모범 사례
성능 관리

뷰 지연 로딩: @defer

@defer 블록은 지연 로딩 뷰를 선언하는 블록입니다. 애플리케이션 초기 렌더링에 꼭 필요하지 않은 코드를 나중에 불러오는 방식으로 애플리케이션 첫 실행에 필요한 파일의 크기를 줄일 수 있습니다. 뷰를 지연 로딩하면 Core Web Vitals(CWV)나 Largest Contentful Paint(LCP), Time to First Byte(TTFB)가 향상되는 경우가 많습니다.

뷰를 지연 로딩하려면 템플릿을 @defer 블록으로 감싸면 됩니다:

@defer {
  <large-component />
}

@defer 블록 안에 있는 컴포넌트나 디렉티브, 파이프는 별도 JavaScript 파일로 분리되며, 나머지 템플릿이 렌더링된 후 이 구성요소가 필요한 경우에 로드됩니다.

지연 로딩 뷰를 지정하면서 다양한 트리거, 사전 로딩 옵션, 플레이스 홀더, 로딩, 오류 상태 관리 등의 기능을 활용할 수 있습니다.

어떤 항목을 지연 로딩할 수 있나요?

컴포넌트, 디렉티브, 파이프, 컴포넌트 CSS 스타일은 애플리케이션 최초 실행에서 제외하여 지연 로딩할 수 있습니다.

@defer 블록 안에 있는 구성요소를 정말 지연 로딩하려면, 다음 조건을 만족해야 합니다:

  1. 독립(standalone) 구성요소여야 합니다. 그렇지 않으면 @defer 블록 안에 있더라도 로딩이 지연되지 않고 즉시 로드됩니다.
  2. 같은 파일의 @defer 블록 밖에서 사용되지 않아야 합니다. @defer 블록 밖에서도 사용되거나 ViewChild 쿼리 등으로 참조되면 해당 항목은 즉시 로드됩니다.

@defer 블록 안에 사용된 컴포넌트, 디렉티브, 파이프의 내부 의존성 구성요소는 독립 구성요소가 아니어도 됩니다; 이 항목들은 NgModule에 등록해서 모듈 단위로 지연 로딩할 수 있습니다.

Angular 컴파일러는 @defer 블록에 사용된 개별 컴포넌트, 디렉티브, 파이프마다 동적 불러오기(dynamic import)를 적용합니다. 그래서 이 블록의 내용은 모든 로딩이 끝난 후에 렌더링됩니다. 로딩 순서는 보장하지 않습니다.

지연 로딩 단계 관리하기

@defer 블록은 지연 로딩 과정을 세분화하는 하위 블록으로 구성할 수 있습니다.

@defer

지연 로딩 뷰를 정의하는 기본 블록입니다. 지연 로딩되는 뷰는 화면의 첫 렌더링에 포함되지 않고, 트리거(trigger)가 동작하거나 when 조건이 맞을 때만 로딩되어 렌더링됩니다.

기본적으로 @defer 블록은 브라우저가 대기(idle) 상태가 되었을 때 트리거됩니다.

@defer {
  <large-component />
}

렌더링 위치 지정하기: @placeholder

기본적으로 @defer 블록은 트리거가 동작하기 전까지 아무것도 렌더링되지 않습니다.

이때 @defer 블록이 표시되기 전에 화면에 표시할 내용이 있다면, @placeholder 블록을 지정하면 됩니다.

@defer {
  <large-component />
} @placeholder {
  <p>Placeholder content</p>
}

@placeholder 블록이 필수인 것은 아니지만, 어떤 트리거는 @placeholder나 템플릿 참조 변수가 필요합니다. 자세한 내용은 트리거 섹션을 참고하세요.

@defer 블록은 로딩된 후에 @placeholder 블록을 대체하며 렌더링됩니다. 그리고 @placeholder 블록에는 일반 HTML, 컴포넌트, 디렉티브, 파이프를 자유롭게 사용할 수 있습니다. @placeholder 블록에 사용되는 컴포넌트, 디렉티브, 파이프는 지연 로딩되지 않고 즉시 로딩된다는 것을 기억하세요.

