Fetch API

김동현·2026년 3월 21일

Fetch API

Fetch API는 리소스를 가져오기 위한 인터페이스를 제공해요 (네트워크를 통한 요청도 포함해서요). 이건 XMLHttpRequest를 대체하는 더 강력하고 유연한 방식이에요.


💡 강사 팁

여러분, Fetch API는 프론트엔드 개발자라면 반드시 알아야 하는 핵심 API 중 하나예요! 서버와 데이터를 주고받을 때 거의 매일 사용하게 될 거예요.

왜 Fetch API를 써야 할까요?

문서에서 XMLHttpRequest(줄여서 XHR이라고 불러요)를 대체한다고 했죠? 예전에는 서버에 요청을 보내려면 XHR을 사용했는데, 코드가 정말 복잡했어요. 콜백 지옥에 빠지기 쉬웠죠. 😵

// 예전 방식 (XMLHttpRequest) - 복잡해요!
const xhr = new XMLHttpRequest();
xhr.open('GET', '/api/data');
xhr.onreadystatechange = function() {
  if (xhr.readyState === 4 && xhr.status === 200) {
    console.log(xhr.responseText);
  }
};
xhr.send();

// Fetch API - 훨씬 깔끔하죠?
fetch('/api/data')
  .then(response => response.json())
  .then(data => console.log(data));

실무에서의 활용

Fetch API는 이런 상황에서 사용해요:

  • REST API 호출할 때
  • 이미지, 파일 다운로드할 때
  • 폼 데이터 서버에 전송할 때
  • 외부 API(날씨, 지도 등) 연동할 때

⚠️ 주의할 점: Fetch는 Promise 기반이에요. 그래서 async/await이나 .then() 문법을 먼저 이해하고 계셔야 편하게 사용할 수 있어요. 만약 Promise가 아직 익숙하지 않으시다면, 그 부분 먼저 공부하시는 걸 추천드려요!

그리고 한 가지 더! Fetch는 HTTP 에러(404, 500 등)가 발생해도 Promise를 reject하지 않아요. 네트워크 오류가 있을 때만 reject해요. 이 부분 때문에 처음에 많이 헷갈려하시더라고요. 나중에 에러 핸들링 부분에서 자세히 다룰 거예요! 👍

안녕하세요! 프론트엔드 개발의 핵심 중 하나인 Fetch API를 공부하고 계시네요. MDN 문서가 처음엔 영어로 되어 있어 막막할 수 있지만, 현업에서 매일같이 쓰이는 내용인 만큼 제가 알기 쉽게, 그리고 실무 팁까지 팍팍 넣어서 설명해 드릴게요. 자, 시작해 볼까요?


개념 및 사용법 (Concepts and usage)

Fetch API는 RequestResponse 객체(그리고 네트워크 요청과 관련된 기타 요소들)를 사용해요. 또한 CORS나 HTTP Origin 헤더의 의미와 같은 관련 개념들도 함께 다루고 있죠.

요청을 만들고 리소스를 가져오기(fetch) 위해서는 fetch() 메서드를 사용합니다. 이 메서드는 WindowWorker 컨텍스트 모두에서 전역(global) 메서드로 제공돼요. 즉, 여러분이 리소스를 가져오고 싶은 거의 모든 상황(일반적인 웹 페이지든 백그라운드 워커든)에서 아주 편하게 사용할 수 있다는 뜻입니다.

fetch() 메서드는 딱 하나의 필수 인자를 받는데, 바로 여러분이 가져오고자 하는 리소스의 경로(URL)예요. 이 메서드는 해당 요청에 대한 Response 객체로 처리(resolve)되는 Promise를 반환합니다. 서버가 헤더를 응답하자마자 바로 Promise가 해결되는데요, 여기서 아주 중요한 점은 서버의 응답이 HTTP 에러 상태(예: 404 Not Found 또는 500 Internal Server Error)일지라도 Promise는 거부(reject)되지 않고 정상적으로 해결(resolve)된다는 거예요. 원한다면 두 번째 인자로 init이라는 옵션 객체를 넘겨서 요청을 세밀하게 설정할 수도 있습니다 (Request 문서를 참고하세요).

💡 강사의 실무 팁 & 면접 꿀팁: > 방금 말씀드린 "HTTP 에러 상태에도 Promise가 resolve된다"는 부분은 프론트엔드 기술 면접에서 단골로 나오는 질문이기도 해요! 초보자분들이 많이 하는 실수 중 하나가 fetch().catch()를 쓰면 404나 500 에러를 다 잡을 수 있을 거라고 생각하는 건데, catch는 오직 '네트워크 단절' 같은 치명적인 오류일 때만 작동합니다.
따라서 React나 Next.js 등에서 데이터를 페칭할 때는 무조건 if (!response.ok) { throw new Error('에러 발생!'); } 처럼 response.ok 속성을 사용해서 에러를 직접 걸러주는 습관을 들이셔야 합니다. 상태 관리 라이브러리(Zustand 등)나 데이터 페칭 라이브러리(SWR 등)와 결합할 때도 이 원리를 정확히 아는 것이 중요해요!

Response를 성공적으로 받아오고 나면, 본문(body) 콘텐츠가 어떤 형태인지, 그리고 그것을 어떻게 처리해야 할지 정의할 수 있는 다양한 메서드들(예: response.json(), response.text() 등)을 사용할 수 있습니다.

Request()Response() 생성자를 사용해서 요청과 응답 객체를 직접 만들 수도 있어요. 하지만 코드를 짜면서 이걸 직접 생성하는 일은 흔치 않습니다. 보통 이런 객체들은 다른 API 동작의 결과로 만들어지는 경우가 훨씬 많아요 (예를 들어, 서비스 워커에서 사용하는 FetchEvent.respondWith() 같은 경우죠).

Fetch API의 다양한 기능들을 사용하는 더 자세한 방법은 Fetch 사용하기 (Using Fetch)에서 확인하실 수 있습니다.

지연된 Fetch (Deferred Fetch)

fetchLater() API를 사용하면 개발자가 지연된 fetch (deferred fetch) 를 요청할 수 있습니다. 지정된 시간이 지난 후, 또는 사용자가 페이지를 닫거나 다른 페이지로 이동할 때 요청이 전송되도록 예약할 수 있는 기능이죠. 자세한 내용은 지연된 Fetch 사용하기 (Using Deferred Fetch)를 참고하세요.

💡 강사의 실무 팁:
fetchLater 기능은 주로 사용자 분석(Analytics)이나 로그 데이터를 보낼 때 유용해요. 사용자가 브라우저 탭을 닫는 순간에 마지막으로 보고 있던 데이터를 서버로 쏴주고 싶을 때가 있거든요. 예전에는 navigator.sendBeacon 같은 API를 썼는데, Fetch API에서도 이런 수요를 충족시키기 위해 발전하고 있다는 점을 알아두시면 트렌드를 쫓아가는 데 도움이 됩니다.


인터페이스 (Interfaces)

이 섹션에서는 Fetch API를 구성하는 주요 객체와 메서드들을 소개합니다.

Window.fetch()WorkerGlobalScope.fetch()
: 리소스를 네트워크에서 가져올 때 사용하는 가장 기본적이고 핵심적인 fetch() 메서드입니다.

Window.fetchLater()
: 나중에 실행될 '지연된 fetch' 요청을 만들 때 사용합니다.

DeferredRequestInit
: 지연된 fetch 요청을 설정할 때 사용할 수 있는 옵션들의 모음(객체 형태)을 나타냅니다.

FetchLaterResult
: 지연된 fetch 요청의 상태나 결과를 나타내는 객체입니다.

Headers
: 요청(request)이나 응답(response)의 헤더를 나타냅니다. 이 객체를 통해 헤더의 값을 조회(query)하고, 그 결과에 따라 조건부 로직을 짜는 등 다양한 액션을 취할 수 있습니다.

Request
: 우리가 서버로 보내는 리소스 요청 그 자체를 나타내는 객체입니다.

Response
: 우리의 요청에 대해 서버가 돌려주는 응답 데이터를 담고 있는 객체입니다.


HTTP 헤더 (HTTP headers)

Fetch API 동작을 제어하기 위한 특별한 HTTP 권한 정책(Permissions Policy) 헤더들입니다.

deferred-fetch
: fetchLater() API가 사용할 수 있는 최상위 할당량 (top-level quota)을 제어합니다. (브라우저가 지연된 요청을 무한정 받으면 안 되니까 시스템 자원 사용량을 제한하는 기능이에요.)

