ChatGPT API 요청 제한, Feign Retry 해결하기
1. 문제 배경📚
장문 텍스트 요약 기능을 개발하면서 AI 모듈을 활용하게 되었고, 여러 후보 중 OpenAI ChatGPT를 선택했다. 하지만 ChatGPT API에는 요청 제한(Rate Limit)이 있어, 이를 관리하지 않으면 에러가 발생할 위험이 있다.
API에는 RPM(분당 요청 수), RPD(일일 요청 수), TPM(분당 토큰 수) 같은 제한이 존재하며, 이 한도를 초과하면 요청이 차단된다. 따라서 요청 제한을 효과적으로 관리하는 방법이 필요했고, 이번 글에서는 이 문제를 어떻게 해결했는지 정리해보았다.
티어별로 API 요청 제한을 보면
- 모델 : gpt-3.5-turbo
Tier | RPM (1분 동안 최대 요청 수) | RPD (1일 동안 최대 요청 수) | TPM (1분 동안 최대 토큰 수) |
무료 | 3 | 200 | 40,000 |
1단계 | 3,500 | 10,000 | 200,000 |
2단계 | 3,500 | 2,000,000 | |
3단계 | 3,500 | 4,000,000 | |
4단계 | 10,000 | 10,000,000 | |
5단계 | 10,000 | 50,000,000 |
각 티어별로 RPM, RPD, TPM 제한이 있는 것을 확인할 수 있다.
내용은 공식 문서를 참고하였습니다.
2. API 요청 제한! 고민의 흔적들 🕐
[ 방안 1. 요금제 Tier 올리기 ]
요금제를 상위 티어로 올리면 최대 요청 수가 증가해 단기적으로 문제를 해결할 수 있다.
하지만 근본적인 해결책이 아니기 때문에 지속적인 해결책이 필요했다.
[ 방안 2. 슬라이딩 윈도우 알고리즘 ]
슬라이딩 윈도우는 고정된 시간(1분, 하루) 동안의 요청 개수를 정확히 계산하는 알고리즘을 사용해 요청을 제한하는 방법이다.
사용하는 자료구조는 Redis의 ZSET(Sorted Set) 사용하여, 특정 시간 동안의 요청 개수를 확인할 수 있다.
Redis 키 설계
- Key: request:{date - 현재 날짜}
- Score : 요청 시간(초 단위)
- Value: 토큰 수
각 요청 제한 확인 방법은
- RPM(분당 요청 수): 현재 시간에서 60초 이전의 요청 개수를 계산하여 초과 여부 판단
- RPD(일일 요청 수): 현재 날짜 기준으로 요청 횟수를 카운트하여 초과 여부 판단
- TPM(분당 토큰 수) :
- 토큰 수는 입력 토큰과 출력 토큰으로 나뉨
- 입력 토큰 수는 tiktoken 라이브러리를 사용하여 미리 계산할 수 있지만, 출력 토큰 수는 API 응답을 받아야 확인 가능함
- 문제점
- 요청 제한을 확인하고 API 응답을 받고 출력 토큰 수를 저장해야 하는데, 이 과정에서 동시성 문제가 발생함
- 동시성 문제를 해결하기 위해서, 다른 스레드를 블로킹하면 기다리는 시간이 오래 걸려서 성능이 저하
- 이로 인해 TPM 핸들링이 어렵고, 이 방법을 사용하기 어려움...
- 왜 출력 토큰 수까지 계산하는 것이냐!!
출력 토큰 수를 미리 알 수 없기 때문에 TPM 을 효과적으로 처리할 방법이 없어서, 이 방법은 사용이 어려웠다.
[ 방안 3. 재시도 전략 ]
이 방안은 API 요청을 했는데 요청 제한되었을 때, 일정 시간 대기 후 재시도하는 방식이다.
재시도 간격을 2배씩 증가시키는 지수 백오프(Exponential Backoff) 적용하여,
만약 5번 재시도 한다면 2 → 4초 → 8초 → 16초 → 32초 순으로 요청을 하는 것이다.
그러면 언제 재시도를 해야할까?
재시도 시점을 결정하기 위해 OpenAI API Errors 문서를 확인했다.

