Spiaminto

채팅앱 후기 - GPT 응답에 timeout 걸기 본문

학습정리(공개)

채팅앱 후기 - GPT 응답에 timeout 걸기

spiaminto 2023. 7. 21. 18:56

채팅 with GPT 프로젝트 이후 정리 1. GPT 응답에 timeout 걸기

1. 문제

스프링 웹소켓과 STOMP 프로토콜을 이용한 채팅 구현중, GPT API 호출을 통해 GPT 의 대답을 받아올때, GPT 가 간헐적으로 대답을 안주거나 매우 늦어지는 일이 발생했다. 해당 프로젝트에서, GPT API 를 직접 호출하는것은 외부 라이브러리에 작성된 기능이었기에, 당시의 나는 외부 라이브러리는 놔 두고 내 코드에서 이를 해결할 방법을 생각했다.

처음 생각해낸 방법은 GPT 에 보내는 호출을 비동기 코드로 바꾸고, 해당 비동기 작업에 timeout 을 거는 방법이었다.

 

2. GPT 요청 비동기화  및 timeout 설정

// AsyncConfig 를 통해 @EnableAsync 및 ThreadPoolTaskExecutor 재정의

// CustomGptService.java - GptRequestSendor 주입됨
protected String askMultiChatGpt(String gptUuid, List<MultiChatMessage> gptRequestMessageList) {
    String gptResponse = ""; // GPT 답변
    try {
        ListenableFuture<String> multichat = gptRequestSendor.multichat(gptRequestMessageList);
        gptResponse= multichat.get(20, TimeUnit.SECONDS);
    } catch (TimeoutException e) {
        log.info("askMultiChatGpt TimeoutException. e = {},\n message = {}, gptUuid = {}, cause={}", e, e.getMessage(), gptUuid, e.getCause());
        gptResponse = "GPT 응답시간이 초과되었습니다. 다시 시도해주세요";
    } catch (Exception e) {
        log.info("askMultiChatGpt Exception. e = {},\n message = {}, gptUuid = {}, cause={}", e, e.getMessage(), gptUuid, e.getCause());
        gptResponse = "시스템 오류로 인해 GPT 응답에 문제가 발생했습니다. 새로고침 해주세요.";
  	}
    return gptResponse;
}

// GptRequestSendor.java - DefaultGptService 주입됨
@Async
public ListenableFuture<String> multichat(List<MultiChatMessage> requestList) {
    return new AsyncResult<>(defaultGptService.multiChat(requestList));
}

// 실행결과 로그
askMultiChatGpt TimeoutException. e = java.util.concurrent.TimeoutException,
 message = null, gptUuid = e6894cfa, cause=null

원래 코드는 CustomGptService.java 의 askMultiChatGpt 에서, DefaultGptService.java 를 주입받아 바로 GPT 요청을 보내도록 작성되있었으나, 스프링이 지원하는 비동기 처리를 위해 해당 부분을 다른 클래스로 분리하고 ListenableFuture 를 통해 비동기 결과를 받아 ListenableFuture.get() 메서드의 timeout 지정 기능을 적용하면 의도대로 timeout 이 걸리는 결과를 확인할 수 있다. 

하지만, 이 방법이 좋은 방법일까...?

 

3. GPT 요청 비동기화의 문제

이렇게 작성은 했지만, 과연 이 방법이 좋은 방법인가 에 대해 이런저런 고민을 해보았다. 

3.1 비동기 요청의 필요성

애초에 스프링의 메시징 처리는 비동기로 이루어지며, 이는 stdout 로그의 스레드를 보면 바로 알 수 있다.

(기본적인 요청처리는 tomcat 의 http-nio-0000-exec-0 스레드를 사용하지만, 메시징 처리는 clientInboundChannel-0 이라는 별도의 스레드를 통해 처리하는것을 확인할 수 있다.)

즉 내가 작성한 코드는, 비동기 작업을 다시 비동기 작업으로 수행하는 효율적이지 못한 코드라고 생각했다.

특히 ListenableFuture 객체의 get 메서드 사용으로, GPT 의 응답을 받을때 까지 작업 수행이 blocking 되는데, 이렇게 되면 clientInboundChannel 과 async 스레드(@Async) 두 스레드가 blocking 되고 있는것이기 때문에 더더욱 비동기의 의미가 퇴색된다고 생각했다.

어차피 GPT 의 응답을 받고 후 처리 해야하는, 흐름적으로 blocking 이 자연스러운 동작을 굳이 비동기로 처리하는 의미가 있을까?

3.2 AsyncConfig 작성과 GPT 요청부 분리

현재 AsyncConfig 작성을 통한 ThreadPoolTaskExecutor 재정의와, GPT 요청부를 GptRequestSendor 로 분리하여 @Async 를 단 것 모두 비동기 처리를 위해 필요한 작업이었다. 하지만 비동기 처리 자체에 의문이 생긴 상황에서, 추가된 해당 코드들은 더더욱 그 의미를 잃어버린다.