deferred-fetch-minimal
: fetchLater() API에 대한 공유된 교차 출처 서브프레임 할당량 (shared cross-origin subframe quota)을 제어합니다. iframe 같은 곳에서 다른 도메인으로 요청을 보낼 때의 자원 제한을 관리합니다.

안녕하세요 예비 프론트엔드 개발자 여러분! 오늘 함께 살펴볼 MDN 공식 문서는 실무에서 숨 쉬듯이 사용하게 될 핵심 기능, 바로 'Fetch API 사용하기(Using the Fetch API)' 입니다.

영어로 된 문서라 처음엔 막막할 수 있지만, 저와 함께 하나씩 차근차근 짚어보면 금방 이해하실 수 있을 거예요. 실무 팁도 팍팍 넣어드릴 테니 집중해서 따라와 주세요!


Fetch API 사용하기 (Using the Fetch API)

Fetch API는 HTTP 요청을 만들고 그 응답을 처리하기 위한 자바스크립트 인터페이스를 제공합니다. 쉽게 말해, 브라우저에서 서버로 데이터를 달라고 요청하거나(GET) 데이터를 보내는(POST) 등의 통신을 할 때 사용하는 기본 도구예요.

Fetch는 과거에 쓰이던 XMLHttpRequest의 현대적인 대체재입니다. 콜백(callback) 함수를 덕지덕지 붙여서 사용해야 했던 XMLHttpRequest와는 다르게, Fetch는 Promise(프로미스) 기반으로 작동해요. 덕분에 코드가 훨씬 깔끔해지죠! 게다가 서비스 워커(Service workers)CORS(교차 출처 리소스 공유) 같은 최신 웹 기능들과도 아주 잘 통합되어 있습니다.

💡 강사님의 실무 팁!
"실무에서는 axios라는 외부 라이브러리도 많이 사용합니다. 하지만 fetch는 브라우저에 내장되어 있어서 별도의 패키지 설치 없이 바로 사용할 수 있다는 엄청난 장점이 있어요. 프론트엔드 개발자라면 axios를 쓰기 전에 내장 API인 fetch의 동작 원리를 완벽하게 이해하고 있어야 합니다."

Fetch API를 사용하려면 windowworker 컨텍스트에서 모두 전역 함수로 제공되는 fetch()를 호출해서 요청을 만듭니다. 이 함수에는 가져올 URL이 담긴 문자열이나 Request 객체를 넘겨주고, 선택적으로 요청을 세세하게 설정할 수 있는 옵션(argument)을 함께 전달할 수 있습니다.

fetch() 함수는 서버의 응답을 나타내는 Response 객체로 이행(fulfilled)되는 Promise를 반환합니다. 이 반환된 객체를 통해 요청이 성공했는지 상태를 확인하고, 응답 객체의 적절한 메서드를 호출해서 텍스트나 JSON 등 다양한 형식으로 응답 본문(body)을 추출해낼 수 있죠.

여기 서버에서 JSON 데이터를 가져오기 위해 fetch()를 사용하는 아주 간단한 형태의 함수를 볼까요?

async function getData() {
  const url = "[https://example.org/products.json](https://example.org/products.json)";
  try {
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error(`Response status: ${response.status}`);
    }

    const result = await response.json();
    console.log(result);
  } catch (error) {
    console.error(error.message);
  }
}

위 코드를 보면, 먼저 URL을 담은 문자열을 선언한 뒤 아무런 추가 옵션 없이 URL만 넘겨서 fetch()를 호출하고 있습니다.

주의할 점이 하나 있어요! fetch() 함수는 네트워크 오류가 났을 때처럼 일부 상황에서는 프로미스를 거부(reject)하지만, 서버가 404 (Not Found) 같은 에러 상태 코드로 응답했을 때는 프로미스를 거부하지 않습니다. 즉, 에러가 났는데도 try...catchcatch 블록으로 바로 넘어가지 않는다는 뜻이에요! 그래서 우리는 응답 상태(response.ok)를 직접 확인하고, 상태가 정상이 아니라면 명시적으로 에러를 던지도록(throw) 처리해야 합니다.

💡 강사님의 부연 설명!
"이 부분이 초보자분들이 가장 많이 헷갈려하는 부분입니다! axios는 4xx나 5xx 에러가 나면 알아서 catch로 넘어가지만, fetch는 응답을 성공적으로 받았다고 판단해서 then이나 다음 코드로 넘어가 버려요. 그래서 위 코드에 있는 if (!response.ok) 검사는 선택이 아니라 필수입니다!"

정상적인 경우라면, Response 객체의 json() 메서드를 호출해서 응답 본문의 내용을 JSON 형태로 파싱해 가져오고, 그중 하나의 값을 로그로 출력합니다. fetch() 자체와 마찬가지로 json() 역시 비동기적으로 동작한다는 점을 기억하세요. 응답 본문 내용에 접근하는 다른 모든 메서드들도 마찬가지로 비동기랍니다.

자, 이제 이 페이지의 나머지 부분에서 이 과정의 각 단계를 조금 더 자세히 파헤쳐 보겠습니다.


요청 만들기 (Making a request)

요청을 만들려면 fetch()를 호출하면서 다음 내용들을 전달하면 됩니다:

  1. 가져올 리소스의 정의. 다음 중 하나일 수 있습니다:
  2. 요청을 구성하는 옵션들이 담긴 객체 (선택 사항)

이 섹션에서는 가장 흔하게 쓰이는 옵션들을 살펴볼 거예요. 사용할 수 있는 모든 옵션을 알고 싶으시다면 fetch() 레퍼런스 페이지를 참고해 주세요.

메서드 설정하기 (Setting the method)

기본적으로 fetch()GET 요청을 보냅니다. 하지만 method 옵션을 사용하면 다른 요청 메서드(request method)를 사용할 수 있어요.

const response = await fetch("[https://example.org/post](https://example.org/post)", {
  method: "POST",
  // …
});

만약 mode 옵션이 no-cors로 설정되어 있다면, method는 반드시 GET, POST, HEAD 중 하나여야만 합니다.

본문 설정하기 (Setting a body)

요청 본문(body)은 요청의 페이로드(payload), 즉 클라이언트가 서버로 보내는 실제 데이터입니다. GET 요청에는 본문을 포함할 수 없지만, 서버로 데이터를 보낼 때 쓰이는 POSTPUT 요청에서는 아주 유용합니다. 예를 들어 서버에 파일을 업로드하고 싶다면 POST 요청을 만들고 그 파일을 요청 본문에 포함시키면 되죠.

요청 본문을 설정하려면 body 옵션에 값을 전달하면 됩니다:

const response = await fetch("[https://example.org/post](https://example.org/post)", {
  method: "POST",
  body: JSON.stringify({ username: "example" }),
  // …
});

💡 강사님의 꿀팁!
"자바스크립트 객체를 서버로 보낼 때는 반드시 JSON.stringify()를 써서 문자열로 변환해 줘야 합니다! 그냥 객체를 넣으면 [object Object]라는 글자만 전송되니 정말 조심하셔야 해요!"

본문에는 다음 타입들의 인스턴스를 제공할 수 있습니다:

다른 객체들은 그 객체의 toString() 메서드를 통해 문자열로 자동 변환됩니다. 예를 들어, URLSearchParams 객체를 사용해서 폼(form) 데이터를 인코딩할 수 있습니다 (더 자세한 내용은 헤더 설정하기를 참고하세요).

const response = await fetch("[https://example.org/post](https://example.org/post)", {
  method: "POST",
  headers: {
    "Content-Type": "application/x-www-form-urlencoded",
  },
  // 자동으로 "username=example&password=password" 문자열로 변환됩니다.
  body: new URLSearchParams({ username: "example", password: "password" }),
  // …
});

여기서 주의할 점은, 응답 본문과 마찬가지로 요청 본문 또한 스트림(stream)이라는 것입니다. 요청을 보내는 과정에서 스트림을 읽어버리기 때문에, 만약 본문이 포함된 요청이라면 같은 요청을 두 번 보낼 수가 없습니다:

const request = new Request("[https://example.org/post](https://example.org/post)", {
  method: "POST",
  body: JSON.stringify({ username: "example" }),
});

const response1 = await fetch(request);
console.log(response1.status);

// 다음 코드는 에러를 던집니다: "Body has already been consumed." (본문이 이미 소비되었습니다.)
const response2 = await fetch(request);
console.log(response2.status);

이럴 때는 전송하기 전에 요청을 복제(clone)해서 만들어 두어야 합니다:

