dev notes

MySQL 동시성 제어: S/X 락부터 선착순 쿠폰 경합까지

2023-06-1511 min read
공유

개요#

선착순 쿠폰 발급 기능을 운영하면서 동시성 문제를 직접 맞닥뜨렸습니다. 이벤트 오픈 직후 수십 명이 동시에 쿠폰 발급 버튼을 누르는데, 같은 쿠폰 재고에 한꺼번에 접근하다 보니 경합이 바로 터졌습니다.

처음에는 단순히 "동시 요청이 많아서 그런가 보다" 정도로 봤는데, 조금만 들여다보니 결국 락을 어떻게 잡고 얼마나 오래 쥐고 있는지까지 같이 봐야 하는 문제였습니다. 그 일을 계기로 MySQL의 락 메커니즘을 다시 파고들게 됐습니다.

동시성 환경에서 생기는 일#

멀티스레딩 환경에서는 여러 스레드가 동시에 동일한 코드를 실행할 수 있습니다. 각 스레드는 독립적으로 실행되지만 같은 데이터에 접근하는 순간 문제가 생깁니다.

동시성 이슈가 까다로운 이유:

  1. 비결정적입니다. 동일한 환경과 조건에서도 매번 다른 결과가 나올 수 있습니다. 재현이 어렵습니다.
  2. 로컬에서 안 터집니다. 단일 스레드로 테스트하면 멀쩡한데 운영에 올리면 터집니다.
  3. 코드에서 안 보입니다. 특정 메서드가 스레드 안전한지 확인하려면 그 메서드가 건드리는 모든 데이터를 추적해야 합니다.

MySQL의 S/X 락#

InnoDB 스토리지 엔진은 행 단위 락을 제공합니다. (MySQL InnoDB Locking) 크게 두 종류입니다.

S/X 락의 동작은 트랜잭션 격리 수준에 따라 달라집니다. InnoDB의 기본 격리 수준인 REPEATABLE READ에서는 일반 SELECT는 락을 걸지 않고(consistent read), FOR SHAREFOR UPDATE를 명시해야 락이 걸립니다.

공유락 (S Lock) — 읽기용#

sql
SELECT * FROM coupon_policy WHERE id = 1 FOR SHARE;
  • 여러 트랜잭션이 동시에 획득할 수 있습니다
  • S Lock이 걸린 동안 다른 트랜잭션은 해당 행을 읽을 수 있지만, 수정할 수 없습니다
  • 읽기 일관성을 보장하면서 동시 읽기를 허용하기 때문에 "공유"락이라는 이름이 붙었습니다

배타적락 (X Lock) — 쓰기용#

sql
SELECT * FROM coupon_stock WHERE coupon_id = 1 FOR UPDATE;
  • 한 번에 하나의 트랜잭션만 획득할 수 있습니다
  • X Lock이 걸린 동안 다른 트랜잭션은 해당 행을 읽는 것도, 수정하는 것도 불가능합니다
  • 데이터 변경의 안전성을 완전히 보장합니다

호환성#

S Lock 요청X Lock 요청
S Lock 보유 중허용대기
X Lock 보유 중대기대기

S끼리는 공존 가능하지만, X가 하나라도 걸리면 나머지는 대기합니다.

실전: 선착순 쿠폰 재고 차감#

선착순 쿠폰 발급 시스템에서 다룬 구조를 바탕으로, 핵심은 재고 차감입니다. 쿠폰 100장이 있으면 100명만 받을 수 있어야 합니다.

문제 상황#

이벤트 오픈 직후 동시에 수십 건의 요청이 들어옵니다. 락 없이 처리하면 이런 일이 생깁니다:

시점  트랜잭션A              트랜잭션B
──────────────────────────────────────
t1   SELECT stock → 100
t2                           SELECT stock → 100
t3   UPDATE stock = 99
t4                           UPDATE stock = 99  ← 100에서 99로 바꿔야 하는데
t5   COMMIT                                        A가 이미 99로 바꿈
t6                           COMMIT             ← 재고 99, 쿠폰은 2장 나감

재고는 1만 줄었는데 쿠폰은 2장 발급됐습니다.

비관적 락으로 해결 (FOR UPDATE)#

java
@Query("SELECT s FROM CouponStock s WHERE s.couponId = :couponId")
@Lock(LockModeType.PESSIMISTIC_WRITE) // FOR UPDATE
CouponStock findByCouponIdForUpdate(@Param("couponId") Long couponId);

FOR UPDATE는 해당 행에 X Lock을 겁니다. 트랜잭션A가 재고를 읽는 순간 다른 트랜잭션은 대기해야 합니다. 순서가 보장되기 때문에 재고 정합성이 유지됩니다.

