[1] 드라이빙 테이블(Driving Table) ?[1.1] 조인 수행 시 첫 번째로 접근하는 테이블[1.2] 쿼리 실행 계획에서 가장 먼저 처리되는 테이블[1.3] 다른 테이블과의 조인을 위한 기준이 되는 테이블[2] 드라이빙 테이블을 써야 하는 이유[3] 실제로 사용할 때
[1] 드라이빙 테이블(Driving Table) ?
일반적으로 드라이빙 테이블(Driving Table)이란, JOIN 시 먼저 액세스 돼서 탐색과정을 주도하는 테이블을 의미합니다.
- 드라이빙 테이블(Driving Table) : 먼저 액세스 되는 테이블
- 조인 수행 시 첫 번째로 접근하는 테이블
- 쿼리 실행 계획에서 가장 먼저 처리되는 테이블
- 다른 테이블과의 조인을 위한 기준이 되는 테이블
- 드리븐 테이블(Driven Table) : 나중에 액세스 되는 테이블
PK나, FK 키 등 인덱스의 존재 유무나 액세스 과정에서, 탐색 성능은 어떤 순서로 탐색을 수행하는지에 따라 달라집니다. 물론 현대의 DBMS는 옵티마이저가 잘 발달되어 있어, 실제로는 개발자가 작성한 쿼리 순서와 관계없이 최적의 실행 계획을 선택하는 경우가 많습니다.
옵티마이저(Optimizer) ?
DBMS의 옵티마이저(Optimizer)는 SQL 쿼리를 실행할 때 가장 효율적인 실행 계획을 수립하는 기능입니다.
- 기본 역할
- SQL 쿼리를 분석하여 최적의 실행 계획 생성
- 다양한 실행 방법들 중 비용이 최소화되는 방법 선택
- 데이터 접근 경로 결정
- 최적화 고려사항
- 테이블의 통계 정보 (레코드 수, 칼럼 분포도 등)
- 사용 가능한 인덱스 정보
- 시스템 자원 (CPU, 메모리, I/O) 상태
- 조인 순서와 조인 방식
- 옵티마이저 종류
- 비용 기반 옵티마이저(CBO, Cost-Based Optimizer)
- 통계 정보를 기반으로 비용을 계산
- 현대 DBMS에서 주로 사용
- 규칙 기반 옵티마이저(RBO, Rule-Based Optimizer)
- 미리 정의된 규칙에 따라 실행 계획 수립
- 과거에 사용되던 방식
- 주요 최적화 기법
- 조인 순서 최적화
- 인덱스 선택
- 실행 계획 재사용
- 서브쿼리 최적화
- 뷰 머징
[1.1] 조인 수행 시 첫 번째로 접근하는 테이블
SELECT u.name, o.order_date, oi.quantity FROM users u JOIN orders o ON u.id = o.user_id JOIN order_items oi ON o.id = oi.order_id WHERE u.status = 'ACTIVE';
실행순서
1. users 테이블을 먼저 접근
2. users의 각 레코드에 대해 orders 조회
3. orders의 각 레코드에 대해 order_items 조회
에서 첫 번째로 접근한 테이블 order를 의미합니다.
첫 번째로 조회한 users 테이블의 레코드 수가 전체 쿼리의 성능을 결정짓습니다.
user → order → order_items 순으로, 첫 번째 users 테이블에서 걸러진 데이터 셋 A로 orders를 조회하고 orders를 통해 걸러진 데이터 셋 B를 통해 C를 조회하게 됩니다.
[1.2] 쿼리 실행 계획에서 가장 먼저 처리되는 테이블
EXPLAIN SELECT u.name, o.total_amount FROM users u JOIN orders o ON u.id = o.user_id // orders 테이블의 user_id FK 인덱스 사용 WHERE u.status = 'ACTIVE'; // users 테이블의 status 인덱스 사용
실행순서
1. users 테이블에서 ACTIVE 상태 필터링
2. 필터링된 user_id로 orders 테이블 조회
첫 번째 users에서 ACTIVE로 필터링된 데이터셋 A로 B를 조회하게 됩니다.
SELECT u.name, o.total_amount FROM users u JOIN orders o ON u.id = o.user_id WHERE CASE WHEN u.status = 'ACTIVE' THEN 1 ELSE 0 END = 1;
만약 위 처럼 전체 조인 후 필터링을 해버리면, 불필요하게 큰 조인을 연산하게 되고 CPU나 메모리를 낭비하게 됩니다.
[1.3] 다른 테이블과의 조인을 위한 기준이 되는 테이블
SELECT u.name, COUNT(o.id) as order_count, SUM(oi.quantity) as total_items, MAX(o.order_date) as last_order_date FROM users u // 드라이빙 테이블 LEFT JOIN orders o // 첫 번째 조인 ON u.id = o.user_id LEFT JOIN order_items oi // 두 번째 조인 ON o.id = oi.order_id WHERE u.created_at >= '2024-01-01' GROUP BY u.id, u.name;
실행순서
1. 기준 테이블(users)의 레코드를 토대로 조인됨
2. 다른 테이블들은 기준 테이블에 맞춰 조인
3. LEFT JOIN의 경우 기준 테이블 데이터 누락 없음
위 코드 처럼, LEFT JOIN을 사용하여 users의 데이터를 보존하고 다은 테이블 또한 users의 기준에 맞게 조인이됩니다.
[2] 드라이빙 테이블을 써야 하는 이유
그렇다면 왜 드라이빙 테이블에 대한 고민을 해야 하는 걸까요?
극단적인 예시를 통해 설명드리겠습니다.
- users: 1,000명
- orders: 500,000건 주문
- order_items: 2,000,000건 주문상세
- products: 1,000개 상품
- categories: 10개 카테고리
위와 같은 데이터셋이 있다고 할 때
드라이빙 테이블에 대한 기준과 원칙을 지켜 특정 기간의 VIP 회원의 카테고리별 주문 통계 쿼리를 구성한다고 하면
SELECT c.category_name, COUNT(DISTINCT o.id) as order_count, SUM(oi.quantity * oi.price) as total_amount FROM users u -- 1,000명 JOIN user_grade ug -- 1,000명 ON u.id = ug.user_id AND ug.grade = 'VIP' -- 100명으로 감소 JOIN orders o -- 50,000건 (100명의 주문) ON u.id = o.user_id AND o.order_date >= '2024-01-01' -- 5,000건으로 감소 JOIN order_items oi -- 20,000건 ON o.id = oi.order_id JOIN products p -- 1,000개 상품 ON oi.product_id = p.id JOIN categories c -- 10개 카테고리 ON p.category_id = c.id WHERE u.status = 'ACTIVE' -- 80명으로 추가 감소 GROUP BY c.id, c.category_name;
실행순서
1. users + user_grade 조인 (1,000건)
2. VIP 필터링 (100명으로 감소)
3. ACTIVE 상태 필터링 (80명으로 감소)
4. 해당 유저들의 주문 조회 (5,000건)
5. 주문상세, 상품, 카테고리 조인
위처럼 구성함으로서 특정 기간의 데이터에 대한 탐색을 최적화할 수 있지만, 만약 극악의 효율로 조회하는 경우
SELECT c.category_name, COUNT(DISTINCT o.id) as order_count, SUM(oi.quantity * oi.price) as total_amount FROM order_items oi -- 2,000,000건 시작 JOIN products p -- 2,000,000건 조인 ON oi.product_id = p.id JOIN categories c -- 2,000,000건 조인 ON p.category_id = c.id JOIN orders o -- 2,000,000건 조인 ON oi.order_id = o.id JOIN users u -- 2,000,000건 조인 ON o.user_id = u.id JOIN user_grade ug -- 2,000,000건 조인 ON u.id = ug.user_id WHERE ug.grade = 'VIP' AND u.status = 'ACTIVE' AND o.order_date >= '2024-01-01' GROUP BY c.id, c.category_name;
실행순서
1. order_items 전체 테이블 스캔 (2,000,000건)
2. 2,000,000건에 대해 products 조인
3. 2,000,000건에 대해 categories 조인
4. 2,000,000건에 대해 orders 조인
5. 2,000,000건에 대해 users 조인
6. 2,000,000건에 대해 user_grade 조인
7. 그 다음에서야 WHERE 절 동작 시작
위 처럼 극악의 탐색으로 인해 메모리와 디스크 I/O, CPU 사용량에서도 극악의 효율이 발생하며 실행시간 또한 차이가 크게 발생함으로서 유저 경험을 저하시킬 수 있습니다.
[3] 실제로 사용할 때
실제로는 아래와 같은 개념만 머리속에 상기시키면 됩니다.
- 적은 데이터셋부터 조회 시작
// best choice users (100건) -- 드라이빙 테이블 orders(10,000건) ... -- 드리븐 테이블 // worst choice orders o (10,000건) -- 드라이빙 테이블 users u (100건) ... -- 드리븐 테이블
- 필터링 후 조인
// best choice (Active 유저만 먼저 필터링) SELECT u.name, o.order_date FROM users u WHERE u.status = 'ACTIVE' -- 100건 -> 50건으로 필터링 JOIN orders o -- 50건에 대해서만 조인 ON u.id = o.user_id; // worst choice SELECT u.name, o.order_date FROM users u JOIN orders o ON u.id = o.user_id WHERE u.status = 'ACTIVE'; -- 10,000건을 모두 조인한 후 필터링
- 기준 테이블은 LEFT JOIN
// best choice (유저 정보가 모두 필요할 때) SELECT u.name, COUNT(o.id) as order_count FROM users u -- 기준 테이블 LEFT JOIN orders o -- 주문이 없는 유저도 포함 ON u.id = o.user_id GROUP BY u.id, u.name;
Share article