Angular의 출발점, Service와 의존성 주입
Angular는 React나 Vue와 출발점이 다릅니다. 별도의 상태 관리 라이브러리를 도입하기 전에, 프레임워크 자체의 의존성 주입 (DI) 시스템으로 상태를 공유할 수 있었습니다.
interface Todo { text: string; done: boolean; }
@Injectable({ providedIn: 'root' })
export class TodoService {
private todos: Todo[] = [];
getAll() { return this.todos; }
add(text: string) {
this.todos.push({ text, done: false });
}
toggle(index: number) {
this.todos[index].done = !this.todos[index].done;
}
}providedIn: 'root'로 등록하면 앱 전체에서 하나의 인스턴스를 공유합니다. 어떤 컴포넌트든 생성자에서 주입받아 사용할 수 있습니다. Redux 같은 보일러플레이트 없이, 일반 TypeScript 클래스만으로 전역 상태를 관리할 수 있습니다.
단순하고 직관적입니다. 하지만 한 가지 질문이 남습니다, Service의 데이터가 바뀌면 컴포넌트는 어떻게 알 수 있을까요?
Zone.js, 마법 같은 변경 감지
Angular의 답은 Zone.js 였습니다. Zone.js는 브라우저의 비동기 API (setTimeout, addEventListener, Promise, XMLHttpRequest 등) 를 몽키패치 하여, 비동기 작업이 완료될 때마다 Angular에게 알려주는 라이브러리입니다.
// Zone.js가 하는 일 (개념적으로)
const originalSetTimeout = window.setTimeout;
window.setTimeout = function(callback, delay) {
return originalSetTimeout(() => {
callback();
angular.triggerChangeDetection(); // 비동기 작업 후 검사
}, delay);
};이벤트 핸들러, HTTP 응답, 타이머, 어떤 비동기 작업이 끝나든, Zone.js가 Angular에게 "뭔가 바뀌었을 수 있다"고 알립니다. 그러면 Angular는 전체 컴포넌트 트리를 위에서 아래로 순회 하면서 변경된 것이 있는지 검사합니다.
개발자 입장에서는 편리합니다. 상태를 바꾸기만 하면 알아서 화면이 업데이트되니까요. 하지만 구조적인 문제가 있습니다.
Zone.js의 한계
첫째, "무엇이 변경되었는지" 모릅니다. Zone.js는 "비동기 작업이 끝났다"만 알려줄 뿐, 어떤 데이터가 변경되었는지는 알지 못합니다. 그래서 Angular는 전체 컴포넌트 트리를 검사해야 합니다. 컴포넌트가 수백 개인 앱에서, 버튼 하나를 클릭했을 뿐인데 모든 컴포넌트의 바인딩을 확인하는 것은 낭비입니다.
둘째, 모든 비동기 API를 패치합니다. Zone.js의 번들 크기는 약 33KB (gzipped) 입니다. 그 자체로 작지 않고, 브라우저의 네이티브 API를 덮어쓰기 때문에 디버깅이 어려워지고, 서드파티 라이브러리와 충돌할 수 있습니다.
셋째, 불필요한 검사를 막기 위한 수동 최적화가 필요합니다. OnPush 전략, ChangeDetectorRef.detach(), NgZone.runOutsideAngular() 같은 도구를 사용하여 개발자가 직접 변경 감지를 제어해야 합니다.
@Component({
changeDetection: ChangeDetectionStrategy.OnPush // 수동 최적화
})
export class TodoListComponent {
constructor(private cdr: ChangeDetectorRef) {}
onDataUpdate() {
// 수동으로 검사 트리거
this.cdr.markForCheck();
}
}이런 최적화를 하지 않으면 성능이 나빠지고, 하려면 Angular의 변경 감지 메커니즘을 깊이 이해해야 합니다.
NgRx, Redux를 Angular로
복잡한 앱에서 Service만으로는 상태 관리가 어려워지면서, 커뮤니티에서 NgRx (2015) 가 등장했습니다. Redux 패턴을 Angular에 맞게 가져오되, RxJS의 Observable 위에 구축했습니다.
// NgRx, Action
export const addTodo = createAction(
'[Todo] Add',
props<{ text: string }>()
);
// NgRx, Reducer
export const todoReducer = createReducer(
initialState,
on(addTodo, (state, { text }) => ({
...state,
todos: [...state.todos, { text, done: false }]
}))
);
// NgRx, Effect (비동기)
@Injectable()
export class TodoEffects {
loadTodos$ = createEffect(() =>
this.actions$.pipe(
ofType(loadTodos),
switchMap(() => this.api.getAll()),
map(todos => loadTodosSuccess({ todos }))
)
);
}
// NgRx, Selector
export const selectDoneTodos = createSelector(
selectTodos,
todos => todos.filter(t => t.done)
);NgRx는 강력합니다. 상태 추적, 시간 여행 디버깅, 사이드 이펙트 관리, Redux가 제공하는 모든 것에 RxJS의 반응형 프로그래밍을 더했습니다.
하지만 대가도 Redux와 동일합니다. Action, Reducer, Effect, Selector, 하나의 기능을 위해 4개의 파일을 작성해야 합니다. RxJS의 학습 곡선까지 더해져, NgRx는 Angular 생태계에서 "강력하지만 무겁다"는 평가를 받았습니다.
Signals, 근본적인 해결
Angular 16 (2023.05) 에서 Signals 가 developer preview로 도입되었고, Angular 17 (2023.11) 에서 안정화되었습니다. Signals는 Zone.js의 "전체 검사"를 대체할 세밀한 반응성 프리미티브 입니다.
import { signal, computed, effect } from '@angular/core';
// Signal, 반응성의 최소 단위
const count = signal(0);
// Computed, Signal에서 파생된 값
const doubled = computed(() => count() * 2);
// Effect, Signal 변경에 반응
effect(() => {
console.log(`count: ${count()}, doubled: ${doubled()}`);
});
// 값 변경
count.set(1); // effect 재실행
count.update(v => v + 1); // 이전 값 기반 업데이트Zone.js와 Signals의 차이
| Zone.js | Signals | |
|---|---|---|
| 변경 감지 방식 | 비동기 작업 패치 → 전체 트리 검사 | 의존성 그래프로 영향받는 곳만 업데이트 |
| "무엇이 변경되었는지" | 모름 | 알고 있음 |
| 수동 최적화 | OnPush, detach() 등 필요 | 불필요, 자동으로 최적화 |
| 번들 크기 | Zone.js ~33KB | Signal 런타임 최소 |
| 서드파티 호환성 | 패치 충돌 가능 | 네이티브 API 건드리지 않음 |
핵심 차이는 의존성 추적 입니다. computed(() => count() * 2)가 실행될 때, Angular는 이 computed가 count Signal에 의존한다는 것을 자동으로 기록합니다. count가 바뀌면 이 computed만 재계산하고, 이 computed를 사용하는 컴포넌트만 업데이트합니다. 전체 트리를 검사할 필요가 없습니다.
컴포넌트에서의 사용
@Component({
template: `
<p>Count: {{ count() }}</p>
<p>Doubled: {{ doubled() }}</p>
<button (click)="increment()">+1</button>
`
})
export class CounterComponent {
count = signal(0);
doubled = computed(() => this.count() * 2);
increment() {
this.count.update(v => v + 1);
}
}OnPush 설정도, ChangeDetectorRef도 필요 없습니다. Signal을 사용하는 것만으로 최적의 변경 감지가 자동으로 적용됩니다.
Signal 기반 Input/Output
Angular 17.x에서 컴포넌트의 Input과 Output도 Signal 기반으로 작성할 수 있게 되었습니다 (input() 은 v17.1, output() 은 v17.3에서 도입).
@Component({ /* ... */ })
export class ChildComponent {
// 기존: @Input() name: string = '';
name = input<string>(''); // Signal Input
nameRequired = input.required<string>(); // 필수 Input
// 기존: @Output() clicked = new EventEmitter();
clicked = output<void>(); // Signal Output
}Zone.js의 퇴장
Angular v20.2에서 Zoneless 모드가 안정화되었으며, v21부터는 Zone.js가 기본 번들에 포함되지 않습니다. 약 10년간 Angular의 핵심이었던 Zone.js가 Signals로 대체된 것입니다.
이 전환은 점진적으로 이루어졌습니다. Angular 16에서 Signals를 도입하면서도 Zone.js와의 공존을 보장했고, 개발자들에게 마이그레이션 시간을 충분히 주었습니다.
Service에서 Signals로의 교훈
Angular의 상태 관리 발전사에서 가장 인상적인 점은, 변경 감지의 근본적인 메커니즘 자체를 바꿨다 는 것입니다. Vue는 Vuex에서 Pinia로 API를 개선했지만, 반응성 시스템 자체는 유지했습니다. Angular는 Zone.js라는 기반을 걷어내고 Signals로 교체했습니다.
"모든 비동기 작업 후 전체를 검사한다"에서 "변경된 것만 정확히 추적한다"로의 전환입니다. 이 방향은 Vue의 ref/reactive, Solid의 Signals, Svelte의 $state와 동일합니다. 프레임워크들이 같은 결론에 도달하고 있다는 것 자체가, 세밀한 반응성이 올바른 방향임을 시사합니다.
다음 단계
Vue와 Angular가 프레임워크 내부에서 상태 관리를 발전시키는 동안, React 생태계에서는 전혀 다른 현상이 일어났습니다. "뷰 레이어만 담당한다"는 React의 철학이 수십 개의 상태 관리 라이브러리를 탄생시켰고, 그 과정에서 클로저 기반 스토어, tearing 문제, useSyncExternalStore라는 독특한 기술적 진화가 이루어졌습니다.