Skip to content

Chapter 02.5. [선택·심화] 50줄로 직접 만드는 미니 Signals 반응형 엔진 ​

"Angular의 signal(), computed(), effect()는 어떻게 서로의 의존성을 자동으로 추적하고 갱신할까? 프레임워크 없이 순수 TypeScript 50줄로 createSignal, createComputed, createEffect를 밑바닥부터 조립해 터미널에서 돌려 보면, 반응형은 더 이상 마법이 아니다."


📋 이 장에서 처음 만나는 개념 — 모르는 문법이 나오면 0장 준비운동에서 찾아볼 수 있습니다.

개념한 줄 요약설명 위치
npx tsxTypeScript 파일을 별도 빌드 없이 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의 핵심 메커니즘을 그대로 코드로 옮깁니다. 우리가 만들 부품은 딱 세 가지입니다:

  1. createSignal(initialValue): 값을 읽을 때(sig()) 현재 활성 소비자를 구독자 명단에 등록하고, 값을 바꿀 때(.set(), .update()) 구독자들을 깨우는 신호 생성기
  2. createEffect(fn): 자신을 활성 소비자로 등록한 뒤 fn()을 실행하여, 그 안에서 읽힌 모든 신호와 자동으로 전선을 연결하는 부수 효과 실행기
  3. 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 코어의 세 가지 고급 최적화를 단순화했습니다:

  1. 지연 평가(Pull-based Lazy Computed): 우리 미니 엔진의 createComputed는 내부에서 createEffect를 써서 원본이 바뀔 때마다 즉시 재계산(Push)하지만, 실제 Angular computed()는 2장 2.4에서 배운 것처럼 "Dirty" 표시만 해 두었다가 누군가 실제로 읽을 때(Pull) 재계산합니다.
  2. 비동기 마이크로태스크 스케줄링: 우리 미니 엔진은 posts.update()를 부르는 즉시 동기적으로 createEffect를 실행합니다. 반면 실제 Angular의 effect()는 한 함수 안에서 신호 10개를 동시에 바꾸더라도 변경을 모아서 렌더링 프레임에 단 1번만 실행합니다.
  3. 동적 의존성 정리(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을 어떻게 다루면 될까요?

📝 미션 요구사항 ​

  1. mini-signals.ts에 export function untracked<T>(fn: () => T): T 함수를 구현하십시오.
  2. 테스트 구역 하단에 currentUser = createSignal('김개발') 신호를 만들고, createEffect 안에서 totalCount()는 일반 호출로 읽고 currentUser()는 untracked(() => currentUser())로 읽도록 작성하십시오.
  3. 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줄

  1. Signals의 본질은 createEffect가 실행되는 동안 스택에 현재 함수를 올려두고(consumerStack.push), 그 사이에 호출된 signal()이 자신의 Set 명단에 그 함수를 적는 것입니다.
  2. 신호의 값을 .set()이나 .update()로 바꿀 때는 Object.is로 이전 값과 비교하여, 참조나 값이 실제로 달라졌을 때만 구독자 함수를 재실행합니다.
  3. **untracked()**는 소비자 스택을 잠시 비운 상태에서 신호를 읽어, 값은 꺼내되 의존성 전선은 연결하지 않는 기법입니다.

다음 단계: 이제 우리는 signal()이 **"호출되는 그 순간"**에만 의존성이 연결된다는 사실을 눈으로 확인했습니다. 그렇다면 컴포넌트 필드나 함수 초반에 const title = this.currentPost().title;처럼 신호 값을 미리 꺼내 일반 변수에 담아 두면 무슨 일이 벌어질까요? 이어지는 Chapter 03에서 실무 개발자들이 가장 많이 겪는 신호 끊김(Reactivity Loss) 함정과 불변 업데이트, 그리고 비동기 검색어 디바운스/요청 취소를 정복해 봅시다!

Moon HomeLab Engineering Knowledge Hub