Spring AI 실전 적용기 - 7단계 AI 진단 파이프라인 구축

HTTP 직접 호출 + 동기 단일 프롬프트 방식을 Spring AI 기반 7단계 파이프라인으로 재설계한 실전 경험을 공유합니다.

배경

우리 서비스에는 사용자의 음성 데이터를 AI로 분석해서 진단 리포트를 생성하는 기능이 있다. 초기 구현도 Java/Spring 기반이었지만, 구조가 단순했다. HTTP 클라이언트로 외부 AI API를 직접 호출하는 방식 하나의 프롬프트에 모든 걸 담아서 LLM에 던지고, 피드백 → 문제 생성을 for 루프로 순차 처리 청킹이나 STT 보정 없이 raw 텍스트를 그대로 사용 결과물의 품질이 불안정 - 할루시네이션, 포맷 깨짐, 누락 등 CS 인입이 꾸준히 발생

특히 LLM 호출을 동기적으로 순차 처리하다 보니 전체 처리 시간이 길었고, 모델을 교체하려면 HTTP 클라이언트 코드를 직접 수정해야 했다. 프로바이더마다 다른 API 스펙, 인증 방식, 요청/응답 포맷을 각각 대응하는 것도 부담이었다.

왜 Spring AI인가

기존 HTTP 직접 호출 방식의 한계를 해결하기 위해 선택지를 검토했다. HTTP 클라이언트 유지 + 구조 개선 - 기존 방식을 그대로 쓰되 파이프라인만 분리. 하지만 모델별 API 차이를 계속 직접 대응해야 한다 LangChain4j - Java용 LangChain 포팅. 기능은 많지만 Spring 생태계와의 통합이 아직 어색한 부분이 있었다 Spring AI - Spring 팀이 공식으로 만드는 프로젝트. Spring Boot 자동설정, 의존성 주입, 프로퍼티 바인딩 등 기존 Spring 개발 경험을 그대로 가져갈 수 있다

Spring AI를 선택한 이유는 명확했다. 우리 팀이 이미 Spring Boot 위에서 일하고 있었고, 새로운 프레임워크를 배우는 데 시간을 쓰기보다 익숙한 패턴 위에서 빠르게 구현하고 싶었다. ~~솔직히 Spring 생태계에서 새로 나온 기술을 실제 프로덕션에 적용해보고 싶다는 개인적인 욕심도 있었다.~~

무엇보다 Gemini, OpenAI, Amazon Bedrock 같은 서로 다른 LLM 프로바이더를 ChatModel이라는 하나의 인터페이스로 추상화해준다는 점이 결정적이었다. 기존에는 프로바이더마다 다른 API 스펙, 인증 방식, 요청/응답 포맷을 HTTP 클라이언트 레벨에서 각각 대응해야 했지만, Spring AI를 도입하면 동일한 ChatClient 코드로 어떤 모델이든 호출할 수 있다. 덕분에 스텝별로 다른 모델을 배치하더라도 코드의 일관성이 유지되고, 모델 교체가 설정 변경만으로 가능했다.

기존 Spring 프로젝트에 의존성 추가하고, application.yml에 모델 설정만 넣으면 바로 쓸 수 있었다.

파이프라인 설계

단일 프롬프트의 한계는 명확했다. 한 번에 모든 걸 시키면 LLM이 일부를 누락하거나 포맷을 깨뜨린다. 그래서 작업을 7단계로 명확히 분리했다.

Step 1: LoadSrtStep - STT 자막 로드

S3에서 STT(음성인식) 결과인 SRT 자막 파일을 가져오는 단계다. S3AsyncClient를 사용해 비동기로 파일을 조회하고, 보정된 파일이 있으면 우선 선택한다. LLM을 사용하지 않는 순수 I/O 스텝이다.

Step 2: ChunkStep - Semantic Chunking

긴 텍스트를 의미 단위로 분할한다. 단순한 토큰 수 기반 분할이 아니라, LLM을 호출해 주제 전환 지점을 감지하고 논리적으로 끊는다. WPM, MLR, 문장 복잡도 같은 강의 메트릭도 함께 계산해서 프롬프트 컨텍스트로 활용한다. 이후 모든 스텝이 청크 단위로 병렬 처리되기 때문에, 파이프라인 전체의 품질과 성능을 좌우하는 중요한 스텝이다.

여기서 비용 최적화 포인트가 있다. LLM에 각 블록을 인덱스와 함께 입력으로 넘기고, 응답으로는 블록 인덱스만 돌려받는다.

LLM이 전체 텍스트를 다시 출력할 필요 없이 인덱스 번호(1~3 토큰)만 반환하면 되므로, 블록당 출력 토큰이 약 80% 이상 절감된다. 애플리케이션에서 인덱스를 원본 텍스트에 매핑하는 건 간단한 subList() 호출이다.

Step 3: CorrectedStep - STT 오류 보정 (조건부)

Raw STT인 경우에만 실행되는 조건부 스텝이다. 각 청크 그룹을 CompletableFuture.allOf()로 병렬 처리하며, LLM이 STT 인식 오류(발음 유사 오타, 단어 누락 등)를 보정한다. 이미 보정된 SRT 파일이면 이 스텝을 건너뛴다.

여기서도 블록 인덱스 패턴을 활용한다. LLM은 변경이 필요한 블록만 인덱스와 보정 텍스트를 반환하고, 변경이 없는 블록은 아예 응답에 포함하지 않는다.

전체 10개 블록 중 수정이 필요한 건 2개뿐이다. 나머지 8개는 응답에 포함하지 않으므로, 출력 토큰이 약 80% 절감된다. 애플리케이션에서는 반환된 인덱스만 원본에 덮어쓰면 되니 로직도 단순하다.

Step 4: AnalyzeMetricsStep - 지표 산출

청크를 다시 하나로 합쳐서 전체 강의에 대한 메트릭(WPM, 평균 발화 길이, 턴 수, 복잡도, 어휘 다양성)을 산출하고 DB에 진단 레코드를 생성한다. LLM을 사용하지 않는 분석 전용 스텝이다.

Step 5: FeedbackStep - 청크별 LLM 피드백 생성

파이프라인의 핵심. 각 청크에 대해 CompletableFuture.allOf()로 병렬 LLM 호출을 수행한다. 학습자 레벨에 따라 피드백 종류가 달라진다 - 초급은 어휘(VOCAB) 피드백, 중급 이상은 문장(SENTENCE) 교정 피드백을 생성한다. LLM 응답이 깨진 JSON일 경우 자체 repair 로직으로 복구를 시도하고, 실패하면 최대 3회 재시도한다. 생성된 피드백은 메시지 큐를 통해 비동기로 DB에 적재한다.

Step 6: QuestionStep - 피드백 기반 문제 생성

이전 스텝에서 집계된 피드백 결과를 받아서, 각 피드백 아이템에 대해 병렬로 연습 문제를 생성한다. 문장 만들기, 빈칸 채우기, 객관식 등 다양한 유형의 문제가 만들어진다. 프롬프트 선택은 수업·피드백 식별자의 해시 기반으로 결정되어, 재시도 시에도 동일한 프롬프트가 선택되는 결정적(deterministic) 구조다.

Step 7: NotificationStep - 완료 알림 발송

진단 상태를 COMPLETED로 업데이트하고, 학습자에게 푸시 알림을 발송한다. 실패 시 Slack 알림을 보내 운영팀이 즉시 인지할 수 있도록 했다.

멀티 모델 배치 전략

모든 스텝에 같은 모델을 쓸 필요가 없다. 스텝별 특성에 맞게 모델을 배치했다.