이전 글에서는 JPA를 활용하여 게시글 관리 REST API를 개발하고 CRUD 기능을 구현하는 과정을 다뤘습니다. 이번에는 댓글 관리 기능을 추가하여, 게시글에 대한 사용자 의견을 저장하고 조회할 수 있도록 개선해보려고 합니다.
엔티티 클래스 작성
package com.demo.domain;
import java.util.Date;
import org.hibernate.annotations.DynamicInsert;
import org.hibernate.annotations.DynamicUpdate;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Temporal;
import jakarta.persistence.TemporalType;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
@Entity
@Builder
@ToString
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@DynamicInsert
@DynamicUpdate
public class Reply {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
public int id; // 댓글 고유번호
public String content; // 댓글 내용
@Temporal(TemporalType.TIMESTAMP)
public Date createDate; // 댓글 작성 시간
@ManyToOne
@JoinColumn(name = "memberId")
public Member member;
@ManyToOne
@JoinColumn(name = "postId")
public Post Post;
}
Repository
package com.demo.repository;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import com.demo.domain.Reply;
public interface ReplyRepository extends JpaRepository<Reply, Integer> {
// 회원 ID로 댓글 조회
List<Reply> findByMemberId(String id);
// reply id로 특정 댓글 찾기
Reply findReplyById(int id);
// post id로 특정 댓글 찾기
List<Reply> findReplyByPostId(int postId);
}
Service
package com.demo.service;
import java.util.Date;
import java.util.List;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.demo.domain.Reply;
import com.demo.repository.ReplyRepository;
import jakarta.transaction.Transactional;
@Service
public class ReplyService {
@Autowired
public ReplyRepository replyRepo;
// 댓글 작성
public Reply createReply(Reply reply) {
reply.setCreateDate(new Date());
return replyRepo.save(reply);
}
// 댓글 수정
@Transactional
public Reply updateReply(int id, String content) {
Optional<Reply> updateReply = replyRepo.findById(id);
if (updateReply.isPresent()) {
Reply reply = updateReply.get();
reply.setContent(content);
return replyRepo.save(reply);
} else {
throw new RuntimeException("댓글을 찾을 수 없습니다.");
}
}
// 댓글 삭제
public void deleteReply(int id) {
if (replyRepo.existsById(id)) {
replyRepo.deleteById(id);
} else {
throw new RuntimeException("댓글을 찾을 수 없습니다.");
}
}
// 내 댓글 조회
public List<Reply> getRepliesByMemberId(String id) {
return replyRepo.findByMemberId(id);
}
public List<Reply> findReplyByPostId(int id) {
return replyRepo.findReplyByPostId(id);
}
public Reply findReplyById(int id) {
return replyRepo.findReplyById(id);
}
}
Controller
package com.demo.controller;
import java.util.List;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.DeleteMapping;
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.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import com.demo.domain.Member;
import com.demo.domain.Post;
import com.demo.domain.Reply;
import com.demo.service.PostService;
import com.demo.service.ReplyService;
import jakarta.servlet.http.HttpSession;
@Controller
public class ReplyController {
@Autowired
public ReplyService replyService;
@Autowired
public PostService postService;
// 내 댓글 조회
@GetMapping("/myreply")
public String myReply(HttpSession session, Model model) {
// 세션에 저장된 로그인 회원
Member loginUser = (Member) session.getAttribute("loginUser");
if (loginUser == null) {
return "redirect:/login"; // 로그인 안 된 경우 로그인 페이지로 리디렉트
}
// 내 댓글 목록 조회
List<Reply> myReplies = replyService.getRepliesByMemberId(loginUser.getId());
model.addAttribute("myReplies", myReplies);
return "/reply/myreply";
}
// 댓글 작성
@PostMapping("/post/{postId}/create")
public ResponseEntity<Reply> createReply(@PathVariable int postId, HttpSession session, @RequestBody Reply reply) {
Member loginUser = (Member) session.getAttribute("loginUser");
if (loginUser == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
Optional<Post> optionalPost = postService.getPostById(postId);
if(optionalPost.isEmpty()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); // 게시글이 없을 경우
}
Post post = optionalPost.get(); // Optional에서 실제 Post 꺼내기
reply.setMember(loginUser); // 로그인한 회원 설정
reply.setPost(post);
Reply savedReply = replyService.createReply(reply); // 댓글 저장
return ResponseEntity.ok(savedReply);
}
// 댓글 수정
@PutMapping("/update/{id}")
public ResponseEntity<Reply> updateReply(@PathVariable int id, @RequestBody Reply updatedReply, HttpSession session) {
Member loginUser = (Member) session.getAttribute("loginUser");
if (loginUser == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
Reply existingReply = replyService.findReplyById(id);
if (existingReply == null || !existingReply.getMember().getId().equals(loginUser.getId())) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); // 본인이 작성한 댓글만 수정 가능
}
existingReply.setContent(updatedReply.getContent()); // 댓글 내용 수정
Reply savedReply = replyService.createReply(existingReply); // 수정된 댓글 저장
return ResponseEntity.ok(savedReply);
}
// 댓글 삭제
@DeleteMapping("/delete/{id}")
public ResponseEntity<Void> deleteReply(@PathVariable int id, HttpSession session) {
Member loginUser = (Member) session.getAttribute("loginUser");
if (loginUser == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
Reply existingReply = replyService.findReplyById(id);
if (existingReply == null || !existingReply.getMember().getId().equals(loginUser.getId())) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); // 본인이 작성한 댓글만 삭제 가능
}
replyService.deleteReply(id); // 댓글 삭제
return ResponseEntity.noContent().build();
}
}
HTML / js 수정 및 작성
main.html / main.js 에 추가


