본문으로 건너뛰기

ERD — 테이블 구조

갱신일 2026-08-11 · 기준: heymoa-server 마이그레이션 V1V24 전량 + JPA 엔티티 22개, heymoa-ai alembic 00010006 + SQLAlchemy 모델 이 문서가 갖는 것: 어떤 테이블이 있고 무엇을 참조하며 스키마가 무엇을 강제하는가. 여기 없는 것: 원본·파생의 소유권 경계(data-architecture.md) · 도메인 규칙과 용어(domain-model.md) · API 스키마(contracts/).

마이그레이션이 정본이다. 엔티티는 ddl-auto: validate로 검증만 하고 스키마를 만들지 않는다.


1. 데이터베이스는 둘, 인스턴스는 하나

database테이블성격만드는 것
heymoa21개원본. 지우면 복구할 수 없다Flyway (V1~V24)
heymoa_ai2개 + LangGraph 체크포인트파생. 통째로 날려도 원본에서 다시 만들어진다alembic (0001~0006) + checkpointer.setup()

둘은 같은 RDS 인스턴스(heymoa-postgres · db.t4g.micro · Single-AZ) 안의 다른 database다. 그래서 두 database 사이에는 외래키가 없다 — Postgres는 database를 넘는 FK를 만들 수 없다. 파생 쪽이 원본을 가리키는 것은 전부 맨 문자열 ID다 (§11).


2. 그룹 일곱과 참조 방향

한 장에 21개를 그리면 화살표가 서른 개 넘게 얽힌다. 소유 사슬 기준으로 일곱 묶음으로 나눈다. 아래 그림에서 노드를 클릭하면 어느 묶음이 어느 묶음에 매달려 있는지 남는다.

모든 사슬이 notes를 지난다. 전사·분석·대화가 전부 노트에 매달려 있고, 그래서 노트 삭제가 §9의 CASCADE 사슬이 된다.

공통 규칙 — 아래 표에서 되풀이하지 않는다.

무엇
기본키VARCHAR(13) TSID. 시간순으로 증가하고 URL에 그대로 나간다
감사 칼럼모든 테이블에 created_at · updated_at (TIMESTAMPTZ(6), NOT NULL)
enum 칼럼VARCHAR + @Enumerated(EnumType.STRING). DB CHECK있는 것과 없는 것이 섞여 있다 (§10)

3. 계정·인증

  • sessions.previous_token이 있는 이유 — 리프레시 회전이 token을 덮어쓰면 옛 해시가 그 순간 사라져서, 같은 토큰으로 거의 동시에 온 두 번째 요청이 세션을 못 찾고 거부됐다. 탭을 여러 개 복원하면 정상 경쟁이 로그아웃이 됐다(V20). 직전 해시를 짧게 남겨 두면 경쟁에서 진 요청도 "이 세션이 맞다"까지는 판정할 수 있다.
  • users.default_workspace_id는 없다. V24에서 지웠다 — NOT NULL + FK라 (1) 멤버가 아닌 워크스페이스를 기본으로 들고 있어도 못 막고 (2) 그것을 기본으로 가진 유저가 있는 워크스페이스를 지울 수 없었다. 「로그인 후 어디로」는 브라우저의 마지막 방문으로 옮겼고 서버는 그 값을 읽지도 쓰지도 않는다.
  • uk_users_email(정확 일치)과 uk_users_email_lower둘 다 있다. 후자가 더 강한데 전자를 남긴 건 엔티티 매핑이 참조하기 때문이다.

4. 조직

4.1 워크스페이스 · 멤버십 · 초대

  • 초대의 1차 식별자는 유저가 아니라 이메일이다. V21invitee_user_id를 nullable로 풀고 invitee_email을 넣었다 — 미가입자도 초대 링크를 받아야 하기 때문이다. 중복 대기 초대를 막는 부분 유니크도 user 기준에서 email 기준으로 바꿨다.
  • 그 마이그레이션은 lower(email) 유니크를 새로 만드는데, 케이스만 다른 계정이 이미 있으면 기동이 막힌다. 그래서 파일 맨 위에서 먼저 검출하고, 계정 병합은 사람이 판단할 일이라 자동으로 고치지 않고 무엇이 걸렸는지 알려주고 멈춘다.
  • notifications.resource_id에는 FK가 없다. 지금은 초대 ID만 들어가지만 타입이 늘면 다른 테이블을 가리키게 된다 — 다형 참조라 FK를 걸 수 없다. type CHECK가 값 하나(WORKSPACE_INVITATION)뿐이라 지금은 사실상 초대 전용이다.

