본문 바로가기

spring

[MileStone 프로젝트] 계층형 데이터베이스 구축 - JPA

쇼핑몰 프로젝트에서 CRUD 및 페이징 기능과 댓글과 대댓글을 구현하고 싶어 커뮤니티 페이지를 구현하였습니다. 사실 많은 시행착오를 겪었지만, JPA로 엔티티 클래스 내부에서 계층형 데이터베이스를 만드는 것이 큰 경험이 되었기에 포스팅하기로 하였습니다.

댓글과 대댓글 기능을 구현하면서 JPA를 이용해 계층형 데이터베이스 구조를 어떻게 설계하고, 그에 따른 로직을 어떻게 구현했는지에 대해 설명할 것이고 실제로 구현된 코드와 함께 JPA의 장점을 살린 설계 방식을 중심으로 서술하겠습니다.

 

계층형 데이터베이스 설계 이론

 

 

계층형 구조는 데이터가 트리 구조를 형성하는 방식입니다. 트리 구조에서 부모 노드가 자식 노드를 가지는 것처럼, 댓글 시스템에서는 하나의 댓글이 다른 댓글의 부모가 될 수 있습니다. 이를 구현하기 위해 JPA에서는 자기참조 관계를 활용합니다.

 

부모 댓글: 상위 댓글로, 다른 댓글을 자식으로 가질 수 있습니다.

자식 댓글: 하위 댓글로, 특정 부모 댓글에 속해 있습니다.

 

@ManyToOne  @OneToMany 어노테이션을 사용해 정의하고 이것은 JPA가 관계를 매핑하기 위해 제공하는 표준적인 방법입니다.

 

 

댓글 엔티티 설계
 

 

@Entity
@Table(name = "COMMENTS")
@Getter
@NoArgsConstructor
public class Comment {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "COMMENT_ID")
    private Long id;

    @Column(name = "CONTENT", nullable = false, length = 150)
    private String content;

    @CreationTimestamp
    @Column(name = "CREATED_AT")
    private LocalDateTime createdAt;

    @ManyToOne
    @JoinColumn(name = "MEMBER_ID", nullable = false)
    private Member member;

    @ManyToOne
    @JoinColumn(name = "BOARD_ID", nullable = false)
    private Board board;

    @ManyToOne
    @JoinColumn(name = "PARENT_COMMENT_ID")
    private Comment parentComment;  // 부모 댓글을 참조하는 필드

    @OneToMany(mappedBy = "parentComment", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Comment> childComments = new ArrayList<>();  // 자식 댓글 리스트
}
 
 
parentComment 필드:
 
@ManyToOne 어노테이션을 사용하여 부모 댓글과의 관계를 정의하는 것입니다. Comment 엔티티 내에서 자신의 부모 댓글을 참조할 수 있도록 하며, 해당 필드를 통해 댓글이 부모 댓글을 참조하고 있는지 여부를 확인할 수 있고 서비스 로직에서 이용하였습니다.
 
childComments 필드:
 
@OneToMany 어노테이션을 사용하여 자식 댓글 리스트를 관리하고 mappedBy = "parentComment"는 이 관계가 parentComment 필드를 통해 매핑하기 위해 작성하며, 부모 댓글이 자신에게 속한 모든 자식 댓글을 리스트 형태로 관리할 수 있게 합니다.
 
 
이렇게 어노테이션을 이용하여 부모와 자식 댓글을 구분할 수 있었습니다. 그리고 만일 부모 댓글이 없는 경우면 parentComment가 null일 것이고, 해당 댓글이 곧 부모 댓글을 의미하게 됩니다. 반대로 설정이 되어있다면 해당 댓글은 어느 부모댓글의 자식 댓글이 되는 것입니다

 

자식 댓글들을 필드 리스트에 있으며, 부모 댓글은 해당 리스트를 통해 자신의 자식 댓글들을 확인하 수 있게 됩니다.

 

 

댓글 작성 서비스 로직

 

 

@Service
@RequiredArgsConstructor
@Transactional
public class CommentService {

    private final CommentRepository commentRepository;
    private final BoardRepository boardRepository;
    private final MemberRepository memberRepository;
    private final SessionManager sessionManager;

