Redis Lua Script로 네이버 API 호출 제한 제어 및 429 오류 해결기

송현진·2025년 6월 9일
0

트러블슈팅

목록 보기
5/7

선물 추천 시스템에서 네이버 쇼핑 검색 API를 사용하는 과정에서 429 Too Many Requests 오류가 반복적으로 발생했다. 해당 API는 초당 10건의 호출 제한이 있었고 EC2 환경에서 다수의 트래픽이 몰리거나 여러 스레드가 동시에 요청을 보내는 상황에서는 기존에 사용하던 단순한 Redis INCR 기반 호출 제한 방식으로는 안정적으로 제어하기 어려웠다. 이 방식은 초당 요청 수를 카운팅할 수는 있었지만 아래와 같은 문제가 있었다.

  • 동시성이 몰릴 경우 간헐적으로 429 오류 발생
  • 즉시 실패 방식은 사용자 경험에 좋지 않음
  • 호출 타이밍이 정확히 겹칠 경우 INCR는 한도 초과 후에도 호출을 허용할 수 있음

그래서 구조를 Redis Lua Script를 사용해서 동시성을 제어하고 초당 횟수 초과 시 재시도 기반 대기 처리를 해서 즉시 실패를 하지 않도록 변경했다.

개선된 구조

Lua Script로 초당 호출 제어

기존에는 redisTemplate.opsForValue().increment()로만 초당 호출수를 제어했지만 다음과 같은 문제점이 있었다.

  • TTL 설정이 호출마다 누락될 수 있음
  • INCR 자체는 원자적이지만 조건부 판단이 필요할 땐 신뢰성 낮음

Lua 스크립트를 통해 아래 로직을 Redis 내에서 원자적으로 실행

local current = tonumber(redis.call('GET', KEYS[1]) or '0')
if current >= tonumber(ARGV[2]) then
    return -1
end
local newCount = redis.call('INCR', KEYS[1])
if newCount == 1 then
    redis.call('EXPIRE', KEYS[1], tonumber(ARGV[1]))
end
return newCount
  • 초당 요청 수 제한(SECOND_LIMIT)을 넘으면 -1 반환
  • TTL은 최초 요청 시에만 2초 설정

일일 쿼터 체크

Long dailyCount = redisTemplate.opsForValue().increment(DAILY_KEY);
if (dailyCount > DAILY_LIMIT) {
    throw new ErrorException(ExceptionEnum.QUOTA_DAILY_EXCEEDED);
}

DAILY_KEYnaver:quota:count이고 일일 호출량이 25,000건 초과 시 즉시 예외 발생한다. 또한 일일 호출량은 매일 0시 @Scheduled로 리셋 처리한다.

초당 쿼터 + 재시도 로직

for (int i = 0; i < MAX_RETRY; i++) {
    Long result = redisTemplate.execute(
        rateLimitScript,
        List.of(secondKey),
        String.valueOf(2),
        String.valueOf(SECOND_LIMIT)
    );

    if (result != null && result != -1L) return;

    Thread.sleep(WAIT_MILLIS); // 100ms 간격 재시도
}
  • naver:quota:second:{yyyyMMddHHmmss} 형식으로 초당 키 구성
  • SECOND_LIMIT은 9로 설정 (버퍼 포함)
  • 100ms 단위로 최대 20번 재시도 -> 최대 2초 유예 후 실패 처리

개선 결과

