Devin.KR
로그인

자바스크립트 fetch 와 JSON - HTTP 에러 처리, CORS 가 막는 것 (JS 중급 16단원)

개발자 조회 1

이 단원에서 배우는 것

중급 커리큘럼의 마지막 단원이다. 12·13단원에서 배운 Promise 와 async/await, 14단원의 배열 처리, 15단원의 모듈이 여기서 하나로 합쳐진다. 지금까지 findBooksetTimeout 으로 흉내 낸 가짜였다. 이번에 진짜 서버 호출로 바꾼다. 그리고 브라우저에서 처음 실행하는 순간 만나는 벽 — CORS — 이 무엇을 막는 것이고 어디서 풀어야 하는지 정리한다. fetch 는 브라우저에서는 오래전부터, Node 에서는 18 부터 표준으로 들어와 지금은 양쪽에서 같은 코드가 돈다.

  • fetch 의 응답을 올바르게 판정하고, HTTP 오류와 네트워크 오류를 구분해 처리한다.
  • JSON 직렬화가 조용히 버리는 값들을 알고, 날짜·undefined·큰 정수를 안전하게 주고받는다.
  • CORS 가 무엇을 막고 무엇을 막지 않는지 판별하고, 프런트에서 고칠 수 있는 것과 없는 것을 나눈다.

왜 필요한가

fetch 는 배우기 쉬워 보인다. 두 줄이면 끝난다.

const res = await fetch('/api/books/B001');
const book = await res.json();

그리고 이 두 줄은 실무에서 거의 항상 잘못됐다. 서버가 404 를 돌려줘도, 500 을 돌려줘도 이 코드는 예외를 던지지 않는다. 그다음 res.json() 이 에러 응답 본문을 파싱해 book 에 담고, 화면에는 "제목: undefined" 가 뜬다. 사용자는 무엇이 잘못됐는지 알 수 없고, 개발자는 로그에서 아무 흔적도 못 찾는다.

이유는 fetch 의 설계에 있다. fetchHTTP 응답을 받는 데 성공했는가만 본다. 404 든 500 든 서버가 응답을 돌려줬으면 그건 성공이다. reject 되는 경우는 응답을 아예 못 받았을 때 — 네트워크 단절, DNS 실패, CORS 차단, 요청 취소 — 뿐이다.

그래서 fetch 를 날것으로 쓰는 코드는 프로젝트에 하나도 없어야 한다. 감싸는 함수를 하나 만들고 전부 그것을 쓴다. 이 단원의 절반은 그 함수를 만드는 이야기다.

문법과 예제

fetch 의 기본 형태

// GET
const res = await fetch('https://api.example.com/books/B001', {
  headers: { Accept: 'application/json' }
});

console.log(res.ok);        // 2xx 이면 true
console.log(res.status);    // 200, 404, 500 ...
console.log(res.headers.get('content-type'));

// POST — body 는 반드시 문자열로 만들고 Content-Type 을 붙인다
const created = await fetch('https://api.example.com/loans', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ bookId: 'B001', memberId: 'M01' })
});

본문을 읽는 메서드는 json(), text(), blob(), arrayBuffer(), formData() 다. 전부 Promise 를 반환한다. 응답이 도착했다고 본문이 다 온 게 아니라, 헤더만 먼저 오고 본문은 스트림으로 흘러오기 때문이다.

제대로 감싼 요청 함수

// api/client.js
export class ApiError extends Error {
  constructor(status, code, message, body) {
    super(message);
    this.name = 'ApiError';
    this.status = status;   // 404, 500 ...
    this.code = code;       // 서버가 준 에러 코드
    this.body = body;       // 원본 응답 (디버깅용)
  }
}

const BASE_URL = 'https://api.example.com';

