공공부하자개발 · 영어 학습 노트
자바
실무 확장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정리‹ 이전다음 ›

5. 자주 하는 실수 (Tip)

❌ 실수 1: 요청 본문을 통째로 읽고 파싱한다

java
byte[] all = ex.getRequestBody().readAllBytes();          // 500MB 업로드 = 500MB 힙
int idx = indexOf(all, boundary);

동시 업로드 몇 개면 OOM 입니다. 크기 제한도 "다 읽은 뒤" 검사하게 되어 의미가 없습니다. Spring 에서 MultipartFile.getBytes() 를 습관적으로 쓰는 것도 같은 문제입니다.

✅ 파트를 InputStream 으로 받아 그대로 디스크·파서로 흘려보냅니다.

java
new MultipartParser(ex.getRequestBody(), boundary).parse(part -> store.save(part.filename(), part.body()));
// Spring: file.getInputStream() 또는 file.transferTo(path). getBytes() 는 작은 파일에만.

❌ 실수 2: 파일명을 그대로 저장 경로로 쓴다

java
Path dest = uploadDir.resolve(part.filename());           // filename = "../../webapps/ROOT/shell.jsp"
Files.copy(part.body(), dest);

resolve 는 .. 을 해석하지 않으므로 업로드 디렉터리 밖에 파일이 생깁니다. 웹 루트에 JSP 를 떨어뜨리면 원격 코드 실행입니다.

✅ 저장명은 서버가 만든 UUID, 원본명은 정규화해서 메타로만 보관합니다. 다운로드 시에도 id 형식을 검사합니다.

java
String name = FileStore.sanitize(part.filename());        // 경로 부분 제거
Path dest = uploadDir.resolve(UUID.randomUUID().toString());
// 다운로드: if (!ID_PATTERN.matcher(id).matches()) return 404;

❌ 실수 3: 확장자 블랙리스트, 또는 브라우저가 보낸 Content-Type 을 믿는다

java
if (name.endsWith(".exe") || name.endsWith(".jsp")) reject();     // ".JSP", ".jspx", ".phtml" 통과
if (!part.contentType().startsWith("image/")) reject();          // 헤더는 클라이언트가 마음대로 씀

✅ 허용 확장자 화이트리스트 + 매직 넘버로 실제 내용 검사. MIME 은 서버가 확장자로 결정합니다.

java
if (!ALLOWED.contains(ext)) throw new UploadException(400, "허용되지 않는 확장자");
checkMagic(ext, first8Bytes);                              // .png 인데 89 50 4E 47 이 아니면 거절
String type = MIME.getOrDefault(ext, "application/octet-stream");

❌ 실수 4: 한글 파일명을 URL 인코딩만 하거나 ISO-8859-1 트릭을 쓴다

java
h.set("Content-Disposition", "attachment; filename=" + URLEncoder.encode(name, "UTF-8"));   // "%ED%9A%8C+..." 로 저장됨
h.set("Content-Disposition", "attachment; filename=\"" + new String(name.getBytes("UTF-8"), "ISO-8859-1") + "\"");  // 브라우저별 제각각

첫 번째는 브라우저가 퍼센트 문자열을 파일명으로 그대로 씁니다. 두 번째는 헤더에 비 ASCII 바이트를 싣는 편법이라 HttpServer 가 거부하거나 브라우저마다 다르게 해석합니다.

✅ RFC 5987 filename*=UTF-8''... 과 ASCII 대체 filename 을 함께 보냅니다.

java
String utf8 = URLEncoder.encode(name, StandardCharsets.UTF_8).replace("+", "%20");
h.set("Content-Disposition", "attachment; filename=\"" + ascii + "\"; filename*=UTF-8''" + utf8);
// Spring: ContentDisposition.attachment().filename(name, StandardCharsets.UTF_8).build()

❌ 실수 5: 거절할 때 본문을 안 읽고 응답한다

java
if (tooLarge) { HtmlPages.send(ex, 413, ...); return; }   // 클라이언트는 아직 3MB 를 보내는 중

서버가 응답 후 연결을 닫으면 클라이언트는 쓰기 도중 Connection reset 예외를 받고 413 본문을 보지 못합니다. "업로드하면 알 수 없는 오류" 로 보고됩니다.

✅ 남은 본문을 소비한 뒤 응답합니다. 한도가 아주 크면(수 GB) 소비 대신 연결을 끊는 것이 맞고, 그때는 클라이언트가 리셋을 보는 것을 감수합니다.

java
ex.getRequestBody().transferTo(OutputStream.nullOutputStream());
HtmlPages.send(ex, 413, "text/plain; charset=UTF-8", "크기 초과");

❌ 실수 6: HttpServer 의 실행기를 지정하지 않는다

java
HttpServer server = HttpServer.create(addr, 0);
server.start();                                            // executor = null → 단일 스레드

업로드 하나가 진행되는 동안 다른 모든 요청이 줄을 섭니다. 개발 중에는 눈치채지 못하고 배포 후 "가끔 멈춘다" 로 나타납니다.

✅ 가상 스레드(JDK 21) 또는 고정 풀을 지정합니다.

java
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());   // 또는 newFixedThreadPool(32)

❌ 실수 7: 응답 본문 스트림을 닫지 않거나 Content-Length 와 다른 양을 쓴다

java
ex.sendResponseHeaders(200, size);
ex.getResponseBody().write(bytes);                          // close 없음 → 응답이 끝나지 않음
ex.sendResponseHeaders(200, 0);  out.write(1바이트)          // 0 = chunked. 길이 다르면 브라우저가 대기/오류

✅ sendResponseHeaders 의 길이와 실제 쓰는 바이트 수를 맞추고, 본문 스트림은 try-with-resources 로 닫습니다. 본문이 없으면 -1.

java
ex.sendResponseHeaders(200, len);
try (OutputStream out = ex.getResponseBody()) { in.transferTo(out); }
자주 하는 실수 (Tip)
  • ❌ 실수 1: 요청 본문을 통째로 읽고 파싱한다
  • ❌ 실수 2: 파일명을 그대로 저장 경로로 쓴다
  • ❌ 실수 3: 확장자 블랙리스트, 또는 브라우저가 보낸 Content-Type 을 믿는다
  • ❌ 실수 4: 한글 파일명을 URL 인코딩만 하거나 ISO-8859-1 트릭을 쓴다
  • ❌ 실수 5: 거절할 때 본문을 안 읽고 응답한다
  • ❌ 실수 6: HttpServer 의 실행기를 지정하지 않는다
  • ❌ 실수 7: 응답 본문 스트림을 닫지 않거나 Content-Length 와 다른 양을 쓴다
이전 섹션4 응용 변형 예제5 / 7다음 섹션6 연습 문제