개발 이야기/Spring

[Spring] Local에서 AWS S3를 테스트하는 방법 with localstack

제이온 (Jayon) 2023. 10. 3.

안녕하세요? 제이온입니다.

오늘은 로컬 환경에서 AWS S3와 스프링을 연동하여 테스트하는 방법을 소개하겠습니다.

 

AWS S3(Simple Storage Service)란?

Simple Storage Service의 약자로 파일 서버의 역할을 하는 서비스입니다. 일반적인 파일 서버는 트래픽이 증가함에 따라서 장비를 증설하는 작업을 해야 하는데 S3는 이와 같은 것을 대행합니다. 즉, 트래픽에 따른 시스템적인 문제는 걱정할 필요가 없어집니다.
또 파일에 대한 접근 권한을 지정 할 수 있어서 서비스를 호스팅 용도로 사용하는 것을 방지 할 수 있습니다.

 

AWS S3의 주요 특징

  • 많은 사용자가 접속을 해도 이를 감당하기 위해서 시스템적인 작업을 하지 않아도 됩니다.
  • 저장할 수 있는 파일 수의 제한이 없습니다.
  • 최소 1바이트에서 최대 5TB의 데이터를 저장하고 서비스 할 수 있습니다.
  • 파일에 인증을 붙여서 무단으로 엑세스 하지 못하도록 할 수 있습니다.
  • HTTP와 BitTorrent 프로토콜을 지원합니다.
  • REST, SOAP 인터페이스를 제공합니다.
  • 데이터를 여러 시설에서 중복으로 저장해 데이터의 손실이 발생할 경우 자동으로 복원합니다.
  • 버전 관리 기능을 통해서 사용자에 의한 실수도 복원이 가능합니다.
  • 정보의 중요도에 따라서 보호 수준을 차등 할 수 있고, 이에 따라서 비용을 절감 할 수 있습니다. (RSS)

 

AWS S3에서 사용되는 용어

  • 객체 - object, AWS는 S3에 저장된 데이터 하나 하나를 객체라고 명명하는데, 하나 하나의 파일이라고 생각하면 됩니다.
  • 버킷 - bucket, 객체가 파일이라면 버킷은 연관된 객체들을 그룹핑한 최상위 디렉토리라고 할 수 있습니다. 버킷 단위로 지역(region)을 지정 할 수 있고, 또 버킷에 포함된 모든 객체에 대해서 일괄적으로 인증과 접속 제한을 걸 수 있습니다.
  • 버전 관리 - S3에 저장된 객체들의 변화를 저장합니다. 예를 들어, A라는 객체를 사용자가 삭제하거나 변경해도 각각의 변화를 모두 기록하기 때문에 실수를 만회할 수 있습니다.
  • RSS - Reduced Redundancy Storage의 약자로 일반 S3 객체에 비해서 데이터가 손실될 확률이 높은 형태의 저장 방식합니다. 대신에 가력이 저렴하기 때문에 복원이 가능한 데이터로, 이를테면 썸네일 이미지와 같은 것을 저장하는데 적합합니다. 그럼에도 불구하고 물리적인 하드 디스크 대비 400배 가량 안전하다는 것이 아마존의 주장입니다.

 

LocalStack가 필요한 상황

AWS 서비스를 사용하면서 발생할 수 있는 문제점은 다음과 같습니다.

  • 로컬 환경에서 AWS 서비스에 접근하기 위하여 access-key와 secret-key를 선언하고 관리해야 합니다.
    • 코드 레벨에서 관리하게 되면 유출되어 보안 사고가 발생할 수 있으며, 팀에서 별도로 관리하는 방법 또한 번거롭습니다.
    • (보안 상의 이유로) 그마저도 access-key, secret-key 사용을 하지 못하면, 실제로 AWS 클라우드 상에 배포하기 전까지 테스트가 불가능할 수 있습니다.
  • AWS 상에 만들어지고 사용되는 서비스들은 다양한 인스턴스에서 접근하고 사용되고 있기 때문에 격리된 환경을 구축하기 어렵고, 이로 인하여 의도와는 다르게 테스트 코드가 실패하기도 합니다.
  • 개발 과정에서 계속 AWS 서비스를 등록하고 삭제하는 것은 비용 측면에서도 좋지 못합니다.

