본문 바로가기

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 경계가 맡습니다.

실제 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 렌더 검사는 아닙니다.

CONTRACT / IMPLEMENTATION / REVIEW SCOPE

domain·MVC 표준·나머지 예외의 실제 응답을 비교한다

좁은 domain advice가 404·409를 먼저 소유합니다. 낮은 우선순위 advice의 상속 handler가 MVC 타입 오류를 400으로 보존하고, 나머지 예외만 일반 500으로 만듭니다.

domain·MVC 표준·나머지 예외의 실제 응답을 비교한다실제 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 렌더 검사는 아닙니다.
scoped domain advice404 / 409 의미
연결 관계scoped domain advice · 404 / 409 의미 → 404 / 409 + 외부 code · handler / query 도달
404 / 409 + 외부 codehandler / query 도달
상속된 MVC handler타입 변환 실패
연결 관계상속된 MVC handler · 타입 변환 실패 → numeric: 400 · handler / query 미도달
numeric: 400handler / query 미도달
lowest catch-all남은 Exception
연결 관계lowest catch-all · 남은 Exception → explode: 500 + ID · handler 도달
explode: 500 + IDhandler 도달
세 예외 의미의 독립 응답 경로기존 데스크톱 여섯 노드와 세 관계를 세 독립 영역에 배치한다. domainadvice는404·409, 상속된MVC타입변환handler는controller전400, 남은handler예외의catchall은일반500과ID이다. 서로 다른요청의 대안이며 domain404가다시400과500으로변하는순서가아니다. HTMLview와sendError·servletfallback은본문에서별도로구분한다.구체 domain 실패MVC 변환 실패남은 handler 실패scoped domain advice404 · 409 의미404 · 409 + codehandler · query 도달상속된 MVC handler타입 변환 실패numeric: 400handler · query 미도달lowest catch-all남은 Exceptionexplode: 500 + IDhandler 도달

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의 순서도 중요합니다.

  1. 특정 domain 예외를 아는 advice가 먼저 404·409 계약을 만듭니다.
  2. ResponseEntityExceptionHandler가 method argument, media type, body 읽기 같은 표준 MVC 오류의 상태를 보존합니다.
  3. 남은 Exception만 가장 낮은 우선순위의 500 handler가 정규화합니다.
  4. 끝까지 처리되지 않은 예외만 servlet ERROR 경계로 나갑니다.

catch-all을 높은 우선순위에 두면 더 구체적인 advice뿐 아니라 400·404·415 같은 프레임워크 의미까지 500으로 덮을 수 있습니다.


domain 예외와 HTTP adapter를 분리한다

두 예외는 모두 runtime 실패지만 클라이언트의 다음 행동이 다릅니다. 404는 목록이나 URI를 다시 확인하고, 409는 현재 상태를 재조회한 뒤 명령을 조정합니다.

src/main/java/board/web/problem/PostNotFoundException.java
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;
    }
}
src/main/java/board/web/problem/DuplicatePostException.java
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를 만들지 않습니다.

src/main/java/board/web/problem/ProblemPostQuery.java
package board.web.problem;

public interface ProblemPostQuery {
    PostSummary required(long postId);

    void createDuplicate(long postId);

    record PostSummary(long id, String title) {
    }
}
src/main/java/board/web/problem/ProblemPostController.java
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 예외만 처리합니다.

src/main/java/board/web/problem/RequestExceptionAdvice.java
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문서와 큰 분기 기준
statusHTTP 의미재인증·재시도·입력 수정 판단
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"로 응답하지 않습니다.

src/main/java/board/web/problem/UnexpectedExceptionAdvice.java
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에 도달했는지 구분합니다.

src/test/java/board/web/problem/RequestExceptionAdviceTest.java
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);
    }
}
exception resolver exact oracle
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 selected

HTML은 @ControllerAdvice가 view로 표현한다

브라우저 상세 페이지의 404는 ProblemDetail JSON보다 목록으로 돌아갈 수 있는 오류 view가 유용합니다. 같은 domain status 의미는 유지하되 controller 범위와 표현을 분리합니다.

src/main/java/board/web/problem/ProblemPostPageController.java
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";
    }
}
src/main/java/board/web/problem/PageExceptionAdvice.java
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에 적용되지 않습니다.

src/test/java/board/web/problem/PageExceptionAdviceTest.java
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 실패좁은 @RestControllerAdvice404·409 ProblemDetail
HTML handler 내부 domain 실패좁은 @ControllerAdvice404 오류 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는 그 예외만 압니다.

src/main/java/board/application/versioning/PostVersionConflictException.java
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의 선택 기준을 다룹니다.