원문: https://preactjs.com/blog/introducing-signals/

시그널은 앱이 복잡해져도 빠른 속도를 유지하도록 하는 상태 표현 방식입니다. 시그널은 반응형 원칙에 기반을 두고 있으며, 가상 돔에 최적화된 독특한 구현을 통해 개발자에게 훌륭한 경험을 제공합니다.

본질적으로 시그널은 특정 값을 가지고 있는 .value 속성을 가진 객체입니다. 컴포넌트 내에서 시그널의 value 속성에 접근하면, 그 시그널의 값이 변경될 때 해당 컴포넌트가 자동으로 업데이트됩니다.

이는 간단하고 작성하기 쉬울 뿐만 아니라, 앱이 얼마나 많은 컴포넌트를 가지고 있든 상태 업데이트가 빠르게 유지되도록 보장합니다. 시그널은 기본적으로 빠르며, 백그라운드에서 자동으로 업데이트를 최적화해줍니다.

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

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

function Counter() {
  return (
    <button onClick={() => count.value++}>
      {count} x 2 = {double}
    </button>
  );
}

REPL에서 실행

시그널은 훅과 달리 컴포넌트 내부 또는 외부에서 사용할 수 있습니다. 또한 시그널은 훅과 클래스 컴포넌트 모두에서 훌륭하게 작동하므로, 기존 지식을 활용하며 자신의 속도에 맞게 시그널을 도입할 수 있습니다. 몇몇 컴포넌트에서 시그널을 시도해보고 점진적으로 채택해보세요.

아 그리고, 저희는 가능한 한 작은 라이브러리를 제공한다는 기본적인 철학에 충실하고 있습니다. Preact에서 시그널을 사용하더라도 번들 크기는 단 1.6kB만 증가합니다.

당장 사용해보고 싶나요? 그렇다면 저희 문서를 읽고 시그널에 대해서 깊이 알아보세요.

시그널은 어떤 문제를 해결했나요?

지난 몇 년 동안, 저희는 소규모 스타트업부터 수백 명의 개발자가 동시에 커밋하는 거대 기업에 이르기까지 다양한 앱 그리고 팀과 협력해 왔습니다. 이 기간 동안, 코어 팀의 모든 구성원은 애플리케이션 상태 관리 방식에 반복되는 문제가 있음을 발견했습니다.

이러한 문제를 해결하기 위한 뛰어난 솔루션들이 만들어졌지만, 최고의 솔루션 조차도 여전히 프레임워크에 수동으로 통합(integration)해야 한다는 단점이 있었습니다. 그 결과, 개발자들은 이러한 솔루션들을 채택하는 데 주저함을 보였고, 대신 프레임워크에서 제공하는 상태 기본 요소들을 사용하여 구축하는 것을 선호했습니다.

우리는 최적의 성능과 개발자 사용성을 결합하여 프레임워크와의 원활한 통합까지 갖추고 있는 매력적인 솔루션을 제공하고자 했습니다.

전역 상태에 대한 고민

애플리케이션 상태는 일반적으로 작고 단순하며, 아마 몇 개의 간단한 useState 훅을 사용하는 것으로 시작됩니다. 앱이 커지고 더 많은 컴포넌트들이 동일한 상태에 접근할 필요가 생기면, 결국 그 상태는 공통의 부모 컴포넌트로 옮겨집니다. 이 패턴은 대부분의 상태가 컴포넌트 트리의 루트에 가깝게 관리될 때까지 여러 번 반복됩니다.

이 시나리오는 상태 무효화(invalidation)의 영향을 받는 전체 트리를 업데이트해야 하기 때문에 기존 가상 돔 기반 프레임워크에 도전 과제를 제시합니다. 본질적으로, 렌더링 성능은 그 트리에 있는 컴포넌트 수에 영향을 받습니다. 프레임 워크가 동일한 객체를 수신하도록 memouseMemo를 사용하여 컴포넌트 트리의 일부를 메모하여 이 문제를 해결할 수 있습니다. 변경된 사항이 없으면, 프레임워크가 트리의 일부 렌더링을 건너뛸 수 있습니다.

