관리 메뉴

나구리의 개발공부기록

게시글 CUD API 추가 개발 본문

프로젝트, 개발 공부 회고/덕후프로젝트

게시글 CUD API 추가 개발

소소한나구리 2024. 12. 27. 11:09

1. 게시글 CUD API 생성

1) 게시글 생성 - 1차 개발(실수 투성이)

게시글 전체 조회를 마무리하고 게시글 생성을 진행했다.

그런데 게시글 생성을 하려고 하다보니 실제 이미지를 어떻게 저장해야할지 한참을 헤맷다.

 

예전 회의에서 클라우드 플레어에 저장한다고 했었는데 이것을 어떻게 써야하는지 한참을 찾아보다가.. 이미 우리 애플리케이션에 구현이 되어있다는 것을 늦게 발견했다.

 

이것과 동시에 Common - baseEntity라는 패키지에 BaseTimeEntity로 수정일과 생성일을 별도로 생성해 두었다는 것을 발견하였다..

해당 클래스에 @MappedSuperclass 애노테이션이 붙어있는 것을 보고 김영한님 강의를 들었을 때 JPA로드맵에서 도메인 개발시 여러 도메인에서 공통적으로 사용하는 필드는 이렇게 상속을 통해 개발을 했던 것이 생각이 났다!

 

원래 순수 JPA를 사용했다면 @MappedSuperclass를 적용한 도메인에 @PrePsersist나 @PreUpdate등의 이벤트가 발생하는 애노테이션을 활용하여 동작을 수행하는 메서드의 기능을 직접 정의해야 하지만

우리는 스프링 데이터 JPA를 사용하므로 @EntityListeners(AuditingEntityListener.class)와 @CreatedDate, @LastModifieDate 애노테이션으로 손쉽게 공통 필드 도메인을 구성하고 있었다.

 

코드 더보기

더보기
package com.ani.taku_backend.common.baseEntity;

@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
@Getter
public class BaseTimeEntity {
    @CreatedDate
    @Column(updatable = false, nullable = false)
    private LocalDateTime createdAt;

    @LastModifiedDate
    @Column(nullable = false)
    private LocalDateTime updatedAt;
}

 

만일 스프링 부트로 생성한 프로젝트에 적용하고 싶다면 애플리케이션을 실행하는 main메서드가 있는 클래스에 @EnableJpaAuditing을 입력해주면된다.

 

그래서 이를 적용하기 위해 Post 클래스에 BaseTimeEntity를 상속을 받아서 다시 구현하였고, Image와 Post의 중간 테이블 역할을 하는 CommunityImages에 연관관계 편의 메서드가 빠져있어서 이 부분도 추가로 작성하였다.

 

코드 더보기

더보기
package com.ani.taku_backend.post.model.entity;

/**
 * 커뮤니티 게시글 Entity
 */
@Table(name = "posts")
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@ToString
@Builder
public class Post extends BaseTimeEntity {

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

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private User user;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "category_id") // 외래 키 컬럼 이름 명시
    private Category category;

    @Builder.Default
    @BatchSize(size = 1000)
    @OneToMany(mappedBy = "post")
    private List<CommunityImage> communityImages = new ArrayList<>();

    private String title;
    private String content;

    private Long views;
    private Long likes;

    private LocalDateTime deletedAt ;

    /**
     * 이미지 연관관계 편의 메서드
     */
    public void addCommunityImage(CommunityImage communityImage) {
        this.communityImages.add(communityImage);
        communityImage.assignPost(this);
    }

    public void removeCommunityImage(CommunityImage communityImage) {
        this.communityImages.remove(communityImage);
        communityImage.unassignPost();
    }

    /**
     * User 연관관계 편의 메서드
     */
    public void setUserInternal(User user) {
        this.user = user;
    }
    public void removeUserInternal() {
        this.user = null;
    }

    /**
     * update 메서드
     */
    public void updatePost(String title, String content, Category category) {
        if (title != null) {
            this.title = title;
        }

        if (content != null) {
            this.content = content;
        }
         if (category != null) {
             this.category = category;
         }
    }

    /**
     * Soft Delete 메서드
     */
    public void softDelete() {
        this.deletedAt = LocalDateTime.now();
    }

    // 조회수 증가
    public void addViews() {
        this.views++;
    }

    // 좋아요 수 증가
    public void addLikes() {
        this.likes++;
    }

    // 좋아요 수 감소
    public void subLikes() {
        if (this.likes > 0) {
            this.likes--;
        }
    }

}

 

