요약
- 반복적인 수동테스트로 불편함을 느껴서 테스트 환경을 구축하였습니다
- 구축하고 테스트를 작성하는 과정에서 구성 방식, 데이터 클린업 등에 대한 고민을 하였습니다
- 결과적으로 어떻게 설계하고 테스트를 작성할지 자신만의 기준을 정할 수 있게 되었습니다
수동 테스트의 반복 비용
마이클 페더는 “레거시 코드 활용 전략”에서 레거시 코드를 다음과 같이 정의했다:
“테스트가 없는 코드는 곧 레거시 코드다”
테스트 코드가 없는 조직은 신기능 개발이나 이슈로 인한 코드 수정이 발생하면 일부 기능에 대한 수동 테스트를 진행하게 된다. 예를 들어, 연차를 생성하는 옵션에 요구사항이 바뀌면 연차 생성을 위한 옵션 설정을 하고 여러 직원, 근무형태 케이스에 대해 모두 테스트를 진행하게 된다. 아무래도 다양한 경우의 수를 확인하기 어렵기 때문에 숨어있는 모든 버그를 찾아내기란 쉽지 않았다.
나중에 고객으로부터 이슈 리포트가 들어와 수정하고, 핫픽스로 내보내기 전에 아래와 같은 루프를 반복하게 된 경험이 많다.

테스트 코드가 없는 시스템에 우려되는 점은 “버그 발생으로 인한 B2B 제품의 신뢰성 하락”, “기능 수정마다 최소 3명의 개발자가 수동 테스트를 하면서 소모되는 리소스” 등. 모두 서비스를 제공하는 회사입장에서 큰 금전적인 손실로 이어진다는 것을 인지할 수밖에 없었다.
내린 결론은 테스트 환경을 구축해서 팀원들이 테스트 코드를 작성할 수 있게 하고, 작성된 테스트 코드로 테스트 자동화하여 개발 효율을 높여 발생한 시간을 더 의미있는 일(아키텍처, 도메인)에 집중할 수 있게 하고자 하였다.
H2 기반의 테스트와 Testcontainers
내가 입사하기 전 부터 이미 Redis, Kafka 등의 시스템들이 구축되어 있었는데, 서버의 상태(state)의 대부분은 RDB에 의존하고 있기 때문에 RDB에 대한 통합테스트 환경구축을 먼저 고민하게 되었다. 선택지는 두 가지였는데:
- 인메모리를 이용한 방법
- 컨테이너 환경을 이용한 방법
인메모리를 이용한 통합 테스트
처음에는 H2 인메모리DB를 이용한 통합 테스트 환경을 고려하였다. 개발을 학습하면서 배운 가장 익숙한 방법이었으며, 인메모리를 사용하기 때문에 빠른 테스트 피드백을 받을 수 있지 않을까 하는데 결정이었다.
추가로 고려할 사항이 있었다. JPA를 하지 않기 때문에 테이블 스키마 관리가 Entity 클래스로 이뤄지지 않고 있었다. 대신 프로젝트 내에서는 Liquibase로 데이터베이스 스키마 변경 및 버전 관리에 사용되어 왔다. 오랜 시간 사용되다 보니 많은 sql 파일들이 쌓여있었지만 실제로 배포과정에서 문제가 생겨 롤백을 하는 이슈는 발생한 적이 없었다.
스키마 변경이력을 H2에 밀어넣으려고 하니 에러가 발생하였는데, 원인은 MySQL 특유의 문법이 원인이었고 H2 Dialect를 MySQL 호환하도록 설정하여도 해결되지 않았다. 스키마 이력들을 제거하기에는 이미 레거시도 고착화된 방식을 우리 모듈만 바꾸기에는 어려울 것으로 보였고 다른 방법을 찾아보기로 결정하였다.
컨테이너 환경을 이용한 방법 (feat. Testcontainers)

