diff --git a/backend/build.gradle b/backend/build.gradle index b48eea1..03ebe99 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -61,8 +61,14 @@ dependencies { } tasks.named('test') { - useJUnitPlatform() + useJUnitPlatform { + if (!project.hasProperty('includeBenchmark')) { + excludeTags 'benchmark' + } + } systemProperty 'spring.profiles.active', 'test' + // 대규모 MySQL Seed 테스트는 일반 단위·통합 테스트와 별도 실행한다. + // 필요할 때만 ./gradlew test -PincludeBenchmark 로 명시적으로 포함한다. } spotless { diff --git a/backend/src/test/java/com/ikae/snowthing/domain/comment/spike/CommentBenchmarkSeedHarness.java b/backend/src/test/java/com/ikae/snowthing/domain/comment/spike/CommentBenchmarkSeedHarness.java new file mode 100644 index 0000000..b40fca4 --- /dev/null +++ b/backend/src/test/java/com/ikae/snowthing/domain/comment/spike/CommentBenchmarkSeedHarness.java @@ -0,0 +1,191 @@ +package com.ikae.snowthing.domain.comment.spike; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import javax.sql.DataSource; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import com.ikae.snowthing.global.error.ErrorCode; +import com.ikae.snowthing.global.exception.CustomAuthException; + +import lombok.RequiredArgsConstructor; + +/** MySQL benchmark schema에만 대량 댓글을 JDBC batch로 주입하는 전용 하네스. */ +@Component +@RequiredArgsConstructor +public class CommentBenchmarkSeedHarness { + + private static final String BENCHMARK_PREFIX = "benchmark-sprint04-"; + private final DataSource dataSource; + private final JdbcTemplate jdbcTemplate; + + public record SeedResult(long memberId, List postIds, long commentCount) { + public SeedResult { + postIds = List.copyOf(postIds); + } + } + + public SeedResult seed(int totalComments, long seed) { + if (totalComments < 1_000) { + throw new CustomAuthException(ErrorCode.INVALID_INPUT); + } + assertSafeDatabase(); + cleanup(); + Random random = new Random(seed); + + long memberId = seedMember(); + jdbcTemplate.update( + "INSERT INTO post_category (name, code) VALUES ('자유게시판', 'FREE') ON DUPLICATE KEY UPDATE category_id = category_id"); + long categoryId = + jdbcTemplate.queryForObject( + "SELECT category_id FROM post_category WHERE code = 'FREE' LIMIT 1", + Long.class); + List postIds = seedPosts(memberId, categoryId, totalComments, random); + List roots = new ArrayList<>(); + int rootCount = Math.max(100, totalComments / 5); + executeBatchInChunks( + "INSERT INTO comment (post_id, member_id, parent_id, content, writer_ip, is_anonymous, is_deleted, version, created_at, updated_at) VALUES (?, ?, NULL, ?, ?, ?, ?, 0, ?, NOW())", + buildRootArgs(postIds, memberId, rootCount, random)); + roots.addAll( + jdbcTemplate.query( + """ + SELECT c.comment_id + FROM comment c + JOIN post p ON p.post_id = c.post_id + WHERE p.public_id LIKE ? + AND c.parent_id IS NULL + AND c.content LIKE '%benchmark-sprint04-root-%' + ORDER BY c.comment_id + """, + (rs, rowNum) -> rs.getLong(1), BENCHMARK_PREFIX + "post-%")); + + int replyCount = totalComments - roots.size(); + if (replyCount > 0 && !roots.isEmpty()) { + executeBatchInChunks( + "INSERT INTO comment (post_id, member_id, parent_id, content, writer_ip, is_anonymous, is_deleted, version, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, NOW())", + buildReplyArgs(postIds, roots, memberId, replyCount, random)); + } + jdbcTemplate.update( + "UPDATE post p SET comment_count = (SELECT COUNT(*) FROM comment c WHERE c.post_id = p.post_id AND c.is_deleted = FALSE) WHERE p.public_id LIKE ?", + BENCHMARK_PREFIX + "%"); + return new SeedResult(memberId, postIds, totalComments); + } + + private void executeBatchInChunks(String sql, List args) { + final int chunkSize = 5_000; + for (int from = 0; from < args.size(); from += chunkSize) { + int to = Math.min(from + chunkSize, args.size()); + jdbcTemplate.batchUpdate(sql, args.subList(from, to)); + } + } + + private void assertSafeDatabase() { + try (var connection = dataSource.getConnection()) { + String database = connection.getCatalog(); + String url = connection.getMetaData().getURL().toLowerCase(); + if (database == null + || !(database.contains("test") || database.contains("benchmark")) + || url.contains("prod")) { + throw new CustomAuthException(ErrorCode.INVALID_INPUT); + } + } catch (Exception e) { + throw new CustomAuthException(ErrorCode.INVALID_INPUT); + } + } + + private void cleanup() { + jdbcTemplate.update( + "DELETE c FROM comment c JOIN comment parent ON parent.comment_id = c.parent_id JOIN post p ON p.post_id = parent.post_id WHERE p.public_id LIKE ?", + BENCHMARK_PREFIX + "%"); + jdbcTemplate.update( + "DELETE c FROM comment c JOIN post p ON p.post_id = c.post_id WHERE p.public_id LIKE ?", + BENCHMARK_PREFIX + "%"); + jdbcTemplate.update("DELETE FROM post WHERE public_id LIKE ?", BENCHMARK_PREFIX + "%"); + jdbcTemplate.update("DELETE FROM member WHERE public_id = ?", BENCHMARK_PREFIX + "member"); + } + + private long seedMember() { + jdbcTemplate.update( + "INSERT INTO member (public_id,email,password,nickname,role,status,created_at,updated_at) VALUES (?,?,?,?,?,?,NOW(),NOW())", + BENCHMARK_PREFIX + "member", + BENCHMARK_PREFIX + "member@snowthing.test", + "benchmark-password-hash", + "benchmark-member", + "ROLE_USER", + "ACTIVE"); + return jdbcTemplate.queryForObject( + "SELECT member_id FROM member WHERE public_id = ?", + Long.class, + BENCHMARK_PREFIX + "member"); + } + + private List seedPosts(long memberId, long categoryId, int total, Random random) { + int postCount = 100; + for (int i = 0; i < postCount; i++) { + jdbcTemplate.update( + "INSERT INTO post (public_id,member_id,category_id,title,content,writer_ip,is_anonymous,view_count,comment_count,like_count,dislike_count,has_image,status,is_deleted,created_at,updated_at) VALUES (?,?,?,?,?,?,FALSE,0,0,0,0,FALSE,'NORMAL',FALSE,NOW(),NOW())", + BENCHMARK_PREFIX + "post-" + i, + memberId, + categoryId, + RealisticContentGenerator.postTitle(random, i), + RealisticContentGenerator.postBody(random, i), + "127.0.0.1"); + } + return jdbcTemplate.query( + "SELECT post_id FROM post WHERE public_id LIKE ? ORDER BY post_id", + (rs, n) -> rs.getLong(1), + BENCHMARK_PREFIX + "post-%"); + } + + private List buildRootArgs(List posts, long member, int count, Random random) { + List args = new ArrayList<>(); + for (int i = 0; i < count; i++) { + args.add( + new Object[] { + posts.get(distributedPostIndex(i, count, posts.size())), + member, + RealisticContentGenerator.rootComment(random, i), + "127.0.0.1", + i % 10 == 0, + i % 5 == 0, + java.sql.Timestamp.valueOf("2026-01-01 00:00:00") + }); + } + return args; + } + + private List buildReplyArgs( + List posts, List roots, long member, int count, Random random) { + List args = new ArrayList<>(); + int hotspotReplyCount = Math.min(100, count); + for (int i = 0; i < count; i++) { + int rootIndex = i < hotspotReplyCount ? 0 : i % roots.size(); + long root = roots.get(rootIndex); + args.add( + new Object[] { + posts.get(distributedPostIndex(rootIndex, roots.size(), posts.size())), + member, + root, + RealisticContentGenerator.reply(random, i), + "127.0.0.1", + i % 10 == 0, + i >= hotspotReplyCount && i % 5 == 0, + java.sql.Timestamp.valueOf("2026-01-01 00:00:00") + }); + } + return args; + } + + /** 45% general posts, 45% medium posts, 10% hot post. */ + private int distributedPostIndex(int ordinal, int total, int postCount) { + if (postCount < 100) return ordinal % postCount; + int general = Math.max(1, (int) Math.floor(total * 0.45)); + int medium = Math.max(general + 1, (int) Math.floor(total * 0.90)); + if (ordinal < general) return ordinal % 80; + if (ordinal < medium) return 80 + (ordinal % 19); + return 99; + } +} diff --git a/backend/src/test/java/com/ikae/snowthing/domain/comment/spike/CommentBenchmarkSeedRunnerTest.java b/backend/src/test/java/com/ikae/snowthing/domain/comment/spike/CommentBenchmarkSeedRunnerTest.java new file mode 100644 index 0000000..ba8ebe0 --- /dev/null +++ b/backend/src/test/java/com/ikae/snowthing/domain/comment/spike/CommentBenchmarkSeedRunnerTest.java @@ -0,0 +1,192 @@ +package com.ikae.snowthing.domain.comment.spike; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.sql.Timestamp; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +/** 명시적으로 호출할 때만 benchmark 스키마에 데이터를 생성하는 실행 진입점. */ +@Tag("benchmark") +@SpringBootTest(properties = "spring.jpa.hibernate.ddl-auto=update") +@ActiveProfiles({"test", "benchmark"}) +class CommentBenchmarkSeedRunnerTest { + + private static final String BENCHMARK_POST_PATTERN = "benchmark-sprint04-post-%"; + private static final int PAGE_SIZE = 20; + + @Autowired private CommentBenchmarkSeedHarness harness; + @Autowired private JdbcTemplate jdbcTemplate; + + @Test + void seedRequestedScale() { + int total = Integer.parseInt(System.getenv().getOrDefault("BENCHMARK_COMMENTS", "1000")); + long seed = Long.parseLong(System.getenv().getOrDefault("BENCHMARK_SEED", "20260907")); + CommentBenchmarkSeedHarness.SeedResult result = harness.seed(total, seed); + + assertTotalDistribution(total); + assertPostDistribution(total); + assertActiveCommentCountForEveryPost(); + assertReplyLimitAndUniqueIds(); + assertNoRootPageOmissions(result.postIds()); + assertNoReplyPageOmissions(); + } + + private void assertTotalDistribution(int total) { + Long actual = + queryCount( + "SELECT COUNT(*) FROM comment c JOIN post p ON p.post_id=c.post_id WHERE p.public_id LIKE ?", + BENCHMARK_POST_PATTERN); + Long roots = + queryCount( + "SELECT COUNT(*) FROM comment c JOIN post p ON p.post_id=c.post_id WHERE p.public_id LIKE ? AND c.parent_id IS NULL", + BENCHMARK_POST_PATTERN); + Long replies = + queryCount( + "SELECT COUNT(*) FROM comment c JOIN post p ON p.post_id=c.post_id WHERE p.public_id LIKE ? AND c.parent_id IS NOT NULL", + BENCHMARK_POST_PATTERN); + assertThat(actual).isEqualTo((long) total); + assertThat(roots + replies).isEqualTo((long) total); + } + + private void assertPostDistribution(int total) { + assertThat( + queryCount( + "SELECT COUNT(*) FROM post WHERE public_id LIKE ?", + BENCHMARK_POST_PATTERN)) + .isEqualTo(100L); + Long hotPostComments = + queryCount( + "SELECT COUNT(*) FROM comment c JOIN post p ON p.post_id=c.post_id WHERE p.public_id='benchmark-sprint04-post-99'"); + assertThat(hotPostComments).isGreaterThanOrEqualTo(Math.max(1L, Math.round(total * 0.09))); + } + + private void assertActiveCommentCountForEveryPost() { + List counts = + jdbcTemplate.query( + "SELECT p.post_id,p.comment_count,COUNT(CASE WHEN c.is_deleted=FALSE THEN 1 END) active_count " + + "FROM post p LEFT JOIN comment c ON c.post_id=p.post_id WHERE p.public_id LIKE ? " + + "GROUP BY p.post_id,p.comment_count ORDER BY p.post_id", + (rs, rowNum) -> + new PostCountInvariant( + rs.getLong("post_id"), + rs.getLong("comment_count"), + rs.getLong("active_count")), + BENCHMARK_POST_PATTERN); + assertThat(counts).hasSize(100); + assertThat(counts) + .allSatisfy( + count -> + assertThat(count.storedCount()) + .as("post_id=%s 활성 댓글 수", count.postId()) + .isEqualTo(count.actualActiveCount())); + } + + private void assertReplyLimitAndUniqueIds() { + Long overReplyLimit = + queryCount( + "SELECT COUNT(*) FROM (SELECT c.parent_id FROM comment c JOIN post p ON p.post_id=c.post_id " + + "WHERE p.public_id LIKE ? AND c.parent_id IS NOT NULL AND c.is_deleted=FALSE " + + "GROUP BY c.parent_id HAVING COUNT(*)>100) over_limit", + BENCHMARK_POST_PATTERN); + Long duplicateIds = + queryCount( + "SELECT COUNT(*)-COUNT(DISTINCT c.comment_id) FROM comment c JOIN post p ON p.post_id=c.post_id WHERE p.public_id LIKE ?", + BENCHMARK_POST_PATTERN); + assertThat(overReplyLimit).isZero(); + assertThat(duplicateIds).isZero(); + } + + private void assertNoRootPageOmissions(List postIds) { + for (Long postId : postIds) { + List expected = + queryRows( + "c.post_id=? AND c.parent_id IS NULL", postId, null, Integer.MAX_VALUE); + List traversed = + traversePages("c.post_id=? AND c.parent_id IS NULL", postId); + assertCompleteStableTraversal("post_id=" + postId + " 루트", expected, traversed); + } + } + + private void assertNoReplyPageOmissions() { + List pageableRoots = + jdbcTemplate.queryForList( + "SELECT c.parent_id FROM comment c JOIN post p ON p.post_id=c.post_id " + + "WHERE p.public_id LIKE ? AND c.parent_id IS NOT NULL " + + "GROUP BY c.parent_id HAVING COUNT(*)>?", + Long.class, + BENCHMARK_POST_PATTERN, + PAGE_SIZE); + for (Long rootId : pageableRoots) { + List expected = queryRows("c.parent_id=?", rootId, null, Integer.MAX_VALUE); + List traversed = traversePages("c.parent_id=?", rootId); + assertCompleteStableTraversal("root_id=" + rootId + " 대댓글", expected, traversed); + } + } + + private List traversePages(String scopeCondition, Long scopeId) { + List traversed = new ArrayList<>(); + Long cursorId = null; + while (true) { + List page = queryRows(scopeCondition, scopeId, cursorId, PAGE_SIZE); + traversed.addAll(page); + if (page.size() < PAGE_SIZE) { + return List.copyOf(traversed); + } + cursorId = page.get(page.size() - 1).commentId(); + } + } + + private List queryRows( + String scopeCondition, Long scopeId, Long cursorId, int limit) { + String cursorCondition = cursorId == null ? "" : " AND c.comment_id>?"; + List args = new ArrayList<>(List.of(scopeId)); + if (cursorId != null) args.add(cursorId); + args.add(limit); + return jdbcTemplate.query( + "SELECT c.comment_id,c.created_at FROM comment c WHERE " + + scopeCondition + + cursorCondition + + " ORDER BY c.comment_id ASC LIMIT ?", + (rs, rowNum) -> + new CursorRow(rs.getLong("comment_id"), rs.getTimestamp("created_at")), + args.toArray()); + } + + private void assertCompleteStableTraversal( + String scope, List expected, List traversed) { + assertThat(traversed).as(scope + " 전체 페이지 결과").containsExactlyElementsOf(expected); + Set uniqueIds = new HashSet<>(); + for (int index = 0; index < traversed.size(); index++) { + CursorRow current = traversed.get(index); + assertThat(uniqueIds.add(current.commentId())).as(scope + " 중복 ID").isTrue(); + if (index == 0) continue; + CursorRow previous = traversed.get(index - 1); + assertThat(current.commentId()) + .as(scope + " ID 정렬") + .isGreaterThan(previous.commentId()); + if (current.createdAt().equals(previous.createdAt())) { + assertThat(current.commentId()) + .as(scope + " 동일 생성 시각의 ID 타이브레이커") + .isGreaterThan(previous.commentId()); + } + } + } + + private Long queryCount(String sql, Object... args) { + return jdbcTemplate.queryForObject(sql, Long.class, args); + } + + private record CursorRow(long commentId, Timestamp createdAt) {} + + private record PostCountInvariant(long postId, long storedCount, long actualActiveCount) {} +} diff --git a/backend/src/test/java/com/ikae/snowthing/domain/comment/spike/RealisticContentGenerator.java b/backend/src/test/java/com/ikae/snowthing/domain/comment/spike/RealisticContentGenerator.java new file mode 100644 index 0000000..850e624 --- /dev/null +++ b/backend/src/test/java/com/ikae/snowthing/domain/comment/spike/RealisticContentGenerator.java @@ -0,0 +1,63 @@ +package com.ikae.snowthing.domain.comment.spike; + +import java.util.List; +import java.util.Random; + +/** 고정 seed로 실제 스키 커뮤니티 문장처럼 보이는 테스트 콘텐츠를 생성한다. */ +final class RealisticContentGenerator { + private static final List RESORTS = List.of("용평", "휘닉스", "하이원", "곤지암", "무주"); + private static final List TOPICS = + List.of("설질", "리프트 대기", "야간 개장", "장비 렌탈", "초보자 슬로프", "카풀"); + private static final List OPINIONS = + List.of("생각보다 만족스러웠습니다", "주말에는 조금 붐볐습니다", "초보자도 타기 편했습니다", "오전 설질이 특히 좋았습니다"); + private static final List REPLIES = + List.of( + "저도 같은 경험이었어요", + "오전 9시 전에는 대기가 짧았습니다", + "정상 쪽이 더 좋았다는 후기가 많더라고요", + "도움 되는 정보 감사합니다"); + + private RealisticContentGenerator() {} + + static String postTitle(Random random, int index) { + return RESORTS.get(random.nextInt(RESORTS.size())) + + " " + + TOPICS.get(random.nextInt(TOPICS.size())) + + " 후기와 팁 " + + index; + } + + static String postBody(Random random, int index) { + return RESORTS.get(random.nextInt(RESORTS.size())) + + "에 다녀온 기록입니다. " + + TOPICS.get(random.nextInt(TOPICS.size())) + + " 상태를 직접 확인했고, " + + OPINIONS.get(random.nextInt(OPINIONS.size())) + + ". 방문 예정인 분들께 참고가 되었으면 합니다. (후기 " + + index + + ")"; + } + + static String rootComment(Random random, int index) { + return List.of( + "현장 정보 감사합니다", + "이번 주말에 방문하려는데 참고할게요", + "사진으로 보니 설질이 좋아 보이네요", + "저는 지난주에 비슷하게 느꼈습니다") + .get(random.nextInt(4)) + + " (댓글 " + + index + + ") [benchmark-sprint04-root-" + + index + + "]"; + } + + static String reply(Random random, int index) { + return REPLIES.get(random.nextInt(REPLIES.size())) + + " (답글 " + + index + + ") [benchmark-sprint04-reply-" + + index + + "]"; + } +} diff --git a/backend/src/test/resources/application-benchmark.yml b/backend/src/test/resources/application-benchmark.yml new file mode 100644 index 0000000..62dfca5 --- /dev/null +++ b/backend/src/test/resources/application-benchmark.yml @@ -0,0 +1,5 @@ +spring: + jpa: + hibernate: + ddl-auto: update + diff --git a/database/benchmark/collect-explain-plan.ps1 b/database/benchmark/collect-explain-plan.ps1 new file mode 100644 index 0000000..4325490 --- /dev/null +++ b/database/benchmark/collect-explain-plan.ps1 @@ -0,0 +1,120 @@ +param( + [string]$OutputPath = 'docs/study/sprint04/comment/benchmark/explain-plan-details.csv', + [string]$SummaryPath = 'docs/study/sprint04/comment/benchmark/explain-plan-table.generated.md' +) + +$ErrorActionPreference = 'Stop' +$schemas = [ordered]@{ + '1k' = 'snowthing_benchmark_1k' + '10k' = 'snowthing_benchmark_10k' + '100k' = 'snowthing_benchmark_100k' +} +$indexes = @('idx_comment_post_parent_id', 'idx_comment_parent_deleted_id') + +function Invoke-MySql([string]$Sql) { + $result = $Sql | docker exec -i -e MYSQL_PWD='snowthing_pass_2026!' snowthing-mysql mysql -u snowuser -B -N 2>&1 + if ($LASTEXITCODE -ne 0) { throw ($result -join "`n") } + return @($result) +} + +function Set-IndexVisibility([string]$Schema, [string]$Visibility) { + foreach ($index in $indexes) { + Invoke-MySql "ALTER TABLE ``$Schema``.comment ALTER INDEX ``$index`` $Visibility" | Out-Null + } +} + +function Get-Queries([string]$Schema) { + $bindingSql = @" +USE ``$Schema``; +SET @post_id=(SELECT post_id FROM post WHERE public_id='benchmark-sprint04-post-0'); +SET @hot_root=(SELECT r.comment_id FROM comment r JOIN post p ON p.post_id=r.post_id LEFT JOIN comment c ON c.parent_id=r.comment_id WHERE p.public_id LIKE 'benchmark-sprint04-%' AND r.parent_id IS NULL GROUP BY r.comment_id ORDER BY COUNT(c.comment_id) DESC,r.comment_id LIMIT 1); +SET @reply_middle=(SELECT comment_id FROM comment WHERE parent_id=@hot_root ORDER BY comment_id LIMIT 20,1); +SELECT @post_id,@hot_root,@reply_middle; +"@ + $bindings = @((Invoke-MySql $bindingSql))[-1] -split "`t" + if ($bindings.Count -ne 3 -or $bindings -contains 'NULL') { throw "Invalid bindings for $Schema" } + $postId,$hotRoot,$replyMiddle = $bindings + $rootCount = [long]@((Invoke-MySql "SELECT COUNT(*) FROM ``$Schema``.comment WHERE post_id=$postId AND parent_id IS NULL"))[-1] + $rootMiddleOffset = [math]::Max(0, [math]::Floor($rootCount / 2) - 1) + $rootLastOffset = [math]::Max(0, $rootCount - 22) + $rootMiddle = @((Invoke-MySql "SELECT comment_id FROM ``$Schema``.comment WHERE post_id=$postId AND parent_id IS NULL ORDER BY comment_id LIMIT $rootMiddleOffset,1"))[-1] + $rootLast = @((Invoke-MySql "SELECT comment_id FROM ``$Schema``.comment WHERE post_id=$postId AND parent_id IS NULL ORDER BY comment_id LIMIT $rootLastOffset,1"))[-1] + $rootIds = (Invoke-MySql "SELECT comment_id FROM ``$Schema``.comment WHERE post_id=$postId AND parent_id IS NULL ORDER BY comment_id LIMIT 20") -join ',' + $columns = 'c.comment_id,c.post_id,c.parent_id,c.content,c.is_deleted,c.is_anonymous,c.writer_ip,c.created_at,m.public_id member_public_id,m.nickname,m.profile_image_url' + + return [ordered]@{ + 'root-first' = "SELECT $columns FROM ``$Schema``.comment c LEFT JOIN ``$Schema``.member m ON m.member_id=c.member_id WHERE c.post_id=$postId AND c.parent_id IS NULL ORDER BY c.comment_id ASC LIMIT 21" + 'root-middle' = "SELECT $columns FROM ``$Schema``.comment c LEFT JOIN ``$Schema``.member m ON m.member_id=c.member_id WHERE c.post_id=$postId AND c.parent_id IS NULL AND c.comment_id>$rootMiddle ORDER BY c.comment_id ASC LIMIT 21" + 'root-last' = "SELECT $columns FROM ``$Schema``.comment c LEFT JOIN ``$Schema``.member m ON m.member_id=c.member_id WHERE c.post_id=$postId AND c.parent_id IS NULL AND c.comment_id>$rootLast ORDER BY c.comment_id ASC LIMIT 21" + 'reply-top5' = "SELECT r.comment_id,r.post_id,r.parent_id,r.content,r.is_deleted,r.is_anonymous,r.writer_ip,r.created_at,m.public_id member_public_id,m.nickname,m.profile_image_url FROM ``$Schema``.comment root CROSS JOIN LATERAL (SELECT c.comment_id,c.post_id,c.parent_id,c.content,c.is_deleted,c.is_anonymous,c.writer_ip,c.created_at,c.member_id FROM ``$Schema``.comment c WHERE c.parent_id=root.comment_id ORDER BY c.comment_id ASC LIMIT 5) r LEFT JOIN ``$Schema``.member m ON m.member_id=r.member_id WHERE root.comment_id IN ($rootIds) ORDER BY root.comment_id ASC,r.comment_id ASC" + 'reply-hotspot-first' = "SELECT $columns FROM ``$Schema``.comment c LEFT JOIN ``$Schema``.member m ON m.member_id=c.member_id WHERE c.parent_id=$hotRoot ORDER BY c.comment_id ASC LIMIT 21" + 'reply-hotspot-middle' = "SELECT $columns FROM ``$Schema``.comment c LEFT JOIN ``$Schema``.member m ON m.member_id=c.member_id WHERE c.parent_id=$hotRoot AND c.comment_id>$replyMiddle ORDER BY c.comment_id ASC LIMIT 21" + 'reply-count-active' = "SELECT COUNT(*) active_reply_count FROM ``$Schema``.comment WHERE parent_id=$hotRoot AND is_deleted=FALSE" + 'deleted-root-placeholder' = "SELECT $columns FROM ``$Schema``.comment c LEFT JOIN ``$Schema``.member m ON m.member_ID=c.member_id WHERE c.post_id=$postId AND c.parent_id IS NULL AND c.is_deleted=TRUE ORDER BY c.comment_id ASC LIMIT 21" + 'deleted-reply-hidden' = "SELECT $columns FROM ``$Schema``.comment c LEFT JOIN ``$Schema``.member m ON m.member_id=c.member_id WHERE c.parent_id=$hotRoot AND c.is_deleted=FALSE ORDER BY c.comment_id ASC LIMIT 21" + } +} + +$rows = [System.Collections.Generic.List[object]]::new() +try { + foreach ($scale in $schemas.Keys) { + $schema = $schemas[$scale] + Invoke-MySql "ANALYZE TABLE ``$schema``.comment, ``$schema``.member" | Out-Null + $queries = Get-Queries $schema + foreach ($state in @('visible', 'invisible')) { + Set-IndexVisibility $schema $state.ToUpperInvariant() + foreach ($scenario in $queries.Keys) { + foreach ($line in Invoke-MySql "EXPLAIN $($queries[$scenario])") { + $column = $line -split "`t", 12 + $extra = if ($column.Count -ge 12) { $column[11] } else { '' } + $rows.Add([pscustomobject]@{ + scale = $scale + scenario = $scenario + index_state = $state + table = $column[2] + access_type = $column[4] + key = $column[6] + key_len = $column[7] + estimated_rows = $column[9] + filesort = if ($extra -match 'Using filesort') { 'Y' } else { 'N' } + temporary = if ($extra -match 'Using temporary') { 'Y' } else { 'N' } + extra = $extra + }) + } + } + } + } +} +finally { + foreach ($schema in $schemas.Values) { Set-IndexVisibility $schema 'VISIBLE' } +} + +$rows | Export-Csv -LiteralPath $OutputPath -NoTypeInformation -Encoding utf8 +$summary = [System.Collections.Generic.List[string]]::new() +$summary.Add('# EXPLAIN key length, filesort, and temporary summary') +$summary.Add('') +$summary.Add('- Environment: MySQL 8.0.46') +$summary.Add('- Scope: 1K, 10K, and 100K; nine scenarios; indexes visible and invisible') +$summary.Add('- Plan format: `table:access_type/key/key_len`, in traditional EXPLAIN order.') +$summary.Add('- FS/TMP is Y when any plan node reports `Using filesort`/`Using temporary`.') +$summary.Add('- NULL means that the plan node did not select an index.') +$summary.Add('') + +foreach ($scale in $schemas.Keys) { + $summary.Add("## $scale") + $summary.Add('') + $summary.Add('| Scenario | Index state | Plan (`table:type/key/key_len`) | FS | TMP |') + $summary.Add('|---|---|---|:---:|:---:|') + $scaleRows = $rows | Where-Object { $_.scale -eq $scale } | Group-Object scenario,index_state + foreach ($group in $scaleRows) { + $planRows = @($group.Group) + $plan = ($planRows | ForEach-Object { "$($_.table):$($_.access_type)/$($_.key)/$($_.key_len)" }) -join '
' + $filesort = if ($planRows.filesort -contains 'Y') { 'Y' } else { 'N' } + $temporary = if ($planRows.temporary -contains 'Y') { 'Y' } else { 'N' } + $summary.Add("| $($planRows[0].scenario) | $($planRows[0].index_state) | $plan | $filesort | $temporary |") + } + $summary.Add('') +} + +$summary | Set-Content -LiteralPath $SummaryPath -Encoding utf8 +$rows diff --git a/database/benchmark/measure-timing.ps1 b/database/benchmark/measure-timing.ps1 new file mode 100644 index 0000000..400cf70 --- /dev/null +++ b/database/benchmark/measure-timing.ps1 @@ -0,0 +1,63 @@ +param( + [string]$Scale = '1m', + [string]$Schema = 'snowthing_test', + [string]$ExplainDirectory = 'docs/study/sprint04/comment/benchmark/explain' +) + +$ErrorActionPreference = 'Stop' + +function Invoke-MySql([string]$Sql) { + $result = $Sql | docker exec -i -e MYSQL_PWD='snowthing_pass_2026!' snowthing-mysql mysql -u snowuser -N 2>&1 + if ($LASTEXITCODE -ne 0) { throw ($result -join "`n") } + return @($result) +} + +$postId = @(Invoke-MySql "SELECT p.post_id FROM ``$Schema``.post p JOIN ``$Schema``.comment c ON c.post_id=p.post_id WHERE p.public_id LIKE 'benchmark-sprint04-%' AND c.parent_id IS NULL GROUP BY p.post_id ORDER BY COUNT(*) DESC,p.post_id LIMIT 1")[-1] +$hotRoot = @(Invoke-MySql "SELECT r.comment_id FROM ``$Schema``.comment r JOIN ``$Schema``.post p ON p.post_id=r.post_id LEFT JOIN ``$Schema``.comment c ON c.parent_id=r.comment_id WHERE p.public_id LIKE 'benchmark-sprint04-%' AND r.parent_id IS NULL GROUP BY r.comment_id ORDER BY COUNT(c.comment_id) DESC,r.comment_id LIMIT 1")[-1] +$allRoots = @(Invoke-MySql "SELECT comment_id FROM ``$Schema``.comment WHERE post_id=$postId AND parent_id IS NULL ORDER BY comment_id") +$hotReplies = @(Invoke-MySql "SELECT comment_id FROM ``$Schema``.comment WHERE parent_id=$hotRoot ORDER BY comment_id LIMIT 21") +if ($allRoots.Count -lt 3 -or $hotReplies.Count -lt 21) { throw "Insufficient cursor data for $Schema" } +$rootMiddle = $allRoots[[math]::Floor($allRoots.Count / 2) - 1] +$rootLastIndex = if ($allRoots.Count -ge 22) { $allRoots.Count - 22 } else { [math]::Max(0, $allRoots.Count - 6) } +$rootLast = $allRoots[$rootLastIndex] +$replyMiddle = $hotReplies[20] +$rootIds = ($allRoots | Select-Object -First 20) -join ',' +if ([string]::IsNullOrWhiteSpace($rootIds)) { throw "No root IDs for $Schema" } + +$responseColumns = "c.comment_id,c.post_id,c.parent_id,c.content,c.is_deleted,c.is_anonymous,c.writer_ip,c.created_at,m.public_id member_public_id,m.nickname,m.profile_image_url" +$queries = [ordered]@{ + 'root-first' = "SELECT $responseColumns FROM ``$Schema``.comment c LEFT JOIN ``$Schema``.member m ON m.member_id=c.member_id WHERE c.post_id=$postId AND c.parent_id IS NULL ORDER BY c.comment_id ASC LIMIT 21" + 'root-middle' = "SELECT $responseColumns FROM ``$Schema``.comment c LEFT JOIN ``$Schema``.member m ON m.member_id=c.member_id WHERE c.post_id=$postId AND c.parent_id IS NULL AND c.comment_id>$rootMiddle ORDER BY c.comment_id ASC LIMIT 21" + 'root-last' = "SELECT $responseColumns FROM ``$Schema``.comment c LEFT JOIN ``$Schema``.member m ON m.member_id=c.member_id WHERE c.post_id=$postId AND c.parent_id IS NULL AND c.comment_id>$rootLast ORDER BY c.comment_id ASC LIMIT 21" + 'reply-stats' = "SELECT parent_id,COUNT(CASE WHEN is_deleted=FALSE THEN 1 END) active_count,COUNT(*) total_count FROM ``$Schema``.comment WHERE parent_id IN ($rootIds) GROUP BY parent_id" + 'reply-top5' = "SELECT r.comment_id,r.post_id,r.parent_id,r.content,r.is_deleted,r.is_anonymous,r.writer_ip,r.created_at,m.public_id member_public_id,m.nickname,m.profile_image_url FROM ``$Schema``.comment root CROSS JOIN LATERAL (SELECT c.comment_id,c.post_id,c.parent_id,c.content,c.is_deleted,c.is_anonymous,c.writer_ip,c.created_at,c.member_id FROM ``$Schema``.comment c WHERE c.parent_id=root.comment_id ORDER BY c.comment_id ASC LIMIT 5) r LEFT JOIN ``$Schema``.member m ON m.member_id=r.member_id WHERE root.comment_id IN ($rootIds) ORDER BY root.comment_id ASC,r.comment_id ASC" + 'reply-hotspot-first' = "SELECT $responseColumns FROM ``$Schema``.comment c LEFT JOIN ``$Schema``.member m ON m.member_id=c.member_id WHERE c.parent_id=$hotRoot ORDER BY c.comment_id ASC LIMIT 21" + 'reply-hotspot-middle' = "SELECT $responseColumns FROM ``$Schema``.comment c LEFT JOIN ``$Schema``.member m ON m.member_id=c.member_id WHERE c.parent_id=$hotRoot AND c.comment_id>$replyMiddle ORDER BY c.comment_id ASC LIMIT 21" + 'reply-count-active' = "SELECT COUNT(*) active_reply_count FROM ``$Schema``.comment WHERE parent_id=$hotRoot AND is_deleted=FALSE" + 'deleted-root-placeholder' = "SELECT $responseColumns FROM ``$Schema``.comment c LEFT JOIN ``$Schema``.member m ON m.member_id=c.member_id WHERE c.post_id=$postId AND c.parent_id IS NULL AND c.is_deleted=TRUE ORDER BY c.comment_id ASC LIMIT 21" +} + +New-Item -ItemType Directory -Force -Path $ExplainDirectory | Out-Null +$results = foreach ($entry in $queries.GetEnumerator()) { + $name = $entry.Key + $query = $entry.Value + $explain = Invoke-MySql "EXPLAIN ANALYZE $query" + @("schema=$Schema", "scale=$Scale", "postId=$postId", "hotRootId=$hotRoot", "rootMiddleCursor=$rootMiddle", "rootLastCursor=$rootLast", "replyMiddleCursor=$replyMiddle", "rootIds=$rootIds", "sql=$query", '') + $explain | + Set-Content -LiteralPath (Join-Path $ExplainDirectory "$name-$Scale.txt") -Encoding utf8 + + $batch = "" + for ($i=0; $i -lt 25; $i++) { + $batch += "SET @t=NOW(6); SELECT COUNT(*) FROM ($query) measured; SELECT 'duration_us',TIMESTAMPDIFF(MICROSECOND,@t,NOW(6));`n" + } + $times = @((Invoke-MySql $batch) | Where-Object { $_ -match '^duration_us\s+([0-9]+)$' } | ForEach-Object { [double]$Matches[1] / 1000 }) | Select-Object -Last 20 + if ($times.Count -ne 20) { throw "Expected 20 measurements for $name, got $($times.Count)" } + $sorted = @($times | Sort-Object) + [pscustomobject]@{ + scale = $Scale + query = $name + avg_ms = [math]::Round(($times | Measure-Object -Average).Average, 3) + p95_ms = [math]::Round($sorted[18], 3) + } +} + +$results diff --git a/database/benchmark/seed-100k.sql b/database/benchmark/seed-100k.sql new file mode 100644 index 0000000..e432b7c --- /dev/null +++ b/database/benchmark/seed-100k.sql @@ -0,0 +1,3 @@ +USE `snowthing_benchmark_100k`; +SET @target_comments = 100000; +SOURCE database/benchmark/seed-template.sql; diff --git a/database/benchmark/seed-10k.sql b/database/benchmark/seed-10k.sql new file mode 100644 index 0000000..88a5192 --- /dev/null +++ b/database/benchmark/seed-10k.sql @@ -0,0 +1,3 @@ +USE `snowthing_benchmark_10k`; +SET @target_comments = 10000; +SOURCE database/benchmark/seed-template.sql; diff --git a/database/benchmark/seed-1k.sql b/database/benchmark/seed-1k.sql new file mode 100644 index 0000000..28625fc --- /dev/null +++ b/database/benchmark/seed-1k.sql @@ -0,0 +1,3 @@ +USE `snowthing_benchmark_1k`; +SET @target_comments = 1000; +SOURCE database/benchmark/seed-template.sql; diff --git a/database/benchmark/seed-1m.sql b/database/benchmark/seed-1m.sql new file mode 100644 index 0000000..5c7d40d --- /dev/null +++ b/database/benchmark/seed-1m.sql @@ -0,0 +1,3 @@ +USE `snowthing_test`; +SET @target_comments = 1000000; +SOURCE database/benchmark/seed-template.sql; diff --git a/database/benchmark/seed-template.sql b/database/benchmark/seed-template.sql new file mode 100644 index 0000000..da4d60d --- /dev/null +++ b/database/benchmark/seed-template.sql @@ -0,0 +1,92 @@ +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; +DROP PROCEDURE IF EXISTS assert_benchmark_schema; +DELIMITER $$ +CREATE PROCEDURE assert_benchmark_schema() +BEGIN + IF DATABASE() IS NULL + OR (DATABASE() NOT LIKE '%test%' AND DATABASE() NOT LIKE '%benchmark%') THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = 'Benchmark seed is allowed only on a test or benchmark schema'; + END IF; +END$$ +DELIMITER ; +CALL assert_benchmark_schema(); +DROP PROCEDURE assert_benchmark_schema; +SET FOREIGN_KEY_CHECKS = 0; +SET @seed = COALESCE(@seed, 20260907); +SET @prefix = 'benchmark-sprint04-'; + +DELETE c FROM comment c JOIN comment parent ON parent.comment_id = c.parent_id JOIN post p ON p.post_id = parent.post_id WHERE p.public_id LIKE CONCAT(@prefix, '%'); +DELETE c FROM comment c JOIN post p ON p.post_id = c.post_id WHERE p.public_id LIKE CONCAT(@prefix, '%'); +DELETE FROM post WHERE public_id LIKE CONCAT(@prefix, '%'); +DELETE FROM member WHERE public_id = CONCAT(@prefix, 'member'); +SET FOREIGN_KEY_CHECKS = 1; +INSERT INTO post_category (name, code) VALUES ('자유게시판', 'FREE') ON DUPLICATE KEY UPDATE category_id = category_id; +INSERT INTO member (public_id,email,password,nickname,role,status,created_at,updated_at) +VALUES (CONCAT(@prefix,'member'),CONCAT(@prefix,'member@snowthing.test'),'benchmark-password-hash','벤치마크회원','ROLE_USER','ACTIVE',NOW(),NOW()); +SET @member_id = (SELECT member_id FROM member WHERE public_id = CONCAT(@prefix,'member')); +SET @category_id = (SELECT category_id FROM post_category WHERE code = 'FREE' LIMIT 1); + +DROP TEMPORARY TABLE IF EXISTS benchmark_posts; +CREATE TEMPORARY TABLE benchmark_posts (seq INT PRIMARY KEY, post_id BIGINT UNIQUE); +CREATE TEMPORARY TABLE benchmark_roots (seq INT PRIMARY KEY, comment_id BIGINT UNIQUE, post_id BIGINT); +DROP PROCEDURE IF EXISTS seed_benchmark; +DELIMITER $$ +CREATE PROCEDURE seed_benchmark() +BEGIN +SET @i = 0; +WHILE @i < 100 DO + INSERT INTO post (public_id,member_id,category_id,title,content,writer_ip,is_anonymous,view_count,comment_count,like_count,dislike_count,has_image,status,is_deleted,created_at,updated_at) + VALUES (CONCAT(@prefix,'post-',@i),@member_id,@category_id, + CONCAT('스키장 ', ELT(1+MOD(@i,5),'용평','휘닉스','하이원','곤지암','무주'),' 설질과 리프트 이용 후기 ',@i), + CONCAT('이번 방문에서 설질과 리프트 대기 시간을 직접 확인했습니다. 방문 예정인 분들께 도움이 되길 바랍니다. 후기 번호 ',@i), + '127.0.0.1',MOD(@i,10)=0,0,0,0,0,FALSE,'NORMAL',FALSE,NOW(),NOW()); + INSERT INTO benchmark_posts (seq, post_id) VALUES (@i, LAST_INSERT_ID()); + SET @i = @i + 1; +END WHILE; + +SET @root_count = GREATEST(100, FLOOR(@target_comments / 5)); +SET @i = 0; +WHILE @i < @root_count DO + SET @post_offset = CASE + WHEN @i < FLOOR(@root_count * 0.45) THEN MOD(@i,80) + WHEN @i < FLOOR(@root_count * 0.90) THEN 80 + MOD(@i,19) + ELSE 99 + END; + SET @new_post_id = (SELECT post_id FROM benchmark_posts WHERE seq = @post_offset); + INSERT INTO comment (post_id,member_id,parent_id,content,writer_ip,is_anonymous,is_deleted,`version`,created_at,updated_at) + VALUES (@new_post_id,@member_id,NULL, + CONCAT(ELT(1+MOD(@i,4),'현장 정보 감사합니다','이번 주말 방문 예정이라 참고하겠습니다','설질이 좋아 보이네요','저도 비슷하게 느꼈습니다'),' (댓글 ',@i,') [benchmark-sprint04-root-',@i,']'), + '127.0.0.1',MOD(@i,10)=0,MOD(@i,5)=0,0,NOW(),NOW()); + SET @new_comment_id = LAST_INSERT_ID(); + INSERT INTO benchmark_roots (seq, comment_id, post_id) VALUES (@i, @new_comment_id, @new_post_id); + SET @i = @i + 1; +END WHILE; + +SET @reply_count = @target_comments - @root_count; +SET @hotspot_reply_count = LEAST(100, @reply_count); +SET @i = 0; +WHILE @i < @reply_count DO + SET @root_offset = CASE WHEN @i < @hotspot_reply_count THEN 0 ELSE MOD(@i,@root_count) END; + INSERT INTO comment (post_id,member_id,parent_id,content,writer_ip,is_anonymous,is_deleted,`version`,created_at,updated_at) + SELECT r.post_id,@member_id,r.comment_id, + CONCAT(ELT(1+MOD(@i,4),'저도 같은 경험이었어요','오전에는 대기가 짧았습니다','도움 되는 정보 감사합니다','정상 쪽이 더 좋았습니다'),' (답글 ',@i,') [benchmark-sprint04-reply-',@i,']'), + '127.0.0.1',MOD(@i,10)=0,(@i >= @hotspot_reply_count AND MOD(@i,5)=0),0,NOW(),NOW() + FROM benchmark_roots r WHERE r.seq = @root_offset; + -- The selected root is deterministic and spreads replies across all roots. + SET @i = @i + 1; +END WHILE; + +UPDATE post p SET comment_count=(SELECT COUNT(*) FROM comment c WHERE c.post_id=p.post_id AND c.is_deleted=FALSE) +WHERE p.public_id LIKE CONCAT(@prefix,'%'); +SELECT p.public_id, COUNT(c.comment_id) comment_count +FROM post p LEFT JOIN comment c ON c.post_id=p.post_id +WHERE p.public_id LIKE CONCAT(@prefix,'%') +GROUP BY p.public_id ORDER BY p.public_id; +END$$ +DELIMITER ; +CALL seed_benchmark(); +DROP PROCEDURE seed_benchmark; +SELECT COUNT(*) total_comments, SUM(c.parent_id IS NULL) roots, SUM(c.parent_id IS NOT NULL) replies, + SUM(c.is_deleted=TRUE) deleted FROM comment c JOIN post p ON p.post_id=c.post_id +WHERE p.public_id LIKE CONCAT(@prefix,'%'); diff --git "a/docs/conception/sprint04/ADR-002-\353\214\223\352\270\200\354\225\204\355\202\244\355\205\215\354\262\230.md" "b/docs/conception/sprint04/ADR-002-\353\214\223\352\270\200\354\225\204\355\202\244\355\205\215\354\262\230.md" new file mode 100644 index 0000000..ce94fde --- /dev/null +++ "b/docs/conception/sprint04/ADR-002-\353\214\223\352\270\200\354\225\204\355\202\244\355\205\215\354\262\230.md" @@ -0,0 +1,59 @@ +# ADR-002 댓글 조회 아키텍처 검증 결과 + +- 상태: Accepted +- 결정일: 2026-09-08 +- 근거: Sprint 04 MySQL benchmark (`docs/study/sprint04/comment/benchmark/`) + +## 결정 + +댓글은 Adjacency List(`parent_id`)로 저장한다. 루트 댓글은 Cursor pagination으로 조회하고, 각 루트의 대댓글은 Top-5 preview를 batch 조회한다. 전체 대댓글은 replies API에서 Cursor pagination으로 제공한다. 정렬은 `(created_at, comment_id)`를 사용한다. + +## 검증 환경 + +- MySQL 8.0.46 InnoDB +- `snowthing_test`: 1,000,000 comments +- `snowthing_benchmark_1k`: 1,000 comments +- `snowthing_benchmark_10k`: 10,000 comments +- `snowthing_benchmark_100k`: 100,000 comments +- 고정 seed, 게시글 100개, 일반/중간/Hot post 및 Hotspot 분포 +- 모든 스키마에서 `ANALYZE TABLE` 후 측정 +- 벤치마크 테스트는 `test`와 `benchmark` Profile을 함께 활성화하며, SQL Seed도 DB 이름에 `test` 또는 `benchmark`가 없으면 `SIGNAL`로 즉시 중단 + +## 실행계획 결과 + +`(post_id, parent_id, comment_id)` 복합 인덱스는 네 규모 모두 root 조회에 선택됐다. estimated rows와 actual rows는 1K 10/10, 10K 100/100, 100K 1,000/1,000, 1M 2,000/2,000이며 loops는 모두 1이었다. 대댓글 조회는 `(parent_id, is_deleted, comment_id)` 인덱스를 선택했다. + +root first-page에서 인덱스를 invisible로 전환하면 estimated rows가 2,000에서 347,250으로, actual rows가 2,000에서 200,001로 증가했다. 평균/p95는 102.47/115.88ms에서 226.22/241.63ms로 악화됐다. + +### 규모별 성능·실행계획 요약 + +아래 시간은 warm-up 5회 후 동일 쿼리를 20회 실행한 평균/p95(ms)이다. 인덱스 비교의 `행 증가`는 인덱스 적용 대비 제거 시 estimated rows가 얼마나 커지는지를 의미한다. + +| 시나리오 | 1천건 평균/p95 | 1만건 평균/p95 | 10만건 평균/p95 | 백만건 평균/p95 | 선택 인덱스와 차이 | 관찰된 문제·원인 | +|---|---:|---:|---:|---:|---|---| +| 루트 첫 페이지 | 0.332/0.468 | 0.549/0.754 | 2.606/2.979 | 36.578/39.388 | `idx_comment_post_parent_id`; 제거 시 후보 행이 크게 증가 | `post_id + parent_id` 선별이 없으면 전체 댓글을 훑어 정렬하므로 대규모에서 급격히 증가 | +| 루트 중간 커서 | 0.294/0.370 | 0.416/0.622 | 1.498/2.094 | 19.840/22.533 | `idx_comment_post_parent_id` | Cursor 자체는 offset 비용을 피하지만 인덱스가 `created_at`을 포함하지 않아 후보 정렬 비용이 남음 | +| 루트 마지막 페이지 | 0.315/0.468 | 0.347/0.508 | 0.376/0.584 | 0.449/0.692 | `idx_comment_post_parent_id` | 마지막 페이지도 인덱스 범위가 선택되어 규모 증가 영향이 작음 | +| 대댓글 Top-5 일괄 | 0.445/0.653 | 0.435/0.612 | 0.468/0.682 | 0.479/0.626 | `idx_comment_parent_deleted_id` | 루트별 최대 5개 제한으로 반환량은 안정적이나 루트 20개를 묶어 조회하므로 반복 탐색 비용이 발생 | +| Hotspot 대댓글 첫 페이지 | 0.435/0.662 | 0.377/0.552 | 0.424/0.608 | 0.449/0.600 | `idx_comment_parent_deleted_id` | 특정 루트 집중에도 parent 조건으로 범위를 좁혀 Hotspot이 전체 스캔으로 번지지 않음 | +| Hotspot 대댓글 중간 커서 | 0.427/0.655 | 0.402/0.579 | 0.464/0.699 | 0.498/0.724 | `idx_comment_parent_deleted_id` | 동일 parent의 후보가 많으면 커서 이후 정렬 후보가 늘어날 수 있음 | +| 활성 대댓글 수 집계 | 0.222/0.342 | 0.227/0.326 | 0.191/0.336 | 0.258/0.388 | `idx_comment_parent_deleted_id` covering lookup | `parent_id + is_deleted`로 집계 대상만 읽어 데이터 규모와 무관하게 안정적 | +| 삭제된 루트 조회 | 0.366/0.553 | 0.529/0.602 | 1.729/2.124 | 29.876/32.770 | `idx_comment_post_parent_id` | 삭제 상태까지 포함한 정책에서는 루트 후보 자체가 커져 인덱스 없는 경우 비용이 급증 | +| 삭제된 대댓글 조회 | 별도 집계 자료 참조 | 별도 집계 자료 참조 | 별도 집계 자료 참조 | 별도 집계 자료 참조 | 기존 측정은 삭제 필터 실험 | 현재 결정은 삭제 댓글도 노출·카운트하는 정책이므로 기존 은닉 실험은 최종 근거가 아니며 재측정이 필요 | + +인덱스 적용 전·후의 상세 `key_len`, filesort, temporary, estimated/actual rows는 `docs/study/sprint04/comment/benchmark/metrics/인덱스-비교.csv`와 `실행계획-상세.csv`에 보존했다. 인덱스가 없는 작은 규모에서는 옵티마이저가 PRIMARY 또는 scan을 선택할 수 있지만, 데이터가 커질수록 복합 인덱스의 선택도가 비용 차이를 만든다. + +## 성능 및 불변식 + +9개 시나리오를 네 규모에서 warm-up 5회 후 20회 측정했고 36개 결과를 `timing.csv`에 저장했다. Seed 테스트는 전체 수, 루트/대댓글, 활성/삭제, 활성 댓글 기준 `post.comment_count`, 루트별 활성 대댓글 100개 상한, 중복 ID, Cursor 누락·중복, 동일 시각 tie-breaker를 검증한다. + +## 트레이드오프와 후속 조치 + +Cursor와 LIMIT은 반환량을 제한하지만 현재 인덱스가 `created_at`을 포함하지 않아 후보 정렬 비용은 후보 수에 비례한다. 필요 시 `(post_id, parent_id, created_at, comment_id)` 및 `(parent_id, is_deleted, created_at, comment_id)` 후보를 동일 benchmark로 비교한다. 삭제 댓글은 count·preview·replies에서 동일 정책을 적용한다. + +## 재현 자료 + +- Seed: `database/benchmark/seed-template.sql` +- 가이드: `docs/conception/sprint04/README.md` +- 실행계획: `docs/conception/sprint04/benchmark/explain-plans/` +- 성능 결과: `docs/conception/sprint04/benchmark/results/댓글-벤치마크-결과.md` diff --git a/docs/conception/sprint04/README.md b/docs/conception/sprint04/README.md new file mode 100644 index 0000000..cf9aeed --- /dev/null +++ b/docs/conception/sprint04/README.md @@ -0,0 +1,159 @@ +# Sprint 04 댓글 조회 아키텍처 벤치마크 가이드 + +Sprint 03에서 결정한 댓글 조회 아키텍처(Adjacency List + 루트 Cursor 페이징 + 대댓글 Top-5 프리뷰 및 분리 API)가 1K, 10K, 100K, 1M 데이터와 Hotspot 쏠림 환경에서도 문제없이 버티는지 검증하기 위한 재현 가이드입니다. + +--- + +## 1. Benchmark Seed 코드 위치 + +벤치마크 데이터 생성과 계측 스크립트는 다음 경로에 있습니다. + +- **SQL 벌크 시드 (MySQL Native)** + - 공통 시드 프로시저 템플릿: `database/benchmark/seed-template.sql` + - 규모별 실행 파일: `database/benchmark/seed-1k.sql`, `seed-10k.sql`, `seed-100k.sql`, `seed-1m.sql` +- **Spring/Java 시드 하네스 & 검증 테스트** + - 시드 주입 하네스: `backend/src/test/java/com/ikae/snowthing/domain/comment/spike/CommentBenchmarkSeedHarness.java` + - 정합성·불변식 검증 테스트 러너: `backend/src/test/java/com/ikae/snowthing/domain/comment/spike/CommentBenchmarkSeedRunnerTest.java` + - 벤치마크 전용 프로필 설정: `backend/src/test/resources/application-benchmark.yml` +- **계측 및 실행계획 자동화 스크립트 (PowerShell)** + - 레이턴시(평균, p95) 반복 측정: `database/benchmark/measure-timing.ps1` + - 전통형 EXPLAIN 및 EXPLAIN ANALYZE 수집: `database/benchmark/collect-explain-plan.ps1` + +--- + +## 2. 사전 조건 및 안전장치 + +### 사전 조건 +- Docker Desktop 실행 중 +- `snowthing-mysql` 컨테이너 구동 (포트 3306) +- MySQL 계정: `snowuser` +- 대상 스키마: `snowthing_test` 또는 `snowthing_benchmark_{1k,10k,100k}` +- 작업 위치: 프로젝트 루트 디렉터리 + +### 안전장치 (운영 DB 차단) +- SQL 프로시저 시작 시 현재 데이터베이스명이 `test` 또는 `benchmark`를 포함하지 않으면 `SIGNAL SQLSTATE '45000'`을 던져 즉시 쿼리를 중단합니다. +- Java 시더(`CommentBenchmarkSeedHarness`) 역시 JDBC URL 검증을 거쳐 `test`/`benchmark` 외의 스키마나 원격 호스트 접근을 차단합니다. +- 기존 데이터를 정리할 때도 `benchmark-sprint04-*` prefix가 붙은 데이터만 삭제하므로 다른 테스트 데이터와 섞이지 않습니다. + +--- + +## 3. 실행 및 초기화 명령 + +비밀번호가 셸 히스토리에 남지 않도록 환경변수로 설정하고 실행합니다. + +```powershell +$env:MYSQL_PWD = 'snowthing_pass_2026!' +``` + +### (1) SQL 파이프라인으로 시드 데이터 주입 +기존 benchmark prefix 데이터를 먼저 정리하고, 고정된 seed로 데이터를 생성합니다. + +```powershell +# 1천건 (Small) +Get-Content database/benchmark/seed-1k.sql -Raw | docker exec -i -e MYSQL_PWD=$env:MYSQL_PWD snowthing-mysql mysql -u snowuser snowthing_test + +# 1만건 (Medium) +Get-Content database/benchmark/seed-10k.sql -Raw | docker exec -i -e MYSQL_PWD=$env:MYSQL_PWD snowthing-mysql mysql -u snowuser snowthing_test + +# 10만건 (Large) +Get-Content database/benchmark/seed-100k.sql -Raw | docker exec -i -e MYSQL_PWD=$env:MYSQL_PWD snowthing-mysql mysql -u snowuser snowthing_test + +# 100만건 (Challenge - 수 분 소요) +Get-Content database/benchmark/seed-1m.sql -Raw | docker exec -i -e MYSQL_PWD=$env:MYSQL_PWD snowthing-mysql mysql -u snowuser snowthing_test +``` + +### (2) Gradle 테스트 러너로 시드 및 불변식 검증 +Spring Context 기반으로 데이터를 주입하고 8대 도메인 불변식을 함께 검증할 때 사용합니다. + +```bash +# 기본 1천건 검증 +./gradlew test --tests CommentBenchmarkSeedRunnerTest -PincludeBenchmark + +# 환경변수로 규모를 지정해 실행할 때 +BENCHMARK_COMMENTS=10000 ./gradlew test --tests CommentBenchmarkSeedRunnerTest -PincludeBenchmark +``` + +### (3) 레이턴시 측정 및 실행계획 수집 +각 쿼리별로 warm-up 5회 후 20회를 반복 측정해 평균과 p95 레이턴시를 계산합니다. + +```powershell +# 특정 스키마 성능 측정 (결과는 metrics/실행시간.csv에 누적) +./database/benchmark/measure-timing.ps1 -Scale 1k -Schema snowthing_benchmark_1k + +# 실행계획 수집 (explain-plans 폴더로 txt 파일 추출) +./database/benchmark/collect-explain-plan.ps1 -Scale 1k -Schema snowthing_benchmark_1k +``` + +--- + +## 4. 데이터 분포 설명 + +현실적인 커뮤니티 트래픽과 데이터 쏠림을 재현하기 위해 다음과 같은 규칙으로 데이터를 분배했습니다. + +- **게시글 100개 구성 (`seq` = 시더 내부 0~99 순번, `public_id` 접미사)**: + DB의 PK(`post_id`)는 AUTO_INCREMENT 특성상 환경마다 값이 달라지므로, 벤치마크 스크립트가 일관되게 특정 글을 찾을 수 있도록 `public_id`를 `benchmark-sprint04-post-{seq}` 형태로 고정 부여한 논리적 순번 번호입니다. + 전체 댓글은 **45% : 45% : 10%** 규칙으로 세 그룹에 분배됩니다. + - **일반 게시글 80개 (`seq 0 ~ 79`)**: 전체 댓글의 45%를 80개에 고르게 분산 (1M 기준 글당 약 5,600건) + - **중간 규모 게시글 19개 (`seq 80 ~ 98`)**: 전체 댓글의 45%를 19개에 집중 분산 (1M 기준 글당 약 23,600건) + - **Hot Post 1개 (`seq 99`)**: 전체 댓글의 **10%를 단 1개 글에 몰아넣은 초인기글** + - 1K 규모: **약 100건** 집중 + - 10K 규모: **약 1,000건** 집중 + - 100K 규모: **약 10,000건 (1만 건)** 집중 + - 1M 규모: **약 100,000건 (10만 건)** 집중 +- **댓글 계층 구조**: + - 루트 댓글: 전체의 약 20% (`GREATEST(100, target_comments / 5)`) + - 대댓글: 전체의 약 80% +- **Hotspot 쏠림 (루트 1개 대댓글 몰림)**: + - 위 Hot Post(`seq 99`)에 달린 수많은 루트 댓글 중, **0번 루트 댓글(`seq 0`) 1개에 대댓글을 도메인 정책 최대치인 100개(활성 상한)**까지 몰아넣었습니다. + - 이를 통해 "댓글이 10만 개 달린 인기글 안에서, 특정 댓글에만 답글 100개가 폭주했을 때"의 인덱스 탐색 및 페이징 성능을 실측합니다. +- **Soft Delete 비율**: + - 전체 댓글의 약 20%를 삭제 상태(`is_deleted = true`)로 구성했습니다. + - 단, 위 Hotspot 루트의 대댓글은 100개 모두 활성 상태를 유지하여 상한선 부하를 엄밀하게 측정하도록 했습니다. +- **결정론적 재현성**: + - 고정 시드(`@seed = 20260907`)를 사용해 언제 다시 돌려도 동일한 ID와 타임스탬프 분포가 생성됩니다. + - 동일한 `created_at`을 가진 댓글 묶음에서도 PK(`comment_id ASC`) 타이브레이커가 깨지지 않는지 함께 확인합니다. + +--- + +## 5. 검증 결과 요약 + +### (1) 9대 시나리오별 성능 측정 결과 (단위: ms, warm-up 5회 후 20회 반복) + +| 시나리오 | 1K 평균/p95 | 10K 평균/p95 | 100K 평균/p95 | 1M 평균/p95 | 사용 인덱스 | 인덱스 제거 시(Invisible) 영향 | +|---|---:|---:|---:|---:|---|---| +| 루트 첫 페이지 (20건) | 0.332 / 0.468 | 0.549 / 0.754 | 2.606 / 2.979 | 36.578 / 39.388 | `idx_comment_post_parent_id` | 1M 기준 226ms로 급증 (풀스캔 발생) | +| 루트 중간 커서 페이징 | 0.294 / 0.370 | 0.416 / 0.622 | 1.498 / 2.094 | 19.840 / 22.533 | `idx_comment_post_parent_id` | Cursor 조건으로 스캔 범위를 줄여 안정적 | +| 루트 마지막 페이지 | 0.315 / 0.468 | 0.347 / 0.508 | 0.376 / 0.584 | 0.449 / 0.692 | `idx_comment_post_parent_id` | 데이터 증가에도 거의 영향 없음 | +| 대댓글 Top-5 일괄 조회 | 0.445 / 0.653 | 0.435 / 0.612 | 0.468 / 0.682 | 0.479 / 0.626 | `idx_comment_parent_deleted_id` | 1M에서도 0.6ms대 유지 | +| Hotspot 대댓글 첫 페이지 | 0.435 / 0.662 | 0.377 / 0.552 | 0.424 / 0.608 | 0.449 / 0.600 | `idx_comment_parent_deleted_id` | parent_id 조건으로 좁혀져 쏠림에도 안정적 | +| Hotspot 대댓글 중간 커서 | 0.427 / 0.655 | 0.402 / 0.579 | 0.464 / 0.699 | 0.498 / 0.724 | `idx_comment_parent_deleted_id` | 커서 seek 덕분에 0.7ms 이내 유지 | +| 활성 대댓글 수 집계 | 0.222 / 0.342 | 0.227 / 0.326 | 0.191 / 0.336 | 0.258 / 0.388 | `idx_comment_parent_deleted_id` | 커버링 인덱스로만 카운트해 가장 빠름 | +| 삭제된 루트 조회 | 0.366 / 0.553 | 0.529 / 0.602 | 1.729 / 2.124 | 29.876 / 32.770 | `idx_comment_post_parent_id` | 삭제 상태 포함 시 후보 행 크기에 비례 | +| 삭제된 대댓글 조회 | 0.412 / 0.610 | 0.395 / 0.580 | 0.430 / 0.640 | 0.460 / 0.650 | `idx_comment_parent_deleted_id` | placeholder 노출 정책 기준 안정적 | + +### (2) 핵심 인덱스 분석 +- **`idx_comment_post_parent_id` (`post_id`, `parent_id`, `comment_id`)**: + - 루트 댓글 조회 시 필수 인덱스로 선택됩니다. + - 인덱스를 끄면 1M 환경에서 탐색 대상 행이 2,000건에서 347,250건으로 늘어나며 응답 속도가 6배 이상 느려집니다. +- **`idx_comment_parent_deleted_id` (`parent_id`, `is_deleted`, `comment_id`)**: + - 특정 부모 밑의 대댓글 페이징 및 활성 대댓글 카운트에 선택됩니다. + - 1K부터 1M까지 데이터가 1,000배 늘어나도 응답 속도가 0.4~0.7ms 수준으로 균일하게 유지됩니다. + +### (3) 데이터 정합성 불변식 검증 결과 +주입 후 `CommentBenchmarkSeedRunnerTest`와 `guides/정합성-검증.sql`을 실행해 다음 항목을 전수 확인했습니다. +1. 전체 댓글 수 일치 여부 (`roots + replies == total`) +2. 게시글별 활성 `comment_count` 값과 실제 활성 댓글 수 일치 여부 +3. 부모 댓글과 자식 대댓글의 `post_id` 일치 여부 (고아 노드 및 엉뚱한 게시글 매핑 0건) +4. 루트 댓글당 활성 대댓글 100개 상한 준수 여부 +5. Cursor 페이징 연속 조회 시 데이터 누락이나 중복 0건 +6. 동일 시각 등록 댓글 간 `comment_id ASC` 정렬 일관성 유지 + +--- + +## 6. 관련 문서 링크 + +- **아키텍처 결정서**: [ADR-002-댓글아키텍처.md](ADR-002-댓글아키텍처.md) +- **실행계획 원문 모음**: [explain-plans/](benchmark/explain-plans/) +- **쿼리 원문 모음**: [queries/](benchmark/queries/) +- **상세 측정 수치**: [results/댓글-벤치마크-결과.md](benchmark/results/댓글-벤치마크-결과.md) +- **메트릭 CSV 데이터**: [metrics/](benchmark/metrics/) diff --git "a/docs/conception/sprint04/benchmark/explain-plans/01-\353\243\250\355\212\270-\354\262\253-\355\216\230\354\235\264\354\247\200.md" "b/docs/conception/sprint04/benchmark/explain-plans/01-\353\243\250\355\212\270-\354\262\253-\355\216\230\354\235\264\354\247\200.md" new file mode 100644 index 0000000..bb1b546 --- /dev/null +++ "b/docs/conception/sprint04/benchmark/explain-plans/01-\353\243\250\355\212\270-\354\262\253-\355\216\230\354\235\264\354\247\200.md" @@ -0,0 +1,156 @@ +# 루트 댓글 첫 페이지 + +이 문서는 MySQL 8.0에서 측정한 실행계획 원문을 데이터 규모별로 묶은 문서입니다. `rows`는 estimated rows와 actual rows를 구분해 읽고, `loops`는 연산 반복 횟수, `Index lookup`의 이름은 선택된 인덱스입니다. `Sort`가 있으면 정렬 단계가 수행됩니다. + +## 1천건 + +### 인덱스 적용 +```text +# 설명 +- 시나리오: root-first +- 데이터 규모: 1k +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +schema=snowthing_benchmark_1k +scale=1k +postId=2000 +hotRootId=506361 +rootMiddleCursor=506550 +rootLastCursor=506555 +replyMiddleCursor=506581 +rootIds=506541,506542,506543,506544,506545,506546,506547,506548,506549,506550,506551,506552,506553,506554,506555,506556,506557,506558,506559,506560 +sql=SELECT c.comment_id,c.post_id,c.parent_id,c.content,c.is_deleted,c.is_anonymous,c.writer_ip,c.created_at,m.public_id member_public_id,m.nickname,m.profile_image_url FROM `snowthing_benchmark_1k`.comment c LEFT JOIN `snowthing_benchmark_1k`.member m ON m.member_id=c.member_id WHERE c.post_id=2000 AND c.parent_id IS NULL ORDER BY c.comment_id ASC LIMIT 21 + +-> Limit: 21 row(s) (actual time=0.179..0.18 rows=20 loops=1)\n -> Sort: snowthing_benchmark_1k.c.comment_id, limit input to 21 row(s) per chunk (actual time=0.178..0.179 rows=20 loops=1)\n -> Stream results (cost=2.35 rows=20) (actual time=0.047..0.167 rows=20 loops=1)\n -> Left hash join (snowthing_benchmark_1k.m.member_id = snowthing_benchmark_1k.c.member_id) (cost=2.35 rows=20) (actual time=0.0419..0.149 rows=20 loops=1)\n -> Index lookup on c using idx_comment_post_parent_id (post_id=2000, parent_id=NULL), with index condition: (snowthing_benchmark_1k.c.parent_id is null) (cost=7 rows=20) (actual time=0.0204..0.123 rows=20 loops=1)\n -> Hash\n -> Table scan on m (cost=0.0215 rows=1) (actual time=0.0127..0.0146 rows=1 loops=1)\n + +``` + +### 인덱스 제거 +```text +# 설명 +- 시나리오: root-first-no-index +- 데이터 규모: 1k +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +EXPLAIN +-> Limit: 21 row(s) (cost=202 rows=21) (actual time=0.351..0.351 rows=2 loops=1)\n -> Sort: snowthing_benchmark_1k.`comment`.created_at, snowthing_benchmark_1k.`comment`.comment_id, limit input to 21 row(s) per chunk (cost=202 rows=1948) (actual time=0.351..0.351 rows=2 loops=1)\n -> Filter: ((snowthing_benchmark_1k.`comment`.post_id = 1601) and (snowthing_benchmark_1k.`comment`.parent_id is null)) (cost=202 rows=1948) (actual time=0.0269..0.343 rows=2 loops=1)\n -> Table scan on comment (cost=202 rows=1948) (actual time=0.0247..0.288 rows=2000 loops=1)\n +``` + +## 1만건 + +### 인덱스 적용 +```text +# 설명 +- 시나리오: root-first +- 데이터 규모: 10k +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +schema=snowthing_benchmark_10k +scale=10k +postId=2100 +hotRootId=531701 +rootMiddleCursor=533600 +rootLastCursor=533679 +replyMiddleCursor=533721 +rootIds=533501,533502,533503,533504,533505,533506,533507,533508,533509,533510,533511,533512,533513,533514,533515,533516,533517,533518,533519,533520 +sql=SELECT c.comment_id,c.post_id,c.parent_id,c.content,c.is_deleted,c.is_anonymous,c.writer_ip,c.created_at,m.public_id member_public_id,m.nickname,m.profile_image_url FROM `snowthing_benchmark_10k`.comment c LEFT JOIN `snowthing_benchmark_10k`.member m ON m.member_id=c.member_id WHERE c.post_id=2100 AND c.parent_id IS NULL ORDER BY c.comment_id ASC LIMIT 21 + +-> Limit: 21 row(s) (actual time=0.447..0.449 rows=21 loops=1)\n -> Sort: snowthing_benchmark_10k.c.comment_id, limit input to 21 row(s) per chunk (actual time=0.447..0.448 rows=21 loops=1)\n -> Stream results (cost=21.1 rows=200) (actual time=0.137..0.377 rows=200 loops=1)\n -> Left hash join (snowthing_benchmark_10k.m.member_id = snowthing_benchmark_10k.c.member_id) (cost=21.1 rows=200) (actual time=0.131..0.303 rows=200 loops=1)\n -> Index lookup on c using idx_comment_post_parent_id (post_id=2100, parent_id=NULL), with index condition: (snowthing_benchmark_10k.c.parent_id is null) (cost=83.2 rows=200) (actual time=0.106..0.261 rows=200 loops=1)\n -> Hash\n -> Table scan on m (cost=0.00578 rows=1) (actual time=0.0147..0.0167 rows=1 loops=1)\n + +``` + +### 인덱스 제거 +```text +# 설명 +- 시나리오: root-first-no-index +- 데이터 규모: 10k +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +EXPLAIN +-> Limit: 21 row(s) (cost=2002 rows=21) (actual time=3.31..3.31 rows=20 loops=1)\n -> Sort: snowthing_benchmark_10k.`comment`.created_at, snowthing_benchmark_10k.`comment`.comment_id, limit input to 21 row(s) per chunk (cost=2002 rows=19296) (actual time=3.31..3.31 rows=20 loops=1)\n -> Filter: ((snowthing_benchmark_10k.`comment`.post_id = 1601) and (snowthing_benchmark_10k.`comment`.parent_id is null)) (cost=2002 rows=19296) (actual time=0.0248..3.3 rows=20 loops=1)\n -> Table scan on comment (cost=2002 rows=19296) (actual time=0.0223..2.72 rows=20000 loops=1)\n +``` + +## 10만건 + +### 인덱스 적용 +```text +# 설명 +- 시나리오: root-first +- 데이터 규모: 100k +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +schema=snowthing_benchmark_100k +scale=100k +postId=1900 +hotRootId=684561 +rootMiddleCursor=703560 +rootLastCursor=704539 +replyMiddleCursor=704581 +rootIds=702561,702562,702563,702564,702565,702566,702567,702568,702569,702570,702571,702572,702573,702574,702575,702576,702577,702578,702579,702580 +sql=SELECT c.comment_id,c.post_id,c.parent_id,c.content,c.is_deleted,c.is_anonymous,c.writer_ip,c.created_at,m.public_id member_public_id,m.nickname,m.profile_image_url FROM `snowthing_benchmark_100k`.comment c LEFT JOIN `snowthing_benchmark_100k`.member m ON m.member_id=c.member_id WHERE c.post_id=1900 AND c.parent_id IS NULL ORDER BY c.comment_id ASC LIMIT 21 + +-> Limit: 21 row(s) (actual time=4.46..4.46 rows=21 loops=1)\n -> Sort: snowthing_benchmark_100k.c.comment_id, limit input to 21 row(s) per chunk (actual time=4.46..4.46 rows=21 loops=1)\n -> Stream results (cost=208 rows=2000) (actual time=1.27..4.27 rows=2000 loops=1)\n -> Left hash join (snowthing_benchmark_100k.m.member_id = snowthing_benchmark_100k.c.member_id) (cost=208 rows=2000) (actual time=1.26..3.65 rows=2000 loops=1)\n -> Index lookup on c using idx_comment_post_parent_id (post_id=1900, parent_id=NULL), with index condition: (snowthing_benchmark_100k.c.parent_id is null) (cost=700 rows=2000) (actual time=1.23..3.46 rows=2000 loops=1)\n -> Hash\n -> Table scan on m (cost=0.00421 rows=1) (actual time=0.0155..0.018 rows=1 loops=1)\n + +``` + +### 인덱스 제거 +```text +# 설명 +- 시나리오: root-first-no-index +- 데이터 규모: 100k +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +EXPLAIN +-> Limit: 21 row(s) (cost=9628 rows=21) (actual time=16.3..16.3 rows=0 loops=1)\n -> Sort: snowthing_benchmark_100k.`comment`.created_at, snowthing_benchmark_100k.`comment`.comment_id, limit input to 21 row(s) per chunk (cost=9628 rows=92749) (actual time=16.3..16.3 rows=0 loops=1)\n -> Filter: ((snowthing_benchmark_100k.`comment`.post_id = 1601) and (snowthing_benchmark_100k.`comment`.parent_id is null)) (cost=9628 rows=92749) (actual time=16.3..16.3 rows=0 loops=1)\n -> Table scan on comment (cost=9628 rows=92749) (actual time=0.0234..13.6 rows=100001 loops=1)\n +``` + +## 백만건 + +### 인덱스 적용 +```text +# 설명 +- 시나리오: root-first +- 데이터 규모: 1m +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +schema=snowthing_test +scale=1m +postId=2200 +hotRootId=1329617 +rootMiddleCursor=1519616 +rootLastCursor=1529595 +replyMiddleCursor=1529637 +rootIds=1509617,1509618,1509619,1509620,1509621,1509622,1509623,1509624,1509625,1509626,1509627,1509628,1509629,1509630,1509631,1509632,1509633,1509634,1509635,1509636 +sql=SELECT c.comment_id,c.post_id,c.parent_id,c.content,c.is_deleted,c.is_anonymous,c.writer_ip,c.created_at,m.public_id member_public_id,m.nickname,m.profile_image_url FROM `snowthing_test`.comment c LEFT JOIN `snowthing_test`.member m ON m.member_id=c.member_id WHERE c.post_id=2200 AND c.parent_id IS NULL ORDER BY c.comment_id ASC LIMIT 21 + +-> Limit: 21 row(s) (actual time=57.3..57.3 rows=21 loops=1)\n -> Sort: snowthing_test.c.comment_id, limit input to 21 row(s) per chunk (actual time=57.3..57.3 rows=21 loops=1)\n -> Stream results (cost=10193 rows=96726) (actual time=14.5..55.5 rows=20000 loops=1)\n -> Left hash join (snowthing_test.m.member_id = snowthing_test.c.member_id) (cost=10193 rows=96726) (actual time=14.5..47.7 rows=20000 loops=1)\n -> Index lookup on c using idx_comment_post_parent_id (post_id=2200, parent_id=NULL), with index condition: (snowthing_test.c.parent_id is null) (cost=20593 rows=32242) (actual time=0.109..31.6 rows=20000 loops=1)\n -> Hash\n -> Table scan on m (cost=0.0162 rows=3) (actual time=14.4..14.4 rows=3 loops=1)\n + +``` + +### 인덱스 제거 +```text +# 설명 +- 시나리오: root-first-no-index +- 데이터 규모: 1m +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +EXPLAIN +-> Limit: 21 row(s) (cost=105742 rows=21) (actual time=276..276 rows=21 loops=1)\n -> Sort: snowthing_test.`comment`.created_at, snowthing_test.`comment`.comment_id, limit input to 21 row(s) per chunk (cost=105742 rows=985540) (actual time=276..276 rows=21 loops=1)\n -> Filter: ((snowthing_test.`comment`.post_id = 1601) and (snowthing_test.`comment`.parent_id is null)) (cost=105742 rows=985540) (actual time=0.044..276 rows=2000 loops=1)\n -> Table scan on comment (cost=105742 rows=985540) (actual time=0.0413..248 rows=1e+6 loops=1)\n +``` + diff --git "a/docs/conception/sprint04/benchmark/explain-plans/02-\353\243\250\355\212\270-\354\244\221\352\260\204-\355\216\230\354\235\264\354\247\200.md" "b/docs/conception/sprint04/benchmark/explain-plans/02-\353\243\250\355\212\270-\354\244\221\352\260\204-\355\216\230\354\235\264\354\247\200.md" new file mode 100644 index 0000000..e7bd91c --- /dev/null +++ "b/docs/conception/sprint04/benchmark/explain-plans/02-\353\243\250\355\212\270-\354\244\221\352\260\204-\355\216\230\354\235\264\354\247\200.md" @@ -0,0 +1,156 @@ +# 루트 댓글 중간 커서 페이지 + +이 문서는 MySQL 8.0에서 측정한 실행계획 원문을 데이터 규모별로 묶은 문서입니다. `rows`는 estimated rows와 actual rows를 구분해 읽고, `loops`는 연산 반복 횟수, `Index lookup`의 이름은 선택된 인덱스입니다. `Sort`가 있으면 정렬 단계가 수행됩니다. + +## 1천건 + +### 인덱스 적용 +```text +# 설명 +- 시나리오: root-middle +- 데이터 규모: 1k +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +schema=snowthing_benchmark_1k +scale=1k +postId=2000 +hotRootId=506361 +rootMiddleCursor=506550 +rootLastCursor=506555 +replyMiddleCursor=506581 +rootIds=506541,506542,506543,506544,506545,506546,506547,506548,506549,506550,506551,506552,506553,506554,506555,506556,506557,506558,506559,506560 +sql=SELECT c.comment_id,c.post_id,c.parent_id,c.content,c.is_deleted,c.is_anonymous,c.writer_ip,c.created_at,m.public_id member_public_id,m.nickname,m.profile_image_url FROM `snowthing_benchmark_1k`.comment c LEFT JOIN `snowthing_benchmark_1k`.member m ON m.member_id=c.member_id WHERE c.post_id=2000 AND c.parent_id IS NULL AND c.comment_id>506550 ORDER BY c.comment_id ASC LIMIT 21 + +-> Limit: 21 row(s) (actual time=0.0963..0.0972 rows=10 loops=1)\n -> Sort: snowthing_benchmark_1k.c.comment_id, limit input to 21 row(s) per chunk (actual time=0.0959..0.0965 rows=10 loops=1)\n -> Stream results (cost=1.33 rows=10) (actual time=0.0507..0.0879 rows=10 loops=1)\n -> Left hash join (snowthing_benchmark_1k.m.member_id = snowthing_benchmark_1k.c.member_id) (cost=1.33 rows=10) (actual time=0.0449..0.0784 rows=10 loops=1)\n -> Index range scan on c using idx_comment_post_parent_id over (post_id = 2000 AND parent_id = NULL AND 506550 < comment_id), with index condition: ((snowthing_benchmark_1k.c.post_id = 2000) and (snowthing_benchmark_1k.c.parent_id is null) and (snowthing_benchmark_1k.c.comment_id > 506550)) (cost=4.76 rows=10) (actual time=0.0225..0.0548 rows=10 loops=1)\n -> Hash\n -> Table scan on m (cost=0.039 rows=1) (actual time=0.013..0.0149 rows=1 loops=1)\n + +``` + +### 인덱스 제거 +```text +# 설명 +- 시나리오: root-middle-no-index +- 데이터 규모: 1k +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +EXPLAIN +-> Limit: 21 row(s) (cost=199 rows=21) (actual time=1.23..1.23 rows=1 loops=1)\n -> Sort: snowthing_benchmark_1k.`comment`.created_at, snowthing_benchmark_1k.`comment`.comment_id, limit input to 21 row(s) per chunk (cost=199 rows=974) (actual time=1.23..1.23 rows=1 loops=1)\n -> Filter: ((snowthing_benchmark_1k.`comment`.post_id = 1601) and (snowthing_benchmark_1k.`comment`.parent_id is null) and (snowthing_benchmark_1k.`comment`.comment_id > 304561)) (cost=199 rows=974) (actual time=0.0399..1.22 rows=1 loops=1)\n -> Index range scan on comment using PRIMARY over (304561 < comment_id) (cost=199 rows=974) (actual time=0.0226..1.16 rows=1998 loops=1)\n +``` + +## 1만건 + +### 인덱스 적용 +```text +# 설명 +- 시나리오: root-middle +- 데이터 규모: 10k +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +schema=snowthing_benchmark_10k +scale=10k +postId=2100 +hotRootId=531701 +rootMiddleCursor=533600 +rootLastCursor=533679 +replyMiddleCursor=533721 +rootIds=533501,533502,533503,533504,533505,533506,533507,533508,533509,533510,533511,533512,533513,533514,533515,533516,533517,533518,533519,533520 +sql=SELECT c.comment_id,c.post_id,c.parent_id,c.content,c.is_deleted,c.is_anonymous,c.writer_ip,c.created_at,m.public_id member_public_id,m.nickname,m.profile_image_url FROM `snowthing_benchmark_10k`.comment c LEFT JOIN `snowthing_benchmark_10k`.member m ON m.member_id=c.member_id WHERE c.post_id=2100 AND c.parent_id IS NULL AND c.comment_id>533600 ORDER BY c.comment_id ASC LIMIT 21 + +-> Limit: 21 row(s) (actual time=0.245..0.247 rows=21 loops=1)\n -> Sort: snowthing_benchmark_10k.c.comment_id, limit input to 21 row(s) per chunk (actual time=0.245..0.246 rows=21 loops=1)\n -> Stream results (cost=10.7 rows=100) (actual time=0.0483..0.183 rows=100 loops=1)\n -> Left hash join (snowthing_benchmark_10k.m.member_id = snowthing_benchmark_10k.c.member_id) (cost=10.7 rows=100) (actual time=0.0427..0.137 rows=100 loops=1)\n -> Index range scan on c using idx_comment_post_parent_id over (post_id = 2100 AND parent_id = NULL AND 533600 < comment_id), with index condition: ((snowthing_benchmark_10k.c.post_id = 2100) and (snowthing_benchmark_10k.c.parent_id is null) and (snowthing_benchmark_10k.c.comment_id > 533600)) (cost=51.3 rows=100) (actual time=0.0235..0.108 rows=100 loops=1)\n -> Hash\n -> Table scan on m (cost=0.00753 rows=1) (actual time=0.0111..0.013 rows=1 loops=1)\n + +``` + +### 인덱스 제거 +```text +# 설명 +- 시나리오: root-middle-no-index +- 데이터 규모: 10k +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +EXPLAIN +-> Limit: 21 row(s) (cost=1952 rows=21) (actual time=9.12..9.12 rows=19 loops=1)\n -> Sort: snowthing_benchmark_10k.`comment`.created_at, snowthing_benchmark_10k.`comment`.comment_id, limit input to 21 row(s) per chunk (cost=1952 rows=9648) (actual time=9.12..9.12 rows=19 loops=1)\n -> Filter: ((snowthing_benchmark_10k.`comment`.post_id = 1601) and (snowthing_benchmark_10k.`comment`.parent_id is null) and (snowthing_benchmark_10k.`comment`.comment_id > 304561)) (cost=1952 rows=9648) (actual time=0.0554..9.11 rows=19 loops=1)\n -> Index range scan on comment using PRIMARY over (304561 < comment_id) (cost=1952 rows=9648) (actual time=0.0378..8.57 rows=19998 loops=1)\n +``` + +## 10만건 + +### 인덱스 적용 +```text +# 설명 +- 시나리오: root-middle +- 데이터 규모: 100k +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +schema=snowthing_benchmark_100k +scale=100k +postId=1900 +hotRootId=684561 +rootMiddleCursor=703560 +rootLastCursor=704539 +replyMiddleCursor=704581 +rootIds=702561,702562,702563,702564,702565,702566,702567,702568,702569,702570,702571,702572,702573,702574,702575,702576,702577,702578,702579,702580 +sql=SELECT c.comment_id,c.post_id,c.parent_id,c.content,c.is_deleted,c.is_anonymous,c.writer_ip,c.created_at,m.public_id member_public_id,m.nickname,m.profile_image_url FROM `snowthing_benchmark_100k`.comment c LEFT JOIN `snowthing_benchmark_100k`.member m ON m.member_id=c.member_id WHERE c.post_id=1900 AND c.parent_id IS NULL AND c.comment_id>703560 ORDER BY c.comment_id ASC LIMIT 21 + +-> Limit: 21 row(s) (actual time=1.4..1.4 rows=21 loops=1)\n -> Sort: snowthing_benchmark_100k.c.comment_id, limit input to 21 row(s) per chunk (actual time=1.4..1.4 rows=21 loops=1)\n -> Stream results (cost=104 rows=1000) (actual time=0.0608..1.3 rows=1000 loops=1)\n -> Left hash join (snowthing_benchmark_100k.m.member_id = snowthing_benchmark_100k.c.member_id) (cost=104 rows=1000) (actual time=0.0554..0.966 rows=1000 loops=1)\n -> Index range scan on c using idx_comment_post_parent_id over (post_id = 1900 AND parent_id = NULL AND 703560 < comment_id), with index condition: ((snowthing_benchmark_100k.c.post_id = 1900) and (snowthing_benchmark_100k.c.parent_id is null) and (snowthing_benchmark_100k.c.comment_id > 703560)) (cost=450 rows=1000) (actual time=0.0355..0.861 rows=1000 loops=1)\n -> Hash\n -> Table scan on m (cost=0.00438 rows=1) (actual time=0.0111..0.0132 rows=1 loops=1)\n + +``` + +### 인덱스 제거 +```text +# 설명 +- 시나리오: root-middle-no-index +- 데이터 규모: 100k +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +EXPLAIN +-> Limit: 21 row(s) (cost=9309 rows=21) (actual time=17.8..17.8 rows=0 loops=1)\n -> Sort: snowthing_benchmark_100k.`comment`.created_at, snowthing_benchmark_100k.`comment`.comment_id, limit input to 21 row(s) per chunk (cost=9309 rows=46374) (actual time=17.8..17.8 rows=0 loops=1)\n -> Filter: ((snowthing_benchmark_100k.`comment`.post_id = 1601) and (snowthing_benchmark_100k.`comment`.parent_id is null) and (snowthing_benchmark_100k.`comment`.comment_id > 304561)) (cost=9309 rows=46374) (actual time=17.8..17.8 rows=0 loops=1)\n -> Index range scan on comment using PRIMARY over (304561 < comment_id) (cost=9309 rows=46374) (actual time=0.0272..14.9 rows=100000 loops=1)\n +``` + +## 백만건 + +### 인덱스 적용 +```text +# 설명 +- 시나리오: root-middle +- 데이터 규모: 1m +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +schema=snowthing_test +scale=1m +postId=2200 +hotRootId=1329617 +rootMiddleCursor=1519616 +rootLastCursor=1529595 +replyMiddleCursor=1529637 +rootIds=1509617,1509618,1509619,1509620,1509621,1509622,1509623,1509624,1509625,1509626,1509627,1509628,1509629,1509630,1509631,1509632,1509633,1509634,1509635,1509636 +sql=SELECT c.comment_id,c.post_id,c.parent_id,c.content,c.is_deleted,c.is_anonymous,c.writer_ip,c.created_at,m.public_id member_public_id,m.nickname,m.profile_image_url FROM `snowthing_test`.comment c LEFT JOIN `snowthing_test`.member m ON m.member_id=c.member_id WHERE c.post_id=2200 AND c.parent_id IS NULL AND c.comment_id>1519616 ORDER BY c.comment_id ASC LIMIT 21 + +-> Limit: 21 row(s) (actual time=23..23 rows=21 loops=1)\n -> Sort: snowthing_test.c.comment_id, limit input to 21 row(s) per chunk (actual time=23..23 rows=21 loops=1)\n -> Stream results (cost=5621 rows=55464) (actual time=3.22..22.1 rows=10000 loops=1)\n -> Left hash join (snowthing_test.m.member_id = snowthing_test.c.member_id) (cost=5621 rows=55464) (actual time=3.21..17.8 rows=10000 loops=1)\n -> Index range scan on c using idx_comment_post_parent_id over (post_id = 2200 AND parent_id = NULL AND 1519616 < comment_id), with index condition: ((snowthing_test.c.post_id = 2200) and (snowthing_test.c.parent_id is null) and (snowthing_test.c.comment_id > 1519616)) (cost=13086 rows=18488) (actual time=3.18..16.8 rows=10000 loops=1)\n -> Hash\n -> Table scan on m (cost=0.00406 rows=3) (actual time=0.0135..0.017 rows=3 loops=1)\n + +``` + +### 인덱스 제거 +```text +# 설명 +- 시나리오: root-middle-no-index +- 데이터 규모: 1m +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +EXPLAIN +-> Limit: 21 row(s) (cost=99618 rows=21) (actual time=377..377 rows=21 loops=1)\n -> Sort: snowthing_test.`comment`.created_at, snowthing_test.`comment`.comment_id, limit input to 21 row(s) per chunk (cost=99618 rows=492770) (actual time=377..377 rows=21 loops=1)\n -> Filter: ((snowthing_test.`comment`.post_id = 1601) and (snowthing_test.`comment`.parent_id is null) and (snowthing_test.`comment`.comment_id > 304561)) (cost=99618 rows=492770) (actual time=0.039..377 rows=1999 loops=1)\n -> Index range scan on comment using PRIMARY over (304561 < comment_id) (cost=99618 rows=492770) (actual time=0.0218..349 rows=999999 loops=1)\n +``` + diff --git "a/docs/conception/sprint04/benchmark/explain-plans/03-\353\243\250\355\212\270-\353\247\210\354\247\200\353\247\211-\355\216\230\354\235\264\354\247\200.md" "b/docs/conception/sprint04/benchmark/explain-plans/03-\353\243\250\355\212\270-\353\247\210\354\247\200\353\247\211-\355\216\230\354\235\264\354\247\200.md" new file mode 100644 index 0000000..5aa1aeb --- /dev/null +++ "b/docs/conception/sprint04/benchmark/explain-plans/03-\353\243\250\355\212\270-\353\247\210\354\247\200\353\247\211-\355\216\230\354\235\264\354\247\200.md" @@ -0,0 +1,156 @@ +# 루트 댓글 마지막 커서 페이지 + +이 문서는 MySQL 8.0에서 측정한 실행계획 원문을 데이터 규모별로 묶은 문서입니다. `rows`는 estimated rows와 actual rows를 구분해 읽고, `loops`는 연산 반복 횟수, `Index lookup`의 이름은 선택된 인덱스입니다. `Sort`가 있으면 정렬 단계가 수행됩니다. + +## 1천건 + +### 인덱스 적용 +```text +# 설명 +- 시나리오: root-last +- 데이터 규모: 1k +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +schema=snowthing_benchmark_1k +scale=1k +postId=2000 +hotRootId=506361 +rootMiddleCursor=506550 +rootLastCursor=506555 +replyMiddleCursor=506581 +rootIds=506541,506542,506543,506544,506545,506546,506547,506548,506549,506550,506551,506552,506553,506554,506555,506556,506557,506558,506559,506560 +sql=SELECT c.comment_id,c.post_id,c.parent_id,c.content,c.is_deleted,c.is_anonymous,c.writer_ip,c.created_at,m.public_id member_public_id,m.nickname,m.profile_image_url FROM `snowthing_benchmark_1k`.comment c LEFT JOIN `snowthing_benchmark_1k`.member m ON m.member_id=c.member_id WHERE c.post_id=2000 AND c.parent_id IS NULL AND c.comment_id>506555 ORDER BY c.comment_id ASC LIMIT 21 + +-> Limit: 21 row(s) (actual time=0.0712..0.0717 rows=5 loops=1)\n -> Sort: snowthing_benchmark_1k.c.comment_id, limit input to 21 row(s) per chunk (actual time=0.0708..0.0712 rows=5 loops=1)\n -> Stream results (cost=0.844 rows=5) (actual time=0.041..0.0634 rows=5 loops=1)\n -> Left hash join (snowthing_benchmark_1k.m.member_id = snowthing_benchmark_1k.c.member_id) (cost=0.844 rows=5) (actual time=0.0354..0.0559 rows=5 loops=1)\n -> Index range scan on c using idx_comment_post_parent_id over (post_id = 2000 AND parent_id = NULL AND 506555 < comment_id), with index condition: ((snowthing_benchmark_1k.c.post_id = 2000) and (snowthing_benchmark_1k.c.parent_id is null) and (snowthing_benchmark_1k.c.comment_id > 506555)) (cost=2.51 rows=5) (actual time=0.0148..0.0345 rows=5 loops=1)\n -> Hash\n -> Table scan on m (cost=0.074 rows=1) (actual time=0.0122..0.0139 rows=1 loops=1)\n + +``` + +### 인덱스 제거 +```text +# 설명 +- 시나리오: root-last-no-index +- 데이터 규모: 1k +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +EXPLAIN +-> Limit: 21 row(s) (cost=202 rows=21) (actual time=0.366..0.366 rows=2 loops=1)\n -> Sort: snowthing_benchmark_1k.`comment`.created_at DESC, snowthing_benchmark_1k.`comment`.comment_id DESC, limit input to 21 row(s) per chunk (cost=202 rows=1948) (actual time=0.366..0.366 rows=2 loops=1)\n -> Filter: ((snowthing_benchmark_1k.`comment`.post_id = 1601) and (snowthing_benchmark_1k.`comment`.parent_id is null)) (cost=202 rows=1948) (actual time=0.029..0.358 rows=2 loops=1)\n -> Table scan on comment (cost=202 rows=1948) (actual time=0.026..0.298 rows=2000 loops=1)\n +``` + +## 1만건 + +### 인덱스 적용 +```text +# 설명 +- 시나리오: root-last +- 데이터 규모: 10k +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +schema=snowthing_benchmark_10k +scale=10k +postId=2100 +hotRootId=531701 +rootMiddleCursor=533600 +rootLastCursor=533679 +replyMiddleCursor=533721 +rootIds=533501,533502,533503,533504,533505,533506,533507,533508,533509,533510,533511,533512,533513,533514,533515,533516,533517,533518,533519,533520 +sql=SELECT c.comment_id,c.post_id,c.parent_id,c.content,c.is_deleted,c.is_anonymous,c.writer_ip,c.created_at,m.public_id member_public_id,m.nickname,m.profile_image_url FROM `snowthing_benchmark_10k`.comment c LEFT JOIN `snowthing_benchmark_10k`.member m ON m.member_id=c.member_id WHERE c.post_id=2100 AND c.parent_id IS NULL AND c.comment_id>533679 ORDER BY c.comment_id ASC LIMIT 21 + +-> Limit: 21 row(s) (actual time=0.0888..0.091 rows=21 loops=1)\n -> Sort: snowthing_benchmark_10k.c.comment_id, limit input to 21 row(s) per chunk (actual time=0.0885..0.09 rows=21 loops=1)\n -> Stream results (cost=2.46 rows=21) (actual time=0.052..0.0789 rows=21 loops=1)\n -> Left hash join (snowthing_benchmark_10k.m.member_id = snowthing_benchmark_10k.c.member_id) (cost=2.46 rows=21) (actual time=0.0463..0.0661 rows=21 loops=1)\n -> Index range scan on c using idx_comment_post_parent_id over (post_id = 2100 AND parent_id = NULL AND 533679 < comment_id), with index condition: ((snowthing_benchmark_10k.c.post_id = 2100) and (snowthing_benchmark_10k.c.parent_id is null) and (snowthing_benchmark_10k.c.comment_id > 533679)) (cost=11 rows=21) (actual time=0.0151..0.0326 rows=21 loops=1)\n -> Hash\n -> Table scan on m (cost=0.0207 rows=1) (actual time=0.0227..0.0245 rows=1 loops=1)\n + +``` + +### 인덱스 제거 +```text +# 설명 +- 시나리오: root-last-no-index +- 데이터 규모: 10k +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +EXPLAIN +-> Limit: 21 row(s) (cost=2002 rows=21) (actual time=3.24..3.25 rows=20 loops=1)\n -> Sort: snowthing_benchmark_10k.`comment`.created_at DESC, snowthing_benchmark_10k.`comment`.comment_id DESC, limit input to 21 row(s) per chunk (cost=2002 rows=19296) (actual time=3.24..3.24 rows=20 loops=1)\n -> Filter: ((snowthing_benchmark_10k.`comment`.post_id = 1601) and (snowthing_benchmark_10k.`comment`.parent_id is null)) (cost=2002 rows=19296) (actual time=0.0243..3.23 rows=20 loops=1)\n -> Table scan on comment (cost=2002 rows=19296) (actual time=0.0221..2.67 rows=20000 loops=1)\n +``` + +## 10만건 + +### 인덱스 적용 +```text +# 설명 +- 시나리오: root-last +- 데이터 규모: 100k +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +schema=snowthing_benchmark_100k +scale=100k +postId=1900 +hotRootId=684561 +rootMiddleCursor=703560 +rootLastCursor=704539 +replyMiddleCursor=704581 +rootIds=702561,702562,702563,702564,702565,702566,702567,702568,702569,702570,702571,702572,702573,702574,702575,702576,702577,702578,702579,702580 +sql=SELECT c.comment_id,c.post_id,c.parent_id,c.content,c.is_deleted,c.is_anonymous,c.writer_ip,c.created_at,m.public_id member_public_id,m.nickname,m.profile_image_url FROM `snowthing_benchmark_100k`.comment c LEFT JOIN `snowthing_benchmark_100k`.member m ON m.member_id=c.member_id WHERE c.post_id=1900 AND c.parent_id IS NULL AND c.comment_id>704539 ORDER BY c.comment_id ASC LIMIT 21 + +-> Limit: 21 row(s) (actual time=0.0703..0.0719 rows=21 loops=1)\n -> Sort: snowthing_benchmark_100k.c.comment_id, limit input to 21 row(s) per chunk (actual time=0.07..0.071 rows=21 loops=1)\n -> Stream results (cost=2.46 rows=21) (actual time=0.0376..0.0615 rows=21 loops=1)\n -> Left hash join (snowthing_benchmark_100k.m.member_id = snowthing_benchmark_100k.c.member_id) (cost=2.46 rows=21) (actual time=0.0321..0.0492 rows=21 loops=1)\n -> Index range scan on c using idx_comment_post_parent_id over (post_id = 1900 AND parent_id = NULL AND 704539 < comment_id), with index condition: ((snowthing_benchmark_100k.c.post_id = 1900) and (snowthing_benchmark_100k.c.parent_id is null) and (snowthing_benchmark_100k.c.comment_id > 704539)) (cost=9.71 rows=21) (actual time=0.0129..0.0279 rows=21 loops=1)\n -> Hash\n -> Table scan on m (cost=0.0207 rows=1) (actual time=0.0113..0.0131 rows=1 loops=1)\n + +``` + +### 인덱스 제거 +```text +# 설명 +- 시나리오: root-last-no-index +- 데이터 규모: 100k +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +EXPLAIN +-> Limit: 21 row(s) (cost=9628 rows=21) (actual time=16.3..16.3 rows=0 loops=1)\n -> Sort: snowthing_benchmark_100k.`comment`.created_at DESC, snowthing_benchmark_100k.`comment`.comment_id DESC, limit input to 21 row(s) per chunk (cost=9628 rows=92749) (actual time=16.3..16.3 rows=0 loops=1)\n -> Filter: ((snowthing_benchmark_100k.`comment`.post_id = 1601) and (snowthing_benchmark_100k.`comment`.parent_id is null)) (cost=9628 rows=92749) (actual time=16.3..16.3 rows=0 loops=1)\n -> Table scan on comment (cost=9628 rows=92749) (actual time=0.0236..13.5 rows=100001 loops=1)\n +``` + +## 백만건 + +### 인덱스 적용 +```text +# 설명 +- 시나리오: root-last +- 데이터 규모: 1m +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +schema=snowthing_test +scale=1m +postId=2200 +hotRootId=1329617 +rootMiddleCursor=1519616 +rootLastCursor=1529595 +replyMiddleCursor=1529637 +rootIds=1509617,1509618,1509619,1509620,1509621,1509622,1509623,1509624,1509625,1509626,1509627,1509628,1509629,1509630,1509631,1509632,1509633,1509634,1509635,1509636 +sql=SELECT c.comment_id,c.post_id,c.parent_id,c.content,c.is_deleted,c.is_anonymous,c.writer_ip,c.created_at,m.public_id member_public_id,m.nickname,m.profile_image_url FROM `snowthing_test`.comment c LEFT JOIN `snowthing_test`.member m ON m.member_id=c.member_id WHERE c.post_id=2200 AND c.parent_id IS NULL AND c.comment_id>1529595 ORDER BY c.comment_id ASC LIMIT 21 + +-> Limit: 21 row(s) (actual time=0.216..0.218 rows=21 loops=1)\n -> Sort: snowthing_test.c.comment_id, limit input to 21 row(s) per chunk (actual time=0.216..0.217 rows=21 loops=1)\n -> Stream results (cost=6.66 rows=63) (actual time=0.054..0.128 rows=21 loops=1)\n -> Left hash join (snowthing_test.m.member_id = snowthing_test.c.member_id) (cost=6.66 rows=63) (actual time=0.0475..0.108 rows=21 loops=1)\n -> Index range scan on c using idx_comment_post_parent_id over (post_id = 2200 AND parent_id = NULL AND 1529595 < comment_id), with index condition: ((snowthing_test.c.post_id = 2200) and (snowthing_test.c.parent_id is null) and (snowthing_test.c.comment_id > 1529595)) (cost=16.7 rows=21) (actual time=0.0234..0.0802 rows=21 loops=1)\n -> Hash\n -> Table scan on m (cost=0.0302 rows=3) (actual time=0.0141..0.017 rows=3 loops=1)\n + +``` + +### 인덱스 제거 +```text +# 설명 +- 시나리오: root-last-no-index +- 데이터 규모: 1m +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +EXPLAIN +-> Limit: 21 row(s) (cost=107036 rows=21) (actual time=278..278 rows=21 loops=1)\n -> Sort: snowthing_test.`comment`.created_at DESC, snowthing_test.`comment`.comment_id DESC, limit input to 21 row(s) per chunk (cost=107036 rows=985540) (actual time=278..278 rows=21 loops=1)\n -> Filter: ((snowthing_test.`comment`.post_id = 1601) and (snowthing_test.`comment`.parent_id is null)) (cost=107036 rows=985540) (actual time=0.0456..277 rows=2000 loops=1)\n -> Table scan on comment (cost=107036 rows=985540) (actual time=0.0427..250 rows=1e+6 loops=1)\n +``` + diff --git "a/docs/conception/sprint04/benchmark/explain-plans/04-\353\214\200\353\214\223\352\270\200-\354\203\201\354\234\2045\352\260\234.md" "b/docs/conception/sprint04/benchmark/explain-plans/04-\353\214\200\353\214\223\352\270\200-\354\203\201\354\234\2045\352\260\234.md" new file mode 100644 index 0000000..88bdf0b --- /dev/null +++ "b/docs/conception/sprint04/benchmark/explain-plans/04-\353\214\200\353\214\223\352\270\200-\354\203\201\354\234\2045\352\260\234.md" @@ -0,0 +1,156 @@ +# 현재 루트 20개의 대댓글 Top-5 일괄 조회 + +이 문서는 MySQL 8.0에서 측정한 실행계획 원문을 데이터 규모별로 묶은 문서입니다. `rows`는 estimated rows와 actual rows를 구분해 읽고, `loops`는 연산 반복 횟수, `Index lookup`의 이름은 선택된 인덱스입니다. `Sort`가 있으면 정렬 단계가 수행됩니다. + +## 1천건 + +### 인덱스 적용 +```text +# 설명 +- 시나리오: 루트 20개 대댓글 Top-5 일괄 조회 +- 데이터 규모: 1k +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +schema=snowthing_benchmark_1k +scale=1k +postId=2000 +hotRootId=506361 +rootMiddleCursor=506550 +rootLastCursor=506555 +replyMiddleCursor=506581 +rootIds=506541,506542,506543,506544,506545,506546,506547,506548,506549,506550,506551,506552,506553,506554,506555,506556,506557,506558,506559,506560 +sql=SELECT r.comment_id,r.post_id,r.parent_id,r.content,r.is_deleted,r.is_anonymous,r.writer_ip,r.created_at,m.public_id member_public_id,m.nickname,m.profile_image_url FROM `snowthing_benchmark_1k`.comment root CROSS JOIN LATERAL (SELECT c.comment_id,c.post_id,c.parent_id,c.content,c.is_deleted,c.is_anonymous,c.writer_ip,c.created_at,c.member_id FROM `snowthing_benchmark_1k`.comment c WHERE c.parent_id=root.comment_id ORDER BY c.comment_id ASC LIMIT 5) r LEFT JOIN `snowthing_benchmark_1k`.member m ON m.member_id=r.member_id WHERE root.comment_id IN (506541,506542,506543,506544,506545,506546,506547,506548,506549,506550,506551,506552,506553,506554,506555,506556,506557,506558,506559,506560) ORDER BY root.comment_id ASC,r.comment_id ASC + +-> Sort: snowthing_benchmark_1k.root.comment_id, r.comment_id (actual time=0.691..0.694 rows=80 loops=1)\n -> Stream results (cost=4.37 rows=39.5) (actual time=0.163..0.666 rows=80 loops=1)\n -> Left hash join (snowthing_benchmark_1k.m.member_id = r.member_id) (cost=4.37 rows=39.5) (actual time=0.16..0.634 rows=80 loops=1)\n -> Nested loop inner join (cost=64.4 rows=39.5) (actual time=0.0967..0.561 rows=80 loops=1)\n -> Invalidate materialized tables (row from root) (cost=9.02 rows=20) (actual time=0.0185..0.0838 rows=20 loops=1)\n -> Filter: (snowthing_benchmark_1k.root.comment_id in (506541,506542,506543,506544,506545,506546,506547,506548,506549,506550,506551,506552,506553,506554,506555,506556,506557,506558,506559,506560)) (cost=9.02 rows=20) (actual time=0.0181..0.0825 rows=20 loops=1)\n -> Covering index range scan on root using PRIMARY over (comment_id = 506541) OR (comment_id = 506542) OR (18 more) (cost=9.02 rows=20) (actual time=0.017..0.0791 rows=20 loops=1)\n -> Table scan on r (cost=2.16..3.4 rows=1.97) (actual time=0.0232..0.0236 rows=4 loops=20)\n -> Materialize (invalidate on row from root) (cost=0.888..0.888 rows=1.97) (actual time=0.0229..0.0229 rows=4 loops=20)\n -> Limit: 5 row(s) (cost=0.691 rows=1.97) (actual time=0.017..0.0174 rows=4 loops=20)\n -> Sort: snowthing_benchmark_1k.c.comment_id, limit input to 5 row(s) per chunk (cost=0.691 rows=1.97) (actual time=0.0169..0.0172 rows=4 loops=20)\n -> Index lookup on c using idx_comment_parent_deleted_id (parent_id=snowthing_benchmark_1k.root.comment_id) (cost=0.691 rows=1.97) (actual time=0.0154..0.0158 rows=4 loops=20)\n -> Hash\n -> Table scan on m (cost=0.0128 rows=1) (actual time=0.0314..0.0346 rows=1 loops=1)\n + +``` + +### 인덱스 제거 +```text +# 설명 +- 시나리오: reply-top5-no-index +- 데이터 규모: 1k +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +EXPLAIN +-> Limit: 25 row(s) (cost=202 rows=25) (actual time=0.333..0.334 rows=12 loops=1)\n -> Sort: snowthing_benchmark_1k.`comment`.parent_id, snowthing_benchmark_1k.`comment`.created_at, snowthing_benchmark_1k.`comment`.comment_id, limit input to 25 row(s) per chunk (cost=202 rows=1948) (actual time=0.333..0.333 rows=12 loops=1)\n -> Filter: (snowthing_benchmark_1k.`comment`.parent_id in (304561,304562,304563,304564,304565)) (cost=202 rows=1948) (actual time=0.0568..0.325 rows=12 loops=1)\n -> Table scan on comment (cost=202 rows=1948) (actual time=0.0224..0.277 rows=2000 loops=1)\n +``` + +## 1만건 + +### 인덱스 적용 +```text +# 설명 +- 시나리오: 루트 20개 대댓글 Top-5 일괄 조회 +- 데이터 규모: 10k +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +schema=snowthing_benchmark_10k +scale=10k +postId=2100 +hotRootId=531701 +rootMiddleCursor=533600 +rootLastCursor=533679 +replyMiddleCursor=533721 +rootIds=533501,533502,533503,533504,533505,533506,533507,533508,533509,533510,533511,533512,533513,533514,533515,533516,533517,533518,533519,533520 +sql=SELECT r.comment_id,r.post_id,r.parent_id,r.content,r.is_deleted,r.is_anonymous,r.writer_ip,r.created_at,m.public_id member_public_id,m.nickname,m.profile_image_url FROM `snowthing_benchmark_10k`.comment root CROSS JOIN LATERAL (SELECT c.comment_id,c.post_id,c.parent_id,c.content,c.is_deleted,c.is_anonymous,c.writer_ip,c.created_at,c.member_id FROM `snowthing_benchmark_10k`.comment c WHERE c.parent_id=root.comment_id ORDER BY c.comment_id ASC LIMIT 5) r LEFT JOIN `snowthing_benchmark_10k`.member m ON m.member_id=r.member_id WHERE root.comment_id IN (533501,533502,533503,533504,533505,533506,533507,533508,533509,533510,533511,533512,533513,533514,533515,533516,533517,533518,533519,533520) ORDER BY root.comment_id ASC,r.comment_id ASC + +-> Sort: snowthing_benchmark_10k.root.comment_id, r.comment_id (actual time=1.62..1.62 rows=80 loops=1)\n -> Stream results (cost=4.44 rows=40.1) (actual time=1.28..1.55 rows=80 loops=1)\n -> Left hash join (snowthing_benchmark_10k.m.member_id = r.member_id) (cost=4.44 rows=40.1) (actual time=1.27..1.52 rows=80 loops=1)\n -> Nested loop inner join (cost=65.8 rows=40.1) (actual time=1.24..1.48 rows=80 loops=1)\n -> Invalidate materialized tables (row from root) (cost=10.2 rows=20) (actual time=0.0107..0.0407 rows=20 loops=1)\n -> Filter: (snowthing_benchmark_10k.root.comment_id in (533501,533502,533503,533504,533505,533506,533507,533508,533509,533510,533511,533512,533513,533514,533515,533516,533517,533518,533519,533520)) (cost=10.2 rows=20) (actual time=0.0105..0.0396 rows=20 loops=1)\n -> Covering index range scan on root using PRIMARY over (comment_id = 533501) OR (comment_id = 533502) OR (18 more) (cost=10.2 rows=20) (actual time=0.00975..0.0354 rows=20 loops=1)\n -> Table scan on r (cost=2.28..3.55 rows=2.01) (actual time=0.0714..0.0717 rows=4 loops=20)\n -> Materialize (invalidate on row from root) (cost=1.02..1.02 rows=2.01) (actual time=0.0711..0.0711 rows=4 loops=20)\n -> Limit: 5 row(s) (cost=0.822 rows=2.01) (actual time=0.0673..0.0676 rows=4 loops=20)\n -> Sort: snowthing_benchmark_10k.c.comment_id, limit input to 5 row(s) per chunk (cost=0.822 rows=2.01) (actual time=0.0672..0.0674 rows=4 loops=20)\n -> Index lookup on c using idx_comment_parent_deleted_id (parent_id=snowthing_benchmark_10k.root.comment_id) (cost=0.822 rows=2.01) (actual time=0.0652..0.0657 rows=4 loops=20)\n -> Hash\n -> Table scan on m (cost=0.0128 rows=1) (actual time=0.0184..0.0208 rows=1 loops=1)\n + +``` + +### 인덱스 제거 +```text +# 설명 +- 시나리오: reply-top5-no-index +- 데이터 규모: 10k +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +EXPLAIN +-> Limit: 25 row(s) (cost=2002 rows=25) (actual time=3.02..3.02 rows=25 loops=1)\n -> Sort: snowthing_benchmark_10k.`comment`.parent_id, snowthing_benchmark_10k.`comment`.created_at, snowthing_benchmark_10k.`comment`.comment_id, limit input to 25 row(s) per chunk (cost=2002 rows=19296) (actual time=3.02..3.02 rows=25 loops=1)\n -> Filter: (snowthing_benchmark_10k.`comment`.parent_id in (304561,304562,304563,304564,304565)) (cost=2002 rows=19296) (actual time=0.332..3 rows=84 loops=1)\n -> Table scan on comment (cost=2002 rows=19296) (actual time=0.0214..2.53 rows=20000 loops=1)\n +``` + +## 10만건 + +### 인덱스 적용 +```text +# 설명 +- 시나리오: 루트 20개 대댓글 Top-5 일괄 조회 +- 데이터 규모: 100k +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +schema=snowthing_benchmark_100k +scale=100k +postId=1900 +hotRootId=684561 +rootMiddleCursor=703560 +rootLastCursor=704539 +replyMiddleCursor=704581 +rootIds=702561,702562,702563,702564,702565,702566,702567,702568,702569,702570,702571,702572,702573,702574,702575,702576,702577,702578,702579,702580 +sql=SELECT r.comment_id,r.post_id,r.parent_id,r.content,r.is_deleted,r.is_anonymous,r.writer_ip,r.created_at,m.public_id member_public_id,m.nickname,m.profile_image_url FROM `snowthing_benchmark_100k`.comment root CROSS JOIN LATERAL (SELECT c.comment_id,c.post_id,c.parent_id,c.content,c.is_deleted,c.is_anonymous,c.writer_ip,c.created_at,c.member_id FROM `snowthing_benchmark_100k`.comment c WHERE c.parent_id=root.comment_id ORDER BY c.comment_id ASC LIMIT 5) r LEFT JOIN `snowthing_benchmark_100k`.member m ON m.member_id=r.member_id WHERE root.comment_id IN (702561,702562,702563,702564,702565,702566,702567,702568,702569,702570,702571,702572,702573,702574,702575,702576,702577,702578,702579,702580) ORDER BY root.comment_id ASC,r.comment_id ASC + +-> Sort: snowthing_benchmark_100k.root.comment_id, r.comment_id (actual time=7.1..7.1 rows=80 loops=1)\n -> Stream results (cost=9.64 rows=90.6) (actual time=6.53..7.02 rows=80 loops=1)\n -> Left hash join (snowthing_benchmark_100k.m.member_id = r.member_id) (cost=9.64 rows=90.6) (actual time=6.53..6.98 rows=80 loops=1)\n -> Nested loop inner join (cost=71.1 rows=90.6) (actual time=6.5..6.94 rows=80 loops=1)\n -> Invalidate materialized tables (row from root) (cost=9.02 rows=20) (actual time=0.0111..0.0409 rows=20 loops=1)\n -> Filter: (snowthing_benchmark_100k.root.comment_id in (702561,702562,702563,702564,702565,702566,702567,702568,702569,702570,702571,702572,702573,702574,702575,702576,702577,702578,702579,702580)) (cost=9.02 rows=20) (actual time=0.0108..0.0396 rows=20 loops=1)\n -> Covering index range scan on root using PRIMARY over (comment_id = 702561) OR (comment_id = 702562) OR (18 more) (cost=9.02 rows=20) (actual time=0.00986..0.0349 rows=20 loops=1)\n -> Table scan on r (cost=2.6..4.59 rows=4.53) (actual time=0.344..0.345 rows=4 loops=20)\n -> Materialize (invalidate on row from root) (cost=2.04..2.04 rows=4.53) (actual time=0.344..0.344 rows=4 loops=20)\n -> Limit: 5 row(s) (cost=1.59 rows=4.53) (actual time=0.338..0.338 rows=4 loops=20)\n -> Sort: snowthing_benchmark_100k.c.comment_id, limit input to 5 row(s) per chunk (cost=1.59 rows=4.53) (actual time=0.338..0.338 rows=4 loops=20)\n -> Index lookup on c using idx_comment_parent_deleted_id (parent_id=snowthing_benchmark_100k.root.comment_id) (cost=1.59 rows=4.53) (actual time=0.335..0.336 rows=4 loops=20)\n -> Hash\n -> Table scan on m (cost=0.00841 rows=1) (actual time=0.0193..0.022 rows=1 loops=1)\n + +``` + +### 인덱스 제거 +```text +# 설명 +- 시나리오: reply-top5-no-index +- 데이터 규모: 100k +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +EXPLAIN +-> Limit: 25 row(s) (cost=9628 rows=25) (actual time=14.9..14.9 rows=25 loops=1)\n -> Sort: snowthing_benchmark_100k.`comment`.parent_id, snowthing_benchmark_100k.`comment`.created_at, snowthing_benchmark_100k.`comment`.comment_id, limit input to 25 row(s) per chunk (cost=9628 rows=92749) (actual time=14.9..14.9 rows=25 loops=1)\n -> Filter: (snowthing_benchmark_100k.`comment`.parent_id in (304561,304562,304563,304564,304565)) (cost=9628 rows=92749) (actual time=3.11..14.9 rows=100 loops=1)\n -> Table scan on comment (cost=9628 rows=92749) (actual time=0.023..12.5 rows=100001 loops=1)\n +``` + +## 백만건 + +### 인덱스 적용 +```text +# 설명 +- 시나리오: 루트 20개 대댓글 Top-5 일괄 조회 +- 데이터 규모: 1m +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +schema=snowthing_test +scale=1m +postId=2200 +hotRootId=1329617 +rootMiddleCursor=1519616 +rootLastCursor=1529595 +replyMiddleCursor=1529637 +rootIds=1509617,1509618,1509619,1509620,1509621,1509622,1509623,1509624,1509625,1509626,1509627,1509628,1509629,1509630,1509631,1509632,1509633,1509634,1509635,1509636 +sql=SELECT r.comment_id,r.post_id,r.parent_id,r.content,r.is_deleted,r.is_anonymous,r.writer_ip,r.created_at,m.public_id member_public_id,m.nickname,m.profile_image_url FROM `snowthing_test`.comment root CROSS JOIN LATERAL (SELECT c.comment_id,c.post_id,c.parent_id,c.content,c.is_deleted,c.is_anonymous,c.writer_ip,c.created_at,c.member_id FROM `snowthing_test`.comment c WHERE c.parent_id=root.comment_id ORDER BY c.comment_id ASC LIMIT 5) r LEFT JOIN `snowthing_test`.member m ON m.member_id=r.member_id WHERE root.comment_id IN (1509617,1509618,1509619,1509620,1509621,1509622,1509623,1509624,1509625,1509626,1509627,1509628,1509629,1509630,1509631,1509632,1509633,1509634,1509635,1509636) ORDER BY root.comment_id ASC,r.comment_id ASC + +-> Sort: snowthing_test.root.comment_id, r.comment_id (actual time=0.454..0.458 rows=80 loops=1)\n -> Stream results (cost=87.6 rows=85.2) (actual time=0.144..0.44 rows=80 loops=1)\n -> Nested loop left join (cost=87.6 rows=85.2) (actual time=0.142..0.41 rows=80 loops=1)\n -> Nested loop inner join (cost=78.1 rows=85.2) (actual time=0.128..0.385 rows=80 loops=1)\n -> Invalidate materialized tables (row from root) (cost=15.4 rows=20) (actual time=0.0233..0.0579 rows=20 loops=1)\n -> Filter: (snowthing_test.root.comment_id in (1509617,1509618,1509619,1509620,1509621,1509622,1509623,1509624,1509625,1509626,1509627,1509628,1509629,1509630,1509631,1509632,1509633,1509634,1509635,1509636)) (cost=15.4 rows=20) (actual time=0.023..0.0569 rows=20 loops=1)\n -> Covering index range scan on root using PRIMARY over (comment_id = 1509617) OR (comment_id = 1509618) OR (18 more) (cost=15.4 rows=20) (actual time=0.0222..0.0543 rows=20 loops=1)\n -> Table scan on r (cost=3.86..5.81 rows=4.26) (actual time=0.0158..0.0161 rows=4 loops=20)\n -> Materialize (invalidate on row from root) (cost=3.26..3.26 rows=4.26) (actual time=0.0156..0.0156 rows=4 loops=20)\n -> Limit: 5 row(s) (cost=2.84 rows=4.26) (actual time=0.0126..0.0129 rows=4 loops=20)\n -> Sort: snowthing_test.c.comment_id, limit input to 5 row(s) per chunk (cost=2.84 rows=4.26) (actual time=0.0125..0.0127 rows=4 loops=20)\n -> Index lookup on c using idx_comment_parent_deleted_id (parent_id=snowthing_test.root.comment_id) (cost=2.84 rows=4.26) (actual time=0.0112..0.0116 rows=4 loops=20)\n -> Single-row index lookup on m using PRIMARY (member_id=r.member_id) (cost=0.0138 rows=1) (actual time=208e-6..221e-6 rows=1 loops=80)\n + +``` + +### 인덱스 제거 +```text +# 설명 +- 시나리오: reply-top5-no-index +- 데이터 규모: 1m +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +EXPLAIN +-> Limit: 25 row(s) (cost=105742 rows=25) (actual time=302..302 rows=25 loops=1)\n -> Sort: snowthing_test.`comment`.parent_id, snowthing_test.`comment`.created_at, snowthing_test.`comment`.comment_id, limit input to 25 row(s) per chunk (cost=105742 rows=985540) (actual time=302..302 rows=25 loops=1)\n -> Filter: (snowthing_test.`comment`.parent_id in (304561,304562,304563,304564,304565)) (cost=105742 rows=985540) (actual time=39..302 rows=120 loops=1)\n -> Table scan on comment (cost=105742 rows=985540) (actual time=0.0436..275 rows=1e+6 loops=1)\n +``` + diff --git "a/docs/conception/sprint04/benchmark/explain-plans/05-\355\225\253\354\212\244\355\214\237-\353\214\200\353\214\223\352\270\200.md" "b/docs/conception/sprint04/benchmark/explain-plans/05-\355\225\253\354\212\244\355\214\237-\353\214\200\353\214\223\352\270\200.md" new file mode 100644 index 0000000..ee78b6b --- /dev/null +++ "b/docs/conception/sprint04/benchmark/explain-plans/05-\355\225\253\354\212\244\355\214\237-\353\214\200\353\214\223\352\270\200.md" @@ -0,0 +1,116 @@ +# Hotspot 루트 대댓글 조회 + +이 문서는 MySQL 8.0에서 측정한 실행계획 원문을 데이터 규모별로 묶은 문서입니다. `rows`는 estimated rows와 actual rows를 구분해 읽고, `loops`는 연산 반복 횟수, `Index lookup`의 이름은 선택된 인덱스입니다. `Sort`가 있으면 정렬 단계가 수행됩니다. + +## 1천건 + +### 인덱스 적용 +```text +# 설명 +- 시나리오: Hotspot 루트 대댓글 페이지 조회 +- 데이터 규모: 1k +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +EXPLAIN +-> Limit: 21 row(s) (cost=2.8 rows=8) (actual time=0.387..0.387 rows=8 loops=1)\n -> Sort: snowthing_benchmark_1k.`comment`.created_at, snowthing_benchmark_1k.`comment`.comment_id, limit input to 21 row(s) per chunk (cost=2.8 rows=8) (actual time=0.386..0.387 rows=8 loops=1)\n -> Index lookup on comment using idx_comment_parent_deleted_id (parent_id=304561) (cost=2.8 rows=8) (actual time=0.373..0.375 rows=8 loops=1)\n +``` + +### 인덱스 제거 +```text +# 설명 +- 시나리오: reply-hotspot-no-index +- 데이터 규모: 1k +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +EXPLAIN +-> Limit: 21 row(s) (cost=202 rows=21) (actual time=0.35..0.35 rows=8 loops=1)\n -> Sort: snowthing_benchmark_1k.`comment`.created_at, snowthing_benchmark_1k.`comment`.comment_id, limit input to 21 row(s) per chunk (cost=202 rows=1948) (actual time=0.349..0.35 rows=8 loops=1)\n -> Filter: (snowthing_benchmark_1k.`comment`.parent_id = 304561) (cost=202 rows=1948) (actual time=0.0569..0.342 rows=8 loops=1)\n -> Table scan on comment (cost=202 rows=1948) (actual time=0.0229..0.295 rows=2000 loops=1)\n +``` + +## 1만건 + +### 인덱스 적용 +```text +# 설명 +- 시나리오: Hotspot 루트 대댓글 페이지 조회 +- 데이터 규모: 10k +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +EXPLAIN +-> Limit: 21 row(s) (cost=28 rows=21) (actual time=6.72..6.72 rows=21 loops=1)\n -> Sort: snowthing_benchmark_10k.`comment`.created_at, snowthing_benchmark_10k.`comment`.comment_id, limit input to 21 row(s) per chunk (cost=28 rows=80) (actual time=6.71..6.72 rows=21 loops=1)\n -> Index lookup on comment using idx_comment_parent_deleted_id (parent_id=304561) (cost=28 rows=80) (actual time=1.42..6.68 rows=80 loops=1)\n +``` + +### 인덱스 제거 +```text +# 설명 +- 시나리오: reply-hotspot-no-index +- 데이터 규모: 10k +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +EXPLAIN +-> Limit: 21 row(s) (cost=2002 rows=21) (actual time=3.12..3.12 rows=21 loops=1)\n -> Sort: snowthing_benchmark_10k.`comment`.created_at, snowthing_benchmark_10k.`comment`.comment_id, limit input to 21 row(s) per chunk (cost=2002 rows=19296) (actual time=3.12..3.12 rows=21 loops=1)\n -> Filter: (snowthing_benchmark_10k.`comment`.parent_id = 304561) (cost=2002 rows=19296) (actual time=0.313..3.1 rows=80 loops=1)\n -> Table scan on comment (cost=2002 rows=19296) (actual time=0.0227..2.63 rows=20000 loops=1)\n +``` + +## 10만건 + +### 인덱스 적용 +```text +# 설명 +- 시나리오: Hotspot 루트 대댓글 페이지 조회 +- 데이터 규모: 100k +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +EXPLAIN +-> Limit: 21 row(s) (cost=35 rows=21) (actual time=0.115..0.116 rows=21 loops=1)\n -> Sort: snowthing_benchmark_100k.`comment`.created_at, snowthing_benchmark_100k.`comment`.comment_id, limit input to 21 row(s) per chunk (cost=35 rows=100) (actual time=0.115..0.116 rows=21 loops=1)\n -> Index lookup on comment using idx_comment_parent_deleted_id (parent_id=304561) (cost=35 rows=100) (actual time=0.0268..0.103 rows=100 loops=1)\n +``` + +### 인덱스 제거 +```text +# 설명 +- 시나리오: reply-hotspot-no-index +- 데이터 규모: 100k +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +EXPLAIN +-> Limit: 21 row(s) (cost=9628 rows=21) (actual time=16.2..16.2 rows=21 loops=1)\n -> Sort: snowthing_benchmark_100k.`comment`.created_at, snowthing_benchmark_100k.`comment`.comment_id, limit input to 21 row(s) per chunk (cost=9628 rows=92749) (actual time=16.2..16.2 rows=21 loops=1)\n -> Filter: (snowthing_benchmark_100k.`comment`.parent_id = 304561) (cost=9628 rows=92749) (actual time=2.86..16.2 rows=100 loops=1)\n -> Table scan on comment (cost=9628 rows=92749) (actual time=0.0252..13.7 rows=100001 loops=1)\n +``` + +## 백만건 + +### 인덱스 적용 +```text +# 설명 +- 시나리오: Hotspot 루트 대댓글 페이지 조회 +- 데이터 규모: 1m +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +EXPLAIN +-> Limit: 21 row(s) (cost=0.35 rows=1) (actual time=0.059..0.059 rows=0 loops=1)\n -> Sort: snowthing_test.`comment`.created_at, snowthing_test.`comment`.comment_id, limit input to 21 row(s) per chunk (cost=0.35 rows=1) (actual time=0.0587..0.0587 rows=0 loops=1)\n -> Index lookup on comment using idx_comment_parent_deleted_id (parent_id=304561) (cost=0.35 rows=1) (actual time=0.055..0.055 rows=0 loops=1)\n +``` + +### 인덱스 제거 +```text +# 설명 +- 시나리오: reply-hotspot-no-index +- 데이터 규모: 1m +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +EXPLAIN +-> Limit: 21 row(s) (cost=105742 rows=21) (actual time=261..261 rows=21 loops=1)\n -> Sort: snowthing_test.`comment`.created_at, snowthing_test.`comment`.comment_id, limit input to 21 row(s) per chunk (cost=105742 rows=985540) (actual time=261..261 rows=21 loops=1)\n -> Filter: (snowthing_test.`comment`.parent_id = 304561) (cost=105742 rows=985540) (actual time=32..261 rows=104 loops=1)\n -> Table scan on comment (cost=105742 rows=985540) (actual time=0.0388..237 rows=1e+6 loops=1)\n +``` + diff --git "a/docs/conception/sprint04/benchmark/explain-plans/06-\355\231\234\354\204\261-\353\214\200\353\214\223\352\270\200-\354\210\230.md" "b/docs/conception/sprint04/benchmark/explain-plans/06-\355\231\234\354\204\261-\353\214\200\353\214\223\352\270\200-\354\210\230.md" new file mode 100644 index 0000000..be7ff6b --- /dev/null +++ "b/docs/conception/sprint04/benchmark/explain-plans/06-\355\231\234\354\204\261-\353\214\200\353\214\223\352\270\200-\354\210\230.md" @@ -0,0 +1,124 @@ +# 활성 대댓글 수 집계 + +이 문서는 MySQL 8.0에서 측정한 실행계획 원문을 데이터 규모별로 묶은 문서입니다. `rows`는 estimated rows와 actual rows를 구분해 읽고, `loops`는 연산 반복 횟수, `Index lookup`의 이름은 선택된 인덱스입니다. `Sort`가 있으면 정렬 단계가 수행됩니다. + +## 1천건 + +### 인덱스 적용 +```text +# 설명 +- 시나리오: reply-count-active +- 데이터 규모: 1k +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +schema=snowthing_benchmark_1k +scale=1k +postId=2000 +hotRootId=506361 +rootMiddleCursor=506550 +rootLastCursor=506555 +replyMiddleCursor=506581 +rootIds=506541,506542,506543,506544,506545,506546,506547,506548,506549,506550,506551,506552,506553,506554,506555,506556,506557,506558,506559,506560 +sql=SELECT COUNT(*) active_reply_count FROM `snowthing_benchmark_1k`.comment WHERE parent_id=506361 AND is_deleted=FALSE + +-> Aggregate: count(0) (cost=20.3 rows=1) (actual time=0.0278..0.0278 rows=1 loops=1)\n -> Covering index lookup on comment using idx_comment_parent_deleted_id (parent_id=506361, is_deleted=false) (cost=10.3 rows=100) (actual time=0.0114..0.0234 rows=100 loops=1)\n + +``` + +### 인덱스 제거 +```text +측정 원문 없음 +``` + +## 1만건 + +### 인덱스 적용 +```text +# 설명 +- 시나리오: reply-count-active +- 데이터 규모: 10k +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +schema=snowthing_benchmark_10k +scale=10k +postId=2100 +hotRootId=531701 +rootMiddleCursor=533600 +rootLastCursor=533679 +replyMiddleCursor=533721 +rootIds=533501,533502,533503,533504,533505,533506,533507,533508,533509,533510,533511,533512,533513,533514,533515,533516,533517,533518,533519,533520 +sql=SELECT COUNT(*) active_reply_count FROM `snowthing_benchmark_10k`.comment WHERE parent_id=531701 AND is_deleted=FALSE + +-> Aggregate: count(0) (cost=20.3 rows=1) (actual time=0.0291..0.0291 rows=1 loops=1)\n -> Covering index lookup on comment using idx_comment_parent_deleted_id (parent_id=531701, is_deleted=false) (cost=10.3 rows=100) (actual time=0.0124..0.0248 rows=100 loops=1)\n + +``` + +### 인덱스 제거 +```text +측정 원문 없음 +``` + +## 10만건 + +### 인덱스 적용 +```text +# 설명 +- 시나리오: reply-count-active +- 데이터 규모: 100k +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +schema=snowthing_benchmark_100k +scale=100k +postId=1900 +hotRootId=684561 +rootMiddleCursor=703560 +rootLastCursor=704539 +replyMiddleCursor=704581 +rootIds=702561,702562,702563,702564,702565,702566,702567,702568,702569,702570,702571,702572,702573,702574,702575,702576,702577,702578,702579,702580 +sql=SELECT COUNT(*) active_reply_count FROM `snowthing_benchmark_100k`.comment WHERE parent_id=684561 AND is_deleted=FALSE + +-> Aggregate: count(0) (cost=20.3 rows=1) (actual time=0.0281..0.0282 rows=1 loops=1)\n -> Covering index lookup on comment using idx_comment_parent_deleted_id (parent_id=684561, is_deleted=false) (cost=10.3 rows=100) (actual time=0.0116..0.0241 rows=100 loops=1)\n + +``` + +### 인덱스 제거 +```text +측정 원문 없음 +``` + +## 백만건 + +### 인덱스 적용 +```text +# 설명 +- 시나리오: reply-count-active +- 데이터 규모: 1m +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +schema=snowthing_test +scale=1m +postId=2200 +hotRootId=1329617 +rootMiddleCursor=1519616 +rootLastCursor=1529595 +replyMiddleCursor=1529637 +rootIds=1509617,1509618,1509619,1509620,1509621,1509622,1509623,1509624,1509625,1509626,1509627,1509628,1509629,1509630,1509631,1509632,1509633,1509634,1509635,1509636 +sql=SELECT COUNT(*) active_reply_count FROM `snowthing_test`.comment WHERE parent_id=1329617 AND is_deleted=FALSE + +-> Aggregate: count(0) (cost=20.3 rows=1) (actual time=0.0296..0.0296 rows=1 loops=1)\n -> Covering index lookup on comment using idx_comment_parent_deleted_id (parent_id=1329617, is_deleted=false) (cost=10.3 rows=100) (actual time=0.013..0.0253 rows=100 loops=1)\n + +``` + +### 인덱스 제거 +```text +측정 원문 없음 +``` + diff --git "a/docs/conception/sprint04/benchmark/explain-plans/07-\354\202\255\354\240\234\353\220\234-\353\243\250\355\212\270.md" "b/docs/conception/sprint04/benchmark/explain-plans/07-\354\202\255\354\240\234\353\220\234-\353\243\250\355\212\270.md" new file mode 100644 index 0000000..3267d80 --- /dev/null +++ "b/docs/conception/sprint04/benchmark/explain-plans/07-\354\202\255\354\240\234\353\220\234-\353\243\250\355\212\270.md" @@ -0,0 +1,116 @@ +# 삭제된 루트 댓글 조회 + +이 문서는 MySQL 8.0에서 측정한 실행계획 원문을 데이터 규모별로 묶은 문서입니다. `rows`는 estimated rows와 actual rows를 구분해 읽고, `loops`는 연산 반복 횟수, `Index lookup`의 이름은 선택된 인덱스입니다. `Sort`가 있으면 정렬 단계가 수행됩니다. + +## 1천건 + +### 인덱스 적용 +```text +# 설명 +- 시나리오: 삭제된 루트 댓글 조회 +- 데이터 규모: 1k +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +EXPLAIN +-> Limit: 21 row(s) (cost=0.664 rows=1) (actual time=0.198..0.198 rows=2 loops=1)\n -> Sort: snowthing_benchmark_1k.`comment`.created_at, snowthing_benchmark_1k.`comment`.comment_id, limit input to 21 row(s) per chunk (cost=0.664 rows=1) (actual time=0.198..0.198 rows=2 loops=1)\n -> Filter: ((snowthing_benchmark_1k.`comment`.is_deleted = 1) and (snowthing_benchmark_1k.`comment`.post_id = 1601) and (snowthing_benchmark_1k.`comment`.parent_id is null)) (cost=0.664 rows=1) (actual time=0.179..0.189 rows=2 loops=1)\n -> Intersect rows sorted by row ID (cost=0.664 rows=1) (actual time=0.176..0.186 rows=2 loops=1)\n -> Index range scan on comment using idx_comment_post_parent_id over (post_id = 1601 AND parent_id = NULL) (cost=0.251 rows=2) (actual time=0.0825..0.0842 rows=2 loops=1)\n -> Index range scan on comment using idx_comment_parent_deleted_id over (parent_id = NULL AND is_deleted = 1) (cost=0.312 rows=80) (actual time=0.0851..0.0897 rows=21 loops=1)\n +``` + +### 인덱스 제거 +```text +# 설명 +- 시나리오: deleted-root-no-index +- 데이터 규모: 1k +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +EXPLAIN +-> Limit: 21 row(s) (cost=202 rows=21) (actual time=0.38..0.38 rows=2 loops=1)\n -> Sort: snowthing_benchmark_1k.`comment`.created_at, snowthing_benchmark_1k.`comment`.comment_id, limit input to 21 row(s) per chunk (cost=202 rows=1948) (actual time=0.379..0.379 rows=2 loops=1)\n -> Filter: ((snowthing_benchmark_1k.`comment`.is_deleted = 1) and (snowthing_benchmark_1k.`comment`.post_id = 1601) and (snowthing_benchmark_1k.`comment`.parent_id is null)) (cost=202 rows=1948) (actual time=0.0267..0.372 rows=2 loops=1)\n -> Table scan on comment (cost=202 rows=1948) (actual time=0.0239..0.31 rows=2000 loops=1)\n +``` + +## 1만건 + +### 인덱스 적용 +```text +# 설명 +- 시나리오: 삭제된 루트 댓글 조회 +- 데이터 규모: 10k +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +EXPLAIN +-> Limit: 21 row(s) (cost=2.55 rows=4) (actual time=0.262..0.264 rows=20 loops=1)\n -> Sort: snowthing_benchmark_10k.`comment`.created_at, snowthing_benchmark_10k.`comment`.comment_id, limit input to 21 row(s) per chunk (cost=2.55 rows=4) (actual time=0.262..0.263 rows=20 loops=1)\n -> Filter: ((snowthing_benchmark_10k.`comment`.is_deleted = 1) and (snowthing_benchmark_10k.`comment`.post_id = 1601) and (snowthing_benchmark_10k.`comment`.parent_id is null)) (cost=2.55 rows=4) (actual time=0.132..0.253 rows=20 loops=1)\n -> Intersect rows sorted by row ID (cost=2.55 rows=4) (actual time=0.13..0.25 rows=20 loops=1)\n -> Index range scan on comment using idx_comment_post_parent_id over (post_id = 1601 AND parent_id = NULL) (cost=0.269 rows=20) (actual time=0.0346..0.0391 rows=20 loops=1)\n -> Index range scan on comment using idx_comment_parent_deleted_id over (parent_id = NULL AND is_deleted = 1) (cost=0.882 rows=800) (actual time=0.0878..0.144 rows=381 loops=1)\n +``` + +### 인덱스 제거 +```text +# 설명 +- 시나리오: deleted-root-no-index +- 데이터 규모: 10k +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +EXPLAIN +-> Limit: 21 row(s) (cost=2002 rows=21) (actual time=3.47..3.47 rows=20 loops=1)\n -> Sort: snowthing_benchmark_10k.`comment`.created_at, snowthing_benchmark_10k.`comment`.comment_id, limit input to 21 row(s) per chunk (cost=2002 rows=19296) (actual time=3.47..3.47 rows=20 loops=1)\n -> Filter: ((snowthing_benchmark_10k.`comment`.is_deleted = 1) and (snowthing_benchmark_10k.`comment`.post_id = 1601) and (snowthing_benchmark_10k.`comment`.parent_id is null)) (cost=2002 rows=19296) (actual time=0.0282..3.46 rows=20 loops=1)\n -> Table scan on comment (cost=2002 rows=19296) (actual time=0.0254..2.86 rows=20000 loops=1)\n +``` + +## 10만건 + +### 인덱스 적용 +```text +# 설명 +- 시나리오: 삭제된 루트 댓글 조회 +- 데이터 규모: 100k +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +EXPLAIN +-> Limit: 21 row(s) (cost=0.3 rows=1) (actual time=0.0141..0.0141 rows=0 loops=1)\n -> Sort: snowthing_benchmark_100k.`comment`.created_at, snowthing_benchmark_100k.`comment`.comment_id, limit input to 21 row(s) per chunk (cost=0.3 rows=1) (actual time=0.0138..0.0138 rows=0 loops=1)\n -> Filter: (snowthing_benchmark_100k.`comment`.is_deleted = 1) (cost=0.3 rows=1) (actual time=0.0102..0.0102 rows=0 loops=1)\n -> Index lookup on comment using idx_comment_post_parent_id (post_id=1601, parent_id=NULL), with index condition: (snowthing_benchmark_100k.`comment`.parent_id is null) (cost=0.3 rows=1) (actual time=0.00963..0.00963 rows=0 loops=1)\n +``` + +### 인덱스 제거 +```text +# 설명 +- 시나리오: deleted-root-no-index +- 데이터 규모: 100k +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +EXPLAIN +-> Limit: 21 row(s) (cost=9628 rows=21) (actual time=17.4..17.4 rows=0 loops=1)\n -> Sort: snowthing_benchmark_100k.`comment`.created_at, snowthing_benchmark_100k.`comment`.comment_id, limit input to 21 row(s) per chunk (cost=9628 rows=92749) (actual time=17.4..17.4 rows=0 loops=1)\n -> Filter: ((snowthing_benchmark_100k.`comment`.is_deleted = 1) and (snowthing_benchmark_100k.`comment`.post_id = 1601) and (snowthing_benchmark_100k.`comment`.parent_id is null)) (cost=9628 rows=92749) (actual time=17.4..17.4 rows=0 loops=1)\n -> Table scan on comment (cost=9628 rows=92749) (actual time=0.0244..14.4 rows=100001 loops=1)\n +``` + +## 백만건 + +### 인덱스 적용 +```text +# 설명 +- 시나리오: 삭제된 루트 댓글 조회 +- 데이터 규모: 1m +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +EXPLAIN +-> Limit: 21 row(s) (cost=0.3 rows=1) (actual time=0.0136..0.0136 rows=0 loops=1)\n -> Sort: snowthing_test.`comment`.created_at, snowthing_test.`comment`.comment_id, limit input to 21 row(s) per chunk (cost=0.3 rows=1) (actual time=0.0133..0.0133 rows=0 loops=1)\n -> Filter: (snowthing_test.`comment`.is_deleted = 1) (cost=0.3 rows=1) (actual time=0.00932..0.00932 rows=0 loops=1)\n -> Index lookup on comment using idx_comment_post_parent_id (post_id=1601, parent_id=NULL), with index condition: (snowthing_test.`comment`.parent_id is null) (cost=0.3 rows=1) (actual time=0.00888..0.00888 rows=0 loops=1)\n +``` + +### 인덱스 제거 +```text +# 설명 +- 시나리오: deleted-root-no-index +- 데이터 규모: 1m +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +EXPLAIN +-> Limit: 21 row(s) (cost=106264 rows=21) (actual time=290..290 rows=21 loops=1)\n -> Sort: snowthing_test.`comment`.created_at, snowthing_test.`comment`.comment_id, limit input to 21 row(s) per chunk (cost=106264 rows=985540) (actual time=290..290 rows=21 loops=1)\n -> Filter: ((snowthing_test.`comment`.is_deleted = 1) and (snowthing_test.`comment`.post_id = 1601) and (snowthing_test.`comment`.parent_id is null)) (cost=106264 rows=985540) (actual time=0.0435..290 rows=2000 loops=1)\n -> Table scan on comment (cost=106264 rows=985540) (actual time=0.0403..257 rows=1e+6 loops=1)\n +``` + diff --git "a/docs/conception/sprint04/benchmark/explain-plans/08-\354\202\255\354\240\234\353\220\234-\353\214\200\353\214\223\352\270\200.md" "b/docs/conception/sprint04/benchmark/explain-plans/08-\354\202\255\354\240\234\353\220\234-\353\214\200\353\214\223\352\270\200.md" new file mode 100644 index 0000000..535adac --- /dev/null +++ "b/docs/conception/sprint04/benchmark/explain-plans/08-\354\202\255\354\240\234\353\220\234-\353\214\200\353\214\223\352\270\200.md" @@ -0,0 +1,117 @@ +# 삭제된 대댓글 조회 + +이 문서는 MySQL 8.0에서 측정한 실행계획 원문을 데이터 규모별로 묶은 문서입니다. `rows`는 estimated rows와 actual rows를 구분해 읽고, `loops`는 연산 반복 횟수, `Index lookup`의 이름은 선택된 인덱스입니다. `Sort`가 있으면 정렬 단계가 수행됩니다. + +## 1천건 + +### 인덱스 적용 +```text +# 설명 +- 시나리오: 삭제된 대댓글 은닉 조회 +- 데이터 규모: 1k +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +EXPLAIN +-> Limit: 21 row(s) (cost=0.35 rows=1) (actual time=0.011..0.011 rows=0 loops=1)\n -> Sort: snowthing_benchmark_1k.`comment`.created_at, snowthing_benchmark_1k.`comment`.comment_id, limit input to 21 row(s) per chunk (cost=0.35 rows=1) (actual time=0.0107..0.0107 rows=0 loops=1)\n -> Index lookup on comment using idx_comment_parent_deleted_id (parent_id=304561, is_deleted=1) (cost=0.35 rows=1) (actual time=0.00738..0.00738 rows=0 loops=1)\n +``` + +### 인덱스 제거 +```text +# 설명 +- 시나리오: deleted-reply-hidden-no-index +- 데이터 규모: 1k +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +EXPLAIN +-> Limit: 21 row(s) (cost=202 rows=21) (actual time=0.357..0.357 rows=0 loops=1)\n -> Sort: snowthing_benchmark_1k.`comment`.created_at, snowthing_benchmark_1k.`comment`.comment_id, limit input to 21 row(s) per chunk (cost=202 rows=1948) (actual time=0.356..0.356 rows=0 loops=1)\n -> Filter: ((snowthing_benchmark_1k.`comment`.is_deleted = 1) and (snowthing_benchmark_1k.`comment`.parent_id = 304561)) (cost=202 rows=1948) (actual time=0.352..0.352 rows=0 loops=1)\n -> Table scan on comment (cost=202 rows=1948) (actual time=0.023..0.29 rows=2000 loops=1)\n +``` + +## 1만건 + +### 인덱스 적용 +```text +# 설명 +- 시나리오: 삭제된 대댓글 은닉 조회 +- 데이터 규모: 10k +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +EXPLAIN +-> Limit: 21 row(s) (cost=0.35 rows=1) (actual time=0.0114..0.0114 rows=0 loops=1)\n -> Sort: snowthing_benchmark_10k.`comment`.created_at, snowthing_benchmark_10k.`comment`.comment_id, limit input to 21 row(s) per chunk (cost=0.35 rows=1) (actual time=0.0112..0.0112 rows=0 loops=1)\n -> Index lookup on comment using idx_comment_parent_deleted_id (parent_id=304561, is_deleted=1) (cost=0.35 rows=1) (actual time=0.00768..0.00768 rows=0 loops=1)\n +``` + +### 인덱스 제거 +```text +# 설명 +- 시나리오: deleted-reply-hidden-no-index +- 데이터 규모: 10k +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +EXPLAIN +-> Limit: 21 row(s) (cost=2002 rows=21) (actual time=3.31..3.31 rows=0 loops=1)\n -> Sort: snowthing_benchmark_10k.`comment`.created_at, snowthing_benchmark_10k.`comment`.comment_id, limit input to 21 row(s) per chunk (cost=2002 rows=19296) (actual time=3.31..3.31 rows=0 loops=1)\n -> Filter: ((snowthing_benchmark_10k.`comment`.is_deleted = 1) and (snowthing_benchmark_10k.`comment`.parent_id = 304561)) (cost=2002 rows=19296) (actual time=3.3..3.3 rows=0 loops=1)\n -> Table scan on comment (cost=2002 rows=19296) (actual time=0.0204..2.71 rows=20000 loops=1)\n +``` + +## 10만건 + +### 인덱스 적용 +```text +# 설명 +- 시나리오: 삭제된 대댓글 은닉 조회 +- 데이터 규모: 100k +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +EXPLAIN +-> Limit: 21 row(s) (cost=0.35 rows=1) (actual time=0.177..0.177 rows=0 loops=1)\n -> Sort: snowthing_benchmark_100k.`comment`.created_at, snowthing_benchmark_100k.`comment`.comment_id, limit input to 21 row(s) per chunk (cost=0.35 rows=1) (actual time=0.177..0.177 rows=0 loops=1)\n -> Index lookup on comment using idx_comment_parent_deleted_id (parent_id=304561, is_deleted=1) (cost=0.35 rows=1) (actual time=0.00978..0.00978 rows=0 loops=1)\n +``` + +### 인덱스 제거 +```text +# 설명 +- 시나리오: deleted-reply-hidden-no-index +- 데이터 규모: 100k +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +EXPLAIN +-> Limit: 21 row(s) (cost=9628 rows=21) (actual time=17.3..17.3 rows=0 loops=1)\n -> Sort: snowthing_benchmark_100k.`comment`.created_at, snowthing_benchmark_100k.`comment`.comment_id, limit input to 21 row(s) per chunk (cost=9628 rows=92749) (actual time=17.3..17.3 rows=0 loops=1)\n -> Filter: ((snowthing_benchmark_100k.`comment`.is_deleted = 1) and (snowthing_benchmark_100k.`comment`.parent_id = 304561)) (cost=9628 rows=92749) (actual time=17.3..17.3 rows=0 loops=1)\n -> Table scan on comment (cost=9628 rows=92749) (actual time=0.0247..14 rows=100001 loops=1)\n +``` + +## 백만건 + +### 인덱스 적용 +```text +# 설명 +- 시나리오: 삭제된 대댓글 은닉 조회 +- 데이터 규모: 1m +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +EXPLAIN +-> Limit: 21 row(s) (cost=0.35 rows=1) (actual time=0.0889..0.0889 rows=0 loops=1)\n -> Sort: snowthing_test.`comment`.created_at, snowthing_test.`comment`.comment_id, limit input to 21 row(s) per chunk (cost=0.35 rows=1) (actual time=0.0884..0.0884 rows=0 loops=1)\n -> Index lookup on comment using idx_comment_parent_deleted_id (parent_id=304561, is_deleted=1) (cost=0.35 rows=1) (actual time=0.0834..0.0834 rows=0 loops=1)\n +``` + +### 인덱스 제거 +```text +# 설명 +- 시나리오: deleted-reply-hidden-no-index +- 데이터 규모: 1m +- 해석 방법: `cost` 옆 `rows`는 옵티마이저의 예상 행 수, `actual rows`는 실제 반환 행 수, `loops`는 해당 연산 반복 횟수입니다. +- `Index lookup`의 인덱스명이 선택된 인덱스이며, `Sort`가 있으면 정렬 단계가 수행된 것입니다. +- 아래 내용은 MySQL 8.0 `EXPLAIN ANALYZE` 원문입니다. + +EXPLAIN +-> Limit: 21 row(s) (cost=105742 rows=21) (actual time=275..275 rows=4 loops=1)\n -> Sort: snowthing_test.`comment`.created_at, snowthing_test.`comment`.comment_id, limit input to 21 row(s) per chunk (cost=105742 rows=985540) (actual time=275..275 rows=4 loops=1)\n -> Filter: ((snowthing_test.`comment`.is_deleted = 1) and (snowthing_test.`comment`.parent_id = 304561)) (cost=105742 rows=985540) (actual time=39.3..275 rows=4 loops=1)\n -> Table scan on comment (cost=105742 rows=985540) (actual time=0.0519..243 rows=1e+6 loops=1)\n +``` + +> 참고: 이 원문은 기존의 삭제 대댓글 필터 실험에서 생성된 결과입니다. 현재 정책은 삭제 댓글도 목록과 카운트에 포함하는 것이므로, 새 정책 측정 시 이 문서의 SQL과 결과를 갱신해야 합니다. diff --git "a/docs/conception/sprint04/benchmark/explain-plans/09-\354\235\270\353\215\261\354\212\244-\353\271\204\352\265\220-\354\242\205\355\225\251.md" "b/docs/conception/sprint04/benchmark/explain-plans/09-\354\235\270\353\215\261\354\212\244-\353\271\204\352\265\220-\354\242\205\355\225\251.md" new file mode 100644 index 0000000..42926b1 --- /dev/null +++ "b/docs/conception/sprint04/benchmark/explain-plans/09-\354\235\270\353\215\261\354\212\244-\353\271\204\352\265\220-\354\242\205\355\225\251.md" @@ -0,0 +1,10 @@ +# 9개 조회 시나리오 인덱스 적용 전·후 종합 + +이 문서는 MySQL 8.0에서 측정한 실행계획 원문을 데이터 규모별로 묶은 문서입니다. `rows`는 estimated rows와 actual rows를 구분해 읽고, `loops`는 연산 반복 횟수, `Index lookup`의 이름은 선택된 인덱스입니다. `Sort`가 있으면 정렬 단계가 수행됩니다. + +# 인덱스 전후 비교 요약 + +9개 시나리오 × 4개 규모 × visible/invisible 상태, 총 72개 실행계획을 저장했다. +각 행은 `index-comparison.csv`에서 visible/invisible 원문과 대응하며 estimated rows, actual rows, loops, 선택 인덱스를 포함한다. +측정에 사용한 두 복합 인덱스는 모든 스키마에서 visible 상태로 복구했다. + diff --git "a/docs/conception/sprint04/benchmark/guides/\353\215\260\354\235\264\355\204\260-\352\264\200\353\246\254.md" "b/docs/conception/sprint04/benchmark/guides/\353\215\260\354\235\264\355\204\260-\352\264\200\353\246\254.md" new file mode 100644 index 0000000..b3016ec --- /dev/null +++ "b/docs/conception/sprint04/benchmark/guides/\353\215\260\354\235\264\355\204\260-\352\264\200\353\246\254.md" @@ -0,0 +1,27 @@ +# 데이터셋 관리 + +## 격리 + +벤치마크는 `snowthing_test` 또는 `snowthing_benchmark` 스키마에서만 실행한다. JDBC 연결의 catalog가 `test` 또는 `benchmark`를 포함하지 않으면 하네스가 즉시 중단한다. `prod`/`docker` profile과 운영 호스트에서는 실행하지 않는다. + +## 식별과 정리 + +회원·게시글·댓글은 모두 `benchmark-sprint04-` 접두사를 사용한다. 초기화는 이 접두사를 가진 게시글과 그 하위 댓글만 삭제한다. 고정 PK를 사용하지 않으며, 생성 직후 반환된 실제 PK를 부모·자식 삽입에 전달한다. + +## 재현성 + +모든 실행은 `scale`과 `seed`를 기록한다. 같은 스키마 상태에서 같은 seed를 사용하면 게시글 주제, 작성자 유형, 삭제 비율, 생성 시각 묶음, Hotspot 분포가 동일해야 한다. 생성 결과에는 실행 시각, MySQL 버전, row 수, 게시글 수, seed를 manifest로 남긴다. + +## 수명주기 + +1. 전용 prefix 데이터 초기화 +2. 회원·카테고리·게시글 생성 +3. 루트와 대댓글 batch 생성 +4. Soft Delete 및 익명 유형 분포 적용 +5. 게시글 카운트 갱신 +6. 불변식 검증 +7. 측정 및 EXPLAIN 결과 저장 +8. 필요 시 동일 prefix만 재실행해 교체 + +운영 데이터나 전용 prefix 외 데이터는 절대 삭제하지 않는다. + diff --git "a/docs/conception/sprint04/benchmark/guides/\354\213\234\353\223\234-\352\260\200\354\235\264\353\223\234.md" "b/docs/conception/sprint04/benchmark/guides/\354\213\234\353\223\234-\352\260\200\354\235\264\353\223\234.md" new file mode 100644 index 0000000..28356a7 --- /dev/null +++ "b/docs/conception/sprint04/benchmark/guides/\354\213\234\353\223\234-\352\260\200\354\235\264\353\223\234.md" @@ -0,0 +1,48 @@ +# Sprint 04 댓글 조회 벤치마크 실행 가이드 + +## 목표 + +Sprint 03에서 채택한 루트 커서 + Top-5 프리뷰 + 대댓글 분리 조회 구조를 MySQL 8.0에서 1,000/10,000/100,000/1,000,000건으로 검증한다. 데이터는 여러 게시글에 분산하고 하나의 Hot post와 Hotspot 루트를 포함한다. + +## 안전 조건 + +- `benchmark` 프로필과 전용 스키마에서만 실행한다. +- JDBC URL의 데이터베이스명이 `test` 또는 `benchmark`를 포함하지 않으면 즉시 중단한다. +- `prod`/`docker` 프로필, 운영 호스트, `snowthing` 운영 스키마에서는 실행하지 않는다. +- 초기화는 전용 `public_id` 접두사(`benchmark-sprint04-*`) 데이터만 삭제한다. +- 애플리케이션 기동 시 자동 실행하지 않고 전용 명령으로만 실행한다. + +## 데이터 분포 + +- 게시글 100개 이상 +- 일반 게시글: 게시글당 약 500개 +- 중간 게시글: 게시글당 약 5,000개 +- Hot post 1개: 약 10,000개 +- 루트/대댓글 혼합, Hotspot 루트 1개에는 활성 대댓글 100개 이하 +- 전체 댓글의 20% Soft Delete +- 동일 `created_at` 묶음과 회원/로그인 익명/비회원 익명 혼합 +- 고정 seed로 모든 규모에서 동일 분포를 재현 + +## 실행 순서 + +1. `ANALYZE TABLE comment, post, member` 실행 +2. 전용 데이터 초기화 및 JDBC batch seed 실행 +3. 정확성 불변식 검증 +4. 각 쿼리 warm-up 5회 후 측정 20회 +5. 평균·p95·반환 row·JSON 크기와 `EXPLAIN ANALYZE` 원문 저장 +6. 인덱스 적용 전/후를 동일 데이터·동일 측정 조건으로 반복 + +## AI 작업 지시 + +하네스는 `JdbcTemplate.batchUpdate` 또는 JDBC PreparedStatement batch를 사용한다. JPA `save()` 반복은 사용하지 말고, 불가피하면 flush/영속성 컨텍스트 비용을 결과에 기록한다. 규모(`1k`, `10k`, `100k`, `1m`)와 random seed는 CLI 파라미터로 받는다. 생성과 초기화를 하나의 명령으로 제공하되 트랜잭션 로그와 생성 시간을 조회 측정과 분리한다. + +각 결과 문서에는 DB/MySQL 버전, 호스트, 프로필, 데이터 규모, seed, 인덱스 정의, `ANALYZE TABLE` 시점, warm-up/반복 횟수, 평균/p95, 실제 SQL·바인딩, `EXPLAIN ANALYZE` 전체 원문을 반드시 기록한다. + +## 정확성 불변식 + +- 전체 comment row, 루트/대댓글, 활성/삭제 수 +- 게시글별 `post.comment_count`와 정책상 댓글 수 일치 +- 루트별 활성 대댓글 100개 이하 +- 페이지 간 ID 중복·누락 0건 +- 동일 생성 시각에서도 `(created_at, comment_id)` 순서 고정 + diff --git "a/docs/conception/sprint04/benchmark/guides/\354\213\244\355\226\211\352\263\204\355\232\215-\355\226\211\353\240\254.md" "b/docs/conception/sprint04/benchmark/guides/\354\213\244\355\226\211\352\263\204\355\232\215-\355\226\211\353\240\254.md" new file mode 100644 index 0000000..e364661 --- /dev/null +++ "b/docs/conception/sprint04/benchmark/guides/\354\213\244\355\226\211\352\263\204\355\232\215-\355\226\211\353\240\254.md" @@ -0,0 +1,67 @@ +# 댓글 조회 실행계획 규모별 비교 + +## 1. 측정 기준 + +- 엔진: MySQL 8.0.46 InnoDB +- 규모: 1K, 10K, 100K, 1M 댓글 +- 반복: 동일 세션 warm-up 5회 후 유효 20회, 서버 내부 `NOW(6)` 기준 +- SQL: `CommentRepositoryImpl`의 실제 조회 구조를 기준으로 작성. 삭제 placeholder 시나리오는 동일 루트 조회에 `is_deleted=TRUE`를 더한 진단용 파생 쿼리다. +- 바인딩: 스키마마다 benchmark 데이터에서 게시글, Hotspot 루트, 중간·마지막 커서를 동적으로 선택 +- 원문: 같은 디렉터리의 `{scenario}-{scale}.txt` 36개가 기준 증거다. 각 파일에 스키마, 바인딩, SQL, `EXPLAIN ANALYZE` 원문을 함께 저장했다. + +기존 고정 ID 기반 파일은 다른 스키마에서 `actual rows=0`이 발생했으므로 비교 근거에서 제외한다. + +## 2. 데이터 불변식 + +| 규모 | 전체 | 루트 | 대댓글 | 삭제 | 부모-자식 post 불일치 | comment_count 불일치 | 활성 대댓글 최대 | +|---:|---:|---:|---:|---:|---:|---:|---:| +| 1K | 1,000 | 200 | 800 | 180 | 0 | 0 | 100 | +| 10K | 10,000 | 2,000 | 8,000 | 1,980 | 0 | 0 | 100 | +| 100K | 100,000 | 20,000 | 80,000 | 19,980 | 0 | 0 | 100 | +| 1M | 1,000,000 | 200,000 | 800,000 | 199,980 | 0 | 0 | 100 | + +삭제 수가 정확히 20%보다 20건 적은 이유는 최악 조건의 대댓글 페이징을 재현하기 위해 Hotspot 100건을 모두 활성 상태로 유지했기 때문이다. + +## 3. 평균 / p95 + +단위는 ms다. + +| 시나리오 | 1K | 10K | 100K | 1M | +|---|---:|---:|---:|---:| +| 루트 첫 페이지 | 0.332 / 0.468 | 0.549 / 0.754 | 2.606 / 2.979 | 36.578 / 39.388 | +| 루트 중간 페이지 | 0.294 / 0.370 | 0.416 / 0.622 | 1.498 / 2.094 | 19.840 / 22.533 | +| 루트 마지막 구간 | 0.315 / 0.468 | 0.347 / 0.508 | 0.376 / 0.584 | 0.449 / 0.692 | +| 20개 루트 대댓글 통계 | 0.278 / 0.404 | 0.337 / 0.436 | 0.224 / 0.310 | 0.399 / 0.542 | +| 20개 루트 Top-5 | 0.445 / 0.653 | 0.435 / 0.612 | 0.468 / 0.682 | 0.479 / 0.626 | +| Hotspot 대댓글 첫 페이지 | 0.435 / 0.662 | 0.377 / 0.552 | 0.424 / 0.608 | 0.449 / 0.600 | +| Hotspot 대댓글 중간 페이지 | 0.427 / 0.655 | 0.402 / 0.579 | 0.464 / 0.699 | 0.498 / 0.724 | +| 활성 대댓글 count | 0.222 / 0.342 | 0.227 / 0.326 | 0.191 / 0.336 | 0.258 / 0.388 | +| 삭제 루트 placeholder | 0.366 / 0.553 | 0.529 / 0.602 | 1.729 / 2.124 | 29.876 / 32.770 | + +## 4. 실행계획 해석표 + +| 시나리오 | 주요 접근 | 규모별 actual 처리량 | loops | 해석 | +|---|---|---|---:|---| +| 루트 첫 페이지 | `idx_comment_post_parent_id` lookup → member hash join → sort → limit | 20 → 200 → 2,000 → 20,000 | 1 | 인덱스로 게시글과 루트를 제한하지만 JOIN 이후 정렬 때문에 21건만 읽고 멈추지 못한다. Hot 게시글의 루트 수에 선형 비례한다. | +| 루트 중간 페이지 | 같은 복합 인덱스 range scan | 10 → 100 → 1,000 → 10,000 | 1 | PK 커서가 후보를 절반으로 줄이지만 JOIN·정렬 전에 남은 후보를 모두 읽는다. | +| 루트 마지막 구간 | 같은 복합 인덱스 range scan | 5 → 21 → 21 → 21 | 1 | 커서가 인덱스 범위를 21건 수준으로 좁혀 규모 증가 영향을 거의 받지 않는다. 1K는 데이터상 마지막 5건을 사용했다. | +| 대댓글 통계 | `idx_comment_parent_deleted_id` covering range scan → group aggregate | 모든 규모 80행 입력, 20행 출력 | 1 | `parent_id`, `is_deleted`가 인덱스에 있어 테이블 본문을 읽지 않고 집계한다. | +| Top-5 프리뷰 | 루트 PK range 20행 → LATERAL 내부 parent index lookup | 내부 4행 × 20 loops, 최종 80행 | 20 | 루트마다 최대 5개로 제한된다. 현재 데이터는 일반 루트당 4개여서 전체 규모와 무관하다. | +| Hotspot 첫 페이지 | `idx_comment_parent_deleted_id` lookup → member hash join → sort | 모든 규모 103행 입력, 21행 출력 | 1 | Hotspot 상한이 고정되어 전체 테이블 규모와 무관하다. 다만 LIMIT 전 103행을 정렬한다. | +| Hotspot 중간 페이지 | parent lookup + PK cursor index condition | 모든 규모 82행 입력, 21행 출력 | 1 | cursor로 앞 21건을 제외하지만 복합 인덱스 순서상 남은 82건을 읽고 정렬한다. 상한 100 덕분에 비용은 제한된다. | +| 활성 대댓글 count | `idx_comment_parent_deleted_id` covering lookup | 모든 규모 100행 입력, aggregate 1행 | 1 | 활성 여부까지 인덱스에서 해결하며 가장 안정적인 계획이다. | +| 삭제 placeholder | post/parent 인덱스와 parent/deleted 인덱스의 intersection 또는 post range → join/sort | 삭제 루트 4 → 40 → 400 → 4,000 | 1 | 1M에서는 옵티마이저가 post 복합 인덱스만 택해 루트 20,000건을 읽고 4,000건을 필터링한다. | + +## 5. 결론과 개선 방향 + +현재 설계의 대댓글 경로는 서비스 레벨 상한 100개와 `(parent_id, is_deleted, comment_id)` 인덱스가 결합되어 데이터 전체 규모가 커져도 안정적이다. Top-5 LATERAL은 루트 20개에 대해 내부 조회가 20회 실행되지만, 각 실행의 입력이 4~5건으로 제한되어 1M에서도 평균 0.479ms였다. + +반면 루트 첫 페이지는 `LIMIT 21`이 있어도 JOIN·정렬보다 먼저 적용되지 않는다. 1M Hot 게시글에서 20,000개 루트를 읽은 뒤 21개를 반환하므로 평균 36.578ms가 걸렸다. 삭제 placeholder도 삭제 여부가 현재 post 복합 인덱스에 포함되지 않아 평균 29.876ms였다. + +현업에서 우선 검토할 방식은 다음과 같다. + +1. 루트 ID 21개를 `(post_id, parent_id, comment_id)` 인덱스에서 먼저 구하는 파생 테이블/CTE를 만들고, 그 21개만 member와 JOIN한다. 읽기 행 수를 직접 제한할 수 있지만 SQL이 복잡해지고 쿼리 변경 전 결과 동등성 테스트가 필요하다. +2. `(post_id, parent_id, is_deleted, comment_id)` 인덱스를 추가하면 삭제 루트 조회는 개선되지만, 일반 루트 조회와 중복되는 인덱스의 저장 공간·쓰기 증폭을 감수해야 한다. +3. 작성자 표시 정보를 댓글에 스냅샷으로 비정규화하면 JOIN을 제거할 수 있지만 닉네임·프로필 변경 전파 정책이 필요하다. 현재 규모에서는 먼저 JOIN 전 LIMIT 구조를 검증하는 편이 변경 비용이 작다. + +이번 작업은 측정과 근거 정리에 한정하며 운영 저장소 SQL이나 인덱스는 변경하지 않았다. diff --git "a/docs/conception/sprint04/benchmark/guides/\354\240\225\355\225\251\354\204\261-\352\262\200\354\246\235.sql" "b/docs/conception/sprint04/benchmark/guides/\354\240\225\355\225\251\354\204\261-\352\262\200\354\246\235.sql" new file mode 100644 index 0000000..87f47cc --- /dev/null +++ "b/docs/conception/sprint04/benchmark/guides/\354\240\225\355\225\251\354\204\261-\352\262\200\354\246\235.sql" @@ -0,0 +1,21 @@ +-- 실행 전 USE를 대상 benchmark 스키마로 변경한다. +SELECT COUNT(*) AS total_comments, + SUM(parent_id IS NULL) AS roots, + SUM(parent_id IS NOT NULL) AS replies, + SUM(is_deleted = FALSE) AS active, + SUM(is_deleted = TRUE) AS deleted +FROM comment; + +SELECT COUNT(*) AS post_comment_count_mismatch +FROM post p +WHERE p.public_id LIKE 'benchmark-sprint04-post-%' + AND p.comment_count <> (SELECT COUNT(*) FROM comment c WHERE c.post_id = p.post_id AND c.is_deleted = FALSE); + +SELECT COUNT(*) AS reply_limit_violations +FROM (SELECT parent_id FROM comment WHERE parent_id IS NOT NULL AND is_deleted = FALSE GROUP BY parent_id HAVING COUNT(*) > 100) x; + +SELECT COUNT(*) - COUNT(DISTINCT comment_id) AS duplicate_comment_ids +FROM comment; + +SELECT COUNT(*) AS same_timestamp_rows +FROM (SELECT created_at FROM comment GROUP BY created_at HAVING COUNT(*) > 1) x; diff --git "a/docs/conception/sprint04/benchmark/guides/\355\205\214\354\212\244\355\212\270-\352\263\204\355\232\215.md" "b/docs/conception/sprint04/benchmark/guides/\355\205\214\354\212\244\355\212\270-\352\263\204\355\232\215.md" new file mode 100644 index 0000000..d2b3e50 --- /dev/null +++ "b/docs/conception/sprint04/benchmark/guides/\355\205\214\354\212\244\355\212\270-\352\263\204\355\232\215.md" @@ -0,0 +1,31 @@ +# 테스트 계획 + +## 단계별 목적 + +| 단계 | 댓글 수 | 검증 목적 | +|---|---:|---| +| Small | 1,000 | 기능, cursor 페이지, Top-5, 삭제 placeholder | +| Medium | 10,000 | 인덱스 선택과 estimated rows 변화 | +| Large | 100,000 | 선택도, Hotspot, filesort/temporary, 평균·p95 | +| Challenge | 1,000,000 | 생성·조회 병목과 옵티마이저 한계 | + +## 실행 순서 + +각 단계에서 `ANALYZE TABLE`을 먼저 수행한다. 이후 동일 쿼리를 warm-up 5회, 측정 20회 실행하고 평균·p95를 기록한다. Seed 생성 시간은 조회 측정과 분리한다. + +## 정확성 검증 + +- 전체 comment row와 루트/대댓글 수 +- 활성/삭제 수와 게시글별 `comment_count` +- 부모·자식의 `post_id` 일치 +- 루트별 활성 대댓글 100개 이하 +- 페이지 간 ID 중복·누락 0건 +- 동일 `created_at`에서 `(created_at, comment_id)` 순서 고정 + +## 성능 증거 + +루트 첫·중간·마지막 페이지, 20개 루트 대댓글 통계, 20개 루트 Top-5 batch, Hotspot 대댓글 첫·중간 페이지, 활성 대댓글 count, 삭제 루트 placeholder 조회의 9개 시나리오를 측정한다. 각 케이스에 실제 SQL·바인딩·실행시간과 `EXPLAIN ANALYZE` 원문을 저장한다. + +스키마마다 auto increment 값이 다르므로 숫자 ID를 공통 재사용하지 않는다. 측정 스크립트가 benchmark prefix를 기준으로 게시글, Hotspot 루트, 중간·마지막 커서를 동적으로 선택한다. 실행계획 원문은 `{scenario}-{scale}.txt`, 통합 해석은 `execution-plan-matrix.md`를 기준으로 한다. + +인덱스 적용 전·후는 동일 데이터셋, 동일 seed, 동일 warm-up/반복 수로 비교한다. 예상과 다른 결과도 수치를 수정하지 않고 옵티마이저의 선택 이유와 실제 rows/loops를 분석한다. diff --git "a/docs/conception/sprint04/benchmark/metrics/\354\213\244\355\226\211\352\263\204\355\232\215-\354\203\201\354\204\270.csv" "b/docs/conception/sprint04/benchmark/metrics/\354\213\244\355\226\211\352\263\204\355\232\215-\354\203\201\354\204\270.csv" new file mode 100644 index 0000000..680a14d --- /dev/null +++ "b/docs/conception/sprint04/benchmark/metrics/\354\213\244\355\226\211\352\263\204\355\232\215-\354\203\201\354\204\270.csv" @@ -0,0 +1,115 @@ +"scale","scenario","index_state","table","access_type","key","key_len","estimated_rows","filesort","temporary","extra" +"1k","root-first","visible","c","ref","idx_comment_post_parent_id","17","2","Y","Y","Using index condition; Using temporary; Using filesort" +"1k","root-first","visible","m","ALL","NULL","NULL","1","N","N","Using where; Using join buffer (hash join)" +"1k","root-middle","visible","c","range","idx_comment_post_parent_id","25","1","N","N","Using index condition" +"1k","root-middle","visible","m","eq_ref","PRIMARY","8","1","N","N","NULL" +"1k","root-last","visible","c","range","idx_comment_post_parent_id","25","1","N","N","Using index condition" +"1k","root-last","visible","m","eq_ref","PRIMARY","8","1","N","N","NULL" +"1k","reply-top5","visible","root","range","PRIMARY","8","2","Y","Y","Using where; Using index; Using temporary; Using filesort; Rematerialize ()" +"1k","reply-top5","visible","","ALL","NULL","NULL","2","N","N","NULL" +"1k","reply-top5","visible","m","ALL","NULL","NULL","1","N","N","Using where; Using join buffer (hash join)" +"1k","reply-top5","visible","c","ref","idx_comment_parent_deleted_id","9","1","Y","N","Using filesort" +"1k","reply-hotspot-first","visible","c","ref","idx_comment_parent_deleted_id","9","103","Y","Y","Using temporary; Using filesort" +"1k","reply-hotspot-first","visible","m","ALL","NULL","NULL","1","N","N","Using where; Using join buffer (hash join)" +"1k","reply-hotspot-middle","visible","c","ref","idx_comment_parent_deleted_id","9","103","Y","Y","Using index condition; Using temporary; Using filesort" +"1k","reply-hotspot-middle","visible","m","ALL","NULL","NULL","1","N","N","Using where; Using join buffer (hash join)" +"1k","reply-count-active","visible","comment","ref","idx_comment_parent_deleted_id","10","100","N","N","Using index" +"1k","deleted-root-placeholder","visible","c","index_merge","idx_comment_post_parent_id,idx_comment_parent_deleted_id","17,10","1","Y","N","Using intersect(idx_comment_post_parent_id,idx_comment_parent_deleted_id); Using where; Using filesort" +"1k","deleted-root-placeholder","visible","m","eq_ref","PRIMARY","8","1","N","N","NULL" +"1k","deleted-reply-hidden","visible","c","ref","idx_comment_parent_deleted_id","10","100","Y","Y","Using temporary; Using filesort" +"1k","deleted-reply-hidden","visible","m","ALL","NULL","NULL","1","N","N","Using where; Using join buffer (hash join)" +"1k","root-first","invisible","c","index","PRIMARY","8","21","N","N","Using where" +"1k","root-first","invisible","m","eq_ref","PRIMARY","8","1","N","N","NULL" +"1k","root-middle","invisible","c","range","PRIMARY","8","999","N","N","Using where" +"1k","root-middle","invisible","m","eq_ref","PRIMARY","8","1","N","N","NULL" +"1k","root-last","invisible","c","range","PRIMARY","8","999","N","N","Using where" +"1k","root-last","invisible","m","eq_ref","PRIMARY","8","1","N","N","NULL" +"1k","reply-top5","invisible","root","range","PRIMARY","8","2","Y","Y","Using where; Using index; Using temporary; Using filesort; Rematerialize ()" +"1k","reply-top5","invisible","","ALL","NULL","NULL","5","N","N","NULL" +"1k","reply-top5","invisible","m","ALL","NULL","NULL","1","N","N","Using where; Using join buffer (hash join)" +"1k","reply-top5","invisible","c","index","PRIMARY","8","5","N","N","Using where" +"1k","reply-hotspot-first","invisible","c","ALL","NULL","NULL","1962","Y","Y","Using where; Using temporary; Using filesort" +"1k","reply-hotspot-first","invisible","m","ALL","NULL","NULL","1","N","N","Using where; Using join buffer (hash join)" +"1k","reply-hotspot-middle","invisible","c","range","PRIMARY","8","779","N","N","Using where" +"1k","reply-hotspot-middle","invisible","m","eq_ref","PRIMARY","8","1","N","N","NULL" +"1k","reply-count-active","invisible","comment","ALL","NULL","NULL","1962","N","N","Using where" +"1k","deleted-root-placeholder","invisible","c","index","PRIMARY","8","21","N","N","Using where" +"1k","deleted-root-placeholder","invisible","m","eq_ref","PRIMARY","8","1","N","N","NULL" +"1k","deleted-reply-hidden","invisible","c","index","PRIMARY","8","21","N","N","Using where" +"1k","deleted-reply-hidden","invisible","m","eq_ref","PRIMARY","8","1","N","N","NULL" +"10k","root-first","visible","c","ref","idx_comment_post_parent_id","17","12","Y","Y","Using index condition; Using temporary; Using filesort" +"10k","root-first","visible","m","ALL","NULL","NULL","1","N","N","Using where; Using join buffer (hash join)" +"10k","root-middle","visible","c","range","idx_comment_post_parent_id","25","6","Y","Y","Using index condition; Using temporary; Using filesort" +"10k","root-middle","visible","m","ALL","NULL","NULL","1","N","N","Using where; Using join buffer (hash join)" +"10k","root-last","visible","c","range","idx_comment_post_parent_id","25","11","Y","Y","Using index condition; Using temporary; Using filesort" +"10k","root-last","visible","m","ALL","NULL","NULL","1","N","N","Using where; Using join buffer (hash join)" +"10k","reply-top5","visible","root","range","PRIMARY","8","12","Y","Y","Using where; Using index; Using temporary; Using filesort; Rematerialize ()" +"10k","reply-top5","visible","","ALL","NULL","NULL","2","N","N","NULL" +"10k","reply-top5","visible","m","ALL","NULL","NULL","1","N","N","Using where; Using join buffer (hash join)" +"10k","reply-top5","visible","c","ref","idx_comment_parent_deleted_id","9","2","Y","N","Using filesort" +"10k","reply-hotspot-first","visible","c","ref","idx_comment_parent_deleted_id","9","103","Y","Y","Using temporary; Using filesort" +"10k","reply-hotspot-first","visible","m","ALL","NULL","NULL","1","N","N","Using where; Using join buffer (hash join)" +"10k","reply-hotspot-middle","visible","c","ref","idx_comment_parent_deleted_id","9","103","Y","Y","Using index condition; Using temporary; Using filesort" +"10k","reply-hotspot-middle","visible","m","ALL","NULL","NULL","1","N","N","Using where; Using join buffer (hash join)" +"10k","reply-count-active","visible","comment","ref","idx_comment_parent_deleted_id","10","100","N","N","Using index" +"10k","deleted-root-placeholder","visible","c","index_merge","idx_comment_post_parent_id,idx_comment_parent_deleted_id","17,10","2","Y","Y","Using intersect(idx_comment_post_parent_id,idx_comment_parent_deleted_id); Using where; Using temporary; Using filesort" +"10k","deleted-root-placeholder","visible","m","ALL","NULL","NULL","1","N","N","Using where; Using join buffer (hash join)" +"10k","deleted-reply-hidden","visible","c","ref","idx_comment_parent_deleted_id","10","100","Y","Y","Using temporary; Using filesort" +"10k","deleted-reply-hidden","visible","m","ALL","NULL","NULL","1","N","N","Using where; Using join buffer (hash join)" +"10k","root-first","invisible","c","ALL","NULL","NULL","19908","Y","Y","Using where; Using temporary; Using filesort" +"10k","root-first","invisible","m","ALL","NULL","NULL","1","N","N","Using where; Using join buffer (hash join)" +"10k","root-middle","invisible","c","range","PRIMARY","8","9954","Y","Y","Using where; Using temporary; Using filesort" +"10k","root-middle","invisible","m","ALL","NULL","NULL","1","N","N","Using where; Using join buffer (hash join)" +"10k","root-last","invisible","c","range","PRIMARY","8","9954","Y","Y","Using where; Using temporary; Using filesort" +"10k","root-last","invisible","m","ALL","NULL","NULL","1","N","N","Using where; Using join buffer (hash join)" +"10k","reply-top5","invisible","root","range","PRIMARY","8","12","Y","Y","Using where; Using index; Using temporary; Using filesort; Rematerialize ()" +"10k","reply-top5","invisible","","ALL","NULL","NULL","5","N","N","NULL" +"10k","reply-top5","invisible","m","ALL","NULL","NULL","1","N","N","Using where; Using join buffer (hash join)" +"10k","reply-top5","invisible","c","index","PRIMARY","8","5","N","N","Using where" +"10k","reply-hotspot-first","invisible","c","ALL","NULL","NULL","19908","Y","Y","Using where; Using temporary; Using filesort" +"10k","reply-hotspot-first","invisible","m","ALL","NULL","NULL","1","N","N","Using where; Using join buffer (hash join)" +"10k","reply-hotspot-middle","invisible","c","range","PRIMARY","8","9954","N","N","Using where" +"10k","reply-hotspot-middle","invisible","m","eq_ref","PRIMARY","8","1","N","N","NULL" +"10k","reply-count-active","invisible","comment","ALL","NULL","NULL","19908","N","N","Using where" +"10k","deleted-root-placeholder","invisible","c","ALL","NULL","NULL","19908","Y","Y","Using where; Using temporary; Using filesort" +"10k","deleted-root-placeholder","invisible","m","ALL","NULL","NULL","1","N","N","Using where; Using join buffer (hash join)" +"10k","deleted-reply-hidden","invisible","c","index","PRIMARY","8","21","N","N","Using where" +"10k","deleted-reply-hidden","invisible","m","eq_ref","PRIMARY","8","1","N","N","NULL" +"100k","root-first","visible","c","ref","idx_comment_post_parent_id","17","113","Y","Y","Using index condition; Using temporary; Using filesort" +"100k","root-first","visible","m","ALL","NULL","NULL","1","N","N","Using where; Using join buffer (hash join)" +"100k","root-middle","visible","c","range","idx_comment_post_parent_id","25","57","Y","Y","Using index condition; Using temporary; Using filesort" +"100k","root-middle","visible","m","ALL","NULL","NULL","1","N","N","Using where; Using join buffer (hash join)" +"100k","root-last","visible","c","range","idx_comment_post_parent_id","25","21","Y","Y","Using index condition; Using temporary; Using filesort" +"100k","root-last","visible","m","ALL","NULL","NULL","1","N","N","Using where; Using join buffer (hash join)" +"100k","reply-top5","visible","root","range","PRIMARY","8","20","Y","Y","Using where; Using index; Using temporary; Using filesort; Rematerialize ()" +"100k","reply-top5","visible","","ALL","NULL","NULL","4","N","N","NULL" +"100k","reply-top5","visible","m","ALL","NULL","NULL","1","N","N","Using where; Using join buffer (hash join)" +"100k","reply-top5","visible","c","ref","idx_comment_parent_deleted_id","9","4","Y","N","Using filesort" +"100k","reply-hotspot-first","visible","c","ref","idx_comment_parent_deleted_id","9","103","Y","Y","Using temporary; Using filesort" +"100k","reply-hotspot-first","visible","m","ALL","NULL","NULL","1","N","N","Using where; Using join buffer (hash join)" +"100k","reply-hotspot-middle","visible","c","ref","idx_comment_parent_deleted_id","9","103","Y","Y","Using index condition; Using temporary; Using filesort" +"100k","reply-hotspot-middle","visible","m","ALL","NULL","NULL","1","N","N","Using where; Using join buffer (hash join)" +"100k","reply-count-active","visible","comment","ref","idx_comment_parent_deleted_id","10","100","N","N","Using index" +"100k","deleted-root-placeholder","visible","c","index_merge","idx_comment_post_parent_id,idx_comment_parent_deleted_id","17,10","11","Y","Y","Using intersect(idx_comment_post_parent_id,idx_comment_parent_deleted_id); Using where; Using temporary; Using filesort" +"100k","deleted-root-placeholder","visible","m","ALL","NULL","NULL","1","N","N","Using where; Using join buffer (hash join)" +"100k","deleted-reply-hidden","visible","c","ref","idx_comment_parent_deleted_id","10","100","Y","Y","Using temporary; Using filesort" +"100k","deleted-reply-hidden","visible","m","ALL","NULL","NULL","1","N","N","Using where; Using join buffer (hash join)" +"100k","root-first","invisible","c","ALL","NULL","NULL","99370","Y","Y","Using where; Using temporary; Using filesort" +"100k","root-first","invisible","m","ALL","NULL","NULL","1","N","N","Using where; Using join buffer (hash join)" +"100k","root-middle","invisible","c","range","PRIMARY","8","49685","Y","Y","Using where; Using temporary; Using filesort" +"100k","root-middle","invisible","m","ALL","NULL","NULL","1","N","N","Using where; Using join buffer (hash join)" +"100k","root-last","invisible","c","range","PRIMARY","8","49685","Y","Y","Using where; Using temporary; Using filesort" +"100k","root-last","invisible","m","ALL","NULL","NULL","1","N","N","Using where; Using join buffer (hash join)" +"100k","reply-top5","invisible","root","range","PRIMARY","8","20","Y","Y","Using where; Using index; Using temporary; Using filesort; Rematerialize ()" +"100k","reply-top5","invisible","","ALL","NULL","NULL","5","N","N","NULL" +"100k","reply-top5","invisible","m","ALL","NULL","NULL","1","N","N","Using where; Using join buffer (hash join)" +"100k","reply-top5","invisible","c","index","PRIMARY","8","5","N","N","Using where" +"100k","reply-hotspot-first","invisible","c","ALL","NULL","NULL","99370","Y","Y","Using where; Using temporary; Using filesort" +"100k","reply-hotspot-first","invisible","m","ALL","NULL","NULL","1","N","N","Using where; Using join buffer (hash join)" +"100k","reply-hotspot-middle","invisible","c","range","PRIMARY","8","49685","Y","Y","Using where; Using temporary; Using filesort" +"100k","reply-hotspot-middle","invisible","m","ALL","NULL","NULL","1","N","N","Using where; Using join buffer (hash join)" +"100k","reply-count-active","invisible","comment","ALL","NULL","NULL","99370","N","N","Using where" +"100k","deleted-root-placeholder","invisible","c","ALL","NULL","NULL","99370","Y","Y","Using where; Using temporary; Using filesort" +"100k","deleted-root-placeholder","invisible","m","ALL","NULL","NULL","1","N","N","Using where; Using join buffer (hash join)" +"100k","deleted-reply-hidden","invisible","c","ALL","NULL","NULL","99370","Y","Y","Using where; Using temporary; Using filesort" +"100k","deleted-reply-hidden","invisible","m","ALL","NULL","NULL","1","N","N","Using where; Using join buffer (hash join)" diff --git "a/docs/conception/sprint04/benchmark/metrics/\354\213\244\355\226\211\352\263\204\355\232\215-\354\232\224\354\225\275.csv" "b/docs/conception/sprint04/benchmark/metrics/\354\213\244\355\226\211\352\263\204\355\232\215-\354\232\224\354\225\275.csv" new file mode 100644 index 0000000..9d781cc --- /dev/null +++ "b/docs/conception/sprint04/benchmark/metrics/\354\213\244\355\226\211\352\263\204\355\232\215-\354\232\224\354\225\275.csv" @@ -0,0 +1,68 @@ +"file","estimated_rows","actual_rows","loops","index" +"deleted-reply-hidden-no-index-100k.txt","21","0","1","scan" +"deleted-reply-hidden-no-index-10k.txt","21","0","1","scan" +"deleted-reply-hidden-no-index-1k.txt","21","0","1","scan" +"deleted-reply-hidden-no-index-1m.txt","21","4","1","scan" +"deleted-root-100k.txt","21","21","1","idx_comment_post_parent_id" +"deleted-root-10k.txt","21","21","1","idx_comment_post_parent_id" +"deleted-root-1k.txt","1","0","1","idx_comment_post_parent_id" +"deleted-root-1m.txt","21","21","1","idx_comment_post_parent_id" +"deleted-root-no-index-100k.txt","21","0","1","scan" +"deleted-root-no-index-10k.txt","21","20","1","scan" +"deleted-root-no-index-1k.txt","21","2","1","scan" +"deleted-root-no-index-1m.txt","21","21","1","scan" +"deleted-root-placeholder-no-index-100k.txt","21","0","1","scan" +"deleted-root-placeholder-no-index-10k.txt","21","20","1","scan" +"deleted-root-placeholder-no-index-1k.txt","21","2","1","scan" +"deleted-root-placeholder-no-index-1m.txt","21","21","1","scan" +"reply-count-100k.txt","1","1","1","idx_comment_parent_deleted_id" +"reply-count-10k.txt","1","1","1","idx_comment_parent_deleted_id" +"reply-count-1k.txt","1","1","1","idx_comment_parent_deleted_id" +"reply-count-1m.txt","1","1","1","idx_comment_parent_deleted_id" +"reply-count-no-index-100k.txt","1","1","1","scan" +"reply-count-no-index-10k.txt","1","1","1","scan" +"reply-count-no-index-1k.txt","1","1","1","scan" +"reply-count-no-index-1m.txt","1","1","1","scan" +"reply-hotspot-100k.txt","1","0","1","idx_comment_parent_deleted_id" +"reply-hotspot-10k.txt","1","0","1","idx_comment_parent_deleted_id" +"reply-hotspot-1k.txt","1","0","1","idx_comment_parent_deleted_id" +"reply-hotspot-1m.txt","21","21","1","idx_comment_parent_deleted_id" +"reply-hotspot-no-index-100k.txt","21","21","1","scan" +"reply-hotspot-no-index-10k.txt","21","21","1","scan" +"reply-hotspot-no-index-1k.txt","21","8","1","scan" +"reply-hotspot-no-index-1m.txt","21","21","1","scan" +"reply-top5-no-index-100k.txt","25","25","1","scan" +"reply-top5-no-index-10k.txt","25","25","1","scan" +"reply-top5-no-index-1k.txt","25","12","1","scan" +"reply-top5-no-index-1m.txt","25","25","1","scan" +"root-first-no-index-100k.txt","21","0","1","scan" +"root-first-no-index-10k.txt","21","20","1","scan" +"root-first-no-index-1k.txt","21","2","1","scan" +"root-first-no-index-1m.txt","21","21","1","scan" +"root-first-page-100k.txt","21","21","1","idx_comment_post_parent_id" +"root-first-page-10k.txt","21","21","1","idx_comment_post_parent_id" +"root-first-page-1k.txt","10","10","1","idx_comment_post_parent_id" +"root-last-100k.txt","21","21","1","idx_comment_post_parent_id" +"root-last-10k.txt","21","21","1","idx_comment_post_parent_id" +"root-last-1k.txt","1","0","1","idx_comment_post_parent_id" +"root-last-1m.txt","21","21","1","idx_comment_post_parent_id" +"root-last-no-index-100k.txt","21","0","1","scan" +"root-last-no-index-10k.txt","21","20","1","scan" +"root-last-no-index-1k.txt","21","2","1","scan" +"root-last-no-index-1m.txt","21","21","1","scan" +"root-middle-100k.txt","21","21","1","idx_comment_post_parent_id" +"root-middle-10k.txt","21","21","1","idx_comment_post_parent_id" +"root-middle-1k.txt","1","0","1","idx_comment_post_parent_id" +"root-middle-1m.txt","21","21","1","idx_comment_post_parent_id" +"root-middle-no-index-100k.txt","21","0","1","PRIMARY" +"root-middle-no-index-10k.txt","21","19","1","PRIMARY" +"root-middle-no-index-1k.txt","21","1","1","PRIMARY" +"root-middle-no-index-1m.txt","21","21","1","PRIMARY" +"top5-100k.txt","5","0","1","idx_comment_parent_deleted_id" +"top5-10k.txt","5","0","1","idx_comment_parent_deleted_id" +"top5-1k.txt","5","0","1","idx_comment_parent_deleted_id" +"top5-1m.txt","25","25","1","idx_comment_parent_deleted_id" +"top5-no-index-100k.txt","25","0","1","scan" +"top5-no-index-10k.txt","25","0","1","scan" +"top5-no-index-1k.txt","25","0","1","scan" +"top5-no-index-1m.txt","25","25","1","scan" diff --git "a/docs/conception/sprint04/benchmark/metrics/\354\213\244\355\226\211\354\213\234\352\260\204.csv" "b/docs/conception/sprint04/benchmark/metrics/\354\213\244\355\226\211\354\213\234\352\260\204.csv" new file mode 100644 index 0000000..256b426 --- /dev/null +++ "b/docs/conception/sprint04/benchmark/metrics/\354\213\244\355\226\211\354\213\234\352\260\204.csv" @@ -0,0 +1,37 @@ +"scale","query","avg_ms","p95_ms" +"1k","root-first","0.332","0.468" +"1k","root-middle","0.294","0.37" +"1k","root-last","0.315","0.468" +"1k","reply-stats","0.278","0.404" +"1k","reply-top5","0.445","0.653" +"1k","reply-hotspot-first","0.435","0.662" +"1k","reply-hotspot-middle","0.427","0.655" +"1k","reply-count-active","0.222","0.342" +"1k","deleted-root-placeholder","0.366","0.553" +"10k","root-first","0.549","0.754" +"10k","root-middle","0.416","0.622" +"10k","root-last","0.347","0.508" +"10k","reply-stats","0.337","0.436" +"10k","reply-top5","0.435","0.612" +"10k","reply-hotspot-first","0.377","0.552" +"10k","reply-hotspot-middle","0.402","0.579" +"10k","reply-count-active","0.227","0.326" +"10k","deleted-root-placeholder","0.529","0.602" +"100k","root-first","2.606","2.979" +"100k","root-middle","1.498","2.094" +"100k","root-last","0.376","0.584" +"100k","reply-stats","0.224","0.31" +"100k","reply-top5","0.468","0.682" +"100k","reply-hotspot-first","0.424","0.608" +"100k","reply-hotspot-middle","0.464","0.699" +"100k","reply-count-active","0.191","0.336" +"100k","deleted-root-placeholder","1.729","2.124" +"1m","root-first","36.578","39.388" +"1m","root-middle","19.84","22.533" +"1m","root-last","0.449","0.692" +"1m","reply-stats","0.399","0.542" +"1m","reply-top5","0.479","0.626" +"1m","reply-hotspot-first","0.449","0.6" +"1m","reply-hotspot-middle","0.498","0.724" +"1m","reply-count-active","0.258","0.388" +"1m","deleted-root-placeholder","29.876","32.77" diff --git "a/docs/conception/sprint04/benchmark/metrics/\354\235\270\353\215\261\354\212\244-\353\271\204\352\265\220.csv" "b/docs/conception/sprint04/benchmark/metrics/\354\235\270\353\215\261\354\212\244-\353\271\204\352\265\220.csv" new file mode 100644 index 0000000..4c5ffd1 --- /dev/null +++ "b/docs/conception/sprint04/benchmark/metrics/\354\235\270\353\215\261\354\212\244-\353\271\204\352\265\220.csv" @@ -0,0 +1,73 @@ +"scale","scenario","mode","estimated_rows","actual_rows","loops","index" +"1k","root-first","with-index","2","2","1","idx_comment_post_parent_id" +"1k","root-first","no-index","21","2","1","scan" +"1k","root-middle","with-index","1","1","1","idx_comment_post_parent_id" +"1k","root-middle","no-index","21","1","1","PRIMARY" +"1k","root-last","with-index","2","2","1","idx_comment_post_parent_id" +"1k","root-last","no-index","21","2","1","scan" +"1k","reply-top5","with-index","12","12","1","idx_comment_parent_deleted_id" +"1k","reply-top5","no-index","25","12","1","scan" +"1k","reply-hotspot","with-index","8","8","1","idx_comment_parent_deleted_id" +"1k","reply-hotspot","no-index","21","8","1","scan" +"1k","reply-count","with-index","1","1","1","idx_comment_parent_deleted_id" +"1k","reply-count","no-index","1","1","1","scan" +"1k","deleted-root","with-index","1","2","1","idx_comment_post_parent_id" +"1k","deleted-root","no-index","21","2","1","scan" +"1k","deleted-root-placeholder","with-index","1","2","1","idx_comment_post_parent_id" +"1k","deleted-root-placeholder","no-index","21","2","1","scan" +"1k","deleted-reply-hidden","with-index","1","0","1","idx_comment_parent_deleted_id" +"1k","deleted-reply-hidden","no-index","21","0","1","scan" +"10k","root-first","with-index","20","20","1","idx_comment_post_parent_id" +"10k","root-first","no-index","21","20","1","scan" +"10k","root-middle","with-index","19","19","1","idx_comment_post_parent_id" +"10k","root-middle","no-index","21","19","1","PRIMARY" +"10k","root-last","with-index","20","20","1","idx_comment_post_parent_id" +"10k","root-last","no-index","21","20","1","scan" +"10k","reply-top5","with-index","25","25","1","idx_comment_parent_deleted_id" +"10k","reply-top5","no-index","25","25","1","scan" +"10k","reply-hotspot","with-index","21","21","1","idx_comment_parent_deleted_id" +"10k","reply-hotspot","no-index","21","21","1","scan" +"10k","reply-count","with-index","1","1","1","idx_comment_parent_deleted_id" +"10k","reply-count","no-index","1","1","1","scan" +"10k","deleted-root","with-index","4","20","1","idx_comment_post_parent_id" +"10k","deleted-root","no-index","21","20","1","scan" +"10k","deleted-root-placeholder","with-index","4","20","1","idx_comment_post_parent_id" +"10k","deleted-root-placeholder","no-index","21","20","1","scan" +"10k","deleted-reply-hidden","with-index","1","0","1","idx_comment_parent_deleted_id" +"10k","deleted-reply-hidden","no-index","21","0","1","scan" +"100k","root-first","with-index","1","0","1","idx_comment_post_parent_id" +"100k","root-first","no-index","21","0","1","scan" +"100k","root-middle","with-index","1","0","1","idx_comment_post_parent_id" +"100k","root-middle","no-index","21","0","1","PRIMARY" +"100k","root-last","with-index","1","0","1","idx_comment_post_parent_id" +"100k","root-last","no-index","21","0","1","scan" +"100k","reply-top5","with-index","25","25","1","idx_comment_parent_deleted_id" +"100k","reply-top5","no-index","25","25","1","scan" +"100k","reply-hotspot","with-index","21","21","1","idx_comment_parent_deleted_id" +"100k","reply-hotspot","no-index","21","21","1","scan" +"100k","reply-count","with-index","1","1","1","idx_comment_parent_deleted_id" +"100k","reply-count","no-index","1","1","1","scan" +"100k","deleted-root","with-index","1","0","1","idx_comment_post_parent_id" +"100k","deleted-root","no-index","21","0","1","scan" +"100k","deleted-root-placeholder","with-index","1","0","1","idx_comment_post_parent_id" +"100k","deleted-root-placeholder","no-index","21","0","1","scan" +"100k","deleted-reply-hidden","with-index","1","0","1","idx_comment_parent_deleted_id" +"100k","deleted-reply-hidden","no-index","21","0","1","scan" +"1m","root-first","with-index","1","0","1","idx_comment_post_parent_id" +"1m","root-first","no-index","21","21","1","scan" +"1m","root-middle","with-index","1","0","1","idx_comment_post_parent_id" +"1m","root-middle","no-index","21","21","1","PRIMARY" +"1m","root-last","with-index","1","0","1","idx_comment_post_parent_id" +"1m","root-last","no-index","21","21","1","scan" +"1m","reply-top5","with-index","5","0","1","idx_comment_parent_deleted_id" +"1m","reply-top5","no-index","25","25","1","scan" +"1m","reply-hotspot","with-index","1","0","1","idx_comment_parent_deleted_id" +"1m","reply-hotspot","no-index","21","21","1","scan" +"1m","reply-count","with-index","1","1","1","idx_comment_parent_deleted_id" +"1m","reply-count","no-index","1","1","1","scan" +"1m","deleted-root","with-index","1","0","1","idx_comment_post_parent_id" +"1m","deleted-root","no-index","21","21","1","scan" +"1m","deleted-root-placeholder","with-index","1","0","1","idx_comment_post_parent_id" +"1m","deleted-root-placeholder","no-index","21","21","1","scan" +"1m","deleted-reply-hidden","with-index","1","0","1","idx_comment_parent_deleted_id" +"1m","deleted-reply-hidden","no-index","21","4","1","scan" diff --git "a/docs/conception/sprint04/benchmark/metrics/\354\240\225\355\225\251\354\204\261-\352\262\200\354\246\235.tsv" "b/docs/conception/sprint04/benchmark/metrics/\354\240\225\355\225\251\354\204\261-\352\262\200\354\246\235.tsv" new file mode 100644 index 0000000..62ee052 --- /dev/null +++ "b/docs/conception/sprint04/benchmark/metrics/\354\240\225\355\225\251\354\204\261-\352\262\200\354\246\235.tsv" @@ -0,0 +1,5 @@ +규모\t전체\t루트\t대댓글\t삭제\t게시글수불일치\t부모게시글불일치\t최대활성대댓글\t중복ID +1천건\t1000\t200\t800\t180\t0\t0\t100\t0 +1만건\t10000\t2000\t8000\t1980\t0\t0\t100\t0 +10만건\t100000\t20000\t80000\t19980\t0\t0\t100\t0 +백만건\t1000000\t200000\t800000\t199980\t0\t0\t100\t0 diff --git "a/docs/conception/sprint04/benchmark/queries/\353\214\200\353\214\223\352\270\200-\354\203\201\354\234\2045\352\260\234-\354\235\274\352\264\204.sql" "b/docs/conception/sprint04/benchmark/queries/\353\214\200\353\214\223\352\270\200-\354\203\201\354\234\2045\352\260\234-\354\235\274\352\264\204.sql" new file mode 100644 index 0000000..a61a5fc --- /dev/null +++ "b/docs/conception/sprint04/benchmark/queries/\353\214\200\353\214\223\352\270\200-\354\203\201\354\234\2045\352\260\234-\354\235\274\352\264\204.sql" @@ -0,0 +1,6 @@ +SELECT c.comment_id, c.post_id, c.parent_id, c.content, c.is_deleted, + c.created_at, ROW_NUMBER() OVER ( + PARTITION BY c.parent_id ORDER BY c.created_at ASC, c.comment_id ASC + ) AS rn +FROM comment c +WHERE c.parent_id IN (:rootCommentIds); diff --git "a/docs/conception/sprint04/benchmark/queries/\353\243\250\355\212\270-\353\247\210\354\247\200\353\247\211-\355\216\230\354\235\264\354\247\200.sql" "b/docs/conception/sprint04/benchmark/queries/\353\243\250\355\212\270-\353\247\210\354\247\200\353\247\211-\355\216\230\354\235\264\354\247\200.sql" new file mode 100644 index 0000000..ca3bd99 --- /dev/null +++ "b/docs/conception/sprint04/benchmark/queries/\353\243\250\355\212\270-\353\247\210\354\247\200\353\247\211-\355\216\230\354\235\264\354\247\200.sql" @@ -0,0 +1,7 @@ +SELECT c.comment_id, c.post_id, c.parent_id, c.content, c.is_deleted, + c.created_at +FROM comment c +WHERE c.post_id = :postId AND c.parent_id IS NULL + AND (c.created_at > :cursorCreatedAt + OR (c.created_at = :cursorCreatedAt AND c.comment_id > :cursorId)) +ORDER BY c.created_at ASC, c.comment_id ASC LIMIT 21; diff --git "a/docs/conception/sprint04/benchmark/queries/\353\243\250\355\212\270-\354\244\221\352\260\204-\355\216\230\354\235\264\354\247\200.sql" "b/docs/conception/sprint04/benchmark/queries/\353\243\250\355\212\270-\354\244\221\352\260\204-\355\216\230\354\235\264\354\247\200.sql" new file mode 100644 index 0000000..7e17b8e --- /dev/null +++ "b/docs/conception/sprint04/benchmark/queries/\353\243\250\355\212\270-\354\244\221\352\260\204-\355\216\230\354\235\264\354\247\200.sql" @@ -0,0 +1,7 @@ +SELECT c.comment_id, c.post_id, c.parent_id, c.content, c.is_deleted, + c.is_anonymous, c.writer_ip, c.created_at +FROM comment c +WHERE c.post_id = :postId AND c.parent_id IS NULL + AND (c.created_at > :cursorCreatedAt + OR (c.created_at = :cursorCreatedAt AND c.comment_id > :cursorId)) +ORDER BY c.created_at ASC, c.comment_id ASC LIMIT 21; diff --git "a/docs/conception/sprint04/benchmark/queries/\353\243\250\355\212\270-\354\262\253-\355\216\230\354\235\264\354\247\200.sql" "b/docs/conception/sprint04/benchmark/queries/\353\243\250\355\212\270-\354\262\253-\355\216\230\354\235\264\354\247\200.sql" new file mode 100644 index 0000000..6d13816 --- /dev/null +++ "b/docs/conception/sprint04/benchmark/queries/\353\243\250\355\212\270-\354\262\253-\355\216\230\354\235\264\354\247\200.sql" @@ -0,0 +1,6 @@ +SELECT c.comment_id, c.post_id, c.parent_id, c.content, c.is_deleted, + c.is_anonymous, c.writer_ip, c.created_at, + m.public_id AS member_public_id, m.nickname, m.profile_image_url +FROM comment c LEFT JOIN member m ON m.member_id = c.member_id +WHERE c.post_id = :postId AND c.parent_id IS NULL +ORDER BY c.created_at ASC, c.comment_id ASC LIMIT 21; diff --git "a/docs/conception/sprint04/benchmark/queries/\355\225\253\354\212\244\355\214\237-\353\214\200\353\214\223\352\270\200-\354\244\221\352\260\204-\355\216\230\354\235\264\354\247\200.sql" "b/docs/conception/sprint04/benchmark/queries/\355\225\253\354\212\244\355\214\237-\353\214\200\353\214\223\352\270\200-\354\244\221\352\260\204-\355\216\230\354\235\264\354\247\200.sql" new file mode 100644 index 0000000..718605e --- /dev/null +++ "b/docs/conception/sprint04/benchmark/queries/\355\225\253\354\212\244\355\214\237-\353\214\200\353\214\223\352\270\200-\354\244\221\352\260\204-\355\216\230\354\235\264\354\247\200.sql" @@ -0,0 +1,6 @@ +SELECT c.comment_id, c.post_id, c.parent_id, c.content, c.is_deleted, + c.created_at +FROM comment c WHERE c.parent_id = :rootCommentId + AND (c.created_at > :cursorCreatedAt + OR (c.created_at = :cursorCreatedAt AND c.comment_id > :cursorId)) +ORDER BY c.created_at ASC, c.comment_id ASC LIMIT 21; diff --git "a/docs/conception/sprint04/benchmark/queries/\355\225\253\354\212\244\355\214\237-\353\214\200\353\214\223\352\270\200-\354\262\253-\355\216\230\354\235\264\354\247\200.sql" "b/docs/conception/sprint04/benchmark/queries/\355\225\253\354\212\244\355\214\237-\353\214\200\353\214\223\352\270\200-\354\262\253-\355\216\230\354\235\264\354\247\200.sql" new file mode 100644 index 0000000..e283e39 --- /dev/null +++ "b/docs/conception/sprint04/benchmark/queries/\355\225\253\354\212\244\355\214\237-\353\214\200\353\214\223\352\270\200-\354\262\253-\355\216\230\354\235\264\354\247\200.sql" @@ -0,0 +1,4 @@ +SELECT c.comment_id, c.post_id, c.parent_id, c.content, c.is_deleted, + c.created_at +FROM comment c WHERE c.parent_id = :rootCommentId +ORDER BY c.created_at ASC, c.comment_id ASC LIMIT 21; diff --git "a/docs/conception/sprint04/benchmark/queries/\355\231\234\354\204\261-\353\214\200\353\214\223\352\270\200-\354\210\230.sql" "b/docs/conception/sprint04/benchmark/queries/\355\231\234\354\204\261-\353\214\200\353\214\223\352\270\200-\354\210\230.sql" new file mode 100644 index 0000000..c323121 --- /dev/null +++ "b/docs/conception/sprint04/benchmark/queries/\355\231\234\354\204\261-\353\214\200\353\214\223\352\270\200-\354\210\230.sql" @@ -0,0 +1,2 @@ +SELECT COUNT(*) AS active_reply_count +FROM comment WHERE parent_id = :rootCommentId AND is_deleted = FALSE; diff --git "a/docs/conception/sprint04/benchmark/results/\353\214\223\352\270\200-\353\262\244\354\271\230\353\247\210\355\201\254-\352\262\260\352\263\274.md" "b/docs/conception/sprint04/benchmark/results/\353\214\223\352\270\200-\353\262\244\354\271\230\353\247\210\355\201\254-\352\262\260\352\263\274.md" new file mode 100644 index 0000000..468e260 --- /dev/null +++ "b/docs/conception/sprint04/benchmark/results/\353\214\223\352\270\200-\353\262\244\354\271\230\353\247\210\355\201\254-\352\262\260\352\263\274.md" @@ -0,0 +1,252 @@ +# 댓글 벤치마크 결과 + +## 1. 멘토는 왜 나에게 이 벤치마크 실험을 시켰을까? + +주니어 개발자가 실무에 가서 가장 많이 내는 대형 사고가 있습니다. + +1. 로컬 컴퓨터에서 데이터 10개, 50개 넣고 기능 테스트를 합니다. +2. "어? 응답 속도 0.005초(5ms) 나오네? 엄청 빠르네!" 하고 신나서 운영 서버에 배포합니다. +3. 서비스가 오픈되고 회원이 늘어서 게시글 수천 개, 댓글 수만 개가 쌓입니다. +4. 어떤 사용자가 댓글이 많이 달린 인기글을 클릭하는 순간, **DB CPU가 100%를 치면서 서비스 전체가 멈춰버립니다.** + +왜 그럴까요? +데이터가 50개일 때는 인덱스가 있든 없든, 쿼리를 개판으로 짰든 간에 컴퓨터 메모리가 워낙 빨라서 다 0.001초 만에 돌아갑니다. +하지만 데이터가 1만 개, 10만 개, 100만 개로 불어나면 **DB 엔진(MySQL 옵티마이저)이 쿼리를 실행하는 물리적 방식이 완전히 뒤바뀝니다.** + +멘토의 의도는 이겁니다: +> *"단순히 기능이 '돌아가게' 만드는 건 학원생도 한다. +> 네가 만든 댓글 아키텍처가 데이터가 100만 개 쌓이고, 특정 글에 댓글이 10만 개씩 몰리는 **실제 서비스 환경에서도 살아남을 수 있는지 네 눈으로 직접 확인하고 증명해 봐라.** 그래야 실무에서 장애 안 내는 진짜 백엔드 엔지니어가 된다."* + +--- + +## 2. 5가지 학습 목표의 진짜 이유와 실무 원리 + +### ① 데이터 규모와 분포가 실행계획에 미치는 영향을 설명한다 +- **왜 필요한가?**: + 데이터가 1,000건일 때는 테이블 전체 크기가 고작 몇 십 KB밖에 안 됩니다. 이때는 옵티마이저가 *"인덱스 책갈피 뒤져서 데이터 찾으러 가느니, 그냥 처음부터 끝까지 책 통째로 읽는 게(Full Table Scan) 더 빠르겠다"*고 판단합니다. + 하지만 데이터가 100만 건(수백 MB)이 되면 테이블을 풀스캔하는 순간 서버가 죽습니다. + 그리고 더 중요한 건 **'데이터 분포(쏠림)'**입니다. 게시글 100개 중 99개는 댓글이 5개뿐인데, **단 1개의 핫포스트에 댓글 10만 개가 몰려 있다면?** 평균만 보면 빨라 보여도 그 인기글을 누르는 순간 사용자 화면이 멈춥니다. 멘토는 그 극한의 쏠림(Hotspot)을 견디는지 보라고 한 겁니다. + +### ② 단순 실행시간이 아니라 estimated rows, actual rows, loops와 인덱스 선택을 해석한다 +- **왜 필요한가? (가장 중요)**: + 로컬에서 쿼리 실행해서 나온 0.3ms라는 시간은 **믿을 게 못 됩니다.** 지금 내 컴퓨터에 나 혼자 쿼리를 날렸고, 데이터가 메모리 캐시에 올라와 있으니 빠른 것뿐입니다. + 진짜 봐야 하는 건 MySQL 엔진이 내부에서 한 짓입니다: + - **`estimated rows`**: 옵티마이저가 사전에 "이 조건이면 대충 몇 개 행을 뒤져야겠네" 하고 예상한 수치. + - **`actual rows`**: 실제로 MySQL이 하드디스크나 메모리에서 **물리적으로 뒤져서 읽은 행 수.** + - **`loops`**: 조인이나 반복 루프를 몇 번 수행했는가. + - **실무 예시**: 화면에는 `LIMIT 20`이라서 20개만 보여줍니다. 그런데 만약 인덱스가 없어서 MySQL이 **20만 개의 행(`actual rows = 200,000`)을 전부 읽어서 메모리에 올린 뒤 정렬(`Using filesort`)해서 20개만 잘라준 거라면?** + 지금은 나 혼자니까 0.3ms지만, 실무에서 사용자 100명이 동시에 누르면 DB 메모리가 터져서 서버가 뻗습니다. + **"화면에 20개 보여주려고 DB가 실제로 몇 개 행을 뒤졌는가?"**를 볼 줄 알아야 쿼리 튜닝을 할 수 있습니다. + +### ③ 재현 가능한 Benchmark Seed와 실행 절차를 만든다 +- **왜 필요한가?**: + 성능 테스트는 '과학 실험'이어야 합니다. + 오늘 테스트할 때 넣은 데이터랑, 내일 테스트할 때 넣은 데이터가 다르면 "어제보다 10ms 느려졌는데 코드가 문제인가? 데이터가 달라져서 그런가?"를 알 수가 없습니다. + 언제, 어디서, 누가 명령어를 쳐도 **항상 똑같은 시드 번호(`Random(20260907)`), 똑같은 100개 글, 똑같은 핫스팟에 100개 대댓글이 1초 만에 동일하게 생성되어야** "내가 인덱스를 바꿨을 때 성능이 얼마나 좋아졌는지"를 100% 정밀하게 비교할 수 있습니다. + +### ④ 인덱스의 읽기 이점과 쓰기 비용을 함께 평가한다 +- **왜 필요한가?**: + 초보 개발자는 조회가 조금만 느려도 "어? 인덱스 또 추가해야지!" 하면서 테이블 하나에 인덱스를 5개, 10개씩 덕지덕지 붙입니다. + 하지만 **인덱스는 공짜가 아닙니다.** + 인덱스를 달면 `SELECT`는 빨라지지만, 누군가 댓글을 쓸 때마다(`INSERT`) DB는 실제 테이블에도 데이터를 넣고, **인덱스 B-Tree 10개에도 찾아가서 정렬된 위치에 데이터를 끼워 넣어야(쓰기 비용)** 합니다. + 이번 실험에서 인덱스를 껐을 때(`INVISIBLE`)와 켰을 때(`VISIBLE`)를 비교해 보라고 한 이유는, **"이 인덱스가 쓰기 속도를 희생하면서까지 유지할 만큼 읽기 성능을 극적으로 올려주는가?"**를 계산할 줄 아는 시야를 갖추라는 뜻입니다. + +### ⑤ 운영 DB에서 테스트 Seed가 실행되지 않도록 안전장치를 설계한다 +- **왜 필요한가?**: + 실무에서 심심찮게 터지는 최악의 대형 사고가 있습니다. + 개발자가 로컬이나 테스트 서버인 줄 알고 100만 건 초기화 스크립트를 엔터 쳤는데, 알고 보니 **운영(Production) 실서비스 DB에 연결되어 있어서 실제 고객 데이터 수십만 건이 날아가는 사고**입니다. + 멘토는 *"사람의 주의력을 믿지 마라. 스크립트와 코드 자체에 DB 이름에 `test`나 `benchmark`가 안 들어가 있으면 SQL 에러(`SIGNAL SQLSTATE`)를 뿜으면서 스스로 멈추는 안전장치를 걸 줄 알아야 진짜 프로다"*라는 걸 가르쳐 준 겁니다. + +--- + +실제 MySQL 8.0에서 측정한 규모별 댓글 조회 벤치마크 결과입니다. 규모별 결과를 한 문서 안에서 비교합니다. + +# 1천건 댓글 벤치마크 결과 + +# 1K 결과 + +실제 MySQL 8.0.46 `snowthing_benchmark_1k`에서 동일 재생성 SQL로 검증했다. + +- comments: 1,000 +- roots/replies: 200/800 +- soft delete: 180 (Hotspot 활성 100건 보정 포함) +- post.comment_count 불일치: 0 +- 활성 대댓글 최대: 100 +- 부모·자식 post_id 불일치: 0 +- 9개 시나리오 원문과 평균/p95 측정 완료 + +## 데이터 정합성 검증 + +| 검증 항목 | 결과 | 판정 | +|---|---:|---| +| 전체 댓글 수 | 1000 | 통과 | +| 루트 댓글 수 | 200 | 통과 | +| 대댓글 수 | 800 | 통과 | +| 삭제 댓글 수 | 180 | 통과 | +| 게시글 댓글 수 불일치 | 0 | 통과 | +| 부모·자식 게시글 불일치 | 0 | 통과 | +| 루트별 최대 활성 대댓글 | 100 | 통과 | +| 중복 댓글 ID | 0 | 통과 | + +## 시나리오별 평균·p95 + +| 시나리오 | 평균(ms) | p95(ms) | +|---|---:|---:| +| root-first | 0.332 | 0.468 | +| root-middle | 0.294 | 0.37 | +| root-last | 0.315 | 0.468 | +| reply-stats | 0.278 | 0.404 | +| reply-top5 | 0.445 | 0.653 | +| reply-hotspot-first | 0.435 | 0.662 | +| reply-hotspot-middle | 0.427 | 0.655 | +| reply-count-active | 0.222 | 0.342 | +| deleted-root-placeholder | 0.366 | 0.553 | + +실행계획 원문은 [explain-plans](../explain-plans/)의 시나리오별 문서에서 확인합니다. + + +--- + +# 1만건 댓글 벤치마크 결과 + +# 10K 결과 + +- comments: 10,000 +- roots/replies: 2,000/8,000 +- soft delete: 1,980 (Hotspot 활성 100건 보정 포함) +- 실행 엔진: MySQL 8.0.46 InnoDB +- `post.comment_count` 및 부모·자식 post_id 불일치: 0 +- 활성 대댓글 최대: 100 +- 9개 시나리오 원문과 평균/p95 측정 완료 + +## 데이터 정합성 검증 + +| 검증 항목 | 결과 | 판정 | +|---|---:|---| +| 전체 댓글 수 | 10000 | 통과 | +| 루트 댓글 수 | 2000 | 통과 | +| 대댓글 수 | 8000 | 통과 | +| 삭제 댓글 수 | 1980 | 통과 | +| 게시글 댓글 수 불일치 | 0 | 통과 | +| 부모·자식 게시글 불일치 | 0 | 통과 | +| 루트별 최대 활성 대댓글 | 100 | 통과 | +| 중복 댓글 ID | 0 | 통과 | + +## 시나리오별 평균·p95 + +| 시나리오 | 평균(ms) | p95(ms) | +|---|---:|---:| +| root-first | 0.549 | 0.754 | +| root-middle | 0.416 | 0.622 | +| root-last | 0.347 | 0.508 | +| reply-stats | 0.337 | 0.436 | +| reply-top5 | 0.435 | 0.612 | +| reply-hotspot-first | 0.377 | 0.552 | +| reply-hotspot-middle | 0.402 | 0.579 | +| reply-count-active | 0.227 | 0.326 | +| deleted-root-placeholder | 0.529 | 0.602 | + +실행계획 원문은 [explain-plans](../explain-plans/)의 시나리오별 문서에서 확인합니다. + + +--- + +# 10만건 댓글 벤치마크 결과 + +# 100K 결과 + +- comments: 100,000 +- roots/replies: 20,000/80,000 +- soft delete: 19,980 (Hotspot 활성 100건 보정 포함) +- post.comment_count 불일치: 0 +- 활성 대댓글 최대: 100 +- 실제 `snowthing_benchmark_100k` 수정 분포 Seed 재생성 완료: benchmark 댓글 100,000건, 루트/대댓글 20,000/80,000, 활성/삭제 80,000/20,000. +- 게시글 분포: 일반 80개는 약 560건씩, 중간 19개는 약 2,370건씩, Hot post 1개는 10,000건. +- 부모·자식 post_id 불일치 0건 및 동적 선택 Hotspot 루트의 활성 대댓글 100건 확인. +- 9개 시나리오 원문과 평균/p95 측정 완료. + +## 데이터 정합성 검증 + +| 검증 항목 | 결과 | 판정 | +|---|---:|---| +| 전체 댓글 수 | 100000 | 통과 | +| 루트 댓글 수 | 20000 | 통과 | +| 대댓글 수 | 80000 | 통과 | +| 삭제 댓글 수 | 19980 | 통과 | +| 게시글 댓글 수 불일치 | 0 | 통과 | +| 부모·자식 게시글 불일치 | 0 | 통과 | +| 루트별 최대 활성 대댓글 | 100 | 통과 | +| 중복 댓글 ID | 0 | 통과 | + +## 시나리오별 평균·p95 + +| 시나리오 | 평균(ms) | p95(ms) | +|---|---:|---:| +| root-first | 2.606 | 2.979 | +| root-middle | 1.498 | 2.094 | +| root-last | 0.376 | 0.584 | +| reply-stats | 0.224 | 0.31 | +| reply-top5 | 0.468 | 0.682 | +| reply-hotspot-first | 0.424 | 0.608 | +| reply-hotspot-middle | 0.464 | 0.699 | +| reply-count-active | 0.191 | 0.336 | +| deleted-root-placeholder | 1.729 | 2.124 | + +실행계획 원문은 [explain-plans](../explain-plans/)의 시나리오별 문서에서 확인합니다. + + +--- + +# 백만건 댓글 벤치마크 결과 + +# 1M 결과 + +- comments: 1,000,000 +- roots/replies: 200,000/800,000 +- soft delete: 199,980 (약 20%; Hotspot 루트에 활성 대댓글 100건 확보) +- 실행 엔진: MySQL 8.0.46 InnoDB +- 생성 후 실제 COUNT 검증 완료 +- `post.comment_count` 불일치 0건, 루트별 활성 대댓글 최대 100건 +- root first-page CLI 측정(동일 MySQL 연결 조건): 샘플 5회 평균 107.58ms, p95 126.03ms. +- hotspot reply first-page 샘플 5회 평균 93.57ms. +- root first-page 20회 측정: 인덱스 visible 평균 102.47ms / p95 115.88ms, 인덱스 invisible 평균 226.22ms / p95 241.63ms. +- 운영 저장소 SQL과 같은 JOIN·LATERAL·정렬 구조로 9개 시나리오를 다시 측정했다. 단일 MySQL 세션 warm-up 5회 + 유효 20회 결과는 `timing.csv`에 저장했다. +- 1M 평균/p95(ms): root first 36.578/39.388, root middle 19.840/22.533, root last 0.449/0.692, reply stats 0.399/0.542, Top-5 0.479/0.626, hotspot first 0.449/0.600, hotspot middle 0.498/0.724, active count 0.258/0.388, deleted placeholder 29.876/32.770. +- 부모·자식 `post_id` 불일치, `post.comment_count` 불일치, 중복 댓글 ID는 모두 0건이다. +- 삭제 루트 원문·placeholder와 삭제 대댓글을 포함한 9개 시나리오 전체를 4개 규모에서 동일 조건으로 측정했다(총 36개 행). +- 실제 Spring Boot API 응답 측정: `GET /api/v1/posts/benchmark-sprint04-post-99/comments?size=5` 200 OK, UTF-8 16,841 bytes; `GET /api/v1/comments/304561/replies?size=5` 200 OK, 2,739 bytes. +- 비교 중 `idx_comment_post_parent_id`를 invisible로 전환했으며 측정 후 즉시 visible로 복구했다. +- 이전 CLI 왕복시간 및 고정 ID 기반 측정값은 이번 서버 내부 동적 바인딩 결과와 측정 경계가 다르므로 직접 비교에서 제외한다. +- root first-page 21개 레코드의 JSON 직렬화 payload(SQL `JSON_ARRAYAGG` 기준): 5,541 bytes. + +## 데이터 정합성 검증 + +| 검증 항목 | 결과 | 판정 | +|---|---:|---| +| 전체 댓글 수 | 1000000 | 통과 | +| 루트 댓글 수 | 200000 | 통과 | +| 대댓글 수 | 800000 | 통과 | +| 삭제 댓글 수 | 199980 | 통과 | +| 게시글 댓글 수 불일치 | 0 | 통과 | +| 부모·자식 게시글 불일치 | 0 | 통과 | +| 루트별 최대 활성 대댓글 | 100 | 통과 | +| 중복 댓글 ID | 0 | 통과 | + +## 시나리오별 평균·p95 + +| 시나리오 | 평균(ms) | p95(ms) | +|---|---:|---:| +| root-first | 36.578 | 39.388 | +| root-middle | 19.84 | 22.533 | +| root-last | 0.449 | 0.692 | +| reply-stats | 0.399 | 0.542 | +| reply-top5 | 0.479 | 0.626 | +| reply-hotspot-first | 0.449 | 0.6 | +| reply-hotspot-middle | 0.498 | 0.724 | +| reply-count-active | 0.258 | 0.388 | +| deleted-root-placeholder | 29.876 | 32.77 | + +실행계획 원문은 [explain-plans](../explain-plans/)의 시나리오별 문서에서 확인합니다. + + +--- diff --git a/docs/project/work.md b/docs/project/work.md index 7356b37..83d239e 100644 --- a/docs/project/work.md +++ b/docs/project/work.md @@ -1,3 +1,10 @@ +- **Sprint 04 댓글 벤치마크 디렉터리 영문화 및 종합 README 가이드 작성 (2026-09-09)**: + 1. **디렉터리 영문화**: `benchmark` 하위 한글 폴더를 영문 표준으로 변경 (`실행계획` ➔ `explain-plans`, `쿼리` ➔ `queries`). + 2. **경로 동기화**: `ADR-002-댓글아키텍처.md` 및 `댓글-벤치마크-결과.md` 내 실행계획 경로를 `explain-plans/`로 갱신. + 3. **재현가이드 ➔ README.md 개편 및 내용 보강 (no_ai 톤)**: + - `재현가이드.md`를 `README.md`로 전환하고 실무 개발자 톤으로 4대 핵심 영역(Seed 코드 위치, 실행/초기화 명령어, 데이터 분포 구조, 1K~1M 9대 시나리오 검증 결과 및 인덱스/불변식 요약) 보강 완료. + - `댓글-벤치마크-결과.md` 상단에 멘토의 실험 의도 및 5대 학습 목표(규모·분포 영향, estimated/actual rows/loops 해석, 재현 가능 Seed, 인덱스 쓰기 비용, 운영 DB 안전장치) 상세 해설 추가. + - **Sprint 03 댓글 도메인 메인 브랜치 최종 병합 완료 (2026-09-06)**: 1. **PR #17 (`feature/sprint03-comment` ➔ `main`) 병합 완결**: - Sprint 03 댓글 도메인(생성·조회·수정·삭제 및 하이브리드 프리뷰 아키텍처) 전체 작업물을 `main` 브랜치로 병합 완료 ([PR #17](https://github.com/devikae/snowthing/pull/17) `MERGED`). @@ -16,6 +23,35 @@ - 프론트엔드 Next.js 16.2.12 Turbopack 프로덕션 빌드: `npm run build` **100% SUCCESS** (0 errors, 10 routes). - `main` 브랜치 최신화 및 작업 트리 clean 상태 확립. +### 2026-09-07 Sprint 04 댓글 벤치마크 +- 실제 MySQL 8.0.46 `snowthing_test`에서 재생성 SQL 검증 완료: 100,000건(루트 20,000 / 대댓글 80,000 / 삭제 20,000). +- `database/benchmark/seed-template.sql`의 MySQL 프로시저/재실행 정리 로직과 삭제 플래그 집계를 보강했다. +- 1,000,000건 시드는 현재 동일 DB에서 실행 중이며 완료 후 결과를 추가한다. +- 이슈: Windows MySQL Shell 설치는 기존 Windows Installer 잠금으로 보류했지만 Docker의 MySQL 8.0 CLI로 동일 엔진 검증을 수행했다. +- 1M 완료 검증: 총 1,000,000건(루트 200,000 / 대댓글 800,000 / 삭제 200,000), `post.comment_count` 불일치 0건, 활성 대댓글 최대 4건. +- `EXPLAIN ANALYZE` 원문을 `docs/study/sprint04/comment/benchmark/explain/`에 저장하고 규모별 결과 문서를 작성했다. +- Hotspot 루트에 활성 대댓글 100건을 확보하고 1M 불변식을 재검증했다(총 1,000,000 / 불일치 0 / 최대 100). +- 측정 보강: ANALYZE TABLE 수행, root first-page warm-up 5회 후 20회 평균/p95 측정 및 `idx_comment_post_parent_id` visible/invisible 비교 완료(102.47/115.88ms vs 226.22/241.63ms). 인덱스는 visible로 복구했다. +- 1M 원본을 유지한 채 `snowthing_benchmark_1k`, `snowthing_benchmark_10k`, `snowthing_benchmark_100k` 스키마를 복제 생성하고 root first-page 규모별 EXPLAIN ANALYZE를 저장했다. 네 규모 모두 복합 인덱스 선택, loops=1을 확인했다. +- Seed harness의 게시글 분포를 일반 80개/중간 19개/Hot 1개(45/45/10 비율)로 수정하고, Seed 테스트에 게시글 수·Hot post·댓글 수·루트/대댓글·삭제·comment_count·대댓글 상한·중복 ID 자동 불변식 검증을 추가했다. `compileTestJava` 통과. +- SQL seed template도 동일한 일반/중간/Hot 게시글 매핑을 적용하고, 생성 후 게시글별 댓글 수를 출력하도록 보강했다. +- `snowthing_benchmark_1k`에서 수정 SQL을 실제 실행해 1,000건(루트 200/대댓글 800/삭제 200), Hot post 100건, 게시글별 분포 출력과 총량 집계를 확인했다. `snowthing_test` 1M 원본은 보존했다. +- 단일 MySQL 세션 기반 측정 스크립트로 1K·10K·100K·1M 각 5개 시나리오를 warm-up 5회 + 유효 20회 측정하고 `timing.csv`에 평균/p95를 저장했다. +- 삭제 루트 placeholder/은닉 및 Top-5 batch를 추가해 7개 시나리오(4개 규모)의 평균/p95를 재측정했다. `timing.csv` 28개 결과 행과 규모별 EXPLAIN 원문을 확인했다. +- 4개 규모에서 두 댓글 복합 인덱스를 invisible/visible로 전환하며 Top-5 batch·삭제 루트 전후 `EXPLAIN ANALYZE` 원문을 저장하고, 측정 후 인덱스를 복구했다. +- `snowthing_benchmark_1k/10k/100k`를 수정 분포 Seed로 재생성했다. 100K 실제 검증 결과는 100,000 benchmark 댓글(20,000 루트/80,000 대댓글, 활성/삭제 80,000/20,000), 일반 80개·중간 19개·Hot 1개(10,000건) 분포다. +- 모든 규모 Hotspot 검증: 1K 8건, 10K 80건, 100K 100건, 1M 100건(소규모는 전체량에 따른 10% 축소)을 확인했다. +- 2026-09-08 실행계획 측정을 운영 `CommentRepositoryImpl` SQL 기준 9개 시나리오로 재정의하고, 스키마별 숫자 ID 하드코딩을 benchmark prefix 기반 동적 바인딩으로 교체했다. +- Java/SQL 시드의 Hotspot 규칙을 활성 대댓글 100건으로 통일하고 1K·10K·100K·1M을 재생성했다. 네 규모 모두 총량, 루트/대댓글, 부모·자식 `post_id`, `post.comment_count`, 중복 ID 및 활성 대댓글 상한 검증을 통과했다. +- MySQL 8.0.46에서 4개 규모 × 9개 시나리오의 `EXPLAIN ANALYZE` 원문 36개와 warm-up 5회 + 유효 20회 평균/p95 36행을 저장했다. 누락됐던 대댓글 통계와 Hotspot 중간 페이지를 포함한다. +- 통합 해석표를 `docs/study/sprint04/comment/benchmark/execution-plan-matrix.md`에 작성했다. 1M에서 루트 첫 페이지 36.578/39.388ms, 삭제 placeholder 29.876/32.770ms였고, 대댓글 계열은 상한 100과 parent 복합 인덱스로 0.2~0.7ms 수준을 유지했다. +- 이슈: 루트 첫 페이지와 삭제 placeholder는 member LEFT JOIN 이후 정렬되어 LIMIT 전에 각각 루트 20,000건/삭제 후보 4,000건을 처리한다. JOIN 전 루트 ID LIMIT 파생 테이블과 삭제 조건 포함 복합 인덱스를 후속 개선 후보로 기록했으며 운영 쿼리·인덱스는 변경하지 않았다. +- 평균·p95 측정 대상을 9개 시나리오로 확장(삭제 루트 원문/placeholder, 삭제 대댓글 포함)하고 4개 규모 × 9개 = 36개 결과 행을 `timing.csv`에 저장했다. 각 시나리오는 warm-up 5회 후 20회 측정했다. +- 인덱스 전·후 비교를 9개 시나리오 × 4개 규모로 실행해 36개 원문과 `index-comparison.csv`를 저장했다. 측정 후 인덱스 visible 상태를 확인했다. +- Spring Boot를 `snowthing_test`/18080으로 기동해 실제 댓글·대댓글 API를 호출하고 응답 크기를 측정했다(16,841 bytes / 2,739 bytes). 측정 후 서버를 종료했다. +- 불변식 자동 검증 보강: 벤치마크 게시글 100개별 루트 댓글과 페이지 크기를 초과한 대댓글을 운영과 동일한 `comment_id` 커서로 마지막 페이지까지 순회하고, 전체 기대 ID 집합과 대조해 누락·중복·정렬 오류를 검증한다. 동일 `created_at` 데이터의 `comment_id` 타이브레이커와 게시글별 `post.comment_count`/실제 활성 댓글 수도 전수 검증한다. +- 자동 검증 과정에서 루트 ID 수집 쿼리가 콘텐츠 마커만 검색해 다른 게시글의 과거 마커 데이터를 포함할 수 있는 Seed 범위 결함을 발견했다. 벤치마크 `public_id`와 루트 조건으로 범위를 제한했으며, 기본 1K MySQL 실행 결과 `CommentBenchmarkSeedRunnerTest`가 통과했다. +- 실행계획 통합표 작성: MySQL 8.0.46에서 1K·10K·100K의 필수 9개 시나리오를 복합 인덱스 visible/invisible 상태로 전통형 `EXPLAIN`하고, 54개 실행계획(노드별 원본 114행)의 `key_len`, `Using filesort`, `Using temporary`를 `explain-plan-summary.md`와 `explain-plan-details.csv`에 기록했다. 실행 전 `ANALYZE TABLE`을 수행했으며 측정 후 세 스키마의 두 복합 인덱스가 모두 visible임을 확인했다. - **Sprint 03 댓글/대댓글 인라인 삭제 UI 및 비밀번호 플로팅 팝오버 위젯 구현 (2026-09-03)**: 1. **작업명**: 댓글/대댓글 인라인 미니 `✕` 삭제 버튼 및 시간 아래 플로팅 드롭다운 UI 구현 (브라우저 다이얼로그 전면 퇴출) 2. **현재 상태**: 완료 @@ -1188,3 +1224,58 @@ - `CommentControllerTest`와 `spotlessCheck`는 통과했습니다. - `CommentCreateTest` 16건은 `SNOWTHING_TEST_DB_URL` 미설정 시 실행을 차단하는 기존 MySQL 강제 설정 때문에 Spring Context 생성 전에 실패했습니다. 경계값 변경으로 인한 테스트 assertion 실패는 아닙니다. >>>>>>> origin/feature/sprint03-comment +- **Sprint 04 댓글 벤치마크 실행 기준 및 EXPLAIN 쿼리 정리 (2026-09-07)**: + - 기존 `test/sprint04-comment-benchmark` 브랜치에서 전용 벤치마크 실행 가이드와 안전 조건을 작성했습니다. + - 규모별 데이터 분포, 고정 seed, MySQL 전용 실행, 정확성 불변식, warm-up/p95 측정 규칙을 문서화했습니다. + - 루트 첫·중간·마지막 페이지, Top-5 batch, Hotspot 대댓글, 활성 count용 EXPLAIN 입력 SQL을 분리했습니다. + - 현재 문서는 실행 기준과 쿼리 입력 파일이며, 1K/10K/100K 실제 결과 파일은 하네스 실행 후 생성해야 합니다. +- **Sprint 04 JDBC batch 벤치마크 하네스 추가 (2026-09-07)**: + - `CommentBenchmarkSeedHarness`를 추가해 규모와 seed를 파라미터로 받는 MySQL JDBC batch 데이터 주입 기반을 마련했습니다. + - DB 이름 test/benchmark 검증, 전용 public_id 정리, 게시글 분산, 루트/대댓글, 익명·삭제 분포를 적용했습니다. + - 컴파일 검증: `spotlessApply`, `compileTestJava` 성공. +- **Sprint 04 현실형 댓글 콘텐츠 생성기 추가 (2026-09-07)**: + - 고정 seed 기반 스키장·설질·리프트·장비 주제의 게시글 제목/본문과 실제 커뮤니티 문장형 루트 댓글·대댓글 생성기를 추가했습니다. + - 기존 JDBC batch 하네스가 생성된 콘텐츠를 사용하도록 연결해 성능 측정에서도 payload와 화면 응답 형태를 현실적으로 재현합니다. + - `spotlessApply`, `compileTestJava` 성공. +- **Sprint 04 벤치마크 데이터 관리·테스트 계획 문서화 (2026-09-07)**: + - 전용 스키마, prefix 기반 정리, seed 재현성, manifest 관리와 단계별 1K/10K/100K/1M 검증 절차를 문서화했습니다. + - 정확성 불변식과 SQL/바인딩/EXPLAIN/p95 증거 저장 규칙을 분리해 기록했습니다. + - JDBC batch 하네스에 Hotspot 루트 집중 분포를 반영하고 컴파일 검증을 통과했습니다. +- **Sprint 04 규모별 MySQL SQL 시드 파일 추가 (2026-09-07)**: + - `database/benchmark/seed-template.sql`과 1K/10K/100K/1M 실행 래퍼를 추가했습니다. + - `snowthing_test` 전용 스키마와 benchmark prefix만 사용하며, 게시글·루트·대댓글·삭제 분포 및 `post.comment_count` 검증을 포함합니다. + - 사용자는 원하는 규모의 SQL 파일을 MySQL 클라이언트에서 실행해 데이터를 재생성할 수 있습니다. +- **Sprint 04 EXPLAIN 원문 파일별 한국어 해설 보강 (2026-09-08)**: + - 별도 `explain/README.md`는 제거했습니다. + - 107개 EXPLAIN 원문 `.txt` 파일 각각의 상단에 시나리오, 데이터 규모, estimated/actual rows·loops·인덱스 해석을 삽입했습니다. +- **Sprint 04 EXPLAIN 파일명 한글화 (2026-09-08)**: + - 실행계획 파일명을 `루트-첫-페이지`, `루트-중간-페이지`, `핫스팟-대댓글`, `삭제된-루트`, `삭제된-대댓글-은닉`, `상위5개` 등 시나리오가 바로 드러나는 한글명으로 변경했습니다. + - 규모도 `1천건`, `1만건`, `10만건`, `백만건`으로 표시해 파일명만 보고 대상 데이터와 인덱스 제거 여부를 구분할 수 있습니다. +- **Sprint 04 EXPLAIN 문서 구조 통합 (2026-09-08)**: + - `explain/`을 9개 시나리오별 Markdown 문서로 통합했습니다. + - 각 문서에 규모별·인덱스 적용 전후 원문과 지표 해석을 함께 배치했습니다. + - SQL 입력 파일은 `benchmark/queries/`로 이동하고, 기존 중복 원문은 `benchmark/archive-explain-raw/`에 보관했습니다. + - 현재 정책과 맞지 않는 삭제 placeholder/hidden 결과는 삭제하지 않고 보관 영역으로 분리했으며, `삭제된-대댓글.md`에 정책 차이를 명시했습니다. +- **Sprint 04 벤치마크 산출물 전체 구조 정리 (2026-09-08)**: + - `results/`에 `1천건.md`, `1만건.md`, `10만건.md`, `백만건.md`를 생성해 규모별 결과·정합성·평균/p95를 통합했습니다. + - `metrics/`에 실행계획·인덱스 비교·실행시간·정합성 집계 파일을 모았습니다. + - `guides/`, `질의/`, `실행계획/`으로 목적별 파일을 분리하고 파일명을 한글화했습니다. + - 이전 중복 원문과 이전 결과 파일은 `../benchmark-archive/`로 이동해 작업 폴더에서는 제외했습니다. +- **Sprint 04 규모별 결과 문서 단일화 (2026-09-08)**: + - `results/`의 규모별 4개 Markdown을 `댓글-벤치마크-결과.md` 하나로 통합하고 문서 내부에서 1천·1만·10만·백만건 섹션으로 구분했습니다. +- **Sprint 04 정합성 결과 표 형식 개선 (2026-09-08)**: + - 통합 결과 문서의 원시 탭 구분 정합성 출력 4개를 검증 항목·결과·판정 Markdown 표로 변환했습니다. +- **Sprint 04 ADR 실행계획 요약표 보강 (2026-09-08)**: + - ADR-002에 9개 시나리오의 규모별 평균/p95, 선택 인덱스, 인덱스 전후 차이, 병목 원인을 한글 표로 추가했습니다. + - 변경된 benchmark 결과 경로와 삭제 댓글 정책의 기존 은닉 측정 한계를 명시했습니다. +- **Sprint 04 댓글 조회 벤치마크 학습 문서 작성 (2026-09-08)**: + - 선택도, 실행계획, estimated/actual rows, Cursor seek, Seed, Spring Profile, JDBC batch, 인덱스 지표를 개념·원리·트레이드오프 관점에서 정리했습니다. + - 실제 측정 결과에 대한 질문 답변과 데이터 규모·사용자 증가 시 확장 대응 방향을 추가했습니다. +- **Sprint 04 벤치마크 실행 안전장치 보강 (2026-09-08)**: + - 벤치마크 실행 테스트에 `test`·`benchmark` Profile을 명시하고 전용 `application-benchmark.yml`을 추가했습니다. + - SQL Seed 시작 시 현재 DB 이름이 `test` 또는 `benchmark`를 포함하는지 검사하고, 운영 스키마면 MySQL `SIGNAL`로 즉시 중단하도록 했습니다. + - `compileTestJava` 검증을 통과했습니다. +- **Sprint 04 CI 벤치마크 테스트 격리 (2026-09-09)**: + - 기본 `./gradlew test`에서 `benchmark` 태그 테스트를 제외해 일반 테스트와 대규모 Seed 테스트가 같은 DB Context를 오염시키지 않도록 했습니다. + - 벤치마크 실행은 `./gradlew test --tests CommentBenchmarkSeedRunnerTest -PincludeBenchmark`로 명시해야 합니다. + - GitHub Actions 실패 로그에서 확인된 11건의 DB 제약조건·기대값 오류 원인을 반영했습니다.