Spring Cloud AWS + LocalStack으로 AWS 서비스 통합 테스트하기

Spring Cloud AWS + LocalStack으로 AWS 서비스 통합 테스트하기

이 가이드에서는 Spring Cloud AWS로 Spring Boot 애플리케이션을 만든 뒤, Testcontainers와 LocalStack을 사용해 S3와 SQS 통합을 테스트하는 방법을 배워요.

출처: 문서

본문

이 가이드를 통해 다음 내용을 배울 수 있어요.

  • Spring Cloud AWS 통합이 포함된 Spring Boot 애플리케이션 만들기
  • AWS S3와 SQS 서비스 사용하기
  • Testcontainers와 LocalStack으로 애플리케이션 테스트하기

사전 준비 (Prerequisites)

  • Java 17 이상
  • Maven 또는 Gradle
  • Testcontainers가 지원하는 Docker 환경

참고: Testcontainers가 처음이라면 Testcontainers 개요를 방문해 알아보는 걸 권장해요.

Spring Boot 프로젝트 만들기

Spring Initializr에서 Testcontainers 스타터를 선택해 Spring Boot 프로젝트를 만들어요. Spring Cloud AWS 스타터는 Spring Initializr에 없으므로 수동으로 추가해야 해요. 또는 가이드 저장소를 클론해도 돼요.

Spring Cloud AWS BOM을 의존성 관리에 추가하고 S3, SQS 스타터를 의존성으로 추가해요. Testcontainers는 AWS 서비스 통합 테스트를 위한 LocalStack 모듈을 제공해요. 또 비동기 SQS 처리를 테스트하려면 Awaitility도 필요해요. pom.xml의 핵심 의존성은 다음과 같아요.

<properties>
    <java.version>17</java.version>
    <testcontainers.version>2.0.4</testcontainers.version>
    <awspring.version>3.0.3</awspring.version>
</properties>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>io.awspring.cloud</groupId>
        <artifactId>spring-cloud-aws-starter-s3</artifactId>
    </dependency>
    <dependency>
        <groupId>io.awspring.cloud</groupId>
        <artifactId>spring-cloud-aws-starter-sqs</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-testcontainers</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>testcontainers-junit-jupiter</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>testcontainers-localstack</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.awaitility</groupId>
        <artifactId>awaitility</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>io.awspring.cloud</groupId>
            <artifactId>spring-cloud-aws-dependencies</artifactId>
            <version>${awspring.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

구성 속성 만들기

SQS 큐 이름과 S3 버킷 이름을 설정 가능하게 만들기 위해 ApplicationProperties 레코드를 만들어요.

package com.testcontainers.demo;

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "app")
public record ApplicationProperties(String queue, String bucket) {}

그다음 메인 애플리케이션 클래스에 @ConfigurationPropertiesScan을 추가해서 Spring이 @ConfigurationProperties로 주석이 달린 클래스를 자동으로 스캔하고 빈(bean)으로 등록하게 해요.

package com.testcontainers.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;

@SpringBootApplication
@ConfigurationPropertiesScan
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

S3용 StorageService 구현하기

Spring Cloud AWS는 파일 업로드/다운로드를 위한 편의 메서드를 가진 S3Template 같은 고수준 추상화를 제공해요. StorageService 클래스를 만들어요.

package com.testcontainers.demo;

import io.awspring.cloud.s3.S3Template;
import java.io.IOException;
import java.io.InputStream;
import org.springframework.stereotype.Service;

@Service
public class StorageService {
    private final S3Template s3Template;

    public StorageService(S3Template s3Template) {
        this.s3Template = s3Template;
    }

    public void upload(String bucketName, String key, InputStream stream) {
        this.s3Template.upload(bucketName, key, stream);
    }

    public InputStream download(String bucketName, String key) throws IOException {
        return this.s3Template.download(bucketName, key).getInputStream();
    }

