🧪

Spring Boot에서 LocalStack + Testcontainers로 AWS 통합 테스트 구축하기

최민석·2025-10-30

Spring Boot에서 LocalStack + Testcontainers로 AWS 통합 테스트 구축하기

localstack_banner

현대의 많은 애플리케이션 컴포넌트가 클라우드로 이전하면서, 테스트 과정에서 테스트만을 위한 별도의 클라우드 인프라를 구성하는 것은 큰 부담으로 다가올 수 있습니다. LocalStack은 이러한 문제를 해결하기 위해 등장했습니다.

이번 포스트에서는 LocalStack을 활용하여 클라우드 인프라 의존적인 테스트 코드를 걷어내고 Testcontainers와 통합하여 어떻게 안전하고 신뢰성 있는 테스트를 할 수 있는지 정리하겠습니다.

💡 다음 선행 지식이 필요하다:

  • JUnit 5
  • Testcontainers

⚠️ 사전 준비: Docker가 설치/실행되어 있어야 한다. (Testcontainers와 LocalStack은 Docker 컨테이너를 사용한다)

LocalStack이란 무엇인가요?

LocalStack은 로컬 환경에서 AWS 클라우드 서비스를 시뮬레이션 할 수 있는 테스트 플랫폼입니다.

즉, 실제 AWS 계정이나 비용 없이도 S3, SQS, SNS, Lambda 등 수십 가지 서비스를 로컬에서 실행하고 테스트할 수 있습니다.

LocalStack이 해결하는 문제점들:

  • 테스트용 AWS 계정이 필요하지 않게 됩니다.
  • IAM 권한, 정책, 네트워크 설정을 다시 할 필요가 없어집니다.
  • 테스트 수행 시 실제 서비스 리소스를 오염시킬 가능성을 차단합니다.
  • 실행 비용이 절감됩니다.

왜 Mockito가 아닌 LocalStack인가요?

Mockito 같은 Mock 프레임워크는 코드 수준(mock level) 에서 동작합니다. 그렇기에 특정 객체의 메서드 호출을 가짜로 대체해 반환값을 제어할 뿐, 실제 인프라 동작은 검증 할 수 없습니다.

즉, "행위 검증"은 가능하지만 "시스템 통합 검증"은 불가능합니다.

반면 LocalStack은 서비스 수준에서 동작합니다. 예를 들면 다음과 같은 시나리오를 테스트할 수 있습니다.

  1. Producer가 SQS 큐로 메시지를 전송한다.
  2. 메시지가 LocalStack 내부 SQS 큐에 실제로 적재된다.
  3. Consumer가 해당 메시지를 폴링하여 처리한다.
  4. 처리 후 큐에서 삭제한다.

Mock은 빠르고 단순하지만, 인프라를 포함한 전체 시나리오 테스트에는 한계가 명확합니다. LocalStack은 Mock으로 메울 수 없는 어플리케이션과 인프라 사이의 회색 지대를 채우는 역할을 할 수 있습니다.

Mockito vs LocalStack 한눈 비교

기준 Mockito (Mock) LocalStack (서비스 시뮬레이터)
테스트 레벨 단위/행위 검증 통합/시스템 경계 검증
외부 시스템 미포함(가짜 응답) 포함(실제 API 시뮬레이션)
속도 매우 빠름 상대적으로 느림(컨테이너 스핀업)
재현성 높음(코드 고정) 높음(고정 이미지/시드)
실패 가시성 메서드/계약 단위 네트워크/권한/리소스 상태 포함
권장 사용 순수 비즈니스 로직 인프라 의존 시나리오/회귀

Spring에서 Testcontainer 기반 LocalStack 환경 구축하기

LocalStack은 localstack-utils를 이용하는 방법으로도 테스팅 할 수 있지만, 본 포스팅에서는 (개인적으로) 더 나은 방법이라고 생각하는 Testcontainers를 이용해서 테스트 환경을 구축해보겠습니다. 테스트할 컴포넌트는 EventBridgeSQS이며, 다른 컴포넌트 테스트를 원하신다면 그에 맞는 awssdk 리소스를 추가하시기 바랍니다.

의존성 추가하기

build.gradle

