본문 바로가기

Fetch API와 AJAX

로컬 Mock API에 XHR과 fetch 요청을 보내고 HTTP 메서드·JSON·오류·취소·CORS를 비동기로 처리합니다.

8장에서는 최신 ECMAScript 문법으로 더 간결하고 안전한 코드를 쓰는 방법을 익혔습니다. 9장 웹 API와 브라우저 기능에서는 언어를 넘어, 브라우저가 제공하는 웹 API(Web API)를 실제로 써 봅니다. DOM과 이벤트, 스토리지, 네트워크 통신은 모두 웹 API 위에서 동작하며, 그 시작은 서버와 데이터를 주고받는 AJAX와 Fetch API입니다.

웹 페이지는 처음 열 때 서버에서 HTML, CSS, 자바스크립트를 받아 옵니다. 하지만 그 뒤 버튼을 누르거나 검색할 때마다 페이지 전체를 다시 받을 필요는 없는 경우가 많습니다. 페이지를 새로고침하지 않고 필요한 데이터만 비동기로 주고받는 방식을 AJAX(Asynchronous JavaScript and XML)라고 합니다. 이름에 XML이 들어 있지만 지금은 JSON 형식을 훨씬 많이 쓰며, 핵심은 페이지 전체가 아니라 필요한 데이터만 교환한다는 점입니다. 예전에는 XMLHttpRequest가 중심이었고, 지금은 Promise를 기반으로 하는 Fetch API가 기본 선택입니다.


실습 API 준비

외부 공개 API의 상태에 결과가 흔들리지 않도록, 이 글의 실습은 내 컴퓨터에서 실행하는 목(mock) API 서버 http://localhost:4000을 기준으로 합니다. 4000번은 목 API, 4100번은 별도 백엔드나 실시간 서버처럼 포트를 나눠 두면 여러 트랙을 함께 공부할 때 충돌을 줄일 수 있습니다.

새 폴더에 다음 내용으로 db.json을 만듭니다. /posts/1과 /users/1은 있고 /users/999는 없는 조건입니다.

Fetch 실습의 초기 db.json
{
  "posts": [{ "id": 1, "title": "첫 글", "body": "본문", "userId": 1 }],
  "users": [{ "id": 1, "name": "민지" }]
}

그 폴더의 터미널에서 다음 명령으로 json-server를 실행합니다. 이 글은 json-server 1.x를 기준으로 하며, 글을 쓰는 시점의 npm 최신 버전은 1.0.0-beta.15이고 Node.js 22.12 이상이 필요합니다.

npx json-server db.json --port 4000

json-server 1.x는 db.json이 바뀌면 자동으로 다시 읽으므로, 예전 버전의 --watch 옵션은 더 이상 필요 없습니다. 서버가 뜨면 브라우저에서 http://localhost:4000/posts가 배열을, /posts/1이 글 객체를, /users/1이 사용자 객체를 돌려주는지 먼저 확인하세요. 서버가 실행되지 않은 상태에서 요청 코드만 반복하면, 네트워크 실패인지 데이터 문제인지 구별하기 어렵습니다. 뒤의 POST 실습은 db.json의 내용을 바꾸므로, 처음 결과와 다시 비교하려면 이 초기 데이터로 되돌립니다.


XMLHttpRequest

XMLHttpRequest(XHR)는 AJAX의 전통적인 구현입니다. 이벤트 콜백으로 결과를 받아 코드가 길어지고 콜백이 중첩되기 쉬웠지만, 웹 애플리케이션의 시대를 연 기술입니다.

// fetch가 없던 시절의 XMLHttpRequest 사용 예시 (간략)
function fetchDataXHR() {
    const xhr = new XMLHttpRequest();
    xhr.open('GET', 'http://localhost:4000/posts/1'); // 요청 초기화
    xhr.onload = function() { // 응답을 받았을 때
        if (xhr.status >= 200 && xhr.status < 300) {
            console.log("XHR 응답 성공:", JSON.parse(xhr.responseText));
        } else {
            console.error("XHR 응답 실패:", xhr.status);
        }
    };
    xhr.onerror = function() { // 요청 실패 (네트워크 오류 등)
        console.error("XHR 네트워크 오류 발생");
    };
    xhr.send(); // 요청 전송
}
fetchDataXHR();

요청을 초기화하고, 응답과 오류에 각각 콜백을 등록하고, 상태 코드로 성공인지 직접 판단한 뒤, 응답 문자열을 JSON.parse()로 직접 바꿔야 합니다. 이 과정을 Promise로 간단하게 만든 것이 Fetch API입니다.


Fetch API

Fetch API는 Promise를 기반으로 네트워크 요청을 보내는 인터페이스입니다. XMLHttpRequest보다 쓰기 쉽고, 요청과 응답을 Request, Response 같은 객체로 다룰 수 있습니다.

fetch()의 기본 사용법

