Separating an additional functionality with Redis Cache & AOP(Eng)

yboy·2024년 8월 26일
0

Learning Log 

목록 보기
41/41
post-thumbnail

Situation

While working on a coupon project, a feature was added to prevent abuse when registering coupon codes within the app. The specifications for the new feature are as follows:

  • If a user enters an incorrect coupon code 10 times within 3 minutes, they will be blocked from registering any coupon codes for 10 minutes.
  • If 3 minutes pass and the user has entered fewer than 10 incorrect coupon codes, the counter will reset, regardless of how many times they entered an incorrect code.
  • If the user enters 10 incorrect codes within 3 minutes, they will be blocked from registering coupon codes for 10 minutes, starting from the time they entered the 10th incorrect code, not the 11th or over. For example, if they enter an incorrect code 11 times, the 10-minute block will start from the time of the 10th incorrect entry, not the 11th.

Solution

Due to the tight project schedule, this feature was suddenly added at the last minute. Initially, I implemented the logic to perform validation at the front of the service layer using Redis. The reasons for choosing Redis over a local cache are as follows:

  • The server providing this feature is composed of multiple instances, so using a local cache would not guarantee data consistency across these instances.
  • By using the incr command provided by Redis, the counting logic can be implemented more easily.
  • According to the current team convention, Redis is used globally for caching in general situations.

Controller

    @PostMapping
    public ApiResponse<Void> registerCoupon(@TokenUser UserInfo userInfo,
                                                @RequestBody RegisterCodeCouponRequest registerCodeCouponRequest) {
        final Integer userId = userInfo.getId();
		final String couponCode = registerCodeCouponRequest.getCouponCode();
        
        couponService.registerCoupon(userId, couponCode);

        return ApiResponse.ok();
    }

