View

728x90
반응형

DB 락이 두 종류로 나뉘는 근본 이유는 설명하지 않고 코드만 던지는 글이 너무 많다. 그래서 "비관적 락 낙관적 락" 차이를 검색해도 결국 신입은 개념만, 주니어는 판단 기준만 따로 보다가 둘 다 어정쩡하게 알게 된다. 이 글에서는 두 락의 차이부터 시작해 실무에서 어느 도메인에 어느 락을 써야 하는지, 그리고 데드락이나 OptimisticLockException 같은 함정까지 한 번에 정리한다. JPA를 쓰든 raw SQL을 쓰든 모두 적용되도록 케이스 위주로 풀었다.

 

 

출처: vladmihalcea.com

 

락이 두 종류로 갈라진 근본 이유

 

동시성 문제란 무엇인가 — 30초 요약

 

DB에서 동시성 문제는 결국 두 트랜잭션이 같은 행을 동시에 건드릴 때 발생한다. 대표적인 사례가 Lost Update다. A가 잔고 1만 원에서 5천 원을 빼는 동안 B가 동시에 3천 원을 빼면 누군가의 차감이 사라진다. Dirty Read는 커밋되지 않은 데이터를 읽는 것이고, Phantom Read는 같은 쿼리를 두 번 날렸는데 행 개수가 달라지는 현상이다.

 

은행 잔고가 가장 직관적인 예시다. 통장에 10만 원이 있는데 ATM에서 7만 원을 출금하면서 동시에 폰뱅킹으로 5만 원을 송금한다고 해 보자. 락이 없으면 둘 다 성공해 잔고가 -2만 원이 되거나, 한쪽 차감이 그대로 증발한다. 이를 막기 위해 락이 필요하다.

 

락이 해결하려는 것은 정확히 무엇인가

 

여러 트랜잭션이 같은 데이터를 동시에 건드릴 때 일관성을 유지하는 것이 락의 목적이다. 다만 트랜잭션 격리 수준(Isolation Level)만으로는 부족한 경우가 많다. READ COMMITTED 수준에서는 Lost Update가 그대로 발생하고, REPEATABLE READ로 올려도 Phantom 문제가 남는다. SERIALIZABLE까지 가면 처리량이 박살난다.

 

그래서 격리 수준은 적당히(보통 READ COMMITTED) 두고, 동시성 충돌 가능성이 있는 부분만 골라 락으로 보호하는 것이 일반적인 패턴이다. 락 전략이 비관적/낙관적 두 갈래로 나뉘는 이유도 여기서 시작된다.

 

비관적 락(Pessimistic Lock)이란 무엇인가

 

한 줄로 정리하면

 

충돌이 일어날 것이라고 미리 가정하고 데이터에 자물쇠를 채우는 방식이다. 누군가 먼저 잡으면 다른 트랜잭션은 락이 풀릴 때까지 기다려야 한다. SQL로는 SELECT ... FOR UPDATE가 그 자체다.

 

-- 비관적 락 (배타 락)
SELECT * FROM stock WHERE product_id = 100 FOR UPDATE;

-- 비관적 공유 락
SELECT * FROM stock WHERE product_id = 100 FOR SHARE;

 

FOR UPDATE를 잡으면 트랜잭션이 커밋되거나 롤백될 때까지 다른 트랜잭션은 그 행을 건드리지 못한다. 읽기조차 막힐 수 있다(DB마다 다르다). MySQL InnoDB는 기본 row-level 락이라 행 단위로 잠그며, PostgreSQL도 비슷하다.

 

JPA에서 비관적 락 사용법

 

public interface StockRepository extends JpaRepository<Stock, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select s from Stock s where s.productId = :productId")
    Optional<Stock> findByProductIdForUpdate(@Param("productId") Long productId);
}

 

@Lock 어노테이션 하나면 끝난다. 위 메서드를 호출하면 자동으로 SELECT ... FOR UPDATE가 나간다.

 

