[번역] 마법의 비밀 공개: 다양한 프레임워크에서 반응성 탐구

date
Aug 8, 2023
slug
reactivity-with-frameworks
author
status
Public
tags
summary
type
Post
thumbnail
category
updatedAt
May 27, 2024 01:05 PM
원문: https://www.builder.io/blog/reactivity-across-frameworks
선호하는 프레임워크에서 반응성이 어떻게 작동하는지 궁금한 적이 있으신가요? 여러분은 운이 좋네요! 이 글을 통해 반응성에 대해 자세히 살펴보고, 각 프레임워크가 어떻게 다른지 비교하여 더 잘 이해할 수 있게 될 것입니다.

반응성(Reactivity)

반응성은 애플리케이션의 상태 변경에 따른 UI의 자동 업데이트라고 폭넓게 정의할 수 있습니다. 반응성의 기본 개념은 개발자는 애플리케이션의 상태만 신경 쓰고 프레임워크가 해당 상태를 UI에 반영하도록 해야 한다는 것입니다. 하지만 프레임워크가 해당 상태를 반영하는 방식은 성능과 코드의 지연 로딩에 영향을 미칠 수 있으므로 자세히 알아보겠습니다.

거대한 반응성(Coarse-grained) vs 세분화된 반응성(fine-grained)

(번역자 주: 이 글에서 "Coarse-grained"는 프로그래밍 문맥에서 사용되며 일반적으로 시스템, 디자인, 작업 또는 데이터가 상대적으로 큰 덩어리로 구성되어 있음을 의미하며 "fine-grained"는 시스템이 더 작고 세분화된 부분으로 구성되어 있다는 것을 의미합니다.)
프레임워크 간의 반응성을 비교할 수 있는 한 가지 축은 거대한 반응성과 세분화된 반응성으로 비교할 수 있습니다.
  • 거대한 반응성(Coarse-grained): 프레임워크는 업데이트가 필요한 DOM 노드를 결정하기 위해 많은 애플리케이션 또는 프레임워크 코드를 실행해야 합니다.
  • 세분화된 반응성(Fine-grained): 프레임워크는 코드를 실행할 필요가 없으며 업데이트해야 하는 DOM 노드를 정확히 알고 있습니다.
이 개념은 이분법적인 것이 아니라 프레임워크가 존재할 수 있는 연속적인 차원입니다. 또한 이 방식은 비교할 수 있는 다른 많은 축 중 하나에 불과합니다. 이 글에서는 렌더링에 대해서도 이야기할 것입니다. 여기서 렌더링은 프레임워크가 업데이트할 DOM을 파악하는 방식을 의미하며, 브라우저가 DOM 업데이트의 결과로 수행하는 실제 브라우저 렌더링은 아닙니다.

나의 의견

이 프레임워크에서 제가 반응성에 대해 생각하는 방식은 다음과 같습니다. 이 글은 어떤 권위도 없습니다. 그러므로 제가 어떻게 이러한 결론에 도달했는지에 대해 이야기해보고 이를 기반으로 여러분의 결론을 내려보면 좋을 것입니다.
notion image

예제 살펴보기

