심화 가이드
컴포넌트

쿼리 함수로 자식 컴포넌트 참조하기

팁: 이 가이드 문서는 핵심 가이드 이후 내용을 다룹니다. 아직 Angular에 익숙하지 않다면 해당 문서를 먼저 읽어보세요.

컴포넌트에서 쿼리 함수(queries) 를 사용하면 자식 엘리먼트를 찾아서 값을 참조할 수 있습니다.

보통은 자식 컴포넌트나 자식 디렉티브, DOM 엘리먼트 등을 참조하는 용도로 사용합니다.

쿼리 함수는 가장 마지막에 찾은 결과를 시그널 타입으로 반환합니다. 그래서 시그널 함수를 실행하면 자식 객체를 참조할 수 있으며, computed 나 effect 함수와 함께 사용해서 반응형 시그널로 활용할 수도 있습니다.

쿼리 함수는 뷰 쿼리(view queries) 와 컨텐츠 쿼리(content queries) , 이렇게 두 종류가 있습니다.

뷰 쿼리(View queries)

뷰 쿼리는 컴포넌트 뷰 , 즉, 컴포넌트 템플릿 자체에 있는 엘리먼트를 찾습니다. 뷰 쿼리 함수 중 viewChild 함수를 사용하면 원하는 엘리먼트를 하나 참조할 수 있습니다.

@Component({
  selector: 'custom-card-header',
  /*...*/
})
export class CustomCardHeader {
  text: string;
}

@Component({
  selector: 'custom-card',
  template: '<custom-card-header>Visit sunny California!</custom-card-header>',
})
export class CustomCard {
  header = viewChild(CustomCardHeader);
  headerText = computed(() => this.header()?.text);
}

이 예제에서 CustomCard 컴포넌트는 자식 CustomCardHeader를 찾아 오고, 이 결과를 computed로 다시 한 번 참조합니다.

만약, 쿼리 결과가 없으면 시그널은 undefined 값을 갖습니다. 이 경우는 찾으려는 엘리먼트가 @if 로 화면에 표시되지 않은 경우에 발생할 수 있습니다. 그리고 애플리케이션의 상태가 변경되면 viewChild 결과는 계속 최신 상태로 갱신됩니다.

자식 컴포넌트를 여러 개 참조하려면 viewChildren 함수를 사용하면 됩니다.

@Component({
  selector: 'custom-card-action',
  /*...*/
})
export class CustomCardAction {
  text: string;
}

@Component({
  selector: 'custom-card',
  template: `
    <custom-card-action>Save</custom-card-action>
    <custom-card-action>Cancel</custom-card-action>
  `,
})
export class CustomCard {
  actions = viewChildren(CustomCardAction);
  actionsTexts = computed(() => this.actions().map((action) => action.text));
}

viewChildren 함수는 탐색 결과를 Array 타입으로 반환하는 시그널입니다.

쿼리 함수는 컴포넌트 밖을 참조할 수 없습니다. 뷰 쿼리는 컴포넌트 템플릿 안에 있는 객체만 참조할 수 있습니다.

컨텐츠 쿼리(Content queries)

컨텐츠 쿼리는 컴포넌트의 컨텐츠, 즉, 컴포넌트를 사용하는 템플릿에서 해당 컴포넌트 태그 안에 중첩된 엘리먼트를 찾습니다. 컨텐츠 쿼리 함수 중 contentChild 함수를 사용하면 원하는 엘리먼트를 하나 참조할 수 있습니다.

@Component({
  selector: 'custom-toggle',
  /*...*/
})
export class CustomToggle {
  text: string;
}

@Component({
  selector: 'custom-expando',
  /* ... */
})
export class CustomExpando {
  toggle = contentChild(CustomToggle);
  toggleText = computed(() => this.toggle()?.text);
}

@Component({
  /* ... */
  // CustomToggle은 CustomExpando 안에 컨텐츠로 사용되었습니다.
  template: `
    <custom-expando>
      <custom-toggle>Show</custom-toggle>
    </custom-expando>
  `,
})
export class UserProfile {}

만약 쿼리 결과가 없으면 시그널은 undefined 값을 갖습니다. 이 경우는 찾으려는 엘리먼트가 @if로 화면에 표시되지 않은 경우에 발생할 수 있습니다. 그리고 애플리케이션의 상태가 변경되면 contentChild 결과는 계속 최신 상태로 갱신됩니다.

기본적으로 contentChild 쿼리는 하위 자식까지 탐색하지만, contentChildren 쿼리는 바로 한 단계 자식만 찾습니다. 하위 자식 탐색을 참고하세요.

자식 컴포넌트를 여러 개 참조하려면 contentChildren 함수를 사용하면 됩니다.

@Component({
  selector: 'custom-menu-item',
  /*...*/
})
export class CustomMenuItem {
  text: string;
}

