카테고리 없음

LocalStack을 이용한 AWS S3 테스트 작성 (feat. Testcontainers)

kiki1875 2025. 4. 8. 17:15

1. 개요

AWS 와 같은 외부 서비스를 사용하는 어플리케이션은 테스트를 구성하기 까다롭다는 장벽이 있다. 외부 의존성, 비용, 네트워크 등의 이슈로 인해 테스트의 멱등성, 일관성 등이 보장되지 않으며, 테스트 코드가 올바르게 작성 되었지만 외부 이슈로 인해 실패할 수 도 있다.

 

이를 해결하기 위해 로컬 환경에서 AWS 를 모방하여 시뮬레이션 할 수 있도록 하는 LocalStack 을 활용하여 실제 AWS 서비스에 연결하지 않고도 AWS 의 다양한 서비스들을 시뮬레이션 해 볼 수 있다. 이번 포스팅에선, S3 기능을 테스트 해 보겠다.

 

 

2. Gradle 의존성

다음과 같이 LocalStack 의존성을 추가해 주었다.

testImplementation 'org.testcontainers:localstack:1.20.6'

 

다른 버전이 필요하다면 여기서 확인하자.

 

https://mvnrepository.com/artifact/org.testcontainers/localstack

 

 

3. docker-compose.local.yml 파일 작성

기존에 작성해둔 docker-compose.yml 이 있기 때문에 테스트 전용 compose 파일을 따로 구성하였다.

version: "3.8"  

services:  
  localstack:  
    image: localstack/localstack  
    ports:  
      - "4566:4566"  
    environment:  
      - SERVICES=s3  
      - DEBUG=1

 

위 yml 파일은 다음과 같다.

  • localstack/localstack 이미지를 사용해서 컨테이너를 만든다
  • 4566 port 를 연다
  • SERVICES : 사용할 localstack 서비스
  • DEBUG : LocalStack 내부 로그레벨

만약, 컨테이너가 종료되어도 업로드 된 파일이 유지되어야 한다면 따로 볼륨을 구성하자.

4. 테스트용 환경 변수 파일

localstack 은 AWS 처럼 인증 정보가 필요하지 않다. 그렇기 때문에 필요한 정보만 기입한 .env 파일을 새롭게 생성해 주면 된다.

AWS_S3_ACCESS_KEY=test
AWS_S3_SECRET_KEY=test
AWS_S3_REGION=ap-northeast-2
AWS_S3_BUCKET=test-bucket
AWS_S3_PRESIGNED_URL_EXPIRATION=60

5. S3Client, presignURL 생성 메서드 재정의

실제 코드의 getS3Client 와 generatePresignedUrl 은 실제 AWS 서비스에 접근하여 접속하고 작업을 실행하게 된다.

 

하지만, 지금은 테스트환경이며, 실제 AWS 서비스가 아닌 일종의 MOCK 서버로 시뮬레이션 테스트를 하는 것이기 때문에 @Override 로 해당 메서드 들을 재정의 해주어야 한다.

 

// 실제 getS3Client(){}
public S3Client getS3Client() {  
  AwsBasicCredentials credentials = AwsBasicCredentials.create(accessKey, secretKey);  

  return S3Client.builder()  
      .region(Region.of(region))  
      .credentialsProvider(StaticCredentialsProvider.create(credentials))  
      .build();  
}

// 재정의한 테스트용 getS3Client()
@Override  
public S3Client getS3Client() {  
  return S3Client.builder()  
      .endpointOverride(URI.create("http://localhost:4566")) // LocalStack 주소  
      .region(Region.of(region))  
      .credentialsProvider(StaticCredentialsProvider.create(  
          AwsBasicCredentials.create(accessKey, secretKey)))  
      .forcePathStyle(true)  
      .build();  
}
  • endpointOverride 를 통해 AWS 대신 LocalStack 으로 요청을 전송
  • 이때, forcePathStyle() 을 통해 명시적으로 경로를 입력해 준다는 것을 알려주어야 한다.
  • StaticCredentialsProvider : 를 통해 자격 증명을 명시적으로 설정
