AWS SDK for S3의업로드관련클래스
。S3관련 기능을 정의한클래스
▶RustFS는S3 API를 그대로 사용하므로, 호환 가능
단일 업로드
PutObjectRequest
。S3에 실제업로드를 수행할파일의정보를 정의
▶버킷명/파일명( =key) /Content-Type등을 정의PutObjectRequest objectRequest = PutObjectRequest.builder() .bucket("my-bucket") .key("images/profile.png") .contentType("video/mp4") .metadata(Map.of( "userId", "123", "project", "asset-box" )) .build();my-bucket └── images └── profile.png
bucket:업로드할S3 버킷을 지정
key: 저장될객체의경로및이름을 정의하여저장될 파일명지정
contentType:파일의MIME 타입(image/png,image/jpeg,video/mp4등 )
PutObjectPresignRequest
。정의한PutObjectRequest 객체를 기반으로S3 Presigned URL을 생성하기 위한요청 객체
▶S3에파일을업로드할 수 있는PresignedURL을 생성하기위한요청을 포함하는객체PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder() .signatureDuration(Duration.ofMinutes(10)) .putObjectRequest(objectRequest) .build();
signatureDuration:Presigned URL의유효시간
▶유효시간이 지나면403 Forbidden발생
PresignedPutObjectRequest
。S3Presigner에 의해 생성된PresignedURL과 관련 정보를 담는결과 객체
▶PresignedPutObjectRequest객체.url().toString();를 통해PresignedURL을 추출
S3Presigner
。업로드를 위한S3 Presigned URL을 생성하기 위한객체
▶AWS 자격증명으로서명을 수행한Presigned URL을 생성하는 역할을 수행
。S3은 기본적으로인증 사용자만 접근가능하므로,브라우저에서 바로업로드 시도시AWS Access Key가 없으므로,403이 도출
▶AWS Access Key를 가진백엔드 서버가 대신서명을 생성하여 적용한URL을 제공하여업로드를 수행
。AWS Region/AWS Access Key/AWS Secret Key를 정의하여S3 Presigner의Spring Bean을 정의
▶ 그러나, 본프로젝트에서는RustFS를 사용하므로 ,RustFS에 대한 설정을 정의S3Presigner presigner = S3Presigner.builder() .region(Region.AP_NORTHEAST_2) .credentialsProvider(credentialsProvider) .build();
S3Presigner객체.presignPutObject(PutObjectPresignRequest객체)
。S3에PresignedURL을 요청하는메서드PresignedPutObjectRequest presignedRequest = s3Presigner.presignPutObject(presignRequest); String presignedUrl = presignedRequest.url().toString(); // PresignedURL 추출▶
파일 정보를 포함하는PutObjectPresignRequest 객체를 포함하여Object Storage에서PresignedURL을요청후PresignedPutObjectRequest 객체로 반환
S3Presigner객체.presignGetObject(GetObjectPresignRequest객체)
。GetObjectPresignRequest에서명을 추가해서 RustFS에 전달함으로써다운로드 URL을 정의하는PresignedURL을 생성하여PresignedGetObjectRequest객체를 반환하는메서드
멀티파트 업로드
CreateMultipartUploadRequest
。S3에서멀티파트 업로드를 시작하기 위한요청 객체
▶ 해당파일에 대해멀티파트 업로드를 시작한다는 정보를 전달하는객체
。s3Client객체.createMultipartUpload()를 통해S3에 전달 시uploadId를 생성하여 반환CreateMultipartUploadRequest request = CreateMultipartUploadRequest.builder() .bucket("my-bucket") .key("videos/movie.mp4") .contentType("video/mp4") .build();my-bucket └── videos └── movie.mp4
CompletedPart
。멀티파트 업로드를 수행하는 경우 각Part의업로드 결과( =ETag)을 저장하는객체
▶파트 번호와ETag을 포함
。클라이언트에서파트 별Presigned URL에 각파트를 업로드하면S3은응답 헤더에ETag을 전달CompletedPart part1 = CompletedPart.builder() .partNumber(1) // .eTag("ab123") .build();
CompletedMultipartUpload
。모든CompletedPart를 하나로 묶는Part 목록을 담당하는객체CompletedMultipartUpload upload = CompletedMultipartUpload.builder() .parts( part1, part2, part3 ) .build();
CompleteMultipartUploadRequest
。멀티파트 업로드를 수행 중인uploadId에 대해 실제로 완료했다고RustFS에 요청하는객체CompleteMultipartUploadRequest request = CompleteMultipartUploadRequest.builder() .bucket(bucket) // 버킷명 .key(path) // 경로를 포함한 파일명 .uploadId(uploadId) // uploadId .multipartUpload(upload) // CompletedMultipartUpload .build();▶
버킷명/파일명/uploadId/CompletedMultipartUpload를 포함
。해당객체를S3Client.completeMultipartUpload(completeRequest)의인자로 포함하여RustFS로 전달
S3Client
。Spring 서버에서S3 API를 사용하여파일의업로드 / 다운로드 / 삭제하는 기능을 수행하는객체
S3Client객체.createMultipartUpload(CreateMultipartUploadRequest객체)
。S3에멀티파트 업로드를 시작하도록요청하는메서드
▶S3은업로드를식별하는UploadId등이 포함된CreateMultipartUploadResponse 객체를 반환
S3Client객체.completeMultipartUpload(CompleteMultipartUploadRequest객체)
。S3에서멀티파트 업로드를 통해업로드된Part들을단일 파일로병합하도록RustFS에게요청하는API
▶호출 시점에서Part1 , Part2 , ...를파일.mp4로병합
단일 / 멀티파트 업로드구현
RustFS도커 이미지 실행
RUSTFS
S3에 대한의존성정의
。AWS SDK S3에 대한의존성 정의implementation 'software.amazon.awssdk:s3:2.29.26'
application.yml/.env정의spring: application: name: springboot-sns-sample config: import: optional:file:.env[.properties] datasource: url: ${DB_URL} username: ${DB_USERNAME} password: ${DB_PASSWORD} driver-class-name: org.postgresql.Driver jpa: show-sql: true hibernate: ddl-auto: update rustfs: endpoint: ${RUSTFS_ENDPOINT} access-key: ${RUSTFS_ACCESS_KEY} secret-key: ${RUSTFS_SECRET_KEY} bucket: ${RUSTFS_BUCKET_NAME} region: ${RUSTFS_REGION} presigned-url-expiration-seconds: ${PRESIGNEDURL_EXPIRATION_TIME_SEC}#PostgreSQL DB_URL=jdbc:postgresql://localhost:5432/snsdb DB_USERNAME=snsuser DB_PASSWORD=snspassword # RUSTFS RUSTFS_ENDPOINT=http://localhost:9000 RUSTFS_ACCESS_KEY=rustfsadmin RUSTFS_SECRET_KEY=1234567 RUSTFS_BUCKET_NAME=media RUSTFS_REGION=ap-northeast-2 PRESIGNEDURL_EXPIRATION_TIME_SEC=3600▶
PresignedUrl의유효시간:1시간지정
RustFS에서AWS S3을 사용하도록마이그레이션하는 방법
。단순하게application.yml의 설정을RustFS에서AWS S3으로 전환rustfs: endpoint: AWS S3 주소 access-key: AWS S3 Access Key secret-key: AWS S3 Secret Key
9001 포트에 진입하여RustFS 웹콘솔진입 및버킷생성
。.env파일에서 지정한버킷명에 해당하는버킷을 생성
▶ 해당버킷으로파일의업로드가 수행됨.
RustFS관련Properties 클래스정의
。사전에진입점 클래스에@ConfigurationPropertiesScan이 선언되어야한다.
。application.yml 파일에서 설정된버킷명(rustfs: bucket: ${RUSTFS_BUCKET_NAME})의버킷으로업로드수행
▶ 사전에 해당버킷이 정의되어있어야한다.@ConfigurationProperties(prefix = "rustfs") public record RustFsProperties( String endpoint, String accessKey, String secretKey, String bucket, String region, Long presignedUrlExpirationSeconds ) { }
S3의Configuration Class정의
。RustFS는S3 API를 그대로 사용하므로, 호환 가능하며,RustFS와 관련된 설정을 수행@Configuration @RequiredArgsConstructor public class S3Config { private final RustFsProperties rustFsProperties; @Bean public S3Client s3Client() { return S3Client.builder() .endpointOverride(URI.create(rustFsProperties.endpoint())) .region(Region.of(rustFsProperties.region())) .credentialsProvider(StaticCredentialsProvider.create( AwsBasicCredentials.create( rustFsProperties.accessKey(), rustFsProperties.secretKey() ) )) .forcePathStyle(true) .build(); } @Bean public S3Presigner s3Presigner() { return S3Presigner.builder() .endpointOverride(URI.create(rustFsProperties.endpoint())) .region(Region.of(rustFsProperties.region())) .credentialsProvider(StaticCredentialsProvider.create( AwsBasicCredentials.create( rustFsProperties.accessKey(), rustFsProperties.secretKey() ) )) .serviceConfiguration(S3Configuration.builder() .pathStyleAccessEnabled(true) .build()) .build(); } }
DTO정의
단일 업로드시Init POST API로요청할DTOpublic record MediaInitRequest( MediaType mediaType, Long fileSize ) { }
Presigned URL을 포함할DTOpublic record PresignedUrl( Media media, String presignedUrl, String uploadId, List<PresignedUrlPart> presignedUrlParts ) { public static PresignedUrl forSingleUpload( Media media, String presignedUrl ) { return new PresignedUrl(media, presignedUrl, null, null); } public static PresignedUrl forMultipartUpload( Media media, String uploadId, List<PresignedUrlPart> presignedUrlParts ) { return new PresignedUrl(media, null, uploadId, presignedUrlParts); } }
멀티파트 업로드를 수행 시 각파트별PresignedUrl을 포함할DTOpublic record PresignedUrlPart( int partNumber, String presignedUrl ) { }
파일 업로드후응답값을 포함하는DTOpublic record MediaInitResponse( Long id, MediaType mediaType, String path, MediaStatus status, Long userId, String presignedUrl, String uploadId, List<PresignedUrlPart> presignedUrlParts, LocalDateTime createdAt, LocalDateTime modifiedAt ) { public static MediaInitResponse from(PresignedUrl presignedUrl) { Media media = presignedUrl.media(); return new MediaInitResponse( media.getId(), media.getMediaType(), media.getPath(), media.getStatus(), media.getUserId(), presignedUrl.presignedUrl(), presignedUrl.uploadId(), presignedUrl.presignedUrlParts(), media.getCreatedAt(), media.getModifiedAt() ); } }
MediaRepository정의public interface MediaRepository extends JpaRepository<Media, Long> { Optional<Media> findByIdAndDeletedAtIsNull(Long id); default Media findByIdAndDeletedAtIsNullOrThrow(Long id){ return findByIdAndDeletedAtIsNull(id) .orElseThrow(() -> new IllegalArgumentException("Media not found: " + id)); } List<Media> findByUserIdAndDeletedAtIsNullOrderByCreatedAtDesc(Long userId); @Query(value = """ SELECT * FROM media WHERE attributes -> 'parts' @> :partNumberJson\\:\\:jsonb AND deleted_at IS NULL ORDER BY created_at DESC """, nativeQuery = true) List<Media> findByPartNumber(@Param("partNumberJson") String partNumberJson); }
。Presigned URL 요청
Presigned URL 요청-MediaService정의@Service @RequiredArgsConstructor public class MediaService { private static final long MULTIPART_THRESHOLD = 8 * 1024 * 1024; // 8MB = 8,388,608 // private final MediaRepository mediaRepository; private final MultipartService multipartService; private final S3Presigner s3Presigner; private final RustFsProperties rustFsProperties; // ... }
initMedia()
。파일크기에 따라서단일 / 멀티파트 업로드를 결정하여업로드를 시작하고,업로드를 수행하는PresignedURL을 반환하는메서드
▶RustFS의Bucket에 저장할Path를 정의
ex )RustFS상users/1/posts/UUID.jpg로 저장private static final long MULTIPART_THRESHOLD = 8 * 1024 * 1024; // 8MB = 8,388,608 // 파일 업로드를 수행하는 메서드 public PresignedUrl initMedia( MediaType mediaType, Long fileSize, User user, String subPath ){ // 해당 MediaType의 확장자를 반환 // MediaType : IMAGE인 경우 filename : UUID.jpg String filename = UUID.randomUUID() + mediaType.fileExtension(); // Object Storage 상 미디어파일 저장경로 생성 // ex ) RustFS 상 users/1/posts/UUID.jpg로 저장 String path = "users/%s/%s/%s".formatted( user.getId(), subPath, filename ); // 파일크기에 따라서 단일업로드 / 멀티파트 업로드 결정 if (fileSize != null && fileSize > MULTIPART_THRESHOLD) return initMultipartUpload(user, path, mediaType, fileSize); return initSingleUpload(user,path,mediaType,fileSize); } // ...
initSingleUpload()
。단일 업로드를 수행하기위해RustFS에Presigned Url을 요청하는메서드
▶initMedia()으로부터8MB 이하인파일에 대해RustFS의버킷 정보및initMedia()에서 정의한Path 정보와파일 MediaType 정보를 활용하여Presigned URL을 요청
。application.yml 파일에서 설정된버킷명(rustfs: bucket: ${RUSTFS_BUCKET_NAME})의버킷으로업로드수행
▶ 사전에 해당버킷이 정의되어있어야한다
。PresignedUrl을 수신한 경우INIT 상태의Media 객체생성 및Entity로서 저장private PresignedUrl initSingleUpload( User user, String path, MediaType mediaType, Long fileSize ) { // RustFS의 버킷정보 및 initMedia()에서 정의한 Path 정보와 MediaType 정보를 전달 PutObjectRequest putObjectRequest = PutObjectRequest.builder() .bucket(rustFsProperties.bucket()) // RustFS에 저장할 버킷명 .key(path) // 경로 및 이름을 정의한 저장될 파일명 .contentType(mediaType.contentType()) // 파일의 MIME 타입 .build(); // RustFS에 전달할 PresignedURL에 대한 요청을 생성하기 위해 PutObjectRequest 객체 생성 // PresignedUrl의 유효기간에 관한 설정도 추가 PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder() .signatureDuration(Duration.ofSeconds(rustFsProperties.presignedUrlExpirationSeconds())) .putObjectRequest(putObjectRequest) .build(); // S3Presigner 객체를 통해 RustFS에 요청을 전달하여 PresignedUrl를 수신 PresignedPutObjectRequest presignedRequest = s3Presigner.presignPutObject(presignRequest); String presignedUrl = presignedRequest.url().toString(); // INIT 상태의 Media 객체 생성 Media media = mediaRepository.save( new Media( mediaType, path, user.getId(), fileSize ) ); // PresinedUrl을 Client에게 반환 return PresignedUrl.forSingleUpload(media, presignedUrl); }
bucket:업로드할S3 버킷을 지정
key: 저장될객체의경로및이름을 정의하여저장될 파일명지정
contentType:파일의MIME 타입(image/png,image/jpeg,video/mp4등 )
initMultiPartUpload()
。멀티파트 업로드를 수행하기위해RustFS에Presigned Url을 요청하는메서드
▶임계값 이상의파일에 대해파트별로업로드를 수행하므로 복수의Presigned URL을 요청private PresignedUrl initMultipartUpload(User user, String path, MediaType mediaType, long fileSize) { // 멀티파트 업로드를 수행하기 위해 RustFS에 요청을 전달하여 복수의 PresignedUrl를 수신 MultipartUploadInfo uploadInfo = multipartService.initMultipartUpload( path, mediaType.contentType(), fileSize ); // INIT 상태의 Media 객체 생성 Media media = mediaRepository.save( new Media( mediaType, path, user.getId(), fileSize, uploadInfo.uploadId() ) ); return PresignedUrl.forMultipartUpload(media, uploadInfo.uploadId(), uploadInfo.presignedUrlParts()); }
Presigned URL 요청-MultipartService
。멀티파트 업로드에 대한 기능을 정의@Service @RequiredArgsConstructor public class MultipartService { private static final long PART_SIZE = 8 * 1024 * 1024; // 8MB private final S3Client s3Client; private final S3Presigner s3Presigner; private final RustFsProperties rustFsProperties; // ... }
initMultipartUpload()
。멀티파트 업로드를 수행하기위해RustFS에 복수의Presigned URL을 요청하는메서드
。업로드시작 전createMultipartUpload()를 호출하여RustFS에멀티파트 업로드를 시작한다고 알림 및uploadId를 수신
。ceil(파일크기 / 임계값)을 계산하여 생성할 각파트별Presigned URL 수를 정의
ex )35MB 파일의 경우ceil(35 / 8) = 5개
▶ 정의한 수 만큼 각파트별PresignedURL을 생성 후 반환public MultipartUploadInfo initMultipartUpload(String path, String contentType, long fileSize) { // 멀티파트 업로드를 위한 요청객체 생성 CreateMultipartUploadRequest createRequest = CreateMultipartUploadRequest.builder() .bucket(rustFsProperties.bucket()) // RustFS에 저장할 버킷명 .key(path) // 경로 및 이름을 정의한 저장될 파일명 .contentType(contentType) // 파일의 MIME 타입 .build(); // S3에 멀티파트 업로드를 시작하도록 요청 전달 및 uploadId 수신 CreateMultipartUploadResponse createResponse = s3Client.createMultipartUpload(createRequest); String uploadId = createResponse.uploadId(); // 생성할 PresignedUrl의 수를 계산 // 35MB 파일의 경우 ceil(35 / 8) = 5개 int numberOfParts = (int) Math.ceil((double) fileSize / PART_SIZE); // 정의한 수 만큼 각 파트별 PresignedURL 생성 및 해당 리스트에 저장 List<PresignedUrlPart> presignedUrlParts = new ArrayList<>(); for (int partNumber = 1; partNumber <= numberOfParts; partNumber++) { UploadPartRequest uploadPartRequest = UploadPartRequest.builder() .bucket(rustFsProperties.bucket()) .key(path) .uploadId(uploadId) .partNumber(partNumber) .build(); // UploadPartPresignRequest presignRequest = UploadPartPresignRequest.builder() .signatureDuration(Duration.ofSeconds(rustFsProperties.presignedUrlExpirationSeconds())) .uploadPartRequest(uploadPartRequest) .build(); // PresignedUploadPartRequest presignedRequest = s3Presigner.presignUploadPart(presignRequest); String presignedUrl = presignedRequest.url().toString(); // presignedUrlParts.add(new PresignedUrlPart(partNumber, presignedUrl)); } return new MultipartUploadInfo(uploadId, presignedUrlParts); }
Presigned URL 요청-MediaController정의@RestController @RequiredArgsConstructor public class MediaController { private final MediaService mediaService; // ... }
PresignedURL 생성 컨트롤러
。단일 / 멀티파트 업로드를 위한Presigned URL을 반환@PostMapping("/api/v1/media/init") public MediaInitResponse initMedia( @RequestBody MediaInitRequest request ) { PresignedUrl result = mediaService.initMedia( request.mediaType(), request.fileSize(), User.create( "kf16", "wjd747" ).setId(1L), "posts" ); return MediaInitResponse.from(result); }
▶단일 / 멀티파트 업로드여부에 따라서PresignedURL이 포함되며,응답값이 서로 다름을 확인 가능
。클라이언트에서 업로드 완료를 알리는 API를 정의
DTO정의
MediaUploadedRequest
。멀티파트 업로드 완료를 위해클라이언트에서 해당Media 객체 ID와 함께업로드 완료된Parts( =partNumber,ETag)들을 전달하는DTOpublic record MediaUploadedRequest( Long mediaId, List<MultipartUploaded> parts ) { }
MultipartUploaded
。멀티파트 업로드시 각파트의PresignedURL과ETAG을 포함하는DTOpublic record MultipartUploaded( int partNumber, String eTag ) { }
MediaResponse
。업로드 완료된미디어 파일의메타데이터를 포함하는DTOpublic record MediaResponse( Long id, MediaType mediaType, String path, MediaStatus status, Long userId, Long fileSize, Map<String, Object> attributes, LocalDateTime createdAt, LocalDateTime modifiedAt ) { public static MediaResponse from(Media media) { return new MediaResponse( media.getId(), media.getMediaType(), media.getPath(), media.getStatus(), media.getUserId(), media.getFileSize(), media.getAttributes(), media.getCreatedAt(), media.getModifiedAt() ); } }
업로드 완료 요청-MediaSerivce정의
mediaUploaded()
。INIT 상태의미디어 객체를UPLOADED 상태로 전환하여업로드 완료를 수행하는메서드
。멀티파트 업로드인 경우completeMultipartUpload()를 호출하여RustFS에업로드 완료를 알림
▶RustFS에서 이를 수신 시RustFS에서백엔드로부터API수신 시업로드된 파일을병합시작public Media mediaUploaded( Long mediaId, List<MultipartUploaded> parts, User user ) { Media media = mediaRepository.findByIdAndDeletedAtIsNullOrThrow(mediaId); // Media가 본인 소유가 아니면 다운로드 차단 if (!media.getUserId().equals(user.getId())) { throw new IllegalArgumentException("You are not authorized to update this media"); } // Media가 INIT 상태 인 경우에만 허용 if (media.getStatus() != MediaStatus.INIT) { throw new IllegalArgumentException("Media is not in INIT status"); } // 멀티파트 업로드인 경우 S3에 작업완료를 지시 ( 단일 업로드 인 경우 생략 ) if (media.getUploadId() != null && !CollectionUtils.isEmpty(parts)) { // completeMultipartUpload()를 호출하여 S3에 업로드 완료를 알림 // S3에서 백엔드로부터 API 수신 시 업로드된 파일을 병합 시작 multipartService.completeMultipartUpload(media.getPath(), media.getUploadId(), parts); Map<String, Object> attributes = new HashMap<>(); attributes.put("parts", parts); media.updateAttributes(attributes); } // Media 객체 상태를 UPLOADED로 전환 media.updateStatus(MediaStatus.UPLOADED); // Media 객체 저장 return mediaRepository.save(media); }
업로드 완료 요청-MultipartService
。클라이언트에서멀티파트 업로드가 완료된 경우RustFS에게 전달
completeMultipartUpload()
。클라이언트에서PresignedURL을 통해 모든파트가업로드 완료시RustFS에게업로드된파트를 하나의파일로 병합하도록 요청하는메서드// 클라이언트에서 멀티파트 업로드가 완료된 경우 RustFS에 public void completeMultipartUpload(String path, String uploadId, List<MultipartUploaded> parts) { // 각 파트의 파트번호와 ETag을 포함하는 CompletedPart를 생성 List<CompletedPart> s3Parts = parts.stream() .map(part -> CompletedPart.builder() .partNumber(part.partNumber()) .eTag(part.eTag()) .build()) .toList(); // CompletedPart를 하나로 묶는 CompletedMultipartUpload를 생성 CompletedMultipartUpload completedMultipartUpload = CompletedMultipartUpload.builder() .parts(s3Parts) .build(); // 멀티파트 업로드 완료를 RustFS에게 알리는 CompleteMultipartUploadRequest 객체를 생성 CompleteMultipartUploadRequest completeRequest = CompleteMultipartUploadRequest.builder() .bucket(rustFsProperties.bucket()) .key(path) .uploadId(uploadId) .multipartUpload(completedMultipartUpload) .build(); // 멀티파트 업로드 완료를 전달하는 API // 이후 RustFS는 업로드된 Part들을 하나의 파일로 병합 s3Client.completeMultipartUpload(completeRequest); }CompletedMultipartUpload │ ▼ CompleteMultipartUploadRequest │ ▼ s3Client.completeMultipartUpload(request) │ ▼ RustFS가 모든 Part를 하나의 파일로 병합
업로드 완료 요청-MediaController정의
업로드 완료 컨트롤러
。멀티파트 업로드가 완료된 시점으로부터클라이언트에게 해당Media 객체 ID와 함께업로드 완료된Parts( =partNumber,ETag)들을 전달받아업로드 완료 처리수행@PostMapping("/api/v1/media/uploaded") public MediaResponse mediaUploaded( @RequestBody MediaUploadedRequest request ) { Media media = mediaService.mediaUploaded( request.mediaId(), request.parts(), User.create( "kf16", "wjd747" ).setId(1L) ); return MediaResponse.from(media); }
업로드 테스트
。프론트엔드를 구축 후업로드를 수행하는 경우 다음처럼 결과가 도출