예제 소스코드입니다.
https://github.com/wjdekdns1028/spring-s3-image-upload
우리가 일상에서 쓰는 서비스들 중
사용자 입장에서는 그냥 사진 올리기 버튼이지만 백엔드 입장에서는 이런 일이 일어납니다
사용자 기기 안에 있는 이미지 파일
↓
네트워크를 통해 서버로 전송
↓
서버가 파일을 어딘가에 저장
↓
나중에 꺼내 쓸 수 있도록 URL로 관리
서버에 이미지를 직접 저장하면 안되냐고 생각할 수 있어요
하지만 서버에 직접 저장하면 생기는 문제가 있답니다
따라서 S3를 쓰면
쉽게 말해 폴더입니다
이미지들을 담을 최상위 저장 공간이에요
버킷 이름은 전 세계에서 유일해야 한답니다
버킷 안에 저장된 파일 하나하나를 오브젝트라고 합니다
이미지, 영상, 문서 등 모든 파일이 오브젝트에요
오브젝트의 경로 + 파일명이에요
예) images/profile/uuid-123.jpg이 Key로 파일을 식별합니다
① 클라이언트가 이미지를 서버로 전송
↓
② 서버가 파일을 받아서 S3에 업로드
↓
③ S3가 저장 후 접근 가능한 URL 생성
↓
④ 서버가 URL을 DB에 저장하고 클라이언트에 응답
클라이언트는 이미지를 직접 가지고 다니는 게 아니라 URL만 저장하고 필요할 때 URL로 접근하는 구조랍니다

s3 검색하고 들어가 줍니다

버킷 만들기 버튼을 눌러줍니다

버킷 이름을 likelion-이름 으로 만들어 줍니다

퍼플릭 액세스 차단을 모두 해제해주고 동의에 체크해 줍니다

나머지는 건들지 말고 버킷 만들기를 클릭합니다

만든 버킷의 권한 섹터로 들어와 버킷 정책의 편집을 클릭해 줍니다

들어와서 새 문 추가를 클릭해 줍니다

서비스 선택에 S3를 검색하고 제일 위에 것을 클릭합니다

클릭 후 검색으로 GetObject를 검색하고 맨 위에 것을 선택합니다

그 다음 리소스 추가를 눌러줍니다

