기록방

14장 : 댓글 엔티티와 리포지토리 만들기 본문

FrameWork/Spring

14장 : 댓글 엔티티와 리포지토리 만들기

Soom_1n 2024. 7. 10. 12:07

길벗 IT도서에서 주관하는 코딩 자율학습단 8기 : Spring Boot 파트에 참여한 기록입니다 [ 목록 ]

14.1 댓글 기능의 개요

14.1.1 댓글과 게시글의 관계

  • 게시글 입장
    • 하나의 게시글에 여러 댓글이 달림
    • one-to-many
    • 일대다(1:n) 관계
  • 댓글 입장
    • 여러 댓글이 하나의 게시글에 달림
    • many-to-one
    • 다대일(n:1) 관계
  • comment 테이블의 외래키로 article 테이블의 대표키를 갖고 있음
    • 대표키(PK, Primary Key)
      • id와 같이 자신을 대표하는 속성
      • 테이블 내에서 중복된 값이 없어야 함
    • 외래키(FK, Foreign Key)
      • comment 테이블의 article_id처럼 연관 대상을 가리키는 속성

14.1.2 댓글 엔티티와 리포지토리 설계

  • JPA(Java Persistence API) : 자바로 DB에 명령을 내리게 하는 도구로, 데이터를 객체 지향적으로 다루는 기술
  • 엔티티(Entity) : DB 데이터를 담는 자바 객체로, 엔티티를 기반으로 테이블 생성
    • 댓글 작성을 위한 엔티티 Comment 생성
  • 리포지토리(Repository) : 엔티티를 관리하는 인터페이스로, 데이터 CRUD 등의 기능 제공
    • CommentRepository 생성
    • 기존의 ArticleRepository처럼 CrudRepository를 상속하지 않고, JpaRepository 상속
    • JpaRepository
      • ListCrudRepositoryListPagingAndSortingRepository를 상속받은 인터페이스
      • CURD(생성, 조회, 수정, 삭제) 기능뿐만 아니라 엔티티를 페이지 단위로 조회 및 정렬하는 기능과 PJA에 특화된 여러 기능 등을 제공

JpaRepository 인터페이스의 계층 구조

  • Repository ← CrudRepository ← ListCrudRepository ← JpaRepository
  • Repository ← PagingAndSortingRepository ← ListPagingAndSortingRepository ← JpaRepository
  • 각 인터페이스의 기능
    • Repository : 최상위 리포지토리 인터페이스
    • CurdRepositoryListCrudRepository : 엔티티의 CRUD 기능 제공
    • PagingAndSortingRepositoryListPagingAndSortingRepository : 엔티티의 페이징 및 정렬 기능 제공
    • JpaRepository : 엔티티의 CRUD 기능과 페이징 및 정렬 기능 뿐만 아니라 JPA에 특화된 기능을 추가로 제공

14.2 댓글 엔티티 만들기

14.2.1 댓글 엔티티 만들기

package com.example.firstproject.entity;

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

@Entity
@Getter
@ToString
@AllArgsConstructor
@NoArgsConstructor
public class Comment {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY) // DB가 자동으로 1씩 증가
    private Long id; // 대표키

    @ManyToOne // Comment 엔티티와 Article 엔티티를 다대일 관계로 설정
    @JoinColumn(name="article_id") // 외래키 설정, Article 엔티티의 기본키(id)와 맵핑
    private Article article; // 해당 댓글의 부모 게시글
    @Column // 해당 필드를 테이블의 속성으로 매핑
    private String nickname; // 댓글을 단 사람
    @Column // 해당 필드를 테이블의 속성으로 매핑
    private String body; // 댓글 본문
}
  • entity 패키지에 Comment.java 생성

  • 실행하면 테이블을 만드는 걸 확인할 수 있음

  • DB에 테이블이 잘 만들어진 것을 확인할 수 있음

14.2.2 더미 데이터 추가하기

