Angular

[Angular] 3. 생명주기(life cycle)와 훅 메소드

엄정식 2024. 11. 14. 09:55

컴포넌트와 디렉티브는 생명주기를 가짐
이 생명주기는 컴포넌트와 디렉티브가 생성하고 소멸되기까지의 여러 과정을 말하며
Angular에 의해 관리

다시 말해 Angular는 생명주기를 통해 컴포넌트와 디렉티브를 생성하고 렌더링하며,
프로퍼티의 변화를 체크하고 DOM에서 제거하는 일련의 과정을 관리
개발자는 Angular가 제공하는 생명주기 훅 메소드를 구현하여,
각각의 생명주기 단계에서 처리하여야 할 행위를 정의할 수 있음

Angular는 위 그림의 순서대로 생명주기를 관리하고,
생명주기 이름 앞에 ng가 붙은 생명주기 훅 메소드를 제공

1. 생명주기 훅 메소드

생명주기 훅 메소드는 인터페이스의 형태로 제공

예를 들어 OnInit 생명주기에 처리되어야 할 행위를 정의하기 위해서는 훅 메소드 ngOnInit를 구현
이 ngOnInit 메소드는 추상 메소드이며 OnInit 인터페이스에 포함되어 있음

interface OnInit {
	ngOnInit(): void
}

이와 같이 생명주기(OnInit)에는 동일한 이름의 인터페이스(OnInit)가 존재.
그리고 이 인터페이스는 생명주기 이름 앞에 ng 접두어가 붙은 추상메소드 (ngOnInit)를 포함

생명주기 (OnInit)에 처리되어야 할 행위를 정의하려면,
생명주기 인터페이스 (OnInit)의 추상메소드(ngOnInit)를 구현해야 함

import { Component, OnInit } from '@angular/core';

...
export class AppComponent implements OnInit {
	name = 'Lee';

	constructor() { }

	// 생명주기 OnInit 단계에 실행할 처리를 구현한다.
	ngOnInit() {...}
}

컴포넌트와 디렉티브는 클래스이므로 constructor의 호출에 의해 생성
그 이후, Angular는 특별한 시점에 구현된 생명주기 훅 메소드를 호출

물론 모든 생명주기 훅 메소드를 구현할 필요는 없음
특정 생명주기에 처리해야 할 행위가 있을 때, 필요한 생명주기 훅 메소드만 구현하면 됨
각각의 생명주기 훅 메소드가 어떤 시점에 호출되는지 알아보자

1) ngOnChanges

부모 컴포넌트에서 자식 컴포넌트의 입력 프로퍼티(@Input)로 바인딩한 값이 초기화 또는 변경되었을 때 실행
따라서 컴포넌트에 입력 프로퍼티가 없을 때는 호출할 필요가 없음

class MyComponent implements OnChanges {
	@Input() props1: number;
	@Imput() props2: string;

	ngOnChanges(changes: SimpleChanges) {
		// changes는 모든 입력 프로퍼티의 이전 값과 현재 값을 포함한다.
		console.log(changes);

		/*
		{ prop1: SimpleChange, prop2: SimpleChange}
			prop1: SimpleChange {previousValue: undefined, currentValue: 100, firstChange: true}
			prop2: SimpleChange {previousValue: undefined, currentValue: "string", firstChange: true}
		*/
		}
	}

ngOnChanges는 ngOnInit 이전에 입력 프로퍼티가 존재하는 경우, 최소 1회 호출
이후에는 입력 프로퍼티가 변경될 때마다 반복 호출
(이때의 변경은 입력 프로퍼티의 참조 변경을 말함)
다시 말해 기본 자료형의 값이 재할당되없을 때와 객체의 참조가 변경되었을 때만 반응함

ngOnChanges는 입력 프로퍼티의 참조 변경에만 반응하므로,
입력 프로퍼티가 아닌 일반 컴포넌트 프로퍼티의 내용이 변경되었을 때는 반응하지 않음

ngOnChanges 메소드는 입력 프로퍼티의 변경 정보를 담고있는
simpleChanges 객체를 인자로 전달받을 수 있음
이 객체는 모든 입력 프로퍼티의 이전 값과 현재 값을 포함

