댓글 기능의 개요

게시판을 보면 하나의 게시글에 수많은 댓글이 달리는데 이러한 관계를 1:n 관계라고 한다.

댓글 입장에서 보면 여러 댓글이 하나의 게시글에 달리므로 n:1 관계라고 한다.

위의 그림을 보면 article 테이블과 comment 테이블이 id를 기준으로 관계를 맺고 있다.

두 테이블 모두 자신을 대표하는 id가 있는데 이것을 대표키 즉 PK라고 하며 PK는 중복된 값이 없어야 한다.

comment 테이블에는 연관 대상을 가리키는 article_id가 하나 더 있는데 이런 것을 외래키라고 한다.

외래키를 따라가면 해당 댓글이 어떤 게시글에 달린 것인지 알 수 있다.

게시판 작성을 위해 Article 엔티티와 ArticleRepository를 작성한 것처럼,

댓글 작성을 위한 Comment 엔티티와 CommentRepository를 만들어 준다. 

이번에는 CommentRepository에다가 엔티티를 페이지 단위로 조회 및 정렬 기능이 포함된 JpaRepository를 상속받을 것이다.


댓글 엔티티 만들기

com.example.firstproject > entity에서 Comment라는 이름으로 새 댓글 엔티티 파일을 생성하고,

아래와 같이 주석과 함께 코드를 추가해 준다.

package com.example.firstproject.entity;


import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

@Entity // 해당 클래스가 엔티티임을 선언
@Getter // 각 필드 값을 조회할 수 있는 getter 메서드 자동 생성
@ToString   // 모든 필드를 출력할 수 있는 toString 메서드 자동 생성
@AllArgsConstructor // 모든 필드를 매개변수로 갖는 생상자 자동 생성
@NoArgsConstructor  // 매개변수가 아예 없는 기본 생성자 자동 생성
public class Comment {

    @Id // 대표키 지정
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;    // pk

    @ManyToOne  // Comment 엔티티와 Article 엔티티를 다대다 관계로 설정
    @JoinColumn(name = "article_id")    // 외래키 생성
    private Article article;    // 부모 게시글

    @Column // 해당 필드를 테이블의 속성으로 매핑
    private String nickname;    // 댓글 작성자

    @Column // 해당 필드를 테이블의 속성으로 매핑
    private String body;    // 댓글 본문

}

엔티티의 동작을 확인하기 위해 FirstprojectApplication.java 파일을 열고,

클래스 시작 행에 있는 실행 버튼을 클릭해 실행시킨 후 로그를 보면 아래와 같이 테이블이 생성된 것을 볼 수 있다.

테이블이 정말 만들어졌는지는 DB에 접속해 보면 아래와 같이 정상적으로 생성된 것을 볼 수 있다.

하지만 현재 테이블은 비어 있으니 기본 더미 데이터를 추가해 볼 것인데 data.sql 파일을 열어 아래와 같이 쿼리를 추가해 준다.

-- 기존 데이터
INSERT INTO article(title, content) VALUES ('가가가가', '1111');
INSERT INTO article(title, content) VALUES ('나나나나', '2222');
INSERT INTO article(title, content) VALUES ('다다다다', '3333');

-- article 테이블에 데이터 추가
INSERT INTO article(title, content) VALUES ('당신의 인생 영화는?', '댓글 작성1');
INSERT INTO article(title, content) VALUES ('당신의 소울 푸드는?', '댓글 작성2');
INSERT INTO article(title, content) VALUES ('당신의 취미는?', '댓글 작성3');

-- 4번 게시글의 댓글 추가
INSERT INTO comment(article_id, nickname, body) VALUES (4, 'Park', '굿 월 헌팅');
INSERT INTO comment(article_id, nickname, body) VALUES (4, 'Kim', '아이 엠 샘');
INSERT INTO comment(article_id, nickname, body) VALUES (4, 'Choi', '쇼생크 탈출');

