DevOps/AWS

AWS S3 + Vue.js + SpringBoot(2/3) - S3와 프론트, 서버 연동

Code Maestro 2023. 1. 5. 15:59
728x90

※ 참고로 이 포스팅은 기본적인 axios와 api 연동에 관한 설명을 다루지 않습니다. 일반 데이터와 이미지 파일들이 뭐가 다른지 중점적으로 설명합니다. 기본적인 Vue.js와 spring boot 연동에 관해서는 다른 글들을 참고하시길 바랍니다.

 

1. 전체적인 플로우

  1. Vue.js에서 MultipartFile 형식의 이미지를 서버에 전송
  2. 서버는 이미지 파일 확장자 확인
  3. 파일 이름을 토대로 중복이 안 되게끔 UUID로 지정
  4. S3 bucket에 파일 업로드
  5. DB에 키 값 저장

 

 

그냥 설명만 하면 재미가 없기에 제가 만든 프로젝트를 이용해 설명하겠습니다. 강남병원의 섬네일과 전체적인 사진들을 올려보겠습니다.

 

등록된 사진이 아무것도 없습니다.

 

 

썸네일 이미지 파일을 받아오는 JSON 형태는 아래처럼 가정하겠습니다. 

{
    "imageFile": 섬네일 이미지 //MultipartFile 타입
    "hospitalId": 병원 아이디(pk) //Long 타입 
}

 

병원 내부 사진들은 아래의 JSON 형식과 같습니다.

 

{
    "imageFiles": 병원 내부사진들 //List<MultipartFile> 타입
    "hospitalId": 병원 id(PK) //Long 타입
}

 

이미지 파일을 전송하는 데 있어서 반드시 key의 이름과 value값의 타입을 일치시키는 게 중요합니다!

 

2. DB

S3 스토리지에 있는 이미지 파일들을 불러오려면 image 고유한 key값들을 DB에 저장해야 합니다. 그리고 프론트에서 DB에 저장된 imageKey들을 조회해야 합니다.  

HospitalThumbnail은 썸네일, HospitalImage는 내부 병원 사진들입니다.

 

 

package site.hospital.domain;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.FetchType;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.OneToOne;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import site.hospital.domain.hospital.Hospital;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class HospitalThumbnail {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "hospitalThumbnail_id")
    private long id;

    private String originalName;

    @Column(unique = true, nullable = false)
    private String imageKey;

    @OneToOne(mappedBy = "hospitalThumbnail", fetch = FetchType.LAZY)
    private Hospital hospital;

    @Builder
    public HospitalThumbnail(String originalName, String imageKey) {
        this.originalName = originalName;
        this.imageKey = imageKey;
    }
    
}

 

 

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class HospitalImage extends BaseEntity {

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "hospital_id")
    Hospital hospital;
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "hospital_image_id")
    private Long id;
    @Column(unique = true, nullable = false)
    private String imageKey;

    private String originalName;

    @Builder
    public HospitalImage(String originalName, String imageKey, Hospital hospital) {
        this.originalName = originalName;
        this.imageKey = imageKey;
        this.hospital = hospital;
    }

}

 

JPA에서 도메인을 위의 코드처럼 설정했습니다. 

 

만약 JPA를 사용하지 않아도 꼭 DB 테이블 설계와 imageKey를 고려해야 합니다. 

 

3. Vue.js

1) 단일 이미지 파일

(참고: 저는 관리자 계정을 따로 만들었기에 관리자 계정에서만 이미지 등록 페이지에 들어갈 수 있습니다.)

 

#HTML
	<form @submit.prevent="submitForm">
                 <!-- 이미지 업로드 -->
                 <div v-if="hospitalImage==''" class="image-dropper">
                    이미지 파일을 올려주세요
                    <input @change ='onInputImage' ref="imageInput" type="file" />
                 </div>

                <!-- 이미지 업로드가 된 후, 미리보기 -->
                <div v-else class = "file-upload-wrapper">
                
                        <div class="image-preview-container">
                            <div class="image-preview-wrapper">
                                <!-- 이미지 닫기-->
                                <div class="image-delete-button" @click="imageDeleteButton">
                                               x
                                </div>
                                <!--이미지 미리보기-->
                                <img :src="uploadImage" />
                            </div>
                        </div>
                </div>

                <button class ="image-button" type="submit">등록</button>
            </form>