OpenAI에서 제공하는 상태 코드를 보면
- 401 → 인증 문제 (잘못된 API 키 등)
- 403 → 지원되지 않는 국가 또는 지역
이 외에 재시도가 필요한 오류는 다음과 같다.
- 429 → 요청 할당량 초과 (Rate Limit Exceeded)
- 500 → 서버 오류 (Internal Server Error)
- 503 → 서버 과부하 (Service Unavailable)
OpenAI 문서에서 429, 500, 503 오류 해결 방법(Solution)을 보면 "retry"라는 단어가 자주 등장한다.
이 오류들은 일시적인 문제일 가능성이 높아, 일정 시간을 두고 다시 요청하면 정상 처리될 가능성이 클 것이다.
429, 500, 503 상태 코드가 발생하면 일정 시간 대기 후 재시도를 진행하는 것이 적절하다고 판단하였다.
기술 스택 : Java 17, Sprint boot 3, Feign Client 3
3. 문제를 해결할 재시도 전략 도입! 😊
API 요청 시 Feign Client 사용하고 있어서 이 라이브러리의 재시도 기능을 알아보았다.
Feign Client에는 기본적으로 재시도 기능이 있지만, IOException이 발생할 때만 동작한다는 제한이 있었다.
Read Timeout, Socket Timeout, Broken Pipe 같은 네트워크 관련 저수준 예외가 발생할 경우에만 IOException으로 감싸져 자동 재시도가 이루어진다.
IOException 뿐만 아니라 429, 500, 503 상태 코드에도 재시도가 필요하여 내부 로직을 어떻게 되어있는지 분석해보았다.
[ 내부 로직 분석 ]
일단 가장 먼저 확인한 부분은 HTTP 요청하는 부분과 에러 핸들링을 어떻게 하는지 확인하는 것이었다.
SynchronousMethodHandler.class

invoke 함수를 통해서 HTTP 요청이 실제로 실행된다.
RequestTemplate 객체는 HTTP 요청 정보를 세팅하는 역할이고 Options 객체는 Read, Connect 타임아웃 등 설정이다.
중요한 건 Retryer 객체인데, 이 객체는 재시도 유무와 어떤 식으로 재시도를 할건지 정보가 담겨있는 객체이다.
만약 등록된 bean 없으면 재시도를 하지 않는다.
그 다음 excuteAndDecode 함수를 보면 HTTP 요청을 하고 응답을 Decode 하게 되는데

HTTP 요청은 client.execute 함수에서 하게 된다.
만약 네트워크 관련 저수준 예외가 발생하면 catch (IOException) {..} 캐치하는 모습을 볼 수 있다.
throw errorExecuting 내부 코드를 보면

함수 안에서 RetryableException 던진다.
다시 처음 실행부 invoke(..) 함수로 돌아오면

RetrableException 예외를 캐치한 다음 retryer에게 이후 실행을 위임하는 것을 볼 수 있다.
이로 통해서 알게된 사실은
- IOException 발생하면 RetrableException 던지게 되고 RetrableException 캐치하여 재시도를 하는 것
- Retryer 객체 Bean 등록을 해야지만, 재시도 한다는 것
retryer.continueOrPropagate() 함수에서 Retryer 객체가 bean 등록되지 않았을 때 함수 내용을 보면

아무것도 하지 않고 예외만 throw e 던진다.
반대로 bean 등록되어 있을 때 함수 내용을 보면

지정한 최대 요청 횟수 안에서 interval 동안 대기하고 retry를 하게 되어 있다.
interval 대기하는 계산식을 보면

1.5배 증가하는 지수 백오프 방식으로 되어있다.
IOException 아닌 다른 에러는 내부적으로 어떻게 처리될까?
다시 executeAndDecode 함수를 보게 되면

responseHandler.handleResponse(...) 함수가 실행되게 된다.
함수 내용을 보면

executionChain.next(...)를 호출하게 되는데, 이 때 함수형 인터페이스로 chain 되어 있어서
InvocationContext.proceed(...) 함수가 실행되게 된다. 이 함수의 내부를 봐보자.

shouldDecodeResponseBody 변수는 상태코드가 2xx번대가 아니게 되면 decodeError를 호출하게 된다.

