봇과 사람을 어떻게 가를까
「봇을 users 처럼 별개 표로 두면 관리가 쉽지 않나」 에 대한 답. 갈래가 둘이고 하나는 이미 기각된 방향이라, 물어보신 이득이 실제로 나오는 모양은 아래 (b) 다.
지금과 무엇이 다른가
지금 (a) — 한 표에 섞여 있다
봇 전용 칸이 nullable 로 actors 안에 산다
확장 (b) — 봇 칸만 딸린 표로
actors 는 그대로 「발화 주체」이고, 봇 칸은 NOT NULL 이 된다
⭐ 오른쪽 세 표(messages · room_participants · direct_rooms)는 두 그림에서 똑같다. 이것이 (b) 의 핵심이다 — 여전히 actors 하나만 가리키므로 타임라인·참여·1:1 은 한 줄도 안 바뀐다. 바뀌는 것은 「봇의 살림살이」를 어디에 두느냐뿐이다.
DBML 로 — 그리고 그것이 드러낸 것
같은 안을 DBML 로 적어 봤다. 적는 동안 하나가 걸렸다 — 지금 (a) 의 owner_user_id 는 ON DELETE SET NULL 인데, (b) 에서 그 칸이 NOT NULL 이 되면 SET NULL 을 쓸 수가 없다. 둘 중 하나를 골라야 한다:
| 사람 계정을 지우면 | 무슨 일이 나나 |
|---|---|
| CASCADE (그림에 쓴 것) | 그 봇의 살림살이만 사라진다 — bots 행이 지워지고 actors 행은 남는다. 지나간 Message 의 작성자 이름은 그대로이고, 토큰이 없어져 더는 말하지 못한다. ⚠️ 대신 사람도 봇도 아닌 화자가 남는다 |
| RESTRICT (기본값) | 봇을 하나라도 가진 사람은 지울 수 없다. 봇은 지워지지 않으므로 (결정 8) 그 상태는 스스로 안 풀린다 — 지금 (a) 가 SET NULL 을 고른 이유가 이것이다 |
⭐ 이것이 그림을 두 번 그려서 얻은 것이다. (b) 는 「칸을 옮기는 일」로 보이지만 실제로는 "주인이 사라진 봇은 무엇인가" 라는 답을 바꾼다 — (a) 는 주인 없는 봇으로 계속 산다, (b)+CASCADE 는 말하기를 그친다. 뒤쪽이 더 안전하지만, 그건 정책 변경이지 리팩터링이 아니다.
DBML 원문 — dbdiagram.io 에 그대로 붙여넣으면 인터랙티브하게 열린다
// ─────────────────────────────────────────────────────────────────────────────
// chatmu — 확장안 (b): 봇의 살림살이만 딸린 표로 나간다
//
// ⚠️ 이것은 **제안**이다. 지금 도는 스키마가 아니다(그쪽은 db/schema.sql 에서 뽑는다).
// actors 는 여전히 「발화 주체」한 축이고, messages·room_participants·direct_rooms 는
// 하나도 안 바뀐다 — 바뀌는 것은 봇 전용 칸의 자리뿐이다.
// ─────────────────────────────────────────────────────────────────────────────
Table users {
id bigint [pk]
login_id text [unique, not null]
password_hash text [not null]
display_name text [not null, note: '⚠ actors.display_name 과 중복 — 별도 검토 중']
created_at timestamptz [not null]
password_changed_at timestamptz [not null, note: '옛 토큰을 끊는 기준선']
}
Table actors {
id bigint [pk]
user_id bigint [unique, note: 'NULL 이면 사람 계정이 없는 화자 (ADR 0001)']
display_name text [not null, note: 'Profile — 남이 보는 이름표']
status_message text [note: 'Profile · 한 줄 · 인라인 마크다운만']
profile_body text [note: 'Profile · Message 본문과 같은 계약']
profile_body_format text [note: '본문과 한 몸 — CHECK 가 짝을 진다']
created_at timestamptz [not null]
}
Table bots {
actor_id bigint [pk, note: 'actors 를 그대로 잇는다 — 새 id 를 만들지 않는다']
owner_user_id bigint [not null, note: '★ NOT NULL — 주인 없는 봇이 표현될 수 없다']
bot_token_hash text [not null, unique, note: '★ NOT NULL — 토큰 없는 봇이 표현될 수 없다']
deactivated_at timestamptz [note: 'NULL 이 살아 있다. 거두면 여기만 채운다']
created_at timestamptz [not null]
}
Table rooms {
id bigint [pk]
name text [not null, note: 'DM 방은 빈 문자열 — 판단 근거가 아니다']
created_at timestamptz [not null]
}
Table room_participants {
room_id bigint [pk]
actor_id bigint [pk]
last_read_message_id bigint [note: '읽음 커서 — 일부러 FK 가 아니다']
joined_at timestamptz [not null]
}
Table messages {
id bigint [pk, note: '정렬·커서의 정본']
room_id bigint [not null]
actor_id bigint [not null, note: '작성자는 언제나 Actor — 사람이든 봇이든']
body text [not null]
body_format text [not null]
client_message_id uuid [note: '재시도가 두 번 남기지 않게']
created_at timestamptz [not null]
edited_at timestamptz
deleted_at timestamptz
}
Table direct_rooms {
room_id bigint [pk]
actor_a_id bigint [not null, note: 'CHECK (a < b)']
actor_b_id bigint [not null, note: 'UNIQUE (a, b)']
created_at timestamptz [not null]
}
// ── 사람 ───────────────────────────────────────────────────────────────────
Ref: users.id < actors.user_id [delete: cascade]
// ── 봇: actors 를 1:1 로 잇는다 ────────────────────────────────────────────
Ref: actors.id - bots.actor_id [delete: cascade]
// ⚠️ owner 가 NOT NULL 이므로 **SET NULL 을 쓸 수 없다.** 지금 (a) 는 그것을 썼다.
// CASCADE 면 「주인을 지우면 그 봇의 살림살이가 사라진다」 — actors 행은 남으므로
// 지나간 Message 의 작성자 이름은 그대로다. 토큰이 사라져 더는 말하지 못할 뿐이다.
Ref: users.id < bots.owner_user_id [delete: cascade]
// ── 방·발화 (두 안에서 똑같다) ─────────────────────────────────────────────
Ref: rooms.id < room_participants.room_id [delete: cascade]
Ref: actors.id < room_participants.actor_id [delete: cascade]
Ref: rooms.id < messages.room_id [delete: cascade]
Ref: actors.id < messages.actor_id
Ref: rooms.id - direct_rooms.room_id [delete: cascade]
Ref: actors.id < direct_rooms.actor_a_id [delete: cascade]
Ref: actors.id < direct_rooms.actor_b_id [delete: cascade]
⚠️ 이 DBML 은 손으로 쓴 제안이라 schema.sql 에서 뽑히지 않는다 — 지금 도는 스키마의 DBML 은 npm run db:diagram 이 만든다. 둘을 나란히 놓고 보면 무엇이 늘고 무엇이 그대로인지가 보인다.
⛔ 기각된 갈래 — (c) 완전 별개 표
물어보신 "users 와 같이 별개 테이블" 을 글자 그대로 하면 이 모양이 되는데, ADR 0001 이 정확히 이것을 기각했다.
messages ──┬──→ users ← 작성자가 사람이면
└──→ bots ← 작성자가 봇이면 ⛔
작성자를 가리키는 FK 가 하나로 성립하지 않는다. 그러면 ① 방의 타임라인이 두 표를 UNION 해야 하고 ② 명부·참여·구독·읽음이 전부 두 벌이 되며 ③ "이 화자의 모든 발화" 가 한 질의로 안 나온다. ADR 0001 은 이것을 피하려고 「발화 주체」라는 한 축을 세웠다 — (b) 는 그 축을 안 건드린다.
표현 가능한 「말이 안 되는 상태」
| 상태 | 지금 (a) | 확장 (b) | CHECK 한 줄 (a+) |
|---|---|---|---|
| 사람인데 owner_user_id 가 있다 | 가능 — 실제로 넣어 봤다 | 불가능 — 그 칸이 없다 | 불가능 |
| 봇인데 주인이 없다 | 가능 (지금 실재한다) | 불가능 — NOT NULL | 가능 |
| 봇인데 토큰이 없다 | 가능 | 불가능 — NOT NULL | 가능 |
| 봇 행이 있는데 actors.user_id 도 있다 | 가능 | 여전히 가능 — 표를 넘는 CHECK 는 못 쓴다 | 불가능 |
⚠️ 마지막 줄이 정직해야 하는 자리다. (b) 로 옮겨도 "봇인데 사람 계정도 달려 있다" 는 여전히 표현된다 — PostgreSQL 의 CHECK 는 한 행 안에서만 보므로 두 표에 걸친 규칙은 트리거나 복합 FK 같은 수법이 필요하고, 그건 사실상 kind 컬럼을 되살리는 것이라 이 레포가 기각한 방향으로 되돌아간다. 즉 (b) 는 「모든 모순을 없애는 것」이 아니라 「봇 칸을 NOT NULL 로 만들 수 있게 하는 것」이다.
질의는 어떻게 달라지나
| 하는 일 | 지금 (a) | 확장 (b) |
|---|---|---|
| 명부 · Message 작성자 · 참여자 목록 | actors 만 | actors 만 — 안 바뀐다 |
| 봇 토큰으로 신원 확정 | WHERE bot_token_hash = ? | bots JOIN actors — 조인 하나 |
| 내가 만든 봇 목록 | WHERE owner_user_id = ? | bots WHERE owner_user_id = ? JOIN actors |
| 사람인가 (isHuman) | user_id IS NULL | user_id IS NULL — 안 바뀐다 |
| 봇을 거둔다 (티켓 03) | UPDATE actors … | UPDATE bots … — 사람 행을 못 건드린다 |
옮긴다면 — 순서와 걸림돌
1 expand CREATE TABLE bots (actor_id PK FK, owner_user_id, bot_token_hash, deactivated_at)
INSERT INTO bots SELECT id, owner_user_id, bot_token_hash, deactivated_at
FROM actors WHERE bot_token_hash IS NOT NULL
2 양쪽 쓰기 새 코드는 bots 를 읽고 쓰되, actors 의 옛 칸도 함께 채운다(롤백 창)
3 전환 읽는 자리를 전부 bots 로 옮긴다
4 contract ALTER TABLE actors DROP COLUMN bot_token_hash, owner_user_id, deactivated_at
⚠️ 1단계에서 바로 막힌다. owner_user_id NOT NULL 로 만들려면 지금 있는 「주인 없는 봇」(옛 관리자 경로로 만들어진 것)에 넣을 값이 있어야 하는데 그 값이 존재하지 않는다. 셋 중 하나를 골라야 한다: ① 그 봇들을 특정 사람(예: 첫 admin)의 것으로 밀어 넣는다 — 없는 사실을 데이터에 적는 것이다 · ② owner_user_id 를 nullable 로 남긴다 — (b) 의 가장 큰 이득이 반쪽이 된다 · ③ 그 봇들을 deactivated_at 으로 거둔 뒤 NOT NULL 로 간다.
⭐ 확장안 (d) — 사람도 같은 모양으로
「사람과 봇이 공존하는 표를 만들고 차이만 따로 빼자」 — 이 제안의 요점은 그 공존하는 표를 새로 만들 필요가 없다는 것이다. actors 가 이미 그것이다(ADR 0001: 발화 주체를 사람 계정으로 한정하지 않는다). 그래서 실제로 하는 일은 차이를 양쪽으로 빼내고 FK 방향을 맞추는 것뿐이다.
지금 (a) actors.user_id ───→ users 사람만 특별대우: 「사람이다」가 actors 의 칸
확장 (b) actors ←── bots.actor_id 봇만 확장 표로
확장 (d) actors ←── users.actor_id ★ 사람도 확장 표로 — 대칭
actors ←── bots.actor_id
(b) 보다 나은 점 셋
| 무엇 | 왜 (d) 에서만 되나 |
|---|---|
| users.display_name 중복이 사라진다 | 이름은 공통이라 actors 에만 남는다. 지금은 두 곳에 있고 PATCH /actors/me 가 한쪽만 고쳐서 이미 갈라진다 — (b) 는 이 문제를 안 건드린다 |
| 대칭 | 지금은 사람만 actors.user_id 라는 특별대우를 받고 봇은 칸 셋이 흩어져 있다. (d) 에서는 둘 다 "actors 한 줄 + 자기 차이 한 줄" 이다 |
| 계정 삭제가 가능해진다 | 지금은 말을 한 사람의 계정을 지울 수 없다(아래 실측). (d) 에서는 users 행만 지우면 로그인은 사라지고 지나간 발화는 남는다 — 봇을 거두는 것과 같은 모양이다 |
⚠️ 지금 스키마에서는 계정을 지울 수 없다 — 테스트 DB 에서 실제로 눌러 본 결과:
DELETE FROM users WHERE id = 1;
ERROR: update or delete on table "actors" violates foreign key constraint
"messages_actor_id_fkey" on table "messages"
DETAIL: Key (id)=(1) is still referenced from table "messages".
actors.user_id 가 CASCADE 라 계정 삭제가 화자까지 지우려 들고, 그것을 messages 가 막는다. 아무도 이 경로를 안 쓰고 있어서(탈퇴 기능이 없다) 지금은 안 드러날 뿐이다.
대가 — (b) 보다 크다
| 비용 | 크기 |
|---|---|
| FK 방향을 뒤집는다 (actors.user_id → users.actor_id) | expand-contract 필수. user_id 를 읽는 자리가 16개 파일에 걸쳐 있다 |
| isHuman 이 「칸의 부재」에서 「행의 존재」로 | 가장 뜨거운 질의(메시지 목록)가 지금은 이미 조인한 actors 에서 user_id 를 공짜로 가져온다. (d) 에서는 LEFT JOIN users 가 하나 더 붙는다 — PK 조인이라 싸지만 공짜였던 것이 아니게 된다 |
| userId 와 actorId 가 같은 값이 된다 | 축이 하나로 통일되는 것은 이득이지만, 두 낱말을 섞어 써도 테스트가 통과한다. API 계약에서 어느 이름을 남길지 정해야 한다(티켓 04 의 POST /users/:userId/role 이 바로 걸린다) |
DBML 원문 (d) — dbdiagram.io 에 그대로 붙여넣는다
// ─────────────────────────────────────────────────────────────────────────────
// chatmu — 확장안 (d): **사람도 봇과 같은 모양으로** 붙는다
//
// ⚠️ 제안이다. 지금 도는 스키마가 아니다 — 다만 users.role 은 티켓 04 로 **이미 있다**
// (옮겨가는 것은 그 표의 PK 이지 컬럼이 아니다).
// 요점: 「사람과 봇이 공존하는 표」를 새로 만들 필요가 없다 — `actors` 가 이미 그것이다
// (ADR 0001). 그래서 하는 일은 **차이를 양쪽으로 빼내고 FK 방향을 맞추는 것**뿐이다.
// 지금: actors.user_id ──→ users (사람만 특별대우)
// (d): actors ←── users.actor_id (사람도 확장 표)
// actors ←── bots.actor_id (봇도 확장 표)
// ─────────────────────────────────────────────────────────────────────────────
Table actors {
id bigint [pk]
display_name text [not null, note: '★ 이름은 여기 하나뿐 — users 의 중복이 사라진다']
status_message text [note: 'Profile · 한 줄']
profile_body text [note: 'Profile · Message 본문과 같은 계약']
profile_body_format text [note: '본문과 한 몸']
created_at timestamptz [not null]
Note: '발화할 수 있는 주체 — 사람이든 봇이든 **여기 한 줄**이다 (ADR 0001)'
}
Table users {
actor_id bigint [pk, note: '★ 방향이 뒤집힌다 — 사람도 확장 표가 된다']
login_id text [unique, not null]
password_hash text [not null, note: 'bcrypt — 로그인은 사람만 한다']
password_changed_at timestamptz [not null, note: '옛 토큰을 끊는 기준선']
role text [not null, note: '★ 이미 있다(티켓 04) — regular | admin. 값 집합은 CHECK 가 진다']
Note: '**사람의 차이 = 로그인**. 이 행이 있으면 사람이다 (isHuman)'
}
Table bots {
actor_id bigint [pk]
owner_actor_id bigint [not null, note: '★ users 를 가리킨다 — 봇은 봇을 소유할 수 없다']
bot_token_hash text [not null, unique]
deactivated_at timestamptz [note: 'NULL 이 살아 있다']
Note: '**봇의 차이 = 주인과 토큰**'
}
Table rooms {
id bigint [pk]
name text [not null]
created_at timestamptz [not null]
}
Table room_participants {
room_id bigint [pk]
actor_id bigint [pk]
last_read_message_id bigint
joined_at timestamptz [not null]
}
Table messages {
id bigint [pk]
room_id bigint [not null]
actor_id bigint [not null, note: '작성자는 언제나 Actor — 안 바뀐다']
body text [not null]
body_format text [not null]
client_message_id uuid
created_at timestamptz [not null]
edited_at timestamptz
deleted_at timestamptz
}
Table direct_rooms {
room_id bigint [pk]
actor_a_id bigint [not null]
actor_b_id bigint [not null]
created_at timestamptz [not null]
}
// ── 차이는 양쪽으로, 대칭으로 ──────────────────────────────────────────────
Ref: actors.id - users.actor_id [delete: cascade]
Ref: actors.id - bots.actor_id [delete: cascade]
// 주인은 **users 를 가리킨다.** 봇의 actor 는 users 에 행이 없으므로 여기 들어올 수 없다 —
// 「봇이 봇을 낳는다」가 (d) 에서도 표현될 수 없다.
Ref: users.actor_id < bots.owner_actor_id [delete: cascade]
// ── 방·발화 (세 안에서 전부 똑같다) ────────────────────────────────────────
Ref: rooms.id < room_participants.room_id [delete: cascade]
Ref: actors.id < room_participants.actor_id [delete: cascade]
Ref: rooms.id < messages.room_id [delete: cascade]
Ref: actors.id < messages.actor_id
Ref: rooms.id - direct_rooms.room_id [delete: cascade]
Ref: actors.id < direct_rooms.actor_a_id [delete: cascade]
Ref: actors.id < direct_rooms.actor_b_id [delete: cascade]
⭐ (b) 는 버려지지 않는다 — (d) 의 첫 걸음이다. 두 안의 bots 표는 사실상 같고, 다른 것은 owner 가 가리키는 곳뿐이다(users.id → users.actor_id). 그래서 (a) → (b) → (d) 로 한 걸음씩 갈 수 있고, 중간에서 멈춰도 말이 된다.
그래서 지금은
| 언제 | 무엇 | 왜 |
|---|---|---|
| 지금 | CHECK (user_id IS NULL OR owner_user_id IS NULL) | 위 표의 1·4번 줄을 한 줄로 막는다 — (b) 가 4번을 못 막는 것을 보면 이건 (b) 의 대체재가 아니라 어차피 있어야 할 것이다. 되돌리기도 쉽다 |
| 사이클 뒤 | (d) 를 목표로, (b) 를 첫 걸음으로 | ⚠️ 티켓 02~04 가 끝났고 봇 칸은 셋에 머물렀다(role 은 users 로 갔다). 그때 「주인 없는 봇」 처리(위 ①②③)를 함께 정해야 하고, 그 결정이 손익을 정한다. (d) 가 개념적으로 더 옳다 — display_name 중복과 계정 삭제가 함께 풀린다 — 그러나 대가도 크므로 (b) 에서 멈춰도 말이 되게 순서를 잡는다 |
⚠️ 사이클이 끝났고, 그 수가 확정됐다 — 봇 전용 칸은 셋에 머물렀다 (bot_token_hash · owner_user_id · deactivated_at). 티켓 04 가 더한 role 은 봇이 아니라 사람의 칸이라 users 로 갔다.
⭐ 그래서 「칸 수」라는 기준만 보면 (a) + CHECK 가 이긴다 — 그런데도 (d) 를 고른 것은 기준이 그것 하나가 아니기 때문이다. (d) 가 푸는 나머지 둘은 칸 수와 무관하게 남아 있다: users.display_name 의 중복(지금도 화면에서 갈린다)과 말을 한 사람의 계정을 지울 수 없는 것. 칸 수는 (b) 를 저울질하던 기준 이었고, 사용자가 (d) 를 고른 근거는 대칭이었다.