쌩로그

[TroubleShooting] 테스트 코드 실패로 알아보는 @Async, 메서드, 트랜잭션, 그리고 영속성 컨텍스트에 대한 정리 본문

TroubleShooting & 고민

[TroubleShooting] 테스트 코드 실패로 알아보는 @Async, 메서드, 트랜잭션, 그리고 영속성 컨텍스트에 대한 정리

.쌩수. 2025. 9. 2. 23:26
반응형

목록

  1. 포스팅 개요
  2. 본론
  3. 요약

1. 포스팅 개요

테스트 코드를 작성 중, @Async 메서드에 대해 테스트 할 일이 있었다.

하나의 테스트를 성공시키기 위해서, 생각을 하다보니
테스트 클래스에 @Transactional을 붙이는 것과 붙이지 않는 것에 대한 차이,
@Async 에 대한 원리, 그리고 영속성 컨텍스트까지...

이 세 가지의 개념이 복합적으로 연관되어 있어서 정리를 하기 위해 해당 포스팅을 하게 되었다.

2. 본론

2-1. 비즈니스 로직

로직은 다음과 같다.

  • 데이터를 생성한다(동기)
  • 데이터를 수정한다(비동기 @Async)
  • 데이터 확인
    이렇다.

※ 아! 참고로 왜 수정 메서드를 비동기로 했냐고 할 수도 있다. 맞다.. 사실 그냥 동기로 처리해도 된다.
그런데 테스트 할 부분이 있어서 일부러 @Async를 붙여서 비동기 메서드로 처리하려고 했다.

일단 테스트 코드는 다음과 같다.

@SpringBootTest  
@Import(TestAsyncConfig.class)  
class BookCommandServiceImplTest {

    ...

    @DisplayName("책의 정보를 수정할 수 있다.")  
    @Test  
    void modifyBookTest() throws InterruptedException {  
        // given  
        RegisterBookServiceRequest registerRequest = RegisterBookServiceRequest.of("리액트를 다루는 기술", "벨로퍼트", "9791160508796");  
        BookResponse bookResponse = bookCommandService.registerBook(registerRequest);  

        ModifyBookServiceRequest modifyRequest = ModifyBookServiceRequest.of("Next.js 뽀개기", "이정환", "hello");  

        // when  
        bookCommandService.modifyBook(modifyRequest, bookResponse.getId());  

        Thread.sleep(1000); // 현재 modifyBook 이 비동기 처리이므로, 0.5초 기다림  

        Book findBook = bookCommandRepository.findById(bookResponse.getId()).orElseThrow();  
        System.out.println(findBook.getTitle());  

        // then  
        assertThat(findBook.getId()).isEqualTo(bookResponse.getId());  
        assertThat(findBook.getTitle()).isEqualTo(modifyRequest.getTitle());  
        assertThat(findBook.getAuthor()).isEqualTo(modifyRequest.getAuthor());  
        assertThat(findBook.getIsbn()).isEqualTo(modifyRequest.getIsbn());  
    }

    ...

}

서비스 코드

해당 코드는 Command 에 해당하는 코드다
이때 modifyBook 메서드는 비동기 메서드다.

@Slf4j  
@Service
@Transactional 
@RequiredArgsConstructor  
public class BookCommandServiceImpl implements BookCommandService {  

    private final BookCommandRepository bookCommandRepository;  

    /**  
     * 책을 등록한다.  
     * @param request 등록 요청 정보  
     * @return 등록된 책의 정보  
     */  
    @Override  
    public BookResponse registerBook(RegisterBookServiceRequest request) {  
        Book book = Book.of(request);  
        bookCommandRepository.save(book);  
        return BookResponse.of(book);  
    }  

    /**  
     * 책의 정보를 업데이트 한다.  
     * @param request 수정 요청 정보  
     * @param id 수정할 책의 Id  
     */    
    @Async  
    @Override    
    public void modifyBook(ModifyBookServiceRequest request, Long id) {  
        Book book = bookCommandRepository.findById(id)  
                .orElseThrow(() -> new BusinessException(NOT_FOUND_BOOK));  
        book.modify(request);  
    }

2-2. 상황별 테스트 결과

위의 테스트 코드를 상황별로 한 번 결과를 보자.
일단 위의 테스트 코드를 토대로 한다.
위의 코드는 현재 테스트에 성공한다.

테스트 클래스에 @Transactional을 붙였을 때


@Transactional  // 트랜잭션 붙임
@SpringBootTest  
@Import(TestAsyncConfig.class)  
class BookCommandServiceImplTest {