그 중 가장 심각한 문제는 통합 테스트 코드가 정상적으로 동작하기 어렵다는 점입니다. 이때 LocalStack을 사용하면 격리된 AWS 클라우드 환경을 만들어서 통합 테스트 코드를 작성할 수 있습니다.

 

LocalStack과 JUnit 5를 사용하여 테스트하기

LocalStack은 AWS 클라우드 리소스의 기능을 에뮬레이션(한 시스템에서 다른 시스템을 복제)하여 제공해 줍니다.

LocalStack은 로컬에서 단독으로 실행이 가능하며, 이것을 사용하여 로컬 환경에서 AWS 서비스를 사용하는 웹 애플리케이션을 쉽게 테스트할 수 있습니다. AWS에서 자주 사용되는 서비스들을 대부분 지원하고 있고, 도커를 사용하여 손쉽게 실행할 수 있습니다.

 

localstack-utils 라이브러리를 통해 LocalStack 사용해보기

S3에 버킷을 생성하고 파일을 하나 업로드 한뒤, 다시 이 파일을 읽어 보는 간단한 테스트 코드를 작성해 보겠습니다.

먼저 아래와 같이 gradle에 관련된 의존성들을 추가하고, 테스트 코드를 작성하면 됩니다.

 

dependencies {
    compileOnly("org.projectlombok:lombok")
    annotationProcessor("org.projectlombok:lombok")
    testCompileOnly("org.projectlombok:lombok")
    testAnnotationProcessor("org.projectlombok:lombok")

    implementation("org.springframework.boot:spring-boot-starter")
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("com.amazonaws:aws-java-sdk-s3:1.12.529")

    implementation("commons-io:commons-io:2.7")
    implementation("javax.xml.bind:jaxb-api:2.3.1")

    testImplementation("org.assertj:assertj-core")
    testImplementation("org.junit.jupiter:junit-jupiter-api")
    testImplementation("org.junit.jupiter:junit-jupiter-params")
    testImplementation("org.springframework.boot:spring-boot-starter-test")

    testImplementation("cloud.localstack:localstack-utils:0.2.20")

    testImplementation("org.projectlombok:lombok:1.18.22")
}
@Slf4j
@ExtendWith(LocalstackDockerExtension.class)
@LocalstackDockerProperties(services = ServiceName.S3, randomizePorts = true)
class LocalStackDockerExtensionTest {

    @Test
    void test() throws Exception {
        AmazonS3 s3 = TestUtils.getClientS3();

        String bucketName = "test-s3";
        s3.createBucket(bucketName);
        log.info("버킷을 생성했습니다. bucketName = {}", bucketName);

        String content = "Hello World";
        String key = "s3-key";
        s3.putObject(bucketName, key, content);
        log.info("파일을 업로드하였습니다. bucketName = {}, key = {}, content = {}", bucketName, key, content);

        List<String> results = IOUtils.readLines(s3.getObject(bucketName, key).getObjectContent(), "utf-8");
        log.info("파일을 가져왔습니다. bucketName = {}, key = {}, results = {}", bucketName, key, results);

        assertThat(results).hasSize(1);
        assertThat(results.get(0)).isEqualTo(content);
    }
}

 

테스트 코드가 실행되면 자동으로 localstack 이미지가 다운로드된 뒤 실행되며, 테스트가 완료되면 실행되었던 localstack 컨테이너는 자동으로 종료됩니다. DockerTestUtils를 사용하여 가져온 S3 클라이언트를 통해 버킷을 만들거나 파일을 업로드하는 작업을 수행할 수 있습니다.

 

