본문 바로가기
개발/개발

[Java / Spring Boot] 비관적 락 vs 낙관적 락, 선택 기준은 무엇이었나 - 3편

by 글쓰는 개발자 2026. 3. 6.

 

2편에서 재고 차감에 비관적 락을 적용했다. 그런데 모든 상황에 비관적 락이 정답은 아니다.

(2편: 스투시 반팔티를 10명이 동시에 주문했을 때 발생한 일-2편 )

비관적 락은 하나씩 순차 처리라 병목현상이 생기고, 데드락 위험도 있다.

충돌이 거의 없는 상황에서도 락을 걸면 비용만 크고 얻는 게 없는 상황이 된다.


충돌 빈도가 다르다

재고 차감:   불특정 다수가 동시에 같은 상품에 달려듦 → 충돌 빈도 높음
게시글 수정: 보통 작성자 혼자 수정 → 충돌 빈도 낮음

게시글 수정에 비관적 락을 걸면 어떻게 될까?

작성자가 수정 버튼 클릭
→ SELECT FOR UPDATE (배타 락 획득)
→ 수정 폼에서 내용 입력 중... (30초)
→ 저장 클릭 → 커밋 → 락 해제

이 30초 동안 배타 락이 걸려있다. 다른 사람들은 읽지도 못한다. 충돌이 거의 없는데 락을 걸어두면 아무 이유 없이 다른 사람들이 피해를 보는 셈이다.


낙관적 락: 잠그지 않고 "버전"으로 비교한다

"충돌이 드물 테니, 일단 진행하고 충돌이 났으면 그때 처리하자"는 전략이다.

잠그는 게 아니라 버전 번호로 충돌을 감지한다.

@Entity
public class Post {
    @Id
    private Long id;

    private String content;

    @Version  // 낙관적 락의 핵심
    private Long version;
}

@Version 필드가 있으면 JPA가 UPDATE 시 자동으로 버전 조건을 건다.

UPDATE post
SET content = ?, version = 2
WHERE id = ? AND version = 1
-- version이 내가 읽었을 때랑 다르면 → 영향받은 rows = 0 → 충돌 감지

T1이랑 T2가 동시에 같은 게시글(version=1)을 수정하려 하면 이렇게 된다.

T1: version=1 읽음
T2: version=1 읽음

T1: UPDATE ... WHERE version=1 → 성공 → version = 2
T2: UPDATE ... WHERE version=1 → 영향받은 rows = 0 (이미 version=2)
                                → OptimisticLockException 발생

비관적 락처럼 T2가 대기하는 게 아니다. T2는 그냥 진행하다가 저장 시점에 "누가 먼저 수정했네"를 감지하고 실패한다.


OptimisticLockException 처리

T2가 예외를 받으면 두 가지 방법으로 처리할 수 있다.

방법 1: 사용자에게 알리고 다시 시도하게 함

"다른 사람이 이미 수정했습니다. 최신 내용을 확인 후 다시 시도해주세요."
→ 최신 내용을 보여주고 수정 버튼을 다시 누를 수 있게 안내

게시글 수정처럼 사용자가 인지하고 재시도하는 게 자연스러운 경우에 맞다.

방법 2: 코드에서 자동 재시도

재고 차감처럼 사용자가 "재시도"를 인식할 필요 없는 경우엔 백엔드에서 알아서 재시도한다.

재시도 루프는 반드시 @Transactional이 없는 Facade에 있어야 한다. 재시도할 때마다 새로운 트랜잭션에서 새로 읽어야 최신 version을 가져올 수 있기 때문이다. 같은 트랜잭션 안에서 재시도하면 스냅샷이 그대로라 version이 안 바뀐다.

재시도 로직은 별도 메서드가 아니라 Facade의 진입점 메서드 안에 포함된다.

// Facade: 외부에서 호출하는 진입점 (재시도 로직 포함)
@Service
public class OrderFacade {
    private final OrderService orderService;

