Backend Dev./Project Impl

Spring Boot 개발 노트: S3를 이용해 프로젝트에 이미지관련 CRUD 구현 2편 - Spring Boot 설정

문괜 2023. 12. 13. 10:00
반응형

 

S3를 이용해 프로젝트에 이미지 관련 CRUD 추가하기 - S3 & IAM 설정

 

Spring Boot 개발 노트: S3를 이용해 프로젝트에 이미지관련 CRUD 추가하기 - S3 & IAM 설정

많은 프로젝트에서 이미지가 필요하다. 그런 이미지를 저장하고, 읽어오고, 수정하고 마지막으로 삭제하기 위해서는 어떻게 해야 할까? 생각해봐야 하는 것들 먼저 이미지가 어떻게 저장되는지

youcanbeable.tistory.com

 

저번 편을 통해서 현재 프로젝트가 이미지를 처리하기 위해 아래와 같이 준비 됐다. 

  1. 이미지가 실제로 담길 저장소
  2. 이미지가 담긴 저장소를 쓸 권한
  3. 프로젝트와 저장소 연결
  4. 프로젝트 내의 이미지 관련 요청 처리 구현

그럼 여기서부터 프로젝트의 이미지 처리를 위한 프로젝트 설정과 요청처리를 구현해 보겠다.

 

프로젝트와 저장소 연결

프로젝트와 저장소를 연결하기 위해 먼저 '설정'을 해줘야 한다.

현재 프로젝트 Directory 구성이고 아래와 같다.

WiiYouBackend(Root)
 src
  main
   java
    com
     willyoubackend
      domain
      global
       config
        S3Config.java(프로젝트를 위한 S3 설정)
       dto
       entity
       exception
       uitl
        S3Uploader.java(S3에 CRUD관련 함수)
       validation
      WillYouBackendApplication.java
   resources
    application.properties
    application-aws.properties(AWS 관련 Seceret Key)
    application-local.properies
 build.gradle
 .gitignore

 위와 같이 설정한 이유는 기능별 Scope를 기준으로 나눈 것이다.

 

먼저 build.gradle에 아래의 내용을 추가해 준다.

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

 

그리고 본격적으로 시작하기 전에 아래의 내용을 참고해 보자.

그다음은 S3Config.java다. 이 클래스 안에서 내가 어떤 저장소를 쓰고 내가 그에 대한 권한이 있다는 걸 지정해준다. 

 

그다음 아래의 내용을 수정한 properties 파일에 입력해줘야 한다.

# S3
cloud.aws.stack.auto=false
cloud.aws.region.static={지역 예 ap-northeast-2} 
cloud.aws.credentials.access-key={엑세스 키 access}
cloud.aws.credentials.secret-key={시크릿 키 secret}
cloud.aws.s3.bucket={aws에 생성한 Bucket이름}
spring.servlet.multipart.max-file-size=20MB
spring.servlet.multipart.max-request-size=20MB

 

access key와 secret key의 경우 저번에 다운로드 한 IAM의 Access Key, Secret Key 페어에서 찾을 수 있다.(아래의 내용을 CSV 파일을 열어보면 찾을 수 있다.)

 

!! 계속해서 강조하지만 이 key 둘 중 하나라도 올라가거나 공유되면 진짜 큰일 난다. 정말 진지하게 보이스 피싱에 계좌번호랑 비밀번호 알려주는 거 와 같다. 즉, 뭐 하나라도 알려줘도 위험하다는 거다. 어떤 사람들이 ACCESS KEY는 괜찮아하는데 진짜 모아 놓고 교육해야 한다!!

 

 

위의 내용이 하나라도 안 맞으면 작동이 안 되니 잘 써야 한다.

그리고 밑에 spring.servlet.multipart.max-file-size=20MB, spring.servlet.multipart.max-request-size=20MB의 경우 요청 보내고 받을 수 있는 최대 사이즈를 정하는 것이다. 그런데 추후에 Nginx에서 따로 설정을 해줘야 한다.

 

그리고 아래의 내용을 추가해 프로젝트의 S3설정을 마무리해 준다.

S3Config.java

import com.amazonaws.auth.AWSStaticCredentialsProvider;
import com.amazonaws.auth.BasicAWSCredentials;
import com.amazonaws.services.s3.AmazonS3Client;
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.access-key}")
    private String accessKey;
    @Value("${cloud.aws.credentials.secret-key}")
    private String secretKey;
    @Value("${cloud.aws.region.static}")
    private String region;

    @Bean
    public AmazonS3Client amazonS3Client() {
        BasicAWSCredentials awsCred = new BasicAWSCredentials(accessKey, secretKey);
        return (AmazonS3Client) AmazonS3ClientBuilder.standard()
                .withRegion(region)
                .withCredentials(new AWSStaticCredentialsProvider(awsCred))
                .build();
    }
}

 

드디어 다 왔다 이제 이미지 관련 CRUD를 마무리하면 우리의 프로젝트에서 이미지를 처리할 수 있다.

