본문으로 바로가기

[PE-Community] 계층형 댓글 구현

category Project/PE-Community 2023. 11. 9. 21:13
회원만 게시글의 댓글을 작성할 수 있고, 자신이 작성한 댓글만 수정 및 삭제가 가능하다. 이는 요청 시 헤더에 토큰을 넣으면 이 값으로 로그인한 회원의 id 정보를 가져올 수 있다. 토큰의 회원 정보와 댓글을 작성한 회원이 같다면 수정 및 삭제가 가능하도록 할 것이다. 댓글을 삭제할 때, 하위의 대댓글이 모두 삭제된 경우에만 DB에서 데이터를 삭제하며 대댓글이 존재하는 경우에는 삭제된 댓글로 보여줄 것이다. 또한, 회원은 비밀 댓글을 달 수 있는데 이 비밀 댓글은 게시글을 작성한 회원과 댓글을 작성한 회원만이 볼 수 있다. 댓글은 계층형으로 대댓글을 달 수 있고, 댓글의 레벨은 2까지만 제한하려고 한다.

 

목차

  • 댓글 등록(계층형, 댓글 레벨 제한) 구현
  • 댓글 수정 및 삭제 구현
  • 댓글 조회 구현(QueryDSL 이용)
  • 생성 및 수정일자 자동화
  • 회고

 

댓글 등록(계층형, 댓글 레벨 제한) 구현

댓글 1
    댓글 1 - 1
        댓글 1 - 1 - 1
댓글 2
    댓글 2 - 1
        댓글 2 - 1 - 1
    댓글 2 - 2
  • 부모 댓글은 자식 댓글을 여러 개 가질 수 있고, 한 자식 댓글은 한 개의 부모 댓글을 가진다. 이 때문에 댓글 엔티티는 부모 엔티티와 자식 엔티티가 1:N의 연관관계를 가져야 한다.
  • 단순 id 값은 데이터를 저장하는 순서로 저장되기 때문에 댓글의 레벨을 알 수 없다. 또한, 댓글 레벨만 존재한다면 해당 댓글이 어느 댓글의 자식인지 알 수 없기 때문에 부모 댓글의 id도 함께 가지고 있어야 한다.
ID 댓글 내용 레벨 부모 ID
1 댓글 1 0 null
2 댓글 2 0 null
3 댓글 1 - 1 1 1
4 댓글 2 - 1 1 2
5 댓글 2 - 2 1 2
6 댓글 1 - 1 - 1 2 3
7 댓글 2 - 1 - 1 2 4
  • [고려] 댓글 삭제의 경우
    • 한 댓글마다 자식 댓글을 여러 개 가질 수 있기 때문에 댓글을 삭제할 때 고민을 해보아야 한다.
    • 해당 댓글이 부모 댓글과 자식 댓글이 모두 있는 중간 레벨의 댓글일 수도 있다.
    • 만약 자식 댓글이 없는 마지막 레벨 댓글이라면 DB 상에서 데이터를 아예 삭제할 수 있지만, 자식 댓글이 있다면 해당 데이터만 삭제할 경우 문제가 발생할 수 있다. 자식 댓글의 부모 ID로 등록되어있는데 DB에서 데이터가 없다면 조회할 경우 문제가 발생할 수 있다.
    • 이 때문에 자식 댓글이 있는 경우에는 DB에서 데이터를 삭제하지 않고, 삭제 여부만 판별하여 삭제된 댓글입니다.라고 보여주는게 더 좋을 것 같다는 생각이 들었다. 이후 자식의 댓글이 DB 상에서 아예 없거나, 자식 댓글 전부다 삭제된 댓글일 경우에만 해당 댓글과 자식 댓글을 DB 상에서 삭제하도록 구현하였다.
  • [고려] 비밀 댓글의 경우
    • 비밀 댓글도 삭제된 댓글과 비슷한 로직을 가진다. 비밀 댓글인지 구분하는 필드를 가지고, 비밀 댓글일 경우에는 해당 게시글의 작성자와 댓글의 작성자만이 비밀 댓글 내용을 볼 수 있도록 구현하였다. 이외의 회원들은 비밀 댓글입니다.라는 문구로 댓글 내용이 보여지도록 할 것이다.