const request1 = new Request("[https://example.org/post](https://example.org/post)", {
  method: "POST",
  body: JSON.stringify({ username: "example" }),
});

const request2 = request1.clone();

const response1 = await fetch(request1);
console.log(response1.status);

const response2 = await fetch(request2);
console.log(response2.status);

더 자세한 내용은 아래의 잠긴 스트림과 손상된 스트림(Locked and disturbed streams) 섹션을 확인해 주세요.

헤더 설정하기 (Setting headers)

요청 헤더(Request headers)는 서버에게 이 요청에 대한 부가 정보를 알려줍니다. 예를 들어 POST 요청에서 Content-Type 헤더는 서버에게 요청 본문의 데이터 형식이 무엇인지(JSON인지, 단순 텍스트인지 등)를 알려주죠.

요청 헤더를 설정하려면 headers 옵션에 값을 할당하면 됩니다.

header-name: header-value 형태의 속성을 가진 단순한 객체 리터럴을 넘길 수 있어요:

const response = await fetch("[https://example.org/post](https://example.org/post)", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ username: "example" }),
  // …
});

아니면 Headers 객체를 새로 만든 다음, Headers.append()를 사용해 헤더를 추가하고, 그 Headers 객체를 headers 옵션에 할당하는 방법도 있습니다.

const myHeaders = new Headers();
myHeaders.append("Content-Type", "application/json");

const response = await fetch("[https://example.org/post](https://example.org/post)", {
  method: "POST",
  headers: myHeaders,
  body: JSON.stringify({ username: "example" }),
  // …
});

단순한 객체를 사용하는 것보다 Headers 객체를 사용하면 입력값에 대한 '살균(sanitization, 데이터 정제)' 처리를 추가로 해줍니다. 예를 들어 헤더 이름을 모두 소문자로 정규화해주고, 헤더 값의 앞뒤 공백을 없애주며, 설정해서는 안 되는 특정 헤더들을 막아주기도 해요. 수많은 헤더들이 브라우저에 의해 자동으로 설정되기 때문에 스크립트에서는 건드릴 수 없도록 되어있는데, 이를 금지된 요청 헤더(Forbidden request headers)라고 부릅니다. 만약 mode 옵션이 no-cors로 설정되어 있다면 허용되는 헤더의 종류는 더욱 제한됩니다.

GET 요청에서 데이터 보내기 (Sending data in a GET request)

GET 요청에는 본문(body)이 없지만, URL 뒤에 쿼리 스트링(query string)을 붙여서 서버로 데이터를 보낼 수 있습니다. 이 방식은 폼(form) 데이터를 서버로 전송할 때 아주 흔하게 쓰이는 방법이에요. 데이터를 인코딩하기 위해 URLSearchParams를 사용하고, 그걸 URL 뒤에 붙이면 됩니다.

const params = new URLSearchParams();
params.append("username", "example");

// [https://example.org/login?username=example](https://example.org/login?username=example) 로 GET 요청을 보냅니다.
const response = await fetch(`https://example.org/login?${params}`);

💡 강사님의 추가 설명:
"파라미터가 많아지면 직접 ?key=value&key2=value2 형태로 문자열을 타이핑하기 까다롭고 실수하기도 쉽습니다. 특수문자나 띄어쓰기 인코딩 문제도 생기고요. 이럴 때 URLSearchParams를 활용하면 브라우저가 알아서 안전하고 예쁘게 인코딩해주니 꼭 활용하세요!"

교차 출처 요청 만들기 (Making cross-origin requests)

요청이 교차 출처(cross-origin, 현재 접속 중인 도메인과 다른 도메인으로 보내는 것)로 이루어질 수 있는지 여부는 RequestInit.mode 옵션의 값에 따라 결정됩니다. 이 옵션은 cors, same-origin, no-cors 이 세 가지 값 중 하나를 가질 수 있어요.

  • fetch 요청의 기본 mode 값은 cors입니다. 즉, 요청이 교차 출처로 갈 경우 CORS (Cross-Origin Resource Sharing) 메커니즘을 사용한다는 뜻이에요. 이렇게 되면:
    • 요청이 단순 요청(simple request)인 경우, 요청 자체는 무조건 전송되지만 서버가 올바른 Access-Control-Allow-Origin 헤더로 응답하지 않으면 브라우저가 이 응답을 우리 코드 쪽에 전달해주지 않습니다.
    • 요청이 단순 요청이 아닐 경우, 브라우저는 진짜 요청을 보내기 전에 서버가 CORS를 이해하고 요청을 허용하는지 확인하기 위해 사전 요청(preflighted request)을 먼저 보냅니다. 서버가 이 사전 요청에 적절한 CORS 헤더로 화답하지 않으면 진짜 요청은 아예 출발하지도 않습니다.
  • modesame-origin으로 설정하면 교차 출처 요청 자체를 완벽하게 금지합니다.
  • modeno-cors로 설정하면 교차 출처 요청에서 CORS 검사를 꺼버립니다. 대신 설정할 수 있는 헤더가 엄격히 제한되고, 메서드도 GET, HEAD, POST로만 제한돼요. 가장 중요한 건 응답이 불투명(opaque)해진다는 점입니다. 자바스크립트 코드에서 응답 헤더나 본문을 읽어볼 수 없게 됩니다. 대부분의 웹사이트에서는 no-cors를 쓰면 안 되며, 보통 특정 서비스 워커 상황에서만 주로 사용됩니다.

더 자세한 사항은 RequestInit.mode 레퍼런스 문서를 참고하세요.

자격 증명 포함하기 (Including credentials)

Fetch API에서 말하는 자격 증명(credential)이란, 서버가 사용자를 인증하기 위해 쓸 수 있도록 요청과 함께 보내는 추가적인 데이터를 말합니다. 다음 항목들이 모두 자격 증명으로 간주돼요:

기본적으로 자격 증명은 '동일 출처(same-origin)' 요청에만 포함됩니다. 이 동작 방식을 바꾸고 싶거나, 브라우저가 서버에서 보낸 Set-Cookie 응답 헤더를 준수하게 만들려면 credentials 옵션을 설정해야 합니다. 이 옵션은 다음 세 가지 값을 가질 수 있어요.

  • omit: 요청에 자격 증명을 보내지 않고 응답에 있는 자격 증명도 무시합니다.
  • same-origin (기본값): 동일 출처 요청에서만 자격 증명을 전송하고 수신합니다.
  • include: 교차 출처 요청이더라도 항상 자격 증명을 포함합니다.

참고로 쿠키의 SameSite 속성이 StrictLax로 설정되어 있다면, credentialsinclude로 설정하더라도 교차 출처로 쿠키가 전송되지 않으니 주의하세요.

교차 출처 요청에 자격 증명을 포함시키면 웹사이트가 CSRF 공격에 취약해질 수 있습니다. 그래서 credentialsinclude로 설정되어 있더라도, 서버 역시 자신의 응답에 Access-Control-Allow-Credentials 헤더를 포함시켜서 자격 증명을 받아들이겠다는 동의를 표시해야 합니다. 게다가 이 상황에서는 서버가 응답 헤더의 Access-Control-Allow-Origin 항목에 클라이언트의 출처(Origin)를 명확히 적어주어야 합니다 (* 즉 와일드카드 사용은 허용되지 않습니다).

요약하자면, credentialsinclude이고 교차 출처 요청이라면 다음과 같이 동작합니다:

  • 요청이 단순 요청일 때: 자격 증명이 포함된 채로 요청이 전송되지만, 서버가 Access-Control-Allow-CredentialsAccess-Control-Allow-Origin 응답 헤더를 제대로 내려주지 않으면 브라우저는 호출자에게 네트워크 에러를 반환합니다. 헤더를 올바르게 줬다면, 자격 증명을 포함한 제대로 된 응답을 받아올 수 있습니다.
  • 요청이 단순 요청이 아닐 때: 브라우저는 자격 증명 없이 사전 요청(preflighted request)을 먼저 보냅니다. 이때 서버가 위의 두 헤더를 제대로 설정하지 않으면 브라우저는 네트워크 에러를 뱉습니다. 서버가 올바르게 헤더를 설정했다면, 브라우저는 그제서야 자격 증명을 얹은 진짜 요청을 보내고, 자격 증명이 포함된 진짜 응답을 반환해 줍니다.

Request 객체 생성하기 (Creating a Request object)

Request() 생성자는 fetch() 함수가 받는 것과 똑같은 인수들을 받습니다. 즉, fetch()에 옵션 객체를 넘기는 대신 Request() 생성자에 똑같은 옵션을 줘서 객체를 만든 다음, 그 객체를 fetch()에 전달해도 된다는 뜻이에요.

