미리보기는 되는데 업로드는 안 될 때: Blob URL·File·FormData 다루기
이미지 주소와 실제 바이트를 구분하고 Blob·File·data URL·FormData의 전달 경계를 점검한다. Node.js 10개 재현 검사로 URL 수명, MIME 메타데이터, multipart boundary를 검증한다.
브라우저의 임시 미리보기와 다른 시스템에 전달할 파일을 구분한 AI 생성 에디토리얼 이미지.
이미지 편집기에서 결과가 잘 보인다. 그런데 업로드 API에 넘기면 파일 필드가 비어 있거나, 저장된 PNG를 열 수 없거나, 다른 프로세스에서 주소를 읽지 못한다. 이럴 때는 렌더링 품질보다 지금 가지고 있는 값이 무엇인지 먼저 확인하는 편이 빠르다.
화면에 표시할 주소, 메모리에 있는 바이트, 파일 이름을 가진 객체, 서버에 영구 저장된 자산은 서로 다른 단계다. 이 글에서는 직접 관리하는 웹 앱을 기준으로 Blob과 File을 원본으로 유지하고, 미리보기용 URL과 업로드 요청을 분리하는 방법을 정리한다. 특정 서비스의 오류 원인을 추정하는 글은 아니다.
1. 이미지 주소와 이미지 바이트를 구분한다
| 가지고 있는 값 | 담고 있는 것 | 다른 단계로 넘길 때 확인할 것 |
| Blob | 읽기 가능한 바이너리 데이터와 MIME 메타데이터 | 크기와 실제 형식 |
| File | Blob의 데이터에 파일 이름·수정 시각 등을 더한 객체 | 이름을 붙였다고 디스크에 저장되지는 않음 |
| blob: URL 문자열 | backing Blob 등을 참조하는 식별자 | 생성 환경과 수명에 의존 |
| data: URL 문자열 | MIME 표시와 인코딩된 데이터 | 먼저 바이트로 디코딩할 것 |
| HTTPS URL | 서버 리소스의 위치 | 인증, 만료, CORS, 실제 응답 |
| 로컬 파일 경로 | 특정 파일시스템의 위치 | 소비자가 같은 파일을 읽을 수 있는가 |
Blob은 불변 바이너리 데이터를 표현하며, File은 파일 이름과 같은 정보를 함께 제공한다. File 생성자를 호출하는 것은 파일시스템에 쓰기 작업을 하는 것과 다르다. W3C File API
blob URL에는 이미지 전체가 들어 있지 않다. 이를 문자열로 서버에 보내도 서버가 사용자의 브라우저 메모리에서 원본을 가져갈 수 있는 다운로드 주소가 되지 않는다. 브라우저 내 접근도 생성 환경의 storage key와 관련된 제약을 받는다. 따라서 “현재 탭에서만 된다”라고 단순화하기보다 환경을 넘어 전달할 저장 주소로 사용하지 않는다는 설계 원칙을 잡는 편이 정확하다. MDN blob URLs
2. new File에 URL 문자열을 넣으면 문자열 파일이 된다
다음 코드는 이름만 보면 PNG를 만든 것 같다.
하지만 File 생성자에 전달한 문자열은 UTF-8 바이트로 들어간다. data:image/png;base64,라는 글자를 포함한 텍스트가 저장되는 것이다. 생성자가 data URL을 알아서 디코딩하지 않는다. MDN File 생성자
원래 Blob을 가지고 있다면 URL을 거치지 않는 것이 가장 간단하다.
이 예제의 result.png는 원본이 PNG라는 것을 이미 아는 경우다. JPEG 바이트에 PNG 이름을 붙이거나 type만 바꿔도 실제 포맷은 변하지 않는다. 포맷 변환이 필요하면 이미지 디코더·인코더를 거쳐야 한다. 파일 확장자와 MIME 문자열은 내용 검사를 대체하지 못한다. MDN Blob.type
data URL만 받는 인터페이스라면 디코딩 단계를 명시한다. 다음 함수는 앱이 생성하거나 사용자가 제공하도록 허용한 작은 인라인 이미지를 다루는 예제다. 임의의 원격 URL을 다운로드하는 함수가 아니다.
data URL에는 Base64 외에 percent-encoding 형태도 있다. 무조건 쉼표 뒤를 atob()에 넣는 코드는 모든 data URL을 처리하지 못한다. 위 함수는 Fetch의 data URL 처리를 이용한다. MDN data URLs
여기서 MIME 허용 목록은 입력 실수를 빠르게 발견하기 위한 검사다. 실제 PNG·JPEG·WebP 디코딩 성공 여부, 가로세로 크기, 픽셀 수와 악성 콘텐츠 검사는 서버에서 별도로 수행해야 한다. 큰 파일을 다룬다면 인라인 문자열과 전체 메모리 버퍼를 만들기보다 서비스가 제공하는 파일·스트리밍 경로를 선택한다.
3. 미리보기 URL의 수명과 원본 File의 수명을 분리한다
업로드 버튼이 img.src를 원본으로 삼으면 화면 상태에 데이터 전달이 종속된다. 대신 원본 File을 상태에 보관하고, object URL은 화면에 표시하는 동안만 사용한다.
createObjectURL()을 호출할 때마다 별도의 URL이 생긴다. 화면을 교체하거나 제거할 때 해제하지 않으면 메모리가 오래 유지될 수 있다. 반대로 이미지의 load 이벤트 직후 무조건 해제하면 우클릭 저장이나 새 탭 열기처럼 아직 필요한 동작이 깨질 수 있다. 사용자가 그 미리보기에 더 이상 접근하지 않는 시점에 정리한다. MDN object URL 메모리 관리
URL을 해제하는 것과 File 객체의 바이트를 없애는 것도 다르다. 앱이 원본 File을 계속 보유하고 있다면 그 객체로 업로드 요청을 만들 수 있다. 프레임워크를 쓴다면 컴포넌트 해제, 파일 교체, 취소 흐름마다 정리 책임을 명시하자.
4. FormData에는 주소 대신 File을 넣는다
multipart 업로드에서 자주 나오는 실수는 다음 두 가지다.
File 또는 Blob을 append하고, FormData 요청의 Content-Type은 런타임이 만들도록 둔다. 브라우저가 선택한 boundary와 실제 본문을 일치시키기 위해서다. Blob을 직접 넣을 때도 세 번째 인자로 파일 이름을 지정할 수 있다. MDN FormData 파일 전송
앞 절의 상태와 연결하면 업로드 함수는 다음처럼 단순해진다.
이 코드는 파일을 담은 요청을 보내는 부분까지만 보여 준다. /api/uploads는 직접 구현할 같은 출처의 예시 경로다. 실제 앱에서는 인증·CSRF 정책, 중복 클릭 방지, 취소, 서버 응답 스키마를 추가해야 한다. API가 raw binary PUT이나 사전 서명된 업로드를 요구한다면 그 계약을 따라야 하며, 모든 업로드에 FormData를 씌우면 안 된다.
HTTP 2xx 이후에도 서버가 원본 저장을 끝낸 것인지, 검사 대기 상태만 만든 것인지 확인하자. 응답의 자산 ID와 처리 상태를 기준으로 다음 작업을 시작하는 편이 안전하다. 미리보기가 보인다는 이유로 서버 저장 완료 표시를 먼저 켜지 않는다.
5. 외부 이미지가 보이는데 fetch는 실패할 수 있다
외부 HTTPS 이미지를 화면에 표시하는 것과 JavaScript가 그 응답 바이트를 읽는 것은 같은 조건이 아니다. 이때 mode: 'no-cors'를 붙여 읽기 제한을 해결하려고 하면 안 된다. opaque 응답에서 response.blob()으로 얻은 Blob은 크기가 0이고 type도 빈 문자열이다. 업로드할 원본 확보에 성공한 상태가 아니다. MDN Response.blob
직접 관리하는 서버라면 필요한 출처에 대한 CORS 설정을 확인한다. 다른 서비스라면 공식 다운로드·파일 API와 허용된 인증 흐름을 사용한다. 권한이 없거나 만료된 URL을 임의로 우회하는 작업과 파일 형식 변환을 혼동하지 않는다.
또한 canvas에 외부 이미지를 그렸다고 항상 toBlob()으로 추출할 수 있는 것은 아니다. 적절한 CORS 승인이 없는 외부 이미지가 캔버스를 오염시키면 읽기·내보내기가 제한된다. MDN 교차 출처 이미지와 canvas
6. 10개 로컬 검사로 전달 계약을 확인한다
아래 재현 코드는 작은 테스트용 PNG 데이터만 사용한다. 외부 서버로 요청을 보내지 않고 Node.js의 Blob·File·FormData·Request 동작을 확인한다. 파일을 blob_file_demo.mjs로 저장한 뒤 node blob_file_demo.mjs로 실행할 수 있다.
검증 환경은 Node.js v24.22.1이며, 다음 10개 항목이 모두 통과했다.
- Blob에 들어간 바이트와 크기 보존
- 파일 이름·MIME을 바꿔도 PNG 바이트 유지
- data URL 문자열을 File에 넣었을 때 텍스트 저장
- Base64 data URL의 바이트 디코딩
- percent-encoded data URL 처리
- object URL 해제 후 새 fetch 실패, 원본 Blob은 유지
- 같은 Blob으로 만든 두 URL의 독립적인 수명
- FormData의 문자열 필드와 파일 필드 구분
- 자동 boundary 생성과 multipart 왕복 변환
- Content-Type을 수동 지정했을 때 boundary 누락과 파싱 실패
검증 범위: 위 결과는 Node의 로컬 데이터·요청 직렬화 검사다. 브라우저 렌더링, CSP, CORS, storage partition과 실제 서버 수신 성공을 검증하지는 않는다. 앞의 DOM 미리보기·업로드 예제는 통합 패턴이며, 실제 브라우저에서 실행 완료한 결과로 제시한 것이 아니다.
7. 자동화 단계 사이에는 영구 자산 식별자를 남긴다
브라우저에서 생성한 결과를 백엔드 작업자나 다음 앱으로 전달한다면, 작업 기록에 미리보기 URL만 남기는 구조를 피한다. 다음은 서비스에서 정의할 수 있는 최소한의 자산 기록 예시다.
assetId는 안정적인 저장 객체를 가리키고, 다운로드 URL은 필요할 때 권한을 확인해 발급하도록 구성할 수 있다. 이 JSON 자체가 저장 성공의 증거는 아니다. 서버가 실제 바이트를 저장하고 검증한 뒤 상태를 기록해야 한다. 여러 환경 사이에서 파일을 옮길 때는 바이트 수와 필요에 따라 SHA-256 같은 해시도 비교하면 잘못된 파일 전달을 발견하기 쉽다.
실패 기록에는 마지막으로 확보한 형태를 구체적으로 남긴다. “이미지 생성 성공”보다 “Blob 확보, 크기 확인, 서버 저장 대기”가 다음 작업에 도움이 된다. 반면 전체 data URL은 원본 데이터를 담으므로 로그에 넣지 않는 편이 좋다. 서명된 다운로드 URL과 인증 정보도 제외한다.
마지막 점검은 네 질문이면 충분하다.
- 지금 가진 것이 주소 문자열인가, 실제 바이트인가?
- 다음 소비자가 읽을 수 있는 형태와 위치인가?
- 파일 이름·MIME과 실제 내용이 일치하는가?
- 미리보기 성공과 서버 저장 완료를 따로 확인했는가?
이 경계를 코드와 상태 모델에 드러내면, 이미지가 화면에 보인 이후의 전달 실패를 훨씬 좁은 범위에서 찾을 수 있다.