
백엔드 개발자를 준비하면서 생긴 궁금증을 정리한 포스트입니다.
이전 포스트에서는 AWS에 접속해 S3 버킷을 생성하는 방법을 정리했습니다.
이번 포스트에서는 Spring Boot 프로젝트에서 AWS S3를 Bean으로 등록하고,
실제로 이미지를 업로드하는 방법을 정리해보겠습니다.
현재 A&I 6주차까지 진행되었다면
AandI_3rdPeriod_code_lab week6
상태까지 완료되어 있을 것입니다.
이제 여기서부터 S3를 프로젝트에 적용해보겠습니다.
Spring 프로젝트에서 AWS S3를 사용하려면 관련 라이브러리를 추가해야 합니다.
implementation("com.amazonaws:aws-java-sdk-s3:1.12.788")
이 의존성은 AWS SDK for Java 1.x 기반입니다.
다만 AWS는 Java SDK 1.x가 2025년 12월 31일에 end-of-support에 도달했다고 안내하고 있으므로,
새로 시작하는 프로젝트라면 AWS SDK for Java 2.x를 사용하는 것이 더 권장됩니다.
이번 글에서는 기존 실습 흐름을 유지하기 위해 1.x 예제를 그대로 사용하겠습니다. (AWS Documentation)
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}
그리고 실제 값은 외부 설정으로 분리해 관리하는 것이 좋습니다.
IAM_ACCESS_KEY=
IAM_SECRET_KEY=
AWS_REGION=
S3_BUCKET=
여기서 한 가지 주의할 점이 있습니다.
Spring Boot는 공식적으로 application.properties, application.yml, 환경변수, 명령줄 인자 같은 외부 설정 방식을 지원합니다.
하지만 .env 파일 자체를 기본으로 자동 로드한다고 보기는 어렵기 때문에,
로컬에서는 IDE 환경변수 설정이나 운영체제 환경변수, 혹은 별도 라이브러리를 통해 불러오는 방식을 고려하는 것이 좋습니다.
즉, .env를 사용하더라도 “Spring Boot가 기본으로 읽는다”기보다 “외부 설정값을 주입하는 한 방법”으로 이해하는 편이 더 정확합니다. (Home)
Spring에서 Bean은 스프링 컨테이너가 생성하고 관리하는 객체입니다.
즉, 개발자가 직접 객체를 생성해 관리하기보다
스프링이 객체 생성과 생명주기, 의존성 주입을 담당하도록 맡기는 방식입니다.
AWS S3 클라이언트도 외부 라이브러리이기 때문에
직접 Bean으로 등록해서 프로젝트 전역에서 사용할 수 있도록 설정합니다.
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()
}
}
이 설정이 끝나면 AmazonS3 객체를 다른 서비스 클래스에서 주입받아 사용할 수 있습니다.

Bean 등록이 끝났다면
프로젝트 아래에 controller, dto, service 패키지를 만들고
각각 관련 클래스를 생성합니다.
data class S3RequestDto(
var imageFileName: String,
)
삭제 요청처럼 간단한 데이터 전달이 필요할 때 사용할 DTO입니다.
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 "이미지가 삭제되었습니다."
}
}
이 서비스의 핵심 흐름은 다음과 같습니다.
ObjectMetadata를 사용해 콘텐츠 타입과 길이를 함께 저장합니다.PutObjectRequest로 S3 버킷에 파일을 업로드합니다.즉, 파일 유효성 검사 → 고유 파일명 생성 → 메타데이터 설정 → 업로드 → URL 반환
순서로 동작한다고 이해하면 됩니다.
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에 저장된 이미지를 파일명 또는 URL로 삭제합니다."
)
@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))
}
}
위 컨트롤러는 다음 세 가지 기능을 제공합니다.
즉, 클라이언트는 multipart/form-data 형식으로 이미지를 보내고,
서버는 이를 S3에 저장한 뒤 URL을 응답으로 반환하게 됩니다.
서비스 로직에서 AmazonS3Exception을 사용해 예외를 던졌으므로,
공통 예외 처리기에도 관련 핸들러를 추가해주는 것이 좋습니다.
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 처리 중 에러가 발생했습니다."
)
)
}
이렇게 하면 S3 업로드나 삭제 중 문제가 생겼을 때
클라이언트에게 일관된 형태의 에러 응답을 내려줄 수 있습니다.
로그인 기능이 이미 적용된 프로젝트라면
기본적으로 대부분의 API에 권한 검사가 걸려 있을 수 있습니다.
이 경우 S3 업로드 테스트를 위해 해당 경로를 공개 엔드포인트로 열어줄 수 있습니다.
.authorizeHttpRequests {
it.requestMatchers(
"/api/member/join",
"/api/member/login",
"/api/member/reset-password-code",
"/api/member/reset-password/request"
).anonymous()
.requestMatchers("/api/aws/s3/**").permitAll()
.requestMatchers("/api/**").hasRole("MEMBER")
.anyRequest().permitAll()
}
여기서 permitAll()은 해당 경로를 인증 없이 접근 가능한 공개 엔드포인트로 만든다는 뜻입니다.
Spring Security 문서에서도 permitAll을 “권한이 필요 없는 public endpoint”로 설명합니다.
다만 실제 운영 환경에서는 업로드 API를 완전히 공개할지, 인증 사용자만 허용할지는 요구사항에 따라 신중히 결정해야 합니다. (Home)
프로젝트를 실행한 뒤 Swagger에 접속해 이미지를 업로드해봅니다.

업로드가 성공하면 S3 버킷 안에 저장된 파일의 URL이 반환됩니다.

이 URL로 접속했을 때 실제 이미지가 정상적으로 보인다면 업로드에 성공한 것입니다.


만약 업로드에 실패했다면,
버킷 이름, 리전, 액세스 키, 시크릿 키, 그리고 실제 환경 변수 주입 방식까지 함께 다시 확인해보는 것이 좋습니다.
다중 이미지 업로드는 내부적으로 단일 업로드 로직을 반복 호출해 구현되어 있습니다.
즉, 여러 개의 MultipartFile을 리스트로 받아
하나씩 S3에 업로드한 뒤 URL 목록을 반환하는 방식입니다.
구조 자체는 단순하지만, 업로드 수가 많아질수록 예외 처리와 성능을 함께 고려할 필요가 있습니다.
마지막으로 업로드된 URL 또는 파일 키를 삭제 API에 전달하면
S3 버킷에서 해당 파일을 삭제할 수 있습니다.



삭제가 정상적으로 완료되면 더 이상 해당 객체에 접근할 수 없게 됩니다.
이번 포스트에서는 Spring Boot 프로젝트에서 AWS S3를 Bean으로 등록하고,
이미지 업로드 및 삭제 기능을 구현하는 흐름을 정리해보았습니다.
정리해보면 다음과 같습니다.
AmazonS3 클라이언트를 Bean으로 등록합니다.즉, S3 연동은 단순히 파일을 저장하는 기능을 넘어서
설정 관리, 보안, 예외 처리까지 함께 고려해야 하는 기능이라고 볼 수 있습니다.
참고로 이 글의 예제는 AWS SDK for Java 1.x를 사용하지만,
AWS는 1.x가 2025년 12월 31일 end-of-support에 도달했다고 안내하고 있습니다.
따라서 새 프로젝트라면 가능하면 AWS SDK for Java 2.x를 고려하는 것이 좋습니다. (AWS Documentation)