이론적으로는 합리적으로 들리지만, 현실은 훨씬 더 혼란스러운 경우가 많습니다. 실제로, 코드베이스가 커짐에 따라 이러한 최적화를 어디에 적용해야 할지 어려워집니다. 종종, 좋은 의도의 메모이제이션조차도 불안정한 의존성 값으로 인해 비효율적인 경우가 있습니다. 훅은 분석할 수 있는 명시적인 의존성 트리를 가지고 있지 않기 때문에, 도구를 사용하여 개발자가 의존성이 불안정한지 진단하는 데 도움을 주지 못합니다.

혼돈의 컨텍스트

상태 공유를 위해 팀들이 자주 사용하는 또 다른 일반적인 해결책은 상태를 컨텍스트에 넣는 것입니다. 이 방법은 컨텍스트 제공자(context provider)와 소비자(consumers) 사이의 컴포넌트들에 대한 렌더링을 건너뛰는 것을 가능하게 함으로써 불필요한 렌더링을 방지할 수 있습니다. 하지만 문제가 있습니다. 컨텍스트 제공자에게 전달된 값만 업데이트할 수 있고, 전체적으로만 업데이트 할 수 있습니다. 컨텍스트를 통해 노출된 객체의 속성을 업데이트해도 그 컨텍스트의 소비자들은 업데이트되지 않습니다. 즉, 세밀한 업데이트가 불가능합니다. 이를 처리하기 위해 사용할 수 있는 옵션들은 상태를 여러 컨텍스트로 나누거나, 그 속성 중 하나라도 변경될 때마다 컨텍스트 객체를 복제하여 과도하게 무효화하는 것입니다.

컨텍스트로 값을 이동시키는 것이 처음에는 유리한 선택처럼 보일 수 있지만, 단지 값을 공유하기 위해 컴포넌트 트리의 크기를 증가시키는 것은 결국 문제가 됩니다. 비즈니스 로직은 필연적으로 여러 컨텍스트 값에 따라 달라지며, 이로 인해 트리의 특정 위치에 로직이 구현되도록 강제할 수 있습니다. 트리 중간에 컨텍스트를 구독하는 컴포넌트를 추가하면 컨텍스트를 업데이트할 때 건너뛸 수 있는 컴포넌트의 수가 줄어들기 때문에 비용이 많이 듭니다. 게다가 구독자 아래에 있는 모든 컴포넌트는 이제 다시 렌더링되어야 합니다. 이 문제에 대한 유일한 해결책은 메모이제이션을 열심히 적용하는 것인데, 이는 메모이제이션의 본질적인 문제가 우리를 다시 원점으로 돌려 놓습니다.

더 나은 상태 관리 방법에 대한 연구

우리는 차세대 상태 기본 요소(primitive)를 찾기 위해 다시 처음으로 돌아갔습니다. 우리는 현재 솔루션들의 문제점을 동시에 해결할 수 있는 무언가를 만들고 싶었습니다. 수동 프레임워크 통합, 메모이제이션에 대한 과도한 의존, 최적화 되지 않은 컨텍스트의 사용, 프로그래밍적으로 관찰하기에 어렵다는 점이 문제로 느껴졌습니다.

개발자들은 이러한 전략(메모이제이션, 컨텍스트 최적화 등)을 통해 성능을 향상시키기 위해 추가적인 노력을 기울여야 합니다. 만약 우리가 이 접근법을 전환하여 기본적으로 빠른 시스템을 제공할 수 있다면 어떨까요? 성능 최적화가 기본적으로 내장된 시스템을 만들 수 있다면요?

이러한 질문에 대한 답은 시그널 입니다. 앱 전반에 걸쳐 메모이제이션이나 트릭을 요구하지 않는 기본적으로 빠른 시스템 입니다. 시그널은 상태가 전역적이거나, props나 컨텍스트를 통해 전달되거나, 컴포넌트 내부에 국한되었는지와 상관없이 세밀한 상태 업데이트의 이점을 제공합니다.

미래를 향한 시그널

시그널의 주요 아이디어는 컴포넌트 트리를 통해 값을 직접 전달하는 대신, 값이 포함된 시그널 객체(ref와 유사)를 전달하는 것입니다. 시그널의 값이 변경되어도 시그널 자체는 그대로 유지됩니다. 결과적으로 컴포넌트는 시그널의 값이 아니라 시그널을 보고 있기 때문에 구독한 컴포넌트들을 다시 렌더링하지 않고도 시그널을 업데이트할 수 있습니다. 이를 통해 컴포넌트들을 렌더링하는 비용이 많이 드는 작업을 모두 건너뛰고 실제로 시그널의 값에 접근하는 컴포넌트로 바로 이동할 수 있습니다.

