회사 리뷰 플랫폼을 만들면서 자연스럽게 이런 기능을 떠올렸다.
이 회사 복지 어때요?
수백 개의 리뷰를 직접 읽는게 아니라, AI가 리뷰를 분석해서 답해주면 어떨까. 단순해 보이지만 구현하면서 꽤 많은 결정들을 해야 했다.
사용자의 리뷰 등록, 임베딩, VectorDB 저장을 하나의 트랜잭션으로 묶었다.
두 가지 문제가 발생했다.
응답이 느리다. Ollama(로컬 LLM)는 CPU bound 작업이라 임베딩 요청 한 번에 수 초가 걸린다. 리뷰를 남기는 사용자가 그 시간을 기다려야 했다.
장애가 전파된다. Ollama가 죽어있으면 트랜잭션의 원자성으로 인해 리뷰 등록 자체가 실패한다. 임베딩 인덱싱은 검색 기능을 위한 부가 작업인데, 핵심 기능인 리뷰 등록을 막는 구조다.
Kafka를 도입하여 비동기 이벤트로 처리하였고, 핵심 비즈니스 로직과 부가 처리 과정을 분리하였다.
Kafka에 즉시 발행하지 않고 Outbox를 쓰는 이유는 리뷰 저장과 이벤트 발행 의도를 하나의 DB 트랜잭션으로 묶기 위함이다. Kafka가 리뷰 생성 시점에 죽어있어도 이벤트는 DB에 보존되고, 복구 후 스케줄러가 재발행한다.
이제 외부 서비스인 Ollama 서버에 문제가 발생해도 리뷰 저장은 독립적이기에 성공할 것이다.
outbox에 저장된 이벤트들을 긁어올때는 polling 방식인 스케줄러를 사용하였다.
OutboxPublishScheduler가 5초마다 PENDING 이벤트를 Kafka로 발행한다.
현재 스케줄러는 발행 결과를 확인하지 않고 즉시
PUBLISHED로 마킹한다. 스케줄러 실행 중 Kafka 장애가 발생하면 이벤트는 여전히 유실될 수 있다. 이 문제는 이후 개선 예정이다.
이후 rag-service에서 컨슈머로 이벤트를 처리한다.
ACKS_CONFIG=allENABLE_IDEMPOTENCE_CONFIG=true
ENABLE_IDEMPOTENCE_CONFIG=true내부 동작 알고리즘
Broker는 Producer가 처음 연결될 때 PID(Producer ID)를 발급하고, 메시지마다 (PID, 파티션, Sequence Number) 세 가지를 붙여서 보낸다. Broker는 이 조합으로 중복을 감지하고, 같은 시퀀스가 오면 기록 없이 성공 응답만 돌려준다.
Kafka의 오토커밋은 처리 성공 여부와 무관하게 5초마다 타이머로 오프셋을 커밋한다.
먼저 오토커밋이 true일 때 어떤 문제가 생기는지 생각해보자.
Consumer가 이벤트를 수신하고 Ollama에 임베딩 요청을 보냈는데 실패했다고 가정하자.

