diff --git a/src/main/java/ex/demo/Comment.java b/src/main/java/ex/demo/Comment.java new file mode 100644 index 0000000..71af5ff --- /dev/null +++ b/src/main/java/ex/demo/Comment.java @@ -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); + } +} diff --git a/src/main/java/ex/demo/CommentController.java b/src/main/java/ex/demo/CommentController.java new file mode 100644 index 0000000..0a48b2f --- /dev/null +++ b/src/main/java/ex/demo/CommentController.java @@ -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 createComment( + @Valid @RequestBody CommentCreateRequest request) { + CommentDto comment = commentService.createComment(request); + return ResponseEntity.ok(comment); + } + + // 커서 기반 페이지네이션: lastPath 파라미터 사용 + @GetMapping + public ResponseEntity> getComments( + @RequestParam Long postId, + @RequestParam(required = false) String lastPath, + @RequestParam(defaultValue = "10") int size) { + List comments = commentService.getComments(postId, lastPath, size); + return ResponseEntity.ok(comments); + } + + @GetMapping("/count") + public ResponseEntity getCommentCount(@RequestParam Long postId) { + long count = commentService.getCommentCount(postId); + return ResponseEntity.ok(count); + } + + @DeleteMapping("/{commentId}") + public ResponseEntity deleteComment(@PathVariable Long commentId) { + commentService.deleteComment(commentId); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/ex/demo/CommentCreateRequest.java b/src/main/java/ex/demo/CommentCreateRequest.java new file mode 100644 index 0000000..9fbdcbc --- /dev/null +++ b/src/main/java/ex/demo/CommentCreateRequest.java @@ -0,0 +1,11 @@ +package ex.demo; + +import lombok.Getter; + +@Getter +public class CommentCreateRequest { + private Long postId; + private String content; + private Long parentId; // null이면 최상위 댓글 +} + diff --git a/src/main/java/ex/demo/CommentDto.java b/src/main/java/ex/demo/CommentDto.java new file mode 100644 index 0000000..4f90923 --- /dev/null +++ b/src/main/java/ex/demo/CommentDto.java @@ -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; +} + diff --git a/src/main/java/ex/demo/CommentRepository.java b/src/main/java/ex/demo/CommentRepository.java new file mode 100644 index 0000000..9456eac --- /dev/null +++ b/src/main/java/ex/demo/CommentRepository.java @@ -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 { + // 커서 기반 페이지네이션 (path 기준) + List findByPostIdAndPathGreaterThanOrderByPathAsc(Long postId, String lastPath, Pageable pageable); + + // 첫 페이지 조회 (lastPath 없이) + List 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 findByIdAndPostId(Long id, Long postId); + + long countByPostId(Long postId); + + // 서브트리 삭제용 + void deleteByPostIdAndPathStartingWith(Long postId, String pathPrefix); +} diff --git a/src/main/java/ex/demo/CommentService.java b/src/main/java/ex/demo/CommentService.java new file mode 100644 index 0000000..40c65af --- /dev/null +++ b/src/main/java/ex/demo/CommentService.java @@ -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 getComments(Long postId, String lastPath, int size) { + Pageable pageable = PageRequest.of(0, size); + List 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(); + } +} diff --git a/src/main/java/ex/demo/DemoApplication.java b/src/main/java/ex/demo/DemoApplication.java index 6781f99..341f099 100644 --- a/src/main/java/ex/demo/DemoApplication.java +++ b/src/main/java/ex/demo/DemoApplication.java @@ -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); + } } diff --git a/src/main/java/ex/demo/Post.java b/src/main/java/ex/demo/Post.java new file mode 100644 index 0000000..265aaa0 --- /dev/null +++ b/src/main/java/ex/demo/Post.java @@ -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; + } +} diff --git a/src/main/java/ex/demo/PostRepository.java b/src/main/java/ex/demo/PostRepository.java new file mode 100644 index 0000000..0571502 --- /dev/null +++ b/src/main/java/ex/demo/PostRepository.java @@ -0,0 +1,7 @@ +package ex.demo; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PostRepository extends JpaRepository { + // 추가적인 쿼리 메서드가 필요하면 여기에 정의 +} diff --git a/src/main/java/ex/demo/TestDataGenerator.java b/src/main/java/ex/demo/TestDataGenerator.java new file mode 100644 index 0000000..8030778 --- /dev/null +++ b/src/main/java/ex/demo/TestDataGenerator.java @@ -0,0 +1,153 @@ +package ex.demo; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Service; + +import java.sql.PreparedStatement; +import java.sql.Statement; +import java.sql.Timestamp; +import java.util.*; + +@Service +@RequiredArgsConstructor +@Slf4j +public class TestDataGenerator { + + private final JdbcTemplate jdbcTemplate; + + /** + * 100만건 테스트 데이터 생성 (Materialized Path, 최대 depth 50) + */ + public void generateTestData(Long postId, int totalCount) { + int rootCount = Math.max(1, totalCount / 100); // 예: 1만개 루트 + int maxDepth = 50; + int positionPadding = 5; + + // 각 depth별로 부모 후보를 관리 + List currentLevel = new ArrayList<>(); + List nextLevel = new ArrayList<>(); + + Random random = new Random(42); + Timestamp now = new Timestamp(System.currentTimeMillis()); + + // 1. 루트 댓글 생성 (batch insert with returning IDs) + log.info("루트 댓글 {} 개 생성 시작", rootCount); + List rootBatch = new ArrayList<>(); + for (int i = 0; i < rootCount; i++) { + String path = String.format("%0" + positionPadding + "d", i + 1); + rootBatch.add(new Object[]{postId, null, 0, "Root " + (i + 1), path, now}); + } + + // 루트 댓글 insert 후 ID 받아오기 + String insertSql = "INSERT INTO comments (post_id, parent_id, depth, content, path, created_at) VALUES (?, ?, ?, ?, ?, ?)"; + int batchSize = 1000; + + for (int i = 0; i < rootBatch.size(); i += batchSize) { + int end = Math.min(i + batchSize, rootBatch.size()); + List batch = rootBatch.subList(i, end); + + for (Object[] args : batch) { + KeyHolder keyHolder = new GeneratedKeyHolder(); + jdbcTemplate.update(connection -> { + PreparedStatement ps = connection.prepareStatement(insertSql, Statement.RETURN_GENERATED_KEYS); + ps.setLong(1, (Long) args[0]); + ps.setObject(2, args[1]); + ps.setInt(3, (Integer) args[2]); + ps.setString(4, (String) args[3]); + ps.setString(5, (String) args[4]); + ps.setTimestamp(6, (Timestamp) args[5]); + return ps; + }, keyHolder); + + Long id = keyHolder.getKey().longValue(); + currentLevel.add(new CommentNode(id, (String) args[4], 0)); + } + + log.info("루트 댓글 {} ~ {} 저장 완료", i + 1, end); + } + + int created = rootCount; + + // 2. 나머지 댓글 생성 (깊이 50까지) + int currentDepth = 1; + while (created < totalCount && !currentLevel.isEmpty() && currentDepth <= maxDepth) { + log.info("Depth {} 생성 중... (현재 생성: {} / {})", currentDepth, created, totalCount); + + List childBatch = new ArrayList<>(); + Map batchIndexToNode = new HashMap<>(); + int batchIndex = 0; + + for (CommentNode parent : currentLevel) { + if (created >= totalCount) break; + + // 자식 개수 랜덤(1~5개로 증가하여 더 빠르게 100만개 도달) + int childCount = 1 + random.nextInt(5); + for (int c = 0; c < childCount && created < totalCount; c++) { + int childPos = c + 1; + String childPath = parent.path + "/" + String.format("%0" + positionPadding + "d", childPos); + + childBatch.add(new Object[]{ + postId, + parent.id, + parent.depth + 1, + "Comment " + (created + 1), + childPath, + now + }); + + batchIndexToNode.put(batchIndex++, new CommentNode(null, childPath, parent.depth + 1)); + created++; + } + } + + // Batch insert with ID retrieval + batchIndex = 0; + for (Object[] args : childBatch) { + KeyHolder keyHolder = new GeneratedKeyHolder(); + jdbcTemplate.update(connection -> { + PreparedStatement ps = connection.prepareStatement(insertSql, Statement.RETURN_GENERATED_KEYS); + ps.setLong(1, (Long) args[0]); + ps.setLong(2, (Long) args[1]); + ps.setInt(3, (Integer) args[2]); + ps.setString(4, (String) args[3]); + ps.setString(5, (String) args[4]); + ps.setTimestamp(6, (Timestamp) args[5]); + return ps; + }, keyHolder); + + Long id = keyHolder.getKey().longValue(); + CommentNode node = batchIndexToNode.get(batchIndex); + nextLevel.add(new CommentNode(id, node.path, node.depth)); + batchIndex++; + + if (batchIndex % 10000 == 0) { + log.info("댓글 {} 개 저장 완료", created); + } + } + + // 다음 레벨로 이동 + currentLevel = new ArrayList<>(nextLevel); + nextLevel.clear(); + currentDepth++; + } + + log.info("총 {} 개 댓글 생성 완료!", created); + } + + // 내부 노드 구조체 + private static class CommentNode { + final Long id; + final String path; + final int depth; + + CommentNode(Long id, String path, int depth) { + this.id = id; + this.path = path; + this.depth = depth; + } + } +} diff --git a/src/test/java/ex/demo/CommentPerformanceTest.java b/src/test/java/ex/demo/CommentPerformanceTest.java new file mode 100644 index 0000000..ac188fe --- /dev/null +++ b/src/test/java/ex/demo/CommentPerformanceTest.java @@ -0,0 +1,173 @@ +package ex.demo; + +import static org.assertj.core.api.Assertions.assertThat; + + +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +@Slf4j +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class CommentPerformanceTest { + + @Autowired + private PostRepository postRepository; + @Autowired + private CommentRepository commentRepository; + @Autowired + private CommentService commentService; + @Autowired + private TestDataGenerator testDataGenerator; + + private static Long testPostId; + private static final int TOTAL_COMMENTS = 1_000_000; + private static final int PAGE_SIZE = 10; + private static final long MAX_RESPONSE_TIME_MS = 300; + + @Test + @Order(1) + @DisplayName("1단계: 게시글 생성") + void createPost() { + Post post = new Post("테스트 게시글", "100만 댓글 성능 테스트용 게시글"); + Post savedPost = postRepository.save(post); + testPostId = savedPost.getId(); + + log.info("게시글 생성 완료: ID = {}", testPostId); + assertThat(testPostId).isNotNull(); + } + + @Test + @Order(2) + @DisplayName("2단계: 100만건 댓글 데이터 생성") + void generateTestData() { + assertThat(testPostId).isNotNull(); + + long startTime = System.currentTimeMillis(); + testDataGenerator.generateTestData(testPostId, TOTAL_COMMENTS); + long endTime = System.currentTimeMillis(); + + long count = commentRepository.countByPostId(testPostId); + log.info("생성된 댓글 개수: {} (소요 시간: {}ms)", count, endTime - startTime); + + assertThat(count).isEqualTo(TOTAL_COMMENTS); + } + + @Test + @Order(3) + @DisplayName("3단계: 첫 페이지 조회 성능 테스트 (목표: 300ms 이내)") + void testFirstPagePerformance() { + assertThat(testPostId).isNotNull(); + + // 워밍업 + commentService.getComments(testPostId, null, PAGE_SIZE); + + long startTime = System.currentTimeMillis(); + List firstPage = commentService.getComments(testPostId, null, PAGE_SIZE); + long endTime = System.currentTimeMillis(); + long responseTime = endTime - startTime; + + log.info("=== 첫 페이지 조회 성능 ==="); + log.info("응답 시간: {}ms (목표: {}ms 이내)", responseTime, MAX_RESPONSE_TIME_MS); + log.info("조회된 댓글 수: {}", firstPage.size()); + + // 계층 구조 출력 + log.info("\n=== 첫 페이지 댓글 구조 (DFS 순서) ==="); + firstPage.forEach(comment -> { + String indent = " ".repeat(comment.getDepth()); + log.info("{}댓글 {} (depth={}, path={}): {}", + indent, comment.getId(), comment.getDepth(), + comment.getPath(), comment.getContent()); + }); + + assertThat(responseTime).isLessThanOrEqualTo(MAX_RESPONSE_TIME_MS); + assertThat(firstPage).hasSize(PAGE_SIZE); + } + + @Test + @Order(4) + @DisplayName("4단계: 중간 페이지 조회 성능 테스트") + void testMiddlePagePerformance() { + assertThat(testPostId).isNotNull(); + + // 중간 커서 구하기 (예: 50만번째 댓글의 path) + String lastPath = getCursorAtIndex(testPostId, 500_000); + + long startTime = System.currentTimeMillis(); + List page = commentService.getComments(testPostId, lastPath, PAGE_SIZE); + long endTime = System.currentTimeMillis(); + long responseTime = endTime - startTime; + + log.info("=== 중간 페이지 조회 성능 (커서: {}) ===", lastPath); + log.info("응답 시간: {}ms (목표: {}ms 이내)", responseTime, MAX_RESPONSE_TIME_MS); + log.info("조회된 댓글 수: {}", page.size()); + + assertThat(responseTime).isLessThanOrEqualTo(MAX_RESPONSE_TIME_MS); + assertThat(page).hasSize(PAGE_SIZE); + } + + @Test + @Order(5) + @DisplayName("5단계: 마지막 페이지 조회 성능 테스트") + void testLastPagePerformance() { + assertThat(testPostId).isNotNull(); + + // 마지막 커서 구하기 (999,990번째 댓글의 path) + String lastPath = getCursorAtIndex(testPostId, TOTAL_COMMENTS - PAGE_SIZE); + + long startTime = System.currentTimeMillis(); + List page = commentService.getComments(testPostId, lastPath, PAGE_SIZE); + long endTime = System.currentTimeMillis(); + long responseTime = endTime - startTime; + + log.info("=== 마지막 페이지 조회 성능 (커서: {}) ===", lastPath); + log.info("응답 시간: {}ms (목표: {}ms 이내)", responseTime, MAX_RESPONSE_TIME_MS); + log.info("조회된 댓글 수: {}", page.size()); + + // 계층 구조 출력 + log.info("\n=== 첫 페이지 댓글 구조 (DFS 순서) ==="); + page.forEach(comment -> { + String indent = " ".repeat(comment.getDepth()); + log.info("{}댓글 {} (depth={}, path={}): {}", + indent, comment.getId(), comment.getDepth(), + comment.getPath(), comment.getContent()); + }); + + assertThat(responseTime).isLessThanOrEqualTo(MAX_RESPONSE_TIME_MS); + assertThat(page).isNotEmpty(); + } + + @Test + @Order(6) + @DisplayName("7단계: DFS 순서 검증 (path 오름차순)") + void testDfsOrder() { + assertThat(testPostId).isNotNull(); + + List page = commentService.getComments(testPostId, null, 100); + + String prevPath = null; + for (CommentDto comment : page) { + if (prevPath != null) { + assertThat(comment.getPath()).isGreaterThan(prevPath); + } + prevPath = comment.getPath(); + } + + log.info("DFS 순서 검증 완료: path 값이 올바르게 오름차순으로 정렬됨"); + } + + // 커서 위치의 path를 구하는 유틸 (테스트용) + private String getCursorAtIndex(Long postId, int index) { + // 실제 환경에서는 인덱스 기반 커서 구하는 쿼리를 최적화 필요 + List comments = commentRepository.findByPostIdOrderByPathAsc(postId, org.springframework.data.domain.PageRequest.of(index / PAGE_SIZE, PAGE_SIZE)); + if (comments.isEmpty()) return null; + return comments.get(0).getPath(); + } +}