프로젝트AWS 허브발행일 2025. 9. 29.원본 https://blog.naver.com/jword_/224026400104 ↗

Spring Boot AWS S3 연동하기 (presigned URL)

Spring Boot AWS S3 연동하기 (presigned URL) — #SpringBoot #개발자의도구들 #SpringBootAWS #AWSS3 #presignedURL #S3Presigned 아키텍...

#Projects#Naver Blog

#SpringBoot #개발자의도구들 #SpringBootAWS #AWSS3 #presignedURL #S3Presigned

​

아키텍처

깃허브 주소

https://github.com/keyveloper/marketing-image-api-server/blob/master/src/main/kotlin/org/example/marketingimageapiserver/service/ProfileImageService.kt

이미지

기존에 이미지 데이터를 로컬 폴더에 저장을 했었습니다. 이후 이미지를 안정적으로 저장,관리 하기 위해서 AWS S3에 이미지를 저장하는 API 서버를 따로 만들었습니다.

​

Flow

  • user는 front에 이밎와 관련된 API 동작 호출
  • front는 image-api-server에 전달
  • image-api-server는 S3를 호출하여 이미지 관리

​

👉 이번 글에서는 image-api-server에 이미지를 저장하는 로직, presigned url을 발급하여 저장된 데이터를 가져오는 로직을 정리하였습니다.

​

이미지 저장

이미지를 S3에 성공적으로 저장하기 위해서 이미지와 관련된 메타데이터를 로컬 RDBMS에 따로 저장하였습니다.

​

image-meta-data 테이블을 구축하였습니다.

sql 코드 예제
                                    CREATE TABLE `profile_image_metadata` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `user_type` varchar(50) NOT NULL,
  `user_id` bigint(20) NOT NULL,
  `image_type` varchar(20) NOT NULL,
  `created_at` bigint(20) NOT NULL,
  `last_modified_at` bigint(20) NOT NULL,
  `original_file_name` varchar(255) NOT NULL,
  `content_type` varchar(100) NOT NULL,
  `size` bigint(20) NOT NULL,
  `bucket_name` varchar(255) NOT NULL,
  `s3_key` varchar(500) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_uca1400_ai_ci
text 코드 예제
                                    id: 고유 식별자
user_type  :   user는 두 분류 = advertiser & influencer
user_id    :   user의 고유 id
image_type :   profile의 경우 background랑 프로필 사진
bucket_name:   S3 버킷 이름
s3_key     :   개별 s3 고유 이미지 key

​

Image 저장을 위한 service 로직을 추가하였습니다.

kotlin 코드 예제
                                    fun saveProfileImage(
        meta: MakeNewProfileImageRequest,
        file: MultipartFile
    ): SaveFileResult {

        return transaction {
            // Extract file metadata from MultipartFile using Tika
            val fileSize = file.size
            val originalFileName = file.originalFilename

            // Use Tika to detect the actual content type from file content
            val detectedContentType = tika.detect(file.inputStream, originalFileName)
            val contentType = detectedContentType ?: file.contentType ?: "application/octet-stream"

            // Generate unique S3 key
            val s3Key = "profile-images/${UUID.randomUUID()}-${UUID.randomUUID()}"
            val bucketName = "marketing-image-bucket"
            var s3UploadSuccessful = false

            try {
                val logger = KotlinLogging.logger {}
                // Upload file to S3
                val putObjectRequest = PutObjectRequest.builder()
                    .bucket(bucketName)
                    .key(s3Key)
                    .contentType(contentType)
                    .contentLength(fileSize)
                    .build()

                val response = s3Client.putObject(
                    putObjectRequest,
                    RequestBody.fromInputStream(file.inputStream, fileSize)
                )

                logger.info { "response: ${response}" }
                s3UploadSuccessful = true

                val createdId = profileImageMetaRepository.saveProfileImageMetadata(
                    ProfileImageMetadata.of(
                        userType = meta.userType,
                        userId = meta.userId,
                        profileImageType = meta.profileImageType,
                        originalFileName = originalFileName,
                        contentType = contentType,
                        size = fileSize,
                        bucketName = bucketName,
                        s3Key = s3Key
                    )
                )

                SaveFileResult.of(
                    id = createdId,
                    s3Key = s3Key,
                    bucketName = bucketName,
                    contentType = contentType,
                    size = fileSize,
                    originalFileName = originalFileName
                )
            } catch (e: S3Exception) {
              throw S3UploadException(
                  logics = "ProfileImageService.saveProfileImage",
                  message = e.message?: "S3 file uploading failed"
                  )
            } catch (e: Exception) {
                // Rollback: Delete S3 object if it was successfully uploaded
                if (s3UploadSuccessful) {
                    try {
                        val deleteObjectRequest = DeleteObjectRequest.builder()
                            .bucket(bucketName)
                            .key(s3Key)
                            .build()

                        s3Client.deleteObject(deleteObjectRequest)
                        looger.info { "Successfully deleted S3 object during rollback: $s3Key" }
                    } catch (deleteException: Exception) {
                        looger.error(deleteException) {
                            "Failed to delete S3 object during rollback: $s3Key"
                        }
                    }
                }
                throw RuntimeException("Failed to save profile image: ${e.message}", e)
            }
        }
    }

