블로그로 돌아가기

AI 연애 시뮬레이션의 대화 엔진 설계: 멀티모델 파이프라인, SSE 스트리밍, pgvector RAG

NestJSNext.jsClaude APISSEpgvectorRAGPrisma

서비스 개요

AI 캐릭터들과 함께하는 연애 리얼리티 시뮬레이션 서비스다. 플레이어가 시나리오를 선택해 그 시나리오 안의 캐릭터들과 대화를 나누고, 각 캐릭터의 호감도가 AI 판단에 따라 실시간으로 변동하며, 스토리가 분기하는 구조다.

이 프로젝트에서는 인터랙티브 로맨스 시뮬레이션의 시나리오 구조를 기반으로, 백엔드(NestJS)와 프론트엔드(Next.js)를 처음부터 설계했다. 멀티모델 AI 파이프라인, 실시간 스트리밍, 시맨틱 검색 기반 장기 기억 등 핵심 엔진의 설계와 구현 과정을 다룬다.


아키텍처 개요

모노레포 구조로 backend/(NestJS)와 frontend/(Next.js)를 분리했다.

백엔드

  • NestJS 11 + Prisma 6.x + PostgreSQL 18 + Redis 7
  • Swagger 문서 자동 생성
  • PrismaModule, RedisModule을 @Global()로 등록하여 전역 주입
  • 5개 Feature Module: Scenarios, Sessions, Chat, Characters, AiPipeline

프론트엔드

  • Next.js 16 App Router + React Compiler + TanStack Query
  • shadcn/ui + Tailwind CSS 4 + framer-motion
  • POST-based SSE 클라이언트로 AI 응답 실시간 수신
  • 캐릭터 @ 멘션으로 특정 캐릭터에게 직접 말걸기

데이터베이스

7개 테이블로 구성했다. 핵심은 ChatMessageaffinitySnapshot(되돌리기용 호감도 스냅샷)과 SessionMemoryembedding(pgvector 시맨틱 검색용 벡터) 컬럼이다.

model SessionMemory {
  id                String   @id @default(uuid())
  sessionId         String   @map("session_id")
  summary           String   @db.Text
  involvedCharacters String[] @map("involved_characters")
  affinitySnapshot  Json?    @map("affinity_snapshot")
  // pgvector를 통한 시맨틱 검색용 임베딩
  embedding         Unsupported("vector(512)")?
  createdAt         DateTime @default(now()) @map("created_at")
}

핵심 1: 3단계 AI 파이프라인

채팅 메시지 하나가 전송되면 3단계 파이프라인이 순차적으로 실행된다.

1단계 — Target Resolution (Haiku 4.5)

누가 대답할 것인가?

플레이어의 메시지를 분석하여 7명 중 누가 응답할지 결정한다. @멘션이 있으면 이 단계를 건너뛰고, 없으면 대화 맥락과 현재 장면에 있는 캐릭터를 고려하여 AI가 자동 선택한다.

주목할 점은 부재 캐릭터 멘션 처리다. 현재 장면에 없는 캐릭터를 @로 부르면, 그 캐릭터 대신 장면에 있는 캐릭터가 "그 사람 여기 없는데?"라는 맥락의 반응을 하도록 파이프라인을 설계했다.

2단계 — Response Generation (Sonnet 4.5, 스트리밍)

나레이션과 대사를 생성한다.

시스템 프롬프트에 캐릭터 페르소나, 호감도 행동 가이드, 에피소드 줄거리, 시맨틱 검색으로 가져온 관련 기억, 최근 15개 메시지를 주입한다. 응답은 JSON 형태로 {currentEvent, items[]} 구조를 반환한다.

// 스트리밍 중 완료된 JSON 항목을 실시간으로 추출
stream.on('text', (textDelta) => {
  fullText += textDelta;
  const items = this.extractCompleteItems(fullText);
  while (sentItemCount < items.length) {
    onItem(items[sentItemCount]);
    sentItemCount++;
  }
});

스트리밍 JSON에서 완전한 항목 객체가 감지되는 즉시 SSE로 전달하는 구조다. 따옴표 상태를 추적하여 문자열 내부의 중괄호를 올바르게 처리하는 점진적 파서를 구현했다.

3단계 — State Update + 후처리

