주문 조회 코드를 try/catch로 감쌌는데, 없는 주문 번호를 넣어도 catch가 실행되지 않고 화면에는 undefined가 찍힙니다. fetch는 서버가 404나 500을 돌려줘도 실패로 보지 않기 때문입니다. 이 글을 읽고 나면 fetch가 어떤 경우에 resolve되고 어떤 경우에 reject되는지 나눠 볼 수 있고, 상태 코드와 시간 초과를 한곳에서 오류로 바꾸는 작은 함수를 직접 쓸 수 있습니다.
예제의 주문 API(/orders/1001, 상품 MUG-1, 수량 1)는 가상입니다. 같은 요청을 2026년 9월 27일 이 컴퓨터의 로컬 서버(127.0.0.1:4310)로 보내, Node.js v24.21.0과 Chromium 152 기반 브라우저에서 각각 실행했습니다. 아래 결과 화면은 그 실행의 출력 그대로이고, 규칙 설명은 2026년 9월 21일 갱신된 Fetch 표준과 MDN을 기준으로 했습니다.
fetch가 알려 주는 것은 응답을 받았는지입니다
fetch가 돌려주는 promise는 응답이 도착하면 resolve됩니다. 그 응답의 상태가 200이든 404든 500이든 마찬가지입니다. 서버가 “그런 주문은 없다”고 분명히 대답했다면, 네트워크 입장에서 요청은 성공한 것입니다. 성공 여부는 Response의 ok로 따로 봅니다. ok는 상태가 200부터 299 사이일 때만 true입니다.
reject는 응답 자체를 받지 못했을 때 일어납니다. 서버가 꺼져 연결이 거부되었거나, 주소가 잘못되었거나, CORS 검사에서 막혔을 때는 TypeError로 reject됩니다. 표준은 응답이 네트워크 오류이면 promise를 TypeError로 reject하라고 정하고, 그 밖의 응답은 Response 객체로 resolve합니다.
로컬 실행에서도 그대로였습니다. /orders/9999는 404, /orders/boom은 500을 돌려줬지만 둘 다 resolved였고 ok만 false였습니다. 본문 JSON도 정상으로 읽혔습니다. 아무도 듣지 않는 포트 4399로 보낸 요청만 reject되었고, Node는 “fetch failed”와 함께 cause에 ECONNREFUSED를 담았습니다.

시간 초과와 취소는 다른 이름으로 옵니다
fetch에는 timeout 옵션이 없습니다. 대신 signal에 AbortSignal.timeout(1000)을 넘기면 1초 뒤 그 신호가 중단되고, fetch는 신호의 중단 이유로 reject됩니다. 시간 초과의 이유는 TimeoutError라는 DOMException입니다. 사용자가 버튼을 눌러 AbortController의 abort()를 부르면 이유를 따로 주지 않는 한 AbortError가 됩니다. 로컬 실행에서 3초 걸리는 /orders/slow는 1초에 TimeoutError, 0.5초에 abort()를 부른 경우는 AbortError로 끝났습니다.
이 구분은 화면 문구를 정할 때 쓸모가 있습니다. TimeoutError면 “응답이 늦습니다. 다시 시도해 주세요”, AbortError면 사용자가 스스로 취소한 것이니 아무 메시지도 띄우지 않는 편이 자연스럽습니다. TypeError는 연결이나 설정 문제이고, ok가 false인 응답은 서버가 준 상태와 본문을 보고 판단합니다. 오류는 네 갈래입니다.
중단은 요청을 되돌리지 않습니다. 서버 기록을 보면 1초에 포기한 /orders/slow도 서버에는 도착해 있었습니다. 클라이언트가 기다리기를 멈춘 것일 뿐, 주문 저장 같은 작업이 서버에서 실행되지 않았다는 뜻은 아닙니다. POST를 시간 초과로 다시 보낼 때는 같은 주문이 두 번 생기지 않는지 따로 확인해야 합니다.
await가 두 번이면 실패할 자리도 두 곳입니다
await fetch(…)가 끝났다는 것은 상태와 헤더가 왔다는 뜻이지, 본문을 다 받았다는 뜻이 아닙니다. 본문은 res.json()이나 res.text()를 await할 때 마저 읽습니다. 넘긴 signal은 그동안에도 붙어 있어서, 본문을 읽는 중에 시간이 다 되면 두 번째 await가 reject됩니다.
/orders/slow-body는 헤더와 JSON 앞부분을 바로 보내고 나머지를 3초 뒤에 보내도록 만든 경로입니다. 1초 제한을 걸자 fetch는 200, ok true로 resolve되었고, 이어진 res.json()이 1초쯤에 reject되었습니다. 그러니 try 블록은 fetch 한 줄이 아니라 본문을 읽는 줄까지 감싸야 합니다.
여기서 두 실행 환경의 차이가 보였습니다. 표준은 본문 스트림도 신호의 중단 이유로 오류 처리하라고 정하고 있고, Node는 본문 단계에서도 TimeoutError를 냈습니다. 같은 코드를 Chromium 152에서 실행하자 fetch 단계는 TimeoutError였지만 본문 단계는 AbortError였습니다. 다른 브라우저는 이번에 실행하지 않았습니다. 이름에만 기대지 말고, 내가 만든 signal의 aborted와 reason을 함께 보면 두 환경에서 같은 판단을 할 수 있습니다.

