[SpringBoot] KnockKnock 개발일지 - 0227 (게시글 작성 시 이미지 넣기- Server 개발)

Hyebin Lee·2022년 2월 28일
0

knockknock 개발일지

목록 보기
25/29
post-thumbnail

오늘의 목표

게시글 작성 시 이미지 넣는 기능 추가하기

참고한 링크

[AWS] Spring boot 에서 AWS S3 로 이미지 저장하기
[Spring] Spring Boot AWS S3 사진 업로드 하는 법
[Spring] 파일 및 이미지 업로드
[Spring] Json with MultipartFile

오늘의 이슈


이미지 데이터는 어떻게 처리해야 하는가에 대한 고민이 많았다.
나는 단순하게 DB에 이미지를 넣으면 될 줄 알았는데,,, 이미지 raw를 저장하는건 지양해야 한다고 하니 하드디스크를 따로 파서 어떻게 보관하며 이미지 전용 서버를 구축하는 경우는 또 어떻게 해야하는 건지,, 너무 막막하다 😭
열심히 구글링 하다 보니까 S3를 이용하여 이미지를 저장해줘야 한다는 것을 알아냈다..생각보다 갈길이 멀었다,,

S3에 이미지 업로드하기 (Server 개발)

  1. S3 bucket과 IAM 사용자 설정을 한다 (과정은 검색하면 많이 나오니까 생략해야지!)
  2. gradle 에 AWS S3 접근과 사용을 위한 dependencies를 추가한다.
 //aws s3 사용자 권한 설정
    implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
  1. aws.yml 파일 생성하고 aws S3 접근 관련 내용 설정하기
cloud:
  aws:
    credentials:
      accessKey: IAM 사용자 엑세스 키
      secretKey: IAM 사용자 비밀 엑세스 키
    s3:
      bucket: 버킷 이름
    region:
      static: ap-northeast-2
    stack:
      auto: false

이때 IAM 사용자 엑세스 키와 비밀 엑세스키는 IAM 사용자를 생성할 때 받는 csv 파일에서 복붙하면 된다!
4. aws.yml 값을 읽어와 AmazonS3Client 객체를 bean으로 등록할 configuration파일 생성

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 AmazonS3Config {

    @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 awsCreds = new BasicAWSCredentials(accessKey, secretKey);
        return (AmazonS3Client) AmazonS3ClientBuilder.standard()
                .withRegion(region)
                .withCredentials(new AWSStaticCredentialsProvider(awsCreds))
                .build();
    }
}

config 디렉토리를 따로 생성하고 그 안에 위의 클래스를 넣어주었다.
5.aws.yml 파일도 읽어와 설정하도록 MainApplication 내용 수정

@SpringBootApplication
public class KnockKnockApplication {

    public static final String APPLICATION_LOCATIONS = "spring.config.location="
            + "classpath:application.yml,"
            + "classpath:aws.yml";

    public static void main(String[] args) {
        new SpringApplicationBuilder(KnockKnockApplication.class)
                .properties(APPLICATION_LOCATIONS)
                .run(args);
    }
}
  1. S3Uploader 클래스 생성
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.web.multipart.MultipartFile;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.Optional;
import java.util.UUID;

@Slf4j
@RequiredArgsConstructor
@Component
public class S3Uploader {

    private final AmazonS3Client amazonS3Client;

    @Value("${cloud.aws.s3.bucket}")
    public String bucket;  // S3 버킷 이름

    public String upload(MultipartFile multipartFile, String dirName) throws IOException {
        File uploadFile = convert(multipartFile)  // 파일 변환할 수 없으면 에러
                .orElseThrow(() -> new IllegalArgumentException("error: MultipartFile -> File convert fail"));

        return upload(uploadFile, dirName);
    }

    // S3로 파일 업로드하기
    private String upload(File uploadFile, String dirName) {
        String fileName = dirName + "/" + UUID.randomUUID() + uploadFile.getName();   // S3에 저장된 파일 이름 
        String uploadImageUrl = putS3(uploadFile, fileName); // s3로 업로드
        removeNewFile(uploadFile);
        return uploadImageUrl;
    }

