메시지 테이블이 없다고! 내가 만든 Claude 기반 챗봇의 데이터 모델링
요약
Claude 기반 챗봇 Claudius를 구축하며 적용한 MongoDB 데이터 모델링 전략을 다룹니다. 관계형 DB의 정규화 방식 대신 애플리케이션의 액세스 패턴을 중심으로 한 문서 모델링의 중요성을 설명합니다.
핵심 포인트
- 액세스 패턴 중심의 문서 모델링 설계 원칙
- LangGraph 체크포인터와 메시지 데이터의 연동 방식
- 스키마 유연성과 Schemaless의 차이점 이해
- Zod를 활용한 코드 기반 스키마 관리
이 튜토리얼은 Néstor Daza에 의해 작성되었습니다.
이 글은 저의 Claude 기반 챗봇인 Claudius (Github)를 구축하는 시리즈의 두 번째 기사입니다. 프롤로그에서는 이 프로젝트를 구축해야 하는 이유와 기반 기술로 MongoDB를 선택한 이유를 다루었습니다.
Claudius 데이터베이스의 conversations 컬렉션을 열어보면 스레드 헤더의 일반적인 필드들은 볼 수 있지만, 그 외에는 아무것도 없습니다. userId, title, 몇몇 timestamps 등은 있지만, 메시지 배열도 없고 그 옆에 위치한 메시지 컬렉션도 없습니다! 모든 대화의 텍스트는 완전히 다른 곳, 즉 이 시리즈의 후반부에서 연결할 LangGraph 체크포인터 (checkpointer)에 저장됩니다. 이러한 부재는 모델링 결정의 결과이며, 제가 어떻게 챗봇을 위한 데이터베이스 스키마 (schema)를 고안했는지가 이 글의 주제입니다.
관계형 데이터베이스 (relational database) 배경을 가진 분들이라면 데이터베이스를 설계할 때 먼저 데이터를 모델링하는 것에 익숙할 것입니다. 이런 프로젝트의 경우, 엔티티 (entities)를 찾아 정규화 (normalizing)하는 것부터 시작할 것이며, 최종 스키마는 데이터의 구조에 따라 결정될 것입니다. 즉, 데이터가 그렇게 생겼기 때문에 conversations 테이블과 그 사이에 외래 키 (foreign key)가 있는 messages 테이블이 만들어지게 됩니다.
문서 모델링 (Document modeling)은 그 반대로 작동합니다. 애플리케이션이 읽고 쓰는 방식에서 시작하며, 문서의 형태는 액세스 패턴 (access patterns)을 따릅니다. Claudius는 에이전트의 전체 작업 상태 (working state)가 메시지를 감싸고 있지 않은 상태로는 대화 메시지를 절대 읽지 않으며, 그 상태는 LangGraph 체크포인터를 사용하여 영속화 (persisted)됩니다. 별도의 messages 테이블은 아무런 도움이 되지 않는데, 앱이 읽을 때마다 매번 해당 상태와 다시 조인 (join)해야 하기 때문입니다. 액세스 패턴은 messages가 에이전트 상태와 함께 있어야 함을 말해주며, 따라서 그것이 저장되는 위치가 됩니다. 그리고 conversations는 목록 보기에서 실제로 필요한 가벼운 헤더로 남겨집니다.
데이터가 아닌 사용 사례를 중심으로 모델링하는 이러한 역전(inversion)은 아래의 모든 내용에 관통하는 핵심 원칙입니다.
스키마 유연성(Schema-flexible)은 스키마가 없는 것(schemaless)이 아니다
이는 많은 사람들이 흔히 갖는 오해이며, 시작하기 전에 반드시 바로잡아야 할 부분입니다. 문서 지향 데이터베이스(document database)가 스키마가 없음을 의미하는 것은 아닙니다. 그것은 사용 사례에 기반한 유연한 데이터 모델(data model)을 의미합니다. 관계형 엔진(relational engine)은 컬럼 타입(column types)과 제약 조건(constraints)을 통해 스키마를 관리하지만, MongoDB는 애플리케이션이 제공하는 어떤 형태의 문서든 저장하며, 원한다면 컬렉션 검증기(collection validator)를 통해 해당 형태를 검증할 수 있습니다. Claudius는 각 컬렉션당 하나의 Zod 스키마를 사용하여 코드 내에 스키마를 유지합니다. 왜냐하면 이 단일 정의가 런타임 체크(runtime check)와 TypeScript 타입(type)을 모두 제공하기 때문입니다.
import { z } from "zod";
import { zObjectId } from "./common";
...
그 이유는 바로 '드리프트(drift, 괴리)' 때문입니다. 문서에 대한 TypeScript 인터페이스(interface)를 선언하고 들어오는 데이터에 대해 별도의 검증기(validator)를 선언하면, 결국 두 가지가 서로 일치하지 않게 되며, 타입 시스템(type system)은 아주 자신만만하게 당신에게 거짓 정보를 제공하게 됩니다. 검증기로부터 타입을 유도(deriving)하면 이 둘을 하나의 아티팩트(artifact)로 통합할 수 있습니다. 하나의 정의만으로 경계에서의 런타임 체크(runtime check)와 그 외 모든 곳에서의 컴파일 타임 타입(compile-time type)을 얻을 수 있습니다. 관계형 데이터베이스 사용자에게 이것은 DDL(Data Definition Language, 데이터 정의 언어)과 ORM(Object-Relational Mapping, 객체 관계 매핑) 모델이 동일한 구조 안에 함께 있는 것과 같으며, 이는 관계형 스택에서는 좀처럼 달성하기 어려운 일입니다.
또 다른 장점은 들어오는 HTTP 바디(body)든 모델의 구조화된 출력(structured output)이든, 모든 외부 입력이 경계에서 Zod를 통해 파싱(parsed)된다는 점입니다. 따라서 데이터가 로직(logic)에 도달할 때쯤이면 이미 검증이 완료되었고 타입이 지정된 상태가 됩니다.
모델 요약 투어
이 단계에는 users, conversations, memories, documents, chunks, usage_events, settings, 그리고 나중에 추가될 jobs까지 총 8개의 컬렉션(collections)이 있습니다. checkpoints와 checkpoint_writes 두 개는 전적으로 체크포인터(checkpointer)에 속하며 직접 쓰이지 않으므로 모델링하지 않았습니다. 액세스 패턴(access-pattern) 프레임워크를 이해하고 나면 대부분의 스키마(schemas)는 평범해 보이지만, 두 가지 모델링 방식은 잠시 짚고 넘어갈 가치가 있습니다.
첫 번째는 settings입니다. 여기에는 각각 다른 형태를 가진 몇 가지 글로벌 싱글톤(singletons)이 포함되어 있으며, 각 항목은 문자열 _id로 식별됩니다. 멤버 허용 목록(allowlist), 모델 카탈로그(model catalog), 역할별 티어(per-role tiers), 그리고 게스트 지출 차단기(guest spend circuit breaker)가 그것입니다. 이들은 하나의 컬렉션을 공유하므로, Zod 스키마는 _id를 기반으로 한 판별된 유니온(discriminated union)이며, settings 문서를 파싱하면 _id 리터럴을 통해 올바른 형태로 좁혀집니다.
export const SettingsSchema = z.discriminatedUnion("_id", [
AllowlistSettingsSchema, // _id: "allowlist"
ModelCatalogSettingsSchema, // _id: "modelCatalog"
...
관계형(relational) 관점에서 보면, 이는 행(rows)마다 정당하게 서로 다른 컬럼(columns)을 가질 수 있는 단일 타입 구성 테이블(typed configuration table)입니다. 이는 보통 JSON 컬럼을 사용하지 않고는 관계형으로 모델링하기 까다로운 부분인데, 여기서는 깔끔하고 읽기 쉽게 구현되었습니다.
두 번째 방식은 데이터 모델링 기법으로서 문서 임베딩 (document embedding)을 사용하는 것입니다. users 문서는 dailyMessageCount를 두 개의 평면적인 필드(flat fields)나 별도의 테이블로 나누지 않고, 횟수와 리셋 시간을 함께 담은 임베디드 객체(embedded object)로 포함합니다. 이는 애플리케이션이 항상 이들을 하나의 단위로 함께 읽고 쓰기 때문입니다. 이것이 바로 문서 모델링의 만트라(mantra)입니다: 함께 사용되는 데이터는 함께 저장된다.
데이터베이스가 규칙을 유지하게 하라
이 단계에서 가장 만족스러운 부분은 내가 작성해야 할 정책(policy)이 거의 없었다는 점입니다. 평소라면 애플리케이션 코드에서 강제했을 세 가지 제품 규칙(product rules)이 대신 스토리지 엔진(storage engine)에 의해 수행됩니다. 엔진이 규칙을 유지할 수 있도록 내가 데이터를 설계했기 때문입니다.
보존(Retention)은 TTL 인덱스이다
Claudius에서 게스트(Guest) 데이터는 휘발성(ephemeral)입니다. 게스트가 생성한 대화와 메모리는 expiresAt 날짜를 포함하지만, 멤버(member)와 관리자(admin) 문서는 이 필드를 아예 제외합니다. expireAfterSeconds가 0으로 설정된 TTL (time-to-live) 인덱스는 MongoDB가 각 문서의 expiresAt 필드에 저장된 정확한 시점에 해당 문서를 삭제하도록 지시합니다. 이 필드가 없는 문서는 절대 건드리지 않습니다.
// 게스트 전용 expiresAt 필드에 대한 TTL. expireAfterSeconds:0은
// "필드에 저장된 날짜에 정확히 만료됨"을 의미합니다.
// 필드가 없는 문서는 절대 건드리지 않으므로, 멤버/관리자
...
따라서 제품 규칙인 '게스트 데이터의 휘발성'은 엔진에 의해 강제됩니다. 실행을 기억해야 하는 크론 잡(cron job)이나 정리 엔드포인트(cleanup endpoint)가 필요 없습니다. 관계형 데이터베이스(relational database)에서의 대응 방식은 스케줄링된 스윕(scheduled sweep)이지만, 여기서는 정책이 인덱스(index) 안에 살아 있습니다.
한 가지 세부 사항은 TypeScript 컴파일러를 데이터베이스 동작과 연결합니다. tsconfig.json에서 exactOptionalPropertyTypes를 활성화하면, 선택적 필드(optional field)는 키가 존재하면서 undefined로 설정되는 것이 아니라, 키 자체가 없는 것을 의미합니다. 컴파일러 설정과 스토리지 규칙이 서로를 강화합니다.
텔레메트리(Telemetry)는 시계열 컬렉션(time-series collection)이다
과금 대상이 되는 모든 모델 호출은 토큰 수, 지연 시간(latency), 그리고 호출 목적을 기록하는 하나의 usage_events 문서를 작성합니다. 해당 데이터는 추가 전용(append-only) 방식이며, 특정 시간 범위 내에서 몇 가지 낮은 카디널리티(low-cardinality) 차원을 따라 쿼리됩니다. 이는 MongoDB 시계열 컬렉션(time-series collection)의 전형적인 형태입니다. 이 컬렉션은 timeField, timestamp, 그리고 측정값과 함께 중첩되어 그룹화 차원(userId, modelId, purpose)을 보유하는 단일 metaField를 가집니다.
이러한 선택으로부터 두 가지 결과가 도출됩니다. 시계열 컬렉션은 첫 번째 삽입 전에 timeseries 사양을 사용하여 명시적으로 생성되어야 합니다(일반 컬렉션처럼 첫 쓰기 시점에 생성될 수 없습니다). 따라서 인덱스 스크립트는 존재 여부를 확인한 후 이를 생성합니다. 일단 생성되면 엔진 자체의 메커니즘이 드러납니다. 컬렉션을 나열하면 usage_events 옆에 버킷화된 저장소가 위치하는 system.buckets.usage_events가 나타납니다.
[
{"name":"memories","type":"collection"},
{"name":"usage_events","type":"timeseries"},
...
]
관계형 데이터베이스의 비유를 들자면, 직접 만들고 관리해야 하는 메트릭(metrics) 또는 롤업(rollup) 테이블과 유사하지만, 버킷화(bucketing)가 자동으로 이루어지며 이에 대해 작성하는 쿼리가 단순하게 유지된다는 점이 다릅니다.
프리필터(Pre-filter) 필드는 보안 제어 장치이다
이 프로젝트의 가장 엄격한 규칙은 벡터 검색(vector search)이 userId로 프리필터링(pre-filter)되어야 하며, 절대로 다른 사용자의 데이터를 반환해서는 안 된다는 것입니다. 이 규칙은 애플리케이션 코드에 검색 기능을 덧붙인 것이 아닙니다. 이는 인덱스의 속성입니다.
memories와 chunks 모두 embedding 필드에 Atlas Vector Search 인덱스를 가지며, userId에 대한 filter가 적용됩니다 (chunks의 경우 documentId에 대한 두 번째 필터가 추가됩니다).
await ensureVectorIndex(db, COLLECTIONS.memories, "memories_vector", [
{ type: "vector", path: "embedding", numDimensions: 1024, similarity: "cosine" },
{ type: "filter", path: "userId" },
...
Pre-filtering (사전 필터링)은 userId 조건이 검색 결과가 나온 이후에 적용되는 것이 아니라, 점수가 매겨지는 동일한 문서들에 대해 인덱싱된 검색 과정 중에 적용됨을 의미합니다. Post-filtering (사후 필터링)은 점수를 매긴 문서들을 나중에 버리게 되므로 속도가 느릴 뿐만 아니라, 필터가 실행되기 전에 잘못된 문서가 랭킹(ranking)과 제한(limits)을 통과할 수 있기 때문에 정확성 및 보안상의 위험이 있습니다.
이것이 바로 chunks가 userId를 비정규화 (denormalize) 하는 이유이기도 합니다. 벡터 검색 (vector search)에는 조인 (join)이라는 개념이 없으므로, 필터는 반드시 검색 대상이 되는 문서 자체에 위치해야 합니다. 청크 (chunk)는 사용자에게 속한 문서에 속해 있지만, 인덱스가 경계(boundary)를 강제할 수 있도록 청크 자체가 소유자 정보를 직접 가지고 있어야 합니다. 이는 임베딩(embed) 대 참조(reference) 사이의 긴장 관계가 구체화된 사례입니다. 즉, 청크는 ID를 통해 부모 문서를 참조하면서도, 보안 경계가 의존하는 단 하나의 필드를 중복해서 가집니다.
이 인덱스들을 생성하는 동안 두 가지 주의사항 (gotchas)이 나타났으며, 두 가지 모두 명시할 가치가 있습니다. 검색 인덱스는 이미 존재하는 컬렉션(collection)에 대해서만 구축할 수 있습니다. 그런데 chunks에는 기존의 인덱스가 없었기 때문에, 스크립트가 처음으로 벡터 인덱스에 접근했을 때 컬렉션이 존재하지 않았습니다:
MongoServerError: Error retrieving collection UUID and view info ::
caused by :: Collection 'claudius.chunks' does not exist. (NamespaceNotFound)
해결 방법은 존재 여부를 확인하는 체크를 추가하고, 컬렉션이 없는 경우에만 먼저 생성하는 것입니다. 두 번째 주의사항은 Atlas가 검색 인덱스를 비동기적으로 구축한다는 점입니다. 따라서 createSearchIndex는 구축이 큐(queue)에 추가될 때 반환될 뿐, 실제로 쿼리가 가능해질 때 반환되는 것이 아닙! 또한 인덱스가 구축되는 동안 재실행 시에도 안전하도록 이름 확인 (name check) 절차도 필요합니다.
다음은 드라이버의 createSearchIndex를 사용하여 코드에서 인덱스를 구축하는 최종 함수이며, 매 배포 시 스크립트를 안전하게 실행할 수 있도록 하는 체크 로직들이 포함되어 있습니다:
async function ensureVectorIndex(
db: Db,
collectionName: string,
...
코드를 정직하게 유지하는 배관 작업 (The plumbing that keeps the code honest)
두 가지 작은 요소가 실제 환경에서 userId 규칙을 하나로 묶어줍니다. 첫째, userId는 어디에서나 사용자의 _id와 일치하는 ObjectId이므로, 변환 과정 없이 필터(filter)와 조회(lookup)가 일치합니다. 둘째, 어떤 컬렉션도 로우 드라이버(raw driver) 호출을 통해 직접 쿼리되지 않습니다. 모든 접근은 conversationsCol(), memoriesCol()과 같이 타입이 지정된 액세서(accessor)를 통해 이루어지며, 각 액세서는 올바른 문서 형태(document shape)에 맞는 타입이 지정된 컬렉션을 반환합니다. 컬렉션 이름을 중앙 집중화하면 이름 변경 시 한 곳에서만 처리하면 되며, 각 액세서에 타입을 지정함으로써 userId 필터가 모든 호출 지점에서 컴파일러에 의해 확인될 수 있습니다. 이것이 바로 글로 작성된 규칙이 코드에서도 정직하게 유지되는 방식입니다.
애플리케이션 전체와 Auth.js 어댑터는 단일한 고정 데이터베이스 이름을 가진 단일 서버리스 안전(serverless-safe) MongoDB 클라이언트를 공유하므로, 정확히 하나의 커넥션 풀(connection pool)이 존재합니다. 그 데이터베이스 이름이 어떻게 조용히 두 번의 별도 장애를 일으켰는지에 대한 이야기는 흥미로운 에피소드이며, 기초를 세우는 과정을 다룰 다음 기사에서 다루도록 하겠습니다.
이 글을 통해 얻은 것
아직 챗봇은 완성되지 않았지만, 그것의 형태는 이미 결정되었습니다. 유지 관리(Retention), 텔레메트리(telemetry) 레이아웃, 그리고 테넌트 격리(tenant-isolation) 경계는 이제 나중에 실행하는 것을 기억해야 하는 코드가 아니라, 스키마(schema)와 인덱스(index)의 속성이 되었습니다. 이것이 액세스 패턴(access patterns)을 우선적으로 고려하여 모델링했을 때 얻는 보상입니다. 데이터베이스가 그에 맞춰 설계되었기 때문에 데이터베이스가 더 많은 부하를 감당하게 됩니다. 데이터 모델은 전체 애플리케이션의 근간이 되는 조각입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기