원정대 활동을 하면서 fetch에 대해 파보기로 해서 내부 동작원리를 공부해보았다.
먼저 fetch란 서버로 네트워크 요청을 보내고 응답을 받을 수 있도록 해주는 매서드이다.
fetch(url)이라는 한 줄짜리 코드가 실행될 때, 브라우저 내부에서는 레이어가 5개 지나간다.
각 단계를 순서대로 살펴보자.
fetch는 JavaScript가 아니다
먼저 짚고 넘어가야 할 점이 있다.
fetch는 JavaScript 언어 자체의 기능이 아니다. 브라우저가 C++로 구현해서 JavaScript에 제공하는 Web API다.
내 JavaScript 코드
↓
Web API (fetch) ← 브라우저가 C++로 구현한 부분
↓
네트워크 스택
fetch()를 호출하는 순간 JS 스레드는 그 작업에서 손을 뗀다. 실제 네트워크 작업은 브라우저의 별도 스레드가 처리한다.
fetch가 진행되는 동안 다른 JS 코드가 멈추지 않는 이유가 여기에 있다.
전체 흐름
fetch(url)이 호출된 순간부터 데이터를 받기까지의 흐름은 다음과 같다.
fetch(url, options) 호출
↓
1. Request 객체 생성
↓
2. Service Worker 인터셉트 확인
↓
3. HTTP Cache 확인
↓
4. CORS Preflight (필요한 경우)
↓
5. DNS 조회 → TCP 연결 → TLS 핸드셰이크
↓
6. HTTP 요청 전송
↓
7. 헤더 수신 → Promise resolve
↓
8. Body 수신 (res.json() 등으로 소비)
1단계 — Request 객체 생성
fetch()를 호출하면 가장 먼저 Request 객체가 만들어진다.
fetch('https://api.example.com/data', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'charlie' })
});
내부에서는 다음 순서로 처리된다.
fetch(url, options) 호출
↓
new Request(url, options) 생성
↓
URL 파싱 → scheme / host / path / query 분리
↓
헤더 정규화 → 대소문자 소문자로 통일
↓
body → ReadableStream으로 변환
여기서 중요한 점은 body가 ReadableStream으로 변환된다는 것이다.
데이터를 한 번에 메모리에 올리는 것이 아니라 스트림으로 관리하기 때문에, 파일이 크더라도 처리할 수 있다.
2단계 — 브라우저 네트워크 레이어
Request 객체가 완성되면 브라우저 네트워크 레이어로 넘어간다. 여기서 세 가지 확인이 순서대로 이루어진다.
Service Worker 인터셉트 확인
네트워크 요청이 실제로 나가기 전에, 브라우저는 Service Worker가 등록되어 있는지 먼저 확인한다.
Service Worker는 브라우저와 서버 사이에서 요청을 가로챌 수 있는 스크립트다.
등록되어 있다면 실제 네트워크 요청 대신 캐시된 응답을 돌려주는 것이 가능하다.
인터넷이 없어도 앱이 동작하는 오프라인 기능이 이 원리로 만들어진다.
Service Worker가 없거나 가로채지 않으면 다음 단계로 넘어간다.
fetch 요청
↓
Service Worker가 등록되어 있는가?
→ YES → SW의 fetch 이벤트 발생 → respondWith()로 응답 가로채기 가능
→ NO → 다음 단계로
HTTP Cache 확인
브라우저는 이전에 같은 URL로 요청한 적이 있다면 그 결과를 저장해둔다. 이것이 HTTP Cache다.
fetch의 cache 옵션으로 이 캐시를 어떻게 다룰지 지정할 수 있다.
cache 옵션동작
| 'default' | 캐시가 있으면 사용하고, 없으면 서버에 요청 (기본값) |
| 'no-store' | 캐시를 완전히 무시하고, 결과도 저장하지 않음 |
| 'no-cache' | 캐시가 있어도 서버에 "아직 유효해?"를 먼저 물어봄 |
| 'force-cache' | 캐시를 무조건 사용 |
| 'reload' | 캐시를 무시하고 새로 요청하되, 결과는 다시 캐시에 저장 |
no-cache가 "캐시를 사용하지 않는다"는 뜻이 아니라는 점에 주의해야 한다.
캐시가 있더라도 서버에 유효성을 확인하는 것이지, 캐시를 버리는 것이 아니다.
CORS Preflight 판단
브라우저는 보안상의 이유로, 다른 출처(도메인)에 요청을 보낼 때 조건에 따라 사전 확인 요청을 먼저 보낸다.
이것이 CORS Preflight다.
Preflight가 생략되는 조건(Simple Request)은 다음과 같다.
- 메서드가 GET, POST, HEAD 중 하나
- 헤더가 기본 헤더(Accept, Content-Type 등)만 사용
- Content-Type이 application/x-www-form-urlencoded, multipart/form-data, text/plain 중 하나
이 조건을 하나라도 벗어나면 브라우저는 실제 요청을 보내기 전에 OPTIONS 메서드로 서버에 "이런 요청 받아줄 수 있어?"를 먼저 물어본다.
Content-Type: application/json 하나만 써도 이 조건에서 벗어나기 때문에, JSON 요청을 보낼 때는 항상 Preflight가 먼저 발생한다.
3단계 — TCP / TLS 연결
DNS 조회
api.example.com → 93.184.216.34
(캐시가 있으면 스킵)
↓
TCP 3-way handshake
클라이언트 → SYN → 서버
클라이언트 ← SYN-ACK ← 서버
클라이언트 → ACK → 서버
↓
TLS 1.3 핸드셰이크 (HTTPS)
→ 인증서 검증
→ 세션 키 협상
실제 네트워크 연결이 이루어지는 단계다.
- DNS 조회는 도메인 이름을 IP 주소로 바꾸는 과정이다. api.example.com이라는 주소를 실제 서버의 IP인 93.184.216.34 같은 숫자로 변환한다. 이전에 조회한 적이 있다면 캐시된 결과를 재사용한다.
- TCP 연결은 서버와 실제로 연결 통로를 만드는 과정이다. 클라이언트와 서버가 서로 신호를 주고받으며 연결을 수립한다(3-way handshake).
- TLS 핸드셰이크는 HTTPS 요청일 때 데이터를 암호화하기 위한 과정이다. 서버가 신뢰할 수 있는지 인증서를 확인하고, 이후 주고받을 데이터를 암호화할 키를 협상한다.
한 가지 중요한 점은, 같은 도메인에 두 번째 요청을 보낼 때는 이 연결을 재사용(Keep-Alive) 한다는 것이다. 매번 처음부터 연결을 맺지 않아도 되기 때문에 두 번째 요청부터는 훨씬 빠르다.
4단계 — HTTP 요청/응답
연결이 완료되면 HTTP 요청이 서버로 전송된다.
서버가 응답을 보내기 시작하면, fetch는 헤더가 도착한 순간 Promise를 resolve한다.
const res = await fetch(url);
// res는 이미 존재하지만, body는 아직 다 전송되지 않았을 수 있다
const data = await res.json();
// 이 시점에서 body를 끝까지 읽는다
fetch를 처음 배울 때 "왜 await을 두 번 쓰나"라는 질문이 생기는 이유가 여기에 있다.
Response 객체는 헤더만 가지고 먼저 온다.
body는 그 이후에 스트리밍으로 들어오고, res.json() 같은 메서드를 호출해야 그때 body 전체를 읽는다.
5단계 — Response Body는 ReadableStream이다
res.body는 ReadableStream이다. 직접 접근해서 청크 단위로 읽는 것도 가능하다.
const reader = res.body.getReader();
while (true) {
const { done, value } = await reader.read(); // 청크 단위로 읽기
if (done) break;
console.log('청크 도착:', value);
}
대용량 파일 다운로드나 SSE(Server-Sent Events) 처리에서 이 방식을 쓰면 메모리를 훨씬 효율적으로 사용할 수 있다.
res.json(), res.text(), res.blob() 같은 편의 메서드는 이 ReadableStream을 내부에서 처리해주는 래퍼다.
에러 처리 — 주의해야할 점
try {
const res = await fetch(url);
// 여기서 에러가 발생하는 경우: 네트워크 자체가 끊겼을 때만
if (!res.ok) {
// 404, 500은 직접 처리해야 한다
// fetch는 HTTP 에러를 throw하지 않는다
throw new Error(`HTTP ${res.status}`);
}
const data = await res.json();
} catch (e) {
// catch는 진짜 네트워크 오류만 잡는다
// CORS 실패, DNS 조회 실패, 연결 거부 등
}
fetch는 404나 500을 에러로 throw하지 않는다.
catch에서 잡힐 것이라 생각하고 넘겼다가 조용히 넘어가는 버그가 여기서 나온다.
res.ok 또는 res.status는 반드시 직접 확인해야 한다.
fetch를 JavaScript로만 구현할 수 있을까
결론부터 말하면, 완전히 똑같이는 불가능하다.
JavaScript는 보안 샌드박스 안에서 동작하기 때문에 TCP 소켓을 직접 열거나 DNS 조회를 직접 수행하는 것이 불가능하다.
TypeScript를 써도 마찬가지다. TypeScript는 결국 JavaScript로 컴파일되기 때문에 런타임 능력이 달라지지 않는다.
어떤 환경이든 JavaScript/TypeScript 레이어 아래에는 반드시 시스템 레벨의 코드가 존재한다.
- 브라우저: Web API (fetch, XMLHttpRequest) — C++ 구현
- Node.js: net / tls / http 모듈 — C++ 바인딩
- Deno / Bun: 각자의 런타임 — Rust / C++
다만 fetch와 동일한 형태의 API는 JavaScript로 흉내 낼 수 있다.
아래는 XHR(XMLHttpRequest)을 사용해 fetch처럼 동작하도록 구현한 예시다.
XHR이란 페이지 새로고침 없이 서버와 비동기적으로 데이터를 교환할 수 있게 해주는 브라우저 내장 API이다.
AJAX의 핵심 기술로, URL을 통해 데이터를 받아오거나 보내며, 주로 JSON, XML, HTML, 일반 텍스트를 처리하는데 사용된다.
const myFetch = (url, options = {}) => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const method = options.method || 'GET';
xhr.open(method, url);
if (options.headers) {
Object.entries(options.headers).forEach(([key, value]) => {
xhr.setRequestHeader(key, value);
});
}
xhr.onload = () => {
const response = {
ok: xhr.status >= 200 && xhr.status < 300,
status: xhr.status,
statusText: xhr.statusText,
json: () => {
try {
return Promise.resolve(JSON.parse(xhr.responseText));
} catch (e) {
return Promise.reject(e);
}
},
text: () => Promise.resolve(xhr.responseText),
};
resolve(response);
};
xhr.onerror = () => {
reject(new TypeError('Network request failed'));
};
xhr.send(options.body || null);
});
};
실제로 `node-fetch`, `cross-fetch` 같은 라이브러리들이 이 방식으로 만들어져 있다.
fetch API와 동일한 인터페이스를 제공하되, 내부적으로는 각 환경의 네트워크 모듈을 호출하는 구조다.
다만 이 구현은 실제 fetch와 비교하면 다음 기능들이 없다.
- AbortController를 통한 요청 중단
- ReadableStream 기반의 body 스트리밍
- Request 객체를 인자로 받는 것
- Headers 객체 형태의 응답 헤더
정리
fetch 한 줄이 실행될 때 일어나는 일을 요약하면 다음과 같다.
- Request 객체를 만든다 (URL 파싱, 헤더 정규화, body 스트림 변환)
- Service Worker가 요청을 가로채는지 확인한다
- HTTP Cache를 확인한다
- 필요하면 CORS Preflight를 먼저 보낸다
- DNS 조회 → TCP 연결 → TLS 핸드셰이크로 서버와 연결한다
- 요청을 전송한다
- 헤더가 도착하면 Promise가 resolve되고 Response 객체가 반환된다
- res.json() 등으로 body를 소비한다