본문 바로가기

Fetch API와 AJAX

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

우리는 8장에서 최신 ECMAScript 문법을 통해 더 견고하고 간결한 자바스크립트 코드를 작성하는 방법을 배웠습니다.

이제 다음 단계는 언어 자체를 넘어, 브라우저가 제공하는 웹 API(Web APIs)를 실전적으로 쓰는 것입니다.

DOM, 이벤트, 스토리지, 네트워크 통신 같은 기능은 모두 웹 API 위에서 동작합니다.

9장 웹 API와 브라우저 기능의 시작점은 서버 통신의 핵심인 AJAX와 Fetch API입니다.

사용자 경험을 부드럽게 만드는 가장 중요한 기술 중 하나입니다.

웹 페이지는 처음 로드될 때 서버에서 HTML, CSS, JavaScript를 받아옵니다.

하지만 이후 상호작용에서는 페이지 전체를 다시 받을 필요가 없는 경우가 많습니다.

필요한 데이터만 비동기로 주고받는 방식이 바로 AJAX입니다.

AJAX는 이름에 XML이 들어가지만, 실무에서는 JSON 같은 형식을 더 자주 사용합니다.

핵심은 전체 새로고침 없이 필요한 데이터만 교환한다는 점입니다.

과거에는 XMLHttpRequest가 중심이었고, 현재는 Promise 기반의 Fetch API가 기본 선택지가 되었습니다.

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 계약인지 확인한다파싱 성공과 필드 유효성은 별개다

실습 API 준비 (로컬 Mock 서버)

외부 공개 API 상태의 영향을 받지 않도록, 이 절의 실습 URL은 http://localhost:4000 기준으로 맞춥니다.

포트 정책은 4000=Mock API, 4100=별도 백엔드/실시간 서버로 분리해 두면 트랙 병행 학습 시 충돌을 줄일 수 있습니다.

npx json-server --watch db.json --port 4000

db.json에는 최소한 posts, users 배열을 준비해 두면 예제를 그대로 재현할 수 있습니다.


XMLHttpRequest (XHR)

XMLHttpRequest 객체는 AJAX의 전통적인 구현체입니다.

복잡하고 콜백 지옥에 빠지기 쉬웠지만, 웹 개발의 중요한 한 획을 그었습니다.

// // fetch API가 없을 때의 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();

XMLHttpRequest는 상태 변화에 따른 이벤트 리스너를 등록하고, 성공/실패 여부를 직접 판단해야 하는 등 다소 번거로운 점이 있었습니다.


Fetch API: AJAX의 현대적인 표준

Fetch API는 Promise를 기반으로 비동기 네트워크 요청을 수행하는 현대적인 인터페이스입니다.

XMLHttpRequest보다 사용하기 쉽고, 더 강력하며, 더 많은 기능을 제공합니다.

fetch() 함수의 기본 사용법

fetch() 함수는 첫 번째 인자로 요청할 URL을 받으며, Promise를 반환합니다.

이 Promise는 네트워크 요청에 대한 Response 객체로 이행(resolve)됩니다.

Response 객체에는 HTTP 응답에 대한 정보(상태 코드, 헤더 등)가 담겨 있습니다.

실제 응답 본문(body)의 데이터(JSON, 텍스트 등)를 얻으려면 Response 객체의 메서드를 추가로 호출해야 합니다.

// 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()는 네트워크 오류·CORS 실패·요청 취소나 잘못된 요청 구성 등에서 Promise를 거부(reject)합니다.

HTTP 상태 코드 404(Not Found)나 500(Server Error)과 같은 서버 응답 오류는 Promise를 거부하지 않고, Response 객체의 ok 프로퍼티가 false로 설정됩니다.

따라서 항상 response.ok를 확인하여 HTTP 오류를 처리해야 합니다.

다양한 HTTP 메서드와 옵션

fetch() 함수의 두 번째 인자로 init 객체를 전달하여 다양한 요청 옵션을 설정할 수 있습니다.

// 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);
    });

