봇과 사람을 어떻게 가를까

「봇을 users 처럼 별개 표로 두면 관리가 쉽지 않나」 에 대한 답. 갈래가 둘이고 하나는 이미 기각된 방향이라, 물어보신 이득이 실제로 나오는 모양은 아래 (b) 다.

지금과 무엇이 다른가

지금 (a) — 한 표에 섞여 있다

봇 전용 칸이 nullable 로 actors 안에 산다

users user_id owner_user_id actors id · user_id display_name status_message · profile_body profile_body_format bot_token_hash NULL 가능 owner_user_id NULL 가능 deactivated_at NULL 가능 room_partic… messages direct_rooms ⚠ 사람 행에도 봇 칸이 있다 ⚠ 봇 행에도 그 칸이 비어 있을 수 있다 ⚠ 셋이 서로 어긋나도 DB 는 모른다

확장 (b) — 봇 칸만 딸린 표로

actors 는 그대로 「발화 주체」이고, 봇 칸은 NOT NULL 이 된다

users user_id actors id · user_id display_name status_message · profile_body profile_body_format 1 : 0..1 bots actor_id PK · FK → actors owner_user_id NOT NULL → users bot_token_hash NOT NULL deactivated_at owner room_partic… messages direct_rooms ✔ 봇 아닌 행에는 그 칸이 아예 없다 ✔ 봇이면 주인과 토큰이 반드시 있다

⭐ 오른쪽 세 표(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 users       users        id     bigint login_id     text (!) password_hash     text (!) display_name     text (!) created_at     timestamptz (!) password_changed_at     timestamptz (!) actors       actors        id     bigint user_id     bigint display_name     text (!) status_message     text profile_body     text profile_body_format     text created_at     timestamptz (!) users:e->actors:w * 1 bots       bots        actor_id     bigint owner_user_id     bigint (!) bot_token_hash     text (!) deactivated_at     timestamptz created_at     timestamptz (!) users:e->bots:w * 1 actors:e->bots:w 1 1 room_participants       room_participants        room_id     bigint actor_id     bigint last_read_message_id     bigint joined_at     timestamptz (!) actors:e->room_participants:w * 1 messages       messages        id     bigint room_id     bigint (!) actor_id     bigint (!) body     text (!) body_format     text (!) client_message_id     uuid created_at     timestamptz (!) edited_at     timestamptz deleted_at     timestamptz actors:e->messages:w * 1 direct_rooms       direct_rooms        room_id     bigint actor_a_id     bigint (!) actor_b_id     bigint (!) created_at     timestamptz (!) actors:e->direct_rooms:w * 1 actors:e->direct_rooms:w * 1 rooms       rooms        id     bigint name     text (!) created_at     timestamptz (!) rooms:e->room_participants:w * 1 rooms:e->messages:w * 1 rooms:e->direct_rooms:w 1 1
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
dbml actors       actors        id     bigint display_name     text (!) status_message     text profile_body     text profile_body_format     text created_at     timestamptz (!) users       users        actor_id     bigint login_id     text (!) password_hash     text (!) password_changed_at     timestamptz (!) role     text (!) actors:e->users:w 1 1 bots       bots        actor_id     bigint owner_actor_id     bigint (!) bot_token_hash     text (!) deactivated_at     timestamptz actors:e->bots:w 1 1 room_participants       room_participants        room_id     bigint actor_id     bigint last_read_message_id     bigint joined_at     timestamptz (!) actors:e->room_participants:w * 1 messages       messages        id     bigint room_id     bigint (!) actor_id     bigint (!) body     text (!) body_format     text (!) client_message_id     uuid created_at     timestamptz (!) edited_at     timestamptz deleted_at     timestamptz actors:e->messages:w * 1 direct_rooms       direct_rooms        room_id     bigint actor_a_id     bigint (!) actor_b_id     bigint (!) created_at     timestamptz (!) actors:e->direct_rooms:w * 1 actors:e->direct_rooms:w * 1 users:e->bots:w * 1 rooms       rooms        id     bigint name     text (!) created_at     timestamptz (!) rooms:e->room_participants:w * 1 rooms:e->messages:w * 1 rooms:e->direct_rooms:w 1 1

(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) 를 고른 근거는 대칭이었다.