[Spring] S3를 통해 이미지 업로드, 이동, 삭제하기
Updated:
I. 개요
기존의 게시글 작성은 단순히 텍스트만으로 이루어졌다. 하지만, 게시판인 이상 적어도 이미지 첨부라면 필수적인 요소라고 생각했다.
처음에는 HTML의 기본 기능만으로 이미지를 구현하고자 하였다. 그러나 작성자가 스스로 올린 사진을 미리 보고 글의 구조를 바꾸는 등의 작업이 필요한데, 이 과정(사진을 선택하면 이를 작성창에 띄워 조작하는 것)이 textarea
에서 동적으로 구현할 수 없어 대부분은 오픈되어있는 다른 editor들을 사용한다는 것을 검색을 통해 알게 되었다.
네이버 에디터도 고민해봤으나, ckeditor
가 아무래도 깔끔하고 편하게 사용할 수 있을 것 같아 선택하게 되었다.
그리고 업로드된 파일들을 관리할 공간이 필요했다. 서버나 데이터베이스 모두 각각의 역할이 너무 불어날 것 같아 배제하게 되었고, 결국 AWS S3
를 이미지저장소로 활용하고자 하였다.
S3
, IAM
의 생성이나 설정 등은 나도 사실 정확히 아는 바가 없이 여러 시행착오 끝에 해결한 부분이 많기 때문에 간단하게 어떤 의도를 가지고 Spring
클래스를 설계했는지만 적어보려 한다!
II. 코드
1. Controller
S3ApiController
@RequiredArgsConstructor
@RestController
public class S3ApiController {
private final S3Service s3Service;
@PostMapping("/api/upload")
public String upload(@LoginUser SessionUser user, MultipartHttpServletRequest multipartFile, HttpServletResponse response) throws IOException {
MultipartFile file = multipartFile.getFile("upload");
String fileName = UUID.randomUUID() + "_" + file.getOriginalFilename();
String fileUrl = s3Service.upload(file, fileName, "temp/" + user.getId().toString() + "/");
PrintWriter printWriter = response.getWriter(); // 서버로 파일 전송 후 이미지 정보 확인을 위해 filename, uploaded, fileUrl 정보를 response 해주어야 함
printWriter.println("{\"filename\" : \"" + fileName + "\", \"uploaded\" : 1, \"url\":\"" + fileUrl + "\"}");
printWriter.flush();
return null;
}
}
해당클래스는 파일을 받는 컨트롤러다. multipartFile
에 사용자가 업로드한 이미지가 딸려온다.
먼저 MultipartFile
의 형태로 업로드한 이미지를 뽑아온다. 그리고 이 파일의 이름을 정해주는데, 나같은 경우는 그 파일의 원래 이름 앞에 UUID
로 생성한 랜덤 ID를 추가로 붙였다.
물론 같은 사람이 업로드한 파일이면 파일이름이 보통 다를테지만, 나는 추후에 이 이미지들을 날짜별로 정리하여 모아놓을 것이기 때문에 혹시모를 중복을 피할 ID가 필요했다.
fileUrl
은 s3service.upload
의 리턴값으로 결정된다. s3service.upload
에는 (MultipartFile, String, String)
을 보내주는데, 이는 뒤에서 따로 알아볼 것이다.
마지막으로 PrintWriter
에 JSON
형식으로 filename
과 uploaded
, url
을 담아 보내는데, 이는 ckeditor
자체적으로 요구하는 Response
이며 이에 맞게 보내줘야 사진을 사용자가 직접 확인하고 수정할 수 있다.
2. Service
S3Service
private AmazonS3 s3Client;
@Value("${cloud.aws.credentials.accessKey}")
private String accessKey;
@Value("${cloud.aws.credentials.secretKey}")
private String secretKey;
@Value("${cloud.aws.s3.bucket}")
private String bucket;
@Value("${cloud.aws.region.static}")
private String region;
@PostConstruct
public void setS3Client() {
AWSCredentials credentials = new BasicAWSCredentials(this.accessKey, this.secretKey);
s3Client = AmazonS3ClientBuilder.standard()
.withCredentials(new AWSStaticCredentialsProvider(credentials))
.withRegion(this.region)
.build();
}
S3
및 IAM
에 대한 설정값들이다. application-s3.properties
에 값들을 넣어놓고 git ignore
에 등록하여 따로 서버에는 올라가지 않도록 해놓았다. (서버에는 별도로 파일을 다시 만들었다.)
아무래도 절대 노출되면 안되는 정보들이다보니 직접적으로 클래스에 명시하지는 않았다.
@PostConstruct
를 통해 의존성 주입이 이루어지는 과정에서 AmazonS3 s3Client
를 초기화할 수 있도록 했다.
S3Service
public String upload(MultipartFile multipartFile, String fileName, String dirName) throws IOException {
File uploadFile = convert(multipartFile).orElseThrow(() -> new IllegalArgumentException("파일변환실패"));
fileName = dirName + fileName;
String uploadImageUrl = putS3(uploadFile, fileName);
removeNewFile(uploadFile);
return uploadImageUrl;
}
private void removeNewFile(File targetFile) {
if(targetFile.delete()) {
log.info("파일이 삭제되었습니다.");
}else {
log.info("파일이 삭제되지 못했습니다.");
}
}
private Optional<File> convert(MultipartFile file) throws IOException {
File convertFile = new File(file.getOriginalFilename());
if(convertFile.createNewFile()) {
try (FileOutputStream fos = new FileOutputStream(convertFile)) {
fos.write(file.getBytes());
}
return Optional.of(convertFile);
}
return Optional.empty();
}
upload
는 말그대로 새로운 파일을 업로드하는 로직을 담당하고 있다.
먼저 convert
메소드를 통해 MultipartFile
형식의 이미지를 File
의 형태로 바꿔준다.
그 후 fileName
을 dirName + fileName
으로 바꾼다.
기존에 upload
를 호출할 때 s3Service.upload(file, fileName, "temp/" + user.getId().toString() + "/");
와 같은 형태로 호출했었으니 실제로 fileName
의 값은
temp/{userId}/{UUID}_{파일명}
의 형태가 된다. 이를 putS3
메서드에 넘겨 실제로 S3
에 업로드하는 과정을 갖게 된다.
참고로 convert
하는 과정에서 로컬파일이 생성되는데, 이제 S3
에 업로드가 완료되었으므로 removeNewFile
메소드를 통해 로컬 파일을 삭제해준다.
S3Service
private String putS3(File uploadFile, String fileName) {
s3Client.putObject(
new PutObjectRequest(bucket, fileName, uploadFile)
.withCannedAcl(CannedAccessControlList.PublicRead)
);
return s3Client.getUrl(bucket, fileName).toString();
}
putS3
은 실제로 S3
에 이미지파일을 업로드하는 역할을 한다.
AmazonS3.putObject
를 통해 파일을 업로드하는데, 이 때 bucket
은 나의 S3저장소
이름, fileName
은 파일을 업로드 할 경로, uploadFile
은 업로드 할 파일을 나타낸다.
위의 경우에는 {나의 S3 주소}/temp/{userId}/{UUID}_{파일명}
의 경로에 파일을 업로드하게 될 것이다.
이 때 .withCannedAcl(CannedAccessControlList.PublicRead)
를 통해 누구나 파일을 읽기
가능하도록 해준다.
그 후 해당 파일의 (실제로 접근할 수 있는) 주소를 리턴해준다.
PostService
public Post parseContextAndMoveImages(Post post) {
Document doc = Jsoup.parse(post.getContext());
String context = post.getContext();
Elements images = doc.getElementsByTag("img");
if (images.size() > 0) {
for (Element image : images) {
String source = image.attr("src");
if (!source.contains("/temp/")) {
continue;
}
source = source.replace("https://ninda-file.s3.ap-northeast-2.amazonaws.com/", "");
String newSource = LocalDate.now().toString() + "/" + source.split("/")[2];
context = context.replace(source, newSource);
s3Service.update(source, newSource);
try {
Photo photo = Photo.builder()
.UUID(newSource.split("/")[1].split("_")[0])
.fileName(URLDecoder.decode(newSource.split("/")[1].split("_")[1], "UTF-8"))
.filePath(newSource.split("/")[0] + "/")
.post(post)
.build();
photoRepository.save(photo);
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
}
}
post.update(post.getTitle(), context);
return post;
}
S3Service
public void update(String oldSource, String newSource) {
try {
oldSource = URLDecoder.decode(oldSource, "UTF-8");
newSource = URLDecoder.decode(newSource, "UTF-8");
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
moveS3(oldSource, newSource);
deleteS3(oldSource);
}
upload
는 파일을 업로드 할 때 호출하는 메소드이고, update
는 게시글을 등록하는 순간 PostService.parseContextAndMoveImages
에 의해 호출되는 메소드다.
간단히 소개하자면, 단순히 S3
입장에서는 사용자가 사진을 업로드했더라도 실제로 게시글을 등록하는 순간에도 이 사진이 사용되었는지는 알 수 없다. 따라서, 해당 게시글의 context
를 파싱해서 사진을 여전히 사용하는지 판단하며 그렇지 않은 사진은 그대로 둔다.(이는 다른 방법을 통해 주기적으로 삭제하고자 한다.)
사용되는 사진이라 판단했을 경우 기존의 temp/{userId}
폴더에서 정식 폴더로 편입시킨다. 사진은 단순히 날짜를 기준으로 한데 모아놓게 된다.
굳이 바로 사진을 날짜별로 올리지 않고 temp를 거치는 이유
- 사용자가 업로드했으나 실제로 사용하지 않기로 한 사진들을 쉽게 정리할 수 있다. 만약 날짜별 폴더에 섞여있다면 다른사람이 업로드한 사진이 섞여 이를 판단하기 어렵게 된다.
이 과정에서 URLDecoder.decode
를 적용하는 부분이 있는데, 이는 context
에 저장된 이미지 소스는 URL
의 형태로 인코딩 된 문자인데 반해 S3
에 저장된 파일은 기존 파일이름 그대로 저장되기 떄문에 이를 매칭시키기 위해서 URL
을 decode하는 과정을 거치도록 했다.
파일을 기존 temp
폴더에서 정식 폴더로 편입시키는 과정은 moveS3
와 deleteS3
를 통해 이루어진다.
S3Service
private void moveS3(String oldSource, String newSource) {
s3Client.copyObject(bucket, oldSource, bucket, newSource);
}
private void deleteS3(String source) {
s3Client.deleteObject(bucket, source);
}
단순히 AmazonS3.copyObject
를 통해 파일을 기존공간에서 새로운 공간으로 복사하고 AmazonS3.deleteObject
를 통해서 기존파일을 삭제하는 과정으로 이루어진다.
PostService
public void parseContextAndDeleteImages(Post post) {
Document doc = Jsoup.parse(post.getContext());
Elements images = doc.getElementsByTag("img");
String source = "";
if (images.size() > 0) {
for (Element image : images) {
try {
source = URLDecoder.decode(image.attr("src").replace("https://ninda-file.s3.ap-northeast-2.amazonaws.com/", ""), "UTF-8");
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
s3Service.delete(source);
}
}
}
게시글을 삭제할 때도 S3
에 저장된 이미지 파일들을 삭제하는게 맞다고 생각했다. 따라서 삭제 직전 context
를 파싱해서 게시글에 포함된 이미지들을 삭제하도록 했다.
III. 마치며
비지니스 로직적인 부분에 있어서는 그렇게 어려운 부분은 없었으나, 사실 S3
와 IAM
을 셋팅하는 부분에서 굉장히 애를 먹었다.
특히 S3
의 파일을 삭제하려 할 때 계속 Access Denied
라는 문구가 나를 괴롭혀 장장 6시간만에 권한을 정리하고 정상적인 파일제어가 가능하게 되었다. 게시글을 작성하기 위해 AmazonS3
의 권한을 사용하는 사람과 단순히 게시글을 읽기 위해 익명의 권한을 사용하는 사람을 제대로 구분하지 못한데에 있어서 생긴 결과인 것 같다.
사실 아직 부족한 점이 있다.
- 업로드되었으나 결과적으로 사용되지 않아
temp
에 남은 파일들에 대한 후처리 - 업로드되었고 정상적으로 사용되어 날짜별 폴더에 편입되었으나 추후 게시글 수정을 통해 사용하지 않게 된 이미지파일에 대한 처리 (굳이 지워버릴 필요가 있나 싶긴 하다.)
이번 프로젝트에 있어서 가장 나를 괴롭게 한 부분이었지 않나 싶다. 아무래도 외부의 인프라적인 부분을 적용하려 하면 너무나도 많은 고생을 하게 되는 것 같다.
그래도…… 뿌듯하다!
Leave a comment