LocalStack을 활용한 Integration Test 환경 만들기
안녕하세요. 주문마케팅서비스팀 송정훈입니다.
이 글에서는 AWS 서비스를 활용하는 웹 어플리케이션이 클라우드 환경이 아닌, 로컬개발환경에서 쉽게 실행하고 테스트할 수 있는 방법에 대해서 설명하고자 합니다.
우리의 Integration Test 를 방해하는 것은 무엇일까?
배달의민족의 주문시스템은 AWS 에서 제공하는 다양한 서비스(sns, sqs, dynamodb 등)를 활용하고 있습니다.
AWS 는 무척 좋은서비스였지만 개발과정에서 이와 관련하여 몇가지 제약사항들이 생겼는데요. 이로인하여 로컬개발환경에서 발생하는 문제점들이 있었습니다.
아래는 저희팀에서 겪었던 로컬개발환경에서 AWS 서비스 사용에 따른 문제점입니다.
- 로컬개발환경에서 AWS 서비스에 접근하기 위하여 accesskey 와 secretkey 를 선언하고 관리해야 합니다.
코드레벨에서 관리하게 된다면 유출되어 보안사고가 발생할 수 있으며, 팀에서 별도로 관리하는 방법 역시 번거롭습니다. - (보안상의 이유로) 그마저도 accesskey, secretkey 사용을 하지 못하게 된다면, 실제로 AWS 클라우드상에 배포하기전까지 테스트가 불가능하기도 합니다.
- AWS 상에 만들어지고 사용되는 서비스들은 다양한 인스턴스에서 접근하고 사용되고 있기 때문에 격리된 환경을 구축하기 어렵고, 이로 인하여 의도와는 다르게 테스트코드가 실패하게 되기도 합니다.
- 개발과정에서 계속 AWS 서비스를 등록하고 삭제하는것은 비용측면에서도 좋지 못합니다.
그 중 가장 심각했던 문제는 Integration Test 코드가 정상적으로 동작하기 어렵다는 점 이었습니다.
그래서 이 문제를 해결하기 위한 방법을 찾아보기 시작하였고, LocalStack 을 사용한 테스트환경 구축에 다다르게 되었습니다.
저희가 LocalStack 을 사용하여 테스트 및 로컬개발환경을 구축하고 고민하였던 경험을 공유하도록 하겠습니다.
LocalStack 와 JUnit 5 를 사용하여 테스트하기
LocalStack은 AWS 클라우드 리소스의 기능을 에뮬레이션하여 제공하여 줍니다.
localstack 은 로컬에서 단독으로 실행이 가능하며 이것을 사용하여 로컬환경에서 AWS 서비스를 사용하는 웹 어플리케이션을 쉽게 테스트할 수 있습니다.
AWS 에서 자주 사용되는 서비스들을 대부분 지원하고 있고, 도커를 사용하여 손쉽게 실행할 수 있기 때문에 도입에 어려운 점은 크게 없었습니다.
localstack-utils
와 같은 라이브러리를 제공하여 junit 에서 쉽게 localstack 을 실행하는 방법도 제공하고 있습니다.
S3 에 버킷을 생성하고 파일을 하나 업로드하고 다시 읽어보는 간단한 테스트코드를 작성해 보았습니다.
먼저 아래와 같이 gradle 에 관련된 의존성들을 추가하고, 테스트코드를 작성하면 됩니다.
dependencies {
implementation("com.amazonaws:aws-java-sdk-s3")
implementation("cloud.localstack:localstack-utils")
}
@Slf4j
@ExtendWith(LocalstackDockerExtension.class)
@LocalstackDockerProperties(services = {"s3"}, randomizePorts = true)
class LocalStackDockerExtensionTest {
@Test
void test() throws Exception {
AmazonS3 s3 = DockerTestUtils.getClientS3();
String bucketName = "test-s3";
s3.createBucket(bucketName);
log.info("버킷을 생성했습니다. bucketName={}", bucketName);
String content = "Hello World";
String key = "s3-key";
s3.putObject(bucketName, key, content);
log.info("파일을 업로드하였습니다. bucketName={}, key={}, content={}", bucketName, key, content);
List<String> results = IOUtils.readLines(s3.getObject(bucketName, key).getObjectContent(), "utf-8");
log.info("파일을 가져왔습니다. bucketName={}, key={}, results={}", bucketName, key, results);
assertThat(results).hasSize(1);
assertThat(results.get(0)).isEqualTo(content);
}
}
테스트코드가 실행되면 자동으로 localstack 이미지가 다운로드되며 실행이 되며, 테스트가 완료되면 실행되었던 localstack 컨테이너는 자동으로 종료되게 됩니다.
DockerTestUtils
를 사용하여 가져온 S3 클라이언트를 사용하여 버킷을 만들거나 파일을 업로드하는 작업을 수행할 수 있습니다.
16:23:11.115 [main] INFO io.github.woowabros.step1.LocalStackDockerExtensionTest - 버킷을 생성했습니다. bucketName=test-s3
16:23:11.201 [main] INFO io.github.woowabros.step1.LocalStackDockerExtensionTest - 파일을 업로드하였습니다. bucketName=test-s3, key=s3-key, content=Hello World
16:23:11.265 [main] INFO io.github.woowabros.step1.LocalStackDockerExtensionTest - 파일을 가져왔습니다. bucketName=test-s3, key=s3-key, results=[Hello World]
Testcontainers 를 사용하여 LocalStack 사용해보기
localstack 컨테이너를 실행하는 다른 방법으로는 Testcontainers 을 사용하는 방법이 있습니다.
Testcontainers 는 코드상에서 여러 도커컨테이너들을 실행하고 테스트코드와 연동할 수 있는 방법을 제공해줍니다.
localstack-utils
를 사용하는 방법도 괜찮았지만 개인적으로는 Testcontainers 를 사용하는 방법이 훨씬 마음에 들었습니다.
gradle 에 의존성을 추가하고 위와 동일한 내용의 테스트코드를 Testcontainers 를 사용하여 작성해보았습니다.
dependencies {
implementation("com.amazonaws:aws-java-sdk-s3")
implementation("org.testcontainers:localstack")
testImplementation("org.testcontainers:junit-jupiter")
}
@Slf4j
@Testcontainers
class LocalStackTestcontainerTest {
@Container
LocalStackContainer container = new LocalStackContainer()
.withServices(LocalStackContainer.Service.S3);
@Test
void test() throws Exception {
AmazonS3 s3 = AmazonS3ClientBuilder.standard()
.withEndpointConfiguration(container.getEndpointConfiguration(LocalStackContainer.Service.S3))
.withCredentials(container.getDefaultCredentialsProvider())
.build();
String bucketName = "test-s3";
s3.createBucket(bucketName);
log.info("버킷을 생성했습니다. bucketName={}", bucketName);
String content = "Hello World";
String key = "s3-key";
s3.putObject(bucketName, key, content);
log.info("파일을 업로드하였습니다. bucketName={}, key={}, content={}", bucketName, key, content);
List<String> results = IOUtils.readLines(s3.getObject(bucketName, key).getObjectContent(), "utf-8");
log.info("파일을 가져왔습니다. bucketName={}, key={}, results={}", bucketName, key, results);
assertThat(results).hasSize(1);
assertThat(results.get(0)).isEqualTo(content);
}
}
localstack-utils
와 동일하게 localstack 컨테이너가 실행되고 테스트가 수행되는 모습을 보실 수 있습니다.
16:20:57.036 [main] INFO 🐳 [localstack/localstack:0.8.6] - Creating container for image: localstack/localstack:0.8.6
16:20:58.449 [main] INFO 🐳 [localstack/localstack:0.8.6] - Starting container with ID: 7804d07a64056a8bb76ae6b04242080f5b5e22fa8a45eb4e8e5780aa7ecf9640
16:20:59.650 [main] INFO 🐳 [localstack/localstack:0.8.6] - Container localstack/localstack:0.8.6 is starting: 7804d07a64056a8bb76ae6b04242080f5b5e22fa8a45eb4e8e5780aa7ecf9640
16:21:05.454 [main] INFO 🐳 [localstack/localstack:0.8.6] - Container localstack/localstack:0.8.6 started
16:21:06.610 [main] INFO io.github.woowabros.step1.LocalStackTestcontainerTest - 버킷을 생성했습니다. bucketName=test-s3
16:21:06.754 [main] INFO io.github.woowabros.step1.LocalStackTestcontainerTest - 파일을 업로드하였습니다. bucketName=test-s3, key=s3-key, content=Hello World
16:21:06.818 [main] INFO io.github.woowabros.step1.LocalStackTestcontainerTest - 파일을 가져왔습니다. bucketName=test-s3, key=s3-key, results=[Hello World]
Integration Test 에서 LocalStack
이제 Integration Test 에서 localstack 을 활용해보고자 합니다.
localstack 을 사용하는 S3 클라이언트는 도커컨테이너가 실행이 완료된 이후 시점에 가져올 수 있는데, 이것을 스프링 Bean 으로 등록하기 위하여 testcontainers 가 제공하는 @Testcontainers
애노테이션을 사용하여 처리하기는 까다로워 조금 다른 방법을 사용하기로 하였습니다.
아래와 같이 localstack 을 사용한 S3 클라이언트를 빈으로 생성하는 코드를 테스트코드에 추가하고 오버라이딩 하는 방법을 사용하였습니다.
@TestConfiguration
public class LocalStackS3Config {
@Bean(initMethod = "start", destroyMethod = "stop")
public LocalStackContainer localStackContainer() {
return new LocalStackContainer()
.withServices(LocalStackContainer.Service.S3);
}
@Bean
public AmazonS3 amazonS3(LocalStackContainer localStackContainer) {
return AmazonS3ClientBuilder.standard()
.withEndpointConfiguration(localStackContainer.getEndpointConfiguration(LocalStackContainer.Service.S3))
.withCredentials(localStackContainer.getDefaultCredentialsProvider())
.build();
}
}
@Slf4j
@Tag("integration")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = LocalStackS3Config.class)
class WallControllerIntegrationTest {
@Autowired
TestRestTemplate testRestTemplate;
@Autowired
AmazonS3 amazonS3;
@BeforeEach
void init() {
amazonS3.createBucket(WallController.BUCKET_WALL_CONTENT);
}
@Test
void test() throws Exception {
String id = "gaemi";
String content = "Hello World";
PostRequest postRequest = new PostRequest();
postRequest.setContent(content);
String createResponse = testRestTemplate.postForObject("/wall/{id}", postRequest, String.class, id);
log.info("createResponse={}", createResponse);
assertThat(createResponse).isNotBlank();
String getResponse = testRestTemplate.getForObject("/wall/{id}", String.class, id);
log.info("getResponse={}", getResponse);
assertThat(getResponse).isEqualTo(content);
}
}
만약 Spring Boot 2.1 이상을 사용하고 계신다면 기본적으로 Bean 오버라이딩이 비활성화 되어 있습니다. (https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-2.1-Release-Notes#bean-overriding)
test/resources/application.properties
에 아래 내용을 추가해주시면 됩니다.spring.main.allow-bean-definition-overriding=true
앞으로 반복적으로 사용하게 될 LocalStackContainer Bean 을 생성하는 부분은 별도 모듈로 빼도 괜찮을 것 같습니다.
testcontainer-support
라는 모듈을 만들어 봤습니다.
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Import(AwsLocalStackConfig.class)
public @interface EnableAwsLocalStack {
LocalStackContainer.Service[] value();
}
@Configuration
public class AwsLocalStackConfig implements ImportAware {
private AnnotationAttributes annotationAttributes;
@Override
public void setImportMetadata(AnnotationMetadata importMetadata) {
this.annotationAttributes = AnnotationAttributes.fromMap(importMetadata
.getAnnotationAttributes(EnableAwsLocalStack.class.getName(), false));
Assert.notNull(this.annotationAttributes,
"@EnableAwsLocalStack is not present on importing class "
+ importMetadata.getClassName());
}
@Bean(initMethod = "start", destroyMethod = "stop")
public LocalStackContainer localStackContainer() {
LocalStackContainer.Service[] values = (LocalStackContainer.Service[]) annotationAttributes.get("value");
Assert.isTrue(values.length > 0, "LocalStack 을 사용할 서비스를 하나 이상 선택하여야 합니다.");
return new LocalStackContainer().withServices(values);
}
}
이제 테스트코드의 LocalStackS3Config 을 아래와 같이 수정하시면 됩니다.
dependencies {
...
testImplementation project(":testcontainer-support")
}
@EnableAwsLocalStack({S3})
@TestConfiguration
public class LocalStackS3Config {
@Bean
public AmazonS3 amazonS3(LocalStackContainer localStackContainer) {
return AmazonS3ClientBuilder.standard()
.withEndpointConfiguration(localStackContainer.getEndpointConfiguration(LocalStackContainer.Service.S3))
.withCredentials(localStackContainer.getDefaultCredentialsProvider())
.build();
}
}
로컬에서 실행하는 웹 애플리케이션과 LocalStack 연동하기
여기까지 읽어보신분들은 예상하셨겠지만 localstack 을 사용하면 로컬환경에서 웹 애플리케이션을 실행하고 테스트하는것도 충분히 가능합니다.
웹 애플리케이션이 실행되는 과정에 자동으로 localstack 도커 컨테이너가 실행되고, 웹 애플리케이션이 종료되면 역시 도커 컨테이너 역시 종료가 됩니다.
아래와 같이 profile 별로 동작하는 2개의 DynamoDBConfig 를 생성하여 로컬환경에서는 localstack 을 사용할 수 있도록 구성해보았습니다.
@Profile("!local")
@EnableDynamoDBRepositories(basePackages = {"io.github.woowabros.step2.domain"})
@Configuration
public class DynamoDBConfig {
@Bean
public AmazonDynamoDB amazonDynamoDB() {
return AmazonDynamoDBClientBuilder.standard()
.withRegion(Regions.AP_NORTHEAST_2)
.build();
}
}
@Profile("local")
@EnableAwsLocalStack({DYNAMODB})
@EnableDynamoDBRepositories(basePackages = {"io.github.woowabros.step2.domain"})
@Configuration
public class DynamoDBLocalConfig {
@Bean
public AmazonDynamoDB amazonDynamoDB(LocalStackContainer localStackContainer) {
return AmazonDynamoDBClientBuilder.standard()
.withEndpointConfiguration(localStackContainer.getEndpointConfiguration(DYNAMODB))
.withCredentials(localStackContainer.getDefaultCredentialsProvider())
.build();
}
}
Spring Data DynamoDB 를 사용한다면
5.1.0
이상부터 진행하는 Autocreate Tables 기능을 사용한다면 테이블도 쉽게 생성하실 수 있습니다. (https://github.com/derjust/spring-data-dynamodb/wiki/Autocreate-Tables)spring.data.dynamodb.entity2ddl.auto=create-only spring.data.dynamodb.entity2ddl.gsiProjectionType=ALL spring.data.dynamodb.entity2ddl.readCapacity=10 spring.data.dynamodb.entity2ddl.writeCapacity=1
여러 웹 애플리케이션과 LocalStack 연동하기
최근 많은 애플리케이션들은 SQS 와 같은 서비스를 사용하여 비동기로 메시지를 주고 받는 경우가 많이 있습니다.
이런 애플리케이션간의 연동을 테스트하기 위하여서는 애플리케이션 단독으로 localstack 컨테이너를 실행하고 사용하는 방법으로는 해결하기가 어렵습니다.
이와 같은 이유로 저희는 웹 애플리케이션과 별도로 동작하는 localstack 실행환경을 만들고 로컬개발환경에서 활용하는 방법을 최종적으로 사용하게 되었습니다.
LocalStack 에서 제공하는 docker-compose.yml
을 사용하여 컨테이너를 실행하는 스크립트를 만들어 프로젝트에 추가하여 사용하고 있습니다.
version: '2.1'
services:
localstack:
image: localstack/localstack
ports:
- "4567-4597:4567-4597"
- "${PORT_WEB_UI-8080}:${PORT_WEB_UI-8080}"
environment:
- SERVICES=${SERVICES- }
- DEBUG=${DEBUG- }
- DATA_DIR=${DATA_DIR- }
- PORT_WEB_UI=${PORT_WEB_UI- }
- LAMBDA_EXECUTOR=${LAMBDA_EXECUTOR- }
- KINESIS_ERROR_PROBABILITY=${KINESIS_ERROR_PROBABILITY- }
- DOCKER_HOST=unix:///var/run/docker.sock
volumes:
- "${TMPDIR:-/tmp/localstack}:/tmp/localstack"
- "/var/run/docker.sock:/var/run/docker.sock"
#!/bin/bash
cd "$(dirname "$0")"
export SERVICES=s3,sns,sqs,dynamodb # 여기에 사용하는 서비스들을 추가합니다.
export TMPDIR=/private$TMPDIR # 개발용으로 MacOS 를 사용하고 있기 때문에 temp directory 를 별도로 지정합니다.
export PORT_WEB_UI=8080
export DEBUG=0
docker-compose up -d
기본적으로 localstack 의 sqs 는 4576 포트로 바인딩이 되어 있으며, 필요하다면 별도로 지정하실수 있습니다.
아래와 같이 Configuration 을 작성하시면 됩니다. (물론 설정값은 properties 로 분리하는걸 추천드립니다.)
@Configuration
public class SqsConfig {
@Bean
public AmazonSQSAsync amazonSQS() {
return AmazonSQSAsyncClientBuilder.standard()
.withEndpointConfiguration(new AwsClientBuilder.EndpointConfiguration("http://localhost:4576", "us-east-1"))
.withCredentials(new AWSStaticCredentialsProvider(new BasicAWSCredentials("accesskey", "secretkey")))
.build();
}
}
SqsListener 를 만들고 테스트해보면 정상적으로 큐를 구독하고 다른 애플리케이션에서 전송하는 메시지를 받는것을 볼 수 있습니다.
@Slf4j
@Service
public class MessageSubscriber {
@SqsListener(value = "test-message", deletionPolicy = SqsMessageDeletionPolicy.ON_SUCCESS)
public void receive(String message) {
log.info("메시지를 받았습니다. message={}", message);
}
}
2019-07-18 15:03:09.821 INFO 62643 --- [enerContainer-2] i.g.w.s.s.message.MessageSubscriber : 메시지를 받았습니다. message=Hello World
2019-07-18 15:07:17.717 INFO 62643 --- [enerContainer-2] i.g.w.s.s.message.MessageSubscriber : 메시지를 받았습니다. message=또 보내볼까요.
마치며
저는 여전히 Unit Test 를 더 좋아합니다만, 여러 컴포넌트간의 통합에 대한 검증을 위하여 Integration Test 역시 중요하다고 생각하고 있습니다.
아무래도 Integration Test 는 Unit Test 와 비교하여 여러가지 제약에 의해 자동화하기 어려운 부분이 많지만, localstack 이나 testcontainers 와 같은 도구의 힘을 빌리면 충분히 가능하겠다고 생각하게 되었습니다.
한가지 더 보통은 하나의 시스템에 여러 프로젝트가 동시에 진행되게 되는데, 그러다보면 프로젝트별 QA환경을 만드는데도 AWS 서비스가 걸림돌이 되는 경우가 많습니다.
localstack 과 kubernates 와 같은 솔루션을 결합한다면 쉽게 격리된 QA환경을 쉽게 만들고 삭제하는것도 가능할것으로 보입니다.
다음 포스팅은 이러한 미션을 달성한 후에 찾아뵐 수 있었으면 좋겠습니다. ^_^
여기 나온 코드들은 localstack-example 에서 확인하실 수 있습니다.
감사합니다!