1. 서론: "API 스펙을 직접 구현해야 하나?"
'Morning Commit' 프로젝트의 핵심 기능은 기술 블로그 게시글을 AI가 분석하여 요약, 핵심 인사이트, 난이도 등을 추출하는 것입니다. 초기에는 이 기능을 구현하기 위해 가장 익숙한 방식인 REST API 직접 호출을 선택했습니다.
하지만 개발을 진행할수록 비즈니스 로직보다 OpenAI의 API 스펙을 관리하는 데 더 많은 시간을 쏟고 있다는 것을 깨달았습니다.
2. 태동기: 직접 통신의 시대 (OpenFeign & ObjectMapper)
초기 아키텍처는 OpenFeign을 사용하여 OpenAI 엔드포인트와 통신했습니다.
2-1. 직면한 문제점 (Pain Points)
- DTO 관리의 부담 (Boilerplate Hell)
- OpenAI의 요청/응답 규격을 맞추기 위해 ChatCompletionRequest, Message, ChatCompletionResponse, Choice 등 4개 이상의 통신 전용 DTO를 직접 만들고 유지보수해야 했습니다.
- API 버전이 업데이트되거나 필드가 추가되면, 우리 쪽 코드도 함께 수정해야 하는 강한 결합이 발생했습니다.
- 불안정한 데이터 파싱 (Parsing Risk)
- AI는 응답을 문자열(String)로 줍니다. 이를 우리가 필요한 객체(BlogAnalysisResult)로 변환하기 위해 ObjectMapper를 직접 사용했습니다.
- 문제: AI가 가끔 마크다운 코드 블록(```json)을 포함해서 응답하면 JsonParseException이 발생했습니다. 이를 방어하기 위한 정규식 처리 로직이 비즈니스 코드에 섞여 들어갔습니다.
- 수동 인증 처리
- 모든 요청마다 헤더에 "Bearer $apiKey"를 수동으로 주입해야 했습니다. 실수로 누락하거나 형식이 틀릴 위험이 항상 존재했습니다.
[Legacy Code] 당시의 OpenFeign 구현
Kotlin
// 통신을 위한 Client 인터페이스 직접 정의
@FeignClient(name = "openAiClient", url = "[https://api.openai.com](https://api.openai.com)")
interface OpenAiClient {
@PostMapping("/v1/chat/completions")
fun createChatCompletion(
@RequestHeader("Authorization") authorization: String,
@RequestBody request: ChatCompletionRequest
): ChatCompletionResponse
}
// 서비스 로직: 인증, 요청 조립, 파싱을 모두 직접 수행
fun analyze(content: String): BlogAnalysisResult {
val request = ChatCompletionRequest(messages = listOf(Message("user", content)))
// 1. 인증 토큰 수동 주입
val response = openAiClient.createChatCompletion("Bearer $apiKey", request)
// 2. 응답 본문 추출 및 수동 파싱 (위험 구간)
val jsonContent = response.choices.first().message.content
return objectMapper.readValue<BlogAnalysisResult>(jsonContent)
}
3. 과도기: 프레임워크의 도입 (ChatModel vs ChatClient)
API 스펙 관리에서 벗어나기 위해 Spring AI 도입을 결정했습니다. 이때 두 가지 핵심 컴포넌트 사이에서 고민이 있었습니다.
3-1. 기술 선택: ChatModel vs ChatClient
- ChatModel (The Engine):
- 저수준 API입니다. Prompt, UserMessage, SystemMessage 객체를 일일이 생성해서 리스트에 담아 주입해야 합니다.
- 단점: 코드가 "명령형(Imperative)"이라 흐름이 뚝뚝 끊깁니다. OpenFeign 때와 비슷한 수준의 객체 조립 과정이 필요했습니다.
- ChatClient (The Interface):
- 고수준 API입니다. Fluent API 스타일을 지원합니다.
- 장점: prompt().user().call()처럼 메서드 체이닝을 통해 마치 문장을 쓰듯 코드를 작성할 수 있습니다. 시스템 프롬프트나 기본 설정을 빌더 단계에서 고정할 수 있어 코드 중복이 획기적으로 줄어듭니다.
결정: 생산성과 가독성을 위해 ChatClient를 선택했습니다. spring-ai-openai-spring-boot-starter를 통해 인증 설정도 자동화되었습니다.
4. 완성기: Kotlin 기술적 최적화 (Type Safety & Erasure)
프레임워크를 도입했음에도 해결되지 않은 난관이 있었습니다. 바로 자바 플랫폼의 고질적인 문제인 제네릭 타입 소거(Type Erasure)였습니다.
4-1. 문제: 런타임에 사라지는 타입 정보
AI의 응답을 List<BlogAnalysisResult> 형태로 받고 싶었지만, 런타임에는 제네릭 타입 정보(<BlogAnalysisResult>)가 지워져 단순 List로 인식됩니다. Spring AI는 리스트 안에 어떤 객체를 넣어야 할지 몰라 LinkedHashMap으로 변환해버리고, 이는 ClassCastException으로 이어집니다.
이를 해결하는 자바의 정석적인 방법은 ParameterizedTypeReference를 사용하는 것입니다.
Kotlin
// Java 스타일의 해결책: 너무 장황함
.entity(object : ParameterizedTypeReference<List<BlogAnalysisResult>>() {})
4-2. 해결: inline과 reified의 마법
Kotlin의 강력한 기능인 inline 함수와 reified 키워드를 도입했습니다.
- inline: 함수 호출 시점에 본문 코드를 호출부로 복사해 넣습니다.
- reified: 복사된 코드 내에서 제네릭 타입 T를 런타임 클래스 정보로 "실체화(Reify)"합니다.
이를 통해 확장 함수(Extension Function)를 직접 구현했습니다.
[Extension Code] ChatClientExtensions.kt
Kotlin
package server.morningcommit.global.extension
import org.springframework.ai.chat.client.ChatClient
import org.springframework.core.ParameterizedTypeReference
// ChatClient의 응답 처리에 날개를 달아주는 확장 함수
inline fun <reified T> ChatClient.CallResponseSpec.entity(): T? {
// reified T 덕분에 T가 'List<BlogAnalysisResult>'라는 것을 런타임에도 알 수 있음
val typeRef = object : ParameterizedTypeReference<T>() {}
return this.entity(typeRef)
}
5. 최종 결과: 완성된 아키텍처와 유지보수성
마지막으로, 코드 내에 하드코딩되어 있던 거대한 시스템 프롬프트(AI 페르소나 설정)를 src/main/resources/prompts/summary-system.st 파일로 격리했습니다.
이로써 OpenFeign 인터페이스, DTO 4종, ObjectMapper 파싱 로직, 프롬프트 문자열이 모두 사라지고, 오직 비즈니스 흐름만 남았습니다.
[Final Code] SummaryService.kt
Kotlin
@Service
class SummaryService(
chatClientBuilder: ChatClient.Builder,
// 프롬프트와 코드를 분리하여 유지보수성 확보 (Resource 주입)
@Value("classpath:/prompts/summary-system.st") private val systemPrompt: Resource
) {
// 1. 기본 설정이 완료된 ChatClient 생성 (인증, 시스템 프롬프트 포함)
private val chatClient = chatClientBuilder
.defaultSystem(systemPrompt)
.build()
fun analyze(content: String): BlogAnalysisResult? {
return chatClient.prompt() // Fluent API 시작
.user(content) // 사용자 입력 전달
.call() // AI 호출
.entity<BlogAnalysisResult>() // Kotlin 확장 함수로 안전하고 간결하게 파싱
}
}
6. 결론 (Retrospective)
이번 리팩토링 과정은 단순히 라이브러리를 교체하는 작업이 아니었습니다. 그것은 "애플리케이션의 핵심 가치에 집중하기 위한 여정"이었습니다.
- Before (OpenFeign): "어떻게 OpenAI API 규격을 맞출 것인가?" (API 구현에 집중)
- After (Spring AI + Kotlin): "어떤 프롬프트로 더 좋은 요약 결과를 낼 것인가?" (서비스 품질에 집중)
우리는 이제 타입 안전성이 보장된 환경에서, 더 적은 코드로 더 안정적인 AI 서비스를 운영할 수 있게 되었습니다. ChatClient의 유려함과 Kotlin의 실용성이 만나 최적의 백엔드 환경을 구축한 것입니다.
'ETC' 카테고리의 다른 글
| [Spring Boot/Kotlin] Redis 캐싱: PageImpl 직렬화 에러와 ClassCastException 완벽 해결 가이드 (0) | 2026.02.17 |
|---|---|
| 만들면서 배우는 클린 아키텍처 (2) | 2025.04.22 |