    public void placeOrder(Long optionId, int quantity) {
        int maxRetry = 3;
        for (int attempt = 0; attempt < maxRetry; attempt++) {
            try {
                orderService.order(optionId, quantity); // 프록시 → 새 트랜잭션 시작
                return; // 성공
            } catch (OptimisticLockException e) {
                if (attempt == maxRetry - 1) {
                    throw new OrderFailedException("잠시 후 다시 시도해주세요.");
                }
                Thread.sleep(50L * (attempt + 1)); // 점진적 대기
            }
        }
    }
}

// Service: @Transactional → 매 호출마다 새 트랜잭션 시작
@Service
public class OrderService {

    @Transactional
    public void order(Long optionId, int quantity) {
        Stock stock = stockRepository.findById(optionId).orElseThrow();
        stock.decrease(quantity); // version 자동 비교 → 충돌 시 OptimisticLockException
    }
}

컨트롤러 입장에서는 Facade의 placeOrder() 하나만 호출하면 된다. 재시도는 Facade 안에서 알아서 처리된다.

Controller → Facade.placeOrder()
                  ↓ 재시도 루프
             Service.order() 호출 (실패 시 재시도)

Facade가 Service(프록시)를 호출할 때마다 새 트랜잭션이 시작되고, 그때마다 최신 version을 읽어온다. 1편에서 다뤘던 "Facade → Service 호출 구조가 프록시를 올바르게 타는 이유"가 여기서도 그대로 적용된다. (1편: @Transactional 붙이면 동시성이 다 해결될 줄 알았다 -1편)


어노테이션이 어디에 붙는지 헷갈린다면

비관적 락이랑 낙관적 락 둘 다 @Transactional이랑 같이 쓰는데, 각 어노테이션이 어디에 붙는지 헷갈릴 수 있다.

비관적 락: @Lock      → Repository 메서드에
낙관적 락: @Version   → Entity 필드에
공통:      @Transactional → Service 메서드에

코드로 보면 이렇다.

// 비관적 락: @Lock은 Repository에
public interface StockRepository extends JpaRepository<Stock, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)  // ← 여기
    @Query("SELECT s FROM Stock s WHERE s.optionId = :optionId")
    Optional<Stock> findByOptionIdWithLock(@Param("optionId") Long optionId);
}

@Service
public class OrderService {

    @Transactional  // ← Service에
    public void order(Long optionId, int quantity) {
        Stock stock = stockRepository.findByOptionIdWithLock(optionId);
        stock.decrease(quantity);
    }
}
// 낙관적 락: @Version은 Entity에
@Entity
public class Stock {

    @Id
    private Long id;

    private int quantity;

    @Version  // ← Entity 필드에
    private Long version;
}

@Service
public class OrderService {

    @Transactional  // ← Service에 (동일)
    public void order(Long optionId, int quantity) {
        Stock stock = stockRepository.findById(optionId).orElseThrow();
        stock.decrease(quantity); // version 자동 비교
    }
}

@Transactional은 둘 다 공통으로 Service에 있고, 락을 거는 방식만 다르다. 비관적 락은 조회 시점에 DB에 락을 거는 거라 Repository에, 낙관적 락은 저장 시점에 버전을 비교하는 거라 Entity에 선언한다.


비관적 락의 또 다른 단점: 커넥션 풀 고갈

병목현상이 실제로 어떻게 일어나는지 이해하려면 HikariCP를 알아야 한다.

커넥션이 뭔가

커넥션은 앱 서버와 DB 서버 사이의 통로다.

[앱 서버] ←── 통로(커넥션) ──→ [DB 서버]

전화로 비유하면 이렇다.

전화 연결 = 커넥션 생성
통화 중   = 쿼리 실행 중
전화 끊음 = 커넥션 종료

DB에 쿼리를 날리려면 반드시 이 통로가 필요하다. 근데 매번 새로 연결을 만들면 비용이 크다.

