java-src/extension/03_file_upload/ 에서 다음처럼 실행합니다.
javac -encoding UTF-8 *.java
java -Dstdout.encoding=UTF-8 Main --demo # 임의 포트로 띄우고 HttpClient 로 10가지 시나리오 검증 후 종료
java -Dstdout.encoding=UTF-8 Main # 8080 포트 유지. 브라우저에서 http://localhost:8080/버퍼 하나(64KB)와 pos/limit 두 인덱스로 동작합니다. PartStream.read() 는 버퍼 안에서 \r\n--boundary 가 시작되는 위치 직전까지만 돌려주고, 경계의 일부만 버퍼 끝에 걸린 경우는 판정을 보류합니다. 핸들러가 본문을 덜 읽어도 drain() 으로 경계까지 소비하므로 다음 파트로 정확히 넘어갑니다.
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 바이트 그대로 보내기 때문입니다.
확장자 화이트리스트 → 스트리밍 복사하며 크기 카운트 → 첫 8바이트로 매직 넘버 검사 → 임시 파일 원자적 이동 → 메타 기록. 실패하면 어느 단계든 .part 를 지우고 UploadException(status) 를 던집니다. 검증이 흩어져 있으면 하나를 빼먹기 쉬우므로 저장 함수 하나가 전부 책임집니다.
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개Content-Length 가 있으면 파싱 전에 거절합니다(1차). chunked 라 길이를 모르면 save 안의 카운트가 잡습니다(2차). 두 경우 모두 남은 본문을 읽어 버린 뒤 응답해야 클라이언트가 413 을 제대로 받습니다.
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/8sendResponseHeaders(status, len) 에 길이를 주면 Content-Length 가 자동으로 붙고, 이후 getResponseBody() 에 정확히 그만큼 써야 합니다. 전체 전송은 transferTo, 부분 전송은 길이 제한 복사입니다.
@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 본문:서버는 컨텍스트 4개와 가상 스레드 실행기로 끝납니다. 클라이언트는 2.2 의 바이트열을 그대로 조립합니다. 브라우저·curl·RestTemplate 이 하는 일이 정확히 이것입니다.
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 조립을 대신합니다.
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-...