통합 테스트로 모듈 경계와 데이터 흐름 검증하기
통합 테스트의 범위와 상향식·하향식·혼합 전략, 격리 환경과 트랜잭션 검증, Testcontainers 기반 DB 테스트를 정리한다.
2026-08-14 · 최초 발행 2025-12-17
모듈 경계에서 확인해야 할 것
통합 테스트는 서로 다른 모듈이나 컴포넌트가 만나는 지점에서 인터페이스, 계약, 데이터 흐름이 정확한지 확인하는 활동이다. 실제 인프라 또는 그에 가까운 환경을 포함해 시스템 경계를 넘는 상호작용을 검증한다.
단위 테스트보다 검증 범위가 넓고, E2E 테스트보다는 빠르게 피드백을 받아 원인을 좁히는 데 초점이 있다. 데이터 일관성, 트랜잭션 경계, 오류 전파와 함께 재시도·타임아웃·백오프 동작도 대상이 된다. 마이크로서비스, 메시지 브로커, 데이터베이스처럼 외부 시스템과 연결되는 부분의 품질을 배포 전에 확인할 때 유용하다.
프로젝트의 구조, 리스크가 집중된 위치, 인력과 인프라 제약에 따라 상향식(Bottom-Up), 하향식(Top-Down), 혼합(Sandwich) 전략을 택할 수 있다.
재현 가능한 통합 환경을 만드는 조건
테스트는 비즈니스 시나리오의 최소 경로를 중심으로 구성하고, 외부 의존성은 필요한 범위에서만 실제 환경으로 기동한다. 데이터는 픽스처를 재사용하되 시드, 스냅샷, 마이그레이션 기반 초기화 방식을 함께 활용할 수 있다.
하향식 검증에는 스텁이나 가짜 서버가 필요하고, 상향식 검증에는 드라이버나 클라이언트가 필요하다. Consumer-Driven Contracts를 적용하면 스키마와 계약 검증을 자동화할 수 있다.
환경은 컨테이너나 에페메럴 인프라로 격리해 실행 간 상태 오염을 막는다. 시계, 랜덤값, 동시성을 제어해 실행 결과를 결정적으로 재현하는 것도 중요하다.
트랜잭션 검증에서는 커밋과 롤백의 경계, 격리 수준, 잠금 경합을 다룬다. 장애 내성을 위해 재시도, 보상 트랜잭션, 멱등성 키가 의도한 대로 작동하는지도 확인한다. 테스트 수명주기는 프로비저닝부터 시딩, 실행, 검증, 정리까지 자동화하고, 결과는 계약·상태·이벤트의 세 축에서 로그·트레이스·메트릭과 함께 확인한다.
리스크 위치에 따라 달라지는 통합 전략
상향식 전략은 하위 모듈부터 검증하며 드라이버가 필요하다. 데이터 계층과 인프라 리스크를 일찍 발견하기 좋지만, UI와 상위 흐름의 확인은 늦어진다. 사용자 가치 관점의 피드백도 뒤로 밀릴 수 있다.
하향식 전략은 상위 모듈부터 검증하고 스텁 또는 서비스 가상화를 사용한다. 사용자 여정과 계약을 확인하기 적합하지만, 하위 리소스의 병목, 락, 격리 문제는 늦게 드러날 수 있다.
혼합 전략은 상·하위 계층을 병렬로 접근한다. 크리티컬 경로의 피드백을 앞당기고 전체 리스크를 균형 있게 다룰 수 있으나, 오케스트레이션 복잡도와 환경 비용이 증가한다.
| 전략 | 테스트 실행 성능 | 확장성 | 일관성(재현성) | 안정성(플레이크율) | 운영 편의(작성/유지) | 요약 |
|---|---|---|---|---|---|---|
| 상향식 | 높음(하위만 기동) | 높음(모듈 독립 병렬화) | 높음(상위 변동 영향 적음) | 높음 | 보통(드라이버 필요) | 인프라 리스크 조기 탐지에 유리 |
| 하향식 | 보통(스텁 응답) | 보통 | 보통(스텁 정확도 의존) | 보통 | 보통(스텁 유지 필요) | 사용자 여정·계약 중심 검증 용이 |
| 혼합 | 보통 | 높음 | 높음 | 높음 | 낮음(오케스트레이션 복잡) | 균형형 접근, 비용·복잡도 증가 |
시나리오 실행부터 결과 확인까지
통합 테스트는 시나리오와 계약 또는 스키마를 고정하고, 초기 데이터 세트를 정하는 데서 시작한다. 이후 컨테이너나 가상화 환경을 기동하고 데이터를 시딩한 뒤 시나리오를 실행한다. 타임아웃, 재시도, 락 경합 같은 장애 조건도 이 단계에서 주입한다.
검증 결과는 계약, 상태, 이벤트에 대한 어서션으로 남긴다. 커밋 또는 롤백 여부를 확인하고, 리포팅과 추적 ID 수집, 자원 정리까지 연결한다.
트랜잭션은 테스트 케이스 단위로 시작과 종료를 구성해 실패 시 롤백을 보장한다. UPDATE나 SELECT FOR UPDATE, Repeatable Read 등의 격리 수준 시나리오로 락과 격리를 검증할 수 있다. 이벤트 기반 최종 일관성과 DB 트랜잭션의 강한 일관성은 선택 기준과 어서션을 분리해 다룬다.
연결 지점별 검증 대상
마이크로서비스 사이에서는 Pact/CDC로 요청과 응답 스키마를 고정하고, 소비자 관점의 테스트 게이트를 둘 수 있다. 회로 차단기, 재시도, 백오프 정책도 함께 검증한다.
데이터베이스와 캐시를 연결할 때는 Testcontainers로 실제 DB와 Redis를 기동해 트랜잭션 경계, 락, 인덱스 계획의 영향을 확인한다. 마이그레이션 후 샘플 데이터를 넣는 방식으로 시딩하고, 테스트가 끝난 뒤 정리도 자동화한다.
Kafka나 RabbitMQ를 포함한 이벤트 흐름에서는 생산자에서 컨슈머로 이어지는 경로, 멱등성 키, 재처리 보장을 점검한다. 지연되거나 중복된 메시지 시나리오로 최종 일관성도 검증할 수 있다.
외부 API는 서비스 가상화를 통해 SLA, 타임아웃, 오류 코드, 스로틀링을 테스트한다. 보안 헤더와 토큰 만료·재발급 흐름 역시 통합 경계에서 확인할 항목이다.
Java와 Testcontainers로 DB 연동 확인하기
전제조건은 Java 17+, JUnit 5, Testcontainers 1.19+, Docker 데몬 가용이다. 아래 예시는 실제 PostgreSQL 컨테이너와 DAO 사이의 인터페이스와 트랜잭션을 검증한다.
// src/test/java/example/UserRepositoryIT.java
package example;
import org.junit.jupiter.api.*;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.junit.jupiter.Container;
import javax.sql.DataSource;
import java.sql.*;
import java.util.Optional;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import static org.junit.jupiter.api.Assertions.*;
@Testcontainers
class UserRepositoryIT {
@Container
static PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine");
static DataSource ds;
static UserRepository repo;
@BeforeAll
static void setUp() throws Exception {
HikariConfig cfg = new HikariConfig();
cfg.setJdbcUrl(POSTGRES.getJdbcUrl());
cfg.setUsername(POSTGRES.getUsername());
cfg.setPassword(POSTGRES.getPassword());
cfg.setMaximumPoolSize(3);
ds = new HikariDataSource(cfg);
repo = new UserRepository(ds);
repo.initSchema();
}
@AfterAll
static void tearDown() throws Exception {
if (ds instanceof HikariDataSource h) h.close();
}
@Test
void shouldSaveAndFindUser_byEmail_withTransaction() throws Exception {
long id = repo.save("alice@example.com", "Alice");
Optional<String> name = repo.findNameByEmail("alice@example.com");
assertTrue(name.isPresent());
assertEquals("Alice", name.get());
assertThrows(SQLException.class, () -> repo.save("alice@example.com", "Dup")); // UNIQUE 위반
}
static class UserRepository {
private final DataSource ds;
UserRepository(DataSource ds) { this.ds = ds; }
void initSchema() throws SQLException {
try (Connection c = ds.getConnection(); Statement st = c.createStatement()) {
st.execute("""
create table if not exists users (
id serial primary key,
email text unique not null,
name text not null
)
""");
}
}
long save(String email, String name) throws SQLException {
try (Connection c = ds.getConnection()) {
c.setAutoCommit(false);
try (PreparedStatement ps = c.prepareStatement(
"insert into users(email, name) values (?, ?) returning id")) {
ps.setString(1, email);
ps.setString(2, name);
try (ResultSet rs = ps.executeQuery()) {
rs.next();
long id = rs.getLong(1);
c.commit();
return id;
}
} catch (SQLException e) {
c.rollback(); // 롤백 검증
throw e;
}
}
}
Optional<String> findNameByEmail(String email) throws SQLException {
try (Connection c = ds.getConnection();
PreparedStatement ps = c.prepareStatement(
"select name from users where email = ?")) {
ps.setString(1, email);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? Optional.of(rs.getString(1)) : Optional.empty();
}
}
}
}
}
이 테스트는 실제 DB 컨테이너를 통해 드라이버, SQL, 트랜잭션, 제약조건의 동작을 확인한다. 실패 경로의 롤백과 고유 제약 위반을 검증해 일관성 정책도 함께 다룬다.
격리 비용과 유지 비용을 관리하는 방식
테스트마다 에페메럴 컨테이너나 네임스페이스를 사용하고 글로벌 상태를 두지 않으면 환경 격리를 강화할 수 있다. 기동 시간은 늘어나지만 병렬화와 레이어드 이미지 캐시로 상쇄할 수 있다.
데이터는 마이그레이션, 시드, 케이스별 추가 데이터로 계층화해 테스트 간 독립성을 확보한다. 초기화 비용은 스냅샷이나 리유즈 전략으로 절충한다.
테스트별 코릴레이션 ID를 주입하고 로그와 트레이스를 수집하면 디버깅이 쉬워진다. 대신 로그량이 증가하므로 샘플링과 로그 레벨 조정이 필요하다.
핵심 경로에는 혼합 전략을 적용하고, 나머지는 상향식 또는 하향식 가운데 비용 대비 효과가 높은 방식을 선택할 수 있다. 이때 오케스트레이션 복잡도와 유지 비용 증가를 함께 고려한다.
배포 전 검증이 줄이는 운영 부담
유사 도입 사례 기반 추정으로 배포 후 통합 이슈는 3050% 감소 가능하다. E2E를 대체하거나 보완하면 피드백 시간은 2040% 개선될 수 있다.
트랜잭션, 락, 타임아웃 동작을 사전에 확인하면 장애 재현과 분석 시간이 줄어들며, MTTR은 15~30% 개선될 수 있다. 핵심 시나리오부터 단계적으로 도입하면서 초기 비용을 관리 가능한 범위로 통제하는 방식이 적합하다.