fetch()는 첫 번째 인수로 요청할 URL을 받고 Promise를 돌려줍니다. 이 Promise는 응답 헤더가 도착하면 Response 객체로 이행됩니다. Response에는 상태 코드와 헤더 같은 응답 정보가 담겨 있고, 본문(body)의 데이터는 response.json()이나 response.text() 같은 메서드를 한 번 더 호출해 읽습니다. 이 메서드들도 본문을 다 읽을 때까지 기다리는 Promise를 돌려줍니다.

// GET 요청 예시
fetch('http://localhost:4000/posts/1') // Promise 반환
    .then(response => {
        // 응답 상태 확인: response.ok는 200-299 상태 코드를 나타냄
        if (!response.ok) {
            throw new Error(`HTTP 오류! 상태: ${response.status}`);
        }
        // 응답 본문을 JSON으로 파싱 (Promise 반환)
        return response.json();
    })
    .then(data => {
        console.log("Fetch GET 응답 데이터:", data);
    })
    .catch(error => {
        console.error("Fetch 요청 실패:", error);
    });

가장 주의할 점은 fetch()가 언제 거부되는지입니다. fetch()의 Promise는 네트워크 오류, CORS 실패, 요청 취소, 잘못된 요청 구성처럼 응답 자체를 받지 못했을 때만 거부됩니다. 404(Not Found)나 500(Internal Server Error) 같은 HTTP 오류 응답은 서버가 응답을 보낸 것이므로 Promise가 거부되지 않고, 대신 response.ok가 false가 됩니다. response.ok는 상태 코드가 200~299일 때만 true이므로, HTTP 오류를 처리하려면 항상 response.ok를 확인해야 합니다.

HTTP 메서드와 요청 옵션

fetch()의 두 번째 인수로 옵션 객체를 넘겨 요청을 설정할 수 있습니다. 다음은 새 글을 만드는 POST 요청입니다.

// POST 요청 예시
const newPost = {
    title: '새로운 게시물',
    body: '이것은 Fetch API로 작성된 새로운 게시물 내용입니다.',
    userId: 1,
};

fetch('http://localhost:4000/posts', {
    method: 'POST', // HTTP 메서드 지정
    headers: {
        'Content-Type': 'application/json; charset=UTF-8', // 요청 헤더 지정
    },
    body: JSON.stringify(newPost), // 전송할 데이터 (JSON.stringify로 문자열화)
})
    .then(response => {
        if (!response.ok) {
            throw new Error(`HTTP 오류! 상태: ${response.status}`);
        }
        return response.json();
    })
    .then(data => {
        console.log("Fetch POST 응답 데이터 (생성된 게시물):", data);
    })
    .catch(error => {
        console.error("Fetch POST 요청 실패:", error);
    });

json-server는 POST 요청으로 만든 글을 201 Created 상태와 함께 돌려주며, 새 글에는 서버가 정한 id가 붙습니다. 실행한 뒤 db.json을 열어 보면 새 글이 추가되어 있습니다.

자주 쓰는 옵션은 다음과 같습니다. method는 GET, POST, PUT, DELETE 같은 HTTP 메서드로 기본값은 GET입니다. headers는 Content-Type이나 Authorization 같은 요청 헤더이고, body는 POST나 PUT과 함께 보낼 데이터로 JSON.stringify()로 만든 문자열이나 FormData 객체를 넣습니다. mode는 cors(기본값), no-cors, same-origin 가운데 하나로 CORS 처리 방식을, credentials는 omit, same-origin(기본값), include 가운데 하나로 쿠키 같은 인증 정보를 보낼지를 정합니다. 그 밖에 캐시 정책을 정하는 cache, 요청을 취소할 때 쓰는 signal 등이 있습니다.

옵션은 덤으로 붙이는 설정이 아니라, 요청의 목적과 데이터 형식, 인증 정보를 보낼 범위, 취소 조건을 서버와 맞추는 일입니다. 예를 들어 JSON을 보내면서 Content-Type을 빠뜨리면 서버가 본문을 JSON으로 읽지 못할 수 있습니다.

async/await와 함께 쓰기

7장에서 배운 async/await를 쓰면 fetch() 코드를 위에서 아래로 읽히게 쓸 수 있고, 오류도 try...catch 하나로 처리할 수 있습니다. 다음 함수는 요청, 상태 코드 확인, 본문 읽기를 차례로 진행하며, 어느 단계에서 오류가 나도 catch로 모여 null을 돌려줍니다. 404 같은 오류 응답은 fetch()가 스스로 거부하지 않으므로, response.ok를 확인해 직접 throw하는 줄이 있어야 catch로 넘어간다는 점을 보세요.

async function fetchUserData(userId) {
    try {
        const response = await fetch(`http://localhost:4000/users/${userId}`);

        if (!response.ok) {
            throw new Error(`사용자 데이터를 불러오는 데 실패했습니다. ${response.status}`);
        }

        const userData = await response.json();
        console.log("사용자 데이터:", userData);
        return userData;
    } catch (error) {
        console.error("데이터 로딩 중 오류 발생:", error);
        return null;
    }
}

fetchUserData(1); // ID가 1인 사용자 데이터 가져오기
fetchUserData(999); // 존재하지 않는 사용자 (오류 처리 테스트)

