개발자로서 살아남기

개발자로서 살아남기 - 루아스크립트를 실무에서 사용한 이유

코드 살인마 2025. 8. 1. 17:22
728x90

리워드 풀(Reward Pool)이란?

  • 뽑기 상품 구매 시 사용자에게 지급되는 골드 보상을 위해 미리 잡아두는 예산(풀)입니다.
  • 즉, 게임 진행 기간 동안 사용자들에게 지급할 수 있는 골드의 최대치를 통제하기 위한 장치입니다.

예시

  • 리워드 풀 잔액:
    현재 리워드 풀에 총 1,000만 골드가 남아 있습니다.

  • 사용자가 반납한 개수에 따른 최대 당첨금:

    1. 반납 100개 → 최대 당첨금: 100만 골드
    2. 반납 200개 → 최대 당첨금: 500만 골드
    3. 반납 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가 순차적으로 처리하여
    최종 결과의 일관성을 보장합니다.

하지만, 조건 로직이 섞이면?

현실적으로는 보통 다음과 같은 로직이 필요합니다.

  1. 현재 리워드 풀 잔액 확인
  2. 차감 후 0 이상인지 검사
  3. 부족하면 실패 처리, 충분하면 차감
  4. 필요 시 추가적인 부가 로직

이런 경우, 단순 DECRBY만으로는 부족하고,
전체 로직을 하나의 atomic 블록으로 묶을 수단이 필요합니다.


Lua 스크립트 사용 이유

문제 상황

  • 현재 리워드 풀: 100억 골드

  • 요청:

    1. 첫 번째 요청: 120억 골드 당첨
    2. 두 번째 요청: 80억 골드 당첨

단순 DECR 사용 시 발생 가능한 흐름

  1. 첫 번째 요청 처리

    • DECRBY reward_pool 120억 → 결과: -20억
  2. 애플리케이션에서 검사

    • 리워드 풀이 < 0인지 확인 후,
      음수면 “실패 + 롤백(원상복구)” 처리 로직 수행
  3. 문제 지점

    • 첫 번째 요청의 “검사 ~ 롤백” 사이에 두 번째 요청(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

동작 요약

  1. KEYS[1]에서 현재 리워드 풀(fund) 조회
  2. 요청 차감액(deduct)을 적용했을 때 0 미만인지 확인
  3. 음수라면: 차감하지 않고 -1 반환 (실패)
  4. 0 이상이라면: 실제로 DECRBY 실행 후, 차감 후 잔액 반환

이 전체가 Redis 입장에서는 하나의 atomic 연산으로 실행되므로,

  • 중간에 다른 요청이 끼어들 수 없고
  • 여러 요청이 동시에 들어와도 리워드 풀의 일관성이 보장됩니다.

결론 정리

  • 리워드 풀(Reward Pool)

    • 사용자에게 지급할 골드 보상을 위해 미리 잡아두는 예산/풀입니다.
    • 반납 개수에 따라 정해지는 최대 당첨금이 리워드 풀 잔액 이내인 경우에만 해당 배팅 옵션을 허용합니다.
  • 동시성 이슈

    • 여러 당첨 요청이 동시에 들어오면, 단순 GET/SET 패턴으로는 race condition이 쉽게 발생합니다.
  • Redis + Lua 스크립트 활용

    • 단일 명령어(DECRBY 등)는 atomic하지만, 조건 로직까지 포함하기엔 부족합니다.
    • Lua 스크립트로 조건 검사 + 차감 + 결과 반환을 하나로 묶어
      리워드 풀이 항상 일관된 상태를 유지하도록 합니다.

이렇게 구현하면, 여러 당첨 요청이 동시에 들어와도
리워드 풀(예산)을 초과해서 지급하거나, 이상한 음수 상태로 가는 문제를 방지할 수 있습니다.