(번역) 시그널(Signals)

Jung Han
21 min readDec 5, 2023

--

원문: https://preactjs.com/guide/v10/signals

시그널은 앱 상태를 관리하기 위한 반응형 상태 기본 요소(primitive)입니다.

시그널의 가장 큰 차별점은 컴포넌트와 UI의 상태 변경을 가능한 가장 효율적인 방식으로 자동으로 업데이트한다는 점입니다. 자동 상태 바인딩 및 종속성 추적 기능을 통해 시그널은 탁월한 사용자 경험 및 생산성을 제공하고 가장 일반적인 상태 관리 함정들을 제거합니다.

시그널은 모든 규모의 애플리케이션에서 효과적이며, 작은 앱의 개발 속도를 높이는 편의성을 제공하면서 모든 규모의 앱이 기본적으로 빠르게 실행되도록 성능 특성을 보장합니다.

소개

자바스크립트에서 상태 관리의 가장 큰 어려움은 값을 직접 관찰할 수(observable) 없기 때문에 주어진 값의 변경에 반응하는 것입니다. 일반적인 해결책으로 값을 변수에 저장하고 변경 여부를 지속해서 확인하는 방식으로 처리하는데, 이는 번거롭고 성능 면에서 적합하지 않습니다. 이상적으로 구현한다면 값이 언제 변경되었는지 알려주는 값을 표현하는 방법이 필요합니다. 이게 바로 시그널이 하는 일입니다.

시그널의 핵심은 값을 보유하는 .value를 가진 객체입니다. 여기는 중요한 특징이 있는데요. 바로 시그널의 값은 변경될 수 있지만, 시그널 자체는 항상 동일하게 유지된다는 점입니다.

import { signal } from "@preact/signals";

const count = signal(0);
// .value를 통해 Signal value를 읽고 있습니다.
console.log(count.value); // 0
// Signal value 업데이트
count.value += 1;
// Signal value가 변경되었습니다.
console.log(count.value); // 1

Preact는 시그널이 프로퍼티나 컨텍스트를 통해 트리 아래로 전달될 때 시그널의 참조만 전달합니다. 컴포넌트는 시그널의 값이 아니라 시그널 자체를 보기 때문에 컴포넌트를 다시 렌더링하지 않고 시그널을 업데이트할 수 있습니다. 따라서 값비싼 렌더링 작업을 모두 건너뛰고 트리에서 시그널의 .value 프로퍼티에 실제로 접근하는 컴포넌트로 바로 들어갈 수 있습니다.

시그널의 두 번째로 중요한 특징으로는 값이 언제 접근되고 언제 업데이트되는지를 추적한다는 점입니다. Preact에서는 컴포넌트 내에서 시그널의 .value 프로퍼티에 접근하면 해당 시그널의 값이 변경될 때 컴포넌트가 자동으로 리렌더링 됩니다.

import { signal } from "@preact/signals";

// 구독할 수 있는 시그널을 생성
const count = signal(0);
function Counter() {
// .value로 접근하는 모든 컴포넌트는 이 값이 변경될 때 자동으로 리렌더링
const value = count.value;
const increment = () => {
// .value에 접근해 시그널 값을 업데이트 함
count.value++;
};
return (
<div>
<p>Count: {value}</p>
<button onClick={increment}>click me</button>
</div>
);
}

마지막으로 시그널은 최상의 성능과 인체공학적 장점을 제공하기 위해 Preact와 긴밀하게 통합되어 있습니다.

위 예시에서는 count 시그널의 현재 값을 알기 위해 count.value에 접근했지만, 이는 사실 불필요합니다. 대신 JSX에서 count 시그널을 직접 사용해 Preact가 모든 작업을 수행하도록 할 수 있습니다.

import { signal } from "@preact/signals";

const count = signal(0);
function Counter() {
return (
<div>
<p>Count: {count}</p>
<button onClick={() => count.value++}>click me</button>
</div>
);
}

설치

시그널은 @preact/signals 패키지를 프로젝트에 추가하면 사용할 수 있습니다.

npm install @preact/signals

사용하고 있는 패키지 매니저를 통해 설치하면 사용할 준비가 완료됩니다.

사용 예제

실제 사례를 통해 시그널을 사용해 봅시다. 투두 앱에서 항목을 추가하고 제거 할 수 있는 앱을 만들어 보겠습니다. 먼저 상태를 모델링하는 것으로 시작해 보죠. 할 일을 담고 있는 시그널이 필요하며, 이 시그널은 Array 타입일 수 있습니다.