INSERT INTO article(title, content) VALUES ('가가가가', '1111');
INSERT INTO article(title, content) VALUES ('나나나나', '2222');
INSERT INTO article(title, content) VALUES ('다다다다', '3333');
INSERT INTO member(id, email, password) VALUES (1, 'mail@cloudstudying.kr', '1111');
INSERT INTO member(id, email, password) VALUES (2, 'sehongpark@cloudstudying.kr', '2222');
INSERT INTO member(id, email, password) VALUES (3, 'email@cloudstudying.kr', '3333');
-- 11장 셀프체크 데이터 추가
INSERT INTO coffee(name, price) VALUES ('아메리카노', '4500');
INSERT INTO coffee(name, price) VALUES ('라떼', '5000');
INSERT INTO coffee(name, price) VALUES ('카페 모카', '5500');
-- 14장 데이터 추가
INSERT INTO article(title, content) VALUES ('당신의 인생 영화는?', '댓글 고');
INSERT INTO article(title, content) VALUES ('당신의 소울 푸드는?', '댓글 고고');
INSERT INTO article(title, content) VALUES ('당신의 취미는?', '댓글 고고고');
-- 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', '독서');

14.2.3 댓글 조회 쿼리 연습하기

특정 게시글의 모든 댓글 조회

SELECT * FROM comment WHERE article_id = 4;

특정 닉네임의 모든 댓글 조회

SELECT * FROM comment WHERE nickname = 'Park';

🚀 1분 퀴즈

  • 댓글(comment)과 게시글(article) 테이블은 ( 다대일 ) 관계다.
  • JPA에서 다대일 관계는 ( @ManyToOne ) 애노테이션으로 정의한다.
  • 데이터의 관계는 대표키와 ( 외래키 )의 연결로 이뤄진다.
  • 댓글(comment) 테이블에서 외래키는 ( article_id )(이)다.
  • JPA에서 외래키 지정은 ( @JoinColumn ) 애노테이션으로 한다.

14.3 댓글 리포지토리 만들기

Comment 엔티티를 DB로 저장할 수 있게 댓글 리포지토리를 만들고 테스트 코드로 검증하기

14.3.1 댓글 리포지토리 만들기

  • 네이티브 쿼리 메서드(native query method)
    • 직접 작성한 SQL 쿼리를 리포지토리 메서드로 실행할 수 있게 함
    • @Query 애노테이션을 사용하거나 orm.xml 파일 이용

특정 게시글의 모든 댓글 조회

  • @Query 애노테이션 형식
    • @Query 애노테이션은 SQL과 유사한 JPQL(Java Persistence Query Language)이라는 객체 지향 쿼리 언어를 통해 복잡한 쿼리 처리를 지원
    • 그러나 nativeQuery 속성을 true로 하면 SQL문을 그대로 사용 가능
    • 주의할 점은 WHERE 절에 조건을 쓸 때 매개 변수 앞에는 꼭 콜론(:)을 붙여야 함
  • @Query(value = "쿼리", nativeQuery = true)
// 특정 게시글의 모든 댓글 조회
@Query(value = "SELECT * FROM comment WHERE article_id = :articleId",
        nativeQuery = true) // value 속성에 실행하려는 쿼리 작성
List<Comment> findByArticleId(Long articleId);

특정 닉네임의 모든 댓글 조회

  • 네이티브 쿼리 XML(native query XML)
    • 리포지토리에서 수행할 SQL쿼리문을 XML로 작성
    • 기본 경로와 파일 이름은 resources > META-INF > orm.xml
    • 형식
<named-native-query
        name="쿼리_수행_대상_엔티티.메서드_이름"
        result-class="쿼리_수행_결과_반환하는_타입의_전체_패키지_경로">
    <query>
        <![CDATA[
            <!-- 쿼리 -->
        ]]>
    </query>
</named-native-query>

 

  • 최종 입력값
<?xml version="1.0" encoding="utf-8" ?>
<entity-mappings xmlns="http://java.sun.com/xml/ns/persistence/orm"
                 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
                 xsi:schemaLocation="http://java.sun.com/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>

14.3.2 댓글 리포지토리 테스트 코드 작성하기

  • @DisplayName 애노테이션 사용
    • 메서드 명을 바꾸는 대신 테스트 이름을 변경
    • 형식 : @DisplayName(”테스트_결과에_보여_줄_이름”)
package com.example.firstproject.repository;

import com.example.firstproject.entity.Article;
import com.example.firstproject.entity.Comment;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;

import java.util.Arrays;
import java.util.List;

import static org.junit.jupiter.api.Assertions.*;

@DataJpaTest
class CommentRepositoryTest {
    @Autowired
    CommentRepository commentRepository;

