infra
Platform

모듈 맵

[Database] 낙관적 락(Optimistic Lock) vs 비관적 락 동시성 충돌 제어

0 / 37 완료

펼치기
0 / 37 완료0%

데이터베이스 설계 & 튜닝 · 33 / 37

[Database] 낙관적 락(Optimistic Lock) vs 비관적 락 동시성 충돌 제어

version 컬럼 기반 낙관적 락으로 재고·좌석 예약 동시성 문제를 해결하고, ORM별 구현과 충돌 시 재시도 전략을 익힙니다

🚨INCIDENT ALERT
HIGH

동시에 두 사용자가 같은 데이터를 수정하면 마지막 저장이 앞선 변경을 덮어쓸 수 있습니다. 트래픽이 크지 않아도 관리자 화면과 배치 작업에서 자주 생기는 문제입니다. 낙관적 락을 이해하면 불필요한 DB 락 없이도 갱신 충돌을 감지할 수 있습니다.

이번 챕터에서 배울 것

낙관적 락은 ORM 레벨에서 version 컬럼 하나로 구현할 수 있고, 비관적 락보다 처리량이 높습니다. 단, 충돌이 잦은 상황에서는 재시도 폭풍이 역효과를 낼 수 있습니다. 시나리오를 보고 어느 방식이 맞는지 판단하는 것이 핵심입니다.

  • 1비관적 락과 낙관적 락의 처리량과 충돌 감지 트레이드오프를 비교해 선택할 수 있다
  • 2정수 버전과 updated_at 중 version 컬럼 스키마를 적절히 설계할 수 있다
  • 3JPA @Version, SQLAlchemy, Prisma로 ORM별 낙관적 락을 구현할 수 있다
  • 4지수 백오프와 최대 재시도 횟수로 충돌 시 재시도 로직을 작성할 수 있다
  • 5낙관적 락과 SELECT FOR UPDATE 중 상황에 맞는 방식을 선택할 수 있다
  • 6수강신청 시나리오에 낙관적 락을 실전 적용할 수 있다

낙관적 락 & 동시성 충돌 처리 패턴

수강신청 기능을 배포하고 나서 1시간 뒤, "정원이 30명인 강의에 32명이 등록됐어요"라는 메시지가 날아왔습니다. 코드를 열어보니 로직 자체는 맞았습니다. 잔여 인원을 읽고, 0보다 크면 INSERT하는 순서였으니까요. 문제는 두 요청이 거의 동시에 잔여 인원 2를 읽고, 둘 다 "자리 있다"고 판단한 뒤 각자 INSERT를 했다는 것입니다. 한 명이 먼저 커밋하는 사이 다른 한 명의 트랜잭션은 이미 읽기가 끝난 상태였고, 아무도 그 사실을 몰랐습니다. 그때 version 컬럼 하나만 있었어도 두 번째 트랜잭션이 "내가 읽은 이후 누군가 먼저 바꿨다"는 걸 커밋 시점에 감지하고 재시도할 수 있었습니다. 낙관적 락은 잠금을 걸지 않고도 이 충돌을 잡아내는 방법이고, ORM에서 애노테이션 하나로 구현됩니다.


학습 목표

  • version 컬럼을 활용한 낙관적 락의 동작 원리를 설명할 수 있다
  • JPA @Version, SQLAlchemy version_id_col, Prisma raw 패턴으로 ORM별 구현을 작성할 수 있다
  • OptimisticLockException 충돌 시 지수 백오프 재시도 로직을 구현할 수 있다
  • 낙관적 락과 비관적 락의 트레이드오프를 시나리오별로 판단할 수 있다
  • 수강신청/좌석 예약 시나리오에 낙관적 락을 직접 적용할 수 있다

실습 환경 준비

💡개념

비관적 락 vs 낙관적 락 — 처리량과 충돌 감지의 트레이드오프

비관적 락 vs 낙관적 락 — 처리량과 충돌 감지의 트레이드오프확대

비관적 락 — "충돌이 일어날 것"

비관적 락은 데이터를 읽는 순간부터 잠금을 겁니다. 다른 트랜잭션은 잠금이 해제될 때까지 기다립니다.

SQL
-- 비관적 락: 읽는 순간 행을 잠금
BEGIN;
SELECT * FROM inventory WHERE product_id = 42 FOR UPDATE;
-- 이 시점부터 다른 트랜잭션은 같은 행에 접근 시 대기

UPDATE inventory SET stock = stock - 1 WHERE product_id = 42;
COMMIT;
OUTPUT
실행 완료 또는 조회 결과가 표시됩니다.
🔍실행 후 확인할 것
  • version 컬럼 먼저: UPDATE 성공 후 version이 이전값 + 1로 증가했는지 확인. 그대로면 SET version = version + 1 구문이 빠진 것
  • 비관적 락 대기 시간: FOR UPDATE가 붙은 세션이 다른 세션의 같은 행 접근을 차단하는지 확인 — 두 번째 터미널에서 해당 행 SELECT가 블로킹되면 락이 걸린 것
  • affected rows = 0이고 version 변화 없음: 다른 트랜잭션이 먼저 수정 → 비관적 락은 막았지만 데드락 가능성도 있음 — 낙관적 락 전환 검토 신호

