Ray Book
에세이

TC39 Signals, 리액티비티의 공용어가 될 수 있을까

React를 제외한 거의 모든 프레임워크가 손을 잡았다. 프레임워크마다 따로 놀던 반응성 시스템을 언어 차원에서 통일하겠다는 제안, TC39 Signals의 이야기.

essayjavascriptsignalstc39reactivityopinion

같은 문제를 각자 풀고 있다

프론트엔드 프레임워크들은 하나같이 비슷한 문제를 풀고 있다. "상태가 바뀌면 화면을 다시 그려라." 이 한 문장이 결국 모든 프레임워크의 존재 이유인데, 풀어내는 방식은 저마다 다르다.

Vue는 ref()를 만들었고, Solid는 createSignal()을 만들었고, Angular는 signal()을 만들었고, Svelte는 아예 컴파일러 문법으로 $state를 만들었다. 이름도 다르고 API도 다르지만, 하는 일은 똑같다. 값을 담고, 그 값이 바뀌면 의존하고 있는 쪽에 알려주는 것. 리액티비티라고 부르는 이 개념은 프레임워크마다 밑바닥부터 새로 구현되어 있다.

문제는 이게 섬이라는 거다. Vue의 ref()로 만든 반응형 데이터는 Solid에서 쓸 수 없다. Angular의 signal()은 Svelte에서 읽을 수 없다. 프레임워크에 종속되지 않는 범용 리액티브 라이브러리를 만들고 싶어도, 어떤 프레임워크의 반응성 위에 올려야 할지 선택해야 한다. 그 순간 나머지 프레임워크는 탈락이다.

그래서 언어가 나서겠다는 거다

2024년 4월, TC39Signals 제안이 올라왔다. 챔피언은 Rob Eisenberg와 Daniel Ehrenberg. 아이디어는 간결하다. 반응성의 기본 단위를 JavaScript 언어 차원에서 제공하자.

API는 이렇게 생겼다.

const counter = new Signal.State(0);
const isEven = new Signal.Computed(() => (counter.get() & 1) === 0);

counter.set(1);
console.log(isEven.get()); // false

Signal.State는 값을 담는 그릇이다. Signal.Computed는 다른 Signal에서 파생되는 계산값이다. counter가 바뀌면 isEven도 다음에 읽힐 때 다시 계산된다. 여기까지는 어디서 많이 본 패턴이다. Vue의 refcomputed, Solid의 createSignalcreateMemo와 본질적으로 같다.

다른 점은, 이게 특정 프레임워크에 속하지 않는다는 거다. 브라우저 엔진에 내장되면, 어떤 프레임워크든 이 위에 자기만의 API를 올릴 수 있다. Vue는 ref() 아래에서 Signal.State를 쓸 수 있고, Solid도 createSignal() 아래에서 같은 걸 쓸 수 있다. 겉모습은 달라도 바닥이 같아지는 거다.

의도적으로 빠진 것들

이 제안에서 눈에 띄는 건, 있는 것보다 없는 것이다.

Effect가 없다. 대부분의 프레임워크에서 "상태가 바뀌면 이 함수를 실행해라"는 effect가 핵심 기능인데, Signals 제안에는 이게 빠져 있다. 대신 Signal.subtle.Watcher라는 저수준 API만 제공한다. 프레임워크가 이 위에 자기만의 스케줄링 전략으로 effect를 구현하라는 뜻이다.

이건 의도된 설계다. Effect의 실행 타이밍을 언제, 어떤 순서로 할지는 프레임워크마다 전략이 다르다. 동기로 즉시 실행할 수도 있고, 마이크로태스크로 묶을 수도 있고, 렌더 사이클에 맞춰 배치할 수도 있다. 이걸 언어 수준에서 하나로 정하면 프레임워크의 자유도를 죽이게 된다.

결국 이 제안은 "리액티비티의 전부"가 아니라 "리액티비티의 바닥"을 깔겠다는 거다. 프레임워크들이 공유할 수 있는 최소한의 공통 기반. 위에 뭘 올릴지는 각자 알아서.

Pull 기반, 게으른 계산

내부 동작도 흥미롭다. Signals는 pull 기반으로 동작한다. 상태가 바뀌었다고 의존하는 모든 계산을 즉시 다시 돌리지 않는다. 누군가 .get()으로 값을 읽을 때 비로소 "아, 의존성이 바뀌었네"하고 재계산한다.

이게 왜 중요하냐면, 불필요한 계산을 아예 안 하게 된다. 화면에 보이지 않는 컴포넌트가 참조하는 Computed Signal은 아무도 읽지 않으니 재계산되지 않는다. 그리고 glitch-free를 보장한다. A가 바뀌고 B가 아직 안 바뀐 어정쩡한 타이밍에 값을 읽어버리는 일이 없다.

