[Spring Boot] AWS S3 bucket을 이용한 image upload - 실습 (2)

Hood·2025년 8월 18일
0

Spring Boot

목록 보기
14/14
post-thumbnail

✍ Back-End 지식을 늘리자!

백엔드 개발자를 준비하며 생기는 의문들을 정리한 포스트입니다.


들어가기 전

이전 포스트에서 AWS에 접속해 S3 버킷을 생성하는 방법에 대해 알아보았습니다.
이번 포스트에서는 Spring Boot 프로젝트에서 AWS S3를 Bean으로 등록하고
어떻게 이미지를 업로드할 수 있는지에 대해 알아보고 실습해볼 생각입니다.


1. Spring Boot Project File 접속

현재 A&I 6주차까지 완료되었다면
AandI_3rdPeriod_code_lab week6 까지 완료되었을 것입니다.
여기서부터 S3를 적용해보도록 합니다.

의존성 추가

AWS S3를 Spring 프로젝트에서 적용하기 위해서는 다음과 같은 라이브러리가 필요합니다.
추가한 뒤 Gradle이 해당 라이브러리를 가져올 수 있도록 재설정해줍니다.

implementation("com.amazonaws:aws-java-sdk-s3:1.12.788") // AWS S3 SDK

yml 환경변수 추가

S3에 제대로 접속할 수 있도록 다음과 같은 환경변수를 추가해주도록 합시다.

cloud:
  aws:
    s3:
      bucket: ${S3_BUCKET}
    credentials:
      access-key: ${IAM_ACCESS_KEY}
      secret-key: ${IAM_SECRET_KEY}
    stack:
      auto: false
    region:
      static: ${AWS_REGION}

여기서 환경변수 안에 값을 넣게 되면 github에 본인의 AWS IAM 계정이 탈취당할 수 있으니
.env 파일 안으로 모두 값을 숨겨주도록 합니다.

#.env
IAM_ACCESS_KEY=
IAM_SECRET_KEY=
#서울이면 ap-northeast-2
AWS_REGION=
#본인이 지정한 S3 Bucket 이름
S3_BUCKET=

이후 .env 파일안에 다음 값을 추가해주도록 합니다.

2. aws @bean 등록

Bean은 쉽게 설명해 스프링 IoC(Inversion of Control) 컨테이너가 관리하는 자바 객체입니다. 즉, 개발자가 직접 new 키워드를 사용해 객체를 생성하고 관리하는 대신, 스프링 컨테이너가 객체의 생성, 생명주기, 그리고 의존성 관리를 대신 처리해 주는 것입니다.

우리는 aws의 외부 라이브러리를 등록해야하기 때문에 S3config 클래스 파일을 생성해
다음과 같이 입력해줍니다. 이 때 가져오는 어노테이션을 주의해주도록 합시다!

import com.amazonaws.auth.AWSStaticCredentialsProvider
import com.amazonaws.auth.BasicAWSCredentials
import com.amazonaws.services.s3.AmazonS3
import com.amazonaws.services.s3.AmazonS3ClientBuilder
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration

@Configuration
class S3config (
  @Value("\${cloud.aws.credentials.access-key}")
    private val accessKey : String,
  @Value("\${cloud.aws.credentials.secret-key}")
    private val secretKey : String,
  @Value("\${cloud.aws.region.static}")
    private val region : String,
) {
    @Bean
    fun amazonS3Client() : AmazonS3 {
        val credentials = BasicAWSCredentials(accessKey, secretKey)
        return AmazonS3ClientBuilder
            .standard()
            .withRegion(region)
            .withCredentials(AWSStaticCredentialsProvider(credentials))
            .build()
    }
}

3. aws 파일 생성 후 서비스 로직 작성

bean 등록이 완료되었다면 프로젝트 파일 아래 controller와 dto, service 폴더를 만든 뒤
각각의 파일을 생성해주도록 합니다.

Dto

data class S3RequestDto (
    var imageFileName: String,
)

Service

import com.amazonaws.services.s3.AmazonS3
import com.amazonaws.services.s3.model.AmazonS3Exception
import com.amazonaws.services.s3.model.ObjectMetadata
import com.amazonaws.services.s3.model.PutObjectRequest
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import org.springframework.web.multipart.MultipartFile
import java.util.UUID

