12-1. (실습) 스프링 V3 (댓글)

박은서's avatar
Feb 24, 2026
12-1. (실습) 스프링 V3 (댓글)

1. 테이블 세팅

1) Reply.java

C:\workspace\spring_lab\boardv1\src\main\java\com\example\boardv1\reply\Reply.java
package com.example.boardv1.reply; import java.time.LocalDateTime; import org.hibernate.annotations.CreationTimestamp; import com.example.boardv1.board.Board; import com.example.boardv1.user.User; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import lombok.Data; import lombok.NoArgsConstructor; /** * 게시글 1 : 댓글 N * 유저 1 : 댓글 N */ @NoArgsConstructor @Data @Entity @Table(name = "reply_tb") public class Reply { @GeneratedValue(strategy = GenerationType.IDENTITY) @Id private Integer id; private String comment; @ManyToOne // 디폴트 : EAGER 전략 private Board board; // board_tb @ManyToOne private User user; @CreationTimestamp private LocalDateTime createdAt; }

2) data.sql - 더미데이터 추가

C:\workspace\spring_lab\boardv1\src\main\resources\db\data.sql
insert into reply_tb (user_id, board_id, comment, created_at) values(1, 6, 'comment1', now()); insert into reply_tb (user_id, board_id, comment, created_at) values(1, 6, 'comment2', now()); insert into reply_tb (user_id, board_id, comment, created_at) values(2, 6, 'comment3', now()); insert into reply_tb (user_id, board_id, comment, created_at) values(1, 5, 'comment4', now()); insert into reply_tb (user_id, board_id, comment, created_at) values(2, 5, 'comment5', now());

2. 댓글 보기

1) ReplyResponse.java

