[AWS + Spring] S3 : 파일 업로드 & 다운로드 (1) (개발 및 프로덕션 환경 연동 기본)

이번 포스팅에서는 기존 Local Stroage를 이용하여 파일 업로드 및 다운로드 API를 제공하던 Spring Application에 S3를 연동하는 작업을 해보도록 하겠다.
개발 환경에서의 S3 연동, EC2 프로덕션 환경에서의 S3 연동 두 가지로 나누어 비즈니스 로직을 작성해보자.
1. 로컬 스토리지 기반의 Spring 파일 업로드 및 다운로드 로직
2. AWS S3 준비
3. 개발 환경 및 프로덕션 환경에 따른 AWS 서비스 연동 방법 및 자격 증명 방식
4. S3 연동 : 로컬 개발 환경
4-1. IAM 사용자 생성
4-2. AWS CLI 설치 및 AWS Profile 설정
4-3. Spring 프로젝트에 SDK 의존성 추가
4-4. Spring 프로젝트의 환경변수 프로파일 설정
4-5. S3 Client 설정을 위한 Configuration 클래스 작성 및 Bean 등록
4-6. S3 Service : S3와 통신을 담당하는 비즈니스 로직 구현
4-7. 기존 FileService의 비즈니스 로직 변경 : S3 Service와의 연동
4-8. 로컬 테스트
5. S3 연동 : EC2 프로덕션 환경
5-1. 프로덕션용 Spring 환경변수 프로파일 설정
5-2. S3 Client 생성 시 프로덕션일 경우 IMDS 기반의 Role-based 방식으로 자격 증명 방식 사용
5-3. IAM 역할 생성
5-4. EC2 프로비저닝
6. S3 업로드 & 다운로드 : 통신방식 분석
6-1. 서버를 매개체로 S3와 통신하는 경우
6-2. 버킷이 Public으로 공개되어 있는 경우
6-3. Presigned URL 사용
로컬 스토리지 기반 파일 업로드 및 다운로드 로직
아래의 내용은 Local Stroage에 파일을 업로드 및 다운로드하는 과정을 Spring으로 구현했던 내용이다. 기존에 구현했던 내용을 바탕으로 S3와 연동하는 방식으로 변경해 볼 예정이다.
API는 업로드 및 다운로드 두 가지로 구분된다.
1. 파일 업로드 : Multipart 파일을 Local Stroage에 저장함과 동시에 메타데이터(경로, 용량, 파일명 등)을 DB에 저장한다. DB에 저장된 데이터의 PK를 반환한다.
2. 파일 다운로드 : 파일의 PK를 통해 요청한다. 해당 PK를 기반으로 DB의 경로에 존재하는 파일을 읽어서 Byte 배열을 반환한다.
해당 코드들의 자세한 구현 내용은 아래의 포스팅을 참고하자.
https://sjh9708.tistory.com/94
[SpringBoot] Multipart 파일 Upload & Download
이번 포스팅에서는 Spring Boot에서 Request로 Multipart 형식의 File을 받아, 서버 내부 스토리지에 저장하는 방법과, 스토리지에 저장된 파일을 Response로 출력하는 과정을 다루어보도록 하겠다. 업로드
sjh9708.tistory.com
▶ 기존 파일 업로드 컨트롤러 및 서비스 상세 코드
@RestController
@RequestMapping("/api/v1/file")
@RequiredArgsConstructor
public class FileApiController {
private final FileService fileService;
@ApiResponses({
@ApiResponse(code = 200, message = "파일 업로드 성공 시 업로드된 파일의 ID(PK)를 반환합니다."),
@ApiResponse(code = 500, message = "파일 업로드 실패 시 다음 코드를 반환합니다. 최대 용량 초과(10MB), 이미지 파일이 아닌 파일을 업로드, 내부 서버 오류의 사유가 있을 수 있습니다.")
})
@PreAuthorize(AuthConstant.AUTH_ROLE_COMMON_USER)
@PostMapping(value = "",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Long> addFile(
@ApiParam(value = "multipart/form-data 형식의 이미지")
@RequestPart("multipartFile")
MultipartFile file) {
Long memberPK = SecurityUtil.getCurrentMemberPk();
FileInfoDto fileinfo = fileService.uploadFile(file);
Long success = fileService.addFileInfo(fileinfo, memberPK);
return ResponseEntity.status(HttpStatus.OK).body(success);
}
@PreAuthorize(AuthConstant.AUTH_ROLE_COMMON_USER)
@GetMapping("/{id}")
public ResponseEntity<byte[]> getImage(@PathVariable Long id){
// ID를 통해 이미지 파일의 경로를 얻어옴
FileInfoDto info = this.fileService.getImageInfoById(id);
String mimeType = info.getFileType();
// 이미지 파일을 바이트 배열로 읽어옴
byte[] imageBytes = this.fileService.getImageFile(info);
return ResponseEntity.status(HttpStatus.OK)
.contentType(MediaType.valueOf(mimeType)) // 이미지 타입에 맞게 설정
.body(imageBytes);
}
}
public interface FileService {
/**
* [경로를 기반으로 이미지 바이트 스트림 반환]
* 해당 경로의 이미지의 바이트 스트림 형태를 얻음
* @param [String 파일 경로]
* @return [byte[] 이미지 바이트 배열]
*/
public abstract byte[] getImageFile(FileInfoDto info);
/**
* [파일 업로드]
* Multipart 파일을 입력받아 스토리지에 저장.
* @param [MultipartFile 파일]
* @return [FileinfoDto 파일 정보]
*/
public abstract FileInfoDto uploadFile(MultipartFile file);
/**
* [파일정보 저장]
* 업로드된 파일 정보를 데이터베이스에 저장
* @param [FileInfoDto 파일정보]
* @return [Long 파일 PK]
*/
public abstract Long addFileInfo(FileInfoDto fileinfo, Long memberPK);
/**
* [해당 ID의 이미지 Info 얻어오기]
* 업로드된 파일의 ID를 통해 경로 얻어오기
* @param [Long 파일 PK]
* @return [FileInfoDto 이미지 Info]
*/
public abstract FileInfoDto getImageInfoById(Long id);
}
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class FileServiceImpl implements FileService {
private final FileRepository fileRepository;
private final MemberServiceImpl memberService;
@Value("${upload.directory}")
private String uploadDirectory;
/**
* [경로를 기반으로 이미지 바이트 스트림 반환]
* 해당 경로의 이미지의 바이트 스트림 형태를 얻음
* @param [Path 파일 경로]
* @return [byte[] 이미지 바이트 배열]
*/
public byte[] getImageFile(FileInfoDto info){
try {
Path path = Paths.get(info.getFilePath());
byte[] imageBytes = Files.readAllBytes(path);
return imageBytes;
} catch (IOException e) {
throw new FileDownloadException("파일 다운로드 중 오류가 발생했습니다.");
}
}
/**
* [파일 업로드]
* Multipart 파일을 입력받아 서버 내부 스토리지에 저장.
* @param [MultipartFile 파일]
* @return [FileinfoDto 파일 정보]
*/
@Transactional()
public FileInfoDto uploadFile(MultipartFile file){
String originalFileName = file.getOriginalFilename();
String mimeType = file.getContentType();
//최대용량 체크
if (file.getSize() > FileConstant.MAX_FILE_SIZE) {
throw new FileUploadException("10MB 이하 파일만 업로드 할 수 있습니다.");
}
//MIMETYPE 체크
if (!FileUtil.isImageFile(mimeType)) {
throw new FileUploadException("이미지 파일만 업로드할 수 있습니다.");
}
//저장 파일명을 중복방지 고유명으로 변경
String newFileName = generateUniqueFileName(originalFileName);
Path filePath = Paths.get(uploadDirectory + File.separator + newFileName);
try {
Files.copy(file.getInputStream(), filePath);
} catch (IOException e) {
throw new FileUploadException("파일 업로드 중 오류가 발생했습니다.");
}
return new FileInfoDto(file.getContentType(),
newFileName,
filePath.toString(),
Long.toString(file.getSize()));
}
/**
* [파일정보 저장]
* 업로드된 파일 정보를 데이터베이스에 저장
* @param [FileInfoDto 파일정보]
* @return [Long 파일 PK]
*/
@Transactional
public Long addFileInfo(FileInfoDto fileinfo, Long memberPK){
com.sklookiesmu.wisefee.domain.File file = new com.sklookiesmu.wisefee.domain.File();
file.setFileType(fileinfo.getFileType()); //MIMETYPE(~확장자)
file.setFileCapacity(fileinfo.getFileCapacity()); //용량
file.setName(fileinfo.getName()); //이름
file.setFilePath(fileinfo.getFilePath()); //경로
file.setFileInfo(FileConstant.FILE_INFO_NO_USE); //정보
file.setDeleted(false);
Member member = memberService.getMember(memberPK);
file.setMember(member);
this.fileRepository.create(file);
return file.getFileId();
}
/**
* [해당 ID의 이미지 Info 얻어오기]
* 업로드된 파일의 ID를 통해 경로 얻어오기
* @param [Long 파일 PK]
* @return [FileInfoDto 이미지 Info]
*/
public FileInfoDto getImageInfoById(Long id){
FileInfoDto info = this.fileRepository.getFilePathById(id);
return info;
}
/**
* [중복방지를 위한 파일 고유명 생성]
* @param fileExtension 확장자
* @return String 파일 고유이름
*/
private String generateUniqueFileName(String originalFileName) {
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMMddHHmmss");
// Random 객체 생성
Random random = new Random();
// 0 이상 100 미만의 랜덤한 정수 반환
String randomNumber = Integer.toString(random.nextInt(Integer.MAX_VALUE));
String timeStamp = dateFormat.format(new Date());
return timeStamp + randomNumber + originalFileName;
}
}
AWS S3 준비
로컬 스토리지 대신 사용할 S3 스토리지를 생성하고 필요한 권한들을 설정해두자.
사용 목적에 따라 스토리지 클래스, 버전관리 및 수명주기 등을 설정해주면 된다.
이번 포스팅에서는 모든 퍼블릭 엑세스 차단 해제만을 하고 디폴트로 설정하였다 (해당 설정만 해제한다고 해서 퍼블릭 접근이 가능해지는 것은 아니다)

버킷을 구성할 때의 다른 옵션들에 대한 자세한 내용은 아래의 포스팅을 참고하자.
https://sjh9708.tistory.com/258
[AWS] S3 : 기본 개념과 버킷 설정 (스토리지 클래스, 버킷정책, 버전관리, 수명주기, 암호화)
S3는 AWS에서 제공하는 스토리지 서비스이다. 높은 가용성 및 내구성을 제공하여 데이터를 안전하게 저장하고 어디에서나 접근할 수 있도록 서비스를 제공한다.이번 포스팅에서는 S3의 기본적인
sjh9708.tistory.com
AWS 연동 방법과 자격 증명
로컬 개발 시:
- S3 접근 권한이 있는 IAM 사용자를 생성 후, SDK를 통해 AWS CLI와 통신
- 인증 방식:
- IAM Access Key를 직접 환경변수에 설정 후 인증 (비권장: 보안상 취약)
- IAM Profile을 기반으로 AWS CLI에서 인증 정보를 로드하여 사용 (권장)
EC2 프로덕션 환경 시:
- EC2 인스턴스에 IAM Role을 부여하여 SDK가 자동으로 S3 접근
- AWS SDK는 Instance Metadata Service (IMDS)를 사용하여 자격 증명을 자동으로 처리
Spring 애플리케이션과 S3 간의 통신을 위해서는 AWS SDK가 Spring 의존성에 포함되어 있어야 하며, S3 서비스 접근을 위한 자격 증명 방식을 로컬 개발 환경과 프로덕션 환경에서 어떻게 설정할 것인지 고려해야 한다.
우리는 로컬 개발 환경에서는 IAM Profile 기반의 장기 자격 증명 방식을 활용하고, 프로덕션 환경에서는 EC2의 IMDS(Instance Metadata Service)를 사용하여 IAM Role 기반으로 자동으로 S3와 통신하도록 설정할 것이다.
https://sjh9708.tistory.com/247
[AWS] IAM : 인증과 접근 방식 (장기 자격 증명 & 임시 자격 증명)
이번 포스팅에서는 이전 포스팅에서 다루어보았던 권한 정책 부여 방식에 따라, 서비스를 사용하기 위해서 자격 증명을 통해 인증하는 방법을 살펴보려고 한다. 영구적 정책 부여 vs 임시 정책
sjh9708.tistory.com
S3 연동 : 로컬 개발 환경
1. IAM 사용자 생성

IAM 사용자에는 S3 전체에 접근할 수 있는 권한 정책을 할당해주었다.

엑세스 키를 발급받아 두었다. 해당 엑세스 키를 기반으로 IAM 프로파일을 생성하고 인증 방식으로 사용할 것이다.
2. AWS CLI 설치 및 프로파일 설정
로컬 개발 환경에서 AWS CLI를 설치해주어야 한다. MAC 기준으로 homebrew를 통해서 AWS CLI를 개발 컴퓨터에 설치해주었다.
brew install awscli
CLI에서 엑세스 키를 이용하여 프로파일을 생성해주자.
aws configure --profile YOUR_PROFILE_NAME

3. Spring 프로젝트에 AWS SDK 의존성 추가
Spring 프로젝트에서 AWS (혹은 AWS CLI) 와 통신할 수 있도록 SDK를 사용하기 위해서 의존성을 추가해주자.
▶ build.gradle
dependencies {
// ...
//aws sdk
implementation 'software.amazon.awssdk:s3:2.21.5'
}
4. Spring 환경변수 프로파일 분리
개발 환경에서도 AWS와 연동하여 개발을 진행할 경우와, AWS를 연동하지 않고 개발을 진행하는 경우가 나뉠 수 있다. 이를 위해서 application.yml파일을 두 개로 분리해주었다.
▶ application.yml
spring:
// ...
cloud:
aws:
active: false
기존 application.yml 프로파일을 사용할 때에는 cloud.aws.active를 false로 두어 AWS를 사용하지 않고 로컬 파일 스토리지를 사용하는 방식을 사용할 것이다.
▶ application-dev.yml
spring:
config:
activate:
on-profile: dev
// ...
cloud:
aws:
active: true
auth: profile # 자격 증명 방식
s3:
bucket: wisefee-bucket-476114146023 # 버킷 네임
region: ap-northeast-2 # 리전
profile: wisefee-app # IAM 프로파일 이름
application-dev.yml 프로파일을 사용할 때에는 cloud.aws.active를 true로 두어 AWS를 사용하여, 즉 S3를 연동할 것이다.
1. S3 정보 : S3 연동을 위한 정보들 (버킷명, 리전) 등을 함께 지정해주었다.
2. 자격 증명 방식 : 로컬 개발 환경에서는 IAM 프로파일을 기반으로 AWS CLI의 인증정보를 활용할 것이기 때문에 auth를 profile로 지정해주었고, 사용할 IAM 프로파일 이름을 함께 지정해주었다.
5. S3 Client 설정을 위한 Configuration 클래스 작성 및 Bean 등록
스프링에서 S3와 통신하기 위한 S3 Client Bean을 생성하고 스프링 컨텍스트에 주입하기 위한 Configuration 파일을 구성하였다.
▶ config/aws/S3Config.java
@Configuration
@ConditionalOnProperty(name = "cloud.aws.active", havingValue = "true")
public class S3Config {
@Value("${cloud.aws.s3.region}")
private String region;
@Value("${cloud.aws.auth}")
private String authType;
@Value("${cloud.aws.s3.profile:#{null}}")
private String profile;
@Bean
public S3Client s3Client() {
if(authType.equals("profile")){
return S3Client.builder()
.region(Region.of(region))
.credentialsProvider(ProfileCredentialsProvider.create(profile)) // CLI 프로파일 사용
.build();
}
return null;
}
}
1. 해당 Configuration은 AWS를 사용할 때에만 사용될 것이다. 즉 cloud.aws.active가 true일 경우에만 활성화되도록 설정되어있다.
2. 환경변수(cloud.aws.auth)의 값에 따라 다른 인증 방식을 사용할 것이기 때문에, "profile"일 경우에는 credentialProvider를 ProfileCredentialsProvider(프로파일)를 기반으로 자격 증명을 통해 S3와 통신할 수 있도록 설정해 주었다.
3. 리전 및 인증 방식을 설정하여 S3Client 객체를 빌드 후 Spring Bean으로 등록한다.
6. S3 Service : S3와 통신을 담당하는 비즈니스 로직 구현
S3Service는 AWS S3와의 파일 업로드 및 다운로드를 처리하는 서비스 클래스로 사용될 것이다.
▶ service/S3Service.java
@Service
@ConditionalOnProperty(name = "cloud.aws.active", havingValue = "true")
@RequiredArgsConstructor
public class S3Service {
private final S3Client s3Client;
@Value("${cloud.aws.s3.bucket}")
private String bucketName;
public String uploadFile(MultipartFile file, String prefix, String filename) {
try {
String s3Key = prefix + "/" + filename;
s3Client.putObject(
PutObjectRequest.builder()
.bucket(bucketName)
.key(s3Key)
.contentType(file.getContentType()) // MIME 타입 지정
.build(),
RequestBody.fromBytes(file.getBytes()) // MultipartFile -> byte[] 변환
);
return "https://" + bucketName + ".s3.amazonaws.com/" + s3Key;
} catch (IOException e) {
throw new FileUploadException("파일 업로드 중 오류가 발생했습니다.");
} catch (Exception e) {
throw new FileUploadException("파일 업로드 중 오류가 발생했습니다.");
}
}
public byte[] downloadFile(String prefix, String filename) {
try {
String s3Key = prefix + "/" + filename;
GetObjectRequest getObjectRequest = GetObjectRequest.builder()
.bucket(bucketName)
.key(s3Key)
.build();
ResponseBytes<?> responseBytes = s3Client.getObjectAsBytes(getObjectRequest);
return responseBytes.asByteArray();
} catch (S3Exception e) {
throw new FileDownloadException("S3 파일 다운로드 중 오류가 발생했습니다: " + e.awsErrorDetails().errorMessage());
}
}
}
1. 위와 마찬가지로 @ConditionalOnProperty를 사용하여 cloud.aws.active=true일 때만 활성화된다.
2. AWS S3에 저장되는 Object는 Key에 의해서 구분된다. 즉 S3 객체(파일)를 저장할 때 사용되는 고유한 식별자이자 경로이다.
- Key를 생성하기 위해서 Prefix와 FileName을 조합한다. 예를 들어 prefix가 public/images, filaname이 cat.jpg라면 Key는 public/images/cat.jpg이다.
3. uploadFile()은 MultipartFile을 받아 S3에 Key에 해당하는 Object로 업로드하고, 업로드된 파일의 URL(경로)을 반환한다.
4. downloadFile()은 Key를 바탕으로 S3에서 지정된 파일을 다운로드하여 바이트 배열로 반환한다.
7. 기존 비즈니스 로직 변경 : S3 Service와의 연동
▶ service/FileService.java
public interface FileService {
/**
* [경로를 기반으로 이미지 바이트 스트림 반환]
* 해당 경로의 이미지의 바이트 스트림 형태를 얻음
* @param [String 파일 경로]
* @return [byte[] 이미지 바이트 배열]
*/
public abstract byte[] getImageFile(FileInfoDto info);
/**
* [파일 업로드]
* Multipart 파일을 입력받아 스토리지에 저장.
* @param [MultipartFile 파일]
* @return [FileinfoDto 파일 정보]
*/
public abstract FileInfoDto uploadFile(MultipartFile file);
/**
* [파일정보 저장]
* 업로드된 파일 정보를 데이터베이스에 저장
* @param [FileInfoDto 파일정보]
* @return [Long 파일 PK]
*/
public abstract Long addFileInfo(FileInfoDto fileinfo, Long memberPK);
/**
* [해당 ID의 이미지 Info 얻어오기]
* 업로드된 파일의 ID를 통해 경로 얻어오기
* @param [Long 파일 PK]
* @return [FileInfoDto 이미지 Info]
*/
public abstract FileInfoDto getImageInfoById(Long id);
}
기존의 FileService의 구현체로서 사용되는 FileServiceImpl에는 위의 추상 메서드들에 대한 구현이 이루어졌었다.
그런데 "AWS S3를 사용하는 로컬 개발 환경일 때의 구현체"와 "로컬 스토리지를 사용하는 개발 환경일 때의 구현체"를 나눌 필요가 있다.
- uploadFile()과 getFile()은 S3를 사용할 때와 사용하지 않았을 때 구현 내용이 달라질 것이다.
- addFileInfo()와 getImageInfoId()는 데이터베이스 관련 작업이기 때문에 S3 사용여부와 무관하게 공통적인 메서드로 사용될 것이다.

1. AbstractFileService에는 공통 메서드들을 구현할 것이다. (FileService 인터페이스 구현)
2. FileServiceLocalImpl에는 로컬 스토리지를 사용하도록 두 가지 메서드를 구현한다. (AbstractFileService 상속)
3. FileServiceAwsImpl에는 AWS S3를 사용하도록 두 가지 메서드를 구현한다.
▶ service/file/AbstractFileService.java
@Transactional(readOnly = true)
@RequiredArgsConstructor
public abstract class AbstractFileService implements FileService {
protected final FileRepository fileRepository;
protected final MemberServiceImpl memberService;
/**
* [파일정보 저장]
* 업로드된 파일 정보를 데이터베이스에 저장
* @param [FileInfoDto 파일정보]
* @return [Long 파일 PK]
*/
@Transactional
public Long addFileInfo(FileInfoDto fileinfo, Long memberPK){
com.sklookiesmu.wisefee.domain.File file = new com.sklookiesmu.wisefee.domain.File();
file.setFileType(fileinfo.getFileType()); //MIMETYPE(~확장자)
file.setFileCapacity(fileinfo.getFileCapacity()); //용량
file.setName(fileinfo.getName()); //이름
file.setFilePath(fileinfo.getFilePath()); //경로
file.setFileInfo(FileConstant.FILE_INFO_NO_USE); //정보
file.setDeleted(false);
Member member = memberService.getMember(memberPK);
file.setMember(member);
this.fileRepository.create(file);
return file.getFileId();
}
/**
* [해당 ID의 이미지 Info 얻어오기]
* 업로드된 파일의 ID를 통해 경로 얻어오기
* @param [Long 파일 PK]
* @return [FileInfoDto 이미지 Info]
*/
public FileInfoDto getImageInfoById(Long id){
FileInfoDto info = this.fileRepository.getFilePathById(id);
return info;
}
/**
* [중복방지를 위한 파일 고유명 생성]
* @param fileExtension 확장자
* @return String 파일 고유이름
*/
protected String generateUniqueFileName(String originalFileName) {
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMMddHHmmss");
// Random 객체 생성
Random random = new Random();
// 0 이상 100 미만의 랜덤한 정수 반환
String randomNumber = Integer.toString(random.nextInt(Integer.MAX_VALUE));
String timeStamp = dateFormat.format(new Date());
return timeStamp + randomNumber + originalFileName;
}
}
▶ service/file/FileServiceLocalImpl.java
@Service
@Transactional(readOnly = true)
@ConditionalOnProperty(name = "cloud.aws.active", havingValue = "false")
public class FileServiceLocalImpl extends AbstractFileService {
@Value("${upload.directory}")
private String uploadDirectory;
public FileServiceLocalImpl(FileRepository fileRepository, MemberServiceImpl memberService) {
super(fileRepository, memberService);
}
/**
* [경로를 기반으로 이미지 바이트 스트림 반환]
* 해당 경로의 이미지의 바이트 스트림 형태를 얻음
* @param [Path 파일 경로]
* @return [byte[] 이미지 바이트 배열]
*/
public byte[] getImageFile(FileInfoDto info){
try {
Path path = Paths.get(info.getFilePath());
byte[] imageBytes = Files.readAllBytes(path);
return imageBytes;
} catch (IOException e) {
throw new FileDownloadException("파일 다운로드 중 오류가 발생했습니다.");
}
}
/**
* [파일 업로드]
* Multipart 파일을 입력받아 서버 내부 스토리지에 저장.
* @param [MultipartFile 파일]
* @return [FileinfoDto 파일 정보]
*/
@Transactional()
public FileInfoDto uploadFile(MultipartFile file){
String originalFileName = file.getOriginalFilename();
String mimeType = file.getContentType();
//최대용량 체크
if (file.getSize() > FileConstant.MAX_FILE_SIZE) {
throw new FileUploadException("10MB 이하 파일만 업로드 할 수 있습니다.");
}
//MIMETYPE 체크
if (!FileUtil.isImageFile(mimeType)) {
throw new FileUploadException("이미지 파일만 업로드할 수 있습니다.");
}
//저장 파일명을 중복방지 고유명으로 변경
String newFileName = generateUniqueFileName(originalFileName);
Path filePath = Paths.get(uploadDirectory + File.separator + newFileName);
try {
Files.copy(file.getInputStream(), filePath);
} catch (IOException e) {
throw new FileUploadException("파일 업로드 중 오류가 발생했습니다.");
}
return new FileInfoDto(file.getContentType(),
newFileName,
filePath.toString(),
Long.toString(file.getSize()));
}
}
AWS 서비스를 사용하지 않을 경우 (cloud.aws.active가 false)일 경우에는 기존처럼 로컬 스토리지 기반의 업로드와 다운로드 비즈니스 로직을 사용하는 구현체를 Bean으로 사용하도록 설정해주었다.
▶ service/file/FileServiceAwsImpl.java
@Service
@Transactional(readOnly = true)
@ConditionalOnProperty(name = "cloud.aws.active", havingValue = "true")
public class FileServiceAwsImpl extends AbstractFileService {
private final S3Service s3Service;
public FileServiceAwsImpl(FileRepository fileRepository, MemberServiceImpl memberService, S3Service s3Service) {
super(fileRepository, memberService);
this.s3Service = s3Service;
}
/**
* [경로를 기반으로 이미지 바이트 스트림 반환]
* 해당 경로의 이미지의 바이트 스트림 형태를 얻음
* @param [Path 파일 경로]
* @return [byte[] 이미지 바이트 배열]
*/
public byte[] getImageFile(FileInfoDto info){
byte[] imageBytes = this.s3Service.downloadFile("public", info.getName());
return imageBytes;
}
/**
* [파일 업로드]
* Multipart 파일을 입력받아 S3 스토리지에 저장.
* @param [MultipartFile 파일]
* @return [FileinfoDto 파일 정보]
*/
@Transactional()
public FileInfoDto uploadFile(MultipartFile file){
String originalFileName = file.getOriginalFilename();
String mimeType = file.getContentType();
//최대용량 체크
if (file.getSize() > FileConstant.MAX_FILE_SIZE) {
throw new FileUploadException("10MB 이하 파일만 업로드 할 수 있습니다.");
}
//MIMETYPE 체크
if (!FileUtil.isImageFile(mimeType)) {
throw new FileUploadException("이미지 파일만 업로드할 수 있습니다.");
}
//저장 파일명을 중복방지 고유명으로 변경
String newFileName = generateUniqueFileName(originalFileName);
String path = s3Service.uploadFile(file, "public", newFileName);
return new FileInfoDto(file.getContentType(),
newFileName,
path,
Long.toString(file.getSize()));
}
}
AWS 서비스를 사용할 경우 (cloud.aws.active가 true)일 경우에는 S3 기반의 업로드와 다운로드 비즈니스 로직을 사용하는 구현체를 Bean으로 사용하도록 설정해주었다.
1. 업로드 : 기존의 로컬 스토리지에 파일을 업로드하던 부분을 S3Service를 활용하여 AWS S3에 업로드하도록 비즈니스 로직을 변경해 준 모습이다.
2. 다운로드 : 마찬가지로 기존의 로컬 스토리지에서 파일을 다운로드하는 부분을 S3Service를 활용하여 AWS S3에서 다운로드하도록 변경해주었다.
8. 로컬 테스트

InteliJ에서 실행 프로파일을 "dev"로 설정한 이후, 업로드와 다운로드 API에 대해서 테스트를 진행한다.
8-1. 업로드 테스트

Swagger UI를 통해서 고양이 사진을 업로드해보았다. 이 때 결과값으로 데이터베이스에 저장된 메타데이터의 PK값이 반환된다.

데이터베이스를 살펴보니 file_path에 S3 버킷의 경로가 저장되어 있는 것을 확인할 수 있다.
https://wisefee-bucket-476114146023.s3.amazonaws.com/public/20250216070217411259893다운로드.jpeg
참고로 해당 S3 버킷의 오브젝트는 현재 Public 접근을 허용하는 Bucket 정책이 설정되지 않았기 때문에 해당 URL을 통해 자격 증명 없이 다운로드가 불가능하다.

S3 버킷을 확인해보니 우리가 업로드했던 고양이 사진이 버킷에 업로드되어있는 것을 확인할 수 있다.
8-2. 다운로드 테스트

마찬가지로 다운로드 API를 사용해 보았을 때, S3에 저장된 고양이 사진을 가져올 수 있는 것을 확인할 수 있다.
S3 연동 : EC2 프로덕션 환경
지금까지 로컬 개발 환경에서 S3와 연동할 때에는 AWS CLI를 통해서 IAM 사용자의 Profile을 설정하고, Spring Application에서는 CLI의 도움을 받아 프로파일 기반의 자격 증명을 사용하여 S3와 연동해보았었다.
프로덕션 환경에서는 EC2의 IMDS(Instance Metadata Service)를 사용하여 IAM Role 기반으로 자동으로 S3와 통신하도록 설정할 것이다. 이것은 AWS CLI를 사용할 필요 없이 자동으로 Spring의 AWS SDK가 S3를 비롯한 AWS 서비스와 통신할 수 있게 된다.
1. 프로덕션용 Spring 환경변수 프로파일 작성
클라우드 프로덕션 환경에서 사용할 Spring 프로파일을 설정해 줄 것이다.
▶ application-prod.yml
spring:
config:
activate:
on-profile: prod
// ...
cloud:
aws:
active: true
auth: role # 자격 증명 방식
s3:
bucket: wisefee-bucket-476114146023 # 버킷 네임
region: ap-northeast-2 # 리전
# profile: wisefee-app # IAM 프로파일 이름
application-prod.yml 프로파일을 사용할 때에는 Role 기반 자격 증명 방식을 사용할 것이기 때문에 Profile은 사용하지 않을 것이다.
2. S3Config에서 Role-based 자격 증명 방식에 따른 S3 Client 생성
기존에는 AWS 서비스를 사용할 때 Profile을 기반으로 자격 증명 방식을 사용하여 S3 Client를 생성하였다.
이제 환경 변수의 자격 증명 방식에 따라서 서로 다르게 S3 클라이언트를 생성하도록 설정할 것이다.
▶ config/aws/S3Config.java
@Configuration
@ConditionalOnProperty(name = "cloud.aws.active", havingValue = "true")
public class S3Config {
@Value("${cloud.aws.s3.region}")
private String region;
@Value("${cloud.aws.auth}")
private String authType;
@Value("${cloud.aws.s3.profile:#{null}}")
private String profile;
@Bean
public S3Client s3Client() {
if(authType.equals("profile")){
return S3Client.builder()
.region(Region.of(region))
.credentialsProvider(ProfileCredentialsProvider.create(profile)) // CLI 프로파일 사용
.build();
}
else if(authType.equals("role")){
return S3Client.builder()
.region(Region.of(region))
.credentialsProvider(InstanceProfileCredentialsProvider.create()) // EC2 인스턴스 CredentialProvider 사용
.build();
}
return null;
}
}
AWS CLI의 IAM Profile을 사용하여 인증 : authType이 "profile"
- ProfileCredentialsProvider.create(profile): ~/.aws/credentials 파일의 지정된 profile을 사용하여 인증.
- 로컬 개발 환경에서 자주 사용됨.
EC2 인스턴스의 IAM Role을 사용하여 자동 인증 : authType이 "role"
- InstanceProfileCredentialsProvider.create(): EC2의 Instance Metadata Service (IMDS)에서 IAM Role의 자격 증명을 가져옴.
- EC2 환경에서 실행될 때 사용됨.
3. IAM 역할 생성

IMDS를 사용하기 위해서 EC2 Role을 생성해주자. 마찬가지로 S3에 대한 모든 접근 권한을 설정해주었다.
4. EC2 프로비저닝

EC2 인스턴스를 생성할 때 IAM 인스턴스 프로파일에 방금 생성한 Role을 설정해주었다.
해당 과정을 통해 EC2 인스턴스 안에서 동작하는 Spring Application의 SDK는 별도의 인증 과정 없이 자동으로 역할을 기반으로 S3 Service와 통신이 가능해진다.
이제 해당 EC2 인스턴스 안에서 Spring Application을 실행시켜서 테스트해보면, 별도의 AWS CLI 설치 및 IAM Profile을 지정하지 않아도 정상적으로 S3와 통신이 가능한 것을 확인할 수 있다.
<함께 보기> EC2 인스턴스에 Spring Application 배포하기 (Docker)
https://sjh9708.tistory.com/100
[AWS] EC2 인스턴스에 Docker 컨테이너 배포하기
이전에 Google Cloud Platform (GCP)를 이용하여 Docker 컨테이너를 배포하는 방법을 포스팅한 적 있다.이번에는 AWS를 사용하여 배포하는 방법을 살펴보도록 하자. AWS 인스턴스에, Docker 컨테이너를 실행
sjh9708.tistory.com
S3 업로드 & 다운로드 : 통신 방식 분석
1. 서버를 매개체로 S3와 통신하는 경우

해당 그림은 우리가 지금까지 구현했던 서버를 경유하여 S3와 통신하는 파일 업로드 및 다운로드 과정을 나타낸다.
서버가 클라이언트와 S3 사이에서 중개 역할을 수행하며, 이는 보안 및 접근 제어의 이점을 제공하지만, 추가적인 비용과 성능 부담이 발생할 수 있다.
위의 그림처럼 업로드 및 다운로드 시 두 번의 네트워크 과정을 거쳐야 한다는 것을 확인할 수 있다.
업로드
클라이언트 → 서버 (Request Upload) : 사용자가 파일(이미지)을 Payload에 포함시켜 업로드 요청
서버 → S3 (S3 Upload, With credential) : 서버가 AWS 자격 증명을 사용하여 S3에 파일을 업로드
다운로드
서버 → S3 (S3 Download, With credential) : 서버는 AWS 자격 증명을 사용하여 S3에서 파일을 다운로드
서버 → 클라이언트 (Response) : 다운로드한 파일을 클라이언트에게 반환
2. 버킷의 Object가 Public으로 공개되어 있을 경우

오브젝트 파일(이미지)가 네트워크상에서 여러 번 Payload에 포함되어 통신되어 오버헤드가 발생하는 것을 완화하고자 위 그림과 같은 방법을 사용할 수도 있다.
- 클라이언트가 서버(Instance)에게 파일 다운로드 요청 (Request Download).
- 서버는 해당 파일의 S3 URL을 클라이언트에게 응답 (Response S3 URL).
- 클라이언트는 응답받은 S3 URL을 통해 직접 파일을 요청 (Request File).
- S3 버킷이 Public 설정이라면, 클라이언트가 직접 S3에서 파일을 다운로드 (Response).
그렇지만 이것은 S3 Bucket이 Public으로 노출되어 있어야 가능한 방법이며, 버킷의 Object를 Public으로 완전히 노출시키는 것은 리스크가 존재한다는 것을 짐작할 수 있다.
- URL이 유출되면 무단 다운로드 및 악용 위험이 발생
- 공격자가 대량의 요청을 보내면 예상치 못한 데이터 전송 비용 증가 및 DDoS 공격의 대상이 될 가능성
그렇다고 익명 클라이언트(모든 사용자)에게 IAM 권한을 제공하는 것은 절대 하면 안 되는 방법이라는 것은 짐작이 될 것이다.
3. Presigned URL 사용

Presigned URL은 클라이언트가 IAM 인증 없이 직접 S3에 접근할 수 있도록 하는 임시 URL이다.
S3의 파일을 안전하게 공유하기 위해 사용되며, 생성자의 IAM 권한을 기반으로 특정 파일에 대한 접근 권한이 부여된다.
- 권한을 가진 Application → Presigned URL 생성 (서명 생성)
- 클라이언트 → 요청 → 서버 → Presigned URL 반환 → 클라이언트는 해당 Presigned URL을 사용하여 업로드 혹은 다운로드
- 즉 클라이언트에서 IAM 인증 없이 직접 S3의 업로드 및 다운로드가 가능.
- 특징
- URL의 만료 기간 및 Method(GET/PUT)등 설정 가능
- URL의 권한은 생성자가 가진 권한 중 일부 혹은 전체 사용
따라서 클라이언트가 임시적으로 직접 S3에 접근하는 것을 허용하여, 서버를 매개체로 할 때의 네트워크 오버헤드를 줄이면서 S3 버킷의 보안을 확보할 수 있는 방법이다.
다운로드 시 Presigned URL의 사용 예시
- 클라이언트 → 서버: Presigned URL 요청
- 서버 → S3: Presigned URL 요청 및 응답
- 서버는 자격 증명을 사용하여 S3에 Presigned URL을 요청
- Presigned URL은 특정 기간 동안만 유효하며, S3 객체에 대한 특정 행동(위 그림에서는 GET, 조회)을 허용하는 서명이 포함됨.
- 조회 뿐 아니라 저장 및 업데이트 메서드(POST/PUT) 등을 허용할 수도 있음.
- 서버 → 클라이언트: Presigned URL 응답
- 서버는 클라이언트에게 S3 Presigned URL을 응답.
- 클라이언트 → S3 : Presigned URL을 사용한 다운로드 요청
- 클라이언트는 Presigned URL을 이용하여 일정 기간동안 S3에 허용된 특정 행동 (조회, 업데이트 등..) 가능
- 서버를 거치지 않고, 클라이언트가 S3에 직접 요청하여 트래픽 비용을 절감
- Presigned URL은 만료 기간이 존재하기 때문에 만료 이후로는 요청 처리를 하지 않음.
- S3 → 클라이언트 : 파일 응답
- S3는 Presigned URL 요청이 유효하면 파일(image.jpeg)을 직접 반환
다음 포스팅에서는 해당 파일 업로드 및 다운로드 방식에서 Presigned URL을 사용하도록 변경해 볼 예정이다.
<함께 보기> [AWS + Spring] S3 활용 : 파일 업로드와 다운로드 (2) (Presigned URL과 Multipart Upload)
https://sjh9708.tistory.com/260
[AWS + Spring] S3 : 파일 업로드 & 다운로드 (2) (Presigned URL, Multipart Upload)
이전 포스팅에서는 Spring 프로젝트에서 로컬 개발 환경과, EC2 프로덕션 환경에 따른 S3 연동 방법 및 기본적인 업로드와 다운로드를 구현해 보았었다. 이번 포스팅에서는 S3 업로드와 다운로드
sjh9708.tistory.com
References
https://docs.aws.amazon.com/
docs.aws.amazon.com
'Backend > AWS' 카테고리의 다른 글
[AWS] S3 활용 : Static Hosting (정적 웹 호스팅) (0) | 2025.02.19 |
---|---|
[AWS + Spring] S3 : 파일 업로드 & 다운로드 (2) (Presigned URL, Multipart Upload) (0) | 2025.02.17 |
[AWS] S3 : 기본 개념과 버킷 설정 (스토리지 클래스, 버킷정책, 버전관리, 수명주기, 암호화) (0) | 2025.02.14 |
[AWS] VPC : 네트워크 연결 방법과 VPC Peering (0) | 2025.02.10 |
[AWS] Private EC2 연결 방법 : Bastion Host & SSM & Instance Connect Endpoint (0) | 2025.02.04 |