dependencies {
    // --- AWS SDK v2 (BOM으로 버전 정렬) ---
    testImplementation platform("software.amazon.awssdk:bom:2.32.28")
    testImplementation "software.amazon.awssdk:sqs"
    testImplementation "software.amazon.awssdk:scheduler"

    // --- Testcontainers 2.x (BOM으로 모듈 버전 정렬) ---
    testImplementation platform("org.testcontainers:testcontainers-bom:2.0.0")
    testImplementation "org.testcontainers:testcontainers-junit-jupiter"  // JUnit 5 연동
    testImplementation "org.testcontainers:testcontainers-localstack"     // LocalStack 모듈

    // --- Spring Test & Spring-Boot ↔ Testcontainers 자동연결(@ServiceConnection) ---
    testImplementation "org.springframework.boot:spring-boot-starter-test"
    testImplementation "org.springframework.boot:spring-boot-testcontainers"

    // --- JUnit 플랫폼 런처 (Gradle/IDE 실행 안정성) ---
    testRuntimeOnly "org.junit.platform:junit-platform-launcher"
}

test {
    useJUnitPlatform()
}

위 버전은 예시이며, 실제 프로젝트에서는 최신 BOM/모듈 버전을 확인해 맞춥니다. (Testcontainers 2.x, AWS SDK v2 BOM 권장)

AWS Config & Bean 등록하기

제 프로젝트 같은 경우에는 외부에서 AWS 엑세스 키 및 시크릿 키를 받아서 AwsCredentialsProvider를 사용했습니다.

@Configuration
@Slf4j
public class AwsCommonConfig {
    
    
    @Value("${aws.access-key}")
    private String awsAccessKey;
    
    @Value("${aws.secret-key}")
    private String awsSecretKey;
    
    @Bean
    public AwsCredentialsProvider awsCredentialsProvider() {
        AwsBasicCredentials credentials = AwsBasicCredentials.create(awsAccessKey, awsSecretKey);
        log.info("AWS Credentials Successfully Generated");
        return StaticCredentialsProvider.create(credentials);
    }
    
    
}

로그에는 절대 자격증명 값을 직접 남기지 않습니다. 위 예시처럼 “생성 성공” 정도의 메시지로 충분합니다.

LocalStack에서는 이 AccessKey, SecretKey가 무엇이든 딱히 검증을 하지 않고 모두 허용합니다. 따라서 application-test.yaml에 다음과 같이 지정합니다:

aws:
  access-key: test
  secret-key: test

이로써 테스트 환경에서는 모든 AWS 리소스에 대해 "test" 자격증명을 사용하게 되어, 실수로 실제 AWS를 호출할 가능성을 원천 차단합니다.

제가 서비스에서 사용하는 EventBridge 클라이언트 빈을 확인해보겠습니다.

@Configuration
public class EventBridgeSchedulerConfig {
    
    @Bean
    public SchedulerClient schedulerClient(AwsCredentialsProvider credentialsProvider) {
        return SchedulerClient.builder()
            .region(Region.AP_NORTHEAST_2)
            .credentialsProvider(credentialsProvider)
            .build();
    
    }
    
}

아까 만든 AwsCredentialsProvider빈을 주입받아서 처리합니다. 하지만 이를 절대로 그대로 사용해서는 안되는데요, 이렇게 하는경우 기본 엔드포인트가 AWS측을 향해 있기 때문입니다.

따라서 Test에서 사용할 별도의 SchedulerClient 빈을 테스트 클래스 안에 하나 더 만들고, @Primary옵션을 붙여줍니다. 이때, 메서드 이름을 똑같이 하면 중복 빈 충돌 문제가 생기니, 앞에 test prefix를 붙여주었습니다:

@SpringBootTest
@AutoConfigureMockMvc
@Testcontainers
@Tag("integration")
@ActiveProfiles("test")
@Transactional
@Slf4j
public class MyAwesomeTest {

    
   
    @Container
    @ServiceConnection
    static final MySQLContainer<?> MYSQL = new MySQLContainer<>("mysql:8.0.32");
    
    
    @Container
    public static final LocalStackContainer localstack =
        new LocalStackContainer(DockerImageName.parse("localstack/localstack:3.8"))
            // v3 계열에서는 SERVICES가 allow-list처럼 동작한다. 명시한 서비스만 기동됨.
            .withEnv("SERVICES", "sqs,scheduler");
    
    
    @DynamicPropertySource
    static void props(DynamicPropertyRegistry registry) {
        registry.add("cloud.aws.region.static", localstack::getRegion);
        registry.add("aws.endpoint.sqs", () -> localstack.getEndpoint().toString());
        registry.add("aws.endpoint.scheduler", () -> localstack.getEndpoint().toString());
        // ... 생략
    }
    
    
    @TestConfiguration
    static class LocalstackTestBeans {
        