상태 확인과 본문 읽기를 함수 하나에 모읍니다
호출하는 곳마다 ok를 확인하면 한 곳쯤은 빠지기 마련입니다. 요청, 시간 제한, 본문 읽기, 상태 확인을 한 함수에 넣고, 2xx가 아니면 상태와 본문을 담은 오류를 던지게 하면 호출하는 쪽은 try/catch 하나로 끝납니다. 아래 getJson은 이번 실행에 쓴 코드입니다.
class HttpError extends Error {
constructor(res, body) {
super(`HTTP ${res.status} ${res.url}`);
this.name = 'HttpError';
this.status = res.status;
this.body = body;
}
}
async function getJson(url, { timeoutMs = 5000 } = {}) {
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
const text = await res.text();
const body = text ? JSON.parse(text) : null;
if (!res.ok) throw new HttpError(res, body);
return body;
}
본문을 text로 먼저 읽는 것은 빈 본문과 오류 응답의 JSON을 같은 길로 다루기 위해서입니다. 빈 본문에 바로 res.json()을 부르면 SyntaxError가 나기 때문입니다. 같은 로컬 서버에서 /orders/1001은 데이터를 돌려줬고, /orders/9999는 HttpError 404와 본문 {"error":"order_not_found"}, /orders/boom은 HttpError 500과 {"error":"db_unavailable"}로 catch에 들어갔습니다. 서버가 JSON이 아닌 오류 페이지를 보내면 JSON.parse가 SyntaxError를 던지므로, 그런 서버를 상대한다면 Content-Type을 먼저 확인하는 줄을 더합니다.
catch를 믿기 전에 ok부터 확인합니다
fetch 코드를 읽을 때 한 가지만 확인하면 됩니다. res.ok를 보는 줄이 있는가, 그리고 그 줄이 본문을 읽은 뒤 오류를 던지는가. 그 줄이 없다면 404와 500은 지금도 조용히 성공으로 지나가고 있습니다. 시간 제한을 쓴다면 본문을 읽는 await까지 같은 try 안에 두고, 중단은 요청을 취소하는 것이 아니라 기다림을 멈추는 것이라는 점을 재시도 로직에 반영합니다.
이 기록에 대화를 더해 주세요.
궁금한 점, 다른 접근, 직접 해 본 결과를 나눠 주세요.
로그인 상태 확인 중…
댓글을 불러오는 중…