본문 바로가기
미니 프로젝트/이노베이션 6주차

이노베이션 6주차 2 - 좋아요 표시,취소 기능 / S3 이미지 업로드

by 구너드 2023. 7. 17.

<4.연관관계 매핑>

좋아요와 싫어요 기능을 구현하면서 양방향 연관관계에 대해서 의문이 들었다. 단순히 게시물에 대한 선호도를 총합하는 기능인데 양방향을 매핑해준다면 가지고 있는 기능에 비해 작성되는 쿼리가 복잡해질 것이라고 생각했다. 따라서 이 둘을 ManyToOne 단방향 매핑으로 구현하고 개수를 총합하는 부분은 따로 코드로 작성해보려고 노력했다.

 

@Service
@RequiredArgsConstructor
@Transactional
public class PreferenceService {

    private final LikeRepository likeRepository;
    private final DislikeRepository dislikeRepository;
    private final PostRepository postRepository;

    public String updateLike(Long postId, User user) {
        Post post = postRepository.findById(postId).orElseThrow(()->
                new IllegalArgumentException("해당 게시글은 존재하지 않습니다"));

        if (!isLikedPost(post, user)) {
            createLike(post, user);
            post.increaseLike();
            return "좋아요 성공";
        }

        removeLike(post, user);
        post.decreaseLike();
        return "좋아요 취소";
    }

    public String updateDislike(Long postId, User user) {
        Post post = postRepository.findById(postId).orElseThrow(()->
                new IllegalArgumentException("해당 게시글은 존재하지 않습니다"));

        if (!isDislikedPost(post, user)) {
            createDislike(post, user);
            post.increaseDislike();
            return "싫어요 성공";
        }

        removeDislike(post, user);
        post.decreaseDislike();
        return "싫어요 취소";
    }

    public boolean isLikedPost(Post post, User user) {
        return likeRepository.findByPostAndUser(post, user).isPresent();
    }

    public void createLike(Post post, User user) {
        Like like = new Like(post, user);
        likeRepository.save(like);
    }

    public void removeLike(Post post, User user) {
        Like like = likeRepository.findByPostAndUser(post, user).orElseThrow();
        likeRepository.delete(like);
    }

    public boolean isDislikedPost(Post post, User user) {
        return dislikeRepository.findByPostAndUser(post, user).isPresent();
    }

    public void createDislike(Post post, User user) {
        Dislike dislike = new Dislike(post, user);
        dislikeRepository.save(dislike);
    }

    public void removeDislike(Post post, User user) {
        Dislike dislike = dislikeRepository.findByPostAndUser(post, user).orElseThrow();
        dislikeRepository.delete(dislike);
    }
}
    public void increaseLike() { this.liked += 1; }

    public void decreaseLike() {
        this.liked -= 1;
    }

    public void increaseDislike() {
        this.disliked += 1;
    }

    public void decreaseDislike() { this.disliked -= 1; }

Post 엔티티 내부에 해당 메서드들을 작성하여 게시글에 달린 선호도 개수를 설정하였다. 이러한 방식에는 경로변수로 넘어온 postId와 인증 토큰에 담긴 user를 이용하여 like, dislike Repository에 담긴 엔티티를 찾고, isPresent() 를 사용해 해당 엔티티가 있다면 좋아요/싫어요가 눌린 상태이기 때문에 Post에서 해당 데이터에 1을 빼주고, 엔티티를 삭제한다. 이를 통해 다음에 누르게 되면 좋아요/싫어요 요청임을 인지하고 Post에 해당 필드들을 +1해주는 메서드를 사용하고 다시 저장할 수 있게 된다. 이를 통해 양방향을 매핑하지 않아도 좋아요/싫어요 개수를 Post 입장에서는 계산할 수 있기에 보다 단순하고 효율적이라고 느꼈다. 다만 게시글을 삭제하는 테스트 중에 영속성 전이가 설정되지 않아,해당 좋아요/싫어요 에 매핑된 Post가 삭제되면 에러를 일으키는 상황이 발생했다. 따라서 Post가 삭제된다면 해당 postid를 가지고 있는 좋아요/싫어요도 삭제를 하게끔 설정해야했다. 이를 위해 몇 가지 글들을 읽으면서 

 

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "post_id")
    @OnDelete(action = CASCADE)
    private Post post;