Comment

@Entity
@Table
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Comment {

    @Id @GeneratedValue
    private Long id;

    @JoinColumn(name = "member_id")
    @ManyToOne(fetch = FetchType.LAZY)
    private Member member;

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

    @JoinColumn(name = "parent_id")
    @ManyToOne(fetch = FetchType.LAZY)
    private Comment parent;

    @OneToMany(mappedBy = "parent", orphanRemoval = true)
    private List<Comment> children = new ArrayList<>();

    private String content;
    private int level;
    
    private LocalDateTime createDate;
    private LocalDateTime updateDate;

    private Boolean isRemoved;
    private Boolean isSecret;

    @Builder(builderMethodName = "createCommentBuilder")
    public Comment(String content, Member member, Post post) {
        this.content = content;
        this.member = member;
        this.post = post;
        this.level = 0;
        this.isRemoved = false;
        this.isSecret = false;
        this.createDate = LocalDateTime.now();
        this.updateDate = LocalDateTime.now();

        //==연관관계 편의 메서드==//
        post.getComments().add(this);
        member.getComments().add(this);
    }

    public void addParent(Comment parent) {
        this.parent = parent;
        parent.getChildren().add(this);
        this.level = parent.getLevel() + 1;
    }
    
    public void changeIsRemoved(boolean isRemoved) {
        this.isRemoved = isRemoved;
    }

    public void changeSecret(boolean isSecret) {
        this.isSecret = isSecret;
    }

}
  • 부모 댓글과 자식 댓글이 연관관계를 가지기 때문에 Comment 엔티티에서 부모와 자식을 모두 가지고 있어야 한다.
  • addParent(): 등록하려는 댓글에 부모 댓글이 있는 경우, 해당 댓글과 부모 댓글 사이의 관계를 매핑하여준다. 또한, 부모의 레벨 + 1을 해서 해당 댓글의 레벨을 지정해준다.

 

CommentService

@Slf4j
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class CommentService {

    private final CommentRepository commentRepository;
    private final PostRepository postRepository;
    private final MemberRepository memberRepository;
    private static final int MAX_COMMENT_LEVEL = 2;

    @Transactional
    public Long save(Long postId, CommentRequestDto requestDto) {
        Member member = memberRepository.findById(requestDto.getMemberId())
                .orElseThrow(() -> new BaseException(MEMBER_NOT_EXIST));

        Post findPost = postRepository.findById(postId)
                .orElseThrow(() -> new BaseException(POST_NOT_EXIST));

        Comment comment = Comment.createCommentBuilder()
                .content(requestDto.getContent())
                .member(member)
                .post(findPost)
                .build();

        if(requestDto.getIsSecret() != null) {
            comment.changeSecret(requestDto.getIsSecret());
        }

        if(requestDto.getParentId() != null) { // 대댓글인 경우
            Comment parent = getParent(postId, requestDto.getParentId());
            comment.addParent(parent);
        }

        if(comment.getLevel() > MAX_COMMENT_LEVEL) { // 댓글 레벨 제한
            throw new BaseException(COMMENT_LEVEL_EXCEED);
        }

        commentRepository.save(comment);
        return comment.getId();
    }
    
    private Comment getParent(Long postId, Long parentId) {
        Comment parent = commentRepository.findById(parentId)
                    .orElseThrow(() -> new BaseException(PARENT_COMMENT_NOT_EXIST));
        if(parent.getPost().getId() != postId) { // 부모와 자식 댓글의 게시글이 같은지 확인
            throw new BaseException(COMMENT_NOT_SAME_POST);
        }
        return parent;
    }
}
  • 등록하려는 댓글의 게시글 id와 댓글 작성자 회원의 id로 Comment 인스턴스를 생성하고, 해당 댓글의 비밀 여부를 변경한다.
  • 해당 댓글이 대댓글인 경우에는 부모 댓글의 id를 이용하여 부모 댓글을 찾고, 해당 댓글과 부모 댓글을 관계를 매핑하여 준다.
  • 이때 댓글의 레벨을 제한하였기 때문에 최대 댓글 레벨을 초과하지 않았다면 댓글을 저장한다.

 

