HTTP 메서드 속성
GET·POST·PUT·패치·삭제의 의미를 게시판 리소스에 적용하고 멱등성 키로 등록 재시도가 중복 생성을 만들지 않게 검증합니다.
HTTP 메서드는 컨트롤러 메서드 이름을 고르는 장식이 아닙니다. 클라이언트, 브라우저, 프록시가 재시도와 미리 가져오기, 캐시를 판단할 때 공유하는 의미입니다. 안전, 멱등, 캐시 가능성은 서로 다른 질문이며 “GET은 성공하고 POST는 저장한다” 정도로 줄이면 장애 시 재전송 정책을 세울 수 없습니다.
안전한 HTTP 메서드
GET과 HEAD는 안전한 메서드로 정의됩니다. 클라이언트가 서버 리소스 변경을 의도하지 않으므로 크롤러가 링크를 따라가거나 브라우저가 미리 가져와도 업무 상태가 바뀌지 않아야 합니다.
GET /api/posts/42를 호출할 때 접근 로그와 조회 메트릭, 캐시 통계가 바뀌는 것은 안전성을 깨지 않습니다.
요청의 목적이 게시글 변경이 아니기 때문입니다.
반면 GET /api/posts/42/delete로 삭제하면 링크 미리보기만으로 데이터가 사라질 수 있습니다.
HEAD는 GET과 같은 헤더 메타데이터를 본문 없이 얻는 용도입니다.
서버가 GET과 전혀 다른 리소스 로직을 만들면 ETag와 Content-Length 같은 메타데이터가 어긋날 수 있습니다.
프레임워크가 GET 처리 결과에서 본문만 제거하도록 두거나 두 메서드가 같은 표현 메타데이터를 계산하게 합니다.
멱등성
멱등한 요청은 한 번 실행한 뒤와 같은 요청을 여러 번 실행한 뒤 서버 리소스의 최종 의도 상태가 같습니다.
| Method | 안전 | 멱등 | 게시판 예 |
|---|---|---|---|
| GET | 예 | 예 | 게시글 조회 |
| HEAD | 예 | 예 | 표현 메타데이터 조회 |
| POST | 아니오 | 일반적으로 아니오 | 컬렉션에 새 게시글 생성 |
| PUT | 아니오 | 예 | 알고 있는 URI의 전체 상태 대체 |
| 패치 | 아니오 | 보장 안 됨 | 일부 필드 변경 |
| 삭제 | 아니오 | 예 | 리소스가 없는 최종 상태 |
삭제를 두 번 호출할 때 첫 응답 204, 둘째 응답 404일 수 있습니다. 응답 상태가 같아야 멱등인 것이 아니라 최종 리소스 상태가 “없음”으로 같습니다. PUT도 매번 갱신 시각을 새 값으로 바꾸는 구현이라면 같은 표현을 반복해도 관찰 상태가 계속 바뀌므로 멱등 계약을 약화합니다.
패치도 작업에 따라 다릅니다.
content 뒤에 문장을 추가는 반복할수록 값이 바뀌지만 content를 새 문자열로 교체는 멱등하게 설계할 수 있습니다.
HTTP 메서드 이름만 보고 애플리케이션 작업의 멱등성을 단정하지 않습니다.
POST 재시도 중복
클라이언트가 등록 요청을 보낸 뒤 응답을 받기 전에 연결이 끊기면 서버가 저장했는지 알 수 없습니다. 같은 POST를 다시 보내면 게시글이 둘 생길 수 있습니다. 클라이언트가 생성할 리소스 URI를 정할 수 있으면 PUT을 고려하고, 컬렉션 POST가 적합하면 멱등성 키를 둡니다.
package board.application;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
public final class IdempotentRegistrationService {
private final AtomicLong sequence = new AtomicLong();
private final ConcurrentHashMap<String, StoredRegistration>
registrations = new ConcurrentHashMap<>();
public PostResponse register(
String idempotencyKey,
CreatePostRequest request) {
var fingerprint = request.canonicalFingerprint();
var stored = registrations.compute(
idempotencyKey,
(key, existing) -> {
if (existing == null) {
return new StoredRegistration(
fingerprint,
new PostResponse(
sequence.incrementAndGet(),
request.title(),
request.content()));
}
if (!existing.fingerprint()
.equals(fingerprint)) {
throw new IdempotencyConflictException(key);
}
return existing;
});
return stored.response();
}
}키만 저장하면 다른 페이로드가 같은 키를 재사용해도 첫 결과를 돌려주는 위험이 있습니다. 정규화한 페이로드 지문과 결과를 함께 저장하고 불일치를 409로 거부합니다. 실제 구현은 메모리 맵이 아니라 여러 인스턴스가 공유하며 원자적 고유 삽입이 가능한 저장소를 사용하고 키 만료 시간과 사용자 범위를 정합니다.
@RestController
@RequestMapping("/api/posts")
class RegistrationController {
private final IdempotentRegistrationService service;
RegistrationController(
IdempotentRegistrationService service) {
this.service = service;
}
@PostMapping
ResponseEntity<PostResponse> register(
@RequestHeader("Idempotency-Key") String key,
@RequestBody CreatePostRequest request) {
var saved = service.register(key, request);
return ResponseEntity
.created(URI.create(
"/api/posts/" + saved.id()))
.body(saved);
}
}멱등 키 재시도
RestTestClient로 같은 요청을 두 번 전송하고 ID와 저장 횟수를 함께 확인합니다.
package board.web;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.http.MediaType.APPLICATION_JSON;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.client.RestTestClient;
class IdempotentRegistrationTest {
@Test
void 같은_key와_payload는_한_resource만_만든다() {
var service = new IdempotentRegistrationService();
var client = RestTestClient
.bindToController(
new RegistrationController(service))
.build();
var json = """
{
"title": "HTTP method",
"content": "HTTP 메서드의 속성을 정리합니다."
}
""";
for (int attempt = 0; attempt < 2; attempt++) {
client.post()
.uri("/api/posts")
.header("Idempotency-Key", "register-7-001")
.contentType(APPLICATION_JSON)
.body(json)
.exchange()
.expectStatus().isCreated()
.expectHeader()
.valueEquals("Location", "/api/posts/1")
.expectBody()
.jsonPath("$.id").isEqualTo(1);
}
assertThat(service.registrationCount()).isEqualTo(1);
}
@Test
void 같은_key의_다른_payload는_충돌이다() {
var service = new IdempotentRegistrationService();
service.register(
"register-7-002",
request("HTTP", 30));
assertThatThrownBy(() -> service.register(
"register-7-002",
request("Spring MVC", 60)))
.isInstanceOf(
IdempotencyConflictException.class);
}
}IdempotentRegistrationTest
> 같은_key와_payload는_한_resource만_만든다() PASSED
IdempotentRegistrationTest
> 같은_key의_다른_payload는_충돌이다() PASSED
registrationCount = 1
BUILD SUCCESSFUL같은 키 재시도의 응답 상태를 항상 201로 할지, 첫 요청 201과 재생 응답 200을 구분할지는 API 계약입니다. 클라이언트가 안정적으로 처리할 수 있게 문서화하고 재생 여부 헤더가 필요하면 명시합니다. 핵심은 저장 효과가 한 번이라는 점입니다.
메서드별 재시도 판단
멱등 메서드라고 무한 재시도해도 되는 것은 아닙니다. 과부하 상황에 재시도가 몰리면 장애를 키웁니다. 타임아웃, 최대 시도, 지수 백오프, 지터, 전체 기한을 정합니다.
| 상황 | 자동 재시도 판단 |
|---|---|
| DNS·연결 실패 전 | 서버 도달 전일 가능성이 높지만 제한된 재시도 |
| GET 응답 타임아웃 | 멱등이므로 예산 안에서 가능 |
| PUT 응답 타임아웃 | 같은 표현이라면 가능 |
| POST 응답 타임아웃 | 멱등성 키 없으면 자동 재시도 위험 |
| 400·401·403 | 같은 요청 반복으로 해결되지 않음 |
| 429·503 | Retry-After와 전체 기한을 고려 |
DB 트랜잭션이 커밋된 뒤 응답 쓰기만 실패할 수 있다는 점이 POST 중복의 핵심입니다. 애플리케이션 로그에 응답이 없다는 사실만으로 트랜잭션도 실패했다고 판단하지 않습니다.
캐시 가능성과 저장
GET과 HEAD는 기본적으로 캐시 가능한 메서드지만 인가가 포함된 개인 응답, 최신성 정보, Cache-Control에 따라 공유 캐시가 저장하지 않을 수 있습니다.
POST 응답도 명시적 최신성과 조건을 갖추면 규격상 캐시 가능한 경우가 있지만 일반 캐시 구현과 API 관행에서는 기대하기 어렵습니다.
“GET이니 자동 캐시된다”가 아니라 다음을 확인합니다.
- 응답이
private인지 공개인지 max-age또는 재검증용 검증기가 있는지Vary가 표현 선택 헤더를 포함하는지- 사용자별 정보가 공유 캐시 키에 섞이지 않는지
- 변경 뒤 기존 표현을 어떻게 무효화하는지
캐시와 조건부 요청은 4장의 마지막 문서에서 실제 ETag와 304로 검증합니다.
연습 문제
PUT /api/posts/{id}로 제목과 시간을 전체 대체하는 API를 구현하세요.
같은 본문을 두 번 보내도 저장 표현과 버전이 불필요하게 증가하지 않게 하고, POST /api/posts에는 멱등성 키를 적용합니다.
같은 키의 동시 요청 20개가 리소스 하나만 만드는지도 검증합니다.
해설 보기
PUT 서비스는 현재 값과 새 값을 비교해 실제 변경이 있을 때만 버전을 증가시킵니다. 응답의 생성 시각처럼 매번 달라지는 값을 리소스 표현에 무조건 넣지 않습니다.
POST 동시 테스트는 Executors.newVirtualThreadPerTaskExecutor()로 같은 키와 페이로드를 20번 호출하고 반환 ID가 하나인지, 리포지토리 개수가 1인지 확인합니다.
var ids = futures.stream()
.map(Future::get)
.map(PostResponse::id)
.collect(Collectors.toSet());
assertThat(ids).containsExactly(1L);
assertThat(repository.count()).isEqualTo(1);프로세스-로컬 ConcurrentHashMap만 통과하면 충분하지 않습니다.
운영에서는 멱등성 테이블의 사용자+키 고유 제약 조건과 페이로드 지문, 결과 스냅샷, 만료 정책을 트랜잭션으로 묶어야 여러 인스턴스에서도 보장됩니다.
다음 문서에서는 리소스 URI와 상태 코드를 연결하고 HTML 폼의 새로고침 중복을 막는 POST-리다이렉트-GET 흐름을 만든다.