XHR과 fetch의 내부 동작이 어떻게 다른지 알아보는 글이다.
해당 글을 읽기 전에 XHR - 왜 알아야하나? 과 fetch - 기본 동작 방식를 읽고 오면 더 잘 이해할 수 있을 것이다.
많은 차이가 있을 수 있겠지만 대표적으로 5가지가 있다.
1. 메모리 처리 방식: 버퍼링 vs 스트리밍
2. Task Queue vs Microtask Queue
3. 모던 웹 생태계와의 결합
4. 요청 취소 방식
5. 진행률 추적
이번 글에서는 1번인 메모리 처리 방식에 따른 XHR과 fetch 차이를 깊이 있게 알아볼 것이다.
메모리 처리 방식: 버퍼링 (Buffering) vs 스트리밍 (Streaming)
유튜브나 동영상을 보면서 버퍼링과 스트리밍에 대해 한 번씩은 들어보았을 것이다.
우리가 아는 버퍼링은 영상을 보다가 중간에 로딩이 화면에 뜨는 것이고, 스트리밍은 유튜브 라이브에서 스트리밍하고 있다라고 한다.
이러한 버퍼링과 스트리밍은 XHR과 fetch를 차이짓게 만드는 가장 결정적이다.
서버에서 1GB짜리 거대한 비디오 파일을 받아온다고 상상해 보자.
- XHR (Buffering 방식)
무식하게 데이터를 모은다. 1GB의 데이터가 랜카드를 통해 브라우저로 들어오면, XHR의 C++ 객체는 이 데이터가 100% 다 다운로드될 때까지 메모리(RAM)에 꾹꾹 눌러 담고 기다린다. 다 받아지면 그제야 1GB짜리 거대한 responseText를 만들어 JS 힙으로 던져준다. 아래 그림을 통해 그 과정을 자세하게 다루어 보자.

- xhr에서 onload 설정을 하고, send()를 통해 데이터를 요청한 후의 과정이라고 생각해보자.
- 외부 서버는 요청받은 데이터를 네트워크 처리 객체의 메모리(RAM) 영역으로 보낸다.
- 램은 데이터를 계속 다운로드하면서 100%가 될 때까지 기다린다.
- 100%가 되면 내부 상태 코드를 DONE (4)로 변경한다.
- 내부 상태 코드 변경에 따라 Task Queue에 실행 이벤트가 보내지고, Call Stack에서 실행되어 콜백 함수 A가 실행된다.

