TestContainer와 멱등성

Testcontainers와 멱등성
테스트인프라
avatar
2025.03.17
·
22 min read

프로젝트에서 테스트는 in-memory DB인 H2로 하고 운영은 MySQL로 했는데, MySQL에서 제공하지만 H2에서는 제공하지 않는 기능들이 꽤 있었고, 사용하는 쿼리 문법도 조금씩 달라서 온전하게 운영 DB 환경에서 테스트를 진행할 수 없다는 문제점들이 있었다.

또한 테스트 DB에 이전 테스트의 데이터가 삭제되지 않고 남아있으면, 이전의 테스트가 다음 테스트에 영향을 주어서 항상 일정한 테스트 결과를 기대할 수 없다. 즉, "멱등성"을 만족하는 테스트를 작성할 수 없다는 것이다.

어떻게 하면 1) 테스트 DB와 운영 DB의 환경을 동일하게 유지하고, 2) 테스트 시에 데이터베이스의 멱등성을 유지할 수 있을지 찾아보던 중 TestContainer 라는 라이브러리를 발견했다. TestContainer란 무엇이고 어떻게 사용할 수 있는지 작성해본다.

멱등성이란?

멱등성이란 같은 요청을 여러 번 수행해도 결과가 변하지 않는 성질을 의미한다. 단순히 돌아온 값이 같은 뿐 아니라, 서버의 데이터 상태 (DB)에도 영향을 미치지 않는다.

멱등성을 만족하는 HTTP method

  • GET

    • 데이터를 한번 조회하든, 여러번 조회하든 같은 결과가 조회되므로 멱등하다.

    • (멱등적이지 않은 설계)

      게시글을 조회하는 GET 메서드 API에 조회수 데이터를 증가하는 로직이 추가되어 있다면, 이것은 HTTP 스펙에 부합하지 않게 API를 구현한 것이다. 왜냐하면 서버의 데이터 상태가 변경되기 때문이다.

  • PUT

    • PUT 메서드는 대상 리소스를 덮어씌워 변경하거나, 대상 리소스가 없다면 새로 추가한다. 그래서 만일 대상 리소스가 없다면 PUT이 POST와 같은 동작을 하게 되는데, POST는 매번 새로운 자원을 만드는 반면, PUT은 해당 자원이 이미 있다면 데이터만 덮어쓴다. 따라서 요청을 한번하든 여러번하든 결국 서버의 상태는 같아지니 PUT은 멱등하다.

  • DELETE

    • DELETE를 처음 요청하면, 서버에서 해당 리소스는 삭제가 된다. 이후 DELETE를 여러번 요청하더라도 해당 리소스는 삭제된 상태로 그대로 일 것이니 서버의 상태는 변하지 않는다.

    • (멱등적이지 않은 설계)

      정확한 식별자를 통해 리소스를 지정한 것이 아니고, 게시글을 삭제할 때 정확한 ID 값이 아닌 last 라는 구조로 엔드포인트를 설계했다고 가정하자. DELETE /posts/last

      이런 경우 DELETE 요청을 여러번 보내게 되면 매번 마지막 게시글을 삭제하기 때문에 매번 서버의 상태가 변하게 되어서, 멱등성을 만족하지 않는다. 이런 경우 멱등성을 가지지 않는 POST를 사용하는게 스펙 상으로 올바르다.