    public CommentDTO createComment(Long boardId, CommentDTO commentDTO, String sessionId) {
        // 세션 ID를 이용해 사용자의 이메일을 가져옴
        String userEmail = sessionManager.getSesstion(sessionId);

        // 부모 댓글이 있는지 확인
        Comment parentComment = null;
        if (commentDTO.getParentCommentId() != null) {
            parentComment = commentRepository.findById(commentDTO.getParentCommentId())
                    .orElseThrow(() -> new CommentNotFoundException("부모 댓글을 찾을 수 없습니다"));
        }

        // 게시물과 작성자 정보 가져오기
        Board board = boardRepository.findById(boardId)
                .orElseThrow(() -> new BoardNotFoundException("게시물을 찾을 수 없습니다"));

        Member member = memberRepository.findByUserEmail(userEmail);

        // 새로운 댓글 엔티티 생성
        Comment comment = commentDTO.toEntity(board, member, parentComment);

        // 댓글 저장
        Comment savedComment = commentRepository.save(comment);

        // 저장된 댓글을 DTO로 변환하여 반환
        return Comment.toDTO(savedComment);
    }
}
 
 
위의 서비스 로직을 크게 보면 결국 3단계로 구분이 됩니다. 부모 댓글 존재 여부 확인, 댓글 엔티티를 생성한 뒤, 댓글 저장하고 끝으로 반환하는 것이 댓글 생성 서비스 로직인데, 각 단계를 구분지어 로직을 설명해보겠습니다.
 
 
부모 댓글 존재 여부 확인:
 
commentDTO.getParentCommentId()를 통해 부모 댓글 ID를 가져와서, 해당 ID로 부모 댓글을 조회합니다. 부모 댓글이 존재하지 않을 경우 예외를 발생시키며, 부모 댓글이 존재하는 경우 parentComment에 할당하게 되고, 위에서 말했듯이부모 댓글이 null이라면 최상위 댓글로 간주됩니다. 결국 부모댓글의 존재로 자식인지 부모인지 구분이 가능한 것입니다.
 
댓글 엔티티 생성:
 
부모 댓글 정보가 있으면, 이를 포함해 새로운 Comment 엔티티를 생성하며, 자식 댓글인 경우 parentComment 필드에 부모 댓글을 할당하여 관계를 설정한 뒤 생성된 댓글을 저장하고, 저장된 댓글을 DTO로 변환하여 반환합니다.

 

댓글 삭제 로직

 

 

public void deleteComment(Long commentId, String sessionId) {
    // 세션 ID를 이용해 사용자의 이메일을 가져옴
    String userEmail = sessionManager.getSesstion(sessionId);
    
    // 댓글 조회
    Comment findComment = commentRepository.findById(commentId)
            .orElseThrow(() -> new CommentNotFoundException("댓글을 찾을 수 없습니다"));

    // 삭제 권한 확인
    if (!findComment.getMember().getUserEmail().equals(userEmail)) {
        throw new UnauthorizedException("댓글을 삭제할 권한이 없습니다");
    }

    // 댓글 삭제
    commentRepository.deleteById(commentId);
}
 
 
 
삭제 권한 확인:
 
댓글의 작성자와 현재 로그인을 한 사용자가 동일한지 확인합니다. 동일하지 않다면 정상적인 회원이 아닌 것을 나타내기에 예외를 발생시킵니다.
 
사실 이러한 예외는 삭제 로직에서만 있는 것이 아니고, 상품 상세페이지에서의 리뷰의 업데이트 삭제, 해당 댓글기능 중의 삭제와 업데이트 기능에 쓰입니다. 
 
댓글 삭제:
 

CascadeType.ALL 옵션으로 인해, 부모 댓글을 삭제하면 그와 연결된 모든 자식 댓글도 함께 삭제가 됩니다. 실제 쇼핑몰 환경에서도 부모 댓글 삭제 시, 밑의 댓글들도 삭제가 되기에 이를 보고 활용하였습니다.

 

 

마무리

 

 

이렇게 JPA를 활용하여 댓글과 대댓글의 계층형 구조를 설계하고, 부모-자식 댓글 간의 관계를 관리하는 방법을 터득할 수 있었습니다. 사실 아직까지 생소하다고 느껴지는 트리 구조에 대해 한 발자국 더욱 알아간 느낌이였는데요. 해당 구조를 추가로 더 정리를 해보면 트리 구조의 형태는 데이터를 표현할 때 매우 유용하며, 특히 현재 댓글 시스템과 같이 깊이 있는 데이터를 처리할 때 효과적이라는 것을 배웠습니다.