프로젝트 내의 이미지 관련 요청 처리 구현

먼저 전체 코드는 아래와 같다. 그리고 그냥 복붙 했다가는 안될 가능성이 있다 왜냐하면 delete가 다른 코드들과는 다를 수 있기 때문이다. 

S3Uploader.java

package com.willyoubackend.global.util;


import com.amazonaws.services.s3.AmazonS3Client;
import com.amazonaws.services.s3.model.CannedAccessControlList;
import com.amazonaws.services.s3.model.PutObjectRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.Optional;

@Slf4j
@RequiredArgsConstructor
@Component
@Service
public class S3Uploader {
    private final AmazonS3Client amazonS3Client;

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

    public String upload(MultipartFile multipartFile, String dirName) throws IOException {
        File uploadFile = convert(multipartFile)
                .orElseThrow(() -> new IllegalArgumentException("MultipartFile -> File 전환 실패"));
        return upload(uploadFile, dirName);
    }

    private String upload(File uploadFile, String dirName) {
        log.info(uploadFile.getName());
        String fileName = dirName + "/" + uploadFile.getName();
        String uploadImageUrl = putS3(uploadFile, fileName);
        removeNewFile(uploadFile);
        return uploadImageUrl;
    }

    private String putS3(File uploadFile, String fileName) {
        amazonS3Client.putObject(
                new PutObjectRequest(bucket, fileName, uploadFile)
                        .withCannedAcl(CannedAccessControlList.PublicRead)
        );
        return amazonS3Client.getUrl(bucket, fileName).toString();
    }

    private void removeNewFile(File targetFile) {
        if (targetFile.delete()) {
            log.info("파일이 삭제되었습니다.");
        } else {
            log.info("파일이 삭제되지 못했습니다.");
        }
    }

    private Optional<File> convert(MultipartFile file) throws IOException {
        if (imageFormatCheck(file)) {
            BufferedImage heicBufferedImage = ImageIO.read(new ByteArrayInputStream(file.getBytes()));
            BufferedImage jpgBufferedImage = new BufferedImage(heicBufferedImage.getWidth(), heicBufferedImage.getHeight(), BufferedImage.TYPE_INT_RGB);
            jpgBufferedImage.createGraphics().drawImage(heicBufferedImage, 0, 0, null);

            File jpgFile = new File(Objects.requireNonNull(file.getOriginalFilename()));
            ImageIO.write(jpgBufferedImage, "jpg", jpgFile);

            return Optional.of(jpgFile);
        } else {
            File convertFile = new File(Objects.requireNonNull(file.getOriginalFilename()));
            if (convertFile.createNewFile()) {
                try (FileOutputStream fos = new FileOutputStream(convertFile)) {
                    fos.write(file.getBytes());
                }
                return Optional.of(convertFile);
            }
            return Optional.empty();
        }
    }

    private Boolean imageFormatCheck(MultipartFile file) throws IOException {
        byte[] headerBytes = new byte[8]; // 최소 8바이트 필요
        int bytesRead = file.getInputStream().read(headerBytes);

        if (bytesRead >= 8 && isHEIC(headerBytes)) {
            return true;
        } else {
            return false;
        }
    }

    private boolean isHEIC(byte[] headerBytes) {
        if (headerBytes.length < 12) {
            return false;
        }
        return (headerBytes[4] == 0x66 && headerBytes[5] == 0x74 &&
                headerBytes[6] == 0x79 && headerBytes[7] == 0x70 &&
                headerBytes[8] == 0x68 && headerBytes[9] == 0x65 &&
                headerBytes[10] == 0x69 && headerBytes[11] == 0x63);
    }

    public void delete(String url) {
        String[] urlArray = url.split("/");
        String fileName = String.join("/",Arrays.copyOfRange(urlArray,3,urlArray.length));
        amazonS3Client.deleteObject(bucket, fileName);
    }
}

 

위의 코드의 로직을 간단히 살펴보면 아래와 같다.

 

먼저 Upload 하는 로직이다.

  1. public String upload(Multipartfile file, String directory) MultipartFile 형태로 받은 이미지와 집어넣을 폴더(String)를 변수로 받고
  2. private File convert(Multipartfile file)에서  file의 형태를 MultipartFile을 File 형태로 전환을 해준다.
    • 이 부분에서 HEIC 포맷을 따로 JPG로 전환해 주는 로직을 추가했다. 이유는 HEIC가 업로드가 안 되는 현상이 발생해서다.
      • private Boolean imageFormatCheck()과 private boolean isHEICH()으로 진행했다. 
  3. private String upload(File file, String directory)에서 putS3(File file, String directory)로 업로드를 진행한다.
  4. private String putS3(File file, String directory)에서는 해당하는 bucket, file, directory로 이미지를 업로드하고 private void removeNewFile로 이미지를 삭제해 준다.(안 그러면 EC2 서버에 이미지가 남게 될 수 있다.) 그리고 업로드한 이미지의 url을 반환해 준다.