myreply.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymleaf.org">
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/sweetalert2@11.4.10/dist/sweetalert2.min.css">
<link rel="stylesheet" th:href="@{/css/reply.css}">
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11.4.10/dist/sweetalert2.min.js"></script>
<script th:src="@{/js/reply.js}"></script>
<body>
<h2>내 댓글 목록</h2>
<table th:if="${not #lists.isEmpty(myReplies)}" class="myreplies-table">
<thead>
<tr>
<th>No</th>
<th>내용</th>
<th>작성 날짜</th>
<th>관리</th>
</tr>
</thead>
<tbody>
<tr th:each="reply, iterStat : ${myReplies}">
<td>
<input type="hidden" class="reply_id" th:value="${reply.id}" />
<span th:text="${iterStat.count}"></span>
</td>
<td>
<a th:href="@{/post/{id}(id=${reply.post.id})}"
th:tetx="${reply.content}" class="reply-link"></a>
</td>
<td th:text="${#dates.format(reply.createDate, 'yyyy-MM-dd hh:mm})"></td>
<td>
<button class="reply_update_btn">수정</button>
<button class="reply_delete_btn">삭제</button>
</td>
</tr>
</tbody>
</table>
<!-- 댓글이 없을 경우: 안내 문구 출력 -->
<p th:if="${#lists.isEmpty(myReplies)}">아직 작성한 댓글이 없습니다.</p>
<button id="go_main_btn">메인 페이지로 이동</button>
</body>
</html>

postdetail.html 수정
<!DOCTYPE html>
<html xmlns:th="http://www.thymleaf.org">
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/sweetalert2@11.4.10/dist/sweetalert2.min.css">
<link rel="stylesheet" th:href="@{/css/post.css}">
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11.4.10/dist/sweetalert2.min.js"></script>
<script th:src="@{/js/post.js}"></script>
</head>
<body>
<div class="post_detail_container">
<h2>게시글 상세보기</h2>
<div class="post_detail">
<input type="hidden" class="post_id" th:value="${post.id}">
<div class="post-title" th:text="${post.title}"></div>
<div class="post-content" th:text="${post.content}"></div>
<div class="post-date"
th:text="'작성일: ' + ${#dates.format(post.createDate, 'yyyy-MM-dd HH:mm')}"></div>
</div>
<!-- 댓글 -->
<div class="reply_container">
<!-- 작성된 댓글 -->
<div class="reply_content" th:if="${not #lists.isEmpty(replies)}">
<div class="reply_item" th:each="reply : ${replies}">
<input type="hidden" th:value="${reply.id}">
<div class="reply_info">
<span class="reply_writer" th:text="${reply.member.name}">작성자</span>
<span> : </span>
<span class="reply_text" th:text="${reply.content}">댓글 내용</span>
</div>
<div class="reply_meta">
<span class="reply_date" th:text="${#dates.format(reply.createDate, 'yyyy-MM-dd HH:mm')}">날짜</span>
<div class="reply_actions">
<button class="reply_update_btn">수정</button>
<button class="reply_delete_btn">삭제</button>
</div>
</div>
</div>
</div>
<p th:if="${#lists.isEmpty(replies)">작성된 댓글이 없습니다..</p>
<!-- 댓글 작성폼 -->
<div class="reply_enter">
<form id="insert_reply_form" method="post">
<textarea name="content" id="content" placeholder="댓글을 입력하세요."></textarea>
</form>
<button class="insert_reply_btn">댓글 등록</button>
</div>
</div>
<div class="post_btn_group">
<button class="post_update_btn">게시글 수정</button>
<button class="post_delete_btn">게시글 삭제</button>
</div>
<button id="post_main_btn">게시글 페이지로 이동</button>
</div>
</body>
</html>
PostController 수정
// 게시글 상세 페이지
@GetMapping("/{id}")
public String postDetail(@PathVariable int id, Model model) {
// 특정 게시글 조회
Optional<Post> post = postService.getPostById(id);
// 댓글 조회
List<Reply> replies = replyService.findReplyByPostId(id);
model.addAttribute("replies", replies);
model.addAttribute("post", post.get());
return "post/postdetail"; // 게시글 상세 페이지
}