실제 이미지는 클라우드 플레어에 저장하고 RDB에는 이미지의 URL과 이미지의 메타데이터들을 보관하기로 되어있었는데, common 패키지에 FileController - FileService를 통해서 이미 이미지를 저장하고 가져오는 로직이 구현되어있었고, FileService에서는 파일의 원본 이름을 받아 이것을 UUID로 변환하고 이미지 URL을 반환할 때 URL + 변환환 UUID를 합쳐서 이미지 URL로 반환하고 있었다.

 

나는 이것이 이미 구현되어있었고 FileController에서 아래처럼 이미 매핑이 되어있어 클라이언트에서 이미지가 저장되는 순간에 바로 이미지가 업로드 된다고 이해하여 이렇게 하는 방식이 있는지 GPT와 검색을 통해 검증을 해보고 실제로 이렇게도 구현한다는 것을 알게되었다!

@PostMapping(path = "/upload", consumes = {MediaType.MULTIPART_FORM_DATA_VALUE})
public String uploadFile(@RequestParam("file") MultipartFile file) throws IOException {
    String uploadUrl = fileUploadService.uploadFile(file);
    return "파일이 스토리지에 업로드 되었습니다. UploadUrl: " + uploadUrl;
}

 

그래서 나는 이것을 활용하기로 마음을 먹고 클라이언트에서의 이미지 저장과 게시글 저장을 별도의 API로 취급하여 개발을 진행하였고 이미지를 저장 후 파일 반환되는 문자열을 클라이언트가 가공하여 이미지 URL과 이미지의 파일이름 정보를 포함한 ERD에 저장할 이미지 메타데이터들을 넘겨주면 되겠구나! 라고 생각하였지만 이것은 나중에 내가 다시 개발하게 되는 원인이 되었다.(이것은 뒤에서 다시 회고를 작성)

 

일단 나는 클라이언트가 문자열 정보를 가공해서 넘긴다는 가정(?)으로 개발을 하였고 카테고리 검증, 이미지는 최대 5개까지 저장 등의 검증을 추가하여 개발을 끝냈다

 

PostCreateRequestDTO라는 클래스에 클라이언트에서 받아야할 정보를 정의하여 컨트롤러에서 @RequestBody로 정보를 받도록 하였고 서비스에서는 실제 게시글을 생성하고, 게시글을 생성하는 메서드에서 다시 이미지를 생성하는 메서드를 추가로 작성하는 방식으로 구현하였다

 

PostController - createPost

 

더보기
@RequireUser
@PostMapping("/{id}")
public ResponseEntity<MainResponse<Long>> createPost(@PathVariable Long id,
                                       PrincipalUser principalUser,
                                       @Valid @RequestBody PostCreateRequestDTO requestDTO) {
    Long postId = postService.createPost(requestDTO, principalUser);
    return ResponseEntity.status(HttpStatus.CREATED).body(MainResponse.getSuccessResponse(postId));
}

 

PostService - createPost / saveImage

 

더보기
// 글과 이미지를 생성하는 service로직
@RequireUser
@Transactional
public Long createPost(PostCreateRequestDTO postCreateRequestDTO, PrincipalUser principalUser) {
    User user = principalUser.getUser();

    Category category = categoryRepository.findById(postCreateRequestDTO.getCategoryId())
            .orElseThrow(() -> new IllegalArgumentException("카테고리를 찾을 수 없습니다. ID: " + postCreateRequestDTO.getCategoryId()));

    Post post = getPost(postCreateRequestDTO, user, category);

    if (postCreateRequestDTO.getImagelist() != null && !postCreateRequestDTO.getImagelist().isEmpty()) {
        validateImageCount(postCreateRequestDTO.getImagelist());    // 5개 이상이면 예외 발생
        saveImage(postCreateRequestDTO, user, post);
    }
    postRepository.save(post);
    return post.getId();
}

