[TIL] BE-18 - OpenAI API 호출, 응답 JSON 파싱, ChatClient

W L·2026년 9월 16일

TIL

목록 보기
30/32

1. 오늘 학습한 주제

  • Spring AI 의존성과 OpenAI 설정 — spring-ai-bom, application-dev.yml
  • OpenAI message format — role(system, user, assistant), content
  • OpenAI API 직접 호출 — OkHttpClient
  • 응답 JSON(text) 파싱 — ObjectMapper, JsonNode
  • 응답 JSON을 받을 DTO — inner class, @JsonIgnoreProperties
  • ChatClient로 호출 — prompt(), system(), user(), call(), entity()
  • 퀴즈 생성, 블로그 본문 생성 — content()

2. 배운 내용

Spring AI 설정

spring boot 버전에 맞춰 spring ai 라이브러리 버전을 통일하기 위해 BOM(Bill Of Materials)을 import한다. 개별 의존성에는 버전을 적지 않는다.

// BOM(Bill Of Materials)
// spring boot 버전에 맞는 spring ai lib version 을 통일 시키겠다...
dependencyManagement {
    imports {
        mavenBom "org.springframework.ai:spring-ai-bom:1.0.0"
    }
}
// spring ai + gpt openai 
implementation 'org.springframework.ai:spring-ai-starter-model-openai'
implementation 'org.springframework.ai:spring-ai-starter-mcp-server-webmvc'
implementation 'org.springframework.ai:spring-ai-starter-mcp-client'

// spring ai
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
implementation 'org.springframework.boot:spring-boot-starter-webflux'

API key와 model은 yml에 직접 적지 않고 환경변수로 받는다.

  ai:
    openai:
      api-key: ${OPEN_AI_KEY}
      chat:
        options:
          model: ${OPEN_AI_MODEL}

service에서는 @Value로 같은 값을 꺼내 쓴다.

@Value("${spring.ai.openai.api-key}")
private String key;

@Value("${spring.ai.openai.chat.options.model}")
private String model;

사용할 객체는 config에서 bean으로 등록한다.

@Configuration 
public class OpenAiConfig {
    
    @Bean 
    public ObjectMapper objectMapper() {
        return new ObjectMapper();
    }

    @Bean 
    public ChatClient chatClient(ChatClient.Builder builder) {
        return builder.build();
    }

    @Bean 
    public OkHttpClient okHttpClient() {
        return new OkHttpClient();
    }
}
bean용도
ObjectMapperObject ↔ JSON 변환
OkHttpClientOpenAI API 직접 호출 (case 01)
ChatClientSpring AI를 통한 호출 (case 02)

controller의 endPoint는 인증 없이 호출할 수 있도록 SecurityConfig의 permitAll()에 /openai/**를 추가했다.

.requestMatchers(
     "/users/**",
    "/swagger-ui/**",
    "/v3/api-docs/**",
    "/openai/**").permitAll()

전체 흐름

1. prompt(openai - role)
2. json(text)
3. parsing - ObjectMapper(DTO)

프롬프트를 role별 메시지로 만들어 보내고, 응답으로 온 JSON 형태의 text를 ObjectMapper로 DTO에 담는다. 날씨와 위치를 받아 맛집을 추천하는 recommend로 이 순서를 따라갔다.

@PostMapping("/recommend")
public ResponseEntity<?> recommend(@RequestParam("weather") String weather,
                                   @RequestParam("location") String location) {
    
    System.out.println("debug >>>> openai controller recommend");
    System.out.println("debug >>>> openai controller recommend params : " + weather);
    System.out.println("debug >>>> openai controller recommend params : " + location);

    return ResponseEntity
            .status(HttpStatus.OK)
            .body(openAiService.recommend(weather, location));
}

1. prompt — OpenAI message format

OpenAI에 보내는 요청은 messages 안에 role과 content를 가진 Map들이 들어가는 형태다.

/*
open ai message format
messages {
    Map - {
        role : system, user, assistant
        content : xxxxxx
    }
}
*/
role내용
system모델이 응답할 방식 지정
user사용자의 요청
assistant모델의 응답

프롬프트는 text block에 조건과 출력 예시를 적고 formatted()로 파라미터를 채운다. 출력 예시를 JSON으로 주는 것은 응답을 DTO로 받기 위해서다.

String prompt="""
    너는 날씨 전문가이고 맛있는 음식을 즐겨하는 인공지능 전문가야.
    현재날씨에 따른 지역맛집을 추천해줘.
    날씨에 따라 먹고싶은 음식이 다를 수 있으며 특정지역의 맛집을 추천해줘.
    <조건>
        - 날씨 : "%s"
        - 위치 : "%s"
    </조건>
    <출력예시>
    {
        "weather" : "날씨",
        "location" : "위치",
        "restaurants" : [
            {"name" : "음식점명", "category" : "분류", "reason" : "추천이유"}
        ]
    }
    </출력예시>
""".formatted(weather, location);