fetchUserData(1)은 사용자 객체를 출력하고, fetchUserData(999)는 json-server가 404를 돌려주므로 response.ok가 false가 되어 “데이터 로딩 중 오류 발생”을 출력한 뒤 null을 돌려줍니다. 두 요청은 동시에 진행되므로 어느 로그가 먼저 나올지는 정해져 있지 않습니다. 결과를 읽을 때는 로그의 순서가 아니라, 각 호출이 요청한 ID와 성공 여부를 짝지어 봐야 합니다.

요청 취소: AbortController

검색어가 바뀌었거나 사용자가 화면을 떠나 이전 요청이 필요 없어졌다면 요청을 취소하는 편이 좋습니다. AbortController를 만들고 그 signal을 fetch()의 옵션으로 넘긴 뒤, controller.abort()를 호출하면 진행 중인 요청이나 본문 읽기가 중단됩니다.

const controller = new AbortController();
const signal = controller.signal;

async function fetchWithCancellation(url) {
    try {
        const response = await fetch(url, { signal });
        if (!response.ok) {
            throw new Error(`HTTP 오류! 상태: ${response.status}`);
        }
        const data = await response.json();
        console.log("데이터 로드 성공:", data);
        return data;
    } catch (error) {
        if (error.name === 'AbortError') {
            console.log("Fetch 요청이 취소되었습니다.");
        } else {
            console.error("Fetch 요청 오류:", error);
        }
        return null;
    }
}

// 50ms 후에 요청 취소를 시도하는 예시
const longRunningFetch = fetchWithCancellation('http://localhost:4000/posts/1');

setTimeout(() => {
    controller.abort(); // 요청 취소
}, 50); // 먼저 완료되면 취소되지 않을 수 있음

취소되면 fetch()나 response.json()의 Promise가 name이 "AbortError"인 오류로 거부되므로, catch에서 다른 오류와 구별해 처리합니다. 이 예제는 50밀리초 뒤에 취소를 시도하는데, 로컬 서버가 그보다 먼저 응답을 끝내면 취소할 것이 없어 정상 결과가 출력됩니다. 이미 끝난 요청은 되돌릴 수 없으며, 서버가 이미 처리한 POST 요청을 취소해도 서버의 데이터가 원래대로 돌아오지는 않습니다.


응답을 단계별로 검사하기

Fetch 요청·응답·오류 계약 — 상태 추적

readPosts에서 전송 거부/Response/HTTP 상태/본문 파싱·구조 검사 결과가 다른 시점에 결정된다. 응답을 받는 것과 성공 데이터의 준비를 단계적으로 구분한다.

Fetch 요청·응답·오류 계약

SEQUENCE

Fetch 요청·응답·오류 계약

요청의 거부와 HTTP 응답 수신을 구분한다. 응답이 오면 상태 코드와 본문 계약을 확인한 뒤 성공·빈 결과·오류 화면을 결정한다.

Fetch 요청·응답·오류 계약위쪽 프레임은 응답과 거부라는 두 대안입니다. 한 요청이 두 경우를 모두 거치지 않습니다. 아래쪽은 응답을 받은 경우 본문의 readPosts 목록 계약입니다. 전송 거부, HTTP 실패, 형식·본문·구조 실패를 구별합니다. 204 등 본문 없는 API는 별도 계약이 필요합니다.화면Fetch서버fetch()HTTP 요청HTTP 응답Response거부[응답][거부]
응답 수신 뒤 목록 계약 검사앞 요청 시퀀스에서 Response를 받은 경우에만 이 흐름을 적용합니다. HTTP 상태, JSON 형식 헤더, 본문 문법과 배열·ID·제목을 검사하고 검증한 목록 또는 실패별 오류 정책을 선택합니다. 전송 거부는 앞 시퀀스의 별도 경로입니다.응답 수신전송 거부와 구별HTTP 상태목록 계약인가?JSON 형식헤더인가?본문 읽기response.json()문법 오류는 거부배열과ID·제목인가?검증한 목록글 있음: success빈 배열: empty오류 정책HTTP·형식·문법구조 실패를 구별통과 ↓ / 실패 →
Fetch 요청·응답·오류 계약위쪽 프레임은 응답과 거부라는 두 대안입니다. 한 요청이 두 경우를 모두 거치지 않습니다. 아래쪽은 응답을 받은 경우 본문의 readPosts 목록 계약입니다. 전송 거부, HTTP 실패, 형식·본문·구조 실패를 구별합니다. 204 등 본문 없는 API는 별도 계약이 필요합니다.화면Fetch서버fetch()HTTP 요청HTTP 응답Response거부[응답][거부]응답 수신전송 거부와 구별HTTP 상태목록 계약인가?JSON 형식헤더인가?본문 읽기response.json()문법 오류는 거부배열과ID·제목인가?검증한 목록글 있음: success빈 배열: empty오류 정책HTTP·형식·문법구조 실패를 구별통과 ↓실패 →