// 이미지 생성을 메서드로
private void saveImage(PostCreateRequestDTO postCreateRequestDTO, User user, Post post) {
    for (ImageCreateRequestDTO getImage : postCreateRequestDTO.getImagelist()) {
        Image image = Image.builder()
                .user(user)
                .fileName(getImage.getFileName())
                .imageUrl(getImage.getImageUrl())
                .originalName(getImage.getOriginalFileName())
                .fileType(getImage.getFileType())
                .fileName(getImage.getFileName())
                .fileSize(getImage.getFileSize())
                .deletedAt(null)
                .build();
        imageRepository.save(image);

        post.addCommunityImage(CommunityImage
                .builder()
                .image(image)
                .build());
    }
}

// 글 생성을 메서드로
private Post getPost(PostCreateRequestDTO postCreateRequestDTO, User user, Category category) {
    return Post.builder()
            .user(user)
            .category(category)
            .title(postCreateRequestDTO.getTitle())
            .content(postCreateRequestDTO.getContent())
            .views(0L)
            .likes(0L)
            .build();

}

// 이미지 검증
private void validateImageCount(List<ImageCreateRequestDTO> imageList) {
    if (imageList != null && imageList.size() > 5) {
        throw new FileException.FileUploadException("5개 이상 이미지를 등록할 수 없습니다.");
    }
}

 

 

글로쓰니 엄청 1분만에 끝났는데 실제 게시글생성 api를 생성하는데 여러 삽질하는 시간 포함하여 6시간은 족히 쓴 것 같았지만 그래도.. 필터링기능이 들어간 게시글 전체 조회 API보다는 시간이 적게 들었고 어느정도 조금씩 감이 잡히다는 생각이 들었다


2) 수정과 삭제 - 1차 개발(실수 투성이)

수정은 Create와 매우 흡사한 로직으로 개발을 진행하였고 게시글 생성을 할 때 보다는 생각하는 시간이 많이 줄어들어서 작업시간이 많이 단축됨을 느꼇다

 

컨트롤러에서 게시글의 id정보를 추가로 받는 것과 서비스 로직에서 id를 조회하는 로직이 추가되고 일부 에러발생시 멘트를 수정하는 것과 JPA의 변경감지를 이용하여 update를 진행할 것이기에 별도로 save를 하지않는다는 점을 제외하면 대부분은 create와 흡사하다고 생각이 들었다

 

그러나 Update는 이미지를 검증하려고 하는 과정에서 시간을 매우 쏟았다

이미지를 수정하려고 할 때 이미 게시글에 저장되어있던 이미지가 전부 삭제되어 넘어올 수도있고, 일부분만 삭제될 수도있고, 새로운 이미지가 추가될 수도 있고.. 이부분을 검사를 하고 바뀐 부분만 저장을 하는 것이 좋지 않을까라고 생각이들어 어떻게 구현해야할지 한참 시간이 걸렸다.

 

넘어온 이미지를 반복문을 통해 리포지토리를 통해 계속 네트워크가 발생되는 것과 수정이 안된 이미지 정보도 DB에 저장 요청을 하는 것은 비효율 적이라고 생각이 들었기 때문이다.

 

그래서.. 여러 방법을 찾아보고 GPT와 논의를 한 끝에 일단 게시글에 저장된 이미지의 파일네임(UUID로 변환된 값)을 조회하고 stream 문법의 partitioningBy 문법을 통해서 컨트롤러에서 넘어온 파일네임과, DB에서 조회된 파일네임을 비교하여 같은것과 다른것을 분리하였다.

 

이렇게 partitioningBy에서 false로 분류된 대상은 DB에서 조회된 파일네임과 컨트롤러에서 넘어온 파일네임이 다르다는 뜻이므로, 여기에서 컨트롤러에서 넘어온 파일네임들을 제외한 대상을 삭제 대상으로 지정하였다.

 

그리고 여기서 엄청난 비즈니스 로직 실수를 저지르고 말았는데, 이때당시에 개발했을 때는 전혀 몰랐고.. 지금 글쓰면서 알게 되었다.

(현업에서 이랬으면 어쩌려고...... 테스트가 정말 중요하다는 것을 다시금 깨닫게 되었다)

 

partitioningBy에서 true로 분류된 대상은 이미 기존에 존재하는 대상이므로 저장에서 제외해야했고, false로 분리된 부분 중 컨트롤러에서 넘어온 파일네임들을 새로운 이미지로 판단하여 이들을 저장했어야하는데.. 실수로 true로 넘어온 대상들(중복대상)을 새로운 이미지로 판단하고 저장하도록 개발한 것이였다..

 

