DB를 배우다보면 항상 나오는 문구가 '비관적 락 vs 낙관적 락' 입니다.
락의 개념을 배운 뒤로 비관적과 낙관적 락에 대해 듣게 되는데, 이게 정확히 무엇인지에 대해 정리를 해보면 좋을 것 같다는 생각이 들어 이번 포스팅을 통해 정리를 해볼까 합니다.
앞서 우리가 지속적으로 살펴보았던 Redis를 활용한 분산락은 애플리케이션 레벨의 락 알고리즘입니다.
하지만 데이터베이스 자체에도 동시성 제어를 위한 락 메커니즘이 존재합니다.
기본적으로 애플리케이션 레벨의 락을 기본적으로 사용하며 DB 락 메커니즘은 이중 보안이나 최후의 방어선 같은 역할을 하게 됩니다.
분산락과 DB락을 건물 보안에 비유를 해서 조금 더 쉽게 설명을 해볼까요?
분산락의 경우 건물 입구 경비원의 역할을 합니다. 특징으로는 건물에 들어가기 전에 신원을 확인하며 빠르게 많은 사람을 걸러낼 수 있습니다. 하지만 경비원이 아프면 아무나 쉽게 들어올 수 있지요.
DB락의 경우 금고 앞 자물쇠 같은 역할을 합니다. 실제 데이터에 접근을 할 때 최후의 방어선 역할을 하지요.
금고 자체의 잠금장치를 통해 보다 안전하게 데이터를 보관할 수 있지만 한 번에 한 명만 접근이 가능하여 성능에 영향을 주기도 합니다.
저희가 락을 활용하여 동시성을 제어할 때의 계층 구조는 아래와 같습니다.

각 레벨의 역할을 정리해보면 아래와 같습니다.
1️⃣ 애플리케이션 레벨
2️⃣ 데이터베이스 레벨
3️⃣ 데이터베이스 엔진 레벨
우린 이러한 락의 종류를 알고 상황에 맞게 락을 선택하여 활용하면 됩니다.
| 상황 | 권장 락 | 이유 |
|---|---|---|
| 단일 서버 + 충돌 드물음 | 낙관적 락 | 성능 좋음, 재시도 부담 적음 |
| 단일 서버 + 충돌 빈번 | 비관적 락 | 재시도 오버헤드 방지 |
| 분산 환경 + 일반적인 경우 | 분산락 | 서버 간 동기화 필요 |
| 분산 환경 + 금융/결제 | 분산락 + 비관적 락 | 이중 보호로 안전성 극대화 |
그럼 이번 포스팅에서 알아보기로 했던 비관적 락과 낙관적 락 중 비관적 락부터 알아보도록 하겠습니다.
비관적 락의 기본적인 개념은 충돌이 발생할 것이라는 비관적 가정을 하고 데이터를 읽는 시점에 락을 걸어 다른 트랜잭션의 접근을 미연에 막는 방식입니다.
쉽게 생각해서 '다른 사람이 같은 데이터를 수정하려고 할테니 미리 잠궈버린다'는 뜻이 됩니다.
최악의 상황을 가정하고 선제적으로 라을 걸기 때문에 실제 충돌이 없어도 락을 걸어 성능상 이슈가 될 가능성이 큽니다.
하지만 그로 인해 보다 안정적으로 비즈니스를 수행할 수 있다는 장점이 있습니다.
동작원리를 시각화해보면 아래와 같습니다.