와 같은 방식으로 영속성 전이를 구현할 수 있음을 알게 되었다. 기존에는 List<Entity> 형태로 필드를 가진 상태에서 영속성 전이와 orphanRemoval을 설정했는데 다대일의 다 입장에서도 영속성 전이를 설정할 수 있다는 것을 처음 알게 되었다. 해당 에너테이션을 붙이고 테스트를 해본 결과 기존에 오류가 발생하던 상황과 달리 정상적으로 삭제되고 작동하였다. 이러한 영속성 전이 방법은 프로젝트에서 얻을 수 있었던 큰 수확 중 하나였던 것 같다


<2.S3 이미지 업로드>

Object Storage. S3는 객체 저장소의 역할을 한다. 파일들을 DB에 저장하기에는 변환되는 문자열의 길이 혹은 용량적인 측면에 있어 상당히 무리가 된다, 따라서 실제 파일들은 클라우드 폴더에서 저장하고 해당 주소만 DB에 남긴다. 즉 실제 파일들은 객체 저장소의 역할을 하는 S3에 존재하고 그 주소만 서버측에서 가지고 있게 되는 방식이다. 그렇다면 해당 이미지 파일들을 받고 이를 변환하여 S3에 저장하는 작업과 DB에 S3 폴더의 주소를 저장하는 작업을 진행해야하는 것을 인지하고 S3에 대해서 공부하였다.

 

    @PostMapping("/newpost")
    public ApiResponse<?> createPost(@RequestPart(value = "data") PostRequestDto postRequestDto,
                                     @RequestPart(value = "file", required = false) MultipartFile image,
                                     @AuthenticationPrincipal UserDetailsImpl userDetailsImpl) {
        return ok(postService.createPost(postRequestDto, image, userDetailsImpl.getUser()));
    }

 

보통 게시물과 이미지는 동시에 업로드된다. 파일은 프론트엔드와의 사전협의에서 하나만 올리기로 약속했기에 단건 파일 업로드를 전제로 파일과 JSON을 동시에 받는 방식을 찾게 되었고 @RequestPart는 이에 대한 답이었다. 기존의 @RequestBody와 다르게 @RequestPart는 form-data 형식에서는 여러가지 종류의 데이터를 전송받을 수 있는데 클라이언트에서 넘어오는 데이터를 하나씩 분류하여 제공한다. 이를 통해 파일과 JSON 데이터를 분리하여 받을 수 있고 각각의 데이터에 추가적인 작업을 진행할 수 있는 환경을 만들 수 있다.

 

@Slf4j
@Service
@RequiredArgsConstructor
public class S3Service {

    private final AmazonS3Client amazonS3Client;

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

    public String upload(MultipartFile multipartFile, String dirName) {
        if (multipartFile == null || multipartFile.isEmpty()) {
            return "";
        }

        try {
            File uploadFile = convert(multipartFile)      
                    .orElseThrow(() -> new IllegalArgumentException("파일이 유효하지 않습니다"));
            return upload(uploadFile, dirName);
        } catch (IOException e) {
            throw new IllegalArgumentException("잘못된 입력입니다", e);
        }
    }

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

    // 1. 로컬 파일생성
    private Optional<File> convert(MultipartFile file) throws IOException {
        File convertFile = new File(file.getOriginalFilename());
        if (convertFile.createNewFile()) {
            try (FileOutputStream fos = new FileOutputStream(convertFile)) {
                fos.write(file.getBytes());
            } catch (IOException e) {
                throw new IllegalArgumentException("잘못된 입력입니다", e);
            }
            return Optional.of(convertFile);
        }

        return Optional.empty();
    }

    // 2. S3 파일업로드
    private String putS3(File uploadFile, String fileName) {
        amazonS3Client.putObject(new PutObjectRequest(bucket, fileName, uploadFile).withCannedAcl(CannedAccessControlList.PublicRead));
        log.info("File Upload : " + fileName);
        return amazonS3Client.getUrl(bucket, fileName).toString();
    }

    // 3. 로컬 파일삭제
    private void removeNewFile(File targetFile) {
        if (targetFile.delete()) {
            log.info("File delete success");
            return;
        }
        log.info("File delete fail");
    }

}

 S3에 파일을 업로드하는 로직. 다른 부분들은 파일 업로드의 이름이 중복되었을 때 발생할 수 있는 상황을 방지하기 위해 uuid를 사용해 파일이름을 새롭게 변환하고 S3에 업로드하고 해당 업로드한 URL을 얻을 수 있다.

 

이러한 로직에서 2가지 부분을 고려할 수 있었다.

 

1. Checked Exception을 Unchecked Exception으로 변환

