Spring Boot에서 S3에 파일 업로드 하기

스프링 부트에서 s3에 이미지를 업로드해야 하는 상황이 생겨 정리 차원에서 글을 남겨 봅니다

보통 파일 업로드 과정에서 MultiPartFile 을 이용하여 업로드를 하게 되는데 이것은 스프링에서 제공하는 인터페이스이며 이 인터페이스를 이용하여 파일을 업로드할 수 있습니다 해당 인터페이스에서는 파일의 이름, 크기, 내용과 같은 업로드된 파일의 내용에 액세스 하는 방법을 제공합니다

아래와 같이 MultiPartFile 인터페이스에는 여러 메서드를 지원합니다

메서드 이름타입설명
getName()String멀티파트 폼에서 메게변수 이름을 반환합니다
getContentType()String파일의 콘텐츠 형식을 반환합니다
getOriginalFilename()String클라이언트의 파일 시스템에서 실제 파일 이름을 반환합니다
isEmpty()boolean업로드한 파일이 있는지 반환합니다
getSize()long바이트의 파일 크기를 반환합니다
getByte()byte[]바이트의 배열로 파일 내용을 반환합니다
getInputStream()InputStream파일 폼의 내용을 읽어 InputStream을 반환합니다
transferTo(File dest)void수신된 파일을 지정한 대상 파일에 전송합니다

MultiPartFile을 활용하여 파일을 업로드하는 방법은 여러 가지가 있지만 여기서는 크게 2가지 getByte와 getinputStram을 이용하여 s3에 이미지를 업로드하는 것을 알아보겠습니다

Config 및 기본 설정

aws s3 sdk를 아래와 같은 버전을 설치 합니다

implementation 'com.amazonaws:aws-java-sdk-s3:1.12.710'

application-credential.properties 파일을 만든 후 Access KeySecretKey를 설정합니다

aws.credentials.access-key=.....
aws.credentials.secret-key=.....

해당 키들을 관리할 수 있는 AwsCredentialProperties 클래스를 만듭니다

@Component
@ConfigurationProperties(prefix = "aws.credentials")
@Getter
@Setter
public class AwsCredentialProperties {
    private String accessKey;
    private String secretKey;
}

그다음 config 파일을 만듭니다

@Configuration
@RequiredArgsConstructor
public class AwsConfig {
    private final AwsCredentialProperties awsCredentialProperties;

    @Bean
    public AmazonS3 amazonS3() {
        AWSCredentials awsCredentials = new BasicAWSCredentials(awsCredentialProperties.getAccessKey(), awsCredentialProperties.getSecretKey());
        return AmazonS3ClientBuilder.standard()
                .withRegion(Regions.AP_NORTHEAST_2)
                .withCredentials(new AWSStaticCredentialsProvider(awsCredentials))
                .build();
    }

}

이제 aws sdk를 활용하여 s3에 업로드할 준비를 다 마쳤습니다

getBytes() 방식이란?

이 메서드는 업로드된 파일의 전체 내용을 바이트 배열(byte array)로 한 번에 읽어옵니다

바이트 배열이란? 컴퓨터에서는 모든 데이터가 숫자로 표현됩니다. 그 숫자들의 가장 작은 단위가 바이트(byte)이며, 1바이트는 8비트로 구성됩니다. 문자, 이미지, 동영상 등의 모든 파일 데이터는 결국 바이트로 표현되며, 이 데이터들의 묶음을 바이트 배열이라고 부릅니다.

[예제 코드]

public class S3UploadUtil {
    private final AmazonS3 amazonS3;

    public String upload(String bucket, MultipartFile multipartFile, String dirName) throws IOException {

        File convertFile = convert(multipartFile)
                .orElseThrow(() -> new IllegalArgumentException("file convert error"));

        // S3에 저장할 파일명
        String fileName = dirName + "/" + convertFile.getName();

        // S3에 파일 업로드
        amazonS3.putObject(new PutObjectRequest(bucket, fileName, convertFile));

		// 업로드된 파일의 S3 URL 주소 반환
        String uploadImageUrl = amazonS3.getUrl(bucket, fileName).toString();

        // 로컬 파일 삭제
        convertFile.delete();

        return uploadImageUrl;
    }

    // 파일 convert 후 로컬에 업로드 (파일을 올리기 전 전처리 작업이 필요할 경우 사용)
    private Optional<File> convert(MultipartFile file) throws IOException {
        File convertFile = new File(System.getProperty("user.dir") + "/" + file.getOriginalFilename());
        if (convertFile.createNewFile()) {
            try (FileOutputStream fos = new FileOutputStream(convertFile)) {
                fos.write(file.getBytes()); // 바이트 배열을 한번에 읽어옴
            }
            return Optional.of(convertFile);
        }
        return Optional.empty();
    }
}

위 코드는 클라이언트에서 전달받은 파일을 메모리에 한번 저장을 합니다 이러면 전처리(이미지 용량, 사이즈 수정) 작업을 할 수 있습니다 그리고 s3에 업로드 후 로컬에 있는 파일을 지우는 프로세스를 가지고 있습니다

자주 사용하지 않고 Admin에서 사용할 API이며 파일 용량 또한 크지 않기 때문에 전처리 기능도 필요 없어 이 방법이 아닌 아래의 Stream 방식으로 구현하였습니다