    ...



    @DisplayName("책의 정보를 수정할 수 있다.")  
    @Test  
    void modifyBookTest() throws InterruptedException {  
        ...
    }

}

테스트 클래스에 @Transactional을 붙였다.

이 때는 테스트코드가 실패한다.

Thread.sleep(1000) 을 제외했을 때

테스트 클래스에는 @Transactional 을 다시 빼고, Thread.sleep(1000) 을 주석처리하고, 테스트를 해보자.

@DisplayName("책의 정보를 수정할 수 있다.")  
@Test  
void modifyBookTest() throws InterruptedException {  
    // given  
    RegisterBookServiceRequest registerRequest = RegisterBookServiceRequest.of("리액트를 다루는 기술", "벨로퍼트", "9791160508796");  
    BookResponse bookResponse = bookCommandService.registerBook(registerRequest);  

    ModifyBookServiceRequest modifyRequest = ModifyBookServiceRequest.of("Next.js 뽀개기", "이정환", "hello");  

    // when  
    bookCommandService.modifyBook(modifyRequest, bookResponse.getId());  

//        Thread.sleep(1000); // 주석 처리 

    Book findBook = bookCommandRepository.findById(bookResponse.getId()).orElseThrow();  
    System.out.println(findBook.getTitle());  

    // then  
    assertThat(findBook.getId()).isEqualTo(bookResponse.getId());  
    assertThat(findBook.getTitle()).isEqualTo(modifyRequest.getTitle());  
    assertThat(findBook.getAuthor()).isEqualTo(modifyRequest.getAuthor());  
    assertThat(findBook.getIsbn()).isEqualTo(modifyRequest.getIsbn());  
}

결과를 보자.

실패했다.

2-3. 실패한 원인 분석

테스트 클래스에 @Transactional을 붙였을 때

  • 테스트 클래스에 @Transactional을 붙이면, 테스트의 메서드를 실행할 때부터 트랜잭션이 시작되고, 테스트가 끝날 때 롤백이 된다.

코드와 함께 로직을 한번 다시 보자.

@DisplayName("책의 정보를 수정할 수 있다.")  
@Test  
void modifyBookTest() throws InterruptedException {  
    // given  
    RegisterBookServiceRequest registerRequest = RegisterBookServiceRequest.of("리액트를 다루는 기술", "벨로퍼트", "9791160508796");  
    BookResponse bookResponse = bookCommandService.registerBook(registerRequest);  

    ModifyBookServiceRequest modifyRequest = ModifyBookServiceRequest.of("Next.js 뽀개기", "이정환", "hello");  

    // when  
    bookCommandService.modifyBook(modifyRequest, bookResponse.getId());  

    Thread.sleep(1000); // 현재 modifyBook 이 비동기 처리이므로, 0.5초 기다림  

    Book findBook = bookCommandRepository.findById(bookResponse.getId()).orElseThrow();  
    System.out.println(findBook.getTitle());  

    // then  
    assertThat(findBook.getId()).isEqualTo(bookResponse.getId());  
    assertThat(findBook.getTitle()).isEqualTo(modifyRequest.getTitle());  
    assertThat(findBook.getAuthor()).isEqualTo(modifyRequest.getAuthor());  
    assertThat(findBook.getIsbn()).isEqualTo(modifyRequest.getIsbn());  
}

일단 given 절의 역할과 같이 해당 테스트를 하기 위한 데이터를 준비한다.
컨트롤러 계층의 종속에서 벗어난 Service 계층용 Request를 만들고,
Request를 통해서 데이터를 등록한다
(비즈니스 로직은 추상화해서 말한다.)

데이터 등록은 서비스 코드에서 본바와 같이 동기처리된다.

이제 when 절에서 테스트하고자하는 메서드를 실행한다.
@Async 가 붙은 데이터를 수정하는 비즈니스 코드다.

그리고 @Async가 붙은 메서드는 기존의 트랜잭션이 전파되는 것이 아니라, 새롭게 분리된다.

그래서 새롭게 분리된 트랜잭션에서 수정이 완료되기까지 기다리기 위해서 1초간 잠시 Thread를 멈추게 했다.

그리고 수정이 완료되었는지 확인하고자 데이터를 조회했고, (이 부분도 동기처리다)
(이는 JPA Repository Interface를 통해서 가져오도록 했다. 아직 조회에 대한 로직을 서비스계층에서 구현하지 않았기 때문이다.)

assertJ를 통해서 수정되었는지 검증했다.

실패의 원인

1. 테스트 클래스에 붙은 @Transactioanl

