쿠폰 프로젝트를 진행하던 중, APP내 쿠폰 코드 등록 어뷰징 방지를 위한 기능을 추가하기로 하였다. 추가된 기능 스펙 정의는 아래와 같다.
프로젝트의 일정이 얼마남지 않았던 상황에서의 갑작스러운 기능 추가여서 처음에는 아래와 같이 service layer의 앞 단에서 아래와 같이 Redis를 사용해 validation 하도록 로직을 짰다. 로컬 캐시가 아닌 Redis를 선택한 이유는 아래와 같다.
@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("%d분 후에 다시 시도해주세요.", 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
사실 위와 같이 구현해도 기능을 하는데 문제가 되진 않지만 실제 위 메서드의 역할인 사용자가 입력한 쿠폰 코드를 검증하고 검증 성공 유무에 따라 유저에게 쿠폰을 발급하는 비즈니스 로직과 쿠폰 코드 어뷰징을 방지로직은 분리될 필요가 있어보인다.
따라서 부가적인 기능인 쿠폰 어뷰징 방지로직을 AOP를 사용해 리펙토링 하기로 결정하였다.
우선 AOP를 적용하기에 앞서 AOP가 무엇인지에 대해 간단하게 알아보자.
어떤 로직을 기준으로 핵심적인 관점, 부가적인 관점으로 나누어서 보고 그 관점을 기준으로 각각 모듈화하겠다는 것
AOP의 정의는 간단히 위와 같이 정의할 수 있다. AOP를 사용하면 부가적인 기능과 비즈니스 로직을 쉽게 분리할 수 있고 핵심적으로 반복되는 코드를 분리해 재사용할 수 있다. 또한 스프링 AOP를 사용하면 Aspect 실행 지점을 지정해 AOP로 분리된 로직이 타켓 메서드의 어느 시점에 호출할지를 정해줄 수 있어 매우 용이하다.
AOP의 개념에 대해 더 궁금하다면 많은 자료가 있으니 찾아보면 좋을 것 같다.
이제 리펙토링을 진행해 보자.
AOP를 사용하기 앞서 최대 요청 수를 관리하는 Repository를 추상화하였다. 현재는 Redis를 사용하고 있지만 나중에 다른 DB로 대체될 수도 있기 때문이다. 또한 Redis를 사용한다는 가정하에 해당 Repository 외에는 RedisTemplate관련 로직을 알 필요도 없고 접근하지 못하도록 하기 위한 목적도 있다.
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);
}
}
다음으로는 타켓 메서드에 적용할 Annotation을 정의해 보자.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidMaxRequest {
Class<?> keyClass();
String keyClassMethodName() default "";
int timeoutSeconds() default 0; // 캐시 데이터 갱신 시간
int maxCount() default 0; // 최대 요청수
int waitingMinutes() default 0; // 최대 요청수 이후 대기시간
}
이제 해당 에노테이션이 적용될 타멧 메서드에 에노테이션을 적용하면 된다.
@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();
}
다음으로 위에서 정의한 Annotation이 적용된 로직에서 처리해야 하는 로직을 정의해야 한다.
@Aspect
@Component
@Slf4j
@RequiredArgsConstructor
public class ValidMaxRequestAspect {
private final MaxRequestHitsRepository maxRequestHitsRepository;
@Before(value = "@annotation(com.laundrygo.purchase.api.aop.ValidMaxRequest)")
public void validMaxRequest(JoinPoint joinPoint) throws Throwable {
...
}
@AfterThrowing(value = "@annotation(com.laundrygo.purchase.api.aop.ValidMaxRequest)", throwing = "exception")
public void increaseRequestCount(JoinPoint joinPoint, Exception exception) throws Throwable {
...
}
}
다음과 같이 실행 시점이 다른 두 개의 메서드로 정의될 수 있다.
validMaxRequest()는 @Before 에노테이션을 적용하여 해당 타켓 메서드의 로직 실행 전에 메서드가 동작하게 된다.
increaseRequestCount()는 @AfterThrowing 에노테이션을 적용하여 타켓 메서드에서 Exception이 발생했을 시에 메서드가 동작하게 된다.
@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));
}
}
해당 메서드로 타켓 메서드 로직 실행 전에 쿠폰 코드 등록 실패 회수가 max count를 넘어섰는지 확인한다. 위 메서드에서 주목해볼 점은 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);
}
}
우선 Reflection이 뭔지 간단하게 알아보자
컴파일한 클래스를 동적으로 프로그래밍 가능하도록 자바에서 지원하는 기능
Reflection을 사용하면 위의 로직 처럼 클래스의 이름만 가지고도 생성자, 필드, 메서드 등등 해당 클래스에 대한 거의 모든 정보를 가져올 수 있다. 하지만 컴파일 타임이 아닌 런타임에 동적으로 타입을 분석하고 정보를 가져오므로 JVM을 최적화할 수 없기 때문에 성능 오버헤드가 발생할 수 있다. 또한 직접 접근할 수 없는 private 변수, 메서드에 접근하기 때문에 추상화가 깨질 수 있다는 단점이 있다. 직접적인 비즈니스 로직에서는 가급적 사용을 자제하는 것이 옳아 보인다.
@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);
}
해당 메서드로 타켓 메서드 로직 실행 중에 Exception이 발생하면 관련 로직을 처리해 줄 수 있다. 타멧 메서드에서 쿠폰 등록이 실패하여 예외가 발행하였다면 해당 로직을 통해 repository의 key 값에 해당하는 value 값을 incr 해주고 있다.
결론적으로 Redis의 incr 명령어를 사용해 쿠폰 코드 등록 어뷰징 방지 로직을 구현하고 Spring AOP를 사용해 해당 로직을 비즈니스 로직에서 분리해 보았다.