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

4. 응용 변형 예제

3절은 하나의 서버를 관통했습니다. 여기서는 파서의 경계 처리를 극단적으로 검증하고, 대용량 업로드를 클라이언트도 스트리밍하고, 업로드 직후 CSV 를 파싱해 검증 결과를 돌려주는 흐름, 정적 파일 서버, 이어받기 클라이언트로 같은 부품을 다르게 조립합니다.

변형 1: 파서 경계 처리 검증 — 20바이트 버퍼로 100KB 파트 파싱

파서의 가장 흔한 버그는 "경계가 버퍼 두 조각에 걸칠 때" 입니다. 버퍼를 경계 길이의 2배(20바이트)까지 줄여 100KB 무작위 바이트를 통과시키고, 본문 안에 경계와 거의 같은 문자열(\r\n--XyZ12!)을 심어 오탐이 없는지도 봅니다. 마지막 파트는 0바이트 파일입니다.

java
String b = "XyZ123";
byte[] data = new byte[100_000]; new Random(1).nextBytes(data);
byte[] near = "\r\n--XyZ12!".getBytes(); System.arraycopy(near, 0, data, 5000, near.length);   // 경계 유사 문자열

ByteArrayOutputStream body = new ByteArrayOutputStream();
body.writeBytes(("--" + b + "\r\nContent-Disposition: form-data; name=\"memo\"\r\n\r\nhello\r\n--" + b
        + "\r\nContent-Disposition: form-data; name=\"file\"; filename=\"x.bin\"\r\nContent-Type: application/octet-stream\r\n\r\n").getBytes());
body.writeBytes(data);
body.writeBytes(("\r\n--" + b + "\r\nContent-Disposition: form-data; name=\"empty\"; filename=\"e.txt\"\r\n\r\n\r\n--" + b + "--\r\n").getBytes());

for (int buf : new int[]{20, 64, 1000, 65536}) {
    List<String> seen = new ArrayList<>();
    new MultipartParser(new ByteArrayInputStream(body.toByteArray()), b, buf).parse(part -> {   // 패키지 전용 생성자
        byte[] got = part.body().readAllBytes();
        seen.add(part.name() + ":" + (part.filename() == null ? new String(got)
                : got.length + (Arrays.equals(got, data) ? "=ok" : "")));
    });
    System.out.println("buf=" + buf + " " + seen);
}
// 출력:
// buf=20 [memo:hello, file:100000=ok, empty:0]
// buf=64 [memo:hello, file:100000=ok, empty:0]
// buf=1000 [memo:hello, file:100000=ok, empty:0]
// buf=65536 [memo:hello, file:100000=ok, empty:0]

버퍼가 20바이트여도 100,000바이트가 정확히 복원됩니다. scanEnd 보류 규칙과 fill() 의 compact 가 맞물려 동작하기 때문입니다. 파서를 고치면 이 검사를 먼저 돌리세요.

변형 2: 클라이언트도 스트리밍 — 3GB 파일을 힙에 올리지 않고 업로드

예제 5 의 upload 는 본문을 byte[] 로 조립합니다. 파일이 크면 클라이언트도 OOM 입니다.

BodyPublishers.ofInputStream 에 "헤더 → 파일 스트림 → 꼬리" 를 이어 붙인 SequenceInputStream 을 주면 클라이언트 메모리는 버퍼 하나뿐이고, 요청은 Transfer-Encoding: chunked 로 나갑니다(서버는 Content-Length 없이 받으므로 2차 검사만 동작 — 데모 [5b]).

