MMax's Develop Story

AI 상담사를 만들며 다시 배운 비동기 아키텍처: Spring AI · WebFlux 회고

Max··22분 읽기
Spring AIWebFluxRAGAIReactor회고

WebFlux와 Spring AI 위에 RAG 기반 AI 상담사를 올렸다. 가장 큰 문제는 LLM이 아니라 스레드 모델이었다. 이벤트 루프 위에서 Blocking을 돌리면 어떤 비동기 프레임워크도 사람을 구해주지 못한다는 사실을, 또 한 번 깨달았다.

상담 자동화 도메인은 트래픽 패턴이 묘하다. 80%의 질의는 정형(약관·정책·자주 묻는 질문)이고, 20%가 비정형인데, 후자가 응답 시간과 비용을 모두 잡아먹는다. 단순히 OpenAI API를 호출해 답을 돌려주는 토이 구현으로는 SLA를 맞출 수 없다. 이번 프로젝트는 그 비대칭을 아키텍처 레벨에서 해결하려는 시도였다.

1. 목표: "AI를 붙인다"가 아니라 "AI 워크로드를 견딘다"

처음 합의한 목표는 단순했다.

  • 정형 질문은 사람이 큐레이션한 답변을 그대로 돌려줄 것 (LLM 호출 없이)
  • 비정형 질문은 RAG로 사내 지식을 끌어와 답변할 것
  • 그리고 위 두 트랙이 같은 API 표면 뒤에 가려져야 할 것

말은 쉽지만, 두 트랙은 응답 특성이 정반대다. Fast Track은 P99가 수십 ms, Smart Track은 평균 5초 이상. 같은 컨테이너에서 두 트래픽을 받으면 느린 쪽이 빠른 쪽을 끌어내린다. 이 비대칭을 어떻게 안전하게 격리할 것인가가 진짜 과제였다.

하이브리드 라우팅 아키텍처

2. 기술 선택: WebFlux를 고른 이유, 그리고 Spring AI

핵심 기준은 단 하나였다.

"비동기 처리 기반에서 AI 워크로드를 안정적으로 수용할 수 있는가?"

  • Core: Spring WebFlux / Netty: LLM 응답이 초 단위인 만큼, MVC의 1 요청 1 스레드 모델은 동시성 한계가 빠르게 온다. 적은 스레드로 많은 in-flight 요청을 들고 있어야 했다.
  • AI: Spring AI: ChatClient, Advisor, VectorStore 같은 추상화가 이미 잘 정돈되어 있다. 직접 OpenAI SDK를 두드릴 수도 있지만, 프롬프트·임베딩·리트리버를 갈아 끼우는 비용을 낮추는 게 훨씬 가치 있다고 봤다.
  • Redis: 세션·대화 히스토리 캐시
  • Spring Data JPA: 도메인 데이터(상담 로그, 메타) 접근

시니어 입장에서 한 가지 강조하고 싶은 건, "새 프레임워크를 쓰니까 좋아 보인다"는 가장 위험한 선택 근거라는 점이다. WebFlux는 매력적인 도구지만, 팀이 Reactor의 동작과 스레드 모델을 충분히 이해하지 못한 채 도입하면 디버깅 비용으로 그 이득을 모두 토해낸다. 이번 프로젝트도 그 함정을 정면으로 밟았다.

3. 구현: Advisor 체이닝으로 만든 "조립 가능한" RAG

Spring AI에서 가장 매력적인 추상화는 Advisor 체이닝이다. 요청-응답 파이프라인의 횡단 관심사를 잘라낸 미들웨어 모델에 가깝다.

ChatClient chatClient = ChatClient.builder(chatModel)
    .defaultAdvisors(
        new QuestionAnswerAdvisor(primaryVectorStore),   // 사내 정책 KB
        new QuestionAnswerAdvisor(faqVectorStore),       // 큐레이션된 FAQ
        new MessageChatMemoryAdvisor(chatMemory),        // 대화 컨텍스트 주입
        new SimpleLoggerAdvisor()                        // 관측
    )
    .build();