@placeholder 블록을 사용할 때 minimum 옵션을 사용할 수 있습니다. 이 옵션은 @placeholder 블록이 처음 렌더링된 후, 표시되는 최소 시간을 지정하는 옵션입니다.

@defer {
  <large-component />
} @placeholder (minimum 500ms) {
  <p>Placeholder content</p>
}

minimum 변수에는 밀리초(ms) 단위나 초(s) 단위 시간을 지정합니다. 이 옵션은 지연 로딩되는 뷰가 너무 빨리 로딩되는 경우에 화면이 깜빡이는 것을 방지하는 용도로 사용합니다.

로딩 표시하기: @loading

지연 로딩되는 뷰가 로딩 중일 때 표시할 내용이 있다면 @loading 블록을 사용합니다. @loading 블록은 트리거가 동작하고 나면 @placeholder 블록 대신 화면에 표시됩니다.

@defer {
  <large-component />
} @loading {
  <img alt="loading..." src="loading.gif" />
} @placeholder {
  <p>Placeholder content</p>
}

@loading 블록에 사용되는 컴포넌트, 디렉티브, 파이프도 @Placeholder 블록과 비슷하게 즉시 로딩됩니다.

@loading 블록은 지연 로딩하는 뷰가 너무 빨리 로딩되어 화면이 깜빡이는 것을 방지하기 위해 옵션을 2개 받을 수 있습니다:

  • minimum - @loading 블록이 표시될 최소 시간
  • after - @loading 블록이 로딩되고 표시되기 전까지 대기할 시간
@defer {
  <large-component />
} @loading (after 100ms; minimum 1s) {
  <img alt="loading..." src="loading.gif" />
}

두 옵션 모두 밀리초(ms)나 초(s) 단위를 지정합니다. 이 타이머는 로딩 트리거가 동작한 직후부터 시작됩니다.

지연 로딩 오류 표시하기: @error

뷰 지연 로딩에서 발생하는 오류를 표시하려면 @error 블록을 사용합니다. @error 블록 안에 사용되는 구성요소는 @placeholder, @loading 블록과 비슷하게 즉시 로딩됩니다.

@defer {
  <large-component />
} @error {
  <p>Failed to load large component.</p>
}

트리거로 지연 로딩 제어하기

뷰 지연 로딩을 제어하려면 트리거(triggers) 를 사용하면 됩니다.

@defer 블록은 트리거가 동작하고 난 후 지연 로딩되면서 @placeholder 블록을 대체하면서 렌더링됩니다.

이때 세미콜론(;)을 사용해서 이벤트 트리거를 여러 개 지정할 수 있으며, 이렇게 지정된 트리거는 OR 조건으로 동작합니다.

트리거는 크게 on과 when 으로 구분할 수 있습니다.

on

on 은 @defer 블록 트리거가 동작하는 조건을 지정합니다.

이런 트리거를 사용할 수 있습니다:

트리거 설명
idle 브라우저가 대기 상태일 때 동작합니다. 타임아웃 옵션을 지원합니다.
viewport 특정 항목이 뷰포트에 진입할 때 동작합니다.
interaction 사용자가 특정 엘리먼트와 상호작용할 때 동작합니다.
hover 마우스가 특정 영역 위에 올라갈 때 동작합니다.
immediate 지연 로딩이 아닌 뷰 렌더링이 끝난 직후 동작합니다.
timer 특정 시간 뒤에 동작합니다.

idle

idle 트리거는 브라우저가 대기 상태로 진입할 때 동작해서 지연 로딩 뷰를 로드합니다. 이 트리거가 기본값입니다.

requestIdleCallback에 전달할 타임아웃을 밀리초 단위로 지정할 수 있습니다. 브라우저가 콜백을 충분히 빨리 예약하지 못하더라도 지정한 제한 시간 안에는 작업이 실행됩니다.

<!-- @defer (on idle) -->
@defer {
  <large-cmp />
} @placeholder {
  <div>Large component placeholder</div>
}

<!-- 500ms 타이머 -->
@defer (on idle(500)) {
  <large-cmp />
}

idle 동작 커스터마이징하기

IdleService를 구현하고 이 객체를 provideIdleServiceWith 함수로 프로바이더에 등록하면 idle 트리거를 커스터마이징할 수 있습니다.

