모든 글

공유 테스트 DB 없이 신뢰할 수 있는 데이터베이스 테스트 만들기

잡아야 할 실패에 따라 목, 인메모리 DB, Testcontainers, 실제 DB를 고르고 일반 테스트는 공유 인프라에서 격리합니다.

  • testing
  • databases
  • backend

출처 및 AI 안내: 이 글은 제미니의 개발실무 유튜브를 기반으로 작성되었습니다. gpt-5.6-sol 모델을 사용해 생성·편집했습니다.

리포지토리를 통해 데이터를 읽는 컴포넌트를 테스트한다고 해봅시다. 목, 인메모리 데이터베이스, Testcontainers, 어딘가에 이미 떠 있는 실제 DB 중 무엇을 써야 할까요? 제 답은 도구 하나가 아닙니다. 잡고 싶은 실패를 확인할 수 있는 가장 저렴한 테스트부터 쓰고, 그 테스트가 남긴 위험을 더는 받아들이기 어려울 때 현실에 가까운 테스트를 추가합니다.

리포지토리 결과 하나로 판단하는 검증기라면 목으로 충분할 때가 많습니다. 조건마다 정확한 데이터 집합을 넣고 검증기의 정책을 빠르게 확인할 수 있습니다. 이 테스트의 대상은 JPA 쿼리의 조인이 아니라 검증기의 판단입니다.

같은 목으로 쿼리까지 맞다고 증명할 수는 없습니다. 서비스 분기를 전부 테스트해도 배포 후 매핑, 쿼리, 스키마, 데이터베이스 동작 때문에 실패할 수 있습니다. 그 가능성 때문에 배포가 불안하다면 목을 버릴 게 아니라 다음 경계도 테스트해야 합니다.

불확실성에 맞는 테스트를 고릅니다

선택지를 비용과 현실성에 따라 대략 놓아볼 수 있습니다.

목은 비즈니스 판단을 빠르고 정확하게 테스트합니다. 대신 데이터베이스 동작과 가장 멀리 있습니다. “리포지토리가 이 결과를 주면 컴포넌트는 이렇게 판단해야 한다”를 검증할 때 씁니다.

H2 같은 인메모리 데이터베이스는 별도 서버 없이 리포지토리와 Spring 통합 테스트를 실행할 수 있습니다. 연결 설정, JPA의 기본 동작, 간단한 쿼리를 더 많이 확인합니다. 그래도 운영 DB와는 다른 엔진입니다. SQL 문법, 타입, 인덱스 동작, 락, 특정 데이터베이스의 함수가 다를 수 있습니다.

Testcontainers는 선택한 엔진의 일회용 데이터베이스를 테스트 실행에 제공할 수 있습니다. 이미지, 스키마 초기화, 격리 범위와 생명주기는 프로젝트가 설정해야 합니다. H2보다 느리고 무겁지만 운영과 같은 계열의 엔진을 선택하면 H2가 잡지 못하는 호환성 실패를 검증할 수 있습니다.

오래 유지하는 실제 DB 환경은 제한된 의미에서 배포 환경과 가장 가깝습니다. 동시에 공유 상태, 네트워크 가용성, 인증 정보, 정리, 소유권 문제를 들여옵니다. 엔진은 더 현실적인데 테스트 자체는 더 불안정해질 수 있습니다.

모든 테스트가 이 단계를 끝까지 올라가야 하는 것은 아닙니다. 검증기 테스트는 목을 유지하고, 소수의 리포지토리 테스트만 Testcontainers를 써도 됩니다.

공유 테스트 스키마는 잘못된 의존성을 만듭니다

개발 DB에 테스트 스키마를 하나 만들고 CI가 그곳에 접속하게 하고 싶을 수 있습니다. DB가 이미 떠 있으니 테스트도 빨라 보입니다. 관리 비용은 뒤늦게 나타납니다.

개발자 A가 한 스키마를 기준으로 배포를 준비합니다. 개발자 B가 피처 브랜치를 위해 공유 테스트 스키마를 변경합니다. A의 애플리케이션 로직은 바뀌지 않았는데 파이프라인이 깨집니다. 배포 시점에 네트워크가 흔들려 코드에 도달하기도 전에 테스트가 실패할 수도 있습니다. 병렬 작업은 데이터와 정리 과정에서 충돌합니다. 마이그레이션, 오래된 데이터, 권한, 복구를 누가 관리할지도 정해야 합니다.