Service

	private final RedisTemplate<String, Integer> redisTemplate;
    
    private static final Integer MAX_REQUEST_MAX_COUNT = 10;
    private static final int MAX_REQUEST_WAITING_MINUTES = 10;
    private static final int MAX_REQUEST_TIME_OUT_SECOND = 180;

    @Transactional
    public Dto registerCoupon(final Integer userId, final String couponCode) {
        final ValueOperations<String, Integer> valueOperations = redisTemplate.opsForValue();
        final String key = String.format(REDIS_KEY_LIMIT_MAX_REQUEST_REGISTER_CODE_COUPON, userId);
        final Integer count = valueOperations.get(key);

        if (count >= MAX_REQUEST_MAX_COUNT) {
            throw new CustomException(
            Code.BAD_REQUEST, 
            String.format("please try later after %dminutes", MAX_REQUEST_WAITING_MINUTES)
            );
        }
        if(isIncorrectCouponCode(userId, couponCode)) {
           if (Boolean.TRUE.equals(redisTemplate.hasKey(key))) {
               valueOperations.increment(key, 1);
               return;
           }

           valueOperations.set(key, 1, MAX_REQUEST_TIME_OUT_SECOND, TimeUnit.SECONDS);
        }
        
 .... 
 ..... businuess logic ~ing       
        

Although the above implementation works without issues, it seems necessary to separate the business logic that verifies the coupon code entered by the user and issues a coupon based on the verification result from the logic that prevents coupon code abuse. Therefore, I decided to refactor the coupon abuse prevention logic, which is an additional functionality, using AOP.

Before applying AOP, let's briefly understand what AOP (Aspect-Oriented Programming) is.

AOP (Aspect Oriented Programming)

It means dividing the logic into core concerns and additional concerns, then modularizing each based on those concerns.

AOP can be simply defined as above. By using AOP, you can easily separate additional functionalities from business logic and reuse core repetitive code. Moreover, with Spring AOP, you can specify the execution points of Aspects, allowing you to determine when the logic separated by AOP should be called in relation to the target method, making it very convenient. If you're curious to learn more about the concept of AOP, there are plenty of resources available for further reading.

Now, let's proceed with the refactoring.

1. Abstraction Repository for managing Maximum number of requests

Before using AOP, I abstracted the repository that manages the maximum request limit. Although Redis is currently being used, it might be replaced by another database in the future. Additionally, assuming the use of Redis, the purpose is to ensure that no other parts of the code need to know about or access any RedisTemplate-related logic, aside from this repository.

interface

public interface MaxRequestHitsRepository {

    void increaseCount(final Integer id, final int timeOutSeconds);

    boolean isHit(final int currentCount, final int maxCount);

    boolean isOverHit(Integer currentCount, int maxCount);

    Integer getCount(final Integer id, final int timeOutSeconds);

    void setCount(Integer id, Integer currentCount, int waitingMinutes);
}

class

@Component
@RequiredArgsConstructor
public class MaxRequestHitsRedisRepository implements MaxRequestHitsRepository {

    private final RedisTemplate<String, Integer> redisTemplate;

    @Override
    public void increaseCount(final Integer id, final int timeOutSeconds) {
        final ValueOperations<String, Integer> stringIntegerValueOperations = redisTemplate.opsForValue();
        final String key = String.format(REDIS_KEY_LIMIT_MAX_REQUEST_REGISTER_CODE_COUPON, id);

        if (Boolean.TRUE.equals(redisTemplate.hasKey(key))) {
            stringIntegerValueOperations.increment(key, 1);
            return;
        }

        stringIntegerValueOperations.set(key, 1, timeOutSeconds, TimeUnit.SECONDS);
    }

    @Override
    public boolean isHit(final int currentCount, final int maxCount) {
        return currentCount == maxCount;
    }

    @Override
    public boolean isOverHit(final Integer currentCount, final int maxCount) {
        return currentCount > maxCount;
    }

    @Override
    public Integer getCount(final Integer id, final int timeOutSeconds) {
        final ValueOperations<String, Integer> stringIntegerValueOperations = redisTemplate.opsForValue();
        final String key = String.format(REDIS_KEY_LIMIT_MAX_REQUEST_REGISTER_CODE_COUPON, id);

        if (Boolean.TRUE.equals(redisTemplate.hasKey(key))) {
            return stringIntegerValueOperations.get(key);
        }

        return 0;
    }

    @Override
    public void setCount(final Integer id, final Integer currentCount, final int waitingMinutes) {
        final ValueOperations<String, Integer> stringIntegerValueOperations = redisTemplate.opsForValue();
        final String key = String.format(REDIS_KEY_LIMIT_MAX_REQUEST_REGISTER_CODE_COUPON, id);
        stringIntegerValueOperations.set(key, currentCount, waitingMinutes, TimeUnit.MINUTES);
    }

}

2. Define Anotation

Next, let's define the Annotation that will be applied to the target method.

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidMaxRequest {

    Class<?> keyClass();

    String keyClassMethodName() default "";

    int timeoutSeconds() default 0; // Cache data refresh time

    int maxCount() default 0; // Maximum number of requests

    int waitingMinutes() default 0; // Waiting time after maximum number of requests.

}

controller

Next, Apply the annotation to the target method where it will be used.

    @PostMapping
    @ValidMaxRequest(keyClass = UserInfo.class, keyClassMethodName = "getId", timeoutSeconds = 180, maxCount = 10, waitingMinutes = 10)
    public ApiResponse<Void> registerCoupon(@TokenUser UserInfo userInfo,
                                                @RequestBody RegisterCodeCouponRequest registerCodeCouponRequest) {
        final Integer userId = userInfo.getId();
		final String couponCode = registerCodeCouponRequest.getCouponCode();
        
        couponService.registerCoupon(userId, couponCode);

        return ApiResponse.ok();
    }

3. Define Aspect to use AOP

Next, you need to define the logic that will be executed when the Annotation defined above is applied.

@Aspect
@Component
@Slf4j
@RequiredArgsConstructor
public class ValidMaxRequestAspect {

    private final MaxRequestHitsRepository maxRequestHitsRepository;

    @Before(value = "@annotation(com.api.aop.ValidMaxRequest)")
    public void validMaxRequest(JoinPoint joinPoint) throws Throwable {

      	... 
    }

    @AfterThrowing(value = "@annotation(com.aop.ValidMaxRequest)", throwing = "exception")
    public void increaseRequestCount(JoinPoint joinPoint, Exception exception) throws Throwable {
    	...
    }

}

These can be defined as two methods with different execution timings.

validMaxRequest() can be annotated with @Before, so it executes before the target method's logic is run.

increaseRequestCount() can be annotated with @AfterThrowing, so it executes when an Exception occurs in the target method.

validMaxRequest()

    @Before(value = "@annotation(com.laundrygo.purchase.api.aop.ValidMaxRequest)")
    public void validMaxRequest(JoinPoint joinPoint) throws Throwable {

        MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();

        ValidMaxRequest validMaxRequest = methodSignature.getMethod().getAnnotation(ValidMaxRequest.class);

        final Class<?> keyClass = validMaxRequest.keyClass();
        final String keyClassMethodName = validMaxRequest.keyClassMethodName();
        final int timeoutSeconds = validMaxRequest.timeoutSeconds();
        final int maxCount = validMaxRequest.maxCount();
        final int waitingMinutes = validMaxRequest.waitingMinutes();
        final Integer id = (Integer) getId(keyClass, keyClassMethodName, joinPoint.getArgs());

        final Integer currentCount = maxRequestHitsRepository.getCount(id, timeoutSeconds);

        if (maxRequestHitsRepository.isOverHit(currentCount, maxCount)) {
            log.info("overHit!");
            throw new CustomException(Code.BAD_REQUEST, String.format(errorMessageFormat, waitingMinutes));
        }

        if (maxRequestHitsRepository.isHit(currentCount, maxCount)) {
            log.info("Hit!, maxCount:{}", maxCount);
            maxRequestHitsRepository.setCount(id, currentCount, waitingMinutes);
            maxRequestHitsRepository.increaseCount(id, timeoutSeconds);
            throw new CustomException(Code.BAD_REQUEST, String.format(errorMessageFormat, waitingMinutes));
        }
    }

This method checks if the number of coupon code registration failures has exceeded the maximum count before executing the target method's logic. A noticiable point in this method is the use of reflection.

Extracting key values using reflection

    private Object getId(Class<?> keyClass, String keyClassMethodName, Object[] args)
            throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {
        if (keyClass.isPrimitive() || Primitives.isWrapperType(keyClass)) {

            return Arrays.stream(args).filter(
                            arg -> arg.getClass() == Primitives.wrap(keyClass))
                    .findFirst().orElseThrow(() -> new CustomException(Code.SERVER_ERROR));
        } else {
            var obj = Arrays.stream(args).filter(arg -> arg.getClass() == keyClass)
                    .findFirst().orElseThrow(() -> new CustomException(Code.SERVER_ERROR));

            Method method = keyClass.getDeclaredMethod(keyClassMethodName);
            return method.invoke(obj);
        }
    }

Let's start by briefly understanding what Reflection is:

A feature in Java that allows dynamic programming by enabling manipulation of compiled classes.

With Reflection, you can retrieve almost all information about a class, such as constructors, fields, and methods, using just the class name, as demonstrated in the logic above. However, since Reflection analyzes types and retrieves information at runtime rather than compile-time, it prevents the JVM from optimizing the code, which can lead to performance overhead. Additionally, because it allows access to private variables and methods that would otherwise be inaccessible, it can break encapsulation. Therefore, it's generally advisable to avoid using Reflection in direct business logic whenever possible.

Note:
The code below checks whether the keyClass passed as a Class type is a primitive type. It first uses the isPrimitive() method provided by java.lang.Class and then double-checks using isWrapperType() provided by Guava. The reason for the double-check with isWrapperType() is that the isPrimitive() method of java.lang.Class does not work on the wrapper classes for primitives.

 if (keyClass.isPrimitive() || Primitives.isWrapperType(keyClass))

increaseRequestCount()

    @AfterThrowing(pointcut = "@annotation(com.laundrygo.purchase.api.aop.ValidMaxRequest)", throwing = "exception")
    public void increaseRequestCount(JoinPoint joinPoint, Exception exception) throws Throwable {
        if (!(exception instanceof CustomException)) {
            throw exception;
        }

        MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();

        ValidMaxRequest validMaxRequest = methodSignature.getMethod().getAnnotation(ValidMaxRequest.class);

        final Class<?> keyClass = validMaxRequest.keyClass();
        final String keyClassMethodName = validMaxRequest.keyClassMethodName();
        final int timeoutSeconds = validMaxRequest.timeoutSeconds();
        final Integer id = (Integer) getId(keyClass, keyClassMethodName, joinPoint.getArgs());

        maxRequestHitsRepository.increaseCount(id, timeoutSeconds);
        final Integer currentCount = maxRequestHitsRepository.getCount(id, timeoutSeconds);
        log.info("request-increment(), count:{}", currentCount);
    }

This method allows you to handle related logic if an Exception occurs during the execution of the target method. If an exception is thrown due to a failure in coupon registration within the target method, this logic increments the value corresponding to the key in the repository.

Conclusion

In conclusion, the INCR command in Redis was used to implement the coupon code registration abuse prevention logic, and Spring AOP was employed to separate this logic from the business logic.

0개의 댓글