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