티스토리 뷰

 

 

 

1. 문제 배경📚


마케팅 프로젝트를 진행하던 중 외부 서비스인 Shopline API를 사용해야 했습니다. 하지만 이 API에는 요청 제한(Rate Limit)이 걸려 있었고, 이 제한은 클라이언트의 요금제 플랜에 따라 달라졌습니다. 기업 요금제에서는 제한이 없지만, 문제는 해당 클라이언트의 요금제 플랜 업그레이드를 우리가 직접 제어할 수 없고 클라이언트 측에서 진행해야 하는 상황이었습니다.

1초에 4회

Shopline API는 토큰 요청, 리프레시 요청, 기프트 카드 생성, 웹 훅 구독 등 여러 가지 작업에 사용되는데, 이 모든 요청은 1초에 4회라는 제한이 있었고, 특히 토큰/리프레시 요청은 공용 제한이라 여러 작업에서 동시에 요청이 몰릴 경우 HTTP 429 Too Many Requests 오류가 빈번하게 발생했습니다.

 





2. 해결 방안 🕐


[ 1초에 4번 요청하기 ]

가장 처음으로 생각했던 게 "그냥 4번 요청하고 스레드 1초 멈췄다가 다시 4번 보내자 ! " 정말 쉽게 생각했었습니다ㅋㅋㅋ 하지만 이 방법은 안정성이 떨어지고 분산 환경에서 제어하기 어려움이 있습니다. 그리고 토큰 요청과 리프레시 요청은 공용 제한이어서 1초에 4번 확실하지 않아서 문제를 발생할 수 있습니다. 그래서 이 방법은 가볍게 skip~~!

 

 

 

[ 재시도 + 지수 백오프 ]

두 번째 방안은 API 요청을 보내고 429 오류 발생 시 재시도하면서, 일정 시간 간격을 점점 늘리는 지수 백오프 방식을 적용했습니다. 그러나 이 방법은 API가 지속적으로 실패할 경우, 스레드가 계속해서 블로킹되며 리소스를 점유하는 문제가 발생했습니다. 특히 스레드 풀 자원이 부족해지면서 다른 비동기 작업까지 영향을 주는 상황이 생겼고, 이는 단순 재시도 방식의 한계를 체감하게 된 계기가 되었습니다.


예전에 Openai API 사용할 때 요청 제한이 있어서 Feign Client + 재시도 + 지수 백오프 방식으로 진행했는데, 이 때는 주체가 클라이언트가 아니고 프로젝트 단위여서 가능했었습니다. 프로젝트도 mvp 여서 큰 문제 없이 사용했던 기억이 나네요.

 



[ Redis ZSet 기반 비동기 재시도 처리 ]

다이어그램 수정 필요
> 2번 실패 시 fail 내용 추가
> 3번 스케쥴러에 주기적으로 어떻게 돌아가는지 짧은 세부 내용 추가
> 4번 화살표 반대로 그리고 짧은 세부 내용 추가

세 번째 방안은 비동기 재시도 큐를 도입하게 되었고, Redis의 ZSet 을 활용하여 요청 실패를 저장하고, 순차적으로 재처리하는 구조를 설계하게 되었습니다. 설계한 내용을 천천히 설명해보겠습니다.

 

Redis ZSet을 선택한 이유

  • ZSet은 데이터 추가, 삭제, 조회가 모두 용이하며 score를 활용해 시간 기반 정렬이 가능하고,
  • 실패한 요청을 순서대로 재처리할 수 있어 안정적인 요청 흐름을 유지할 수 있습니다.
  • 만약 재시도 10번이 넘어가면 기프트 카드 발급 같은 api는 클라이언트 측에서 "왜 안오지?" 생각할 수 있기 때문에 선순위를 보장할 수 있습니다.

 

 

ZSet 설계 구조

  • Key: shopline:retry
  • Score: 처음 실패 발생 시간 (timestamp)
  • Value: 재시도에 필요한 데이터를 담은 JSON 문자열
    • type: API 타입
    • payload: 타입별로 필요한 데이터
    • retryCount: 재시도 카운트

예시

ZADD shopline:retry 1716114000000 '{"type":"GIFT","payload":{...},"retryCount":0}'
ZADD shopline:retry 1820381000021 '{"type":"WEBHOOK","payload":{...},"retryCount":3}'
ZADD shopline:retry 1902395400009 '{"type":"REFRESH","payload":{...},"retryCount":5}'

 

 
 
 
 
 

스케줄러 재시도 로직 

실패 요청은 Redis에 저장된 후, 주기적으로 실행되는 스케줄러가 이를 조회하여 다시 재시도합니다.

 

동작 방식은

  • @Scheduled(fixedDelay = 10_000) 으로 10초 간격으로 실행
  • Redis에서 오름차순으로 전체 조회
  • 재시도 실행
    • 성공 시 데이터 삭제
    • 실패 시 continue
  • 재시도 횟수는 30회까지 허용

 

 

중복 방지는

  • 서비스가 분산 환경으로 구성되어 있어, 하나의 인스턴스만 스케줄러가 실행되도록 @SchedulerLock을 활용하여 분산 락을 걸었습니다.
  • 그리고 아직 처리 중인 스레드가 10초를 넘기게 되면, 다른 스레드에서 아직 처리가 덜 끝난 데이터를 조회하여 중복이 일어날 수 있습니다.
  • 그래서 fixedDelay 값을 설정하여 스레드 작업이 끝나고 10초 딜레이 한 다음 다른 스레드에서 작업을 진행하도록 하여 중복을 방지하였습니다.

 

 

 

재시도 최대 횟수 제어

  • 요청 실패가 30회를 초과하면 Slack 알림을 통해 개발자에게 자동으로 전달됩니다.
  • 이로 인해 장기 미처리 요청을 자동 감지하고 대응할 수 있도록 했습니다.