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

3. 코드 예제

java-src/extension/03_file_upload/ 에서 다음처럼 실행합니다.

text
javac -encoding UTF-8 *.java
java -Dstdout.encoding=UTF-8 Main --demo     # 임의 포트로 띄우고 HttpClient 로 10가지 시나리오 검증 후 종료
java -Dstdout.encoding=UTF-8 Main            # 8080 포트 유지. 브라우저에서 http://localhost:8080/

예제 1: 스트리밍 multipart 파서

버퍼 하나(64KB)와 pos/limit 두 인덱스로 동작합니다. PartStream.read() 는 버퍼 안에서 \r\n--boundary 가 시작되는 위치 직전까지만 돌려주고, 경계의 일부만 버퍼 끝에 걸린 경우는 판정을 보류합니다. 핸들러가 본문을 덜 읽어도 drain() 으로 경계까지 소비하므로 다음 파트로 정확히 넘어갑니다.

java
public final class MultipartParser {
    public record Part(String name, String filename, String contentType, InputStream body) {
        public boolean isFile() { return filename != null; }
    }
    public interface PartHandler { void handle(Part part) throws IOException; }

    private final InputStream in;
    private final byte[] delimiter;                 // "\r\n--" + boundary
    private final byte[] buf = new byte[64 * 1024];
    private int pos, limit;
    private boolean eof;

    public static String boundaryOf(String contentType) {           // "multipart/form-data; boundary=xxx" → "xxx"
        if (contentType == null || !contentType.toLowerCase(Locale.ROOT).startsWith("multipart/form-data")) return null;
        Matcher m = Pattern.compile("boundary=\"?([^\";]+)\"?").matcher(contentType);
        return m.find() ? m.group(1) : null;
    }

    public void parse(PartHandler handler) throws IOException {
        String first = readLine();                                   // "--boundary"
        String expected = new String(delimiter, 2, delimiter.length - 2, StandardCharsets.ISO_8859_1);
        if (!expected.equals(first)) throw new IOException("multipart 시작 경계가 없습니다: " + first);
        while (true) {
            Map<String, String> headers = readHeaders();             // 빈 줄까지
            String cd = headers.getOrDefault("content-disposition", "");
            Matcher n = NAME.matcher(cd), f = FILENAME.matcher(cd);
            PartStream body = new PartStream();
            handler.handle(new Part(n.find() ? n.group(1) : null, f.find() ? f.group(1) : null,
                    headers.get("content-type"), body));
            body.drain();                                            // 핸들러가 덜 읽었어도 경계까지 소비
            pos += delimiter.length;                                 // "\r\n--boundary" 건너뜀
            fill(2);
            if (limit - pos >= 2 && buf[pos] == '-' && buf[pos + 1] == '-') return;   // "--boundary--" = 끝
            if (limit - pos < 2 || buf[pos] != '\r' || buf[pos + 1] != '\n') throw new IOException("경계 뒤에 CRLF 가 없습니다");
            pos += 2;
        }
    }

    private void fill(int n) throws IOException {                    // buf[pos..limit) 에 최소 n 바이트 확보
        if (limit - pos >= n || eof) return;
        if (pos > 0) { System.arraycopy(buf, pos, buf, 0, limit - pos); limit -= pos; pos = 0; }   // compact
        while (limit - pos < n && !eof) {
            int r = in.read(buf, limit, buf.length - limit);
            if (r < 0) eof = true; else limit += r;
        }
    }

    private final class PartStream extends InputStream {
        private boolean done;
        @Override public int read(byte[] b, int off, int len) throws IOException {
            if (done) return -1;
            fill(delimiter.length);
            if (matchesAt(pos)) { done = true; return -1; }          // 경계 도달
            if (pos >= limit) throw new EOFException("multipart 본문이 경계 없이 끝났습니다");
            int scanEnd = eof ? limit : limit - delimiter.length + 1; // 이 앞의 바이트만 판정 가능
            int n = 0;
            while (n < len && pos + n < scanEnd) {
                if (buf[pos + n] == '\r' && matchesAt(pos + n)) break;
                b[off + n] = buf[pos + n];
                n++;
            }
            pos += n;
            return n;
        }
        void drain() throws IOException { byte[] sink = new byte[8192]; while (read(sink, 0, sink.length) >= 0) {} }
    }
}
// 출력 (데모 [1]): 200 {"saved":[{"id":"6089a292-...","name":"회원명단.csv","size":64,"type":"text/csv; charset=UTF-8"}],"fields":{"memo":"9월 회원명단"}}