  • 테스트 메서드를 시작할 때 트랜잭션이 시작된다.
  • given절의 bookCommandService.registerBook() 메서드에 @Transacional 이 선언되어있다. 즉, 테스트 메서드를 시작할 때의 트랜잭션을 그대로 물고간다.
    • repository 의 save() 메서드를 호출하게 되고, DB에 저장되었다가 그 반환된 Entity를 통해서 Response 객체를 만든다.
  • Response 객체에 담긴 id와 수정할 데이터를 통해서 modifyBook() 이라는 비동기 메서드를 실행한다.
    • 이 때 해당 메서드에 붙은 @Async 메서드를 통해서 새로운 트랜잭션이 시작된다.
    • modifyBook() 메서드를 호출하면서 해당 메서드는 새로운 트랜잭션으로 실행되지만, 테스 자체는 테스트를 시작할 때부터 시작되었던 트랜잭션이 아직 진행중이다.
      • 나는 이때 Threead.sleep()을 통해서 메서드를 실행하는 Thread를 잠시 멈추고, 그동안 새롭게 시작된 트랜잭션에서는 충분히 변경 감지가 일어나서 데이터 수정이 반영되었을 줄 알았다.
  • 그리고 findById() 를 통해 찾아온 Entity 와 수정을 요청하는 데이터와 비교했더니, 수정이 되지 않았다.

일단 장황하게 좀 적었지만, 위의 내용 중 내가 놓쳤던 부분은

트랜잭션이 메서드를 시작할 때부터 이미 물고 시작되었다는 것
이러한 이유로 인해서 findById() 가 새롭게 DB에서 조회한 데이터를 가져오는 것이 아니라, 영속성 컨텍스트에 남아있는 객체를 가져왔다는 것이다.

영속성 컨텍스트는 트랜잭션 단위로 생명주기를 가진다(OSIV를 열지 않는다면 말이다.)

그래서 테스트가 시작될 때 트랜잭션이 시작된 후로, 테스트가 끝날 때까지 테스트는 같은 영속성 컨텍스트를 가지게 된다.

그리고 데이터를 수정하는 메서드를 호출하는데
해당 메서드는 @Async 에 의해서 새로운 스레드와 새로운 트랜잭션에서 해당메서드가 처리된다.

중간에 Thread.sleep 코드가 나오는데 이 코드를 넣은 이유는 새로운 스레드 / 새로운 트랜잭션에 의해서 처리될 작업을 완료될 때까지 기다리기 위해 해당 코드를 넣어두었다.

그리고 수정이 그동안 수정이 완료되었을 것이라 생각하고 findById로 데이터를 조회했는데, 내가 기대한 것은 findById 를 호출할 시 DB에서 데이터를 가져오길 원했다.
하지만 테스트 시작시 발생했던 트랜잭션에 의해서 findById 메서드는 DB가 아니라 영속성컨텍스트에서 가져온 것이었다

현재 테스트에서는 계속 트랜잭션이 진행되고 있고, 해당 트랜잭션과 같은 생명주기를 가지는 영속성 컨텍스트가 있다.
그리고 영속성 컨텍스트에는 데이터를 등록하면서 생긴 Entity의 정보가 남아있기 때문에 findById() 를 해도 DB에서 다시 조회하는 것이 아니라, 영속성 컨텍스트에 있는 Entity를 가져온다.

테스트에 @Transactional을 붙임으로써 트랜잭션과 더불어서 영속성 컨텍스트에 대한 경계를 잘 못 인지하고 있던 것이었다...
물론 그 덕에 이번 기회에 명확하게 알게되었다.

이제 findById() 메서드 전 후로, 로그성 문자열을 출력해보자.
그러면 findById()가 영속성컨텍스트에서 가져오는 것울 분명히 알 수 있다.

// 테스트 코드
System.out.println("select 전");  
Book findBook = bookCommandRepository.findById(bookResponse.getId()).orElseThrow();  
System.out.println("select 후");

...
...

// 결과
select 전
select 후

마치 그림으로 표현하면 다음과 같다.

트랜잭션과 영속성 컨텍스트가 같은 주기에 있다고 보면된다.

아! @Async 메서드가 새로운 트랜잭션으로 분리된다고 했었는데, 마찬가지로 새로운 영속성 컨텍스트를 사용하게 된다.

그래서 해결하는 방법은??

테스트 클래스에 @Transactional을 빼면 된다.

영속성 컨텍스트를 비운다면??

"영속성 컨텍스트를 비우고, findById() 를 하면 DB에서 조회해서 가져오게 되지 않을까??"라는 생각도 했다.

그래서 코드에 EntityManager를 주입받게 해서 em.clear() 코드를 넣고 시도해봤지만 안 됐다.

GPT에 질문해 본 결과 다음과 같이 답변을 했다.

결론은 insert 한 부분이 commit 이 되어야 하는데, 테스트 메서드로부터 시작된 트랜잭션을 물고 있어서 commit 되지 않았다.
그래서 @Async를 사용하는 비동기 메서드에서 커밋되지 않은 데이터를 조회할 수 없기 때문에, 데이터 수정이 일어나지 않는 것이다.
아래의 2번 내용에 관련 내용을 덧붙였다.
아래의 GPT 답을 참고해보면 될 것이다.


왜 생각대로 안 되나?

