본문으로 건너뛰기
개발 머꼬
개발 노트JavaScript
hohyeon.dev44

fetch가 502를 받았는데 네트워크 오류 안내가 뜬 이유

  • #Common Pitfall
  • #Engineering Note
  • #JavaScript

문제 발생

주문 내역을 불러오는 평범한 코드였습니다.

async function loadOrders() {
  try {
    const res = await fetch('/api/orders');
    const data = await res.json();
    renderOrders(data.items);
  } catch (e) {
    showToast('네트워크 연결을 확인해 주세요.');
  }
}

배포하던 몇십 초 사이에 문의가 몰렸습니다. 와이파이도 멀쩡한데 왜 네트워크를 확인하라고 하냐는 내용이었습니다. 에러 수집 도구를 열어 보니 네트워크 오류는 한 건도 없었고, 대신 이런 게 쌓여 있었습니다.

SyntaxError: Unexpected token '<', "<html>
<h"... is not valid JSON

API 서버가 재시작되는 동안 앞단의 nginx가 502 Bad Gateway HTML 페이지를 돌려줬고, 코드는 그 HTML을 JSON으로 읽으려다 실패한 겁니다. 이게 끝이 아니었습니다. 로그인 토큰이 만료된 사용자도 똑같이 "네트워크 연결을 확인해 주세요"를 보고 있었습니다. 이쪽 로그는 TypeError: Cannot read properties of undefined (reading 'map')였습니다. 서버는 401과 함께 {"message":"토큰이 만료되었습니다"}를 정확히 보냈는데, 로그인 화면으로 보내 주기는커녕 엉뚱한 안내만 띄운 셈입니다. 원인은 서로 다른데 사용자에게 보이는 건 같은 문구 하나라서, 처음엔 어디부터 봐야 할지 감이 안 왔습니다.

원인 분석

fetch는 서버가 에러 상태 코드를 보내도 실패하지 않습니다. MDN의 fetch() 문서는 fetch() 프로미스가 요청 자체가 실패했을 때만, 예를 들어 URL이 잘못됐거나 네트워크 오류가 났을 때만 reject된다고 적습니다. 서버가 404나 504처럼 에러를 뜻하는 상태 코드로 응답해도 reject하지 않으니 Response.ok나 Response.status를 직접 확인해야 한다고요. 502든 401이든 서버가 응답을 보낸 이상 await fetch()는 멀쩡한 Response를 돌려주고, 코드는 아무 일 없다는 듯 다음 줄로 넘어갑니다.

그다음 res.json()이 본문을 무조건 JSON으로 읽습니다. Response.json() 문서에 따르면 본문을 JSON으로 파싱할 수 없을 때 SyntaxError를 던집니다. nginx의 502 페이지는 <html>로 시작하니 첫 글자 <에서 바로 실패합니다. 그래서 Unexpected token '<'가 보이면 거의 항상 "JSON 대신 HTML을 받았다"는 뜻입니다. 401은 반대로 본문이 진짜 JSON이라 파싱은 성공했고, data.items가 undefined라서 그다음 줄에서 터졌습니다.

마지막으로 catch 하나가 전부를 한 바구니에 담았습니다. 진짜 네트워크 오류(TypeError), HTTP 에러 뒤에 따라온 파싱 실패(SyntaxError), 렌더링 코드에서 난 버그(TypeError)가 모두 같은 catch로 들어와 같은 문구가 됐습니다. Node 22로 재현해 보면 서버에 아예 연결하지 못했을 때는 TypeError: fetch failed가 나는데, 이번 사고에서 이 에러는 한 번도 없었습니다. 네트워크를 확인하라는 안내가 맞았던 경우가 0건이었던 겁니다.

해결 방안

  1. res.ok부터 확인하고, 아니면 직접 에러를 던집니다. MDN 예제도 !response.ok면 상태 코드를 담아 에러를 던지는 방식입니다. ok는 상태 코드가 200~299일 때만 true입니다. 여기에 더해 fetch 자체가 실패한 경우도 따로 이름을 붙여 둡니다. 그래야 나중에 catch에서 둘을 구분할 수 있습니다.
class HttpError extends Error {
  constructor(status, body) {
    super(`HTTP ${status}`);
    this.name = 'HttpError';
    this.status = status;
    this.body = body;
  }
}

class NetworkError extends Error {
  constructor(cause) {
    super('network', { cause });
    this.name = 'NetworkError';
  }
}