댓글 수정 및 삭제 구현

댓글 수정

Comment

public class Comment
    ...
    
    public void update(String content) {
        this.content = content;
        this.updateDate = LocalDateTime.now();
    }
    ...
}
  • 댓글은 댓글 내용비밀 댓글 여부만 수정이 가능하다.

 

CommentService

 public class CommentService {
    ... 
    
    @Transactional
    public Long update(Long commentId, CommentRequestDto requestDto) {
        Comment comment = commentRepository.findById(commentId)
                .orElseThrow(() -> new BaseException(COMMENT_NOT_EXIST));

        SecurityUtil.checkAuthorizedMember(comment.getMember().getMemberId());

        comment.update(requestDto.getContent());

        if(requestDto.getIsSecret() != null) {
            comment.changeSecret(requestDto.getIsSecret());
        }

        return comment.getId();
    }
     
    ...
}
  • 수정하려는 댓글을 찾고, 해당 댓글의 작성자와 로그인되어 있는 회원이 같다면 댓글을 수정한다.

 

댓글 삭제

CommentService

 public class CommentService {
    ... 
    
    @Transactional
    public void delete(Long commentId) {
        Comment comment = commentRepository.findById(commentId)
                .orElseThrow(() -> new BaseException(COMMENT_NOT_EXIST));

        SecurityUtil.checkAuthorizedMember(comment.getMember().getMemberId());

        comment.changeIsRemoved(true);

        deleteRootComment(comment); // 최상위 댓글 delete or not
    }
    
    private void deleteRootComment(Comment comment) {
        Comment root = findRootComment(comment);
        if(!root.getIsRemoved()) return;
        int cnt = countNotRemovedChild(root);
        if(cnt == 0) {
            commentRepository.deleteById(root.getId());
        }
    }

    private Comment findRootComment(Comment curr) {
        if(curr.getParent() == null) return curr;
        return findRootComment(curr.getParent());
    }

    private int countNotRemovedChild(Comment parent) {
        if(parent == null) return 0;
        int cnt = 0;
        if(parent.getChildren().size() > 0) {
            for (Comment child : parent.getChildren()) {
                if (!child.getIsRemoved()) cnt += 1;
                cnt += countNotRemovedChild(child);
            }
        }
        return cnt;
    }
     
    ...
}
  • 댓글을 삭제하는 부분을 가장 많이 고민했었는데, 삭제하려는 댓글의 모든 자식 댓글도 삭제된 경우에만 DB에서 데이터를 삭제하기로 하였다. 또한, 이를 구현하기 위해서 어떻게 로직을 짤지도 많이 고민하였다.
  • DB에서 데이터를 아예 삭제하는 경우는 해당 댓글의 레벨에 따라 로직이 달라진다고 생각하였다.
    • 최상위 댓글인 경우: 자식 댓글이 모두 삭제된 경우에만, 최상위 댓글과 모든 자식 댓글을 DB에서 삭제한다.
    • 최하위 댓글인 경우: 해당 댓글을 DB에서 삭제한다.
    • 중간 레벨 댓글인 경우:  자식 댓글들 모두가 삭제된 경우에만, 중간 레벨 댓글과 자식 댓글을 DB에서 삭제한다.
  • [고민] 중간 또는 최하위 레벨의 경우, 부모 댓글까지 고려해야 하는가?
    • 부모 댓글을 고려하지 않고, 자식 댓글만 고려한다면 삭제하려는 댓글 A의 모든 자식 댓글이 삭제된 경우에만 A 댓글과 함께 A의 자식 댓글을 DB에서 삭제할 수 있다. 자식도 함께 삭제하지 않고, A 댓글만 삭제하게 되면 A 댓글을 부모 댓글로 저장해둔 자식 댓글들은 나중에 부모 댓글을 찾으려고 할 때 DB에서 찾을 수 없게 된다. 결국 문제가 발생하는데 자식 댓글을 함께 삭제하면 이 문제는 생기지 않는다.
    • 그렇다면 A 댓글을 자식으로 둔 그 상위의 댓글들은 자식 댓글들이 DB에서 사라지게 되고, 삭제되었다는 대댓글을 보여줄 수 없게 된다. 이왕이면 자식 댓글들이 모두 삭제되었더라도 삭제되었다고 보여주고 싶어서 부모 댓글까지 고려해서 삭제를 해야겠다고 생각했다. 사실 이 부분은 내가 구현하기 나름이었기 때문에 부모 댓글도 모두 삭제되어야만 댓글들을 DB 상에서 삭제하도록 하였다.
    • 이렇게 되면 결국에는 최상위 댓글이 삭제될 경우만 고려해주면 된다. 중간 레벨과 하위 레벨 댓글들이 모두 삭제되었다고 해도 상위 댓글이 있다면 DB에서는 삭제가 되지 않는다.
      • 최상위 댓글의 자식 댓글이 없거나 모두 삭제된 자식 댓글인 경우 : 부모 및 모든 대댓글의 DB 삭제 O -> delete
      • 삭제되지 않은 자식 댓글이 존재하는 경우: DB 삭제 X, "삭제된 댓글입니다"라고 표시 -> remove
  • deleteRootComment(): 삭제하려는 댓글의 최상위 댓글을 찾고, 최상위 댓글의 모든 자식 댓글이 삭제된 경우에만 DB에서 삭제한다.
    • findRootCommnet(): 해당 댓글의 최상위 부모 댓글을 재귀로 찾는다.
    • countNotRemovedChild(): 최상위 댓글에서 삭제되지 않은 자식 댓글의 개수를 재귀로 구한다.
    • 이때 삭제되지 않은 자식 댓글이 하나도 없는 경우 즉, 자식 댓글이 모두 삭제된 경우 최상위 부모 댓글을 삭제한다.
  • [참고] 왜 부모 댓글만 삭제할까?
    • @OneToMany(mappedBy = "parent", orphanRemoval = true) private List<Comment> children = new ArrayList<>();
      • CascadeType.REMOVE
        • 부모가 자식의 삭제 생명 주기를 관리하기 때문에 부모 엔티티가 삭제되면 자식 엔티티도 삭제된다.
        • 하지만 이 경우, 부모 엔티티가 자식 엔티티와의 관계를 제거하고 자식 엔티티가 삭제되지 않고 그대로 남아있게 된다.
      • orphanRemoval = true
        • 부모 엔티티가 삭제되면 자식 엔티티도 함께 삭제된다. 
          • 이때 CascadeType.PERSIST를 함께 사용하게 되면 부모가 자식의 전체 생명 주기를 관리하게 된다.
        • 이 경우에는 부모 엔티티가 자식 엔티티의 관계를 제거할 경우, 자식은 고아 객체로 취급되어서 삭제된다.
      • 이를 이용하면 부모가 삭제될 경우, 자식도 함께 삭제되기 때문에 최상위 부모 댓글만 삭제해도 자식 댓글이 모두 삭제된다. 여기서는 부모 댓글과 자식 댓글의 관계가 삭제될 경우는 없지만, 부모 댓글과 자식 댓글의 관계가 끊어진다면 자식 댓글은  삭제되었다고 보는게 좋을 것 같아서 orphanRemoval 옵션을 사용하였다.

 