제가 어떻게 결론에 도달했는지에 대해 이야기하기 전에 프레임워크 간의 반응성 동작을 비교할 몇 가지 기준을 정의하는 것이 좋을 것 같습니다. 가장 간단한 애플리케이션인 카운터 컴포넌트부터 시작하겠습니다. 카운터 컴포넌트는 상태, 이벤트 핸들러, DOM에 대한 바인딩이 필요합니다. 더 복잡한 애플리케이션을 구축하는 데 필요한 모든 기본 요소는 다음과 같습니다.
notion image
import { useState } from "react"; export const Counter = () => { const [count, setCount] = useState(0); return ( <div> Count: {count} <button onClick={() => setCount(count + 1)}>+1</button></div>); };
하지만 위의 예시는 너무 단순합니다. 실제 애플리케이션에서는 상태, 이벤트, 바인딩이 항상 같은 컴포넌트에 없을 수 있습니다. 따라서 예제를 좀 더 세분화된 컴포넌트로 나누어 상태를 저장하고(Counter) 수정하며(Incrementor) 여러 컴포넌트에 분산해 바인딩(Display) 할 수 있는지 보여드리도록 하겠습니다.
notion image
import { useState } from "react"; export default function Counter() { const [count, setCount] = useState(0); return ( <><Display value={count} /><Incrementor setCount={setCount} /></>); } function Display({ value }) { return <main>{value}</main>; } function Incrementor({ setCount }) { return <button onClick={() => setCount((v) => v + 1)}>+1</button>; }
아직 실제 앱에서 흔히 볼 수 있는 부분 한 가지를 놓치고 있습니다. 부모 Counter 컴포넌트에서 자식 Display 컴포넌트로 상태를 전달하는 데만 사용되는 상태를 가지고 있지 않는 Wrapper를 소개해 보겠습니다. 실제 애플리케이션에서는 상태를 가지고 있지 않은 컴포넌트가 일반적이며 프레임워크가 이를 어떻게 처리하는지 살펴보고자 합니다.
notion image
import { useState } from "react"; export default function Counter() { const [count, setCount] = useState(0); return ( <><Wrapper value={count} /><Incrementor setCount={setCount} /></>); } function Wrapper({ value }) { return <Display value={value} />; } function Display({ value }) { return <main>{value}</main>; } function Incrementor({ setCount }) { return <button onClick={() => setCount((v) => v + 1)}>+1</button>; }
마지막으로, 대부분의 프레임워크는 성능에 대한 반응성을 개선(미세 조정)하기 위한 메커니즘을 제공합니다. 그러나 여기서 살펴보고자 하는 것은 프레임워크의 '기본 동작'이므로 이러한 최적화에 대해서는 논의하지 않겠습니다.

리액트 및 앵귤러

거대한 반응성(course-grained) 시스템부터 시작하겠습니다. 리액트 및 앵귤러가 거대한 반응성인 이유는 상태가 변경되면 컴포넌트 트리를 다시 실행해야 하기 때문입니다. 재실행이란 프레임워크가 변경 사항을 감지하여 DOM을 업데이트할 수 있도록 관련 컴포넌트의 애플리케이션 코드를 다시 실행해야 한다는 것을 의미합니다.
리액트에서는 컴포넌트를 다시 실행하여 vDOM을 다시 생성한 다음, 이전 vDOM과 비교하여 업데이트가 필요한 DOM 요소를 결정해야 합니다. (앵귤러에서는 컴포넌트가 표현식을 다시 읽어 DOM 업데이트가 필요한지 결정해야 합니다.)
실제 세부 동작은 중요하지 않고, 프레임워크에서 어떤 상태가 어떤 DOM 요소에 바인딩되어 있는지 알지 못한다는 점이 중요합니다. 대신 프레임워크는 현재와 이전 vDOM(또는 값)을 비교하여 변경 사항을 감지합니다.
리액트 플레이 그라운드에서 체험해보세요.
notion image
NOTE: +1을 클릭하면 모든 컴포넌트가 리렌더링됩니다. 하이드레이션(hydration)을 위한 렌더링 1회 + count의 증가를 위한 렌더링 1회 = 렌더링 2회
NOTE: 위의 링크에서 애플리케이션과 상호작용할 때마다 각 컴포넌트가 리렌더링(count가 증가되어 렌더링)되는 것을 확인할 수 있습니다.
즉, 렌더링 트리에 있는 모든 컴포넌트가 각 상호 작용마다 다시 실행됩니다. 결론은 애플리케이션과 상호 작용하기 전에 모든 컴포넌트를 다운로드해야 한다는 것입니다. 마지막으로 프레임워크의 하이드레이션으로 인해 초기 렌더링 횟수가 증가하는 것을 확인할 수 있습니다.(이는 시작 시 모든 코드가 실행됨을 의미합니다)
NOTE: 이 데모는 SSR/SSG가 아니지만, SSR/SSG를 사용하더라도 동작은 동일합니다.

