BackEnd/자바 스프링

[JAVA] Test code : 2. Service 통합 테스트 (TestContainers 적용)

세모 2026. 5. 5. 21:13

Java Test cod : 1. Service 단위 테스트

단위 테스트는 DB를 사용하지 않아 빠르다는 장점이 있지만, 실제 DB와 연동 했을 때 로직이 의도와 다르게 동작할 수 있다.
이러한 문제점을 해결하기 위해 Spring Boot Test를 진행할 수 있다.


Service 통합 테스트

실제 스프링 환경에서 진행하는 테스트이며,

Service의 통합 테스트는 Service 검증 + Repository 검증 + DB 검증이 동시에 진행할 수 있다.

하지만 테스트를 위해선 스프링을 가동하면서 생기는 시간이 추가적으로 발생해 단위 테스트보다 상대적으로 시간이 걸린다.

 

기본적으로  구조는 다음과 같다.

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

@SpringBootTest
@Transactional
class AuthServiceIntegrationTest {

    @Autowired
    private AuthService authService;

    private  final Address address = new Address("서울시","동작구","사당동");

    @Nested
    @DisplayName("회원가입")
    class Signup {

        @Test
        @DisplayName("[HAPPY] 회원가입이 정상적으로 작동한다.")
        void signup_validRequest_success() {
            AuthSignupRequestDto dto = new AuthSignupRequestDto(
                    "test@test.com", "nickname", "password", address
            );

            String message = authService.signup(dto);

            assertThat(message).contains("회원가입 성공");
        }

        @Test
        @DisplayName("[Exception] 중복된 이메일로 회원가입하면 예외처리가 정상작동한다.")
        public void signup_DuplicateEmail_ExceptionThrown(){
            //given
            AuthSignupRequestDto dto = new AuthSignupRequestDto("test@test.com", "이름", "password", address);
            //when
            authService.signup(dto);
            //then
            assertThatThrownBy(() -> authService.signup(dto))
                    .isInstanceOf(InvalidInputException.class)
                    .hasMessage("중복된 이메일입니다.");
        }
    }
}

단위 테스트와 비슷하지만 가짜 객체인 Mock을 사용하지 않고 Service, Repository, DB 전부 사용하여 테스트를 진행하고, 그에 따른 기대 값과의 비교를 검증한다.


Service 통합 테스트를 진행하며 발생한 문제

현재 프로젝트는 .env파일을 사용해 환경 변수를 적용해 DB의 계정 정보나, JWT 시크릿키를 보관할 계획을 가지고 사용했지만, 통합 테스트를 하면서 테스트 환경에서 돌아야할 DB의 계정 정보등이 테스트 환경에 적용이 되지 않아 실행이 불가능한 상황이 되었다.

 

따라서 다음과 같은 선택지가 생겨났다.

  1. 환경 변수 사용하지 않기
    • 해당 선택지의 경우 보안을 위해서 선택한 환경 변수를 사용할 수 없는 문제가 발생하며, 근본적인 문제해결 방안이 아니라고 판단
    • 따라서 환경 변수를 사용하여 보안을 적용시키며, 다음과 같은 방안을 고려해봄
  2. 설정된 yml 파일을 분할해 prod, dev, test를 각각 다르게 적용하는 방법
    • 환경별 YML을 분리 할 경우 얻을 수 있는 이점은 다음과 같다.
      1. 환경별 설정의 완전한 분리
      2. 보안 강화
      3. 테스트 안정성 및 속도 최적화
      4. CI/CD 파이프라인 연동 편의성
      5. 운영 최적화
      6. 협업 시 환경 가이드라인 제공
    • 같은 코드 베이스 위에서 프로파일만 바꿔도 다른 환경을 적용 할 수 있어 안전하면서 유연한 개발, 테스트, 운영 모두 가능해진다.
    • 여기서 다음과 같은 테스트를 위해 DB에 대한 선택지가 놓이게 된다.
      1. 테스트 환경에서 H2와 같은 In-Memory사용
        • H2를 사용하면, In-Memory DB를 사용함에 따라 Disk I/O에서 시간적으로 이점이 존재
        • 운영 DB와 분리되어 보안상 안전하다.
        • 하지만 실제 환경과 다른 트랜잭션 격리 수준이나 SQL 방언 차이로 인한 부담이 존재한다.
      2. 실제 환경과 비슷한 테스트를 하기 위해 DB의 테스트용 계정을 생성해 사용
        • 계정 설정을 잘못하여 권한이 많다면 위험하다고 판단.
        • 또한 DB URL의 정보를 공개하는것과 동일하다는 판단
        • 따라서 실제 환경과 비슷하게 사용할 수 있지만 보안적으로 미흡하다는 생각이 든다.
      3. 테스트 환경을 독립적으로 컨테이너로 생성
        • 실제 환경과 비슷하게 사용이 가능하다.
        • 테스트가 끝날 경우 DB를 내리기 때문에 초기화 등 부담이 줄어든다.
        • 공개할 계정의 정보가 존재하지 않는다.
        • Docker를 통해 모든 팀원이 동일한 데이터 베이스 버전을 사용하게 되어 환경 차이로 인한 문제에서 자유롭다.
        • 하지만 초기 설정이 복잡하며, 테스트 실행시 컨테이너 실행 시간으로 인해 시간이 더 오래걸린다.

 

