UI 프레임워크가 상태 업데이트와 렌더링을 언제, 어떻게 적용하느냐에 따라 생기는 ‘일관성’의 트레이드오프를 React, Vue, Svelte, Solid의 서로 다른 모델로 비교한다.
때로는 보편적으로 좋은 해법이 존재하지 않는 문제들이 있습니다. 반드시 어떤 트레이드오프가 필요합니다. 보호할 수 없는 관점도 있고, 어떤 선택지가 다른 것보다 더 낫다고 말하기조차 애매한 경우도 있습니다.
우리가 로그에서 얻은 결과는 이랬습니다:
React 0 0 0
Vue 1 2 0
Svelte 1 0 0
Solid 1 2 2
이걸 처음 올린 건 1년 반 전이었는데, 그 이후로 계속 머릿속을 떠나지 않았습니다. 계속 다시 돌아보게 돼요. 꿈에서도, 본업에서도요. Marko 6을 작업할 때는 결정을 못 내렸고, 마음을 정할 때까지 같은 사이클에서 이미 업데이트된 값을 읽으려 하면 에러를 던지기로 했었습니다.
그렇다면 어떻게 모든 자바스크립트 프레임워크가 이렇게 서로 다른 동작을 가질 수 있을까요? 각자 그럴싸한 논리가 있기 때문입니다. 그 트윗에 “우리 프레임워크가 유일하게 합리적인 일을 한다”고 답한 사람들도 있었고요. 그리고 그들은 모두 맞을 수도 있고, 어쩌면 모두 틀렸을 수도 있습니다.
React부터 시작해 봅시다. 상태를 업데이트하면, React는 다음 렌더 사이클까지 그 변경을 커밋하지 않고 미룹니다. 장점은 React가 항상 일관적(consisent)이라는 점입니다. count와 doubleCount, 그리고 DOM은 언제 관측해도 항상 서로 동기화되어 있습니다.
프레임워크의 일관성은 중요합니다. 신뢰를 쌓아 줍니다. 뷰와 상호작용할 때 “보이는 게 전부”라는 걸 알 수 있죠. 사용자가 무언가를 보고 있는데 앱의 상태는 다른 경우, 사용자가 만든 액션이 의도적으로 보이는데도 예상치 못한 결과를 낳을 수 있어 버그가 숨어들기 쉽습니다. 때로는(금전적이든 뭐든) 심각한 결과로 이어질 수도 있고요.
이는 개발 과정에도 확장됩니다. 개발자가 다루는 모든 것이 동기화되어 있다고 확신할 수 있다면, 코드가 예상대로 실행된다고 믿을 수 있습니다.
하지만 그 결과로 종종 고통스러운 상황이 생깁니다:
// updating state in React
count === 0; // true
setCount(count + 1);
console.log(count, doubleCount, el.textContent); // 0, 0, 0
상태 업데이트가 즉시 반영되지 않습니다. 변경을 연속으로 수행하거나 값을 전달해 가며 작업하는 경우, 이전 값을 받게 됩니다. 긍정적인 면도 있는데, 상태 변경을 한 번에 몰아서 하도록 유도하므로 성능에 도움이 될 수 있습니다. 다만 같은 상태를 여러 번 설정하면 마지막 설정이 이긴다는 점을 의식해야 합니다.
React의 배치 업데이트 기반 일관성 모델은 늘 안전한 선택지입니다. 모두가 좋아하진 않지만, 기본값으로는 정말 훌륭합니다.
배치 일관성은 “정확하다” 하더라도, 값이 바로 업데이트될 것이라는 기대와 어긋나 혼란과 버그를 낳곤 합니다. 그래서 Solid는 반대로, 다음 줄에서는 모든 것이 업데이트되도록 합니다.
// updating state in Solid
count() === 0; // true
setCount(count() + 1);
console.log(count(), doubleCount(), el.textContent); // 1, 2, 2
이건 완전히 일관적이고, 기대에도 부합합니다. 하지만 당연히 트레이드오프가 있겠죠.
여러 변경을 하면 여러 번 리렌더가 트리거되어 많은 작업을 하게 됩니다. Solid처럼 컴포넌트를 통째로 리렌더하지 않고 바뀐 것만 업데이트하는 프레임워크에서는 합리적인 기본값이긴 하지만, 그래도 불필요한 작업이 발생할 수 있습니다. 반면 서로 독립적인 변경이라면 성능 오버헤드가 없습니다. 하지만 React와 마찬가지로, 변경을 한 번에 적용하도록 유도할 수도 있습니다.
Solid의 일관성 모델은, 최적화를 위해 배칭 메커니즘이 존재한다는 점을 인지하고 있어야 한다는 ‘비용’을 치르게 합니다.
$mol 프레임워크의 저자는 자신의 프레임워크와 Vue의 입장을 방어하는 꽤 괜찮은 논리를 제시합니다. Vue에서는 리액티브하게 업데이트되지만, React처럼 스케줄링됩니다. 다만 직접 상태 변경은 즉시 적용됩니다.
// updating state in Vue
count.value === 0; // true
count.value++;
console.log(count.value, doubleCount.value, el.textContent) // 1, 2, 0
이 라이브러리들이 하는 “요령”은 값을 stale(낡음)로 표시하고 스케줄만 걸어 두되, 파생(derived) 값을 읽지 않는 한 즉시 업데이트를 실행하지 않는 것입니다. 파생 값을 읽는 순간에는 보통 스케줄링된 시점까지 기다리지 않고, 즉시(eagerly) 실행합니다. 이렇게 하면 필요한 만큼만 성능을 쓰면서, 렌더링 같은 무거운 작업(사이드 이펙트)을 뒤로 미룰 수 있습니다.
이건 지금까지 이야기한 것 중 처음으로 “일관적이지 않은” 접근입니다. 순수 계산은 부분적으로 일관적이지만, DOM에는 즉시 반영되지 않습니다. 대부분의 경우에는 일관적으로 보이게 된다는 장점이 있죠. 하지만 하위(다운스트림) 사이드 이펙트가 상태를 또 업데이트하는 경우, 읽었다고 해도 그 변경은 이후까지 적용되지 않습니다.
Vue의 배치 리액티비티는 이 문제를 가장 ‘별일 아닌 것’처럼 만들어 주는 데 효과적이지만, 예측 가능성은 가장 낮을 수 있습니다.
다른 것들과 비교하면 Svelte의 실행 방식은 그다지 매력적으로 보이지 않을 수도 있습니다. 일관적이지 않고, 일관적으로 보이려는 시도조차 하지 않습니다. 그리고 그게 Svelte에는 일종의 완벽한 선택이기도 합니다.
// updating state in Svelte
let count = 0;
count++;
console.log(count, doubleCount, el.textContent); // 1, 0, 0
Svelte에서는 모든 것이 일반 자바스크립트처럼 보입니다. 변수를 설정했다고 해서 다음 줄에서 파생 값인 doubleCount나 DOM까지 업데이트되리라고 기대하는 게 오히려 이상합니다.
Vue처럼, 사람들은 이걸 많이 의식하지 않을 겁니다. 하지만 파생 데이터에서의 불일치를 더 빨리 맞닥뜨릴 가능성은 큽니다. 시작할 때 별다른 설명이 필요 없어서, 선입견이 없는 사람에게는 이 모델이 가장 자연스럽게 느껴질 수 있습니다. 하지만 우리가 정말 원하는 게 그걸까요?
Svelte는 애초에 일관성을 추구하지 않습니다. 이건 축복일 수도, 저주일 수도 있습니다.
여기서 보통은 “정답은 상황에 따라 다르다(it depends)”라고 말하고 뭔가 심오한 이야기를 남기고 끝내야 하는 부분이죠. 하지만 지금 제 생각은 그렇지 않습니다.
이 모든 것 뒤에는 가변성(mutable) vs 불변성(immutable) 논쟁이 있습니다. 예를 들어 배열에서 특정 인덱스의 아이템을 꺼내 끝으로 옮긴다고 해 봅시다.
const array = ["a", "c", "b"];
const index = 1;
// immutable
const newArray = [
...array.slice(0, index),
...array.slice(index + 1),
array[index]
];
// or, mutable
const [item] = array.splice(index, 1);
array.push(item);
어느 쪽이든 결과는 ['a', 'b', 'c']가 되리라고 기대할 겁니다.
보시다시피 불변 변경은 newArray에 한 번의 대입(assign)으로 적용할 수 있습니다. 반면 가변 예제는 실제 배열을 2번의 연산으로 바꿉니다.
React처럼(예: Vue의 프록시 같은 걸 떠올려 보세요) 두 연산 사이에 상태가 업데이트되지 않는다면, 결과는 ['a', 'c', 'b', 'c']가 됩니다. splice로 얻는 item은 여전히 "c"일 테고요. 두 번째 배열 연산(push)이 사실상 첫 번째를 덮어써서, 리스트에서 제거되지 않게 됩니다.
게다가 현실은 이 예제들보다 좀 더 복잡합니다. 저는 일부러 이벤트 핸들러를 골랐는데, 이벤트 핸들러는 일반적인 업데이트/렌더 흐름의 바깥이지만, 그 안에서도 다른 동작을 찾을 수 있습니다.
React의 함수형 setter는 최신 값을 제공합니다:
// count === 0
setCount(count => count + 1);
setCount(count => count + 1); // results in 2 eventually
console.log(count); // still 0
Vue는 Effect를 사용해 Svelte의 동작을 흉내 낼 수 있습니다:
const count = ref(0);
const doubleCount = ref(0);
// deferred until after
watchEffect(() => doubleCount.value = count.value * 2);
console.log(count.value, doubleCount.value, el.textContent) // 1, 0, 0
Solid의 업데이트는 기본적으로 Vue처럼 동작하면서, 리액티브 시스템 내부 변경을 전파합니다. 이는 무한 루프를 방지하는 데 필요합니다. 하지만 명시적 배칭(explicit batching)과 Transitions API는 React처럼 과거의 값에 머무르게 합니다.
솔직히 말해서, 이건 다 별로입니다. 배칭 동작을 인지해야 할 정도로요. 그리고 한 번 그걸 인지하면, 가장 제정신인 선택처럼 느껴지는 “일관적인 기본값”을 제공해야겠다는 강박이 생깁니다.
많은 분들에겐 아마 놀랍지 않을 겁니다. 저는 SolidJS 저자니까요. Solid의 eager 업데이트는 렌더링 모델과 잘 맞고, 배칭은 옵트인으로 보완됩니다.
하지만 제게 진짜로 충격이었던 건 지난 몇 년 동안 제 의견이 얼마나 바뀌었는지였습니다. Marko 6을 설계하면서 이 문제를 처음 봤을 때는, 저는 Vue의 배치 리액티비티에 완전히 찬성했습니다. 컴파일된 문법을 가진 프레임워크에서는 명시적 옵트인이 어울리지 않는다고 느꼈고, 변이(mutation)했는데 업데이트가 안 되는 건 어색했거든요. 반면 Svelte의 접근은 제 기준에서 가장 마음에 들지 않았습니다.
그런데 이제는 그렇게 확신하지 못하겠습니다. 명시적 문법을 수용하는 Solid를 작업하면서 저는 필요한 도구를 다 갖게 됐습니다. 배칭이 옵트인이고, “직관적인 동작”(그리고 변이 지원)을 위해 일관성을 포기할 거라면, 저는 최소한 예측 가능성은 원합니다. 그런 면에서 Svelte의 너무 단순한 모델은 꽤 말이 됩니다.
그래서 Solid 1.5에 들어오면서, eager하고 일관적인 기본값(그리고 Transitions의 ‘과거로 미루는’ 배칭)을 보완하기 위한 새로운 “자연스러운” 배칭 모델을 검토 중입니다. 여기서 무슨 교훈이 있는지는 모르겠습니다. 다른 결론에 도달한 사람을 탓할 수는 없어요. 이런 까다로운 문제들이 제가 이 일을 এত这么 좋아하는 이유입니다.
회의적인 사람은 “그럼 Solid는 결국 모든 업데이트 모델을 다 넣겠네”라고 말할지도 모르고, 그 말은 어느 정도 맞을 겁니다. 모르겠네요. 못 이기면 합류해야 하나요?
이 주제에 대한 의견이 있고 토론에 참여하고 싶다면, 현재 이 주제를 논의 중인 SolidJS discord에 참여해 주세요.