물론 이후에 저장방식이 완전히 바뀌어서 이부분이 잘못된지도 모른채 넘어갔고 이후에 개발된 항목은 테스트까지 진행하여 정상 동작을 확인하여 문제가 없었지만, 아무리 급하고 답답해도 최소한으로 기본적인 테스트는 꼭 하고 넘어가야겠다는 생각이 들었다.

 

그리고 updatePost에서 Image 수정, 삭제에 너무 매몰되어 쉽게 Image를 삭제할 수 있는 방법이 있음에도 불구하고 이때에는 직접 DB의 delete_At필드에 @Query를 통해 삭제일시를 입력하고, @Modifying 애노테이션으로 영속성 컨텍스트로 관리되도록 작성하였다.

 

일단 작성하였으니 나중에도 이부분을 사용하긴하는데 다음에는 이렇게 작업하지는 않고 아래 deletePost에서 했던 것처럼 엔티티에 작성한 softdelete 메서드를 활용할 것 같다

 

PostController - updatePost

 

더보기
@RequireUser
@PutMapping("/{id}")
public ResponseEntity<MainResponse<Long>> updatePost(@PathVariable Long id,
                                       PrincipalUser principalUser,
                                       @Valid @RequestBody PostUpdateRequestDTO requestDTO) {
    Long updatePostId = postService.updatePost(requestDTO, principalUser, id);
    return ResponseEntity.ok(MainResponse.getSuccessResponse(updatePostId));
}

 

ImageRepository - update이미지에 사용된 쿼리들..

 

더보기
package com.ani.taku_backend.common.repository;

public interface ImageRepository extends JpaRepository<Image, Long> {

    @Query("select i.fileName from Image i join CommunityImage ci on i.id = ci.image.id where ci.post.id = :postId")
    List<String> findFileNamesByPostId(@Param("postId") Long postId);

    @Modifying
    @Query("update Image i set i.deletedAt = current_timestamp where i.fileName in :fileNames")
    void softDeleteByFileNames(@Param("fileNames") List<String> fileNames);
}

 

PostService - updatePost / updateImage

 

더보기
@RequireUser
@Transactional
public Long updatePost(PostUpdateRequestDTO postUpdateRequestDTO, PrincipalUser principalUser, Long postId) {
    User user = principalUser.getUser();
    Post post = postRepository.findById(postId)
            .orElseThrow(() -> new PostException.PostNotFoundException("ID: " + postId));

    if (!user.getUserId().equals(post.getUser().getUserId())) {
        throw new PostException.PostAccessDeniedException("게시글을 수정할 권한이 없습니다.");
    }
    Category newCategory = null;
    if (postUpdateRequestDTO.getCategoryId() != null && !postUpdateRequestDTO.getCategoryId().equals(post.getCategory().getId())) {
        newCategory = categoryRepository.findById(postUpdateRequestDTO.getCategoryId())
                .orElseThrow(() -> new IllegalArgumentException("카테고리를 찾을 수 없습니다. ID: " + postUpdateRequestDTO.getCategoryId()));
    }
    post.updatePost(postUpdateRequestDTO.getTitle(), postUpdateRequestDTO.getContent(), newCategory);
    validateImageCount(postUpdateRequestDTO.getImagelist());    // 5개 이상이면 예외 발생
    updateImage(postUpdateRequestDTO, user, post);

    return post.getId();
}

