동시성 제어를 위한 DB 락 (JPA 낙관적·비관적 락 / MySQL Named Lock / Redis 분산 락)

재고 차감, 포인트 적립, 쿠폰 발급처럼 여러 요청이 같은 데이터를 동시에 갱신하는 로직은 한 번쯤 동시성 문제로 사고가 난다. 재고가 1개 남았는데 두 요청이 동시에 들어와 둘 다 차감에 성공해 버리는 식이다. 단일 인스턴스라면 synchronized로 막을 수 있을 것 같지만, 서버를 두 대만 띄워도 JVM 락은 의미가 없어진다. 결국 DB나 외부 저장소 수준의 락이 필요하다.

본론에 들어가기 전에 락의 기본 개념만 짧게 짚고 간다.

  • 공유 락(Shared Lock, S Lock) : 여러 트랜잭션이 동시에 읽을 수는 있지만 쓰기는 막는 읽기 락. 공유 락끼리는 호환된다.
  • 배타 락(Exclusive Lock, X Lock) : 한 트랜잭션이 독점하며 다른 트랜잭션의 읽기·쓰기를 모두 막는 쓰기 락.
  • 비관적 락(Pessimistic Lock) : 충돌이 자주 난다고 가정하고, 데이터를 읽는 시점에 미리 DB 락을 건다. 안전하지만 동시성이 떨어지고 데드락 위험이 있다.
  • 낙관적 락(Optimistic Lock) : 충돌이 드물다고 가정해 락을 걸지 않고, 버전(version) 컬럼으로 갱신 시점에 충돌을 검사한다. 동시성은 좋지만 충돌이 잦으면 재시도 비용이 크다.

이 글에서는 이 개념들을 실제 코드로 어떻게 적용하는지에 집중한다. JPA의 낙관적·비관적 락, MySQL의 Named Lock, Redis 분산 락을 예제 중심으로 다룬다.

예제는 아래 재고 엔티티를 기준으로 한다.

@Entity
public class Stock {

    @Id @GeneratedValue
    private Long id;

    private Long productId;

    private Long quantity;

    public void decrease(Long count) {
        if (this.quantity - count < 0) {
            throw new IllegalArgumentException("재고는 0개 미만이 될 수 없습니다.");
        }
        this.quantity -= count;
    }
}

1. JPA 낙관적 락 (Optimistic Lock)

낙관적 락은 “충돌은 거의 안 일어난다” 고 가정하고 DB 락을 걸지 않는다. 대신 엔티티에 버전(version) 컬럼을 두고, 갱신 시점에 “내가 읽었던 버전이 그대로인가”를 검사한다. 중간에 다른 트랜잭션이 값을 바꿔 버전이 올라갔다면 OptimisticLockException을 던진다.

@Version

@Version 필드만 추가하면 Hibernate가 자동으로 버전 검사 UPDATE를 만든다.

@Entity
public class Stock {

    @Id @GeneratedValue
    private Long id;

    private Long productId;
    private Long quantity;

    @Version
    private Long version;   // Long/Integer 권장 (Instant도 가능, Timestamp는 비권장)

    public void decrease(Long count) {
        if (this.quantity - count < 0) {
            throw new IllegalArgumentException("재고는 0개 미만이 될 수 없습니다.");
        }
        this.quantity -= count;
    }
}

실제로 실행되는 UPDATE는 다음과 같다. WHERE 절에 버전이 포함되는 것이 핵심이다.

UPDATE stock
   SET quantity = ?, version = version + 1
 WHERE id = ? AND version = ?;   -- 읽었던 version과 다르면 0 rows → 예외

영향받은 row가 0개면 누군가 먼저 갱신했다는 뜻이므로 Hibernate가 OptimisticLockException을 던진다.

명시적으로 잠금 모드를 지정하고 싶다면 Repository에 @Lock을 붙인다.

public interface StockRepository extends JpaRepository<Stock, Long> {

    @Lock(LockModeType.OPTIMISTIC)
    @Query("select s from Stock s where s.id = :id")
    Stock findByIdWithOptimisticLock(@Param("id") Long id);
}

