[Spring/TIL] 댓글, 대댓글을 구현할 때 고려해봐야 할 점에 대하여

resilient

·

2023. 4. 5. 12:28

728x90
반응형

최근 서비스들을 봤을 때, 빠질 수 없는 기능 중에 댓글과 대댓글이 있습니다.

 

현재 진행 중인 프로젝트에도 댓글과 대댓글 기능을 추가했는데요. 

 

댓글과 대댓글을 구현하는 방법 중에 여러 가지가 있지만 어떤 방법을 선택했고, 선택한 과정에서 알게 된 내용들에 대해 정리해보려고 합니다.

 

0. 첫 번째 생각

 

가장 먼저 생각을 했던 방법입니다. 굉장히 직관적이였죠.

 

대댓글 엔티티의 경우 부모댓글을 알 수 있는 필드가 반드시 필요하게 됩니다. 따라서 부모댓글의 PK값을 자식댓글에서 가지고 있도록 아래와 같이 설계했습니다.

 

@Entity
@Table(name = "pin_comment")
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Getter
public class PinComment extends BaseTimeEntity {

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

    @Column(name = "text", nullable = false)
    private String text;

    @ManyToOne(fetch = FetchType.LAZY)
    private Users writer;

    @ManyToOne(fetch = FetchType.LAZY)
    private Pin pin;

    @Column
    private Long parentComment; // 부모댓글의 PK값

    @Column(nullable = false)
    private Boolean isDeleted; // 댓글 삭제 여부

}

 

이렇게 설계를 했을 때의 문제점을 보겠습니다.

 

가장 문제는 부모댓글에 달린 자식댓글들의 관리가 안된다는 점입니다.

 

부모 댓글로 같은 PK값을 가지고 있으므로 조회를 할 경우에는 효율적일 수도 있으나 자식댓글 간의 계층을 나타내기에는 적절하지 않습니다. 조회를 하는 쿼리에 조건문이 붙을 가능성이 많죠.

 

또한 PK값을 이렇게 쓰는 게 맞나?라는 생각이 들었습니다.

 

여기서 잠깐 PK값을 왜 쓰는지에 대해 간단하게 알아보겠습니다.

 

0-1. PK값을 쓰는 이유에 대하여

RDBMS을 사용한다면 PK값을 당연하게 사용하고 있는 분들이 많을 텐데요. PK값을 왜 사용해야 할까요?

말 그대로 PK값은 Primary Key. 고유 한 키라는 의미입니다.

관계형 데이터베이스에서는 반드시 각 행이 고유하게 식별되어야 합니다. 이 규칙이 깨진다면 관계형이라고 할 수 없죠. 데이터들이 일관성이 없어질 수도 있고, 모호해지는 경우가 발생할 수도 있기 때문입니다.

일관성 없는 데이터가 반복적으로 쌓이면 쿼리 속도도 느려지고 조회를 했을 때, 원하지 않는 결과를 가져올 수도 있죠. 데이터의 실제 저장되는 정렬 순서를 보장할 수 없기도 하고요.

또한 PK 값 없이 JOIN 쿼리를 날리게 된다면 JOIN 할 테이블의 FK값이 고유하지 않은 값이 될 것이라는 건데... 그럼 정말 대참사가 발생할 수도 있습니다.

한 문장으로 정의하자면 Indexing이라고 생각하면 됩니다.

 

PK값은 위와 같이 사용합니다.

 

보통 엔티티에서 다른 엔티티의 PK값을 사용하는 경우는 조인을 할 때 필요한 FK값으로 사용하는 경우 말고는 없는데.. 

 

계층을 표시하기 위해서, 조건을 걸어서 사용하기 위해 PK값을 다른 엔티티에서 가지고 있는다는 사실이 PK를 사용하는 이유에 약간 맞지 않는 듯해 보였습니다.

 

 

1. 두 번째 생각

 

첫 번째 방법을 사용했을 경우 발생할 문제점을 해결하기 위해 생각한 방법입니다.

 

PK값을 사용할 수는 없으니 다른 '번호'가 필요해 보였고, 전체적으로 관리를 할 번호를 만들어줬습니다.

 

@Entity
@Table(name = "pin_comment")
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Getter
public class PinComment extends BaseTimeEntity {

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

    @Column(name = "text", nullable = false)
    private String text;

    @ManyToOne(fetch = FetchType.LAZY)
    private Users writer;

    @ManyToOne(fetch = FetchType.LAZY)
    private Pin pin;

    @Column
    private Long parentCommentOrder; // 원댓글의 번호

    @Column
    private Long commentOrder; // 순서

    @Column
    private int childCommentCount; // 자식 댓글의 개수

    @Column(nullable = false)
    private Boolean isDeleted; // 댓글 삭제 여부

}

 

위와 같이 commentOrder라는 '순서 번호'의 의미를 담고 있는 필드를 추가해 줬습니다.

 

commentOrder는 PK값처럼 AutoIncrement를 사용하지 않고 다음 로직과 같이 번호를 부여받습니다.

 

@Service
@RequiredArgsConstructor
public class PinCommentServiceImpl implements PinCommentService {

    private final PinRepository pinRepository;
    private final PinCommentRepository pinCommentRepository;