예를 들어, 아까 fetch()에 옵션을 넣어서 만들었던 POST 요청 코드를 볼까요?

const myHeaders = new Headers();
myHeaders.append("Content-Type", "application/json");

const response = await fetch("[https://example.org/post](https://example.org/post)", {
  method: "POST",
  body: JSON.stringify({ username: "example" }),
  headers: myHeaders,
});

이 코드를 Request() 생성자를 사용하도록 다시 작성할 수 있습니다.

const myHeaders = new Headers();
myHeaders.append("Content-Type", "application/json");

const myRequest = new Request("[https://example.org/post](https://example.org/post)", {
  method: "POST",
  body: JSON.stringify({ username: "example" }),
  headers: myHeaders,
});

const response = await fetch(myRequest);

이 방식을 쓰면 한 가지 멋진 팁이 생깁니다. 기존에 만들어둔 요청(Request) 객체를 바탕으로 삼아서, 두 번째 인수로 일부 속성만 덮어씌워 새로운 요청을 만들 수도 있답니다.

async function post(request) {
  try {
    const response = await fetch(request);
    const result = await response.json();
    console.log("Success:", result);
  } catch (error) {
    console.error("Error:", error);
  }
}

const request1 = new Request("[https://example.org/post](https://example.org/post)", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ username: "example1" }),
});

const request2 = new Request(request1, {
  body: JSON.stringify({ username: "example2" }),
});

post(request1);
post(request2);

요청 취소하기 (Canceling a request)

요청을 중간에 취소(cancel)할 수 있게 만들려면, AbortController 객체를 생성한 뒤 그 컨트롤러가 가진 AbortSignal을 요청 옵션 중 signal 속성에 연결해주면 됩니다.

요청을 진짜로 취소하려면 컨트롤러의 abort() 메서드를 호출하세요. 그러면 fetch() 호출이 AbortError 예외를 발생시키며 프로미스를 거절하게 됩니다.

💡 강사님의 실무 팁!
"검색 자동완성 기능 만들 때 사용자가 'a', 'ap', 'app', 'appl' 이렇게 빠르게 타이핑하면 이전 요청들이 무의미해지죠? 이때 AbortController를 써서 이전 요청을 취소해주면 불필요한 네트워크 리소스 낭비도 막고, 옛날 데이터가 뒤늦게 화면에 덮어씌워지는 버그도 막을 수 있습니다. 실무에서 정말 꿀기능이에요!"

const controller = new AbortController();

const fetchButton = document.querySelector("#fetch");
fetchButton.addEventListener("click", async () => {
  try {
    console.log("Starting fetch");
    const response = await fetch("[https://example.org/get](https://example.org/get)", {
      signal: controller.signal,
    });
    console.log(`Response: ${response.status}`);
  } catch (e) {
    console.error(`Error: ${e}`);
  }
});

const cancelButton = document.querySelector("#cancel");
cancelButton.addEventListener("click", () => {
  controller.abort();
  console.log("Canceled fetch");
});

만약 fetch() 호출이 이미 이행(fulfilled)되어서 응답 헤더는 다 받았는데 본문(body)을 읽기 전에 요청이 취소되었다면 어떻게 될까요? 이때 응답 본문을 읽으려고 시도하면 마찬가지로 AbortError 예외를 뿜어냅니다.

async function get() {
  const controller = new AbortController();
  const request = new Request("[https://example.org/get](https://example.org/get)", {
    signal: controller.signal,
  });

  const response = await fetch(request);
  controller.abort();
  // 바로 아래 코드는 `AbortError`를 발생시킵니다.
  const text = await response.text();
  console.log(text);
}

응답 처리하기 (Handling the response)

브라우저가 서버로부터 응답 상태와 헤더를 수신하는 즉시 (응답 본문을 다 받기도 전에!) fetch()가 반환한 프로미스는 Response 객체와 함께 이행(fulfilled)됩니다.

응답 상태 확인하기 (Checking response status)

fetch()가 반환한 프로미스는 네트워크 에러나 스킴(scheme)이 잘못된 경우 등 몇몇 상황에서 거부(reject)됩니다. 하지만 거듭 강조했듯, 서버가 404 같은 오류로 응답하더라도 fetch()Response를 반환하며 성공적으로 이행돼 버립니다. 그러므로 응답 본문을 읽기 전에 우리가 직접 상태를 확인해야만 해요.

Response.status 속성은 상태 코드 숫자를 알려주고, Response.ok 속성은 그 상태 코드가 200번대(성공적인 응답)일 경우 true를 반환합니다.

실무에서 가장 흔하게 쓰이는 패턴은 ok 값을 체크해서 false면 에러를 던져버리는 것입니다.

async function getData() {
  const url = "[https://example.org/products.json](https://example.org/products.json)";
  try {
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error(`Response status: ${response.status}`);
    }
    // …
  } catch (error) {
    console.error(error.message);
  }
}

응답 타입 확인하기 (Checking the response type)

응답 객체는 type 속성을 가지고 있는데, 다음 값 중 하나를 가집니다:

  • basic: 동일 출처(same-origin)로 보낸 요청이었습니다.
  • cors: 교차 출처(cross-origin)로 보낸 CORS 요청이었습니다.
  • opaque: no-cors 모드로 교차 출처 단순 요청을 보냈을 때 나오는 타입입니다.
  • opaqueredirect: redirect 옵션을 manual로 설정했는데, 서버가 리다이렉트 상태 코드를 돌려주었을 때 나옵니다.

이 타입에 따라 우리가 응답에서 꺼내볼 수 있는 내용물이 달라집니다.

  • Basic 응답: 금지된 응답 헤더 이름 목록에 있는 응답 헤더들을 제외하고 모두 볼 수 있습니다.
  • CORS 응답: CORS 안전 목록(CORS-safelisted) 응답 헤더 목록에 포함된 헤더들만 꺼내볼 수 있습니다.
  • Opaque 및 Opaque redirect 응답: 상태 코드(status)가 0이고, 헤더 목록은 텅 비어있으며, 본문은 null입니다. (즉, 속을 전혀 들여다볼 수 없습니다.)

헤더 확인하기 (Checking headers)

요청 객체와 마찬가지로, 응답 객체도 headers 속성을 가지고 있어요. 이것 역시 Headers 객체입니다. 앞서 말한 응답 타입에 따른 제한 규칙에 따라, 스크립트에서 접근할 수 있는 응답 헤더들이 모두 이 안에 들어있습니다.

이 속성을 활용하는 가장 흔한 사례는, 본문(body) 내용을 읽기 전에 데이터 형식(Content-Type)이 올바른지 체크해보는 거예요. JSON을 기대했는데 서버가 HTML 오류 페이지를 줬다면 파싱하다가 에러가 날 테니까요!

async function fetchJSON(request) {
  try {
    const response = await fetch(request);
    const contentType = response.headers.get("content-type");
    if (!contentType || !contentType.includes("application/json")) {
      throw new TypeError("Oops, we haven't got JSON!"); // 앗, JSON 데이터가 아니네요!
    }
    // 무사히 통과했다면, 이제 본문을 JSON으로 읽어오면 됩니다.
  } catch (error) {
    console.error("Error:", error);
  }
}

응답 본문 읽기 (Reading the response body)

Response 인터페이스는 전체 본문 내용을 다양하고 편리한 포맷으로 꺼내볼 수 있도록 여러 메서드를 제공합니다:

이 메서드들은 전부 비동기(asynchronous) 로 동작합니다! 즉, 본문 내용과 함께 이행되는 Promise를 반환하므로 앞에 await를 붙이거나 .then()으로 처리해야 합니다.

아래 예제에서는 이미지를 가져온 다음 본문을 Blob으로 읽고, 그걸 이용해 화면에 보여줄 Object URL을 만드는 과정을 보여줍니다.

const image = document.querySelector("img");

const url = "flowers.jpg";

async function setImage() {
  try {
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error(`Response status: ${response.status}`);
    }
    const blob = await response.blob();
    const objectURL = URL.createObjectURL(blob);
    image.src = objectURL;
  } catch (e) {
    console.error(e);
  }
}

만약 응답 본문이 우리가 요청한 포맷과 맞지 않다면(예를 들어 JSON으로 파싱할 수 없는 텍스트인데 json()을 호출했다면) 이 메서드들은 예외를 던집니다.

응답 본문 스트리밍하기 (Streaming the response body)