멱등성을 만족하지 않는 HTTP method

  • POST

    • 요청을 여러번 보내는 경우 매번 새로운 리소스가 생겨나고, 이는 서버의 상태가 변경되는 것을 의미한다.

  • PATCH

    • PUT이 리소스 전체 교체라면, PATCH는 리소스의 부분적인 수정을 할 때 사용된다. PATCH의 특이한 점은 기본적으로 멱등성을 가지지 않는 메서드인데, 그 구현을 PUT과 동일한 방식으로 할 경우 멱등성을 가지게 되는 특성을 가지고 있다.

    • (멱등적인 설계)

      수정할 리소스의 일부분만 담아서 보내는 경우에는 멱등성이 보장된다. 왜냐하면 한번 요청을 보낸후 같은 요청을 여러번 하더라도 변경된 같은 결과가 나오기 때문이다.

    • (멱등적이지 않은 설계)

      PATCH의 동작을 데이터를 또 다른 데이터로 ‘대체’하는 것이 아닌 ‘증가’를 통한 변경이라고 하면 멱등성을 가지지 않는다. 예를 들어 매 요청마다 age가 1씩 증가하도록 PATCH api 를 설계하였을 경우에는 요청마다 age의 값이 달라지기 때문에, 멱등성을 가지지 않는다.

테스트 시 가능한 데이터베이스 환경

  1. 로컬 환경에서 실제 DB를 띄우고, 해당 DB에 연결해서 테스트를 진행하는 방법

    • 운영 DB와 동일한 DB를 설치해서 띄우면 환경은 일치시킬 수 있지만, 테스트가 끝났음에도 테스트용 데이터가 남아있어서 멱등성을 유지하지 어렵다. 만약 멱등성을 유지하고 싶다면 매 테스트가 끝날 때마다 DB를 초기화하는 작업을 직접 수행해야한다.

    • 다른 사람과 동시에 테스트를 진행하면, 동일한 DB를 사용하기 때문에 예상하지 못한 문제들이 생길 수 있다.

  2. in-memory DB 를 사용하는 방법

    • H2와 같은 in-memory DB를 사용하면 빠르게 테스트를 진행할 수 있고, 외부 DB 커넥션이 없고, 테스트 후 데이터 관리도 따로 신경쓰지 않아도 된다는 장점이 있다.

    • 하지만, 테스트 DB와 운영 DB가 달라서 일부 기능을 테스트 하지 못하거나, 운영DB와는 다른 SQL 을 사용해서 테스트를 작성해야할 수 있다.

  3. docker-compose로 container 띄우기

    • 운영DB와 동일한 환경을 만들 수 있지만, 테스트 시작 전후로 컨테이너를 올렸다가 내렸다가 하는게 귀찮을 수도 있다. 만약, 컨테이너를 내리는 것을 까먹으면 해당 컨테이너가 백그라운드에 남아서 리소스를 잡아먹을 수 있다.

  4. TestContainer 사용하기

    • 어떤 DB를 테스트시 사용할건지 직접 선택할 수 있다.

    • 그리고, 테스트 시작시 자동으로 DB 컨테이너를 생성해서 실행하고, 종료시 자동으로 컨테이너를 삭제한다. 테스트 마다 새로운 DB 컨테이너가 생성되기 때문에 멱등성도 유지할 수 있다.

TestContainer

TestContainer란

https://testcontainers.com/getting-started/

"Testcontainers is a testing library that provides easy and lightweight APIs for bootstrapping integration tests with real services wrapped in Docker containers. Using Testcontainers, you can write tests talking to the same type of services you use in production without mocks or in-memory services."

"도커 컨테이너로 래핑된 실제 서비스를 제공해서, 로컬 테스트 시에도 mocking이나 in-memory 서비스들을 사용하지 않고 운영환경에서 사용하는 실제 서비스에 종속되는 테스트를 작성할 수 있게 해주는 오픈 소스 라이브러리입니다."

  • 어떤 DB를 테스트시 사용할건지 직접 선택할 수 있다.

  • 그리고, 테스트 시작시 자동으로 DB 컨테이너를 생성해서 실행하고, 종료시 자동으로 컨테이너를 삭제한다. 테스트 마다 새로운 DB 컨테이너가 생성되기 때문에 멱등성도 유지할 수 있다.

