앵귤러 고급 강좌 #3 동적 컴포넌트와 ViewContainerRef

11 분 소요

지금까지 우리가 만진 컴포넌트는 거의 다 템플릿에 정적으로 작성된 컴포넌트였습니다. 기초 #2에서처럼 <app-user-card></app-user-card>라고 부모 템플릿이 자식의 위치를 직접 적어두면, 앵귤러 컴파일러는 그걸 보고 트리를 빌드합니다.

그런데 실무에는 이 그림이 잘 맞지 않는 경우가 있습니다. 모달, 토스트, 툴팁, 사용자가 고른 위젯을 띄우는 대시보드, 플러그인이 동적으로 등록하는 화면 — 이런 경우는 컴포넌트가 언제,어디에,몇 개나 뜰지 컴파일 시점에 모릅니다. 런타임에 코드가 “이 컴포넌트를 지금 여기에 띄워줘"라고 명령해야 합니다.

이번 글에서는 모던 앵귤러에서 동적 컴포넌트를 다루는 도구들을 한 바퀴 돌아봅니다. ViewContainerRef/createComponent 같은 저수준 API부터 @defer, ngComponentOutlet, CDK Portal 같은 고수준 도구까지, 마지막엔 작은 토스트 서비스를 만들어보면서 마무리하겠습니다.

동적 컴포넌트가 필요한 경우 #

대표적인 후보들을 정리해 봅시다.

  • 모달/다이얼로그 — 부모의 overflow나 z-index를 피하려면 DOM 트리 밖(<body> 직속)으로 보내는 게 편합니다
  • 토스트/알림 — 어디서 호출하든 화면 한쪽에 잠깐 떴다 사라져야 합니다
  • 툴팁/팝오버 — 트리거 요소 위치 기준으로 띄우고, 보통 한 번에 하나만
  • 위젯/플러그인 시스템 — 관리자가 위젯을 고르거나 외부 플러그인이 컴포넌트를 등록하는 영역
  • 위저드 — 단계마다 완전히 다른 컴포넌트가 들어가는 흐름

공통점은 “정적 템플릿으로는 안 풀린다"는 점입니다. 그래서 앵귤러는 템플릿에 위치만 잡아두고 실제 컴포넌트는 코드로 채워 넣는 도구를 여러 단계로 제공합니다.

ViewContainerRef — 뷰가 들어갈 위치 #

가장 기본이 되는 개념이 ViewContainerRef입니다. 이름 그대로 “뷰가 들어갈 컨테이너의 참조”. 보통 템플릿에 마커 요소를 두고 시그널 기반 viewChild()로 참조를 잡습니다.

src/app/host.component.ts
import { Component, ViewContainerRef, viewChild } from '@angular/core';

@Component({
  selector: 'app-host',
  standalone: true,
  template: `
    <h2>여기에 동적 컴포넌트가 들어갑니다</h2>
    <ng-container #slot></ng-container>
  `,
})
export class HostComponent {
  slot = viewChild('slot', { read: ViewContainerRef });
}

<ng-container #slot>는 화면에 아무것도 그리지 않는 마커입니다. read: ViewContainerRef는 그 위치를 ViewContainerRef로 읽어달라는 옵션입니다.

