먼저 이 글은.. docker run 명령어도 copy&paste 를 하지 않고는 치지 못하는 시점에 시도 해 보았던 것 임을 밝힙니다 ( 현재 사용 하고 있지는 않음 )
Intro : 왜 dokcer compose 랑 testcontainer ?
docker-compose.yaml 로 내가 실행할 컨테이너에 대한 이미지 버전, 해당 컨테이너에 대한 환경 변수들의 설정 을 하고 docker-compose up -d 만 실행해 주는 것이
매 번 필요한 것들(변수 설정, expose 포트 명시, lifecycle 전략 등등 ) 을 여러 줄로 이루어진 docker run 명령어로 치는 것 보다 더 유용하다고 생각이 들었다
이런 생각이 들 때 즈음, testcontainer 에 대해서도 본격적으로 한 번 시도 해 봐야겠다는 생각이 겹치게 되어
결론적으로
docker compose 파일로 설정된 컨테이너를 testcontainer 로 사용해 보자
를 해 봐야겠다는 생각에 도달했고, 이에 대해서.. 남들은 다 알고 있을 삽질기를 기록했다
상황
테스트 컨테이너를 사용하는 이유는 ‘local DB 에 대한 데이터 오염 방지’ , '테스트의 독립성 (외부 요인에 의해 실패하지 않는 테스트)' 등등 다양하다.
그 다양한 이유에 대해서는 이 글에서는 생략한다 (하지만 실제로는 왜 필요한가? 가 가장 중요할 것이다 )
나의 경우 외부 요인에 의해 테스트 코드가 실패하는 것을 방지하고 싶었다. 단순히 이런 요인만이라면 embedded H2 db 만을 사용하더라도 가능 할 것이다.
하지만 embedded DB 들을 사용할 경우 운영환경과 동일한 설정의 DB 인스턴스를 사용하는 것이 아니게 된다. 사실상 테스트를 성공하더라도 실제 어플리케이션 코드 실행시에는 실패할 가능성이 "크게" 존재하게 되는 것이다. (DBMS 에 따라서 쿼리 매핑이 다르게 일어나거나 특정 DBMS 에서만 제공하는 기능이 동작하지 않을 수도 있다 )
아무튼 이런 이유로 application-test.yaml 에서 test 를 위한 datasource 를 별도로 설정하여 사용할 예정이며, 이 때 테스트 컨테이너를 사용해, 실제 운영 DB 와 같은 외부 요인이 테스트에 개입하지 못하도록 하고 싶었다.
그리고 이 때는 계층형 아키텍쳐 중에서도 영속성 계층, JPA 를 사용한다면 Repository 에 대한 테스트만을 위한 테스트 컨테이너 설정을 목표로 하고 있었기 때문에 repository 에 대한 slice test 인 @DataJpaTest 를 사용했고, 이 때 embedded h2 db 가 아닌 다른 db 를 사용하기 위한 추가 설정이 필요하다.
이를 위해 어떤 것들이 필요하고, 어떻게 설정해야 가능한지..하나 하나 설정해보자 !!
먼저 @DataJpaTest 에서 자동설정되는 DB (embedded H2)가 아닌 다른 DB 사용하기 위한 설정
이부분도 이분 글 에 더해 추가적인 정리를 더했으나, 추후에 추가 하도록 하겠다.
일단 위 링크를 보고 설정해 주도록 하자.
TestContainer 설정하기
testcontainers 를 사용해 DB 컨테이너를 생성하고 test 에서는 이를 사용하여 테스트를 실행하도록 할 것이다.
TestContainer 에 대한 개념
- ✅ testcontainer 는 디자인 상, random port number 를 expose 도록 하고 있다 (중요!! 모르고 삽질했다..)
- 참고로 testcontainer 로 만들어진 container 내부 port 는 default port 가 있다. mysql 의 경우는 3306 이다. 하지만 host 에 대해서는 random port 와 binding 된다.
- 따라서 이 random port 를 알기 위해 container.getMappedPort(3306) 을 호출할 수 있다.
- Integer onYourMachine = container.getMappedPort(3306);
- ✅ 왜 이런 디자인을 택한걸까???
- 만약 우리가 특정 포트를 사용하도록 명시하는 것이 가능하다고 생각해 보자.
- 이미 다른 프로젝트에서 해당 포트넘버를 사용하고 있는 상황에서, 우리가 해당 포트 넘버를 사용하도록 설정한 테스트를 실행시킨다면 테스트는 실패하게 될 것이다.
- testcontainer 를 사용하는 이유 중 하나가 테스트에 대한 멱등성( 항상 같은 결과를 내야 함 ) 이기도 한데 , 위와 같은 port number collision 으로 인해 “테스트가 실패” 한다면 이는 바람직하지 않은 상황일 것이다.
- 따라서 testcontainer 에서는 테스트가 실행할 때 마다 new random port 를 사용하도록 한다.
- 만약 우리가 특정 포트를 사용하도록 명시하는 것이 가능하다고 생각해 보자.
- ✅ 그럼 여기서 궁금증이 생길 것이다. 우리는 datasource 를 통해서 JDBC 연결을 하게 된다. 이 datasource 에서 connection 을 생성하기 위해선 url 이라는 정보가 필요한데, 이런식으로 random port 가 생성되는 상황이라면 우리가 어떻게 datasource url 을 명시 해 줄 수 있는 걸까?
- MySQLContainer 를 사용할 때와 DockerComposeContainer 를 사용 할 때 각각 다르다. MySQLContainer 에 대해서는 어렵지 않다. 문제는 DockerComposeContainer 였다. 차차 살펴보자
- ✅ testcontainer 는 우리의 db container 를 실행시키고는 해당 db 가 stated up되었는지 확인하기 위해서 100ms 마다 db 에게 poll 하고 있다.
- 아마 아래와 같은 부분을 말하는 것 같다
12:42:33.952 [Test worker] DEBUG 🐳 [mysql:8.0.24] - Trying to create JDBC connection using com.mysql.cj.jdbc.Driver to jdbc:mysql://localhost:58993/test?useSSL=false&allowPublicKeyRetrieval=true with properties: {password=test, user=test} 12:42:34.077 [Test worker] DEBUG 🐳 [mysql:8.0.24] - Trying to create JDBC connection using com.mysql.cj.jdbc.Driver to jdbc:mysql://localhost:58993/test?useSSL=false&allowPublicKeyRetrieval=true with properties: {password=test, user=test} 12:42:34.192 [Test worker] DEBUG 🐳 [mysql:8.0.24] - Trying to create JDBC connection using com.mysql.cj.jdbc.Driver to jdbc:mysql://localhost:58993/test?useSSL=false&allowPublicKeyRetrieval=true with properties: {password=test, user=test}
컨테이너를 static 필드로 선언
- 컨테이너를 생성하고 종료하는 과정이 필요해 졌다보니, 테스트에 소모되는 시간이 상당히 커졌다. 매 테스트 메소드를 실행할 때 마다 새로운 컨테이너를 생성하고, 해당 테스트 가 종료되면 테스트 컨테이너 또한 종료할 경우 테스트에 대한 멱등성은 지켜질 것이나 시간이 기하급수적으로 늘어날 수 밖에 없다.
- 따라서 컨테이너 선언시 static 으로 선언하여 모든 테스트에서 해당 컨테이너를 사용하도록 하였다 → 컨테이너를 한 번 만 띄우고, 모든 테스트 메소드에서 해당 컨테이너를 공유해서 사용할 수 있도록 하였다.
- 이 경우 테스트 멱등성을 위해 test managed transaction 에 들어가게 해서 롤백되게 하거나 수동으로@BeforeEach, @AfterEach 에서 데이터를 세팅 하고 delete 하는 관리에 신경 써 줘야 한다.
먼저 테스트 컨테이너를 사용하지 않고 로컬에서 따로 먼저 컨테이너를 실행시 켜 mysql 서버를 만들고, 스프링 부트 테스트를 실행해보자
다음과 같은 docker-compose 를 사용하여 docker compose up 을 통해 실행중인 컨테이너에
version: "3.2.0"
services:
# 생성되는 컨테이너 이름
mysql-prac:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: 1234
MYSQL_DATABASE: test_db
TZ: Asia/Seoul
ports:
- 3308:3306
volumes:
- ./mysql/my.cnf:/etc/mysql/mysql.conf.d/mysqld.cnf
Spring boot test 를 연결한 것이다. 이 때 application-test.yaml 설정은 아래와 같다
spring:
datasource:
url: jdbc:mysql://localhost:3308/test_db
driver-class-name: com.mysql.cj.jdbc.Driver
username: root
password: 1234
jpa:
database: mysql
open-in-view: false
properties:
hibernate:
format_sql: true
query.in_clause_parameter_padding: true
hibernate:
ddl-auto: create
dialect: org.hibernate.dialect.MySQL8Dialect
MySQLContainer 를 사용해 보자
참조 문서 : https://www.testcontainers.org/modules/databases/jdbc/
- url 의 host, port 는 TestContainers 에서 자동으로 설정할 것 이기에 아래와 같은 url 을 가져야 한다.
- jdbc: 뒤에 tc: 를 붙여야 한다.
- url 에 적어봤자 hostname, port, db name 은 무시된다.
- 결과적으로 → jdbc:tc:mysql:///test_db
- 또한 testcontainer 에서 제공하는 드라이버 를 통해 datasource 를 생성해야 하기 때문에 아래와 같은 설정이 필요하다.
spring:
datasource:
url: jdbc:tc:mysql:///test_db
driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver
jpa:
@Testcontainers
@ActiveProfiles("test")
public class TCMysqlRepositoryTest {
static final MySQLContainer<?> mysql;
static {
mysql = new MySQLContainer<>(
DockerImageName.parse("mysql:8.0.24"))
.withDatabaseName("test_db");
mysql.start();
}
}
심지어 username, password , 그리고 url 의 host 와 port 설정을 하지 않아도 connection 이 이루어짐을 확인할 수 있다.
자동으로 다음과 같은 password, user 그리고 url 을 가지고 커넥션 이 이루어지고 있다.
16:15:29.392 [Test worker] DEBUG 🐳 [mysql:8.0.24] - Trying to create JDBC connection using com.mysql.cj.jdbc.Driver to jdbc:mysql://localhost:53364/test_db?useSSL=false&allowPublicKeyRetrieval=true with properties: {password=test, user=test}
(issue💥 ) mysql container 가 2개 생성되는 현상
위의 코드로는 이런 현상이 발생하지 않지만
만약 아래와 같은 코드를 사용할 경우, mysql container 가 2개 생성되게 된다.
@Testcontainers
@ActiveProfiles("test")
public class TCMysqlRepositoryTest {
@Container
static final MySQLContainer<?> mysql;
static {
mysql = new MySQLContainer<>(
DockerImageName.parse("mysql:8.0.24"))
.withDatabaseName("test_db");
mysql.start();
}
}
이유는 @Container 을 annotate 하고는 start() 도 호출하고 있기 때문이었다.
@TestContainers 와 함께 @Container 를 사용하면, 자동으로 @Container annotated 된 컨테이너들을 인식하여 컨테이너를 생성한다.
만약 testcontainer 에서 지원하는 모듈이 없다면 GenericContainer 를 사용하면 된다
@Container
private static GenericContainer genericContainer = new GenericContainer("이미지이름");
docker compose 를 사용해 testcontainer 를 띄워보자
(Issue💥) testcontainers 를 사용하면서 DockerComposeContainer 를 사용할 경우 도커 컴포즈를 통해 생성된 db 컨테이너와의 접속 문제 (포트 문제 )
https://github.com/testcontainers/testcontainers-java/issues/5687
특정 포트를 통해 host machine 과 도커 컴포즈를 통해 생성된 컨테이너가 연결되도록 할 수가 없었다…
하루종~~일 마주하던 에러가 다음과 같았다…..
- 연결이 되지 않으니 timeout 이 발생하는 것이다. 앞서 polling 을 한다고 했었는데 연결이 안되니 예외가 발생한다.
- docker-compose 의 서비스 포트로 명시하더라도 안된다.. 대체 뭔지 이해가 안됐다…
- docker-compose 에 포트까지 명시 하면 이렇게 해당 포트 컨테이너가 하나 만들어지긴 하는데..?? 대체 무슨일일까?
- ports: - "3309:3306"
원인 : testcontainers 는 디자인 상 random port 를 expose
원인 : testcontainers 의 내부 동작 방식
( 이미 앞에서 testcontainer 에 대한 개념 부분에서 언급한 부분이다. )
Networking and communicating with containers
- 병렬로 실행되는 테스트들 사이에 또는 로컬에서 실행되는 소프트웨어 사이에서는 포트 충돌이 발생할 수 있다. testcontainer 는 디자인 될 때 부터, 이러한 포트 충돌(port collision) 을 피하기 위해 host 에게는 random port 를 expose 하도록 디자인 되었다. 앞에서 봤던 것 처럼 socat(testcontainer 사용에 docker compose 를 통할 경우에는 socat 을 사용) 같은 indirection layer 가 존재하다보니, 실제 매핑된 포트를 알기 위해서는 런타임에 Testcontainers 에게 요청을 해야 한다.
- MySQLContainer 를 사용할 때에는, 테스트 실행시 docker ps -a 를 쳤을 때, testcontainer, mysql 컨테이너 외의 추가적인 컨테이너가 존재하지 않는데, DockerComposeContainer 를 사용할 경우, 위의 캡쳐처럼 docker-compose 를 실행하기 위한 컨테이너가 초반에 실행되고 exit 되며, socat 컨테이너도 실행되고 있음을 볼 수 있다.
- Testcontainers 에서는 또다른 작은 하나의 컨테이너를 돌린다. (ambassador 컨테이너라고 부르고 있음 ) 이 컨테이너에서는 Compose-managed 컨테이너 들과, 우리가 실행하는 테스트에서 접근가능한 포트 사이의 프록싱을 담당하게 된다. 이 작은 컨테이너는 최소 크기의 컨테이너로서 TCP proxy 를 위한 socat 를 실행한다. 위의 캡쳐에서도 볼수 있듯이 alpine:socat 이라는 베이스 이미지를 가진 컨테이너를 확인할 수 있다. ( socat? 잘은 모르겠지만 패킷에 대한 포트 포워딩이 가능하다고 한다 )
Java - Timed out waiting for container port to open (localhost ports: [32773] should be listening)
중간 정리
- (내가 내린 결론은 이렇다 ) 저렇게 docker-compose 에서 명시한 포트 매핑 으로 mysql 컨테이너가 만들어 지더라도 우리는 이 컨테이너를 3309 포트로 접속해서 사용할 수가 없다. alpine:socat 이미지로 실행되고 있는 컨테이너에서 port forwarding 을 하기 때문에 우리는 결국 random port 를 받게 될 수 밖에 없다.
- 그래서 Testcontainers 문서에서도 YAML file 에 ports 를 expose 시킬 필요 없다고 한다. 오히려 이거는 해당 file 을 다른 여러 context 에서 재사용 하는 것을 방해 하게 되기 때문이라고 한다.
( 그래서 일단 docker-compose.yaml 에 명시하던 port 번호를 삭제 했다 )
- socat 을 사용한 포트 포워딩이 있다 보니, TestContainers 에게 런타임에 실제 매핑된 port가 무엇인지 묻는 것이 필요하다.
- docker compose 를 사용하지 않는 경우에는, 자동으로 url 까지 생성
런타임에 테스트 property 설정 변경 할 수 있다면 ?
@DynamicPropertySource
- Spring 5.2.5 에 도입 된 것으로 testing 을 수월하게 해 준다.
- 앞서 매 번 random port 에 연결을 해야 하는 문제가 생겼다. 해당 포트번호는 testcontainer 에 의해 container 가 생성 되어 실행된 이후에서야 알 수 있는 런타임에서야 알게 되는 정보다. spring.datasource.url 설정 프로퍼티를 동적으로 변경해야 하게 되었다.
- 이를 위해 DynamicPropertySource 을 사용해 볼 수 있다. 기존에는 ApplicationContextInitializer 라는 것을 통해 이미 가능하던 것을 좀 더 편하게 만든 거라고 보면된다고 한다. 애초에 Testcontainers 와 함께 쓰기 위해 디자인 된 것이긴 한데, 테스트 상에서 동적인 설정이 필요한 경우 어디에서나 사용할 수 있다.
@Testcontainers
@ActiveProfiles("tcompose")
public abstract class TCRepositoryTest {
private static final int MYSQL_PORT = 3306;
private static final String MYSQL_SERVICE_NAME = "test-mysql";
static final DockerComposeContainer<?> composeContainer;
static {
composeContainer = new DockerComposeContainer<>(
new File("src/test/resources/docker-compose.yaml"))
.withExposedService(MYSQL_SERVICE_NAME, MYSQL_PORT);
composeContainer.start();
}
@DynamicPropertySource
static void setProperties(DynamicPropertyRegistry registry) {
int mappedPort = composeContainer.getServicePort(MYSQL_SERVICE_NAME, MYSQL_PORT);
registry.add("spring.datasource.url", () ->
String.format("jdbc:mysql://localhost:%d/test_db", mappedPort));
}
}
앞서 테스트 컨테이너가 실행 된 이후 -> TestContainers 에게 런타임에 실제 매핑된 port가 무엇인지 묻는 것이 필요 하다고 했었다.
--> 코드 상 composeContainer.getServicePort(MYSQL_SERVICE_NAME, MYSQL_PORT);이 부분을 통해 할 수 있다.
그리고 이때 프로퍼티 파일에서 url 상의 포트는 아무거나 명시해도 되지만 연결에 필요한 username, password는 앞서 사용한 docker-compose 파일에 명시했던 username, password 를 명시했어야 한다.
version: "3.2.0"
services:
# 생성되는 컨테이너 이름
mysql-prac:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: 1234
MYSQL_DATABASE: test_db
TZ: Asia/Seoul
ports:
- 3308:3306
volumes:
- ./mysql/my.cnf:/etc/mysql/mysql.conf.d/mysqld.cnf
spring:
config:
activate:
on-profile: tcompose
datasource:
# 아무포트로 명시해도 된다 동적으로 변경 해야 하기 때문
url: jdbc:mysql://localhost:3306/test_db
driver-class-name: com.mysql.cj.jdbc.Driver
# docker-compose 에 명시한 username, password 와 같아야 한다
username: root
password: 1234
결론
testcontainer 를 사용하는 목적 자체가 명등성 있는 테스트, 외부 요인에 의해 실패하지 않는 테스트가 가능하도록 위함이다. (물론 이를 위해 도커 라는 것이 설치되어있음을 전제로 하지만 )
따라서 얘를 사용해서 띄워주는 컨테이너는 항상 랜덤 포트를 host 에게 expose 한다.
일반적인 MySQLContainer 타입을 사용할 때면, url 역시 tc 에 의존적인 것을 명시할 것인데, 이 때 테스트 컨테이너가 알아서 설정해주는 것 같다.
반면 DockerComposeContainer 를 사용할 때면, 연결할 url 을 런타임에 설정하는 것이 필요하기에 @DynamicPropertySource 와 같은 것을 통해 이를 설정해야 함에 주의해야 할 듯 하다.
찍먹 삽질기였기에 , 정확하지 않을 수도 있다.
오래 걸리는 테스트 코드를 조직에서 용인할 지는 잘 모르겠다.. testcontainer 를 사용할 경우 컨테이너를 띠우는데 시간이 걸리기 때문에 테스트 실행에서 걸리는 시간이 배로 늘어난다.
이로 인해 테스트 실행을 꺼리는 경향이 생길 수 있으며 이는 테스트 존재 이유에 위협을 가하는 일이 될 것이다. (빠르게 실행되는 테스트를 통해, 베이스 코드를 수정하고 추가하는 과정에서 방금 변경된 코드가 어떤 영향을 끼치는지 아는데 도움이 될 수 있다. 테스트 실행 속도가 너무 느리다면, 테스트 run 버튼 누르는 것도 꺼릴 수 있다 )
그리고 이 글에 등장했던 testcontainer 를 CI, CD 환경에서 돌리려면 더 무거워질 것 같아서 실제로 testcontainer 를 사용하는 조직이 있는지 요런 것도 궁금하긴 하다.
Spring Boot 3.x 부터는 Project 에 있는 docker-compose 를 자동으로 함께 run 시키는 기능이 추가되었다고 하는데, 이에 대해 살펴봐야 할 것 같다
참조
https://kangwoojin.github.io/programing/auto-configure-test-database/
testcontainers공식문서들
https://www.testcontainers.org/features/networking/ https://www.baeldung.com/spring-dynamicpropertysource https://www.testcontainers.org/modules/docker_compose/ https://www.youtube.com/watch?v=v3eQCIWLYOw