<script>
import {staffCreateThumbnail} from '@/api/staff';
import {staffViewThumbnail, staffDeleteThumbnail} from '@/api/staff';
export default {
    data() {
        return {
            hospitalImage: '',
            thumnailId: '',
            uploadImage:'',
            imageKey:'',
            imageUrl:'http://d123wf46onsgyf.cloudfront.net/w140/',
            thumbnailId:'',
        }
    },
    methods:{
        //이미지 등록 버튼
        onInputImage(event){
            this.hospitalImage = this.$refs.imageInput.files[0];

            //이미지 미리보기
            let input = event.target;
            let reader = new FileReader(); 
            reader.onload = (e) => { this.uploadImage = e.target.result; } 
            reader.readAsDataURL(input.files[0]);
        },
}
</script>

파일을 업로드하면 @change로 인해 onInputImage 함수가 실행이 됩니다. $refs.imageInput.files[0]으로 업로드한 이미지 파일을 할당할 수 있습니다. 

 

이미지를 전송할 때 가장 핵심인 것은 FormData입니다. 

        //이미지 전송 버튼
        async submitForm(){
            const data = new FormData();
            data.append("imageFile",this.hospitalImage);
            data.append("hospitalId",this.$route.query.hospitalId);
            

            const URL = await staffCreateThumbnail(data);

            //페이지 이동
            this.$router.push(`/staff/view/hospital`);
        },

FormData는 일련의 key/value 쌍을 쉽게 생성할 수 있습니다. FormData 객체를 생성해주고, append로 데이터를 추가해 axios로 서버에 전송할 수 있습니다.

 

※ 자세한 코드는 깃허브 링크 참조하시면 됩니다. 

 

 

 

2) 다수의 이미지 파일

#HTML
	<form @submit.prevent="submitForm" enctype ="multipart/form-data">
         <!-- 이미지 업로드 -->
         <div v-if="!hospitalImages.length" class="imageUpload__image-dropper">
            이미지 파일을 올려주세요
            <input multiple @change ='onInputImage' ref="imageInput" type="file" />
         </div>

        <!-- 이미지 업로드가 된 후, 미리보기 -->
        <div v-else class = "imageUpload__file-upload-wrapper">

                <div class="imageUpload__image-preview-container">
                    <div v-for="(file, index) in hospitalImages" :key="index" class="image-preview-wrapper">
                        <!-- 이미지 닫기-->
                        <div class="imageUpload__image-delete-button" @click="imageDeleteButton" :name="file.number">
                                       x
                        </div>
                        <!--이미지 미리보기-->
                        <img :src="file.preview" />
                    </div>

                    <div class="imageUpload__image-preview-wrapper-upload">
                        <input @change ='imageAddUpload' ref="imageInput" type="file" />
                    </div>
                </div>
        </div>
        <button class ="imageUpload__image-button" type="submit">등록</button>
    </form>

이전과 동일하지만 가장 큰 차이가 있습니다. form 태그의 multipart/form-data입니다. 단일 이미지와 달리 다수 이미지 형태의 이미지를 보내려면 통신할 때 헤더 multipart/form-data가 추가되어야 합니다. 

 

Content-Type은 HTTP 헤더에 있으며 표현 데이터의 형식을 의미합니다. multipart/form-data는 모든 문자를 인코딩하지 않음을 명시하고, 파일이나 이미지를 서버로 전송할 때 사용합니다. Body에 들어가는 데이터의 타입을 HTTP Header에 명시해 줌으로써 서버가 타입에 따라 알맞게 처리하게끔 해줍니다. 

 

        //이미지 등록 버튼
        onInputImage(){
            this.images = this.$refs.imageInput.files;

            let num = -1;
            for(let i = 0; i<this.$refs.imageInput.files.length; i++){
                this.hospitalImages = [
                    ...this.hospitalImages,
                    {
                        file: this.$refs.imageInput.files[i],
                        //이미지 미리보기
                        preview: URL.createObjectURL(this.$refs.imageInput.files[i]),
                        //관리 인덱스 값
                        number: i
                    }
                ];
                num = i;
            }
            this.imageIndex = num +1;// 이미지 index 마지막 값 +1 저장.
        },

