앵귤러 중급 강좌 #4 컴포넌트 라이프사이클 훅

8 분 소요

지난 시간에는 RxJS를 다루면서 마지막에 “구독은 언젠가 정리해야 한다"는 이야기를 짧게 했습니다. 그 “언젠가"가 정확히 언제인지, 그리고 그 외에도 컴포넌트가 살아 있는 동안 우리가 끼어들 수 있는 지점이 어디인지를 이번 시간에 본격적으로 다뤄보겠습니다.

앵귤러 컴포넌트는 그냥 화면에 그려졌다가 사라지는 게 아닙니다. 태어나고, 입력을 받고, 그려지고, 다시 입력을 받고, 정리되고, 사라지는 일련의 단계를 거칩니다. 이 단계마다 앵귤러가 우리가 정의한 메서드를 불러주는 것이 **라이프사이클 훅(lifecycle hooks)**입니다.

라이프사이클의 큰 그림 #

흐름부터 한 장으로 정리해보겠습니다.

컴포넌트 라이프사이클 흐름
constructor()           // 클래스 생성 (DI 받기)
ngOnChanges()           // 입력 값이 처음 세팅됨 (이후로도 입력 변화마다)
ngOnInit()              // 초기화. 입력 사용 가능
ngDoCheck()             // 변화 감지마다 호출 (자주)
ngAfterContentInit()    // <ng-content>로 들어온 자식이 준비됨
ngAfterViewInit()       // 자기 템플릿이 그려짐. ViewChild 사용 가능
   ▼ (변화 감지 사이클마다 ngOnChanges 이하 반복)
ngOnDestroy()           // 컴포넌트 제거. 정리(cleanup) 단계

복잡해 보이지만 실무에서 자주 쓰는 건 손에 꼽힙니다. **ngOnInit, ngOnDestroy, ngOnChanges, ngAfterViewInit**이 네 개를 먼저 익히면 90%는 해결됩니다.

ngOnInit — 가장 많이 쓰는 훅 #

가장 먼저, 그리고 가장 자주 만나게 될 훅이 ngOnInit입니다. 컴포넌트의 초기화 로직을 두는 단계입니다.

src/app/article.component.ts
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에.

앵귤러 17 이후의 input() 시그널은 입력이 시그널이라 컴포넌트 어디서든 자연스럽게 읽을 수 있어 ngOnInit의 부담이 더 줄어듭니다. 그래도 “한 번만 실행할 비동기 초기화"는 여전히 ngOnInit이 자연스러운 단계입니다.

ngOnDestroy — 정리 단계 #

컴포넌트가 트리에서 제거되기 직전에 호출됩니다. 외부 자원과의 연결을 끊는 단계입니다.

src/app/clock.component.ts
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 연산자가 구독 정리를 한 줄로 처리해줍니다.

src/app/search.component.ts
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 인터페이스를 구현하지 않아도 됩니다.

src/app/listener.component.ts
ngOnInit() {
  const onScroll = () => (this.scrollY = window.scrollY);
  window.addEventListener('scroll', onScroll);

  this.destroyRef.onDestroy(() => {
    window.removeEventListener('scroll', onScroll);
  });
}

새 코드라면 이 패턴을 우선 고려하세요.

ngOnChanges — 입력이 바뀔 때마다 #

@Input()(또는 신규 input())으로 들어오는 값이 바뀔 때마다 호출됩니다. 어떤 입력이 무엇으로 바뀌었는지 SimpleChanges 객체로 받습니다.

src/app/chart.component.ts
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() { /* ... */ }
}

ngOnChangesngOnInit 직전에 첫 호출이 한 번 더 일어납니다(첫 입력 세팅). 이후로는 입력이 바뀔 때마다 호출됩니다.

다만 input() 시그널을 쓰는 코드라면 ngOnChanges 대신 effect()computed()로 입력 변화에 반응하는 쪽이 자연스럽습니다(아래에서 다룹니다).

ngAfterViewInit — DOM 접근이 가능해지는 시점 #

ngOnInit은 입력은 사용할 수 있지만 자기 템플릿이 아직 DOM에 그려지기 전입니다. ViewChild로 가리킨 자식 컴포넌트나 DOM 엘리먼트는 이 시점엔 undefined일 수 있습니다. 자기 템플릿이 그려진 다음에 호출되는 게 ngAfterViewInit입니다.

src/app/auto-focus-input.component.ts
@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이 정답입니다.

노트
앵귤러 17+에서는 viewChild() 시그널 함수가 새로 들어와서 @ViewChild 데코레이터를 대체합니다. 시그널이라 ngAfterViewInit 안 쓰고도 effect()로 변화를 잡을 수 있습니다. 다만 데코레이터 방식 코드도 한참 더 만나게 될 테니 양쪽을 다 알아두는 게 좋습니다.

나머지 훅들 — 짧게만 #

실무에서 거의 만나지 않지만 이름은 알아두면 좋습니다.

  • ngDoCheck — 변화 감지 사이클마다 매번 호출됩니다. 호출이 잦으니 무거운 로직은 금물. 앵귤러가 자동으로 잡지 못하는 변화(예: 배열 내부 수정)를 직접 검출해야 할 때만 씁니다.
  • ngAfterContentInit / ngAfterContentChecked<ng-content>로 투영된(projected) 콘텐츠가 준비/검사된 직후. ContentChild 쿼리를 다룰 때 가끔 만납니다.

99%의 컴포넌트는 이 셋을 만질 일이 없으니 “이런 게 있었지” 정도만 알아두세요.

Signals 시대의 라이프사이클 — effect() #

앵귤러 16부터 들어온 Signals는 라이프사이클을 다루는 방식 자체를 바꾸고 있습니다. 핵심은 effect()입니다.

src/app/counter.component.ts
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도 거의 대체할 수 있습니다.

src/app/chart-signal.component.ts
@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에서 비동기 결과를 기다리지 않는다 #

ngOnInitasync로 만들어도 앵귤러는 그것을 기다려주지 않습니다. await가 끝나기 전에 다음 라이프사이클이 진행됩니다. 렌더링이 데이터를 기다리도록 만들려면 시그널이나 @if로 “데이터 도착 여부"를 판단하는 패턴을 쓰세요.

@if (user(); as u) {
  <p>{{ u.name }}</p>
} @else {
  <p>로딩 ...</p>
}

2. ngOnInit에서 ViewChild에 접근 #

ngOnInit 시점엔 자기 템플릿이 아직 그려지지 않아서 ViewChildundefined일 수 있습니다. 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 패턴을 다뤄보겠습니다.

X