요청 body를 Map으로 조립한다. system에는 JSON으로만 반환하라는 지시를, user에는 위 프롬프트를 넣는다.

Map<String, Object> messages = new HashMap<>();
messages.put("model", model);

Map<String, Object> user = new HashMap<>();
user.put("role", "user");
user.put("content", prompt);

Map<String, Object> system = new HashMap<>();
system.put("role", "system");
system.put("content", "전처리된 json 형태로만 반환해줘.");

messages.put("messages", List.of(system, user));

2. json(text) — 직접 호출

조립한 Map을 ObjectMapper로 JSON 문자열로 바꾼다.

// Object(Map) -> JSON
String requestJson = null;
try {
    requestJson = objectMapper.writeValueAsString(messages);
} catch (Exception e) {
    e.printStackTrace();
}

OkHttpClient로 endpoint에 POST 요청을 보낸다. key는 Authorization 헤더에 Bearer를 붙여 넣는다.

// endpoint
// https://api.openai.com/v1, https://api.openai.com/v1/chat/completions
private String endPoint = "https://api.openai.com/v1/chat/completions";
Request request = new Request.Builder()
                        .url(endPoint)
                        .header("Authorization", "Bearer " + key)
                        .header("Content-Type", "application/json")
                        .post(RequestBody.create(requestJson, MediaType.parse("application/json")))
                        .build();
response = okHttpClient.newCall(request).execute();
System.out.println("debug >>>> response");
System.out.println(response);    
///////////////////////////////////            
String responseJson = response.body().string();

3. parsing — ObjectMapper(DTO)

응답 전체가 곧 추천 결과는 아니다. 모델이 생성한 답변은 응답 JSON의 choices[0].message.content에 들어 있고, 그 값은 JSON 모양을 한 문자열이다. 그래서 파싱이 두 번 필요하다.

JsonNode node = objectMapper.readTree(responseJson);
String exr = node.at("/choices/0/message/content").asText();

System.out.println(exr);

RecommendResponseDTO result = objectMapper.readValue(exr, RecommendResponseDTO.class);
return result;
단계메서드결과
응답 전체readTree()JsonNode
답변 꺼내기at("/choices/0/message/content").asText()JSON 형태의 String
DTO 변환readValue(exr, RecommendResponseDTO.class)RecommendResponseDTO

DTO의 필드 이름은 출력 예시의 property 이름에 맞춘다. 배열 안의 객체는 보통 inner class로 만든다.

@Builder
@Getter
@ToString
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties (ignoreUnknown = true)
public class RecommendResponseDTO {
    
    // json의 propertiy 이름에 맞춤
    private String weather;
    private String location;
    private List<ResaurantDTO> restaurants;

    // json의 json 형태 -> 보통 inner class 사용
    @Builder 
    @Getter 
    @ToString
    @NoArgsConstructor
    @AllArgsConstructor
    @JsonIgnoreProperties (ignoreUnknown = true)
    public static class ResaurantDTO {
        private String name;
        private String category;
        private String reason;
    }
}

@JsonIgnoreProperties(ignoreUnknown = true)는 DTO에 없는 property가 JSON에 들어 있어도 무시하고 변환한다. 모델의 응답이 출력 예시와 항상 똑같다는 보장이 없기 때문이다. readValue()는 기본 생성자로 객체를 만든 뒤 값을 채우므로 @NoArgsConstructor도 필요하다.

ChatClient

위의 1~3 과정을 ChatClient가 대신한다. yml의 설정(model, key)으로 ChatClient가 만들어지고, prompt()로 요청을 구성한다.

yml -> OpenAI Configuration(model, key) - ChatClient - prompt()
public RecommendResponseDTO recommend(String weather, String location) {
    System.out.println("debug >>>> openai service recommend model : " + model);
    System.out.println("debug >>>> openai service recommend params : " + weather);
    System.out.println("debug >>>> openai service recommend params : " + location);

    // ChatClient - OpenAI api 직접호출하지 않고
    // ChatClient(endPoint)
    RecommendResponseDTO result = chatClient
        .prompt()
        .system("전처리된 json 형태로만 반환해줘.")
        .user("""
            너는 날씨 전문가이고 맛있는 음식을 즐겨하는 인공지능 전문가야.
            현재날씨에 따른 지역맛집을 추천해줘.
            날씨에 따라 먹고싶은 음식이 다를 수 있으며 특정지역의 맛집을 추천해줘.
            <조건>
                - 날씨 : "%s"
                - 위치 : "%s"
            </조건>
            <출력예시>
            {
                "weather" : "날씨",
                "location" : "위치",
                "restaurants" : [
                    {"name" : "음식점명", "category" : "분류", "reason" : "추천이유"}
                ]
            }
            </출력예시>
        """.formatted(weather, location))
        .call()
        .entity(RecommendResponseDTO.class);

    return result;
}
case 01 (직접 호출)case 02 (ChatClient)
role별 Map 조립.system(), .user()
endPoint, 헤더 지정 후 OkHttpClient 호출.call()
readTree() → at() → readValue().entity(RecommendResponseDTO.class)