readLine() 은 헤더 줄을 UTF-8 로 디코딩합니다. 브라우저가 filename="회원명단.csv" 를 UTF-8 바이트 그대로 보내기 때문입니다.

예제 2: 검증과 저장을 한곳에 — FileStore.save

확장자 화이트리스트 → 스트리밍 복사하며 크기 카운트 → 첫 8바이트로 매직 넘버 검사 → 임시 파일 원자적 이동 → 메타 기록. 실패하면 어느 단계든 .part 를 지우고 UploadException(status) 를 던집니다. 검증이 흩어져 있으면 하나를 빼먹기 쉬우므로 저장 함수 하나가 전부 책임집니다.

java
public StoredFile save(String rawName, InputStream body) throws IOException {
    String name = sanitize(rawName);                                        // "../../evil.txt" → "evil.txt"
    String ext = extensionOf(name);
    if (!ALLOWED.contains(ext)) throw new UploadException(400, "허용되지 않는 확장자: ." + ext + " (" + name + ")");

    String id = UUID.randomUUID().toString();
    Path tmp = dir.resolve(id + ".part");
    long size = 0;
    byte[] head = new byte[8]; int headLen = 0; boolean magicChecked = false;
    try (OutputStream out = new BufferedOutputStream(Files.newOutputStream(tmp))) {
        byte[] b = new byte[8192];
        int n;
        while ((n = body.read(b)) > 0) {
            if (headLen < head.length) {                                    // 처음 8바이트 모아 두기
                int c = Math.min(head.length - headLen, n);
                System.arraycopy(b, 0, head, headLen, c);
                headLen += c;
            }
            if (!magicChecked && headLen == head.length) { checkMagic(ext, head, headLen); magicChecked = true; }
            size += n;
            if (size > maxBytes) throw new UploadException(413, "크기 초과: " + name + " (최대 " + maxBytes + " bytes)");
            out.write(b, 0, n);
        }
        if (!magicChecked) checkMagic(ext, head, headLen);                  // 8바이트 미만 파일
    } catch (IOException e) {
        Files.deleteIfExists(tmp);                                          // 반쪽 파일 제거
        throw e;
    }
    Path dest = dir.resolve(id);
    Files.move(tmp, dest, StandardCopyOption.ATOMIC_MOVE);                  // 완성된 순간에만 정식 이름
    String type = MIME.getOrDefault(ext, "application/octet-stream");
    Files.writeString(dir.resolve(id + ".meta"), name + "\n" + type + "\n" + size, StandardCharsets.UTF_8);
    return new StoredFile(id, name, type, size, dest);
}

public static String sanitize(String raw) {
    String name = raw == null ? "" : raw.replaceAll("^.*[/\\\\]", "");    // 마지막 / 또는 \ 앞은 전부 버림
    name = name.replaceAll("\\p{Cntrl}", "").strip();
    if (name.isEmpty() || name.equals(".") || name.equals("..")) name = "file";
    return name;
}
// 출력 (데모 [2]~[5b]):
//   400 허용되지 않는 확장자: .exe (virus.exe)
//   400 확장자는 .png 이지만 내용이 PNG 형식이 아닙니다
//   200 {"saved":[{"id":"11c3aa12-...","name":"evil.txt","size":2,"type":"text/plain; charset=UTF-8"}],"fields":{}}
//   ../../evil.txt 생성됨? false → 경로 부분을 떼고 evil.txt 로 uploads/ 안에만 저장
//   413 Content-Length 3145887 > 최대 2097152
//   413 크기 초과: big.csv (최대 2097152 bytes)
//   .part 임시 파일 잔재: 0개

예제 3: 업로드 핸들러 — 파트를 돌며 저장, 거절 시 본문 소비

Content-Length 가 있으면 파싱 전에 거절합니다(1차). chunked 라 길이를 모르면 save 안의 카운트가 잡습니다(2차). 두 경우 모두 남은 본문을 읽어 버린 뒤 응답해야 클라이언트가 413 을 제대로 받습니다.

