Spring Framework/Spring Boot

[Spring Boot] AOP, Redis를 이용한 멱등성 보장 구현하기

장쫄깃 2024. 9. 18. 15:08
728x90


멱등성(Idempotent)이란?


첫 번째 수행을 한 뒤 여러 차례 적용해도 결과를 변경시키지 않는 작업 또는 기능의 속성

 

 

멱등한 작업이란, 한 번 수행하든 여러 번 수행하든 결과가 동일한 작업을 의미한다.

 

멱등성이 보장되면 메소드가 여러 번 실행되어도 결과가 동일하기 때문에 안전하게 사용할 수 있다. 반면, 멱등성이 보장되지 않으면 동일한 요청을 중복해서 실행할 위험이 있다.

 

예를 들어, 동일한 등록 요청을 중복해서 실행한다면 중복되는 여러 개의 데이터가 생성될 수 있다.

 

 

HTTP 메소드의 멱등성


HTTP 메소드에도 멱등성이 있다.

 

메소드 멱등성
GET O
POST X
PUT O
PATCH X
DELEETE O
HEAD O
OPTIONS O
TRACE O
CONNECT X

 

GET, PUT은 리소스를 조회하거나 대체하는 메소드이기 때문에 멱등하다. GET은 여러 번 호출해도 같은 결과가 돌아온다. PUT은 매번 같은 리소스로 업데이트되기 때문에 결과가 바뀌지 않는다. DELETE는 여러 번 호출해도 같은 데이터 삭제라는 결과가 나타나기 때문에 멱등하다.

 

반면, 서버 데이터를 변경하는 POST, PATCH는 호출할 때마다 응답이 달라지기 때문에 멱등하지 않다. POST는 여러번 호출하면 중복되는 데이터가 등록될 수 있다. PATCH는 예를 들어 값을 1 증가시키는 요청을 여러번 보낸다면, 중복 요청으로 인해 값이 여러번 증가할 수 있다. 이렇게 멱등하지 않은 메소드의 멱등성을 제공하려면 서버에서 멱등성을 구현해야 한다.

 

안정성과 멱등성

HTTP 메소드에는 멱등성 외에 안정성이 있다. HTTP 메소드의 안정성이란 보안 취약성을 말하는 것이 아닌, 호출해도 리소스가 변경되지 않는 성질을 말한다. 간단하게 생각해서 GET 메소드는 데이터를 조회하는 기능을 수행하기 때문에 데이터의 변경이 없어 안전한 메소드이다. 안정성이 보장된 메소드는 멱등성을 보장하지만, 멱등성이 보장된 메소드가 안정성을 보장하지는 않는다.

 

 

AOP와 Redis를 이용한 멱등성 보장 구현


공통 어노테이션을 사용할 수 있도록 AOP를 기반으로 멱등성 보장을 구현하려고 한다. 이 게시글은 멱등성 보장 구현에 초점을 맞추고 있기 때문에, 멱등성 보장 구현 위주의 내용만을 설명할 예정이다. 구체적인 비즈니스 로직 관련 코드는 아래 깃허브 링크를 참고하면 된다.

 

1. build.gradle

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-aop'
	implementation 'org.springframework.boot:spring-boot-starter-validation'
	compileOnly 'org.projectlombok:lombok'
	developmentOnly 'org.springframework.boot:spring-boot-devtools'
	runtimeOnly 'com.mysql:mysql-connector-j'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'

	// log4jdbc
	implementation 'org.bgee.log4jdbc-log4j2:log4jdbc-log4j2-jdbc4.1:1.16'

	// redis
	implementation 'org.springframework.boot:spring-boot-starter-data-redis'
}

 

2. application.yml

spring:
  # redis
  data:
    redis:
      port: 6379
      host: ****
      password: ****
  # datasource
  datasource:
    driver-class-name: net.sf.log4jdbc.sql.jdbcapi.DriverSpy
    url: jdbc:log4jdbc:mysql://****:3306/idempotent_practice
    username: ****
    password: ****
  # jpa
  jpa:
    properties:
      hibernate:
        show_sql: true # sql show
        format_sql: true # pretty show
    hibernate:
      ddl-auto: none # db init (create, create-drop, update, validate, none)
  web:
    resources:
      add-mappings: false

 

