[DB Deep] 채팅 저장 구조

cup-wan·2025년 6월 7일
5

DBDeep

목록 보기
3/3

Intro

또! 다시 설계 이야기를 해볼까 합니다. 구현보다 어려운 설계, 언제쯤 알잘딱 잘하게 될지 참 궁금합니다. DB Deep은 기업 요구사항에 맞춰 진행한 프로젝트라 우선 요구사항에 맞추는 것이 ..급했습니다.
그러다 오늘 Mongo DB를 조금 파보니 여지없이 설계에 실수가 있는 것 같더라구요. 제대로 알지 못하는 상태에서 사용한다면 티가 나버리는 것 같습니다.
그래서 한번 대대적으로 갈아엎기 전에 고민의 흐름을 기록해두겠습니다.

초기 채팅 저장 구조

당시에는 단순하게 "채팅 메시지를 시간 순서대로 저장하고 불러오자!" 라는 생각으로 Google Cloud Platform의 Firestore에 메시지를 저장했습니다.
저장 방식은 다음과 같은 구조입니다.

def save_chat_message(
    chat_room_id: str,
    sender_type: Literal["user", "ai", "system"],
    message_type: Literal["text", "sql", "chart", "insight"],
    content: str | dict
):
    db = get_firestore_client()
    doc_ref = db.collection("chat_messages").add({
        "chat_room_id": chat_room_id,
        "sender_type": sender_type,
        "type": message_type,
        "content": content,
        "timestamp": datetime.utcnow()
    })

    return doc_ref[1].id
def get_chat_messages(chat_room_id: str, limit: int = 100):
    db = get_firestore_client()
    query = (
        db.collection("chat_messages")
          .where("chat_room_id", "==", chat_room_id)
          .order_by("timestamp")
          .limit(limit)
    )
    return [doc.to_dict() for doc in query.stream()]

모든 메시지를 chat_messages라는 하나의 컬렉션에 저장하고, 필드값으로 구분만 했습니다. 그리고 특정 채팅방(chat_room_id)의 기록을 불러오는 형식이었습니다.
겉보기에는 괜찮아 보이지만, 시간이 지나고 데이터가 쌓이기 시작한다면 여러 한계가 드러날 구조라 생각합니다.

문제점

성능 저하 : 전부 하나에 모여있는 단일 컬렉션

  • chat_messages 컬렉션이 모든 채팅방의 메시지를 다 담고 있다 보니, 수십만 건이 쌓일수록 하나의 쿼리만으로도 비용이 커질 수 있습니다.
  • chat_room_id를 기준으로 filter를 걸어도 Firestore에서는 항상 index scan이 발생하기 때문에 컬렉션 자체가 커질수록 조회 성능은 떨어집니다.

구조적 불일지 : content 필드

  • content 필드는 타입에 따라 str일 수도 있고 dict일 수도 있는 비정형 필드입니다.
  • message_type이 text일 때 string이지만, chart인 경우 json 형태의 dict가 됩니다.
  • 이를 처리하는 코드에서는 매번 타입 체크 + 조건 분기를 해야하기 때문에 전체 흐름을 파악하거나 유지보수에 극악의 효율을 보여주고 있습니다😥

확장 불가능한 구조

  • 단일 컬렉션의 모든 단점을 포함하고 있기에 당연히 향후 메시지 저장 형식의 확장이 불가능합니다.
  • 답변 평가, 피드백, 첨부파일, system message에 대한 처리 등의 기능을 추가할 때 구조적인 제약이 발생합니다
    • 실제로 사용자 피드백을 받아 성능 개선 당시 Mongo DB 설계 미스로 구현에 어려움을 겪었습니다.
  • Firestore에서 제공하는 subcollection이나 nested document로 충분히 해결 가능합니다.

Mongo DB의 설계 방법

RDB는 정규화, MongoDB는 사용 패턴 (Workloads)이 설계의 중심

그렇다면 Mongo DB는 스키마 설계를 어떻게 해야할까요?
이번 프로젝트를 하며 느낀점은 Mongo DB는 RDB와는 데이터 모델링 자체에 대한 철학이 다른 것을 인지해야한다는 점 입니다.

워크로드 기반 설계

Mongo DB에서는 데이터를 어떻게 쓰고, 읽고, 업데이트 할 것인지 실제 사용 패턴을 중심으로 데이터 모델을 설계해야합니다.

  • 어떤 데이터를 자주 함께 조회하는가?
  • 읽기와 쓰기 중 어떤 작업이 더 많은가?
  • 특정 문서의 크기가 계속 커질 가능성이 있는지?
항목관계형 데이터베이스 (RDB)MongoDB (문서 DB)
설계 기준정규화 (데이터 중복 제거)워크로드 (쿼리 최적화)
구조테이블/행/열문서(JSON-like)
관계 표현JOINEmbedded / Reference

이러한 설계의 차이는 RDB는 데이터 정합성과 무결성이 중요하고 Mongo DB는 가장 큰 특징인 데이터 중복 허용쿼리 효율성 우선시 하기 때문입니다.

모델링 시 고려사항

Mongo DB에서 문서 설계 시 다음과 같은 기준을 고려합니다.

  • 한 번의 요청으로 필요한 데이터를 모두 가져올 수 있는지?
  • 문서 크기가 16MB (Mongo DB의 단일 문서 크기)를 초과할 위험이 있는지?

이 기준을 통해 문서 간 관계를 Embedding으로 할 지 Referencing으로 할 지 결정됩니다.

{
  "userId": "abc123",
  "name": "홍길동",
  "posts": [
    {"title": "첫 글", "createdAt": "..."},
    {"title": "두 번째 글", "createdAt": "..."}
  ]
}