댓글 조회 구현(단순, 계층형)

QueryDSL 이용

  • 이번에는 댓글을 조회할 때 좀 더 유연하고 자바 코드로 동적 query를 구현하는데 편리한 querydsl을 이용해보려고 한다.

build.gradlequerydsl 추가

buildscript {
	ext {
		queryDslVersion = "5.0.0"
	}
}

plugins {
	...
	//querydsl 추가
	id "com.ewerk.gradle.plugins.querydsl" version "1.0.10"
}

dependencies {
	...
    //Querydsl 추가
	implementation "com.querydsl:querydsl-jpa:${queryDslVersion}"
	annotationProcessor "com.querydsl:querydsl-apt:${queryDslVersion}"
    ...
}

def querydslDir = "$buildDir/generated/querydsl"

querydsl {
	jpa = true
	querydslSourcesDir = querydslDir
}
sourceSets {
	main.java.srcDir querydslDir
}
configurations {
	querydsl.extendsFrom compileClasspath
}
compileQuerydsl {
	options.annotationProcessorPath = configurations.querydsl
}

//Querydsl 추가, 자동 생성된 Q클래스 gradle clean으로 제거
clean {
	delete file(querydslDir)
}

 

QueryDSLConfing

@Configuration
public class QueryDSLConfig {
    @PersistenceContext
    private EntityManager entityManager;

    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(entityManager);
    }
}

 

Entity로 Qclass 생성

  • gradle(compileQuerydsl)로 Qclass를 생성해주면 build/generated/querydsl 폴더에 Qclass가 생성된다.
    • 인텔리제이에서 Gradle -> [Tasks] - [other] - [compileQuerydsl]을 클릭하면 컴파일된다.
    • 또는 ./gradlew compileQuerydsl로 컴파일할 수 있다.
  • 이 Qclass는 querydsl에서 엔티티를 기반으로 만든 static class로, querydsl로 쿼리를 작성할 때 사용된다.

 

전체 댓글 단순 조회 구현

  • 전체 댓글을 조회할 때  계층형으로 보여주기 위해서는 부모 댓글에 자식 댓글을 넣어주어야 한다. 그러기 위해서는 부모 ID를 기준으로 정렬하고, 부모 ID가 같다면 같은 레벨이기 때문에 생성된 일자를 기준으로 정렬해야 한다.
ID 댓글 내용 레벨 부모 ID
1 댓글 1 0 null
2 댓글 2 0 null
3 댓글 1 - 1 1 1
4 댓글 2 - 1 1 2
5 댓글 2 - 2 1 2
6 댓글 1 - 1 - 1 2 3
7 댓글 2 - 1 - 1 2 4

 

CommentRepositoryCustom

public interface CommentRepositoryCustom {
    List<Comment> findAllByPostId(Long postId);
}
import static pe.pecommunity.domain.comment.domain.QComment.comment;

@RequiredArgsConstructor
public class CommentRepositoryCustomImpl implements CommentRepositoryCustom {

    private final JPAQueryFactory queryFactory;

    @Override
    public List<Comment> findAllByPostId(Long postId) {
        return queryFactory.selectFrom(comment)
                .where(comment.post.id.eq(postId))
                .orderBy(
                    comment.parent.id.asc().nullsFirst(),
                    comment.createDate.asc()
                ).fetch();
    }
}
  • 게시글의 모든 댓글을 조회할 때, 부모의 id를 기준으로 정렬하는데 이때 null 값이 가장 최상위 부모 댓글이므로 null 값을 가장 먼저 조회되게 정렬한다. 이후 부모 id가 같다면 생성일 기준으로 정렬한다.

 

CommentRepository

public interface CommentRepository extends JpaRepository<Comment, Long>, CommentRepositoryCustom {

}
  • CommentRepositoryCustom 인터페이스를 상속하여 CommentRepository에서 사용할 수 있도록 한다. 

 

CommentService

public class CommentService {
    ...
    public List<CommentDto> findAll(Long postId) {
        Post post = postRepository.findById(postId)
                .orElseThrow(() -> new BaseException(POST_NOT_EXIST));

        return commentRepository.findAllByPostId(post.getId())
                .stream().map(c -> CommentDto.of(c))
                .collect(Collectors.toList());
    }
    ...
}
  • 단순 댓글을 조회하면 아래와 같은 응답 결과를 받을 수 있다.

 

단순 댓글 조회, API 응답 결과

{
    "status": "SUCCESS",
    "data": {
        "comment_list": [
            {
                "id": 100,
                "parent_id": -1,
                "member_id": "test1",
                "content": "100, LEVEL 0 - 1, null",
                "level": 0,
                "create_date": "2023-10-01T10:10:10",
                "update_date": "2023-10-01T10:10:10",
                "is_removed": false,
                "is_secret": false
            },
            {
                "id": 106,
                "parent_id": -1,
                "member_id": "test2",
                "content": "106, LEVEL 0 - 2, null",
                "level": 0,
                "create_date": "2023-10-01T10:20:10",
                "update_date": "2023-10-01T10:20:10",
                "is_removed": false,
                "is_secret": false
            },
            {
                "id": 101,
                "parent_id": 100,
                "member_id": "test2",
                "content": "101, LEVEL 1 - 1, 100",
                "level": 1,
                "create_date": "2023-10-01T11:10:10",
                "update_date": "2023-10-01T11:10:10",
                "is_removed": false,
                "is_secret": true
            },
            {
                "id": 102,
                "parent_id": 100,
                "member_id": "test1",
                "content": "102, LEVEL 1 - 2, 100",
                "level": 1,
                "create_date": "2023-10-01T12:20:10",
                "update_date": "2023-10-01T12:20:10",
                "is_removed": true,
                "is_secret": false
            },
            {
                "id": 103,
                "parent_id": 101,
                "member_id": "test1",
                "content": "103, LEVEL 2 - 1, 101",
                "level": 2,
                "create_date": "2023-10-01T13:25:10",
                "update_date": "2023-10-01T13:25:10",
                "is_removed": false,
                "is_secret": true
            },
            {
                "id": 104,
                "parent_id": 102,
                "member_id": "test1",
                "content": "104, LEVEL 2 - 1, 102",
                "level": 2,
                "create_date": "2023-10-01T13:18:10",
                "update_date": "2023-10-01T13:18:10",
                "is_removed": false,
                "is_secret": true
            },
            {
                "id": 105,
                "parent_id": 102,
                "member_id": "test1",
                "content": "105, LEVEL 2 - 2, 102",
                "level": 2,
                "create_date": "2023-10-01T13:21:10",
                "update_date": "2023-10-01T13:21:10",
                "is_removed": true,
                "is_secret": false
            }
        ]
    },
    "message": "댓글 전체 조회 성공"
}

 

계층형 댓글 조회 구현

CommentService

public class CommentService {
    ...
    public List<CommentDto> findAll(Long postId) {
        Post post = postRepository.findById(postId)
                .orElseThrow(() -> new BaseException(POST_NOT_EXIST));

        List<Comment> commentList = commentRepository.findAllByPostId(post.getId());

        List<CommentDto> responseList = new ArrayList<>();

        Map<Long, CommentDto> parent = new HashMap<>();
        for (Comment comment : commentList) {
            CommentDto cDto = CommentDto.of(comment, checkRemovedOrSecret(comment));
            parent.put(comment.getId(), cDto);

            if(comment.getParent() == null) responseList.add(cDto);
            else {
                parent.get(comment.getParent().getId())
                        .getChildren()
                        .add(cDto);
            }
        }

        return responseList;
    }

