콘텐츠로 이동

데이터베이스 — 트랜잭션과 ACID

개요 — 트랜잭션이 게임 서버에서 중요한 이유

섹션 제목: “개요 — 트랜잭션이 게임 서버에서 중요한 이유”

온라인 게임 서버에서는 매 초 수천 건의 데이터 변경이 발생합니다.

  • 두 플레이어 간 아이템 거래가 진행 중에 서버가 다운된다면?
  • 같은 희귀 아이템을 두 명이 동시에 구매하려고 한다면?
  • 경험치를 얻었는데 레벨업 처리가 실패하면?
  • 결제가 완료됐는데 아이템 지급이 누락된다면?

이런 상황을 방지하는 핵심 메커니즘이 바로 트랜잭션(Transaction)입니다. ACID 속성을 이해하면 데이터 무결성을 보장하는 게임 DB를 설계할 수 있습니다.


트랜잭션은 데이터베이스에서 하나의 논리적 작업 단위를 이루는 연산의 집합입니다. 트랜잭션 내의 모든 연산은 완전히 성공하거나 완전히 실패해야 합니다.

-- 아이템 거래 트랜잭션 예시
BEGIN TRANSACTION;
-- 1. 판매자의 인벤토리에서 아이템 제거
UPDATE inventory
SET item_count = item_count - 1
WHERE player_id = 'seller' AND item_id = 'sword_001';
-- 2. 구매자의 인벤토리에 아이템 추가
UPDATE inventory
SET item_count = item_count + 1
WHERE player_id = 'buyer' AND item_id = 'sword_001';
-- 3. 판매자 골드 증가
UPDATE players SET gold = gold + 5000 WHERE player_id = 'seller';
-- 4. 구매자 골드 감소
UPDATE players SET gold = gold - 5000 WHERE player_id = 'buyer';
-- 모두 성공 시 확정
COMMIT;
-- 하나라도 실패 시 모두 취소
-- ROLLBACK;
[Active] -> [Partially Committed] -> [Committed]
| |
| └-> [Failed] -> [Aborted]
└-> [Failed] -> [Aborted]
Active: 트랜잭션 실행 중
Partially Committed: 마지막 연산 완료, 커밋 전
Committed: 영구적으로 DB에 반영됨
Failed: 오류 발생
Aborted: 롤백 완료, 트랜잭션 전 상태로 복원

ACID는 트랜잭션이 안전하게 수행되기 위한 4가지 속성입니다.

트랜잭션의 모든 연산은 완전히 실행되거나 전혀 실행되지 않아야 합니다. “All or Nothing”의 원칙입니다.

아이템 거래 시나리오:
1. 판매자 아이템 제거 - 성공
2. 구매자 아이템 추가 - 성공
3. 판매자 골드 증가 - 성공
4. 구매자 골드 감소 - 실패! (잔액 부족)
원자성 보장 시: 1, 2, 3 모두 롤백 -> 거래 없었던 것으로
원자성 미보장 시: 구매자가 아이템을 받고 골드도 내지 않는 버그 발생

구현 방법 — WAL (Write-Ahead Log): 변경 사항을 DB에 쓰기 전에 먼저 로그에 기록합니다. 장애 발생 시 로그를 보고 완료된 트랜잭션은 재실행(Redo), 미완료 트랜잭션은 취소(Undo)합니다.

트랜잭션 실행 전후에 DB는 항상 일관된 상태를 유지해야 합니다. 정의된 제약 조건과 규칙이 항상 만족되어야 합니다.

-- 일관성 제약 예시
CREATE TABLE players (
player_id VARCHAR(50) PRIMARY KEY,
gold INT NOT NULL CHECK (gold >= 0), -- 골드는 0 이상
level INT NOT NULL CHECK (level BETWEEN 1 AND 100)
);
-- 골드가 음수가 되는 트랜잭션은 제약 위반으로 자동 실패
UPDATE players SET gold = gold - 99999 WHERE player_id = 'poor_player';
-- Error: CHECK constraint failed: gold >= 0

동시에 실행되는 트랜잭션들은 서로에게 영향을 주지 않아야 합니다. 각 트랜잭션은 혼자 실행되는 것처럼 동작해야 합니다.