post.js 추가
// 댓글 등록 버튼
$(".insert_reply_btn").click(function() {
let postId = $(".post_id").val(); //게시글 id
let content = $("#content").val(); // 댓글 내용
if(!content) {
swal.fire({
title : '입력 오류!',
icon : 'warning',
text : '댓글을 입력하세요.',
confirmButtonText : '확인'
});
return;
}
$.ajax({
url : `/post/${postId}/create`,
type : 'POST',
contentType : "application/json",
data : JSON.stringify({content : content}),
success : function(response) {
swal.fire({
title : '댓글 등록 완료!',
icon : 'success',
text : '댓글을 등록했습니다.',
confirmButtonText : '확인'
}).then(() => {
location.reload(); // 등록 후 새로고침
});
},
error : function(xhr, status, error) {
swal.fire({
title : '등록 실패!',
icon : 'error',
text : '댓글 등록 중 오류가 발생했습니다.',
confirmButtonText : '확인'
});
}
});
});
수정 및 삭제 함수(post.js에 추가)
// 댓글 수정 버튼
$(".reply_update_btn").click(function() {
// 댓글 id와 내용 가져오기 (버튼 기준으로 가장 가까운 reply_item에서)
let replyItem = $(this).closest(".reply_item");
let replyId = replyItem.find("input[type='hidden']").val();
let currentContent = replyItem.find(".reply_text").text();
swal.fire({
title : '댓글 수정',
input : 'textarea',
inputLabel : '수정할 내용을 입력하세요.',
inputValue : currentContent, // 기존 내용 미리 채워넣기
showCancleButton : true,
confiirmButtonText : '수정',
cancelButtonText : '취소',
inputValidator : (value) => {
if(!value) {
return '수정할 내용을 입력하세요!';
}
}
}).then((result) => {
if(result.isConfirmed) {
let updatedContent = result.value;
$.ajax({
url : `/update/${replyId}`,
type : 'PUT',
contentType : 'application/json',
data : JSON.stringify({content : updatedContent}),
success : function() {
swal.fire({
icon : 'success',
title : '댓글 수정 완료!',
text : '댓글이 수정되었습니다.',
}).then(() => {
location.reload();
});
},
error : function() {
swal.fire({
icon : 'error',
title : '오류 발생',
text : '댓글 수정 중 문제가 발생했습니다.',
});
}
});
}
});
});
// 댓글 삭제 버튼
$(".reply_delete_btn").click(function() {
let replyItem = $(this).closest(".reply_item");
let replyId = replyItem.find("input[type='hidden']").val(); // 댓글 ID
swal.fire({
title : '댓글 삭제',
text : '정말 이 댓글을 삭제하시겠습니까?',
icon : 'warning',
showCancelButton : true,
confirmButtonColor : '#d33', // 빨간색
cancelButtonColor : '#3085d6', // 파란색
confirmButtonText : '삭제',
cancelButtonText : '취소'
}).then((result) => {
if (result.isConfirmed) {
$.ajax({
url : `/delete/${replyId}`,
type : 'DELETE',
success: function() {
swal.fire({
icon : 'success',
title : '삭제 완료!',
text : '댓글이 삭제되었습니다.'
}).then(() => {
location.reload();
});
},
error: function() {
swal.fire({
icon : 'error',
title : '삭제 실패',
text : '댓글 삭제 중 문제가 발생했습니다.'
});
}
});
}
});
});
reply.js에 작성
$(document).ready(function() {
// 메인 페이지로 이동
$("#go_main_btn").click(function() {
swal.fire({
title: '이동',
icon: 'info',
text: '메인 페이지로 이동합니다.',
confirmButtonText: '확인'
}).then(() => {
window.location.href = '/main';
});
});
// 댓글 수정 버튼 (내 댓글 목록에서)
$(".reply_update_btn").click(function() {
let replyItem = $(this).closest("tr");
let replyId = replyItem.find(".reply_id").val();
let originalContent = replyItem.find(".reply-link").text();
swal.fire({
title : '댓글 수정',
input : 'textarea',
inputLabel : '수정할 댓글 내용',
inputValue : originalContent,
inputPlaceholder : '댓글을 입력하세요...',
showCancelButton : true,
confirmButtonText : '수정',
cancelButtonText : '취소',
inputAttributes : {
'aria-label' : '댓글 내용'
}
}).then((result) => {
if (result.isConfirmed && result.value.trim() !== "") {
$.ajax({
url : `/update/${replyId}`,
type : 'PUT',
contentType : "application/json",
data : JSON.stringify({ content: result.value }),
success : function() {
swal.fire('수정 완료!', '댓글이 수정되었습니다.', 'success').then(() => {
location.reload(); // 수정 후 새로고침
});
},
error: function() {
swal.fire('수정 실패', '댓글 수정 중 오류가 발생했습니다.', 'error');
}
});
} else if (result.isConfirmed) {
swal.fire('입력 오류', '내용을 입력하세요.', 'warning');
}
});
});
// 댓글 삭제 버튼 (내 댓글 목록에서)
$(".reply_delete_btn").click(function() {
let replyItem = $(this).closest("tr");
let replyId = replyItem.find(".reply_id").val();
Swal.fire({
title : '댓글 삭제',
text : '정말 이 댓글을 삭제하시겠습니까?',
icon : 'warning',
showCancelButton : true,
confirmButtonColor : '#e57373', // 연한 빨간색
cancelButtonColor : '#64b5f6', // 파란색
confirmButtonText : '삭제',
cancelButtonText : '취소'
}).then((result) => {
if (result.isConfirmed) {
$.ajax({
url : `/delete/${replyId}`,
type : 'DELETE',
success : function() {
swal.fire('삭제 완료!', '댓글이 삭제되었습니다.', 'success').then(() => {
location.reload(); // 삭제 후 새로고침
});
},
error : function() {
swal.fire('삭제 실패', '댓글 삭제 중 오류가 발생했습니다.', 'error');
}
});
}
});
});
});
댓글 작성 > 수정 > 삭제 테스트
작성





