배경1. 비관적 락 (Pessimistic Lock) — SELECT ... FOR UPDATE2. 낙관적 락 (Optimistic Lock) — @VersionDB 락도 여러 서버에서 동작한다3. Lettuce SET NX 직접 구현 — 스핀락4. Redisson — pub/sub 대기정리 — 한 표로
배경
같은 상품의 재고 100개를 100명이 동시에 1개씩 차감하면, 마지막 재고는 0이어야 한다. 하지만 동시성 제어 없이 실행하면 0이 되지 않을 수 있다. 여러 스레드가 같은 재고 값을 동시에 읽은 뒤 각자 계산한 결과를 저장하면서, 다른 스레드의 갱신을 덮어쓰기 때문이다.
이 문제를 4가지 방식으로 직접 구현해서 비교했다. 순서대로 비관적 락 → 낙관적 락 → Lettuce 스핀락 → Redisson이다. 앞의 둘은 DB 안에서, 뒤의 둘은 Redis에서 동시성을 제어한다.
모든 방식은 같은 테스트로 검증한다.
@Test @DisplayName("동시에 100개의 재고 감소 요청이 와도 재고는 정확히 0이 된다") void concurrent_decrease() throws InterruptedException { int threadCount = 100; ExecutorService executorService = Executors.newFixedThreadPool(32); CountDownLatch latch = new CountDownLatch(threadCount); for (int i = 0; i < threadCount; i++) { executorService.submit(() -> { try { stockService.decrease(stockId, 1L); } finally { latch.countDown(); } }); } latch.await(); executorService.shutdown(); Stock stock = stockRepository.findById(stockId).orElseThrow(); assertThat(stock.getQuantity()).isZero(); }
1. 비관적 락 (Pessimistic Lock) — SELECT ... FOR UPDATE
"충돌은 반드시 일어난다"고 비관적으로 가정하고, 조회 시점부터 DB 행에 배타 락을 건다.
public interface PessimisticStockRepository extends JpaRepository<Stock, Long> { @Lock(LockModeType.PESSIMISTIC_WRITE) Stock findWithPessimisticLockById(Long id); }
MySQL InnoDB에서는 일반적으로 다음 SQL로 변환된다.
SELECT * FROM stock WHERE id = ? FOR UPDATE;
PESSIMISTIC_WRITE를 사용하면 조회된 행에 수정 권한을 확보하기 위한 배타(X) 락이 걸린다. 다른 트랜잭션이 같은 행에 FOR UPDATE, UPDATE, DELETE를 수행하려 하면 현재 트랜잭션이 커밋하거나 롤백해 락을 해제할 때까지 대기한다.
@Transactional public void decrease(Long id, Long quantity) { Stock stock = stockRepository.findWithPessimisticLockById(id); stock.decrease(quantity); }
동작을 정리하면 다음과 같다.
100개의 트랜잭션이 동시에 들어와도
SELECT ... FOR UPDATE 단계에서 순차적으로 대기한다. 앞선 트랜잭션이 재고를 차감하고 커밋하면, 다음 트랜잭션이 락을 획득해 이미 차감된 최신 값을 읽는다. 같은 재고 값을 동시에 읽고 덮어쓰는 갱신 손실을 락 대기로 방지하므로, 낙관적 락처럼 충돌 후 재시도하는 로직은 필요하지 않다.하지만 비용도 있다. 락 대기 동안 DB 커넥션과 스레드가 묶여 처리량이 떨어진다. 그리고 여러 행을 서로 다른 순서로 잠그면 데드락이 발생할 수 있다.
- 언제 쓰나: 동일한 행에 대한 경합이 자주 발생하고, 충돌 가능성이 높은 경우에 적합하다. 낙관적 락은 충돌할 때마다 트랜잭션을 다시 실행해야 한다. 충돌이 드물면 효율적이지만, 같은 재고 행에 요청이 집중되면 재시도가 반복될 수 있다. 이런 상황에서는 처음부터 비관적 락으로 요청을 순차 처리하는 편이 더 안정적이다.
2. 낙관적 락 (Optimistic Lock) — @Version
DB에 실제 락을 걸지 않는다. "충돌은 드물 것"이라고 낙관적으로 가정하고 일단 진행한 뒤, 커밋 시점에 version 컬럼으로 충돌을 감지한다.
@Entity public class Stock { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private Long quantity; @Version private Long version; }
@Version이 붙으면 JPA가 커밋(flush) 시점에 다음 UPDATE를 실행한다.
UPDATE optimistic_stock SET quantity = ?, version = version + 1 WHERE id = ? AND version = ?;
version 조건에는 엔티티를 조회했을 때의 값이 들어간다. JPA는 해당 id와 version이 그대로라면 UPDATE가 1개 행에 반영될 것으로 기대한다. 그런데 갱신된 행이 0개라면, 내가 조회한 이후 다른 트랜잭션이 먼저 데이터를 수정해 version이 변경됐거나 행이 삭제되어 UPDATE 조건과 일치하는 행이 없다는 뜻이다. JPA는 이를 동시 수정 충돌로 판단해 ObjectOptimisticLockingFailureException을 발생시킨다.중요한 점은 낙관적 락이 충돌을 막는 방식이 아니라 감지하는 방식이라는 것이다. 따라서 충돌한 요청을 반드시 성공시켜야 한다면, 잡아서 재시도하는 코드를 개발자가 직접 작성해야 한다.
@Component public class OptimisticLockStockFacade { private static final int MAX_RETRY = 100; public void decrease(Long id, Long quantity) throws InterruptedException { for (int retry = 0; retry < MAX_RETRY; retry++) { try { stockService.decrease(id, quantity); return; } catch (ObjectOptimisticLockingFailureException e) { Thread.sleep(50); } } throw new IllegalStateException("재시도 횟수 초과: 낙관적 락 충돌이 계속 발생했습니다."); } }
version 충돌 예외를 잡으면 잠깐 대기한 뒤 같은 요청을 다시 시도한다.
재시도가 Facade로 분리된 이유가 중요하다. 재시도는 기존 트랜잭션이 완전히 종료된 뒤 새로운 트랜잭션으로 실행해야 한다. 낙관적 락 충돌은 보통 flush 또는 commit 과정에서 발견된다. 충돌이 발생한 트랜잭션은 롤백 대상이 되므로, 같은 트랜잭션 안에서 단순히 다시 호출하면 정상적인 새 시도가 되지 않는다. 따라서
@Transactional이 적용된 서비스 바깥에서 예외를 잡고, 서비스 메서드를 다시 호출해 새로운 트랜잭션을 시작해야 한다.장점은 락 대기가 없어 처리량이 높고 데드락이 원천적으로 없다는 것. 단점은 충돌이 잦으면 재시도가 반복되며 DB 조회가 계속 발생해 오히려 비관적 락보다 느려진다는 것이다. 100명이 같은 행을 노리는 이 테스트가 정확히 그 최악 시나리오다.
- 언제 쓰나: 경합이 낮을 때 - 회원 정보 수정, 게시글 수정처럼 같은 행을 동시에 고칠 확률이 낮은 경우. 선착순 이벤트처럼 한 행에 요청이 몰리는 상황에는 부적합하다.
DB 락도 여러 서버에서 동작한다
여기서 자주 생기는 오해가 있다.
그렇지 않다. 비관적 락과 낙관적 락은 DB가 동시성을 제어한다. 여러 애플리케이션 서버가 같은 DB를 바라본다면, 어느 서버에서 요청이 들어왔는지와 관계없이 동일한 DB 행과 version을 기준으로 충돌을 제어할 수 있다. 서버가 여러 대일 때 동작하지 않는 것은 synchronized, ReentrantLock 같은 JVM 로컬 락이다.
그런데 DB 락은 대기하는 동안 커넥션을 점유한다. 트래픽이 몰리면 DB 자체가 병목이 된다. 그래서 DB 앞단(애플리케이션 레벨)에서 요청을 줄 세우는 Redis 분산락이 등장한다. 동일 자원에 대한 동시 진입을 Redis에서 먼저 제한해, DB에 동시에 몰리는 트랜잭션 수를 줄이는 것이다. 아래 두 가지는 모두 Redis를 사용하는 락이다. 락 획득은
SET NX로 동일하고, 락을 못 잡았을 때 어떻게 기다리는지가 다르다.3. Lettuce SET NX 직접 구현 — 스핀락
별도 라이브러리 없이 Spring Boot 기본 Redis 클라이언트(Lettuce)의 SET NX PX 명령만으로 락을 구현한다.
public boolean lock(Long id, String token) { return Boolean.TRUE.equals( redisTemplate.opsForValue() .setIfAbsent(key(id), token, Duration.ofMillis(3_000))); }
실행되는 명령은
SET stock-lock:{id} {token} NX PX 3000이다. 각 옵션은 다음을 의미한다.NX: 키가 존재하지 않을 때만 저장
PX 3000: 키 만료 시간을 3,000ms(3초 TTL)로 설정
token: 락을 획득한 소유자를 식별하는 값, TTL 만료 후 다른 스레드가 잡은 락을 실수로 지우지 않기 위해 저장
락 해제는 "내가 잡은 락일 때만" 지워야 한다. GET으로 확인하고 DEL 하면 그 사이에 락이 만료되고 다른 스레드가 잡을 수 있으므로, Lua 스크립트로 비교+삭제를 원자적으로 실행한다.
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT = new DefaultRedisScript<>( """ if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end """, Long.class);
사용하는 쪽은 락을 잡을 때까지 재시도하는 스핀락이다. 락을 획득할 때까지 반복해서
SET NX를 요청한다.public void decrease(Long id, Long quantity) throws InterruptedException { String token = UUID.randomUUID().toString(); while (!lockRepository.lock(id, token)) { Thread.sleep(100); } try { stockService.decrease(id, quantity); } finally { lockRepository.unlock(id, token); } }
대기 스레드 수 × 재시도 주기만큼 Redis에 요청이 계속 날아간다. 경합이 심하면 Redis에 상당한 부하를 준다. 그리고 TTL, 소유자 토큰, 원자적 해제 등을 전부 직접 구현해야 한다. 빠뜨리면 TTL 없음 → 서버 죽으면 영구 데드락, 토큰 없음 → 다른 요청의 락을 삭제할 수 있음
- 언제 쓰나: 분산락의 내부 동작 원리를 학습할 때, 또는 분산락을 단순하게 직접 구현해도 되는 환경(경합이 적고 요구사항이 단순한 경우). 실무에서는 단순한
SET NX한 줄보다 예외 상황이 훨씬 많기 때문에, 직접 구현해야 할 특별한 이유가 없다면 Redisson 같은 검증된 분산락 라이브러리를 사용하는 경우가 안전하다.
다만 대기 자체가 필요 없는 경우는 이야기가 다르다. 중복 클릭 방지가 대표적이다. 버튼을 두 번 눌렀을 때 두 번째 요청은 기다릴 이유가 없고 그냥 버리면 된다. 이때는 SET NX가 실패하는 즉시 종료하므로 폴링도 pub/sub도 불필요하다. 이중 결제 방지, 게시판 중복 등록 방지가 모두 같은 구조다.
4. Redisson — pub/sub 대기
Redisson도 락 획득 자체는 SET NX로 한다. 앞의 Lettuce 방식과 다른 것은 락을 못 잡았을 때 어떻게 기다리느냐다. Lettuce 방식은 100ms마다 SET NX를 다시 던지는 폴링이었다. Redisson은 pub/sub으로 기다린다. 락이 해제되면 Redis 채널로 알림이 발행되고, 대기 중인 스레드만 깨운다. Lettuce 스핀락처럼 계속 요청을 날리지 않아 Redis 부하가 훨씬 적다.
public void decrease(Long id, Long quantity) throws InterruptedException { RLock lock = redissonClient.getLock("stock-lock:" + id); boolean acquired = lock.tryLock(10, 3, TimeUnit.SECONDS); if (!acquired) { throw new IllegalStateException("락 획득에 실패했습니다."); } try { stockService.decrease(id, quantity); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } }
tryLock()의 인자는 다음 의미다.waitTime: 락 획득을 최대 10초 동안 기다림. pub/sub 알림 기반이라 대기 중에 스핀하지 않는다.
leaseTime: 락 획득 후 3초가 지나면 자동 해제. 죽은 서버가 락을 무한 점유하는 것을 막는다.
해제할 때
isHeldByCurrentThread()를 확인하는 이유는, leaseTime 만료로 이미 풀린 락을 unlock하면 예외가 발생하기 때문이다.대기 중이던 스레드는 해제 알림을 받으면 다시 획득을 시도한다. 알림을 받았다고 락을 얻는 것이 아니다. 깨어난 스레드는 다시 SET NX를 시도하고, 거기서 하나의 스레드만 락 획득에 성공한다.
주의할 점이 있다. leaseTime을 명시하면 워치독이 동작하지 않는다. 작업이 3초를 넘길 수 있다면 락이 중간에 풀려 두 스레드가 동시에 진입할 수 있으므로, 점유 시간이 일정하지 않다면 leaseTime을 생략해 워치독의 자동 연장에 맡기는 편이 안전하다.
다만 워치독도 무조건적인 안전장치는 아니다. 애플리케이션이 긴 GC pause나 네트워크 단절로 TTL을 제때 연장하지 못하면 락이 만료될 가능성은 남아 있다. 분산 환경에서는 락만으로 모든 장애 상황을 완벽히 해결할 수 없으므로, 중요한 작업은 DB 조건부 UPDATE, fencing token, 멱등성 같은 추가 방어도 검토해야 한다. Redisson도 이를 위해 락을 획득할 때마다 토큰을 발급하는 Fenced Lock을 별도로 제공한다. 다만 그 토큰이 이전 값보다 크거나 같은지 검사하고 아니면 거부하는 일은, 락이 보호하는 리소스 쪽에서 해야 한다.
— Redisson Reference Guide, Locks and Synchronizers
Lettuce에서 직접 구현했던 것들(TTL, 소유자 검증, 원자적 해제)이 Redisson에서는 라이브러리 내부에서 처리한다. 또한 공정락(FairLock), 읽기쓰기락, 세마포어 등 분산 동기화 도구가 풍부하다.
단점은 Redisson은 스프링 공식이 아닌 별도 의존성이고, 자체 커넥션 풀과 스레드를 별도로 운영한다. 기존 Lettuce 연결과 무관하게 Redis 연결이 추가된다.
- 언제 쓰나: 다중 서버에서 특정 키에 경합이 심할 때(선착순 쿠폰, 타임세일 재고), 또는 락 점유 시간이 일정하지 않아 자동 TTL 연장이 필요할 때.
정리 — 한 표로
비관적 락 | 낙관적 락 | Lettuce SET NX | Redisson | |
락 위치 | DB 행 | 락 없음 (version 감지) | Redis | Redis |
대기 방식 | DB가 줄 세움 | 실패 후 재시도 | 스핀 (직접 구현) | pub/sub |
재시도 코드 | 불필요 | 직접 작성 (Facade) | 직접 작성 | 불필요 (tryLock) |
TTL/소유자 검증 | 해당 없음 | 해당 없음 | 전부 직접 | 자동 (워치독) |
경합 심할 때 | 유리 (줄 세움) | 불리 (재시도 폭풍) | Redis 부하 큼 | 유리 |
의존성 | 없음 (JPA) | 없음 (JPA) | 없음 (기본 Lettuce) | 서드파티 |
네 방식 모두 같은 동시성 테스트를 통과한다. 재고는 정확히 0이 된다. 같은 로컬 환경 단일 실행 기준 소요 시간은 비관적 락 0.05초, Redisson 0.2초, 낙관적 락 0.37초, Lettuce 스핀락 2.4초였다. Lettuce만 유독 느린 이유는 락 대기가 100ms(0.1초) 간격의 재시도라, 대기 시간이 계단식으로 쌓이기 때문이다.
선택 기준을 정리하면
- 단일 DB + 경합 잦음 → 비관적 락. 줄 세우는 게 재시도보다 싸다.
- 경합 드묾 → 낙관적 락. 락 비용 없이 감지만
- 다중 서버 + DB 부하 분리 필요 → Redis 분산락. 경합이 심하면 pub/sub 대기를 지원하는 Redisson, 대기가 필요 없는 단순한 경우라면 SET NX 직접 구현