동시 처리 문제 예시 (격리성 미보장):
시간 트랜잭션 A (플레이어 X가 아이템 구매) 트랜잭션 B (GM이 골드 확인)
T1 READ gold = 1000
T2 READ gold = 1000 <-- A의 미완료 작업 읽음
T3 WRITE gold = 0
T4 COMMIT
B가 읽은 값 1000은 최종 값 0과 다름 -> 더티 리드(Dirty Read) 문제

커밋된 트랜잭션의 결과는 영구적으로 보존되어야 합니다. 시스템 장애가 발생해도 손실되지 않아야 합니다.

지속성 보장 방법:
1. WAL(Write-Ahead Log): 디스크에 변경 로그 먼저 기록
2. 체크포인트(Checkpoint): 주기적으로 메모리의 변경 내용을 디스크에 플러시
3. 데이터베이스 복제(Replication): 여러 서버에 동일 데이터 유지
게임 서버 적용:
서버가 갑자기 다운되어도 "COMMIT 완료"된 거래 내역은 복구 후에도 유지

격리성을 100% 보장하면 성능이 크게 저하됩니다. 실제로는 격리 수준을 선택해 성능과 정확성 사이의 균형을 맞춥니다.

격리 수준별 허용되는 이상 현상

섹션 제목: “격리 수준별 허용되는 이상 현상”
격리 수준Dirty ReadNon-Repeatable ReadPhantom Read성능
READ UNCOMMITTED발생발생발생가장 빠름
READ COMMITTED방지발생발생빠름
REPEATABLE READ방지방지발생보통
SERIALIZABLE방지방지방지가장 느림

더티 리드 (Dirty Read):

-- 트랜잭션 A
UPDATE players SET gold = 9999 WHERE player_id = 'hacker';
-- 아직 COMMIT 안 함
-- 트랜잭션 B (READ UNCOMMITTED 격리 수준)
SELECT gold FROM players WHERE player_id = 'hacker';
-- 9999 읽음! (커밋 안 된 데이터)
-- 트랜잭션 A
ROLLBACK; -- A가 롤백하면 B가 읽은 9999는 존재하지 않는 데이터

반복 불가능한 읽기 (Non-Repeatable Read):

-- 트랜잭션 A
SELECT gold FROM players WHERE player_id = 'user1'; -- 결과: 1000
-- 트랜잭션 B (중간에 커밋)
UPDATE players SET gold = 500 WHERE player_id = 'user1';
COMMIT;
-- 트랜잭션 A (같은 트랜잭션 내 재조회)
SELECT gold FROM players WHERE player_id = 'user1'; -- 결과: 500 (달라짐!)

팬텀 리드 (Phantom Read):

-- 트랜잭션 A
SELECT COUNT(*) FROM items WHERE owner_id = 'user1'; -- 결과: 5
-- 트랜잭션 B (중간에 커밋)
INSERT INTO items (owner_id, item_name) VALUES ('user1', 'new_sword');
COMMIT;
-- 트랜잭션 A (재조회)
SELECT COUNT(*) FROM items WHERE owner_id = 'user1'; -- 결과: 6 (행이 추가됨!)

충돌이 자주 발생한다고 가정하고 데이터 접근 시 즉시 락을 걸어 다른 트랜잭션의 접근을 차단합니다.

-- SELECT FOR UPDATE: 조회와 동시에 행에 배타적 락
BEGIN TRANSACTION;
SELECT gold FROM players
WHERE player_id = 'user1'
FOR UPDATE; -- 다른 트랜잭션이 이 행 수정 불가
-- 비즈니스 로직 처리...
UPDATE players SET gold = gold - 100 WHERE player_id = 'user1';
COMMIT;
-- 적합한 경우: 실시간 경매, 동시 아이템 구매

충돌이 드물다고 가정하고 락 없이 읽은 후, 업데이트 시 데이터가 변경됐는지 확인합니다.

-- 버전 번호(version) 컬럼을 이용한 낙관적 락
BEGIN TRANSACTION;
-- 현재 버전 확인하며 읽기
SELECT gold, version FROM players WHERE player_id = 'user1';
-- gold = 1000, version = 5
-- 업데이트 시 버전 확인
UPDATE players
SET gold = 900, version = version + 1
WHERE player_id = 'user1' AND version = 5; -- 버전이 변경됐으면 0행 업데이트
-- 영향받은 행이 0이면 다른 트랜잭션이 먼저 수정한 것 -> 롤백 후 재시도
COMMIT;

