본문 바로가기

학습 기록 (Learning Logs)/Today I Learned

비관적락, 낙관적락

같이 스터디하는 사람이 예약시스템을 만든다.

좌석 예약 시스템(가게id, 년월일, 시작시간~종료시간, 좌석번호)

1. 동시에 같은 자리에 예약 경쟁하는 경우

2. 동시간에 같은 좌석인데 시작시간이랑 종료시간이 묘하게 다른데 예약이 겹치는 경우

동시성 문제가 발생할 것 같으니 고려해야한다고 했다.

❌ 좌석 예약은 충돌 가능성 높아서 대부분 비관적 락이 적합다고 말했는데

 

 

 


 

스터디원은 동시성 문제에 대한 해결책으로

  • redis로 앞에서 예약을 확인하고
  • DB에서는 where절로 update, insert
  • 를 하기떄문에 비관적락이 필요없다고 말하는 것이었다.

그래서 비관적 락이 필요없다고 하는 것에 대해서 설명 해주기 위해, 정리하는 글이다.

 

 

 


✅ 전제: 좌석 예약 시스템 (멀티 서버 + 멀티 스레드 환경)

  • 주요 키: 가게ID, 예약일, 시작시간, 종료시간, 좌석번호
  • 동시에 여러 명이 같은 좌석/시간에 예약 요청 가능

 

✅ 문제 1: 같은 자리에 동시에 예약 요청

 

  • 사용자 A, B가 동일한 좌석에 같은 시간 요청
  • 선착순 한 명만 예약 성공해야 함 → 동시성 제어 필요

 

 

✅ 문제 2: 시간 겹치는 예약

  • A가 14:00 ~ 15:00 예약 중
  • B가 14:30 ~ 15:30을 예약하면 부분적으로 겹침
  • 시간 조건에 따라 충돌 여부 판단 필요

  • Java Code 에서 비관적락, 낙관적락
  • DB에서 비관적락, 낙관적락
  • OS에서 비관적락, 낙관적락

 

💡 실무에서 선택 기준


 

상황 추천 락 방식
계좌 이체, 재고 감소 비관적 락 (정확성 최우선)
게시글 좋아요, 읽기 많은 통계 낙관적 락 (충돌 드묾, 성능 중요)
실시간 동시성 높은 CPU 처리 낙관적 락 (CAS 기반 lock-free 처리)
멀티스레드 접근이 심한 Java 객체 비관적 락 (synchronized) 또는 StampedLock

 


 

✅ 1. Java Code에서의 비관적 락 vs 낙관적 락

🔒 비관적 락 (Pessimistic Lock)

synchronized (lockObj) {
    // 공유 자원 접근
}

 

  • 환경: 멀티스레드 환경에서 synchronized, ReentrantLock 등 사용
  • 특징:
    • 자원을 미리 잠금해서 다른 스레드의 접근을 차단
    • 락을 잡는 동안 다른 스레드는 대기
  • 유리한 상황:
    • 경합이 잦고, 데이터 충돌 가능성이 높은 경우

 

✅ 낙관적 락 (Optimistic Lock)

AtomicInteger count = new AtomicInteger(0);
count.compareAndSet(expected, update); // CAS 연산
  • 환경: AtomicInteger, Compare-And-Swap(CAS) 기반
  • 특징:
    • 충돌이 없다고 낙관하고 먼저 접근
    • 마지막에 변경 여부 확인(CAS) 후 충돌 시 다시 시도
  • 유리한 상황:
    • 충돌 가능성이 낮고, 재시도가 성능에 큰 영향을 주지 않는 경우

 

✅ 2. DB에서의 비관적 락 vs 낙관적 락

🔒 비관적 락 (Pessimistic Lock)

SELECT * FROM account WHERE id = 1 FOR UPDATE;
  • 환경: RDBMS (PostgreSQL, MySQL 등)에서 SELECT ... FOR UPDATE
  • 특징:
    • 읽은 데이터를 즉시 잠금다른 트랜잭션은 대기하거나 실패
    • 트랜잭션 커밋 전까지 다른 접근 차단
  • 유리한 상황:
    • 동시 수정이 빈번하고 충돌 시 비용이 큰 경우
    • 예: 은행 계좌 이체, 재고 차감

 

 