3. RedisConfig.java

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;

@Configuration
public class RedisConfig {

    @Value("${spring.data.redis.host}")
    private String host;

    @Value(("${spring.data.redis.port}"))
    private String port;

    @Value(("${spring.data.redis.password}"))
    private String password;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();
        redisStandaloneConfiguration.setHostName(host);
        redisStandaloneConfiguration.setPort(Integer.parseInt(port));
        redisStandaloneConfiguration.setPassword(password);
        return new LettuceConnectionFactory(redisStandaloneConfiguration);
    }

}

 

4. Idempotent Annotation

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 {

    // 만료시간 7초
    int expireTime() default 7;

}

 

멱등성 보장이 필요한 메소드에 적용할 어노테이션을 구현한다. 멱등성을 보장하는 기본 시간은 7초이다.

 

5. CachingRequestFilter.java

import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.util.ContentCachingRequestWrapper;

import java.io.IOException;

/**
 * HTTP body를 여러 번 참조하기 위해 GET 요청이 아닌 경우 HTTP body를 캐싱하는 Filter
 */
@Component
@Order(Integer.MIN_VALUE)
public class CachingRequestFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;

        // GET 요청이 아닌 경우에만 ContentCachingRequestWrapper 적용
        if (!"GET".equalsIgnoreCase(httpServletRequest.getMethod())) {
            // ContentCachingRequestWrapper로 요청 래핑
            ContentCachingRequestWrapper wrappedRequest = new ContentCachingRequestWrapper(httpServletRequest);

            filterChain.doFilter(wrappedRequest, response);
        }
        // GET 요청인 경우 ContentCachingRequestWrapper 적용하지 않음
        else {
            filterChain.doFilter(request, response);
        }
    }

}

 

ContentCachingRequestWrapper는 Spring Framework에서 제공하는 클래스로, HTTP 요청 내용을 캐싱하기 위해 사용된다.

 

일반적으로, HTTP 요청 본문(body)은 한번 읽으면 다시 읽을 수 없는 제약이 있다. 하지만 멱등성 보장을 위해서는 동일한 요청 본문(body)을 사용했는지를 AOP에서 확인해야 한다. 그러면 어플리케이션 동작 때 요청 본문을 사용하지 못하게 된다. 이때, ContentCachingRequestWrapper를 사용하면 이러한 문제를 해결할 수 있다.

 

위 코드에서는 GET 요청이 아닌 경우에만 요청 내용을 캐싱하도록 구현했다. GET 요청은 body가 아닌 parameter를 사용하기 때문이다.

 

6. IdempotentAspect.java

import com.jdh.idempotent.config.idempotent.annotaion.Idempotent;
import com.jdh.idempotent.config.idempotent.exception.ConflictException;
import com.jdh.idempotent.config.idempotent.exception.UnprocessableEntityException;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.util.ContentCachingRequestWrapper;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.TimeUnit;

/**
 * 중복된 요청을 방지하기 위해 Redis를 활용한 멱등성 처리를 담당하는 AOP 클래스
 * HTTP 요청의 중복 여부를 Redis를 통해 확인하고, 중복된 요청일 경우 예외 발생
 *
 * <p>
 * `@Idempotent` 어노테이션이 붙은 경우 동작
 * </p>
 *
 * <p>
 * 관련 예외:
 * <ul>
 *     <li>UnprocessableEntityException: 기존 요청과 다른 데이터로 중복된 요청이 들어온 경우</li>
 *     <li>ConflictException: 동일한 데이터로 중복된 요청이 들어온 경우</li>
 * </ul>
 * </p>
 *
 * <p>
 * 사용 예시:
 *
 * <pre>
 * &#64;Idempotent(expireTime = 60)
 * public ResponseEntity&lt;T&gt; processRequest() {
 *     // 로직 처리
 * }
 * </pre>
 *
 * Redis 캐시 만료 시간은 어노테이션의 `expireTime` 속성으로 설정 (default 7초)
 * </p>
 */