4.2 프로젝트 · 연동 토큰

  • 연동 토큰의 소유자는 사람이 아니라 워크스페이스다. 처음엔 user_id였는데(V6), 한 유저가 여러 워크스페이스에 속하면 어느 워크스페이스의 연동인지 정할 수 없어 V9에서 테이블을 드랍하고 다시 만들었다 — 기존 개인 연동은 매핑할 방법이 없어 버렸다. connected_by_user_id는 소유가 아니라 누가 연결했는지의 기록이다. ADMIN의 연동이 곧 팀 사용 동의라는 것이 여기서 나온다.
  • 토큰은 암호화해서 넣는다. 열 이름이 encrypted_*인 것이 그 표시다.
  • projectsURL에 없다. 노트는 워크스페이스 안에서 유일하게 식별되고 project는 목록의 묶음 축이다 → IA §3.

5. 회의

  • meeting_status에는 CHECK 제약이 없다. V3CHECK 없이 만들었고 이후로도 붙이지 않았다 — 값 목록을 강제하는 것은 Kotlin MeetingStatus enum 하나뿐이다. V16PAUSED를 폐기할 때 마이그레이션으로 남은 행을 되돌려야 했던 이유가 이것이다. @Enumerated(EnumType.STRING)이라 모르는 값이 한 행이라도 남아 있으면 읽는 순간 Hibernate가 던지고 그 노트는 영구 교착이 된다.
  • PAUSEDV16에서 폐기했다가 V17에서 되살렸다. 지금은 살아 있는 값이다.
  • note_participants.user_id에는 CASCADE가 없다. 워크스페이스에서 나간 멤버도 참여 기록은 남는다 — 회의 시점의 사실이기 때문이다. 유저 삭제 흐름이 아직 없어서 생길 때 함께 정한다.
  • 화자 분리와 무관하다. 이 테이블은 "누가 그 회의에 있었나"만 기록한다.

6. 전사

  • 노트 하나에 열려 있는 세션은 최대 하나다. 애플리케이션 규칙이 아니라 부분 유니크 인덱스가 강제한다.
    CREATE UNIQUE INDEX uk_transcription_sessions_note_open
    ON transcription_sessions (note_id) WHERE status IN ('READY', 'ACTIVE');
  • started_at_ms는 세션별 오프셋이다. 노트에 세션이 둘 이상이면 전사 화면의 이어붙인 타임라인과 어긋난다 — 같은 04:12가 다른 줄을 가리킨다(APP-398). IA §7의 ④가 이것이다.
  • end_reasonMEETING_PAUSED는 legacy 값이다. V16PAUSED 상태를 폐기하면서도 이 값은 건드리지 않았다 — 과거에 실제로 그렇게 끊긴 세션의 이력이고, 다른 사유로 덮으면 거짓이 된다.