import { signal } from "@preact/signals";

const todos = signal([
{ text: "Buy groceries" },
{ text: "Walk the dog" },
]);

유저가 새로운 할 일 아이템을 입력하려면, <input>요소에 연결할 시그널 하나가 더 필요합니다. 지금은 이미 이 시그널을 사용해 목록에 할 일 항목을 추가하는 함수를 만들었습니다. .value 프로퍼티에 할당해 시그널의 값을 업데이트할 수 있다는 점을 기억하세요.

// input에 사용할 예정입니다.
const text = signal("");

function addTodo() {
todos.value = [...todos.value, { text: text.value }];
text.value = ""; // 추가 시 인풋 값을 제거할 것입니다.
}

💡 Tip: 시그널은 새 값을 할당하는 경우에만 업데이트됩니다. 시그널에 할당하는 값이 현재 값과 같으면 시그널은 업데이트되지 않습니다.

const count = signal(0);

count.value = 0; // 값이 같아 업데이트 하지 않음

count.value = 1; // 값이 다르므로 업데이트

지금까지의 로직이 올바른지 확인해 보죠. text 시그널을 업데이트하고 addTodo()를 호출하면 todos 시그널에 새 항목이 추가되는 것을 볼 수 있습니다. 사용자 인터페이스 없이 이런 함수를 직접 호출해 이런 시나리오를 시뮬레이션할 수 있습니다!

import { signal } from "@preact/signals";

const todos = signal([
{ text: "Buy groceries" },
{ text: "Walk the dog" },
]);

const text = signal("");

function addTodo() {
todos.value = [...todos.value, { text: text.value }];
text.value = ""; // 추가 시 인풋 값을 제거할 것입니다.
}

// 우리 로직이 제대로 동작하는지 확인합니다.
console.log(todos.value);
// Logs: [{text: "Buy groceries"}, {text: "Walk the dog"}]

// 새로운 할 일을 추가한다고 시뮬레이션합니다.
text.value = "Tidy up";
addTodo();

// 새로운 항목이 추가되는 것과 `text` 시그널이 초기화 되는지 확인합니다.
console.log(todos.value);
// Logs: [{text: "Buy groceries"}, {text: "Walk the dog"}, {text: "Tidy up"}]

console.log(text.value); // Logs: ""

마지막으로 추가하고 싶은 기능은 목록에서 항목을 제거하는 기능입니다. 이를 위해 todos 배열에서 지정된 항목을 삭제하는 함수를 추가하겠습니다.

function removeTodo(todo) {
todos.value = todos.value.filter(t => t !== todo);
}

UI 개발

앱의 상태를 모델링했으니 이제 사용자가 상호작용할 수 있는 멋진 UI를 연결해보겠습니다.

function TodoList() {
const onInput = event => (text.value = event.target.value);

return (
<>
<input value={text.value} onInput={onInput} />
<button onClick={addTodo}>Add</button>
<ul>
{todos.value.map(todo => (
<li>
{todo.text}{' '}
<button onClick={() => removeTodo(todo)}>❌</button>
</li>
))}
</ul>
</>
);
}

이제 완벽하게 작동하는 투두 앱이 완성되었습니다. 전체 앱은 여기에서 사용해 볼 수 있습니다 🎉.

computed 시그널을 통한 파생(Deriving) 상태 다루기

투두 앱에 기능을 하나 더 추가해 보겠습니다. 각 항목을 완료한 것으로 체크하면 사용자에게 완료된 항목의 수를 표시하도록 하겠습니다. 이를 위해 다른 시그널의 값을 기반으로 새 시그널을 생성하는 computed(fn)함수를 사용하겠습니다. 반환되는 계산된 시그널은 읽기 전용이며 콜백 함수 내에서 접근한 시그널이 변경되면 해당 값이 자동으로 업데이트됩니다.

import { signal, computed } from "@preact/signals";

const todos = signal([
{ text: "Buy groceries", completed: true },
{ text: "Walk the dog", completed: false },
]);

// 다른 시그널을 기반으로 계산되는 시그널 생성
const completed = computed(() => {
// `todos`가 변경되면 자동으로 재실행
return todos.value.filter(todo => todo.completed).length;
});

// Logs: 1, 한 개의 할 일 항목만이 완료되었기 때문
console.log(completed.value);

간단한 투두 앱에는 많은 계산 시그널이 필요하지 않지만, 복잡한 앱은 여러 곳에서 상태 중복을 피하고자 computed()에 의존하는 경향이 있습니다.