리소스 유형을 object로 선택 후 {본인 버킷명}/* 으로 설정해 줍니다
(모든 오브젝트에 접근 가능)

Principal을 사진과 같이 수정해 줍니다

그 다음 변경 사항 저장을 해주세요
S3는 아무나 접근하면 안되겠죠! 그래서 AWS는 너 누구야? 를 확인합니다
IAM (Identity and Access Management)
: AWS 서비스에 접근할 수 있는 신분증을 만들어주느 곳이에요
Spring 서버가 S3에 업로드하려면 IAM에서 발급한 신분증이 있어야 해요
액세스 키, 시크릿 키
: IAM에서 발급하는 아이디, 비밀번호 같은 거에요
Spring boot는 이 키를 가지고 S3에 접근해요

다시 검색창에 iam을 검색해서 들어가 줍니다

왼쪽에 액세스 관리 아래 사용자에 들어가서 사용자 생성을 클릭해 줍니다

사용자 이름을 likelion-이름 으로 지정하고 다음을 눌러줍니다

직접 정책 연결을 클릭한 뒤 권한 정책의 S3Full을 검색해서 체크한 뒤 다음으로 넘어가 주세요

그 다음 건들지 말고 사용자 생성을 해주세요

만든 사용자를 클릭한 뒤

보안 자격 증명에 들어가 액세스 키 만들기를 클릭해 줍니다

AWS 외부에서 실행되는 애플리케이션을 체크한 뒤 다음을 눌러줍니다

다음은 건들지 말고 액세스 키 만들기를 클릭해 줍니다

그럼 액세스 키와 비밀 액세스 키가 나오는데 꼭꼭 메모장에 잘 저장해주세요~!!
(추가된 코드를 구별하기 어려우신 경우는 이 링크에서 보시면 됩니다)
plugins {
id 'java'
id 'org.springframework.boot' version '3.5.13'
id 'io.spring.dependency-management' version '1.1.7'
}
group = 'com.likelion'
version = '0.0.1-SNAPSHOT'
description = 'likelion-crud'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
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.mysql:mysql-connector-j'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
//swagger세팅
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.12'
}
dependencyManagement {
imports {
mavenBom "org.springframework.cloud:spring-cloud-dependencies:2021.0.8"
}
}
tasks.named('test') {
useJUnitPlatform()
}
(dependencyManagement를 선언하면 dependencies 블록에서 버전을 따로 안 써도 BOM이 알아서 맞는 버전을 골라줌)
spring:
datasource:
url: jdbc:mysql://localhost:3306/likelion # 연결할 MySQL 데이터베이스의 주소
username: root # 본인 MySQL 사용자 이름
password: mysql0!! # 본인 MySQL 비밀번호
driver-class-name: com.mysql.cj.jdbc.Driver # MySQL 드라이버 클래스
jpa:
hibernate:
ddl-auto: create # 테이블 자동 생성 (create, update, validate, none 중 택 1)
show-sql: true # 실행되는 SQL을 콘솔에 출력
properties:
hibernate:
format_sql: true # SQL 쿼리를 보기 좋게 출력 (정렬됨)
logging:
level:
org.hibernate.SQL: debug # 실행되는 SQL 로그 출력
org.hibernate.type.descriptor.sql: trace # 바인딩된 파라미터 값 로그 출력
cloud:
aws:
credentials:
access-key: ${AWS_ACCESS_KEY}
secret-key: ${AWS_SECRET_KEY}
s3:
bucket: ${S3_BUCKET_NAME}
region:
static: ap-northeast-2 # 서울 리전
stack:
auto: false # CloudFormation 스택 자동감지 비활성화
아까 발급받은 키들과 버킷명을 작성해 주세요
| application.yml | S3Config | S3Uploader |
|---|---|---|
| access-key: ... | AmazonS3 빈 생성 | amazonS3.putObject() |
| secret-key: ... | (인증 정보 주입) | (실제 업로드) |
| region: ... |
package com.likelion.likelioncrud.common.config;
import com.amazonaws.auth.AWSCredentials;
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 // "나는 설정 클래스야" → Spring이 앱 시작 시 가장 먼저 읽음
public class S3Config {
@Value("${cloud.aws.credentials.access-key}")
private String accessKey; // application.yml에서 값 꺼내서 여기에 넣어줌
@Value("${cloud.aws.credentials.secret-key}")
private String secretKey;
@Value("${cloud.aws.region.static}")
private String region; // 실제 값: ap-northeast-2
@Bean // "이 메서드가 반환하는 객체를 Spring이 관리해줘"
public AmazonS3 amazonS3() {
// 1단계: 아이디/비밀번호 묶기
AWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey);
// 2단계: 인증 정보 + 리전으로 S3 클라이언트 생성
return AmazonS3ClientBuilder.standard()
.withCredentials(new AWSStaticCredentialsProvider(credentials))
.withRegion(region)
.build(); // 완성된 S3 클라이언트 반환
}
}
S3Uploader.upload() 호출
↓
① 파일명 생성 (UUID + 원본파일명)
② 메타데이터 생성 (파일크기, 타입)
③ S3에 업로드
④ 업로드된 URL 반환
↓
사용자에게 URL 응답
UUID : 세상에서 단 하나뿐인 랜덤 문자열을 만들어주는 도구
// UUID 없을 때
홍길동 → "고양이.jpg" 업로드
김철수 → "고양이.jpg" 업로드 ← 홍길동 파일 덮어써짐!
// UUID 있을 때
홍길동 → "a1b2c3고양이.jpg"
김철수 → "d4e5f6고양이.jpg" ← 둘 다 안전하게 저장
package com.likelion.likelioncrud.image;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.model.ObjectMetadata;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.UUID;
@Service
@RequiredArgsConstructor // final 필드 생성자 자동 생성 (Lombok)
public class S3Uploader {
private final AmazonS3 amazonS3; // S3Config에서 만든 빈 자동 주입
@Value("${cloud.aws.s3.bucket}")
private String bucket; // application.yml에서 버킷명 주입
public String upload(MultipartFile file) throws IOException {
// ① 중복 방지 파일명 생성
String fileName = UUID.randomUUID() + "_" + file.getOriginalFilename();
// ② S3에 전달할 파일 정보 세팅
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentLength(file.getSize());
metadata.setContentType(file.getContentType());
// ③ 실제 업로드
amazonS3.putObject(bucket, fileName, file.getInputStream(), metadata);
// ④ 업로드된 파일 URL 반환
return amazonS3.getUrl(bucket, fileName).toString();
}
}
package com.likelion.likelioncrud.common.response.code;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import org.springframework.http.HttpStatus;
@Getter // getter 메소드 자동 생성 lombok 어노테이션
@AllArgsConstructor(access = AccessLevel.PRIVATE) // 모든 필드를 파라미터로 받는 생성자 자동 생성 어노테이션
public enum ErrorCode {
/**
* 404 NOT FOUND (찾을 수 없음)
*/
MEMBER_NOT_FOUND_EXCEPTION(HttpStatus.NOT_FOUND, "해당 사용자가 없습니다. memberId = "),
POST_NOT_FOUND_EXCEPTION(HttpStatus.NOT_FOUND, "해당 게시글이 없습니다. postId = "),
/**
* 400 BAD REQUEST
*/
VALIDATION_EXCEPTION(HttpStatus.BAD_REQUEST, "유효성 검사에 실패하였습니다 - "),
/**
* 500 INTERNAL SERVER ERROR (내부 서버 에러)
*/
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "내부 서버 에러가 발생했습니다"),
// S3 이미지 업로드 에러
FILE_UPLOAD_FAIL_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "파일 업로드에 실패했습니다.");
private final HttpStatus httpStatus; // HTTP 상태 코드를 스프링에서 쉽게 작성하기 위한 enum값들의 모임
private final String message; // 에러 메세지
public int getHttpStatusCode() { // HTTP 상태 코드에서 404와 같은 숫자 값만 반환해 주기 위한 메소드
return httpStatus.value();
}
}
package com.likelion.likelioncrud.post.domain;
import com.likelion.likelioncrud.member.domain.Member;
import com.likelion.likelioncrud.post.api.dto.request.PostUpdateRequestDto;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "post_id")
private Long postId;
private String title;
private String contents;
// S3에 업로드된 이미지의 URL을 저장, 이미지가 없을 경우 null 허용
private String imageUrl;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
@Builder
private Post(String title, String contents, String imageUrl, Member member) {
this.title = title;
this.contents = contents;
this.imageUrl = imageUrl; // 이미지 없이 글만 작성할 경우 null로 저장됨
this.member = member;
}
public void update(PostUpdateRequestDto postUpdateRequestDto) {
this.title = postUpdateRequestDto.title();
this.contents = postUpdateRequestDto.contents();
}
}
package com.likelion.likelioncrud.post.api.dto.response;
import com.likelion.likelioncrud.post.domain.Post;
import lombok.Builder;
@Builder
public record PostInfoResponseDto(
String title,
String contents,
String writer,
String imageUrl // S3에 업로드된 이미지 URL, 이미지 없으면 null 반환
) {
public static PostInfoResponseDto from(Post post) {
return PostInfoResponseDto.builder()
.title(post.getTitle())
.contents(post.getContents())
.writer(post.getMember().getName())
.imageUrl(post.getImageUrl()) // 이미지 없이 저장된 게시글이면 null
.build();
}
}
게시글 텍스트만 보낼 때는 아래와 같은 JSON으로 충분합니다
{
"title": "제목",
"contents": "내용"
}
근데 파일을 보내려면? 파일은 텍스트가 아니라 바이트데이터(2진수의 나열)이라 JSON에 담을 수 없어요
그래서 multipart/form-data를 사용합니다(여러 데이터를 경계선으로 구분해서 한 요청에 같이 보내는 방식)
--boundary
Content-Disposition: form-data; name="data"
{ "title": "제목", "contents": "내용" }
--boundary
Content-Disposition: form-data; name="image"; filename="고양이.jpg"
Content-Type: image/jpeg
(이미지 바이트 데이터.....)
--boundary--
MultipartFile이란 :
HTTP로 전송된 파일 데이터를 Spring이 자바 객체로 변환해서 우리가 쓸 수 있게 해주는 인터페이스입니다
package com.likelion.likelioncrud.post.application;
import com.likelion.likelioncrud.common.exception.BusinessException;
import com.likelion.likelioncrud.common.response.code.ErrorCode;
import com.likelion.likelioncrud.image.S3Uploader;
import com.likelion.likelioncrud.member.domain.Member;
import com.likelion.likelioncrud.member.domain.repository.MemberRepository;
import com.likelion.likelioncrud.post.api.dto.request.PostSaveRequestDto;
import com.likelion.likelioncrud.post.api.dto.request.PostUpdateRequestDto;
import com.likelion.likelioncrud.post.api.dto.response.PostInfoResponseDto;
import com.likelion.likelioncrud.post.domain.Post;
import com.likelion.likelioncrud.post.domain.repository.PostRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class PostService {
private final MemberRepository memberRepository;
private final PostRepository postRepository;
private final S3Uploader s3Uploader; // S3 이미지 업로드를 위해 주입
// 게시물 저장
@Transactional
public void postSave(PostSaveRequestDto postSaveRequestDto, MultipartFile image) {
Member member = memberRepository.findById(postSaveRequestDto.memberId()).orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND_EXCEPTION, ErrorCode.MEMBER_NOT_FOUND_EXCEPTION.getMessage() + postSaveRequestDto.memberId()));
// 이미지가 있을 때만 S3에 업로드. 이미지 없이 글만 작성하는 경우도 허용하기 위해 null 체크
String imageUrl = null;
if (image != null && !image.isEmpty()) {
try {
imageUrl = s3Uploader.upload(image); // S3 업로드 후 반환된 URL을 저장
} catch (IOException e) {
// 업로드 실패 시 커스텀 예외로 변환해서 던짐 → GlobalExceptionHandler가 처리
throw new BusinessException(ErrorCode.FILE_UPLOAD_FAIL_EXCEPTION, ErrorCode.FILE_UPLOAD_FAIL_EXCEPTION.getMessage());
}
}
Post post = Post.builder()
.title(postSaveRequestDto.title())
.contents(postSaveRequestDto.contents())
.imageUrl(imageUrl) // 이미지 없으면 null, 있으면 S3 URL
.member(member)
.build();
postRepository.save(post);
}
// 특정 작성자가 작성한 게시글 목록을 조회
public Page<PostInfoResponseDto> postFindMember(Long memberId, Pageable pageable) {
Member member = memberRepository.findById(memberId).orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND_EXCEPTION, ErrorCode.MEMBER_NOT_FOUND_EXCEPTION.getMessage() + memberId));
Page<Post> posts = postRepository.findByMember(member, pageable);
return posts.map(PostInfoResponseDto::from);
}
// 게시물 수정
@Transactional
public void postUpdate(Long postId, PostUpdateRequestDto postUpdateRequestDto)
{
Post post = postRepository.findById(postId).orElseThrow(() -> new BusinessException(ErrorCode.POST_NOT_FOUND_EXCEPTION, ErrorCode.POST_NOT_FOUND_EXCEPTION.getMessage() + postId));
post.update(postUpdateRequestDto);
}
// 게시물 삭제
@Transactional
public void postDelete(Long postId) {
Post post = postRepository.findById(postId).orElseThrow(() -> new BusinessException(ErrorCode.POST_NOT_FOUND_EXCEPTION, ErrorCode.POST_NOT_FOUND_EXCEPTION.getMessage() + postId));
postRepository.delete(post);
}
}
package com.likelion.likelioncrud.post.api;
import com.likelion.likelioncrud.common.response.code.SuccessCode;
import com.likelion.likelioncrud.common.template.ApiResTemplate;
import com.likelion.likelioncrud.post.api.dto.request.PostSaveRequestDto;
import com.likelion.likelioncrud.post.api.dto.request.PostUpdateRequestDto;
import com.likelion.likelioncrud.post.api.dto.response.PostInfoResponseDto;
import com.likelion.likelioncrud.post.application.PostService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@RestController
@RequiredArgsConstructor
@RequestMapping("/post")
@Tag(name = "POST API", description = "게시글 관리하는 api ")
public class PostController {
private final PostService postService;
// 게시물 저장
// consumes = "multipart/form-data": JSON이 아닌 파일 전송 방식으로 요청을 받음
// @RequestPart("data"): multipart 요청에서 "data" 키에 담긴 JSON을 DTO로 변환
// required = false: 이미지 없이 글만 저장하는 경우도 허용
@PostMapping(consumes = "multipart/form-data")
@Operation(summary = "게시물 저장", description = "게시물 저장. 이미지는 선택사항입니다.")
public ApiResTemplate<Void> postSave(
@RequestPart("data") PostSaveRequestDto postSaveRequestDto,
@RequestPart(value = "image", required = false) MultipartFile image) {
postService.postSave(postSaveRequestDto, image);
return ApiResTemplate.successWithNoContent(SuccessCode.POST_SAVE_SUCCESS);
}
// 사용자 id를 기준으로 해당 사용자가 작성한 게시글 목록 조회
@GetMapping("/{memberId}")
@Operation(summary = "게시물 memberId로 조회", description = "게시물 memberId로 조회")
public ApiResTemplate<Page<PostInfoResponseDto>> myPostFindAll(@PathVariable("memberId") Long memberId, @ParameterObject @PageableDefault(size = 10, sort = "postId", direction = Sort.Direction.ASC) Pageable pageable) {
Page<PostInfoResponseDto> posts = postService.postFindMember(memberId, pageable);
return ApiResTemplate.successResponse(SuccessCode.GET_SUCCESS, posts);
}
// 게시물 id를 기준으로 사용자가 작성한 게시물 수정
@PatchMapping("/{postId}")
@Operation(summary = "게시물 Id로 수정", description = "게시물 제목, 내용 수정")
public ApiResTemplate<Void> postUpdate(@PathVariable("postId") Long postId,
@RequestBody PostUpdateRequestDto postUpdateRequestDto) {
postService.postUpdate(postId, postUpdateRequestDto);
return ApiResTemplate.successWithNoContent(SuccessCode.POST_UPDATE_SUCCESS);
}
// 게시물 id를 기준으로 사용자가 작성한 게시물 삭제
@DeleteMapping("/{postId}")
@Operation(summary = "게시물 삭제", description = "게시물 Id로 삭제")
public ApiResTemplate<Void> postDelete(@PathVariable("postId") Long postId) {
postService.postDelete(postId);
return ApiResTemplate.successWithNoContent(SuccessCode.POST_DELETE_SUCCESS);
}
}
스웨거가 아니라 Postman으로 테스트하는 이유는 뭔가요?
| 비교 항목 | Swagger | Postman |
|---|---|---|
| multipart Content-Type | 자동으로 octet-stream으로 보내버림 | multipart/form-data를 안정적으로 처리함 |
| 헤더 제어 | 제한적 | 자유롭게 설정 가능 |
| 용도 | API 문서 확인용 | 실제 테스트용 |
octet = 8비트(1바이트). 즉 application/octet-stream = "그냥 바이트 덩어리인데 타입을 모르겠음" 이라는 의미.

포스트맨을 켜서 POST로 위와 같이 새로 생성하고

body는 form-data로 설정해주세요

그 다음 맨 오른쪽 쩜쩜쩜을 클릭해 Content-Type을 추가해줍니다

그 후 data키는 Text 타입으로 사진과 같이 만들어 주시고
(꼭 Content-Type안에 application/json를 넣어주세요!)

image키는 file 타입으로 만들어서

이렇게 이미지를 넣어줍니다

성공했다면

S3 버킷에 들어가서

업로드된 객체에 들어가서

속성 속 객체 URL을 클릭해보면

사진이 뜨면 성공입니다~

PATCH /post/{postId})는 제목과 내용만 수정 가능하며, 이미지 교체가 되지 않는 상태입니다. 이미지도 함께 수정할 수 있도록 구현해주세요!Post.java, PostService.java, PostController.javaS3Uploader.java, PostService.javaimageUrl은 null로 설정되며, S3 버킷에서도 해당 이미지가 삭제되어야 합니다.PostService.java, PostController.java