Spring Boot 4.1 + Testcontainers로 MariaDB 통합 테스트하기: H2를 운영 DB처럼 믿지 말아야 하는 이유
메타 설명: Spring Boot 4.1과 Testcontainers 2.0으로 실제 MariaDB를 띄워 JPA 통합 테스트를 실행하고, @ServiceConnection으로 연결 설정을 자동화합니다.
최종 확인: 2026년 7월 31일
로컬 테스트에서는 모두 통과했는데 운영 MariaDB에서만 SQL 문법 오류가 발생하거나, 대소문자 중복 데이터가 들어가거나, 락 동작이 달라지는 경우가 있습니다.
테스트에서 H2를 사용하고 운영에서는 MariaDB를 사용한다면 충분히 가능한 일입니다. H2의 MySQL 호환 모드는 유용하지만, H2가 MariaDB로 바뀌는 것은 아닙니다. SQL 함수, JSON 처리, collation, 인덱스, 락과 트랜잭션 동작까지 완전히 같다고 볼 수 없습니다.
Testcontainers를 사용하면 테스트가 실행될 때 실제 MariaDB 컨테이너를 띄우고, 종료 시 자동으로 정리할 수 있습니다. Spring Boot 4.1에서는 @ServiceConnection 덕분에 동적 포트와 JDBC URL을 직접 연결할 필요도 없습니다.
이 글에서는 다음 흐름을 구현합니다.
Gradle test
↓
Spring이 MariaDB 컨테이너 시작
↓
@ServiceConnection이 JDBC 연결 정보 생성
↓
@DataJpaTest가 실제 MariaDB로 Repository 검증
↓
ApplicationContext 종료 후 컨테이너 정리
H2 테스트가 잡지 못하는 차이
| 영역 | H2로 생길 수 있는 공백 | 실제 MariaDB 테스트가 확인하는 것 |
|---|---|---|
| SQL 문법 | 호환 모드가 일부 차이를 감춤 | 운영 DB가 실제 SQL을 해석하는지 |
| DDL·마이그레이션 | MariaDB 전용 구문이 누락될 수 있음 | Flyway 스크립트가 실제로 적용되는지 |
| 문자열 비교 | 기본 collation과 대소문자 규칙이 다를 수 있음 | 운영과 같은 collation 동작 |
| JSON | 타입과 함수 지원이 다름 | MariaDB JSON 함수와 제약 |
| 락·트랜잭션 | 동시성 동작이 단순화될 수 있음 | 실제 격리 수준과 DB 락 |
| 인덱스·실행 계획 | 엔진 자체가 다름 | MariaDB 옵티마이저 기준의 쿼리 실행 |
Testcontainers가 운영 데이터 규모와 트래픽까지 재현하는 것은 아닙니다. 그러나 “서로 다른 DB 엔진에서 테스트했다”는 가장 큰 불일치를 제거합니다.
2026년 기준: Testcontainers 2.0 좌표와 패키지가 바뀌었다
Testcontainers 2.0은 모듈 이름 앞에 testcontainers-를 붙였고, 컨테이너 클래스도 모듈별 패키지로 이동했습니다.
MariaDB 기준 차이는 다음과 같습니다.
| 구분 | Testcontainers 1.x | Testcontainers 2.x |
|---|---|---|
| Gradle 좌표 | org.testcontainers:mariadb |
org.testcontainers:testcontainers-mariadb |
| Java 패키지 | org.testcontainers.containers.MariaDBContainer |
org.testcontainers.mariadb.MariaDBContainer |
Spring Boot 4.1.0의 의존성 관리 목록은 Testcontainers 2.0.5를 관리합니다. 따라서 Spring Boot Gradle 플러그인을 사용한다면 아래 테스트 의존성에 Testcontainers 버전을 따로 적지 않아도 됩니다.
Spring Boot 3.x 프로젝트에 2.x 예제를 그대로 복사하지 말고, 해당 Boot 버전이 관리하는 Testcontainers 버전과 좌표를 먼저 확인해야 합니다.
1. 의존성 추가
// build.gradle.kts
dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
runtimeOnly("org.mariadb.jdbc:mariadb-java-client")
testImplementation(
"org.springframework.boot:spring-boot-starter-data-jpa-test"
)
testImplementation(
"org.springframework.boot:spring-boot-testcontainers"
)
testImplementation(
"org.testcontainers:testcontainers-mariadb"
)
}
Spring Boot 4.1은 기능별 테스트 스타터를 제공합니다. JPA 슬라이스 테스트에는 spring-boot-starter-data-jpa-test를 사용했습니다.
spring-boot-testcontainers는 @ServiceConnection 처리를 담당하고, testcontainers-mariadb는 MariaDB 컨테이너 구현을 제공합니다. Testcontainers 모듈만 추가한다고 MariaDB JDBC 드라이버까지 자동으로 들어오는 것은 아니므로 mariadb-java-client도 필요합니다.
2. MariaDB 테스트 설정 작성
컨테이너를 JUnit의 static 필드로 관리할 수도 있지만, Spring Boot 4.1 문서는 ApplicationContext가 컨테이너에 의존한다면 컨테이너를 Spring 빈으로 관리하는 방식을 권장합니다.
package com.example.member;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.testcontainers.mariadb.MariaDBContainer;
@TestConfiguration(proxyBeanMethods = false)
class MariaDbTestConfiguration {
@Bean
@ServiceConnection
MariaDBContainer<?> mariaDbContainer() {
return new MariaDBContainer<>("mariadb:11.8.8")
.withDatabaseName("app")
.withUsername("app")
.withPassword("test-password");
}
}
예제는 재현성을 위해 latest 대신 mariadb:11.8.8을 고정했습니다. 실제 프로젝트에서는 운영 중인 MariaDB 메이저·마이너와 테스트 이미지를 맞추는 것이 중요합니다.
Spring 빈으로 관리하면 다음 순서가 보장됩니다.
- 다른 애플리케이션 빈보다 컨테이너를 먼저 시작합니다.
- 컨테이너에 의존하는 빈을 먼저 종료합니다.
- 마지막에 컨테이너를 정리합니다.
ApplicationContext 캐시가 여러 테스트 클래스에서 재사용될 때 컨테이너가 먼저 종료되는 문제도 줄일 수 있습니다.
3. @ServiceConnection이 하는 일
과거에는 컨테이너가 할당한 JDBC URL과 포트를 @DynamicPropertySource로 직접 전달하는 코드가 필요했습니다.
// 이전에 자주 사용하던 방식
@DynamicPropertySource
static void databaseProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", mariadb::getJdbcUrl);
registry.add("spring.datasource.username", mariadb::getUsername);
registry.add("spring.datasource.password", mariadb::getPassword);
}
@ServiceConnection을 사용하면 Spring Boot가 컨테이너에서 연결 정보를 읽어 ConnectionDetails 빈을 만듭니다. 이 연결 정보는 spring.datasource.* 같은 일반 설정값보다 우선합니다.
MariaDBContainer는 JDBC 데이터베이스 컨테이너이므로 Spring Boot가 다음 연결 정보도 만들 수 있습니다.
JdbcConnectionDetailsFlywayConnectionDetails- 조건에 따라
LiquibaseConnectionDetails
따라서 동적 포트를 직접 YAML에 적거나 테스트 전용 JDBC URL을 조합할 필요가 없습니다.
4. 테스트 대상 Entity와 Repository
package com.example.member;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
@Entity
@Table(
name = "member",
uniqueConstraints = @UniqueConstraint(
name = "uk_member_email",
columnNames = "email"
)
)
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 100)
private String email;
protected Member() {
}
public Member(String email) {
this.email = email;
}
public Long getId() {
return id;
}
public String getEmail() {
return email;
}
}
package com.example.member;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface MemberRepository extends JpaRepository<Member, Long> {
Optional<Member> findByEmail(String email);
}
5. @DataJpaTest로 실제 MariaDB 검증
package com.example.member;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.context.annotation.Import;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.jdbc.core.JdbcTemplate;
@DataJpaTest(
properties = "spring.jpa.hibernate.ddl-auto=create-drop"
)
@Import(MariaDbTestConfiguration.class)
class MemberRepositoryTest {
@Autowired
private MemberRepository memberRepository;
@Autowired
private JdbcTemplate jdbcTemplate;
@Test
void 실제_MariaDB에서_실행된다() {
String version = jdbcTemplate.queryForObject(
"select version()",
String.class
);
assertThat(version).startsWith("11.8.");
}
@Test
void 이메일_중복을_DB_제약조건이_차단한다() {
memberRepository.saveAndFlush(
new Member("steve@example.com")
);
assertThatThrownBy(() ->
memberRepository.saveAndFlush(
new Member("steve@example.com")
)
).isInstanceOf(DataIntegrityViolationException.class);
}
}
첫 번째 테스트는 테스트가 H2가 아니라 MariaDB 11.8에서 실행됐다는 사실을 확인합니다.
두 번째 테스트에서는 save()가 아니라 saveAndFlush()를 사용했습니다. JPA는 SQL 실행을 flush 시점까지 미룰 수 있기 때문에, DB 제약조건 위반을 테스트 메서드 안에서 확실히 발생시키려면 즉시 flush해야 합니다.
Spring Boot 4.1의 @DataJpaTest 패키지는 다음과 같습니다.
org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest
Spring Boot 3.x 예제에서 사용하던 org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest를 그대로 import하면 안 됩니다.
또한 Spring Boot 4.1의 테스트 DB 교체 기본값은 NON_TEST입니다. @ServiceConnection이 붙은 Testcontainers 연결은 테스트 DB로 인식하므로, 예전 예제처럼 @AutoConfigureTestDatabase(replace = NONE)를 추가하지 않아도 MariaDB 연결을 유지합니다.
운영 프로젝트에서는 Flyway까지 함께 검증
위 예제는 코드를 짧게 보여 주기 위해 ddl-auto=create-drop을 사용했습니다. 운영 애플리케이션에서는 보통 다음 조합이 더 안전합니다.
spring:
jpa:
hibernate:
ddl-auto: validate
- 테이블 생성과 변경: Flyway
- Entity와 실제 스키마 일치 여부: Hibernate
validate - 테스트 DB: Testcontainers MariaDB
@ServiceConnection은 Flyway 연결 정보도 제공할 수 있으므로, 운영에서 사용하는 db/migration SQL을 실제 MariaDB에 그대로 적용해 볼 수 있습니다.
이렇게 구성하면 테스트 시작 단계에서 다음 오류를 잡습니다.
- MariaDB가 이해하지 못하는 DDL
- 잘못된 Flyway 파일 순서
- 이미 적용된 마이그레이션의 체크섬 불일치
- Entity 필드와 실제 컬럼의 불일치
- 인덱스·제약조건 이름 충돌
테스트용으로 별도의 축약 스키마를 만들지 말고, 가능한 한 운영과 같은 마이그레이션 파일을 사용해야 합니다.
@DataJpaTest와 @SpringBootTest의 선택
| 테스트 목적 | 권장 방식 |
|---|---|
| 순수 도메인 규칙 | DB 없는 단위 테스트 |
| Entity 매핑, Repository 쿼리, DB 제약조건 | @DataJpaTest + MariaDB |
| Controller부터 DB까지 전체 흐름 | @SpringBootTest + MariaDB |
| 락·동시성 | 별도 다중 스레드 통합 테스트 |
| 실행 계획·성능 | 운영과 비슷한 데이터 규모의 별도 성능 테스트 |
모든 테스트를 무거운 @SpringBootTest로 만들 필요는 없습니다. Repository 문제를 검증할 때는 JPA 관련 빈만 로드하는 @DataJpaTest가 더 빠르고 실패 원인도 분명합니다.
CI에서는 별도 공유 DB가 필요 없다
CI 실행 환경에 Docker 호환 컨테이너 런타임이 있다면 기본 명령은 그대로입니다.
./gradlew test
Testcontainers가 테스트마다 필요한 MariaDB를 만들기 때문에, CI 설정에 장기간 유지되는 공용 테스트 DB 계정과 비밀번호를 둘 필요가 없습니다.
공유 테스트 DB보다 격리된 컨테이너가 나은 이유는 다음과 같습니다.
- 다른 브랜치의 테스트 데이터와 충돌하지 않습니다.
- 테스트 순서에 따라 결과가 달라질 가능성이 줄어듭니다.
- 스키마가 매번 깨끗한 상태에서 시작합니다.
- 개발자 로컬과 CI가 같은 방식으로 실행됩니다.
다만 Docker를 사용할 수 없는 제한된 러너에서는 Testcontainers도 실행할 수 없습니다. CI 도입 전에 컨테이너 런타임 접근 권한부터 확인해야 합니다.
테스트 시간을 줄이는 방법
실제 DB 컨테이너는 H2보다 시작 시간이 더 듭니다. 그렇다고 테스트 정확성을 버릴 필요는 없습니다.
컨테이너를 Spring 빈으로 관리합니다.
같은 ApplicationContext를 사용하는 테스트가 컨테이너를 공유할 수 있습니다.ApplicationContext를 불필요하게 깨지 않습니다.
습관적으로@DirtiesContext를 붙이면 컨테이너와 컨텍스트가 반복 생성될 수 있습니다.테스트 슬라이스를 사용합니다.
Repository 테스트는@DataJpaTest로 필요한 범위만 로드합니다.단위 테스트와 통합 테스트를 구분합니다.
문자열 계산이나 도메인 규칙까지 모두 DB 테스트로 만들지 않습니다.이미지 태그를 고정합니다.
latest가 갑자기 바뀌어 이미지 다운로드와 테스트 결과가 흔들리는 일을 막습니다.
자주 하는 실수
- Testcontainers 2.x에서 예전
org.testcontainers:mariadb좌표를 사용합니다. - 패키지가 이동했는데
org.testcontainers.containers.MariaDBContainer를 계속 import합니다. spring-boot-testcontainers를 빼고@ServiceConnection만 붙입니다.- MariaDB JDBC 드라이버를 추가하지 않습니다.
mariadb:latest를 사용해 빌드 재현성을 잃습니다.- 운영은 Flyway인데 테스트에서는 Hibernate가 별도 스키마를 자동 생성합니다.
- 제약조건 예외 테스트에서 flush하지 않아 테스트 메서드 밖에서 오류가 발생합니다.
- 실제 DB를 사용했다는 이유만으로 인덱스와 성능까지 검증됐다고 생각합니다.
- 모든 테스트를
@SpringBootTest로 만들어 피드백 속도를 크게 낮춥니다.
완료 기준
- 테스트 로그와
select version()결과가 MariaDB임을 확인한다. - Testcontainers 2.x의 새 모듈 좌표와 패키지를 사용한다.
-
@ServiceConnection으로 JDBC 연결 정보를 자동 주입한다. - 운영과 동일한 MariaDB 메이저·마이너 이미지를 고정한다.
- 운영 Flyway 마이그레이션을 테스트에서도 실행한다.
- JPA 매핑과 스키마 차이를
ddl-auto=validate로 검사한다. - DB 제약조건 테스트에서는 필요한 시점에 flush한다.
- Docker 호환 런타임이 있는 CI에서
./gradlew test가 통과한다. - 단위 테스트, Repository 통합 테스트, 성능 테스트의 책임을 구분한다.
핵심 요약
- H2 호환 모드는 MariaDB 자체가 아니므로 운영 DB 차이를 모두 잡을 수 없습니다.
- Testcontainers는 테스트마다 실제 MariaDB를 격리된 컨테이너로 실행합니다.
- Spring Boot 4.1의
@ServiceConnection은 동적 JDBC URL과 인증 정보를 자동으로 연결합니다. - Testcontainers 2.x에서는 MariaDB 모듈 좌표와 Java 패키지가 변경됐습니다.
- 운영에서 Flyway를 사용한다면 테스트에서도 같은 마이그레이션을 실제 MariaDB에 적용해야 합니다.
- 실제 DB 통합 테스트와 운영 규모 성능 테스트는 서로 다른 검증입니다.
함께 보면 좋은 글
- Flyway로 끝내는 Spring Boot DB 마이그레이션 실전 가이드
- Spring Boot JPA N+1 문제 해결하기
- OFFSET 페이지네이션이 느려지는 이유: MariaDB 커서 방식과 Spring Data JPA 구현
공식 자료
'Programming > Database' 카테고리의 다른 글
| MariaDB 트랜잭션 격리수준과 데드락 원인 진단법 (0) | 2026.08.07 |
|---|---|
| JPA 대량 INSERT 성능 개선: Hibernate JDBC 배치와 flush·clear 설정 (0) | 2026.08.04 |
| OFFSET 페이지네이션이 느려지는 이유: MariaDB 커서 방식과 Spring Data JPA 구현 (0) | 2026.07.29 |
| 낙관적 락 vs 비관적 락, 재고 차감에서 뭘 써야 하나 (0) | 2026.07.23 |
| 인덱스를 타지 않는 쿼리, 원인과 점검 (0) | 2026.07.16 |