코딩은 마라톤

[SpringBoot + OpenAI(ChatGPT)] SpringBoot에서 OpenAI API를 이용해 연동하기 본문

Backend/SpringBoot

[SpringBoot + OpenAI(ChatGPT)] SpringBoot에서 OpenAI API를 이용해 연동하기

anxi 2024. 11. 10. 20:21

진행 중인 프로젝트에서 최근 ChatGPT를 연동해서 응답을 받아와야할 상황이 생겼습니다.

OpenAI Java SpringBoot 라이브러리도 있기는 하지만 (https://github.com/TheoKanning/openai-java?tab=readme-ov-file)

라이브러리보다 직접 구현하는게 나중에 API 명세가 바뀌더라도 변경하는데 용이하지 않을까 해서 구현해보았습니다!

 

RestClient와 WebClient를 사용하여 구현할 수 있는데 저는 WebClient를 사용하였습니다!

* 추후 RestClient와 WebClient에 관한 블로그도 올릴게요!

 

1. OpenAI API 요청과 응답 확인하기

Create Chat Completion API를 사용하였습니다! (참고: https://platform.openai.com/docs/api-reference/chat/create)

 

요청

curl https://api.openai.com/v1/chat/completions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -d '{
    "model": "gpt-4o",
    "messages": [
      {
        "role": "system",
        "content": "You are a helpful assistant."
      },
      {
        "role": "user",
        "content": "Hello!"
      }
    ]
  }'

 

예시 요청은 위와 같습니다.

 

여기서 우리가 요청할 때 보내야하는 값은 

  • Authorization : OpenAI에서 제공받은 Secret Key
  • model : 사용할 GPT 모델명 (gpt-4o,  gpt-4o-mini,  gpt-4,  gpt-3.5-turbo 등)
  • messages
    • role
      • system : 모델이 질문에 답하는 방식을 지정
      • user : 사용자의 질의
      • assistant : user 질의에 대응되는 응답
    • content : gpt에 제공할 프롬프트

이렇게 존재합니다.

 

role 참고 : https://community.openai.com/t/understanding-role-management-in-openais-api-two-methods-compared/253289

 

Authorization Key 얻기

https://platform.openai.com/api-keys 

 

위 페이지에서 회원가입을 진행합니다.

 

 

 

create new secret key 버튼을 누르면 name과 permission을 지정하고 생성할 수 있습니다!

 

 

그러면 이와 같이 secret key가 나옵니다! 

여기서 Done을 누를 시 이제 secret key를 확인할 수 없게 되므로, secret key는 꼭!!! 메모장이나 private한 공간에 저장하시길 바랍니다. (공유도 하면 안돼요!)

 

이외 model과 messages는 개발하면서 설정하면 됩니다!

 

응답

{
  "id": "chatcmpl-123456",
  "object": "chat.completion",
  "created": 1728933352,
  "model": "gpt-4o-2024-08-06",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "Hi there! How can I assist you today?",
        "refusal": null
      },
      "logprobs": null,
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 19,
    "completion_tokens": 10,
    "total_tokens": 29,
    "prompt_tokens_details": {
      "cached_tokens": 0
    },
    "completion_tokens_details": {
      "reasoning_tokens": 0,
      "accepted_prediction_tokens": 0,
      "rejected_prediction_tokens": 0
    }
  },
  "system_fingerprint": "fp_6b68a8204b"
}

 

응답은 여러 필드가 존재합니다.

여기서 저희가 봐야할 필드는 "choices"와 "usage" 입니다!

 

  • choices
    • messages
      • content : 사용자의 질의 요청에 대한 응답을 반환합니다.
  • usage : 사용된 토큰의 개수를 알 수 있습니다. 이를 통해 가격을 계산할 수 있습니다.
    • prompt_tokens: 요청 시 작성한 프롬프트에 관한 토큰 개수입니다.
    • completion_tokens: 응답의 토큰 개수입니다.