분리 환경에 대한 정리표

위의 내용을 간략하게 표로 정리해봤다.

선택지 장점 단점
H2 In-Memory 빠른 속도, 운영 DB와 분리되어 보안 안전 실제 환경과 다른 SQL방언, 격리 수준 차이 부담
테스트용 계정 생성 실제 환경과 동일한 테스트 가능 DB URL/ 계정 노출 위험, 권한 관리 부담
독립 컨테이너 실제 환경과 동일, 테스트 후 자동 정리, 민감정보 노출 없음 초기 설정 필요, 컨테이너 실행 시간

해당 내용을 팀원과 공유하고 다음과 같이 의논 후 독립 컨테이너로 결정하였다.

  • 나의 의견
    • 보안이 중요하므로 테스트용 계정을 사용해 DB URL을 공개하는 것은 좋지 않아보이며, 권한에 대한 부담이 존재한다.
    • 따라서 운영 DB와 분리하여 사용하는 것이 좋아보인다.
    • 초기 설정의 어려움과, 시간적인 부분을 생각해보면 H2를 통한 테스트가 좋아보인다.
  • 팀원들의 의견
    • 실제 환경과 다른 환경을 사용했을 때 이점보다 단점이 많아보인다.
    • 통합 테스트는 DB또한 검증하는 것을 목표로 하기 때문에 같은 테스트 환경에서 진행함이 좋아보인다.
    • 초기 설정의 어려움을 고려 했을때, 테스트용 계정을 사용하는 것이 좋을 것 같다.
  • 결과
    • 각자의 의견에서 핵심을 추리면 다음과 같았다.
    • 실제 환경에서 DB검증을 가장 유사하게 할 수 있는 것이 좋다.
    • 보안이 중요하므로 운영 DB와 분리하여 사용할 수 있는 것이 좋다.
    • 따라서 두 의견을 종합해 초기 설정의 복잡함보다 보안과 테스트의 본질적 가치를 더 높게 판단해 독립 컨테이너를 사용하는 것으로 마무리되었다.

TestContainer 적용

독립 컨테이너를 적용하기 위해선 다음과 같은 절차가 필요하다.

또한 Docker환경에서 진행되기 때문에 Docker가 필수로 필요하다.

  1. dependency 추가
  2. YML 분할 및 Test yml 추가
  3. TestConfig 추가

bulid.gradle dependency 추가

테스트 환경에서 컨테이너를 사용하고, 컨테이너에서 JUnit사용과 사용할 DB를 적용해야한다.

	//test 환경 dependency
	testImplementation 'org.springframework.boot:spring-boot-testcontainers'
	testImplementation platform('org.testcontainers:testcontainers-bom:2.0.4')
	testImplementation 'org.testcontainers:mysql:1.21.4'
	testImplementation 'org.testcontainers:junit-jupiter:1.21.4'

현재 프로젝트는 MySQL로 진행되고 있기 때문에 MySQL을 적용시켰다.

 

YML 분할 및 TEST YML 추가

기존 application.yml의 내용을 application-dev.yml로 적용시켜서 환경변수는 그대로 사용하고 application-test.yml을 새로 만들어 테스트 환경에서 사용할 방식을 적용한다.