장점: 충돌이 절대 발생하지 않음. 데이터 일관성 최강.
단점: 잠금 대기로 처리량 저하. 트래픽이 몰리면 대기열이 폭발. 데드락 가능성.

낙관적 락 — "충돌이 드물 것"

낙관적 락은 읽을 때 잠금을 걸지 않습니다. 대신 수정할 때 "내가 읽은 이후 다른 사람이 먼저 바꿨는가?"를 version으로 확인합니다.

SQL
-- Step 1: 잠금 없이 읽기 (version도 함께 읽음)
SELECT stock, version FROM inventory WHERE product_id = 42;
-- stock=1, version=7 을 읽었다고 가정

-- Step 2: 수정 시 version 조건 추가
UPDATE inventory
SET stock = stock - 1, version = version + 1
WHERE product_id = 42 AND version = 7;  -- ← 핵심!

-- affected_rows = 1: 성공 (아무도 중간에 수정하지 않음)
-- affected_rows = 0: 충돌 감지 → 재시도 또는 에러 처리

낙관적 락 버전 충돌 시퀀스 — 두 트랜잭션이 같은 재고 행(stock=1, version=7)을 잠금 없이 읽은 뒤 둘 다 UPDATE WHERE version=7을 실행하면, 먼저 도착한 A만 affected_rows=1로 성공해 version이 8로 오르고, 뒤이은 B는 version이 이미 8이라 WHERE 조건이 불일치해 affected_rows=0으로 충돌이 감지되어 재조회·재시도로 넘어간다확대

장점: 잠금 없음 → 높은 처리량. 읽기 위주 서비스에서 강력.
단점: 충돌 발생 시 재시도 필요. 충돌이 잦으면 재시도 폭풍.

처리량 비교

상황비관적 락 처리량낙관적 락 처리량
충돌률 1% 미만낮음 (불필요한 대기)높음
충돌률 10~30%보통낮음 (재시도 비용)
충돌률 50% 이상예측 가능매우 낮음 (재시도 폭풍)

낙관적 잠금이 충돌을 잡아내는 여섯 단계

💡개념

동시에 같은 행을 고쳐도 덮어쓰지 않는 법 — 낙관적 잠금 흐름

앞에서 WHERE version = 7 조건 하나로 충돌을 잡는다고 했는데, 그 한 줄이 언제·무엇을 비교하는지를 단계로 펼쳐 보지 않으면 "왜 0행이 충돌이지", "왜 재시도가 필수지"가 손에 안 잡힙니다. 낙관적 잠금은 읽기 → 수정 → version 조건 UPDATE → 영향 행 수 확인 → 성공/충돌 분기 → (충돌이면) 재시도의 여섯 단계로 도는 순환입니다. 이 흐름을 알면 충돌이 예외가 아니라 정상 경로의 한 갈래임이 보입니다.

TEXT
[트랜잭션 A]  재고 행을 고치려 한다  (product_id=42)
   │
   ① 잠금 없이 행을 version과 함께 읽음     SELECT stock, version → stock=1, version=7
   │    → 이 순간 락을 걸지 않음 (다른 트랜잭션도 같은 값을 읽을 수 있음)
   │
   ② 애플리케이션에서 값 계산·수정           stock 1 → 0 (아직 메모리에서만)
   │    → 여기서도 DB 락은 없음
   │
   ③ version 조건을 건 UPDATE 실행          WHERE id=42 AND version=7 · SET version=8
   │    → "내가 읽은 그 version일 때만 써라"
   │
   ④ 영향 행 수(affected rows) 확인
   │      ├─ 1행 → ⑤ 성공: 읽은 뒤 아무도 안 고침 → COMMIT
   │      └─ 0행 → ⑥ 충돌: 그새 누가 version을 8로 올림 → ROLLBACK
   ▼
   ⑥ 충돌이면 ①로 되돌아가 다시 읽기부터 재시도   (지수 백오프로 간격 벌림)

각 단계가 하는 일과, 어긋나면 나타나는 증상:

단계하는 일여기서 어긋나면
① 읽기행을 version과 함께 조회, 락은 걸지 않음version 컬럼을 안 읽으면 ③에서 조건을 못 만듦 — 스키마에 version(또는 타임스탬프) 컬럼이 있어야 함
② 수정애플리케이션 메모리에서 새 값 계산, DB엔 아직 안 씀이 구간이 길수록(사용자 입력 대기 등) 그새 남이 고칠 확률↑ = 충돌률↑
③ UPDATEWHERE ... AND version = <읽은값>version = version + 1 을 함께 실행WHERE의 version 조건이 빠지면 낙관적 잠금이 아예 안 걸려 lost update 발생
④ 영향 행 수rowcount로 성공·충돌 판정rowcount를 안 보고 성공 처리하면 0행(충돌)을 놓쳐 갱신이 조용히 사라짐
⑤ 성공(1행)읽은 뒤 아무도 안 고침 → 커밋정상 경로 — 별도 처리 없음
⑥ 충돌(0행)그새 남이 version을 올림 → 롤백 후 재조회·재시도충돌을 예외로만 처리하고 재시도를 안 하면 사용자에게 불필요한 실패로 노출

