S3 이미지를 업로드 프로세스


  1. 클라이언트에서 이미지를 업로드하면 서버가 이를 수신해 MultipartFile 객체로 처리한다.
  2. MultipartFile을 File 객체로 변환하고 S3 클라이언트를 사용하여 변환된 파일을 S3 버킷에 업로드한다. 여기서 AWS S3 버킷의 이름과 업로드할 파일의 이름을 지정한다.
  3. 업로드된 파일은 S3 버킷에 저장되며, 해당 파일에 접근할 수 있는 URL을 생성하여 클라이언트에게 반환한다. 이 URL은 클라이언트가 파일을 접근하거나 다운로드할 수 있도록 한다. 이 URL을 db에 저장해서 필요할 때 이 URL로 이미지 데이터를 사용할 수 있다.

 

Spring Boot + Gradle 프로젝트 - S3 파일 업로드


✅ build.gradle

implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'

 

✅ application.yml

accessKey와 secretKey를 application.yml에 등록해야 하는데 이 Key는 외부에 노출되서는 안된다!!!!

이 Key를 이용하여 누군가 악용하면 AWS 계정에 비용이 청구될 수 있다.

 

따라서 application.yml 파일에 설정을 추가해 주고, application-private.yml 파일을 생성하고 . gitignore 파일에 등록하여 github에 올라가지 않도록 설정해야 한다.

application.yml

 

application-private.yml

 

.gitignore

✅ S3Config.java

ackage com.example.fitwithme.common;

import com.amazonaws.auth.AWSCredentials;
import com.amazonaws.auth.AWSStaticCredentialsProvider;
import com.amazonaws.auth.BasicAWSCredentials;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.AmazonS3ClientBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class S3Config {
    @Value("${cloud.aws.credentials.accessKey}")
    private String accessKey;
    @Value("${cloud.aws.credentials.secretKey}")
    private String secretKey;
    @Value("${cloud.aws.region.static}")
    private String region;

    @Bean
    public AmazonS3 amazonS3() {
        AWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey);

        return AmazonS3ClientBuilder
                .standard()
                .withCredentials(new AWSStaticCredentialsProvider(credentials))
                .withRegion(region)
                .build();
    }
}

 

✅ S3ImageService.java

package com.example.fitwithme.application.service;

import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.model.CannedAccessControlList;
import com.amazonaws.services.s3.model.DeleteObjectRequest;
import com.amazonaws.services.s3.model.ObjectMetadata;
import com.amazonaws.services.s3.model.PutObjectRequest;
import com.amazonaws.util.IOUtils;
import com.example.fitwithme.common.exception.S3ErrorStatus;
import com.example.fitwithme.common.exception.S3Exception;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLDecoder;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.UUID;

@RequiredArgsConstructor
@Component
public class S3ImageService {
    private final AmazonS3 amazonS3;

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

    public String upload(MultipartFile image) {
        if(image.isEmpty() || Objects.isNull(image.getOriginalFilename())){
            throw new S3Exception(S3ErrorStatus.EMPTY_FILE_EXCEPTION);
        }
        return this.uploadImage(image);
    }

    private String uploadImage(MultipartFile image) {
        this.validateImageFileExtension(image.getOriginalFilename());
        try {
            return this.uploadImageToS3(image);
        } catch (IOException e) {
            throw new S3Exception(S3ErrorStatus.IO_EXCEPTION_ON_IMAGE_UPLOAD);
        }
    }

    private void validateImageFileExtension(String filename) {
        int lastDotIndex = filename.lastIndexOf(".");
        if (lastDotIndex == -1) {
            throw new S3Exception(S3ErrorStatus.NO_FILE_EXTENSION);
        }

        String extension = filename.substring(lastDotIndex + 1).toLowerCase();
        List<String> allowedExtensionList = Arrays.asList("jpg", "jpeg", "png", "gif");

        if (!allowedExtensionList.contains(extension)) {
            throw new S3Exception(S3ErrorStatus.INVALID_FILE_EXTENSION);
        }
    }

