블로그 목록

데이터베이스 동시성 제어: 비관적 락(Pessimistic Lock)과 낙관적 락(Optimistic Lock) 실무 비교

두 사람이 같은 계좌에서 동시에 돈을 인출하거나, 마지막 남은 재고 1개를 두 명이 동시에 주문하면 어떻게 될까요? 아무 대비가 없으면 둘 다 성공한 것처럼 처리되어 잔액이나 재고가 틀어질 수 있습니다. 이 글은 이런 문제를 막는 대표적인 두 방법인 비관적 락(Pessimistic Lock)과 낙관적 락(Optimistic Lock)을 처음 접하는 분도 선택할 수 있도록 정리했습니다.

1. 비유로 이해하기

  • 비관적 락은 회의실 예약과 비슷합니다. "누군가 쓸지도 모르니" 먼저 예약(잠금)을 걸고, 다른 사람은 예약이 끝날 때까지 기다립니다.
  • 낙관적 락은 공유 문서의 버전 확인과 비슷합니다. 누구나 자유롭게 편집하되, 저장하는 순간 "내가 열었던 버전 그대로인가"를 확인하고 달라졌으면 저장을 거절합니다.

2. 문제 시나리오: 사라지는 업데이트

  1. 잔액이 10,000원인 계좌에 요청 A(3,000원 출금)와 요청 B(5,000원 출금)가 동시에 도착합니다.
  2. A와 B 모두 잔액을 읽어 10,000원을 얻습니다.
  3. A는 7,000원을, B는 5,000원을 저장합니다.
  4. 최종 잔액은 2,000원이어야 하지만, 나중에 저장한 B의 값인 5,000원이 남습니다.

이처럼 "읽고, 애플리케이션에서 계산하고, 쓰는" 사이에 다른 요청이 끼어들면 한쪽의 변경이 사라집니다. 이를 갱신 손실(Lost Update)이라고 합니다.

3. 먼저 확인할 것: 원자적 UPDATE 한 줄

단순한 차감이라면 락을 따로 쓰지 않고 계산을 DB에 맡기는 것이 가장 간단합니다.

UPDATE account
   SET balance = balance - 3000
 WHERE id = 1 AND balance >= 3000;

UPDATE는 대상 행에 쓰기 락을 걸고 최신 값을 기준으로 계산하므로, 동시에 실행되어도 차감이 누락되지 않습니다. 영향받은 행 수가 0이면 잔액 부족으로 처리하면 됩니다. 여러 행을 읽고 복잡한 판단을 해야 할 때 아래의 락이 필요해집니다.

4. 비관적 락: SELECT ... FOR UPDATE

트랜잭션 안에서 SELECT ... FOR UPDATE로 행을 읽으면, 그 트랜잭션이 끝날 때까지 다른 트랜잭션은 같은 행을 수정하거나 같은 방식으로 잠그려 할 때 기다립니다. 오해가 많은 부분인데, PostgreSQL과 MySQL InnoDB 모두 일반 SELECT(잠금 없는 읽기)는 막지 않습니다. PostgreSQL 문서는 행 수준 락이 데이터 조회에는 영향을 주지 않고 같은 행을 쓰거나 잠그려는 쪽만 막는다고 설명합니다.

// Go + GORM
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
	var acc Account
	if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
		First(&acc, id).Error; err != nil {
		return err
	}
	if acc.Balance < amount {
		return ErrInsufficientBalance
	}
	return tx.Model(&acc).Update("balance", acc.Balance-amount).Error
})

Spring Data JPA에서는 조회 메서드에 @Lock(LockModeType.PESSIMISTIC_WRITE)를 지정하고 @Transactional 안에서 호출하면, PostgreSQL이나 MySQL에서는 일반적으로 FOR UPDATE가 붙은 SQL이 실행됩니다.

// Kotlin + Spring Data JPA
interface AccountRepository : JpaRepository<Account, Long> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select a from Account a where a.id = :id")
    fun findForUpdate(@Param("id") id: Long): Account?
}

락을 무한정 기다리지 않게 하기

비관적 락의 가장 큰 위험은 대기가 길어지는 것입니다. 다음 옵션으로 대기 시간을 통제할 수 있습니다.

  • FOR UPDATE NOWAIT: 이미 잠겨 있으면 기다리지 않고 즉시 오류를 냅니다. PostgreSQL과 MySQL 8.0 이상에서 지원합니다.
  • FOR UPDATE SKIP LOCKED: 잠긴 행은 건너뛰고 나머지만 가져옵니다. 작업 큐에서 여러 워커가 서로 다른 작업을 가져갈 때 유용하지만, 일관된 결과가 필요한 조회에는 맞지 않습니다.
  • 대기 시간 설정: PostgreSQL은 lock_timeout, MySQL InnoDB는 innodb_lock_wait_timeout(기본 50초)으로 최대 대기 시간을 정할 수 있습니다.

