다크 모드
Chapter 02.5. [선택·심화] 50줄로 직접 만드는 미니 Signals 반응형 엔진
"Angular의
signal(),computed(),effect()는 어떻게 서로의 의존성을 자동으로 추적하고 갱신할까? 프레임워크 없이 순수 TypeScript 50줄로createSignal,createComputed,createEffect를 밑바닥부터 조립해 터미널에서 돌려 보면, 반응형은 더 이상 마법이 아니다."
📋 이 장에서 처음 만나는 개념 — 모르는 문법이 나오면 0장 준비운동에서 찾아볼 수 있습니다.
| 개념 | 한 줄 요약 | 설명 위치 |
|---|---|---|
npx tsx | TypeScript 파일을 별도 빌드 없이 Node.js 터미널에서 즉시 실행하는 도구 | 2.5.1 🆕 박스 |
activeConsumer와 호출 스택 | 현재 실행 중인 effect나 computed를 가리키는 전역 포인터와 중첩 호출 스택 | 2.5.2 Step 1 |
Set<Subscriber> & Object.is | 중복 없는 구독자 명단 관리와 엄격한 값 동등성 비교 | 2.5.2 Step 2 🆕 박스 |
함수에 프로퍼티 부착 (Object.assign) | 호출 가능한 getter 함수 sig()에 .set()과 .update() 메서드를 결합하는 기법 | 2.5.2 Step 2 🆕 박스 |
createComputed | 다른 신호들을 구독해 파생 값을 계산하고 캐싱하는 미니 computed 구현체 | 2.5.2 Step 4 |
TIP
🏁 이 장의 시작 파일
이 장은 BoardAppComponent를 건드리지 않고 독립된 연습 파일 하나만 작성해 터미널에서 실행합니다. 이 장에서 손대는 파일: frontend/src/practice/mini-signals.ts
NOTE
이 장은 선택·심화 실습입니다. 2장을 읽었다면 3장 이후 실습에 필요한 지식은 이미 충분합니다. 하지만 직접 50줄 엔진을 타이핑해 돌려 보면, 3장의 "신호 끊김(Reactivity Loss)" 버그가 왜 발생하는지 1초 만에 꿰뚫어 보는 눈이 생깁니다. (소요 시간: 약 25분)
2.5.1 실습 준비: 무엇을 어떻게 만드나?
2장에서 배운 Angular Signals의 핵심 메커니즘을 그대로 코드로 옮깁니다. 우리가 만들 부품은 딱 세 가지입니다:
createSignal(initialValue): 값을 읽을 때(sig()) 현재 활성 소비자를 구독자 명단에 등록하고, 값을 바꿀 때(.set(),.update()) 구독자들을 깨우는 신호 생성기createEffect(fn): 자신을 활성 소비자로 등록한 뒤fn()을 실행하여, 그 안에서 읽힌 모든 신호와 자동으로 전선을 연결하는 부수 효과 실행기createComputed(calcFn): 내부적으로 신호와 효과를 결합하여, 의존하는 신호가 바뀔 때마다 파생 값을 자동으로 재계산하는 파생 신호 생성기
파일 위치와 터미널 실행 방법
- 작성할 파일 경로:
frontend/src/practice/mini-signals.ts - 실행 명령어: 터미널의
frontend/디렉터리에서 아래 명령을 실행하면 브라우저 없이 Node.js에서 즉시 결과를 확인할 수 있습니다.
bash
cd frontend
npx tsx src/practice/mini-signals.ts🆕 처음 보는 문법: npx tsx가 무엇인가요?
tsx(TypeScript Execute):.ts파일을 복잡한tsc컴파일 설정 없이 Node.js에서 즉시 실행해 주는 초고속 실행기입니다.- 브라우저나 Angular 컴파일러 없이 순수 TypeScript 언어 기능만으로 Signals가 어떻게 작동하는지 증명하기 위해 터미널에서 실행합니다.
2.5.2 Step 1 ~ Step 4: 미니 Signals 엔진 조립하기
에디터로 frontend/src/practice/mini-signals.ts 파일을 열고(없으면 새로 만들고), 1단계부터 차례대로 작성해 봅시다.
[Step 1] 활성 소비자(activeConsumer) 호출 스택 선언
effect 안에서 computed를 읽는 것처럼 반응형 컨텍스트가 **중첩(Nested)**될 수 있으므로, 단순 변수 하나 대신 배열 스택(consumerStack)을 두면 부모 컨텍스트를 잃어버리지 않고 완벽히 복원할 수 있습니다.
typescript
// frontend/src/practice/mini-signals.ts
// 1. 구독자(Consumer) 함수 타입과 중첩 호출을 지원하는 활성 소비자 스택
type Subscriber = () => void;
const consumerStack: Subscriber[] = [];
function getActiveConsumer(): Subscriber | null {
return consumerStack.length > 0 ? consumerStack[consumerStack.length - 1] : null;
}[Step 2] createSignal<T>() 구현: 읽을 때 기록하고, 쓸 때 통보하기
Angular의 WritableSignal<T>처럼 평소에 count()로 호출하면 값을 반환하고, count.set(값)이나 count.update(fn)을 호출하면 값을 갱신하는 하이브리드 함수 객체를 만듭니다.
typescript
export interface MiniWritableSignal<T> {
(): T; // 함수처럼 호출 시 값 반환 (Getter)
set(newValue: T): void; // 새 값으로 교체
update(updater: (prev: T) => T): void; // 이전 값을 받아 새 값으로 갱신
}
export function createSignal<T>(initialValue: T): MiniWritableSignal<T> {
let value = initialValue;
const subscribers = new Set<Subscriber>(); // 나를 읽은 소비자 명단 (중복 자동 방지)
// 🌟 1) 읽을 때(Getter): 현재 실행 중인 소비자가 있다면 명단에 등록!
const getter = (): T => {
const active = getActiveConsumer();
if (active) {
subscribers.add(active);
}
return value;
};
// 🌟 2) 쓸 때(Setter): 이전 값과 다를 때만 저장하고 모든 구독자에게 통보!
const set = (newValue: T): void => {
if (!Object.is(value, newValue)) {
value = newValue;
// 실행 도중 Set이 변형되어 무한 루프에 빠지지 않도록 복사본 배열([...subscribers])을 순회
[...subscribers].forEach((sub) => sub());
}
};
// 🌟 3) 업데이트(Updater): 현재 값을 기반으로 새 값을 계산해 set 호출
const update = (updater: (prev: T) => T): void => {
set(updater(value));
};
return Object.assign(getter, { set, update });
}🆕 처음 보는 문법: Set, Object.is, 그리고 함수에 메서드 붙이기(Object.assign)
new Set<Subscriber>(): 중복을 허용하지 않는 집합 자료구조입니다. 한 템플릿이나effect안에서count()를 세 번 읽어도subscribers.add(active)는 딱 한 개만 남기므로, 나중에 값이 바뀔 때 중복 실행되지 않습니다.Object.is(value, newValue):===와 거의 같지만NaN과NaN을 같다고 정확히 판단하는 표준 동등성 비교 함수입니다. Angular Signals 실제 소스 코드도 기본 동등성 비교기로Object.is를 사용합니다.Object.assign(getter, { set, update }): 자바스크립트에서 함수(getter)도 일급 객체이므로 속성을 붙일 수 있습니다. 이렇게 하면sig()처럼 함수로 호출할 수도 있고,sig.set(...)처럼 메서드를 부를 수도 있는 Angular Signal과 똑같은 인터페이스가 완성됩니다!
[Step 3] createEffect() 구현: 자동 의존성 연결기
typescript
export function createEffect(fn: () => void): void {
const execute = (): void => {
consumerStack.push(execute); // 🌟 실행 시작 전: 스택 맨 위에 내 이름표를 올린다!
try {
fn(); // 이 안에서 읽히는 모든 signal의 subscribers 명단에 execute가 자동 등록됨!
} finally {
consumerStack.pop(); // 🌟 실행 종료 후: 무슨 일이 있어도 이름표를 내린다!
}
};
execute(); // 최초 1회 즉시 실행하여 초기 의존성 수집
}[Step 4] createComputed<T>() 구현: 파생 신호 계산기
computed는 **"스스로는 다른 신호를 구독하는 소비자(Consumer)이면서, 동시에 다른 effect나 템플릿에게 값을 제공하는 생산자(Producer)"**입니다. 앞서 만든 createSignal과 createEffect를 조합하면 단 몇 줄로 구현할 수 있습니다!
typescript
export type MiniSignal<T> = () => T;
export function createComputed<T>(calcFn: () => T): MiniSignal<T> {
// 최초 계산값은 undefined로 시작하되 즉시 아래 createEffect에서 첫 계산이 수행됨
const derived = createSignal<T>(undefined as unknown as T);
createEffect(() => {
const nextValue = calcFn(); // calcFn 안에서 읽은 신호들이 바뀌면 이 블록이 자동 재실행!
derived.set(nextValue); // 계산 결과가 실제로 달라졌을 때만 하류 구독자들에게 재통보!
});
// 외부에서는 .set()을 못 쓰게 순수 getter 함수만 노출 (읽기 전용 보장)
return () => derived();
}2.5.3 전체 통합 코드 및 게시판 렌더링 시뮬레이션 실행
위의 Step 1~4에 게시판 시뮬레이션 테스트 코드까지 합친 frontend/src/practice/mini-signals.ts의 생략 없는 전체 풀코드입니다.
typescript
// frontend/src/practice/mini-signals.ts
// Chapter 02.5 실습: 50줄로 밑바닥부터 만드는 Angular 22 미니 Signals 엔진 전체 코드
type Subscriber = () => void;
const consumerStack: Subscriber[] = [];
function getActiveConsumer(): Subscriber | null {
return consumerStack.length > 0 ? consumerStack[consumerStack.length - 1] : null;
}
export interface MiniWritableSignal<T> {
(): T;
set(newValue: T): void;
update(updater: (prev: T) => T): void;
}
export type MiniSignal<T> = () => T;
// 1. createSignal: 원천 반응형 신호 생성
export function createSignal<T>(initialValue: T): MiniWritableSignal<T> {
let value = initialValue;
const subscribers = new Set<Subscriber>();
const getter = (): T => {
const active = getActiveConsumer();
if (active) {
subscribers.add(active);
}
return value;
};
const set = (newValue: T): void => {
if (!Object.is(value, newValue)) {
value = newValue;
[...subscribers].forEach((sub) => sub());
}
};
const update = (updater: (prev: T) => T): void => {
set(updater(value));
};
return Object.assign(getter, { set, update });
}
// 2. createEffect: 의존성 자동 수집 및 부수 효과 실행
export function createEffect(fn: () => void): void {
const execute = (): void => {
consumerStack.push(execute);
try {
fn();
} finally {
consumerStack.pop();
}
};
execute();
}
// 3. createComputed: 파생 신호 생성 (읽기 전용)
export function createComputed<T>(calcFn: () => T): MiniSignal<T> {
const derived = createSignal<T>(undefined as unknown as T);
createEffect(() => {
derived.set(calcFn());
});
return () => derived();
}
// ============================================================================
// 🧪 [테스트 실행 구역] 사내 기술 게시판 반응형 시뮬레이션
// ============================================================================
console.log('🚀 [Chapter 02.5] 미니 Angular Signals 엔진 테스트 시작!\n');
interface MiniPost {
id: number;
title: string;
category: 'DEV' | 'NOTICE';
}
// 1. 게시글 목록 원본 신호 선언
const posts = createSignal<MiniPost[]>([
{ id: 1, title: 'Zone.js 없는 세상', category: 'DEV' },
{ id: 2, title: '사내 보안 점검 안내', category: 'NOTICE' }
]);
// 2. 파생 신호(computed): 전체 건수와 DEV 카테고리 건수 자동 계산
const totalCount = createComputed(() => posts().length);
const devCount = createComputed(() => posts().filter((p) => p.category === 'DEV').length);
// 3. 화면 렌더링(effect) 시뮬레이션
createEffect(() => {
console.log(
`📺 [화면 자동 렌더링] 총 게시글: ${totalCount()}건 (개발글: ${devCount()}건) | 최신 글: "${posts()[0]?.title}"`
);
});
console.log('\n--- 1. 새 개발(DEV) 게시글 등록 (posts.update) ---');
posts.update((prev) => [
{ id: 3, title: 'Angular 22 Signals 완벽 가이드', category: 'DEV' },
...prev
]);
console.log('\n--- 2. 공지(NOTICE) 게시글 삭제 (posts.update filter) ---');
posts.update((prev) => prev.filter((p) => p.id !== 2));
console.log('\n--- 3. 동일한 참조로 set 호출 시 불필요한 렌더링 방지 검증 ---');
const currentRef = posts();
posts.set(currentRef); // Object.is가 true이므로 아무 로그도 찍히지 않아야 정상!
console.log('\n🎉 모든 Signals 의존성 그래프와 파생 계산이 완벽히 작동했습니다!');터미널에서 실행해 결과를 확인해 보십시오:
bash
cd frontend
npx tsx src/practice/mini-signals.ts[실제 콘솔 출력 결과]:
text
🚀 [Chapter 02.5] 미니 Angular Signals 엔진 테스트 시작!
📺 [화면 자동 렌더링] 총 게시글: 2건 (개발글: 1건) | 최신 글: "Zone.js 없는 세상"
--- 1. 새 개발(DEV) 게시글 등록 (posts.update) ---
📺 [화면 자동 렌더링] 총 게시글: 3건 (개발글: 2건) | 최신 글: "Angular 22 Signals 완벽 가이드"
--- 2. 공지(NOTICE) 게시글 삭제 (posts.update filter) ---
📺 [화면 자동 렌더링] 총 게시글: 2건 (개발글: 2건) | 최신 글: "Angular 22 Signals 완벽 가이드"
--- 3. 동일한 참조로 set 호출 시 불필요한 렌더링 방지 검증 ---
🎉 모든 Signals 의존성 그래프와 파생 계산이 완벽히 작동했습니다!🔬 심화(건너뛰어도 됨): 우리 미니 엔진과 실제 @angular/core Signals 엔진의 차이점 3가지
원리를 50줄로 선명하게 보여주기 위해 실제 Angular 코어의 세 가지 고급 최적화를 단순화했습니다:
- 지연 평가(Pull-based Lazy Computed): 우리 미니 엔진의
createComputed는 내부에서createEffect를 써서 원본이 바뀔 때마다 즉시 재계산(Push)하지만, 실제 Angularcomputed()는 2장 2.4에서 배운 것처럼 "Dirty" 표시만 해 두었다가 누군가 실제로 읽을 때(Pull) 재계산합니다. - 비동기 마이크로태스크 스케줄링: 우리 미니 엔진은
posts.update()를 부르는 즉시 동기적으로createEffect를 실행합니다. 반면 실제 Angular의effect()는 한 함수 안에서 신호 10개를 동시에 바꾸더라도 변경을 모아서 렌더링 프레임에 단 1번만 실행합니다. - 동적 의존성 정리(Cleanup)와 양방향 연결 리스트:
if (showDetail()) { console.log(post().title); }처럼 조건문이 있는effect에서showDetail()이false로 바뀌면, 실제 Angular는 더 이상 읽히지 않는post신호의 구독을 자동으로 해제합니다.
2.5.4 [직접 해보기] 실습 미션 💪
3장에서 중요하게 쓰일 Angular의 untracked() 함수를 우리 미니 엔진(mini-signals.ts)에 직접 구현해 추가해 보십시오!
- 힌트:
untracked(fn)는 "fn()을 실행하되, 그 안에서 신호를 읽더라도getActiveConsumer()가null을 반환하게 만들어 구독 명단에 적히지 않게 하는 함수"입니다.consumerStack을 어떻게 다루면 될까요?
📝 미션 요구사항
mini-signals.ts에export function untracked<T>(fn: () => T): T함수를 구현하십시오.- 테스트 구역 하단에
currentUser = createSignal('김개발')신호를 만들고,createEffect안에서totalCount()는 일반 호출로 읽고currentUser()는untracked(() => currentUser())로 읽도록 작성하십시오. currentUser.set('박해커')를 호출했을 때 화면 렌더링 로그가 찍히지 않는지 확인하십시오.
💡 미션 정답 코드 확인하기
typescript
// mini-signals.ts에 untracked 구현 추가
export function untracked<T>(fn: () => T): T {
// 🌟 스택의 상태를 잠시 비워 두거나, 임시로 비활성화한 채 fn()을 실행한 뒤 복원!
const savedStack = consumerStack.splice(0, consumerStack.length);
try {
return fn();
} finally {
consumerStack.push(...savedStack);
}
}
// 🧪 미션 테스트 코드
console.log('\n--- 4. untracked() 의존성 격리 테스트 ---');
const currentUser = createSignal('김개발');
createEffect(() => {
// totalCount()는 추적되지만, currentUser()는 untracked 내부라 추적되지 않음!
const user = untracked(() => currentUser());
console.log(`📝 [감사 로그] 작업자: ${user} | 현재 글 수: ${totalCount()}건`);
});
console.log('-> currentUser를 "박해커"로 변경 (로그가 안 찍혀야 정상!)');
currentUser.set('박해커'); // 아무 출력 없음!
console.log('-> posts에 새 글 추가 (바뀐 작업자 이름 "박해커"와 함께 로그 출력!)');
posts.update((prev) => [...prev, { id: 4, title: '추가 글', category: 'DEV' }]);2.5.5 이번 장 핵심 요약 및 다음 단계 🚀
핵심 요약 3줄
- Signals의 본질은
createEffect가 실행되는 동안 스택에 현재 함수를 올려두고(consumerStack.push), 그 사이에 호출된signal()이 자신의Set명단에 그 함수를 적는 것입니다. - 신호의 값을
.set()이나.update()로 바꿀 때는Object.is로 이전 값과 비교하여, 참조나 값이 실제로 달라졌을 때만 구독자 함수를 재실행합니다. - **
untracked()**는 소비자 스택을 잠시 비운 상태에서 신호를 읽어, 값은 꺼내되 의존성 전선은 연결하지 않는 기법입니다.
다음 단계: 이제 우리는 signal()이 **"호출되는 그 순간"**에만 의존성이 연결된다는 사실을 눈으로 확인했습니다. 그렇다면 컴포넌트 필드나 함수 초반에 const title = this.currentPost().title;처럼 신호 값을 미리 꺼내 일반 변수에 담아 두면 무슨 일이 벌어질까요? 이어지는 Chapter 03에서 실무 개발자들이 가장 많이 겪는 신호 끊김(Reactivity Loss) 함정과 불변 업데이트, 그리고 비동기 검색어 디바운스/요청 취소를 정복해 봅시다!