Post

JPA 에서 한 요청에 여러 트랜잭션을 처리 할 때 주의할 점

들어가며

쇼핑몰 프로젝트를 진행하면서 하루에 한 번 상품의 재고 Entity 를 데이터베이스로 부터 영속성 컨텍스트로 가져온 뒤 재고 Entity 를 조건에 따라 Delete 하거나 Update 해야 했다. 이때 상품 Entity 와 재고 Entity 는 OneToMany 관계이다.

상품 하나에 재고 Entity 를 Delete 하거나 Update 로직이 하나의 트랜잭션 안에서 동작하고 이를 1만 개의 상품에 대해서 수행해야 한다고 했을 때 로직은 다음과 같다.

  • StockScheduler.class
1
2
3
4
5
6
7
8
    ...

    final List<Long> productIds = productRepository.findAllIds(); // 1만 개 상품 ID 를 가져온다.
    for (Long productId : productIds) {		
        stockProcessingService.doStockProcess(productId);
    }

    ...
  • StockProcessingService.class
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Transactional
public void doStockProcess(final Long productId) {
	
    Product product = getProductById(productId);

    // 상품의 재고를 찾아온 뒤 Delete 혹은 Update
	
    List<Stock> stocks = findStocksByProduct(product); 
    for (Stock stock : stocks) {
          ...
    	updateStockQuantity(stock);
    }	
    deleteStock(stock);
}

필자는 상품 재고를 찾아온 뒤 Delete 혹은 Update 하는 doStockConsistencyProcess 로직을 최적화하기 위해 성능 측정을 계획했다.

이는 개발 환경 서버에서 수행되어야 했으므로 굉장히 원초적인 방법으로 /scheduler 라는 앤드포인트를 설계하고 요청을 보내 트랜잭션 단위로 걸린 시간을 로깅으로 측정하였다.

  • StockProcessingService.class
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Transactional
public void doStockProcess(final Long productId) {
    
    Product product = getProductById(productId);
    long startTime = currentTimeMillis();
   
    // 상품의 재고를 찾아온 뒤 Delete 혹은 Update
    List<Stock> stocks = findStocksByProduct(product); 
    for (Stock stock : stocks) {
          ...
    	updateStockQuantity(stock);
    }	
    deleteStock(stock);

    log.info("doStockConsistencyProcess 걸린 시간 = {}ms ", currentTimeMillis() - startTime);
}

문제점

1만 개의 상품에 대해 동기적으로 시간 측정을 했을 때 초기에는 걸린 시간이 평균적으로 56 ms 가 나오던 시간이 점점 오래 걸리기 시작하고 약 1000번째 상품에서 걸린 시간이 1300ms 이상이 소요되는 문제를 마주했다.

상품마다 재고 테이블에 DELETE , UPDATE 쿼리 전달하는 수는 거의 동일했다는 점에서 로직의 문제가 아니라 다른 문제가 있을 것이라고 생각했다. 팀원들을 포함해서 다른 우테캠 교육생 분들에게 문제 상황을 공유한 뒤 원인을 알아내려고 노력했다. 그때 우테캠 교육생 분들께서 “로직에 문제가 있는 것이 아니라면 영속성 컨텍스트와 관련이 있어보인다.” 라고 단서를 주었고 곧바로 트랜잭션이 끝난 뒤 영속성 컨텍스트를 비우는 로직을 추가하고 성능 테스트를 진행해 보았다.

  • StockProcessingService.class
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Transactional
public void doStockProcess(final Long productId) {
    
    Product product = getProductById(productId);
    long startTime = currentTimeMillis();

    // 상품의 재고를 찾아온 뒤 Delete 혹은 Update
    List<Stock> stocks = findStocksByProduct(product); 
    for (Stock stock : stocks) {
          ...
    	updateStockQuantity(stock);
    }	
    deleteStock(stock);
    log.info("doStockConsistencyProcess 걸린 시간 = {}ms ", currentTimeMillis() - startTime);
	
    // 영속성 컨텍스트 비우기
    entityManager.flush();
    entityManager.clear();
}

놀랍게도 결과는 상품 1만개에 대해 평균적으로 56 ~ 60 ms 가 소요되었다.

문제점의 원인은 OSIV

OSIV (Open In View Session) 은 View 레이어에서도 Session 을 유지하겠다는 의미로 영속성 컨텍스트와 트랜잭션은 일반적으로 같은 생명주기를 가지는데 OSIV 가 true 인 경우 트랜잭션이 닫히더라도 View 레이어까지 영속성 컨텍스트를 유지하는 기능을 말한다.

프로젝트에서 OSIV 설정을 별도로 해준 적이 없고 기본 설정이 true 이므로 StockProcessingServicedoStockProcess 에서 트랜잭션이 종료되더라도 영속성 컨텍스트는 계속해서 유지가 되어 하나의 요청에 수행한 트랜잭션이 많아질수록 영속성 컨텍스트에 엔티티들이 계속해서 쌓이게 되어 오버헤드가 발생해 시간이 오래 걸린 것이었다.

즉 OSIV 옵션이 true 인 상태에서 하나의 요청에 대해 여러 트랜잭션을 호출하게 된다면 영속성 컨텍스트가 계속 유지되므로 의도하지 않았다면 주의해야 한다.

프로젝트 내에서 Controller, VIew 영역에서 지연로딩 등 영속성 컨텍스트를 유지할 필요가 없었으므로 spring.jpa.open-in-view=false 설정한 뒤 문제를 해결했다.

마치며

StockScheduler 클래스는 @Scheduled(zone = "Asia/Seoul", cron = "0 0 0 * * ?") 옵션을 사용하여 매일 자정에 로직을 수행하는데 이때에는 OSIV 설정이 동작하지 않으므로 이미 제대로 동작하고 있었다. 하지만 성능 측정을 위해서 API 요청 정의하고 호출하면서 한 요청에 여러 트랜잭션을 처리할 때 주의할 점에 대해 알게 된 뜻깊은 순간이었다.

번외로 OSIV 옵션이 true 인 경우 최초 데이터베이스 커넥션 시작 시점부터 API 응답이 끝날 때까지 데이터베이스 커넥션을 유지하므로 데이터베이스 병목을 줄이기 위해서라도 false 옵션을 기본적으로 가져가는 것이 좋다고 생각한다.

This post is licensed under CC BY 4.0 by the author.