@Service
class S3Service (
    private val amazonS3: AmazonS3
) {
    @Value("\${cloud.aws.s3.bucket}")
    private lateinit var bucketName: String

    fun uploadImageS3(image: MultipartFile): String {
        if (image.isEmpty || image.originalFilename == null) {
            throw AmazonS3Exception("파일이 비었습니다!")
        }

        val originalFilename = image.originalFilename!!
        val extension = originalFilename.substringAfterLast('.', "")
        if (extension.isBlank()) {
            throw AmazonS3Exception("파일 확장자가 없습니다.")
        }

        val s3FileName = "${UUID.randomUUID()}.$extension"
        val inputStream = image.inputStream

        val metadata = ObjectMetadata().apply {
            this.contentType = image.contentType
            this.contentLength = image.size
        }

        if (!::bucketName.isInitialized || bucketName.isBlank()) {
            throw AmazonS3Exception("버킷 이름이 설정되지 않았습니다.")
        }

        try {
            val putObjectRequest = PutObjectRequest(bucketName, "image/$s3FileName", inputStream, metadata)
            amazonS3.putObject(putObjectRequest)
        } catch (e: Exception) {
            // 예외 로그 출력
            println("S3 업로드 에러: ${e.message}")
            throw AmazonS3Exception("이미지 업로드 에러가 발생했습니다.", e)
        } finally {
            inputStream.close()
        }

        return amazonS3.getUrl(bucketName, "image/$s3FileName").toString()
    }

    fun uploadImage(image: MultipartFile): String {
        return uploadImageS3(image)
    }

    fun uploadImages(images: List<MultipartFile>): List<String> {
        val imageUrls = mutableListOf<String>()
        for (image in images) {
            val imageUrl = uploadImageS3(image)
            imageUrls.add(imageUrl)
        }
        return imageUrls
    }

    fun deleteImage(keyOrUrl: String): String {
        if (!::bucketName.isInitialized || bucketName.isBlank()) {
            throw AmazonS3Exception("버킷 이름이 설정되지 않았습니다.")
        }

        val key = if (keyOrUrl.startsWith("http")) {
            keyOrUrl.substringAfter(".amazonaws.com/")
        } else {
            keyOrUrl
        }

        amazonS3.deleteObject(bucketName, key)
        return "이미지가 삭제되었습니다."
    }
}

핵심 부분만을 설명만 주석을 달아보면

fun uploadImageS3(image: MultipartFile): String {
        // 1. 파일 유효성 검사 (File Validation)
        // 비어있는 파일이나 확장자가 없는 파일은 업로드하지 않도록 확인합니다.
        if (image.isEmpty || image.originalFilename == null) {
            throw AmazonS3Exception("파일이 비었습니다!")
        }

        val originalFilename = image.originalFilename!!
        val extension = originalFilename.substringAfterLast('.', "")
        if (extension.isBlank()) {
            throw AmazonS3Exception("파일 확장자가 없습니다.")
        }

        // 2. 고유한 파일명 생성 (Generating a Unique File Name)
        // 기존 파일명과의 충돌을 피하기 위해 UUID를 사용하여 고유한 이름을 생성합니다.
        val s3FileName = "${UUID.randomUUID()}.$extension"
        val inputStream = image.inputStream

        // 3. 파일 메타데이터 설정 (Setting File Metadata)
        // S3에 저장될 파일의 유형과 크기 정보를 설정합니다.
        val metadata = ObjectMetadata().apply {
            this.contentType = image.contentType
            this.contentLength = image.size
        }

        if (!::bucketName.isInitialized || bucketName.isBlank()) {
            throw AmazonS3Exception("버킷 이름이 설정되지 않았습니다.")
        }

        try {
            // 4. S3 버킷에 업로드 (Uploading to the S3 Bucket)
            // PutObjectRequest를 생성하여 버킷 이름, 파일 경로, 입력 스트림, 메타데이터를 담아 S3에 파일을 전송합니다.
            val putObjectRequest = PutObjectRequest(bucketName, "image/$s3FileName", inputStream, metadata)
            amazonS3.putObject(putObjectRequest)
        } catch (e: Exception) {
            println("S3 업로드 에러: ${e.message}")
            throw AmazonS3Exception("이미지 업로드 에러가 발생했습니다.", e)
        } finally {
            inputStream.close()
        }

        // 5. 업로드된 파일 URL 반환 (Returning the Uploaded File URL)
        // 업로드가 성공하면, 해당 파일에 접근할 수 있는 URL을 반환하여 클라이언트에서 사용할 수 있도록 합니다.
        return amazonS3.getUrl(bucketName, "image/$s3FileName").toString()
    }

controller

import com.example.spring_security.aws.dto.S3RequestDto
import com.example.spring_security.aws.service.S3Service
import com.example.spring_security.common.dto.BaseResponse
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.Parameter
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
import org.springframework.web.multipart.MultipartFile