@Component({
  selector: 'custom-menu',
  /*...*/
})
export class CustomMenu {
  items = contentChildren(CustomMenuItem);
  itemTexts = computed(() => this.items().map((item) => item.text));
}

@Component({
  selector: 'user-profile',
  template: `
    <custom-menu>
      <custom-menu-item>Cheese</custom-menu-item>
      <custom-menu-item>Tomato</custom-menu-item>
    </custom-menu>
  `,
})
export class UserProfile {}

contentChildren 함수는 탐색 결과를 Array 타입으로 반환하는 시그널입니다.

쿼리 함수는 컴포넌트 경계를 넘어 탐색하지 않습니다. 컨텐츠 쿼리는 해당 컴포넌트를 사용하는 템플릿에서만 결과를 찾을 수 있습니다.

필수 쿼리(Required queries)

viewChild 함수나 contentChild 함수를 사용해서 자식 컴포넌트를 탐색했지만 아무것도 찾지 못한 경우, 쿼리 함수가 반환하는 시그널은 undefined 값을 갖습니다. 이 경우는 찾으려는 엘리먼트가 @if나 @for로 인해 화면에 표시되지 않는 경우에 발생할 수 있습니다. 결국 자식 엘리먼트를 쿼리하는 함수는 찾으려는 객체 타입이거나 undefined 타입일 수 있습니다.

하지만 자식 컴포넌트가 반드시 존재하는 경우를 간주할 수 있습니다. 이 경우에는 대상 컴포넌트가 반드시 존재한다는 것을 지정해서 값이 반드시 존재하는 쿼리로 처리할 수 있습니다.

@Component(/* ... */)
export class CustomCard {
  header = viewChild.required(CustomCardHeader);
  body = contentChild.required(CustomCardBody);
}

필수 쿼리로 지정했지만 대상을 찾지 못하는 경우에는 오류가 발생합니다. 왜냐하면 필수 쿼리는 대상이 있는 것을 보장하기 때문에 시그널이 undefined 값을 가질 수 없기 때문입니다.

쿼리 구분자(Query locators)

쿼리 데코레이터의 첫번째 인자는 구분자(locator) 입니다.

대부분의 경우, 구분자는 컴포넌트나 디렉티브를 그대로 사용합니다.

아니면 템플릿 참조 변수를 활용해서 문자열 구분자를 사용할 수도 있습니다.

@Component({
  /*...*/
  template: `
    <button #save>Save</button>
    <button #cancel>Cancel</button>
  `,
})
export class ActionBar {
  saveButton = viewChild<ElementRef<HTMLButtonElement>>('save');
}

템플릿에 구분자가 여러 개 매칭되는 경우에는 첫번째 매칭되는 엘리먼트를 참조합니다.

CSS 셀렉터는 쿼리 구분자로 사용할 수 없습니다.

쿼리 함수와 인젝터 계층

팁: 프로바이더와 인젝션 트리 계층에 대해 알아보려면 의존성 주입(Dependency Injection) 문서를 참고하세요.

ProviderToken을 구분자로 사용하는 고급 활용 방식도 있습니다. 이 방식을 활용하면 컴포넌트 프로바이더나 디렉티브 프로바이더로 자식 객체를 탐색할 수 있습니다.

const SUB_ITEM = new InjectionToken<string>('sub-item');

@Component({
  /*...*/
  providers: [{provide: SUB_ITEM, useValue: 'special-item'}],
})
export class SpecialItem {}

@Component(/* ... */)
export class CustomList {
  subItemType = contentChild(SUB_ITEM);
}

위 예제에서는 InjectionToken을 구분자로 사용했지만, ProviderToken 타입 중 어떠한 것을 사용해도 됩니다.

쿼리 옵션

쿼리 함수는 두번째 인자로 옵션 객체를 받을 수 있습니다. 옵션을 지정하면 쿼리 함수가 어떻게 객체를 찾을지 지정합니다.

엘리먼트 인젝터로 원하는 값 읽기

기본적으로 쿼리 구분자는 찾을 엘리먼트와 그 엘리먼트에서 읽어 올 값을 모두 지정합니다. read 옵션을 지정하면 구분자로 찾은 엘리먼트에서 다른 값을 읽어 올 수 있습니다.

@Component(/* ... */)
export class CustomExpando {
  toggle = contentChild(ExpandoContent, {read: TemplateRef});
}

위 예제 코드처럼 작성하면 Angular는 ExpandoContent를 찾아서 해당 엘리먼트의 TemplateRef를 반환합니다.

read에는 일반적으로 ElementRef나 TemplateRef를 사용합니다.

read에 Injector를 전달할 수도 있습니다.

