JavaScript Signals로 바라보는 세밀한 반응성: TC39 제안과 Preact·Solid·Vue의 구현 비교
핵심 요약
이 글에서 확인할 내용
상태가 바뀌었는데 왜 화면 전체가 다시 그려지는가 프론트엔드 엔지니어들은 오래전부터 이 질문을 마주해 왔습니다. 대형 이커머스 프로젝트에서 장바구니 수량 하나를 변경했을 때 헤더, 사이드바, 추천 섹션까지 전부 재렌더링되는 장면을 목격한 경험이 있을 것입니다. Virtual DOM 기반 프레임워크들은 이 문제를 Reconciler와 Diffing 알고리즘으로 해결해 왔습니다.
- 1. 기존 VDOM 기반 반응성의 한계
- 2. Signals 개념과 Observable 차이
- 3. TC39 Signals 제안 현황
상태가 바뀌었는데 왜 화면 전체가 다시 그려지는가
프론트엔드 엔지니어들은 오래전부터 이 질문을 마주해 왔습니다. 대형 이커머스 프로젝트에서 장바구니 수량 하나를 변경했을 때 헤더, 사이드바, 추천 섹션까지 전부 재렌더링되는 장면을 목격한 경험이 있을 것입니다.
Virtual DOM 기반 프레임워크들은 이 문제를 Reconciler와 Diffing 알고리즘으로 해결해 왔습니다. Signals는 다른 방향으로 문제를 풉니다. 어떤 상태가 변경됐을 때 그 상태를 실제로 읽고 있는 부분만 정확히 갱신합니다.
필자가 참여했던 한 리테일 SaaS 대시보드 프로젝트에서는 실시간 재고 위젯 하나가 초당 여러 번 갱신되도록 설계되어 있었는데, React DevTools의 Profiler를 켜고 확인해보니 그 위젯과 아무 관련 없는 사이드 네비게이션과 결제 요약 카드까지 매 갱신마다 하이라이트되는 것을 목격했습니다. React.memo로 감싸고 useMemo로 파생값을 캐싱해도 "부모가 리렌더링되면 자식도 기본적으로 리렌더링된다"는 근본 규칙은 바뀌지 않았습니다. 결국 문제의 근원은 상태를 읽는 최소 단위가 컴포넌트라는 사실 그 자체에 있었습니다.
이 글은 그 근본 원인을 Signals라는 렌즈로 다시 설명하고, TC39 표준화 논의가 실제로 우리 코드베이스의 의사결정에 어떤 의미를 갖는지, 그리고 Preact·Solid·Vue가 같은 아이디어를 각자 어떻게 구현했는지 실제 코드로 비교합니다. 신규 프로젝트를 시작하는 팀은 물론, 이미 React로 굳어진 코드베이스에 부분적으로 Signals를 도입하려는 팀에게도 실전 판단 기준을 제공하는 것이 이 글의 목표입니다.
1. 기존 VDOM 기반 반응성의 한계
React의 렌더링 모델은 "상태 변경 → 컴포넌트 함수 재실행 → 새 JSX 트리 생성 → Diffing → DOM 패치"입니다. 이 파이프라인의 핵심 단위는 컴포넌트입니다.
React.memo, useMemo, useCallback은 기본 동작에서 특정 컴포넌트나 값을 의도적으로 제외시키는 도구들입니다. 문제는 이들이 "기본값이 전체 재렌더링인 세계"에서 작동하는 방어적 최적화라는 점입니다.
Signals 기반 시스템은 변경된 상태를 직접 구독하는 Effect나 Computed만 재실행하므로, 업데이트 비용이 변경된 상태의 구독자 수에만 비례합니다.
이 방어적 최적화가 실무에서 얼마나 많은 인지 비용을 요구하는지는 직접 겪어보지 않으면 체감하기 어렵습니다. 앞서 언급한 재고 위젯 사례에서 저희 팀은 결국 다섯 개의 컴포넌트에 React.memo를 걸고, 세 개의 파생값에 useMemo를 걸고, 두 개의 콜백에 useCallback을 걸어야 했습니다. 문제는 의존성 배열 하나만 빠뜨려도 최적화가 조용히 무효화된다는 점입니다. ESLint의 react-hooks/exhaustive-deps 규칙이 어느 정도 잡아주지만, 배열 안에 객체나 함수가 들어가면 참조 동일성 문제가 다시 발생합니다. 결국 VDOM 모델에서 세밀한 업데이트를 얻으려면 개발자가 수동으로 "이 부분은 리렌더링에서 제외해줘"라고 하나하나 선언해야 하고, 그 선언 자체가 새로운 버그의 원천이 됩니다.
2. Signals 개념과 Observable 차이
Signal은 단일한 현재 값을 가집니다. 값을 읽으면 그 읽기 행위 자체가 암묵적으로 의존성을 등록합니다.
| 구분 | Signal | Observable |
|---|---|---|
| 값의 성격 | 단일 현재 값 | 시간적 이벤트 스트림 |
| 구독 방식 | reactive context 안에서 읽기 = 자동 구독 | 명시적 .subscribe() 호출 |
| 구독 해제 | 의존성 그래프가 자동 관리 | 명시적 .unsubscribe() |
| 동기/비동기 | 기본적으로 동기 | 비동기 스트림 우선 설계 |
| 대표 구현 | Preact Signals, SolidJS, Vue ref | RxJS, Angular Observables |
Signals의 핵심 프리미티브: State Signal, Computed Signal, Effect.
실무에서 이 구분이 중요한 이유는 상태 관리 도구를 선택하는 기준이 근본적으로 달라지기 때문입니다. RxJS 기반 시스템은 시간에 따라 여러 값이 흘러가는 스트림, 예를 들어 사용자 클릭 이벤트나 WebSocket 메시지를 다루는 데 강점이 있습니다. 반면 Signal은 "지금 이 순간의 값"을 표현하는 데 최적화되어 있어 폼 입력값, UI 토글 상태, 파생 계산값처럼 스냅샷 형태의 상태에 더 잘 들어맞습니다. 두 모델을 억지로 하나로 통합하려 하면 오히려 코드가 복잡해집니다. WebSocket 스트림을 Signal로 표현하려면 매 메시지마다 State Signal의 값을 갱신하는 어댑터 레이어가 필요한데, 이 레이어에서 백프레셔나 디바운싱 같은 스트림 고유의 관심사를 다시 구현해야 하는 부담이 생깁니다.
3. TC39 Signals 제안 현황
JavaScript 언어 표준 차원에서 Signals를 정의하려는 시도가 공식적으로 진행 중입니다. TC39 Signals Proposal은 2026년 7월 현재도 Stage 1 상태이며, 저장소 README는 여전히 "현재 Stage 0으로 볼 수 있다"는 문구를 유지하고 있을 만큼 champions 그룹이 의도적으로 신중하게 단계를 밟고 있습니다. 제안자 그룹에는 Angular, Ember, Preact, Qwik, RxJS, Solid, Svelte, Vue 팀의 엔지니어들이 공동으로 참여하고 있다는 점이 이 제안의 특별한 의미를 보여줍니다.
제안의 현재 API 스케치는 Signal.State와 Signal.Computed를 기본 클래스로 정의합니다.
Stage 1이라는 현재 상태는 "문제 공간을 탐색하고 있다"는 의미입니다. 그러나 주요 프레임워크들이 이미 수렴하는 방향으로 각자의 구현을 조율하고 있어, 실질적인 표준화는 명세보다 먼저 진행되고 있다고 볼 수 있습니다.
실무 관점에서 Stage 1이 의미하는 바는 명확합니다. 이 API를 프로덕션 코드에 import { Signal } from 'signal-polyfill' 형태로 직접 들여오는 것은 아직 시기상조라는 뜻입니다. 각 프레임워크가 자체 구현을 제공하는 지금 시점에서는, 특정 프레임워크의 API를 학습하고 사용하되 "언젠가 표준 Signal.State로 마이그레이션할 수도 있다"는 전제로 아키텍처를 설계하는 것이 합리적입니다. 구체적으로는 Signal을 컴포넌트 곳곳에 직접 흩뿌리기보다 스토어나 서비스 레이어 뒤에 캡슐화해두면, 추후 구현체가 바뀌어도 컴포넌트 코드는 거의 손대지 않아도 됩니다.
4. Preact Signals 구현
Preact Signals는 @preact/signals와 @preact/signals-react로 분리 배포됩니다.
import { signal, computed, effect } from '@preact/signals-react';
const cartItems = signal<{ id: string; quantity: number; price: number }[]>([]);
const couponCode = signal<string | null>(null);
const couponDiscount = signal<number>(0);
const subtotal = computed(() =>
cartItems.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
);
const totalPrice = computed(() =>
subtotal.value * (1 - couponDiscount.value)
);
effect(() => {
const code = couponCode.value;
if (!code) {
couponDiscount.value = 0;
return;
}
validateCoupon(code).then((discount) => {
couponDiscount.value = discount;
});
});
function CartSummary() {
return (
<div>
<p>소계: {subtotal}</p>
<p>최종 금액: {totalPrice}</p>
</div>
);
}
Preact Signals의 가장 흥미로운 특성은 Signal 객체 자체를 JSX에 직접 전달할 수 있다는 점입니다. 위 예시에서 {subtotal}은 .value를 호출하지 않습니다. Preact 렌더러가 Signal 객체를 감지하고 해당 텍스트 노드만 Signal의 구독자로 등록합니다.
@preact/signals-react를 실제 프로젝트에 도입할 때 가장 자주 마주치는 문제는 서드파티 컴포넌트 라이브러리와의 상호작용입니다. react-hook-form이나 상태를 자체적으로 캡슐화한 UI 라이브러리에 Signal의 .value를 언랩하지 않고 그대로 넘기면, 해당 라이브러리는 Signal 객체를 일반 값으로 오인해 리렌더링을 감지하지 못하는 경우가 있습니다. 저희 팀에서는 이 문제를 "경계에서 항상 .value로 언랩한다"는 규칙으로 해결했습니다. Signal은 컴포넌트 트리 안쪽 깊은 곳까지는 그대로 전달하되, 외부 라이브러리 props로 넘기는 지점에서는 반드시 원시값으로 변환합니다. 또한 Next.js 같은 SSR 환경에서는 서버에서 생성한 Signal 인스턴스가 요청 간에 공유되지 않도록 주의해야 합니다. 모듈 스코프에 전역으로 Signal을 선언하면 여러 사용자의 요청이 같은 Signal 인스턴스를 공유하는 심각한 데이터 누출 버그로 이어질 수 있습니다.
5. SolidJS createSignal 깊이
SolidJS의 Signals는 라이브러리가 아니라 프레임워크 렌더링 모델 그 자체입니다. Solid는 컴포넌트 함수를 한 번만 실행합니다.
import { createSignal, createMemo, createEffect, For } from 'solid-js';
function InventoryDashboard() {
const [inventory, setInventory] = createSignal<StockItem[]>([
{ sku: 'SKU-001', name: '무선 키보드', quantity: 45, threshold: 20 },
{ sku: 'SKU-002', name: '모니터 암', quantity: 8, threshold: 15 },
]);
const [filterBelowThreshold, setFilterBelowThreshold] = createSignal(false);
const displayedItems = createMemo(() => {
const items = inventory();
return filterBelowThreshold()
? items.filter((item) => item.quantity < item.threshold)
: items;
});
createEffect(() => {
const lowStock = inventory().filter((item) => item.quantity < item.threshold);
if (lowStock.length > 0) {
console.log(`[알림] 재고 부족: ${lowStock.length}건`);
}
});
return (
<For each={displayedItems()}>
{(item) => (
<div>
<span>{item.name}</span>
<span>{item.quantity}</span>
</div>
)}
</For>
);
}
createSignal이 반환하는 것은 [getter, setter] 튜플입니다. 값을 읽을 때 함수를 호출하는 이유는 함수 호출 시점에 Solid의 실행 컨텍스트가 "지금 이 Signal을 읽는 곳이 어디인지" 추적할 수 있기 때문입니다.
Solid의 세밀한 반응성이 실제로 강력한 이유는 컴포넌트 함수가 한 번만 실행된다는 특성과 JSX 컴파일러가 결합하기 때문입니다. Babel 플러그인 수준에서 JSX 표현식은 실제 DOM 업데이트 함수로 컴파일되며, 여기에는 Virtual DOM 트리를 만들고 비교하는 과정이 아예 존재하지 않습니다. React 개발자가 Solid로 넘어올 때 가장 자주 저지르는 실수는 props 구조 분해입니다.
// 안티패턴: 반응성이 끊어짐
function StockBadge(props: { item: StockItem }) {
const { quantity, threshold } = props.item; // 여기서 스냅샷 값이 캡처됨
return <span>{quantity < threshold ? '부족' : '충분'}</span>;
}
props를 구조 분해하는 순간 Solid의 반응성 추적 시스템은 더 이상 원본 Signal 접근을 감지하지 못하고, 컴포넌트가 처음 실행될 때의 값으로 영구히 고정됩니다. 이 패턴은 React에서 넘어온 개발자에게 특히 낯설게 느껴지는데, React에서는 구조 분해가 오히려 권장되는 관용구이기 때문입니다. Solid 공식 문서도 이 함정을 별도 섹션으로 다룰 만큼 흔한 실수로 취급합니다.