7. 분석 — 항목과 근거

  • analysis_jobs에 결과 본문이 없다. 원래는 overview · action_items · insights TEXT 세 칼럼이었는데(V5), markdown 덩어리는 항목 단위로 쪼갤 수도 어느 발화에서 나왔는지 되짚을 수도 없어서 V23에서 드랍하고 두 테이블로 옮겼다. 제품 한 줄이 "결정·변경·Action Item을 근거와 함께 축적"이기 때문이다. 되돌릴 수 없다. 세 칼럼의 markdown은 V23에서 버려지고 어디에도 남지 않았다 — 항목 목록에서 markdown은 만들 수 있지만 그 역은 불가능하다. 운영 사용자가 없다는 전제 위에서만 성립한 결정이다.
  • meeting_items는 노트가 아니라 잡에 매달린다. 재분석하면 새 잡 아래 새 항목이 생기고 옛 잡의 항목은 남는다 — latest가 가리키는 곳만 바뀐다. note_id를 함께 두는 건 프로젝트 횡단 조회와 콜백의 세그먼트 소속 검증이 "노트별 최신 잡" lateral join 없이 끝나게 하려는 것이다.
  • 근거 상한 3개는 제약 둘이 함께 만든다. UNIQUE (meeting_item_id, position) 하나만 두면 position 0인 행을 서로 다른 세그먼트로 여러 개 넣을 수 있고, CHECK (position BETWEEN 0 AND 2) 하나만 두면 같은 position이 중복돼 정렬이 비결정적이 된다.
  • status · origin스키마 선반영이다. 칼럼과 기본값만 두고 애플리케이션 로직은 아직 없다.
  • ⚠️ 근거를 채우는 경로가 아직 없다. 테이블은 서 있지만 server가 세그먼트 ID를 AI에 넘기지 않는다 — 열려 있는 것 ①.

8. 대화 — 챗봇 2종과 승인

  • agent_chat_approvals.chat_id에는 FK가 없다. agent_chatsnote_shared_chats 둘 중 하나를 가리키는 다형 참조라 FK를 걸 수 없다. 그래서 V18이 노트 CASCADE를 세울 때 이 테이블만 사슬로 못 왔고, 노트를 직접 가리키는 note_id 칼럼을 따로 뒀다. 서비스가 삭제 전에 지우는 방식은 못 쓴다 — 승인 등록(SSE 릴레이)이 별도 트랜잭션이라 삭제와 등록이 겹치면 요청자 ID와 summary가 고아로 남는다. FK만이 그 경합을 막는다.
  • id를 heymoa-ai가 발급한다. 승인 API path와 조회 응답에 나가는 공개 식별자라 13자 TSID여야 한다. 형식이 다르면 server가 승인 row 등록을 건너뛰고, web에는 카드가 뜨는데 승인 API가 404가 되어 300초 뒤 REJECTED로 끝난다 — 조용히 깨지는 경로다.
  • "활성 세션 하나"는 부분 유니크 인덱스 둘이 만든다.
    CREATE UNIQUE INDEX uq_agent_chats_active_workspace
    ON agent_chats (user_id, workspace_id) WHERE active AND scope = 'WORKSPACE';
    CREATE UNIQUE INDEX uq_agent_chats_active_note
    ON agent_chats (user_id, note_id) WHERE active AND scope = 'NOTE';
  • 공유 챗봇에는 "새 대화"가 없다. note_shared_chatsUNIQUE (note_id)라 노트당 하나이고 노트와 수명을 같이 한다. 두 챗봇의 나머지 차이는 → 도메인 모델 § 챗봇 2종.
  • note_shared_chat_messages.author_user_id가 nullable인 것은 AI 응답에는 작성자가 없기 때문이다. 개인 챗봇 메시지에는 이 칼럼 자체가 없다 — 주인이 하나뿐이라 채팅에 이미 적혀 있다.

9. 노트 하나를 지우면

V18(APP-335)이 노트에 매달린 FK를 전부 ON DELETE CASCADE로 바꿨다. 서비스가 자식을 순서대로 지우는 방식을 쓰지 않은 이유: 자식 테이블이 늘 때마다 그 서비스를 고쳐야 하고, 빠뜨려도 컴파일이 안 잡는다. 2단 사슬이라 순서도 틀리기 쉽다. FK가 스스로 지키게 한다.

CASCADE는 부모 한 행마다 자식 테이블을 뒤진다. 자식의 FK 칼럼에 인덱스가 없으면 그 자리가 매번 순차 스캔이 된다. 그래서 V18idx_agent_chats_note_id를, V23idx_meeting_items_note·idx_meeting_item_evidence_*를 함께 만들었다.

휴지통은 없다. 물리 삭제이고 전사·요약·채팅이 통째로 사라진다. 복구 요구가 실제로 생기면 소프트 삭제 + N일 후 물리 삭제로 승격한다.

