사이드 프로젝트를 진행하면서 AWS S3에 파일을 넣으면 어떻겠냐는 의견이 있어서 AWS도 배워볼 겸 나름대로 찾아보고 공부한 다음 까먹을 까봐 벨로그를 써보기로 결심했다 !
나름 일주일동안 카페에서 머리 박으면서 찾은거라.. 만약 찾아보시는 분이 계시다면 꼭 이게 되시길 바라겠습니다 ^.^
이제 본격적으로
맨 처음으로 build.gradle에 AWS Cloud와 S3 관련 라이브러리를 가져와야 한다.
implementation 'io.awspring.cloud:spring-cloud-aws:3.0.0' // 최신 Spring Cloud AWS
implementation 'software.amazon.awssdk:s3:2.20.0' // AWS SDK S3 의존성
implementation 'org.springframework.boot:spring-boot-starter'
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
중복된게 있긴한데 일단 이걸 넣고 import 해준다.
그리고 본인 AWS 계정에서 S3 버킷을 만든 후 bucket이름, access-key, secret-key를 application.yml파일에 넣어준다.
cloud:
aws:
s3:
bucket: jae~ # S3 버킷 이름을 입력하세요
credentials:
access-key: A~
secret-key: R~/~/~
region:
static: ap-northeast-2
auto: false
stack:
auto: false
라인은 cloud랑 spring이 같은 선상에 두게 하면 된다.
그리고 여기서 제일 중요한게 이 파일을 .gitignore로 설정해야 한다 !!!!!!!!!!!!!
아님 깃허브에 올릴 때 문제가 생길 확률이 매우 기하급수적으로 늘어난다
@Configuration
public class S3Config {
@Value("${cloud.aws.credentials.access-key}")
private String accessKey;
@Value("${cloud.aws.credentials.secret-key}")
private String secretKey;
@Value("${cloud.aws.region.static}")
private String region = "ap-northeast-2"; // 또는 직접 값으로 설정
@Bean
public AmazonS3 amazonS3() {
AWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey);
return AmazonS3ClientBuilder
.standard()
.withCredentials(new AWSStaticCredentialsProvider(credentials))
.withRegion(region)
.build();
}
}
@Slf4j
@RequiredArgsConstructor
@Service
@Transactional
public class S3ImageService {
private final AmazonS3 amazonS3;
@Value("${cloud.aws.s3.bucket}")
private String bucketName;
/**
* 이미지를 업로드하는 메서드
*/
public String upload(MultipartFile image) {
validateImage(image);
return uploadImageToS3(image);
}
/**
* S3에 이미지를 업로드하는 메서드
*/
private String uploadImageToS3(MultipartFile image) {
String originalFilename = image.getOriginalFilename();
String s3FileName = createS3FileName(originalFilename);
try (InputStream is = image.getInputStream()) {
ObjectMetadata metadata = createObjectMetadata(is.available(), originalFilename);
uploadFileToS3(s3FileName, is, metadata);
return getS3FileUrl(s3FileName);
} catch (IOException e) {
log.error("이미지 업로드 중 IO 예외 발생: {}", e.getMessage(), e);
throw new CustomS3Exception(ErrorCode.IO_EXCEPTION_ON_IMAGE_UPLOAD, "이미지 업로드 중 IO 예외 발생.");
}
}
/**
* S3에 업로드된 파일의 URL 생성 메서드
*/
private String getS3FileUrl(String s3FileName) {
return amazonS3.getUrl(bucketName, s3FileName).toString();
}
/**
* S3에 파일을 업로드하는 실제 작업을 수행하는 메서드
*/
private void uploadFileToS3(String s3FileName, InputStream inputStream, ObjectMetadata metadata) {
PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, s3FileName, inputStream, metadata)
.withCannedAcl(CannedAccessControlList.PublicRead);
amazonS3.putObject(putObjectRequest);
}
/**
* 업로드할 이미지의 유효성 검사
*/
private void validateImage(MultipartFile image) {
if (image.isEmpty()) {
throw new CustomS3Exception(ErrorCode.EMPTY_FILE_EXCEPTION, "업로드된 파일이 없습니다.");
}
String originalFilename = image.getOriginalFilename();
if (originalFilename == null) {
throw new CustomS3Exception(ErrorCode.EMPTY_FILE_EXCEPTION, "파일 이름이 없습니다.");
}
FileValidator.validateImageFileExtension(originalFilename);
}
/**
* S3에 업로드할 파일의 이름을 생성하는 메서드
*/
private String createS3FileName(String originalFilename) {
return UUID.randomUUID().toString().substring(0, 10) + originalFilename;
}
/**
* S3에 업로드할 파일의 메타데이터를 생성하는 메서드
*/
private ObjectMetadata createObjectMetadata(int contentLength, String filename) {
String extension = filename.substring(filename.lastIndexOf(".") + 1);
ObjectMetadata metadata = new ObjectMetadata();
if (FileExtension.isValidExtension(extension)) {
metadata.setContentType(FileExtension.valueOf(extension.toUpperCase()).getMimeType());
}
metadata.setContentLength(contentLength);
return metadata;
}
/**
* 이미지 주소를 URL 객체로 변환하는 메서드
*/
private URL createURL(String imageAddress) {
try {
return new URL(imageAddress);
} catch (MalformedURLException e) {
log.error("이미지 주소가 유효하지 않습니다: {}", e.getMessage(), e);
throw new CustomS3Exception(ErrorCode.INVALID_IMAGE_ADDRESS, "이미지 주소가 유효하지 않습니다.");
}
}
/**
* S3에 사용할 키를 디코딩(데이터를 원래 형태로 변환)하는 메서드
*/
private String decodeKey(String path) {
try {
return URLDecoder.decode(path.substring(1), "UTF-8"); // 맨 앞의 '/' 제거
} catch (UnsupportedEncodingException e) {
log.error("이미지 경로 디코딩 중 오류 발생: {}", e.getMessage(), e);
throw new CustomS3Exception(ErrorCode.INVALID_IMAGE_ADDRESS, "이미지 경로 디코딩 중 오류 발생.");
}
}
/**
* 이미지 주소에서 S3 객체키를 추출하는 메서드
*/
private String getKeyFromImageAddress(String imageAddress) {
URL url = createURL(imageAddress);
return decodeKey(url.getPath());
}
/**
* S3에서 이미지 삭제하는 메서드 (개별 삭제)
*/
public void deleteImageFromS3(String imageAddress) {
String key = getKeyFromImageAddress(imageAddress);
deleteS3Object(key);
}
/**
* S3에서 객체를 삭제하는 메서드 (전체 삭제)
*/
private void deleteS3Object(String key) {
try {
amazonS3.deleteObject(new DeleteObjectRequest(bucketName, key));
} catch (AmazonServiceException e) {
log.error("S3에서 이미지 삭제 중 오류 발생: {}", e.getMessage(), e);
throw new CustomS3Exception(ErrorCode.IO_EXCEPTION_ON_IMAGE_UPLOAD, "S3에서 이미지 삭제 중 오류 발생.");
}
}
}
이렇게 두 파일을 만들어 준 후
이제 파일 서비스 클래스를 만들어준다.
@Service
@Transactional
@RequiredArgsConstructor
@Slf4j
@Getter
public class FileService {
private final FileRepository fileRepository;
private final S3ImageService s3ImageService;
//파일 생성
public File createFile(MultipartFile image) {
validateImage(image);
String imageUrl = s3ImageService.upload(image);
File file = new File(image.getOriginalFilename());
file.setStoreFilename(generateFileName(image.getOriginalFilename()));
file.setAccessUrl(imageUrl);
return fileRepository.save(file);
}
//파일 아이디로 조회
public File getFileById(Long id) {
return fileRepository.findById(id)
.orElseThrow(() -> new FileMissingException("파일이 존재하지 않습니다. 파일 아이디 : " + id));
}
// 파일 수정
public File updateFile(Long id, String newFileName, MultipartFile newFile) {
File findFile = getFileById(id);
//S3에서 기존 파일 삭제
s3ImageService.deleteImageFromS3(findFile.getAccessUrl());
// 새로운 파일을 S3에 업로드하고 URL을 가져옴
String newImageUrl = s3ImageService.upload(newFile);
// 파일 정보 업데이트
findFile.setFileName(newFileName); // 새로운 파일 이름으로 설정
findFile.setAccessUrl(newImageUrl); // S3 URL 업데이트
return fileRepository.save(findFile); // 변경된 파일 저장
}
//파일 삭제
public void deleteFile(Long id) {
File findFile = getFileById(id); // 파일 조회
// S3에서 파일 삭제 (필요한 경우)
s3ImageService.deleteImageFromS3(findFile.getS3Url());
// 데이터베이스에서 파일 삭제
fileRepository.delete(findFile);
}
//파일 검증
public void validateImage(MultipartFile image) {
//파일 유효성 검사
if(image==null || image.isEmpty()) {
throw new CustomS3Exception(ErrorCode.EMPTY_FILE_EXCEPTION, "업로드된 파일이 없습니다.");
}
//파일 크기 체크
if(image.getSize()>5*1024*1024) {
throw new CustomS3Exception(ErrorCode.FILE_SIZE_EXCEPTION, "파일 크기는 5MB를 초과할 수 없습니다.");
}
//파일 형식 체크
List<String> allowTypes = List.of("image/jpeg", "image/png", "image/jpg");
if(!allowTypes.contains(image.getContentType())) {
throw new CustomS3Exception(ErrorCode.INVALID_FILE_TYPE_EXCEPTION, "허용되지 않는 파일 형식입니다.");
}
}
private String generateFileName(String originalFilename) {
return UUID.randomUUID() + extractExtension(originalFilename); // 파일 이름 생성
}
private String extractExtension(String originalFilename) {
int index = originalFilename.lastIndexOf('.');
return (index == -1) ? "" : originalFilename.substring(index); // 확장자 추출 또는 빈 문자열 반환
}
//파일 생성 후 마커와 연결하는 메서드 (File 기본 생성자 protected)
public File createFile(String imageUrl, Marker marker) {
File file = new File();
file.setAccessUrl(imageUrl);
file.assignMarker(marker);
fileRepository.save(file);
return file;
}
}
이 후 포스트맨으로 api가 찍히는지 보기 위해 컨트롤러를 작성한다.
@RestController
@RequiredArgsConstructor
//@RequestMapping("/api/files")
public class FileController {
private final FileService fileService;
private final S3ImageService s3ImageService;
// 파일 업로드
@PostMapping("/upload")
public ResponseEntity<File> uploadFile(@RequestParam(value = "image", required = false) MultipartFile image) {
fileService.validateImage(image);
File file = fileService.createFile(image);
return ResponseEntity.ok(file);
}
// 파일 수정
@PutMapping("/update/{id}")
public ResponseEntity<File> updateFile(@PathVariable("id") Long id, @RequestParam("file") MultipartFile newFile) {
// 파일 이름을 가져옵니다.
String newFileName = newFile.getOriginalFilename();
// 파일을 업데이트하고 결과를 가져옵니다.
File updatedFile = fileService.updateFile(id, newFileName, newFile);
// 응답 반환
return ResponseEntity.ok(updatedFile);
}
@GetMapping("/{id}")
public ResponseEntity<File> getFile(@PathVariable("id") Long id) {
File findFile = fileService.getFileById(id);
return ResponseEntity.ok(findFile);
}
// 파일 삭제
@DeleteMapping("/delete/{id}")
public ResponseEntity<Void> deleteFile(@PathVariable("id") Long id) {
fileService.deleteFile(id);
return ResponseEntity.noContent().build();
}
//s3 이미지 삭제
@GetMapping("/s3/delete")
public ResponseEntity<Void> s3Delete(@RequestParam String addr) {
s3ImageService.deleteImageFromS3(addr);
return ResponseEntity.noContent().build();
}
}
이제 AWS 설정에 들어가서
ACL - 퍼블릭 액세스를 해주고,
퍼블릭 액세스 차단을 열어준다
이 후 포스트맨을 켜서 body에 img를 넣고 파일을 보내본다.
200 OK가 뜨면 성공이다 이제 AWS S3에 들어가서 확인을 해준다.
이렇게 들어가면 성공이다 !!!
AWS을 처음써봤는데 확실히 많이 어려웠다 특히 4xx오류가 뜨는데 왜인지 모르겠고 겨우 1주일 걸려서야 퍼블릭 액세스를 다 차단하고 보내야 한다는 것을 깨달았다 IAM계정인데도 ...
암튼 열심히 나름대로 공부하면서 찾아보는 재미를 느꼈다 ~~