private void updateImage(PostUpdateRequestDTO postUpdateRequestDTO, User user, Post post) {
    List<String> fileNameByPostIdList = imageRepository.findFileNamesByPostId(post.getId());

    // 기존 게시글에 저장된 이미지와 요청으로 들어온 이미지의 파일네임을 비교하여 같은것과 다른 것을 분리
    Map<Boolean, List<ImageCreateRequestDTO>> partitionedImages = postUpdateRequestDTO.getImagelist()
            .stream()
            .collect(Collectors.partitioningBy(
                    imageDTO -> fileNameByPostIdList.contains(imageDTO.getFileName())));

    // 이미지 삭제 대상: 기존 게시글에 저장된 이미지와 분리된 파티션의 fasle인 대상중에서 요청으로 넘어온 대상을 제외한 대상
    List<String> deleteFileNameList = fileNameByPostIdList
            .stream()
            .filter(fileName -> partitionedImages.get(false)
                    .stream()
                    .noneMatch(imageDTO -> imageDTO.getFileName().equals(fileName)))
            .toList();

    // 새로 추가된 이미지 정보만 담긴 ImageCreateRequestDTO
    List<ImageCreateRequestDTO> newImageCreateRequestDTO = partitionedImages.get(true);

    // 이미지 소프트 딜리트
    if (!deleteFileNameList.isEmpty()) {
        imageRepository.softDeleteByFileNames(deleteFileNameList);
    }

    if (!newImageCreateRequestDTO.isEmpty()) {
        for (ImageCreateRequestDTO newImageDTO : newImageCreateRequestDTO) {
            Image image = Image.builder()
                    .user(user)
                    .fileName(newImageDTO.getFileName())
                    .imageUrl(newImageDTO.getImageUrl())
                    .originalName(newImageDTO.getOriginalFileName())
                    .fileType(newImageDTO.getFileType())
                    .fileSize(newImageDTO.getFileSize())
                    .build();

            imageRepository.save(image);

            post.addCommunityImage(
                    CommunityImage.builder()
                            .image(image)
                            .post(post)
                            .build()
            );
        }
    }

}

 

 

 

이렇게 잘못된 게시글 업데이트 개발...? 을 마무리 짓고 게시글 삭제 API를 개발에 착수하였다

softdelete를 진행해야하므로 DB 데이터에 delete_at 필드에 삭제일시를 입력하고 연관관계를 끊어서 조회되지 않도록하고 DB에는 데이터를  삭제하지 않도록 적용하였고 다른 API개발에 비해 비교적 수월하게 진행 할 수 있었다

 

여기에서는 이미지 엔티티에 softdelete를 작성하고 객체그래프 탐색을 통해 softdelete를 호출하여 반영되도록 하였다.

 

PostController - deletePost

 

더보기
@RequireUser
@DeleteMapping("/{id}")
public ResponseEntity<MainResponse<Void>> deletePost(@PathVariable Long id,
                                                                    PrincipalUser principalUser) {
    postService.deletePost(id, principalUser);
    return ResponseEntity.noContent().build();
}

 

PostService - deletePost

 

더보기
@Transactional
public void deletePost(Long postId, PrincipalUser principalUser) {
    User user = principalUser.getUser();
    Post post = postRepository.findById(postId)
            .orElseThrow(() -> new PostException.PostNotFoundException("ID: " + postId));

    if (!user.getUserId().equals(post.getUser().getUserId())) {
        throw new PostException.PostAccessDeniedException("게시글을 삭제할 권한이 없습니다.");
    }
    post.softDelete();
    post.getCommunityImages().forEach(communityImage -> {
        communityImage.getImage().softDelete();
        post.removeCommunityImage(communityImage);
    });
}

 

이렇게 게시글 삭제까지 완성하여 1차로 CUD API 개발을 완성하고 PR을 요청하였다.

우선 게시글 상세조회를 다른분이 맡아서 진행하기로 하였기에 그것과 합쳐서 통합테스트를 진행해야겠다는 생각으로 개발이 들어가다보니 개발과정에서 상당히 실수가 많았다는 것을 알았고, 개발하면서 더 좋은 방법이 계속 발견된다는 것을 알게 되었다.

 

무엇이 더 좋은지는 모르겠지만 일단 개발 속도도 중요하다고 생각되어 내가 고민할 수 있는 부분에선 최대한 좋은 코드를 짜는 것을 목표로 하겠으나, 너무 고민만하고 개발이 늦어지고 있다는 생각이 많이 들었다.

시간이 지나 익숙해지고 몸에 익은 개발 지식이 많아지면 고민하는 분야가 줄어들긴 하겠지만 지금의 시점에서는 이부분이 개발할 때 가장 힘든 부분인 것 같다

 

지금 상황에서는 어느정도 트레이드 오프가 필요하다고 판단이 들어 우선은 적당히 고민하고 개발을 착수하고 그 이후에 리펙토리를 하는 방향으로 개발 방향을 잡아야 겠다고 생각이 들었다.