본문 바로가기
Spring/Spring Data JPA

[Spring Data JPA] 연관 관계 자식(하위) Entity 작성 - QuickStart 3

by 임채훈 2021. 12. 12.

2021.12.12 - [Spring/Spring Data JPA] - [Spring Data JPA] JPA Entity, Repository, Service 클래스 작성 (조회 및 저장) - QuickStart 2

2021.12.12 - [Spring/Spring Data JPA] - [Spring Data JPA] 예제 프로젝트 생성 및 초기 환경 구성 - QuickStart 1

 

이전글의 내용을 이어서 작성합니다.

 

# 해당 시리즈 게시글은 Notion에서 작성된 내용을 그대로 옮겨오는 과정에서 서식의 깨짐 및 부자연스러움이 발생할 수 있습니다. 

 

1:N 연관 관계를 가지는 하위 Entity 작성

  • io.starter.jpatutorial.domain.jpo.CommentJpo
@Getter
@Setter
@ToString
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "comment")
public class CommentJpo {
    /**
     * 댓글 고유 ID
     */
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id = 0L;

    /**
     * 게시글 번호
     */
    private Long postNo;

    /**
     * 댓글 내용
     */
    private String content;

    /**
     * 댓글 작성 일시
     */
    private LocalDateTime createdAt;
}

 

상위 Entity에 하위 Entity를 필드로 추가

  • io.starter.jpatutorial.domain.jpo.PostJpo
@Getter
@Setter
@ToString
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "post")
public class PostJpo {
    /**
     * 게시글 번호 (Auto Increment)
     */
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long no = 0L;

    /**
     * 게시글 제목
     */
    private String title;

    /**
     * 게시글 내용
     */
    private String content;

    /**
     * 게시글 작성 일시
     */
    private LocalDateTime createdAt = LocalDateTime.now();

    /**
     * 조회수
     */
    private int views = 0;

    @ToString.Exclude
    @OneToMany(cascade = CascadeType.PERSIST)
    @JoinColumn(name = "postNo")
    private List<CommentJpo> comments = new ArrayList<>();

    /**
     * 댓글 생성
     */
    public void addComment(CommentJpo commentJpo) {
        this.comments.add(commentJpo);
    }
}

우선 @ToString.Exclude 애노테이션을 달아줌으로써 Lazy loading이 되는 필드와 Lombok으로 Generation되는 ToString간의 우려되는 성능상의 문제를 제거해줍니다.

Join의 기준이 되는 컬럼은 임의로 설정을 해주지 않는 경우 기본적으로 각 Entity들의 Primary key를 기준으로 수행됩니다. 그래서 @JoinColumn 애노테이션을 통해 임의로 Join의 기준이 되는 컬럼을 설정해 줄 수 있습니다.

다음으로 Post Entity와 Comment Entity는 도메인 특성상 1:N 관계로 @OneToMany 애노테이션을 달아주면서 cascade 정책으로는 Persist로 설정해주었습니다. cascade는 연관 관계에 있는 Entity간에 영속성을 전파하는 옵션으로 가능 설정은 다음과 같습니다.

  • CascadeType.ALL
  • CascadeType.PERSIST
  • CascadeType.MERGE
  • CascadeType.REMOVE
  • CascadeType.REFRESH
  • CascadeType.DETACH

엔티티간의 연관 관계를 설정하는 Annotation의 종류는 다음과 같고 특성에 따라 알맞은 Annotation을 사용할 수 있습니다.

  • @OneToOne - 1:1
  • @OneToMany - 1:N
  • @ManyToOne - N:1
  • @ManyToMany - N:N

마지막으로 Comment 객체를 추가하는 addComment 메소드를 작성해줍니다.

 

하위 Entity Service 클래스 저장(insert) 로직 작성

  • io.starter.jpatutorial.service.CommentService
@Service
@RequiredArgsConstructor
public class CommentService {
    private final PostMariaRepository postMariaRepository;

    @Transactional
    public void save(long postNo, Comment comment) {
        PostJpo postJpo = postMariaRepository.findById(postNo)
                .orElseThrow(IllegalArgumentException::new);
        postJpo.addComment(comment.asJpo());
    }
}

우선 특정 게시글 번호(ID)와 작성된 댓글의 객체를 받아서 댓글을 추가하는 함수를 작성합니다.

처리 순서는 다음과 같습니다.

  1. 특정 {postNo} 번호의 게시글 Select
    1. 게시글이 존재하는 경우 해당 게시글 Entity에 Comment 객체 추가
    2. 게시글이 존재하지 않는 경우 예외 Case로 Exception 발생

여기서 별도로 Repository 클래스에서 Insert가 이루어지는 save 메소드를 호출하지 않고 Entity의 상태를 변경해주는 addComment 함수를 호출하게 되면 Spring Data JPA의 Dirty Checking 변경 감지로 인해 자동으로 SQL문이 수행됩니다.

 

하위 Entity 저장(insert) 로직 테스트

  • io.starter.jpatutorial.JpaTutorialApplicationTests
package io.starter.jpatutorial;

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.List;

@SpringBootTest
class JpaTutorialApplicationTests {
    @Autowired
    private CommentService commentService;

    @Test
    @DisplayName("게시글 댓글 생성 테스트")
    void saveComment() {
        Comment comment = Comment.builder()
                .content("게시글 1의 첫번째 댓글")
                .build();
        commentService.save(1, comment);
    }
}

테스트를 수행해보면 아래와 같이 총 4번의 SQL이 수행되는데 간략하게 설명을 해보면

  1. 게시글의 번호(no)가 1인 Post 데이터 조회
  2. 해당 Post 데이터의 자식 Entity 조회
  3. 해당 Post Entity에서 Comment가 추가됨에 따라 Insert문 수행
  4. 저장된 Comment 데이터의 Join 기준이되는 postNo 값을 해당 Post 객체의 No 값으로 Update

 

그리고 다시 전체 게시글을 조회하는 테스트 로직을 수행해보면 성공적으로 자식 Entity(댓글) 목록이 조회됩니다.

그런데 한가지의 문제가 있습니다.

고작 게시글 목록과 해당 게시글들의 댓글 목록을 조회하는데 예상과는 달리 조금 많은 SQL이 수행되고 있습니다.

이는 Post 목록 조회 → 3개의 데이터가 조회됨 → 3개 각각의 Post의 Comment 목록들을 하나하나 조회가 이루어지기 때문입니다.

그렇다면 만약 Post 데이터가 10,000개라고 가정을 해본다면, 실제 쿼리 수행 횟수는 10,001회가 수행될 것 입니다. 또한 만약 Comment Entity에도 연관 관계를 가지는 자식 Entity가 있다면 그 SQL 수행 횟수는 천문학적으로 늘어날것입니다. 이를 N + 1 문제라고 불리고 있습니다.

물론 기능 또는 도메인 특성에 따라 현재와 같은 엔티티 지연 로딩이 적합할 수도 있고 부적합할 수 있는데 이를 해결할 수 있는 방법은 Fetch Join, @EntityGraph 등이 있는데 비교적 간단하게 적용할 수 있는 Fetch Join으로 해결해보도록 하겠습니다.


프로젝트 전체 소스 코드는 아래 Github에서 참고 가능합니다.

Github Source Code

 

GitHub - youspend8/spring-data-jpa-tutorial

Contribute to youspend8/spring-data-jpa-tutorial development by creating an account on GitHub.

github.com

댓글