일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
31 |
- K3S
- JPQL
- 자바 ORM 표준 JPA 프로그래밍
- 약속 장소 추천
- 모임 장소 추천
- 객체지향 쿼리 언어
- Domain Driven Design
- Spring Batch
- kusitms
- cicd
- 영속성
- ddd
- JPA
- 큐시즘
- java
- 한국대학생it경영학회
- RESTClient
- redis
- 최범균
- springboot
- 중간 지점 추천
- Spring
- 백엔드
- 중간 장소 추천
- 도메인 주도 개발 시작하기
- 이펙티브자바
- Container Registry
- 30기
- 쿠버네티스
- GitHub Actions
- Today
- Total
코딩은 마라톤
[Redis] 외부 API Rate Limiter를 만들어보기 (with. AOP) 본문
SPOT은... 외부 API 호출이 많다.
SPOT의 메인 기능은 애플리케이션 레벨에서 최적의 중간 지점(역)을 계산하고, 이후 여러 출발지부터 중간 지점까지의 경로를 보여주는 것이다.
이때 출발지 ~ 중간 지점까지의 경로를 대중교통과 자가용, 두 가지를 기준으로 보인다.
중간 지점을 계산할 때, 지하철역 공공데이터를 가져와서 환승까지 전부 고려하여 계산하기 때문에 지하철 상세 경로는 보여줄 수 있지만, 버스나 자가용은 구현할 엄두가 나지 않았다. 그래서 대중교통 경로는 Odsay를, 자가용 경로는 Kakao Mobility를 호출하여 가져오도록 하였다.
그러나,,
외부 API를 사용하는 편리함을 무료로 얻는 데는 한계가 있다.
특히 Odsay의 제한 호출수는 일 1,000회로 리미트를 초과하면 서비스를 운영할 수 없다..
어떻게 하면 해결할 수 있을까?
- 캐싱을 적용하기
이미 서비스에 적용되어 있는 부분이다.
프로젝트 초기의 설계 당시에는 지도 조회할 때마다 매번 외부 API호출을 하려 했다.
하지만 문득 이런 생각이 들었다.
"악성 사용자가 새로고침을 반복하면? 우리가 가진 1일 1,000회의 호출 제한은 금방 소진되지 않을까?"
그렇다. 실제로는 1분도 안 되어 1,000회가 사라질 수 있는 구조였고, 우리는 일 1,000회의 작고 소중한 리미트를 야금야금 사용해야 했다.
이를 해결하기 위해, 고민을 하다가 캐싱을 떠올렸다.
경로 데이터는 몇 초, 몇 분이 지났다고 해서 급격하게 변하지는 않는다고 판단했다.
- 모임에 출발지가 추가되지 않았을 때, 이미 요청했던 값이 Redis에 있다면
- 외부 API를 호출하지 않고 캐시된 결과를 반환하기
이 방법만으로도 외부 호출 수를 크게 줄일 수 있었다.
- 일일 API Rate Limiter를 만들기
하지만 캐싱은 "이미 호출한 값"에만 유효하고, 결국 새로운 요청이 들어올 경우에는 외부 API를 호출해야 한다.
그래서 일일 호출 횟수를 제한하고, 초과 시 대응 가능한 로직이 필요했기에 어떻게 하면 좋을지 찾아보았다.
아래 두 블로그에서 제가 겪고 있던 고민과 유사한 상황을 실제로 해결한 사례들을 보게 되었고, 이를 참고하여 Rate Limiter를 개발하게 되었다.
Redis Lua Script를 이용해서 API Rate Limiter개발
AWS SES 일일 50,000건 제한 Redis로 대응하기
설계
API Rate Limiter에서 필요한 기능은 다음과 같다.
- Kakao Mobility, Odsay 별로 일일 호출수 누적 및 리셋
- 일일 호출수 1,000회 초과 시 예외 처리
1. 커스텀 애노테이션 만들기
@Target(value = {ElementType.TYPE, ElementType.METHOD})
@Retention(value = RetentionPolicy.RUNTIME)
public @interface LimitRequestPerDay {
String key() default "";
int count();
}
지금은 호출하는 외부 API가 2개이지만, 추후 늘어날 수 있을 것을 대비해 애노테이션으로 메타 데이터 관리하는 방식으로 구현했다.
@LimitRequestPerDay(
key = "odsay-transit",
count = 1000
)
public OdsayTransitRouteSearchResponse sendRequest(OdsayTransitRouteSearchRequest request) {
// API 호출 로직
}
2. API Rate Limiter 구현체 만들기
@Component
@RequiredArgsConstructor
public class RedisRateLimiter implements RateLimiter {
private final RedisTemplate<String, String> redisRateLimitTemplate;
@Override
public void tryApiCall(LimitRequestPerDay limitRequestPerDay) {
String key = limitRequestPerDay.key();
String value = redisRateLimitTemplate.opsForValue().get(key);
if (value == null) {
redisRateLimitTemplate.opsForValue().set(key, "1", getTTL());
return;
}
long previousCount = Long.parseLong(value);
if ((int) previousCount >= limitRequestPerDay.count()) {
// Todo. Discord Notification
throw new ClientException(ClientErrorType.EXCEED_RATE_LIMIT_PER_DAY);
}
redisRateLimitTemplate.opsForValue().increment(key);
}
private Duration getTTL() {
ZonedDateTime now = ZonedDateTime.now(TimeUtil.KST_ZONE_ID);
ZonedDateTime midnight = now.plusDays(1).toLocalDate().atStartOfDay(TimeUtil.KST_ZONE_ID);
return Duration.between(now, midnight);
}
}
- LimitRequestPerDay 애노테이션에서 받아온 key를 기반으로 value를 가져온다.
- 만약 value가 없으면 key에 "1"을 삽입한다.
- TTL은 요청 시점에서 자정 전까지의 시간을 설정한다.
- 이전 호출수가 제한 호출수 이상일 경우, 예외를 반환한다.
- 지금은 예외만 처리하고 끝나는데, 추후 디스코드에 알림을 보내는 기능도 추가할 예정이다.
- 위 경우에 해당하지 않는다면, INCR 명령어를 통해 원자적 계산을 수행한다.
3. AOP를 활용할 Aspect를 구현하기
@Aspect
@Component
public class RateLimiterAspect {
private final RateLimiter rateLimiter;
public RateLimiterAspect(@Qualifier("redisRateLimiter") RateLimiter rateLimiter) {
this.rateLimiter = rateLimiter;
}
@Around("execution(* com.meetup.server.global.clients.odsay.OdsayTransitRouteSearchClient.*(..)) || " +
"execution(* com.meetup.server.global.clients.kakao.mobility.KakaoMobilityClient.*(..))")
public Object findLimitRequestPerDayAnnotation(ProceedingJoinPoint joinPoint) throws Throwable {
LimitRequestPerDay limitRequestPerDay = getLimitRequestPerDayAnnotationFromMethod(joinPoint);
if (Objects.isNull(limitRequestPerDay)) {
return joinPoint.proceed();
}
rateLimiter.tryApiCall(limitRequestPerDay);
return joinPoint.proceed();
}
private LimitRequestPerDay getLimitRequestPerDayAnnotationFromMethod(ProceedingJoinPoint joinPoint) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
return method.getAnnotation(LimitRequestPerDay.class);
}
}
외부 API를 사용하는 곳에서 RateLimiter를 반복해서 사용하는 것은 결국 부가기능의 중복으로 볼 수 있다.
이는 횡단 관심으로 볼 수 있기 때문에 AOP를 사용한다.
- 메소드에 적용된 LimitRequestPerDay 애노테이션에서 값을 가져온 다음 값이 있는 경우에만 RateLimiter를 동작하도록 한다.
결과
@Test
void 싱글스레드_환경에서_RedisRateLimiter_원자성을_보장한다() {
// given
LimitRequestPerDay annotation = new LimitRequestPerDay() {
@Override
public String key() {
return "test";
}
@Override
public int count() {
return 5;
}
@Override
public Class<? extends Annotation> annotationType() {
return LimitRequestPerDay.class;
}
};
AtomicInteger successCount = new AtomicInteger();
AtomicInteger failCount = new AtomicInteger();
for (int i = 0; i < 10; i++) {
try {
rateLimiter.tryApiCall(annotation);
successCount.incrementAndGet();
} catch (ClientException e) {
failCount.incrementAndGet();
}
}
System.out.println("성공 횟수: " + successCount.get());
System.out.println("실패 횟수: " + failCount.get());
Assertions.assertEquals(5, successCount.get());
Assertions.assertEquals(5, failCount.get());
}
- 제한 호출수 : 10
- 초기 5번의 호출은 successCount를 증가한다.
- 이후 5번의 호출은 ClientException이 발생하여 failCount를 증가한다.
이대로 끝?!
테스트 코드와 실제 호출 시 호출수도 잘 저장된다. 그래서 이대로 마무리를 지으려고 하였다.
그런데 이전에 쿠폰 발급에서 발생하는 동시성 문제를 해결했던 경험이 갑자기 떠올랐고, 멀티스레드 환경에서는 Race Condition이 발생하지 않는지 궁금했다.
@Test
void 멀티스레드_환경에서_RedisRateLimiter_원자성을_보장한다() throws InterruptedException {
// given
LimitRequestPerDay annotation = new LimitRequestPerDay() {
@Override
public String key() {
return "test";
}
@Override
public int count() {
return 10;
}
@Override
public Class<? extends Annotation> annotationType() {
return LimitRequestPerDay.class;
}
};
int totalThreads = 100;
ExecutorService executorService = Executors.newFixedThreadPool(totalThreads);
CountDownLatch latch = new CountDownLatch(totalThreads);
AtomicInteger successCount = new AtomicInteger();
AtomicInteger failCount = new AtomicInteger();
for (int i = 0; i < totalThreads; i++) {
executorService.submit(() -> {
try {
rateLimiter.tryApiCall(annotation);
successCount.incrementAndGet();
} catch (ClientException e) {
failCount.incrementAndGet();
} finally {
latch.countDown();
}
});
}
latch.await();
executorService.shutdown();
System.out.println("성공 횟수: " + successCount.get());
System.out.println("실패 횟수: " + failCount.get());
Assertions.assertEquals(10, successCount.get());
}
동시성 이슈가 역시나 발생했다.
@Override
public void tryApiCall(LimitRequestPerDay limitRequestPerDay) {
String key = limitRequestPerDay.key();
String value = redisRateLimitTemplate.opsForValue().get(key);
if (value == null) {
redisRateLimitTemplate.opsForValue().set(key, "1", getTTL());
return;
}
long previousCount = Long.parseLong(value);
if ((int) previousCount >= limitRequestPerDay.count()) {
// Todo. Discord Notification
throw new ClientException(ClientErrorType.EXCEED_RATE_LIMIT_PER_DAY);
}
redisRateLimitTemplate.opsForValue().increment(key);
}
위 코드는 아까 RedisRateLimiter의 부분이다.
- key를 이용해 [GET] 명령어를 수행한다.
- 없을 경우, [SET] 명령어를 이용해 새로운 value를 삽입한다.
- 있을 경우, [INCR] 명령어를 수행한다.
아까 말했다시피, INCR은 원자적으로 처리된다. 결국 동시성 이슈가 발생하는 부분은 바로 [GET] 조회할 때다.
Race Condition을 해결하기 위해 Redis Lua Script, MUlTI 명령어 (트랜잭션), Lock 등이 있다.
다음 글에서 위 해결책을 고민해 보고 적용하는 과정에 대해 작성해야겠다 ✌🏻
'Backend' 카테고리의 다른 글
모이삼에서 사용하는 Git-flow 방식을 소개합니다 (3) | 2025.08.12 |
---|---|
[Redis] Lua Script로 동시성 문제 해결하기 (2) | 2025.06.09 |
[RabbitMQ] Delayed Message Plugin를 활용한 메시지 지연 처리 (SpringBoot) (4) | 2025.02.04 |
[도메인 주도 개발 시작하기] Chapter6. 응용서비스와 표현 영역 (1) | 2024.11.18 |
[도메인 주도 개발 시작하기] Chapter5. 스프링 데이터 JPA를 이용한 조회 기능 (1) | 2024.11.03 |