ThreadPoolTaskExectuor 재정의와 @Async 없이 CustomGptService 에서 직접 Executors.newsinglethreadexecutor() 같은 싱글 스레드풀 등을 직접 생성해서 비동기로 처리하고, 해당 스레드풀을 shutdown() 하는 식으로 처리하는것도 시도해 보았으나 파일 몇개, 코드 몇줄 줄어들 뿐 결국 수행하는 방식은 오히려 비효율적으로 변해버리는지라 의미가 없다고 판단했다.

3.3 그렇다면 어떻게 해야할까...

결국 하고싶은것은 "GPT 요청처리 메서드에 timeout 을 거는것" 인데...

while 문에 System.currentTimeMilis 를 이용한 방법도 눈에 띄었으나 일단 더 생각해보기로 했다. 

그리고 떠오른 것이 "api 를 호출하는 RestTemplate 에 상식적으로 timeout 기능이 있지 않을까?" 였다. GPT 요청 라이브러리를 구경할때 보였던 해당 클래스가 api 를 호출을 수행하는 클래스라는것은 알고 있었으나, 내 코드가 아니었기에 크게 신경쓰지 않았던 부분이었다. 

사실, 초보개발자인 나로서는 외부 라이브러리란 "나보다 대단한 사람이 만든 좋은 코드" 라는 인식이 강했기 때문에, 라이브러리 자체를 손대는 것에 소극적을 넘어 부정적인 인식을 가지고 있었다. 비동기 처리 또한 그런 인식이 기저에 깔린 채로 나온 방법이기도 했고... 좀 더 해당 라이브러리를 살펴보기로 했다. 

 

4. 라이브러리 수정을 통한 GPT 요청 timeout

4.1 라이브러리 코드 구경

// 라이브러리 : io.github.flashvayne:chatgpt-spring-boot-starter:1.0.4
public class DefaultChatgptService implements ChatgptService {
    protected final ChatgptProperties chatgptProperties;
    private final String AUTHORIZATION;
    private final RestTemplate restTemplate = new RestTemplate();
    
    // ... chatgptProperties, AUTHORIZATION 초기화 생성자
    
    @Override
    public String multiChat(List<MultiChatMessage> messages) {
        MultiChatRequest multiChatRequest = new MultiChatRequest(chatgptProperties.getMulti().getModel(), messages, chatgptProperties.getMulti().getMaxTokens(), chatgptProperties.getMulti().getTemperature(), chatgptProperties.getMulti().getTopP());
        MultiChatResponse multiChatResponse = this.getResponse(this.buildHttpEntity(multiChatRequest), MultiChatResponse.class, chatgptProperties.getMulti().getUrl());
        try {
            return multiChatResponse.getChoices().get(0).getMessage().getContent();
        } catch (Exception e) {
            log.error("parse chatgpt message error", e);
            throw e;
        }
    }
    // ... 다른 메서드들
} 

@Configuration
public class ChatgptAutoConfiguration {
    private final ChatgptProperties chatgptProperties;
    //... properteis 등록
    
    @Bean @ConditionalOnMissingBean(ChatgptService.class)
    public ChatgptService chatgptService(){
        return new DefaultChatgptService(chatgptProperties);
    }
}

해당 라이브러리는 GPT 요청을 위해 내부적으로 RestTemplate 을 생성하여 사용하고 있었고, 기본적인 예외 처리 후 예외를 던져 개발자로 하여금 예외처리를 할 수 있도록 작성되어 있었다. 

내가 주목했던것은 AutoConfiguration 인데, 해당 설정클래스에서 @ConditionalOnMissingBean 으로 라이브러리의 DefaultchatGptService 가 등록되도록 작성되어있다는 것은 내가 DefaultChatgptService 를 수정하여 Bean 으로 등록하거나, ChatgptService 의 구현체를 직접 만들어 등록하는것을 전제하여 작성되어있다고 나름대로 판단하였다. 

라이선스 문제같은것도 혹시 몰라 살펴봤으나 큰 문제는 없을것 같았다. 

4.2 RestTemplate 생성후 @Bean 으로 등록

예상대로, RestTemplate 에는 timeout 기능이 존재 했다. 따라서 해당 기능을 설정한 RestTemplate 을 @Bean 으로 등록 후, 라이브러리를 수정한 코드에서 주입받아 사용하도록 하면 문제를 해결할 수 있을것이라 생각하고 작성하였다. 

