JUnit의 BeforeEach와 JPA를 MultiThread에서 사용하기

최창효·2023년 3월 16일
1
post-thumbnail

서론

멀티쓰레드 환경에서 querydsl 사용하기에 사용할 예제코드를 작성하다가 생긴 문제에 대한 글입니다.
이로 인해 아주 약간은 이번 주제에 불필요한 코드가 들어있지만 천천히 읽으면 이해하기 어렵지 않다고 생각해 그대로 사용했습니다.

Entity와 Repository에 대한 코드는 설명에서 생략했습니다.

본론

문제 상황 확인하기

Service

@Service
@RequiredArgsConstructor
public class Service {

    private final Repository repo;

    @Transactional
    public Dto getAData(){
        return repo.findAFetchFirst();
    }

Testcode

@SpringBootTest
@Slf4j
@Transactional
public class deadlock {
    @Autowired
    Service service;

    @Autowired
    EntityManager em;

    @BeforeEach
    void initData(){
        B b = B.builder().id(1L).var1(99).build();
        A a = A.builder().id(1L).var1(1).build();
        b.setA(a);
        a.setB(b);
        em.persist(a);
        em.persist(b);
        em.flush();
        em.clear();
        log.info("Data insert End");
    }

    @Test
    void getDataWithoutOtherThread(){
        Dto aData = service.getAData();
        System.out.println("aData = " + aData);  
        Assertions.assertNotNull(aData);

    }
    
    @Test
    void getDataInMyThread() throws InterruptedException {
        Runnable userA = () -> {
            log.info("thread A start");
            Dto aData = service.getAData();
            log.info("aData = {}",aData);
            Assertions.assertNull(aData);            
        };
        Thread threadA = new Thread(userA);
        threadA.start();
        threadA.join();
        
    }    

비교적 직관적인 코드라고 생각합니다. 내용을 살펴보면

  • initData()에서 데이터를 삽입합니다. initData()@BeforeEach기 때문에 다른 테스트가 실행되기 전에 같이 호출됩니다.
  • getDataWithoutOtherThread()는 service를 이용해 데이터를 호출합니다. 결과가 잘 출력되는 것도 확인할 수 있습니다.
  • getDataInMyThread()getDataWithoutOtherThread에서와 동일한 호출을 threadA에서 실행합니다. 이 경우 sevice.getAData()는 null을 반환합니다.

원인 살펴보기

이 문제는 트랜잭션과 관련있습니다. 우선 멀티 쓰레드보다 익숙한 일반적인 상황에서 추가적인 테스트를 진행해 보겠습니다.

service

@Service
@RequiredArgsConstructor
public class Service {

    private final Repository repo;

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public Dto getDataForTransactionCheckRequiresNew(){
        String currentTransactionName = TransactionSynchronizationManager.getCurrentTransactionName();
        System.out.println("getDataForTransactionCheckRequiresNew's actualTransactionActive = " + currentTransactionName);
        return repo.findAFetchFirst();
    }

    @Transactional
    public Dto getDataForTransactionCheckBasicPropagation(){
        String currentTransactionName = TransactionSynchronizationManager.getCurrentTransactionName();
        System.out.println("getDataForTransactionCheckBasicPropagation's actualTransactionActive = " + currentTransactionName);
        return repo.findAFetchFirst();
    }

testcode

    @Test
    void propagationTest(){
        Dto dataForTransactionCheckBasicPropagation = service.getDataForTransactionCheckBasicPropagation();
        Dto dataForTransactionCheckRequiresNew = service.getDataForTransactionCheckRequiresNew();
        Assertions.assertNotNull(dataForTransactionCheckBasicPropagation);
        Assertions.assertNull(dataForTransactionCheckRequiresNew);
    }

크게 달라진 건 없습니다. service는 이전과 동일한 역할을 수행합니다. 다만 중간에 현재 트랜잭션의 이름을 출력하는 코드가 추가되었습니다.
getDataForTransactionCheckRequiresNew는 트랜잭션의 propagation옵션을 Propagation.REQUIRES_NEW로 설정했습니다. 이 경우 항상 새로운 트랜잭션을 생성합니다. 반면 dataForTransactionCheckBasicPropagation은 propagation옵션을 설정하지 않은 상태로 REQUIRED가 적용돼 부모 트랜잭션이 존재한다면 부모 트랜잭션에 합류, 그렇지 않다면 새로운 트랜잭션을 만듭니다.

