(번역) 리액트 vs Signals: 10년이 지난 지금

Jung Han
12 min readApr 12, 2023

원문: https://dev.to/this-is-learning/react-vs-signals-10-years-later-3k71

윈스턴 처칠은 이런 말을 했습니다.

역사로부터 배우지 못한 사람은 그 역사를 되풀이할 수밖에 없는 운명이다.

하지만 좀 더 아이러니한 말을 덧붙여 본다면 이럴 수 있습니다.

역사를 공부하는 사람은 다른 사람들이 역사를 반복하는 동안 방관할 운명입니다.

지난 몇 주 동안 프런트엔드 생태계에서는 Signal이라고 불리는 세분화된 반응형의 부활에 대한 기대감이 절정에 달했습니다.

자바스크립트 Signal의 역사를 알아보려면 제 글을 확인하세요.
원문: https://dev.to/this-is-learning/the-evolution-of-signals-in-javascript-8ob
번역: https://medium.com/p/4bd6a991d2f

사실 Signal은 결코 사라지지 않았습니다. 서드파티 라이브러리로 몇 년 동안 잘 알려지지 않았거나 프레임워크 일반 객체 API 뒤에 숨어있었습니다. 리액트와 가상 DOM과 함께 제공되는 일반적인 수사학(rhetoric)은 이 패턴을 예측할 수 없고 위험하다고 비난했지만 말이죠. 그리고 그 비난이 틀린 말은 아니었습니다.

하지만 10년 전의 논쟁보다 현재는 더 많은 것이 있습니다. 지난 몇 년간 어떤 변화가 있었는지 이야기하고 SolidJS를 비교 대상으로 사용해 보겠습니다.

프런트엔드 “해결책(fix)”

이 대화의 핵심은 리액트가 무엇인지 이해하는 것입니다. 리액트는 가상 DOM이 아닙니다. 또한, 리액트는 JSX가 아닙니다. 지금까지 제가 이 주제에 대해 설명한 것 중 하나는 Dan Abramov가 초기에 작성한 ‘당신은 React의 핵심을 놓치고 있다’에서 나온 것인데, 이 글에서 그는 리액트의 진정한 강점은 다음과 같다고 말합니다.

합성, 단방향 데이터 흐름, DSL로 부터의 자유, 명시적 뮤테이션, 그리고 정적 멘탈 모델

리액트는 구현 세부 사항보다 더 중요한 매우 강력한 원칙이 있습니다. 그리고 몇 년이 지난 후에도 리액트의 사상가들은 리액트가 프런트엔드의 해결책이었다는 생각을 갖고 있습니다.

하지만 만약 그렇지 않다면 어떨까요? 그렇게 급격한 재조정(re-alignment)을 하지 않고 당시의 문제를 해결할 수 있는 다른 방법이 있었다면 어떨까요?

확실한 대안으로서의 Solid

Solid의 개념도 마찬가지로 간단합니다. 심지어 합성, 단방향 데이터 흐름, 명시적 뮤테이션과 같이 리액트가 UI 개발에 대한 단순한 해답처럼 느껴지도록 만든 아이디어도 공유합니다. 다른 점이 있다면 반응형의 세계 밖은 모든 것이 이펙트(effect)라는 점입니다. 모든 것을 순수하게(부수 작용이 없는 것처럼) 취급하는 리액트와는 거의 정반대입니다.

템플릿 없이 Signal을 사용해 카운터 버튼을 만든다면 다음과 같이 작성됩니다.

function MyCounter() {
const [count, setCount] = createSignal();

const myButton = document.createElement("button");
myButton.onclick = () => setCount(count() + 1);
// 텍스트는 처음 그리고 count가 변할때마다 업데이트 됩니다.
createEffect(() => {
myButton.textContent = count();
});
return myButton;
}

