본문으로 건너뛰기

안동민 개발노트

본문 시작

HTTP 메서드 속성

HTTP 메서드의 안전성·멱등성·캐시 가능성을 구분하고, Java 17과 Spring Framework 6.2.11로 제한된 재시도와 애플리케이션 멱등성 기록을 검증합니다.

HTTP 메서드는 컨트롤러 메서드 이름을 고르는 장식이 아닙니다.

클라이언트, 브라우저, 프록시, 캐시는 메서드에 부여된 공통 의미를 바탕으로 미리 가져오기, 저장, 재사용, 재시도 가능성을 판단합니다.

RFC 9110의 메서드 속성에서 안전성, 멱등성, 캐시 가능성은 서로 다른 질문입니다.

HTTP method를 안전성, 멱등성, 캐시 가능성의 독립된 세 속성으로 비교하고 GET·HEAD, OPTIONS·TRACE, PUT·DELETE, POST, PATCH, CONNECT의 표준 의미를 설명합니다.

METHOD SEMANTICS · SAFE · IDEMPOTENT · CACHEABLE

안전·멱등·캐시 가능성은 서로 다른 method 속성이다

하나의 예·아니오로 method를 묶지 않습니다. 안전성은 요청의 의도, 멱등성은 같은 요청을 반복한 의도된 효과, 캐시 가능성은 응답 저장·재사용 허용 여부를 각각 말합니다.

안전 ⇒ 멱등이지만 멱등 ⇏ 안전입니다. 또한 POSTPATCH는 비멱등이어도 조건부로 캐시 가능할 수 있습니다. 캐시 가능은 허용이지 실제 저장의 보장이 아닙니다.

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 제한이 없어야 하며, 명시적 최신성 또는 재검증 조건을 만족해야 합니다.

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같은 요청은 중단하고 입력·자격·권한을 고친 뒤 새 판단
429RFC 6585의 선택적 Retry-After와 서버 정책을 확인
503Retry-After가 있으면 기다릴 최소 시간을 반영

Retry-After는 지연 초 또는 HTTP 날짜일 수 있습니다.

유효하지 않거나 없으면 클라이언트가 정한 backoff를 쓰고, 동시 재시도 집중을 줄이는 jitter를 더하되 다음 시도 시각이 전체 deadline 안에 있을 때만 재시도합니다.

다음 Java 17 단위는 실제 네트워크를 흉내 내지 않고 이 클라이언트 판단 함수만 결정적으로 실행합니다.

src/test/java/board/http/HttpMethodPolicyTest.java
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, 1 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");
        }
        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");
    }
    private static Optional<Duration> parseRetryAfter(
            String value, Instant now) {
        if (value == null || value.isBlank()) {
            return Optional.empty();
        }
        try {
            long seconds = Long.parseLong(value);
            return seconds < 0
                    ? Optional.empty()
                    : Optional.of(Duration.ofSeconds(seconds));
        } catch (NumberFormatException ignored) {
            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);
        }
    }
}

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를 보장하지 않는다는 사실을 공개합니다.

422409 구분도 만료 초안의 제안을 채택한 이 API의 계약이지 모든 서버에 강제되는 HTTP 일반 규칙은 아닙니다.

POST의 business commit 뒤 응답이 유실되어 client가 결과를 알 수 없게 되는 경계와, verified member·operation·Idempotency-Key 범위의 원자적 기록이 중복 처리를 막는 네 가지 분기, 그리고 한 번의 자동 재시도를 전체 deadline 안으로 제한하는 정책을 설명합니다.

AMBIGUOUS COMMIT · APPLICATION IDEMPOTENCY · RETRY BUDGET

모호한 POST 재시도는 멱등성 기록과 전체 deadline으로 제한한다

timeout은 실패 결과가 아니라 결과를 모른다는 관측일 수 있습니다. application이 같은 operation을 하나의 원자적 기록에 연결하고, client는 그 보장과 남은 전체 deadline이 있을 때만 제한적으로 재시도해야 합니다.

01 · COMMIT BOUNDARY