그런데 React는 빠졌다

여기가 이 이야기의 가장 흥미로운 지점이다.

Angular, Vue, Solid, Svelte, Preact, Qwik, MobX, Ember, 이 제안의 설계에 참여한 프레임워크 목록이다. 프론트엔드 생태계의 거의 전부가 모였는데, 가장 큰 이름 하나가 빠져 있다. React다.

React가 빠진 건 정치적인 이유가 아니라 철학적인 이유다. React는 애초에 Signal 모델을 쓰지 않는다. React의 세계에서 상태가 바뀌면, 해당 컴포넌트와 그 하위 트리를 통째로 다시 렌더링한다. 어떤 값이 바뀌었는지 세밀하게 추적하는 게 아니라, "일단 다시 그려보고 달라진 부분만 반영하자"는 접근이다.

이 방식의 단점은 명확하다. 불필요한 리렌더가 생긴다. 그래서 useMemo, useCallback, React.memo 같은 최적화 도구가 필요했고, 개발자가 이걸 직접 챙겨야 하는 게 고통이었다.

React의 답은 Signal이 아니라 React Compiler였다. 컴파일러가 코드를 분석해서 메모이제이션을 자동으로 넣어주는 방식이다. 개발자는 최적화를 신경 쓰지 않고 코드를 쓰면 되고, 컴파일러가 알아서 처리한다. 리렌더 모델을 유지하되, 그 비효율을 도구로 해결하겠다는 전략이다.

결국 같은 문제에 대한 두 가지 다른 답이다. Signal 진영은 "변경을 세밀하게 추적해서 필요한 부분만 업데이트하자"이고, React 진영은 "일단 다 다시 그리되 컴파일러가 불필요한 부분을 걸러내자"이다. 어느 쪽이 맞다고 말하기 어렵다. 둘 다 실전에서 검증되고 있는 접근이니까.

Promise의 기시감

이 상황을 보면 떠오르는 역사가 하나 있다. Promise다.

콜백 지옥이 문제라는 건 모두가 동의했지만, jQuery Deferred, Bluebird, Q, RSVP 등 라이브러리마다 자기만의 Promise 구현이 있었다. 서로 호환이 안 됐고, 라이브러리를 바꾸면 비동기 코드를 다 고쳐야 했다.

그때 등장한 게 Promises/A+ 스펙이다. 라이브러리들이 먼저 공통 스펙에 합의했고, 그 합의가 결국 ES2015의 네이티브 Promise로 이어졌다. 지금 누구도 Bluebird를 쓰지 않는다. 언어 자체가 해결했으니까.

Signals 제안의 챔피언들이 명시적으로 이 비유를 들고 있다. 프레임워크들이 먼저 공통 기반에 합의하고, 그 위에서 각자의 API를 유지한다. 그리고 궁극적으로는 언어에 내장된다.

물론 실패한 선례도 있다. Object.observe는 객체 변경을 감지하는 API로 제안되었다가, Proxy가 더 나은 해법으로 판명되면서 철회됐다. Signals도 같은 길을 갈 수 있다. 아직 Stage 1이고, 브라우저에 들어가려면 최소 몇 년은 더 걸린다.

성공하면 뭐가 달라질까

만약 Signals가 표준이 되면, 가장 크게 바뀌는 건 "프레임워크 독립적인 리액티브 코드"가 가능해진다는 거다.

지금은 리액티브 상태 관리 라이브러리를 만들면, React용, Vue용, Solid용을 따로 만들거나 어댑터를 제공해야 한다. Signals가 표준이면, Signal.StateSignal.Computed로 한 번 작성하면 어디서든 동작한다. 프레임워크가 바뀌어도 비즈니스 로직은 그대로 유지된다.

프레임워크 선택이 "전부를 결정하는 선택"에서 "뷰 레이어만 결정하는 선택"으로 바뀔 수 있다. 리액티비티라는 가장 깊은 층이 공유되면, 그 위의 것들은 훨씬 쉽게 교체할 수 있게 된다.

물론 React가 빠진 채로 이게 "표준"이라고 부를 수 있을지는 여전히 열린 질문이다. 프론트엔드 생태계에서 React의 점유율을 무시할 수는 없으니까. 하지만 Angular, Vue, Solid, Svelte가 같은 바닥을 공유한다는 것만으로도 생태계에 의미 있는 변화가 될 수 있다.

아직 갈 길이 멀다. 하지만 방향은 분명하다. 리액티비티는 프레임워크가 각자 풀어야 할 문제가 아니라, 플랫폼이 제공해야 할 기반일 수 있다. 그 시도가 지금 진행되고 있다.