Spring Data JPA를 사용하여 동시성 이슈를 해결하려 할 때, 가장 강력하면서도 확실한 방법인 LockModeType.PESSIMISTIC_WRITE(비관적 쓰기 락)에 대해 정리해 봅니다.
1. PESSIMISTIC_WRITE란?
LockModeType.PESSIMISTIC_WRITE는 데이터베이스의 배타적 락(Exclusive Lock) 기능을 사용하여 동시성을 제어하는 방식입니다.
쉽게 비유하자면 다음과 같습니다.
"내가 이 데이터를 다 쓰고 나갈 때까지, 아무도 들어오지 마! (읽지도 말고 쓰지도 마!)"
트랜잭션이 시작되고 데이터를 조회하는 순간부터 락을 걸어버리기 때문에, 데이터의 무결성이 깨질 확률을 '0'에 수렴하게 만드는 아주 강력한 옵션입니다. (대신 해당 lock 이 풀리기 전까진 다른 유저가 접근을 못함.)

"왜 내 요청만 멈춰 있을까?"
위 다이어그램은 비관적 락(PESSIMISTIC_WRITE) 이 걸려 있는 상황에서 여러 트랜잭션이 동시에 들어왔을 때 벌어지는 일을 시각화한 것입니다. 하나씩 뜯어서 상황을 살펴봅시다.
Transaction A (현재 처리 중인 승리자)
- 상황: 가장 먼저 도착한 Transaction A가 데이터(Database Row)에 접근하여 열쇠(Exclusive Lock)를 획득했습니다.
- 상태: A가 작업을 모두 마치고 커밋(Commit)할 때까지 이 데이터는 오직 A만의 것입니다.
- 비유: 화장실이 딱 하나뿐인 카페에서 A가 문을 잠그고 들어간 상태입니다.
Transaction B, C, D ... (문 밖의 대기자들)
- 상황: Transaction A가 아직 일을 끝내지 않았는데, 뒤이어 B, C, D가 들어왔습니다.
- 동작: 이미 자물쇠(Lock)가 채워져 있기 때문에, DB는 이들의 접근을 차단(Blocked) 합니다.
- 결과: 에러가 나고 튕겨 나가는 것이 아니라, "자리가 날 때까지 무한 대기(Waiting for Lock)" 상태에 빠집니다.
"로딩(Loading...)"의 정체
- 사용자 화면에서 뱅글뱅글 도는 로딩바의 정체가 바로 저 Waiting for Lock 구간입니다.
- 문제는 단순히 기다리는 것에서 끝나지 않습니다. Transaction B, C, D는 대기하는 동안 데이터베이스 커넥션(DB Connection)을 하나씩 붙잡고 놓아주지 않습니다.
결론: 트래픽이 몰리면?
- 대기열이 길어질수록 뒤에 있는 Transaction Z는 앞사람들이 다 끝날 때까지 한참을 기다려야 합니다.
- 결국 DB 커넥션 풀(Connection Pool)이 바닥나면서, 락과 상관없는 다른 기능까지 덩달아 멈추거나 서버 전체가 다운되는 장애로 이어질 수 있습니다.
2. 동작 원리 (SELECT ... FOR UPDATE)
이 옵션을 적용한 Repository 메서드를 실행하면, JPA는 데이터베이스에 다음과 같은 쿼리를 날립니다.
SELECT * FROM product
WHERE id = 1
FOR UPDATE; -- 핵심: 이 구문이 붙으면서 배타적 락을 획득함
- 락 획득: 트랜잭션 A가 이 쿼리를 실행하면 해당 Row(행)에 대한 열쇠(Lock)를 획득합니다.
- 타 트랜잭션 차단: 트랜잭션 B, C가 같은 데이터를 수정하려고 접근하면, A가 커밋(Commit)하거나 롤백(Rollback)해서 열쇠를 반납할 때까지 무한정 대기(Waiting) 상태에 빠집니다.
3. 코드 적용 예시
public interface ProductRepository extends JpaRepository<Product, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE) // 비관적 락 적용
@Query("select p from Product p where p.id = :id")
Optional<Product> findByIdWithPessimisticLock(@Param("id") Long id);
}
4. 치명적인 단점: 대규모 트래픽에서의 병목 현상
데이터의 정합성(동시성 제어) 측면에서는 완벽하지만, 성능 측면에서는 양날의 검입니다.
사용자가 적을 때는 문제가 없지만, 접근 유저가 급증하는 상황(수강 신청, 선착순 이벤트 등)에서는 심각한 성능 저하가 발생합니다.
- 상황: 1개의 Row에 100명의 유저가 동시에 접근.
- 현상:
- 1번 유저가 락을 잡고 로직을 수행 (약 0.1초 소요 가정).
- 나머지 99명은 DB 앞에서 줄 서서 대기 (DB Connection 점유).
- 뒤쪽 순번의 유저들은 앞사람들이 끝날 때까지 기다리느라 화면이 멈춤 (Loading...).
- 결국 DB 커넥션 풀(Connection Pool)이 고갈되거나 타임아웃(Timeout) 에러 발생.
즉, "줄 세우기"를 너무 확실하게 하다 보니 처리 속도가 느려지는 병목(Bottleneck) 현상이 생기는 것입니다.
5. 해결 방안 및 향후 계획 (Native DB / QueryDSL 등)
현재 구조에서는 PESSIMISTIC_WRITE가 데이터 꼬임 방지에는 탁월하지만, 트래픽이 몰릴 경우 서비스 전체가 느려질 위험이 있습니다.
따라서 트래픽이 증가할 것을 대비하여 다음과 같은 개선이 필요할 수 있습니다.
- Native Query (Native DB) 사용: JPA의 오버헤드를 줄이고 DB 고유의 기능을 직접 사용하여 쿼리 최적화.
- 분산 락 (Redis): DB에 부하를 주지 않고 Redis를 통해 락을 관리하여 DB 커넥션 고갈 방지.
- 낙관적 락 (Optimistic Lock): 락을 걸지 않고 버전(Version) 관리를 통해 충돌 발생 시에만 재시도 처리 (충돌이 잦지 않은 경우 유리).
결론
PESSIMISTIC_WRITE는 '속도보다는 데이터의 정확성이 절대적으로 중요할 때(예: 계좌 잔액 차감)' 사용하는 최후의 보루와 같습니다. 하지만 고성능/대용량 트래픽 환경에서는 DB 락이 병목 지점이 될 수 있으므로, 서비스 규모에 맞춰 적절한 락 전략을 선택하거나 Native 레벨의 튜닝을 고려해야 합니다.