토큰 갱신 로직, 어디에 둬야 할까?


시작하며: 편리함 뒤에 숨은 복잡성
모든 API 요청에 액세스 토큰을 자동으로 주입하고, 토큰이 만료되면 알아서 갱신 후 재요청까지 해주는 로직. 저 역시 이 기능을 구현하여 사용자 경험을 향상시키고자 했습니다.
하지만 이 편리함을 구현하는 과정은 간단하지 않았습니다. 특히 "이 토큰 갱신 코드를 어디에 위치시키고 어떻게 관리할 것인가?" 라는 근본적인 설계 문제와 마주했고, 이는 결국 "API 무한 재요청" 이라는 치명적인 버그로 이어졌습니다. 이 글은 그 문제를 해결하며 얻은 경험에 대한 기록입니다.
문제의 발단: 재사용성과 관심사의 분리
토큰 갱신 로직을 구현할 때, 가장 먼저 고민한 것은 "코드의 위치"였습니다.
선택지 1: 각 컴포넌트에서 개별 처리? API를 호출하는 모든 컴포넌트나 커스텀 훅에서
try-catch
로 401 에러를 잡고, 직접 갱신 함수를 호출하는 방식입니다. 다만 수십, 수백 개의 API 호출 지점에서 코드가 중복되고, 로직 변경 시 모든 파일을 수정해야 하는 유지보수 지옥이 펼쳐질 가능성이 높아 보였습니다.선택지 2: API 헬퍼에서 중앙 처리? 모든 API 통신이 거쳐 가는
api.helper.ts
에 로직을 집중시키는 것이 정답이라고 생각했습니다.ky
라이브러리의hooks
를 사용하면, 모든 요청과 응답을 한 곳에서 가로챌 수 있어 '관심사의 분리' 원칙에도 부합했습니다.
저는 2번을 선택했고, afterResponse
훅을 이용해 401 에러 시 토큰을 갱신하고 원래 요청을 재시도하는 코드를 작성했습니다.
치명적인 실수: 무한 재요청의 덫
중앙 처리 방식은 우아해 보였지만, 저는 한 가지 간과한 사실이 있었습니다. 바로 "토큰을 갱신하는 API 요청(authService.refresh()
) 또한 내가 만든 중앙 처리 로직을 통과한다" 는 점이었습니다.
이로 인해 다음과 같은 무한 루프 시나리오가 발생했습니다.
Request A: 일반 API를 호출했으나, 액세스 토큰이 만료되어
401 Unauthorized
응답을 받습니다.afterResponse
훅 발동: 401 에러를 감지하고, 토큰을 갱신하기 위해authService.refresh()
를 호출합니다.Request B:
authService.refresh()
가 토큰 갱신 API(PUT /api/auth
)를 호출합니다. 하지만 사용자의 리프레시 토큰마저 만료된 상태였습니다.afterResponse
훅 또 발동:PUT /api/auth
요청 또한401
응답을 받습니다. 이 응답 역시 중앙 처리 로직에 의해 감지되고, 또다시 토큰을 갱신하기 위해authService.refresh()
를 호출합니다.무한 루프: 3번과 4번 과정이 무한히 반복되며, 브라우저의 네트워크 탭은 순식간에 수많은 실패 요청으로 가득 찼습니다.
중앙 처리 로직이 자기 자신을 처리하려 들면서 생긴 문제였습니다. 이 문제를 해결할 '탈출구'가 필요했습니다.
해결의 실마리: 독립적인 통신 채널
해결책은 의외로 간단했습니다. 토큰 갱신을 위한 API 요청은 "어떠한 훅도 거치지 않는 순수한 통신 채널" 로 보내야 한다는 것이었습니다.
// src/lib/api.helper.ts
// 1. 모든 요청을 가로채는 메인 API 인스턴스
export const api = ky.create({
hooks: {
// 여기에 afterResponse 등 토큰 갱신 로직이 들어감
}
});
// 2. 토큰 갱신만을 위한 '순수한' API 인스턴스
export const refreshApi = ky.create({
// 훅 없음
});
api.helper.ts
에 훅이 없는 새로운 ky
인스턴스(refreshApi
)를 만들었습니다. 그리고 authService.refresh()
함수는 이제 api
가 아닌 refreshApi
를 사용하도록 수정했습니다.
// src/services/auth.service.ts
const refresh = async () => {
// refreshApi를 사용함으로써 무한 루프의 고리를 끊음
const accessToken = await refreshApi.put(...).text();
return accessToken;
};
이제 토큰 갱신 요청은 afterResponse
훅의 영향을 받지 않으므로, 갱신이 실패하면 그냥 에러를 반환하고 무한 루프 없이 깔끔하게 상황이 종료됩니다.
이번 프로젝트를 통해 얻은 진짜 교훈
중앙화된 '마법'은 '탈출구'를 필요로 한다: 인터셉터나 훅처럼 보이지 않는 곳에서 동작하는 로직은 매우 편리하지만, 그 '마법'이 자기 자신에게도 적용될 때의 부작용을 반드시 고려해야 합니다. 모든 규칙에는 예외가 필요하듯, 중앙 처리 로직에는 그 로직을 우회할 수 있는 독립적인 통로를 마련해두는 설계가 중요합니다.
문제의 원인은 아키텍처에 있다: "API 요청이 무한히 나간다"는 현상만 보면 당황하기 쉽습니다. 하지만 근본 원인은 코드 한 줄이 아니라, "모든 통신은 단일 인스턴스를 통한다"고 설정한 아키텍처의 허점에 있었습니다. 버그를 잡을 때는 현상 너머의 구조를 보는 시각이 필요하다는 것을 절실히 느꼈습니다.
마치며
'토큰 자동 갱신'이라는 비교적 흔한 기능을 구현하면서, 저는 아키텍처 설계의 중요성과 부작용에 대해 경험하며 배울 수 있었습니다. 단순히 기능을 완성하는 것을 넘어, 발생할 수 있는 모든 엣지 케이스를 고려하고 시스템 전체의 안정성을 확보하는 것이 얼마나 중요한지 체감할 수 있었습니다.
Subscribe to my newsletter
Read articles from Ted Lee directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Ted Lee
Ted Lee
Software engineer for web tech. Interested in sustainable growth as software engineer.