1️⃣ 댓글 달기 및 댓글 삭제
Comment.java (댓글 엔티티)
package com.example.springcrudback.comment;
import com.example.springcrudback.post.Post;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.Instant;
@Getter
@Entity
@EntityListeners(AuditingEntityListener.class)
public class Comment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Setter
@Column(nullable = false)
private String content;
@Column(nullable = false)
private String writer;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "post_id")
private Post post;
@CreatedDate
private Instant createdAt;
@LastModifiedDate
private Instant updatedAt;
protected Comment() {
}
public Comment(String content, String writer, Post post) {
this.content = content;
this.writer = writer;
this.post = post;
}
}
CommentRequest (댓글 요청 DTO)
package com.example.springcrudback.comment;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
@Getter @Setter
public class CommentRequest {
@NotBlank(message = "댓글 내용은 비어 있을 수 없습니다.")
@Size(max = 300, message = "댓글은 300자 이하여야 합니다.")
private String content;
public CommentRequest() {
}
}
CommentResponse.java (댓글 응답 DTO)
package com.example.springcrudback.comment;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
public record CommentResponse(Long id, Long postId, String content, String writer, String createdAt,
String updatedAt) {
private static final ZoneId KST = ZoneId.of("Asia/Seoul");
private static final DateTimeFormatter FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
.withZone(KST);
public static CommentResponse from(Comment comment) {
return new CommentResponse(
comment.getId(),
comment.getPost().getId(),
comment.getContent(),
comment.getWriter(),
format(comment.getCreatedAt()),
format(comment.getUpdatedAt())
);
}
private static String format(Instant instant) {
return instant == null ? null : FORMATTER.format(instant);
}
}
CommentRepository.java (댓글 레포지토리)
package com.example.springcrudback.comment;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface CommentRepository extends JpaRepository<Comment, Long> {
List<Comment> findByPostIdOrderByIdAsc(Long postId);
void deleteByPostId(Long postId);
}
CommentNotFoundException.java (댓글 없음 예외)
package com.example.springcrudback.comment;
public class CommentNotFoundException extends RuntimeException {
public CommentNotFoundException(Long commentId) {
super("해당 댓글이 없습니다. id=" + commentId);
}
}
CommentService.java (댓글 서비스)
package com.example.springcrudback.comment;
import com.example.springcrudback.post.Post;
import com.example.springcrudback.post.PostNotFoundException;
import com.example.springcrudback.post.PostRepository;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class CommentService {
private final CommentRepository commentRepository;
private final PostRepository postRepository;
public CommentService(CommentRepository commentRepository, PostRepository postRepository) {
this.commentRepository = commentRepository;
this.postRepository = postRepository;
}
public CommentResponse create(Long postId, CommentRequest request, String username) {
Post post = postRepository.findById(postId)
.orElseThrow(() -> new PostNotFoundException(postId));
Comment comment = new Comment(
request.getContent(),
username,
post
);
Comment savedComment = commentRepository.save(comment);
return CommentResponse.from(savedComment);
}
public List<CommentResponse> findByPostId(Long postId) {
if (!postRepository.existsById(postId)) {
throw new PostNotFoundException(postId);
}
return commentRepository.findByPostIdOrderByIdAsc(postId)
.stream()
.map(CommentResponse::from)
.toList();
}
public void delete(Long postId, Long commentId) {
if (!postRepository.existsById(postId)) {
throw new PostNotFoundException(postId);
}
Comment comment = commentRepository.findById(commentId)
.orElseThrow(() -> new CommentNotFoundException(commentId));
if (!comment.getPost().getId().equals(postId)) {
throw new IllegalArgumentException("해당 게시글에 속한 댓글이 아닙니다.");
}
commentRepository.delete(comment);
}
public void deleteAllByPostId(Long postId) {
commentRepository.deleteByPostId(postId);
}
}
CommentController.java (댓글 컨트롤러)
package com.example.springcrudback.comment;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/posts/{postId}/comments")
public class CommentController {
private final CommentService commentService;
public CommentController(CommentService commentService) {
this.commentService = commentService;
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public CommentResponse create(
@PathVariable Long postId,
@Valid @RequestBody CommentRequest request,
@AuthenticationPrincipal UserDetails userDetails
) {
return commentService.create(postId, request, userDetails.getUsername());
}
@GetMapping
public List<CommentResponse> findByPostId(@PathVariable Long postId) {
return commentService.findByPostId(postId);
}
@DeleteMapping("/{commentId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(
@PathVariable Long postId,
@PathVariable Long commentId
) {
commentService.delete(postId, commentId);
}
}
PostService.java
package com.example.springcrudback.post;
import com.example.springcrudback.comment.CommentService; // 추가
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class PostService {
private final PostRepository postRepository;
private final CommentService commentService; // 추가
public PostService(PostRepository postRepository, CommentService commentService) {
this.postRepository = postRepository;
this.commentService = commentService; // 추가
}
/* ... */
public void delete(Long id) {
Post post = postRepository.findById(id)
.orElseThrow(() -> new PostNotFoundException(id));
commentService.deleteAllByPostId(id); // 추가
postRepository.delete(post);
}
}
- 게시글 삭제 시 댓글도 같이 삭제하도록 수정했다.
GlobalExceptionHandler.java
/* ... */
@ExceptionHandler(CommentNotFoundException.class)
public ResponseEntity<ErrorResponse> handleCommentNotFound(CommentNotFoundException e) {
ErrorResponse response = new ErrorResponse(
HttpStatus.NOT_FOUND.value(),
e.getMessage(),
LocalDateTime.now()
);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
}
- 전역 예외 핸들러에 새로 만들었던 댓글 없음 예외를 추가했다.
테스트
- 댓글 작성

- 댓글 목록 조회

- 댓글 삭제

2️⃣ 댓글 수정 및 댓글 삭제 기능
위에서 구현한 댓글 삭제 기능은 누구나 할 수 있지만, 일반적으로 본인이 작성한 댓글만 수정/삭제가 가능해야 한다.
CommentAccessDeniedException.java
package com.example.springcrudback.comment;
public class CommentAccessDeniedException extends RuntimeException {
public CommentAccessDeniedException() {
super("해당 댓글에 대한 권한이 없습니다.");
}
}
CommentService.java
package com.example.springcrudback.comment;
import com.example.springcrudback.post.Post;
import com.example.springcrudback.post.PostNotFoundException;
import com.example.springcrudback.post.PostRepository;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class CommentService {
/* ... */
public CommentResponse update(Long postId, Long commentId, CommentRequest request, String username) {
if (!postRepository.existsById(postId)) {
throw new PostNotFoundException(postId);
}
Comment comment = commentRepository.findById(commentId)
.orElseThrow(() -> new CommentNotFoundException(commentId));
validateCommentBelongsToPost(comment, postId);
validateWriter(comment, username);
comment.setContent(request.getContent());
Comment updatedComment = commentRepository.save(comment);
return CommentResponse.from(updatedComment);
}
public void delete(Long postId, Long commentId, String username) {
if (!postRepository.existsById(postId)) {
throw new PostNotFoundException(postId);
}
Comment comment = commentRepository.findById(commentId)
.orElseThrow(() -> new CommentNotFoundException(commentId));
validateCommentBelongsToPost(comment, postId);
validateWriter(comment, username);
commentRepository.delete(comment);
}
public void deleteAllByPostId(Long postId) {
commentRepository.deleteByPostId(postId);
}
private void validateCommentBelongsToPost(Comment comment, Long postId) {
if (!comment.getPost().getId().equals(postId)) {
throw new IllegalArgumentException("해당 게시글에 속한 댓글이 아닙니다.");
}
}
private void validateWriter(Comment comment, String username) {
if (!comment.getWriter().equals(username)) {
throw new CommentAccessDeniedException();
}
}
}
CommentController.java
package com.example.springcrudback.comment;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/posts/{postId}/comments")
public class CommentController {
/* ... */
@PatchMapping("/{commentId}")
public CommentResponse update(
@PathVariable Long postId,
@PathVariable Long commentId,
@Valid @RequestBody CommentRequest request,
@AuthenticationPrincipal UserDetails userDetails
) {
return commentService.update(postId, commentId, request, userDetails.getUsername());
}
@DeleteMapping("/{commentId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(
@PathVariable Long postId,
@PathVariable Long commentId,
@AuthenticationPrincipal UserDetails userDetails
) {
commentService.delete(postId, commentId, userDetails.getUsername());
}
}
GlobalExceptionHandler.java
@ExceptionHandler(CommentAccessDeniedException.class)
public ResponseEntity<ErrorResponse> handleCommentAccessDenied(CommentAccessDeniedException e) {
ErrorResponse response = new ErrorResponse(
HttpStatus.FORBIDDEN.value(),
e.getMessage(),
LocalDateTime.now()
);
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(response);
}
- 다른 사람의 댓글을 수정/삭제하려고 시도하면 403 에러가 발생한다.
테스트
- 댓글 수정

- 남의 댓글 수정 시도

3️⃣ 본인이 작성한 게시글만 수정/삭제 가능
게시글도 마찬가지로 본인이 작성했던 게시글만 수정하거나 삭제할 수 있어야 한다.
PostAccessDeniedException.java
package com.example.springcrudback.post;
public class PostAccessDeniedException extends RuntimeException {
public PostAccessDeniedException() {
super("해당 게시글에 대한 권한이 없습니다.");
}
}
Post 엔티티에 작성자 추가
package com.example.springcrudback.post;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Column;
import lombok.Getter;
import lombok.Setter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.Instant;
@Getter
@Entity
@EntityListeners(AuditingEntityListener.class)
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Setter
@Column(nullable = false)
private String title;
@Setter
@Column(nullable = false, length = 1000)
private String content;
@Column(nullable = false)
private String writer;
@CreatedDate
private Instant createdAt;
@LastModifiedDate
private Instant updatedAt;
protected Post() {
}
public Post(String title, String content, String writer) {
this.title = title;
this.content = content;
this.writer = writer;
}
}
응답 DTO에도 작성자 추가
package com.example.springcrudback.post;
import com.example.springcrudback.common.DateTimeUtils;
public record PostResponse(Long id, String title, String content, String writer, String createdAt, String updatedAt) {
public static PostResponse from(Post post) {
return new PostResponse(
post.getId(),
post.getTitle(),
post.getContent(),
post.getWriter(),
DateTimeUtils.format(post.getCreatedAt()),
DateTimeUtils.format(post.getUpdatedAt())
);
}
}
※ 참고로 DateTimeUtils는 저번에 작성했던 UTC → KST 문자열 반환 함수이다.
package com.example.springcrudback.common;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
public class DateTimeUtils {
private static final ZoneId KST = ZoneId.of("Asia/Seoul");
private static final DateTimeFormatter FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
.withZone(KST);
public static String format(Instant instant) {
return instant == null ? null : FORMATTER.format(instant);
}
}
PostService.java
package com.example.springcrudback.post;
import com.example.springcrudback.comment.CommentRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
public class PostService {
private final PostRepository postRepository;
private final CommentRepository commentRepository;
public PostService(PostRepository postRepository, CommentRepository commentRepository) {
this.postRepository = postRepository;
this.commentRepository = commentRepository;
}
public PostResponse create(PostRequest request, String username) {
Post post = new Post(
request.getTitle(),
request.getContent(),
username
);
Post savedPost = postRepository.save(post);
return PostResponse.from(savedPost);
}
public List<PostResponse> findAll() {
return postRepository.findAll()
.stream()
.map(PostResponse::from)
.toList();
}
public PostResponse findById(Long id) {
Post post = postRepository.findById(id)
.orElseThrow(() -> new PostNotFoundException(id));
return PostResponse.from(post);
}
public PostResponse update(Long id, PostRequest request, String username) {
Post post = postRepository.findById(id)
.orElseThrow(() -> new PostNotFoundException(id));
validateWriter(post, username);
post.setTitle(request.getTitle());
post.setContent(request.getContent());
Post updatedPost = postRepository.save(post);
return PostResponse.from(updatedPost);
}
@Transactional
public void delete(Long id, String username) {
Post post = postRepository.findById(id)
.orElseThrow(() -> new PostNotFoundException(id));
validateWriter(post, username);
commentRepository.deleteByPostId(id);
postRepository.delete(post);
}
private void validateWriter(Post post, String username) {
if (!post.getWriter().equals(username)) {
throw new PostAccessDeniedException();
}
}
}
- 게시글 작성 시 writer 정보를 저장한다.
- 게시글 수정/삭제 시 writer를 검사한다.
PostController.java
package com.example.springcrudback.post;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/posts")
public class PostController {
private final PostService postService;
public PostController(PostService postService) {
this.postService = postService;
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public PostResponse create(
@Valid @RequestBody PostRequest request,
@AuthenticationPrincipal UserDetails userDetails
) {
return postService.create(request, userDetails.getUsername());
}
@GetMapping
public List<PostResponse> findAll() {
return postService.findAll();
}
@GetMapping("/{id}")
public PostResponse findById(@PathVariable Long id) {
return postService.findById(id);
}
@PatchMapping("/{id}")
public PostResponse update(
@PathVariable Long id,
@Valid @RequestBody PostRequest request,
@AuthenticationPrincipal UserDetails userDetails
) {
return postService.update(id, request, userDetails.getUsername());
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(
@PathVariable Long id,
@AuthenticationPrincipal UserDetails userDetails
) {
postService.delete(id, userDetails.getUsername());
}
}
GlobalExceptionHandler.java
@ExceptionHandler(PostAccessDeniedException.class)
public ResponseEntity<ErrorResponse> handlePostAccessDenied(PostAccessDeniedException e) {
ErrorResponse response = new ErrorResponse(
HttpStatus.FORBIDDEN.value(),
e.getMessage(),
LocalDateTime.now()
);
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(response);
}
- 본인이 작성하지 않은 게시글의 수정/삭제를 시도할 경우 403 Forbidden 에러가 발생한다.

4️⃣ 트러블슈팅
DELETE /posts/1 요청을 보냈더니 게시글이 삭제되지 않고 500 ERROR가 발생하는 현상이 발생했다. 이는 댓글 삭제 → 게시글 삭제를 연달아 하는데, 이 작업이 하나의 트랜잭션으로 묶여 있지 않아서 문제가 발생했다고 생각해서 다음과 같은 작업을 수행했다.
- PostService.delete()에 @Transactional 붙이기
package com.example.springcrudback.post;
import com.example.springcrudback.comment.CommentService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; // 추가
import java.util.List;
@Service
public class PostService {
/* ... */
@Transactional // 추가
public void delete(Long id) {
Post post = postRepository.findById(id)
.orElseThrow(() -> new PostNotFoundException(id));
commentService.deleteAllByPostId(id);
postRepository.delete(post);
}
}
- CommentRepository.deleteByPostId(...)에도 트랜잭션 보강하기
package com.example.springcrudback.comment;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.transaction.annotation.Transactional; // 추가
import java.util.List;
public interface CommentRepository extends JpaRepository<Comment, Long> {
List<Comment> findByPostIdOrderByIdAsc(Long postId);
@Transactional // 추가
void deleteByPostId(Long postId);
}
실행 결과 게시글 삭제가 다시 정상적으로 작동했다.
내일 할 일
- 대댓글 기능 구현
- 게시판 UI 구현
'프레임워크 > Spring' 카테고리의 다른 글
| [Spring Boot] Spring CRUD 구현 - Day 4 : Spring Security + JWT (0) | 2026.03.13 |
|---|---|
| [Spring Boot] Spring CRUD 구현 - Day 3 (0) | 2026.03.12 |
| [Spring Boot] Spring CRUD 구현 - Day 2 (0) | 2026.03.11 |
| [Spring] JPA와 H2 DB (0) | 2026.03.10 |
| [Spring Boot] Spring CRUD 구현 - Day 1 (0) | 2026.03.10 |