본문으로 건너뛰기

안동민 개발노트

본문 시작

게시판 CRUD 웹

게시글 CRUD를 Location·ETag·If-Match 계약과 원자적 버전 비교로 연결하고 실제 MVC 경계에서 검증합니다.

CRUD는 다섯 엔드포인트를 나열하는 일이 아닙니다. 생성 응답의 LocationETag, 조회 응답의 현재 표현, 수정 요청의 If-Match, 삭제 뒤의 관찰 계약이 한 리소스의 수명으로 이어져야 합니다.

이 문서는 그 HTTP 계약과 애플리케이션의 조건부 변경만 소유합니다. JSON 역직렬화·Bean Validation의 일반 규칙은 ch6-4, 메시지 컨버터와 표현 협상은 ch6-6, 전체 예외 진단 순서는 ch6-8의 범위입니다.

CRUD 요청은 Location·ETag·If-Match로 생성·조회·수정·삭제 계약을 연결한다

SEQUENCE · CRUD CONTRACT · CONDITIONAL UPDATE

CRUD 요청은 Location·ETag·If-Match로 생성·조회·수정·삭제 계약을 연결한다

세 참여자의 열한 메시지가 생성 응답의 위치와 버전, 조회한 버전을 되돌려 보내는 조건부 교체, 삭제 뒤의 관찰 계약을 한 리소스 수명으로 잇는다.

CRUD 요청은 Location·ETag·If-Match로 생성·조회·수정·삭제 계약을 연결한다 Client, MVC boundary, Atomic version store 사이의 열한 메시지로 POST 생성, GET 조회, If-Match PUT의 성공 또는 실패 ALT, DELETE를 보여준다. 현재 버전의 PUT만 새 ETag와 본문을 가진 200이 되고, 오래된 버전은 412, 없는 대상은 404, 지원하지 않는 태그는 store 호출 전 400이 된다. ALT [current v1 · target exists] [stale v1 · target missing] POST /api/posts validation 400 · duplicate title 409 create(title, content) 201 · Location /api/posts/1 · ETag v1 · body GET /api/posts/1 200 · ETag "post-1-v1" · current body PUT /api/posts/1 · If-Match "post-1-v1" unsupported or malformed tag → 400 before store replaceIfVersion(id, v1, full body) 200 · ETag "post-1-v2" · replaced body 412 stale · 404 missing · ProblemDetail DELETE /api/posts/1 204 · empty body · repeat → 404 BOUNDED CONCURRENCY same If-Match v1 × 2 한 원자 구간을 차례로 통과 1 UPDATED · 1 STALE Client HTTP consumer MVC boundary controller · advice Atomic version store compare + update LEGEND call return BRANCH PUT outcome branch current-version success
  1. PHASE 1 · CREATE

    POST가 새 항목의 위치와 첫 버전을 만든다

    성공은 201, Location: /api/posts/1, ETag: "post-1-v1", 생성 표현을 함께 반환합니다. 검증 실패는 400, 같은 제목은 이 API 정책의 409입니다.

  2. PHASE 2 · READ

    GET이 현재 표현과 현재 ETag를 묶는다

    컬렉션 GET은 200 배열, 항목 GET은 200과 현재 표현·강한 ETag를 반환합니다. 항목이 없으면 404입니다.

  3. PHASE 3 · REPLACE

    PUT이 If-Match를 원자 비교에 연결한다

    • current · exists → 전체 표현을 바꾸고 200, ETag: "post-1-v2", 새 본문을 반환합니다.
    • stale → 저장값을 보존하고 412를 반환합니다.
    • missing → 같은 원자 구간의 Missing 결과로 404를 반환합니다.
    • unsupported tag → 약한 태그·와일드카드·목록·다른 ID·잘못된 형식은 store 호출 전에 400입니다.
  4. PHASE 4 · DELETE

    첫 삭제와 반복 삭제의 관찰 계약을 분리한다

    존재하는 항목의 첫 삭제는 빈 204입니다. 같은 요청을 반복했을 때 이 API는 대상 부재를 404로 드러냅니다.

