본문 바로가기
Archive/우당탕탕 개발 일지

[개발일지] API의 멱등성을 고려하여 개발하기

by sebinChu 2024. 4. 26.

개요

내가 개발한 기능을 쓰는 사람이 Admin 관리자였는데, 개발 과정/구현 내용을 모르다보니 이걸 쓰다가 문제가 생겼다. 

  • 이 문제의 핵심은 API의 멱등성을 고려하지 않아서 발생한 것
  • 또한, 사용자 관점에서 테스트를 진행해야 한다는 깨달음을 얻었다.

 

API 멱등성이라는 것에 대해 전혀 몰랐고,, 코드 리뷰를 통해 알게 되었다. 따라서 이 포스팅에서는 API의 멱등성에 대해 다루어 앞으로 의식하고 적용할 수 있도록 해볼 것이다!

 


멱등성(Idempotency)

생소한 용어 먼저 살펴보자. 멱등성은 보통 수학에서 자주 사용하는 용어로, 여러 번 연산을 적용하더라도 결과는 달라지지 않는 성질을 말한다. 한자어도 덮을 멱, 무리 등인데 무리(swarm)의 효과를 덮어버린다! 이런 뜻을 담고있다. 

 

HTTP의 멱등성

기본적으로 HTTP 메소드의

GET, PUT, DELETE

 와 같은 메서드는 멱등하다.

  • 예를 들어, 여러 번 호출해도 GET 은 항상 같은 결과를 보여준다.
  • PUT 또한 여러 번의 요청에도, 매번 같은 리소스로 업데이트 한다. 

 

반면, 서버의 데이터를 변경하는

POST, PATCH

 와 같은 메서드는 호출을 할 때 마다 요청 데이터, 응답이 달라지기 때문에 멱등하지 않다.

  • 그래서 이 메서드를 사용하는 API가 멱등하려면 직접 개발자가 구현해야 한다.

 

HTTP 요청 API의 멱등성

  • 메서드 자체가 멱등한 경우: 직접 구현하지 않아도 된다. 예를 들어 GET, PUT, DELETE가 있다.
  • 메서드가 멱등하지 않은 경우: 직접 구현해야 한다. 예를 들어 POST, PATCH가 있다.

 

나의 경우도 POST를 통한 요청이었고, 직접 멱등함을 구현하지 않아서 요청 시마다 값이 달라졌기에, 요구사항에 정확하게 맞지 않는 기능이 개발되었다! 

 

 

 


멱등한 POST 요청 API 직접 구현하는 방법들!

1) (나의 경우) 프론트에서 alert창을 만들어서 애초에 사용자가 한 번만 요청을 할 수밖에 없도록 만들었다.

 

  •  회사에서 개인적으로 이 멱등한 API 구현을 잡고 있을 수 없었고,,,  대안으로 이 방법을 썼는데, 직접적으로 멱등성을 구현하지 않았지만 꽤나 효과적인 방법이었다. 구현을 한 지 3개월이 지난 지금까지 문제 없이 의도대로 잘 사용되고 있다.
  • 하지만 내가 만든 API는 이러한 우회적인 방법이 잘 통하는 아주 특수한 케이스였기에 가능했다. 또한 백엔드에서 직접적인 처리를 한 것은 아니기에, 궁금증은 여전했고 백엔드에서 직접적으로 어떻게 처리를 할 것인지에 대해 학습했다.

 


 

2) 멱등키 헤더를 사용한다.

간단하게 멱등키를 API 요청 헤더에 포함하면 된다. 서버는 이 멱등키를 통해 같은 멱등키가 2 번 이상 요청되면 중복으로 판단한 뒤, 실제로 처리하지 않는 방식으로 동작한다.

 

 

 

IETF에서 표준으로 제안하는 요청 헤더의 멱등키 는 다음과 같다.

Idempotency-Key: {IDEMPOTENCY_KEY}

 

 

 

결제 취소 API의 멱등성을 구현한 토스 페이먼츠 예시 에서는 결제 취소와 같은 API에 이 멱등성을 제공한다. 

toss payments에서 결제 서비스에 적용한 예시

 