async function request(url, init) {
  let res;
  try {
    res = await fetch(url, init);
  } catch (cause) {
    throw new NetworkError(cause);   // 응답 자체를 못 받은 경우만 여기로
  }
  if (!res.ok) {
    throw new HttpError(res.status, await res.text());
  }
  return res;
}

에러 본문을 json()이 아니라 text()로 읽은 건 일부러입니다. 에러 응답은 우리 서버가 만든 JSON일 수도, 프록시가 만든 HTML일 수도 있으니 일단 문자열로 받아 두는 쪽이 안전합니다. AbortSignal.timeout()으로 시간 제한을 걸었다면 MDN 문서대로 TimeoutError로 중단되는데, 이것도 NetworkError의 cause로 들어오니 cause.name으로 한 번 더 나누면 됩니다.

  1. 본문은 한 번만 읽을 수 있으니, text()로 읽고 직접 파싱합니다. 처음에 고치다가 한 번 더 막힌 부분입니다. json()이 실패하면 catch에서 text()로 원문을 찍어 보려 했는데, 이렇게 나왔습니다.
TypeError: Body is unusable: Body has already been read

Response.json() 문서는 본문이 이미 소비됐거나 잠겨 있으면 TypeError를 던진다고 적습니다. clone() 문서는 본문 객체가 한 번만 쓸 수 있는 것이라서 그걸 여러 번 쓰게 해 주는 게 clone()이 존재하는 주된 이유라고 설명합니다. 다만 clone()도 본문을 이미 읽은 뒤에 부르면 TypeError라서, 읽기 전에 복제해 둬야 합니다. 저는 복제 대신 text()로 한 번만 읽고 JSON.parse를 직접 부르는 쪽을 택했습니다. 파싱이 실패해도 원문이 손에 남아서 로그에 그대로 남길 수 있습니다.

async function readJson(res) {
  if (res.status === 204) return null;
  const raw = await res.text();
  if (raw === '') return null;
  try {
    return JSON.parse(raw);
  } catch {
    throw new Error(`JSON 파싱 실패: ${raw.slice(0, 100)}`);
  }
}
  1. 204처럼 본문이 없는 성공 응답을 빼먹지 않습니다. 삭제 API가 204 No Content를 돌려주면 ok는 true인데, res.json()은 SyntaxError: Unexpected end of JSON input으로 실패합니다. ok 검사만 넣고 끝내면 이번엔 성공한 요청이 에러로 보입니다. 위의 readJson에서 204와 빈 본문을 먼저 걸러 null을 돌려주는 이유가 이겁니다.

  2. 상태 코드가 200이어도 HTML이 올 수 있습니다. 정적 파일 서버가 모르는 경로를 전부 index.html로 돌려주게 설정돼 있으면, API 주소를 잘못 적어도 200 OK에 HTML이 옵니다. ok 검사는 통과하고 파싱에서 <!DOCTYPE을 만나 실패합니다. readJson이 원문 앞부분을 에러 메시지에 넣어 두면 이런 경우가 로그에서 바로 보입니다. 더 일찍 막고 싶으면 파싱 전에 res.headers.get('content-type')에 json이 들어 있는지 확인합니다.

  3. catch에서는 에러 종류별로 안내를 나누고, 모르는 에러는 숨기지 않습니다. 1~4번을 거치면 에러마다 이름이 붙어서, 그제야 사용자에게 맞는 말을 할 수 있습니다.

try {
  const res = await request('/api/orders');
  const data = await readJson(res);
  renderOrders(data.items);
} catch (e) {
  if (e instanceof NetworkError) {
    showToast('네트워크 연결을 확인해 주세요.');
  } else if (e instanceof HttpError && e.status === 401) {
    redirectToLogin();
  } else if (e instanceof HttpError && e.status >= 500) {
    showToast('서버가 잠시 응답하지 않습니다. 잠시 후 다시 시도해 주세요.');
  } else {
    showToast('문제가 발생했습니다.');
    throw e;   // 렌더링 버그 같은 건 에러 수집 도구까지 올라가게 둔다
  }
}

마지막 else가 중요합니다. 이번 사고에서 data.items의 TypeError는 버그였는데, 네트워크 안내 뒤에 묻혀서 아무도 버그로 보지 않았습니다. 예상한 에러만 골라 처리하고 나머지는 다시 던져야 다음 사고가 덜 조용합니다.

공식 문서

마지막 수정

좋아요북마크

댓글0

아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.