함수를 호출하고 버튼을 다시 가져옵니다. 다른 버튼이 필요하면 다시 호출하면 됩니다. 이 방법은 작성한 뒤 잊어버리면 됩니다. DOM 요소를 만들고 이벤트 리스너를 설정했습니다. DOM 자체와 마찬가지로, 버튼을 업데이트하기 위해 무언가를 호출할 필요가 없습니다. 독립적으로 동작하죠. 좀 더 사용자 친화적인 방식을 원한다면 JSX를 사용하면 됩니다.

function MyCounter() {
const [count, setCount] = createSignal();

return <button onClick={() => setCount(count() + 1)}>
{count()}
</button>
}

Signal은 더 이상 과거와 같지 않습니다. 실행에 결함이 없습니다(glitch-free). Suspense 또는 Concurrent 렌더링과 같은 예약된 워크플로를 모델링할 수 있는 푸시/풀 하이브리드 방식입니다. 또한, 자동화된 폐기를 통해 옵서버 누수 패턴도 완화합니다. 몇 년 간 업데이트뿐만 아니라 생성을 위한 벤치마크를 주도해 왔습니다.

불변성

지금까지 괜찮지 않았냐고요? 아닐 수도 있습니다.

분명하게 이 문제는 리액트가 해결한 것입니다. 정확히 무엇을 해결했을까요?

Dan의 고민은 잠시 제쳐두고, 이 내용은 불변성으로 귀결됩니다. 하지만, 가장 직접적인 방식은 아닙니다. Signal 자체는 불변입니다. 콘텐츠를 수정할 수 없으며 반응을 기대할 수 없습니다.

const [list, setList] = createSignal([]);

createEffect(() => console.log(JSON.stringify(list())));
list().push("Doesn't trigger");
setList(() => [...list(), "Does trigger"]);

.value를 사용하는 Vue, Preact 또는 Qwik의 변형(variant)을 사용하더라도 할당을 통해 값을 변경하는 것이 아니라 대체를 한다는 것을 의미합니다. 그렇다면 Signal이 "변경 가능한(mutable) 상태"라는 것은 무엇을 의미할까요?

세분화된 이벤트 중심 아키텍처를 사용하면 격리된 업데이트를 수행할 수 있다는 장점이 있습니다. 다시 말해, 뮤테이션을 할 수 있다는 것입니다. 이와 대조적으로 리액트의 순수 렌더링 모델은 실행할 때마다 가상 표현을 다시 생성하여 내부적으로 변경 가능한 세계를 추상화합니다.

데이터 인터페이스가 명시적이고 부수 작용이 잘 관리되며, 실행이 잘 정의된 상태에서 업데이트를 수행하는 두 선언적 라이브러리를 살펴볼 때 이 차이점이 얼마나 중요할까요?

단방향 흐름

저는 양방향 바인딩을 좋아하지 않습니다. 단방향 흐름은 정말 좋은 것입니다. 저도 이 트윗에 언급된 것과 같은 일을 겪었습니다. Solid가 프리미티브(primitive)에 읽기/쓰기 분리를 사용하는 것을 눈치채셨을 것입니다. 이는 중첩된 반응형 프록시에도 마찬가지입니다.