5. 낙관적 락: version 컬럼

테이블에 version 컬럼을 두고, 수정할 때 "읽었을 때의 버전과 같을 때만" 업데이트합니다. 영향받은 행이 0개라면 그사이 누군가 먼저 수정한 것이므로 다시 읽고 재시도하거나 사용자에게 알립니다.

// NestJS + Prisma: updateMany는 영향받은 행 수(count)를 돌려줍니다
const acc = await this.prisma.account.findUniqueOrThrow({ where: { id } });
const { count } = await this.prisma.account.updateMany({
  where: { id, version: acc.version },
  data: { balance: acc.balance - amount, version: { increment: 1 } },
});
if (count === 0) {
  throw new ConflictException(); // 다른 요청이 먼저 수정함
}
// Kotlin + Spring Data JPA
@Entity
class Account(
    @Id val id: Long = 0,
    var balance: Long = 0,
    @Version var version: Long = 0,
)

JPA에서는 @Version만 붙이면 수정 시 버전 조건이 자동으로 붙습니다. 충돌하면 Spring 환경에서는 보통 ObjectOptimisticLockingFailureException이 발생하는데, 이 예외는 SQL이 실제로 실행되는 flush나 커밋 시점에 나오므로 재시도 로직은 트랜잭션 바깥에 두어야 합니다.

재시도할 때는 반드시 데이터를 다시 읽은 뒤 계산을 새로 해야 합니다. 예전 값으로 계산한 결과를 그대로 다시 저장하면 버전만 맞춰 준 셈이 되어 갱신 손실이 그대로 남습니다. 보통 2~3회 정도로 횟수를 제한하고, 계속 실패하면 사용자에게 "잠시 후 다시 시도해 주세요" 같은 안내를 보여 줍니다.

6. 한눈에 비교

항목비관적 락낙관적 락
충돌 처리 시점읽을 때 미리 잠금쓸 때 버전 확인
충돌 시 동작다른 요청이 대기실패 후 재시도 또는 알림
잘 맞는 상황충돌이 잦고 재시도 비용이 큰 경우충돌이 드문 경우
주의할 점대기 증가, 데드락 가능성충돌이 잦으면 재시도 폭증

7. 언제 쓰고, 언제 안 써도 되나

  • 단일 값의 증감이라면 원자적 UPDATE를 먼저 검토합니다. 락이 필요 없는 경우가 많습니다.
  • 인기 상품 재고처럼 같은 행에 요청이 몰리면 비관적 락이 결과를 예측하기 쉽습니다.
  • 게시글이나 프로필 수정처럼 동시에 같은 행을 고칠 일이 드물면 낙관적 락이 가볍습니다.
  • 여러 서버가 DB 밖의 자원(외부 API 호출, 파일 작업 등)을 두고 경쟁한다면 행 락으로는 부족하며, DB 기반 분산 락이나 Redis 분산 락을 검토합니다.

8. 자주 하는 실수

  • 트랜잭션 없이 FOR UPDATE 실행: 자동 커밋 모드에서는 문장이 끝나자마자 락이 풀려 아무 효과가 없습니다.
  • 락을 잡은 채 외부 API 호출: 결제 대행사 응답을 기다리는 동안 행이 잠겨 다른 요청이 줄줄이 대기합니다. 트랜잭션은 짧게 유지합니다.
  • 잠그는 순서가 제각각: 트랜잭션 A는 1번 → 2번, B는 2번 → 1번 순서로 잠그면 데드락이 생길 수 있습니다. id 오름차순처럼 순서를 통일합니다.
  • 화면 표시용 조회까지 FOR UPDATE 사용: 수정하지 않을 데이터를 잠그면 쓰기 요청만 불필요하게 기다리게 됩니다. 잠금은 수정할 행에만 겁니다.
  • 모든 예외를 재시도: 낙관적 락 재시도는 버전 충돌일 때만 하고, 횟수 제한과 짧은 대기를 둡니다.

9. 정리

  • "읽고-계산하고-쓰기" 사이의 끼어들기가 갱신 손실을 만듭니다.
  • 가능하면 원자적 UPDATE로 해결하고, 복잡한 판단이 필요할 때 락을 씁니다.
  • 충돌이 잦으면 비관적 락, 드물면 낙관적 락이 기본 선택입니다.
  • FOR UPDATE는 일반 SELECT를 막지 않으며, 반드시 트랜잭션 안에서 사용합니다.

참고 자료