응답을 받지 못해도 business commit은 이미 끝났을 수 있다

  1. client가 최초 POST를 보낸다

    server는 새 resource를 만들기 위한 transaction을 시작합니다.

  2. business transaction이 commit된다

    resource 42가 durable state가 되어 최초 operation은 성공합니다.

  3. commit 뒤 response가 유실된다

    network 경계에서 성공 응답이 client에 도달하지 않습니다.

  4. client에는 결과를 모르는 상태만 남는다

    같은 POST를 그대로 실행하면 resource 43을 만들 수 있습니다. timeout·DNS·connect 오류만으로 원 요청이 적용되지 않았다고 단정할 수 없습니다.

02 · OPERATION RECORD

verified principal·operation·key가 하나의 처리 slot을 식별한다

  • Composite scope — 인증 경계에서 검증한 memberId + operation 이름 + Idempotency-Key
  • Request identity — 의미가 같은 payload를 같은 값으로 만드는 normalized fingerprint
  • State machine — 최초 claim의 PENDING과 처리가 끝난 COMPLETED
  • Completed record — status·headers·body·Location의 정확한 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인데 normalized fingerprint가 다르면 다른 payload의 재사용이므로 거부합니다.
  • 409 같은 key·fingerprint의 record가 아직 PENDING이면 진행 중임을 알리고 두 번째 business execution을 시작하지 않습니다.
  • REPLAY 같은 key·fingerprint가 COMPLETED이면 저장한 이전 status·headers·body를 그대로 반환합니다.

이 API profile이 key를 필수로 정했다면 누락되거나 형식이 잘못된 key는 business operation 전에 400으로 중단합니다.

04 · RETRY GATE · ONE DEADLINE

재시도 가능성과 시간 budget을 모두 통과해야 한다

  1. operation semantics를 먼저 확인한다

    method 자체가 멱등이거나, application idempotency profile이 같은 operation을 멱등으로 만들거나, client가 원 요청이 적용되지 않았음을 확실히 아는 경우에만 retry gate를 엽니다.

  2. 자동 재시도는 최대 한 번만 허용한다

    Retry-After 또는 backoff + jitter로 다음 시점을 정하되, 최초 시도부터 공유한 overall deadline의 남은 시간 안에서만 실행합니다.

  3. 반복해도 달라지지 않는 응답은 즉시 멈춘다

    요청을 바꾸지 않은 400·401·403은 중단합니다. 429·503도 retry gate, server의 Retry-After, 남은 deadline을 모두 만족할 때만 한 번 재시도합니다.

안전한 경로: 최초 POST → 원자적 PENDING claim → business state와 COMPLETED result snapshot의 durable commit. 응답이 유실되어도 같은 operation의 retry는 새 실행이 아니라 저장된 결과로 수렴합니다.

다음 Spring Framework 6.2.11 단위는 MockMvcBuilders.standaloneSetup으로 mock Servlet 요청부터 MVC 매핑, 컨트롤러, 예외 응답, JSON 변환까지만 실행합니다.

실제 TCP 연결, 프록시의 자동 재시도, 응답 유실은 실행하지 않으므로 앞의 전송 실패를 증명하는 테스트는 아닙니다.

src/test/java/board/web/IdempotentRegistrationTest.java
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와 복구 정책까지 정하지 않으면 영구 대기 기록이 될 수 있습니다.


경계 확인

  • 이 문서는 HTTP 메서드의 의미, 제한된 자동 재시도, 애플리케이션 멱등성 기록을 소유합니다.
  • 연결 단계와 “응답 없음이 업무 실패를 뜻하지 않는다”는 전송 경계는 앞 문서의 범위입니다.
  • 201 Created, Location, 리소스 URI 설계는 다음 문서에서 더 자세히 다룹니다.
  • POST-리다이렉트-GET은 브라우저 새로고침 재제출을 줄이지만 응답 유실 뒤의 네트워크 재시도 중복을 해결하지 않습니다.
  • Vary와 콘텐츠 협상은 콘텐츠 협상에서, 검증기·304 Not Modified·표현별 캐시 키는 HTTP 캐시와 조건부 요청에서 이어집니다.

연습에서는 test fixture의 맵을 그대로 확장하지 말고, 공유 저장소의 원자적 고유 삽입과 완료 snapshot을 먼저 설계한 뒤 동일 key 동시 요청, 다른 fingerprint, 만료, 프로세스 중단 복구를 각각 검증합니다.