본문 바로가기
Frontend2026년 9월 5일20분 읽기

시그널(Signals) 완벽 정리 — React·Solid·Svelte 5·Angular가 반응성을 다시 짠 이유

YS
김영삼
조회 5
시그널(Signals) 완벽 정리 — React·Solid·Svelte 5·Angular가 반응성을 다시 짠 이유

시그널은 값이 바뀌면 그 값을 실제로 읽는 곳만 정확히 다시 실행되게 만드는 반응성 원시 타입이다. 컴포넌트 전체를 다시 그리고 이전 트리와 비교하는 가상DOM 대신, 의존성 그래프를 따라 필요한 계산과 DOM 갱신만 세밀하게(fine-grained) 일으킨다.

Solid가 대중화하고 Svelte 5와 Angular가 정식 채택하면서 2023~2025년 프런트엔드의 공용어가 됐다. 유독 React만 시그널 대신 컴파일러로 같은 문제를 푼다. 이 글은 네 프레임워크가 signal·computed·effect라는 같은 이름 아래 서로 다른 선택을 한 지점을 실제 코드로 비교한다.

몇 년 전만 해도 프런트엔드 성능 이야기는 곧 가상DOM 이야기였다. 그런데 요즘 채용 공고나 컨퍼런스 슬라이드를 보면 죄다 ‘시그널’이다. Solid, Svelte 5, Angular, Vue Vapor, Qwik까지. 처음엔 나도 ‘또 새 유행어인가’ 싶었는데, 뜯어보니 이건 십수 년 묵은 아이디어가 제대로 무기를 갖추고 돌아온 쪽에 가까웠다. 왜 지금 다시 뜨는지, 프레임워크마다 뭐가 다른지 정리해 둔다.

프레임워크시그널 API정식 도입렌더 방식
SolidcreateSignal / createMemo / createEffect1.0 (2021)가상DOM 없음, 세밀한 반응성
Svelte 5$state / $derived / $effect (룬)5.0 (2024-10)컴파일러 + 시그널
Angularsignal / computed / effectv16 프리뷰(2023) → v17+ 안정Zone.js 탈피, 세밀한 변경 감지
React내장 없음 (Preact Signals·외부 라이브러리)미채택 — React Compiler로 대체가상DOM + 컴파일러 메모이제이션

시그널이란 무엇인가 — 30초 정의

시그널은 값을 읽는 함수(getter)와 쓰는 함수(setter)로 감싼 상태다. 핵심은 두 가지 자동 동작이다. 값을 ‘읽으면’ 지금 실행 중인 계산이 그 시그널의 구독자로 등록되고, 값을 ‘쓰면’ 등록된 구독자만 정확히 다시 실행된다. 개발자가 의존성 배열을 손으로 적을 필요가 없다. 이게 useEffect의 deps 배열과 결정적으로 다른 지점이다.

처음 Solid를 만졌을 때 가장 낯설었던 건 count가 값이 아니라 함수라는 점이었다. count가 아니라 count()로 읽어야 한다. 이유는 단순하다. 함수 호출이 일어나는 그 순간 ‘누가 이 값을 읽고 있는가’를 추적해야 하기 때문이다. 값을 그냥 변수로 노출하면 읽는 시점을 붙잡을 수가 없다. 그래서 Solid·Angular는 getter 호출 문법을, Svelte 5는 컴파일러로 이 추적 코드를 자동 주입하는 방식을 택했다.

// Solid — 시그널의 본질
const [count, setCount] = createSignal(0);

count();        // 읽기 → 현재 실행 중인 effect/memo가 구독자로 등록
setCount(1);    // 쓰기 → count를 읽던 구독자만 다시 실행
setCount(c => c + 1); // 이전 값 기반 갱신

가상DOM은 왜 한계에 부딪혔나

