3절은 하나의 서버를 관통했습니다. 여기서는 파서의 경계 처리를 극단적으로 검증하고, 대용량 업로드를 클라이언트도 스트리밍하고, 업로드 직후 CSV 를 파싱해 검증 결과를 돌려주는 흐름, 정적 파일 서버, 이어받기 클라이언트로 같은 부품을 다르게 조립합니다.
파서의 가장 흔한 버그는 "경계가 버퍼 두 조각에 걸칠 때" 입니다. 버퍼를 경계 길이의 2배(20바이트)까지 줄여 100KB 무작위 바이트를 통과시키고, 본문 안에 경계와 거의 같은 문자열(\r\n--XyZ12!)을 심어 오탐이 없는지도 봅니다. 마지막 파트는 0바이트 파일입니다.
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 가 맞물려 동작하기 때문입니다. 파서를 고치면 이 검사를 먼저 돌리세요.
예제 5 의 upload 는 본문을 byte[] 로 조립합니다. 파일이 크면 클라이언트도 OOM 입니다.
BodyPublishers.ofInputStream 에 "헤더 → 파일 스트림 → 꼬리" 를 이어 붙인 SequenceInputStream 을 주면 클라이언트 메모리는 버퍼 하나뿐이고, 요청은 Transfer-Encoding: chunked 로 나갑니다(서버는 Content-Length 없이 받으므로 2차 검사만 동작 — 데모 [5b]).
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 조립을 대신합니다.
실무의 "엑셀 업로드" 는 저장이 목적이 아니라 내용 검증과 등록이 목적입니다. 파트 스트림을 디스크에 저장하지 않고 바로 줄 단위로 읽어 검증하고, 행 번호가 붙은 오류 목록을 돌려줍니다. XLSX 라면 이 자리에서 01/02 레슨의 리더를 호출합니다(POI 는 WorkbookFactory.create(part.body())).
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() 를 파싱합니다.
/static/{path} 로 폴더의 파일을 서빙합니다. 핵심은 resolve 결과를 normalize 한 뒤 루트 디렉터리로 시작하는지 확인하는 한 줄입니다. .. 을 문자열로 걸러내는 방식은 %2e%2e, ..\ 같은 변형에 뚫립니다.
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 명령도 같은 것). 직접 쓴 이유는 경로 검증이 어디서 일어나는지 보기 위해서입니다.
이미 받은 만큼을 Range: bytes={received}- 로 요청하고 파일 끝에 덧붙입니다. 서버가 206 이 아니라 200 을 주면(Range 미지원) 처음부터 다시 씁니다.
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)