요청과 응답의 본문은 사실 겉보기에만 단순 데이터일 뿐, 내부적으로는 ReadableStream 객체입니다. 여러분이 본문을 읽을 때 사실은 데이터를 '스트리밍'하고 있는 거예요. 이건 메모리 효율 측면에서 아주 훌륭한 설계입니다. 브라우저가 거대한 응답 파일 전체를 메모리에 전부 다 올려놓고 대기할 필요 없이, json() 같은 메서드로 요청할 때 쪼개서 처리할 수 있거든요.

게다가 개발자가 원한다면, 네트워크에서 데이터가 넘어오는 족족 아주 조금씩 점진적으로 처리하는 것도 가능합니다.

예를 들어, 엄청나게 큰 텍스트 파일을 가져와서 어떤 처리를 하거나 화면에 보여주는 GET 요청이 있다고 가정해 볼게요:

const url = "[https://www.example.org/a-large-file.txt](https://www.example.org/a-large-file.txt)";

async function fetchText(url) {
  try {
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error(`Response status: ${response.status}`);
    }

    const text = await response.text();
    console.log(text);
  } catch (e) {
    console.error(e);
  }
}

위 코드처럼 Response.text()를 사용하면, 서버로부터 거대한 파일이 처음부터 끝까지 100% 다 다운로드될 때까지 기다려야만 텍스트 처리를 시작할 수 있습니다.

하지만 대신 스트림 방식으로 처리하면 네트워크에서 데이터 덩어리(chunk)들이 도착하는 족족 바로바로 처리할 수 있죠.

const url = "[https://www.example.org/a-large-file.txt](https://www.example.org/a-large-file.txt)";

async function fetchTextAsStream(url) {
  try {
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error(`Response status: ${response.status}`);
    }

    const stream = response.body.pipeThrough(new TextDecoderStream());
    for await (const value of stream) {
      console.log(value);
    }
  } catch (e) {
    console.error(e);
  }
}

이 예제에서는 도착하는 스트림 덩어리들을 비동기적으로 반복(iterate asynchronously) 돌면서 바로바로 처리하고 있습니다.

이렇게 본문에 직접 접근하면 데이터가 원시 바이트(raw bytes) 단위로 들어옵니다. 그래서 개발자가 직접 이걸 알아볼 수 있는 형태로 변환해야 해요. 위 코드에서는 ReadableStream.pipeThrough()를 호출해서 받아온 데이터를 TextDecoderStream 필터에 통과시키고 있습니다. 이 객체가 알아서 UTF-8로 인코딩된 바이트 데이터를 예쁜 텍스트로 변환해 주는 역할을 하죠.

💡 강사님의 추가 설명:
"요즘 ChatGPT 같은 AI 챗봇이 답변을 한 글자 한 글자씩 타이핑 치듯이 보여주죠? 그게 바로 이 Stream 기능을 활용한 대표적인 사례입니다. 전체 답변이 완성될 때까지 멍하니 기다리게 하는 대신, 스트림으로 넘어오는 조각(chunk)들을 바로 화면에 뿌려주는 것이죠!"

텍스트 파일을 한 줄씩 처리하기 (Processing a text file line by line)

아래의 조금 더 발전된 예제는 텍스트 리소스를 가져온 뒤, 줄바꿈 기호를 찾는 정규 표현식을 사용해 텍스트를 한 줄(line)씩 나눠서 처리하는 방법을 보여줍니다. 코드의 간소화를 위해 문서는 무조건 UTF-8이라 가정하고, fetch 에러 처리는 생략했습니다.

async function* makeTextFileLineIterator(fileURL) {
  const response = await fetch(fileURL);
  const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();

  let { value: chunk = "", done: readerDone } = await reader.read();

  const newline = /\r?\n/g;
  let startIndex = 0;

  while (true) {
    const result = newline.exec(chunk);
    if (!result) {
      if (readerDone) break;
      const remainder = chunk.slice(startIndex);
      ({ value: chunk, done: readerDone } = await reader.read());
      chunk = remainder + (chunk || "");
      startIndex = newline.lastIndex = 0;
      continue;
    }
    yield chunk.substring(startIndex, result.index);
    startIndex = newline.lastIndex;
  }

  if (startIndex < chunk.length) {
    // 마지막 줄이 줄바꿈 기호로 끝나지 않았을 경우 처리
    yield chunk.substring(startIndex);
  }
}

async function run(urlOfFile) {
  for await (const line of makeTextFileLineIterator(urlOfFile)) {
    processLine(line);
  }
}

function processLine(line) {
  console.log(line);
}

run("[https://www.example.org/a-large-file.txt](https://www.example.org/a-large-file.txt)");

잠긴 스트림과 손상된 스트림 (Locked and disturbed streams)

요청과 응답의 본문이 스트림이기 때문에 생기는 두 가지 중요한 규칙이 있습니다:

  • ReadableStream.getReader()를 호출해서 스트림에 '리더(reader)'가 부착되는 순간, 해당 스트림은 잠깁니다(locked). 그러면 다른 누구도 이 스트림을 읽을 수 없게 됩니다.
  • 스트림에서 내용을 한 번이라도 읽어냈다면, 그 스트림은 손상됩니다(disturbed). 훼손된 이후에는 더 이상 해당 스트림을 읽을 수 없습니다.

결론적으로, 같은 응답(혹은 요청) 본문은 절대로 두 번 읽을 수 없다는 뜻입니다.

아래처럼 코드를 짜면 에러가 폭발합니다!

// 나쁜 예시 코드 (에러 발생)
async function getData() {
  const url = "[https://example.org/products.json](https://example.org/products.json)";
  try {
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error(`Response status: ${response.status}`);
    }

    const result1 = await response.json();
    const result2 = await response.json(); // 여기서 예외(throw)가 발생합니다! 이미 스트림을 소비했기 때문이죠.
  } catch (error) {
    console.error(error.message);
  }
}

만약 어쩔 수 없이 응답 본문을 두 번 이상 읽어야 하는 상황이라면, 본문을 읽어버리기 전에 반드시 Response.clone()을 호출해서 복사본을 쟁여두어야 합니다.

async function getData() {
  const url = "[https://example.org/products.json](https://example.org/products.json)";
  try {
    const response1 = await fetch(url);
    if (!response1.ok) {
      throw new Error(`Response status: ${response1.status}`);
    }

    // 본문을 읽기 전에 미리 복제본을 만듭니다.
    const response2 = response1.clone();

    const result1 = await response1.json();
    const result2 = await response2.json(); // 이제는 에러가 나지 않습니다!
  } catch (error) {
    console.error(error.message);
  }
}

이 패턴은 서비스 워커로 오프라인 캐시 기능을 구현할 때 아주 자주 쓰이는 필수 기법입니다. 서비스 워커는 브라우저 앱에게 응답을 전달해 줘야 하는 동시에, 다음번 오프라인 접속을 위해 해당 응답을 캐시(저장소)에도 넣어둬야 하거든요. 그래서 원본 응답을 복제한 뒤, 원본은 화면에 그려주는 앱 쪽에 던져주고 복제본은 캐시 저장소에 집어넣는 방식을 씁니다.

async function cacheFirst(request) {
  const cachedResponse = await caches.match(request);
  if (cachedResponse) {
    return cachedResponse;
  }
  try {
    const networkResponse = await fetch(request);
    if (networkResponse.ok) {
      const cache = await caches.open("MyCache_1");
      // 앱에 반환할 원본은 놔두고, clone()으로 만든 복제본을 캐시에 저장합니다!
      cache.put(request, networkResponse.clone());
    }
    return networkResponse;
  } catch (error) {
    return Response.error();
  }
}

self.addEventListener("fetch", (event) => {
  if (precachedResources.includes(url.pathname)) {
    event.respondWith(cacheFirst(event.request));
  }
});

안녕하세요 예비 프론트엔드 개발자 여러분! 오늘 함께 살펴볼 MDN 공식 문서는 브라우저 통신 기술의 새로운 패러다임을 열어줄 '지연된 Fetch 사용하기 (Using Deferred Fetch)' 입니다.

사용자가 페이지를 닫고 나갈 때 데이터를 안전하게 전송하는 건 프론트엔드 개발자들의 오랜 골칫거리였죠. 이 문제를 말끔하게 해결해 줄 fetchLater() API에 대해 우리 평소 수업하듯 편안하고 자세하게, 그리고 실무 꿀팁까지 곁들여서 알아볼게요. 자, 시작해 봅시다!


지연된 Fetch 사용하기 (Using Deferred Fetch)