TestContainer 라이브러리의 장점은 다음과 같다.

  • On-demand isolated infrastructure provisioning

    • 사전에 프로비저닝된 통합 테스트 인프라가 필요하지 않다. Testcontainers는 테스트를 실행하기 전에 필요한 서비스를 제공한다. 여러 빌드 파이프라인이 병렬로 실행되는 경우에도 각 파이프라인은 격리된 서비스 집합으로 실행되기 때문에 테스트 데이터 오염이 없다.

  • Consistent experience on both local and CI environments
    (로컬 및 CI 환경 모두에서 일관된 경험)

    • 단위 테스트를 실행하는 것처럼 IDE에서 바로 통합 테스트를 실행할 수 있다. 변경 사항을 푸시하고 CI 파이프라인이 완료될 때까지 기다릴 필요가 없다.

  • Reliable test setup using wait strategies
    (대기 전략을 사용한 신뢰할 수 있는 테스트 설정)

    • Docker 컨테이너는 테스트에서 사용하기 전에 시작하고 완전히 초기화해야 한다. Testcontainers 라이브러리는 컨테이너(및 내부 애플리케이션)가 완전히 초기화되었는지 확인하기 위한 여러 가지 기본 대기 전략 구현을 제공한다. Testcontainers 모듈은 이미 특정 기술에 대한 관련 대기 전략을 구현하고 있으며, 필요한 경우 언제든지 자체적으로 구현하거나 복합 전략을 만들 수 있다.

  • Advanced networking capabilities
    (고급 네트워킹 기능)

    • Testcontainers 라이브러리는 호스트 컴퓨터에서 사용할 수 있는 임의의 포트에 컨테이너의 포트를 매핑하여 테스트가 해당 서비스에 안정적으로 연결되도록 한다. 또한 (Docker) 네트워크를 생성하고 여러 컨테이너를 함께 연결하여 정적 Docker 네트워크 별칭을 통해 서로 통신할 수도 있다.

  • Automatic clean up
    (자동 정리)

    • Testcontainers 라이브러리는 Ryuk 사이드카 컨테이너를 사용하여 테스트 실행이 완료된 후 생성된 리소스(컨테이너, 볼륨, 네트워크 등)를 자동으로 제거한다. 필요한 컨테이너를 시작하는 동안 테스트 컨테이너는 생성된 리소스(컨테이너, 볼륨, 네트워크 등)에 라벨 세트를 부착하고, Ryuk은 해당 라벨을 매칭하여 리소스 정리를 자동으로 수행한다. 이는 테스트 프로세스가 비정상적으로 종료되는 경우(예: SIGKILL 전송)에도 안정적으로 작동한다.

테스트를 실행하면, TestContainer가 2개의 컨테이너를 자동으로 생성 및 실행한다.

testcontainers-ryuk-XXX 라는 docker container가 생성 및 실행되고, Ryuk 는 코드에 설정한 DB(e.g., silly_faraday)를 생성 및 실행한다. 테스트가 종료되면 Ryuk가 인스턴스를 정리하고 마지막으로 종료된다.

4103

프로젝트에 도입하기

  • build.gradle

testImplementation 'org.testcontainers:testcontainers:1.20.1'
testImplementation 'org.testcontainers:junit-jupiter:1.20.1'
testImplementation 'org.testcontainers:mysql:1.20.1'
  • 환경 세팅하기 (자동으로 라이프사이클 관리)

@Testcontainers
public abstract class RepositoryTest {
    
    private static final String USERNAME = "testUser";
    private static final String PASSWORD = "testPassword";
    private static final String DATABASE_NAME = "testDB";

    @Container
    public static org.testcontainers.containers.MySQLContainer<?> mySQLContainer = new org.testcontainers.containers.MySQLContainer<>("mysql:8.0.35")
            .withDatabaseName(DATABASE_NAME)
            .withUsername(USERNAME)
            .withPassword(PASSWORD)
            ;

