심화 가이드
라우팅

데이터 리졸버

데이터 리졸버를 사용하면 라우팅 규칙으로 이동하기 전에 데이터를 가져와서, 컴포넌트가 렌더링되기 전에 필요한 데이터를 전달할 수 있습니다. 필수 데이터를 미리 로드하기 때문에 로딩 상태를 표시할 필요를 줄이고 사용자 경험을 개선할 수 있습니다.

TIP: 애플리케이션에서 Angular 시그널을 사용한다면 라우팅 규칙 리소스를 활용해 보세요. Resource API로 반응형 데이터 요청을 구현할 수 있고, 논블로킹 데이터 로딩도 기본으로 지원합니다.

데이터 리졸버란?

데이터 리졸버는 ResolveFn 함수를 구현하는 서비스입니다. 라우팅 규칙이 활성화되기 전에 실행되며 API, 데이터베이스 등에서 데이터를 가져올 수 있습니다. 리졸버가 가져온 데이터는 ActivatedRoute를 통해 컴포넌트에 전달됩니다.

데이터 리졸버는 라우팅 규칙 수준에서 제공하는 서비스에 접근할 수 있으며, route 인자로 해당 라우팅 규칙의 정보에도 접근할 수 있습니다.

데이터 리졸버를 사용하는 이유

데이터 리졸버는 라우팅에서 자주 발생하는 문제를 해결합니다.

  • 빈 상태 방지: 컴포넌트가 로드되면 바로 데이터를 받습니다.
  • 사용자 경험 개선: 핵심 데이터를 위한 로딩 스피너가 필요하지 않습니다.
  • 오류 처리: 내비게이션 전에 데이터 요청 오류를 처리합니다.
  • 데이터 일관성: 렌더링 전에 필요한 데이터를 확보합니다. 이는 SSR에서 중요합니다.

리졸버 생성하기

ResolveFn 타입의 함수를 작성하면 리졸버를 생성할 수 있습니다.

이 함수는 ActivatedRouteSnapshot과 RouterStateSnapshot을 파라미터로 받습니다.

다음 리졸버는 inject 함수를 사용해서 라우팅 규칙을 렌더링하기 전에 사용자 정보를 가져옵니다.

import {inject} from '@angular/core';
import {UserStore, SettingsStore} from './user-store';
import type {ActivatedRouteSnapshot, ResolveFn, RouterStateSnapshot} from '@angular/router';
import type {User, Settings} from './types';

export const userResolver: ResolveFn<User> = (
  route: ActivatedRouteSnapshot,
  state: RouterStateSnapshot,
) => {
  const userStore = inject(UserStore);
  const userId = route.paramMap.get('id')!;
  return userStore.getUser(userId);
};

export const settingsResolver: ResolveFn<Settings> = (
  route: ActivatedRouteSnapshot,
  state: RouterStateSnapshot,
) => {
  const settingsStore = inject(SettingsStore);
  const userId = route.paramMap.get('id')!;
  return settingsStore.getUserSettings(userId);
};

라우팅 규칙에 리졸버 설정하기

라우팅 규칙에 데이터 리졸버를 하나 이상 추가하려면 라우팅 규칙 설정의 resolve 키에 지정하세요. 라우팅 규칙 설정의 구조는 Routes 타입에 정의되어 있습니다.

import {Routes} from '@angular/router';

export const routes: Routes = [
  {
    path: 'user/:id',
    component: UserDetail,
    resolve: {
      user: userResolver,
      settings: settingsResolver,
    },
  },
];

자세한 내용은 API 문서의 resolve 설정을 참고하세요.

컴포넌트에서 리졸버 데이터에 접근하기

ActivatedRoute 사용하기

컴포넌트에서는 toSignal 함수로 ActivatedRoute의 data 옵저버블을 시그널로 변환해서 리졸버가 가져온 데이터에 접근할 수 있습니다.

import {Component, inject, computed} from '@angular/core';
import {ActivatedRoute} from '@angular/router';
import {toSignal} from '@angular/core/rxjs-interop';
import type {User, Settings} from './types';

@Component({
  template: `
    <h1>{{ user().name }}</h1>
    <p>{{ user().email }}</p>
    <div>Theme: {{ settings().theme }}</div>
  `,
})
export class UserDetail {
  private route = inject(ActivatedRoute);
  private data = toSignal(this.route.data, {requireSync: true});
  user = computed(() => this.data().user as User);
  settings = computed(() => this.data().settings as Settings);
}

withComponentInputBinding 사용하기

provideRouter로 라우터를 설정할 때 withComponentInputBinding()을 사용하는 방법도 있습니다. 이 방식을 사용하면 리졸버가 가져온 데이터가 컴포넌트의 입력 프로퍼티로 직접 전달됩니다.