fetchLater() API는 지정된 시간이 지난 후, 혹은 사용자가 탭을 닫거나 다른 페이지로 이동할 때(navigated away) 전송될 수 있도록 지연된 Fetch(deferred fetch) 요청을 예약하는 인터페이스를 제공합니다.


개요 (Overview)

우리 개발자들은 종종 사용자의 페이지 방문이 끝나는 시점에 서버로 데이터를 전송(비컨, beacon)해야 할 때가 있습니다. 구글 애널리틱스 같은 데이터 분석 서비스가 대표적인 예죠. 이를 구현하기 위해 그동안 여러 가지 방법이 쓰였습니다. 1픽셀짜리 <img> 태그를 페이지에 꼼수로 넣는 것부터, XMLHttpRequest, 전용 API인 Beacon API, 그리고 Fetch API 자체를 사용하는 방법까지 말이죠.

문제는 이 모든 방법들이 '방문 종료 시점(end-of-visit)'에 데이터를 보낼 때 신뢰성(reliability) 문제를 겪는다는 겁니다. Beacon API나 Fetch API의 keepalive 속성을 사용하면 문서가 파괴되더라도 브라우저가 최선을 다해 데이터를 보내주긴 하지만, 이건 문제의 절반만 해결할 뿐이에요.

나머지 절반의 더 까다로운 문제는 바로 '언제' 데이터를 보낼지 결정하는 것입니다. 웹 페이지의 생명주기(lifecycle) 안에서 비컨을 쏘는 자바스크립트를 실행하기에 완벽하게 이상적인 타이밍은 없거든요.

  • unloadbeforeunload 이벤트는 신뢰할 수 없고, 심지어 주요 브라우저들에서는 아예 대놓고 무시하기도 합니다.
  • pagehidevisibilitychange 이벤트가 그나마 더 믿을 만하지만, 모바일 환경에서는 여전히 한계가 있습니다.

이렇다 보니, 비컨을 통해 데이터를 확실하게 보내고 싶은 개발자들은 이상적인 타이밍보다 훨씬 더 자주 데이터를 쏴야만 했습니다. 예를 들어, 페이지에 머문 최종 시간이 아직 확정되지도 않았는데 상태가 변할 때마다 비컨을 보내는 식이죠. 이건 네트워크 데이터 낭비, 서버 처리 비용 증가, 그리고 서버에서 낡은 비컨 데이터들을 일일이 병합하거나 버려야 하는 후처리 비용을 발생시킵니다.

아니면 아예 일부 데이터가 유실되는 걸 감수하는 방법을 택하기도 하죠:

  • 지정된 컷오프(cut-off) 시간 이후에 비컨을 쏘고, 그 이후에 발생하는 데이터는 포기해 버리거나.
  • 페이지 생명주기의 맨 마지막에 비컨을 쏘되, 가끔씩 전송이 실패하는 건 어쩔 수 없다고 받아들이거나요.

💡 강사님의 실무 팁!
"실무에서 사용자 로그(Log) 수집 시스템을 짤 때 이 문제가 정말 심각하게 다가옵니다. 사용자가 '구매 완료' 버튼을 누르자마자 창을 닫아버리면, 로그 전송 요청이 네트워크를 타기도 전에 브라우저 프로세스가 죽어버려서 로그가 날아가 버리거든요. 그래서 울며 겨자 먹기로 setTimeout을 주거나, localStorage에 임시 저장했다가 다음 방문 때 보내는 복잡한 로직을 짰었는데, 이제 fetchLater()가 이걸 우아하게 해결해 줍니다!"

fetchLater() API는 Fetch API를 확장해서 이러한 fetch 요청들을 미리 '예약(set up in advance)'할 수 있게 해 줍니다. 이렇게 지연된 fetch들은 아직 실제로 전송되기 전이라면 내용을 업데이트할 수도 있어서, 최종적으로 쏘아질 페이로드(payload, 전송 데이터)에 가장 최신 데이터를 담을 수 있습니다.

그럼 브라우저가 알아서 탭이 닫히거나 페이지를 벗어날 때, 혹은 (지정했다면) 설정된 시간이 지났을 때 비컨을 쏴줍니다. 이렇게 하면 비컨을 여러 번 쏠 필요도 없고, (브라우저 프로세스가 충돌해서 갑자기 꺼지는 등 예외적인 상황만 제외하면) 우리가 기대하는 합리적인 선에서 안정적인 비컨 전송을 보장받을 수 있습니다.

만약 지연된 fetch가 더 이상 필요 없어졌다면, 불필요한 네트워크 비용을 아끼기 위해 AbortController를 사용해 요청을 취소(abort)할 수도 있답니다.


할당량 (Quotas)

지연된 fetch 요청들은 차곡차곡 모아졌다가(batched) 탭이 닫힐 때 한 번에 전송됩니다. 이때가 되면 사용자가 이 요청들을 취소할 방법이 없죠. 악의적인 문서가 이 대역폭을 악용해서 네트워크로 무제한의 데이터를 쏟아내는 걸 막기 위해, 최상위 문서(top-level document)에 주어지는 전체 할당량은 640KiB로 캡(cap, 상한선)이 씌워져 있습니다.

fetchLater()를 호출하는 개발자는 코드를 방어적으로 작성해야 하며, 특히 서드파티(제3자) 자바스크립트를 임베드하는 경우 거의 항상 QuotaExceededError 에러를 try...catch로 잡아내야 합니다.

이러한 제한 때문에 지연된 fetch의 대역폭은 매우 희소한 자원이 됩니다. 여러 보고 출처(reporting origins, 예를 들어 여러 개의 RUM(Real User Monitoring) 라이브러리)와 다양한 출처의 하위 프레임(subframes)들이 이 자원을 나눠 써야 하기 때문이죠. 그래서 플랫폼은 이 할당량에 대해 합리적인 기본 분배 방식을 제공합니다. 또한, 원한다면 할당량을 다르게 쪼갤 수 있도록 deferred-fetchdeferred-fetch-minimal 이라는 권한 정책 (Permissions Policy) 지시어를 제공합니다.

fetchLater()의 전체 할당량은 문서당 640KiB입니다. 기본적으로 이는 512KiB의 최상위(top-level) 할당량128KiB의 공유(shared) 할당량으로 나뉩니다:

  • 512KiB 최상위 할당량 (기본값): 최상위 문서와 동일한 출처를 사용하는 직계 하위 프레임에서 만들어진 fetchLater() 요청들이 사용합니다.
  • 128KiB 공유 할당량 (기본값): 교차 출처(cross-origin) 하위 프레임(예: <iframe>, <object>, <embed>, <frame> 요소)에서 만들어진 fetchLater() 요청들이 사용합니다.

fetchLater() 요청은 문서나 하위 프레임과 동일한 출처로만 제한되지 않고 어떤 URL로든 보낼 수 있습니다. 따라서, 최상위 문서의 콘텐츠에서 만들어진 요청(1사(first-party)로 보내든 3사(third-party) 출처로 보내든)과 하위 프레임에서 만들어진 요청을 구분하는 것이 중요합니다.

예를 들어볼까요? a.com이라는 최상위 문서 안에 analytics.example.com으로 fetchLater()를 쏘는 <script>가 포함되어 있다면, 이 요청은 최상위 512KiB 제한의 적용을 받습니다. 반대로, 최상위 문서가 analytics.example.com을 소스(src)로 하는 <iframe>을 임베드했고 그 안에서 fetchLater() 요청을 만든다면, 그 요청은 128KiB 제한의 적용을 받게 됩니다.

보고 출처 및 하위 프레임별 할당량 제한 (Quota limits by reporting origin and subframe)

최상위 512KiB 할당량 중에서, 동일한 보고 출처(요청 URL의 출처)에 대해 동시에 쓸 수 있는 용량은 64KiB뿐입니다. 이는 서드파티 라이브러리들이 보낼 데이터가 생기기도 전에 기회주의적으로 할당량을 미리 선점해버리는 걸 막기 위해서입니다.

각 교차 출처 하위 프레임은 기본적으로 128KiB의 공유 할당량 중에서 8KiB의 할당량을 받게 되는데, 이 할당량은 해당 하위 프레임이 DOM에 추가될 때 (실제로 fetchLater()를 쓸지 안 쓸지와 상관없이) 즉시 할당됩니다. 즉, 페이지에 16개의 교차 출처 하위 프레임이 추가되면 128KiB(16 * 8KiB) 할당량이 전부 소진되므로, 보통은 처음 추가된 16개의 교차 출처 하위 프레임만 fetchLater()를 사용할 수 있다는 뜻입니다.

