spring-ai-bom, application-dev.ymlrole(system, user, assistant), contentOkHttpClientObjectMapper, JsonNode@JsonIgnorePropertiesChatClient로 호출 — prompt(), system(), user(), call(), entity()content()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 | 용도 |
|---|---|
ObjectMapper | Object ↔ JSON 변환 |
OkHttpClient | OpenAI API 직접 호출 (case 01) |
ChatClient | Spring 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));
}
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));
조립한 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();
응답 전체가 곧 추천 결과는 아니다. 모델이 생성한 답변은 응답 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도 필요하다.
위의 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처럼 직접 호출하는 방식을 쓴다.
과목을 받아 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;
}
}
기존 블로그 기능에 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는 인증이 필요하다.