export async function requestJson(path, options = {}) {
  const { timeoutMs = 10000, ...rest } = options;

  let res;
  try {
    res = await fetch(BASE_URL + path, {
      ...rest,
      headers: { Accept: 'application/json', ...rest.headers },
      signal: rest.signal ?? AbortSignal.timeout(timeoutMs)
    });
  } catch (err) {
    // 여기 오는 것은 네트워크 실패 · CORS 차단 · 타임아웃뿐이다
    if (err.name === 'TimeoutError') throw new ApiError(0, 'TIMEOUT', `응답 없음(${timeoutMs}ms)`);
    if (err.name === 'AbortError')   throw new ApiError(0, 'ABORTED', '요청이 취소됨');
    throw new ApiError(0, 'NETWORK', '서버에 연결할 수 없다');
  }

  const text = await res.text();          // json() 대신 text() 로 먼저 받는다
  let data = null;
  if (text) {
    try {
      data = JSON.parse(text);
    } catch {
      throw new ApiError(res.status, 'INVALID_JSON', `JSON 이 아닌 응답: ${text.slice(0, 80)}`, text);
    }
  }

  if (!res.ok) {
    throw new ApiError(res.status, data?.code ?? 'HTTP_ERROR', data?.message ?? `HTTP ${res.status}`, data);
  }
  return data;
}

설계 의도를 하나씩 본다.

  • res.ok 검사가 있다. 없으면 앞에서 말한 "제목: undefined" 가 난다.
  • json() 대신 text() 로 받고 직접 파싱한다. 서버가 502 를 내면 본문은 대개 nginx 의 HTML 이다. res.json() 은 여기서 SyntaxError: Unexpected token '<' 를 던지는데, 이 메시지만으로는 원인을 알 수 없다. text() 로 받아 두면 실제 응답을 로그에 남길 수 있다. 본문이 빈 204 응답에서 res.json() 이 터지는 문제도 함께 해결된다.
  • 모든 실패를 ApiError 한 종류로 정규화한다. 호출하는 쪽이 err.status === 404 하나로 분기할 수 있다. 11단원에서 배운 클래스 상속을 실제로 쓰는 자리다.
  • 타임아웃이 기본으로 걸린다. fetch 에는 타임아웃이 없다. 서버가 응답을 안 주면 영원히 기다린다. AbortSignal.timeout() 은 Node 18+/Chrome 103+/Safari 16 에서 쓸 수 있다.
// 쓰는 쪽
import { requestJson, ApiError } from './api/client.js';

export function findBook(bookId) {
  return requestJson(`/books/${encodeURIComponent(bookId)}`);
}

export function saveLoan(bookId, memberId) {
  return requestJson('/loans', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ bookId, memberId })
  });
}

try {
  const book = await findBook('B001');
  console.log(book.title);
} catch (err) {
  if (err instanceof ApiError && err.status === 404) showMessage('없는 도서입니다');
  else if (err.code === 'TIMEOUT')                     showMessage('네트워크가 느립니다');
  else                                                 showMessage('잠시 후 다시 시도해 주세요');
}

encodeURIComponent 를 빠뜨리지 않는다. bookId/? 가 들어가면 경로가 통째로 바뀐다. 쿼리 문자열이라면 URLSearchParams 를 쓴다.

const params = new URLSearchParams({ category: '설계', minStock: 1 });
await requestJson(`/books?${params}`);   // /books?category=%EC%84%A4%EA%B3%84&minStock=1

취소 — AbortController

검색어를 입력할 때마다 요청을 보내면, 이전 요청의 응답이 나중에 도착해 최신 결과를 덮어쓰는 일이 생긴다. 이걸 막으려면 이전 요청을 취소한다.

let controller = null;

async function searchBooks(keyword) {
  controller?.abort();                 // 진행 중인 이전 요청을 취소
  controller = new AbortController();

  try {
    return await requestJson(`/books?q=${encodeURIComponent(keyword)}`, {
      signal: controller.signal
    });
  } catch (err) {
    if (err.code === 'ABORTED') return null;   // 취소는 오류가 아니다
    throw err;
  }
}

13단원에서 "Promise.all 이 실패해도 나머지가 취소되지 않는다"고 했던 문제의 해답이 이것이다. AbortController 하나를 여러 fetch 에 넘기면 abort() 한 번으로 전부 중단된다.

JSON 을 주고받을 때 사라지는 것들

console.log(JSON.stringify({
  a: undefined,
  b: () => 1,
  c: new Date('2026-08-25T00:00:00Z'),
  d: NaN,
  e: [undefined, () => 1]
}));
// {"c":"2026-08-25T00:00:00.000Z","d":null,"e":[null,null]}

