최종 프로젝트: 게임 랭킹 서비스 DB 설계·성능·운영 점검
22강. 최종 프로젝트: 게임 랭킹 서비스 DB 설계·성능·운영 점검
1. 이번 강의에서 해결할 문제
테이블과 쿼리가 동작하는 것만으로 운영 준비가 끝나지 않습니다. 요구사항에서 시작해 ERD와 제약 근거를 만들고, 핵심 조회의 인덱스와 실행 계획, 동시 경기 저장 트랜잭션, FastAPI 연결, 마이그레이션, 백업·보안·모니터링까지 하나의 검토 가능한 결과물로 묶습니다.
2. 학습 목표
3. 핵심 개념
최종 프로젝트의 중심은 “어떤 SQL을 썼는가”가 아니라 “어떤 요구를 어떤 데이터 규칙으로 보장하는가”입니다. 원천 사건인 경기 결과와 현재 상태인 플레이어 평점, 시점 이력인 랭킹 스냅샷을 분리합니다. PK·FK·UNIQUE·CHECK는 잘못된 상태를 막고, 인덱스는 실제 API 조회를 지원하며, 트랜잭션은 한 경기의 결과와 평점 갱신을 하나의 업무 단위로 묶습니다.
시작 전 상태와 구현 순서
이 프로젝트는 기존 개발 DB에 바로 적용하지 않습니다. 새 game_ranking_final_dev 데이터베이스와 전용 로컬 역할을 준비하고, 다음 순서를 지킵니다.
| 순서 | 입력 | 완료 기준 |
|---|---|---|
| 1. 설계 | 요구사항·ERD | 여섯 엔터티와 관계·삭제 정책 설명 |
| 2. 스키마 | 빈 개발 DB | 모든 테이블·제약·인덱스 생성 |
| 3. fixture | 가상 시즌·플레이어·캐릭터·경기 | transaction 예제의 ID 존재 확인 |
| 4. 쓰기 | 진행 중 game 101 | 결과 2행, 평점 2명, game finished가 함께 반영 |
| 5. 실패 검증 | 같은 요청 재시도 | UNIQUE 오류와 평점 추가 변경 없음 |
| 6. 조회 계획 | ranking snapshot fixture | 실제 행·버퍼·스캔 노드 기록 |
| 7. 앱 연결 | 최소 권한 URL | SQLAlchemy mapping·Alembic head 일치 |
| 8. 운영 증거 | dump·로그·체크리스트 | 별도 DB restore와 핵심 조회 통과 |
명령을 실행하기 전에 SELECT current_database(), current_user;로 대상과 역할을 확인합니다. 운영 DB, 실제 사용자 데이터, 실제 비밀번호를 실습에 사용하지 않습니다.
ERD와 설계 근거
erDiagram
SEASONS ||--o{ GAMES : schedules
GAMES ||--|{ MATCH_RESULTS : contains
PLAYERS ||--o{ MATCH_RESULTS : participates
CHARACTERS ||--o{ MATCH_RESULTS : selected_as
SEASONS ||--o{ RANKING_SNAPSHOTS : groups
PLAYERS ||--o{ RANKING_SNAPSHOTS : receives
| 엔터티 | 저장하는 사실 | 분리 근거 |
|---|---|---|
| players | 플레이어 정체성과 현재 평점 | 경기와 독립된 수명, 닉네임 유일성 |
| seasons | 시즌 기간과 상태 | 경기·랭킹을 같은 기간 정책으로 묶음 |
| games | 한 경기의 모드·상태·시각 | 참가 결과보다 먼저 생성되는 사건 |
| characters | 선택 가능한 캐릭터 기준 정보 | 이름 중복과 변경을 결과 행에서 제거 |
| match_results | 플레이어의 한 경기 참가 결과 | Game과 Player의 N:M 관계 및 점수 속성 |
| ranking_snapshots | 시점별 순위와 평점 이력 | 현재 rating과 과거 화면을 분리 |
현재 평점은 빠른 현재 조회를 위한 상태이고 match_results는 변경하지 않는 경기 사건입니다. snapshot은 특정 시점의 랭킹 화면을 재현하기 위한 이력입니다. 세 값을 한 테이블에 덮어쓰면 과거 순위와 평점 변화 근거를 잃습니다.
4. 단계별 예시
4-1. 최종 PostgreSQL 스키마
파일 경로: C:\dev\game-ranking-service\database\sql\22-final-schema.sql
CREATE SCHEMA IF NOT EXISTS ranking;
CREATE TABLE ranking.seasons (
season_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
code varchar(20) NOT NULL UNIQUE,
starts_at timestamptz NOT NULL,
ends_at timestamptz,
status varchar(12) NOT NULL DEFAULT 'planned',
CONSTRAINT ck_seasons_period CHECK (ends_at IS NULL OR ends_at > starts_at),
CONSTRAINT ck_seasons_status CHECK (status IN ('planned', 'active', 'closed'))
);
CREATE TABLE ranking.players (
player_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
nickname varchar(30) NOT NULL,
rating integer NOT NULL DEFAULT 1000,
version integer NOT NULL DEFAULT 1,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT ck_players_rating CHECK (rating >= 0),
CONSTRAINT ck_players_version CHECK (version > 0)
);
CREATE UNIQUE INDEX uq_players_active_nickname
ON ranking.players (lower(nickname))
WHERE deleted_at IS NULL;
CREATE TABLE ranking.characters (
character_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
code varchar(30) NOT NULL UNIQUE,
display_name varchar(60) NOT NULL,
is_active boolean NOT NULL DEFAULT true
);
CREATE TABLE ranking.games (
game_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
season_id bigint NOT NULL REFERENCES ranking.seasons(season_id) ON DELETE RESTRICT,
mode varchar(20) NOT NULL,
status varchar(12) NOT NULL DEFAULT 'waiting',
started_at timestamptz NOT NULL,
ended_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT ck_games_status CHECK (status IN ('waiting', 'playing', 'finished', 'cancelled')),
CONSTRAINT ck_games_time CHECK (ended_at IS NULL OR ended_at >= started_at)
);
CREATE TABLE ranking.match_results (
result_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
game_id bigint NOT NULL REFERENCES ranking.games(game_id) ON DELETE CASCADE,
player_id bigint NOT NULL REFERENCES ranking.players(player_id) ON DELETE RESTRICT,
character_id bigint NOT NULL REFERENCES ranking.characters(character_id) ON DELETE RESTRICT,
score integer NOT NULL,
outcome varchar(8) NOT NULL,
rating_delta integer NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_results_game_player UNIQUE (game_id, player_id),
CONSTRAINT ck_results_score CHECK (score >= 0),
CONSTRAINT ck_results_outcome CHECK (outcome IN ('win', 'loss', 'draw'))
);
CREATE TABLE ranking.ranking_snapshots (
snapshot_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
season_id bigint NOT NULL REFERENCES ranking.seasons(season_id) ON DELETE RESTRICT,
player_id bigint NOT NULL REFERENCES ranking.players(player_id) ON DELETE RESTRICT,
rank_position integer NOT NULL,
rating integer NOT NULL,
recorded_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_snapshot_season_player_time UNIQUE (season_id, player_id, recorded_at),
CONSTRAINT ck_snapshot_rank CHECK (rank_position > 0),
CONSTRAINT ck_snapshot_rating CHECK (rating >= 0)
);
CREATE INDEX ix_results_player_game_desc
ON ranking.match_results (player_id, game_id DESC);
CREATE INDEX ix_games_season_finished_started
ON ranking.games (season_id, started_at DESC)
WHERE status = 'finished';
CREATE INDEX ix_snapshots_season_rank_time
ON ranking.ranking_snapshots (season_id, recorded_at DESC, rank_position);
CREATE INDEX ix_results_character
ON ranking.match_results (character_id);
스키마 핵심 줄과 제약 이유
- identity PK는 애플리케이션이 충돌 없는 ID를 직접 계산하지 않게 합니다.
uq_players_active_nickname은 삭제되지 않은 플레이어만 소문자 기준으로 닉네임을 유일하게 유지합니다. 탈퇴 이력은 보존할 수 있습니다.- games의 시간 CHECK는 종료가 시작보다 빠른 상태를 막고, match_results의 FK는 존재하는 경기·플레이어·캐릭터만 참조하게 합니다.
uq_results_game_player는 재시도된 요청이 같은 경기의 같은 플레이어 결과를 두 번 넣지 못하게 합니다.- 부분 인덱스
ix_games_season_finished_started는 finished 조회에 맞추되 모든 상태의 쓰기 비용을 늘리지 않습니다.
4-1-1. 재현 가능한 개발 fixture
뒤의 transaction 예시는 player 42·77, character 3·5, season 7, game 101을 사용합니다. 빈 DB에는 이 ID가 없으므로 먼저 가상 fixture를 넣습니다. OVERRIDING SYSTEM VALUE는 새 학습 DB에서 예제 ID를 고정하기 위한 용도이며 운영 seed 방식으로 그대로 사용하지 않습니다.
INSERT INTO ranking.seasons
(season_id, code, starts_at, ends_at, status)
OVERRIDING SYSTEM VALUE
VALUES (7, 'S2026-07', '2026-07-01T00:00:00Z', '2026-08-01T00:00:00Z', 'active');
INSERT INTO ranking.players
(player_id, nickname, rating, version)
OVERRIDING SYSTEM VALUE
VALUES (42, 'alpha_42', 1200, 1),
(77, 'beta_77', 1200, 1);
INSERT INTO ranking.characters
(character_id, code, display_name)
OVERRIDING SYSTEM VALUE
VALUES (3, 'knight', 'Knight'),
(5, 'mage', 'Mage');
INSERT INTO ranking.games
(game_id, season_id, mode, status, started_at)
OVERRIDING SYSTEM VALUE
VALUES (101, 7, 'ranked', 'playing', '2026-07-12T10:00:00Z');
INSERT INTO ranking.ranking_snapshots
(snapshot_id, season_id, player_id, rank_position, rating, recorded_at)
OVERRIDING SYSTEM VALUE
VALUES (1, 7, 42, 1, 1200, '2026-07-12T09:00:00Z'),
(2, 7, 77, 2, 1200, '2026-07-12T09:00:00Z');
-- 명시 ID 뒤의 자동 생성값이 충돌하지 않도록 개발용 sequence를 맞춥니다.
SELECT setval(pg_get_serial_sequence('ranking.seasons', 'season_id'), 7, true);
SELECT setval(pg_get_serial_sequence('ranking.players', 'player_id'), 77, true);
SELECT setval(pg_get_serial_sequence('ranking.characters', 'character_id'), 5, true);
SELECT setval(pg_get_serial_sequence('ranking.games', 'game_id'), 101, true);
SELECT setval(pg_get_serial_sequence('ranking.ranking_snapshots', 'snapshot_id'), 2, true);
createdb game_ranking_final_dev
psql -d game_ranking_final_dev -v ON_ERROR_STOP=1 --single-transaction -f .\database\sql\22-final-schema.sql
psql -d game_ranking_final_dev -c "\dt ranking.*"
psql -d game_ranking_final_dev -v ON_ERROR_STOP=1 --single-transaction -f .\database\sql\22-final-fixture.sql
psql -d game_ranking_final_dev -c "SELECT player_id,nickname,rating,version FROM ranking.players ORDER BY player_id;"
psql -d game_ranking_final_dev -c "SELECT game_id,status FROM ranking.games WHERE game_id=101;"
예상 시작값은 두 플레이어 rating 1200/version 1, game 101 status playing입니다. 값이 다르면 transaction을 실행하지 말고 fixture와 접속 DB부터 확인합니다.
4-2. 경기 결과와 평점의 원자적 저장
파일 경로: C:\dev\game-ranking-service\database\sql\22-record-game.sql
BEGIN;
SELECT player_id, rating, version
FROM ranking.players
WHERE player_id IN (42, 77)
ORDER BY player_id
FOR UPDATE;
INSERT INTO ranking.match_results
(game_id, player_id, character_id, score, outcome, rating_delta)
VALUES
(101, 42, 3, 1850, 'win', 25),
(101, 77, 5, 1210, 'loss', -25);
UPDATE ranking.players AS p
SET rating = p.rating + delta.rating_delta,
version = p.version + 1,
updated_at = now()
FROM (VALUES (42::bigint, 25), (77::bigint, -25)) AS delta(player_id, rating_delta)
WHERE p.player_id = delta.player_id;
UPDATE ranking.games
SET status = 'finished', ended_at = now()
WHERE game_id = 101 AND status = 'playing';
COMMIT;
transaction의 값 흐름과 검산
FOR UPDATE가 player 42와 77 행을 같은 오름차순으로 잠급니다.- match_results에 승자 +25와 패자 -25가 한 경기의 두 행으로 들어갑니다.
UPDATE ... FROM (VALUES ...)가 같은 delta를 현재 rating에 더하고 version을 1 올립니다.- game 101이 아직
playing일 때만finished로 바뀝니다. - 중간 문장 하나라도 실패하면 COMMIT하지 않고 전체 transaction을 롤백해야 합니다.
정상 실행 뒤 다음 읽기 전용 검증을 수행합니다.
SELECT player_id, rating, version
FROM ranking.players
WHERE player_id IN (42, 77)
ORDER BY player_id;
SELECT game_id, status, ended_at IS NOT NULL AS has_ended_at
FROM ranking.games WHERE game_id = 101;
SELECT player_id, score, outcome, rating_delta
FROM ranking.match_results WHERE game_id = 101
ORDER BY player_id;
player 42: 1200 + 25 = 1225, version 2
player 77: 1200 - 25 = 1175, version 2
game 101: finished, has_ended_at true
result: (42, 1850, win, +25), (77, 1210, loss, -25)
같은 SQL 파일을 다시 실행하면 uq_results_game_player 위반으로 실패해야 합니다. psql -v ON_ERROR_STOP=1로 실행해 연결을 끝낸 뒤 검증 쿼리를 다시 실행하면 rating이 1225·1175에서 더 바뀌지 않아야 합니다.
잠금 순서를 player_id 오름차순으로 고정해 교착 가능성을 줄입니다. uq_results_game_player는 재시도된 요청이 같은 결과를 중복 저장하지 못하게 하는 마지막 방어선입니다. API는 별도 idempotency key가 필요하다면 요청 테이블을 추가할 수 있습니다.
4-3. 핵심 조회와 실행 계획
파일 경로: C:\dev\game-ranking-service\database\sql\22-ranking-plan.sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT rs.player_id, p.nickname, rs.rank_position, rs.rating
FROM ranking.ranking_snapshots AS rs
JOIN ranking.players AS p ON p.player_id = rs.player_id
WHERE rs.season_id = 7
AND rs.recorded_at = (
SELECT max(recorded_at)
FROM ranking.ranking_snapshots
WHERE season_id = 7
)
AND p.deleted_at IS NULL
ORDER BY rs.rank_position
LIMIT 100;
psql -d game_ranking_final_dev -f .\database\sql\22-ranking-plan.sql | Tee-Object .\reports\22-ranking-plan.txt
인덱스 적용 전후의 스캔 노드, 예상·실제 행 수, shared buffer 수를 기록합니다. 데이터가 작은 개발 DB에서 순차 스캔이 나오는 것은 자연스러울 수 있으므로 운영과 비슷한 규모·분포의 테스트 데이터로 재측정합니다.
fixture 기준 최신 snapshot 시각은 두 행 모두 2026-07-12 09:00:00+00이며 조회 결과는 alpha_42 rank 1/rating 1200, beta_77 rank 2/rating 1200입니다. 현재 players 평점은 경기 transaction 후 1225·1175지만 snapshot은 이전 시점 기록이므로 달라도 정상입니다. 이 차이가 현재 상태와 시점 이력을 분리한 이유입니다.
4-4. FastAPI·SQLAlchemy 모델과 환경 변수
파일 경로: C:\dev\game-ranking-service\app\models\player.py
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, Integer, String, text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
class Player(Base):
__tablename__ = "players"
__table_args__ = {"schema": "ranking"}
id: Mapped[int] = mapped_column("player_id", BigInteger, primary_key=True)
nickname: Mapped[str] = mapped_column(String(30), nullable=False)
rating: Mapped[int] = mapped_column(Integer, nullable=False, server_default="1000")
version: Mapped[int] = mapped_column(Integer, nullable=False, server_default="1")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=text("now()")
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=text("now()")
)
ORM 매핑에서 확인할 줄
__table_args__ = {"schema": "ranking"}이 기본public이 아닌 실제 테이블 위치를 지정합니다.- Python 속성
id와 DB 열player_id를mapped_column("player_id", ...)로 연결합니다. server_default는 DB가 값을 생성하도록 하므로 Python 기본값만 둔 경우와 다릅니다.- ORM 모델은 스키마 변경 도구가 아닙니다. Alembic 리비전과 실제 DB의
alembic current가 일치해야 합니다.
파일 경로: C:\dev\game-ranking-service\.env.example
DATABASE_URL=postgresql+psycopg://ranking_app:CHANGE_ME@localhost:5432/game_ranking_dev
DB_POOL_SIZE=5
DB_MAX_OVERFLOW=5
Copy-Item .env.example .env
# .env의 CHANGE_ME는 로컬 비밀로 교체하고 Git에 추가하지 않습니다.
alembic upgrade head
alembic current
python -m uvicorn app.main:app --reload
SQLAlchemy 모델은 애플리케이션 매핑이고 Alembic 리비전이 환경별 실제 스키마 변경 이력입니다. .env는 .gitignore에 두며 운영에서는 배포 플랫폼의 비밀 저장소로 DATABASE_URL을 주입합니다. 애플리케이션 역할과 마이그레이션 역할은 분리합니다.
4-5. 백업·보안·성능 운영 체크리스트
문서 경로: C:\dev\game-ranking-service\docs\database\22-operations-checklist.md
## 백업·복구
- [ ] pg_dump 성공 여부와 파일 크기·checksum을 기록한다.
- [ ] 격리된 복구 DB에서 pg_restore와 핵심 조회를 정기 검증한다.
- [ ] RPO, RTO, 보관 기간, 외부 저장 위치와 암호화 책임자를 정한다.
## 보안
- [ ] API, 마이그레이션, 읽기 전용 역할을 분리한다.
- [ ] API 역할에는 CREATE, DROP, SUPERUSER 권한이 없다.
- [ ] .env와 dump 파일을 Git에 넣지 않고 비밀 교체 절차를 연습한다.
- [ ] SQL은 바인드 파라미터를 사용하고 로그에서 민감값을 마스킹한다.
## 성능·운영
- [ ] 핵심 API별 p95 지연 시간과 실행 계획 기준본이 있다.
- [ ] 인덱스 근거와 쓰기 비용을 기록한다.
- [ ] 오래 열린 트랜잭션, 연결 사용량, dead tuple을 관찰한다.
- [ ] deadlock·직렬화 실패는 제한된 트랜잭션 재시도로 처리한다.
- [ ] Alembic 리비전을 빈 DB와 기존 데이터가 있는 테스트 DB에서 검증한다.
5. 동작 원리
설계 근거가 ERD와 제약에 연결되면 잘못된 상태가 저장 경계에서 거부됩니다. 실제 조회와 맞춘 인덱스는 읽기 범위를 줄이고 EXPLAIN (ANALYZE, BUFFERS)가 효과를 확인합니다. 경기 결과와 평점 갱신은 같은 트랜잭션과 잠금 순서로 일관성을 유지합니다. 모델, 리비전, 환경 변수, 역할, 백업과 모니터링을 함께 관리해야 코드 변경이 운영 데이터에 안전하게 도달합니다.
6. 자주 하는 실수와 해결법
7. 직접 실습
8. 이해 점검 질문 3개
9. 핵심 요약
다음 학습 연결
운영 가능한 저장 경계를 만들었습니다. 이 데이터를 통계와 모델 입력으로 사용할 때의 품질·해석 한계를 배우려면 실제 문서인 데이터 분석·AI 기초 1강으로 이어갑니다.
최종 프로젝트: 게임 랭킹 서비스 DB 설계·성능·운영 점검 미니 퀴즈
선택 즉시 정답과 해설을 확인할 수 있습니다. 결과는 이 브라우저에만 저장됩니다.
학습을 마쳤나요?
직접 실습과 점검 질문까지 확인한 뒤 완료로 표시하세요.