제품 실패가 아니라 공유 테스트 인프라의 실패입니다. 다른 브랜치 때문에 상태가 바뀌거나 네트워크가 사라지면 실행할 수 없는 필수 검사는 커밋을 막는 좋은 게이트가 아닙니다.

저도 공유 DB 방식을 써 봤지만 관리가 쉽지 않았습니다. 팀의 제약 때문에 선택할 수는 있어도 일반적인 기본값으로 두지는 않겠습니다. 테스트마다 데이터베이스가 필요하다면 해당 실행이 직접 만들고 없앨 수 있는 격리된 DB를 주는 편이 낫습니다.

Testcontainers는 프로젝트가 현재 테스트 실행을 위한 데이터베이스와 시작 스키마를 설정한다면 이 원칙에 맞게 사용할 수 있습니다. 모든 브랜치가 중앙 테스트 DB 한 곳에서 만나는 일 없이 현실적인 엔진을 쓸 수 있습니다. Spring Boot 3.1에서는 Testcontainers 연동도 더 편리해졌지만, 저는 최근 버전을 충분히 사용해 보지 않았으므로 정확한 구성을 단정하지 않겠습니다. 편의 기능이 컨테이너 시작 시간과 리소스 비용까지 없애지는 않습니다.

평범한 테스트 명령은 믿을 수 있어야 합니다

저는 일반 Gradle test 태스크가 와이파이를 꺼도 돌아가는 편을 선호합니다. 비행기 안이든, 네트워크가 불안한 곳이든, 외부 서비스에 장애가 났든 프로젝트의 기본 테스트는 실행할 수 있어야 합니다.

모든 통합 테스트를 가장 빠른 단위 테스트 태스크에 넣으라는 뜻은 아닙니다. 목적에 따라 테스트 묶음을 나눌 수 있습니다. 빠른 테스트는 로컬 변경마다 실행하고, 데이터베이스 통합 테스트는 PR이나 전용 태스크에서 돌립니다. 각 묶음의 계약이 분명하고 관리되지 않는 외부 서비스에 몰래 의존하지 않는 것이 중요합니다.

컨테이너 기반 데이터베이스는 H2보다 무겁고 느립니다. 데이터베이스 엔진을 맞춰 얻는 현실성이 그 비용을 정당화하는 테스트에 사용하고, 모든 테스트의 기본값으로 두지는 않습니다.

승자를 고르지 말고 조합합니다

각 위험에 어느 정도 확신이 필요한지를 물어야 합니다.

목으로 많은 비즈니스 조건을 저렴하게 검증합니다. 기본적인 영속성 연결이 중요하고 엔진 차이를 받아들일 수 있으면 H2를 씁니다. 운영 엔진과 맞아야 하는 쿼리, 매핑, 마이그레이션, 제약, 데이터베이스별 동작은 Testcontainers로 확인합니다. 일회용 환경에서 재현할 수 없는 속성이 있고 팀이 운영 비용을 받아들이는 경우에만 별도로 관리하는 실제 환경을 씁니다.

목 테스트는 통과하지만 DB 버그가 계속 운영에 나간다면 실패한 경계에 통합 테스트를 추가합니다. H2가 운영 엔진 차이를 숨긴다면 그 테스트를 Testcontainers로 옮깁니다. 컨테이너도 의존하는 관리형 DB 기능을 표현하지 못한다면 통제된 실제 환경에서 그 기능을 검증합니다. 단계마다 우리가 실제로 걱정할 이유가 있는 실패에 답해야 합니다.

테스트는 소프트웨어를 바꿀 때의 두려움을 줄입니다. 테스트 자체를 믿을 수 있을 때만 그렇습니다. 빠르지만 엉뚱한 사실을 증명하는 테스트는 약한 보호입니다. 다른 개발자가 공유 스키마를 바꿨다는 이유로 실패하는 현실적인 테스트도 약한 보호입니다.

작은 테스트는 빠르게 유지합니다. 구체적인 위험을 잡는 곳에 현실성을 더합니다. 필수 검사는 공유 인프라에서 격리합니다. 이 균형이 있어야 아무도 완전히 통제하지 못하는 데이터베이스 때문에 모든 커밋이 멈추지 않으면서도 팀이 배포를 신뢰할 수 있습니다.