Spring boot

2024.10.17 Blog 프로젝트 만들기(JPA) 댓글 목록 보기

정훈5 2024. 10. 17. 09:16

 

지연 로딩

Hibernate: 
    select
        b1_0.id,
        b1_0.content,
        b1_0.created_at,
        b1_0.title,
        b1_0.user_id 
    from
        board_tb b1_0 
    where
        b1_0.id=?
Hibernate: 
    select
        u1_0.id,
        u1_0.created_at,
        u1_0.email,
        u1_0.password,
        u1_0.role,
        u1_0.username 
    from
        user_tb u1_0 
    where
        u1_0.id=?

 

 

board.java 잠시 변경

package com.tenco.blog_v1.board;

import com.tenco.blog_v1.reply.Reply;
import com.tenco.blog_v1.user.User;
import jakarta.persistence.*;
import lombok.*;

import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.List;

@Getter
@Setter
@NoArgsConstructor
@Entity
@Table(name = "board_tb")
public class Board {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY) // 기본 키 전략, DB 위임
    // 데이터베이스가 기본 키 값을 직접 관리하도록 위임
    private Integer id;
    private String title;

    @Lob // 대용량 데이터 저장 가능
    private String content;

    @ManyToOne(fetch = FetchType.LAZY) // EAGER 즉시 전략
    @JoinColumn(name = "user_id")
    private User user; // 게시글 작성자 정보

    // created_at 컬럼과 매핑하여, 이 필드는 데이터 저장시 자동으로 설정 됨
    @Column(name = "created_at", insertable = false, updatable = false)
    private Timestamp createdAt;

    // 코드 추가
    @Transient // 해당 테이블에 컬럼을 만들지 마 (테이블에 만들지 않음)
            // 즉, JPA 메모리 상에서만 활용 가능한 필드 메서드 이다.
    boolean isBoardOwner;

    @OneToMany(mappedBy = "board", fetch = FetchType.EAGER)
    // 댓글 엔티티를 넣어서 관계 설정하면 -- 양방향
    private List<Reply> replies = new ArrayList<Reply>(); // 빠른 초기화

    @Builder
    public Board(Integer id, String title, String content, User user, Timestamp createdAt) {
        this.id = id;
        this.title = title;
        this.content = content;
        this.user = user;
        this.createdAt = createdAt;
    }

}

 

Reply.java

package com.tenco.blog_v1.reply;

import com.tenco.blog_v1.board.Board;
import com.tenco.blog_v1.user.User;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;

import java.time.LocalDateTime;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
@Table(name = "reply_tb")
@ToString(exclude = {"user", "board"}) // 연관된 엔티티를 제외하여 순환 참조 방지 및 보안 강화 때문에 사용한다.
public class Reply {

    // 일반적으로ID는 LONG 타입을 사용하는 것을 권장한다.
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;

    // Null 값이 들어올 수 없어 - 기본 값은 Null 허용
    @Column(nullable = false) 
    private String comment;


    // 한 사용자는 댓글을 여러개 작성할 수 있다. (지금은 댓글을 기준으로 적어야 하므로 N:1)
    // 지연로딩
    @ManyToOne(fetch = FetchType.LAZY)
    // 단방향 관계 설계 -> User 엔티티(Entity)에는 정보가 없다.
    @JoinColumn(name = "user_id")
    private User user;