- 네트워크 처리 객체의 메모리에 있는 데이터에 접근하기 위해 콜백함수 A에 있는 xhr.responseText를 사용한다.
- responseText의 포인터를 타고 네트워크 처리 객체의 메모리에 접근한다.
- 접근을 하면 순수 0과 1의 Byte로 이루어진 데이터가 Decoder에 의해 문자열 객체로 변환된다. 이는 JS Memory Heap이 해당 데이터를 문자열로 읽을 수 있기 때문에 변환 과정이 필요하다.
- 변환된 문자열 객체를 JS Memory Heap에 그대로 복사한다.
- 해당 문자열 데이터를 JSON.parse()로 파싱하여 우리가 사용하는 JS 객체 데이터를 변환한다.
이와 같은 과정을 거쳐 네트워크 처리 객체 메모리에 있는 데이터를 우리가 사용할 수 있게 된다.
하지만, 이 과정에서 엄청난 메모리 소모가 발생한다.
xhr.responseText()를 통해 문자열 데이터를 생성한다. 이때, 만들어지는 문자열이 1GB라고 하자. (현재 JS 힙 메모리 사용량: 1GB)
1GB 문자열을 처음부터 끝까지 읽는다. 여기서도 CPU를 엄청 사용한다.
문자열을 분석하면서 JS 힙 메모리에 새로운 JS 객체를 만든다. 이때, 새로 만든 객체 1GB가 생성된다.
(현재 JS 힙 메모리 사용량: 기본 문자열 1GB + 새로 만든 객체 1GB)
순수한 데이터를 JSON 객체로 받기 위해, JS 힙 메모리에 똑같은 내용의 문자열 데이터와 객체 데이터가 동시에 존재한다.
- fetch (Streaming 방식)
fetch는 최신 Streams API를 기반으로 설계되었다. 1GB가 다 오기를 기다리지 않고, 물이 흐르듯 데이터가 도착하는 대로 조금씩(Chunk) 쪼개서 자바스크립트로 넘겨줄 수 있다. 덕분에 메모리를 거의 쓰지 않고도 거대한 파일을 실시간으로 처리하거나 비디오를 스트리밍할 수 있다.
아래 코드를 예시를 들어 자세히 설명을 하겠다.
async function fetchStreamData() {
const response = await fetch(url);
const reader = response.body.getReader();
// 날것의 0과 1(바이트)을 우리가 읽을 수 있는 문자로 바꿔주는 변환기
const decoder = new TextDecoder('utf-8');
let receivedLength = 0; // 진행률 추적을 위한 변수
console.log('데이터 스트리밍 수신 시작');
// 무한 루프를 돌며 데이터를 조금씩 퍼온다.
while (true) {
const { done, value } = await reader.read();
if (done) {
console.log('모든 스트림 읽기 완료. 총 수신 바이트:', receivedLength);
break;
}
receivedLength += value.length;
const chunkText = decoder.decode(value, { stream: true });
console.log('수신중, 방금 받은 조각 크기: ', value.length);
}
}
fetchStreamData();
1. 데이터를 다운로드하는 과정
// 서버로부터 응답 헤더를 받고 Response 객체 생성 (1차 Promise 완료)
const response = await fetch('https://api.example.com/large-data');
- 외부 서버로부터 응답 패킷이 들어오기 시작한다.
- 브라우저의 C++ 네트워크 엔진은 바디 데이터가 다 오기를 기다리지 않고, 패킷 앞 부분의 HTTP 헤더만 발견하면 즉시 바인딩을 따라 이동해 V8 엔진에게 헤더 정보를 전송한다.
- V8 엔진은 헤더 정보를 번역하여 JS 힙 메모리에 Response 객체를 생성한다.
- Response 객체에 헤더 정보를 입력한 후, Promise를 Fulfilled 상태로 변경한다.

헤더 인식 과정 - 이와 동시에 V8 엔진은 JS 힙 메모리에 ReadableStream 객체를 생성한다.
- ReadableStream 객체는 외부 서버에서 데이터를 받고 있는 C++ 스트림 파이프와 연결되어 있다.
- V8 엔진은 Response 객체의 body 속성을 ReadableStream 객체와 연결한다. Response.body를 통해 C++ 스트림 파이프에 접근할 수 있게 된다.

2. 데이터를 파싱하기까지의 과정
이전 fetch 기본 동작 방식 설명에서 json()을 예로 설명했으니 이번에는 getReader()를 통해 데이터를 받아오는 과정을 설명하려고 한다. 이전 내용을 알고 싶으면 가장 위에서 말한 글을 읽고 오면 좋다 (거기에 설명이 더 자세하게 작성되어있다.).
// [1단계: 독점적 판권 획득 (Locking)]
// 이 순간 ReadableStream에 자물쇠가 채워집니다.
const reader = response.body.getReader();
- Response 객체의 body 속성이 가지는 getReader() 메서드를 Call Stack에서 실행한다.
- V8 엔진은 기존의 ReadableStream 내부 슬롯 상태를 LOCKED로 변경한다. 또한, 해당 스트림을 통제할 수 있는 조종 장치인 ReadableStreamDefaultReader 객체를 JS 힙 메모리에 생성한다.
- ReadableStreamDefaultReader의 내부 슬롯 [[ownerReadableStream]]에 방금 잠근 ReadableStream의 주소를 적어 연결한다.
- ReadableStreamDefaultReader의 주소가 reader 변수에 저장된다.

