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:
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:
@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();
}
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.
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.
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.
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);
}
@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);
}
}
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.
}
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();
}
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.
@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.
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))
@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.
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.