DB

[Two-way Join] 정렬병합조인 (Sort-Merge Join)

타우루스 2025. 9. 14. 14:33

오늘은 데이터베이스의 중요한 개념인 정렬-병합 조인(Sort-Merge Join)에 대해 알아볼께요.

 

✨ 정렬-병합 조인 학습 계획 ✨

  1. 조인(Join)의 개념: 먼저 '조인'이 무엇인지 가볍게 알아보고, 왜 필요한지 이해해요.
  2. 정렬 단계 (Sort Phase): 이름 그대로, 첫 번째 단계인 '정렬'이 어떻게 작동하는지 배워봐요.
  3. 병합 단계 (Merge Phase): 두 번째 단계인 '병합'을 통해 어떻게 데이터가 합쳐지는지 살펴봐요
  4. 정렬-병합 조인이 장단점

 

1. 조인(Join)의 개념 🤝

 

혹시 두 개의 다른 목록에 있는 정보를 합쳐본 경험이 있나요? 예를 들어, 한 목록에는 '학생 이름''좋아하는 과목'이 있고, 다른 목록에는 '학생 이름'과 '가입한 동아리'가 있다고 상상해 보세요.

 

이 두 목록을 합쳐서 '수학을 좋아하는 학생은 어떤 동아리에 가입했지?'라는 질문에 답하고 싶을 때가 있죠. 바로 이럴 때 조인(Join)을 사용해요!

 

조인(Join)이란, 이렇게 여러 테이블(목록)에 흩어져 있는 정보를 '학생 이름'처럼 공통된 값(키)을 기준으로 합쳐서, 마치 하나의 큰 표처럼 보여주는 기능이에요. 데이터를 효율적으로 관리하고, 더 의미 있는 정보를 찾아낼 수 있게 도와주는 아주 중요한 친구랍니다.

 

제가 지금부터 카페 사장님이 되었다고 상상해 보세요. 제게는 두 개의 메모지가 있어요.

 

📝 메모 1: 주문 목록

  • 라이언: 아메리카노
  • 어피치: 라떼
  • 춘식이: 바닐라 라떼

📝 메모 2: 회원 정보

  • 어피치: 1500점
  • 라이언: 2000점
  • 무지: 500점

퀴즈

이 두 메모지를 '조인'해서 '라떼를 주문한 손님의 멤버십 포인트'가 몇 점인지 알아내야 해요.

정답은 무엇일까요? 힌트는 두 메모지에 공통으로 들어있는 '이름'이랍니다!

더보기

정답 : 

'라떼'를 주문한 손님은 '어피치'이고, 회원 정보에서 '어피치'의 포인트는 1500점이죠.

방금 두 목록의 공통된 정보인 '이름'을 기준으로 정보를 성공적으로 조인하신 거예요!

 

2. 정렬 단계 (Sort Phase) alphabetically

정렬-병합 조인은 이름처럼 '정렬'을 먼저 하고, 그 다음에 '병합'을 해요.

 

정렬 단계는 아주 간단해요. 우리가 방금 예시로 들었던 두 개의 메모지를 공통된 키(손님 이름)를 기준으로 가나다순으로 미리 정리해두는 거예요. 마치 책을 찾기 전에 도서관 서가를 장르별, 제목별로 정리하는 것과 같죠.

 

[정렬 전]

  • 주문 목록: 라이언, 어피치, 춘식이
  • 회원 정보: 어피치, 라이언, 무지

[정렬 후 (가나다순)]

  • 주문 목록: 라이언, 어피치, 춘식이
  • 회원 정보: 라이언, 무지, 어피치

이렇게 미리 정렬해두면, 다음 '병합' 단계에서 데이터를 훨씬 빠르고 효율적으로 찾을 수 있답니다. 마치 잘 정리된 사전에서 단어를 찾는 것처럼요!

 

그러면 왜 미리 정렬해두는걸까요?

미리 정렬해두는 이유는 '비교 횟수'를 획기적으로 줄여서 훨씬 빠르게 데이터를 합치기 위해서예요.

 

재미있는 비유를 하나 들어볼게요.

 

서로 다른 반 학생 200명이 섞여 있는 운동장에서 '이름이 같은 짝'을 찾는다고 상상해 보세요.

 