커넥션 생성 과정:
TCP 연결 → DB 인증 → 세션 설정
→ 매번 하면 100~200ms씩 걸림

HikariCP가 하는 일

그래서 커넥션을 미리 여러 개 만들어두고 재사용한다. 이게 커넥션 풀이고, Spring Boot 기본 구현체가 HikariCP다.

전화기 10대를 항상 연결된 상태로 유지해두고
필요할 때 빌려주고, 다 쓰면 반납받는 것

개발자가 직접 커넥션을 꺼내고 반납하는 코드를 안 써도 된다. @Transactional이랑 JPA가 알아서 HikariCP한테 요청하고 반납해준다.

@Transactional
public void order(Long optionId, int quantity) {
    // 이 한 줄 뒤에서 일어나는 일:
    // 1. HikariCP에 "커넥션 하나 줘" 요청
    // 2. HikariCP가 풀에서 놀고 있는 커넥션 꺼내서 줌
    // 3. 그 커넥션으로 DB에 쿼리 전송
    Stock stock = stockRepository.findByOptionId(optionId);
    ...
} // 트랜잭션 끝 → 커넥션 풀에 자동 반납

application.yml에서 설정을 확인할 수 있다.

spring:
  datasource:
    hikari:
      maximum-pool-size: 10     # 최대 커넥션 수 (기본값 10)
      connection-timeout: 30000 # 커넥션 못 받으면 30초 후 에러

비관적 락 + 커넥션 풀 고갈

비관적 락이 걸린 상황에서 동시 요청이 몰리면 이런 일이 생긴다.

T1:  전화기 빌림 → FOR UPDATE → 락 잡고 통화 중 (전화기 안 돌려줌)
T2:  전화기 빌림 → FOR UPDATE → T1 끝날 때까지 대기 (전화기 안 돌려줌)
T3:  전화기 빌림 → 대기 (전화기 안 돌려줌)
...
T10: 전화기 빌림 → 대기 (전화기 안 돌려줌)
T11: 전화기 빌리려는데 → 10대 다 누군가 들고 있음
     → 30초 기다려도 안 옴 → 에러 💥

락을 잡고 기다리는 동안 커넥션(전화기)을 계속 점유하기 때문에, 동시 요청이 많아지면 커넥션 풀이 고갈된다. 이게 비관적 락의 병목현상이 실제로 일어나는 메커니즘이다.

데드락과는 다른 문제다.

데드락: T1이 T2 락 기다리고, T2가 T1 락 기다리는 것 (교착 상태)
커넥션 풀 고갈: 락 대기 중인 트랜잭션들이 커넥션을 다 잡고 있어서
               새 요청이 커넥션을 못 받는 것

둘 다 비관적 락의 단점이지만 원인이 다르다.

그럼 커넥션 풀을 늘리면 해결되지 않나?

늘릴 수는 있지만 한계가 있다.

커넥션 하나는 DB 서버에서 스레드 하나와 메모리를 소비한다. 무작정 늘리면 DB 서버가 메모리 부족으로 죽는다.

앱 서버 커넥션 풀: 100개
DB 서버 max_connections: 150개
→ 앱 서버가 100개 다 열면 DB 서버 한계치의 66% 차지
→ 다른 앱 서버나 관리 도구가 DB에 붙을 자리가 없음

HikariCP 창시자가 제안한 적정 커넥션 수 공식이 있다.

적정 커넥션 수 = (CPU 코어 수 × 2) + 유효 디스크 수

CPU 4코어짜리 서버면 4 × 2 + 1 = 9개가 적정이다. 기본값 10개가 이 공식에서 나온 거다. 커넥션만 늘리면 DB에서 컨텍스트 스위칭이 늘어나 오히려 더 느려질 수 있다.

커넥션 풀을 늘려도 되는 경우

커넥션을 늘리는 게 맞는 상황은 따로 있다.

DB CPU는 여유있고, 쿼리도 빠른데
그냥 요청이 많아서 커넥션이 부족한 것
→ 늘리는 게 맞음