@RestController
@RequestMapping("/api/aws/s3")
class S3Controller(
    private val s3Service: S3Service
) {

    @Operation(
        summary = "단일 이미지 업로드",
        description = "단일 이미지를 AWS S3에 업로드하고, 업로드된 이미지 URL을 반환합니다."
    )
    @PostMapping("/imageUpload", consumes = ["multipart/form-data"])
    fun imageUpload(
        @Parameter(description = "업로드할 이미지 파일", required = true)
        @RequestPart(value = "image", required = true) image: MultipartFile
    ): ResponseEntity<BaseResponse<String>> {
        val result = s3Service.uploadImage(image)
        return ResponseEntity.status(HttpStatus.CREATED)
            .body(BaseResponse(data = result))
    }

    @Operation(
        summary = "다중 이미지 업로드",
        description = "여러 이미지를 AWS S3에 업로드하고, 업로드된 이미지 URL 리스트를 반환합니다."
    )
    @PostMapping("/imagesUpload", consumes = ["multipart/form-data"])
    fun imagesUpload(
        @Parameter(description = "업로드할 이미지 파일 리스트", required = true)
        @RequestPart(value = "image", required = true) images: List<MultipartFile>
    ): ResponseEntity<BaseResponse<List<String>>> {
        val result = s3Service.uploadImages(images)
        return ResponseEntity.status(HttpStatus.CREATED)
            .body(BaseResponse(data = result))
    }

    @Operation(
        summary = "이미지 삭제",
        description = "S3에 저장된 이미지를 파일명으로 삭제합니다."
    )
    @DeleteMapping("/delete")
    fun deleteImage(
        @RequestBody s3RequestDto: S3RequestDto
    ): ResponseEntity<BaseResponse<String>> {
        val result = s3Service.deleteImage(s3RequestDto.imageFileName)
        return ResponseEntity.status(HttpStatus.OK).body(BaseResponse(data = result))
    }
}

다음과 같이 로직을 작성해줍니다.
여기서 서비스 로직의 AmazonS3Exception를 이용하여 예외처리를 해주었으니
[common]-[exception] 에 들어가 다음과 같은 Exception Handing을 추가해주도록 합시다.

private val logger = LoggerFactory.getLogger(CommonExceptionHandler::class.java)

@ExceptionHandler(AmazonS3Exception::class)
    protected fun amazonS3ExceptionHandler(exception: AmazonS3Exception):
            ResponseEntity<BaseResponse<String>> {
        logger.error("AWS S3 처리 중 예외 발생", exception)
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(
            BaseResponse(
                status = ResultStatus.ERROR.name,
                data = null,
                resultMsg = exception.message ?: "AWS S3 처리 중 에러가 발생했습니다."
            )
        )
    }

그리고 로그인 기능을 추가하면서 모든 api 권한이 MEMBER로 되어 있을테니
SecurityConfig 파일에 들어가서 다음 api에 대해 모든 사용자 접근을 허용하겠다는
코드 한줄도 추가해주도록 합시다.

// 권한 설정
.authorizeHttpRequests{
    it.requestMatchers("/api/member/join", "/api/member/login",
    "/api/member/reset-password-code", "/api/member/reset-password/request").anonymous()
    //다음 api 추가
    .requestMatchers("/api/aws/s3/**").permitAll()
    .requestMatchers("/api/**").hasRole("MEMBER")
    .anyRequest().permitAll()
}

이렇게 까지 하면 오늘 이미지 업로드 코드는 모두 작성되었습니다.
한 번 확인해보록 합시다!


실습 확인

1. 단일 업로드 파일

프로젝트를 실행하여 로컬 Swagger 접속하여 이미지를 올려보겠습니다.

그렇다면 해당 파일이 업로드 된 뒤에 AWS S3 버킷 안의 Url을 반환해줄 것입니다.
(만약, 에러가 발생하였다면 .env 파일 안에 엑세스키나 오타가 있는지 확인해봅시다!)

url에 접속해보면

잘 올라온 모습입니다.

2. 다중 업로드 파일

이는 서비스 로직을 보면 파악할 수 있는데
단일 업로드를 mutableList에 url을 더 추가해주는 식으로 구현되어있어
넘어가도록 하겠습니다.

3. 업로드 파일 삭제

마지막으로 업로드된 url을 삭제 api에 넣어주면 알아서 S3 버킷에서 삭제될 것입니다.

삭제 되니 접근 권한이 없어진 모습입니다.


📌 결론

이번 주제는 S3 버킷을 이용한 이미지 업로드 서비스 로직을 작성해보았습니다.
다음 서비스 로직을 이용하여 요구사항에 맞는 나만의 커스텀 이미지 업로드 로직을 작성해봅시다!

[Spring Boot]AWS S3를 이용한 이미지 업로드 기능 구현

profile
달을 향해 쏴라, 빗나가도 별이 될 테니 👊

0개의 댓글