응답 생성이 끝나면 세 가지 후처리가 병렬로 진행된다:

  • 메시지 저장 — 나레이션과 대사를 DB에 영속화
  • 호감도 평가 (Haiku 4.5) — 보수적 ±5 범위의 점수 변동, 0~100 클램핑
  • 기억 생성 (Haiku 4.5) — 5턴마다 Redis 분산 락 기반으로 대화 요약 생성

핵심 2: POST-based SSE 스트리밍

브라우저의 EventSource API는 GET만 지원한다. 채팅 메시지 전송은 request body가 필요하므로 POST-based SSE를 직접 구현했다.

백엔드에서는 Express의 Response 객체에 직접 SSE 헤더를 설정하고 res.write()로 이벤트를 스트리밍한다:

res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');

// AI 파이프라인의 각 항목이 생성될 때마다 즉시 전송
const result = await this.aiPipelineService.runStream(
  pipelineContext,
  (item) => {
    if (item.type === 'narration') {
      res.write(`event: narration\ndata: ${JSON.stringify({ text: item.text })}\n\n`);
    } else if (item.type === 'dialogue') {
      res.write(`event: dialogue\ndata: ${JSON.stringify({ character: item.character, text: item.text })}\n\n`);
    }
  },
);

프론트엔드에서는 fetch 응답의 ReadableStream을 수동으로 읽어 SSE 프로토콜을 파싱한다. narration, dialogue, done, error 네 가지 이벤트 타입으로 구분하여 처리한다.


핵심 3: pgvector RAG 기억 시스템

대화가 길어지면 컨텍스트 윈도우에 전체 히스토리를 넣을 수 없다. 이를 해결하기 위해 주기적 요약 + 시맨틱 검색 구조를 도입했다.

기억 생성

사용자 메시지 5개마다 Haiku 4.5가 해당 구간의 대화를 3~5문장으로 요약한다. 요약에는 구체적 사건, 캐릭터 간 관계 변화, 인상적인 대사 인용이 포함된다.

// Redis 분산 락으로 중복 생성 방지
const lockKey = `memory:gen:${sessionId}`;
const acquired = await this.redis.set(lockKey, '1', { ex: 30, nx: true });
if (!acquired) return false;

생성된 요약은 Voyage AI(voyage-3-lite)로 512차원 벡터 임베딩을 만들어 pgvector에 저장한다.

시맨틱 검색

응답 생성 시 사용자의 현재 메시지를 쿼리로 사용하여, 코사인 유사도 기반으로 가장 관련 있는 기억 8개를 검색해 프롬프트에 주입한다:

SELECT id, summary, involved_characters, created_at
FROM session_memories
WHERE session_id = $1
ORDER BY embedding <=> $2::vector
LIMIT $3

핵심 4: 호감도 시스템과 채팅 되돌리기

호감도 평가

매 턴마다 Haiku 4.5가 사용자 메시지와 AI 응답을 분석하여 각 캐릭터의 호감도 변동을 판단한다. 보수적 ±5 범위로 제한하되, 같은 방향의 변동이 연속되면 프로그래밍 방식 감쇄를 적용한다:

// 동일 캐릭터에 같은 방향으로 연속 변동 시 감쇄
const dampingFactor = Math.max(0, 1 - recentSameDir * 0.1);
const dampened = Math.round(change.delta * dampingFactor);

이렇게 하면 "계속 칭찬만 하면 호감도가 무한히 오르는" 문제를 방지할 수 있다.

채팅 되돌리기

플레이어가 선택을 후회하면 특정 메시지 시점으로 되돌릴 수 있다. 핵심은 ChatMessage.affinitySnapshot — 매 사용자 메시지마다 현재 호감도 상태를 JSON으로 캡처해두고, 되돌리기 시 해당 스냅샷으로 호감도를 복원한다.

되돌리기 시 해당 시점 이후의 메시지뿐 아니라, 그 범위와 겹치는 기억(SessionMemory)도 함께 삭제하여 일관성을 유지한다.


핵심 5: Claude 거부 응답 처리

연애 시뮬레이션이다 보니, Claude가 특정 대화 맥락에서 응답을 거부하는 경우가 발생했다. stop_reason: 'refusal'이나 영문 거부 패턴("I can't generate", "content policy" 등)을 감지하면, 미리 준비한 대체 나레이션으로 자연스럽게 넘어가도록 처리했다.