java
static HttpResponse<String> uploadStreaming(HttpClient client, String base, Path file) throws IOException, InterruptedException {
    String boundary = "----JavaDemo" + System.nanoTime();
    byte[] head = ("--" + boundary + "\r\nContent-Disposition: form-data; name=\"file\"; filename=\""
            + file.getFileName() + "\"\r\nContent-Type: application/octet-stream\r\n\r\n").getBytes(StandardCharsets.UTF_8);
    byte[] tail = ("\r\n--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8);
    HttpRequest req = HttpRequest.newBuilder(URI.create(base + "/upload"))
            .header("Content-Type", "multipart/form-data; boundary=" + boundary)
            .POST(HttpRequest.BodyPublishers.ofInputStream(() -> {
                try {
                    return new SequenceInputStream(
                            new SequenceInputStream(new ByteArrayInputStream(head), Files.newInputStream(file)),
                            new ByteArrayInputStream(tail));
                } catch (IOException e) { throw new UncheckedIOException(e); }
            }))
            .build();
    return client.send(req, HttpResponse.BodyHandlers.ofString());
}
// 출력 (데모 [5b] 와 같은 경로. 3MB 파일, 서버 한도 2MB):
//   413 크기 초과: big.csv (최대 2097152 bytes)
//   .part 임시 파일 잔재: 0개

Content-Length 를 미리 알면(Files.size) ofInputStream 대신 fromPublisher 로 길이를 명시할 수도 있지만, 서버 쪽 2차 검사가 있으니 chunked 로도 안전합니다. JDK 22 부터는 BodyPublishers.concat(...) 이 이 SequenceInputStream 조립을 대신합니다.

변형 3: 업로드 → CSV 파싱 → 검증 결과 JSON (회원 일괄 등록 흐름)

실무의 "엑셀 업로드" 는 저장이 목적이 아니라 내용 검증과 등록이 목적입니다. 파트 스트림을 디스크에 저장하지 않고 바로 줄 단위로 읽어 검증하고, 행 번호가 붙은 오류 목록을 돌려줍니다. XLSX 라면 이 자리에서 01/02 레슨의 리더를 호출합니다(POI 는 WorkbookFactory.create(part.body())).

java
record RowError(int row, String field, String message) {}

static String validateMembers(InputStream csv) throws IOException {
    List<RowError> errors = new ArrayList<>();
    Set<String> emails = new HashSet<>();
    int ok = 0, row = 0;
    try (BufferedReader r = new BufferedReader(new InputStreamReader(csv, StandardCharsets.UTF_8))) {
        String header = r.readLine(); row++;
        if (!"name,email".equals(header)) return "{\"fatal\":\"헤더가 name,email 이 아닙니다: " + header + "\"}";
        String line;
        while ((line = r.readLine()) != null) {
            row++;
            String[] f = line.split(",", -1);
            if (f.length != 2) { errors.add(new RowError(row, "-", "컬럼 수 " + f.length)); continue; }
            boolean bad = false;
            if (f[0].isBlank()) { errors.add(new RowError(row, "name", "필수")); bad = true; }
            if (!f[1].matches("[^@\\s]+@[^@\\s]+\\.[a-z]{2,}")) { errors.add(new RowError(row, "email", "형식 오류: " + f[1])); bad = true; }
            else if (!emails.add(f[1])) { errors.add(new RowError(row, "email", "중복: " + f[1])); bad = true; }
            if (!bad) ok++;
        }
    }
    StringBuilder sb = new StringBuilder("{\"ok\":" + ok + ",\"errors\":[");
    for (int i = 0; i < errors.size(); i++) {
        RowError e = errors.get(i);
        sb.append(i > 0 ? "," : "").append("{\"row\":").append(e.row()).append(",\"field\":\"").append(e.field())
          .append("\",\"message\":\"").append(HtmlPages.escapeJson(e.message())).append("\"}");
    }
    return sb.append("]}").toString();
}

// 핸들러 안에서: 저장 대신 검증
new MultipartParser(ex.getRequestBody(), boundary).parse(part -> {
    if (part.isFile() && FileStore.extensionOf(FileStore.sanitize(part.filename())).equals("csv"))
        result[0] = validateMembers(part.body());               // 디스크를 거치지 않음
});
// 입력:
//   name,email
//   홍길동,hong@example.com
//   ,kim@example.com
//   이철수,not-an-email
//   홍길동,hong@example.com
// 출력:
//   {"ok":1,"errors":[{"row":3,"field":"name","message":"필수"},{"row":4,"field":"email","message":"형식 오류: not-an-email"},{"row":5,"field":"email","message":"중복: hong@example.com"}]}

디스크에 저장하지 않으면 임시 파일 정리 문제가 사라지지만, 검증 후 "등록 실행" 을 별도 요청으로 나누는 2단계 UI(미리보기 → 확정)라면 저장해 두고 id 로 다시 읽어야 합니다. 그 경우 FileStore.save 후 find(id).path() 를 파싱합니다.

변형 4: 정적 파일 서버 — 디렉터리 밖 접근 차단과 MIME 추정

/static/{path} 로 폴더의 파일을 서빙합니다. 핵심은 resolve 결과를 normalize 한 뒤 루트 디렉터리로 시작하는지 확인하는 한 줄입니다. .. 을 문자열로 걸러내는 방식은 %2e%2e, ..\ 같은 변형에 뚫립니다.

java
static HttpHandler staticFiles(Path root) {
    Path base = root.toAbsolutePath().normalize();
    return ex -> {
        String rel = ex.getRequestURI().getPath().substring("/static/".length());      // 이미 퍼센트 디코딩됨
        Path target = base.resolve(rel).normalize();
        if (!target.startsWith(base) || !Files.isRegularFile(target)) {                // 루트 밖이거나 파일 아님
            HtmlPages.send(ex, 404, "text/plain; charset=UTF-8", "not found");
            return;
        }
        String type = Files.probeContentType(target);                                   // OS 의 확장자 매핑
        ex.getResponseHeaders().set("Content-Type", type != null ? type : "application/octet-stream");
        ex.getResponseHeaders().set("Cache-Control", "public, max-age=3600");
        ex.sendResponseHeaders(200, Files.size(target));
        try (InputStream in = Files.newInputStream(target); OutputStream out = ex.getResponseBody()) {
            in.transferTo(out);
        }
    };
}
// server.createContext("/static/", staticFiles(Path.of("public")));
// 출력:
//   GET /static/app.css            → 200 text/css
//   GET /static/../Main.java       → 404 (normalize 후 base 밖)
//   GET /static/%2e%2e/Main.java   → 404 (디코딩되어 같은 경로)

JDK 18+ 에는 SimpleFileServer.createFileHandler(root) 가 내장되어 있어 같은 일을 한 줄로 합니다(jwebserver 명령도 같은 것). 직접 쓴 이유는 경로 검증이 어디서 일어나는지 보기 위해서입니다.

변형 5: 이어받기 클라이언트 — Range 로 중단된 다운로드 재개

이미 받은 만큼을 Range: bytes={received}- 로 요청하고 파일 끝에 덧붙입니다. 서버가 206 이 아니라 200 을 주면(Range 미지원) 처음부터 다시 씁니다.

java
static void resumeDownload(HttpClient client, String url, Path dest) throws IOException, InterruptedException {
    long have = Files.exists(dest) ? Files.size(dest) : 0;
    HttpRequest req = HttpRequest.newBuilder(URI.create(url)).header("Range", "bytes=" + have + "-").build();
    HttpResponse<InputStream> res = client.send(req, HttpResponse.BodyHandlers.ofInputStream());
    try (InputStream in = res.body()) {
        if (res.statusCode() == 206) {
            try (OutputStream out = Files.newOutputStream(dest, StandardOpenOption.CREATE, StandardOpenOption.APPEND)) {
                in.transferTo(out);
            }
            System.out.println("이어받기 " + have + " → " + Files.size(dest) + " (" + res.headers().firstValue("Content-Range").orElse("") + ")");
        } else if (res.statusCode() == 416) {
            System.out.println("이미 완료 (" + have + " bytes)");
        } else {
            Files.copy(in, dest, StandardCopyOption.REPLACE_EXISTING);              // 200: 처음부터
            System.out.println("서버가 Range 미지원, 전체 재수신 " + Files.size(dest));
        }
    }
}
// 출력 (64바이트 파일을 10바이트만 받은 상태에서 호출):
//   이어받기 10 → 64 (bytes 10-63/64)
// 다시 호출:
//   이미 완료 (64 bytes)
응용 변형 예제
  • 변형 1: 파서 경계 처리 검증 — 20바이트 버퍼로 100KB 파트 파싱
  • 변형 2: 클라이언트도 스트리밍 — 3GB 파일을 힙에 올리지 않고 업로드
  • 변형 3: 업로드 → CSV 파싱 → 검증 결과 JSON (회원 일괄 등록 흐름)
  • 변형 4: 정적 파일 서버 — 디렉터리 밖 접근 차단과 MIME 추정
  • 변형 5: 이어받기 클라이언트 — Range 로 중단된 다운로드 재개
이전 섹션3 코드 예제4 / 7다음 섹션5 자주 하는 실수 (Tip)