    private String uploadImageToS3(MultipartFile image) throws IOException {
        String originalFilename = image.getOriginalFilename(); //원본 파일 명
        String extention = originalFilename.substring(originalFilename.lastIndexOf("."));

        String s3FileName = UUID.randomUUID().toString().substring(0, 10) + originalFilename;

        InputStream is = image.getInputStream();
        byte[] bytes = IOUtils.toByteArray(is);

        ObjectMetadata metadata = new ObjectMetadata();
        metadata.setContentType("image/" + extention);
        metadata.setContentLength(bytes.length);
        ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes);

        try{
            PutObjectRequest putObjectRequest =
                    new PutObjectRequest(bucketName, s3FileName, byteArrayInputStream, metadata)
                            .withCannedAcl(CannedAccessControlList.PublicRead);
            amazonS3.putObject(putObjectRequest);
        }catch (Exception e){
            throw new S3Exception(S3ErrorStatus.PUT_OBJECT_EXCEPTION);
        }finally {
            byteArrayInputStream.close();
            is.close();
        }

        return amazonS3.getUrl(bucketName, s3FileName).toString();
    }

    public void deleteImageFromS3(String imageAddress){
        String key = getKeyFromImageAddress(imageAddress);
        try{
            amazonS3.deleteObject(new DeleteObjectRequest(bucketName, key));
        }catch (Exception e){
            throw new S3Exception(S3ErrorStatus.IO_EXCEPTION_ON_IMAGE_DELETE);
        }
    }

    private String getKeyFromImageAddress(String imageAddress){
        try{
            URL url = new URL(imageAddress);
            String decodingKey = URLDecoder.decode(url.getPath(), "UTF-8");
            return decodingKey.substring(1);
        }catch (MalformedURLException | UnsupportedEncodingException e){
            throw new S3Exception(S3ErrorStatus.IO_EXCEPTION_ON_IMAGE_DELETE);
        }
    }
}

 

🔹upload(MultipartFile image)

넘겨받은 이미지 파일이 비어있는 파일인지 검증한다.

uploadImage를 호출하여 S3에 저장된 이미지 객체의 public url을 반환한다.

 

🔹uploadImage(MultipartFile image)

validateImageFileExtension을 호출하여 확장자 명이 올바른지 확인한다.

uploadImageToS3를 호출하여 이미지를 S3에 업로드하고 업로드된 이미지의 public url을 받아서 서비스 로직에 반환한다.

 

🔹validateImageFileExtension(String filename)

filename을 받아서 파일 확장자가  jpg, jpeg, png, gif 중에 속하는지 검증한다.

 

🔹uploadImageToS3(MultipartFile image)

S3에 이미지를 업로드한다.

private String uploadImageToS3(MultipartFile image) throws IOException {
  String originalFilename = image.getOriginalFilename(); //원본 파일 명
  String extension = originalFilename.substring(originalFilename.lastIndexOf(".")); //확장자 명

  String s3FileName = UUID.randomUUID().toString().substring(0, 10) + originalFilename; //변경된 파일 명

  InputStream is = image.getInputStream();
  byte[] bytes = IOUtils.toByteArray(is); //image를 byte[]로 변환

  ObjectMetadata metadata = new ObjectMetadata(); //metadata 생성
  metadata.setContentType("image/" + extension);
  metadata.setContentLength(bytes.length);
  
  //S3에 요청할 때 사용할 byteInputStream 생성
  ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes); 

  try{
    //S3로 putObject 할 때 사용할 요청 객체
    //생성자 : bucket 이름, 파일 명, byteInputStream, metadata
    PutObjectRequest putObjectRequest =
        new PutObjectRequest(bucketName, s3FileName, byteArrayInputStream, metadata)
            .withCannedAcl(CannedAccessControlList.PublicRead);
            
    //실제로 S3에 이미지 데이터를 넣는 부분이다.
    amazonS3.putObject(putObjectRequest); // put image to S3
  }catch (Exception e){
    throw new S3Exception(ErrorCode.PUT_OBJECT_EXCEPTION);
  }finally {
    byteArrayInputStream.close();
    is.close();
  }

  return amazonS3.getUrl(bucketName, s3FileName).toString();
}

 