두 요청이 같은 If-Match v1을 보내도 fixture store의 한 원자 구간을 차례로 지나므로 하나만 v2가 되고 다른 하나는 stale이다. 이 그림은 메시지 예산상 GET·DELETE의 내부 store 왕복을 생략했다. 메모리 임계 구역은 DB 트랜잭션·SQL 격리·여러 프로세스의 직렬화 가능성·멱등성·재시도 안전·보존 정책을 증명하지 않는다.

그림은 열한 메시지 예산을 지키기 위해 GETDELETE의 내부 store 왕복을 생략하고 외부 응답과 조건부 교체 경계를 남깁니다. 생략한 호출은 아래 코드와 테스트가 소유합니다.


메서드만이 아니라 상태·헤더·본문을 고정한다

예제 리소스의 변경 가능한 전체 표현은 titlecontent입니다. 따라서 이 문서의 PUT은 두 필드를 모두 받는 전체 교체이고, 일부 필드 변경은 별도 PATCH 계약으로 남깁니다.

요청전제·분기응답 상태·헤더·본문대표 실패·정책
POST /api/posts유효하고 중복되지 않은 제목201 · Location: /api/posts/{id} · 강한 ETag · 생성 표현검증 실패 400 · 같은 제목 409
GET /api/posts컬렉션 조회200 · JSON 배열운영 API는 페이지 크기와 안정 정렬을 별도 설계
GET /api/posts/{id}항목 존재200 · 현재 ETag · 현재 표현대상 없음 404
PUT /api/posts/{id}같은 항목의 단일 강한 태그store 호출 전에 태그의 ID·버전을 해석누락·약한 태그·와일드카드·목록·형식 오류 400
PUT /api/posts/{id}현재 버전과 원자적으로 일치200 · 증가한 ETag · 교체 표현대상 없음 404 · 오래된 버전 412
DELETE /api/posts/{id}항목 존재204 · 빈 본문
같은 DELETE 반복첫 삭제 뒤 항목 부재이 API가 선택한 관찰 계약은 404

중복 제목의 409와 반복 삭제의 404는 이 애플리케이션이 고른 정책입니다. 표 하나가 일반적인 멱등성, 재시도 안전성, 중복 억제, 데이터 보존 정책을 증명하지는 않습니다.


격리된 CRUD 애플리케이션

배치의 단일 Gradle 기준은 ch6-6이 소유하고, 이 문서는 충돌 없는 board.crud 경로 13개만 추가합니다. CrudTestApplication의 component scan도 이 패키지 아래로 한정되어 이웃 문서의 fixture와 컨트롤러 경로가 섞이지 않습니다.

src/main/java/board/crud/CrudTestApplication.java
package board.crud;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class CrudTestApplication {
    public static void main(String[] args) {
        SpringApplication.run(CrudTestApplication.class, args);
    }
}

게시글은 응답 표현과 조건 비교에 필요한 id, 두 변경 필드, 단조 증가 version만 가집니다. fixture가 가르치지 않는 영속성 애노테이션이나 감사 필드는 넣지 않습니다.

src/main/java/board/crud/Post.java
package board.crud;
public record Post(
        long id,
        String title,
        String content,
        long version) {
}

생성과 교체는 서로 다른 요청 타입입니다. 지금 두 타입의 필드가 같더라도 URI와 메서드가 표현하는 사용 사례를 합치지 않습니다.

src/main/java/board/crud/CreatePostRequest.java
package board.crud;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record CreatePostRequest(
        @NotBlank @Size(max = 80) String title,
        @NotBlank @Size(max = 720) String content) {
}
src/main/java/board/crud/ReplacePostRequest.java
package board.crud;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record ReplacePostRequest(
        @NotBlank @Size(max = 80) String title,
        @NotBlank @Size(max = 720) String content) {
}

응답은 내부 객체를 그대로 직렬화하지 않고 공개할 네 필드를 명시합니다. 테스트는 이 네 필드 외의 우연한 속성이 생기지 않는지도 확인합니다.

src/main/java/board/crud/PostResponse.java
package board.crud;
public record PostResponse(
        long id,
        String title,
        String content,
        long version) {
    static PostResponse from(Post post) {
        return new PostResponse(
                post.id(),
                post.title(),
                post.content(),
                post.version());
    }
}