  1. 테스트 클래스에 @Transactional
    테스트가 한 개의 큰 트랜잭션에서 돌아가고, 끝날 때 롤백됩니다.
    registerBook() 호출도 같은 트랜잭션에 참여(Propagation: REQUIRED)하므로 커밋이 안 된 상태예요.
  2. @Async 메서드는 다른 스레드 + 다른 트랜잭션
    modifyBook()는 별도 스레드에서 새 트랜잭션으로 실행됩니다.
    그런데 DB의 기본 격리수준이 READ_COMMITTED라서, 테스트 트랜잭션에서 아직 커밋되지 않은 insert@Async 트랜잭션에서 보이지 않습니다. (flush만으론 소용없고 커밋이 되어야 다른 트랜잭션에서 조회 가능)

잠시 GPT의 답변에 끼어들어본다..
정말이다.
@Async를 통해서 가져오는 메서드에 다음과 같이 로그를 남기도록 했다.

/**  
 * 책의 정보를 업데이트 한다.  
 * @param request 수정 요청 정보  
 * @param id 수정할 책의 Id  
 */
@Async  
@Override  
public void modifyBook(ModifyBookServiceRequest request, Long id) {  
    log.info("book 정보 수정 비즈니스 로직 시작");  
    Book book = bookCommandRepository.findById(id)  
            .orElseThrow(() -> new BusinessException(NOT_FOUND_BOOK));  
    log.info("id = {}", book.getId());  
    log.info("title = {}", book.getTitle());  
    log.info("author = {}", book.getAuthor());  
    log.info("isbn = {}", book.getIsbn());  
    book.modify(request);  
    log.info("book 정보 수정 비즈니스 로직 끝");  
}

테스트 결과를 보면 findById() 메서드 이후에 id, title, 등 필드를 확인하는 로그가 찍히지 않는다.

테스트 코드

@DisplayName("책의 정보를 수정할 수 있다.")  
@Test  
void modifyBookTest() throws InterruptedException {  
    // given  
    RegisterBookServiceRequest registerRequest = RegisterBookServiceRequest.of("리액트를 다루는 기술", "벨로퍼트", "9791160508796");  
    BookResponse bookResponse = bookCommandService.registerBook(registerRequest);  
    ModifyBookServiceRequest modifyRequest = ModifyBookServiceRequest.of("Next.js 뽀개기", "이정환", "hello");  

    // when  
    bookCommandService.modifyBook(modifyRequest, bookResponse.getId());  // 비동기 메서드 시작

    Thread.sleep(1000);  // 스레드 멈춤

    em.clear();  // 영속성 컨텍스트 비움

    System.out.println("select 전");  
    Book findBook = bookCommandRepository.findById(bookResponse.getId()).orElseThrow();  
    System.out.println("select 후");  

    // then  
    assertThat(findBook.getId()).isEqualTo(bookResponse.getId());  
    assertThat(findBook.getTitle()).isEqualTo(modifyRequest.getTitle());  
    assertThat(findBook.getAuthor()).isEqualTo(modifyRequest.getAuthor());  
    assertThat(findBook.getIsbn()).isEqualTo(modifyRequest.getIsbn());  
}

아래는 테스트 결과다

Hibernate: insert into book (author,isbn,title,id) values (?,?,?,default)
2025-09-02T22:53:23.709+09:00  INFO 9744 --- [book-village-refactor] [    test-task-1] s.b.d.b.service.BookCommandServiceImpl   : book 정보 수정 비즈니스 로직 시작
Hibernate: select b1_0.id,b1_0.author,b1_0.isbn,b1_0.title from book b1_0 where b1_0.id=?
select 전
Hibernate: select b1_0.id,b1_0.author,b1_0.isbn,b1_0.title from book b1_0 where b1_0.id=?
select 후
Hibernate: delete from book b1_0

영속성 컨텍스트를 비웠으므로, select 전 / 후로 DB에서 select로 조회해온다.
영속성 컨텍스트는 비워졌지만, 데이터가 수정되어있진 않다.

그리고 modifyBook() 메서드를 보면, id로 Entity를 조회하지 못하면 예외가 터지는데, 분명 안 보이지만, 예외가 터졌을 것이다.

DB 격리 수준이 커밋된 데이터만 볼 수 있는데,
insert 한 데이터가 커밋이 되지 않았으므로, 다른 스레드 + 다른 트랜잭션에서는 해당 데이터를 볼 수 없는 것이다.