PESSIMISTIC_READ vs PESSIMISTIC_WRITE의 차이는 다음과 같다.

 

  • PESSIMISTIC_WRITE: 배타 락. FOR UPDATE. 다른 트랜잭션은 읽기/쓰기 모두 불가(DB에 따라 읽기는 가능)
  • PESSIMISTIC_READ: 공유 락. FOR SHARE. 여러 트랜잭션이 동시에 읽을 수는 있지만 쓰기는 모두 막힌다
  • PESSIMISTIC_FORCE_INCREMENT: 비관적 락 + @Version 강제 증가. 자식 엔티티가 바뀔 때 부모 버전도 함께 올리고 싶을 때 사용한다

 

비관적 락의 함정 — 이를 챙기지 않으면 운영에서 문제가 발생한다

 

1. 데드락(Deadlock). 가장 자주 만나는 문제다. 트랜잭션 A가 행1을 잡고 행2를 기다리는데, 트랜잭션 B가 행2를 잡고 행1을 기다리면 둘 다 영원히 대기한다. DB가 데드락을 감지해 한쪽을 죽이긴 하지만, 죽은 쪽 트랜잭션은 롤백되고 사용자는 에러를 보게 된다.

 

2. 락 타임아웃. 락을 잡지 못하고 무한 대기해서는 안 되니 타임아웃을 거는데, 이 값이 짧으면 정상 처리도 죽고 길면 사용자 경험이 망가진다.

 

@QueryHints({
    @QueryHint(name = "javax.persistence.lock.timeout", value = "3000")
})
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithLock(@Param("id") Long id);

 

3. 처리량 저하. 한 트랜잭션이 락을 잡고 있으면 그 행을 건드리려는 다른 트랜잭션은 모두 줄을 서야 한다. 트래픽이 몰리는 시점에 락 경합이 심해지면 응답 시간이 폭발한다.

 

낙관적 락(Optimistic Lock)이란 무엇인가

 

한 줄로 정리하면

 

충돌이 거의 일어나지 않을 것이라 가정하고 일단 진행한 다음, 커밋 시점에 "내가 읽어 온 것과 지금의 것이 동일한가"를 검증하는 방식이다. 락 자체를 잡지 않는다. 그래서 동시 처리량은 비관적 락보다 훨씬 좋다.

 

검증은 보통 버전 컬럼으로 한다. 트랜잭션 시작 시 version=3으로 읽어 왔다면, UPDATE를 칠 때 WHERE version = 3 조건을 박고 동시에 version을 4로 올린다. 다른 트랜잭션이 먼저 커밋해 version이 4로 올라갔다면 내 UPDATE는 0건 영향 → 충돌 감지 → 예외 발생이다.

 

-- 낙관적 락 동작 원리 (직접 짠다면 이런 식)
UPDATE product
SET stock = stock - 1, version = version + 1
WHERE id = 100 AND version = 3;
-- affected rows = 0 이면 충돌

 

JPA에서 낙관적 락 사용법

 

엔티티에 @Version을 박으면 끝난다.

 

@Entity
public class Product {

    @Id
    private Long id;

    private String name;
    private int stock;

    @Version
    private Long version;
}

 

JPA가 알아서 매 UPDATE마다 version 검증을 박고, 맞지 않으면 OptimisticLockException을 던진다. 추가로 @Lock으로 명시할 수도 있다.

 

@Lock(LockModeType.OPTIMISTIC)
Optional<Product> findById(Long id);

@Lock(LockModeType.OPTIMISTIC_FORCE_INCREMENT)
Optional<Product> findByIdWithIncrement(Long id);

 

OPTIMISTIC_FORCE_INCREMENT는 엔티티 자체가 바뀌지 않아도 강제로 버전을 올린다. 자식이 바뀌었는데 부모 엔티티 버전도 올려야 할 때 유용하다.

 