여기서 핵심 포인트는 TX2는 TX1이 커밋할 때 대기해야하고, TX2가 락을 획득했을 때 보이는 데이터는 TX1이 수정한 최신 데이터입니다.
따라서 갱신 분실 문제가 절대 발생하지 않게 됩니다.
다음은 샘플코드들로 SQL과 JPA 환경에서 비관적 락을 알아보도록 하겠습니다.
-- ═══════════════════════════════════════════════════════════════
-- 비관적 락의 SQL: SELECT ... FOR UPDATE
-- ═══════════════════════════════════════════════════════════════
START TRANSACTION;
-- 1️⃣ 락을 걸면서 데이터 조회
-- FOR UPDATE가 핵심! 이 순간부터 해당 행은 잠김
SELECT * FROM points
WHERE user_id = 123
FOR UPDATE; -- 👈 이 키워드가 비관적 락을 활성화!
-- 이 시점에서 다른 트랜잭션이 같은 행에 FOR UPDATE를 시도하면
-- 이 트랜잭션이 끝날 때까지 대기하게 됨
-- 2️⃣ 비즈니스 로직 수행 (이 동안 다른 트랜잭션은 대기)
-- 현재 잔액: 100
-- 차감할 금액: 30
-- 새 잔액: 70
-- 3️⃣ 데이터 수정
UPDATE points
SET amount = amount - 30
WHERE user_id = 123;
-- 4️⃣ 커밋 → 락 자동 해제
COMMIT;
-- 이제 대기 중이던 다른 트랜잭션이 락을 획득하고 진행할 수 있음
-- 기본 FOR UPDATE
SELECT * FROM points WHERE user_id = 123 FOR UPDATE;
-- FOR UPDATE NOWAIT: 락을 즉시 획득하지 못하면 에러 발생 (대기 안 함)
SELECT * FROM points WHERE user_id = 123 FOR UPDATE NOWAIT;
-- 장점: 대기로 인한 타임아웃 방지
-- 단점: 락 획득 실패 시 재시도 로직 필요
-- FOR UPDATE SKIP LOCKED: 잠긴 행은 건너뛰고 나머지만 조회
SELECT * FROM points WHERE status = 'PENDING' FOR UPDATE SKIP LOCKED LIMIT 10;
-- 장점: 대기열 처리 등에서 유용 (다른 워커가 처리 중인 것은 건너뜀)
-- 사용 예: 작업 큐에서 여러 워커가 동시에 작업을 가져갈 때
/**
* Repository에서 비관적 락 정의
*/
public interface PointRepository extends JpaRepository<Point, Long> {
/**
* 비관적 쓰기 락 (Exclusive Lock, X Lock)
*
* 동작: SELECT ... FOR UPDATE 실행
* 효과: 다른 트랜잭션의 FOR UPDATE, FOR SHARE, UPDATE, DELETE 차단
* 일반 SELECT는 허용 (MVCC 때문)
*
* 언제 사용? 조회 후 수정이 확실한 경우
*/
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT p FROM Point p WHERE p.userId = :userId")
Optional<Point> findByUserIdWithPessimisticLock(@Param("userId") Long userId);
/**
* 비관적 읽기 락 (Shared Lock, S Lock)
*
* 동작: SELECT ... FOR SHARE 실행 (MySQL 기준)
* 효과: 다른 트랜잭션의 FOR SHARE는 허용, FOR UPDATE/UPDATE/DELETE 차단
*
* 언제 사용? 읽는 동안 데이터가 변경되지 않음을 보장하고 싶을 때
* (수정할 계획은 없지만 일관된 읽기가 필요한 경우)
*/
@Lock(LockModeType.PESSIMISTIC_READ)
@Query("SELECT p FROM Point p WHERE p.userId = :userId")
Optional<Point> findByUserIdWithSharedLock(@Param("userId") Long userId);
}
/**
* Service에서 비관적 락 활용
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class PointService {
private final PointRepository pointRepository;
/**
* 비관적 락을 사용한 포인트 차감
*
* 실행 흐름:
* 1. findByUserIdWithPessimisticLock 호출
* → SELECT ... FOR UPDATE 실행
* → 해당 행 잠금
* 2. 비즈니스 로직 수행 (잔액 확인, 차감)
* → 이 동안 다른 트랜잭션은 대기
* 3. 트랜잭션 커밋
* → UPDATE 실행, 락 해제
*/
@Transactional
public void deductPointsWithPessimisticLock(Long userId, int amount) {
log.info("비관적 락으로 포인트 차감 시작: userId={}, amount={}", userId, amount);
// 1️⃣ 비관적 락으로 조회
// 이 순간부터 해당 행은 잠기고, 다른 트랜잭션은 대기
Point point = pointRepository.findByUserIdWithPessimisticLock(userId)
.orElseThrow(() -> new PointNotFoundException(userId));
log.info("락 획득 완료. 현재 잔액: {}", point.getAmount());
// 2️⃣ 잔액 확인
if (point.getAmount() < amount) {
// 예외가 발생해도 트랜잭션 롤백 시 락은 자동 해제됨
throw new InsufficientBalanceException("잔액 부족: 현재 " +
point.getAmount() + ", 필요 " + amount);
}
// 3️⃣ 차감
// JPA의 더티 체킹으로 트랜잭션 커밋 시 자동 UPDATE
point.deduct(amount);
log.info("포인트 차감 완료. 새 잔액: {}", point.getAmount());
// 4️⃣ 트랜잭션 종료 시 (메서드 종료 시)
// → 자동으로 COMMIT 실행
// → 락 자동 해제
// → 대기 중이던 다른 트랜잭션이 진행
}
}
낙관적 락은 충돌이 거의 발생하지 않을 것이라고 낙관적인 가능을 하고 실제 데이터를 수정할 때만 충돌을 감지하는 방식입니다.
즉, '다른 사람이 같은 데이터를 수정하는 일은 드물테니 일단 작업하고 저장할 떄 충돌이 있으면 그때 처리하자'라는 개념을 보유하고 있습니다.
충돌이 드물다고 가정하고 락 없이 진행을 하다 저장 시점에 버전을 비교해 충돌을 감지하고 만약 충돌이 발생한다면 예외를 던지고 재시도 하는거죠.
비관적 락이 '미리부터 잠그는'전략을 쓴다면 낙관적 락은 '나중에 저장할 때 확인'하는 것입니다.
즉, 락의 타이밍을 앞으로 땡겨오느냐, 뒤로 미루느냐의 차이가 있는 것이죠.
아래는 버전 기반의 충돌 감지 플로우 차트입니다.