@Component({
  selector: 'custom-table',
  template: `
    <third-party-table #inner>
      <ng-template>
        <ng-container [ngTemplateOutlet]="columns()" [ngTemplateOutletInjector]="innerInjector()" />
      </ng-template>
    </third-party-table>
  `,
})
export class CustomTable {
  columns = contentChild(TemplateRef);
  innerInjector = viewChild('inner', {read: Injector});
}

위 예제는 third-party-table 엘리먼트의 노드 인젝터, 즉 트리에서 해당 엘리먼트 위치에 해당하는 인젝터를 가져옵니다. 이 인젝터를 ngTemplateOutletInjector로 NgTemplateOutlet에 전달하면 프로젝션된 템플릿의 디렉티브에서 서드파티 컴포넌트가 제공하는 값을 주입받을 수 있습니다.

컨텐츠의 자식 객체

기본적으로 contentChildren 쿼리 함수는 컴포넌트의 바로 한 단계 자식 컴포넌트를 탐색하며 더 하위 자식은 탐색하지 않습니다. 반면에 contentChild 쿼리 함수는 자식 엘리먼트를 탐색합니다.

@Component({
  selector: 'custom-expando',
  /*...*/
})
export class CustomExpando {
  toggle = contentChildren(CustomToggle); // 찾을 수 없음
  // toggle = contentChild(CustomToggle); // 찾음
}

@Component({
  selector: 'user-profile',
  template: `
    <custom-expando>
      <some-other-component>
        <custom-toggle>Show</custom-toggle>
      </some-other-component>
    </custom-expando>
  `,
})
export class UserProfile {}

위 예제 코드에서 <custom-toggle> 컴포넌트는 CustomExpando의 직접적인 자식 컴포넌트가 아니기 때문에 <custom-toggle>를 탐색할 수 없습니다. 이 경우 descendants: true 옵션을 지정하면 쿼리 함수가 자식 컴포넌트를 모두 탐색합니다. 하지만 이런 경우에도 컴포넌트를 벗어나는 영역은 절대 탐색할 수 없습니다.

뷰 쿼리는 항상 자식 컴포넌트를 참조하기 때문에 이 옵션을 사용하지 않습니다.

데코레이터 기반 쿼리 함수

팁: 기존에 사용하던 데코레이터 기반 쿼리 API도 온전히 잘 작동하지만, 시그널 기반 쿼리 함수 사용을 권장합니다.

쿼리 함수는 데코레이터 프로퍼티로 사용할 수도 있습니다. 데코레이터 기반 쿼리 함수는 시그널 기반 쿼리 함수와 동일하게 동작합니다.

뷰 쿼리

대상을 하나만 참조하려면 @ViewChild 데코레이터를 사용합니다.

@Component({
  selector: 'custom-card-header',
  /*...*/
})
export class CustomCardHeader {
  text: string;
}

@Component({
  selector: 'custom-card',
  template: '<custom-card-header>Visit sunny California!</custom-card-header>',
})
export class CustomCard implements AfterViewInit {
  @ViewChild(CustomCardHeader) header: CustomCardHeader;

  ngAfterViewInit() {
    console.log(this.header.text);
  }
}

위 코드는 CustomCard 컴포넌트가 자식 컴포넌트 CustomCardHeader를 탐색하며, 탐색 결과는 ngAfterViewInit에서 접근하는 코드입니다.

그리고 애플리케이션 상태가 변경되면 @ViewChild 탐색 결과는 최신 상태로 갱신됩니다.

뷰 쿼리는 ngAfterViewInit 라이프사이클 메서드 시점부터 결과값을 참조할 수 있습니다. 이 메서드가 실행되기 전 시점에는 undefined 값을 갖습니다. 컴포넌트 라이프사이클에 대해 알아보려면 라이프사이클 훅 문서를 참고하세요.

자식 컴포넌트를 여러 개 탐색하려면 @ViewChildren 데코레이터를 사용하면 됩니다.

@Component({
  selector: 'custom-card-action',
  /*...*/
})
export class CustomCardAction {
  text: string;
}

@Component({
  selector: 'custom-card',
  template: `
    <custom-card-action>Save</custom-card-action>
    <custom-card-action>Cancel</custom-card-action>
  `,
})
export class CustomCard implements AfterViewInit {
  @ViewChildren(CustomCardAction) actions: QueryList<CustomCardAction>;

  ngAfterViewInit() {
    this.actions.forEach((action) => {
      console.log(action.text);
    });
  }
}

@ViewChildren 을 사용하면 QueryList 타입을 반환합니다. 그리고 애플리케이션 상태가 변경되는 것에 따라 쿼리 결과가 달라지는 것을 확인하려면 changes 프로퍼티를 구독하면 됩니다.

컨텐츠 쿼리

대상을 하나만 참조하려면 @ContentChild 데코레이터를 사용합니다.

@Component({
  selector: 'custom-toggle',
  /*...*/
})
export class CustomToggle {
  text: string;
}