  1. em.clear()의 의미 오해
    em.clear()현재 테스트 트랜잭션의 1차 캐시만 비움 → 이후 조회 시 DB를 치게 하려는 의도는 맞아요.
    하지만 다른 트랜잭션에서의 변경(= @Async 커밋)이 끝나 있어야 “바뀐 값”을 읽을 수 있습니다.
    Thread.sleep()는 타이밍을 ‘운’에 맡기는 방법이라 레이스 컨디션이 생깁니다.

방법 B) 중간에 커밋하고 다시 시작하기 (스프링 테스트 유틸)

@Transactional을 유지해야 한다면,

  • 등록까지 한 뒤 지금 트랜잭션을 커밋하고 끝내고,
  • 새 트랜잭션을 시작해서 검증합니다.
import org.springframework.test.context.transaction.TestTransaction;  

@Test void modifyBookTest() {     
    // given     
    var id = bookCommandService.registerBook(req).getId();      
    // 현재 테스트 트랜잭션 커밋 후 종료     

    TestTransaction.flagForCommit();     
    TestTransaction.end();      

    // when - 비동기 수정 트랜잭션 시작/커밋은 서비스 내부     
    bookCommandService.modifyBook(modReq, id);      

    // 검증용 새 트랜잭션 시작     
    TestTransaction.start();      

    // polling으로 변경 완료 대기 (sleep 대신)     
    await().untilAsserted(() -> {         
        em.clear();         
        var b = bookCommandRepository.findById(id).orElseThrow();        
        assertThat(b.getTitle()).isEqualTo(modReq.getTitle());     
    }); 
}

await()[Awaitility] 같은 폴링 라이브러리 예시입니다. 없다면 간단한 루프+짧은 sleep으로 대체해도 됩니다.


처음엔 테스트 클래스에 @Transacional을 넣어야 하나 말아야하나 고민을 했지만, 현재는 내 나름의 기준을 정했다.

