infra
Platform

모듈 맵

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

0 / 37 완료

펼치기
0 / 37 완료0%

Database · 33 / 37

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

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

🚨INCIDENT ALERT
HIGH

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

이번 챕터에서 배울 것

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

  • 1비관적 락 vs 낙관적 락 — 처리량 vs 충돌 감지 트레이드오프
  • 2version 컬럼 스키마 설계 — 정수 버전 vs updated_at
  • 3ORM별 낙관적 락 구현 — JPA @Version, SQLAlchemy, Prisma
  • 4충돌 시 재시도 로직 — 지수 백오프, 최대 재시도 횟수
  • 5낙관적 락 vs 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
실행 완료 또는 조회 결과가 표시됩니다.
🔍실행 후 확인할 것
  • 버전 증가UPDATE 성공 시 version 컬럼이 함께 증가하는지 확인합니다.
  • 충돌 감지영향 받은 행 수가 0이면 동시 수정 충돌로 처리하는지 봅니다.
  • 재시도 정책사용자 재입력과 자동 재시도를 구분합니다.

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

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

낙관적 락은 읽을 때 잠금을 걸지 않습니다. 대신 수정할 때 "내가 읽은 이후 다른 사람이 먼저 바꿨는가?"를 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: 충돌 감지 → 재시도 또는 에러 처리

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

처리량 비교

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

실습 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;

💡개념

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 ✅
→ 또는: 큐 기반으로 직렬화 처리

결정 트리

충돌이 자주 발생하는가?
├── 예 → 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;

언제 발생하나

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. 재시도 횟수 초과 시 사용자에게 명확한 메시지 ("요청이 많아 처리 실패, 다시 시도해주세요")

💼
실무 맥락
현업 패턴

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

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

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

처음 시도: 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%를 넘으면 해당 상품은 비관적 락으로 전환하는 자동화 로직 추가

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

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

지식 확인

퀴즈 — 3문제

Q1

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

Q2

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

Q3

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

0 / 3 답변

🧪 실습으로 확인하기

PostgreSQL 설치 및 기본 설정

초급

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

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

이것도 배워보세요

database고급 · 60
[Database] Master-Slave 복제(Replication) 구축과 DB 고가용성(HA) 아키텍처
Database 트랙 계속
linux입문 · 30
[Linux] 개발자가 왜 리눅스 서버와 커맨드라인을 반드시 배워야 하는가
Linux 트랙 시작점