createComponent로 모달 만들기

Adam Kim·2025년 10월 10일
0

angular

목록 보기
53/88

createComponent는 ngComponentOutlet보다 더 저수준에서 동적 컴포넌트를 제어할 수 있어, 툴팁뿐만 아니라 복잡한 상호작용이 필요한 모달(Modal) 시스템을 구축하는 데 매우 유용합니다.

모달의 정의

툴팁과 달리 모달은 더 많은 기능을 고려해야 합니다.

  • 서비스 기반 호출: 모달은 특정 이벤트에 의해 즉시 호출되기보다, 애플리케이션의 어느 곳에서든 필요할 때 호출할 수 있어야 합니다. 이를 위해 재사용 가능한 서비스(Injectable)를 사용하는 것이 좋습니다.
  • 계층 관리: 모달 위에 또 다른 모달이 겹쳐서 표시될 수 있어야 합니다. (이 글에서는 기본 단일 모달을 다루지만, 서비스 구조는 확장이 용이해야 합니다.)
    결과 반환: 모달이 닫힐 때 '확인', '취소' 또는 특정 데이터를 반환해야 합니다. 호출한 측에서는 이 결과를 기다렸다가 후속 처리를 할 수 있어야 합니다.

기본 동작 계획

  • ModalService 생성: 모달 컴포넌트를 동적으로 생성(createComponent)하고, 결과를 Observable로 반환하며, 모달을 닫는 역할을 하는 중앙 집중식 서비스를 만듭니다.
  • ModalHost 설정: AppComponent에 모달이 렌더링될 전용 위치(ViewContainerRef)를 마련하고, 이를 ModalService에 등록합니다.
  • 동적 모달 컴포넌트 생성: 사용자와 상호작용하고 결과를 Subject를 통해 서비스에 전달할 모달 컴포넌트들을 standalone으로 만듭니다.
  • 컨테이너 컴포넌트: ModalService를 주입받아 모달을 열고, 반환된 결과를 Signal에 저장하여 UI에 표시합니다.

1. ModalService 생성하기

이 예제의 핵심입니다. 모달의 생성, 소멸, 결과 처리를 모두 담당하는 서비스입니다.

// src/app/modal.service.ts
import { Injectable, ViewContainerRef, ComponentRef, Type, inject } from '@angular/core';
import { Subject, Observable } from 'rxjs';
import { first, tap } from 'rxjs/operators';

// 모달 컴포넌트가 구현해야 할 인터페이스
export interface ModalComponent<R> {
  result$: Subject<R>;
}

@Injectable({ providedIn: 'root' })
export class ModalService {
  private hostVcr?: ViewContainerRef;
  private componentRef?: ComponentRef<ModalComponent<any>>;

  // 모달이 렌더링될 ViewContainerRef를 등록
  registerHost(vcr: ViewContainerRef): void {
    this.hostVcr = vcr;
  }

  // 제네릭을 사용하여 어떤 컴포넌트든 열고, 어떤 타입의 결과든 반환
  open<T extends ModalComponent<R>, R>(component: Type<T>): Observable<R> {
    if (!this.hostVcr) {
      throw new Error('Modal host container is not registered!');
    }
    // 이전 모달이 있다면 제거 (단일 모달 정책)
    this.hostVcr.clear();

    const result$ = new Subject<R>();
    
    // 컴포넌트를 동적으로 생성
    this.componentRef = this.hostVcr.createComponent(component);
    // 생성된 컴포넌트의 인스턴스에 result$ Subject를 주입
    this.componentRef.instance.result$ = result$;

    // Observable을 반환하고, 첫 번째 결과가 오면 모달을 닫음
    return result$.asObservable().pipe(
      first(),
      tap(() => this.close())
    );
  }

  close(): void {
    if (this.componentRef) {
      this.componentRef.destroy();
      this.componentRef = undefined;
    }
  }
}

2. 컴포넌트 생성하기

AppComponent (모달 호스트 설정)

애플리케이션의 최상단에서 모달이 렌더링될 위치를 ModalService에 알려줍니다.

// src/app/app.component.ts
import { Component, ViewChild, ViewContainerRef, AfterViewInit, inject } from '@angular/core';
import { ContainerComponent } from './container.component'; // 예제 호출 컴포넌트
import { ModalService } from './modal.service';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [ContainerComponent],
  template: `
    <app-container></app-container>
    <!-- 모든 모달은 이 위치에 렌더링됩니다 -->
    <ng-container #modalHost></ng-container>
  `,
})
export class AppComponent implements AfterViewInit {
  @ViewChild('modalHost', { read: ViewContainerRef })
  modalHostVcr!: ViewContainerRef;

  private modalService = inject(ModalService);
  
  ngAfterViewInit(): void {
    // 서비스에 호스트 ViewContainerRef를 등록
    this.modalService.registerHost(this.modalHostVcr);
  }
}

동적 모달 컴포넌트 (AModalComponent, BModalComponent)

ModalComponent 인터페이스를 구현하여 result$ 프로퍼티를 가집니다.

// src/app/a-modal.component.ts
import { Component, Input } from '@angular/core';
import { Subject } from 'rxjs';
import { ModalComponent } from './modal.service';

@Component({
  standalone: true,
  template: `
    <div class="modal">
      <p>Modal A: Are you sure?</p>
      <button (click)="onConfirm()">확인</button>
      <button (click)="onCancel()">취소</button>
    </div>
  `,
  styles: `.modal { border: 1px solid blue; padding: 20px; background: white; }`
})
export class AModalComponent implements ModalComponent<boolean> {
  result$!: Subject<boolean>;

  onConfirm(): void {
    this.result$.next(true);
    this.result$.complete();
  }
  onCancel(): void {
    this.result$.next(false);
    this.result$.complete();
  }
}

(BModalComponent도 유사하게 작성합니다.)

컨테이너 컴포넌트 (모달 호출)

이제 모달을 열고 싶은 컴포넌트에서는 ModalService만 주입받으면 됩니다.

// src/app/container.component.ts
import { Component, inject, signal } from '@angular/core';
import { ModalService } from './modal.service';
import { AModalComponent } from './a-modal.component';
import { BModalComponent } from './b-modal.component';

@Component({
  selector: 'app-container',
  standalone: true,
  template: `
    <button (click)="openModal('A')">Show Modal A</button>
    <button (click)="openModal('B')">Show Modal B</button>
    <p>Last Modal Result: {{ modalResult() }}</p>
  `
})
export class ContainerComponent {
  private modalService = inject(ModalService);
  modalResult = signal<any>('N/A');

  openModal(type: 'A' | 'B'): void {
    const modalToOpen = type === 'A' ? AModalComponent : BModalComponent;
    
    this.modalService.open(modalToOpen).subscribe(result => {
      // 모달이 닫히고 반환된 결과를 Signal에 저장
      this.modalResult.set(result);
    });
  }
}

결과

이 아키텍처는 Angular의 DI 시스템을 활용하여 매우 깔끔하고 재사용 가능한 모달 시스템의 기반을 제공합니다.

  • 관심사 분리: 모달을 여는 컴포넌트는 모달이 어디에 어떻게 렌더링되는지 알 필요가 없습니다. 오직 ModalService만 알면 됩니다.
  • 타입 안정성: 제네릭을 사용하여 열리는 컴포넌트의 타입과 반환 값의 타입을 명확히 할 수 있습니다.
  • 확장성: 여러 모달을 겹쳐 띄우거나, 애니메이션을 추가하거나, 중앙에서 상태를 관리하는 등 기능을 추가하기 용이한 구조입니다.
profile
Angular2+ Developer

0개의 댓글