💡 Tip: 가능한 많은 상태를 파생하면 상태에 단일 진실 공급원(single source of truth)을 확보할 수 있습니다. 이는 시그널의 핵심 원칙 중 하나입니다. 이렇게 하면 나중에 앱 로직에 결함이 있는 경우 걱정할 부분이 줄어들기 때문에 디버깅이 훨씬 쉬워집니다.

글로벌 앱 상태 관리

지금까지는 컴포넌트 트리 외부에서만 시그널을 생성했습니다. 이는 투두 앱과 같은 작은 앱의 경우 괜찮지만, 더 크고 복잡한 앱의 경우 테스트가 어려워질 수 있습니다. 테스트 시 일반적으로 앱 상태의 값을 변경하여 특정 시나리오를 재현한 다음 해당 상태를 컴포넌트에 전달하고 렌더링 된 HTML을 대상으로 단언합니다. 이를 위해 할 일 상태를 함수로 추출할 수 있습니다.

function createAppState() {
const todos = signal([]);

const completed = computed(() => {
return todos.value.filter(todo => todo.completed).length
});
return { todos, completed }
}

💡 Tip: 여기는 addTodo()removeTodo(todo) 함수를 의식적으로 포함하지 않았습니다. 데이터를 수정하는 함수와 데이터를 분리하면 앱 아키텍처를 단순화하는 데 도움이 됩니다. 자세한 내용은 데이터 지향 설계를 참조하세요.

이제 렌더링할 때 투두 앱 상태를 프로퍼티로 전달할 수 있습니다.

const state = createAppState();

// ...
<TodoList state={state} />

상태는 전역이기 때문에 투두 앱에서 작동하지만, 규모가 큰 앱은 일반적으로 동일한 상태에 접근하는 여러 컴포넌트로 구성됩니다. 여기는 일반적으로 공통의 공유 조상 컴포넌트를 상태로 ‘끌어올리는(lifting)’하는 작업이 포함됩니다. 프로퍼티를 통해 각 컴포넌트에 수동으로 상태를 전달하지 않으려면 트리의 모든 컴포넌트가 접근할 수 있도록 컨텍스트에 상태를 배치할 수 있습니다. 다음은 일반적으로 어떻게 작성하는지 보여주는 간단한 예시입니다.

import { createContext } from "preact";
import { useContext } from "preact/hooks";
import { createAppState } from "./my-app-state";

const AppState = createContext();

render(
<AppState.Provider value={createAppState()}>
<App />
</AppState.Provider>
);

// ... 후에 앱 상태에 접근할 필요가 생길 때
function App() {
const state = useContext(AppState);
return <p>{state.completed}</p>;
}

컨텍스트의 작동 방식에 대해 궁금하다면 문서를 참조하세요.

시그널을 통한 로컬 상태 관리

대부분의 앱 상태는 프로퍼티와 컨텍스트를 사용해 전달됩니다. 하지만 컴포넌트 자체 고유한 내부 상태를 갖는 시나리오도 많이 있습니다. 이런 상태는 앱의 비즈니스 로직의 일부로 존재할 이유가 없으므로 컴포넌트 내부에 국한되어야 합니다. 이런 시나리오에서는 직접 useSignal()useComputed() 훅을 사용해 컴포넌트 내에서 계산된 시그널뿐만 아니라 시그널을 생성할 수 있습니다.

import { useSignal, useComputed } from "@preact/signals";

function Counter() {
const count = useSignal(0);
const double = useComputed(() => count.value * 2);

return (
<div>
<p>{count} x 2 = {double}</p>
<button onClick={() => count.value++}>click me</button>
</div>
);
}

이 두 훅은 컴포넌트가 처음 실행될 때 시그널을 생성하고 이후 렌더링에서 동일한 시그널을 사용하는 signal()computed()를 래핑한 결과입니다.

💡 실제로는 이렇게 구현이 되어 있습니다.

function useSignal(value) {
return useMemo(() => signal(value), []);
}

시그널 사용 심화

지금까지 다룬 주제는 시작을 위해 필요한 모든 내용입니다. 이제 앱 상태를 전적으로 시그널로 모델링해 더 많은 이점을 얻고자 하는 독자를 위한 이야기를 해보겠습니다.

컴포넌트 외부에서 시그널에 반응하기