위쪽 프레임은 응답과 거부라는 두 대안입니다. 한 요청이 두 경우를 모두 거치지 않습니다. 아래쪽은 응답을 받은 경우 본문의 readPosts 목록 계약입니다. 전송 거부, HTTP 실패, 형식·본문·구조 실패를 구별합니다. 204 등 본문 없는 API는 별도 계약이 필요합니다.

Response 수신 ≠ HTTP 성공

Fetch의 resolve는 HTTP 성공을 뜻하지 않는다. 전송·HTTP 상태·응답 형식·구조를 각각 확인한다. 204처럼 본문 없는 응답은 API 계약에 따라 파싱을 건너뛴다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
전송 결과실패: transport rejection / 성공: Responsefetch가 거부되거나 Response를 얻는다HTTP 오류는 fetch rejection과 별개다
HTTP와 JSON검사: HTTP status / 본문: JSON parse 가능/실패ok 검사 뒤 본문 파싱을 시도한다Response를 받았다는 사실만으로 데이터 처리가 성공했다는 뜻은 아니다
shape 판정입력: unknown JSON 값 / 결과: 유효 posts 또는 계약 오류파싱 값이 readPosts 계약인지 확인한다파싱 성공과 필드 유효성은 별개다

Fetch 요청·응답·오류 계약 — 상태 추적

readPosts에서 전송 거부/Response/HTTP 상태/본문 파싱·구조 검사 결과가 다른 시점에 결정된다. 응답을 받는 것과 성공 데이터의 준비를 단계적으로 구분한다.

Fetch 요청·응답·오류 계약

SEQUENCE

Fetch 요청·응답·오류 계약

요청의 거부와 HTTP 응답 수신을 구분한다. 응답이 오면 상태 코드와 본문 계약을 확인한 뒤 성공·빈 결과·오류 화면을 결정한다.

Fetch 요청·응답·오류 계약위쪽 프레임은 응답과 거부라는 두 대안입니다. 한 요청이 두 경우를 모두 거치지 않습니다. 아래쪽은 응답을 받은 경우 본문의 readPosts 목록 계약입니다. 전송 거부, HTTP 실패, 형식·본문·구조 실패를 구별합니다. 204 등 본문 없는 API는 별도 계약이 필요합니다.화면Fetch서버fetch()HTTP 요청HTTP 응답Response거부[응답][거부]
응답 수신 뒤 목록 계약 검사앞 요청 시퀀스에서 Response를 받은 경우에만 이 흐름을 적용합니다. HTTP 상태, JSON 형식 헤더, 본문 문법과 배열·ID·제목을 검사하고 검증한 목록 또는 실패별 오류 정책을 선택합니다. 전송 거부는 앞 시퀀스의 별도 경로입니다.응답 수신전송 거부와 구별HTTP 상태목록 계약인가?JSON 형식헤더인가?본문 읽기response.json()문법 오류는 거부배열과ID·제목인가?검증한 목록글 있음: success빈 배열: empty오류 정책HTTP·형식·문법구조 실패를 구별통과 ↓ / 실패 →
Fetch 요청·응답·오류 계약위쪽 프레임은 응답과 거부라는 두 대안입니다. 한 요청이 두 경우를 모두 거치지 않습니다. 아래쪽은 응답을 받은 경우 본문의 readPosts 목록 계약입니다. 전송 거부, HTTP 실패, 형식·본문·구조 실패를 구별합니다. 204 등 본문 없는 API는 별도 계약이 필요합니다.화면Fetch서버fetch()HTTP 요청HTTP 응답Response거부[응답][거부]응답 수신전송 거부와 구별HTTP 상태목록 계약인가?JSON 형식헤더인가?본문 읽기response.json()문법 오류는 거부배열과ID·제목인가?검증한 목록글 있음: success빈 배열: empty오류 정책HTTP·형식·문법구조 실패를 구별통과 ↓실패 →

위쪽 프레임은 응답과 거부라는 두 대안입니다. 한 요청이 두 경우를 모두 거치지 않습니다. 아래쪽은 응답을 받은 경우 본문의 readPosts 목록 계약입니다. 전송 거부, HTTP 실패, 형식·본문·구조 실패를 구별합니다. 204 등 본문 없는 API는 별도 계약이 필요합니다.

Response 수신 ≠ HTTP 성공

Fetch의 resolve는 HTTP 성공을 뜻하지 않는다. 전송·HTTP 상태·응답 형식·구조를 각각 확인한다. 204처럼 본문 없는 응답은 API 계약에 따라 파싱을 건너뛴다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
전송 결과실패: transport rejection / 성공: Responsefetch가 거부되거나 Response를 얻는다HTTP 오류는 fetch rejection과 별개다
HTTP와 JSON검사: HTTP status / 본문: JSON parse 가능/실패ok 검사 뒤 본문 파싱을 시도한다Response를 받았다는 사실만으로 데이터 처리가 성공했다는 뜻은 아니다
shape 판정입력: unknown JSON 값 / 결과: 유효 posts 또는 계약 오류파싱 값이 readPosts 계약인지 확인한다파싱 성공과 필드 유효성은 별개다

