Skip to content

Chapter 12. httpResource()와 Repository 계층 분리, 함수형 인터셉터, 전역 ErrorHandler ​

"컴포넌트와 스토어마다 흩어져 있는 HttpClient 호출을 PostRepository와 Angular 22의 선언적 httpResource()로 정돈하고, 동시 401 만료 시 단 한 번만 토큰을 재발급받는 Single-Flight 인터셉터와 전역 에러 방어막을 완성하자."


📋 이 장에서 처음 만나는 개념 — 모르는 문법이 나오면 0장 준비운동에서 찾아볼 수 있습니다.

개념한 줄 요약설명 위치
PostRepository 계층URL 경로와 HTTP 메서드를 캡슐화하여 도메인 메서드로 제공하는 서비스12.1~12.2
httpResource() (Angular 22 안정화)반응형 신호가 바뀔 때마다 자동으로 HTTP 조회를 수행하고 결과·로딩·에러 신호를 제공하는 API12.2 [코드 12-1]
HttpInterceptorFn모든 HTTP 요청·응답을 가로채 헤더 주입과 에러 처리를 수행하는 함수형 인터셉터12.3 [코드 12-2]
Single-Flight 401 토큰 갱신 큐여러 요청이 동시에 401을 받아도 /api/auth/refresh는 1번만 호출하고 나머지는 대기 후 재전송12.3 시퀀스 그림
SKIP_AUTH_INTERCEPTOR (HttpContextToken)토큰 갱신 요청 자신이 401 인터셉터에 다시 걸려 무한 교착에 빠지는 것을 막는 컨텍스트 토큰12.3 해부 박스
글로벌 ErrorHandler앱 전역의 미처리 예외를 포착해 화면 백화(White-out)를 막고 에러 배너로 복구하는 안전망12.4 [코드 12-4]

TIP

🏁 이 장의 시작 파일
frontend/src/app/board-app/board-app.component.ts와 post.store.ts가 11장 완료 상태에서 시작합니다.
이 장에서 작성·수정하는 파일:

  • frontend/src/app/repositories/post.repository.ts (PostRepository 및 httpResource() 팩토리)
  • frontend/src/app/interceptors/auth.interceptor.ts (Single-Flight 401 갱신 인터셉터)
  • frontend/src/app/core/global-error-handler.ts (전역 ErrorHandler 및 에러 상태 배너)
  • frontend/src/app/stores/post.store.ts (HttpClient 직접 호출을 PostRepository로 교체)
  • frontend/tests/unit/auth.interceptor.spec.ts (동시 401 Single-Flight 검증 테스트)

12.1 왜 컴포넌트와 스토어에서 HttpClient를 분리해야 하는가? ​

지금까지 우리는 PostStore와 일부 컴포넌트 안에서 this.http.get('/api/posts')를 직접 호출했습니다. 만약 백엔드 팀이 *"다음 달부터 모든 게시판 API 경로가 /api/v2/posts로 바뀝니다"*라고 통보한다면 어떨까요?
비즈니스 상태 관리 코드(PostStore)와 네트워크 엔드포인트 상세('/api/posts')가 한 파일에 뒤섞여 있으면 수정 범위가 넓어지고 테스트 시 모킹(Mocking)하기도 까다로워집니다.

따라서 백엔드의 Controller - Service - Repository 계층처럼 프론트엔드도 역할을 3단계로 명확히 나눕니다:


12.2 [실습 1] Angular 22 httpResource()와 PostRepository 구축 ​

Angular 22에서 공식 안정화된 **httpResource()**는 @angular/common/http가 제공하는 선언적 반응형 HTTP 조회 프리미티브입니다. 검색어나 페이지 번호 같은 Signal을 읽는 요청 팩토리 함수를 넘기면, 신호가 바뀔 때마다 자동으로 이전 진행 중 요청을 취소(AbortController)하고 새 요청을 날려주며 .value(), .isLoading(), .error(), .reload() 신호를 한 세트로 제공합니다!

