// ❌ GET 요청에 JSON 본문으로 검색 조건을 실어 보낸다
HttpRequest.newBuilder(uri).method("GET", BodyPublishers.ofString(filterJson)).build();HTTP 표준(RFC 9110)은 GET 본문의 의미를 정의하지 않습니다. 프록시·CDN·일부 HTTP 라이브러리가 GET 본문을 통째로 버려서, 서버는 필터를 못 받고 전체 목록을 돌려줍니다.
// ✅ 조건은 쿼리 파라미터로
HttpRequest.newBuilder(URI.create(base + "/members?name=홍&page=0&size=10")).GET().build();// ❌ 상태 코드는 항상 200, 성공/실패를 본문의 "success" 필드로만 구분
Router.writeJson(ex, 200, Map.of("success", false, "message", "이메일 형식 오류"));이러면 axios/fetch 는 이 요청을 "성공"으로 처리해 .catch() 가 안 걸립니다. 매 호출마다 if (!body.success) 를 손으로 검사해야 하고, 캐시·재시도 미들웨어(상태 코드 기준으로 동작)가 전부 무력화됩니다.
// ✅ 상태 코드로 성공/실패를 표현하고, 본문은 상세 정보만 담당
ApiError.send(ex, 400, "VALIDATION_ERROR", "이메일 형식 오류", errors);// ❌ NullPointerException, SQLException 메시지를 그대로 응답에
} catch (Exception e) {
ApiError.send(ex, 500, "ERROR", e.getMessage(), null); // "Duplicate entry '[email protected]' for key ..." 노출
}DB 스키마, 테이블명, 내부 클래스 경로가 그대로 새 나가 공격자에게 정보를 줍니다. 이 레슨의 Router.dispatch 는 서버 콘솔에만 상세를 찍고 클라이언트에는 고정 문구만 보냅니다.
// ✅ 상세는 서버 로그, 클라이언트에는 코드 + traceId 만
System.err.println(" [UNHANDLED] " + method + " " + path + " -> " + e);
ApiError.send(exchange, 500, "INTERNAL_ERROR", "서버 내부 오류가 발생했습니다", null);// ❌ id 를 Long 그대로 JSON 숫자로 직렬화
m.put("id", id); // 9007199254740993L → JS 에서 9007199254740992 로 반올림돼 다른 행을 가리킬 수 있다JS 의 Number 는 IEEE754 배정도 부동소수점이라 정수 정밀도가 2^53(약 9천조)까지입니다. 그 이상은 반올림되고, 회원가입 트래픽이 많은 서비스는 시퀀스 값이 실제로 이 근처까지 갑니다.
// ✅ 문자열로 내보낸다(이 레슨의 Member.toJson())
m.put("id", String.valueOf(id));// ❌ LocalDateTime.now().toString() → "2026-09-09T18:00:00" (시간대 정보 없음)
m.put("createdAt", LocalDateTime.now().toString());서버가 한국(UTC+9)이고 프런트가 브라우저 로컬(UTC)로 파싱하면 9시간이 어긋납니다. 여러 서버·타임존이 섞인 시스템에서는 재현이 어려운 버그가 됩니다.
// ✅ Instant(항상 UTC) + ISO-8601, 표시는 프런트에서 로컬로 변환
m.put("createdAt", Instant.now().toString()); // "2026-09-09T09:00:00.123456Z"// ❌ Content-Type 을 안 보고 바로 JSON 으로 파싱 시도
Map<String, Object> body = MiniJson.read(bodyAsString(ex)); // form-urlencoded 가 오면 파싱 예외 → 500폼 전송(application/x-www-form-urlencoded)이나 빈 본문이 오면 JSON 파서가 예외를 던지고, 그게 잡히지 않으면 400 이어야 할 상황이 500 으로 나갑니다.
// ✅ 먼저 Content-Type 을 확인해 415 로 명확히 응답
if (ct == null || !ct.toLowerCase(Locale.ROOT).contains("application/json"))
throw new Router.ApiException(415, "UNSUPPORTED_MEDIA_TYPE", "Content-Type: application/json 이 필요합니다");// ❌ Content-Type 만 보고 바로 리턴, 본문 스트림을 안 읽고 안 닫음
if (!"application/json".equals(ct)) { ApiError.send(ex, 415, ...); return; } // getRequestBody() 를 아예 안 건드림JDK HttpServer(및 대부분의 커넥션 재사용 서버)는 다음 요청을 같은 커넥션에서 처리하려면 이전 요청의 본문을 끝까지 읽어야 합니다. 안 읽으면 커넥션이 재사용되지 못하거나 다음 요청이 밀려 대기합니다.
// ✅ 분기와 무관하게 항상 끝까지 읽고 닫는다(이 레슨의 bodyAsString)
try (InputStream is = ex.getRequestBody()) { return new String(is.readAllBytes(), StandardCharsets.UTF_8); }