개발

AWS S3 에서 이미지를 관리해보자

모달조아 2023. 3. 27. 05:29

우선 S3 에서 자주 사용하는 버킷과 객체라는 간단한 개념에 대해 먼저 알아보고 가겠습니다.

버킷 특징

  • 객체를 저장하고 관리하는 역할
  • Amazon S3에서 생성되는 최상위의 디렉토리이며, Amazon S3에 저장된 객체의 컨테이너 이다.
  • S3 상의 모든 객체는 버킷에 포함된다.
  • 버킷의 이름은 S3에서 유일해야 한다. 즉, 전세계에 어디에도 중복된 이름이 존재 할 수 없다.

객체 특징

  • 객체는 데이터와 메타 데이터를 구성하고 있는 저장 단위
  • 객체는 키를 통해서 버킷에서 유일한 것으로 식별되고, 버킷에 존재하는 모든 객체는 단 하나의 키를 지닌다.
  • S3 내에서 버킷, 키, 버전 id를 통해서 특정 객체를 파악할 수 있다.

S3 서비스를 위한 코드는 정말 간단합니다.

일단 우선 AWS 와 연결을 해주기 위해 아래의 의존성이 필요합니다.

implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'

그 후 AWS S3 서비스 접근을 위한 access key, secret key 가 필요한데, 저는 그 정보를 yml 파일과 환경 변수로 관리하였습니다.

cloud:
  aws:
    credentials:
      access-key: ${ACCESS-KEY}
      secret-key: ${SECRET-KEY}
    s3:
      bucket:
        name: ${BUCKET-NAME}
    region:
      static: ap-northeast-2
    stack:
      auto: false

다른 것은 딱 보면 알겠는데 cloud.aws.stack.auto 이것이 무엇인지 잘 와닿지 않을 것입니다.

  • cloud.aws.stack.auto: false
    • EC2에서 Spring Cloud 프로젝트를 실행시키면 기본으로 CloudFormation 구성을 시작합니다.
    • 설정한 CloudFormation이 없으면 프로젝트 시작이 안되니, 해당 내용을 사용하지 않도록 false 를 등록합니다.

 

S3 에 객체 업로드하는 로직

@Service
@RequiredArgsConstructor
public class ImageS3UploadService implements ImageUploadService {

	private final AmazonS3 amazonS3;

	@Value("${cloud.aws.s3.bucket}")
	private String bucketName;

	@Override
	public String upload(MultipartFile multipartFile, String subPath) {
		if (multipartFile.isEmpty()) {
			throw new BusinessException(ErrorCode.BAD_REQUEST);
		}
		String originalFilename = multipartFile.getOriginalFilename();
		String savedFilename = createSavedFilename(originalFilename);

		ObjectMetadata objectMetadata = new ObjectMetadata();
		objectMetadata.setContentType(multipartFile.getContentType());

		try {
			amazonS3.putObject(new PutObjectRequest(
					bucketName,
					subPath + "/" + savedFilename,
					multipartFile.getInputStream(),
					objectMetadata
			));
		} catch (IOException e) {
			throw new IllegalStateException("이미지를 업로드할 수 없습니다.", e);
		}

		return amazonS3.getUrl(bucketName, subPath + "/" + savedFilename).toString();
	}
}

	private String createSavedFilename(String originalFilename) {
		String uuid = UUID.randomUUID().toString();
		return uuid + "." + extractExtension(originalFilename);
	}

	private String extractExtension(String originalFilename) {
		int beforeExtensionIndex = originalFilename.lastIndexOf(".");
		return originalFilename.substring(beforeExtensionIndex + 1);
	}

upload 메서드

  • S3 에 업로드하고자하려먼 어느 위치에 무엇을 업로드해야할지 알아야합니다. 그러므로 multipartFile 과 버킷의 하위 path 를 인자로 받습니다.
  • 업로드할 객체에 대한 정보를 set 해주고 싶을 때, ObjectMetadata 를 작성해주면 됩니다. 저는 업로드 할 시 ContentType 을 지정해주었습니다