핵심은 **"0행은 에러가 아니라 정상 경로"**라는 것입니다. 낙관적 잠금은 락을 안 잡아 경합이 적을 때 비관적 SELECT FOR UPDATE보다 빠르지만, 충돌은 반드시 일어나므로 재시도가 설계의 일부입니다(재시도가 없으면 lost update). 진단은 세 신호로 좁힙니다 — 영향 행 수 0을 로그로 남기고, 재시도 카운터로 경합 정도를 재고, 특정 행에 0행이 몰리면(핫로우) 그 행만 비관적 락·큐로 돌립니다. 경합이 올라가면 재시도가 폭증하므로 이 지표가 곧 방식 전환의 신호입니다.


실습 1: version 컬럼 스키마 설계

낙관적 락에 필요한 스키마를 설계합니다. 버전 추적 방식에는 두 가지가 있습니다.

방법 A: 정수 버전 컬럼 (권장)

SQL
CREATE TABLE inventory (
    id          BIGSERIAL PRIMARY KEY,
    product_id  BIGINT NOT NULL UNIQUE,
    stock       INTEGER NOT NULL CHECK (stock >= 0),
    version     INTEGER NOT NULL DEFAULT 0,  -- ← 낙관적 락용 버전
    updated_at  TIMESTAMPTZ DEFAULT NOW()
);

-- 인덱스: product_id로 조회가 많으므로
CREATE INDEX idx_inventory_product_id ON inventory(product_id);

정수 버전의 장점:

  • 비교가 단순하고 명확 (version = 7)
  • 오버플로우 걱정이 없음 (BIGINT 사용 시)
  • ORM 기본 지원 방식 (JPA, SQLAlchemy 모두 정수 버전 권장)

방법 B: updated_at 타임스탬프 버전

SQL
CREATE TABLE seats (
    id          BIGSERIAL PRIMARY KEY,
    seat_number VARCHAR(10) NOT NULL,
    is_reserved BOOLEAN DEFAULT FALSE,
    updated_at  TIMESTAMPTZ DEFAULT NOW()  -- ← 버전 대신 타임스탬프
);
SQL
-- 조회 시 updated_at도 함께 읽기
SELECT is_reserved, updated_at FROM seats WHERE id = 101;

-- 수정 시 조건으로 확인
UPDATE seats
SET is_reserved = TRUE, updated_at = NOW()
WHERE id = 101 AND updated_at = '2024-03-15 14:23:01.123456+00';

타임스탬프 버전의 단점:

  • 마이크로초 이내 동시 요청 시 같은 타임스탬프 → 충돌 미감지 가능
  • DB 서버 시간 동기화 의존
  • 실무에서 정수 버전 컬럼을 강력히 권장

테스트 데이터 삽입

SQL
INSERT INTO inventory (product_id, stock, version)
VALUES (42, 10, 0),
       (99, 1, 0);

-- 확인
SELECT * FROM inventory;
1낙관적 락 충돌을 affected rows로 감지 — 같은 version을 두 번 갱신

두 요청이 같은 version=0을 읽었다고 가정하고, 둘 다 WHERE version = 0으로 UPDATE를 시도합니다. 첫 번째는 1행을 갱신(version→1)하지만, 두 번째는 조건이 안 맞아 0행이 갱신됩니다. 이 "0행"이 곧 충돌 신호입니다.

SQL
-- 요청 1: version 0 → 1 (성공)
UPDATE inventory SET stock = stock - 1, version = version + 1
WHERE product_id = 42 AND version = 0;
-- UPDATE 1

-- 요청 2: 여전히 version = 0 조건으로 시도 (이미 1이 됨 → 불일치)
UPDATE inventory SET stock = stock - 1, version = version + 1
WHERE product_id = 42 AND version = 0;
-- UPDATE 0   ← 충돌! 애플리케이션은 여기서 재조회+재시도해야 함
OUTPUT
UPDATE 1
UPDATE 0
UPDATE inventory SET stock = stock - 1, version = version + 1 WHERE product_id = 42 AND version = 0;
🔍실행 후 확인할 것
  • 두 번째 UPDATE의 결과가 'UPDATE 0'(affected rows = 0)인지 본다 — 0이면 다른 트랜잭션이 먼저 바꿔 충돌이 감지된 것이다. 이 값을 앱이 읽어 재시도 로직을 돈다
  • 갱신된 행의 version이 정확히 +1 됐는지 SELECT로 확인 — 2가 됐다면 두 번째도 성공한 것(WHERE version 조건이 빠진 버그)
  • stock이 음수로 안 떨어졌는지 본다 — CHECK (stock >= 0)와 낙관적 락이 함께 동시 차감 폭주를 막는다
  • affected rows를 무시하고 '성공'으로 처리하면 잃어버린 갱신(lost update)이 된다 — 0행이면 반드시 에러/재시도로 분기하는지 코드를 점검

💡개념

ORM별 낙관적 락 구현 — JPA, SQLAlchemy, Prisma

JPA (Java) — @Version 애노테이션

JPA에서는 @Version 하나로 낙관적 락이 자동 활성화됩니다. 프레임워크가 UPDATE 시 WHERE version 조건을 자동으로 추가합니다.

Java
@Entity
@Table(name = "inventory")
public class Inventory {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private Long productId;
    private Integer stock;

    @Version  // ← 이것만 추가하면 낙관적 락 활성화
    private Integer version;
}
Java
@Service
@Transactional
public class InventoryService {