ErrorDecoder에서 decode를 호출하게 된다.
ErrorDecoder는 Customizing 하여 에러 핸들링을 할 수 있는 클래스이다. (참고 문서)
이 클래스를 overring 해서 에러 핸들링을 하게 되면, 내가 원하는 429, 500, 503 상태 코드가 되었을 때 RetrableException 던져서 재시도를 할 수 있게 된다.
즉, IOException 발생했을 때와 같은 플로우로 동작할 것이다.
전체 요약
- Feign Client는 기본적으로 IOException 발생 시 RetryableException을 던져서 재시도를 함
- 재시도를 하려면 Retryer 객체를 Bean으로 등록 필수
- HTTP 응답이 2xx(성공) 상태 코드가 아니면 ErrorDecoder가 호출됨
- 재시도를 위해 ErrorDecoder를 Customizing 해서 RetryableException 던지도록 해야함
[ 구현하기 ]
가장 먼저 OpenAI API 요청 실패 시 재시도할 수 있도록 ErrorDecoder를 Customizing 하였다.
OpenAiErrorDecoder.class
@Slf4j
public class OpenAiErrorDecoder implements ErrorDecoder {
@Override
public Exception decode(String methodKey, Response response) {
log.error("[OpenAi] API 실패 에러 status : {}, reason : {}", response.status(), response.reason());
// 요청 할달량 초과 && 서버 에러 시 재시도
if(response.status() == 429 || response.status() >= 500) {
return new RetryableException(response.status(),
response.reason(),
response.request().httpMethod(),
(Long) null,
response.request());
}
throw new OpenAiApiException();
}
}
실패 상태 코드가 429, 500 이상이면 RetryableException 예외를 던져서 재시도할 수 있도록 해주었다.
나머지 실패 케이스는 재시도 하지 않고 예외 OpenAiApiException(..) 던져주었다.
그 다음 Retryer 객체를 Bean 등록한다.
OpenAiFeignConfig.class
public class OpenAiFeignConfig {
@Bean
public Retryer retryer() {
/**
* period = 2 (초기 대기 시간)
* maxPeriod = 32 (최대 대기 시간)
* maxAttempts = 5 (최대 재시도 횟수)
*/
return new Retryer.Default(5, 32, 5);
}
@Bean
public ErrorDecoder openaiErrorDecoder() {
return new OpenAiErrorDecoder();
}
}
Retryer 객체에는 최대 몇번 재시도, 최대 대기 시간, 초기 대기 시간을 설정할 수 있는 변수들이 있다.
API 성공률이 높아질 수 있도록 아래와 같이 설정하였다.
- 최대 재시도 횟수 : 5번
- 최대 대기 시간 : 32초
- 초기 대기 시간 : 2초
이렇게 하면 첫번째 재시도 2초 → 두번째 재시도 4초 → 세번째 재시도 8초 → 네번째 재시도 16초 → 다섯번째 시도 32초로 일정 시간 대기한 후 요청을 한다.
하지만 Retryer 객체에 지수 백오프 방식이 2배가 아닌 1.5배 증가로 되어있었다.

이거를 어떻게 할지 고민하다가, Retryer 인터페이스의 함수를 재정의해서 내 입맛대로 재시도할 수 있도록 구성하였다.
OpenAiRetryer.class
/**
* OpenAi API 예외 발생 시 재시도 하는 클래스
*/
@Builder
public class OpenAiRetryer implements Retryer {
private final long period;
private final long maxPeriod;
private final int maxAttempts;
int attempt;
int multiplier;
/**
* 재시도 하기 전, 일정 시간 대기
* @param e 재시도할 예외
* @throws RetryableException 재시도 횟수를 초과한 경우 예외를 던진다.
*/
@Override
public void continueOrPropagate(RetryableException e) {
// 최대 재시도 횟수 체크
if (attempt++ >= maxAttempts) {
throw e;
}
// 대기 시간 계산
long interval = nextMaxInterval();
try {
// 쓰레드 대기 (초 단위)
Thread.sleep(interval * 1000);
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
throw e;
}
}
/**
* 대기 시간을 계산하여 반환
* 지수 증가 방식(period * multiplier^(attempt - 1)) 사용
* @return 다음 재시도까지의 대기 시간
*/
long nextMaxInterval() {
long interval = (long) (period * Math.pow(multiplier, attempt - 1));
return Math.min(interval, maxPeriod);
}
@Override
public Retryer clone() {
return OpenAiRetryer.builder()
.period(period)
.maxPeriod(maxPeriod)
.maxAttempts(maxAttempts)
.multiplier(multiplier)
.attempt(attempt)
.build();
}
}
continueOrPropagate(..)와 clone(..) 함수를 재정의해서, 일정시간 대기한 후 재시도를 할 수 있도록 하고
지수 백오프 증가 방식은 multiplier 변수로 관리할 수 있도록 해주었다.
만약 multiplier = 2 설정되면 2배 증가하는 방식으로 하였다.
그 다음 OpenAiRetryer 구현 클래스로 실행시킬 수 있도록 Bean 등록하는 부분을 수정해주었다.
public class OpenAiFeignConfig {
@Bean
public Retryer retryer() {
return OpenAiRetryer.builder()
.period(2) // 초기 대기 시간
.maxPeriod(32) // 최대 대기 시간
.maxAttempts(5) // 최대 재시도 횟수
.multiplier(2) // 각 재시도 시 대기 시간이 증가하는 지수 배율
.build();
}
@Bean
public ErrorDecoder openaiErrorDecoder() {
return new OpenAiErrorDecoder();
}
}
[ 테스트 ]
1분 동안 허용할 수 있는 범위까지 요청한 다음, 한번 더 요청해보았다.

