AWS S3 파일 업로드 기능 구현하기
AWS S3 File Upload with Spring
웹 애플리케이션을 개발하면서 필수적으로 다루게 되는 부분 중 하나가 바로 파일 관리입니다. 이미지, 동영상, 문서 등 다양한 파일의 업로드, 다운로드, 조회 등이 이에 해당합니다. 이를 효율적으로 관리하고자 많은 개발자들이 클라우드 기반의 스토리지 서비스를 사용하게 되는데, 대표적인 예가 바로 Amazon Web Services의 S3 Bucket입니다.
최근 Spring Boot 새로운 버전인 3.x가 릴리즈되었고, 관련 AWS 라이브러리도 함께 업데이트되었습니다. Amazon S3 Bucket으로 파일을 업로드하는 간단한 API를 구현하면서 이런 변화를 살펴보고, AWS S3와의 통합 방법을 확인해보겠습니다.
Prerequisites
Amazon S3 Bucket과 AmazonS3FullAccess
권한을 가진 IAM 유저가 구축된 환경을 기반으로 하고 있습니다.
- Java 17
- Spring Boot 3.2.3
- Gradle 8
Implementing File Upload API
먼저, Spring Initializr를 통해 새로운 Spring Boot 프로젝트를 생성합니다. 이 프로젝트에서는 다음 의존성들이 필요합니다:
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'com.h2database:h2'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
- Spring Web: 웹 개발을 위한 기능들을 제공하며, RESTful 웹 서비스를 생성합니다.
- Spring Data JPA: 데이터 엑세스 계층이 필요할 때 JPA를 이용해 데이터베이스에서 객체를 적절하게 처리합니다.
- H2 Database: 인메모리 데이터베이스로, 업로드한 파일 정보를 저장하고 접근하게 합니다.
- Lombok: 반복되는 코드를 줄이고 가독성을 향상시킵니다. Lombok의 어노테이션을 이용하면 Getter, Setter, Constructor와 같은 보일러플레이트 코드를 자동으로 생성해주어 코드 작성을 간결하게 할 수 있습니다.
프로젝트 초기화가 완료되었으면, 이제 AWS SDK를 추가해줍니다. AWS SDK에는 두 가지 유형이 있습니다:
- AWS SDK For Java: Amazon에서 제공하는 공식 SDK입니다. 풍부한 기능과 기존의 AWS 환경과의 통합이 잘 되어 있습니다.
- Spring Cloud AWS: 공식 SDK를 Spring 애플리케이션에서 AWS를 사용하기 쉽도록 Spring Framework 스타일로 추상화한 오픈 소스 라이브러리입니다.
어느 것을 선택할지는 요구사항과 선호하는 스타일에 따라 달라집니다. 이번 글에서는 Spring Boot 애플리케이션에서 AWS를 보다 편리하게 사용할 수 있게 하는 Spring Cloud AWS를 사용하도록 하겠습니다.
Spring Cloud AWS의 버전에 따라 현재 지원하는 AWS 서비스는 다음과 같습니다:
AWS Service | Spring Cloud AWS 2.x | Spring Cloud AWS 3.x |
---|---|---|
S3 | ✅ | ✅ |
SNS | ✅ | ✅ |
SES | ✅ | ✅ |
Parameter Store | ✅ | ✅ |
Secrets Manager | ✅ | ✅ |
SQS | ✅ | ✅ |
RDS | ✅ | ❌ |
EC2 | ✅ | ❌ |
ElastiCache | ✅ | ❌ |
CloudFormation | ✅ | ❌ |
CloudWatch | ✅ | ✅ |
Cognito | ✅ | Covered by Spring Boot |
DynamoDB | ❌ | ✅ |
참고로, 어느 버전의 Spring Cloud AWS를 선택하더라도, 해당 버전에서 지원하지 않는 AWS 서비스가 필요한 경우에는 공식 AWS SDK를 활용하여 직접 구현해야 합니다.
Configuring SDK
Spring Cloud AWS를 사용하게 되면, Amazon S3를 지원하는 기능을 손쉽게 이용할 수 있습니다. 이를 프로젝트에 통합하기 위해서는 우선 Spring Cloud AWS 의존성을 build.gradle
파일에 추가해야 합니다:
ext {
+ springCloudAwsVersion = '3.1.1'
}
dependencies {
+ implementation platform("io.awspring.cloud:spring-cloud-aws-dependencies:${springCloudAwsVersion}")
+ implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'com.h2database:h2'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
다음으로, AWS SDK를 사용하기 위해 필요한 IAM 사용자 인증 정보를 application.yml
에 추가합니다:
spring:
cloud:
aws:
region:
static: ap-northeast-2
credentials:
access-key: ${AWS_ACCESS_KEY}
secret-key: ${AWS_SECRET_KEY}
이처럼 spring.cloud.aws.credentials
속성을 입력하면 애플리케이션이 구동될 때, Spring Cloud AWS 라이브러리는 AWS 작업을 위한 Client 객체를 자동으로 Spring Bean으로 등록합니다. 이를 통해 추가적인 설정 없이도 바로 S3 Client를 사용할 수 있게 됩니다.
S3 Bucket의 이름은 개발 환경에 따라 변경될 수 있으므로, 이를 감안하여 spring.cloud.aws.s3.bucket
속성을 application.yml
에 별도로 추가하여 관리하도록 하였습니다:
spring:
cloud:
aws:
region:
static: ap-northeast-2
credentials:
access-key: ${AWS_ACCESS_KEY}
secret-key: ${AWS_SECRET_KEY}
+ s3:
+ bucket: ${AWS_S3_BUCKET}
이 속성은 AWS SDK에서 지원하지 않으므로, 애플리케이션 내에서 사용하려면 전용 바인딩 클래스를 추가해야 합니다:
@Getter
@AllArgsConstructor
@ConfigurationProperties(prefix = AmazonS3BucketProperties.PREFIX)
public class AmazonS3BucketProperties {
public static final String PREFIX = "spring.cloud.aws.s3";
private String bucket;
}
사용자 정의 속성을 자동으로 객체로 바인딩 하기 위해 선언한 @ConfigurationProperties
어노테이션이 동작하게 하려면, 메인 클래스에 @ConfigurationPropertiesScan
어노테이션이 필요합니다. 이 어노테이션은 Spring Boot가 @ConfigurationProperties
어노테이션이 선언된 클래스를 스캔하여 application.yml
의 속성을 자동으로 해당 객체에 바인딩해줍니다:
@SpringBootApplication
@ConfigurationPropertiesScan
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
이제 AWS SDK 설정이 완료되었으므로, REST API 구현을 위한 준비가 모두 마무리되었습니다.
Handling Client Requests
API를 새로 구현할 때, 저는 보통 요청과 응답의 데이터 구조와 API URL을 먼저 설계하고 이를 바탕으로 클라이언트의 요청을 처리하는 컨트롤러를 작성합니다.
이 예제에서는 간결함을 위해 클라이언트로부터 파일 분류를 위한 값은 쿼리 스트링으로, 파일은 HTTP Content-Type이 multipart/form-data
인 MultipartFile
형식으로 전달받도록 하였습니다. 이를 컨트롤러 단에서 FileUploadRequest
타입으로 바인딩합니다. 이렇게 바인딩하는 과정에서 요청 데이터의 기본적인 검증을 진행하도록 컨트롤러의 역할을 설정합니다:
@Slf4j
@Getter
@Builder
public class FileUploadRequest {
private final String type;
private final MultipartFile file;
public static FileUploadRequest bind(String type, MultipartFile file) {
validate(type, file);
return FileUploadRequest.builder()
.type(type)
.file(file)
.build();
}
private static void validate(String type, MultipartFile file) {...}
}
기본적인 검증 절차를 통과하면, 이 요청은 서비스로 위임되어 처리됩니다. 이 때, 객체를 다른 레이어로 전달하면 변경사항이 발생했을 때 영향을 받는 부분이 많기 때문에 가능하면 객체를 분리하여 관리하는 것이 좋은것 같습니다. 💭 이를 위해 서비스에서 사용할 FileCreate
객체를 정의합니다.
@Getter
public class FileCreate {
private final String type;
private final MultipartFile file;
@Builder
public FileCreate(String type, MultipartFile file) {
this.type = type;
this.file = file;
}
}
그리고, FileUploadRequest
에서 데이터를 FileCreate
객체로 이관합니다.
public FileCreate toModel() {
return FileCreate.builder()
.type(type)
.file(file)
.build();
}
이 API의 전반적인 처리 프로세스는 파일이 성공적으로 업로드되면 그 결과가 데이터베이스에 저장되고, 클라이언트는 업로드된 파일 접근에 필요한 레코드 정보를 반환받도록 설계되었습니다. 컨트롤러에서 응답 페이로드를 작성하려면 데이터베이스에 저장된 이 파일 레코드 데이터가 필요합니다. 그러나 데이터베이스 레코드를 그대로 전달하는 것은 보안상 위험할 수 있습니다. 이를 위해, 비즈니스 로직의 결과를 안전하게 컨트롤러로 전달하는 역할을 하는 FileDetails
클래스를 추가합니다.
@Getter
public class FileDetails {
private final Long fileId;
private final String originalFilename;
private final String storedFilename;
private final String fileKey;
private final String fileUrl;
@Builder
public FileDetails(Long fileId, String originalFilename, String storedFilename, String fileKey, String fileUrl) {
this.fileId = fileId;
this.originalFilename = originalFilename;
this.storedFilename = storedFilename;
this.fileKey = fileKey;
this.fileUrl = fileUrl;
}
}
클라이언트가 최종적으로 전달받는 응답 데이터는 업로드된 파일에 접근하기 위해 필요한 파일 레코드의 식별자, 파일명, 그리고 파일 경로 등을 포함합니다. 클라이언트와의 사전 합의된 스펙에 따라 FileDetails
클래스를 이용해 응답 데이터를 작성합니다:
@Getter
public class FileUploadResponse {
private final Long fileId;
private final String filename;
private final String filepath;
@Builder
public FileUploadResponse(Long fileId, String filename, String filepath) {
this.fileId = fileId;
this.filename = filename;
this.filepath = filepath;
}
public static FileUploadResponse write(FileDetails fileDetails) {
return FileUploadResponse.builder()
.fileId(fileDetails.getFileId())
.filename(fileDetails.getOriginalFilename())
.filepath(fileDetails.getFileUrl())
.build();
}
}
이 프로세스를 기반으로 컨트롤러를 다음과 같이 작성할 수 있습니다. 컨트롤러는 클라이언트로부터 요청을 받아 최소한의 검증을 수행한 후, 이를 서비스로 위임하고, 그 결과를 바탕으로 응답을 작성합니다:
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping(value = "/files")
public class FileUploadController {
private final FileService service;
@PostMapping
public ResponseEntity<?> fileUploadApi(
@RequestParam String type,
@RequestPart MultipartFile file) {
FileUploadRequest fileUploadRequest = FileUploadRequest.bind(type, file);
FileDetails fileDetails = service.upload(fileUploadRequest.toModel());
return ResponseEntity
.ok(FileUploadResponse.write(fileDetails));
}
}
type
: 파일의 종류를 구분하기 위해 클라이언트로부터 전달받는 값입니다.file
:MultipartFile
타입으로 전달받는 파일입니다. 여기서는 간단히 처리하기 위해 단일 파일을 전달받지만, 이를 이해하면 여러 파일을 동시에 전달받아 처리하는 경우도 쉽게 구현할 수 있습니다.- 클라이언트에서 전송한 데이터를
FileUploadRequest
객체로 바인딩하고 기본적인 검증을 진행합니다. - 요청 데이터가 검증을 통과하면,
FileUploadRequest
를FileCreate
객체로 변환하고 이를 비즈니스 로직 처리를 위해 서비스로 전달합니다. - 파일 업로드가 성공적으로 이루어지면, 해당 파일 정보를 포함하는
FileDetails
객체가 반환됩니다. 이 객체는 클라이언트로 보내지는 적절한 응답FileUploadResponse
을 작성하는 데 사용됩니다.
Processing Requests
이제, 컨트롤러로부터 받은 요청을 실제로 처리하는 비즈니스 로직을 단계별로 구현해보겠습니다. 각 단계에서 필요한 작업들을 하나씩 살펴보고, 이를 코드로 구현하여 전체 파일 업로드 흐름을 완성해보겠습니다.
AWS S3 Bucket에 파일을 업로드하는 기능은 S3Client
객체를 사용하여 구현할 수 있습니다. Spring Cloud AWS에서는 S3 Bucket과 상호작용하는 방법이 크게 세 가지가 있습니다.
S3Client
:- AWS SDK for Java에서 제공하는 라이브러리로 S3 엔드포인트와의 저수준 HTTP 연결을 관리합니다. 이를 사용하면, S3 Bucket과 객체에 대한 다양한 작업을 수행할 수 있습니다.
S3TransferManager
:- 고수준 API인 Transfer Manager를 제공합니다. 이를 사용하면, 큰 파일을 자동으로 나눈 후 병렬로 업로드 및 다운로드하는 등의 간소화된 인터페이스를 제공합니다. 비동기 파일 전송을 지원하여 파일 전송을 완료하기 위해 애플리케이션 스레드를 차단하는 것을 피할 수 있습니다.
S3Template
:- Spring Cloud AWS가 제공하는 클래스로, S3 Bucket을 관리하는 데 사용하는 Spring Framework 스타일의 템플릿입니다. 이 템플릿을 사용하면 상대적으로 간편하게 S3의 다양한 동작을 수행할 수 있습니다.
여기서는 가장 간편한 방법인 S3Template
을 이용해 파일 업로드를 구현해보겠습니다. 사실, 아래와 같이 S3Template
구현 코드를 살펴보면 S3Template
도 내부적으로 S3Client
를 사용하고 있는 것을 확인할 수 있습니다. S3Template
은 S3Client
의 기능을 더 사용자 친화적으로 추상화하여 제공합니다:
private void putObject(byte[] content) {
try {
s3Client.putObject(PutObjectRequest.builder().bucket(location.getBucket()).key(location.getObject())
.contentLength((long) content.length).applyMutation(builder -> {
getHash(content).ifPresent(builder::contentMD5);
if (objectMetadata != null) {
objectMetadata.apply(builder);
}
applyContentType(builder::contentType);
}).build(), RequestBody.fromBytes(content));
} catch (SdkException e) {
throw new S3Exception("Simple upload failed.", e);
}
}
이와 같이, Spring Cloud AWS의 주요 장점 중 하나는 이러한 추상화된 인터페이스가 제공하는 편리함입니다. 이 S3Template
을 활용하여 실제로 AWS S3 Bucket에 파일을 업로드하는 기능을 하나씩 구현해보겠습니다. 이 과정을 통해 파일 업로드 작업이 얼마나 간편해지는지 직접 체감할 수 있을 것입니다.
S3Template
의 upload()
함수는 S3 Bucket의 이름, S3 Object의 고유 이름인 Key, 그리고 InputStream
형태의 파일을 매개변수로 받아 실제로 파일을 S3 Bucket으로 업로드하는 역할을 합니다. 이를 위해 AmazonS3PutRequest
라는 전용 클래스를 먼저 준비해보겠습니다. 이 클래스는 컨트롤러에서 전달한 FileCreate
객체의 데이터를 적절하게 재가공하여 S3 Bucket 업로드 요청에 맞도록 변환합니다:
@Getter
public class AmazonS3PutRequest {
private final MultipartFile file;
private final String originalFilename;
private final String storedFilename;
private final String key;
private final long size;
@Builder
public AmazonS3PutRequest(
MultipartFile file,
String originalFilename,
String storedFilename,
String key,
long size) {
this.file = file;
this.originalFilename = originalFilename;
this.storedFilename = storedFilename;
this.key = key;
this.size = size;
}
public static AmazonS3PutRequest from(FileCreate request, String datestamp) {
MultipartFile file = request.getFile();
String originalFilename = generateOriginalFilename(file);
String storedFilename = generateStoredFilename(originalFilename);
String directory = generateDirectory(request.getType(), datestamp);
String key = generateObjectKey(directory, storedFilename);
return AmazonS3PutRequest.builder()
.file(file)
.originalFilename(originalFilename)
.storedFilename(storedFilename)
.key(key)
.size(file.getSize())
.build();
}
...
}
generateOriginalFilename()
: 원본 파일명을 추출합니다.generateStoredFilename()
: 원본 파일명을 기반으로 저장할 파일의 새 이름을 생성합니다. 파일 이름은 UUID (Universally Unique Identifier)에 파일 확장자를 추가하여 생성합니다.generateDirectory()
: 파일 분류와 날짜를 기반으로 파일이 저장될 S3 Bucket의 경로를 생성합니다.generateObjectKey()
: 경로와 저장된 파일의 이름을 결합하여 S3 Object의 Key를 생성합니다. S3에서 Object는 파일을 뜻하며, 고유한 키로 식별됩니다.
이렇게 가공된 데이터를 이용해 s3Template.upload()
를 호출하여 S3 Bucket으로 파일 업로드를 요청합니다. 요청이 성공적으로 처리되면 S3Resource
객체가 반환됩니다. 이 객체는 S3 Bucket에 업로드된 파일의 정보를 포함하고 있으며, S3 Object의 URL 경로를 추출할 수 있습니다.
지금까지 정의한 객체들을 활용하여 AWS S3 Bucket에 파일을 업로드하는 로직을 작성하면, 다음과 같이 구현할 수 있습니다:
@Slf4j
@Component
@RequiredArgsConstructor
public class AmazonS3BucketService implements CloudStorageService {
private final AmazonS3BucketProperties s3BucketProperties;
private final ClockHolder clockHolder;
private final S3Template s3Template;
@Override
public FileMetadata upload(FileCreate fileCreate) {
AmazonS3PutRequest amazonS3PutRequest = AmazonS3PutRequest.from(fileCreate, clockHolder.stampCurrentDate());
try (InputStream inputStream = amazonS3PutRequest.getFile().getInputStream()) {
S3Resource s3Resource = s3Template.upload(
s3BucketProperties.getBucket(),
amazonS3PutRequest.getKey(),
inputStream);
String objectUrl = s3Resource.getURL().toExternalForm();
return AmazonS3ObjectMetadata.from(amazonS3PutRequest, objectUrl).toModel();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
파일이 성공적으로 업로드되면 해당 파일에 대한 정보를 담은 FileMetadata
를 생성해 반환합니다. 다음 단계의 비즈니스 로직에서는 이 FileMetadata
를 활용하여 데이터베이스에 정보를 저장합니다. 이 작업은 FileCreator
가 처리하며, JpaRepository
를 사용해 데이터를 저장합니다. 레코드 저장 과정은 현재 주요 관심사가 아니므로 여기서는 생략하겠습니다. 저장된 레코드는 앞서 정의한 FileDetails
로 변환되어 컨트롤러로 반환됩니다. 이러한 과정을 통해 파일 업로드와 관련된 전체 비즈니스 로직이 완성됩니다:
@Slf4j
@Service
@RequiredArgsConstructor
public class FileServiceImpl implements FileService {
private final FileCreator fileCreator;
private final CloudStorageService cloudStorageService;
@Override
public FileDetails upload(FileCreate fileCreate) {
FileMetadata metadata = cloudStorageService.upload(fileCreate);
FileDetails fileDetails = fileCreator.persist(metadata);
log.info("upload: Successfully created file - fileId={} filename={}",
fileDetails.getFileId(),
fileDetails.getOriginalFilename());
return fileDetails;
}
}
지금까지 AWS S3 Bucket에 파일을 업로드하는 API의 구현 과정을 살펴보았습니다. 이제 이 구현이 실제로 정상적으로 작동하는지 HTTP 클라이언트 도구를 이용하여 확인해보겠습니다.
Playgrounds
API의 실제 동작을 확인하기 위해 HTTP 클라이언트 도구를 활용하여 API를 호출해보겠습니다. 애플리케이션을 구동하고 아래와 같이 요청을 작성하여 전송합니다:
POST /files?type=avatar HTTP/1.1
Content-Type: multipart/form-data; charset=utf-8; boundary=__X_PAW_BOUNDARY__
Host: localhost:8080
Connection: close
User-Agent: RapidAPI/4.2.0 (Macintosh; OS X/14.4.0) GCDHTTPRequest
Content-Length: 1290096
서버는 클라이언트의 요청을 받고 처리한 후, 업로드된 파일에 관한 정보를 JSON 형식으로 반환하게 됩니다. 반환되는 JSON의 형태는 다음과 같습니다:
{
"fileId": 1,
"filename": "catsriding.png",
"filepath": "https://ocean-waves.s3.ap-northeast-2.amazonaws.com/user/avatar/2024-03-23/6d009e1a-6980-4a25-92bd-a42c9da18848.png"
}
여기서 filepath
는 AWS S3 Bucket에 저장된 파일에 접근할 수 있는 경로를 가리킵니다. 이 URL을 웹 브라우저에 입력하면 업로드한 이미지가 정상적으로 표시되는 것을 확인할 수 있습니다.
이렇게 AWS S3 Bucket에 파일 업로드 기능을 구현하고 테스트를 통해 검증을 완료했습니다. AWS S3는 뛰어난 내구성과 확장성을 제공하여 다양한 비즈니스 요구사항을 충족시키며 현대 애플리케이션에 필수적인 서비스입니다. AWS는 SDK 지원을 비롯한 다양한 도구와 리소스를 제공하여 개발자들이 쉽게 AWS 서비스를 통합하고 활용할 수 있도록 돕고 있습니다. 이를 통해 개발자들은 안정적이고 효율적인 애플리케이션을 구축하는 데에 더욱 집중할 수 있습니다.
이 글에 사용된 코드는 생략된 부분이 많습니다. 프로젝트의 전체 코드는 🐙 GitHub Repository에서 확인할 수 있습니다.
- Spring
- AWS