2. SpringBoot OpenAI API 연동하기

SpringBoot 기본 세팅은 되어있다는 가정 하에 시작하겠습니다.

개발 환경
Java 17, SpringBoot 3.3.4, Gradle

 

2-1. build.gradle 의존성 추가하기

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-webflux'
}

 

우선 WebClient를 사용하기 위해 webflux 의존성을 추가해줍니다.

만약 동기식으로 처리할거면 의존성 추가가 필요 없는 RestClient를 사용하셔도 무방합니다!

 

2-2. 프로퍼티 설정하기(application.yml, properties)

Secret Key는 유출되면 안되는 값이라 프로퍼티에 추가해서 사용합니다!

 

application.yml

openai:
  model: {model 명} ex) gpt-4o-mini
  secret-key: {위에서 발급받은 secret key}
  chat-completions-url: https://api.openai.com/v1/chat/completions

 

model과 chat-completions-url도 프로퍼티에 추가해서 사용합니다! (model과 url도 변경될 수 있어 프로퍼티에서 관리합니다.)

 

2-3 요청, 응답 DTO 만들기

우리는 OpenAI에 요청을 보내고 응답을 받기 때문에 이를 전달할 수 있는 DTO를 만들려고 합니다!

 

요청

저희는 요청에서 필요한 값 중  Authorization과 model은 프로퍼티에서 가져오기 때문에 Message DTO만 생성하면 됩니다.

 

@Builder
public record Message(String role, String content) {

    public static List<Message> createMessages() {
        List<Message> messages = new ArrayList<>();
        messages.add(createSystemMessage());
        messages.add(createUserMessage());
        return messages;
    }

    public static Message createSystemMessage() {
        return Message.builder()
                .role(SYSTEM.getDescription())
                // content에는 프롬프트를 작성하면 됩니다!
                .content("따뜻한 분위기로 글을 작성해줘")
                .build();
    }

    public static Message createUserMessage() {
        return Message.builder()
                .role(USER.getDescription())
                // content에는 프롬프트를 작성하면 됩니다!
                .content("오늘 나 맛있는 거 먹었다!")
                .build();
    }
    
    @Getter
    @RequiredArgsConstructor
    public enum Role {
        SYSTEM("system"),
        USER("user");
        
        private final String description;
    }
}

 

  • SYSTEM과 USER의 role로 Message를 생성하는 메서드를 만들었습니다.
  • 요청에서 사용되는 messages는 리스트 형태로 들어가기 때문에 createMessages()를 통해 리스트로 만들어줍니다.

 

응답

public record OpenAIResponse(
        String id,
        String object,
        int created,
        String model,
        @JsonProperty("system_fingerprint")
        String systemFingerprint,
        List<Choice> choices,
        Usage usage
) {

    public record Choice(
            int index,
            Message message,
            Boolean logprobs,
            @JsonProperty("finish_reason")
            String finishReason
    ) {
    }

    public record Usage(
            @JsonProperty("prompt_tokens")
            int promptTokens,
            @JsonProperty("completion_tokens")
            int completionTokens,
            @JsonProperty("total_tokens")
            int totalTokens,
            @JsonProperty("completion_tokens_details")
            CompletionTokensDetails completionTokensDetails
    ) {
    }

    public record CompletionTokensDetails(
            @JsonProperty("reasoning_tokens")
            int reasoningTokens,
            @JsonProperty("accepted_prediction_tokens")
            int acceptedPredictionTokens,
            @JsonProperty("rejected_prediction_tokens")
            int rejectedPredictionTokens
    ) {
    }
}

 

  • 응답 DTO는 위와 같이 생성하면 됩니다!
  • camelCase로 변수명을 사용하기 위해 @JsonProperty를 사용하였습니다.
  • DTO에서 사용할 값을 서비스 레이어에서 조회, 수정하면 됩니다.

 

2-4. WebClient로 OpenAI 연동하기