낙관적 락의 LockModeType 종류

@Version만 두면 수정한 엔티티는 알아서 버전 검사가 되지만, JPA는 그보다 세밀한 모드를 제공한다. 핵심 구분은 “읽기만 한 데이터도 보호하느냐” 다.

LockModeType 동작 버전 증가
NONE (기본) 락 없음. @Version이 있으면 수정한 엔티티만 커밋 시 자동 검사 수정 시
OPTIMISTIC 위에 더해 읽기만 한 엔티티도 “내가 읽은 뒤 누가 안 바꿨나”를 트랜잭션 끝에 검사 안 함
OPTIMISTIC_FORCE_INCREMENT OPTIMISTIC + 내 트랜잭션이 끝날 때 버전을 강제로 +1
  • OPTIMISTIC : 읽기 전용으로 참조한 값까지 보호한다. 주문 금액 계산에 쓴 상품 가격을 누가 중간에 바꾸면 안 될 때처럼, 반복 읽기를 애플리케이션 레벨에서 보장하고 싶을 때 쓴다.
  • OPTIMISTIC_FORCE_INCREMENT : 컬럼 변경이 없어도 버전을 올려 “논리적 변경”을 충돌로 검출한다. (바로 아래에서 다룬다)
  • 참고로 OPTIMISTIC은 예전 이름이 READ, OPTIMISTIC_FORCE_INCREMENTWRITE였다. 지금은 deprecated 별칭이다.

재시도(Retry)는 호출하는 쪽의 책임

낙관적 락은 충돌 시 예외만 던질 뿐, 재시도는 직접 처리해야 한다. 주의할 점은 재시도가 트랜잭션 경계 밖에서 일어나야 한다는 것이다. 같은 트랜잭션 안에서 다시 조회해 봐야 이미 롤백 대상이라 의미가 없다.

@Service
@RequiredArgsConstructor
public class StockService {

    private final StockRepository stockRepository;

    @Transactional
    public void decrease(Long id, Long count) {
        Stock stock = stockRepository.findByIdWithOptimisticLock(id);
        stock.decrease(count);
        stockRepository.saveAndFlush(stock);   // 커밋 전에 충돌을 앞당겨 확인
    }
}
@Component
@RequiredArgsConstructor
public class OptimisticLockStockFacade {

    private final StockService stockService;

    // 트랜잭션 밖에서 재시도 루프를 돈다
    public void decrease(Long id, Long count) throws InterruptedException {
        while (true) {
            try {
                stockService.decrease(id, count);
                break;
            } catch (ObjectOptimisticLockingFailureException e) {
                Thread.sleep(50);   // 잠깐 대기 후 재시도
            }
        }
    }
}

매번 facade를 만들기 번거롭다면 Spring Retry의 @Retryable로 대체할 수 있다. 다만 @Retryable은 트랜잭션보다 바깥쪽에 있어야 한다. 같은 메서드에 @Transactional과 함께 붙이면 어드바이스 순서에 따라 재시도가 트랜잭션 안에서 돌 수 있고, 그러면 첫 충돌에서 이미 rollback-only로 마킹돼 재시도해도 UnexpectedRollbackException으로 계속 실패한다. 그래서 @RetryableFacade(바깥), @TransactionalService(안쪽) 로 나눈다.

// 바깥: 재시도만 담당 (트랜잭션 없음). 위의 수동 Facade를 이걸로 대체한다
@Component
@RequiredArgsConstructor
public class OptimisticLockStockRetryFacade {

    private final StockService stockService;

    @Retryable(
        retryFor = ObjectOptimisticLockingFailureException.class,
        maxAttempts = 3,
        backoff = @Backoff(delay = 50, multiplier = 2)  // 50 → 100 → 200ms 지수 백오프
    )
    public void decrease(Long id, Long count) {
        stockService.decrease(id, count);   // 매 재시도마다 새 트랜잭션
    }

    // 재시도를 다 써도 실패하면 호출됨 (로그/알림 등)
    @Recover
    public void recover(ObjectOptimisticLockingFailureException e, Long id, Long count) {
        throw new IllegalStateException("재고 차감 재시도 초과: id=" + id, e);
    }
}

