[Dev-Fortune] 시리즈 다시보기


1. 서론: 조각난 퍼즐을 하나로 연결하는 엔드투엔드 여정

이전 포스팅들을 통해 로컬 LLM 연동(Ollama), 사주 데이터의 벡터화 및 색인(SimpleVectorStore), 시니컬한 시니어 개발자 페르소나 설계(Prompt Engineering), 그리고 리액티브 SSE 스트리밍 엔드포인트(WebFlux)까지 각각의 빌딩 블록을 완성했습니다.

하지만 단위 모듈이 개별적으로 동작하는 것과, 이들이 유기적으로 결합되어 실서비스 트래픽을 처리하는 것은 완전히 다른 차원의 문제입니다. 사용자가 브라우저에서 고민을 입력하고 엔터 키를 누르는 찰나의 순간, 서버 내부에서는 Spring Boot의 비동기 리액티브 파이프라인, 임베딩 모델의 벡터 유사도 검색, 프롬프트 템플릿 컴파일, 그리고 로컬 LLM의 토큰 생성 과정이 톱니바퀴처럼 맞물려 돌아갑니다.

이번 6편에서는 **사용자 요청부터 최종 스트리밍 응답 완료까지의 전체 데이터 라이프사이클과 아키텍처 워크플로우를 시퀀스 레벨에서 심층 분석(Deep-Dive)**하고, 각 구간별 병목 지점과 장애 격리 전략을 점검합니다.


2. 전체 워크플로우 시퀀스 다이어그램 (End-to-End Sequence)

사용자의 고민 입력 한 번이 실시간 스트리밍 답변으로 변환되는 7단계의 여정을 시각화한 시퀀스 다이어그램입니다.

sequenceDiagram autonumber actor User as 사용자 (Web Client) participant Controller as FortuneChatController participant Service as FortuneAIService participant VectorStore as SimpleVectorStore participant Embedding as OllamaEmbeddingModel participant LLM as OllamaChatModel (qwen2.5)
User->>Controller: POST /api/fortune/stream (고민 텍스트 JSON)
activate Controller
Controller->>Service: analyzeFortuneStream(userQuery)
activate Service

rect rgb(240, 245, 255)
    Note over Service,Embedding: 1단계: RAG 검색 및 지식 보강 (Retrieval)
    Service->>VectorStore: similaritySearch(userQuery, topK=3, threshold=0.65)
    activate VectorStore
    VectorStore->>Embedding: embedQuery(userQuery)
    Embedding-->>VectorStore: Query Vector (1536 float[])
    VectorStore->>VectorStore: 코사인 유사도 연산 및 상위 3건 필터링
    VectorStore-->>Service: List<Document> (사주 해석 규칙, 안티패턴)
    deactivate VectorStore
end

rect rgb(255, 245, 240)
    Note over Service,LLM: 2단계: 프롬프트 합성 및 스트리밍 추론 (Generation)
    Service->>Service: System 페르소나 + RAG Context + User 질의 합성
    Service->>LLM: chatClient.prompt().stream()
    activate LLM
    LLM-->>Service: Flux<ChatResponse> (단어 토큰 단위 청크)
end

Service-->>Controller: Flux<ServerSentEvent<String>>
deactivate Service
Controller-->>User: HTTP 200 (text/event-stream 연결 수립)

loop 실시간 토큰 방출 (Token Streaming)
    LLM-->>Service: Next Token Chunk
    Service-->>Controller: Formatted SSE Event
    Controller-->>User: data: {"token": "오늘의", "isLast": false}
end

LLM-->>Service: Generation Complete (FinishReason: STOP)
deactivate LLM
Controller-->>User: data: {"token": "", "isLast": true} [Stream Close]
deactivate Controller

3. 핵심 단계별 아키텍처 상세 분석

Step 1. 요청 수신 및 DTO 유효성 검증

사용자가 웹 인터페이스에서 입력한 고민 텍스트는 Spring WebFlux Controller로 유입됩니다. 이때 스레드 블로킹을 방지하기 위해 전통적인 HttpServletRequest 방식이 아닌 Netty 기반의 이벤트 루프가 요청을 논블로킹으로 수신합니다.

@PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<FortuneChunkResponse>> streamFortune(
        @Valid @RequestBody FortuneRequest request) {
    log.info("사주 분석 요청 수신 - Query Length: {}", request.content().length());
    return fortuneAIService.analyzeFortuneStream(request.content())
            .map(token -> ServerSentEvent.<FortuneChunkResponse>builder()
                    .event("fortune-token")
                    .data(new FortuneChunkResponse(token, false))
                    .build())
            .concatWith(Mono.just(ServerSentEvent.<FortuneChunkResponse>builder()
                    .event("fortune-done")
                    .data(new FortuneChunkResponse("", true))
                    .build()))
            .onErrorResume(this::handleStreamError);
}

Step 2. 질문 임베딩과 메모리 벡터 유사도 검색 (Retrieval)

사용자의 자연어 질의는 로컬 임베딩 모델을 거쳐 고차원 밀집 벡터(Dense Vector)로 변환됩니다. SimpleVectorStore는 메모리에 로드된 수백 개의 사주 운세 청크 벡터들과 코사인 유사도(Cosine Similarity)를 계산합니다.

public List<Document> retrieveRelevantSajuData(String userQuery) {
    SearchRequest searchRequest = SearchRequest.query(userQuery)
            .withTopK(3)
            .withSimilarityThreshold(0.65);
    
    List<Document> matchedDocuments = vectorStore.similaritySearch(searchRequest);
    
    // Fallback: 유사도 높은 문서가 없을 경우 기본 개발자 잠언 제공
    if (matchedDocuments.isEmpty()) {
        log.warn("일치하는 사주 데이터 없음. 기본 컨텍스트 적용");
        return List.of(new Document("일반 개발자 조언: 배포 전 단위 테스트를 반드시 실행할 것."));
    }
    return matchedDocuments;
}

