들어가며
스프링 애플리케이션을 테스트하다 보면, 예상치 못한 데이터 충돌로 인해 테스트가 실패하는 상황을 경험할 때가 있습니다. 특히, 하나의 스프링 컨텍스트에서 생성된 인메모리 데이터베이스를 여러 테스트가 공유하거나, 싱글톤 컨테이너 패턴으로 생성한 Testcontainers 인스턴스를 여러 테스트에서 함께 사용하는 경우, 테스트 간 데이터가 서로 영향을 주며 의도치 않은 충돌이 발생할 가능성이 높습니다.
이런 문제를 완전히 방지하기 위해, 각 테스트마다 서로 다른 컨텍스트를 사용하거나, 매번 새로운 컨테이너를 띄우는 방법도 있습니다. 하지만, 이 방식은 테스트 실행 속도를 매우 느리게 만들 수 있습니다. 따라서, 테스트 간 완전한 독립성은 유지하기 어렵더라도, 테스트 후 데이터를 정리해 다음 테스트에 영향을 주지 않도록 하는 방식으로 적정선에서 타협할 수 있습니다. 이번 글에서는 스프링 테스트 환경에서 테스트 후 데이터 정리 방법과, 이를 통해 테스트 간 데이터 독립성을 보장하는 방법을 살펴보겠습니다.
개발 환경
현재 제가 개발하고 있는 플러피 서비스를 기준으로 설명하겠습니다.
- Java 21
- Spring Boot 3
- JUnit 5
- Testcontainers
- PostgreSQL
- Valkey(Redis)
DataCleanupExtension 구현
전체 구조
데이터를 지우는 DataCleaner라는 인터페이스가 있습니다. DataCleaner를 구현한 PostgresDatabaseCleaner와 RedisCacheCleaner가 있습니다. 이 두 클래스는 각각 PostgreSQL과 Redis에 있는 데이터를 지우는 역할을 합니다. CompositeDataCleaner는 DataCleaner 빈들을 주입받아서 순차적으로 데이터를 지우는 역할을 합니다.
DataCleanupExtension은 JUnit 5의 AfterEachCallback을 구현한 클래스로, 각 테스트 메서드가 끝난 후 CompositeDataCleaner를 사용해 데이터를 지우는 역할을 합니다. 새로운 Cleaner가 추가될 필요 없이 DataCleaner 인터페이스를 구현한 클래스만 추가하면 됩니다.
DataCleaner 인터페이스
DataCleaner 인터페이스에는 clear() 메서드가 있습니다. clear() 메서드는 데이터를 지우는 역할을 합니다.
public interface DataCleaner {
void clear();
}
PostgresDatabaseCleaner
PostgresDatabaseCleaner는 DataCleaner 인터페이스를 구현한 클래스입니다. PostgreSQL 데이터베이스에서 모든 테이블을 비우는 역할을 합니다. 트랜잭션을 사용하여 데이터베이스의 제약 조건을 미루고, 각 테이블을 TRUNCATE 명령어로 초기화한 후, 다시 제약 조건을 즉시 적용하는 방식입니다.
@Component
public class PostgresDatabaseCleaner implements DataCleaner {
private static final Logger log = LoggerFactory.getLogger(PostgresDatabaseCleaner.class);
@PersistenceContext
private EntityManager em;
@Override
@Transactional
public void clear() {
em.clear();
truncate();
}
private void truncate() {
em.createNativeQuery("SET CONSTRAINTS ALL DEFERRED").executeUpdate();
getTruncateQueries().forEach(query -> em.createNativeQuery(query).executeUpdate());
em.createNativeQuery("SET CONSTRAINTS ALL IMMEDIATE").executeUpdate();
log.info("[PostgresDatabaseCleaner] All tables are truncated.");
}
@SuppressWarnings("unchecked")
private List<String> getTruncateQueries() {
String sql = """
SELECT 'TRUNCATE TABLE ' || tablename || ' RESTART IDENTITY CASCADE;'
FROM pg_tables
WHERE schemaname = 'public'
""";
return em.createNativeQuery(sql).getResultList();
}
}
@PersistenceContext
private EntityManager em;
@Override
@Transactional
public void clear() {
em.clear();
truncate();
}
@PersistenceContext를 통해 EntityManager를 주입받습니다. clear() 메서드에서는 EntityManager의 clear() 메서드를 호출하여 관리 중인 모든 엔티티를 초기화합니다. 즉, 영속성 컨텍스트를 비워서, 이후 실행되는 SQL 쿼리가 더 이상 엔티티를 캐시하지 않도록 합니다.
private void truncate() {
em.createNativeQuery("SET CONSTRAINTS ALL DEFERRED").executeUpdate();
getTruncateQueries().forEach(query -> em.createNativeQuery(query).executeUpdate());
em.createNativeQuery("SET CONSTRAINTS ALL IMMEDIATE").executeUpdate();
log.info("[PostgresDatabaseCleaner] All tables are truncated.");
}
truncate는 행단위로 삭제하는 delete와 달리 테이블 전체를 삭제하는 명령어입니다. 한 번에 삭제하기 때문에 delete보다 빠릅니다. 또한, truncate는 시퀀스를 초기화 시킬 수 있습니다. 각 테이블을 지울 때 제약조건이 걸려있을 경우, 삭제에 실패할 수 있습니다. 따라서 제약 조건을 해제하고, 테이블을 삭제한 후 다시 제약 조건을 적용하는 방식으로 데이터를 지웁니다.
@SuppressWarnings("unchecked")
private List<String> getTruncateQueries() {
String sql = """
SELECT 'TRUNCATE TABLE ' || tablename || ' RESTART IDENTITY CASCADE;'
FROM pg_tables
WHERE schemaname = 'public'
""";
return em.createNativeQuery(sql).getResultList();
}
getTruncateQueries() 메서드는 public 스키마에 있는 모든 테이블을 TRUNCATE 명령어로 초기화하는 쿼리를 생성합니다. 이 쿼리를 실행하여 모든 테이블을 초기화합니다. RESTART IDENTITY는 시퀀스를 초기화하는 옵션입니다. CASCADE는 참조하는 테이블도 함께 삭제하는 옵션입니다.
RedisCacheCleaner
RedisCacheCleaner는 DataCleaner 인터페이스를 구현한 클래스입니다. Redis 캐시 데이터를 지우는 역할을 합니다. RedisTemplate을 사용하여 저장된 데이터를 flushDb() 메서드를 사용하여 삭제합니다.
@Component
public class RedisCacheCleaner implements DataCleaner {
private static final Logger log = LoggerFactory.getLogger(RedisCacheCleaner.class);
@Autowired
private RedisTemplate<?, ?> redisTemplate;
@Override
public void clear() {
clearCache();
}
private void clearCache() {
Objects.requireNonNull(redisTemplate.getConnectionFactory())
.getConnection()
.serverCommands()
.flushDb();
log.info("[RedisCacheCleaner] Redis cache data is cleared.");
}
}
CompositeDataCleaner
CompositeDataCleaner 또한 DataCleaner를 구현한 클래스입니다. CompositeDataCleaner는 DataCleaner 빈들을 주입받아서 순차적으로 데이터를 지우는 역할을 합니다.
@Component
@ActiveProfiles("test")
public class CompositeDataCleaner implements DataCleaner {
private final List<DataCleaner> cleaners;
public CompositeDataCleaner(List<DataCleaner> cleaners) {
this.cleaners = cleaners;
}
@Override
public void clear() {
cleaners.forEach(DataCleaner::clear);
}
}
DataCleanupExtension
DataCleanupExtension은 JUnit 5 확장 기능을 사용하여 각 테스트가 끝난 후 자동으로 데이터를 정리하는 역할을 합니다. AfterEachCallback을 구현하여, 각 테스트 메서드가 끝난 후 CompositeDataCleaner를 호출하여 데이터 정리를 수행합니다.
import org.junit.jupiter.api.extension.AfterEachCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.springframework.test.context.junit.jupiter.SpringExtension;
public class DataCleanupExtension implements AfterEachCallback {
@Override
public void afterEach(ExtensionContext context) {
CompositeDataCleaner dataCleaner = getDataCleaner(context);
dataCleaner.clear();
}
private CompositeDataCleaner getDataCleaner(ExtensionContext context) {
return SpringExtension.getApplicationContext(context)
.getBean(CompositeDataCleaner.class);
}
}
사용 예시
DataCleanupExtension을 사용하려면, 테스트 클래스에 @ExtendWith(DataCleanupExtension.class)를 추가하면 됩니다. 아래의 예시는 추상 클래스인 AbstractIntegrationTest에 DataCleanupExtension을 사용하는 방법을 보여줍니다. 테스트 클래스에서 AbstractIntegrationTest를 상속받아 사용하면, 각 테스트 메서드가 끝난 후 데이터가 정리됩니다.
import org.junit.jupiter.api.extension.ExtendWith;
@SpringBootTest
@ExtendWith(DataCleanupExtension.class) // DataCleanupExtension을 사용합니다.
@ActiveProfiles("test")
// 아래 부분은 Testcontainers 코드로 무시하셔도 됩니다.
@ContextConfiguration(initializers = {
RedisContainerInitializer.class,
PostgreSQLContainerInitializer.class
})
public abstract class AbstractIntegrationTest {
// ... 생략 ...
}
참고 사항
일반적으로 테스트 환경에서 인메모리 데이터베이스인 H2를 사용하므로 H2DatabaseCleaner에 대한 예시도 보겠습니다. H2의 경우 제약 조건을 해제하고 적용하기 위해서 SET REFERENTIAL_INTEGRITY를 사용합니다.
@Component
public class H2DatabaseCleaner implements DataCleaner {
private static final Logger log = LoggerFactory.getLogger(H2DatabaseCleaner.class);
@PersistenceContext
private EntityManager em;
@Override
@Transactional
public void clear() {
em.clear();
truncate();
}
private void truncate() {
em.createNativeQuery("SET REFERENTIAL_INTEGRITY=0").executeUpdate();
getTruncateQueries().forEach(query -> em.createNativeQuery(query).executeUpdate());
em.createNativeQuery("SET REFERENTIAL_INTEGRITY=1").executeUpdate();
log.info("[H2DatabaseCleaner] All tables are truncated.");
}
@SuppressWarnings("unchecked")
private List<String> getTruncateQueries() {
String sql = """
SELECT Concat('TRUNCATE TABLE ', TABLE_NAME, ' RESTART IDENTITY', ';')
FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = 'PUBLIC'
""";
return em.createNativeQuery(sql).getResultList();
}
}
마치며
이번 글에서는 스프링 테스트에서 데이터를 공유하는 경우, 테스트 메서드 이후에 데이터를 정리하는 방법에 대해서 알아보았습니다. JUnit Extension을 만들어서 사용하면 간편하게 테스트 간 독립성을 보장할 수 있습니다. 여러분도 사용해보시길 권장드립니다. 읽어주셔서 감사합니다. 잘못된 부분에 대한 지적은 언제나 환영입니다.
플러피 서비스의 DataCleanupExtension 코드
fluffy/server/src/test/java/com/fluffy/support at 6fdccbb890bfbee258b09cd17a16acc3882a1c45 · alstn113/fluffy
온라인 시험 문제 제작 및 관리 서비스 - 플러피. Contribute to alstn113/fluffy development by creating an account on GitHub.
github.com
'서버' 카테고리의 다른 글
코틀린 기본기 정리 (0) | 2025.03.20 |
---|---|
Presigned URL과 CDN으로 이미지 업로드 & 조회 개선 (0) | 2025.03.07 |
Testcontainers로 실제 서비스와 유사한 환경에서 테스트하기 (1) | 2025.03.02 |
분산락을 이용한 중복 생성 문제 해결 (2) | 2025.02.23 |
로컬에서 AWS Private Subnet에 있는 인스턴스에 접속하는 방법 (0) | 2025.02.13 |