에러 로그에서 초 단위를 보면 39 → 41 → 45 → 53 → 10 → 42초로 재시도할 때마다 일정 대기 시간이 2배씩 늘어나는 것을 볼 수 있다.
이렇게 최대 재시도 횟수와 지수 백오프 방식이 정상적으로 실행이 되는 것을 볼 수 있다.
4. 재시도 도입 완료! 성공 확률이 어떻게 될까? 🤣
API 성공 확률을 알기 위해서 jMeter로 신뢰성 테스트를 해보았습니다.
테스트를 진행하기 위해 OpenAI Rate 세팅은 아래와 같이 구성하였습니다.
- Tier : 1
- Model : gpt-3.5-turbo
- RPM(분당 요청 수) : 500
- RPD(일일 요청 수) : 10000
- TPM(분당 토큰 수) : 200000


Rate Limit 이 1분당 500개 요청을 받을 수 있어서, 10초 안에 1000개의 요청을 보내보았다.
아마도 초과 요청을 보냈으니깐 에러가 발생할 것으로 예측된다.
재시도 도입 전

1000개 요청을 보냈을 때 582건 성공적으로 요청이 됐고 418개 실패가 되었다.
평균 시간은 659ms -> 0.6초 걸리고 성공률은 58.2% 이다.
재시도 도입 후

1000개 요청을 보냈을 때 1000건 모두 다 성공이 되었다...!
평균 시간은 14709ms -> 14.7초 걸리고 성공률은 100% 이다.
아무래도 5번 재시도를 지수 백오프 방식으로 일정 시간 대기한 후 요청을 보내니깐 평균 시간이 오래 걸리게 된 거 같다.
결론적으로 재시도 전략 도입으로 성공 확률이 약 72% 증가했다!
5. 추후 고도화 적용하기 🧐
API 요청 제한을 재시도 전략을 도입해서 해결했지만, 단순히 동일한 요청을 반복하는 방식은 한계가 있을 수 있다.
보다 안정적인 운영을 위해, API Key를 변경하는 방식을 적용하는 것이 더 효과적인 해결책이 될 수 있다.
또한, OpenAI API에는 TPM(분당 토큰 수) 제한이 존재하는데, 1분 동안 허용된 최대 토큰 수를 초과하면 아무리 재시도를 하더라도 실패할 수밖에 없다.
이러한 문제를 해결하는 방법 중 하나는 요금제를 업그레이드(Tier 상승)하는 것이지만, 실제 운영 환경에서는 즉시 적용되기 어려워 현실적인 해결책이 되지 않는다.
따라서 보다 효율적인 접근 방식은 모델을 변경하는 방법이다.
예를 들어, gpt-3.5-turbo를 사용하다가 토큰 제한에 걸리면 gpt-4-turbo로 변경하면 정상적으로 요청을 처리할 수 있다.
1️⃣ 첫 번째 재시도: API Key를 변경하여 다시 요청
2️⃣ 두 번째 재시도: 모델을 변경하여 다시 요청
이 방식으로 구성하면, 요청 실패 확률을 크게 줄일 수 있고, API Key 변경과 모델 변경을 통해 보다 안정적인 API 요청이 가능해진다.
이러한 전략을 통해 요청 제한 문제를 효과적으로 해결할 수 있다.