리워드 풀(Reward Pool)이란?
- 뽑기 상품 구매 시 사용자에게 지급되는 골드 보상을 위해 미리 잡아두는 예산(풀)입니다.
- 즉, 게임 진행 기간 동안 사용자들에게 지급할 수 있는 골드의 최대치를 통제하기 위한 장치입니다.
예시
리워드 풀 잔액:
현재 리워드 풀에 총 1,000만 골드가 남아 있습니다.사용자가 반납한 개수에 따른 최대 당첨금:
- 반납 100개 → 최대 당첨금: 100만 골드
- 반납 200개 → 최대 당첨금: 500만 골드
- 반납 500개 → 최대 당첨금: 2,000만 골드
제한 조건:
- 반납 개수에 따른 최대 당첨금을, 리워드 풀에서 차감하여 지급합니다.
- 만약 계산된 최대 당첨금이 현재 리워드 풀 잔액보다 크다면(예: 500개 반납의 경우 2,000만 골드 > 1,000만 골드) 해당 배팅 옵션은 사용할 수 없습니다.
즉, 사용자가 선택할 수 있는 반납 개수는
“해당 반납 개수에 대한 최대 당첨금을, 현재 리워드 풀로 실제 지급 가능할 때만” 허용됩니다.
- 100개 반납 시: 최대 당첨금 100만 골드 → 리워드 풀 1,000만 골드 내에서 지급 가능 → 선택 가능
- 200개 반납 시: 최대 당첨금 500만 골드 → 리워드 풀 1,000만 골드 내에서 지급 가능 → 선택 가능
- 500개 반납 시: 최대 당첨금 2,000만 골드 → 리워드 풀 1,000만 골드보다 큼 → 선택 불가
따라서, 배팅 시에는 항상
“반납 개수별 최대 당첨금 ≤ 현재 리워드 풀 잔액”
인 경우에만 그 배팅을 사용할 수 있습니다.
동시성 이슈
상황 예시:
리워드 풀에 100억 골드가 남아 있을 때, 동시에 두 개의 당첨 요청(각각 80억 골드)이 들어오는 경우
→ 두 요청 모두 정상 처리되어야 합니다.문제점:
동시 요청이 있을 경우, 하나의 요청이 리워드 풀을 갱신하기 전에 다른 요청이 같은 값을 참조하면,
의도하지 않은 잔액/처리 결과가 발생할 수 있습니다.해결 방법:
동시성 제어를 위해 MySQL Lock 또는 Redis를 사용할 수 있습니다.MySQL Lock:
- 동기 방식이라 동시성 제어는 확실하지만, 성능(지연 시간)이 상대적으로 떨어집니다.
Redis:
- 싱글 스레드 구조라 명령어 하나하나는 atomic하게 처리되고, 속도가 빠릅니다.
- 다만, 여러 명령어(GET + SET 등)를 조합해 사용할 경우 명령어 사이에 다른 요청이 끼어들 수 있어 동시성 이슈가 발생합니다.
정리
- MySQL Lock: 안전하지만 느림
- Redis: 빠르지만, 여러 명령어 조합 시 race condition 주의 필요
싱글 스레드 Redis에서도 동시성 이슈가 발생하는 이유
예시 (단순 GET + SET 패턴)
여러 클라이언트가 동시에 아래와 같이 동작한다고 가정합니다.
-- 클라이언트 A:
GET reward_pool -- (예: 100 반환)
-- 조건 검사: 100 >= 60 (충분)
SET reward_pool 40 -- (100 - 60)
-- 클라이언트 B:
GET reward_pool -- (동시에 100 반환, 아직 A의 SET 이전)
-- 조건 검사: 100 >= 60 (충분)
SET reward_pool 40 -- (100 - 60)
발생하는 문제 (Race Condition)
- A와 B 모두 동일한 리워드 풀 값 100을 읽어옴
- 둘 다 “충분하다”고 판단 후 각자 60씩 차감했다고 생각하지만
- 실제 최종 값은 40으로만 설정됨 (한 번만 차감된 효과)
즉,
- Redis의 각 명령어(GET, SET)는 atomic하지만
- “GET → 조건 검사 → SET” 전체 시퀀스는 atomic하지 않기 때문에
명령어 사이에 다른 요청이 끼어들면 동시성 문제가 발생합니다.
Atomic 명령어 사용의 필요성
Redis는 INCR, DECR 같은 단일 명령어 기반의 atomic 연산을 제공합니다.
예: 단순 차감
단순히 리워드 풀에서 금액만 차감하면 되는 경우:
DECRBY reward_pool 60
- 이 명령어 하나는 Redis 내부에서 원자적으로 처리됩니다.
- 여러 클라이언트가 동시에
DECRBY를 호출해도 Redis가 순차적으로 처리하여
최종 결과의 일관성을 보장합니다.
하지만, 조건 로직이 섞이면?
현실적으로는 보통 다음과 같은 로직이 필요합니다.
- 현재 리워드 풀 잔액 확인
- 차감 후 0 이상인지 검사
- 부족하면 실패 처리, 충분하면 차감
- 필요 시 추가적인 부가 로직
이런 경우, 단순 DECRBY만으로는 부족하고,
전체 로직을 하나의 atomic 블록으로 묶을 수단이 필요합니다.
Lua 스크립트 사용 이유
문제 상황
현재 리워드 풀: 100억 골드
요청:
- 첫 번째 요청: 120억 골드 당첨
- 두 번째 요청: 80억 골드 당첨
단순 DECR 사용 시 발생 가능한 흐름
첫 번째 요청 처리
DECRBY reward_pool 120억→ 결과:-20억
애플리케이션에서 검사
- 리워드 풀이
< 0인지 확인 후,
음수면 “실패 + 롤백(원상복구)” 처리 로직 수행
- 리워드 풀이
문제 지점
- 첫 번째 요청의 “검사 ~ 롤백” 사이에 두 번째 요청(80억)이 들어오면,
- Redis 상 리워드 풀은 이미 -20억인 상태
- 두 번째 요청은 정상적인 시나리오라면 고려 대상이지만,
현재 상태만 보면 이미 음수라 잘못된 판단이 내려질 수 있음
즉, 애플리케이션 레벨에서 검사/롤백을 분리해서 처리하면
그 사이에 다른 요청이 끼어들어 이상한 상태가 발생할 수 있습니다.
이 문제를 막기 위해,
조건 검사 + 차감 + 결과 반환을 하나의 atomic 연산으로 만들기 위해
Redis Lua 스크립트를 사용합니다.
Lua 스크립트를 이용한 Atomic 처리
Lua 스크립트에서는 여러 Redis 명령을 하나로 묶어
“조건 확인 → 차감 → 결과 반환”을 통째로 atomic하게 실행할 수 있습니다.
예시 Lua 스크립트
-- Lua 스크립트 예시: 리워드 풀 차감 및 조건 검사
local fund = tonumber(redis.call('GET', KEYS[1]))
local deduct = tonumber(ARGV[1])
if (fund - deduct) < 0 then
return -1 -- 예산 부족
else
redis.call('DECRBY', KEYS[1], deduct)
return fund - deduct
end
동작 요약
KEYS[1]에서 현재 리워드 풀(fund) 조회- 요청 차감액(
deduct)을 적용했을 때 0 미만인지 확인 - 음수라면: 차감하지 않고 -1 반환 (실패)
- 0 이상이라면: 실제로
DECRBY실행 후, 차감 후 잔액 반환
이 전체가 Redis 입장에서는 하나의 atomic 연산으로 실행되므로,
- 중간에 다른 요청이 끼어들 수 없고
- 여러 요청이 동시에 들어와도 리워드 풀의 일관성이 보장됩니다.
결론 정리
리워드 풀(Reward Pool)
- 사용자에게 지급할 골드 보상을 위해 미리 잡아두는 예산/풀입니다.
- 반납 개수에 따라 정해지는 최대 당첨금이 리워드 풀 잔액 이내인 경우에만 해당 배팅 옵션을 허용합니다.
동시성 이슈
- 여러 당첨 요청이 동시에 들어오면, 단순 GET/SET 패턴으로는 race condition이 쉽게 발생합니다.
Redis + Lua 스크립트 활용
- 단일 명령어(DECRBY 등)는 atomic하지만, 조건 로직까지 포함하기엔 부족합니다.
- Lua 스크립트로 조건 검사 + 차감 + 결과 반환을 하나로 묶어
리워드 풀이 항상 일관된 상태를 유지하도록 합니다.
이렇게 구현하면, 여러 당첨 요청이 동시에 들어와도
리워드 풀(예산)을 초과해서 지급하거나, 이상한 음수 상태로 가는 문제를 방지할 수 있습니다.
'개발자로서 살아남기' 카테고리의 다른 글
| 서버개발자로서 살아남기 - MCP 실무에서 활용하기 with 옵시디언, mysql, 파일시스템 (1) (9) | 2025.08.01 |
|---|---|
| 챌린지 배틀 회고 근데 이제 OOP와 도메인 주도 설계를 곁들인 (0) | 2025.08.01 |
| 개발자로서 살아남기 - JVM GC 동작 원리 (0) | 2025.08.01 |
| JAVA - Synchronized와 ThreadLocal 을 이용한 동시성 문제 해결 (0) | 2024.05.03 |
| 스카우터를 활용한 서비스 성능 최적화 (0) | 2023.09.19 |