다크 모드
Chapter 02. Angular Signals 심층 원리: Zone.js의 종말과 미세 반응형 엔진
"과거 Angular는 왜 마우스만 움직여도 전체 화면 트리를 처음부터 끝까지 뒤졌을까? 그리고 최신 Angular 22의 Signals는 어떻게 가상 DOM(Virtual DOM)이나 Zone.js 없이도 바뀐 HTML 텍스트 노드 딱 하나만 0.0001초 만에 핀포인트로 갱신하는가?"
📋 이 장에서 처음 만나는 개념 — 모르는 문법이 나오면 0장 준비운동에서 찾아볼 수 있습니다.
| 개념 | 한 줄 요약 | 설명 위치 |
|---|---|---|
| Zone.js와 Dirty Checking | 브라우저 비동기 API를 몽키패칭하여 이벤트마다 컴포넌트 트리 전체를 위에서 아래로 검사하던 구식 엔진 | 2.1 |
| 미세 반응형 (Fine-grained Reactivity) | 생산자(Producer) 신호와 소비자(Consumer) 템플릿/함수를 양방향 그래프로 직결하는 아키텍처 | 2.2 |
signal() / computed() / effect() | 원시 반응형 상태 / 지연 평가·캐싱되는 파생 신호 / 부수 효과 실행기 3대장 비교 | 2.3 |
| Glitch-Free (Push-Pull 알고리즘) | 다이아몬드 의존성 그래프에서 중간 계산값이 화면에 노출되지 않고 단 한 번만 계산되도록 보장하는 원리 | 2.4 |
ChangeDetectionStrategy.OnPush & Zoneless | Angular 22에서 기본화된 정밀 변경 감지 전략과 Zone.js 없는 런타임 동작 원리 | 2.5 |
TIP
🏁 이 장의 시작 파일
이 장은 1장~1.5장에서 우리가 작성한 코드가 내부적으로 어떻게 동작하는지 멘탈 모델을 세우는 원리 장이며, 2.6절에서 BoardAppComponent에 실시간 통계 파생 신호(computed)와 로컬 스토리지 자동 동기화(effect)를 장착하는 실습을 진행합니다. 이 장에서 손대는 파일: frontend/src/app/board-app/board-app.component.ts, frontend/src/app/board-app/board-app.component.html
NOTE
바쁜 분을 위한 3줄 요약
- 과거 Angular(Zone.js)는 "어디가 바뀌었는지 몰라서 트리 전체를 다 훑는" 방식이었고, Angular 22 Signals는 "신호를 읽은 템플릿 위치만 정확히 기억해 두었다가 그 자리만 고치는" 직결 방식입니다.
- 화면에 보여 줄 가공/통계 값은 무조건
computed()(지연 평가 + 캐싱)로 만들고, 로컬 스토리지 저장이나 로깅 같은 **부수 효과(Side Effect)**만effect()에 둡니다. - Signals 엔진을 50줄 순수 TypeScript로 직접 조립해 보는 실습은 2.5장(선택·심화)에 이어집니다.
2.1 자바스크립트의 한계와 과거 Zone.js의 무거운 짐
먼저 근본적인 질문을 던져 봅시다. 1장에서 우리가 작성한 삭제 코드를 다시 보십시오:
typescript
this.posts.update((prev) => prev.filter((p) => p.id !== id));우리는 단지 메모리에 있는 배열 신호를 업데이트했을 뿐인데, 브라우저는 어떻게 테이블의 해당 <tr>을 지우고 상단 배지 숫자(총 N건)까지 동시에 고쳤을까요?
원래 자바스크립트 언어 자체는 반응형(Reactive)이 아닙니다:
typescript
let price = 1000;
let quantity = 2;
let total = price * quantity; // 2000
price = 2000;
console.log(total); // 🚨 여전히 2000! (엑셀처럼 자동으로 4000이 되지 않는다)💡 비유: 엑셀(Excel) 스프레드시트 vs 고지식한 자바스크립트
엑셀에서는 A1 셀에1000, B1에2, C1에=A1*B1을 적어 두면 A1을2000으로 고치는 순간 C1이 자동으로4000으로 바뀝니다. 반면 자바스크립트는 누군가 다시 계산하라고 등을 떠밀지 않으면 영원히 옛날 값(2000)을 붙들고 있는 명령형 언어입니다.
과거 Angular(v2 ~ v16)의 해법: Zone.js와 전수 조사(Dirty Checking)
과거 Angular는 "개발자가 일반 변수(this.title = '새 제목')를 언제 바꿨는지 어떻게 알까?"라는 문제를 풀기 위해 Zone.js라는 라이브러리를 썼습니다.
- Zone.js가 브라우저의 모든 비동기 이벤트(
click,keydown,setTimeout,fetch/XMLHttpRequest,Promise.then)를 가로챕니다(Monkey-patching). - 사용자가 버튼을 클릭하거나 마우스를 움직이거나 타이머가 끝나면, Zone.js는 **"어딘가에서 데이터가 바뀌었을지도 모른다!"**고 외치며 Angular에게 알립니다.
- 그러면 Angular는 최상위
AppComponent부터 말단 자식 컴포넌트까지 컴포넌트 트리 전체를 위에서 아래로 훑으며(Dirty Checking) 모든 템플릿 표현식을 이전 값과 비교했습니다.
컴포넌트가 500개인 대형 금융/ERP 화면에서 아무 상관없는 버튼 하나만 눌러도 500개 컴포넌트를 전수 조사하느라 CPU가 낭비되었고, 스택 트레이스는 zone.js 호출로 뒤덮여 디버깅이 까다로웠습니다.
2.2 Signals의 등장: 미세 반응형(Fine-grained Reactivity)과 Producer-Consumer 그래프
Angular 17~22에 걸쳐 완성된 Signals는 이 패러다임을 180도 뒤집었습니다.
더 이상 브라우저 이벤트를 몽키패칭해 "혹시 바뀐 데 없나?" 하고 온 동네를 뒤지지 않습니다. 대신 값을 담고 있는 **생산자(Producer, signal / computed)**와 그 값을 읽는 소비자(Consumer, 컴포넌트 템플릿 / computed / effect) 사이에 실시간 양방향 의존성 그래프를 구축합니다.
⚡ 비유: 전 동네 가가호호 방문 순찰(Zone.js) vs 전력망 직결 스위치(Signals)
- 과거 Zone.js: 마을에서 누군가 전등을 켰는지 확인하려고, 이장님이 마을에 무슨 소리만 나면 100가구를 일일이 걸어 다니며 초인종을 눌러 확인하는 방식입니다.
- 모던 Angular 22 Signals: 내 방 벽면 스위치(
signal)와 천장 전구(템플릿 텍스트 노드)가 **구리 전선(의존성 간선)**으로 1:1 직결된 방식입니다. 스위치를 올리는 순간 다른 방은 쳐다보지도 않고 내 방 전구만 0.0001초 만에 켜집니다!
왜 posts가 아니라 posts()처럼 함수로 호출하게 만들었을까요?
1장에서 템플릿에 {{ posts().length }}라고 쓸 때 왜 괄호 ()를 붙여야 하는지 궁금했을 것입니다.
자바스크립트에서 일반 변수 this.posts를 읽는 행위는 엔진이 가로챌 수 없지만, 함수 this.posts()를 호출하는 순간에는 그 함수 내부 코드가 실행됩니다! 바로 그 getter 함수 안에서 Angular는 다음 한 줄을 수행합니다:
typescript
// 개념 모델 (2.5장에서 직접 구현합니다!)
function postSignalGetter() {
if (activeConsumer) {
// 🌟 "지금 화면을 그리고 있는 템플릿(activeConsumer)이 나를 읽었구나! 내 구독자 명단에 적어두자!"
this.consumers.add(activeConsumer);
}
return this.value;
}즉, 괄호 ()를 열고 닫는 행위 자체가 바로 전선을 꽂는(구독을 등록하는) 스위치입니다!
2.3 반응형 3대장 완벽 비교: signal() vs computed() vs effect()
Angular 22 반응형 프로그래밍은 이 세 가지 기본 도구(Primitives)의 조합으로 이루어집니다.
1) signal<T>(initialValue): 쓰기 가능한 원천 상태 (WritableSignal)
데이터의 원본(Single Source of Truth)을 보관합니다.
- 읽기:
this.posts()(현재 값을 반환하며, 반응형 컨텍스트 안에서 읽으면 의존성 자동 등록) - 교체(
.set):this.isLoading.set(true)(새로운 값으로 즉시 교체) - 갱신(
.update):this.posts.update(prev => [newPost, ...prev])(직전 값을 바탕으로 새 값을 계산해 교체)
2) computed<T>(() => 표현식): 읽기 전용 파생 신호 (Computed Signal)
하나 이상의 다른 Signal을 읽어 새로운 값을 계산해 반환하는 순수 함수입니다.
typescript
posts = signal<Post[]>([]);
selectedCategory = signal<string>('ALL');
// 🌟 posts()나 selectedCategory() 중 하나라도 바뀔 때만 재계산되고, 평소에는 캐시된 배열을 즉시 반환!
filteredPosts = computed(() => {
const category = this.selectedCategory();
const list = this.posts();
if (category === 'ALL') return list;
return list.filter((p) => p.category === category);
});
// 🌟 computed가 다른 computed를 다시 구독(체이닝)할 수도 있습니다!
filteredCount = computed(() => this.filteredPosts().length);- 핵심 특징 1 — 지연 평가(Lazy Evaluation):
posts가 바뀌었다고 그 즉시filteredPosts를 계산하지 않습니다. "더러워짐(Dirty)" 표시만 해 두었다가, 템플릿이나 코드가 실제로filteredPosts()를 읽는 순간 딱 한 번 계산합니다. 아무도 읽지 않으면 계산 비용은0입니다! - 핵심 특징 2 — 메모이제이션(Memoization / 캐싱): 템플릿의 여러 곳에서
{{ filteredCount() }}를 10번 호출해도 계산은 최초 1번만 수행하고 나머지 9번은 캐시된 숫자를 그대로 돌려줍니다. - 절대 규칙:
computed()내부에서는 절대로 다른 Signal을.set()으로 수정하거나 HTTP 요청을 보내는 등 부수 효과(Side Effect)를 일으키면 안 됩니다(Angular가 에러를 던져 막습니다).
3) effect(() => { ... }): 부수 효과 실행기 (Side Effect Runner)
신호 값이 바뀔 때마다 화면 렌더링 이외의 외부 작업(예: localStorage 자동 저장, console.log 디버깅, Canvas/차트 라이브러리 수동 갱신)을 수행할 때 사용합니다.
typescript
constructor() {
effect(() => {
// 이 안에서 this.pageSize()를 읽었으므로, pageSize가 바뀔 때마다 자동으로 재실행됨!
localStorage.setItem('board_page_size', String(this.pageSize()));
});
}| 비교 항목 | signal() | computed() | effect() |
|---|---|---|---|
| 역할 | 원본 상태 저장 (Producer) | 파생 값 계산 및 캐싱 (Producer & Consumer) | 외부 부수 효과 실행 (Consumer) |
직접 수정 (.set) | 가능 (WritableSignal) | 불가능 (Signal<T>, 읽기 전용) | 반환값으로 값을 내놓지 않음 (EffectRef) |
| 실행 시점 | 즉시 값 보유 | 누군가 ()로 읽을 때 지연 평가 (캐싱) | 의존 신호 변경 후 비동기 스케줄링 시점 |
| 게시판 실무 용도 | posts, keyword, pageSize | filteredPosts, totalPages, isFormValid | localStorage 동기화, 분석 로그 전송 |
💡 비유로 1초 만에 정리하는
computed()vseffect()
computed()= 태양광 전자계산기: 화면에 가공된 숫자를 띄우기 위해 존재합니다. 입력 숫자가 안 바뀌면 액정에 떠 있는 계산 결과(캐시)를 그대로 보여줍니다. 화면에 보여줄 파생 데이터는 100%computed()로 만드십시오!effect()= 현관문 동작 감지 센서: 사람이 지나갈 때마다(신호 변경) 현관 불을 켜거나 경보음을 울립니다(외부 부수 효과).
2.4 글리치 프리(Glitch-Free)와 Push-Pull 2단계 알고리즘
초기 반응형 라이브러리나 RxJS combineLatest를 잘못 쓰면 흔히 겪던 치명적인 문제가 바로 **다이아몬드 의존성 문제(Diamond Problem)**로 인한 **글리치(Glitch, 일시적 불일치 깜빡임)**입니다.
다음과 같은 코드가 있다고 해 봅시다:
typescript
const count = signal(1);
const double = computed(() => count() * 2); // count에 의존
const triple = computed(() => count() * 3); // count에 의존
const sum = computed(() => double() + triple()); // double과 triple 모두에 의존!만약 단순한 즉시 전파(Push-only) 방식이라면 count.set(2)를 호출했을 때 무슨 일이 벌어질까요?
count가2로 바뀜 →double이4로 갱신됨.double이 바뀌었으니sum이 즉시 재계산됨 → 이때triple은 아직 갱신 전인3이므로sum이 일시적으로4 + 3 = 7이라는 틀린 값으로 계산되어 화면에 깜빡 노출됨(Glitch!).- 직후에
triple이6으로 갱신되면서sum이 다시4 + 6 = 10으로 두 번 계산됨.
Angular Signals의 해법: 2단계 Push-Pull 알고리즘
Angular 22 Signals는 이 문제를 Push(더러움 표시 전파) 단계와 Pull(지연 재계산) 단계로 완벽히 분리하여 해결합니다:
- 1단계 (Push — 알림 전파):
count.set(2)가 호출되면, Angular는 값을 계산하지 않고 하위 그래프(double,triple,sum)를 따라 **"원본이 바뀌었으니 너희 캐시는 상했을 수 있다(Dirty)"**라는 깃발만 빛의 속도로 꽂아 내려갑니다. - 2단계 (Pull — 필요한 순간 단 1회 계산): 렌더링 시점에 템플릿이
sum()을 읽으면,sum은 자신의 상류인double()과triple()이 최신(2 기준)으로 업데이트된 것을 모두 확인한 뒤4 + 6 = 10을 딱 한 번만 계산합니다. 중간의 잘못된 값(7)은 세상에 단 1밀리초도 존재하지 않습니다!
🔬 심화(건너뛰어도 됨): 버전 카운터(Version Counter)를 통한 불필요한 재계산 건너뛰기
만약 isEven = computed(() => count() % 2 === 0)이라는 파생 신호가 있고, 그 아래에 message = computed(() => isEven() ? '짝수' : '홀수')가 연결되어 있다면 어떨까요?count가 2에서 4로 바뀌면 isEven은 재계산되지만 결과는 여전히 true로 같습니다. Angular Signals의 각 노드는 내부에 **값 버전 번호(version)**를 들고 있어서, isEven을 다시 계산해 보았더니 이전 값(true)과 Object.is 비교 결과가 같다면 자신의 version을 올리지 않습니다. 그러면 하류의 message는 "어? isEven의 버전 번호가 그대로네!" 하고 아예 재계산을 건너뛰고 기존 캐시('짝수')를 즉시 반환합니다.
2.5 ChangeDetectionStrategy.OnPush 기본화와 Zoneless 아키텍처
1장부터 우리는 모든 컴포넌트에 changeDetection: ChangeDetectionStrategy.OnPush를 붙였습니다(Angular 22 CLI 설정에서도 기본 권장값입니다).
Default전략 (구식): 이벤트가 발생할 때마다 부모부터 자식까지 무조건 다 검사합니다.OnPush+ Signals 전략 (모던 표준): 해당 컴포넌트는 다음 세 경우에만 깨어납니다:- 컴포넌트 템플릿이 읽고 있는
signal()또는computed()값이 변경되었을 때 (이 경우에도 컴포넌트 전체가 아니라 해당 신호가 바인딩된 뷰만 정밀 타격!) - 부모가 내려준
input()신호 값이 변경되었을 때 - 컴포넌트 템플릿 자체에서 DOM 이벤트(
(click)등)가 발생했을 때
- 컴포넌트 템플릿이 읽고 있는
게다가 Angular 22에서는 app.config.ts에 provideZonelessChangeDetection()이 기본 통합되어, 무거운 zone.js 폴리필(약 35KB+)을 번들에서 완전히 제거하고 100% Signals 알림만으로 초고속 렌더링을 수행합니다.
2.6 [실습] BoardAppComponent에 실시간 카테고리 통계(computed)와 설정 자동 저장(effect) 장착하기
이론을 배웠으니 우리 게시판(BoardAppComponent)에 computed()와 effect()를 실제로 적용해 봅시다!
요구사항:
- 전체 게시글 중
DEV(개발),NOTICE(공지),FREE(자유) 글이 각각 몇 건인지 실시간으로 집계하는 파생 신호categoryStats = computed(...)를 만들고 상단 요약 바에 표시한다. - 사용자가 1.5장의 툴바에서 페이지 크기(
pageSize: 5, 10, 20)나 작성자 유지 여부(keepAuthor)를 바꾸면,effect()가 이를 감지해 브라우저localStorage에 자동으로 저장하고 새로고침 시 복원한다.
[코드 2-1] board-app.component.ts에 computed와 effect 추가하기
typescript
// frontend/src/app/board-app/board-app.component.ts
import {
Component,
ChangeDetectionStrategy,
signal,
computed, // 🌟 추가!
effect, // 🌟 추가!
inject,
OnInit,
viewChild,
ElementRef,
afterNextRender,
Injector
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { BoardToolbarComponent } from '../components/board-toolbar.component';
export interface Post {
id: number;
title: string;
author: string;
content: string;
category: string;
createdAt: string;
likeCount?: number;
}
interface PageResponse {
items: Post[];
total: number;
}
@Component({
selector: 'app-board-app',
standalone: true,
imports: [FormsModule, BoardToolbarComponent],
templateUrl: './board-app.component.html',
styleUrl: './board-app.component.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BoardAppComponent implements OnInit {
private readonly http = inject(HttpClient);
private readonly injector = inject(Injector);
titleInput = viewChild<ElementRef<HTMLInputElement>>('titleInput');
tableBody = viewChild<ElementRef<HTMLTableSectionElement>>('tableBody');
readonly categoryOptions = [
{ value: 'DEV', label: '개발' },
{ value: 'NOTICE', label: '공지' },
{ value: 'FREE', label: '자유' }
] as const;
posts = signal<Post[]>([]);
isLoading = signal<boolean>(false);
isSubmitting = signal<boolean>(false);
// 🌟 localStorage에서 초기 설정값 복원
pageSize = signal<number>(Number(localStorage.getItem('board_page_size')) || 20);
keepAuthor = signal<boolean>(localStorage.getItem('board_keep_author') === 'true');
selectedId = signal<number | null>(null);
lastCreatedId = signal<number | null>(null);
newTitle = signal<string>('');
newAuthor = signal<string>('');
newContent = signal<string>('');
newCategory = signal<string>('DEV');
// 🌟 1. computed() 파생 신호: posts() 배열이 바뀔 때만 카테고리별 건수를 딱 1번 재계산!
categoryStats = computed(() => {
const list = this.posts();
return {
total: list.length,
dev: list.filter((p) => p.category === 'DEV').length,
notice: list.filter((p) => p.category === 'NOTICE').length,
free: list.filter((p) => p.category === 'FREE').length
};
});
constructor() {
afterNextRender(() => {
this.titleInput()?.nativeElement.focus();
});
// 🌟 2. effect() 부수 효과: pageSize()나 keepAuthor() 신호가 바뀔 때마다 localStorage에 자동 저장!
effect(() => {
localStorage.setItem('board_page_size', String(this.pageSize()));
localStorage.setItem('board_keep_author', String(this.keepAuthor()));
});
}
ngOnInit(): void {
this.loadPosts();
}
// ... (loadPosts, handleSubmit, onDeleteClick, handleDelete 메서드는 1.5장과 동일) ...
loadPosts(): void {
this.isLoading.set(true);
this.http
.get<PageResponse | Post[]>('/api/posts', {
params: { size: this.pageSize() }
})
.subscribe({
next: (res) => {
const list = Array.isArray(res) ? res : (res.items ?? []);
this.posts.set(list);
this.isLoading.set(false);
},
error: () => {
alert('게시글 목록을 불러오지 못했습니다.');
this.isLoading.set(false);
}
});
}
handleSubmit(): void {
const title = this.newTitle().trim();
const author = this.newAuthor().trim();
const content = this.newContent().trim();
if (!title || !author || !content) {
alert('제목, 작성자, 내용을 모두 입력해 주세요.');
return;
}
this.isSubmitting.set(true);
this.http
.post<Post>('/api/posts', {
title,
author,
content,
category: this.newCategory()
})
.subscribe({
next: (createdPost) => {
this.posts.update((prev) => [createdPost, ...prev]);
this.lastCreatedId.set(createdPost.id);
this.newTitle.set('');
this.newContent.set('');
if (!this.keepAuthor()) {
this.newAuthor.set('');
}
this.isSubmitting.set(false);
afterNextRender(
() => {
this.tableBody()
?.nativeElement.querySelector('tr')
?.scrollIntoView({ behavior: 'smooth', block: 'center' });
this.titleInput()?.nativeElement.focus();
},
{ injector: this.injector }
);
},
error: () => {
alert('게시글 등록에 실패했습니다.');
this.isSubmitting.set(false);
}
});
}
onDeleteClick(id: number, event: MouseEvent): void {
event.stopPropagation();
this.handleDelete(id);
}
handleDelete(id: number): void {
if (!confirm('정말 삭제하시겠습니까?')) return;
this.http.delete<void>(`/api/posts/${id}`).subscribe({
next: () => {
this.posts.update((prev) => prev.filter((p) => p.id !== id));
if (this.selectedId() === id) {
this.selectedId.set(null);
}
},
error: () => {
alert('게시글 삭제에 실패했습니다.');
}
});
}
}[코드 2-2] board-app.component.html에 @let과 통계 배지 추가하기
.card-header 아래에 @let stats = categoryStats(); 구문을 사용하여 깔끔하게 카테고리별 통계를 렌더링합니다:
html
<div class="board-card">
<div class="card-header">
<h2>✅ [Angular 22 마이그레이션] 사내 기술 Q&A 게시판</h2>
@let stats = categoryStats();
<div class="stats-bar">
<span class="badge">전체 {{ stats.total }}건</span>
<span class="category-badge category-dev">개발 {{ stats.dev }}</span>
<span class="category-badge category-notice">공지 {{ stats.notice }}</span>
<span class="category-badge category-free">자유 {{ stats.free }}</span>
</div>
</div>
<!-- 이하 등록 폼, 툴바, 테이블 코드는 1.5장과 동일 -->💡 [확인하기]: 글을 하나 새로 등록하거나 삭제해 보십시오!
this.posts.update(...)한 줄이 실행되는 순간categoryStats파생 신호가 자동으로 재계산되어 상단의전체 / 개발 / 공지 / 자유배지 숫자가 실시간으로 바뀝니다. 또한 "5개씩 보기"로 바꾼 뒤 브라우저 F5(새로고침)를 눌러도effect가localStorage에 저장해 둔 덕분에 "5개씩 보기"가 그대로 유지됩니다!
2.7 [직접 해보기] 실습 미션 💪
현재 작성 중인 폼의 글자 수를 실시간으로 계산하는 computed() 파생 신호와, 폼이 제출 가능한 상태인지 판별하는 isFormReady 파생 신호를 직접 만들어 보십시오.
📝 미션 요구사항
contentLength = computed(() => this.newContent().trim().length)파생 신호를 추가하십시오.- 제목(
newTitle)과 작성자(newAuthor)가 각각 1자 이상이고 본문(contentLength())이 5자 이상일 때만true가 되는 파생 신호isFormReady = computed(...)를 작성하십시오. - 템플릿의
<textarea>아래에<small>현재 본문 글자 수: {{ contentLength() }}자 (최소 5자)</small>안내를 띄우고, 등록 버튼의[disabled]조건을[disabled]="isSubmitting() || !isFormReady()"로 연결해 보십시오.
💡 미션 정답 코드 확인하기
typescript
// board-app.component.ts 내부에 파생 신호 2개 추가
contentLength = computed(() => this.newContent().trim().length);
isFormReady = computed(() => {
return (
this.newTitle().trim().length >= 1 &&
this.newAuthor().trim().length >= 1 &&
this.contentLength() >= 5
);
});html
<!-- board-app.component.html의 등록 버튼 및 textarea 아래 수정 -->
<button
type="submit"
[disabled]="isSubmitting() || !isFormReady()"
class="btn-submit"
>
{{ isSubmitting() ? '등록 중...' : '게시글 등록' }}
</button>
<!-- textarea 바로 아래에 글자 수 실시간 표시 -->
<div style="text-align: right; font-size: 12px; color: #64748b; margin-top: 4px;">
현재 본문 글자 수: <strong>{{ contentLength() }}</strong>자 (최소 5자 이상 입력 시 등록 버튼 활성화)
</div>2.8 이번 장 핵심 요약 및 다음 단계 🚀
핵심 요약 3줄
- 과거 Zone.js는 비동기 이벤트마다 컴포넌트 트리 전체를 훑었지만(Dirty Checking), Angular 22 Signals는 값을 읽은 소비자(Consumer)와 생산자(Producer)를 양방향 그래프로 직결해 바뀐 DOM만 핀포인트로 갱신합니다.
- 화면에 표시할 파생 데이터는 지연 평가와 캐싱을 지원하는 **
computed()**로 만들고,localStorage저장 같은 외부 부수 효과만 **effect()**로 처리합니다. - Signals의 Push-Pull 2단계 알고리즘은 다이아몬드 의존성에서도 불완전한 중간값이 노출되지 않는 완벽한 **글리치 프리(Glitch-Free)**를 보장합니다.
다음 단계: "신호를 읽을 때 구독자 명단에 적고, 신호를 쓸 때 명단의 함수를 다시 실행한다"는 이 원리를 우리 손으로 직접 만들어 보면 어떨까요? 이어지는 Chapter 02.5에서 단 50줄의 순수 TypeScript로 createSignal, createComputed, createEffect를 밑바닥부터 구현해 마법의 장막을 완전히 걷어내 봅시다!