여기서 핵심은 두 가지다.

  1. 여러 Retriever를 앙상블해서 도메인 지식의 커버리지를 넓혔다. 단일 임베딩 인덱스만 두면 KB가 커질수록 noise가 늘고 답변 품질이 떨어진다. 정책 KB와 FAQ KB를 분리하고, 각 Retriever의 top-k와 score threshold를 따로 튜닝한 게 hit rate를 의미 있게 끌어올렸다.
  2. 체인은 코드 변경 없이 교체 가능해야 한다. 운영 중 "이 도메인은 BM25 결합이 더 낫다"는 결정이 내려졌을 때, 새로운 Advisor 하나만 끼워 넣고 A/B를 돌렸다. 변경의 단위가 명확하다는 건 운영 시스템에서 굉장히 큰 자산이다.

Fast Track은 분기 로직에서 LLM을 건드리지도 않는다. 라우터가 매칭 신뢰도 임계값을 넘으면 사전 정의된 응답을 그대로 반환한다. 이게 전체 비용의 절반 이상을 잘라냈다.

4. 첫 번째 함정: "WebFlux를 쓰면 알아서 논블로킹이 된다"는 착각

배포 후 며칠은 평화로웠다. 그러다 평일 오후, 트래픽이 평소의 2배 정도로 늘었을 뿐인데 서버가 전체 응답 불가 상태에 빠졌다. 헬스체크도 죽었다.

원인은 익숙한 그것이었다. 이벤트 루프 위에서 Blocking I/O를 돌리고 있었다.

// ❌ Anti-pattern: 이벤트 루프 스레드에서 JPA 호출
@GetMapping("/api/counselor/history")
public Mono<History> history(String userId) {
    History h = historyRepository.findByUserId(userId); // Blocking JDBC
    return Mono.just(h);
}

JDBC는 본질적으로 Blocking이다. 위 코드의 findByUserId는 호출되는 그 스레드를 그대로 점유한다. WebFlux의 Netty 이벤트 루프 스레드는 보통 CPU 코어 수만큼(예: 8개)밖에 없다. 그중 몇 개가 DB I/O를 기다리느라 멈춰 있으면, 새로 들어오는 요청은 큐에서 굶기 시작하고, 결국 시스템 전체가 정지한다.

이벤트 루프 차단과 boundedElastic 격리

문제의 본질은 코드 한 줄이 아니라 멘탈 모델이었다. WebFlux는 "자동으로 논블로킹화" 해주는 마법이 아니다. 개발자가 명시적으로 Blocking 경계를 옮겨주지 않으면, 프레임워크는 그저 빠르게 망가질 수 있는 도구가 될 뿐이다.

처방은 단순했다.

// ✅ Blocking 작업은 boundedElastic으로 격리
public Mono<History> history(String userId) {
    return Mono.fromCallable(() -> historyRepository.findByUserId(userId))
        .subscribeOn(Schedulers.boundedElastic());
}
  • 이벤트 루프: 오직 I/O 라우팅만 담당
  • boundedElastic: JDBC, JPA 등 Blocking 작업 전담 (기본 한도 10 × CPU)
  • parallel: CPU-bound 연산

이 분리를 코드 레벨에서 강제하기 위해, 우리는 BlockHound를 dev/staging 환경에 상시 켜두는 컨벤션을 도입했다. 이벤트 루프에서 Blocking이 한 번이라도 발생하면 즉시 예외로 떨어뜨려 PR 단계에서 잡아낸다. "사람의 주의력"에 의존하지 않는 안전장치는, 이런 류의 함정에서 늘 가장 효과적이다.

여기까지가 응급 처방이었다. JDBC/JPA 호출은 여전히 Blocking이고, 우리는 그저 그것이 도는 자리를 옮겼을 뿐이다. 격리는 시스템을 살려놨지만 비용을 없애주진 않는다: 모든 DB 호출이 스레드 컨텍스트 스위치를 한 번씩 더 타게 되고, boundedElastic의 큐가 곧 새로운 병목이 된다. 진짜 해결은 한 단계 아래, 데이터 액세스 레이어 자체를 reactive로 바꾸는 일이었다.

5. 진짜 해결: JPA/JDBC를 걷어내고 R2DBC로 전환

응급 처방 이후 두 번째 스프린트에서, 우리는 spring-boot-starter-data-jpa를 통째로 제거하고 R2DBC로 데이터 액세스 레이어를 갈아엎었다. Redis도 Reactive 클라이언트로 옮겼다.