    // S3로 업로드
    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("File delete success");
            return;
        }
        log.info("File delete fail");
    }

    // 로컬에 파일 업로드 하기
    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();
    }
}

이 부분의 코드는 이해해야 할 내용이 많아서 찬찬히 정리해보자...🥰
우선 우리는 controller에서 S3에 업로드할 때 upload라는 메소드에 업로드할 multipartfile 형식의 이미지 파일과 해당 이미지를 저장할 s3 저장소의 directory 명을 입력 파라미터로 넘길 것이다.
upload 메서드가 하는 일은 convert 메서드를 실행하여 로컬 저장소에 MultipartFileFile형태로 변환하여 저장해준 후, File 형태의 입력 파라미터를 받는 또 다른 upload 메서드를 실행+ return 하여 해당upload 메서드에서 putS3메서드를 활용하여 File형태로 변환된 이미지 파일을 S3에 업로드하고 removeNewfile 메서드를 사용하여 로컬 저장소에 일시적으로 저장했던 File 을 삭제하는 것이다.

  1. 사진 데이터를 postMapping으로 받아오는 Controller 작성
@RequiredArgsConstructor
@RestController
public class HelloController {

    private final S3Uploader s3Uploader;

    @PostMapping("/images")
    public String upload(@RequestParam("images") MultipartFile multipartFile) throws IOException {
        s3Uploader.upload(multipartFile, "static");
        return "test";
    }
}

파일을 업로드 할 때 api 통신을 통해 받아올 객체의 타입은 MultipartFile이다.
upload 메소드의 두번째 파라미터 (위의 예시에서는 static)의 이름에 따라 S3 bucket 내부에 이미지를 담을 해당 이름의 directory가 생성된다.

이제 postman에서 해당 이미지가 잘 s3에 담기는지 확인해보려는데..!
다음과 같은 에러가 났따 하아,,, 구글링해보니 S3에서 인증된 사용자그룹은 ACL 권한을 나열, 읽기 허용해주어야 한다고 해서 해줬다.
그러고 다시 해보려는데

오류 어게인,,,ㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎ🥺

이 오류가 나는 원인은 의외였는데
내가 넣으려는 파일 이름이 java라서 오류가 나는 것이였다.
실제로 다른 이름의 파일들을 넣으면 오류 없이 s3의 static 폴더에 잘 담겼는데 위 파일만 담기지 않고 오류가 났다.
현재 프로젝트 경로에 업로드 하려는 사진의 이름(java)과 동일한 파일이 있어서 같은 파일로 간주하고 에러를 발생시키는 것 같다.

따라서 S3Uploader 클래스에서 로컬에 파일을 임시 저장하는 과정에서 파일의 실제 파일명을 쓰는 것이 아니라 임시 코드를 써서 기존 로컬에 있는 파일들과 중복이 일어나지 않도록 설정해주었다.

public class S3Uploader {

    private final AmazonS3Client amazonS3Client;

    @Value("${cloud.aws.s3.bucket}")
    public String bucket;  // S3 버킷 이름


    public String upload(MultipartFile multipartFile, String dirName) throws IOException {
        File uploadFile = convert(multipartFile)  // 파일 변환할 수 없으면 에러
                .orElseThrow(() -> new IllegalArgumentException("error: MultipartFile -> File convert fail"));

        return upload(uploadFile, dirName, multipartFile.getOriginalFilename()); //📌 파일의 originalName을 바로 넘기도록 설정 
    }

    // S3로 파일 업로드하기
    private String upload(File uploadFile, String dirName,String originalName) { //📌입력 파라미터에 originalName 추가 
        String fileName = dirName + "/" + UUID.randomUUID() + originalName;   // S3에 저장된 파일 이름 📌random 값 + 기존의 파일명 으로 설정. 기존의 파일명은 upload 메서드 당시 multipartFile 에서 바로 getOriginalFileName으로 가져와서 입력 파라미터로 받기 
        String uploadImageUrl = putS3(uploadFile, fileName); // s3로 업로드
        removeNewFile(uploadFile);
        return uploadImageUrl;
    }

