@Transactional을 이용한 테스트 코드 속 더티체킹

최창효·2023년 10월 10일
0
post-thumbnail
post-custom-banner

기본 코드

Product 엔티티

@Getter
@NoArgsConstructor
@Entity
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long productId;

    private int cnt;

    public void changeCnt(int cnt) {
        this.cnt = cnt;
    }

    @Builder
    private Product(int cnt) {
        this.cnt = cnt;
    }
}

ProductRepository

public interface ProductRepository extends JpaRepository<Product,Long> {
}

ProductService

@Slf4j
@RequiredArgsConstructor
@Service
public class ProductService {
    private final ProductRepository productRepository;

    @Transactional
    public void changeProductCnt(Long productId, int cnt) {
        String txName = TransactionSynchronizationManager.getCurrentTransactionName();
        log.info("txName={}", txName);

        Product product = productRepository.findById(productId).orElseThrow(() -> new RuntimeException("데이터 없음"));
        product.changeCnt(cnt);
    }
}

테스트 코드

@Slf4j
@SpringBootTest
@Transactional
class ProductServiceTest {
    @Autowired
    private ProductRepository productRepository;
    @Autowired
    private ProductService productService;
    @Autowired
    private EntityManager em;
    
    @Test
    public void changeStatus() {
    	// 트랜잭션 이름 출력
        String txName = TransactionSynchronizationManager.getCurrentTransactionName();
        log.info("txName={}", txName);

        // Product객체 생성
        Product product = Product.builder()
                .cnt(5)
                .build();

		// Product객체 저장
        productRepository.save(product);
        
		// Product객체의 상태 변경 메서드 실행
        productService.changeProductCnt(product.getProductId(),100);

		// dirtyChecking으로 변경된 내용을 반영하기 위해 flush 강제 실행
		em.flush();
        em.clear();
        
		// 값을 변경한 객체 찾아오기
        Product changedProduct = productRepository.findById(product.getProductId()).get();
        
        // 값이 변경됐는지 검증
        Assertions.assertThat(changedProduct.getCnt()).isEqualTo(100);
    }

}
  • 이 코드는 update작업에 대한 검증을 정상적으로 수행합니다.
  • changeProductCnt메서드는 DirtyChecking기능을 활용해 값을 업데이트하고 있습니다. DirtyChecking은 트랜잭션이 끝나는 시점의 Commit이 flush를 호출함으로써 변화가 있는 엔티티 객체를 DB에 반영해 줍니다.
  • log를 통해 트랜잭션 이름을 출력하고 있습니다. 트랜잭션 이름을 확인해보면 테스트코드를 실행하는 changeStatus()메서드와 changeProductCnt() 모두 changeStatus라는 이름을 가지고 있습니다. 이는 트랜잭션 전파의 기본옵션인 REQUIRED에 의해 내부 트랜잭션(changeProductCnt트랜잭션)이 기존에 존재하는 외부 트랜잭션(changeStatus트랜잭션)에 참여했기 때문입니다.
  • changeStatus트랜잭션은 해당 트랜잭션이 종료되는 시점에 Commit과 함께 flush가 나가야 하지만, 외부 트랜잭션에 참여함으로써 이 Commit시점이 외부 트랜잭션이 종료되는 시점으로 미뤄지게 됐습니다. 그래서 flush를 강제적으로 호출해 임의로 DB에 반영해야 검증이 가능합니다.

다시 한번 얘기하지만 위 코드는 아무런 문제 없이 잘 동작합니다. 이 상태에서 코드를 변경하면서 몇 가지 사항에 대해 얘기해보겠습니다.

여기부터는 검증되지 않은 저의 부정확한 '추측'을 다수 포함하고 있습니다. 이점 유의해서 읽어주시면 감사하겠습니다.

1. flush 미실행

  • 위 코드에서 em.flush();를 실행하지 않으면 어떻게 될까요? flush를 하지 않으면 DirtyChecking이 실행되지 않고, 그 상태로 clear만 진행했기 때문에 변경 사항이 DB에 반영되지 못했습니다. 결과적으로 가져온 product에서는 cnt가 100으로 변해있지 않아 검증에 실패하게 됩니다.

2. 트랜잭션 분리

  • 정상 코드에서 changeProductCnt메서드의 트랜잭션은 테스트코드의 트랜잭션에 참여했고, 이로 인해 changeProductCnt가 끝나는 시점에 트랜잭션이 끝나지 않게 됐습니다. 트랜잭션이 끝나지 않아 메서드가 끝나는 시점에 Commit 및 flush가 진행되지 않았고, 결과적으로 em.flush라는 강제 플러쉬가 필요했습니다.
  • 그렇다면 changeProductCnt와 changeStatus의 트랜잭션을 별도로 분리하면 정상적으로 작동하지 않을까? 라는 생각이 들었습니다. 트랜잭션을 분리한다면 changeStatus의 트랜잭션이 종료되는 시점에 Commit이 발생하면서 DirtyChecking이 실행될 것이라 판단했기 때문입니다.

ProductService