WebClient를 사용한 이유는 요청하고 받아오는 시간이 꽤 걸리기도 해서 동기식으로 처리하는 것보다 비동기로 처리하고 응답 받아오기 전까지 다른 작업을 수행할 수 있으므로 WebClient를 사용하였습니다!

 

1) 프로퍼티에 추가한 값 가져오기

@Component
@RequiredArgsConstructor
@Log4j2
public class OpenAIRecommendationProvider {

    @Value("${openai.model}")
    private String model;

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

    @Value("${openai.chat-completions-url}")
    private String chatCompletionsUrl;

 

  • @Value 어노테이션을 사용해 프로퍼티의 값을 가지고 옵니다.

 

2) WebClient로 연동하기

public Mono<OpenAIResponse> getRecommendation() {
        return WebClient.create()
                .post()
                .uri(chatCompletionsUrl)
                .headers(header -> {
                    header.setContentType(MediaType.APPLICATION_JSON);
                    header.setBearerAuth(secretKey);
                })
                .bodyValue(createRecommendationRequestBody())
                .retrieve()
                .bodyToMono(OpenAIResponse.class)
                .onErrorResume(WebClientException.class, e -> {
                    log.error("WebClient 에러 발생 - 에러 메시지: {}", e.getMessage(), e);
                    return Mono.error(GlobalErrorCode.WEB_CLIENT_ERROR.toException());
                })
                .onErrorResume(Exception.class, e -> {
                    log.error("알 수 없는 내부 오류 발생 - 에러 메시지: {}", e.getMessage(), e);
                    return Mono.error(GlobalErrorCode.INTERNAL_ERROR.toException());
                });
    }

    private Map<String, Object> createRecommendationRequestBody() {
        Map<String, Object> requestBody = new HashMap<>();
        requestBody.put("model", model);
        requestBody.put("messages", Message.createMessages());
        return requestBody;
    }

 

  • 요청 시 POST 메서드를 이용해 가지고 옵니다!
  • headers 
    • APPLICATION_JSON 형식으로 content type을 지정합니다.
    • secretKey도 header에 지정합니다.
  • bodyValue
    • 요청시 보낼 값을 넣습니다!
    • 우리는 model, messages를 넣어 보냅니다.
    • createRecommendationRequestBody() 메서드를 이용해 requestBody를 생성합니다.
  • retrieve, mono
    • 비동기 처리를 위해 retrieve를 사용하고 mono 타입으로 반환합니다.
    • 만약 동기 식으로 처리하고 싶으면 block()을 사용하면 됩니다.

 

WebClient 전체 코드는 다음과 같습니다!

@Component
@RequiredArgsConstructor
@Log4j2
public class OpenAIRecommendationProvider {

    @Value("${openai.model}")
    private String model;

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

    @Value("${openai.chat-completions-url}")
    private String chatCompletionsUrl;

    public Mono<OpenAIResponse> getRecommendation() {
        return WebClient.create()
                .post()
                .uri(chatCompletionsUrl)
                .headers(header -> {
                    header.setContentType(MediaType.APPLICATION_JSON);
                    header.setBearerAuth(secretKey);
                })
                .bodyValue(createRecommendationRequestBody())
                .retrieve()
                .bodyToMono(OpenAIResponse.class)
                .onErrorResume(WebClientException.class, e -> {
                    log.error("WebClient 에러 발생 - 에러 메시지: {}", e.getMessage(), e);
                    return Mono.error(GlobalErrorCode.WEB_CLIENT_ERROR.toException());
                })
                .onErrorResume(Exception.class, e -> {
                    log.error("알 수 없는 내부 오류 발생 - 에러 메시지: {}", e.getMessage(), e);
                    return Mono.error(GlobalErrorCode.INTERNAL_ERROR.toException());
                });
    }

    private Map<String, Object> createRecommendationRequestBody() {
        Map<String, Object> requestBody = new HashMap<>();
        requestBody.put("model", model);
        requestBody.put("messages", Message.createMessages());
        return requestBody;
    }
}

 

