HTTP 멱등성이란?

박병욱·2025년 4월 25일

TIL

목록 보기
7/11
post-thumbnail

💎 멱등성 (Idempotency)

“멱등성” 이란 연산을 반복적으로 적용해도 변하지 않는 성질을 말한다. 컴퓨터 과학에서 “멱등하다” 는 의미는, 첫 번째 수행을 한 뒤 여러 차례 적용해도 결과를 변경시키지 않는 작업 또는 기능의 속성을 말한다.

예를 들어, 어떤 숫자에 1을 곱하는 연산은 여러 번 수행해도 처음 1을 곱한 것과 같은 숫자가 되기 때문에 해당 연산은 멱등하다고 한다. 마찬가지로 숫자의 절댓값을 계산하는 절댓값 함수는 같은 값에 대해 여러 번 수행해도 처음과 항상 같은 숫자가 돌아오기 때문에 멱등 함수라고 부른다.

 

HTTP 메서드는 캐시 가능 여부(Cacheable), 안전(Safety), 멱등성(Idempotency)이 있다.

  • Cacheable : 빠르게 사용하기 위해서 미리 저장해놓고 나중에 같은 요청에 대해 같은 응답을 줄 수 있는지 여부를 의미한다.
  • Safety : 서버의 상태(State)를 변경하지 않으면 안전하다고 한다. 여기에는 GET, HEAD, OPTIONS 등이 해당된다.
  • Idempotency : 응답(Response)이 항상 같다는 것을 의미하는 건 아니다. 시스템의 상태(State)가 변하지 않으면 멱등하다고 한다. GET, PUT, DELETE, HEAD 등이 해당된다.

 

🤔 HTTP 메서드에 왜 멱등성이 필요하지?

HTTP 멱등성이 필요한 이유는 요청의 재시도 때문이다. 만약 HTTP 요청이 멱등하다면, 요청이 실패한 경우에 주저없이 재시도 요청을 하면 된다. 하지만 만약 HTTP 요청이 멱등하지 않다면, 리소스가 이미 처리되었는데 중복 요청을 보낼 수 있다. 예를 들어 이미 결제된 요청인데, 중간에 연결이 끊겨서 다시 결제 요청을 보내서 문제를 일으킬 수 있는 것이다. 그래서 클라이언트는 무지성으로 재시도 요청을 보내면 안되고, 멱등성을 고려하여 재시도 요청을 해야 한다.

 

그럼 안전 == 멱등인가? HTTP 메서드의 안전성과 멱등성은 어떻게 다를까?

안전성이 보장된 메서드는 리소스를 변경하지 않는다. GET, HEAD, OPTIONS는 안전한 메서드다. 안전성이 보장된 메서드는 멱등성도 보장하지만, 멱등성을 지닌 메서드가 항상 안전성을 보장하지는 않는다. 예를 들어 PUT과 DELETE는 멱등한 메서드지만, 리소스에 변화를 일으키기 때문에 안전한 메서드는 아닌 것이다.

 

여러 번 실행에도 같은 시스템 상태를 보장하는 멱등성은 HTTP 프로토콜에서 요청의 안전성과 예측 가능성을 보장하는 핵심 속성이다.

메서드설명CacheableSafetyIdempotency
CONNECT요청된 리소스와 양뱡향 통신을 시작XXX
DELETE리소스 삭제XXO
GET리소스 조회OOO
HEAD콘텐츠 다운로드 없이 자원의 메타데이터 조회OOO
OPTIONS대상 리소스에 대한 커뮤니케이션 옵션 설명XOO
POST리소스 생성OXX
PUT리소스 대체XXO
PATCH리소스 부분 업데이트OXX
TRACE대상 리소스 경로 테스트하는 ‘메시지 루프백 테스트’ 에 사용XOO

 

🤔 근데 리소스를 삭제하는 DELETE 메서드는 왜 멱등할까?

  1. DELETE 요청 → 200 OK
  2. DELETE 요청 → 404 NOT FOUND (상태 응답 코드 변경)
  3. DELETE 요청 → 404 NOT FOUND

"멱등성은 응답이 항상 같은 것을 말하는 게 아니라, 시스템의 상태가 불변한 성질" 을 말하는 것이다.

첫 번째 DELETE 요청으로 해당 리소스를 삭제하면, 그 리소스는 더 이상 존재하지 않는다. 두 번째 DELETE 요청을 한다면, 그 리소스는 이미 삭제되었기 때문에 삭제할 리소스가 없다. 계속 DELETE 요청해도 마찬가지다.

DELETE 요청을 여러 번 하면 응답 코드는 다르지만, 서버에서는 리소스가 없는 상태를 유지하고 있기에 DELETE 메서드가 멱등하다고 하는 것이다.

 