주요 init 객체 옵션

  • method: GET, POST, PUT, DELETE 등 HTTP 메서드 (GET이 기본값).
  • headers: 요청 헤더를 설정하는 객체 (Content-Type, Authorization 등).
  • body: POST, PUT 등과 함께 전송할 데이터. 문자열(JSON.stringify), FormData 객체 등이 될 수 있습니다.
  • mode: cors (기본값), no-cors, same-origin. CORS 정책과 관련됩니다.
  • cache: default, no-store, reload, force-cache 등 캐시 정책.
  • credentials: omit, same-origin, include. 인증 정보(쿠키, HTTP 인증) 전송 여부.
  • referrer: 요청의 Referer 헤더.
  • signal: AbortController와 함께 요청을 취소할 때 사용.

Fetch 옵션은 단순한 부가 설정이 아니라 요청의 목적, 데이터 형식, 인증 범위, 취소 조건을 서버와 맞추는 설계표입니다.

async/await와 함께 Fetch 사용하기

우리는 7장에서 async/await 문법을 학습했습니다.

fetch() 함수가 Promise를 반환하므로, async/await와 함께 사용하면 비동기 코드를 동기 코드처럼 읽기 쉽게 작성할 수 있습니다.

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); // 존재하지 않는 사용자 (오류 처리 테스트)

async/await를 사용하면 then().catch() 체인보다 에러 처리 및 코드 흐름을 파악하기 훨씬 용이합니다.

요청 취소: AbortController

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는 무시한다완료 속도가 현재 소유권을 대신하지 않는다

Fetch 요청은 한 번 시작되면 취소하기 어려웠습니다.

AbortController는 Fetch 요청을 취소할 수 있는 메커니즘을 제공합니다.

이는 사용자가 페이지를 떠나거나, 검색어가 바뀌어 이전 요청이 불필요해질 때 유용합니다.

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); // 먼저 완료되면 취소되지 않을 수 있음

signal을 전달한 뒤 controller.abort()를 호출하면 진행 중인 fetch나 응답 본문 읽기를 중단할 수 있습니다. 이미 완료된 요청은 되돌리지 못하며, 이 예제의 AbortError 발생 여부는 응답 시간에 따라 달라집니다.


CORS (Cross-Origin Resource Sharing)

Fetch API를 사용해 다른 도메인의 리소스에 접근할 때는 CORS (Cross-Origin Resource Sharing) 정책을 이해해야 합니다.

웹 브라우저는 보안상의 이유로 Same-Origin Policy (동일 출처 정책)을 따르며, 한 출처(Origin: 프로토콜, 호스트, 포트가 모두 동일한 경우)에서 로드된 페이지가 다른 출처 리소스에 접근하는 것을 제한합니다.

  • 동일 출처: http://example.com:8080/path와 http://example.com:8080/anotherpath는 동일 출처.
  • 다른 출처
    • http://sub.example.com (다른 호스트)
    • https://example.com (다른 프로토콜)
    • http://example.com:3000 (다른 포트)

다른 출처의 리소스에 접근하려면 서버에서 CORS 관련 HTTP 헤더(Access-Control-Allow-Origin 등)를 적절히 설정해 요청을 허용해야 합니다.

Fetch 요청의 mode 옵션을 cors(기본값) 또는 no-cors 등으로 설정할 수 있습니다. 다른 출처의 JSON을 읽는 목적이라면 no-cors도 같은 읽기 권한을 제공한다고 가정하지 않습니다. 아래 실습에서 응답 형식과 접근 가능한 상태를 구별합니다.


Fetch 코드는 요청만 보내는 함수로 끝나지 않고, 화면의 로딩/성공/빈 결과/오류 상태와 함께 설계해야 실전에서 안정적입니다.

앞의 Fetch UI 요청 수명 다이어그램은 같은 요청 흐름을 UI 상태 전환 기준으로 다시 묶어 보여줍니다.


AJAX·Fetch 정리

이 절에서는 요청-응답 흐름을 오류 처리까지 포함해 재현 가능한 형태로 정리합니다.

이번 장에서는 웹 애플리케이션에서 서버와 비동기적으로 데이터를 주고받는 핵심 기술인 AJAX의 개념과, 그 현대적인 구현 표준인 Fetch API에 대해 심도 있게 학습했습니다.

fetch()는 Promise 기반으로 동작하며, HTTP 오류 응답만으로는 자동으로 reject되지 않습니다.

