프레임워크/Spring

[Spring Boot] Spring CRUD 구현 - Day 5 : 댓글 기능 구현

munsik22 2026. 3. 16. 11:34

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);
}
  • 전역 예외 핸들러에 새로 만들었던 댓글 없음 예외를 추가했다.

테스트

  • 댓글 작성

  • 댓글 목록 조회

  • 댓글 삭제

성공한 경우 204 No Content가 뜬다.


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 구현