API 예외 응답
Spring MVC exception resolver와 controller advice의 우선순위를 고정하고, domain 실패·표준 MVC 오류·알 수 없는 실패를 서로 다른 RFC 9457 ProblemDetail 또는 HTML view로 검증합니다.
컨트롤러 또는 서비스 예외가 servlet 컨테이너까지 올라가기 전에 Spring MVC의 HandlerExceptionResolver 목록이 먼저 처리 기회를 갖습니다. 처리한 resolver가 상태·헤더·본문 또는 view를 완성하면 최초 REQUEST 안에서 응답이 끝나고 ERROR 재디스패치는 일어나지 않습니다.
도메인 예외는 좁은 handler가 HTTP 의미로 바꾸고, Spring MVC 자체 예외는 프레임워크 의미를 유지하며, 그 밖의 실패만 마지막 500 경계가 맡습니다.
FLOWCHART · FIRST MATCH WINS
예외는 첫 번째로 의미를 아는 resolver에서 멈춘다
좁은 domain advice, Spring MVC 표준 handler, 가장 낮은 우선순위의 500 catch-all을 차례로 평가한다. 처리 완료된 가지는 servlet ERROR fallback까지 내려가지 않는다.
-
DECISION 1 · DOMAIN TYPE
PostNotFoundException 또는 DuplicatePostException인가?
맞으면 scoped
@RestControllerAdvice가 404 또는 409 ProblemDetail을 만들고 평가를 끝낸다. -
DECISION 2 · MVC STANDARD
type mismatch·media type·body 읽기 실패인가?
맞으면
ResponseEntityExceptionHandler의 구체 handler가 400·415 같은 프레임워크 의미를 보존한다. -
DECISION 3 · UNKNOWN EXCEPTION
handler 안의 나머지 Exception인가?
LOWEST_PRECEDENCEcatch-all이 일반 문구의 500과 Filter가 게시한 request ID를 반환한다. -
FALLBACK · SERVLET ERROR
MVC가 끝까지 처리하지 못했는가?
그때만 container ERROR path가 마지막 안전망으로 실행된다. commit 이후 실패는 별도 stream protocol이 맡는다.
REST CONTROLLER ADVICE
ProblemDetail
안정적인 type·status·code와
선택적 requestId를 반환한다.
CONTROLLER ADVICE
HTML error view
같은 404 의미를 유지하면서 목록 이동을 제공하는
posts/not-found view를 선택한다.
999 → 404 · duplicate 41 → 409 · explode + req-api-77 → 500 · numeric/not-a-number → framework 400
- 표현이 달라도 HTTP 의미 유지
- 끝까지 미처리된 fallback
advice order는 domain과 framework 의미를 잃지 않기 위한 계약이다. catch-all은 편의를 위한 첫 handler가 아니라 마지막 관찰 가능한 경계다.
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, 세 version 필드를 확인합니다. SQL, entity 전체, 예외 메시지와 stack trace가 JSON에 없는지도 검증합니다.
다음 문서에서는 요청 문자열과 domain 값 객체 사이를 연결하는 Converter와 locale-aware Formatter의 선택 기준을 다룹니다.