스벨트

스벨트는 컴파일러를 사용하여 .svelte 파일을 사용자 정의 코드로 변환합니다. 컴파일러는 출력 코드를 생성하는 데 매우 영리하고 효율적입니다.
스벨트 플레이 그라운드에서 체험해보세요.
notion image
초기 렌더링에서 스벨트는 하이드레이션의 일부로 모든 컴포넌트를 실행해야 합니다. 이것이 초기 렌더링 횟수가 1회인 이유입니다. 그러나 이후 상호 작용에서는 횟수가 더 이상 업데이트되지 않습니다. 스벨트가 세분화된 반응성을 갖췄기 때문이라고 생각할 수도 있지만, 실제로는 조금 더 미묘한 차이가 있습니다.
  1. 새 창에서 열기를 클릭하여 디버깅하기 쉽도록 스벨트 애플리케이션을 독립적으로 실행할 수 있는 탭으로 표시합니다.
  1. 개발자 도구를 엽니다.
  1. 생성된 코드로 이동할 수 있도록 소스 맵을 비활성화합니다.
  1. p: 함수를 검색하여 스벨트가 변경 감지를 수행하는 모든 위치를 찾습니다.
  1. 파일 이름을 기준으로 Render: Display와 같이 각각에 로그 포인트를 추가합니다.
스벨트 애플리케이션과 상호 작용할 때 클릭하면 Counter, Wrapper 및 Display는 다시 실행되지만 Incrementor는 실행되지 않습니다.
Render Counter Render Wrapper Render Display
따라서 상호작용으로 인해 컴포넌트가 다시 실행되기는 하지만 스벨트가 리액트 또는 앵귤러보다 확실히 더 세분화된 반응성으로 처리 되어 있습니다. 하지만 중요한 차이점이 있습니다. 스벨트 예시에서는 Incrementor가 다시 실행되지 않았습니다. 이는 개발자의 개입 없이도 스벨트가 가지 치기를 해주므로 중요한 최적화입니다. 리액트 또는 앵귤러에서도 가능하지만 개발자의 더 많은 작업이 필요합니다.(그리고 우리는 "별도의 설치나 구성이 필요없이 바로 사용할 수 있는" 동작에 관심이 있습니다.)
스벨트 컴파일러는 내부에서 더티 체킹(dirty checking)을 매우 효율적으로 수행하고 있습니다. 이를 위해서는 변경이 이루어진 컴포넌트로부터 변경이 전파되는 모든 하위 컴포넌트(이 경우, CounterWrapperDisplay)를 방문해야 합니다.
(번역자 주: 이 글에서 "더티 체킹(dirty checking)"은 데이터의 상태 변화를 감지하여 자동으로 뷰를 업데이트하는 방식을 의미하는 프론트엔드 프레임워크에서 사용되는 개념으로 사용되었습니다.)
즉, 컴파일러가 코드를 매우 효율적으로 만들더라도 코드를 지속적으로 다시 실행해야 한다는 것을 의미하며, 실제로는 성능 병목 현상이 발생하지 않을 것이라고 생각합니다. 하지만 코드가 계속 재실행된다는 사실은 렌더 트리에 있는 모든 컴포넌트를 지연 로드(lazy-loaded)할 수 없다는 것을 의미합니다.

store와 함께 사용한 스벨트

