타임아웃 설계, 2번의 실패로 배운 것
지난 운영 중 트래픽이 몰리는 순간마다 같은 문제가 반복됐다. 사용자는 결제 화면에서 "결제 실패" 토스트를 봤다. 하지만 서버 로그를 확인해보면 주문은 이미 정상적으로 완료되어 있었다.
원인은 프론트엔드였다. apiClient는 자체적으로 AbortController와 setTimeout을 이용해 요청을 강제로 중단하고 있었다. 기본 10초, 결제 요청은 30초. 평소에는 문제가 없었지만, 서버 응답이 살짝만 지연돼도 프론트가 먼저 손을 놓아버렸다.
문제는 프론트가 손을 놓는 시점과 서버가 처리를 끝내는 시점이 어긋난다는 데 있었다. 프론트는 실패로 간주하고 사용자에게 에러를 띄웠지만, 서버는 그 요청을 계속 처리해서 결제를 완료시켰다. 프론트와 서버가 서로 다른 결과를 믿고 있는 상태.
타임아웃이 뜨면, 그건 정말 요청이 실패했다는 뜻일까?
이 질문에 제대로 답하지 못한 채로 timeout을 다뤄왔다는 걸 이번 문제를 겪으며 깨달았다.
timeout이란 무엇인가
AbortController는 fetch 요청에 취소 신호(signal)를 연결해주는 브라우저 API다. setTimeout으로 일정 시간 뒤에 controller.abort()를 호출하면, 그 시점에 응답이 오지 않은 요청은 강제로 취소되고 AbortError가 발생한다. 이게 흔히 말하는 "클라이언트 타임아웃"의 구현 방식이다.
여기서 중요한 건, 타임아웃이 서버의 실패를 감지하는 게 아니라는 점이다. 서버는 여전히 요청을 처리하고 있을 수 있다. 타임아웃은 그저 "나(클라이언트)는 여기까지만 기다리겠다"는 클라이언트의 일방적인 결정일 뿐이다. 서버 입장에서는 아무것도 달라지지 않는다.
즉, 타임아웃이 발생했다는 사실 하나만으로는 요청의 성패를 알 수 없다. 이 전제를 놓치면 지금과 같은 상태 불일치가 생긴다.
Phase 1: 타임아웃을 아예 없애면?
가장 먼저 떠올린 방법은 단순했다. 프론트가 임의로 요청을 끊지 않으면, 프론트와 서버의 판단이 어긋날 일도 없다.
// Before: 프론트가 자체적으로 요청을 강제 중단 const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMillis); const response = await fetch(url, { ...fetchOptions, signal: controller.signal });
AbortController와 setTimeout 관련 로직을 통째로 걷어냈다. timeoutMillis 옵션 자체도 인터페이스에서 제거했다. 이제 프론트는 서버가 응답을 줄 때까지 무조건 기다린다. 서버가 자체적으로 처리 시간을 초과했다고 판단하면 {"result": "failure", "error": "TIMEOUT_ERROR"} 형태로 응답을 내려주고, 프론트는 그 응답만 처리하면 된다.
배포 후 실제로 "프론트가 먼저 끊어서 생기는 상태 불일치"는 사라졌다. 그런데 곧 다른 문제가 보이기 시작했다.
서버가 정말로 응답을 주지 못하는 상황 — 네트워크가 끊기거나, 서버 자체가 뻗어버리는 경우 — 에는 프론트가 기댈 곳이 없었다. 브라우저나 OS 수준까지 요청이 무한정 대기했다. 사용자는 "결제 중" 화면에 갇힌 채로 아무것도 할 수 없었다. 타임아웃을 없앤 게 아니라, 타임아웃의 책임을 전부 서버에 떠넘긴 셈이었다.
Phase 2: 요청 성격에 따라 다르게 기다리기
여기서 질문을 다시 세웠다.
프론트와 서버 중 누가 타임아웃을 관리해야 하는가?
답은 "둘 중 하나"가 아니었다. 프론트는 사용자가 기다릴 수 있는 한계를, 서버는 실제 처리 성패의 근거를 책임진다. 이 둘은 대체 관계가 아니라 각자의 역할이 다른 관계였다.
그래서 타임아웃을 없애는 대신, 요청의 성격에 따라 다르게 설계했다.
// After: 요청별로 timeout을 지정할 수 있도록 옵션화 const timeoutMillis: number = options.timeoutMillis ?? 15000; // 기본 15초 const timeoutId = setTimeout(() => controller.abort(), timeoutMillis);
// 결제/주문 API만 5분으로 별도 지정 export async function apiSaveOrder(request: SaveOrderRequest): Promise<SaveOrderResponse> { return apiClient.post('/order/saveAppOrder', request, { authRequired: true, timeoutMillis: 300000, // 외부 PG 연동 + 서버 처리 시간을 고려한 5분 }); }
단순 조회나 변경 API는 기본 15초로 충분하다. 사용자가 그 이상 기다릴 이유가 없기 때문이다. 반면 결제·주문 API는 외부 PG사와의 통신, 서버 내부 처리까지 포함되어 지연 가능성이 훨씬 크다. 그래서 5분이라는 여유를 뒀다.
그리고 클라이언트 타임아웃으로 요청이 끊기더라도, 서버가 내려주는 TIMEOUT_ERROR와 동일한 에러로 변환해서 처리 흐름을 통일했다.
} catch (error: unknown) { const isAbortError = error instanceof Error && error.name === 'AbortError'; // ... if (isAbortError) { throw new CustomError(ErrorCode.TIMEOUT_ERROR, 408); } // ... }
이렇게 하면 "프론트가 끊은 타임아웃"과 "서버가 알려준 타임아웃"을 상위 코드에서 굳이 구분할 필요가 없어진다. 둘 다 같은 방식으로 다루면 된다.
POST 요청, 중복은 어떻게 막았나
타임아웃을 다시 설계해도 근본적인 질문은 남는다. 타임아웃이 발생한 요청은, 서버에 진짜 도달했을 수도 있고 아닐 수도 있다. 그 상태에서 사용자에게 "다시 시도하시겠어요?"라고 묻고 재전송 버튼을 누르게 하면 어떻게 될까. 최악의 경우 같은 주문이 두 번 결제된다.
POST는 멱등하지 않다. 실패를 확신할 수 없다면, 다시 보내지 말고 먼저 확인해야 한다.
결제 흐름에는 이 원칙을 지키기 위한 장치를 다섯 겹으로 두었다.
① 애매한 실패를 별도로 분류한다
타임아웃과 네트워크 단절(TypeError)은 "실패"가 아니라 "결과를 알 수 없음"으로 분류한다.
function isAmbiguousError(error: unknown): boolean { if (error instanceof CustomError) { return error.errorCode === ErrorCode.TIMEOUT_ERROR; } return error instanceof TypeError; // fetch 자체가 실패한 네트워크 단절 }
② 재시도 대신 재확인한다
결과가 불확실할 때는 결제 요청을 다시 보내지 않는다. 대신 서버가 처리를 마칠 시간을 잠깐 기다린 뒤, 조회성 API로 실제로 주문이 만들어졌는지 확인한다.
if (isAmbiguousError(error)) { await new Promise<void>(resolve => window.setTimeout(resolve, ORDER_RECHECK_DELAY_MS)); const recentOrder = await recheckRecentOrder(); // POST를 재전송하지 않고 조회로 확인 if (recentOrder) { await handleOrderSuccess(recentOrder.id); return; } }
주문 결제 요청(saveAppOrder)은 다시 부르지 않는다. 대신 "최근 주문이 존재하는가"를 조회하는 별도 API로 실제 상태를 확인한 뒤에야 성공/실패를 최종 판정한다.
③ 같은 요청이 두 번 발사되지 않도록 막는다
리렌더링이나 effect 재실행으로 결제 요청이 중복 발사되는 걸 막기 위해 ref로 실행 여부를 기록한다.
const hasStartedExecutionRef = useRef<boolean>(false); useEffect(() => { if (hasStartedExecutionRef.current) return; hasStartedExecutionRef.current = true; void handleExecuteOrderPayment(); }, [...]);
④ 결제 중에는 사용자가 스스로 재시도할 경로 자체를 막는다
결제가 진행되는 동안 뒤로가기를 막아, 사용자가 답답함에 뒤로 갔다가 다시 결제를 시도하는 경로를 차단한다.
useNavigationBlocker({ isEnabled: true, shouldBlockNavigation: (context) => context.historyAction === "POP", behavior: { type: "toast", message: "결제 중에는 페이지 이동이 불가능해요", variant: "error" }, });
⑤ 애초에 중복 주문 가능성을 먼저 경고한다
결제 버튼을 누르기 전, 최근 주문 이력이 있으면 경고 모달을 먼저 띄운다. 사고를 사후에 복구하는 것보다 애초에 실수를 줄이는 편이 낫다.
getExistRecentOrder().then((recentOrder) => { if (!recentOrder) return; // 최근 주문 없으면 그대로 진행 // 있으면 "중복 주문 시 환불이 어려워요" 경고 모달 표시 });
배운 점
이번 작업을 거치면서 문제를 보는 관점이 바뀌었다. 처음엔 "타임아웃을 몇 초로 잡을지"가 고민의 전부라고 생각했다. 하지만 진짜 문제는 타임아웃이 발생한 이후, 그 상태를 어떻게 신뢰할 것인가였다.
- Before: 모든 요청에 동일한 타임아웃(10초/30초)을 고정으로 적용, 타임아웃 = 실패로 단정
- After: 요청 성격별 차등 타임아웃(15초/5분) 적용, 애매한 실패는 재시도 대신 서버 상태를 재확인해서 판정
타임아웃은 "언제 포기할지"를 정하는 설정값이 아니라, "포기한 뒤에 무엇을 믿을지"까지 함께 설계해야 하는 문제라는 걸 배웠다.
첫 댓글을 남겨보세요.