핵심 한 줄
18편까지 쌓은 테스트엔 세 가지 빚이 있었다 — ①
@SpringBootTest는 롤백을 안 해서 한 테스트가 커밋한 데이터가 다음 테스트로 샜고(그래서@BeforeEach deleteAll손청소를 달았고), ②매번 앱 전체를 띄워 무거웠고, ③내가docker compose up으로 손수 띄운 3307 MySQL에 의존해서 그게 없으면 전부 빨간불이었다. 이 셋을@Transactional롤백 →@DataJpaTest슬라이스 → Testcontainers로 한 층씩 걷어냈다. 그리고 그 과정에서@DataJpaTest가 조용히 내 DB를 H2로 바꿔치우려는 지뢰를 밟았다.
시리즈 — 커머스 백엔드를 카타로 쌓다 (계속 연재)
00.카타 서문 · 01.돈을 primitive로 두면 언젠가 샌다 · 02.불변식은 누가 지키나 · 03.동시에 사면 초과판매된다 · 04.두 번 취소해도 한 번만 · 05.save가 INSERT가 아니었다 · 06.기다리게 하지 말고 거절하라 · 07.트랜잭션 안에서 HTTP를 부르지 마라 · 08.응답을 못 받았다는 실패가 아니다 · 09.요청 하나에 쿼리 1+N · 10.5만 행을 1행으로 · 11.같은 걸 두 번 묻지 않기 · 12.가짜는 내가 시킨 답만 한다 · 13.분명히 잡았는데 터졌다 · 14.5개 주세요 했는데 5만 개를 읽었다 · 15.순수하게 만들었더니 저장이 사라졌다 · 16.설정 한 줄 바꿨더니 세 번 터졌다 · 17.두 번 뺐는데 한 번만 빠졌다 · 18.분명히 REQUIRES_NEW인데 같이 사라졌다 · 19.손으로 안 띄워도 되는 테스트 · … 그리고 계속
1. 손청소는 격리가 아니다
18편에서 전파를 실측한 테스트엔 이런 줄이 있었다.
@BeforeEach
void clean() {
auditLogRepository.deleteAll(); // 매번 손으로 치운다
}이게 왜 거기 있었을까? 없이 돌려보면 안다. 두 테스트를 순서를 고정해 놓고, 하나는 로그 3개를 커밋하고, 다른 하나는 “DB가 깨끗하니 count는 0이겠지” 하고 기대하게 했다.
@Test @Order(1)
void test1_writesThreeAndCommits() {
repo.save(new AuditLog("a"));
repo.save(new AuditLog("b"));
repo.save(new AuditLog("c")); // 커밋됨
}
@Test @Order(2)
void test2_expectsFreshDb() {
assertThat(repo.count()).isEqualTo(0); // 격리돼 있다면 0
}결과.
expected: 0L
but was: 3L
test1이 커밋한 3개가 그대로 test2로 샜다. @SpringBootTest는 테스트마다 롤백하지 않는다. 테스트가 쓴 건 진짜로 커밋되고, 다음 테스트가 그걸 본다. 손청소(deleteAll)는 이 오염을 매번 걸레질로 덮는 것일 뿐 — 깜빡하면 새고, 병렬로 돌리면 무너진다. 격리가 아니라 격리인 척이다.
2. 1층 — @Transactional로 롤백 격리
정석은 걸레질이 아니라, 테스트마다 트랜잭션을 열고 끝나면 롤백하는 것이다. 스프링 테스트는 클래스에 @Transactional 한 줄이면 이걸 해준다 — 각 @Test가 끝날 때 커밋 대신 롤백.
@SpringBootTest
@Transactional // ← 테스트마다 롤백
class TestRollbackDemoTest {
@Test void test1_writesTwo() {
baseline = repo.count();
repo.save(new AuditLog("x"));
repo.save(new AuditLog("y"));
assertThat(repo.count()).isEqualTo(baseline + 2); // 내 tx 안에선 보인다
}
@Test void test2_test1WritesRolledBack() {
assertThat(repo.count()).isEqualTo(baseline); // test1의 2개는 롤백돼 사라졌다
}
}test1은 자기 트랜잭션 안에서 baseline + 2를 봤지만, 끝나며 롤백돼 test2는 baseline 그대로를 본다. 손청소 없이 격리가 자동이다.
그런데 여기 함정이 하나 있다. 18편의 전파 테스트는 왜 이 편한 @Transactional을 안 쓰고 굳이 손청소를 했을까? 이유가 이제 명확하다 — 테스트를 @Transactional로 감싸면 테스트 자체가 하나의 트랜잭션이 된다. 그러면 그 안에서 부른 REQUIRES_NEW가 “독립적으로 커밋돼 살아남았다”를 관찰할 수가 없다. 진짜 커밋·롤백 경계가 테스트 트랜잭션에 오염되기 때문이다. 트랜잭션의 동작 그 자체를 검증하는 테스트는, 롤백 격리를 포기하고 진짜 커밋을 봐야 한다. 격리 도구조차 만능이 아니라 트레이드오프다.
3. 2층 — @DataJpaTest 슬라이스, 그리고 H2 지뢰
@SpringBootTest는 앱 전체를 띄운다 — 웹, Redis, 모든 서비스. 리포지토리 하나 테스트하는데 그게 다 필요할 리 없다. @DataJpaTest는 JPA 층만 슬라이스로 띄우고(빠르다), 롤백 격리도 기본으로 내장한다.
그래서 붙였다.
@DataJpaTest
class DataJpaSliceDemoTest { ... }그런데 실행하자마자 터졌다.
Failed to replace DataSource with an embedded database for tests.
If you want an embedded database please put a supported one on the classpath
or tune the replace attribute of @AutoConfigureTestDatabase.
@DataJpaTest는 “테스트는 가벼운 임베디드 DB로 돌려야지” 하고, 내가 설정한 MySQL을 H2로 갈아치우려 한다. 클래스패스에 H2가 없으니 터진 것이다.
여기서 “그럼 H2를 넣어서 고치자”가 진짜 지뢰다. 그러면 테스트는 H2에서 초록불인데, 운영 MySQL에선 방언이 달라 터질 수 있다. 17편에서 본 MySQL 갭 락, ON DUPLICATE KEY, JSON 함수 — 이런 건 H2에 아예 없거나 다르게 동작한다. 테스트가 통과하는데 운영이 터지는 것보다 나쁜 건 없다.
그래서 수리는 반대 방향이다 — 임베디드로 바꾸지 말고 설정된 진짜 MySQL을 그대로 쓰라고 지시한다.
@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE) // 교체 끄기
class DataJpaSliceDemoTest { ... } // → MySQL 3307에서 통과4. 3층 — Testcontainers로 손을 뗀다
이제 격리도 됐고 가벼워졌다. 그런데 아직 하나가 남았다. 이 테스트도 여전히 내가 docker compose up으로 손수 띄운 3307 MySQL에 붙는다. 그걸 안 띄우면 빨간불이고, CI 서버엔 내 노트북 MySQL이 없다. 테스트가 환경에 매여 있다.
Testcontainers가 이 마지막 끈을 끊는다. 테스트가 시작될 때 일회용 진짜 MySQL 컨테이너를 스스로 띄우고, 끝나면 버린다.
@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE)
@Testcontainers
class TestcontainersDemoTest {
@Container
@ServiceConnection // 접속정보를 스프링에 자동 주입
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.4");
@Test
void runsAgainstThrowawayMysql() {
repo.save(new AuditLog("tc"));
assertThat(repo.count()).isEqualTo(1); // 갓 뜬 깨끗한 컨테이너
}
}돌리면 로그가 이렇게 찍힌다.
Container mysql:8.4 started in PT5.014864S
>>> JDBC URL = jdbc:mysql://localhost:57961/test
포트가 57961 — 3307이 아니다. 내가 손 댄 적 없는, 랜덤 포트의 일회용 컨테이너다. 핵심은 @ServiceConnection이다. 이 애노테이션이 방금 뜬 컨테이너의 JDBC URL·계정을 스프링 DataSource로 자동 주입해서, 코드엔 접속 정보를 한 줄도 안 썼다. (같이 뜨는 ryuk이라는 작은 컨테이너는, 테스트가 도중에 죽어도 띄운 컨테이너를 반드시 청소해주는 지킴이다.)
이제 이 테스트는 어디서 돌리든 똑같이 통과한다. 내 노트북이든, 처음 clone한 동료든, CI 서버든 — Docker만 있으면 손으로 아무것도 안 띄워도 된다. 제목 그대로.
5. 세 층은 트레이드오프의 층이기도 하다
빚 셋을 층 셋으로 갚았다.
| 층 | 빚 | 도구 | 트레이드오프 |
|---|---|---|---|
| 격리 | 커밋이 옆 테스트로 샌다 | @Transactional 롤백 | 트랜잭션 자체를 테스트할 땐 못 쓴다 |
| 슬라이스 | 매번 전체 컨텍스트 | @DataJpaTest | H2 교체 지뢰 → replace=NONE |
| 환경독립 | 손 띄운 3307 의존 | Testcontainers | 컨테이너 기동 ~5초 |
각 층에 공짜는 없다. @Transactional은 편하지만 트랜잭션 동작을 못 보고, H2는 빠르지만 거짓 초록불을 주고, Testcontainers는 정확하지만 몇 초를 쓴다. 테스트 격리는 “세우고 끝”이 아니라, 무엇을 검증하느냐에 맞춰 세 다이얼을 조율하는 일이다. 등뼈 한 줄 — 테스트는 자기가 쓰는 것만·격리된 상태로·재현 가능한 환경에서 세운다.
다음
값 하나에서 시작해, 이제 그 값들을 검증하는 테스트의 바닥까지 정리했다. @Transactional 전파(18편)를 실측하려고 손청소를 했던 그 자리가, 이번 편에서 왜 손청소여야 했는지까지 이어졌다 — 두 카타가 한 실로 꿰였다.
바닥을 다졌으니 다시 도메인으로 올라간다. 아직 손 안 댄 벽들 — 반품 기한의 날짜 경계, 할인을 나눌 때 남는 잔돈 1원, 주문 상태를 함부로 못 바꾸게 하는 전이 가드. 기술 함정에서 잠시 벗어나, 비즈니스 규칙이 코드에서 어떻게 무너지는지로.
이 시리즈는 계속 이어진다.
→ 00.카타 서문