오늘은 데이터베이스의 중요한 개념인 정렬-병합 조인(Sort-Merge Join)에 대해 알아볼께요.
✨ 정렬-병합 조인 학습 계획 ✨
- 조인(Join)의 개념: 먼저 '조인'이 무엇인지 가볍게 알아보고, 왜 필요한지 이해해요.
- 정렬 단계 (Sort Phase): 이름 그대로, 첫 번째 단계인 '정렬'이 어떻게 작동하는지 배워봐요.
- 병합 단계 (Merge Phase): 두 번째 단계인 '병합'을 통해 어떻게 데이터가 합쳐지는지 살펴봐요
- 정렬-병합 조인이 장단점

1. 조인(Join)의 개념 🤝
혹시 두 개의 다른 목록에 있는 정보를 합쳐본 경험이 있나요? 예를 들어, 한 목록에는 '학생 이름'과 '좋아하는 과목'이 있고, 다른 목록에는 '학생 이름'과 '가입한 동아리'가 있다고 상상해 보세요.
이 두 목록을 합쳐서 '수학을 좋아하는 학생은 어떤 동아리에 가입했지?'라는 질문에 답하고 싶을 때가 있죠. 바로 이럴 때 조인(Join)을 사용해요!
조인(Join)이란, 이렇게 여러 테이블(목록)에 흩어져 있는 정보를 '학생 이름'처럼 공통된 값(키)을 기준으로 합쳐서, 마치 하나의 큰 표처럼 보여주는 기능이에요. 데이터를 효율적으로 관리하고, 더 의미 있는 정보를 찾아낼 수 있게 도와주는 아주 중요한 친구랍니다.
제가 지금부터 카페 사장님이 되었다고 상상해 보세요. 제게는 두 개의 메모지가 있어요.
📝 메모 1: 주문 목록
- 라이언: 아메리카노
- 어피치: 라떼
- 춘식이: 바닐라 라떼
📝 메모 2: 회원 정보
- 어피치: 1500점
- 라이언: 2000점
- 무지: 500점
퀴즈
이 두 메모지를 '조인'해서 '라떼를 주문한 손님의 멤버십 포인트'가 몇 점인지 알아내야 해요.
정답은 무엇일까요? 힌트는 두 메모지에 공통으로 들어있는 '이름'이랍니다!

정답 :
'라떼'를 주문한 손님은 '어피치'이고, 회원 정보에서 '어피치'의 포인트는 1500점이죠.
방금 두 목록의 공통된 정보인 '이름'을 기준으로 정보를 성공적으로 조인하신 거예요!
2. 정렬 단계 (Sort Phase) alphabetically
정렬-병합 조인은 이름처럼 '정렬'을 먼저 하고, 그 다음에 '병합'을 해요.
정렬 단계는 아주 간단해요. 우리가 방금 예시로 들었던 두 개의 메모지를 공통된 키(손님 이름)를 기준으로 가나다순으로 미리 정리해두는 거예요. 마치 책을 찾기 전에 도서관 서가를 장르별, 제목별로 정리하는 것과 같죠.
[정렬 전]
- 주문 목록: 라이언, 어피치, 춘식이
- 회원 정보: 어피치, 라이언, 무지
[정렬 후 (가나다순)]
- 주문 목록: 라이언, 어피치, 춘식이
- 회원 정보: 라이언, 무지, 어피치
이렇게 미리 정렬해두면, 다음 '병합' 단계에서 데이터를 훨씬 빠르고 효율적으로 찾을 수 있답니다. 마치 잘 정리된 사전에서 단어를 찾는 것처럼요!
그러면 왜 미리 정렬해두는걸까요?
미리 정렬해두는 이유는 '비교 횟수'를 획기적으로 줄여서 훨씬 빠르게 데이터를 합치기 위해서예요.
재미있는 비유를 하나 들어볼게요.
서로 다른 반 학생 200명이 섞여 있는 운동장에서 '이름이 같은 짝'을 찾는다고 상상해 보세요.
정렬을 안 하면? (비효율적 😵)
- A반의 첫 번째 학생(김민준)을 붙잡고, B반 학생 100명에게 일일이 "혹시 이름이 김민준이세요?"라고 물어봐야 해요.
- A반의 두 번째 학생(이서연)을 붙잡고, 또 B반 학생 100명에게 전부 물어봐야 하죠.
- 이 과정을 A반 학생 100명에 대해 모두 반복해야 해요. 엄청나게 오래 걸리겠죠?

