본문 바로가기

게시판 CRUD 웹

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

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

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

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

POST의 v1·GET 현재 표현·If-Match PUT의 v2·DELETE 204로 같은 게시글 상태가 바뀐다.

POST201은리소스1의Location과ETagv1/생성표현,GET200은현재표현과태그를반환합니다. 같은ID의단일강한If-Matchv1 PUT만원자비교뒤전체교체하고200태그v2를반환합니다. unsupported입력400은service전,stale412와missing404는같은store원자구간의결과입니다. 첫DELETE204본문없음과반복DELETE404는API정책이며동시두service작업은하나v2하나VersionMismatch입니다.

SEQUENCE · CRUD CONTRACT · CONDITIONAL UPDATE

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

리소스 1의 성공 수명과 조건부 교체의 독립 실패를 비교합니다. Location은 생성 항목을, ETag와 If-Match는 같은 항목의 관찰 버전과 기대 버전을 연결합니다.

리소스1의 생성·조회·교체·삭제 성공 경로POST는Location/api/posts/1과강한ETagpost-1-v1,생성표현을201로반환합니다. GET은같은항목의현재태그와표현을200으로보여줍니다. PUT은같은항목의If-Matchv1을원자비교하고성공하면v2의200을반환합니다. DELETE첫성공은본문없는204입니다. 실패분기는아래에독립적으로비교합니다.
POST201 · ETag v1
연결 관계POST · 201 · ETag v1 → GET · 200 · ETag v1
GET200 · ETag v1
연결 관계GET · 200 · ETag v1 → PUT · If-Match · 200 · ETag v2
PUT · If-Match200 · ETag v2
연결 관계PUT · If-Match · 200 · ETag v2 → DELETE · 204 · body 없음
DELETE204 · body 없음
같은 항목의 성공 수명과 관찰 태그원 데스크톱의 네 사건·세 관계를 재배치한다. 생성 201의 Location과 태그 v1에서 같은 항목 GET 200, 기대 v1을 원자 비교한 PUT 200 태그 v2, 첫 DELETE 204로 이어진다. 선은 독립 HTTP 요청의 관찰 순서이며 자동 요청이나 인과를 강제하지 않는다. PUT 태그는 이 메모리 fixture의 버전 비교 시연이고 요청 표현 무변환 동일성까지 검증한 RFC 배포 모델이 아니다. 입력 400·stale 412·Missing 404와 경쟁 경계는 아래 지원 설명과 본문에 따로 있다.같은 URI기대 v1삭제POST · 201Location:/api/posts/1ETag v1 · 생성 표현GET · 200같은 항목의 표현ETag v1PUT · If-Match v1원자 비교 뒤 교체200 · ETag v2첫 DELETE204 · 빈 본문

성공한 리소스 1의 네 요청을 잇는 그림입니다. 실패·경쟁을 모든 요청의 필수 순서로 끼워 넣지 않습니다. PUT 태그 시연의 HTTP 표현 저장 전제와 서비스 호출 전·후의 실패 위치는 본문에서 구분합니다.

  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로 드러냅니다.

BEFORE SERVICE · 400

이 API가 받는 단일 강한 태그

If-Match: "post-1-v1"처럼 같은 ID와 양의 version만 허용합니다. 누락·약한 태그·*·목록·다른 ID·잘못된 형식은 replace service 0회입니다. *와 목록은 RFC가 허용하지만 이 제품 계약은 지원하지 않습니다.

ONE JVM · SYNCHRONIZED

조회·버전 비교·쓰기와 결과 분류를 함께

store의 하나의 원자 구간에서 현재 항목 조회 → 비교 → 전체 title·content 교체를 합니다. 같은 버전이면 Replaced(v2), 다른 버전이면 Stale, 항목이 없으면 Missing입니다. 늦은 별도 조회로 404와 412를 추측하지 않습니다.

BOUNDED CONCURRENCY

같은 기대 v1 두 작업의 결과

service를 동시에 시작하는 두 작업이 원자 구간을 지나면 정확히 하나의 Post v2와 하나의 VersionMismatch입니다. 각 작업의 최대 대기는 3초이고 replace 호출은 2회입니다. HTTP 경쟁 요청·운영 DB 부하 테스트로 확대하지 않습니다.

AFTER 412

최신 표현을 읽고 사용자가 결정한다