@Service()
class CustomIdleService implements IdleService {
  requestOnIdle(callback: (deadline?: IdleDeadline) => void, options?: IdleRequestOptions): number {
    // idle 스케줄 로직을 여기에 작성합니다.
    return requestIdleCallback(callback, options);
  }

  cancelOnIdle(id: number) {
    // 커스텀 idle 취소 로직을 여기에 작성합니다.
  }
}

bootstrapApplication(App, {
  providers: [provideIdleServiceWith(CustomIdleService)],
});

viewport

viewport 트리거는 Intersection Observer API를 활용해서 특정 항목이 뷰포트에 진입할 때 동작합니다. 이때 특정 항목은 @placeholder 항목이거나 명시적으로 지정한 엘리먼트가 됩니다.

기본적으로 @defer 블록은 @placeholder 가 뷰포트에 진입하는 것을 감지합니다. 이 경우 @placeholder 는 반드시 엘리먼트 하나여야 합니다.

@defer (on viewport) {
  <large-cmp />
} @placeholder {
  <div>Large component placeholder</div>
}

아니면 대상 엘리먼트에 템플릿 참조 변수를 지정한 후에 뷰포트 트리거로 전달하면 됩니다.

<div #greeting>Hello!</div>
@defer (on viewport(greeting)) {
  <greetings-cmp />
}

IntersectionObserver 옵션을 커스터마이징하려면 viewport 트리거로 객체 리터럴을 전달하면 됩니다. 이 리터럴은 root를 제외하고 IntersectionObserver의 두 번째 인자의 모든 프로퍼티를 지원합니다. 객체 리터럴 표기법을 사용할 때는 trigger 프로퍼티로 트리거를 전달하면 됩니다.

<div #greeting>Hello!</div>

<!-- 옵션을 지정하며 트리거링 -->
@defer (on viewport({trigger: greeting, rootMargin: '100px', threshold: 0.5})) {
  <greetings-cmp />
}

<!-- 옵션을 지정하며 암묵적 트리거링 -->
@defer (on viewport({rootMargin: '100px', threshold: 0.5})) {
  <greetings-cmp />
} @placeholder {
  <div>Implied trigger</div>
}

interaction

interaction 트리거는 사용자가 특정 엘리먼트와 click 이벤트나 keydown 이벤트로 상호작용할 때 동작합니다.

기본적으로 @placeholder는 상호작용하는 엘리먼트로 간주됩니다. 이 경우 @placeholder는 엘리먼트 하나여야 합니다.

@defer (on interaction) {
  <large-cmp />
} @placeholder {
  <div>Large component placeholder</div>
}

아니면 대상 엘리먼트에 템플릿 참조 변수를 지정한 후에 상호작용 트리거로 전달하면 됩니다.

<div #greeting>Hello!</div>
@defer (on interaction(greeting)) {
  <greetings-cmp />
}

hover

hover 트리거는 마우스가 어떤 영역으로 이동해서 mouseover 이벤트나 focusin 이벤트가 발생했을 때 동작합니다.

기본적으로 @placeholder는 상호작용하는 엘리먼트로 간주됩니다. 이 경우 @placeholder는 엘리먼트 하나여야 합니다.

@defer (on hover) {
  <large-cmp />
} @placeholder {
  <div>Large component placeholder</div>
}

아니면 같은 템플릿 안에서 마우스가 올라오는 것을 감지할 엘리먼트에 템플릿 참조 변수를 지정한 후 hover 트리거의 인자로 전달하면 됩니다.

<div #greeting>Hello!</div>
@defer (on hover(greeting)) {
  <greetings-cmp />
}

immediate

immediate 트리거는 지연 로딩 뷰를 즉시 로드합니다. 다르게 표현하면, 지연 로딩이 아닌 부분의 렌더링이 끝난 직후, 지연 로딩하도록 지정된 나머지 뷰를 로드합니다.

@defer (on immediate) {
  <large-cmp />
} @placeholder {
  <div>Large component placeholder</div>
}

timer

timer 트리거는 특정 시간이 지난 후에 동작합니다.

@defer (on timer(500ms)) {
  <large-cmp />
} @placeholder {
  <div>Large component placeholder</div>
}

