이 글에서 정리하는 내용
Promise 상태와 async/await의 관계를 이해하고, 로컬 HTTP 서버에 fetch를 보내 로딩·성공·HTTP 오류·JSON 오류·네트워크 오류를 재현합니다.
먼저 읽기: JavaScript 함수와 객체, import export로 모듈 나누기
Promise 상태와 async await의 관계
JavaScript fetch 오류 처리를 이해하려면 Promise부터 구분해야 합니다. Promise는 비동기 작업의 결과를 나타내며 처음에는 pending이고, 성공 값으로 fulfilled되거나 실패 이유로 rejected됩니다. 한 번 settled되면 fulfilled와 rejected 사이를 다시 오가지 않습니다.
async 함수는 항상 Promise를 반환합니다. 함수 안의 await는 해당 Promise가 처리될 때까지 그 함수의 다음 줄을 미루지만 프로그램 전체를 멈추지는 않습니다. rejected Promise를 await하면 예외처럼 전달되므로 try/catch에서 다룰 수 있습니다.

fetch에서 네트워크 오류와 HTTP 오류 구분하기
fetch()는 응답 헤더를 받으면 Response로 이행합니다. 서버가 404나 500을 보냈다는 이유만으로 Promise가 자동 rejected되지는 않습니다. 따라서 response.ok를 확인하고 false이면 상태 코드를 포함한 Error를 직접 던집니다.
서버에 연결하지 못하거나 요청이 중단되는 상황은 fetch 자체가 rejected되어 catch로 갑니다. 반면 200 응답이어도 본문이 잘못된 JSON이면 response.json()에서 실패합니다. 요청, HTTP 상태, 본문 파싱은 서로 다른 실패 지점입니다.
외부 API 없이 오류를 만드는 로컬 서버
공개 API는 상태와 데이터가 바뀔 수 있으므로 학습 결과가 흔들립니다. 아래 서버는 운영체제가 고른 빈 포트에서 시작하며 정상 JSON, 404 JSON, 깨진 JSON을 항상 같은 방식으로 반환합니다.
server.mjs
실습 파일
파일 경로를 확인하고 같은 프로젝트 안에 저장하세요. 이미지 등 소스 목록에 없는 파일과 실행 안내는 실습 ZIP에 포함되어 있습니다.
전체 코드
client.mjs
const baseUrl = process.argv[2];
async function load(path) {
console.log(`로딩: ${path}`);
try {
const response = await fetch(`${baseUrl}${path}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
console.log(`성공: ${data.title}`);
} catch (error) {
console.log(`실패: ${error.message}`);
}
}
await load('/todos/1');
await load('/missing');
await load('/broken');
server.mjs
import http from 'node:http';
const server = http.createServer((request, response) => {
response.setHeader('Content-Type', 'application/json; charset=utf-8');
if (request.url === '/todos/1') {
response.writeHead(200);
response.end(JSON.stringify({ id: 1, title: '문서 읽기' }));
} else if (request.url === '/broken') {
response.writeHead(200);
response.end('{"title":');
} else {
response.writeHead(404);
response.end(JSON.stringify({ message: '찾을 수 없음' }));
}
});
server.listen(0, '127.0.0.1', () => {
const { port } = server.address();
console.log(`READY http://127.0.0.1:${port}`);
});
process.on('SIGTERM', () => server.close());
solution.mjs
export class RequestError extends Error {
constructor(kind, message, options = {}) {
super(message, { cause: options.cause });
this.name = 'RequestError';
this.kind = kind;
this.status = options.status;
}
}
export async function loadTodo(url, { signal, request = fetch } = {}) {
let response;
try {
response = await request(url, { signal });
} catch (cause) {
throw new RequestError(signal?.aborted ? 'aborted' : 'request', '요청 단계 실패', {
cause,
});
}
if (!response.ok) {
throw new RequestError('http', `HTTP ${response.status}`, {
status: response.status,
});
}
let text;
try {
text = await response.text();
} catch (cause) {
throw new RequestError(signal?.aborted ? 'aborted' : 'body', '본문 읽기 실패', {
cause,
});
}
let data;
try {
data = JSON.parse(text);
} catch (cause) {
throw new RequestError('json', 'JSON 문법 오류', { cause });
}
if (
data === null ||
typeof data !== 'object' ||
Array.isArray(data) ||
typeof data.title !== 'string'
) {
throw new RequestError('shape', 'title 문자열이 필요합니다.');
}
return data;
}
test.mjs
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { once } from 'node:events';
import { loadTodo, RequestError } from './solution.mjs';
const server = createServer((req, res) => {
res.setHeader('Content-Type', 'application/json');
const routes = {
'/ok': [200, '{"title":"문서 읽기"}'],
'/missing': [404, '{}'],
'/broken': [200, '{'],
'/shape': [200, '{}'],
'/empty': [204, ''],
};
const [status, body] = routes[req.url] ?? [500, '{}'];
res.writeHead(status);
res.end(body);
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const base = `http://127.0.0.1:${server.address().port}`;
const kind = (expected) => (error) =>
error instanceof RequestError && error.kind === expected;
try {
assert.equal((await loadTodo(`${base}/ok`)).title, '문서 읽기');
await assert.rejects(
loadTodo(`${base}/missing`),
(error) => error.kind === 'http' && error.status === 404,
);
await assert.rejects(loadTodo(`${base}/broken`), kind('json'));
await assert.rejects(loadTodo(`${base}/shape`), kind('shape'));
await assert.rejects(loadTodo(`${base}/empty`), kind('json'));
await assert.rejects(
loadTodo('unused', {
request: async () => {
throw new TypeError('fixture');
},
}),
kind('request'),
);
await assert.rejects(
loadTodo('unused', {
request: async () => ({
ok: true,
text: async () => {
throw new TypeError('stream');
},
}),
}),
kind('body'),
);
const controller = new AbortController();
controller.abort(new Error('사용자 취소'));
await assert.rejects(
loadTodo('data:application/json,{}', { signal: controller.signal }),
kind('aborted'),
);
const response = new Response('{"title":"한 번"}');
await response.text();
await assert.rejects(
loadTodo('unused', { request: async () => response }),
kind('body'),
);
console.log('PASS 9 fetch scenarios');
} finally {
server.closeAllConnections();
await new Promise((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve())),
);
}
터미널 1에서 node server.mjs를 실행하고 READY 뒤의 주소를 복사합니다. 예를 들어 포트가 43123이면 주소는 http://127.0.0.1:43123입니다.

로딩 성공 실패를 한 함수에서 처리하기
클라이언트는 경로를 받을 때마다 로딩을 먼저 출력합니다. response.ok 검사 뒤에만 JSON을 읽으며, 어느 단계에서 생긴 오류든 같은 catch에서 사용자에게 실패 상태를 알립니다.
client.mjs
터미널 2에서 node client.mjs http://127.0.0.1:43123처럼 실제 READY 주소를 인수로 전달합니다. 먼저 서버가 켜진 상태에서 실행합니다. 결과는 성공, HTTP 404, JSON 파싱 오류의 세 가지입니다. 다음으로 터미널 1에서 Ctrl+C로 서버를 종료한 뒤, 터미널 2에서 같은 주소로 다시 실행합니다. 이번에는 서버가 없으므로 세 요청 모두 연결 실패가 됩니다. 첫 실행과 두 번째 실행을 아래 표처럼 구분해 기록하세요.
try/catch의 범위는 실패로 함께 처리할 작업을 감쌉니다. 이 예제에서는 fetch, HTTP 상태 검사, JSON 파싱, 성공 출력이 하나의 요청 흐름입니다. 어느 단계든 실패하면 성공 로그를 건너뛰고 catch로 이동합니다. finally를 추가하면 성공과 실패 모두에서 로딩 표시를 끄는 공통 정리를 넣을 수 있습니다.
오류 메시지는 개발자 진단 정보와 사용자 안내를 구분하는 편이 좋습니다. HTTP 404는 요청한 자원이 없다는 뜻이고, JSON 파싱 실패는 서버 응답 형식이 약속과 다르다는 뜻입니다. 둘을 모두 “인터넷 오류”로 표시하면 원인을 숨기게 됩니다. 화면에는 이해하기 쉬운 문구를 보여 주고 개발 로그에는 상태 코드와 원래 오류를 남길 수 있습니다.
response 본문은 스트림이므로 일반적으로 한 번만 읽습니다. JSON을 확인하려고 먼저 text로 읽은 뒤 다시 json을 호출하면 이미 소비된 본문 때문에 실패할 수 있습니다. JSON API라면 response.json 한 번으로 읽고, 원문 진단이 꼭 필요하면 처음부터 text로 읽은 뒤 JSON.parse를 직접 적용하는 방식 중 하나를 선택합니다.
순차 실행에서는 첫 await가 끝난 다음 두 번째 요청을 시작합니다. 요청들이 서로 독립적이고 동시에 실행해도 된다면 Promise.all 같은 방식도 있지만, 하나가 실패했을 때 나머지를 어떻게 다룰지 별도 설계가 필요합니다. 이 입문 예제는 각 실패 결과를 순서대로 관찰하려고 의도적으로 await를 차례로 사용합니다.
재시도는 모든 오류에 자동 적용하지 않습니다. 잘못된 JSON이나 404는 같은 요청을 즉시 반복해도 대개 결과가 같습니다. 일시적인 연결 실패나 서버 과부하처럼 회복 가능성이 있는 경우에만 횟수와 간격을 제한해 고려합니다. 요청이 중복 실행되어도 안전한지 확인하고, 사용자 취소로 발생한 AbortError는 재시도 대상에서 제외합니다.
상태를 화면에 연결한다면 요청 시작 직전에 loading을 true로, 성공 시 data를 응답 값으로, 실패 시 error를 이해할 수 있는 문구로 바꿉니다. 요청이 끝나는 finally에서는 loading을 false로 되돌립니다. 새 요청을 시작할 때 이전 data와 error를 유지할지 지울지는 제품 요구에 따라 정하지만, 오래된 오류와 새 성공 결과가 동시에 보이지 않도록 규칙을 일관되게 적용해야 합니다. 빠르게 연속 요청할 수 있는 화면에서는 늦게 끝난 과거 응답이 최신 결과를 덮지 않도록 요청을 중단하거나 요청 식별자를 비교합니다. 이 구분을 해 두면 Promise의 기술적 상태와 사용자가 보는 화면 상태를 혼동하지 않고 설계할 수 있습니다.
| 실행 조건 | /todos/1 | /missing | /broken |
|---|---|---|---|
| 서버 실행 중 | 성공: 문서 읽기 | 실패: HTTP 404 | JSON 파싱 오류 |
| 서버 종료 후 같은 주소 | 연결 실패 | 연결 실패 | 연결 실패 |
요청 중단과 실전 점검 기준
화면 전환이나 검색어 변경으로 이전 요청이 불필요해지면 AbortController의 signal을 fetch 옵션에 전달하고 abort()를 호출할 수 있습니다. 기본 abort()의 이유는 AbortError이지만 사용자 지정 reason은 다른 값일 수 있습니다. 아래 확장 실습처럼 소유한 signal의 aborted도 확인해 취소 안내 정책을 정하세요.
Promise 정의는 MDN Promise 문서, async 함수는 MDN async function 문서, fetch의 응답과 오류는 MDN Fetch 사용 안내, 중단은 MDN AbortController 문서에서 확인할 수 있습니다. 실전에서는 로딩 시작과 종료, response.ok, JSON 파싱, 사용자 메시지, 필요 시 중단까지 각각 확인하세요.
실습: 실패 지점을 보존하는 요청 함수
앞의 client.mjs는 흐름을 관찰하는 최소 예제입니다. 이제 원인을 분류하는 solution.mjs로 확장합니다. 아래 RequestError의 kind는 우리 프로그램이 정한 분류이며 브라우저 표준 오류 이름이 아닙니다. request는 fetch 자체가 거절된 단계입니다. 네트워크 단절뿐 아니라 잘못된 URL, 브라우저의 CORS 차단 등도 포함될 수 있으므로 모두 “인터넷 연결 실패”라고 단정하지 않습니다. HTTP 상태는 응답을 받은 뒤 검사하고, 본문 읽기와 JSON 문법, 데이터 구조 검증은 각각 별도로 처리합니다.
response.text()로 본문을 한 번 소비한 뒤 JSON.parse를 호출하므로 전송 중 본문 스트림 오류와 JSON 문법 오류를 분리할 수 있습니다. 이미 소비한 본문도 body 오류입니다. 유효한 JSON인 {}도 title 문자열이 없으면 shape 오류이며, 204의 빈 본문은 이 JSON 필수 API 계약에서는 json 오류입니다. 204가 정상인 삭제 API라면 JSON을 요구하지 않는 별도 계약을 작성하세요.
signal.aborted는 이 예제가 넘긴 signal의 취소 여부입니다. abort(reason)에 임의의 이유를 넘길 수 있으므로 error.name === “AbortError”만으로 취소를 모두 판별할 수 없습니다. 여기서는 해당 signal이 취소되어 있다면 취소 안내를 우선하는 정책이며, 거의 동시에 발생한 다른 오류의 근본 원인을 증명하는 분류는 아닙니다.
ZIP을 풀고 Node.js 22 이상에서 node test.mjs를 실행하세요. 로컬 서버를 자동 시작·종료하며 정상 응답, 404, 깨진 JSON, 잘못된 구조, 204를 실제 fetch로 확인합니다. 요청 단계와 본문 스트림 실패는 주입한 결정적 fixture로 검사합니다. 미리 취소한 실제 fetch와 이미 소비된 Response도 확인하며 기대 출력은 PASS 9 fetch scenarios입니다. 이것은 브라우저 CORS 테스트가 아닙니다.
연습 순서: ① test.mjs의 경로와 기대 kind를 먼저 예측합니다. ② solution.mjs를 복사해 title 검사를 제거하고 shape assertion이 실패하는지 확인합니다. ③ 검사를 복구한 뒤 title이 빈 문자열일 때 허용할지 결정하고 규칙과 assertion을 함께 추가합니다. 원래 cause는 개발 로그에 남기고 사용자에게는 kind별 안내를 보여주세요.
API 동작 근거: WHATWG Fetch 표준, WHATWG DOM 취소 모델. 확인일: 2026-09-12.
실행·검증 실습 파일
완성 코드와 결정적인 테스트, 단계별 연습 안내가 들어 있습니다. README.md의 순서대로 실행하세요.
이 글이 도움이 되었나요?
JavaScript 학습 순서
필수 13개 · 전체 14개
읽음 기록 관리
전체 과정 목차 (14개)
- 필수 학습 · JavaScript 조건문과 반복문: 변수 값의 흐름부터 추적하기
- 필수 학습 · JavaScript 함수와 객체, import export로 모듈 나누기
- 필수 학습 · JavaScript 배열 메서드: map filter forEach reduce 차이
- 필수 학습 · JavaScript 객체 참조와 불변 갱신: 중첩 객체를 안전하게 바꾸기
- 필수 학습 · JavaScript reduce 사용법: 배열 누적 계산을 이해하는 기준
- 필수 학습 · JavaScript DOM 폼 만들기: 입력 검증과 접근성 처리
- 필수 학습 · JavaScript 투두리스트 만들기: 상태와 이벤트 위임으로 완성하기
- 필수 학습 · JavaScript URLSearchParams 사용법: URL 파라미터 읽고 수정하기
- 필수 학습 · JavaScript Date UTC KST 차이: 시간대 변환 기준 잡기
- 필수 학습 · JavaScript 실행 컨텍스트 기준: 스코프 호이스팅 클로저 연결하기
- 필수 학습 · JavaScript fetch 오류 처리: Promise부터 404까지 현재 글
- 필수 학습 · JavaScript 이벤트 루프: Promise와 await 실행 순서 추적하기
- 필수 학습 · JavaScript 고급 비동기: AbortController와 최신 요청 경쟁 제어
- 선택 참고 · GSAP이 처음일 때 기초 사용법: 설치부터 기본 애니메이션까지
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.