YML

spring:
  config:
    activate:
      on-profile: test

  jpa:
    database-platform: org.hibernate.dialect.MySQLDialect
    hibernate:
      ddl-auto: update
    show-sql: true
    open-in-view: false

logging:
  pattern:
    console: "[%d{HH:mm:ss.SSS}][%-5level][%logger.%method:line%line] - %msg%n"

on-profile을 test로 지정해 test 환경에 대한 설정임을 정의하고 로깅은 팀 컨벤션으로 맞춘 형식으로 진행했다.

추가적으로 datasource에 대한 내용은 작성되어있지 않은데 해당 내용을 TestContainerConfig에 작성했다.

 

TestContainerConfig 설정

Config설정을 위해 config 디렉토리를 생성하여 안에 넣어뒀다.

테스트코드 구조

@TestConfiguration(proxyBeanMethods = false)
public class TestContainerConfig {

    @Bean
    @ServiceConnection
    public MySQLContainer mySQLContainer(){
        return new MySQLContainer<>("mysql:8.0");
    }
}

간략하게 @ServiceConnection을 통해서 datasource 자동 설정하도록하여 yml에 설정하지 않는 방법으로 진행했다.

proxyBeanMethods는 false를 적용해 불필요한 프록시 생성을 막아뒀다.

 

추가적으로 개선점으로는 컨테이너 재사용등 설정 해줄 것 들이 존재하지만 지금은 간단하게만 구현하는 방안으로 작성했다.

 

TestContainer를 사용한 테스트 예시

해당 부분은 통합 테스트에서만 적용하면 되는 부분으로 DB를 사용하지 않는 단위 테스트의 경우 적용하지 않아도 된다.

테스트 클래스에서 application-test.yml을 적용하겠다는 내용과 방금만든 Config를 Import해주면 된다.

@SpringBootTest
@Import(TestContainerConfig.class)
@ActiveProfiles("test")
@Transactional
public class MemberServiceIntegrationTest {

    @Autowired
    private MemberService memberService;

    @Autowired
    private EntityManager em;

    private final Address address = new Address("서울시", "동작구", "사당동");

    @Test
    @DisplayName("[HAPPY] 내 정보 조회시 성공적으로 반환한다.")
    public void etMyInfo_ValidMember_Success(){
        //given
        Member member = Member.builder()
                .email("test@test.com")
                .nickName("이름")
                .address(address)
                .build();

        em.persist(member);
        Long memberId = member.getId();
        em.flush();
        em.clear();
        //when
        MemberInfoResponseDto result = memberService.getMyInfo(memberId);
        //then
        assertThat(result.email()).isEqualTo("test@test.com");
        assertThat(result.nickname()).isEqualTo("이름");
        assertThat(result.address()).isEqualTo(new Address("서울시", "동작구", "사당동"));
    }
}

여기서 추가적으로 이야기 해볼 부분은 트랜잭션이다.

@Transactional을 적용하면 테스트 코드에서 실행된 로직은 DB에 commit이 일어나지 않고 rollback되어 버린다.

 

그렇다면 예시 기준으로 의문점은 em.flush()를 통해 INSERT문을 DB로 보냈고, em.clear()를 통해서 JPA가 관리하던 영속성 컨텍스트를 초기화한 상태에서 검증하는 것이 가능할까에 대한 의문점이 존재할 수 있지만, 다음과 같이 설명이 가능하다.

 

flush()시점에 DB의 트랜잭션 로그(Undo/Redo Log)에 기록이 되고, 커밋 전까지 uncommitted 상태로 유지되어 있다.

따라서 같은 트랜잭션 내에서 member에 대한 정보를 조회할 수 있고, rollback이 일어남과 동시에 Undo Log를 통해 변경 사항이 취소되어 원복된다.

'BackEnd > 자바 스프링' 카테고리의 다른 글

[Java] Exception : 예외 처리  (0) 2026.05.07
[JAVA] Test code : 0. Assertions  (0) 2026.05.05
[JAVA] Test code : 1. Service 단위 테스트  (0) 2026.05.03
[JAVA Spring] 트랜잭션 심화  (0) 2026.04.01
[Java Spring] Transaction  (0) 2026.03.22