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도 선언한 의미를 지켜야 한다.
그림은 메서드를 세 속성으로 나누어 비교합니다. 안전한 메서드는 언제나 멱등하지만 멱등한 메서드가 언제나 안전한 것은 아니고, POST와 PATCH는 멱등하지 않아도 조건을 갖추면 응답을 캐시할 수 있습니다. 또 캐시할 수 있다는 것은 저장해도 된다는 허용일 뿐, 실제로 저장된다는 보장은 아닙니다.
안전성·멱등성·캐시 가능성
안전한(safe) 메서드는 클라이언트가 원 서버의 상태를 바꾸어 달라고 요청하지 않는 메서드입니다. 접근 로그나 조회 수 집계, 광고 과금처럼 서버가 덤으로 남기는 효과가 있다고 안전성이 깨지지는 않습니다. 반면 GET /api/posts/42/delete가 게시글을 지우게 만들면, 링크 검사기나 미리 가져오기가 업무 상태를 바꿀 수 있으므로 GET의 의미를 어기게 됩니다.
멱등한(idempotent) 메서드는 같은 요청을 여러 번 보내도 사용자가 요청한 서버의 효과가 한 번 보낸 것과 같습니다. 첫 DELETE가 204이고 두 번째 DELETE가 404여도, 결과로 남는 상태가 둘 다 "리소스 없음"이면 응답이 다르다는 이유만으로 멱등성이 깨지지 않습니다. PUT을 할 때마다 접근 로그나 감사 기록이 쌓이는 것도 괜찮습니다. 다만 같은 표현을 PUT할 때마다 리소스의 버전이나 업무상의 수정 시각을 쓸데없이 바꾸면, 애플리케이션이 약속한 최종 상태가 흔들릴 수 있으므로 따로 설계를 판단해야 합니다. IANA HTTP 메서드 등록부의 등록값과 각 메서드의 규격을 함께 보면 다음과 같습니다.
| 메서드 | 안전 | 멱등 | 응답 캐시 가능성 |
|---|---|---|---|
| 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의 저장 조건에 따라 캐시는 그 요청 메서드를 이해해야 하고, 응답은 1xx가 아닌 최종 응답이어야 합니다. 캐시가 상태 코드 자체까지 이해해야 하는 것은 상태 코드가 206이나 304이거나 must-understand 지시가 있을 때입니다. 또 no-store나 공유 캐시에 대한 private 제한이 없어야 하며, 명시적인 최신성 정보가 있거나 휴리스틱 최신성이 허용되는 상태 코드처럼 저장을 허락하는 조건을 갖추어야 합니다.
저장할 자격과 지금 다시 써도 되는지는 다른 문제입니다. GET의 200 응답에 max-age나 Expires, 검증기가 없다고 해서 언제나 저장이 금지되는 것은 아니며, 다른 저장 제한이 없다면 캐시는 휴리스틱 최신성을 계산할 수 있습니다. 하지만 캐시가 꼭 저장하거나 충분히 긴 최신성을 준다는 보장은 없고, 저장한 뒤 다시 쓸 때는 Vary가 맞는지, 아직 신선한지, 검증이 필요한지 같은 별도의 조건이 적용됩니다.
Authorization이 있는 요청의 응답도 무조건 저장이 금지되지는 않지만, 공유 캐시가 다시 쓰려면 public, s-maxage, must-revalidate처럼 규격이 허용하는 명시적 지시가 있어야 합니다. POST 응답은 명시적 최신성 정보와, POST 대상 URI와 같은 값의 Content-Location이 모두 있어야 이후의 GET이나 HEAD에 다시 쓸 수 있고, PATCH 응답도 같은 조건을 갖출 때만 이후의 GET이나 HEAD에 쓰입니다. 대부분의 캐시 구현은 GET과 HEAD만 지원하므로, POST 응답의 캐시를 보통의 API 동작으로 기대하지 않습니다. 반대로 PUT, POST, DELETE 같은 안전하지 않은 요청이 성공하면, 그 요청이 지나간 캐시는 대상 URI에 저장해 둔 응답을 무효로 만듭니다. Vary와 콘텐츠 협상은 콘텐츠 협상에서, 검증기와 304, 표현별 캐시 키는 HTTP 캐시와 조건부 요청에서 다룹니다.
응답을 읽지 못한 요청
클라이언트가 응답을 읽지 못했다는 것만으로 원래 요청이 적용되지 않았다고 결론 낼 수 없습니다. POST의 데이터베이스 트랜잭션이 커밋된 뒤 응답을 쓰다가만 실패했다면, 같은 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) 안에 있을 때만 다시 보냅니다. 표현할 수 있는 시간 범위를 넘는 대기도 전체 기한을 넘는 경우처럼 중단합니다.
다음 Java 17 단위는 실제 네트워크를 흉내 내지 않고, 이 클라이언트의 판단 함수만 실행합니다. 예를 들어 GET, 상태 503, Retry-After 3초, 지터 0.1초, 지금까지의 자동 재시도 0회를 넣으면, GET은 멱등하고 503은 재시도 후보이므로 기다릴 시간은 3 + 0.1 = 3.1초입니다. 기한이 지금부터 5초 뒤라면 다음 시도의 시작이 그보다 이르므로 retry는 true가 됩니다. 이 함수는 판단 결과와 기다릴 시간만 돌려줄 뿐 실제로 기다리거나 요청을 보내지 않으며, 연결과 읽기의 제한 시간, 요청이 끝날 때까지 남은 시간은 호출하는 전송 계층이 따로 적용해야 합니다. 날짜 예의 일자는 RFC 9110의 날짜 형식에 맞춰 01처럼 두 자리로 적었습니다. 테스트에서 쓰는 DateTimeFormatter.RFC_1123_DATE_TIME은 고른 날짜 사례를 확인하는 JDK 도구이며, HTTP 수신자가 받아들여야 하는 세 가지 날짜 형식을 모두 구현한 파서는 아닙니다.
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);
}
}
}다섯 테스트는 모두 테스트 안의 decide 함수 하나의 판단을 확인합니다. 첫 테스트는 메서드마다 안전성, 멱등성, 캐시 규칙을 담은 표를 확인하고, 나머지 넷은 응답 없는 GET의 재시도 한 번, 다시 보내도 된다는 정보가 없는 POST의 중단, 400·401·403의 중단, Retry-After와 지터가 기한 안에 드는지를 차례로 확인합니다. decide는 알 수 없는 메서드, 자동 재시도 횟수, 고쳐야 할 상태 코드, 다시 보내도 되는지, 재시도할 수 있는 상태, 기한의 순서로 검사하고 처음 걸린 곳에서 멈춥니다. 그래서 이미 한 번 자동 재시도한 요청은 상태 코드와 상관없이 두 번째 검사에서 중단됩니다.
POST와 애플리케이션 멱등성 기록
Idempotency-Key는 HTTP의 핵심 표준 헤더가 아닙니다. draft-ietf-httpapi-idempotency-key-header는 2026년 10월에 08판이 나온 Internet-Draft이며, 아직 RFC가 아닙니다. 그래서 이 글의 예제는 그 초안에서 착안해 서버와 클라이언트가 분명히 합의한 이 API만의 규칙(profile)입니다. 이 규칙은 다음 내용을 문서로 밝힙니다.
| 항목 | 이 API의 규칙 |
|---|---|
| 헤더 값 | RFC 9651의 구조화 필드 문자열 가운데 이 API가 허용한 따옴표 형식과 문자 집합 |
| 범위 | 클라이언트 본문이나 아무 헤더가 아니라, 인증 계층이 검증한 memberId와 작업 이름, 키의 조합 |
| 요청 지문 | 역직렬화한 업무 필드를 길이와 함께 해시한 안정적인 지문(fingerprint) |
| 첫 요청 | 범위에 PENDING을 원자적으로 넣은 뒤 업무 효과를 실행 |
| 같은 범위, 다른 지문 | 422 Unprocessable Content로 거부 |
같은 범위, 같은 지문, 아직 PENDING | 409 Conflict로 응답 |
COMPLETED | 처음 응답의 상태와 Location, JSON 스냅샷을 다시 돌려줌 |
| 보존 기간 | 완료 기록을 얼마나 보관하는지, 만료 뒤에는 다시 돌려주지 않는다는 것을 공개 |
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 · 운영: 원자·복구 계약 필요 | 모호한 재요청에서 재사용할 기록이 생깁니다. |
그림의 첫 부분은 같은 범위에 유효한 기록이 없을 때 PENDING을 고유하게 넣는 경합에서 이긴 요청만 업무 효과와 응답 스냅샷을 확정하고, 진 요청은 같은 범위를 다시 조회한다는 흐름입니다. 다음 부분은 유효한 기록이 이미 있을 때로, 지문이 다르면 422, 같지만 아직 스냅샷이 없으면 409, 확정된 스냅샷이 있으면 처음 응답을 그대로 돌려줍니다. 아래쪽 단계들은 커밋 뒤에 응답이 사라진 POST가 왜 결과를 모르는 상태가 되는지와, 재시도가 메서드 의미와 한 번의 횟수 제한, 전체 기한을 모두 통과해야 한다는 점을 정리합니다.
HttpMethodPolicyTest는 JDK와 JUnit만으로 판단 함수를 확인했고, 다음 MVC 단위는 앞 글의 Java 17·Spring 6.2.11 별도 구성과 Jackson을 씁니다. 이 픽스처의 인증은 요청 속성에 이미 검증된 회원 식별자가 들어 있다고 가정하며, 그 속성을 만드는 실제 인증 기능을 구현한 것은 아닙니다. 코드를 읽을 때는 먼저 Scope의 회원과 작업, 키를 찾고, 지문이 역직렬화한 title과 content를 해시하는 곳을 봅니다. 그다음 PENDING의 고유 삽입, 스냅샷이 없는 동안의 409, 완성된 스냅샷을 다시 돌려주는 순서로 첫 요청과 이후 요청을 비교합니다. JSON의 공백이 달라도 해석한 두 업무 값이 같으면 이 규칙에서는 같은 지문이 됩니다.
지문을 만들 때 필드의 길이를 먼저 넣는 데는 이유가 있습니다. title="ab", content="c"와 title="a", content="bc"는 값만 이어 붙이면 둘 다 abc가 됩니다. 이 예제는 각 필드의 UTF-8 바이트 길이를 4바이트 정수로 먼저 기록하므로, 두 입력이 2 + ab + 1 + c와 1 + a + 2 + bc로 구별됩니다. 회원 식별자는 지문에 넣지 않고 바깥의 Scope로 구분하므로, 업무 값이 같아도 다른 회원의 기록을 다시 쓰지 않습니다.
다음 Spring Framework 6.2.11 단위는 MockMvcBuilders.standaloneSetup으로 가짜 서블릿 요청부터 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은 상태 전이를 매번 같은 결과로 확인하려는 단일 프로세스의 테스트 픽스처입니다. 운영에서는 검증된 memberId와 작업, 키의 고유 제약 조건, 지문, PENDING과 COMPLETED 상태, 처음 응답의 스냅샷, 완료 시각과 만료 시각을 여러 인스턴스가 함께 쓰는 영속 저장소에 둡니다. 업무 효과와 완료 스냅샷의 저장도 하나의 트랜잭션이나 그와 같은 복구 가능한 절차로 묶어야 합니다. PENDING 요청을 처리하던 프로세스가 중간에 죽었을 때의 임대(lease)와 복구 정책까지 정하지 않으면, 영원히 기다리는 기록이 남을 수 있습니다.
이 글이 다루는 범위
이 글은 HTTP 메서드의 의미와 제한된 자동 재시도, 애플리케이션의 멱등성 기록을 다룹니다. 연결 단계와 "응답이 없다고 업무가 실패한 것은 아니다"라는 전송의 문제는 DNS·IP·TCP·UDP와 연결 경계에서 다루었고, 201과 Location, 리소스 URI 설계는 다음 글에서 더 자세히 다룹니다. POST 뒤에 리다이렉트하고 GET으로 다시 보여 주는 방식(PRG)은 브라우저를 새로 고칠 때의 재전송은 줄여 주지만, 응답이 사라진 뒤 네트워크 재시도로 생기는 중복까지 막아 주지는 않습니다.
정리
- 안전성은 서버 상태를 바꾸어 달라고 요청하지 않는다는 뜻이고, 멱등성은 여러 번 보내도 의도한 효과가 한 번과 같다는 뜻이며, 캐시 가능성은 응답을 저장해도 된다는 허용입니다.
- 멱등성은 응답이 같다는 뜻이 아니므로, 두 번째 DELETE가 404여도 남는 상태가 같으면 멱등합니다.
- 캐시할 수 있는 응답이라도 실제 저장과 재사용은 저장 조건과 최신성,
Vary에 따라 정해집니다. - 응답을 읽지 못했다고 요청이 적용되지 않은 것은 아니므로, 자동 재시도는 멱등한 요청에 한 번, 전체 기한 안에서만 합니다.
- 멱등하지 않은 POST의 재시도를 안전하게 하려면, 검증된 회원과 작업, 키를 범위로 삼고 지문과 상태, 응답 스냅샷을 영속 저장소에 기록하는 애플리케이션 규칙이 필요합니다.
연습 문제
1. 다음 시도 시각 계산하기
본문의 예(GET, 503, Retry-After 3초, 지터 0.1초, 자동 재시도 0회)에서 다른 입력은 그대로 두고 기한만 지금부터 3.1초 뒤와 3.2초 뒤로 바꾸면 retry는 무엇일까요? 이어서 3.2초 기한을 그대로 두고 automaticRetries가 이미 1이면 결과는 무엇인지 판단하세요. 실제로 기다리지 말고 조건과 비교 연산만 따라갑니다.
해설 보기
다음 시도는 정확히 3.1초에 시작하므로, 기한이 3.1초이면 isBefore가 false여서 중단합니다. 기한이 3.2초이면 0.1초 앞서 시작할 수 있으므로 재시도 후보가 됩니다. 하지만 자동 재시도를 이미 한 번 했다면 횟수 제한에서 먼저 중단합니다. 또 0.1초가 남았다는 것이 다음 HTTP 요청을 끝마칠 만큼 충분한 시간이라는 뜻은 아닙니다. 전송 계층이 다음 요청에 최소 0.2초가 필요하다면, 시작 후보가 되었더라도 기한 안에 끝낼 수 없습니다.
2. 같은 키의 범위
회원 7이 키 R1로 title과 content를 보내 성공했고, 완료 기록의 보존 기간은 24시간입니다. 다른 업무 고유 제약은 없고 이 API의 규칙만 적용할 때, 다음 경우를 각각 따로 판단하세요. ① 회원 7, R1, 같은 업무 값을 JSON 공백만 바꾸어 다시 보냅니다. ② 만료 전에 회원 7, R1로 content를 바꾸어 보냅니다. ③ 회원 9가 R1과 같은 업무 값을 보냅니다. ④ 회원 7의 완료 기록이 만료된 뒤 같은 R1과 같은 값을 다시 보냅니다. ⑤ 같은 회원, 작업, 키에서 첫 요청이 (title="ab", content="c")로 완료되었고, 다음 요청이 (title="a", content="bc")입니다.
해설 보기
①은 업무 값의 지문이 같으므로 보관된 처음 스냅샷을 다시 돌려주고, 게시글은 더 만들어지지 않습니다. ②는 같은 범위에 지문이 다르므로 422입니다. ③은 회원이 달라 범위가 다르므로 별도의 첫 요청입니다. ④는 이 규칙이 다시 돌려주기로 한 기간이 끝났으므로 새로 만들어질 수 있습니다. 완료 기록을 지웠다는 것이 이미 만든 게시글을 지웠다는 뜻은 아닙니다. ⑤는 값만 이어 붙이면 둘 다 abc이지만, 길이를 먼저 넣은 해시 입력은 서로 다르므로 지문도 달라 422가 됩니다. 이것은 이 API의 규칙이며, 다른 모든 POST API에 적용되는 규칙은 아닙니다.
운영 설계에서는 업무 효과가 반영된 뒤 스냅샷을 저장하기 전에 프로세스가 멈추는 경우도 처리해야 합니다. 픽스처의 catch가 맵의 항목을 지운다고 바깥 데이터베이스의 반영까지 저절로 되돌려지지는 않으며, 이 두 저장 사이의 틈 때문에 영속 트랜잭션과 복구 절차가 필요합니다.
3. 검증기 없는 GET 응답
캐시가 GET 200을 이해하고 no-store나 공유 캐시의 private, 인증 관련 저장 제한이 없다고 가정합니다. GET 응답에 max-age도 Expires도 ETag도 없다면, 그것만으로 저장 금지라고 판단할 수 있을까요? 저장할 수 있는지와 바로 다시 써도 되는지를 나누어 답하세요.
해설 보기
그 이유만으로 저장이 금지되지는 않습니다. 다른 조건을 갖추었다면 캐시는 휴리스틱 최신성을 쓸 수 있습니다. 하지만 실제로 저장할지, 최신성을 얼마나 줄지, 이후 요청과 맞는지, 검증이 필요한지를 따로 판단해야 하므로, 바로 다시 쓸 수 있다는 보장도 아닙니다.
다음 글 URI·상태 코드·PRG에서는 201과 Location, 리소스 URI 설계, 상태 코드의 선택과 POST 뒤의 리다이렉트를 더 자세히 다룹니다.