브라우저 다운로드와 Blob 메모리¶
한 줄 요약: 큰 파일을 받을 때 브라우저 메모리를 쓰는지는 파일이 어디서 오느냐가 아니라 누가 받느냐로 정해진다. 브라우저의 다운로드 관리자가 받는
<a href download>는 메모리를 거의 안 쓴다. 페이지의 JS가 받는fetch→blob은 파일 크기만큼 쓴다.
📍 만난 상황¶
- 2026-11-11 — disaster-dashboard의 할 일 중 다운로드 형식(2번)과 presigned URL(4번)을 다시 따지다가 3번 물음 → 그날 일지
- 배경: 시니어가 "큰 파일을 받으면 브라우저도 과부하가 온다, 메모리가 찬다"고 한 말에서 나온 과제였다.
- 09:09 두 과제를 같이 설계하는 이유("파일을 어디서, 누가 만드느냐")를 들은 직후였다. presigned URL을 쓰면 브라우저 메모리를 많이 쓰게 되냐고 물었다. 답은 "둘 다 거의 안 쓴다. 지금 대시보드도
<a href download>라 거의 안 쓴다"였다. - 09:13 Claude가 서버 중계와 presigned URL을 비교하자, 그게 아니라
<a href>와 blob 방식을 비교해 달라고 다시 물었다. - 09:16 비교표를 보고 "blob이 뭐고, 왜 얘는 브라우저 메모리에 올리냐"고 물었다.
🧩 쉽게 말하면¶
택배를 받는 두 가지 방법을 떠올리면 된다.
A(<a href download>)는 택배 기사가 상자를 바로 창고에 넣는 방식이다. 상자가 100개여도 거실은 비어 있다.
B(fetch→blob)는 상자를 일단 거실에 전부 쌓아 둔 뒤, 마지막 상자가 오면 한꺼번에 창고로 옮기는 방식이다. 상자가 많으면 거실(탭 메모리)이 꽉 차서 아무것도 못 한다.
택배를 어느 회사가 보냈는지(대시보드 서버냐 S3냐)는 상관없다. 받는 방식이 거실을 채우느냐를 정한다.
📖 제대로 알기¶
등장하는 것들¶
- 다운로드 관리자: 브라우저에 들어 있는 다운로드 담당 기능. 페이지와 따로 돈다. 다운로드 표시줄, 일시정지, 이어받기를 맡는다.
- 페이지의 JS: 지금 열린 웹페이지에서 도는 자바스크립트. 보안 때문에 샌드박스(바깥과 격리된 실행 공간) 안에서 돈다.
- Blob(Binary Large Object: 큰 바이트 덩어리): JS에서 "바이트 덩어리 하나"를 담는 객체. 안에 든 게 CSV든 이미지든 Parquet이든 그냥 바이트 나열로 다룬다.
- Object URL:
URL.createObjectURL(blob)이 만드는blob:https://…모양의 임시 주소. 메모리 속 Blob을 가리킨다.
Blob의 특징 세 가지¶
- 완성품이다. 만들어지는 순간 크기(
size)가 정해진다. 내용을 바꿀 수 없다. - 출처를 가리지 않는다.
fetch응답(res.blob()), 직접 만든 문자열, 캔버스 그림(canvas.toBlob), 사용자가 고른 파일(<input type="file">의File)이 모두 Blob이다. - JS가 다룰 수 있다. 잘라 보기(
slice), 글자로 읽기(text()), 다른 곳으로 보내기, 미리보기·다운로드용 주소 만들기가 된다.
const blob = new Blob(["a,b\n1,2\n"], { type: "text/csv" });
blob.size; // 8 (바이트 수)
blob.type; // "text/csv"
두 방식 한눈에¶
| 항목 | A. <a href download> |
B. fetch→blob |
|---|---|---|
| 누가 받나 | 다운로드 관리자 | 페이지의 JS |
| 메모리 | 작은 버퍼만 (크기 무관) | 파일 크기 이상 |
| GB급 파일 | 문제없음 | 탭이 죽을 수 있음 |
⚙️ 어떻게 동작하나¶
A. <a href download>¶
- 사용자가 링크를 누른다.
- 브라우저가 요청을 보낸다. 같은 사이트면 쿠키가 자동으로 실린다.
- 응답이 파일이면 다운로드 관리자가 넘겨받는다.
- 받은 조각을 작은 버퍼에 잠깐 두었다가 곧바로 디스크의 임시 파일(Chrome은
.crdownload)에 쓴다. - 다 받으면 임시 파일을 원래 이름으로 바꾼다.
B. fetch→blob¶
- JS가
fetch()로 요청한다. 필요하면Authorization헤더를 붙인다. res.blob()을 부른다. "끝까지 다 받아서 하나로 합쳐 줘"라는 뜻이다.- 마지막 바이트가 올 때까지 받은 조각을 탭 메모리에 쌓는다.
createObjectURL로 그 Blob을 가리키는blob:주소를 만든다.- 그 주소로
<a>를 눌러 다운로드 관리자에게 넘긴다. 이제야 디스크에 쓴다. URL.revokeObjectURL을 불러야 메모리가 풀린다.
왜 B는 메모리에 쌓을 수밖에 없나¶
- Blob은 완성품이어야 한다. 조각을 다 모을 때까지 어딘가에 쥐고 있어야 한다.
res.text(),res.json(),res.arrayBuffer()도 똑같이 전부 받아 하나로 만든다. - 페이지 JS는 디스크에 마음대로 쓸 수 없다. 아무 사이트나 내 디스크에 파일을 쓸 수 있으면 위험하다. 그래서 JS가 쥔 데이터가 머물 곳은 기본적으로 메모리뿐이다.
브라우저가 큰 Blob을 디스크로 옮겨 두기도 하지만 한도가 있다. 수백 MB~GB가 되면 탭이 느려지거나 "메모리 부족"으로 죽을 수 있다.
flowchart LR
S["서버 또는 S3"] -->|"A. 링크"| D["다운로드 관리자"]
D -->|"조각마다 바로"| F[("디스크")]
S -->|"B. fetch"| J["페이지 JS"]
J -->|"전부 모음"| M["탭 메모리<br/>Blob"]
M -->|"다 모인 뒤"| D
A는 서버에서 디스크까지 바로 흐른다. B는 탭 메모리를 한 번 거치고, 다 모일 때까지 그 안에 쌓인다. 출발점(서버냐 S3냐)은 두 길 모두 같다.
🧪 예시¶
-
예시 1 · 일상에 빗대어: 1GB짜리 CSV를 받는다고 하자. A는 64KB짜리 바구니 하나로 계속 날라 창고에 쌓는다. 끝까지 바구니는 하나다. B는 1GB를 전부 거실에 쌓는다. 같은 탭에 지도까지 떠 있으면 거실이 넘쳐 탭이 죽는다.
-
예시 2 · 실제로는: 같은 주소를 두 방식으로 받는 코드다.
<a href="/api/datasets/123/download?from=2026-01-01" download>CSV 받기</a>
const res = await fetch("/api/datasets/123/download"); // ①
if (!res.ok) { alert("받기 실패"); return; } // ②
const blob = await res.blob(); // ③
const url = URL.createObjectURL(blob); // ④
const a = document.createElement("a");
a.href = url; a.download = "data.csv"; a.click(); // ⑤
URL.revokeObjectURL(url); // ⑥
blob: 주소를 만든다.
⑤ 그 주소로 다운로드 관리자에게 넘긴다.
⑥ 주소를 풀어야 Blob 메모리가 회수될 수 있다.
disaster-dashboard는 다운로드 버튼 네 곳이 모두 A였다. 코드 전체에 createObjectURL이나 .blob()이 없었다.
⚖️ 비교 · 흔한 오해¶
| 항목 | A. href | B. blob |
|---|---|---|
| 다운로드 표시줄 | 받기 시작하자마자 뜸 | 다 받을 때까지 안 뜸 |
| 화면에 진행률 직접 표시 | 불가 | 가능 (res.body) |
| 이어받기 | 됨 (서버가 Range 지원 시) | 끊기면 처음부터 |
| 오류 안내 | 다운로드 목록에 "실패"만 | JS가 알고 안내 가능 |
| 인증 헤더 | 못 붙임 (쿠키만) | 붙일 수 있음 |
헷갈렸던 점 ①: "presigned URL이면 브라우저 메모리가 줄어든다"¶
- 왜 그렇게 생각하기 쉬운가: presigned URL 이야기가 "큰 파일" 문제를 풀려고 나왔다. 그래서 "큰 파일 문제 = 브라우저 메모리"로 묶어서 기억했다.
- 실제로는: presigned URL도 링크로 열면 A와 똑같이 다운로드 관리자가 받는다. 브라우저 입장에선 받아 오는 곳이 서버에서 S3로 바뀔 뿐이다.
- presigned URL의 진짜 이점은 서버 쪽에 있다. 대시보드 서버가 바이트를 중계하지 않는다. 플랫폼에 1,000줄씩 50번 묻지 않는다. 끊겨도 이어받기가 된다.
헷갈렸던 점 ②: "어차피 마지막엔 디스크에 쓰니 같은 것 아닌가"¶
- B도 마지막에는 다운로드 관리자가 디스크에 쓴다. 그래서 같아 보인다.
- 차이는 그 전에 이미 전부 메모리에 올라와 있다는 점이다. 최고 메모리 사용량이 파일 크기만큼 치솟는다.
그럼 "브라우저 메모리가 찬다"는 말은 언제 맞나¶
- 토큰을
Authorization헤더로 보내야 하는 구조일 때다.<a href>는 헤더를 못 붙이니 B로 만들게 되고, 그때 메모리가 찬다. - 원인은 "큰 파일" 자체가 아니라 "헤더 인증 + blob으로 받기"의 조합이다.
🛠️ 실무에서는¶
- 큰 파일은 A(링크나 주소 이동)로 받게 만든다. 인증은 쿠키 세션이나 presigned URL로 푼다.
- B는 작은 파일(수 MB 이하)이거나, JS가 내용을 써야 할 때(미리보기, 브라우저에서 만든 파일 저장) 쓴다.
- B를 써야 하는데 파일이 크면: 응답 스트림(
res.body)을 조각 단위로 처리하거나, 파일 쓰기 API(File System Access API, 크롬 계열 위주)를 검토한다. 브라우저 지원이 고르지 않다는 점은 감안한다.
체크리스트
- 코드에 .blob(), .arrayBuffer(), createObjectURL이 있나?
- 있다면 받는 파일이 얼마나 커질 수 있나?
- revokeObjectURL을 부르고 있나?
"다운로드 중 탭이 죽는다"는 신고가 오면 먼저 받는 방식을 본다. 그다음 브라우저 작업 관리자(Chrome은 Shift+Esc)로 그 탭 메모리가 다운로드 내내 오르는지 확인한다.
❓ 자주 묻는 질문¶
- Q.
<a href>로 받는데 서버가 401을 주면 어떻게 되나? A. JS는 알 수 없다. 다운로드 목록에 "실패"로만 뜨거나 오류 화면이 보인다. 친절한 안내가 필요하면 받기 전에 가벼운 확인 요청을 먼저 보내는 식으로 보완한다. - Q. Blob은 파일인가?
A. 아니다. JS 안의 바이트 덩어리일 뿐 디스크 파일이 아니다.
File은 Blob에 이름과 수정 시각을 더한 것이다. - Q.
res.json()도 메모리를 많이 쓰나? A. 그렇다. 응답 전체를 받아 하나로 만든다. 큰 JSON을 한 번에 받는 API는 같은 문제가 생긴다. - Q. 그럼 presigned URL은 쓸 이유가 없나? A. 있다. 이유가 브라우저가 아니라 서버·플랫폼 부담과 이어받기일 뿐이다.
🔭 더 깊이 보기¶
- 쿠키 세션과 헤더 토큰은 어떻게 다르고, 왜
<a href>에는 쿠키만 실릴까? — 다운로드 방식을 정하는 진짜 갈림길이 인증 방식이라서. - Streams API로 응답을 조각 단위로 처리하는 법 — B를 꼭 써야 할 때 메모리를 줄이는 방법이라서.
- HTTP Range 요청과
Content-Disposition헤더 — 이어받기와 저장 파일 이름이 어떻게 정해지는지 알 수 있어서. - 관련 주제: OIDC 로그인 흐름 — 로그인 뒤 세션을 어떻게 유지하느냐가 받는 방식을 좌우한다
- 관련 주제: Parquet과 CSV 파일 형식 비교 — 같은 날 다운로드 형식을 정하며 본 주제
📚 참고 자료¶
- Blob (MDN 한국어) — Blob이 무엇이고 어떤 메서드가 있는지 · 링크
- Response.blob() (MDN 한국어) — 응답을 끝까지 읽어 Blob으로 만든다는 설명 · 링크
<a>: 앵커 요소 (MDN 한국어) —download속성의 동작과 제약 · 링크
🗓️ 이력¶
- 2026-11-11: presigned URL과 브라우저 메모리 →
<a href>·blob 비교 → Blob의 정체 순으로 3번 물음 → 일지