    @Test
    @DisplayName("특정 게시글의 모든 댓글 조회")
    void findByArticleId() {
        // 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(), "4번 글의 모든 댓글을 출력!");
        }
        // 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의 모든 댓글을 출력!");
        }
    }
}

테스트 성공과 실패한 경우

🚀 1분 퀴즈

  • ( JpaRepository )은/는 ListCrudRepository와 ListPagingAndSortingRepository로부터 확장된 리포지토리 인터페이스다.
  • ( @Query ) 애노테이션을 사용하면 리포지토리가 수행할 SQL 문을 직접 명시할 수 있다.
  • 리포지토리가 수행할 SQL 문을 ( XML ) 파일로 만들어 연결할 수도 있다.
  • 리포지토리와 엔티티 등의 테스트, 즉 JPA 영역의 테스트는 ( @DataJpaTest ) 애노테이션으로 명시한다.

✅ 셀프 체크

본문의 CommentRepositoryTest에 다음 6가지 테스트 케이스 코드를 추가하세요.

 

1. 9번 게시글의 모든 댓글 조회

// Case 3 : 9번 게시글의 모든 댓글 조회
{
    // 1. 입력 데이터 준비
    Long articleId = 9L;
    // 2. 실제 데이터
    List<Comment> comments = commentRepository.findByArticleId(articleId);
    // 3. 예상 데이터
    Article article = null;
    List<Comment> expected = Arrays.asList();

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

 

2. 999번 게시글의 모든 댓글 조회

// Case 4 : 999번 게시글의 모든 댓글 조회
{
    // 1. 입력 데이터 준비
    Long articleId = 999L;
    // 2. 실제 데이터
    List<Comment> comments = commentRepository.findByArticleId(articleId);
    // 3. 예상 데이터
    Article article = null;
    List<Comment> expected = Arrays.asList();

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

 

3. -1번 게시글의 모든 댓글 조회

// Case 5 : -1번 게시글의 모든 댓글 조회
{
    // 1. 입력 데이터 준비
    Long articleId = -1L;
    // 2. 실제 데이터
    List<Comment> comments = commentRepository.findByArticleId(articleId);
    // 3. 예상 데이터
    Article article = null;
    List<Comment> expected = Arrays.asList();

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

 

4. “Kim”의 모든 댓글 조회

// Case 2 : "Kim"의 모든 댓글 조회
{
    // 1. 입력 데이터 준비
    String nickname = "Kim";
    // 2. 실제 데이터
    List<Comment> comments = commentRepository.findByNickname(nickname);
    // 3. 예상 데이터
    Comment a = new Comment(2L, new Article(4L, "당신의 인생 영화는?", "댓글 고"),
            nickname, "아이 엠 샘");
    Comment b = new Comment(5L, new Article(5L, "당신의 소울 푸드는?", "댓글 고고"),
            nickname, "샤브샤브");
    Comment c = new Comment(8L, new Article(6L, "당신의 취미는?", "댓글 고고고"),
            nickname, "유튜브 시청");
    List<Comment> expected = Arrays.asList(a, b, c);

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

 

5. null의 모든 댓글 조회(특정 닉네임의 입력값이 null일 때)

// Case 3 : null의 모든 댓글 조회
{
    // 1. 입력 데이터 준비
    String nickname = null;
    // 2. 실제 데이터
    List<Comment> comments = commentRepository.findByNickname(nickname);
    // 3. 예상 데이터
    List<Comment> expected = Arrays.asList();

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

 

6. “”의 모든 댓글 조회(특정 닉네임의 입력값이 없을 때)

// Case 4 : ""의 모든 댓글 조회
{
    // 1. 입력 데이터 준비
    String nickname = "";
    // 2. 실제 데이터
    List<Comment> comments = commentRepository.findByNickname(nickname);
    // 3. 예상 데이터
    List<Comment> expected = Arrays.asList();

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

 

전체 통과

🏓 더 알아볼 내용

0. 들어가기에 앞서

아직 ‘다대일’, ‘일대다’라는 용어가 익숙하지 않을 수 있습니다. 이는 DB에서 테이블과 테이블 간의 연관 관계를 나타내는 말로, 이 외에도 일대일 관계, 다대다 관계 등의 관계가 존재합니다. DB의 관계에 대한 이해가 부족한 분들은 본 학습 가이드에서 설명하는 것이 충분하지 않을 수 있으니 해당 개념을 보충 학습할 것을 추천합니다.

이번 학습 가이드에서는 ‘학생’과 ‘반’을 예로 들어 설명하겠습니다.

1. @ManyToOne

@ManyToOne은 N:1(다대일) 관계입니다. 예를 들어 반에는 여러 명의 학생이 있을 수 있습니다. 때문에 학생:반 = N:1 관계입니다. 이때 학생은 아이디, 이름, 반 정보를 가지고 있습니다. 이를 코드로 작성하면 다음과 같습니다.

@AllArgsConstructor
@NoArgsConstructor
@Getter
@Entity
public class Student {
    @Id
    @GeneratedValue
    private Long id;
    private String name;
    private Clazz clazz;
}

Student 클래스에 id(아이디), name(이름), clazz(반) 필드를 선언했습니다. 이어서 Clazz 클래스 코드를 보겠습니다.

@AllArgsConstructor
@NoArgsConstructor
@Getter
@Entity
public class Clazz {
    @Id
    @GeneratedValue
    private Long id;
    private int grade;
}

위의 Clazz 클래스 코드를 보면 학생 정보가 없습니다. 그렇다면 어떻게 학생과 반의 관계를 N:1로 설정하는 걸까요? 바로 @ManyToOne 어노테이션을 사용합니다. 다음과 같이 말이죠.

@AllArgsConstructor
@NoArgsConstructor
@Getter
@Entity
public class Student {
    @Id
    @GeneratedValue
    private Long id;
    private String name;
    @ManyToOne
    private Clazz clazz;
}

위 코드를 보면 Student 클래스의 Clazz 필드 위에 @ManyToOne 애노테이션을 붙였습니다.
여기서 Many는 Student를 가리키고 One은 Clazz를 가리킵니다. 학생과 반은 N:1 관계이기 때문에 Student 클래스에 @ManyToOne 애노테이션을 붙이는 것입니다. 이렇게 하면
Student 테이블과 Clazz 테이블이 N:1 관계를 맺게 됩니다.

2. @OneToMany

@OneToMany는 1:N(일대다) 관계입니다. 앞서 학생:반 = N:1 관계였는데요. 이를 뒤집으면반:학생 = 1:N 관계입니다. 한 반에 여러 학생이 있다는 말입니다. 다음과 Clazz 클래스에
@OneToMany 애노테이션을 사용하면 그 반에 어떤 학생들이 있는지 알 수 있습니다.

@AllArgsConstructor
@NoArgsConstructor
@Getter
@Entity
public class Clazz {
    @Id
    @GeneratedValue
    private Long id;
    private int grade;
    @OneToMany(mappedBy = "clazz") // The field that owns the relationship.
    Set<Student> students;
}

@ManyToOne과 @OneToMany 애노테이션을 활용해 코드를 작성하면 다음과 같습니다.
이렇게 연관 관계를 이용하면, 학생을 생성했을 때 Clazz에도 학생 정보가 들어가는 것을 볼 수 있습니다.

@SpringBootTest
class StudentServiceTest {
    @Autowired
    private StudentService studentService;
    @Autowired
    private StudentRepository studentRepository;
    @Autowired
    private ClazzService clazzService;
    @Autowired
    private ClazzRepository clazzRepository;
    @Test
    void createStudent() {
        Clazz clazz = new Clazz(1, new ArrayList<>());
        clazzRepository.save(clazz);
        StudentCreateRequest studentCreateRequest = new StudentCreateRequest("test student", clazz.getId());
        Student student = studentService.createStudent(studentCreateRequest);
        assertThat(student.getId()).isNotNull();
        assertThat(student.getName()).isEqualTo("test student");
        assertThat(student.getClazz().getId()).isEqualTo(clazz.getId());
        assertThat(student.getClazz().getStudents().size()).isEqualTo(1);
        }
}
@Service
@RequiredArgsConstructor
public class StudentService {
    private final StudentRepository studentRepository;
    private final ClazzService clazzService;
    @Transactional
    public Student createStudent(StudentCreateRequest studentCreateRequest) {
        Clazz clazz = clazzService.getClazz(studentCreateRequest.getClazzId());
        Student student = new Student(studentCreateRequest.getName(), clazz);
        clazz.getStudents().add(student);
        return studentRepository.save(student);
    }
}

 

728x90