// 실제 generatePresignedUrl()
public String generatePresignedUrl(String key, String contentType) {  
  try (S3Presigner presigner = S3Presigner.builder()  
      .region(Region.of(region))  
      .credentialsProvider(StaticCredentialsProvider.create(  
          AwsBasicCredentials.create(accessKey, secretKey)))  
      .build()) {  

    GetObjectRequest getObjectRequest = GetObjectRequest.builder()  
        .bucket(bucket)  
        .key(key)  
        .build();  

    GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder()  
        .signatureDuration(Duration.ofSeconds(expirationTime))  
        .getObjectRequest(getObjectRequest)  
        .build();  

    PresignedGetObjectRequest presignedGetObjectRequest = presigner.presignGetObject(  
        presignRequest);  

    return presignedGetObjectRequest.url().toString();  
  }  
}

// 재정의한 테스트용 generatePresignedUrl()
@Override  
public String generatePresignedUrl(String key, String contentType) {  
  try (S3Presigner presigner = S3Presigner.builder()  
      .endpointOverride(URI.create("http://localhost:4566")) // LocalStack 주소  
      .region(Region.of(region))  
      .credentialsProvider(StaticCredentialsProvider.create(  
          AwsBasicCredentials.create(accessKey, secretKey)))  
      .build()) {  

    GetObjectRequest getObjectRequest = GetObjectRequest.builder()  
        .bucket(bucket)  
        .key(key)  
        .build();  

    GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder()  
        .signatureDuration(Duration.ofSeconds(expirationTime))  
        .getObjectRequest(getObjectRequest)  
        .build();  

    PresignedGetObjectRequest presignedGetObjectRequest = presigner.presignGetObject(  
        presignRequest);  
    return presignedGetObjectRequest.url().toString();  
  }  
}

 

또한, LocalStack 은 실제 S3 가 아니기 때문에, 테스트 전 임시 버킷을 생성하는 로직이 필요하다

storage.getS3Client().createBucket(CreateBucketRequest.builder().bucket(bucket).build());

6. 단점 및 대안

LocalStack 은 분명 강력한 도구이지만, 다음과 같은 단점이 있다

  • 테스트마다 컨테이너 초기화가 되지 않는다.
  • .env 혹은 compose 파일이 필요하다 -> 외부 설정 의존
  • LocalStack 컨테이너가 떠있지 않을 경우 테스트가 실패한다
  • CI 와 연동하려면 별도의 shell script 를 작성해야 한다

이 단점을 보완하고자, @TestContainers 를 활용한 방법도 작성해 보고자 한다.

 

Testcontainers + LocalStack은 테스트 코드상에서 java 코드로 직접 LocalStack 컨테이너를 자동으로 실행하고 제어하는 방식이다.

 

그렇기 때문에, 완전한 자동화, 테스트 격리성, .env 나 docker-compose 없이 실행 가능함이 보장된다.

 

이전의 LocalStack만 사용한 방식은 직접 컨테이너를 띄우고 종료하거나, shell script 를 통한 제어가 필수적이지만, Testcontainers 를 사용한 방식은 테스트마다 컨테이너가 실행되며, 테스트 종료시 자동 종료된다.

6.1 의존성

아까의 의존성에 더불어 다음 의존성을 추가하자

testImplementation 'org.testcontainers:junit-jupiter:1.20.6'

6.2 컨테이너 생성

이제 실행할 컨테이너를 명시해주어야 한다.

@Container  
static LocalStackContainer localstack = new LocalStackContainer(  
    DockerImageName.parse("localstack/localstack")  
).withServices(LocalStackContainer.Service.S3);

 

위와 같이 실행할 컨테이너 image name 과 사용할 AWS 서비스를 명시해 주면 된다.

6.3 AWS 자격 증명 부여

Testcontainers 를 사용하면, 컨테이너가 시작 될때, 자동으로 AWS credential 이 부여된다. 하지만 이 테스트 코드에선, S3Client 오버라이드 등, credential 이 필요하기 때문에, 다음과 같이 불러오면 된다.

String accessKey = localstack.getAccessKey();  
String secretKey = localstack.getSecretKey();  
URI endpoint = localstack.getEndpointOverride(LocalStackContainer.Service.S3);

 

이로써, URI 가 하드코딩되지 않아, 실제로 실행된 컨테이너의 주소를 동적으로 받아와 CI 이식성, 자동화에 매우 유리하다.

6.4 전체 테스트 코드

package com.sprint.mission.discodeit.storage;  