DB CPU가 이미 100% 찍고 있거나
락 대기 때문에 커넥션 점유가 문제인 것
→ 늘려봤자 DB만 더 힘들어짐, 근본 원인 해결이 먼저
DB가 한가한데 커넥션이 부족한 것 → 늘려도 됨
DB가 바쁜데 커넥션이 부족한 것  → 근본 원인 해결이 먼저

비관적 락 대기 문제를 커넥션 풀 늘려서 해결하려는 건 근본 해결책이 아니다.

내가 생각하는 진짜 해결책:
1. 트랜잭션을 짧게 유지 (락 점유 시간 최소화)
2. 충돌이 드문 곳은 낙관적 락으로 전환
3. 트래픽이 정말 많으면 Redis 분산 락 고려

서비스 규모에 따라 다르다

커넥션 풀 설정은 서비스 규모에 따라 접근이 달라진다.

단일 서버에 앱 + DB 같이 운영한다면
    → 비용이 중요, 트래픽 적음
    → 기본값(10개) 그대로 써도 충분

앱 서버 / DB 서버 분리가 되어 있다면?
    → 트래픽 늘어나면서 자연스럽게 분리
    → 이때부터 공식이 의미 있음

앱 서버 N대 + DB Master/Replica로 되어 있다면?
    → 커넥션 풀 × 서버 대수가 DB max_connections 초과 안 되게 관리

앱과 DB를 단일 서버에서 운영한다면 공식을 이렇게 조정한다.

CPU 4코어, 앱 + DB 같은 서버:

DB가 쓸 CPU: 4코어 중 절반 → 2코어
앱이 쓸 CPU: 나머지 2코어

커넥션 풀 = (2 × 2) + 1 = 5개 정도

다만 이런 계산보다 모니터링하면서 조정하는 게 더 실용적일 것 같다.

커넥션 풀 고갈을 어떻게 감지하나

문제가 생기면 이런 에러가 터진다.

Unable to acquire JDBC Connection
HikariPool-1 - Connection is not available,
request timed out after 30000ms

connection-timeout(기본 30초) 동안 커넥션을 못 받으면 발생한다. 사용자 입장에선 그냥 500 에러로 보인다.

에러가 나기 전에 로그로 미리 감지할 수 있다.

java
// HikariCP가 주기적으로 풀 상태를 찍어줌
HikariPool-1 - Pool stats (total=10, active=10, idle=0, waiting=3)
//                                  전부 사용 중  놀고있는거 없음  대기 중인 요청

waiting이 쌓이기 시작하면 커넥션 풀이 부족하다는 신호다. 이때 DB CPU 모니터링과 함께 풀 사이즈 늘리는 걸 고려하면 된다.

낙관적 락은 커넥션을 얼마나 점유하나

낙관적 락도 DB 쿼리를 날리니까 HikariCP 커넥션을 쓴다. 차이는 커넥션을 잡고 있는 시간이다.

비관적 락:
커넥션 꺼냄 → SELECT FOR UPDATE (락 획득)
             → 다른 트랜잭션 대기 중... (이 시간 동안 커넥션 점유)
             → UPDATE → COMMIT → 커넥션 반납

낙관적 락:
커넥션 꺼냄 → SELECT (락 없이 그냥 읽기)
             → UPDATE ... WHERE version = 1
             → COMMIT → 커넥션 반납 (대기 없음)

10명이 동시에 요청할 때 각 트랜잭션의 커넥션 점유 시간을 비교하면 이렇다.

비관적 락:
T1:  쿼리 실행 시간
T2:  T1 대기 + 쿼리 실행 시간
T3:  T1 + T2 대기 + 쿼리 실행 시간
...
T10: 앞 9개 대기 + 쿼리 실행 시간  ← 점점 길어짐

낙관적 락:
T1~T10 전부: 쿼리 실행 시간만  ← 대기 없음

