들어가며
1편의 동시성 테스트를 통해 재고 정합성이 실제로 깨지는 현상을 확인했습니다. 재고는 10개뿐이었지만 99개의 주문이 성공했고, 단순히 `@Transactional`을 적용하는 것만으로는 이 문제를 막을 수 없었습니다. 문제는 명확했습니다. 여러 트랜잭션이 동시에 같은 재고를 수정하지 못하도록 동시성을 제어해야 한다는 점이었습니다.
처음에는 "단순히 락을 걸면 되지 않을까?"라고 가볍게 생각했습니다. 하지만 막상 구현해 보니 락에도 다양한 종류가 존재했고, 방식마다 장단점이 명확했습니다. 이번 글에서는 여러 대안 중 비관적 락(Pessimistic Lock)을 선택한 이유와 실제 적용 과정, 그리고 적용 후 직면했던 데드락 해결 과정까지 정리해 보겠습니다.
1. 동시성 제어를 위한 대안 검토
동시성 문제를 해결하는 방법은 다양합니다. 대표적으로 다음과 같은 방식들을 떠올려볼 수 있습니다.
- synchronized
- Redis Lock (분산 락)
- Optimistic Lock (낙관적 락)
- Pessimistic Lock (비관적 락)
- Atomic UPDATE
가장 먼저 떠올린 것은 `synchronized` 키워드였습니다.
하지만 여러 애플리케이션 서버를 띄워 운영하는 다중 서버 환경에서는 단일 JVM 내부의 락만으로 동시성을 제어할 수 없습니다.
Redis Lock 역시 훌륭한 대안이지만, 현재 프로젝트 규모에서 단지 락 제어를 위해 새로운 인프라를 도입하고 관리 포인트를 늘리는 것은 과하다고 판단했습니다. 결국 RDB 수준에서 JPA를 통해 즉시 적용할 수 있는 락 전략을 먼저 검토하기로 했습니다.
2. 왜 비관적 락(Pessimistic Lock)을 선택했을까?
JPA에서 지원하는 대표적인 락 전략은 두 가지입니다.
- Optimistic Lock (낙관적 락): "충돌은 거의 발생하지 않을 것이다"라고 가정
- Pessimistic Lock (비관적 락): "충돌이 반드시 발생할 것이다"라고 가정하고 먼저 막음

본 프로젝트는 인기 상품이나 한정판 타임세일처럼 많은 사용자가 동시에 동일한 상품을 주문하는 상황을 전제로 합니다. 즉, 충돌은 예외적인 상황이 아니라 자주 발생하는 일반적인 상황입니다. 낙관적 락을 사용할 경우 빈번한 충돌로 인해 실패와 재시도(Retry) 로직이 대량으로 발생하여 오히려 성능이 저하될 위험이 컸습니다. 따라서 데이터 접근 단계에서부터 확실하게 동시 처리를 차단하는 비관적 락이 더 적합하다고 판단했습니다.
3. 락의 대상과 적용 위치 선정
다음 고민은 "어디에 락을 걸어야 하는가?"였습니다.
처음에는 주문(Order) 엔티티에 락을 거는 방식도 생각했습니다. 하지만 실제로 동시성 경합이 발생하는 주체는 주문이 아닌 재고(Inventory)입니다. 데이터베이스 관점에서도 동시 수정이 일어나는 Row는 Inventory이므로, 재고를 조회하는 시점에 락을 획득해야 했습니다. 기존 Repository는 단순히 조회만 수행하고 있었습니다.
List<Inventory> inventories = inventoryRepository.findAllByProductIdInForUpdate(productIds);
이를 다음과 같이 `@Lock` 어노테이션을 추가하여 변경했습니다.

이 락이 유지되는 동안 다른 트랜잭션은 해당 재고 데이터를 수정하거나 동일한 쓰기 락을 획득할 수 없습니다.
4. 서비스 로직 변경 및 비관적 락의 동작 원리
서비스 로직의 변화
서비스 레이어의 전체 구조는 유지한 채, 조회 메서드만 변경했습니다.
- [ 기존 ] `inventoryRepository.findAllByProductIdIn(...)`
- [ 변경 ] `inventoryRepository.findAllByProductIdInForUpdate(...)`
- 재고 조회 (Pessimistic Lock 획득)
- 재고 검증
- 재고 차감
- 주문 생성
실제 DB와 Hibernate 수준에서의 동작
비관적 락을 선언하면 Hibernate는 단순 SELECT문 대신 `FOR UPDATE` 구문이 포함된 SQL을 실행합니다.
SELECT *
FROM inventory
WHERE product_id = ? FOR UPDATE;
이 FOR UPDATE 구문에 의해 DB 수준에서 해당 Row에 쓰기 락이 설정됩니다.
이에 따라 동시 요청 시 다음과 같이 순차적인 흐름이 만들어집니다.
sequenceDiagram
participant A as Thread A
participant DB as Inventory(DB)
participant B as Thread B
A->>DB: SELECT ... FOR UPDATE
Note right of A: Lock 획득
A->>DB: 재고 조회
DB-->>A: 재고 = 10
B->>DB: SELECT ... FOR UPDATE
Note right of B: Lock 대기 (Thread A Commit까지)
A->>DB: 재고 1개 차감
DB-->>A: 재고 = 9
A->>DB: COMMIT
Note over DB: Lock 해제
DB-->>B: Lock 획득
B->>DB: 재고 조회
DB-->>B: 재고 = 9 (감소된 값)
B->>DB: 재고 1개 차감
DB-->>B: 재고 = 8
B->>DB: COMMIT
Note over A,B: Lost Update 발생하지 않음
Note over DB: 최종 재고 = 8
Thread B는 Thread A의 트랜잭션이 완료될 때까지 대기한 후, 이미 차감된 최신 재고 데이터를 조회하게 됩니다.
결과적으로 두 트랜잭션이 동시에 동일 데이터를 수정하며 발생하는 Lost Update(갱신 손실) 문제가 근본적으로 차단됩니다.
5. 테스트 재실행 및 결과 검증
비관적 락을 적용한 뒤, 1편에서 작성했던 동일한 동시성 테스트를 다시 실행했습니다.
테스트 조건
- 초기 재고: 10개
- 동시 요청: 200명의 사용자 (스레드 200개)
- 주문 수량: 1개씩
테스트 결과

