-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathindex.html
More file actions
838 lines (762 loc) · 68.1 KB
/
Copy pathindex.html
File metadata and controls
838 lines (762 loc) · 68.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RDB 공통 원리 — 커리큘럼 | DB Study</title>
<meta name="description" content="락(낙관적·비관적·분산) · 트랜잭션 · 격리 수준 · 조인 알고리즘 · SQL 튜닝 · 안티패턴 — 벤더를 가리지 않는 관계형 DB 기본기 커리큘럼">
<link rel="stylesheet" href="../assets/style.css">
<script defer src="../assets/app.js"></script>
</head>
<body data-page="rdb/">
<header class="topbar">
<button id="nav-toggle" type="button">☰</button>
<a class="brand" href="../index.html"><span class="logo">🗄️</span> DB Study</a>
<nav class="db-switch">
<a href="../pg/index.html">🐘 PostgreSQL</a>
<a href="../ch/index.html">⚡ ClickHouse</a>
<a href="../es/index.html">🔍 Elasticsearch</a>
<a href="../or/index.html">🔴 Oracle</a>
<a class="on-rdb" href="index.html">🧩 RDB 공통</a>
</nav>
<div class="spacer"></div>
<button id="theme-toggle" type="button" title="테마 전환">🌓</button>
</header>
<div class="layout">
<aside class="sidebar">
<div class="group-title">🧩 RDB 공통 원리</div>
<a class="chap" href="index.html" data-page="rdb/"><span class="num">🏠</span> 커리큘럼 홈</a>
<div class="group-title">커리큘럼 5트랙</div>
<a class="chap" href="#t1"><span class="num">T1</span> 기초 — 실행 순서와 자료구조</a>
<a class="chap" href="#t2"><span class="num">T2</span> 트랜잭션과 격리 수준</a>
<a class="chap" href="#t3"><span class="num">T3</span> 락 — 동시성의 실전</a>
<a class="chap" href="#t4"><span class="num">T4</span> 실행계획과 조인</a>
<a class="chap" href="#t5"><span class="num">T5</span> 튜닝과 안티패턴</a>
<div class="group-title">바로 쓰는 참고표</div>
<a class="chap" href="#lock-matrix"><span class="num">🔐</span> 락 3종 비교</a>
<a class="chap" href="#iso-matrix"><span class="num">🧪</span> 격리 수준 × 이상현상</a>
<a class="chap" href="#join-matrix"><span class="num">🔗</span> 조인 알고리즘 3종</a>
<a class="chap" href="#antipatterns"><span class="num">🚫</span> 쓰면 안 되는 쿼리</a>
<a class="chap" href="#tuning"><span class="num">📈</span> 튜닝 방법론</a>
<a class="chap" href="#vendor"><span class="num">🏷</span> 벤더 차이 매트릭스</a>
<a class="chap" href="#paths"><span class="num">🧭</span> 추천 학습 경로</a>
</aside>
<main class="content">
<h1>🧩 RDB 공통 원리</h1>
<p class="lead">PostgreSQL을 알아도, Oracle을 알아도, 결국 매일 부딪히는 문제는 같습니다. <strong>"동시에 같은 행을 건드리면 어떻게 되는가"</strong>, <strong>"이 쿼리는 왜 느린가"</strong>. 이 트랙은 특정 제품이 아니라 <strong>모든 관계형 DB가 공유하는 원리</strong>를 다룹니다. 락·트랜잭션·격리 수준·조인 알고리즘·튜닝 — 한 번 배우면 어느 DB로 옮겨도 그대로 쓰입니다.</p>
<div class="chapter-meta">
<span class="tag">📚 5트랙 16장</span>
<span class="tag">🧭 벤더 중립</span>
<span class="tag">🔐 락 4장 · 🔗 조인 1장 · 🚫 안티패턴 20+</span>
<span class="tag">🎬 애니메이션 2개</span>
</div>
<div class="callout info">
<span class="ico">🧭</span>
<div>
<div class="callout-title">이 트랙과 벤더 가이드의 역할 분담</div>
<p>같은 주제를 두 번 배우는 게 아닙니다. 여기서는 <b>왜 이 문제가 존재하고 어떤 선택지가 있는가</b>를, 벤더 가이드에서는 <b>그 DB가 그걸 어떻게 구현했는가</b>를 봅니다.</p>
</div>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>주제</th><th>🧩 RDB 공통 (여기)</th><th>벤더 가이드</th></tr></thead>
<tbody>
<tr>
<td>격리 수준</td>
<td>이상현상 5종이 왜 생기는가, 표준 4단계, 벤더별 해석 차이</td>
<td><a href="../pg/ch07.html">PG 7장</a> — 스냅샷·xmin, <code>pg_stat_activity</code></td>
</tr>
<tr>
<td>동시성 제어</td>
<td>MVCC와 2PL의 구조적 차이, 무엇을 포기하고 무엇을 얻는가</td>
<td><a href="../pg/ch03.html">PG 3장</a> — 튜플 버전, <a href="../or/ch06.html">Oracle 6장</a> — Undo</td>
</tr>
<tr>
<td>락</td>
<td>낙관적·비관적·분산 락의 선택 기준과 함정</td>
<td><a href="../pg/ch07.html">PG 7장</a> — <code>pg_locks</code>, <a href="../or/ch08.html">Oracle 8장</a> — 락 모드</td>
</tr>
<tr>
<td>실행계획·조인</td>
<td>NL/Hash/Merge의 비용 모델, 스캔 방식 선택 원리</td>
<td><a href="../pg/ch06.html">PG 6장</a> — EXPLAIN, <a href="../or/tuning.html">Oracle 튜닝 팁</a></td>
</tr>
<tr>
<td>인덱스</td>
<td>B+Tree 구조, 선두 컬럼 규칙, 커버링·선택도</td>
<td><a href="../pg/ch05.html">PG 5장</a> — GIN·BRIN 등 6종, <a href="../or/ch09.html">Oracle 9장</a></td>
</tr>
</tbody>
</table>
</div>
<h2 id="why">먼저 이 장면부터 — 재고 1개가 사라지는 순간</h2>
<p>커리큘럼을 읽기 전에, 이 트랙 전체가 왜 필요한지 보여주는 장면 하나를 봅시다. 재고 10개짜리 상품을 두 사용자가 <b>동시에</b> 1개씩 주문했습니다. 코드에는 버그가 없습니다. 그런데 재고는 8이 아니라 9가 됩니다.</p>
<div class="anim" data-steps="7" data-title="Lost Update — 그리고 세 가지 락 처방" data-interval="3400">
<div class="anim-stage">
<svg viewBox="0 0 900 430">
<defs>
<marker id="ar-rdb" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
<path d="M0,0 L7,3 L0,6 z" fill="var(--rdb-soft)"/>
</marker>
<marker id="ar-warn" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
<path d="M0,0 L7,3 L0,6 z" fill="var(--danger)"/>
</marker>
<marker id="ar-ok" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
<path d="M0,0 L7,3 L0,6 z" fill="var(--ok)"/>
</marker>
</defs>
<text x="450" y="22" text-anchor="middle" class="sv-label" font-size="14">🛒 상품 A — 재고 10개, 두 주문이 같은 순간에 도착</text>
<!-- 세션 A / B -->
<rect x="30" y="42" width="210" height="48" rx="9" fill="var(--accent-soft)" stroke="var(--accent)" stroke-width="1.6"/>
<text x="135" y="63" text-anchor="middle" class="sv-label">👤 세션 A — 주문 1건</text>
<text x="135" y="81" text-anchor="middle" class="sv-label-sm sv-mono">BEGIN;</text>
<rect x="660" y="42" width="210" height="48" rx="9" fill="color-mix(in srgb, var(--purple) 12%, transparent)" stroke="var(--purple)" stroke-width="1.6"/>
<text x="765" y="63" text-anchor="middle" class="sv-label">👤 세션 B — 주문 1건</text>
<text x="765" y="81" text-anchor="middle" class="sv-label-sm sv-mono">BEGIN;</text>
<!-- 중앙 행 -->
<rect x="350" y="112" width="200" height="62" rx="10" class="sv-box"/>
<text x="450" y="132" text-anchor="middle" class="sv-label-sm">products (id=1)</text>
<g data-show="1-2"><text x="450" y="158" text-anchor="middle" class="sv-label sv-mono" font-size="17">stock = 10</text></g>
<g data-show="3-3"><text x="450" y="158" text-anchor="middle" class="sv-label sv-mono" font-size="17" fill="var(--warn)">stock = 9</text></g>
<g data-show="4-4"><text x="450" y="158" text-anchor="middle" class="sv-label sv-mono" font-size="17" fill="var(--danger)">stock = 9</text></g>
<g data-show="5-7"><text x="450" y="158" text-anchor="middle" class="sv-label sv-mono" font-size="17" fill="var(--ok)">stock = 8 ✓</text></g>
<!-- step2: 둘 다 읽음 -->
<g data-show="2-4">
<path d="M 346 138 L 244 128" class="sv-arrow" marker-end="url(#ar-rdb)"/>
<path d="M 554 138 L 656 128" class="sv-arrow" marker-end="url(#ar-rdb)"/>
<text x="150" y="112" class="sv-label-sm sv-mono" fill="var(--rdb-soft)">SELECT stock → 10</text>
<text x="672" y="112" class="sv-label-sm sv-mono" fill="var(--rdb-soft)">SELECT stock → 10</text>
</g>
<!-- step3: A가 UPDATE -->
<g data-show="3-4">
<path d="M 240 150 L 346 158" class="sv-arrow" marker-end="url(#ar-rdb)"/>
<text x="66" y="182" class="sv-label-sm sv-mono" fill="var(--warn)">UPDATE … stock = 10-1; COMMIT;</text>
</g>
<!-- step4: B가 덮어씀 -->
<g data-show="4-4">
<path d="M 660 150 L 554 158" class="sv-arrow" marker-end="url(#ar-warn)"/>
<text x="600" y="182" class="sv-label-sm sv-mono" fill="var(--danger)">UPDATE … stock = 10-1; COMMIT;</text>
<rect x="255" y="196" width="390" height="42" rx="8" fill="color-mix(in srgb, var(--danger) 14%, transparent)" stroke="var(--danger)" stroke-width="1.6" class="blink"/>
<text x="450" y="213" text-anchor="middle" class="sv-label" fill="var(--danger)">❌ Lost Update</text>
<text x="450" y="230" text-anchor="middle" class="sv-label-sm">2건을 팔았는데 재고는 1개만 줄었다 — B가 A의 결과를 덮어씀</text>
</g>
<!-- 처방 1: 비관적 락 -->
<g data-show="5-5" class="rise">
<rect x="60" y="256" width="780" height="150" rx="12" fill="color-mix(in srgb, var(--rdb) 10%, transparent)" stroke="var(--rdb-soft)" stroke-width="1.6"/>
<text x="80" y="282" class="sv-label" font-size="15">🔒 처방 1 — 비관적 락 (Pessimistic Lock)</text>
<text x="80" y="308" class="sv-label-sm sv-mono">A: SELECT stock FROM products WHERE id=1 FOR UPDATE; → 행에 배타 락</text>
<text x="80" y="330" class="sv-label-sm sv-mono" fill="var(--warn)">B: SELECT … FOR UPDATE; → ⏳ A가 COMMIT할 때까지 대기(블로킹)</text>
<text x="80" y="352" class="sv-label-sm sv-mono" fill="var(--ok)">B: 대기 해제 후 stock=9를 다시 읽고 → 8로 UPDATE ✓</text>
<text x="80" y="382" class="sv-label-sm">👍 충돌이 잦을 때 확실하다 · 👎 대기·데드락·동시성 저하 — 트랜잭션을 짧게!</text>
</g>
<!-- 처방 2: 낙관적 락 -->
<g data-show="6-6" class="rise">
<rect x="60" y="256" width="780" height="150" rx="12" fill="color-mix(in srgb, var(--ok) 10%, transparent)" stroke="var(--ok)" stroke-width="1.6"/>
<text x="80" y="282" class="sv-label" font-size="15">🔁 처방 2 — 낙관적 락 (Optimistic Lock)</text>
<text x="80" y="308" class="sv-label-sm sv-mono">둘 다 stock=10, version=3을 읽는다 (락 없음)</text>
<text x="80" y="330" class="sv-label-sm sv-mono" fill="var(--ok)">A: UPDATE … SET stock=9, version=4 WHERE id=1 AND version=3; → 1 row ✓</text>
<text x="80" y="352" class="sv-label-sm sv-mono" fill="var(--warn)">B: UPDATE … WHERE id=1 AND version=3; → <tspan fill="var(--danger)">0 rows</tspan> → 충돌 감지 → 재조회 후 재시도 → 8 ✓</text>
<text x="80" y="382" class="sv-label-sm">👍 대기가 없어 처리량이 높다 · 👎 경합이 심하면 재시도 폭주 — 백오프 필수</text>
</g>
<!-- 처방 3: 분산 락 -->
<g data-show="7-7" class="rise">
<rect x="60" y="256" width="780" height="150" rx="12" fill="color-mix(in srgb, var(--purple) 10%, transparent)" stroke="var(--purple)" stroke-width="1.6"/>
<text x="80" y="282" class="sv-label" font-size="15">🌐 처방 3 — 분산 락 (Distributed Lock)</text>
<text x="80" y="306" class="sv-label-sm">DB 한 대로 못 막는 경우: <tspan class="sv-mono">앱 인스턴스 3대의 스케줄러가 같은 배치를 동시 실행</tspan>,</text>
<text x="80" y="326" class="sv-label-sm">또는 <tspan class="sv-mono">외부 결제 API 호출처럼 DB 트랜잭션 밖에 있는 자원</tspan>을 보호할 때.</text>
<text x="80" y="352" class="sv-label-sm sv-mono" fill="var(--purple)">SET lock:order:1 <token> NX PX 3000 → 획득한 1대만 진행 · fencing token 동봉</text>
<text x="80" y="382" class="sv-label-sm" fill="var(--warn)">⚠️ 마지막 질문: 유니크 제약이나 멱등키로 풀 수 있다면 분산 락은 쓰지 않는 게 정답이다.</text>
</g>
</svg>
</div>
<div class="anim-caption-src" hidden>
<p>재고 10개짜리 상품에 주문 두 건이 동시에 들어옵니다. 세션 A와 B 모두 <code>BEGIN</code>으로 트랜잭션을 시작했습니다. 애플리케이션 코드는 지극히 평범합니다 — "재고를 읽고, 1을 빼서, 저장한다".</p>
<p>두 세션이 <b>각각</b> <code>SELECT stock</code>을 실행합니다. 둘 다 <b>10</b>을 읽습니다. 여기까지는 아무 문제가 없어 보입니다. 문제는 이 값이 <b>이미 낡은 값이 될 운명</b>이라는 것입니다.</p>
<p>A가 먼저 <code>UPDATE … stock = 10 - 1</code>을 실행하고 커밋합니다. 재고는 정상적으로 <b>9</b>가 되었습니다.</p>
<p>이제 B가 <b>자기가 읽었던 10</b>을 기준으로 <code>stock = 10 - 1</code>을 실행합니다. 결과는 또 <b>9</b>. <b style="color:var(--danger)">A의 차감이 통째로 사라졌습니다.</b> 주문은 2건인데 재고는 1개만 줄었죠. 이것이 <b>Lost Update</b>입니다. 대부분의 DB 기본 격리 수준(Read Committed)에서 <b>정상 동작</b>으로 처리되며, 에러 한 줄 나지 않습니다.</p>
<p><b>처방 1 — 비관적 락.</b> "충돌은 반드시 난다"고 가정하고 <b>읽을 때부터 행을 잠급니다</b>(<code>SELECT … FOR UPDATE</code>). B는 A가 커밋할 때까지 대기했다가, 갱신된 9를 다시 읽고 8로 만듭니다. 확실하지만 대가가 있습니다 — 대기, 데드락, 그리고 처리량 저하. <b>T3의 8장</b>에서 다룹니다.</p>
<p><b>처방 2 — 낙관적 락.</b> "충돌은 드물다"고 가정하고 락 없이 진행하되, <code>version</code> 컬럼으로 <b>커밋 시점에 검증</b>합니다. B의 UPDATE는 <b>0행</b>이 갱신되며 충돌이 드러나고, 애플리케이션이 재조회 후 재시도합니다. 대기가 없어 빠르지만, 경합이 심하면 재시도가 폭주합니다. <b>T3의 9장</b>에서 다룹니다.</p>
<p><b>처방 3 — 분산 락.</b> 앞의 둘은 <b>DB 한 대 안에서만</b> 통합니다. 앱 인스턴스 3대의 스케줄러가 같은 배치를 동시에 돌리거나, 보호해야 할 자원이 외부 결제 API처럼 <b>DB 트랜잭션 바깥</b>에 있으면 Redis·ZooKeeper 같은 외부 조정자가 필요합니다. 다만 TTL 만료로 인한 이중 실행 위험이 있어 <b>fencing token</b>이 따라붙습니다. 그리고 가장 중요한 질문 — <b>유니크 제약이나 멱등키로 풀리는 문제라면 분산 락은 쓰지 않는 게 정답입니다.</b> <b>T3의 10장</b>에서 다룹니다.</p>
</div>
</div>
<div class="callout tip">
<span class="ico">🎯</span>
<div>
<div class="callout-title">이 트랙을 끝내면 답할 수 있는 질문들</div>
<p>"재고 차감에 비관적 락과 낙관적 락 중 뭘 쓰죠?" · "우리 서비스 격리 수준이 Read Committed인데 괜찮나요?" · "이 쿼리에 인덱스가 있는데 왜 Full Scan이 뜨죠?" · "Hash Join이 Nested Loop보다 항상 빠른가요?" · "스케줄러가 인스턴스마다 중복 실행되는데 분산 락이 답인가요?"</p>
</div>
</div>
<h2 id="curriculum">커리큘럼 — 5트랙 16장</h2>
<p>앞의 순서대로 읽으면 뒤 장의 발판이 됩니다. 각 카드의 <b>"이 장에서 답하는 질문"</b>이 곧 그 장의 학습 목표입니다.</p>
<h3 id="t1">T1. 기초 — 옵티마이저를 이해하기 위한 전제 <span class="tag lv-basic">기초</span></h3>
<p>3~5장의 튜닝 이야기는 전부 이 세 장 위에 서 있습니다. "인덱스를 타지 않는 이유"를 외우지 않고 <b>설명할 수 있게</b> 만드는 것이 목표입니다.</p>
<div class="grid g2">
<div class="card plan">
<h4><span class="ch-no">01</span>RDB 모델과 SQL 논리적 실행 순서</h4>
<p>우리가 쓰는 순서(SELECT→FROM→WHERE)와 DB가 처리하는 순서(FROM→WHERE→GROUP BY→HAVING→SELECT→ORDER BY→LIMIT)는 다릅니다. 이 차이 하나가 수많은 "왜 안 되지?"의 원인입니다.</p>
<ul class="qlist">
<li>왜 <code>WHERE</code>에서는 <code>SELECT</code> 별칭을 못 쓰는데 <code>ORDER BY</code>에서는 되나?</li>
<li><code>WHERE</code>와 <code>HAVING</code>은 무엇이 다른가?</li>
<li><code>NULL</code>은 왜 <code>= NULL</code>로 비교되지 않는가? 3값 논리란?</li>
</ul>
<div class="chapter-meta"><span class="tag lv-basic">기초</span><span class="tag lv-plan">집필 예정</span></div>
</div>
<div class="card plan">
<h4><span class="ch-no">02</span>페이지·블록과 B+Tree 인덱스 구조</h4>
<p>인덱스를 "빠르게 해주는 마법"이 아니라 <b>정렬된 트리 자료구조</b>로 이해합니다. 여기서 복합 인덱스의 선두 컬럼 규칙이 자연스럽게 유도됩니다.</p>
<ul class="qlist">
<li>루트→리프 탐색이 왜 로그 시간인가? 레벨 3~4가 왜 흔한가?</li>
<li><code>(a, b, c)</code> 인덱스에서 <code>WHERE b = ?</code>가 왜 못 타는가?</li>
<li>커버링 인덱스·클러스터링 팩터·선택도(selectivity)란?</li>
</ul>
<div class="chapter-meta"><span class="tag lv-basic">기초</span><span class="tag lv-plan">집필 예정</span></div>
</div>
<div class="card plan">
<h4><span class="ch-no">03</span>통계정보와 카디널리티 추정</h4>
<p>옵티마이저의 모든 판단은 "이 조건에 몇 행이 걸릴까?"라는 <b>추정</b>에서 출발합니다. 추정이 틀어지면 플랜 전체가 무너집니다.</p>
<ul class="qlist">
<li>히스토그램과 NDV로 행 수를 어떻게 추정하는가?</li>
<li>추정 1행 → 실제 100만 행이면 무슨 일이 벌어지는가?</li>
<li>통계가 낡으면 왜 어제 빠르던 쿼리가 오늘 느려지는가?</li>
</ul>
<div class="chapter-meta"><span class="tag lv-mid">중급</span><span class="tag lv-plan">집필 예정</span></div>
</div>
</div>
<h3 id="t2">T2. 트랜잭션과 격리 수준 — 정합성의 뼈대 <span class="tag lv-mid">중급</span></h3>
<p>요청하신 "트랜잭션 / 아이솔레이션 레벨"의 본진입니다. 이상현상 5종을 <b>세션 2개 타임라인</b>으로 직접 재현해 보는 실습이 중심입니다.</p>
<div class="grid g2">
<div class="card plan">
<h4><span class="ch-no">04</span>트랜잭션과 ACID, 그리고 트랜잭션 경계 설계</h4>
<p>ACID 암기가 아니라, <b>트랜잭션을 어디서 열고 어디서 닫을 것인가</b>라는 실무 설계 문제로 접근합니다. 장애의 상당수가 여기서 시작됩니다.</p>
<ul class="qlist">
<li>savepoint·중첩 트랜잭션·autocommit은 실제로 어떻게 동작하나?</li>
<li>트랜잭션 안에서 외부 API를 호출하면 왜 위험한가?</li>
<li><code>@Transactional</code> 범위를 넓게 잡으면 무엇이 무너지나?</li>
</ul>
<div class="chapter-meta"><span class="tag lv-basic">기초</span><span class="tag lv-plan">집필 예정</span></div>
</div>
<div class="card plan">
<h4><span class="ch-no">05</span>격리 수준 4단계 × 이상현상 5종</h4>
<p>Dirty Read · Non-repeatable Read · Phantom · Lost Update · <b>Write Skew</b>를 각각 재현하고, 어느 격리 수준에서 막히는지 눈으로 확인합니다.</p>
<ul class="qlist">
<li>Read Committed에서 같은 쿼리가 두 번 다른 결과를 주는 이유는?</li>
<li>MySQL의 RR과 PostgreSQL의 RR은 왜 다르게 동작하는가?</li>
<li>Serializable만 막을 수 있는 Write Skew는 어떤 모양인가?</li>
</ul>
<div class="chapter-meta"><span class="tag lv-mid">중급</span><span class="tag lv-plan">집필 예정</span></div>
</div>
<div class="card plan">
<h4><span class="ch-no">06</span>MVCC vs 2PL — 동시성 제어의 두 갈래</h4>
<p>"읽기가 쓰기를 막지 않는다"는 말이 <b>공짜가 아니라 트레이드오프</b>라는 것을 이해합니다. 두 방식의 비용 구조를 비교합니다.</p>
<ul class="qlist">
<li>스냅샷 방식은 무엇을 대가로 읽기 락을 없앴는가?</li>
<li>PG는 왜 VACUUM이 필요하고 Oracle은 왜 Undo가 필요한가?</li>
<li>SQL Server의 기본 잠금 방식은 왜 읽기가 쓰기를 막는가?</li>
</ul>
<div class="chapter-meta"><span class="tag lv-adv">고급</span><span class="tag lv-plan">집필 예정</span></div>
</div>
</div>
<h3 id="t3">T3. 락 — 동시성의 실전 <span class="tag lv-mid">이 트랙의 하이라이트</span></h3>
<p>요청하신 <b>낙관적 락 · 비관적 락 · 분산 락</b>을 각각 한 장씩, 예시 코드와 함께 다룹니다. 앞의 애니메이션이 이 트랙 전체의 예고편입니다.</p>
<div class="grid g2">
<div class="card plan">
<h4><span class="ch-no">07</span>DB 락의 종류 — 무엇이 무엇을 막는가</h4>
<p>락을 "걸린다/안 걸린다"가 아니라 <b>모드 × 대상 × 호환성 행렬</b>로 이해합니다. 락 대기 체인을 읽는 법까지.</p>
<ul class="qlist">
<li>공유(S)·배타(X)·의도(IS/IX) 락은 서로 어떻게 충돌하는가?</li>
<li>행·페이지·테이블 락과 락 에스컬레이션이란?</li>
<li>FK가 걸린 테이블에 INSERT하면 왜 부모 행이 잠기는가?</li>
<li>DDL(ALTER TABLE) 한 줄이 왜 서비스 전체를 멈추는가?</li>
</ul>
<div class="chapter-meta"><span class="tag lv-mid">중급</span><span class="tag lv-plan">집필 예정</span></div>
</div>
<div class="card plan">
<h4><span class="ch-no">08</span>비관적 락 — 먼저 잠그고 시작한다</h4>
<p><code>FOR UPDATE</code> 계열의 모든 것. 특히 <code>SKIP LOCKED</code>를 이용한 <b>작업 큐 패턴</b>은 실무에서 바로 쓰는 무기입니다.</p>
<ul class="qlist">
<li><code>FOR UPDATE</code>와 <code>FOR SHARE</code>는 언제 갈리는가?</li>
<li><code>NOWAIT</code> / <code>SKIP LOCKED</code>로 대기를 어떻게 제어하나?</li>
<li>여러 워커가 겹치지 않게 작업을 집어가는 큐를 어떻게 만드나?</li>
<li>락 획득 <b>순서</b>가 왜 데드락을 만들고, 어떻게 표준화하나?</li>
</ul>
<div class="chapter-meta"><span class="tag lv-mid">중급</span><span class="tag lv-plan">집필 예정</span></div>
</div>
<div class="card plan">
<h4><span class="ch-no">09</span>낙관적 락 — 일단 진행하고 커밋에서 검증한다</h4>
<p><code>version</code> 컬럼 한 개로 대기 없는 동시성을 얻는 방법과, 그 대가인 <b>재시도 전략</b>을 설계합니다.</p>
<ul class="qlist">
<li>version / updated_at / 값 비교(CAS) 중 무엇을 쓸 것인가?</li>
<li>충돌 시 재시도를 몇 번, 얼마 간격으로? (지수 백오프)</li>
<li>JPA <code>@Version</code>은 내부적으로 어떤 SQL을 만드는가?</li>
<li><b>충돌률 몇 %부터 비관적 락이 유리해지는가?</b></li>
</ul>
<div class="chapter-meta"><span class="tag lv-mid">중급</span><span class="tag lv-plan">집필 예정</span></div>
</div>
<div class="card plan">
<h4><span class="ch-no">10</span>분산 락 — DB 하나로 못 막을 때</h4>
<p>가장 오남용되는 도구입니다. <b>먼저 "정말 필요한가?"를 묻는 체크리스트</b>부터 시작해서, 필요할 때 안전하게 쓰는 법을 봅니다.</p>
<ul class="qlist">
<li>DB advisory lock / Redis / ZooKeeper·etcd — 무엇을 언제?</li>
<li>TTL이 만료됐는데 작업이 안 끝났다면? (<b>fencing token</b>)</li>
<li>Redlock 논쟁 — 왜 "안전하지 않다"는 반론이 나왔나?</li>
<li><b>유니크 제약·멱등키로 락 없이 푸는 방법</b>은 없는가?</li>
</ul>
<div class="chapter-meta"><span class="tag lv-adv">고급</span><span class="tag lv-plan">집필 예정</span></div>
</div>
<div class="card plan">
<h4><span class="ch-no">11</span>데드락과 락 경합 — 실전 진단</h4>
<p>실제 데드락 로그를 놓고 <b>누가 무엇을 기다렸는지</b> 역추적하는 훈련. 락 경합은 대개 코드가 아니라 순서의 문제입니다.</p>
<ul class="qlist">
<li>데드락 로그에서 두 트랜잭션의 락 순서를 어떻게 읽는가?</li>
<li>핫 로우(인기 상품 한 줄)에 몰리는 경합은 어떻게 분산하나?</li>
<li><code>lock_timeout</code> / <code>innodb_lock_wait_timeout</code>은 얼마가 적절한가?</li>
</ul>
<div class="chapter-meta"><span class="tag lv-adv">고급</span><span class="tag lv-plan">집필 예정</span></div>
</div>
</div>
<h3 id="t4">T4. 실행계획과 조인 — 쿼리는 이렇게 실행된다 <span class="tag lv-mid">중급</span></h3>
<p>요청하신 <b>Nested Loop · Hash Join</b> 등 조인 알고리즘이 여기 있습니다. 실행계획을 읽는 법 → 스캔 방식 → 조인 → 집계 순으로, 플랜 트리를 아래에서 위로 읽어 올라가는 순서입니다.</p>
<div class="grid g2">
<div class="card plan">
<h4><span class="ch-no">12</span>실행계획 읽는 법 — 벤더 공통 문법</h4>
<p>PostgreSQL <code>EXPLAIN ANALYZE</code>, MySQL <code>EXPLAIN ANALYZE</code>, Oracle <code>DBMS_XPLAN</code>은 표기만 다를 뿐 <b>읽는 방법은 같습니다</b>.</p>
<ul class="qlist">
<li>플랜 트리는 어디서부터 읽어야 하는가?</li>
<li><b>추정 행 수 vs 실제 행 수</b>의 괴리를 어디서 찾는가? (튜닝의 8할)</li>
<li>cost·rows·actual time·loops는 각각 무엇을 뜻하는가?</li>
</ul>
<div class="chapter-meta"><span class="tag lv-mid">중급</span><span class="tag lv-plan">집필 예정</span></div>
</div>
<div class="card plan">
<h4><span class="ch-no">13</span>스캔 방식 — 인덱스를 타거나, 못 타거나</h4>
<p>"인덱스를 만들었는데 왜 안 타나요?"에 대한 <b>완전한 답 8가지</b>를 정리합니다. 가장 자주 받는 질문이자 가장 자주 잘못 진단되는 문제입니다.</p>
<ul class="qlist">
<li>Full/Seq Scan이 <b>오히려 빠른</b> 경우는 언제인가?</li>
<li>Index Only Scan과 Bitmap Scan은 언제 선택되는가?</li>
<li>암묵적 형변환·함수 감싸기·<code>LIKE '%x'</code>가 왜 인덱스를 죽이는가?</li>
</ul>
<div class="chapter-meta"><span class="tag lv-mid">중급</span><span class="tag lv-plan">집필 예정</span></div>
</div>
<div class="card plan">
<h4><span class="ch-no">14</span>조인 알고리즘 — NL · Hash · Sort Merge</h4>
<p>조인 <b>문법</b>(INNER/OUTER)이 아니라 조인 <b>알고리즘</b>을 다룹니다. 옵티마이저가 왜 그걸 골랐는지 설명할 수 있게 됩니다.</p>
<ul class="qlist">
<li>세 알고리즘의 비용 공식과 손익분기점은?</li>
<li>드라이빙 테이블(선행 집합)은 왜 작은 쪽이어야 하는가?</li>
<li>조인 순서 경우의 수가 폭발하면 옵티마이저는 어떻게 하는가?</li>
<li>세미/안티 조인, 서브쿼리 언네스팅이란?</li>
</ul>
<div class="chapter-meta"><span class="tag lv-mid">중급</span><span class="tag lv-plan">집필 예정</span></div>
</div>
<div class="card plan">
<h4><span class="ch-no">15</span>집계·정렬·그룹핑의 비용</h4>
<p>조인만큼 자주 병목이 되지만 훨씬 덜 이야기되는 영역. <b>정렬이 메모리를 넘치면</b> 무슨 일이 벌어지는지 봅니다.</p>
<ul class="qlist">
<li>Sort Aggregate와 Hash Aggregate는 어떻게 갈리는가?</li>
<li>작업 메모리 초과 시 디스크 spill이 성능에 얼마나 영향을 주나?</li>
<li><code>DISTINCT</code>·윈도우 함수를 남용하면 어디서 터지는가?</li>
</ul>
<div class="chapter-meta"><span class="tag lv-adv">고급</span><span class="tag lv-plan">집필 예정</span></div>
</div>
</div>
<h3 id="t5">T5. 튜닝과 안티패턴 — 실무로 내려오기 <span class="tag lv-adv">실전</span></h3>
<p>요청하신 <b>튜닝 기법</b>과 <b>쓰면 안 되는 쿼리</b>가 여기 모입니다. 개별 기법을 나열하기 전에 <b>방법론</b>을 먼저 세웁니다 — 측정 없이 하는 튜닝은 도박이기 때문입니다.</p>
<div class="grid g2">
<div class="card plan">
<h4><span class="ch-no">16</span>SQL 튜닝 방법론과 안티패턴 카탈로그</h4>
<p>느린 쿼리를 <b>찾고 → 원인을 좁히고 → 고치고 → 검증하는</b> 루프. 그리고 현업에서 반복해서 발견되는 안티패턴 20+개를 <code>❌ 나쁜 쿼리 / 왜 / ✅ 개선</code> 3단으로.</p>
<ul class="qlist">
<li>느린 쿼리를 어디서 찾는가? (slow log · 대기 이벤트 · top query)</li>
<li>인덱스 추가·쿼리 재작성·스키마 변경 중 <b>무엇을 먼저</b>?</li>
<li>대용량 UPDATE/DELETE를 안전하게 쪼개는 방법은?</li>
<li>딥 페이지네이션(<code>OFFSET 1000000</code>)을 어떻게 없애는가?</li>
</ul>
<div class="chapter-meta"><span class="tag lv-adv">고급</span><span class="tag lv-plan">집필 예정</span></div>
</div>
</div>
<hr>
<h2 id="lock-matrix">🔐 락 3종 비교 — 지금 바로 고르기</h2>
<p>16장을 다 읽기 전에도 쓸 수 있도록, 세 가지 락의 선택 기준을 한 표로 정리했습니다. (9장·10장의 요약)</p>
<div class="table-wrap">
<table>
<thead><tr><th>구분</th><th>🔁 낙관적 락</th><th>🔒 비관적 락</th><th>🌐 분산 락</th></tr></thead>
<tbody>
<tr><td><strong>전제</strong></td><td>충돌은 드물다</td><td>충돌은 자주 난다</td><td>여러 프로세스/노드가 경쟁한다</td></tr>
<tr><td><strong>구현</strong></td><td><code>version</code> 컬럼 + <code>WHERE version = ?</code></td><td><code>SELECT … FOR UPDATE</code></td><td>Redis <code>SET NX PX</code>, ZooKeeper, DB advisory lock</td></tr>
<tr><td><strong>대기</strong></td><td>없음 (실패 후 재시도)</td><td>있음 (블로킹)</td><td>있음 (TTL·타임아웃)</td></tr>
<tr><td><strong>충돌 감지 시점</strong></td><td>UPDATE 실행 시 (0행)</td><td>락 획득 시 (선점)</td><td>락 획득 시</td></tr>
<tr><td><strong>데드락</strong></td><td>없음</td><td>있음 — 락 순서 표준화 필요</td><td>드묾 — 대신 <b>락 만료 중 이중 실행</b> 위험</td></tr>
<tr><td><strong>주 비용</strong></td><td>재시도 (CPU·응답시간 분산)</td><td>동시성 저하 (처리량)</td><td>네트워크 왕복 + 운영 복잡도</td></tr>
<tr><td><strong>잘 맞는 곳</strong></td><td>게시글·프로필 수정, 낮은 경합 갱신</td><td>재고 차감, 잔액 이체, 좌석 예약</td><td>다중 인스턴스 스케줄러, 외부 API 중복 호출 방지</td></tr>
<tr><td><strong>대표 함정</strong></td><td>경합 급증 시 재시도 폭주 → 백오프 없으면 장애 증폭</td><td>트랜잭션이 길면 전체 대기 → 락 구간 최소화</td><td>TTL 만료 후 두 워커가 동시 진행 → <b>fencing token</b> 필수</td></tr>
</tbody>
</table>
</div>
<div class="callout tip">
<span class="ico">🧭</span>
<div>
<div class="callout-title">선택 순서 — 위에서부터 내려오세요</div>
<p><b>1.</b> 락 없이 풀리는가? → <code>UNIQUE</code> 제약, 멱등키, <code class="wrap">UPDATE … SET stock = stock - 1 WHERE stock >= 1</code> 같은 <b>원자적 단일 문장</b>이면 락은 필요 없습니다.<br>
<b>2.</b> 한 DB 안에서 끝나는가? → 예라면 <b>낙관적(경합 낮음)</b> 또는 <b>비관적(경합 높음)</b>.<br>
<b>3.</b> DB 밖 자원까지 보호해야 하는가? → 그때만 <b>분산 락</b>. 그리고 fencing token을 잊지 마세요.</p>
</div>
</div>
<p>세 가지가 실제 SQL로는 이렇게 생겼습니다.</p>
<pre><code><span class="c">-- ❶ 락이 필요 없는 경우 — 원자적 단일 문장이 가장 빠르고 안전하다</span>
<span class="k">UPDATE</span> products
<span class="k">SET</span> stock = stock - <span class="n">1</span>
<span class="k">WHERE</span> id = <span class="n">1</span>
<span class="k">AND</span> stock >= <span class="n">1</span>; <span class="c">-- 0행이면 재고 부족 → 애플리케이션에서 실패 처리</span>
<span class="c">-- ❷ 비관적 락 — 읽는 순간부터 잠근다 (다른 계산이 끼어야 할 때)</span>
<span class="k">BEGIN</span>;
<span class="k">SELECT</span> stock <span class="k">FROM</span> products <span class="k">WHERE</span> id = <span class="n">1</span> <span class="k">FOR UPDATE</span>; <span class="c">-- 여기서 대기 발생</span>
<span class="c">-- … 검증 로직 … (이 구간이 길수록 전체 처리량이 떨어진다)</span>
<span class="k">UPDATE</span> products <span class="k">SET</span> stock = <span class="n">8</span> <span class="k">WHERE</span> id = <span class="n">1</span>;
<span class="k">COMMIT</span>;
<span class="c">-- ❸ 낙관적 락 — 락 없이 읽고, 커밋 시점에 version으로 검증</span>
<span class="k">SELECT</span> stock, version <span class="k">FROM</span> products <span class="k">WHERE</span> id = <span class="n">1</span>; <span class="c">-- stock=10, version=3</span>
<span class="k">UPDATE</span> products
<span class="k">SET</span> stock = <span class="n">9</span>, version = <span class="n">4</span>
<span class="k">WHERE</span> id = <span class="n">1</span> <span class="k">AND</span> version = <span class="n">3</span>; <span class="c">-- 0행 = 누군가 먼저 바꿈 → 재조회 후 재시도</span>
<span class="c">-- ❹ 작업 큐 — 여러 워커가 겹치지 않게 집어간다 (비관적 락의 응용)</span>
<span class="k">SELECT</span> id, payload <span class="k">FROM</span> jobs
<span class="k">WHERE</span> status = <span class="s">'ready'</span>
<span class="k">ORDER BY</span> created_at
<span class="k">LIMIT</span> <span class="n">10</span>
<span class="k">FOR UPDATE SKIP LOCKED</span>; <span class="c">-- 잠긴 행은 대기 없이 건너뛴다</span>
</code></pre>
<div class="callout warn">
<span class="ico">⚠️</span>
<div>
<div class="callout-title">분산 락에서 가장 많이 빠뜨리는 것 — fencing token</div>
<p>워커 A가 락을 잡고 작업하다 GC 스톱이나 네트워크 지연으로 멈춘 사이 <b>TTL이 만료</b>되면, 워커 B가 같은 락을 잡습니다. 그리고 A가 깨어나 <b>자기가 아직 락을 가진 줄 알고</b> 작업을 마저 진행합니다 — 둘이 동시에 실행되는 것이죠. 그래서 락을 줄 때 <b>단조 증가하는 번호(fencing token)</b>를 함께 발급하고, 최종 저장소에서 <b>더 낮은 번호의 쓰기를 거부</b>해야 합니다. 락만으로는 상호배제가 보장되지 않습니다.</p>
</div>
</div>
<h2 id="iso-matrix">🧪 격리 수준 × 이상현상 매트릭스</h2>
<p>표준(ANSI SQL)이 정의한 것과 실제 제품의 동작은 다릅니다. 아래 표는 <b>표준 기준</b>이고, 벤더 차이는 그 아래 표에서 봅니다.</p>
<div class="table-wrap">
<table>
<thead><tr><th>격리 수준</th><th>Dirty Read</th><th>Non-repeatable Read</th><th>Phantom Read</th><th>Lost Update</th><th>Write Skew</th></tr></thead>
<tbody>
<tr><td><strong>Read Uncommitted</strong></td><td>발생</td><td>발생</td><td>발생</td><td>발생</td><td>발생</td></tr>
<tr><td><strong>Read Committed</strong> <span class="tag">대부분의 기본값</span></td><td>✗ 차단</td><td>발생</td><td>발생</td><td><strong>발생</strong></td><td>발생</td></tr>
<tr><td><strong>Repeatable Read</strong></td><td>✗</td><td>✗ 차단</td><td>표준상 발생 <span class="tag">PG·MySQL은 차단</span></td><td>✗ (구현별)</td><td>발생</td></tr>
<tr><td><strong>Serializable</strong></td><td>✗</td><td>✗</td><td>✗</td><td>✗</td><td>✗ 차단</td></tr>
</tbody>
</table>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>이상현상</th><th>한 줄 정의</th><th>현실의 사고 사례</th></tr></thead>
<tbody>
<tr><td><strong>Dirty Read</strong></td><td>커밋되지 않은 남의 변경을 읽음</td><td>롤백될 주문을 집계에 포함해 매출이 부풀려짐</td></tr>
<tr><td><strong>Non-repeatable Read</strong></td><td>같은 행을 두 번 읽었더니 값이 다름</td><td>화면 상단 잔액과 하단 합계가 어긋남</td></tr>
<tr><td><strong>Phantom Read</strong></td><td>같은 조건으로 두 번 읽었더니 <b>행 개수</b>가 다름</td><td>정원 체크 후 예약 확정 사이에 다른 예약이 끼어들어 초과 예약</td></tr>
<tr><td><strong>Lost Update</strong></td><td>읽고-계산하고-쓰는 사이 남의 갱신이 덮임</td><td><b>위 애니메이션의 재고 차감</b> — 2건 팔고 1개만 차감</td></tr>
<tr><td><strong>Write Skew</strong></td><td>각자 <b>다른 행</b>을 써서 충돌은 없지만, 둘을 합치면 <b>불변식이 깨짐</b></td><td>"당직은 최소 1명" 규칙 아래 두 명이 동시에 당직을 빼서 0명이 됨</td></tr>
</tbody>
</table>
</div>
<div class="callout danger">
<span class="ico">🚨</span>
<div>
<div class="callout-title">가장 흔한 오해 — "우리는 트랜잭션을 쓰니까 안전하다"</div>
<p>대부분의 서비스가 기본값인 <b>Read Committed</b>에서 돌아갑니다. 이 수준은 <b>Lost Update를 막아주지 않습니다.</b> 즉 <code>BEGIN … SELECT … UPDATE … COMMIT</code>으로 감쌌다고 해서 재고 차감이 안전해지지 않습니다. 안전하게 만드는 건 트랜잭션이 아니라 <b>락 또는 원자적 UPDATE 문장</b>입니다.</p>
</div>
</div>
<h2 id="join-matrix">🔗 조인 알고리즘 3종</h2>
<p>같은 <code>JOIN</code> 문법이라도 옵티마이저는 상황에 따라 완전히 다른 알고리즘을 고릅니다. 재생해 보세요.</p>
<div class="anim" data-steps="5" data-title="Nested Loop · Hash Join · Sort Merge Join" data-interval="3600">
<div class="anim-stage">
<svg viewBox="0 0 900 350">
<text x="450" y="22" text-anchor="middle" class="sv-label" font-size="14">
SELECT * FROM users u JOIN orders o ON o.user_id = u.id
</text>
<!-- 두 테이블 -->
<rect x="250" y="40" width="170" height="52" rx="9" fill="var(--accent-soft)" stroke="var(--accent)" stroke-width="1.5"/>
<text x="335" y="62" text-anchor="middle" class="sv-label">👥 users</text>
<text x="335" y="80" text-anchor="middle" class="sv-label-sm sv-mono">1,000,000 행</text>
<rect x="480" y="40" width="170" height="52" rx="9" fill="color-mix(in srgb, var(--purple) 12%, transparent)" stroke="var(--purple)" stroke-width="1.5"/>
<text x="565" y="62" text-anchor="middle" class="sv-label">🧾 orders</text>
<text x="565" y="80" text-anchor="middle" class="sv-label-sm sv-mono">10,000,000 행</text>
<!-- Nested Loop -->
<g data-show="2-2" class="rise">
<rect x="60" y="112" width="780" height="196" rx="12" fill="color-mix(in srgb, var(--rdb) 9%, transparent)" stroke="var(--rdb-soft)" stroke-width="1.6"/>
<text x="82" y="138" class="sv-label" font-size="15">🔁 Nested Loop Join — "하나씩 들고 가서 찾아본다"</text>
<rect x="90" y="156" width="140" height="112" rx="8" class="sv-box"/>
<text x="160" y="176" text-anchor="middle" class="sv-label-sm">외부(드라이빙)</text>
<text x="160" y="198" text-anchor="middle" class="sv-label-sm sv-mono" fill="var(--ok)">WHERE 조건 후</text>
<text x="160" y="218" text-anchor="middle" class="sv-label-sm sv-mono" fill="var(--ok)">users 12행</text>
<text x="160" y="246" text-anchor="middle" class="sv-label-sm">← 작을수록 좋다</text>
<path d="M 234 212 L 344 212" class="sv-arrow" marker-end="url(#ar-rdb)"/>
<text x="252" y="202" class="sv-label-sm" fill="var(--rdb-soft)">행마다 1번씩 (12 loops)</text>
<rect x="350" y="156" width="200" height="112" rx="8" class="sv-box"/>
<text x="450" y="180" text-anchor="middle" class="sv-label-sm">내부: orders 인덱스</text>
<text x="450" y="204" text-anchor="middle" class="sv-label-sm sv-mono">idx(user_id) 탐색</text>
<text x="450" y="228" text-anchor="middle" class="sv-label-sm sv-mono">B+Tree 3~4 레벨</text>
<text x="450" y="252" text-anchor="middle" class="sv-label-sm" fill="var(--warn)">인덱스 없으면 재앙</text>
<text x="600" y="180" class="sv-label-sm">✅ 유리: 외부 결과가 <tspan font-weight="700">소량</tspan>이고</text>
<text x="600" y="200" class="sv-label-sm"> 내부 조인 키에 인덱스가 있을 때</text>
<text x="600" y="228" class="sv-label-sm" fill="var(--danger)">❌ 위험: 외부 행 수 추정이 빗나가면</text>
<text x="600" y="248" class="sv-label-sm" fill="var(--danger)"> 12 → 120만 loops로 폭발</text>
<text x="82" y="292" class="sv-label-sm sv-mono">비용 ≈ 외부 행수 N × (내부 인덱스 탐색 비용 log M)</text>
</g>
<!-- Hash Join -->
<g data-show="3-3" class="rise">
<rect x="60" y="112" width="780" height="196" rx="12" fill="color-mix(in srgb, var(--ok) 9%, transparent)" stroke="var(--ok)" stroke-width="1.6"/>
<text x="82" y="138" class="sv-label" font-size="15">🪣 Hash Join — "작은 쪽으로 사전을 만들고, 큰 쪽을 훑는다"</text>
<rect x="90" y="156" width="150" height="112" rx="8" class="sv-box"/>
<text x="165" y="178" text-anchor="middle" class="sv-label-sm">① Build 단계</text>
<text x="165" y="200" text-anchor="middle" class="sv-label-sm sv-mono" fill="var(--ok)">작은 쪽(users)으로</text>
<text x="165" y="220" text-anchor="middle" class="sv-label-sm sv-mono" fill="var(--ok)">해시 테이블 생성</text>
<text x="165" y="248" text-anchor="middle" class="sv-label-sm">메모리에 올린다</text>
<path d="M 244 212 L 354 212" class="sv-arrow" marker-end="url(#ar-ok)"/>
<rect x="360" y="156" width="190" height="112" rx="8" class="sv-box"/>
<text x="455" y="178" text-anchor="middle" class="sv-label-sm">② Probe 단계</text>
<text x="455" y="200" text-anchor="middle" class="sv-label-sm sv-mono">orders 1000만 행을</text>
<text x="455" y="220" text-anchor="middle" class="sv-label-sm sv-mono">한 번씩만 스캔</text>
<text x="455" y="248" text-anchor="middle" class="sv-label-sm">해시로 O(1) 매칭</text>
<text x="600" y="180" class="sv-label-sm">✅ 유리: <tspan font-weight="700">대량 등가(=) 조인</tspan>,</text>
<text x="600" y="200" class="sv-label-sm"> 인덱스가 없어도 강하다</text>
<text x="600" y="228" class="sv-label-sm" fill="var(--warn)">❌ 제약: 등가 조인만 가능,</text>
<text x="600" y="248" class="sv-label-sm" fill="var(--warn)"> 메모리 부족 시 디스크 spill</text>
<text x="82" y="292" class="sv-label-sm sv-mono">비용 ≈ N + M (양쪽을 한 번씩) — 단, 해시 테이블이 메모리에 들어갈 때</text>
</g>
<!-- Sort Merge -->
<g data-show="4-4" class="rise">
<rect x="60" y="112" width="780" height="196" rx="12" fill="color-mix(in srgb, var(--warn) 9%, transparent)" stroke="var(--warn)" stroke-width="1.6"/>
<text x="82" y="138" class="sv-label" font-size="15">🤝 Sort Merge Join — "양쪽을 줄 세우고 지퍼처럼 맞춘다"</text>
<rect x="90" y="156" width="150" height="112" rx="8" class="sv-box"/>
<text x="165" y="178" text-anchor="middle" class="sv-label-sm">① 양쪽 정렬</text>
<text x="165" y="202" text-anchor="middle" class="sv-label-sm sv-mono">users → id 순</text>
<text x="165" y="224" text-anchor="middle" class="sv-label-sm sv-mono">orders → user_id 순</text>
<text x="165" y="250" text-anchor="middle" class="sv-label-sm" fill="var(--warn)">여기가 비싸다</text>
<path d="M 244 212 L 354 212" class="sv-arrow" marker-end="url(#ar-rdb)"/>
<rect x="360" y="156" width="190" height="112" rx="8" class="sv-box"/>
<text x="455" y="178" text-anchor="middle" class="sv-label-sm">② 병합 스캔</text>
<text x="455" y="202" text-anchor="middle" class="sv-label-sm sv-mono">1 ─ 1 ─ 2 ─ 3 ─ 3</text>
<text x="455" y="224" text-anchor="middle" class="sv-label-sm sv-mono">양쪽 포인터 전진</text>
<text x="455" y="250" text-anchor="middle" class="sv-label-sm">되돌아가지 않는다</text>
<text x="600" y="180" class="sv-label-sm">✅ 유리: 이미 <tspan font-weight="700">정렬</tspan>되어 있거나</text>
<text x="600" y="200" class="sv-label-sm"> <tspan font-weight="700">범위 조인(<, >, BETWEEN)</tspan>일 때</text>
<text x="600" y="228" class="sv-label-sm" fill="var(--warn)">❌ 부담: 정렬 비용이 크고</text>
<text x="600" y="248" class="sv-label-sm" fill="var(--warn)"> 메모리를 많이 쓴다</text>
<text x="82" y="292" class="sv-label-sm sv-mono">비용 ≈ N log N + M log M (정렬) + N + M (병합)</text>
</g>
<!-- 요약 -->
<g data-show="5-5" class="rise">
<rect x="60" y="112" width="250" height="196" rx="12" fill="color-mix(in srgb, var(--rdb) 10%, transparent)" stroke="var(--rdb-soft)" stroke-width="1.5"/>
<text x="185" y="146" text-anchor="middle" class="sv-label">🔁 Nested Loop</text>
<text x="185" y="180" text-anchor="middle" class="sv-label-sm">소량 × 인덱스</text>
<text x="185" y="206" text-anchor="middle" class="sv-label-sm sv-mono">OLTP 단건 조회</text>
<text x="185" y="240" text-anchor="middle" class="sv-label-sm" fill="var(--danger)">추정 실패에 취약</text>
<text x="185" y="276" text-anchor="middle" class="sv-label-sm sv-mono">N × log M</text>
<rect x="325" y="112" width="250" height="196" rx="12" fill="color-mix(in srgb, var(--ok) 10%, transparent)" stroke="var(--ok)" stroke-width="1.5"/>
<text x="450" y="146" text-anchor="middle" class="sv-label">🪣 Hash Join</text>
<text x="450" y="180" text-anchor="middle" class="sv-label-sm">대량 × 등가 조인</text>
<text x="450" y="206" text-anchor="middle" class="sv-label-sm sv-mono">배치·분석 쿼리</text>
<text x="450" y="240" text-anchor="middle" class="sv-label-sm" fill="var(--warn)">메모리에 의존</text>
<text x="450" y="276" text-anchor="middle" class="sv-label-sm sv-mono">N + M</text>
<rect x="590" y="112" width="250" height="196" rx="12" fill="color-mix(in srgb, var(--warn) 10%, transparent)" stroke="var(--warn)" stroke-width="1.5"/>
<text x="715" y="146" text-anchor="middle" class="sv-label">🤝 Sort Merge</text>
<text x="715" y="180" text-anchor="middle" class="sv-label-sm">정렬됨 · 범위 조인</text>
<text x="715" y="206" text-anchor="middle" class="sv-label-sm sv-mono">대량 + ORDER BY</text>
<text x="715" y="240" text-anchor="middle" class="sv-label-sm" fill="var(--warn)">정렬 비용이 관건</text>
<text x="715" y="276" text-anchor="middle" class="sv-label-sm sv-mono">N logN + M logM</text>
</g>
<text x="450" y="332" text-anchor="middle" class="sv-label-sm">옵티마이저는 이 셋의 <tspan font-weight="700">예상 비용을 계산해</tspan> 가장 싼 것을 고른다 — 그래서 통계가 틀리면 잘못 고른다</text>
</svg>
</div>
<div class="anim-caption-src" hidden>
<p><code>users</code>(100만 행)와 <code>orders</code>(1000만 행)를 조인합니다. <b>SQL 문장은 하나</b>지만, 옵티마이저가 고를 수 있는 실행 방법은 최소 세 가지입니다. 어느 것이 빠른지는 <b>상황에 따라 완전히 달라집니다.</b></p>
<p><b>Nested Loop.</b> 외부 테이블(드라이빙 집합)의 행을 하나씩 들고 가서, 내부 테이블에서 짝을 찾습니다. 외부가 12행이면 내부 탐색도 12번. 그래서 <b>외부 결과가 작고 내부 조인 키에 인덱스가 있을 때</b> 압도적으로 빠릅니다. 반대로 옵티마이저가 "12행이겠지" 했는데 실제로 120만 행이면 탐색이 120만 번 일어나 그대로 장애가 됩니다. <b>OLTP 단건 조회의 기본 전략</b>입니다.</p>
<p><b>Hash Join.</b> 작은 쪽 테이블로 <b>해시 테이블(사전)을 메모리에 만들고</b>(Build), 큰 쪽을 한 번 훑으면서 해시로 즉시 매칭합니다(Probe). 양쪽을 딱 한 번씩만 읽으므로 <b>대량 데이터 조인에 강하고, 인덱스가 없어도</b> 잘 작동합니다. 단 <code>=</code> 조인에만 쓸 수 있고, 해시 테이블이 메모리에 안 들어가면 디스크로 넘쳐(spill) 급격히 느려집니다. <b>배치·분석 쿼리의 주력</b>입니다.</p>
<p><b>Sort Merge Join.</b> 양쪽을 조인 키로 <b>정렬한 뒤</b> 지퍼를 채우듯 양쪽 포인터를 함께 전진시키며 맞춥니다. 정렬 비용이 크지만, 이미 인덱스 순서로 정렬돼 있거나 <code>ORDER BY</code>가 어차피 필요하다면 그 비용이 상쇄됩니다. Hash Join이 못 하는 <b>범위 조인(<code><</code>, <code>BETWEEN</code>)</b>도 처리할 수 있습니다.</p>
<p>정리하면 — <b>소량+인덱스면 Nested Loop, 대량 등가 조인이면 Hash, 정렬이 이미 있거나 범위 조인이면 Sort Merge</b>입니다. 중요한 건 이 선택을 <b>옵티마이저가 예상 비용으로 한다</b>는 점입니다. 통계가 낡아 행 수 추정이 틀리면 잘못된 알고리즘을 고르고, 그것이 <b>"어제까지 멀쩡하던 쿼리가 오늘 갑자기 느려진"</b> 사고의 가장 흔한 원인입니다. → <b>14장</b>에서 실제 실행계획으로 확인합니다.</p>
</div>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>알고리즘</th><th>동작</th><th>최적 조건</th><th>대략적 비용</th><th>주요 위험</th></tr></thead>
<tbody>
<tr>
<td><strong>Nested Loop</strong></td>
<td>외부 행마다 내부를 탐색</td>
<td>외부 결과 소량 + 내부 조인 키 인덱스</td>
<td><code>N × log M</code></td>
<td>외부 행 수 추정 실패 시 비용 폭발</td>
</tr>
<tr>
<td><strong>Hash Join</strong></td>
<td>작은 쪽 해시 빌드 → 큰 쪽 프로브</td>
<td>대량 <b>등가(=)</b> 조인, 인덱스 없어도 가능</td>
<td><code>N + M</code></td>
<td>메모리 부족 시 디스크 spill, 등가 조인 전용</td>
</tr>
<tr>
<td><strong>Sort Merge</strong></td>
<td>양쪽 정렬 후 병합</td>
<td>이미 정렬됨 · 범위 조인 · 대량 + <code>ORDER BY</code></td>
<td><code>N logN + M logM</code></td>
<td>정렬 비용·메모리 사용량이 큼</td>
</tr>
</tbody>
</table>
</div>
<h2 id="antipatterns">🚫 쓰면 안 되는 쿼리 — 안티패턴 카탈로그</h2>
<p>16장에서 각각을 실행계획과 함께 파헤치지만, 목록만으로도 당장 코드 리뷰에 쓸 수 있습니다. <b>빈도 × 파급력</b> 순으로 정렬했습니다.</p>
<div class="table-wrap">
<table>
<thead><tr><th>#</th><th>안티패턴</th><th>왜 문제인가</th><th>대안</th></tr></thead>
<tbody>
<tr><td>1</td><td><strong>N+1 쿼리</strong></td><td>목록 1번 + 건별 N번 → 네트워크 왕복이 지배적 비용이 됨</td><td>조인 / <code>IN</code> 일괄 조회 / ORM fetch join</td></tr>
<tr><td>2</td><td><code>OFFSET</code> <strong>딥 페이지네이션</strong></td><td><code>OFFSET 1000000</code>은 앞의 100만 행을 <b>읽고 버린다</b></td><td><b>키셋 페이지네이션</b> (<code>WHERE id < ? ORDER BY id DESC LIMIT n</code>)</td></tr>
<tr><td>3</td><td><strong>WHERE 컬럼을 함수로 감싸기</strong></td><td><code>WHERE DATE(created_at) = ?</code> → 인덱스 사용 불가</td><td>범위 조건으로 변환하거나 함수 기반 인덱스</td></tr>
<tr><td>4</td><td><strong>암묵적 형변환</strong></td><td>문자 컬럼에 숫자 비교 → 컬럼 측이 변환되어 인덱스 무력화</td><td>타입을 맞춰 바인딩 / 컬럼 타입 정정</td></tr>
<tr><td>5</td><td><code>SELECT *</code></td><td>불필요한 컬럼·TOAST/LOB까지 읽고, 커버링 인덱스를 놓침</td><td>필요한 컬럼만 명시</td></tr>
<tr><td>6</td><td><strong>선행 와일드카드</strong> <code>LIKE '%kw%'</code></td><td>B+Tree는 앞에서부터 비교 → 전체 스캔</td><td>전문 검색 인덱스 / 역순 컬럼 / 검색엔진 분리</td></tr>
<tr><td>7</td><td><strong>대량 UPDATE·DELETE 한 방</strong></td><td>긴 트랜잭션 → 락 장기 점유, 로그 폭증, 복제 지연</td><td>PK 범위로 <b>청킹</b> 후 반복 (배치 커밋)</td></tr>
<tr><td>8</td><td><strong>트랜잭션 안에서 외부 API 호출</strong></td><td>외부 지연이 그대로 락 점유 시간이 됨</td><td>트랜잭션 밖으로 분리 / 아웃박스 패턴</td></tr>
<tr><td>9</td><td><code>NOT IN</code> <strong>+ NULL</strong></td><td>목록에 NULL이 하나라도 있으면 <b>결과가 통째로 0행</b></td><td><code>NOT EXISTS</code> 또는 <code>LEFT JOIN … IS NULL</code></td></tr>
<tr><td>10</td><td><strong>중복을 <code>DISTINCT</code>로 덮기</strong></td><td>조인 카디널리티 오류를 감추고 정렬 비용까지 추가</td><td>조인 조건을 바로잡거나 <code>EXISTS</code>로 변경</td></tr>
<tr><td>11</td><td><strong>거대한 <code>IN</code> 리스트</strong></td><td>수천 개 바인딩 → 파싱 비용·플랜 캐시 오염</td><td>임시 테이블 조인 / 배치 분할</td></tr>
<tr><td>12</td><td><code>OR</code> <strong>조건 남발</strong></td><td>서로 다른 컬럼의 OR는 인덱스 결합이 어려움</td><td><code>UNION ALL</code> 분리 또는 복합 조건 재설계</td></tr>
<tr><td>13</td><td><strong>인덱스 과다 생성</strong></td><td>INSERT/UPDATE마다 모든 인덱스 갱신 → 쓰기 성능 붕괴</td><td>미사용 인덱스 제거, 복합 인덱스로 통합</td></tr>
<tr><td>14</td><td><strong>페이징 없는 전체 조회</strong></td><td>데이터 증가에 비례해 언젠가 반드시 터진다</td><td>필수 <code>LIMIT</code> + 커서 기반 순회</td></tr>
<tr><td>15</td><td><code>COUNT(*)</code> <strong>전체 건수 매 요청</strong></td><td>대형 테이블에서 매번 전체 스캔</td><td>근사치 통계 / 카운터 테이블 / "다음 페이지 유무"만 확인</td></tr>
<tr><td>16</td><td><strong>상관 서브쿼리 반복 실행</strong></td><td>외부 행마다 서브쿼리 재실행</td><td>조인으로 평탄화 또는 윈도우 함수</td></tr>
<tr><td>17</td><td><strong>커서 루프 (RBAR)</strong></td><td>행 단위 반복 처리 — 집합 연산의 장점을 버림</td><td>집합 기반 단일 문장으로 재작성</td></tr>
<tr><td>18</td><td><strong>읽고-계산하고-쓰기</strong></td><td>락 없이 하면 <b>Lost Update</b> (맨 위 애니메이션)</td><td>원자적 UPDATE / 낙관적·비관적 락</td></tr>
<tr><td>19</td><td><code>ORDER BY RAND()</code></td><td>전체 행에 난수를 매겨 정렬</td><td>PK 랜덤 샘플링 / 미리 계산된 랜덤 컬럼</td></tr>
<tr><td>20</td><td><strong>인덱스 없는 FK</strong></td><td>부모 삭제·갱신 시 자식 전체 스캔 + 락 확대</td><td>모든 FK 컬럼에 인덱스 생성</td></tr>
</tbody>
</table>
</div>
<p>가장 자주 보는 세 가지는 미리 코드로 봐 둡시다.</p>
<pre><code><span class="c">-- ❌ 1. 딥 페이지네이션 — 100만 행을 읽고 버린 뒤 10행을 준다</span>
<span class="k">SELECT</span> * <span class="k">FROM</span> orders <span class="k">ORDER BY</span> id <span class="k">DESC</span> <span class="k">LIMIT</span> <span class="n">10</span> <span class="k">OFFSET</span> <span class="n">1000000</span>;
<span class="c">-- ✅ 키셋(커서) 페이지네이션 — 마지막으로 본 id부터 10행만 읽는다</span>
<span class="k">SELECT</span> * <span class="k">FROM</span> orders
<span class="k">WHERE</span> id < <span class="n">8481920</span> <span class="c">-- 직전 페이지의 마지막 id</span>
<span class="k">ORDER BY</span> id <span class="k">DESC</span> <span class="k">LIMIT</span> <span class="n">10</span>;
<span class="c">-- ❌ 2. 컬럼을 함수로 감싸면 idx(created_at)를 쓸 수 없다</span>
<span class="k">SELECT</span> * <span class="k">FROM</span> orders <span class="k">WHERE</span> <span class="f">DATE</span>(created_at) = <span class="s">'2026-07-28'</span>;
<span class="c">-- ✅ 범위 조건으로 바꾸면 인덱스를 그대로 탄다</span>
<span class="k">SELECT</span> * <span class="k">FROM</span> orders
<span class="k">WHERE</span> created_at >= <span class="s">'2026-07-28'</span>
<span class="k">AND</span> created_at < <span class="s">'2026-07-29'</span>;
<span class="c">-- ❌ 3. NOT IN 목록에 NULL이 섞이면 결과가 통째로 0행이 된다</span>
<span class="k">SELECT</span> * <span class="k">FROM</span> users
<span class="k">WHERE</span> id <span class="k">NOT IN</span> (<span class="k">SELECT</span> user_id <span class="k">FROM</span> blacklist); <span class="c">-- user_id에 NULL 존재 시</span>
<span class="c">-- ✅ NOT EXISTS는 NULL에 안전하고 대개 더 빠르다</span>
<span class="k">SELECT</span> * <span class="k">FROM</span> users u
<span class="k">WHERE</span> <span class="k">NOT EXISTS</span> (
<span class="k">SELECT</span> <span class="n">1</span> <span class="k">FROM</span> blacklist b <span class="k">WHERE</span> b.user_id = u.id);
</code></pre>
<h2 id="tuning">📈 SQL 튜닝 방법론 — 순서가 8할</h2>
<p>기법을 아무리 많이 알아도 <b>순서가 틀리면</b> 시간만 씁니다. 위에서부터 내려오세요. 아래로 갈수록 비용과 리스크가 커집니다.</p>
<div class="table-wrap">
<table>
<thead><tr><th>단계</th><th>무엇을 하는가</th><th>먼저 확인할 것</th></tr></thead>
<tbody>
<tr><td><strong>0. 측정</strong></td><td>느린 쿼리를 <b>데이터로</b> 특정한다</td><td>slow query log, 누적 실행시간 상위 쿼리, 대기 이벤트</td></tr>
<tr><td><strong>1. 실행계획 확인</strong></td><td>추정 행 수와 실제 행 수의 괴리를 찾는다</td><td>통계가 최신인가? 이것만으로 해결되는 경우가 많다</td></tr>
<tr><td><strong>2. 쿼리 재작성</strong></td><td>불필요한 작업을 없앤다</td><td>안티패턴 표에 걸리는 게 있는가? 안 쓰는 컬럼·조인·정렬은?</td></tr>
<tr><td><strong>3. 인덱스</strong></td><td>접근 경로를 만든다</td><td>쓰기 비용은? 기존 인덱스로 커버되지 않는가?</td></tr>
<tr><td><strong>4. 스키마·모델링</strong></td><td>정규화 조정, 파티셔닝, 집계 테이블</td><td>영향 범위가 크다 — 앞 단계로 안 될 때만</td></tr>
<tr><td><strong>5. 파라미터·하드웨어</strong></td><td>메모리·병렬도·커넥션 풀 조정</td><td>대개 <b>마지막</b>. 쿼리 문제를 장비로 덮으면 비용만 는다</td></tr>
</tbody>
</table>
</div>
<div class="callout warn">
<span class="ico">📌</span>
<div>
<div class="callout-title">튜닝 전에 항상 묻는 세 가지</div>
<p><b>①</b> 이 쿼리가 정말 병목인가? (전체 응답시간에서 차지하는 비중) <b>②</b> 결과 행이 정말 다 필요한가? (페이징·집계로 줄일 수 있나) <b>③</b> 실시간이어야 하는가? (배치·캐시·비동기로 옮길 수 있나) — 셋 중 하나만 걸려도 SQL을 건드리지 않고 끝나는 경우가 많습니다.</p>
</div>
</div>
<h2 id="vendor">🏷 벤더 차이 매트릭스</h2>
<p>"공통 원리"를 배웠어도 실무에서는 제품 차이에 걸립니다. 자주 부딪히는 지점만 모았습니다.</p>
<div class="table-wrap">
<table>
<thead><tr><th>항목</th><th>PostgreSQL</th><th>MySQL (InnoDB)</th><th>Oracle</th><th>SQL Server</th></tr></thead>
<tbody>
<tr><td><strong>기본 격리 수준</strong></td><td>Read Committed</td><td><strong>Repeatable Read</strong></td><td>Read Committed</td><td>Read Committed (잠금 기반)</td></tr>
<tr><td><strong>동시성 제어</strong></td><td>MVCC (튜플 버전)</td><td>MVCC (Undo 로그)</td><td>MVCC (Undo 세그먼트)</td><td>기본 2PL, RCSI 옵션</td></tr>
<tr><td><strong>RR에서 Phantom</strong></td><td>차단 (스냅샷)</td><td>차단 (갭 락)</td><td>RR 미지원</td><td>RR에서 발생</td></tr>
<tr><td><strong>지원 격리 수준</strong></td><td>RC · RR · Serializable(SSI)</td><td>RU · RC · RR · Serializable</td><td><b>RC · Serializable</b>만</td><td>RU · RC · RR · Snapshot · Serializable</td></tr>
<tr><td><strong>읽기가 쓰기를 막나</strong></td><td>아니오</td><td>아니오</td><td>아니오</td><td><b>기본 설정에서는 막음</b></td></tr>
<tr><td><code>SKIP LOCKED</code></td><td>9.5+ 지원</td><td>8.0+ 지원</td><td>지원</td><td><code>READPAST</code> 힌트</td></tr>
<tr><td><strong>락 대기 타임아웃</strong></td><td><code>lock_timeout</code></td><td><code>innodb_lock_wait_timeout</code></td><td><code>FOR UPDATE WAIT n</code></td><td><code>SET LOCK_TIMEOUT</code></td></tr>
<tr><td><strong>실행계획 확인</strong></td><td><code>EXPLAIN (ANALYZE, BUFFERS)</code></td><td><code>EXPLAIN ANALYZE</code> (8.0.18+)</td><td><code>DBMS_XPLAN.DISPLAY_CURSOR</code></td><td>실제 실행 계획 (SSMS)</td></tr>
<tr><td><strong>정리/공간 회수</strong></td><td>VACUUM 필요</td><td>퍼지 스레드</td><td>Undo 자동 관리</td><td>고스트 정리</td></tr>
</tbody>
</table>
</div>
<h2 id="quiz-sec">✍️ 이해도 체크</h2>
<div class="quiz">
<div class="quiz-q">재고 차감 로직을 <code>BEGIN → SELECT stock → 앱에서 -1 계산 → UPDATE → COMMIT</code>으로 구현했고, DB는 기본 격리 수준(Read Committed)입니다. 동시 주문 시 재고가 잘못 차감되는 문제가 보고되었습니다. 원인으로 가장 정확한 것은?</div>
<div class="quiz-opts">
<button type="button">트랜잭션으로 감싸지 않아서 발생한 Dirty Read다</button>
<button type="button" data-correct>Read Committed는 Lost Update를 막지 않기 때문이다 — 두 세션이 같은 값을 읽고 서로의 갱신을 덮어썼다</button>
<button type="button">인덱스가 없어 UPDATE가 느려져 순서가 뒤바뀐 것이다</button>
<button type="button">격리 수준을 Read Uncommitted로 낮추면 해결된다</button>
</div>
<div class="quiz-expl">✅ 전형적인 <strong>Lost Update</strong>입니다. 트랜잭션으로 감쌌다는 사실 자체는 이 문제를 막아주지 않습니다 — Read Committed에서 두 세션은 모두 커밋된 값(10)을 정상적으로 읽었고, 각자 계산한 9를 쓴 것뿐이니 DB 입장에서는 아무 위반도 없습니다. 해결책은 세 가지입니다: <code>UPDATE … SET stock = stock - 1 WHERE stock >= 1</code>처럼 <strong>원자적 단일 문장</strong>으로 바꾸거나, <code>SELECT … FOR UPDATE</code>(비관적 락)로 읽는 순간부터 잠그거나, <code>version</code> 컬럼(낙관적 락)으로 커밋 시점에 충돌을 감지해 재시도하는 것입니다.</div>
</div>
<div class="quiz">
<div class="quiz-q">배치 집계 쿼리가 1000만 행 테이블 두 개를 <code>=</code> 조건으로 조인합니다. 조인 키에는 인덱스가 없습니다. 옵티마이저가 선택할 가능성이 가장 높고, 또 실제로 가장 적합한 알고리즘은?</div>
<div class="quiz-opts">
<button type="button">Nested Loop — 어떤 경우에도 가장 안전한 기본 선택이다</button>
<button type="button" data-correct>Hash Join — 양쪽을 한 번씩만 읽으면 되고 인덱스가 없어도 되는 대량 등가 조인의 주력이다</button>
<button type="button">Sort Merge Join — 인덱스가 없으면 정렬이 유일한 방법이다</button>
<button type="button">인덱스를 먼저 만들지 않으면 어떤 조인도 실행할 수 없다</button>
</div>
<div class="quiz-expl">✅ <strong>Hash Join</strong>입니다. 인덱스 없이 1000만 × 1000만을 Nested Loop로 처리하면 내부 테이블을 매번 전체 스캔하게 되어 사실상 끝나지 않습니다. Hash Join은 한쪽으로 해시 테이블을 만들고 다른 쪽을 한 번 훑는 <code>N + M</code> 비용이라 <strong>대량 등가 조인에 가장 적합</strong>합니다. Sort Merge도 가능하지만 양쪽 정렬 비용(<code>N logN + M logM</code>)이 추가되므로, 이미 정렬돼 있거나 범위 조인일 때가 아니면 보통 Hash에 밀립니다. 다만 Hash Join은 해시 테이블이 작업 메모리를 넘으면 디스크로 spill되어 급격히 느려지므로, 이 경우 <strong>작은 쪽이 build 대상으로 선택됐는지</strong> 실행계획에서 확인해야 합니다.</div>
</div>
<h2 id="paths">🧭 추천 학습 경로</h2>
<p>16장을 순서대로 읽는 게 기본이지만, 목적이 뚜렷하다면 아래처럼 골라 읽어도 됩니다.</p>
<div class="grid g2">
<div class="card">
<h4>🌱 백엔드 개발자 (2주)</h4>
<p><b>01 → 04 → 05 → 08 → 09 → 13 → 14 → 16</b></p>
<p>매일 쓰는 순서 그대로입니다. 실행 순서로 SQL의 오해를 걷어내고, 트랜잭션 경계와 락 두 종류를 잡은 뒤, 인덱스가 안 먹는 이유와 조인으로 마무리합니다.</p>
</div>
<div class="card">
<h4>🎤 면접·기술 리뷰 대비 (3일)</h4>
<p><b>05 → 06 → 08 → 09 → 14</b> + <a href="#lock-matrix">락 비교표</a> · <a href="#vendor">벤더 매트릭스</a></p>
<p>가장 자주 나오는 질문 셋 — "격리 수준 설명해 보세요", "낙관적 락과 비관적 락의 차이는?", "Nested Loop와 Hash Join은 언제 갈리나요?"에 집중합니다.</p>
</div>
<div class="card">
<h4>🚒 지금 장애 대응 중</h4>
<p><b>11 → 07 → 12</b> + <a href="#antipatterns">안티패턴 표</a></p>
<p>데드락·락 경합부터 보고, 실행계획으로 범인을 좁힙니다. 벤더별 진단 쿼리는 <a href="../pg/ch14.html">PG 14장</a>·<a href="../or/ch12.html">Oracle 12장</a>에 있습니다.</p>
</div>
<div class="card">
<h4>🌐 분산 시스템 설계</h4>
<p><b>04 → 05 → 06 → 09 → 10</b></p>
<p>트랜잭션 경계와 격리 수준을 확실히 한 뒤 분산 락으로 갑니다. 순서를 지키세요 — 10장만 먼저 읽으면 <b>안 써도 될 곳에 분산 락을 쓰게 됩니다.</b></p>
</div>
</div>
<div class="callout info">
<span class="ico">🚧</span>
<div>
<div class="callout-title">이 페이지는 커리큘럼 설계도입니다</div>
<p>16개 챕터는 순차적으로 집필됩니다. 각 챕터는 다른 가이드와 동일한 형식 — <b>단계별 애니메이션 + 두 세션 실습 SQL + 이해도 퀴즈</b> — 으로 만들어집니다. 집필 순서는 <b>T3 락(07~11) → T4 조인(12~15) → T2 트랜잭션(04~06) → T5 튜닝(16) → T1 기초(01~03)</b>로, 요청 빈도가 높은 주제부터 진행합니다.</p>
</div>
</div>
<div class="callout tip">
<span class="ico">🔗</span>
<div>
<div class="callout-title">지금 바로 이어서 볼 것</div>
<p>이 페이지의 주제를 특정 DB에서 확인하고 싶다면 — <a href="../pg/ch07.html">PostgreSQL 7장 · 트랜잭션과 격리 수준</a>(실제 <code>pg_locks</code> 진단), <a href="../pg/ch03.html">PG 3장 · MVCC</a>, <a href="../or/ch08.html">Oracle 8장 · 트랜잭션과 락</a>, <a href="../or/tuning.html">Oracle 쿼리 튜닝 팁</a>, <a href="../pg/ch06.html">PG 6장 · 플래너와 EXPLAIN</a>.</p>
</div>
</div>
</main>
</div>
<footer class="site-footer">
DB Study — RDB 공통 원리 커리큘럼<br>
락 · 트랜잭션 · 격리 수준 · 조인 알고리즘 · SQL 튜닝 — 벤더를 가리지 않는 기본기
</footer>
</body>
</html>