프론트React
8

Axios 인터셉터로 JWT 토큰 인증 자동화하기

로그인할 때마다 토큰을 직접 붙이는 반복 작업, Axios 인터셉터로 한 번에 해결하는 방법을 정리합니다.

Axios 인터셉터로 JWT 토큰 인증 자동화하기

Axios 인터셉터로 JWT 토큰 인증 자동화하기

로그인할 때마다 토큰을 직접 붙이는 반복 작업, Axios 인터셉터로 한 번에 해결하는 방법을 정리합니다.


1. JWT 토큰이란?

로그인하면 서버가 두 가지 토큰을 발급합니다.

토큰역할유효기간
Access TokenAPI 요청 시 신원 증명짧음 (보통 30분~1시간)
Refresh TokenAccess Token 만료 시 재발급 요청김 (보통 7~30일)

Access Token을 짧게 유지하는 이유는 보안 때문입니다.
토큰이 탈취되더라도 짧은 시간 안에 만료되도록 설계합니다.


2. JWT 토큰 구조

JWT(JSON Web Token)는 .으로 구분된 3개의 파트로 구성됩니다.

eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjEsImV4cCI6MTcwMDAwMH0.abc123xyz
         ↑                          ↑                      ↑
       Header                    Payload               Signature
   (알고리즘 정보)             (실제 데이터)             (서명)

2-1. 각 파트 의미

Header — 이 토큰이 어떤 방식으로 서명됐는지

{
  "alg": "HS256",  // 서명 알고리즘 (HMAC SHA-256)
  "typ": "JWT"     // 토큰 타입
}

Payload — 토큰 안에 담긴 실제 데이터

{
  "userId": 1,
  "email": "user@example.com",
  "role": "user",
  "iat": 1700000000,   // 발급 시각 (issued at)
  "exp": 1700003600    // 만료 시각 (expiration)
}

Signature — 토큰이 변조되지 않았음을 증명하는 서명

HMAC_SHA256(
  Base64URL(Header) + "." + Base64URL(Payload),
  서버만 아는 SecretKey
)

2-2. Base64URL 인코딩이란?

Header와 Payload는 Base64URL 방식으로 인코딩됩니다.

{ "userId": 1, "exp": 1700000000 }
              ↓ Base64URL 인코딩
eyJ1c2VySWQiOjEsImV4cCI6MTcwMDAwMDAwMH0

중요: Base64는 암호화가 아닙니다.
누구나 디코딩해서 내용을 볼 수 있습니다.
그래서 Payload에 비밀번호 같은 민감한 정보는 절대 넣으면 안 됩니다.

2-3. 서버 인증 처리 — 어떻게 검증하나

클라이언트가 API 요청 시 토큰을 보내면 서버는 아래 순서로 검증합니다.

클라이언트 요청
Authorization: Bearer eyJhbGci...
        ↓
① 토큰을 Header / Payload / Signature 로 분리

② Signature 검증
   서버가 직접 Header + Payload로 서명을 다시 만들어봄
   → 내가 만든 서명 == 받은 Signature ? 통과 : 변조된 토큰 (401)

③ 만료 시각 확인
   Payload의 exp > 현재 시각 ? 통과 : 만료된 토큰 (401)

④ 검증 통과 → 요청 정상 처리

Signature 검증이 핵심인 이유

해커가 Payload를 바꿔서 { "role": "admin" } 으로 변조 시도
  ↓
Signature를 새로 만들려면 서버의 SecretKey가 필요
  ↓
SecretKey는 서버만 알고 있음
  ↓
Signature 불일치 → 변조 감지 → 요청 거부

이것이 JWT가 별도 DB 조회 없이 서버 혼자 검증 가능한 이유입니다.


3. 전체 인증 흐름

로그인
  ├─ ID/PW 불일치 → 로그인 실패처리 → 종료
  └─ 일치 → Access Token + Refresh Token 발급
              └─ API 요청
                   ├─ Access Token 유효 → 정상처리
                   └─ Access Token 만료(401)
                        ├─ Refresh Token 유효 → 새 Access Token 발급 → 요청 재시도
                        └─ Refresh Token 만료 → 강제 로그아웃 → 로그인 페이지

4. Axios 인터셉터란?

모든 HTTP 요청/응답이 내 코드에 도달하기 전에 중간에서 가로채는 검문소

[요청 인터셉터]          [응답 인터셉터]
내 코드                  서버 응답
  ↓                         ↓
가로챔 (토큰 자동 삽입)   가로챔 (401 처리, 토큰 갱신)
  ↓                         ↓
