어제 오늘 내일

[Spring Boot] "선착순 100명인데 101명이 당첨?" 동시성 이슈 해결: Redis 분산락 (feat. Redisson) 본문

IT/SpringBoot

[Spring Boot] "선착순 100명인데 101명이 당첨?" 동시성 이슈 해결: Redis 분산락 (feat. Redisson)

hi.anna 2026. 3. 21. 01:45

 

쇼핑몰이나 티켓팅 서비스를 개발할 때 가장 무서운 순간이 언제일까요? 바로 "재고 관리"입니다.
재고 = 재고 - 1이라는 아주 간단한 로직도, 수천 명이 동시에 누르면 재앙(Race Condition)이 발생합니다.

오늘은 자바의 synchronized 키워드로는 해결할 수 없는 다중 서버 환경에서의 동시성 문제Redis와 Redisson 라이브러리를 사용해 완벽하게 해결해 보겠습니다.


1. 왜 동시성 문제가 생기나요? (Race Condition)

가장 흔한 재고 감소 로직을 볼까요?

// 재고 감소 로직 (문제 있음!)
public void decrease(Long id) {
    Stock stock = stockRepository.findById(id).orElseThrow();
    stock.decrease(1L); // 수량 1 감소
    stockRepository.saveAndFlush(stock);
}

이 코드는 혼자 테스트할 땐 잘 됩니다. 하지만 쓰레드 A쓰레드 B가 동시에 들어오면 어떻게 될까요?

  1. 쓰레드 A: 현재 재고 100개 확인. (아직 안 줄임)
  2. 쓰레드 B: 현재 재고 100개 확인. (A가 줄이기 전이라 100개로 보임!)
  3. 쓰레드 A: 99개로 저장.
  4. 쓰레드 B: 99개로 저장.

결과적으로 2명이 샀는데 재고는 1개만 줄어드는 마법이 일어납니다. 이것이 바로 경쟁 상태(Race Condition)입니다.


2.  쓰면 안 되나요?

자바 개발자라면 synchronized 키워드를 먼저 떠올리실 겁니다.

// 자바의 기본 락
public synchronized void decrease(Long id) { ... }

하지만 이건 서버가 1대일 때만 통합니다.
요즘 서비스들은 트래픽 분산을 위해 서버를 여러 대(Scale-out) 띄우죠? synchronized는 각 서버(프로세스) 안에서만 동작하기 때문에, 서버 1의 락은 서버 2를 막지 못합니다.

그래서 우리는 모든 서버가 공통으로 바라보는 외부의 "락 관리자"가 필요합니다. 그게 바로 Redis입니다.


3. Redis 분산락 라이브러리: Lettuce vs Redisson

Redis 클라이언트에는 크게 두 가지가 있습니다.

  1. Lettuce: 스프링 부트 기본 라이브러리. setnx 명령어를 사용해 락을 구현하지만, 스핀 락(Spin Lock) 방식이라 Redis에 부하를 많이 줍니다. (계속 "락 풀렸니?" 물어봄)
  2. Redisson: Pub/Sub 방식을 사용합니다. 락이 해제되면 "야, 락 풀렸어!"라고 알려주기 때문에 부하가 적고, 사용법이 훨씬 쉽습니다. (강력 추천!)

오늘은 Redisson을 사용하겠습니다.


4. 구현하기: Redisson으로 락 걸기

① 의존성 추가 ()

implementation 'org.redisson:redisson-spring-boot-starter:3.23.1'

② 서비스 로직 수정 ()

이제 비즈니스 로직 앞뒤로 락을 걸고 푸는 코드를 넣어줍니다.

@Service
@RequiredArgsConstructor
public class StockService {

    private final RedissonClient redissonClient; // Redisson 주입
    private final StockRepository stockRepository;

    public void decrease(Long key, Long quantity) {
        // 1. 락 이름 정의 (상품 ID마다 고유한 락 생성)
        RLock lock = redissonClient.getLock("stock_lock_" + key);

        try {
            // 2. 락 획득 시도 (tryLock)
            // waitTime: 락을 기다리는 시간 (10초)
            // leaseTime: 락을 점유하는 시간 (1초 지나면 자동 해제)
            boolean available = lock.tryLock(10, 1, TimeUnit.SECONDS);

            if (!available) {
                System.out.println("락 획득 실패! (사람이 너무 많아요)");
                return;
            }

            // 3. ★ 핵심 로직 (안전 구역)
            Stock stock = stockRepository.findById(key).orElseThrow();
            stock.decrease(quantity);
            stockRepository.saveAndFlush(stock);

        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            // 4. 락 해제 (필수!)
            if (lock.isLocked() && lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

코드 설명

  1. getLock: 락 객체를 가져옵니다. 같은 이름("stock_lock_1")을 쓰면 같은 자물쇠를 공유하게 됩니다.
  2. tryLock(10, 1, ...):
  • 첫 번째 인자(10초): 락을 얻기 위해 기다리는 시간. 10초 동안 못 얻으면 false 반환.
  • 두 번째 인자(1초): 락을 얻고 나서 유지하는 시간. 이 시간이 지나면 서버가 죽어도 자동으로 락이 풀립니다. (데드락 방지)
  1. finally { unlock }: 로직이 성공하든 에러가 나든 반드시 락을 반납해야 다음 사람이 들어올 수 있습니다.

5. 트랜잭션 주의사항 (AOP의 필요성)

위 코드에는 사실 미세한 구멍이 있습니다.
@Transactional과 락을 같이 쓸 때, "트랜잭션이 커밋되기 전에 락이 먼저 풀려버리는" 문제가 생길 수 있습니다.

  • 문제 상황:
  1. 락 해제 (unlock)
  2. 다른 쓰레드 진입 (재고 읽음)
  3. 트랜잭션 커밋 (DB 업데이트) -> 다른 쓰레드는 옛날 값을 읽게 됨!
  • 해결책:
  • 락을 거는 범위가 트랜잭션 범위보다 넓어야 합니다.
  • 보통 Facade 패턴을 써서 락을 거는 클래스트랜잭션을 수행하는 클래스를 분리하거나, AOP를 직접 구현해서 해결합니다.
// Facade 패턴 예시
@Component
public class StockFacade {
    public void decrease(Long id) {
        // 1. 락 획득
        try {
             lock.tryLock(...)
             stockService.decrease(id); // 2. 트랜잭션 메서드 호출
        } finally {
             lock.unlock(); // 3. 락 해제
        }
    }
}

마치며

오늘의 결론입니다.

  1. 동시성 이슈는 다중 서버 환경에서 synchronized로 해결할 수 없다.
  2. Redis 분산락은 여러 서버가 공유하는 '외부 자물쇠' 역할을 한다.
  3. Redisson 라이브러리를 쓰면 Pub/Sub 방식으로 부하 없이 락을 구현할 수 있다.

이제 여러분의 서비스는 선착순 이벤트가 열려도, 재고가 꼬이지 않고 정확하게 100명에게만 혜택을 줄 수 있게 되었습니다.
다음 포스팅에서는 성능 최적화의 또 다른 축, "데이터 100만 건을 매일 밤마다 처리해야 한다면?" 대용량 데이터 처리를 위한 Spring Batch에 대해 알아보겠습니다.
도움이 되셨다면 좋아요와 댓글 부탁드립니다! 😊

반응형
Comments