import com.sprint.mission.discodeit.storage.s3.S3BinaryContentStorage;  
import java.io.IOException;  
import java.io.InputStream;  
import java.net.URI;  
import java.time.Duration;  
import java.util.UUID;  
import org.assertj.core.api.Assertions;  
import org.junit.jupiter.api.BeforeAll;  
import org.junit.jupiter.api.Test;  
import org.springframework.http.ResponseEntity;  
import org.testcontainers.containers.localstack.LocalStackContainer;  
import org.testcontainers.junit.jupiter.Container;  
import org.testcontainers.junit.jupiter.Testcontainers;  
import org.testcontainers.utility.DockerImageName;  
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;  
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;  
import software.amazon.awssdk.regions.Region;  
import software.amazon.awssdk.services.s3.S3Client;  
import software.amazon.awssdk.services.s3.model.CreateBucketRequest;  
import software.amazon.awssdk.services.s3.model.GetObjectRequest;  
import software.amazon.awssdk.services.s3.presigner.S3Presigner;  
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;  
import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest;  

@Testcontainers  
public class S3BinaryContentStorageTest {  

  private static final String BUCKET = "test-bucket";  
  private static final Region REGION = Region.AP_NORTHEAST_2;  
  private static final int EXPIRATION = 60;  
  @Container  
  static LocalStackContainer localstack = new LocalStackContainer(  
      DockerImageName.parse("localstack/localstack")  
  ).withServices(LocalStackContainer.Service.S3);  

  private static S3BinaryContentStorage storage;  
  private static UUID id = UUID.randomUUID();  
  private static byte[] content = "test content".getBytes();  

  @BeforeAll  
  static void init() throws Exception {  
    String accessKey = localstack.getAccessKey();  
    String secretKey = localstack.getSecretKey();  
    URI endpoint = localstack.getEndpointOverride(LocalStackContainer.Service.S3);  

    storage = new S3BinaryContentStorage(accessKey, secretKey, REGION.id(), BUCKET, EXPIRATION) {  
      @Override  
      public S3Client getS3Client() {  
        return S3Client.builder()  
            .endpointOverride(endpoint) // 동적 LocalStack 주소  
            .region(REGION)  
            .credentialsProvider(StaticCredentialsProvider.create(  
                AwsBasicCredentials.create(accessKey, secretKey)))  
            .forcePathStyle(true)  
            .build();  
      }  

      @Override  
      public String generatePresignedUrl(String key, String contentType) {  
        try (S3Presigner presigner = S3Presigner.builder()  
            .endpointOverride(endpoint) // LocalStack 주소  
            .region(REGION)  
            .credentialsProvider(StaticCredentialsProvider.create(  
                AwsBasicCredentials.create(accessKey, secretKey)))  
            .build()) {  

          GetObjectRequest getObjectRequest = GetObjectRequest.builder()  
              .bucket(BUCKET)  
              .key(key)  
              .build();  

          GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder()  
              .signatureDuration(Duration.ofSeconds(EXPIRATION))  
              .getObjectRequest(getObjectRequest)  
              .build();  

          PresignedGetObjectRequest presignedGetObjectRequest = presigner.presignGetObject(  
              presignRequest);  
          return presignedGetObjectRequest.url().toString();  
        }  
      }  
    };  

    storage.getS3Client().createBucket(CreateBucketRequest.builder().bucket(BUCKET).build());  

    id = UUID.randomUUID();  
    content = "test content".getBytes();  
  }  


  @Test  
  void testPutAndGet() throws IOException {  
    storage.put(id, content);  

    InputStream stream = storage.get(id);  

    byte[] downloaded = stream.readAllBytes();  

    Assertions.assertThat(downloaded).isEqualTo(content);  
  }  

  @Test  
  void testDownloadPresignedUrl() throws Exception {  
    ResponseEntity<?> response = storage.download(id);  

    Assertions.assertThat(response.getStatusCode().is3xxRedirection()).isTrue();  

    String location = response.getHeaders().getLocation().toString();  
    Assertions.assertThat(location.contains(id.toString())).isTrue();  
  }  
}

7. 마무리

지금까지 LocalStackTestcontainers 를 통해 S3 기능을 로컬에서 시뮬레이션 테스트 하는 방법을 알아보았다.

 

외부 의존성에 의존하는 테스트를 작성하는것은 언제나 까다로운 일이지만, 이와 같이 적절한 방법을 통해 테스트를 작성할 경우, 어플리케이션의 신뢰성과 무결성을 보장할 수 있다.

 

이번 글에서는 S3 를 예시로 들었지만, LocalStack 이 제공하는 다양한 서비스를 통해 테스트의 범위를 확장시킬 수 있다.