동시성 제어는 DB 설계의 핵심이다. 같은 데이터를 여러 트랜잭션이 동시에 수정하려 할 때 어떤 락 전략을 선택하느냐에 따라 성능과 정합성이 극단적으로 달라진다. 이 글에서는 비관적 락, 낙관적 락, 네임드 락을 MySQL/InnoDB + Java/JPA 기준으로 완전히 정리한다.


1. 비관적 락 (Pessimistic Lock)

개념

“충돌이 발생할 것이다” 라고 가정하고 데이터를 읽는 순간부터 락을 걸어 다른 트랜잭션의 접근을 차단한다.

T1: SELECT ... FOR UPDATE  ← 즉시 X Lock 획득
T2: SELECT ... FOR UPDATE  ← T1이 COMMIT/ROLLBACK 할 때까지 대기

공유 락(S Lock) vs 배타 락(X Lock)

┌───────────┬────────────┬────────────┐
│           │  S Lock    │  X Lock    │
├───────────┼────────────┼────────────┤
│  S Lock   │  호환 (O)  │  충돌 (X)  │
│  X Lock   │  충돌 (X)  │  충돌 (X)  │
└───────────┴────────────┴────────────┘

S Lock: SELECT ... LOCK IN SHARE MODE  (읽기 락, 다른 S Lock과 공존 가능)
X Lock: SELECT ... FOR UPDATE          (쓰기 락, 어떤 락과도 충돌)

InnoDB 락 종류

Record Lock

인덱스 레코드 하나에 거는 락.

-- id=5 인 레코드에만 X Lock
SELECT * FROM users WHERE id = 5 FOR UPDATE;
인덱스:  1  2  3  4  [5]  6  7  8
                      ↑
                   X Lock (Record Lock)

중요: InnoDB는 레코드가 아닌 인덱스에 락을 건다. 인덱스가 없으면 Full Table Scan → 테이블 전체 레코드에 락 발생.

Gap Lock

인덱스 레코드 사이의 간격(Gap) 에 거는 락. 새 레코드 삽입을 방지한다.

-- id가 5~10 사이 범위 조회
SELECT * FROM users WHERE id BETWEEN 5 AND 10 FOR UPDATE;
인덱스:  1  2  3  4  5  (6  7  8  9)  10  11
                        ↑──────────↑
                            Gap Lock
                      (5와 10 사이에 INSERT 불가)

Gap Lock은 Phantom Read 방지를 위해 존재한다. REPEATABLE READ 이상에서 동작.

Next-Key Lock

Record Lock + Gap Lock 의 조합. InnoDB의 기본 락 단위.

인덱스: ... 4  [5]  (5~10 gap)  [10] ...
              ↑──────────────────↑
                  Next-Key Lock
             (레코드 5 + 5~10 사이 간격)
-- 실제로 Next-Key Lock이 걸리는 범위:
-- (-∞, 5], (5, 10], (10, +∞)  중 쿼리 범위에 해당하는 구간
SELECT * FROM users WHERE id BETWEEN 5 AND 10 FOR UPDATE;

비관적 락 장점/단점

장점:

  • 충돌 시 재시도 로직 불필요
  • 데이터 정합성 강하게 보장
  • 충돌이 잦은 환경에서 낙관적 락보다 효율적 (재시도 비용 없음)

단점:

  • 락 대기로 인한 처리량 감소
  • 데드락 위험
  • 락 보유 시간이 길어지면 병목 발생
  • 분산 환경에서 적용 어려움

적합한 상황

  • 같은 데이터를 동시에 수정하는 빈도가 높은 경우
  • 금융 거래, 재고 차감 등 정합성이 절대적으로 중요한 경우
  • 충돌 시 재시도 비용이 크거나 재시도 자체가 불가능한 경우

Java/JPA 구현 예제

// Entity
@Entity
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private int stock;
}

// Repository
public interface ProductRepository extends JpaRepository<Product, Long> {

    // 비관적 쓰기 락 (X Lock: SELECT FOR UPDATE)
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT p FROM Product p WHERE p.id = :id")
    Optional<Product> findByIdWithPessimisticLock(@Param("id") Long id);