ETag 문법을 애플리케이션 경계에서 좁힌다

If-Match는 강한 비교를 사용하고 조건이 거짓인 상태 변경 요청은 수행하지 않습니다. RFC 문법은 태그 목록과 *도 허용하지만, 이 예제의 제품 계약은 같은 항목을 가리키는 단일 강한 태그 "post-{id}-v{positiveVersion}"만 받습니다. 지원하지 않는 RFC 선택지를 “HTTP 자체가 잘못되었다”고 설명하지 않고 이 API가 좁게 거부한다고 명시해야 합니다.

src/main/java/board/crud/PostEtag.java
package board.crud;
import java.util.regex.Pattern;
final class PostEtag {
    private static final Pattern STRONG_TAG =
            Pattern.compile(
                    "\"post-([1-9][0-9]*)-v([1-9][0-9]*)\"");
    private PostEtag() {
    }
    static String format(Post post) {
        return "\"post-" + post.id()
                + "-v" + post.version() + "\"";
    }
    static long requireVersion(
            long resourceId,
            String rawIfMatch) {
        if (rawIfMatch == null) {
            throw new CrudFailures.InvalidIfMatch();
        }
        var matcher = STRONG_TAG.matcher(rawIfMatch);
        if (!matcher.matches()) {
            throw new CrudFailures.InvalidIfMatch();
        }
        try {
            var taggedId = Long.parseLong(matcher.group(1));
            var version = Long.parseLong(matcher.group(2));
            if (taggedId != resourceId) {
                throw new CrudFailures.InvalidIfMatch();
            }
            return version;
        } catch (NumberFormatException failure) {
            throw new CrudFailures.InvalidIfMatch();
        }
    }
}

웹 계층에는 저장 기술의 예외 대신 네 애플리케이션 실패가 도착합니다. 버전 충돌은 클라이언트가 보낸 기대 버전과 store가 원자 구간에서 관찰한 현재 버전을 함께 보존합니다.

src/main/java/board/crud/CrudFailures.java
package board.crud;
final class CrudFailures {
    private CrudFailures() {
    }
    static final class PostNotFound
            extends RuntimeException {
        PostNotFound(long id) {
            super("Post " + id + " was not found");
        }
    }
    static final class DuplicateTitle
            extends RuntimeException {
        DuplicateTitle(String title) {
            super("Post title already exists: " + title);
        }
    }
    static final class VersionMismatch
            extends RuntimeException {
        private final long expectedVersion;
        private final long currentVersion;
        VersionMismatch(
                long expectedVersion,
                long currentVersion) {
            super("Post version did not match");
            this.expectedVersion = expectedVersion;
            this.currentVersion = currentVersion;
        }
        long expectedVersion() {
            return expectedVersion;
        }
        long currentVersion() {
            return currentVersion;
        }
    }
    static final class InvalidIfMatch
            extends RuntimeException {
        InvalidIfMatch() {
            super("If-Match must contain one strong post tag");
        }
    }
}

작은 probe는 조건 헤더가 서비스 이전에 거부되었는지만 관찰합니다. 실행 출력을 적어 둔 문자열이 아니라 테스트가 읽는 실제 호출 계수입니다.

src/main/java/board/crud/CrudProbe.java
package board.crud;
import java.util.concurrent.atomic.AtomicInteger;
import org.springframework.stereotype.Component;
@Component
final class CrudProbe {
    private final AtomicInteger replaceCalls =
            new AtomicInteger();
    void recordReplaceCall() {
        replaceCalls.incrementAndGet();
    }
    int replaceCalls() {
        return replaceCalls.get();
    }
    void reset() {
        replaceCalls.set(0);
    }
}

버전 비교와 쓰기를 한 원자 구간에 둔다