00:05:37.849 [main] WARN com.amazonaws.http.AmazonHttpClient -- SSL Certificate checking for endpoints has been explicitly disabled.
00:05:39.348 [main] INFO com.jayon.awss3practice.LocalStackDockerExtensionTest -- 버킷을 생성했습니다. bucketName = test-s3
00:05:39.650 [main] INFO com.jayon.awss3practice.LocalStackDockerExtensionTest -- 파일을 업로드하였습니다. bucketName = test-s3, key = s3-key, content = Hello World
00:05:39.703 [main] INFO com.jayon.awss3practice.LocalStackDockerExtensionTest -- 파일을 가져왔습니다. bucketName = test-s3, key = s3-key, results = [Hello World]

 

Testcontainers를 통해 LocalStack 사용해보기

Testcontainers는 코드 상에서 여러 도커 컨테이너들을 실행하고 테스트 코드와 연동할 수 있는 방법을 제공해 줍니다. 테스트할 때 컨테이너를 띄우고, 테스트가 끝나면 자동으로 컨테이너를 내리며, Docker Desktop을 통해 이를 확인할 수도 있습니다.

 

dependencies {
    compileOnly("org.projectlombok:lombok")
    annotationProcessor("org.projectlombok:lombok")
    testCompileOnly("org.projectlombok:lombok")
    testAnnotationProcessor("org.projectlombok:lombok")

    implementation("org.springframework.boot:spring-boot-starter")
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("com.amazonaws:aws-java-sdk-s3:1.12.529")

    implementation("commons-io:commons-io:2.7")
    implementation("javax.xml.bind:jaxb-api:2.3.1")

    implementation("org.testcontainers:localstack:1.11.3")

    testImplementation("org.assertj:assertj-core")
    testImplementation("org.junit.jupiter:junit-jupiter-api")
    testImplementation("org.junit.jupiter:junit-jupiter-params")
    testImplementation("org.springframework.boot:spring-boot-starter-test")

    testImplementation("cloud.localstack:localstack-utils:0.2.20")
    testImplementation("org.testcontainers:junit-jupiter:1.11.3")

    testImplementation("org.projectlombok:lombok:1.18.22")
}
package com.jayon.awss3practice;

import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.AmazonS3ClientBuilder;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.localstack.LocalStackContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.shaded.org.apache.commons.io.IOUtils;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

@Slf4j
@Testcontainers
class LocalStackTestcontainerTest {

    @Container
    LocalStackContainer container = new LocalStackContainer()
            .withServices(LocalStackContainer.Service.S3);

    @Test
    void test() throws Exception {
        AmazonS3 s3 = AmazonS3ClientBuilder.standard()
                .withEndpointConfiguration(container.getEndpointConfiguration(LocalStackContainer.Service.S3))
                .withCredentials(container.getDefaultCredentialsProvider())
                .build();

        String bucketName = "test-s3";
        s3.createBucket(bucketName);
        log.info("버킷을 생성했습니다. bucketName = {}", bucketName);

        String content = "Hello World";
        String key = "s3-key";
        s3.putObject(bucketName, key, content);
        log.info("파일을 업로드하였습니다. bucketName = {}, key = {}, content = {}", bucketName, key, content);

        List<String> results = IOUtils.readLines(s3.getObject(bucketName, key).getObjectContent(), "utf-8");
        log.info("파일을 가져왔습니다. bucketName = {}, key = {}, results = {}", bucketName, key, results);

        assertThat(results).hasSize(1);
        assertThat(results.get(0)).isEqualTo(content);
    }
}

localstack-utils와 동일하게 localstack 컨테이너가 실행되고 테스트가 수행되는 모습을 볼 수 있습니다.

