Axios 인터셉터로 JWT 토큰 인증 자동화하기
로그인할 때마다 토큰을 직접 붙이는 반복 작업, Axios 인터셉터로 한 번에 해결하는 방법을 정리합니다.
Axios 인터셉터로 JWT 토큰 인증 자동화하기
로그인할 때마다 토큰을 직접 붙이는 반복 작업, Axios 인터셉터로 한 번에 해결하는 방법을 정리합니다.
1. JWT 토큰이란?
로그인하면 서버가 두 가지 토큰을 발급합니다.
| 토큰 | 역할 | 유효기간 |
|---|---|---|
| Access Token | API 요청 시 신원 증명 | 짧음 (보통 30분~1시간) |
| Refresh Token | Access 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 포함 여부 체크 |
method | GET / POST 등 | 재시도 시 동일 메서드 유지 |
headers | 요청 헤더 전체 | 새 토큰으로 Authorization 교체 |
data | 요청 body | POST 재시도 시 동일 데이터 유지 |
params | 쿼리스트링 | 재시도 시 동일 파라미터 유지 |
_retry | 재시도 여부 플래그 | 무한루프 방지용 (개발자 추가) |
_retry는 Axios 기본 속성이 아니라 개발자가 직접 추가한 커스텀 속성입니다.
_접두사는 "내부에서만 쓰는 임시 속성"임을 나타내는 컨벤션입니다.
7-3. 401 vs 403 차이
// 403 → 로그만 찍고 끝 (재시도 의미 없음)
if (error.response?.status === 403) {
logger.error('403 Forbidden - 권한 없음');
}
| 401 Unauthorized | 403 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 만료 시 강제 로그아웃 처리
- 무한루프 방지