    public void decreaseStock(Long productId, int quantity) {
        Inventory inventory = inventoryRepository
            .findByProductId(productId)
            .orElseThrow(() -> new RuntimeException("상품 없음"));

        if (inventory.getStock() < quantity) {
            throw new RuntimeException("재고 부족");
        }

        inventory.setStock(inventory.getStock() - quantity);
        // save() 시점에 JPA가 자동으로:
        // UPDATE inventory SET stock=?, version=version+1
        // WHERE id=? AND version=?  ← 자동 추가
        inventoryRepository.save(inventory);
        // 다른 트랜잭션이 먼저 수정했다면 → OptimisticLockException 발생
    }
}

JPA가 실제로 실행하는 쿼리 (로그로 확인 가능):

SQL
UPDATE inventory SET stock=9, version=1 WHERE id=1 AND version=0;
-- affected rows=0이면 OptimisticLockException 자동 발생

SQLAlchemy (Python) — version_id_col

Python
from sqlalchemy import Column, BigInteger, Integer, create_engine
from sqlalchemy.orm import DeclarativeBase, Session

class Base(DeclarativeBase):
    pass

class Inventory(Base):
    __tablename__ = "inventory"

    id = Column(BigInteger, primary_key=True)
    product_id = Column(BigInteger, nullable=False)
    stock = Column(Integer, nullable=False)
    version = Column(Integer, nullable=False, default=0)

    __mapper_args__ = {
        "version_id_col": version  # ← 낙관적 락 활성화
    }
Python
def decrease_stock(session: Session, product_id: int, quantity: int):
    inventory = session.query(Inventory)\
        .filter(Inventory.product_id == product_id)\
        .one()

    if inventory.stock < quantity:
        raise ValueError("재고 부족")

    inventory.stock -= quantity
    # commit() 시점에 SQLAlchemy가 version 조건 자동 추가
    # StaleDataError 발생 시 충돌 감지
    session.commit()

충돌 발생 시 SQLAlchemy는 sqlalchemy.orm.exc.StaleDataError를 발생시킵니다.

Prisma (Node.js/TypeScript) — Raw 쿼리 패턴

Prisma는 공식 낙관적 락 지원이 없습니다. Raw 쿼리로 직접 구현합니다.

TS
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

async function decreaseStock(productId: number, quantity: number): Promise<void> {
  // Step 1: 현재 버전과 재고 읽기
  const inventory = await prisma.inventory.findUniqueOrThrow({
    where: { productId },
    select: { id: true, stock: true, version: true }
  });

  if (inventory.stock < quantity) {
    throw new Error('재고 부족');
  }

  // Step 2: version 조건을 포함한 UPDATE
  const result = await prisma.$executeRaw`
    UPDATE inventory
    SET stock = ${inventory.stock - quantity},
        version = ${inventory.version + 1}
    WHERE id = ${inventory.id}
      AND version = ${inventory.version}
  `;

  // Step 3: affected rows 확인
  if (result === 0) {
    throw new OptimisticLockError('동시 수정 감지 — 재시도 필요');
  }
}

class OptimisticLockError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'OptimisticLockError';
  }
}

실습 2: 충돌 시 재시도 로직 — 지수 백오프

충돌이 발생했을 때 그냥 예외를 던지면 사용자 경험이 나빠집니다. 대부분의 충돌은 재시도하면 성공합니다. 단, 무한 재시도는 시스템을 망칩니다.

지수 백오프 재시도 패턴 (Python)

Python
import time
import random
from sqlalchemy.orm import exc as orm_exc

def with_optimistic_retry(func, max_retries: int = 3, base_delay: float = 0.1):
    """
    낙관적 락 충돌 시 지수 백오프로 재시도.
    max_retries=3, base_delay=0.1초 → 0.1s, 0.2s, 0.4s 대기
    """
    for attempt in range(max_retries + 1):
        try:
            return func()
        except orm_exc.StaleDataError:
            if attempt == max_retries:
                # 최대 재시도 초과 → 상위로 예외 전파
                raise RuntimeError(
                    f"낙관적 락 충돌: {max_retries}회 재시도 후 실패"
                )

            # 지수 백오프 + 지터(jitter) — 동시 재시도 폭풍 방지
            delay = base_delay * (2 ** attempt) + random.uniform(0, 0.05)
            print(f"충돌 감지, {delay:.2f}초 후 재시도 (시도 {attempt + 1}/{max_retries})")
            time.sleep(delay)
            session.rollback()  # 재시도 전 세션 초기화 필수!

# 사용 예시
def purchase_item(product_id: int, quantity: int):
    def _do_purchase():
        with Session(engine) as session:
            decrease_stock(session, product_id, quantity)

    with_optimistic_retry(_do_purchase, max_retries=3)

지수 백오프 재시도 패턴 (Java + Spring)

Java
@Service
public class InventoryService {

    private static final int MAX_RETRIES = 3;

    public void decreaseStockWithRetry(Long productId, int quantity) {
        int attempt = 0;

        while (attempt <= MAX_RETRIES) {
            try {
                decreaseStock(productId, quantity);
                return; // 성공
            } catch (OptimisticLockingFailureException e) {
                attempt++;
                if (attempt > MAX_RETRIES) {
                    throw new RuntimeException("재고 차감 실패: 동시 요청 충돌", e);
                }

                long delayMs = (long) (100 * Math.pow(2, attempt - 1))
                             + ThreadLocalRandom.current().nextLong(50);
                log.warn("낙관적 락 충돌, {}ms 후 재시도 ({}/{})", delayMs, attempt, MAX_RETRIES);

                try {
                    Thread.sleep(delayMs);
                } catch (InterruptedException ie) {
                    Thread.currentThread().interrupt();
                    throw new RuntimeException("재시도 중단", ie);
                }
            }
        }
    }
}

