inblog logo
|
{CODE-RYU};
    SPIRNG

    [Spring] @OneToMany 댓글 목록보기

    류재성's avatar
    류재성
    Mar 25, 2024
    [Spring] @OneToMany 댓글 목록보기
    Contents
    1. 전략2. Reply 테이블 만들기3. @OneToMany 어노테이션 활용4. open-in-view
     

    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이 일어난다.
     
    notion image
     
    @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 을 사용한다.
     
    notion image
     
    notion image
     
    💡
    상세보기 페이지로 갔을 때 조인 쿼리가 실행된다. 조인 쿼리가 컨트롤러를 통해 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
    notion image
     
    - Open-In-View : false
    notion image
     
     
     
    notion image
     
    notion image
     
    만약 open-in-view 를 false 상태로 만든다면 트랜잭션이 종료되는 서비스 레이어에서 커넥션이 종료되기 때문에 오류가 발생한다.
     
    그래서 false 상태를 사용한다면 서비스 레이어에서 replies에 접근해야 한다.
     
     
    notion image
     
    Share article
    Contents
    1. 전략2. Reply 테이블 만들기3. @OneToMany 어노테이션 활용4. open-in-view

    {CODE-RYU};

    RSS·Powered by Inblog