createSavedFilename 메서드

  • 입력 받은 multipartFile 이름 그대로 S3 에 업로드하면 편하겠지만, 그렇게 하면 중복된 값이 생길 수 있습니다. 그러므로 UUID 를 통해 랜덤한 값으로 저장해줍니다.
  • 파일 관리 시 확장자를 볼 수 있으면 편리하기에, 확장자는 extractExtension 메서드를 통해 추출하여 함께 저장합니다.

 

S3 에서 객체 삭제하는 로직

@Override
	public void delete(String subPath, String savedFilename) {
		try {
			amazonS3.deleteObject(new DeleteObjectRequest(bucketName, subPath + "/" + savedFilename));
		} catch (SdkClientException e) {
			throw new BusinessException(ErrorCode.ENTITY_NOT_FOUND);
		}
	}

삭제하는 로직도 간단합니다. 어떤 것을 삭제해야할 지 특정할 수 있어야하므로 버킷, 파일의 세부 경로가 필요합니다. 그것들을 입력 받아서 삭제해주면 됩니다.


S3의 이미지 링크가 바로 다운로드 되는 경우 해결

위의 코드대로 하면 그럴 일이 없지만, 혹여나 저장된 S3 링크를 타고 들어갔는데 이미지가 나오지 않고 다운로드가 되는 경우가 있을 수 있습니다.

S3 에 올릴 파일의 ContentType 를 지정해주지 않아서 생긴 일입니다.

아래처럼 ContentType 을 지정해주면 해결할 수 있습니다.

ObjectMetadata objectMetadata = new ObjectMetadata();
objectMetadata.setContentType(multipartFile.getContentType());

테스트 코드

mockito 를 이용한 방법

@ExtendWith(MockitoExtension.class)
class ImageS3UploadServiceTest {

    @InjectMocks
    private ImageS3UploadService imageS3UploadService ;

    @Mock
    private AmazonS3 amazonS3;

    @Nested
    @DisplayName("upload")
    class Upload {

        @Test
        @DisplayName("Success - S3 에 이미지 업로드를 성공한다.")
        void uploadSuccess() throws IOException {
            // given
            MockMultipartFile multipartFile = new MockMultipartFile("test", "test.jpg", "jpg", "test".getBytes());

            when(amazonS3.putObject(any(PutObjectRequest.class))).thenReturn(new PutObjectResult());
            when(amazonS3.getUrl(any(), anyString())).thenReturn(new URL("<https://s3.heycake>"));

            // when
            imageS3UploadService.upload(multipartFile, "subpath");

            // then
            verify(amazonS3).putObject(any(PutObjectRequest.class));
            verify(amazonS3).getUrl(any(), anyString());
        }

        @Test
        @DisplayName("Fail - 이미지가 없으면 S3 에 이미지 업로드를 실패한다.")
        void uploadImageFail() {
            // given
            MockMultipartFile multipartFile = new MockMultipartFile("test", "test.jpg", "jpg", "test".getBytes());

            // when & then
            Assertions.assertThatThrownBy(() -> imageS3UploadService.upload(multipartFile, "subpath"))
                    .isExactlyInstanceOf(IllegalArgumentException.class);
        }

    }
}

모킹을 이용하여 S3 업로드 로직이 실행되었는지 검증할 수 있습니다.

 

테스트 시 운영 S3 에 올라가지 않도록 만들기

만약 이 상태로 통합 테스트를 하는 경우 테스트 이미지가 그대로 S3 에 업로드가 될 것입니다. 테스트용 S3 를 두는 방법도 있겠지만 테스트를 위해서 비용이 들어가는 S3 를 운영하는 것은 비효율적입니다. 이를 해결하기 위해 테스트용 S3 환경을 만들 수 있는 몇 가지 라이브러리가 있는데 저는 가장 레퍼런스가 많은 findify s3mock 을 이용하였습니다.