    private String checkRemovedOrSecret(Comment comment) {
        if(comment.getIsRemoved()) return REMOVED_COMMENT;

        String loinId = SecurityUtil.getCurrentMemberId().get();
        String writerId = comment.getMember().getMemberId();
        if(comment.getIsSecret() && !writerId.equals(loinId)) return SECRET_COMMENT;

        return comment.getContent();
    }
    ...
}
  • querydsl로 정렬된 댓글을 바탕으로 계층형으로 댓글을 조회하려고 한다. commentList를 중첩구조로 변환하기 위해서 Map을 이용한다.
    • 이때 엔티티를 Dto로 변환하여 사용한다.
    • key는 부모 댓글의 id이며, value는 부모 댓글의 Dto인데 이 Dto에 자식 리스트가 존재한다. 이 자식 리스트에 자식 댓글 Dto를 담아서 반환한다.
  • parent에 댓글 id와 해당 댓글 Dto를 넣고, 해당 댓글의 부모가 없다면 최상위 댓글이기 때문에 최종 결과를 담는 responseList에 댓글 Dto를 추가한다.
  • 만약 해당 댓글의 부모가 존재한다면, 해당 댓글의 부모의 id로 parent에서 부모 댓글을 찾고 부모 댓글의 자식 리스트에 해당 댓글을 추가한다. 이렇게 되면 댓글마다 자식 댓글이 존재한다면 부모 댓글에 자식 댓글을 리스트로 넣어서 계층형 조회가 가능해진다.

 

계층형 댓글 조회, API 응답 결과

{
    "status": "SUCCESS",
    "data": {
        "comment_list": [
            {
                "id": 100,
                "parent_id": -1,
                "member_id": "test1",
                "content": "100, LEVEL 0 - 1, null",
                "level": 0,
                "create_date": "2023-10-01T10:10:10",
                "update_date": "2023-10-01T10:10:10",
                "is_removed": false,
                "is_secret": false,
                "children": [
                    {
                        "id": 101,
                        "parent_id": 100,
                        "member_id": "test2",
                        "content": "비밀 댓글입니다.",
                        "level": 1,
                        "create_date": "2023-10-01T11:10:10",
                        "update_date": "2023-10-01T11:10:10",
                        "is_removed": false,
                        "is_secret": true,
                        "children": [
                            {
                                "id": 103,
                                "parent_id": 101,
                                "member_id": "test1",
                                "content": "103, LEVEL 2 - 1, 101",
                                "level": 2,
                                "create_date": "2023-10-01T13:25:10",
                                "update_date": "2023-10-01T13:25:10",
                                "is_removed": false,
                                "is_secret": true,
                                "children": []
                            }
                        ]
                    },
                    {
                        "id": 102,
                        "parent_id": 100,
                        "member_id": "test1",
                        "content": "삭제된 댓글입니다.",
                        "level": 1,
                        "create_date": "2023-10-01T12:20:10",
                        "update_date": "2023-10-01T12:20:10",
                        "is_removed": true,
                        "is_secret": false,
                        "children": [
                            {
                                "id": 104,
                                "parent_id": 102,
                                "member_id": "test1",
                                "content": "104, LEVEL 2 - 1, 102",
                                "level": 2,
                                "create_date": "2023-10-01T13:18:10",
                                "update_date": "2023-10-01T13:18:10",
                                "is_removed": false,
                                "is_secret": true,
                                "children": []
                            },
                            {
                                "id": 105,
                                "parent_id": 102,
                                "member_id": "test1",
                                "content": "삭제된 댓글입니다.",
                                "level": 2,
                                "create_date": "2023-10-01T13:21:10",
                                "update_date": "2023-10-01T13:21:10",
                                "is_removed": true,
                                "is_secret": false,
                                "children": []
                            }
                        ]
                    }
                ]
            },
            {
                "id": 106,
                "parent_id": -1,
                "member_id": "test2",
                "content": "106, LEVEL 0 - 2, null",
                "level": 0,
                "create_date": "2023-10-01T10:20:10",
                "update_date": "2023-10-01T10:20:10",
                "is_removed": false,
                "is_secret": false,
                "children": []
            }
        ]
    },
    "message": "댓글 전체 조회 성공"
}
  • 계층형 댓글이 잘 조회된다. 👍🏻👍🏻👍🏻

 