private static readonly REFUSAL_REPLACEMENT_NARRATIONS = [
  '묘한 긴장감이 감돌았지만, 분위기는 자연스럽게 다른 방향으로 흘러갔다.',
  '잠시 정적이 흘렀다. 서로의 시선이 엇갈리며 미묘한 공기가 감돌았다.',
  '알 수 없는 감정이 두 사람 사이를 스쳐 지나갔다.',
];

한국어가 포함된 응답은 게임 응답으로 판단하고, 한국어 없이 영문 거부 패턴만 매칭되면 거부로 분류한다. 이 휴리스틱 덕분에 오탐 없이 안정적으로 동작했다.


기타 구현 사항

  • 캐릭터 @ 멘션: ChatInput에서 @ 입력 시 드롭다운이 뜨고, 선택한 캐릭터에게 직접 말을 건다. 이때 Target Resolution 단계를 건너뛴다.
  • AI 응답 제안: 매 턴 종료 후 Haiku 4.5가 3가지 응답 제안을 자동 생성한다. 행동 중심, 대사 중심, 감정적/유머러스 스타일로 다양성을 확보한다.
  • 자동 메시지 생성: 플레이어가 대사를 직접 쓰기 어려울 때, AI가 대화 맥락에 맞는 자연스러운 응답을 자동 생성해준다.
  • 스토리 이벤트 상태 머신: PlaySession.stateJson.currentEventId로 이벤트 진행 상태를 추적하고, 호감도 조건(affinity_gte 등)에 따라 자동 분기한다.
  • 에피소드 전환: 에피소드 종료 시 다음 에피소드 오프닝 스크립트를 자동 로드하고 채팅에 삽입한다.
  • Sentry 통합: 프론트엔드·백엔드 양쪽에 에러 추적을 설정하여 프로덕션 안정성을 확보했다.

사용 기술 스택 정리

영역기술
BackendNestJS 11, Prisma 6.x, PostgreSQL 18, Redis 7 (ioredis)
FrontendNext.js 16, React 19, TanStack Query, Tailwind CSS 4, framer-motion
AIClaude Sonnet 4.5 (응답 생성), Claude Haiku 4.5 (라우팅/평가/기억), Voyage AI (임베딩)
벡터 검색pgvector (PostgreSQL 확장)
인프라Docker Compose, Sentry

정리

단순한 "AI 챗봇"이 아닌, 여러 AI 모델이 역할을 분담하는 파이프라인 설계를 처음부터 끝까지 구현했다. 핵심 설계 판단을 정리하면:

  1. AI 파이프라인은 역할 분리가 핵심이다 — Haiku로 라우팅·평가·요약을 하고, Sonnet으로 핵심 응답을 생성하는 구조가 비용과 품질 모두에서 효과적이었다. 한 모델에 모든 것을 맡기는 것보다 각 단계의 복잡도에 맞는 모델을 배치하는 것이 비용 효율과 응답 속도 양쪽에서 유리하다.
  2. 스트리밍은 체감 성능을 결정한다 — POST-based SSE로 JSON 항목이 완성되는 즉시 전달하여, 전체 응답을 기다리지 않고 나레이션과 대사가 순차적으로 나타나는 경험을 구현할 수 있었다.
  3. RAG는 장기 대화에 필수적이다 — pgvector 시맨틱 검색으로 "30턴 전에 나눈 대화"도 맥락에 자연스럽게 반영되어, 캐릭터가 실제로 기억하고 있다는 느낌을 줄 수 있었다.
  4. 되돌리기에는 스냅샷 전략이 필요하다 — 호감도 같은 파생 상태를 매 메시지마다 캡처해두면, 시간 역행이 단순한 DELETE + 복원으로 해결된다. 상태 변경 이력을 역추적하는 것보다 훨씬 간결하다.
  5. AI 거부 응답은 서비스 레벨에서 대응해야 한다 — 연애 시뮬레이션의 특성상 거부가 발생할 수 있는 영역이고, 이를 자연스러운 나레이션으로 전환하는 폴백 전략이 사용자 경험을 보호한다.