핵심 포인트는 WHERE version=1로 내가 읽었을 때 버전과 현재 버전이 같은지 확인하고 만약 버전이 다르다면 누가 '먼저' 수정했다는 것을 의미하므로 0 rows affected 상태로 OptimisticLockException이 발생하게 됩니다.
이를 기점으로 재시도 시 최신 데이터를 다시 읽어와서 작업을 하게 됩니다.
다음은 실제 코드로 보면서 낙관적 락을 분석해보겠습니다.
-- ═══════════════════════════════════════════════════════════════
-- 낙관적 락의 핵심: WHERE 절에 version 조건 추가
-- ═══════════════════════════════════════════════════════════════
-- 1️⃣ 데이터 조회 (일반 SELECT, 잠금 없음)
SELECT id, user_id, amount, version
FROM points
WHERE user_id = 123;
-- 결과: amount=100, version=1
-- 2️⃣ 애플리케이션에서 계산
-- 새로운 amount = 100 - 30 = 70
-- 3️⃣ 업데이트 시 version 조건 추가
UPDATE points
SET amount = 70,
version = version + 1 -- 👈 버전 증가!
WHERE user_id = 123
AND version = 1; -- 👈 읽었을 때의 버전과 비교!
-- 실행 결과 해석:
-- "1 row affected" → 성공! 내가 읽은 이후 변경 없었음
-- "0 rows affected" → 실패! 누군가 먼저 수정함
-- JPA는 "0 rows affected"를 감지하면
-- OptimisticLockException을 자동으로 던져줍니다!
/**
* Entity에 @Version 필드 추가
*
* @Version 어노테이션의 역할:
* 1. JPA가 UPDATE 시 자동으로 WHERE version=? 조건 추가
* 2. UPDATE 후 자동으로 version = version + 1
* 3. 0 rows affected 시 OptimisticLockException 발생
*/
@Entity
@Table(name = "points")
@Getter
public class Point {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long userId;
private Integer amount;
/**
* 버전 필드 - 낙관적 락의 핵심!
*
* 지원 타입: int, Integer, long, Long, short, Short, Timestamp
* 권장: Long (오버플로우 걱정 없음)
*
* 동작 방식:
* - 최초 저장 시: 0 또는 1로 자동 설정
* - 수정될 때마다: 자동으로 1씩 증가
* - 조회만 하면: 변경 안 됨
*/
@Version
private Long version;
public void deduct(int deductAmount) {
if (this.amount < deductAmount) {
throw new InsufficientBalanceException("잔액 부족");
}
this.amount -= deductAmount;
// version은 JPA가 자동으로 증가시킴 - 직접 수정하면 안 됨!
}
}
/**
* Service에서 낙관적 락 활용
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class PointService {
private final PointRepository pointRepository;
/**
* 낙관적 락을 사용한 포인트 차감
*
* 실행 흐름:
* 1. findByUserId 호출 → 일반 SELECT (잠금 없음)
* 2. point.deduct() → 엔티티 상태 변경 (아직 DB 반영 안 됨)
* 3. 트랜잭션 커밋 시:
* → UPDATE ... WHERE version=? 실행
* → 버전 일치하면 성공, 불일치면 OptimisticLockException
*/
@Transactional
public void deductPointsWithOptimisticLock(Long userId, int amount) {
// 1️⃣ 일반 조회 (락 없음! 누구나 동시에 읽을 수 있음)
Point point = pointRepository.findByUserId(userId)
.orElseThrow(() -> new PointNotFoundException(userId));
log.info("포인트 조회: userId={}, amount={}, version={}",
userId, point.getAmount(), point.getVersion());
// 2️⃣ 차감 (메모리에서만 변경, DB는 아직 그대로)
point.deduct(amount);
// 3️⃣ 트랜잭션 커밋 시 (메서드 종료 시)
// JPA가 더티 체킹으로 UPDATE 쿼리 생성:
// UPDATE points SET amount=70, version=2
// WHERE user_id=123 AND version=1
//
// - version=1이 맞으면 → 성공, version=2로 업데이트
// - version이 이미 2 이상이면 → 0 rows affected → 예외!
log.info("포인트 차감 처리 중... (커밋 시 버전 체크 예정)");
// save() 호출이 없어도 @Transactional로 인해 자동 저장됨
// 명시적으로 호출해도 동일하게 동작
}
}
낙관적 락의 경우 락을 미리 걸고 작업하는 것이 아니기 때문에 충돌의 발생 확률이 현격히 높습니다. 그렇기 때문에 재시도 로직이 필수입니다.
Spring Retry를 활용하면 이를 간단하게 구현할 수 있습니다.
/**
* Spring Retry를 활용한 낙관적 락 재시도
*
* 의존성 추가 필요:
* implementation 'org.springframework.retry:spring-retry'
* implementation 'org.springframework:spring-aspects'
*
* 설정 클래스에 @EnableRetry 추가 필요
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class PointServiceWithRetry {
private final PointRepository pointRepository;
/**
* @Retryable 동작 방식:
* 1. 메서드 실행
* 2. OptimisticLockException 발생 시
* 3. 100ms 대기 후 재시도
* 4. 최대 3번까지 반복
* 5. 3번 모두 실패하면 예외 전파
*/
@Retryable(
retryFor = OptimisticLockException.class, // 이 예외 발생 시 재시도
maxAttempts = 3, // 최대 3번 시도
backoff = @Backoff(delay = 100) // 재시도 간 100ms 대기
)
@Transactional
public void deductPointsWithRetry(Long userId, int amount) {
log.info("포인트 차감 시도: userId={}, amount={}", userId, amount);
Point point = pointRepository.findByUserId(userId)
.orElseThrow(() -> new PointNotFoundException(userId));
point.deduct(amount);
// 트랜잭션 커밋 시 버전 체크
// 충돌 발생하면 OptimisticLockException → @Retryable이 재시도
}
/**
* 모든 재시도 실패 시 호출되는 복구 메서드
*/
@Recover
public void recover(OptimisticLockException e, Long userId, int amount) {
log.error("포인트 차감 최종 실패 (3회 재시도 후): userId={}, amount={}", userId, amount);
throw new PointDeductionFailedException(
"잠시 후 다시 시도해주세요. (동시 요청이 많습니다)", e);
}
}
그럼 비관적 락과 낙관적 락을 간단하게 비교하여 분석해보도록 하겠습니다.

