[Spring Boot] AWS S3 이미지 업로드

hellonayeon·2021년 11월 25일
9
post-thumbnail

지난 프로젝트때 Flask로 만든 웹 서비스를 EB 환경에 배포했다. 사용자가 업로드한 이미지를 프로젝트 폴더 안에 저장했었는데 EB에 배포할 때마다 이미지가 초기화되는 문제(Github Wiki Link)가 발생했다. 원인은 로컬 개발 환경에서 테스트하며 사용하는 이미지들은 원격 레파지토리에 올라갈때 무시하도록 설정했기 때문에 이미지 폴더가 비워진 상태였고, 배포 시점의 폴더 내용이 배포 환경에 추가되는게 아니라 덮어씌워져서 생기는 문제였다🙄

자동배포 환경에서는 지속적으로 코드를 푸시하기 때문에 이미지를 같은 서버에서 관리하는 것은 어려운 일이다. 따라서 이미지만 저장하는 다른 서버를 두고 사용자가 이미지를 업로드하면 이미지 서버로 업로드시키고 가져와서 보여주도록 서버를 구성해야한다. 따라서 이번 프로젝트에서는 게시물 등록 기능을 구현하면서 S3에 이미지를 업로드하는 로직도 함께 구현했다.

HTML Javascript에서 여러 이미지들을 입력받아 요청을 전송하고 Spring Boot에서 처리하는 코드를 작성했다.


💻 개발환경

org.springframework.boot 2.5.7
├── org.springframework.boot:spring-boot-starter-data-jpa
├── org.springframework.boot:spring-boot-starter-web
└── org.springframework.boot:spring-boot-starter-test

// spring-cloud-starter-aws
org.springframework.cloud:spring-cloud-starter-aws 2.2.1.RELEASE
// lombok
org.projectlombok:lombok 1.18.22

이미지 업로드 요청

이전에는 프론트에서 이미지를 입력받을때 한 개의 이미지만을 받아서 요청에 전송했다. 이미지를 Javascript FormData 객체에 넣어서 전송했었는데 이번에는 여러개의 이미지와 게시물 정보를 함께 어떻게 보내야할지 고민했다. FormDatakey-value 형태로 이미지 리스트와 다른 데이터들을 함께 저장해서 전송했다.

HTML - 이미지 파일 입력

<div>
    <div>
        <div id="image-list" class="modal-dynamic-contents">
      		<!-- dynamic image thumbnail -->
      	</div>
    </div>
    <div>
        <div class="form-group" id="article-image-form">
            <input class="form-control form-control-user modal-dynamic-contents" type="file" 
                   multiple="multiple" name="article-images" id="article-images">
        </div>
    </div>
</div>

Javascript - 입력된 이미지 파일 처리

const MAX_IMAGE_UPLOAD = 10;
let imageFileDict = {};
let imageFileDictKey = 0;

function registerListener() {
    
    ...
    
    // 이미지 파일 입력 리스너
    $('#article-images').on('change', function (e) {
        let files = e.target.files;
        let filesArr = Array.prototype.slice.call(files);

        // 업로드 될 파일 총 개수 검사
        let totalFileCnt = Object.keys(imageFileDict).length + filesArr.length
        if (totalFileCnt > MAX_IMAGE_UPLOAD) {
            alert("이미지는 최대 " + MAX_IMAGE_UPLOAD + "개까지 업로드 가능합니다.");
            return;
        }

        filesArr.forEach(function (file) {
            if (!file.type.match("image.*")) {
                alert("이미지 파일만 업로드 가능합니다.");
                return;
            }

            let reader = new FileReader();
            reader.onload = function (e) {
                imageFileDict[imageFileDictKey] = file;

                let tmpHtml = `<div class="article-image-container" id="image-${imageFileDictKey}">
                                <img src="${e.target.result}" data-file=${file.name} 
                                         class="article-image"/>
                                <div class="article-image-container-middle"token interpolation">${imageFileDictKey++})">
                                    <div class="text">삭제</div>
                                </div>
                           </div>`
                $('#image-list').append(tmpHtml);
            };
            reader.readAsDataURL(file);
        });
    });
}


