최근 추천 시스템에서 네이버 쇼핑 검색 API를 활용해 다양한 키워드 조합에 대해 상품을 수집하는 로직을 테스트 중이었다. 초기에는 예상보다 잘 작동했으나 테스트를 반복할수록 다음과 같은 문제가 빈번하게 발생했다.
429 Too Many Requests
HTTP 에러초기 구현은 Guava 라이브러리의 RateLimiter
를 활용한 초당 제한 제어였다. 코드는 아래와 같다.
private final RateLimiter rateLimiter = RateLimiter.create(10.0); // 초당 10회
rateLimiter.acquire(); // blocking 방식으로 호출 간격 자동 조정
이는 JVM 메모리 내에서 요청 간 간격을 자동으로 조정해주는 도구이며 단일 스레드 환경에선 효과적이다. 그러나 아래와 같은 한계를 명확히 드러냈다.
다중 스레드 환경에서 동시 요청 시 초과 호출을 막지 못함
RateLimiter.acquire()
는 내부적으로 호출 간격을 조정하지만 비동기 또는 다수 스레드가 동시에 acquire()
를 호출하는 상황에서는 서로 간 간섭이 없이 지나칠 수 있다. 그 결과 짧은 시간에 여러 요청이 API 서버로 몰리게 된다.
분산 환경에서는 효과가 없음
비록 이번 환경은 EC2 단일 인스턴스였지만 추후 여러 인스턴스로 수평 확장할 경우 Guava RateLimiter
는 인스턴스 간 동기화가 되지 않아 전체 API 호출 수 제한을 통제할 수 없다.
예상치 못한 RateLimiter 실패
단일 인스턴스에서도 짧은 순간 많은 스레드가 생성되어 Race Condition이 발생하면 429가 빈번하게 나타났으며 이는 사용자 경험에도 영향을 줄 수 있는 치명적인 문제였다.
이 문제를 해결하기 위해 RateLimiter를 제거하고 Redis를 활용한 초당 쿼터 제한 로직을 새롭게 도입했다.
// 1초 단위 키 생성
String secondKey = "naver:quota:second:" + currentSecond();
Long secCount = redisTemplate.opsForValue().increment(secondKey);
redisTemplate.expire(secondKey, Duration.ofSeconds(2));
if (secCount > SECOND_LIMIT) {
return false; // 초당 제한 초과
}
Redis를 통해 초당 요청 수(9건) 초과 여부를 분산 환경에서도 안전하게 검사한다. 네이버 검색 API는 초당 요청 수가 10건으로 알고 있지만 혹시나 모를 상황을 대비해 1건은 남겨놨다. expire
를 2초로 설정해 1초 단위 키의 메모리 자동 정리하도록 했다.
Long dailyCount = redisTemplate.opsForValue().increment("naver:quota:count");
if (dailyCount >= 25000) {
return false; // 일일 제한 초과
}
일일 제한은 여전히 Redis 기반으로 관리되며 매일 00시에 자동 초기화되도록 구성되어 있다.
이번 구조 변경은 단순히 429
에러를 피하기 위한 임시방편이 아니라 API 호출 흐름의 신뢰성과 확장성을 동시에 확보하기 위한 전략적인 리팩토링이었다. 주요 이유는 다음과 같다.
정확하고 일관된 제어를 위해 서버 간 공유 가능한 상태가 필요했다.
RateLimiter는 인스턴스 내에서만 동작하며 JVM 메모리에 의존한다. 이는 요청 제한 수치를 한곳에서 통제하지 못해 전체 시스템 차원에서 호출 수를 정확히 추적할 수 없다는 한계가 있다. Redis를 활용하면 단일 소스(Redis)를 중심으로 모든 호출 수를 통제할 수 있다.
향후 서버 확장을 고려한 유연한 구조
현재는 단일 EC2지만 이후 트래픽 증가나 서비스 확장으로 인해 여러 서버가 배치된다면 중앙화된 호출 수 제어 방식(Redis 기반)이 반드시 필요하다. 미리 준비된 구조를 갖춰두는 것이 안정적이다.
Race Condition 제거를 위한 원자적 연산 도입
Redis의 INCR
, EXPIRE
등은 원자적이기 때문에 동시성 문제에서 자유롭다. 서버가 몇 개든 스레드가 몇 개든 관계없이 초당 호출 수를 정확히 제어할 수 있다.
구현이 단순하고 직관적이며 관리가 쉬움
매 초마다 새로운 키가 생성되고 자동으로 만료되므로 로그 분석이나 쿼터 시각화 측면에서도 유용하다. 무엇보다도 코드를 봤을 때 누구나 직관적으로 이해할 수 있다.
429 Too Many Requests
오류가 완전히 사라짐
특히 동시 요청이 몰리는 시간대에서도 안전하게 초당 호출 수를 제한할 수 있었다.
로그로도 안정성 확인 가능
초과 시점에는 로그에 정확히 초과된 카운트가 찍히고 이후 요청은 모두 차단된다. 따라서 실시간 모니터링 용이하다.
초당 요청 분산이 자연스럽게 이루어짐
프론트엔드 또는 다른 서비스에서 호출 빈도를 줄이지 않아도 시스템이 자율적으로 제어하낟.
이번 작업을 통해 가장 크게 느낀 점은 시스템 제약 조건을 단순히 코드로 해결하려고 하기보다는 그 제약의 본질이 무엇인지 파악하고 구조적인 관점에서 접근하는 것이 훨씬 효과적이라는 것이다. 초당 호출 제한이라는 문제는 단순히 "지연시키면 되겠지"라는 생각으로 Guava의 RateLimiter
를 도입했지만 이는 로컬 환경에서의 간단한 속도 조절에는 유용할지 몰라도 실시간으로 다중 스레드 혹은 다중 요청이 발생하는 상황에선 전혀 신뢰할 수 없었다. 특히 JVM 메모리에 의존하는 구조다 보니 시스템이 하나라도 순간적인 병목이나 누락 없이 제한을 잘 지켜줄 거란 보장이 없었고 이로 인해 실제 테스트 환경에서 429가 계속 발생하며 네이버 API 호출이 실패했다.
반면 Redis를 기반으로 구조를 재설계하자 동시성 문제는 자연스럽게 해결되었고 초당 제한을 분산 환경에서도 완벽히 통제할 수 있었다. Redis의 INCR
, EXPIRE
같은 원자적 연산은 병렬 환경에서의 안정성과 신뢰성을 동시에 제공했으며 이를 통해 단순하면서도 강력한 제한 제어가 가능해졌다. 결국 이번 경험은 "하나의 서버에서 잘 동작한다고 전체 시스템이 안전한 것은 아니다"라는 분산 시스템의 기본 원칙을 몸소 체험한 사례였고 애플리케이션 로직만으로는 한계가 있는 문제를 Redis 같은 외부 상태 저장소와 결합해 해결하는 것이 얼마나 중요한지 깊이 깨달을 수 있는 계기가 되었다.