이전 단일 이미지 this.$refs.imageInput.files[0]와 달리 this.$refs.imageInput.files로 받아왔습니다. 

 

이미지 파일들을 ArrayList 타입으로 맞춰주기 위해 

        //이미지 등록 버튼
        async submitForm(){
            const data = new FormData();

            for(let i=0; i<this.hospitalImages.length;i++){
                data.append("imageFiles",this.hospitalImages[i].file);
            }
            data.append("hospitalId",this.$route.query.hospitalId);
            await staffCreateHospitalImage(data);
            this.$router.go();
        },

for 문으로 이미지 데이터를 계속 추가해줬습니다.

 

 

헤더의 Content-Type 설정. 그리고 변수명과 데이터 형태가 일치한다면 서버에 정상적으로 전송이 됩니다. 

 

 

※ 자세한 코드는 깃허브 링크 참조

 

 

4. SPRING BOOT

이제 프론트에서 이미지 파일을 서버에 전송했으니, 서버는 파일이 정상적인 이미지 파일인지 확인합니다.  그리고 파일명을 토대로 이름이 중복되지 않게 UUID로 지정. S3 bucket에 파일을 업로드하고, DB에 고유한 키 값을 저장하면 됩니다. 

 

프론트에서 보내온 데이터 형식에 맞게끔 서버도 맞춰줘야겠죠?

    //단일 이미지 파일(섬네일 등록)

    @PostMapping("/staff/hospital/register/thumbnail")
    public String staffRegisterThumbnail(
            @RequestParam(value = "imageFile", required = false) MultipartFile imageFile,
            @RequestParam(value = "hospitalId", required = false) Long hospitalId)
            throws IOException {
        String ImageURL = imageManagementService
                .thumbnailUpload(imageFile, "thumbnail", hospitalId);

        return ImageURL;
    }
    //다수의 이미지(병원 내부 사진)

    @PostMapping("/admin/hospital/register/images")
    public List<String> adminRegisterHospitalImages(
            @RequestParam(value = "imageFiles", required = false) List<MultipartFile> imageFiles,
            @RequestParam(value = "hospitalId", required = false) Long hospitalId)
            throws IOException {
        List<String> ImageURLS = imageManagementService
                .hospitalImageUpload(imageFiles, "hospitalImage", hospitalId);

        return ImageURLS;
    }

여기서 가장 중요한 점은 @RequestParam입니다.

 

제가 처음에 @RequestBody로 받다가 정상적으로 이미지 파일들이 등록이 안 돼서 크게 헤맸었습니다.  @RequestBody는 body로 전달받은 JSON 형태의 데이터를 파싱을 합니다. 반면 Content-Type이 multipart/form-data로 전달되어 올 때는 Exception을 발생시켜 문제가 됩니다.

Content-Type이 multipart/form-data인 경우. @RequestPart 혹은 @RequestParam을 사용하시면 정상적으로 받을 수 있습니다. 만약 데이터가 엄청 복잡하다면 @ModelAttribute을 추천드립니다. 

 

 

받아온 파일들을 이미지 파일인지 확인하고, 버킷에 전송하겠습니다.

 

@RequiredArgsConstructor
@Component
public class ImageManagementService {

    private final AmazonS3Client amazonS3Client;
    private final HospitalRepository hospitalRepository;
    private final HospitalThumbnailRepository hospitalThumbnailRepository;
    private final HospitalImageRepository hospitalImageRepository;
    
    // S3 버킷 이름
    @Value("${cloud.aws.s3.bucket}")
    public String bucket;
    