서버로 전송              내 코드 (then/catch)

인터셉터 덕분에 모든 API 요청 파일마다 토큰을 붙이는 반복 코드를 없앨 수 있습니다.


5. 코드 전체 구조

import axios from "axios";
import { config, logger } from "@/lib/config";

const API_URL = config.apiUrl;

const api = axios.create({
  baseURL: API_URL,
  timeout: 10000,
  headers: {
    'Content-Type': 'application/json',
  },
})

axios.create()로 기본 설정이 담긴 커스텀 axios 인스턴스를 만듭니다.
이후 모든 요청은 api.get(), api.post() 형태로 사용합니다.


6. 요청 인터셉터 — 토큰 자동 삽입

api.interceptors.request.use(
  (config) => {
    // localStorage에서 Access Token 꺼내기
    const token = typeof window !== 'undefined'
      ? localStorage.getItem('accessToken')
      : null;

    // 토큰이 있으면 Authorization 헤더에 자동으로 붙이기
    if (token) {
      config.headers['Authorization'] = `Bearer ${token}`;
    }

    return config;
  },
  (error) => Promise.reject(error)
);

핵심 포인트

  • typeof window !== 'undefined' : Next.js 같은 SSR 환경에서 서버 측 실행 시 window가 없어 에러나는 것을 방지
  • Bearer ${token} : HTTP 인증 표준 형식. 서버는 이 헤더를 보고 신원을 확인

7. 응답 인터셉터 — 401 시 토큰 자동 갱신

7-1. 기본 구조

api.interceptors.response.use(
  (response) => response,        // 성공(200번대) → 그냥 통과
  async (error) => {             // 실패(4xx, 5xx) → 처리 로직 실행
    const originalRequest = error.config; // 실패한 요청 원본 저장
    ...
  }
);
  • error.config : Axios가 자동으로 채워주는 실패한 요청의 원본 정보 (URL, headers, method 등)
  • originalRequest 에 저장해두는 이유 → 토큰 갱신 후 원본 요청을 그대로 재시도하기 위해

7-2. error.config 안에 뭐가 들어있나?

개발자가 직접 저장하는 게 아니라 Axios가 요청 실패 시 자동으로 채워주는 값들입니다.

// error.config 실제 내용 예시
{
  url: '/api/user/profile',         // 요청했던 URL
  method: 'get',                    // 요청 방식 (get, post, put, delete...)
  baseURL: 'https://api.example.com', // axios.create()에서 설정한 기본 URL
  headers: {
    'Content-Type': 'application/json',
    'Authorization': 'Bearer 만료된토큰'  // 실패 당시 토큰
  },
  data: '{"name":"홍길동"}',         // 요청 시 보냈던 body 데이터 (POST/PUT)
  params: { page: 1, size: 10 },    // URL 쿼리스트링 (?page=1&size=10)
  timeout: 10000,                   // 타임아웃 설정값
  _retry: true,                     // 개발자가 직접 추가한 재시도 플래그 (원래 없음)
}
속성의미재시도 시 활용
url실패한 요청 경로/login 포함 여부 체크
methodGET / POST 등재시도 시 동일 메서드 유지
headers요청 헤더 전체새 토큰으로 Authorization 교체
data요청 bodyPOST 재시도 시 동일 데이터 유지
params쿼리스트링재시도 시 동일 파라미터 유지
_retry재시도 여부 플래그무한루프 방지용 (개발자 추가)

_retry는 Axios 기본 속성이 아니라 개발자가 직접 추가한 커스텀 속성입니다.
_ 접두사는 "내부에서만 쓰는 임시 속성"임을 나타내는 컨벤션입니다.


7-3. 401 vs 403 차이

// 403 → 로그만 찍고 끝 (재시도 의미 없음)
if (error.response?.status === 403) {
  logger.error('403 Forbidden - 권한 없음');
}
401 Unauthorized403 Forbidden
의미신원 확인 안 됨 (토큰 없음/만료)신원은 확인됐지만 권한 없음
예시로그인 안 한 사용자일반 유저가 관리자 페이지 접근
해결토큰 갱신 후 재시도 가능재시도해도 소용없음

7-4. 무한루프 방지 — _retry 플래그

if (error.response?.status === 401 && !originalRequest._retry) {
  originalRequest._retry = true; // 재시도 플래그 설정
  ...
}

_retry가 없으면 아래와 같은 무한루프가 발생합니다.

❌ _retry 없으면
401 실패 → 토큰 갱신 → 재시도 → 또 401 → 또 갱신 → 무한루프 💀

