데이터베이스 동시성 — 비관적 락과 낙관적 락
여러 사용자나 프로세스가 동시에 같은 데이터를 수정하려고 할 때 데이터가 어긋나는 동시성 문제가 발생한다. 이를 막기 위한 대표적인 방어 전략이 비관적 락(Pessimistic Lock)과 낙관적 락(Optimistic Lock)이다.
문제 — 레이스 컨디션과 로스트 업데이트
통제되지 않은 레이스 컨디션(경쟁 상태)이라는 환경 때문에, 로스트 업데이트(갱신 손실)라는 데이터 유실 사고가 발생한다. 즉 레이스 컨디션이 원인이고, 로스트 업데이트는 그 결과 중 하나이다.
① 레이스 컨디션 (Race Condition, 경쟁 상태)
두 개 이상의 스레드나 프로세스가 공유 자원(메모리, 데이터베이스 등)에 동시에 접근하여 값을 변경하려고 할 때 발생하는 문제다. 운영체제의 스케줄링에 따라 A 스레드와 B 스레드의 실행 순서가 매번 뒤섞이기 때문에, 타이밍에 따라 최종 결과값이 매번 달라질 수 있는 상태를 뜻한다.
1234로, 다른 한 명은 5678로 바꾸는 버튼을 거의 동시에 눌렀을 때, 최종 비밀번호가 무엇이 될지는 시스템의 미세한 타이밍(경쟁)에 따라 결정된다.② 로스트 업데이트 (Lost Update, 갱신 손실)
레이스 컨디션으로 인해 발생하는 결과 중 하나이다. 두 트랜잭션(또는 스레드)이 거의 동시에 같은 데이터를 읽고 수정한 뒤 저장할 때, 나중에 저장된 값이 먼저 저장된 값을 덮어쓰면서 먼저 수행된 업데이트가 날아가는(Lost) 현상이다.
잔액이 100인 계좌에 두 트랜잭션이 동시에 진입했다고 가정하면 다음과 같이 흘러간다.
| 시점 | 트랜잭션 A (+50) | 트랜잭션 B (+30) | DB 잔액 |
|---|---|---|---|
| t1 | 100 읽음 | 100 | |
| t2 | 100 읽음 | 100 | |
| t3 | 100+50=150 저장 | 150 | |
| t4 | 100+30=130 저장 | 130 |
정상이라면 잔액은 180이어야 한다. 그런데 B가 A의 결과를 보지 못한 채 옛날 값(100)을 기준으로 계산해 저장하면서, A의 +50이 통째로 사라졌다. 이것이 로스트 업데이트다.
해결 ① 비관적 락 (Pessimistic Lock)
"충돌이 무조건 발생할 것이다"라고 비관적으로 가정하고 접근하는 방식이다.
- 동작 원리: 데이터를 읽는 시점부터 데이터베이스 엔진 자체의 기능(Row-level Lock 등)을 사용해 아예 잠가버린다. 다른 트랜잭션은 락이 풀릴 때까지 대기해야 한다.
- 장점: 데이터의 무결성을 확실하게 보장한다. 충돌이 잦은 환경에서는 롤백과 재시도를 반복하는 낙관적 락보다 오히려 효율적일 수 있다.
- 단점: 다른 요청이 대기해야 해서 시스템 전체의 동시 처리량이 크게 저하될 수 있고, 서로의 락이 풀리기를 기다리는 데드락(Deadlock, 교착 상태)에 빠질 위험이 있다.
- 사용처: 은행 계좌 이체, 선착순 재고 차감 등 데이터 정합성이 성능보다 압도적으로 중요한 곳.
데이터를 읽을 때 수정 권한까지 함께 획득한다.
-- 조회 시점에 행에 락을 건다 → 다른 트랜잭션은 대기
SELECT * FROM account WHERE id = 1 FOR UPDATE;
JPA에서는 @Lock 어노테이션으로 이 쿼리를 만들술 있다.
public interface AccountRepository extends JpaRepository<Account, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE) // SELECT ... FOR UPDATE
@Query("SELECT a FROM Account a WHERE a.id = :id")
Optional<Account> findByIdForUpdate(@Param("id") Long id);
}
해결 ② 낙관적 락 (Optimistic Lock)
"충돌이 거의 발생하지 않을 것이다"라고 낙관적으로 가정하고 접근하는 방식이다.
- 동작 원리: DB의 실제 락 기능을 쓰지 않는다. 대신 테이블에
version이나updated_at같은 컬럼을 두어 애플리케이션 단에서 충돌을 감지한다. 데이터를 수정할 때 내가 처음 읽은 버전과 현재 DB의 버전이 일치하는지 확인한다. - 장점: DB 락을 잡지 않아 대기 시간이 없어 성능이 우수하고, 데드락이 발생하지 않는다.
- 단점: 충돌이 발생했을 때 예외를 잡아 데이터를 다시 읽고 수정을 시도하는 재시도(Retry) 로직을 개발자가 직접 구현해야 한다.
- 사용처: 게시글 수정, 사용자 프로필 업데이트 등 같은 데이터를 동시에 수정할 확률이 낮고 읽기 요청이 많은 곳.
수정 시 버전 조건을 함께 걸어, 그사이 값이 바뀌었으면 업데이트가 0건이 되도록 만든다.
-- 내가 읽은 version(1)이 그대로일 때만 수정된다
UPDATE account
SET balance = 5000, version = 2
WHERE id = 1 AND version = 1;
-- 영향받은 행이 0건이면 → 그사이 누군가 먼저 수정함 (충돌)
JPA에서는 @Version 필드 하나만 두면 이 동작이 자동으로 적용된다.
@Entity
public class Account {
@Id @GeneratedValue
private Long id;
private int balance;
@Version // 충돌 감지용 버전 컬럼
private Long version;
}
// 충돌 시 OptimisticLockException → 재시도
public void transferWithRetry(Long id, int amount) {
int maxRetry = 3;
for (int i = 0; i < maxRetry; i++) {
try {
accountService.transfer(id, amount);
return;
} catch (OptimisticLockException e) {
// 버전 충돌 → 다시 읽고 재시도
}
}
throw new RuntimeException("처리 실패");
}
한눈에 비교
| 항목 | 비관적 락 | 낙관적 락 |
|---|---|---|
| 가정 | 충돌이 잦다 | 충돌이 드물다 |
| 구현 | DB 락 (FOR UPDATE) | version 컬럼 비교 |
| 대기/성능 | 대기 발생, 처리량 저하 | 대기 없음, 빠름 |
| 데드락 | 가능 | 없음 |
| 재시도 로직 | 불필요 | 필요 |
| 적합한 상황 | 계좌 이체, 선착순 차감 | 게시글·프로필 수정 |
'DATABASE' 카테고리의 다른 글
| 트랜잭션 격리 수준 4단계 (0) | 2026.06.01 |
|---|---|
| [ DATABASE ] - Oracle에서 사용자 생성 및 테이블스페이스 생성 (0) | 2025.01.07 |
| 데이터베이스 JOIN 종류 (0) | 2023.11.15 |
| [DATABASE] - 기본키·후보키·고유키·외래키와 인덱스 (0) | 2023.10.23 |
| [ mongodb ] - mongodb 비밀번호 까먹었을 때 (0) | 2022.12.04 |