@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class IdempotentAspect {

    private final StringRedisTemplate stringRedisTemplate;

    /**
     * `@Idempotent` 어노테이션이 선언된 메소드를 포인트컷으로 설정
     *
     * @param idempotent 멱등성 처리를 위한 어노테이션
     */
    @Pointcut("@annotation(idempotent)")
    public void pointCut(Idempotent idempotent) {
    }

    /**
     * <p>
     * 중복된 요청인지 확인하는 로직을 실행하기 전에 실행
     * </p>
     * <p>
     * HTTP 요청의 헤더와 본문(또는 파라미터)을 Redis에 캐싱하고, 중복된 요청인 경우 예외를 발생
     * </p>
     *
     * @param joinPoint 현재 실행 중인 메서드에 대한 정보
     * @param idempotent 멱등성 처리를 위한 어노테이션 정보
     * @throws IOException 요청 본문을 읽을 수 없을 경우 발생
     */
    @Before(value = "pointCut(idempotent)", argNames = "joinPoint,idempotent")
    public void before(JoinPoint joinPoint, Idempotent idempotent) throws IOException {
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();

        // header 에 담겨있는 요청키 조회
        String requestKey = getRequestKey(request);
        // request body 조회
        String requestValue = getRequestValue(request);

        log.info("[IdempotentAspect] ({}) 요청 데이터 :: {}", requestKey, requestValue);

        // redis 만료시간
        int expireTime = idempotent.expireTime();

        // 중복되는 요청인지 체크
        Boolean isPoss = stringRedisTemplate
                .opsForValue()
                .setIfAbsent(requestKey, requestValue, expireTime, TimeUnit.SECONDS);

        // 중복되는 요청인 경우
        if(Boolean.FALSE.equals(isPoss)) {
            // 적절한 예외처리 handle
            handleRequestException(requestKey, requestValue);
        }
    }

    /**
     * 요청 key 조회
     */
    private String getRequestKey(final HttpServletRequest request) {
        String token = request.getHeader("requestKey");

        if(token == null)
            throw new IllegalArgumentException();

        return token;
    }

    /**
     * 요청 value 조회
     */
    private String getRequestValue(final HttpServletRequest request) {
        // GET 요청이 아닌 경우 request body 데이터 조회
        if (!"GET".equalsIgnoreCase(request.getMethod())) {
            ContentCachingRequestWrapper cachingRequest = (ContentCachingRequestWrapper) request;

            return new String(cachingRequest.getContentAsByteArray(), StandardCharsets.UTF_8);
        }
        // GET 요청인 경우 request parameter 데이터 조회
        else {
            return request.getQueryString();
        }
    }

    /**
     * 중복되는 요청인 경우 적절한 예외처리
     */
    private void handleRequestException(final String requestKey, final String requestValue) {
        // 기존의 요청 데이터 조회
        String originRequestValue = stringRedisTemplate.opsForValue().get(requestKey);
        log.info("[IdempotentAspect] ({}) 기존의 요청 데이터 :: {}", requestKey, originRequestValue);

        // 기존의 요청 데이터와 일치하지 않는 경우 잘못된 요청으로 판단
        if(!requestValue.isBlank() && !requestValue.equals(originRequestValue))
            throw new UnprocessableEntityException();
            // 기존의 요청 데이터와 일치하는 경우 중복 요청으로 판단
        else
            throw new ConflictException();
    }

}

 

멱등성 보장 기능을 구현한 Aspect class를 구현한다. 이 class는 위에서 구현한 @Idempotent 어노테이션이 적용된 메소드를 포인트컷으로 설정한다. 즉, @Idempotent 어노테이션이 적용된 경우 실행된다.

 