| 비교 항목 | 비관적 락 | 낙관적 락 |
|---|---|---|
| 철학 | "충돌할 것이다" (비관적) | "충돌 안 할 것이다" (낙관적) |
| 충돌 처리 시점 | 읽기 시점 (선제적 차단) | 쓰기 시점 (사후 감지) |
| 락 방식 | DB 행 잠금 (FOR UPDATE) | 버전 번호 비교 (@Version) |
| 다른 요청의 대기 | 락 해제까지 대기 | 대기 없이 즉시 진행 |
| 동시 처리량 | 낮음 (직렬 처리) | 높음 (병렬 처리) |
| 충돌 시 동작 | 자동으로 순차 처리 | 예외 발생 → 재시도 필요 |
| 데드락 가능성 | 있음 (주의 필요) | 없음 |
| 구현 복잡도 | 낮음 (FOR UPDATE 추가) | 중간 (재시도 로직 필요) |
| DB 부하 | 높음 (락 관리 비용) | 낮음 (버전 컬럼만 추가) |
두 가지 락 방식은 상황에 다라 다르게 사용할 수 있습니다.
일반적으로 낙관적 락이 더 좋은 경우는 읽기 비중이 쓰기 비중보다 더 높아 충돌이 드문 경우, 응답시간이 매우 중요한 경우, 읽기 API와 쓰기 API가 분리된 시스템의 경우에서 유용합니다.
간단한 예로 게시글을 수정하거나 사용자 프로필을 업데이트 하거나 설정을 변경하는 것이죠.
반면 비관적 락이 더 좋은 경우에는 인기 상품 구매 및 재고 시스템이나 입출금 등 충돌이 빈번하게 일어나는 경우, 재시도의 비용이 락보다 더 비쌀 경우(외부 API 연동 등), 마지막으로 반드시 성공을 해야만 하는 경우에는 비관적 락으로 초기 락 비용을 지불하더라도 안전하게 비즈니스를 진행시키는 편이 더 좋습니다.
간단한 예로 결제나 취소/환불, 재고 증/차감, 포인트 증/차감 등 수치와 관련된 민감한 부분들에선 비관적 락이 필수적입니다.