    public String downloadAsString(String bucketName, String key) throws IOException {
        try (InputStream is = this.download(bucketName, key)) {
            return new String(is.readAllBytes());
        }
    }
}

SQS 메시지 모델 만들기

SQS 큐로 보낼 페이로드를 나타내는 Message 레코드를 만들어요.

package com.testcontainers.demo;

import java.util.UUID;

public record Message(UUID uuid, String content) {}

메시지 전송자 구현하기

SqsTemplate을 사용해 메시지를 게시하는 MessageSender를 만들어요.

package com.testcontainers.demo;

import io.awspring.cloud.sqs.operations.SqsTemplate;
import org.springframework.stereotype.Service;

@Service
public class MessageSender {
    private final SqsTemplate sqsTemplate;

    public MessageSender(SqsTemplate sqsTemplate) {
        this.sqsTemplate = sqsTemplate;
    }

    public void publish(String queueName, Message message) {
        sqsTemplate.send(to -> to.queue(queueName).payload(message));
    }
}

메시지 리스너 구현하기

@SqsListener로 주석이 달린 핸들러 메서드를 가진 MessageListener를 만들어요. 메시지가 도착하면 리스너는 메시지 UUID를 키로 사용해 내용을 S3 버킷에 업로드해요.

package com.testcontainers.demo;

import io.awspring.cloud.sqs.annotation.SqsListener;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import org.springframework.stereotype.Service;

@Service
public class MessageListener {
    private final StorageService storageService;
    private final ApplicationProperties properties;

    public MessageListener(StorageService storageService, ApplicationProperties properties) {
        this.storageService = storageService;
        this.properties = properties;
    }

    @SqsListener(queueNames = {"${app.queue}"})
    public void handle(Message message) {
        String bucketName = this.properties.bucket();
        String key = message.uuid().toString();

        ByteArrayInputStream is = new ByteArrayInputStream(
                message.content().getBytes(StandardCharsets.UTF_8)
        );
        this.storageService.upload(bucketName, key, is);
    }
}

${app.queue} 표현식은 큐 이름을 하드코딩하지 않고 애플리케이션 설정에서 읽어와요.

Testcontainers로 테스트 작성하기

애플리케이션을 테스트하려면 AWS S3와 SQS 서비스를 에뮬레이트하는 실행 중인 LocalStack 인스턴스가 필요해요. Testcontainers가 Docker 컨테이너에서 LocalStack을 띄우고, @DynamicPropertySource가 이를 Spring Cloud AWS에 연결해요.

테스트 컨테이너 구성하기

LocalStack 컨테이너를 시작하고, 실제 AWS 서비스 대신 그와 통신하도록 Spring Cloud AWS 속성을 구성할 수 있어요. 설정해야 할 속성은 다음과 같아요.

spring.cloud.aws.s3.endpoint=http://localhost:4566
spring.cloud.aws.sqs.endpoint=http://localhost:4566
spring.cloud.aws.credentials.access-key=noop
spring.cloud.aws.credentials.secret-key=noop
spring.cloud.aws.region.static=us-east-1

테스트에는 임의의 사용 가능한 포트에서 시작하는 임시(ephemeral) 컨테이너를 사용해서, 포트 충돌 없이 CI에서 여러 빌드를 병렬로 실행할 수 있게 해요.

테스트 작성하기

MessageListenerTest.java를 만들어요.

package com.testcontainers.demo;

import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import static org.testcontainers.containers.localstack.LocalStackContainer.Service.S3;
import static org.testcontainers.containers.localstack.LocalStackContainer.Service.SQS;

import java.io.IOException;
import java.time.Duration;
import java.util.UUID;

import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.localstack.LocalStackContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;

@SpringBootTest
@Testcontainers
class MessageListenerTest {
    @Container
    static LocalStackContainer localStack =
            new LocalStackContainer(DockerImageName.parse("localstack/localstack:3.0"));