가상DOM은 무엇이 바뀌었는지 모른 채 컴포넌트를 통째로 다시 실행해 새 트리를 만들고, 이전 트리와 비교(diff)해서 실제 바뀐 부분만 DOM에 반영한다. 문제는 이 diff 자체가 공짜가 아니라는 점이다. 상태 하나 바뀌었을 뿐인데 컴포넌트 함수 전체와 그 자식들이 다시 돌고, 트리를 순회하며 비교한다. 시그널은 이 단계가 통째로 없다. 바뀐 지점을 이미 알고 있으니 비교할 이유가 없다.

실무에서 리스트 수천 개짜리 대시보드를 React로 만들다 보면, 셀 하나 값이 바뀌었는데 부모가 리렌더되면서 화면이 버벅이는 경험을 누구나 한다. memo, useMemo, useCallback을 덕지덕지 붙여서 리렌더를 막는 게 React 최적화의 대부분이다. 시그널 진영의 주장은 이거다 — “애초에 세밀하게 갱신하면 그 손질이 통째로 필요 없다.” 개인적으로 이 대목이 시그널이 다시 뜬 진짜 이유라고 본다. 성능 숫자보다도, 수동 메모이제이션이라는 정신적 세금에서 벗어나게 해주니까.

항목가상DOM (전통 React)세밀한 반응성 (시그널)
갱신 단위컴포넌트 함수 전체 재실행해당 값을 읽는 표현식/DOM 노드만
diff 필요필요 (트리 비교)불필요 (변경 지점 사전 인지)
수동 최적화memo·useMemo·useCallback 상시 필요대체로 불필요
의존성 추적수동 (deps 배열)자동 (읽기 시점 추적)
컴포넌트 함수 실행렌더마다 반복보통 최초 1회 (Solid 기준)
참고 “가상DOM이 느리다”는 건 과장이다. 대부분의 앱에서 가상DOM diff는 충분히 빠르다. 시그널의 이점은 순수 속도보다 ‘예측 가능한 갱신 범위’와 ‘수동 최적화 제거’에 있다. 벤치마크(js-framework-benchmark)에서 Solid가 상위권인 건 사실이지만, 그 차이가 체감되는 건 대규모 리스트·고빈도 갱신 같은 특정 상황이다.

signal, computed, effect — 반응성 3형제

시그널 계열 프레임워크는 이름만 다를 뿐 세 가지 원시로 돌아간다. signal은 쓰기 가능한 상태, computed(memo/derived)는 다른 시그널로부터 파생된 읽기 전용 값(의존성이 바뀔 때만 다시 계산, 그 외엔 캐시), effect는 시그널이 바뀔 때 실행되는 부수효과(로그·DOM·네트워크)다. 이 셋의 관계만 잡으면 어느 프레임워크든 문법만 갈아끼우면 된다.

// Solid — 3형제 한 화면
const [price, setPrice] = createSignal(1000);
const [qty, setQty]     = createSignal(2);

// computed: 의존성(price, qty)이 바뀔 때만 재계산되고 결과는 캐시된다
const total = createMemo(() => price() * qty());

// effect: total이 바뀔 때마다 자동 실행. deps 배열이 없다
createEffect(() => {
  console.log('합계:', total());
});

setQty(3); // total 재계산 → effect 1회 실행 → '합계: 3000'

여기서 자주 하는 실수 하나. total을 굳이 createMemo로 안 감싸고 () => price() * qty() 함수로 만들어도 동작은 한다. 다만 그러면 여러 곳에서 읽을 때마다 곱셈이 반복된다. memo는 ‘비싼 계산의 결과를 캐시’하는 용도라, 단순 표현식엔 오히려 오버헤드다. 이 판단 기준이 문서엔 잘 안 나온다.

왜 하필 지금 다시 뜨나 — 사실 오래된 아이디어다

결론부터 말하면 시그널은 신기술이 아니다. 2010년 Knockout.js의 observable, 그 뒤의 MobX, Vue의 반응형 시스템, S.js, Meteor의 Tracker — 전부 ‘값을 읽는 곳을 추적해 자동 갱신’하는 같은 계보다. 심지어 스프레드시트가 이 모델의 원조다. A1 셀을 바꾸면 그걸 참조하는 수식만 다시 계산되지 않나. 그게 computed다.

