고아(Goa) 여행을 계획하고 싶다고 가정해 봅시다. 5일의 일정, 25,000루피의 예산, 그리고 해변과 해산물을 선호한다는 명확한 취향이 있습니다. 보통 이럴 때는 브라우저 탭을 열어 수십 개의 사이트를 뒤지고, 오래된 포럼 게시물을 읽으며, 수동으로 일정을 짜야 합니다. 대신, 단 한 번의 POST 요청을 보내고 식사 제안, 활동 목록, 정확한 예산 배분이 포함된 구조화된 일별 계획을 받는 모습을 상상해 보세요. 이 프로젝트가 바로 그 기능을 제공합니다.

우리는 Spring Boot와 Azure OpenAI를 사용하여 REST API를 구축할 것입니다. 이 API는 목적지, 예산, 기간, 관심사를 입력받습니다. 그리고 프론트엔드나 모바일 앱에서 즉시 렌더링할 수 있는 깔끔한 JSON을 반환합니다. 스크래핑도, 하드코딩된 일정표도 없습니다. 그저 여행 플래너 역할을 하도록 프롬프트가 설정된 AI 모델이 있을 뿐입니다.

API 반환 내용

응답은 정규 표현식(regex)으로 일일이 분리해야 하는 마크다운 텍스트 덩어리가 아닙니다. 일일 활동, 식사 추천, 예산 내역을 포함하는 구조화된 JSON 객체입니다. 고아 여행의 경우, 해변 식당(beach shack)에서의 아침 식사에 500루피를 할당하고, 오전에는 팔레올렘(Palolem)에서 시간을 보내며, 저녁에는 특정 지역에서 해산물 저녁 식사를 하는 첫째 날의 세그먼트를 받을 수 있습니다. 각 날짜에는 시간대, 예상 비용, 그리고 "beach"나 "food"와 같은 태그가 포함됩니다. 이러한 구조가 중요한 이유는 현대적인 여행 앱이 문단을 파싱하기보다는 RecyclerView나 React 컴포넌트에 매핑할 수 있는 객체를 원하기 때문입니다.

기술 스택 및 선정 이유

이 프로젝트는 Spring AI를 포함한 Spring Boot 3.5를 사용합니다. Spring AI가 핵심적인 부분입니다. Spring AI는 통합된 ChatModel 추상화를 제공하므로 Azure OpenAI를 위해 직접 로우 레벨(raw) HTTP 클라이언트를 작성할 필요가 없습니다. 서비스 코드가 아닌 의존성과 프로퍼티만 교체하면 됩니다.

빌드 파일에 다음 네 가지 의존성이 필요합니다:

  • REST 계층을 위한 spring-boot-starter-web.
  • Spring AI 인터페이스를 통해 LLM에 연결하기 위한 spring-ai-starter-model-azure-openai.
  • 자동 Swagger 문서를 위한 springdoc-openapi.
  • 요청 및 응답 POJO의 보일러플레이트 코드를 줄이기 위한 Lombok.

Spring AI는 비즈니스 로직과 LLM 제공자 사이에 위치합니다. 이러한 배치는 의도적인 것입니다. 이를 통해 @Service 클래스를 깔끔하게 유지하고 특정 제공자에 종속되지 않게(provider-agnostic) 만들 수 있습니다.

PromptTemplates를 활용한 프롬프트 엔지니어링

Java 문자열 내에 프롬프트를 하드코딩하는 것은 유지보수가 불가능한 소프트웨어를 만드는 지름길입니다. 만약 제품 팀에서 AI의 말투를 더 캐주얼하게 바꾸거나, 특정 임계값 이상의 예산 추정치는 거부하도록 결정한다면, 서비스를 다시 컴파일할 필요가 없어야 합니다.

Spring AI는 PromptTemplate을 제공합니다. 프롬프트의 골격을 리소스 파일이나 전용 템플릿 문자열에 저장하고, {destination}, {budget}, {days}, {interests}와 같은 변수를 위한 플레이스홀더를 남겨둡니다. 런타임 시 서비스는 Prompt 객체를 생성하고 사용자의 값을 주입합니다.

시스템 메시지와 사용자 메시지를 분리하세요. 시스템 메시지를 사용하여 페르소나를 정의합니다. 예를 들어, 모델에게 인도 여행 전문 플래너이며, 예산을 중시하고, 마크다운 구분 기호 없이 오직 JSON만 반환해야 한다는 규칙을 지키라고 지시합니다. 사용자 메시지는 구체적인 여행 세부 정보를 전달하는 데 사용합니다. 이렇게 분리해 두면 나중에 API 규약을 변경하지 않고도 페르소나를 A/B 테스트하기 용이합니다.

서비스 계층: Azure OpenAI와 통신하기

@Service 클래스의 역할은 단 하나입니다. 프롬프트를 구성하고, 모델을 호출하며, 응답을 정제하고, 결과를 파싱하는 것입니다.

Spring AI의 ChatClient 또는 ChatModel을 주입합니다. 들어오는 요청 값으로 PromptTemplate을 렌더링한 다음 chat 메서드를 호출합니다. 응답은 String 형태로 도착합니다. 여기서 많은 튜토리얼이 끝나지만, 실제 프로덕션 코드는 여기서부터 시작됩니다.