🤔 PUT 메서드는 멱등한데, POST, PATCH 메서드는 왜 멱등하지 않을까?

  • PUT 메서드 : 보통 리소스의 상태를 전체적으로 업데이트하는 것보다는, 갈아끼우는 것에 가깝다. 그래서 같은 요청을 여러 번 보내더라도 결과적으로 서버의 상태가 변하지 않는 것이다.
  1. PUT id1 사용자의 닉네임을 a1으로 수정 → 200 OK
  2. PUT id1 사용자의 닉네임을 a1으로 수정 → 200 OK
  3. PUT id1 사용자의 닉네임을 a1으로 수정 → 200 OK

위와 같이 id1 사용자가 이미 존재할 때 해당 사용자의 상태를 교체하는 동작을 3번 수행해도, 리소스의 상태는 변경되지 않는다.

 

  • POST 메서드 : 이 메서드는 새로운 리소스를 생성한다. 동일한 요청을 N번 보내면, 서버는 N개의 리소스를 생성하는 것이다.
  1. POST user1 → 201 Created
  2. POST user1 → 201 Created
  3. POST user1 → 201 Created

동일한 사용자 user1이 3번 생성된다. 사실은 동일한 사용자이지만 id=1, id=2, id=3인 사용자가 생성되는 것이다. 이처럼 N번 요청하면 N개의 리소스가 서버에 생성되므로 서버의 상태가 계속 변경되는 것이다. 그렇기 때문에 멱등하지 않은 거다.

 

  • PATCH 메서드 : 리소스의 일부를 수정한다. 전체를 교체하는 PUT 메서드와는 차이가 있다. PATCH 메서드는 상황에 따라서 멱등하게 사용될 수도 있지만, 항상 멱등성을 보장할 수는 없다.

만약, 장바구니에 물건을 추가한다고 가정해보자.

  1. PATCH item1 → 200 OK
  2. PATCH item1 → 200 OK
  3. PATCH item1 → 200 OK

첫 번째 요청으로 장바구니의 상태는 {item1} 일 것이다. 두 번째 요청으로는 {item1, item1} 이 되고, 세 번째 요청까지 가면, {item1, item1, item1} 이 된다. N번 요청하면 N번의 변화가 생기므로 멱등하지 않은 것이다.

이제 멱등하다는 의미가 어떤 건지 알 것 같다. 그래서 이렇게 멱등하지 않다면 어떤 문제들이 발생한다는 걸까?

 

💥 멱등성이 보장되지 않았을 경우 생기는 문제점

온라인으로 제품을 구입해보자.

  1. 제품을 구매한다.
  2. 클라이언트는 서버로 POST 요청을 보내고, 서버는 이를 처리해서 응답을 보낸다. 결제 성공이다.

그런데 만약, 2번 과정에서 일시적인 네트워크 문제로 클라이언트가 응답을 받지 못했다면? 그럼 클라이언트가 구매에 실패했기 때문에 서버에 다시 동일한 POST 요청을 보낼 것이다. 이때 POST 요청이 멱등하지 않기 때문에 서버는 동일한 두 번째 요청을 처리할 것이다. 결국… 클라이언트는 동일한 제품을 2번 구매하게 되고, 카드값에 지출 내역이 2번 찍히게 되는 참사가 발생하는 것이다.

돈으로 연결 시키니까 확 온다. 이처럼 멱등하지 않을 경우, 시스템은 중복 데이터를 생성 하기 때문에 시스템 신뢰성이 떨어진다. 심지어, 저런 중복된 데이터를 처리하기 위해 수동으로 작업을 해야 하기 때문에 운영 및 유지보수 비용도 발생 한다.

 

⛓ 그럼 멱등성을 어떻게 지킬 수 있을까?

위처럼 API 맥락에서 멱등한 연산은 "같은 요청을 여러 번 해도 한 번 한 것과 다름 없는 시스템 상태(State)를 생성하는 것" 이다. 멱등성이 보장되면 시스템이 복원력 있고 신뢰성도 높아진다.

POST 메서드와 같이 멱등하지 않은 HTTP 메서드에 관해서는 멱등성을 개발자가 부여해줘야 한다. 이때 많이 사용되는 방법으로 “멱등키” 를 사용하는 방법이 있다.

 

<클라이언트 측 멱등키 전송 및 재시도>

public class CancelClient {

    private static final RestTemplate restTemplate = new RestTemplate();

    public static void main(String[] args) {
        String idempotencyKey = UUID.randomUUID().toString();
        try {
            CancelRequest request = new CancelRequest("ORDER-1234", 100);
            CancelResponse response = cancelPayment(idempotencyKey, request);
            System.out.println("응답: " + response.getMessage());
        } catch (Exception e) {
            System.err.println("실패: " + e.getMessage());
        }
    }

