쿠폰 발급 시 일정 기간에 쿠폰을 발급 할 수 있고 발급 수량이 정해져 있다고 가정해보자.
선착순으로 이벤트 쿠폰을 발급해준다고 했을 때, 한 번에 많은 사용자들이 동시에 쿠폰 발급 버튼을 누르면 어떻게 될까?
발급 가능한 수량보다 더 많은 쿠폰이 발급 될 것이다.
⚠️ 왜 이런 상황이 발생할까?
바로 데이터 정합성이 깨지기 때문이다. 데이터 정합성이란 무엇인가 하면, 데이터가 논리적으로 모순없이 올바르게 유지되는 상태를 말한다. 즉, 시스템 내의 데이터가 항상 정확하고 신뢰할 수 있도록 보장하는 개념이다.
모든 트랜잭션 이후에도 데이터가 규칙(rule), 제약조건(constraint), 비즈니스 로직에 맞게 유지되는 성질을 의미한다.
쿠폰 총 수량이 100개 인데, 150명이 동시에 발급 요청을 보내서 120명이 받게되면 실제로 존재하지 않는 쿠폰이 발급된 것으로
데이터 상태가 논리적으로 잘못됨을 알수있다.
사용자가 쿠폰 발급 버튼을 누르면, 애플리케이션은 DB에서 남은 쿠폰 수량을 조회할 것이다. 쿠폰 발급 가능하다고 판단하면 UPDATE를 통해 수량을 감소시키고 발급 완료하는 로직이 실행 될 것이다.
@Transactional
public void issue(Long couponId, Long userId) {
Coupon coupon = findCoupon(couponId);
coupon.isssue(); // 전체 수량에 대한 검증과, 발급 기간에 대한 검증 후 이슈 발급 수 증가.
saveCouponIssue(couponId, userId);
}
// coupon entity 내에 쿠폰 발급 메서드
public void issue() {
if (!avaliableIssueQuantity()) {
throw new CouponIssueException(ErrorCode.INVALID_COUPON_ISSUE_QUANTITY, "발급 가능한 수량을 초과합니다. total : %s, issued : %s".formatted(totalQuantity, issuedQuantity));
}
if (!avaliableIssueDate()) {
throw new CouponIssueException(ErrorCode.INVALID_COUPON_ISSUE_DATE, "발급 가능한 기한이 아닙니다. now : %s, issueStart : %s, issueEnd : %s".formatted(LocalDateTime.now(), dateIssueStart, dateIssueEnd));
}
issuedQuantity ++;
}
❓ 만약, 동시에 쿠폰 발급 버튼을 누르는 상황이 발생하면?
문제 시나리오
- 쿠폰 수량이 totalQuantity = 100, issuedQuantity = 99 라고 가정
- 두 명의 사용자가 동시에 쿠폰 발급을 요청
- 두 트랜잭션이 거의 동시에 다음 순서로 실행됨
트랜잭션 A | 트랜잭션 B |
1. 쿠폰 조회 (issuedQuantity = 99) | 1. 쿠폰 조회 (issuedQuantity = 99) |
2. 발급 가능하다고 판단됨 | 2. 발급 가능하다고 판단됨 |
3. issuedQuantity++ → 100 | 3. issuedQuantity++ → 101 |
4. DB 저장 | 4. DB 저장 |
issuedQuantity = 101로 설정됨
발급 가능한 수량은 100인데, 실제로 초과 발급됨
-> 데이터 정합성 깨짐
coupon.issue()는 메모리 상 연산이기 때문에, 해당 메서드 호출 시점에는 DB 락이 없고, 다른 트랜잭션도 같은 쿠폰 객체를 가져와서 변경 가능하다.
두 트랜잭션이 동일한 쿠폰을 각자의 영속성 컨텍스트에서 관리하고 있다가, 커밋 시점에 @Transactional 내부에서 각각의 변경분이 DB에 반영됨 (즉, 병합) → 충돌 방지 로직이 없으면 뒤늦게 저장한 트랜잭션이 앞선 값을 덮어쓴다.
여기서, 이 문제를 해결하기 위해 DB 락을 이용할 수 있다.
DB 락 (낙관적 락과 비관적 락)
DB 락은 두개의 락으로 표현할 수 있다.
그 중 Optimistic Lock (낙관적 락) 은 말 그대로 충돌이 잘 나지 않을거라고 기대하며 낙관적으로 락을 거는 방법이다.
☑️ Optimistic Lock 예시 (JPA + @Version)
@Entity
public class Coupon {
@Id
private Long id;
private int issuedQuantity;
private int totalQuantity;
@Version
private Long version; // 트랜잭션 충돌 감지용
}
트랜잭션 커밋 시 version이 DB의 값과 일치하는지 확인 한 후,
다른 트랜잭션이 먼저 커밋했다면 → OptimisticLockingFailureException 예외를 발생시킨다.
엔티티에 @Version 필드(숫자형)를 추가하고, 커밋 시점에 DB의 버전 값과 비교한다.
버전이 동일하면 update 수행 + 버전 +1, 버전이 다르면 OptimisticLockingFailureException 예외 발생 → 충돌로 간주하는 방식이다.
낙관적 락은 동시성 처리 성능 높고, 락을 점유하지 않으므로 데드락(deadlock) 발생하지 않는다는 장점이 있다.
하지만, 단점과 한계점도 존재한다.
1. 예외 발생 시 catch 후 재시도 로직 필요 (안 하면 사용자 실패 경험 증가)
2. 충돌이 자주 발생하면, 결국 재시도 횟수 폭증 → 성능 저하 가능
사용자 수가 많고, 동시성 요청이 많을 경우 충돌이 빈번하게 발생하게 되면 재시도 횟수가 늘어나게 된다. 이러한 경우 불가피하게 병목이 발생하게 되고 성능이 저하 될 수 있다.
또한, 즉각적으로 소모되는 자원에 사용하기에는 부적합하다. 왜냐하면 트랜잭션 커밋 직전에야 충돌을 감지하기 때문이다.
결국 낙관적 락은 "된 줄 알았는데 안 되는" 구조다. 그래서 선착순 이벤트 같은 "실시간 자원 소모 제어가 필요한 상황"에서는 부적합하다.
반면에 비관적 락은 충돌이 날 수 있는 상황이라고 생각하며 실제로 미리 DB에 락을 걸어놓는 방식이다.
☑️ Pessimistic Lock 예시 (JPA + FOR UPDATE)
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT c FROM Coupon c WHERE c.id = :id")
Coupon findByIdForUpdate(Long id);
비관적 락 방식은 해당 row를 조회하면서 동시에 쓰기 락(PESSIMISTIC_WRITE)을 획득한다.
다른 트랜잭션은 해당 row에 접근할 수 없고, 락이 풀릴 때까지 대기하게 된다.
비관적 락은 동시성 상황에서 충돌 자체를 사전에 차단하므로, 데이터가 틀어질 가능성이 거의 없다.
또한 트랜잭션 접근이 불가능하므로, 선착순 처리나 재고 감소 등 실시간성 요구에 유리한 구조이다.
하지만, 락이 걸린동안 다른 트랜잭션이 대기해야 하므로 TPS (Transactions per Second) 가 감소하는 성능 저하의 가능성이 있다.
그리고 데드락 문제가 발생한다는 치명적인 단점이 있다.
데드락이란? 두 개 이상의 트랜잭션이 서로가 가진 자원을 기다리면서, 무한히 대기 상태에 빠지는 현상
즉, A는 B의 락을 기다리고, B는 A의 락을 기다리는 상황이다.
문제 시나리오
두 사용자가 서로 계좌 이체 중일 때,
👤 트랜잭션 A: 사용자 A → B에게 돈 이체
UPDATE account SET balance = balance - 100 WHERE user_id = 'A'; -- 🔒 A 계좌 락
-- 여기서 B 계좌 락도 필요함
UPDATE account SET balance = balance + 100 WHERE user_id = 'B'; -- 대기 중
👤 트랜잭션 B: 사용자 B → A에게 돈 이체
UPDATE account SET balance = balance - 200 WHERE user_id = 'B'; -- 🔒 B 계좌 락
-- 여기서 A 계좌 락도 필요함
UPDATE account SET balance = balance + 200 WHERE user_id = 'A'; -- 대기 중
이처럼 서로의 락을 영원히 기다리는 상태가 되는 것이다. 이렇게 되면 DB는 일정 시간 후 둘 중 하나의 트랜잭션을 kill해서 데드락을 해소한다.
Redis 락 (분산락)
Redis 클라이언트에는 Lettuce와 Redisson 이 있는데, Spring Data Redis를 사용하면 기본적으로 지원하는 클라이언트는 Lettuce이다. 때문에 편리하게 사용할 수 있지만, Lettuce 는 기본적으로 락 기능이 없기 때문에, SETNX 기반의 스핀락을 구현해야한다.
SETNX 는 SET if Not exists 라는 Redis 명령어로, 키가 존재하지 않을 때만 값을 저장하는 명령어이다.
SETNX lock:coupon 1
이 명령이 성공하면, 락을 획득한 것이고 실패하면 이미 누가 락을 선점한 상태임을 알 수 있다.
스핀 락이란 락이 안 풀리면 반복적으로 계속 락을 요청해서 락을 차지하려는 방식으로 락이 풀릴 때까지 계속 Redis에 요청을 보내는 방식이다. Lettuce에서는 대기 큐나 락 요청 큐가 없기 때문에, 락을 잡지 못한 클라이언트는 보통 while(true) 루프를 돌면서 계속 SETNX를 요청하게 된다. 잠깐 기다렸다 다시 요청하는 구조로 성능 상 Redis에 부하가 매우 크다.
그리고, 자체적인 타임아웃 구현이 없기 때문에 락을 획득하지 못해 무한 루프를 돈다는가 하는 문제를 해소하려면 코드상으로 직접 구현해야하고, 그 외에도 이 모든 락 시도 로직을 개발자가 직접 구현해야한다.
반면, Redisson 을 이용하면 부하와 타임아웃에 대한 문제를 해결할 수 있다. 또한 락 획득, 점유, 해제, 만료까지 모든 시나리오를 라이브러리 레벨에서 안전하게 다룰 수 있다.
Redisson 은 Redis의 pub/sub 메커니즘을 활용하여 락을 획득/해제하는 로직을 구현하고 있다.
만약 다른 클라이언트가 락을 획득하고 있다가 해제하면, Redisson 은 해당 이벤트를 수신하고 그 다음에 락을 시도한다.
이 과정은 Redis 에게 불필효한 SETNX 요청을 반복적으로 날리지 않기 때문에 부하가 획기적으로 낮아진다.
또한 leaseTime 을 설정해두면 클라이언트가 죽거나 트랜잭션이 중간에 터졌을 때도 자동으로 락이 만료되어 해제된다.
락 해제를 놓쳤을 때 발생할 수 있는 영구 락 점유 문제도 방지 할 수 있다.
이러한 이유 때문에, 분산락을 구현할 때는 Redisson을 사용하는 것이 더 낫다고 생각한다.
Redisson 을 사용한 분산락 구현
우선, Redisson 에 대한 의존성을 설정해주어야한다.
implementation 'org.redisson:redisson-spring-boot-starter:3.18.0'
Redis를 사용할 때 많이 사용하는 Spring Data Redis는 기본 클라이언트로 Lettuce를 사용하기 때문에, Redisson은 추가적인 의존성을 추가해주어야 합니다.
@RequiredArgsConstructor
@Component
public class DistributeLockExecutor {
private final RedissonClient redissonClient;
public void execute(String lockName, long waitMilliSecond, long leaseMilliSecond, Runnable logic) {
RLock lock = redissonClient.getLock(lockName);
try {
boolean isLocked = lock.tryLock(waitMilliSecond, leaseMilliSecond, TimeUnit.MILLISECONDS);
if (!isLocked) {
throw new IllegalStateException("[" + lockName + "] lock 획득 실패");
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
logic.run();
}
}
Redisson을 통해 분산 락을 획득하는 클래스를 작성한 뒤,
public void issueRequest_V1_1(CouponIssueRequestDTO couponIssueRequestDTO) {
distributeLockExecutor.execute("lock_" + couponIssueRequestDTO.couponId(), 10000, 10000, () ->
couponIssueService.issue(couponIssueRequestDTO.couponId(), couponIssueRequestDTO.userId())
);
log.info("쿠폰 발급 완료. couponId: %s, userId: %s ".formatted(couponIssueRequestDTO.couponId(), couponIssueRequestDTO.userId()));
}
// couponIssueService
@Transactional
public void issue(Long couponId, Long userId) {
Coupon coupon = findCouponWithLock(couponId);
coupon.isssue(); // 전체 수량에 대한 검증과, 발급 기간에 대한 검증 후 이슈 발급 수 증가.
saveCouponIssue(couponId, userId);
}
위처럼 구현한 분산락 클래스를 이용하여 쿠폰 발급 시, Redisson 락을 걸어주었다. 위처럼 발급하는 서비스 로직에 락을 걸지 않고, 그 위 메서드에 분산락을 걸어준 이유는 분산락 해제 시점과 트랜잭션 커밋 시점의 불일치 문제를 해결하기 위해서이다.
만약, @Transactional 어노테이션이 붙어있는 issue 메서드에 분산락을 걸어주면, 스프링 AOP 를 통해 issue 메서드 바깥으로 트랜잭션을 처리하는 프록시가 동작하게 된다. 하지만, 락 획득과 해제는 메서드 내부에서 일어나기 때문에 멀티 스레드가 작동할 때 트랜잭션이 커밋 되기 전에 락을 획득하고 해제하는 상황이 발생하게 된다. 때문에, 락 범위가 트랜잭션 범위보다 큰 Facade 형식을 적용한 것이다.
DB락 과 Redis락을 통해 동시성을 제어하는 방법에 대해서 알아보았다.
각각의 특징과 장점 및 단점, 한계점이 있기 때문에 상황에 따라서 적절하게 동시성을 제어해야한다.
상황에 따라서, Redisson 보다 Lettuce 를 사용하는 것이 나을 수도 있고, Redis 락보다 DB 락을 사용하는 것이 나을 수도 있다.
요구사항과 서비스의 규모를 고려하여 락 방식을 선택할 수 있도록 각 락 방식에 대해서 잘 인지하고 있는 것이 필요하다.
'JAVA' 카테고리의 다른 글
[JAVA] 자바의 Object 클래스와 메서드 정리 (1) | 2025.04.18 |
---|---|
[Java] Static (4) | 2024.09.06 |