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
결과
- 댓글을 최신 순으로 나열
- 댓글의 작성자를 DTO에 담아서 가져옴
- 작성자가 sessionUser와 일치할 경우 삭제 버튼 활성화

예외 처리 1. 댓글이 없는 게시물 상세보기
- 현재 코드에서는 댓글이 없는 1번 게시물을 확인할 수 없음 : NoResultException 발생

- 이는 댓글이 존재하지 않는 게시물에서 join fetch를 통해 값이 존재하는 게시물만을 찾기 때문에 발생
- left join을 통해 존재하지 않는 값은 null로 처리하여 해결
left join을 사용할 수 있는 이유
- 게시글은 항상 존재 : dummy data
- 댓글을 달기 위해서는 게시글이 우선적으로 존재해야 함
- 게시글을 처음 생성하면 댓글이 달리지 않음 : 댓글이 null인 경우 또한 존재해야 함
- 댓글 리스트 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로 표시

- 홈페이지 정상 작동

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);
}
결과
- 댓글 작성, 이후 새로고침을 통한 상세화면 확인까지 가능

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)까지 작성 가능

예외 처리 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