고정 간격(delay만)보다 지수 백오프(multiplier) 가 낫다. 경합이 심할 때 같은 간격으로 재시도하면 또 같이 부딪히지만, 간격을 점점 벌리면 충돌이 흩어져 성공률이 올라간다.

OPTIMISTIC_FORCE_INCREMENT

연관 엔티티만 바뀌고 정작 루트 엔티티 컬럼은 그대로일 때, 버전이 올라가지 않아 충돌을 놓치는 경우가 있다. 예를 들어 게시글(Board)은 그대로 두고 댓글(Comment)만 추가하는데, “댓글 추가 = 게시글의 논리적 변경”으로 취급하고 싶을 때다. 이때 OPTIMISTIC_FORCE_INCREMENT를 쓰면 컬럼 변경이 없어도 버전을 강제로 올려 충돌을 검출한다.

@Lock(LockModeType.OPTIMISTIC_FORCE_INCREMENT)
@Query("select b from Board b where b.id = :id")
Board findByIdForUpdate(@Param("id") Long id);

낙관적 락은 이럴 때 : 충돌 빈도가 낮고, 읽기가 많은 환경. 락 대기가 없어 동시성이 좋다. 단, 충돌이 잦으면 재시도 비용이 커지므로 비관적 락을 검토한다.

2. JPA 비관적 락 (Pessimistic Lock)

비관적 락은 반대로 “충돌이 자주 난다” 고 가정하고, 조회 시점에 아예 DB 락을 건다. SELECT ... FOR UPDATE로 행에 배타 락을 걸어, 락이 풀릴 때까지 다른 트랜잭션을 대기시킨다. 재시도 로직이 필요 없는 대신, 락 대기와 데드락을 신경 써야 한다.

@Lock(PESSIMISTIC_WRITE)

public interface StockRepository extends JpaRepository<Stock, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select s from Stock s where s.id = :id")
    Stock findByIdWithPessimisticLock(@Param("id") Long id);
}
@Service
@RequiredArgsConstructor
public class PessimisticLockStockService {

    private final StockRepository stockRepository;

    @Transactional
    public void decrease(Long id, Long count) {
        // 이 시점에 SELECT ... FOR UPDATE 가 나가고, 락을 잡는다
        Stock stock = stockRepository.findByIdWithPessimisticLock(id);
        stock.decrease(count);
    }
}

실행되는 SQL (MySQL InnoDB 기준):

SELECT * FROM stock WHERE id = ? FOR UPDATE;

락은 트랜잭션이 끝나는 시점(커밋/롤백) 에 풀린다. 따라서 락을 잡은 트랜잭션은 가능한 짧게 유지해야 한다.

잠금 모드와 타임아웃

LockModeType 동작 SQL
PESSIMISTIC_READ 공유 락(다른 트랜잭션의 읽기는 허용, 쓰기는 차단) ... FOR SHARE
PESSIMISTIC_WRITE 배타 락(읽기·쓰기 모두 차단) ... FOR UPDATE
PESSIMISTIC_FORCE_INCREMENT 배타 락 + version 증가 FOR UPDATE + version++
  • PESSIMISTIC_READ : “읽는 동안 이 값이 안 바뀌면 된다, 남이 같이 읽는 건 괜찮다”일 때. 공유 락이라 여러 트랜잭션이 동시에 잡을 수 있지만 쓰려는 트랜잭션은 막힌다. (MySQL InnoDB는 FOR SHARE로 구현되며 DBMS마다 차이가 있다.)
  • PESSIMISTIC_WRITE : 재고 차감·결제처럼 읽고 바로 쓸 데이터를 독점한다. 실무에서 비관적 락 하면 대부분 이거다.
  • PESSIMISTIC_FORCE_INCREMENT : DB 락으로 직렬화하면서 버전까지 올려, 락을 안 거는 다른 낙관적 락 트랜잭션에도 변경 사실을 알린다. (비관적·낙관적 혼용 환경)