쿼리 실행이 5ms라고 하면

비관적 락 T10: 5ms × 9 대기 + 5ms 실행 = 50ms 커넥션 점유
낙관적 락 T10: 5ms 커넥션 점유 (충돌 시 반납 후 재시도)

낙관적 락은 충돌 시 재시도 비용이 있지만, 커넥션을 잡고 있는 시간 자체는 비관적 락보다 훨씬 짧다.


선택 기준

비관적 락: 충돌이 자주 일어나고 + 틀리면 절대 안 되는 곳
낙관적 락: 충돌이 드물고 + 실패해도 사용자가 납득할 수 있는 곳

기준 비관적 락 낙관적 락

충돌 빈도 높을 때 유리 낮을 때 유리
처리량 낮음 (대기 발생) 높음 (락 없이 진행)
실패 처리 대기 후 순차 처리 재시도 또는 사용자 안내
커넥션 점유 락 대기 동안 점유 짧게 점유
데드락 위험 있음 없음
적합한 상황 재고, 결제 게시글 수정, 프로필 변경

그럼 원자적 연산은?

락 얘기를 하다 보면 원자적 연산도 나온다. 락이랑은 개념이 다르다.

락:          "내가 쓰는 동안 다른 애들 못 들어오게 막는 것"
원자적 연산: "읽고 쓰는 걸 쪼갤 수 없는 하나의 연산으로 처리하는 것"
-- 일반 방식 (읽고 → 쓰기, 두 단계)
SELECT quantity FROM stock WHERE id = 1
UPDATE stock SET quantity = 0 WHERE id = 1

-- 원자적 연산 (읽고 쓰기가 한 번에)
UPDATE stock SET quantity = quantity - 1 WHERE id = 1

아래 쿼리는 DB 안에서 읽고 쓰는 게 한 번에 처리된다. 중간에 다른 트랜잭션이 끼어들 틈이 없다.

근데 재고처럼 조건 확인이 필요한 경우엔 쓰기 어렵다.

UPDATE stock SET quantity = quantity - 1
WHERE id = 1 AND quantity > 0
-- 영향받은 rows가 0이면 재고 부족인지, 없는 행인지 구분이 안 됨

그래서 원자적 연산은 조건 없이 그냥 더하고 빼도 되는 곳에 쓴다. 좋아요 카운트가 딱 맞는 케이스다.

재고 차감     → 비관적 락    (조건 확인 필요, 틀리면 안 됨)
게시글 수정   → 낙관적 락    (충돌 드묾, 실패해도 납득 가능)
좋아요 카운트 → 원자적 연산  (조건 없이 그냥 +1/-1)

좋아요 카운트에서 원자적 연산을 쓰면 어떤 일이 생기는지는 5편에서 자세히 다룬다.


그런데, 유니크 키는 어디에 쓰는 걸까?

락도 아니고, 버전 비교도 아닌데 동시성 문제를 막는 방법이 하나 더 있다.

UNIQUE INDEX uq_user_coupon (user_id, coupon_id)

DB 유니크 키를 쓰면 애플리케이션 레벨에서 아무리 꼼꼼하게 체크해도 막지 못하는 동시 요청을 DB가 직접 막아준다. 락을 걸지 않아도.

어떤 상황에서 유니크 키가 비관적 락보다 더 나은 선택이 될까? 다음 편에서 쿠폰 사용 로직을 구현하면서 직접 확인해본다.


시리즈 목차

  1. @Transactional 붙이면 동시성이 다 해결될 줄 알았다
  2. 스투시 반팔티를 10명이 동시에 주문했을 때 발생한 일
  3. 비관적 락 vs 낙관적 락, 선택 기준은 무엇이었나 ← 현재 글
  4. 쿠폰 사용에 대한 처리는 어떤 락이 적절할까
  5. 브랜드에서 전화가 왔다, "우리 상품 좋아요가 한번에 수십개씩 빠져요!"
반응형