최신 항목과 ETag를 다시 읽어 병합 또는 수정 취소를 결정합니다. 실패한 변경을 그대로 자동 반복해 최신 변경을 존중한다고 주장할 수 없습니다.

GET·DELETE의 내부 store 호출은 아래 구현이 소유합니다. 메모리 임계 구역은 DB 트랜잭션·SQL 격리·여러 프로세스의 직렬화 가능성·멱등성·재시도 안전·보존 정책을 증명하지 않습니다. 중복 제목409와 반복 삭제404는 이 API의 정책입니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • POST는 Location /api/posts/1과 강한 ETag post-1-v1, 생성 표현을 201로 반환합니다. GET은 같은 항목의 현재 태그와 표현을 200으로 보여 줍니다. PUT은 같은 항목의 If-Match v1을 원자 비교하고 성공하면 v2의 200을 반환합니다. DELETE 첫 성공은 본문 없는 204입니다. 실패 분기는 아래에 독립적으로 비교합니다.
  • 원 데스크톱의 네 사건·세 관계를 재배치한다. 생성 201의 Location과 태그 v1에서 같은 항목 GET 200, 기대 v1을 원자 비교한 PUT 200 태그 v2, 첫 DELETE 204로 이어진다. 선은 독립 HTTP 요청의 관찰 순서이며 자동 요청이나 인과를 강제하지 않는다. PUT 태그는 이 메모리 fixture의 버전 비교 시연이고 요청 표현 무변환 동일성까지 검증한 RFC 배포 모델이 아니다. 입력 400·stale 412·Missing 404와 경쟁 경계는 아래 지원 설명과 본문에 따로 있다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
2POST 201은 리소스 1의 Location과 ETag v1/생성 표현을, GET 200은 현재 표현과 태그를 반환합니다. 같은 ID의 단일 강한 If-Match v1 PUT만 원자 비교 뒤 전체 교체하고 200 태그 v2를 반환합니다. unsupported 입력 400은 service 전, stale 412와 missing 404는 같은 store 원자 구간의 결과입니다. 첫 DELETE 204의 본문 없음과 반복 DELETE 404는 API 정책이며 동시 두 service 작업의 결과는 하나는 v2, 하나는 VersionMismatch입니다.
5리소스 1의 생성·조회·교체·삭제 성공 경로
6POST는 Location /api/posts/1과 강한 ETag post-1-v1, 생성 표현을 201로 반환합니다. GET은 같은 항목의 현재 태그와 표현을 200으로 보여 줍니다. PUT은 같은 항목의 If-Match v1을 원자 비교하고 성공하면 v2의 200을 반환합니다. DELETE 첫 성공은 본문 없는 204입니다. 실패 분기는 아래에 독립적으로 비교합니다.
86GET·DELETE의 내부 store 호출은 아래 구현이 소유합니다. 메모리 임계 구역은 DB 트랜잭션·SQL 격리·여러 프로세스의 직렬화 가능성·멱등성·재시도 안전·보존 정책을 증명하지 않습니다. 중복 제목 409와 반복 삭제 404는 이 API의 정책입니다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
생성

POST가 생성 표현과 Location·강한 ETag를 반환합니다.

상태: 201 · Location: /api/posts/1 · ETag: post-1-v1항목과 첫 버전의 validator가 생깁니다.
현재 표현 읽기

GET이 같은 항목의 현재 tag와 표현을 반환합니다.

상태: 200 · ETag: post-1-v1 · 항목: 1조회 결과로 현재 표현과 버전을 확인합니다.
조건부 갱신

PUT이 If-Match의 post-1-v1을 원자 비교하고 성공하면 post-1-v2 표현을 반환합니다.

상태: 200 · 요청 조건: If-Match post-1-v1 · 새 ETag: post-1-v2같은 항목의 표현과 validator가 함께 바뀝니다.
삭제

첫 DELETE 성공은 본문 없이 완료됩니다.

상태: 204 · 본문: 없음 · 항목: 삭제같은 항목의 정상 성공 추적이 종료됩니다.

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

POST의 v1·GET 현재 표현·If-Match PUT의 v2·DELETE 204로 같은 게시글 상태가 바뀐다.

