1. 문제 발견
이번 주차 과제에서는 여러 사용자가 동시에 같은 자원을 갱신하는 상황에서 발생하는 Race Condition 문제를 직접 경험하였습니다.
로컬 환경에서는 쉽게 드러나지 않지만, 동시 요청이 몰리는 운영 환경이나 부하 테스트 상황에서는 생각보다 자주 발생할 수 있습니다.
대표적인 예시는 다음과 같습니다.
- 동시에 두 명의 사용자가 같은 상품을 주문 → 재고가 음수가 되는 현상 발생
- 여러 사용자가 동일 포인트를 사용 → 포인트가 부족한데도 결제가 완료되는 문제
2. Race Condition이란?
Race Condition은 여러 스레드(또는 트랜잭션)가 동시에 같은 데이터에 접근하여 갱신할 때, 실행 순서나 타이밍에 따라 결과가 달라지는 상황을 말합니다.
예시 시나리오
- 사용자 A, B가 동시에 재고 10인 상품을 주문 (각각 1개)
- 두 트랜잭션이 거의 같은 시점에 재고를 읽음 → 둘 다 10으로 인식
- 각자 재고 차감 후 9로 업데이트
- 최종 재고는 9여야 하지만, 동시에 처리되면 중간 업데이트가 덮어씌워져 재고가 꼬임 (Lost Update)
3. RDB에서의 해결 전략
RDB에서는 대표적으로 다음 두 가지 방법이 있습니다.
| 전략 | 설명 | 장점 | 단점 |
| 낙관적 락 | @Version 필드를 두고, 트랜잭션 종료 시점에 버전 비교로 충돌 감지 | 락 비용이 없음, 성능 우수 | 충돌 시 재시도 필요, 필드 침투 |
| 비관적 락 | 조회 시점부터 DB가 해당 행을 잠금 (SELECT ... FOR UPDATE) | 강력한 정합성 보장, 충돌 시 대기 | 데드락 가능성, 성능 저하 |
4. 제가 선택한 방법 — 비관적 락
이번 과제에서는 비관적 락(Pessimistic Lock) 을 사용하였습니다.
그 이유는 비즈니스의 중요한 정보를 담는 도메인 모델에 version이라는 필드를 넣는 방식이 마음에 들지 않았기 때문입니다.
- 중요한 도메인 모델: 재고, 포인트, 쿠폰 사용 이력 등
- 이러한 모델들은 비즈니스 핵심 규칙을 담고 있으므로, 불필요한 필드 침투를 피하고 싶었습니다.
- 낙관적 락의 경우 @Version 필드가 필수인데, 이는 도메인 설계에 영향을 줍니다.
- 반면, 비관적 락은 조회 쿼리 레벨에서만 잠금을 걸어 도메인 변경 없이 정합성을 확보할 수 있습니다.
5. 비관적 락 적용 예시
public interface InventoryRepository extends JpaRepository<Inventory, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints({@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")}) // 타임아웃 3초
@Query("SELECT i FROM Inventory i WHERE i.productId = :productId")
Inventory findByProductIdForUpdate(@Param("productId") Long productId);
}
@Transactional
public void decreaseStock(Long productId, int quantity) {
Inventory inventory = inventoryRepository.findByProductIdForUpdate(productId);
if (inventory.getStock() < quantity) {
throw new CoreException(ErrorType.OUT_OF_STOCK);
}
inventory.decrease(quantity);
}
- PESSIMISTIC_WRITE → 해당 행을 쓰기 락으로 점유하여 다른 트랜잭션 접근 차단
- @QueryHint의 jakarta.persistence.lock.timeout 옵션으로 락 대기 시간을 설정 가능
6. 동시성 테스트로 검증
@Test
void concurrencyTest_stockShouldNotBeNegative() throws InterruptedException {
int threadCount = 10;
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
executor.submit(() -> {
try {
orderService.placeOrder(1L, 1L, 1); // userId, productId, quantity
} finally {
latch.countDown();
}
});
}
latch.await();
Inventory inventory = inventoryRepository.findByProductId(1L);
assertThat(inventory.getStock()).isGreaterThanOrEqualTo(0);
}
7. 회고
- 비관적 락을 사용하면 도메인 설계에 영향을 주지 않고도 강력한 정합성 보장이 가능합니다.
- 하지만 성능 부담과 데드락 가능성을 고려해야 하므로, 락 범위와 지속 시간을 최소화하는 것이 중요합니다.
- 타임아웃(@QueryHint)을 적극적으로 활용하면, 특정 요청이 무한정 대기하지 않도록 안전장치를 둘 수 있습니다.
- 실제 운영 환경에서는 락 경합 시 재시도 로직, 타임아웃 발생 시 예외 처리 방안까지 함께 설계하는 것이 좋습니다.
📌 정리
- Race Condition은 운영 환경에서 충분히 발생 가능한 문제입니다.
- RDB에서는 낙관적/비관적 락을 통해 해결할 수 있습니다.
- 이번 케이스에서는 도메인 필드 침투를 피하기 위해 비관적 락을 선택하였고, 타임아웃 설정을 통해 안정성을 확보하였습니다.
- 반드시 테스트를 통해 검증한 뒤 적용하는 것이 좋습니다.
'WIL' 카테고리의 다른 글
| MSA와 EDA 이해하기: 분산 아키텍처 기본 개념 정리 (0) | 2025.10.26 |
|---|---|
| DDD 강의 회고: 도메인 주도 설계의 사실과 오해 (4) | 2025.10.12 |
| 10주간의 백엔드 부트캠프 회고: 설계부터 운영까지, 실전적 고민의 기록 (0) | 2025.09.17 |
| 캐시 전략(Cache Strategies) 정리 (3) | 2025.08.17 |
| WIL – TDD & 테스트 가능한 구조 (0) | 2025.07.16 |