"동일한 API를 요청해서 동일한 결과를 전달받을거면,
굳이 서버와 DB의 리소스를 사용할 필요가 있을까?"
이번글은 API 멱등성(Idempotency)에 대해서 알아보고,
멱등성 키(Idempotency key)를 사용해서 API 멱등성을 보장하는 방법을 코드 예시를 통해 알아보도록 하겠습니다.
API 멱등성은 무엇일까요?
API 멱등성
API를 설계할 때, 똑같은 요청을 여러 번 보내더라도 서버의 상태가 변하지 않고 동일한 응답을 반환하도록 하는 개념을 의미합니다.
실제로, 멱등이라는 단어는 "연산을 여러 번 적용하더라도 결과가 달라지지 않는 성질"을 의미합니다.
왜 API 멱등성을 보장해줘야 하는 건가요?
먼저, API 멱등성이 보장되지 않으면 발생되는 두가지 예시 상황을 들어보도록 하겠습니다.
사용자가 상품을 구매하기 위해서 상품 구매 버튼을 눌렀지만, 구매를 누른 후의 화면이 나타나지 않아 한번을 더 누른 상황
만약, API 멱등성이 보장되지 않았다면, 어떻게 될까요? "구매"라는 똑같은 요청을 보낼때마다 중복 구매 현상이 발생될 것입니다.
사용자는 의도치 않게 같은 상품을 2개를 구매하게 된 것입니다.
A라는 은행 서버에서 B라는 은행 서버에 계좌를 이체하라는 동일한 API를 시스템 문제로 10번 호출한 경우
만약, API 멱등성이 보장되지 않았다면, 10번 전부 A은행 계좌에서 B은행 계좌로 돈이 이체되는 일이 발생될 것입니다.
사용자는 서버 내부 문제로 1번만 이체하면 되는데 10번을 이체하게 된 것입니다.
만약, 이 두가지 예시 상황에서 API멱등성이 보장되었다면 어떻게 될까요?
첫번째 상황에서는 상품이 한번만 구매가 될 것이고, 두 번째 상황에서는 1번만 이체가 될 것입니다.
이처럼, API 멱등성이 보장되지 않는다면, 사용자는 예상치 못한 문제(중복 결제, 중복 송금 등)로 직접적인 불편을 겪을 수 있습니다.
API 멱등성을 보장하면 사용자가 불필요한 중복 요청으로 인해 불편을 겪지 않으며, 서비스의 안정성과 신뢰성을 높일 수 있습니다.
그렇다면, API 멱등성과 HTTP메서드는 어떠한 관계가 있을까요?
API 멱등성과 HTTP 메서드
API를 설계할 때 멱등성(Idempotency)은 중요한 고려 사항 중 하나입니다.
특히, RESTful API를 설계할 때는 멱등성을 보장할 수 있도록 HTTP 메서드(GET, POST, PUT, DELETE 등)를 올바르게 선택하는 것이 중요합니다.
그렇다면, 모든 HTTP 메서드에서 멱등성을 보장해야 할까요?
그렇지 않다면, 어떠한 HTTP 메서드에만 멱등성을 보장해야 할까요?
결론부터 말하자면, 모든 HTTP 메서드가 멱등성을 보장해야 하는 것은 아닙니다.
HTTP 메서드 중에 멱등성이 기본적으로 보장이 되는 메서드가 있습니다.
다음은 HTTP메서드와 멱등성의 관계를 나타낸 표입니다.
HTTTP 메서드 | 멱등성 보장 여부 | 설명 |
GET | ✅ | 여러 번 호출해도 서버 상태가 변하지 않음 |
POST | ❌ | 같은 요청을 반복하면 데이터가 중복 생성됨 |
PUT | ✅ | 여러 번 호출해도 동일한 결과 유지 |
DELETE | ✅ | 여러 번 호출해도 같은 리소스 삭제 상태 유지 |
일반적으로 GET, PUT, DELETE HTTP메서드는 멱등성이 보장됩니다.
즉, 같은 요청을 여러 번 보내더라도 서버의 상태가 변하지 않으며, 항상 동일한 응답을 반환해야 합니다.
GET 요청은 데이터를 조회하는 용도로, 같은 요청을 반복해도 응답이 동일해야 합니다.
PUT 요청은 리소스를 전체 업데이트하는 용도로, 여러 번 호출해도 최종 상태는 동일해야 합니다.
DELETE 요청은 리소스를 삭제하는 용도로, 한 번 삭제된 리소스는 이후 동일한 상태를 유지해야 합니다.
하지만, 예외가 있을 수 있습니다. 멱등성이 보장되는 메서드라도 아래와 같은 특정한 상황에서는 멱등성이 깨질 수 있습니다.
(1) 조회할 때마다 조회수(View Count) 또는 좋아요(Like Count)가 증가하는 경우, 같은 요청을 여러 번 보내면 응답이 달라질 수 있습니다.
(2) Soft Delete(소프트 삭제)를 적용하면, 첫 번째 요청에서는 "User deactivated" 라고 응답하고, 두 번째 요청에서는 "User not found" 라고 응답하는 등 상태가 변할 수 있습니다.
위 표를 보면, 멱등성이 보장되지 않는 HTTP 메서드는 POST입니다.
POST 요청은 새로운 리소스를 생성하는 용도로, 같은 요청을 여러 번 보내면 데이터가 중복 생성될 가능성이 있습니다.
이처럼 POST 요청은 기본적으로 멱등성이 보장되지 않으므로, 추가적인 처리가 필요합니다.
어떻게 멱등성을 보장해줄 수 있을까요?
POST 요청에서도 중복 실행을 방지하고 멱등성을 보장할 수 있는 방법이 있습니다.
바로 "멱등성 키(Idempotency Key)"를 활용하는 것입니다.
멱등성 키
멱등성 키는 클라이언트가 요청을 보낼 때 함께 전송하는 멱등성을 보장하기 위해 사용되는 고유한 키입니다.
서버는 이 키를 저장하고, 같은 키로 요청이 들어오면 중복 실행을 방지할 수 있습니다.
실제 플로우랑 연관시켜서 표현해줄 수 있을까요?
멱등성 키로 인해 멱등성이 보장되는 상황은 다음과 같은 흐름을 가집니다. 이전 예시인 중복 구매 상황을 예시로 들겠습니다.
(1) 클라이언트 단에서 A라는 멱등성 키를 갖고 구매 API를 서버에 요청하게 됩니다.
(2)서버는 A라는 멱등성 키가 처음 들어왔음을 확인하고, 이 키를 저장합니다. 이후, 구매 요청을 정상적으로 처리한 후, 응답 결과도 함께 저장합니다.
(3) 네트워크 오류 등으로 인해 클라이언트가 동일한 요청을 재전송 하게 됩니다.
(4) 서버는 이미 A라는 멱등성 키가 저장되어 있는지 확인합니다. 저장된 키가 있기 때문에 비즈니스 로직을 다시 수행하지 않고,이전에 저장된 응답 결과를 그대로 반환합니다.
(5) 최종적으로 중복 구매가 발생하지 않으며, 클라이언트는 정상적인 응답을 받게 됩니다.
멱등성 키를 코드에 어떻게 적용할 수 있을까요?
적용하기
일반적으로 멱등성 키는 Redis에 저장하여 관리하지만, 이번 코드 예제에서는 MySQL을 활용하여 멱등성을 보장하는 방법을 소개합니다. 이 코드 예제는 특정 게시물에 "좋아요"를 누를 때 호출되는 API에 적용됩니다. 특히, API 요청마다 멱등성을 검증해야 하므로 AOP와 커스텀 어노테이션을 활용했습니다.
멱등성 키 저장을 위한 엔티티 (IdempotencyEntity)
package org.sangyunpark99.common.idempotency.repository.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.sangyunpark99.common.idempotency.Idempotency;
import org.sangyunpark99.common.utils.ResponseObjectMapper;
@Entity
@Table(name = "community_idempotency")
@NoArgsConstructor
@AllArgsConstructor
public class IdempotencyEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String idempotencyKey;
@Getter
@Column(nullable = false)
private String response;
public IdempotencyEntity(Idempotency idempotency) {
this.idempotencyKey = idempotency.getKey();
this.response = ResponseObjectMapper.toStringResponse(idempotency.getResponse());
}
}
멱등성 키와 요청 API에 대한 응닶값을 갖고 있는 엔티티를 선언해줍니다.
멱등성 키를 저장하고 조회하는 Repository
package org.sangyunpark99.common.idempotency.repository.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.sangyunpark99.common.idempotency.Idempotency;
import org.sangyunpark99.common.utils.ResponseObjectMapper;
@Entity
@Table(name = "community_idempotency")
@NoArgsConstructor
@AllArgsConstructor
public class IdempotencyEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String idempotencyKey;
@Getter
@Column(nullable = false)
private String response;
public IdempotencyEntity(Idempotency idempotency) {
this.idempotencyKey = idempotency.getKey();
this.response = ResponseObjectMapper.toStringResponse(idempotency.getResponse());
}
public Idempotency toIdempotency() {
return new Idempotency(this.idempotencyKey, ResponseObjectMapper.toResponseObject(response));
}
}
레포지토리를 사용해서 IdempotencyEntity를 DB에 저장해줍니다.
멱등성 검사를 수행하는 AOP 적용
package org.sangyunpark99.common.idempotency;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.sangyunpark99.common.ui.Response;
import org.springframework.stereotype.Component;
@Aspect
@Component
@RequiredArgsConstructor
public class IdempotencyAspect {
private final IdemPotencyRepository idemPotencyRepository;
private final HttpServletRequest request;
@Around("@annotation(Idempotent)")
public Object checkIdempotency(ProceedingJoinPoint joinPoint) throws Throwable {
String idempotencyKey =request.getHeader("Idempotency-Key");
if(idempotencyKey == null) {
return joinPoint.proceed();
}
Idempotency idempotency = idemPotencyRepository.getByKey(idempotencyKey);
if(idempotency != null) {
return idempotency.getResponse(); // 로직을 수행하지 않고, 저장된 응답값 반환
}
Object result = joinPoint.proceed(); // 로직을 수행
// 아래 부분은 Controller가 응답이 된 후, 실행된다.
Idempotency newIdempotency = new Idempotency(idempotencyKey, (Response<?>) result);
idemPotencyRepository.save(newIdempotency);
return result;
}
}
Aspect를 사용해서 전달받은 API의 멱등키를 repository를 통해 현재 DB에 존재하는지 확인해 줍니다.
DB에 존재하는 경우, 기존 응답값을 바로 반환해주고 그렇지 않은경우 비즈니스 로직이 수행되도록 해줍니다.
멱등성 검사를 위한 커스텀 어노테이션
package org.sangyunpark99.common.idempotency;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
}
커스텀 어노테이션을 선언해줍니다. 메서드에 적용이되고, 런타임에도 리플렉션(Reflection)으로 조회가 가능합니다.
Controller에서 멱등성 검사 적용
@PostMapping("/like")
@Idempotent
public Response<Void> likePost(@RequestBody LikePostRequestDto dto) {
postService.likePost(dto);
return Response.ok(null);
}
멱등성 검사가 필요한 API는 @Idempotent라는 이전에 선언해 주었던 커스텀 어노테이션을 사용해서 검증을 해줍니다.
POSTMAN을 활용한 멱등성 검증
같은 멱등키를 가진 API로 특정 게시물의 좋아요를하는 API를 호출한 경우 서버 내부의 비즈니스의 로직이 실행되는지 직접 포스트맨으로 확인해보겠습니다.
Header에 특정 Idempotench-Key를 추가해줍니다.
POSTMAN을 사용해서 API를 두 번 요청해 보도록 하겠습니다.
첫번째 요청시 발생하는 Hibernate 쿼리 로직은 다음과 같습니다.(사진은 일부 로직만 보입니다.)
포스트맨으로 응답온 결과는 다음과 같습니다.
두번째 요청시 발생하는 Hibernate 쿼리 로직은 다음과 같습니다.
Idempotency 키 유무에 대한 조회만 할 뿐, 좋아요에 대한 비즈니스 로직 처리는 따로 하지 않습니다.
포스트맨으로 응답온 결과는 다음과 같습니다.
MySQL을 활용하여 멱등성 키를 저장하고, API의 중복 요청을 방지하는 방법을 구현했습니다. AOP와 커스텀 어노테이션(@Idempotent)을 사용하여 멱등성 검사를 자동화했습니다. POSTMAN을 활용하여 같은 멱등성 키로 요청 시, 두 번째 요청에서는 비즈니스 로직이 실행되지 않음을 확인했습니다.
정리
멱등성은 API를 설계할 때 같은 요청을 여러 번 보내더라도 서버의 상태가 변하지 않고 동일한 응답을 반환하는 성질을 의미합니다.
하지만, HTTP 메서드 중 POST는 기본적으로 멱등성을 보장하지 않기 때문에, 이를 해결하기 위해 멱등성 키를 활용할 수 있습니다.
또한, 멱등성 검사를 Controller에서 직접 수행하는 대신, 커스텀 어노테이션(@Idempotent)과 AOP를 활용하면 자동으로 검사가 이루어지도록 구성할 수 있습니다. 이를 통해 중복된 코드 작성을 줄이고, API 요청을 효율적으로 처리할 수 있습니다.
'Architecture' 카테고리의 다른 글
VO(Value Obejct) (0) | 2025.01.31 |
---|