
Spring Boot에서 OpenAI API를 호출하고, 프롬프트를 통해 생성된 응답을 Java DTO로 변환하여 REST API로 반환하는 과정을 학습했다.
오늘은 OpenAI API를 직접 호출하는 방식과 Spring AI의 ChatClient를 사용하는 방식을 비교하고, 날씨 기반 맛집 추천과 과목별 퀴즈 생성 기능을 구현했다.
오늘 구현한 전체 구조는 다음과 같다.
Client
↓ HTTP 요청
OpenAiController
↓ 사용자 입력 전달
OpenAiService
↓ 프롬프트 생성
Spring AI ChatClient
↓ OpenAI API 호출
AI 응답
↓ DTO 변환
ResponseEntity로 반환
기존에는 서버가 DB에 저장된 데이터를 조회해 반환했다면, 이번에는 사용자가 전달한 조건을 프롬프트에 포함하고 AI가 생성한 결과를 반환한다는 차이가 있었다.
프로젝트에는 Spring AI의 OpenAI 의존성을 추가했다.
implementation 'org.springframework.ai:spring-ai-starter-model-openai'
API Key와 사용할 모델은 코드에 직접 작성하지 않고 환경변수로 분리했다.
spring:
ai:
openai:
api-key: ${OPEN_AI_KEY}
chat:
options:
model: ${OPEN_AI_MODEL}
API Key는 외부에 공개되면 다른 사람이 사용할 수 있으므로 저장소에 올리지 않도록 주의해야 한다.
OpenAiConfig에서는 실습에 필요한 객체를 Bean으로 등록했다.
@Bean
public ChatClient chatClient(ChatClient.Builder builder) {
return builder.build();
}
@Bean
public ObjectMapper objectMapper() {
return new ObjectMapper();
}
@Bean
public OkHttpClient okHttpClient() {
return new OkHttpClient();
}
각 객체의 역할은 다음과 같다.
ChatClient: 프롬프트를 작성하고 AI 모델을 호출한다.ObjectMapper: Java 객체와 JSON을 서로 변환한다.OkHttpClient: OpenAI API를 HTTP로 직접 호출할 때 사용한다.OpenAI에 전달하는 메시지는 역할에 따라 구분할 수 있다.
system: AI가 따라야 할 전체적인 역할과 응답 규칙user: 사용자가 요청한 실제 질문이나 조건assistant: 이전에 AI가 응답한 내용오늘 실습에서는 system과 user를 구분하여 작성했다.
chatClient
.prompt()
.system("전처리된 JSON 형태로만 반환해줘")
.user("현재 날씨와 지역에 맞는 맛집을 추천해줘")
.call();
system에는 JSON 반환과 같은 공통 규칙을 작성하고, user에는 날씨, 위치, 과목처럼 요청마다 달라지는 값을 전달했다.
OpenAI의 Chat Completions API도 여러 역할을 가진 메시지 목록을 전달하고, 모델이 그 대화 내용을 바탕으로 응답을 생성하는 구조이다.
AI에게 단순히 “맛집을 추천해줘”라고 요청하는 것보다 역할, 조건, 출력 형태를 함께 작성하면 원하는 결과를 얻기 쉽다.
역할
→ 너는 날씨와 맛집을 잘 아는 전문가야.
조건
→ 날씨: 비
→ 위치: 서울
출력 형식
→ weather
→ location
→ restaurants
Java의 Text Block과 formatted()를 사용해 입력받은 값을 프롬프트에 넣었다.
String prompt = """
현재 날씨에 따른 지역 맛집을 추천해줘.
<조건>
- 날씨: "%s"
- 위치: "%s"
</조건>
""".formatted(weather, location);
프롬프트에는 다음 내용을 포함하는 것이 좋다.
AI의 응답을 문자열로만 반환하지 않고 Java DTO로 변환했다.
RecommandResponseDTO result = chatClient
.prompt()
.system("전처리된 JSON 형태로만 반환해줘")
.user(prompt)
.call()
.entity(RecommandResponseDTO.class);
entity()에 원하는 클래스 타입을 전달하면 AI 응답을 해당 DTO 구조로 변환할 수 있다.
맛집 추천 응답 구조는 다음과 같다.
RecommandResponseDTO
├─ weather
├─ location
└─ restaurants
├─ name
├─ category
└─ reason
퀴즈 응답도 별도의 DTO로 만들었다.
QuizResponseDTO
└─ quizs
├─ question
├─ options
├─ answer
└─ desc
이렇게 응답 구조를 DTO로 정의하면 Controller가 일정한 JSON 형식으로 결과를 반환할 수 있다.
날씨와 지역을 요청 파라미터로 전달받는 API를 구현했다.
@PostMapping("/recommend")
public ResponseEntity<?> recommend(
@RequestParam String weather,
@RequestParam String location) {
return ResponseEntity.ok(
openAiService.recommand(weather, location)
);
}
요청 예시는 다음과 같다.
POST /openai/recommend?weather=비&location=서울
Service에서는 날씨와 위치를 프롬프트에 넣고, AI가 생성한 맛집 목록을 RecommandResponseDTO로 변환했다.
과목명을 전달하면 객관식 문제를 생성하는 API도 구현했다.
@PostMapping("/quiz")
public ResponseEntity<?> quiz(
@RequestParam String subject) {
return ResponseEntity.ok(
openAiService.quiz(subject)
);
}
요청 예시는 다음과 같다.
POST /openai/quiz?subject=Java
프롬프트에는 문제 수와 응답 형식을 구체적으로 작성했다.
- 총 10문제 생성
- 문제마다 보기 4개 제공
- 정답과 해설 포함
- JSON 형태로 반환
수업에서는 OpenAI API를 호출하는 두 가지 방법을 살펴봤다.
직접 호출하는 방식에서는 다음 작업을 모두 작성해야 한다.
요청 메시지 구성
→ Java 객체를 JSON으로 변환
→ Authorization 헤더 설정
→ HTTP 요청 전송
→ 응답 JSON 읽기
→ 필요한 값 추출
→ DTO로 변환
응답에서도 다음처럼 필요한 경로를 직접 찾아야 했다.
JsonNode node = objectMapper.readTree(responseJson);
String content = node
.at("/choices/0/message/content")
.asText();
ChatClient를 사용하면 복잡한 HTTP 요청과 응답 분석 과정을 줄일 수 있다.
chatClient
.prompt()
.system(systemPrompt)
.user(userPrompt)
.call()
.entity(ResponseDTO.class);
직접 호출 과정을 먼저 확인한 뒤 ChatClient를 사용하니, Spring AI가 어떤 작업을 대신 처리하는지 이해하기 쉬웠다.
AI의 응답은 항상 완전히 동일하지 않다. JSON 앞뒤에 설명이나 Markdown 코드 블록이 붙으면 DTO 변환 과정에서 오류가 발생할 수 있다.
이를 줄이기 위해 프롬프트에 다음 조건을 명확하게 작성했다.
반드시 JSON만 반환해줘.
다른 설명은 작성하지 마.
지정한 출력 구조를 지켜줘.
단순히 질문만 전달하는 것이 아니라, 서버가 처리할 수 있는 응답 형식까지 프롬프트로 제어해야 한다는 점을 알게 되었다.
API Key를 Java 코드에 직접 작성하면 저장소를 통해 외부로 노출될 수 있다.
따라서 실제 값은 환경변수에 저장하고 설정 파일에서는 변수명만 참조하도록 구성했다.
api-key: ${OPEN_AI_KEY}
공식 문서: OpenAI API — 환경변수와 API Key