배경 우리 서비스에는 사용자의 음성 데이터를 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 알림을 보내 운영팀이 즉시 인지할 수 있도록 했다. 멀티 모델 배치 전략 모든 스텝에 같은 모델을 쓸 필요가 없다. 스텝별 특성에 맞게 모델을 배치했다. | 스텝 | 모델 | 선택 이유 | |||--| | LoadSrtStep | - | LLM 불필요, S3 I/O만 수행 | | ChunkStep | OpenAI (gpt-5-mini) | 빠른 응답, 구조화된 세그먼트 분할 | | CorrectedStep | OpenAI (gpt-5-mini) | STT 오류 보정에 충분한 성능, 낮은 비용 | | AnalyzeMetricsStep | - | LLM 불필요, 규칙 기반 메트릭 산출 | | FeedbackStep | 프롬프트별 설정 | 품질이 핵심, 백오피스에서 모델 동적 변경 | | QuestionStep | 프롬프트별 설정 | 문제 유형별 최적 모델 배치 | | NotificationStep | - | LLM 불필요, 알림 발송만 수행 | Spring AI의 장점은 여기서 빛난다. ChatModel 인터페이스가 통일되어 있어서, 프로바이더만 Bean으로 등록해두면 런타임에 어떤 모델이든 동적으로 선택할 수 있다. 각 스텝은 DB에 저장된 프롬프트 설정에서 modelId를 읽어오고, resolveProviderByModelId()로 적절한 프로바이더(OpenAI, Bedrock, Gemini)를 선택한 뒤 요청을 보낸다. 백오피스에서 modelId 값만 바꾸면 해당 스텝의 모델이 즉시 교체된다. 배포 없이 실시간으로 모델을 전환할 수 있다. 할루시네이션 대응 LLM을 프로덕션에서 쓸 때 가장 신경 쓰이는 부분이다. 두 가지 전략을 적용했다. CorrectedStep - STT 오류 보정 피드백 생성 전에 STT 인식 오류를 먼저 잡는다. Raw STT 결과를 LLM에 넣어 발음 유사 오타, 단어 누락 등을 보정한다. 이 단계를 거치면 이후 FeedbackStep이 정확한 텍스트를 기반으로 피드백을 생성하므로 할루시네이션이 크게 줄어든다. JSON repair + 포맷 검증 재시도 LLM이 깨진 JSON을 반환하는 경우가 종종 있다. 특히 응답이 길어질수록 콤마 누락, 닫히지 않은 괄호, markdown 코드블록 래핑 같은 문제가 빈번하게 발생한다. 이에 대해 2단계 복구 전략을 적용했다. Phase 1: repairJson - 구문 레벨 복구 파싱 실패 시 가장 먼저 repairJson으로 복구를 시도한다. 핵심은 누락된 콤마 자동 삽입이다. 문자열을 한 글자씩 순회하면서 inString(문자열 내부 여부), escape(이스케이프 여부), prevNonWhitespace(이전 의미 있는 문자)를 추적한다. 문자열 밖에서 ", {, [가 나타났는데 직전 토큰이 값의 끝(예: }, ], ", 숫자 등)이라면 콤마가 빠진 것으로 판단해 자동 삽입한다. 실제 호출 흐름은 이렇다: Phase 2: LLM 재호출 (MAX_RETRY = 3) repairJson으로도 복구가 안 되면, LLM을 다시 호출한다. 모든 스텝이 동일한 재시도 루프를 갖는다. json", "").replace(" 응답에서 markdown 코드블록 래핑을 벗겨내는 것도 이 단계에서 처리한다. LLM이 JSON을 코드블록으로 감싸 응답하는 경우가 잦기 때문이다. 유연한 필드명 매핑 LLM은 같은 프롬프트에도 corrected_texts, correctedTexts, corrected 등 다양한 키 이름으로 응답할 수 있다. 이를 대비해 파싱 시 여러 후보 키를 순서대로 탐색한다. 스키마가 미묘하게 달라도 파이프라인이 깨지지 않도록 방어하는 것이다. 최종적으로 모든 재시도가 실패하면 스텝별 실패 상태(FAILED_CHUNKING, FAILED_FEEDBACK 등)를 DB에 기록하고, Slack 알림으로 운영팀에 즉시 전파된다. 무중단 모델·프롬프트 변경 AI 서비스 운영에서 빠질 수 없는 요구사항이다. 모델을 바꾸거나 프롬프트를 튜닝할 때