function removeImage(key) {
    delete imageFileDict[key];
    $(`#image-${key}`).remove();
}

이 코드를 수정하던 중에 files filesArr 길이가 항상 1 인데 왜 굳이 반복문을 돌리나 싶어서 불필요하다 생각한 부분을 제거했었다. 하지만 나는 파일을 한 개씩 업로드 했기 때문에 항상 값이 1 이었다🤣 사용자 폴더에서 업로드할 파일 선택할때 여러개의 파일을 한번에 선택할 수 있고, 그럴 경우 e.target.files 에 여러개의 파일이 들어온다.

Javascript - Ajax 요청

function addArticle() {
    let formData = new FormData();
    formData.append("text", $('#article-textarea').val());
    formData.append("location", $('#article-location-span').text());
    formData.append("hashtagNameList", hashtagNameList);

    Object.keys(imageFileDict).forEach(function (key) {
        formData.append("imageFileList", imageFileDict[key]);
    });

    $.ajax({
        type: 'POST',
        url: `${LOCALHOST}/articles`,
        enctype: 'multipart/form-data',
        cache: false,
        contentType: false,
        processData: false,
        data: formData,
        success: function (response) {
            // TODO: 서버로부터 결과값 받기
            alert("게시물이 성공적으로 등록됐습니다.");

            $('#article-modal').modal('hide');
            showArticles();
        },
        fail: function (err) {
            alert("fail");
        }
    })
}

이미지 업로드 요청 처리

지금까지는 POST 요청에 대해서 컨트롤러 메서드 파라미터 값에 @ResponseBody 어노테이션을 붙여서 DTO 객체로 요청 메시지의 페이로드에 담겨오는 데이터를 받았었다. 하지만 프론트에서 application/json 형식이 아닌 용량이 큰 이미지 미디어가 포함된 multipart/form-data 데이터 형식을 보내주므로 @RequestParma 어노테이션을 사용해서 FormData에 넣었던 데이터들의 이름과 매핑시켜줘야한다.

그리고 이미지를 자바의 어떤 객체로 매핑시켜서 받을까 싶었는데 MultipartFile 객체 형태로 받으면 된다. 이를 처리하려면 MultipartResolver가 필요하다. Spring Boot의 경우 내장되어있지만 기본 Spring MVC 프레임워크를 사용할때는 설정에 직접 추가해줘야한다. 파일 업로드 시 파일 크기 등의 제한할 사항들도 설정할 수 있다.

ArticleController

@RestController
@RequiredArgsConstructor
public class ArticleController {

    private final ArticleService articleService;

    ...

    @PostMapping("/articles")
    public void createArticle(@AuthenticationPrincipal UserDetailsImpl userDetails,
                                @RequestParam("text") String text,
                                @RequestParam("location") String location,
                                @RequestParam("hashtagNameList") List<String> hashtagNameList,
                                @RequestParam("imageFileList") List<MultipartFile> imageFileList) {
        articleService.createArticle(userDetails.getUser(), text, location, hashtagNameList, imageFileList);
    }
}

S3 이미지 업로드

YML 파일 설정

Spring Boot를 실행시킬때 필요한 AWS 관련 속성을 적용해줘야한다. application.properties 만 사용하다가 이미지 업로드 기능을 구현하면서 application.yml 파일을 처음 적용시켜봤는데 가시적으로 보이는 두 확장자의 차이는 구조다. YML 형식의 속성 파일을 사용하면 계층 구조로 정보를 기술할 수 있다. application-*.yml 이름으로 생성한 파일들은 application.yml 파일의 spring.profiles.include 속성 안에 * 값들을 넣어줌으로써 애플리케이션 구동 시 읽어들일 수 있나보다! 와! 🤭

2021-11-24 23:14:52.324  INFO 4979 --- [           main] c.u.backend.BackendApplication           : The following profiles are active: aws,credentials

application-aws.yml

cloud:
  aws:
    s3:
      bucket: [BUCKETNAME]
      folder:
        [VARIABLE]: [VALUE]
    region:
      static: ap-northeast-2
    stack:
      auto: false

application-credentials.yml