endPoint, key, 요청 body 조립, 응답 파싱이 코드에서 사라진다. 이 프로젝트의 ChatClient는 spring-ai-starter-model-openai와 yml 설정을 바탕으로 만들어지므로, 이 설정으로 연결되지 않는 API를 쓸 때는 case 01처럼 직접 호출하는 방식을 쓴다.

퀴즈 생성 — quiz

과목을 받아 10문제를 만든다. 구조는 recommend와 같다.

@PostMapping("/quiz")
public ResponseEntity<?> quiz(@RequestParam("subject") String subject) {
    
    System.out.println("debug >>>> openai controller quiz");
    System.out.println("debug >>>> openai controller quiz params : " + subject);
    return ResponseEntity
            .status(HttpStatus.OK)
            .body(openAiService.quiz(subject));
}      
public QuizResponseDTO quiz(String subject) {
    System.out.println("debug >>>> openai service quiz params : " + subject);

    // ChatClient - OpenAI api 직접호출하지 않고
    // ChatClient(endPoint)
    QuizResponseDTO result = chatClient
        .prompt()
        .system("""
            너는 멋진 인공지능이고 국가공인 문제출제 전문가야.
            반드시 json만 반환해줘.                
        """)
        .user("""
            너는 문제출제 전문위원이고 %s 전공이며 박사학위를 가지고 있어.
            <조건>
                - 10문제를 만들어줘.
                - ` 쓰지마
                - 무조건 json 형태로 만들어줘.
            </조건>
            <출력예시>
            {
                "quizzes" : [
                    {"question" : <문제내용>,
                    "options"   : [보기1, 보기2, 보기3, 보기4],
                    "answer"    : <정답>,
                    "desc       : <해설>}
                ]
            }
            </출력예시>
        """.formatted(subject))
        .call()
        .entity(QuizResponseDTO.class);

    return result;
}

출력 예시의 quizzes 배열이 List<Quiz>가 되고, 배열 안의 options는 List<String>이다.

@Builder 
@Getter 
@ToString 
@NoArgsConstructor 
@AllArgsConstructor 
@JsonIgnoreProperties (ignoreUnknown = true)
public class QuizResponseDTO {
    
    // json의 propertiy 이름에 맞춤
    private List<Quiz> quizzes;

    // json의 json 형태 -> 보통 inner class 사용
    @Builder 
    @Getter 
    @ToString
    @NoArgsConstructor
    @AllArgsConstructor
    @JsonIgnoreProperties (ignoreUnknown = true)
    public static class Quiz {
        private String question;
        private List<String> options;
        private String answer;
        private String desc;
    }
}

블로그 본문 생성 — ai/agent

기존 블로그 기능에 ChatClient를 붙였다. 카테고리와 키워드를 Map으로 받는다.

@PostMapping("/ai/agent")
public ResponseEntity<?> agent(@RequestBody Map<String, Object> map) {
    System.out.println("debug >>>> blog controller agent ");
    System.out.println("debug >>>> blog controller agent params : " + map.get("category"));
    System.out.println("debug >>>> blog controller agent params : " + map.get("keyword"));
    
    return ResponseEntity
            .status(HttpStatus.CREATED)
            .body(blogService.contentGenerate(map));
}

BlogService에 ChatClient를 주입받는다.

private final UserRepository userRepository;
private final BlogRepository blogRepository;

// ai
private final ChatClient chatClient;

본문은 JSON이 아니라 글 자체가 필요하므로 entity() 대신 content()로 받는다. content()는 응답을 String으로 반환한다.

public String contentGenerate(Map<String, Object> map) {
    
    String result = chatClient
        .prompt()
        .user("""
            넌 국문학과 박사 수료한 블로그 작성 전문가야.
            주어진 카테고리와 키워드를 기반으로 차분한 톤의 블로그를 작성해줘.
            글자수는 200자 이내로 작성해줘.
            <조건>
                - 카테고리 : "%s"
                - 키워드 : "%s"
            </조건>                
        """.formatted((String)map.get("category"), 
                      (String)map.get("keyword")))
        .call()
        .content();

    return result;
}
메서드반환
.entity(XXX.class)응답을 변환한 DTO
.content()응답 String

/blogs/**는 permitAll()에 없으므로 이 endPoint는 인증이 필요하다.

profile
한량

0개의 댓글