    public static CancelResponse cancelPayment(String idempotencyKey, CancelRequest request) throws InterruptedException {
        String url = "http://.../cancel-payment";

        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);
        headers.set("Idempotency-Key", idempotencyKey);

        HttpEntity<CancelRequest> entity = new HttpEntity<>(request, headers);

        for (int i = 0; i < 3; i++) { // 최대 3번 재시도
            try {
                ResponseEntity<CancelResponse> response = restTemplate.exchange(
                        url,
                        HttpMethod.POST,
                        entity,
                        CancelResponse.class
                );
                return response.getBody();
            } catch (ResourceAccessException e) {
                System.out.println("요청 타임아웃, 재시도 중...");
                Thread.sleep(1000); // 1초 대기 후 재시도
            }
        }

        throw new RuntimeException("최대 재시도 횟수를 초과했습니다.");
    }
}
  • 헤더에 멱등키를 추가해서 요청한다. 멱등키는 UUID v4와 같이 충분히 무작위적인 고유 값이어야 한다.
  • 최초 요청 이후에는 다시 요청해도 HTTP 코드 200과 함께 매번 같은 결과가 돌아온다.

 

<서버 측 멱등키 처리>

@RestController
public class CancelController {

    private final InMemoryIdempotencyStore idempotencyStore;

    public CancelController(InMemoryIdempotencyStore store) {
        this.idempotencyStore = store;
    }

    @PostMapping("/cancel-payment")
    public ResponseEntity<?> cancelPayment(
            @RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey,
            @RequestBody CancelRequest cancelReq) {

		// 멱등키가 있고 멱등 응답도 저장되어 있다면 실제 처리하지 않고 저장된 응답을 내보낸다.
        if (idempotencyKey != null && idempotencyStore.contains(idempotencyKey)) {
            return idempotencyStore.get(idempotencyKey); 
        }

        // 실제 결제 취소 처리
        CancelResponse responseBody = new CancelResponse("결제 취소 성공");

        ResponseEntity<?> response = ResponseEntity.ok(responseBody);

		// 멱등키가 있다면 멱등응답 저장
        if (idempotencyKey != null) {
            idempotencyStore.save(idempotencyKey, response);
        }

        return response;
    }
}
  • 멱등성을 지원하는 서버에서는 이렇게 구현한다. 멱등키 DB에 멱등키와 매칭되는 요청 기록을 추가하고, 취소 처리에 성공했다면 성공 응답을 보내준다.
  • 같은 취소 요청이 반복되면 요청에 멱등키가 포함되어 있는지, 이미 저장된 멱등키가 있는지 확인한다.

 

<정리>

  • 💡 멱등성이란?

    • 멱등성은 같은 요청을 여러 번 보내도 결과적으로 시스템 상태가 변하지 않는 성질이다.

    • DELETE, PUT은 멱등하지만, POST, PATCH는 기본적으로 멱등하지 않다.

       

  • ⚠️ 멱등성이 중요한 이유

    • 멱등성이 없는 POST 요청은 중복 처리 문제를 일으킬 수 있다.

      • 예: 결제 요청이 중복되면 카드 결제가 여러 번 이루어지는 심각한 문제 발생 가능
    • 시스템에 불필요한 중복 데이터가 생기고, 운영·유지보수 비용이 늘어날 수 있다.

       

  • 🔐 멱등성을 지키는 실전 방법

    • 멱등하지 않은 POST 요청에 개발자가 직접 멱등성을 부여해야 한다.

    • 가장 널리 사용되는 방법은 “멱등키(Idempotency-Key)”를 사용하는 방식이다.

      • 클라이언트가 UUID 기반의 멱등키를 요청 헤더에 포함하여 보냄
      • 버는 해당 키로 요청을 식별하고, 이미 처리된 요청이면 재처리하지 않음

 

이처럼 "멱등성은 시스템의 신뢰성과 복원력을 높여주는 필수 속성이다." 특히, 결제처럼 중요한 POST 요청에서 멱등성을 직접 구현하지 않으면, 사용자 피해와 신뢰 저하로 이어질 수 있다. 멱등키를 통해 HTTP 요청에 멱등성을 보장하면, 시스템은 더 예측 가능하고 안전하게 동작할 수 있다.


<참고 자료>
멱등성이 뭔가요?
HTTP 메소드의 멱등성(Idempotence)과 Delete 메소드가 멱등한 이유
RFC 7231 스펙 문서

profile
도메인을 이해하는 백엔드 개발자(feat. OOP)

0개의 댓글