선착순 쿠폰 발급 시스템을 구현하면서 가장 중요한 요구사항은 정해진 수량만큼만 쿠폰이 발급되어야 한다는 것이었다. 동시에 수천 명의 사용자가 몰릴 수 있기 때문에 동시성 문제를 해결하지 않으면 DB에 초과 발급이 발생할 수 있다. 초기에는 synchronized
키워드를 활용하여 단일 서버 환경에서만 동작하도록 처리했으나 멀티 서버 환경에서는 효과가 없었기 때문에 분산 환경에서도 동시성을 제어할 수 있는 Redisson 분산 락을 도입하게 되었다.
Redisson은 Java 기반의 Redis 클라이언트 라이브러리다. 단순한 Redis 접근뿐 아니라 분산 락, 캐시, 세마포어, 큐, pub/sub 등 고수준 구조를 지원한다. 특히 RLock
을 이용해 멀티 서버 환경에서도 하나의 리소스에 대해 단일 스레드만 접근하게 제어할 수 있다. 이는 분산 환경에서 선착순 처리나 중복 방지 등에 유용하게 활용된다.
대표적으로 제공되는 기능은 다음과 같다
RLock
: 분산 환경에서의 분산 락 구현RMap
, RSet
, RQueue
: Redis 기반 분산 컬렉션RSemaphore
, RCountDownLatch
: Redis 기반의 동기화 도구RRateLimiter
: 속도 제한(rate limiting) 기능RExecutorService
: 분산 Task 실행기특히 이번에 사용한 RLock
은 lock()
, tryLock()
등 Java의 java.util.concurrent.locks.Lock
과 유사한 API를 제공하면서도 내부적으로 Redis를 이용하여 멀티 서버 환경에서도 하나의 리소스를 단 하나의 노드에서만 점유 가능하게 만든다.
이러한 구조 덕분에 다수의 서버 인스턴스가 존재해도 동시에 하나의 리소스(예: 특정 쿠폰 ID)에 대해서는 오직 하나의 클라이언트만 접근하게 제어할 수 있다. 이 점에서 Redisson은 분산 환경에서의 선착순, 중복 방지, 경쟁 제어와 같은 문제에 적합한 선택지가 된다.
@Slf4j
@Service
@RequiredArgsConstructor
public class RedisCouponService {
private final RedissonClient redissonClient;
private final StringRedisTemplate redisTemplate;
private final CouponIssueProducer couponIssueProducer;
private static final String COUPON_LOCK_KEY = "lock:coupon:%d";
private static final String COUPON_TOTAL_KEY = "coupon:%d:total";
private static final String COUPON_COUNT_KEY = "coupon:%d:count";
private static final String USER_ISSUED_KEY = "coupon:%d:user:%d";
private static final String COUPON_EXPIRE_KEY = "coupon:%d:expire";
public void issueWithLock(Long couponId, Long userId) {
String lockKey = String.format(COUPON_LOCK_KEY, couponId);
RLock lock = redissonClient.getLock(lockKey);
boolean locked = false;
try {
// 최대 3초 대기, 2초 점유
locked = lock.tryLock(3, 2, TimeUnit.SECONDS);
if (!locked) {
log.warn("락 획득 실패: couponId={}, userId={}", couponId, userId);
throw new IllegalStateException("잠시 후 다시 시도해주세요.");
}
String userKey = String.format(USER_ISSUED_KEY, couponId, userId);
String countKey = String.format(COUPON_COUNT_KEY, couponId);
String totalKey = String.format(COUPON_TOTAL_KEY, couponId);
String expireKey = String.format(COUPON_EXPIRE_KEY, couponId);
// 중복 발급 확인
if (Boolean.TRUE.equals(redisTemplate.hasKey(userKey))) {
log.warn("🚫 이미 발급된 유저: couponId={}, userId={}", couponId, userId);
return;
}
int total = redisTemplate.opsForValue().get(totalKey);
int current = redisTemplate.opsForValue().get(countKey);
// 재고 초과 확인
if (current >= total) {
log.warn("🎯 재고 소진: couponId={}", couponId);
return;
}
redisTemplate.opsForValue().increment(countKey);
Long ttl = redisTemplate.getExpire(expireKey, TimeUnit.SECONDS);
long expireSeconds = (ttl != null && ttl > 0) ? ttl : 3600;
redisTemplate.opsForValue().set(userKey, "true", Duration.ofSeconds(expireSeconds));
// Kafka로 발급 성공 이벤트 전송
CouponIssueEventDto eventDto = new CouponIssueEventDto(couponId, userId);
couponIssueProducer.send("coupon.issue", String.valueOf(couponId), eventDto);
log.info("✅ 쿠폰 발급 성공: couponId={}, userId={}", couponId, userId);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("락 획득 중 인터럽트 발생", e);
} finally {
if (locked) {
lock.unlock();
}
}
}
}
부하 테스트: K6로 1만 명 동시 요청 시도 (쿠폰 수량: 100개)
결과
Redisson 락을 통해 동시성 문제가 완전히 해결되었다. Kafka와 연동된 구조 덕분에 발급 로직은 빠르게 반환되고 실제 DB 저장은 비동기 이벤트 처리로 분리되어 서비스 확장성도 확보했다.
Redisson 기반 락은 정합성 보장 측면에서는 좋은 방법이었다. 하지만 실제 운영 수준의 트래픽을 시뮬레이션한 테스트 결과 성능적인 병목이 두드러졌다. 락을 걸기 위한 tryLock()
호출은 내부적으로 Redis와의 네트워크 왕복(RTT)을 수반하고 동시에 수천 개의 요청이 몰릴 경우 락 획득 대기 시간이 길어졌다. 실제로 k6 테스트에서 평균 응답 시간이 30초 이상 늘어났고 최대 1분이 넘는 경우도 발생했다.
이러한 현상은 사용자의 체감 속도에 큰 영향을 주며 락 획득 실패로 인한 빈번한 실패 응답 또한 사용자 경험을 저하시키는 요소가 된다. 락을 잡지 못한 요청은 재시도 없이 바로 실패 처리되기 때문에 선착순에서 밀려난 사용자는 실패 피드백을 너무 빨리 받는 꼴이 된다. 경우에 따라 "왜 실패했는지"가 사용자 입장에서 명확하지 않아 혼란을 유발할 수도 있다.
무엇보다도 락은 성능을 희생해서 얻는 안정성이라는 점에서 정합성이 우선인 상황에서는 좋지만 TPS가 중요한 시스템에서는 병목 요소가 될 수 있다. 특히 락 기반 구조는 쿠폰 발급 요청이 많아질수록 서버를 여러 대 늘리더라도 처리 속도가 쉽게 올라가지 않는 구조라는 점에서 확장에 한계가 있었다. 따라서 고성능이 요구되는 상황에서는 Redis Lua 스크립트로 처리 로직을 원자화하거나 Kafka와 Redis Queue를 조합한 구조로 개선하는 것이 더욱 적합할 수 있다.
이번 구현을 통해 Redisson 분산 락만으로도 충분히 정합성을 보장할 수 있다는 확신을 얻었다. 분산 환경에서도 초과 발급 없이 안정적으로 시스템을 운영할 수 있었고, Kafka 이벤트 분리 구조를 통해 비동기 처리 기반 확장성도 확보할 수 있었다.
하지만 동시에 성능이 중요한 서비스에서는 락 기반 구조의 한계를 명확히 체감했다. 이를 계기로 Redisson은 정합성 우선 상황에서의 강력한 도구이며, 성능 병목이 발생할 수 있다는 전제 하에 선택해야 한다는 점을 분명히 인식하게 되었다.