| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 | 31 |
- 이스케이프 문자
- Graph
- 윤성우의 열혈 자료구조
- insertion sort
- stream
- R
- buffer
- s
- 혼자 공부하는 C언어
- datastructure
- Serialization
- Stack
- Selection Sorting
- ㅅ
- 이것이 자바다
- C 언어 코딩 도장
- coding test
- list 컬렉션
- 메모리구조
- Algorithm
- C programming
- JSON
- 알기쉬운 알고리즘
- 윤성우 열혈자료구조
- Today
- Total
Engineering Note
[JPA] Repository로 동시성 테스트하면 안 되는 이유 본문
아래는 선착순 이벤트 시스템을 구축하면서 재고관리에 동시성 이슈가 발생한 상황을 정리하기 위해 작성한 글입니다.
Spring에서 동시성 테스트를 할 때 Repository로 동시성 테스트를 하면 안 되는 이유가 무엇일까요?
1. 문제 상황: 동시성 테스트가 실패했다
선착순 이벤트 시스템을 구축하면서 100명이 동시에 재고 100개인 상품의 주문을 요청했을 때, 주문은 100개가 성공했는데 재고가 88개나 남는 현상을 발견했습니다. 100건의 주문에 대해 12건만 재고가 차감되고 88건의 재고는 차감된 데이터를 DB에 제대로 업데이트 하지 못했다는 뜻입니다.
DB관점에서 정리하면 2개 이상의 트랜잭션이 하나의 Row에 접근하면서 Race Condition이 발생했고, 이를 제대로 제어하지 못해서 데이터 정합에 실패했습니다. (동시성 문제가 발생한 이유에 대해서는 'https://techbook11.tistory.com/767' 에 자세히 작성해두었으니 참고하시면 됩니다.)
===== 동시성 문제 재현 결과 =====
성공 요청: 100
예상 재고: 0
실제 재고: 88 // 88개의 재고 차감이 유실됨!
이는 전형적인 Lost Update 문제입니다.
2. 첫 번째 시도: Repository에 비관적 락 적용
동시성 문제를 해결하기 위해 Repository에 비관적 락을 추가했습니다.
public interface EventRepository extends JpaRepository<Event, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT e FROM Event e WHERE e.id = :eventId")
Optional<Event> findByIdForUpdate(@Param("eventId") Long eventId);
}
주문을 위해 현재 재고를 조회하는 쿼리에 DB레벨에서 Lock을 적용해, 트랜잭션간 경합문제를 해결하는 방법입니다.
그리고 테스트 코드를 작성했습니다.
@DataJpaTest
class EventRepositoryTest {
@Autowired
private EventRepository eventRepository;
// ❌ @Transactional 없음!
@Test
void concurrency_test_with_pessimistic_lock() {
// 데이터 준비
Event event = eventRepository.save(testEvent);
// 100개 스레드 생성
ExecutorService executorService = Executors.newFixedThreadPool(100);
for (int i = 0; i < 100; i++) {
executorService.submit(() -> {
Event found = eventRepository.findByIdForUpdate(event.getId());
found.decreaseStock(1);
eventRepository.save(found);
});
}
// 결과: 재고가 여전히 98개 남음!
}
}
하지만 여전히 실패했습니다. 비관적 락을 추가했는데도 동시성 문제가 해결되지 않았습니다.
3. 문제 원인: 트랜잭션이 없어서 락이 작동하지 않았다
비관적 락의 동작 원리
비관적 락(PESSIMISTIC_WRITE)은 다음과 같이 동작합니다:
-- @Lock(PESSIMISTIC_WRITE)는 이렇게 변환됨
SELECT * FROM event WHERE id = 1 FOR UPDATE;
FOR UPDATE는 해당 row에 락을 걸어 다른 트랜잭션이 수정하지 못하게 합니다.
핵심: 락은 트랜잭션 내에서만 유효하다
// ❌ 트랜잭션 없는 경우
void someMethod() {
Event event = eventRepository.findByIdForUpdate(id);
// ↑ SELECT ... FOR UPDATE 실행
// ↓ 즉시 락 해제! (트랜잭션이 없으므로 자동 커밋)
event.decreaseStock(1);
eventRepository.save(event);
// ↑ 이미 락이 해제된 상태에서 UPDATE
}
```
**문제 분석:**
1. `@Lock`은 **트랜잭션 경계 안에서만** 락을 유지
2. 트랜잭션이 없으면 조회 직후 **즉시 자동 커밋**되어 락이 해제됨
3. 결과적으로 락이 전혀 작동하지 않음
### 실제 동작 순서
```
Thread 1 Thread 2
| |
SELECT ... FOR UPDATE |
→ 락 획득 |
→ 즉시 자동 커밋 (트랜잭션 없음) |
→ 락 해제! ❌ |
| |
decreaseStock(1) SELECT ... FOR UPDATE
| → 락 획득 ✅ (Thread1의 락, 이미 해제됨)
UPDATE (100 → 99) → 즉시 자동 커밋
| → 락 해제
| decreaseStock(1)
| UPDATE (100 → 99) ❌
| |
결과: 99 결과: 99 (Lost Update!)
```
4. 올바른 해결책: Service 레이어에서 트랜잭션 관리
Step 1: EventService 생성
@Service
@RequiredArgsConstructor
public class EventService {
private final EventRepository eventRepository;
/**
* ✅ 비관적 락 + 트랜잭션
* 트랜잭션 내에서 락을 유지하므로 동시성 제어가 작동함
*/
@Transactional
public void decreaseStockWithPessimisticLock(Long eventId, int quantity) {
Event event = eventRepository.findByIdForUpdate(eventId)
.orElseThrow(() -> new IllegalArgumentException("이벤트를 찾을 수 없습니다."));
event.decreaseStock(quantity);
// 메서드 종료 시 자동 커밋 → 이때 락 해제
}
/**
* ❌ 비교용: 락 없는 버전
*/
@Transactional
public void decreaseStockWithoutLock(Long eventId, int quantity) {
Event event = eventRepository.findById(eventId)
.orElseThrow(() -> new IllegalArgumentException("이벤트를 찾을 수 없습니다."));
event.decreaseStock(quantity);
}
}
Step 2: Service 레이어에서 테스트
@SpringBootTest
class EventConcurrencyTest {
@Autowired
private EventService eventService;
@Autowired
private EventRepository eventRepository;
private ExecutorService executorService;
@BeforeEach
void setUp() {
executorService = Executors.newFixedThreadPool(100);
// 테스트 데이터 준비 (각 save는 즉시 커밋됨)
Event event = eventRepository.save(testEvent);
}
@Test
void 비관적_락_적용_테스트() throws InterruptedException {
CountDownLatch latch = new CountDownLatch(100);
// 100개 스레드가 동시에 재고 차감
for (int i = 0; i < 100; i++) {
executorService.submit(() -> {
try {
// ✅ Service의 @Transactional이 각 스레드에서 작동
eventService.decreaseStockWithPessimisticLock(event.getId(), 1);
} finally {
latch.countDown();
}
});
}
latch.await();
// 결과 확인
Event result = eventRepository.findById(event.getId()).get();
assertThat(result.getRemainingStock()).isEqualTo(0); // ✅ 성공!
}
}
```
### 왜 Service에서는 작동하는가?
```
Thread 1 Thread 2
| |
@Transactional 시작 |
| |
SELECT ... FOR UPDATE |
→ 락 획득 ✅ |
| |
decreaseStock(1) @Transactional 시작
UPDATE (100 → 99) SELECT ... FOR UPDATE
| → 대기... (락 획득 대기) ⏳
@Transactional 커밋 |
→ 락 해제 |
| → 락 획득 ✅
| decreaseStock(1)
| UPDATE (99 → 98)
| @Transactional 커밋
| → 락 해제
| |
결과: 99 결과: 98 ✅ (정확!)
```
**핵심:**
- `@Transactional`이 메서드 시작부터 끝까지 트랜잭션을 유지
- 락은 트랜잭션이 커밋될 때까지 유지됨
- 다른 스레드는 락이 해제될 때까지 대기
## 5. 추가 문제: 멀티모듈 의존성 역전
Repository에서 테스트하면 안 되는 또 다른 이유는 **모듈 의존성** 때문입니다.
```
shop-api (Controller)
↓
shop-core (Service) ← 테스트는 여기서!
↓
shop-domain (Repository) ← 여기서 테스트하면 안 됨
Domain 모듈에서 Service를 의존하게 되면 의존성 방향이 역전됩니다. Domain은 최하위 레이어로 다른 레이어를 의존하면 안 됩니다.
6. 정리: 왜 Service에서 테스트해야 하는가?
| 비교 | Repository 테스트 | Service 테스트 |
| 트랜잭션 | ❌ 없음 → 락이 즉시 해제 | ✅ @Transactional로 락 유지 |
| 락 동작 | ❌ 비관적 락 작동 안 함 | ✅ 정상 작동 |
| 모듈 의존성 | ❌ 의존성 역전 발생 | ✅ 올바른 의존 방향 |
| 실제 환경 재현 | ❌ 운영과 다른 동작 | ✅ 운영 환경과 동일 |
| 비즈니스 로직 | ❌ 테스트 불가 | ✅ 완전한 테스트 가능 |
7. 결론
Repository 레이어에서 @Transactional을 사용한 동시성 테스트는 실제 운영 환경의 동시성 문제를 제대로 재현하지 못합니다. 테스트는 Service 레이어에서 수행해야 합니다.
- 비관적 락은 트랜잭션 내에서만 유효: @Lock은 트랜잭션이 커밋될 때까지 락을 유지합니다. 트랜잭션이 없으면 즉시 해제되어 의미가 없습니다.
- Service가 트랜잭션 경계: Spring의 기본 설계 원칙대로 Service 레이어가 @Transactional로 트랜잭션을 관리해야 합니다.
- Repository는 단순 데이터 접근: Repository는 데이터 접근만 담당하고, 비즈니스 로직과 트랜잭션 관리는 Service의 책임입니다.
- 동시성 테스트는 Service 레이어에서: 실제 운영 환경과 동일한 트랜잭션 경계에서 테스트해야 정확한 결과를 얻을 수 있습니다.
이 경험을 통해 동시성 제어 메커니즘(락, 격리 수준 등)은 반드시 트랜잭션 컨텍스트 내에서 동작한다는 것을 배웠습니다. Repository에서 직접 테스트하는 것은 트랜잭션 경계가 없어 락이 제대로 작동하지 않으며, Spring의 레이어드 아키텍처 원칙도 위배하게 됩니다.
'Server > JPA ORM' 카테고리의 다른 글
| [JPA] Spring Data JPA Query Method 네이밍 규칙 (0) | 2025.12.23 |
|---|---|
| [JPA] 지연로딩과 N+1문제해결, 실행 쿼리 비교 (0) | 2025.11.09 |
| [JPA] 컬렉션 지연로딩(Lazy Loading)으로 인한 N+1 문제 (0) | 2025.11.08 |
| [JPA] 연관관계 주인 (1) | 2025.10.10 |
| [JPA] 기본 키 매핑 방법 (0) | 2025.10.08 |