// org.apache.httpcomponents:httpclient:4.5.13 사용
@Configuration
public class RestTemplateConfig {
    @Bean
    HttpClient httpClient() {
        return HttpClientBuilder.create().setMaxConnTotal(20).build();
    }
    @Bean
    HttpComponentsClientHttpRequestFactory factory(HttpClient httpClient) {
        HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory();
        factory.setHttpClient(httpClient);
        factory.setReadTimeout(20000); // 수신 timeout 20초
        factory.setConnectTimeout(20000); // 연결 timeout 20초
        return factory;
    }
    @Bean
    RestTemplate restTemplate(HttpComponentsClientHttpRequestFactory factory) {
        return new RestTemplate(factory);
    }
}

RestTemplate을 생성할때, apache.httpcomponents:httpclient 라이브러리 사용을 통해 connection pool 을 미리 생성하고 효율적으로 사용할수 있다는 정보를 알게되어 해당방법으로 생성 하도록 해보았다. GPT 응답 서버가 keep-alive 를 지원하기 때문에 사용에 문제는 없었다. 원래 목표였던 timeout 역시 걸어놓았다. 이렇게 하면 RestTemplate 에서의 http 요청 자체에 timeout 이 걸릴것이며, 해당 timeout 이 발생하면 Exception 을 터뜨릴 것이다. 

4.3 라이브러리 코드 수정

사실 라이브러리를 직접 수정하진 않았고, 해당 라이브러리의 DefaultchatGptService 의 코드를 긁어 DefaultGptService 에 붙여넣고, RestTemplate 을 주입받도록 한뒤 @Service로 등록했다. 어차피 다른 기능은 딱히 건드릴 필요가 없었기 때문이다. 이렇게 하면 내가 등록한 DefaultGptService 가 스프링 컨테이너에 ChatgptService 로 등록될 것이다. 

@Service
public class DefaultGptService implements ChatgptService {
    protected final ChatgptProperties chatgptProperties;
    private final String AUTHORIZATION;
    private final RestTemplate restTemplate;

    public DefaultGptService(ChatgptProperties chatgptProperties, RestTemplate restTemplate) {
        this.chatgptProperties = chatgptProperties;
        AUTHORIZATION = "Bearer " + chatgptProperties.getApiKey();
        this.restTemplate = restTemplate; // RestTemplate 주입
    }
    //...
}

4.4 timeout 예외처리

마지막으로, GPT 요청코드를 수정하고 timeout 예외를 처리해 주었다.

// CustomGptService
protected String askMultiChatGpt(String gptUuid, List<MultiChatMessage> gptRequestMessageList) {
    String gptResponse = ""; // GPT 답변
    try {
        gptResponse = defaultGptService.multiChat(gptRequestMessageList);
    } catch (HttpServerErrorException.ServiceUnavailable e) {
        // GPT response Error ...
    } catch (ResourceAccessException e) {
        log.info("askMultiChatGpt ResourceAccessException e = {},\n message = {}, rootCause ={} gptUuid = {}", e, e.getMessage(), e.getRootCause(), gptUuid);
        if (e.getRootCause() instanceof SocketTimeoutException || e.getRootCause() instanceof ConnectTimeoutException)
            gptResponse = "GPT 응답시간이 초과되었습니다. 다시 시도해주세요"; // RestTemplate timeout
        else
            gptResponse = "GPT 응답에 문제가 발생했습니다. 새로고침 해주세요.";
    } catch (Exception e) {
        // error...
    }
    return gptResponse;
}

RestTemplate 의 timeout exception 은 SocketTimeoutException, ConnectTimeoutException 및 기타에러가 ResourceAccessException 으로 래핑되어 던져지기 때문에 해당 예외의 getRootCause() 메서드를 통해 분류하여 처리하였다. 

 

5. 결론과 생각해볼 점

결론적으로, GPT 로 보내는 요청에 대한 timeout 을 명확하게 걸 수 있었고,  timeout 이 발생했을때 connection 을 끊으므로써 불필요한 리소스 낭비와 뜬금없는 GPT 로부터의 에러 응답을 방지할 수 있었다.

 

사실 이번 경험에서 나의 생각의 좁음을 여실히 느꼈다. 아는것이 부족하다 보니 쓸데없이 빙빙 돌려 생각한다던가... 상황을 좀더 유연하게 보지 못한다던가... RestTemplate 사용시 timeout 설정은 상식이라는 블로그 글들이 나를 아프게 했다.

라이브러리도 결국 사람이 만든 코드고, 라이선스의 문제가 없다면 필요에 의해 얼마든지 수정해도 되며 애초에 수정을 전제로 유연하게 만들어져 있다는것도 느낄수 있었다.

 

나중에는 RestTemplate 과 WebClient 에 대한 공부를 더 하여 DefaultGptService 의 api 호출 부분을 WebClient 로 바꾸어 본다던가 하는식으로 바꿔볼 생각이다. 지금의 구조를 none-blocking 으로 바꾸면 아마 메시지 처리 전체에 변화를 주어야 할 것 같아 조심스럽지만, 뭐든 해보는게 낫겟지 라는 생각이다.