import {bootstrapApplication} from '@angular/platform-browser';
import {provideRouter, withComponentInputBinding} from '@angular/router';
import {routes} from './app.routes';

bootstrapApplication(App, {
  providers: [provideRouter(routes, withComponentInputBinding())],
});

이렇게 설정하면 컴포넌트에서 input 함수로 리졸버 키와 일치하는 입력 프로퍼티를 정의할 수 있습니다. 필수 입력에는 input.required를 사용하세요.

import {Component, input} from '@angular/core';
import type {User, Settings} from './types';

@Component({
  template: `
    <h1>{{ user().name }}</h1>
    <p>{{ user().email }}</p>
    <div>Theme: {{ settings().theme }}</div>
  `,
})
export class UserDetail {
  user = input.required<User>();
  settings = input.required<Settings>();
}

이 방식은 타입 안전성을 높이며, 리졸버 데이터에 접근하기 위해 ActivatedRoute를 주입할 필요가 없습니다.

리졸버 오류 처리하기

내비게이션이 실패할 때는 데이터 리졸버에서 오류를 적절하게 처리하는 것이 중요합니다. 오류를 처리하지 않으면 NavigationError가 발생하고 해당 라우팅 규칙으로 이동하지 못해서 사용자 경험이 나빠집니다.

데이터 리졸버의 오류를 처리하는 주요 방법은 세 가지입니다.

  1. withNavigationErrorHandler에서 오류 처리를 통합합니다.
  2. 라우터 이벤트를 구독해서 오류를 관리합니다.
  3. 리졸버에서 직접 오류를 처리합니다.

withNavigationErrorHandler에서 오류 처리 통합하기

withNavigationErrorHandler는 데이터 리졸버의 실패를 포함한 모든 내비게이션 오류를 한곳에서 처리하는 기능입니다. 오류 처리 로직을 한곳에 모으기 때문에 리졸버마다 오류 처리 코드가 중복되는 일을 방지할 수 있습니다.

import {bootstrapApplication} from '@angular/platform-browser';
import {provideRouter, Router, withNavigationErrorHandler} from '@angular/router';
import {inject} from '@angular/core';
import {routes} from './app.routes';

bootstrapApplication(App, {
  providers: [
    provideRouter(
      routes,
      withNavigationErrorHandler((error) => {
        const router = inject(Router);

        if (error.error?.message) {
          console.error('Navigation error occurred:', error.error.message);
        }

        router.navigate(['/error']);
      }),
    ),
  ],
});

이렇게 설정하면 오류 상황은 공통 핸들러가 관리하고, 리졸버는 데이터 요청에 집중할 수 있습니다.

export const userResolver: ResolveFn<User> = (route) => {
  const userStore = inject(UserStore);
  const userId = route.paramMap.get('id')!;
  // 여기서 직접 에러를 처리하지 않고 상위로 전달합니다.
  return userStore.getUser(userId);
};

라우터 이벤트를 구독해서 오류 관리하기

라우터 이벤트를 구독하고 NavigationError 이벤트를 감지해서 리졸버 오류를 처리할 수도 있습니다. 이 방식은 오류 처리를 더 세밀하게 제어할 수 있으며 커스텀 오류 복구 로직도 구현할 수 있습니다.

import {Component, inject, signal} from '@angular/core';
import {Router, NavigationError, RouterOutlet} from '@angular/router';
import {toSignal} from '@angular/core/rxjs-interop';
import {map} from 'rxjs';

@Component({
  selector: 'app-root',
  imports: [RouterOutlet],
  template: `
    @if (errorMessage()) {
      <div class="error-banner">
        {{ errorMessage() }}
        <button (click)="retryNavigation()">Retry</button>
      </div>
    }
    <router-outlet />
  `,
})
export class App {
  private router = inject(Router);
  private lastFailedUrl = signal('');

  private navigationErrors = toSignal(
    this.router.events.pipe(
      map((event) => {
        if (event instanceof NavigationError) {
          this.lastFailedUrl.set(event.url);

          if (event.error) {
            console.error('Navigation error', event.error);
          }

          return 'Navigation failed. Please try again.';
        }
        return '';
      }),
    ),
    {initialValue: ''},
  );

  errorMessage = this.navigationErrors;

  retryNavigation() {
    if (this.lastFailedUrl()) {
      this.router.navigateByUrl(this.lastFailedUrl());
    }
  }
}

이 방식은 다음과 같은 경우에 특히 유용합니다.

  • 실패한 내비게이션에 커스텀 재시도 로직을 구현할 때
  • 실패 유형에 따라 다른 오류 메시지를 표시할 때
  • 분석 목적으로 내비게이션 실패를 추적할 때

리졸버에서 직접 오류 처리하기