그럼 왜 2023년부터 다시 전면에 나왔나. 몇 가지가 겹쳤다고 본다. 첫째, 가상DOM 세대가 성숙하면서 그 한계(수동 메모이제이션, 예측 어려운 리렌더)가 명확해졌다. 둘째, Solid가 ‘가상DOM 없이도 React급 DX가 가능하다’는 걸 실증했다. 셋째, 서버 컴포넌트 시대가 오면서 클라이언트로 내려보내는 자바스크립트를 최소화해야 했고, 세밀한 반응성이 그 목표에 잘 맞았다. 넷째 — 이게 결정적인데 — Angular와 Svelte라는 대형 프레임워크가 나란히 시그널을 채택하면서 ‘틈새 실험’에서 ‘업계 표준’으로 격상됐다.

프레임워크별로 뭐가 다른가

같은 시그널이라도 ‘어떻게 읽고 쓰느냐’에서 갈린다. Solid·Angular는 getter 호출(count()), Svelte 5는 컴파일러가 일반 변수처럼 쓰게 마법을 부린다(count). React는 시그널을 아예 내장하지 않고 컴파일러로 다른 길을 갔다. 아래 표가 핵심 차이다.

구분SolidSvelte 5AngularReact
선언createSignal(0)$state(0)signal(0)useState(0)
읽기count()countcount()count
쓰기setCount(1)count = 1count.set(1)setCount(1)
파생값createMemo$derivedcomputed렌더 중 계산
추적 방식런타임(getter)컴파일러런타임(getter)수동 deps / Compiler
가상DOM없음없음없음(변경감지)있음

Solid — 가장 순수한 시그널

컴포넌트 함수가 딱 한 번만 실행된다. 이게 React 하다 넘어오면 뇌를 다시 배선해야 하는 지점이다. JSX 안의 {count()}만 반응성 지점으로 남고, 나머지 코드는 setup처럼 한 번 돌고 끝이다.

import { createSignal } from 'solid-js';

function Counter() {
  const [count, setCount] = createSignal(0);
  // 이 console.log는 최초 1회만 찍힌다 (React였다면 매 렌더)
  console.log('setup');
  return <button onClick={() => setCount(c => c + 1)}>{count()}</button>;
}

Svelte 5 — 룬(runes)으로 변수처럼

Svelte 5의 룬은 컴파일러가 뒤에서 시그널 코드를 심어주는 문법이다. 겉보기엔 그냥 변수 대입인데 반응한다. Svelte 4의 let + $: 라벨 문법이 헷갈린다는 원성이 많았는데, 룬은 그걸 명시적으로 정리한 버전이다.

<script>
  let count = $state(0);
  let double = $derived(count * 2);

  $effect(() => {
    console.log('count:', count); // count 바뀔 때마다 실행
  });
</script>

<button onclick={() => count++}>{double}</button>

Angular — Zone.js 탈출구

Angular의 시그널 도입은 사실상 Zone.js와의 결별 선언이다. 기존 Angular는 이벤트가 터질 때마다 컴포넌트 트리 전체의 변경 감지를 돌렸다. 시그널을 쓰면 바뀐 시그널을 읽는 템플릿 표현식만 갱신되고, zoneless 모드로 갈 수 있다. 문법은 Solid와 판박이지만 .set()·.update()로 쓰기가 메서드다.

import { signal, computed, effect } from '@angular/core';

const count  = signal(0);
const double = computed(() => count() * 2);

effect(() => console.log('count:', count()));

count.set(1);           // 값 교체
count.update(c => c + 1); // 이전 값 기반
// 배열/객체는 불변 갱신 권장: items.update(a => [...a, x])

React — 시그널을 안 쓰기로 한 유일한 진영