java
public final class UploadHandler implements HttpHandler {
    @Override public void handle(HttpExchange ex) throws IOException {
        if (!"POST".equals(ex.getRequestMethod())) { HtmlPages.send(ex, 405, "text/plain; charset=UTF-8", "POST only"); return; }
        String boundary = MultipartParser.boundaryOf(ex.getRequestHeaders().getFirst("Content-Type"));
        if (boundary == null) { HtmlPages.send(ex, 400, "text/plain; charset=UTF-8", "multipart/form-data 가 아닙니다"); return; }

        List<FileStore.StoredFile> saved = new ArrayList<>();
        Map<String, String> fields = new LinkedHashMap<>();
        try {
            String len = ex.getRequestHeaders().getFirst("Content-Length");           // 1차: 헤더로 조기 거절
            if (len != null && Long.parseLong(len) > store.maxBytes() + 4096)
                throw new FileStore.UploadException(413, "Content-Length " + len + " > 최대 " + store.maxBytes());

            new MultipartParser(ex.getRequestBody(), boundary).parse(part -> {
                if (!part.isFile()) {                                                  // 일반 필드: 작으니 문자열로
                    fields.put(part.name(), new String(part.body().readAllBytes(), StandardCharsets.UTF_8));
                    return;
                }
                saved.add(store.save(part.filename(), part.body()));                  // 2차: 스트리밍 중 검증
            });
        } catch (FileStore.UploadException e) {
            ex.getRequestBody().transferTo(OutputStream.nullOutputStream());          // 남은 본문 소비 후 응답
            HtmlPages.send(ex, e.status, "text/plain; charset=UTF-8", e.getMessage());
            return;
        }
        HtmlPages.send(ex, 200, "application/json; charset=UTF-8", toJson(saved, fields));
    }
}
// 출력 (데모 [6]): 동시 업로드 8건 (가상 스레드 풀) → 200 응답: 8/8

예제 4: 다운로드 핸들러 — 헤더 조립, Range, 스트리밍 전송

sendResponseHeaders(status, len) 에 길이를 주면 Content-Length 가 자동으로 붙고, 이후 getResponseBody() 에 정확히 그만큼 써야 합니다. 전체 전송은 transferTo, 부분 전송은 길이 제한 복사입니다.

java
@Override public void handle(HttpExchange ex) throws IOException {
    FileStore.StoredFile f = store.find(HtmlPages.query(ex.getRequestURI().getRawQuery()).get("name"));
    if (f == null) { HtmlPages.send(ex, 404, "text/plain; charset=UTF-8", "파일 없음"); return; }

    long size = f.size(), start = 0, end = size - 1;
    int status = 200;
    Headers h = ex.getResponseHeaders();
    h.set("Content-Type", f.contentType());
    h.set("Content-Disposition", contentDisposition(f.originalName()));
    h.set("Accept-Ranges", "bytes");

    String range = ex.getRequestHeaders().getFirst("Range");
    if (range != null) {
        Matcher m = RANGE.matcher(range);                                    // "bytes=(\d*)-(\d*)"
        if (m.matches()) {
            if (m.group(1).isEmpty()) start = Math.max(0, size - Long.parseLong(m.group(2)));   // bytes=-8
            else {
                start = Long.parseLong(m.group(1));
                if (!m.group(2).isEmpty()) end = Math.min(Long.parseLong(m.group(2)), size - 1);
            }
        }
        if (!m.matches() || start > end || start >= size) {
            h.set("Content-Range", "bytes */" + size);
            ex.sendResponseHeaders(416, -1); ex.close(); return;
        }
        status = 206;
        h.set("Content-Range", "bytes " + start + "-" + end + "/" + size);
    }

    long len = end - start + 1;
    ex.sendResponseHeaders(status, len == 0 ? -1 : len);                    // 고정 길이 → Content-Length 자동
    try (InputStream in = Files.newInputStream(f.path()); OutputStream out = ex.getResponseBody()) {
        in.skipNBytes(start);
        if (status == 200) in.transferTo(out);                               // 8KB 버퍼로 흘려보냄
        else copy(in, out, len);
    }
}

public static String contentDisposition(String filename) {                  // RFC 6266 / 5987
    String ascii = filename.replaceAll("[^\\x20-\\x7E]", "_").replace("\"", "");
    String utf8 = URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20");
    return "attachment; filename=\"" + ascii + "\"; filename*=UTF-8''" + utf8;
}
// 출력 (데모 [8][9]):
//   200
//   Content-Type: text/csv; charset=UTF-8
//   Content-Length: 64
//   Content-Disposition: attachment; filename="____.csv"; filename*=UTF-8''%ED%9A%8C%EC%9B%90%EB%AA%85%EB%8B%A8.csv
//   Accept-Ranges: bytes
//   본문 == 업로드 원본? true
//   bytes=0-9 → 206 Content-Range: bytes 0-9/64 본문: name,email
//   bytes=-8 → 206 Content-Range: bytes 56-63/64 본문: ple.com\n
//   bytes=999- → 416 Content-Range: bytes */64 본문:

예제 5: 서버 조립과 HttpClient 로 multipart 업로드

서버는 컨텍스트 4개와 가상 스레드 실행기로 끝납니다. 클라이언트는 2.2 의 바이트열을 그대로 조립합니다. 브라우저·curl·RestTemplate 이 하는 일이 정확히 이것입니다.

java
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", demo ? 0 : 8080), 0);
server.createContext("/", ex -> {                                  // "/" 는 모든 경로의 접두사 → 정확히 "/" 만
    if (!ex.getRequestURI().getPath().equals("/")) { HtmlPages.send(ex, 404, "text/plain; charset=UTF-8", "not found"); return; }
    HtmlPages.send(ex, 200, "text/html; charset=UTF-8", HtmlPages.INDEX);
});
server.createContext("/upload", new UploadHandler(store));
server.createContext("/files", ex -> {
    boolean json = "json".equals(HtmlPages.query(ex.getRequestURI().getRawQuery()).get("format"));
    HtmlPages.send(ex, 200, json ? "application/json; charset=UTF-8" : "text/html; charset=UTF-8",
            json ? HtmlPages.listJson(store.list()) : HtmlPages.listHtml(store.list()));
});
server.createContext("/download", new DownloadHandler(store));
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());   // 요청마다 가상 스레드
server.start();

// 클라이언트: multipart 본문 조립
static HttpResponse<String> upload(HttpClient client, String base, Map<String, String> fields,
                                   String filename, String contentType, byte[] data) throws IOException, InterruptedException {
    String boundary = "----JavaDemo" + System.nanoTime();
    ByteArrayOutputStream body = new ByteArrayOutputStream();
    for (var e : fields.entrySet())
        body.writeBytes(("--" + boundary + "\r\nContent-Disposition: form-data; name=\"" + e.getKey() + "\"\r\n\r\n"
                + e.getValue() + "\r\n").getBytes(StandardCharsets.UTF_8));
    body.writeBytes(("--" + boundary + "\r\nContent-Disposition: form-data; name=\"file\"; filename=\"" + filename
            + "\"\r\nContent-Type: " + contentType + "\r\n\r\n").getBytes(StandardCharsets.UTF_8));
    body.writeBytes(data);
    body.writeBytes(("\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.ofByteArray(body.toByteArray()))
            .build();
    return client.send(req, HttpResponse.BodyHandlers.ofString());
}
// 출력 (데모 [7][10]):
//   200 파일 10개 (앞 2개: [{"name":"evil.txt","size":2,"type":"text/plain; charset=UTF-8"},{"name":"par-0.txt","size...)
//   name=no-such-id → 404 파일 없음
//   name=..%2F..%2FMain.java → 404 파일 없음

같은 요청을 curl 로 보내면 다음과 같습니다. -F 가 multipart 조립을 대신합니다.

text
curl -F "memo=9월 회원명단" -F "file=@회원명단.csv;type=text/csv" http://localhost:8080/upload
curl -s http://localhost:8080/files?format=json
curl -OJ http://localhost:8080/download?name=6089a292-...        # -J: Content-Disposition 의 파일명으로 저장
curl -H "Range: bytes=0-9" -i http://localhost:8080/download?name=6089a292-...

예제 직접 실행

아래 폴더를 JDK 21 로 컴파일하고 실행합니다. 외부 jar 를 쓰는 레슨은 java-src/lib 를 클래스패스에 넣습니다.

cd java-src\extension\03_file_upload
javac -encoding UTF-8 *.java && java Main

:: 외부 jar 가 필요한 레슨
javac -encoding UTF-8 -cp "..\..\lib\*;." *.java && java -cp "..\..\lib\*;." Main
코드 예제
  • 예제 1: 스트리밍 multipart 파서
  • 예제 2: 검증과 저장을 한곳에 — FileStore.save
  • 예제 3: 업로드 핸들러 — 파트를 돌며 저장, 거절 시 본문 소비
  • 예제 4: 다운로드 핸들러 — 헤더 조립, Range, 스트리밍 전송
  • 예제 5: 서버 조립과 HttpClient 로 multipart 업로드
이전 섹션2 핵심 원리3 / 7다음 섹션4 응용 변형 예제