    //섬네일 파일 업로드
    public String thumbnailUpload(MultipartFile multipartFile, String dirName, Long hospitalId)
            throws IOException {
        File uploadFile = convert(multipartFile)  // 파일 변환할 수 없으면 에러
                .orElseThrow(() -> new IllegalArgumentException(
                        "error: MultipartFile -> File convert fail"));

        return thumbnailUpload(uploadFile, dirName, hospitalId);
    }
    
    //병원 이미지 파일 업로드
    public List<String> hospitalImageUpload(List<MultipartFile> multiparFile, String dirName,
            Long hospitalId) throws IOException {
        List<File> uploadFile = multipleConvert(multiparFile);

        return hospitalsImageUpload(uploadFile, dirName, hospitalId);
    }
    
 }

먼저 amzaonS3client를 주입합니다. @Value로 yml 파일에 등록한 버킷 이름을 선언합니다.

 

이미지 파일들을 받고 로컬에 파일을 업로드합니다. 

 

    // 로컬에 파일 업로드 하기
    private Optional<File> convert(MultipartFile file) throws IOException {
        File convertFile = new File(
                System.getProperty("user.dir") + "/" + file.getOriginalFilename());
        if (convertFile.createNewFile()) { // 바로 위에서 지정한 경로에 File이 생성됨 (경로가 잘못되었다면 생성 불가능)
            try (FileOutputStream fos = new FileOutputStream(
                    convertFile)) { // FileOutputStream 데이터를 파일에 바이트 스트림으로 저장하기 위함
                fos.write(file.getBytes());
            }
            return Optional.of(convertFile);
        }
        return Optional.empty();
    }

    // 로컬에 다중 파일 업로드 하기
    private List<File> multipleConvert(List<MultipartFile> files) throws IOException {
        List<File> convertFiles = new ArrayList<>();

        for (MultipartFile file : files) {
            File convertFile = new File(
                    System.getProperty("user.dir") + "/" + file.getOriginalFilename());
            if (convertFile.createNewFile()) { // 바로 위에서 지정한 경로에 File이 생성됨 (경로가 잘못되었다면 생성 불가능)
                try (FileOutputStream fos = new FileOutputStream(
                        convertFile)) { // FileOutputStream 데이터를 파일에 바이트 스트림으로 저장하기 위함
                    fos.write(file.getBytes());
                }
                convertFiles.add(convertFile);
            }
        }

        return convertFiles;
    }

로컬에 파일이 올라와야 S3에 파일을 업로드할 수 있습니다. 

    // S3로 섬네일 파일 업로드하기
    private String thumbnailUpload(File uploadFile, String dirName, Long hospitalId) {

        //확장자
        String uploadName = uploadFile.getName();
        String extension = uploadName.substring(uploadName.lastIndexOf(".") + 1);
        extension = extension.toLowerCase();

        //이미지 파일 확장자가 아닌 경우 exception 발생.
        if (!extension.equals("bmp") && !extension.equals("rle") && !extension.equals("dib")
                && !extension.equals("jpeg") && !extension.equals("jpg")
                && !extension.equals("png") && !extension.equals("gif")
                && !extension.equals("jfif") && !extension.equals("tif")
                && !extension.equals("tiff") && !extension
                .equals("raw")) {
            throw new IllegalStateException("이미지 확장자가 아닙니다.");
        }

        String fileName = dirName + "/" + UUID.randomUUID() + "." + extension; // S3에 저장된 파일 이름
        String uploadImageUrl = putS3(uploadFile, fileName); // s3로 업로드
        removeNewFile(uploadFile);

        String key = fileName.replace(dirName + "/", ""); // 키 값 저장.

        //DB에 정보 저장.
        HospitalThumbnail hospitalThumbnail = HospitalThumbnail.builder()
                .originalName(uploadFile.getName()) // 파일 원본 이름
                .imageKey(key).build();

        registerHospitalThumbnail(hospitalThumbnail, hospitalId);

        return uploadImageUrl;
    }

파일들이 이미지 파일인지 확인하고, UUID를 사용해 파일명을 고유의 값으로 변환합니다.

 

putS3 함수로 이미지를 S3에 업로드하고 removeNewFile 함수로 로컬에 업로드된 파일을 삭제했습니다.

 