낙관적 락의 함정 — 재시도를 구현하지 않으면 시한폭탄이다

 

1. OptimisticLockException 처리. 충돌이 나면 RuntimeException 계열이 터지면서 트랜잭션이 롤백된다. 사용자에게 그대로 에러를 보내면 망한다. 재시도 로직이 반드시 필요하다.

 

2. 재시도 로직 필수. 낙관적 락은 거의 항상 재시도가 세트다. 짜지 않으면 사용자가 "왜 또 실패했지" 하며 새로고침을 누르는 비극이 펼쳐진다.

 

3. 비즈니스 로직이 무거우면 재시도 비용이 크다. 트랜잭션 안에서 외부 API를 호출하거나 무거운 계산을 하는데 충돌로 롤백되고 재시도하면 그 비용이 모두 두 배가 된다. 외부 API는 트랜잭션 밖으로 빼는 것이 정석이다.

 

 

출처: Baeldung (18KB)

 

비관적 락과 낙관적 락, 언제 써야 하는가 (이것이 핵심이다)

 

충돌 빈도로 판단하는 1차 기준

 

가장 직관적이고 강력한 기준이다. 도메인별로 정리하면 다음 표가 나온다.

 

도메인 충돌 빈도 추천 락 이유
게시글 좋아요 낮음(분산됨) 낙관적 같은 글 동시 좋아요 거의 없음
한정 수량 재고 차감 매우 높음 비관적 핫딜에 트래픽 몰림
좌석/티켓 예매 매우 높음 비관적 인기 공연은 사실상 동시 1만 명
사용자 프로필 수정 거의 없음 낙관적 본인이 본인 것만 수정
결제 처리 높음 비관적 중복 결제 절대 금지
댓글 작성 낮음 낙관적 새 행 생성, 충돌 적음
포인트 적립/차감 중간 상황별 적립 자주, 차감 드뭄
인기 게시글 조회수 매우 높음 비관적 또는 별도 처리 사실상 별도 카운터 권장

 

핵심 원칙은 단 하나다. "같은 행을 동시에 건드릴 확률이 어느 정도냐"이다. 사용자가 자기 것만 만지는 도메인은 무조건 낙관적, 모두가 같은 행을 노리는 도메인(재고/예매)은 무조건 비관적이다.

 

트랜잭션 길이로 판단하는 2차 기준

 

충돌 빈도가 비슷한 수준이라면 트랜잭션 길이가 다음 판단 축이 된다.

 

  • 짧은 트랜잭션(수 ms 이내): 비관적도 무방하다. 락을 잡고 있는 시간이 짧아 다른 트랜잭션에 미치는 영향이 적다
  • 긴 트랜잭션(외부 API, 복잡한 비즈니스 로직 포함): 낙관적을 권장한다. 비관적으로 락을 잡고 있으면 그 사이 들어오는 트랜잭션이 모두 줄을 서다 죽는다

 

은행 송금 트랜잭션이 외부 결제망 응답을 기다리는 동안 비관적 락으로 행을 잡고 있다고 생각해 보자. 그동안 다른 사람들의 입출금이 모두 막힌다. 이건 답이 없다.

 

재시도 가능 여부로 판단하는 3차 기준

 

낙관적 락은 충돌 시 재시도를 전제로 한다. 그래서 재시도 가능한 작업이어야 한다.

 

  • 재시도 가능: 단순 카운터, 좋아요, 단순 데이터 수정 → 낙관적 OK
  • 재시도 불가: 사용자가 폼을 다시 채워야 하는 경우, 외부 API로 이미 결제가 완료된 상황 → 비관적 권장

 

결제 같은 작업이 충돌로 한 번 실패했다고 자동 재시도하면 중복 결제 위험이 있다. 이런 경우는 처음부터 비관적 락으로 직렬화하는 것이 안전하다.

 

실무 의사결정 플로우차트

 