2-5. 결과 확인하기

Controller에서 OpenAIRecommendationProvider를 통해 응답을 받아봅시다!

 

@RestController
@RequiredArgsConstructor
public class RecommendationController {

    private final OpenAIRecommendationProvider openAIRecommendationProvider;

    @GetMapping("/api/v1/recommendations")
    public Mono<OpenAIResponse> getRecommendation() {
        return openAIRecommendationProvider.getRecommendation();
    }
}

 

 

  • 만약 ChatGPT에게 프롬프트를 동적으로 처리하고 싶다면 아래의 방식을 추가해주시면 됩니다!
    • PostMapping을 통해 RequestBody로 프롬프트를 넘겨주기
    • Message에 Content에 동적으로 값 설정해주기 (createUserMessage() 변경)

 

결과

{
  "id": "chatcmpl-123456",
  "object": "chat.completion",
  "created": 1731236284,
  "model": "gpt-4o-mini-2024-07-18",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "와, 정말 기쁘네요! 어떤 맛있는 음식을 드셨나요? 맛있게 먹은 순간은 언제나 특별하죠. 함께 나누고 싶은 음식이나 그 경험에 대한 이야기도 들려주시면 좋을 것 같아요. 그 음식을 드시면서 느꼈던 감정이나, 친구들과 함께한 즐거운 시간은 또 어떤지요?🌼✨"
      },
      "logprobs": null,
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 29,
    "completion_tokens": 83,
    "total_tokens": 112,
    "completion_tokens_details": {
      "reasoning_tokens": 0,
      "accepted_prediction_tokens": 0,
      "rejected_prediction_tokens": 0
    }
  },
  "system_fingerprint": "fp_6b68a8204b"
}

 

이렇게 나오게 됩니다!

 

그래서 나중에 내용만 필요할 경우에는

response.choices().get(0).message().content()

 

이런 식으로 추출해올 수 있어요!


+ 503 Service Temporarily Unavailable가 발생할 경우

저도 처음에 curl 요청을 보냈을 때 위의 에러가 발생했는데요!

# Secret Key를 발급받으시고 curl 요청을 보내보세요! (터미널에서 복붙하시면 돼요!)

curl https://api.openai.com/v1/chat/completions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OPENAI_API_KEY" \ // 발급 받은 secret key 넣기
  -d '{
    "model": "gpt-4o",
    "messages": [
      {
        "role": "system",
        "content": "You are a helpful assistant."
      },
      {
        "role": "user",
        "content": "Hello!"
      }
    ]
  }'

 

위 에러는 OpenAI에 충전된 금액이 없어서 발생하더라고요!

 

https://platform.openai.com/settings/organization/billing/overview

 

위 페이지에서 카드 등록 후, Add to credit balance를 통해 충전하시면 됩니다!

 

5$ 부터 가능하니 참고해주세요!


GPT 비싸다는데 얼마인가요..??

저희가 반환받은 응답의 토큰 수로 계산을 한 번 해보겠습니다!

 

"model": "gpt-4o-mini-2024-07-18",

"prompt_tokens": 29, "completion_tokens": 83, "total_tokens": 112,

 

모델은 gpt-4o-mini-2024-07-18를 사용하였고,

입력 29토큰, 출력 83토큰 총 112토큰을 사용하였습니다!

https://openai.com/api/pricing/

 

 

gpt한테 계산해달라고 부탁했습니다.

 

0.00005415달러???

 

 

네. 0.076원입니다..!

 

저렴하다고 생각할 수 있지만, 지금은 프롬프트가 매우 짧은 문장이라 그렇고 문장의 길이가 길어지면 길어질 수록 가격은 기하급수적으로 올라갈 수 있기 때문에 매번 사용하고 대시보드에서 사용량과 비용을 꼭 확인하세요!!!!!

 

끝!