본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
5장 : REST API 설계와 구현

Record DTO와 Jakarta Validation

외부 JSON과 JPA entity가 같은 class를 사용하면 DB 필드를 바꾼 때 API 계약도 함께 흔들립니다. request와 response를 각각 record로 두어 외부에서 받을 값과 노출할 값을 명시합니다. 형식·크기 오류는 Jakarta Validation으로 HTTP 경계에서 빠르게 거부합니다.


전송 계약과 도메인 모델은 변경 이유가 다르다

request DTO는 사용자의 입력 오류를 설명하기 위한 계약이고, domain 규칙은 HTTP 밖에서 호출되어도 반드시 지켜야 할 불변식입니다. 따라서 @Min 하나를 붙였다고 entity의 검증을 지우지 않습니다. response DTO는 lazy association이나 내부 컬럼이 우연히 직렬화되는 것을 막는 화이트리스트입니다.


생성 요청과 응답 record를 정의한다

record는 불변 전송 값을 짧게 표현하고 Jackson이 component 이름으로 JSON을 binding할 수 있게 합니다. @NotBlank는 null·빈 문자열·공백만 있는 값을 모두 막고, @PastOrPresent는 아직 오지 않은 학습 기록을 방지합니다. Controller 매개변수의 @Valid가 없으면 이 annotation들은 실행되지 않는다는 점을 기억합니다.

src/main/java/com/andongmin/studylog/session/CreateStudySessionRequest.java
package com.andongmin.studylog.session;

import java.time.LocalDate;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PastOrPresent;
import jakarta.validation.constraints.Size;

public record CreateStudySessionRequest(
        @NotBlank @Size(max = 80) String subject,
        @NotBlank @Size(max = 120) String topic,
        @NotNull @PastOrPresent LocalDate studyDate,
        @Min(1) @Max(1440) int minutes) {
}
src/main/java/com/andongmin/studylog/session/StudySessionResponse.java
package com.andongmin.studylog.session;

import java.time.LocalDate;
import java.util.UUID;

public record StudySessionResponse(
        UUID id,
        String subject,
        String topic,
        LocalDate studyDate,
        int minutes,
        SessionStatus status,
        long version) {
    public static StudySessionResponse from(StudySessionEntity entity) {
        return new StudySessionResponse(entity.getId(), entity.getSubject(),
                entity.getTopic(), entity.getStudyDate(), entity.getMinutes(),
                entity.getStatus(), entity.getVersion());
    }
}

유효한 JSON과 잘못된 JSON을 비교한다

서버를 terminal A에 두고 terminal B에서 빈 subject와 0분을 보냅니다. 요청이 service에 도달하기 전에 400이 나와야 하며, write token이 없으면 validation보다 먼저 401이 나오는 것이 맞습니다. 보안 필터가 Controller argument resolution보다 앞에 있기 때문입니다.

terminal A — local 서버
export LOCAL_JWT_SECRET="study-log-local-development-key-2026"
SPRING_PROFILES_ACTIVE=local ./mvnw -q spring-boot:run
terminal B — 잘못된 입력
export LOCAL_JWT_SECRET="study-log-local-development-key-2026"
WRITE_TOKEN="$(java dev/LocalToken.java "$LOCAL_JWT_SECRET" "study.write")"
curl -i -X POST http://localhost:8080/api/study-sessions \
  -H "Authorization: Bearer $WRITE_TOKEN" \
  -H "Content-Type: application/json" \
  -d "{\"subject\":\"\",\"topic\":\"REST\",\"studyDate\":\"2026-07-13\",\"minutes\":0}"
Response DTO 확인 결과
빈 subject와 0분 입력은 Controller 진입 전에 400으로 거절되어야 합니다.

검증은 HTTP와 도메인에 서로 다른 이유로 남긴다

DTO 검증은 어느 필드가 잘못됐는지 client에게 설명하고, domain 검증은 batch·GraphQL·message consumer 같은 다른 진입점에서도 같은 상태를 지킵니다. 두 검증에 비슷한 조건이 보여도 중복 코드라고 단정하지 않습니다. 없어야 하는 이유가 다릅니다.


이제 잘못된 request는 400으로 끝나지만 body 모양은 예외 종류마다 다를 수 있습니다. 조회 실패와 domain 충돌까지 하나의 ProblemDetail 계약으로 모으겠습니다.