1. 서론
프로젝트를 하다보면
특정 메소드의 로그를 쌓거나,
반복적인 요청을 방지하거나,
공통 변수를 받거나 등등
공통적으로 작동해야하는 기능을 개발해야할 때가 있습니다.
그렇다면 이러한 기능을 어떻게 개발할 수 있을까요?
가장 쉬운 방법은 필요한 곳에 하나씩 개발해서 추가하는 것 입니다.
하지만 그렇게 되면 중복코드가 많아질 것이고, 코드를 수정해야한다면 모든 코드를 수정해야 하는 불편함이 존재할 것입니다.
이번 글은 그러한 불편함을 줄인 방법에 대해 글을 쓰려고 합니다.
2. 다수 요청 방지 Annoatation 개발
2 - 1) Annotation Value 개발
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
int value() default 60; // 초 단위
}
- Target(ElementType.METHOD)
- @Target 어노테이션을 사용하여, 이 사용자 정의 어노테이션(@RateLimit)이 적용될 수 있는 위치를 지정합니다.
- ElementType.METHOD는 이 어노테이션이 메서드에만 적용될 수 있음을 의미합니다. 즉, 클래스나 필드가 아닌 메서드에만 이 어노테이션을 사용할 수 있습니다.
- Retention(RetentionPolicy.RUNTIME)
- @Retention 어노테이션을 사용하여, 이 사용자 정의 어노테이션이 유지되는 시점을 설정합니다.
- RetentionPolicy.RUNTIME는 이 어노테이션이 런타임(실행 시)에도 유지됨을 의미합니다. 따라서, 이 어노테이션은 실행 중에 리플렉션(reflection)을 통해 읽을 수 있습니다. 즉, 이 어노테이션에 대한 정보를 실행 시에도 사용할 수 있습니다.
- @interface RateLimit
- RateLimit라는 사용자 정의 어노테이션을 정의합니다.
2 - 2) Annotation 개발
@Aspect
@Component
@RequiredArgsConstructor
public class RateLimitAspect {
private final CacheManager cacheManager;
@Around("@annotation(rateLimit)")
public Object checkRateLimit(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable {
// username 가져오기
String username = extractUsername(joinPoint);
String key = "rate:limit:" + username;
Cache cache = cacheManager.getCache("rateLimitCache");
if (cache != null) {
Cache.ValueWrapper valueWrapper = cache.get(key);
if (valueWrapper != null) {
throw new IllegalStateException(rateLimit + "초 후 재요청이 가능합니다.");
}
// 캐시에 저장
cache.put(key, "1");
try {
return joinPoint.proceed();
} finally {
new Timer().schedule(new TimerTask() {
@Override
public void run() {
cache.evict(key);
}
}, rateLimit.value() * 1000L);
}
}
return joinPoint.proceed();
}
private String extractUsername(ProceedingJoinPoint joinPoint) {
// 1. 메서드 파라미터에서 직접 username 찾기
Object[] args = joinPoint.getArgs();
for (Object arg : args) {
if (arg instanceof String) {
return (String) arg;
}
}
// 2. Spring Security Context에서 찾기
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.isAuthenticated()) {
return authentication.getName();
}
throw new IllegalStateException("사용자를 찾을 수 없습니다.");
}
}
- @Aspect : AOP 등록 / @Component : Bean 등록 / @RequiredArgsConstructor : 생성자 주입
- @Around("@annotation(rateLimit)")
- AOP Around Advice : 특정 어노테이션이 붙은 메서드 호출 전후로 이 로직을 실행합니다.
- ProceedingJoinPoint : 실제로 호출된 메서드
- RateLimit : 어노테이션에서 설정된 속도 제한 값
- extractUsername 메서드
- 현재 사용자 이름을 가져오는 메서드
- 글로벌 Authentication 으로 username을 가져온 것을 똑같이 가져옴
- username을 사용해 캐시 키 생성
- cacheManager를 통해 캐시를 가져옴
- 캐시에 해당 키가 있다면(즉, 최근에 동일한 사용자가 요청을 보낸 적이 있다면), 예외를 던짐
- joinPoint.proceed() : 실제로 메서드 실행
- 그 후 rateLimit.value() 만큼의 시간이 지난 후 캐시에서 해당 키를 제거
- joinPoint.proceed()
- 캐시가 없거나 특별한 예외가 발생하지 않으면, 메서드를 정상적으로 실행
2 - 3) Spring Cache 설정
@Bean
public CacheManager cacheManager() {
ConcurrentMapCacheManager cacheManager = new ConcurrentMapCacheManager();
cacheManager.setCacheNames(List.of("rateLimitCache"));
cacheManager.setCacheNames(List.of("boardStore"));
return cacheManager;
}
3. 적용
아래와 같이 username 별로 해당 메서드를 실행하는 제한을 걸고 싶다면 Annoation을 적용하면 됩니다.
@RateLimit(120)
@Transactional
public void save(String username, CommunitySaveRequest request) {
Member member = memberDao.get(username);
validateSaveRequest(member, request);
Community save = communityDao.save(Community.toEntity(member, request));
if (!request.getImageList().isEmpty()) {
communityImagesDao.updateAll(save.getId(), request.getImageList());
}
}
게시글 작성에 대한 시간제한을 120초(2분)을 설정
2분내로 똑같은 사람이 요청을 보내면 아래와 같은 에러메시지가 출력되게 됩니다.