심화 가이드
디렉티브

디렉티브 조합 API

Angular 디렉티브는 재사용을 염두에 두고 캡슐화되어 설계되었습니다. 디렉티브는 엘리먼트에 어트리뷰트, CSS 클래스, 이벤트 리스너를 적용할 수 있습니다.

디렉티브 조합 API 를 활용하면 컴포넌트 TypeScript 클래스 안에서 컴포넌트 호스트 엘리먼트에 디렉티브를 적용할 수 있습니다.

컴포넌트에 디렉티브 추가하기

컴포넌트 데코레이터에 hostDirectives 프로퍼티를 추가하면 적용할 수 있습니다. 이렇게 사용하는 디렉티브를 호스트 디렉티브(host directive) 라고 합니다.

위 예제에서는 MenuBehavior 디렉티브를 AdminMenu의 호스트 엘리먼트에 적용했습니다. 이 방식은 템플릿에서 <admin-menu> 엘리먼트에 MenuBehavior 디렉티브를 적용한 것과 같은 효과입니다.

@Component({
  selector: 'admin-menu',
  templateUrl: './admin-menu.html',
  hostDirectives: [MenuBehavior],
})
export class AdminMenu {}

프레임워크가 컴포넌트를 렌더링할 때 Angular는 호스트 디렉티브마다 각각 인스턴스를 생성합니다. 그리고 이 디렉티브는 컴포넌트의 호스트 엘리먼트에 호스트 바인딩됩니다. 기본적으로 호스트 디렉티브의 입출력 프로퍼티는 컴포넌트 public API처럼 컴포넌트 밖으로 노출되지 않습니다. 자세한 내용은 아래 입출력 프로퍼티 지정하기 섹션을 참고하세요.

호스트 디렉티브는 컴파일 시점에 정적으로 적용됩니다. 실행 시점에는 디렉티브를 동적으로 적용할 수 없습니다.

hostDirectives에 사용된 디렉티브는 standalone: false를 지정해서는 안 됩니다.

hostDirectives 프로퍼티에 적용된 디렉티브의 selector는 무시합니다.

입출력 프로퍼티 지정하기

컴포넌트에 hostDirectives를 적용하더라도 호스트 디렉티브의 입출력 프로퍼티는 컴포넌트 API에 기본으로 포함되지 않습니다. 그렇다면 hostDirectives 설정을 변경해서 디렉티브의 입출력 프로퍼티를 컴포넌트 API에 등록해야 합니다:

@Component({
  selector: 'admin-menu',
  templateUrl: './admin-menu.html',
  hostDirectives: [
    {
      directive: MenuBehavior,
      inputs: ['menuId'],
      outputs: ['menuClosed'],
    },
  ],
})
export class AdminMenu {}

입출력 프로퍼티를 등록하고 나면 템플릿에서 이렇게 사용할 수 있습니다:

<admin-menu menuId="top-menu" (menuClosed)="logMenuClosed()"></admin-menu>

그리고 hostDirectives에 입출력 프로퍼티를 등록할 때 별칭을 지정할 수도 있습니다:

@Component({
  selector: 'admin-menu',
  templateUrl: './admin-menu.html',
  hostDirectives: [
    {
      directive: MenuBehavior,
      inputs: ['menuId: id'],
      outputs: ['menuClosed: closed'],
    },
  ],
})
export class AdminMenu {}
<admin-menu id="top-menu" (closed)="logMenuClosed()"></admin-menu>

디렉티브를 다른 디렉티브에 적용하기

컴포넌트뿐 아니라 다른 디렉티브에서 hostDirectives를 활용할 수도 있습니다. 이 방식을 활용하면 디렉티브 여러 개를 한 번에 적용할 수 있습니다.

아래 예제에는 Menu 디렉티브와 Tooltip 디렉티브가 있습니다. 두 디렉티브를 MenuWithTooltip 디렉티브로 합쳐봅시다. 그리고 최종적으로는 MenuWithTooltip을 SpecializedMenuWithTooltip에 적용해 봅시다.