        @Bean
        @Primary
        SchedulerClient testSchedulerClient(AwsCredentialsProvider provider) {
            // 서비스별 엔드포인트를 명시하면 내부 포트/라우팅 변경에도 안전하다.
            return SchedulerClient.builder()
                .endpointOverride(localstack.getEndpoint())
                .region(Region.of(localstack.getRegion()))
                .credentialsProvider(provider)
                .build();
        }
        
        @Bean
        @Primary
        SqsClient testSqsClient(AwsCredentialsProvider provider) {
            return SqsClient.builder()
                .endpointOverride(localstack.getEndpoint())
                .region(Region.of(localstack.getRegion()))
                .credentialsProvider(provider)
                .build();
        }
    }

}

위에 작성된 것처럼, LocalStack 컨테이너가 어떤 엔드포인트(포트포함)를 쓸지 런타임까지 알 수 없기 때문에, @DynamicPropertySource를 사용하여 동적으로 엔드포인트 정보를 받습니다. 이를 통해 sqs, scheduler가 자동으로 LocalStack 컨테이너를 바라보도록 할 수도 있습니다. 그러나, LocalstackTestBeans안에 이번 테스트에서 필요한 빈을 따로 정의하는 편이 더 깔끔하다고 생각합니다. .endpointOverride를 통해 엔드포인트 주소를 직접 매핑하고, Region도 같은 방식으로 매핑합니다. 두 방식 중에 편하신 방식을 사용하시면 됩니다. 서비스별 엔드포인트를 지정하면 리버스 프록시/포트 전략 변경에도 테스트가 더 견고해집니다.

이제 테스트코드만 짜면 되는데요, 제 예시를 보여드리겠습니다.


@Nested
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@DisplayName("크롤링 트리거 시나리오")
class CrawlTriggerSchedulerReserveScenario extends FlywaySanitizer {
    
    private String queueName;
    private String queueUrl;
    private Long adIdx;
    private Long cuIdx;
    private String llmId;
    
    @BeforeAll
    void setUp() throws Exception {
        // GIVEN
        // ========== 1. 🐳 Preparing SQS Queue ==========
        // application-test.yaml의 ARN과 동일한 이름으로 큐를 만들어준다.
        // arn:aws:sqs:us-east-1:000000000000:test-crawling-request-queue
        queueName = "test-crawling-request-queue";
        
        try {
            CreateQueueResponse created = sqsClient.createQueue(CreateQueueRequest.builder()
                .queueName(queueName)
                .attributes(Map.of(QueueAttributeName.VISIBILITY_TIMEOUT, "30"))
                .build());
            queueUrl = created.queueUrl();
        } catch (QueueNameExistsException e) {
            // 이미 있으면 재사용
            queueUrl = sqsClient.getQueueUrl(GetQueueUrlRequest.builder().queueName(queueName).build()).queueUrl();
        }
        log.info("SQS ready. name={}, url={}", queueName, queueUrl);
        
        
        // ========== 2. 🐳 Apply LLM model Id ==========
        llmId = "anthropic.claude-3-haiku-20240307-v1:0";
        LargeLanguageModelCreateRequest largeLanguageModelCreateRequest = new LargeLanguageModelCreateRequest(llmId, "모델 테스트");
        mockMvcTestHelper.createLargeLanguageModel(largeLanguageModelCreateRequest);
        
        // ========== 3. 🐳 Create New Admin & Customer ==========
        
        AdminCreateRequest adminCreateRequest = new AdminCreateRequest(
            "테스트", "test4187@example.com", "testPass12!@",
            "01028773332", "000113", "기술개발부"
        );
        CustomerCreateRequest customerCreateRequest = new CustomerCreateRequest(
            "테스트고객사9", "01052991122", null, null, null
        );
        
        adIdx = mockMvcTestHelper.createAdmin(adminCreateRequest);
        cuIdx = mockMvcTestHelper.createCustomer(customerCreateRequest);
        // ========== 4. 🐳 Linking for make ManageInfo ==========
        mockMvcTestHelper.linkAdminToCustomer(cuIdx, new CreateManageInfoRequest(adIdx));
        // ========== 5. 🐳 Update ManageInfo for reserve schedule ==========
        ManageInfoUpdateRequest manageInfoUpdateRequest = new ManageInfoUpdateRequest();
        mockMvcTestHelper.updateManageInfo(cuIdx, manageInfoUpdateRequest);
        // ========== 6. 🐳 Apply new Keyword for ManageInfo ==========
        Map<KeywordTarget, List<String>> ownKeywords = new HashMap<>();
        ownKeywords.put(KeywordTarget.CRAWLING_BOTH, List.of("apple", "samsung"));
        ManageInfoKeywordsUpdateRequest manageInfoKeywordsUpdateRequest = new ManageInfoKeywordsUpdateRequest();
        mockMvcTestHelper.updateKeyword(cuIdx, manageInfoKeywordsUpdateRequest);
        
        // WHEN
        crawlingTriggerScheduler.crawlingTriggerScheduler();
    }
    
    
    @Test
    @Order(1)
    @DisplayName("THEN 1: interval에 따른 올바른 스케줄 개수가 등록되었는지 확인")
    void 스케줄_개수_확인() throws Exception {
        int expectedCalls = 12 * 24 * 2;
        // -- 페이징 처리: nextToken을 따라 전량 수집한다.
        String token = null;
        List<ScheduleSummary> schedules = new ArrayList<>();
        do {
            var resp = schedulerClient.listSchedules(ListSchedulesRequest.builder()
                .groupName("default")
                .maxResults(100)
                .nextToken(token)
                .build());
            schedules.addAll(resp.schedules());
            token = resp.nextToken();
        } while (token != null);

        assertThat(schedules.size()).isEqualTo(expectedCalls);
        log.info("Expected count: {}", expectedCalls);
        log.info("Scheduled count: {}", schedules.size());

        // 샘플 한 건을 조회해 payload를 점검한다.
        String sampleName = schedules.get(0).name();
        GetScheduleResponse scheduleDetail = schedulerClient.getSchedule(
            GetScheduleRequest.builder().name(sampleName).build());
        String payloadJson = scheduleDetail.target().input();

        log.info("Sample schedule payload: {}", payloadJson);
        // keyword 필드가 존재하고, 예시 키워드 중 하나(apple/samsung)가 포함되는지 확인
        assertThat(payloadJson).contains("keyword");
        assertThat(payloadJson).matches(s -> s.contains("apple") || s.contains("samsung"));
    }
    
}

