[스프링부트] 22. Blog v2 : Reply

문정준's avatar
Apr 09, 2025
[스프링부트] 22. Blog v2 : Reply

1. Reply List 출력

 
  • Board
    • OneToMany : 양방향 Mapping
    • One-Shot Query를 이용하여 Board에 연관된 모든 테이블들을 PC에 캐싱
    • 자료의 탐색 속도 향상, 객체지향 DB
      • 컬렉션을 저장하여 n:1 양방향 매핑을 통해 내부 속성 활용 가능
package shop.mtcoding.blog.board; import jakarta.persistence.*; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import org.hibernate.annotations.CreationTimestamp; import shop.mtcoding.blog.love.Love; 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 @Getter @Table(name = "board_tb") @Entity public class Board { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; private String title; private String content; private Boolean isPublic; @ManyToOne(fetch = FetchType.LAZY) private User user; // ORM @OneToMany(mappedBy = "board", fetch = FetchType.LAZY, cascade = CascadeType.ALL) private List<Reply> replies = new ArrayList<Reply>(); @CreationTimestamp private Timestamp createdAt; @Builder public Board(Integer id, String title, String content, Boolean isPublic, User user, Timestamp createdAt) { this.id = id; this.title = title; this.content = content; this.isPublic = isPublic; this.user = user; this.createdAt = createdAt; } public void update(String title, String content, Boolean isPublic) { this.title = title; this.content = content; this.isPublic = isPublic == null ? false : isPublic; } }
 
  • DetailDTO
    • List<ReplyDTO> 사용
      • Reply 작성자와 현재 로그인한 유저가 일치하는지 확인하기 위한 isOwner 추가
      • Reply - Board 간 양방향 매핑으로 인해 JSON fetch 중 발동되는 Getter의 무한루프 방지
@Data public static class DetailDTO { private Integer id; private String title; private String content; private Boolean isPublic; private Boolean isOwner; private Boolean isLove; private Integer loveCount; private String username; private Timestamp createdAt; private Integer loveId; private List<ReplyDTO> replies; @Data public class ReplyDTO { private Integer id; private String content; private String username; private Boolean isOwner; public ReplyDTO(Reply reply, Integer sessionUserId) { this.id = reply.getId(); this.content = reply.getContent(); this.username = reply.getUser().getUsername(); this.isOwner = reply.getUser().getId().equals(sessionUserId); } } public DetailDTO(Board board, Integer sessionUserId, Boolean isLove, Integer loveCount, Integer loveId) { this.id = board.getId(); this.title = board.getTitle(); this.content = board.getContent(); this.isPublic = board.getIsPublic(); this.isOwner = sessionUserId == board.getUser().getId(); this.username = board.getUser().getUsername(); this.createdAt = board.getCreatedAt(); this.isLove = isLove; this.loveCount = loveCount; this.loveId = loveId; List<ReplyDTO> repliesDTO = new ArrayList<>(); for(Reply reply : board.getReplies()) { ReplyDTO replyDTO = new ReplyDTO(reply, sessionUserId); repliesDTO.add(replyDTO); } this.replies = repliesDTO; } }
 
  • Reply
@NoArgsConstructor @Getter @Entity @Table(name="reply_tb") public class Reply { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private int id; @ManyToOne(fetch = FetchType.LAZY) private User user; @ManyToOne(fetch = FetchType.LAZY) private Board board; private String content; @CreationTimestamp private Timestamp createdAt; @Builder public Reply(int id, User user, Board board, String content, Timestamp createdAt) { this.id = id; this.user = user; this.board = board; this.content = content; this.createdAt = createdAt; } }
 
  • ReplyRepository
    • findAllByBoardId : 댓글의 모든 목록 출력
    • 댓글 작성자와 로그인한 유저가 일치하는지 확인할 수가 없음 : 변경 필요
public List<Reply> findAllByBoardId(int boardId) { Query q = em.createQuery("select r from Reply r join fetch r.user where r.board.id = :boardId order by r.id desc"); q.setParameter("boardId", boardId); List<Reply> replies = q.getResultList(); return replies; }
 
  • BoardService
    • DetailDTO에 Board의 정보를 추가
    • findByIdJoinUserAndReplies : Board 테이블 안에 Reply 테이블 정보까지 추가
      • 양방향 Mapping
public BoardResponse.DetailDTO 글상세보기(Integer id, Integer userId) { Board board = boardRepository.findByIdJoinUserAndReplies(id); Love love = loveRepository.findByUserIdAndBoardId(userId, id); Long loveCount = loveRepository.findByBoardId(id); Integer loveId = love == null ? null : love.getId(); Boolean isLove = love == null ? false : true; BoardResponse.DetailDTO detailDTO = new BoardResponse.DetailDTO(board, userId, isLove, loveCount.intValue(), loveId); return detailDTO; }
 
  • BoardRepository
    • ORM을 사용한 join
      • One-Shot Query를 사용하여 필요한 결과를 한 번에 조회
        • Board → Replies → User의 정보를 Join하려면 : Replies에 별칭 부여
      • 나머지 정보가 필요하면 PC에서 Caching
public Board findByIdJoinUserAndReplies(Integer id) { Query query = em.createQuery("select b from Board b join fetch b.user join fetch b.replies r join fetch r.user where b.id = :id order by r.id desc", Board.class); query.setParameter("id", id); return (Board) query.getSingleResult(); }
 

Test

1. Lazy Loading

  • Lazy Loading을 이용하여 이후에 필요한 정보를 추가 Join으로 찾음
notion image
  • 마지막 ssar을 찾을 때 쿼리 발동이 되지 않은 이유? : PC 내부에서 Caching
notion image
 

2. One-Shot Query

  • 처음부터 필요한 정보를 전부 담은 One-Shot Query로 데이터 탐색
notion image
  • 모든 정보를 한 번에 찾아올 수 있음 : 데이터 Caching에서 유리
    • 맞춤형 One-Shot Query를 작성해야 하는 불편함은 존재
      • ORM을 이용하여 DTO에 담고 한 번에 반환
notion image
 

결과

  • 댓글을 최신 순으로 나열
    • 댓글의 작성자를 DTO에 담아서 가져옴
    • 작성자가 sessionUser와 일치할 경우 삭제 버튼 활성화
notion image
 

예외 처리 1. 댓글이 없는 게시물 상세보기

  • 현재 코드에서는 댓글이 없는 1번 게시물을 확인할 수 없음 : NoResultException 발생
notion image
 
  • 이는 댓글이 존재하지 않는 게시물에서 join fetch를 통해 값이 존재하는 게시물만을 찾기 때문에 발생
    • left join을 통해 존재하지 않는 값은 null로 처리하여 해결
 
✏️
left join을 사용할 수 있는 이유
  1. 게시글은 항상 존재 : dummy data
      • 댓글을 달기 위해서는 게시글이 우선적으로 존재해야 함
  1. 게시글을 처음 생성하면 댓글이 달리지 않음 : 댓글이 null인 경우 또한 존재해야 함
  1. 댓글 리스트 select 후 케이스 분류하는 경우를 합친 것이 left join
 
  • BoardRepository
public Board findByIdJoinUserAndReplies(Integer id) { Query query = em.createQuery("select b from Board b join fetch b.user join fetch b.replies r join fetch r.user where b.id = :id order by r.id desc", Board.class); query.setParameter("id", id); return (Board) query.getSingleResult(); }
 
  • 1번 게시글의 쿼리 작동
    • 댓글이 없으므로 댓글, 댓글 작성자 정보가 전부 null로 표시
notion image
 
  • 홈페이지 정상 작동
notion image
 

2. 댓글 작성

  • PostMapping을 사용한 form 태그 : SSR
  • detail.mustache : 댓글 작성 부분
    • 댓글 작성, 삭제의 경우 해당 게시글의 상세보기 화면을 새로고침 : boardId를 가져와야 함
<!-- 댓글등록 --> <div class="card-body"> <form action="/reply/save" method="post"> <input type="hidden" name="boardId" value="{{model.id}}"> <textarea class="form-control" rows="2" name="content"></textarea> <div class="d-flex justify-content-end"> <button type="submit" class="btn btn-outline-primary mt-1">댓글등록</button> </div> </form> </div>
 
  • ReplyRequest : 댓글 저장 DTO
    • 유저 정보는 따로
@Data public static class SaveDTO { private Integer boardId; private String content; public Reply toEntity(User sessionUser) { return Reply.builder() .content(content) .user(sessionUser) .board(Board.builder().id(boardId).build()) .build(); } }
 
  • ReplyController
@PostMapping("/reply/save") public String save(ReplyRequest.SaveDTO saveDTO, HttpSession session) { User sessionUser = (User) session.getAttribute("sessionUser"); if (sessionUser == null) throw new RuntimeException("인증이 필요합니다"); replyService.댓글저장(saveDTO, sessionUser); return "redirect:/board/"+saveDTO.getBoardId(); }
 
  • ReplyService
@Transactional public void 댓글저장(ReplyRequest.SaveDTO saveDTO, User sessionUser) { Reply req = saveDTO.toEntity(sessionUser); replyRepository.save(req); }
 
  • ReplyRepository
public void save(Reply reply) { em.persist(reply); }
 

결과

  • 댓글 작성, 이후 새로고침을 통한 상세화면 확인까지 가능
notion image
 
 

3. 댓글 삭제

  • PostMapping을 사용한 form 태그 : SSR
  • detail.mustache : 댓글 삭제 부분
    • 댓글 작성, 삭제의 경우 해당 게시글의 상세보기 화면을 새로고침 : boardId를 가져와야 함
{{#isOwner}} <form action="/reply/{{id}}/delete" method="post"> <input type="hidden" name="boardId" value="{{model.id}}"> <button class="btn">🗑</button> </form> {{/isOwner}}
 
  • ReplyController
    • 주소에 replyId가 있고, boardId 하나만 받아오면 되므로 RequestParam 사용
@PostMapping("/reply/{id}/delete") public String delete(@PathVariable("id") int replyId, @RequestParam int boardId, HttpSession session) { User sessionUser = (User) session.getAttribute("sessionUser"); if (sessionUser == null) throw new RuntimeException("인증이 필요합니다"); replyService.댓글삭제(replyId); return "redirect:/board/"+boardId; }
 
  • ReplyService
@Transactional public void 댓글삭제(int id) { replyRepository.deleteById(id); }
 
  • ReplyRepository
    • JPA를 사용해도 됨
public void deleteById(int id) { em.createQuery("delete from Reply r where r.id = :id") .setParameter("id", id) .executeUpdate(); }
 

결과

  • 기존 게시글에 존재하던 댓글3을 삭제, 새 댓글(댓글4)까지 작성 가능
notion image
 

예외 처리 1. 권한 검사

  • 댓글은 로그인 한 유저가 작성한 댓글만 삭제할 수 있어야 함
  • reply 테이블 내의 user_id와 sessionUser의 id가 일치해야 삭제할 수 있도록 코드 수정
 
  • ReplyController
@PostMapping("/reply/{id}/delete") public String deletev2(@PathVariable("id") int id, HttpSession session) { User sessionUser = (User) session.getAttribute("sessionUser"); if (sessionUser == null) throw new RuntimeException("인증이 필요합니다"); int boardId = replyService.댓글삭제2(id, sessionUser.getId()); return "redirect:/board/"+boardId; }
 
  • ReplyService
@Transactional public Integer 댓글삭제2(int id, Integer sessionUserId) { // 1. 댓글 존재 확인 Reply reply = replyRepository.findById(id); if(reply == null) throw new RuntimeException("댓글이 존재하지 않습니다."); // 2. 로그인 한 유저와 일치하는 지 확인 if (reply.getUser().getId() != sessionUserId) throw new RuntimeException("권한이 없습니다."); // 3. 삭제 replyRepository.deleteById(id); return reply.getBoard().getId(); }
 
  • ReplyRepository
public Reply findById(int id) { return em.find(Reply.class, id); }
 

결과

  • 작동 방식은 위와 동일
Share article

sxias