2) ngOnInit

ngOnChanges 이후, 입력 프로퍼티를 포함한 모든 프로퍼티의 초기화가 완료된 시점에 한번만 호출

constructor는 TypeScript 클래스의 메소드로서 인스턴스 생성을 위해 호출
Angular에서 constructor는 의존성 주입을 위해 사용되기도 하지만,
Angular의 생명주기와 직접적인 관계는 없음

⚠️ TypeScript에서는 constructor에서 프로퍼티를 초기화하는 것이 일반적이지만,
Angular에서 프로퍼티의 초기화 처리는 contructor가 아닌 ngOnInit에서 수행하는 것이 좋음

간단한 값으로 프로퍼티를 초기화하는 것은 문제가 되지 않지만,
서버에서 데이터를 가져와 할당하는 것과 같이 복잡한 처리는 constructor가 아닌 ngOnInit에서 수행해야 함

constructor가 실행되는 시점에 Angular에서 관리하는 입력 프로퍼티는 초기화되기 이전의 상태이며 undefined가 반환됨 만일 이 시점에 입력 프로퍼티를 사용한다면 의도하지 않은 결과가 발생하기 때문에 주의해야 함

ngOnInit에서는 입력 프로퍼티의 참조가 보장돼야 함
입력 프로퍼티의 초기화는 ngOnChanges에서 처음 수행되지만,
ngOnChanges는 입력 프로퍼티가 변경될 때마다 반복 호출
따라서 프로퍼티 초기화를 수행하기에 가장 적당한 훅 메소드는 ngOnInit이다.

 

3) ngDoCheck

ngOnInit 이후, 컴포넌트 또는 디렉티브의 모든 상태 변화가 발생할 때마다 호출
다시 말해 변화 감지(Change Detection) 로직이 실행될 때 호출

ngDoCheck는 Angular의 변화 감지에 의해 감지되지 않거나 감지할 수 없는 변경 사항을,
수동으로 더티 체크(dirty check)하기 위해 사용하자.
커스텀 더티 체크를 통해 사용자 변화 감지 로직을 구현하기 위해서는
Angular가 제공하는 KeyValueDiffers와 IterableDiffers를 사용.

⚠️ 하지만 ngOnCheck는 모든 상태 변화가 발생할 때마다 매번 호출되기 때문에 성능에 악영향을 줄 수 있음
가장 바람직한 것은 Angular의 변화 감지가 상태 변화를 감지하도록 코드를 구현하는 것이지만,
ngDoCheck를 사용할 수밖에 없는 상황이라면 최대한 가벼운 처리로 성능에 무리를 주지 않도록 주의하여야 함

ngOnChanges는 입력 프로퍼티에 바인딩된 값이 초기화 또는 변경(기본 자료형의 값이 재할당되었을 때와 객체의 참조가 변경)되었을 때만 반응하여 호출되지만, ngDoCheck는 모든 상태의 변경에 의해 호출
ngOnChanges와 ngDoCheck의 차이점에 대해서는 뒤에서 보다 자세히 살펴보도록 하자

4) ngAfterContentInit

ngContent 디렉티브를 사용하여 외부 콘텐츠를 컴포넌트의 뷰에 콘텐트 프로젝션한 이후 호출
첫 번째 ngDoCheck 호출 이후에 한 번만 호출되며, 컴포넌트에서만 동작하는 컴포넌트 전용 훅 메소드임

👉 콘텐츠 프로젝션 (Content Projection)

부모 컴포넌트가 자식 컴포넌트에게 부모 컴포넌트의 템플릿 일부를 전달하는 기능.
(@ContentChild, @ContentChildren)


### 5) ngAfterContentChecked

콘텐츠 프로젝션에 의해 부모 컴포넌트가 전달한 부모 컴포넌트의 템플릿 조각을 체크한 후 호출
ngAfterContentInit 호출 이후, ngDoCheck가 호출된 이후에 호출되며
컴포넌트에서만 동작하는 컴포넌트 전용 훅 메소드

6) ngAfterViewInit