스벨트 컴파일러는 .svelte 파일에서만 작동합니다. 즉, .svelte 파일 외부에서 반응성을 갖고자 하는 경우, 컴파일러에 의존할 수 없으며 대신 스벨트는 store라는 별도의 메커니즘을 제공합니다.
store는 옵저버블(observables)하고 특히 행동 주체(Behavior-Subjects)이므로 현재 값을 가지며 store 구독자에게 동기적으로 값을 전달합니다.
스벨트의 아래쪽 예시는 store와 함께 작성된 동일한 애플리케이션입니다. 해당 애플리케이션과 상호 작용하고 로그 포인트로 계측하면 스벨트가 Display 컴포넌트만 리렌더링하는 것을 볼 수 있습니다. 이는 컴파일러보다 store가 더 효율적이라는 것을 보여주기 때문에 흥미롭습니다. (실행해야 하는 코드가 더 적다는 것을 의미합니다.)
실행할 코드가 적다는 것은 성능에 관한 것이 아니라 실행되지 않는 코드를 클라이언트에 다운로드할 필요가 없다는 사실에 관한 것입니다. 하지만 하이드레이션을 사용하면 시작 시 모든 컴포넌트가 적어도 한 번은 실행되므로 렌더 트리에서 컴포넌트에 대한 코드의 지연 로딩이 불가능해집니다.
NOTE: 이 예제에서는 SSR/SSG를 사용하지 않지만, SSR/SSG를 사용하더라도 결과는 동일합니다.
NOTE: 이 예제에서 볼 수 있듯이 스벨트에는 구문과 런타임 동작이 다른 두 개의 개별 반응성 프리미티브가 있다는 점이 흥미롭습니다. 이는 .svelte 파일에서 .js 파일로 코드를 이동하려면 반응성 리팩터링이 필요할 수 있으며, 따라서 단순한 복사-붙여넣기보다 더 많은 작업이 필요할 수 있다는 의미입니다.

뷰(Vue)

뷰는 컴포넌트를 다시 실행한다는 점에서 스벨트와 유사합니다. 증가(increment) 버튼을 클릭하면 CounterWrapperDisplay가 다시 실행됩니다. 스벨트와 달리 뷰에는 컴파일러 기반 반응성이 없으며, 대신 모든 반응성이 런타임 기반입니다. 뷰는 반응성을 프리미티브 Ref라고 부르며, 나중에 설명할 시그널과 유사합니다.
뷰 플레이 그라운드에서 체험해보세요.
notion image
제가 흥미롭게 생각하는 것은 뷰가 컴포넌트 경계를 넘어 반응성 프리미티브를 전달할 때 작동한다는 점입니다. Counter에서 Display로 Ref를 Wrapper를 통해 전달할 수도 있지만, 대신 뷰는 컴포넌트 경계에서 Ref의 래핑을 해제하고 다시 래핑합니다.
그 결과, Wrapper가 단지 값을 전달하는 용도일 뿐인데도 Display의 리렌더링에 Wrapper가 관여해야 합니다. (나중에 이와 관련하여 다르게 작동하는 퀵(Qwik)과 솔리드(Solid)에 대해 살펴보겠습니다.)
Refs에 대해 알아두어야 할 또 다른 점은 스벨트와 달리 행동 주체(Behavior Subjects)가 아니라는 점입니다. 즉, subscribe API가 없는 대신 렌더링 중에 Ref를 읽음으로써 암시적으로 구독이 생성됩니다.(시그널과 마찬가지로)
이전과 마찬가지로 뷰는 시작 시 하이드레이트해야 하므로 렌더링 트리의 모든 컴포넌트는 시작 시 한 번 실행해야 합니다. 즉, 다운로드해야 하므로 지연 로딩이 어렵습니다.
NOTE: 이 예시에서는 SSR/SSG를 사용하지 않았지만, SSR/SSG를 사용하더라도 결과는 동일합니다.

inject를 활용한 뷰(Vue)