역자주: primitive가 궁금하다면 이 링크를 참고해 보세요. (https://www.solidjs.com/guides/reactivity#introducing-primitives)

반응형 프리미티브를 만들면 읽기 전용 인터페이스와 쓰기 인터페이스를 각각 갖게 됩니다. 이에 대한 의견은 Solid의 설계에 깊게 뿌리내려 있습니다. 종종 커뮤니티 구성원들은 이 아이디어 때문에 가변성을 가장하는 게터와 세터를 남용하며 저를 괴롭히곤 합니다.

Solid를 디자인하면서 제가 하고 싶었던 것 중 하나는 사고의 지역성을 유지하는 것이었습니다. Solid의 모든 작업은 이펙트가 있는 곳, 즉 DOM에 삽입하는 곳에서 이뤄집니다. 부모 컴포넌트에서 Signal을 사용하는지 여부는 중요하지 않습니다. 필요에 따라 작성하기만 하면 됩니다. 필요한 경우 모든 프로퍼티를 반응형으로 취급하고 필요한 곳에서 접근하면 됩니다. 전역적인 사고가 필요하지 않으며, 이로 인해 리팩터링에 대한 걱정도 없게 됩니다.

프로퍼티를 작성할 때 Signal의 값을 전달하지 않고 접근하는 것을 권장함으로써 이를 다시 한번 강조합니다. 컴포넌트가 Signal이 아닌 값을 기대하도록 하세요. Solid는 반응형이 있을 수 있는 경우 이를 게터로 래핑해 반응형을 보존합니다.

<Greeting name={name()} />

// becomes
Greeting({ get name() { return name() })
<Greeting name={"John"} />

// becomes
Greeting({ name: "John" })

어떻게 알 수 있을까요? 간단한 휴리스틱입니다. 표현식에 함수 호출이나 프로퍼티 접근이 포함되어 있으면 래핑합니다. 자바스크립트에서 반응형 값은 함수 호출이어야만 읽기를 추적할 수 있습니다. 따라서 게터나 프록시일 수 있는 함수 호출이나 프로퍼티 접근은 모두 반응형일 수 있으므로 래핑합니다.

긍정적인 점은 Greeting의 경우 어떻게 사용하든 프로퍼티에 동일한 방식(props.name)으로 접근한다는 것입니다. isSignal 검사나 불필요한 오버래핑을 통해 Signal로 만들 필요가 없습니다. props.name은 항상 문자열이고 값이기 때문에 변경이 예상되지 않습니다. 프로퍼티는 읽기 전용이며 데이터는 단방향으로 흐릅니다.

옵트인 vs 옵트아웃

이것이 논의의 핵심일 수 있습니다. 이에 접근하는 방법에는 여러 가지가 있습니다. 대부분의 라이브러리가 개발자 경험을 위해 반응형을 선택한 이유는 자동 종속성 추적을 통해 업데이트 누락에 대해 걱정할 필요를 만들지 않기 위해서입니다.

리액트 개발자라면 상상하기 어렵지 않을 것입니다. 의존성 배열이 없는 훅을 상상해 보세요. 훅에 의존성 배열이 존재한다는 것은 리액트가 업데이트를 놓칠 수 있다는 것을 의미합니다. 마찬가지로 리액트 서버 컴포넌트를 사용할 때 클라이언트 컴포넌트를 옵트인합니다(use client). 수년간 컴파일을 통해 이를 자동화해 온 다른 해결책이 있지만, 때로는 명시적으로 하는 것이 더 낫습니다.

일반적으로 이것은 단일한 결정은 아닙니다. 어떤 프레임워크에서든 옵트인 할 수 있는 항목과 옵트아웃할 수 있는 항목이 있습니다. 실제로 모든 프레임워크가 그럴 겁니다.

프레임워크는 이상적일 수 있지만, 현실은 그렇게 밝지 않습니다.

이 사례를 살펴보시죠.

이 두 함수는 Solid의 관점에서 볼 때 매우 다른 두 함수입니다. 왜냐하면 Solid의 JSX 처리 방식과 한 번만 실행된다는 사실 때문입니다. 이것은 모호하지 않으며 일단 알고 나면 쉽게 피할 수 있습니다. 심지어 이에 대한 린트 규칙도 있습니다.

마치 아래의 두 함수가 동일할 것이라고 기대하는 것과 같습니다.

const value = Date.now();
function getTime1() {
return value;
}

function getTime2() {
return Date.now();
}

표현식을 이동해도 Date.now()가 수행하는 작업은 변경되지 않지만, 끌어올리면 함수의 동작이 변경됩니다.

이상적이지 않을 수 있지만, 이 멘탈 모델에 장점이 전혀 없는 것은 아닙니다.

정말 이게 “해결책”이 될 수 있을까요?

이것은 논리적인 후속 조치입니다. 이 내용은 매우 언어적인 문제입니다. 수정은 어떻게 생겼나요? 컴파일러의 문제는 엣지 케이스를 설명하고 일이 잘못되었을 때 어떤 일이 발생하는지 이해하기가 더 어렵다는 것입니다. 역사적으로 리액트 또는 Solid가 명확한 경계를 유지하는 데 상당한 주의를 기울인 이유는 대체로 이 때문입니다.

Solid를 처음 도입한 이후로 우리는 사람들의 다양한 컴파일을 탐색해 왔습니다. 왜냐하면 프리미티브로서의 Signal은 매우 적용성이 좋고 성능이 뛰어났기 때문입니다.

2021년에 저는 한번 도전해 봤습니다.

링크: The Quest for ReactiveScript(https://dev.to/this-is-learning/the-quest-for-reactivescript-3ka3)

리액트 팀도 이 문제를 검토 중이라고 발표했습니다.

두 시스템 모두에 규칙이 적용되고 있습니다. 리액트는 함수 본문에서 순수하지 않은 작업을 하지 않기를 원합니다. 그렇게 하면 내부를 최적화할 때 누수가 발생할 수 있기 때문입니다. 그 결과로 컴포넌트의 일부가 재실행되지 않을 수 있습니다.

Solid는 이미 컴파일러나 React.memo, useCallback, useRef와 같은 추가 래퍼 없이도 이미 최적화되어 있습니다. 그러나 리액트처럼 Signal을 읽는 위치를 표시하는 데 신경 쓸 필요가 없는 것과 같은, 보다 간소화된 작업을 통해 사용성을 향상시킬 수 있습니다.

최종 결과는 거의 동일합니다.

정리해보면

이 모든 것의 가장 이상한 부분은 리액트팀이 반응형을 바라볼 때 거울을 보지 않는다는 것입니다. 훅을 추가함으로써 그들은 Signal에 근접한 모델을 위해 리렌더링 순도(purity)를 일부 희생했습니다. 그리고 메모이제이션과 관련된 훅을 제거하기 위해 컴파일러를 추가함으로써 그 이야기를 완성했습니다.

이제 이것들은 독립적으로 개발되었다는 것을 이해해야 합니다. 또는, 적어도 인정받지 못했지만, 이전 개발자들의 의견을 고려해보면 놀라운 일이 아닙니다.

다행히도 오늘날의 리액트는 10년 전의 리액트가 아닙니다.

리액트는 UI를 구축하는 방법을 안내하는 중요한 원칙을 가르쳐주며 프런트엔드 세계를 변화시켰습니다. 그들은 혼돈의 바다에서 독특한 이성의 목소리를 내는 신념의 힘으로 해냈습니다. 리액트 팀의 위대한 업적 덕분에 우리가 있으며, 수많은 사람이 그들의 교훈을 통해 많은 것을 배웠습니다.

시대가 바뀌었습니다. 해결책에서 멀어졌던 패러다임이 다시 부상하는 것은 적절합니다. 반복을 멈추고 이야기를 완성합니다. 그리고 모든 잡음과 부족주의(tribalism)가 사라지면 웹을 발전시키는 건강한 이야기만 남게 됩니다.

문맥에 맞지 않는 인용문을 이어 붙여 내러티브를 구성하는 것은 상당히 예의에 어긋납니다. 시간은 흐르고 관점은 변합니다. 하지만 이 내용은 리액트의 사고 리더십이 오랫동안 강력하게 지켜온 정서입니다. 처음부터 내왔던 목소리이죠. 저는 지난 며칠간의 대화를 통해 이 모든 인용문을 거의 노력하지 않고 수집할 수 있었습니다. 리액트의 초기 역사에 대해 알고 있다면 이 영상을 보세요.

역자주: Dan은 댓글을 통해 글의 내용 중 잘못 전달된 내용에 대해 바로잡고 Solid의 방식과 비교를 하며 그렇게 작성될 때의 장점, 단점 등 생산적인 토론을 진행합니다. 관심이 있으신 분들은 원문의 댓글을 참고해 주세요.

원문 링크: https://dev.to/this-is-learning/react-vs-signals-10-years-later-3k71

--

--