| 방식 | Content-Type | 특징 | 용도 |
|---|---|---|---|
| 본문에 그대로 | image/png, application/octet-stream |
파일 하나 = 본문 전체. 파일명·다른 필드를 실을 곳이 없음 | REST PUT /files/{name}, S3 업로드 |
multipart/form-data |
multipart/form-data; boundary=... |
여러 파트(필드 + 파일들)를 경계 문자열로 구분. 바이너리 그대로 | HTML 폼 업로드의 표준, Spring MultipartFile |
| Base64 JSON | application/json |
파일을 문자열로 인코딩해 JSON 필드에 넣음. 크기 33% 증가, 메모리에 전체 로딩 | 작은 아이콘·서명 이미지 정도 |
브라우저의 <form enctype="multipart/form-data"> 와 FormData 객체는 두 번째를 씁니다. 이 레슨의 주제도 두 번째입니다.
memo 필드 하나와 회원명단.csv 파일 하나를 올리는 요청은 실제로 다음 바이트열입니다.
POST /upload HTTP/1.1
Host: localhost:8080
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryAbC123
Content-Length: 331
------WebKitFormBoundaryAbC123\r\n ← "--" + boundary
Content-Disposition: form-data; name="memo"\r\n ← 파트 헤더
\r\n ← 빈 줄 = 헤더 끝
9월 회원명단\r\n ← 파트 본문 (다음 경계 직전까지)
------WebKitFormBoundaryAbC123\r\n
Content-Disposition: form-data; name="file"; filename="회원명단.csv"\r\n ← 파일 파트: filename 있음
Content-Type: text/csv\r\n ← 브라우저가 확장자로 추측한 타입 (신뢰 금지)
\r\n
name,email\n
홍길동,[email protected]\n ← 파일 바이트 그대로 (인코딩 없음)
------WebKitFormBoundaryAbC123--\r\n ← "--" + boundary + "--" = 끝| 요소 | 규칙 | 함정 |
|---|---|---|
boundary |
요청 헤더에 선언. 본문 안에 나타나지 않을 무작위 문자열. 본문에서는 앞에 -- 가 붙음 |
본문 안 경계는 \r\n--boundary 로 찾음. 파일 끝의 \r\n 은 파일이 아니라 구분자 |
Content-Disposition |
form-data; name="필드명", 파일이면 filename="원본명" 추가 |
filename 은 사용자 입력. ../ 나 C:\ 경로가 올 수 있음 |
파트 Content-Type |
파일 파트에만. 브라우저가 확장자로 추측 | .png 로 이름만 바꾼 exe 도 image/png 로 옴 |
| 파일명 인코딩 | 현대 브라우저는 UTF-8 바이트를 그대로 헤더에 씀 (RFC 7578) | 헤더를 ISO-8859-1 로 디코딩하면 한글이 깨짐. UTF-8 로 디코딩 |
| 파일 여러 개 | <input multiple> → 같은 name 의 파일 파트가 여러 개 |
파트 순서는 폼 순서 |
핵심은 본문 안에서 파일 바이트와 경계를 구분하는 방법이 "경계 문자열 검색" 뿐이라는 점입니다. 길이 필드가 없습니다. 그래서 파서는 스트림을 읽으면서 \r\n--boundary 가 나타나는지 계속 살펴야 하고, 그 검색이 버퍼 경계에 걸쳐도 놓치지 않아야 합니다.
❌ readAllBytes() 후 파싱:
요청 본문 500MB ──▶ byte[500MB] ──▶ indexOf(boundary) ──▶ 파일 저장
동시 요청 4개 = 힙 2GB. 크기 제한은 "다 읽은 뒤" 검사 → 이미 늦음
✅ 스트리밍:
요청 본문 ──▶ 64KB 버퍼 ──▶ 경계 탐색 ──▶ 파트 InputStream ──▶ 디스크 (8KB 씩)
메모리 = 64KB + 8KB. 크기 제한은 "읽는 도중" 검사 → 한도 넘는 순간 중단이 레슨의 MultipartParser 는 64KB 버퍼 하나를 두고, 파트마다 "경계 직전까지만 읽히는 InputStream" 을 핸들러에 넘깁니다. 핸들러는 그 스트림을 디스크로 복사하면 됩니다. 버퍼 경계 처리의 핵심 규칙 하나:
buf: [........ 파일 바이트 ........ \r\n--bou] ← 버퍼 끝에 경계의 일부만 들어옴
↑ 여기서부터 delimiter.length-1 바이트는
"경계의 시작일 수도 있음" → 아직 돌려주지 않고 다음 read 로 넘김
scanEnd = limit - delimiter.length + 1 (EOF 면 limit)버퍼의 마지막 delimiter.length - 1 바이트는 판정을 보류하고, 다음 fill() 에서 앞으로 당긴 뒤(compact) 다시 봅니다. 이렇게 하면 버퍼 크기와 무관하게 경계를 놓치지 않습니다(변형 1 에서 20바이트 버퍼로 검증).
업로드 요청의 모든 요소는 사용자(또는 공격자)가 만든 것입니다.
| 항목 | 믿으면 생기는 일 | 대책 |
|---|---|---|
Content-Length |
없거나(chunked) 거짓일 수 있음 | 헤더로 1차 조기 거절 + 읽으면서 2차 카운트 |
filename |
../../app.jar, C:\Windows\x, 제어 문자, 빈 문자열 |
경로·제어 문자 제거, 빈값 대체. 저장명은 UUID, 원본명은 메타 |
| 확장자 | .exe, .jsp, .php 업로드 → 서버에서 실행 |
화이트리스트(허용 목록). 블랙리스트는 .phtml, .jSp 로 우회됨 |
파트 Content-Type |
브라우저 추측값. 조작 가능 | 무시. 확장자로 결정한 MIME 을 응답에 씀 |
| 파일 내용 | 확장자와 내용이 다름 | 매직 넘버: PNG 89 50 4E 47, JPEG FF D8 FF, PDF %PDF, ZIP PK |
다운로드 ?name= |
../../Main.java 로 소스 유출 |
UUID 형식만 허용. 메타 파일 존재 확인. Path.resolve 결과가 저장 디렉터리 안인지 확인 |
매직 넘버 검사는 완벽하지 않습니다(polyglot 파일). 그래도 "이름만 바꾼 파일" 이라는 가장 흔한 경우를 걸러 줍니다. 텍스트(txt, csv)는 시그니처가 없으므로 검사를 건너뜁니다.
data/uploads/
6089a292-....part ← 쓰는 중 (검증 실패·연결 끊김이면 삭제)
6089a292-.... ← 완성 후 ATOMIC_MOVE. 이 이름이 존재하면 "완전한 파일"
6089a292-.....meta ← 원본명 \n MIME \n 크기02 파일 I/O 레슨의 원칙 그대로입니다. 저장명을 UUID 로 하면 파일명 충돌·경로 탐색·OS 별 금지 문자 문제가 한 번에 사라지고, 원본명은 표시용 메타데이터로만 씁니다. 실무에서는 메타를 DB 테이블(file_id, original_name, content_type, size, uploader, created_at)에 넣습니다.
| 헤더 | 값 | 이유 |
|---|---|---|
Content-Type |
저장 시 결정한 MIME | 브라우저가 열지/저장할지 결정. 모르면 application/octet-stream |
Content-Length |
파일 크기 | 진행률 표시, 연결 재사용. sendResponseHeaders(200, size) 로 자동 |
Content-Disposition |
attachment; filename="ascii"; filename*=UTF-8''%ED... |
한글은 RFC 5987 filename*, ASCII 대체는 filename |
Accept-Ranges |
bytes |
Range 요청 지원 광고 |
Content-Range |
bytes 0-9/64 |
206 응답에서 이 조각이 전체의 어디인지 |
Content-Disposition: attachment; filename="____.csv"; filename*=UTF-8''%ED%9A%8C%EC%9B%90%EB%AA%85%EB%8B%A8.csv
└── 구형 클라이언트용 대체 ──┘ └──── 현대 브라우저가 우선 사용 (UTF-8 퍼센트 인코딩) ────┘URLEncoder.encode 는 공백을 + 로 만들지만 RFC 5987 은 %20 이어야 하므로 치환합니다. 예전 방식인 new String(name.getBytes("UTF-8"), "ISO-8859-1") 은 브라우저마다 결과가 달라 더 이상 쓰지 않습니다.
요청: Range: bytes=0-9 → 응답 206, Content-Range: bytes 0-9/64, 본문 10바이트
요청: Range: bytes=-8 → 응답 206, Content-Range: bytes 56-63/64, 마지막 8바이트
요청: Range: bytes=999- → 응답 416, Content-Range: bytes */64 (범위 밖)
요청: Range 없음 → 응답 200, 전체동영상 태그가 탐색(seek)할 때, 다운로드 관리자가 이어받을 때 씁니다. 서버는 skipNBytes(start) 후 end - start + 1 바이트만 씁니다. 여러 범위(bytes=0-9,20-29, multipart/byteranges 응답)는 실무에서 거의 안 쓰므로 단일 범위만 지원합니다.
HttpServer.create(addr, backlog)
├─ createContext("/upload", handler) ← 경로 접두사 매칭 (가장 긴 접두사 우선. "/" 는 전부에 매칭)
├─ setExecutor(executor) ← null 이면 단일 스레드! 반드시 지정
└─ start()
HttpExchange (요청 1건)
├─ getRequestHeaders() / getRequestBody() ← 본문은 InputStream (스트리밍)
├─ getResponseHeaders()
├─ sendResponseHeaders(status, len) ← len>0 고정 길이, 0 chunked, -1 본문 없음
└─ getResponseBody() ← 반드시 close (try-with-resources)setExecutor(null) 기본값은 한 스레드가 모든 요청을 순서대로 처리합니다. 업로드 하나가 오래 걸리면 다른 사용자가 전부 대기합니다. JDK 21 이면 Executors.newVirtualThreadPerTaskExecutor() 가 가장 간단합니다. 요청마다 가상 스레드 하나, 블로킹 I/O 를 그대로 써도 수천 동시 연결이 됩니다.
고정 풀(newFixedThreadPool(32))은 동시 처리 상한을 명시적으로 두고 싶을 때 씁니다.
한 가지 함정: 오류 응답을 보낼 때 요청 본문을 다 읽지 않으면 서버가 연결을 끊고 클라이언트는 413 대신 connection reset 을 봅니다. 그래서 거절 후에도 남은 본문을 transferTo(nullOutputStream()) 로 소비하고 응답합니다(Tomcat 도 maxSwallowSize 설정으로 같은 일을 합니다).
| 이 레슨 (순수 JDK) | Servlet 3.0+ | Spring MVC |
|---|---|---|
MultipartParser.parse(handler) |
request.getParts() (Tomcat 이 파싱) |
MultipartResolver |
Part.filename() |
part.getSubmittedFileName() |
MultipartFile.getOriginalFilename() |
Part.body() (InputStream) |
part.getInputStream() |
MultipartFile.getInputStream() / transferTo(path) |
Part.name() 일반 필드 |
request.getParameter("memo") |
@RequestParam String memo |
| 파일 파트 | @MultipartConfig + request.getPart("file") |
@RequestParam MultipartFile file / List<MultipartFile> |
FileStore.maxBytes 검사 |
@MultipartConfig(maxFileSize=...) |
spring.servlet.multipart.max-file-size, max-request-size |
| 임시 파일 후 이동 | Tomcat 이 location 에 임시 저장 (기본 임계 넘으면) |
file-size-threshold 이상이면 임시 파일 |
HtmlPages.send(ex, 200, json) |
response.getWriter() |
ResponseEntity<T> |
DownloadHandler 헤더 조립 |
response.setHeader(...) + getOutputStream() |
ResponseEntity<Resource> + ContentDisposition.attachment() |
| Range 처리 | 직접 | ResourceHttpRequestHandler/ResourceRegion 이 자동 |
Spring 의 ContentDisposition.attachment().filename("회원명단.csv", StandardCharsets.UTF_8).build() 는 2.6 의 filename* 을 만들어 줍니다. 직접 문자열을 조립하지 마세요.