안녕하세요. 주문마케팅서비스팀 송정훈입니다.

이 글에서는 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 와 같은 도구의 힘을 빌리면 충분히 가능하겠다고 생각하게 되었습니다.

localstack diagram

출처 : LocalStack Intro

한가지 더 보통은 하나의 시스템에 여러 프로젝트가 동시에 진행되게 되는데, 그러다보면 프로젝트별 QA환경을 만드는데도 AWS 서비스가 걸림돌이 되는 경우가 많습니다.

localstack 과 kubernates 와 같은 솔루션을 결합한다면 쉽게 격리된 QA환경을 쉽게 만들고 삭제하는것도 가능할것으로 보입니다.

다음 포스팅은 이러한 미션을 달성한 후에 찾아뵐 수 있었으면 좋겠습니다. ^_^

여기 나온 코드들은 localstack-example 에서 확인하실 수 있습니다.

감사합니다!