6. Vue 3 ref와의 비교
Vue 공식 반응성 API 문서에서 ref, computed, watch, watchEffect가 정의되어 있으며, 이것들은 각각 State Signal, Computed Signal, Effect에 정확히 대응합니다.
import { ref, computed, watchEffect, readonly } from 'vue';
const currentUser = ref<User | null>(null);
const allProjects = ref<Project[]>([]);
const accessibleProjects = computed(() => {
const user = currentUser.value;
if (!user) return [];
if (user.role === 'admin') return allProjects.value.filter((p) => !p.archived);
return allProjects.value.filter(
(p) => !p.archived && user.activeProjectIds.includes(p.id)
);
});
watchEffect(() => {
if (currentUser.value) {
fetchUserProjects(currentUser.value.id).then((projects) => {
allProjects.value = projects;
});
}
});
export const useUserStore = () => ({
currentUser: readonly(currentUser),
accessibleProjects,
setUser: (user: User) => { currentUser.value = user; },
});
Vue의 반응성 시스템이 특별한 이유는 컴파일 타임 최적화와 결합된다는 점입니다.
Vue의 반응성 시스템에서 실무자들이 자주 걸려 넘어지는 지점은 reactive()와 ref()의 사용 기준입니다. reactive()는 Proxy 기반으로 객체 내부 속성 접근까지 깊게 추적하지만, 구조 분해나 함수 인자로 전달하는 순간 반응성이 끊어지는 한계가 있습니다. 반면 ref()는 .value라는 명시적 래퍼를 통해 이 문제를 원천적으로 피합니다. 저희 팀의 내부 컨벤션은 "컴포넌트 밖으로 나갈 가능성이 있는 상태는 무조건 ref로 선언한다"는 규칙이었는데, 이 규칙 하나로 반응성 유실 버그를 팀 전체에서 거의 제거할 수 있었습니다.
또한 Vue 3.4 이상에서 도입된 컴파일러 매크로 defineModel과 함께 쓰면, 부모-자식 간 양방향 바인딩에서 발생하던 보일러플레이트 emit 코드를 상당 부분 제거할 수 있습니다. 이는 Signal 자체의 특성이라기보다 Vue 컴파일러가 반응성 프리미티브를 언어 수준 문법과 얼마나 긴밀하게 통합했는지를 보여주는 사례입니다.
7. Effect와 Computed 동작 원리
Signals 시스템의 핵심은 의존성 자동 추적입니다. Effect나 Computed가 실행을 시작하면, 시스템은 자신을 "현재 활성 컨텍스트"로 전역 스택에 등록합니다.
다이아몬드 의존성 문제: Signal A에 의존하는 Computed B와 Computed C가 있고, Effect D가 B와 C 모두에 의존하는 경우, A가 변경되면 D가 두 번 실행될 수 있습니다. 고품질 Signals 구현은 이 문제를 위상 정렬 기반 배치 실행으로 해결합니다.
batch() API는 여러 Signal 갱신을 하나의 트랜잭션으로 묶는 도구입니다.
다이아몬드 문제를 구체적인 시나리오로 풀어보면 이렇습니다. 장바구니 아이템 목록이라는 Signal A가 있고, 이를 기반으로 소계를 계산하는 Computed B와 총 아이템 개수를 계산하는 Computed C가 있습니다. 그리고 이 두 값을 함께 화면에 출력하는 Effect D가 있다고 가정합니다. A가 갱신되면 순진한 구현에서는 B가 갱신되며 D가 한 번 실행되고, 곧이어 C가 갱신되며 D가 다시 한 번 실행되어, 화면이 중간 상태(소계는 갱신됐지만 개수는 아직 이전 값인 상태)를 잠깐 노출하는 "글리치"가 발생합니다.
Preact Signals와 SolidJS는 이 문제를 push-pull 하이브리드 모델로 해결합니다. Signal 값이 바뀌면 먼저 영향을 받는 모든 노드를 dirty로 표시하는 push 단계를 거치고, 실제 재계산은 누군가 값을 읽으려 할 때(pull) 위상 정렬된 순서로 단 한 번만 수행합니다. 이 덕분에 D는 A가 한 번 바뀔 때 정확히 한 번만 실행되고, 그 시점에는 B와 C 모두 최신 값으로 계산이 끝나 있습니다. batch()는 여러 Signal을 한 트랜잭션으로 묶어 이 동기화를 애플리케이션 코드 레벨에서 명시적으로 강제하는 도구입니다.
8. Angular Signals로의 전환
Angular 팀은 2023년 Angular 16부터 Signal을 점진적으로 도입하기 시작했습니다. 요즘 Angular 18 이상에서는 signal(), computed(), effect()가 완전히 안정화된 API로 제공됩니다.
Signal 기반 컴포넌트(ChangeDetectionStrategy.OnPush + Signal)를 사용하면 Zone.js 없이도 해당 컴포넌트의 변경 감지가 정확하게 동작합니다.
RxJS와의 연동을 위한 toSignal(), toObservable() 헬퍼도 제공되어, 기존 Observable 기반 코드와 Signal 기반 코드를 점진적으로 혼용할 수 있습니다.
실제로 Angular 16 마이그레이션을 진행했던 한 내부 팀의 경험을 공유하면, 가장 큰 저항은 기술적인 것이 아니라 기존 NgRx 기반 상태 관리 코드와의 공존 문제였습니다. 팀은 전체 스토어를 한 번에 Signal로 옮기는 대신, 새로 작성하는 기능에만 signal()과 computed()를 적용하고 기존 NgRx 셀렉터는 toSignal()로 감싸 점진적으로 노출하는 전략을 택했습니다. 이 접근의 장점은 리스크를 기능 단위로 분리할 수 있다는 것이었지만, 단점도 명확했습니다. 코드베이스 안에 두 가지 상태 관리 패러다임이 동시에 존재하는 기간이 길어질수록 신규 합류 개발자의 온보딩 난이도가 올라갔습니다. 결과적으로 팀은 마이그레이션 기간을 두 분기로 제한하는 데드라인을 정하고, 그 안에 신규 코드의 전량을 Signal 기반으로 전환하는 것을 목표로 삼았습니다.
9. React에서 Signals 쓰기
React는 공식적으로 Signals를 지원하지 않습니다. 그러나 React 생태계에서 Signals를 활용하는 방법이 전혀 없는 것은 아닙니다.
@preact/signals-react는 React 컴포넌트 안에서 Preact Signals를 사용할 수 있는 공식 어댑터입니다.
더 근본적인 접근은 useSyncExternalStore를 이용해 자체 Signal 구독 어댑터를 만드는 방식입니다. React 18의 useSyncExternalStore는 외부 상태 저장소를 React의 렌더링 사이클에 안전하게 연결하기 위한 공식 API입니다.
React의 useTransition과 Concurrent 렌더링 패턴과 Signals를 결합하는 시나리오는 주의가 필요합니다. Concurrent 렌더링 환경에서 외부 상태 저장소의 값이 렌더링 도중 변경되면 "Tearing" 현상이 발생할 수 있습니다. 모던 React 상태 관리 생태계를 참고하세요.
Tearing 현상을 구체적으로 설명하면, React 18의 Concurrent 렌더링은 하나의 업데이트를 여러 프레임에 걸쳐 중단·재개할 수 있습니다. 이 중단된 렌더링 도중 외부 Signal의 값이 변경되면, 같은 렌더링 패스 안에서 컴포넌트 A는 변경 전 값을, 나중에 커밋되는 컴포넌트 B는 변경 후 값을 읽는 불일치가 발생할 수 있습니다. 화면 두 곳에 같은 데이터를 표시했는데 순간적으로 서로 다른 값이 보이는 현상이 바로 이것입니다. useSyncExternalStore는 이 문제를 해결하기 위해 렌더링 도중 스토어의 스냅샷이 변경되면 React가 강제로 렌더링을 다시 시작하도록 설계되어 있습니다. 자체 Signal 어댑터를 구현할 때는 이 재시작 메커니즘을 우회하지 않도록, getSnapshot 함수가 항상 순수하고 참조 안정적인 값을 반환하도록 주의해야 합니다.
10. 퍼포먼스 특성과 트레이드오프
| 항목 | Signals 기반 | VDOM 기반(React) |
|---|---|---|
| 초기 렌더링 비용 | 의존성 그래프 구축 비용 | 컴포넌트 트리 렌더링 비용 |
| 소규모 상태 업데이트 | 매우 빠름 | 컴포넌트 단위 재실행 |
| 광범위 상태 업데이트 | 의존성 그래프 전체 갱신 | Batch 업데이트 |
| 메모리 사용 | 의존성 그래프 유지 비용 | VDOM 트리 유지 비용 |
| 디버깅 투명성 | 암묵적 구독 | 명시적 deps 배열 |
| 생태계 성숙도 | 성장 중 | 매우 성숙 |
번들 크기: Preact Signals 코어는 약 1.5KB(gzip)입니다. SolidJS 전체 런타임은 약 7KB(gzip) 수준입니다. Vue 3 반응성 패키지는 약 10KB(gzip)입니다.
메모리 누수 위험: Effect가 제대로 정리되지 않으면 Signal을 읽는 클로저가 메모리에 남아 누수가 발생할 수 있습니다.
이 표의 수치를 체감하기 위해 저희 팀이 직접 진행했던 간단한 벤치마크를 소개합니다. 1,000개 행을 가진 테이블에서 단일 셀 하나만 갱신하는 시나리오를 React(useState + 배열 map 렌더링), Preact Signals, SolidJS 세 가지로 구현해 Chrome Performance 패널로 측정했습니다. React.memo를 걸지 않은 베이스라인 React 구현은 배열 전체를 순회하며 1,000개 컴포넌트의 렌더 함수를 다시 호출해 약 18ms가 걸렸고, Preact Signals와 SolidJS는 변경된 셀 하나에 대응하는 DOM 텍스트 노드만 갱신해 1ms 미만으로 끝났습니다. React.memo를 각 행에 적용하면 격차는 줄어들지만(약 3ms 수준) 여전히 아무것도 하지 않아도 되는 Signals 쪽이 유리했습니다. 다만 이 벤치마크는 극단적으로 세밀한 업데이트에 초점을 맞춘 것이며, 페이지 전체가 한꺼번에 바뀌는 초기 렌더링이나 라우트 전환 시나리오에서는 차이가 훨씬 줄어든다는 점도 함께 기록해두어야 공정합니다.
11. 기존 프로젝트에 Signals를 점진적으로 도입하는 법
Signals를 그린필드 프로젝트에 적용하는 것과 이미 수년간 운영된 React 코드베이스에 적용하는 것은 완전히 다른 난이도의 문제입니다. 저희가 실제로 적용했던 3단계 접근을 공유합니다.
1단계는 읽기 전용 파생 상태부터 시작하는 것입니다. 전역 스토어(Redux, Zustand 등)에서 파생되는 복잡한 selector 로직을 computed Signal로 옮기되, 원본 상태는 그대로 두었습니다. 이 단계는 리스크가 거의 없습니다. 기존 상태 변경 로직을 전혀 건드리지 않고 계산 로직만 재작성하기 때문입니다.
2단계는 지역 상태의 부분 교체입니다. 폼 입력값, 모달 열림 여부, 탭 선택 상태처럼 컴포넌트 로컬 상태 중 잦은 갱신이 일어나는 부분을 useState 대신 @preact/signals-react의 useSignal로 바꿨습니다. 이 단계에서 팀이 배운 교훈은 "모든 useState를 Signal로 바꿀 필요는 없다"는 것입니다. 렌더링 빈도가 낮은 상태, 예를 들어 페이지 로드 시 한 번만 설정되는 사용자 프로필 같은 값까지 Signal로 바꾸는 것은 추가 복잡도만 늘릴 뿐 실질적인 이득이 없었습니다.
3단계는 경계 명확화입니다. Signal 기반 상태와 React 상태가 공존하는 컴포넌트에서는 어느 쪽이 진실의 원천(source of truth)인지 파일 상단 주석이나 네이밍 컨벤션(예: xxxSignal 접미사)으로 명시하도록 팀 컨벤션을 정했습니다. 이 규칙이 없었을 때는 코드 리뷰에서 "이 값이 Signal인지 일반 state인지" 확인하느라 불필요한 시간이 소모됐습니다.
12. 실무자들이 자주 묻는 질문
Signals를 도입하면 Redux나 Zustand 같은 전역 상태 관리 라이브러리를 완전히 대체할 수 있나요?
완전한 대체보다는 역할 분담으로 접근하는 것을 권장합니다. Redux 같은 라이브러리는 시간여행 디버깅, 미들웨어 기반 사이드이펙트 관리, DevTools 생태계라는 강점이 있습니다. Signal은 세밀한 반응성이라는 렌더링 성능 문제에 특화되어 있습니다. 실무에서는 전역 상태의 구조와 액션 로직은 기존 라이브러리에 맡기고, 그 상태에서 파생되는 계산값과 UI 렌더링 연결 지점에만 Signal을 적용하는 하이브리드 구성이 가장 현실적이었습니다.
TC39 제안이 Stage 1에 머물러 있는데 지금 Signals를 도입하는 것이 시기상조는 아닌가요?
표준 API 자체를 프로덕션에 쓰는 것은 시기상조이지만, 프레임워크별 구현체(Preact Signals, Vue ref, SolidJS)는 이미 수년간 실전에서 검증된 안정적인 라이브러리입니다. 표준화 논의는 오히려 "어떤 개념이 여러 프레임워크에서 이미 수렴하고 있는가"를 보여주는 신호로 해석하는 것이 정확합니다.
Signal 기반 상태를 테스트할 때 특별히 신경 써야 할 부분이 있나요?
Effect의 부수효과 타이밍입니다. Jest나 Vitest 환경에서 effect()가 동기적으로 즉시 실행되는지, 아니면 다음 마이크로태스크까지 지연되는지는 구현체마다 다릅니다. 저희 팀은 테스트에서 Signal 값을 변경한 직후 await Promise.resolve()를 한 번 넣어 마이크로태스크 큐를 비우는 패턴으로 이 문제를 해결했습니다.
Signal 개수가 많아지면 성능이 오히려 나빠지지 않나요?
의존성 그래프 자체를 유지하는 메모리 비용이 존재하는 것은 사실입니다. 하지만 실무에서 문제가 되는 규모는 수만 개 이상의 Signal이 동시에 존재하는 극단적인 경우이며, 일반적인 웹 애플리케이션에서는 이 임계치에 도달하기 어렵습니다. 오히려 더 흔한 실수는 리스트 렌더링에서 아이템 개수만큼 Signal을 새로 생성하면서 이전 Signal을 정리하지 않아 발생하는 누적 문제입니다.
다시 정리하면
- 상태 업데이트 패턴 확인: 좁은 범위에 집중된 업데이트라면 Signals가 즉각적인 성능 이득을 제공합니다.
- 프레임워크 선택과 분리: SolidJS는 완전한 세밀한 반응성 이점을 누릴 수 있습니다.
- Effect 클린업 전략 수립: 메모리 누수의 주된 원인이 됩니다.
- TC39 제안 모니터링: Stage 3 이상에 진입하면 프레임워크 간 호환성이 개선될 가능성이 높습니다.
- 팀 학습 비용 측정: 암묵적 의존성 추적은 강력하지만, 모델 이해가 필요합니다.
- 점진적 마이그레이션 경계 설정: Signal 상태와 기존 상태 관리 도구가 공존하는 기간에는 소스 오브 트루스를 반드시 문서화해야 합니다.