| 항목 | 결과 |
| 초기 재고 | 10개 |
| 주문 성공 | 10건 |
| 주문 실패 (재고 부족) | 190건 |
| 최종 남은 재고 | 0개 |
성공 건수(10) + 남은 재고(0) = 초기 재고(10)
락 어노테이션 하나를 적절한 위치에 추가함으로써 재고 정합성이 완벽하게 보장되는 것을 검증할 수 있었습니다.
6. 비관적 락 적용 시 고려해야 할 점 (비용과 타임아웃)
문제가 완전히 해결된 것처럼 보였지만, 비관적 락에는 '대기(Waiting)'에 따른 비용이 발생합니다. 락을 보유한 트랜잭션의 처리 시간이 길어질수록 뒤따르는 모든 요청은 그만큼 대기해야 합니다. 대기하는 요청이 많아지면 DB 커넥션 풀을 계속 점유하게 되어, 시스템 전체의 응답 속도가 느려지거나 커넥션 고갈 문제로 이어질 수 있습니다.
Lock Timeout 설정의 필요성
무한정 대기하는 상황을 방지하기 위해 JPA와 DB는 락 대기 시간(Timeout) 설정을 지원합니다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints({@QueryHint(name = "javax.persistence.lock.timeout", value = "3000")}) // 3초 대기
Optional<Inventory> findWithPessimisticLock(Long productId);
지정한 대기 시간을 초과하면 `LockTimeoutException`이 발생하며 트랜잭션이 종료됩니다.
따라서 비관적 락을 설계할 때는 다음과 같은 2가지 원칙을 지켜야 합니다.
- 최소한의 점유 시간
- 락을 잡은 상태에서 외부 API 호출, 복잡한 연산, 불필요한 조회를 수행하지 않고 빠른 시간 내에 트랜잭션을 끝내야 합니다. - 예외 처리
- 락 획득 실패(`LockTimeoutException`) 시 사용자에게 적절한 안내나 재시도 방안을 제공해야 합니다.
7. 예상치 못한 두 번째 문제: 데드락(Deadlock)
단일 상품 주문 환경에서는 비관적 락으로 모든 문제가 해결되었습니다.
하지만 "사용자가 한 번에 여러 상품을 장바구니에 담아 주문하는 기능"을 추가하면서 새로운 문제가 발생했습니다.
데드락 발생 시나리오
두 사용자가 서로 다른 순서로 여러 상품을 동시에 주문할 때 문제가 생깁니다.
- 사용자 A: 상품 A → 상품 B 순서로 주문
- 사용자 B: 상품 B → 상품 A 순서로 주문
sequenceDiagram
participant A as Thread A (사용자 A)
participant DB as Database
participant B as Thread B (사용자 B)
Note over A,B: 사용자 A : 상품 A → 상품 B 순서 주문
Note over A,B: 사용자 B : 상품 B → 상품 A 순서 주문
A->>DB: 상품 A Lock 획득
DB-->>A: Lock Granted
B->>DB: 상품 B Lock 획득
DB-->>B: Lock Granted
A->>DB: 상품 B Lock 요청
Note right of A: 상품 B는 Thread B가 점유 중\n→ 대기(Waiting)
B->>DB: 상품 A Lock 요청
Note left of B: 상품 A는 Thread A가 점유 중\n→ 대기(Waiting)
Note over A,B: 서로 상대방의 Lock을 기다림
Note over DB: Deadlock 발생
DB-->>B: Deadlock 감지
DB-->>B: Victim Transaction Rollback
DB-->>A: Lock 획득 가능
A->>DB: Transaction Commit
Thread A는 Thread B가 가진 상품 B의 락을 기다리고, Thread B는 Thread A가 가진 상품 A의 락을 기다리는 교착 상태(Deadlock)에 빠지게 됩니다. DB(e.g., MySQL)는 데드락을 감지하면 한쪽 트랜잭션을 강제로 롤백(Victim Transaction)시켜 시스템 멈춤을 방지하지만, 이로 인해 사용자는 알 수 없는 주문 실패 오류를 경험하게 됩니다.
해결책: 락 획득 순서의 정렬 (Ordering)
데드락을 방지하는 가장 확실한 방법은 모든 트랜잭션이 항상 동일한 순서로 락을 획득하게 만드는 것입니다.
주문 요청으로 들어온 상품 ID 목록을 조회 전에 미리 오름차순 정렬하도록 코드를 수정했습니다.