✅ _retry 있으면
401 실패 → _retry = true → 토큰 갱신 → 재시도 → 또 401
         → !_retry 가 false → 갱신 안 함 → 로그아웃 처리 🛑

_retry 앞의 _는 "내부에서만 쓰는 임시 속성"임을 나타내는 컨벤션입니다.


7-5. /login 요청 예외 처리

if (originalRequest.url?.includes('/login')) {
  return Promise.reject(error);
}

/login, /login/refresh 요청 자체가 401을 반환하면 토큰 갱신을 시도하지 않고 즉시 에러를 반환합니다.

❌ 예외처리 없으면
/login → 401 → /login/refresh 호출 → 401 → 또 호출 → 무한루프 💀

✅ 예외처리 있으면
/login → 401 → includes('/login') 감지 → 즉시 에러 반환 🛑

7-6. 동시 요청 대기열 처리

API 요청이 동시에 여러 개 실패했을 때, 토큰 갱신을 한 번만 하고 나머지는 대기시킵니다.

let isRefreshing = false;  // 갱신 요청 중복 방지 플래그
let failedQueue = [];      // 갱신 대기 중인 요청들

const processQueue = (error, token = null) => {
  failedQueue.forEach((prom) => {
    if (error) {
      prom.reject(error);
    } else {
      prom.resolve(token); // 갱신 완료 → 대기 중인 요청 모두 재시도
    }
  });
  failedQueue = [];
};
요청 A, B, C 동시 401 실패
  ↓
A → isRefreshing = true, 토큰 갱신 시작
B, C → isRefreshing이 true라서 failedQueue에 대기
  ↓
토큰 갱신 완료
  ↓
processQueue → B, C 모두 새 토큰으로 재시도

A, B, C 각각의 상세 흐름

A (제일 먼저 진입)

if (error.response?.status === 401 && !originalRequest._retry) {
  // ✅ 401 맞음, _retry 없음 → 진입

  if (isRefreshing) { }
  // ❌ isRefreshing = false → 이 블록 스킵

  originalRequest._retry = true;
  isRefreshing = true; // ← B, C를 위해 true로 변경

  // await를 만나는 순간 A는 여기서 멈춤
  // 응답을 기다리는 동안 B, C가 인터셉터로 진입할 수 있게 됨
  const { data } = await axios.post('/login/refresh', { refreshToken });

  processQueue(null, newAccessToken); // B, C 재요청
  return api(originalRequest);        // A 재요청
}

B, C (A가 await로 대기 중인 틈에 진입)

if (isRefreshing) {
  // ✅ A가 이미 true로 바꿔놨음 → 진입

  return new Promise((resolve, reject) => {
    failedQueue.push({ resolve, reject }); // 큐에서 대기
  })
  .then((token) => {
    return api(originalRequest); // processQueue가 resolve하면 그때 실행
  })
}

await가 만드는 "대기 틈" 덕분에 B, C가 인터셉터로 진입할 수 있고
isRefreshing 플래그가 이 틈을 막아 갱신을 딱 1번만 실행합니다.


타임라인

A → /login/refresh 요청 전송 → await 대기 중...
         ↓ 이 사이에
         B 진입 → isRefreshing true → 큐 대기
         C 진입 → isRefreshing true → 큐 대기
         ↓ 응답 도착
A → 새 토큰 받음
  → processQueue 실행
      → B resolve() 호출 → B의 .then() 예약
      → C resolve() 호출 → C의 .then() 예약
  → return api(originalRequest) → A 재요청 전송 (동기, 즉시)
  → B의 .then() 실행 → B 재요청 전송
  → C의 .then() 실행 → C 재요청 전송

최종 재요청 순서: A → B → C

processQueue가 먼저 호출되지만 B, C의 실제 재요청은 .then() 안에 있어
동기 코드인 return api(originalRequest)(A 재요청)가 먼저 실행됩니다.


주요 포인트

  • api.defaults.headers.common : 이후 모든 요청에 새 토큰이 자동으로 적용
  • finally : 성공/실패 상관없이 isRefreshing = false 로 초기화

9. 마치며

이 패턴을 적용하면 아래 문제들이 한 번에 해결됩니다.

  • 모든 API 요청에 토큰 자동 삽입
  • Access Token 만료 시 자동 갱신 및 재시도
  • 동시 다발적 401 요청 시 토큰 갱신 단 1회만 실행
  • Refresh Token 만료 시 강제 로그아웃 처리
  • 무한루프 방지

댓글

(0)
Axios 인터셉터로 JWT 토큰 인증 자동화하기 | 강민석의 개발블로그