Angular의 RouteReuseStrategy

🚀 RouteReuseStrategy

Angular는 라우팅 시점마다 RoutingModule에 제공된 Routes 중 이전 페이지와 다음 페이지에 해당하는 Route 객체를 찾아 서로 비교하여 변경이 있을 때만 컴포넌트를 교체한다.

동일한 Route 간 이동 시. 같은 컴포넌트가 렌더링 되며 이뤄지는 API 호출은 중복으로 판단하는 것으로 보인다. 가이드 문서에는 없지만, 이 동작은 개발자가 커스터마이징 할 수 있다.

예를 들면 상세에서 목록으로 뒤로 가기로 이동했을 때는 원래 Route 설정이 달라 컴포넌트를 새로 만들어야 하지만 목록에서 상세로 진입 시점에 목록 컴포넌트 상태를 캐시 했다가 뒤로 가기 시점에 복원하여 API 호출을 줄일 수 있다.

RouterReuseStrategy API 상세 설명을 간략히 설명하면 아래와 같다.

export abstract class RouteReuseStrategy {
  /**
   * 현재 이동에 컴포넌트 재사용 '여부'를 확인한다
   * false반환 시 재사용없이 컴포넌트를 새로 만들고
   * true를 반환하면 아래 4개의 메서드를 상황별로 호출하여 캐시 및 복원한다
   * (캐시, 복원 로직은 직접 구현해야 한다)
   */
  abstract shouldReuseRoute(
    future: ActivatedRouteSnapshot,
    curr: ActivatedRouteSnapshot
  ): boolean

  /**
   * 페이지를 빠져나갈 때 현재 컴포넌트 캐시 '여부'를 반환한다
   * false 반환 시 캐시 안해도 되는것으로 판단
   * true 반환 시 아래 store메서드를 호출한다
   */
  abstract shouldDetach(route: ActivatedRouteSnapshot): boolean

  /**
   * 페이지 빠져나가기 전 상태를 캐시한다
   * 캐시를 위해서 두 번째 인자인 DetachedRouteHandle을 어딘가에 저장하면 된다
   */
  abstract store(
    route: ActivatedRouteSnapshot,
    handle: DetachedRouteHandle | null
  ): void

  /**
   * 페이지 진입 시점에 복원 '여부'를 반환한다
   * false 반환 시 복원 안해도 되는것으로 판단
   * true 반환 시 아래 retrieve메서드를 호출한다
   */
  abstract shouldAttach(route: ActivatedRouteSnapshot): boolean

  /**
   * 페이지 진입 시 캐시된 데이터를 복원한다
   * 위에서 구현한 store 메서드 호출 시점에 어딘가에 저장했던 캐시를 반환하면 된다
   */
  abstract retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle | null
}

위의 인터페이스를 상속받아 최상위 NgModule에 Providing 해 주면 된다.

Angular의 DefaultRouteReuseStrategyRouteConfig가 같을 때 shouldReuseRoute의 실행 결과를 true로 반환하고 있지만. 각 메서드에서 캐시 여부(shouldDetach), 복원 여부(shouldAttach) 모두 false를 반환하고 있어 아무 일도 일어나지 않는다.

RouterLink가 동작하지 않아요

Angular는 앞서 설명한 대로 라우팅 전, 후의 Route 객체를 비교하여 다를 때만 컴포넌트를 교체한다. 따라서 같은 Route 객체 간 이동이라면 컴포넌트가 교체되지 않는다.

const routes = [
  {
    path: 'detail/:id',
    component: DetailComponent,
  },
]

위의 라우팅 설정에서 아래 컴포넌트의 링크 클릭해서 '/detail/3'에서 '/detail/12'로 이동했다면 같은 Route객체(정확히는 같은 ActivatedRouteSnapshot)를 비교한다.

따라서 컴포넌트가 교체되지 않아 ngOnInit 을 비롯한 라이프사이클 메서드들이 실행되지 않는다. 만약 ngOnInit에서 id를 받아 API를 호출해 상세 데이터를 보여주도록 개발했다면 문제가 될 수 있다.

@Component({
  selector: 'app-detail',
  template: `
    <h1>detail component</h1>
    <a routerLink="/detail/12">go to '/detail/12'</a>
    <div>{{ content }}</div>
  `,
})
export class DetailComponent {
  content = ''