멱등성을 보장하는 로직 순서는 다음과 같다.

  1. header에 담겨있는 요청 키 조회
  2. 요청 데이터 조회 (payload)
  3. redis에 해당 요청 키에 대한 값이 존재하는지 확인 (중복 요청 확인)
  4. 중복 요청이 아닌 경우 (redis에 요청 키에 대한 값이 존재하지 않는 경우) redis에 요청 키와 요청 데이터를 redis에 저장
  5. 중복 요청인 경우 (redis에 요청 키에 대한 값이 존재하는 경우) 적절한 예외 발생

 

먼저 요청에 대한 키와 데이터를 조회한다. 여기서 요청 키는 UUID와 같은 무작위값을 사용하는 것이 좋다. 만약 header에 요청 키가 존재하지 않으면 400 Bad Request 에러를 발생시킨다.

 

그리고 redis에 해당 요청 키에 대한 값이 존재하는지 확인한다. 이때, 값이 존재하지 않는 경우 해당 요청에 대한 키와 요청 데이터를 만료시간과 함께 redis에 저장한다. 이때, setIfAbsent에서 값이 존재하지 않아 데이터를 정상적으로 저장한 경우 true를, 이미 값이 존재해 데이터를 저장하지 못한 경우 false를 반환한다. 따라서 isPoss는 키가 이미 존재하는지, 다시 말해 이미 요청한 적이 있는지 여부를 나타낸다.

 

isPoss가 false인 경우, 즉, 중복 요청인 경우에는 handleRequestException 메소드가 적절한 예외를 발생시킨다. 예외 조건은 두 가지로 나뉜다. 중복 요청이면서 요청 데이터가 동일한 경우는 409 Conflict를, 중복 요청이면서 요청 데이터가 다른 경우 422 Unprocessable Entity를 발생시킨다.

 

재시도한 요청의 본문(payload)이 다르지만 같은 요청 키를 사용한 경우, 요청 자체에 문제가 없고 서버가 받아들일 수 있는 요청이지만 멱등한 요청이 아니기 때문에 422 Unprocessable Entity 에러를 발생시킨다. 

 

재시도한 요청의 본문(payload)이 같으면서 같은 요청 키를 사용한 경우, 이전 요청을 아직 진행중인 상태이기 때문에 409 Conflict 에러를 발생시킨다. 이런 경우에 클라이언트는 기다렸다가 요청을 다시 보내면 된다고 판단할 수 있다.

 

예외 발생에 사용한 Exception class와 해당 Exception에 대한 advice 코드는 하단 깃허브 링크를 들어가 config.exception 패키지를 참고하면 된다.

 

 

테스트


위에서 HTTP 메소드의 멱등성 중 POST와 PUT은 기본적으로 멱등성을 보장하지 않는다고 설명했다. 때문에, POST와 PUT 관련 요청이 멱등성을 보장하도록 구성하고 테스트를 진행하도록 하겠다.

 

필자는 사용자 등록(POST)과 사용자 나이 1살 증가(PUT)라는 service 로직이 멱등성을 보장하도록 구현했다.

 

UserAddServiceImpl.java

import com.jdh.idempotent.api.user.application.UserAddService;
import com.jdh.idempotent.api.user.domain.entity.User;
import com.jdh.idempotent.api.user.domain.repository.UserRepository;
import com.jdh.idempotent.api.user.dto.request.UserAddRequestDTO;
import com.jdh.idempotent.config.idempotent.annotaion.Idempotent;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class UserAddServiceImpl implements UserAddService {

    private final UserRepository userRepository;

    /**
     * 사용자 등록
     * @param userAddRequestDTO UserAddRequestDTO
     */
    @Override
    @Transactional
    @Idempotent(expireTime = 20)
    public void addUser(final UserAddRequestDTO userAddRequestDTO) {
        userRepository.save(User.addOf(userAddRequestDTO));
    }

}

 

 

UserEditServiceImpl.java

