본문 바로가기

Programming/Technical Writing

동시성 제어: 트랜잭션 락은 언제 쓰는가

TL;DR 동시성 제어 — 락을 쓰기 전에 락이 필요한지부터 판단하자

 

이커머스 시스템에서 쿠폰 차감, 재고 감소, 좋아요 카운트 같은 기능을 만들다 보면 동시성 문제를 만나게 된다. 여러 요청이 같은 데이터를 동시에 수정하면 데이터 정합성이 깨질 수 있기 때문이다. 이 글에서는 동시성 문제를 해결하는 세 가지 접근 방식을 "언제 쓰는가" 중심으로 정리한다.

 

 

1. 아토믹 업데이트 vs Read-Modify-Write.

2. 낙관적 락 vs 비관적 락

3. 비관적 락: FOR UPDATE

4. 마무리: 동시성 제어 판단 흐름

 


1. 아토믹 업데이트 vs Read-Modify-Write

아토믹 업데이트

SQL 한 문장으로 조건 판단과 갱신을 동시에 처리하는 방식이다. DB가 이 쿼리를 원자적으로 실행하므로 락이 필요 없다.

UPDATE coupon_stock
SET quantity = quantity - 1
WHERE coupon_id = 1 AND quantity > 0;

affected rows = 0이면 재고가 없다고 판단하면 된다. 중간에 다른 트랜잭션이 끼어들 여지가 없다.

 

Read-Modify-Write

기존 값을 읽고 → 애플리케이션에서 판단하고 → 다시 쓰는 패턴이다. 이 패턴이 등장하는 순간 동시성 문제가 생긴다.

// Read
int qty = couponRepo.getQuantity(couponId);  // qty = 1

// Modify
if (qty > 0) {
    // Write
    couponRepo.decreaseQuantity(couponId);
}

두 요청이 동시에 qty = 1을 읽으면, 둘 다 조건을 통과해서 재고가 -1이 된다.

 

언제 아토믹 업데이트가 불가능한가

다른 테이블을 조회해서 판단해야 하는 경우에는 SQL 한 문장으로 표현할 수 없다.

// 유저 등급을 읽어와서 할인률을 결정해야 하는 경우
User user = userService.findById(userId);       // Read (다른 테이블)
BigDecimal discount = user.isGold() ? 0.05 : 0; // Modify (앱 레벨 판단)
order.applyDiscount(discount);                   // Write

이처럼 Read와 Write 사이에 앱 레벨의 로직이 개입하면 아토믹 업데이트로 해결할 수 없고, 락이 필요해진다.

Read-Modify-Write를 피할 수 있으면 피하자. SQL 한 문장으로 해결되는 문제에 락을 거는 건 오버엔지니어링이다.


2. 낙관적 락 vs 비관적 락

아토믹 업데이트로 해결이 안 되면, 락을 선택해야 한다. 판단 기준은 하나다.

같은 row에 동시 충돌이 얼마나 자주 일어나는가?

낙관적 락 (Optimistic Lock)

충돌이 드문 경우에 사용한다.

락을 잡지 않고 자유롭게 읽고 쓴다. 대신 쓰기 시점에 version 컬럼을 확인해서, 내가 읽은 이후 다른 트랜잭션이 수정했으면 예외를 발생시킨다.

@Entity
public class UserCoupon {
    @Version
    private Long version;

    private boolean used;
}
@Transactional
public void useCoupon(Long couponId) {
    UserCoupon coupon = couponRepo.findById(couponId).orElseThrow();
    coupon.markUsed();
    // 커밋 시점에 version 불일치 → OptimisticLockException
}

적합한 예시: 개인 쿠폰 사용. 한 사용자가 자기 쿠폰을 동시에 두 번 사용하는 경우는 극히 드물다. 대부분 중복 클릭 정도이므로, 실패 시 "이미 사용된 쿠폰입니다"로 응답하면 된다.

 

비관적 락 (Pessimistic Lock)

충돌이 빈번한 경우에 사용한다.

