화면에 뭘 그리든 그 재료는 대개 서버에 있다. 그걸 가져오는 통로가 fetch다. 나는 fetch 전에 XMLHttpRequest(옛 요청 객체)로 코드를 짜본 세대인데, 상태 코드를 숫자로 비교하고 콜백을 네 개씩 등록하던 그 시절 코드를 다시는 보고 싶지 않다. fetch는 그 지저분한 걸 프로미스 하나로 걷어냈다. 주소를 넣고 부르면 프로미스가 나오고, 그 프로미스가 응답으로 이행된다. 그게 전부다.


fetch는 브라우저와 요즘 Node.js(자바스크립트 실행 환경)에 기본으로 들어 있다. 이번 편은 기본 요청, 응답을 꺼내는 방식, 사람들이 꼭 한 번씩 걸려 넘어지는 에러 처리, 데이터를 보내는 POST까지 훑는다. 실제 네트워크가 오가는 부분이라, 출력은 응답이 대략 어떤 형태로 돌아오는지를 예시로 보여준다.


fetch는 응답의 약속을 준다. 가장 단순한 요청은 주소 하나만 넘기는 거다. 그러면 Response(응답 객체)로 이행되는 프로미스가 나온다.


const res = await fetch("https://api.example.com/users/1");
console.log(res.status); // 200
console.log(res.ok); // true


여기서 res는 응답의 껍데기다. 상태 코드와 헤더 같은 메타 정보는 들어 있지만, 정작 우리가 원하는 본문 데이터는 아직 여기 없다. 나는 처음에 res를 그대로 찍어보고 왜 사용자 이름이 안 나오나 한참 헤맸다. 본문은 한 번 더 꺼내야 한다.


본문은 한 번 더 꺼낸다. 응답 본문을 JSON으로 받으려면 res.json()을 부른다. 그런데 이게 또 프로미스다. 본문이 네트워크로 조금씩 흘러 들어오는 스트림이라, 다 받을 때까지 기다려야 하기 때문이다.


const res = await fetch("https://api.example.com/users/1");
const user = await res.json();
console.log(user.name); // "김철수"


이때 서버가 돌려주는 응답은 대략 이런 모양이다.


{
"id": 1,
"name": "김철수",
"email": "[email protected]"
}


초보가 제일 먼저 데는 곳이 이 await를 빠뜨리는 거다. res.json() 앞에 await가 없으면 user에 데이터가 아니라 프로미스가 담긴다. 그걸 화면에 뿌리면 어디엔가 [object Promise]가 찍힌다. 나는 이 문자열을 볼 때마다 아, await를 또 빠뜨렸구나 하고 반사적으로 안다.


fetch는 404에도 성공한다. 이게 fetch의 가장 악명 높은 함정이다. 서버가 404(찾을 수 없음)나 500(서버 내부 오류)을 돌려줘도 fetch의 프로미스는 거부되지 않고 멀쩡히 이행된다. fetch 입장에선 서버와 통신에 성공했으니 할 일을 다 한 거다. 응답 내용이 에러인지 아닌지는 우리가 직접 확인해야 한다.


const res = await fetch("https://api.example.com/users/999");
if (!res.ok) {
throw new Error("요청 실패: " + res.status);
}
const user = await res.json();


res.ok는 상태 코드가 200번대면 참, 아니면 거짓이다. 나는 이걸 모르고 try catch만 믿었다가, 존재하지 않는 글을 요청했는데 에러가 안 잡혀서 빈 화면만 뜨는 버그를 반나절 잡았다. catch는 멀쩡히 비어 있고 화면만 이상하니 원인을 못 찾았던 거다. 그 뒤로 fetch 다음 줄엔 반드시 res.ok 확인을 넣는다.


POST로 데이터를 보낸다. 받기만 하는 게 아니라 서버에 뭔가 보낼 땐 두 번째 인자에 설정 객체를 넘긴다. 방식은 method, 헤더는 headers, 실을 데이터는 body에 넣는다. 객체는 JSON.stringify로 문자열로 바꿔 실어야 한다.


