
PlayTalk은 처음부터 DB 없이 시작한 사이드 프로젝트입니다. 회원 정보는 users.json, 게스트 전적은 guestStats.json, 친구 관계는 friends.json, 점수 랭킹은 rankings.json. 파일 네 개에 JSON을 통째로 읽고 통째로 쓰는 방식이었어요. 게임이 두세 개일 때는 이게 제일 빨랐습니다. 스키마 마이그레이션도 없고, 서버 켜면 바로 돌아가니까요.
그런데 게임이 열 개를 넘고, 로그인 회원과 게스트가 섞이고, 친구 기능까지 붙으면서 슬슬 이상해지기 시작했습니다.
파일 저장소가 아팠던 지점
첫째, 같은 사람이 파일마다 다른 이름으로 존재했습니다. 로그인 사용자는 users.json 안의 id, 게스트는 guestStats.json의 uuid. 그래서 "친구 목록을 보여줘" 같은 요청 하나를 처리하려면 두 저장소를 다 뒤지고, 어느 쪽에서 왔는지에 따라 다른 코드를 타야 했습니다. 랭킹도 마찬가지였고요.
둘째, 게스트가 회원가입하면 기록을 옮겨줘야 했습니다. 게스트로 200판 하고 가입한 사람의 전적이 날아가면 안 되니까요. 이 병합 로직이 JSON에서는 "게스트 전적을 읽고 → 회원 전적과 게임별로 합치고 → 회원 쪽에 쓰고 → 게스트 쪽을 지운다"는 4단계 수작업이었습니다. 중간에 서버가 죽으면? 생각하기 싫었습니다.
셋째, 집계가 전부 메모리였습니다. 대전 랭킹은 서버가 뜰 때 전적을 읽어 메모리에 집계를 만들고, 판이 끝날 때마다 그 집계를 갱신하는 구조였어요. 그러다 보니 "이번 주 랭킹"처럼 조건이 하나만 추가돼도 손댈 방법이 없었습니다. 판 단위 기록이 애초에 없었으니까요. 남아 있는 건 누적 합계뿐이었습니다.
node:sqlite — 외부 패키지 0개
DB를 붙이기로 하면서 제일 먼저 정한 건 의존성을 늘리지 않는다였습니다. 개인 서버에서 돌리는 서비스라 관리 포인트를 늘리고 싶지 않았어요. 그래서 별도 DB 서버(PostgreSQL, MySQL)는 후보에서 뺐고, better-sqlite3 같은 네이티브 모듈도 빌드 툴체인 때문에 피하고 싶었습니다.
결론은 Node 내장 node:sqlite 였습니다.
import { DatabaseSync } from 'node:sqlite';
export function openDb(path = DB_FILE) {
const db = new DatabaseSync(path);
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA journal_mode = WAL');
db.exec(DDL);
return db;
}
package.json에 추가한 의존성은 하나도 없습니다. 대신 Node 24가 필요해서 Dockerfile의 베이스 이미지를 node:20-alpine에서 node:24-alpine으로 올렸습니다. 동기 API(DatabaseSync)라 async/await 지옥도 없고, 게임 서버 특성상 쿼리가 전부 짧아서 동기 호출이 오히려 코드를 단순하게 만들었어요.
핵심 결정: 신원 키를 하나로 합친다
스키마를 짜면서 가장 잘한 결정은 이거였습니다. 로그인 사용자와 게스트를 같은 테이블의 같은 컬럼에 넣는다. 대신 키에 접두사를 붙였습니다.
- 로그인 사용자: u: + userId
- 게스트: g: + uuid
CREATE TABLE players (
key TEXT PRIMARY KEY, -- 'u:123' | 'g:abcd-...'
kind TEXT NOT NULL CHECK(kind IN ('user','guest')),
nickname TEXT,
emoji TEXT,
created_at INTEGER NOT NULL,
last_seen INTEGER,
username TEXT UNIQUE,
password_hash TEXT
);
이 한 줄 덕분에 아래 테이블들이 전부 player_key 하나만 참조하면 끝났습니다.
CREATE TABLE plays ( -- 판 단위 원천
id INTEGER PRIMARY KEY AUTOINCREMENT,
player_key TEXT NOT NULL REFERENCES players(key) ON DELETE CASCADE ON UPDATE CASCADE,
game TEXT NOT NULL,
mode TEXT NOT NULL CHECK(mode IN ('versus','solo','score')),
result TEXT CHECK(result IN ('win','draw','loss')),
score INTEGER,
match_id TEXT,
played_at INTEGER NOT NULL
);
CREATE INDEX idx_plays_game_time ON plays (game, played_at);
CREATE INDEX idx_plays_player_time ON plays (player_key, played_at);
CREATE TABLE player_game_stats ( -- 집계(레벨·전체 랭킹용)
player_key TEXT NOT NULL REFERENCES players(key) ON DELETE CASCADE ON UPDATE CASCADE,
game TEXT NOT NULL,
solo INTEGER NOT NULL DEFAULT 0,
plays INTEGER NOT NULL DEFAULT 0,
wins INTEGER NOT NULL DEFAULT 0,
draws INTEGER NOT NULL DEFAULT 0,
best_score INTEGER,
PRIMARY KEY (player_key, game, solo)
);
plays(판 단위 원천)와 player_game_stats(집계)를 둘 다 둔 게 포인트입니다. 레벨과 전체 랭킹은 집계 테이블만 읽으면 되니 빠르고, "이번 주 랭킹" 같은 건 원천에서 새로 계산하면 됩니다. 이 선택이 4편에서 어떻게 회수되는지 보실 수 있습니다.
FK CASCADE가 지워준 코드
신원 키를 통합하고 외래 키를 제대로 걸었더니, 가장 골치 아팠던 두 로직이 SQL 한 줄로 줄었습니다.
게스트 → 회원 승격(회원가입 병합)
UPDATE players SET key = 'u:123', kind = 'user', ... WHERE key = 'g:abcd-...'
기본 키가 바뀌면 ON UPDATE CASCADE가 plays·player_game_stats·friendships·friend_requests의 참조를 전부 따라옵니다. 게임별로 전적을 합치던 4단계 수작업이 사라졌어요.
게스트 TTL 정리(14일 미활동)
DELETE FROM players WHERE key IN (...)
ON DELETE CASCADE가 그 게스트의 판 기록·집계·친구 관계를 알아서 걷어갑니다. 이것도 원래는 파일 네 개를 순회하며 손으로 지우던 코드였습니다.
바깥은 그대로
전환하면서 세운 원칙이 하나 더 있습니다. 외부 동작은 한 글자도 바꾸지 않는다. 소켓 이벤트 이름, HTTP 응답의 JSON 모양, XP/레벨 공식 전부 그대로 두고 저장 계층만 갈아치웠습니다. 각 모듈(auth/guestStats/friends/rankings)의 함수 시그니처를 유지하고 내부만 DB 호출로 교체한 거죠.
덕분에 클라이언트는 단 한 줄도 안 고쳤습니다. 서버 12개 파일에서 +680/−580 라인이 오갔는데, 사용자 입장에서는 아무 일도 일어나지 않은 배포였습니다. 정확히는 — 아무 일도 안 일어났어야 했습니다. 3편에서 무슨 일이 있었는지 이야기하겠습니다.
다음 편에서는
스키마를 다 짜고 나면 남는 진짜 문제가 있습니다. 이미 서비스 중인 데이터를 어떻게 옮길 것인가. 게다가 제 배포 파이프라인에는 "배포 전에 이 스크립트 한 번 돌려주세요" 를 끼워 넣을 자리가 없었습니다. 다음 편은 그 제약 안에서 마이그레이션을 어떻게 자동화했는지, 그리고 실패했을 때 일부러 서버를 안 띄운 이야기입니다.
👉 PlayTalk 에서 직접 플레이해 보실 수 있습니다.