템플릿에 SpecializedMenuWithTooltip이 사용되면 Angular는 Menu, Tooltip, MenuWithTooltip 인스턴스를 각각 생성합니다. 그러고 나면 이 디렉티브들이 각각 SpecializedMenuWithTooltip 호스트 엘리먼트에 적용됩니다.

@Directive({/* ... */})
export class Menu {}

@Directive({/* ... */})
export class Tooltip {}

// MenuWithTooltip은 디렉티브 여러 개를 결합하는 디렉티브입니다.
@Directive({
  hostDirectives: [Tooltip, Menu],
})
export class MenuWithTooltip {}

// 디렉티브가 결합된 MenuWithToolTip을 최종 목적지에 적용합니다.
@Directive({
  hostDirectives: [MenuWithTooltip],
})
export class SpecializedMenuWithTooltip {}

호스트 디렉티브 활용

디렉티브 실행 순서

템플릿에 직접 사용된 호스트 디렉티브는 컴포넌트나 디렉티브와 동일한 라이프사이클을 갖습니다. 하지만 호스트 디렉티브도 클래스 생성자와 라이프사이클 훅이 실행되는데, 이 동작은 컴포넌트나 디렉티브에 바인딩되기 전에 실행됩니다.

예제를 보며 확인해 봅시다:

@Component({
  selector: 'admin-menu',
  templateUrl: './admin-menu.html',
  hostDirectives: [MenuBehavior],
})
export class AdminMenu {}

위 코드는 아래 순서대로 실행됩니다:

  1. MenuBehavior 인스턴스가 생성됩니다.
  2. AdminMenu 인스턴스가 생성됩니다.
  3. MenuBehavior 가 입력 프로퍼티로 입력을 받습니다. (ngOnInit)
  4. AdminMenu 가 입력 프로퍼티로 입력을 받습니다. (ngOnInit)
  5. MenuBehavior 호스트 바인딩이 적용됩니다.
  6. AdminMenu 호스트 바인딩이 적용됩니다.

실행 순서가 이렇기 때문에, hostDirectives를 사용하는 컴포넌트는 호스트 디렉티브에 지정된 호스트 바인딩을 오버라이드할 수 있습니다.

디렉티브 적용이 중첩된 경우도 방식은 같습니다.

@Directive({...})
export class Tooltip { }

@Directive({
  hostDirectives: [Tooltip],
})
export class CustomTooltip { }

@Directive({
  hostDirectives: [CustomTooltip],
})
export class EvenMoreCustomTooltip { }

위 코드는 아래 순서대로 실행됩니다:

  1. Tooltip 인스턴스가 생성됩니다.
  2. CustomTooltip 인스턴스가 생성됩니다.
  3. EvenMoreCustomTooltip 인스턴스가 생성됩니다.
  4. Tooltip이 입력 프로퍼티로 입력을 받습니다. (ngOnInit)
  5. CustomTooltip이 입력 프로퍼티로 입력을 받습니다. (ngOnInit)
  6. EvenMoreCustomTooltip이 입력 프로퍼티로 입력을 받습니다. (ngOnInit)
  7. Tooltip 호스트 바인딩이 적용됩니다.
  8. CustomTooltip 호스트 바인딩이 적용됩니다.
  9. EvenMoreCustomTooltip 호스트 바인딩이 적용됩니다.

의존성 주입

hostDirectives를 지정하는 컴포넌트나 디렉티브는 호스트 디렉티브를 의존성으로 주입받아 참조할 수 있으며, 반대 경우도 마찬가지입니다.

호스트 디렉티브를 컴포넌트에 적용할 때 컴포넌트나 호스트 디렉티브 양쪽에서 프로바이더를 등록할 수 있습니다.

hostDirectives를 지정한 컴포넌트나 디렉티브, 그리고 호스트 디렉티브가 같은 의존성 토큰을 등록하는 경우에는, 호스트 디렉티브 프로바이더보다 hostDirectives를 지정한 클래스의 프로바이더 우선순위가 더 높습니다.

호스트 디렉티브 중복 제거

