앵귤러 중급 강좌 #4 컴포넌트 라이프사이클 훅
지난 시간에는 RxJS를 다루면서 마지막에 “구독은 언젠가 정리해야 한다"는 이야기를 짧게 했습니다. 그 “언젠가"가 정확히 언제인지, 그리고 그 외에도 컴포넌트가 살아 있는 동안 우리가 끼어들 수 있는 지점이 어디인지를 이번 시간에 본격적으로 다뤄보겠습니다.
앵귤러 컴포넌트는 그냥 화면에 그려졌다가 사라지는 게 아닙니다. 태어나고, 입력을 받고, 그려지고, 다시 입력을 받고, 정리되고, 사라지는 일련의 단계를 거칩니다. 이 단계마다 앵귤러가 우리가 정의한 메서드를 불러주는 것이 **라이프사이클 훅(lifecycle hooks)**입니다.
라이프사이클의 큰 그림 #
흐름부터 한 장으로 정리해보겠습니다.
constructor() // 클래스 생성 (DI 받기)
▼
ngOnChanges() // 입력 값이 처음 세팅됨 (이후로도 입력 변화마다)
▼
ngOnInit() // 초기화. 입력 사용 가능
▼
ngDoCheck() // 변화 감지마다 호출 (자주)
▼
ngAfterContentInit() // <ng-content>로 들어온 자식이 준비됨
▼
ngAfterViewInit() // 자기 템플릿이 그려짐. ViewChild 사용 가능
▼ (변화 감지 사이클마다 ngOnChanges 이하 반복)
▼
ngOnDestroy() // 컴포넌트 제거. 정리(cleanup) 단계복잡해 보이지만 실무에서 자주 쓰는 건 손에 꼽힙니다. **ngOnInit, ngOnDestroy, ngOnChanges, ngAfterViewInit**이 네 개를 먼저 익히면 90%는 해결됩니다.
ngOnInit — 가장 많이 쓰는 훅 #
가장 먼저, 그리고 가장 자주 만나게 될 훅이 ngOnInit입니다. 컴포넌트의 초기화 로직을 두는 단계입니다.
import { Component, Input, OnInit } from '@angular/core';
@Component({
selector: 'app-article',
standalone: true,
template: `<h1>{{ title }}</h1>`,
})
export class ArticleComponent implements OnInit {
@Input() articleId!: string;
title = '';
constructor() {
// articleId는 아직 undefined!
console.log('constructor:', this.articleId);
}
ngOnInit() {
// 여기서는 articleId 값이 들어와 있다
this.loadArticle(this.articleId);
}
private loadArticle(id: string) { /* ... */ }
}여기서 자주 나오는 질문 — “그냥 constructor에서 하면 안 됩니까?” 둘은 역할이 다릅니다.
- constructor — 클래스의 인스턴스를 만드는 단계. 의존성 주입(DI)을 받는 용도로만 쓰는 게 좋습니다. 이 시점엔 아직
@Input()(또는input())으로 들어오는 값이 세팅되지 않았습니다. - ngOnInit — 입력 값이 모두 세팅된 직후에 한 번 호출됩니다. 입력을 사용해야 하는 초기화는 여기에 둡니다.
규칙으로 외워두기: 생성자에는 inject()만, 입력을 쓰는 초기화는 ngOnInit에.
input() 시그널은 입력이 시그널이라 컴포넌트 어디서든 자연스럽게 읽을 수 있어 ngOnInit의 부담이 더 줄어듭니다. 그래도 “한 번만 실행할 비동기 초기화"는 여전히 ngOnInit이 자연스러운 단계입니다.ngOnDestroy — 정리 단계 #
컴포넌트가 트리에서 제거되기 직전에 호출됩니다. 외부 자원과의 연결을 끊는 단계입니다.
export class ClockComponent implements OnInit, OnDestroy {
time = '';
private intervalId?: number;
ngOnInit() {
this.intervalId = window.setInterval(() => {
this.time = new Date().toLocaleTimeString();
}, 1000);
}
ngOnDestroy() {
if (this.intervalId !== undefined) clearInterval(this.intervalId);
}
}setInterval을 정리하지 않으면 컴포넌트가 사라진 뒤에도 콜백이 계속 돌면서 메모리 누수를 만듭니다. RxJS 구독, WebSocket 연결, 전역 이벤트 리스너 — 외부에 끈을 묶어둔 모든 것은 여기서 풀어야 합니다.
다만 모던 앵귤러에서는 ngOnDestroy를 직접 구현하는 일이 점점 줄어들고 있습니다. 더 깔끔한 도구가 들어왔기 때문입니다.
DestroyRef와 takeUntilDestroyed — 모던 cleanup 패턴 #
앵귤러 16부터 들어온 DestroyRef는 “이 컴포넌트가 파괴될 때 실행할 콜백"을 등록할 수 있게 해줍니다. 그리고 RxJS와 결합한 takeUntilDestroyed 연산자가 구독 정리를 한 줄로 처리해줍니다.
import { Component, DestroyRef, inject, OnInit } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { interval } from 'rxjs';
@Component({
selector: 'app-search',
standalone: true,
template: `<p>틱: {{ count }}</p>`,
})
export class SearchComponent implements OnInit {
private destroyRef = inject(DestroyRef);
count = 0;
ngOnInit() {
interval(1000)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(n => (this.count = n));
}
}takeUntilDestroyed는 컴포넌트가 파괴되는 순간 자동으로 구독을 정리해줍니다. OnDestroy 인터페이스를 구현할 필요도, Subject를 따로 만들 필요도 없습니다.
DestroyRef는 RxJS가 아닌 일반 정리에도 쓸 수 있습니다. destroyRef.onDestroy(callback) 한 줄로 정리 로직을 등록할 수 있어서 OnDestroy 인터페이스를 구현하지 않아도 됩니다.
ngOnInit() {
const onScroll = () => (this.scrollY = window.scrollY);
window.addEventListener('scroll', onScroll);
this.destroyRef.onDestroy(() => {
window.removeEventListener('scroll', onScroll);
});
}새 코드라면 이 패턴을 우선 고려하세요.
ngOnChanges — 입력이 바뀔 때마다 #
@Input()(또는 신규 input())으로 들어오는 값이 바뀔 때마다 호출됩니다. 어떤 입력이 무엇으로 바뀌었는지 SimpleChanges 객체로 받습니다.
import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
@Component({
selector: 'app-chart',
standalone: true,
template: `<canvas #c></canvas>`,
})
export class ChartComponent implements OnChanges {
@Input() data: number[] = [];
@Input() color = 'blue';
ngOnChanges(changes: SimpleChanges) {
if (changes['data']) {
console.log('이전:', changes['data'].previousValue);
console.log('현재:', changes['data'].currentValue);
this.redraw();
}
// color만 바뀌었을 땐 redraw 생략
}
private redraw() { /* ... */ }
}ngOnChanges는 ngOnInit 직전에 첫 호출이 한 번 더 일어납니다(첫 입력 세팅). 이후로는 입력이 바뀔 때마다 호출됩니다.
다만 input() 시그널을 쓰는 코드라면 ngOnChanges 대신 effect()나 computed()로 입력 변화에 반응하는 쪽이 자연스럽습니다(아래에서 다룹니다).
ngAfterViewInit — DOM 접근이 가능해지는 시점 #
ngOnInit은 입력은 사용할 수 있지만 자기 템플릿이 아직 DOM에 그려지기 전입니다. ViewChild로 가리킨 자식 컴포넌트나 DOM 엘리먼트는 이 시점엔 undefined일 수 있습니다. 자기 템플릿이 그려진 다음에 호출되는 게 ngAfterViewInit입니다.
@Component({
selector: 'app-auto-focus-input',
standalone: true,
template: `<input #box type="text" />`,
})
export class AutoFocusInputComponent implements AfterViewInit {
@ViewChild('box') box!: ElementRef<HTMLInputElement>;
ngAfterViewInit() {
this.box.nativeElement.focus();
}
}ngOnInit에서 같은 일을 시도하면 undefined 에러를 만나게 됩니다. DOM에 직접 손을 대거나 자식 컴포넌트 메서드를 호출해야 한다면 ngAfterViewInit이 정답입니다.
viewChild() 시그널 함수가 새로 들어와서 @ViewChild 데코레이터를 대체합니다. 시그널이라 ngAfterViewInit 안 쓰고도 effect()로 변화를 잡을 수 있습니다. 다만 데코레이터 방식 코드도 한참 더 만나게 될 테니 양쪽을 다 알아두는 게 좋습니다.나머지 훅들 — 짧게만 #
실무에서 거의 만나지 않지만 이름은 알아두면 좋습니다.
ngDoCheck— 변화 감지 사이클마다 매번 호출됩니다. 호출이 잦으니 무거운 로직은 금물. 앵귤러가 자동으로 잡지 못하는 변화(예: 배열 내부 수정)를 직접 검출해야 할 때만 씁니다.ngAfterContentInit/ngAfterContentChecked—<ng-content>로 투영된(projected) 콘텐츠가 준비/검사된 직후.ContentChild쿼리를 다룰 때 가끔 만납니다.
99%의 컴포넌트는 이 셋을 만질 일이 없으니 “이런 게 있었지” 정도만 알아두세요.
Signals 시대의 라이프사이클 — effect() #
앵귤러 16부터 들어온 Signals는 라이프사이클을 다루는 방식 자체를 바꾸고 있습니다. 핵심은 effect()입니다.
export class CounterComponent {
count = signal(0);
constructor() {
effect(() => {
console.log('count가 바뀌었습니다:', this.count());
document.title = `Count: ${this.count()}`;
});
}
}effect()는 자기가 읽은 시그널이 바뀔 때마다 자동으로 다시 실행됩니다. ngOnInit에서 한 번 실행하고, ngOnChanges에서 다시 실행하고, ngOnDestroy에서 정리하던 흐름이 한 줄로 끝납니다.
또한 effect()는 컴포넌트(혹은 등록된 인젝션 컨텍스트)가 파괴되면 자동으로 정리됩니다. DestroyRef를 따로 챙기지 않아도 됩니다.
input() 시그널과 함께 쓰면 ngOnChanges도 거의 대체할 수 있습니다.
@Component({ /* ... */ })
export class ChartSignalComponent {
data = input<number[]>([]);
color = input<string>('blue');
constructor() {
effect(() => {
// data나 color가 바뀔 때마다 자동 실행
this.redraw(this.data(), this.color());
});
}
private redraw(data: number[], color: string) { /* ... */ }
}새 코드라면 가능한 한 시그널 + effect() 조합으로 가는 게 추세입니다. 다만 기존 코드의 ngOnInit/ngOnDestroy를 굳이 다 들어내야 한다는 뜻은 아닙니다. 새로 짜는 부분부터 자연스럽게 바꿔가면 됩니다.
자주 하는 실수 #
라이프사이클 훅 주변에서 처음 빠지기 쉬운 함정들입니다.
1. ngOnInit에서 비동기 결과를 기다리지 않는다 #
ngOnInit을 async로 만들어도 앵귤러는 그것을 기다려주지 않습니다. await가 끝나기 전에 다음 라이프사이클이 진행됩니다. 렌더링이 데이터를 기다리도록 만들려면 시그널이나 @if로 “데이터 도착 여부"를 판단하는 패턴을 쓰세요.
@if (user(); as u) {
<p>{{ u.name }}</p>
} @else {
<p>로딩 중...</p>
}2. ngOnInit에서 ViewChild에 접근 #
ngOnInit 시점엔 자기 템플릿이 아직 그려지지 않아서 ViewChild가 undefined일 수 있습니다. DOM/자식 컴포넌트 접근은 ngAfterViewInit(또는 시그널 기반 viewChild() + effect())으로.
3. OnPush 모드와 라이프사이클 호출 빈도 #
ChangeDetectionStrategy.OnPush로 컴포넌트를 만들면 변화 감지가 훨씬 덜 돌고, ngDoCheck/ngAfterContentChecked 등의 호출도 줄어듭니다. 이런 훅에 비즈니스 로직을 의존시키지 않는 게 안전합니다.
4. constructor에서 무거운 일 하기 #
constructor는 DI 수신 단계이지, 초기화 로직 단계가 아닙니다. HTTP 호출이나 타이머 등록 같은 부수 효과는 ngOnInit 또는 effect()로 옮기세요.
마무리 #
이번 글에서는 컴포넌트의 라이프사이클을 처음부터 끝까지 살펴보고, 그 위에 끼어드는 훅들을 다뤘습니다. 정리하면:
- 라이프사이클 흐름: 생성 → 입력 → 초기화 → 렌더 → (반복) → 파괴
- 가장 자주 쓰는 훅:
ngOnInit,ngOnDestroy,ngOnChanges,ngAfterViewInit - constructor에는 DI만, 입력을 사용하는 초기화는
ngOnInit에 - 정리(cleanup)는
ngOnDestroy보다 **DestroyRef+takeUntilDestroyed**가 더 깔끔 - DOM/
ViewChild접근은ngAfterViewInit시점부터 - Signals 시대에는
effect()가 라이프사이클의 많은 부분을 대체
다음 글인 “앵귤러 중급 강좌 #5 Standalone과 Lazy Loading"에서는 NgModule 없이 동작하는 standalone 컴포넌트의 구조를 더 깊이 보고, 라우트 단위로 코드를 잘게 쪼개 초기 로딩을 빠르게 만드는 lazy loading 패턴을 다뤄보겠습니다.