✅ 낙관적 락 (Optimistic Lock)

UPDATE account SET balance = 100, version = 2 WHERE id = 1 AND version = 1;

@Version
private Long version;
  • 환경: 주로 JPA/Hibernate에서 @Version 활용
  • 특징:
    • 업데이트 시점에 버전 번호 비교 → 충돌 시 실패
    • 트랜잭션 중 락을 잡지 않음
  • 유리한 상황:
    • 읽기 많고 쓰기 적은 환경, 충돌 가능성이 낮은 경우
    • 예: 게시글 조회/수정, 통계 입력 등

 

 

 

 

 


✅ 3. 운영체제(OS)에서의 비관적 락 vs 낙관적 락

🔒 비관적 락

  • 환경: 커널 수준의 mutex, semaphore, spinlock
  • 사용 예:
    • pthread_mutex_lock(), semaphore_wait() 등
  • 특징:
    • 자원 선점다른 프로세스/스레드는 대기
    • 커널 스케줄러 관여
  • 유리한 상황:
    • 공유 자원에 대한 충돌이 자주 발생하는 저수준 병렬 처리
    • 예: 디바이스 접근, 공유 메모리 쓰기 등

✅ 낙관적 락

  • 환경: 사용자 공간에서의 CAS(CPU 명령어 레벨)
  • 사용 예:
    • x86의 cmpxchg (compare-and-swap) 명령어 사용
    • Java의 Unsafe.compareAndSwapInt() 등도 내부적으로 사용
  • 특징:
    • 자원을 잠그지 않고, 마지막에 변경 여부 확인
    • 충돌 시 재시도 (busy-wait 방식 가능)
  • 유리한 상황:
    • 경합이 적은 고성능 처리
    • 예: lock-free queue, atomic counter

 

 


 

🔍 질문 1. Redis에서 예약 체크하고 예약 처리 → 비관적 락

“Redis로 application에서 예약체크를 하고 처리” → 이것은 Application 레벨의 "비관적 락"으로 볼 수 있어요.

✅ 설명

  • 예를 들어 Redis의 SETNX(Lock)나 Redisson의 tryLock() 등을 사용해서
    • seat:{storeId}:{date}:{seatId} 같은 키로 락을 걸고
    • 그동안 다른 요청은 대기하거나 실패 처리
  • 이건 미리 락을 선점하고 다른 트래픽을 막는비관적 락(Pessimistic Lock) 방식
  • 락을 걸고 → 예약 가능성 체크 → 예약 → 락 해제
if (redissonClient.getLock(key).tryLock(3, 1, TimeUnit.SECONDS)) {
    try {
        // 예약 가능 여부 확인 + 처리
    } finally {
        lock.unlock();
    }
}

 

 

 

 

🔍 질문 2. DB에서 WHERE 조건으로 insert/update → 비관적 락

정확히는, 트랜잭션 안에서 "조건 체크 + insert/update"를 동시에 수행하면서 다른 트랜잭션의 진입을 막는다면 → DB 차원에서의 비관적 락이라고 볼 수 있어요.

SELECT * FROM reservation
WHERE store_id = ? AND seat_id = ? AND date = ?
AND (
   (start_time < :endTime AND end_time > :startTime)
)
FOR UPDATE;


@Lock(LockModeType.PESSIMISTIC_WRITE)
Reservation findReservationBySeatAndTime(...)

 

  • 이 쿼리는 해당 좌석의 예약 중 겹치는 시간대가 있는지 확인하면서,
  • 동시에 해당 row를 락 걸기(FOR UPDATE) → 다른 트랜잭션 진입 방지

 

 

방식 장점 단점 추천 상황
🔐 Redis Lock
(Application 레벨
비관적 락)
빠르고 분산 락 가능 락 해제 실패, 복잡한 장애 처리 서버 여러 대일 때, 빠른 응답 원할 때
🔒 DB Lock
(SELECT FOR UPDATE)
트랜잭션 내에서 처리 일관성 보장 성능 병목 발생 가능, 락 경합 충돌 가능성 높고, 데이터 정확성이 중요할 때
🍀 낙관적 락 (@Version) 락 없이 빠름, 스케일러블 충돌 발생 시 재시도 필요 충돌 가능성 낮고, 성능 우선일 때

 


