콘텐츠로 이동

브라우저 다운로드와 Blob 메모리

11/11(수)

한 줄 요약: 큰 파일을 받을 때 브라우저 메모리를 쓰는지는 파일이 어디서 오느냐가 아니라 누가 받느냐로 정해진다. 브라우저의 다운로드 관리자가 받는 <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>

  1. 사용자가 링크를 누른다.
  2. 브라우저가 요청을 보낸다. 같은 사이트면 쿠키가 자동으로 실린다.
  3. 응답이 파일이면 다운로드 관리자가 넘겨받는다.
  4. 받은 조각을 작은 버퍼에 잠깐 두었다가 곧바로 디스크의 임시 파일(Chrome은 .crdownload)에 쓴다.
  5. 다 받으면 임시 파일을 원래 이름으로 바꾼다.

B. fetch→blob

  1. JS가 fetch()로 요청한다. 필요하면 Authorization 헤더를 붙인다.
  2. res.blob()을 부른다. "끝까지 다 받아서 하나로 합쳐 줘"라는 뜻이다.
  3. 마지막 바이트가 올 때까지 받은 조각을 탭 메모리에 쌓는다.
  4. createObjectURL로 그 Blob을 가리키는 blob: 주소를 만든다.
  5. 그 주소로 <a>를 눌러 다운로드 관리자에게 넘긴다. 이제야 디스크에 쓴다.
  6. 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);                              // ⑥
① JS가 직접 요청한다. 헤더를 붙일 수 있다. ② 실패를 JS가 알 수 있어서 화면에 안내할 수 있다. A는 하기 어려운 일이다. ③ 여기서 파일 전체가 메모리에 쌓인다. 끝까지 받아야 다음 줄로 간다. ④ 메모리 속 Blob을 가리키는 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번 물음 → 일지

🕸️ 연결된 주제