여러 사용자가 같은 데이터를 동시에 수정하려 할 때, 충돌을 막는 가장 좋은 방법은 무엇일까요?

두 가지 동시성 제어 전략

동시에 같은 데이터를 수정하려는 상황을 처리하는 방법은 크게 두 가지입니다.

  • 비관적 락 (Pessimistic Lock): "충돌이 일어날 것이다"라고 가정하고, 데이터에 접근할 때 미리 락을 겁니다
  • ** 낙관적 락 (Optimistic Lock)**: "충돌이 드물 것이다"라고 가정하고, 수정할 때 충돌을 감지합니다

어떤 것이 더 좋다고 단정할 수 없습니다. 상황에 따라 적합한 전략이 다릅니다.

비관적 락 — SELECT FOR UPDATE

기본 동작

SQL
BEGIN;
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
-- id = 1에 배타적 락(X Lock) 획득
-- 다른 트랜잭션은 이 행을 수정하거나 FOR UPDATE로 읽을 수 없음

UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;
-- 커밋 시 락 해제

FOR UPDATE는 선택한 행에 배타적 락(X Lock) 을 겁니다. 다른 트랜잭션이 같은 행에 대해 FOR UPDATE, FOR SHARE, UPDATE, DELETE를 실행하면 대기합니다.

SELECT ... FOR SHARE

SQL
BEGIN;
SELECT * FROM accounts WHERE id = 1 FOR SHARE;
-- 공유 락(S Lock) 획득
-- 다른 트랜잭션의 FOR SHARE는 허용, FOR UPDATE/쓰기는 차단

FOR SHARE공유 락(S Lock) 을 겁니다. 여러 트랜잭션이 동시에 공유 락을 획득할 수 있지만, 쓰기는 차단됩니다.

구분FOR UPDATEFOR SHARE
락 종류X LockS Lock
다른 FOR SHARE차단허용
다른 FOR UPDATE차단차단
일반 SELECT허용(MVCC)허용(MVCC)

NOWAIT과 SKIP LOCKED (MySQL 8.0+)

락을 기다리지 않고 즉시 결과를 얻을 수 있는 옵션입니다.

SQL
-- 락을 획득할 수 없으면 즉시 에러 반환
SELECT * FROM orders WHERE status = 'pending'
FOR UPDATE NOWAIT;

-- 이미 잠긴 행은 건너뛰고 나머지만 반환
SELECT * FROM orders WHERE status = 'pending'
FOR UPDATE SKIP LOCKED;

SKIP LOCKED는 큐 형태의 작업 분배에 유용합니다. 여러 워커가 동시에 미처리 건을 가져갈 때 충돌 없이 분배할 수 있습니다.

SQL
-- 워커 A
BEGIN;
SELECT * FROM tasks WHERE status = 'waiting'
ORDER BY created_at LIMIT 5
FOR UPDATE SKIP LOCKED;
-- 잠기지 않은 5건을 가져옴

-- 워커 B (동시에 실행)
SELECT * FROM tasks WHERE status = 'waiting'
ORDER BY created_at LIMIT 5
FOR UPDATE SKIP LOCKED;
-- A가 잠근 행은 건너뛰고, 다음 5건을 가져옴

비관적 락의 주의점

  1. **반드시 인덱스를 사용해야 합니다 **: 인덱스가 없으면 풀 테이블 스캔이 발생하고, 의도하지 않은 행까지 잠길 수 있습니다
  2. ** 트랜잭션을 짧게 유지해야 합니다 **: 락 보유 시간이 길수록 다른 트랜잭션의 대기 시간이 증가합니다
  3. ** 데드락 가능성 **: 여러 행을 순서 없이 잠그면 데드락이 발생할 수 있습니다

낙관적 락 — 버전 관리

기본 원리

낙관적 락은 데이터베이스 락을 사용하지 않습니다. 대신 version 컬럼 을 사용하여 수정 시점에 충돌을 감지합니다.

SQL
-- 1. 데이터 조회 (version도 함께 조회)
SELECT id, balance, version FROM accounts WHERE id = 1;
-- 결과: id=1, balance=1000, version=3

-- 2. 수정 시 version 조건 추가
UPDATE accounts
SET balance = 900, version = version + 1
WHERE id = 1 AND version = 3;

-- affected_rows = 1이면 성공
-- affected_rows = 0이면 다른 트랜잭션이 먼저 수정한 것 (충돌!)

테이블 설계