const res = await fetch("https://api.example.com/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: "제목", body: "내용" })
});
const created = await res.json();


여기서 Content-Type(본문 형식) 헤더를 빠뜨리면 서버가 이 데이터를 JSON으로 안 읽고 엉뚱하게 해석한다. 나는 이 헤더를 빼먹어 서버가 body를 빈 값으로 받는 걸 여러 번 겪었다. 프론트 코드는 멀쩡한데 서버 로그에만 빈 요청이 찍히니, 범인이 헤더 한 줄이라는 걸 알아채기가 오래 걸린다.


두 종류의 실패를 한 번에 감싼다. 네트워크 자체가 끊기거나 주소를 못 찾으면, 그땐 fetch의 프로미스가 진짜로 거부된다. 그러니 실무 함수엔 res.ok 확인과 try catch가 같이 들어간다. HTTP 에러와 네트워크 에러라는 두 종류를 다 감당해야 하기 때문이다.


async function getUser(id) {
try {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error("HTTP " + res.status);
return await res.json();
} catch (err) {
console.error("불러오기 실패:", err.message);
return null;
}
}


이 뼈대 하나면 대부분의 요청을 안전하게 감싼다. 성공하면 데이터를, 실패하면 null을 돌려주니 부르는 쪽에서 값 하나만 확인하면 된다.


쿼리 파라미터는 조립한다. 주소 뒤에 page=2&sort=new 같은 조건을 붙일 때, 나는 예전에 문자열을 손으로 이어붙였다. 그러다 값에 한글이나 공백이 들어가면 주소가 깨졌다. URLSearchParams(주소 조건 조립기)를 쓰면 인코딩(특수문자를 안전한 형태로 바꾸는 처리)을 알아서 해준다.


const params = new URLSearchParams({ page: 2, sort: "new" });
const res = await fetch("/api/posts?" + params);
// 실제 요청 주소: /api/posts?page=2&sort=new


요청은 취소할 수 있다. 검색창처럼 글자를 칠 때마다 요청이 나가는 화면에선, 이전 요청이 아직 안 끝났는데 새 요청이 나간다. 늦게 도착한 옛 응답이 새 응답을 덮어써 엉뚱한 결과가 뜨기도 한다. AbortController(요청 취소기)로 이전 요청을 끊으면 된다. 타임아웃(제한 시간 초과)에도 같은 걸 쓴다.


const controller = new AbortController();
setTimeout(() => controller.abort(), 3000);
const res = await fetch("/api/slow", { signal: controller.signal });


3초 안에 응답이 안 오면 abort가 불려 요청이 거부된다. 이때 던져지는 에러 이름은 AbortError라, catch에서 이건 진짜 실패가 아니라 내가 일부러 끊은 거라고 구분할 수 있다. 나는 이걸 몰라 취소된 요청까지 에러 메시지로 띄우다가 화면이 에러 팝업 밭이 된 적이 있다.


CORS는 프론트가 못 고친다. 다른 출처(도메인이 다른 서버)로 요청하면 브라우저가 응답을 막고 콘솔에 CORS(교차 출처 자원 공유) 에러를 뿜는다. 서버가 이 출처를 허용한다는 헤더를 안 붙여서 생기는 일이라, 프론트 코드를 아무리 고쳐도 안 풀린다. 서버 설정의 몫이다. 나는 이걸 모르던 시절 내 fetch 코드가 문제인 줄 알고 옵션을 바꿔가며 반나절을 태웠다. CORS 에러가 보이면 프론트는 그만 만지고 서버 쪽을 봐야 한다.


정리하면 fetch는 주소를 넣으면 응답 프로미스를 주고, 본문은 res.json()으로 한 번 더 꺼내며, 404나 500은 거부되지 않으니 res.ok를 꼭 확인하고, 보낼 땐 methodheadersbody를 설정 객체에 담는다. 이 네 가지만 몸에 익히면 실무 요청의 대부분이 손에 잡힌다. 통신은 언제든 실패할 수 있는 일이라, fetch를 쓸수록 다음에 다룰 탄탄한 에러 처리가 왜 필요한지가 저절로 와닿는다.