💡 강사님의 실무 팁!
"웹페이지에 광고 배너 <iframe>이나 소셜 미디어 위젯을 많이 달게 되면, 이 128KiB(16개) 제한에 금방 걸릴 수 있습니다. 이럴 때는 아래에서 설명하는 '권한 정책(Permissions Policy)' HTTP 헤더를 통해 똑똑하게 용량을 재분배하는 설계가 프론트엔드 아키텍처의 핵심 역량이 됩니다."

최상위 할당량을 공유하여 하위 프레임 할당량 늘리기 (Increasing subframe quotas by sharing the top-level quota)

최상위 출처는 선택한 교차 출처 하위 프레임들에게 더 큰 최상위 512KiB 제한에서 용량을 떼어주어 64KiB의 증가된 할당량을 부여할 수 있습니다. 이를 위해서는 deferred-fetch 권한 정책 지시어에 해당 출처들을 나열하면 됩니다. 이 할당량 역시 하위 프레임이 DOM에 추가될 때 할당되므로, 결과적으로 최상위 문서와 동일 출처 직계 하위 프레임들이 쓸 수 있는 할당량은 그만큼 줄어들게 됩니다. 여러 개의 동일 출처 하위 도메인들이 각각 64KiB의 할당량을 가져갈 수도 있습니다.

공유 할당량 제한하기 (Restricting the shared quota)

최상위 출처는 deferred-fetch-minimal 권한 정책에 특정 출처들을 나열하여, 128KiB 공유 할당량을 오직 이름이 명시된 교차 출처 하위 프레임들만 쓰도록 제한할 수 있습니다. 또한, deferred-fetch-minimal 정책을 ()로 설정해서 128KiB 기본 하위 프레임 할당량을 아예 취소(revoke)해버리고, 전체 640KiB 할당량을 최상위 문서와 deferred-fetch에 명시된 교차 출처들만 오롯이 독식하게 만들 수도 있습니다.

하위 프레임의 하위 프레임으로 할당량 위임하기 (Delegating quotas to subframes of subframes)

기본적으로 하위 프레임의 하위 프레임(손자 프레임)에게는 할당량이 부여되지 않으므로 fetchLater()를 쓸 수 없습니다. 하지만 증가된 64KiB 할당량을 받은 하위 프레임은 자신만의 deferred-fetch 권한 정책을 설정해서, 자신의 하위 프레임들에게 자신의 전체 64KiB 할당량을 그대로 위임(delegate) 하여 그들이 fetchLater()를 쓰게 해줄 수 있습니다.

주의할 점은, 용량의 일부만 쪼개서 주거나 새로운 용량을 지정할 수는 없으며, 오직 자신의 전체 할당량을 통째로 위임하는 것만 가능합니다. 최소 할당량인 8KiB를 쓰는 하위 프레임은 다른 프레임에 할당량을 위임할 수 없습니다. 손자 프레임이 할당량을 위임받으려면, 최상위 문서와 중간 하위 프레임 양쪽 모두의 deferred-fetch Permissions-Policy 지시어에 포함되어 있어야 합니다.

할당량을 초과했을 때 (When quotas are exceeded)

할당량을 초과한 상태에서 지연된 요청을 시작하려고 fetchLater() 메서드를 호출하면 QuotaExceededError 에러가 발생(throw)합니다.

에러만 보고서는 이것이 실제 용량이 초과해서 난 에러인지, 아니면 권한 정책(Permissions Policy)에 의해 제한되어서 난 에러인지 구분할 수 없습니다. 실제로 용량을 초과했을 때도, 그리고 권한 정책으로 인해 해당 출처의 용량이 제한되었을 때도 fetchLater()는 똑같이 QuotaExceededError를 발생시킵니다.

따라서 fetchLater()를 호출할 때는 거의 모든 경우에 방어적으로 코드를 작성하여 QuotaExceededErrorcatch로 잡아내야 하며, 서드파티 자바스크립트를 포함할 때는 특히 더 주의해야 합니다.


할당량 예시 (Quota examples)

최소 할당량 소진하기 (Using up the minimal quota)

Permissions-Policy: deferred-fetch=(self "[https://b.com](https://b.com)")
  1. <iframe src="https://b.com/page">는 최상위 문서의 512KiB 한도 내에서 64KiB를 받으며 DOM에 추가됩니다.
  2. <iframe src="https://c.com/page">는 리스트에 없으므로, 최상위 문서에 추가될 때 128KiB 공유 한도 내에서 8KiB를 받습니다.
  3. 이후 15개의 교차 출처 iframe이 더 추가되면 각각 8KiB씩 받게 됩니다 (c.com과 동일).
  4. 그다음 추가되는 교차 출처 iframe은 더 이상 할당량을 받지 못합니다. (128KiB가 모두 찼기 때문이죠)
  5. 만약 교차 출처 iframe 중 하나가 삭제(removed)되면, 그 안에 있던 지연된 fetch들이 전송됩니다.
  6. 그렇게 되면 다시 사용 가능한 할당량이 생기므로, 다음번 교차 출처 iframe은 8KiB의 할당량을 무사히 받을 수 있습니다.

명시된 출처로 최소 할당량 제한하기 (Restricting the minimal quota to named origins)

Permissions-Policy: deferred-fetch-minimal=("[https://b.com](https://b.com)")
  1. <iframe src="https://b.com/page">는 최상위 문서에 추가될 때 8KiB를 받습니다.
  2. <iframe src="https://c.com/page">는 명시되지 않았으므로 최상위 문서에 추가될 때 할당량을 아예 받지 못합니다.
  3. 최상위 문서와 그와 동일한 출처를 가진 자손 프레임들은 최대 512KiB를 사용할 수 있습니다.

최상위 예외를 허용하면서 최소 할당량 완전히 취소하기 (Revoking the minimal quota altogether with top-level exceptions)

Permissions-Policy: deferred-fetch=(self "[https://b.com](https://b.com)")
Permissions-Policy: deferred-fetch-minimal=()
  1. <iframe src="https://b.com/page">는 최상위 문서에 추가될 때 64KiB를 받습니다.
  2. <iframe src="https://c.com/page">는 최상위 문서에 추가될 때 할당량을 아예 받지 못합니다.
  3. 최상위 문서와 동일 출처 자손 프레임들은 전체 640KiB를 풀로 사용할 수 있지만, b.com 하위 프레임이 생성되면 574KiB(640-66)로 줄어듭니다. (만약 b.com 하위 프레임이 여러 개 생성된다면, 각각 64KiB씩 할당되므로 가용 용량은 더 줄어듭니다).

예외 없이 최소 할당량 완전히 취소하기 (Revoking the minimal quota altogether with no exceptions)

Permissions-Policy: deferred-fetch-minimal=()
  1. 최상위 문서와 그와 동일한 출처를 가진 자손 프레임들은 전체 640KiB를 전부 사용할 수 있습니다.
  2. 하위 프레임들은 어떤 할당량도 받지 못하며 fetchLater()를 사용할 수 없게 됩니다.

동일 출처 하위 프레임은 최상위와 할당량을 공유하며 하위 프레임으로 위임 가능 (Same-origin subframes share quota with the top-level and can delegate to subframes)

최상위 문서가 a.com에 있고, 그 안에 a.com의 하위 프레임을 임베드하고, 그 안에 다시 b.com의 하위 프레임을 임베드한 상황 (명시적인 권한 정책이 없다고 가정):

  1. a.com의 최상위 문서는 기본 512KiB 할당량을 가집니다.
  2. <iframe src="https://a.com/embed">는 최상위 문서에 추가될 때 512KiB 할당량을 최상위 문서와 공유합니다.
  3. <iframe src="https://b.com/embed">는 최상위 문서에 추가될 때 8KiB의 할당량을 받습니다.

교차 출처 하위 프레임에 의해 단절된 동일 출처 하위 프레임은 최상위와 할당량을 공유할 수 없음 (Same-origin subframes can not share quota with the top-level when separated by a cross-origin subframe)

최상위 문서가 a.com에 있고, 그 안에 <iframe src="https://b.com/">을 임베드하고, 그 안에 다시 <iframe src="https://a.com/embed">를 임베드한 상황 (명시적인 권한 정책이 없다고 가정):

  1. a.com의 최상위 문서는 기본 512KiB 할당량을 가집니다.
  2. <iframe src="https://b.com/">는 8KiB의 할당량을 공유받습니다.
  3. <iframe src="https://a.com/embed">는 할당량을 받지 못합니다. 비록 최상위 문서와 동일한 출처(a.com)이긴 하지만, 중간에 교차 출처(b.com)로 인해 흐름이 단절되었기 때문입니다.