    // 비관적 읽기 락 (S Lock: LOCK IN SHARE MODE)
    @Lock(LockModeType.PESSIMISTIC_READ)
    @Query("SELECT p FROM Product p WHERE p.id = :id")
    Optional<Product> findByIdWithSharedLock(@Param("id") Long id);
}

// Service
@Service
@RequiredArgsConstructor
public class ProductService {

    private final ProductRepository productRepository;

    @Transactional
    public void decreaseStock(Long productId, int quantity) {
        // 1. 락 획득과 동시에 조회 (SELECT ... FOR UPDATE)
        Product product = productRepository.findByIdWithPessimisticLock(productId)
                .orElseThrow(() -> new IllegalArgumentException("상품 없음"));

        // 2. 재고 검증
        if (product.getStock() < quantity) {
            throw new IllegalStateException("재고 부족");
        }

        // 3. 재고 차감
        product.setStock(product.getStock() - quantity);

        // 4. 트랜잭션 종료 시 락 해제 + 변경사항 저장
    }
}

실행되는 SQL:

SELECT * FROM product WHERE id = ? FOR UPDATE;
UPDATE product SET stock = ? WHERE id = ?;

타임아웃 설정 (무한 대기 방지):

@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints({@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")})
@Query("SELECT p FROM Product p WHERE p.id = :id")
Optional<Product> findByIdWithPessimisticLock(@Param("id") Long id);


2. 낙관적 락 (Optimistic Lock)

개념

“충돌이 드물 것이다” 라고 가정하고 DB 락 없이 데이터를 읽은 뒤, 수정 시점에 다른 트랜잭션이 변경했는지 버전을 비교해 충돌을 감지한다.

CAS(Compare-And-Swap) 원리:

읽기: version=5, data='A' 읽어옴
수정: UPDATE ... SET data='B', version=6 WHERE id=? AND version=5
결과: 업데이트된 행이 0이면 충돌 감지 → 예외 발생

@Version 필드 동작 원리

초기: { id=1, name='Lee', version=0 }

T1: SELECT → { name='Lee', version=0 }
T2: SELECT → { name='Lee', version=0 }

T1: UPDATE SET name='Kim', version=1 WHERE id=1 AND version=0;
    → 성공 (affected rows = 1)
    DB: { id=1, name='Kim', version=1 }

T2: UPDATE SET name='Park', version=1 WHERE id=1 AND version=0;
    → 실패 (affected rows = 0, version이 이미 1로 바뀜)
    → OptimisticLockException 발생
시간 축:
  T1: [READ v=0]──────────────────[WRITE v=0→1: 성공]
  T2:   [READ v=0]────────────────────────[WRITE v=0→1: 실패!]
                                          충돌 감지 → 예외

낙관적 락 장점/단점

장점:

  • DB 락 없음 → 읽기 성능 극대화
  • 충돌이 드문 경우 처리량 최대화
  • 데드락 없음

단점:

  • 충돌 시 재시도 로직 직접 구현 필요
  • 충돌이 잦은 환경에서 재시도 폭풍으로 성능 역전
  • 재시도 중 다른 데이터와 불일치 가능성
  • 응답 시간 보장 어려움 (재시도 횟수 불확실)

적합한 상황

  • 읽기 비율이 매우 높고 쓰기 충돌이 드문 경우
  • 게시글 수정, 사용자 프로필 변경 등 개인화 데이터
  • 충돌 시 재시도가 허용되는 비즈니스 로직
  • 분산 환경 (DB 락에 의존하지 않으므로 확장성 좋음)

Java/JPA 구현 예제

// Entity
@Entity
public class Article {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;
    private String content;

    @Version  // 낙관적 락의 핵심 - JPA가 자동으로 버전 관리
    private Long version;
}

// Repository
public interface ArticleRepository extends JpaRepository<Article, Long> {

    @Lock(LockModeType.OPTIMISTIC)
    @Query("SELECT a FROM Article a WHERE a.id = :id")
    Optional<Article> findByIdWithOptimisticLock(@Param("id") Long id);
}

// Service
@Service
@RequiredArgsConstructor
public class ArticleService {

    private final ArticleRepository articleRepository;

    // 낙관적 락 + 재시도 로직
    public void updateArticle(Long articleId, String newContent) {
        int maxRetry = 3;
        for (int attempt = 0; attempt < maxRetry; attempt++) {
            try {
                updateArticleInternal(articleId, newContent);
                return;  // 성공
            } catch (OptimisticLockingFailureException e) {
                if (attempt == maxRetry - 1) {
                    throw new IllegalStateException("동시 수정 충돌: 잠시 후 다시 시도해주세요.", e);
                }
                // 재시도 전 짧은 대기 (선택적)
            }
        }
    }

    @Transactional
    private void updateArticleInternal(Long articleId, String newContent) {
        Article article = articleRepository.findByIdWithOptimisticLock(articleId)
                .orElseThrow(() -> new IllegalArgumentException("게시글 없음"));

        article.setContent(newContent);
        // 트랜잭션 커밋 시점에:
        // UPDATE article SET content=?, version=version+1 WHERE id=? AND version=?
        // affected rows = 0 이면 OptimisticLockingFailureException
    }
}

실행되는 SQL:

-- 조회
SELECT * FROM article WHERE id = ?;

-- 수정 (version 조건 포함)
UPDATE article SET content = ?, version = 2 WHERE id = ? AND version = 1;
-- affected rows = 0 이면 예외 발생

@Version 없이 수동 구현 (레거시 대응):

// 수동 버전 관리 쿼리
@Modifying
@Query("UPDATE Article a SET a.content = :content, a.version = :version + 1 " +
       "WHERE a.id = :id AND a.version = :version")
int updateWithVersion(@Param("id") Long id,
                      @Param("content") String content,
                      @Param("version") Long version);

// 서비스
int updated = articleRepository.updateWithVersion(id, newContent, article.getVersion());
if (updated == 0) {
    throw new OptimisticLockingFailureException("버전 충돌");
}


3. 네임드 락 (Named Lock / User-Level Lock)

개념

테이블이나 레코드가 아닌 임의의 문자열(이름) 을 기준으로 거는 락. MySQL에서 GET_LOCK() 함수로 사용한다.

-- 락 획득 (최대 10초 대기)
SELECT GET_LOCK('payment_user_100', 10);
-- 반환값: 1(성공), 0(타임아웃), NULL(오류)

-- 작업 수행
-- ...

-- 락 해제
SELECT RELEASE_LOCK('payment_user_100');
-- 반환값: 1(성공), 0(락 없음), NULL(락 보유자 아님)

특징

일반 레코드 락:          네임드 락:
  테이블 또는 레코드       임의의 문자열
  트랜잭션과 연동          트랜잭션과 독립
  InnoDB 엔진 관리         MySQL 서버 레벨 관리
  COMMIT/ROLLBACK 해제     명시적 RELEASE_LOCK 필요

네임드 락의 범위:

  • 동일 MySQL 서버 내 모든 세션 간에 공유
  • 세션 종료 시 자동 해제 (명시적 해제 누락 안전장치)
  • IS_FREE_LOCK('name'), IS_USED_LOCK('name') 으로 상태 확인

분산 환경에서의 활용

[App Server 1] ──┐
[App Server 2] ──┼──▶ [MySQL] → GET_LOCK('resource_key')
[App Server 3] ──┘

→ 여러 서버가 동일 MySQL에 접속하는 환경에서 분산 락 역할
→ Redis 분산 락과 유사하지만 MySQL 하나면 추가 인프라 불필요

네임드 락 장점/단점

장점:

  • 테이블/레코드에 무관하게 임의 리소스에 락 적용 가능
  • 여러 테이블에 걸친 복잡한 비즈니스 로직 동기화
  • 추가 인프라(Redis 등) 없이 분산 락 구현 가능
  • 타임아웃 내장 지원

단점:

  • 명시적으로 RELEASE_LOCK 호출 필요 (누락 시 타 세션 무한 대기)
  • MySQL 단일 서버에 종속 (진정한 분산 환경에선 Redis Redlock 권장)
  • 재진입(Reentrant) 불가 (동일 세션에서도 같은 이름 락 재획득 불가)
  • 많은 네임드 락 사용 시 MySQL 내부 메모리 압박

적합한 상황

  • 여러 테이블에 걸친 복잡한 원자적 작업 (단일 레코드 락으로 표현 불가)
  • 외부 시스템 호출(결제 API, 이메일 발송) 중복 방지
  • 배치 작업의 중복 실행 방지
  • Redis 없이 분산 락이 필요한 소규모 환경

Java/Spring 구현 예제

Repository:

public interface LockRepository extends JpaRepository<Object, Long> {

    @Query(value = "SELECT GET_LOCK(:name, :timeout)", nativeQuery = true)
    Integer getLock(@Param("name") String name, @Param("timeout") int timeout);

    @Query(value = "SELECT RELEASE_LOCK(:name)", nativeQuery = true)
    Integer releaseLock(@Param("name") String name);
}

Named Lock 템플릿 (try-finally 패턴):

@Component
@RequiredArgsConstructor
public class NamedLockTemplate {

    private final LockRepository lockRepository;

    public <T> T executeWithLock(String lockName, int timeout, Supplier<T> supplier) {
        try {
            Integer result = lockRepository.getLock(lockName, timeout);
            if (result == null || result != 1) {
                throw new IllegalStateException("락 획득 실패: " + lockName);
            }
            return supplier.get();
        } finally {
            lockRepository.releaseLock(lockName);  // 반드시 해제
        }
    }
}

// Service
@Service
@RequiredArgsConstructor
public class PaymentService {

    private final NamedLockTemplate namedLockTemplate;
    private final PaymentProcessor paymentProcessor;

    public void processPayment(Long userId, long amount) {
        String lockName = "payment_user_" + userId;  // 사용자별 고유 락 이름

        namedLockTemplate.executeWithLock(lockName, 10, () -> {
            // 이 블록은 동시에 하나의 스레드만 실행 보장
            paymentProcessor.process(userId, amount);
            return null;
        });
    }
}

별도 DataSource 사용 (커넥션 풀 고갈 방지):

// 네임드 락은 별도 커넥션을 사용해야 함
// (같은 커넥션에서 트랜잭션 + 네임드 락 혼용 시 커넥션 반납 타이밍 문제)

@Configuration
public class DataSourceConfig {

    @Bean
    @Primary
    public DataSource dataSource() { /* 메인 DataSource */ }

    @Bean
    @Qualifier("lockDataSource")
    public DataSource lockDataSource() {
        // 락 전용 DataSource (풀 크기 작게 설정)
        HikariConfig config = new HikariConfig();
        config.setMaximumPoolSize(5);
        return new HikariDataSource(config);
    }
}


4. 세 가지 락 비교

항목 비관적 락 낙관적 락 네임드 락
충돌 감지 시점 읽기 시 (선제적) 쓰기 시 (사후적) 읽기 시 (선제적)
DB 락 사용 O (레코드/갭 락) X (버전 비교) O (서버 레벨)
데드락 가능성 높음 없음 낮음
충돌 多 환경 성능 좋음 나쁨 (재시도 폭풍) 좋음
충돌 少 환경 성능 보통 최고 보통
재시도 로직 불필요 필요 불필요
분산 환경 어려움 가능 MySQL 종속
구현 난이도 쉬움 중간 중간
락 범위 테이블/레코드 없음 임의 이름
트랜잭션 연동 O O X (독립)


5. 극한 시나리오

5-1. 비관적 락 데드락 시나리오

시나리오: 계좌 이체 (A→B, B→A 동시 요청)

T1 (A→B):              T2 (B→A):
LOCK account_A ──┐     LOCK account_B ──┐
                 │                       │
       ┌─────────┘             ┌─────────┘
       ▼                       ▼
LOCK account_B   ←── 대기 ──  (T1이 보유)
(T2가 보유)      ←── 대기 ──  LOCK account_A

→ T1은 B를 기다리고, T2는 A를 기다림 = Deadlock!

해결 방법:

// 항상 작은 ID → 큰 ID 순으로 락 획득
@Transactional
public void transfer(Long fromId, Long toId, long amount) {
    Long firstId = Math.min(fromId, toId);
    Long secondId = Math.max(fromId, toId);

    Account first = accountRepository.findByIdWithPessimisticLock(firstId)
            .orElseThrow();
    Account second = accountRepository.findByIdWithPessimisticLock(secondId)
            .orElseThrow();

    // firstId < secondId 순서로 항상 동일 → 데드락 없음
    if (fromId < toId) {
        first.decrease(amount);
        second.increase(amount);
    } else {
        second.decrease(amount);
        first.increase(amount);
    }
}

또는 타임아웃으로 데드락 탈출:

@QueryHints({@QueryHint(
    name = "jakarta.persistence.lock.timeout",
    value = "5000"  // 5초 대기 후 LockTimeoutException
)})

5-2. 낙관적 락 무한 재시도 시나리오

상황: 인기 상품 재고 차감 (100명이 동시에 1개씩)

T1~T100 동시 SELECT version=0
T1 UPDATE version=0→1: 성공
T2~T100 UPDATE version=0→1: 99개 실패 → 재시도
  → T2 재시도: version=1→2 성공
  → T3~T100 재시도: 98개 실패 → 재시도
    → T3 재시도: version=2→3 성공
    ... (O(N²) 쿼리 발생!)

해결 방법 1 - 재시도 횟수 제한 + 지수 백오프:

public void decreaseStock(Long productId, int quantity) {
    int maxRetry = 5;
    long waitMs = 50;

    for (int i = 0; i < maxRetry; i++) {
        try {
            decreaseStockInternal(productId, quantity);
            return;
        } catch (OptimisticLockingFailureException e) {
            if (i == maxRetry - 1) throw new StockException("재시도 초과");
            try {
                Thread.sleep(waitMs * (1L << i));  // 50, 100, 200, 400ms
            } catch (InterruptedException ie) {
                Thread.currentThread().interrupt();
                throw new StockException("인터럽트 발생");
            }
        }
    }
}

해결 방법 2 - 충돌 多 상황에서는 비관적 락으로 전환:

충돌률 측정 → 충돌률 > 임계치(예: 20%) → 비관적 락으로 동적 전환
(또는 처음부터 재고 차감같이 충돌 多 케이스에는 비관적 락 사용)

5-3. 네임드 락 해제 누락 시나리오

T1: GET_LOCK('payment_100', 10)  → 성공
T1: 작업 중 예외 발생
T1: RELEASE_LOCK 호출 안 됨 (버그)

T2: GET_LOCK('payment_100', 10)  → 최대 10초 대기
T3: GET_LOCK('payment_100', 10)  → 최대 10초 대기
...
→ T1 세션 유지되는 동안 모든 요청 지연

해결 방법 - try-finally 필수:

// 잘못된 예
public void processWithLock(String lockName) {
    lockRepository.getLock(lockName, 10);
    doSomething();  // 예외 발생 시 RELEASE_LOCK 호출 안 됨!
    lockRepository.releaseLock(lockName);
}

// 올바른 예
public void processWithLock(String lockName) {
    try {
        lockRepository.getLock(lockName, 10);
        doSomething();
    } finally {
        lockRepository.releaseLock(lockName);  // 예외 발생해도 반드시 실행
    }
}

추가 안전장치 - 세션 타임아웃:

-- 세션이 종료되면 MySQL이 자동으로 네임드 락 해제
SET SESSION wait_timeout = 30;

5-4. 높은 동시성에서 각 락의 성능 차이

시나리오: 100 TPS, 동일 레코드 경쟁

비관적 락:
  처리량: 순차 처리 (락 대기)
  지연:  평균 대기 = (N-1) * 트랜잭션시간 / 2
  특징:  처리량 일정, 지연 증가
  ──────────────────────────────────
  T=0: [T1 실행]
  T=1: [T1 완료][T2 실행]
  T=2: [T2 완료][T3 실행]
  → 안정적이지만 처리량 제한

낙관적 락:
  처리량: 초기 높음, 충돌 증가 시 급락
  지연:  예측 불가 (재시도 N회 가능)
  특징:  충돌 적을 때 비관적 락보다 빠름
  ──────────────────────────────────
  T=0: [T1~T100 동시 실행]
  T=0: [T1 성공, T2~T100 실패]
  T=1: [T2~T100 재시도 → 대부분 또 실패]
  → 폭풍 재시도로 성능 역전 가능

선택 기준:
  충돌률 < 5%  → 낙관적 락
  충돌률 5~20% → 케이스 별 측정
  충돌률 > 20% → 비관적 락

5-5. 마스터-슬레이브 환경에서 락 동작

마스터-슬레이브 복제:
  [Master] ──복제──▶ [Slave1]
                ──▶ [Slave2]

비관적 락:
  SELECT FOR UPDATE → 반드시 Master에서 실행 (Slave에서는 락 없음)
  Slave에서 FOR UPDATE 실행 시: 복제 지연 동안 비일관성 가능

낙관적 락:
  SELECT → Slave에서 가능 (읽기 성능 분산)
  UPDATE → Master에서 실행
  주의: Slave 복제 지연 동안 오래된 version을 읽을 수 있음
        → 불필요한 재시도 증가 가능

네임드 락:
  GET_LOCK → 반드시 Master에서 (Slave는 락 상태 모름)
  읽기 분산 불가 (락 획득 후 작업은 모두 Master에서)

권장 패턴:

// 쓰기 작업 전용 트랜잭션에서 Master 강제 사용
@Transactional
@Primary  // Master DataSource 사용
public void updateWithLock(Long id) {
    // Master에서 SELECT FOR UPDATE
}

// 읽기 작업은 Slave 허용
@Transactional(readOnly = true)
public Product findProduct(Long id) {
    // Slave에서 읽기 가능
}


6. 락 선택 가이드

데이터 충돌 빈도?
  │
  ├─ 높음 ──▶ 비관적 락
  │              │
  │         재시도 비용 높음?
  │              ├─ Yes → 비관적 락 확정
  │              └─ No  → 낙관적 락도 고려
  │
  └─ 낮음 ──▶ 낙관적 락
                 │
            단일 레코드?
                 ├─ Yes → 낙관적 락 확정
                 └─ No  → 네임드 락 고려
                            │
                       여러 테이블/외부 리소스?
                            ├─ Yes → 네임드 락
                            └─ No  → 비관적 락 재고

실전 적용 예시:

시나리오 추천 락 이유
좌석 예매 (동시 경쟁 극심) 비관적 락 충돌 多, 중복 예매 절대 불가
게시글 수정 낙관적 락 개인 데이터, 충돌 드묾
인기 상품 재고 차감 비관적 락 충돌 多, 정합성 최우선
사용자 포인트 적립 낙관적 락 충돌 가능성 낮음
결제 중복 방지 (여러 테이블) 네임드 락 트랜잭션 경계 복잡
배치 작업 중복 실행 방지 네임드 락 프로세스 간 동기화


정리

비관적 락: "먼저 잠그고 작업한다"
  → SELECT FOR UPDATE → 작업 → COMMIT(락 해제)
  → 충돌 多, 정합성 최우선

낙관적 락: "작업 후 충돌을 확인한다"
  → SELECT → 작업 → UPDATE WHERE version=? → 충돌 시 재시도
  → 충돌 少, 읽기 많은 시스템

네임드 락: "이름으로 잠근다"
  → GET_LOCK('name') → 작업 → RELEASE_LOCK('name')
  → 여러 테이블/외부 리소스, 분산 동기화

락은 만능이 아니다. 락 없이 해결할 수 있다면 (예: 원자적 SQL UPDATE stock = stock - 1 WHERE stock > 0) 그것이 최선이다. 락은 꼭 필요한 곳에만 최소 범위로 최단 시간 동안 사용하는 것이 원칙이다.

카테고리:

업데이트: