[스프링] Post-Comment DTO 만들기, 양방향 무한참조 현상 해결하기

사이드 프로젝트로 워크북 서비스를 만들던 중.. 팀원으로부터 기묘한 일이 생겼다고 연락이 왔습니다.

포스트 정보를 가져오는 GET post API의 Response 데이터 중 comments에서 이상한 이슈가 발생했기 때문입니다.

JSON의 구조를 살펴보면,

Post 정보 > Comments 정보 > Comments가 작성된 Post 정보 > Post의 User 정보 >
User의 Like Post 정보 > User 정보 > User의 Like Post 정보 ...

Comment를 달고 나서 포스트를 불러왔을 때, 위와 같은 형태로 계속해서 정보를 가져오는 버그가 있었습니다.

위의 구조를 살펴보았을 때, 변경해야할 부분이 두 부분 있었습니다.

  1. GET Post API에서 받아오는 Comment 정보 축소
  2. User에 PostLikeList를 넣으면서 발생한 무한 루프 없애기

1번의 경우, "Comments 정보 > Comments가 작성된 Post 정보" 부분이 이상하다고 생각했습니다. 애초에 Comment가 작성된 Post의 값을 가져오는 것이기 때문에 필요없는 데이터라고 판단되었습니다.

2번의 경우, User와 PostLike 모델을 양방향으로 설계하면서 발생한 문제였습니다. User가 좋아하는 포스트를 관리하기 위해 User Entity에 PostLikeList를 추가해서 관리하고 있었습니다. 그러나 PostLike에 Post, User의 정보가 존재하다보니 User -> PostLike -> User -> PostLike ... 의 무한 참조 현상이 발생하게 된 것입니다.

-> 이를 해결하기 위한 방법으로는 (https://www.baeldung.com/jackson-bidirectional-relationships-and-infinite-recursion)

  • Entity로 반환하지 않고, DTO를 적극 활용
  • Json으로 직렬화 할 속성에서 무시 해버리기 (@JsonIgnore)
  • 직렬화할 대상 객체의 toString override하여 재정의하기
  • @JsonManagedReference, @JsonBackReference 어노테이션으로, 직렬화 방향을 설정을 통해 해결
  • @JsonIdentityInfo을 통해 순환참조될 대상의 식별키로 구분해 더이상 순환참조되지 않게 하기

등이 있지만, PostLikeList를 굳이 관리할 필요가 없다고 생각해서(추후에 마이페이지 구현 시 사용하기 위해 넣은 컬럼이었는데, 좋아하는 포스트만 반환하는 GET API를 구성하는 것이 나을 것 같다고 생각했습니다) 가차없이 빼버렸습니다.

1번 문제는 Sprint 4를 마치면서 Comment 기능을 추가한 뒤에 Post에서 CommentList를 불러오는 로직에서 DTO를 적용함으로써 고칠 수 있었습니다.

<수정 전 Post Response 코드>

@Getter
@Setter
public class PostResponse {
    private final Long postId;
    private final Long authorId;
    private final String authorName;
    private final String title;
    private final String description;
    private final String startLocation;
    private final String finishLocation;
    private final String tmi;
    private final String createdDate;
    private final String modifiedDate;
    private Boolean liked;
    private final Long likeCount;
    private final Long commentCount;
    private final List comments;
    private List comments;

    public PostResponse(Post post) {
        DateTimeFormatter format = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
        this.postId = post.getPostId();
        this.authorId = post.getUser().getUserId();
        this.authorName = post.getUser().getNickname();
        this.title = post.getTitle();
        this.description = post.getDescription();
        this.startLocation = post.getStartLocation();
        this.finishLocation = post.getFinishLocation();
        this.tmi = post.getTmi();
        this.createdDate = post.getCreatedDate().format(format);
        this.modifiedDate = post.getModifiedDate().format(format);
        this.liked = false;
        this.likeCount = post.getLikeCount();
        this.commentCount = post.getCommentCount();
        this.comments = post.getCommentList(); //get Comment List
    }

위 코드처럼 처음에는 이렇게 post에서 getCommentList로 뿌려주려 했습니다. post와 comment를 1:N, N:1로 매핑하면서 post에 commentList로 작성된 comment를 관리하고 있었기 때문입니다.

그러나 Comment Entity를 보면

<수정 전 Post Comment Entity 코드>

package walkbook.server.domain;

import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.*;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import javax.persistence.*;

import java.time.LocalDateTime;

import static javax.persistence.FetchType.LAZY;

@Entity
@Table(name = "postcomment")
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@EntityListeners(AuditingEntityListener.class)
public class PostComment {
    @JsonIgnore
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long commentId;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "postId")
    private Post post;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "userId")
    private User user;

    private String content;

    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdDate;

    @LastModifiedDate
    private LocalDateTime modifiedDate;
}