하지만 비관적 락은 대기 시간이 길어지면 커넥션을 점유하는 시간도 늘어납니다. 동시 요청이 많으면 HikariCP 커넥션 풀이 고갈될 수 있습니다. Thread Starvation 장애 해결기에서 비슷한 패턴을 겪었습니다.

낙관적 락으로 해결 (@Version)#

실제로 적용한 방식은 낙관적 락입니다.

java
@Entity
public class CouponStock {
    @Id
    private Long id;
    private Long couponId;
    private int remainingStock;
 
    @Version
    private Long version;
 
    public void decrease() {
        if (this.remainingStock <= 0) {
            throw new IllegalStateException("재고가 소진되었습니다.");
        }
        this.remainingStock--;
    }
}

@Version을 쓰면 JPA가 UPDATE 시 자동으로 버전을 체크합니다. (JPA Optimistic Locking)

sql
UPDATE coupon_stock
SET remaining_stock = 99, version = 2
WHERE id = 1 AND version = 1;

다른 트랜잭션이 먼저 업데이트해서 version이 바뀌어 있으면 OptimisticLockException이 발생합니다. 이때 재시도 로직으로 다시 읽고 다시 차감합니다.

java
@Retryable(
    retryFor = OptimisticLockException.class,
    maxAttempts = 3,
    backoff = @Backoff(delay = 50)
)
@Transactional
public void issueCoupon(Long couponId, Long memberId) {
    CouponStock stock = couponStockRepository.findByCouponId(couponId);
    stock.decrease();
    couponRepository.save(Coupon.create(couponId, memberId));
}

@Retryable로 최대 3회 재시도, 50ms backoff. 재시도할 때마다 새 트랜잭션에서 최신 version을 읽기 때문에 충돌이 해소됩니다.

비관적 vs 낙관적#

비관적 락낙관적 락
방식DB에서 행 잠금 (FOR UPDATE)버전 체크 후 실패 시 재시도
충돌 빈도 낮을 때불필요한 락 오버헤드재시도 거의 없어서 효율적
충돌 빈도 높을 때대기 시간 길어짐재시도 빈번하지만 락 대기 없음
커넥션 점유락 대기 중에도 점유실패 즉시 반환
데드락 위험있음없음

선착순 쿠폰은 이벤트 초반에만 경합이 몰리고, 재고가 줄어들수록 경합이 줄어듭니다. 낙관적 락이 이 패턴에 더 맞았습니다.

데드락과 연쇄 장애#

비관적 락을 쓸 때 주의할 점이 데드락입니다.

Loading diagram...

MySQL은 데드락을 감지하면 한쪽 트랜잭션을 강제 롤백시킵니다. (MySQL Deadlock Detection) 데드락 자체로 인스턴스가 죽지는 않습니다.

하지만 데드락이 연쇄적으로 발생하면 문제가 커집니다. 락 대기 중인 트랜잭션이 커넥션을 계속 점유하고, 새로운 요청은 커넥션을 못 받고 대기하고, 결국 HikariCP 풀이 고갈되면서 서비스 전체가 멈출 수 있습니다.

실제로 선착순 이벤트 초반에 이런 패턴으로 인스턴스가 응답 불능 상태에 빠진 적이 있습니다. 데드락 자체가 아니라, 데드락으로 인한 커넥션 풀 고갈이 원인이었습니다.

예방 방법#

  1. 트랜잭션을 짧게 유지합니다. 락을 잡고 있는 시간을 최소화해야 합니다. 외부 API 호출이나 무거운 로직은 트랜잭션 밖에서 처리합니다.
  2. 접근 순서를 통일합니다. 여러 행에 락을 걸어야 한다면 항상 같은 순서로 접근해야 데드락을 피할 수 있습니다.
  3. 락 타임아웃을 설정합니다. innodb_lock_wait_timeout으로 무한 대기를 방지합니다.
  4. 낙관적 락을 우선 고려합니다. 충돌 빈도가 낮으면 낙관적 락이 커넥션 점유 측면에서 유리합니다.

S/X 락 자체는 개념만 보면 단순합니다. 그런데 실서비스에서 동시 요청이 몰리기 시작하면 그 단순한 개념이 바로 응답 지연, 데드락, 커넥션 풀 고갈 같은 형태로 드러납니다.

선착순 쿠폰처럼 경합이 자연스럽게 생기는 기능에서는 락 전략을 뭘 쓸지뿐 아니라, 데드락을 어떻게 피할지, 락 대기가 커넥션 풀에 어떤 영향을 주는지까지 같이 봐야 했습니다. 결국 문제는 락 하나가 아니라, 그 락이 시스템 전체에 어떻게 번지는가에 더 가까웠습니다.

Connected Notes