POST201은리소스1의Location과ETagv1/생성표현,GET200은현재표현과태그를반환합니다. 같은ID의단일강한If-Matchv1 PUT만원자비교뒤전체교체하고200태그v2를반환합니다. unsupported입력400은service전,stale412와missing404는같은store원자구간의결과입니다. 첫DELETE204본문없음과반복DELETE404는API정책이며동시두service작업은하나v2하나VersionMismatch입니다.

SEQUENCE · CRUD CONTRACT · CONDITIONAL UPDATE

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

리소스 1의 성공 수명과 조건부 교체의 독립 실패를 비교합니다. Location은 생성 항목을, ETag와 If-Match는 같은 항목의 관찰 버전과 기대 버전을 연결합니다.

리소스1의 생성·조회·교체·삭제 성공 경로POST는Location/api/posts/1과강한ETagpost-1-v1,생성표현을201로반환합니다. GET은같은항목의현재태그와표현을200으로보여줍니다. PUT은같은항목의If-Matchv1을원자비교하고성공하면v2의200을반환합니다. DELETE첫성공은본문없는204입니다. 실패분기는아래에독립적으로비교합니다.
POST201 · ETag v1
연결 관계POST · 201 · ETag v1 → GET · 200 · ETag v1
GET200 · ETag v1
연결 관계GET · 200 · ETag v1 → PUT · If-Match · 200 · ETag v2
PUT · If-Match200 · ETag v2
연결 관계PUT · If-Match · 200 · ETag v2 → DELETE · 204 · body 없음
DELETE204 · body 없음
같은 항목의 성공 수명과 관찰 태그원 데스크톱의 네 사건·세 관계를 재배치한다. 생성 201의 Location과 태그 v1에서 같은 항목 GET 200, 기대 v1을 원자 비교한 PUT 200 태그 v2, 첫 DELETE 204로 이어진다. 선은 독립 HTTP 요청의 관찰 순서이며 자동 요청이나 인과를 강제하지 않는다. PUT 태그는 이 메모리 fixture의 버전 비교 시연이고 요청 표현 무변환 동일성까지 검증한 RFC 배포 모델이 아니다. 입력 400·stale 412·Missing 404와 경쟁 경계는 아래 지원 설명과 본문에 따로 있다.같은 URI기대 v1삭제POST · 201Location:/api/posts/1ETag v1 · 생성 표현GET · 200같은 항목의 표현ETag v1PUT · If-Match v1원자 비교 뒤 교체200 · ETag v2첫 DELETE204 · 빈 본문

성공한 리소스 1의 네 요청을 잇는 그림입니다. 실패·경쟁을 모든 요청의 필수 순서로 끼워 넣지 않습니다. PUT 태그 시연의 HTTP 표현 저장 전제와 서비스 호출 전·후의 실패 위치는 본문에서 구분합니다.

  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로 드러냅니다.

BEFORE SERVICE · 400

이 API가 받는 단일 강한 태그

If-Match: "post-1-v1"처럼 같은 ID와 양의 version만 허용합니다. 누락·약한 태그·*·목록·다른 ID·잘못된 형식은 replace service 0회입니다. *와 목록은 RFC가 허용하지만 이 제품 계약은 지원하지 않습니다.

ONE JVM · SYNCHRONIZED

조회·버전 비교·쓰기와 결과 분류를 함께

store의 하나의 원자 구간에서 현재 항목 조회 → 비교 → 전체 title·content 교체를 합니다. 같은 버전이면 Replaced(v2), 다른 버전이면 Stale, 항목이 없으면 Missing입니다. 늦은 별도 조회로 404와 412를 추측하지 않습니다.

BOUNDED CONCURRENCY

같은 기대 v1 두 작업의 결과

service를 동시에 시작하는 두 작업이 원자 구간을 지나면 정확히 하나의 Post v2와 하나의 VersionMismatch입니다. 각 작업의 최대 대기는 3초이고 replace 호출은 2회입니다. HTTP 경쟁 요청·운영 DB 부하 테스트로 확대하지 않습니다.

AFTER 412

최신 표현을 읽고 사용자가 결정한다

최신 항목과 ETag를 다시 읽어 병합 또는 수정 취소를 결정합니다. 실패한 변경을 그대로 자동 반복해 최신 변경을 존중한다고 주장할 수 없습니다.