    // S3로 업로드
    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("File delete success");
            return;
        }
        log.info("File delete fail");
    }

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

        return Optional.empty();
    }
}

수정된 S3Uploader 코드이다. 📌 핀 꽂혀있는 부분 설명을 읽어보면 플로우는 어렵지 않다!

짠 이제 java 이름의 이미지 파일도 잘 들어가는 것을 확인할 수 있다.
물론 프로젝트에서 local이랑 겹칠만한 파일이 들어올 확률은 정말 드물겠지만,, 혹시나 하는 마음에 예외사항을 두고싶지 않아서 수정했다..ㅎㅎㅎㅎㅎ 나중에 가서 고치려고 하면 뭐가 문제인지도 모를거 같아서 😅

DB에 S3 이미지 경로 저장하기 (Server 개발)

  1. Image Entity 만들기
public class Image {
    @Id
    @GeneratedValue
    @Column(name="image_id")
    private Long id;

    String imgurl;
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "post_id")
    private Post post;

}

이 Entity는 post에 종속되는 entity이기 때문에 Post Entity 클래스에서 CASCADE를 사용하여 OneToMany 매핑을 한다.

   @OneToMany(mappedBy = "post", cascade = CascadeType.ALL)
    private List<Image> img = new ArrayList<>();
  1. Image Repository
@Repository
public interface ImageRepository extends JpaRepository<Image,Long> {
}

Repository는 단순한 기능만 활용할 수 있도록 구현했다.

  1. Image Service 구현
@Service
public class ImageService extends S3Uploader {

    private ImageRepository imageRepository;

    public ImageService(AmazonS3Client amazonS3Client,ImageRepository imageRepository) {
        super(amazonS3Client);
        this.imageRepository = imageRepository;
    }

    public String saveImage(MultipartFile multipartFile, String dirName, Post post) throws IOException {
        String uri = super.upload(multipartFile, dirName);
        Image img = new Image(uri,post);
        imageRepository.save(img);
        return uri;
    }
}
  1. post save API (Controller) 수정
    중요한 것은 Multipartfile은 DTO 안에 같이 필드로 들어가 있으면 안되고 따로 RequestPart 로 나눠서 api 통신시 받아와야 한다.
    그리고 이렇게 하려면 @PostMapping(consumes={}) 형태로 아래와 같이 각 requestpart의 content type이 무엇인지 정의를 해 주어야 한다!
 @PostMapping(value = "api/post/{userId}",consumes = {MediaType.APPLICATION_JSON_VALUE, MediaType.MULTIPART_FORM_DATA_VALUE})
    public PostSaveResponse savePost(@RequestPart @Valid PostWriteRequest request , @RequestPart MultipartFile image,
                                     @PathVariable("userId") String writerId) throws IOException {

       PostSaveRequest postSaveRequest = new PostSaveRequest(request.getTitle(),request.getContent(),writerId,request.getHashtag());
       Post post = postService.save(postSaveRequest);
       imageService.saveImage(image,"knockknock",post);
       PostSaveResponse response = new PostSaveResponse(post.getId(),post.getPostwriter().getUserId(),post.getTitle(),post.getContent(),post.getTimestamp());
       String[] hashtag = postSaveRequest.getHashTags().split(" ");
       List<String> hashtags = new ArrayList<>();
       for(String tag: hashtag)hashtags.add(tag);
       response.setHashtag(hashtags);
       return response;
    }

Error : Content type 'application/octet-stream' not supported


위에까지 구현하고 postman 으로 테스트를 해보려고 하면 다음과 같은 오류가 난다.
Content Type를 명시해주지 않았기 때문이다!


아래와 같이 Content Type를 명시해줘야 API 통신이 원활하게 이루어지는 것을 확인할 수 있었다.
나중에 안드로이드에서 보낼 때도 Content Type을 명확하게 해야할 것 같다..

아무튼 이렇게 서버쪽 개발은 끝났다 이제 내일 안드로이드쪽 개발 부수기 파이팅 💪🐱‍🏍

0개의 댓글