- getReader() 메서드가 Call Stack에서 pop된다.
// [2단계: 조각 요청 (Read Request)]
// V8 엔진이 C++ 파이프에 "다음 바이트 조각 줘!" 라고 요청합니다.
const { done, value } = await reader.read();
- reader.read()가 Call Stack에 push되어 실행된다.
- V8 엔진은 JS 힙 메모리에 Pending 상태의 두 번째 Promise 객체를 생성한다.
- V8 엔진은 reader 객체에 연결된 [[ownerReadableStream]] 포인터를 타고 ReadableStream 객체로 간 뒤, 그곳에 바인딩되어 있는 C++ 스트림 파이프 주소를 추적한다.
- V8 엔진은 해당 주소의 네트워크 처리 객체에게 chunk만큼 데이터를 JS 힙 메모리에 보내라는 명령을 보낸다. 또한, 네트워크 처리 객체에 데이터가 도착하면 Promise를 resolve하라는 콜백을 등록해둔다.
- read() 함수는 방금 만든 2차 Promise를 반환하며, pop된다.
- await가 방금 반환된 Promise의 상태를 확인한다. Pending 상태이므로, 현재 전체 코드를 감싸고 있던 함수(fetchStreamData)를 Call Stack에서 pop하여 JS 메모리에 보존한다. (그림에서는 해당 부분을 그리지 않았다.)

- 외부 서버에서 제공하는 데이터 네트워크 처리 객체로 들어오면 이 객체가 데이터 조각(chunk)를 잡아서 C++ 스트림 파이프 객체에 넣는다.
- C++ 스트림 파이프 객체는 chunk 데이터를 브라우저 내부의 C++ 메모리(Off-Heap)에 둔다. V8 엔진에게 ArrayBuffer와 Uint8Array 객체를 생성하라는 명령을 내린다.V8 엔진 내부에 ArrayBuffer라는 JS 객체를 만든다. ArrayBuffer 객체 안에는 C++ 메모리(Off-Heap) 주소가 적혀있다. 그리고 이 주소를 JS가 읽고 쓸 수 있게 해주는 Uint8Array 객체도 V8 엔진에서 생성한다

- 이때, C++ 스트림 파이프 객체는 JS가 읽을 수 있도록 C++ 백그라운드(Blink)를 통해 IteratorResult 객체(JS 일반 객체)를 생성한다. IteratorResult 객체 안에는 방금 memory heap에 올려둔 데이터 접근용 주소표(value: Uint8Array)와, 앞으로 받을 데이터가 더 남았는지 여부(done: false)를 담아 { done, value } 형태의 JS 객체로 만든다. (통신이 끝났다면 { done: true, value: undefined }로 생성한다.). 개발자가 Unit8Array의 요소에 접근하면, V8 엔진 ArrayBuffer에 적힌 주소 포인터를 타고 넘어가 C++ 외부 메모리에 있는 해당 데이터를 직접 읽어올 수 있게 된다.

- IteratorResult 객체가 모두 준비되면, C++ 스트림 파이프 객체는 V8 엔진에게 2번째 Promise 객체 상태를 Fulfilled로 바꾸고, 방금 생성한 { done, value } 객체를 2번째 Promise의 [[PromiseResult]] 결과값으로 넣어라는 명령을 내린다.
- 2번째 Promise 상태가 fulfilled 되고, 결과값이 IteratorResult 객체 주소가 된다.

- V8 엔진은 await 이후의 작업들을 Microtast queue에 대기시킨다.
- Call Stack이 비어있음을 event loop가 확인하면, Microtask queue 에서 기다리던 작업을 Call Stack에 가져와 다시 실행한다.
- 비로서 const { done, value } = await reader.read(); 가 실행된다. await 연산자는 V8 엔진을 통해 2번째 Promise 객체의 [[PromiseResult]]에 접근해 그 안의 객체를 꺼내오고, done과 value 변수에 값을 변수에 할당한다.

이러한 과정을 통해 현재 수신한 데이터가 마지막 데이터인지 (done) 확인할 수 있고, chunk 데이터(value)는 2번째 Promise 객체 -> IteratorResult 객체 -> Uint8Array 객체 -> ArrayBuffer 객체 -> C++ 메모리(Off-Heap)로 이어지는 참조 체인을 타고 내려가 복사 과정 없이 직접 접근할 수 있게 된다.
// [5단계: EOF(End Of File) 및 스트림 종료]
// 서버가 모든 데이터를 보냈다면 done이 true가 됩니다.
if (done) {
console.log('모든 스트림 읽기 완료! 총 수신 바이트:', receivedLength);
break;
}
- EOF가 C++ 스트림 파이프에 도착하면 { value: undefined, done: true } 라는형태의 iteratorResult 객체를 생성하여 반환한다.
- V8 엔진은 반환된 객체의 done: true를 보자마자 C++ 메모리(Off-Heap)와의 바인딩을 끊어버린다. 또한, ReadableStream과 C++ 스트림 파이프 객체 사이의 연결(바인딩)을 완전히 끊어버립니다.
- 데이터를 주는 쪽(네트워크)과 받는 쪽(V8 엔진) 모두 C++ 스트림 파이프 객체를 찾지 않게 되면서 참조 횟수가 0이 된다. 그 즉시, 브라우저의 C++ 엔진은 해당 파이프 스트림 객체와 임시 버퍼 메모리를 완전히 해제하여 OS에 반납한다.