FORCE_INCREMENT는 낙관적·비관적 양쪽에 다 있는데 의미는 같다 — “실제로 바꾼 컬럼이 없어도 버전을 올려라”. 낙관적 쪽은 충돌 검출용, 비관적 쪽은 DB 락 + 버전 동기화용이다.

무한정 대기하면 위험하므로 락 타임아웃을 거는 것이 안전하다.

@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints({
    // 락 획득을 3초만 기다리고, 못 잡으면 예외
    @QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")
})
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithPessimisticLock(@Param("id") Long id);

타임아웃 동작은 DBMS마다 다르다. MySQL InnoDB는 innodb_lock_wait_timeout(기본 50초)의 영향을 받고, SKIP LOCKED / NOWAIT(MySQL 8.0+) 같은 옵션은 JPA 표준 힌트로는 직접 표현하기 어려워 네이티브 쿼리가 필요할 수 있다.

비관적 락은 이럴 때 : 충돌이 잦고 반드시 직렬화해야 하는 결제·재고 차감 같은 로직. 단, 락을 오래 잡으면 처리량이 급감하고 데드락 위험이 있으니 트랜잭션 범위를 최소화한다.

데드락(Deadlock) — 락을 잡는 순서가 다를 때

비관적 락에서 가장 흔히 만나는 사고가 데드락이다. 두 트랜잭션이 서로가 잡은 락을 기다리며 둘 다 영원히 멈추는 상황이다. 원인은 대부분 단순하다 — 여러 행에 락을 거는데 트랜잭션마다 거는 순서가 다른 것이다.

계좌 이체처럼 두 행(1번, 2번 계좌)에 모두 락이 필요한 경우를 보자.

@Service
@RequiredArgsConstructor
public class TransferService {

    private final AccountRepository accountRepository;

    // 데드락이 나는 코드 — 파라미터로 받은 순서 그대로 락을 잡는다
    @Transactional
    public void transfer(Long fromId, Long toId, Long amount) {
        Account from = accountRepository.findByIdWithPessimisticLock(fromId); // 락 1
        Account to   = accountRepository.findByIdWithPessimisticLock(toId);   // 락 2
        from.withdraw(amount);
        to.deposit(amount);
    }
}

A는 transfer(1, 2, ...), B는 transfer(2, 1, ...)를 동시에 호출하면:

시간 →
A: 1번 락 획득 ──── 2번 락 요청 ⏳ (B가 잡고 있어 대기)
B: 2번 락 획득 ──── 1번 락 요청 ⏳ (A가 잡고 있어 대기)
                              └─ 서로 상대를 기다림 → 데드락

MySQL InnoDB는 이 교착을 자동으로 감지해서 한쪽 트랜잭션을 강제 롤백시킨다. 이때 던져지는 예외다.

com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException:
  Deadlock found when trying to get lock; try restarting transaction

해결은 락을 잡는 순서를 항상 똑같이 맞추는 것이다. ID가 작은 쪽부터 잠그도록 정렬하면, 두 트랜잭션 모두 1번 → 2번 순으로 잡으므로 교착이 생기지 않는다.

@Transactional
public void transfer(Long fromId, Long toId, Long amount) {
    // 항상 작은 ID부터 락을 건다 → 모든 트랜잭션이 같은 순서로 진행
    Long firstId  = Math.min(fromId, toId);
    Long secondId = Math.max(fromId, toId);
    accountRepository.findByIdWithPessimisticLock(firstId);
    accountRepository.findByIdWithPessimisticLock(secondId);

    Account from = accountRepository.getReferenceById(fromId);
    Account to   = accountRepository.getReferenceById(toId);
    from.withdraw(amount);
    to.deposit(amount);
}

데드락을 완전히 0으로 만들기는 어렵다. 락 순서를 맞춰 빈도를 줄이되, 감지된 데드락 예외 (CannotAcquireLockException)는 짧은 재시도로 흡수하는 것이 현실적인 대응이다.

3. MySQL Named Lock (사용자 레벨 락)