    // 하나의 게시글에는 여러개의 댓글이 있을 수 있다. (1:N)
    // 앙방향 매핑 (FK 주인은 댓글 Reply 이다)
    // 지연로딩
    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name = "board_id")
    private Board board;

    // JPA 엔티티(Entity)에서 데이터베이스에서 저장할 필요가 없는 필드를 정의할 때 사용한다.
    // 여기서는 필요한데 DB에 저장할 내용은 아니라는 뜻
    @Transient
    private boolean isReplyOwner;

    @Builder.Default // 위에 @Builder 패턴이 없어서 빨간줄 뜬다.
    // 댓글 상태 (ACTIVE, DELETED)
    private String status = "ACTIVE";
    
    // 처음 엔티티가 생성될 때 자동으로 현재 시간으로 설정 --> db에 now() 이런거 쓸 필요가 없다.
    @CreationTimestamp
    @Column(name = "created_at")
    private LocalDateTime createdAt;

    /**
     * 엔티티가 데이터베이스에 영속화 되기 전에 호출 되는 메서드가 있다면 사용한다.
     * @PrePersist 어노테이션은 JPA 라이프 사이클 이벤트 중 하나로 엔티티가 영속화 되기전에 실행 된다.
     */
    @PrePersist
    protected  void onCreate() {
        if(this.status == null) {
            this.status = "ACTIVE";
        }

        if(this.createdAt == null) {
            this.createdAt = LocalDateTime.now();
        }
    }

}

 

data.sql 잠시 변경

-- 사용자 데이터 삽입
INSERT INTO user_tb(username, password, email, role, created_at) VALUES('길동', '1234', 'a@nate.com', 'USER', NOW());
INSERT INTO user_tb(username, password, email, role, created_at) VALUES('둘리', '1234', 'b@nate.com', 'USER', NOW());
INSERT INTO user_tb(username, password, email, role, created_at) VALUES('마이콜', '1234', 'c@nate.com', 'ADMIN', NOW());

-- 게시글 데이터 삽입
INSERT INTO board_tb(title, content, user_id, created_at) VALUES('제목1', '내용1', 1, NOW());
INSERT INTO board_tb(title, content, user_id, created_at) VALUES('제목2', '내용2', 1, NOW());
INSERT INTO board_tb(title, content, user_id, created_at) VALUES('제목3', '내용3', 2, NOW());
INSERT INTO board_tb(title, content, user_id, created_at) VALUES('제목4', '내용4', 3, NOW());

-- 댓글 데이터 삽입
--INSERT INTO reply_tb(comment, board_id, user_id, created_at, status) VALUES('댓글1', 4, 1, NOW(), 'DELETED');
--INSERT INTO reply_tb(comment, board_id, user_id, created_at, status) VALUES('댓글1', 4, 1, NOW(), 'ACTIVE');
--INSERT INTO reply_tb(comment, board_id, user_id, created_at, status) VALUES('댓글2', 4, 1, NOW(), 'DELETED');
--INSERT INTO reply_tb(comment, board_id, user_id, created_at, status) VALUES('댓글3', 4, 2, NOW(), 'ACTIVE');
--INSERT INTO reply_tb(comment, board_id, user_id, created_at, status) VALUES('댓글4', 3, 2, NOW(), 'ACTIVE');

 

즉시 로딩

Hibernate: 
    select
        b1_0.id,
        b1_0.content,
        b1_0.created_at,
        b1_0.title,
        b1_0.user_id,
        r1_0.board_id,
        r1_0.id,
        r1_0.comment,
        r1_0.created_at,
        r1_0.status,
        r1_0.user_id 
    from
        board_tb b1_0 
    left join
        reply_tb r1_0 
            on b1_0.id=r1_0.board_id 
    where
        b1_0.id=?
Hibernate: 
    select
        u1_0.id,
        u1_0.created_at,
        u1_0.email,
        u1_0.password,
        u1_0.role,
        u1_0.username 
    from
        user_tb u1_0 
    where
        u1_0.id=?

 

boardService.java 코드 부분 수정

더보기
더보기
더보기
/**
     * 게시글 상세보기 서비스, 게시글 주인 여부 판별
     */
    public Board getBoardDetails(int boardId, User sessionUser) {
        Board board = boardJPARepository
                .findById(boardId)
                .orElseThrow(()-> new Exception404("게시글을 찾을 수 없어요"));
        boolean isBoardOwner = false;
        if(sessionUser != null) {
            if(sessionUser.getId().equals(board.getUser().getId())) {
                isBoardOwner = true;
            }
        }

        // 집중 - 코드 추가
        // 내가 작성한 댓글인가를 구현 해야 한다.
        board.getReplies().forEach(reply -> {
            boolean isReplayOwner = false;
            if(sessionUser != null) {
                if(sessionUser.getId().equals(reply.getUser().getId())) {
                    isReplayOwner = true;
                }
            }
            // 객체에서만 존재하는 필드 - 리플 객체 엔티티 상태값 변경 처리 
            reply.setReplyOwner(isReplayOwner);
        });


        board.setBoardOwner(isBoardOwner);
        return board;

    }

 

