JavaScript 비동기 흐름 다시 보기 (06) - fetch 에러는 어디서 throw해야 할까
fetch가 HTTP 404와 500을 rejected Promise로 만들지 않는 이유와 response.ok 기준으로 throw할 위치를 정리합니다.
fetch는 서버가 404나 500을 응답해도 자동으로 rejected Promise를 만들지 않습니다. HTTP 응답을 앱의 실패 상태로 바꾸는 지점을 직접 정해야 합니다.
들어가며
앞선 글에서는 여러 Promise를 함께 다룰 때 실패를 어떻게 해석할지 정리했습니다. 이제 가장 자주 만나는 비동기 작업인 fetch로 넘어가 보겠습니다.
많은 코드가 처음에는 이렇게 시작합니다.
async function loadPost(id: string) {
try {
const response = await fetch(`/api/posts/${id}`);
const post = await response.json();
return post;
} catch {
return null;
}
}겉으로는 안전해 보입니다. 요청도 try/catch 안에 있고, await도 붙어 있습니다. 그래서 404나 500이 오면 catch로 들어갈 것처럼 보입니다.
하지만 fetch는 그렇게 동작하지 않습니다. 서버가 응답을 보냈다면, 그 응답의 상태 코드가 실패를 의미하더라도 fetch Promise는 보통 fulfilled 상태가 됩니다. 404 페이지를 받았든, 500 응답을 받았든, 네트워크 관점에서는 "응답을 받았다"는 뜻이기 때문입니다.
이번 글의 질문은 하나입니다.
fetch를 쓸 때 에러는 어디서 throw해야 할까요. 기준은
HTTP 응답을 앱의 성공과 실패로 번역하는 경계
입니다.
fetch가 rejected가 되는 경우부터 구분합니다
Promise 관점에서 fetch는 네트워크 요청을 나타내는 비동기 작업입니다. 그런데 이 작업의 성공과 실패는 HTTP 상태 코드와 완전히 같지 않습니다.
const response = await fetch("/api/posts/unknown");
console.log(response.status); // 404일 수 있습니다.
console.log(response.ok); // false입니다.위 요청이 404 응답을 받았다면 response.ok는 false입니다. 하지만 await fetch(...) 자체가 반드시 catch로 넘어가는 것은 아닙니다. 서버가 404라는 응답을 정상적으로 돌려줬기 때문입니다.
반대로 네트워크 연결 자체가 실패하거나, 요청이 중단되거나, 브라우저가 응답을 받을 수 없는 상황이면 fetch Promise가 rejected 상태가 될 수 있습니다.
그래서 fetch를 다룰 때는 실패를 두 층으로 나눠 보는 편이 좋습니다.
네트워크 실패는 요청 자체가 완료되지 못한 실패입니다. 이 경우 fetch Promise가 rejected가 될 수 있습니다.
HTTP 실패는 서버 응답은 받았지만, 상태 코드가 앱에서 실패로 해석되어야 하는 경우입니다. 이 경우 response.ok를 보고 직접 판단해야 합니다.
response.ok를 확인하지 않으면 실패가 성공 흐름으로 들어옵니다
일부러 잘못 쓴 예를 보겠습니다.
async function getPost(id: string) {
const response = await fetch(`/api/posts/${id}`);
return response.json();
}이 함수는 짧지만 중요한 판단이 빠져 있습니다. 응답이 200인지, 404인지, 500인지 확인하지 않고 바로 JSON을 읽습니다.
만약 서버가 404와 함께 아래 같은 응답을 보낸다면 어떻게 될까요.
{
"message": "게시글을 찾을 수 없습니다."
}response.json()은 이 JSON을 정상적으로 읽을 수 있습니다. 그러면 호출한 쪽에서는 이것이 성공 데이터인지 실패 응답인지 구분하기 어려워집니다.
const post = await getPost("missing");
renderPost(post);이 코드는 없는 게시글을 성공 데이터처럼 렌더링하려고 할 수 있습니다. 실패를 catch에서 처리하고 싶었다면, HTTP 상태를 확인한 뒤 rejected 흐름으로 바꿔야 합니다.
throw는 응답을 해석하는 함수 안에서 합니다
가장 기본적인 형태는 response.ok를 확인하고, false이면 throw하는 것입니다.
async function getPost(id: string) {
const response = await fetch(`/api/posts/${id}`);
if (!response.ok) {
throw new Error("게시글을 불러오지 못했습니다.");
}
return response.json();
}이제 getPost를 호출하는 쪽에서는 HTTP 실패를 rejected Promise로 다룰 수 있습니다.
async function loadPost(id: string) {
try {
const post = await getPost(id);
renderPost(post);
} catch (error) {
renderError(error);
}
}이 구조에서 throw는 UI 코드 안에 흩어져 있지 않습니다. getPost가 HTTP 응답을 앱의 데이터 또는 실패로 번역합니다. 호출하는 쪽은 그 결과를 기준으로 fulfilled와 rejected 상태를 처리합니다.
즉 throw 위치는 아무 곳이나 잡는 것이 아닙니다. 서버 응답의 의미를 해석하는 함수 안에 두는 편이 좋습니다.
상태 코드가 필요하면 에러에 담아 둡니다
실무에서는 모든 실패를 같은 문구로 보여 주기 어렵습니다. 401이면 로그인 화면으로 보내야 할 수 있고, 404면 "없음"을 보여 줄 수 있으며, 500이면 재시도 안내가 필요할 수 있습니다.
그럴 때는 상태 코드를 잃어버리지 않는 편이 좋습니다.
class HttpError extends Error {
constructor(
message: string,
public status: number,
) {
super(message);
this.name = "HttpError";
}
}
async function getPost(id: string) {
const response = await fetch(`/api/posts/${id}`);
if (!response.ok) {
throw new HttpError("게시글을 불러오지 못했습니다.", response.status);
}
return response.json();
}이제 호출하는 쪽은 실패를 더 구체적으로 처리할 수 있습니다.
async function loadPost(id: string) {
try {
const post = await getPost(id);
renderPost(post);
} catch (error) {
if (error instanceof HttpError && error.status === 404) {
renderNotFound();
return;
}
renderError(error);
}
}여기서 중요한 점은 catch가 상태 코드를 만들지 않는다는 것입니다. 상태 코드는 응답을 받은 시점에만 정확히 알 수 있습니다. 그래서 응답을 해석하는 함수에서 에러를 만들고, UI 쪽에서는 그 에러를 이용해 상태를 결정합니다.
404를 항상 throw해야 하는 것은 아닙니다
다만 response.ok가 false라고 해서 언제나 throw해야 하는 것은 아닙니다. 같은 404라도 앱의 의미에 따라 다르게 다룰 수 있습니다.
예를 들어 선택적인 사용자 설정을 불러오는 API가 있다고 하겠습니다. 설정이 없다는 404를 실패 화면으로 보여 주기보다 기본값을 쓰는 편이 자연스러울 수 있습니다.
async function getUserSettings() {
const response = await fetch("/api/settings");
if (response.status === 404) {
return {
theme: "light",
compactMode: false,
};
}
if (!response.ok) {
throw new HttpError("설정을 불러오지 못했습니다.", response.status);
}
return response.json();
}이 코드는 404를 실패가 아니라 기본값으로 해석합니다. 반면 500 같은 다른 실패는 여전히 throw합니다.
결국 핵심은 상태 코드 자체가 아니라 그 상태 코드를 이 화면에서 어떤 의미로 볼 것인가입니다. 없는 리소스를 정상적인 빈 상태로 볼 수도 있고, 사용자에게 알려야 할 실패로 볼 수도 있습니다.
JSON 파싱도 별도의 실패 지점입니다
response.ok를 확인했다고 해서 모든 처리가 끝나는 것은 아닙니다. 응답 본문을 읽는 과정도 실패할 수 있습니다.
async function getPost(id: string) {
const response = await fetch(`/api/posts/${id}`);
if (!response.ok) {
throw new HttpError("게시글을 불러오지 못했습니다.", response.status);
}
return response.json();
}이 코드에서 response.json()은 Promise를 반환합니다. 응답 본문이 JSON 형식이 아니거나, 본문을 읽는 중 문제가 생기면 rejected 상태가 될 수 있습니다.
그래서 getPost는 두 종류의 실패를 호출자에게 넘길 수 있습니다.
HTTP 상태를 보고 직접 던진 HttpError가 있을 수 있습니다. 또는 JSON 파싱 과정에서 발생한 에러가 있을 수 있습니다.
호출하는 쪽에서는 둘을 구분해도 되고, 같은 에러 UI로 묶어도 됩니다. 중요한 것은 둘 다 await getPost(id)의 rejected 흐름으로 들어온다는 점입니다.
공통 요청 함수에 실패 변환을 모을 수 있습니다
같은 패턴이 여러 API에 반복된다면 공통 요청 함수로 모을 수 있습니다.
async function requestJson<T>(url: string): Promise<T> {
const response = await fetch(url);
if (!response.ok) {
throw new HttpError("요청에 실패했습니다.", response.status);
}
return response.json() as Promise<T>;
}
async function getPost(id: string) {
return requestJson<Post>(`/api/posts/${id}`);
}
async function getComments(postId: string) {
return requestJson<Comment[]>(`/api/posts/${postId}/comments`);
}이 구조의 장점은 실패 변환 규칙이 한곳에 모인다는 것입니다. HTTP 실패를 언제 throw할지, 어떤 에러 타입으로 만들지, 어떤 상태 코드를 예외적으로 처리할지 결정하기 쉬워집니다.
다만 공통 함수가 모든 판단을 대신할 수는 없습니다. 어떤 404는 실패이고, 어떤 404는 빈 상태일 수 있습니다. 그래서 공통 함수는 기본 규칙을 맡고, 도메인별 예외는 각 API 함수에서 명시하는 편이 안전합니다.
async function getPostOrNull(id: string) {
const response = await fetch(`/api/posts/${id}`);
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new HttpError("게시글을 불러오지 못했습니다.", response.status);
}
return response.json();
}이름도 중요합니다. getPost가 실패하면 던지는 함수라면 호출자는 try/catch를 예상합니다. getPostOrNull처럼 없는 상태를 값으로 돌려주는 함수라면 호출자는 null 처리를 예상합니다.
UI 상태로 보면 throw는 rejected 상태를 만드는 일입니다
지금까지의 이야기를 UI 상태로 다시 보면 더 분명합니다.
type PostState =
| { status: "pending" }
| { status: "fulfilled"; post: Post }
| { status: "empty" }
| { status: "rejected"; message: string };
let state: PostState = { status: "pending" };
async function loadPost(id: string) {
state = { status: "pending" };
try {
const post = await getPostOrNull(id);
if (post === null) {
state = { status: "empty" };
return;
}
state = { status: "fulfilled", post };
} catch {
state = {
status: "rejected",
message: "게시글을 불러오지 못했습니다.",
};
}
}이 코드에서 404는 empty 상태로 이어집니다. 다른 HTTP 실패나 JSON 파싱 실패는 rejected 상태로 이어집니다.
이처럼 throw는 단순히 에러를 던지는 문법이 아닙니다. 비동기 UI에서는 특정 응답을 rejected 흐름으로 보내겠다는 설계입니다.
그래서 fetch 코드를 작성할 때는 아래 순서로 생각하는 편이 좋습니다.
- 서버 응답을 받았는지 확인합니다.
- HTTP 상태를 앱의 의미로 해석합니다.
- 값으로 다룰 상태는 반환합니다.
- 실패로 다룰 상태는 예외로 던집니다.
- 호출하는 쪽에서 fulfilled, empty, rejected 같은 UI 상태로 번역합니다.
마치며
fetch는 HTTP 404나 500을 자동으로 rejected Promise로 바꾸지 않습니다. 서버 응답을 받은 것과 앱에서 성공으로 볼 수 있는 것은 다른 문제입니다.
그래서 fetch를 사용할 때는 response.ok나 response.status를 확인하고, 앱에서 실패로 다룰 응답을 직접 throw해야 합니다. 이 작업은 UI 컴포넌트 곳곳보다 응답을 해석하는 API 함수나 공통 요청 함수 안에 두는 편이 좋습니다.
결국 기준은 하나입니다.
fetch 에러 처리의 핵심은 catch를 많이 쓰는 것이 아닙니다.
HTTP 응답을 어떤 UI 상태로 번역할지 정하고, 실패로 볼 응답을 그 경계에서 throw하는 것
입니다.
다음 글에서는 이 흐름이 React Query 같은 라이브러리 안에서 어떻게 UI 상태로 바뀌는지 보겠습니다. query function이 rejected Promise를 반환할 때 왜 isError가 되고, fulfilled Promise를 반환할 때 왜 data가 되는지 연결해 보겠습니다.