일반적으로 대부분의 경우에는 낙관적 락을 통해 동시성을 제어할 수 있을겁니다. 또는 동시성 제어가 그닥 중요하지 않은 비즈니스에서는 락을 굳이 걸지 않는 것이 속도적인 측면에서 우위를 점할 수 있습니다.
하지만 락이 반드시 필요한 경우에는 비관적 락을 분산락과 더불어 사용하는 이중 잠금 전략을 사용하곤 합니다.
최근 결제 시스템을 개발할 때에도 이러한 이유로 이중 잠금 전략을 사용했습니다.
일반적으로 이중 잠금까지는 필요 없을 가능성이 큽니다. 하지만 정합성이 매우 중요한 결제/취소/환불 시스템이나 포인트 시스템, 재고 시스템의 경우 이중 잠금 전략을 사용하곤 합니다.
이중 잠금 전략은 말 그대로 락 시스템을 두 개를 중첩해서 쓰는 것을 말하며, 분산락(애플리케이션 레벨의 락)과 비관적 락(데이터베이스 레벨의 락)을 동시에 사용하여 최고 수준의 데이터 정합성을 보장하는 전략입니다.
이중 잠금 동작 원리는 아래와 같습니다.

일반적으로 Redis 레벨에서 1차 필터로 락을 걸어주어 DB로 전달되는 트래픽 량을 최소화하여 충돌을 방지할 수 있습니다.
만약 시스템 장애 상황이나 특수한 이유로 충돌이 발생했다고 해도, 즉 Redis가 분산락의 역할을 제대로 수행하지 못했다고 해도 DB 수준의 락인 비관적 락을 통해 DB 행을 미리 잠궈 정합성을 유지할 수 있는 것이죠.