이로써, 데이터의 흐름이 종료되고 스트림 내부 과정도 마치게 된다.
그럼 EOF를받지 않았다면 어떻게 코드가 흘러가는 것일까?
// [3단계: 청크(Chunk)의 배달]
// value 안에는 C++ 엔진이 넘겨준 작은 바이트 조각(Uint8Array)이 들어있습니다.
receivedLength += value.length;
- 앞서 await 연산자가 실행된 이후 동기 코드가 실행된다.
- 렉시컬 환경에는 done과 value 변수가 있다. value 변수에는 Uint8Array 객체의 메모리 주소가 할당되어 있다.
- V8 엔진이 value.length를 읽기 위해 Uint8Array 객체를 찾아간다.
- 이 객체가 C++ 메모리(Off-Heap)에 있는 실제 데이터 덩어리에 직접 접근하기 때문에, 그 데이터의 크기가 length가 된다.

참조 체인을 타고 흘러가 C++ 메모리(Off-Heap)에 담겨 있는 chunk 데이터를 읽어올 수 있게 된다.
이러한 데이터를 계속 저장하면서 현재 데이터가 얼마나 들어왔는지 계산할 수 있게 된다.
// [4단계: 실시간 처리와 메모리 해제]
// 날것의 바이트 조각을 텍스트로 디코딩합니다. (stream: true 옵션으로 조각이 이어지게 만듦)
const chunkText = decoder.decode(value, { stream: true });
- TextDecoder는 value 객체의 Uint8Array 객체를 통해 ArrayBuffer의 포인터를 타고 넘어가 C++ 메모리(Off-Heap)에 있는 순수 바이트 chunk 데이터를 읽기 시작한다. (new TextDecoder() 실행 시, JS 힙 메모리와 브라우저의 메모리에 TextDecoder가 생성된다. JS 힙 메모리에서 C++ TextDecoder()로 번역 실행 명령을 내린다. 이때, value 안에 있는 Uint8Array 객체의 메모리 주소를 전달한다.)
- 이 바이트 코드를 완전히 번역할 수 있으면 유니코드 문자열로 번역하여 V8 엔진 힙 메모리에 새로운 문자열 객체를 만들고, 그 주소를 렉시컬 환경의 chunkText 변수에 할당한다.
- { stream: true }를 통해 TextDecoder는 잘려진 불완전한 바이트를 무시하지 않고 C++ 메모리(Off-Heap)의 내부 메모리에 잠시 저장한다. 다음 루프에서 자신의 C++ 임시 버퍼에 보관해 두었던 데이터와 새로 들어온 청크 데이터를 이어 붙여서 완벽한 데이터를 만든다.
- 예를 들어보자. "가"를 만드는데 보통 3바이트(예: [123, 456, 789])로 이루어진다. 만약 chunk 조각으로 쪼개지다가 우연히 [123, 456]의 2바이트와 [789]의 1바이트로 나누어졌다. 만약, { stream: true }가 없다면, TextDecoder는 깨진 글자를 강제 번역한다. 있으면, TextDecoder는 해당 바이트가 불안정하다는 것을 인식하고 번역하지 않은채 건너뛴다. 대신, C++ 메모리(Off-Heap)의 임시 보관 버퍼(Internal Buffer)에 이 2바이트를 저장한다. 다음 루프에서 나머지 1바이트 조각이 들어온다. TextDecoder는 새로운 번역을 시작하기 전에, 임시 보관 버퍼에 보관해둔 2바이트와 새로 들어온 1바이트를 합쳐서 완전한 3바이트를 만든다.