재시도 시 반드시 지켜야 할 규칙:

  1. 세션/EntityManager 초기화 — 실패한 트랜잭션 컨텍스트를 그대로 재사용하면 캐시된 구버전 데이터로 또 실패
  2. 지터(jitter) 추가 — 모든 요청이 같은 타이밍에 재시도하면 또 충돌 → 랜덤 지연 추가
  3. 최대 재시도 횟수 설정 — 무한 재시도는 스레드 고갈과 DB 부하를 유발

💡개념

낙관적 락 vs SELECT FOR UPDATE 선택 기준

이전 모듈에서 배운 SELECT FOR UPDATE(비관적 락)와 낙관적 락은 같은 문제를 해결하지만 적합한 시나리오가 다릅니다.

언제 낙관적 락을 쓰는가

✅ 낙관적 락이 적합한 경우:

  • 읽기 : 쓰기 비율이 높은 경우 — 상품 상세 페이지 조회가 많고 구매는 가끔
  • 충돌률이 낮은 경우 — 상품이 다양하고 같은 상품을 동시에 구매하는 빈도가 낮음
  • 응답 시간이 중요한 경우 — 잠금 대기 없이 즉시 응답 가능
  • 분산 시스템 / 마이크로서비스 — DB 레벨 잠금이 아닌 애플리케이션 레벨에서 처리
일반 이커머스 상품 구매:
→ 재고 100개, 동시 구매자 5~10명
→ 충돌 확률 낮음 → 낙관적 락 ✅

언제 비관적 락을 쓰는가

✅ SELECT FOR UPDATE가 적합한 경우:

  • 충돌률이 높은 경우 — 한정 수량 이벤트, 선착순 예약
  • 재시도가 불가능한 경우 — 결제 완료 후 포인트 적립처럼 멱등성 보장이 어려운 경우
  • 잠금 대기가 허용되는 경우 — 배치 처리, 관리자 작업
인기 콘서트 좌석 예약 (10분 만에 매진):
→ 재고 1,000석, 동시 접속자 50,000명
→ 충돌 확률 매우 높음 → SELECT FOR UPDATE ✅
→ 또는: 큐 기반으로 직렬화 처리

결정 트리

낙관적 락 vs 비관적 락 선택 결정 트리 — ① 동시 쓰기 충돌이 자주 발생하는가? 예면 SELECT FOR UPDATE(비관적) 또는 큐 기반 직렬화, 아니오면 ② 실패 시 재시도가 가능한가(멱등·재실행 안전)? 예면 낙관적 락(version 컬럼+지수 백오프 재시도), 아니오면 비관적 락. 일반 이커머스 상품 구매(재고 100·동시 5~10명)는 충돌 확률이 낮아 낙관적 락, 인기 콘서트 좌석 예약(재고 1000·동접 5만)은 충돌 확률이 높아 비관적 락/큐 직렬화. 처리량은 낙관적이 높지만 데드락 위험은 비관적에만 있다확대

  • 충돌이 자주 발생하는가?
    • SELECT FOR UPDATE (비관적 락) 또는 큐 기반 직렬화
    • 아니오 → 재시도가 가능한가?
      • → 낙관적 락 ✅
      • 아니오SELECT FOR UPDATE
기준낙관적 락SELECT FOR UPDATE
처리량높음낮음 (잠금 대기)
충돌 시 비용재시도없음 (선점)
데드락 위험없음있음
ORM 지원네이티브쿼리 레벨
분산 환경적합단일 DB에 종속

실습 3: 수강신청 시나리오에 낙관적 락 적용

수강신청 시스템을 직접 구현합니다. 강의당 정원이 30명이고, 동시에 여러 학생이 신청하는 상황입니다.

스키마 설계

SQL
CREATE TABLE courses (
    id           BIGSERIAL PRIMARY KEY,
    name         VARCHAR(200) NOT NULL,
    capacity     INTEGER NOT NULL,         -- 정원
    enrolled     INTEGER NOT NULL DEFAULT 0, -- 현재 수강 인원
    version      INTEGER NOT NULL DEFAULT 0, -- 낙관적 락 버전
    CHECK (enrolled >= 0),
    CHECK (enrolled <= capacity)
);

CREATE TABLE enrollments (
    id         BIGSERIAL PRIMARY KEY,
    course_id  BIGINT NOT NULL REFERENCES courses(id),
    student_id BIGINT NOT NULL,
    enrolled_at TIMESTAMPTZ DEFAULT NOW(),
    UNIQUE (course_id, student_id)         -- 중복 수강 방지
);

-- 테스트 강의 삽입
INSERT INTO courses (name, capacity, enrolled, version)
VALUES ('PostgreSQL 고급 과정', 30, 28, 0); -- 잔여 2석

수강신청 로직 (순수 SQL)

SQL
-- Step 1: 잠금 없이 현재 상태 조회
SELECT id, capacity, enrolled, version
FROM courses
WHERE id = 1;
-- 결과: id=1, capacity=30, enrolled=28, version=5