참고: @ServiceConnection과 트랜잭션 롤백의 범위

  • @ServiceConnection(Spring Boot 3.1+): Testcontainers 컨테이너의 접속 정보를 자동으로 애플리케이션 컨텍스트에 주입해줍니다. 별도 spring.* 프로퍼티를 덜 작성해도 됩니다.
  • 트랜잭션 롤백 범위: @TransactionalDB에만 영향을 줍니다. SQS/Scheduler 같은 외부 시스템 상태는 롤백되지 않으므로 테스트 종료 시 정리 로직(큐 비우기/스케줄 삭제 등)이 필요합니다.

한계와 우회책(실전에서 자주 만나는 이슈)

  • 서비스 커버리지 갭: 일부 AWS 서비스/엔드포인트는 실제와 동작 차이가 있을 수 있습니다. 커버리지 문서를 수시로 확인하고, 필요 시 계약 테스트/스텁을 혼용합니다.
  • 스케줄 실행기 차이: EventBridge Scheduler의 실제 실행 동작은 제한적일 수 있습니다. 본문처럼 리소스 생성/조회 검증 중심으로 작성하고, 실행은 애플리케이션 레벨에서 별도 시뮬레이션합니다.
  • IAM/ARN 불일치: 테스트 리전/계정(000000000000) 기반 ARN을 사용한다. 주석/설정의 리전을 일치시켜 혼선을 줄입니다.
  • 성능/대기 시간: 컨테이너 스핀업 때문에 테스트가 느려질 수 있습니다. 이미지 프리풀, 컨테이너 재사용 옵션 등을 활용합니다.

CI에서 속도 높이는 팁

  • 이미지 프리풀: CI 시작 단계에 docker pull localstack/localstack:3.8 수행.
  • 컨테이너 재사용: ~/.testcontainers.propertiestestcontainers.reuse.enable=true 설정(공유 러너 보안 정책 확인).
  • JUnit 병렬화: I/O 경합이 적은 테스트를 병렬로 실행하고, 컨테이너 생성은 최소화합니다.
  • 캐시: Gradle 캐시 및 Docker 레이어 캐시를 적극 활용합니다.

정리

이 글에서는 LocalStack과 Testcontainers로 실제 AWS 없이도 인프라 의존 통합 테스트를 구축하는 방법을 살펴보았습니다.

여러 애플리케이션 간에 하나의 LocalStack으로 테스트를 해야하는 경우가 있을 수 있습니다. 그런 경우는 Docker compose 를 이용하거나 Kubernetes 환경이라면 Job을 임시로 만들어서 단인 LocalStack을 배포하는 방법을 사용해야합니다.