// 요청된 상품 ID 리스트를 정렬하여 락 획득 순서를 고정
List<Long> sortedProductIds = productIds.stream()
.sorted()
.toList();
for (Long productId : sortedProductIds) {
inventoryRepository.findWithPessimisticLock(productId);
}
요청 순서가 [15, 3, 7]로 들어오더라도 실제 락은 반드시 [3 -> 7 -> 15] 순서로 획득합니다.
이 작은 정렬 로직 하나로 교착 상태 발생 조건 자체를 원천 차단할 수 있었습니다.
8. 비관적 락이 항상 정답일까? (트레이드오프 분석)
비관적 락을 통해 정합성 문제와 데드락까지 해결했지만, 기술 선택에 절대적인 정답은 없습니다.
다른 대안들과 비교했을 때 비관적 락의 입지를 재확인해 보았습니다.
1) Optimistic Lock과의 비교
- 낙관적 락: DB 락을 잡지 않으므로 동시 처리량이 높음. 하지만 충돌 발생 시 애플리케이션에서 재시도(Retry)를 처리해야 함.
- 선택 기준: 충돌이 드문 시스템이라면 낙관적 락이 효율적이지만, 동시 주문이 몰리는 타임세일 환경에서는 충돌 폭발로 인한 재시도 비용이 비관적 락의 대기 비용보다 훨씬 커집니다.
2) Atomic UPDATE와의 비교
재고 차감을 SQL 수준에서 직접 처리하는 방식입니다.
UPDATE inventory
SET quantity = quantity - :quantity
WHERE product_id = :productId AND quantity >= :quantity;
이 방식은 별도의 락을 명시하지 않아도 DB가 원자적으로 단일 Row를 수정하므로 성능이 매우 뛰어납니다.
하지만 본 프로젝트의 주문 흐름은 단순히 숫자만 바꾸는 작업이 아니었습니다. 재고 조회 -> 판매 가능 상태/기간 검증 -> 재고 차감 -> 주문 생성 -> 쿠폰/포인트 차감 등 복잡한 비즈니스 로직들이 하나의 트랜잭션 안에서 묶여있었습니다. 단순 Atomic UPDATE 방식만으로는 전체 비즈니스 흐름의 일관성을 보호하기 어려웠기에 트랜잭션 범위 전체를 안전하게 제어하는 비관적 락을 최종 선택했습니다.
2편을 마치면서
1편에서는 동시성 문제의 원인을 재현했고, 2편에서는 비관적 락을 도입하여 정합성을 보장하고 데드락 문제까지 해결해 나간 과정을 다루었습니다. 단순히 @Lock 어노테이션 하나를 붙이는 것에 그치지 않고, 락의 점유 시간, 타임아웃, 락 획득 순서, 그리고 아키텍처적 트레이드오프까지 깊이 있게 고민해 볼 수 있었던 계기였습니다.
하지만 동시성은 주문 기능 하나만으로 끝나지 않습니다.
- 관리자가 동시 접근하여 재고를 수정할 때는 어떻게 처리할 것인가?
- 주문 취소 및 결제 실패 시 재고 복구 로직에서의 동시성은 안전한가?
- 모든 곳에 비관적 락을 쓰는 것이 과연 최선인가?
다음 3편에서는 프로젝트 전체 관점에서 동시성 제어 전략을 회고하고, 비관적 락과 Atomic UPDATE를 혼용하는 설계 개선 방안 및 확장 가능성에 대해 정리해 보겠습니다.
'💭Retrospective' 카테고리의 다른 글
| 누구나 이해하는 클라우드 인프라 지식 사전: 백엔드는 개발해봤는데, 서비스는 어떻게 안정적으로 운영하는 사고력 키우기 (0) | 2026.07.26 |
|---|---|
| 동시성 문제(Concurrency Issue): 3편. 주문만 안전하면 끝일까? 시스템 전체를 관통하는 동시성 설계와 한계 회고 (0) | 2026.07.24 |
| 동시성 문제(Concurrency Issue): 1편. 문제 도출! 주문 재고 동시성 문제는 어떻게 발견했는가? (0) | 2026.07.24 |
| 상품 상세 조회 API 성능 개선기: DB 부하 줄이고 데이터 정합성 지키기 (Redis, k6, Cache) (0) | 2026.07.23 |
| 클린 아키텍처 with 파이썬: 높은 자유도를 보장하는 파이썬에서 클린 아키텍처는 어떻게 구현할까? (0) | 2026.05.24 |