정리하면 이렇다.

  • undefined 와 함수는 객체에서 키째로 사라진다. 배열 안에서는 null 로 바뀐다. "필드를 지우려고 undefined 를 넣었는데 서버에 안 갔다"가 여기서 나온다. 명시적으로 지우려면 null 을 보낸다.
  • NaNInfinitynull 이 된다. 나눗셈 결과를 그대로 보내면 조용히 null 이 된다.
  • Date 는 ISO 문자열이 된다. 되돌려 받으면 문자열이다. JSON.parse 가 알아서 Date 로 만들어 주지 않는다.
  • BigIntTypeError 를 던진다. 순환 참조도 마찬가지다.

날짜는 reviver 로 되살릴 수 있다.

const raw = '{"loanId":"L2026-0001","dueAt":"2026-09-08T00:00:00.000Z"}';

const loan = JSON.parse(raw, (key, value) =>
  key.endsWith('At') && typeof value === 'string' ? new Date(value) : value
);

console.log(loan.dueAt.getFullYear());   // 2026

다만 이 변환을 전역 규칙으로 두면 예상 못 한 필드까지 Date 가 된다. 필드를 아는 자리에서 명시적으로 바꾸는 편이 낫다. 11단원에서 본 toJSON() / static fromJSON() 짝을 도메인 객체에 두는 방식이 그것이다.

큰 정수는 별도 주의가 필요하다. 자바스크립트 숫자는 2^53 - 1(약 9,007조)까지만 정확하다. 서버가 64비트 정수 ID 를 보내면 JSON.parse 단계에서 이미 값이 뭉개진다. ID 는 처음부터 문자열로 주고받도록 API 를 설계한다. 이건 프런트에서 고칠 수 없는 문제다.

실무에서 자주 틀리는 것

1. res.ok 를 확인하지 않는다

// 잘못
const book = await (await fetch('/api/books/B999')).json();
console.log(book.title);   // undefined — 404 응답 본문을 파싱했을 뿐이다

// 확인
const res = await fetch('/api/books/B999');
console.log(res.ok, res.status);   // false 404  ← 예외는 나지 않았다

가장 흔하고 가장 오래 숨는 버그다. catch 블록을 아무리 잘 써도 애초에 던지지 않으니 잡히지 않는다. 앞에서 만든 requestJson 같은 래퍼를 두고, 코드 리뷰에서 날 fetch 호출을 금지하는 것이 실질적인 대책이다.

2. 응답 본문을 두 번 읽는다

const res = await fetch('/api/books/B001');

if (!res.ok) {
  const err = await res.json();       // 여기서 본문을 소비했다
  console.error(err);
}
const book = await res.json();
// TypeError: Body is unusable: Body has already been read

응답 본문은 스트림이라 한 번만 읽을 수 있다. 로깅하려고 text() 를 부른 뒤 json() 을 부르는 패턴에서 자주 난다. 해결책은 두 가지다. 한 번만 읽고 그 결과를 재사용하거나(앞의 requestJson 이 이 방식이다), 정말 두 번 필요하면 res.clone() 으로 복제한다. clone() 은 본문을 메모리에 버퍼링하므로 큰 응답에서는 피한다.

3. CORS 를 프런트엔드 코드로 고치려 한다

Access to fetch at 'https://api.other.com/books' from origin 'https://app.example.com'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present
on the requested resource.

먼저 사실 관계를 정리한다.

  • CORS 는 브라우저가 거는 제약이다. Node, curl, Postman 에는 없다. "curl 로는 되는데 브라우저에서만 안 된다"면 100% CORS 다.
  • 요청은 실제로 서버에 도착했다. 브라우저가 응답을 자바스크립트에 넘겨주지 않았을 뿐이다. POST 라면 데이터는 이미 저장됐을 수 있다.
  • 허용 여부는 응답 헤더로 서버가 결정한다. Access-Control-Allow-Origin 이 그것이다. 요청하는 쪽 코드로는 아무것도 바꿀 수 없다.

그래서 mode: 'no-cors' 는 해결책이 아니다. 그걸 붙이면 에러는 사라지지만 응답이 opaque 가 되어 본문도 상태 코드도 읽을 수 없다. 검색해서 나온 이 옵션을 붙이고 "에러는 없어졌는데 데이터가 안 온다"고 하는 경우가 많다.

