처음 쿠폰 발급 서비스를 생각했을 때 동시성을 막아주려면 synchronized
를 사용해서 하면 동시성에서 문제가 일어나지 않고 잘 작동하겠지라는 생각으로 synchronized
로 구현했었다.
@Slf4j
@Service
@RequiredArgsConstructor
public class CouponIssueService {
private final CouponIssueProducer couponIssueProducer;
private final CouponRepository couponRepository;
private final Object lock = new Object(); // 전체 락
private final Map<Long, Set<Long>> issuedUserMap = new ConcurrentHashMap<>();
private final Map<Long, Integer> remainingQuantityMap = new ConcurrentHashMap<>();
public void tryIssueCoupon(Long couponId, Long userId) {
synchronized (lock) {
issuedUserMap.putIfAbsent(couponId, new HashSet<>());
int remainingQuantity = couponRepository.findById(couponId)
.orElseThrow(() -> new ErrorException(ExceptionEnum.COUPON_NOT_FOUND))
.getRemainingQuantity();
remainingQuantityMap.putIfAbsent(couponId, remainingQuantity);
Set<Long> issuedUsers = issuedUserMap.get(couponId);
int remaining = remainingQuantityMap.get(couponId);
if (issuedUsers.contains(userId)) {
log.warn("🚫 이미 발급된 유저: couponId={}, userId={}", couponId, userId);
throw new ErrorException(ExceptionEnum.COUPON_ALREADY_USED);
}
if (remaining <= 0) {
log.warn("🚫 쿠폰 재고 소진: couponId={}", couponId);
throw new ErrorException(ExceptionEnum.COUPON_OUT_OF_STOCK);
}
remainingQuantityMap.put(couponId, remaining - 1);
issuedUsers.add(userId);
log.info("✅ 쿠폰 발급 성공: couponId={}, userId={}, 남은 수량={}", couponId, userId, remaining - 1);
couponIssueProducer.sendIssueEvent(couponId, userId);
}
}
}
서버가 1대일 때는 synchronized
키워드로 발급 동시성을 막을 수 있었다. 쿠폰 총 수량이 100개일 경우 테스트 결과는 초과 발급 없이 정확히 100개만 발급되었다. 이땐 Race Condition(경합)이 발생하지 않고 작동이 잘되었다.
서버를 2대(8080, 8081)를 띄운 후 k6 부하 테스트로 2000명 이상 동시 요청을 보냈다. 쿠폰 발급이 100개만 정상 발급이 되는 걸 기대했지만 실제로는 200개가 발급되었다. 이유는 각 서버가 서로 다른 JVM 메모리를 갖기 때문에 synchronized
가 서버 간에는 적용되지 않는 것이었다.
결과
즉, 서버 간 Race Condition이 발생했고 동시성 문제가 터져버린 것이다.
synchronized
는 JVM 프로세스 내부에서만 락이 걸리기 때문이다. 서버가 여러 대일 경우 각 JVM 메모리는 서로 독립적으로 돌아간다. 그렇기 때문에 서버 간에 공유되는 공통된 락이 없어서 각 서버가 각자 발급을 시도하게 되어 초과 발급이 발생하는 것이었다.
Redis Lua 스크립트를 사용해서 발급 로직을 변경했다. Redis Lua는 Redis는 하나의 서버가 아니라 모든 서버가 공유하는 중앙 저장소이다. 그래서 Redis 내부에서 Lua 스크립트를 실행하면 중복 체크, 재고 감소, 발급 기록 저장등 트랜잭션처럼 원자적으로 처리할 수 있기 때문에 이는 멀티 서버 환경에서도 Race Condition 없이 쿠폰 수량 초과가 되지 않고 정상적으로 발급할 수 있었다.
결과
멀티 서버 환경에서도 Race Condition 없이 정확한 발급 수량을 지킬 수 있었다.
@Slf4j
@Service
@RequiredArgsConstructor
public class CouponIssueService {
private final CouponRepository couponRepository;
private final CouponIssueProducer couponIssueProducer;
private final RedisCouponService redisCouponService;
public void sendIssueEvent(Long couponId, Long userId) {
// 쿠폰 존재 여부만 검증 (DB 조회)
couponRepository.findById(couponId).orElseThrow(
() -> new ErrorException(ExceptionEnum.COUPON_NOT_FOUND)
);
// Redis Lua를 통해 발급 시도
int totalCount = redisCouponService.getTotalCount(couponId); // 총 수량 가져오기
CouponIssueEnum result = redisCouponService.tryIssueCoupon(couponId, userId, totalCount);
// Redis Lua 발급 결과에 따라 분기
switch (result) {
case SUCCESS -> {
log.info("✅ 쿠폰 발급 성공: couponId={}, userId={}", couponId, userId);
couponIssueProducer.sendIssueEvent(couponId, userId); // Kafka 발행
}
case ALREADY_ISSUED -> {
log.warn("🚫 이미 발급된 유저: couponId={}, userId={}", couponId, userId);
throw new ErrorException(ExceptionEnum.COUPON_ALREADY_USED);
}
case OUT_OF_STOCK -> {
log.warn("🚫 쿠폰 재고 소진: couponId={}", couponId);
throw new ErrorException(ExceptionEnum.COUPON_OUT_OF_STOCK);
}
default -> {
log.error("❌ 알 수 없는 Lua 스크립트 처리 결과: couponId={}, userId={}", couponId, userId);
throw new ErrorException(ExceptionEnum.COUPON_LUA_UNEXPECTED_RESULT);
}
}
}
}
@Slf4j
@Service
@RequiredArgsConstructor
public class RedisCouponService {
private final StringRedisTemplate redisTemplate;
private final CouponRepository couponRepository;
private static final String COUPON_COUNT_KEY_PREFIX = "coupon:%d:count";
private static final String USER_ISSUED_KEY_PREFIX = "coupon:%d:user:%d";
private static final String COUPON_TOTAL_KEY_PREFIX = "coupon:%d:total";
public int getTotalCount(Long couponId) {
String totalKey = String.format(COUPON_TOTAL_KEY_PREFIX, couponId);
String value = redisTemplate.opsForValue().get(totalKey);
if (value != null) {
return Integer.parseInt(value);
}
// Redis에 아직 total이 설정되지 않은 경우 DB에서 가져와 저장
Coupon coupon = couponRepository.findById(couponId)
.orElseThrow(() -> new ErrorException(ExceptionEnum.COUPON_NOT_FOUND));
int total = coupon.getTotalQuantity();
redisTemplate.opsForValue().set(totalKey, String.valueOf(total));
return total;
}
// Lua 스크립트 정의 (원자적 실행)
private static final String LUA_SCRIPT = """
local userKey = KEYS[1]
local countKey = KEYS[2]
local total = tonumber(ARGV[1])
-- 이미 발급받은 유저인지 확인
if redis.call("EXISTS", userKey) == 1 then
return 1 -- 중복 발급
end
-- 현재 발급된 수 확인 (get으로 count 조회)
local current = tonumber(redis.call("GET", countKey) or "0")
if current >= total then
return 2 -- 재고 없음
end
-- 발급 처리
redis.call("INCR", countKey)
redis.call("SET", userKey, "true", "EX", 300)
return 0 -- 발급 성공
""";
public CouponIssueEnum tryIssueCoupon(Long couponId, Long userId, int totalCount) {
String userKey = String.format(USER_ISSUED_KEY_PREFIX, couponId, userId);
String countKey = String.format(COUPON_COUNT_KEY_PREFIX, couponId);
DefaultRedisScript<Long> script = new DefaultRedisScript<>(LUA_SCRIPT, Long.class);
Long result = redisTemplate.execute(
script,
Arrays.asList(userKey, countKey),
String.valueOf(totalCount)
);
if (result == null) return CouponIssueEnum.FAIL;
log.info("[Redis] Lua 실행 결과: {}, couponId={}, userId={}", result, couponId, userId);
return switch (result.intValue()) {
case 1 -> CouponIssueEnum.ALREADY_ISSUED;
case 2 -> CouponIssueEnum.OUT_OF_STOCK;
default -> CouponIssueEnum.SUCCESS;
};
}
}
단일 서버 환경에서는 synchronized
키워드만으로도 동시성을 어느 정도 해결할 수 있다. 하지만 멀티 서버, 분산 환경에서는 JVM 락이 전혀 무의미해지고 초과 발급 같은 심각한 문제가 발생할 수 있다. 서버가 여러 대인 경우 메모리 기반 락(synchronized
, Local Lock
) 은 서버 간 공유가 되지 않아 Race Condition이 반드시 발생할 수밖에 없다는 사실을 직접 경험함으로써 알게 되었다.
그래서 멀티 인스턴스, 대규모 트래픽 환경에서는 외부 공통 저장소(Redis 등)를 통한 중앙 집중형 동기화 처리가 필요**하다는 점을 배웠다. 특히 Redis Lua 스크립트는 중복 발급 방지, 재고 초과 방지, 데이터 일관성 보장을 모두 원자적으로 처리할 수 있다는 점에서 멀티 서버 환경에 최적화된 방법임을 확인했다.
결국, 운영 환경(멀티 서버/트래픽 폭주/예상치 못한 장애 가능성) 을 항상 고려하고 초기에 설계할 때부터 분산 환경을 염두에 두는 것의 중요성을 깨닫게 되었다.