- Published on
로그인이 자꾸 풀리던 서비스 — 401 처리를 한곳으로 모으기까지
- Authors

- Name
- Hyo814
로그인이 자꾸 풀리던 서비스 — 401 처리를 한곳으로 모으기까지
[TripTune](/blog/웹 기술로 만드는 협업형 여행 계획 플랫폼 TripTune 개발)의 토큰은 access 5분 / refresh 1주입니다. access가 짧으니 갱신은 일상적으로 일어나고, 갱신 처리가 어긋나면 증상이 바로 사용자에게 보입니다. 실제로 초기에 겪은 버그들은 이랬습니다.
- 일정을 편집하다가 갑자기 로그인이 풀림
- 에러 하나에 alert가 두 번 뜸
- 로그아웃이 안 됨, 닉네임이 사라짐
- 갱신 실패 → 재시도 → 또 실패 → … 무한 갱신 루프
증상은 다 다른데, 고치다 보니 뿌리가 같았습니다. 401 처리 로직이 화면마다 복사돼 흩어져 있었고, 화면마다 조금씩 달랐던 것. A 화면은 갱신을 시도하는데 B 화면은 바로 로그인 페이지로 보내고, C 화면은 alert를 띄우는데 공통 코드도 또 띄우는 식이었어요. 같은 계열 버그를 세 번쯤 고치고 나서야 "이건 화면의 버그가 아니라 구조의 버그"라는 걸 인정했습니다.
1. 어디로 모을 것인가
401 처리를 한곳으로 모은다고 할 때 선택지는 세 개였습니다.
| 방법 | 장점 | 포기하는 것 | 판단 |
|---|---|---|---|
| 지금처럼 화면별 try/catch, 대신 컨벤션 문서화 | 코드 변경 없음 | 강제력이 없음 — 새 화면마다 같은 실수 재생산 | 이미 세 번 실패한 방식 |
| axios 도입 + 인터셉터 | 인터셉터 생태계 성숙, 레퍼런스 많음 | 의존성 추가. 이미 fetch 기반 코드 전체 이관 비용 | 인터셉터 하나 쓰자고 HTTP 클라이언트를 갈 이유가 없었음 |
| 커스텀 fetch 래퍼 | 기존 fetch 코드의 자연스러운 수렴점, 의존성 0 | 재시도·에러 분기를 전부 직접 작성 | 채택 — 어차피 [React-Query와 역할을 나누며](/blog/웹 기술로 만드는 협업형 여행 계획 플랫폼 TripTune 개발) 인증 요청은 fetch 직접 통제로 정한 상태였음 |
axios가 유력해 보였지만, 이 프로젝트는 이미 "캐싱이 핵심인 요청은 React-Query, 인증처럼 실시간 통제가 필요한 요청은 fetch"로 나눠 둔 상태였습니다. 인증 처리를 위해 세 번째 도구를 들이면 나눈 의미가 없어져요.
2. 서버 에러를 enum 4종으로 분류
래퍼의 핵심은 "무슨 에러가 왔는가"가 아니라 "그래서 뭘 해야 하는가" 기준으로 에러를 접는 것이었습니다. 서버가 주는 에러 메시지·상태 코드를 행동 4종으로 분류합니다.
// src/apis/api.ts
enum ErrorType {
NEED_LOGIN, // 쿠키 지우고 로그인 페이지로
NEED_BACK, // 접근 불가 리소스 — 뒤로가기
SHOW_MESSAGE, // 사용자에게 메시지만 표시
REFRESH_TOKEN, // refresh로 갱신 후 원 요청 재시도
}
const getErrorType = (message: string, statusCode?: number): ErrorType => {
if (
message === '유효하지 않은 JWT 토큰입니다.' ||
message === '지원되지 않는 JWT 토큰입니다.' ||
message === '로그아웃 된 사용자입니다. 로그인 후 이용해주세요.'
) {
return ErrorType.NEED_LOGIN;
}
if (message === 'JWT 토큰이 만료되었습니다.' || statusCode === 401) {
return ErrorType.REFRESH_TOKEN;
}
// 갱신 실패는 "다시 갱신"이 아니라 로그인 유도로 강등 — 무한 루프 차단
if (message === '토큰 갱신이 불가능합니다. 다시 로그인 후 이용해주세요.') {
return ErrorType.NEED_LOGIN;
}
if (message === '접근 권한이 없습니다.' /* … 권한·부재 계열 문구들 */) {
return ErrorType.NEED_BACK;
}
return ErrorType.SHOW_MESSAGE;
};
무한 갱신 루프는 여기서 두 겹으로 막힙니다.
- 분류 단계 — "토큰 갱신이 불가능합니다"는
REFRESH_TOKEN이 아니라NEED_LOGIN으로 강등. 갱신 실패에 다시 갱신으로 답하지 않습니다. - 재시도 단계 — 래퍼의 재시도는
isRetry플래그로 1회 제한. 갱신 후 재요청이 또 401이면 그대로 실패 처리합니다.
const fetchData = async <T>(method, endpoint, body?, options?, isRetry = false) => {
// …
if (response.status === 401 && !isRetry) {
await refreshApi();
return await fetchData<T>(method, endpoint, body, options, true); // 재시도는 딱 한 번
}
// …
};
화면 쪽 코드에서는 401이라는 개념이 사라졌습니다. get/post/patch/remove를 호출하면 갱신·재시도·리다이렉트가 래퍼 안에서 끝나고, 화면은 성공 데이터 또는 "표시할 메시지"만 받습니다. 중복 alert도 이때 같이 사라졌어요 — alert를 띄울 자격이 있는 곳이 한 곳뿐이니까요.
3. 인증 "상태"는 boolean이 아니라 3-state
요청 쪽을 모으고 나니 남은 축은 화면 쪽 인증 상태였습니다. 헤더·로그인 유도 모달 등 인증 분기가 필요한 모든 곳이 useAuth 훅 하나를 쓰는데, 여기서 배운 게 초기값 문제입니다.
// src/hooks/useAuth.ts
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
// null: 확인 중 / true: 인증 / false: 비인증
초기값을 false로 두면 로그인된 사용자도 첫 렌더에 비로그인 UI가 한 번 깜빡입니다. 쿠키 확인은 비동기로 끝나니까요. "확인 전"이라는 상태를 null로 분리하고, 그동안은 어느 쪽 UI도 그리지 않는 게 답이었습니다. "로그인돼 있는데 닉네임이 사라진다"던 이슈도 이 확인 순서 정리로 같이 풀렸어요.
checkAuth의 순서에도 게이팅이 하나 있습니다.
if (!accessToken) {
const refreshToken = Cookies.get('refreshToken');
if (refreshToken) { // refresh 쿠키가 있을 때만 갱신 시도
await refreshApi();
// …
} else {
setIsAuthenticated(false); // 비로그인 사용자는 조용히 확정 — 갱신 요청 안 쏨
}
}
이 if (refreshToken)이 없으면 비로그인 사용자가 페이지에 들어올 때마다 실패할 갱신 요청을 서버에 쏘게 됩니다. 그리고 갱신 실패는 alert 없이 조용히 비인증으로 확정합니다 — 로그인한 적 없는 사용자에게 "갱신 실패"를 알릴 이유가 없으니까요. 마지막으로 access가 5분이라, 훅은 4분 간격으로 checkAuth를 돌려 만료 직전에 선제 갱신합니다. 작성 중 문서가 날아가는 최악의 경로를 막는 마지막 겹입니다.
4. 결과, 그리고 정직한 결말
401 오처리 계열 — 로그인 풀림, 중복 alert, 갱신 루프 — 은 이 구조 이후 재발하지 않았습니다. 같은 자리를 세 번 고치던 패턴이 끊긴 게 이 리팩토링의 성과예요.
다만 "인증 버그가 끝났다"는 뜻은 아니었습니다. 반년 뒤, 이번엔 인증이 확정되기 전에 나간 요청 때문에 북마크 상태가 갈리는 버그가 나왔어요(그 기록은 따로 정리했습니다). 401을 어떻게 처리하느냐를 모았더니, 다음 문제는 언제 요청을 보내느냐에서 나온 거죠. 인증은 한 번의 리팩토링으로 끝나는 주제가 아니라 층이 있는 주제였습니다.
5. 지금 코드에 남아 있는 결함
이 글을 쓰며 현재 api.ts를 다시 읽었고, 그대로 두면 언젠가 물릴 것들을 적어둡니다.
- 서버 에러 문구 완전 일치 매칭.
message === '유효하지 않은 JWT 토큰입니다.'— 서버가 문구를 한 글자만 바꿔도 분류가SHOW_MESSAGE로 빠집니다. 백엔드와 에러 코드 체계(문자열 코드나 숫자)를 합의하는 게 정석인데, 2인 팀에서 메시지 문구를 사실상의 프로토콜로 써버렸어요. 지금까지 안 터진 건 서버 문구가 안 바뀌었기 때문이지, 구조가 안전해서가 아닙니다. - 갱신 경로가 두 곳입니다.
fetchData의 401 선처리 분기와handleError의REFRESH_TOKEN분기가 둘 다refreshApi()를 호출합니다. 동작은 하지만 같은 일을 하는 코드가 두 벌이라, 한쪽만 고치는 사고의 예약석이에요. NEED_LOGIN·NEED_BACK의 하드 이동.window.location.href = '/login'과history.back()은 작성 중이던 화면 상태를 그대로 날립니다. 토큰 자동 갱신의 목적이 "작성 중 데이터 보호"였는데, 정작 최후 처리 단계는 데이터를 보호하지 않는 아이러니가 남아 있습니다. 임시 저장이나 이동 전 확인이 후속 과제입니다.FetchOptions에credentials필드가 선언돼 있지만 아무 데서도 안 읽습니다(항상'include'하드코딩). 죽은 옵션은 지우는 게 맞아요.
6. 돌아보면
- 세 번째 재발이 신호였습니다. 같은 계열 버그를 두 번 고쳤으면 화면 탓, 세 번째면 구조 탓이었어요. 더 일찍 인정했으면 두 번째는 없었을 겁니다.
- 에러 분류의 축은 "원인"이 아니라 "행동"이었습니다. 서버 에러가 스무 가지여도 클라이언트가 할 수 있는 행동은 네 가지뿐이라, 그 네 가지로 접는 순간 처리 코드가 단순해졌습니다.
- 실패를 조용히 처리할 자리를 정하는 것도 설계였습니다. 비로그인 사용자의 갱신 실패는 알리지 않고, 작성 중 사용자의 만료는 최대한 선제 갱신으로 막고, 최후에만 리다이렉트 — 같은 "인증 실패"라도 맥락마다 답이 달랐어요.