🔹deleteImageFromS3(String imageAddress)

이미지의 public url을 이용해서 S3에서 해당 이미지를 삭제한다.

getKeyFromImageAddress를 호출해서 삭제에 필요한 key를 가져온다.

 

🔹getKeyFromImageAddress(String imageAddress)

이미지의 url을 받아와 디코딩한 url을 맨 앞의 '/'를 제거한 key를 반환한다.

 

✅ S3Exception.java

package com.example.fitwithme.common.exception;

public class S3Exception extends RuntimeException{
    private final S3ErrorStatus status;

    public S3Exception(S3ErrorStatus status) {
        super(status.getMessage());
        this.status = status;
    }

    public S3ErrorStatus getStatus() {
        return this.status;
    }
}

 

✅ S3ErrorStatus.java

package com.example.fitwithme.common.exception;

public enum S3ErrorStatus {
    OK("Success"),
    EMPTY_FILE_EXCEPTION("첨부된 이미지가 없습니다."),
    IO_EXCEPTION_ON_IMAGE_UPLOAD("IOException이 발생했습니다."),
    NO_FILE_EXTENSION("잘못된 파일 형식입니다."),
    INVALID_FILE_EXTENSION("이미지 파일 형식이 아닙니다."),
    PUT_OBJECT_EXCEPTION("이미지 업로드에 실패했습니다."),
    IO_EXCEPTION_ON_IMAGE_DELETE("이미지 삭제에 실패했습니다.");

    private final String message;

    S3ErrorStatus(String message) {
        this.message = message;
    }

    public String getMessage() {
        return message;
    }

}

 

✅ UserController.java

@PostMapping("/Upload-profile")
public ResponseEntity<String> profileUpload(@RequestHeader("ACCESS_TOKEN") String accessToken, @RequestPart(value = "image", required = false) MultipartFile image){
    String userId = jwtUtil.getUserIdFromToken(accessToken);
    String profileImage = userService.upload(userId, image);
    return ResponseEntity.ok(profileImage);
}
    
@PutMapping("/update-profile")
public ResponseEntity<String> updateProfile(@RequestHeader("ACCESS_TOKEN") String accessToken, @RequestPart(value = "image", required = false) MultipartFile image){
    String userId = jwtUtil.getUserIdFromToken(accessToken);
    String profileImage = userService.updateProfile(userId, image);
    return ResponseEntity.ok(profileImage);
}

 

✅ UserService.java

@Transactional
public String upload(String userId, MultipartFile image) {
        String profileImage = s3ImageService.upload(image);
        userDao.uploadProfile(userId, profileImage);

        return userId;
    }
    
@Transactional
public String updateProfile(String userId, MultipartFile image) {
    String imageUrl = userDao.findById(userId).imageUrl();
    s3ImageService.deleteImageFromS3(imageUrl);

    String profileImage = s3ImageService.upload(image);
    userDao.uploadProfile(userId, profileImage);

    return userId;
}

 

이미지 업로드 시 s3 ImageService를 이용하여 이미지를 저장한 뒤, 반환받은 public url을 DB에 저장하는 로직으로 처리했다.


☝ S3를 사용해 이미지를 업로드할 경우의 장점

  1. 내구성과 안전성 : AWS의 S3는 데이터의 내구성과 안정성을 보장합니다. 데이터가 여러 복제본에 걸쳐 저장되므로 데이터 손실 우려가 줄어듭니다.
  2. 확장성 : S3는 거의 무한한 확장성을 제공하므로 대용량 데이터를 저장하고 처리할 수 있습니다.
  3. 다양한 데이터 관리 기능 : 버전 관리, 암호화, 액세스 제어 등 다양한 데이터 관리 기능을 제공하여 데이터를 보다 효율적으로 관리할 수 있습니다.
  4. 저렴한 비용 : S3는 사용한 만큼만 비용을 지불하므로 비용을 절감할 수 있습니다.

+ Recent posts