테스트 코드 트랜잭션 간 격리

yb__char·2024년 3월 2일
1
post-thumbnail

문제 상황

와 테스트 코드도 간만에 작성해보는데 쉽지 않았다.

JPA를 사용하고 있는 지금 상황에서 @Transactional 을 사용해서 하나의 테스트, 즉 트랜잭션이 끝날 때 데이터가 롤백되고 새로운 트랜잭션이 실행되는 것을 기대하고 있었다.

@WebMvcTest(controllers = MissionController.class)
@AutoConfigureMockMvc(addFilters = false)
@MockBean(JpaMetamodelMappingContext.class)
@ActiveProfiles("test")
class MissionControllerTest {
}

아직도 영속성 컨텍스트에 아직 영속화되는 현상이 발견되었고, 일일히 entityManager의 flush, clear를 사용할 수도 없는 노릇이였다.

    뭔가 잘못됐음을 느낀 나..

뭔가 잘못됐음을 느낀 나..

문제 정리

End-to-End 테스트 환경에서의 롤백과정으로

  1. 테스트 메서드가 스레드 A에서 실행된다.
  2. 테스트 메서드 내의 코드에서 컨트롤러의 쓰기 작업 메서드를 호출한다.
  3. 호출받은 컨트롤러 메서드는 스레드 B에서 실행
  4. 메서드가 끝나면 롤백을 수행하는데,
  5. 트랜잭션 범위는 스레드 A 내로 한정되기에 B에서 아무런 영향을 끼치지 못한다.

결국 트랜잭션 범위 내 A 테스트 후 B 테스트가 DB를 공유하고 예시 케이스로 생성했던 미션같은 경우 롤백이 안되어 리스트 갯수나 Auto-increment 인덱스가 계속 증가되고 있는 것이 보였다. 마냥 Transactional이 답이 아니란 것을 느꼈다.

이 문제를 해결하기 위해 많은 레퍼런스를 찾아보았고, 두 가지 케이스에 대해서 도입을 고려하였다.

1️⃣ DirtiesContext 사용하기

매 테스트마다 Context를 새롭게 띄워서 새로운 테스트 케이스를 만들어 진행하는 방법일텐데 장단점이 명확하다.

장점

컨텍스트를 다시 로드하면서 테스트 간 격리는 깔끔하고 구분을 지어줄 수 있다.

단점

위와 같이 새롭게 Context를 띄운다는 것은 테스트 실행 시간동안 Context를 새로 생성하는 시간도 존재하고 메모리 측면에서 좋지 않았다.

많은 프로젝트에서 매번 테스트의 환경을 초기화 시키기 위해 DirtiesContext를 사용한다. 하지만 DirtiesContext는 스프링 테스트가 매 테스트마다 Application Context를 다시 Load하도록 한다. 이는 스프링 테스트가 제공해주는 Application Context의 캐싱의 이점을 전혀 가져가지 못하고 있다는 뜻이다.

사실 프로젝트 초반에는 큰 문제가 없었으나 프로젝트 규모가 점점 커질수록 테스트코드가 많아지고 CI/CD 과정에서 문제를 야기할 수도 있다.

무려 테스트를 한번 돌리는데 분 단위로 넘어가는 시간이 소요될 수도 있고 실제로 규모가 커지니 실행 시간이 커지고 있다.. 중간중간에 Application Context가 재시작 되는 시간이 누산되지 않아서 실제 시간은 보다 더 큰 시간을 잡아먹고 있었다.

초기 작업할 땐 DirtiesContext를 제거하고 공통으로 사용하는 어노테이션들을 추상클래스에 몰아넣어 테스트 클래스들이 이를 상속받도록 하였다. 하지만 여기서 문제가 발생되는데, Application Context가 공유되다보니 그래서 DB의 초기화 작업이 필요하게 된 것이다.

2️⃣ TRUNCATE 활용 (채택)

왜 이 방법을 채택하였냐, 분명 테스트 환경을 격리시키거나 @DirtiesContext 를 사용하는 것이 방법이겠지만 DB 초기화가 안돼…

리스트에서 분명 1,2,3,4,5 id 검증을 하는데 이전에 진행했던 쓰기기록이 있기에 auto-increment 땜시 7,8,9,10 이렇게 id가 생성되는 경우가 발생한다.

그래서 BeforeEach를 활용과 DB를 Truncate하면 id 값도 초기화 되기에 이 방법을 채택하였다.

@Component
public class DatabaseCleaner implements InitializingBean {

    @PersistenceContext private EntityManager entityManager;
    private List<String> tableNames;

    @Override
    public void afterPropertiesSet() {
        entityManager.unwrap(Session.class).doWork(this::extractTableNames);
    }

    private void extractTableNames(Connection conn) {
        tableNames =
                entityManager.getMetamodel().getEntities().stream()
                        .map(e -> e.getName().replaceAll("([a-z])([A-Z])", "$1_$2").toLowerCase())
                        .collect(Collectors.toList());
    }

    public void execute() {
        entityManager.unwrap(Session.class).doWork(this::cleanUpDatabase);
    }

    private void cleanUpDatabase(Connection conn) throws SQLException {
        Statement statement = conn.createStatement();
        statement.executeUpdate("SET REFERENTIAL_INTEGRITY FALSE");

        for (String tableName : tableNames) {
            statement.executeUpdate("TRUNCATE TABLE " + tableName);
            statement.executeUpdate(
                    "ALTER TABLE "
                            + tableName
                            + " ALTER COLUMN "
                            + tableName
                            + "_id RESTART WITH 1");
        }

        statement.executeUpdate("SET REFERENTIAL_INTEGRITY TRUE");
    }
}
  1. 위 코드는 EntityManager를 사용해 존재하는 DB 테이블 이름을 추출하고 Truncate를 진행한다.

  2. SET REFERENTIAL_INTEGRITY FALSE는 테이블의 제약 조건들을 비활성화하고, 받은 TableNames를 each로 돌려 TRUNCATE TABLE ALTER TABLE ~~ RESTART WITH 1로 id값을 1로 초기화 한다.

  3. 그리고 비활성된 제약조건은 다시 활성화 해야기에 SET REFERENTIAL_INTEGRITY TRUE를 실행해주었다.

OK. 이 코드가 무슨 의미인지 알겠어. 그럼 어떻게 활용할까?

  	@Autowired private DatabaseCleaner databaseCleaner;

 	@BeforeEach
    void setUp() {
        databaseCleaner.execute();
    }

이처럼 BeforeEach에서 각 레이어 테스트 코드에 실행하도록 한다.

이렇게 되면 매번 delete 쿼리같은 반복 작업을 안해도되고, 아무래도 쓰기 쿼리이기에 실행 시간 측면에서 TRUNCATE 명령어 하나로 퉁치고 끝내고 만다.

결과

해당 Topic은 DirtiesContext를 사용하지 않고, 실행을 하였기에 Context를 생성하는 시간을 줄였고, 테스트 코드에서 각각의 격리 환경을 구성하였다. 실제로 DirtiesContext를 사용하여 분 단위로 실행되는 테스트를 1분 채 안되서 실행되는 결과를 확인하였고, 인수 테스트에서도 사용되는 방법이라고도 한다.

해당 이슈에 대한 PR: https://github.com/depromeet/10mm-server/pull/84

profile
안녕하세요 백엔드 개발자 차윤범입니다 :)

0개의 댓글

관련 채용 정보