게시글 상세보기 (Post Details View)

 

목차

    💡 학습 목표
        1. Fetch 전략 이해하기 : EAGER와 LAZY (Fetch) 전략의 차이점과 동작 방식을 이해한다.
        2. Lazy Loading 동작 방식 이해하기 : 지연 로딩이 어떻게 작동하고, 언제 데이터를 가져오는지 학습한다.
        3. 직접 조인(Fetch Join) 사용하기 : 필요한 경우 직접 조인을 사용하여 성능을 최적화하는 방법을 배운다.

    1. 게시글 상세보기 구현 (Eager Fetching)

    목표: EAGER 페치 전략을 사용하여 게시글 상세보기 기능을 구현하고, 연관된 객체가 즉시 로딩되는 것을 확인한다.

    package com.tenco.blog_v1.board;
    
    import com.tenco.blog_v1.user.User;
    import jakarta.persistence.*;
    import lombok.Builder;
    import lombok.Data;
    
    import java.sql.Timestamp;
    
    @Entity
    @Table(name = "board_tb")
    @Data
    public class Board {
    
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY) // 기본키 전략 db 위임
        private Integer id;
        private String title;
        private String content;
    
        @ManyToOne(fetch = FetchType.EAGER)
        @JoinColumn(name = "user_id")
        private User user; // 게시글 작성자 정보
    
        // created_at 컬럼과 매핑하며, 이 필드는 데이터 저장시 자동으로 설정 됨
        @Column(name = "created_at", insertable = false, updatable = false)
        private Timestamp createdAt;
    
        @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;
        }
    
    }

     

    fetch = FetchType.EAGER 로 설정하여 Board 엔티티를 조회할 때 연관된 User 엔티티도 즉시 로딩한다.

     

    BoardRepository.java
    package com.tenco.blog_v1.board;
    
    import jakarta.persistence.EntityManager;
    import lombok.RequiredArgsConstructor;
    import org.springframework.stereotype.Repository;
    
    @RequiredArgsConstructor
    @Repository // IoC
    public class BoardRepository  {
    
        private final EntityManager em;
    
        /**
         * 게시글 조회 메서드
         * @param id 조회할 게시글 ID
         * @return 조회된 Board 엔티티, 존재하지 않으면 null 반환 
         */
        public Board findById(int id) {
             return em.find(Board.class, id);
        }
    
    }

     

    board/detail.mustache
    {{> layout/header}}
    
    <div class="container p-5">
    
        <!-- 수정, 삭제버튼 -->
        <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>
    
        <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">
                <!-- 댓글아이템 -->
                <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">cos</div>
                        <div>댓글 내용입니다</div>
                    </div>
                    <form action="/reply/1/delete" method="post">
                        <button class="btn">🗑</button>
                    </form>
                </div>
                <!-- 댓글아이템 -->
                <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">ssar</div>
                        <div>댓글 내용입니다</div>
                    </div>
                    <form action="/reply/1/delete" method="post">
                        <button class="btn">🗑</button>
                    </form>
                </div>
            </div>
        </div>
    </div>
    
    {{> layout/footer}}

     

    Fetch 전략 이해하기 : EAGER와 LAZY (Fetch) 전략의 차이점과 동작 방식을 이해한다.

    (확인을 위해 잠시 board.user.username을 제거)
    ----------- EAGER ---------------
    Hibernate: 
     select
            b1_0.id,
            b1_0.content,
            b1_0.created_at,
            b1_0.title,
            u1_0.id,
            u1_0.created_at,
            u1_0.email,
            u1_0.password,
            u1_0.username 
        from
            board_tb b1_0 
        left join
            user_tb u1_0 
                on u1_0.id=b1_0.user_id 
        where
            b1_0.id=?
    Hibernate: 
        select
            b1_0.user_id,
            b1_0.id,
            b1_0.content,
            b1_0.created_at,
            b1_0.title 
        from
            board_tb b1_0 
        where
            b1_0.user_id=?
            
    ----------- LAZY ---------------
    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=?

     

    board.user.username 활성화 시
    지연 로딩을 하더라도 결국 사용하는 시점에 객체 쿼리가 발생 된다.
    <div class="d-flex justify-content-end">
        <b>작성자</b> : {{ board.user.username }}
    </div>

    요약

    EAGER 전략과 Lazy 전략에 대한 차이점을 이해 한다. 하지만 둘 다 사용하더라도 N + 1 문제 발생 발생 할 수 있다.

    : 지연 로딩이더라도 연관된 데이터를 가져 와야 된다면 —> 쿼리가 두번 생성 호출 된다 (N + 1 문제 발생)

     

    2. JPQL 활용

    BoardController 코드 수정
      // 특정 게시글 요청 화면
      // 주소설계 - http://localhost:8080/board/1
      @GetMapping("/board/{id}")
      public String detail(@PathVariable(name = "id") Integer id, HttpServletRequest request) {
          // JPA API 사용
          // Board board = boardRepository.findById(id);
    
          // JPQL FETCH join 사용
          Board board = boardRepository.findByIdJoinUser(id);
          request.setAttribute("board", board);
          return "board/detail";
      }

     

    BoardRepository 코드 추가
    package com.tenco.blog_v1.board;
    
    import jakarta.persistence.EntityManager;
    import lombok.RequiredArgsConstructor;
    import org.springframework.stereotype.Repository;
    
    @RequiredArgsConstructor
    @Repository // IoC
    public class BoardRepository  {
    
        private final EntityManager em;
    
        /**
         * 게시글 조회 메서드
         * @param id 조회할 게시글 ID
         * @return 조회된 Board 엔티티, 존재하지 않으면 null 반환
         */
        public Board findById(int id) {
             return em.find(Board.class, id);
        }
    
        /**
         * JPQL의 FETCH 조인 사용 - 성능 최적화
         * 한방에 쿼리를 사용해서 즉, 직접 조인해서 데이터를 가져 옵니다.
         * @param id
         * @return
         */
        public Board findByIdJoinUser(int id) {
            // JPQL -> Fetch join 을 사용해 보자.
            String jpql = " SELECT b FROM Board b JOIN FETCH b.user WHERE b.id = :id ";
            return em.createQuery(jpql, Board.class)
                    .setParameter("id", id)
                    .getSingleResult();
        }
    
    }

    User.board 도 LAZY로 변경해야 함 !!

     

    결과:

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

    : 쿼리문을 한번만 던짐

     

    3. JPQL 이란?

    • JPQLJava Persistence Query Language의 약자로, JPA에서 사용되는 객체 지향 쿼리 언어이다.
    • SQL과 유사하지만, 테이블이 아닌 엔티티 객체를 대상으로 쿼리를 작성한다.
    • JPQL을 사용하면 데이터베이스에 독립적인 쿼리를 작성할 수 있어, 특정 데이터베이스 벤더에 종속되지 않는다.
    • JPQL은 JPA 표준 스펙의 일부로, 대부분의 JPA 구현체(Hibernate 등)에서 지원한다.

     

    JPQL - Fetch Join 활용 (게시글 상세 보기)

    • Fetch Join이란:
      • JPQL에서 제공하는 기능으로, 연관된 엔티티를 한 번의 쿼리로 함께 조회하기 위해 사용한다.
      • 지연 로딩 설정과 관계없이 연관된 엔티티를 즉시 로딩한다.
    • 사용 이유
      • N+1 문제를 해결하여 데이터베이스 쿼리 횟수를 줄이고 성능을 최적화하기 위해 사용한다.
    • 사용 방법:
      • JPQL 쿼리에서 JOIN FETCH 구문을 사용하여 연관된 엔티티를 함께 조회한다.

     

    Fetch Join 사용 시 주의사항

    • 데이터 중복:
      • Fetch Join으로 여러 연관 엔티티를 조인하면 결과가 중복될 수 있으므로, 필요한 엔티티만 선택적으로 조인해야 한다.
    • 페이징 제한:
      • JPA에서는 Fetch Join을 사용한 상태에서 페이징을 지원하지 않는다.
      • 데이터가 많을 경우 메모리 사용량이 증가할 수 있으므로 주의해야 한다.
    • 적절한 사용:
      • 무분별한 사용은 오히려 성능을 저하시킬 수 있으므로, 필요한 경우에만 사용해야 한다.

     

    JPQL과 SQL의 차이점

    • JPQL은 객체 지향 쿼리 언어로, 엔티티 객체를 대상으로 쿼리한다.
    • SQL은 데이터베이스 테이블과 컬럼을 대상으로 쿼리한다.
    • JPQL은 엔티티와 그 사이의 연관 관계를 사용하므로, 데이터베이스에 독립적인 쿼리를 작성할 수 있다.

     

    4. 결론

    우리는 어떤 전략을 선택해야 하나?

    • 기본적으로 Lazy Fetching을 사용하여 불필요한 데이터 로딩을 방지한다.
    • 필요한 경우 Fetch Join 등을 사용하여 성능을 최적화한다.

    요약

    • Eager Fetching: 연관된 엔티티를 즉시 로딩하여, 엔티티 조회 시 함께 데이터를 가져온다.
      • 장점: 연관된 데이터를 바로 사용할 수 있다.
      • 단점: 불필요한 데이터까지 로딩되어 성능이 저하될 수 있다.
    • Lazy Fetching: 연관된 엔티티를 실제로 접근할 때까지 로딩을 지연시킨다.
      • 장점: 필요한 시점에만 데이터를 로딩하여 성능을 향상시킨다.
      • 단점: 지연 로딩 시점에 추가적인 SQL 쿼리가 발생한다.
    • Fetch Join: 한 번의 쿼리로 연관된 엔티티를 함께 로딩하여 성능을 최적화한다.
      • 사용 시 주의점: 너무 많이 사용하면 오히려 성능이 저하될 수 있으므로 필요한 경우에만 사용한다.

     

    Fetch 전략 선택 기준

    • Lazy Fetching을 기본으로 사용하여 불필요한 데이터 로딩을 방지한다.
    • Eager Fetching은 반드시 함께 로딩해야 하는 연관 데이터가 있을 때 신중히 사용한다.

    Fetch Join 사용 시 주의사항

    • Fetch Join은 즉시 로딩과 유사하게 작동하지만, 원하는 시점에 적용할 수 있다.
    • 복잡한 쿼리나 데이터 양이 많은 경우 오히려 성능이 저하될 수 있으므로 주의해야 한다.

    N+1 문제

    • 지연 로딩을 사용할 때 연관된 엔티티를 반복적으로 조회하면, 예상치 못한 많은 수의 SQL 쿼리가 발생할 수 있다.
    • 이를 N+1 문제라고 하며, Fetch Join 등을 사용하여 해결할 수 있다.

    목차로 돌아가기