뷰는 Wrapper를 우회하여 Counter에서 Display로 Ref를 전달할 수 있습니다. 이는 두 번째 예시와 같이 provide 및 inject API를 사용하면 가능합니다. 이 경우, 애플리케이션과 상호 작용하면 Display만 다시 실행되므로 더 효율적인 동작입니다.
이 차이점은 .vue 파일에서 컴포넌트 간에 값을 전달하면 Ref가 래핑 해제되고 컴포넌트 경계에서 다시 래핑되지만 provide/inject를 사용하면 이러한 래핑 동작을 우회하고 래핑 해제 없이 직접 Ref를 전달하여 보다 세분화된 반응성에 대한 업데이트를 수행할 수 있습니다.(스벨트 store와 유사합니다)
뷰는 스벨트에서 볼 수 있는 것처럼 반응성 모델이 두 개가 아닌 단일 반응성 모델 Ref만 가지고 있기 때문에 스벨트보다 반응성이 더 뛰어나다고 말할 수 있습니다. .vue 파일에서 코드를 이동하는 경우 반응성 리팩터링이 필요하지 않아야 합니다.

퀵(Qwik)

지금까지는 모든 변경이 컴포넌트 경계에서 발생했습니다. 즉, 변경 사항이 감지되면 최소한 컴포넌트를 다시 실행해야 했습니다. 컴포넌트는 최소한의 작업이었습니다. 하지만 우리는 컴포넌트보다 더 나은 DOM 수준의 반응성을 구현할 수 있습니다.
퀵(Qwik)은 DOM 수준에서 반응성을 표시합니다. 애플리케이션과 상호 작용할 때 시그널은 연결된 컴포넌트가 아니라 DOM 요소에 직접 연결됩니다. 시그널을 업데이트하면 연결된 컴포넌트를 실행하지 않고 DOM을 직접 업데이트합니다. 컴포넌트가 실행되지 않으므로 다운로드할 필요가 없습니다. 따라서 컴포넌트를 실행하지 않는 것보다 다운로드하지 않는 것이 더 많은 비용을 절약할 수 있습니다.
퀵에는 또 다른 흥미로운 동작이 있습니다. 시작 시 하이드레이션 실행이 필요하지 않습니다. 하이드레이션이 없기 때문에 코드를 실행할 필요가 없고, 따라서 코드를 다운로드할 필요도 없습니다.
퀵(Qwik) 플레이 그라운드에서 체험해보세요.
이 예제에서는 클라이언트에서 애플리케이션 코드가 다운로드되거나 실행되지 않습니다.(클릭 핸들러 외부) 이는 퀵이 시그널과 DOM 간의 관계를 충분히 설명할 수 있기 때문입니다. 이 관계는 서버에서 애플리케이션을 실행할 때 추출한 것입니다.(따라서 애플리케이션을 브라우저에서 실행할 필요가 없습니다.)
퀵은 (아직) 시그널 내의 구조적 변경을 설명할 수 없습니다. 따라서 구조적 변경(DOM 노드 추가/제거)의 경우, 퀵은 컴포넌트를 다운로드하고 다시 실행해야 합니다.(이 예제에는 나타나지 않음)

솔리드(Solid)

솔라드는 퀵과 마찬가지로 시그널을 DOM 업데이트에 직접 연결합니다. 하지만 솔리드는 일반 값뿐만 아니라 구조적 변경에 대해서도 이 작업을 수행할 수 있습니다.
솔리드 플레이 그라운드에서 체험해보세요.
솔리드는 컴포넌트를 정확히 한 번만 실행합니다! 컴포넌트는 다시 실행되지 않습니다. 이는 매우 멋진 특성이지만 다른 프레임워크와 마찬가지로 솔리드도 하이드레이션 시 컴포넌트를 정확히 한 번만 실행해야 하므로 애플리케이션 시작 시 모든 컴포넌트를 열심히 다운로드하고 실행해야 한다는 의미입니다.
제가 보기에 솔리드는 반응성이 가장 좋은데, 그 이유는 반응성이 항상 컴포넌트가 아닌 DOM이라는 가장 미세한 형태로 이루어지기 때문입니다. (하지만 여전히 하이드레이션이 필요하기 때문에 코드를 다운로드하고 실행하는 데 시간이 오래 걸립니다.)

값(Values) vs 옵저버블(Observables) vs 시그널(Signals)