반면 등록(POST), 삭제(DELETE), 좋아요(POST)처럼 사용자의 버튼 클릭 시점에 실행되는 **명령형 변경 작업(Mutation)**은 기존처럼 HttpClient + firstValueFrom 메서드로 작성합니다.

[코드 12-1] 도메인 레포지토리 (frontend/src/app/repositories/post.repository.ts) ​

typescript
// frontend/src/app/repositories/post.repository.ts
import { Injectable, Signal, inject } from '@angular/core';
import { HttpClient, HttpParams, httpResource, HttpResourceRef } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import {
  PostResponse,
  CreatePostRequest,
  PageResponse,
  isPageResponse,
  isPostResponse
} from '../models/post.model';
import { FetchPostsParams } from '../stores/post.store';

@Injectable({ providedIn: 'root' })
export class PostRepository {
  private readonly http = inject(HttpClient);
  private readonly baseUrl = '/api/posts';

  /**
   * 🌟 1. Angular 22 선언적 httpResource 팩토리:
   * 파라미터 Signal이 바뀔 때마다 자동으로 GET /api/posts 요청을 트리거하고
   * value(), isLoading(), error(), reload() 신호를 담은 HttpResourceRef를 반환합니다.
   */
  createPostsResource(
    paramsSignal: Signal<Required<FetchPostsParams>>
  ): HttpResourceRef<PageResponse<PostResponse> | undefined> {
    return httpResource<PageResponse<PostResponse>>(() => {
      const p = paramsSignal();
      const params: Record<string, string | number> = {
        page: p.page,
        size: p.size
      };
      if (p.category && p.category !== 'ALL') {
        params['category'] = p.category;
      }
      if (p.keyword.trim()) {
        params['keyword'] = p.keyword.trim();
      }
      return {
        url: this.baseUrl,
        method: 'GET',
        params
      };
    });
  }

  /**
   * 🌟 2. 단건 상세 조회용 httpResource 팩토리:
   * 선택된 postId 신호가 null이면 undefined를 반환해 네트워크 요청을 보내지 않습니다!
   */
  createPostDetailResource(
    postIdSignal: Signal<number | null>
  ): HttpResourceRef<PostResponse | undefined> {
    return httpResource<PostResponse>(() => {
      const id = postIdSignal();
      return id !== null ? `${this.baseUrl}/${id}` : undefined;
    });
  }

  /**
   * 3. 명령형 목록 조회 (PostStore의 수동 TTL 캐시 액션용)
   */
  async getPosts(params: Required<FetchPostsParams>): Promise<PageResponse<PostResponse>> {
    let httpParams = new HttpParams()
      .set('page', String(params.page))
      .set('size', String(params.size));

    if (params.category !== 'ALL') {
      httpParams = httpParams.set('category', params.category);
    }
    if (params.keyword.trim()) {
      httpParams = httpParams.set('keyword', params.keyword.trim());
    }

    const raw = await firstValueFrom(
      this.http.get<unknown>(this.baseUrl, { params: httpParams })
    );

    if (isPageResponse(raw, isPostResponse)) {
      return raw;
    }
    if (Array.isArray(raw) && raw.every(isPostResponse)) {
      return {
        content: raw,
        totalElements: raw.length,
        totalPages: 1,
        pageNumber: params.page,
        pageSize: params.size
      };
    }
    throw new Error('서버 응답이 PageResponse<PostResponse> 규격과 일치하지 않습니다.');
  }

  async createPost(payload: CreatePostRequest): Promise<PostResponse> {
    return firstValueFrom(this.http.post<PostResponse>(this.baseUrl, payload));
  }

  async deletePost(postId: number): Promise<void> {
    await firstValueFrom(this.http.delete<void>(`${this.baseUrl}/${postId}`));
  }