  // '/detail/12'로 이동했을 때는 호출되지 않아 3번 데이터를 계속 보여준다
  ngOnInit() {
    this.http
      .get(`/detail/${this.activatedRoute.snapshot.params.id}`)
      .subscribe((o) => (this.content = o))
  }
}

아래 데모를 통해 문제를 확인해 볼 수 있다.

이런 경우 ngOnInit이 재실행되지 않아도 갱신되도록 스트림을 이용하여 수정하는 것이 일반적이지만. RouteReuseStrategy를 이용하여 Route 객체 비교를 커스터마이징 할 수 있고. 강제로 컴포넌트를 교체하도록 할 수 있다.

export class CustomRouteReuseStrategy extends RouteReuseStrategy {
  shouldDetach(route: ActivatedRouteSnapshot) {
    return false
  }

  store(route: ActivatedRouteSnapshot, detachedTree: DetachedRouteHandle) {}

  shouldAttach(route: ActivatedRouteSnapshot) {
    return false
  }

  retrieve(route: ActivatedRouteSnapshot) {
    return null
  }

  shouldReuseRoute(
    future: ActivatedRouteSnapshot,
    curr: ActivatedRouteSnapshot
  ) {
    const [futureUrl, currUrl] = [future, curr].map((o) =>
      o.url.map((seg) => seg.path).join('/')
    )

    /**
     * Route비교 시 둘 다 'detail'을 포함한 path라면 컴포넌트를
     * 재사용하지 않도록 false를 반환한다.
     */
    if (futureUrl.includes('detail') && currUrl.includes('detail')) {
      return false
    }

    return future.routeConfig === curr.routeConfig
  }
}

아래는 위 CustomRouteReuseStrategy를 이용한 강제 컴포넌트 교체의 예제이다.

shouldReuseRoute는 여러 번 호출된다

👀 Angular v8 버전 기준으로 작성했으나 v9 에서도 비슷하게 동작한다

Angular의 RouteConfig는 재귀적으로 선언할 수 있게 되어있다. 그래서 shouldReuseRoutefuture, curr파라미터는 path에 대한 정보를 트리 구조로 담고 있다. 자세한 내용은 예제를 통해 파악해 보자.

먼저 앱의 라우팅 설정이 아래처럼 선언되어 있다고 가정한다.

// app-routing.module.ts
const routes = [
  { path: 'list', component: ListComponent },
  { path: 'detail/:id', component: DetailComponent },
  {
    path: 'delivery',
    loadChildren: () =>
      import('./delivery/delivery.module').then((mod) => mod.DeliveryModule),
  },
]

// delivery-routing.module.ts
const routes = [{ path: 'detail/:id', component: DeliveryDetailComponent }]

아래 RouteReuseStrategy는 각 호출 단계에서 url과 해당 Route와 연결된 컴포넌트 이름을 출력한다. 이 strategy를 사용하여 위의 Route 설정에서 발생할 수 있는 이동들에 대한 호출 로그를 분석해 보자.

export class CustomRouteReuseStrategy implements RouteReuseStrategy {
  shouldReuseRoute(
    future: ActivatedRouteSnapshot,
    curr: ActivatedRouteSnapshot
  ) {
    // 분석을 위해 파라미터를 로깅함
    console.log(
      `[future]\n${getInfo(future)}\n\n[curr]:\n${getInfo(curr)}\n\n----------`
    )

    return future.routeConfig === curr.routeConfig
  }
}

1. 앱 진입

[future]
  → '' / null
[curr]:
  → '' / null
----------

앱 진입 시점에 한번 호출된다. 큰 의미는 없다

2. '' 에서 'list'로 이동하는 경우

[future]
  → '' / AppComponent
  → 'list' / ListComponent
[curr]:
  → '' / null
----------

future를 보면 ''는 AppComponent, 'list'는 ListComponent에 제공되는 것을 알 수 있다.

3. 'list'에서 'detail/2'로 이동하는 경우

[future]
  → '' / AppComponent
  → 'detail/2' / DetailComponent
[curr]:
  → '' / AppComponent
  → 'list' / ListComponent
----------
[future]
  → 'list' / ListComponent
[curr]:
  → 'detail/2' / DetailComponent