    @Override
    @Transactional
    public PinCommentDTO.PinCommentDetailResponse createPinComment(
    Users user, Long pinId, PinCommentDTO.CreatePinCommentRequest request) {

        Pin pin = pinRepository.findById(pinId).
        orElseThrow(() -> new EntityNotFoundException("존재하지 않는 핀 입니다."));
        PinComment pinComment = request.toEntity(user, pin);

	// pin아이디가 가지고 있는 댓글 중 가장 마지막 commentOrder를 불러와서 +1 을 해준다.
        pinComment.setCommentOrder(pinCommentRepository.getLastPinCommentOrder(pin.getId()) + 1);

}

 

그리고는 부모댓글에 자식댓글이 달리게 되면 자식댓글은 해당 부모의 commentOrder를 갖게 되는 것이죠.

 

@Override
    @Transactional
    public PinCommentDTO.PinCommentDetailResponse createPinComment(Users user, Long pinId, PinCommentDTO.CreatePinCommentRequest request) {

        Pin pin = pinRepository.findById(pinId).orElseThrow(() -> new EntityNotFoundException("존재하지 않는 핀 입니다."));
        PinComment pinComment = request.toEntity(user, pin);

        pinComment.setCommentOrder(pinCommentRepository.getLastPinCommentOrder(pin.getId()) + 1);

        // 부모 댓글은 부모 번호를 자신의 댓글 번호로 한다.
        if (pinComment.getParentCommentOrder() == null) {
            pinComment.setParentCommentOrder(pinComment.getCommentOrder());
        } else {
            long parentCommentOrder = pinComment.getParentCommentOrder();
            // 자식 댓글이라면, 부모 댓글의 자식 수를 증가시킨다.
            PinComment parentComm = pinCommentRepository.findByCommentOrder(parentCommentOrder);
            parentComm.plusChildCommentCount();

        }
        pinCommentRepository.save(pinComment);

        return new PinCommentDTO.PinCommentDetailResponse(pinComment);
    }
///////////////////////////////////////////////
   @Data
    public static class CreatePinCommentRequest {
        @NotBlank(message = "최소 한 글자 이상을 입력해야 합니다.")
        private String text;

        private Long parentCommentOrder;

        public PinComment toEntity(Users user, Pin pin) {
            return PinComment.builder()
                    .pin(pin)
                    .text(text)
                    .parentCommentOrder(parentCommentOrder)
                    .childCommentCount(0)
                    .isDeleted(false)
                    .writer(user)
                    .build();
        }
    }

 

자식댓글(대댓글)을 작성할 경우 CreatePinCommentRequest에 parentCommentOrder를 넣어주고, 부모댓글(댓글)을 작성할 경우에는 null값을 넣어줌으로써 위의 서비스로직이 실행이 됩니다.

 

저는 두 번째 방법을 선택해서 댓글 대댓글 기능을 구현하였고 해당 Pin의 ID로 조회를 해보면 아래와 같이 데이터를 조회할 수 있습니다.

 

2. 댓글, 대댓글을 삭제하는 방법

 

생각한 댓글, 대댓글 삭제 방법은 두 가지 로직으로 이루어집니다.

 

삭제할 댓글이 부모 댓글일 경우 자식댓글들 보존을 위해서 status 상태값을 변경하는 로직

 

자식댓글이 없고 삭제할 댓글이 부모 댓글일 경우 아예 Delete 쿼리를 날리는 로직

 

기획에 따라 다르겠지만 자식 댓글을 가진 부모댓글을 삭제할 경우 자식 댓글까지 모두 삭제되는 로직은 좋지 않다고 판단했고 자식 댓글을 남기기 위해서 isDeleted라는 status필드를 추가했습니다.

 

@Service
@RequiredArgsConstructor
public class PinCommentServiceImpl implements PinCommentService {

    private final PinRepository pinRepository;
    private final PinCommentRepository pinCommentRepository;

    @Override
    @Transactional
    public void deletePinComment(Long pinCommentId) {

        PinComment pinComment = pinCommentRepository.findById(pinCommentId).orElseThrow(() -> new EntityNotFoundException("존재하지 않는 핀 댓글 입니다."));

        PinComment parentPinComment;
        if (!pinComment.getCommentOrder().equals(pinComment.getParentCommentOrder())) {
            // 부모 댓글 확인
            parentPinComment = pinCommentRepository.findByCommentOrder(pinComment.getParentCommentOrder());
            parentPinComment.minusChildCommentCount();
            // 자식 댓글이 없고, isDeleted = true인 부모 댓글은 삭제시킨다.
            if (parentPinComment.getChildCommentCount() == 0 && parentPinComment.getIsDeleted())
                pinCommentRepository.delete(parentPinComment);

        }
        pinCommentRepository.delete(pinComment);
    }

    @Override
    public void deletePinCommentWithStatus(Long pinCommentId) {
        PinComment pinComment = pinCommentRepository.findById(pinCommentId).orElseThrow(
                () -> new EntityNotFoundException("해당 핀 댓글이 존재하지 않습니다."));

        pinComment.setDeleted();
    }
}

 

3. 정리

 

이번 게시물에서는 프로젝트에 적용한 댓글, 대댓글 구현 방법에 대해서 기록했습니다.

 

물론 더 다양한 방법이 있겠지만 직관적이면서도 지킬 건 지킨 구현이 아닌가 하는 생각이 드네요.

 

감사합니다.

반응형