LLM은 때때로 정중한 서문을 덧붙이곤 합니다. "여기 당신의 일정입니다"라는 문구로 시작한 뒤 세 개의 백틱(```)으로 감싸진 JSON을 던져줄 수도 있습니다. 이를 Jackson으로 직접 역직렬화하려고 하면 앱이 충돌합니다. 원시 문자열을 스캔하여 첫 번째 여는 중괄호와 마지막 닫는 중괄호를 찾아 JSON 페이로드만 추출하는 작은 헬퍼 메서드를 추가하세요. 그런 다음 추출된 블록을 검증해야 합니다. 컨트롤러에 객체를 반환하기 전에 필수 필드가 존재하는지, 숫자 값이 타당한지 확인하십시오.

이러한 방어적 파싱(defensive parsing)은 선택 사항이 아닙니다. 이는 데모와 신뢰할 수 있는 API를 가르는 경계선입니다.

성숙한 시스템처럼 에러 처리하기

외부 API는 실패할 수 있습니다. Azure OpenAI는 속도 제한(rate limit) 에러, 인증 실패 또는 일시적인 500 에러를 반환할 수 있습니다. 이러한 에러가 스택 트레이스(stack trace) 형태로 사용자에게 그대로 노출되게 두면 신뢰를 잃게 됩니다.

@RestControllerAdvice를 사용하여 예외를 전역적으로 가로챕니다. Spring AI 예외, HttpClientErrorException, 그리고 일반적인 RuntimeException을 일관된 에러 응답으로 매핑하세요. 명확한 메시지, 속도 제한(rate limit)의 경우 429와 같은 HTTP 상태 코드, 그리고 클라이언트가 재시도하거나 문제를 로그로 남길 수 있는 충분한 세부 정보를 포함한 JSON 본문을 반환해야 합니다. 사용자는 Java 클래스 이름이 가득한 화면이 아니라 "서비스가 일시적으로 바쁩니다. 30초 후에 다시 시도해 주세요."와 같은 메시지를 보게 되어야 합니다.

비밀 정보를 절대 하드코딩하지 마세요

Azure OpenAI API 키를 Git에 체크인되는 application.properties에 포함해서는 안 됩니다. 이를 외부화하세요. Spring 설정에서 ${AZURE_OPENAI_KEY}${AZURE_OPENAI_ENDPOINT}와 같이 참조되는 환경 변수를 사용하세요. 개발용으로 로컬 .env 파일을 유지하고, 이를 .gitignore에 추가한 뒤 Spring Boot의 relaxed binding을 통해 로드하세요. 키가 유출되더라도 아티팩트를 다시 빌드할 필요 없이 한 곳에서 키를 교체(rotate)할 수 있습니다.

Swagger를 통한 테스트

springdoc-openapi 의존성은 런타임에 Swagger UI 엔드포인트를 노출합니다. 애플리케이션이 시작되면 브라우저에서 /swagger-ui.html을 여세요. Goa 예시를 직접 입력할 수 있습니다: 목적지는 "Goa", 예산은 25000, 기간은 5일, 관심사는 "beaches, food"로 설정합니다. 'execute'를 누르면 JSON 일정(itinerary)이 나타나는 것을 확인할 수 있습니다. 이를 통해 프롬프트 변경 사항을 검증하고, 직렬화(serialization)를 확인하며, 양측이 유닛 테스트를 작성하기 전에 프론트엔드 개발자와 실시간 플레이그라운드를 공유할 수 있습니다.

코드 재작성 없이 제공업체 교체하기

스타트업은 제공업체를 변경하곤 합니다. Azure 크레딧이 만료될 수도 있고, 비용 절감을 위해 로컬 Ollama 인스턴스에서 추론을 실행하고 싶을 수도 있습니다. Spring AI는 ChatModel 인터페이스를 추상화하므로, 교체 작업은 기계적으로 이루어집니다. Maven 의존성을 spring-ai-starter-model-azure-openai에서 다른 starter로 변경하고, 속성 파일(properties file)을 새 엔드포인트와 키로 업데이트하기만 하면 서비스 클래스는 그대로 유지할 수 있습니다. 모바일 앱이 보는 API 계약(contract)은 동일하게 유지됩니다.

이러한 이식성 덕분에 이 아키텍처는 실제 제품에 특히 유용합니다. Azure와 결혼하는 것이 아닙니다. 깨끗한 Spring 파이프라인에 연결된 하나의 엔진으로 사용할 뿐입니다.

핵심 요약

AI 모델은 애플리케이션 그 자체가 아닙니다. 예측 불가능한 텍스트를 반환하는 외부 서비스일 뿐입니다. 결제 게이트웨이나 제3자 날씨 API를 다루는 것과 동일한 엄격함으로 대하세요. 자격 증명(credentials)을 외부화하고, 모든 응답을 검증하며, 파싱하기 전에 페이로드를 정제하세요. 사용자가 스택 트레이스(stack trace)를 절대 보지 않도록 에러를 전역적으로 처리하세요.

25,000루피 예산으로 Goa 여행 일정을 짜는 창의적인 작업은 AI에게 맡기세요. 당신은 배관(plumbing, 시스템의 기반 구조)을 담당하면 됩니다. 이 두 가지가 분리되어 있을 때, 실제로 출시 가능한 시스템을 구축할 수 있습니다.

이 기사의 영감이 된 원문 가이드는 여기에서 확인할 수 있습니다.

Spring AI 및 유사 프로젝트에 대해 논의하고 싶으신가요? GyaanSetu 학습 커뮤니티에 참여하세요.