TIL | [Spring] AWS S3로 다중 이미지 업로드

hyemin·2022년 4월 20일
3

Spring

목록 보기
7/7
post-thumbnail
post-custom-banner

AWS S3

AWS S3는 AWS에서 제공하는 Storage 서비스로서 아래와 같은 특징들이 있다

  • 모든 종류의 데이터를 원하는 형식으로 저장
  • 저장할 수 있는 데이터의 전체 볼륨과 객체 수에는 제한이 없음
  • Amazon S3는 간단한 key 기반의 객체 스토리지이며, 데이터를 저장 및 검색하는데 사용할 수 있는 고유한 객체 키가 할당됨
  • Amazon S3는 간편한 표준 기반 REST 웹 서비스 인터페이스를 제공

1. 준비 작업

S3에 이미지 업로드를 하기위해서는 사전 작업이 필요하다
(AWS 계정이 있다는 전제하에 필요한 작업들이다)

  • AWS IAM 계정 - Access key, Secret key 생성 → 참고
  • S3 버킷 생성참고

주의할 점은 발급받은 Access key와 Secret key는 절대 유출이 되서는 안된다(과금 우려)

또한, 이미지 업로드가 되지 않는다면, 액세스 권한을 풀어줬는지 확인해야한다

특히 ACL이 활성화 되지 않았을 경우 postman에서 다음과 같은 error를 만날 수 있으니 꼭 권한을 잘 풀어줘야한다

File upload failed : AccessControlListNotSupported: The bucket does not allow ACLs

2. 구현

다른 방법이 있는지는 모르겠지만, 찾아본 바에 따르면 사진 한 장 올리는 것을 List에 담아내는 방식으로 구현을 했다

  • Instagram 클론 코딩을 하며 사용한 코드라 이미지 업로드와 관련없는 코드들도 포함되어 있으니 참고 바란다

build.gradle

build.gradle애 아래 코드를 추가해 주어야

dependencies {
    implementation group: 'org.springframework.cloud', name: 'spring-cloud-starter-aws', version: '2.2.5.RELEASE'
}

Model - Img

@Getter
@Entity
@NoArgsConstructor
public class Img {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String imgUrl;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "post_id", nullable = false)
    @OnDelete(action = OnDeleteAction.CASCADE)
    private Post post;

    public Img(String imgUrl, Post post) {
        this.imgUrl = imgUrl;
        this.post = post;
    }
}
  • Post가 삭제 될 때 이미지도 같이 삭제되도록 (fetch = FetchType = false)를 붙여줬고, Pot 테이블과 Many To One 연관관계를 맺어줬다

Model - Post

@Getter
@Entity
@NoArgsConstructor
public class Post extends Timestamped {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long Id;

    @Column(nullable = false)
    private String content;

    @Transient
    private final List<Img> imgList = new ArrayList<>();
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn
    private Member member;

    @OneToMany(mappedBy = "post")
    private List<Comment> comment;

    public Post(String content, Member member) {
        this.content = content;
        this.member = member;
    }

    public void updatePost(PostRequestDto res) {
        this.content = res.getContent();
    }
}

PostController

@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class PostController {

    private final PostService postService;
    private final S3Service s3Service;

    // 게시글 전체 조회
    @GetMapping("/posts")
    public Map<String, List<PostGetResponseDto>> getAllPost(){ return postService.getAllPost();}

    // 메인 페이지 무한 스크롤
    @GetMapping("/postsScroll")
    public Map<String, List<PostGetResponseDto>> getPostSlice(
            @RequestParam(required=false) Integer page,
            @RequestParam(required=false) Integer size,
            @RequestParam(required=false) String sortBy ,
            @RequestParam(required=false) Boolean isAsc
    ) {
        if (isNotNullParam(page, size, sortBy, isAsc)) {
            page -= 1;
            return postService.getAllPostSlice(page, size, sortBy, isAsc);
        } else {
            throw new PrivateException(Code.PAGING_ERROR);
        }
    }

    private boolean isNotNullParam(Integer page, Integer size, String sortBy, Boolean isAsc) {
        return (page != null) && (size != null) && (sortBy != null) && (isAsc != null);
    }

    // 게시글 상세 조회
    @GetMapping("/post/{postId}")
    public ExceptionResponseDto getPost(@PathVariable Long postId) {
        PostGetResponseDto postGetResponseDto = postService.getPostOne(postId);
        return new ExceptionResponseDto(Code.OK, postGetResponseDto);
    }

    // 게시글 작성
    @PostMapping("/post")
    public ExceptionResponseDto uploadPost(@RequestPart("content") PostRequestDto postRequestDto,
                                           @RequestPart("imgUrl") List<MultipartFile> multipartFiles) {
        if (multipartFiles == null) {
            throw new PrivateException(Code.WRONG_INPUT_CONTENT);
        }
        List<String> imgPaths = s3Service.upload(multipartFiles);
        System.out.println("IMG 경로들 : " + imgPaths);
        postService.uploadPost(postRequestDto, imgPaths);
        return new ExceptionResponseDto(Code.OK);
    }

    // 게시글 수정
    @PutMapping("/post/{postId}")
    public ExceptionResponseDto updatePost(@PathVariable Long postId,@RequestPart("content") PostRequestDto postRequestDto) {
        PostUpdateResponseDto postUpdateResponseDto = postService.updatePost(postId, postRequestDto);
        return new ExceptionResponseDto(Code.OK, postUpdateResponseDto);
    }

    // 게시글 삭제
    @DeleteMapping("/post/{postId}")
    public ExceptionResponseDto deletePost(@PathVariable Long postId) {
        postService.deletePost(postId);
        return new ExceptionResponseDto(Code.OK);
    }
}
  • 프로젝트 당시 Custom Exception을 활용해서 모든 요청에 응답코드를 내려주기 위해 ExcptionResponseDto가 활용되었다
  • 이미지와 관련된 부분을 본다면 게시글 작성관 관련된 @PostMapping에서 @RequsetPart("imgUrl") 부분을 참고하면 된다
  • 파일 전송시 @RequestBody가 아니라 @RequestPart 어노테이션을 사용
  • POST 요청시 이미지 파일 뿐만 아니라 입력되는 content도 같이 내려보내는 구조이다