@Component({
  selector: 'custom-expando',
  /*...*/
})
export class CustomExpando implements AfterContentInit {
  @ContentChild(CustomToggle) toggle: CustomToggle;

  ngAfterContentInit() {
    console.log(this.toggle.text);
  }
}

@Component({
  selector: 'user-profile',
  template: `
    <custom-expando>
      <custom-toggle>Show</custom-toggle>
    </custom-expando>
  `,
})
export class UserProfile {}

위 코드는 CustomExpando 컴포넌트가 자식 컴포넌트 CustomToggle을 탐색하며, 탐색 결과는 ngAfterContentInit에서 접근하는 코드입니다.

그리고 애플리케이션 상태가 변경되면 @ContentChild 탐색 결과는 최신 상태로 갱신됩니다.

컨텐츠 쿼리는 ngAfterContentInit 라이프사이클 메서드 시점부터 결과값을 참조할 수 있습니다. 이 메서드가 실행되기 전 시점에는 undefined 값을 갖습니다. 컴포넌트 라이프사이클에 대해 알아보려면 라이프사이클 훅 문서를 참고하세요.

자식 컴포넌트를 여러 개 탐색하려면 @ContentChildren 데코레이터를 사용하면 됩니다.

@Component({
  selector: 'custom-menu-item',
  /*...*/
})
export class CustomMenuItem {
  text: string;
}

@Component({
  selector: 'custom-menu',
  /*...*/
})
export class CustomMenu implements AfterContentInit {
  @ContentChildren(CustomMenuItem) items: QueryList<CustomMenuItem>;

  ngAfterContentInit() {
    this.items.forEach((item) => {
      console.log(item.text);
    });
  }
}

@Component({
  selector: 'user-profile',
  template: `
    <custom-menu>
      <custom-menu-item>Cheese</custom-menu-item>
      <custom-menu-item>Tomato</custom-menu-item>
    </custom-menu>
  `,
})
export class UserProfile {}

@ContentChildren 을 사용하면 QueryList 타입을 반환합니다. 그리고 애플리케이션 상태가 변경되는 것에 따라 쿼리 결과가 달라지는 것을 확인하려면 changes 프로퍼티를 구독하면 됩니다.

데코레이터 기반 쿼리 옵션

쿼리 데코레이터는 두번째 인자로 옵션 객체를 받을 수 있습니다. 이 옵션 객체는 시그널 기반 쿼리 함수와 동일하게 동작합니다.

정적 쿼리

@ViewChild와 ContentChild 데코레이터에 static 옵션을 지정할 수 있습니다.

@Component({
  selector: 'custom-card',
  template: '<custom-card-header>Visit sunny California!</custom-card-header>',
})
export class CustomCard implements OnInit {
  @ViewChild(CustomCardHeader, {static: true}) header: CustomCardHeader;

  ngOnInit() {
    console.log(this.header.text);
  }
}

static: true 옵션을 지정하면 찾으려는 대상 컴포넌트가 언제나 존재하며 렌더링 조건에 영향을 받지 않는다는 것을 의미합니다. 그래서 탐색 결과를 빠른 시점부터 참조할 수 있으며, ngOnInit 라이프사이클 메서드에서 활용할 수도 있습니다.

정적 쿼리 결과는 처음 초기화된 후에 변경되지 않습니다.

static 옵션은 @ViewChildren 쿼리 함수나 @ContentChildren 쿼리 함수에는 사용할 수 없습니다.

QueryList 활용하기

@ViewChildren과 @ContentChildren은 모두 탐색 결과를 QueryList 타입으로 반환합니다.

QueryList는 배열과 비슷하게 map이나 reduce, forEach와 같은 API를 제공하며, toArray 메서드를 활용하면 일반적인 배열 형태로 결과물을 참조할 수 있습니다.

그리고 changes 프로퍼티를 구독하면 탐색 결과가 변경되는 것을 추적할 수도 있습니다.

쿼리를 잘못 사용하는 경우

쿼리를 사용할 때 혼동하는 경우에는 코드를 복잡하고 유지보수하기 어렵게 만들 수 있습니다.

여러 컴포넌트가 공유하는 상태의 원본 데이터는 항상 한 곳에서 관리하세요. 컴포넌트끼리 동기화되지 않아 발생하는 문제를 예방하는 방법입니다.

자식 컴포넌트의 상태값을 직접 설정하지 마세요. 이런 방식은 이해하기 어렵고 ExpressionChangedAfterItHasBeenChecked 오류를 유발할 수 있습니다.

부모 컴포넌트나 더 위쪽 컴포넌트의 상태값을 직접 설정하지 마세요. 이런 방식은 이해하기 어렵고 ExpressionChangedAfterItHasBeenChecked 오류를 유발할 수 있습니다.