-- 안전한 아이템 거래 프로시저
CREATE PROCEDURE TradeItem(
seller_id VARCHAR(50),
buyer_id VARCHAR(50),
item_id VARCHAR(50),
price INT
)
BEGIN
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
ROLLBACK;
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Trade failed';
END;
START TRANSACTION;
-- 판매자 검증 (FOR UPDATE로 동시 거래 방지)
SELECT 1 FROM inventory
WHERE player_id = seller_id AND item_id = item_id AND quantity >= 1
FOR UPDATE;
-- 구매자 골드 검증
SELECT 1 FROM players
WHERE player_id = buyer_id AND gold >= price
FOR UPDATE;
-- 거래 실행
UPDATE inventory SET quantity = quantity - 1
WHERE player_id = seller_id AND item_id = item_id;
UPDATE inventory SET quantity = quantity + 1
WHERE player_id = buyer_id AND item_id = item_id;
UPDATE players SET gold = gold + price WHERE player_id = seller_id;
UPDATE players SET gold = gold - price WHERE player_id = buyer_id;
-- 거래 로그 기록
INSERT INTO trade_log (seller_id, buyer_id, item_id, price, trade_time)
VALUES (seller_id, buyer_id, item_id, price, NOW());
COMMIT;
END;
-- 네트워크 재전송으로 인한 중복 요청 방지
CREATE TABLE item_grants (
request_id VARCHAR(100) PRIMARY KEY, -- 클라이언트가 생성한 고유 ID
player_id VARCHAR(50),
item_id VARCHAR(50),
granted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 아이템 지급 시
INSERT INTO item_grants (request_id, player_id, item_id)
VALUES ('req_abc123', 'user1', 'sword_001')
ON DUPLICATE KEY UPDATE request_id = request_id; -- 이미 있으면 무시
-- 클라이언트가 같은 request_id로 재전송해도 중복 지급 없음

6. MVCC (Multi-Version Concurrency Control)

섹션 제목: “6. MVCC (Multi-Version Concurrency Control)”

대부분의 현대 DB(PostgreSQL, MySQL InnoDB, Oracle)는 락 기반 대신 MVCC를 사용해 읽기와 쓰기가 서로를 차단하지 않도록 합니다.

MVCC 동작 원리:
트랜잭션 A (READ, 스냅샷 시점 T1) 트랜잭션 B (WRITE)
T1: A가 스냅샷 획득 (gold=1000)
T2: B가 gold=500으로 UPDATE
T3: B COMMIT
T4: A가 gold 재조회
-> 스냅샷(T1 기준) 버전 읽음 = 1000 (B의 변경 안 보임)
-> LOCK 없이 일관된 읽기 가능!
구현 방식:
- 각 행에 버전 번호(xmin, xmax) 저장
- UPDATE 시 기존 행 무효화 + 새 버전 행 삽입
- 트랜잭션은 자신의 시작 시점 이전 커밋된 버전만 읽음
- 오래된 버전은 VACUUM(PostgreSQL) 또는 Purge(MySQL)로 정리
-- PostgreSQL에서 REPEATABLE READ 설정 시 MVCC 동작
BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- 스냅샷 획득 (이후 다른 트랜잭션의 변경이 보이지 않음)
SELECT gold FROM players WHERE player_id = 'user1'; -- 1000
-- 다른 트랜잭션이 gold를 500으로 변경하고 COMMIT해도:
SELECT gold FROM players WHERE player_id = 'user1'; -- 여전히 1000
-- 같은 스냅샷을 사용하므로 Non-Repeatable Read 없음
COMMIT;

트랜잭션 내에서 중간 지점을 설정해 전체 롤백 없이 일부만 취소할 수 있습니다.

BEGIN TRANSACTION;
-- 1단계: 플레이어 골드 차감
UPDATE players SET gold = gold - 5000 WHERE player_id = 'buyer';
SAVEPOINT after_gold_deduct; -- 중간 저장점
-- 2단계: 아이템 추가 시도
INSERT INTO inventory (player_id, item_id, quantity)
VALUES ('buyer', 'rare_sword', 1);
-- 만약 인벤토리가 가득 찼다면?
-- ROLLBACK TO SAVEPOINT after_gold_deduct; -- 골드 차감은 유지, 아이템만 취소
-- 3단계: 거래 로그 기록
INSERT INTO trade_log (buyer_id, item_id, gold, time)
VALUES ('buyer', 'rare_sword', 5000, NOW());
COMMIT;
-- 게임 서버 활용:
-- 퀘스트 보상 지급 시 아이템 A 지급 성공, 아이템 B 지급 실패 ->
-- SAVEPOINT로 A만 유지하고 B 관련 작업만 롤백

-- 교착상태 발생 시나리오
-- 트랜잭션 A: user1 -> user2 순서로 락
-- 트랜잭션 B: user2 -> user1 순서로 락
-- 해결책 1: 항상 동일한 순서로 락 획득 (player_id 오름차순)
BEGIN;
-- 낮은 ID를 먼저 잠금
SELECT * FROM players WHERE player_id IN ('user1', 'user2')
ORDER BY player_id -- 정렬로 일관된 락 순서 보장
FOR UPDATE;
-- 이후 업데이트 진행
COMMIT;
-- 해결책 2: 락 타임아웃 설정
SET innodb_lock_wait_timeout = 5; -- MySQL: 5초 후 타임아웃
-- SET lock_timeout = '5s'; -- PostgreSQL
-- 해결책 3: 데드락 감지 후 자동 롤백 (DB가 자동 처리)
-- MySQL/PostgreSQL은 데드락 감지 시 한 트랜잭션을 자동으로 희생(victim)으로 선택해 롤백
-- 애플리케이션은 데드락 에러(ERROR 1213)를 받으면 재시도 로직 필요
-- 게임 서버 재시도 패턴 (Python/C# 의사 코드)
-- for attempt in range(3):
-- try:
-- execute_trade_transaction()
-- break
-- except DeadlockException:
-- sleep(random(0.1, 0.5)) # 랜덤 백오프
-- continue

전통적 RDBMS (MySQL, PostgreSQL):
- 완전한 ACID 보장
- 강력한 일관성 (Strong Consistency)
- 수직 확장 중심
NoSQL (MongoDB, Cassandra, DynamoDB):
- BASE (Basically Available, Soft state, Eventually consistent)
- 최종 일관성 (Eventual Consistency): 잠시 다른 값이 보일 수 있음
- 수평 확장에 유리
- 최근에는 ACID 트랜잭션을 지원하는 NoSQL도 증가
(MongoDB 4.0+: 다중 문서 트랜잭션 지원)
게임 서버 선택 기준:
- 결제, 아이템 거래, 랭킹 등 데이터 정확성이 중요한 경우: RDBMS + ACID
- 채팅 로그, 분석 데이터, 세션 정보 등 유연한 스키마가 필요한 경우: NoSQL
분산 트랜잭션 (2PC, Two-Phase Commit):
- 여러 DB 서버에 걸친 트랜잭션을 조율하는 프로토콜
- Phase 1 (Prepare): 모든 참여자에게 커밋 준비 여부 확인
- Phase 2 (Commit/Abort): 모두 준비 완료 시 커밋, 하나라도 실패 시 전체 롤백
- 성능 비용이 크고 조율자(coordinator) 장애 시 블로킹 문제 발생

트랜잭션이란? 데이터베이스에서 하나의 논리적 작업 단위를 이루는 연산의 집합입니다. 모든 연산이 완전히 성공하거나 모두 실패(롤백)해야 합니다.

ACID란?

  • 원자성(Atomicity): All or Nothing
  • 일관성(Consistency): 트랜잭션 전후 DB 제약 조건 유지
  • 격리성(Isolation): 동시 트랜잭션 간 간섭 없음
  • 지속성(Durability): 커밋된 데이터는 영구 보존

격리 수준 중 어느 것을 선택해야 하는가? MySQL InnoDB의 기본값은 REPEATABLE READ입니다. 게임 서버에서는 대부분의 조회가 READ COMMITTED로도 충분하지만, 재고 차감 등 정확한 동시성 제어가 필요한 경우 비관적/낙관적 락을 함께 사용합니다.

교착상태(Deadlock)란? 두 트랜잭션이 서로가 가진 락을 기다려 영원히 진행이 안 되는 상태입니다. 항상 동일한 순서로 락을 획득하거나, 타임아웃을 설정해 해결합니다.