비관적 락은 특정 행(row) 에 락을 건다. 그런데 “아직 존재하지 않는 데이터”에 락을 걸어야 할 때가 있다. 예를 들어 회원가입 시 같은 이메일로 동시에 두 번 요청이 들어오면, 아직 INSERT 전이라 잠글 row가 없다. 이럴 때 MySQL의 Named Lock(GET_LOCK) 이 유용하다. 임의의 문자열 이름에 락을 거는 방식이다.

SELECT GET_LOCK('user:email:test@example.com', 3);  -- 3초 대기, 성공 시 1
-- ... 작업 ...
SELECT RELEASE_LOCK('user:email:test@example.com');

구현 시 주의: 반드시 별도 커넥션을 쓴다

Named Lock은 세션(커넥션) 단위로 걸린다. 비즈니스 로직이 쓰는 커넥션에서 락을 잡으면, 락을 잡은 채로 트랜잭션이 얽혀 풀이 꼬이기 쉽다. 별도의 DataSource(커넥션 풀) 를 두고, 락 획득 → 비즈니스 트랜잭션 → 락 해제 순서로 분리하는 것이 정석이다.

// 락 전용 Repository (네이티브 쿼리)
public interface LockRepository extends JpaRepository<Stock, Long> {

    @Query(value = "SELECT GET_LOCK(:key, 3)", nativeQuery = true)
    Integer getLock(@Param("key") String key);

    @Query(value = "SELECT RELEASE_LOCK(:key)", nativeQuery = true)
    Integer releaseLock(@Param("key") String key);
}
@Component
@RequiredArgsConstructor
public class NamedLockStockFacade {

    private final LockRepository lockRepository;
    private final PessimisticLockStockService stockService;  // 실제 차감은 REQUIRES_NEW로

    public void decrease(Long id, Long count) {
        String key = "stock:" + id;
        try {
            Integer result = lockRepository.getLock(key);
            if (result == null || result != 1) {
                throw new IllegalStateException("락 획득 실패: " + key);
            }
            // 락을 잡은 상태에서 별도 트랜잭션으로 비즈니스 로직 실행
            stockService.decrease(id, count);
        } finally {
            lockRepository.releaseLock(key);   // 예외가 나도 반드시 해제
        }
    }
}

비즈니스 로직은 @Transactional(propagation = REQUIRES_NEW) 로 분리해, 락 획득/해제와 트랜잭션 경계가 겹치지 않게 한다.

Named Lock은 이럴 때 : 행이 아닌 임의의 키에 락을 걸어야 할 때(중복 가입, 선착순 처리 등). 주의 — getLock이 락 전용 풀의 커넥션을 점유하므로, 락 풀 크기와 타임아웃을 별도로 관리해야 한다. 해제를 빼먹으면 세션이 끝날 때까지 락이 남는다.

4. Redis 분산 락 (Distributed Lock)

DB 락은 결국 DB에 부하를 집중시킨다. 락 경합이 심하면 DB 커넥션이 락 대기로 묶여 다른 쿼리까지 느려진다. 트래픽이 크고 인스턴스가 여러 대인 환경에서는 Redis로 락을 분리하면 DB 부하를 덜 수 있다.

4-1. Redisson (권장)

직접 SETNX로 구현할 수도 있지만, 락 만료·스핀락·재시도·watchdog(자동 연장) 까지 다뤄야 해서 손이 많이 간다. 실무에서는 이런 것들을 다 처리해 주는 Redisson을 권장한다.

// build.gradle
implementation 'org.redisson:redisson-spring-boot-starter:3.27.0'
@Component
@RequiredArgsConstructor
public class RedissonLockStockFacade {

    private final RedissonClient redissonClient;
    private final PessimisticLockStockService stockService;

    public void decrease(Long id, Long count) {
        RLock lock = redissonClient.getLock("stock:" + id);
        try {
            // 최대 10초 대기, 획득하면 3초간 점유(3초 뒤 자동 해제)
            // leaseTime을 생략하면(tryLock(10, SECONDS)) watchdog이 자동 연장한다
            boolean available = lock.tryLock(10, 3, TimeUnit.SECONDS);
            if (!available) {
                throw new IllegalStateException("락 획득 실패");
            }
            stockService.decrease(id, count);   // REQUIRES_NEW 권장
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException(e);
        } finally {
            if (lock.isHeldByCurrentThread()) {  // 내가 잡은 락만 해제
                lock.unlock();
            }
        }
    }
}

