다크 모드
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 조회를 수행하고 결과·로딩·에러 신호를 제공하는 API | 12.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줄
- 컴포넌트와 스토어에서
HttpClient를 걷어내고PostRepository로 도메인 통신을 격리하며, 반응형 조회에는 Angular 22의httpResource()를 활용합니다. authInterceptor에서isRefreshing플래그와BehaviorSubject대기열 큐를 조합해 동시 401 응답 시 단 1회만 토큰을 갱신(Single-Flight)합니다.- 전역
ErrorHandler를 구현해 미처리 예외를GlobalErrorState신호로 수집하고 에러 배너 UI로 우아하게 복구합니다.
바뀐 파일
| 파일 | 무엇이 바뀌었나 |
|---|---|
src/app/repositories/post.repository.ts | httpResource() 팩토리 및 CRUD 도메인 메서드 캡슐화 |
src/app/interceptors/auth.interceptor.ts | Bearer 토큰 주입 및 Single-Flight 401 대기열 큐 인터셉터 구현 |
src/app/core/global-error-handler.ts | GlobalErrorHandler 및 ErrorBoundaryBannerComponent 구현 |
src/app/stores/post.store.ts | HttpClient 의존성을 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에서 리소스를 생성하고 신호 변경으로 재요청 유도 |
셀프 체크 — 답을 먼저 떠올린 뒤 펼쳐 보십시오.
PostRepository의createPostDetailResource에서postIdSignal()이null일 때undefined를 반환하면httpResource()는 어떻게 동작하나요?authInterceptor에서 두 번째로 401을 받은 요청은 왜refreshTokenSubject.pipe(filter(t => t !== null), take(1), ...)를 구독하나요?- 토큰 갱신마저 실패했을 때 왜
localStorage.clear()대신authStore.logout()을 호출해야 하나요?
셀프 체크 정답
- 요청 팩토리가
undefined를 반환하면httpResource()는 HTTP 요청을 보내지 않고 유휴(Idle) 상태로 대기하다가, 나중에postIdSignal()이 숫자로 바뀌는 순간 자동으로 요청을 발송합니다. - 첫 번째 요청이 갱신을 시작할 때
refreshTokenSubject.next(null)로 비워 두었으므로, 갱신이 완료되어null이 아닌 새 토큰 문자열이 방출될 때까지 기다렸다가 딱 1번(take(1))만 받아 재요청하기 위해서입니다. localStorage.clear()는 10장의DraftStore가 저장한 글 작성 초안까지 삭제해 버리므로, 인증 토큰 키만 선택적으로 지우는authStore.logout()을 사용해야 합니다.
다음 챕터로 이어지는 질문
네트워크 계층과 에러 방어막까지 완벽히 갖추었습니다. 그런데 사내 핫이슈 게시글에 댓글이 10,000개 달리자, 상세 화면을 열 때마다 브라우저가 3초간 얼어붙고 스크롤이 뚝뚝 끊깁니다.
이어지는 Chapter 13에서는 @angular/cdk/scrolling 가상 스크롤(CdkVirtualScrollViewport), Angular 22의 기본 ChangeDetectionStrategy.OnPush 원리, afterNextRender 성능 측정, 그리고 DestroyRef 메모리 누수 방어를 실습합니다.