충돌 빈도 높음? → YES → 비관적 락
                 → NO → 트랜잭션 김?
                          → YES → 낙관적 락
                          → NO → 재시도 가능?
                                   → YES → 낙관적 락
                                   → NO → 비관적 락

 

이 흐름만 외워 두면 90%의 케이스는 답이 나온다. 나머지 10%는 분산 환경 같은 특수 케이스인데 뒤에서 다룬다.

 

자주 발생하는 함정과 해결법

 

비관적 락의 데드락을 어떻게 막을 것인가

 

데드락 100% 예방은 불가능하지만 빈도는 확연히 줄일 수 있다.

 

1. 락 획득 순서 통일. 트랜잭션 A가 행1 → 행2 순서, 트랜잭션 B가 행2 → 행1 순서로 잡으면 데드락은 확정이다. 모든 트랜잭션이 같은 순서(예: ID 오름차순)로 락을 잡도록 코드를 짜면 데드락이 거의 사라진다.

 

2. 타임아웃을 짧게. 위에 적은 javax.persistence.lock.timeout 같은 값으로 락 대기 시간을 제한해 두면 데드락을 감지하지 못한 케이스도 결국 풀린다.

 

3. SKIP LOCKED 활용. 큐 형태로 작업을 분배할 때 매우 유용하다. 락이 걸린 행은 건너뛰고 다음 행을 가져온다.

 

SELECT * FROM job_queue
WHERE status = 'pending'
ORDER BY created_at
LIMIT 1
FOR UPDATE SKIP LOCKED;

 

워커 여러 개가 큐를 처리할 때 이를 쓰지 않으면 모두 첫 번째 행에서 줄을 선다. SKIP LOCKED를 박으면 워커마다 다른 작업을 잡고 병렬로 처리한다.

 

낙관적 락 재시도 로직 작성법

 

스프링이라면 @Retryable이 깔끔하다.

 

@Retryable(
    value = {ObjectOptimisticLockingFailureException.class},
    maxAttempts = 3,
    backoff = @Backoff(delay = 100, multiplier = 2)
)
@Transactional
public void decreaseStock(Long productId, int quantity) {
    Product product = productRepository.findById(productId)
        .orElseThrow();
    product.decrease(quantity);
}

@Recover
public void recover(ObjectOptimisticLockingFailureException e,
                    Long productId, int quantity) {
    log.error("재시도가 모두 실패했다. productId={}", productId);
    throw new BusinessException("일시적 오류이다. 다시 시도해 달라");
}

 

백오프 전략. 첫 재시도는 100ms 뒤, 그다음은 200ms, 400ms… 점점 늘리는 지수 백오프(exponential backoff)다. 모두 같은 시점에 재시도하면 또 충돌이 나므로 시간차를 두는 것이다.

 

최대 재시도 횟수. 보통 3~5회 정도가 적정하다. 그 이상 실패한다면 진짜 시스템 문제이거나 트래픽 폭증이라 그냥 사용자에게 에러를 보내는 편이 낫다.

 

주의: @Retryable은 메서드 단위 재시도다. 그 메서드가 @Transactional이면 재시도할 때마다 트랜잭션이 새로 시작된다. 트랜잭션 안에서 수동 재시도를 짜면 같은 트랜잭션 안에서 또 충돌이 나니 의미가 없다. 반드시 트랜잭션 밖에서 재시도가 도는 구조여야 한다.

 

DB 락만으로는 부족한 분산 환경

 

서버 인스턴스가 여러 개 떠 있는 환경에서 DB 락은 한계가 명확하다. 왜일까? DB 락은 DB 단에서 동작하니 인스턴스가 몇 개든 같은 DB만 본다면 동작은 한다. 다만 캐시 무효화나 외부 API 호출 직렬화 같은 "DB 행 외의 자원" 동시성 제어는 DB 락으로 할 수 없다.

 