@Slf4j
@RequiredArgsConstructor
@Service
public class ProductService {
    private final ProductRepository productRepository;

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void changeProductCnt(Long productId, int cnt) {
        String txName = TransactionSynchronizationManager.getCurrentTransactionName();
        log.info("txName={}", txName);

        Product product = productRepository.findById(productId).orElseThrow(() -> new RuntimeException("데이터 없음"));
        product.changeCnt(cnt);
    }
}
  • 트랜잭션 전파(propagation)을 REQURIES_NEW로 설정함으로써 기존의 트랜잭션과 분리된 별도의 트랜잭션을 만들었습니다. 로그를 살펴보면 두 트랜잭션의 이름 또한 다르다는 걸 확인할 수 있습니다.

  • 하지만 이 코드는 실패합니다. 단순히 flush를 실행하지 않았을 때는 검증에서 실패하지만, 이번에는 데이터가 존재하지 않는다는 에러가 발생합니다

    • 이 에러는 ProductService에서 findById를 실행할 때 해당 Entity가 존재하지 않아서 발생하는 에러입니다.

3. 트랜잭션 분리 + 격리수준 낮추기

저는 Entity를 찾지 못한 이유가 save를 진행하는 트랜잭션이 아직 커밋되지 않아서라고 판단했습니다. 내부 트랜잭션인 changeProductCnt에서 findById를 하는 시점에 외부 트랜잭션인 changeStatus는 save는 실행했지만 아직까지 커밋되지 않은 상태입니다. 대부분의 트랜잭션 격리수준 기본값은 READ_COMMITTED이기 때문에 아직 커밋되지 않은 데이터에 다른 트랜잭션이 접근하지 못해 발생한 상황이라 생각했습니다.

우선 격리수준을 READ_UNCOMMITTED로 낮추면 flush없이 update코드를 정상적으로 테스트할 수 있는지 확인하기로 했습니다.

@Slf4j
@SpringBootTest
@Transactional(isolation = Isolation.READ_UNCOMMITTED)
class ProductServiceTest {
	@Test
    void changeStatus() {}
}
@Slf4j
@RequiredArgsConstructor
@Service
public class ProductService {
	@Transactional(propagation = Propagation.REQUIRES_NEW)
	public void changeProductCnt() {}
}
  • 내부 트랜잭션인 changeProductCnt에 propagation = REQUIRES_NEW속성을 줘 두 트랜잭션을 분리했습니다.
  • 외부 트랜잭션인 changeStatus에 isolation = Isolation.READ_UNCOMMITTED속성을 줘 커밋되지 않은 데이터 읽기를 가능하게 했습니다.

하지만 여전히 Entity를 찾지 못합니다.

그런데 아래와 같이 코드를 설계하면 에러는 발생하지만 DirtyChecking에 의해 UPDATE쿼리 까지는 정상적으로 진행됩니다.

@Slf4j
@SpringBootTest
@Transactional
class ProductServiceTest {
	@Test
    void changeStatus() {}
}
@Slf4j
@RequiredArgsConstructor
@Service
public class ProductService {
	@Transactional(propagation = Propagation.REQUIRES_NEW,
    				isolation = Isolation.READ_UNCOMMITTED)
	public void changeProductCnt() {}
}
  • 내부 트랜잭션인 changeProductCnt의 격리수준을 READ_UNCOMMITTED로 낮췄을 때 Entity도 정상적으로 찾고, DirtyChecking에 의한 UPDATE쿼리도 나가는 걸 확인할 수 있었습니다.
  • UPDATE쿼리는 나가지만 코드가 정상적으로 실행되는 건 아닙니다. 이 경우 동시성 문제로 인한 lock timeout이 발생합니다. 아마 insert를 실행하는 트랜잭션, 그리고 update를 실행하는 트랜잭션 모두 동일한 Entity에 접근해서 발생하는 문제라고 생각됩니다.

4. @Transactional과 강제flush 제거

@Slf4j
@SpringBootTest
// @Transactional
class ProductServiceTest {
    @Autowired
    private ProductRepository productRepository;
    @Autowired
    private ProductService productService;
    @Autowired
    private EntityManager em;
    
    @Test
    public void changeStatus() {
        String txName = TransactionSynchronizationManager.getCurrentTransactionName();
        log.info("txName={}", txName);
        Product product = Product.builder()
                .cnt(5)
                .build();
        productRepository.save(product);
        productService.changeProductCnt(product.getProductId(),100);
//		em.flush();
//      em.clear();
        Product changedProduct = productRepository.findById(product.getProductId()).get();
        Assertions.assertThat(changedProduct.getCnt()).isEqualTo(100);
    }

}
  • @Transactional은 테스트 코드에서 정상적으로 코드진행이 완료됐을 때 해당 작업들을 롤백하는 기능을 가지고 있습니다.
  • 처음의 잘 동작하는 테스트코드에서 다음과 같이 @Transactionalem.flush();, em.clear();를 제거해도 테스트는 통과합니다. 하지만 이 경우 테스트 코드의 결과가 롤백되지 않아 DB에 그대로 값이 남아있게 됩니다. (그래서 @Transactional을 사용하지 않는 경우 AfterEach시점에 deleteAllInBatch를 진행하는 게 일반적입니다)
profile
기록하고 정리하는 걸 좋아하는 개발자.
post-custom-banner

0개의 댓글