React 팀은 시그널을 코어에 넣지 않았다. 이유는 명확한데, 시그널의 ‘읽는 곳만 갱신’ 모델이 React의 ‘UI는 상태의 순수 함수’라는 멘탈 모델과 충돌하기 때문이다. 대신 React Compiler(구 React Forget)를 만들어, 개발자가 손으로 붙이던 memo/useMemo를 컴파일 타임에 자동으로 넣어준다. 목적지(불필요한 리렌더 제거)는 같고 경로가 다른 셈이다. 시그널을 꼭 쓰고 싶다면 Preact Signals의 React 어댑터가 있다.

// @preact/signals-react — React에 시그널을 얹는 방법
import { signal, computed } from '@preact/signals-react';

const count  = signal(0);
const double = computed(() => count.value * 2);

function Counter() {
  // .value를 JSX에서 읽으면 이 부분만 갱신되고 컴포넌트는 리렌더 안 됨
  return <button onClick={() => count.value++}>{double}</button>;
}
주의 Solid와 Angular의 문법이 count()로 똑같아 보여도 배열/객체 갱신 관례는 다르다. Angular는 불변 갱신(update(a => [...a, x]))을 권하고, Solid는 별도의 createStore와 세밀한 produce로 중첩 상태를 다룬다. 문법이 같다고 습관까지 복붙하면 반응성이 안 도는 상황을 만난다.

실전: 장바구니 합계를 네 방식으로

단가와 수량으로 합계를 계산하는 흔한 시나리오다. computed의 캐시와 자동 추적이 어떻게 코드를 줄이는지 비교해 보자. React만 파생값을 렌더 중에 직접 계산하고(또는 useMemo), 나머지는 파생 원시를 쓴다.

// Solid
const [price, setPrice] = createSignal(1000);
const [qty, setQty] = createSignal(1);
const total = createMemo(() => price() * qty());
// 템플릿: 합계 {total().toLocaleString()}원

// Svelte 5
let price = $state(1000);
let qty = $state(1);
let total = $derived(price * qty);
// 템플릿: 합계 {total.toLocaleString()}원

// Angular
price = signal(1000);
qty = signal(1);
total = computed(() => this.price() * this.qty());
// 템플릿: 합계 {{ total().toLocaleString() }}원

// React (전통 방식)
const [price, setPrice] = useState(1000);
const [qty, setQty] = useState(1);
const total = price * qty; // 매 렌더 재계산 (비싸면 useMemo)
// JSX: 합계 {total.toLocaleString()}원

차이가 보이나. 시그널 진영은 total이 ‘무엇에 의존하는지’를 코드가 스스로 안다. React는 total이 렌더 함수 안에 있어서 컴포넌트가 리렌더될 때마다 다시 계산되고, 비싸지면 useMemo(() => price * qty, [price, qty])로 deps를 손수 적어야 한다. 이 deps 배열이 실무에서 버그의 온상이다. 하나 빠뜨리면 stale 값, 잘못 넣으면 무한 루프.

시그널을 쓰지 말아야 할 때, 그리고 함정

시그널이 만능은 아니다. 가장 흔한 자충수는 effect 남용이다. effect로 다른 시그널을 세팅하기 시작하면, 옛 useEffect로 상태를 동기화하던 지옥이 이름만 바꿔 재현된다. Angular 팀조차 공식 문서에서 “파생 상태는 effect가 아니라 computed로”를 못 박는다. effect는 어디까지나 로그·DOM 조작·서드파티 연동 같은 ‘바깥 세계로 나가는’ 부수효과 전용으로 두는 게 맞다.

  • Solid의 props 구조분해 금지const { value } = props로 뜯으면 반응성이 그 순간 끊긴다. 값이 아니라 getter를 잃기 때문이다. props.value로 접근하거나 splitProps를 써야 한다. 처음 온 사람 90%가 여기서 데인다.
  • Svelte 5의 깊은 프록시$state는 객체·배열을 깊은 프록시로 감싼다. 클래스 인스턴스나 Map/Set을 넣을 땐 동작 방식($state.raw 등)을 확인해야 한다.
  • Angular의 배열 mutateitems().push(x)는 시그널을 통지하지 않는다. 참조가 그대로라서다. items.update(a => [...a, x])로 새 참조를 줘야 갱신된다.
  • Preact Signals + React 리스트.value를 읽는 위치에 따라 갱신 범위가 달라진다. 잘못 두면 세밀 갱신 이점이 사라진다.

