HTTP 메서드 속성
HTTP 메서드의 안전성·멱등성·캐시 가능성을 구분하고, Java 17과 Spring Framework 6.2.11로 제한된 재시도와 애플리케이션 멱등성 기록을 검증합니다.
HTTP 메서드는 컨트롤러 메서드 이름을 고르는 장식이 아닙니다.
클라이언트, 브라우저, 프록시, 캐시는 메서드에 부여된 공통 의미를 바탕으로 미리 가져오기, 저장, 재사용, 재시도 가능성을 판단합니다.
RFC 9110의 메서드 속성에서 안전성, 멱등성, 캐시 가능성은 서로 다른 질문입니다.
METHOD SEMANTICS · SAFE · IDEMPOTENT · CACHEABLE
안전·멱등·캐시 가능성은 서로 다른 method 속성이다
하나의 예·아니오로 method를 묶지 않습니다. 안전성은 요청의 의도, 멱등성은 같은 요청을 반복한 의도된 효과, 캐시 가능성은 응답 저장·재사용 허용 여부를 각각 말합니다.
안전 ⇒ 멱등이지만 멱등 ⇏ 안전입니다. 또한 POST와 PATCH는 비멱등이어도 조건부로 캐시 가능할 수 있습니다. 캐시 가능은 허용이지 실제 저장의 보장이 아닙니다.
SAFE · IDEMPOTENT · CACHE SEMANTICS
GET · HEAD
- 안전 · 예 — client가 대상 resource 상태 변경을 요청하지 않습니다.
- 멱등 · 예 — 같은 요청의 반복은 의도된 효과를 바꾸지 않습니다.
- 캐시 · 의미가 정의됩니다. 실제 저장·재사용은 응답의 cache controls와 구현에 좌우됩니다.
SAFE · IDEMPOTENT · NOT CACHEABLE
OPTIONS · TRACE
- 안전 · 예 — client가 대상 resource 상태 변경을 요청하지 않습니다.
- 멱등 · 예 — 안전한 method는 멱등입니다.
- 캐시 · 불가 — 응답을 cache에 저장해 재사용하는 method가 아닙니다.
UNSAFE · IDEMPOTENT · NOT CACHEABLE
PUT · DELETE
- 안전 · 아니오 — resource 상태 변경을 요청할 수 있습니다.
- 멱등 · 예 — 같은 요청을 여러 번 보내도 의도된 효과는 한 번과 같습니다.
- 캐시 · 불가 — 응답 저장·재사용이 허용된 method가 아닙니다.
UNSAFE · NON-IDEMPOTENT · CONDITIONAL CACHE
POST
- 안전 · 아니오 — 처리 결과로 상태가 바뀔 수 있습니다.
- 멱등 · method 자체는 보장하지 않습니다.
- 캐시 · 응답이 명시적 freshness와 요청 target URI와 같은
Content-Location을 함께 제공할 때 후속GET·HEAD에 재사용할 수 있으며, 일반 구현은 드뭅니다.
UNSAFE · NON-IDEMPOTENT · CONDITIONAL CACHE
PATCH
- 안전 · 아니오 — resource의 부분 변경을 요청합니다.
- 멱등 · method 자체는 보장하지 않습니다.
- 캐시 · 응답이 명시적 freshness와 요청 URI와 같은
Content-Location을 함께 제공하면 후속GET·HEAD에만 재사용할 수 있습니다.
UNSAFE · NON-IDEMPOTENT · NOT CACHEABLE
CONNECT
- 안전 · 아니오 — 목적지 server와의 tunnel 생성을 요청합니다.
- 멱등 · method 자체는 보장하지 않습니다.
- 캐시 · 불가 — 응답 저장·재사용이 허용된 method가 아닙니다.
method 속성은 자동 retry와 prefetch, cache 정책의 출발점이다. 실제 operation도 선언한 의미를 지켜야 한다.
안전성·멱등성·캐시 가능성
안전한 메서드는 클라이언트가 원 서버의 상태 변경을 요청하지 않는 메서드입니다.
접근 로그, 조회 메트릭, 광고 과금처럼 서버가 부수적으로 남기는 효과가 있다는 이유만으로 안전성이 깨지지는 않습니다.
반면 GET /api/posts/42/delete가 게시글을 삭제하게 만들면 링크 검사기나 미리 가져오기가 업무 상태를 바꿀 수 있으므로 GET의 의미를 위반합니다.
멱등한 메서드는 동일한 요청을 여러 번 적용했을 때 사용자가 요청한 서버 효과가 한 번 적용한 것과 같습니다.
첫 DELETE가 204, 두 번째 DELETE가 404여도 최종 의도 상태가 모두 “리소스 없음”이면 응답이 다르다는 이유만으로 멱등성이 깨지지 않습니다.
PUT 때마다 접근 로그나 감사 이력이 추가되는 것도 허용됩니다.
다만 같은 표현을 PUT할 때마다 리소스 자체의 버전이나 업무 수정 시각을 불필요하게 바꾸는 것은 애플리케이션이 약속한 최종 상태를 흔들 수 있으므로 별도의 설계 판단이 필요합니다.
IANA HTTP Method Registry의 안전·멱등 등록값과 각 메서드 규격을 함께 보면 다음과 같습니다.
| Method | 안전 | 멱등 | 응답 캐시 가능성 |
|---|---|---|---|
| GET | 예 | 예 | 가능. 실제 저장·재사용은 캐시 조건에 따름 |
| HEAD | 예 | 예 | 가능. 저장된 GET 응답의 메타데이터에도 영향을 줄 수 있음 |
| OPTIONS | 예 | 예 | 캐시 불가 |
| TRACE | 예 | 예 | 캐시 불가 |
| PUT | 아니오 | 예 | 캐시 불가 |
| DELETE | 아니오 | 예 | 캐시 불가 |
| POST | 아니오 | 아니오 | 명시적 최신성과 같은 대상 URI의 Content-Location이 있을 때만 가능 |
| PATCH | 아니오 | 아니오 | RFC 5789의 명시적 조건을 갖춘 응답만 가능 |
| CONNECT | 아니오 | 아니오 | 캐시 불가 |
PATCH 자체는 멱등 메서드로 등록되어 있지 않습니다.
문장을 뒤에 추가하는 패치는 반복할수록 값이 바뀌지만 content를 이 값으로 교체하는 애플리케이션 작업은 멱등하게 설계할 수 있습니다.
메서드의 표준 속성과 특정 작업이 추가로 제공하는 보장을 구분해야 합니다.
HEAD는 GET과 같은 선택 표현의 메타데이터를 본문 없이 얻는 용도입니다.
서버는 표현을 실제로 생성할 때만 알 수 있는 일부 필드를 생략할 수 있으므로 “항상 GET 응답에서 본문만 제거한다”라고 단정하지 않고 RFC 9110의 HEAD 계약을 지킵니다.
캐시 가능하다는 말의 범위
캐시 가능성은 응답을 저장할 수 있는 규격상 자격이지 저장이나 재사용의 보장이 아닙니다.
RFC 9111의 저장 조건에 따라 메서드와 상태를 이해할 수 있어야 하고, no-store나 공유 캐시의 private 제한이 없어야 하며, 명시적인 최신성 정보나 휴리스틱 캐시가 허용되는 상태 코드 등 저장을 허용하는 조건을 갖춰야 합니다.
저장 자격과 지금 재사용해도 되는지는 다릅니다. GET의 200 OK에 max-age, Expires, 검증기가 없다고 해서 표준상 항상 저장 금지는 아닙니다. 다른 저장 제한이 없으면 휴리스틱 최신성을 계산할 수 있습니다. 다만 캐시가 반드시 저장하거나 충분히 긴 최신성을 부여한다는 보장은 없습니다. 저장 후 재사용에는 Vary 일치, 최신 여부, 필요한 검증 등 별도의 조건이 적용됩니다.
Authorization이 있는 요청의 응답도 무조건 저장 금지는 아닙니다.
공유 캐시가 재사용하려면 public, s-maxage, must-revalidate처럼 규격이 허용하는 명시적 지시가 필요합니다.
POST 응답은 명시적 최신성 정보와 POST 대상 URI와 같은 값의 Content-Location이 모두 있어야 이후 GET이나 HEAD에 재사용할 수 있습니다.
PATCH 응답도 명시적 최신성과 요청 URI와 일치하는 Content-Location을 함께 가질 때만 캐시할 수 있고, 저장한 응답은 후속 GET이나 HEAD에만 재사용할 수 있습니다.
대부분의 캐시 구현은 GET과 HEAD만 지원하므로 POST 캐시를 일반적인 API 동작으로 기대하지 않습니다.
성공한 PUT·POST·DELETE 같은 안전하지 않은 요청은 그 요청이 지나간 캐시에서 대상 URI의 저장 응답을 무효화합니다.
Vary와 콘텐츠 협상은 콘텐츠 협상에서, 검증기·304 Not Modified·표현별 캐시 키는 HTTP 캐시와 조건부 요청에서 다룹니다.
응답을 읽지 못한 요청
클라이언트가 응답을 읽지 못했다는 사실만으로 원 요청이 적용되지 않았다고 결론 내릴 수 없습니다.
POST의 DB 트랜잭션이 커밋된 뒤 응답 쓰기만 실패했다면 같은 POST는 두 번째 리소스를 만들 수 있습니다.
DNS 조회 오류나 연결 오류도 오류 이름만으로 일괄 판정하지 않습니다.
사용한 전송 스택이 “요청이 원 서버에 적용되지 않았다”는 사실을 입증한 경우와, 전송·처리 여부가 불명인 경우를 구분합니다.
클라이언트가 생성할 대상 URI를 정할 수 있고 전체 상태 대체가 의미에 맞으면 PUT을 고려할 수 있습니다.
서버가 새 URI를 정하는 컬렉션 POST가 맞다면 애플리케이션이 중복 억제 계약을 별도로 제공해야 합니다.
제한된 자동 재시도
RFC 9110의 멱등성 규칙은 통신 실패로 응답을 읽지 못한 멱등 요청의 재전송을 허용합니다.
비멱등 요청은 작업 자체가 멱등하다는 별도 지식이 있거나 원 요청이 적용되지 않았음을 판별할 수 있을 때만 자동 재시도합니다.
프록시는 비멱등 요청을 자동으로 재시도해서는 안 되며, 클라이언트도 실패한 자동 재시도를 다시 자동 재시도하지 않는 것이 기본입니다.
이 문서의 예제 정책은 그래서 자동 재시도를 한 번으로 제한합니다.
| 관찰 | 동일 요청 정책 |
|---|---|
| 응답 없는 GET·PUT·DELETE | 한 번의 자동 재시도 후보이지만 전체 기한을 넘으면 중단 |
| 응답 없는 POST·PATCH | 문서화된 애플리케이션 멱등 계약이 없으면 중단 |
| 400·401·403 | 같은 요청은 중단하고 입력·자격·권한을 고친 뒤 새 판단 |
| 429 | RFC 6585의 선택적 Retry-After와 서버 정책을 확인 |
| 503 | Retry-After가 있으면 기다릴 최소 시간을 반영 |
Retry-After는 지연 초 또는 HTTP 날짜일 수 있습니다.
지연 초는 부호 없이 숫자로만 적습니다. 유효하지 않거나 없으면 클라이언트가 정한 backoff를 쓰고, 동시 재시도 집중을 줄이는 jitter를 더하되 다음 시도 시각이 전체 deadline 안에 있을 때만 재시도합니다. 시간 표현 범위를 넘는 대기도 전체 deadline을 넘는 경우와 같이 중단합니다.
다음 Java 17 단위는 실제 네트워크를 흉내 내지 않고 이 클라이언트 판단 함수만 결정적으로 실행합니다.
예시
원 정책에 GET, 상태 503, Retry-After 3초, jitter 0.1초, 이미 실행한 자동 재시도 0회를 넣었다고 가정합니다. 먼저 GET은 멱등 메서드이고 503은 이 정책의 재시도 후보입니다. 대기는 3 + 0.1 = 3.1초입니다. deadline이 현재로부터 5초 뒤라면 다음 시도 시작은 그보다 이르므로 retry가 true입니다.
이 함수는 판단 결과와 지연 값을 반환할 뿐 기다리거나 요청을 보내지 않습니다. 연결·읽기 제한 시간과 요청 종료까지의 남은 예산은 호출하는 전송 계층이 따로 적용해야 합니다. retry가 true라는 값만으로 재시도 전체가 deadline 전에 끝난다고 결론 내리지 마세요.
HTTP 날짜와 예제 파서의 범위
RFC 9110의 날짜 형식에 맞춰 날짜 예시의 일자는 01처럼 두 자리로 적습니다. 아래 DateTimeFormatter.RFC_1123_DATE_TIME 호출은 선택한 날짜 사례를 확인하는 JDK 도구이며, HTTP 수신자가 지원해야 하는 세 날짜 형식을 모두 구현한 파서는 아닙니다. 실제 전송 스택에서는 HTTP-date 수신 계약을 별도로 충족해야 합니다.
package board.http;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
import java.time.Instant;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.Test;
final class HttpMethodPolicyTest {
private static final Duration BASE_BACKOFF =
Duration.ofMillis(200);
private static final Map<String, MethodTraits> METHODS =
Map.ofEntries(
Map.entry("GET", traits(true, true,
CacheRule.ELIGIBLE)),
Map.entry("HEAD", traits(true, true,
CacheRule.ELIGIBLE)),
Map.entry("OPTIONS", traits(true, true,
CacheRule.NOT_CACHEABLE)),
Map.entry("TRACE", traits(true, true,
CacheRule.NOT_CACHEABLE)),
Map.entry("PUT", traits(false, true,
CacheRule.NOT_CACHEABLE)),
Map.entry("DELETE", traits(false, true,
CacheRule.NOT_CACHEABLE)),
Map.entry("POST", traits(false, false,
CacheRule.EXPLICIT_FRESHNESS_AND_CONTENT_LOCATION)),
Map.entry("PATCH", traits(false, false,
CacheRule.EXPLICIT_FRESHNESS_AND_CONTENT_LOCATION)),
Map.entry("CONNECT", traits(false, false,
CacheRule.NOT_CACHEABLE)));
@Test
void method_matrix_matches_the_registered_contracts() {
assertAll(
() -> assertEquals(
traits(true, true, CacheRule.ELIGIBLE),
METHODS.get("GET")),
() -> assertEquals(
traits(true, true, CacheRule.ELIGIBLE),
METHODS.get("HEAD")),
() -> assertEquals(
traits(true, true, CacheRule.NOT_CACHEABLE),
METHODS.get("OPTIONS")),
() -> assertEquals(
traits(true, true, CacheRule.NOT_CACHEABLE),
METHODS.get("TRACE")),
() -> assertEquals(
traits(false, true, CacheRule.NOT_CACHEABLE),
METHODS.get("PUT")),
() -> assertEquals(
traits(false, true, CacheRule.NOT_CACHEABLE),
METHODS.get("DELETE")),
() -> assertEquals(
traits(false, false,
CacheRule.EXPLICIT_FRESHNESS_AND_CONTENT_LOCATION),
METHODS.get("POST")),
() -> assertEquals(
traits(false, false,
CacheRule.EXPLICIT_FRESHNESS_AND_CONTENT_LOCATION),
METHODS.get("PATCH")),
() -> assertEquals(
traits(false, false,
CacheRule.NOT_CACHEABLE),
METHODS.get("CONNECT")));
}
@Test
void a_no_response_get_gets_only_one_bounded_retry() {
var now = Instant.parse("2026-08-26T00:00:00Z");
var first = decide(context(
"GET", false, null, 0, now,
now.plusSeconds(5), null,
Duration.ofMillis(50)));
var second = decide(context(
"GET", false, null, 1, now,
now.plusSeconds(5), null,
Duration.ofMillis(50)));
assertAll(
() -> assertTrue(first.retry()),
() -> assertEquals(
Duration.ofMillis(250), first.delay()),
() -> assertFalse(second.retry()));
}
@Test
void a_non_idempotent_method_needs_an_explicit_retry_basis() {
var now = Instant.parse("2026-08-26T00:00:00Z");
var ordinaryPost = decide(context(
"POST", false, null, 0, now,
now.plusSeconds(5), null, Duration.ZERO));
var profiledPost = decide(context(
"POST", true, null, 0, now,
now.plusSeconds(5), null, Duration.ZERO));
var knownNotAppliedPost = decide(context(
"POST", false, true, null, 0, now,
now.plusSeconds(5), null, Duration.ZERO));
assertAll(
() -> assertFalse(ordinaryPost.retry()),
() -> assertTrue(profiledPost.retry()),
() -> assertTrue(knownNotAppliedPost.retry()));
}
@Test
void the_same_400_401_or_403_request_stops() {
var now = Instant.parse("2026-08-26T00:00:00Z");
for (int status : new int[] {400, 401, 403}) {
var decision = decide(context(
"GET", false, status, 0, now,
now.plusSeconds(5), null, Duration.ZERO));
assertFalse(decision.retry(), "status=" + status);
}
}
@Test
void retry_after_backoff_and_jitter_stay_inside_the_deadline() {
var now = Instant.parse("2026-01-01T00:00:00Z");
var retry429 = decide(context(
"GET", false, 429, 0, now,
now.plusSeconds(10), "3",
Duration.ofMillis(100)));
var retry503 = decide(context(
"PUT", false, 503, 0, now,
now.plusSeconds(10),
"Thu, 01 Jan 2026 00:00:04 GMT",
Duration.ofMillis(100)));
var pastDeadline = decide(context(
"GET", false, 503, 0, now,
now.plusSeconds(3), "3",
Duration.ofMillis(100)));
assertAll(
() -> assertTrue(retry429.retry()),
() -> assertEquals(
Duration.ofMillis(3100),
retry429.delay()),
() -> assertTrue(retry503.retry()),
() -> assertEquals(
Duration.ofMillis(4100),
retry503.delay()),
() -> assertFalse(pastDeadline.retry()));
}
private static RetryDecision decide(RetryContext context) {
var traits = METHODS.get(context.method());
if (traits == null) {
return RetryDecision.stop("unknown method");
}
if (context.automaticRetries() >= 1) {
return RetryDecision.stop("automatic retry exhausted");
}
if (context.status() != null
&& (context.status() == 400
|| context.status() == 401
|| context.status() == 403)) {
return RetryDecision.stop("same request needs correction");
}
if (!traits.idempotent()
&& !context.applicationOperationIdempotent()
&& !context.knownNotApplied()) {
return RetryDecision.stop("unsafe to repeat");
}
if (context.status() != null
&& context.status() != 429
&& context.status() != 503) {
return RetryDecision.stop("status is not retryable");
}
if (context.jitter().isNegative()) {
return RetryDecision.stop("negative jitter");
}
try {
var delay = parseRetryAfter(
context.retryAfter(), context.now())
.orElse(BASE_BACKOFF)
.plus(context.jitter());
if (!context.now().plus(delay)
.isBefore(context.deadline())) {
return RetryDecision.stop("overall deadline");
}
return new RetryDecision(true, delay, "bounded retry");
} catch (java.time.DateTimeException | ArithmeticException outOfRange) {
return RetryDecision.stop("overall deadline");
}
}
private static Optional<Duration> parseRetryAfter(
String value, Instant now) {
if (value == null || value.isBlank()) {
return Optional.empty();
}
if (value.matches("[0-9]+")) {
try {
return Optional.of(Duration.ofSeconds(Long.parseLong(value)));
} catch (NumberFormatException outOfRange) {
return Optional.of(Duration.ofSeconds(Long.MAX_VALUE));
}
}
try {
var retryAt = ZonedDateTime.parse(
value,
DateTimeFormatter.RFC_1123_DATE_TIME)
.toInstant();
var delay = Duration.between(now, retryAt);
return Optional.of(
delay.isNegative() ? Duration.ZERO : delay);
} catch (RuntimeException invalidHttpDate) {
return Optional.empty();
}
}
private static RetryContext context(
String method,
boolean applicationOperationIdempotent,
Integer status,
int automaticRetries,
Instant now,
Instant deadline,
String retryAfter,
Duration jitter) {
return new RetryContext(
method,
applicationOperationIdempotent,
false,
status,
automaticRetries,
now,
deadline,
retryAfter,
jitter);
}
private static RetryContext context(
String method,
boolean applicationOperationIdempotent,
boolean knownNotApplied,
Integer status,
int automaticRetries,
Instant now,
Instant deadline,
String retryAfter,
Duration jitter) {
return new RetryContext(
method,
applicationOperationIdempotent,
knownNotApplied,
status,
automaticRetries,
now,
deadline,
retryAfter,
jitter);
}
private static MethodTraits traits(
boolean safe,
boolean idempotent,
CacheRule cacheRule) {
return new MethodTraits(safe, idempotent, cacheRule);
}
private enum CacheRule {
ELIGIBLE,
EXPLICIT_FRESHNESS_AND_CONTENT_LOCATION,
NOT_CACHEABLE
}
private record MethodTraits(
boolean safe,
boolean idempotent,
CacheRule cacheRule) {
}
private record RetryContext(
String method,
boolean applicationOperationIdempotent,
boolean knownNotApplied,
Integer status,
int automaticRetries,
Instant now,
Instant deadline,
String retryAfter,
Duration jitter) {
}
private record RetryDecision(
boolean retry,
Duration delay,
String reason) {
private static RetryDecision stop(String reason) {
return new RetryDecision(false, Duration.ZERO, reason);
}
}
}다음 시도 시각을 먼저 계산하기
위 예시에서 다른 입력은 그대로 두고 deadline을 현재로부터 3.1초 뒤와 3.2초 뒤로 각각 바꾸면 retry는 무엇일까요? 이어 3.2초 기한을 유지하되 automaticRetries가 이미 1이면 어떤 결과인지 판단합니다. 실제 기다림 없이 조건과 비교 연산만 따라가세요.
기한과 횟수의 해설
다음 시작 시각이 정확히 3.1초이므로 같은 3.1초 deadline에서는 isBefore가 false여서 중단합니다. 3.2초 deadline에는 0.1초 앞서 시작할 수 있어 후보가 됩니다. 그러나 자동 재시도를 이미 한 번 수행했다면 횟수 제한에서 먼저 중단합니다. 0.1초가 남았다는 사실은 다음 HTTP 요청을 끝낼 충분한 예산이라는 뜻이 아닙니다.
POST와 애플리케이션 멱등성 기록
Idempotency-Key는 HTTP 핵심 표준 헤더가 아닙니다.
draft-ietf-httpapi-idempotency-key-header-07은 2026년 4월 18일 만료된 Internet-Draft이며 RFC가 아닙니다.
따라서 이 문서의 예제는 그 초안에서 착안해 서버와 클라이언트가 명시적으로 합의한 API 프로필입니다.
프로필은 다음 계약을 문서화합니다.
- 헤더 값은 RFC 8941 문자열 중 이 API가 허용한 따옴표 형식과 문자 집합을 사용합니다.
- 범위는 클라이언트 본문이나 임의 헤더가 아니라 인증 계층이 검증한
memberId, 작업 식별자, key의 튜플입니다. - 역직렬화한 업무 필드를 길이와 함께 해시해 안정적인 payload fingerprint를 만듭니다.
- 첫 요청은 범위에
PENDING을 원자적으로 삽입한 뒤 업무 효과를 수행합니다. - 같은 범위와 다른 fingerprint는
422 Unprocessable Content로 거부합니다. - 같은 범위와 같은 fingerprint가 아직
PENDING이면409 Conflict로 응답합니다. COMPLETED이면 최초 응답의 상태,Location, JSON snapshot을 다시 돌려줍니다.- 완료 기록의 보존 기간과 만료 뒤에는 replay를 보장하지 않는다는 사실을 공개합니다.
이 422와 409 구분도 만료 초안의 제안을 채택한 이 API의 계약이지 모든 서버에 강제되는 HTTP 일반 규칙은 아닙니다.
모호한 POST 재시도는 멱등성 기록과 전체 deadline으로 제한한다
같은 Scope에서 기록 없음·PENDING 고유 삽입·경합 재조회·담당의 업무/snapshot 확정으로 상태가 변한다.
AMBIGUOUS COMMIT · APPLICATION IDEMPOTENCY · RETRY BUDGET
모호한 POST 재시도는 멱등성 기록과 전체 deadline으로 제한한다
timeout은 실패 결과가 아니라 결과를 모른다는 관측일 수 있습니다. application이 같은 operation을 하나의 원자적 기록에 연결하고, client는 그 보장과 남은 전체 deadline이 있을 때만 제한적으로 재시도해야 합니다.
01 · COMMIT BOUNDARY
응답을 받지 못해도 business commit은 이미 끝났을 수 있다
client가 최초
POST를 보낸다server는 새 resource를 만들기 위한 transaction을 시작합니다.
business transaction이 commit된다
resource 42가 durable state가 되어 최초 operation은 성공합니다.commit 뒤 response가 유실된다
network 경계에서 성공 응답이 client에 도달하지 않습니다.
client에는 결과를 모르는 상태만 남는다
같은
POST를 그대로 실행하면resource 43을 만들 수 있습니다. timeout·DNS·connect 오류만으로 원 요청이 적용되지 않았다고 단정할 수 없습니다.
02 · OPERATION RECORD
verified principal·operation·key가 하나의 처리 slot을 식별한다
- Composite scope — 인증 경계에서 검증한
memberId+ operation 이름 +Idempotency-Key - Request identity — 역직렬화한
title·content의 길이와 UTF-8 값을 해시한 fingerprint - State machine — 최초 claim의
PENDING과 처리가 끝난COMPLETED - Completed record — status·
Location·JSON body의 result snapshot과 문서화된 expiry
Idempotency-Key는 core HTTP가 보장하는 성질이 아닙니다. 만료된 HTTPAPI Internet-Draft에 착안해 해당 application API가 scope·fingerprint·expiry·replay를 문서화하고 구현하는 profile입니다.
03 · ATOMIC LOOKUP
원자적 lookup·claim이 네 결과 중 하나를 고른다
- UNSEEN key가 없으면
PENDING을 claim하고 business operation을 한 번만 실행합니다. business result와 정확한 response snapshot은 하나의 durable transaction 또는 동등한 복구 가능한 프로토콜로COMPLETED로 확정합니다. - 422 같은 key인데 업무 필드 fingerprint가 다르면 다른 payload의 재사용이므로 거부합니다.
- 409 같은 key·fingerprint의 record가 아직
PENDING이면 진행 중임을 알리고 두 번째 business execution을 시작하지 않습니다. - REPLAY 같은 key·fingerprint가
COMPLETED이면 저장한 이전 status·Location·JSON body를 반환합니다.
이 API profile이 key를 필수로 정했다면 누락되거나 형식이 잘못된 key는 business operation 전에 400으로 중단합니다.
04 · RETRY GATE · ONE DEADLINE
재시도 가능성과 시간 budget을 모두 통과해야 한다
operation semantics를 먼저 확인한다
method 자체가 멱등이거나, application idempotency profile이 같은 operation을 멱등으로 만들거나, client가 원 요청이 적용되지 않았음을 확실히 아는 경우에만 retry gate를 엽니다.
자동 재시도는 최대 한 번만 허용한다
Retry-After또는 backoff + jitter로 다음 시점을 정하되, 최초 시도부터 공유한 overall deadline의 남은 시간 안에서만 실행합니다.반복해도 달라지지 않는 응답은 즉시 멈춘다
요청을 바꾸지 않은 400·401·403은 중단합니다. 429·503도 retry gate, server의
Retry-After, 남은 deadline을 모두 만족할 때만 한 번 재시도합니다.
운영 계약: 최초 POST → 원자적 PENDING claim → business state와 COMPLETED snapshot의 복구 가능한 확정. 문서의 ConcurrentHashMap fixture는 단일 프로세스 상태 전이만 검증합니다. 완료 기록 보존 기간 안에서 같은 scope·fingerprint의 재시도는 저장 결과로 수렴하며, 만료 뒤 replay는 보장하지 않습니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 회원·작업·key Scope와 유효한 key 형식이 먼저 정해졌다고 가정한다. 없는 기록이나 제거된 만료 기록이면 PENDING 고유 삽입으로 담당을 정한다. 경합에서 졌으면 같은 Scope를 다시 조회하고, 성공한 담당만 업무 효과와 응답 snapshot을 확정한다. 이미 유효 기록이 있으면 다음 그림의 fingerprint와 완료 여부를 판단한다. 그림의 업무와 snapshot 묶음은 운영에서 필요한 원자적 또는 복구 가능한 계약이며 in-memory fixture의 외부 저장 증명은 아니다.
- 첫 그림에서 같은 Scope의 만료되지 않은 기록을 찾은 뒤의 부분 흐름이다. 다른 fingerprint면 422, 같은 fingerprint지만 snapshot이 없으면 409, 확정 snapshot이 있으면 최초 상태·Location·JSON을 재현한다. HTTP 표준 전체의 보편적인 422·409 분류가 아니라 이 API 프로필의 계약이다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 21 | 첫 그림에서 같은 Scope의 만료되지 않은 기록을 찾은 뒤의 부분 흐름이다. 다른 fingerprint면 422, 같은 fingerprint지만 snapshot이 없으면 409, 확정 snapshot이 있으면 최초 상태·Location·JSON을 재현한다. HTTP 표준 전체의 보편적인 422·409 분류가 아니라 이 API 프로필의 계약이다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| Scope 기록 조회 회원·작업·유효 key Scope에서 기록을 찾습니다. | 없음·제거 만료: 삽입 후보 · 있음: 별도 fingerprint 판정 | 현재 key의 업무 소유를 결정할 입력이 됩니다. |
| PENDING 경합 고유 PENDING 삽입의 승자가 담당이고 패자는 같은 Scope를 재조회합니다. | 승자: 업무 담당 · 패자: 재조회 | 한 담당만 새 효과를 확정할 수 있습니다. |
| 업무와 snapshot 담당이 업무 효과·응답 snapshot을 확정합니다. | 확정: 효과·snapshot · 운영: 원자·복구 계약 필요 | 모호한 재요청에서 재사용할 기록이 생깁니다. |
모호한 POST 재시도는 멱등성 기록과 전체 deadline으로 제한한다
같은 Scope에서 기록 없음·PENDING 고유 삽입·경합 재조회·담당의 업무/snapshot 확정으로 상태가 변한다.
AMBIGUOUS COMMIT · APPLICATION IDEMPOTENCY · RETRY BUDGET
모호한 POST 재시도는 멱등성 기록과 전체 deadline으로 제한한다
timeout은 실패 결과가 아니라 결과를 모른다는 관측일 수 있습니다. application이 같은 operation을 하나의 원자적 기록에 연결하고, client는 그 보장과 남은 전체 deadline이 있을 때만 제한적으로 재시도해야 합니다.
01 · COMMIT BOUNDARY
응답을 받지 못해도 business commit은 이미 끝났을 수 있다
client가 최초
POST를 보낸다server는 새 resource를 만들기 위한 transaction을 시작합니다.
business transaction이 commit된다
resource 42가 durable state가 되어 최초 operation은 성공합니다.commit 뒤 response가 유실된다
network 경계에서 성공 응답이 client에 도달하지 않습니다.
client에는 결과를 모르는 상태만 남는다
같은
POST를 그대로 실행하면resource 43을 만들 수 있습니다. timeout·DNS·connect 오류만으로 원 요청이 적용되지 않았다고 단정할 수 없습니다.
02 · OPERATION RECORD
verified principal·operation·key가 하나의 처리 slot을 식별한다
- Composite scope — 인증 경계에서 검증한
memberId+ operation 이름 +Idempotency-Key - Request identity — 역직렬화한
title·content의 길이와 UTF-8 값을 해시한 fingerprint - State machine — 최초 claim의
PENDING과 처리가 끝난COMPLETED - Completed record — status·
Location·JSON body의 result snapshot과 문서화된 expiry
Idempotency-Key는 core HTTP가 보장하는 성질이 아닙니다. 만료된 HTTPAPI Internet-Draft에 착안해 해당 application API가 scope·fingerprint·expiry·replay를 문서화하고 구현하는 profile입니다.
03 · ATOMIC LOOKUP
원자적 lookup·claim이 네 결과 중 하나를 고른다
- UNSEEN key가 없으면
PENDING을 claim하고 business operation을 한 번만 실행합니다. business result와 정확한 response snapshot은 하나의 durable transaction 또는 동등한 복구 가능한 프로토콜로COMPLETED로 확정합니다. - 422 같은 key인데 업무 필드 fingerprint가 다르면 다른 payload의 재사용이므로 거부합니다.
- 409 같은 key·fingerprint의 record가 아직
PENDING이면 진행 중임을 알리고 두 번째 business execution을 시작하지 않습니다. - REPLAY 같은 key·fingerprint가
COMPLETED이면 저장한 이전 status·Location·JSON body를 반환합니다.
이 API profile이 key를 필수로 정했다면 누락되거나 형식이 잘못된 key는 business operation 전에 400으로 중단합니다.
04 · RETRY GATE · ONE DEADLINE
재시도 가능성과 시간 budget을 모두 통과해야 한다
operation semantics를 먼저 확인한다
method 자체가 멱등이거나, application idempotency profile이 같은 operation을 멱등으로 만들거나, client가 원 요청이 적용되지 않았음을 확실히 아는 경우에만 retry gate를 엽니다.
자동 재시도는 최대 한 번만 허용한다
Retry-After또는 backoff + jitter로 다음 시점을 정하되, 최초 시도부터 공유한 overall deadline의 남은 시간 안에서만 실행합니다.반복해도 달라지지 않는 응답은 즉시 멈춘다
요청을 바꾸지 않은 400·401·403은 중단합니다. 429·503도 retry gate, server의
Retry-After, 남은 deadline을 모두 만족할 때만 한 번 재시도합니다.
운영 계약: 최초 POST → 원자적 PENDING claim → business state와 COMPLETED snapshot의 복구 가능한 확정. 문서의 ConcurrentHashMap fixture는 단일 프로세스 상태 전이만 검증합니다. 완료 기록 보존 기간 안에서 같은 scope·fingerprint의 재시도는 저장 결과로 수렴하며, 만료 뒤 replay는 보장하지 않습니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 회원·작업·key Scope와 유효한 key 형식이 먼저 정해졌다고 가정한다. 없는 기록이나 제거된 만료 기록이면 PENDING 고유 삽입으로 담당을 정한다. 경합에서 졌으면 같은 Scope를 다시 조회하고, 성공한 담당만 업무 효과와 응답 snapshot을 확정한다. 이미 유효 기록이 있으면 다음 그림의 fingerprint와 완료 여부를 판단한다. 그림의 업무와 snapshot 묶음은 운영에서 필요한 원자적 또는 복구 가능한 계약이며 in-memory fixture의 외부 저장 증명은 아니다.
- 첫 그림에서 같은 Scope의 만료되지 않은 기록을 찾은 뒤의 부분 흐름이다. 다른 fingerprint면 422, 같은 fingerprint지만 snapshot이 없으면 409, 확정 snapshot이 있으면 최초 상태·Location·JSON을 재현한다. HTTP 표준 전체의 보편적인 422·409 분류가 아니라 이 API 프로필의 계약이다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 21 | 첫 그림에서 같은 Scope의 만료되지 않은 기록을 찾은 뒤의 부분 흐름이다. 다른 fingerprint면 422, 같은 fingerprint지만 snapshot이 없으면 409, 확정 snapshot이 있으면 최초 상태·Location·JSON을 재현한다. HTTP 표준 전체의 보편적인 422·409 분류가 아니라 이 API 프로필의 계약이다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| Scope 기록 조회 회원·작업·유효 key Scope에서 기록을 찾습니다. | 없음·제거 만료: 삽입 후보 · 있음: 별도 fingerprint 판정 | 현재 key의 업무 소유를 결정할 입력이 됩니다. |
| PENDING 경합 고유 PENDING 삽입의 승자가 담당이고 패자는 같은 Scope를 재조회합니다. | 승자: 업무 담당 · 패자: 재조회 | 한 담당만 새 효과를 확정할 수 있습니다. |
| 업무와 snapshot 담당이 업무 효과·응답 snapshot을 확정합니다. | 확정: 효과·snapshot · 운영: 원자·복구 계약 필요 | 모호한 재요청에서 재사용할 기록이 생깁니다. |
긴 테스트를 따라가는 순서
HttpMethodPolicyTest는 JDK와 JUnit만으로 판단 함수를 확인하고, 아래 MVC 단위는 앞 절의 Java 17·Spring 6.2.11 별도 구성과 Jackson을 사용합니다. 아래 fixture의 인증은 요청 attribute에 이미 검증된 회원 ID를 넣었다고 가정합니다. 이 attribute를 만드는 실제 인증 기능을 구현했다는 뜻은 아닙니다.
먼저 Scope의 회원·작업·key를 찾고, fingerprint가 역직렬화한 title·content를 해시하는 지점을 읽으세요. 그다음 PENDING의 고유 삽입, snapshot이 없는 동안의 409, 완성된 snapshot의 재현 순서로 첫 요청과 후속 요청을 비교합니다. JSON 공백이 달라도 해석한 두 업무 값이 같으면 이 프로필에서는 같은 fingerprint가 됩니다.
필드 길이를 먼저 넣는 이유
title="ab", content="c"와 title="a", content="bc"를 값만 이어 붙이면 둘 다 abc가 됩니다. 이 예제는 각 필드의 UTF-8 바이트 길이를 4바이트 정수로 먼저 기록하므로 입력이 2 + ab + 1 + c와 1 + a + 2 + bc로 구별됩니다. 해시가 읽는 필드 경계를 보존하는 장치입니다. 회원 ID는 이 fingerprint에 넣지 않고 바깥 Scope로 구분하므로, 같은 업무 값이어도 서로 다른 회원의 기록을 재사용하지 않습니다.
다음 Spring Framework 6.2.11 단위는 MockMvcBuilders.standaloneSetup으로 mock Servlet 요청부터 MVC 매핑, 컨트롤러, 예외 응답, JSON 변환까지만 실행합니다.
실제 TCP 연결, 프록시의 자동 재시도, 응답 유실은 실행하지 않으므로 앞의 전송 실패를 증명하는 테스트는 아닙니다.
package board.web;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.springframework.http.MediaType.APPLICATION_JSON;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.nio.ByteBuffer;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.HexFormat;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.regex.Pattern;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.http.converter.json.ProblemDetailJacksonMixin;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.bind.MissingRequestHeaderException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
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;
import org.springframework.web.bind.annotation.RestControllerAdvice;
final class IdempotentRegistrationTest {
private static final ObjectMapper OBJECT_MAPPER =
new ObjectMapper().addMixIn(
ProblemDetail.class,
ProblemDetailJacksonMixin.class);
private static final String VERIFIED_MEMBER_ID =
"verifiedMemberId";
private static final String IDEMPOTENCY_HEADER =
"Idempotency-Key";
private static final String OPERATION =
"POST /api/posts";
private static final String KEY =
"\"register-7-001\"";
private static final String REQUEST_JSON =
"""
{"title":"HTTP method","content":"safe retry"}
""";
private static final String RESPONSE_JSON =
"{\"id\":1,\"title\":\"HTTP method\","
+ "\"content\":\"safe retry\"}";
private static final Instant START =
Instant.parse("2026-08-26T00:00:00Z");
private static final Duration RETENTION =
Duration.ofHours(24);
@Test
void first_request_is_created_and_completed_retry_is_replayed()
throws Exception {
var service = newService();
var mvc = mvc(service);
var first = mvc.perform(
postJson(7L, KEY, REQUEST_JSON))
.andReturn();
var replay = mvc.perform(
postJson(7L, KEY, REQUEST_JSON))
.andReturn();
assertAll(
() -> assertEquals(
201, first.getResponse().getStatus()),
() -> assertEquals(
"/api/posts/1",
first.getResponse().getHeader(
HttpHeaders.LOCATION)),
() -> assertCompatible(
APPLICATION_JSON,
first.getResponse().getContentType()),
() -> assertEquals(
RESPONSE_JSON,
new String(
first.getResponse()
.getContentAsByteArray(),
UTF_8)),
() -> assertEquals(1, service.registrationCount()),
() -> assertEquals(
first.getResponse().getStatus(),
replay.getResponse().getStatus()),
() -> assertEquals(
first.getResponse().getHeader("Location"),
replay.getResponse().getHeader("Location")),
() -> assertArrayEquals(
first.getResponse().getContentAsByteArray(),
replay.getResponse().getContentAsByteArray()));
}
@Test
void the_same_key_with_a_different_payload_is_422()
throws Exception {
var service = newService();
var mvc = mvc(service);
var first = mvc.perform(
postJson(7L, KEY, REQUEST_JSON))
.andReturn();
var mismatch = mvc.perform(postJson(
7L,
KEY,
"""
{"title":"Spring MVC","content":"different"}
"""))
.andReturn();
assertAll(
() -> assertEquals(
201, first.getResponse().getStatus()),
() -> assertEquals(
422, mismatch.getResponse().getStatus()),
() -> assertCompatible(
MediaType.APPLICATION_PROBLEM_JSON,
mismatch.getResponse().getContentType()),
() -> assertEquals(
"IDEMPOTENCY_PAYLOAD_MISMATCH",
readBody(mismatch).path("code").textValue()),
() -> assertEquals(1, service.registrationCount()));
}
@Test
void a_retry_while_the_first_request_is_pending_is_409()
throws Exception {
var entered = new CountDownLatch(1);
var release = new CountDownLatch(1);
Runnable blockFirstCreation = () -> {
entered.countDown();
await(release);
};
var service = new IdempotentRegistrationService(
Clock.fixed(START, ZoneOffset.UTC),
RETENTION,
blockFirstCreation);
var mvc = mvc(service);
var executor = Executors.newFixedThreadPool(2);
try {
var first = executor.submit(() -> service.register(
7L,
KEY,
new CreatePostRequest(
"HTTP method", "safe retry")));
assertTrue(entered.await(5, SECONDS));
var pending = mvc.perform(
postJson(7L, KEY, REQUEST_JSON))
.andReturn();
assertAll(
() -> assertEquals(
409, pending.getResponse().getStatus()),
() -> assertEquals(
"IDEMPOTENCY_REQUEST_PENDING",
readBody(pending)
.path("code").textValue()));
release.countDown();
var completed = first.get(5, SECONDS);
assertAll(
() -> assertFalse(completed.replayed()),
() -> assertEquals(
1L, completed.snapshot().body().id()),
() -> assertEquals(
1, service.registrationCount()));
var replay = mvc.perform(
postJson(7L, KEY, REQUEST_JSON))
.andReturn();
assertAll(
() -> assertEquals(
201, replay.getResponse().getStatus()),
() -> assertEquals(
RESPONSE_JSON,
new String(
replay.getResponse()
.getContentAsByteArray(),
UTF_8)));
} finally {
release.countDown();
executor.shutdownNow();
}
}
@Test
void a_missing_key_is_400_without_creation() throws Exception {
var service = newService();
var mvc = mvc(service);
var missing = mvc.perform(post("/api/posts")
.requestAttr(VERIFIED_MEMBER_ID, 7L)
.contentType(APPLICATION_JSON)
.accept(APPLICATION_JSON)
.content(REQUEST_JSON))
.andReturn();
assertAll(
() -> assertEquals(
400, missing.getResponse().getStatus()),
() -> assertEquals(
"IDEMPOTENCY_KEY_REQUIRED",
readBody(missing).path("code").textValue()),
() -> assertEquals(0, service.registrationCount()));
}
@Test
void an_invalid_unquoted_key_is_400_without_creation()
throws Exception {
var service = newService();
var mvc = mvc(service);
var invalid = mvc.perform(postJson(
7L, "register-7-001", REQUEST_JSON))
.andReturn();
assertAll(
() -> assertEquals(
400, invalid.getResponse().getStatus()),
() -> assertEquals(
"IDEMPOTENCY_KEY_INVALID",
readBody(invalid).path("code").textValue()),
() -> assertEquals(0, service.registrationCount()));
}
@Test
void the_key_scope_uses_the_verified_member() throws Exception {
var service = newService();
var mvc = mvc(service);
var member7 = mvc.perform(
postJson(7L, KEY, REQUEST_JSON))
.andReturn();
var member8 = mvc.perform(
postJson(8L, KEY, REQUEST_JSON))
.andReturn();
assertAll(
() -> assertEquals(
201, member7.getResponse().getStatus()),
() -> assertEquals(
"/api/posts/1",
member7.getResponse().getHeader(
HttpHeaders.LOCATION)),
() -> assertEquals(
201, member8.getResponse().getStatus()),
() -> assertEquals(
"/api/posts/2",
member8.getResponse().getHeader(
HttpHeaders.LOCATION)),
() -> assertEquals(2, service.registrationCount()));
}
@Test
void an_expired_completed_record_no_longer_guarantees_replay() {
var clock = new MutableClock(START, ZoneOffset.UTC);
var service = new IdempotentRegistrationService(
clock, RETENTION, () -> { });
var request = new CreatePostRequest(
"HTTP method", "safe retry");
var first = service.register(7L, KEY, request);
clock.advance(RETENTION);
var afterExpiry = service.register(7L, KEY, request);
assertAll(
() -> assertNotEquals(
first.snapshot().body().id(),
afterExpiry.snapshot().body().id()),
() -> assertEquals(2, service.registrationCount()));
}
private static IdempotentRegistrationService newService() {
return new IdempotentRegistrationService(
Clock.fixed(START, ZoneOffset.UTC),
RETENTION,
() -> { });
}
private static MockMvc mvc(
IdempotentRegistrationService service) {
return MockMvcBuilders
.standaloneSetup(new RegistrationController(service))
.setControllerAdvice(new RegistrationAdvice())
.setMessageConverters(
new MappingJackson2HttpMessageConverter(
OBJECT_MAPPER))
.build();
}
private static JsonNode readBody(MvcResult result)
throws Exception {
return OBJECT_MAPPER.readTree(
result.getResponse().getContentAsByteArray());
}
private static void assertCompatible(
MediaType expected, String actual) {
assertTrue(actual != null);
assertTrue(expected.isCompatibleWith(
MediaType.parseMediaType(actual)));
}
private static MockHttpServletRequestBuilder postJson(
long memberId, String key, String json) {
return post("/api/posts")
.requestAttr(VERIFIED_MEMBER_ID, memberId)
.header(IDEMPOTENCY_HEADER, key)
.contentType(APPLICATION_JSON)
.accept(APPLICATION_JSON)
.content(json);
}
private static void await(CountDownLatch latch) {
try {
if (!latch.await(5, SECONDS)) {
throw new IllegalStateException("latch timeout");
}
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new IllegalStateException(
"interrupted while waiting", exception);
}
}
@RestController
@RequestMapping("/api/posts")
public static final class RegistrationController {
private final IdempotentRegistrationService service;
public RegistrationController(
IdempotentRegistrationService service) {
this.service = service;
}
@PostMapping(
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<PostResponse> register(
@RequestAttribute(VERIFIED_MEMBER_ID)
long memberId,
@RequestHeader(IDEMPOTENCY_HEADER)
String rawKey,
@RequestBody CreatePostRequest request) {
var snapshot = service
.register(memberId, rawKey, request)
.snapshot();
return ResponseEntity
.status(snapshot.status())
.location(URI.create(snapshot.location()))
.body(snapshot.body());
}
}
@RestControllerAdvice
public static final class RegistrationAdvice {
@ExceptionHandler(MissingRequestHeaderException.class)
public ResponseEntity<ProblemDetail> missingKey(
MissingRequestHeaderException exception) {
return problem(
HttpStatus.BAD_REQUEST,
"Idempotency key is required",
"Send a documented Idempotency-Key value.",
"IDEMPOTENCY_KEY_REQUIRED");
}
@ExceptionHandler(InvalidIdempotencyKeyException.class)
public ResponseEntity<ProblemDetail> invalidKey(
InvalidIdempotencyKeyException exception) {
return problem(
HttpStatus.BAD_REQUEST,
"Idempotency key is invalid",
exception.getMessage(),
"IDEMPOTENCY_KEY_INVALID");
}
@ExceptionHandler(PayloadMismatchException.class)
public ResponseEntity<ProblemDetail> payloadMismatch(
PayloadMismatchException exception) {
return problem(
HttpStatus.UNPROCESSABLE_ENTITY,
"Idempotency key was reused",
exception.getMessage(),
"IDEMPOTENCY_PAYLOAD_MISMATCH");
}
@ExceptionHandler(RequestPendingException.class)
public ResponseEntity<ProblemDetail> requestPending(
RequestPendingException exception) {
return problem(
HttpStatus.CONFLICT,
"The first request is still pending",
exception.getMessage(),
"IDEMPOTENCY_REQUEST_PENDING");
}
private static ResponseEntity<ProblemDetail> problem(
HttpStatus status,
String title,
String detail,
String code) {
var problem = ProblemDetail.forStatusAndDetail(
status, detail);
problem.setType(URI.create(
"https://api.example.test/problems/idempotency"));
problem.setTitle(title);
problem.setProperty("code", code);
return ResponseEntity
.status(status)
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.body(problem);
}
}
public static final class IdempotentRegistrationService {
private static final Pattern KEY_CONTENT =
Pattern.compile("[A-Za-z0-9._:-]{1,64}");
private final Clock clock;
private final Duration retention;
private final Runnable beforeCreate;
private final ConcurrentHashMap<Scope, Entry> entries =
new ConcurrentHashMap<>();
private final AtomicLong sequence = new AtomicLong();
private final AtomicInteger creations = new AtomicInteger();
public IdempotentRegistrationService(
Clock clock,
Duration retention,
Runnable beforeCreate) {
this.clock = Objects.requireNonNull(clock);
this.retention = Objects.requireNonNull(retention);
this.beforeCreate = Objects.requireNonNull(beforeCreate);
if (retention.isZero() || retention.isNegative()) {
throw new IllegalArgumentException(
"retention must be positive");
}
}
public RegistrationResult register(
long memberId,
String rawKey,
CreatePostRequest request) {
var key = parseKey(rawKey);
var scope = new Scope(memberId, OPERATION, key);
var fingerprint = fingerprint(request);
while (true) {
var now = clock.instant();
var existing = entries.get(scope);
if (existing == null) {
var candidate = new Entry(fingerprint);
if (entries.putIfAbsent(
scope, candidate) == null) {
return create(
scope, candidate, request);
}
continue;
}
if (existing.isExpired(now)) {
entries.remove(scope, existing);
continue;
}
if (!existing.matches(fingerprint)) {
throw new PayloadMismatchException(
"The key belongs to another payload.");
}
var snapshot = existing.snapshot();
if (snapshot == null) {
throw new RequestPendingException(
"A request with this key is in progress.");
}
return new RegistrationResult(snapshot, true);
}
}
public int registrationCount() {
return creations.get();
}
private RegistrationResult create(
Scope scope,
Entry entry,
CreatePostRequest request) {
try {
beforeCreate.run();
long id = sequence.incrementAndGet();
creations.incrementAndGet();
var response = new PostResponse(
id, request.title(), request.content());
var snapshot = new ResponseSnapshot(
HttpStatus.CREATED.value(),
"/api/posts/" + id,
response);
entry.complete(
snapshot,
clock.instant().plus(retention));
return new RegistrationResult(snapshot, false);
} catch (RuntimeException | Error failure) {
entries.remove(scope, entry);
throw failure;
}
}
private static String parseKey(String rawKey) {
if (rawKey == null
|| rawKey.length() < 3
|| rawKey.charAt(0) != '"'
|| rawKey.charAt(rawKey.length() - 1) != '"') {
throw new InvalidIdempotencyKeyException(
"Use a quoted key from the documented profile.");
}
var value = rawKey.substring(1, rawKey.length() - 1);
if (!KEY_CONTENT.matcher(value).matches()) {
throw new InvalidIdempotencyKeyException(
"The key content is outside the allowed profile.");
}
return value;
}
private static String fingerprint(
CreatePostRequest request) {
try {
var digest = MessageDigest.getInstance("SHA-256");
addField(digest, request.title());
addField(digest, request.content());
return HexFormat.of().formatHex(digest.digest());
} catch (NoSuchAlgorithmException impossible) {
throw new IllegalStateException(
"SHA-256 is required by Java", impossible);
}
}
private static void addField(
MessageDigest digest, String value) {
var bytes = Objects.requireNonNull(value)
.getBytes(UTF_8);
digest.update(ByteBuffer
.allocate(Integer.BYTES)
.putInt(bytes.length)
.array());
digest.update(bytes);
}
}
public record CreatePostRequest(
String title,
String content) {
}
public record PostResponse(
long id,
String title,
String content) {
}
public record ResponseSnapshot(
int status,
String location,
PostResponse body) {
}
public record RegistrationResult(
ResponseSnapshot snapshot,
boolean replayed) {
}
private record Scope(
long memberId,
String operation,
String key) {
}
private static final class Entry {
private final String fingerprint;
private volatile ResponseSnapshot snapshot;
private volatile Instant expiresAt;
private Entry(String fingerprint) {
this.fingerprint = fingerprint;
}
private boolean matches(String candidate) {
return fingerprint.equals(candidate);
}
private ResponseSnapshot snapshot() {
return snapshot;
}
private boolean isExpired(Instant now) {
var expiry = expiresAt;
return expiry != null && !now.isBefore(expiry);
}
private void complete(
ResponseSnapshot response,
Instant expiry) {
snapshot = response;
expiresAt = expiry;
}
}
public static final class InvalidIdempotencyKeyException
extends RuntimeException {
public InvalidIdempotencyKeyException(String message) {
super(message);
}
}
public static final class PayloadMismatchException
extends RuntimeException {
public PayloadMismatchException(String message) {
super(message);
}
}
public static final class RequestPendingException
extends RuntimeException {
public RequestPendingException(String message) {
super(message);
}
}
private static final class MutableClock extends Clock {
private volatile Instant current;
private final ZoneId zone;
private MutableClock(Instant current, ZoneId zone) {
this.current = current;
this.zone = zone;
}
private void advance(Duration duration) {
current = current.plus(duration);
}
@Override
public ZoneId getZone() {
return zone;
}
@Override
public Clock withZone(ZoneId newZone) {
return new MutableClock(current, newZone);
}
@Override
public Instant instant() {
return current;
}
}
}이 단위의 ConcurrentHashMap은 상태 전이를 결정적으로 검증하기 위한 단일 프로세스 test fixture입니다.
운영에서는 (verified memberId, operation, key) 고유 제약, fingerprint, PENDING/COMPLETED 상태, 최초 응답 snapshot, 완료 시각과 만료 시각을 여러 인스턴스가 공유하는 durable 저장소에 둡니다.
업무 효과와 완료 snapshot 저장도 하나의 트랜잭션 경계 또는 그와 동등한 복구 가능한 프로토콜로 묶어야 합니다.
PENDING 상태인 요청을 처리하던 프로세스가 중단됐을 때의 lease와 복구 정책까지 정하지 않으면 영구 대기 기록이 될 수 있습니다.
같은 key의 범위를 판단하기
회원 7이 key R1로 title·content를 보내 성공했고 완료 기록의 보존 기간은 24시간이라고 가정합니다. 다음 네 경우를 각각 독립적으로 판단하세요. 다른 업무 유일성 제약은 없고 원 프로필의 규칙만 적용합니다.
- 회원 7·R1·같은 업무 값을 JSON 공백만 바꾸어 다시 보냅니다.
- 만료 전 회원 7·R1로 content를 바꾸어 보냅니다.
- 회원 9가 R1과 같은 업무 값을 보냅니다.
- 회원 7의 완료 기록이 만료된 뒤 같은 R1과 같은 값을 다시 보냅니다.
확정 결과와 만료의 해설
첫 경우는 업무 값의 fingerprint가 같으므로 보존된 최초 snapshot을 재현하고 생성 횟수는 늘지 않습니다. 두 번째는 같은 범위에 다른 fingerprint여서 422입니다. 세 번째는 회원이 달라 Scope가 다르므로 별도 첫 요청입니다. 네 번째는 이 프로필의 replay 보장이 끝났으므로 새 생성 후보입니다. 완료 기록을 지웠다는 사실이 이미 생성한 게시글을 삭제했다는 뜻은 아닙니다.
운영 설계에서는 업무 효과가 반영된 뒤 snapshot 저장 전에 프로세스가 중단되는 경우에도 대응해야 합니다. fixture의 catch가 맵 항목을 제거한다고 외부 데이터베이스의 반영까지 자동 롤백되는 것은 아닙니다. 앞서 설명한 영속 트랜잭션·복구 계약이 필요한 이유가 이 두 저장 사이의 공백입니다.
경계 확인
- 이 문서는 HTTP 메서드의 의미, 제한된 자동 재시도, 애플리케이션 멱등성 기록을 소유합니다.
- 연결 단계와 “응답 없음이 업무 실패를 뜻하지 않는다”는 전송 경계는 앞 문서의 범위입니다.
201 Created,Location, 리소스 URI 설계는 다음 문서에서 더 자세히 다룹니다.- POST-리다이렉트-GET은 브라우저 새로고침 재제출을 줄이지만 응답 유실 뒤의 네트워크 재시도 중복을 해결하지 않습니다.
Vary와 콘텐츠 협상은 콘텐츠 협상에서, 검증기·304 Not Modified·표현별 캐시 키는 HTTP 캐시와 조건부 요청에서 이어집니다.
연습에서는 test fixture의 맵을 그대로 확장하지 말고, 공유 저장소의 원자적 고유 삽입과 완료 snapshot을 먼저 설계한 뒤 동일 key 동시 요청, 다른 fingerprint, 만료, 프로세스 중단 복구를 각각 검증합니다.
캐시·재시도·요청 식별을 따로 판단하기
다음은 위 예제 정책과 프로필을 그대로 사용합니다. 모든 시각은 같은 시작 시각을 기준으로 계산하고, 캐시는 GET 200 OK를 이해하며 no-store, 공유 캐시의 private, 인증 관련 저장 제한이 없다고 가정합니다.
- GET 응답에
max-age,Expires,ETag가 없습니다. 이것만으로 저장 금지라고 판정할 수 있을까요? 저장 가능성과 즉시 재사용의 보장을 나누어 답하세요. - GET에
503,Retry-After: 3, jitter 0.1초, deadline 3.2초, 자동 재시도 0회를 넣습니다. 판단 함수의 결과와 남는 시작 예산은 무엇인가요? 전송 계층이 다음 요청에 최소 0.2초를 필요로 한다면 전체 기한 안에 완료할 충분한 예산이 있나요? - 같은 회원·작업·key에서 첫 요청은
(title="ab", content="c"), 다음 요청은(title="a", content="bc")입니다. 첫 기록은 완료되었고 만료되지 않았습니다. 단순 연결과 길이 접두어 방식은 무엇이 다르며, 예제 API의 후속 결과는 무엇인가요?
세 판단의 해설
- 그 이유만으로 저장 금지는 아닙니다. 다른 조건을 충족하면 휴리스틱 최신성을 사용할 수 있습니다. 실제 저장 여부·최신성 길이·후속 요청과의 일치·검증 필요성을 추가로 판단해야 하므로 즉시 재사용을 보장하지도 않습니다.
- 다음 시작은 3 + 0.1 = 3.1초이고 3.1 < 3.2이므로 판단 함수는
retry=true, 지연 3.1초입니다. 남은 시간은 0.1초여서 필요한 0.2초보다 작습니다. 시작 후보가 된다는 사실과 기한 안에 요청을 완료할 예산이 있다는 사실은 다릅니다. - 단순 연결은 둘 다
abc여서 경계를 잃지만 길이를 먼저 넣은 두 해시 입력은 다릅니다. 예제에서 계산한 fingerprint도 다르므로 같은 Scope의 다른 업무 값으로 판정하여422를 반환합니다. 이는 이 API 프로필의 계약이며 다른 모든 POST API에 강제되는 규칙은 아닙니다.