컴포넌트의 뷰와 자식 컴포넌트의 뷰를 초기화한 이후 호출
첫 번째 ngAfterContentChecked 호출 이후 한 번만 호출되며
컴포넌트에서만 동작하는 컴포넌트 전용 훅 메소드

7) ngAfterViewChecked

컴포넌트의 뷰와 자식 컴포넌트의 뷰를 체크한 이후 호출
첫 번째 ngAfterViewInit 호출 이후, ngAfterContentChecked 호출 이후 호출되며
컴포넌트에서만 동작하는 컴포넌트 전용 훅 메소드

8) ngOnDestroy

컴포넌트와 디렉티브가 소멸하기 이전 호출
RxJs의 unsubscribe 등 메모리 누수를 방지하기 위한 코드 등을 정의


2. 생명주기 훅 메소드 실습

예제를 통해 훅 메소드가 실제로 동작하는 것을 살펴보며, 각각의 호출 시점과 용도에 대해 알아보도록 하자
(실습 사이트 : https://stackblitz.com/edit/lifecycle-hooks-exam)

부모 컴포넌트

import { Component } from '@angular/core';

@Component({
	selector: 'app-root',
	template: `
		<button (click)="status = !status">
			{{ status ? 'Destroy Child' : 'Create Child' }}
		</button>
		<app-root *ngIf="status" [prop]="prop"></app-child>
		`
	})
	export class AppComponent {
		status = false;
		prop = 'Hello';
	}

자식 컴포넌트를 아래와 같이 작성합니다.
자식 컴포넌트는 @Input 데코레이터를 사용하여 부모 컴포넌트로부터 상태를 전달받음
생성자 함수와 모든 생명주기 훅 메소드를 구현하여 훅 메소드의 호출 여부를 확인하여 보자

자식 컴포넌트

import { Component, Input, OnChanges, OnInit, DoCheck, AfterContentInit, AfterContentChecked, AfterViewInit, AfterViewChecked, OnDestroy, SimpleChanges } from '@angular/core';

@Component({
  selector: 'app-child',
  template: `
    <p>child component</p>
    <p>부모 컴포넌트가 전달한 값: {{ prop }}</p>
  `
})
export class ChildComponent implements OnChanges, OnInit, DoCheck, AfterContentInit, AfterContentChecked, AfterViewInit, AfterViewChecked, OnDestroy {
  @Input() prop: string;

  constructor() {
    console.log('[construnctor]');
    console.log(`prop: ${this.prop}`);
    this.prop = 'TEST';
    console.log(`prop: ${this.prop}`);
  }

  ngOnChanges(changes: SimpleChanges) {
    console.log('[OnChanges]');
    console.log(`prop: ${this.prop}`);
    console.log('changes:', changes);
  }

  ngOnInit() {
    console.log('[OnInit]');
    console.log(`prop: ${this.prop}`);
  }

  ngDoCheck() {
    console.log('[DoCheck]');
  }

  ngAfterContentInit() {
    console.log('[ngAfterContentInit]');
  }

  ngAfterContentChecked() {
    console.log('[ngAfterContentChecked]');
  }

  ngAfterViewInit() {
    console.log('[ngAfterViewInit]');
  }

  ngAfterViewChecked() {
    console.log('[ngAfterViewChecked]');
  }

  ngOnDestroy() {
    console.log('[ngOnDestroy]');
  }
}

[Create Child] 버튼을 클릭하면 자식 컴포넌트가 생성되면서 훅 메소드가 순차적으로 호출
버튼 클릭 후 콘솔에 출력되는 결과는 다음과 같음

[Constructor]
prop: undefined
prop: TEST

[OnChanges]
prop: Hello
changes: {prop: SimpleChange}
prop: SimpleChange {previousValue: undefined, currentValue: "Hello", firstChange: true"
__proto__: Object
  
[OnInit]
prop: Hello

[DoCheck]

[ngAfterContentInit]

[ngAfterContentChecked]

[ngAfterViewInit]

[ngAfterViewChecked]

가장 먼저 constructor가 호출

constructor는 인스턴스 생성을 위해 호출되며 Angular가 관리하는 메소드가 아님
constructor에서 입력 프로퍼티를 참조하면 undefined가 출력되는데,
이는 입력 프로퍼티의 초기화가 OnChanges에서 완성되기 때문
따라서 위 예제처럼 constructor에서 입력 프로퍼티를 초기화하는 것은 무의미한 행위이며 해서도 안됨

ngOnChanges는 자식 컴포넌트에 입력 프로퍼티가 존재하기 때문에 실행
만약 자식 컴포넌트에 입력 프로퍼티가 존재하지 않으면 ngOnChanges는 호출되지 않음
이후 OnInit, DoCheck, ngAfterContentInit, ngAfterContentChecked, ngAfterViewInit, ngAfterViewChecked가 순차적으로 호출되는 것을 확인할 수 있음

이제 생성된 자식 컴포넌트를 소멸시켜 보자
[Destroy Child] 버튼은 ngIf에 false를 할당하여 자식 컴포넌트를 소멸시킴
[Destroy Child] 버튼을 클릭하면 ngOnDestroy가 호출되는 것을 확인할 수 있음


ngOnChanges와 ngDoCheck

ngOnChanges와 ngDoCheck는 모두 상태 변화와 관계가 있음
하지만 ngOnChange는 입력 프로퍼티의 초기화, 입력 프로퍼티의 참조 변경 시에 호출되고,
ngOnCheck는 모든 상태 변화 시점, 즉 변화 감지 로직이 실행될 때 호출

하지만 객체의 경우, 프로퍼티의 값을 변경하여도 객체의 참조는 변경되지 않기 때문에
ngOnChanges는 이 변화에 반응하지 않음
즉, 기본 자료형이나 불변 객체와 같이 이뮤터블한 값에만 반응

아래에서 실습을 통해 확인해 봅자
(실습 사이트 : https://stackblitz.com/edit/lifecycle-hooks-ngonchanges-ngdocheck)

부모 컴포넌트

import { Component } from '@angular/core';

@Component({
  selector: 'app-root',
  template: `
    <button (click)="status = !status">
      {{ status ? 'Destroy Child' : 'Create Child' }}
    </button>
    <div *ngIf="status">
      <button (click)="immutable='HELLO'">
        기본자료형 프로퍼티 변경
      </button>
      <button (click)="mutable.name='kim'">
        객체형 프로퍼티 변경
      </button>

      <app-child 
        [immutable]="immutable" 
        [mutable]="mutable"></app-child>
    </div>
  `
})
export class AppComponent {
  status = false;

  immutable = 'Hello';
  mutable = { name: 'Lee' };
}

부모 컴포넌트의 버튼에 의해 자식 컴포넌트가 생성 소멸하도록 ngIf 디렉티브를 사용하도록 함
자식 컴포넌트가 생성된 이후 2개의 버튼이 나타나는데,
이 버튼이 변경하고 전달하는 값은 이뮤터블한 값과 뮤터블한 값

자식 컴포넌트

import { Component, Input, OnChanges, OnInit, DoCheck, AfterContentInit, AfterContentChecked, AfterViewInit, AfterViewChecked, OnDestroy, SimpleChanges } from '@angular/core';

@Component({
  selector: 'app-child',
  template: `
    <p>child component</p>
    <p>부모 컴포넌트가 전달한 값: {{ immutable }}</p>
    <p>부모 컴포넌트가 전달한 값: {{ mutable | json }}</p>
  `
})
export class ChildComponent implements OnChanges, OnInit, DoCheck, AfterContentInit, AfterContentChecked, AfterViewInit, AfterViewChecked, OnDestroy {
  @Input() immutable: string;
  @Input() mutable: object;
  prop = 'normal prop';

  constructor() {
    console.log('[construnctor]');
  }

  ngOnChanges(changes: SimpleChanges) {
    console.log('[OnChanges]');
    console.log(`changes:`, changes);
    console.log(`immutable: ${this.immutable}`);
    console.log(`mutable:`, this.mutable);
  }

  ngOnInit() {
    console.log('[OnInit]');
    console.log(`prop: ${this.prop}`);
    console.log(`immutable: ${this.immutable}`);
    console.log(`mutable:`, this.mutable);
  }

  ngDoCheck() {
    console.log('[DoCheck]');
    console.log(`immutable: ${this.immutable}`);
    console.log(`mutable:`, this.mutable);
  }

  ngAfterContentInit() {
    console.log('[ngAfterContentInit]');
  }

  ngAfterContentChecked() {
    console.log('[ngAfterContentChecked]');
  }

  ngAfterViewInit() {
    console.log('[ngAfterViewInit]');
  }

  ngAfterViewChecked() {
    console.log('[ngAfterViewChecked]');
  }

  ngOnDestroy() {
    console.log('[ngOnDestroy]');
  }
}

자식 컴포넌트는 @Input 컴포넌트를 사용하여 부모 컴포넌트로부터 상태를 전달받음
이때 전달된 값의 타입에 따라 다르게 동작하는 것을 확인할 수 있음

  • 👉 기본 자료형 프로퍼티 변경

첫번째 <기본 자료형 프로퍼티 변경> 버튼을 클릭하면
기본 자료형인 string타입의 값을 자식 컴포넌트에 전송함
이때 자식 컴포넌트의 입력 프로퍼티 immutable에 새로운 값이 재할당

입력 프로퍼티 immutable은 기본 자료형이므로 입력 프로퍼티 immutable의 값은 절대로 변경할 수 없습니다. 재할당 이전의 값인 문자열 "Hello"를 변경하는 것이 아니라,
새로운 문자열 "Hello"를 메모리에 생성하고 입력 프로퍼티 immutable은 이것을 가르킴

즉, 재할당 시에 입력 프로퍼티 immutable이 가리키는 참조가 변경
따라서 ngOnChanges가 호출

첫 번째 <기본 자료형 프로퍼티 변경> 버튼을 클릭한 후 콘솔에 출력되는 결과는 다음과 같음

[OnChanges]
changes: {immutable: SimpleChange}
	immutable:SimpleChange {previousValue: "Hello", currentValue: "HELLO", firstChange: false
immutable: HELLO
mutable: {name: "Lee"}

[DoCheck]
immutable: STRING
mutable: {name: "Lee"}

[ngAfterContentChecked]

[ngAfterViewChecked]
  • 👉 객체형 프로퍼티 변경

두 번째 <객체형 프로퍼티 변경> 버튼을 클릭하면 객체의 프로퍼티값을 변경하여 자식 컴포넌트에 전송
이때 객체는 뮤터블 하므로, 객체의 프로퍼티값을 변경하도라도 참조가 변경되지 않음
따라서 입력 프로퍼티가 변경되지 않은 것으로 간주되어 OnChanges가 호출되지 않음

하지만 Angular의 변화 감지 로직은 실행되며 변화 감지 로직이 실행될 때 호출되는 ngDoCheck도 호출
두 번째 <객체형 프로퍼티 변경> 버튼을 클릭한 후 콘솔에 출력되는 결과는 다음과 같음

[DoCheck]
immutable: HELLO
mutable: {name: "kim"}

[ngAfterContentChecked]

[ngAfterViewChecked]

ngDoCheck는 모든 상태 변경에 의해 호출
따라서 입력 프로퍼티뿐만 아니라 컴포넌트 프로퍼티가 변경되어도 호출

예를 들어, 자식 컴포넌트 템플릿에 아래의 버튼을 추가하고 클릭하면 ngDoCheck는 호출

<<button (click)="prop=checked!">컴포넌트 프로퍼티 변경</button>

 


3. 디렉티브 생명주기 훅 메소드

디렉티브도 컴포넌트와 동일한 생명주기와 훅 메소드를 사용
다만, 뷰가 없는 디렉티브의 특성상 뷰에 관련한 생명주기는 디렉티브에 존재하지 않음

뷰와 관련된 생명주기인
AfterViewInit, AfterViewChecked, AfterContentInit, AfterContentChecked는
디렉티브 생명주기에 존재하지 않는 컴포넌트 전용 생명주기이다.

따라서 디렉티브 생명주기에는 OnChanges, DoCheck, OnInit, OnDestroy만이 존재