[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가 필요했다.

fileUrls3service.upload의 리턴값으로 결정된다. s3service.upload에는 (MultipartFile, String, String)을 보내주는데, 이는 뒤에서 따로 알아볼 것이다.

마지막으로 PrintWriterJSON형식으로 filenameuploaded, 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();
}

S3IAM에 대한 설정값들이다. 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의 형태로 바꿔준다.

그 후 fileNamedirName + 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폴더에서 정식 폴더로 편입시키는 과정은 moveS3deleteS3를 통해 이루어진다.

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. 마치며

비지니스 로직적인 부분에 있어서는 그렇게 어려운 부분은 없었으나, 사실 S3IAM을 셋팅하는 부분에서 굉장히 애를 먹었다.

특히 S3의 파일을 삭제하려 할 때 계속 Access Denied라는 문구가 나를 괴롭혀 장장 6시간만에 권한을 정리하고 정상적인 파일제어가 가능하게 되었다. 게시글을 작성하기 위해 AmazonS3의 권한을 사용하는 사람과 단순히 게시글을 읽기 위해 익명의 권한을 사용하는 사람을 제대로 구분하지 못한데에 있어서 생긴 결과인 것 같다.

사실 아직 부족한 점이 있다.

  1. 업로드되었으나 결과적으로 사용되지 않아 temp에 남은 파일들에 대한 후처리
  2. 업로드되었고 정상적으로 사용되어 날짜별 폴더에 편입되었으나 추후 게시글 수정을 통해 사용하지 않게 된 이미지파일에 대한 처리 (굳이 지워버릴 필요가 있나 싶긴 하다.)

이번 프로젝트에 있어서 가장 나를 괴롭게 한 부분이었지 않나 싶다. 아무래도 외부의 인프라적인 부분을 적용하려 하면 너무나도 많은 고생을 하게 되는 것 같다.

그래도…… 뿌듯하다!

Leave a comment