위 그림의 위쪽은 화면이 fetch()를 호출한 뒤의 두 가지 대안을 보여 줍니다. 서버에 요청이 가서 HTTP 응답이 돌아오면 fetch()는 Response로 이행하고, 네트워크 오류처럼 응답을 받지 못하면 거부됩니다. 한 요청이 두 경로를 모두 거치지는 않습니다.

아래쪽은 응답을 받은 경우에만 적용하는 목록 검사 순서입니다. 먼저 HTTP 상태가 성공인지, 다음으로 Content-Type 헤더가 JSON 형식인지 확인합니다. 그다음 response.json()으로 본문을 읽는데, 본문의 JSON 문법이 틀렸다면 이 단계에서 거부됩니다. 마지막으로 결과가 배열이고 각 항목에 ID와 제목이 있는지 검사합니다. 모두 통과하면 글이 있을 때는 success, 빈 배열이면 empty 화면을 보여 주고, 중간에 실패하면 어디서 실패했는지 구별해 오류 화면을 보여 줍니다.

그림 아래의 설명처럼 Response를 받았다는 것이 HTTP 요청의 성공을 뜻하지는 않습니다. 전송, HTTP 상태, 응답 형식, 데이터 구조를 각각 확인해야 합니다. 또 204(No Content)처럼 본문이 없는 응답을 돌려주는 API라면, 본문을 읽지 않는 규칙을 따로 정해야 합니다.

다음 함수는 이 순서대로 /posts 목록 응답을 읽습니다. 이 화면에는 ID와 제목만 필요하므로 그 두 필드와 배열인지만 검사하며, 게시물의 모든 업무 규칙을 검증하지는 않습니다.

목록 응답을 HTTP·형식·구조 단계로 검사하기
async function readPosts(response) {
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    const type = response.headers.get('content-type') ?? '';
    if (!/^application\/json(?:\s*;|\s*$)/i.test(type)) {
        throw new Error('JSON 응답 형식이 아닙니다.');
    }
    const data = await response.json();
    const validPost = post => post !== null && typeof post === 'object'
        && (typeof post.id === 'number' || typeof post.id === 'string')
        && typeof post.title === 'string';
    if (!Array.isArray(data) || !data.every(validPost)) {
        throw new Error('글 목록의 구조가 다릅니다.');
    }
    return data;
}

async function showPosts() {
    try {
        const response = await fetch('http://localhost:4000/posts');
        const posts = await readPosts(response);
        console.log(posts.length === 0 ? 'empty' : 'success', posts);
    } catch (error) {
        console.log('error', error.message);
    }
}