// build.gradle
- implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
- implementation 'org.springframework.boot:spring-boot-starter-data-redis'
- implementation "p6spy:p6spy:${p6spyVersion}"
- runtimeOnly  'com.mysql:mysql-connector-j'
- testRuntimeOnly 'com.h2database:h2'
- testImplementation "com.epages:restdocs-api-spec-mockmvc:${oasVersion}"
 
+ implementation 'org.springframework.boot:spring-boot-starter-data-r2dbc'
+ implementation 'org.springframework.boot:spring-boot-starter-data-redis-reactive'
+ runtimeOnly  'io.asyncer:r2dbc-mysql'
+ runtimeOnly  'org.postgresql:r2dbc-postgresql'
+ runtimeOnly  'io.r2dbc:r2dbc-pool'
+ testRuntimeOnly 'io.r2dbc:r2dbc-h2'
+ testImplementation "com.epages:restdocs-api-spec-webtestclient:${oasVersion}"

연결 풀과 timeout도 reactive 환경에 맞춰 명시했다. R2DBC pool은 HikariCP와 의미는 비슷하지만 연결 획득/생성 자체에 timeout을 거는 사고방식이 더 자연스럽다.

spring:
  r2dbc:
    url: r2dbc:postgresql://${PG_DB_HOST}:${PG_DB_PORT}/aiagent
    username: ${PG_DB_USERNAME}
    password: ${PG_DB_PASSWORD}
    properties:
      sslMode: require
    pool:
      initial-size: 3
      max-size: 30
      max-idle-time: 10s
      max-acquire-time: 10s          # 연결 획득 대기 한도
      max-create-connection-time: 10s # 신규 연결 생성 한도
      connection-init-sql: set timezone = 'Asia/Seoul'

Adapter 레이어의 시그니처도 따라 바뀐다. 가장 큰 변화는 List<T>를 반환하던 포트가 모두 Flux<T> / Mono<T>로 통일된다는 점이다. 즉, "DB에서 N건을 한 번에 모아 메모리에 올린다"는 사고가 사라지고, 요청 한 건의 수명 동안 데이터가 흘러가는 파이프라인이 된다.

// Before (JPA · Blocking)
public interface ChatHistoryLogQueryPort {
    List<ChatHistoryLog> getChatHistory(Long customerId);
}
 
@RequiredArgsConstructor
@Component
public class ChatHistoryLogQueryAdapter implements ChatHistoryLogQueryPort {
    private final ChatHistoryLogJpaRepository repository;
 
    @Override
    public List<ChatHistoryLog> getChatHistory(Long customerId) {
        return repository.findHistoryByCustomerId(customerId, historyLimit).stream()
                .map(this::toDomain)
                .toList();
    }
}
// After (R2DBC · Reactive)
public interface ChatHistoryLogQueryPort {
    Flux<ChatHistoryLog> getChatHistory(Long customerId);
    Mono<ChatHistoryLog> saveChatHistory(ChatHistoryLog log);
}
 
@RequiredArgsConstructor
@Component
public class ChatHistoryLogQueryAdapter implements ChatHistoryLogQueryPort {
    private final ChatHistoryLogRepository repository;  // R2dbcRepository
 
    @Override
    public Flux<ChatHistoryLog> getChatHistory(Long customerId) {
        if (customerId == null) return Flux.empty();
        return repository.findHistoryByCustomerId(Math.toIntExact(customerId), historyLimit)
                .map(this::toDomain);
    }
 
    @Override
    public Mono<ChatHistoryLog> saveChatHistory(ChatHistoryLog log) {
        return repository.save(toEntity(log)).map(this::toDomain);
    }
}

부수적으로 정리된 것들도 적지 않다.

  • Projection 폐기: JPA에서 N+1을 피하려고 잡고 있던 인터페이스 Projection이 사라지고, R2DBC @Table에 매핑된 record 엔티티가 그 자리를 차지했다. 도메인 변환 로직이 훨씬 짧고 명료해졌다.
  • p6spy 제거: SQL 로깅용 도구지만 본질적으로 JDBC 프록시라 R2DBC와 함께 살 수 없다. 대신 reactor.netty.http.client.HttpClient / org.springframework.data.redis 로깅 레벨을 손봐서 reactive 스택에 맞는 관측 포인트를 다시 잡았다.
  • 테스트 인프라 교체: mockmvcwebtestclient, H2도 r2dbc-h2로 교체. WebFlux 라우트는 WebTestClient로 검증하는 게 가장 호환성이 좋다.

