API 예외 응답
Spring MVC exception resolver와 controller advice의 우선순위를 고정하고, domain 실패·표준 MVC 오류·알 수 없는 실패를 서로 다른 RFC 9457 ProblemDetail 또는 HTML view로 검증합니다.
컨트롤러나 서비스의 예외가 서블릿 컨테이너까지 올라가기 전에, Spring MVC의 HandlerExceptionResolver 목록이 먼저 처리할 기회를 갖습니다. 처리한 리졸버가 상태와 헤더, 본문이나 뷰를 완성하면 응답은 처음의 REQUEST 안에서 끝나고 ERROR 재디스패치는 일어나지 않습니다.
이 글의 어드바이스 예제는 응답 본문까지 완성하는 경로입니다. 리졸버가 예외를 처리했다는 것이 언제나 컨테이너의 ERROR 전환이 없다는 뜻은 아닙니다. ResponseStatusExceptionResolver는 기본적으로 sendError()로 상태를 적용하므로, 그 경로의 컨테이너 오류 매핑은 따로 확인해야 합니다. 아래 네 MockMvc 결과가 REQUEST였다는 사실을 모든 리졸버와 실제 컨테이너의 결과로 넓혀 보지 않습니다. 도메인 예외는 좁은 범위의 핸들러가 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 렌더 검사는 아닙니다.
그림은 서로 다른 요청의 세 가지 대안을 나란히 보여 줍니다. 범위를 좁힌 도메인 어드바이스는 핸들러와 조회에 도달한 뒤 404와 409를 외부 코드와 함께 만들고, 상속받은 MVC 처리기는 숫자 변환 실패를 핸들러에 들어가기 전에 400으로 끝내며, 가장 낮은 우선순위의 처리기는 남은 핸들러 예외를 일반적인 500과 요청 ID로 바꿉니다. 도메인의 404가 다시 400이나 500으로 바뀌는 순서가 아니며, 네 MVC 결과의 디스패처는 모두 REQUEST입니다. HTML 뷰와 sendError, 서블릿의 대체 경로는 본문에서 따로 다룹니다.
리졸버의 순서는 가장 구체적인 의미를 먼저 찾습니다
기본 MVC 구성에는 @ExceptionHandler를 찾는 리졸버, @ResponseStatus와 ResponseStatusException을 해석하는 리졸버, Spring MVC의 표준 예외를 상태로 바꾸는 리졸버가 있습니다. 컨트롤러 어드바이스의 순서도 중요합니다.
- 특정 도메인 예외를 아는 어드바이스가 먼저 404와 409를 만듭니다.
ResponseEntityExceptionHandler가 메서드 인자와 미디어 타입, 본문 읽기 같은 표준 MVC 오류의 상태를 그대로 둡니다.- 남은
Exception만 가장 낮은 우선순위의 500 처리기가 정규화합니다. - 끝까지 처리되지 않은 예외만 서블릿의
ERROR경로로 나갑니다.
모든 예외를 받는 처리기를 높은 우선순위에 두면, 더 구체적인 어드바이스뿐 아니라 400과 404, 415 같은 프레임워크의 의미까지 500으로 덮을 수 있습니다.
도메인 예외와 HTTP 어댑터를 나눕니다
두 예외는 모두 런타임 실패지만, 클라이언트가 다음에 할 일이 다릅니다. 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;
}
}예제의 조회 포트와 컨트롤러는 어드바이스 테스트가 실제 핸들러 호출에서 시작하게 합니다. 테스트 전용 컨트롤러나 리졸버는 만들지 않습니다.
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 어드바이스가 404와 409를 맡습니다
@RestControllerAdvice는 @ControllerAdvice에 응답 본문의 의미를 더한 것입니다. 대상 컨트롤러를 assignableTypes로 좁혀 다른 장의 API와 겹치지 않게 하고, 두 도메인 예외만 처리합니다.
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;
}
}응답에는 스택 트레이스나 SQL 제약 이름, 내부 예외 메시지를 공개하지 않습니다. 본문의 status와 실제 HTTP 상태는 ProblemDetail이 가진 같은 값을 씁니다.
ProblemDetail은 사람이 읽는 문구와 기계용 값을 나눕니다
| 필드 | 얼마나 안정적인가 | 클라이언트의 쓰임 |
|---|---|---|
type | 버전 관리되는 문제 종류의 URI | 문서와 큰 분기 기준 |
status | HTTP의 의미 | 재인증, 재시도, 입력 수정의 판단 |
title, detail | 로케일과 문구가 바뀔 수 있음 | 사람에게 표시 |
code | 애플리케이션이 정한 바뀌지 않는 값 | 세밀한 프로그램 분기 |
requestId | 요청 추적용 상관 값 | 문의와 서버 로그의 연결 |
모든 오류에 type=about:blank만 쓰면 기계가 문제의 종류를 구분하지 못합니다. 반대로 오류가 날 때마다 새 type URI를 만들면 종류의 수가 끝없이 늘어납니다. 발생마다 다른 값은 requestId나 instance에 두고, type은 종류마다 안정적으로 유지합니다.
마지막 500 어드바이스는 표준 MVC 예외를 그대로 둡니다
모든 예외를 받는 처리기는 반드시 가장 낮은 어드바이스 우선순위에 둡니다. ResponseEntityExceptionHandler를 상속하면 미디어 타입과 본문 변환, 타입 불일치 같은 Spring MVC의 표준 오류가, 상속받은 더 구체적인 핸들러에서 먼저 해석됩니다.
이 처리기는 서블릿 필터의 RequestTraceFilter가 게시한 요청 속성을 읽습니다. 요청 ID를 만드는 필터를 여기서 다시 정의하거나, 값이 없을 때 문자열 "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을 모든 예외를 받는 처리기가 500으로 바꿔서도 안 됩니다. 상속받은 표준 핸들러와 도메인 어드바이스가 실제 응답에서 먼저 선택되는지 통합 테스트로 고정합니다.
더 깊이: 어드바이스가 여럿일 때 예외는 어떻게 짝지어질까
@ExceptionHandler는 던져진 예외 자체뿐 아니라 그 안에 감싸인 원인(cause)과도 짝지어질 수 있고, Spring Framework 5.3부터는 원인 연쇄의 깊이에 상관없이 찾습니다. 한 클래스 안에서 여러 메서드가 맞으면 원인보다 던져진 예외 자체와의 일치를 먼저 고르고, ExceptionDepthComparator로 던져진 타입에서 가까운 선언을 고릅니다. 그런데 Spring 레퍼런스는 이 우선순위가 컨트롤러나 어드바이스 클래스 하나 안에서만 정해진다고 설명합니다. 그래서 우선순위가 높은 어드바이스에서 원인으로 맞은 메서드가, 우선순위가 낮은 어드바이스에서 던져진 예외 자체로 맞은 메서드보다 먼저 선택됩니다.
이 규칙은 이 글의 순서 설계와 같은 방향으로 작동합니다. PostNotFoundException이 다른 런타임 예외에 감싸여 올라와도, HIGHEST_PRECEDENCE + 100의 도메인 어드바이스가 원인을 보고 404를 만들 수 있습니다. 반대로 Exception을 받는 처리기를 높은 우선순위에 두면, 그 처리기가 바깥 예외 자체와 맞아 먼저 선택되므로 도메인 어드바이스는 기회를 얻지 못합니다. 원인으로 맞았을 때 메서드에 어떤 예외 객체가 넘어오는지는 선언한 파라미터 타입에 따라 달라지므로, 레퍼런스는 시그니처에 가능한 한 구체적인 예외 타입을 적으라고 권합니다.
Spring Framework 6.2부터는 @ExceptionHandler에 produces를 적어 응답 미디어 타입도 짝짓기 조건에 넣을 수 있습니다. 같은 예외 타입을 처리하는 메서드 두 개에 각각 application/json과 text/html을 적으면 중복 선언으로 거부되지 않고, 요청의 Accept 헤더에 따라 하나가 선택됩니다. 이 글처럼 컨트롤러 범위로 API와 HTML 어드바이스를 나누는 대신, 같은 컨트롤러가 두 표현을 모두 내야 할 때 쓸 수 있는 방법입니다.
REST 통합 테스트는 세 갈래를 모두 확인합니다
첫 두 테스트는 도메인 어드바이스의 404와 409를, 셋째는 요청 ID가 연결된 500을, 넷째는 숫자 경로의 변환 실패가 모든 예외를 받는 처리기에 잡히지 않고 400으로 남는지를 확인합니다. 핸들러 타입과 조회 호출도 함께 확인해, 요청이 컨트롤러에 도달했는지를 구분합니다.
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가 뷰로 나타냅니다
브라우저의 상세 페이지에서 404가 나면, ProblemDetail JSON보다 목록으로 돌아갈 수 있는 오류 뷰가 쓸모 있습니다. 도메인 상태의 의미는 같게 유지하되 컨트롤러의 범위와 표현을 나눕니다.
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 테스트는 뷰 이름과 404 상태, 모델을 함께 고정합니다. REST 어드바이스는 이 페이지 컨트롤러에 적용되지 않습니다.
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 핸들러 안의 도메인 실패 | 범위를 좁힌 @RestControllerAdvice | 404와 409 ProblemDetail |
| HTML 핸들러 안의 도메인 실패 | 범위를 좁힌 @ControllerAdvice | 404 오류 뷰 |
| MVC 인자와 미디어 타입 실패 | ResponseEntityExceptionHandler | 프레임워크의 400과 415 ProblemDetail |
| 알 수 없는 핸들러 실패 | 가장 낮은 우선순위의 처리기 | 일반 문구의 500과 요청 ID |
| 필터, 매핑 없음, 처리되지 않은 예외 | 서블릿 오류 경로 | 안전한 대체 응답 |
| 응답 커밋 뒤의 실패 | 스트림 프로토콜 | 종료, 커서, 미리 생성 |
예외 핸들러에서 재시도나 DB 쓰기, 이메일 전송 같은 부수 효과를 시작하지 않습니다. 리졸버는 이미 실패한 요청을 어떻게 표현할지만 맡고, 오류 통계와 경보는 관찰 계층에서 모읍니다.
정리
- 처리하지 못한 예외는 컨테이너로 가기 전에
HandlerExceptionResolver목록이 먼저 처리하며, 본문까지 완성하면 REQUEST 안에서 끝납니다. - 도메인 예외는 범위를 좁힌 어드바이스가 404와 409로, 표준 MVC 예외는
ResponseEntityExceptionHandler가 프레임워크의 상태로 처리합니다. - 모든 예외를 받는 처리기는 가장 낮은 우선순위에 두어 남은 예외만 일반 500과 요청 ID로 바꿉니다.
- ProblemDetail의
type과code는 바뀌지 않는 기계용 값이고,title과detail은 사람이 읽는 문구이므로 분기에 쓰지 않습니다. - HTML 화면은 별도의
@ControllerAdvice가 오류 뷰로 표현하며, 예외 핸들러에서는 부수 효과를 시작하지 않습니다.
연습 문제
1. 낙관적 잠금 충돌을 409로 바꾸기
게시글 수정 API에 낙관적 잠금 충돌을 추가하려고 합니다. 저장소의 버전 불일치를 post-version-conflict 409 ProblemDetail로 바꾸고, 현재 버전과 클라이언트가 보낸 버전을 확장 필드에 넣어야 합니다. 예외를 어느 계층에서 바꾸고 무엇을 테스트하나요?
해설 보기
애플리케이션 계층은 영속성 예외를 도메인 의미가 있는 예외로 바꾸고, 웹 어드바이스는 그 예외만 압니다.
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와 두 버전 필드를 확인합니다. SQL이나 엔티티 전체, 예외 메시지와 스택 트레이스가 JSON에 없는지도 검증합니다.
2. 변환 실패와 입력의 의미
컨트롤러와 두 REST 어드바이스를 그대로 두고, 각 경우를 별도의 모의 예제에서 확인합니다. A는 /numeric/not-a-number입니다. B는 /numeric/0이고, 이 예제의 query.required(0)이 양수 ID 요구 위반으로 IllegalArgumentException을 던지게 설정합니다. C는 /numeric/99이고 조회가 PostNotFoundException(99)를 던집니다. 각 요청의 조회 호출 수와 HTTP 상태, 애플리케이션 code가 있는지 예측하세요.
해설 보기
A는 long으로 변환하기 전에 실패하므로 조회는 0회이고, 프레임워크의 400 ProblemDetail이며 애플리케이션 code는 없습니다. B의 문자열 0은 long으로 변환되고 메서드에 양수 경로 검증이 없으므로 조회가 1회 호출됩니다. 지정한 IllegalArgumentException은 도메인 어드바이스가 처리하지 않으므로, 마지막 처리기의 500과 internal-error가 됩니다. 숫자로 읽을 수 있다고 업무상 올바른 ID인 것은 아니며, 양수 조건을 클라이언트에 400으로 알리려면 별도의 입력 검증과 매핑을 설계해야 합니다.
C는 조회가 1회 호출된 뒤 구체적인 도메인 어드바이스의 404와 post-not-found입니다. 세 예외를 입력 오류라는 한 이름으로 묶으면 실패한 위치와 실제 코드가 다르다는 점을 놓칩니다. 서로 다른 두 요청에 같은 허용된 X-Request-Id를 보내면 추적 필터가 각각 그 값을 받아들일 수 있으므로, 상관 ID가 전역적으로 유일하거나 사용자의 신원을 보장한다고 판단하지도 않습니다.
다음 글 타입 변환과 포매팅에서는 요청 문자열과 도메인 값 객체를 잇는 Converter와 로케일을 아는 Formatter를 어떤 기준으로 고르는지 다룹니다.