getInputStream 방식이란?

서버에 임시로 저장하지 않고 바로 s3에 저장할 수 있습니다

이 방식을 알기 전에 Stream에 대해 알고 있어야 하므로 간단하게 알아보겠습니다

스트림(Stream) 은 프로그래밍에서 데이터를 전달하는 연결 통로라고 할 수 있습니다 이는 데이터가 원본(파일, 메모리)에서 목적지까지 단방향으로만 흐르도록 설계되어 있어 입력과 출력을 동시에 처리하려면 각각 별도의 스트림이 필요합니다 입력 데이터를 처리하는 입력 스트림(InputStream) 과 데이터를 외부로 전송하는 출력 스트림(OutputStream) 으로 구분됩니다.

  1. 입력 스트림 (InputStream) 데이터 소스로부터 데이터를 읽어 들이는 용도로 사용됩니다 이를 통해 파일이나 다른 소스로부터 데이터를 읽어 프로그램 내로 가져올 수 있습니다

  2. 출력 스트림 (OutputStream) 데이터를 외부로 출력하기 위해 사용됩니다. 이를 통해 생성한 데이터를 파일에 쓰거나 네트워크를 통해 전송할 수 있습니다

[예제 코드]

public class S3UploadUtil {
    private final AmazonS3 amazonS3;

	// s3에 파일 업로드
    public String upload(String bucket, MultipartFile multipartFile, String dirName) throws IOException {

		// s3 경로 설정
		String objectPath = dirName + "/" + multipartFile.getOriginalFilename();
        return uploadFile(bucket, multipartFile, objectPath);
    }

	// s3 파일 업데이트
	public String update(String bucket, MultipartFile multipartFile, String objectPath) throws IOException {
	    try {
            amazonS3.getObjectMetadata(bucket, objectPath);

        } catch (AmazonServiceException e) {
            throw new CustomHttpException(Domain.CONTENT, Layer.S3UTIL, ErrorCode.CONTENT_FILE_NOT_FOUND, e);
        }

        return uploadFile(bucket, multipartFile, objectPath);
	}

	// 파일을 업로드
	private String uploadFile(String bucket, MultipartFile multipartFile, String objectPath) throws IOException {
        // try-with-resources 사용
        try (InputStream inputStream = multipartFile.getInputStream()) {

			// 해당 파일의 메타데이터
            ObjectMetadata metadata = new ObjectMetadata();
            metadata.setContentType(multipartFile.getContentType());
            metadata.setContentLength(multipartFile.getSize());

			// S3에 파일 업로드
            amazonS3.putObject(new PutObjectRequest(bucket, objectPath, inputStream, metadata));

            return objectPath;
        }
    }
}

위 예제코드에서는 크게 3가지로 나눠져 있습니다

파일을 s3에 업로드하는 upload() 메서드와 파일을 업데이트할 수 있는 update() 메서드 그리고 실질적으로 s3에 파일을 업로드하는 private 메서드인 uploadFile() 이 있습니다 하나씩 알아보겠습니다

upload()

s3에 파일을 업로드하는 메서드입니다 파라미터로 bucket, MultipartFile, dirName(경로)를 받습니다 dirName은 s3에서 파일을 저장할 경로를 말합니다

예를 들어 test/s3upload 경로에 file.png 파일을 업로드하고 싶을 경우 dirNametest/s3upload 를 전달하면 objectPath 부분에서 전달받은 dirName + "/" + 파일 이름 으로 경로를 재생성 후 s3에 업로드하게 됩니다

update()

s3에 있는 파일을 업데이트하는 메서드입니다 파라미터로 bucket, MultipartFile, objectPath(경로)를 받습니다

나머지는 upload와 같고 objectPath는 파일을 업로드 후 return값으로 objectPath를 반환하는데 이 반환값을 넘겨주어야 합니다 그러면 amazonS3.getObjectMetadata() 메서드를 이용하여 해당 버킷에 해당 경로에 파일이 있는지 확인한 후 파일이 있다면 새로운 파일로 업데이트가 되고 만약 일치하는 정보가 없다면 catch 문에서 throw를 통해 Exception이 발생하게 됩니다

uploadFile()

inputStream을 매개변수로 받는 메서드의 경우에는 마지막으로 ObjectMetadata를 추가로 받는 것을 볼 수 있습니다 이는 InputStream을 통해 Byte만이 전달되기 때문에, 해당 파일에 대한 정보가 없어 ObjectMetadata에 파일에 대한 정보를 추가하여 매개변수로 같이 전달해야 합니다

추가적으로 자원 관리를 조금 더 쉽고 효율적으로 관리하기 위해 try-with-resources를 사용하였습니다 InputStream 객체를 try-with-resources 문의 파라미터로 생성하면 이 구문을 벗어날 때 자동으로 InputStream의 close() 메서드가 호출되어 자원이 해제됩니다 이 방식은 InputStream이 AutoCloseable 인터페이스를 구현하기 때문에 가능한 부분입니다 AutoCloseable을 구현하지 않은 객체는 이러한 방식으로 자동 해제되지 않습니다

try-with-resources를 사용함으로써 자원 해제 코드를 간소화할 수 있는 장점이 있습니다