-- 5번 게시글의 댓글 추가
INSERT INTO comment(article_id, nickname, body) VALUES (5, 'Park', '치킨');
INSERT INTO comment(article_id, nickname, body) VALUES (5, 'Kim', '샤브샤브');
INSERT INTO comment(article_id, nickname, body) VALUES (5, 'Choi', '초밥');

-- 6번 게시글의 댓글 추가
INSERT INTO comment(article_id, nickname, body) VALUES (6, 'Park', '유튜브 시청');
INSERT INTO comment(article_id, nickname, body) VALUES (6, 'Kim', '독서');
INSERT INTO comment(article_id, nickname, body) VALUES (6, 'Choi', '조깅');

 

서버를 재시작하고 h2-console 페이지에 접속해서 테이블을 조회하면 데이터가 삽입된 것을 확인할 수 있다.

이제 댓글 조회 쿼리를 연습해 볼 것인데 쿼리와 실행 결과는 아래와 같다.

-- 특정 게시글의 모든 댓글 조회
SELECT * 
FROM COMMENT  
WHERE article_id = 4;

-- 특정 닉네임의 모든 댓글 조회
SELECT * 
FROM COMMENT  
WHERE nickname = 'Park';


댓글 레파지토리 만들기

이제 레파지토리를 만들고 테스트 코드를 작성해 코드 검증까지 해 보도록 하겠다.

프로젝트 탐색기의 repository에서 CommentRepository 인터페이스를 만들어 JpaRepository를 상속받도록 해 준다.

package com.example.firstproject.repository;

import com.example.firstproject.entity.Comment;
import org.springframework.data.jpa.repository.JpaRepository;

public interface CommentRepository extends JpaRepository<Comment, Long> {
   


}

다음으로 앞서 연습한 두 쿼리를 메서드로 아래와 같이 작성해 준다.

특정 게시글의 모든 댓글을 조회하기 위해 쿼리를 메서드로 작성하는 네이티브 쿼리 메서드로 SQL 쿼리를 작성해 준다.

@Query 어노테이션을 활용하여 작성하거나 orm.xml 파일을 이용하는 방법이 존재한다.

    // 특정 게시글의 모든 댓글 조회
    @Query(value = "SELECT * FROM comment WHERE article_id = :articleId", nativeQuery = true)
    List<Comment> findByArticleId(Long articleId);

    // 특정 닉네임의 모든 댓글 조회
    List<Comment> findByNickname(String nickname);

findByNickname() 메서드에서 수행할 쿼리를 이번에는 네이티브 쿼리 XML 기본경로인 META-INF에다가,

orm.xml 이라는 이름으로 파일을 생성하고 아래와 같이 작성해 준다.

<?xml version="1.0" encoding="utf-8" ?>
<entity-mappings xmlns="https://jakarta.ee/xml/ns/persistence/orm"
                 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
                 xsi:schemaLocation="https://jakarta.ee/xml/ns/persistence/orm
                 https://jakarta.ee/xml/ns/persistence/orm/orm_3_0.xsd"
                 version="3.0">
    <named-native-query
            name="Comment.findByNickname"
            result-class="com.example.firstproject.entity.Comment">
        <query>
            <![CDATA[
                    SELECT * FROM comment WHERE nickname = :nickname
            ]]>
        </query>
    </named-native-query>
</entity-mappings>

서버를 재시작하여 아래와 같이 문제없이 동작하는지 확인을 한 번 해 준다.

이어서 findByArticleId() 메서드와 findByNickname() 메서드가 문제없이 동작하는지 테스트 코드를 작성해 확인해 볼 것이다.

findByArticleId() 메서드 위로 마우스를 올리고 Generate -> Test 선택해 파일을 아래와 같이 생성해 준다.

package com.example.firstproject.repository;

import org.junit.jupiter.api.Test;

class CommentRepositoryTest {

    @Test
    void findByArticleId() {
    }

    @Test
    void findByNickname() {
    }
}

그다음 레파지토리를 테스트하므로 @DataJpaTest 어노테이션을 붙여주고,

