공공부하자개발 · 영어 학습 노트
자바
실무 확장Excel · 파일 업로드 · DB 연동0/22 완료
  • 01Excel(XLSX) 구조와 순수 JDK로 읽기/쓰기
  • 02Apache POI로 Excel 업로드/다운로드
  • 03파일 업로드/다운로드 서버 (HttpServer)
  • 04JDBC 기초와 트랜잭션 (H2)
  • 05MyBatis 어노테이션 매퍼로 쿼리 연동
  • 06MyBatis XML 매퍼 · Oracle 방언 · PageHelper · Spring Boot
  • 07REST API 서버와 JSON
  • 08Vue 3 SPA 와 Java 서버 연동
  • 09@Scheduled 운영
  • 10로깅 실무: 레벨·계층, MDC 추적, 예외·성능, 마스킹, 롤링, JSON 로그
  • 11외부 API 연동
  • 12테스트 실무
  • 13암호화·개인정보 보호
  • 14인코딩·한글 실무
  • 15@Transactional 심화
  • 16긴 작업 비동기 처리와 진행률
  • 17SFTP·FTP 파일 연계
  • 18로컬 캐시와 @Cacheable
  • 19메일·알림 발송
  • 20웹 보안 체크리스트
  • 21빌드 도구와 폐쇄망 의존성 반입
  • 22성능 측정: p50·p95·p99, 측정 계층, JFR, JMH 함정, 자체 부하 테스트, 병목 순위
사이트 소개개인정보처리방침연락처
© 2026 공부하자
홈 › 실무 확장 › 03 / 22

파일 업로드/다운로드 서버 (HttpServer)

섹션 7진행 0 / 22
1왜 배우는가2핵심 원리3코드 예제4응용 변형 예제5자주 하는 실수 (Tip)6연습 문제7정리‹ 이전다음 ›

2. 핵심 원리

2.1 HTTP 로 파일을 보내는 세 가지 방법

방식 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 객체는 두 번째를 씁니다. 이 레슨의 주제도 두 번째입니다.

2.2 multipart/form-data 해부

memo 필드 하나와 회원명단.csv 파일 하나를 올리는 요청은 실제로 다음 바이트열입니다.

text
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
홍길동,hong@example.com\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 가 나타나는지 계속 살펴야 하고, 그 검색이 버퍼 경계에 걸쳐도 놓치지 않아야 합니다.

2.3 스트리밍 파싱 — 왜 전체를 메모리에 올리면 안 되는가

text
❌ readAllBytes() 후 파싱:
   요청 본문 500MB ──▶ byte[500MB] ──▶ indexOf(boundary) ──▶ 파일 저장
   동시 요청 4개 = 힙 2GB. 크기 제한은 "다 읽은 뒤" 검사 → 이미 늦음

✅ 스트리밍:
   요청 본문 ──▶ 64KB 버퍼 ──▶ 경계 탐색 ──▶ 파트 InputStream ──▶ 디스크 (8KB 씩)
   메모리 = 64KB + 8KB. 크기 제한은 "읽는 도중" 검사 → 한도 넘는 순간 중단

이 레슨의 MultipartParser 는 64KB 버퍼 하나를 두고, 파트마다 "경계 직전까지만 읽히는 InputStream" 을 핸들러에 넘깁니다. 핸들러는 그 스트림을 디스크로 복사하면 됩니다. 버퍼 경계 처리의 핵심 규칙 하나:

text
buf: [........ 파일 바이트 ........ \r\n--bou]   ← 버퍼 끝에 경계의 일부만 들어옴
                                    ↑ 여기서부터 delimiter.length-1 바이트는
                                      "경계의 시작일 수도 있음" → 아직 돌려주지 않고 다음 read 로 넘김
scanEnd = limit - delimiter.length + 1   (EOF 면 limit)

버퍼의 마지막 delimiter.length - 1 바이트는 판정을 보류하고, 다음 fill() 에서 앞으로 당긴 뒤(compact) 다시 봅니다. 이렇게 하면 버퍼 크기와 무관하게 경계를 놓치지 않습니다(변형 1 에서 20바이트 버퍼로 검증).

2.4 신뢰 경계에서의 검증 — 무엇을 믿으면 안 되는가

업로드 요청의 모든 요소는 사용자(또는 공격자)가 만든 것입니다.

항목 믿으면 생기는 일 대책
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)는 시그니처가 없으므로 검사를 건너뜁니다.

2.5 저장 — 임시 파일 + 원자적 이동 + UUID

text
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)에 넣습니다.

2.6 다운로드 응답 헤더

헤더 값 이유
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 응답에서 이 조각이 전체의 어디인지
text
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") 은 브라우저마다 결과가 달라 더 이상 쓰지 않습니다.

2.7 Range 요청 — 이어받기와 스트리밍 재생

text
요청:  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 응답)는 실무에서 거의 안 쓰므로 단일 범위만 지원합니다.

2.8 JDK HttpServer 의 구조와 스레드

text
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 설정으로 같은 일을 합니다).

2.9 Servlet / Spring 대응표

이 레슨 (순수 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* 을 만들어 줍니다. 직접 문자열을 조립하지 마세요.

핵심 원리
  • 2.1 HTTP 로 파일을 보내는 세 가지 방법
  • 2.2 multipart/form-data 해부
  • 2.3 스트리밍 파싱 — 왜 전체를 메모리에 올리면 안 되는가
  • 2.4 신뢰 경계에서의 검증 — 무엇을 믿으면 안 되는가
  • 2.5 저장 — 임시 파일 + 원자적 이동 + UUID
  • 2.6 다운로드 응답 헤더
  • 2.7 Range 요청 — 이어받기와 스트리밍 재생
  • 2.8 JDK HttpServer 의 구조와 스레드
  • 2.9 Servlet / Spring 대응표
이전 섹션1 왜 배우는가2 / 7다음 섹션3 코드 예제