    @DynamicPropertySource
    public static void overrideProps(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", mySQLContainer::getJdbcUrl);
        registry.add("spring.datasource.username", mySQLContainer::getUsername);
        registry.add("spring.datasource.password", mySQLContainer::getPassword);
        registry.add("spring.jpa.hibernate.ddl-auto", () -> "create");
    }

📖 참고

🌐 HTTP의 멱등성 · 안정성 · 캐시성 💯 완벽 이해하기
HTTP 메서드의 속성 주요 HTTP Method인 GET / POST / PUT / PATCH / DELETE 는 각 메서드의 동작 과정 뿐만 아니라, 메서드의 속성 또한 알 필요가 있다. 왜냐하면 어떠한 HTTP 메서드로 서버에 요청했느냐에 따라 API 설계나 복구 메커니즘 캐시 최적화 등, 설계 로직이 달라질 수 있기 때문이다. HTTP 메서드의 속성으로는 크게 3 가지인 안전(Safe), 멱등(Idempotent), 캐시 가능(Cacheable)이 있다. 이들을 하나씩 살펴보는 시간을 가져보자. 안전성(Safe) HTTP 메소드의 안정성이란 보안 취약성을 말하는 것이 아니라 호출해도 리소스가 변경되지 않는 성질을 말하는 것이다. 정말 쉽게 생각해서 GET 메서드는 단순히 데이터를 조회하는 기능을 ..
https://inpa.tistory.com/entry/WEB-%F0%9F%8C%90-HTTP%EC%9D%98-%EB%A9%B1%EB%93%B1%EC%84%B1-%C2%B7-%EC%95%88%EC%A0%95%EC%84%B1-%C2%B7-%EC%BA%90%EC%8B%9C%EC%84%B1-%F0%9F%92%AF-%EC%99%84%EB%B2%BD-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0
🌐 HTTP의 멱등성 · 안정성 · 캐시성 💯 완벽 이해하기
TestContainer
테스트 코드를 작성하는 환경을 구축하는 것은 중요하다. 처음에 가장 어렵고, 귀찮고, 시행착오도 많이 겪는 과정이라고 생각한다. 테스트 환경을 구축하는 과정에서 많은 부분을 고려해야 하지만 특히 주의해야할 부분은 "멱등성"이다. ■ 멱등성이란 여러번 연산을 실행해도 결과가 바뀌지 않는 성질을 뜻한다. HTTP method에서 보자면 POST를 제외하고 나머지는 멱등성을 만족한다. POST 요청을 반복한다면, 데이터들은 계속해서 추가가 되고, 서버의 응답은 다 다른 응답을 나타낸다. 같은 내용이더라도 서로 다른 데이터이다. PUT 요청으로 2번 데이터를 수정한다고 치면, 2번 데이터가 없는 경우에는 데이터가 생성될 수 있지만, 이미 존재하면 데이터는 수정이 된다. 계속해서 PUT 요청을 날려도 2번 데이터..
https://codingram.tistory.com/104
TestContainer
[사내 TestContainer 적용] Spring boot 통합테스트 도입기
개발자가 되고 가장 고된 작업으로 기억에 남을 통합테스트 작성 과정을 기록에 남긴다. 작년 3월 입사하고 백엔드 시니어님의 갑작스러운 퇴사로, 익숙하지 않은 지식들을 내것으로 만들기 위해 또는 살아남기 위해 씨름하던 중, 작년 하반기 무렵 드디어 회사에 새로운 시니어님이 입사하셨다. 직장 상사 보다는 동료로, 그리고 굉장히 능력있는 분이 오셔서 내게는 작년중 가장 큰 행운이었다고 할 수 있다. 그리고 작년 9월부터 새로온 분과 기존 Mybatis와 Postgres로 되어있던 프로젝트를 다른 기술 spec으로 처음부터 다시 구축하기로 결정했다. (처음에는 Logging 작업을 진행한 후에, 점진적으로 프로젝트를 개선하려고 했지만, 일주일 정도를 작업해보시고는 처음부터 다시 만드는 쪽이 속도가 더 나올 것 ..
https://yeoon.tistory.com/97
[사내 TestContainer 적용] Spring boot 통합테스트 도입기






- 컬렉션 아티클