앵귤러 실전 강좌 #2 인증과 HTTP Interceptor

8 분 소요

#1 프로젝트 골격에서 라우팅과 레이아웃, 빈 대시보드 화면까지 다져뒀습니다. 그런데 로그인이 없는 대시보드는 사실상 정적 페이지에 불과합니다. 이번 글에서는 그 위에 인증 흐름을 얹어보겠습니다 — 로그인 화면, 토큰 저장, 라우트 보호, 그리고 모든 요청에 토큰을 자동으로 붙여주는 HTTP Interceptor까지 정리합니다.

백엔드 가정 #

이번 글의 코드는 다음 두 엔드포인트가 있다고 가정합니다.

백엔드 API 가정
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)입니다.

src/app/pages/login/login.component.ts
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'로 싱글톤을 만들고 거기에 토큰과 사용자 정보를 시그널로 보관합니다.

src/app/auth/auth.service.ts
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);
  }
}

세 가지 포인트만 짚습니다.

  1. _tokenlocalStorage에서 앱 시작 시 한 번 복원합니다. 새로고침해도 로그인 상태가 유지되는 이유가 이것입니다.
  2. 토큰을 저장하는 길은 setToken() 하나뿐입니다. 시그널과 localStorage를 항상 함께 갱신하기 위한 작은 가드레일입니다. 외부에서 시그널을 직접 만지지 못하도록 _token은 private, 외부에는 asReadonly()만 노출했습니다.
  3. isLoggedIncomputed로 파생합니다. 토큰이 바뀌면 자동으로 업데이트되고, 가드와 헤더 컴포넌트가 같은 진실 한 가지를 보게 됩니다.
노트
실무에서는 토큰을 localStorage에 두는 것이 XSS에 취약하다는 지적이 자주 나옵니다. 더 안전한 선택지는 백엔드에서 HttpOnly; Secure; SameSite=Strict 쿠키로 내려주는 방식이고, 이때는 클라이언트가 토큰을 직접 만질 일조차 없습니다. 다만 SPA + 별도 도메인 백엔드 + 모바일 앱 공유 같은 시나리오에서는 헤더 + localStorage가 여전히 흔하니, 둘의 트레이드오프를 인지하고 선택하시면 됩니다. 이 글은 가장 흔한 헤더 방식을 따라갑니다.

로그인 흐름 — 한 줄로 다시 보기 #

지금까지의 조각을 모아 흐름을 정리하면 — /login 폼 제출 → AuthService.login() → POST 성공 시 tap이 토큰을 시그널 + localStorage에 저장 → 이어 fetchMe()가 사용자 정보 채움 → 컴포넌트는 returnUrl(또는 기본 /dashboard)로 이동. 컴포넌트는 토큰을 직접 만지지 않습니다. 저장,복원의 책임은 모두 AuthService 안에 있고, 컴포넌트는 그저 호출자입니다.

Auth Guard — 보호된 라우트 #

토큰이 없으면 /dashboard로 들어오지 못하게 막아야 합니다. 중급 #6 Guards에서 다룬 함수형 가드 패턴을 그대로 가져옵니다.

src/app/auth/auth.guard.ts
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 },
  });
};

라우트에는 이렇게 답니다.

src/app/app.routes.ts
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가 정확히 이 부분에서 빛납니다.

src/app/auth/auth.interceptor.ts
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 한 줄.

src/app/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가 응답까지도 가로챌 수 있으니, 그 시점에 일괄 처리합시다.

src/app/auth/auth.interceptor.ts (401 처리 추가)
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)**를 한 곳에 모은다는 점입니다.

401을 모든 요청에 대해 일괄 로그아웃시키는 게 항상 정답은 아닙니다. 예를 들어 비밀번호 변경 폼에서 “현재 비밀번호 틀림"을 401로 돌려주는 백엔드라면, 그 호출은 로그아웃 대상이 아니어야 합니다. 보통은 백엔드 쪽에서 인증 실패는 401, 비즈니스 검증 실패는 422나 400으로 약속을 가져가거나, Interceptor에서 특정 URL을 화이트리스트로 빼는 방식으로 처리합니다.

사용자 정보 표시 — 헤더의 이름과 로그아웃 #

앞에서 AuthService.user()에 사용자 정보를 채워뒀으니, 헤더 컴포넌트는 그대로 가져다 쓰면 됩니다.

src/app/layout/header.component.ts
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) 한 줄로 — 사용자 정보가 채워졌을 때만 이름과 로그아웃 버튼이 나타납니다. 시그널이 바뀌면 템플릿이 알아서 갱신되니, subscribeOnInit도 필요 없습니다.

다만 새로고침 직후에는 토큰은 복원됐지만 user는 아직 비어 있는 한순간이 있습니다. 보통은 AppComponent에서 앱 부팅 시 한 번 fetchMe()를 호출해줍니다.

src/app/app.component.ts (부팅 시 사용자 복원)
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)까지 정리합니다. 인증된 요청이 자동으로 통과되는 환경에서 도메인 로직에 집중하는 단계가 될 것입니다.

X