이 전환 이후 boundedElastic의 점유율이 눈에 띄게 떨어졌다. 의미가 분명하다: DB I/O가 더 이상 별도 스레드 풀을 빌리지 않고, 이벤트 루프 위에서 자기 일을 하는 콜백 체인으로 자연스럽게 흘러가게 됐다는 뜻이다.

한 가지 솔직히 적어둘 것: R2DBC는 만능이 아니다. 선언적 트랜잭션 경계가 JPA만큼 매끄럽지 않고, 영속성 컨텍스트(1차 캐시 / dirty checking)가 없어서 도메인 모델링 스타일을 바꿔야 한다. 우리 도메인은 "한 트랜잭션 안에서 엔티티 그래프를 끌고 다니는" 패턴이 적었기에 이득이 컸지만, 무거운 트랜잭션 도메인이라면 R2DBC 전환 자체가 비용이 될 수 있다. 도구를 바꾸기 전에 도메인의 데이터 액세스 패턴을 먼저 보는 게 순서다.

6. 두 번째 함정: Read Timeout, 그리고 연쇄 장애

스레드 모델을 정리하고 나니, 다음 적은 외부 LLM의 응답 지연이었다.

OpenAI API의 P99는 우리가 통제할 수 있는 값이 아니다. 어느 날 LLM이 30초씩 토해내는 상황이 오면, 우리 서버의 호출 스레드는 그동안 그 응답을 기다리며 점유된다. 동시 호출이 늘면, 외부 의존성의 지연이 우리 서비스의 가용성으로 그대로 옮겨붙는다. 전형적인 연쇄 장애(Cascade Failure) 패턴이다.

여기서 흔히 빠지는 잘못된 처방은 "타임아웃을 더 길게 늘리는 것"이다. 정반대다. 타임아웃은 짧고, 명시적이고, 계층별로 다르게 잡아야 한다.

WebClient.builder()
    .clientConnector(new ReactorClientHttpConnector(
        HttpClient.create()
            .responseTimeout(Duration.ofSeconds(15))     // L4
            .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 2000)
    ))
    .build();
 
// 호출부에서도 한 번 더, 의미가 있는 단위로
chatClient.prompt(prompt)
    .stream()
    .content()
    .timeout(Duration.ofSeconds(20))                     // L7 비즈니스 SLA
    .retryWhen(Retry.backoff(2, Duration.ofMillis(500)).filter(this::isRetryable))
    .onErrorResume(this::fallbackTemplate);

핵심은 타임아웃이 비즈니스 의사결정이라는 점이다. "20초 안에 답하지 못하면 LLM 답변을 포기하고 사전 정의된 안내문으로 fallback한다"는 것은 SRE 결정이 아니라 상품 결정이다. 이 결정을 코드 어딘가에 묻어두지 말고, fallback 정책을 명시적인 한 함수로 빼두는 것이 운영 가능한 시스템을 만든다.

7. 진짜 처방: Responsiveness와 Completion을 분리하기

타임아웃을 아무리 잘 잡아도, 사용자가 5~30초씩 기다리는 동기 요청이라는 모델 자체에 한계가 있다. 모바일 환경에선 네트워크 사정으로 연결이 끊기기도 하고, 사용자는 그 사이에 다른 화면으로 이동하기도 한다.

그래서 우리는 한 발 더 들어가 요청 모델 자체를 비동기로 바꿨다.

ACK + 백그라운드 큐 패턴

  • API는 즉시 202 Accepted + jobId를 반환한다. (=Responsiveness 보장)
  • 실제 추론은 메시지 큐를 거쳐 AI Worker가 백그라운드에서 수행한다. (=Completion 분리)
  • 결과는 SSE / Webhook / jobId Polling 중 클라이언트가 선호하는 채널로 전달한다.

이 모델로 옮긴 뒤 얻은 것은 단순한 응답 속도가 아니다.

  • 백프레셔(Backpressure) 가시화: 큐 길이가 시스템의 부하 지표가 되어, 워커만 수평 확장하면 LLM 비용/스레드/DB 부하를 독립적으로 다룰 수 있다.
  • 장애 격리: LLM이 죽거나 느려져도 API 게이트웨이는 멀쩡하다. 큐가 쌓일 뿐이다.
  • 재시도의 단위가 명확해진다: 작업 단위(jobId)가 명시적이라, "이 jobId만 재시도"가 가능해진다. 동기 모델에서는 "사용자가 새로고침했는지" 알 수가 없다.