수정




삭제





이번 댓글 관리 API 구현 프로젝트는 단순히 기능 구현을 넘어서,
JPA의 엔티티 연관관계, REST API의 역할, 프론트와의 통신 방식 등을 직접 경험할 수 있었습니다.
- 배운점
- 연관관계(@ManyToOne)와 fetch 사용.
- 예외 처리와 적절한 HTTP 상태 코드 응답.
- 프론트에서 AJAX와 SweetAlert2로 사용자 경험 개선
다음에는 더 효율적이고, 더 사용자 친화적인 API를 만들수 있게 공부해야겠다..
이전에 실패했던 JWT 기반 인증/ 인가 처리 , 페이징 , WebScoket을 활용한 실시간 채팅 및 댓글
로그인한 사용자 권한 확인 로직 등....
'SpringBoot 프로젝트' 카테고리의 다른 글
[Spring Boot] JPA로 REST API 개발하기 (2) (0) | 2025.04.03 |
---|---|
[Spring Boot] JPA로 REST API 개발하기 (1) (0) | 2025.03.28 |
Spring Boot - 국립 도서관 Open API를 활용한 도서 검색 구현 (0) | 2024.12.10 |
Spring Boot - 국립 도서관 Open API를 활용한 사서 추천 도서 목록 구현 (0) | 2024.12.10 |
Spring Boot - 관리자 페이지 (5) (0) | 2024.12.07 |