한정 수량의 쿠폰을 뿌리는 이벤트를 할 때, 지정된 수량만큼만 풀리게 하려면 어떻게 해야할까? 단순히 db에 있는 데이터의 숫자를 줄이거나 하는 방법으로는 올바르게 처리하지 않으면 쿠폰이 중복 발급되거나 수량이 음수가 되는 문제가 발생할 수 있다.
NestJS, MongoDB, Redis를 활용하여 동시성 이슈 없이 한정 수량 쿠폰을 발급하는 시스템 설계 및 구현 과정을 공유하고자 한다. 관련 코드는 GitHub 저장소 링크에서 확인할 수 있습니다!
도메인 설계
시스템은 크게 두 도메인으로 나뉜다:
1. Coupon (쿠폰 정의): 발급 가능한 쿠폰의 종류와 총 수량을 정의
{
"couponId": "WELCOME2025",
"quantity": 100,
}
2. UserCoupon (실제 발급 내역): 어떤 유저가 어떤 쿠폰을 언제 발급받았는지 기록
시스템 아키텍처
초기 구현의 한계와 Race Condition 문제
초기에는 아래와 같은 방식으로 구현했다.
async issueCoupon(createUserCouponDto: CreateUserCouponDto) : Promise<void> {
const remainingCount = await this.couponQuantityDao.getRemainingCount(createUserCouponDto.couponId);
if (remainingCount <= 0) {
throw new CouponExcepetion(CouponError.NO_COUPON_REMAINING);
}
await this.couponQuantityDao.decreaseRemainingCount(createUserCouponDto.couponId);
}
async decreaseRemainingCount(couponId: string): Promise<void> {
const key = this.getKey(couponId);
const updatedAt = new Date().toISOString();
await this.redis.hincrby(key, 'remainingCount', -1);
await this.redis.hset(key, 'updatedAt', updatedAt);
}
이 코드는 겉보기에는 문제가 없어 보이지만, remainingCount <= 0 검사 후 decreaseRemainingCount가 실행되기 전에 다른 요청이 끼어들 경우 심각한 Race Condition이 발생할 수 있다. 예를 들어, 남은 쿠폰이 1개일 때 동시에 여러 요청이 들어오면, 모든 요청이 remainingCount <= 0 조건을 통과하고 -1 연산을 수행하여 쿠폰 수량이 음수가 되는 문제가 발생한다. 이는 결국 정해진 수량보다 훨씬 많은 사용자에게 쿠폰이 발급될 수 있음을 의미한다.
동시성 이슈 해결 방안
Race Condition 문제를 해결하기 위해 두 가지 방법을 고안했다.
해결방법1: 선감소 후 롤백
쿠폰 발급 시 먼저 수량을 감소시킨 후, 나중에 정해진 개수보다 많이 발급되었을 경우 롤백(1을 증가시키고 에러 발생)하는 방법이다.
이 방법도 원자 연산을 사용하지만, 근본적인 해결책이라고 보기는 어렵다. 애초에 잘못된 상태가 발생하지 않도록 구현하는 것이 훨씬 올바른 접근 방법이다. 예외 처리 메커니즘에 가깝다고 본다.
해결 방법 2: Lua 스크립트로 원자적 처리
앞선 방법의 문제를 해결하려면 "조건 검사"와 "수량 감소" 두 가지 작업을 하나의 원자적인 단위로 처리해야 한다. Redis는 Lua 스크립트로 단일 트랜잭션처럼 실행시킬 수 있다. 따라서 중간에 다른 명령이 끼어들 수 없어 Race Condition을 효과적으로 방지할 수 있다.
내가 최종적으로 채택한 방법은 바로 이 Lua 스크립트 방식이다.
const script = `
local count = redis.call('HGET', KEYS[1], 'remainingCount')
if count and tonumber(count) > 0 then
redis.call('HINCRBY', KEYS[1], 'remainingCount', -1)
return 1
else
return 0
end`;
const result = await redis.eval(script, 1, key);
if (result !== 1) {
throw new CouponException(CouponError.NO_COUPON_REMAINING);
}
이 스크립트는 다음과 같이 동작한다:
- HGET 명령으로 현재 쿠폰의 남은 수량(remainingCount)을 가져온다.
- 가져온 수량이 0보다 큰지 확인한다.
- 만약 0보다 크다면, HINCRBY 명령으로 remainingCount를 -1만큼 감소시키고 1을 반환한다.
- 그렇지 않다면, 즉 수량이 없거나 0 이하라면 0을 반환한다.
redis.eval을 통해 스크립트를 실행한 결과가 1이 아니라면, 쿠폰 발급에 실패했음을 의미하므로 예외를 발생시킨다. 이 방식은 Race Condition 없이 정확한 수량 제어가 가능하며, 예외적인 상황을 사전에 방지한다는 점에서 첫 번째 방법보다 훨씬 더 견고하고 올바른 접근 방법이라고 판단했다.
테스트
구현된 시스템의 안정성을 확인하기 위해 간단한 프론트엔드 웹 페이지를 제작하여 테스트를 진행했다.
브라우저 내에서 한 번에 만 개 요청을 보내는 과정에서 성능 이슈가 있어, 요청을 여러 배치로 나누어 처리했다. 컴퓨터 성능에 따라 소요 시간은 다소 길어졌지만, 최종적으로 요청은 성공적으로 처리되었으며, 정해진 쿠폰 개수에 맞게 정확하게 발급되는 것을 확인할 수 있었다.
후기
이번 프로젝트를 통해 한정 수량 쿠폰 시스템에서 발생할 수 있는 동시성 이슈를 이해하고, Redis의 Lua 스크립트를 활용한 원자적 처리가 얼마나 강력하고 유용한 해결책이 될 수 있는지 직접 경험했다. 실무에서 빈번히 마주하는 동시성 문제를 해결하는 데 있어 Redis의 원자 연산은 필수적인 도구라고 생각한다.
물론 실제로 운영 환경에서 사용하려면, 메시지 큐잉을 통한 비동기 처리, 쿠폰 중복 발급 방지를 위한 추가적인 유저별 발급 이력 관리, 가장 유리한 쿠폰 발급 로직 등 다양한 예외 처리와 비즈니스 로직이 더해져야 할 것으로 예상한다.
다음 글에서는 이 시스템의 안정성과 성능을 더욱 강화하기 위한 추가적인 노력들을 다룰 예정이다. 구체적으로는 부하 테스트를 통해 시스템의 한계를 측정하고 병목 지점을 파악하는 방법, Jest를 활용한 테스트 자동화로 코드의 신뢰성을 확보하는 과정, 그리고 데이터의 일관성을 보장하기 위한 트랜잭션(Transaction) 적용 방안에 대해 다뤄보겠습니다.!