API 예외 응답
Spring MVC exception resolver와 controller advice의 우선순위를 고정하고, domain 실패·표준 MVC 오류·알 수 없는 실패를 서로 다른 RFC 9457 ProblemDetail 또는 HTML view로 검증합니다.
컨트롤러 또는 서비스 예외가 servlet 컨테이너까지 올라가기 전에 Spring MVC의 HandlerExceptionResolver 목록이 먼저 처리 기회를 갖습니다. 처리한 resolver가 상태·헤더·본문 또는 view를 완성하면 최초 REQUEST 안에서 응답이 끝나고 ERROR 재디스패치는 일어나지 않습니다.
이 문서의 advice 예시는 응답 본문을 완성하는 경로입니다. 모든 resolver의 “예외 처리됨”이 컨테이너 ERROR 전이 부재를 뜻하는 것은 아닙니다. ResponseStatusExceptionResolver의 기본 상태 적용은 sendError()를 사용하므로, 해당 경로의 컨테이너 오류 매핑은 별도로 확인해야 합니다. 아래 네 MockMvc 결과가 REQUEST라는 관찰을 모든 resolver·실제 컨테이너의 결과로 확대하지 않습니다.
도메인 예외는 좁은 handler가 HTTP 의미로 바꾸고, Spring MVC 자체 예외는 프레임워크 의미를 유지하며, 그 밖의 실패만 마지막 500 경계가 맡습니다.
CONTRACT / IMPLEMENTATION / REVIEW SCOPE
domain·MVC 표준·나머지 예외의 실제 응답을 비교한다
좁은 domain advice가 404·409를 먼저 소유합니다. 낮은 우선순위 advice의 상속 handler가 MVC 타입 오류를 400으로 보존하고, 나머지 예외만 일반 500으로 만듭니다.
SCOPED DOMAIN / REQUEST
999 부재 404 · 중복 41은 409
높은 우선순위 RequestExceptionAdvice는 해당 REST controller의 두 domain 예외만 처리합니다. required(999)와 createDuplicate(41)는 각각 실제 query에 도달하고, 외부 code는 post-not-found와 post-conflict입니다.
MVC STANDARD / BEFORE HANDLER
숫자 변환 실패는 framework 400으로 남는다
numeric/not-a-number는 MethodArgumentTypeMismatchException입니다. 상속된 ResponseEntityExceptionHandler가 400 ProblemDetail로 처리하며 query 호출은 0회입니다. 이 실제 응답에는 애플리케이션 code 속성이 없고 internal-error 500으로 덮이지 않습니다.
LOWEST CATCH-ALL / HANDLER
explode는 일반 500과 이미 게시된 ID
handler가 던진 내부 원문은 공개하지 않습니다. 응답은 internal-error, 일반 detail과 requestId=req-api-77입니다. ID를 advice가 새로 만들지 않으며 없으면 문자열 null을 넣지 않습니다.
SCOPED HTML ADVICE / VIEW CONTRACT
같은 부재 404를 posts/not-found로 표현
HTML controller 전용 advice가 ModelAndView와 postId=999를 만듭니다. standalone test는 handler·status404·view 이름·model을 확인합니다. 실제 HTML template/목록 링크/Thymeleaf 렌더까지 이 fixture에 제공되어 있지는 않습니다.
ADVICE ORDER / STOP WHEN HANDLED
네 실제 MVC 결과는 모두 REQUEST
domain 먼저, lowest advice 안에서는 상속된 구체 표준 handler가 catch-all보다 해당 예외를 구체적으로 처리합니다. 처리된 네 test는 ERROR handler를 선택하지 않습니다. 이를 모든 resolver가 문서의 세 advice 순서로 구성된다는 일반 규칙으로 확대하지 않습니다.
ADDITIONAL ERROR / VERSION CONTRACT
미처리 fallback과 version 충돌은 별도 검사
다른 범위의 미처리 예외·filter 실패는 servlet 오류 경계를 따르고 commit 뒤 stream은 별도 protocol이 필요합니다. 연습 PostVersionConflictException의 세 ID/version 값은 구현되지만 실제 repository 및 409 advice 연결·회귀 검사는 추가 범위입니다.
실제 scoped domain advice는 게시글 999 부재를 404 post-not-found, 중복 41을 409 post-conflict로 만듭니다. numeric not-a-number는 상속된 표준 MVC handler의 400이며 controller/query에 들어가지 않습니다. explode는 handler에서 발생하여 lowest catch-all의 일반 500 internal-error와 req-api-77로 끝납니다. 네 MVC 결과의 dispatcher는 REQUEST입니다. HTML standalone 검사는 posts/not-found view 이름과 model 및 status를 확인하며 template 렌더 검사는 아닙니다.
resolver 순서는 가장 구체적인 의미를 먼저 찾는다
기본 MVC 구성에는 @ExceptionHandler를 찾는 resolver, @ResponseStatus와 ResponseStatusException을 해석하는 resolver, Spring MVC 표준 예외를 상태로 바꾸는 resolver가 있습니다.
Controller advice의 순서도 중요합니다.
- 특정 domain 예외를 아는 advice가 먼저 404·409 계약을 만듭니다.
ResponseEntityExceptionHandler가 method argument, media type, body 읽기 같은 표준 MVC 오류의 상태를 보존합니다.- 남은
Exception만 가장 낮은 우선순위의 500 handler가 정규화합니다. - 끝까지 처리되지 않은 예외만 servlet
ERROR경계로 나갑니다.
catch-all을 높은 우선순위에 두면 더 구체적인 advice뿐 아니라 400·404·415 같은 프레임워크 의미까지 500으로 덮을 수 있습니다.
domain 예외와 HTTP adapter를 분리한다
두 예외는 모두 runtime 실패지만 클라이언트의 다음 행동이 다릅니다. 404는 목록이나 URI를 다시 확인하고, 409는 현재 상태를 재조회한 뒤 명령을 조정합니다.
package board.web.problem;
public final class PostNotFoundException
extends RuntimeException {
private final long postId;
public PostNotFoundException(long postId) {
super("post was not found");
if (postId <= 0) {
throw new IllegalArgumentException(
"postId must be positive");
}
this.postId = postId;
}
public long postId() {
return postId;
}
}package board.web.problem;
public final class DuplicatePostException
extends RuntimeException {
private final long postId;
public DuplicatePostException(long postId) {
super("post conflicts with current state");
if (postId <= 0) {
throw new IllegalArgumentException(
"postId must be positive");
}
this.postId = postId;
}
public long postId() {
return postId;
}
}샘플 query port와 controller는 advice 테스트가 실제 handler 호출에서 시작되게 합니다. test-only controller나 resolver를 만들지 않습니다.
package board.web.problem;
public interface ProblemPostQuery {
PostSummary required(long postId);
void createDuplicate(long postId);
record PostSummary(long id, String title) {
}
}package board.web.problem;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import board.web.problem.ProblemPostQuery.PostSummary;
@RestController
@RequestMapping("/api/ch8/problem-posts")
public final class ProblemPostController {
private final ProblemPostQuery query;
public ProblemPostController(ProblemPostQuery query) {
this.query = query;
}
@GetMapping("/{postId}")
PostSummary one(@PathVariable long postId) {
return query.required(postId);
}
@PostMapping("/{postId}/duplicate")
ResponseEntity<Void> duplicate(
@PathVariable long postId
) {
query.createDuplicate(postId);
return ResponseEntity.noContent().build();
}
@GetMapping("/explode")
String explode() {
throw new IllegalStateException(
"database password must never leave the server");
}
@GetMapping("/numeric/{postId}")
PostSummary numeric(@PathVariable long postId) {
return query.required(postId);
}
}구체적인 REST advice가 404와 409를 소유한다
@RestControllerAdvice는 @ControllerAdvice에 response body 의미를 더합니다. 대상 controller를 assignableTypes로 좁혀 다른 장의 API와 경쟁하지 않고, 두 domain 예외만 처리합니다.
package board.web.problem;
import java.net.URI;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice(assignableTypes = ProblemPostController.class)
@Order(Ordered.HIGHEST_PRECEDENCE + 100)
public final class RequestExceptionAdvice {
@ExceptionHandler(PostNotFoundException.class)
ProblemDetail postNotFound(
PostNotFoundException exception
) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND,
"요청한 게시글을 찾을 수 없습니다.");
problem.setType(URI.create(
"https://board.example/problems/post-not-found"));
problem.setTitle("게시글 없음");
problem.setProperty("code", "post-not-found");
problem.setProperty("postId", exception.postId());
return problem;
}
@ExceptionHandler(DuplicatePostException.class)
ProblemDetail duplicate(
DuplicatePostException exception
) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.CONFLICT,
"현재 게시글 상태와 요청이 충돌합니다.");
problem.setType(URI.create(
"https://board.example/problems/post-conflict"));
problem.setTitle("게시글 충돌");
problem.setProperty("code", "post-conflict");
problem.setProperty("postId", exception.postId());
return problem;
}
}응답의 stack trace, SQL 제약 이름, 내부 예외 메시지는 공개하지 않습니다. status 본문과 실제 HTTP status는 ProblemDetail이 가진 같은 값을 사용합니다.
ProblemDetail은 사람용 문구와 기계 계약을 나눈다
| 필드 | 안정성 | 클라이언트 사용 |
|---|---|---|
type | 버전 관리되는 문제 종류 URI | 문서와 큰 분기 기준 |
status | HTTP 의미 | 재인증·재시도·입력 수정 판단 |
title, detail | 로케일과 문구 변경 가능 | 사람에게 표시 |
code | 애플리케이션의 안정 계약 | 세밀한 프로그램 분기 |
requestId | 요청 추적용 상관 값 | 지원 문의와 서버 로그 연결 |
type=about:blank만 모든 오류에 사용하면 기계가 문제 종류를 구분하지 못합니다. 반대로 발생 인스턴스마다 새 type URI를 만들면 계약 카디널리티가 폭발합니다. 발생별 값은 requestId나 instance에 두고 type은 종류마다 안정적으로 유지합니다.
마지막 500 advice는 표준 MVC 예외를 보존한다
전체 예외 handler는 반드시 가장 낮은 advice 우선순위에 둡니다. ResponseEntityExceptionHandler를 상속하면 media type, body 변환, type mismatch 같은 Spring MVC 표준 오류가 더 구체적인 inherited handler로 먼저 해석됩니다.
catch-all은 앞 문서의 Filter가 게시한 request attribute를 읽습니다. 요청 ID를 만드는 Filter를 여기서 다시 정의하거나, 없는 값을 문자열 "null"로 응답하지 않습니다.
package board.web.problem;
import java.net.URI;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
import board.web.RequestTraceFilter;
@RestControllerAdvice(assignableTypes = ProblemPostController.class)
@Order(Ordered.LOWEST_PRECEDENCE)
public final class UnexpectedExceptionAdvice
extends ResponseEntityExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(
UnexpectedExceptionAdvice.class);
@ExceptionHandler(Exception.class)
ResponseEntity<ProblemDetail> unexpected(
Exception exception,
HttpServletRequest request
) {
Object candidate = request.getAttribute(
RequestTraceFilter.REQUEST_ID_ATTRIBUTE);
String requestId =
candidate instanceof String value
&& !value.isBlank()
? value
: null;
log.error(
"unexpected request failure requestId={}",
requestId, exception);
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.INTERNAL_SERVER_ERROR,
"잠시 후 다시 시도하세요.");
problem.setType(URI.create(
"https://board.example/problems/internal"));
problem.setTitle("요청 처리 실패");
problem.setProperty("code", "internal-error");
if (requestId != null) {
problem.setProperty("requestId", requestId);
}
return ResponseEntity.internalServerError().body(problem);
}
}NullPointerException을 400으로 바꾸면 서버 결함이 클라이언트 책임으로 숨습니다. 반대로 MethodArgumentTypeMismatchException을 catch-all이 500으로 바꿔도 안 됩니다. inherited 표준 handler와 domain advice가 실제 응답에서 먼저 선택되는지 통합 테스트로 고정합니다.
REST 통합 테스트는 세 resolver 가지를 모두 증명한다
첫 두 테스트는 domain advice의 404·409를, 셋째는 request ID가 연결된 500을, 넷째는 숫자 path 변환 실패가 catch-all에 잡히지 않고 400으로 남는 것을 검증합니다. handler type과 query 호출도 함께 확인해 요청이 controller에 도달했는지 구분합니다.
package board.web.problem;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.handler;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import jakarta.servlet.DispatcherType;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import board.web.RequestTraceFilter;
@WebMvcTest(ProblemPostController.class)
@Import({
RequestTraceFilter.class,
RequestExceptionAdvice.class,
UnexpectedExceptionAdvice.class
})
class RequestExceptionAdviceTest {
@Autowired
MockMvc mvc;
@MockitoBean
ProblemPostQuery query;
@Test
void domain_not_found는_specific_advice가_404로_처리한다()
throws Exception {
org.mockito.Mockito.when(query.required(999L))
.thenThrow(new PostNotFoundException(999L));
mvc.perform(get("/api/ch8/problem-posts/999"))
.andExpect(handler().handlerType(
ProblemPostController.class))
.andExpect(handler().methodName("one"))
.andExpect(status().isNotFound())
.andExpect(content().contentTypeCompatibleWith(
MediaType.APPLICATION_PROBLEM_JSON))
.andExpect(jsonPath("$.type").value(
"https://board.example/problems/post-not-found"))
.andExpect(jsonPath("$.code")
.value("post-not-found"))
.andExpect(jsonPath("$.postId").value(999))
.andExpect(result -> {
assertThat(result.getResolvedException())
.isInstanceOf(
PostNotFoundException.class);
assertThat(result.getRequest()
.getDispatcherType())
.isEqualTo(DispatcherType.REQUEST);
});
verify(query).required(999L);
}
@Test
void domain_conflict는_specific_advice가_409로_처리한다()
throws Exception {
org.mockito.Mockito.doThrow(
new DuplicatePostException(41L))
.when(query).createDuplicate(41L);
mvc.perform(post(
"/api/ch8/problem-posts/41/duplicate"))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code")
.value("post-conflict"))
.andExpect(jsonPath("$.postId").value(41))
.andExpect(result -> assertThat(result.getRequest()
.getDispatcherType())
.isEqualTo(DispatcherType.REQUEST));
verify(query).createDuplicate(41L);
}
@Test
void unknown_failure만_lowest_catch_all의_500이_된다()
throws Exception {
mvc.perform(get("/api/ch8/problem-posts/explode")
.header(
RequestTraceFilter.REQUEST_ID_HEADER,
"req-api-77"))
.andExpect(handler().handlerType(
ProblemPostController.class))
.andExpect(handler().methodName("explode"))
.andExpect(status().isInternalServerError())
.andExpect(jsonPath("$.code")
.value("internal-error"))
.andExpect(jsonPath("$.requestId")
.value("req-api-77"))
.andExpect(jsonPath("$.detail")
.value("잠시 후 다시 시도하세요."))
.andExpect(jsonPath("$.message")
.doesNotExist())
.andExpect(result -> assertThat(result.getRequest()
.getDispatcherType())
.isEqualTo(DispatcherType.REQUEST));
}
@Test
void mvc_type_mismatch는_500_catch_all보다_먼저_400이_된다()
throws Exception {
mvc.perform(get(
"/api/ch8/problem-posts/numeric/not-a-number"))
.andExpect(status().isBadRequest())
.andExpect(content().contentTypeCompatibleWith(
MediaType.APPLICATION_PROBLEM_JSON))
.andExpect(jsonPath("$.status").value(400))
.andExpect(jsonPath("$.code").doesNotExist())
.andExpect(result -> {
assertThat(result.getResolvedException())
.isInstanceOf(
MethodArgumentTypeMismatchException.class);
assertThat(result.getRequest()
.getDispatcherType())
.isEqualTo(DispatcherType.REQUEST);
});
verifyNoInteractions(query);
}
}GET /api/ch8/problem-posts/999 -> handler reached -> 404 post-not-found
POST /api/ch8/problem-posts/41/duplicate -> handler reached -> 409 post-conflict
GET /api/ch8/problem-posts/explode -> handler reached -> 500 internal-error + req-api-77
GET /api/ch8/problem-posts/numeric/not-a-number -> handler not entered -> 400 framework ProblemDetail
all four MockMvc results dispatcher type = REQUEST; ERROR handler not selectedHTML은 @ControllerAdvice가 view로 표현한다
브라우저 상세 페이지의 404는 ProblemDetail JSON보다 목록으로 돌아갈 수 있는 오류 view가 유용합니다. 같은 domain status 의미는 유지하되 controller 범위와 표현을 분리합니다.
package board.web.problem;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@Controller
public final class ProblemPostPageController {
private final ProblemPostQuery query;
public ProblemPostPageController(ProblemPostQuery query) {
this.query = query;
}
@GetMapping("/ch8/problem-posts/{postId}")
String one(
@PathVariable long postId,
Model model
) {
model.addAttribute("post", query.required(postId));
return "posts/detail";
}
}package board.web.problem;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.servlet.ModelAndView;
@ControllerAdvice(
assignableTypes = ProblemPostPageController.class)
@Order(Ordered.HIGHEST_PRECEDENCE + 100)
public final class PageExceptionAdvice {
@ExceptionHandler(PostNotFoundException.class)
ModelAndView postNotFound(
PostNotFoundException exception
) {
var result = new ModelAndView("posts/not-found");
result.setStatus(HttpStatus.NOT_FOUND);
result.addObject("postId", exception.postId());
return result;
}
}standalone MVC 테스트는 view 이름, 404 status, model을 함께 고정합니다. REST advice는 이 page controller에 적용되지 않습니다.
package board.web.problem;
import static org.mockito.Mockito.mock;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.handler;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view;
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MockMvc;
class PageExceptionAdviceTest {
@Test
void controller_advice는_HTML_404_view를_선택한다()
throws Exception {
ProblemPostQuery query = mock(ProblemPostQuery.class);
org.mockito.Mockito.when(query.required(999L))
.thenThrow(new PostNotFoundException(999L));
MockMvc mvc = standaloneSetup(
new ProblemPostPageController(query))
.setControllerAdvice(
new PageExceptionAdvice())
.build();
mvc.perform(get("/ch8/problem-posts/999"))
.andExpect(handler().handlerType(
ProblemPostPageController.class))
.andExpect(status().isNotFound())
.andExpect(view().name("posts/not-found"))
.andExpect(model().attribute("postId", 999L));
}
}오류 경계의 소유자를 명시한다
| 실패 위치 | 첫 처리 경계 | 대표 표현 |
|---|---|---|
| API handler 내부 domain 실패 | 좁은 @RestControllerAdvice | 404·409 ProblemDetail |
| HTML handler 내부 domain 실패 | 좁은 @ControllerAdvice | 404 오류 view |
| MVC argument·media type 실패 | ResponseEntityExceptionHandler | 프레임워크 400·415 ProblemDetail |
| 알 수 없는 handler 실패 | lowest-precedence catch-all | 일반 문구의 500 + request ID |
| Filter·미매핑·미처리 예외 | servlet error path | 안전한 fallback |
| response commit 뒤 실패 | stream protocol | 종료·cursor·사전 생성 |
예외 handler에서 재시도, DB 쓰기, 이메일 전송 같은 부수 효과를 시작하지 않습니다. resolver는 이미 실패한 요청의 표현만 책임지고, 오류 통계와 alert는 관찰 가능성 계층에서 수집합니다.
연습 문제
게시글 수정 API에 낙관적 잠금 충돌을 추가하세요. repository의 version 불일치를 post-version-conflict 409 ProblemDetail로 바꾸고 현재 version과 클라이언트가 보낸 version을 확장 필드에 넣으세요.
해설 보기
application 계층은 영속성 예외를 domain 의미가 있는 예외로 바꾸고, web advice는 그 예외만 압니다.
package board.application.versioning;
public final class PostVersionConflictException
extends RuntimeException {
private final long postId;
private final long expectedVersion;
private final long actualVersion;
public PostVersionConflictException(
long postId,
long expectedVersion,
long actualVersion
) {
super("post version conflict");
this.postId = positive(postId, "postId");
this.expectedVersion = positive(
expectedVersion, "expectedVersion");
this.actualVersion = positive(
actualVersion, "actualVersion");
}
public long postId() {
return postId;
}
public long expectedVersion() {
return expectedVersion;
}
public long actualVersion() {
return actualVersion;
}
private static long positive(long value, String name) {
if (value <= 0) {
throw new IllegalArgumentException(
name + " must be positive");
}
return value;
}
}테스트는 HTTP 409, 안정적인 type과 code, 게시글 ID와 두 version 필드를 확인합니다. SQL, entity 전체, 예외 메시지와 stack trace가 JSON에 없는지도 검증합니다.
독립 확인
원 controller와 두 REST advice를 그대로 두고 각 경우를 별도 mock fixture에서 확인합니다. A는 /numeric/not-a-number입니다. B는 /numeric/0이고 이 fixture의 query.required(0)는 양수 ID 요구 위반으로 IllegalArgumentException을 던지게 설정합니다. C는 /numeric/99이고 query가 PostNotFoundException(99)를 던집니다. 각 요청의 query 호출 수, HTTP 상태와 애플리케이션 code 유무를 예측하세요.
변환 실패와 입력 의미를 구분하는 풀이
A는 long 변환 전에 실패해 query 0회, 프레임워크 400 ProblemDetail이며 애플리케이션 code는 없습니다. B의 문자열 0은 long으로 변환할 수 있고 원 메서드에 양수 경로 검증이 없으므로 query가 1회 호출됩니다. 지정한 IllegalArgumentException을 domain advice가 처리하지 않아 마지막 catch-all의 500과 internal-error입니다. 숫자로 읽을 수 있다는 사실은 업무상 유효 ID라는 뜻이 아닙니다. 클라이언트 양수 조건을 400으로 표현하려면 별도 입력 검증·매핑을 설계해야 하며 원 코드는 그 계약을 자동 제공하지 않습니다.
C는 query 1회 뒤 구체 domain advice의 404와 post-not-found입니다. 세 예외를 모두 “입력 오류”라는 하나의 이름으로 합치면 실패 위치와 실제 코드가 달라진다는 점을 놓칩니다. 서로 다른 두 요청에 같은 허용 X-Request-Id를 보내면 앞 추적 필터는 그 값을 각각 수용할 수 있으므로 상관 ID가 전역 유일성이나 사용자 신원을 보장한다고 판단하지도 않습니다.
다음 문서에서는 요청 문자열과 domain 값 객체 사이를 연결하는 Converter와 locale-aware Formatter의 선택 기준을 다룹니다.