SpringBoot 프로젝트

[Spring Boot] JPA로 REST API 개발하기 (3)

orin602 2025. 4. 5. 16:55

이전 글에서는 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을 활용한 실시간 채팅 및 댓글

로그인한 사용자 권한 확인 로직 등....