ProblemDetail과 예외 변환
client가 오류를 처리하려면 status만으로는 부족합니다. 없는 자원, 잘못된 필드, 허용되지 않는 상태 전이를 서로 다른 원인으로 보존하되 RFC 9457 application/problem+json 모양으로 통일합니다. 이렇게 하면 HTTP 예외이 domain code 안쪽으로 스며들지 않습니다.
예외 타입은 실패 원인을 보존해야 한다
예외 타입은 오류가 발생한 기술 위치보다 실패 원인을 말해야 합니다. SessionNotFoundException은 JPA를 알지 못하고, ControllerAdvice는 이 원인을 404와 안정된 type URI로 번역합니다. type URI는 사람에게 설명 문서를, 프로그램에게 오류 분류를 제공하므로 문구보다 안정적으로 유지합니다.
ControllerAdvice에서 오류 계약을 만든다
@RestControllerAdvice는 Controller 예외를 가로채 response로 바꾸는 지점입니다. validation 실패에서는 필드별 메시지만 별도 errors에 담고, 서버 내부 class 이름과 stack trace는 노출하지 않습니다. ProblemDetail factory로 status를 만든 후 title·detail·type을 명시해 예외별 응답 정책을 한눈에 보이게 합니다.
package com.andongmin.studylog.web;
import java.net.URI;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import com.andongmin.studylog.session.SessionNotFoundException;
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(SessionNotFoundException.class)
ProblemDetail notFound(SessionNotFoundException failure) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND, failure.getMessage());
problem.setTitle("Study session not found");
problem.setType(URI.create("https://andongmin.com/problems/session-not-found"));
return problem;
}
@ExceptionHandler(MethodArgumentNotValidException.class)
ProblemDetail invalid(MethodArgumentNotValidException failure) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST, "Request validation failed");
problem.setTitle("Invalid study session request");
problem.setType(URI.create("https://andongmin.com/problems/invalid-request"));
List<FieldError> errors = failure.getBindingResult().getFieldErrors().stream()
.map(error -> new FieldError(error.getField(), error.getDefaultMessage()))
.toList();
problem.setProperty("errors", errors);
return problem;
}
record FieldError(String field, String message) {
}
}필드 오류와 미조회 오류의 body를 검증한다
같은 terminal B에서 없는 UUID와 잘못된 request body를 각각 보냅니다. 첫 응답은 404와 session-not-found, 두 번째는 400과 필드별 errors를 가져야 합니다. Content-Type 역시 application/problem+json인지 확인합니다. status만 보면 body 계약이 깨져도 놓칠 수 있습니다.
export LOCAL_JWT_SECRET="study-log-local-development-key-2026"
SPRING_PROFILES_ACTIVE=local ./mvnw -q spring-boot:runexport LOCAL_JWT_SECRET="study-log-local-development-key-2026"
READ_TOKEN="$(java dev/LocalToken.java "$LOCAL_JWT_SECRET" "study.read")"
curl -i -H "Authorization: Bearer $READ_TOKEN" \
http://localhost:8080/api/study-sessions/00000000-0000-0000-0000-000000000001미조회 응답은 404와 application/problem+json을 사용하고 type, title, detail을 포함해야 합니다.내부 예외 메시지를 그대로 노출하지 않는다
예외의 getMessage()를 detail에 그대로 넣으면 SQL, 파일 경로, 내부 ID 같은 정보가 누출될 수 있습니다. 로그에는 원인과 correlation ID를 남기고, client에는 조치에 필요한 안정된 문구만 제공합니다. 예상하지 못한 예외를 모두 200이나 세부 메시지로 숨기기보다 500으로 유지하고 서버 로그에서 추적합니다.
단건 조회와 생성 오류가 안정됐습니다. 마지막으로 목록이 커질 때의 페이징, 동시 수정의 사전조건, 계약 진화를 하나의 REST API에 겹쳐 올립니다.