우리는 애플리케이션의 상태 그래프가 일반적으로 컴포넌트 트리보다 훨신 간단하다는 사실을 활용하고 있습니다. 상태 그래프를 업데이트하는 데 필요한 작업이 컴포넌트 트리를 업데이트하는 것보다 훨씬 적기 때문에 렌더링이 더 빨라집니다. 이 차이는 브라우저에서 측정할 때 가장 명확하게 드러납니다. 아래 스크린샷은 동일한 앱에 대해 두 번 측정된 DevTools 프로파일 추적을 보여줍니다. 한 번은 상태 기본 요소로 훅을 사용하여 측정하고 다른 한 번은 시그널을 사용하였습니다.

시그널 버전은 전통적인 가상 돔 기반 프레임워크의 업데이트 메커니즘보다 성능이 훨씬 뛰어납니다. 우리가 테스트한 일부 앱에서는 시그널이 너무 빨라 *플레임그래프(flamegraph)에서 찾기가 어려울 정도입니다.

시그널은 성능에 관한 관점을 완전히 뒤집어 놓습니다. 메모이제이션 또는 셀렉터를 통한 성능 최적화 대신, 시그널은 기본적으로 빠릅니다. 시그널을 사용하면, 성능 최적화가 기본적으로 활성화되어 있으며 최적화를 하지 않고자 할 때는 별도의 조치(시그널을 사용하지 않음)를 취해야 합니다.

이러한 수준의 성능을 내기 위해, 시그널은 다음과 같은 핵심 원칙들을 바탕으로 구축되었습니다.

  • 기본적으로 지연 처리: 현재 어딘가에서 사용되는 시그널만 관찰되고 업데이트되며, 연결되지 않은 시그널은 성능에 영향을 미치지 않습니다.
  • 최적의 업데이트: 시그널의 값이 변하지 않았다면, 해당 시그널의 값을 사용하는 컴포넌트와 effect는 시그널의 의존성이 변경되었더라도 업데이트되지 않습니다.
  • 최적의 의존성 추적: 프레임워크는 모든 것이 의존하는 시그널을 대신 추적해줍니다. 훅과 같은 의존성 배열이 필요하지 않습니다.
  • 직접 접근: 컴포넌트에서 시그널의 값을 접근하는 것만으로도 셀렉터나 훅 없이 자동으로 업데이트를 구독하게 됩니다.

이러한 원칙들은 시그널을 광범위한 사용 사례, 심지어 UI 렌더링과 관련없는 시나리오에도 적합하게 만듭니다.

* 역자주: Flamegraph는 소프트웨어 성능 분석 도구 중 하나로, 프로그램의 실행 시간 동안 발생하는 다양한 함수 호출과 그 실행 시간을 시각화하는 그래프입니다.

Preact에 시그널 가져오기

올바른 상태 기본 요소를 확인한 후, 우리는 그것을 Preact에 연결하기 시작했습니다. 우리가 항상 훅을 좋아하는 이유는 컴포넌트 내에서 직접 사용할 수 있다는 점입니다. 이는 “셀렉터” 함수에 의존하거나 컴포넌트를 특별한 함수로 감싸서 상태 업데이트를 구독하는 것과 같은 써드 파티 상태 관리 솔루션들에 비해 사용성이 우수하다는 장점이 있습니다.

// 셀렉터 기반 구독 :(
function Counter() {
  const value = useSelector(state => state.count);
  // ...
}

// 래퍼 함수 기반 구독 :(
const counterState = new Counter();

const Counter = observe(props => {
  const value = counterState.count;
  // ...
});

두 가지 접근 방식 모두 우리에게 만족스럽지 않았습니다. 셀렉터 접근 방식은 모든 상태 접근을 셀렉터로 감싸야 하며, 이는 복잡하거나 중첩된 상태에서는 번거로워집니다. 컴포넌트를 함수로 감싸는 방식은 컴포넌트를 수동으로 감싸야 하는 노력이 필요하며, 이는 컴포넌트 이름과 정적 프로퍼티가 누락되는 등의 여러 문제를 가져옵니다.