MongoDB로 사용자 게시글을 조회한 예시입니다. 자주 같이 조회된다면 예시와 같이 임베딩이 적절하지만 게시글 수가 수천 개 이상으로 커진다면 분리한 후 레퍼런싱 하는 것이 더 적절합니다.
예시를 통해 더 알아봅시다.

  • 유저 프로필 + 설정
{
  "userId": "123",
  "name": "철수",
  "settings": {
    "theme": "dark",
    "notifications": true
  }
}

namesettings는 유저 정보를 볼 때 항상 함께 조회됩니다. 따라서 지금처럼 임베딩이 적절합니다.

  • 유저 + 게시글
{
  "userId": "123",
  "name": "철수"
}
{
  "postId": "999",
  "userId": "123",
  "title": "MongoDB 잘 쓰는 법",
  "content": "..."
}

사용자에 대한 정보는 자주 변경되지 않습니다. 하지만 게시글은 개수가 많고 조회, 추가, 삭제가 잦습니다
따라서 유저 데이터를 조회할 때 게시글이 항상 가져올 필요가 없기 때문에 레퍼런싱하는 것이 적절합니다.


대안 설계 방향

위 내용을 바탕으로 Firestore의 구조적 장점을 최대한 살릴 수 있는 방향으로 구조를 고민해봤습니다.

1. 채팅방 중심 구조

기존 구조

  • 모든 채팅방의 메시지를 동일 컬렉션에 넣어 쿼리 성능이 점점 악화
  • 인덱스 구성해도 전체 컬렉션 단위 인덱스 스캔이 빈번한 기능이 많기 때문에 부담 여전

개선안: 채팅방 하위 컬렉션 분리

  • Firestore의 Subcollection을 활용해 각 채팅방에 종속적인 메시지 컬렉션 분리
  • 탐색 범위 감소 : 각 채팅방 별로 데이터를 쿼리, 필요한 메시지만 빠르게 조회 가능
  • 문서 수 제한 회피 : 메시지 수가 늘어도 단일 컬렉션에 데이터가 몰리지 않음
  • 확장 유연성 : 향후 채팅방 메타 데이터, 참여자 관리 등 구조 확장 용이

적용 방법

  • MongoDB의 16MB 제한에 맞춰야함
  • 채팅방 별 메시지 컬렉션 분리
    • chat_messages_{chat_room_id} 식으로 컬렉션을 나누거나 chat_room_id로 샤딩 키를 설정
  • 채팅 메시지를 채팅방에 임베딩
    • 메시지가 많으면 16MB를 초과할 수 있기에 테스트 단계에서 고려해볼만한 듯?

2. 메시지 타입에 따른 구조 정리

기존 구조

  • content 필드 하나에 모든 메시지 데이터를 넣음
    • text일 땐 string
    • sql, chart, insight일 땐 dict(JSON)
  • 타입에 따라 구조가 다르기 때문에:
    • 매번 타입 체크 → 파싱 로직 복잡
    • 프론트엔드에서 메시지 렌더링 시 로직 분기 증가
    • 추후 로그 분석, 백업 등 구조화된 데이터 필요 시 어려움 발생

예: type == "chart"content["x"], content["y"] 체크 후 시각화
type == "sql"content["query"] 존재 여부 확인 후 실행 요청 등
모든 타입마다 if-else 분기처리 필요


개선안: 메시지 타입별 content 스키마 수정

message_type에 따라 content 필드를 명확한 구조로 구분합니다.

예시)

  • 텍스트
{
  "type": "text",
  "sender_type": "user",
  "content": "이번 쿼리의 문제점은 뭐야?",
  "timestamp": "2024-01-01T12:00:00Z"
}
  • SQL 생성 응답
{
  "type": "sql",
  "sender_type": "ai",
  "content": {
    "query": "SELECT name FROM employees WHERE salary > 5000"
  },
  "timestamp": "2024-01-01T12:01:00Z"
}
  • 차트 (그래프)
{
  "type": "chart",
  "sender_type": "ai",
  "content": {
    "chart_type": "bar",
    "x": ["부서 A", "부서 B", "부서 C"],
    "y": [12, 23, 5]
  },
  "timestamp": "2024-01-01T12:02:00Z"
}
  • 인사이트 요약
{
  "type": "insight",
  "sender_type": "ai",
  "content": {
    "summary": "부서 B는 전체 대비 2배 이상의 매출을 기록했습니다.",
    "related_kpi": ["매출", "부서"]
  },
  "timestamp": "2024-01-01T12:03:00Z"
}

추가 고려사항

  • 메시지 유형에 따라 content 필드의 구조를 union type으로 설계
  • 각 메시지를 별도 도큐먼트로 분리하고 message_type별 하위 필드로?

예: text, sql, chart, insight 각각의 필드 구성이 다를 경우
content 하나로 두기보단, 아예 각 필드를 독립적으로 선언하는 것도 고려

{
  "type": "chart",
  "sender_type": "ai",
  "chart_data": {
    "chart_type": "line",
    "x": [...],
    "y": [...]
  },
  "timestamp": ...
}

Outro

항상 프로젝트가 끝나면 여기도 고쳐야되고 저기도 고쳐야할 것 같고 그렇습니다. 짧은 기간에 임팩트있게 구현하려다 보니 부족한 점이 먼저 눈에 띄는 것 같아요. 하지만? 이제.. 시간이 좀 강제로 생겼기 때문에 ^^ 차근차근 리팩토링을 진행해볼까 합니다.

슬슬 회고 쓰고 리팩토링하는 글을 자주 작성할 것 같습니다. 긴 글 읽어주셔서 감사합니다.👍

진짜 마지막으로 윈도우에서는 따봉으로 따봉티콘이 검색됩니다.

profile
아무것도 안해서 유죄 판결 받음

0개의 댓글