  async toggleLike(postId: number): Promise<PostResponse> {
    return firstValueFrom(
      this.http.post<PostResponse>(`${this.baseUrl}/${postId}/like`, {})
    );
  }
}

🆕 처음 보는 문법: Angular 22 httpResource()

  • httpResource(() => urlOrRequestObject): 내부에서 HttpClient와 함수형 인터셉터 체인을 그대로 거쳐 동작하는 Signal 기반 반응형 리소스입니다.
  • 요청 생략 기능: 팩토리 함수가 undefined를 반환하면(예: 상세 모달이 닫혀 postIdSignal()이 null일 때) 네트워크 요청을 아예 보내지 않고 대기합니다.
  • 자동 취소(Auto-Cancellation): 사용자가 검색어를 빠르게 고쳐 이전 요청이 끝나기 전에 파라미터 신호가 바뀌면, 진행 중이던 이전 HTTP 요청을 자동으로 취소해 응답 순서가 뒤집히는 경쟁 상태를 원천 차단합니다.

12.3 [실습 2] Single-Flight 401 토큰 갱신 함수형 인터셉터 (auth.interceptor.ts) ​

화면에 진입할 때 게시글 목록 조회와 내 알림 조회 API 2개가 동시에 나갔는데, 공교롭게도 Access Token이 방금 만료되어 둘 다 401 Unauthorized를 받았다고 해봅시다. 만약 두 요청이 각자 /api/auth/refresh를 호출하면 먼저 도착한 요청이 Refresh Token을 소모(Rotation)해버려 두 번째 요청은 인증 에러로 튕겨 나갑니다!

🏃 비유: 사무실 똘똘한 막내의 편의점 심부름 (Single-Flight 대기열 큐)
사무실에서 5명이 동시에 "목마르다"고 각자 편의점으로 뛰어나가는 대신, 똘똘한 막내 한 명만 대표로 편의점에 보내고(isRefreshing = true), 나머지 4명은 사무실 대기열 큐(refreshTokenSubject)에서 막내가 새 음료수(새 Access Token)를 사 올 때까지 얌전히 기다렸다가 받아 마시는 패턴입니다.

[코드 12-2] Single-Flight 401 갱신 인터셉터 (frontend/src/app/interceptors/auth.interceptor.ts) ​

typescript
// frontend/src/app/interceptors/auth.interceptor.ts
import {
  HttpContext,
  HttpContextToken,
  HttpErrorResponse,
  HttpInterceptorFn,
  HttpClient
} from '@angular/common/http';
import { inject } from '@angular/core';
import { BehaviorSubject, Observable, catchError, filter, finalize, switchMap, take, throwError } from 'rxjs';
import { AuthStore } from '../stores/auth.store';

// 🌟 토큰 갱신 요청 자신이 다시 401 인터셉터에 걸려 무한 대기(Self-Deadlock)에 빠지는 것을 막는 토큰
export const SKIP_AUTH_INTERCEPTOR = new HttpContextToken<boolean>(() => false);

// Single-Flight 상태 제어용 모듈 스코프 변수 (테스트 초기화를 위해 리셋 함수도 함께 제공)
let isRefreshing = false;
const refreshTokenSubject = new BehaviorSubject<string | null>(null);

