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

## **시작하며: 편리함 뒤에 숨은 복잡성**

모든 API 요청에 액세스 토큰을 자동으로 주입하고, 토큰이 만료되면 알아서 갱신 후 재요청까지 해주는 로직. 저 역시 이 기능을 구현하여 사용자 경험을 향상시키고자 했습니다.

하지만 이 편리함을 구현하는 과정은 간단하지 않았습니다. 특히 **"이 토큰 갱신 코드를 어디에 위치시키고 어떻게 관리할 것인가?"** 라는 근본적인 설계 문제와 마주했고, 이는 결국 **"API 무한 재요청"** 이라는 치명적인 버그로 이어졌습니다. 이 글은 그 문제를 해결하며 얻은 경험에 대한 기록입니다.

## **문제의 발단: 재사용성과 관심사의 분리**

토큰 갱신 로직을 구현할 때, 가장 먼저 고민한 것은 "코드의 위치"였습니다.

* **선택지 1: 각 컴포넌트에서 개별 처리?** API를 호출하는 모든 컴포넌트나 커스텀 훅에서 `try-catch`로 401 에러를 잡고, 직접 갱신 함수를 호출하는 방식입니다. 다만 수십, 수백 개의 API 호출 지점에서 코드가 중복되고, 로직 변경 시 모든 파일을 수정해야 하는 유지보수 지옥이 펼쳐질 가능성이 높아 보였습니다.
    
* **선택지 2: API 헬퍼에서 중앙 처리?** 모든 API 통신이 거쳐 가는 `api.helper.ts`에 로직을 집중시키는 것이 정답이라고 생각했습니다. `ky` 라이브러리의 `hooks`를 사용하면, 모든 요청과 응답을 한 곳에서 가로챌 수 있어 '관심사의 분리' 원칙에도 부합했습니다.
    

저는 2번을 선택했고, `afterResponse` 훅을 이용해 401 에러 시 토큰을 갱신하고 원래 요청을 재시도하는 코드를 작성했습니다.

## **치명적인 실수: 무한 재요청의 덫**

중앙 처리 방식은 우아해 보였지만, 저는 한 가지 간과한 사실이 있었습니다. 바로 **"토큰을 갱신하는 API 요청(**`authService.refresh()`**) 또한 내가 만든 중앙 처리 로직을 통과한다"** 는 점이었습니다.

이로 인해 다음과 같은 무한 루프 시나리오가 발생했습니다.

1. **Request A**: 일반 API를 호출했으나, 액세스 토큰이 만료되어 `401 Unauthorized` 응답을 받습니다.
    
2. `afterResponse` 훅 발동: 401 에러를 감지하고, 토큰을 갱신하기 위해 `authService.refresh()`를 호출합니다.
    
3. **Request B**: `authService.refresh()`가 토큰 갱신 API(`PUT /api/auth`)를 호출합니다. **하지만 사용자의 리프레시 토큰마저 만료된 상태였습니다.**
    
4. `afterResponse` 훅 또 발동: `PUT /api/auth` 요청 또한 `401` 응답을 받습니다. 이 응답 역시 중앙 처리 로직에 의해 감지되고, **또다시 토큰을 갱신하기 위해** `authService.refresh()`를 호출합니다.
    
5. **무한 루프**: 3번과 4번 과정이 무한히 반복되며, 브라우저의 네트워크 탭은 순식간에 수많은 실패 요청으로 가득 찼습니다.
    

중앙 처리 로직이 자기 자신을 처리하려 들면서 생긴 문제였습니다. 이 문제를 해결할 '탈출구'가 필요했습니다.

## **해결의 실마리: 독립적인 통신 채널**

해결책은 의외로 간단했습니다. 토큰 갱신을 위한 API 요청은 **"어떠한 훅도 거치지 않는 순수한 통신 채널"** 로 보내야 한다는 것이었습니다.

```typescript
// 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`를 사용하도록 수정했습니다.

```typescript
// src/services/auth.service.ts

const refresh = async () => {
  // refreshApi를 사용함으로써 무한 루프의 고리를 끊음
  const accessToken = await refreshApi.put(...).text();
  return accessToken;
};
```

이제 토큰 갱신 요청은 `afterResponse` 훅의 영향을 받지 않으므로, 갱신이 실패하면 그냥 에러를 반환하고 무한 루프 없이 깔끔하게 상황이 종료됩니다.

## **이번 프로젝트를 통해 얻은 진짜 교훈**

* **중앙화된 '마법'은 '탈출구'를 필요로 한다**: 인터셉터나 훅처럼 보이지 않는 곳에서 동작하는 로직은 매우 편리하지만, 그 '마법'이 자기 자신에게도 적용될 때의 부작용을 반드시 고려해야 합니다. 모든 규칙에는 예외가 필요하듯, 중앙 처리 로직에는 그 로직을 우회할 수 있는 독립적인 통로를 마련해두는 설계가 중요합니다.
    
* **문제의 원인은 아키텍처에 있다**: "API 요청이 무한히 나간다"는 현상만 보면 당황하기 쉽습니다. 하지만 근본 원인은 코드 한 줄이 아니라, "모든 통신은 단일 인스턴스를 통한다"고 설정한 아키텍처의 허점에 있었습니다. 버그를 잡을 때는 현상 너머의 구조를 보는 시각이 필요하다는 것을 절실히 느꼈습니다.
    

## **마치며**

'토큰 자동 갱신'이라는 비교적 흔한 기능을 구현하면서, 저는 아키텍처 설계의 중요성과 부작용에 대해 경험하며 배울 수 있었습니다. 단순히 기능을 완성하는 것을 넘어, 발생할 수 있는 모든 엣지 케이스를 고려하고 시스템 전체의 안정성을 확보하는 것이 얼마나 중요한지 체감할 수 있었습니다.