처리가 실패했는데 오프셋은 이미 커밋이 되었고, Kafka 입장에서는 이 이벤트를 처리한것이다.
해당 리뷰는 영구적으로 임베딩 인덱싱에서 누락 될 것이다.
ENABLE_AUTO_COMMIT_CONFIG=falsefalse 설정 시 커밋 책임이 Kafka -> Spring Kafka로 넘어오고, 기본 Ackmode.BATCH에 의해 poll() 배치 처리 완료 후 오프셋을 커밋한다. Ackmode.BATCH가 배치 단위로 커밋하는 이유는 Kafka 오프셋의 구조적 특성 때문이다. 오프셋은 '여기까지 처리한다'는 위치값이고, 개별 메시지마다 성공/실패를 표시하는 구조가 아니다.
만약 리뷰 1~5번 중 3번이 실패했다면, 3번만 빼고 커밋할 방법이 없다. 오프셋 5를 커밋하면 1~5 전체를 처리했다는 의미이기 때문이다. 구조적으로 배치 전체가 성공해야 마지막 오프셋을 커밋할 수 있다.
따라서 중간에 하나라도 실패하면 커밋하지 않고, 다음 poll()에서 그 배치를 처음부터 다시 받는다.
재처리가 보장되는 구조는 새로운 문제를 야기할 수 있다.
위의 예시처럼 리뷰 1~5번 중 3번을 제외하고는 이미 성공했지만 poll()을 진행할 때 다시 처리될것이다. 그대로 처리하면 동일한 리뷰가 ES에 중복 인덱싱 된다.
Producer 멱등성 문제도 있다. ENABLE_IDEMPOTENCE_CONFIG는 세션 내 재전송만 막는다. Producer가 Kafka 발행에 성공했지만 DB에 PUBLISHED 업데이트 전에 서비스가 죽으면, 재시작 후 새 PID로 같은 이벤트를 다시 발행한다. Broker는 PID가 달라서 중복임을 알지 못한다.
두 경우 모두 Consumer 측에서 처리해야 한다. existsByReviewId()로 이미 인덱싱된 reviewId면 처리를 스킵한다.
ES document ID를 review-{reviewId}로 고정했기 때문에 같은 reviewId가 두 번 들어와도 중복 여부를 정확하게 판별할 수 있다.
이제 리뷰가 등록되면 비동기로 임베딩되어 VectorDB에 저장된다.
사용자가 "이 회사 복지 어때요?"라고 질문하면, 질문을 임베딩해 유사한 리뷰를 검색하고 LLM 프롬프트에 주입해 답변을 생성한다.
RAG 내부 구축 과정(임베딩, kNN 검색, 프롬프트 설계)은 별도 포스팅에서 다룬다.
문제는 답변을 어떻게 전달하느냐다.
LLM이 전체 답변을 생성하고 나서 한꺼번에 내려보내면 사용자는 수십 초를 빈 화면만 보게 된다.
생성형 AI처럼 단어 단위로 타이핑되는 것처럼 보이게 구현하기 위해 SSE 스트리밍을 사용하였다.
WebSocket도 고려했지만, LLM 응답은 서버 → 클라이언트 단방향이다. WebSocket은 양쪽이 계속 메시지를 주고받는 채팅 같은 구조에 적합하다. 단방향 스트리밍은 HTTP 위에서 동작하는 SSE가 더 단순하다.
Ollama는 스트리밍 모드에서 JSON을 한 줄씩 내려보낸다.
이걸 한 줄씩 읽어서 클라이언트로 즉시 전송한다.
retrieve() 대신 exchange()를 쓰는 이유가 있다. retrieve()는 응답 전체를 버퍼에 담은 뒤 반환한다. Ollama가 응답을 전부 생성할 때까지 기다려야 하므로 스트리밍이 불가능하다. exchange()는 응답 본문을 InputStream으로 직접 열어준다.
Ollama는 JSON을 줄 단위로 flush하므로 readLine()으로 자연스럽게 각 청크가 분리된다.
청크 하나를 읽을 때마다 emitter.send()로 클라이언트에 즉시 내려보낸다.
done: true를 수신하면 done 이벤트를 전송하고 연결을 닫는다. 클라이언트는 이 이벤트를 받아 EventSource를 종료한다.
Tomcat 스레드는 기본 200개다. Ollama 응답이 평균 10초라면, 동시 요청이 200개만 들어와도 Tomcat 스레드 전부가 Ollama 대기에 묶인다. 그 이후 들어오는 모든 요청이 블로킹된다.
그럼 Tomcat 스레드를 늘리면 되지 않을까? 플랫폼 스레드는 OS 스레드와 1:1로 매핑되고 OS 스레드는 생성 비용이 크고 스택 메모리만 기본 1MB를 점유한다고 알고있다.
따라서 스레드를 늘릴수록 컨텍스트 스위칭 비용도 선형적으로 늘어날것이다. 단순히 Tomcat 스레드를 늘리는것은 해결책이 아니라고 생각한다.
Tomcat 스레드는 sseExecutor.execute()를 호출하고 emitter만 반환하면 즉시 해방된다. Ollama 응답 대기는 온전히 sseExecutor가 감당한다.
sseExecutor는 Java 21에서 등장한 Virtual Thread를 사용하였다.
Virtual Thread는 JVM이 관리하는 경량 스레드다. 플랫폼 스레드와 달리 OS 스레드와 1:1로 매핑되지 않는다. 여러 Virtual Thread가 소수의 OS 스레드 위에서 실행된다.
핵심은 I/O 대기 시 동작이다. Virtual Thread가 Ollama 응답을 기다리는 순간, JVM이 자동으로 해당 OS 스레드를 다른 Virtual Thread에게 양보한다.
Ollama 응답이 도착하면 다시 재개된다. OS 스레드를 점유한 채 블로킹하지 않는것이 핵심이다.
스택 크기도 수 KB 수준이라 수천 개를 동시에 만들어도 메모리 부담이 없다.
단, Virtual Thread는 I/O 바운드 작업에서만 효과적이다. 임베딩처럼 CPU 연산이 주인 작업은 I/O 대기가 없으므로 OS 스레드를 반납할 시점 자체가 없다. Ollama 응답 대기처럼 외부 I/O를 기다리는 구조에서 효과가 극대화 된다.
가장 기억에 남는 기술적인 부분은 두 가지이다.
1. Kafka 재처리 및 멱등성 처리
2. 가상 스레드 사용
블로그 초반 서술했던 트랜잭션 아웃박스 패턴은 많이 접해봤고 여럿 기술 블로그에서 자주 접했던것이라 어렵지 않았다. 사실 프로덕션 환경에서는 더욱 응용될 수 있겠지만 아직 경험해보지 못해서 이부분까지는 잘 모르겠다 (토스 컨퍼런스를 보던 중 아웃박스 패턴을 사용함에도 특별한 설계 과정으로 인해 정합성이 깨지는것을 감수했다는 내용을 본 적이 있는데 다시 확인해봐야겠다)
Kafka 브로커 설정시 리드 브로커 장애시 어떻게 대처해야하는가, 오토 커밋 수동으로 설정하기 등은 몇번 경험해봤지만 상세하게 멱등성을 처리하는것은 낯설었다. 프로듀서에서 멱등성을 옵션을 켠다면 완전히 멱등하게 보장할 수 있다고 생각했었지만 '같은 세션 내'만 한정되어 서버가 재부팅 될 시에는 이를 보장할 방법이 없다는것을 알게되었다.
프로듀서측에서 멱등성을 보장하기 위해 Kafka 트랜잭션을 사용하는 방법이 있다고하지만 프로듀서와 컨슈머 측에서 다양한 설정들과 옵션이 필요하다고 해서 우선 복잡도를 낮추기 위해 Consumer 측에서 코드로 멱등성을 보장하는 방향을 선택했다.
가상 스레드는 Java 버전별 어떤 기능이 등장했는지 학습하다가 알게된 기술이다. OS에 종속된 플랫폼 스레드와 비교하여 경량화된 스레드라는 정도만 이론으로 알고있었고 직접 프로젝트를 하면서 사용해본것은 이번이 처음이다. I/O 대기 시 가상 스레드가 플랫폼 스레드를 반납하고, 그 스레드가 다른 가상 스레드에 재할당되어 병목을 최소화할 수 있다는 것을 배웠다.