컨트롤러가 먼저 조회해 버전을 비교하고 나중에 조건 없는 저장을 하면 두 동작 사이에 다른 요청이 끼어들 수 있습니다. fixture store는 한 JVM 안에서 synchronized 메서드의 같은 임계 구역에 “현재 항목 조회 → 버전 비교 → 새 버전 저장”을 둡니다. Missing, Stale, Replaced도 그 구역에서 결정하므로 뒤늦은 existsById 조회로 404412를 추측하지 않습니다.

src/main/java/board/crud/VersionedPostStore.java
package board.crud;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.springframework.stereotype.Component;
@Component
final class VersionedPostStore {
    private final Map<Long, Post> posts =
            new LinkedHashMap<>();
    private long nextId = 1;
    synchronized Post create(
            String title,
            String content) {
        if (posts.values().stream()
                .anyMatch(post ->
                        post.title().equals(title))) {
            throw new CrudFailures.DuplicateTitle(title);
        }
        var id = nextId;
        nextId = Math.incrementExact(nextId);
        var created = new Post(
                id, title, content, 1);
        posts.put(id, created);
        return created;
    }
    synchronized List<Post> findAll() {
        return List.copyOf(posts.values());
    }
    synchronized Optional<Post> findById(long id) {
        return Optional.ofNullable(posts.get(id));
    }
    synchronized ReplaceResult replaceIfVersion(
            long id,
            long expectedVersion,
            String title,
            String content) {
        var current = posts.get(id);
        if (current == null) {
            return new Missing();
        }
        if (current.version() != expectedVersion) {
            return new Stale(current.version());
        }
        var replaced = new Post(
                id,
                title,
                content,
                Math.incrementExact(current.version()));
        posts.put(id, replaced);
        return new Replaced(replaced);
    }
    synchronized boolean delete(long id) {
        return posts.remove(id) != null;
    }
    synchronized void reset() {
        posts.clear();
        nextId = 1;
    }
    sealed interface ReplaceResult
            permits Replaced, Missing, Stale {
    }
    record Replaced(Post post)
            implements ReplaceResult {
    }
    record Missing()
            implements ReplaceResult {
    }
    record Stale(long currentVersion)
            implements ReplaceResult {
    }
}

서비스는 store 결과를 HTTP와 무관한 애플리케이션 실패로 바꿉니다. 이 구분을 한 번 만든 뒤 웹 어드바이스가 상태 코드를 선택합니다.

src/main/java/board/crud/PostService.java
package board.crud;
import java.util.List;
import org.springframework.stereotype.Service;
@Service
final class PostService {
    private final VersionedPostStore store;
    private final CrudProbe probe;
    PostService(
            VersionedPostStore store,
            CrudProbe probe) {
        this.store = store;
        this.probe = probe;
    }
    Post create(CreatePostRequest request) {
        return store.create(
                request.title(),
                request.content());
    }
    List<Post> findAll() {
        return store.findAll();
    }
    Post required(long id) {
        return store.findById(id)
                .orElseThrow(() ->
                        new CrudFailures.PostNotFound(id));
    }
    Post replace(
            long id,
            long expectedVersion,
            ReplacePostRequest request) {
        probe.recordReplaceCall();
        var result = store.replaceIfVersion(
                id,
                expectedVersion,
                request.title(),
                request.content());
        if (result instanceof
                VersionedPostStore.Replaced replaced) {
            return replaced.post();
        }
        if (result instanceof
                VersionedPostStore.Stale stale) {
            throw new CrudFailures.VersionMismatch(
                    expectedVersion,
                    stale.currentVersion());
        }
        throw new CrudFailures.PostNotFound(id);
    }
    void delete(long id) {
        if (!store.delete(id)) {
            throw new CrudFailures.PostNotFound(id);
        }
    }
}

이 구현은 한 프로세스의 메모리 fixture만 증명합니다. 실제 데이터베이스에서는 조건부 UPDATE ... WHERE id = ? AND version = ?, 영향을 받은 행 수, 트랜잭션 안의 결과 분류처럼 사용하는 저장 기술에 맞는 원자 계약이 필요합니다. 여기의 모니터가 DB 트랜잭션, SQL 격리 수준, 여러 인스턴스 사이의 직렬화 가능성을 증명한다고 확대하면 안 됩니다.


MVC 경계가 URI·헤더·본문을 조립한다

