게임에서 이벤트가 열리면 평소에는 별문제 없던 “아이템 개봉” 기능도 갑자기 어려운 문제가 된다. 한 명이 아이템 100개를 한 번에 열 수도 있고, 이벤트 시작 직후 수많은 유저가 동시에 요청할 수도 있다. 결과는 즉시 보여줘야 하지만, 재고 차감과 보상 지급은 정확해야 한다.
이 글은 아이템 대량 개봉을 하나의 분산 시스템 문제로 보고 정리한 메모다. 핵심은 빠른 응답, 확률의 신뢰성, 동시성 제어, 부하 분산을 동시에 만족시키는 것이다.
즉시 결과가 필요하면 RPC가 잘 맞는다
아이템 개봉은 보통 사용자가 버튼을 누른 직후 결과를 기대하는 기능이다. 이런 경우에는 비동기 이벤트만으로 처리하기보다 RPC처럼 요청과 응답이 명확한 방식이 잘 맞는다. gRPC나 Thrift 같은 방식은 바이너리 프로토콜을 쓰기 때문에 REST보다 지연 시간을 줄이기 쉽고, 스키마 기반이라 요청과 응답 타입도 비교적 안전하게 관리할 수 있다.
service ItemService {
rpc OpenItems(OpenItemsRequest) returns (OpenItemsResponse);
}
message OpenItemsRequest {
string user_id = 1;
repeated string item_ids = 2;
}다만 RPC라고 해서 모든 처리를 한 요청 안에 다 밀어 넣으면 위험하다. 결과 계산과 사용자 응답은 빠르게 끝내되, 로그 적재나 통계 집계, 알림 같은 부가 작업은 메시지 큐로 분리하는 편이 낫다.
확률 로직은 구현보다 검증이 더 중요하다
아이템 뽑기에서 확률은 민감한 영역이다. 구현 자체는 Redis Set에서 후보를 뽑거나, 누적 가중치를 TreeMap에 넣고 랜덤 값으로 선택하는 방식처럼 여러 가지가 가능하다.
public class ProbabilitySelector<T> {
private final TreeMap<Double, T> probabilityMap = new TreeMap<>();
private double totalWeight = 0;
public void add(T item, double weight) {
totalWeight += weight;
probabilityMap.put(totalWeight, item);
}
public T select() {
double random = Math.random() * totalWeight;
return probabilityMap.higherEntry(random).getValue();
}
}하지만 운영에서는 “코드가 맞다”보다 “실제로 기대 확률대로 나오고 있다”가 중요하다. 배포 전에는 대량 시뮬레이션으로 기대 확률과 실제 결과의 차이를 확인하고, 운영 중에는 아이템별 분포를 모니터링해야 한다. 카이제곱 검정 같은 통계 검증을 붙이면 특정 아이템이 과하게 나오거나 덜 나오는 문제를 더 빨리 찾을 수 있다.
큐는 소비 속도를 제어하기 위해 쓴다
아이템 개봉 요청이 폭증하면 모든 작업을 동기 처리하기 어렵다. 특히 로그, 정산, 통계, 랭킹 반영처럼 사용자 응답과 직접 연결되지 않은 작업은 메시지 큐로 넘기는 게 자연스럽다.
Push 방식은 지연 시간이 낮지만 소비자가 감당할 수 있는 속도를 넘기기 쉽다. Pull 방식은 소비자가 자기 처리량에 맞춰 가져갈 수 있어서 부하 제어가 쉽다. RabbitMQ 같은 시스템에서는 prefetch size가 이 균형을 조절하는 중요한 값이 된다.
spring:
rabbitmq:
listener:
simple:
prefetch: 250prefetch가 너무 작으면 네트워크 왕복이 잦아지고, 너무 크면 특정 컨슈머가 메시지를 과하게 가져가 장애 시 재처리 부담이 커진다. 이벤트 트래픽이 큰 서비스라면 이 값을 고정값으로만 보지 말고, 처리 시간과 컨슈머 수에 맞춰 실험해야 한다.
동시 개봉은 사용자 단위 직렬화가 필요하다
가장 위험한 시나리오는 같은 사용자가 가진 아이템을 여러 서버에서 동시에 개봉하는 경우다. 두 요청이 같은 아이템을 동시에 소비하면 중복 보상이 생기거나 재고가 음수가 될 수 있다.
해결 방식은 크게 세 가지다. Redis SETNX 같은 분산 락으로 사용자별 임계 구역을 만들 수 있고, 버전 필드를 활용한 낙관적 락으로 충돌을 감지할 수도 있다. 트래픽이 매우 크다면 사용자별 큐로 요청을 직렬화하는 방식도 가능하다.
낙관적 락은 충돌이 적을 때 효율적이고, 비관적 락은 충돌이 많은 핵심 자원에 더 안전하다. 예를 들어 재고 차감처럼 정확성이 중요한 작업은 FOR UPDATE로 잠그거나, 버전 조건을 걸어 성공한 요청만 반영되게 만든다.
-- 비관적 락
SELECT * FROM inventory WHERE item_id = ? FOR UPDATE;
UPDATE inventory SET quantity = quantity - 1 WHERE item_id = ?;
-- 낙관적 락
UPDATE inventory
SET quantity = quantity - 1, version = version + 1
WHERE item_id = ? AND version = ?;락을 쓴다면 락 획득 순서를 통일하고, 트랜잭션 범위를 짧게 유지하고, 타임아웃을 반드시 둬야 한다. 락은 동시성 문제를 해결하지만, 잘못 쓰면 병목과 데드락의 원인이 된다.
MongoDB 트랜잭션은 재시도 전제를 둔다
MongoDB에서 여러 문서를 함께 수정하면 트랜잭션 에러를 만날 수 있다. 동시에 같은 문서를 수정해서 생기는 WriteConflict, 트랜잭션 크기 제한, 실행 시간 제한 같은 문제가 대표적이다.
이런 에러는 일부가 일시적이기 때문에 재시도 로직을 준비해야 한다. 단, 모든 에러를 무작정 재시도하면 안 되고 TransientTransactionError처럼 재시도 가능한 라벨이 붙은 경우에만 제한된 횟수로 다시 시도한다.
async function executeWithRetry(operation, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
const session = client.startSession()
try {
session.startTransaction()
const result = await operation(session)
await session.commitTransaction()
return result
} catch (error) {
await session.abortTransaction()
if (error.hasErrorLabel("TransientTransactionError") && i < maxRetries - 1) {
continue
}
throw error
} finally {
session.endSession()
}
}
}트랜잭션 자체를 줄이는 것도 중요하다. 아이템 개봉 결과 계산, 보상 지급, 로그 적재를 모두 하나의 큰 트랜잭션에 넣기보다 반드시 원자적으로 묶어야 하는 부분만 묶는 편이 안정적이다.
이벤트 트래픽은 즉시 대응과 구조적 대응을 나눠야 한다
이벤트 시작 직후 동시 접속자가 10배 늘었다면 먼저 해야 할 일은 시스템을 살리는 것이다. Auto Scaling 트리거를 조정하고, 캐시 TTL을 늘리고, 비필수 기능은 서킷브레이커로 잠시 제한할 수 있다. 유저가 반드시 받아야 하는 보상 지급 경로와 없어도 되는 부가 기능을 분리해두면 이런 대응이 쉬워진다.
장기적으로는 읽기와 쓰기를 분리하는 CQRS, 이벤트 소싱, 샤딩 같은 구조를 검토할 수 있다. 다만 이런 구조는 복잡도를 크게 올리기 때문에 처음부터 정답처럼 넣기보다 실제 병목이 어디인지 확인한 뒤 도입하는 게 좋다.
정리하면 아이템 대량 개봉은 단순히 “빠르게 랜덤 결과를 뽑는 기능”이 아니다. 유저에게는 즉시 결과를 보여주면서도, 서버 내부에서는 동시성·확률·트랜잭션·부하를 모두 통제해야 하는 문제다. 그래서 기능 설계 단계부터 어떤 작업을 동기 처리하고, 어떤 작업을 큐로 넘기고, 어디에 락과 재시도를 둘지 정해두는 게 중요하다.