cloud:
  aws:
    credentials:
      accessKey: [AWS_ACCESS_KEY_ID]
      secretKey: [AWS_SECRET_ACCESS_KEY]

application.yml

spring:
  profiles:
    include:
      - aws
      - credentials
...

AWS 설정값 컴포넌트 등록

기능마다 등록하는 이미지들을 S3 Bucket에 각각 다른 폴더들의 하위에 저장하기 위해 application-aws.ymlfolder 라는 속성을 하나 더 두었다. folderName: folderName/ 형식으로 폴더 이름에 대한 속성을 추가해줬다. 코드에서 버킷 이름 버킷 내의 폴더 이름을 가져와 사용하기 위해 컴포넌트를 등록했다.

AmazonS3Coponent

@Component
@Getter
public class AmazonS3Component {
    @Value("${cloud.aws.s3.bucket}")
    private String bucket;

    @Value("${cloud.aws.s3.folder.folderName}")
    private String folderName;
}

이미지 파일 처리

Controller 에서 받은 요청 데이터를 바탕으로 Service 단에서 비즈니스 로직을 구현해줘야한다. 필요한 로직은 데이터베이스 저장 S3 이미지 업로드 이다. 데이터베이스 관련해서는 Repository 계층을 이용해서 손쉽게 구현할 수 있지만 S3 관련한 로직은 직접 구현해줘야한다. Spring Boot에서 S3에 이미지를 업로드하는 방법을 찾다가 한 블로그 글에서 파일 업로드와 관련한 클래스를 인터페이스와 구현체 형태로 분리해놓은 방법을 봤다. 지금은 S3에 업로드하는 방식을 사용하고 있지만 다른 클라우드 서비스를 사용하게 된다면 해당 서비스에서 제공하는 라이브러리를 사용해서 파일 처리를 구현해줘야한다. 파일을 업로드하고 삭제하는 메서드는 어떤 파일 서비스를 사용하든 구현해야하는 메서드이기 때문에 인터페이스 형태로 정의해주고, 이를 실제로 구현한 구현체에서는 파일 서비스 라이브러리를 이용해 실질적으로 파일을 처리하는 코드를 작성해주면 된다.

FileService

public interface FileService {
    void uploadFile(InputStream inputStream, ObjectMetadata objectMetadata, String fileName);

    void deleteFile(String fileName);

    String getFileUrl(String fileName);

    String getFileFolder(FileFolder fileFolder);
}

FileFolder

이미지를 사용하는 기능들마다 저장하는 이미지를 폴더를 분리해서 저장하기 위해 버킷에 폴더를 만들어서 사용한다. 그래서 업로드하고자 하는 파일이 어떤 기능인지에 따라 버킷의 경로가 달라지기 때문에 이미지 파일 처리 시에 폴더 경로도 넣어줘야한다. 코드상에 /folderName 형식으로 들어가는 것은 좋이 않아보여 열거형 클래스로 분리하고 ArticleService 단에서 폴더에 대한 열거형 정보인 FileFolderFileProcessService 로 넘겨주면 실제로 AmazonS3Service 에서 파일을 처리할때 FileFolder 정보를 통해 버킷 폴더 를 지정할 수 있도록 구현했다.

📝 새로운 폴더 추가 방법

✔️ [application-aws.yml] 폴더 경로 속성 추가
✔️ [FileFolder] 클래스 열거형 추가 
✔️ [AmazonS3Component] 클래스 멤버변수 추가
✔️ [AmazonS3Service - getFileFolder(FileFolder fileFolder)] 함수 제어문 추가
public enum FileFolder {
    ARTICLE_IMAGES
}

AmazonS3Service

@Component
@RequiredArgsConstructor
public class AmazonS3Service implements FileService {

    private final AmazonS3 amazonS3;
    private final AmazonS3Component amazonS3Component;

    public void uploadFile(InputStream inputStream, ObjectMetadata objectMetadata, String fileName) {
        amazonS3.putObject(new PutObjectRequest(amazonS3Component.getBucket(), fileName, inputStream, objectMetadata).withCannedAcl(CannedAccessControlList.PublicReadWrite));
    }