ResponseEntity는 상태와 헤더와 변환할 본문을 한 반환값에 담습니다. 생성은 created(location), 조회와 교체는 ok().eTag(...).body(...), 삭제는 noContent().build()로 서로 다른 응답 계약을 코드에 드러냅니다.

src/main/java/board/crud/PostController.java
package board.crud;
import jakarta.validation.Valid;
import java.net.URI;
import java.util.List;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
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.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/posts")
final class PostController {
    private final PostService service;
    PostController(PostService service) {
        this.service = service;
    }
    @PostMapping
    ResponseEntity<PostResponse> create(
            @Valid @RequestBody
                    CreatePostRequest request) {
        var created = service.create(request);
        return ResponseEntity
                .created(URI.create(
                        "/api/posts/" + created.id()))
                .eTag(PostEtag.format(created))
                .body(PostResponse.from(created));
    }
    @GetMapping
    List<PostResponse> list() {
        return service.findAll().stream()
                .map(PostResponse::from)
                .toList();
    }
    @GetMapping("/{id}")
    ResponseEntity<PostResponse> get(
            @PathVariable("id") long id) {
        var post = service.required(id);
        return ResponseEntity.ok()
                .eTag(PostEtag.format(post))
                .body(PostResponse.from(post));
    }
    @PutMapping("/{id}")
    ResponseEntity<PostResponse> replace(
            @PathVariable("id") long id,
            @RequestHeader(HttpHeaders.IF_MATCH)
                    String ifMatch,
            @Valid @RequestBody
                    ReplacePostRequest request) {
        var expectedVersion =
                PostEtag.requireVersion(id, ifMatch);
        var replaced = service.replace(
                id, expectedVersion, request);
        return ResponseEntity.ok()
                .eTag(PostEtag.format(replaced))
                .body(PostResponse.from(replaced));
    }
    @DeleteMapping("/{id}")
    ResponseEntity<Void> delete(
            @PathVariable("id") long id) {
        service.delete(id);
        return ResponseEntity.noContent().build();
    }
}

오래된 If-Match412 Precondition Failed, 같은 제목 생성 충돌은 이 API 정책의 409, 존재하지 않는 대상은 404로 번역합니다. 문제 본문은 안정적인 code를 주고, 상세 문자열이나 Java 예외 이름을 클라이언트 분기 키로 만들지 않습니다.

src/main/java/board/crud/CrudExceptionHandler.java
package board.crud;
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;
@RestControllerAdvice(
        assignableTypes = PostController.class)
final class CrudExceptionHandler {
    @ExceptionHandler(
            CrudFailures.PostNotFound.class)
    ResponseEntity<ProblemDetail> notFound(
            CrudFailures.PostNotFound failure) {
        return problem(
                HttpStatus.NOT_FOUND,
                "Post was not found",
                "POST_NOT_FOUND");
    }
    @ExceptionHandler(
            CrudFailures.DuplicateTitle.class)
    ResponseEntity<ProblemDetail> duplicateTitle(
            CrudFailures.DuplicateTitle failure) {
        return problem(
                HttpStatus.CONFLICT,
                "Post title already exists",
                "POST_TITLE_CONFLICT");
    }
    @ExceptionHandler(
            CrudFailures.VersionMismatch.class)
    ResponseEntity<ProblemDetail> versionMismatch(
            CrudFailures.VersionMismatch failure) {
        var body = ProblemDetail.forStatusAndDetail(
                HttpStatus.PRECONDITION_FAILED,
                "The post has changed");
        body.setProperty(
                "code", "POST_VERSION_MISMATCH");
        body.setProperty(
                "expectedVersion",
                failure.expectedVersion());
        body.setProperty(
                "currentVersion",
                failure.currentVersion());
        return ResponseEntity
                .status(HttpStatus.PRECONDITION_FAILED)
                .body(body);
    }
    @ExceptionHandler(
            CrudFailures.InvalidIfMatch.class)
    ResponseEntity<ProblemDetail> invalidIfMatch(
            CrudFailures.InvalidIfMatch failure) {
        return problem(
                HttpStatus.BAD_REQUEST,
                "If-Match is not supported by this API",
                "INVALID_IF_MATCH");
    }
    private static ResponseEntity<ProblemDetail> problem(
            HttpStatus status,
            String detail,
            String code) {
        var body = ProblemDetail.forStatusAndDetail(
                status, detail);
        body.setProperty("code", code);
        return ResponseEntity.status(status).body(body);
    }
}