지난 몇 년 동안 우리는 많은 개발자들과 긴밀하게 협력할 기회를 가졌습니다. 특히 (P)react를 처음 접하는 사람들의 일반적인 어려움 중 하나는 셀렉터나 래퍼와 같은 개념들이 추가적인 패러다임으로 여겨져, 각 상태 관리 솔루션의 생산성을 느껴보기 전에 이러한 개념들을 먼저 배워야 하는 부담이 있습니다.

이상적으로는 셀렉터나 래퍼 함수에 대해 알 필요 없으며 단순히 컴포넌트 내에서 직접 상태에 접근할 수 있습니다.

// 이것이 전역 상태이고 전체 앱에서 이에 접근해야 하는 상황을 상상해보세요.
let count = 0;

function Counter() {
 return (
   <button onClick={() => count++}>
     value: {count}
   </button>
 );
}

코드는 명확하고 이해하기 쉽지만, 불행히도 작동하지 않습니다. 버튼을 클릭할 때 컴포넌트가 업데이트되지 않는데, 이는 count가 변경되었다는 것을 알 수 있는 방법이 없기 때문입니다.

하지만 우리는 이 시나리오를 머릿속에서 지울 수 없었습니다. 이렇게 명확한 모델을 현실로 만들기 위해 우리는 무엇을 할 수 있을까요? 우리는 Preact의 pluggable 렌더러를 사용하여 다양한 아이디어와 구현 방법을 프로토타이핑 해보기 시작했습니다. 시간이 걸렸지만, 결국 그것을 실현시킬 수 있는 방법을 찾아냈습니다.

// 이것이 전역 상태이고 전체 앱에서 이에 접근해야 하는 상황을 상상해보세요.
const count = signal(0);

function Counter() {
 return (
   <button onClick={() => count.value++}>
     Value: {count.value}
   </button>
 );
}

REPL에서 실행

셀렉터도, 래퍼 함수도, 아무 것도 없습니다. 시그널의 값에 접근하는 것만으로도 컴포넌트가 해당 시그널의 값이 변경될 때 업데이트해야 한다는 것을 알 수 있습니다. 몇몇 앱에서 프로토타입을 테스트해본 후, 우리는 무언가를 해냈다는 것이 분명해졌습니다. 이런 방식으로 코드를 작성하는 것은 직관적이었고, 최적의 상태로 작동시키기 위해 복잡한 생각을 할 필요가 없었습니다.

더 빠르게 할 수 있을까요?

여기서 멈출 수도 있었고, 시그널을 그대로 출시할 수도 있었지만, 우리는 Preact 팀입니다. 우리는 Preact 통합을 얼마나 더 밀어붙일 수 있는지 보고 싶었습니다. 위의 카운터 예제에서, count의 값은 텍스트를 표시하는 데만 사용되는데, 이것이 전체 컴포넌트를 다시 렌더링할 필요는 정말 없어야 합니다. 시그널의 값이 변경될 때 자동으로 컴포넌트를 리렌더링하는 대신, 텍스트만 다시 렌더링하는 것은 어떨까요? 더 나아가, 가상 돔을 완전히 우회하여 텍스트를 돔에서 직접 업데이트한다면 어떨까요?

const count = signal(0);

// 아래처럼 하지 않고
<p>Value: {count.value}</p>

// … 우리는 시그널을 직접 JSX에 전달할 수 있습니다.
<p>Value: {count}</p>

// … 또는 돔 속성으로도 전달할 수 있습니다.
<input value={count} onInput={...} />

그래서, 네. 그렇게 했습니다. 문자열을 일반적으로 사용하는 어떤 곳이든 JSX에 직접 시그널을 전달할 수 있습니다. 시그널의 값은 텍스트로 렌더링되며, 시그널이 변경될 때 자동으로 스스로를 업데이트합니다. 이는 props에도 적용됩니다

다음 스텝

궁금하고 바로 시작해보시려면, 시그널 문서를 확인해보세요. 여러분이 어떻게 사용할지 듣고 싶습니다.

시그널로 전환하는 것에 서두를 필요는 없다는 것을 기억하세요. 훅은 계속 지원될 것이며, 시그널과 함께 사용해도 훌륭하게 작동합니다! 개념에 익숙해지기 위해 몇 개의 컴포넌트로 시작하여 시그널을 점진적으로 시도해보는 것을 추천합니다.