그다음 Update/Delete의 로직이다. 이 로직에서 Update의 중요한 점은 수정이 아닌 삭제 후 재업로드라는 점이다. 그래서 이는 S3 Uploader.java에서 구현하는 게 아닌 Business Logic에서 구현해야 한다.

 

이렇게 생각하는 이유는 S3는 정적인 정보를 담는 저장소이기 때문이다. 한번 저장된 객체의 경우 수정을 하는 게 할 수도 없는 Immutable객체이자 그렇게 하는 게 더 비효율적이라고 생각한다.

 

삭제로직은 간단하다. 저장되어 있는 url에 이미지를 삭제해 달라고 보내는 건데 여기서 중요한 점은 앞의 https에서 direcotry 앞까지의 부분을 넘겨줘야 한다. 그래서 아래와 같은 url을 나눠서 삭제한 거다.

https://willyou-images.s3.ap-northeast-2.amazonaws.com/profileImage/jwywoo26/%E1%84%8B%E1%85%A5%E1%86%AF%E1%84%80%E1%85%AE%E1%86%AF%E1%84%89%E1%85%A1%E1%84%8C%E1%85%B5%E1%86%AB.jpeg

 

마지막으로 Read는 어떻게 해올까? 간단하다. 

위에서 말했듯이 우리는 Image를 S3에 Upload 하게 되면 해당하는 URL을 받게 된다. 그 URL을 RDS MySQL과 같은 DB에 저장한 다음 필요할 때마다 Image를 불러오면 되는 거다. 즉, 우리는 Image를 DB에 저장하지 않고 S3에 저장한 Image를 조회가 가능하게 만들어 조회할 수 있는 URL을 DB에 저장하는 것이다. 

 

그럼 마지막으로 Project에 적용되어 있는 Image 로직을 보며 마무리하겠다.

 

먼저 Controller다. 여기서 중요한 점은 PutMapping을 사용하고 추가적으로 Media.Type를 MULTIPART_FORM_DATA_VALUE로 해줘야 한다. 진짜 이게 제일 중요하다. 그리고 @RequestBody가 아니라 @ModelAttribute를 사용한다.

@Operation(summary = "데이팅 이미지 입력")
@PutMapping(value = "/dating/image/{id}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<ApiResponse<ImageResponseDto>> updateDatingImage(
	@AuthenticationPrincipal UserDetailsImpl userDetails,
    	@PathVariable Long id,
    	@ModelAttribute DatingImageRequestDto requestDto) throws IOException {
    	return datingService.updateDatingImage(userDetails.getUser(), id, requestDto);
}

 

그리고 Service다.

@Transactional
    public ResponseEntity<ApiResponse<ImageResponseDto>> updateDatingImage(UserEntity user, Long id, DatingImageRequestDto requestDto) throws IOException {
        Dating dating = findByIdDateAuthCheck(id, user);
        switch (requestDto.getAction()) {
            case ADD -> {
                String fileName = s3Uploader.upload(requestDto.getImage(), "datingImage/" + user.getUsername());
                DatingImage datingImage = new DatingImage(fileName);
                datingImage.setDating(dating);
                dating.setDatingImage(datingImage);
                datingImageRepository.save(datingImage);
            }
            case MODIFY -> {
                DatingImage datingImage = findByIDatingImageAuthCheck(requestDto.getId(), dating);
                s3Uploader.delete(datingImage.getImage());
                String fileName = s3Uploader.upload(requestDto.getImage(), "datingImage/" + user.getUsername());
                datingImage.update(fileName);
            }
            case DELETE -> {
                DatingImage datingImage = findByIDatingImageAuthCheck(requestDto.getId(), dating);
                s3Uploader.delete(datingImage.getImage());
                datingImageRepository.delete(datingImage);
            }
        }
        return ResponseEntity.status(HttpStatus.OK).body(ApiResponse.successData(new ImageResponseDto(dating.getDatingImage())));
    }

 

ADD와 MODIFY 그리고 DELETE의 모든 상황에서 보면 위에서 언급했듯이 S3에 대한 Update후 바로 DB에서도 Update를 진행한다는 점이다. 

 

감사합니다!

 

만약 1MB 이상 이미지가 업로드 되지 않는 다면 아래의 링크로 그 이유를 확인해보자.

WHY: Nginx 용량 재한, 왜 사진이 1MB 이상이면 업로드가 되지 않을까?

 

WHY: Nginx 용량 재한, 왜 사진이 1MB 이상이면 업로드가 되지 않을까?

프로젝트를 진행하던 도중 클라이언트를 개발하는 프런트개발 쪽에서 아래와 같은 질문이 들어왔다. 문괜님 1MB 이상 사진이 안 올라가지는데요? 그 당시 이런 질문에 답하고 해결하기 위해 아

youcanbeable.tistory.com

 

참고 자료

 

* 항상 정확한 정보를 드리고 싶지만 실수가 있을 수도 있습니다! 

* 실수를 찾게 되거나 질문이 있으시면 댓글 달아주세요!!

반응형