PostService

@Service
@RequiredArgsConstructor
public class PostService {

    private final MemberRepository memberRepository;
    private final PostRepository postRepository;
    private final ImgRepository imgRepository;
    private final S3Service s3Service;

    // 게시글 작성
    @Transactional
    public void uploadPost(PostRequestDto res, List<String> imgPaths) {
        postBlankCheck(imgPaths);
        System.out.println("로그인한 username : " + SecurityUtil.getCurrentUsername());

        String username = SecurityUtil.getCurrentUsername();

        Member member = memberRepository.findMemberByUsername(username).orElseThrow(
                () -> new PrivateException(Code.NOT_FOUND_MEMBER)
        );
        String content = res.getContent();

        Post post = new Post(content, member);
        postRepository.save(post);

        List<String> imgList = new ArrayList<>();
        for (String imgUrl : imgPaths) {
            Img img = new Img(imgUrl, post);
            imgRepository.save(img);
            imgList.add(img.getImgUrl());
        }
    }

    private void postBlankCheck(List<String> imgPaths) {
        if(imgPaths == null || imgPaths.isEmpty()){ //.isEmpty()도 되는지 확인해보기
            throw new PrivateException(Code.WRONG_INPUT_IMAGE);
        }
    }
}
  • 로그인 한 유저만 게시글 작성이 가능
  • List imgList에 저장한 imgUrl들 저장

application.properties

cloud.aws.credentials.accessKey=****
cloud.aws.credentials.secretKey=****

cloud.aws.s3.bucket=bucket name
cloud.aws.stack.auto=false
cloud.aws.region.static=ap-northeast-2
  
  • 발급받았던 accessKet, secretKet , 버킷 이름을 넣어준다

S3Service

@Service
@RequiredArgsConstructor
public class S3Service  {

    private final AmazonS3 s3Client;

    @Value("${cloud.aws.credentials.accessKey}")
    private String accessKey;

    @Value("${cloud.aws.credentials.secretKey}")
    private String secretKey;

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

    @Value("${cloud.aws.region.static}")
    private String region;

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

    public List<String> upload(List<MultipartFile> multipartFile) {
        List<String> imgUrlList = new ArrayList<>();

        // forEach 구문을 통해 multipartFile로 넘어온 파일들 하나씩 fileNameList에 추가
        for (MultipartFile file : multipartFile) {
            String fileName = createFileName(file.getOriginalFilename());
            ObjectMetadata objectMetadata = new ObjectMetadata();
            objectMetadata.setContentLength(file.getSize());
            objectMetadata.setContentType(file.getContentType());

            try(InputStream inputStream = file.getInputStream()) {
                s3Client.putObject(new PutObjectRequest(bucket+"/post/image", fileName, inputStream, objectMetadata)
                        .withCannedAcl(CannedAccessControlList.PublicRead));
                imgUrlList.add(s3Client.getUrl(bucket+"/post/image", fileName).toString());
            } catch(IOException e) {
                throw new PrivateException(Code.IMAGE_UPLOAD_ERROR);
            }
        }
        return imgUrlList;
    }

    // 이미지파일명 중복 방지
    private String createFileName(String fileName) {
        return UUID.randomUUID().toString().concat(getFileExtension(fileName));
    }

    // 파일 유효성 검사
    private String getFileExtension(String fileName) {
        if (fileName.length() == 0) {
            throw new PrivateException(Code.WRONG_INPUT_IMAGE);
        }
        ArrayList<String> fileValidate = new ArrayList<>();
        fileValidate.add(".jpg");
        fileValidate.add(".jpeg");
        fileValidate.add(".png");
        fileValidate.add(".JPG");
        fileValidate.add(".JPEG");
        fileValidate.add(".PNG");
        String idxFileName = fileName.substring(fileName.lastIndexOf("."));
        if (!fileValidate.contains(idxFileName)) {
            throw new PrivateException(Code.WRONG_IMAGE_FORMAT);
        }
        return fileName.substring(fileName.lastIndexOf("."));
    }
}
  • 이미지 관련 확장자만 파일 업로드할 수 있도록 설정

구현 확인 - POSTMAN

  • 알맞은 POST URL을 입력
  • body에 Content-type application/json 추가
  • key 이름 확인
  • value json 형식으로 입력

정상적으로 통신이 되서 Custom Exception으로 설정해두었던 데이터로 body에 잘 내려보내진 것을 확인할 수 있다

post-custom-banner

4개의 댓글

comment-user-thumbnail
2022년 7월 15일

s3에서 파일 삭제는 어떻게 하셨나요?

1개의 답글