이번 구조 개선을 통해 429 Too Many Requests 오류는 사실상 완전히 사라졌다. 특히 다음과 같은 측면에서 효과가 뚜렷하게 나타났다.

  1. 초당 호출 제어의 신뢰도 향상
    Redis Lua 스크립트를 도입하면서 기존 INCR 방식이 갖고 있던 race condition 문제를 해소할 수 있었다. 이전에는 동시 호출 시점이 겹치면 Redis가 여러 요청을 허용해버리는 일이 있었고 그 결과 429가 불쑥 튀어나오곤 했다. 하지만 Lua는 Redis 내부에서 하나의 트랜잭션처럼 작동하기 때문에 초당 호출 수가 정확하게 제어되었고 실제로 동시 부하 테스트에서도 단 한 건의 초과 호출도 발생하지 않았다.

  2. 사용자 경험 측면에서 ‘즉시 실패’ 제거
    추천 시스템에서 외부 API 호출 실패는 사용자에게는 선물 추천이 아예 안 뜨는 것처럼 보일 수 있어 치명적인 UX 손상으로 이어졌다. 기존에는 초과 요청이 들어오면 바로 예외를 던졌기 때문에 추천 실패율이 높았다. 그러나 재시도 로직을 추가하면서 약 100ms 단위로 최대 2초까지 기다릴 수 있게 되면서 사용자는 체감상 "빠르지는 않지만 항상 결과가 나온다"는 인상을 받을 수 있었다. 실제 트래픽이 몰리는 시간대에도 API 요청 성공률은 100%를 기록했다.

  3. 유지보수성 향상 및 재사용 가능성 확보
    초당 제한, 일일 제한, 재시도 횟수 등 모든 설정이 상수로 분리되어있어 이후 다른 외부 API에 동일한 구조로 확장 적용할 수 있다. 재시도 설정만 조정하면 다른 API의 제한 정책에도 유연하게 대응 가능하다. 또한 Lua 스크립트는 DefaultRedisScript로 선언되어 있어 별도 관리도 수월해졌다.

  4. Redis TTL 설정과 자원 정리 문제 해결
    이전에는 INCR만 사용하면 TTL을 따로 설정해야 했고, 실수로 TTL을 누락할 경우 Redis 키가 무한정 쌓이는 문제가 있었다. Lua 스크립트에서 TTL을 자동 설정함으로써 정확히 2초 후 키가 삭제되고 이는 Redis 자원 관리 측면에서도 효과적이었다.

📝 느낀점

이번 개선 작업을 통해 Redis의 Lua 스크립트가 단순한 캐시 용도를 넘어 분산 환경에서의 동시성 제어 도구로도 매우 강력하다는 사실을 다시금 체감할 수 있었다. Java 코드만으로는 race condition을 완전히 회피하기 어렵고 이를 분산 락으로 해결하려면 과도한 비용과 복잡성이 수반된다. 반면 Redis Lua 스크립트는 적은 비용으로도 강력한 원자성을 보장해주며 고트래픽 환경에서 실질적인 해결책이 되어주었다. 특히 이번 경험에서는 API 호출 실패를 단순히 로그 한 줄로 넘기는 것이 아니라 사용자 경험(UX) 관점에서 접근해 개선할 수 있었다는 점이 인상 깊었다. 추천 서비스는 결국 “선물을 잘 골라준다”는 신뢰를 기반으로 하기 때문에 단순한 응답 속도보다 “항상 결과가 나온다”는 예측 가능성과 안정성이 더 중요할 수 있다. 그런 점에서 이번 구조는 기술적 안정성과 사용자 만족도를 동시에 잡을 수 있었던 결정적인 개선이었다.

물론 한계도 있었다. 최대 2초까지 재시도하면서 기다리는 구조는 요청 성공률을 높이는 데는 효과적이었지만 전체 응답 속도가 늘어나는 단점도 함께 발생했다. 이 문제를 해결하기 위해 이후에는 성능과 안정성의 균형에 대해 끊임없이 고민하게 되었다. 특히 실시간 응답이 꼭 필요한 요청과 그렇지 않은 요청을 분기할 필요성을 느꼈고 다음과 같은 두 가지 방향을 고려하게 되었다. 첫째는 Redis 기반의 rate-limit 구조를 더 정밀하게 고도화하는 것이다. 예를 들어 요청 흐름에 따라 동적으로 재시도 간격이나 최대 대기 시간을 조절하거나 트래픽 상황에 따라 유연하게 임계값을 설정하는 방식이 가능하다. 둘째는 자체 캐시나 선제적 큐잉을 통해 실시간 API 호출을 최소화하는 전략이다. 예를 들어 같은 키워드로 최근에 호출된 결과가 있다면 일정 시간 동안은 캐시된 응답으로 대체하거나, 부하가 심할 경우에는 미리 준비된 추천 상품 리스트로 fallback하는 구조를 도입할 수 있다. 이번 개선은 단순한 오류 수정이 아니라 트래픽 제어, 사용자 경험, 시스템 안정성 전반에 대한 깊은 고민으로 이어졌다. 서비스를 운영하는 입장에서 “한 번의 호출이 실패하지 않도록 만든다”는 것이 얼마나 다양한 요소들의 정교한 조합인지를 실감할 수 있었고, 이런 구조적 고민은 향후 더 큰 트래픽과 복잡도를 감당할 수 있는 기반이 되어줄 것이라 확신하게 되었다.

profile
개발자가 되고 싶은 취준생

0개의 댓글