초기 데이터로 showPosts()를 호출하면 success와 글 한 개를 출력합니다. 같은 함수라도 응답에 따라 실패하는 단계가 다릅니다. 404 응답은 HTTP 상태 검사에서, 상태가 200이지만 본문이 [{처럼 잘린 응답은 JSON 읽기에서, 상태가 200이고 본문이 {}인 응답은 배열 검사에서 실패합니다. 세 경우 모두 오류 화면이 될 수 있지만 고쳐야 할 곳은 서로 다르므로, 오류 메시지로 단계를 구별해 둡니다. Content-Type 검사는 응답 형식의 단서일 뿐이며, 헤더가 JSON이라고 해서 본문의 JSON 문법이 올바르다는 보장은 아닙니다.


화면 상태와 함께 설계하기

실제 화면에서 Fetch 코드는 요청만 보내고 끝나지 않습니다. 로딩 중, 성공, 빈 결과, 오류 같은 화면 상태와 함께 설계해야 안정적으로 동작합니다.

Fetch UI 요청 수명 — 상태 추적

새 load로 current 번호와 signal을 바꾸며 이전 응답은 UI 전이를 만들지 않는다. 두 요청과 현재 UI를 고정해 늦은 완료 폐기/현재 취소를 보여 줄 가치가 크다.

Fetch UI 요청 수명

STATE MAP

Fetch UI 요청 수명

요청은 pending에서 성공·빈 결과·오류·취소로 갈라진다. 새 요청이 시작되면 화면을 바꿀 권한도 새 요청으로 넘어간다.

현재 요청의 UI 상태 전이한 요청의 수명과 pending 중 새 요청을 나타냅니다. 어느 상태에서든 load를 다시 부르면 새 pending이 됩니다. 이 그림의 완료 간선은 현재 번호가 같은 경우만 적용하며 오래된 완료는 상태 전이를 만들지 않습니다.idle요청 전pending현재 번호만 권한새 signal 준비새 요청success검증한 글 있음empty검증한 빈 배열error통신·HTTP·본문검사 실패canceled현재 요청 취소오래된 완료는 폐기새 load는 다시 pending
현재 요청의 UI 상태 전이한 요청의 수명과 pending 중 새 요청을 나타냅니다. 어느 상태에서든 load를 다시 부르면 새 pending이 됩니다. 이 그림의 완료 간선은 현재 번호가 같은 경우만 적용하며 오래된 완료는 상태 전이를 만들지 않습니다.idle요청 전pending현재 번호만 권한새 signal 준비새 요청success검증한 글 있음empty검증한 빈 배열error통신·HTTP·본문검사 실패canceled현재 요청 취소오래된 완료는 폐기새 load는 다시 pending

한 요청의 수명과 pending 중 새 요청을 나타냅니다. 어느 상태에서든 load를 다시 부르면 새 pending이 됩니다. 이 그림의 완료 간선은 현재 번호가 같은 경우만 적용하며 오래된 완료는 상태 전이를 만들지 않습니다.

현재 token과 같은 완료만 UI에 반영

이전 요청의 늦은 성공·오류·취소가 새 요청의 pending 또는 결과를 덮어쓰지 않게 한다. AbortController로 중단해도 token 검사로 화면의 소유권을 확인한다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
요청 A현재: A / UI: loadingA가 현재 ID와 signal을 가진다현재 요청의 쓰기 권한을 캡처한다
요청 B 시작현재: B / A: 취소/권한 상실A를 취소하고 현재 ID를 B로 바꾼다전송 중단과 상태 쓰기 차단을 각각 적용한다
완료 판정B: 현재 UI 반영 / A: 현재 UI 변경 없음B는 반영하고 늦은 A는 무시한다완료 속도가 현재 소유권을 대신하지 않는다

Fetch UI 요청 수명 — 상태 추적

새 load로 current 번호와 signal을 바꾸며 이전 응답은 UI 전이를 만들지 않는다. 두 요청과 현재 UI를 고정해 늦은 완료 폐기/현재 취소를 보여 줄 가치가 크다.

Fetch UI 요청 수명

STATE MAP

Fetch UI 요청 수명

요청은 pending에서 성공·빈 결과·오류·취소로 갈라진다. 새 요청이 시작되면 화면을 바꿀 권한도 새 요청으로 넘어간다.

현재 요청의 UI 상태 전이한 요청의 수명과 pending 중 새 요청을 나타냅니다. 어느 상태에서든 load를 다시 부르면 새 pending이 됩니다. 이 그림의 완료 간선은 현재 번호가 같은 경우만 적용하며 오래된 완료는 상태 전이를 만들지 않습니다.idle요청 전pending현재 번호만 권한새 signal 준비새 요청success검증한 글 있음empty검증한 빈 배열error통신·HTTP·본문검사 실패canceled현재 요청 취소오래된 완료는 폐기새 load는 다시 pending
현재 요청의 UI 상태 전이한 요청의 수명과 pending 중 새 요청을 나타냅니다. 어느 상태에서든 load를 다시 부르면 새 pending이 됩니다. 이 그림의 완료 간선은 현재 번호가 같은 경우만 적용하며 오래된 완료는 상태 전이를 만들지 않습니다.idle요청 전pending현재 번호만 권한새 signal 준비새 요청success검증한 글 있음empty검증한 빈 배열error통신·HTTP·본문검사 실패canceled현재 요청 취소오래된 완료는 폐기새 load는 다시 pending

한 요청의 수명과 pending 중 새 요청을 나타냅니다. 어느 상태에서든 load를 다시 부르면 새 pending이 됩니다. 이 그림의 완료 간선은 현재 번호가 같은 경우만 적용하며 오래된 완료는 상태 전이를 만들지 않습니다.

현재 token과 같은 완료만 UI에 반영

이전 요청의 늦은 성공·오류·취소가 새 요청의 pending 또는 결과를 덮어쓰지 않게 한다. AbortController로 중단해도 token 검사로 화면의 소유권을 확인한다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
요청 A현재: A / UI: loadingA가 현재 ID와 signal을 가진다현재 요청의 쓰기 권한을 캡처한다
요청 B 시작현재: B / A: 취소/권한 상실A를 취소하고 현재 ID를 B로 바꾼다전송 중단과 상태 쓰기 차단을 각각 적용한다
완료 판정B: 현재 UI 반영 / A: 현재 UI 변경 없음B는 반영하고 늦은 A는 무시한다완료 속도가 현재 소유권을 대신하지 않는다

위 그림은 한 요청의 화면 상태가 바뀌는 과정입니다. 요청 전의 idle 상태에서 요청을 시작하면 pending이 되고, 새 signal을 준비하며 이 요청의 번호를 현재 번호로 정합니다. 응답을 검증한 결과 글이 있으면 success, 빈 배열이면 empty, 통신이나 HTTP 상태, 본문 검사에서 실패하면 error, 현재 요청을 취소하면 canceled가 됩니다.

어느 상태에서든 새로 요청을 시작하면 다시 pending으로 돌아갑니다. 중요한 점은 완료된 요청이 화면을 바꿀 수 있는 것은 그 요청의 번호가 현재 번호와 같을 때뿐이라는 것입니다. 이전 요청이 뒤늦게 성공하거나 실패해도, 그 결과는 버려지고 새 요청의 pending이나 결과를 덮어쓰지 않습니다. AbortController로 이전 요청을 취소하더라도, 화면을 바꿀 권한은 번호로 한 번 더 확인합니다.

다음 함수는 같은 목록 화면에서 요청을 바꿀 때 이 규칙을 구현합니다. 요청마다 새 AbortController를 만들어 이전 요청을 취소하고, 완료되었을 때는 자기 번호가 현재 번호인지 확인합니다.

현재 요청만 목록 상태를 바꾸는 로더
function createPostsLoader(show, request = fetch) {
    let current = 0;
    let previous;

    async function load(url) {
        const token = ++current;
        previous?.abort();
        const controller = new AbortController();
        previous = controller;
        show('pending');
        try {
            const response = await request(url, { signal: controller.signal });
            const posts = await readPosts(response);
            if (token !== current) return;
            if (controller.signal.aborted) { show('canceled'); return; }
            show(posts.length === 0 ? 'empty' : 'success', posts);
        } catch (error) {
            if (token !== current) return;
            show(controller.signal.aborted ? 'canceled' : 'error', error.message);
        }
    }

    return { load, cancel: () => previous?.abort() };
}

const loader = createPostsLoader((state, value) => console.log(state, value));
// 준비된 Mock API에서 실행: loader.load('http://localhost:4000/posts');

token은 화면을 바꿀 권한이 어느 요청에 있는지를 앱이 직접 기록하는 번호이고, AbortSignal은 요청을 중단하는 수단입니다. 둘을 함께 쓰는 이유는, 취소를 무시하는 요청 함수가 있거나 취소 직전에 응답이 끝나는 경우에도 오래된 결과가 화면을 덮지 않게 하기 위해서입니다. 두 번째 인수 request는 기본으로 fetch를 쓰지만, 테스트에서 응답을 흉내 내는 함수로 바꿔 넣을 수 있게 열어 둔 것입니다. 이 코드는 서버가 이미 처리한 POST를 되돌리지는 않습니다.


CORS

Fetch로 다른 출처(origin)의 자원에 접근할 때는 CORS(Cross-Origin Resource Sharing)를 이해해야 합니다. 브라우저는 보안을 위해 동일 출처 정책(same-origin policy)을 따릅니다. 출처는 프로토콜, 호스트, 포트의 조합이며, 셋이 모두 같아야 같은 출처입니다.

http://example.com:8080/path와 http://example.com:8080/anotherpath는 경로만 다르므로 같은 출처입니다. 반면 호스트가 다른 http://sub.example.com, 프로토콜이 다른 https://example.com, 포트가 다른 http://example.com:3000은 모두 다른 출처입니다. 그래서 편집기의 로컬 서버가 http://localhost:5500에서 연 페이지가 http://localhost:4000의 목 API를 호출하는 것도 다른 출처 요청입니다.

브라우저는 다른 출처의 응답을 스크립트가 읽게 해 줄지를 서버가 보낸 Access-Control-Allow-Origin 같은 CORS 헤더로 판단합니다. json-server 1.x는 CORS 헤더를 기본으로 보내므로 이 글의 실습은 그대로 동작합니다. 직접 만든 서버라면 허용할 출처를 서버에서 설정해야 합니다.

fetch()의 mode를 no-cors로 바꾸면 CORS 문제가 해결될 것 같지만 그렇지 않습니다. no-cors 모드는 쓸 수 있는 메서드와 헤더가 제한되고, 다른 출처의 응답을 불투명(opaque) 응답으로 줍니다. 불투명 응답은 스크립트에 상태 0과 읽을 수 없는 빈 본문으로 보이므로, 다른 출처의 JSON을 읽는 목적에는 쓸 수 없습니다.

더 깊이: Network 패널에 OPTIONS 요청이 먼저 보이는 이유

다른 출처로 JSON을 POST하면 Network 패널에 실제 요청보다 먼저 OPTIONS 메서드의 요청이 하나 보이기도 합니다. 이것을 사전 요청(preflight request)이라고 합니다. HTML 폼은 fetch()가 생기기 전부터 어느 출처로든 단순한 모양의 요청을 보낼 수 있었으므로, 브라우저는 폼 제출처럼 보이는 요청에는 서버가 이미 대비하고 있다고 보고 바로 보냅니다. 그 밖의 요청은 서버가 받아들이는지 먼저 묻는 것입니다.

MDN은 단순 요청(simple request)의 조건을 다섯 가지로 드는데, fetch()로 보내는 요청에서 주로 갈리는 것은 다음 세 가지입니다. 메서드가 GET, HEAD, POST 가운데 하나여야 하고, 직접 붙인 헤더가 Accept, Content-Type 같은 허용 목록 안에 있어야 하며, Content-Type은 application/x-www-form-urlencoded, multipart/form-data, text/plain 가운데 하나여야 합니다. 그래서 Content-Type: application/json을 붙인 POST나 PUT, DELETE, Authorization 헤더를 쓰는 요청은 모두 사전 요청을 거칩니다. 사전 요청에는 Access-Control-Request-Method와 Access-Control-Request-Headers가 담기고, 서버가 Access-Control-Allow-Methods와 Access-Control-Allow-Headers로 허용해야 실제 요청이 나갑니다.

사전 요청은 요청마다 왕복을 하나 더 하므로, 서버는 Access-Control-Max-Age로 허용 결과를 몇 초 동안 캐시해도 되는지 알려 줄 수 있습니다. 쿠키를 함께 보내려고 credentials: 'include'를 쓴다면, 서버는 Access-Control-Allow-Credentials: true를 보내고 Access-Control-Allow-Origin에 * 대신 정확한 출처를 적어야 합니다. 자세한 조건은 MDN의 CORS 설명에 있습니다.


정리

  • AJAX는 페이지 전체를 새로고침하지 않고 필요한 데이터만 비동기로 주고받는 방식이며, 지금은 Promise 기반의 Fetch API를 기본으로 씁니다.
  • fetch()는 응답을 받지 못했을 때만 거부되고, 404나 500 같은 HTTP 오류는 response.ok가 false인 응답으로 이행되므로 직접 확인해야 합니다.
  • 응답을 받으면 HTTP 상태, 응답 형식, JSON 문법, 데이터 구조를 차례로 검사하고, 실패한 단계를 구별해 처리합니다.
  • 두 번째 인수의 옵션으로 메서드, 헤더, 본문, CORS 모드, 인증 정보, 취소 신호를 정합니다.
  • AbortController로 필요 없어진 요청을 취소하고, 요청 번호로 현재 요청의 결과만 화면에 반영해 오래된 응답이 새 화면을 덮지 않게 합니다.
  • 다른 출처의 응답을 읽으려면 서버가 CORS 헤더로 허용해야 하며, no-cors 모드는 응답을 읽을 수 없게 만들 뿐입니다.

연습 문제

1. 늦게 도착한 이전 요청

createPostsLoader()로 만든 로더에서 첫 요청 A가 pending인 동안 요청 B를 시작했습니다. B는 상태 200과 JSON 배열 []로 먼저 끝나고, A는 취소를 무시하는 요청 함수 때문에 상태 200과 글 한 개를 뒤늦게 돌려줬다고 가정합니다. 최종 화면 상태는 무엇일까요? 또 다른 실험에서 현재 요청 C가 상태 200과 JSON 본문 {}를 돌려주면 어디서 실패할까요?

해설 보기

최종 상태는 empty입니다. B가 현재 번호를 갖고 있고 검증한 배열이 비어 있으므로 empty를 표시합니다. 뒤늦게 끝난 A는 자기 번호가 현재 번호와 달라 success를 표시하지 못하고 결과가 버려집니다.

C는 JSON 문법은 올바르지만 결과가 배열이 아니므로 구조 검사에서 실패해 error가 됩니다. 빈 배열은 검증한 목록에 글이 없다는 정상 결과이고, 목록 자리에 객체가 오는 것은 약속한 응답 구조와 맞지 않는 오류입니다. HTTP 상태가 200이라는 것만으로는 둘을 구별할 수 없습니다.

2. 거부와 HTTP 오류

json-server를 실행한 상태에서 fetchUserData(999)를 호출할 때와, 서버를 끈 상태에서 fetchUserData(1)을 호출할 때 각각 어느 줄에서 오류가 생길까요? 두 경우 모두 catch로 가는데, 원인을 어떻게 구별할 수 있을까요?

해설 보기

서버가 켜져 있으면 /users/999에 404 응답이 오므로 fetch()는 이행되고, if (!response.ok)에서 직접 던진 Error가 catch로 갑니다. 메시지에 상태 코드 404가 들어 있습니다.

서버가 꺼져 있으면 응답을 받지 못해 await fetch(...) 자체가 거부되며, 브라우저는 보통 TypeError(Chrome에서는 “Failed to fetch”)를 던집니다. 오류의 종류와 메시지, 또는 response를 받았는지로 구별할 수 있고, 필요하면 HTTP 오류는 상태 코드를 담은 별도의 오류로 만들어 던지면 화면에서 다르게 처리하기 쉽습니다.

3. no-cors로 바꾸면

http://localhost:5500에서 연 페이지가 CORS 헤더를 보내지 않는 다른 서버에 fetch(url, { mode: "no-cors" })를 보냈습니다. 받은 response의 type, status, ok는 무엇이고, await response.json()은 어떻게 될까요?

해설 보기

response.type은 "opaque", response.status는 0, response.ok는 false입니다. 불투명 응답은 서버가 실제로 무엇을 돌려줬든 스크립트에 그 내용을 보여 주지 않습니다.

본문도 비어 있는 것처럼 보이므로 await response.json()은 빈 문자열을 JSON으로 읽으려다 SyntaxError로 거부됩니다. 다른 출처의 JSON을 읽으려면 no-cors가 아니라 서버가 CORS 헤더로 그 출처를 허용해야 합니다.

Response를 받는 시점과 본문을 읽는 시점의 차이는 Fetch 표준의 본문 규칙에서, 요청 옵션과 오류 처리는 MDN의 Fetch API 사용하기 문서에서 확인할 수 있습니다.

다음 글 로컬 스토리지와 세션 스토리지에서는 서버 대신 브라우저에 데이터를 저장하는 웹 스토리지 API를 살펴봅니다.