실제 해결 경로는 셋뿐이다.

  1. API 서버에 Access-Control-Allow-Origin 을 추가한다. 서버가 우리 것이면 이게 정답이다.
  2. 같은 출처로 만든다. 개발 중에는 번들러의 프록시 설정(Vite 의 server.proxy 등), 운영에서는 리버스 프록시로 /api 를 같은 도메인 아래 붙인다.
  3. 우리 서버가 대신 호출한다(백엔드 프록시). 남의 API 이고 CORS 헤더를 안 주면 이 방법뿐이다.

preflight 도 알아 둔다. Content-Type: application/json 을 붙이거나 커스텀 헤더(Authorization 등)를 넣으면, 브라우저가 본 요청 전에 OPTIONS 요청을 먼저 보낸다. 서버가 OPTIONS 를 처리하지 않으면 본 요청은 나가지도 않는다. 네트워크 탭에서 OPTIONS 가 실패했다면 서버 라우팅 문제다.

쿠키를 보내려면 credentials: 'include' 가 필요하고, 이때 서버는 Access-Control-Allow-Origin* 를 쓸 수 없다. 구체적인 출처를 적고 Access-Control-Allow-Credentials: true 를 함께 줘야 한다.

4. Content-Type 을 빠뜨리거나 잘못 붙인다

// 서버가 body 를 못 읽는다 — Content-Type 이 없으면 text/plain 으로 취급된다
await fetch('/api/loans', { method: 'POST', body: JSON.stringify(data) });

// 정상
await fetch('/api/loans', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(data)
});

반대로 FormData 를 보낼 때는 Content-Type 을 직접 붙이면 안 된다. multipart 는 본문에 경계 문자열(boundary)이 들어가고, 그 값은 브라우저가 만든다. 손으로 multipart/form-data 만 적으면 boundary 가 빠져 서버가 파싱하지 못한다.

const form = new FormData();
form.append('cover', fileInput.files[0]);
await fetch('/api/books/B001/cover', { method: 'POST', body: form });   // 헤더를 건드리지 않는다

5. 목록 요청을 하나씩 순차로 보낸다

// 도서 20권의 상세를 가져오는데 한 건에 80ms 라면 1.6초
const details = [];
for (const id of bookIds) {
  details.push(await findBook(id));
}

13단원의 내용이 그대로 적용된다. 서로 무관한 요청이면 Promise.all 로 묶는다. 다만 브라우저는 같은 출처에 대해 동시 연결을 6개 정도로 제한하므로(HTTP/1.1 기준), 100건을 한꺼번에 보내도 실제로는 줄을 선다. 서버 부하까지 고려하면 13단원에서 만든 mapWithLimit 같은 동시 실행 제한이 현실적이다.

더 나은 답은 대개 API 를 고치는 것이다. /books?ids=B001,B002,... 처럼 한 번에 받는 엔드포인트가 있으면 요청 하나로 끝난다. 프런트에서 반복문을 줄이는 것보다 왕복 횟수를 줄이는 쪽이 훨씬 크게 이긴다.

스스로 확인하기

  1. 다음 코드는 서버가 500 을 반환할 때 어떤 일이 일어나는가. 세 단계로 나눠 설명하라.
    async function loadBook(id) {
      try {
        const res = await fetch(`/api/books/${id}`);
        const book = await res.json();
        return book.title;
      } catch (e) {
        console.error('조회 실패', e);
        return null;
      }
    }
    서버가 Content-Type: text/html 로 nginx 오류 페이지를 돌려준 경우도 함께 답하라.
  2. 대출 신청 폼을 보내는 함수를 작성하라. 조건은 (1) 5초 안에 응답이 없으면 취소, (2) 409 응답이면 '이미 대출 중입니다' 를 던지고 그 외 HTTP 오류는 '신청에 실패했습니다', (3) 네트워크 자체가 끊긴 경우는 '연결을 확인해 주세요'. 이 단원의 requestJson 을 써도 되고 fetch 를 직접 써도 된다.
  3. 브라우저 콘솔에 아래 오류가 떴다. 확인해야 할 것 세 가지를 순서대로 쓰고, mode: 'no-cors' 를 붙이면 왜 안 되는지 설명하라.
    Access to fetch at 'https://api.lib.example.com/loans' from origin 'https://app.lib.example.com'
    has been blocked by CORS policy: Response to preflight request doesn't pass access control check.

