본문 바로가기

Dot Programming/Spring

[Spring] LocalStack과 Testcontainers를 활용하여 AWS S3 통합 테스트 환경 구축하기

    로컬이나 어떠한 운영 환경에서도 쉽게 AWS S3 통합 테스트를 실행하기 위해 LocalStack과 Testcontainers를 사용하여 테스트 환경을 구축해보았다.

     

    Testcontainers

    Testcontainers는 docker 컨테이너를 외부 설정 없이 Java 언어만으로 구축할 수 있는 오픈소스 라이브러리이다. 테스트 실핼시 외부에서 따로 DB를 설정하거나 별도의 프로그램 또는 스크립트를 실행할 필요가 없다. 컨테이너 기술을 사용하기 때문에 임베디드 라이브러리를 사용하는 것보다 훨씬 더 경량화된 방법으로 테스트 환경을 구축할 수 있다.  

     

    LocalStack

    LocalStack은 로컬이나 CI 환경의 단일 컨테이너 위에서 실행되는 AWS 클라우드 서비스 에뮬레이터이다. 해당 오픈소스는 로컬에서 단독으로 실행이 가능하기 떄문에 AWS 클라우드 서비스를 사용하는 웹 어플리케이션을 쉽게 테스트할 수 있다. 일부 유료이지만 AWS에서 자주 사용되는 서비스들을 대부분 무료로 지원하고 있다. 해당 서비스들은 docker를 사용하여 간단하게 실행하여 사용할 수 있다.

     

    Testcontainers로 LocalStack 사용하기

    LocalStack 컨테이너를 실행하는 방법으로 여러가지가 있지만 testcontainers로도 실행이 가능하다. (testcontainers에서 localstack 모듈을 지원해주고 있다.) 기존에 redis나 mysql 통합 테스트 환경도 testcontainers로 구축했고 이에 대한 편의성은 이미 알고있기 때문에 망설임없이 testcontainer로 localstack 컨테이너를 띄워서 테스트를 진행해보았다.

     

    의존성 추가하기

    먼저 스프링 프로젝트에 다음 세 가지 의존성을 추가해줘야 한다.

    // localstack
    testImplementation "org.testcontainers:localstack:1.16.3"
    // testContainers
    testImplementation "org.testcontainers:junit-jupiter:1.16.3"
    // aws s3
    implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'

     

    Testcontainers로 Localstack S3 컨테이너 실행하기

    그런 다음 다음과 같이 코드를 작성해준다.

    1. docker 이미지 "localstack/localstack"를 불러온다.
    2. LocalStackContainer를 해당 docker 이미지로 S3 서비스를 실행시킨다.
    3. 실행된 localstack 컨테이너로 AmazonS3 설정 값에 추가한다.
    4. 그렇게 설정된 AmazonS3 인스턴스로 S3 버킷 생성 및 put, get을 실행해본다.
    @Testcontainers
    public class LocalStackTestContainersTest {
    	private static final DockerImageName LOCALSTACK_IMAGE = DockerImageName.parse("localstack/localstack");
    
    	@Container
    	LocalStackContainer localStackContainer = new LocalStackContainer(LOCALSTACK_IMAGE)
    		.withServices(S3);
    
    	@Test
    	void test(){
    		AmazonS3 amazonS3 = AmazonS3ClientBuilder
    			.standard()
    			.withEndpointConfiguration(localStackContainer.getEndpointConfiguration(S3))
    			.withCredentials(localStackContainer.getDefaultCredentialsProvider())
    			.build();
    
    		String bucketName = "foo";
    		amazonS3.createBucket(bucketName);
    		System.out.println(bucketName +" 버킷 생성");
    
    		String key = "foo-key";
    		String content = "foo-content";
    		amazonS3.putObject(bucketName, key, content);
    		System.out.println("파일을 업로드하였습니다. key=" + key +", content=" + content);
    
    		S3Object object = amazonS3.getObject(bucketName, key);
    		System.out.println("파일을 가져왔습니다. = " + object.getKey());
    	}
    }

     

    실행 결과

    그러면 다음과 같이 docker 이미지를 불러와 컨테이너를 실행하고 S3 버킷 생성 및 put, get 연산까지 정상적으로 실행되는 것을 확인할 수 있다.

    13:39:29.523 [Test worker] DEBUG 🐳 [localstack/localstack:latest] - localstack/localstack:latest is not in image name cache, updating...

    ... 

    13:39:29.564 [Test worker] INFO 🐳 [localstack/localstack:latest] - HOSTNAME_EXTERNAL environment variable set to localhost (to match host-routable address for container)
    13:39:29.565 [Test worker] DEBUG 🐳 [localstack/localstack:latest] - Starting container: localstack/localstack:latest
    13:39:29.565 [Test worker] DEBUG 🐳 [localstack/localstack:latest] - Trying to start container: localstack/localstack:latest (attempt 1/1)
    13:39:29.565 [Test worker] DEBUG 🐳 [localstack/localstack:latest] - Starting container: localstack/localstack:latest
    13:39:29.565 [Test worker] INFO 🐳 [localstack/localstack:latest] - Creating container for image: localstack/localstack:latest

    ... 

    foo 버킷 생성
    파일을 업로드하였습니다. key=foo-key, content=foo-content
    파일을 가져왔습니다. = foo-key

     

    LocalStack과 Testcontainers를 활용하여 AWS S3 통합 테스트 환경 구축하기

    정상적으로 동작하는 것을 확인했으니 이제 AWS S3 통합 테스트 환경을 구축해보자. 위에서 @Testcontainers 애노테이션을 가져와 사용하는 방식은 통합 테스트 환경을 구축하기에는 적절하지 않다. 왜냐하면 모든 통합 테스트 클래스에 일일이 localstack 컨테이너를 실행해주고 종료해주고 반복해야하기 떄문이다. 그래서 오버라이딩하는 방법을 사용할 것이다. Redis 통합 테스트 환경을 구축할 때는 yml 파일을 오버라이딩 하여 host, port 값만 덮어씌워줬다면 AWS S3 통합 테스트 환경에서는 빈 자체를 덮어씌워줄 것이다.

     

    1. 각 환경에 Config 클래스 생성하기

    Local 환경

    1) S3Config 클래스

    먼저 Local에서 사용할 S3Config 클래스를 생성하여 AmazonS3 클래스를 생성한다.

    @Configuration
    public class S3Config {
    	@Bean
    	public AmazonS3 amazonS3() {
    		return AmazonS3ClientBuilder.standard()
    			.build();
    	}
    }

     

    2) application.yml

    aws-cloud-aws-starter을 사용하면 metadata로 region을 설정해줘야 한다.

    cloud:
      aws:
        region:
          static: ap-northeast-2
        stack:
          auto: false

     

    Test 환경

    1) LocalStackS3Config 클래스

    test에서 사용할 S3Config 클래스를 생성해준다. LocalStack 컨테이너를 실행시켜 해당 endpoint와 credentials 정보를 주입해주면 된다.

    • LocalStackContainer의 부모 클래스인 GenericContainer가 docker 컨테이너를 실행하는 start()메서드와 해당 컨테이너를 중지시키는 stop() 메서드를 기준으로 localstack 컨테이너 빈 생명 주기를 설정해준다. 
    • LocalstackContainer로 실행된 S3 서비스 설정 값들을 AmazonS3에 주입해주어 빈을 생성한다. 그리고 로컬에서 사용하는 Bucket을 생성해준다.
    @TestConfiguration
    public class LocalStackS3Config {
    	private static final DockerImageName LOCALSTACK_IMAGE = DockerImageName.parse("localstack/localstack");
    
    	// GenericContainer start(), stop() 메서드로 생명주기 설정
    	@Bean(initMethod = "start", destroyMethod = "stop")
    	public LocalStackContainer localStackContainer(){
    		return new LocalStackContainer(LOCALSTACK_IMAGE)
    			.withServices(S3);
    	}
    
    	@Bean
    	public AmazonS3 amazonS3(LocalStackContainer localStackContainer){
    		AmazonS3 amazonS3 = AmazonS3ClientBuilder
    			.standard()
    			.withEndpointConfiguration(localStackContainer.getEndpointConfiguration(S3))
    			.withCredentials(localStackContainer.getDefaultCredentialsProvider())
    			.build();
    		// 버킷 생성
    		amazonS3.createBucket(S3Controller.BUCKET_NAME);
    		return amazonS3;
    	}
    }

     

     

    2) application-test.yml

    그리고 테스트에서 생성한 빈을 오버라이딩할 것이므로 application-test.yml에 빈 오버라이딩 설정을 true해준다.

    spring:
      main:
        allow-bean-definition-overriding: true

     

    2. AWS S3 통합 테스트 실행하기

    @SpringBootTest 통합 테스트 애노테이션에 Test환경 Config 클래스인 LocalStackS3Config를 설정 파일이라고 명시해준다. 그리고 "test" 프로필을 active 해준다.

    @ActiveProfiles("test")
    @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = LocalStackS3Config.class)
    class S3ControllerTest {
    	@Autowired
    	AmazonS3 amazonS3;
    	@Autowired
    	TestRestTemplate testRestTemplate;
    
    	@Test
    	public void test(){
    		String id = "1";
    		String content = "test content";
    
    		String uploadResponse = testRestTemplate.postForObject("/s3/{id}", content, String.class, id);
    		System.out.println("upload response = " + uploadResponse);
    
    		String readResponse = testRestTemplate.getForObject("/s3/{id}", String.class, id);
    		System.out.println("read response = " + readResponse);
    	}
    }

      

    그러면 빈 오버라이딩이 실행되어 Local AmazonS3가 아닌 LocalStack 컨테이너로 실행된 AmazonS3로 통합 테스트가 정상적으로 실행되는 것을 확인할 수 있다. 

    2022-04-26 14:06:47.472 INFO 21912 --- [ Test worker] 🐳 [localstack/localstack:latest] : HOSTNAME_EXTERNAL environment variable set to localhost (to match host-routable address for container)
    2022-04-26 14:06:47.474 INFO 21912 --- [ Test worker] 🐳 [localstack/localstack:latest] : Creating container for image: localstack/localstack:latest
    2022-04-26 14:06:48.212 INFO 21912 --- [ Test worker] 🐳 [localstack/localstack:latest] : Starting container with ID: 924b03191837e110931dfb2fa19f3534441322c41f0746be839719a49c02da5f
    2022-04-26 14:06:48.548 INFO 21912 --- [ Test worker] 🐳 [localstack/localstack:latest] : Container localstack/localstack:latest is starting: 924b03191837e110931dfb2fa19f3534441322c41f0746be839719a49c02da5f
    2022-04-26 14:06:52.569 INFO 21912 --- [ Test worker] 🐳 [localstack/localstack:latest] : Container localstack/localstack:latest started in PT5.095319S


    ... 

    upload response = lHP90NiApDwht3eNNIchVw==
    read response = test content

     

    3. LocalStack으로 Local 환경과 Prod 환경 AWS S3 분리하기

    이제는 Local과 Prod 환경을 서로 분리해야 하는 데 번거롭게 Local용 S3 객체를 따로 생성하지 않아도 된다. 위에서 다룬 방법을 사용하여 Local 환경에서 localstack 컨테이너를 실행하여 사용하는 것도 충분히 가능하다.

     

    스프링 앱이 실행되는 과정에 자동으로 localstack 컨테이너가 실행되고 앱이 종료되면 자동으로 컨테이너 역시 종료된다.

     

    먼저 로컬 환경에도 localstack 의존성을 추가해주고 default 프로필에 "local"도 추가해준다.

    implementation "org.testcontainers:localstack:1.16.3"
    spring:
      profiles:
        group:
          "default": "local"

     

    Local과 Prod별로 Config 분리하기

    1) Prod 환경

    실제로 운영하게 될 Config 클래스에는 그냥 @Profile 애노테이션만 추가해주면 된다. 만약 dev가 아닌 real1, real2만 포함하고 싶다면 @Profile({"real1", "real2"})를 설정해주면 된다.

    @Profile("!local")
    @Configuration
    public class S3Config {
    	@Bean
    	public AmazonS3 amazonS3() {
    		return AmazonS3ClientBuilder.standard()
    			.build();
    	}
    }

     

    2) Local 환경

    Local에서 앱을 실행할 때마다 localstack 컨테이너를 띄워서 사용해주는 방식이기 때문에 TestConfig 클래스와 다를 게 없다. 

    @Profile("local")
    @Configuration
    public class LocalS3Config {
    
    	private static final DockerImageName LOCAL_STACK_IMAGE = DockerImageName.parse("localstack/localstack");
    
    	// GenericContainer start(), stop() 메서드로 생명주기 설정
    	@Bean(initMethod = "start", destroyMethod = "stop")
    	public LocalStackContainer localStackContainer(){
    		return new LocalStackContainer(LOCAL_STACK_IMAGE)
    			.withServices(S3);
    	}
    
    	@Bean
    	public AmazonS3 amazonS3(LocalStackContainer localStackContainer){
    		AmazonS3 amazonS3 = AmazonS3ClientBuilder
    			.standard()
    			.withEndpointConfiguration(localStackContainer.getEndpointConfiguration(S3))
    			.withCredentials(localStackContainer.getDefaultCredentialsProvider())
    			.build();
    		amazonS3.createBucket(BUCKET_NAME);
    		return amazonS3;
    	}
    }

     

    실행 결과

    다음과 같이 "local"환경에서 실행하면 Localstack 컨테이너가 실행되는 것을 로그로 확인할 수 있다.

     

    local
    Localstack 컨테이너 실행

     

    그리고 S3 get, put을 수행하는 API를 실행해보면 정상적으로 동작하는 것을 확인할 수 있다. 

     

    put 실행
    get 실행

     

    실습한 내용은 github에서 확인할 수 있습니다. 


    참고

    https://www.testcontainers.org/modules/localstack/

    https://github.com/localstack/localstack

    https://techblog.woowahan.com/2638/