컴포넌트 트리 외부에서 시그널로 작업할 때, 값을 명시적으로 읽지 않는 한 계산된 시그널이 다시 계산되지 않는 것을 보셨을 겁니다. 이는 시그널이 기본적으로 지연되도록(lazy) 구현되었기 때문에 값이 접근될 때만 새 값을 계산합니다.

const count = signal(0);
const double = computed(() => count.value * 2);

// `double` 시그널에 의존하는 `count` 시그널을 업데이트 하더라도,
// `double`은 해당 값을 사용하지 않기 때문에 업데이트 되지 않습니다.
count.value = 1;

// `double`의 값을 읽어야만 재계산됩니다.
console.log(double.value); // Logs: 2

컴포넌트 트리 외부에서 시그널을 구독하려면 어떻게 해야 할까요? 시그널값이 변경될 때마다 콘솔에 무언가를 기록하거나 LocalStorage에 상태를 기록해 지속하고 싶을 수 있습니다.

시그널 변경에 대한 응답으로 임의의 코드를 실행하려면 effect(fn)을 사용하면 됩니다. 계산된 시그널과 마찬가지로 effect는 어떤 시그널에 접근했는지를 추적하고 해당 시그널이 변경되면 해당 콜백을 다시 실행합니다. 계산된 시그널과 달리 effect()는 시그널을 반환하지 않고 변경 시퀀스의 종료 함수를 반환합니다.

import { signal, computed, effect } from "@preact/signals-core";

const name = signal("Jane");
const surname = signal("Doe");
const fullName = computed(() => `${name.value} ${surname.value}`);

// name이 변경될 때마다 로깅
effect(() => console.log(fullName.value));
// Logs: "Jane Doe"

// `name`이 업데이트 되면 `fullName`이 업데이트 되어 effect가 다시 로깅 됩니다.
name.value = "John";
// Logs: "John Doe"

반환된 함수를 호출해 effect를 파괴하고 접근한 모든 시그널 구독을 취소할 수 있습니다.

import { signal, effect } from "@preact/signals-core";

const name = signal("Jane");
const surname = signal("Doe");
const fullName = computed(() => name.value + " " + surname.value);
const dispose = effect(() => console.log(fullName.value));
// Logs: "Jane Doe"

// effect와 구독을 폐기
dispose();

// `name`을 업데이트해도 이미 폐기했기 때문에 effect는 실행되지 않습니다.
// 또한, 아무것도 구독하지 않으므로 `fullName`을 다시 계산하지 않습니다.
name.value = "John";

💡 Tip: effect를 광범위하게 사용하는 경우 폐기하는 것을 잊지 마세요. 그렇지 않으면 앱이 필요 이상으로 많은 메모리를 소비합니다.

구독하지 않고 시그널 읽기

드물게 effect(fn) 내부의 시그널을 써야 하지만 해당 시그널이 변경될 때 effect가 다시 실행되는 것을 원하지 않는 경우, .peek()를 사용해 구독하지 않고 시그널의 현재 값을 가져올 수 있습니다.

const delta = signal(0);
const count = signal(0);

effect(() => {
// `count`를 구독하지 않고 `count`를 업데이트
count.value = count.peek() + delta.value;
});

// `delta`를 세팅하면 effect가 다시 실행됩니다.
delta.value = 1;

// `.value`에 접근하지 않았기 때문에 effect는 다시 실행되지 않습니다.
count.value = 10;

💡 Tip: 시그널을 구독하지 않으려는 시나리오는 드뭅니다. 대부분의 경우 effect가 모든 시그널을 구독하기를 원합니다. 꼭 필요한 경우에만 peek()을 사용하세요.

여러 업데이트를 하나로 결합하기

앞서 투두 앱에서 사용했던 addTodo()함수를 기억하시나요? 이 함수가 어떻게 생겼는지 다시 함께 살펴보죠.

const todos = signal([]);
const text = signal("");

function addTodo() {
todos.value = [...todos.value, { text: text.value }];
text.value = "";
}

이 함수는 todo.value를 설정할 때와 text 값을 설정할 때 두 개의 개별 업데이트를 발생시킵니다. 이는 때때로 바람직하지 않을 수 있으며 성능이나 기타 이유로 두 업데이트를 하나로 결합해야 할 수 있습니다. batch(fn)함수는 콜백이 끝날 때 여러 값 업데이트를 하나의 "커밋"으로 결합해 사용할 수 있습니다.

function addTodo() {
batch(() => {
todos.value = [...todos.value, { text: text.value }];
text.value = "";
});
}

