DB가 2개인 Spring Boot에 Testcontainers 통합 테스트 깔기 — 그리고 커버리지 래칫

seonwoo_jung·4일 전

테스트가 없는 인증 서비스, 그런데 DB가 2개

우리 인증 서비스에는 특수한 제약이 있다. 신규 시스템의 DB와 레거시 시스템의 DB를 동시에 바라본다는 것. 회원이 가입하면 신규 DB에 쓰고, outbox 패턴으로 레거시 DB에 단방향 동기화한다. 즉 EntityManagerFactory가 2개, DataSource가 2개, 트랜잭션 매니저도 2개다.

이 구조에 테스트 코드가 없었다. 로그인 로직을 고칠 때마다 dev에 배포해서 손으로 눌러보는 게 검증의 전부였고, "레거시 동기화가 누락되는" 류의 버그는 늘 운영에서 발견됐다.

이 글은 이 이중 DB 구조에 Testcontainers 기반 통합 테스트를 까는 과정과, 한 번 올린 커버리지가 다시 내려가지 못하게 만드는 래칫(ratchet) 전략의 기록이다.


1. 왜 H2가 아니라 Testcontainers인가

인메모리 H2로 시작할까 고민은 짧았다. 탈락 사유가 명확했기 때문이다.

  1. 방언 차이가 본질적인 위험이다. 동시 가입 레이스를 막는 partial unique index, ON CONFLICT 같은 PostgreSQL 고유 기능을 쓰고 있다. H2에서 통과한 테스트는 이 코드를 검증한 게 아니다.
  2. DB가 2개다. H2 2개를 띄워 멀티 데이터소스를 흉내 내느니, 진짜 PostgreSQL 2개를 띄우는 게 오히려 단순하다.
  3. Testcontainers는 컨테이너를 테스트 프로세스 생명주기에 맞춰 일회용으로 띄우고 버린다. 로컬에 뭔가 설치하거나 정리할 필요가 없다.
// build.gradle
testImplementation 'org.testcontainers:junit-jupiter:1.20.4'
testImplementation 'org.testcontainers:postgresql:1.20.4'
testImplementation 'org.awaitility:awaitility:4.2.2'

PostgreSQL 컨테이너 2개(신규/레거시)를 베이스 클래스에서 띄우고, @DynamicPropertySource로 각 데이터소스의 URL을 주입하면 — 운영과 동일한 토폴로지의 테스트 환경이 로컬에서 ./gradlew test 한 번으로 재현된다.

Awaitility는 outbox 워커처럼 폴링 기반 비동기 동작을 검증할 때 쓴다. "레거시 DB에 N초 안에 동기화 행이 생긴다"를 sleep 없이 선언적으로 기다릴 수 있다.


2. 운영 코드를 테스트 가능하게 — 침습은 최소로

통합 테스트를 띄워보면 바로 부딪히는 문제: 운영용 부수 동작들이 테스트를 오염시킨다. 스케줄러가 돌면서 outbox를 집어가고, 시드 데이터 러너가 계정을 만들어버린다.

운영 코드를 테스트 때문에 뜯어고치는 건 본말전도라, 게이팅만 추가했다. 핵심은 기본값이 활성이라는 점 — 운영 동작은 아무것도 바뀌지 않는다.

@Component
@ConditionalOnProperty(value = "app.scheduling.enabled", havingValue = "true", matchIfMissing = true)
public class OutboxScheduler { ... }

테스트 프로파일에서만 app.scheduling.enabled: false로 끈다. 시드 데이터 러너도 같은 방식.

ddl-auto도 마찬가지 원칙으로 풀었다. 기존엔 none이 하드코딩돼 있었는데, 테스트에서는 컨테이너가 매번 새로 뜨므로 스키마 생성이 필요하다:

// 운영은 none 고정, 테스트에서만 create-drop으로 재정의
@Value("${app.jpa.ddl-auto:none}")
private String ddlAuto;

기본값이 운영값이고, 테스트만 명시적으로 재정의한다. 설정 누락이 사고로 이어질 수 없는 방향으로 기본값을 둔다.


3. 커버리지 게이트 — 측정보다 어려운 건 분모 정의

테스트를 깔았으니 다음은 "이게 후퇴하지 않게" 만들 차례다. JaCoCo로 커버리지 게이트를 걸었는데, 숫자보다 먼저 정한 게 무엇을 분모에서 뺄 것인가였다.

// 커버리지 측정에서 제외할 대상 (분모 오염 방지)
// 원칙: 검증 가치가 없는 코드 + 테스트에서 항상 mock/비활성 처리되는 외부 경계
def jacocoExcludes = [
        '**/dto/**',                      // 요청/응답 record
        '**/Q*',                          // QueryDSL 생성 코드
        '**/*Application*',
        '**/application/oauth/client/**', // 소셜 OAuth HTTP 클라이언트 (테스트에서 mock)
        '**/common/mail/**',              // SMTP (테스트에서 mock)
        '**/common/sms/**',               // SMS 발송 (테스트에서 mock)
        '**/bootstrap/**',                // ApplicationRunner (테스트에서 게이팅 off)
]