export function resetAuthInterceptorState(): void {
  isRefreshing = false;
  refreshTokenSubject.next(null);
}

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  // 갱신 전용 요청은 인터셉터 로직을 건너뛰고 바로 통과!
  if (req.context.get(SKIP_AUTH_INTERCEPTOR)) {
    return next(req);
  }

  const authStore = inject(AuthStore);
  const http = inject(HttpClient);
  const currentToken = authStore.token();

  // 1. 발신 요청에 Authorization 헤더 자동 첨부
  const authReq = currentToken
    ? req.clone({ setHeaders: { Authorization: `Bearer ${currentToken}` } })
    : req;

  // 2. 응답 감시 및 401 발생 시 Single-Flight 토큰 갱신 파이프라인 가동
  return next(authReq).pipe(
    catchError((error: unknown) => {
      if (!(error instanceof HttpErrorResponse) || error.status !== 401) {
        return throwError(() => error);
      }

      // 2-A. 이미 다른 요청이 토큰 갱신을 진행 중이라면: 대기열 큐(refreshTokenSubject)에서 새 토큰이 올 때까지 대기!
      if (isRefreshing) {
        return refreshTokenSubject.pipe(
          filter((token): token is string => token !== null),
          take(1),
          switchMap((newToken) =>
            next(req.clone({ setHeaders: { Authorization: `Bearer ${newToken}` } }))
          )
        );
      }

      // 2-B. 내가 첫 번째 401 요청이라면: 총대를 메고 토큰 갱신 API 호출!
      isRefreshing = true;
      refreshTokenSubject.next(null);

      const refreshToken =
        typeof localStorage !== 'undefined' ? localStorage.getItem('REFRESH_TOKEN') : null;

      return http
        .post<{ accessToken: string }>(
          '/api/auth/refresh',
          { refreshToken },
          { context: new HttpContext().set(SKIP_AUTH_INTERCEPTOR, true) }
        )
        .pipe(
          switchMap((res): Observable<any> => {
            const newToken = res.accessToken;
            authStore.updateAccessToken(newToken);
            refreshTokenSubject.next(newToken); // 대기 중인 다른 요청들에게 새 토큰 방송!
            return next(
              req.clone({ setHeaders: { Authorization: `Bearer ${newToken}` } })
            );
          }),
          catchError((refreshErr) => {
            // 갱신마저 실패(Refresh Token 만료)하면 인증 상태만 정리(10장 초안은 보존!)
            authStore.logout();
            return throwError(() => refreshErr);
          }),
          finalize(() => {
            isRefreshing = false;
          })
        );
    })
  );
};
🔬 심화(건너뛰어도 됨): SKIP_AUTH_INTERCEPTOR가 없으면 왜 영원히 멈추나요? (Self-Deadlock)

만약 /api/auth/refresh 요청에 SKIP_AUTH_INTERCEPTOR 컨텍스트를 붙이지 않으면, Refresh Token마저 만료되어 /api/auth/refresh가 401을 반환했을 때 그 응답이 다시 authInterceptor의 catchError로 들어옵니다.
이때 isRefreshing은 이미 true이므로, 갱신 요청 자신이 refreshTokenSubject에 줄을 서서 "자기 자신이 끝나서 새 토큰을 줄 때까지" 영원히 기다리는 교착 상태(Self-Deadlock)에 빠집니다! HttpContextToken으로 갱신 요청을 제외하면 이 함정을 완벽히 피할 수 있습니다.

[코드 12-3] PostStore를 PostRepository 기반으로 리팩터링 (frontend/src/app/stores/post.store.ts 핵심 변경부) ​

typescript
// frontend/src/app/stores/post.store.ts (HttpClient 대신 PostRepository 주입!)
import { Injectable, computed, inject, signal } from '@angular/core';
import { PostResponse, CreatePostRequest } from '../models/post.model';
import { PostRepository } from '../repositories/post.repository';

@Injectable({ providedIn: 'root' })
export class PostStore {
  // 🌟 이제 PostStore는 HttpClient나 URL('/api/posts')을 전혀 알지 못합니다!
  private readonly repository = inject(PostRepository);

  // ... (_posts, _totalElements, _loading, _pendingPostIds 등 신호 선언은 10장과 동일) ...