비동기 API는 단순한 성능 최적화 기법이 아니라, 시스템의 인과(causality)를 클라이언트로부터 분리하는 설계다. 응답 시간이 긴 도메인에서는 결국 여기로 수렴한다.

8. 시니어의 시선으로 정리하는 인사이트

회고를 마무리하며 팀과 합의한 다섯 가지가 있다.

① WebFlux는 만능이 아니다: 스레드 모델은 프레임워크가 아니라 개발자가 안다

비동기 프레임워크일수록 추상화 아래의 실체(이벤트 루프, 스레드 풀, 백프레셔)를 더 정확히 알아야 한다. WebFlux를 도입할 때 가장 먼저 팀에 정착시켜야 하는 것은 reactive 코드 스타일이 아니라 "이 코드가 어느 스레드에서 도는가"를 모두가 답할 수 있는 문화다.

② AI 워크로드는 I/O와 CPU가 섞인다: 설계로 분리하라

LLM 호출은 I/O지만, 임베딩 정규화, 토큰 카운팅, 결과 후처리는 CPU-bound다. 이걸 한 스케줄러에 섞어 돌리면, 두 종류의 부하가 서로를 잡아먹는다. "이 작업은 어느 Scheduler에서 돌 것인가" 를 코드에 적어두는 것이 후일의 자기 자신을 구한다.

③ Spring AI의 가치: 다만, 추상화를 신뢰하기 전에 그 안을 한 번은 들여다보라

Spring AI는 분명 생산성을 끌어올린다. 그러나 QuestionAnswerAdvisor가 내부에서 어떤 형태로 컨텍스트를 주입하는지, ChatMemory가 토큰을 어디서 자르는지, 한 번도 들여다보지 않은 채 운영에 올리면 디버깅 비용이 폭발한다. 추상화를 쓰는 것과 추상화에 종속되는 것은 다르다.

④ 격리(boundedElastic)는 응급조치, 근본 해결은 레이어 자체를 바꾸는 것

JDBC를 boundedElastic으로 옮기는 것은 시스템을 살리는 응급조치지, 비동기 시스템의 본래 모습이 아니다. 데이터 액세스 레이어 전체가 reactive로 일관되면, 스레드 컨텍스트 스위치와 추가 큐가 사라지고 백프레셔가 진짜로 동작한다. 단, R2DBC는 JPA의 영속성 컨텍스트를 포기하는 결정이기도 하다. 우리처럼 "한 요청 = 짧고 명확한 데이터 흐름" 도메인에서는 큰 이득이지만, 그렇지 않은 도메인이라면 신중해야 한다.

⑤ 시스템의 균형은 "응답"과 "완료"를 분리하는 것에서 시작된다

긴 작업을 가진 시스템의 본질적인 설계 결정은 "우리는 사용자에게 무엇을 보장할 것인가" 다. 언제 답한다(Responsiveness)와 언제 끝낸다(Completion)를 분리해서 정의해야 한다. 이 두 가지를 같은 timeout 한 줄로 묶어버리는 순간, 시스템은 외부 의존성의 인질이 된다.

마무리: 결국 남는 것은 "아키텍처적 사고"

이번 프로젝트에서 가장 비싸게 배운 교훈을 한 문장으로 줄이면 이렇다.

새 기술의 학습 곡선보다, 기존 멘탈 모델을 다시 검증하는 비용이 항상 더 크다.

WebFlux도, Spring AI도, RAG도, 결국은 도구다. 도구는 언제 어떤 트레이드오프를 받아들이고 있는가를 명시적으로 적어둘 때만 자산이 된다. 다음 프로젝트에선 첫 스프린트에서 다음 두 문서를 먼저 만들기로 했다.

  1. Thread Model 문서: 우리 시스템의 스레드 풀과, 각 풀에 들어가도 되는 작업/들어가면 안 되는 작업
  2. Failure Scenario 문서: 외부 의존성이 죽거나 느려졌을 때, 어떤 SLA로, 어떤 응답으로 fallback할 것인가

이 두 가지가 코드보다 먼저 합의되었다면, 우리는 절반의 야근을 면했을 것이다. 다음에 만나는 비동기 시스템에서는 반드시 이걸 먼저 쓰자고, 스스로에게 적어둔다.