C:\workspace\spring_lab\boardv1\src\main\java\com\example\boardv1\reply\ReplyResponse.java
package com.example.boardv1.reply; public class ReplyResponse { public static class DTO { private Integer id; private String comment; private Integer replyUserId; private String replyUsername; private boolean isReplyOwner; // 로그인한 유저가 댓글을 작성한 유저인지? public DTO(Reply reply, Integer sessionUserId) { this.id = reply.getId(); this.comment = reply.getComment(); this.replyUserId = reply.getUser().getId(); this.replyUsername = reply.getUser().getUsername(); this.isReplyOwner = reply.getUser().getId() == sessionUserId; } } }

2) BoardResponse.java

C:\workspace\spring_lab\boardv1\src\main\java\com\example\boardv1\board\BoardResponse.java
package com.example.boardv1.board; import java.util.List; import com.example.boardv1.reply.ReplyResponse; import lombok.Data; public class BoardResponse { @Data public static class DetailDTO { // 화면에 보이지 않는 것 (PK는 화면에 안보여도 무조건 적어야 함!) private int id; private int userId; // 화면에 보이는 것 private String title; private String content; private String username; // 연산해서 만들어야 되는 것 private boolean isOwner; // 게시글의 주인인가? private List<ReplyResponse.DTO> replies; public DetailDTO(Board board, Integer sessionUserId) { this.id = board.getId(); this.userId = board.getUser().getId(); this.title = board.getTitle(); this.content = board.getContent(); this.username = board.getUser().getUsername(); this.isOwner = board.getUser().getId() == sessionUserId; this.replies = board.getReplies().stream() .map(reply -> new ReplyResponse.DTO(reply, sessionUserId)) .toList(); } } }

3) BoardController.java

package com.example.boardv1.board; import java.util.List; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.ResponseBody; import com.example.boardv1.user.User; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; import lombok.RequiredArgsConstructor; @RequiredArgsConstructor // final이 적혀있는 애로 생성자 만들어줌 @Controller // @Controller 적어야 리턴값이 파일이 됨 (외부진입점) public class BoardController { private final BoardService boardService; private final HttpSession session; // title : title=title7&content=content7 (x-www-form) @PostMapping("/boards/save") public String save(BoardRequest.SaveOrUpdateDTO reqDTO){ // new해서 넣어줌! 필드가 많을 때 상태만 추가하면 되기 때문에 매우 편함! 재사용 가능!(필드명 잘 적어야 함) // 인증(v) 권한(x) // 인증 User sessionUser = (User) session.getAttribute("sessionUser"); if (sessionUser == null) throw new RuntimeException("인증되지 않았습니다."); boardService.게시글쓰기(reqDTO.getTitle(), reqDTO.getContent()); return "redirect:/"; } // body : title=제목&content=내용 @PostMapping("/boards/{id}/update") public String update(@PathVariable("id") int id, BoardRequest.SaveOrUpdateDTO reqDTO){ // 인증(v) 권한(v) // 인증 User sessionUser = (User) session.getAttribute("sessionUser"); if (sessionUser == null) throw new RuntimeException("인증되지 않았습니다."); boardService.게시글수정(id,reqDTO.getTitle(), reqDTO.getContent(), sessionUser.getId()); return "redirect:/boards/"+id; } @GetMapping("/") public String index(HttpServletRequest req){ List<Board> list = boardService.게시글목록(); req.setAttribute("models", list); return "index"; } @GetMapping("/boards/save-form") public String saveForm(){ // 인증(v) 권한(x) // 인증 User sessionUser = (User) session.getAttribute("sessionUser"); if (sessionUser == null) throw new RuntimeException("인증되지 않았습니다."); return "board/save-form"; } @GetMapping("/boards/{id}/update-form") public String updateForm(@PathVariable("id") int id, HttpServletRequest req){ // 인증(v) 권한(v) // 인증 User sessionUser = (User) session.getAttribute("sessionUser"); if (sessionUser == null) throw new RuntimeException("인증되지 않았습니다."); Board board = boardService.수정폼게시글정보(id, sessionUser.getId()); req.setAttribute("model", board); return "board/update-form"; } @GetMapping("/boards/{id}") public String detail(@PathVariable("id") int id, HttpServletRequest req){ User sessionUser = (User) session.getAttribute("sessionUser"); // Object타입으로 반환하기 때문에 다운캐스팅해야 함 Integer sessionUserId = sessionUser == null ? null : sessionUser.getId(); BoardResponse.DetailDTO dto = boardService.상세보기(id, sessionUserId); req.setAttribute("model", dto); return "board/detail"; // mustache 파일의 경로 } @PostMapping("/boards/{id}/delete") public String delete(@PathVariable("id") int id){ // 인증(v) 권한(v) // 인증 User sessionUser = (User) session.getAttribute("sessionUser"); if (sessionUser == null) throw new RuntimeException("인증되지 않았습니다."); boardService.게시글삭제(id, sessionUser.getId()); return "redirect:/"; } @GetMapping("/api/boards/{id}") public @ResponseBody BoardResponse.DetailDTO apiDetail(@PathVariable("id") int id) { User sessionUser = (User) session.getAttribute("sessionUser"); Integer sessionUserId = sessionUser == null ? null : sessionUser.getId(); BoardResponse.DetailDTO dto = boardService.상세보기(id, sessionUserId); return dto; } }

4) detail.mustache

{{> header}} <div class="container p-5"> {{#model.isOwner}} <!-- 수정삭제버튼 --> <div class="d-flex justify-content-end"> <a href = "/boards/{{model.id}}/update-form" class="btn btn-secondary me-1">수정</a> <form action = "/boards/{{model.id}}/delete" method="post"> <button class="btn btn-outline-secondary">삭제</button> </form> </div> {{/model.isOwner}} <!-- 게시글내용 --> <div> <h2><b>{{model.title}}</b></h2> <hr /> <div class="d-flex justify-content-end"> 작성자 : {{model.username}} </div> <div class="m-4 p-2"> {{model.content}} </div> </div> <!-- 댓글 --> <div class="card mt-3"> <!-- 댓글등록 --> <div class="card-body"> <form action="/replies/save" method="post"> <textarea class="form-control" rows="2" name="comment"></textarea> <div class="d-flex justify-content-end"> <button class="btn btn-secondary mt-1"> 댓글등록 </button> </div> </form> </div> <!-- 댓글목록 --> <div class="card-footer"> <b>댓글리스트</b> </div> <div class="list-group"> {{#model.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-secondary text-white rounded">{{replyUsername}}</div> <div>{{comment}}</div> </div> {{#isReplyOwner}} <form action="/replies/{{id}}/delete" method="post"> <button class="btn">🗑</button> </form> {{/isReplyOwner}} </div> {{/model.replies}} </div> </div> </div> </body> </html>
💡
DTO 만드는 이유(목적)
  1. 추가 필드가 필요할 때
  1. 필드를 원하는 것만 뽑아서 전달하고 싶을 때
  1. 화면에 딱 맞는 데이터를 전달하기 위해

4. 댓글 쓰기

1) ReplyRequest.java

package com.example.boardv1.reply; import lombok.Data; public class ReplyRequest { @Data public static class SaveDTO { private Integer boardId; private String comment; } }

2) ReplyController.java

package com.example.boardv1.reply; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import com.example.boardv1.user.User; import jakarta.servlet.http.HttpSession; import lombok.RequiredArgsConstructor; @RequiredArgsConstructor @Controller public class ReplyController { private final ReplyService replyService; private final HttpSession session; @PostMapping("/replies/save") public String save(ReplyRequest.SaveDTO reqDTO){ // 인증 User sessionUser = (User) session.getAttribute("sessionUser"); if (sessionUser == null) throw new RuntimeException("인증되지 않았습니다."); int sessionUserId = sessionUser.getId(); replyService.댓글등록(sessionUserId, reqDTO.getBoardId(), reqDTO.getComment()); return "redirect:/boards/" + reqDTO.getBoardId(); } }

3) ReplyService.java

package com.example.boardv1.reply; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.example.boardv1.board.Board; import com.example.boardv1.board.BoardRepository; import com.example.boardv1.user.User; import com.example.boardv1.user.UserRepository; import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; @RequiredArgsConstructor @Service public class ReplyService { private final ReplyRepository replyRepository; private final EntityManager em; @Transactional public void 댓글등록(int sessionUserId, int boardId, String comment) { Board board = em.getReference(Board.class, boardId); // 가짜로 영속화. 조회하면 계속 쿼리 터지는데 조회 안해도 됨 User user = em.getReference(User.class, sessionUserId); Reply reply = new Reply(); reply.setBoard(board); reply.setUser(user); reply.setComment(comment); replyRepository.save(reply); } }

4) ReplyRepository.java

package com.example.boardv1.reply; import java.util.Optional; import org.springframework.stereotype.Repository; import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; @RequiredArgsConstructor @Repository public class ReplyRepository { private final EntityManager em; public Reply save(Reply reply) { em.persist(reply); return reply; } }

5. 댓글 삭제

1) ReplyController.java

package com.example.boardv1.reply; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; import com.example.boardv1.user.User; import jakarta.servlet.http.HttpSession; import lombok.RequiredArgsConstructor; @RequiredArgsConstructor @Controller public class ReplyController { private final ReplyService replyService; private final HttpSession session; // replies/5/delete?boardId=2 @PostMapping("/replies/{id}/delete") public String delete(@PathVariable("id") int id, @RequestParam("boardId") int boardId){ // 인증 User sessionUser = (User) session.getAttribute("sessionUser"); if (sessionUser == null) throw new RuntimeException("인증되지 않았습니다."); replyService.댓글삭제(id, sessionUser.getId()); return "redirect:/boards/" + boardId; } }

2) ReplyService.java

package com.example.boardv1.reply; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.example.boardv1.board.Board; import com.example.boardv1.board.BoardRepository; import com.example.boardv1.user.User; import com.example.boardv1.user.UserRepository; import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; @RequiredArgsConstructor @Service public class ReplyService { private final ReplyRepository replyRepository; private final EntityManager em; @Transactional public void 댓글삭제(int id, int sessionUserId) { // 영속화(권한체크 하려면 findById해야 함. 권한체크 안해도 되면 getReference사용) Reply reply = replyRepository.findById(id) .orElseThrow(() -> new RuntimeException("삭제할 댓글을 찾을 수 없어요")); // 권한 if (sessionUserId != reply.getUser().getId()) throw new RuntimeException("삭제할 권한이 없습니다"); replyRepository.delete(reply); } }

3) ReplyRepository.java

package com.example.boardv1.reply; import java.util.Optional; import org.springframework.stereotype.Repository; import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; @RequiredArgsConstructor @Repository public class ReplyRepository { private final EntityManager em; public Optional<Reply> findById(int id) { Reply reply = em.find(Reply.class, id); return Optional.ofNullable(reply); } public Reply save(Reply reply) { em.persist(reply); return reply; } public void delete(Reply reply) { em.remove(reply); } }

4) detail.mustache

{{> header}} <div class="container p-5"> {{#model.isOwner}} <!-- 수정삭제버튼 --> <div class="d-flex justify-content-end"> <a href = "/boards/{{model.id}}/update-form" class="btn btn-secondary me-1">수정</a> <form action = "/boards/{{model.id}}/delete" method="post"> <button class="btn btn-outline-secondary">삭제</button> </form> </div> {{/model.isOwner}} <!-- 게시글내용 --> <div> <h2><b>{{model.title}}</b></h2> <hr /> <div class="d-flex justify-content-end"> 작성자 : {{model.username}} </div> <div class="m-4 p-2"> {{model.content}} </div> </div> <!-- 댓글 --> <div class="card mt-3"> <!-- 댓글등록 --> <div class="card-body"> <form action="/replies/save" method="post"> <input type="hidden" name="boardId" value="{{model.id}}"> <textarea id="comment" class="form-control" rows="2" name="comment"></textarea> <div class="d-flex justify-content-end"> <button class="btn btn-secondary mt-1"> 댓글등록 </button> </div> </form> </div> <!-- 댓글목록 --> <div class="card-footer"> <b>댓글리스트</b> </div> <div class="list-group"> {{#model.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-secondary text-white rounded">{{replyUsername}}</div> <div>{{comment}}</div> </div> {{#isReplyOwner}} <form action="/replies/{{id}}/delete?boardId={{model.id}}" method="post"> <input type="hidden" name="boardId" value="{{model.id}}"> <button class="btn">🗑</button> </form> {{/isReplyOwner}} </div> {{/model.replies}} </div> </div> </div> </body> </html>
boardId를 쿼리스트링으로 넘겨주기!

※ 로그인 안 한 상태에서 댓글 입력창 클릭했을 때

1) detail.mustache - 수정

① 코드

{{> header}} <div class="container p-5"> {{#model.isOwner}} <!-- 수정삭제버튼 --> <div class="d-flex justify-content-end"> <a href = "/boards/{{model.id}}/update-form" class="btn btn-secondary me-1">수정</a> <form action = "/boards/{{model.id}}/delete" method="post"> <button class="btn btn-outline-secondary">삭제</button> </form> </div> {{/model.isOwner}} <!-- 게시글내용 --> <div> <h2><b>{{model.title}}</b></h2> <hr /> <div class="d-flex justify-content-end"> 작성자 : {{model.username}} </div> <div class="m-4 p-2"> {{model.content}} </div> </div> <!-- 댓글 --> <div class="card mt-3"> <!-- 댓글등록 --> <div class="card-body"> <form action="/replies/save" method="post"> <input type="hidden" name="boardId" value="{{model.id}}"> <textarea id="comment" class="form-control" rows="2" name="comment"></textarea> <div class="d-flex justify-content-end"> <button class="btn btn-secondary mt-1"> 댓글등록 </button> </div> </form> </div> <!-- 댓글목록 --> <div class="card-footer"> <b>댓글리스트</b> </div> <div class="list-group"> {{#model.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-secondary text-white rounded">{{replyUsername}}</div> <div>{{comment}}</div> </div> {{#isReplyOwner}} <form action="/replies/{{id}}/delete?boardId={{model.id}}" method="post"> <input type="hidden" name="boardId" value="{{model.id}}"> <button class="btn">🗑</button> </form> {{/isReplyOwner}} </div> {{/model.replies}} </div> </div> </div> {{^sessionUser}} <script> // 1. 조건 (querySelector 사용, textArea에 comment라는 id 하나 추가하기) const textArea = document.querySelector("#comment"); // 2. textArea에 마우스로 클릭했을 때의 이벤트 리스너가 필요함 textArea.addEventListener("click", function () { // 3. 이 때, 행위가 하나 필요함 -> 알림창을 띄워서 '로그인하세요'라고 알려줘야 함 alert("로그인하세요"); }); </script> {{/sessionUser}} </body> </html>

② 테스트

notion image
Share article