이럴 땐 Redis 분산 락(Redisson 라이브러리)을 쓴다. RLock을 잡고 비즈니스 로직을 돌린 뒤 풀어 주는 식이다. 자세한 내용은 분산 락을 따로 정리한 글에서 다룰 예정이다.

 

RLock lock = redissonClient.getLock("payment:" + userId);
try {
    if (lock.tryLock(3, 10, TimeUnit.SECONDS)) {
        // 비즈니스 로직
    }
} finally {
    if (lock.isHeldByCurrentThread()) {
        lock.unlock();
    }
}

 

PostgreSQL은 MVCC라 일반 SELECT는 락을 잡지 않는다는 점도 자주 헷갈리는 포인트다. PG에서 동시성을 제어하려면 명시적으로 FOR UPDATE를 박거나 SERIALIZABLE 격리 수준을 써야 한다.

 

 

출처: sqlskull.com

 

면접 단골 질문 정리

 

각설하고, 면접에서 거의 매번 나오는 질문들의 답변 포인트만 정리한다.

 

Q. 낙관적 락과 비관적 락의 차이는?

A. 비관적 락은 충돌을 가정하고 미리 락을 잡는 방식, 낙관적 락은 충돌이 나지 않는다고 가정하고 커밋 시점에 버전으로 검증하는 방식이다. 비관적은 SELECT FOR UPDATE, 낙관적은 @Version 컬럼 기반이다.

 

Q. 언제 어느 것을 쓰는가?

A. 충돌 빈도가 높으면 비관적, 낮으면 낙관적이다. 트랜잭션이 길면 낙관적이 처리량 면에서 좋다. 결제처럼 재시도가 위험한 도메인은 비관적이 안전하다.

 

Q. @Version 동작 원리는?

A. UPDATE할 때 WHERE version = ? 조건이 자동으로 박히고, 함께 version을 +1 한다. 다른 트랜잭션이 먼저 커밋해 버전을 올렸다면 affected rows가 0이 되고, JPA가 그것을 감지해 OptimisticLockException을 던진다.

 

Q. OptimisticLockException을 어떻게 처리하는가?

A. 트랜잭션 밖에서 재시도 로직을 둔다. 스프링이라면 @Retryable + 지수 백오프다. 최대 3~5회 정도 시도하고, 모두 실패하면 사용자에게 에러를 보낸다.

 

Q. PESSIMISTIC_WRITE와 PESSIMISTIC_READ의 차이는?

A. WRITE는 배타 락(FOR UPDATE)이라 다른 트랜잭션이 읽기/쓰기 모두 불가하다. READ는 공유 락(FOR SHARE)이라 다른 트랜잭션도 읽을 수는 있지만 쓰기는 막힌다.

 

핵심 3줄 요약

 

  1. 충돌 빈도가 판단의 첫 기준이다 — 같은 행을 동시에 건드릴 확률이 높으면 비관적, 낮으면 낙관적이다. 재고/예매는 비관적, 좋아요/프로필은 낙관적이다.
  2. 재시도 로직이 없으면 낙관적 락은 의미가 없다 — OptimisticLockException을 그대로 사용자에게 보내면 욕먹는다. @Retryable + 지수 백오프가 정석이다.
  3. DB 락이 만능은 아니다 — 분산 환경에서는 Redis 분산 락이 필요한 케이스가 있다. DB 락만 믿고 가면 멀티 인스턴스 환경에서 구멍이 난다.

 

비관적 락과 낙관적 락의 차이는 결국 "충돌을 사전에 막을 것인가, 사후에 감지할 것인가"의 트레이드오프다. 정답은 도메인이 결정한다. 위 표를 한 번 보고 자기 도메인에 매칭해 보면 답이 거의 나온다. 다음 단계로 가고 싶다면 분산 락(Redisson), MVCC 동작 원리, SKIP LOCKED를 활용한 큐 패턴 정도가 자연스러운 학습 루트다.

728x90
반응형
Share Link
reply
«   2026/05   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
31