Spring Boot 테스트 완벽 가이드: JUnit5 단위 테스트부터 TestContainers 통합 테스트까지
1. 서론
소프트웨어 개발 수명 주기(SDLC)에서 ‘테스트’가 차지하는 비중은 날이 갈수록 커지고 있습니다. 과거에는 일정에 쫓겨 “기능 구현만 되면 배포”하는 문화가 만연했지만, 이제는 서비스의 복잡도가 증가하고 배포 주기가 짧아지면서 테스트 코드 없는 배포는 곧 재앙을 의미하게 되었습니다. 버그를 발견하는 시점이 늦어질수록, 즉 개발 단계에서 발견하는 것보다 운영 단계에서 발견했을 때 수정 비용이 수십 배에서 수백 배까지 증가한다는 ‘비용 증가의 법칙’은 이미 널리 알려진 사실입니다.
하지만 많은 자바 스프링(Spring Boot) 개발자들이 여전히 테스트 코드 작성을 어려워하거나, 단순히 라인 커버리지(Line Coverage)를 채우기 위한 형식적인 테스트에 그치는 경우가 많습니다. “단위 테스트(Unit Test)만 짜면 되는 것 아닌가?”, “DB 테스트는 로컬 H2로 충분하지 않나?”라는 의문들이 해소되지 않은 채 불안한 코드가 프로덕션으로 나아갑니다.
오늘은 견고한 애플리케이션을 만들기 위한 스프링 부트의 테스트 전략을 밑바닥부터 훑어보려 합니다. 가장 기초가 되는 JUnit 5와 Mockito를 활용한 단위 테스트 작성법부터, 실제 데이터베이스 환경을 완벽하게 모사하여 신뢰도를 극대화하는 TestContainers 기반의 통합 테스트까지, 실무에서 반드시 알아야 할 모범 사례(Best Practices)를 깊이 있게 다뤄보겠습니다. 이 글이 여러분의 코드를 ‘불안한 코드’에서 ‘믿을 수 있는 코드’로 바꿔주는 이정표가 되기를 바랍니다.
2. 본론
1. 테스트의 견고한 기반, JUnit 5와 단위 테스트 (Unit Testing)
단위 테스트는 소프트웨어의 가장 작은 단위(주로 메서드나 클래스)가 의도한 대로 동작하는지 검증하는 과정입니다. 외부 시스템(데이터베이스, 네트워크, 파일 시스템 등)에 의존하지 않고 격리된 상태에서 로직 자체를 검증하기 때문에 실행 속도가 매우 빠르고, 문제 발생 시 원인을 즉시 파악할 수 있다는 강력한 장점이 있습니다.
자바 진영의 표준 테스트 프레임워크인 JUnit 5는 이전 버전인 JUnit 4와 달리 모듈화 된 아키텍처(Jupiter, Platform, Vintage)를 가지고 있어 훨씬 유연하고 강력한 기능을 제공합니다.
- 가독성을 높이는 @DisplayName:과거에는 테스트 메서드 이름을 testCreateUserSuccess()와 같이 영어로 길게 작성해야 했지만, JUnit 5에서는 @DisplayName(“회원가입 성공 시 DB에 데이터가 저장된다”)와 같이 한글로 명확한 의도를 기술할 수 있게 되었습니다. 이는 테스트 결과 리포트를 읽는 동료 개발자나 QA 엔지니어에게 큰 도움을 줍니다.
- 생명주기 관리 (@BeforeEach, @AfterAll):테스트의 독립성을 보장하기 위해 각 테스트 실행 전후에 데이터를 초기화하거나 자원을 해제하는 작업이 필수적입니다. @BeforeEach는 각각의 테스트 메서드가 실행되기 직전에 매번 호출되어 깨끗한 상태(Clean State)를 만들어주고, @AfterAll은 테스트 클래스 전체가 종료된 후 무거운 리소스를 정리할 때 유용하게 사용됩니다.
2. Mockito를 활용한 의존성 격리와 Stubbing
스프링 프레임워크의 핵심은 DI(의존성 주입)를 통한 객체 간의 협력입니다. 하지만 단위 테스트 입장에서는 이것이 걸림돌이 됩니다. 예를 들어 UserService를 테스트하고 싶은데, 이 서비스가 UserRepository를 통해 DB에 접근하고 EmailSender를 통해 메일을 발송한다면, 순수한 UserService의 로직만 검증하기가 어려워집니다.
이때 등장하는 것이 바로 Mockito와 같은 Mocking 프레임워크입니다. Mockito는 실제 객체 대신 가짜 객체(Mock Object)를 만들어 주입함으로써, 테스트 대상을 외부 의존성으로부터 완벽하게 격리시킵니다.
- @Mock vs @InjectMocks:가장 많이 헷갈리는 개념입니다. @Mock은 가짜 객체를 생성하는 어노테이션이고, @InjectMocks는 테스트 대상 객체(여기서는 UserService)를 생성하면서 내부에 선언된 의존성(UserRepository, EmailSender) 자리에 앞서 만든 @Mock 객체들을 자동으로 주입해 주는 역할을 합니다.
- BDD 스타일의 Stubbing (Given-When-Then):”어떤 상황이 주어졌을 때(Given), 행동을 하면(When), 결과가 나온다(Then)”라는 BDD(Behavior Driven Development) 패턴은 테스트의 가독성을 비약적으로 높여줍니다. Mockito의 given(userRepository.findById(1L)).willReturn(Optional.of(user));와 같은 구문은 “DB에서 ID가 1인 유저를 찾으면 무조건 준비된 유저 객체를 반환하라”고 가짜 객체를 학습(Stubbing)시키는 것입니다. 이를 통해 DB가 셧다운 되든 말든 우리는 비즈니스 로직 검증에만 집중할 수 있습니다.
3. 통합 테스트(Integration Test)와 인메모리 DB의 함정
단위 테스트가 숲이 아닌 나무를 보는 것이라면, 통합 테스트는 나무들이 모여 이루는 숲을 보는 것입니다. 컨트롤러로 요청이 들어와서 서비스의 비즈니스 로직을 거쳐 리포지토리를 통해 실제 DB에 저장되고, 다시 응답이 나가는 전체 흐름(Flow)을 검증해야 합니다.
많은 프로젝트에서 통합 테스트를 위해 H2와 같은 인메모리(In-memory) DB를 사용합니다. 설정이 간편하고 속도가 빠르기 때문입니다. 하지만 여기에는 치명적인 함정이 숨어 있습니다. 바로 ‘운영 환경과의 불일치‘입니다.
운영 서버는 MySQL이나 PostgreSQL을 사용하는데 테스트는 H2로 돌린다면, 다음과 같은 문제 상황에 직면할 수 있습니다.
- SQL 문법 차이: MySQL 전용 함수(예:
DATE_FORMAT,JSON_TYPE)나 방언(Dialect)을 사용한 쿼리가 H2에서는 문법 에러를 발생시킬 수 있습니다. - 격리 수준 및 락(Lock) 동작의 차이: 동시성 이슈를 해결하기 위해 비관적 락(Pessimistic Lock)을 걸었을 때, H2와 실제 DB의 동작 방식이 달라 테스트에서는 통과했으나 운영에서는 데드락(Deadlock)이 발생하는 경우가 비일비재합니다.
- 제약 조건 처리: 데이터 타입의 범위나 Unique 제약 조건 처리 방식이 미묘하게 달라 버그를 놓칠 수 있습니다.
결국 “내 로컬에서는 되는데요?”라는 변명이 통하지 않게 하려면, 테스트 환경도 운영 환경과 최대한 동일하게(Production-like) 맞춰야 합니다.
4. TestContainers, 도커(Docker)를 품은 테스트 혁명
인메모리 DB의 한계를 극복하고, 로컬에서도 실제 운영 DB와 똑같은 환경을 구축해 주는 구세주가 바로 TestContainers입니다.
TestContainers는 자바 코드로 도커(Docker) 컨테이너를 제어할 수 있게 해주는 라이브러리입니다. 테스트 코드가 실행될 때 도커 데몬에 명령을 내려 실제 MySQL, Redis, Kafka 등의 이미지를 다운로드하고 컨테이너를 실행시킵니다. 그리고 테스트가 끝나면 컨테이너를 자동으로 파괴하여 깔끔하게 정리해 줍니다.
- Wait Strategy의 중요성:컨테이너가 Started 상태가 되었다고 해서 내부의 DB 프로세스가 즉시 연결 가능한 것은 아닙니다. OS가 부팅되고 MySQL 서비스가 초기화되는 시간이 필요하기 때문입니다. TestContainers는 로그 메시지 감지, 포트 리스닝 확인, 헬스 체크(Health Check) 쿼리 실행 등 다양한 대기 전략(Wait Strategy)을 제공하여 “DB가 진짜로 준비될 때까지” 테스트 실행을 지연시켜 줍니다.
- Singleton Container 패턴으로 속도 최적화:TestContainers의 유일한 단점은 매 테스트 클래스마다 컨테이너를 띄우고 내리는 데 시간이 오래 걸린다는 점입니다. 이를 해결하기 위해 abstract 클래스에 static 필드로 컨테이너를 정의하고, 모든 통합 테스트 클래스가 이를 상속받게 하는 ‘싱글톤 컨테이너 패턴’을 사용합니다. 이렇게 하면 전체 테스트 슈트(Test Suite)가 실행되는 동안 컨테이너를 딱 한 번만 띄우고 재사용하므로 테스트 속도를 획기적으로 개선할 수 있습니다.
- @DynamicPropertySource를 통한 동적 설정:컨테이너가 뜰 때마다 호스트와 매핑되는 포트 번호가 무작위로 변경됩니다. 스프링 부트가 이 변경된 포트 번호를 알고 DB에 접속할 수 있도록, @DynamicPropertySource를 사용하여 application.yml의 spring.datasource.url 값을 동적으로 덮어씌워 주는 작업이 필요합니다.
3. 결론
지금까지 스프링 부트 애플리케이션의 품질을 책임지는 테스트 전략에 대해 살펴보았습니다.
JUnit 5와 Mockito를 이용한 단위 테스트는 개발자에게 빠른 피드백을 제공하여 로직의 결함을 조기에 발견하게 해 줍니다. 이는 전체 테스트의 약 70% 이상을 차지해야 하는 근간입니다. 그리고 TestContainers를 활용한 통합 테스트는 인프라와의 상호작용 과정에서 발생할 수 있는 문제를 사전에 차단하여 배포의 두려움을 없애줍니다.
테스트 코드는 단순히 ‘작동 여부’를 확인하는 절차가 아닙니다. 미래의 나, 그리고 함께 일하는 동료들에게 “이 코드는 이런 의도로 작성되었으며, 이렇게 동작하는 것이 보장됩니다”라고 말하는 살아있는 문서(Living Documentation)입니다. 오늘 소개한 기술들을 여러분의 프로젝트에 하나씩 적용해 보십시오. 처음에는 설정하고 익숙해지는 데 시간이 걸리겠지만, 어느 순간 버그 수정에 들이는 야근 시간이 줄어들고, 리팩토링을 과감하게 시도할 수 있는 자신감을 얻게 될 것입니다. 테스트는 비용이 아니라, 가장 수익률 높은 투자입니다.