본문 바로가기
mody

Spring Boot에서 OpenAI API 사용에 프롬프트 최적화하기

by seoshinehyo 2025. 3. 9.

1. 세팅

다음 값들을 하드 코딩하지 않고, application.yml 파일에 작성해둔다.

// yml 파일
openai:
  secret-key: $OPENAI_API_KEY
  model: gpt-4o-mini
  max-tokens: 250
  temperature: 0.5

2. 흐름

  1. OpenAi Api와 통신을 하기 위해서는 보통 RESTful 서비스를 소비하기 위한 동기식 HTTP 클라이언트인 RestTemplate을 빈으로 등록하여 사용한다. 하지만 여기서는 WebClient를 사용하여 동기식 통신을 진행하였다. 다음은 WebClient를 빈으로 등록하는 과정이다. OpenAiConfig 클래스를 통해 openai secret key 값과 기본 url을 갖고 공통 헤더를 작성한다. 그 이유는 OpenAi 공식 문서를 확인하면 알 수 있다.
@Configuration
public class OpenAiConfig {

    @Value("${openai.secret-key}")
    private String secretKey;

    // OpenAI API의 기본 URL
    private static final String OPENAI_BASE_URL = "https://api.openai.com/v1";

    // JSON 형태의 메타데이터와 secretKey 값을 넣은 공통 헤더
    @Bean
    public WebClient webClient(WebClient.Builder webClientBuilder) {
        return webClientBuilder
                .baseUrl(OPENAI_BASE_URL)
                .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + secretKey) // jwt 토큰으로 Bearer 토큰 값을 입력하여 전송
                .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                .build();
    }
}
  1. 이제 이 빈으로 등록된 WebClient를 아래 OpenAiApiClient 클래스에서 사용하는데, 이 클래스는 OpenAI API와 통신하기 위한 클라이언트 역할을 수행한다. Spring의 WebClient를 활용하여 OpenAI API에 요청을 보내고, 응답을 받아 처리하는 구조를 갖는다. 이 코드에서 작성한대로 https://api.openai.com/v1//chat/completions로 통신이 가능하다.
@Slf4j
@Service
@RequiredArgsConstructor
public class OpenAiApiClient {

    private final WebClient webClient;

    public ChatGPTResponse sendRequestToModel(String model, List<Message> messages, int maxTokens, double temperature) {
        ChatGPTRequest request = new ChatGPTRequest(model, messages, maxTokens, temperature);

        return webClient.post()
                .uri("/chat/completions")
                .bodyValue(request)
                .retrieve()
                .bodyToMono(ChatGPTResponse.class)
                .block();
    }
}

여기서 ChatGPTRequestChatGPTResponse 클래스는 OpenAi에 요청을 보내고 응답을 받는 DTO이다.

@Data
@AllArgsConstructor
public class ChatGPTRequest {
    private String model;
    private List<Message> messages;
    private int max_tokens;
    private double temperature;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ChatGPTResponse {
    private List<Choice> choices;

    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    public static class Choice {
        private int index;
        private Message message;
    }
}
  1. 그리고 OpenAi Api와 통신을 할 때는 프롬프트가 필요한데, 프롬프트를 직접 타이핑으로 치는 것이 아닌 PromptManager, PromptTemplate을 통해 관리하였다.

    public class PromptTemplate {
    
     private static final String BASE_TEMPLATE = """
             ### 요청하고 싶은 것
             {{request}}
    
             ### 응답 값 형식
             {{responseFormat}}
             """;
    
     private final String template;
    
     public PromptTemplate() {
         this.template = BASE_TEMPLATE;
     }
    
     public String fillTemplate(String request, String responseFormat) {
         return template.replace("{{request}}", request)
                 .replace("{{responseFormat}}", responseFormat);
     }
    }

    이를 통해 어떻게 요청을 할 것이고, 어떻게 응답을 받을지에 대한 프롬프트를 쉽게 작성할 수 있다.

  2. 이제 가장 중요한 OpenAi Api와 통신을 하는 서비스 계층 코드이다.

    @Slf4j
    @Service
    @RequiredArgsConstructor
    public final class ChatGptService {
    
     private final OpenAiApiClient openAiApiClient; // ChatGPT API와의 통신을 담당
     private final PromptManager promptManager; // 프롬프트 생성
     private final ObjectMapper objectMapper = new ObjectMapper(); // JSON 응답 변환
    
     @Value("${openai.model}")
     private String model; // OpenAI 모델
    
     @Value("${openai.max-tokens}")
     private int maxTokens; // 최대 토큰 수
    
     @Value("${openai.temperature}")
     private double temperature; // 생성된 응답의 창의성 정도
    
