데이터베이스 동시성 제어: 비관적 락(Pessimistic Lock)과 낙관적 락(Optimistic Lock) 실무 비교
두 사람이 같은 계좌에서 동시에 돈을 인출하거나, 마지막 남은 재고 1개를 두 명이 동시에 주문하면 어떻게 될까요? 아무 대비가 없으면 둘 다 성공한 것처럼 처리되어 잔액이나 재고가 틀어질 수 있습니다. 이 글은 이런 문제를 막는 대표적인 두 방법인 비관적 락(Pessimistic Lock)과 낙관적 락(Optimistic Lock)을 처음 접하는 분도 선택할 수 있도록 정리했습니다.
1. 비유로 이해하기
- 비관적 락은 회의실 예약과 비슷합니다. "누군가 쓸지도 모르니" 먼저 예약(잠금)을 걸고, 다른 사람은 예약이 끝날 때까지 기다립니다.
- 낙관적 락은 공유 문서의 버전 확인과 비슷합니다. 누구나 자유롭게 편집하되, 저장하는 순간 "내가 열었던 버전 그대로인가"를 확인하고 달라졌으면 저장을 거절합니다.
2. 문제 시나리오: 사라지는 업데이트
- 잔액이 10,000원인 계좌에 요청 A(3,000원 출금)와 요청 B(5,000원 출금)가 동시에 도착합니다.
- A와 B 모두 잔액을 읽어 10,000원을 얻습니다.
- A는 7,000원을, B는 5,000원을 저장합니다.
- 최종 잔액은 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를 막지 않으며, 반드시 트랜잭션 안에서 사용합니다.