결제취소는 POST 요청으로 진행되며, 취소 요청마다 헤더에 멱등키를 담아준다.

  • API 서버는 취소 요청마다 헤더에 멱등키가 있는지 확인한다. (이 확인을 통해 키의 중복을 판별하고, 멱등함을 구현하기 위해서다.)
  • 멱등키 저장을 위한 DB가 있다. 만약 요청1의 헤더에서 DB에 저장된 멱등키가 발견된다면, 이에 대한 멱등성 보장을 위해 실제 요청을 진행하지 않고 저장된 응답 데이터를 돌려준다. 없으면 새로운 요청이니까 그대로 로직을 수행한다!

 

const idempotencyResponses = new Map();

let cancelReq = {
  orderId: req.body.orderId
  amount: req.body.amount,
};

let idempotencyKey = req.headers.idempotencyKey || null // 요청 헤더에서 멱등키를 가져옵니다.

// 멱등키가 있고 멱등 응답도 저장되어 있다면 실제 처리하지 않고 저장된 응답을 내보냅니다.
if (idempotencyKey != null && idempotencyResponses.has(idempotencyKey)) {
  const response = idempotencyResponses.get(idempotencyKey);
  return res.status(response.status).json(response);
};

const result = cancelProcessor.cancel(cancelReq); // 실제로 취소를 처리합니다.

// 멱등키가 있으면 멱등응답을 저장합니다.
if (idempotencyKey != null) {
  idempotencyResponses.set(idempotencyKey, result);
}

const responseBody = {
  message: `결제 취소 성공`,
};

return res.status(200).json(responseBody);

 

서버에서는 위와 같이 요청에 대해서 멱등키를 가져오고, DB에서 확인한 다음, 케이스 분기를 통해 응답을 한다. 깔끔하고 직관적인 구현이라 학습에 많은 도움이 되었다!

 

 


 

3-1) Entity Tag 사용하기

MDN에서 본 방법인데, 이도 키 헤더를 사용하는 방식과 거의 비슷하다! 특정 URL의 리소스가 변경되면 새로운 Etag를 생성하고, 이들의 비교를 통해 자원이 동일한지 여부를 확인한다.

 

요청

GET /users/123

 

응답

HTTP/1.1 200 OK
Etag: "33a64df551425fcc55e4d42a148795d9f25f89d4"

{
"name": "cobinding",
"email": "cobinding@abc.com"
}

 

If-Match를 통한 Etag 확인

POST /users/123
If-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"

{
"name": "cobinding123"
}

 

 

3-2) Spring 프로젝트로 간단하게 구현해보기

 

  • Entity 정의
public class Resource {
    private Long id;
    private String title;
    private String content;
    private String etag;
    
    public String getEtag() {
        return DigestUtils.md5Hex(title + content + lastModified.getTime());
    }
}

 

 

  • POST 수정 요청이 왔을 때 If-Match 헤더와 Etag 비교
@PostMapping("/resource")
public ResponseEntity<Resource> updateResource(@RequestBody Resource resource,@RequestHeader(value = "If-Match", required = false) String ifMatch,
HttpServletRequest request) {
   
   String currentEtag = "\"" + resource.getEtag() + "\"";
    if (ifMatch != null && !ifMatch.equals(currentEtag)) {
        return ResponseEntity.status(HttpStatus.PRECONDITION_FAILED).build();
    }
    
    Resource updatedResource = resourceService.updateResource(resource);
    String newEtag = "\"" + updatedResource.getEtag() + "\"";
    return ResponseEntity.ok()
                         .eTag(newEtag)
                         .body(updatedResource);
}

 

 


 

API를 개발하면서 새로운 개념에 대해 접할 수 있어서 재밌었다. 네트워크를 수강하고 HTTP를 따로 공부도 했지만, 직접적으로 개발하면서 배우는 지식은 훨씬 깊이있고 그 깨달음의 깊이도 다르다고 느껴진다. 앞으로도 개발 프로젝트에 도전하면서 더 깊이있는 학습과 그의 적용을 위해 노력해야겠다.

 

 

 

 

댓글