- while 문을 다시 돌기 시작하면서 value와 chunkText 변수를 렉시컬 환경에서 삭제한다. Uint8Array 객체와 ArrayBuffer 객체를 메모리에서 삭제한다. 또한, C++ 엔진이 이제 필요없는 Off-Heap 메모리를 비운다. 이로 인해, 파일을 다운로드하더라도 브라우저가 실제로 사용하는 메모리 공간은 단일 청크 크기 주변으로 영원히 안정적이게 유지된다. 메모리 폭발(OOM, Out of Memory) 없이 대용량 데이터를 안전하게 처리할 수 있는 이유이다.
결과적으로 이 한 줄의 코드는 데이터의 디코딩, 이음매(Seam)의 버퍼링 방어, 그리고 JS-C++ 간의 메모리 브릿지 역할을 동시에 수행한다. 1GB의 대용량 파일도 단 64KB의 메모리만으로 안정적으로 처리할 수 있는 브라우저의 방법이 바로 이 작은 설정 안에서 완성되는 것이다
정리
지금까지 메모리 처리 방식이라는 렌즈를 통해 XHR과 fetch의 내부 동작을 브라우저 엔진의 밑바닥까지 파헤쳐 보았다. 이 복잡한 여정의 결론을 요약하자면 다음과 같다.
| 구분 | XHR (Buffering) | fetch (Streaming) |
| 데이터 수신 | 100% 완료될 때까지 기다림 | 도착하는 대로 즉시(Chunk) 처리 |
| 메모리 적재 | JS 힙에 거대한 사본을 통째로 복사 | C++ Off-Heap에 두고 주소만 참조 (Zero-Copy) |
| 메모리 해제(GC) | 요청이 완전히 끝나야 비워짐 | 매 루프마다 즉각적으로 비워짐 |
| 적합한 상황 | 단순하고 크기가 작은 데이터 통신 | 대용량 파일, 실시간 미디어 스트리밍 |
이제 단순한 API 호출자에서 벗어나 브라우저 엔진의 동작 원리를 이해할 수 있는 수준에 이르렀다. 이 지식을 바탕으로 우리는 다음의 것들을 할 수 있다.
- 메모리 폭발(OOM) 방어: 1GB, 아니 10GB의 대용량 파일이 주어지더라도 브라우저가 뻗지 않는 안전하고 평탄한 64KB 단위의 스트리밍 파이프라인을 설계할 수 있습니다.
- Zero-Copy 최적화 활용: 데이터를 무의미하게 복사하지 않고, Uint8Array와 V8 엔진의 바인딩을 통해 C++ 메모리에 직접 접근하여 애플리케이션의 성능을 극한으로 끌어올릴 수 있다.
- 안전한 디코딩 제어: { stream: true } 옵션 뒤에 숨겨진 C++ 임시 버퍼의 역할을 이해함으로써, 이음매(Seam)에서 잘려나간 불완전한 데이터의 손실 없이 완벽한 텍스트 변환 로직을 구현할 수 있다.
과거의 텍스트 위주의 웹 환경에서는 XHR의 버퍼링 방식만으로도 충분히 훌륭하고 혁신적이었다. 하지만 실시간 미디어와 대용량 파일이 쏟아지는 오늘날, 브라우저의 메모리를 지키기 위해서는 더 유연한 도구가 필요해졌다. 단순해 보이던 fetch 코드 한 줄은 사실 V8 엔진과 Blink 엔진이 협력하여 메모리 폭발을 막아내는 정교한 방어선이다. 이 밑바닥의 동작 원리를 꿰뚫어 보는 통찰력이 앞으로 마주할 수많은 프론트엔드 최적화 과제에서 가장 든든한 무기가 되어줄 것이다.
메모리 처리 방식 이외에도
2. Task Queue vs Microtask Queue
3. 모던 웹 생태계와의 결합
4. 요청 취소 방식
5. 진행률 추적
등의 차이가 있다. 알아보도록 하자.
'우아한테크코스 8기 > 원정대' 카테고리의 다른 글
| fetch - 기본 동작 방식 (0) | 2026.04.23 |
|---|---|
| XHR의 단점 (0) | 2026.04.22 |
| XHR - 왜 알아야하나? (1) | 2026.04.21 |
| 원정대 - HTTP Client (0) | 2026.04.20 |