testImplementation 'io.findify:s3mock_2.13:0.2.6'

의존성을 추가해줍니다.

@TestConfiguration
public class S3MockConfig {

    private final int port = new ServerSocket(0).getLocalPort();

    public S3MockConfig() throws IOException {
    }

    @Bean(name = "s3Mock")
    public S3Mock s3Mock() {
        return new S3Mock.Builder()
                .withPort(port)
                .withInMemoryBackend()
                .build();
    }

    @Primary
    @Bean(name = "amazonS3", destroyMethod = "shutdown")
    public AmazonS3 amazonS3() {
        AwsClientBuilder.EndpointConfiguration endpoint = new AwsClientBuilder.EndpointConfiguration(
                "<http://127.0.0.1>:" + port, Regions.AP_NORTHEAST_2.name());
        AmazonS3 client = AmazonS3ClientBuilder
                .standard()
                .withPathStyleAccessEnabled(true)
                .withEndpointConfiguration(endpoint)
                .withCredentials(new AWSStaticCredentialsProvider(new AnonymousAWSCredentials()))
                .build();
        return client;
    }

}

S3Mock 서버로 사용될 S3Mock 을 빈으로 등록해야합니다. 서버로 사용될 port를 등록해야하는데 사용 가능한 포트를 찾기 위해 아래와 같이 작성하였습니다.

private final int port = new ServerSocket(0).getLocalPort();

서버에 파일로 저장할지에 대해서 withInMemoryBackend, withFileBackend 로 지정할 수 있습니다. 다만, 실제 저장 유무만 확인하면 되는 테스트이기에 메모리에서만 확인해도 충분하다고 판단하여withInMemoryBackend 로 진행하였습니다.

그리고 amazonS3 를 생성하여 s3에 요청을 보낼 client 를 빈으로 등록합니다. 그리고 현재 이 aws client는 aws ec2 server에서 동작하는 것이 아니기 때문에 region auto 설정을 false 로 지정해줘야합니다.

cloud:
  aws:
    credentials:
      access-key: ${ACCESS-KEY}
      secret-key: ${SECRET-KEY}
    s3:
      bucket:
        name: ${BUCKET-NAME}
    region:
      static: ap-northeast-2
			auto: false
    stack:
      auto: false
@Import(S3MockConfig.class)
@SpringBootTest
public class S3Test {

		private static final String BUCKET_NAME = "test";

    @Autowired
    private AmazonS3 amazonS3;

    @BeforeAll
    static void setUp(@Autowired S3Mock s3Mock, @Autowired AmazonS3 amazonS3) {
        s3Mock.start();
        amazonS3.createBucket(BUCKET_NAME);
    }

    @AfterAll
    static void tearDown(@Autowired S3Mock s3Mock, @Autowired AmazonS3 amazonS3) {
        amazonS3.shutdown();
        s3Mock.stop();
    }

    @Test
    @DisplayName("s3 import 테스트")
    void S3Import() throws IOException {
        // given
        String path = "test/test.txt";
        String contentType = "text/plain";

        ObjectMetadata objectMetadata = new ObjectMetadata();
        objectMetadata.setContentType(contentType);
        
        amazonS3.putObject(new PutObjectRequest(BUCKET_NAME, path, new ByteArrayInputStream("test".getBytes(StandardCharsets.UTF_8)), objectMetadata));

        // when
        S3Object s3Object = amazonS3.getObject(BUCKET_NAME, path);

        // then
        assertThat(s3Object.getObjectMetadata().getContentType()).isEqualTo(contentType);
        assertThat(new String(FileCopyUtils.copyToByteArray(s3Object.getObjectContent()))).isEqualTo("test");
    }

}

BeforeAll

  • S3Mock서버를 실행시켜야하고 test에서 사용할 Bucket을 미리 생성해야합니다.

AfterAll

  • 테스트가 종료된 후 s3Mock을 종료시켜줍니다.