Spring boot

블로그5 - 게시글 상세보기 (Post Details View)

ryeonng 2024. 10. 23. 16:26

학습 목표

  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 lombok.NoArgsConstructor;

import java.sql.Timestamp;

@NoArgsConstructor
@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 엔티티도 즉시 로딩한다.

 

repository
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);
    }
}

 

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) 전략의 차이점과 동작방식을 이해한다.

 

EAGER 전략

 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=?

 

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

 

요약

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

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

 

BoardController 코드 수정
// 특정 게시글 요청 화면
    // 주소설계 http://localhost:8080/board/10
    @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_tb 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=?

 

JPQL

  • JPQL은 Java 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은 엔티티와 그 사이의 연관 관계를 사용하므로, 데이터베이스에 독립적인 쿼리를 작성할 수 있다.

 

우리는 어떤 전략을 선택해야 할까?

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

 

요약

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

 

Fetch 전략 선택 기준

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

2) Fetch Join 사용 시 주의사항

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

3) N+1 문제

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