비관적 락은 “이 데이터는 충돌할 가능성이 높다”고 보고, 한 트랜잭션이 수정하려는 데이터를 잡고 있는 동안 다른 트랜잭션이 기다리게 만드는 방식
Transaction A
SELECT ... FOR UPDATE
→ Product 행 Lock 획득
Transaction B
같은 Product 조회 시도
→ 기다림
A
재고 차감
COMMIT
→ Lock 해제
B
이제 조회
→ A가 반영한 최신 재고 확인
→ 차감
1. 왜 이름이 "비관적" 락일까?
처음부터 "여러 사용자가 같은 데이터를 동시에 수정하면 충돌할 가능성이 높다"고 생각하기 때문.
그래서 충돌이 실제로 일어난 뒤 처리하는 것이 아니라, "아예 먼저 잠가놓고 다른 트랜잭션이 기다리게 하자"는 접근
2. 문제 상황
DB stock = 100
Transaction A
SELECT → 100
Transaction B
SELECT → 100
A
100 → 99
B
100 → 99
A UPDATE 99
B UPDATE 99
최종 = 99
둘 다 같은 100을 봤기 때문에 문제가 발생했다.
그렇다면 가장 단순한 해결법은, "A가 재고를 보고 수정하는 동안 B가 같은 재고를 읽어서 수정하지 못하게 하면 되지 않을까?"이다.
3. 비관적 락을 걸면 어떻게 달라질까?
// 초기 재고
stock = 100
A가 먼저 상품을 조회하면서 Lock을 잡는다.
Transaction A
SELECT product
FOR UPDATE
→ stock = 100
→ Product 행 Lock 획득
이제 B도 같은 상품을 수정하려고 조회한다.
Transaction B
SELECT product
FOR UPDATE
그런데 A가 아직 Lock을 가지고 있다.
그래서 B는 "지금은 못 읽고 수정하겠네. A가 끝날 때까지 기다리자" 상태가 된다.
4. 시간 흐름으로 보면
시간
│
▼
Transaction A Transaction B
SELECT ... FOR UPDATE
stock = 100
Lock 획득
│
│ SELECT ... FOR UPDATE
│ 같은 행 접근
│ → 대기
│
decreaseStock()
100 → 99
│
UPDATE
│
COMMIT
│
Lock 해제
│
대기 종료
↓
SELECT 결과
stock = 99
decreaseStock()
99 → 98
UPDATE
COMMIT
최종 결과는 `98`으로, 정상이다.
핵심은 B가 100을 보지 않고, A가 Commit한 뒤의 99를 보게 된다는 점이다.
5. `SELECT ... FOR UPDATE`
DB 수준에서는 대표적으로 `SELECT ~ FOR UPDATE` SQL 형태를 사용할 수 있다.
SELECT *
FROM products
WHERE id = ?
FOR UPDATE;
일반적인 SELECT문과는 차이가 있는데, 일반 SELECT는 "데이터 좀 읽을게."에 가깝다면
SELECT ... FOR UPDATE는 "이 데이터를 읽을 건데, 곧 수정할 거니까 다른 트랜잭션이 같은 방식으로 수정하려 하면 기다리게 해줘." 라는 의미이다.
6. JPA에서는 `PESSIMISTIC_WRITE`
JPA에서는 직접 SQL 끝에 `FOR UPDATE`를 붙이지 않고 락 모드를 지정할 수 있다.
// 가장 대표적인 방법 : LockModeType.PESSIMISTIC_WRITE
// Spring Data JPA에서 Repository 메서드에 붙임
@Lock(LockModeType.PESSIMISTIC_WRITE)
findByIdWithLock()
↓
JPA
"이 조회는 PESSIMISTIC_WRITE로 해."
↓
DB
SELECT ... FOR UPDATE
7. 왜 기존 `findById()`를 그냥 수정하지 않을까?
현재 `ProductRepository`
public interface ProductRepository
extends JpaRepository<Product, Long> {
}
기본 `findById()`가 있는데, 여기에 무조건 비관적 락을 걸어버리는 것보다는 `findByIdWithPessimisticLock(...)`처럼 락이 필요한 조회를 별도로 만드는 편이 학습하기 좋다. 이 경우 두 가지를 비교할 수 있다.
기존 조회
→ 락 없음
→ 동시성 테스트 실패
락 조회
→ PESSIMISTIC_WRITE
→ 동시성 테스트 성공
8. `ProductRepository.java` 수정
추가
- `@Lock`
- `LockModeType.PESSIMISTIC_WRITE`
- 락을 사용하는 조회 메서드
수정
기존 `JpaRepository` 상속은 그대로 둔다.
왜 변경하나?
동시 재고 차감 시 같은 Product 행에 대한 수정 충돌을 직렬화하기 위해서
`ProductRepository.java`
package com.bblackbean.bootminimals.jpa.stock;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Optional;
public interface ProductRepository extends JpaRepository<Product, Long> {
// [추가]
// Product를 조회하면서 DB의 비관적 쓰기 락을 획득한다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select p from Product p where p.id = :id")
Optional<Product> findByIdWithPessimisticLock(@Param("id") Long id);
}
9. `@Lock`
@Lock(LockModeType.PESSIMISTIC_WRITE)
Spring Data JPA에게 "이 Repository 조회는 일반 조회가 아니라 비관적 쓰기 락을 사용해"라고 알려준다.
`PESSIMISTIC_WRITE`라는 이름에서 `WRITE`가 붙은 이유는, "이 Entity를 조회한 뒤 수정하려고 한다"는 목적의 락이기 때문이다.
10. 왜 `@Query`까지 작성했나?
@Query("select p from Product p where p.id = :id")
id가 같은 Product 하나를 조회한다는 의미이고, `@Lock`을 적용할 Repository 조회 메서드를 명확하게 하나 정의하기 위해 작성했다.
11. `@Param("id")`
@Param("id") Long id
`:id`와 Java 메서드의 `id` 값을 연결한다.
12. `StockService.java` 수정
변경 사항
// 기존
productRepository.findById(productId)
↓
// 수정
productRepository.findByIdWithPessimisticLock(productId)
변경 이유
재고를 변경하기 전에 Product 행에 비관적 락을 획득하도록 하기 위해서
`StockService.java`
package com.bblackbean.bootminimals.jpa.stock;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class StockService {
private final ProductRepository productRepository;
public StockService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@Transactional
public void decrease(Long productId) {
// [변경]
// 일반 조회가 아니라 비관적 락을 사용하는 조회
Product product = productRepository
.findByIdWithPessimisticLock(productId)
.orElseThrow();
product.decreaseStock();
}
}
`decreaseStock()`은 Product의 비즈니스 규칙이 잘못된 게 아니었기 때문에 변경하지 않았다.
문제 상황은 `여러 트랜잭션이 같은 Product`를 동시에 가져온 것이기 때문에, 동시성 제어는 Repository 조회 방식에서 해결한 것이다.
13. `@Transactional`
여기서 비관적 락을 사용하려면 트랜잭션이 매우 중요하다.
@Transactional 시작
↓
SELECT ... FOR UPDATE
↓
Lock 획득
↓
product.decreaseStock()
↓
Dirty Checking
↓
UPDATE
↓
COMMIT
↓
Lock 해제
Lock은 보통 트랜잭션이 끝날 때까지 유지된다.
만약 조회하자마자 트랜잭션이 끝나서 Lock이 풀려버리면,
`Lock 획득 → 바로 해제 → 그 뒤 수정`이 되어 의미가 없어질 수 있다.
따라서 비관적 락은 `조회 + 수정 + commit`이라는 트랜잭션 경계와 함께 이해해야 한다.
15. 동시성 테스트 실행
이제 `StockConcurrencyTest`를 실행하면, 정상적으로 테스트를 통과한다.
이제 32개의 Worker가 있어도, 아래와 같이 Worker들이 기다리기 시작한다.
Worker A → Product 1 Lock 획득
Worker B → Product 1 Lock 요청 → 기다림
Worker C → Product 1 Lock 요청 → 기다림
Worker D → Product 1 Lock 요청 → 기다림
...
A 완료
stock
100 → 99
COMMIT
Lock 해제
그 다음 누군가 Lock을 얻는다.
99 조회
→ 98
→ Commit
그리고 다음,
98 → 97
그래서 이렇게 같은 상품에 대한 재고 변경이 사실상 순서대로 처리된다.
16. 그러면 비관적 락은 완벽한 해결책인가?
그건 아니다. 여기서부터 선택을 해야한다.
비관적 락의 장점은 매우 명확하다.
같은 데이터를 동시에 수정하려는 요청을 DB에서 직접 기다리게 한다.
↓
충돌 상황을 강하게 제어
하지만 비용도 있다.
비용 1 - Lock 대기
재고 하나가 인기 상품이라고 해보자.
사용자 1
사용자 2
사용자 3
...
사용자 10,000
모두 같은 Product에 접근한다.
그러면 한 명 처리하고 나머지는 대기 상태가 될 수 있다.
즉 안정성은 높지만 처리량과 응답시간에 영향을 줄 수 있다.
비용 2 - 트랜잭션이 길면 더 심각함
Lock을 잡은 상황을 예를 들어보면,
Product Lock 획득
↓
외부 API 호출
5초
↓
메일 발송
↓
다른 DB 작업
↓
COMMIT
5초 동안 다른 요청들이 계속 기다릴 수 있다.
그래서 "트랜잭션을 불필요하게 오래 유지하지 않는 것"이 중요해진다.
비용 3 - Deadlock 가능성
`Product A`와 `Product B` 두 데이터가 있다.
- Transaction 1: Product A Lock 획득 → Product B Lock 필요
- Transaction 2: Product B Lock 획득 → Product A Lock 필요
Transaction 1
A 🔒
↓
B 기다림
↑
│
Transaction 2
B 🔒
↓
A 기다림
둘 다 서로 상대방이 가진 Lock을 기다린다.
이게 Deadlock인데, DB는 보통 이런 상황을 감지해서 트랜잭션 하나를 실패시키는 등의 처리를 한다.
17. 비관적 락은 언제 어울릴까?
비관적 락은 상대적으로 이런 상황에서 검토할 수 있다.
충돌 가능성이 높다.
+
충돌 후 실패하거나 Retry하는 비용이 크다.
+
조금 기다리는 것이 실패보다 낫다.
같은 재고에 동시 수정 요청이 많이 들어온다라면 선택지가 될 수 있다.
18. 반대로 충돌이 거의 없다면?
100만 개의 상품이 있고, 각 사용자가 서로 다른 상품을 수정한다면 충돌이 거의 없다.
A → Product 1
B → Product 95042
C → Product 700001
그런데 모든 조회에 무조건 강한 Lock을 거는 것은 불필요한 비용일 수 있다.
그래서 "비관적 락이 안전하니까 언제나 사용한다"라고 생각하면 안 된다.
19. 비관적 락에서 중요한 질문
락을 배우면 흔히 `PESSIMISTIC_WRITE` 어떻게 쓰는지에 집중하기 쉽다.
하지만 그 전에 질문해봐야 한다.
① 실제로 같은 데이터에 동시 수정이 발생하는가?
② 충돌 빈도는 높은가?
③ 기다리는 비용은 괜찮은가?
④ 트랜잭션은 얼마나 오래 유지되는가?
⑤ 정말 Lock이 필요한 문제인가?
20. `synchronized`와는 무엇이 다른가?
Java `synchronized`
하나의 JVM 안에서 Thread의 접근 제어
DB 비관적 락
DB의 같은 데이터에 접근하는 여러 트랜잭션의 접근 제어
서버가 여러 대라면, Server A JVM과 Server B JVM의 `synchronized`는 서로 공유되지 않는다.
하지만 두 서버가 같은 DB를 쓴다면 DB Lock은 같은 DB 행을 기준으로 제어할 수 있다.
21. Spring/JPA가 해주는 것과 우리가 결정하는 것
우리가 결정
- 이 조회에서는 충돌을 막아야 한다 → PESSIMISTIC_WRITE 선택
Spring Data JPA / JPA
- @Lock 해석
- 적절한 LockMode로 조회 수행
DB
- 실제 Row Lock 관리
- 다른 트랜잭션 대기
- Commit / Rollback 때 Lock 해제
즉 최종적으로 실제 동시 접근을 제어하는 주체는 DB이다.
연습
1) 초기 재고 `stock = 2`이고, 두 트랜잭션 A, B가 비관적 락을 사용해서 동시에 재고를 차감한다고 하자.
A가 Lock 획득 후 조회한 값: 2
B가 같은 Product 조회를 시도하면: 대기
A가 차감한 값:1
A Commit 후:1
그 뒤 B가 조회한 값:1
B가 차감한 값:0
최종 DB stock:0
[비관적 락]
A → 2 조회 + Lock
B → 대기
A → 1 → Commit
B → 1 조회
B → 0 → Commit
최종 = 0 ✅
2) 다음 문장이 맞는지 판단한다.
A : 비관적 락을 사용하면 여러 트랜잭션이 같은 행을 수정할 때 다른 트랜잭션이 기다릴 수 있다.
- 맞음
B : `PESSIMISTIC_WRITE`를 사용하면 `@Transactional`은 필요 없다.
- 필요함. PESSIMISTIC_WRITE로 획득한 락은 조회 후 변경 작업이 끝날 때까지 유지되어야 하므로, 조회 → 수정 → Commit을 하나의 트랜잭션 경계 안에서 처리해야 한다.
C : 비관적 락은 충돌이 많을수록 항상 성능도 좋아진다.
- 충돌이 많을수록 비용이 상승하기 때문에 성능이 안좋아진다.
D : 비관적 락은 데이터 정합성을 보호하는 데 도움이 되지만 Lock 대기와 Deadlock 등의 비용을 고려해야 한다.
- 맞음
3) 두 상황 중에서 어느 상황에 비관적 락을 검토할 이유가 더 클까?
[상황 A]
한정 수량 100개
이벤트 시작 직후
수천 명이 같은 재고를 동시에 수정
[상황 B]
회원 프로필 수정
사용자마다 자기 프로필만 주로 수정
동시에 같은 프로필을 수정하는 경우는 거의 없음
A. B는 충돌이 거의 없는 상황이기 때문에 Lock을 걸면 불필요한 자원을 소모할 수 있다.
정리
- 비관적 락은 충돌 가능성이 높다고 보고 데이터를 수정하기 전에 Lock을 획득해 다른 트랜잭션의 접근을 기다리게 한다.
- JPA의 `PESSIMISTIC_WRITE`는 DB의 `SELECT ... FOR UPDATE`와 같은 비관적 락 동작으로 연결될 수 있으며 트랜잭션이 종료될 때까지 Lock이 유지된다.
- Lost Update를 막을 수 있지만 Lock 대기·처리량 감소·Deadlock 가능성이라는 비용이 있으므로 항상 사용하는 정답은 아니다.
'Spring' 카테고리의 다른 글
| ExecutorService / CountDownLatch / 동시성 테스트 (0) | 2026.08.29 |
|---|---|
| 동시성 / Race Condition / Lost Update (0) | 2026.08.27 |
| JPA 영속성 컨텍스트 / Dirty Checking / Flush (0) | 2026.08.26 |
| 트랜잭션 / ACID / Commit / Rollback / @Transactional (0) | 2026.08.25 |
| [설계] Layered Architecture / Application Service / Repository Interface / DIP (0) | 2026.08.25 |