실행 방법입니다. 외부 jar 는 필요 없습니다.
cd java-src\extension\07_rest_json
C:\project\jdk-21.0.8\bin\javac -encoding UTF-8 *.java
C:\project\jdk-21.0.8\bin\java -Dstdout.encoding=UTF-8 Main --demo # 11개 시나리오 자동 실행
C:\project\jdk-21.0.8\bin\java -Dstdout.encoding=UTF-8 Main # 8080 에 서버만. curl 로 직접 호출// "/members/{id}" 를 "/members/([^/]+)" 정규식으로 컴파일하고 {id} 위치를 기억한다
public Router add(String method, String pathPattern, Handler handler) {
List<String> names = new ArrayList<>();
StringBuilder regex = new StringBuilder();
for (String seg : pathPattern.split("/")) {
if (seg.isEmpty()) continue;
regex.append('/');
if (seg.startsWith("{") && seg.endsWith("}")) {
names.add(seg.substring(1, seg.length() - 1));
regex.append("([^/]+)"); // '/' 를 뺀 한 구간이 변수 값
} else {
regex.append(Pattern.quote(seg));
}
}
routes.add(new Route(method, Pattern.compile(regex.toString()), names, handler));
return this;
}router.add("GET", "/members/{id}", (ex, vars) -> {
System.out.println("경로 변수: " + vars);
});
// 요청 GET /members/42
// 출력: 경로 변수: {id=42}패턴이 매칭돼도 메서드가 다르면 405 후보로 남기고, 아예 매칭이 없으면 404 후보로 남깁니다. 이 두 상태를 구분해야 "경로는 맞는데 메서드가 틀렸다"는 정확한 신호를 클라이언트에 줄 수 있습니다.
private void create(HttpExchange ex, Map<String, String> vars) throws IOException {
Map<String, Object> body = readJson(ex);
var errors = Validation.validateCreate(body);
if (!errors.isEmpty()) { ApiError.send(ex, 400, "VALIDATION_ERROR", "입력 값을 확인하세요", errors); return; }
Member m = store.create((String) body.get("name"), (String) body.get("email"), toInteger(body.get("age")));
ex.getResponseHeaders().set("Location", "/members/" + m.id()); // 201 은 새 자원 위치를 Location 에
ex.getResponseHeaders().set("ETag", "\"" + m.version() + "\"");
Router.writeJson(ex, 201, m.toJson());
}// 데모 [1] 실행 결과
// 출력: 201 Location=/members/1 {"id":"1","name":"홍길동","email":"[email protected]","age":30,"version":1,"createdAt":"2026-09-09T09:02:20.292485300Z"}id 가 문자열 "1" 로 나가는 이유는 Member.toJson() 이 String.valueOf(id) 를 쓰기 때문입니다(2.4절 long 정밀도 문제). createdAt 은 Instant.toString() 이 만드는 ISO-8601 UTC 문자열입니다.
public static List<FieldError> validateCreate(Map<String, Object> body) {
List<FieldError> errors = new ArrayList<>();
checkName(body.get("name"), errors); // 필수, 2~30자
checkEmail(body.get("email"), errors); // 필수, 정규식
checkAge(body.get("age"), errors); // 선택, 0~150
return errors;
}// 데모 [2]: name 없음, email 형식 오류, age 범위 초과를 한 번에
// 출력: 400 {"code":"VALIDATION_ERROR","message":"입력 값을 확인하세요","errors":[
// {"reason":"필수 항목입니다","field":"name"},
// {"reason":"이메일 형식이 아닙니다","field":"email"},
// {"reason":"0~150 사이여야 합니다","field":"age"}],"traceId":"312691f1", ...}세 검증기가 각각 독립적으로 오류를 모으므로 한 번의 요청으로 세 개의 필드 오류를 동시에 돌려줍니다. 첫 오류에서 멈추면 사용자는 폼을 세 번 고쳐 세 번 재요청해야 합니다.
Page<Member> p = Page.of(all, page, size);
Map<String, Object> body = new LinkedHashMap<>();
body.put("page", p.page());
body.put("size", p.size());
body.put("totalElements", p.totalElements());
body.put("totalPages", p.totalPages());
body.put("content", p.content().stream().map(Member::toJson).toList());
Router.writeJson(ex, 200, body);// 데모 [8]: 회원 5명 중 GET /members?page=0&size=2&sort=name,asc
// 출력: 200 {"page":0,"size":2,"totalElements":5,"totalPages":3,"content":[
// {"id":"2","name":"김영희", ...},{"id":"4","name":"박지훈", ...}]}size 를 100 으로 상한을 두는 이유는(MemberApi.list) 클라이언트가 size=1000000 을 보내 전체 데이터를 한 번에 덤프하는 것을 막기 위해서입니다. 정렬 키는 화이트리스트(switch 문)로만 받아 임의 필드로 SQL 을 조작하는 경로를 막습니다.
static HttpResponse<String> call(HttpClient client, String method, String url, String jsonBody) throws Exception {
var b = HttpRequest.newBuilder(URI.create(url)).timeout(Duration.ofSeconds(5));
if (jsonBody == null) b.method(method, HttpRequest.BodyPublishers.noBody());
else b.header("Content-Type", "application/json; charset=UTF-8")
.method(method, HttpRequest.BodyPublishers.ofString(jsonBody, StandardCharsets.UTF_8));
return client.send(b.build(), HttpResponse.BodyHandlers.ofString());
}var r = call(client, "PUT", base + "/members/1", "{\"name\":\"홍길동\",\"email\":\"hong2@test.com\",\"age\":31,\"version\":1}");
// 출력: 200 {"id":"1","name":"홍길동","email":"[email protected]","age":31,"version":2, ...}Content-Type 은 본문이 있을 때만 붙입니다. GET/DELETE 처럼 본문이 없는 요청에 억지로 빈 JSON 을 붙이면 서버가 "본문이 있는데 형식이 이상하다"고 오해할 수 있습니다.