GET·DELETE의 내부 store 호출은 아래 구현이 소유합니다. 메모리 임계 구역은 DB 트랜잭션·SQL 격리·여러 프로세스의 직렬화 가능성·멱등성·재시도 안전·보존 정책을 증명하지 않습니다. 중복 제목409와 반복 삭제404는 이 API의 정책입니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • POST는 Location /api/posts/1과 강한 ETag post-1-v1, 생성 표현을 201로 반환합니다. GET은 같은 항목의 현재 태그와 표현을 200으로 보여 줍니다. PUT은 같은 항목의 If-Match v1을 원자 비교하고 성공하면 v2의 200을 반환합니다. DELETE 첫 성공은 본문 없는 204입니다. 실패 분기는 아래에 독립적으로 비교합니다.
  • 원 데스크톱의 네 사건·세 관계를 재배치한다. 생성 201의 Location과 태그 v1에서 같은 항목 GET 200, 기대 v1을 원자 비교한 PUT 200 태그 v2, 첫 DELETE 204로 이어진다. 선은 독립 HTTP 요청의 관찰 순서이며 자동 요청이나 인과를 강제하지 않는다. PUT 태그는 이 메모리 fixture의 버전 비교 시연이고 요청 표현 무변환 동일성까지 검증한 RFC 배포 모델이 아니다. 입력 400·stale 412·Missing 404와 경쟁 경계는 아래 지원 설명과 본문에 따로 있다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
2POST 201은 리소스 1의 Location과 ETag v1/생성 표현을, GET 200은 현재 표현과 태그를 반환합니다. 같은 ID의 단일 강한 If-Match v1 PUT만 원자 비교 뒤 전체 교체하고 200 태그 v2를 반환합니다. unsupported 입력 400은 service 전, stale 412와 missing 404는 같은 store 원자 구간의 결과입니다. 첫 DELETE 204의 본문 없음과 반복 DELETE 404는 API 정책이며 동시 두 service 작업의 결과는 하나는 v2, 하나는 VersionMismatch입니다.
5리소스 1의 생성·조회·교체·삭제 성공 경로
6POST는 Location /api/posts/1과 강한 ETag post-1-v1, 생성 표현을 201로 반환합니다. GET은 같은 항목의 현재 태그와 표현을 200으로 보여 줍니다. PUT은 같은 항목의 If-Match v1을 원자 비교하고 성공하면 v2의 200을 반환합니다. DELETE 첫 성공은 본문 없는 204입니다. 실패 분기는 아래에 독립적으로 비교합니다.
86GET·DELETE의 내부 store 호출은 아래 구현이 소유합니다. 메모리 임계 구역은 DB 트랜잭션·SQL 격리·여러 프로세스의 직렬화 가능성·멱등성·재시도 안전·보존 정책을 증명하지 않습니다. 중복 제목 409와 반복 삭제 404는 이 API의 정책입니다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
생성

POST가 생성 표현과 Location·강한 ETag를 반환합니다.

상태: 201 · Location: /api/posts/1 · ETag: post-1-v1항목과 첫 버전의 validator가 생깁니다.
현재 표현 읽기

GET이 같은 항목의 현재 tag와 표현을 반환합니다.

상태: 200 · ETag: post-1-v1 · 항목: 1조회 결과로 현재 표현과 버전을 확인합니다.
조건부 갱신

PUT이 If-Match의 post-1-v1을 원자 비교하고 성공하면 post-1-v2 표현을 반환합니다.

상태: 200 · 요청 조건: If-Match post-1-v1 · 새 ETag: post-1-v2같은 항목의 표현과 validator가 함께 바뀝니다.
삭제

첫 DELETE 성공은 본문 없이 완료됩니다.

상태: 204 · 본문: 없음 · 항목: 삭제같은 항목의 정상 성공 추적이 종료됩니다.

그림은 리소스 1의 성공 수명과 조건부 교체의 실패·원자성·경쟁 경계를 나누어 보여 줍니다. GET과 DELETE의 내부 store 호출과 각 결과의 상세 계약은 아래 코드와 테스트가 소유합니다.


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

예제 리소스의 변경 가능한 전체 표현은 title과 content입니다. 따라서 이 문서의 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