tryLock(waitTime, leaseTime, unit) 의 앞 두 인자가 핵심이다.

  • waitTime : 락을 얻으려고 기다리는 최대 시간 (Redisson은 pub/sub 기반이라 무의미한 스핀이 적다)
  • leaseTime : 락 점유 시간. 이 시간이 지나면 자동 해제되어 데드락을 방지한다.

4-2. 직접 구현한다면 (SETNX + Lua)

Redisson 없이 원리를 이해하려면 직접 구현해 볼 수 있다. 두 가지가 핵심이다.

  1. 락 획득 : SET key value NX PX ttl — 키가 없을 때만(NX) 만료시간(PX)과 함께 설정. 만료시간이 없으면 프로세스가 죽었을 때 락이 영원히 남는다.
  2. 락 해제 : 반드시 내가 건 락인지 확인 후 삭제. 단순 DEL은 위험하다 — 내 락이 만료된 뒤 남이 잡은 락을 내가 지워 버릴 수 있다. 검사와 삭제를 원자적으로 하려고 Lua 스크립트를 쓴다.
@Component
@RequiredArgsConstructor
public class SimpleRedisLock {

    private final StringRedisTemplate redisTemplate;

    // 내 토큰일 때만 삭제 (검사 + 삭제를 원자적으로)
    private static final String UNLOCK_SCRIPT =
        "if redis.call('get', KEYS[1]) == ARGV[1] then " +
        "  return redis.call('del', KEYS[1]) " +
        "else return 0 end";

    public boolean tryLock(String key, String token, Duration ttl) {
        Boolean success = redisTemplate.opsForValue()
            .setIfAbsent(key, token, ttl);   // SET key token NX PX ttl
        return Boolean.TRUE.equals(success);
    }

    public void unlock(String key, String token) {
        redisTemplate.execute(
            new DefaultRedisScript<>(UNLOCK_SCRIPT, Long.class),
            List.of(key),
            token   // 락 획득 시 발급한 고유 토큰 (예: UUID)
        );
    }
}
public void decrease(Long id, Long count) {
    String key = "stock:" + id;
    String token = UUID.randomUUID().toString();   // 락 소유자 식별
    if (!redisLock.tryLock(key, token, Duration.ofSeconds(3))) {
        throw new IllegalStateException("락 획득 실패");
    }
    try {
        stockService.decrease(id, count);
    } finally {
        redisLock.unlock(key, token);   // 내 토큰일 때만 풀린다
    }
}

분산 락의 한계 : Redis 단일 노드 락은 마스터 장애 + 복제 지연 시 두 클라이언트가 동시에 락을 잡을 수 있다. 이를 줄이려는 Redlock 알고리즘이 있지만 논쟁이 있다. 강한 정합성이 절대적으로 필요하면 DB 락(비관적 락)을 우선 검토하고, Redis 분산 락은 처리량이 중요하고 짧은 중복을 비즈니스적으로 감내할 수 있을 때 쓴다.

5. 실무에서 보통 주는 옵션

기법을 골랐다면, 옵션값을 어떻게 잡는지가 다음 고민이다. 실무에서 흔히 쓰는 기본값을 정리한다.

낙관적 락

@Version
private Long version;   // 타입은 Long/Integer (Timestamp는 비권장)
  • 재시도 횟수 : 보통 3회 안팎. 무한 루프는 쓰지 않는다.
  • 백오프 : 고정 50ms보다 지수 + jitter(50 → 100 → 200ms에 난수를 섞음)가 낫다. 경합 시 같이 재충돌하는 걸 흩기 위함이다.
  • 3회를 넘겨야 성공한다면 충돌이 잦다는 신호다 → 비관적 락으로 전환을 검토하는 게 정석이다.

비관적 락