즉시로딩

더보기
더보기
더보기
Hibernate: 
    select
        b1_0.id,
        b1_0.content,
        b1_0.created_at,
        b1_0.title,
        b1_0.user_id,
        r1_0.board_id,
        r1_0.id,
        r1_0.comment,
        r1_0.created_at,
        r1_0.status,
        r1_0.user_id 
    from
        board_tb b1_0 
    left join
        reply_tb r1_0 
            on b1_0.id=r1_0.board_id 
    where
        b1_0.id=?
Hibernate: 
    select
        u1_0.id,
        u1_0.created_at,
        u1_0.email,
        u1_0.password,
        u1_0.role,
        u1_0.username 
    from
        user_tb u1_0 
    where
        u1_0.id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)

 

다시 바꾼거 돌리기 원점으로 되돌리기

 EAGER -> LAZY

 

Board.JAVA 

Reply.JAVA

 

우리들의 페치 전략(Fetch Strategy)은 어떻게 사용해야 할까??

  1. 모든 연관 관계(OneToOne, ManyToOne, OneToMany, ManyToMany)에서는 기본적으로 지연 로딩(LAZY)을 사용한다. (특히 컬렉션일 때)
    1. 한 엔티티가 다른 엔티티와 연결될 때, 필요한 시점까지 로딩을 지연하는 것이 성능 면에서 유리하다.
    2. 연관된 엔티티를 실제로 필요로 할 때만 로딩하여 자원 낭비를 줄일 수 있다.
  2. 필요한 경우에만 연관된 엔티티를 함께 로딩한다.
    1. JPQL의 FETCH JOIN이나 네이티브 쿼리를 사용하여 연관된 엔티티를 한 번에 로딩한다.
    2. 이를 통해 N+1 문제를 방지하고 성능을 최적화할 수 있다.
  3. 페이징 처리 등으로 많은 데이터를 가져와야 할 때는 지연 로딩(LAZY) 전략에 배치 사이즈(batch size)를 설정한다.
    1. 배치 사이즈를 설정하면 지연 로딩 시 한 번에 가져오는 엔티티의 수를 조절할 수 있다.
    2. N+1 문제를 완화하고, 데이터베이스 쿼리 횟수를 줄여 성능을 향상시킬 수 있다.
    3. application.yml 설정 확인
	spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 20
지연 로딩 - 쿼리 3번, 즉시 로딩 쿼리 2번 즉, 표면적으로는 EAGER 이 효율적으로 보일 수 있으나 
카테시안 곱(Cartesian Product)과 데이터 중복 등 잠재적 문제들이 발생 할 수 있습니다.

 

결론 - 기본적으로 LAZY 전략 설정을 하고 많은 양에 데이터페이칭 처리를 한다.

 

BoareService.java - 게시글 상세보기 수정

getBoardDetails() 메서드 부분 수정

핵심 내용 !

JPQL JOIN FETCH 사용 즉 Board USER 엔티티를 한번에 조인 처리 그리고 Replay LAZY 전략에 배치 사이즈 설정으로 가져오는 것