  async fetchPosts(params: FetchPostsParams = {}, force = false): Promise<void> {
    const normalized = {
      page: params.page ?? 1,
      size: params.size ?? 10,
      category: params.category ?? 'ALL',
      keyword: (params.keyword ?? '').trim()
    };
    // ... (캐시 히트 검사 9장과 동일) ...
    this._loading.set(true);
    try {
      const pageData = await this.repository.getPosts(normalized);
      this._posts.set(pageData.content);
      this._totalElements.set(pageData.totalElements);
      this._lastFetchedAt.set(Date.now());
      this._lastQueryKey.set(JSON.stringify(normalized));
    } finally {
      this._loading.set(false);
    }
  }

  async createPost(payload: CreatePostRequest): Promise<PostResponse> {
    const created = await this.repository.createPost(payload);
    await this.fetchPosts({ page: 1, size: 10 }, true);
    return created;
  }

  async removePost(postId: number): Promise<void> {
    await this.repository.deletePost(postId);
    this._posts.update((list) => list.filter((p) => p.id !== postId));
    this._totalElements.update((total) => Math.max(0, total - 1));
  }

  async toggleLikeOptimistic(postId: number): Promise<void> {
    // ... (10장의 백업·잠금·+1 선제 반영 동일, API 호출만 repository.toggleLike(postId)로 교체!) ...
    const updated = await this.repository.toggleLike(postId);
    // ...
  }
}

12.4 [실습 3] 글로벌 ErrorHandler와 에러 바운더리 배너 구축 ​

컴포넌트 렌더링 도중이나 처리되지 않은 Promise에서 예기치 못한 런타임 예외가 발생했을 때 앱 전체가 하얗게 죽는(White-out) 현상을 막기 위해, Angular의 **ErrorHandler**를 커스텀 구현하여 에러 상태를 신호로 기록하고 화면 상단에 복구 가능한 에러 배너를 띄웁니다.

[코드 12-4] 전역 에러 핸들러 및 에러 배너 (frontend/src/app/core/global-error-handler.ts) ​

typescript
// frontend/src/app/core/global-error-handler.ts
import { Component, ChangeDetectionStrategy, ErrorHandler, Injectable, inject, signal } from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';

@Injectable({ providedIn: 'root' })
export class GlobalErrorState {
  private readonly _lastError = signal<string | null>(null);
  readonly lastError = this._lastError.asReadonly();

  report(message: string): void {
    this._lastError.set(message);
  }

  clear(): void {
    this._lastError.set(null);
  }
}

@Injectable()
export class GlobalErrorHandler implements ErrorHandler {
  private readonly errorState = inject(GlobalErrorState);

  handleError(error: unknown): void {
    // Promise unhandledrejection 래핑 해제
    const unwrapped =
      error && typeof error === 'object' && 'rejection' in error
        ? (error as { rejection: unknown }).rejection
        : error;

    let userMessage = '일시적인 화면 오류가 발생했습니다. 다시 시도해 주세요.';

    if (unwrapped instanceof HttpErrorResponse) {
      userMessage = `네트워크 통신 오류 (${unwrapped.status}): 서버 응답을 처리할 수 없습니다.`;
    } else if (unwrapped instanceof Error) {
      userMessage = unwrapped.message;
    }

    console.error('💥 [GlobalErrorHandler 포착]:', unwrapped);
    this.errorState.report(userMessage);
  }
}