00:10:59.469 [main] INFO tc.localstack/localstack:0.8.6 -- Container localstack/localstack:0.8.6 is starting: df25cc9e082887a9ccc9c2fc3efc7ffc8bde2639cd08ad4388202565029bdb5c
00:11:04.633 [main] INFO tc.localstack/localstack:0.8.6 -- Container localstack/localstack:0.8.6 started in PT56.4745534S
00:11:07.054 [main] INFO com.jayon.awss3practice.LocalStackTestcontainerTest -- 버킷을 생성했습니다. bucketName = test-s3
00:11:07.187 [main] INFO com.jayon.awss3practice.LocalStackTestcontainerTest -- 파일을 업로드하였습니다. bucketName = test-s3, key = s3-key, content = Hello World
00:11:07.242 [main] INFO com.jayon.awss3practice.LocalStackTestcontainerTest -- 파일을 가져왔습니다. bucketName = test-s3, key = s3-key, results = [Hello World]

 

통합 테스트

localstack을 사용하는 S3 클라이언트는 도커 컨테이너가 실행이 완료된 이후 시점에 가져올 수 있는데, 이 내용을 주의해서 스프링 빈으로 등록하거나 기타 테스트를 위한 사전 작업을 하면 됩니다.

 

가령 @Testcontainers가 아닌 직접 Testcontainers 클래스를 만들어서 수동으로 localstack 컨테이너를 띄우고, 그 이후 S3 클라이언트 빈을 등록하거나 빈 없이 클래스 자체를 AmazonS3ClientBuilder를 통해 만들어 사용할 수 있습니다.

 

로컬에서 실행하는 웹 애플리케이션과 LocalStack 연동하기 - 1

웹 애플리케이션이 실행되는 과정에 자동으로 localstack 도커 컨테이너가 실행되고, 웹 애플리케이션이 종료되면 localstack 도커 컨테이너가 종료된다. 따라서 다음과 같이 testcontainers로 localstack으로 띄워서 로컬 환경에서 AWS S3를 테스트해 볼 수 있습니다.

 

테스트 코드와는 달리, 실제 파일을 업로드하고 다운로드하는 코드를 작성해 보겠습니다.

 

@Configuration
public class S3Config {

    public static final String BUCKET_NAME = "test-bucket";

    @Bean(initMethod = "start", destroyMethod = "stop")
    public LocalStackContainer localStackContainer(){
        return new LocalStackContainer()
                .withServices(LocalStackContainer.Service.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;
    }
}

@RestController
@RequiredArgsConstructor
public class S3Controller {

    private final AmazonS3 amazonS3;

    @GetMapping("/download")
    public ResponseEntity<byte[]> get(@RequestParam String fileName) throws IOException {
        S3Object s3Object = amazonS3.getObject(BUCKET_NAME, fileName);
        S3ObjectInputStream objectInputStream = s3Object.getObjectContent();
        byte[] bytes = IOUtils.toByteArray(objectInputStream);
        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.setContentType(MediaType.APPLICATION_OCTET_STREAM);
        httpHeaders.setContentDispositionFormData("attachment", fileName);

        return new ResponseEntity<>(bytes, httpHeaders, HttpStatus.OK);
    }

    @PostMapping("/upload")
    public ResponseEntity<Void> upload(@RequestParam MultipartFile file) {
        File uploadFile = convert(file);
        uploadToS3(uploadFile);
        return ResponseEntity.ok(null);
    }

    private File convert(MultipartFile multipartFile) {
        File file = new File(multipartFile.getOriginalFilename());
        try (FileOutputStream fos = new FileOutputStream(file)) {
            fos.write(multipartFile.getBytes());
        } catch (IOException e) {
            throw new RuntimeException();
        }
        return file;
    }

    private void uploadToS3(File uploadFile) {
        try {
            amazonS3.putObject(BUCKET_NAME, uploadFile.getName(), uploadFile);
        } catch (SdkClientException e) {
            throw e;
        } finally {
            removeNewFile(uploadFile);
        }
    }

