본문으로 바로가기

댓글 목록 보기

category Spring boot 2024. 11. 6. 12:25

BoardService 코드 수정 (게시글 상세 보기)

/**
     * 게시글 상세보기 서비스, 게시글 주인 여부 판별
     */
    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 
    from
        board_tb b1_0 
    where
        b1_0.id=?
Hibernate: 
    select
        r1_0.board_id,
        r1_0.id,
        r1_0.comment,
        r1_0.created_at,
        r1_0.status,
        r1_0.user_id 
    from
        reply_tb r1_0 
    where
        r1_0.board_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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)

즉시 로딩

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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)

 

지연 로딩 - 쿼리 3번, 즉시 로딩 쿼리 2번 즉, 표면적으로는 EAGER 이 효율적으로 보일 수 있으나 카테시안 곱(Cartesian Product)과 데이터 중복 등 잠재적 문제들이 발생 할 수 있습니다.

 

⭐⭐⭐ 중요 !! ⭐⭐⭐

우리들의 페치 전략(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 설정 확인
    4. spring: jpa: properties: hibernate: default_batch_fetch_size: 20

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

// @Repository  생략 가능
public interface BoardJPARepository extends JpaRepository<Board, Integer> {

    // 커스텀 쿼리 메서드 만들어 보기
    // Board 와 User 엔티티를 조인하여 특정 Board 엔티티를 조회
    @Query("select b from Board b join fetch b.user u where b.id = :id")
    Optional<Board> findByIdJoinUser(@Param("id") int id);
}

**BoardService - 게시글 상세보기 수정

핵심 내용 !** 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번 JAP 가 객체간에 관계를 통해 직접 쿼리를 만들고 가지고 왔다.
//        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;
}

그런데 한번에 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 [ {{#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}}

    <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}}