Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions src/main/java/ex/demo/Comment.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package ex.demo;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Table(name = "comments")
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class Comment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long postId;
private Long parentId;
private int depth;
private String content;
private LocalDateTime createdAt;

// Materialized Path
@Column(nullable = false, length = 255)
private String path;

public Comment(Long postId, Long parentId, int depth, String content, String parentPath, int position) {
this.postId = postId;
this.parentId = parentId;
this.depth = depth;
this.content = content;
this.createdAt = LocalDateTime.now();
this.path = (parentPath == null ? "" : parentPath + "/") + String.format("%05d", position);
}
}
51 changes: 51 additions & 0 deletions src/main/java/ex/demo/CommentController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package ex.demo;

import jakarta.validation.Valid;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/comments")
@RequiredArgsConstructor
public class CommentController {

private final CommentService commentService;

@PostMapping
public ResponseEntity<CommentDto> createComment(
@Valid @RequestBody CommentCreateRequest request) {
CommentDto comment = commentService.createComment(request);
return ResponseEntity.ok(comment);
}

// 커서 기반 페이지네이션: lastPath 파라미터 사용
@GetMapping
public ResponseEntity<List<CommentDto>> getComments(
@RequestParam Long postId,
@RequestParam(required = false) String lastPath,
@RequestParam(defaultValue = "10") int size) {
List<CommentDto> comments = commentService.getComments(postId, lastPath, size);
return ResponseEntity.ok(comments);
}

@GetMapping("/count")
public ResponseEntity<Long> getCommentCount(@RequestParam Long postId) {
long count = commentService.getCommentCount(postId);
return ResponseEntity.ok(count);
}

@DeleteMapping("/{commentId}")
public ResponseEntity<Void> deleteComment(@PathVariable Long commentId) {
commentService.deleteComment(commentId);
return ResponseEntity.noContent().build();
}
}
11 changes: 11 additions & 0 deletions src/main/java/ex/demo/CommentCreateRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package ex.demo;

import lombok.Getter;

@Getter
public class CommentCreateRequest {
private Long postId;
private String content;
private Long parentId; // null이면 최상위 댓글
}

18 changes: 18 additions & 0 deletions src/main/java/ex/demo/CommentDto.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package ex.demo;

import lombok.Builder;
import lombok.Getter;
import java.time.LocalDateTime;

@Getter
@Builder
public class CommentDto {
private Long id;
private Long postId;
private String content;
private Long parentId;
private int depth;
private String path; // Materialized Path
private LocalDateTime createdAt;
}

38 changes: 38 additions & 0 deletions src/main/java/ex/demo/CommentRepository.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package ex.demo;

import jakarta.persistence.LockModeType;
import java.util.List;
import java.util.Optional;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;

public interface CommentRepository extends JpaRepository<Comment, Long> {
// 커서 기반 페이지네이션 (path 기준)
List<Comment> findByPostIdAndPathGreaterThanOrderByPathAsc(Long postId, String lastPath, Pageable pageable);

// 첫 페이지 조회 (lastPath 없이)
List<Comment> findByPostIdOrderByPathAsc(Long postId, Pageable pageable);

// 같은 부모 아래 형제들의 최대 position 조회 (동시성 처리)
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""
SELECT COALESCE(MAX(CAST(SUBSTRING(c.path, LENGTH(:prefix) + 2, 5) AS int)), 0)
FROM Comment c
WHERE c.postId = :postId
AND (:prefix = '' AND c.parentId IS NULL
OR c.path LIKE CONCAT(:prefix, '/%'))
""")
int findMaxPositionByPostIdAndPrefixForUpdate(
Long postId,
String prefix
);

Optional<Comment> findByIdAndPostId(Long id, Long postId);

long countByPostId(Long postId);

// 서브트리 삭제용
void deleteByPostIdAndPathStartingWith(Long postId, String pathPrefix);
}
90 changes: 90 additions & 0 deletions src/main/java/ex/demo/CommentService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package ex.demo;

import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class CommentService {

private final CommentRepository commentRepository;
private static final int MAX_DEPTH = 50;

@Transactional
public CommentDto createComment(CommentCreateRequest request) {
if (request.getParentId() == null) {
return addRootComment(request.getPostId(), request.getContent());
} else {
return addReply(request.getPostId(), request.getParentId(), request.getContent());
}
}

@Transactional
public CommentDto addRootComment(Long postId, String content) {
String prefix = "";
int maxPos = commentRepository.findMaxPositionByPostIdAndPrefixForUpdate(postId, prefix);
int newPos = maxPos + 1;
// 루트 댓글: parentId=null, parentPath=null
Comment comment = new Comment(postId, null, 0, content, null, newPos);
comment = commentRepository.save(comment);
return toDto(comment);
}

@Transactional
public CommentDto addReply(Long postId, Long parentId, String content) {
Comment parent = commentRepository.findByIdAndPostId(parentId, postId)
.orElseThrow(() -> new IllegalArgumentException("부모 댓글 없음"));
if (parent.getDepth() >= MAX_DEPTH) {
throw new IllegalArgumentException("최대 깊이 초과");
}
String prefix = parent.getPath();
int maxPos = commentRepository.findMaxPositionByPostIdAndPrefixForUpdate(postId, prefix);
int newPos = maxPos + 1;
Comment comment = new Comment(postId, parentId, parent.getDepth() + 1, content, prefix, newPos);
comment = commentRepository.save(comment);
return toDto(comment);
}

// 커서 기반 페이지네이션
public List<CommentDto> getComments(Long postId, String lastPath, int size) {
Pageable pageable = PageRequest.of(0, size);
List<Comment> comments;
if (lastPath == null || lastPath.isEmpty()) {
comments = commentRepository.findByPostIdOrderByPathAsc(postId, pageable);
} else {
comments = commentRepository.findByPostIdAndPathGreaterThanOrderByPathAsc(postId, lastPath, pageable);
}
return comments.stream().map(this::toDto).collect(Collectors.toList());
}

public long getCommentCount(Long postId) {
return commentRepository.countByPostId(postId);
}

@Transactional
public void deleteComment(Long commentId) {
Comment target = commentRepository.findById(commentId)
.orElseThrow(() -> new IllegalArgumentException("댓글 없음"));
String prefix = target.getPath() + "/";
commentRepository.deleteByPostIdAndPathStartingWith(target.getPostId(), prefix);
commentRepository.delete(target);
}

private CommentDto toDto(Comment comment) {
return CommentDto.builder()
.id(comment.getId())
.postId(comment.getPostId())
.content(comment.getContent())
.parentId(comment.getParentId())
.depth(comment.getDepth())
.createdAt(comment.getCreatedAt())
.path(comment.getPath())
.build();
}
}
6 changes: 3 additions & 3 deletions src/main/java/ex/demo/DemoApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
@SpringBootApplication
public class DemoApplication {

public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}

}
29 changes: 29 additions & 0 deletions src/main/java/ex/demo/Post.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package ex.demo;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Entity
@Getter
@AllArgsConstructor
@Setter
@NoArgsConstructor
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

private String title;
private String content;

public Post(String title, String content) {
this.title = title;
this.content = content;
}
}
7 changes: 7 additions & 0 deletions src/main/java/ex/demo/PostRepository.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package ex.demo;

import org.springframework.data.jpa.repository.JpaRepository;

public interface PostRepository extends JpaRepository<Post, Long> {
// 추가적인 쿼리 메서드가 필요하면 여기에 정의
}
Loading