----------
  • shoudReuseRoute가 두 번 호출되고 있다.
  • 이상한 점이 있는데 두 번째 호출에서는 future, curr값이 뒤바뀌었다.
  • future를 보면 AppComponent에는 :id에 해당하는 문자열이 없다. 라우팅 설정 자체도 그러한데. AppComponent가 DI 받는 ActivatedRouteSnapshot에서는 id를 가져올 수 없는 이유이기도 하다.

4. 'detail/2'에서 'delivery/detail/4'로 이동하는 경우

[future]
  → '' / AppComponent
  → 'delivery' / null
  → 'detail/4' / DeliveryDetailComponent
[curr]:
  → '' / AppComponent
  → 'detail/2' / DetailComponent
----------
[future]
  → 'detail/2' / DetailComponent
[curr]:
  → 'delivery' / null
  → 'detail/4' / DeliveryDetailComponent
----------
  • 3번처럼 두 번째 호출의 future, curr값이 뒤바뀌어 있다. 관련 PR이 있는데 아직 별다른 업데이트가 없다.
  • 첫 shouldReuseRoute의 호출에서 future파라미터를 보면loadChildren을 사용한 Route에는 컴포넌트가 없다.

두 번씩 호출되는 이유는 어디에도 나와 있지 않지만. 관련 코드를 볼 때 두 번씩 호출하더라도 컴포넌트에 올바른 라우팅 상태를 줄 수 있기 때문에 따로 정리하지 않은 것으로 보인다. 따라서 구현 할 때 주의가 필요하다.

상품상세, 목록 간 컴포넌트 캐싱 예제

상세, 목록 페이지의 경우 상세에서 뒤로가기 시 이전에 보고 있던 목록과 스크롤을 유지하면 페이지 탐색 사용성을 크게 개선할 수 있다. 특히 전자상거래 서비스의 경우 매출과 직결되는 부분이기도 하다.

캐싱을 위해 일반적으로 bfcache에 의존하거나. 상세 진입 전의 앱 상태를 persist로 저장했다가 복원하는 방법을 사용하는데. 두 방법은 코드베이스 외적인 부분에 의존하기 때문에 관리가 어렵고 사이드이펙트가 있을 수 있다.

RouteReuseStrategy를 이용한 방법은 캐싱이 필요한 구간에 부분적으로 적용해야 하지만 구현이 코드베이스 안에 있으므로 앞서 언급한 문제에서 자유롭다. 가능한 이 방법을 도입하는 것이 좋아 보인다.

위 데모는 상품목록 상세 예제이다. 원래라면 상세에서 뒤로 가기로 목록으로 돌아왔을 때 컴포넌트가 교체어 버리므로 화면이 깜빡이며 1번째 페이지부터 새로 그리고. 스크롤 위치도 잃어버린다.

export class CustomRouteReuseStrategy extends RouteReuseStrategy {
  private cache = new Map<string, DetachedRouteHandle>()

  shouldDetach(route: ActivatedRouteSnapshot) {
    // 목록에서 빠져나갈 때 true반환하여 store를 호출한다
    if (getPath(route).startsWith('list')) {
      return true
    }

    return false
  }

  store(route: ActivatedRouteSnapshot, detachedTree: DetachedRouteHandle) {
    // 컴포넌트 상태 캐시
    this.cache.set(getPath(route), detachedTree)
  }

  shouldAttach(route: ActivatedRouteSnapshot) {
    const path = getPath(route)

    // 목록 재진입 시 캐시가 있다면 true반환하여 retrieve를 호출한다
    if (path.startsWith('list') && this.cache.has(path)) {
      return true
    }

    return false
  }

  retrieve(route: ActivatedRouteSnapshot) {
    // 컴포넌트 상태 복원
    return this.cache.get(getPath(route))
  }

  shouldReuseRoute(
    future: ActivatedRouteSnapshot,
    curr: ActivatedRouteSnapshot
  ) {
    return future.routeConfig === curr.routeConfig
  }
}

하지만 본문에서 설명한 RouteReuseStrategy를 상속한 커스텀 클래스를 구현하면 캐시된 컨텍스트를 복원하기 때문에 상품목록 컴포넌트가 깜빡이지 않고 곧바로 렌더링 되는 것을 볼 수 있다.