이전에 UUID로 변환한 중복 없는 파일 키 값들을 DB에 저장합니다. 이는 프론트엔드에서 imageURL을 불러올 때 사용하기 위해서입니다.  

 

지금까지 서버의 역할을 요약하면 1) 이미지 파일이 맞는지 확인, 2) 고유의 키 값을 DB에 저장하고, 3) S3 버킷에 이미지 파일을 등록했습니다. 

 

프론트 엔드와 서버의 코드를 모두 작성했으니 이제 정상적으로 S3 버킷에 이미지 파일이 등록됐는지 확인하겠습니다. 

 

※ 자세한 코드는 깃허브 링크 참조.

 

 

5. S3 이미지 저장 & URL 불러오기 

Vue.js에서 이미지를 등록하면 다음과 같은 결과가 나옵니다.

병원 내부 사진 등록

기존 DB에 저장된 image_key값들이 저장됩니다.  

 

 

S3 버킷에 해당 이미지들이 저장됩니다. 

 

객체 URL을 눌러보면 

S3에 이미지가 정상적으로 등록된 걸 알 수가 있습니다. 저장은 정상적으로 됐으니 S3에 저장된 이미지 파일들을 불러오겠습니다. 

 

S3에 저장된 이미지들을 프론트에서 어떻게 불러오면 될까요?

 

서버에 저장한 DB의 고유한 image key값들을 불러오면 됩니다. 정확히 말하면 Hospital 객체를 불러올 때 2번 문단에서 설계한 HospitalImage와 HospitalThumbnail의 imageKey 값들을 Join 해서 같이 불러오면 됩니다.

(이 글은 이미지만 다루기에 조회 쿼리와 이미지 Key값 조회 방법은 생략하겠습니다.)

 

S3 bucket에 저장된 이미지들을 불려 오려면

 

 

S3에 저장된 객체 URL을 불려 와야 합니다. 위의 URL을 설명하면 https://hospital-image-upload.s3.ap-northeast-2.amazonaws.com/thumbnail/08376e9c-931f-4598-9593-c27e4eb2668b.png

 

  • 검은색: S3 URL
  • 주황색: 이미지 파일을 저장한 폴더 이름 (이전 포스팅은 raw로 설정해서 raw일 겁니다. 저는 둘이 다른 파일임을 표시하기에 thumbnail로 설정했습니다. )
  • 갈색: 고유한 imageKey값.

 

위의 이미지를 vue에서 불려 오려면 

 

<img alt="thumbnail" class="image__thumbnail" 
:src='`https://hospital-image-upload.s3.ap-northeast-2.amazonaws.com/thumbnail/${contentItem.imageKey}`'/>

위의 코드처럼 설정하면 됩니다. 여기서 ${contentItem.imageKey}는 DB의 이미지 키이니 contentItem.imageKey가 아닌 여러분이 설정한 DB에서 조회한 image key를 넣으시면 됩니다. 

 

 

 

위의 과정들을 모두 거치면 

 

 

이전에 사진이 없던 것과 달리 썸네일이 정상적으로 등록됐고 병원 내부 사진들이 보이는 걸 확인할 수 있습니다. 

 

 

6. 마무리

위의 내용들을 정리하면 아래와 같습니다. 

 

( 프론트에서 MultipartFile 형식의 이미지를 서버에 전송 => 서버는 이미지 파일을 받아오고 확장자 확인 => imageKey 값을 DB에 저장 => S3 버킷에 저장 => DB에 저장된 이미지 키값들을 프론트에서 불러와서 S3에 저장된 URL 불러오기 )

 

이렇게 전체적인 흐름을 설명했습니다. 근데 여기서도 문제가 있습니다. 

 

 

무수한 썸네일 이미지들이 있는데 원본 크기로 저장했다면 브라우저 로딩이 과연 빠를까요? 로딩을 빠르게 하려면 어떻게 해야 할까요?

 

CDN과 lambda를 활용한 이미지 resizing은 3편에서 소개하겠습니다. 

728x90