생성 및 수정일자 자동화

  • JPA에서는 Audit라는 기능을 제공하는데, Spring Date JPA에 대해서 시간에 대해 자동으로 값을 넣어주는 기능이다. 이를 이용하면 저장하거나 수정할 때 자동으로 시간을 매핑하여 DB 테이블에 넣어준다.

BaseTimeEntity

@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public class BaseTimeEntity {
    @CreatedDate
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime createDate;

    @LastModifiedDate
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime updateDate;
}

public class Comment extends BaseTimeEntity { }
public class Post extends BaseTimeEntity { }
  • Entity의 상위 클래스에서 생성일과 수정일을 자동으로 관리해주는 역할이다.
    • @EntityListeners(AuditingEntityListener.class): BaseTimeEntity 클래스에 Auditing(자동으로 값 매핑) 기능을 포함시킴
    • @MappedSuperclass: JPA Entity 클래스들이 BaseTimeEntity를 상속할 경우, 해당 필드(createDate, updateDate)도 모두 컬럼으로 인식하도록 설정함
    • @CreatedDate: 최초 생성 시, 날짜를 자동 생성해줌
    • @LastModifiedDate: 엔티티의 값 수정 시, 날짜를 자동 갱신해줌

Application

@EnableJpaAuditing
@SpringBootApplication
public class PecommunityApplication { }
  • @EnableJpaAuditing: JPA Auditing 어노테이션들이 모두 활성화되도록 하기 위해서 Application 클래스 위에 추가함

 

회고

 댓글을 저장할 때보다 삭제하고 조회할 때 어떻게 로직을 짜는게 좋을지 많은 고민을 했다. 특히 삭제에서 많은 고민을 했는데 지금 와서 생각해보면 자식 댓글이 모두 삭제되었을 때 해당 댓글과 자식 댓글을 바로 DB에서 삭제하는 것도 괜찮은 것 같다. 최상위 댓글이 삭제되어야만 하위 모든 댓글이 삭제될 수 있는 지금 로직은, 나중에 댓글이 많이 쌓이게 되었을 때 댓글을 보여주면 삭제된 불필요한 댓글이 많이 보이게 될 것 같다. 다음에 댓글을 구현할 일이 있다면 이 방향으로 로직을 자는 것도 좋을 것 같다.
 그리고 이때  처음으로 QueryDSL을 사용해보았는데 이때까지는 조건이나 로직이 복잡하지 않아서 querydsl의 편리함이 크게 와닿지는 않았었다. 그런데 지금 진행중인 사이드 프로젝트에서는 여러 조건에 따라 조회를 해야하기 때문에 querydsl이 매우 편리하다는 것을 깨달았다. 게시글을 조회할 때도 이름, 날짜, 어떤 조건 등에 따라서 정렬될 경우, 여러 조건에 따라 모든 쿼리를 다 작성하게 되면 쿼리 코드의 중복도 많아지고 조건이 변경될 때마다 모든 코드를 바꿔야하는 문제가 발생한다. 하지만 querydsl을 이용하니 조건을 제외한 쿼리 코드들을 재사용할 수 있고, 조건이 추가되면 조건에 따라 정렬하는 코드들만 수정하면 되니깐 수정에도 편리했다. 
 지금 이렇게 코드들을 보면 불필요한 코드들도 보이고, 개선해야 될 부분들이 조금씩 보이기 시작했다. 다음에는 코드들을 리팩토링 해보는 시간을 가져보고 이후에 여러 기능들을 좀 더 추가해볼 예정이다.

 


GitHub Link

https://github.com/jmxx219/PE-Community/pull/27

 

Jimin: 계층형 댓글 기능 구현(등록, 수정, 삭제, 조회) by jmxx219 · Pull Request #27 · jmxx219/PE-Community

댓글 등록(계층형, 댓글 레벨 제한) 댓글 수정 댓글 삭제 모든 대댓글이 삭제된 상태일 경우, DB에서 삭제 댓글 조회 비밀 댓글 및 삭제 댓글 계층형 조회 생성 및 수정 일자 자동화

github.com