따라서 response.ok 확인, 요청 옵션(method, headers, body), async/await, AbortController, CORS 제약을 함께 점검해야 합니다.

Fetch API는 서버 통신을 구현할 때 가장 기본적으로 확인해야 할 브라우저 API입니다.

로컬 Mock API나 스테이징 API로 요청을 보내고, 응답 상태와 데이터를 화면에 표시하는 흐름을 반복해 검증해 보세요. 따라하기: 먼저 응답 조건을 고정하기

앞의 db.json을 다음처럼 시작합니다. /posts/1과 /users/1은 존재하고 /users/999는 없는 조건입니다. POST 실습은 파일 내용을 바꾸므로, 처음 결과를 다시 비교하려면 이 초기 데이터를 복원합니다. 실행 명령의 옵션은 설치한 json-server 판본에 맞춰 확인합니다. 명령이 실행되지 않았는데 요청 코드만 반복하면 네트워크 실패와 데이터 오류를 구별하기 어렵습니다.

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

서버가 준비되면 /posts는 배열, /posts/1은 글 객체, /users/1은 사용자 객체를 반환하는지 먼저 확인합니다. fetchUserData(1)과 fetchUserData(999)를 연달아 호출해도 두 완료 로그의 순서를 고정하지 않습니다. 각 호출이 요청한 ID와 성공·실패를 연결해서 읽습니다.

예시: HTTP 상태 다음에 JSON과 구조를 검사하기

다음 함수는 /posts 목록의 응답을 읽습니다. 이 화면에는 ID와 제목만 필요하므로 두 필드와 배열 여부를 검사합니다. 이 검사가 게시물의 모든 업무 규칙을 검증하는 것은 아닙니다. Content-Type은 응답 형식을 확인하는 단서이며, JSON 문법이 올바르다는 증명은 아닙니다.

목록 응답의 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이고 본문이 {}인 응답은 배열 검사에서 실패합니다. 세 경우 모두 오류 화면이 될 수 있지만 고칠 지점은 다릅니다. 204 응답을 허용하는 별도 API라면 본문을 읽지 않는 정책부터 정해야 합니다. 여기서는 JSON 배열을 반환하는 /posts 계약을 사용합니다.

따라하기: 이전 요청의 완료가 새 화면을 덮지 않게 하기

아래 함수는 같은 목록 화면에서 요청을 바꾸는 확장입니다. 요청마다 새 AbortController를 만들고, 완료 시점에는 현재 번호도 확인합니다. 번호는 앱이 정한 화면 소유권이고 AbortSignal은 요청 중단 수단입니다. 이 코드는 서버에서 이미 처리한 POST를 되돌리는 기능을 구현하지 않습니다.

현재 요청만 목록 상태를 바꾸는 로더
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');

독립 연습

첫 요청 A가 pending인 동안 B를 시작합니다. B는 상태 200·JSON 배열 []로 먼저 끝나고, A는 취소를 무시한 요청 함수가 상태 200·글 한 개를 뒤늦게 반환했다고 가정합니다. 최종 화면 상태는 무엇일까요? 또 다른 실험에서 현재 요청 C가 상태 200·JSON 본문 {}를 반환하면 어디에서 실패할까요? HTTP 성공과 빈 결과의 의미를 따로 설명하세요.

전송 성공과 화면 소유권을 나누는 풀이

B가 현재 번호를 갖고 있고 정상 배열이 비어 있으므로 마지막 상태는 empty입니다. A는 자신의 번호가 현재 번호와 달라 success를 표시하지 못합니다. C의 JSON 문법은 유효하지만 배열이 아니므로 구조 검사에서 error가 됩니다. 빈 배열은 검증한 목록에 글이 없다는 정상 결과이고, 객체를 목록으로 받는 일은 계약 불일치입니다.

Fetch 표준의 응답·본문 읽기 규칙은 Response를 받은 시점과 본문을 소비하는 시점을 구별합니다. 다른 출처에서 JSON을 읽어야 할 때 no-cors로 바꾸면 해결되지 않습니다. 이 모드는 메서드·헤더에 제한이 있고, opaque 응답은 스크립트에 상태 0과 읽을 수 없는 본문으로 노출됩니다.