내 기준은 일단 트랜잭션의 변화가 있는지 없는지에 따라 붙일 것이고, 변화가 발생하는 비즈니스가 있다면 붙이지 않는 것이 좋다고 현재 생각했다.
물론 이 생각은 시간이 지나서 달라질 수도 있다.

향로님의 테스트 클래스에 @Transactional을 붙여야할지 말아야할지에 대한 생각을 적은 글이 있는데, 그 글을 보면서 좀 정해진거 같다.
참고로 향로님은 반대하는 쪽이다.
하지만 그 글에는 영한님과 토비님의 생각도 담겨있는데, 두 분은 @Transactioanal을 붙이자는 생각을 가지고 계신다.
그 글에는 스프링팀의 코드도 있는데 스프링팀도 역시 영한님과 토비님과 마찬가지로 붙여져있는 것을 확인할 수 있다.

그 글의 링크다. : https://jojoldu.tistory.com/761

Thread.sleep(1000) 을 제외했을 때

이 부분은 아마 성능차이일 수도 있는데,
변경감지로 인해서 데이터가 수정되기 전에, 일찍이 findById() 메서드를 호출했기 때문이다.
그래서 별개의 트랜잭션에서 수정될 시간을 기다려주고 findById() 를 하면 데이터가 수정된 것을 확인할 수 있다.

참고로 이 때는 테스트 클래스에 @Transactional을 제외해야한다.

2-4. 내용 정리

트랜잭션(@Transactional)

  • 테스트 클래스에 붙으면 매 테스트가 끝날 때마다 데이터를 rollback한다.
  • 하지만, 트랜잭션의 변화나 혹은 경계를 주의하면서 붙일지 말지 고민이 필요하겠다.

@Async

  • 다른 스레드 + 다른 트랜잭션이 시작되며 메서드가 실행된다.
  • 이 메서드에서 예외가 터져도 트랜잭션이 분리되었기 때문에 해당 메서드를 호출한 호출자에로 이 예외가 전파되지 않는다.
    • 만약 이를 테스트한다면 다음과 같은 방식으로 테스트해야 한다.
    • 따라서 예외가 발생했을 때 예외의 Queue에 예외를 넣어두고, 그 Queue에서 예외를 꺼내와서 예외를 검증해야 한다.

영속성 컨텍스트

  • 트랜잭션과 생명주기가 같다
    • 단 OSIV를 열어주면 얘기는 달라진다.
  • 만약 트랜잭션이 분리되면 그 분리된 트랜잭션과 생명주기를 같이하는 영속성 컨텍스트가 새로 생성된다.
  • 영속성 컨텍스트를 비우더라도 새로운 트랜잭션에서 일어난 데이터 변경을 확인할 수 가 없다.
    • 이 내용이 가능하기 위해서는 새로운 트랜잭션이 일어나기 전 commit을 계속 해주어야 한다.

3. 요약

데이터 수정을 비동기 메서드로 사용하지는 않는다.
비동기 메서드를 사용할 일이 있었기 때문에 코드를 작성하고 테스트 코드를 작성해봤는데, 이 하나로도 많은 내용과 더불어서 트랜잭션, 영속성 컨텍스트, 그리고 GPT의 답변을 통해서 DB 격리 수준에 대한 내용까지 많은 것을 알게 되었다.

이제 비동기 메서드를 테스트할 일이 없으니, 이 프로젝트를 이제 혼자 완성을 하러 가볼것이다.
뿐만 아니라, 비동기로 처리한 데이터 수정 메서드에는 @Async 를 지우고, 코드를 계속해서 작성하기로 하겠다.

책의 정보를 담고, 수정하고, 조회하는 비즈니스 로직인데,,, 트랜잭션의 변화와는 딱히 거리가 먼 부분이다.
그래서 책의 비즈니스 로직을 검증하기 위한 테스트 코드에는 테스트 클래스에 @Transactional을 붙이도록 하겠다.

그나저나 향로님이 글을 정말 잘 정리해두셨다.

다시 링크를 공유한다.

https://jojoldu.tistory.com/761

728x90
Comments