Post, User 정보까지 전부 다 가져오다 보니 필요없는 정보까지 가져오게 된 것임을 알 수 있습니다.
필요한 정보만을 가져오기 위해 Comment DTO를 만들기로 결정했습니다.

<수정 후 Post Response 코드>

package walkbook.server.dto.post;

import lombok.Getter;
import lombok.Setter;
import walkbook.server.domain.Post;

import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;

@Getter
@Setter
public class PostResponse {
    private final Long postId;
    private final Long authorId;
    private final String authorName;
    private final String title;
    private final String description;
    private final String startLocation;
    private final String finishLocation;
    private final String tmi;
    private final String createdDate;
    private final String modifiedDate;
    private Boolean liked;
    private final Long likeCount;
    private final Long commentCount;
    private List<PostCommentResponse> comments; //List -> List<PostCommentResponse>

    public PostResponse(Post post) {
        DateTimeFormatter format = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
        this.postId = post.getPostId();
        this.authorId = post.getUser().getUserId();
        this.authorName = post.getUser().getNickname();
        this.title = post.getTitle();
        this.description = post.getDescription();
        this.startLocation = post.getStartLocation();
        this.finishLocation = post.getFinishLocation();
        this.tmi = post.getTmi();
        this.createdDate = post.getCreatedDate().format(format);
        this.modifiedDate = post.getModifiedDate().format(format);
        this.liked = false;
        this.likeCount = post.getLikeCount();
        this.commentCount = post.getCommentCount();
        this.comments = new ArrayList<>();
    }
}

이렇게 PostCommentResponse를 가지는 List로 Comment를 구성해 주었습니다.

또한 기존에 raw type이었던 List의 타입을 지정해주면서 안정성도 보장할 수 있게 되었습니다!
-> raw type을 사용하여 잘못된 타입을 코드에 작성할 경우 그 내용에 대한 에러를 런타임 시에 잡게 되지만 Generic을 사용하였을 경우에는, 오류를 컴파일 시 즉시 잡아낼 수 있다는 점에서 안전성을 보장할 수 있습니다.
(https://ojava.tistory.com/27)

<생성한 Post Comment Response>

package walkbook.server.dto.post;

import lombok.*;
import walkbook.server.domain.PostComment;

import java.time.format.DateTimeFormatter;

@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PostCommentResponse {
    private Long commentId;
    private Long postId;
    private Long authorId;
    private String authorName;
    private String content;
    private String createdDate;
    private String modifiedDate;

    public PostCommentResponse(PostComment postComment){
        DateTimeFormatter format = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
        this.commentId = postComment.getCommentId();
        this.postId = postComment.getPost().getPostId();
        this.authorId = postComment.getUser().getUserId();
        this.authorName = postComment.getUser().getNickname();
        this.content = postComment.getContent();
        this.createdDate = postComment.getCreatedDate().format(format);
        this.modifiedDate = postComment.getModifiedDate().format(format);
    }

    public static PostCommentResponse fromEntity(PostComment postComment){
        DateTimeFormatter format = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
        return PostCommentResponse.builder()
                .commentId(postComment.getCommentId())
                .postId(postComment.getPost().getPostId())
                .authorId(postComment.getUser().getUserId())
                .authorName(postComment.getUser().getNickname())
                .content(postComment.getContent())
                .createdDate(postComment.getCreatedDate().format(format))
                .modifiedDate(postComment.getModifiedDate().format(format))
                .build();
    }
}

이렇게 Comment의 필요한 값만 담길 수 있도록 Response DTO를 만들어주었습니다.

<PostService - getPostById 코드>

    @Transactional(readOnly = true)
    public PostResponse getPostById(UserDetails requestUser, Long postId) {
        Post post = postRepository.findById(postId).orElseThrow(CPostNotFoundException::new);
        return getPostResponseByRequestUser(requestUser, post);
    }

<PostService - getPostResponseByRequestUser 코드>

    private PostResponse getPostResponseByRequestUser(UserDetails requestUser, Post post) {
        PostResponse postResponse = new PostResponse(post);
        List<PostCommentResponse> postCommentList = post.getCommentList().stream().map(PostCommentResponse::fromEntity).collect(Collectors.toList());
        postResponse.setComments(postCommentList);
        if (requestUser != null) {
            User user = userService.findByUsername(requestUser.getUsername());
            if (isLiked(user, post)) {
                postResponse.setLiked(true);
            }
        }
        return postResponse;
    }

post에 저장되어있는 commentList를 PostCommentResponse형태로 변경시켜 postCommentList로 가져오고, 이를 postResponse에 set하는 방식으로 문제를 해결할 수 있었습니다.

<변경 후 Json Response>

정상적으로 원하는 값만 가져오는 것을 확인할 수 있습니다!

전체 소스는 https://github.com/walkbook/walkbook-backend/tree/master/src/main/java/walkbook/server 에서 볼 수 있습니다 :)

좋은 웹페이지 즐겨찾기