본문으로 건너뛰기

안동민 개발노트

본문 시작

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

예외는 첫 번째로 의미를 아는 resolver에서 멈춘다

FLOWCHART · FIRST MATCH WINS

예외는 첫 번째로 의미를 아는 resolver에서 멈춘다

좁은 domain advice, Spring MVC 표준 handler, 가장 낮은 우선순위의 500 catch-all을 차례로 평가한다. 처리 완료된 가지는 servlet ERROR fallback까지 내려가지 않는다.

예외는 첫 번째로 의미를 아는 resolver에서 멈춘다 컨트롤러 예외는 좁은 도메인 advice, Spring MVC 표준 handler, 가장 낮은 우선순위의 예기치 않은 예외 advice 순서로 평가된다. 각 resolver가 처리한 결과는 즉시 멈추고, 모두 처리하지 못한 예외만 servlet ERROR fallback으로 내려간다. 도메인 404는 REST ProblemDetail과 HTML view로 표현만 달리할 수 있다. HANDLER EXCEPTION · ORDERED RESOLVERS · FIRST MATCH WINS DECISION 1 narrow domain advice PostNotFound · DuplicatePost controller 범위 제한 DECISION 2 MVC standard handler type · media · body failure framework 의미 보존 DECISION 3 unexpected catch-all @Order LOWEST_PRECEDENCE 나머지 Exception만 처리 FALLBACK servlet ERROR path MVC가 끝까지 미처리 마지막 안전망 HANDLED · STOP 404 · 409 REST ProblemDetail HTML posts/not-found view HANDLED · STOP 400 · 415 MethodArgumentTypeMismatch controller method 미도달 HANDLED · STOP 500 internal-error 일반 detail + requestId stack trace 공개 안 함 ERROR DISPATCH /error handler original URI · status 복원 commit 전 안전 응답 EXACT ORACLE 999 → 404 · duplicate 41 → 409 · numeric/not-a-number → 400 · explode + req-api-77 → 500
  1. DECISION 1 · DOMAIN TYPE

    PostNotFoundException 또는 DuplicatePostException인가?

    맞으면 scoped @RestControllerAdvice가 404 또는 409 ProblemDetail을 만들고 평가를 끝낸다.

  2. DECISION 2 · MVC STANDARD

    type mismatch·media type·body 읽기 실패인가?

    맞으면 ResponseEntityExceptionHandler의 구체 handler가 400·415 같은 프레임워크 의미를 보존한다.

  3. DECISION 3 · UNKNOWN EXCEPTION

    handler 안의 나머지 Exception인가?

    LOWEST_PRECEDENCE catch-all이 일반 문구의 500과 Filter가 게시한 request ID를 반환한다.

  4. 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, @ResponseStatusResponseStatusException을 해석하는 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를 만들면 계약 카디널리티가 폭발합니다. 발생별 값은 requestIdinstance에 두고 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, 세 version 필드를 확인합니다. SQL, entity 전체, 예외 메시지와 stack trace가 JSON에 없는지도 검증합니다.

다음 문서에서는 요청 문자열과 domain 값 객체 사이를 연결하는 Converter와 locale-aware Formatter의 선택 기준을 다룹니다.