- 발행일
로그인이 자꾸 풀리던 서비스 — 401 처리를 한곳으로 모으기까지
로그인이 자꾸 풀리던 서비스 — 401 처리를 한곳으로 모으기까지
TripTune2025-01-06협업형 여행 계획 플랫폼 TripTune — 2인 팀에서 프런트엔드 전체를 맡은 기록피그마처럼 여럿이 함께 일정을 짜는 서비스를 2인 팀으로 만들었습니다. MVP는 6개월, 이후 운영·개선까지 25개월간 프런트엔드 전체를 맡았어요. 인증 토큰 자동 갱신·STOMP 실시간 채팅·드래그 앤 드롭 일정 편집이 핵심이었고, React-Query와 Fetch를 역할로 나눈 기준과 MVP 이후에 드러난 것들을 함께 정리했습니다.의 토큰은 access 5분 / refresh 1주입니다. access가 짧으니 갱신은 일상적으로 일어나고, 갱신 처리가 어긋나면 증상이 바로 사용자에게 보입니다. 실제로 초기에 겪은 버그들은 이랬습니다.
- 일정을 편집하다가 갑자기 로그인이 풀림
- 에러 하나에 alert가 두 번 뜸
- 로그아웃이 안 됨, 닉네임이 사라짐
- 갱신 실패 → 재시도 → 또 실패 → … 무한 갱신 루프
증상은 다 다른데, 고치다 보니 뿌리가 같았습니다. 401 처리 로직이 화면마다 복사돼 흩어져 있었고, 화면마다 조금씩 달랐던 것. A 화면은 갱신을 시도하는데 B 화면은 바로 로그인 페이지로 보내고, C 화면은 alert를 띄우는데 공통 코드도 또 띄우는 식이었어요. 같은 계열 버그를 세 번쯤 고치고 나서야 "이건 화면의 버그가 아니라 구조의 버그"라는 걸 인정했습니다.
1. 어디로 모을 것인가
401 처리를 한곳으로 모은다고 할 때 선택지는 세 개였습니다.
| 방법 | 장점 | 포기하는 것 | 판단 |
|---|---|---|---|
| 지금처럼 화면별 try/catch, 대신 컨벤션 문서화 | 코드 변경 없음 | 강제력이 없음 — 새 화면마다 같은 실수 재생산 | 이미 세 번 실패한 방식 |
| axios 도입 + 인터셉터 | 인터셉터 생태계 성숙, 레퍼런스 많음 | 의존성 추가. 이미 fetch 기반 코드 전체 이관 비용 | 인터셉터 하나 쓰자고 HTTP 클라이언트를 갈 이유가 없었음 |
| 커스텀 fetch 래퍼 | 기존 fetch 코드의 자연스러운 수렴점, 의존성 0 | 재시도·에러 분기를 전부 직접 작성 | 채택 — 어차피 React-Query와 역할을 나누며2025-01-06협업형 여행 계획 플랫폼 TripTune — 2인 팀에서 프런트엔드 전체를 맡은 기록피그마처럼 여럿이 함께 일정을 짜는 서비스를 2인 팀으로 만들었습니다. MVP는 6개월, 이후 운영·개선까지 25개월간 프런트엔드 전체를 맡았어요. 인증 토큰 자동 갱신·STOMP 실시간 채팅·드래그 앤 드롭 일정 편집이 핵심이었고, React-Query와 Fetch를 역할로 나눈 기준과 MVP 이후에 드러난 것들을 함께 정리했습니다. 인증 요청은 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, 갱신 루프 — 은 이 구조 이후 재발하지 않았습니다. 같은 자리를 세 번 고치던 패턴이 끊긴 게 이 리팩토링의 성과예요.
다만 "인증 버그가 끝났다"는 뜻은 아니었습니다. 반년 뒤, 이번엔 인증이 확정되기 전에 나간 요청 때문에 북마크 상태가 갈리는 버그가 나왔어요(그 기록은 따로 정리2025-07-12인증이 확정되기 전에 쏜 요청 — 북마크가 가끔 초기화되던 이유여행지 목록에서 상세로 들어가면 북마크가 가끔 빈 상태로 보였습니다. isAuthenticated가 null에서 확정되기 전에 요청이 나갔고, 같은 데이터가 캐시 키 두 개로 갈려 있었어요. 고치긴 했는데 지금 다시 보니 원인 진단과 처방이 어긋나 있고 캐시를 통째로 끄는 코드가 남았습니다.했습니다). 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. 돌아보면
- 세 번째 재발이 신호였습니다. 같은 계열 버그를 두 번 고쳤으면 화면 탓, 세 번째면 구조 탓이었어요. 더 일찍 인정했으면 두 번째는 없었을 겁니다.
- 에러 분류의 축은 "원인"이 아니라 "행동"이었습니다. 서버 에러가 스무 가지여도 클라이언트가 할 수 있는 행동은 네 가지뿐이라, 그 네 가지로 접는 순간 처리 코드가 단순해졌습니다.
- 실패를 조용히 처리할 자리를 정하는 것도 설계였습니다. 비로그인 사용자의 갱신 실패는 알리지 않고, 작성 중 사용자의 만료는 최대한 선제 갱신으로 막고, 최후에만 리다이렉트 — 같은 "인증 실패"라도 맥락마다 답이 달랐어요.