    public void deleteFile(String fileName) {
        amazonS3.deleteObject(new DeleteObjectRequest(amazonS3Component.getBucket(), fileName));
    }

    // FIXME: Cloud Front URL
    public String getFileUrl(String fileName) {
        return amazonS3.getUrl(amazonS3Component.getBucket(), fileName).toString();
    }

    public String getFileFolder(FileFolder fileFolder) {
        String folder = "";
        if (fileFolder == FileFolder.ARTICLE_IMAGES) {
            folder = amazonS3Component.getArticleImagesFolder();
        }
        return folder;
    }
}

FileProcessService

@Service
@RequiredArgsConstructor
public class FileProcessService {

    private final FileService amazonS3Service;

    public String uploadImage(MultipartFile file, FileFolder fileFolder) {
        String fileName = amazonS3Service.getFileFolder(fileFolder) + createFileName(file.getOriginalFilename());

        ObjectMetadata objectMetadata = new ObjectMetadata();
        objectMetadata.setContentLength(file.getSize());
        objectMetadata.setContentType(file.getContentType());

        try (InputStream inputStream = file.getInputStream()) {
            amazonS3Service.uploadFile(inputStream, objectMetadata, fileName);
        } catch (IOException ioe) {
            throw new IllegalArgumentException(String.format("파일 변환 중 에러가 발생했습니다 (%s)", file.getOriginalFilename()));
        }

        return amazonS3Service.getFileUrl(fileName);
    }

    private String createFileName(String originalFileName) {
        return UUID.randomUUID().toString().concat(getFileExtension(originalFileName));
    }

    private String getFileExtension(String fileName) {
        return fileName.substring(fileName.lastIndexOf("."));
    }

    public void deleteImage(String url) {
        amazonS3Service.deleteFile(getFileName(url));
    }

    private String getFileName(String url) {
        String[] paths = url.split("/");
        return paths[paths.length-2] + "/" + paths[paths.length-1];
    }
}

ArticleService

@Service
@RequiredArgsConstructor
public class ArticleService {

    ...

    private final FileProcessService fileProcessService;

    ...
    
    @Transactional
    public void createArticle(User user, String text, String location, List<String> hashtagNameList, List<MultipartFile> imageFileList) {
        Article article = articleRepository.save(new Article(text, location, user));

        for(String tag : hashtagNameList) {
            hashtagRepository.save(new Hashtag(tag, article, user.getId()));
        }

        for(MultipartFile multipartFile : imageFileList) {
            String url = fileProcessService.uploadImage(multipartFile, FileFolder.ARTICLE_IMAGES);
            imageRepository.save(new Image(url, article));
        }
    }
}

생각정리

Spring Boot + S3 이미지 업로드 방법을 찾으면서 이미지 파일 요청을 어떻게 보내고 어떤 객체로 받아야할지 처음 접해서 낯설었다. 그리고 많은 방법들이 S3에 이미지를 업로드하기 위해 서버에 임시 파일을 만들었다가 지우는 과정을 밟았다. 굳이 파일을 만들었다 지워야하나 싶어서 찾아보던 중에 InputStreamObjectMetatdata 를 이용하면 MultipartFile ➡️ File 과정을 거치지 않고도 MultipartFile에 있는 파일에 대한 정보와 InputStream을 이용해서 얻은 MultipartFile 자체에 저장된 파일의 바이트 정보를 넘겨주어 파일을 업로드하는 방법을 알게됐다.

참고문서

📌 seungh0. "Spring Boot를 이용한 AWS S3에 파일 업로드하기", will.log, 29 Aug 2021.

📌 신매력. "[SpringBoot / JSP] 여러 이미지 파일 업로드 서버에 전송하기", Take Action, 27 Sep 2021.

📌 다한. "javascript로 이미지 업로드 및 미리보기 기능 구현하는 방법", 다한의 웹 블로그, 14 Jun 2020.

4개의 댓글

comment-user-thumbnail
2021년 11월 25일

서버부터 웹, 그리고 정리까지.. 👍👍

1개의 답글
comment-user-thumbnail
2021년 11월 25일

잘 봣습니다:)

1개의 답글