이 프로젝트의 테스트는 두 계층으로 나뉜다.
처음엔 H2 하나로 충분하다고 생각했다. 그런데 배포를 준비하면서 "지금까지 모든 테스트가 H2에서만 돌았는데, 실제 MySQL에서도 똑같이 동작하는지는 한 번도 확인한 적이 없다"는 사실을 깨달았다. H2를 MODE=MySQL로 띄우면 문법은 비슷하게 흉내 내지만, 실제 MySQL 엔진과 동작이 다른 지점이 분명히 있다.
매칭 엔진 단위 테스트는 규칙 하나하나를 독립적으로 검증한다.
@Test
@DisplayName("단위가 달라도 정규화 후 비교한다 (8000Mbps < 10Gbps → 미달)")
void differentUnits() {
MatchEvaluation evaluation = engine.evaluate(
requirement("처리성능", "네트워크 처리량 10Gbps 이상", "10", "Gbps"),
product(spec("처리성능", "8000", "Mbps")));
assertThat(evaluation.status()).isEqualTo(MatchStatus.UNSATISFIED);
}
API 통합 테스트는 MockMvc로 실제 HTTP 요청/응답을 검증한다. 요구사항 등록 → 제품 등록 → 매칭 실행 → 갭분석 조회까지 한 흐름을 통째로 돈다.
mockMvc.perform(post("/api/v1/bids/{bidId}/match", bidId).param("productId", productId.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.results[2].status").value("미충족")) // 검증 항목 미보유
.andExpect(jsonPath("$.results[3].status").value("확인불가")) // 대응 스펙 없음
.andExpect(jsonPath("$.summary.biddable").value(false));
외부 API 클라이언트 테스트는 MockRestServiceServer로 진짜 네트워크 호출 없이 다양한 응답 형태(배열/래핑/XML 에러/HTTP 오류)를 시뮬레이션한다. 4편에서 다룬 응답 파싱 로직이 여기서 검증된다.
mvn test가 도는 H2 계층과 별도로, *IT(Integration Test) 네이밍으로 MySQL 8 컨테이너 기반 테스트를 분리했다. surefire 플러그인은 *IT 파일을 수집하지 않기 때문에 기본 빌드에는 전혀 영향을 주지 않고, failsafe 플러그인이 별도 Maven 프로필로만 이 테스트를 실행한다.
<profile>
<id>mysql-test</id>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<executions>
<execution>
<goals><goal>integration-test</goal><goal>verify</goal></goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
mvn test # H2, Docker 불필요
mvn verify -Pmysql-test # 위 + MySQL 컨테이너 검증
@Testcontainers와 @ServiceConnection을 쓰면 스프링이 컨테이너 접속 정보를 자동으로 주입해준다. 데이터소스 URL을 손으로 오버라이드할 필요가 없다.
@SpringBootTest
@Testcontainers
class MySqlCompatibilityIT {
@Container
@ServiceConnection
static final MySQLContainer<?> MYSQL = new MySQLContainer<>("mysql:8.0")
.withDatabaseName("rfpmatch")
.withUrlParam("characterEncoding", "UTF-8")
.withUrlParam("serverTimezone", "Asia/Seoul");
}
이 테스트 클래스에서 실제로 확인한 항목들이다.
| 검증 | 왜 H2로는 부족한가 |
|---|---|
ddl-auto=update 스키마 생성 | 운영과 같은 경로로 5개 테이블이 실제로 만들어지는지 |
| 한글 저장·조회 | utf8mb4 문자셋 설정이 실제로 먹는지 |
| 마감일 null 정렬 | nulls last는 MySQL에 없는 구문. Hibernate가 우회 생성해야 하는데 그게 의도대로 나오는지는 실제 엔진에서만 확인된다 |
| 공고번호 유니크 제약 | 제약이 실제로 걸려서 중복 저장을 막는지 |
| 매칭 결과 왕복 | enum이 문자열로 저장되고, 한글 판정 근거가 깨지지 않고 돌아오는지 |
| 목록 집계 쿼리 | group by / distinct 집계가 MySQL에서도 같은 결과를 주는지 |
특히 nulls last 정렬은 H2에서는 네이티브로 지원돼서 조용히 통과하지만, MySQL 8에는 이 구문 자체가 없다. Hibernate가 case when 문으로 우회 변환하는데, 그 변환이 의도한 순서를 만드는지는 실제 MySQL 위에서 돌려보기 전까지는 확신할 수 없는 부분이었다.
@Test
@DisplayName("마감일이 없는 공고는 목록 맨 뒤로 밀린다")
void nullCloseDateSortsLast() {
saveBid("20260900001", "마감 임박 공고", now.plusDays(3));
saveBid("20260900002", "여유 있는 공고", now.plusDays(30));
saveBid("20260900003", "마감일 미정 공고", null);
PageResponse<BidSummaryResponse> page = bidService.search(null, null, PageRequest.of(0, 10));
assertThat(page.content()).extracting(BidSummaryResponse::title)
.containsExactly("여유 있는 공고", "마감 임박 공고", "마감일 미정 공고");
}
MySQL 컨테이너를 매번 띄우면서 개발하면 피드백 루프가 느려진다. 반대로 H2로만 검증하고 배포하면 "로컬에서는 됐는데 운영에서는 안 되는" 전형적인 사고가 난다.
그래서 평소 개발 사이클은 H2로 빠르게 돌리고, 배포 전이나 스키마·쿼리를 바꿨을 때만 MySQL 계층을 돌리는 구조로 나눴다. CI에서도 기본 파이프라인은 H2 테스트만 돌게 하고, MySQL 계층은 별도 프로필로 분리해뒀다. Docker 데몬이 없는 환경(예: 일부 CI 러너나 원격 작업 환경)에서도 기본 빌드가 절대 깨지지 않는다는 게 이 분리의 핵심 이점이다.
단위 정규화(UnitNormalizer) 8건
CC등급 파싱(AssuranceLevel) 5건
항목 매핑(SpecKeyResolver) 5건
판정 규칙(MatchEngine, 4유형) 12건
공고 동기화(BidSyncService) 5건
외부 API 클라이언트 7건
API 통합 테스트 5건
───────────────────────────────────
H2 합계 47건
MySQL 8 컨테이너 (Testcontainers) 6건
다음 글은 이 프로젝트를 공개 저장소로 올리기 위해 무엇을 정리했는지 — 특정 서비스명·제품명 등 식별 정보 제거, Docker화, CI 구성, 그리고 개발 중 마주친 트러블슈팅을 모아서 정리한다.
이 시리즈는 개인 학습 프로젝트 개발 기록입니다. 코드에 등장하는 기관명·제품명은 전부 가상이며, 실제 연동 대상 서비스의 명칭·엔드포인트는 기술적 세부사항 보호를 위해 일반화해서 서술합니다.