기존의 잉크링크 서비스에서 츄즈미라는 서비스로 이름을 변동했습니다 !
서비스 명칭 변경과 관계없이, 바로 포스팅을 시작하겠습니다.
기존에 Ai 이미지 생성을 위해
DALL·E 2
을 이용해 이미지를 생성했었습니다. 하지만 더 나은 품질을 위해서DALL·E 3
으로 모델을 변경하였고, 이후 문제가 발생하기 시작했습니다.
아래 로그는 테스트를 위해 진행한 코드의 에러입니다.
Rate limit exceeded: [429 Too Many Requests] during [POST] to [https://api.openai.com/v1/chat/completions] [ChatCompletionClient#chatCompletions(String,ChatRequest)]: [{
"error": {
"message": "Rate limit reached for gpt-3.5-turbo in organization on requests per min (RPM): Limit 5000, Used 5000, Requested 1. Please try again in 12ms. Visit https://platform.openai.com/account/rate-limits to learn more.",
"type": "requests",
"param": null,
"code": "rate_limit_exceeded"
}
}
포스팅 제목의 Rate limit
가 보이시나요 ?
자세한 설명을 위해 OpenAi Dashboard를 통해 rate-Limit
를 살펴보겠습니다.
DALL·E 2
,DALL·E 3
의 per minute
대부분의 API는 호출 횟수 제한이 있습니다. 이는 API를 제공하는 서버가 무분별한 호출로 인해 악용되는 것을 방지하고, 서버의 안정성과 성능을 유지하기 위함이죠. (다양한 이유들이 많습니다 !)
즉, 모델이 변경된 DALL·E 3
의 분당 요청 수는 7회로, 1분 안에 7번 이상의 API 호출을 할 경우 Rate Limit 오류가 발생하는 것을 확인할 수 있습니다.
이 외에도 API 호출 시 발생할 수 있는 예외로는 네트워크 연결 문제
, 토큰 초과(분당 토큰, 일일 토큰)
등이 있습니다.
저는 재시도 알고리즘 중 하나인
지수 백오프
방법을 이용했습니다.지수 백오프(exponential backoff)란 재전송 지연 시간을 지수적으로 늘리는 방식으로, 일시적인 오류로 인해 실패한 요청을 재시도할 때 사용됩니다. 재시도 시간을 점차적으로 늘리기 때문에
지수 백오프 알고리즘
이라고 불립니다.
조금 더 말하자면,
처음 재시도에서는 짧은 시간(예:1초)을 기다리고, 실패하면 재시도를 시도하되, 그 시간을 두 배(예:2초)로 늘린다. → 이후 실패 시마다 이전 시간의 2배씩 늘려가며 요청을 재시도 하다가 일정 시간이 지난 경우 재시도를 중단하게 되는거죠.
여기서 핵심은 요청을 얼마나 대기시킬 것인가?
입니다.
사용자가 무한정 대기할 수 없기 때문에, API의 평균 응답 시간과 Rate Limit을 고려하여 최적화된 재시도 횟수를 찾아가는 것이 중요합니다.
지수 백오프는 스프링 부트에서 제공하는 Retry
를 사용하거나, 직접 로직을 작성하여 구현할 수 있습니다. 이 포스팅에서는 스프링 부트에서 제공하는 Retry
를 사용하여 작성되었습니다.
지수 백오프를 적용하기 위해서는 먼저 Spring Boot
애플리케이션 클래스 또는 별도의 구성 클래스에서 @EnableRetry
를 추가해 기능을 활성화합니다.
저는 별도의 구성 클래스에서 @EnableRetry
를 추가했습니다.
이러한 방식은 유연성과 재사용성이 향상됩니다.
@Configuration
@EnableRetry
public class RetryConfig {
}
@Service
public class RetryService {
private static final Logger logger = Logger.getLogger(RetryService.class.getName());
@Retryable(
value = { FeignException.class, Exception.class },
maxAttempts = 5,
backoff = @Backoff(delay = 2000, multiplier = 2, maxDelay = 32000)
)
public <T> T retryableOperation(RetryableOperation<T> operation) throws Exception {
return retryableOperation(operation, 1);
}
private <T> T retryableOperation(RetryableOperation<T> operation, int attempt) throws Exception {
if (attempt == 1) {
logger.info("Attempting operation for the first time...");
} else {
logger.warning("Retrying operation, attempt number: " + attempt);
}
try {
return operation.execute();
} catch (Exception e) {
if (attempt >= 5) {
throw e;
}
return retryableOperation(operation, attempt + 1);
}
}
@Recover
public ResponseEntity<String> recover(FeignException e) {
logger.severe("All retries failed: " + e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("GPT Error");
}
@Recover
public ResponseEntity<String> recover(Exception e) {
logger.severe("All retries failed: " + e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("GPT Error");
}
@FunctionalInterface
public interface RetryableOperation<T> {
T execute() throws Exception;
}
}
코드를 뜯어보면서 해석해보겠습니다 !
@Retryable(
value = { FeignException.class, Exception.class },
maxAttempts = 5,
backoff = @Backoff(delay = 2000, multiplier = 2, maxDelay = 32000)
)
public <T> T retryableOperation(RetryableOperation<T> operation) throws Exception {
return retryableOperation(operation, 1);
}
value = { FeignException.class, Exception.class }
은 재시도를 실행할 때 어떤 예외가 발생했을 때 재시도를 할 것인지를 지정합니다. 즉, 지정된 예외들이 발생하면 재시도를 수행하는 것이죠.
maxAttempts
은 최대 재시도 실행 횟수를 의미합니다.
backoff = @Backoff(delay = 2000, multiplier = 2, maxDelay = 32000)
에서 각각의 의미는 아래와 같습니다.
delay
: 첫 번째 재시도까지의 대기 시간(밀리초)multiplier
: 각 재시도 시 대기 시간이 증가하는 지수 배율maxDelay
: 최대 대기 시간(밀리초)public <T> T retryableOperation(RetryableOperation<T> operation) throws Exception {
return retryableOperation(operation, 1);
}
위 메서드는 재시도가 가능한 작업을 수행하는 기본 메서드로, 실제 재시도 로직을 포함하는 private <T> T retryableOperation(RetryableOperation<T> operation, int attempt)
내부 메서드를 호출합니다.
private <T> T retryableOperation(RetryableOperation<T> operation, int attempt) throws Exception {
if (attempt == 1) {
logger.info("Attempting operation for the first time...");
} else {
logger.warning("Retrying operation, attempt number: " + attempt);
}
try {
return operation.execute();
} catch (Exception e) {
if (attempt >= 5) {
throw e; // All retries failed, propagate exception
}
return retryableOperation(operation, attempt + 1);
}
}
operation.execute();
를 호출하여 작업을 실행하고, 예외가 발생하면 현재 시도 횟수를 확인합니다. 만약 시도 횟수가 5회 이상이면 예외를 그대로 던져 재시도를 중단합니다. 그렇지 않은 경우에는 시도 횟수를 증가시키고, 재귀 호출을 통해 작업을 재시도 합니다.
재시도 횟수 관리는 attempt
매개변수를 사용해 현재 시도 횟수를 관리합니다.
@Recover
public ResponseEntity<String> recover(FeignException e) {
logger.severe("All retries failed: " + e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("GPT Error");
}
@Recover
public ResponseEntity<String> recover(Exception e) {
logger.severe("All retries failed: " + e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("GPT Error");
}
@Recover
이 어노테이션은 스프링 리트라이 프레임워크에서 제공하는 기능으로, 모든 재시도가 실패한 후에 호출되는 복구 메서드를 지정합니다.
각각의 예외 타입에 대한 별도의 복구 메서드를 정의하여, 재시도가 실패했음을 클라이언트에게 알리는 역할을 합니다.
@FunctionalInterface
public interface RetryableOperation<T> {
T execute() throws Exception;
}
@Functional Interface
어노테이션은 인터페이스가 함수형 인터페이스임을 명시합니다.
함수형 인터페이스는 하나의 추상 메서드만 가질 수 있고, 람다 표현식이나 메서드 참조로 사용할 수 있습니다. 해당 어노테이션은 선택사항이지만, 함수형 인터페이스로 사용될 의도를 명확히 나타내고, 인터페이스에 두 개 이상의 추상 메서드가 선언될 경우 컴파일 에러를 발생시킵니다.
해당 어노테이션을 되도록 사용한다면 런타임 시점이 아닌 컴파일 시점에서 코드를 검증하기 때문에 코드의 안정성을 보장할 수 있다는 장점과 코드를 읽을 때 함수형 인터페이스임을 명확히 알 수 있기 때문에 가독성 또한 향상되게 됩니다.
이제 마지막으로 OpenAI GPT
에 요청을 보내고, 재시도 로직을 적용하여 예외가 발생하면 재시도하는 기능을 구현한 코드를 살펴보겠습니다.
@Service
@RequiredArgsConstructor
public class LimitsServiceTest {
private final ChatCompletionClient chatCompletionClient;
private final RetryService retryService;
private static final Logger logger = Logger.getLogger(LimitsServiceTest.class.getName());
@Value("${chat-gpt-api-key}")
private String apiKey;
public ResponseEntity<String> openAiModelLimitTest() {
return retryService.retryableOperation(() -> {
Message message = Message.builder()
.role("user")
.content("hi")
.build();
ChatRequest chatRequest = ChatRequest.builder()
.model("gpt-3.5-turbo")
.max_tokens(5)
.messages(Collections.singletonList(message))
.build();
try {
logger.info("Sending request to GPT-3.5: " + chatRequest);
ChatResponse response = chatCompletionClient.chatCompletions("Bearer " + apiKey, chatRequest);
logger.info("Received response from GPT-3.5: " + response);
return ResponseEntity.ok(response.getChoices().get(0).getMessage().getContent());
} catch (FeignException e) {
logger.severe("FeignException occurred: " + e.getMessage());
throw e;
} catch (Exception e) {
logger.severe("Other errors occurred: " + e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Other errors occurred: " + e.getMessage());
}
});
}
}
retryService.retryableOperation(() -> { ... })
람다 표현식으로 재시도 가능한 작업을 정의합니다. 즉,RetryableOperation
인터페이스의 execute
메서드를 구현하게 됩니다.
chatCompletionClient.chatCompletions("Bearer " + apiKey, chatRequest)
메서드를 호출해 GPT에게 요청을 전송하고 응답을 받습니다.
catch (FeignException e) {
logger.severe("FeignException occurred: " + e.getMessage());
throw e;
}
여기서 FeignException
을 던져야 재시도 로직이 작동됩니다.
FeignException
예외를 발생시켜 로그를 기록하고, 예외를 다시 던져 재시도 로직이 작동되도록 합니다.네트워크 문제 등의 기타 Exception
이 발생하면 로그를 기록, HTTP 상태코드 500
과 함께 예외 메시지를 포함한 응답을 반환하게 됩니다.
지수 백오프를 적용하기 전 결과
성공 : 30.28%
실패 : 69.72%
지수 백오프를 적용하고 난 뒤 결과
성공 : 87.98
실패 : 12.02%
기존의 성공률에 비해, 지수 백오프를 적용한 뒤 성공률이 57.70% 증가했습니다. 해당 테스트는 분당 30,000번의 요청을 통해 얻은 결과입니다.
추후 적용한 것
본질적인 문제는 API를 제공하는 OpenAi에서 무분별한 호출을 막기 위한 것이기 때문에 Retry 구현 후, 재시도 로직이 한 번 실패할 때마다 API 키를 지속적으로 변경하도록 구성해 서버의 안정성을 최대한 확보하였습니다.
서비스 기대하겠습니다 ~!