레파지토리를 테스트하기 위해 commentRepository 객체를 선언하고 @Autowired를 붙이는 것도 잊지 말고 해 준다.

...

@DataJpaTest    // 해당 클래스를 JPA와 연동해 테스팅
class CommentRepositoryTest {

    @Autowired
    CommentRepository commentRepository;    // 객체 주입
    ...
    ...
}

테스트의 메서드 명을 한글로 수정하지 않고 @DisplayName 어노테이션으로 테스트 이름을 붙여준다.

그다음은 테스트 케이스를 여러 개 작성할 것이니 테스트마다 중괄호로 묶고 주석과 함께 코드를 작성해 준다.

먼저 4번 게시글의 모든 댓글을 조회하는 테스트를 해 볼 것인데 유의할 점은,

3번째 단계인 예상 데이터 작성에서 부모 게시글 객체를 먼저 생성하고 댓글 객체들을 생성해 주고 합쳐야 한다.

        /* Case 1: 4번 게시글의 모든 댓글 조회 */
        {
            // 1. 입력 데이터 준비
            Long articleId = 4L;

            // 2. 실제 데이터
            List<Comment> comments = commentRepository.findByArticleId(articleId);

            // 3. 에상 데이터
            Article article = new Article(4L, "당신의 인생 영화는?", "댓글 고");   // 부모 게시글 생성
            Comment a = new Comment(1L, article, "Park", "굿 윌 헌팅"); // 댓글 객체 생성
            Comment b = new Comment(2L, article, "Kim", "아이 엠 샘"); // 댓글 객체 생성
            Comment c = new Comment(3L, article, "Choi", "쇼생크 탈출"); // 댓글 객체 생성
            List<Comment> expected = Arrays.asList(a, b, c); // 댓글 객체 합치기

            // 4. 비교 및 검증
            assertEquals(expected.toString(), comments.toString());
        }

두 번째 케이스는 댓글이 하나도 없는 1번 게시글의 댓글을 조회하려고 할 때를 테스트해 볼 것인데 코드는 아래와 같다.

       /* Case 2: 1번 게시글의 모든 댓글 조회 */
        {
            // 1. 입력 데이터 준비
            Long articleId = 1L;

            // 2. 실제 데이터
            List<Comment> comments = commentRepository.findByArticleId(articleId);

            // 3. 예상 데이터
            Article article = new Article(1L, "가가가가", "1111");
            List<Comment> expected = Arrays.asList();

            // 4. 비교 및 검증
            assertEquals(expected.toString(), comments.toString(), "1번 글은 댓글이 없음");
        }

이번에는 특정 닉네임의 모든 댓글을 조회할 것인데 위와 유사하게 코드를 작성해 주면 된다.

    @Test
    @DisplayName("특정 닉네임의 모든 댓글 조회")
    void findByNickname() {
        /* Case 1: "Park"의 모든 댓글 조회 */
        {
            // 1. 입력 데이터 준비
            String nickname = "Park";

            // 2. 실제 데이터
            List<Comment> comments = commentRepository.findByNickname(nickname);

            // 3. 예상 데이터
            Comment a = new Comment(1L, new Article(4L, "당신의 인생 영화는?", "댓글 고"), nickname, "굿 윌 헌팅");
            Comment b = new Comment(4L, new Article(5L, "당신의 소울 푸드는?", "댓글 고고"), nickname, "치킨");
            Comment c = new Comment(7L, new Article(6L, "당신의 취미는?", "댓글 고고고"), nickname, "유튜브 시청");
            List<Comment> expected = Arrays.asList(a, b, c);

            // 4. 비교 및 검증
            assertEquals(expected.toString(), comments.toString(), "Park의 모든 댓글을 출력!");
        }

    }

실행한 결과 아래와 같이 테스트 코드를 통과한 것을 볼 수 있다.

테스트가 실패하는 경우를 확인해 보기 예상 데이터 a의 1L을 임의로 2L로 바꾸고 재실행하면 아래와 같이,

expected와 actual이 일치하지 않아 실패한 것을 확인할 수 있다.