읽는 시점에 row를 잠가서 다른 트랜잭션의 접근을 차단한다. 대기 비용이 있지만, 재시도 폭풍이 발생하지 않는다.

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT c FROM Coupon c WHERE c.id = :id")
@QueryHints({@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")})
Optional<Coupon> findByIdForUpdate(@Param("id") Long id);

적합한 예시: 선착순 수량 제한 쿠폰. "100명 한정 50% 할인" 이벤트에서 수천 명이 동시에 같은 row를 향해 요청을 보낸다.

 

낙관적 → 비관적 전환이 필요해지는 순간

서비스 초기에 낙관적 락으로 잘 동작하던 쿠폰 발급이 있다고 하자.

6개월 뒤, 마케팅팀에서 "오후 2시 정각, 50장 한정 70% 할인 쿠폰" 이벤트를 기획한다. 수천 명이 동시에 요청하면 이런 일이 벌어진다.

14:00:00.000  사용자 500명 동시 요청
    → 500명 모두 version = 1로 읽음
    → 1명만 성공, 499명 OptimisticLockException
    → 499명 재시도, 다시 1명만 성공
    → 498명 재시도...

50장을 발급하는 데 수만 번의 트랜잭션이 돌면서 재시도 폭풍이 발생한다. DB CPU가 치솟고 커넥션 풀이 고갈되며, 쿠폰과 무관한 다른 기능까지 영향을 받는다.

비관적 락으로 바꾸면 500명이 와도 한 명씩 순서대로 처리된다. 각 트랜잭션이 수 밀리초면 끝나니까, 사용자가 느끼는 체감 지연은 크지 않다.

 

전환 징후 정리

징후의미
OptimisticLockException 발생률 급증 동시 충돌이 잦아졌다
재시도 3회 이상 반복되는 요청 증가 낙관적 락의 전제가 무너졌다
특정 시간대 DB 커넥션 풀 사용률 급등 재시도로 인한 트랜잭션 누적

3. 비관적 락: FOR UPDATE

비관적 락은 SQL에서 SELECT ... FOR UPDATE로 구현된다.

기본 동작

-- 일반 조회: 락 없음
SELECT * FROM coupon_stock WHERE coupon_id = 1;

-- 비관적 락 조회: 해당 row에 배타적 잠금
SELECT * FROM coupon_stock WHERE coupon_id = 1 FOR UPDATE;

FOR UPDATE를 붙이면 DB가 해당 row에 **배타적 잠금(Exclusive Lock)**을 건다. 다른 트랜잭션이 같은 row에 FOR UPDATE나 UPDATE를 시도하면 대기 상태에 빠진다.

 

락의 생명주기

트랜잭션 A                          트랜잭션 B
─────────                          ─────────
BEGIN;
SELECT ... FOR UPDATE;  ← 락 획득
                                   BEGIN;
                                   SELECT ... FOR UPDATE;  ← 대기
UPDATE ... ;
COMMIT;                 ← 락 해제
                                   ← 락 획득, 진행
                                   COMMIT;

락은 트랜잭션이 끝날 때(COMMIT 또는 ROLLBACK) 자동으로 해제된다. 별도로 해제하는 명령이 없다.

 

JPA에서의 사용

@Lock(LockModeType.PESSIMISTIC_WRITE)  // → FOR UPDATE 생성
@Query("SELECT c FROM CouponStock c WHERE c.couponId = :couponId")
@QueryHints({@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")})
Optional<CouponStock> findByCouponIdForUpdate(@Param("couponId") Long couponId);
@Transactional  // 메서드 종료 → 커밋 → 락 해제
public void useCoupon(Long couponId) {
    CouponStock stock = stockRepo.findByCouponIdForUpdate(couponId);
    stock.decrease();
}

주의: 트랜잭션이 길면 락도 길다

락이 트랜잭션 종료까지 유지되므로, FOR UPDATE 이후에 오래 걸리는 작업(외부 API 호출, 파일 처리 등)을 넣으면 그만큼 다른 요청이 대기한다. 락을 잡는 구간은 최대한 짧게 유지해야 한다.


마무리: 동시성 제어 판단 흐름

SQL 한 문장으로 해결 가능한가?
  ├─ YES → 아토믹 업데이트 (락 불필요)
  └─ NO → 동시 충돌이 자주 발생하는가?
              ├─ 드물다 → 낙관적 락 (@Version)
              └─ 빈번하다 → 비관적 락 (FOR UPDATE)
                              └─ 그마저도 병목? → Redis 등 외부 솔루션 검토

락을 고르는 것보다 중요한 건, 락이 정말 필요한지를 먼저 판단하는 것이다. 아토믹 업데이트로 충분한 문제에 비관적 락을 거는 건 오버엔지니어링이고, 충돌이 빈번한 곳에 낙관적 락을 고집하는 건 장애를 부르는 일이다.

 

동시성 제어에서 가장 비싼 실수는 잘못된 락을 고르는 게 아니라, 락이 필요 없는 곳에 락을 거는 것이다. SQL 한 문장으로 끝나는 문제인지 먼저 확인한 다음, 락을 선택한다.