-- Step 2: 정원 확인 후 version 조건 포함 UPDATE
-- (애플리케이션에서 enrolled < capacity 검증 후 실행)
UPDATE courses
SET enrolled = enrolled + 1,
    version  = version + 1
WHERE id = 1
  AND version = 5          -- ← 낙관적 락
  AND enrolled < capacity; -- ← DB 레벨 이중 안전장치

-- affected rows 확인:
-- 1 → 성공 → enrollments에 INSERT
-- 0 → 실패 (충돌 또는 정원 초과) → 재조회 후 판단

수강신청 서비스 (Python 전체 구현)

Python
from sqlalchemy import text
from sqlalchemy.orm import Session

class EnrollmentService:

    def enroll(self, session: Session, course_id: int, student_id: int) -> str:
        """
        낙관적 락 기반 수강신청.
        Returns: "success" | "full" | "already_enrolled"
        """
        max_retries = 3

        for attempt in range(max_retries):
            # Step 1: 현재 상태 조회 (잠금 없음)
            row = session.execute(
                text("SELECT id, capacity, enrolled, version FROM courses WHERE id = :id"),
                {"id": course_id}
            ).fetchone()

            if row is None:
                raise ValueError("강의를 찾을 수 없습니다")

            # Step 2: 정원 초과 확인 (애플리케이션 레벨)
            if row.enrolled >= row.capacity:
                return "full"

            # Step 3: 중복 수강 확인
            existing = session.execute(
                text("SELECT 1 FROM enrollments WHERE course_id=:c AND student_id=:s"),
                {"c": course_id, "s": student_id}
            ).fetchone()
            if existing:
                return "already_enrolled"

            # Step 4: 낙관적 락 UPDATE
            affected = session.execute(
                text("""
                    UPDATE courses
                    SET enrolled = enrolled + 1, version = version + 1
                    WHERE id = :id AND version = :version AND enrolled < capacity
                """),
                {"id": course_id, "version": row.version}
            ).rowcount

            if affected == 1:
                # 성공: 수강 이력 기록
                session.execute(
                    text("INSERT INTO enrollments (course_id, student_id) VALUES (:c, :s)"),
                    {"c": course_id, "s": student_id}
                )
                session.commit()
                return "success"
            else:
                # 충돌: 재시도
                session.rollback()
                if attempt < max_retries - 1:
                    time.sleep(0.05 * (2 ** attempt))  # 지수 백오프

        return "conflict"  # 최대 재시도 초과

동시성 테스트

SQL
-- 두 터미널에서 동시에 실행해 충돌 재현
-- 터미널 A:
BEGIN;
SELECT enrolled, version FROM courses WHERE id = 1;  -- enrolled=28, version=5
-- (여기서 잠깐 멈춤)

-- 터미널 B: (먼저 커밋)
BEGIN;
UPDATE courses SET enrolled=29, version=6 WHERE id=1 AND version=5;
COMMIT;  -- 성공

-- 터미널 A: (늦게 시도)
UPDATE courses SET enrolled=29, version=6 WHERE id=1 AND version=5;
-- affected rows = 0 → 충돌 감지 → ROLLBACK 후 재시도
ROLLBACK;
🔍실행 후 확인할 것
  • version 컬럼 먼저: 터미널 B 커밋 후 SELECT version FROM courses WHERE id=1 — 값이 6이면 정상. 5 그대로면 COMMIT이 누락된 것
  • affected rows = 0 확인: 터미널 A의 UPDATE 결과가 0 rows이면 충돌 감지 성공 — 이것이 낙관적 락의 핵심 신호. 0이 아니면 version 조건이 WHERE에 빠진 것
  • retry 3회 초과 시: 경합 횟수가 잦음 → 같은 강의(course_id)에 동시 신청이 집중되는 상황. 비관적 락(SELECT FOR UPDATE)이나 큐 기반 직렬화 전환 고려

언제 발생하나

JPA에서 save() 또는 커밋 시점에 version 불일치가 감지되면 이 예외가 발생합니다.