PUT 응답의 태그에는 추가 HTTP 전제가 있습니다. RFC 9110의 PUT 규칙은 요청 표현 데이터를 변환 없이 저장하여 새 표현 데이터가 받은 내용과 같을 때만 성공 PUT에 ETag 같은 검증자를 보내도록 합니다. 이 fixture는 title·content를 읽어 id·version이 있는 응답 DTO를 조립하며, 요청과 새 표현 데이터의 무변환 동일성까지 검증하지 않습니다. 표와 원 테스트의 PUT ETag는 버전 비교를 보여 주는 고정 fixture 계약으로 읽고, 이 상태만으로 RFC를 준수하는 운영 표현 저장 모델을 완성했다고 판단하지 않습니다. 운영에서 표현을 변환한다면 PUT 성공 응답의 검증자를 생략하고 GET으로 최신 표현·태그를 다시 얻는 계약 등으로 나누어 설계해야 합니다.

중복 제목의 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 조회로 404와 412를 추측하지 않습니다.

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);
        }
    }
}

예시

두 사용자가 버전 1을 읽었고, 두 교체 작업 모두 기대 버전 1을 전달한다고 합시다. A가 먼저 모니터를 얻는 경우만 순서대로 추적해 봅시다. A는 현재 버전 1과 기대 버전 1이 같음을 확인하고 새 표현·버전 2를 저장한 뒤 모니터를 놓습니다. 그다음 B가 얻으면 현재 버전은 이미 2입니다. B는 Stale(2)에서 끝나 A의 표현을 덮어쓰지 않습니다.

두 작업 모두 서비스에 진입했으므로 replace 호출은 2회지만, 성공한 교체와 버전 증가는 각각 1회입니다. B가 먼저 얻으면 승자만 바뀝니다. 원 경쟁 테스트가 특정 작업의 승리를 요구하지 않고 성공 하나·불일치 하나·최종 버전 2를 요구하는 이유입니다. 버전을 미리 읽고 잠금 밖에서 비교한 뒤 나중에 쓰는 흐름과도 구분해야 합니다.

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

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

독립 확인

빈 원 store와 replace probe 0에서 아래 요청을 순서대로 이어 보세요. 모든 JSON은 유효한 title·content를 가지며 별도 요청 검증·보안 실패는 없습니다. 1번은 title A, content 처음, 3번은 B·승자, 4번과 6번은 C·후속입니다. 원 fixture의 태그 시연 계약과 위 HTTP 표현 저장 한계 안에서 상태·응답 태그 또는 오류 코드, 현재 저장값, 누적 replaceCalls()를 적으세요.

  1. POST /api/posts
  2. PUT /api/posts/1, If-Match="post-2-v1"
  3. PUT /api/posts/1, If-Match="post-1-v1"
  4. PUT /api/posts/1, If-Match="post-1-v1"
  5. DELETE /api/posts/1
  6. PUT /api/posts/1, If-Match="post-1-v1"
헤더 거부·오래된 버전·대상 부재를 나누는 풀이

1번은 201, Location /api/posts/1, 태그 "post-1-v1", A·처음·버전 1을 만듭니다. 교체 서비스 호출은 아직 0입니다. 2번은 URI의 ID 1과 태그 ID 2가 달라, 서비스 이전에 400 INVALID_IF_MATCH로 거부합니다. 저장값과 호출 수는 그대로입니다.

3번은 200과 원 fixture의 "post-1-v2", B·승자·버전 2이며 누적 호출 수 1입니다. 4번은 기대 1과 현재 2가 달라 412 POST_VERSION_MISMATCH이고, 오류의 expectedVersion·currentVersion은 1·2입니다. 서비스에는 진입했으므로 호출 수 2이지만 저장값은 B·승자·버전 2를 보존합니다.

5번은 204와 빈 본문으로 항목을 지우며 교체 호출 수는 2입니다. 6번의 태그는 형식과 ID가 맞아 교체 서비스까지 들어가 호출 수 3이 됩니다. 그러나 원자 구간에서 현재 항목이 없어 Missing을 먼저 얻으므로 404 POST_NOT_FOUND입니다. 지운 항목을 대상으로 현재 버전 2를 관찰하여 Stale로 분류하는 흐름이 아닙니다.

태그 문법이 잘못된 400은 서비스 0, 오래된 버전 412와 유효한 태그의 대상 부재 404는 서비스 진입 뒤라는 차이가 드러납니다. 한 초기화를 여섯 요청이 공유하는 조건을 유지하세요. 이 계산은 원 코드 추적이며, 새 HTTP 경쟁·운영 DB·Java 25 실행의 결과가 아닙니다.

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