     private final String systemRole = "system"; // 대화에서의 역할(시스템 메시지)
    
     private final String userRole = "user"; // 대화에서의 역할(사용자 메시지)
    
     // 체형 분석
     public BodyTypeAnalysisResponse analyzeBodyType(String name, String gender, String answers) {
    
         // 템플릿 생성
         String prompt = promptManager.createBodyTypeAnalysisPrompt(name, gender, answers);
    
         // OpenAI API 호출
         ChatGPTResponse response = openAiApiClient.sendRequestToModel(
                 model,
                 List.of(
                         new Message(systemRole, prompt)
                 ),
                 maxTokens,
                 temperature
         );
    
         // response에서 content 추출
         String content = response.getChoices().get(0).getMessage().getContent().trim();
         log.info("content : " + content);
    
         // 백틱과 "json" 등을 제거
         if (content.startsWith("```")) {
             content = content.replaceAll("```[a-z]*", "").trim();
         }
         log.info("content after removing backticks: " + content);
    
         try {
             // content -> BodyTypeAnalysisResponse 객체로 변환
             return objectMapper.readValue(content, BodyTypeAnalysisResponse.class);
         } catch (JsonMappingException e) {
             throw new RestApiException(AnalysisErrorStatus._GPT_ERROR);
         } catch (JsonProcessingException e) {
             throw new RestApiException(AnalysisErrorStatus._GPT_ERROR);
         }
     }
    }

    PromptManager를 통해 프롬프트를 생성하고, OpenAiApiClient를 통해 OpenAi Api를 호출하고, ChatGPTResponse의 형식에 맞게 응답을 받는다. 그리고 이 response에서 우리가 필요한 content 값만 추출한다. 이 content 값을 ObjectMapper를 사용하여 우리가 응답 DTO로 사용하고자 하는 BodyTypeAnalysisResponse 로 변환한다.

  3. 마지막으로 컨트롤러 계층이다.

    @Tag(name = "체형 분석", description = "체형 분석 API")
    @RestController
    @RequestMapping("/body-analysis")
    @RequiredArgsConstructor
    public class BodyTypeController {
    
     private final ChatGptService chatGptService;
    
     @PostMapping("/result")
     @Operation(summary = "체형 분석 API", description = "OpenAI를 사용해서 사용자의 체형을 분석하는 API입니다. Request Body에는 질문에 맞는 답변 목록을 보내주세요.")
     public BaseResponse<BodyTypeAnalysisResponse> analyzeBodyType(
             @AuthenticationPrincipal CustomUserDetails customUserDetails,
             @Valid @RequestBody BodyTypeAnalysisRequest request
     ) {
         return BaseResponse.onSuccess(memberBodyTypeCommandService.analyzeBodyType(customUserDetails.getMember(), request.getAnswer()));
     }
    }

    /body-analysis/result 로 Post 요청이 오면 OpenAi Api를 사용하여 체형 분석을 해주고 반환한다.

3. 겪었던 문제와 해결 방법

  1. content에는 로그가

    2025-01-12T20:03:58.963+09:00  INFO 31648 --- [mody-server-local] [nio-8080-exec-4] c.e.m.d.chatgpt.service.ChatGptService   : content : ```json
    {
    "name": "차은우",
    "bodyTypeAnalysis": {
     "type": "스트레이트",
     "description": "스트레이트 체형은 상대적으로 균형 잡힌 비율을 가지고 있으며, 어깨와 엉덩이의 너비가 비슷한 특징을 보입니다. 근육이 탄탄하고 뼈대가 두드러지며, 허리의 굴곡이 적어 직선적인 실루엣을 형성합니다.",
     "featureBasedSuggestions": {
       "emphasize": "어깨선과 쇄골을 강조하여 상체의 강한 인상을 주는 스타일을 선택하세요.",
       "enhance": "허리 라인을 부각시킬 수 있는 디자인의 의상을 선택하여 전체적인 균형을 맞추는 것이 좋습니다."
     }
    }
    }

    이렇게 잘 찍히는데
    스웨거에는

    그래서 에러 로그를 찍어보니

Unexpected character ('`' (code 96)): expected a valid value (JSON String, Number, Array, Object or token 'null', 'true' or 'false')

-> 이는 백틱 때문이라는데, OpenAI API 응답에 JSON 코드 블록 표시용 백틱(```json`)이 포함되어 content와 DTO 형식이 맞아도 ObjectMapper가 변환을 못 해주던 것이다.

2. 해결 방법 : 서비스 계층에서 백틱 제거 로직 추가

if (content.startsWith("```")) {
        content = content.replaceAll("```[a-z]*", "").trim(); // 백틱과 "json" 등을 제거
    }

    log.info("content after removing backticks: " + content);