IOException을 그대로 던지기에는 예외전파가 너무 광범위하게 영향을 미친다고 생각했다. 비록 유저에게 불친절하겠지만 IllegalArgumentException으로 변환하여 해당 예외를 Unchecked로 처리하고 영향을 최소화 하고자 했다. 실제 서비스에서는 절대 이렇게 해서는 안 되겠지만 빠른 시간 내에 배포하여 프론트엔드에게 데이터를 제공하여 속도를 맞추고 싶었기 때문에 우선은 이런 식으로 예외를 감싸주었다. 해당 부분은 프로젝트 기간 중 시간이 남게 되면 코드를 좀 더 살펴보고 더 적합한 방식으로 예외를 새로 감싸주거나 처리해야할 거 같다.

 

2. 로컬에 파일을 저장하고 또, 삭제하는 이유

바로 S3에 이미지를 올리는 게 더 효율적이지 않나라는 생각이 들었다. 굳이 로컬에서 리소스를 소모하여 저장하고 또 삭제하는 과정은 굉장히 비효율적으로 느껴졌는데 이 부분에 대해서 찾아보니 나름 합리적인 이유가 존재했었다.

 

- convert 메서드를 통해 MultipartFile을 File로 변환하고 로컬에 임시로 생성하는 이유는 S3에 파일을 업로드하기전에 추가적인 처리가 필요하기 때문이다. 업로드하는 파일의 크기를 변경하거나 썸네일을 생성하게 될 경우 이러한 처리를 위해 로컬에 파일을 임시로 생성하는 것이 효율적이다.

 

- S3에 저장되기 전에 파일에 대한 검증을 통해 문제가 있을 경우 업로드를 막을 수 있다. 이러한 유효성 검사를 통해 악성 파일이나 잘못된 파일이 S3에 저장하는 것을 방지할 수 있고 이는 서비스의 안정성과 신뢰성을 높일 수 있다.

 

- 업로드 과정에서 발생한 오류를 처리할 수 있다. 로컬에 파일을 먼저 업로드하게 되면 해당 업로드 과정에서 발생할 수 있는 오류들을 선발적으로 검증하고 오류처리를 할 수 있다.

 

- 서버와 S3의 의존성을 줄일 수 있다. 로컬에 업로드없이 S3를 직접 사용하게 된다면 서비스의 모든 이미지 불러오기는 S3에 의존하게 되고 테스트 및 개발 환경에서는 별도의 S3 환경이 필요하게된다. 이러한 단점은 로컬에 업로드를 하는 방식으로 막을 수 있다.

 

    @Transactional
    public String createPost(PostRequestDto postRequestDto,MultipartFile image, User user) {
        String imageUrl = s3Service.upload(image, "GG");
        postRepository.save(new Post(postRequestDto, imageUrl, user));
        return "게시글 작성 성공";
    }

위의 과정을 통해 생성된 이미지의 저장 주소는 PostService에서 호출한 S3Service의 반환값이고 post의 필드로 DB에 저장되게 된다.


사실 처음에는 단순히 이미지URL을 직접 String으로 받아 DB에 저장하려고 했다. 해당 방법은 매우 간편하기도 하고 백엔드 입장에서 별도의 작업이 거의 필요하지 않다. 하지만 이는 이미지URL이 너무 긴 경우 DB 저장에서 오류가 발생하고, 만약 해당 URL에서 이미지의 변경,손상이 일어난다면 게시판의 이미지도 같은 상태로 변환된다. 이러한 점에서 유저에게 큰 불편을 초래할 수 있다고 판단하였다. 따라서 해당 서비스적인 배려를 고려하면 파일을 업로드하는 것이 적합하다고 생각하였고 S3를 공부하게 되었다.

 

완전히 S3에 숙달하게 된 건 아니지만 대략적인 흐름과 원리들을 이해하는 과정은 꽤 흥미로웠다. 다만 AWS는 아직도 그 난이도가 어렵게 느껴진다. 물론 대단한 기능들을 제공하는 것을 여태껏 봐왔고 S3도 신선한 기술이었다. 조금 어렵게 느껴진다고해서 AWS를 꺼리게 되기보다는 AWS가 추가적으로 가지고 있는 무기들이 또 어떤 것들이 있을지 궁금하면서 기대된다. 당장 해당 기술들에 대해 배울 시간은 부족하다고 느끼지만 기회가 된다면 꼭 AWS에 대해서 전체적으로 배워보고 싶다.