@Lock(LockModeType.PESSIMISTIC_WRITE)   // 실무는 거의 항상 WRITE
@QueryHints(@QueryHint(
    name = "jakarta.persistence.lock.timeout", value = "3000"))   // 3초
  • 모드 : PESSIMISTIC_WRITE가 9할. READ는 거의 안 쓴다.
  • 타임아웃 : 3초 내외로 짧게. MySQL 기본 innodb_lock_wait_timeout(50초)은 너무 길어 줄인다.
  • 트랜잭션 길이 : 락을 잡은 뒤 외부 API 호출·무거운 연산 금지. 락 구간을 최소화한다.
  • 인덱스 필수 : FOR UPDATE 조회 컬럼에 인덱스가 없으면 갭 락/풀스캔 락으로 번져 엉뚱한 행까지 잠근다. (가장 흔한 운영 사고)

분산 락 (Redisson)

lock.tryLock(waitTime, leaseTime, unit);
  • waitTime(대기) : 사용자 응답 경로면 3~5초처럼 짧게. 못 잡으면 빠르게 실패 응답한다.
  • leaseTime(점유) : 두 방식이 있다.
    • 명시(예: 3초) : 단순하고 예측 가능하지만, 로직이 그보다 오래 걸리면 락이 먼저 풀려 위험하다.
    • -1 (watchdog) : 기본 30초 점유 + 10초마다 자동 연장. 수행시간 예측이 어려운 작업에 안전하다. 단 unlock을 반드시 호출해야 한다(안 하면 최대 30초까지 점유).
  • 실무 기본값은 “waitTime은 짧게, leaseTime은 watchdog(-1) 또는 로직 최대시간보다 약간 길게”.

한 줄 요약 — 낙관적: 재시도 3회 + 지수 백오프 / 비관적: WRITE + 타임아웃 3초 + 인덱스 / 분산: waitTime 짧게 + leaseTime은 watchdog.

6. 실전 사례: 중복 결제 막기

지금까지의 기법을 한데 모아 볼 좋은 예가 중복 결제 방지다. 결론부터 말하면, 중복 결제는 “락”보다 한 단계 위 개념인 멱등성(idempotency) 으로 접근하고, 그 멱등성을 보장하는 수단으로 DB 제약·락을 쓰는 것이 정석이다.

단순 락만으론 부족하다

중복 결제는 보통 버튼 더블클릭, 타임아웃 후 재시도, 네트워크 재전송으로 발생한다. 가장 흔한 잘못된 코드가 “조회해서 없으면 INSERT”(check-then-act)다.

// 동시에 들어오면 둘 다 통과하는 경쟁 조건
if (paymentRepository.findByOrderId(orderId) == null) {  // ① 둘 다 null 확인
    paymentRepository.save(new Payment(orderId, ...));    // ② 둘 다 INSERT → 중복!
}

두 요청이 ①을 동시에 통과하면 ②가 둘 다 실행된다. 비관적 락(FOR UPDATE)으로 막으려 해도 아직 결제 row가 없어 잠글 대상이 없다. 행 락으로는 “존재하지 않는 것”을 막지 못한다.

1순위 — 멱등키 + UNIQUE 제약

결제 요청마다 클라이언트가 고유 키(또는 orderId)를 보내고, DB에 UNIQUE 제약을 건다.

ALTER TABLE payment ADD CONSTRAINT uk_idempotency UNIQUE (idempotency_key);
// 안쪽: INSERT만. 중복이면 예외를 던지고 트랜잭션 롤백
@Service
@RequiredArgsConstructor
public class PaymentService {

    private final PaymentRepository paymentRepository;

    @Transactional
    public Payment create(String idempotencyKey, Long orderId, ...) {
        return paymentRepository.save(new Payment(idempotencyKey, orderId, ...));
    }

    @Transactional(readOnly = true)
    public Payment findByKey(String idempotencyKey) {
        return paymentRepository.findByIdempotencyKey(idempotencyKey);
    }
}
// 바깥: 제약 위반은 트랜잭션 밖에서 잡아, 새 트랜잭션으로 기존 결과를 조회
@Component
@RequiredArgsConstructor
public class PaymentFacade {

    private final PaymentService paymentService;