위에 언급한 이슈와 테스트 독립성 그리고 실제 프로덕션 환경 재현을 고려하여 컨테이너를 띄워 MySQL을 실행하는 방법을 고려하였다. 아무래도 프로덕션 환경 및 개발환경도 컨테이너에서 구동되기 때문에 동일한 환경을 구축할 수 있을것이라는 기대감을 가졌다. 위에서 언급한 문법 에러 자체가 실제 프로덕션 환경을 고려하지 않은 H2를 사용하였기 때문에 발생한 결과가 아닐까 생각되기도 한다. 빠르게 실패해서 다행이라고 생각.
아무튼 직접 도커를 구성할 수 있지만, 오픈소스 라이브러리인 Testcontainers를 이용하기로 결정했다. 결정하는데 여러 이유가 있겠지만, 직접 구성하는 과정에서 발생한 여러 문제들을 해결하기 위해 탄생한 라이브러리라는 점과 이미 구축된 생태계가 있어서 이것을 고민할 시간에 더 중요한 일들에 고민하기로 결정하였다. 무엇보다 좋았던 점은 환경 구성을 Java 코드로 명시적으로 구현할 수 있다는 점이었다.
@ActiveProfiles("test")
@SpringBootTest
public class AbstractIntegrationTest {
static MySQLContainer<?> mySqlContainer = new MySQLContainer<>(DockerImageName.parse("mysql:8.0"))
.withDatabaseName("name")
.withUsername("root")
.withPassword("password")
.withReuse(true);
static {
mySqlContainer.start();
}
@DynamicPropertySource
static void mySqlProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.live.jdbc-url", mySqlContainer::getJdbcUrl);
registry.add("spring.datasource.live.username", mySqlContainer::getUsername);
registry.add("spring.datasource.live.password", mySqlContainer::getPassword);
registry.add("spring.liquibase.url", mySqlContainer::getJdbcUrl);
registry.add("spring.liquibase.user", mySqlContainer::getUsername);
registry.add("spring.liquibase.password", mySqlContainer::getPassword);
registry.add("spring.liquibase.change-log", () -> "classpath:/db/config/db.changelog-local.yaml");
}
}
위 예제 코드를 제외한 구체적인 구현 방법은 공식문서에 잘 나와있어서 여기서 서술하지 않으려고 한다. RDB 뿐만 아니라 Redis와 Kafka에 대한 컨테이너 설정도 매우 쉽게 할 수 있으며 테스트 실행시 랜덤 포트로 구성되고 라이프 사이클을 구성할 수 있게 해 준다.
트랜잭션을 이용한 클린업과 DB 초기화를 이용한 클린업
테스트 간의 독립성을 위한 방법으로 Transactional을 어노테이션을 이용한 방법과 DB 초기화를 이용한 클린업이 있다. 이것에 대한 논쟁은 사실상 꽤 오랜시간 이뤄진것 같은데, 둘 다 사용하며 상황에 따라 효율적인 방법을 택하면 될 것 같다.
하지만 Transactional을 이용한 방법에는 치명적인 단점이 있다. 테스트환경을 구축하고 PoC로 Transactional을 이용한 클린업을 처음 시도했지만 쿼리가 예상과 다르게 동작하는 이슈가 발생하였다.
쿼리 로그를 통해 파악한 내용은 같은 세션이었지만 서비스 로직순서와 다르게 쿼리가 발생하였고 내부적으로 예상치 못한 트랜잭션 경계가 설정되는 경우에 발생하게 되었다. 원래 로직을 수정하고 Transactional을 고수하기에는 좋지 않은 생각으로 판단해서 DB 초기화를 이용한 클린업을 진행하였다.
@Component
public class DatabaseCleanupUtil {
private final JdbcTemplate jdbcTemplate;
private List<String> tableNames;
@Autowired
public DatabaseCleanupUtil(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@PostConstruct
public void init() {
tableNames = jdbcTemplate.queryForList(
"SELECT table_name FROM information_schema.tables WHERE table_schema = DATABASE()",
String.class
);
}
public void cleanUpAll() {
jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 0");
for (String tableName : tableNames) {
if (tableName.equals("DATABASECHANGELOG")) {
continue;
}
jdbcTemplate.execute("TRUNCATE TABLE `" + tableName + "`");
}
jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 1");
}
}
구현 방식은 여러 가지가 있지만 고려해야 할 점은 두 가지였다:
- FK가 설정되어 있는가?
- 삭제되지 말아야 할 테이블이 있는가?
FK로 인한 constraint은 분명 에러를 발생할 것이고, Liquibase 이력과 같이 삭제되지 말아야 할 테이블이 분명 존재할 것이다. 이에 대한 처리도 같이 고려해 주면 좋을 것 같다.
통합 테스트는 느리다, 그 다음은?

테스트 환경을 성공적으로 구축해서 테스트 코드를 작성하고 있고, 테스트 간 독립성을 지킬 수 있었지만 한 가지 문제점이 있었다. DB를 이용한 통합 테스트는 느리고 오래 걸린다. 아래와 같은 보틀넥이 존재했는데 :
- 컨테이너를 띄우는 데 걸리는 시간
- Liquibase가 DB 스키마 히스토리를 검사하는 데 걸리는 시간
- 스프링을 띄우는데 걸리는 시간
이에 대한 고민이 굉장히 많았고 해결방법에 대한 설루션을 찾고 있었다. 테스트를 병렬로 수행하는 방식은 테스트 하나의 소모시간을 절대적으로 줄이지는 않을 것이라고 생각했다. 좀 더 근본적인 해결방법이 무엇일까?
예전부터 테스트에 대한 강의와 아키텍처에 대한 공부를 천천히 하고 있었다. 회사 내 프로젝트의 구조 및 테스트 방법에 대한 많은 생각이 책과 강의를 읽고 찾아보게 하였는데, 다음과 같은 리소스들을 읽거나 수강했었다:
- 인프런의 Practical Testing: 실용적인 테스트 가이드 - 현실적인 테스트 작성법과 고려사항을 충실하게 담은 강의였다
- 로버트 C. 마틴의 클린 아키텍처 - 구체적인 방법보다는 원칙과 추상적인 방법으로 세부사항을 나중에 고려할 수 있도록 생각하는 방법을 길러준 책이었다. 두 번 정도 정독 했다
- 인프런의 Java/Spring 테스트를 추가하고 싶은 개발자들의 오답노트 - 여기서 생각이 바뀌게 되었다
위에 언급한 마지막 강의를 학습하면서 얻은 지식은 내가 하고자 했던 방향과 유사했다. 팀 내 테스트 환경을 구축했지만 테스트 커버리지를 높이는 데는 상당히 오랜 시간이 걸리고 있었다. 테스트보다는 구조적인 문제를 해결하기 위해, 더 쉬운 테스트를 작성하기 위한 고민과 리팩토링을 진행하고 있었기 때문이다. 물론 테스트가 작성된 상태에서 진행하면 더 좋았겠지만.
그 강의에서 제시한 테스트 피라미드의 개념과 클린아키텍처 그리고 도메인과 같은 키워드들은 어떤 방향으로 개발을 하고 테스트를 해야 할지 방향성을 제공해 주었다.

구글식 테스트 피라미드에서는 테스트 피라미드를 다음과 같이 정의를 했다:
- small - 한 프로세스/스레드, 디스크/네트워크 I/O 금지, 슬립 금지 (단위 테스트)
- Medium - 한 머신 안에서 여러 프로세스, 로컬 네트워크 허용 (좁은 통합-API)
- Large - 머신 제한 없음, 원격 리소스 사용 가능 (시스템/E2E)
원래의 테스트 피라미드는 굉장히 추상적인 개념이었지만, 하드웨어와 메트릭을 구체적으로 정해서 그 구분선을 긋게 해 주었다. 유닛테스트를 하나의 스레드와 I/O 관점에서 정의를 내려주는것을 보고 역시 구글은 구글하구나 하는 생각이 들었다. 개발자들이 좋아하는 선긋기 아닐까? 관심있는 분들을 위해서 관련해서 “구글 엔지이어는 이렇게 일한다” 책을 읽어보는것을 추천한다.
피드백 루프가 빠른 테스트
내가 해야 할 일은 구조를 개선하고 서비스에 산재되어 있는 로직들을 도메인과 코어 영역으로 내려주는 일들이었다. 도메인으로 분리하고 순수한 자바 객체를 만들어 단위 테스트를 해야 한다. e2e나 통합 테스트를 작성하지 않아도 테스트 커버리지가 낮지 않은 상태를 만들어야 코어가 단단한 서비스라고 한다.

이러한 관점에서 “좋은 테스트란 무엇인가?“에 어느 정도 답변을 할 수 있게 되었다. 좋은 테스트란 “피드백 루프가 빠른 테스트"이다. 수동으로 서버를 실행해서 웹브라우저를 통한 테스트에서 벗어나 테스트 코드를 작성하고 자동화할 수 있는 환경을 구축하는 것이 1번이다.
적절한 경곗값을 설정하여 테스트를 작성하는 것은 기본이며, 빠르게 피드백을 받아 더 나은 애플리케이션으로 수정할 수 있어야 한다고 생각한다. 그러기 위해서는 테스트하기 쉬운 구조로 작성하고 리팩토링 해야 하며, 핵심 로직들을 도메인으로 내려서 단위 테스트의 비중을 높여야 한다.
신입으로 처음 팀에 합류하여 수동 테스트를 진행하는 단계에서 들었던 생각과 관념. 그리고 테스트 환경을 구축했을때의 트러블 슈팅과 고민들, 테스트를 작성하면서 겪은 경험과 앞으로의 설계방향을 고민하는 지금도 정답이 아닐 수 있다. 앞으로 어떤 경험을 통해 새로운 인사이트를 얻게 될 수 있을지 기대가 된다.