  • 참고로 BeforeEach의 트랜잭션은 실행중인 테스트코드의 트랜잭션과 동일합니다.
  • 이러한 propagation설정의 결과 getDataForTransactionCheckRequiresNewService.getDataForTransactionCheckRequiresNew라는 트랜잭션을 생성했고, dataForTransactionCheckBasicPropagation는 부모 트랜잭션인 multiThread.deadlock.propagationTest합류한걸 알 수 있습니다.
  • 또한 이 테스트코드의 결과가 dataForTransactionCheckBasicPropagation는 null을 반환하지 않았고, dataForTransactionCheckRequiresNew는 null을 반환했습니다.

이를 통해 우리는 BeforeEach가 실행되는 기존의 트랜잭션과 다른 트랜잭션에서는 데이터를 성공적으로 불러올 수 없다라는 걸 알게 되었습니다.

여기까지 확인하고 멀티 쓰레드 테스트코드를 작성해 봅시다.

testcode

    @Test
    void multiThreadTransactionCheck() throws InterruptedException {
        Dto data1 = service.getDataForTransactionCheckBasicPropagation();
        System.out.println("outTransaction = " + data1);
        Runnable userA = () -> {
            Dto data2 = service.getDataForTransactionCheckBasicPropagation();
            System.out.println("inTransaction = " + data2);
        };
        Thread threadA = new Thread(userA);
        threadA.start();
        threadA.join();
    }

  • 우리가 만든 threadA는 Service의 getDataForTransactionCheckBasicPropagation을 사용하고 있습니다. 하지만 데이터를 생성한 beforeEach은 테스트코드와 동일한 deadlock.multiThreadTransactionCheck에서 실행되기 때문에 데이터를 가져올 수 없었습니다.
    • 여기서 또 하나 중요한 사실은 beforeEach에서 시작된 트랜잭션은 커밋되지 않았다는 점입니다. 트랜잭션이 커밋되지 않았기 때문에 다른 트랜잭션에서 이 값을 보지 못하고 있는 겁니다.

해결

  1. 그렇다면 우리가 만든 쓰레드에서도 밖에 있는 트랜잭션에 합류하면 되지 않을까요? Propagation에는 무조건 부모 트랜잭션에 합류시키는 MANDATORY라는 옵션이 있습니다.
  • 하지만 결과는 다음과 같이 실패합니다. 실패하는 이유는 우리가 만드는 트랜잭션의 부모가 없기 때문입니다. 즉, 외부에서 실행되는 트랜잭션이 우리가 만든 쓰레드의 부모 트랜잭션은 아니기 때문에 이러한 방법은 실패합니다.
  1. isolation level을 READ_UNCOMMITTED로 설정합니다. 문제의 원인은 결국 데이터를 삽입한 트랜잭션이 커밋되지 않은 상태에서 새로운 쓰레드의 새로운 트랜잭션이 해당 데이터를 보려고 시도해서 발생한 문제입니다. 커밋되지 않은 데이터를 보기 위해 isolation level을 낮출 수 있습니다.
  • 이 경우 트랜잭션은 여전히 다르지만 새로운 쓰레드에서 성공적으로 데이터를 조회했습니다. 하지만 격리수준이 낮으면 실제 서비스에서 Dirty Read를 비롯한 많은 문제가 발생할 수 있기 때문에 좋은 해결책이 아니라고 생각합니다.
  1. BeforeEach가 아니라 sql.init.mode를 활용해 스프링 부트를 실행하는 시점에 데이터를 넣어줍니다. 트랜잭션 격리수준을 건들지 않고 문제를 해결할 수 있는 가장 좋은 방법이라고 생각합니다.

기타

  • 해당 문제를 겪은 코드는 여기에 있습니다. 처음 설명에서처럼 멀티쓰레드 환경에서 querydsl의 deadlock이라는 다른 문제에 관한 코드지만 참고해볼만 합니다.

정리

  1. BeforeEach에서 insert한 데이터를 새롭게 생성한 쓰레드에서 조회하려고 하면 조회되지 않습니다.
  2. 그 이유는 BeforeEach부터 테스트코드가 끝날 때까지 데이터가 실제 DB에 커밋되지 않으며 트랜잭션이 다른 상태에서 커밋되지 않은 데이터를 조회하는게 불가능하기 때문입니다.
  3. 단편적으로 이 문제를 해결하기 위해 데이터를 조회하는 service의 trasaction isolation level을 Read Uncommited로 변경할 수도 있으나, 개인적으로 이러한 방법보다 테스트 데이터를 BeforeEach가 아니라 스프링 부트를 시작하는 시점에 넣는 방법으로 진행하는 걸 추천드립니다.
profile
기록하고 정리하는 걸 좋아하는 개발자.

0개의 댓글