자바스크립트 fetch 와 JSON - HTTP 에러 처리, CORS 가 막는 것 (JS 중급 16단원)
이 단원에서 배우는 것
중급 커리큘럼의 마지막 단원이다. 12·13단원에서 배운 Promise 와 async/await, 14단원의 배열 처리, 15단원의 모듈이 여기서 하나로 합쳐진다. 지금까지 findBook 은 setTimeout 으로 흉내 낸 가짜였다. 이번에 진짜 서버 호출로 바꾼다. 그리고 브라우저에서 처음 실행하는 순간 만나는 벽 — 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 의 설계에 있다. fetch 는 HTTP 응답을 받는 데 성공했는가만 본다. 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을 보낸다.NaN과Infinity는null이 된다. 나눗셈 결과를 그대로 보내면 조용히null이 된다.Date는 ISO 문자열이 된다. 되돌려 받으면 문자열이다.JSON.parse가 알아서Date로 만들어 주지 않는다.BigInt는TypeError를 던진다. 순환 참조도 마찬가지다.
날짜는 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 가 되어 본문도 상태 코드도 읽을 수 없다. 검색해서 나온 이 옵션을 붙이고 "에러는 없어졌는데 데이터가 안 온다"고 하는 경우가 많다.
실제 해결 경로는 셋뿐이다.
- API 서버에
Access-Control-Allow-Origin을 추가한다. 서버가 우리 것이면 이게 정답이다. - 같은 출처로 만든다. 개발 중에는 번들러의 프록시 설정(Vite 의
server.proxy등), 운영에서는 리버스 프록시로/api를 같은 도메인 아래 붙인다. - 우리 서버가 대신 호출한다(백엔드 프록시). 남의 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,... 처럼 한 번에 받는 엔드포인트가 있으면 요청 하나로 끝난다. 프런트에서 반복문을 줄이는 것보다 왕복 횟수를 줄이는 쪽이 훨씬 크게 이긴다.
스스로 확인하기
- 다음 코드는 서버가 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 오류 페이지를 돌려준 경우도 함께 답하라. - 대출 신청 폼을 보내는 함수를 작성하라. 조건은 (1) 5초 안에 응답이 없으면 취소, (2) 409 응답이면
'이미 대출 중입니다'를 던지고 그 외 HTTP 오류는'신청에 실패했습니다', (3) 네트워크 자체가 끊긴 경우는'연결을 확인해 주세요'. 이 단원의requestJson을 써도 되고fetch를 직접 써도 된다. - 브라우저 콘솔에 아래 오류가 떴다. 확인해야 할 것 세 가지를 순서대로 쓰고,
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.
정답
- 세 단계는 이렇다.
fetch는 정상적으로 resolve 된다. 500 도 응답이므로res.ok가false,res.status가 500 일 뿐 예외는 없다.- 서버가 JSON 형태의 에러 본문(
{"code":"INTERNAL"}등)을 줬다면res.json()도 성공한다.book에는 그 에러 객체가 담기고book.title은undefined다. - 함수는
undefined를 반환한다.catch는 실행되지 않고 로그도 남지 않는다. 화면에는 빈 제목이 표시된다.
res.json()이SyntaxError를 던지므로catch가 잡고null을 반환한다. 다만 로그에 남는 메시지는Unexpected token '<', "<html>..." is not valid JSON이라 진짜 원인(서버 500)이 드러나지 않는다. 두 경우 모두res.ok검사가 빠진 것이 근본 원인이다.
핵심은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안에 넣으면 파싱 오류가 네트워크 오류로 둔갑한다.- 확인 순서:
- 네트워크 탭에서
OPTIONS요청의 응답을 본다. preflight 단계에서 막혔다는 뜻이므로 본 요청은 아직 나가지도 않았다.OPTIONS가 404·405 라면 서버가 이 메서드를 라우팅하지 않는 것이다. OPTIONS응답 헤더에Access-Control-Allow-Origin(요청 출처와 정확히 일치해야 한다. 끝의/유무, http/https, 포트까지),Access-Control-Allow-Methods에POST,Access-Control-Allow-Headers에 우리가 보낸Content-Type·Authorization이 들어 있는지 본다.- 쿠키를 쓴다면
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).
공통점이 하나 있다. 여덟 단원 모두 "동작은 하는데 왜 그런지 모르는 상태"에서 "왜 그런지 알고 고를 수 있는 상태"로 옮기는 것이었다. 고급 과정에서는 여기서 한 걸음 더 나가 이벤트 루프의 세부, 제너레이터와 이터레이터, 프록시와 리플렉션, 그리고 타입스크립트로 이 모든 것을 정적으로 검증하는 방법을 다룬다.