heymoa_ai는 이 사슬에 없다. DB가 달라 CASCADE가 넘어가지 못한다 — 노트를 지워도 그 노트의 임베딩과 분석 캐시는 남는다 (§11).


10. 스키마가 지키는 규칙

부분 유니크 인덱스 — 도메인 불변식을 DB가 든다

인덱스무엇을 막나
uk_transcription_sessions_note_open노트 하나에 열린 전사 세션 둘
uq_agent_chats_active_workspace · uq_agent_chats_active_note같은 대상에 활성 개인 챗봇 둘
uk_workspace_invitations_pending같은 워크스페이스·이메일에 대기 중 초대 둘
uk_sessions_previous_token서로 다른 세션이 같은 직전 해시를 갖는 상태

WHERE 절이 붙은 유니크라 끝난 행은 색인 대상이 아니다 — 완료 세션이 아무리 쌓여도 "열린 것 하나" 조건만 지킨다.

부분 인덱스 — 워치독 스캔용

인덱스조건
idx_analysis_jobs_watchdogstatus IN ('PENDING','RUNNING')
ix_agent_chat_approvals_unresolved_created_atstatus IN ('PENDING','RESOLVING')

둘 다 미완결 행만 색인한다. 완료 잡이 쌓여도 워치독의 분주 스캔이 전체 테이블을 훑지 않는다.

enum 값 — 그림에서는 개수만 적었다

칼럼DB CHECK
workspace_members.role · workspace_invitations.roleADMIN MEMBER
workspace_invitations.statusPENDING ACCEPTED DECLINED CANCELED EXPIRED
notifications.typeWORKSPACE_INVITATION
transcription_sessions.statusREADY ACTIVE COMPLETED INTERRUPTED
transcription_sessions.end_reasonREADY_TIMEOUT CLIENT_DISCONNECTED CLIENT_PROTOCOL_ERROR STT_PROVIDER_ERROR INTERNAL_ERROR MEETING_ENDED MEETING_PAUSED(legacy)
meeting_items.kindOVERVIEW ACTION_ITEM DECISION
meeting_items.statusPROPOSED CONFIRMED REJECTED DONE
meeting_items.originAI USER
notes.meeting_statusNOT_STARTED IN_PROGRESS PAUSED ENDED
analysis_jobs.statusPENDING RUNNING SUCCEEDED FAILED
agent_chats.scopeWORKSPACE NOTE
메시지 role 2종USER ASSISTANT TOOL
agent_chat_approvals.statusPENDING RESOLVING APPROVED REJECTED EXPIRED
tool_connections.providerLINEAR GITHUB
accounts.providerGOOGLE

CHECK가 있는 것과 없는 것

있다없다
workspace_members.role · workspace_invitations.role·status · notifications.type · transcription_sessions.status·end_reason · meeting_items.kind·status·origin · meeting_item_evidence.position · transcript_segments의 범위 4종notes.meeting_status · analysis_jobs.status · agent_chats.scope · 메시지 role 2종 · agent_chat_approvals.status · tool_connections.provider · accounts.provider

일관되지 않다. 없는 쪽은 Kotlin enum이 유일한 방어선이고, V16이 보여줬듯 값 하나를 폐기할 때 마이그레이션으로 남은 행을 직접 되돌려야 한다.


11. 스키마가 못 지키는 것