batch 내에서 수정된 시그널에 접근하면 업데이트된 값이 반영됩니다. batch 내에서 다른 시그널에 의해 무효가 되어(invalidated) 계산된 시그널에 접근하면 최신 값을 반환하기 위해 필요한 종속성만 다시 계산합니다. 무효화 된 다른 시그널은 영향받지 않으며 batch 콜백이 끝날 때만 업데이트됩니다.

import { signal, computed, effect, batch } from "@preact/signals-core";

const count = signal(0);
const double = computed(() => count.value * 2);
const triple = computed(() => count.value * 3);

effect(() => console.log(double.value, triple.value));

batch(() => {
// `count`를 지정하면, `double`과 `triple`은 무효화됩니다.
count.value = 1;
// batch 호출 되었음에도, `double`은 새로운 계산 값을 반영합니다.
// 그러나, `triple`은 콜백이 완료된 후에만 업데이트 됩니다.
console.log(double.value); // Logs: 2
});

💡 Tip: batch가 중첩될 수 있는데, 이 경우 가장 바깥쪽 batch 콜백이 마무리 된 뒤에만 업데이트가 발동됩니다.

렌더링 최적화

시그널을 사용하면 가상 DOM 렌더링을 우회하고 시그널 변경을 DOM 변경에 직접 바인딩 할 수 있습니다. 텍스트 위치에서 JSX에 시그널을 전달하면 가상 DOM을 변경하지 않고 텍스트로 렌더링되고 제자리에서 자동으로 업데이트 됩니다.

const count = signal(0);

function Unoptimized() {
// `count`가 변경될 때 컴포넌트 리렌더링
return <p>{count.value}</p>;
}

function Optimized() {
// 컴포넌트 리렌더링 없이 텍스트만 자동으로 업데이트
return <p>{count}</p>;
}

이 최적화를 활성화하려면 .value 프로퍼티에 접근하는 대신 시그널을 JSX에 명시해야합니다.

비슷한 렌더링 최적화로 DOM요소에 프로퍼티로 시그널을 전달할 때도 지원됩니다.

API

이 섹션은 시그널 API 개요입니다. 이미 시그널 사용법을 알고 있으시겠지만, 기능을 다시 한 번 상기시켜드리기 위해 작성해봤습니다.

signal(initialValue)

주어진 인자를 초기값으로 사용해 새 시그널을 생성합니다.

const count = signal(0);

컴포넌트 내에서 시그널을 생성할 때는 useSignal(initialValue) 훅을 사용합니다.

반환된 시그널에 값을 읽고 쓰도록 할 수 있는 .value프로퍼티가 있습니다. 시그널을 구독하지 않고 시그널에서 읽으려면 signal.peek()을 사용합니다.

computed(fn)

다른 시그널 값을 기반으로 계산된 새 시그널을 생성합니다. 반환된 시그널은 읽기 전용이며 콜백함수 내에서 접근한 시그널이 변경되면 해당 값이 자동으로 업데이트됩니다.

const name = signal("Jane");
const surname = signal("Doe");

const fullName = computed(() => `${name.value} ${surname.value}`);

컴포넌트 내에서 계산된 시그널을 생성할 때는 useComputed(fn) 훅을 사용합니다.

effect(fn)

시그널 변경에 반응해 임의의 코드를 실행할 때는 effect(fn)을 사용합니다. 계산된 시그널과 마찬가지로 effect는 어던 시그널에 접근했는지 추적하고 해당 시그널이 변경되면 해당 콜백을 실행합니다. 계산된 시그널과 달리 effect()는 시그널을 반환하지 않고 변경 시퀀스를 종료시키는 함수를 반환합니다.

const name = signal("Jane");

// `name`이 변경할 때 로깅
effect(() => console.log('Hello', name.value));
// Logs: "Hello Jane"
name.value = "John";
// Logs: "Hello John"

컴포넌트 내에서 시그널 변경에 반응할 때는 useSignalEffect(fn)을 사용합니다.

batch(fn)

batch(fn) 함수는 제공된 콜백이 끝날 때 여러 값의 업데이트를 하나의 "커밋"으로 결합할 수 있습니다. batch는 중첩될 수 있으며 변경 사항은 가장 바깥쪽 콜백이 완료된 후에 발동됩니다. batch 내에서 수정된 시그널에 접근하면 업데이트된 값이 반영됩니다.

const name = signal("Jane");
const surname = signal("Doe");

// 하나의 업데이트로 묶기
batch(() => {
name.value = "John";
surname.value = "Smith";
});

--

--