채팅 시스템 고도화 DB 설계 (design-db)

Background

근거 문서:

  • PRD: /Users/biuea/Desktop/dpdpdndn/프로젝트/채팅 시스템/20260704-채팅시스템고도화-prd.md
  • BE TDD: /Users/biuea/Desktop/dpdpdndn/프로젝트/채팅 시스템/20260704-채팅시스템고도화-tdd.md
  • BE-01 티켓: /Users/biuea/Desktop/dpdpdndn/프로젝트/채팅 시스템/tickets/BE-01-db-migration-schema-expand.md

본 문서는 채팅 고도화(실시간 전송·읽음 커서·게스트 초대·커뮤니티 도메인·contextType 확장 메커니즘)를 지탱하는 스키마 설계를 확정합니다. DDL 전문 작성·실행은 private-mysql-implementer가 BE-01에서 수행하며, 본 문서가 그 입력(테이블 정의·인덱스 근거·마이그레이션 순서)입니다.

AS-IS (실제 마이그레이션 재구성)

채팅 관련 현재 스키마는 V8__create_messages_rooms.sql + V11__add_last_message_at_to_rooms.sql로 재구성됩니다. 최신 마이그레이션은 V37이므로 신규는 V38부터입니다.

테이블현재 컬럼현재 인덱스
roomsid, type(VARCHAR20), name(VARCHAR100 NULL), last_message_at(DATETIME6 NULL), audit 6종(created_at/by, updated_at/by, deleted_at/by)PK(id), idx_rooms_deleted_at, idx_rooms_type_deleted_at(type,deleted_at), idx_rooms_last_message_at(last_message_at)
room_participantsid, room_id, user_id, joined_at, audit 6종PK(id), uq_room_participants_room_user(room_id,user_id,deleted_at), idx(room_id,deleted_at), idx(user_id,deleted_at)
messagesid, room_id, user_id, content(TEXT), audit 6종PK(id), idx_messages_room_id_deleted_at(room_id,deleted_at), idx_messages_deleted_at, idx_messages_room_id_created_at(room_id,created_at)
  • messages.id는 BIGINT AUTO_INCREMENT PK — 읽음 커서(last_read_message_id)·안읽은 수·backfill의 정렬/비교 기준이 됩니다. (MessageDomainService.kt#listMessages PAGE_SIZE=30 커서 조회 존재)
  • RoomParticipant.ktroom·userId·joinedAt만 보유 — 읽음 시점·발화권한·만료 필드 없음(확장 대상).
  • InnoDB 전제. 기존 V8/V33 등은 소프트 삭제 컬럼(deleted_at)을 모든 조회 인덱스 말미에 두는 관례를 따릅니다 — 신규 설계도 이 관례를 유지합니다.

참고: 기존 rooms/room_participants/messages에는 COMMENT가 없습니다(V8 시점 관례). 컨벤션(private-db-schema-convention)상 신규 컬럼·신규 테이블은 COMMENT 필수이므로, 이번에 추가하는 컬럼·테이블에만 COMMENT를 부여합니다(기존 컬럼 소급 COMMENT는 범위 밖).

저장소 선택

전부 MySQL. MongoDB 미채택.

  • 채팅 방·참여자·초대·커뮤니티·멤버십은 관계(방↔참여자↔메시지)·상태 전이(초대 PENDING→ACCEPTED, 멤버십 승인)·트랜잭션 정합(자동 가입/퇴장)·유니크 제약(방-유저 중복 방지)이 핵심이라 MySQL이 적합합니다. private-mongodb-convention의 Mongo 채택 근거(유동 스키마·관계 불필요 대량 적재·계층 중첩)에 해당하지 않습니다.
  • 메시지 본문도 기존 messages 테이블(TEXT)을 그대로 사용 — 관계형 커서 조회(id 기준)와 소프트 삭제 정책을 유지하므로 이관 불필요합니다.
  • 실시간 팬아웃 상태(세션 레지스트리)는 in-memory(Simple Broker)로 DB 비영속 — 저장소 대상 아님.

Detail Design — 테이블 정의

1. rooms (기존 확장 — additive)

컬럼타입NULLDEFAULTCOMMENT근거
context_typeVARCHAR(30)NULL연결된 외부 도메인 유형 (COMMUNITY / GOODS_PRODUCT). DIRECT/GROUP 순수 방은 NULLFR-16/17/18, ENUM 금지→VARCHAR. 기존 방 호환 위해 nullable
context_idBIGINTNULL연결된 외부 엔티티 id (community_id 또는 product_id). context_type이 NULL이면 NULLFR-16. FK 금지→일반 BIGINT
  • 기존 DIRECT/GROUP 방은 두 컬럼 모두 NULL 유지 → additive, 기존 데이터 무충돌.
  • context_type/context_id는 항상 쌍으로 채워짐(둘 다 NULL 또는 둘 다 값). 정합은 애플리케이션 레벨(Room.createForContext)에서 보장.

2. room_participants (기존 확장 — additive)

컬럼타입NULLDEFAULTCOMMENT근거
participant_typeVARCHAR(20)NOT NULL’MEMBER’참여 유형 (MEMBER / GUEST)FR-11~14, ENUM 금지→VARCHAR. 기존 참여자는 정회원이므로 DEFAULT ‘MEMBER’로 백필
can_speakTINYINT(1)NOT NULL1발화 권한 (1=발화 가능, 0=읽기 전용 게스트)FR-13, BOOLEAN 금지→TINYINT(1). 기존 참여자는 발화 가능이므로 DEFAULT 1
expires_atDATETIME(6)NULL게스트 참여 만료 시각. MEMBER는 NULL(무기한)FR-14, 날짜 DATETIME(6)
last_read_message_idBIGINTNULL마지막으로 읽은 messages.id (읽음 커서). 미열람 시 NULLFR-7/9, 안읽은 수 계산 기준. FK 금지→일반 BIGINT
  • participant_type/can_speak는 NOT NULL이지만 의미 있는 비즈니스 기본값(기존 참여자 = 정회원 = 발화 가능)이 존재하므로 DEFAULT를 영구 유지하는 단일 ALTER로 백필합니다. 컨벤션의 “DEFAULT 임시 부여 → 채우기 → DEFAULT 제거” 3단계는 유효 기본값이 없을 때의 절차이므로 여기선 불필요 — DEFAULT를 남겨 신규 MEMBER INSERT 경로도 안전하게 유지합니다.
  • last_read_message_idmessages.id를 논리 참조하되 FK 금지 규칙에 따라 일반 BIGINT. 정합(방 내 메시지인지)은 애플리케이션에서 markReadUpTo forward-only로 보장.

3. room_invitations (신규)

컬럼타입NULLDEFAULTCOMMENT근거
idBIGINTNOT NULLAUTO_INCREMENTPKPK=id 통일
room_idBIGINTNOT NULL초대 대상 방 id조회 키(멱등·목록). FK 금지
inviter_user_idBIGINTNOT NULL초대한 사용자 id (방장)FR-11
invitee_user_idBIGINTNOT NULL초대받은 정회원 idFR-11, 멱등 조회 키
statusVARCHAR(20)NOT NULL’PENDING’초대 상태 (PENDING/ACCEPTED/REJECTED/REVOKED/EXPIRED)상태 전이표(TDD). ENUM 금지→VARCHAR
can_speakTINYINT(1)NOT NULL1수락 시 부여할 발화 권한FR-13, BOOLEAN→TINYINT(1)
expires_atDATETIME(6)NOT NULL초대로 부여될 게스트 참여 만료 시각(초대 시 expiresInDays로 산정)FR-14, DATETIME(6)
responded_atDATETIME(6)NULL수락/거절/철회/만료 처리 시각. PENDING이면 NULL상태 전이 감사
created_at/by, updated_at/by, deleted_at/by(audit 6종)JpaAuditingBase 표준기존 관례
  • 상태 값은 TDD 상태 전이표의 5종. statusRoomInvitation.accept()/reject()/revoke()/expire()가 캡슐화 전이, terminal 재전이는 애플리케이션에서 거부.

4. communities (신규)

컬럼타입NULLDEFAULTCOMMENT근거
idBIGINTNOT NULLAUTO_INCREMENTPKPK=id
nameVARCHAR(100)NOT NULL커뮤니티 이름FR-1
descriptionVARCHAR(500)NULL커뮤니티 설명FR-1. 선택 입력
visibilityVARCHAR(20)NOT NULL공개 여부 (PUBLIC/PRIVATE)FR-1/2, ENUM 금지→VARCHAR
sport_categoryVARCHAR(30)NOT NULL스포츠 종목 카테고리FR-1, ENUM 금지→VARCHAR
host_user_idBIGINTNOT NULL현재 방장 사용자 id (위임 시 갱신)FR-3, FK 금지
created_at/by, updated_at/by, deleted_at/by(audit 6종)표준기존 관례
  • host_user_id는 방장 위임(transferHostTo) 시 UPDATE. 별도 조회 패턴(방장별 커뮤니티 목록)이 인터페이스 시그니처에 없으므로 인덱스 미부여(쓰기 비용 회피).

5. community_members (신규)

컬럼타입NULLDEFAULTCOMMENT근거
idBIGINTNOT NULLAUTO_INCREMENTPKPK=id
community_idBIGINTNOT NULL소속 커뮤니티 id조회 키. FK 금지
user_idBIGINTNOT NULL멤버 사용자 id조회 키
roleVARCHAR(20)NOT NULL’MEMBER’역할 (HOST/MEMBER)FR-3, ENUM 금지→VARCHAR
statusVARCHAR(20)NOT NULL멤버십 상태 (PENDING_APPROVAL/ACTIVE/LEFT/KICKED)FR-2/3/5 상태 전이표. ENUM 금지→VARCHAR
joined_atDATETIME(6)NULLACTIVE 전이 시각(승인·즉시가입). PENDING이면 NULLFR-2
created_at/by, updated_at/by, deleted_at/by(audit 6종)표준기존 관례

쿼리 패턴 → 인덱스 매핑

TDD “인터페이스 시그니처”를 근거로, 각 인덱스가 어떤 쿼리를 위한 것인지 1:1 매핑합니다. 근거 없는 인덱스는 만들지 않습니다.

rooms

#쿼리 (근거 시그니처)조건인덱스컬럼 순서 근거
R1RoomRepository.findByContext(contextType, contextId)context_type=? AND context_id=? AND deleted_at IS NULLidx_rooms_context (context_type, context_id, deleted_at)context_type·context_id·deleted_at 전부 equality. context_type(저카디널리티, 2~3값)을 선두로 도메인 그룹핑 후 context_id(고카디널리티)로 단일 방 특정, deleted_at은 soft-delete 필터. TDD 제안 (context_type, context_id)에 deleted_at 말미 추가(관례 정합)

room_participants

#쿼리 (근거 시그니처)조건인덱스컬럼 순서 근거
P1findExpiredGuestsBefore(threshold) (GuestExpiryScheduler 배치)participant_type=‘GUEST’ AND deleted_at IS NULL AND expires_at thresholdidx_rp_guest_expires (participant_type, deleted_at, expires_at)equality 2개(participant_type, deleted_at IS NULL)를 선두, range(expires_at)를 말미 — equality→range 원칙. TDD 제안 순서 (participant_type, expires_at, deleted_at)를 조정: range 컬럼(expires_at) 뒤의 deleted_at은 인덱스로 걸러지지 않으므로 deleted_at을 range 앞으로 이동해 두 equality를 앞에 배치(선두 저카디널리티지만 GUEST 행만 좁혀 배치 비용 회피 목적이 명확)
P2findActiveByUserId(userId)user_id=? AND deleted_at IS NULL (+ 앱단 만료·타입 필터)기존 idx_room_participants_user_id_deleted_at (user_id, deleted_at) 재사용user_id(고카디널리티) equality + deleted_at equality로 충분. 신규 인덱스 불필요(쓰기 비용 회피)
P3방 참여자 목록·중복 방지 (기존)room_id=?, (room_id,user_id) 유니크기존 uq/idx 재사용변경 없음

messages

#쿼리 (근거 시그니처)조건인덱스컬럼 순서 근거
M1countUnread(roomId, afterMessageId, excludeUserId)room_id=? AND deleted_at IS NULL AND id > afterMessageId AND user_id != excludeUserId기존 idx_messages_room_id_deleted_at (room_id, deleted_at) 재사용InnoDB 보조 인덱스는 리프에 PK(id)를 암묵 부가 → 실질 (room_id, deleted_at, id). room_id·deleted_at equality 후 id range를 인덱스만으로 처리. user_id≠는 잔여 필터. 신규 인덱스 불필요
M2findAfter(roomId, afterMessageId, pageSize) (backfill, FR-10)room_id=? AND deleted_at IS NULL AND id > afterMessageId ORDER BY id ASC LIMIT ?동일 idx_messages_room_id_deleted_at 재사용M1과 동일 구조(암묵 PK id로 range+정렬 커버). 신규 인덱스 불필요

messages는 신규 인덱스 0개 — 기존 (room_id, deleted_at) 인덱스의 암묵 PK 부가 특성으로 커서(id) 쿼리를 커버합니다. deleted_at을 항상 IS NULL로 고정 필터하는 두 쿼리 특성이 전제이므로, 만약 삭제 메시지 포함 조회가 생기면 그때 (room_id, id) 전용 인덱스를 재검토합니다.

room_invitations

#쿼리 (근거 시그니처)조건인덱스컬럼 순서 근거
I1findPendingBy(roomId, inviteeUserId) (멱등 초대)room_id=? AND invitee_user_id=? AND status=‘PENDING’ AND deleted_at IS NULLidx_room_invitations_room_invitee_status (room_id, invitee_user_id, status, deleted_at)전부 equality. room_id·invitee_user_id(고카디널리티) 선두 → status → deleted_at. 동일 (room,invitee) PENDING 중복 방지 조회 최적
I2findById(id) (accept/reject/revoke)id=?PK별도 인덱스 불필요

communities

#쿼리 (근거 시그니처)조건인덱스컬럼 순서 근거
C1findPublicByKeyword(keyword)visibility=‘PUBLIC’ AND deleted_at IS NULL (+ name LIKE 앱/DB 필터)idx_communities_visibility_deleted_at (visibility, deleted_at)visibility·deleted_at equality로 공개 커뮤니티 집합을 좁힘. name LIKE ‘%kw%‘는 선행 와일드카드라 인덱스 불가 → 좁혀진 집합에서 필터. visibility 저카디널리티지만 PUBLIC만 스캔 대상이 되어 PRIVATE 제외 효과가 명확
C2findById(id)id=?PK불필요

community_members

#쿼리 (근거 시그니처)조건인덱스컬럼 순서 근거
CM1findActiveBy(communityId, userId)community_id=? AND user_id=? AND deleted_at IS NULL (+ status=‘ACTIVE’)uq_community_members_community_user (community_id, user_id, deleted_at) UNIQUE방-유저 유니크(room_participants 관례 동형)로 중복 가입 방지 + 조회 커버. community_id·user_id 고카디널리티 equality
CM2findActiveByCommunityId(communityId) (자동 가입/퇴장 팬아웃 대상)community_id=? AND status=‘ACTIVE’ AND deleted_at IS NULLidx_community_members_community_status (community_id, status, deleted_at)community_id equality 선두 + status equality로 ACTIVE 멤버만. CM1 유니크는 (community_id,user_id) 순이라 status 필터 미커버 → 별도 필요

용량 추정 · 보존 정책

개인 프로젝트·로컬 docker 단일 인스턴스 규모(PRD 300 동시 세션 가정) 기준 초안입니다.

테이블1행 크기(대략)예상 행 수(1년)증가율1년 후 크기보존 정책
rooms (+2컬럼)~120B수백~수천커뮤니티·거래 방 생성 시< 1MB소프트 삭제 유지, 만료 삭제 없음
room_participants (+4컬럼)~120B수천~수만방당 수명당 참여자수 MB소프트 삭제. 게스트 방출은 soft-delete(읽은 이력 유지, FR-14)
messages (기존)본문 가변(TEXT)무한 성장실시간 활성화로 증가 가속수십~수백 MBPRD 결정: 소프트 삭제 유지, 무기한 보존(별도 만료 삭제 없음)
room_invitations~120B수백초대 발송 시< 1MB소프트 삭제. 만료 초대는 status=EXPIRED 전이(행 유지)
communities~200B수십~수백커뮤니티 개설 시< 1MB소프트 삭제
community_members~120B수천가입 시수 MB소프트 삭제
  • 무한 성장 테이블은 messages입니다. PRD/NFR가 “메시지 무기한 보존(별도 만료 삭제 없음)“으로 확정했고, Open Questions에 스토리지 증가 대응을 미결로 남겼습니다. 본 설계는 파티셔닝/아카이빙을 선반영하지 않습니다(개인 프로젝트 규모에 과함 — 단순함 우선). 향후 스토리지 임계 도달 시 room_id·created_at 기반 월 단위 아카이빙 테이블 분리를 재검토합니다.
  • 게스트 방출·초대 만료는 물리 삭제가 아닌 상태 전이/soft-delete로 이력을 보존합니다(FR-14 “읽은 이력 유지”).
  • 로그성 대량 적재 데이터가 없어 TTL(MySQL 이벤트 스케줄러) 도입은 불필요합니다.

무중단 마이그레이션 (expand-contract)

배포 순서: 스키마 먼저 → 코드 (spring.jpa.hibernate.ddl-auto=validate이므로 컬럼이 코드보다 먼저 존재해야 함). 이번 변경은 전부 expand(additive) 단계이며 contract(제거) 단계가 없습니다.

단계작업락 영향롤백 지점
S1V38: rooms ADD context_type, context_id (둘 다 NULL) + idx_rooms_contextADD COLUMN(nullable) = MySQL 8.0 INSTANT 가능(락 없음). 인덱스 추가는 ALGORITHM=INPLACE, LOCK=NONE역방향 DDL: DROP idx_rooms_context, DROP COLUMN context_id, context_type
S2V39: room_participants ADD participant_type(NOT NULL DEFAULT ‘MEMBER’), can_speak(NOT NULL DEFAULT 1), expires_at(NULL), last_read_message_id(NULL) + idx_rp_guest_expiresDEFAULT 있는 NOT NULL·nullable ADD COLUMN = MySQL 8.0.12+ INSTANT(기존 행 즉시 백필, 락 없음). 인덱스는 INPLACE/LOCK=NONE역방향 DDL: DROP idx_rp_guest_expires, DROP 4개 컬럼
S3V40: CREATE room_invitations (인덱스 포함)신규 테이블 = 락 무관DROP TABLE room_invitations
S4V41: CREATE communities신규 테이블 = 락 무관DROP TABLE communities
S5V42: CREATE community_members (UNIQUE·인덱스 포함)신규 테이블 = 락 무관DROP TABLE community_members
C1코드 배포 (피처 플래그 OFF: chat.realtime.enabled=false, chat.community.enabled=false)플래그 OFF 유지 → 기존 REST 채팅만 동작
C2점진 활성화 (플래그 ON)플래그 OFF로 즉시 비활성 (스키마 롤백 불필요)
  • 파일 분리 판단: S1S5를 개별 마이그레이션(V38V42)으로 분리 권장 — 테이블별 롤백 지점을 독립시키고 실패 격리. 단일 파일로 묶어도 무방하나, 실패 시 부분 롤백이 어려움. (BE-01 티켓은 “마이그레이션 파일 산출 대표”이므로 파일 개수는 implementer 재량 — 분리 권장을 명시)
  • 락 없음 근거: 모든 ALTER는 MySQL 8.0 INSTANT(컬럼 추가) 또는 INPLACE/LOCK=NONE(인덱스). 온라인 DDL로 서비스 중단·쓰기 블로킹 없음. NOT NULL 컬럼도 유효 DEFAULT가 있어 백필이 즉시 이뤄지므로 3단계 분리 불요.
  • contract 없음: expand-only라 기존 코드는 신규 컬럼/테이블을 참조하지 않아, 코드 롤백만으로 안전합니다. 스키마 롤백(역방향 DDL)은 불가피할 때만 위 롤백 지점을 역순 적용.

ERD (Mermaid erDiagram)

erDiagram
    COMMUNITIES ||--o{ COMMUNITY_MEMBERS : has
    ROOMS ||--o{ ROOM_PARTICIPANTS : has
    ROOMS ||--o{ MESSAGES : contains
    ROOMS ||--o{ ROOM_INVITATIONS : has
    ROOMS {
        bigint id PK
        varchar type
        varchar name
        datetime last_message_at
        varchar context_type "NULL COMMUNITY|GOODS_PRODUCT"
        bigint context_id "NULL"
    }
    ROOM_PARTICIPANTS {
        bigint id PK
        bigint room_id
        bigint user_id
        datetime joined_at
        varchar participant_type "MEMBER|GUEST DEFAULT MEMBER"
        tinyint can_speak "DEFAULT 1"
        datetime expires_at "NULL"
        bigint last_read_message_id "NULL"
    }
    MESSAGES {
        bigint id PK
        bigint room_id
        bigint user_id
        text content
    }
    ROOM_INVITATIONS {
        bigint id PK
        bigint room_id
        bigint inviter_user_id
        bigint invitee_user_id
        varchar status "PENDING|ACCEPTED|REJECTED|REVOKED|EXPIRED"
        tinyint can_speak
        datetime expires_at
        datetime responded_at "NULL"
    }
    COMMUNITIES {
        bigint id PK
        varchar name
        varchar description "NULL"
        varchar visibility "PUBLIC|PRIVATE"
        varchar sport_category
        bigint host_user_id
    }
    COMMUNITY_MEMBERS {
        bigint id PK
        bigint community_id
        bigint user_id
        varchar role "HOST|MEMBER"
        varchar status "PENDING_APPROVAL|ACTIVE|LEFT|KICKED"
        datetime joined_at "NULL"
    }

FK 컬럼 금지 규칙상 관계는 논리적(ID 참조)입니다. ERD의 관계선은 도메인 의미이며 실제 FK 제약을 생성하지 않습니다.

자가 점검 (TDD 도메인 모델 대응)

TDD 도메인 필드/시그니처대응 컬럼확인
Room.contextType/contextId, findByContextrooms.context_type/context_id + idx_rooms_contextOK
RoomParticipant.participantType/canSpeak/expiresAt/lastReadMessageIdroom_participants 4컬럼OK
findExpiredGuestsBeforeidx_rp_guest_expiresOK (순서 조정)
markReadUpTo(forward-only)last_read_message_id (BIGINT)OK
countUnread/findAfter기존 idx_messages_room_id_deleted_at 재사용OK
RoomInvitation status 5종/can_speak/expires_at, findPendingByroom_invitations + idx_room_invitations_room_invitee_statusOK
Community name/description/visibility/sportCategory/host, findPublicByKeywordcommunities + idx_communities_visibility_deleted_atOK
CommunityMember role/status, findActiveBy/findActiveByCommunityIdcommunity_members + uq + idx_community_members_community_statusOK

컨벤션 점검: FK 컬럼 0 · ENUM 0(전부 VARCHAR) · JSON 0 · BOOLEAN 0(can_speak=TINYINT(1)) · 날짜 전부 DATETIME(6) · PK=id · 참조 {entity}_id · 신규 컬럼/테이블 COMMENT 필수 명시 — 위반 없음.

Document History

날짜변경 내용
2026-07-04최초 작성 — BE TDD 인터페이스 시그니처 기반 테이블·인덱스 설계 확정. rooms/room_participants expand + room_invitations/communities/community_members 신규. idx_rp_guest_expires 컬럼 순서를 equality→range 원칙으로 조정, messages 신규 인덱스 0(암묵 PK 부가 활용). expand-only 무중단 순서·INSTANT 락 영향 명시.