SQL
CREATE TABLE accounts (
    id BIGINT PRIMARY KEY,
    balance INT NOT NULL,
    version INT NOT NULL DEFAULT 0,    -- 버전 컬럼
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

version 대신 updated_at 타임스탬프를 사용할 수도 있지만, 밀리초 단위 충돌 가능성이 있으므로 정수형 version이 더 안전합니다.

충돌 감지와 재시도

JAVA
// 자바 의사 코드
public void transfer(Long fromId, Long toId, int amount) {
    int maxRetries = 3;
    for (int retry = 0; retry < maxRetries; retry++) {
        try {
            Account from = accountDao.findById(fromId);
            Account to = accountDao.findById(toId);

            from.setBalance(from.getBalance() - amount);
            to.setBalance(to.getBalance() + amount);

            // version 조건이 맞지 않으면 0행 업데이트 → 예외 발생
            int updated = accountDao.updateWithVersion(from);
            if (updated == 0) {
                throw new OptimisticLockException("충돌 발생");
            }
            accountDao.updateWithVersion(to);
            return; // 성공
        } catch (OptimisticLockException e) {
            if (retry == maxRetries - 1) throw e;
            // 잠시 대기 후 재시도
        }
    }
}

JPA에서의 낙관적 락 — @Version

JAVA
@Entity
public class Account {
    @Id
    private Long id;
    private int balance;

    @Version  // JPA가 자동으로 버전 관리
    private int version;
}

@Version을 붙이면 JPA가 자동으로 다음을 처리합니다.

  • UPDATE 시 WHERE version = ? 조건 추가
  • UPDATE 성공 시 version 자동 증가
  • 불일치 시 OptimisticLockException 발생
JAVA
// JPA가 생성하는 SQL
UPDATE accounts
SET balance = ?, version = 4
WHERE id = ? AND version = 3;

비관적 락 vs 낙관적 락 비교

구분비관적 락낙관적 락
락 방식DB 레벨 실제 락애플리케이션 레벨 버전 체크
충돌 처리대기 (블로킹)실패 후 재시도
동시성낮음 (락으로 직렬화)높음 (락 없음)
데드락 위험있음없음
적합한 상황충돌이 빈번한 경우충돌이 드문 경우
구현 복잡도SQL만으로 가능재시도 로직 필요

선택 기준

**비관적 락을 선택하는 경우 **:

  • 같은 데이터에 대한 동시 수정이 빈번합니다
  • 충돌 시 재시도 비용이 높습니다 (외부 시스템 연동 등)
  • 반드시 한 번에 성공해야 하는 작업입니다

** 낙관적 락을 선택하는 경우 **:

  • 읽기가 대부분이고 수정은 드문 경우입니다
  • 충돌 시 재시도가 가능한 작업입니다
  • 높은 동시성이 필요합니다

실무 패턴 — 재고 차감 예제

비관적 락 방식

SQL
BEGIN;
-- 재고 행을 잠금
SELECT stock FROM products WHERE id = 100 FOR UPDATE;
-- stock = 5

-- 재고 확인 후 차감
UPDATE products SET stock = stock - 1 WHERE id = 100;
COMMIT;

장점은 확실하게 동시 수정을 방지한다는 것이고, 단점은 인기 상품에 요청이 몰리면 대기가 길어진다는 것입니다.

낙관적 락 방식

SQL
-- 조회
SELECT stock, version FROM products WHERE id = 100;
-- stock = 5, version = 10

-- 차감 시도
UPDATE products
SET stock = stock - 1, version = version + 1
WHERE id = 100 AND version = 10;

-- affected_rows 확인
-- 0이면 다른 요청이 먼저 처리됨 → 재시도

장점은 동시성이 높다는 것이고, 단점은 인기 상품에 요청이 몰리면 재시도가 폭증한다는 것입니다.

실무 절충안

SQL
-- CAS(Compare-And-Swap) 패턴
UPDATE products
SET stock = stock - 1
WHERE id = 100 AND stock >= 1;
-- version 없이도 stock 자체를 조건으로 사용

이 방식은 version 컬럼 없이도 동작하며, 단순한 재고 차감에는 충분합니다.

JPA에서 비관적 락 사용

JAVA
// 비관적 쓰기 락
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT a FROM Account a WHERE a.id = :id")
Account findByIdForUpdate(@Param("id") Long id);

// 비관적 읽기 락
@Lock(LockModeType.PESSIMISTIC_READ)
@Query("SELECT a FROM Account a WHERE a.id = :id")
Account findByIdForShare(@Param("id") Long id);

// 타임아웃 설정
@QueryHints(@QueryHint(name = "javax.persistence.lock.timeout", value = "3000"))
@Lock(LockModeType.PESSIMISTIC_WRITE)
Account findByIdForUpdate(@Param("id") Long id);

주의할 점

비관적 락에서 인덱스가 없으면 전체 테이블이 잠길 수 있다

SELECT ... FOR UPDATE WHERE status = 'pending'에서 status에 인덱스가 없으면, InnoDB가 모든 행을 스캔하면서 락을 겁니다. 의도한 것보다 훨씬 넓은 범위가 잠기므로 반드시 인덱스를 확인해야 합니다.

낙관적 락에서 재시도 로직이 없으면 데이터가 유실된다

affected_rows = 0인 경우를 처리하지 않으면, 충돌이 발생해도 사용자에게 "성공"으로 응답하는 심각한 버그가 됩니다. 반드시 재시도 또는 에러 처리 로직이 필요합니다.

인기 상품 재고 차감에서 낙관적 락은 재시도 폭풍을 일으킨다

동시 요청이 많은 상품에 낙관적 락을 적용하면, 한 번에 하나만 성공하고 나머지는 모두 재시도합니다. 이런 경우에는 비관적 락이나 CAS 패턴(WHERE stock >= 1)이 더 적합합니다.

정리

항목비관적 락낙관적 락
방식DB 레벨 실제 락 (FOR UPDATE)애플리케이션 레벨 버전 체크
충돌 처리대기 (블로킹)실패 후 재시도
동시성낮음높음
데드락가능없음
적합한 상황충돌 빈번, 재시도 비용 높음충돌 드묾, 읽기 위주
MySQL 8.0NOWAIT, SKIP LOCKED 지원version 컬럼 + affected_rows 확인
댓글 로딩 중...