일반적으로 세 가지 접근 방식이 있습니다.
  • Values: 어떤 형태의 더티 체킹이 필요합니다. 현재 값과 이전 값을 비교합니다. 앵귤러는 표현식을 비교하고, 리액트는 vDOM을 비교하며, 스벨트는 컴파일러가 수행한 더티 마킹이 있는 표현식을 비교합니다.
  • Observables: 앵귤러에서는 RxJS로, 스벨트에서는 store와 함께 사용됩니다.
  • Signals (Ref): 뷰, 퀵 및 솔리드에서 사용됩니다. 그러나 뷰에서는 컴포넌트에 연결되는 반면, 퀵에서는 일반적으로 DOM에 연결되고 솔리드에서는 항상 DOM에 연결되며 DOM이 더 세분화된 반응성으로 선호됩니다.

컴포넌트 계층 구조

앵귤러, 리액트, 스벨트, 뷰는 상태에 변경 사항을 전파할 때 컴포넌트 계층 구조를 따릅니다. (예, 스벨트와 뷰도 직접 컴포넌트 업데이트를 수행할 수 있지만 이러한 프레임워크의 "기본" 패턴은 아닙니다.) 그리고 이러한 업데이트는 항상 컴포넌트 수준에서 이루어집니다.
퀵과 솔리드는 컴포넌트 계층 구조를 따르지 않고 DOM을 직접 업데이트합니다. 구조 변경에 있어서는 솔리드가 퀵보다 유리합니다. 솔리드는 DOM 업데이트를 수행할 수 있는 반면, 퀵은 트리가 아닌 단일 컴포넌트로 되돌아갑니다.

하이드레이션(Hydration)

퀵은 하이드레이션이 필요 없는 유일한 프레임워크라는 점에서 독특합니다. 다른 프레임워크와 달리 퀵은 시작 시 모든 컴포넌트를 실행하여 상태가 DOM에 연결되는 방식을 학습할 필요가 없습니다. 퀵은 해당 정보를 SSR/SSG의 일부로 직렬화하여 클라이언트에서 재개할 수 있습니다.
이러한 재개 가능성으로 인해 퀵은 시작 시 대부분의 애플리케이션 코드를 다운로드할 필요가 없다는 점에서 고유한 이점을 제공하며, 이는 하이드레이션이 할 수 없는 일입니다. 따라서 솔리드가 퀵보다 더 세분화된 반응성이라고 생각하지만, 퀵의 재개 가능성은 이점을 제공한다고 생각합니다. (시간이 지남에 따라 퀵이 더 세분화된 반응성이 될 수도 있습니다.)

트레이드 오프

거대한 반응성은 그냥 작동한다는 장점이 있습니다. 즉, 반응성 상자에서 벗어나기 어렵다는 뜻입니다. 어떤 방식으로든 데이터를 읽고 변환할 수 있으며 여전히 작동합니다. 반면 시그널에는 규칙이 필요합니다. 규칙을 따르지 않으면 반응성이 깨집니다. 거대한 반응성이 선호되는 것처럼 보일 수 있지만 "그냥 작동"하는 데에는 비용이 발생하며, 그것은 바로 성능입니다. 거대한 반응성의 시스템은 트리를 가지치기하는 최적화 메커니즘을 제공합니다. 반면에 시그널은 이미 최적화되어 있기 때문에 최적화가 필요하지 않습니다. 따라서 거대한 반응성을 잘못 사용하면 앱이 느려지는 반면, 시그널을 잘못 사용하면 앱이 망가진다는 단점이 있습니다.
저는 고장난 앱은 고장난 것이 명백하고 규칙을 따르므로 고치기가 간단하기 때문에 더 선호한다고 주장할 것입니다. 느린 앱은 고칠 것이 한 가지가 아니라 최적화해야 할 것이 많기 때문에 고치기가 어렵습니다. 그리고 최적화를 추가할 때 잘못하면 결국 앱이 망가질 수도 있습니다.
notion image