정답

  1. 세 단계는 이렇다.
    • fetch정상적으로 resolve 된다. 500 도 응답이므로 res.okfalse, res.status 가 500 일 뿐 예외는 없다.
    • 서버가 JSON 형태의 에러 본문({"code":"INTERNAL"} 등)을 줬다면 res.json() 도 성공한다. book 에는 그 에러 객체가 담기고 book.titleundefined 다.
    • 함수는 undefined 를 반환한다. catch 는 실행되지 않고 로그도 남지 않는다. 화면에는 빈 제목이 표시된다.
    nginx 가 HTML 을 돌려준 경우에는 res.json()SyntaxError 를 던지므로 catch 가 잡고 null 을 반환한다. 다만 로그에 남는 메시지는 Unexpected token '<', "<html>..." is not valid JSON 이라 진짜 원인(서버 500)이 드러나지 않는다. 두 경우 모두 res.ok 검사가 빠진 것이 근본 원인이다.
  2. export async function applyLoan(bookId, memberId) {
      let res;
      try {
        res = await fetch('/api/loans', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ bookId, memberId }),
          signal: AbortSignal.timeout(5000)
        });
      } catch (err) {
        if (err.name === 'TimeoutError') throw new Error('연결을 확인해 주세요');
        throw new Error('연결을 확인해 주세요');
      }
    
      if (res.status === 409) throw new Error('이미 대출 중입니다');
      if (!res.ok)            throw new Error('신청에 실패했습니다');
    
      return res.json();
    }
    핵심은 fetch 를 감싼 try네트워크 실패만 담당하고, HTTP 상태 판정은 그 밖에서 한다는 점이다. res.json() 까지 try 안에 넣으면 파싱 오류가 네트워크 오류로 둔갑한다.
  3. 확인 순서:
    1. 네트워크 탭에서 OPTIONS 요청의 응답을 본다. preflight 단계에서 막혔다는 뜻이므로 본 요청은 아직 나가지도 않았다. OPTIONS 가 404·405 라면 서버가 이 메서드를 라우팅하지 않는 것이다.
    2. OPTIONS 응답 헤더Access-Control-Allow-Origin(요청 출처와 정확히 일치해야 한다. 끝의 / 유무, http/https, 포트까지), Access-Control-Allow-MethodsPOST, Access-Control-Allow-Headers 에 우리가 보낸 Content-Type·Authorization 이 들어 있는지 본다.
    3. 쿠키를 쓴다면 credentials: 'include' 와 서버의 Access-Control-Allow-Credentials: true 가 짝을 이루는지, 그리고 이때 Allow-Origin* 가 아닌지 확인한다.
    mode: 'no-cors' 를 붙이면 브라우저는 요청을 "단순 요청"으로 낮춰 보내고 응답을 opaque 로 만든다. 콘솔 에러는 사라지지만 res.status 는 0, res.json() 은 빈 본문에서 실패한다. 게다가 Content-Type: application/json 도 못 붙이므로 서버가 본문을 파싱하지 못한다. 에러 메시지만 없애고 기능은 완전히 잃는 선택이라, 응답을 읽어야 하는 요청에서는 절대 쓰지 않는다.

중급 커리큘럼을 마치며

9단원부터 여기까지, 하나의 도서 대출 서비스를 붙들고 여덟 단원을 왔다. 클로저로 상태를 감췄고(9), this 가 호출부로 정해진다는 예외를 확인했고(10), class 아래의 프로토타입 체인을 봤고(11), 콜백을 Promise 로 폈고(12), async/await 로 읽기 좋게 만들면서 병렬과 순차를 구분했고(13), 배열 처리를 선언적으로 바꾸되 비용을 측정했고(14), 파일을 나누고 의존 방향을 정했고(15), 마지막으로 진짜 서버에 요청을 보냈다(16).

공통점이 하나 있다. 여덟 단원 모두 "동작은 하는데 왜 그런지 모르는 상태"에서 "왜 그런지 알고 고를 수 있는 상태"로 옮기는 것이었다. 고급 과정에서는 여기서 한 걸음 더 나가 이벤트 루프의 세부, 제너레이터와 이터레이터, 프록시와 리플렉션, 그리고 타입스크립트로 이 모든 것을 정적으로 검증하는 방법을 다룬다.