해시 조인(Hash Join)
- 해시 조인(Hash Join)은 두 개의 테이블을 조인할 때, 더 작은 테이블을 메모리에 올리고 해시 테이블을 만들어 효율적으로 조인하는 방식
- 대용량 테이블을 조인할 때 주로 사용되며, 조인 조건에 인덱스가 없는 경우에도 빠르게 작동
해시 조인의 원리
- 해시 조인은 크게 두 단계로 나뉩니다.
- 빌드 단계 (Build Phase):
- 옵티마이저는 두 테이블 중 더 작은 테이블(빌드 테이블)을 선택
- 이 테이블의 조인 키(컬럼)를 사용하여 메모리 기반 해시 테이블을 생성. 각 조인 키 값은 해시 함수의 입력으로 사용되어 해시 테이블의 특정 위치에 저장.
- 프로브 단계 (Probe Phase):
- 더 큰 테이블(프로브 테이블)을 순차적으로 읽어옮
- 프로브 테이블의 각 행에서 조인 키를 추출하여, 빌드 단계에서 사용한 동일한 해시 함수로 해시 값을 계산
- 계산된 해시 값을 이용해 빌드 테이블의 해시 테이블에서 일치하는 행을 찾음.
- 일치하는 행을 찾으면, 두 테이블의 행을 결합하여 결과를 반환.
- 만약 빌드 테이블이 메모리에 다 들어가지 않을 정도로 크다면, 테이블을 여러 개의 파티션으로 나누어 디스크에 임시로 저장한 뒤, 각 파티션별로 빌드와 프로브 단계를 반복하는 '그레이스풀 해시 조인(Graceful Hash Join)'이 수행
파이썬 예제
- 두 개의 CSV 파일 sales.csv와 products.csv가 있다고 가정하고, 이를 파이썬 딕셔너리로 해시 조인을 구현하는 예제.
1. 데이터 준비 (sales.csv, products.csv 파일)
1) 대량 테이블 - 판매(sales.csv 파일)
sale_id,product_id,amount 1,101,100 2,102,150 3,101,200 4,103,50 .... ....
2) 제품 (products.csv)
product_id,product_name 101,Laptop 102,Mouse 103,Keyboard
2. 해시 조인 구현 코드
소량인 products 테이블을 빌드 테이블로, sales 테이블을 프로브 테이블로 사용하여 조인을 수행.
import csv # 1. 빌드 테이블: products.csv를 읽어 메모리 해시 테이블(딕셔너리) 생성 # 'product_id'를 키로, 'product_name'을 값으로 저장 products_hash_table = {} with open('products.csv', 'r') as f: reader = csv.DictReader(f) for row in reader: # 해시 테이블 생성 (딕셔너리) products_hash_table[row['product_id']] = row['product_name'] print("--- 빌드 단계 (해시 테이블) ---") print(products_hash_table) # 출력: {'101': 'Laptop', '102': 'Mouse', '103': 'Keyboard'} print("\n--- 프로브 단계 (조인 결과) ---") # 2. 프로브 테이블: sales.csv를 읽어 해시 테이블에서 찾기 with open('sales.csv', 'r') as f: reader = csv.DictReader(f) for row in reader: # sale_id 3의 'product_id'인 '101'을 해시 테이블에서 찾음 product_id = row['product_id'] if product_id in products_hash_table: # 해시 테이블에서 'product_id'를 키로 검색하여 'product_name'을 가져옴 product_name = products_hash_table[product_id] print(f"Sale ID: {row['sale_id']}, Product Name: {product_name}, Amount: {row['amount']}")
- 예상 출력:
# Sale ID: 1, Product Name: Laptop, Amount: 100 # Sale ID: 2, Product Name: Mouse, Amount: 150 # Sale ID: 3, Product Name: Laptop, Amount: 200 # Sale ID: 4, Product Name: Keyboard, Amount: 50
- products_hash_table이라는 딕셔너리가 바로 해시 테이블 역할을 합니다.
- sales 테이블의 각 행을 읽을 때마다 product_id를 딕셔너리(해시 테이블)에서 빠르게 찾아 product_name을 가져와 조인된 결과를 출력하는 방식
- 이를 통해 전체 products 테이블을 다시 스캔할 필요 없이 O(1)에 가까운 시간 복잡도로 조인 조건을 만족하는 데이터를 찾을 수 있음.
HASH 조인의 성능 향상 원리
Hash 버킷의 역할과 기능
- 해시 버킷은 두 테이블을 효율적으로 조인하기 위한 핵심적인 논리적 저장 공간.
- 이는 조인 키(Join Key)를 기반으로 데이터를 빠르게 찾고 저장하는 데 사용.
역할: 데이터의 논리적 분류 및 저장소
- 해시 버킷은 조인 키 값을 해시 함수에 넣어 얻은 해시 값이 같은 데이터들을 모아두는 공간.
- 이는 거대한 데이터를 조인 키를 기준으로 더 작은 그룹으로 나누어 관리하는 역할을 합니다.
- 이를 통해 빌드 테이블의 모든 데이터를 일일이 탐색하지 않고도, 특정 조인 키에 해당하는 데이터가 어느 버킷에 있는지 바로 알 수 있습니다.
기능: Build 단계와 Probe 단계에서의 활용
- Build 단계:
- Oracle은 먼저 두 테이블 중 더 작은 테이블을 선택하여 빌드 테이블로 지정합니다.
- 빌드 테이블의 각 행을 읽어 조인 키에 해시 함수를 적용하여 해시 값을 계산합니다.
- 계산된 해시 값에 해당하는 버킷에 해당 행의 데이터를 저장합니다. 만약 같은 버킷에 이미 다른 데이터가 있다면, 이를 연결 리스트(Linked List) 형태로 연결하여 **해시 충돌(Hash Collision)**을 처리합니다.
- Probe 단계:
- 이제 더 큰 테이블인 프로브 테이블을 순차적으로 읽습니다.
- 프로브 테이블의 각 행에서 조인 키를 추출하여, 빌드 단계에서 사용한 동일한 해시 함수로 해시 값을 계산합니다.
- 계산된 해시 값을 이용해 빌드 테이블의 해시 버킷 위치를 즉시 찾아갑니다.
- 해당 버킷 안의 연결 리스트를 탐색하여 조인 키가 일치하는 행을 찾습니다.
- 일치하는 행이 발견되면 두 테이블의 데이터를 결합하여 최종 결과를 생성합니다.
- 이러한 해시 버킷의 활용 덕분에, 해시 조인은 테이블의 크기와 상관없이 조인 키가 같은 데이터들을 O(1)에 가까운 시간 복잡도로 찾아낼 수 있어 대용량 데이터 조인에 매우 효과적입니다.
HASH 조인이 느려지는 원인
- 해시 조인 성능이 느려지는 주요 원인 중 하나는 해시 버킷 때문입니다.
- 정확히 말하면, 해시 충돌이 심하게 발생하거나 해시 테이블이 메모리에 맞지 않아 디스크 I/O가 발생하는 경우입니다.
- 해시 버킷 자체는 효율성을 위한 도구지만, 비정상적인 상황에서는 오히려 성능 저하의 원인이 됩니다.
1. 해시 충돌 (Hash Collisions)
- 원리: 해시 함수는 조인 키가 다르더라도 동일한 해시 값을 반환할 수 있습니다. 이것이 해시 충돌입니다.
- 문제점: 해시 충돌이 발생하면, 하나의 버킷에 여러 행이 연결 리스트 형태로 쌓이게 됩니다. 이 버킷을 탐색할 때, 원하는 데이터를 찾기 위해 연결된 모든 데이터를 순차적으로 비교해야 하므로, 탐색 시간이 O(1)에서 O(N)으로 늘어납니다.
- 결과: 조인 키 데이터 분포가 불균형하거나(데이터 편중), 해시 함수가 비효율적이면 해시 충돌이 자주 발생하여 성능이 크게 저하됩니다.
- (결론) 해시조인은 조인키로 사용되는 컬럼이 중복이 많을수록 성능이 좋지 않음
- 파이썬으로 해시 충돌 구현 예제
- 고의로 해시 충돌을 유발하는 데이터를 사용하여 해시 조인의 성능 차이를 보여줍니다.
- products_collision 리스트의 모든 product_id는 해시 함수를 거치면 같은 값을 반환하도록 설계되었습니다.
import time # 일반적인 데이터 (해시 충돌이 거의 없음) products_normal = [ {'product_id': 101, 'product_name': 'Laptop'}, {'product_id': 102, 'product_name': 'Mouse'}, {'product_id': 103, 'product_name': 'Keyboard'}, ] # 의도적으로 해시 충돌을 유발하는 데이터 # 101, 201, 301 모두 100으로 나눈 나머지가 1이 되도록 설계 products_collision = [ {'product_id': 101, 'product_name': 'Laptop'}, {'product_id': 201, 'product_name': 'Mouse'}, {'product_id': 301, 'product_name': 'Keyboard'}, ] # 조인 대상 데이터 (각 product_id에 대한 sales 데이터) sales = [{'sale_id': i, 'product_id': 101, 'amount': 100} for i in range(1000)] + \ [{'sale_id': i+1000, 'product_id': 201, 'amount': 150} for i in range(1000)] + \ [{'sale_id': i+2000, 'product_id': 301, 'amount': 200} for i in range(1000)] # 사용자 정의 해시 함수 (단순화를 위해) def custom_hash(key): return key % 100 def hash_join(build_table, probe_table): # 빌드 단계: 해시 버킷(딕셔너리) 생성 # 파이썬 딕셔너리의 키 충돌을 구현하기 위해, 딕셔너리의 값을 리스트로 저장 hash_buckets = {} for item in build_table: key = item['product_id'] bucket_index = custom_hash(key) if bucket_index not in hash_buckets: hash_buckets[bucket_index] = [] hash_buckets[bucket_index].append(item) # 프로브 단계: 조인 start_time = time.time() joined_results = [] for sale in probe_table: key = sale['product_id'] bucket_index = custom_hash(key) # 해시 버킷에서 일치하는 데이터를 찾기 (충돌 발생 시 순차 탐색) if bucket_index in hash_buckets: for product_info in hash_buckets[bucket_index]: if product_info['product_id'] == key: joined_results.append({**sale, **product_info}) break end_time = time.time() return end_time - start_time # 일반적인 데이터로 조인 normal_time = hash_join(products_normal, sales) # 해시 충돌 데이터로 조인 collision_time = hash_join(products_collision, sales) print(f"일반 데이터 조인 시간: {normal_time:.6f} 초") print(f"해시 충돌 조인 시간: {collision_time:.6f} 초") print(f"성능 저하 비율: {(collision_time / normal_time):.2f}배 느림")
2. 메모리 부족 및 디스크 I/O
- 원리: 해시 조인은 더 작은 테이블(빌드 테이블)을 메모리에 올려 해시 테이블을 만드는 것이 기본 전제입니다.
- 문제점: 빌드 테이블이 너무 커서 **메모리(PGA)**에 다 담을 수 없게 되면, Oracle은 해시 테이블을 여러 파티션으로 나누어 일부를 **임시 테이블스페이스(Temporary Tablespace)**에 저장합니다.
- 결과: 조인을 수행하기 위해 디스크에 기록된 파티션 데이터를 읽고 쓰는 디스크 I/O 작업이 반복적으로 발생합니다. 이는 메모리에서만 작업할 때보다 훨씬 느리므로 성능 저하의 결정적인 원인이 됩니다.
결론
- 해시 조인이 효율적으로 작동하는 것은 해시 버킷이 잘 분산되어 있고, 해시 테이블이 메모리에서 모두 처리될 때입니다. 만약 데이터가 한쪽으로 치우쳐 해시 충돌이 심하거나, 메모리 부족으로 디스크 I/O가 발생하면 해시 조인의 장점이 사라져 성능이 느려지게 됩니다.