실제 MVC 경계에서 11개 계약을 실행한다

@SpringBootTest와 Boot 4의 @AutoConfigureMockMvc로 격리된 CrudTestApplication 문맥을 만들고, 실제 요청 매핑·검증·메시지 변환·어드바이스를 통과시킵니다. 각 테스트 전에 store와 probe를 초기화하므로 ID 1과 호출 계수는 다른 테스트 순서에 기대지 않습니다.

마지막 테스트는 시작 신호를 공유하는 두 작업을 최대 3초로 제한합니다. 둘 다 버전 1을 기대하지만 store의 원자 구간을 지난 결과는 정확히 한 Post v2와 한 VersionMismatch입니다. 이것은 fixture의 경쟁 불변식이지 운영 DB의 부하·잠금·장애 복구 시험은 아닙니다.

src/test/java/board/crud/PostCrudContractTest.java
package board.crud;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.hamcrest.Matchers.aMapWithSize;
import static org.hamcrest.Matchers.hasSize;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.springframework.http.HttpHeaders.ETAG;
import static org.springframework.http.HttpHeaders.IF_MATCH;
import static org.springframework.http.HttpHeaders.LOCATION;
import static org.springframework.http.MediaType.APPLICATION_JSON;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
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.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.util.List;
import java.util.Set;
import java.util.concurrent.Callable;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest(classes = CrudTestApplication.class)
@AutoConfigureMockMvc
class PostCrudContractTest {
    private static final String POST_1_V1 =
            "\"post-1-v1\"";
    @Autowired
    MockMvc mvc;
    @Autowired
    PostService service;
    @Autowired
    VersionedPostStore store;
    @Autowired
    CrudProbe probe;
    @BeforeEach
    void resetFixture() {
        store.reset();
        probe.reset();
    }
    @Test
    void create는_201_location_etag와_exact_body를_반환한다()
            throws Exception {
        mvc.perform(post("/api/posts")
                        .contentType(APPLICATION_JSON)
                        .content(json(
                                "HTTP contract",
                                "create body")))
                .andExpect(status().isCreated())
                .andExpect(header().string(
                        LOCATION, "/api/posts/1"))
                .andExpect(header().string(
                        ETAG, POST_1_V1))
                .andExpect(jsonPath(
                        "$", aMapWithSize(4)))
                .andExpect(jsonPath("$.id").value(1))
                .andExpect(jsonPath("$.title")
                        .value("HTTP contract"))
                .andExpect(jsonPath("$.content")
                        .value("create body"))
                .andExpect(jsonPath("$.version")
                        .value(1));
    }
    @Test
    void collection_get은_저장_순서의_body를_반환한다()
            throws Exception {
        service.create(new CreatePostRequest(
                "first", "one"));
        service.create(new CreatePostRequest(
                "second", "two"));
        mvc.perform(get("/api/posts"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$", hasSize(2)))
                .andExpect(jsonPath("$[0].id").value(1))
                .andExpect(jsonPath("$[0].title")
                        .value("first"))
                .andExpect(jsonPath("$[1].id").value(2))
                .andExpect(jsonPath("$[1].title")
                        .value("second"));
    }
    @Test
    void item_get은_200_etag와_exact_body를_반환한다()
            throws Exception {
        service.create(new CreatePostRequest(
                "read", "current body"));
        mvc.perform(get("/api/posts/1"))
                .andExpect(status().isOk())
                .andExpect(header().string(
                        ETAG, POST_1_V1))
                .andExpect(jsonPath(
                        "$", aMapWithSize(4)))
                .andExpect(jsonPath("$.id").value(1))
                .andExpect(jsonPath("$.title")
                        .value("read"))
                .andExpect(jsonPath("$.content")
                        .value("current body"))
                .andExpect(jsonPath("$.version")
                        .value(1));
    }
    @Test
    void current_if_match는_200_new_etag와_교체_body를_반환한다()
            throws Exception {
        service.create(new CreatePostRequest(
                "before", "old body"));
        mvc.perform(put("/api/posts/1")
                        .header(IF_MATCH, POST_1_V1)
                        .contentType(APPLICATION_JSON)
                        .content(json(
                                "after",
                                "new body")))
                .andExpect(status().isOk())
                .andExpect(header().string(
                        ETAG, "\"post-1-v2\""))
                .andExpect(jsonPath(
                        "$", aMapWithSize(4)))
                .andExpect(jsonPath("$.id").value(1))
                .andExpect(jsonPath("$.title")
                        .value("after"))
                .andExpect(jsonPath("$.content")
                        .value("new body"))
                .andExpect(jsonPath("$.version")
                        .value(2));
    }
    @Test
    void stale_if_match는_412이고_저장값을_보존한다()
            throws Exception {
        service.create(new CreatePostRequest(
                "before", "version one"));
        service.replace(
                1,
                1,
                new ReplacePostRequest(
                        "winner", "version two"));
        probe.reset();
        mvc.perform(put("/api/posts/1")
                        .header(IF_MATCH, POST_1_V1)
                        .contentType(APPLICATION_JSON)
                        .content(json(
                                "stale",
                                "must not win")))
                .andExpect(status()
                        .isPreconditionFailed())
                .andExpect(jsonPath("$.code")
                        .value(
                                "POST_VERSION_MISMATCH"))
                .andExpect(jsonPath(
                        "$.expectedVersion").value(1))
                .andExpect(jsonPath(
                        "$.currentVersion").value(2));
        var current = service.required(1);
        assertEquals("winner", current.title());
        assertEquals("version two", current.content());
        assertEquals(2, current.version());
        assertEquals(1, probe.replaceCalls());
    }
    @Test
    void missing_item_put은_404를_반환한다()
            throws Exception {
        mvc.perform(put("/api/posts/99")
                        .header(
                                IF_MATCH,
                                "\"post-99-v1\"")
                        .contentType(APPLICATION_JSON)
                        .content(json(
                                "missing",
                                "body")))
                .andExpect(status().isNotFound())
                .andExpect(jsonPath("$.code")
                        .value("POST_NOT_FOUND"));
    }
    @Test
    void invalid_if_match는_400이고_service에_진입하지_않는다()
            throws Exception {
        var requestBody = json("title", "body");
        mvc.perform(put("/api/posts/1")
                        .contentType(APPLICATION_JSON)
                        .content(requestBody))
                .andExpect(status().isBadRequest());
        for (var invalid : List.of(
                "post-1-v1",
                "W/\"post-1-v1\"",
                "*",
                "\"post-2-v1\"",
                "\"post-1-v0\"",
                "\"post-1-v1\", \"post-1-v2\"")) {
            mvc.perform(put("/api/posts/1")
                            .header(IF_MATCH, invalid)
                            .contentType(
                                    APPLICATION_JSON)
                            .content(requestBody))
                    .andExpect(status().isBadRequest())
                    .andExpect(jsonPath("$.code")
                            .value(
                                    "INVALID_IF_MATCH"));
        }
        assertEquals(0, probe.replaceCalls());
    }
    @Test
    void duplicate_title_create는_409를_반환한다()
            throws Exception {
        mvc.perform(post("/api/posts")
                        .contentType(APPLICATION_JSON)
                        .content(json(
                                "same", "first")))
                .andExpect(status().isCreated());
        mvc.perform(post("/api/posts")
                        .contentType(APPLICATION_JSON)
                        .content(json(
                                "same", "second")))
                .andExpect(status().isConflict())
                .andExpect(jsonPath("$.code")
                        .value(
                                "POST_TITLE_CONFLICT"));
    }
    @Test
    void delete는_204_empty이고_repeat는_404이다()
            throws Exception {
        service.create(new CreatePostRequest(
                "delete", "body"));
        mvc.perform(delete("/api/posts/1"))
                .andExpect(status().isNoContent())
                .andExpect(content().string(""));
        mvc.perform(delete("/api/posts/1"))
                .andExpect(status().isNotFound())
                .andExpect(jsonPath("$.code")
                        .value("POST_NOT_FOUND"));
    }
    @Test
    void missing_item_get은_404를_반환한다()
            throws Exception {
        mvc.perform(get("/api/posts/1"))
                .andExpect(status().isNotFound())
                .andExpect(jsonPath("$.code")
                        .value("POST_NOT_FOUND"));
    }
    @Test
    void same_version_concurrency는_하나만_교체한다()
            throws Exception {
        service.create(new CreatePostRequest(
                "before", "version one"));
        var start = new CountDownLatch(1);
        var pool = Executors.newFixedThreadPool(2);
        try {
            var first = pool.submit(replaceAfter(
                    start, "first winner"));
            var second = pool.submit(replaceAfter(
                    start, "second winner"));
            start.countDown();
            var outcomes = List.of(
                    first.get(3, SECONDS),
                    second.get(3, SECONDS));
            assertEquals(
                    1,
                    outcomes.stream()
                            .filter(Post.class::isInstance)
                            .count());
            assertEquals(
                    1,
                    outcomes.stream()
                            .filter(
                                    CrudFailures
                                            .VersionMismatch
                                            .class
                                            ::isInstance)
                            .count());
            assertInstanceOf(
                    CrudFailures.VersionMismatch.class,
                    outcomes.stream()
                            .filter(
                                    CrudFailures
                                            .VersionMismatch
                                            .class
                                            ::isInstance)
                            .findFirst()
                            .orElseThrow());
            var current = service.required(1);
            assertEquals(2, current.version());
            assertTrue(Set.of(
                            "first winner",
                            "second winner")
                    .contains(current.content()));
            assertEquals(2, probe.replaceCalls());
        } finally {
            pool.shutdownNow();
            assertTrue(pool.awaitTermination(
                    3, SECONDS));
        }
    }
    private Callable<Object> replaceAfter(
            CountDownLatch start,
            String content) {
        return () -> {
            if (!start.await(3, SECONDS)) {
                throw new IllegalStateException(
                        "Concurrent start timed out");
            }
            try {
                return service.replace(
                        1,
                        1,
                        new ReplacePostRequest(
                                "winner", content));
            } catch (
                    CrudFailures.VersionMismatch
                            failure) {
                return failure;
            }
        };
    }
    private static String json(
            String title,
            String content) {
        return """
                {
                  "title": "%s",
                  "content": "%s"
                }
                """.formatted(title, content);
    }
}

조건부 변경에서 지켜야 할 불변식

이 fixture가 고정하는 불변식은 네 가지입니다.

  1. POSTLocation은 실제 생성된 항목 URI이고, 같은 응답의 ETag와 본문은 그 항목의 버전 1을 가리킵니다.
  2. GET에서 받은 항목별 강한 ETag만 같은 ID의 PUT에 되돌려 보냅니다. 지원하지 않는 약한 태그·와일드카드·목록·다른 ID는 store 호출 전에 400입니다.
  3. store의 한 원자 구간에서 현재 버전이 같을 때만 표현 전체를 바꾸고 버전을 정확히 1 증가시킵니다. 오래된 버전은 412, 없는 항목은 그 구간이 반환한 Missing을 근거로 404입니다.
  4. 첫 삭제는 빈 204, 반복 삭제는 이 API가 명시적으로 선택한 404입니다. 보존·감사·논리 삭제 여부는 이 메모리 fixture 밖의 설계입니다.

클라이언트가 412를 받으면 최신 항목과 ETag를 다시 읽고 사용자의 병합 또는 수정 취소 결정을 받아야 합니다. 무조건 같은 요청을 자동 반복하는 것은 최신 변경을 존중한다는 보장이 없습니다.

다음 문서에서는 이 성공·실패 계약을 기준점으로 삼아 매핑 이전의 404·405·415·406, 인자 해석의 400, 애플리케이션의 404, 응답 쓰기와 500을 DispatcherServlet 경계별로 역추적합니다.