1. 전략
1. One 관계는 조인, Many 관계는 Lazy Loading 한다.
2. One 관계는 조인, Many 관계를 페이징해야 한다면 직접 쿼리를 만들어서 두 번 조회
3. One 관계와 Many 관계를 한 번에 조인
지금은 Lazy Loading을 사용한다.
2. Reply 테이블 만들기
package shop.mtcoding.blog.reply;
import jakarta.persistence.*;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.CreationTimestamp;
import shop.mtcoding.blog.board.Board;
import shop.mtcoding.blog.user.User;
import java.time.LocalDateTime;
@NoArgsConstructor // 빈생성자가 필요
@Entity
@Data
@Table(name = "reply_tb")
public class Reply {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id ;
private String comment;
//user_id 로 테이블 만들어짐.
@ManyToOne(fetch = FetchType.LAZY)
private User user ;
@ManyToOne(fetch = FetchType.LAZY)
private Board board ;
@CreationTimestamp
private LocalDateTime createdAt;
@Builder
public Reply(int id, String comment, User user, Board board, LocalDateTime createdAt) {
this.id = id;
this.comment = comment;
this.user = user;
this.board = board;
this.createdAt = createdAt;
}
}
한 명의 유저는 댓글 여러 개를 작성할 수 있지만 댓글 한 개를 여러명이 작성할 수 없다. 또 게시글 한 개에는 여러 개의 댓글이 달릴 수 있지만 한 개의 댓글은 여러 개시글에 달릴 수 없다. 댓글 테이블은 2개의 래 키를 가진다.
insert into reply_tb(comment, board_id, user_id, created_at) values('댓글1', 4, 1, now());
insert into reply_tb(comment, board_id, user_id, created_at) values('댓글2', 4, 1, now());
insert into reply_tb(comment, board_id, user_id, created_at) values('댓글3', 4, 2, now());
insert into reply_tb(comment, board_id, user_id, created_at) values('댓글4', 3, 2, now());
3. @OneToMany
어노테이션 활용
Board
package shop.mtcoding.blog.board;
import jakarta.persistence.*;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.CreationTimestamp;
import shop.mtcoding.blog.reply.Reply;
import shop.mtcoding.blog.user.User;
import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.List;
@NoArgsConstructor // 빈생성자가 필요
@Entity
@Data
@Table(name = "board_tb")
public class Board {
@Id
@GeneratedValue (strategy = GenerationType.IDENTITY)
private Integer id;
private String title;
private String content;
//@JoinColumn(name="user_id") 변수명을 직접 지정 가능
@ManyToOne(fetch = FetchType.LAZY)
private User user ; // 변수명이 user. user_id 를 만들어줌
@CreationTimestamp // persistance centext 에 전달될 때 자동으로 주입됨.
private Timestamp createdAt;
//테이블은 생성되면 안됨. 조회된 것을 담는 용도로만 사용.
//@ManyToOne 은 eager 가 기본, @OneToMany 는 lazy 가 기본
@OneToMany(mappedBy = "board",fetch = FetchType.LAZY) // Board 는 엔티티 객체의 필드명, reply 엔티티에 Board 객체를 넣는거임
private List<Reply> replies = new ArrayList<>(); // 댓글이 없으면 null 일 때 오류남. 그래서 new 를 해서 크기를 0 으로 만들어놓는다.
@Transient // 테이블 생성이 안됨. 임시로 사용함
private boolean isOwner ;
@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;
}
public void update(BoardRequest.UpdateDTO requestDTO){
this.title =requestDTO.getTitle();
this.content = requestDTO.getContent() ;
}
}
BoardController
package shop.mtcoding.blog.board;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;
import shop.mtcoding.blog._core.err.exception.Exception403;
import shop.mtcoding.blog._core.err.exception.Exception404;
import shop.mtcoding.blog.user.User;
import java.util.List;
@RequiredArgsConstructor
@Controller
public class BoardController {
private final BoardService boardService ;
private final HttpSession session;
@PostMapping("/board/save")
public String save(BoardRequest.SaveDTO requestDTO){
//ORM 으로 INSERT 할 때, USER객체의 ID만 들어가있어도 된다.
//즉 비영속 객체여도 된다. 하지만 없을 수도 있기 때문에 조회를 먼저 하는게 좋다.
User sessionUser = (User) session.getAttribute("sessionUser");
boardService.글쓰기(requestDTO,sessionUser);
return "redirect:/";
}
@GetMapping({ "/"})
public String index(HttpServletRequest request) {
List<Board> boardList = boardService.글목록조회();
request.setAttribute("boardList",boardList);
return "index"; // 리퀘스트디스패쳐 방식으로 가방을 내부적으로 전달.
}
@GetMapping("/board/save-form")
public String saveForm() {
return "board/save-form";
}
@GetMapping("/board/{id}")
public String detail(@PathVariable Integer id,HttpServletRequest request) { // int 를 쓰면 값이 없으면 0, Integer 를 넣으면 값이 없을 때 null 값이 들어옴.
// Board board = boardReposiroty.findByIdJoinUser(id); 이건 조인해서 하는 것
User sessionUser = (User) session.getAttribute("sessionUser");
Board board = boardService.글상세보기(id,sessionUser);
System.out.println("서버사이드 랜더링 직전에 Lazy Loading 실행된다.");
request.setAttribute("board", board);
return "board/detail";
}
// @PostMapping("/board/{id}/delete")
@RequestMapping(value = "/board/{id}/delete", method = {RequestMethod.GET, RequestMethod.POST})
public String delete(@PathVariable Integer id){
User sessionUser = (User) session.getAttribute("sessionUser");
boardService.글삭제(id,sessionUser.getId());
return "redirect:/";
}
@GetMapping("/board/{id}/update-form")
public String updateForm(@PathVariable Integer id,HttpServletRequest request){
User sessionUser = (User) session.getAttribute("sessionUser");
Board board = boardService.글수정폼(id,sessionUser.getId());
request.setAttribute("board",board);
return "board/update-form";
}
@PostMapping("/board/{id}/update")
public String update(@PathVariable Integer id,BoardRequest.UpdateDTO requestDTO){
User sessionUser = (User) session.getAttribute("sessionUser");
boardService.글조회(id,sessionUser.getId(),requestDTO);
return "redirect:/board/"+id ;
}
}
BoardJPARepository
package shop.mtcoding.blog.board;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Optional;
public interface BoardJPARepository extends JpaRepository<Board,Integer> {
@Query("select b from Board b join fetch b.user where b.id = :id")
Optional<Board> findByIdJoinUser(@Param("id") int id);
}
BoardService
package shop.mtcoding.blog.board;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import shop.mtcoding.blog._core.err.exception.Exception403;
import shop.mtcoding.blog._core.err.exception.Exception404;
import shop.mtcoding.blog.user.User;
import java.util.List;
@RequiredArgsConstructor
@Service
public class BoardService {
private final BoardJPARepository boardJPARepository ;
@Transactional
public void 글쓰기(BoardRequest.SaveDTO requestDTO, User sessionUser){
boardJPARepository.save(requestDTO.toEntity(sessionUser));
}
@Transactional
public void 글조회(int boardId,int sessionUserId,BoardRequest.UpdateDTO requestDTO){
//조회 및 예외처리
Board board = boardJPARepository.findById(boardId)
.orElseThrow(() -> new Exception404("게시글을 찾을 수 없습니다."));
//권한 처리,
if(sessionUserId!=board.getUser().getId()){
throw new Exception403("게시글을 수정할 권한이 없습니다");
}
board.setTitle(requestDTO.getTitle());
board.setContent((requestDTO.getContent()));
}
public Board 글수정폼(int boardId,int sessionUserId){
Board board = boardJPARepository.findById(boardId)
.orElseThrow(() -> new Exception404("게시글을 찾을 수 없습니다."));
if(sessionUserId!=board.getUser().getId()){
throw new Exception403("게시글을 수정할 권한이 없습니다");
}
return board ;
}
@Transactional
public void 글삭제(int boardId, int sessionUserId) {
Board board = boardJPARepository.findById(boardId).orElseThrow(() -> new Exception404("게시글을 찾을 수 없습니다."));
if(sessionUserId!=board.getUser().getId()){
throw new Exception403("게시글을 삭제할 권한이 없습니다");
}// 트랜잭션은 런타임익셉션이 발동하면 롤백된다.
boardJPARepository.deleteById(boardId);
}
public List<Board> 글목록조회() {
Sort sort = Sort.by(Sort.Direction.DESC,"id");
List<Board> boardList = boardJPARepository.findAll(sort);
return boardList;
}
public Board 글상세보기(int boardId, User sessionUser) {
Board board = boardJPARepository.findByIdJoinUser(boardId)
.orElseThrow(() -> new Exception404("게시글을 찾을 수 없습니다"));
boolean isOwner = false;
if(sessionUser != null){
if(sessionUser.getId() == board.getUser().getId()){
isOwner = true;
}
}
board.setOwner(isOwner);
return board;
}}
Board
@OneToMany(mappedBy = "board",fetch = FetchType.LAZY)
private List<Reply> replies = new ArrayList<>();
@OneToMany
어노테이션을 사용해 하나의 게시판(Board) 엔티티가 여러 개의 댓글(Reply) 엔티티와 관계를 나타낸다. mappedBy = "board"
는 Reply 엔티티 내에 Board엔티티를 참조하는 필드의 이름이 board임을 의미한다.board/detail.mustache
{{#board.replies}}
<!-- 댓글아이템 -->
<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>
<form action="/reply/{{id}}/delete" method="post">
<button class="btn">🗑</button>
</form>
</div>
{{/board.replies}}
Board 엔티티에
@OneToMany
어노테이션 말고는 추가된 코드가 없다. DB에서 조회된 Board 데이터에서 replies 데이터를 꺼낼 시점에 Lazy Loading이 일어나게 된다.
현재는 mustache에서 화면에 데이터를 출력할 때 Lazy Loading이 일어난다.
@GetMapping("/board/{id}")
public String detail(@PathVariable Integer id,HttpServletRequest request) { // int 를 쓰면 값이 없으면 0, Integer 를 넣으면 값이 없을 때 null 값이 들어옴.
User sessionUser = (User) session.getAttribute("sessionUser");
Board board = boardService.글상세보기(id,sessionUser);
System.out.println("서버사이드 랜더링 직전에 Lazy Loading 실행된다.");
request.setAttribute("board", board);
return "board/detail";
}
컨트롤러에서 Lazy Loading 시점을 확인하기 위에 System.out.println 을 사용한다.


상세보기 페이지로 갔을 때 조인 쿼리가 실행된다. 조인 쿼리가 컨트롤러를 통해 mustache로 전달되기 직전에 System.out.println 이 실행되고, 그 이후 reply 조회 쿼리와 reply 작성자를 조회하기 위해 user 조회 쿼리가 실행된다.
4. open-in-view
Spring에서의 Open-In-View(OSIV)는 세션 당 요청(Session per request)이라는 트랜잭션 패턴의 구현체이다.
- Open-In-View : false
DB를 통해 조회된 영속성 컨텍스트(Persistence Context)는 트랜잭션이 종료되는 순간 커넥션이 종료된다.
- Open-In-View : true (디폴트값)
DB를 통해 조회된 영속성 컨텍스트(Persistence Context)는 트랜잭션이 종료되어도 커넥션이 종료되지 않고, 클라이언트에게 응답이 된 이후 종료된다.
- Open-In-View : true

- Open-In-View : false



만약 open-in-view 를 false 상태로 만든다면 트랜잭션이 종료되는 서비스 레이어에서 커넥션이 종료되기 때문에 오류가 발생한다.
그래서 false 상태를 사용한다면 서비스 레이어에서 replies에 접근해야 한다.

Share article