미리 정렬하면? (효율적 😄)
- A반과 B반 학생들을 모두 이름 가나다순으로 한 줄로 세워요. (이게 '정렬' 단계!)
- 이제 두 줄의 맨 앞에 있는 학생부터 이름을 비교해요.
- 이름이 같으면? 짝을 찾았네요! 함께 내보내고 다음 학생들을 비교해요.
- 이름이 다르면? (예: A반은 '강하늘', B반은 '박지훈') 이름이 더 빠른 쪽('강하늘')은 짝이 없다는 뜻이니, 그 학생만 줄에서 내보내고 다음 학생을 데려와요.
정렬을 해두니 두 줄을 딱 한 번만 쓱 훑으면 모든 짝을 찾을 수 있죠. 컴퓨터도 똑같아요. 정렬을 통해 불필요한 비교를 없애고 아주 효율적으로 조인을 처리한답니다.

3. 병합 단계 (Merge Phase) 👫
이제 가나다순으로 정렬된 두 개의 목록이 준비되었어요. 병합 단계에서는 이 두 목록을 처음부터 동시에 훑어보면서 짝을 찾는 과정이에요. 마치 지퍼의 양쪽 날이 맞물리며 잠기는 모습과 비슷하죠.
다시 우리 카페 목록으로 돌아가 보죠.
[정렬된 목록]
- 주문 목록: 라이언, 어피치, 춘식이
- 회원 정보: 라이언, 무지, 어피치
[병합 과정]
- 첫 번째 위치 비교: 주문 목록의 '라이언'과 회원 정보의 '라이언'을 비교해요. 이름이 같죠? 짝을 찾았어요! 그럼 (라이언, 아메리카노, 2000점) 정보를 합치고, 두 목록 모두 다음 칸으로 이동해요.
- 주문 목록: 라이언 -> 어피치
- 회원 정보: 라이언 -> 무지
- 두 번째 위치 비교: 이제 주문 목록의 '어피치'와 회원 정보의 '무지'를 비교해요. 이름이 다르네요!
- 이때 '무지'가 '어피치'보다 가나다순으로 앞서죠? 그렇다는 건 '무지'는 주문 목록에 짝이 없다는 뜻이에요. 그래서 '무지'는 그냥 지나치고 회원 정보 목록만 다음 칸으로 이동해요.
- 주문 목록: 어피치 (그대로)
- 회원 정보: 무지 -> 어피치
- 세 번째 위치 비교: 주문 목록의 '어피치'와 회원 정보의 '어피치'를 비교해요. 이름이 같네요! 또 짝을 찾았어요! (어피치, 라떼, 1500점) 정보를 합치고, 두 목록 모두 다음 칸으로 이동해요.
이렇게 한쪽 목록이 끝날 때까지 반복하면 조인이 끝나요. 정렬이 되어 있으니, 두 목록을 딱 한 번씩만 훑으면 되어서 정말 빠르답니다!
4. 정렬-병합 조인의 장단점
1) 정렬-병합 조인의 장점 (👍 Pros)
- 대용량 데이터 처리에 강해요: 컴퓨터의 메모리(RAM)보다 훨씬 큰 초대용량의 데이터를 조인할 때 아주 효율적이에요. 정렬과 병합 과정을 나눠서 처리하기 때문에 메모리가 부족해도 안정적으로 작동하죠.
- 미리 정렬된 데이터에겐 최고: 만약 조인하려는 데이터가 이미 필요한 키를 기준으로 정렬되어 있다면, 비싼 '정렬' 단계를 건너뛸 수 있어서 엄청나게 빨라져요.
- 다양한 조인 조건에 사용 가능해요: 이름이 '같은' 경우(=)를 찾는 것 외에도, 'A의 점수가 B보다 큰 경우' (>)나 '작은 경우' (<)를 찾는 '비등가 조인(Non-equi join)'에도 사용할 수 있다는 큰 장점이 있어요. (다른 조인 방법 중에는 이것이 안 되는 경우가 많답니다!)
2) 정렬-병합 조인의 단점 (👎 Cons)
- 정렬 비용이 비쌀 수 있어요: 가장 큰 단점이에요. 만약 데이터가 전혀 정렬되어 있지 않다면, 조인을 하기 전에 전체 데이터를 정렬하는 데 시간과 자원이 많이 소요돼요. 배보다 배꼽이 더 커질 수도 있죠.
- 작은 데이터에는 비효율적: 데이터 양이 적어서 메모리에 전부 올라갈 수 있을 정도라면, 굳이 정렬을 하는 것보다 다른 조인 방식(예: 해시 조인)이 훨씬 더 빠를 수 있어요.
- 전체 데이터를 먼저 읽어야 해요: 정렬을 하려면 일단 전체 데이터를 한 번 다 훑어야 해요. 그래서 조인 결과를 빨리 몇 개만 보고 싶을 때는 불리할 수 있어요.
한마디로 요약하자면, 정렬-병합 조인은 데이터가 아주 크거나 이미 정렬되어 있을 때 빛을 발하는 강력한 도구예요. 하지만 데이터가 작거나 정렬 비용이 너무 클 때는 다른 방법을 고려하는 것이 좋답니다!
3) 정렬-병합 조인의 특징
- 첫번째 특징은 전체 범위의 데이터를 처리하는 데 매우 효과적이라는 것입니다. : 정렬-병합 조인은 처음부터 모든 데이터를 처리하는 것을 전제로 설계되었기 때문이에요.
- 정렬 단계: 조인을 시작하기 위해 두 테이블의 데이터 전체를 읽어서 정렬해야만 합니다. 이 과정에서 이미 모든 데이터를 한 번씩 훑어보게 되죠.
- 병합 단계: 정렬된 두 목록을 처음부터 끝까지 단 한 번만 훑으면서 모든 일치하는 짝을 찾아냅니다. 중간에 멈추거나 일부만 처리하는 방식이 아니에요.
이러한 "일단 모두 준비하고(Sort), 한 번에 끝내는(Merge)" 방식은 특정 몇 개의 결과만 빨리 찾아야 하는 작업(예: 웹사이트에서 상위 5개 게시물 보여주기)에는 불리할 수 있지만, 보고서 작성, 데이터 분석, 대용량 데이터 이전 등 전체 데이터에 대한 결과가 필요한 배치(Batch) 작업에서는 아주 강력하고 안정적인 성능을 보여줍니다.
- 두번째 특징은 동등 조건(equi-join)에 아주 적합하고 효율적이라는 것입니다. 사실상 정렬-병합 조인이 가장 기본적으로 잘하는 분야가 바로 동등 조건 조인입니다.
동등 조건 조인은 두 테이블에서 조인하는 컬럼의 값이 정확히 같은 (=) 경우를 찾는 것을 의미합니다.
정렬-병합 조인의 핵심인 병합(Merge) 단계를 떠올려 보세요. 정렬된 두 목록을 나란히 놓고 포인터를 하나씩 이동하면서 두 값이 같은지 다른지를 비교합니다.
- 값이 같으면 → 조인 성공! (이것이 바로 동등 조건)
- 값이 다르면 → 둘 중 더 작은 값을 가진 쪽의 포인터를 이동
이처럼 알고리즘 자체가 '두 값이 같은가?'를 반복적으로 확인하도록 설계되었기 때문에, A.id = B.id와 같은 동등 조건은 정렬-병합 조인에게 가장 이상적인 작업 환경이라고 할 수 있습니다.
마지막으로 정렬병합조인이 진행되는 과정을 보여드릴께요.
테이블의 조인과정을 따라가다보면 쉽게 이해할 수 있을거에요.
Sort-Merge Join Simulation
Let's simulate how Sort-Merge Join works step-by-step.
1. Initial Unsorted Data
Table A
IDNameValue| 3 | Alice | 100 |
| 1 | Charlie | 300 |
| 2 | Bob | 200 |
| 5 | Eve | 500 |
Table B
IDLocationCategory| 2 | New York | Books |
| 1 | London | Electronics |
| 4 | Paris | Food |
| 3 | Tokyo | Clothing |
2. Sort Phase: Sorting Tables by Join Key
Both tables are sorted based on their join key (ID in this example).
Sorted Table A (by ID)
IDNameValue| 1 | Charlie | 300 |
| 2 | Bob | 200 |
| 3 | Alice | 100 |
| 5 | Eve | 500 |
Sorted Table B (by ID)
IDLocationCategory| 1 | London | Electronics |
| 2 | New York | Books |
| 3 | Tokyo | Clothing |
| 4 | Paris | Food |
Table A Pointer
IDNameValue| 1 | Charlie | 300 |
| 2 | Bob | 200 |
| 3 | Alice | 100 |
| 5 | Eve | 500 |
Table B Pointer
IDLocationCategory| 1 | London | Electronics |
| 2 | New York | Books |
| 3 | Tokyo | Clothing |
| 4 | Paris | Food |
✓ Match found! ID: 1. Appending to result.
Table A Pointer
IDNameValue| 1 | Charlie | 300 |
| 2 | Bob | 200 |
| 3 | Alice | 100 |
| 5 | Eve | 500 |
Table B Pointer
IDLocationCategory| 1 | London | Electronics |
| 2 | New York | Books |
| 3 | Tokyo | Clothing |
| 4 | Paris | Food |
✓ Match found! ID: 2. Appending to result.
Table A Pointer
IDNameValue| 1 | Charlie | 300 |
| 2 | Bob | 200 |
| 3 | Alice | 100 |
| 5 | Eve | 500 |
Table B Pointer
IDLocationCategory| 1 | London | Electronics |
| 2 | New York | Books |
| 3 | Tokyo | Clothing |
| 4 | Paris | Food |
✓ Match found! ID: 3. Appending to result.
Table A Pointer
IDNameValue| 1 | Charlie | 300 |
| 2 | Bob | 200 |
| 3 | Alice | 100 |
| 5 | Eve | 500 |
Table B Pointer
IDLocationCategory| 1 | London | Electronics |
| 2 | New York | Books |
| 3 | Tokyo | Clothing |
| 4 | Paris | Food |
→ Key 4 (Table B) < Key 5 (Table A). Moving Table B pointer.
3. Merge Phase: Finding Matches
Now, we iterate through both sorted tables simultaneously to find matching IDs.
4. Final Joined Result
IDNameValueLocationCategory| 1 | Charlie | 300 | London | Electronics |
| 2 | Bob | 200 | New York | Books |
| 3 | Alice | 100 | Tokyo | Clothing |
import time
from IPython.display import display, HTML
def sort_merge_join_simulation(table_a, table_b, join_key_index_a, join_key_index_b):
"""
Simulates the Sort-Merge Join process with textual output.
Args:
table_a (list of lists): First table (list of rows).
table_b (list of lists): Second table (list of rows).
join_key_index_a (int): Index of the join key column in table_a.
join_key_index_b (int): Index of the join key column in table_b.
"""
output = []
output.append("<h2>Sort-Merge Join Simulation</h2>")
output.append("<p>Let's simulate how Sort-Merge Join works step-by-step.</p>")
# --- Initial State ---
output.append("<h3>1. Initial Unsorted Data</h3>")
output.append("<div style='display: flex; justify-content: space-around;'>")
output.append("<div style='border: 1px solid #ccc; padding: 10px; margin: 5px; width: 45%;'>")
output.append("<h4>Table A</h4>")
output.append("<table><tr><th>ID</th><th>Name</th><th>Value</th></tr>")
for row in table_a:
output.append(f"<tr><td>{row[0]}</td><td>{row[1]}</td><td>{row[2]}</td></tr>")
output.append("</table></div>")
output.append("<div style='border: 1px solid #ccc; padding: 10px; margin: 5px; width: 45%;'>")
output.append("<h4>Table B</h4>")
output.append("<table><tr><th>ID</th><th>Location</th><th>Category</th></tr>")
for row in table_b:
output.append(f"<tr><td>{row[0]}</td><td>{row[1]}</td><td>{row[2]}</td></tr>")
output.append("</table></div>")
output.append("</div>")
display(HTML("".join(output)))
time.sleep(1)
output.clear()
# --- Sort Phase ---
output.append("<h3>2. Sort Phase: Sorting Tables by Join Key</h3>")
output.append("<p>Both tables are sorted based on their join key (ID in this example).</p>")
# Sort tables
sorted_table_a = sorted(table_a, key=lambda x: x[join_key_index_a])
sorted_table_b = sorted(table_b, key=lambda x: x[join_key_index_b])
output.append("<div style='display: flex; justify-content: space-around;'>")
output.append("<div style='border: 1px solid #4CAF50; padding: 10px; margin: 5px; width: 45%;'>")
output.append("<h4>Sorted Table A (by ID)</h4>")
output.append("<table><tr><th>ID</th><th>Name</th><th>Value</th></tr>")
for row in sorted_table_a:
output.append(f"<tr><td>{row[0]}</td><td>{row[1]}</td><td>{row[2]}</td></tr>")
output.append("</table></div>")
output.append("<div style='border: 1px solid #4CAF50; padding: 10px; margin: 5px; width: 45%;'>")
output.append("<h4>Sorted Table B (by ID)</h4>")
output.append("<table><tr><th>ID</th><th>Location</th><th>Category</th></tr>")
for row in sorted_table_b:
output.append(f"<tr><td>{row[0]}</td><td>{row[1]}</td><td>{row[2]}</td></tr>")
output.append("</table></div>")
output.append("</div>")
display(HTML("".join(output)))
time.sleep(2)
output.clear()
# --- Merge Phase ---
output.append("<h3>3. Merge Phase: Finding Matches</h3>")
output.append("<p>Now, we iterate through both sorted tables simultaneously to find matching IDs.</p>")
joined_result = []
ptr_a, ptr_b = 0, 0
# Live visualization of pointers
merge_output_steps = []
while ptr_a < len(sorted_table_a) and ptr_b < len(sorted_table_b):
key_a = sorted_table_a[ptr_a][join_key_index_a]
key_b = sorted_table_b[ptr_b][join_key_index_b]
current_step_output = []
current_step_output.append("<div style='display: flex; justify-content: space-around; margin-top: 15px;'>")
# Display Table A with pointer
current_step_output.append("<div style='border: 1px solid #FFC107; padding: 10px; margin: 5px; width: 45%;'>")
current_step_output.append("<h4>Table A Pointer</h4><table><tr><th>ID</th><th>Name</th><th>Value</th></tr>")
for i, row in enumerate(sorted_table_a):
style = "background-color: #FFFFCC;" if i == ptr_a else ""
current_step_output.append(f"<tr style='{style}'><td>{row[0]}</td><td>{row[1]}</td><td>{row[2]}</td></tr>")
current_step_output.append("</table></div>")
# Display Table B with pointer
current_step_output.append("<div style='border: 1px solid #FFC107; padding: 10px; margin: 5px; width: 45%;'>")
current_step_output.append("<h4>Table B Pointer</h4><table><tr><th>ID</th><th>Location</th><th>Category</th></tr>")
for i, row in enumerate(sorted_table_b):
style = "background-color: #FFFFCC;" if i == ptr_b else ""
current_step_output.append(f"<tr style='{style}'><td>{row[0]}</td><td>{row[1]}</td><td>{row[2]}</td></tr>")
current_step_output.append("</table></div>")
current_step_output.append("</div>")
if key_a == key_b:
# Match found
joined_row = sorted_table_a[ptr_a] + sorted_table_b[ptr_b][1:] # Exclude key_b from table_b to avoid duplication
joined_result.append(joined_row)
current_step_output.append(f"<p style='color: green; font-weight: bold;'>✓ Match found! ID: {key_a}. Appending to result.</p>")
ptr_a += 1
ptr_b += 1
elif key_a < key_b:
# Table A's key is smaller, move A's pointer
current_step_output.append(f"<p style='color: orange;'>→ Key {key_a} (Table A) < Key {key_b} (Table B). Moving Table A pointer.</p>")
ptr_a += 1
else: # key_a > key_b
# Table B's key is smaller, move B's pointer
current_step_output.append(f"<p style='color: orange;'>→ Key {key_b} (Table B) < Key {key_a} (Table A). Moving Table B pointer.</p>")
ptr_b += 1
merge_output_steps.append("".join(current_step_output))
for step in merge_output_steps:
display(HTML(step))
time.sleep(1) # Pause for better visualization
output.append("<h3>4. Final Joined Result</h3>")
if joined_result:
output.append("<table border='1'><tr><th>ID</th><th>Name</th><th>Value</th><th>Location</th><th>Category</th></tr>")
for row in joined_result:
output.append(f"<tr><td>{row[0]}</td><td>{row[1]}</td><td>{row[2]}</td><td>{row[3]}</td><td>{row[4]}</td></tr>")
output.append("</table>")
else:
output.append("<p>No matching records found.</p>")
display(HTML("".join(output)))
# --- Example Usage ---
# Define sample data for two tables
# Each inner list represents a row, the first element is the ID (join key)
table1_data = [
[3, "Alice", 100],
[1, "Charlie", 300],
[2, "Bob", 200],
[5, "Eve", 500]
]
table2_data = [
[2, "New York", "Books"],
[1, "London", "Electronics"],
[4, "Paris", "Food"],
[3, "Tokyo", "Clothing"]
]
# Run the simulation
sort_merge_join_simulation(table1_data, table2_data, join_key_index_a=0, join_key_index_b=0)
코드 설명 및 사용 방법:
- sort_merge_join_simulation 함수:
- 두 개의 테이블(table_a, table_b), 그리고 각 테이블에서 조인 키로 사용할 컬럼의 인덱스(join_key_index_a, join_key_index_b)를 인자로 받습니다.
- HTML을 사용하여 구글 코랩 셀에 단계별로 시각적인 출력을 생성합니다. display(HTML(...)) 함수를 사용하여 렌더링하고, time.sleep()을 사용하여 각 단계 사이에 잠시 멈춰서 시각적인 흐름을 이해하기 쉽게 합니다.
- 1. Initial Unsorted Data:
- 처음 입력된 정렬되지 않은 두 테이블의 상태를 보여줍니다.
- 2. Sort Phase: Sorting Tables by Join Key:
- 각 테이블이 조인 키(여기서는 'ID')를 기준으로 정렬되는 과정을 시뮬레이션하고, 정렬된 테이블을 보여줍니다. 파이썬의 sorted() 함수를 사용합니다.
- 3. Merge Phase: Finding Matches:
- 가장 핵심적인 단계입니다. ptr_a와 ptr_b라는 두 개의 포인터가 각 정렬된 테이블의 시작점을 가리키는 것을 시각적으로 표현합니다.
- 포인터가 가리키는 ID 값을 비교하여:
- 같으면: 매칭된 것으로 간주하고 결과를 추가한 뒤, 두 포인터 모두 다음 행으로 이동합니다.
- 다르면: 더 작은 값을 가진 테이블의 포인터만 다음 행으로 이동시킵니다.
- 각 스텝마다 현재 포인터 위치와 비교 결과를 출력하여 동적인 흐름을 보여줍니다.
- 4. Final Joined Result:
- 병합 단계를 통해 최종적으로 조인된 결과 테이블을 출력합니다.
코랩에서 실행하는 방법:
- 구글 코랩 노트북을 엽니다.
- 위의 파이썬 코드를 셀에 붙여넣습니다.
- 셀을 실행합니다 (Shift + Enter).
코드가 단계별로 출력되면서 정렬-병합 조인의 동작 방식을 시각적으로 확인할 수 있을 것입니다!
'DB' 카테고리의 다른 글
| [함수종속] 암스트롱 추론 규칙(Armstrong's Axioms) (0) | 2026.02.08 |
|---|---|
| [Two-way Join] 해시조인 (Hash Join) (0) | 2025.09.14 |
| [Two-way Join] 중첩루프조인 (Nested Loop Join) (0) | 2025.09.13 |
| [데이터마이닝] 베이즈 정리 (Bayes Theorem) (3) | 2025.08.30 |
| [빅데이터] HDFS (Hadoop Distributed File System) (3) | 2025.08.30 |