React에서 가장 불평이 많은 두 설계 선택인 지연된 상태 커밋과 Effect 의존성 배열이 임의가 아니라, 비동기 UI 모델이 반드시 마주치는 제약의 신호였던 이유를 살펴본다.
개발자들은 특정 React API를 싫어하는 데 주저함이 없었습니다. 어색하고, 제한적이며, 그냥 직관에 반한다고 느끼죠. 하지만 현실은, React에서 가장 많이 불평을 듣는 두 가지 설계 선택은 전혀 임의적인 것이 아니었습니다. 그것들은 모든 UI 모델이 결국 마주치게 되는 더 깊은 제약의 초기 신호였습니다.
많은 분들이 아시듯, 저는 지난 몇 년 동안 Solid 2.0을 작업해 왔습니다. 여정이었죠. 저는 10년 넘게 Signals를 써 왔고, 전체 설계 공간을 이해한다고 생각했습니다. 하지만 더 깊이 들어갈수록, 예상치 못한 영역에 들어와 있다는 걸 점점 더 많이 깨달았습니다.
그리고 그 과정 어딘가에서, 불편한 사실 하나를 깨달았습니다. 사람들을 정말 미치게 만드는 그 설계 결정들에 대해 React가 옳았다는 것입니다. React의 모델 전체가 옳다는 말은 아닙니다 — 저는 그걸 변호하러 온 게 아닙니다. 하지만 React는, 나머지 생태계( Solid 1.x 포함)가 대충 넘어가 버린 두 가지 불변조건을 정확히 집어냈습니다.
제가 말하는 건 지연된 상태 커밋입니다:
const [state, setState] = useState(1);
// later
setState(2);
state === 1; //not committed yet
그리고 Effect의 의존성 배열입니다:
useEffect(() => console.log(state), [state]);
이 둘은 Signals가 “고치기로” 했던 것들입니다. 그리고 어떤 의미에서는 실제로 고쳤습니다. 하지만 사람들이 생각하는 방식으로는 아닙니다. 오늘은 왜 그게 이야기의 전부가 아닌지 살펴보겠습니다.
웹에서 우리가 하는 모든 일은 비동기성을 기반으로 만들어져 있습니다. 플랫폼 전체가 네트워크 경계로 분리된 클라이언트와 서버로 정의됩니다. 스트리밍, 데이터 패칭, 분산 업데이트, 트랜잭션 변이, 낙관적 UI — 이 모든 것은 그 단순한 진실에서 갈라져 나옵니다.
비동기는 우리를 명령형의 안락지대 밖으로 밀어냅니다. 명령형 코드는 쓰기에 관한 것입니다: “이걸 설정하고, 그다음 다시 읽어라.” 비동기는 읽기에 관한 것입니다: “이 값이 사용 가능한가, 오래된(stale) 것인가, 아니면 아직 진행 중인가?” 이는 모든 UI가 무엇이든 렌더링하기 전에 반드시 답해야 하는 질문입니다: 이걸 보여도 되는가, 아니면 일관성 없는 것을 드러내게 되는가?
대부분의 프레임워크에서 비동기는 동기적인 선언형 세계 안팎으로 들락날락하는 덧없는 상태처럼 보입니다. 비동기가 우리의 계산과 교차하는 순간만 보이기 때문에 예측 불가능하게 느껴집니다. 하지만 비동기는 혼돈이 아닙니다 — 그저 시간입니다. 그리고 그것을 추론하고 싶다면, 언어가 그것을 직접 표현할 수 있어야 합니다.
이는 상태를 표현하는 방식에서부터 시작됩니다. 어떤 값이 아직 사용 가능하지 않다면, 그것을 안전하게 대체할 수 있는 플레이스홀더는 존재하지 않습니다. null, undefined, 혹은 래퍼를 반환하면 결정성이 깨집니다. 어쨌든 계속 진행해 버리면, 실제 어떤 시점에도 대응하지 않는 결과를 만들어냅니다. 일관성을 유지하는 유일한 방법은 멈추는 것입니다.
또한 선언형 모델을 존중해야 합니다. 반응형 시스템(React 포함)이 매력적인 이유는 특정 시점의 상태로 UI를 표현할 수 있기 때문입니다. 모든 아키텍처적 명료함과 실행 보장은 여기서 비롯됩니다. 목표는 결정성입니다: 같은 입력은 같은 출력을 만들고, 타이밍이 형태를 바꾸지 않으며, UI는 항상 일관적입니다.
비동기가 조건 분기나 대체 값 형태를 통해 사용자 공간으로 새어 들어오면, 사용자가 수동으로 일관성을 관리해야 하며 선언형 모델은 붕괴합니다.
// Derived computation forced to branch on async state
const firstInitial = user.loading ? "" : user.name[0];
비동기를 위한 UI 장치—로딩 인디케이터, 스켈레톤, 폴백—가 문제는 아닙니다. 그것들은 표현(presentation)의 관심사입니다. 문제는 비동기가 상태 그래프를 통해 흐르는 값의 일부가 되는 순간입니다. 그러면 모든 소비자가 분기해야 합니다. UI는 무엇이든 보여줄 수 있지만, 그래프는 오직 실제 값만을 보아야 합니다.
다른 반응형 시스템들과 달리, React는 상태와 렌더링을 강하게 결합해 두었기 때문에 이 문제를 일찍 마주할 수밖에 없었습니다. 모든 상태 변경이 리렌더를 트리거한다면, 동기적 파생(derivation) 뒤에 불일치를 숨길 수 없습니다. Signals는 읽을 때쯤이면 모든 것이 항상 최신이기 때문에 이를 피합니다—리렌더도 없고, 오케스트레이션도 없고, 낭비되는 작업도 없습니다.
하지만 그런 특성은 근본적인 진실을 그저 가릴 뿐입니다. 비동기 작업이 동기 커밋과 교차(interleave)하도록 둘 수는 없습니다. 어떤 계산이 아직 비동기를 기다리는 중이라면, 그 계산이 수행하는 어떤 쓰기(writes)도 추측(speculative)입니다. 아직 갖고 있지 않은 상태에 기반한 UI를 사용자에게 보여줄 수는 없습니다. 왜냐하면 사용자가 상호작용할 때, 그들은 자신이 보고 있는 것과 상호작용하고 있다고 기대하지—프레임워크가 붙잡아 둔 어떤 중간 상태와 상호작용한다고 기대하지 않기 때문입니다.
다음을 보세요:
let count = 0;
let doubleCount = count * 2;
function increment() {
count++;
console.log(`${count} * 2 = ${doubleCount}`);
}
<button onClick={increment}>{count} * 2 = {doubleCount}</div>
저는 과거에도 이 예시를 여러 번 사용했지만, 문제의 본질을 잘 담고 있습니다. 보시죠:
일반 JavaScript에서는 count와 doubleCount가 서로 어긋나게 됩니다. Signals는 읽을 때 doubleCount를 업데이트함으로써 이를 고칩니다. 하지만 여전히 질문이 남습니다. 이 업데이트는 언제 DOM에 도달하나요? 즉시 플러시하면(Solid 1.x처럼) 연속적인 업데이트가 비쌀 수 있습니다. 즉시 플러시하지 않으면, 시스템에 어느 정도의 스케줄링이 본질적으로 내재해 있음을 인정하는 셈입니다.
React는 count를 즉시 업데이트하지 않는 유일한 시스템이었고, 사람들은 이를 싫어했습니다. 하지만 동기는 타당했습니다. React는 이벤트 핸들러가 일관된 상태를 보길 원했고, 컴포넌트가 다시 실행되기 전까지는 파생 값을 업데이트할 방법이 없었습니다.
이제 핸들러가 다음과 같다고 상상해 봅시다:
function onClick(event) {
setBooks([]);
// derived value
if (booksLength) {
books[booksLength - 1]
}
}
books는 업데이트되었는데 booksLength는 업데이트되지 않았다면, 범위를 벗어난 값을 읽게 됩니다.
Signals는 상태와 파생 상태를 완벽하게 동기화해 주며, 개발자에게 강한 안전감을 줍니다. 코드를 한 번만 작성하면 그냥 동작하니까요. 하지만 파생 값 중 하나가 비동기가 되는 순간, 그 자신감은 곧바로 위험요소가 됩니다. 동기화가 유지된다는 보장이 없기 때문입니다.
count와 doubleCount로 돌아가되, doubleCount를 비동기로 만들어 봅시다. UI를 일관적으로 유지하고 싶다면 — 비동기 doubleCount가 resolve될 때까지 UI가 계속 1 * 2 = 2를 보여주게 하려면 — count의 업데이트도 지연해야 합니다. 그렇지 않으면 이상한 상황이 됩니다. UI는 여전히 1 * 2 = 2를 보여주는데, 콘솔은 기저 데이터가 이미 count = 2로 진행해 버렸기 때문에 2 * 2 = 2를 기록하게 됩니다.
그 불일치를 한번 보고 나면 — UI는 일관성을 위해 기다리는데 데이터는 이미 앞으로 나아가 버린 상황 — 결론은 피할 수 없게 됩니다. 동기 세계는 모든 것이 함께 업데이트되므로 안전하다고 느끼게 만들었지만, 그 안전은 모든 파생 값이 즉시 사용 가능하다는 가정 위에 세워진 환상이었습니다. 그중 하나라도 비동기가 되는 순간 그 가정은 무너집니다. UI를 일관적으로 유지하고 싶다면 커밋을 지연해야 합니다. 그리고 UI에서 커밋을 지연한다면, 데이터에서도 지연해야 합니다. 그렇지 않으면 둘은, 여러분이 의지하던 바로 그 보장을 위반하는 방식으로 서로 어긋납니다. 비동기는 지연(latency)만 추가하는 게 아니라, 다른 실행 모델을 강제합니다.
React의 리렌더 모델은 누구보다 먼저 또 다른 진실을 마주하게 만들었습니다. 파생(derivation)과 사이드 이펙트(side effect)는 서로 다른 규칙을 따릅니다.
컴포넌트가 매 변경마다 다시 실행된다면, 매번 모든 것을 재계산하는 건 낭비가 됩니다. 그래서 Hooks가 도입될 때 의존성 배열도 함께 들어왔습니다 — 투박하지만 효과적인 메모이제이션 형태였죠.
필요한 계산만 다시 실행되고 의존성이 동적으로 발견되는 Signals와 비교하면, 이는 제한적으로 보입니다. 하지만 중요한 결과가 있었습니다. React는 렌더링이나 사이드 이펙트를 실행하기 전에 트리의 모든 의존성을 알고 있었습니다.
이 디테일은 비동기가 그림에 들어오는 순간 결정적으로 중요해집니다. 렌더링이 언제든 중단될 수 있다면 — 일시정지, 재생(replay), 혹은 중단(abort) — 어떤 사이드 이펙트도 아직 실행되면 안 됩니다. 모든 의존성이 알려지기 전에 발화하는 사이드 이펙트는 부분적이거나 추측적인 상태에서 실행될 위험이 있습니다. React의 아키텍처는 이를 즉시 드러냈습니다. 렌더링이 완료된다고 보장되지 않으므로, 이펙트는 렌더링에 묶일 수 없었습니다.
Signals는 외과수술 같은 정밀함 덕분에 이 문제를 수년간 피했습니다. 변경 전파는 동기적이고 고립되어 있어서, 파생과 사이드 이펙트가 단일하고 예측 가능한 흐름에서 실행되는 것처럼 보입니다. 하지만 비동기가 그래프에 들어오는 순간 그 예측 가능성은 사라집니다.
왜냐하면 비동기를 사이드 이펙트 중에야 발견한다면, 이미 늦었기 때문입니다. 그리고 비동기가 인터럽트 가능하다면 — 예컨대 promise를 던지고 resolve 시 재실행한다면 — 실행은 완전히 예측 불가능해집니다.
다음을 보세요:
const a = asyncSignal(fetchA());
const b = asyncSignal(fetchB());
const c = asyncSignal(fetchC());
effect(() => {
console.log(a());
console.log(b());
console.log(c());
});
이 이펙트는 무엇을 로그로 남길까요? 몇 번 실행될까요? 순수하게 동기인 세계에서는 이런 질문이 거의 중요하지 않습니다 — 파생은 안정적이고, 이펙트는 커밋당 한 번 실행되니까요. 하지만 비동기에서는 답할 수 없게 됩니다. 각 비동기 소스는 서로 다른 시점에 resolve될 수 있습니다. 각 resolve는 이펙트를 다시 트리거할 수 있습니다. 그리고 그중 하나라도 suspend되거나 retry되면, 전체 실행 순서는 비결정적이 됩니다.
그리고 이는 초기 로드만의 문제가 아닙니다. 이 비동기 소스들이 시간에 따라 독립적으로 업데이트될 수 있다면, 예측 불가능성은 더 커집니다. 이펙트가 언제 실행되는지, 어떤 값을 보는지 추론할 수 없다면, 사이드 이펙트를 추론할 수도 없습니다.
해결책은 단순하고 피할 수 없습니다. 이펙트는 자신이 의존하는 모든 비동기 소스가 안정화(settled)된 이후에만 실행되어야 합니다. 그리고 그렇게 하려면, 어떤 이펙트든 실행하기 전에 모든 의존성을 알아야 합니다. 의존성을 수집하는 것과 이펙트를 실행하는 것을 분리해야 합니다.
이 시점에서 아키텍처는 선택을 강제합니다. 비동기를 정면으로 마주하든지, 아니면 비동기 세계에서도 동기적 보장이 유지된다고 계속 가장하든지. 비동기는 현실입니다. 그래프 어딘가에 반드시 나타납니다. 그리고 일단 나타나면, 시스템이 그것을 인정하지 않는 한 동기 케이스에서 의지하던 보장은 더 이상 성립하지 않습니다.
아니요. 컴파일러는 구문을 재배열한다고 해서 의미론적 문제를 고칠 수 없습니다. 이른 커밋은 기계적 한계가 아니라 정확성의 한계입니다. 비동기가 그래프에 들어오는 순간, 시스템은 어떤 값이 실재(real)이며 어떤 값이 추측적인지 알아야 합니다. 어떤 정적 분석도 그 사실을 바꿀 수 없습니다.
컴파일러가 단일 이펙트 함수에서 의존성을 추출할 수 있을까요? 피상적인 의미에서는 가능합니다 — React의 컴파일러가 정확히 그렇게 합니다. 하지만 컴파일러 기반 추출은 스코프 안에 있는 것만 봅니다. 전체 그래프는 볼 수 없습니다. 소스가 signal 자체가 아니라 signals를 호출하는 함수라면, 컴파일러는 그 함수들이 순수한지 혹은 사이드 이펙트를 숨기고 있는지 알 방법이 없습니다.
이것이 바로 Svelte 5가 Runes(Signals)로 이동한 이유입니다. 컴파일 시간 의존성 캡처는 단단한 한계에 부딪혔습니다. 문법적으로 보이지 않는 소스는 추적할 수 없었습니다.
let count = 0;
function getDoubleCount() {
return count * 2;
}
// never updates because count is not
// visible in this scope
$: doubled = getDoubleCount();
이런 경계에 도달하면, 추가되는 복잡성, 숨겨진 규칙, 불완전한 커버리지가 그만한 가치가 있는지 자문하게 됩니다. 컴파일러 추론은 문제를 임시로 덮을 수는 있어도, 해결할 수는 없습니다. 비동기는 런타임 현상입니다. 보장은 런타임에서 강제되어야 합니다.
전혀 아닙니다. 이것은 React를 베끼는 게 아닙니다. React가 가장 먼저 부딪힌 것과 동일한 근본적 진실을 인정하는 것입니다. 비동기는 커밋 고립(commit isolation)을 강제합니다. 비동기는 이펙트 분리(effect splitting)를 강제합니다. Vue는 수년 전부터 watchers(effects)에서 이런 분리를 갖고 있었습니다. 이것들은 React만의 특징(React‑isms)이 아닙니다. 비동기 존재 하에서 일관성을 보존하고자 하는 어떤 시스템에도 존재하는 불변조건입니다.
그리고 결정적으로, 이런 불변조건을 채택한다고 해서 Signals의 장점이 사라지지는 않습니다:
오히려 이러한 불변조건을 받아들이는 것은 모델의 강점을 더 부각합니다. Signals의 표현력과 함수형 프로그래밍의 정확성 규율을 결합합니다. 현실과 싸우는 대신 현실을 인정합니다. 그리고 비동기에도, Signals가 동기 계산에 이미 제공하던 것과 같은 결정성과 명료함을 부여합니다.
Solid는 새로움을 좇아서가 아니라 UI를 예측 가능하고, 일관되며, 빠르게 만드는 근본 규칙을 드러냄으로써 프론트엔드 아키텍처의 경계를 늘 밀어 왔습니다. React는 아키텍처가 그렇게 강제했기 때문에 이 규칙들을 먼저 마주쳤습니다. React가 이런 제약을 선택한 것이 아니라 — 그것들에 부딪힌 것입니다. 이를 “설계 결정”이라 부르는 것은 관여한 의지(agency)를 오히려 과장하는 표현에 가깝습니다. 그것들은 발견이었습니다.
같은 불변조건을 ‘강점의 위치’에서 받아들이는 것은 완전히 다른 이야기입니다. 우리는 막다른 골목에 몰려서 이 제약을 채택하는 것이 아닙니다 — 그것이 사실이기 때문에 채택하는 것입니다. 비동기는 커밋 고립을 강제합니다. 비동기는 이펙트 분리를 강제합니다. 비동기는 일관된 스냅샷을 강제합니다. 이것들은 React만의 특징이 아니라, UI의 물리법칙입니다.
이를 받아들이는 것은 모방이 아닙니다. 성숙함입니다. 눈을 뜨고 불가피한 길을 선택하는 것이며, 비동기를 엣지 케이스가 아니라 아키텍처의 일급 구성 요소로 다루는 시스템을 만드는 것입니다. Solid를 그저 빠른 것이 아니라, 근본적으로 ‘옳은’ 것으로 만드는 다음 단계입니다.
명료함이 세상을 단순하게 만들지는 않지만, 방향을 분명하게 해 주기는 합니다.