정렬을 안 하면? (비효율적 😵)

  1. A반의 첫 번째 학생(김민준)을 붙잡고, B반 학생 100명에게 일일이 "혹시 이름이 김민준이세요?"라고 물어봐야 해요.
  2. A반의 두 번째 학생(이서연)을 붙잡고, 또 B반 학생 100명에게 전부 물어봐야 하죠.
  3. 이 과정을 A반 학생 100명에 대해 모두 반복해야 해요. 엄청나게 오래 걸리겠죠?

미리 정렬하면? (효율적 😄)

  1. A반과 B반 학생들을 모두 이름 가나다순으로 한 줄로 세워요. (이게 '정렬' 단계!)
  2. 이제 두 줄의 맨 앞에 있는 학생부터 이름을 비교해요.
    • 이름이 같으면? 짝을 찾았네요! 함께 내보내고 다음 학생들을 비교해요.
    • 이름이 다르면? (예: A반은 '강하늘', B반은 '박지훈') 이름이 더 빠른 쪽('강하늘')은 짝이 없다는 뜻이니, 그 학생만 줄에서 내보내고 다음 학생을 데려와요.

정렬을 해두니 두 줄을 딱 한 번만 쓱 훑으면 모든 짝을 찾을 수 있죠. 컴퓨터도 똑같아요. 정렬을 통해 불필요한 비교를 없애고 아주 효율적으로 조인을 처리한답니다.

 

3. 병합 단계 (Merge Phase) 👫

이제 가나다순으로 정렬된 두 개의 목록이 준비되었어요. 병합 단계에서는 이 두 목록을 처음부터 동시에 훑어보면서 짝을 찾는 과정이에요. 마치 지퍼의 양쪽 날이 맞물리며 잠기는 모습과 비슷하죠.

 

다시 우리 카페 목록으로 돌아가 보죠.

 

[정렬된 목록]

  • 주문 목록: 라이언, 어피치, 춘식이
  • 회원 정보: 라이언, 무지, 어피치

[병합 과정]

  1. 첫 번째 위치 비교: 주문 목록의 '라이언'과 회원 정보의 '라이언'을 비교해요. 이름이 같죠? 짝을 찾았어요! 그럼 (라이언, 아메리카노, 2000점) 정보를 합치고, 두 목록 모두 다음 칸으로 이동해요.
    • 주문 목록: 라이언 -> 어피치
    • 회원 정보: 라이언 -> 무지
  2. 두 번째 위치 비교: 이제 주문 목록의 '어피치'와 회원 정보의 '무지'를 비교해요. 이름이 다르네요!
    • 이때 '무지'가 '어피치'보다 가나다순으로 앞서죠? 그렇다는 건 '무지'는 주문 목록에 짝이 없다는 뜻이에요. 그래서 '무지'는 그냥 지나치고 회원 정보 목록만 다음 칸으로 이동해요.
    • 주문 목록: 어피치 (그대로)
    • 회원 정보: 무지 -> 어피치
  3. 세 번째 위치 비교: 주문 목록의 '어피치'와 회원 정보의 '어피치'를 비교해요. 이름이 같네요! 또 짝을 찾았어요! (어피치, 라떼, 1500점) 정보를 합치고, 두 목록 모두 다음 칸으로 이동해요.

이렇게 한쪽 목록이 끝날 때까지 반복하면 조인이 끝나요. 정렬이 되어 있으니, 두 목록을 딱 한 번씩만 훑으면 되어서 정말 빠르답니다!

 

4. 정렬-병합 조인의 장단점

1) 정렬-병합 조인의 장점 (👍 Pros)

  • 대용량 데이터 처리에 강해요: 컴퓨터의 메모리(RAM)보다 훨씬 큰 초대용량의 데이터를 조인할 때 아주 효율적이에요. 정렬과 병합 과정을 나눠서 처리하기 때문에 메모리가 부족해도 안정적으로 작동하죠.
  • 미리 정렬된 데이터에겐 최고: 만약 조인하려는 데이터가 이미 필요한 키를 기준으로 정렬되어 있다면, 비싼 '정렬' 단계를 건너뛸 수 있어서 엄청나게 빨라져요.
  • 다양한 조인 조건에 사용 가능해요: 이름이 '같은' 경우(=)를 찾는 것 외에도, 'A의 점수가 B보다 큰 경우' (>)나 '작은 경우' (<)를 찾는 '비등가 조인(Non-equi join)'에도 사용할 수 있다는 큰 장점이 있어요. (다른 조인 방법 중에는 이것이 안 되는 경우가 많답니다!)