이때 지연시간은 밀리초(ms) 단위나 초(s) 단위를 사용합니다.

when

when 트리거는 조건 표현식을 인자로 받으며 이 조건이 참으로 평가될 때 뷰를 로딩합니다.

@defer (when condition) {
  <large-cmp />
} @placeholder {
  <div>Large component placeholder</div>
}

이 로딩 동작은 한 번만 실행됩니다. 조건이 참이었다가 거짓으로 평가되더라도 @placeholder 블록이 다시 표시되거나 하지는 않습니다.

사전 로딩하기: prefetch

내용을 로딩하는 조건문을 지정하면서 사전 로딩 트리거(prefetch trigger) 를 옵션으로 지정할 수 있습니다. 이 트리거를 사용하면 지연 로딩하는 내용을 표시하기 전에 @defer 블록과 연결된 JavaScript를 로드할 수 있습니다.

사전 로딩을 활용하면 사용자가 지연 로딩 뷰를 실제로 보기 전이나 특정 블록과 상호작용하기 전에 리소스를 빠르게 로딩해 둘 수 있습니다.

사전 로딩 트리거는 블록 트리거와 비슷하지만, prefetch 키워드가 접두사로 붙습니다. 블록 트리거와 사전 로딩 트리거를 함께 사용하는 경우에는 세미콜론(;)으로 구분합니다.

아래 예제처럼 구현하면, 브라우저가 대기 상태로 진입하는 시점에 사전 로딩이 시작되며, @defer 블록의 내용은 사용자가 @placeholder 블록과 상호작용할 때 렌더링됩니다.

@defer (on interaction; prefetch on idle) {
  <large-cmp />
} @placeholder {
  <div>Large component placeholder</div>
}
<!-- 500ms idle 타임아웃으로 사전 로딩하기 -->
@defer (on interaction; prefetch on idle(500)) {
  <large-cmp />
}

@defer 블록 테스트하기

Angular는 다양한 트리거와 함께 @defer 블록을 테스트할 수 있는 TestBed API를 제공합니다. 기본적으로 @defer 블록은 실제 애플리케이션이 동작하는 것과 동일하게 테스트 환경에서 동작합니다. 하지만 TestBed를 수동으로 설정하면 각 단계를 직접 조작할 수 있습니다.

it('should render a defer block in different states', async () => {
  // defer 블록의 동작 방식을 수동으로 조작합니다. 시작 상태는 "paused" 입니다.
  TestBed.configureTestingModule({deferBlockBehavior: DeferBlockBehavior.Manual});
  @Component({
    // ...
    template: `
      @defer {
        <large-component />
      } @placeholder {
        Placeholder
      } @loading {
        Loading...
      }
    `,
  })
  class ExampleA {}
  // 컴포넌트 픽스처를 생성합니다.
  const componentFixture = TestBed.createComponent(ExampleA);
  // defer 블록을 모두 참조하고 그중 첫번째 블록을 가져옵니다.
  const deferBlockFixture = (await componentFixture.getDeferBlocks())[0];
  // placeholder 블록이 렌더링된 것을 확인합니다.
  expect(componentFixture.nativeElement.innerHTML).toContain('Placeholder');
  // 로딩 상태로 바꾸고 loading 블록이 렌더링된 것을 확인합니다.
  await deferBlockFixture.render(DeferBlockState.Loading);
  expect(componentFixture.nativeElement.innerHTML).toContain('Loading');
  // 최종 상태로 바꾸고 최종 렌더링을 확인합니다.
  await deferBlockFixture.render(DeferBlockState.Complete);
  expect(componentFixture.nativeElement.innerHTML).toContain('large works!');
});

@defer를 NgModule과 함께 사용할 수 있나요?

@defer 블록은 독립 구성요소는 물론이고 NgModule 기반 컴포넌트, 디렉티브, 파이프와도 함께 사용할 수 있습니다. 하지만 실제로 지연 로딩되는 것은 독립 컴포넌트, 독립 디렉티브, 독립 파이프뿐입니다. NgModule 기반으로 등록된 컴포넌트, 디렉티브, 파이프는 지연 로딩되지 않으며 즉시 로드됩니다.