노트
구버전 코드에서는 @ViewChild('slot', { read: ViewContainerRef }) slot!: ViewContainerRef; 형태를 만나게 됩니다. 동작은 동일하지만 시그널 기반 viewChild() 쪽이 타이밍이 더 명확해서, 새로 짤 때는 시그널 형태가 권장됩니다. 또 ng-container 대신 일반 요소(<div #slot>)에 변수를 붙이면 동적 컴포넌트는 그 요소의 형제 위치에 들어갑니다. “그 요소 안쪽"이 아닙니다.

createComponent로 직접 생성 #

위치를 잡았으면 그 위치에 컴포넌트를 만들어 넣을 차례입니다. ViewContainerRef.createComponent()가 가장 단순합니다.

src/app/alert.component.ts
@Component({
  selector: 'app-alert',
  standalone: true,
  template: `
    <strong>{{ title() }}</strong>
    <p>{{ message() }}</p>
    <button (click)="dismiss.emit()">닫기</button>
  `,
})
export class AlertComponent {
  title = input.required<string>();
  message = input.required<string>();
  dismiss = output<void>();
}

호스트에서 이걸 동적으로 띄워보겠습니다.

src/app/host.component.ts
@Component({
  selector: 'app-host',
  standalone: true,
  template: `
    <button (click)="showAlert()">경고 띄우기</button>
    <ng-container #slot></ng-container>
  `,
})
export class HostComponent {
  slot = viewChild('slot', { read: ViewContainerRef });

  showAlert() {
    const container = this.slot();
    if (!container) return;

    container.clear();
    const ref = container.createComponent(AlertComponent);
    ref.setInput('title', '저장 실패');
    ref.setInput('message', '네트워크 연결을 확인해주세요.');
    ref.instance.dismiss.subscribe(() => ref.destroy());
  }
}

흐름이 단순합니다 — clear()로 비우고, createComponent로 인스턴스를 만들고, setInput으로 input을 주입하고, output(dismiss)을 구독해서 닫기 시점에 destroy()로 정리. setInput은 시그널 input과 잘 동작하고 변경 감지를 안전하게 트리거해주니, ref.instance.title.set(...)처럼 인스턴스를 직접 만지는 대신 setInput을 쓰는 게 표준입니다.

옛 코드에서 보던 ComponentFactoryResolverdeprecated입니다. v13부터 createComponent를 직접 호출하는 방식으로 바뀌어서 새 코드에서는 신경 쓸 필요가 없습니다.

@defer 블록 — 가장 모던한 방식 #

라우트 단위가 아니라 화면 안 일부를 lazy하게 띄우고 싶을 때, Angular 17+의 @defer 블록이 가장 깔끔합니다. 동적 생성의 효과를 주면서 템플릿에서 선언적으로 끝낼 수 있습니다.

src/app/dashboard.component.html
<h1>대시보드</h1>

@defer (on viewport) {
  <app-heavy-chart [data]="metrics()" />
} @placeholder {
  <div class="placeholder">차트 영역</div>
} @loading (after 100ms; minimum 500ms) {
  <div>차트를 준비 중입니다...</div>
} @error {
  <div>차트를 불러오지 못했습니다.</div>
}

@defer (on viewport)는 “이 블록이 뷰포트에 들어오면 그때 청크를 받아 인스턴스화"라는 뜻입니다. 트리거는 on viewport, on idle(기본값), on timer(2s), on interaction, on hover, when condition() 등 다양합니다.

빌드 도구는 @defer 블록 안의 컴포넌트 import를 자동으로 별도 청크로 분리합니다. 라우트 lazy loading의 화면 내 버전이라고 보면 됩니다. 차트, 무거운 에디터, 동영상 플레이어처럼 “처음부터 보일 필요는 없지만 결국 보일 수 있는” 조각의 1순위 도구입니다. @defer로 충분하면 굳이 createComponent 같은 저수준 API를 쓸 이유가 없고, 직접 명령형으로 띄워야만 하는 경우(모달, 토스트 등)에서만 그 아래 단계로 내려가면 됩니다.

ngComponentOutlet — 템플릿에서 클래스 바꿔끼우기 #

“띄울 컴포넌트의 종류가 런타임에 결정되는데, 위치를 템플릿 안에 두고 싶다” — 이때 어울리는 도구가 NgComponentOutlet입니다. 위저드 단계, 사용자가 고른 위젯, A/B로 갈리는 폼 같은 경우에 자주 등장합니다.

src/app/wizard.component.ts
@Component({
  selector: 'app-wizard',
  standalone: true,
  imports: [NgComponentOutlet],
  template: `
    <ng-container *ngComponentOutlet="currentStep(); inputs: stepInputs()" />
    <button (click)="next()">다음</button>
  `,
})
export class WizardComponent {
  private steps: Type<unknown>[] = [Step1Component, Step2Component, Step3Component];
  private index = signal(0);

  currentStep = () => this.steps[this.index()];
  stepInputs = () => ({ stepIndex: this.index() });

  next() {
    this.index.update(i => Math.min(i + 1, this.steps.length - 1));
  }
}

ngComponentOutlet에 컴포넌트 클래스 자체를 넘기면 그 위치에 인스턴스가 들어갑니다. 클래스를 바꾸면 기존 인스턴스는 자동으로 destroy되고 새 컴포넌트가 그 위치에 들어갑니다. inputs로 input도 같이 넘길 수 있습니다. createComponent보다 표현력은 살짝 떨어지지만 템플릿에서 선언적으로 끝낼 수 있어서 코드가 짧고, “한 위치에 들어가는 컴포넌트 종류만 바뀌는” 단순한 케이스에서는 이쪽이 1순위입니다.

CDK Portal — 부모 트리 밖으로 보내기 #

모달이나 오버레이를 호스트 트리 안에 두면 부모의 overflow: hidden에 잘리거나, z-index 경합으로 깔리거나, transform이 걸린 조상 때문에 fixed positioning이 깨지는 문제가 생기기 쉽습니다. 이걸 깔끔하게 푸는 도구가 Angular CDKPortal입니다. 컴포넌트를 만든 위치에 두지 않고 다른 위치(보통 <body> 직속)에 그려넣어 줍니다.

CDK 설치
npm install @angular/cdk
src/app/modal-host.component.ts
@Component({
  selector: 'app-modal-host',
  standalone: true,
  template: `<button (click)="openSettings()">설정 열기</button>`,
})
export class ModalHostComponent {
  private overlay = inject(Overlay);

  openSettings() {
    const overlayRef = this.overlay.create({
      hasBackdrop: true,
      positionStrategy: this.overlay.position().global().centerHorizontally().centerVertically(),
    });

    overlayRef.attach(new ComponentPortal(SettingsModalComponent));
    overlayRef.backdropClick().subscribe(() => overlayRef.dispose());
  }
}

Overlay는 CDK가 관리하는 별도 DOM 영역(cdk-overlay-container)에 컴포넌트를 띄우고, 위치 전략,백드롭,키보드 처리까지 한 번에 해결해줍니다. 직접 createComponent로 모달을 구현하면 backdrop, focus trap, ESC 닫기, 위치 계산 같은 디테일에 시간을 잡아먹는데, CDK Portal/Overlay는 그 디테일을 미리 풀어둔 라이브러리입니다. 모달, 드롭다운, 툴팁 — 부모 트리 밖으로 보내야 하는 거의 모든 경우의 표준 도구라고 봐도 됩니다.

동적 인젝터 — 컴포넌트마다 다른 컨텍스트 #

동적으로 띄우는 컴포넌트마다 다른 서비스 인스턴스를 주입하고 싶을 때가 있습니다. 같은 모달인데 호출자에 따라 “닫기” 동작이 달라야 한다든가. createComponent는 옵션으로 인젝터를 받기 때문에 Injector.create()로 즉석 인젝터를 만들어 넘길 수 있습니다.

src/app/host.component.ts
export class DialogContext {
  constructor(public title: string, public onConfirm: () => void) {}
}

export class HostComponent {
  private parentInjector = inject(Injector);
  slot = viewChild('slot', { read: ViewContainerRef });

  open() {
    const container = this.slot();
    if (!container) return;

    const dialogInjector = Injector.create({
      providers: [{
        provide: DialogContext,
        useValue: new DialogContext('정말 삭제하시겠습니까?', () => this.handleConfirm()),
      }],
      parent: this.parentInjector,
    });

    container.createComponent(ConfirmDialogComponent, { injector: dialogInjector });
  }

  private handleConfirm() { /* 실제 삭제 로직 */ }
}

ConfirmDialogComponentinject(DialogContext)만 하면 되고, 호출자가 다르면 같은 컴포넌트에도 매번 다른 컨텍스트가 들어옵니다. CDK의 MatDialog도 내부적으로 같은 패턴이고, MAT_DIALOG_DATA 토큰이 바로 그 역할을 합니다.

메모리 관리 #

동적 컴포넌트는 강력한 만큼 정리도 직접 챙겨야 합니다. 가장 기본은 ComponentRef.destroy() — 컴포넌트의 OnDestroy가 돌고 자식 트리까지 함께 destroy됩니다. ViewContainerRef에 들어 있는 동적 뷰를 한 번에 비우려면 clear()를 쓰면 됩니다.

호스트 컴포넌트가 destroy될 때 ViewContainerRef.createComponent로 만든 자식들은 호스트의 뷰 트리에 연결돼 있어 자동으로 함께 정리됩니다. 반대로 CDK Overlay처럼 부모 트리 밖으로 보낸 컴포넌트는 호스트가 사라져도 자동으로 사라지지 않습니다 — overlayRef.dispose()를 직접 호출하거나, DestroyRef로 호스트 destroy 시점에 묶어줘야 합니다.

DestroyRef로 정리 묶기
import { DestroyRef, inject } from '@angular/core';

const destroyRef = inject(DestroyRef);
const overlayRef = this.overlay.create(/* ... */);
destroyRef.onDestroy(() => overlayRef.dispose());
output을 subscribe로 구독했다면 그 Subscription도 호스트와 함께 정리해야 합니다. 한 번 띄우고 닫는 모달이라면 닫기 시점 destroy로 충분하지만, 호스트가 살아 있는 동안 여러 번 동적 컴포넌트를 띄우는 경우라면 매번 만든 Subscription을 잘 회수해야 누수가 안 생깁니다.

실전: 토스트 서비스 #

여기까지 본 도구들을 묶어서 작은 토스트 서비스를 만들어보겠습니다. 어디서든 toast.show('저장되었습니다')로 호출하면 화면 우측 상단에 잠깐 떴다 사라지는, 흔한 그 토스트입니다. 먼저 토스트 한 개를 그리는 컴포넌트.

src/app/toast/toast.component.ts
export type ToastVariant = 'info' | 'success' | 'error';

@Component({
  selector: 'app-toast',
  standalone: true,
  template: `
    <div class="toast" [class]="variant()">
      <span>{{ message() }}</span>
      <button (click)="dismiss.emit()">×</button>
    </div>
  `,
})
export class ToastComponent {
  message = input.required<string>();
  variant = input<ToastVariant>('info');
  dismiss = output<void>();
}

서비스는 앱에 컨테이너를 한 번 만들어두고, 호출이 올 때마다 그 안에 토스트 컴포넌트를 동적으로 추가합니다.

src/app/toast/toast.service.ts
@Injectable({ providedIn: 'root' })
export class ToastService {
  private appRef = inject(ApplicationRef);
  private injector = inject(EnvironmentInjector);
  private container: HTMLElement | null = null;

  show(message: string, variant: ToastVariant = 'info', duration = 3000) {
    const host = this.ensureContainer();

    const ref = createComponent(ToastComponent, {
      hostElement: document.createElement('div'),
      environmentInjector: this.injector,
    });
    ref.setInput('message', message);
    ref.setInput('variant', variant);

    host.appendChild(ref.location.nativeElement);
    this.appRef.attachView(ref.hostView);

    const close = () => {
      this.appRef.detachView(ref.hostView);
      ref.destroy();
    };
    ref.instance.dismiss.subscribe(close);
    setTimeout(close, duration);
  }

  private ensureContainer(): HTMLElement {
    if (this.container) return this.container;
    const el = document.createElement('div');
    el.style.cssText = 'position:fixed; top:16px; right:16px; z-index:9999;';
    document.body.appendChild(el);
    return (this.container = el);
  }
}

여기서는 ViewContainerRef 대신 standalone createComponent 함수를 썼습니다. 호스트 컴포넌트 없이도 컴포넌트를 만들 수 있어서 서비스 안에서 직접 인스턴스화할 때 어울립니다. 만들어진 ComponentRef를 ApplicationRef.attachView()로 변경 감지 트리에 붙이고, DOM에는 appendChild로 직접 끼워 넣습니다. 호출은 this.toast.show('저장되었습니다.', 'success') 한 줄이면 끝입니다.

이 예제는 일부러 작게 짰습니다. 프로덕션에서는 애니메이션, 큐 길이 제한, 메시지 dedup, 키보드 접근성 같은 디테일이 더 들어가겠지만 핵심 골격은 이게 전부입니다. CDK Overlay로 만들면 위치/백드롭/스택 처리가 더 깔끔하니, 실무에서는 직접 짜기 전에 CDK 위에 얹는 쪽을 먼저 검토해보세요.

마무리 #

이번 글에서는 모던 앵귤러의 동적 컴포넌트 패턴을 한 바퀴 돌아봤습니다. 핵심을 정리하면:

  • ViewContainerRef위치, createComponent끼워 넣기, setInput으로 input 주입
  • @defer는 화면 내 lazy의 가장 모던한 도구. 가능하면 1순위로 고려
  • ngComponentOutlet은 템플릿에서 클래스를 바꿔끼우는 선언적 방법
  • 모달,오버레이는 CDK Portal/Overlay로 부모 트리 밖에 띄우는 게 표준
  • 동적 인젝터로 컴포넌트마다 다른 컨텍스트 주입
  • destroy()/clear()/dispose()로 메모리 정리는 직접 챙긴다 (ComponentFactoryResolver는 deprecated)

대부분의 실무 상황은 @deferngComponentOutlet만으로 풀리고, 모달,토스트류는 CDK Overlay로 갑니다. createComponent를 직접 부르는 경우는 많지 않지만, 라이브러리를 만들거나 깊은 부분을 디버깅할 때를 위해 모양은 익혀두는 게 좋습니다.

다음 글인 “앵귤러 고급 강좌 #4 RxJS 심화"에서는 비동기 데이터 흐름의 다른 한 축 — Observable과 연산자, 멀티캐스팅, 에러 처리, Signals와의 경계 — 을 본격적으로 다루겠습니다. 동적 컴포넌트와 RxJS가 만나면 토스트 큐, 모달 스택, 검색 자동완성 같은 패턴이 한층 깔끔해집니다.

X