그리고 솔직히, 이미 잘 돌아가는 React 앱을 시그널 때문에 갈아엎을 이유는 없다. React Compiler가 안정화되면서 수동 메모이제이션 문제도 상당 부분 해소되는 중이다. 시그널은 새 프로젝트에서 프레임워크를 고를 때, 또는 고빈도 갱신·거대 리스트처럼 세밀한 제어가 절실할 때 진가를 발휘한다. 유행이라고 무작정 따라가는 게 아니라 문제에 맞춰 고르면 된다.

자주 묻는 질문

시그널과 React의 useState는 뭐가 다른가요?

useState는 값이 바뀌면 컴포넌트 함수 전체를 다시 실행하고 가상DOM으로 비교합니다. 시그널은 그 값을 실제로 읽는 표현식·DOM 노드만 다시 실행합니다. 또 시그널은 컴포넌트 밖에서도 만들어 여러 곳에서 공유할 수 있지만, useState는 컴포넌트 렌더 안에서만 삽니다.

가상DOM은 이제 죽은 기술인가요?

아닙니다. React가 여전히 시장 점유율 1위이고 가상DOM + React Compiler 조합으로 발전하고 있습니다. 가상DOM은 ‘느린 기술’이 아니라 ‘다른 트레이드오프’를 택한 기술입니다. 다만 신규 프레임워크들이 세밀한 반응성 쪽으로 수렴하는 흐름은 분명합니다.

computed(memo)와 effect는 언제 각각 쓰나요?

다른 상태로부터 새 값을 ‘계산’해 화면에 쓸 거면 computed입니다. 반대로 로그 출력, DOM 직접 조작, 네트워크 호출처럼 반응성 시스템 ‘바깥’으로 나가는 부수효과면 effect입니다. effect 안에서 다른 시그널을 세팅하는 패턴은 되도록 피하세요.

Svelte 5의 룬도 시그널인가요?

네. $state·$derived·$effect는 내부적으로 시그널 그래프로 컴파일됩니다. 다른 프레임워크가 런타임 getter 호출로 추적하는 걸, Svelte는 컴파일러가 추적 코드를 심는 방식으로 처리해 겉으로는 일반 변수처럼 보이게 한 것뿐입니다.

Angular에서 시그널을 쓰면 RxJS는 버려야 하나요?

아닙니다. 역할이 다릅니다. 시그널은 동기적인 현재 상태값에, RxJS는 시간에 따른 이벤트 스트림(디바운스·재시도·취소 등)에 강합니다. toSignal()·toObservable()로 둘을 오갈 수 있으니, UI 상태는 시그널, 복잡한 비동기 흐름은 RxJS로 나눠 쓰는 게 실전 조합입니다.

기존 React 프로젝트에 시그널을 도입할 가치가 있나요?

대개는 서두를 필요가 없습니다. React Compiler가 수동 메모이제이션 부담을 줄여주고 있어서, 팀 전체의 멘탈 모델을 바꾸는 비용이 이점보다 클 때가 많습니다. 특정 고성능 위젯에 @preact/signals-react를 국소 도입하는 정도가 현실적인 절충안입니다.

시그널이 있으면 상태관리 라이브러리(Redux 등)가 필요 없나요?

상당 부분 대체합니다. 시그널은 컴포넌트 밖에 두고 어디서나 공유·구독할 수 있어 전역 상태 도구로 충분한 경우가 많습니다. 다만 미들웨어, 시간여행 디버깅, 엄격한 액션 규율 같은 대규모 협업용 기능이 필요하면 여전히 전용 상태관리 라이브러리가 유효합니다.

댓글 0

아직 댓글이 없습니다.
Ctrl+Enter로 등록