문제 상황
퇴근하고 출근을 했는데, 기존에 테스트를 위해 로그인해두었던 사이트가
'인증이 만료되었습니다. 로그인으로 이동합니다.' 라고 출력이 되어있었다.
다른 사내 서비스에선 확인을 누르면 로그인 창으로 이동하지만,
1. 로그인창으로 가지 않고
2. 그 상태에서 401 에러가 뜨면서 뻗어버리는 에러가 발생
최종 데모 시연까지 이틀 남은 상황에서 급하게 코드를 부랴부랴 분석하기 시작했다. (with claude)
내가 생각한 원인은
1. 리다이렉트 주소가 일치하지 않거나 (반복된 수정으로 인해 놓친 것 아닐까?)
2. 리다이렉트 주소를 추가하지 않았거나 (사내 도메인에서 우린 서비스 아이디에 따라 분기를 하고 있음)
로그인 창으로 가지 않기 때문에 단순 리다이렉트 오류겠거니 생각을 했는데,
그리고 원인을 찾아냈는데
reject() 뒤에 오지 않는 return으로 인해 setTimeout이 충돌남
왜 발생했을까?
우리 서비스는 토큰이 만료되면 401 응답을 받고, 팝업을 띄운 뒤 로그인 페이지로 이동시키는 로직이 있었다.
if (errorResponse.status === 401) {
if (useCustomErrorAlert) reject(errorResponse);
setTimeout(() => {
alertController.notifyCaution("인증이 만료되었습니다. 다시 로그인해주세요.", () => {
utilController.openPage(`/services/${CommonDOM.serviceId}/login`);
});
}, 200);
}
useCustomErrorAlert가 true인 경우 reject()로 처리하고, 그렇지 않은 경우 팝업을 띄우는 의도였다.
근데 실제로는 둘 다 실행되고 있었다.
원인 분석
Promise.reject()는 함수를 멈추지 않는다
많은 사람들이 reject()를 호출하면 함수 실행이 멈출 것이라고 착각한다. 하지만 그렇지 않다.
new Promise((resolve, reject) => {
reject('에러 발생');
console.log('이것도 실행됨'); // ← 실행된다!
});
reject()는 Promise의 상태를 실패로 바꾸는 것이지, return처럼 함수 실행을 멈추는 게 아니다.
// throw는 실행을 멈춘다
new Promise((resolve, reject) => {
throw new Error('에러');
console.log('이건 실행 안 됨'); // ← 실행 안 됨
});
// return은 실행을 멈춘다
new Promise((resolve, reject) => {
reject('에러');
return; // ← 여기서 멈춤
console.log('이건 실행 안 됨'); // ← 실행 안 됨
});
함수 실행을 멈추려면 예시처럼 return이나 throw를 써야 한다.
setTimeout은 예약만 하고 넘어간다
setTimeout은 콜백을 바로 실행하지 않는다. 이벤트 루프에 예약만 해두고 현재 코드는 계속 진행된다.
console.log('1');
setTimeout(() => {
console.log('3'); // ← 나중에 실행
}, 200);
console.log('2'); // ← 먼저 실행
// 출력: 1 → 2 → (200ms 후) 3
즉 reject() 다음에 setTimeout이 있으면, reject()로 Promise가 실패 상태가 되더라도 setTimeout 예약은 이미 실행된다.
버그가 발생한 흐름
1. 장시간 방치 → 토큰 만료
2. API 요청 → 401 응답
3. useCustomErrorAlert = true인 상황에서 호출
4. reject(errorResponse) 실행
→ Promise 실패 상태로 변경
→ 하지만 함수는 계속 진행
5. setTimeout 알림도 실행됨
6. 팝업 확인 클릭 → 로그인 페이지 이동 시도
7. 동시에 reject 받은 쪽에서도 에러 처리 실행
8. 중복 처리로 충돌
해결
reject() 후에 단순히 return을 추가하면 끝이다.
if (errorResponse.status === 401) {
if (useCustomErrorAlert) {
reject(errorResponse);
return; // ← 이 한 줄
}
setTimeout(() => {
alertController.notifyCaution("인증이 만료되었습니다. 다시 로그인해주세요.", () => {
utilController.openPage(`/services/${CommonDOM.serviceId}/login`);
});
}, 200);
}
이제 useCustomErrorAlert가 true이면 reject() 후 바로 함수가 종료되어 setTimeout은 실행되지 않는다.
왜 이런 코드가 만들어졌을까?
원래 코드는 이랬을 것이다.
// 초기 버전 - useCustomErrorAlert 조건 없음
if (errorResponse.status === 401) {
setTimeout(() => {
alertController.notifyCaution(...);
}, 200);
}
나중에 globalAlarm 기능을 추가하면서 useCustomErrorAlert 분기를 넣었는데, return을 빠뜨리지 않았을까? 한다.
// 기능 추가 후 - return 누락
if (errorResponse.status === 401) {
if (useCustomErrorAlert) reject(errorResponse); // return 없음!
setTimeout(() => { ... }, 200); // 항상 실행됨
}
다른 에러 상태(404, 500, 502)들은 else if 체인 안에 있어서 자연스럽게 하나만 실행됐는데, 401만 내부 구조가 달라서 이 문제가 발생했다.
정리
| 함수 | 용도 |
| return | 함수 종료 |
| throw | 예외 발생 |
| reject() | Promise 실패 상태로 변경만 함 |
reject()는 return이 아니다. Promise를 실패시키고 싶으면서 함수도 멈추고 싶다면 반드시 return을 함께 써야 한다.
// 올바른 패턴
if (someCondition) {
reject(error);
return; // 항상 함께
}
작은 실수지만, 찾기 어려운 버그가 될 수 있었다. 개념을 정확히 알고 있지 않아 오늘도 하나 배웠다.. ㅎ
'개발자로 살아남기 > 트러블슈팅' 카테고리의 다른 글
| [AIoT] 게이트웨이가 뭐길래? Level vs Edge Triggered 알람 이슈 (1) | 2026.04.21 |
|---|---|
| [Git] 브랜치 간 변동 사항을 옮길 때 주의할 점 (0) | 2026.04.06 |
| [CSS] ag-grid autoHeight vs normal 모드에서 가로 스크롤 제어하기 (0) | 2026.04.06 |
| [JS] Cannot read properties of null (reading 'shapeFlag') - setTimeout과 디바운스 (0) | 2026.03.25 |