🔹 단일 서버

  • 서버 인스턴스가 1대인 경우
  • 즉, 동시 접근이 모두 같은 JVM 내에서 처리됨
  • 이 경우 애플리케이션 락(Redis 등)보다 DB 트랜잭션으로 제어하는 게 더 간단하고 안전할 수 있음

🔹 트랜잭션 중요

  • “한 번의 요청에서 예약 가능 여부 체크 → 예약 등록”까지가 꼭 묶여야만 하는 경우
  • 예: 중간에 누군가 선점하면 절대 안 됨 → 원자성이 필수

즉, 이 모든 과정이 하나의 트랜잭션으로 묶여 있어야 한다면
→ DB 트랜잭션 + FOR UPDATE가 가장 확실한 방법입니다

 

 

✅ DB 트랜잭션 + SELECT FOR UPDATE 예시

 
@Transactional
public void reserveSeat(UUID storeId, LocalDate date, int seatNum, LocalTime start, LocalTime end) {
    // 겹치는 예약이 있는지 조회하며 락을 건다
    List<Reservation> existing = reservationRepository.findConflictingReservationsWithLock(
        storeId, date, seatNum, start, end
    );

    if (!existing.isEmpty()) {
        throw new RuntimeException("이미 예약된 시간입니다");
    }

    // 없다면 예약 생성
    reservationRepository.save(new Reservation(...));
}


@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT r FROM Reservation r WHERE r.storeId = :storeId AND r.date = :date AND r.seatNum = :seatNum AND r.startTime < :endTime AND r.endTime > :startTime")
List<Reservation> findConflictingReservationsWithLock(...);

 

✅ 이 방법이 유리한 경우?

서버 1대 동시성 문제 범위가 JVM 내 → DB 트랜잭션이면 충분
락 분산 불필요 Redis 안 써도 됨, 단순
처리 순서 매우 중요 한 트랜잭션 안에서 "체크 → 등록" 해야 함

 

✅ 반대로 Redis 분산 락이 필요한 경우?

서버가 여러 대 (수평 확장) JVM이 서로 달라 락 공유 불가능 → Redis로 분산 락 필요
처리 속도 중요 DB 락보다 빠름 (단, 정확도는 낮아질 수 있음)

 

 

 

 


ReentrantLock이 비관적 락인 이유

import java.util.concurrent.locks.ReentrantLock;

public class Counter {
    private int count = 0;
    private final ReentrantLock lock = new ReentrantLock();

    public void increment() {
        lock.lock(); // 🔒 락 획득
        try {
            count++;
        } finally {
            lock.unlock(); // 🔓 반드시 해제
        }
    }

    public int getCount() {
        return count;
    }

    // 테스트용
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();

        Runnable task = () -> {
            for (int i = 0; i < 1000; i++) {
                counter.increment();
            }
        };

        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);
        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println("최종 카운트: " + counter.getCount()); // 2000이어야 함
    }
}

✅ 비관적 락

락을 잡고 시작

 

ReentrantLock lock = new ReentrantLock();

lock.lock();  // 🔒 먼저 락을 선점
try {
    // 공유 자원 처리 (임계 구역)
} finally {
    lock.unlock();  // 🔓 락 해제
}

 

  • ReentrantLock은 임계 구역(critical section)에 진입하기 전에 락을 먼저 잡습니다
  • 이 락은 다른 스레드가 접근하는 걸 아예 막아버립니다
  • 즉, 다른 스레드는 락이 풀릴 때까지 기다려야 함

 

✅ 낙관적 락

락을 선점하지 않음

AtomicInteger count = new AtomicInteger(0);

while (true) {
    int current = count.get();
    if (count.compareAndSet(current, current + 1)) {
        break;
    }
    // 실패 시 다시 시도
}

 

  • 여기선 락 없이 진행하다가 마지막에 값이 바뀌었는지 확인하고 충돌 시 재시도
  • 이게 낙관적(lock-free) 철학

 

'학습 기록 (Learning Logs) > Today I Learned' 카테고리의 다른 글

정렬  (0) 2025.03.26
원자성  (0) 2025.03.25
레디스 pub/sub  (0) 2025.03.24
✨ Operating Systems: Three Easy Pieces ✨ - 개요  (0) 2025.03.23
머신러닝  (0) 2025.03.21