2) 정렬-병합 조인의 단점 (👎 Cons)

  • 정렬 비용이 비쌀 수 있어요: 가장 큰 단점이에요. 만약 데이터가 전혀 정렬되어 있지 않다면, 조인을 하기 전에 전체 데이터를 정렬하는 데 시간과 자원이 많이 소요돼요. 배보다 배꼽이 더 커질 수도 있죠.
  • 작은 데이터에는 비효율적: 데이터 양이 적어서 메모리에 전부 올라갈 수 있을 정도라면, 굳이 정렬을 하는 것보다 다른 조인 방식(예: 해시 조인)이 훨씬 더 빠를 수 있어요.
  • 전체 데이터를 먼저 읽어야 해요: 정렬을 하려면 일단 전체 데이터를 한 번 다 훑어야 해요. 그래서 조인 결과를 빨리 몇 개만 보고 싶을 때는 불리할 수 있어요.

한마디로 요약하자면, 정렬-병합 조인은 데이터가 아주 크거나 이미 정렬되어 있을 때 빛을 발하는 강력한 도구예요. 하지만 데이터가 작거나 정렬 비용이 너무 클 때는 다른 방법을 고려하는 것이 좋답니다!

3) 정렬-병합 조인의 특징

  • 첫번째 특징은 전체 범위의 데이터를 처리하는 데 매우 효과적이라는 것입니다. : 정렬-병합 조인은 처음부터 모든 데이터를 처리하는 것을 전제로 설계되었기 때문이에요.
  1. 정렬 단계: 조인을 시작하기 위해 두 테이블의 데이터 전체를 읽어서 정렬해야만 합니다. 이 과정에서 이미 모든 데이터를 한 번씩 훑어보게 되죠.
  2. 병합 단계: 정렬된 두 목록을 처음부터 끝까지 단 한 번만 훑으면서 모든 일치하는 짝을 찾아냅니다. 중간에 멈추거나 일부만 처리하는 방식이 아니에요.

이러한 "일단 모두 준비하고(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;'>&#10003; 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;'>&#8594; 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;'>&#8594; 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)

 

코드 설명 및 사용 방법:

  1. sort_merge_join_simulation 함수:
    • 두 개의 테이블(table_a, table_b), 그리고 각 테이블에서 조인 키로 사용할 컬럼의 인덱스(join_key_index_a, join_key_index_b)를 인자로 받습니다.
    • HTML을 사용하여 구글 코랩 셀에 단계별로 시각적인 출력을 생성합니다. display(HTML(...)) 함수를 사용하여 렌더링하고, time.sleep()을 사용하여 각 단계 사이에 잠시 멈춰서 시각적인 흐름을 이해하기 쉽게 합니다.
  2. 1. Initial Unsorted Data:
    • 처음 입력된 정렬되지 않은 두 테이블의 상태를 보여줍니다.
  3. 2. Sort Phase: Sorting Tables by Join Key:
    • 각 테이블이 조인 키(여기서는 'ID')를 기준으로 정렬되는 과정을 시뮬레이션하고, 정렬된 테이블을 보여줍니다. 파이썬의 sorted() 함수를 사용합니다.
  4. 3. Merge Phase: Finding Matches:
    • 가장 핵심적인 단계입니다. ptr_a와 ptr_b라는 두 개의 포인터가 각 정렬된 테이블의 시작점을 가리키는 것을 시각적으로 표현합니다.
    • 포인터가 가리키는 ID 값을 비교하여:
      • 같으면: 매칭된 것으로 간주하고 결과를 추가한 뒤, 두 포인터 모두 다음 행으로 이동합니다.
      • 다르면: 더 작은 값을 가진 테이블의 포인터만 다음 행으로 이동시킵니다.
    • 각 스텝마다 현재 포인터 위치와 비교 결과를 출력하여 동적인 흐름을 보여줍니다.
  5. 4. Final Joined Result:
    • 병합 단계를 통해 최종적으로 조인된 결과 테이블을 출력합니다.

코랩에서 실행하는 방법:

  1. 구글 코랩 노트북을 엽니다.
  2. 위의 파이썬 코드를 셀에 붙여넣습니다.
  3. 셀을 실행합니다 (Shift + Enter).

코드가 단계별로 출력되면서 정렬-병합 조인의 동작 방식을 시각적으로 확인할 수 있을 것입니다!