javax.persistence.OptimisticLockException:
Row was updated or deleted by another transaction
(or unsaved-value mapping was incorrect):
[com.example.Inventory#42]

Python SQLAlchemy에서는:

sqlalchemy.orm.exc.StaleDataError:
UPDATE statement on table 'inventory' expected to update 1 row(s);
0 were matched.

흔한 원인 3가지

원인 1: 재시도 시 EntityManager/세션 미초기화

Java
// ❌ 잘못된 패턴: 같은 EntityManager로 재시도
for (int i = 0; i < 3; i++) {
    inventory.setStock(inventory.getStock() - 1);
    repo.save(inventory);  // 두 번째 시도도 구버전 version으로 실패
}

// ✅ 올바른 패턴: 재시도마다 새로 조회
for (int i = 0; i < 3; i++) {
    try {
        Inventory fresh = repo.findById(id).get(); // 재조회!
        fresh.setStock(fresh.getStock() - 1);
        repo.save(fresh);
        break;
    } catch (OptimisticLockingFailureException e) {
        // 재조회 후 재시도
    }
}

원인 2: @Transactional 없이 @Version 사용

Java
// ❌ 트랜잭션 없으면 낙관적 락이 작동하지 않음
public void decreaseStock(Long id) { ... }  // @Transactional 누락

// ✅ 반드시 트랜잭션 내에서 실행
@Transactional
public void decreaseStock(Long id) { ... }

원인 3: N+1 업데이트로 version이 예상보다 빠르게 증가

Lazy Loading으로 연관 엔티티까지 update되면서 version이 예상과 다르게 증가하는 경우. @DynamicUpdate를 사용하거나 실제로 변경된 필드만 UPDATE하도록 쿼리를 분리합니다.

해결 방법 요약

  1. 재시도 시 반드시 세션/EntityManager에서 최신 데이터 재조회
  2. 낙관적 락 코드는 반드시 @Transactional 내에서 실행
  3. 재시도 횟수 초과 시 사용자에게 명확한 메시지 ("요청이 많아 처리 실패, 다시 시도해주세요")

심화 — 낙관적 락이 못 지키는 우회 경로

💡개념

심화: version은 '행 전체'를 지킨다 — 우회 UPDATE와 거짓 충돌

지금까지 낙관적 락은 읽은 version과 커밋 시점 version이 같은지로 충돌을 잡았습니다. 그런데 이 안전장치에는 두 가지 사각지대가 있습니다 — 하나는 version이 지키는 범위, 다른 하나는 version을 아예 거치지 않는 경로입니다.

  • version은 컬럼이 아니라 행 전체를 지킵니다. 두 트랜잭션이 같은 행의 서로 다른 컬럼을 고쳐도 version은 하나뿐이라 뒤에 커밋하는 쪽이 충돌로 실패합니다(거짓 충돌, false conflict). 논리적으로는 안 부딪혔는데 재시도가 발생하는 것입니다. 갱신이 잦고 컬럼이 독립적이라면, 자주 바뀌는 필드를 별도 테이블/행으로 분리해 충돌 단위를 좁힐 수 있습니다.
  • 엔티티를 우회하면 version이 무력화됩니다. @Version은 엔티티를 조회 후 수정하고 flush하는 경로에서만 UPDATE에 WHERE version = ?version = version + 1을 자동으로 붙입니다. JPQL·네이티브 bulk UPDATE(관리자 배치, 대량 보정)는 이 경로를 거치지 않으므로 version을 올리지도, 검사하지도 않습니다. 그 결과 bulk가 다른 트랜잭션의 변경을 조용히 덮어쓸 수 있고, 영속성 컨텍스트에 캐시된 엔티티의 version이 DB와 어긋나 이후 저장이 엉뚱하게 동작합니다.

그래서 성숙한 팀은 모든 갱신 경로가 version을 존중하는지 점검합니다. bulk 쿼리에는 version = version + 1을 직접 포함하고 실행 뒤 컨텍스트를 clear 또는 refresh 하며, 동시성이 중요한 컬럼은 반드시 엔티티 경로(또는 명시적 락)로만 바꿉니다.

상황: @Version으로 낙관적 락을 잘 걸어 둔 재고 테이블인데, 매일 밤 재고를 일괄 보정하는 배치가 도입된 뒤부터 낮 시간대 재고 차감이 가끔 사라지거나 배치 값으로 덮이는 사고가 생깁니다. 사용자 트랜잭션 코드는 예전 그대로입니다.

원인: 배치가 성능을 위해 엔티티 대신 JPQL·네이티브 bulk UPDATE로 재고를 직접 수정했습니다. 이 경로는 version을 올리지도 검사하지도 않아, 배치와 사용자 트랜잭션이 서로의 변경을 낙관적 락에 잡히지 않은 채 덮어씁니다. 또 배치 실행 시점에 영속성 컨텍스트에 남아 있던 엔티티의 version이 DB와 어긋나, 이후 그 엔티티 저장이 잘못된 판단을 합니다.

진단: 배치가 실행하는 UPDATE 문에 version = version + 1WHERE ... version = ?이 있는지 확인합니다(없으면 우회 경로). 사고 시점과 배치 실행 시각의 상관을 보고, DB의 version 값이 갱신 횟수만큼 증가하지 않았다면 확정입니다.

해결: bulk UPDATE 문에 version = version + 1을 명시적으로 포함하고, 실행 직후 EntityManager.clear() 또는 관련 엔티티 refresh로 컨텍스트의 낡은 version을 버립니다. 동시성이 중요한 재고 같은 컬럼은 대량 보정도 엔티티 경로로 처리하거나, 배치 구간에만 비관적 락(SELECT FOR UPDATE)으로 직렬화합니다. 낙관적 락은 모든 쓰기 경로가 version을 존중할 때만 성립한다는 점이 핵심입니다.


💼
실무 맥락
현업 패턴

이커머스 결제 재고 차감 — 왜 낙관적 락을 선택했나

실제 이커머스 서비스 결제 시스템 설계 당시의 결정 과정입니다.

상황: 결제 완료 후 재고 차감 로직에서 동시 요청이 들어올 때 재고가 마이너스가 되는 문제 발생.

처음 시도: SELECT FOR UPDATE

SQL
BEGIN;
SELECT stock FROM inventory WHERE product_id = ? FOR UPDATE;
UPDATE inventory SET stock = stock - 1 WHERE product_id = ?;
COMMIT;

피크 타임에 초당 500건 결제가 들어오면서 문제가 생겼습니다. 결제 평균 응답시간이 200ms → 1.2초로 늘어났고, 데드락이 간헐적으로 발생했습니다. 잠금 대기가 연쇄적으로 쌓이면서 커넥션 풀이 고갈됐습니다.

전환 결정: 낙관적 락

분석해보니 상품 종류가 10만 가지인데 같은 상품을 동시에 구매하는 경우는 전체의 2% 미만이었습니다. 충돌률이 낮은 상황에서 SELECT FOR UPDATE는 과도한 잠금이었습니다.

낙관적 락으로 전환 후:

  • 결제 응답시간: 1.2초 → 220ms (잠금 대기 제거)
  • 재시도 발생률: 평균 1.8% (충돌률과 거의 동일)
  • 데드락: 0건

주의한 것들:

  1. 한정 수량 이벤트는 예외 — 특가 상품처럼 동시 경쟁이 극심한 케이스는 별도로 큐 기반 처리
  2. 재고 0 감지 정확성 — 낙관적 락에서도 CHECK (stock >= 0) 제약을 DB에 유지해 최후 방어선 확보
  3. 재시도 메트릭 모니터링 — 재시도율이 5%를 넘으면 해당 상품은 비관적 락으로 전환하는 자동화 로직 추가

교훈: 낙관적 락과 비관적 락은 "더 좋은 것"이 없습니다. 충돌률과 재시도 허용 가능성을 데이터로 측정한 뒤 결정해야 합니다. 처음부터 두 방식을 모두 지원하는 추상화 레이어를 만들어두면 나중에 유연하게 전환할 수 있습니다.


명령어·구문 빠른 참조

이 모듈에서 다룬 낙관적·비관적 락 구문을 실전 조합과 함께 모았습니다.

구문/명령용도
SELECT ... FOR UPDATE비관적 락(읽는 순간 행 잠금)SELECT * FROM inventory WHERE product_id = 42 FOR UPDATE;
UPDATE ... WHERE version = ?낙관적 락 충돌 감지 조건UPDATE inventory SET stock = stock - 1, version = version + 1 WHERE product_id = 42 AND version = 7;
affected rows(rowcount) 검사0이면 충돌 → 재시도 신호if result == 0: raise OptimisticLockError
version INTEGER DEFAULT 0낙관적 락용 버전 컬럼(정수 권장)CHECK (stock >= 0)과 함께 최후 방어선
WHERE ... AND enrolled < capacityversion + 업무 조건 이중 안전장치정원 초과·충돌을 한 UPDATE로 차단
BEGIN / COMMIT / ROLLBACK트랜잭션 경계(재시도 전 롤백 필수)충돌 시 session.rollback() 후 재조회
@Version (JPA)엔티티 저장 시 version 자동 검사UPDATE에 WHERE version=?·version+1 자동 부착
version_id_col (SQLAlchemy)커밋 시 version 검사(StaleDataError)__mapper_args__ = {"version_id_col": version}
$executeRaw (Prisma)낙관적 락 직접 구현(공식 미지원)WHERE id = ${id} AND version = ${version}
지수 백오프 + 지터재시도 폭풍 방지delay = base * 2 ** attempt + random()
EntityManager.clear() / refreshbulk UPDATE 후 낡은 version 폐기우회 경로의 낙관적 락 무력화 방지

관련 모듈로 더 깊이:

다음 모듈에서는 EXPLAIN ANALYZE로 쿼리 실행 계획을 읽고 인덱스 최적화 기회를 찾는 방법을 다룹니다.

지식 확인

퀴즈 — 8문제

Q1

낙관적 락에서 UPDATE 쿼리에 'WHERE id = 1 AND version = 3'을 조건으로 추가하는 이유는?

Q2

이커머스 결제 페이지에서 재고가 1개인 상품을 두 사용자가 동시에 '구매하기'를 눌렀습니다. 낙관적 락을 사용할 때 올바른 처리 흐름은?

Q3

낙관적 락 대신 비관적 락(SELECT FOR UPDATE)을 선택해야 하는 상황은?

Q4

낙관적 락에서 UPDATE ... WHERE product_id=42 AND version=0 을 실행했더니 'affected rows = 0'이 반환됐다. 의미와 올바른 처리는?

Q5

낙관적 락 충돌 시 즉시 같은 방식으로 무한 재시도하면 생기는 문제와, 본문이 권장하는 대안은?

Q6

낙관적 락의 version 컬럼을 정수 증가(version+1) 대신 updated_at 타임스탬프로 구현할 때의 단점은?

Q7

[심화] JPA @Version 낙관적 락을 쓰는 테이블에, 관리자 배치가 JPQL bulk UPDATE(예: UPDATE Inventory SET stock = stock - 1 WHERE ...)로 재고를 직접 줄였다. 이때 낙관적 락 관점에서 무슨 일이 벌어지나?

Q8

[심화] @Version이 걸린 엔티티인데, 야간 배치가 돈 뒤부터 사용자 요청의 재고 차감이 간헐적으로 유실되거나 엉뚱한 값으로 덮인다. 가장 유력한 원인과 조치는?

0 / 8 답변

🧪 실습으로 확인하기

PostgreSQL 설치 및 기본 설정

초급

Linux 서버에 PostgreSQL을 설치하고, 데이터베이스와 사용자를 생성한 뒤 외부 접속이 가능하도록 설정한다.

50📋 5단계💻 직접 환경
실습 시작하기 →

이것도 배워보세요