    private void removeNewFile(File uploadFile) {
        uploadFile.delete();
    }
}

 

아래는 postman으로 테스트한 결과입니다.

 

 

 

제 이력서가 정상적으로 잘 업로드 및 다운로드된 것을 확인하실 수 있습니다.

 

로컬에서 실행하는 웹 애플리케이션과 LocalStack 연동하기 - 2

위에서는 testContainers 라이브러리를 사용하여 로컬 스프링 서버와 LocalStack을 연동하였습니다.

이번에는 docker-compose를 통해 실제로 localstack 도커 컨테이너를 띄워서 연동해보겠습니다.

우선 다음과 같이 docker-compose.yml을 작성합니다.

 

version: "3.8"

services:
  localstack:
    container_name: "localstack"
    image: localstack/localstack:0.14.3
    ports:
      - "4566:4566" # localstack port
      - "4572:4572" # aws s3 port
    environment:
      - SERVICES=s3
      - DEBUG=1
      - DATA_DIR=/tmp/localstack/data
      - DOCKER_SOCK=unix:///var/run/docker.sock
      - AWS_ACCESS_KEY_ID=test
      - AWS_SECRET_ACCESS_KEY=test
      - AWS_DEFAULT_REGION=us-east-1
    volumes:
      - "./localstack:/tmp/localstack"
      - "/var/run/docker.sock:/var/run/docker.sock"

 

그리고 기존 testContainers를 사용하던 설정 클래스와 docker-compose를 사용하는 설정 클래스는 분리하기 위해 아래와 같이 코드를 작성합니다.

 

# application.yml
spring:
  profiles:
    active: test
---
spring:
  profiles:
    active: local

// S3Config
@Profile(value = "test")
@Configuration
public class S3Config {

    public static final String BUCKET_NAME = "test-bucket";

    @Bean(initMethod = "start", destroyMethod = "stop")
    public LocalStackContainer localStackContainer(){
        return new LocalStackContainer()
                .withServices(LocalStackContainer.Service.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;
    }
}

// S3LocalConfig
@Profile(value = "local")
@Configuration
public class S3LocalConfig {

    public static final String BUCKET_NAME = "local-bucket";

    private final String AWS_REGION = Regions.US_EAST_1.getName();
    private final String AWS_ENDPOINT = "http://127.0.0.1:4566";

    private final String LOCAL_STACK_ACCESS_KEY = "test";
    private final String LOCAL_STACK_SECRET_KEY = "test";

    @Bean
    public AmazonS3 amazonS3() {
        AwsClientBuilder.EndpointConfiguration endpoint = new AwsClientBuilder.EndpointConfiguration(AWS_ENDPOINT, AWS_REGION);
        BasicAWSCredentials credentials = new BasicAWSCredentials(LOCAL_STACK_ACCESS_KEY, LOCAL_STACK_SECRET_KEY);

        AmazonS3 amazonS3 = AmazonS3ClientBuilder.standard()
                .withEndpointConfiguration(endpoint)
                .withCredentials(new AWSStaticCredentialsProvider(credentials))
                .build();

        amazonS3.createBucket(BUCKET_NAME);
        return amazonS3;
    }
}

// S3Controller
import static com.jayon.awss3practice.S3LocalConfig.BUCKET_NAME; // 상수 수정

@RestController
@RequiredArgsConstructor
public class S3Controller {

    private final AmazonS3 amazonS3;

    ...
}

 

그리고 localstack을 모니터링하기 위해 LocalStack 클라우드에 접속합니다. 깃허브 로그인만 하면 쉽게 localstack을 모니터링할 수 있습니다.

 

 

postman을 통해 아까와 같이 테스트를 하면 LocalStack 대시보드 상에서 local-bucket이 생긴 것을 확인할 수 있고, 안에 업로드한 파일이 있는 것을 확인할 수 있습니다.

 

지금까지 작업한 소스 코드는 아래 GitHub 저장소에서 확인하시기 바랍니다!

https://github.com/pjy1368/localstack-s3-practice

 

GitHub - pjy1368/localstack-s3-practice

Contribute to pjy1368/localstack-s3-practice development by creating an account on GitHub.

github.com

 

참고 링크

댓글

추천 글