게임 서버에서 포인트 차감, 아이템 구매, 게임 결과에 따른 보상 지급은 단순한 데이터 변경이 아니다. 여러 요청이 동시에 발생하고, 네트워크가 끊기거나, 서버가 중간에 종료되더라도 플레이어의 재화와 아이템이 정확하게 유지되어야 하는 정산 시스템이다.
예를 들어 플레이어가 1,000포인트를 보유하고 있을 때 700포인트짜리 아이템을 동시에 두 번 구매한다고 가정하자. 서버가 잔액을 검증하는 시점과 차감하는 시점을 적절하게 제어하지 못하면 두 구매가 모두 성공하여 잔액이 음수가 되거나, 포인트는 차감되었지만 아이템은 지급되지 않는 문제가 발생할 수 있다.
게임 경제 시스템을 설계할 때는 다음 원칙을 지켜야 한다.
- 잔액과 거래 내역을 일관되게 관리한다.
- 포인트 차감과 아이템 지급을 원자적으로 처리한다.
- 동일한 구매나 보상이 중복 처리되지 않도록 한다.
- 네트워크 오류와 서버 장애 이후에도 결과를 복구할 수 있어야 한다.
- 클라이언트가 아닌 서버가 최종 정산 결과를 결정한다.
이 글에서는 Node.js, TypeScript, MySQL, Redis를 활용하는 멀티플레이어 게임 서버를 기준으로 각 개념과 구현 방법을 살펴본다.
포인트 잔액 관리
포인트 잔액은 플레이어가 현재 사용할 수 있는 재화의 양이다.
예를 들어 플레이어가 10,000포인트를 보유하고 있고, 3,000포인트짜리 아이템을 구매했다면 잔액은 7,000포인트가 된다.
가장 단순한 구현은 다음과 같다.
UPDATE users
SET points = points - 3000
WHERE id = 1001;
하지만 이 쿼리만으로는 안전한 구매를 보장할 수 없다. 잔액이 충분한지 확인하지 않으면 음수 잔액이 발생할 수 있기 때문이다.
조건을 추가하면 잔액 확인과 차감을 하나의 SQL 문으로 처리할 수 있다.
UPDATE users
SET points = points - 3000
WHERE id = 1001
AND points >= 3000;
영향받은 행이 1개라면 차감에 성공한 것이고, 0개라면 사용자가 존재하지 않거나 잔액이 부족한 것이다. 실제 서비스에서는 두 원인을 구분하기 위한 추가 조회가 필요할 수 있다.
이 방식의 중요한 장점은 잔액을 별도로 조회한 후 차감하는 것보다 동시성 문제에 강하다는 것이다. MySQL의 InnoDB는 해당 행을 수정하는 과정에서 필요한 잠금을 사용하므로, 동시에 여러 구매 요청이 들어오더라도 조건을 만족하는 요청만 차감할 수 있다.
다만 잔액 차감과 아이템 지급을 함께 보장하려면 트랜잭션이 필요하다.
포인트 원장(Ledger)
잔액만 저장하면 현재 상태는 알 수 있지만, 그 상태가 만들어진 과정은 알 수 없다.
예를 들어 포인트가 5,000에서 2,000으로 감소했다면 다음과 같은 원인이 있을 수 있다.
- 아이템 구매로 3,000포인트 차감
- 운영자의 포인트 회수
- 잘못 지급된 보상의 취소
- 다른 서비스와의 정산
이런 문제를 해결하기 위해 포인트 원장(Ledger) 을 사용한다.
원장은 포인트의 모든 증감 내역을 기록하는 거래 기록이다. 잔액 테이블이 현재 상태를 나타낸다면 원장은 상태가 변경된 근거를 나타낸다.
잔액 테이블
현재 사용할 수 있는 포인트
5,000 P
포인트 원장 예시
| 거래 | 변동 | 거래 후 잔액 |
| 최초 지급 | +10,000 | 10,000 |
| 아이템 구매 | -3,000 | 7,000 |
| 게임 보상 | +2,000 | 9,000 |
| 아이템 구매 | -4,000 | 5,000 |
예시를 위한 단순화된 원장이다. 실제 시스템에서는 거래 ID, 거래 유형, 대상 계정, 생성 시각, 관련 주문 ID 등을 함께 저장한다.
원장 테이블은 다음과 같이 설계할 수 있다.
CREATE TABLE point_ledger (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
transaction_id VARCHAR(100) NOT NULL,
amount BIGINT NOT NULL,
balance_after BIGINT NOT NULL,
transaction_type VARCHAR(30) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE KEY uq_ledger_transaction (transaction_id),
INDEX idx_ledger_user_created (user_id, created_at)
);
user_id: 거래 대상 플레이어transaction_id: 거래를 식별하는 고유 IDamount: 증감량. 양수는 지급, 음수는 차감balance_after: 거래 처리 후 잔액transaction_type: 구매, 보상, 환불 등 거래 유형
여기서 balance_after는 기록 당시의 잔액이다. 거래 기록의 순서와 정합성을 올바르게 관리해야 의미가 있으며, 이를 임의로 수정하지 않는 것이 중요하다.
원장과 잔액 테이블은 서로 다른 목적을 가진다. 원장만으로 매번 잔액을 계산하면 거래가 누적될수록 조회 비용이 증가하므로, 일반적으로 잔액은 별도 테이블에 저장하고 원장을 감사 및 복구의 근거로 사용한다.
또한 원장은 추가 기록 중심으로 운영해야 한다. 잘못된 거래를 발견했을 때 기존 내역을 삭제하거나 수정하기보다는 반대 방향의 보정 거래를 추가하는 편이 추적 가능성을 높인다.
아이템 구매 트랜잭션
아이템 구매는 일반적으로 다음 작업으로 구성된다.
- 구매 요청 검증
- 포인트 잔액 확인 및 차감
- 구매 내역 생성
- 인벤토리에 아이템 지급
- 포인트 원장 기록
이 작업 중 일부만 성공하면 데이터 불일치가 발생한다.
예를 들어 포인트 차감은 성공했지만 아이템 지급에 실패하면 플레이어는 돈을 지불하고 아이템을 받지 못한다. 반대로 아이템은 지급됐지만 포인트 차감에 실패하면 무료 구매가 발생한다.
따라서 동일한 MySQL 데이터베이스 안에서 처리할 수 있는 작업은 하나의 트랜잭션으로 묶는 것이 기본이다.
START TRANSACTION;
-- 포인트 차감
UPDATE users
SET points = points - 3000
WHERE id = 1001
AND points >= 3000;
-- 애플리케이션에서 영향받은 행이 1개인지 확인한다.
-- 0개라면 ROLLBACK 한다.
-- 구매 기록 생성
INSERT INTO purchases (
user_id,
item_id,
price,
status
)
VALUES (
1001,
2001,
3000,
'COMPLETED'
);
-- 인벤토리에 아이템 지급
INSERT INTO inventory (
user_id,
item_id,
quantity
)
VALUES (
1001,
2001,
1
)
ON DUPLICATE KEY UPDATE
quantity = quantity + 1;
-- 포인트 원장 기록
INSERT INTO point_ledger (
user_id,
transaction_id,
amount,
balance_after,
transaction_type
)
SELECT
id,
'purchase-uuid-001',
-3000,
points,
'PURCHASE'
FROM users
WHERE id = 1001;
COMMIT;
위 코드는 동작 원리를 설명하기 위한 SQL 예시다. 실제 구현에서는 요청 ID의 유일성, 구매 내역의 제약 조건, 가격의 서버 측 검증, 트랜잭션 격리 및 잠금 순서를 함께 설계해야 한다.
핵심은 COMMIT 이전에 오류가 발생하면 모든 변경을 ROLLBACK 한다는 점이다. 단, 이미 커밋된 트랜잭션은 일반적인 롤백으로 되돌릴 수 없다.
또한 클라이언트가 전달한 가격을 그대로 사용해서는 안 된다. 서버가 상품 테이블에서 실제 가격과 판매 가능 여부를 조회해야 한다.
Node.js와 TypeScript 구현
mysql2/promise를 사용한다면 다음과 같이 작성할 수 있다.
import type { Pool } from "mysql2/promise";
async function purchaseItem(
pool: Pool,
userId: number,
itemId: number,
) {
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
// 상품 정보는 서버가 관리하는 데이터를 사용한다.
const [items] = await connection.execute<any[]>(
`SELECT id, price
FROM shop_items
WHERE id = ?
AND is_available = 1`,
[itemId],
);
if (items.length === 0) {
throw new Error("ITEM_NOT_AVAILABLE");
}
const price = items[0].price;
// 잔액 확인과 차감을 원자적으로 처리한다.
const [result] = await connection.execute<any>(
`UPDATE users
SET points = points - ?
WHERE id = ?
AND points >= ?`,
[price, userId, price],
);
if (result.affectedRows !== 1) {
throw new Error("INSUFFICIENT_POINTS_OR_USER_NOT_FOUND");
}
const [balanceRows] = await connection.execute<any[]>(
`SELECT points
FROM users
WHERE id = ?`,
[userId],
);
const balance = balanceRows[0].points;
const transactionId = crypto.randomUUID();
await connection.execute(
`INSERT INTO purchases
(user_id, item_id, price, transaction_id, status)
VALUES (?, ?, ?, ?, 'COMPLETED')`,
[userId, itemId, price, transactionId],
);
await connection.execute(
`INSERT INTO inventory (user_id, item_id, quantity)
VALUES (?, ?, 1)
ON DUPLICATE KEY UPDATE quantity = quantity + 1`,
[userId, itemId],
);
await connection.execute(
`INSERT INTO point_ledger
(user_id, transaction_id, amount,
balance_after, transaction_type)
VALUES (?, ?, ?, ?, 'PURCHASE')`,
[userId, transactionId, -price, balance],
);
await connection.commit();
return {
success: true,
transactionId,
itemId,
balance,
};
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
}
이 예제에는 중요한 전제가 있다.
purchases.transaction_id에는 유니크 제약 조건이 필요하다.inventory에는(user_id, item_id)복합 유니크 제약 조건이 필요하다.shop_items.price는 정수형 재화 단위로 저장하고, 상품 가격의 범위와 자료형을 검증해야 한다.crypto.randomUUID()를 사용하려면 Node.js의node:crypto모듈을 가져오거나 해당 API를 사용할 수 있는 환경이어야 한다.catch에서 발생한 오류를 모두 무조건 롤백하면 안 되는 경우도 있다. 특히 커밋 요청이 서버에 전달된 뒤 연결이 끊겼다면 실제 커밋 여부가 불명확할 수 있다.
마지막 상황은 단순한 예외 처리만으로 해결할 수 없다. 이후 설명할 멱등성과 거래 상태 조회가 필요하다.
게임 결과 및 보상 지급
게임 종료 후 보상을 지급하는 과정도 구매와 같은 정산 원칙을 적용한다.
예를 들어 플레이어가 승리하면 500포인트를 지급한다고 가정하자.
게임 서버는 다음 순서로 처리한다.
게임 종료 판정
서버 권한으로 승패와 결과 확정
보상 계산 및 지급 요청 생성
게임 ID, 플레이어 ID, 보상 규칙 확정
트랜잭션 처리
중복 확인 → 포인트 증가 → 원장 기록
지급 결과 확정
지급 완료 기록 및 결과 반환
보상 지급은 다음과 같이 처리할 수 있다.
START TRANSACTION;
UPDATE users
SET points = points + 500
WHERE id = 1001;
INSERT INTO point_ledger (
user_id,
transaction_id,
amount,
balance_after,
transaction_type
)
SELECT
id,
'reward-match-9001-user-1001',
500,
points,
'GAME_REWARD'
FROM users
WHERE id = 1001;
COMMIT;
여기서 transaction_id는 매 요청마다 새로 만드는 값이 아니라, 동일한 게임 결과에 대한 동일한 플레이어의 보상을 항상 식별할 수 있는 값이어야 한다.
예를 들어 다음 조합을 사용할 수 있다.
matchId + userId + rewardType
단, 해당 조합이 실제로 유일하도록 설계해야 하며, 여러 차례 지급 가능한 보상이라면 라운드 ID나 지급 회차 등 추가 식별자가 필요하다.
또한 승패와 보상 금액을 클라이언트가 결정하게 해서는 안 된다. 클라이언트는 게임 결과를 요청하거나 전달받을 뿐이며, 서버가 검증한 결과를 기준으로 정산해야 한다.
중복 구매·보상 지급 방지
네트워크 환경에서 동일한 요청이 여러 번 도착하는 것은 정상적인 상황이다.
예를 들어 클라이언트가 구매 요청을 보냈지만 응답을 받지 못했다고 가정하자. 클라이언트는 구매가 실패했다고 판단해 다시 요청할 수 있다. 하지만 첫 요청이 실제로는 성공했을 수도 있다.
서버가 재요청을 새로운 구매로 처리하면 포인트가 두 번 차감되거나 아이템이 두 번 지급될 수 있다.
이를 방지하려면 애플리케이션 코드의 조건문만으로 처리하지 말고 데이터베이스의 유니크 제약 조건을 최종 방어선으로 사용해야 한다.
CREATE TABLE purchases (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
item_id BIGINT NOT NULL,
price BIGINT NOT NULL,
transaction_id VARCHAR(100) NOT NULL,
status VARCHAR(20) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE KEY uq_purchase_transaction (transaction_id)
);
보상 지급 기록도 동일한 방식으로 구성할 수 있다.
CREATE TABLE reward_claims (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
match_id VARCHAR(100) NOT NULL,
user_id BIGINT NOT NULL,
reward_type VARCHAR(30) NOT NULL,
amount BIGINT NOT NULL,
status VARCHAR(20) NOT NULL,
UNIQUE KEY uq_match_user_reward (
match_id,
user_id,
reward_type
)
);
이 제약 조건은 같은 경기, 같은 플레이어, 같은 보상 유형에 대해 지급 기록이 중복 생성되는 것을 막는다.
다만 보상 기록에 유니크 제약 조건을 걸었다고 해서 포인트 중복 지급이 자동으로 해결되는 것은 아니다. 보상 기록 생성과 포인트 증가를 하나의 트랜잭션으로 묶어야 한다.
또한 게임 결과를 다시 계산하거나 보상 규칙을 변경하는 경우에도 이미 확정된 보상을 무조건 재지급해서는 안 된다. 지급된 보상은 명시적인 보정 거래를 통해 처리하는 것이 안전하다.
멱등성(Idempotency)
멱등성은 동일한 작업을 여러 번 요청하더라도 결과가 한 번 실행한 것과 같도록 보장하는 성질이다.
수학적으로는 다음과 같이 표현한다.
\[ f(f(x)) = f(x) \]
게임 서버에서는 단순히 함수의 반환값이 같은 것보다 중요한 의미를 가진다. 동일한 구매 요청이 재전송되더라도 포인트 차감과 아이템 지급이 한 번만 발생해야 한다.
예를 들어 3,000포인트짜리 아이템을 구매하는 요청이 세 번 전달되었다고 가정하자.
| 처리 방식 | 포인트 차감 | 아이템 지급 |
|---|---|---|
| 멱등성 없음 | 9,000 | 3개 |
| 멱등성 적용 | 3,000 | 1개 |
멱등성은 네트워크 재시도, 클라이언트의 중복 클릭, 로드밸런서의 재전송, 메시지 큐의 중복 전달 등에 대응하는 핵심 설계다.
Idempotency-Key
일반적인 HTTP API에서는 클라이언트가 요청마다 고유한 Idempotency-Key를 생성해 전달하도록 설계할 수 있다.
POST /api/shop/purchases
Authorization: Bearer <access-token>
Idempotency-Key: 7c5b1e2a-5df3-4f9a-a1c4-8c8b2f6e31ab
Content-Type: application/json
{
"itemId": 2001
}
여기서 중요한 점은 같은 구매 작업을 재시도할 때는 기존 키를 재사용해야 한다는 것이다. 새로운 키를 생성하면 서버는 새로운 구매 요청으로 판단할 수 있다.
서버는 일반적으로 다음 정보를 관리한다.
user_id: 요청 주체idempotency_key: 요청 식별자request_hash: 요청 내용의 해시status: 처리 상태response_body: 최초 처리 결과created_at: 생성 시각
테이블은 다음과 같이 설계할 수 있다.
CREATE TABLE idempotency_requests (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
idempotency_key VARCHAR(100) NOT NULL,
request_hash CHAR(64) NOT NULL,
status VARCHAR(20) NOT NULL,
response_body JSON NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
ON UPDATE CURRENT_TIMESTAMP(3),
UNIQUE KEY uq_idempotency_user_key (
user_id,
idempotency_key
)
);
request_hash는 같은 키로 서로 다른 요청이 들어오는 문제를 탐지하기 위한 값이다.
예를 들어 동일한 키로 처음에는 itemId: 2001, 이후에는 itemId: 3001을 요청한다면 서버는 이를 같은 요청의 재시도로 취급하면 안 된다. 409 Conflict 등으로 요청을 거부해야 한다.
요청 해시는 서버에서 검증된 요청 데이터를 기준으로 계산해야 한다. JSON 문자열의 단순 해시는 필드 순서나 직렬화 방식에 따라 달라질 수 있으므로, 필요하다면 정규화된 표현을 사용한다.
유니크 제약 조건과 멱등성의 관계
멱등성을 애플리케이션 코드만으로 구현하면 동시 요청에서 문제가 발생할 수 있다.
const existing = await findRequest(key);
if (!existing) {
await createPurchase();
}
두 요청이 동시에 실행되면 둘 다 existing이 없다고 판단할 수 있다. 이후 두 요청 모두 구매를 진행할 가능성이 있다.
따라서 다음 두 가지가 필요하다.
- 유니크 제약 조건: 동일한 사용자와 키의 중복 등록을 데이터베이스에서 차단한다.
- 트랜잭션: 멱등성 기록과 실제 정산 작업을 원자적으로 처리한다.
멱등성 키는 요청의 중복을 식별하고, 거래 ID는 실제 정산 거래를 식별한다. 두 개념은 연결되지만 반드시 같은 값일 필요는 없다.
멱등성 구매 API 구현
앞서 설명한 구조를 실제 구매 API에 적용해 보자.
먼저 처리 흐름을 정의한다.
- 인증된 사용자 ID와
Idempotency-Key를 확인한다. - 요청 내용을 검증하고 해시를 계산한다.
- 트랜잭션을 시작한다.
- 멱등성 기록을 생성한다.
- 이미 처리된 요청이면 기존 결과를 반환한다.
- 포인트 차감, 구매 기록, 인벤토리 지급, 원장 기록을 처리한다.
- 처리 결과를 멱등성 테이블에 저장한다.
- 트랜잭션을 커밋한다.
여기서 핵심은 멱등성 기록과 구매 작업이 같은 트랜잭션에 포함된다는 점이다. 그래야 구매가 롤백되면 멱등성 기록도 함께 롤백되어 재시도할 수 있다.
다음은 이를 구현한 TypeScript 예시다. purchaseItem이라는 기존 서비스가 아니라, 멱등성 처리를 포함한 독립적인 구매 서비스다.
import { createHash } from "node:crypto";
import type { Pool, ResultSetHeader, RowDataPacket } from "mysql2/promise";
interface PurchaseResult {
success: true;
transactionId: string;
itemId: number;
balance: number;
}
interface IdempotencyRow extends RowDataPacket {
request_hash: string;
status: string;
response_body: PurchaseResult | null;
}
interface ItemRow extends RowDataPacket {
id: number;
price: number;
}
interface UserRow extends RowDataPacket {
points: number;
}
export async function purchaseItemIdempotently(
pool: Pool,
userId: number,
itemId: number,
idempotencyKey: string,
): Promise<PurchaseResult> {
if (
!Number.isSafeInteger(userId) ||
userId <= 0 ||
!Number.isSafeInteger(itemId) ||
itemId <= 0
) {
throw new Error("INVALID_INPUT");
}
if (
idempotencyKey.length < 1 ||
idempotencyKey.length > 100
) {
throw new Error("INVALID_IDEMPOTENCY_KEY");
}
const requestHash = createHash("sha256")
.update(JSON.stringify({ itemId }))
.digest("hex");
const connection = await pool.getConnection();
let transactionStarted = false;
try {
await connection.beginTransaction();
transactionStarted = true;
// 같은 사용자와 키의 중복 등록은 UNIQUE 제약 조건이 차단한다.
await connection.execute(
`INSERT INTO idempotency_requests
(user_id, idempotency_key, request_hash, status)
VALUES (?, ?, ?, 'PROCESSING')`,
[userId, idempotencyKey, requestHash],
);
const [items] = await connection.execute<ItemRow[]>(
`SELECT id, price
FROM shop_items
WHERE id = ?
AND is_available = 1`,
[itemId],
);
if (items.length !== 1) {
throw new Error("ITEM_NOT_AVAILABLE");
}
const price = items[0].price;
if (!Number.isSafeInteger(price) || price < 0) {
throw new Error("INVALID_ITEM_PRICE");
}
const [debit] = await connection.execute<ResultSetHeader>(
`UPDATE users
SET points = points - ?
WHERE id = ?
AND points >= ?`,
[price, userId, price],
);
if (debit.affectedRows !== 1) {
throw new Error("INSUFFICIENT_POINTS_OR_USER_NOT_FOUND");
}
const [users] = await connection.execute<UserRow[]>(
`SELECT points
FROM users
WHERE id = ?`,
[userId],
);
const balance = users[0].points;
const transactionId = crypto.randomUUID();
await connection.execute(
`INSERT INTO purchases
(user_id, item_id, price, transaction_id, status)
VALUES (?, ?, ?, ?, 'COMPLETED')`,
[userId, itemId, price, transactionId],
);
await connection.execute(
`INSERT INTO inventory (user_id, item_id, quantity)
VALUES (?, ?, 1)
ON DUPLICATE KEY UPDATE
quantity = quantity + 1`,
[userId, itemId],
);
await connection.execute(
`INSERT INTO point_ledger
(user_id, transaction_id, amount,
balance_after, transaction_type)
VALUES (?, ?, ?, ?, 'PURCHASE')`,
[userId, transactionId, -price, balance],
);
const response: PurchaseResult = {
success: true,
transactionId,
itemId,
balance,
};
await connection.execute(
`UPDATE idempotency_requests
SET status = 'COMPLETED',
response_body = ?
WHERE user_id = ?
AND idempotency_key = ?`,
[
JSON.stringify(response),
userId,
idempotencyKey,
],
);
await connection.commit();
transactionStarted = false;
return response;
} catch (error) {
if (transactionStarted) {
try {
await connection.rollback();
} catch {
// 연결 장애 등으로 롤백 결과를 확정할 수 없는 상황이다.
// 새 키로 무조건 재시도하지 말고 기존 키의 결과를 확인한다.
}
}
throw error;
} finally {
connection.release();
}
}
이 코드는 핵심 정산 경로를 보여주는 예제다. 실제 서비스에서는 다음과 같은 추가 처리가 필요하다.
- 동일한 키가 이미 존재하면 유니크 제약 조건 위반을 감지하고, 별도의 트랜잭션에서 기존 요청 결과를 조회한다.
- 기존 요청의
request_hash가 다르면 충돌로 처리한다. COMPLETED상태라면 저장된 결과를 반환한다.PROCESSING상태를 사용하는 비동기 처리 구조에서는 진행 중 요청에 대한 대기 또는 재조회 정책을 정의한다.- MySQL의 JSON 반환 형태와 드라이버 타입을 실제 환경에 맞게 처리한다.
crypto.randomUUID()를 사용하려면import { randomUUID } from "node:crypto"처럼 명시적으로 가져와 사용하는 편이 좋다.
특히 위 예제에서는 멱등성 키가 이미 존재할 때 발생하는 중복 키 오류를 성공 결과로 간주하지 않는다. 이를 처리하는 API 계층이 기존 기록을 조회해 결과를 반환해야 비로소 재시도에 대한 멱등성이 완성된다.
또한 PROCESSING 기록을 별도 트랜잭션으로 먼저 커밋하는 구조라면 장애 이후 처리 상태가 남을 수 있다. 이런 설계에서는 처리 소유권, 만료 시간, 복구 작업까지 정의해야 한다. 단순히 상태 문자열만 추가한다고 장애 복구가 완성되는 것은 아니다.
실패 복구와 재시도
정산 시스템에서 가장 어려운 문제는 오류가 발생했다는 사실 자체가 아니라, 오류가 발생했을 때 실제로 어디까지 처리되었는지 불확실한 상황이다.
예를 들어 서버가 구매 트랜잭션을 커밋한 직후 클라이언트 연결이 끊겼다고 가정하자.
클라이언트는 실패로 인식할 수 있지만 데이터베이스에서는 구매가 완료되었을 수 있다. 이때 새로운 구매 요청을 생성하면 중복 정산이 발생한다.
따라서 실패 상황을 유형별로 구분해야 한다.
| 상황 | 처리 방법 |
|---|---|
| 잔액 부족 | 거래를 롤백하고 재시도하지 않는다. |
| 잘못된 상품 ID | 요청 오류로 반환한다. |
| 트랜잭션 중 확실한 실패 | 롤백 후 필요에 따라 재시도한다. |
| 데드락으로 트랜잭션 취소 | 제한된 횟수만큼 트랜잭션 전체를 재시도한다. |
| DB 연결 타임아웃 | 커밋 여부가 불명확할 수 있으므로 기존 거래를 조회한다. |
| 커밋 이후 응답 전송 실패 | 기존 멱등성 키로 저장된 결과를 반환한다. |
재시도는 트랜잭션 전체에 적용한다
데드락이 발생하면 MySQL은 희생 트랜잭션을 롤백할 수 있다. 이때 실패한 SQL 문 하나만 다시 실행해서는 안 된다. 트랜잭션 전체를 다시 실행해야 한다.
async function retryTransaction<T>(
operation: () => Promise<T>,
maxAttempts = 3,
): Promise<T> {
for (let attempt = 1; ; attempt++) {
try {
return await operation();
} catch (error: any) {
const retryable =
error?.code === "ER_LOCK_DEADLOCK" ||
error?.code === "ER_LOCK_WAIT_TIMEOUT";
if (!retryable || attempt >= maxAttempts) {
throw error;
}
const delay =
50 * 2 ** (attempt - 1) +
Math.random() * 50;
await new Promise((resolve) =>
setTimeout(resolve, delay),
);
}
}
}
이 코드는 재시도 정책의 예시다. 실제 사용 시 operation은 매번 새로운 트랜잭션을 열고, 작업을 실행하고, 커밋하거나 롤백해야 한다. ER_LOCK_WAIT_TIMEOUT의 경우 MySQL 설정에 따라 트랜잭션 전체가 자동 롤백되지 않을 수 있으므로, 명시적으로 롤백한 뒤 재시도해야 한다.
또한 네트워크 연결 오류나 커밋 결과가 불명확한 오류를 위 재시도 목록에 무조건 추가해서는 안 된다. 이런 경우에는 먼저 기존 거래의 상태를 확인해야 한다.
재시도에는 지수 백오프(Exponential Backoff)를 적용할 수 있다. 재시도 간격을 점차 늘리면 장애가 발생한 데이터베이스에 요청이 집중되는 현상을 줄일 수 있다. 여러 서버가 동시에 재시도할 때는 무작위 지연인 Jitter를 추가하는 것도 효과적이다.
단, 재시도 횟수에는 반드시 상한이 있어야 한다.
Redis를 사용하면 충분할까?
Redis의 SET NX를 사용하면 특정 키가 존재하지 않을 때만 생성할 수 있다.
SET purchase:request:USER_1001:KEY_ABC 1 NX EX 3600
이 명령은 중복 요청을 빠르게 차단하는 데 유용하다. 하지만 Redis에 키를 기록하는 작업과 MySQL에서 포인트를 차감하는 작업은 서로 다른 시스템에서 발생한다.
다음과 같은 문제가 생길 수 있다.
- Redis 키는 생성됐지만 MySQL 구매는 실패한다.
- MySQL 구매는 성공했지만 Redis 키가 만료된다.
- Redis에는 성공 기록이 있지만 실제 데이터베이스 커밋 여부를 알 수 없다.
따라서 Redis의 중복 요청 차단만으로 정산의 정확성을 보장할 수는 없다.
실제 재화 정산에서는 MySQL의 트랜잭션과 유니크 제약 조건을 최종적인 정합성 보장 수단으로 사용하고, Redis는 캐싱이나 요청량 제한 등 보조적인 용도로 사용하는 것이 적절하다.
트랜잭션 밖의 작업과 Outbox 패턴
아이템 구매가 완료되면 서버는 클라이언트에 결과를 보내거나, 메시지 큐에 이벤트를 발행하거나, 로그 시스템에 기록할 수 있다.
하지만 데이터베이스 트랜잭션과 외부 시스템 호출을 하나의 일반적인 MySQL 트랜잭션으로 묶을 수는 없다.
예를 들어 구매 트랜잭션은 커밋되었지만 메시지 큐 발행에 실패하면 데이터베이스에는 구매 내역이 존재해도 후속 시스템은 구매 사실을 알지 못한다.
이 문제에는 Transactional Outbox 패턴을 적용할 수 있다.
MySQL 트랜잭션
포인트 차감 + 아이템 지급 + 구매 기록 + Outbox 이벤트 생성
Outbox 발행 작업자
미발행 이벤트 조회 및 메시지 발행
메시지 큐 및 후속 시스템
알림, 통계, 업적, 이벤트 분석
Outbox 이벤트를 구매 트랜잭션 안에서 함께 저장하면 구매가 롤백될 때 이벤트도 롤백된다. 구매가 커밋되었다면 발행 작업자가 이벤트를 나중에 전송할 수 있다.
다만 이벤트 발행과 소비 과정에서는 중복이 발생할 수 있다. 따라서 Outbox 발행 작업자는 발행 상태를 관리해야 하고, 소비자는 이벤트 ID를 기준으로 중복 처리를 방지해야 한다.
이 방식은 데이터베이스와 메시지 큐 사이의 원자적 커밋을 제공하는 것은 아니다. 대신 데이터베이스에 확정된 작업을 잃지 않고 후속 시스템으로 전달할 수 있도록 설계하는 방식이다.
실전 설계 체크리스트
정산 시스템 점검표
0/16 완료
잔액 및 원장잔액 변경과 원장 기록을 같은 트랜잭션에서 처리한다.포인트 차감은 조건부 UPDATE로 수행한다.원장 거래 ID에 유니크 제약 조건을 적용한다.운영자 보정과 환불도 별도 거래로 기록한다.
구매 및 보상상품 가격과 게임 보상은 서버에서 결정한다.포인트 차감과 아이템 지급을 원자적으로 처리한다.경기 ID와 보상 대상에 맞는 중복 방지 키를 정의한다.인벤토리와 구매 기록에 적절한 유니크 제약 조건을 둔다.
멱등성 및 복구동일 작업의 재시도에 같은 Idempotency-Key를 사용한다.동일 키에 다른 요청 내용이 들어오면 거부한다.커밋 여부가 불명확하면 기존 거래 상태를 확인한다.데드락 재시도와 외부 이벤트 재발행 정책을 정의한다.
운영 및 검증동시 구매 및 중복 보상 테스트를 수행한다.잔액과 원장의 정합성을 주기적으로 검증한다.미완료 거래와 Outbox 미발행 이벤트를 모니터링한다.실패 원인, 거래 ID, 재시도 횟수를 로그로 남긴다.