이중 잠금 구현의 예시 코드는 아래와 같습니다.
/**
* 이중 잠금 서비스 - 가장 안전한 동시성 제어
*
* 적용 대상:
* - 금융 거래 (결제, 송금)
* - 포인트 충전/차감
* - 재고 차감
* - 정합성이 매우 중요한 모든 연산
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class PointService {
private final PointRepository pointRepository;
/**
* 이중 잠금 전략: 분산락 + 비관적 락
*
* 동작 순서:
* 1. @DistributedLock → Redis 분산락 획득 시도
* - 다른 서버의 요청 차단 (1차 방어)
* 2. @Transactional 시작
* 3. findByUserIdWithPessimisticLock → DB FOR UPDATE
* - 혹시 모를 동시 접근 차단 (2차 방어)
* 4. 비즈니스 로직 수행
* 5. 트랜잭션 커밋 → DB 락 해제
* 6. 분산락 해제
*/
@DistributedLock(
key = "'user:' + #userId + ':point:lock'",
waitTime = 5, // 최대 5초 대기
leaseTime = 30 // 30초 후 자동 해제 (안전장치)
)
@Transactional
public PointResponse deductPointsWithDoubleLock(Long userId, int amount) {
log.info("=== 이중 잠금 포인트 차감 시작 ===");
log.info("userId={}, amount={}", userId, amount);
// 2차 방어: DB 비관적 락
// 분산락이 실패하더라도 DB 락이 최종 방어선 역할
Point point = pointRepository.findByUserIdWithPessimisticLock(userId)
.orElseThrow(() -> new PointNotFoundException(userId));
int beforeAmount = point.getAmount();
// 잔액 확인 및 차감
if (point.getAmount() < amount) {
throw new InsufficientBalanceException("잔액 부족");
}
point.deduct(amount);
log.info("포인트 차감 완료: userId={}, before={}, after={}",
userId, beforeAmount, point.getAmount());
return new PointResponse(userId, point.getAmount());
}
}
앞서 잠시 언급되었던 데드락에 대해 간략하게 정리해보도록 하겠습니다.
데드락은 두 개 이상의 트랜잭션이 서로가 보유한 자원의 락을 기다리면서 영원히 대기하는 상태를 말합니다.

데드락이 발생하는 조건은 다음 4가지가 있습니다.
1. 상호배제 : 자원은 한 번에 하나의 프로세스만 사용
2. 점유와 대기 : 자원을 보유하면서 다른 자원을 기다림
3. 비선점 : 다른 프로세스가 자원을 강제로 빼앗을 수 없음
4. 순환 대기 : 자원 대기가 원형으로 연결됨
위 4가지의 조건을 모두 만족하면 데드락이 발생하게 됩니다.
그럼 데드락을 해결하는 방법은 무엇이 있는지 알아보겠습니다.
1. 예방
위 해결책에 대해서 1번 예방과 2번 회피의 코드 예제를 살펴보겠습니다.
// ❌ 데드락 위험: 락 순서가 일정하지 않음
public void transferBad(Long fromUserId, Long toUserId, int amount) {
// TX1: fromUserId=1, toUserId=2 → users(1) 먼저
// TX2: fromUserId=2, toUserId=1 → users(2) 먼저
// → 데드락 가능!
Point from = pointRepository.findByUserIdWithPessimisticLock(fromUserId);
Point to = pointRepository.findByUserIdWithPessimisticLock(toUserId);
from.deduct(amount);
to.add(amount);
}
// ✅ 데드락 방지: 항상 작은 ID 먼저 락
public void transferGood(Long fromUserId, Long toUserId, int amount) {
// 항상 작은 ID 먼저 락을 획득
Long firstId = Math.min(fromUserId, toUserId);
Long secondId = Math.max(fromUserId, toUserId);
Point first = pointRepository.findByUserIdWithPessimisticLock(firstId);
Point second = pointRepository.findByUserIdWithPessimisticLock(secondId);
// 실제 송금 로직
Point from = fromUserId.equals(firstId) ? first : second;
Point to = toUserId.equals(firstId) ? first : second;
from.deduct(amount);
to.add(amount);
}
public interface PointRepository extends JpaRepository<Point, Long> {
/**
* 타임아웃이 있는 비관적 락
* 3초 동안 락을 획득하지 못하면 예외 발생
*/
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints({
@QueryHint(name = "javax.persistence.lock.timeout", value = "3000") // 3초
})
@Query("SELECT p FROM Point p WHERE p.userId = :userId")
Optional<Point> findByUserIdWithPessimisticLockTimeout(@Param("userId") Long userId);
}
@Service
@RequiredArgsConstructor
@Slf4j
public class PointService {
private final PointRepository pointRepository;
@Transactional
public void deductWithTimeout(Long userId, int amount) {
try {
Point point = pointRepository.findByUserIdWithPessimisticLockTimeout(userId)
.orElseThrow(() -> new PointNotFoundException(userId));
point.deduct(amount);
} catch (PessimisticLockingFailureException e) {
// 타임아웃 발생 시 처리
log.warn("락 획득 타임아웃: userId={}", userId);
throw new LockAcquisitionException("잠시 후 다시 시도해주세요.", e);
}
}
}
비관적 락은 JPA를 통해서도 간단하게 LockModeType을 통해 구현할 수 있습니다.
어떤 것들이 있는지 아래 도식화 자료를 통해서 확인할 수 있습니다.