하위 프레임의 2차 하위 프레임은 기본적으로 할당량을 얻지 못함 (Secondary subframes of subframes do not get quota by default)

최상위 문서가 a.com에 있고, 그 안에 <iframe src="https://b.com/">을 임베드하고, 그 안에 다시 <iframe src="https://c.com/">을 임베드한 상황 (명시적인 권한 정책이 없다고 가정):

  1. a.com의 최상위 프레임은 기본 512KiB 할당량을 가집니다.
  2. <iframe src="https://b.com/">는 기본 공유 할당량 중 8KiB를 받습니다.
  3. <iframe src="https://c.com/">는 할당량을 받지 못합니다.

하위의 하위 프레임에 전체 할당량 부여하기 (Granting the full quota to a further subframe)

최상위 문서가 a.com에 있고, 그 안에 <iframe src="https://b.com/">을 임베드하고, 그 안에 다시 <iframe src="https://c.com/">을 임베드한 상황입니다.

a.com에 다음과 같은 권한 정책이 설정되어 있다고 가정합니다:

Permissions-Policy: deferred-fetch=("[https://c.com](https://c.com)" "[https://c.com](https://c.com)")

그리고 b.com에는 다음과 같은 권한 정책이 설정되어 있다고 가정합니다:

Permissions-Policy: deferred-fetch=("[https://c.com](https://c.com)")
  1. a.com의 최상위 프레임은 기본 512KiB 할당량을 가집니다.
  2. <iframe src="https://b.com/">는 기본 할당량 중 64KiB를 받습니다.
  3. <iframe src="https://b.com/">는 자신이 받은 전체 8KiB(역주: MDN 원문의 8KiB는 오류로 보이며 문맥상 64KiB 전체를 위임하는 구조입니다) 할당량을 c.com에 그대로 위임(delegate)합니다. 따라서 b.com은 스스로 fetchLater()를 쓸 수 없게 됩니다.
  4. <iframe src="https://c.com/">는 8KiB (위임받은) 할당량을 획득합니다.

리다이렉트 시 할당량은 전송되지 않음 (Redirects do not transfer quota)

최상위 문서가 a.com에 있고, 그 안에 임베드된 <iframe src="https://b.com/">이 다시 c.com으로 리다이렉트(redirect) 되는 상황 (명시적인 권한 정책이 없다고 가정):

  1. a.com의 최상위 프레임은 기본 512KiB 할당량을 가집니다.
  2. <iframe src="https://b.com/">는 기본 공유 할당량 중 8KiB를 받습니다.
  3. <iframe src="https://b.com/">c.com으로 리다이렉트 되더라도, 기존에 받은 8KiB가 c.com으로 넘겨지지 않습니다. 그렇다고 그 8KiB가 곧바로 환수(반환)되는 것도 아닙니다. (공중분해 되는 셈이죠.)

샌드박스 처리된 동일 출처 iframe은 사실상 별개의 출처로 취급됨 (Sandboxed same-origin iframes are effectively separate origins)

예를 들어, https://www.example.com에 다음과 같은 <iframe>이 임베드되었다고 가정해 봅시다:

<iframe src="[https://www.example.com/iframe](https://www.example.com/iframe)" sandbox="allow-scripts"></iframe>

이 iframe은 최상위 문서와 동일한 출처에서 호스팅됨에도 불구하고 "동일 출처(same-origin)"로 간주되지 않습니다. 샌드박스(sandboxed) 환경 안에 있기 때문이죠. 따라서 기본 설정에 따르면, 전체 공유 128KiB 할당량 중에서 별개의 8KiB 할당량을 부여받게 됩니다.

iframe에서 fetchLater() 사용 금지하기 (Disallowing fetchLater() from iframes)

<iframe>allow 속성을 사용하면 fetchLater() 할당량이 해당 <iframe>에 부여되는 것을 막을 수 있습니다:

<iframe
  src="[https://www.example.com/iframe](https://www.example.com/iframe)"
  allow="deferred-fetch;deferred-fetch-minimal;"></iframe>

동일 출처(same-origin) iframe이 512KiB 할당량을 갉아먹는 것을 막으려면 allow="deferred-fetch" 지시어가 필요하고, 교차 출처(cross-origin) iframe이 128KiB 할당량을 쓰는 것을 막으려면 allow="deferred-fetch-minimal" 지시어가 필요합니다. 두 지시어를 모두 포함하면 src의 출처가 무엇이든 상관없이 양쪽 할당량을 모두 쓰지 못하도록 원천 봉쇄할 수 있습니다.

QuotaExceededError를 발생시키는 예시들 (Examples which throw a QuotaExceededError)

// 출처당 최대 용량은 64KiB입니다.
const url = "<72KiB 길이의 문자열>";
fetchLater(url); // 에러! 용량 초과

// 헤더(headers)를 포함하여 출처당 최대 64KiB입니다.
fetchLater("[https://origin.example.com](https://origin.example.com)", { headers: headersExceeding64KiB }); // 에러!

// 본문(body)과 헤더(headers)를 합쳐서 출처당 최대 64KiB입니다.
fetchLater("<32KiB 문자열>", { headers: headersExceeding32KiB }); // 에러!

// 본문(body)을 포함하여 출처당 최대 64KiB입니다.
fetchLater("[https://origin.example.com](https://origin.example.com)", {
  method: "POST",
  body: bodyExceeding64KiB,
}); // 에러!

// 자동으로 추가되는 헤더(referrer 등)와 본문을 포함하여 최대 64KiB입니다.
fetchLater("<62KiB 문자열>" /* 3kb 짜리 referrer가 붙는다고 가정 */); // 에러! 합치면 65KiB

최종적으로 QuotaExceededError를 발생시키는 예시 (Examples which eventually throw a QuotaExceededError)

최상위 문서 안에 있는 다음 코드 시퀀스에서, 처음 두 개의 요청은 성공하지만 세 번째 요청은 에러를 던지게 됩니다. 전체 640KiB 할당량은 넘지 않았지만, 세 번째 요청이 들어가는 순간 https://a.example.com이라는 특정 '보고 출처(reporting-origin)'에 허용된 최대치(64KiB)를 초과하게 되기 때문입니다.

fetchLater("[https://a.example.com](https://a.example.com)", { method: "POST", body: a40KiBBody }); // a 출처: 누적 40KiB (성공)
fetchLater("[https://b.example.com](https://b.example.com)", { method: "POST", body: a40KiBBody }); // b 출처: 누적 40KiB (성공)
fetchLater("[https://a.example.com](https://a.example.com)", { method: "POST", body: a40KiBBody }); // a 출처: 누적 80KiB -> 64KiB 한도 초과! (에러 발생)

최상위 출처로 돌아오는 하위 프레임의 리다이렉트는 최상위 할당량을 사용할 수 있음 (Redirects of subframes back to the top-level origin allow use of the top-level quota)

최상위 문서가 a.com에 있고, 그 안에 <iframe src="https://b.com/">을 임베드했는데 그 프레임이 다시 a.com으로 리다이렉트 된 상황 (명시적인 권한 정책이 없다고 가정):

  1. a.com의 최상위 프레임은 기본 512KiB 할당량을 가집니다.
  2. <iframe src="https://b.com/">는 128KiB 공유 할당량 중 8KiB를 받습니다.
  3. <iframe src="https://b.com/">a.com으로 리다이렉트 될 때 8KiB 용량이 a.com으로 넘어가지는 않습니다. 하지만 돌아온 프레임은 이제 다시 최상위 프레임의 전체 할당량(512KiB 풀)을 쉐어할 수 있게 되며, 기존에 잡아먹었던 8KiB 할당량은 시스템에 다시 반환(release)되어 여유분이 됩니다.

이 페이지는 MDN 기여자들에 의해 2026년 1월 12일에 마지막으로 수정되었습니다.

자, 어떠셨나요? fetchLater() API는 사용자가 언제 탭을 닫고 도망갈지(?) 전전긍긍하던 개발자들에게 한 줄기 빛과도 같은 존재입니다. 물론 할당량 계산이나 권한 정책 등 조금 까다로운 제약이 있긴 하지만, 이 문서에서 배운 내용들을 바탕으로 꼼꼼히 에러 처리를 하신다면 훨씬 더 견고한 웹 애플리케이션을 만드실 수 있을 겁니다. 화이팅!

profile
프론트에_가까운_풀스택_개발자

0개의 댓글