feat: 문서 구조 변경 및 tech-visual 스킬 추가

This commit is contained in:
DongHyeonka
2026-09-04 18:20:00 +09:00
parent 43901f0abf
commit 2efb7ee1f2
683 changed files with 61180 additions and 10479 deletions
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="700" height="230" viewBox="0 0 700 230" role="img" aria-labelledby="diagram-title diagram-description">
<title id="diagram-title">배치 페치는 부모를 먼저 페이징하고 자식을 IN 으로 묶는다</title>
<desc id="diagram-description">왼쪽에서 엔티티만 페이징해 DB Limit이 정상 발행되고 부모 페이지가 정해진다. 가운데에서 그 페이지의 부모 키를 모아 자식을 IN 조건 한 번으로 가져온다. 오른쪽 실행계획은 준조인이라 부모와 자식을 곱하지 않고 자식 행만 반환한다. 왕복 수는 부모 수를 배치 크기로 나눈 올림값이 된다.</desc>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
<pattern id="hatch" width="7" height="7" patternTransform="rotate(45)" patternUnits="userSpaceOnUse">
<line x1="0" y1="0" x2="0" y2="7" stroke="#9ca3af" stroke-width="3" />
</pattern>
<style>
:root { color-scheme: light; }
text { font-family: Inter, Pretendard, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #111827; }
.canvas { fill: #ffffff; }
.group-box { fill: #ffffff; stroke: #9ca3af; stroke-width: 1.4; stroke-dasharray: 7 5; }
.group-label-bg { fill: #ffffff; }
.group-label { font-size: 13px; font-weight: 650; fill: #374151; }
.edge { fill: none; stroke: #374151; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; marker-end: url(#arrow); }
.edge.dashed { stroke-dasharray: 7 5; }
.edge.primary { stroke: #2563eb; stroke-width: 2.2; }
.edge.muted { stroke: #9ca3af; }
.node { fill: #ffffff; stroke: #4b5563; stroke-width: 1.7; }
.node.primary { stroke: #2563eb; stroke-width: 2.4; }
.node.muted { stroke: #9ca3af; fill: #f9fafb; }
.node.marked { stroke: #4b5563; stroke-width: 2.4; stroke-dasharray: 5 4; }
.label { font-size: 14px; font-weight: 650; text-anchor: middle; }
.fill-kept { fill: #2563eb; stroke: none; }
.fill-drop { fill: url(#hatch); stroke: #9ca3af; stroke-width: 1; }
.fill-none { fill: #ffffff; stroke: #d1d5db; stroke-width: 1; }
</style>
</defs>
<rect class="canvas" width="700" height="230" />
<rect class="node primary" x="40" y="70" width="160" height="80" rx="7" />
<text class="label" x="120.0" y="115.0">부모 페이징</text>
<rect class="node primary" x="265" y="60" width="170" height="100" rx="7" />
<text class="label" x="350.0" y="115.0">부모 키 IN</text>
<rect class="node " x="500" y="70" width="160" height="80" rx="7" />
<text class="label" x="580.0" y="115.0">준조인</text>
<polyline class="edge primary" points="200,110 265,110" />
<polyline class="edge primary" points="435,110 500,110" />
<rect class="group-box" x="20" y="178" width="660" height="40" rx="9" />
<rect class="group-label-bg" x="34" y="169" width="97.6" height="18" />
<text class="group-label" x="40" y="182">자식은 따로 가져온다</text>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="700" height="250" viewBox="0 0 700 250" role="img" aria-labelledby="diagram-title diagram-description">
<title id="diagram-title">컬렉션 fetch join은 부모 행을 자식 수만큼 늘린다</title>
<desc id="diagram-description">왼쪽 부모 테이블에서 출발한 조인이 가운데를 지나면서 부모 한 행을 자식 수만큼 반복한 행 묶음으로 만든다. 그 묶음이 오른쪽 전송 단계로 그대로 나간다. 아래쪽에서 Hibernate 6 이상이 루트 엔티티를 중복 제거해 결과 리스트를 부모 수로 되돌리지만, 늘어난 행은 SQL과 전송 단계에 남아 있다. 그래서 리스트 크기로는 이 증가가 보이지 않고 조인 카디널리티나 실행계획의 실제 행수로 확인해야 한다.</desc>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
<pattern id="hatch" width="7" height="7" patternTransform="rotate(45)" patternUnits="userSpaceOnUse">
<line x1="0" y1="0" x2="0" y2="7" stroke="#9ca3af" stroke-width="3" />
</pattern>
<style>
:root { color-scheme: light; }
text { font-family: Inter, Pretendard, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #111827; }
.canvas { fill: #ffffff; }
.group-box { fill: #ffffff; stroke: #9ca3af; stroke-width: 1.4; stroke-dasharray: 7 5; }
.group-label-bg { fill: #ffffff; }
.group-label { font-size: 13px; font-weight: 650; fill: #374151; }
.edge { fill: none; stroke: #374151; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; marker-end: url(#arrow); }
.edge.dashed { stroke-dasharray: 7 5; }
.edge.primary { stroke: #2563eb; stroke-width: 2.2; }
.edge.muted { stroke: #9ca3af; }
.node { fill: #ffffff; stroke: #4b5563; stroke-width: 1.7; }
.node.primary { stroke: #2563eb; stroke-width: 2.4; }
.node.muted { stroke: #9ca3af; fill: #f9fafb; }
.node.marked { stroke: #4b5563; stroke-width: 2.4; stroke-dasharray: 5 4; }
.label { font-size: 14px; font-weight: 650; text-anchor: middle; }
.fill-kept { fill: #2563eb; stroke: none; }
.fill-drop { fill: url(#hatch); stroke: #9ca3af; stroke-width: 1; }
.fill-none { fill: #ffffff; stroke: #d1d5db; stroke-width: 1; }
</style>
</defs>
<rect class="canvas" width="700" height="250" />
<rect class="node " x="40" y="70" width="150" height="80" rx="7" />
<text class="label" x="115.0" y="115.0">부모 테이블</text>
<rect class="node primary" x="255" y="60" width="170" height="100" rx="7" />
<text class="label" x="340.0" y="115.0">조인</text>
<rect class="node " x="490" y="70" width="170" height="80" rx="7" />
<text class="label" x="575.0" y="115.0">전송 행</text>
<rect class="node muted" x="490" y="175" width="170" height="60" rx="7" />
<text class="label" x="575.0" y="210.0">결과 리스트</text>
<polyline class="edge " points="190,110 255,110" />
<polyline class="edge primary" points="425,110 490,110" />
<polyline class="edge dashed muted" points="575,150 575,175" />
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="680" height="542" viewBox="0 0 680 542" role="img" aria-labelledby="diagram-title diagram-description">
<title id="diagram-title">EAGER 2차 조회는 반환 전에, LAZY highlights 조회는 매핑 접근 뒤에 실행된다</title>
<desc id="diagram-description">세 참가자를 왼쪽부터 loadFeed DTO 매핑, Hibernate, PostgreSQL 순으로 읽는다. loadFeed가 findAllBy 파생 쿼리를 호출하면 Hibernate가 PostgreSQL에서 feed_items를 먼저 조회한다. 이어 fetch join되지 않은 EAGER user와 page를 별도의 2차 SELECT로 채우고, 반환 시점까지 로딩된 FeedItem을 loadFeed에 돌려준다. 이후 DTO 매핑이 getHighlights()에 접근하면 Hibernate가 해당 아이템의 highlights 컬렉션 SELECT를 실행한다.</desc>
<metadata>{&quot;techviz&quot;:{&quot;spec_version&quot;:&quot;1.1&quot;,&quot;id&quot;:&quot;eager-lazy-query-sequence&quot;,&quot;profile&quot;:&quot;sequence&quot;},&quot;source_context&quot;:{&quot;document&quot;:&quot;/home/donghyeon/workspace/ai-tool/topic-arrange/n+1liner/n+1liner.md&quot;,&quot;document_sha256&quot;:&quot;1bb38964ca2a849690f5355646c1aa988111b4cd4e1055c6cb05cf7e5fc35cde&quot;,&quot;anchor&quot;:{&quot;kind&quot;:&quot;marker&quot;,&quot;value&quot;:&quot;eager-lazy-query-sequence&quot;,&quot;line&quot;:324}},&quot;evidence_policy&quot;:&quot;Each factual element cites source lines or is marked assumption.&quot;,&quot;diagram_only&quot;:true}</metadata>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
<style>
:root { color-scheme: light; }
text { font-family: Inter, Pretendard, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #111827; }
.canvas { fill: #ffffff; }
.group-box { fill: #ffffff; stroke: #9ca3af; stroke-width: 1.4; stroke-dasharray: 7 5; }
.group-label-bg { fill: #ffffff; }
.group-label { font-size: 13px; font-weight: 650; fill: #374151; }
.edge { fill: none; stroke: #374151; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; marker-end: url(#arrow); }
.edge.style-dashed, .edge.semantic-dashed, .edge.assumption { stroke-dasharray: 7 5; }
.edge.style-dotted { stroke-dasharray: 2 5; }
.edge.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.edge.emphasis-muted { stroke: #9ca3af; }
.edge.emphasis-warning, .edge.kind-failure, .edge.kind-error { stroke: #dc2626; stroke-width: 2.2; }
.edge-label-bg { fill: #ffffff; }
.edge-label { font-size: 12px; font-weight: 560; text-anchor: middle; }
.node-shape { fill: #ffffff; stroke: #4b5563; stroke-width: 1.7; }
.node-shape.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.node-shape.emphasis-muted { stroke: #9ca3af; fill: #f9fafb; }
.node-shape.emphasis-warning { stroke: #d97706; stroke-width: 2; fill: #fffdf5; }
.node-shape.kind-database, .node-shape.kind-datastore, .node-shape.kind-storage { fill: #f8fafc; }
.node-shape.kind-queue, .node-shape.kind-event, .node-shape.kind-topic { fill: #fafafa; }
.node-shape.assumption { stroke-dasharray: 4 4; }
.storage-bottom, .controller-divider { fill: none; stroke: #4b5563; stroke-width: 1.4; }
.controller-led { fill: #4b5563; }
.actor-symbol { fill: none; stroke: #4b5563; stroke-width: 1.8; stroke-linecap: round; }
.actor-symbol.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.node-label { font-size: 14px; font-weight: 650; text-anchor: middle; }
.node-role { font-size: 10px; letter-spacing: 0.04em; text-anchor: middle; fill: #6b7280; }
.node-detail-divider { stroke: #d1d5db; stroke-width: 1; }
.node-detail { font-size: 11px; fill: #374151; }
.assumption-badge { font-size: 9px; font-weight: 700; fill: #92400e; }
.failure-mark { stroke: #dc2626; stroke-width: 4; stroke-linecap: round; }
.lifeline { stroke: #9ca3af; stroke-width: 1.2; stroke-dasharray: 5 5; }
.timeline-axis { stroke: #374151; stroke-width: 1.8; marker-end: url(#arrow); }
.timeline-stem { stroke: #6b7280; stroke-width: 1.3; }
.timeline-marker { fill: #ffffff; stroke: #374151; stroke-width: 1.7; }
.timeline-marker.primary { fill: #2563eb; stroke: #2563eb; }
.timeline-marker.warning { fill: #dc2626; stroke: #dc2626; }
.timeline-label { font-size: 13px; font-weight: 650; text-anchor: middle; }
.timeline-detail { font-size: 11px; fill: #4b5563; text-anchor: middle; }
</style>
</defs>
<rect class="canvas" width="680" height="542" />
<rect class="node-shape kind-participant emphasis-normal role-participant" data-evidence="322-322,449-451" x="45.0" y="35.0" width="150.0" height="64.0" rx="7" />
<text class="node-label" x="120.0" y="65.0">loadFeed DTO 매핑</text>
<line class="lifeline" x1="120.0" y1="99.0" x2="120.0" y2="512.0" />
<rect class="node-shape kind-participant emphasis-primary role-participant" data-evidence="320-322" x="255.0" y="35.0" width="150.0" height="64.0" rx="7" />
<text class="node-label" x="330.0" y="65.0">Hibernate</text>
<line class="lifeline" x1="330.0" y1="99.0" x2="330.0" y2="512.0" />
<rect class="node-shape kind-participant emphasis-normal role-participant" data-evidence="417-422,447-447" x="465.0" y="45.7" width="150.0" height="42.7" /><ellipse class="node-shape kind-participant emphasis-normal role-participant" cx="540.0" cy="45.7" rx="75.0" ry="10.7" /><path class="storage-bottom" d="M 465.0 88.3 A 75.0 10.7 0 0 0 615.0 88.3" />
<text class="node-label" x="540.0" y="65.0">PostgreSQL</text>
<line class="lifeline" x1="540.0" y1="99.0" x2="540.0" y2="512.0" />
<polyline class="edge kind-request style-solid emphasis-normal" points="120.0,140.0 330.0,140.0" data-evidence="320-320" />
<rect class="edge-label-bg" x="159.1" y="114.0" width="131.9" height="22" rx="3" />
<text class="edge-label" x="225.0" y="129.0">1. findAllBy(...)</text>
<polyline class="edge kind-data style-solid emphasis-primary" points="330.0,202.0 540.0,202.0" data-evidence="320-320" />
<rect class="edge-label-bg" x="359.0" y="176.0" width="152.0" height="22" rx="3" />
<text class="edge-label" x="435.0" y="191.0">2. SELECT feed_items</text>
<polyline class="edge kind-data style-solid emphasis-normal" points="330.0,264.0 540.0,264.0" data-evidence="318-321" />
<rect class="edge-label-bg" x="318.8" y="238.0" width="232.4" height="22" rx="3" />
<text class="edge-label" x="435.0" y="253.0">3. SELECT user / page · EAGER 2차</text>
<polyline class="edge kind-response style-dashed emphasis-normal semantic-dashed" points="330.0,326.0 120.0,326.0" data-evidence="318-320" />
<rect class="edge-label-bg" x="122.2" y="300.0" width="205.6" height="22" rx="3" />
<text class="edge-label" x="225.0" y="315.0">4. EAGER 연관이 채워진 FeedItem 반환</text>
<polyline class="edge kind-request style-solid emphasis-normal" points="120.0,388.0 330.0,388.0" data-evidence="322-322,449-451" />
<rect class="edge-label-bg" x="128.9" y="362.0" width="192.2" height="22" rx="3" />
<text class="edge-label" x="225.0" y="377.0">5. 매핑 중 getHighlights() 접근</text>
<polyline class="edge kind-data style-solid emphasis-primary" points="330.0,450.0 540.0,450.0" data-evidence="322-322,429-430" />
<rect class="edge-label-bg" x="281.9" y="424.0" width="306.1" height="22" rx="3" />
<text class="edge-label" x="435.0" y="439.0">6. SELECT highlights WHERE feed_item_id = ?</text>
</svg>

After

Width:  |  Height:  |  Size: 7.4 KiB

@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="700" height="290" viewBox="0 0 700 290" role="img" aria-labelledby="diagram-title diagram-description">
<title id="diagram-title">컬렉션 fetch join에 페이징을 걸면 자르는 곳이 DB에서 메모리로 옮겨진다</title>
<desc id="diagram-description">위쪽은 컬렉션 fetch join에 페이징을 건 경로다. 발행 SQL에 Limit 노드가 없어 조인 결과 전체가 애플리케이션으로 넘어오고, Hibernate가 메모리에서 부모 페이지만 남기고 나머지를 버린다. 아래쪽은 엔티티만 페이징한 경로다. Limit 노드가 붙어 DB가 페이지만 돌려주므로 애플리케이션은 버릴 것을 받지 않는다. 두 경로의 반환 건수는 같지만 위쪽은 부모 전체를 하이드레이트한다.</desc>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
<pattern id="hatch" width="7" height="7" patternTransform="rotate(45)" patternUnits="userSpaceOnUse">
<line x1="0" y1="0" x2="0" y2="7" stroke="#9ca3af" stroke-width="3" />
</pattern>
<style>
:root { color-scheme: light; }
text { font-family: Inter, Pretendard, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #111827; }
.canvas { fill: #ffffff; }
.group-box { fill: #ffffff; stroke: #9ca3af; stroke-width: 1.4; stroke-dasharray: 7 5; }
.group-label-bg { fill: #ffffff; }
.group-label { font-size: 13px; font-weight: 650; fill: #374151; }
.edge { fill: none; stroke: #374151; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; marker-end: url(#arrow); }
.edge.dashed { stroke-dasharray: 7 5; }
.edge.primary { stroke: #2563eb; stroke-width: 2.2; }
.edge.muted { stroke: #9ca3af; }
.node { fill: #ffffff; stroke: #4b5563; stroke-width: 1.7; }
.node.primary { stroke: #2563eb; stroke-width: 2.4; }
.node.muted { stroke: #9ca3af; fill: #f9fafb; }
.node.marked { stroke: #4b5563; stroke-width: 2.4; stroke-dasharray: 5 4; }
.label { font-size: 14px; font-weight: 650; text-anchor: middle; }
.fill-kept { fill: #2563eb; stroke: none; }
.fill-drop { fill: url(#hatch); stroke: #9ca3af; stroke-width: 1; }
.fill-none { fill: #ffffff; stroke: #d1d5db; stroke-width: 1; }
</style>
</defs>
<rect class="canvas" width="700" height="290" />
<rect class="group-box" x="30" y="50" width="640" height="96" rx="9" />
<rect class="group-label-bg" x="44" y="41" width="166.0" height="18" />
<text class="group-label" x="50" y="54">컬렉션 fetch join + 페이징</text>
<rect class="node " x="55" y="74" width="165" height="56" rx="7" />
<text class="label" x="137.5" y="107.0">조인 결과 전량</text>
<rect class="node primary" x="275" y="74" width="165" height="56" rx="7" />
<text class="label" x="357.5" y="107.0">메모리에서 자름</text>
<rect class="node " x="495" y="74" width="150" height="56" rx="7" />
<text class="label" x="570.0" y="107.0">페이지</text>
<polyline class="edge " points="220,102 275,102" />
<polyline class="edge " points="440,102 495,102" />
<rect class="group-box" x="30" y="186" width="640" height="96" rx="9" />
<rect class="group-label-bg" x="44" y="177" width="74.8" height="18" />
<text class="group-label" x="50" y="190">엔티티만 페이징</text>
<rect class="node primary" x="55" y="210" width="165" height="56" rx="7" />
<text class="label" x="137.5" y="243.0">정렬 + Limit</text>
<rect class="node primary" x="275" y="210" width="165" height="56" rx="7" />
<text class="label" x="357.5" y="243.0">DB가 자름</text>
<rect class="node " x="495" y="210" width="150" height="56" rx="7" />
<text class="label" x="570.0" y="243.0">페이지</text>
<polyline class="edge primary" points="220,238 275,238" />
<polyline class="edge primary" points="440,238 495,238" />
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="720" height="280" viewBox="0 0 720 280" role="img" aria-labelledby="diagram-title diagram-description">
<title id="diagram-title">OFFSET은 건너뛸 행까지 읽고 버리지만 keyset은 커서 이후만 읽는다</title>
<desc id="diagram-description">위쪽 OFFSET 막대에서 빗금 구간은 정렬 순서상 앞에 있어 만들어졌다가 버려지는 범위이고, 오른쪽 진한 구간이 실제로 반환되는 페이지다. 아래쪽 keyset 막대에서 빈 구간은 아예 읽지 않는 범위이고, 커서 표시 뒤부터 페이지만 읽는다. 페이지가 깊어질수록 위쪽 빗금 구간만 길어지고 아래쪽은 그대로다. 이 이점은 커서와 같은 순서의 정렬키 인덱스가 있을 때만 성립한다.</desc>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
<pattern id="hatch" width="7" height="7" patternTransform="rotate(45)" patternUnits="userSpaceOnUse">
<line x1="0" y1="0" x2="0" y2="7" stroke="#9ca3af" stroke-width="3" />
</pattern>
<style>
:root { color-scheme: light; }
text { font-family: Inter, Pretendard, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #111827; }
.canvas { fill: #ffffff; }
.group-box { fill: #ffffff; stroke: #9ca3af; stroke-width: 1.4; stroke-dasharray: 7 5; }
.group-label-bg { fill: #ffffff; }
.group-label { font-size: 13px; font-weight: 650; fill: #374151; }
.edge { fill: none; stroke: #374151; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; marker-end: url(#arrow); }
.edge.dashed { stroke-dasharray: 7 5; }
.edge.primary { stroke: #2563eb; stroke-width: 2.2; }
.edge.muted { stroke: #9ca3af; }
.node { fill: #ffffff; stroke: #4b5563; stroke-width: 1.7; }
.node.primary { stroke: #2563eb; stroke-width: 2.4; }
.node.muted { stroke: #9ca3af; fill: #f9fafb; }
.node.marked { stroke: #4b5563; stroke-width: 2.4; stroke-dasharray: 5 4; }
.label { font-size: 14px; font-weight: 650; text-anchor: middle; }
.fill-kept { fill: #2563eb; stroke: none; }
.fill-drop { fill: url(#hatch); stroke: #9ca3af; stroke-width: 1; }
.fill-none { fill: #ffffff; stroke: #d1d5db; stroke-width: 1; }
</style>
</defs>
<rect class="canvas" width="720" height="280" />
<rect class="group-box" x="30" y="46" width="660" height="84" rx="9" />
<rect class="group-label-bg" x="44" y="37" width="59.599999999999994" height="18" />
<text class="group-label" x="50" y="50">OFFSET</text>
<rect class="fill-drop" x="55" y="76" width="500" height="34" rx="3" />
<rect class="fill-kept" x="555" y="76" width="110" height="34" rx="3" />
<text class="group-label" x="305" y="98" text-anchor="middle">버려지는 범위</text>
<rect class="group-box" x="30" y="170" width="660" height="84" rx="9" />
<rect class="group-label-bg" x="44" y="161" width="59.599999999999994" height="18" />
<text class="group-label" x="50" y="174">keyset</text>
<rect class="fill-none" x="55" y="200" width="500" height="34" rx="3" />
<rect class="fill-kept" x="555" y="200" width="110" height="34" rx="3" />
<text class="group-label" x="305" y="222" text-anchor="middle">읽지 않는 범위</text>
<line x1="555" y1="188" x2="555" y2="246" stroke="#2563eb" stroke-width="2.4" />
<text class="group-label" x="555" y="182" text-anchor="middle" fill="#2563eb">커서</text>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="660" height="230" viewBox="0 0 660 230" role="img" aria-labelledby="diagram-title diagram-description">
<title id="diagram-title">프로젝션은 엔티티 적재를 없애지만 자식 행수는 그대로 남는다</title>
<desc id="diagram-description">왼쪽 배치 방식은 페이지를 조회하면서 엔티티를 대량으로 하이드레이트한다. 가운데 프로젝션은 필요한 스칼라 값만 캐리어로 받아 엔티티를 만들지 않고 발행 쿼리도 상수로 고정한다. 다만 오른쪽처럼 자식 조회는 페이지 부모의 자식을 전부 가져오므로 행수는 줄지 않는다. 화면이 필요로 하는 것은 부모당 상위 몇 개뿐이다.</desc>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
<pattern id="hatch" width="7" height="7" patternTransform="rotate(45)" patternUnits="userSpaceOnUse">
<line x1="0" y1="0" x2="0" y2="7" stroke="#9ca3af" stroke-width="3" />
</pattern>
<style>
:root { color-scheme: light; }
text { font-family: Inter, Pretendard, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #111827; }
.canvas { fill: #ffffff; }
.group-box { fill: #ffffff; stroke: #9ca3af; stroke-width: 1.4; stroke-dasharray: 7 5; }
.group-label-bg { fill: #ffffff; }
.group-label { font-size: 13px; font-weight: 650; fill: #374151; }
.edge { fill: none; stroke: #374151; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; marker-end: url(#arrow); }
.edge.dashed { stroke-dasharray: 7 5; }
.edge.primary { stroke: #2563eb; stroke-width: 2.2; }
.edge.muted { stroke: #9ca3af; }
.node { fill: #ffffff; stroke: #4b5563; stroke-width: 1.7; }
.node.primary { stroke: #2563eb; stroke-width: 2.4; }
.node.muted { stroke: #9ca3af; fill: #f9fafb; }
.node.marked { stroke: #4b5563; stroke-width: 2.4; stroke-dasharray: 5 4; }
.label { font-size: 14px; font-weight: 650; text-anchor: middle; }
.fill-kept { fill: #2563eb; stroke: none; }
.fill-drop { fill: url(#hatch); stroke: #9ca3af; stroke-width: 1; }
.fill-none { fill: #ffffff; stroke: #d1d5db; stroke-width: 1; }
</style>
</defs>
<rect class="canvas" width="660" height="230" />
<rect class="node " x="30" y="70" width="170" height="80" rx="7" />
<text class="label" x="115.0" y="115.0">엔티티 적재</text>
<rect class="node primary" x="245" y="60" width="170" height="100" rx="7" />
<text class="label" x="330.0" y="115.0">스칼라 프로젝션</text>
<rect class="node marked" x="460" y="70" width="170" height="80" rx="7" />
<text class="label" x="545.0" y="115.0">자식 행 전량</text>
<polyline class="edge primary" points="200,110 245,110" />
<polyline class="edge muted" points="415,110 460,110" />
<rect class="group-box" x="20" y="178" width="620" height="40" rx="9" />
<rect class="group-label-bg" x="34" y="169" width="135.6" height="18" />
<text class="group-label" x="40" y="182">적재는 사라지고 행수는 남는다</text>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="720" height="300" viewBox="0 0 720 300" role="img" aria-labelledby="diagram-title diagram-description">
<title id="diagram-title">그룹당 상위 몇 개를 만드는 세 방식은 읽는 범위가 다르다</title>
<desc id="diagram-description">세 방식 모두 부모마다 상위 몇 개씩 같은 결과를 만든다. 윈도우 함수는 파티션 전체를 읽어 순번을 매긴 뒤 상위만 남긴다. LATERAL은 부모마다 정렬 인덱스에서 필요한 개수만 읽고 멈춘다. 애플리케이션 그룹핑은 자식 전량을 애플리케이션으로 보낸 뒤 코드에서 자른다. 읽는 범위가 다르므로 읽은 블록 수도 갈린다. 그룹이 크고 필요한 개수가 작을수록 LATERAL이 읽지 않는 범위가 넓어진다.</desc>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
<pattern id="hatch" width="7" height="7" patternTransform="rotate(45)" patternUnits="userSpaceOnUse">
<line x1="0" y1="0" x2="0" y2="7" stroke="#9ca3af" stroke-width="3" />
</pattern>
<style>
:root { color-scheme: light; }
text { font-family: Inter, Pretendard, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #111827; }
.canvas { fill: #ffffff; }
.group-box { fill: #ffffff; stroke: #9ca3af; stroke-width: 1.4; stroke-dasharray: 7 5; }
.group-label-bg { fill: #ffffff; }
.group-label { font-size: 13px; font-weight: 650; fill: #374151; }
.edge { fill: none; stroke: #374151; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; marker-end: url(#arrow); }
.edge.dashed { stroke-dasharray: 7 5; }
.edge.primary { stroke: #2563eb; stroke-width: 2.2; }
.edge.muted { stroke: #9ca3af; }
.node { fill: #ffffff; stroke: #4b5563; stroke-width: 1.7; }
.node.primary { stroke: #2563eb; stroke-width: 2.4; }
.node.muted { stroke: #9ca3af; fill: #f9fafb; }
.node.marked { stroke: #4b5563; stroke-width: 2.4; stroke-dasharray: 5 4; }
.label { font-size: 14px; font-weight: 650; text-anchor: middle; }
.fill-kept { fill: #2563eb; stroke: none; }
.fill-drop { fill: url(#hatch); stroke: #9ca3af; stroke-width: 1; }
.fill-none { fill: #ffffff; stroke: #d1d5db; stroke-width: 1; }
</style>
</defs>
<rect class="canvas" width="720" height="300" />
<rect class="node " x="40" y="56" width="190" height="62" rx="7" />
<text class="label" x="135.0" y="92.0">윈도우 함수</text>
<rect class="node " x="330" y="56" width="350" height="62" rx="7" />
<text class="label" x="505.0" y="92.0">파티션 전체</text>
<polyline class="edge " points="230,87 330,87" />
<rect class="node primary" x="40" y="146" width="190" height="62" rx="7" />
<text class="label" x="135.0" y="182.0">LATERAL</text>
<rect class="node primary" x="330" y="146" width="350" height="62" rx="7" />
<text class="label" x="505.0" y="182.0">부모별 인덱스 seek</text>
<polyline class="edge primary" points="230,177 330,177" />
<rect class="node " x="40" y="236" width="190" height="62" rx="7" />
<text class="label" x="135.0" y="272.0">애플리케이션 그룹핑</text>
<rect class="node muted" x="330" y="236" width="350" height="62" rx="7" />
<text class="label" x="505.0" y="272.0">자식 전량</text>
<polyline class="edge " points="230,267 330,267" />
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB