앵귤러 실전 강좌 #2 인증과 HTTP Interceptor
#1 프로젝트 골격에서 라우팅과 레이아웃, 빈 대시보드 화면까지 다져뒀습니다. 그런데 로그인이 없는 대시보드는 사실상 정적 페이지에 불과합니다. 이번 글에서는 그 위에 인증 흐름을 얹어보겠습니다 — 로그인 화면, 토큰 저장, 라우트 보호, 그리고 모든 요청에 토큰을 자동으로 붙여주는 HTTP Interceptor까지 정리합니다.
백엔드 가정 #
이번 글의 코드는 다음 두 엔드포인트가 있다고 가정합니다.
POST /api/auth/login { email, password } → { accessToken: string }
GET /api/auth/me Authorization: Bearer ... → { id, email, name }모던 파이썬 백엔드(FastAPI) 시리즈로 만든 백엔드든 Django REST Framework로 만든 것이든, 모양만 같다면 그대로 붙습니다. JWT(Access Token)를 헤더에 실어 보내는 흔한 패턴입니다. Refresh Token은 마지막에 한 단락만 짧게 다룹니다. environment.apiBase는 #1에서 이미 설정해뒀다고 가정합니다.
Login 화면 — Reactive Forms #
먼저 로그인 컴포넌트를 만듭니다. 중급 #1 Reactive Forms에서 다룬 패턴 그대로, FormGroup 두 필드 + 검증 + (ngSubmit)입니다.
import { Component, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { Router, ActivatedRoute } from '@angular/router';
import { AuthService } from '../../auth/auth.service';
@Component({
selector: 'app-login',
standalone: true,
imports: [ReactiveFormsModule],
templateUrl: './login.component.html',
})
export class LoginComponent {
private fb = inject(FormBuilder);
private auth = inject(AuthService);
private router = inject(Router);
private route = inject(ActivatedRoute);
protected readonly loading = signal(false);
protected readonly errorMsg = signal<string | null>(null);
protected readonly form = this.fb.nonNullable.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
});
submit() {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
this.loading.set(true);
this.errorMsg.set(null);
const { email, password } = this.form.getRawValue();
this.auth.login(email, password).subscribe({
next: () => {
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl') ?? '/dashboard';
this.router.navigateByUrl(returnUrl);
},
error: (err) => {
this.errorMsg.set(err?.error?.message ?? '로그인에 실패했습니다.');
this.loading.set(false);
},
});
}
}템플릿은 평범합니다 — 두 입력, 검증 메시지, 서버 에러, submit 버튼 정도. 핵심은 이 컴포넌트가 자기 책임을 정확히 알고 있다는 점입니다. 입력을 받아 AuthService.login()을 호출하고, 성공하면 라우터로 다음 화면으로 보낸다. 토큰을 저장하거나 헤더를 만지는 일은 하지 않습니다. 그건 다음 단계의 책임입니다.
AuthService — 토큰을 들고 있는 단일 위치 #
인증 상태는 앱 전체에서 한 군데로 모입니다. 기초 #5 Service와 DI 패턴 그대로, providedIn: 'root'로 싱글톤을 만들고 거기에 토큰과 사용자 정보를 시그널로 보관합니다.
import { Injectable, computed, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, tap } from 'rxjs';
import { environment } from '../../environments/environment';
export interface User {
id: number;
email: string;
name: string;
}
const TOKEN_KEY = 'auth_token';
@Injectable({ providedIn: 'root' })
export class AuthService {
private http = inject(HttpClient);
// 앱 시작 시 localStorage에서 토큰 복원
private readonly _token = signal<string | null>(localStorage.getItem(TOKEN_KEY));
private readonly _user = signal<User | null>(null);
readonly token = this._token.asReadonly();
readonly user = this._user.asReadonly();
readonly isLoggedIn = computed(() => this._token() !== null);
login(email: string, password: string): Observable<{ accessToken: string }> {
return this.http
.post<{ accessToken: string }>(`${environment.apiBase}/auth/login`, { email, password })
.pipe(
tap((res) => this.setToken(res.accessToken)),
tap(() => this.fetchMe().subscribe()),
);
}
fetchMe(): Observable<User> {
return this.http
.get<User>(`${environment.apiBase}/auth/me`)
.pipe(tap((user) => this._user.set(user)));
}
logout(): void {
this._token.set(null);
this._user.set(null);
localStorage.removeItem(TOKEN_KEY);
}
private setToken(token: string): void {
this._token.set(token);
localStorage.setItem(TOKEN_KEY, token);
}
}세 가지 포인트만 짚습니다.
_token은localStorage에서 앱 시작 시 한 번 복원합니다. 새로고침해도 로그인 상태가 유지되는 이유가 이것입니다.- 토큰을 저장하는 길은
setToken()하나뿐입니다. 시그널과 localStorage를 항상 함께 갱신하기 위한 작은 가드레일입니다. 외부에서 시그널을 직접 만지지 못하도록_token은 private, 외부에는asReadonly()만 노출했습니다. isLoggedIn은computed로 파생합니다. 토큰이 바뀌면 자동으로 업데이트되고, 가드와 헤더 컴포넌트가 같은 진실 한 가지를 보게 됩니다.
localStorage에 두는 것이 XSS에 취약하다는 지적이 자주 나옵니다. 더 안전한 선택지는 백엔드에서 HttpOnly; Secure; SameSite=Strict 쿠키로 내려주는 방식이고, 이때는 클라이언트가 토큰을 직접 만질 일조차 없습니다. 다만 SPA + 별도 도메인 백엔드 + 모바일 앱 공유 같은 시나리오에서는 헤더 + localStorage가 여전히 흔하니, 둘의 트레이드오프를 인지하고 선택하시면 됩니다. 이 글은 가장 흔한 헤더 방식을 따라갑니다.로그인 흐름 — 한 줄로 다시 보기 #
지금까지의 조각을 모아 흐름을 정리하면 — /login 폼 제출 → AuthService.login() → POST 성공 시 tap이 토큰을 시그널 + localStorage에 저장 → 이어 fetchMe()가 사용자 정보 채움 → 컴포넌트는 returnUrl(또는 기본 /dashboard)로 이동. 컴포넌트는 토큰을 직접 만지지 않습니다. 저장,복원의 책임은 모두 AuthService 안에 있고, 컴포넌트는 그저 호출자입니다.
Auth Guard — 보호된 라우트 #
토큰이 없으면 /dashboard로 들어오지 못하게 막아야 합니다. 중급 #6 Guards에서 다룬 함수형 가드 패턴을 그대로 가져옵니다.
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from './auth.service';
export const authGuard: CanActivateFn = (_route, state) => {
const auth = inject(AuthService);
const router = inject(Router);
if (auth.isLoggedIn()) {
return true;
}
// 원래 가려던 URL을 returnUrl로 보존
return router.createUrlTree(['/login'], {
queryParams: { returnUrl: state.url },
});
};라우트에는 이렇게 답니다.
import { Routes } from '@angular/router';
import { authGuard } from './auth/auth.guard';
export const routes: Routes = [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{
path: 'login',
loadComponent: () => import('./pages/login/login.component').then(m => m.LoginComponent),
},
{
path: 'dashboard',
canActivate: [authGuard],
loadComponent: () => import('./pages/dashboard/dashboard.component').then(m => m.DashboardComponent),
},
{ path: '**', redirectTo: 'dashboard' },
];미인증 상태에서 /dashboard/orders로 들어오면 가드가 /login?returnUrl=%2Fdashboard%2Forders로 보내고, 로그인 후에는 LoginComponent가 그 returnUrl을 읽어 정확히 그 위치로 돌려보냅니다. 사용자 입장에서는 **“어, 다시 그 화면으로 왔네”**가 자연스럽습니다.
HTTP Interceptor — 헤더 자동 첨부 #
이제 인증된 요청 차례입니다. 매 호출마다 headers: { Authorization: 'Bearer ...' }를 손으로 붙이는 건 답이 아닙니다. 기초 #7에서 한 줄로 소개했던 함수형 Interceptor가 정확히 이 부분에서 빛납니다.
import { inject } from '@angular/core';
import { HttpInterceptorFn } from '@angular/common/http';
import { AuthService } from './auth.service';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const token = inject(AuthService).token();
// 로그인 요청 자체에는 토큰을 붙이지 않음
if (!token || req.url.includes('/auth/login')) {
return next(req);
}
const cloned = req.clone({
setHeaders: { Authorization: `Bearer ${token}` },
});
return next(cloned);
};HttpRequest는 불변(immutable) 이라서 직접 수정하지 않고 req.clone()으로 새 요청을 만든 다음 next()에 흘려보냅니다. 등록은 app.config.ts 한 줄.
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes';
import { authInterceptor } from './auth/auth.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideHttpClient(withInterceptors([authInterceptor])),
],
};이 한 줄로 앱의 모든 HTTP 요청에 토큰이 자동으로 붙습니다. UserService, OrderService, 앞으로 만들 모든 서비스가 이 사실을 의식할 필요 없이 그냥 http.get()만 호출하면 됩니다.
401 자동 처리 — 만료된 토큰 청소 #
토큰은 만료되거나, 백엔드가 무효화시킬 수 있습니다. 그 결과는 보통 401 Unauthorized로 옵니다. Interceptor가 응답까지도 가로챌 수 있으니, 그 시점에 일괄 처리합시다.
import { inject } from '@angular/core';
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { Router } from '@angular/router';
import { catchError, throwError } from 'rxjs';
import { AuthService } from './auth.service';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthService);
const router = inject(Router);
const token = auth.token();
const outgoing =
!token || req.url.includes('/auth/login')
? req
: req.clone({ setHeaders: { Authorization: `Bearer ${token}` } });
return next(outgoing).pipe(
catchError((err: HttpErrorResponse) => {
if (err.status === 401 && !req.url.includes('/auth/login')) {
auth.logout();
router.navigateByUrl('/login');
}
return throwError(() => err);
}),
);
};만료된 토큰으로 어떤 화면을 보고 있다가 API가 401을 떨어뜨리면, 모든 컴포넌트는 그 사실을 모른 채로 깔끔히 로그인 화면으로 돌아갑니다. 401 처리 코드를 매 서비스마다 반복할 필요가 사라집니다. 이게 Interceptor의 본질입니다 — **횡단 관심사(cross-cutting concern)**를 한 곳에 모은다는 점입니다.
사용자 정보 표시 — 헤더의 이름과 로그아웃 #
앞에서 AuthService.user()에 사용자 정보를 채워뒀으니, 헤더 컴포넌트는 그대로 가져다 쓰면 됩니다.
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { AuthService } from '../auth/auth.service';
@Component({
selector: 'app-header',
standalone: true,
template: `
<header class="app-header">
<h1>Dashboard</h1>
@if (auth.user(); as user) {
<div class="user-area">
<span>{{ user.name }}</span>
<button type="button" (click)="logout()">로그아웃</button>
</div>
}
</header>
`,
})
export class HeaderComponent {
protected auth = inject(AuthService);
private router = inject(Router);
logout() {
this.auth.logout();
this.router.navigateByUrl('/login');
}
}@if (auth.user(); as user) 한 줄로 — 사용자 정보가 채워졌을 때만 이름과 로그아웃 버튼이 나타납니다. 시그널이 바뀌면 템플릿이 알아서 갱신되니, subscribe도 OnInit도 필요 없습니다.
다만 새로고침 직후에는 토큰은 복원됐지만 user는 아직 비어 있는 한순간이 있습니다. 보통은 AppComponent에서 앱 부팅 시 한 번 fetchMe()를 호출해줍니다.
export class AppComponent {
private auth = inject(AuthService);
constructor() {
if (this.auth.isLoggedIn()) {
this.auth.fetchMe().subscribe({
error: () => this.auth.logout(), // 토큰이 죽었다면 정리
});
}
}
}토큰만 살아 있고 백엔드에서 무효화된 케이스도 여기서 한 번에 잡힙니다.
Refresh Token — 패턴 소개 #
Access Token의 수명이 짧을수록 보안상 유리하지만, 사용자 입장에서는 자꾸 로그아웃되는 게 불편합니다. 그래서 흔히 Refresh Token 짝을 두고, Access Token이 만료되면 Refresh로 조용히 새 Access를 받아와 진행합니다.
Interceptor 안에서 401을 만났을 때, 곧장 로그아웃 대신 /api/auth/refresh를 한 번 시도하고, 성공하면 원래 실패했던 요청을 새 토큰으로 다시 보내는 패턴이 표준입니다. 동시 다발 요청에 대비해 RxJS의 Subject로 갱신 요청을 한 번만 흘리고 나머지는 결과를 기다리게 하는 디테일이 따라붙습니다. 코드가 꽤 길어져서 이 글에서는 분량상 다루지 않지만, “401을 보면 무조건 로그아웃"이 첫 단계 → “한 번은 갱신을 시도"가 다음 단계 라는 흐름만 머릿속에 담아두시면 됩니다.
마무리 #
이번 글에서는 #1의 빈 골격에 인증 흐름을 채워 넣었습니다.
- Login 화면: Reactive Forms +
AuthService.login()호출자 역할만 - AuthService: 토큰,사용자를 시그널로 들고 있는 단일 위치, localStorage 영속
- Auth Guard: 함수형
CanActivateFn, 미인증이면returnUrl보존 후/login - HTTP Interceptor: 모든 요청에
Bearer헤더 자동 첨부 + 401 자동 로그아웃 - 헤더:
auth.user()시그널을 그대로 바인딩, 새로고침 시fetchMe()로 복원
라우팅,레이아웃,인증까지 갖췄으니, 다음 단계는 진짜 데이터입니다. 다음 글 “앵귤러 실전 강좌 #3 폼과 API CRUD” 에서는 보호된 영역 안에서 자원 하나(예: posts 또는 tasks)를 CRUD하는 흐름을 끝까지 따라가 보겠습니다 — 목록 조회, 상세 + 편집 폼, 생성/수정/삭제, 그리고 낙관적 업데이트(optimistic update)까지 정리합니다. 인증된 요청이 자동으로 통과되는 환경에서 도메인 로직에 집중하는 단계가 될 것입니다.