참고사항

📌 size, orfinalFileName은 MultipartFile내에서

📌 content-type은 tike 라이브러리를 사용하여 서버에서 직접 추출

📌 s3 업로드 및 메타데이터 함께 업로드

​

🚀 관리 편의성을 위해서 하나라도 실패하면 저장을 rollback할 로직을 추가함

  • Exception도 종류별로 최대한 나눠서 처리해주면 좋다.

​

이미지 저장 테스트

이미지

이미지

S3에 성공적으로 데이터가 들어갑니다.

​

이미지 가져오기

S3 Bucket에는 두 유형의 버킷이 존재합니다.

  • Public Bucket: 모든 사람이 접근가능
  • Private Bucket: 허가된 사람만 접근가능

​

프로필 이미지의 경우 로그인하지 않은 유저도 볼 수 있는 것이 일반적입니다. 그래서 처음에는 Public을 생각했습니다.

​

하지만, AWS는 outbound 트래픽을 부과하기 때문에, S3 퍼블릭 주소가 유출된다면, 공격대상이 되어 요금 폭탄을 맞을 수 도 있을 것 같았습니다.

​

그래서 그냥 서버에서만 접근 가능하도록 Private로 버킷을 두고, presigned URL을 발급하는 방법을 사용했습니다.

​

서비스 코드입니다.

kotlin 코드 예제
                                    fun getProfileImage(
        userId: Long,
        userType: UserType
    ): List<ProfileImageMetadataWithUrl> {
        return transaction {
            // 1. Find bucket-key from profile-image-metadata by userId and userType

            val profileImageMetaDataEntities: List<ProfileImageMetadataEntity> =
                profileImageMetaRepository.findProfileImageMetaDataByUserInfo(userId, userType)

            // 2. Make S3 presigned URL request using the key
            profileImageMetaDataEntities.map { entity ->
                val getObjectRequest = GetObjectRequest.builder()
                    .bucket(entity.bucketName)
                    .key(entity.s3Key)
                    .build()

                val presignRequest = GetObjectPresignRequest.builder()
                    .signatureDuration(Duration.ofMinutes(15)) // URL expires in 15 minutes
                    .getObjectRequest(getObjectRequest)
                    .build()

                val presignedUrl = s3Presigner.presignGetObject(presignRequest).url().toString()
                ProfileImageMetadataWithUrl.of(
                    presignedUrl = presignedUrl,
                    bucketName = entity.bucketName,
                    s3Key = entity.s3Key,
                    contentType = entity.contentType,
                    size = entity.size,
                    originalFileName = entity.originalFileName
                )
            }
        }
    }
  • 메타 데이터에 저장된 S3\_key를 사용하여 presignedURL 발급이 가능합니다.
  • 이를 사용하면 지정 시간동안 어떤 유저든 해당 이미지로 접근이 가능합니다.

이미지

  • presigned url이 발급되었습니다.

이미지

  • 웹브라우저에서 버킷에 저장된 이미지에 접근이 가능합니다.

trouble Shooting

🤔 enum 클래스로 직접 Table로 매핑하고 있는데 왜 Int로 들어갈까?

  • enum에 val code: Int를 선언하면 자동으로 Int로 변환되어서 들어간다.
kotlin 코드 예제
                                    object ProfileImageMetadataTable : BaseDateLongIdTable("profile_image_metadata") {
    val imageType: Column<ProfileImageType> = enumeration("image_type", ProfileImageType::class)
}

enum class ProfileImageType(val code: Int) {
    BACKGROUND(1),
    PROFILE(2);

    companion object {

        private val valueToCodeMap: Map<String, Int> = entries.associate { it.name to it.code}
        private val codeToEnumMap: Map<Int, ProfileImageType> = entries.associateBy { it.code }

        fun getByCode(code: Int): ProfileImageType? {
            return codeToEnumMap[code]
        }

        fun getCodeByValue(value: String): Int? {
            return valueToCodeMap[value]
        }
    }
}

ProfileImageType을 사용해서 BACKGROUND 형태로 들어갈 것 같지만, Int로 자동변환해서 들어간다.

👉 enumerationByName()을 사용하면 Varchar로 들어간다!