게시판 CRUD 웹
게시글 CRUD를 Location·ETag·If-Match 계약과 원자적 버전 비교로 연결하고 실제 MVC 경계에서 검증합니다.
CRUD는 엔드포인트 다섯 개를 나열하는 일이 아닙니다. 생성 응답의 Location과 ETag, 조회 응답의 현재 표현, 수정 요청의 If-Match, 삭제 뒤에 보이는 결과가 한 리소스의 수명으로 이어져야 합니다. 이 글은 그 HTTP 규칙과 애플리케이션의 조건부 변경만 다룹니다. JSON 역직렬화와 Bean Validation의 일반 규칙은 요청 본문·JSON 변환에서, 메시지 컨버터와 표현 협상은 HTTP 메시지 변환기에서, 예외를 진단하는 전체 순서는 다음 글에서 다룹니다.
CRUD 요청은 Location·ETag·If-Match로 생성·조회·수정·삭제 계약을 연결한다
POST의 v1·GET 현재 표현·If-Match PUT의 v2·DELETE 204로 같은 게시글 상태가 바뀐다.
SEQUENCE · CRUD CONTRACT · CONDITIONAL UPDATE
CRUD 요청은 Location·ETag·If-Match로 생성·조회·수정·삭제 계약을 연결한다
리소스 1의 성공 수명과 조건부 교체의 독립 실패를 비교합니다. Location은 생성 항목을, ETag와 If-Match는 같은 항목의 관찰 버전과 기대 버전을 연결합니다.
성공한 리소스 1의 네 요청을 잇는 그림입니다. 실패·경쟁을 모든 요청의 필수 순서로 끼워 넣지 않습니다. PUT 태그 시연의 HTTP 표현 저장 전제와 서비스 호출 전·후의 실패 위치는 본문에서 구분합니다.
-
PHASE 1 · CREATE
POST가 새 항목의 위치와 첫 버전을 만든다
성공은
201,Location: /api/posts/1,ETag: "post-1-v1", 생성 표현을 함께 반환합니다. 검증 실패는400, 같은 제목은 이 API 정책의409입니다. -
PHASE 2 · READ
GET이 현재 표현과 현재 ETag를 묶는다
컬렉션 GET은
200배열, 항목 GET은200과 현재 표현·강한 ETag를 반환합니다. 항목이 없으면404입니다. -
PHASE 3 · REPLACE
PUT이 If-Match를 원자 비교에 연결한다
-
current · exists →
전체 표현을 바꾸고
200,ETag: "post-1-v2", 새 본문을 반환합니다. -
stale →
저장값을 보존하고
412를 반환합니다. -
missing →
같은 원자 구간의
Missing결과로404를 반환합니다. -
unsupported tag →
약한 태그·와일드카드·목록·다른 ID·잘못된 형식은 store 호출
전에
400입니다.
-
current · exists →
전체 표현을 바꾸고
-
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와 경쟁 경계는 아래 지원 설명과 본문에 따로 있다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 2 | POST 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의 생성·조회·교체·삭제 성공 경로 |
| 6 | POST는 Location /api/posts/1과 강한 ETag post-1-v1, 생성 표현을 201로 반환합니다. GET은 같은 항목의 현재 태그와 표현을 200으로 보여 줍니다. PUT은 같은 항목의 If-Match v1을 원자 비교하고 성공하면 v2의 200을 반환합니다. DELETE 첫 성공은 본문 없는 204입니다. 실패 분기는 아래에 독립적으로 비교합니다. |
| 86 | GET·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로 같은 게시글 상태가 바뀐다.
SEQUENCE · CRUD CONTRACT · CONDITIONAL UPDATE
CRUD 요청은 Location·ETag·If-Match로 생성·조회·수정·삭제 계약을 연결한다
리소스 1의 성공 수명과 조건부 교체의 독립 실패를 비교합니다. Location은 생성 항목을, ETag와 If-Match는 같은 항목의 관찰 버전과 기대 버전을 연결합니다.
성공한 리소스 1의 네 요청을 잇는 그림입니다. 실패·경쟁을 모든 요청의 필수 순서로 끼워 넣지 않습니다. PUT 태그 시연의 HTTP 표현 저장 전제와 서비스 호출 전·후의 실패 위치는 본문에서 구분합니다.
-
PHASE 1 · CREATE
POST가 새 항목의 위치와 첫 버전을 만든다
성공은
201,Location: /api/posts/1,ETag: "post-1-v1", 생성 표현을 함께 반환합니다. 검증 실패는400, 같은 제목은 이 API 정책의409입니다. -
PHASE 2 · READ
GET이 현재 표현과 현재 ETag를 묶는다
컬렉션 GET은
200배열, 항목 GET은200과 현재 표현·강한 ETag를 반환합니다. 항목이 없으면404입니다. -
PHASE 3 · REPLACE
PUT이 If-Match를 원자 비교에 연결한다
-
current · exists →
전체 표현을 바꾸고
200,ETag: "post-1-v2", 새 본문을 반환합니다. -
stale →
저장값을 보존하고
412를 반환합니다. -
missing →
같은 원자 구간의
Missing결과로404를 반환합니다. -
unsupported tag →
약한 태그·와일드카드·목록·다른 ID·잘못된 형식은 store 호출
전에
400입니다.
-
current · exists →
전체 표현을 바꾸고
-
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와 경쟁 경계는 아래 지원 설명과 본문에 따로 있다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 2 | POST 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의 생성·조회·교체·삭제 성공 경로 |
| 6 | POST는 Location /api/posts/1과 강한 ETag post-1-v1, 생성 표현을 201로 반환합니다. GET은 같은 항목의 현재 태그와 표현을 200으로 보여 줍니다. PUT은 같은 항목의 If-Match v1을 원자 비교하고 성공하면 v2의 200을 반환합니다. DELETE 첫 성공은 본문 없는 204입니다. 실패 분기는 아래에 독립적으로 비교합니다. |
| 86 | GET·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이 POST로 만들어져 201과 Location: /api/posts/1, 태그 v1을 받고, GET으로 같은 항목의 현재 표현과 태그를 읽은 뒤, If-Match v1을 원자적으로 비교한 PUT이 200과 태그 v2를 돌려주고, 첫 DELETE가 빈 204로 끝나는 성공 경로를 보여 줍니다. 선은 독립된 HTTP 요청들의 순서일 뿐 자동으로 이어지는 요청이 아닙니다. 아래쪽 칸들은 서비스 전에 거부되는 400, 같은 원자 구간에서 결정되는 412와 404, 같은 버전을 기대한 두 작업의 경쟁을 따로 설명합니다. 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 같은 검증자(validator)를 보내도록 합니다. 이 예제는 title과 content를 읽어 id와 version이 있는 응답 DTO를 조립하며, 요청과 새 표현이 변환 없이 같은지까지는 검증하지 않습니다. 표와 테스트의 PUT ETag는 버전 비교를 보여 주기 위한 예제의 규칙으로 읽고, 이것만으로 RFC를 지키는 운영용 표현 저장 모델이 완성되었다고 보지 않습니다. 운영에서 표현을 변환한다면 PUT 성공 응답의 검증자를 생략하고 GET으로 최신 표현과 태그를 다시 받게 하는 식으로 나누어 설계해야 합니다.
중복 제목의 409와 반복 삭제의 404는 이 애플리케이션이 고른 정책입니다. 표 하나가 일반적인 멱등성이나 재시도 안전성, 중복 억제, 데이터 보존 정책을 보장하지는 않습니다.
격리된 CRUD 애플리케이션
이 묶음의 Gradle 기준은 HTTP 메시지 변환기에서 정했고, 이 글은 다른 글과 겹치지 않는 board.crud 경로의 파일 13개만 더합니다. CrudTestApplication의 컴포넌트 스캔도 이 패키지 아래로 한정되어, 이웃 글의 예제와 컨트롤러 경로가 섞이지 않습니다.
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만 가집니다. 이 예제에서 다루지 않는 영속성 애노테이션이나 감사 필드는 넣지 않습니다.
package board.crud;
public record Post(
long id,
String title,
String content,
long version) {
}생성과 교체는 서로 다른 요청 타입입니다. 지금은 두 타입의 필드가 같더라도, URI와 메서드가 나타내는 사용 사례를 합치지 않습니다.
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) {
}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) {
}응답은 내부 객체를 그대로 직렬화하지 않고 공개할 네 필드를 명시합니다. 테스트는 이 네 필드 말고 우연히 생긴 속성이 없는지도 확인합니다.
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가 범위를 좁혀 거부한다고 분명히 적어야 합니다.
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가 원자 구간에서 본 현재 버전을 함께 보존합니다.
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");
}
}
}작은 프로브는 조건 헤더가 서비스 이전에 거부되었는지만 확인합니다. 실행 결과를 적어 둔 문자열이 아니라 테스트가 읽는 실제 호출 횟수입니다.
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);
}
}더 깊이: If-Match가 빠진 요청을 위한 428
이 예제는 If-Match가 없는 PUT을 형식 오류와 같은 400으로 거부하지만, HTTP에는 바로 이 상황을 위한 상태 코드가 따로 있습니다. RFC 6585가 정의한 428 Precondition Required는 원 서버가 요청이 조건부이기를 요구한다는 뜻입니다. RFC는 대표적인 쓰임으로 갱신 손실(lost update) 문제를 듭니다. 클라이언트가 리소스를 GET해 고친 뒤 PUT하는 사이에 다른 쪽이 그 리소스를 바꾸면 충돌이 생기는데, 조건부 요청을 강제하면 서버는 클라이언트가 최신 사본으로 작업했는지 확인할 수 있습니다.
RFC는 428 응답이 요청을 어떻게 다시 보내면 성공하는지 설명해야 한다고(SHOULD) 하고, 예로 If-Match를 쓰라고 안내하는 응답을 보여 줍니다. 또 428 응답은 캐시에 저장되어서는 안 됩니다(MUST NOT). 조건이 없으면 428, 조건은 있지만 현재 버전과 다르면 412, 태그의 형식이 틀리면 400으로 나누면 클라이언트는 무엇을 고쳐야 하는지 상태 코드만으로 구분할 수 있습니다. 어느 쪽을 고르든 이 글의 표처럼 API 문서에 밝혀 두어야 합니다.
버전 비교와 쓰기를 한 원자 구간에 둡니다
컨트롤러가 먼저 조회해 버전을 비교하고 나중에 조건 없이 저장하면, 두 동작 사이에 다른 요청이 끼어들 수 있습니다. 예제의 store는 한 JVM 안에서 synchronized 메서드의 같은 임계 구역에 현재 항목 조회와 버전 비교, 새 버전 저장을 함께 둡니다. Missing, Stale, Replaced도 그 구역 안에서 정하므로, 나중에 따로 existsById를 조회해 404와 412를 추측하지 않습니다.
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와 무관한 애플리케이션 실패로 바꿉니다. 이 구분을 한 번 만들어 두면, 웹의 어드바이스가 상태 코드를 고릅니다.
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를 요구하는 이유입니다. 버전을 미리 읽고 잠금 밖에서 비교한 뒤 나중에 쓰는 흐름과도 구분해야 합니다.
이 구현이 보여 주는 것은 한 프로세스의 메모리 예제뿐입니다. 실제 데이터베이스에서는 조건부 UPDATE ... WHERE id = ? AND version = ?와 영향받은 행 수, 트랜잭션 안에서의 결과 분류처럼, 쓰는 저장 기술에 맞는 원자적 방법이 필요합니다. 여기의 모니터를 DB 트랜잭션이나 SQL 격리 수준, 여러 인스턴스 사이의 직렬화 가능성까지 보장하는 것으로 넓혀 보면 안 됩니다.
MVC가 URI와 헤더, 본문을 조립합니다
ResponseEntity는 상태와 헤더, 변환할 본문을 하나의 반환 값에 담습니다. 생성은 created(location), 조회와 교체는 ok().eTag(...).body(...), 삭제는 noContent().build()로 서로 다른 응답을 코드에 드러냅니다.
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 예외 이름을 클라이언트가 분기하는 키로 쓰지 않게 합니다.
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와 프로브를 초기화하므로 ID 1과 호출 횟수는 다른 테스트의 순서에 기대지 않습니다.
마지막 테스트는 시작 신호를 함께 쓰는 두 작업을 최대 3초로 제한합니다. 둘 다 버전 1을 기대하지만 store의 원자 구간을 지난 결과는 정확히 Post v2 하나와 VersionMismatch 하나입니다. 이것은 예제 안의 경쟁 규칙이며, 운영 DB의 부하나 잠금, 장애 복구를 시험한 것은 아닙니다.
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);
}
}조건부 변경에서 지켜야 할 불변식
이 예제가 고정하는 불변식은 네 가지입니다.
- POST의
Location은 실제로 만들어진 항목의 URI이고, 같은 응답의ETag와 본문은 그 항목의 버전 1을 가리킵니다. - GET에서 받은 항목별 강한
ETag만 같은 ID의 PUT에 되돌려 보냅니다. 지원하지 않는 약한 태그와 와일드카드, 목록, 다른 ID는 store 호출 전에 400입니다. - store의 한 원자 구간에서 현재 버전이 같을 때만 표현 전체를 바꾸고 버전을 정확히 1 올립니다. 오래된 버전은 412이고, 없는 항목은 그 구간이 돌려준
Missing에 따라 404입니다. - 첫 삭제는 빈 204, 반복 삭제는 이 API가 명시적으로 고른 404입니다. 보존과 감사, 논리 삭제 여부는 이 메모리 예제 밖에서 설계할 일입니다.
클라이언트가 412를 받으면 최신 항목과 ETag를 다시 읽고, 사용자에게 병합할지 수정을 취소할지 결정을 받아야 합니다. 같은 요청을 무조건 자동으로 반복하면 최신 변경을 존중한다는 보장이 없습니다.
정리
- POST는 201과 실제 항목의
Location, 버전 1의 강한ETag를 돌려주고, GET은 현재 표현과 현재 태그를 함께 돌려줍니다. - PUT은 같은 항목의 단일 강한 태그만 받으며, 지원하지 않는 형태의
If-Match는 서비스 전에 400으로 거부합니다. - 버전 비교와 쓰기, 결과 분류를 한 원자 구간에 두어, 오래된 버전은 412, 없는 항목은 404로 정하고 다른 요청이 끼어들지 못하게 합니다.
- 같은 버전을 기대한 두 작업은 하나만 성공하며, 메모리의
synchronized는 DB 트랜잭션이나 여러 인스턴스의 직렬화를 대신하지 않습니다. - 중복 제목의 409와 반복 삭제의 404는 이 API의 정책이며, 412를 받은 클라이언트는 최신 표현을 다시 읽고 사용자의 결정을 받습니다.
연습 문제
1. 여섯 요청 이어 가기
빈 store와 replace 프로브 0에서 아래 요청을 차례로 보냅니다. 모든 JSON은 올바른 title과 content를 가지며 별도의 요청 검증이나 보안 실패는 없습니다. ①은 title A와 content 처음, ③은 B와 승자, ④와 ⑥은 C와 후속입니다. ① POST /api/posts, ② PUT /api/posts/1과 If-Match: "post-2-v1", ③ PUT /api/posts/1과 If-Match: "post-1-v1", ④ PUT /api/posts/1과 If-Match: "post-1-v1", ⑤ DELETE /api/posts/1, ⑥ PUT /api/posts/1과 If-Match: "post-1-v1"에서 각 단계의 상태와 응답 태그 또는 오류 코드, 현재 저장값, 누적 replaceCalls()를 적으세요.
해설 보기
① 201과 Location: /api/posts/1, 태그 "post-1-v1"로 A, 처음, 버전 1을 만듭니다. 교체 서비스 호출은 아직 0입니다. ② URI의 ID 1과 태그의 ID 2가 달라 서비스 전에 400 INVALID_IF_MATCH로 거부되며, 저장값과 호출 수는 그대로입니다.
③ 200과 "post-1-v2", B, 승자, 버전 2이며 누적 호출 수는 1입니다. ④ 기대 버전 1과 현재 버전 2가 달라 412 POST_VERSION_MISMATCH이고, 오류의 expectedVersion과 currentVersion은 1과 2입니다. 서비스에는 들어갔으므로 호출 수는 2지만, 저장값은 B, 승자, 버전 2 그대로입니다.
⑤ 204와 빈 본문으로 항목을 지우며 교체 호출 수는 2입니다. ⑥ 태그의 형식과 ID가 맞으므로 교체 서비스까지 들어가 호출 수가 3이 됩니다. 그러나 원자 구간에서 현재 항목이 없어 먼저 Missing을 얻으므로 404 POST_NOT_FOUND입니다. 지운 항목의 현재 버전 2를 보고 Stale로 분류하는 흐름이 아닙니다.
태그 문법이 잘못된 400은 서비스 호출이 0이고, 오래된 버전의 412와 올바른 태그의 대상 없음 404는 서비스에 들어간 뒤라는 차이가 드러납니다. 여섯 요청은 한 번의 초기화를 함께 씁니다. 이 계산은 코드를 따라간 예상이므로 테스트로 확인합니다.
2. 잠금 밖에서 비교하면
store를 바꿔 findById로 버전을 먼저 읽고, 잠금 없이 버전을 비교한 뒤, 같으면 조건 없이 save하도록 고쳤다고 합시다. 두 작업이 동시에 버전 1을 기대하고 들어오면 어떤 결과가 생길 수 있고, 경쟁 테스트는 무엇을 발견하게 될까요?
해설 보기
두 작업이 모두 현재 버전 1을 읽고 비교를 통과한 뒤 차례로 저장하면, 둘 다 성공으로 끝나고 나중에 저장한 쪽이 먼저 저장한 쪽의 표현을 덮어씁니다. 버전도 두 작업이 각자 계산한 2로 저장되어 증가가 한 번만 반영될 수 있습니다. 경쟁 테스트는 성공 하나와 VersionMismatch 하나를 기대하므로, 이 경우 성공이 둘 나와 실패합니다. 다만 타이밍에 따라 한 작업이 먼저 끝나면 우연히 통과할 수도 있으므로, 통과했다고 해서 원자성이 있다는 뜻은 아닙니다. 비교와 쓰기를 같은 임계 구역에 두거나, DB라면 조건부 UPDATE와 영향받은 행 수로 판단해야 합니다.
다음 글 MVC 실패 경계 진단에서는 이 성공과 실패의 결과를 기준으로 삼아, 매핑 이전의 404·405·415·406, 인자 해석의 400, 애플리케이션의 404, 응답 쓰기의 500을 DispatcherServlet의 단계별로 거슬러 추적합니다.