@defer 블록과 핫 모듈 갱신(HMR) 호환성

핫 모듈 대체(Hot Module Replacement, HMR)이 활성화되면 @defer 블록 청크는 설정에 관계없이 모두 즉시 로딩됩니다. 표준 트리거 동작을 복원하려면 애플리케이션을 실행할 때 --no-hmr 플래그를 지정해서 HMR 기능을 비활성화해야 합니다.

서버 사이드 렌더링(SSR), 정적 사이트 생성(SSG)인 경우는 @defer가 어떻게 동작하나요?

SSR이나 SSG와 같이 애플리케이션이 서버에서 렌더링되는 경우는 기본적으로 @placeholder 블록이 있는 경우는 @placeholder 블록을 렌더링하고, @placeholder 블록이 없으면 아무것도 렌더링하지 않습니다. 그리고 클라이언트에서 애플리케이션이 실행될 때 @placeholder가 하이드레이션되면서 트리거가 동작합니다.

서버에서 @defer 블록의 본문을 렌더링하려면 점진적 하이드레이션을 활성화하고 필요한 블록에 hydrate 트리거를 설정해야 합니다.

배럴 파일과 지연 로딩 청크

@defer를 사용하는데 별도로 지연 로딩 청크가 생성되지 않는다면, 지연 로딩 컴포넌트를 어떻게 불러오는지 확인해 보세요. 배럴 파일(index.ts)로 로딩하는 것이 흔한 원인입니다. 이런 경우는 번들러가 배럴 파일을 단일 모듈로 인식하고 모든 항목을 함께 유지하기 때문에, @defer 사용 여부와 관계없이 컴포넌트가 메인 번들 파일에 포함됩니다.

index.ts

export {HeavyComponent} from './heavy.component';
export {OtherComponent} from './other.component';

parent.component.ts

import {Component} from '@angular/core';
import {HeavyComponent} from './index'; // 다른 컴포넌트도 함께 로드합니다.

@Component({
  imports: [HeavyComponent],
  template: `@defer {
    <heavy-component />
  }`,
})
export class ParentComponent {}

해결방법은 간단합니다. 컴포넌트가 정의된 파일 자체를 직접 로드하세요:

import {HeavyComponent} from './heavy.component';

이렇게 작성하면 별도 청크 파일을 정상적으로 생성하며, 지연 로딩도 제대로 동작합니다.

모범사례

@defer 블록 중첩 사용을 피하세요

@defer 블록을 중첩할 때는 동시에 로딩되어 연속적인 요청이 발생하지 않도록 서로 다른 트리거를 사용하세요. 연속적인 요청은 페이지 로딩 성능에 나쁜 영향을 줄 수 있습니다.

레이아웃 변경을 피하세요

첫 로딩에서 사용자의 뷰포트에 있는 컴포넌트를 지연 로딩하지 마세요. 이 경우는 누적 레이아웃 이동(cumulative layout shift, CLS)이 발생하면서 Core Web Vital에 부정적인 영향을 줍니다.

꼭 필요한 경우라면, 첫 화면 로딩을 방해할 수 있는 immediate, timer, viewport, 커스텀 when 트리거 사용을 피하세요.

접근성을 항상 고려하세요

@defer 블록을 사용하면 스크린 리더와 같은 보조 기술을 사용하는 사용자에게 큰 영향을 줄 수 있습니다. 스크린 리더는 지연 로딩되는 섹션의 플레이스홀더나 로딩 중이라는 것은 읽을 수 있지만, 이후에 내용물이 갱신되고 나면 변경사항을 알리지 않을 수도 있습니다.

화면이 변경된 것을 스크린 리더에게 명확하게 알리려면, @defer 블록을 aria-live 어트리뷰트로 랩핑하면 됩니다:

<div aria-live="polite" aria-atomic="true">
  @defer (on timer(2000)) {
    <user-profile [user]="currentUser" />
  } @placeholder {
    Loading user profile...
  } @loading {
    Please wait...
  } @error {
    Failed to load profile
  }
</div>

이렇게 구현하면 화면이 변경될 때 변경사항을 사용자에게 제대로 알릴 수 있습니다.