제외 원칙은 두 가지다.

  • 검증 가치가 없는 코드: record DTO, QueryDSL 생성 코드. 이걸 분모에 넣으면 커버리지가 실제보다 좋아 보이거나(자동 생성 getter가 커버됨), 나빠 보인다(거대한 Q클래스가 0%).
  • 테스트에서 구조적으로 mock되는 외부 경계: 외부 HTTP 클라이언트, 메일, SMS. 어차피 mock하는 코드를 분모에 두면 "커버리지를 올리려고 mock 호출 테스트를 쓰는" 왜곡된 인센티브가 생긴다.

제외 목록에 주석으로 사유를 강제하는 것도 중요하다. 6개월 뒤 "이거 왜 빠져있지?"의 답이 코드에 있어야 한다.


4. 래칫 전략 — 하한선은 올릴 수만 있다

커버리지 목표를 정하는 흔한 방식은 "80%를 목표로 하자"다. 그리고 대부분 실패한다. 현재가 59%인데 80%를 게이트로 걸면 빌드가 영원히 깨지므로, 게이트를 끄거나 형식적인 테스트가 양산된다.

반대로 접근했다. 현재 측정치 직하에 하한선을 긋고, 단방향으로만 움직이게 한다.

violationRules {
    // 래칫 방식: 현재 측정치 직하로 하한선을 둔다. 내려가면 빌드 실패.
    // 커버리지가 오르면 이 값도 따라 올릴 것 (내리는 변경은 금지).
    // 측정 기준일: 2026-06-07, 전체 59.2%
    rule {
        limit {
            counter = 'LINE'
            value = 'COVEREDRATIO'
            minimum = 0.58
        }
    }
    // 레이어별 하한선 (장기 목표: domain 0.90 / application 0.85)
    rule {
        element = 'PACKAGE'
        includes = ['com.example.auth.domain']   // 도메인 패키지
        ...
    }
}

규칙은 세 줄로 요약된다.

  1. 측정치(59.2%) 바로 아래(0.58)에 하한선을 둔다 — 오늘부터 게이트가 살아있다
  2. 커버리지가 오르면 하한선도 따라 올린다 — PR 리뷰에서 챙긴다
  3. 하한선을 내리는 변경은 금지 — 이게 래칫이다

이 방식의 좋은 점은 심리적 부담이 없다는 것이다. 누구도 "80%까지 채워야 한다"는 빚을 지지 않는다. 대신 "내가 작성한 코드만큼은 테스트와 함께 간다"가 자연스럽게 강제된다. 전체 하한선과 별개로 레이어별 하한선을 둔 것도 같은 맥락 — 도메인 로직의 커버리지가 DTO 변환 코드에 희석되지 않게 한다.


5. 논쟁적인 선택: 테스트 코드를 커밋하지 않는다?

이 서비스에는 특이한 정책이 하나 있다. src/test/가 gitignore 대상이라 테스트 코드 자체는 저장소에 없고, 테스트 인프라(빌드 설정, 게이팅, 커버리지 게이트)만 커밋된다.

부수 효과로 CI에는 테스트 소스가 없으므로 CI 빌드에 Docker가 필요 없다는 장점 아닌 장점이 생기는데… 솔직히 말하면 권장할 만한 구조는 아니다. 테스트가 공유 자산이 아니게 되고, 커버리지 게이트도 로컬에서만 의미를 가진다. 실제로 옆 서비스(MongoDB 기반)에서는 반대로 가서 — .gitignore에서 테스트를 제외 해제하고 단위 27개 + 통합 17개(@DataMongoTest + Testcontainers MongoDB) 테스트를 버전 관리하며 CI에서 매 배포마다 돌리기 시작했다.

조직의 정책과 인프라 제약 사이에서 과도기적 선택이었다고 기록해 둔다. 방향은 명확하다: 테스트도 코드다. 저장소에 있어야 한다.


정리

결정이유
H2 대신 Testcontainers PostgreSQL 2개방언/인덱스까지 검증, 이중 DB 토폴로지 그대로 재현
@ConditionalOnProperty 게이팅 (기본 활성)운영 동작 무변경으로 테스트 격리
ddl-auto 외부화 (기본 none)설정 누락이 사고가 될 수 없는 기본값 방향
분모에서 mock 경계·생성 코드 제외커버리지 수치의 왜곡된 인센티브 차단
래칫 하한선 (현재치 직하, 단방향)빚 없는 게이트 — 오늘부터 살아있고, 후퇴만 막는다

커버리지 80% 같은 거창한 목표보다, "내려가면 빌드가 깨진다"는 단순한 규칙 하나가 코드베이스를 천천히, 그러나 확실하게 좋은 방향으로 민다.

0개의 댓글