가상 화폐 거래소 프로젝트를 진행하면서 가장 신경 썼던 부분은 **"돈과 관련된 데이터의 무결성"**입니다. 만약 사용자가 **따닥!(동시 클릭)**을 해서 보유 현금보다 더 많은 주식을 살 수 있게 된다면? 이는 서비스의 신뢰도를 바닥으로 떨어뜨리는 치명적인 버그가 될 것입니다.
이번 포스팅에서는 **멀티 스레드 환경에서 발생하는 동시성 이슈(Race Condition)**를 테스트 코드로 직접 재현해보고, **JPA의 비관적 락(Pessimistic Lock)**을 통해 이를 완벽하게 방어한 과정을 기록합니다.
1. 문제 상황: 100명이 동시에 매수 버튼을 누른다면?
🛑 시나리오
- 사용자 잔액: 1,000원
- 주식 가격: 1,000원
- 상황: 사용자(혹은 악의적인 해커)가 동시에 100번의 매수 요청을 보냄.

❓ 기대 결과 vs 실제 결과
- 기대 결과: 딱 1번만 성공하고, 잔액은 0원이 되어야 함. (나머지 99번은 잔액 부족 실패)
- ❓ 실제 결과 (Actual Result)
- 돈 복사 버그: 여러 스레드가 동시에 잔액을 확인하여, 실제 잔액보다 더 많은 주문이 성공하고 잔액은 한 번만 차감된다.
- 데이터 중복 (DB 폭발): "데이터가 없으면 새로 생성(INSERT)"하는 로직에 여러 스레드가 동시에 진입하여, 동일한 주식 데이터가 DB에 여러 줄 쌓인다. -> 해결하기 위해 아래 테스트 코드 중 list<stockholding>로 바꿈
- 시스템 셧다운: 하나만 있어야 할 데이터가 여러 개 조회되면서 NonUniqueResultException이 발생하고, 이후의 모든 조회 로직이 마비된다.
- 동시성 제어가 없는 상태에서는 단순히 숫자가 틀리는 것을 넘어 시스템의 무결성이 파괴되는 두 가지 치명적인 버그가 발생한다.
2. 테스트 코드로 버그 재현하기 (공격! 🔫)
이론상의 문제를 눈으로 확인하기 위해 ExecutorService와 CountDownLatch를 사용하여 멀티 스레드 테스트를 작성했습니다.
- ExecutorService: 32개의 스레드 풀(일꾼) 생성
- CountDownLatch: 100개의 요청이 끝날 때까지 대기
- 검증: 주문 성공 횟수 == 1 이어야 함.
@Test
@DisplayName("동시에 100개 주문이 들어오면 잔고가 망가진다")
void concurrentOrderTest() throws InterruptedException {
// ... (유저 잔액 1000원 세팅) ...
int threadCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
orderService.createOrder(userId, req); // 따닥!
} finally {
latch.countDown();
}
});
}
latch.await();
// 검증 로직 ...
}
😱 충격적인 결과 (Before)
테스트를 돌려본 결과, 주문이 10개나 성공해버렸습니다.


잔고는 1,000원(1건)만 차감됐는데, 주식은 10개나 생겼습니다. 9,000원의 횡령이 발생한 것입니다. 이는 여러 스레드가 동시에 잔액 조회(1000원) 시점을 통과하면서 발생한 레이스 컨디션(Race Condition) 문제입니다.
3. 해결책: 비관적 락(Pessimistic Lock) 적용 🛡️
이 문제를 해결하기 위해 **DB 수준에서 강력하게 줄을 세우는 '비관적 락'**을 도입했습니다.
비관적 락(Pessimistic Lock)이란? "데이터에 접근할 때 충돌이 발생할 것이라고 비관적으로 가정하고, 조회 시점부터 트랜잭션이 끝날 때까지 락(자물쇠)을 걸어버리는 방식"입니다.
1) Repository 수정
모든 조회에 락을 걸면 성능이 저하되므로, 주문처럼 정합성이 중요한 로직에만 사용할 전용 메서드를 분리했습니다.
public interface AccountRepository extends JpaRepository<Account, Long> {
// 일반 조회 (메인 페이지 등에서 사용 - 빠름)
Optional<Account> findByUserId(Long userId);
// ★ 락을 건 조회 (주문/결제 시 사용 - 안전함)
@Lock(LockModeType.PESSIMISTIC_WRITE) // 다른 트랜잭션의 읽기/쓰기 모두 차단
@Query("select a from Account a where a.user.id = :userId")
Optional<Account> findByUserIdWithLock(@Param("userId") Long userId);
}
2) Service 수정
주문 로직(createOrder)에서 잔액을 확인할 때, 위에서 만든 findByUserIdWithLock을 호출하도록 변경했습니다. 이제 누군가 내 계좌를 조회 중이면, 다른 요청은 대기해야 합니다.
4. 결과 검증 (After) ✅
락을 적용한 후, 동일하게 100명 동시 요청 테스트를 실행했습니다

정확하게 단 1건만 성공하고, 나머지는 예외 처리되어 데이터 정합성이 완벽하게 지켜지는 것을 확인했습니다.
💡 인사이트 및 다음 단계
Q. 왜 모든 조회 메서드에 락을 걸지 않고 따로 만들었나요?
모든 findByUserId에 락을 걸어버리면, 단순히 프로필을 조회하거나 메인 페이지에 접속하는 유저들까지 **불필요한 대기(Blocking)**를 겪게 되어 서비스 속도가 현저히 느려질 수 있습니다. 따라서 "성능(단순 조회)"과 "정합성(주문)"을 분리하여 필요한 곳에만 락을 적용하는 전략을 택했습니다.
🚀 Next Step: Redis 분산 락
현재 방식은 DB에 직접 락을 걸기 때문에, 주문량이 폭주하면 DB 부하가 심해질 수 있습니다. 다음 단계에서는 DB 앞단에 Redis를 배치하여 **분산 락(Distributed Lock)**을 구현해, DB의 부담을 줄이고 성능을 더 최적화해보려 합니다.