**
     * 게시글 상세보기 서비스, 게시글 주인 여부 판별
     */
    public Board getBoardDetails(int boardId, User sessionUser) {
        // 전략 2번
        // JPQL - JOIN FETCH 사용, 즉 User 엔티티를 한번에 조인 처리
        Board board = boardJPARepository.findByIdJoinUser(boardId).orElseThrow(()-> new Exception404("게시글을 찾을 수 없어요"));
        
        
        // 전략을 1번 JPA 가 객체간에 관계를 통해 직접 쿼리를 만들고 지가고 왔다.
//        Board board = boardJPARepository
//                .findById(boardId)
//                .orElseThrow(()-> new Exception404("게시글을 찾을 수 없어요"));
        
        // 현재 사용자가 게시글을 작성했는지 여부 판별
        boolean isBoardOwner = false;
        if(sessionUser != null) {
            if(sessionUser.getId().equals(board.getUser().getId())) {
                isBoardOwner = true;
            }
        }

 

그런데 한번에 Reply 정보도 조인해서 들고 오면 안될까?

String jpql = "SELECT b FROM Board b JOIN FETCH b.user LEFT JOIN FETCH b.replies r LEFT JOIN FETCH r.user WHERE b.id = :id";

 

데이터 중복 발생

  • 여러 개의 JOIN FETCH를 사용하면 결과 집합에 중복 데이터가 포함될 수 있습니다.
    • Board가 하나이고 Reply가 여러 개라면, Board와 User의 정보가 Reply의 개수만큼 중복됩니다.
    • 이는 애플리케이션 레벨에서 데이터 중복을 처리해야 하는 부담을 줍니다.

JPA의 제약사항

  • JPA에서는 한 쿼리에서 둘 이상의 컬렉션을 페치 조인하는 것을 권장하지 않습니다.
    • JPA 표준 스펙에서는 컬렉션을 둘 이상 페치 조인하면 결과가 정의되지 않는다고 명시하고 있습니다.
    • 일부 JPA 구현체(Hibernate 등)에서는 동작할 수 있지만, 예상치 못한 동작이나 성능 문제가 발생할 수 있습니다.

팁!! 컬렉션은 지연 로딩(LAZY)으로 유지하고 필요한 경우에만 로딩한다.


 

detail.mustache

detail.mustache [ {{#board.replies}}, {{#replyOwner}} ] 코드 추가 및 수정

{{> layout/header}}

<div class="container p-5">

    <!-- 수정, 삭제버튼 -->
    {{#isOwner}}
    <div class="d-flex justify-content-end">
        <a href="/board/{{board.id}}/update-form" class="btn btn-warning me-1">수정</a>
        <form action="/board/{{board.id}}/delete" method="post">
            <button class="btn btn-danger">삭제</button>
        </form>
    </div>
    {{/isOwner}}
    <form action="/board/{{board.id}}/delete1" method="post">
        <button class="btn btn-danger">삭제1</button>
    </form>

    <div class="d-flex justify-content-end">
        <b>작성자</b> : {{board.user.username}}
    </div>

    <!-- 게시글내용 -->
    <div>
        <h2><b>{{board.title}}</b></h2>
        <hr />
        <div class="m-4 p-2">
            {{board.content}}
        </div>
    </div>

    <!-- 댓글 -->
    <div class="card mt-3">
        <!-- 댓글등록 -->
        <div class="card-body">
            <form action="/reply/save" method="post">
                <textarea class="form-control" rows="2" name="comment"></textarea>
                <div class="d-flex justify-content-end">
                    <button type="submit" class="btn btn-outline-primary mt-1">댓글등록</button>
                </div>
            </form>
        </div>

        <!-- 댓글목록 -->
        <div class="card-footer">
            <b>댓글리스트</b>
        </div>
        <div class="list-group">

            {{#board.replies}}
            <!-- 댓글아이템 2-->
            <div class="list-group-item d-flex justify-content-between align-items-center">
                <div class="d-flex">
                    <div class="px-1 me-1 bg-primary text-white rounded">{{user.username}}</div>
                    <div>{{comment}}</div>
                </div>

                {{#replyOwner}}
                <form action="/reply/1/delete" method="post">
                    <button class="btn">🗑</button>
                </form>
                {{/replyOwner}}
            </div>
                {{/board.replies}}
        </div>
    </div>
</div>

{{> layout/footer}}