    public Payment pay(String idempotencyKey, Long orderId, ...) {
        try {
            return paymentService.create(idempotencyKey, orderId, ...);
        } catch (DataIntegrityViolationException e) {
            // 이미 같은 키로 결제됨 → 기존 결과를 반환 (재시도도 같은 응답)
            return paymentService.findByKey(idempotencyKey);
        }
    }
}

UNIQUE 제약 자체가 DB가 보장하는 배타 락이라, check-then-act 경쟁 조건이 아예 사라진다. 두 번째 INSERT는 무조건 실패하므로 락을 직접 잡을 필요도 없다. 단, 제약 위반이 나면 그 트랜잭션은 롤백되므로 재조회는 위처럼 트랜잭션 밖(새 트랜잭션)에서 해야 한다. 같은 트랜잭션 안에서 이어 조회하면 UnexpectedRollbackException으로 실패한다.

2순위 — 상태 전이엔 비관적 락

이미 존재하는 결제 건의 상태를 바꿀 때(예: READY → PAID)는 비관적 락이 잘 맞는다.

Payment p = paymentRepository.findByIdWithPessimisticLock(paymentId); // FOR UPDATE
if (p.getStatus() != READY) {
    throw new AlreadyPaidException();   // 중복 승인 차단
}
p.markPaid();

결제 row를 만들기 전 단계에서 같은 주문을 직렬화하고 싶다면, 행이 없으므로 앞서 본 Named Lock(키: payment:order:{orderId})이 맞다.

⚠️ 락을 잡은 채 PG API를 호출하지 말 것

결제는 외부 PG사 호출이 섞인다. 락을 잡은 채 PG API를 부르면, 응답이 느릴 때 락이 수 초간 잡혀 다른 요청을 모두 막는다. 비관적 락 절에서 강조한 “락 구간에서 외부 API 금지”가 결제에서 특히 중요하다. PG사에도 멱등키를 함께 넘겨(대부분 Idempotency-Key 헤더 지원) PG 레벨에서도 중복을 거르게 하는 것이 표준이다.

요약 — 신규 결제 생성은 멱등키 + UNIQUE 제약(1순위), 상태 전이는 비관적 락, 키 단위 직렬화는 Named Lock, 그리고 외부 PG에도 멱등키를 넘긴다.

7. 정리 — 어떤 락을 선택할까

기법 락 위치 적합한 상황 핵심 주의점
JPA 낙관적 락 애플리케이션(version) 충돌이 드물고 읽기 위주 충돌 시 재시도 로직 필수, 트랜잭션 밖에서 재시도
JPA 비관적 락 DB 행(FOR UPDATE) 충돌이 잦고 직렬화 필수 락 대기·데드락, 트랜잭션 짧게 유지
MySQL Named Lock DB 세션(이름) 행이 없는 대상(중복 가입 등) 별도 커넥션 풀 필수, 해제 누락 주의
Redis 분산 락 Redis 다중 인스턴스, DB 부하 분산 락 만료·소유자 검증, 강한 정합성엔 부적합

판단 순서는 단순하다.

  1. 충돌이 드물다 → 낙관적 락. 가장 가볍다.
  2. 충돌이 잦고 단일 DB로 충분하다 → 비관적 락. 재시도 없이 직렬화된다.
  3. 잠글 행이 없거나 임의 키 단위로 막아야 한다 → Named Lock.
  4. 인스턴스가 여러 대고 DB 락으로는 부하를 못 견딘다 → Redis 분산 락. 단, 정합성 한계를 감안한다.

가장 흔한 실수는 분산 환경에서 synchronized로 막으려는 것과, 낙관적 락을 걸어 놓고 재시도를 빼먹는 것이다. 락은 “걸었다”가 끝이 아니라 “언제 풀리고, 실패하면 어떻게 되는가“까지 설계해야 비로소 동작한다.

이 글은 여러 요청이 같은 데이터(행)를 동시에 갱신하는 문제를 다뤘다. 반면 이중화된 배치·스케줄러가 같은 잡을 두 번 실행하는 문제(ShedLock·Quartz·SKIP LOCKED)는 결이 달라 별도로 정리했다 — 이중화된 배치·스케줄러 중복 실행 막기