#무엇지금 상태
DB 경계를 넘는 참조에 FK가 없다analysis_results.analysis_idanalysis_jobs.id, transcript_embeddings.note_idnotes.id. Postgres가 database를 넘는 FK를 못 만든다 — 구조적 한계이지 누락이 아니다
그 칼럼의 폭이 원본과 다르다파생 쪽은 VARCHAR(32), 원본은 VARCHAR(13) TSID다. 값은 13자만 들어오지만 스키마가 그것을 강제하지 않는다. 32자짜리가 들어와도 DB는 받는다
노트를 지워도 파생이 안 지워진다CASCADE가 DB를 못 넘는다. transcript_embeddingsanalysis_results의 고아 행을 지우는 경로가 코드에 없다. "파생이라 날려도 된다"가 청소를 대신하고 있다
agent_chat_approvals.chat_id가 어느 테이블도 안 가리킨다다형 참조. 존재하지 않는 채팅 ID가 들어와도 DB가 막지 못한다
유저 삭제 흐름이 없다note_participants.user_id·notes.meeting_started_by_user_id 등 유저를 가리키는 FK 9개에 CASCADE도 SET NULL도 없다. 유저를 지우려면 지금은 FK 위반으로 실패한다
notes.meeting_status에 CHECK가 없다§10. enum 밖의 값이 한 행이라도 들어가면 그 노트를 읽는 순간 애플리케이션이 던진다
analysis_results의 legacy 칼럼 셋이 남아 있다overview·action_items·insights. sections JSONB가 정본이고 저 셋은 배포 롤백 창을 위한 잔여물이다 — 지우는 것은 APP-316

12. 파생 — heymoa_ai

  • analysis_results는 멱등성 캐시다. 워치독이 같은 분석을 다시 요청했을 때 처음부터 다시 돌리지 않으려는 것뿐이다. 그래서 0006이 모양을 바꿀 때 옛 모양 행을 그냥 지웠다 — 재요청이 새로 분석하면 되고, markdown 덩어리를 항목 하나로 감싸면 근거 없는 가짜 항목이 원본 DB에 영구 저장되기 때문이다.
  • sections JSONB인 이유 — 무엇을 분석할지는 sections.yml이 정한다. 블록을 하나 더한다고 마이그레이션을 쓰지 않으려는 것이다.
  • HNSW 인덱스를 테이블이 비어 있을 때 만들었다(0003). 데이터가 쌓인 뒤에 만들면 빌드 동안 락이 걸린다.
  • 재적재는 note_id DELETE 후 INSERT다. 둘이 겹치면 두 벌이 들어가므로 pg_advisory_xact_lock(hashtext(note_id))으로 한 줄로 세운다. UNIQUE (note_id, chunk_index)는 DELETE가 빠진 경로가 생겼을 때 조용히 중복되는 대신 터지게 하는 장치다.
  • LangGraph 체크포인트 테이블은 마이그레이션이 아니라 기동 시 AsyncPostgresSaver.setup()이 만든다. 스키마 소유자가 라이브러리라 여기서 형태를 적지 않는다 — 라이브러리를 올리면 바뀐다. 커넥션 안정화 인자 넷은 ADR 0005.

13. 마이그레이션 이력이 말해 주는 것

번호는 늘어나기만 하고 되감기지 않는다. 뒤집힌 결정이 파일로 남아 있다.

마이그레이션무엇이 바뀌었나되돌릴 수 있나
V6V9연동 토큰 소유자 user → workspace. 테이블을 드랍하고 다시 만들었다❌ 기존 개인 연동은 버렸다
V16V17회의 PAUSED 폐기했다가 되살림
V5V23분석 결과 TEXT 3칼럼 → meeting_items + meeting_item_evidence❌ markdown은 버렸다
V24users.default_workspace_id 제거❌ 값을 버렸고, 옛 JAR은 ddl-auto: validate에 막혀 기동조차 안 된다
00040005옛 칼럼을 지웠다가 되살림 — alembic이 같은 revision을 다시 안 돌려서 새 revision으로 고쳐야 했다

server에는 롤백 경로가 없다. deploy.sh에 이전 이미지 캡처가 없고 ddl-auto: validate라, 파괴적 마이그레이션 뒤에 옛 JAR을 올리면 컨테이너가 아예 안 뜬다. 앞으로만 고칠 수 있다.

ai는 다르다. deploy.sh가 마이그레이션을 컨테이너 교체보다 먼저 돌리고 헬스체크가 실패하면 앱만 되돌린다. 그래서 파괴적 마이그레이션을 두 번에 나눠 올린다 — 0004가 옛 칼럼을 남긴 이유이고, 아직 지우지 못한 이유다.