다음은 오류를 기록하고 Router 서비스로 일반 /users 페이지에 돌아가도록 수정한 userResolver 예제입니다.

import {inject} from '@angular/core';
import {ResolveFn, RedirectCommand, Router} from '@angular/router';
import {catchError, of, EMPTY} from 'rxjs';
import {UserStore} from './user-store';
import type {User} from './types';

export const userResolver: ResolveFn<User | RedirectCommand> = (route) => {
  const userStore = inject(UserStore);
  const router = inject(Router);
  const userId = route.paramMap.get('id')!;

  return userStore.getUser(userId).pipe(
    catchError((error) => {
      console.error('Failed to load user:', error);
      return of(new RedirectCommand(router.parseUrl('/users')));
    }),
  );
};

내비게이션 로딩 시 고려사항

데이터 리졸버를 사용하면 컴포넌트 안에 로딩 상태를 표시할 필요는 줄어들지만, 다른 사용자 경험 문제를 고려해야 합니다. 리졸버가 실행되는 동안 내비게이션이 대기하기 때문입니다. 특히 네트워크 요청이 느리면 사용자가 링크를 클릭한 뒤 새 라우팅 규칙이 표시되기까지 지연을 느낄 수 있습니다.

TIP: 내비게이션을 막지 않고 스켈레톤 화면이나 로딩 표시와 함께 컴포넌트를 즉시 렌더링하려면 논블로킹 라우팅 규칙 리소스를 사용하세요.

내비게이션 진행 상황 표시하기

리졸버가 실행되는 동안 사용자 경험을 개선하려면 라우터 이벤트를 감지해서 로딩 표시기를 표시할 수 있습니다.

import {Component, computed, inject} from '@angular/core';
import {Router, RouterOutlet} from '@angular/router';

@Component({
  selector: 'app-root',
  imports: [RouterOutlet],
  template: `
    @if (isNavigating()) {
      <div class="loading-bar">Loading...</div>
    }
    <router-outlet />
  `,
})
export class App {
  private router = inject(Router);
  isNavigating = computed(() => !!this.router.currentNavigation());
}

이렇게 하면 리졸버가 데이터를 가져오는 동안 사용자에게 내비게이션이 진행 중이라는 사실을 시각적으로 알릴 수 있습니다.

권장사항

  • 리졸버를 가볍게 유지하세요: 페이지에서 필요할 수 있는 모든 데이터 대신 필수 데이터만 가져오세요.
  • 오류를 처리하세요: 사용자에게 가능한 한 좋은 경험을 제공할 수 있도록 항상 오류를 적절하게 처리하세요.
  • 캐시를 활용하세요: 성능을 높이기 위해 리졸버가 가져온 데이터의 캐시를 고려하세요.
  • 내비게이션 사용자 경험을 고려하세요: 데이터를 가져오는 동안 내비게이션이 대기하므로 리졸버 실행을 위한 로딩 표시기를 구현하세요.
  • 적절한 제한 시간을 설정하세요: 리졸버가 무기한 대기하면서 내비게이션을 막지 않도록 하세요.
  • 타입 안전성을 확보하세요: 리졸버가 가져오는 데이터에 TypeScript 인터페이스를 사용하세요.

자식 리졸버에서 부모의 리졸버 데이터 읽기

리졸버는 부모에서 자식 순서로 순차 실행됩니다. 부모 라우팅 규칙에 리졸버가 정의되어 있다면, 이후에 실행되는 자식 리졸버는 부모 리졸버가 가져온 데이터를 사용할 수 있습니다.

NOTE: 리졸버는 부모에서 자식 순서로 순차 실행되므로 중첩 단계마다 전체 내비게이션 대기 시간이 길어집니다(네트워크 워터폴). 자식 라우팅 규칙이 부모 데이터에 의존하지 않는다면 모든 라우팅 규칙에서 동시에 실행되는 라우팅 규칙 리소스를 고려하세요.

import {inject} from '@angular/core';
import {provideRouter, ActivatedRouteSnapshot} from '@angular/router';
import {userResolver} from './resolvers';
import {UserPosts} from './pages';
import {PostService} from './services';
import type {User} from './types';

provideRouter([
  {
    path: 'users/:id',
    resolve: {user: userResolver}, // 부모 라우팅 규칙의 사용자 리졸버
    children: [
      {
        path: 'posts',
        component: UserPosts,
        // 이 리졸버가 실행되는 동안 여기에서 route.data.user를 사용할 수 있습니다.
        resolve: {
          posts: (route: ActivatedRouteSnapshot) => {
            const postService = inject(PostService);
            const user = route.parent?.data['user'] as User; // 부모 데이터
            const userId = user.id;
            return postService.getPostByUser(userId);
          },
        },
      },
    ],
  },
]);

다음 단계