지연 로딩
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)은 어떻게 사용해야 할까??
- 모든 연관 관계(OneToOne, ManyToOne, OneToMany, ManyToMany)에서는 기본적으로 지연 로딩(LAZY)을 사용한다. (특히 컬렉션일 때)
- 한 엔티티가 다른 엔티티와 연결될 때, 필요한 시점까지 로딩을 지연하는 것이 성능 면에서 유리하다.
- 연관된 엔티티를 실제로 필요로 할 때만 로딩하여 자원 낭비를 줄일 수 있다.
- 필요한 경우에만 연관된 엔티티를 함께 로딩한다.
- JPQL의 FETCH JOIN이나 네이티브 쿼리를 사용하여 연관된 엔티티를 한 번에 로딩한다.
- 이를 통해 N+1 문제를 방지하고 성능을 최적화할 수 있다.
- 페이징 처리 등으로 많은 데이터를 가져와야 할 때는 지연 로딩(LAZY) 전략에 배치 사이즈(batch size)를 설정한다.
- 배치 사이즈를 설정하면 지연 로딩 시 한 번에 가져오는 엔티티의 수를 조절할 수 있다.
- N+1 문제를 완화하고, 데이터베이스 쿼리 횟수를 줄여 성능을 향상시킬 수 있다.
- 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}}
'Spring boot' 카테고리의 다른 글
| 2024.10.17 Blog 프로젝트 만들기(JPA) 댓글 쓰기 및 삭제 인터셉터 적용 (2) | 2024.10.17 |
|---|---|
| 2024.10.17 Blog 프로젝트 만들기(JPA) 게시글 삭제 오류 해결 (미완성) (0) | 2024.10.17 |
| 2024.10.16 Blog 프로젝트 만들기(JPA) 댓글 테이블 설계 (엔티티) (1) | 2024.10.16 |
| 2024.10.15 Blog 프로젝트 만들기(JPA) Service 레이어 만들기 (0) | 2024.10.15 |
| 2024.10.14 Blog 프로젝트 만들기(JPA) JPARepository 란? (2) | 2024.10.14 |