호스트 디렉티브 트리에 같은 디렉티브가 두 번 이상 사용되면, 오류를 발생시키는 대신 자동으로 중복이 제거됩니다. 어떤 항목이 유지될지는 다음 규칙을 따릅니다.

템플릿 매칭을 우선합니다.

디렉티브가 템플릿 셀렉터 와 호스트 디렉티브 에 동시에 사용되면, 템플릿 매칭이 적용되고 호스트 디렉티브 매칭은 모두 폐기합니다.

호스트 디렉티브 매칭이 Partial<YourDirective>와 같다고 생각하면 이해하기 쉽습니다. 입출력 프로퍼티는 hostDirectives에서 외부로 공개된 일부(Partial)를 의미합니다. 반면에, 템플릿 매칭은 디렉티브의 전체 public API와 매칭된다는 것을 의미합니다.

@Directive({selector: '[hoverable]'})
export class Hoverable {}

@Component({
  selector: 'app-button',
  hostDirectives: [Hoverable],
})
export class Button {}
<!-- hoverable은 셀렉터와 매칭되면서 호스트 디렉티브로 지정되었습니다. -->
<!-- Angular는 전체 public API 매칭을 의미하는 셀렉터 매칭만 유지합니다. -->
<app-button hoverable></app-button>

호스트 디렉티브가 여러 개 매칭되면 합쳐집니다.

동일한 디렉티브가 호스트 디렉티브로 여러 번 나타나면 Angular는 인스턴스 하나로 합칩니다. 예를 들어 서로 다른 두 디렉티브의 hostDirectives가 같은 디렉티브를 참조하는 경우입니다. 모든 인스턴스의 입출력 프로퍼티 매핑도 함께 합쳐집니다.

이 정책은 흔히 발생하는 다이아몬드 문제를 해결하기 위한 것입니다:

// 트리거 2개의 참조
@Directive({
  host: {
    '[attr.data-trigger-id]': 'triggerId()',
  },
})
export class TriggerRef {
  readonly triggerId = input(`trigger-${crypto.randomUUID()}`);
}

// 트리거는 2개지만, TriggerRef를 사용해서 호스트 디렉티브에 적용됩니다.
@Directive({
  selector: '[popoverTrigger]',
  hostDirectives: [TriggerRef],
})
export class PopoverTrigger {
  readonly triggerRef = inject(TriggerRef);
}

@Directive({
  selector: '[dropdownTrigger]',
  hostDirectives: [TriggerRef],
})
export class DropdownTrigger {
  readonly triggerRef = inject(TriggerRef);
}
<!-- 트리거 2개는 TriggerRef 인스턴스 하나를 공유합니다. -->
<button popoverTrigger dropdownTrigger>Actions</button>

참고: 디렉티브가 중복되는 경우에는 인스턴스가 하나만 유지되기 때문에, PopoverTrigger와 DropdownTrigger는 동일한 TriggerRef 인스턴스를 의존성으로 주입받습니다.

별칭이 겹치는 경우

호스트 디렉티브에 적용되는 입출력 프로퍼티는 모두 하나로 합쳐집니다. 그런데 같은 호스트 디렉티브에 같은 입출력 프로퍼티가 다른 별칭으로 사용되면, 컴파일 시점에 NG8024 오류를 냅니다.

@Directive({
  selector: '[popoverTrigger]',
  hostDirectives: [{directive: TriggerRef, inputs: ['triggerId: popoverTriggerId']}],
})
export class PopoverTrigger {}

@Directive({
  selector: '[dropdownTrigger]',
  hostDirectives: [
    {directive: TriggerRef, inputs: ['triggerId: dropdownTriggerId']}, // 다른 별칭을 지정!
  ],
})
export class DropdownTrigger {}
<!-- 에러: "popoverTriggerId"와 "dropdownTriggerId"는 triggerId가 같습니다. -->
<button popoverTrigger dropdownTrigger></button>

이 문제를 해결하려면 양쪽 경로에서 공유 입출력 프로퍼티를 동일한 별칭으로 공개하거나, 해당 프로퍼티를 아예 공개하지 마세요.

다음 단계