낙관적 락의 경우 앞서 살펴보았던 것처럼 충돌이 발생한다는 가정 하에 작업을 진행하기 때문에 충돌이 발생할 수 밖에 없습니다.
그에 따라 재시도 전략을 잘 선택하여 구현해야 합니다.
재시도 전략은 아래와 같이 존재합니다.
위 내용들을 토대로 낙관적 락의 재시도 전략 구현 예제를 살펴보겠습니다.
@Configuration
@EnableRetry
public class RetryConfig {
// Spring Retry 활성화
}
@Service
@RequiredArgsConstructor
@Slf4j
public class PointService {
private final PointRepository pointRepository;
/**
* 전략 1: 고정 대기 시간 재시도
*/
@Retryable(
value = OptimisticLockException.class,
maxAttempts = 3,
backoff = @Backoff(delay = 100) // 100ms 고정 대기
)
@Transactional
public void deductWithFixedRetry(Long userId, int amount) {
Point point = pointRepository.findByUserId(userId)
.orElseThrow(() -> new PointNotFoundException(userId));
point.deduct(amount);
}
/**
* 전략 2: 지수 백오프 재시도
* 1차: 100ms, 2차: 200ms, 3차: 400ms
*/
@Retryable(
value = OptimisticLockException.class,
maxAttempts = 5,
backoff = @Backoff(
delay = 100,
multiplier = 2, // 2배씩 증가
maxDelay = 3000 // 최대 3초
)
)
@Transactional
public void deductWithExponentialRetry(Long userId, int amount) {
Point point = pointRepository.findByUserId(userId)
.orElseThrow(() -> new PointNotFoundException(userId));
point.deduct(amount);
}
/**
* 전략 3: 지수 백오프 + 지터 (직접 구현)
*/
@Transactional(propagation = Propagation.NOT_SUPPORTED) // 재시도마다 새 트랜잭션
public void deductWithJitter(Long userId, int amount) {
int maxAttempts = 5;
int baseDelay = 100;
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
try {
deductInternal(userId, amount);
return; // 성공 시 종료
} catch (OptimisticLockException e) {
if (attempt == maxAttempts) {
throw new RetryExhaustedException("최대 재시도 횟수 초과", e);
}
// 지수 백오프 + 지터 계산
int exponentialDelay = baseDelay * (1 << (attempt - 1)); // 2^(attempt-1)
int jitter = ThreadLocalRandom.current().nextInt(0, exponentialDelay / 2);
int totalDelay = exponentialDelay + jitter;
log.warn("낙관적 락 충돌. {}ms 후 재시도 (시도 {}/{})",
totalDelay, attempt, maxAttempts);
try {
Thread.sleep(totalDelay);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new RuntimeException("재시도 중단됨", ie);
}
}
}
}
@Transactional
protected void deductInternal(Long userId, int amount) {
Point point = pointRepository.findByUserId(userId)
.orElseThrow(() -> new PointNotFoundException(userId));
point.deduct(amount);
}
/**
* 재시도 실패 시 호출되는 복구 메서드
*/
@Recover
public void recoverFromOptimisticLock(OptimisticLockException e, Long userId, int amount) {
log.error("낙관적 락 재시도 모두 실패: userId={}, amount={}", userId, amount);
// 알림 발송, 수동 처리 큐에 추가 등
notificationService.sendAlert("포인트 차감 실패", userId, amount);
}
}
여러가지 상황이 있겠지만 락 전략을 선택하는 체크리스트를 도식화해보았습니다.

이번에는 비관적 락과 낙관적 락에 대해 심도 깊게 알아보았습니다.
이제는 락이란 왜 걸어야 하며 어떤 상황에서 어떤 락을 활용해야 하는지도 알게된 것 같습니다.
읽어주셔서 감사드립니다 🫡