Step 3. 동적 프롬프트 합성 (Prompt Orchestration)

Spring AI의 PromptTemplate과 SystemPromptTemplate을 결합하여 세 가지 요소를 하나의 통합 컨텍스트로 조립합니다:

  1. System Persona: 실리콘밸리 15년 차 시니컬한 시니어 아키텍트 페르소나 및 응답 포맷 제약 규칙.
  2. Context Data: Step 2에서 검색된 사주 안티패턴 및 IT 해석 메타데이터.
  3. User Query: 사용자가 입력한 현재 프로젝트 고민 또는 커리어 불안감.

Step 4. 로컬 Ollama 모델 스트리밍 추론 및 토큰 방출

조립된 최종 프롬프트는 HTTP Keep-Alive 연결을 통해 로컬 Ollama 데몬(Port 11434)으로 전송됩니다. Ollama 내부에서는 qwen2.5:3b 모델이 가중치를 순회하며 다음 확률 토큰을 하나씩 계산합니다.

이때 Spring AI의 ChatClient는 Flux<String> 형태로 각 토큰을 수신하자마자 다운스트림으로 방출하여 사용자가 첫 글자를 보기까지의 대기 시간(Time-to-First-Token, TTFT)을 300ms 이내로 단축시킵니다.


4. 구간별 지연 시간(Latency) 프로파일링

실무 환경에서 RAG 스트리밍 챗봇의 성능을 측정했을 때의 구간별 소요 시간 분석입니다.

단계 처리 주체 평균 소요 시간 비고
1. HTTP 핸드셰이크 & 역직렬화 Spring WebFlux (Netty) 2 ~ 5ms 완전 비동기 논블로킹
2. 사용자 질의 임베딩 생성 Ollama (bge-m3 등) 40 ~ 80ms 텍스트 길이에 비례
3. Vector 검색 (코사인 유사도) SimpleVectorStore 5 ~ 15ms 인메모리 수천 건 탐색
4. 프롬프트 템플릿 컴파일 Java CPU Thread 1 ~ 3ms 단순 문자열 조합
5. 첫 토큰 방출 (TTFT) Ollama (qwen2.5:3b) 250 ~ 450ms 모델 로드 상태 기준
6. 전체 문장 스트리밍 완료 Netty SSE Flush 1,500 ~ 2,500ms 200~300자 생성 기준

[!TIP] 성능 튜닝의 핵심은 2단계와 5단계입니다. SimpleVectorStore 자체의 검색 시간은 15ms 미만으로 극히 미미합니다. 응답 속도의 90% 이상은 로컬 LLM의 연산 능력(GPU 가속 여부, 양자화 비트 수)에 달려 있습니다.


5. 실무 운영 시 발생 가능한 장애 시나리오 및 대응

1) 유사도 검색 결과가 부실한 경우 (Low Similarity)

사용자가 “점심 메뉴 추천해줘"처럼 사주와 전혀 무관한 질문을 던졌을 때, 낮은 임계치의 데이터가 억지로 주입되면 할루시네이션(Hallucination)이 발생합니다.

  • 해결책: SimilarityThreshold를 0.65 이상으로 엄격히 제한하고, 검색 결과가 비어있을 때는 “사주 분석 범위 밖의 질문"이라는 고정 안내 문구를 내보내는 가드레일 로직을 구현합니다.

2) 클라이언트의 조기 연결 종료 (Client Abort)

사용자가 답변을 다 읽기 전에 브라우저 탭을 닫으면, 불필요하게 LLM 연산이 계속 돌아 GPU/CPU 자원을 낭비할 수 있습니다.

  • 해결책: WebFlux의 doOnCancel() 훅을 등록하여 SSE 구독 취소 시 백엔드 파이프라인과 Ollama 요청을 즉시 인터럽트(Abort) 처리합니다.
return chatClient.prompt(prompt)
        .stream()
        .content()
        .doOnCancel(() -> log.info("클라이언트 연결 종료 감지. AI 추론 스트림 중단"))
        .doOnError(err -> log.error("스트리밍 오류 발생: {}", err.getMessage()));

6. 요약: 아키텍처 체크리스트

[클라이언트 질문]
       │ (JSON POST)
       ▼
[WebFlux Controller] (Netty 비차단 수신)
       │
       ▼
[FortuneAIService]
       ├─ VectorStore 검색 (임베딩 변환 + 코사인 유사도)
       ├─ 프롬프트 합성 (Persona System + RAG Docs + User Query)
       └─ Ollama 추론 요청 (Non-blocking HTTP Stream)
       │
       ▼
[Server-Sent Events] (토큰 단위 즉각 Flush)
       │
       ▼
[사용자 브라우저] (실시간 타이핑 애니메이션 렌더링)
  1. 데이터 분리: 사주 지식 데이터는 외부 JSON 파일로 관리하여 코드 수정 없이 언제든 지식베이스를 갱신할 수 있습니다.
  2. 지능 합성: 단순 검색 결과만 던지는 것이 아니라, 프롬프트 엔지니어링을 통해 완벽한 개발자 페르소나 옷을 입혔습니다.
  3. 반응형 UX: WebFlux SSE 파이프라인으로 체감 지연 시간을 극소화했습니다.

마지막 7편에서는 프로젝트를 완주하며 느낀 로컬 인메모리 LLM의 기술적 한계와, 프로덕션 환경으로 가기 위한 고도화 로드맵을 정리하겠습니다.