    static final String BUCKET_NAME = UUID.randomUUID().toString();
    static final String QUEUE_NAME = UUID.randomUUID().toString();

    @DynamicPropertySource
    static void overrideProperties(DynamicPropertyRegistry registry) {
        registry.add("app.bucket", () -> BUCKET_NAME);
        registry.add("app.queue", () -> QUEUE_NAME);

        registry.add("spring.cloud.aws.region.static", () -> localStack.getRegion());
        registry.add("spring.cloud.aws.credentials.access-key", () -> localStack.getAccessKey());
        registry.add("spring.cloud.aws.credentials.secret-key", () -> localStack.getSecretKey());
        registry.add("spring.cloud.aws.s3.endpoint", () -> localStack.getEndpointOverride(S3).toString());
        registry.add("spring.cloud.aws.sqs.endpoint", () -> localStack.getEndpointOverride(SQS).toString());
    }

    @BeforeAll
    static void beforeAll() throws IOException, InterruptedException {
        localStack.execInContainer("awslocal", "s3", "mb", "s3://" + BUCKET_NAME);
        localStack.execInContainer("awslocal", "sqs", "create-queue", "--queue-name", QUEUE_NAME);
    }

    @Autowired
    StorageService storageService;

    @Autowired
    MessageSender publisher;

    @Autowired
    ApplicationProperties properties;

    @Test
    void shouldHandleMessageSuccessfully() {
        Message message = new Message(UUID.randomUUID(), "Hello World");

        publisher.publish(properties.queue(), message);

        await()
            .pollInterval(Duration.ofSeconds(2))
            .atMost(Duration.ofSeconds(10))
            .ignoreExceptions()
            .untilAsserted(() -> {
                String msg = storageService.downloadAsString(properties.bucket(), message.uuid().toString());
                assertThat(msg).isEqualTo("Hello World");
            });
    }
}

테스트가 하는 일을 살펴보면,

  • @SpringBootTest가 전체 Spring 애플리케이션 컨텍스트를 시작해요.
  • Testcontainers JUnit 5 어노테이션인 @Testcontainers와 @Container가 LocalStackContainer 인스턴스의 수명주기를 관리해요.
  • @DynamicPropertySource는 컨테이너에서 동적인 S3/SQS 엔드포인트 URL, 리전, 액세스 키, 시크릿 키를 얻어 Spring Cloud AWS 구성 속성으로 등록해요.
  • @BeforeAll은 LocalStack Docker 이미지에 미리 설치된 awslocal CLI 도구로 필요한 SQS 큐와 S3 버킷을 만들어요. localStack.execInContainer() API는 컨테이너 안에서 명령을 실행해요.
  • shouldHandleMessageSuccessfully()는 SQS 큐에 Message를 게시해요. 리스너가 메시지를 받아 UUID를 키로 내용을 S3 버킷에 저장해요.
  • Awaitility는 버킷에 예상 내용이 나타날 때까지 최대 10초를 기다려요.

테스트 실행과 다음 단계

테스트를 실행해요.

$ ./mvnw test

또는 Gradle로,

$ ./gradlew test

LocalStack Docker 컨테이너가 시작되고 테스트가 통과하는 걸 볼 수 있어요. 테스트가 끝나면 컨테이너는 자동으로 중지되고 제거돼요.

요약 (Summary)

LocalStack을 사용하면 AWS 기반 애플리케이션을 로컬에서 개발하고 테스트할 수 있어요. Testcontainers LocalStack 모듈은 외부 설정 없이 임의의 포트에서 시작하는 임시 LocalStack 컨테이너를 사용해 통합 테스트를 작성하기 쉽게 만들어줘요.

Testcontainers에 대해 더 알아보고 싶다면 Testcontainers 개요를 방문해요.

더 읽어보기 (Further reading)

  • Testcontainers LocalStack 모듈
  • Testcontainers for Java 시작하기
  • Spring Cloud AWS 문서

더 알아보기 (Learn more)