Prologue: 리렌더링의 오랜 피로감
대학시절 역사 연구 모임에서 학기말 발표를 준비할 때였습니다. 아주 사소한 사실 한 구절을 수정했을 뿐인데, 연결된 모든 발표자료 슬라이드의 번호와 개요를 전부 수동으로 다시 고치고 확인해야 하는 비효율이 무척 괴로웠습니다.
이후 리액트(React)를 배우고 프론트엔드 상태 관리를 다루면서, 묘하게 그 시절의 비효율이 다시 떠올랐습니다. 바로 리액트의 핵심이자 동시에 아킬레스건인 **'컴포넌트 리렌더링(Re-rendering)'**의 흐름 때문이었습니다.
우리가 자주 쓰는 전역 상태 관리 라이브러리인 Zustand나 Redux는 훌륭합니다. 하지만 이들은 상태가 바뀔 때 **'해당 상태를 참조하는 컴포넌트 함수 전체를 통째로 다시 실행'**시키는 방식으로 작동합니다.
예를 들어, 페이지 우측 상단의 작은 장바구니 숫자 count 하나가 올라갔을 뿐인데, 그 숫자가 적힌 헤더 컴포넌트 전체와 그 하위 요소들까지 무의미하게 가상 DOM 비교 연산을 다시 수행하게 됩니다.
"컴포넌트 단위의 거대한 리렌더링 없이, 오직 값이 변한 그 HTML 텍스트 노드 하나만 핀포인트로 업데이트할 수는 없을까?" 이 물음에 대한 모던 웹 생태계의 가장 뜨거운 해답이 바로 Signal입니다. SolidJS, Svelte 5, 그리고 Preact 등이 도입한 이 혁신적인 패러다임을 리액트에 결합하고 최적화를 이뤄낸 과정을 정리해 보았습니다.
Concept: 리액트의 상태 전파 vs Signal의 미세 반응형(Fine-grained Reactivity)
전통적인 리액트의 렌더링 방식과 Signal의 미세 반응형(Fine-grained Reactivity)은 동작 철학부터가 완전히 다릅니다.
1. 리액트의 '하향식 풀-렌더링(Pull-rendering)'
리액트에서는 어떤 상태가 변경되면 컴포넌트 트리의 상단에서부터 하단으로 렌더링 함수를 실행(Pull)시킵니다.
State변경 ->Component A함수 재실행 -> 가상 DOM(Virtual DOM) 트리 재성공 -> 구버전 가상 DOM과 비교(Reconciliation) -> 달라진 부분만 실제 DOM에 반영(Commit).- 이 과정은 매우 직관적이고 안전하지만, 상태의 변경 범위가 넓어질수록 CPU의 가상 DOM 연산 부담이 가중됩니다. 이를 막기 위해 우리는
useMemo,useCallback,React.memo같은 복잡한 최적화 코드를 온 동네에 도배해야 했습니다.
2. Signal의 '핀포인트 푸시-렌더링(Push-rendering)'
Signal은 가상 DOM의 화려한 비교 연산 없이, 변경된 값이 실제 DOM 노드로 다이렉트하게 전달(Push)되는 패러다임입니다.
- 구조: Signal은 단순히 값을 담는 상자가 아닙니다. 그 값을 누가 읽어갔는지(Subscriber)를 추적하는 똑똑한 메커니즘을 가집니다.
- 동작: 어떤 컴포넌트의 특정 DOM 텍스트 노드가
signal.value를 참조하여 그려지면, 그 Signal은 컴포넌트 전체를 리렌더링하지 않고 해당 DOM 노드의 텍스트 속성만 브라우저 레벨에서 다이렉트로 업데이트합니다. - 결과적으로, 컴포넌트 함수는 최초 딱 한 번만 실행되고, 이후 값의 변화는 DOM의 국소 부위로만 핀포인트 전파됩니다.
Deep Dive: Preact Signals가 리액트에서 마법을 부리는 원리
리액트 공식 API에는 아직 공식적인 Signal이 없습니다. 하지만 Preact 팀이 개발한 @preact/signals-react 라이브러리를 사용하면 리액트 프로젝트에서도 이 강력한 무기를 부작용 없이 활용할 수 있습니다.
리액트 컴포넌트 전체가 다시 실행되지 않는데 화면이 어떻게 바뀌는 걸까요? 그 뒤편에는 흥미로운 내부 작동 원리가 숨겨져 있습니다.
1. JSX 바인딩 가로채기
리액트 컴포넌트의 리턴문 안에 Signal 객체를 다음과 같이 바인딩하면:
const count = signal(0);
function Counter() {
return <div>{count}</div>; // string이 아닌 Signal 객체 자체를 전달
}
Preact Signals 플러그인은 컴포넌트 렌더링 과정에서 리턴된 JSX를 분석합니다. 자식 노드로 들어온 count가 Signal 객체임을 확인하면, 그 자리에 **보이지 않는 가볍고 아주 미세한 더미 컴포넌트(Reactive Node)**를 샌드위치처럼 몰래 끼워 넣습니다.
이후 count.value가 변경되면 리액트는 전체 Counter 컴포넌트를 건드리지 않고, 오직 이 샌드위치 사이에 숨어있던 미세 더미 컴포넌트 한 조각만 콕 집어서 렌더링을 업데이트합니다.
2. 컴포넌트 리렌더링 제로(0)의 실체
이 마법 덕분에 개발자 도구의 Profiler를 켜고 렌더링 현황을 감시해 보면, 카운터 숫자가 미친 듯이 올라가도 Counter 컴포넌트의 렌더링 횟수는 **'최초 1회'**에 영원히 멈춰있는 신기한 광경을 목격할 수 있습니다. 불필요한 연산이 말 그대로 물리적 제로(0)에 수렴하게 되는 것입니다.
Practical: 리액트 프로젝트에 Preact Signals 도입하고 최적화하기
그럼 실제로 간단한 전역 상태 공유 시나리오를 통해 Zustand 대신 Signals를 적용하는 코드를 작성해 보겠습니다.
1. 설치 및 Babel/Vite 플러그인 추가
반응형 바인딩을 자동으로 가로채기 위해 패키지를 설치하고 빌드 플러그인을 설정해야 합니다.
npm install @preact/signals-react
Vite 환경이라면 vite.config.ts 파일에 프리셋 플러그인을 넣어주어야 올바르게 작동합니다.
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [
react({
babel: {
// Signals 반응형 분석을 돕는 바벨 플러그인 탑재
plugins: [["@preact/signals-react/babel"]],
},
}),
],
});
2. Global Signal Store 설계
여러 컴포넌트가 함께 쓸 전역 스토어를 설계해 보겠습니다. Zustand나 Redux 같은 보일러플레이트 없이 순수 자바스크립트 객체 스타일로 우아하게 선언 가능합니다.
// src/store/todoStore.ts
import { signal, computed } from "@preact/signals-react";
export interface Todo {
id: number;
text: string;
completed: boolean;
}
// 1. 상태 선언 (Primitive or Object)
export const todosSignal = signal<Todo[]>([
{ id: 1, text: "역사 논문 초고 다듬기", completed: false },
{ id: 2, text: "리액트 19 소스코드 분석", completed: true },
]);
// 2. Computed State 선언 (다른 Signal에 의존하여 자동으로 계산되는 파생 상태)
export const completedCount = computed(() => {
return todosSignal.value.filter(todo => todo.completed).length;
});
// 3. Action 정의 (상태를 변화시키는 순수 함수)
export const addTodo = (text: string) => {
todosSignal.value = [
...todosSignal.value,
{ id: Date.now(), text, completed: false }
];
};
export const toggleTodo = (id: number) => {
todosSignal.value = todosSignal.value.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
);
};
3. 컴포넌트 결합
생성한 스토어를 컴포넌트에서 바인딩하는 방법은 극도로 직관적입니다. 별도의 Hook 호출도 없고, useSelector 같은 셀렉터 함수도 필요 없습니다. 그저 임포트해서 .value를 읽거나 Signal 객체 자체를 렌더링 트리에 박아 넣으면 끝입니다.
// src/components/TodoStats.tsx
import React from "react";
import { completedCount, todosSignal } from "../store/todoStore";
export function TodoStats() {
// 이 컴포넌트는 전체 할 일 개수와 완료 개수가 변해도 '자신은' 절대 리렌더링되지 않습니다!
// 오직 브라우저 화면의 숫자 텍스트만 핀포인트 업데이트됩니다.
return (
<div className="p-4 rounded-xl bg-slate-800 text-white">
<h3 className="text-lg font-bold">Todo 통계</h3>
<p>전체 할 일: {todosSignal.value.length}개</p>
<p>완료한 할 일: {completedCount}개</p>
</div>
);
}
Epilogue: 가상 DOM을 넘어선 진화
대학 시절 발표 슬라이드의 한 문장을 수정하기 위해 전체 슬라이드 흐름을 일일이 전전긍긍하며 검사하던 피로감에서 벗어나, 이제는 값이 변한 그 한곳만 정교하게 바뀌는 반응형 아키텍처를 프론트엔드 코드에 안착시켰습니다.
리액트가 제시한 가상 DOM 패러다임은 지난 10년간 웹 생태계를 평정했습니다. 하지만 프론트엔드 환경은 더 정교하고 극단적인 프레임 레이트 성능을 요구하는 시대로 진화하고 있습니다.
Zustand와 같은 전통적인 렌더링 구독 모델이 주는 안정성과, Signals가 약속하는 미세 단위의 최적화 효율성을 프로젝트의 성격과 복잡도에 따라 유연하게 배합하여 다룰 수 있는 시야를 갖추는 것. 렌더링 굴레의 근본적인 한계를 뚫고 사용자와 개발자 모두에게 가장 쾌적한 런타임을 제공하는 일, 그것이 바로 모던 웹 프론트엔드 개발자가 지향해야 할 장인정신의 실체입니다.