import com.jdh.idempotent.api.user.application.UserEditService;
import com.jdh.idempotent.api.user.domain.entity.User;
import com.jdh.idempotent.api.user.domain.repository.UserRepository;
import com.jdh.idempotent.api.user.dto.request.UserEditRequestDTO;
import com.jdh.idempotent.api.user.exception.UserException;
import com.jdh.idempotent.api.user.exception.UserExceptionResult;
import com.jdh.idempotent.config.idempotent.annotaion.Idempotent;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class UserEditServiceImpl implements UserEditService {

    private final UserRepository userRepository;

    /**
     * 사용자 정보 수정
     *
     * @param userEditRequestDTO UserEditRequestDTO
     */
    @Override
    @Transactional
    public void editUser(final UserEditRequestDTO userEditRequestDTO) {
        // 사용자 정보 조회
        User findUser = userRepository.findById(userEditRequestDTO.getId())
                .orElseThrow(() -> new UserException(UserExceptionResult.NOT_EXISTS));

        // 수정될 사용자 값 입력
        findUser.editOf(userEditRequestDTO);

        userRepository.save(findUser);
    }

    /**
     * 사용자 나이 1살 증가
     *
     * @param id user id
     */
    @Override
    @Transactional
    @Idempotent
    public void editUserAgePlusOne(final long id) {
        // 사용자 정보 조회
        User findUser = userRepository.findById(id)
                .orElseThrow(() -> new UserException(UserExceptionResult.NOT_EXISTS));

        // 사용자 나이 1살 증가
        findUser.agePlusOne();

        userRepository.save(findUser);
    }

}

 

다음과 같이 @Idempotent 어노테이션을 적용했다.

 

또, 멱등성 요청 시 사용할 Request Key는 UUID를 다음과 같이 header에 담았다.

 

이제 테스트를 진행하겠다.

 

1. 사용자 등록 (POST)

처음 사용자 등록을 요청하면 요청이 성공적으로 수행된다.

 

이후 동일한 요청을 다시 하면 409 Conflict 상태 코드와 함께 요청이 실패한다.

 

만약 동일한 request key에 다른 요청 전문을 사용하면 422 unprocessable Entity 상태 코드와 함께 요청이 실패한다.

 

DB를 확인하면 사용자 정보가 하나만 등록되어 있음을 확인할 수 있다.

 

2. 사용자 나이 1살 증가 (PATCH)

처음 사용자 나이 1살 증가를 요청하면 요청이 성공적으로 수행된다.

 

이후 동일한 요청을 다시 하면 409 Conflict 상태 코드와 함께 요청이 실패한다.

 

DB를 확인하면, 기존에 20살이었던 사용자의 나이가 1살 증가하여 21살이 된 것을 확인할 수 있다.

 

3. 멱등성 보장에 사용할 Request Key를 Header에 담지 않은 경우

멱등성 보장에 사용할 Request Key를 Header에 담지 않은 경우 다음과 같이 400 Bad Request 에러와 함께 요청이 실패한다.

 

 

정리하며


멱등성을 보장하면 단순히 요청으로 돌아온 값이 같을 뿐 아니라 서버 상태나 DB에도 영향을 미치지 않는다. 이렇게 의도하지 않은 문제를 방지하고 요청을 재시도할 수 있기 때문에, 멱등성은 결함 없고 안전한 API를 만드는데 중요하다.

 

전체 코드는 깃허브를 참고하면 된다.

링크 : https://github.com/JangDaeHyeok/Idempotent_Practice

 

GitHub - JangDaeHyeok/Idempotent_Practice: Idempotent practice for Spring Boot, AOP, Redis

Idempotent practice for Spring Boot, AOP, Redis. Contribute to JangDaeHyeok/Idempotent_Practice development by creating an account on GitHub.

github.com

 

 

 


참고

https://endmemories.tistory.com/19

https://docs.tosspayments.com/blog/what-is-idempotency

https://inpa.tistory.com/entry/WEB-%F0%9F%8C%90-HTTP%EC%9D%98-%EB%A9%B1%EB%93%B1%EC%84%B1-%C2%B7-%EC%95%88%EC%A0%95%EC%84%B1-%C2%B7-%EC%BA%90%EC%8B%9C%EC%84%B1-%F0%9F%92%AF-%EC%99%84%EB%B2%BD-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0

728x90