
현대의 많은 애플리케이션 컴포넌트가 클라우드로 이전하면서, 테스트 과정에서 테스트만을 위한 별도의 클라우드 인프라를 구성하는 것은 큰 부담으로 다가올 수 있습니다. LocalStack은 이러한 문제를 해결하기 위해 등장했습니다.
이번 포스트에서는 LocalStack을 활용하여 클라우드 인프라 의존적인 테스트 코드를 걷어내고 Testcontainers와 통합하여 어떻게 안전하고 신뢰성 있는 테스트를 할 수 있는지 정리하겠습니다.
💡 다음 선행 지식이 필요하다:
- JUnit 5
- Testcontainers
⚠️ 사전 준비: Docker가 설치/실행되어 있어야 한다. (Testcontainers와 LocalStack은 Docker 컨테이너를 사용한다)
LocalStack은 로컬 환경에서 AWS 클라우드 서비스를 시뮬레이션 할 수 있는 테스트 플랫폼입니다.
즉, 실제 AWS 계정이나 비용 없이도 S3, SQS, SNS, Lambda 등 수십 가지 서비스를 로컬에서 실행하고 테스트할 수 있습니다.
LocalStack이 해결하는 문제점들:
Mockito 같은 Mock 프레임워크는 코드 수준(mock level) 에서 동작합니다. 그렇기에 특정 객체의 메서드 호출을 가짜로 대체해 반환값을 제어할 뿐, 실제 인프라 동작은 검증 할 수 없습니다.
즉, "행위 검증"은 가능하지만 "시스템 통합 검증"은 불가능합니다.
반면 LocalStack은 서비스 수준에서 동작합니다. 예를 들면 다음과 같은 시나리오를 테스트할 수 있습니다.
Mock은 빠르고 단순하지만, 인프라를 포함한 전체 시나리오 테스트에는 한계가 명확합니다. LocalStack은 Mock으로 메울 수 없는 어플리케이션과 인프라 사이의 회색 지대를 채우는 역할을 할 수 있습니다.
Mockito vs LocalStack 한눈 비교
| 기준 | Mockito (Mock) | LocalStack (서비스 시뮬레이터) |
|---|---|---|
| 테스트 레벨 | 단위/행위 검증 | 통합/시스템 경계 검증 |
| 외부 시스템 | 미포함(가짜 응답) | 포함(실제 API 시뮬레이션) |
| 속도 | 매우 빠름 | 상대적으로 느림(컨테이너 스핀업) |
| 재현성 | 높음(코드 고정) | 높음(고정 이미지/시드) |
| 실패 가시성 | 메서드/계약 단위 | 네트워크/권한/리소스 상태 포함 |
| 권장 사용 | 순수 비즈니스 로직 | 인프라 의존 시나리오/회귀 |
LocalStack은 localstack-utils를 이용하는 방법으로도 테스팅 할 수 있지만, 본 포스팅에서는 (개인적으로) 더 나은 방법이라고 생각하는 Testcontainers를 이용해서 테스트 환경을 구축해보겠습니다.
테스트할 컴포넌트는 EventBridge와 SQS이며, 다른 컴포넌트 테스트를 원하신다면 그에 맞는 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 엑세스 키 및 시크릿 키를 받아서 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.* 프로퍼티를 덜 작성해도 됩니다.@Transactional은 DB에만 영향을 줍니다. SQS/Scheduler 같은 외부 시스템 상태는 롤백되지 않으므로 테스트 종료 시 정리 로직(큐 비우기/스케줄 삭제 등)이 필요합니다.docker pull localstack/localstack:3.8 수행.~/.testcontainers.properties에 testcontainers.reuse.enable=true 설정(공유 러너 보안 정책 확인).이 글에서는 LocalStack과 Testcontainers로 실제 AWS 없이도 인프라 의존 통합 테스트를 구축하는 방법을 살펴보았습니다.
여러 애플리케이션 간에 하나의 LocalStack으로 테스트를 해야하는 경우가 있을 수 있습니다. 그런 경우는 Docker compose 를 이용하거나 Kubernetes 환경이라면 Job을 임시로 만들어서 단인 LocalStack을 배포하는 방법을 사용해야합니다.