// 최상위 AppComponent에 장착할 에러 바운더리 배너 컴포넌트
@Component({
  selector: 'app-error-boundary-banner',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @if (errorState.lastError(); as msg) {
      <div class="error-banner" role="alert">
        <span>⚠️ <strong>시스템 오류 감지:</strong> {{ msg }}</span>
        <button type="button" (click)="errorState.clear()">닫기 및 복구</button>
      </div>
    }
  `,
  styles: [`
    .error-banner {
      display: flex; justify-content: space-between; align-items: center;
      background: #fef2f2; color: #991b1b; border: 1px solid #f87171;
      padding: 12px 16px; border-radius: 8px; margin: 12px auto; max-width: 960px;
    }
    .error-banner button {
      background: #dc2626; color: #fff; border: none; padding: 6px 12px;
      border-radius: 6px; cursor: pointer; font-weight: 600;
    }
  `]
})
export class ErrorBoundaryBannerComponent {
  readonly errorState = inject(GlobalErrorState);
}

main.ts의 bootstrapApplication에 인터셉터와 에러 핸들러를 한 줄씩 등록합니다:

typescript
// frontend/src/main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { ErrorHandler } from '@angular/core';
import { AppComponent } from './app/app.component';
import { routes } from './app/app.routes';
import { authInterceptor } from './app/interceptors/auth.interceptor';
import { GlobalErrorHandler } from './app/core/global-error-handler';

bootstrapApplication(AppComponent, {
  providers: [
    provideRouter(routes),
    provideHttpClient(withInterceptors([authInterceptor])),
    { provide: ErrorHandler, useClass: GlobalErrorHandler }
  ]
});

12.5 [직접 해보기] 실습 미션 💪 ​

🎯 미션: 동시 401 발생 시 /api/auth/refresh가 딱 1번만 호출되는지 검증하는 테스트 작성하기 ​

TestBed에 provideHttpClient(withInterceptors([authInterceptor]))를 등록하고, 만료된 토큰 상태에서 http.get('/api/posts') 2개를 Promise.all로 동시에 호출했을 때 MSW의 /api/auth/refresh 핸들러가 정확히 **1회(refreshCount === 1)**만 호출되는지 검증하는 테스트를 작성해 보세요.

💡 미션 정답 코드 확인하기
typescript
// frontend/tests/unit/auth.interceptor.spec.ts
import { TestBed } from '@angular/core/testing';
import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { describe, it, expect, beforeAll, afterAll, afterEach, beforeEach } from 'vitest';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { authInterceptor, resetAuthInterceptorState } from '../../src/app/interceptors/auth.interceptor';
import { AuthStore } from '../../src/app/stores/auth.store';

const server = setupServer();
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => {
  server.resetHandlers();
  resetAuthInterceptorState();
  localStorage.clear();
});
afterAll(() => server.close());

describe('authInterceptor Single-Flight 401 토큰 갱신 테스트', () => {
  let httpClient: HttpClient;
  let authStore: AuthStore;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        provideHttpClient(withInterceptors([authInterceptor])),
        AuthStore
      ]
    });
    httpClient = TestBed.inject(HttpClient);
    authStore = TestBed.inject(AuthStore);
    authStore.updateAccessToken('expired-token');
    localStorage.setItem('REFRESH_TOKEN', 'valid-refresh-token');
  });

  it('동시에 2개의 요청이 401을 받아도 갱신 API는 단 1번만 호출되고 두 요청 모두 재전송에 성공한다', async () => {
    let refreshCount = 0;

    server.use(
      http.get('/api/posts', ({ request }) => {
        if (request.headers.get('Authorization') !== 'Bearer fresh-token-999') {
          return new HttpResponse(null, { status: 401 });
        }
        return HttpResponse.json({ content: [], totalElements: 0, totalPages: 0, pageNumber: 1, pageSize: 10 });
      }),
      http.post('/api/auth/refresh', () => {
        refreshCount++;
        return HttpResponse.json({ accessToken: 'fresh-token-999' });
      })
    );

    const [res1, res2] = await Promise.all([
      firstValueFrom(httpClient.get('/api/posts')),
      firstValueFrom(httpClient.get('/api/posts'))
    ]);

    expect(res1).toBeDefined();
    expect(res2).toBeDefined();
    expect(refreshCount).toBe(1); // 🌟 Single-Flight 검증 완료!
    expect(authStore.token()).toBe('fresh-token-999');
  });
});

12.6 이번 장 핵심 요약 및 다음 단계 🚀 ​

핵심 3줄

  1. 컴포넌트와 스토어에서 HttpClient를 걷어내고 PostRepository로 도메인 통신을 격리하며, 반응형 조회에는 Angular 22의 httpResource()를 활용합니다.
  2. authInterceptor에서 isRefreshing 플래그와 BehaviorSubject 대기열 큐를 조합해 동시 401 응답 시 단 1회만 토큰을 갱신(Single-Flight)합니다.
  3. 전역 ErrorHandler를 구현해 미처리 예외를 GlobalErrorState 신호로 수집하고 에러 배너 UI로 우아하게 복구합니다.

바뀐 파일

파일무엇이 바뀌었나
src/app/repositories/post.repository.tshttpResource() 팩토리 및 CRUD 도메인 메서드 캡슐화
src/app/interceptors/auth.interceptor.tsBearer 토큰 주입 및 Single-Flight 401 대기열 큐 인터셉터 구현
src/app/core/global-error-handler.tsGlobalErrorHandler 및 ErrorBoundaryBannerComponent 구현
src/app/stores/post.store.tsHttpClient 의존성을 PostRepository로 교체

자주 하는 실수

증상원인해결
authInterceptor를 만들었는데 헤더에 토큰이 안 붙음main.ts의 provideHttpClient()에 withInterceptors([authInterceptor])를 빠뜨림provideHttpClient(withInterceptors([authInterceptor])) 등록 확인
Refresh Token 만료 시 앱이 멈추고 아무 응답도 안 옴/api/auth/refresh 요청 자신이 다시 401 인터셉터 큐에 들어감갱신 요청에 SKIP_AUTH_INTERCEPTOR 컨텍스트 토큰을 부여해 인터셉터 우회
httpResource()를 버튼 클릭 함수 안에서 호출했더니 에러 발생httpResource()는 effect()처럼 Injection Context에서 선언해야 함클래스 필드 선언부나 constructor에서 리소스를 생성하고 신호 변경으로 재요청 유도

셀프 체크 — 답을 먼저 떠올린 뒤 펼쳐 보십시오.

  1. PostRepository의 createPostDetailResource에서 postIdSignal()이 null일 때 undefined를 반환하면 httpResource()는 어떻게 동작하나요?
  2. authInterceptor에서 두 번째로 401을 받은 요청은 왜 refreshTokenSubject.pipe(filter(t => t !== null), take(1), ...)를 구독하나요?
  3. 토큰 갱신마저 실패했을 때 왜 localStorage.clear() 대신 authStore.logout()을 호출해야 하나요?
셀프 체크 정답
  1. 요청 팩토리가 undefined를 반환하면 httpResource()는 HTTP 요청을 보내지 않고 유휴(Idle) 상태로 대기하다가, 나중에 postIdSignal()이 숫자로 바뀌는 순간 자동으로 요청을 발송합니다.
  2. 첫 번째 요청이 갱신을 시작할 때 refreshTokenSubject.next(null)로 비워 두었으므로, 갱신이 완료되어 null이 아닌 새 토큰 문자열이 방출될 때까지 기다렸다가 딱 1번(take(1))만 받아 재요청하기 위해서입니다.
  3. localStorage.clear()는 10장의 DraftStore가 저장한 글 작성 초안까지 삭제해 버리므로, 인증 토큰 키만 선택적으로 지우는 authStore.logout()을 사용해야 합니다.

다음 챕터로 이어지는 질문
네트워크 계층과 에러 방어막까지 완벽히 갖추었습니다. 그런데 사내 핫이슈 게시글에 댓글이 10,000개 달리자, 상세 화면을 열 때마다 브라우저가 3초간 얼어붙고 스크롤이 뚝뚝 끊깁니다.
이어지는 Chapter 13에서는 @angular/cdk/scrolling 가상 스크롤(CdkVirtualScrollViewport), Angular 22의 기본 ChangeDetectionStrategy.OnPush 원리, afterNextRender 성능 측정, 그리고 DestroyRef 메모리 누수 방어를 실습합니다.

Moon HomeLab Engineering Knowledge Hub