MySQL의 대부분의 조인은 NestedLoop Join입니다.
하나의 주문은 여러 개의 상품을 가질 수 있다.
하나의 주문상품은 반드시 하나의 주문을 가질 수 있다.
하나의 상품은 여러 개의 상품카테고리를 가질 수 있다.
하나의 상품카테고리는 하나의 상품을 포함할 수 있다.
하나의 결재는 여러 개의 결재이력을 가질 수 있다.
하나의 결재이력은 하나의 결재를 포함할 수 있다.
* M:N의 관계는 중간에 테이블을 하나 더 생성해서 모든 테이블이 1:N 관계로 가져갈 수 있도록 합니다.
예시 1)
예시 2)
예시 3)
👉 예시 3과 같이 한 쪽의 테이블 내용만 필요한 데 GROUP BY, DISTINCT를 수행하는 것은 비효율적입니다. 이러한 쿼리는 튜닝이 필요합니다.
INNER JOIN이란 조인 대상 테이블 사이의 칼럼 값 Equal(=) 비교를 통해 같은 값을 가진 행끼리 연결하는 것입니다.
FROM A, B WHERE A.Key = B.Key
(가장 많이 사용하는 형식)FROM A INNER JOIN B ON B.Key = A.Key
(ANSI 표준)FROM A NATURAL JOIN B
→ 동일한 이름을 가지는 칼럼은 모두 조인SELECT * FROM ordr a NATURAL JOIN ordr_prod b
FROM A INNER JOIN B USING (A.Key)
→ 지정된 칼럼과 동일한 이름의 칼럼 조인SELECT * FROM ordr a INNER JOIN ordr_prod b USING(ordr_no)
ANSI 표준을 사용하면 다른 DBMS로 이관하여도 바꿔야하는 것이 별로 없다는 장점이 있습니다.
만약 오라클을 사용하고 있다면 DBMS를 이관할 가능성이 크므로 ANSI 표준을 사용하는 것이 좋습니다.
explain
을 사용하여 실행계획을 확인할 수 있습니다.
1:1 관계라면 eq_ref, 1:N 관계이고 1이 driving이면 ref, 1:N 관계이고 N이 driving이면 eq_ref로 실행계획에 나타납니다.
mysql> explain SELECT * FROM ordr a , ordr_prod b where a.ordr_no =b.ordr_no;
+----+-------------+-------+------+---------------+---------+---------+---------------+------+-------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+-------+------+---------------+---------+---------+---------------+------+-------+
| 1 | SIMPLE | a | ALL | PRIMARY | NULL | NULL | NULL | 14 | NULL |
| 1 | SIMPLE | b | ref | PRIMARY | PRIMARY | 8 | edu.a.ordr_no | 1 | NULL |
+----+-------------+-------+------+---------------+---------+---------+---------------+------+-------+
mysql> explain SELECT * FROM ordr a , ordr b where a.ordr_no =b.ordr_no;
+----+-------------+-------+--------+---------------+---------+---------+---------------+------+-------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+-------+--------+---------------+---------+---------+---------------+------+-------+
| 1 | SIMPLE | a | ALL | PRIMARY | NULL | NULL | NULL | 14 | NULL |
| 1 | SIMPLE | b | eq_ref | PRIMARY | PRIMARY | 8 | edu.a.ordr_no | 1 | NULL |
+----+-------------+-------+--------+---------------+---------+---------+---------------+------+-------+
INNER JOIN 쿼리의 결과로는 1:N 관계에서 [=]조건을 만족하는 N의 건수가 최종 건수( N만큼 1의 데이터가 복제 )가 됩니다.
예시 1)
예시 2)
예시 3)
👉 예시 2와 같이 한 쪽의 테이블 내용만 필요한 데 GROUP BY, DISTINCT를 수행하는 것은 비효율적입니다. 이러한 쿼리는 튜닝이 필요합니다.
조인 시 한 쪽(또는 양쪽) 테이블에서 조인 조건에 맞지 않은 Row도 결과에 포함하기 위해 사용하는 JOIN입니다.
ex) 상품정보를 기준으로 주문된 상품을 조회한다. 이때, 팔리지 않은 것도 추출한다.
FROM A LEFT (OUTER) JOIN B ON A.Key = B.Key
SELECT * FROM prod a LEFT OUTER JOIN ordr_prod b ON a.prod_cd = b.prod_cd;
FROM A RIGHT (OUTER) JOIN B ON A.Key = B.Key
SELECT * FROM prod a RIGHT OUTER JOIN ordr_prod b ON a.prod_cd = b.prod_cd;
편의성을 위해 RIGHT OUTER JOIN은 잘 사용하지 않고 LEFT OUTER JOIN을 사용합니다.
(고정되는 쪽이 왼쪽으로 오도록 작성합니다.)
explain
을 사용하여 실행계획을 확인할 수 있습니다.
1:1 관계라면 eq_ref, 1:N 관계이고 1이 driving이면 ref, 1:N 관계이고 N이 driving이면 eq_ref로 실행계획에 나타납니다.
mysql> explain SELECT * FROM prod a left outer JOIN ordr_prod b ON a.prod_cd = b.prod_cd;
+----+-------------+-------+------+----------------+----------------+---------+---------------+------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+-------+------+----------------+----------------+---------+---------------+------+-------------+
| 1 | SIMPLE | a | ALL | NULL | NULL | NULL | NULL | 7 | NULL |
| 1 | SIMPLE | b | ref | idx2_ordr_prod | idx2_ordr_prod | 32 | edu.a.prod_cd | 2 | Using where |
+----+-------------+-------+------+----------------+----------------+---------+---------------+------+-------------+
mysql> explain SELECT * FROM ordr_prod a left outer JOIN prod b ON a.prod_cd = b.prod_cd;
+----+-------------+-------+--------+---------------+---------+---------+---------------+------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+-------+--------+---------------+---------+---------+---------------+------+-------------+
| 1 | SIMPLE | b | ALL | NULL | NULL | NULL | NULL | 22 | NULL |
| 1 | SIMPLE | a | eq_ref | PRIMARY | PRIMARY | 62 | edu.b.prod_cd | 1 | Using where |
+----+-------------+-------+--------+---------------+---------+---------+---------------+------+-------------+
1:N 관계의 LEFT OUTER JOIN 쿼리의 결과로는 1:N 관계( 1 left N )에서 [=]조건을 만족하는 N의 건수 + [=]조건을 만족하지 않는 1의 건수 (INNER JOIN 건수 + N) 가 최종 건수가 됩니다.
이때, [=]조건을 만족하는 N의 건수만큼 1의 데이터복제가 일어나게 됩니다.
N:1 관계의 LEFT OUTER JOIN 쿼리의 결과로는 [=]조건과 관계없이 언제나 N건이 최종 건수가 됩니다.
Q. 2016년 이후 입사한 사원이 있는 부서리스트를 조회하라.
👉
SELECT distinct a.dept_nm
FROM dept a, emp b
WHERE a.dept_cd = b.dept_cd AND b.enter_ymd >= '2016.01.01';
쿼리 계획 조회 시 distinct나 group by 사용 시 tmp table을 만드는 과정은 불필요한 과정인 것을 알 수 있습니다.
위의 문제에서는 한 부서에 해당되는 사원이 1명이 있건 2명이 있건 관계 없습니다.
이를 위해 Outer Join 대신 Semi Join을 사용할 수 있습니다.
Semi Join은 조인되는 행의 개수와 상관없이 Outer 테이블의 결과를 추출하는 Join 방법입니다.
잡초뽑기 기법을 사용한다면 옵티마이저가 임의의 테이블을 만들어서 dept_cd를 pk로 지정하고 조인의 결과를 insert시키게 됩니다.
이때 중복된 결과는 dup으로 결과에 들어가지 않게 됩니다.
SEMI JOIN은 서브 쿼리와 메인 쿼리와의 연결 처리를 위한 유사 조인 방식입니다.
WHERE절에 IN(sub-query) 또는 EXISTS(correlated sub-query)를 사용합니다.
❗️1:N의 관계에서 1의 테이블 결과만 조회하는 경우, group by가 있는데 집계함수(sum 등)가 없는 경우라면 SEMI JOIN의 사용을 고려하도록 합니다.
이러한 SEMI JOIN으로의 변경은 인덱스 다음으로 가장 중요한 튜닝 방법입니다.
SELECT *
FROM prod a
WHERE a.prod_cd IN (SELECT b.prod_cd FROM ordr_prod b WHERE ordr_prod_cnt > 3);
SELECT *
FROM dept a
WHERE a.dept_cd IN (SELECT b.dept_cd FROM emp b where );
SELECT *
FROM prod a
WHERE EXISTS (SELECT 1 FROM ordr_prod b
WHERE a.prod_cd = b.prod_cd AND b.ordr_prod_cnt >3);
ANTI JOIN은 SEMI JOIN의 부정형입니다. WHERE절에 NOT IN, NOT EXISTS를 사용합니다.
SELECT a.prod_cd, a.prod_nm
FROM prod a
WHERE a.prod_cd NOT IN (SELECT b.prod_cd FROM ordr_prod b
WHERE b.ordr_prod_cnt > 3);
SELECT a.prod_cd, a.prod_nm
FROM prod a
WHERE NOT EXISTS (SELECT 1 FROM ordr_prod b
WHERE a.prod_cd = b.prod_cd AND b.ordr_prod_cnt > 3);
SELECT a.prod_cd, a.prod_nm
FROM prod a LEFT OUTER JOIN ordr_prod b
ON a.prod_cd = b.prod_cd and b.ordr_prod_cnt > 3
WHERE b.prod_cd IS NULL;
SELECT * FROM 부서 a, 사원 b WHERE a.부서코드 = b.부서코드
인덱스가 없다고 가정하고 위의 쿼리를 수행하게 된다면 부서 테이블이 먼저 driving됩니다.
부서의 첫 row인 아나운서팀의 부서코드(10004)를 가져온 후,
사원 테이블을 full scan하여 10004에 해당하는 값을 가져오게 됩니다.
이때 10004에 해당하는 사원의 정보가 여러개라면 부서 정보를 중복시켜줍니다.
아나운서팀에 대한 작업이 끝나면 두 번째로 배우팀의 부서코드(10002)를 가져오고,
사원 테이블을 full scan하여 10002에 해당하는 모든 값을 가져와 부서 정보를 중복시켜줍니다.
이러한 과정을 아역배우팀까지 반복합니다.
NESTED LOOP 방식은 이렇게 선행테이블을 SCAN한 후에 후행테이블의 내용을 가져오게 됩니다.
❗️ 이때 선행테이블의 처리 범위에 따라 전체 쿼리 비용이 결정되므로, 선행테이블을 무엇으로 결정하는지가 중요합니다.
👉 따라서 NESTED LOOP 방식은 주로 좁은 범위 처리에 유리합니다.
SELECT * FROM 부서 a, 사원 b WHERE a.부서코드 = b.부서코드
인덱스가 없다고 가정하고 위의 쿼리를 수행하게 된다면,
부서 테이블을 부서코드 기준으로 정렬하고 사원 테이블도 부서코드 기준으로 정렬하게 됩니다.
그 후 부서 테이블의 row 하나를 들고 sort된 사원 테이블에 해당되는 부분만 가져오고 이후는 확인하지 않습니다.
모든 부서 테이블의 row에 대해 앞의 과정을 반복하게 됩니다.
이러한 SORT MERGE 방식은 정렬 후 머지만 진행하면 됩니다.
테이블이 매우 큰데 정렬을 위한 메모리가 적다면 효율이 낮아지게 됩니다.
SORT MERGE 방식은 메모리 공간이 클수록 좋으며, sort 부하가 발생합니다.
SELECT * FROM 부서 a, 사원 b WHERE a.부서코드 = b.부서코드
인덱스가 없다고 가정하고 위의 쿼리를 수행하게 된다면, mysql 내부에서 hash function이 하나 만들어집니다.
ex) h(x)=mod(부서코드)
그 후 1:N의 1에 해당하는 테이블에 hash function을 적용하여 hash table을 생성합니다.
이후 N 테이블의 값을 하나씩 hash function을 적용하여 해당되는 버킷에 담습니다.
이러한 과정은 메모리에서만 처리되므로 매우 빠릅니다.
hash에 해당하는 몇몇 케이스(도큐먼트 참고)를 제외하고는 모두 nested loop로 튜닝 문제를 해결할 수 있습니다.
SELECT 부서코드, 부서명, 사원번호, 사원명
FROM 부서 A, 사원 B
where a.부서코드 = b.부서코드
Access순서는? ( 두 테이블은 pk만 존재 )
필요한 인덱스를 만들고 스캔하는 것을 확인해봅시다.
-- 사원.사원명 인덱스 생성
SELECT 부서코드, 부서명, 사원번호, 사원명
FROM 부서 A, 사원 B
WHERE a.부서코드 = b.부서코드
AND B.사원명 = ‘정아나’;
-- 부서.부서명 인덱스 생성
SELECT 부서코드, 부서명, 사원번호, 사원명
FROM 부서 A, 사원 B
WHERE a.부서코드 = b.부서코드
AND A.부서명 = ‘배우팀’;
만약 따로 요구사항이 없다면, 기본적으로 pk와 fk에 인덱스를 걸도록 한다.
(* fk에 fk constraint를 거는 것이 아닌 그냥 인덱스를 거는 것)
SELECT City.Name, Language
FROM Languages, Country, City
WHERE City.Country = Country.Code
AND City.ID = Country.Capital
AND City.Population >= 5000000
AND Languages.Country = Country.Code
AND Percentage > 4.0;
👉 플랜을 뜨기 전에 눈으로 쿼리를 보고 group by/distinct를 semi join으로 변경하면 매우 빨라질 수 있다는 것을 눈치챌 수 있다면 됩니다.
위와 같은 형식으로 게시물의 목록을 보여주려면 덧글의 count를 뽑기위해 덧글 테이블을 모두 scan해야 합니다.
👉 반정규화하여 덧글의 개수를 게시물 테이블에서 가지고있게 합니다.
👉 모든 SNS가 게시물 형식을 가지므로 해당 개념 활용할 수 있습니다.
Join 방식
Join 순서의 결정
SELECT a.prod_cd, a.prod_nm
FROM prod a
WHERE NOT EXISTS (SELECT 1 FROM ordr_prod b
WHERE a.prod_cd = b.prod_cd AND b.ordr_prod_cnt > 3);
SELECT a.prod_cd, a.prod_nm
FROM prod a INNER JOIN ordr_prod b USING (prod_cd)
WHERE b.ordr_prod_cnt = 3 ;
SELECT a.prod_cd, a.prod_nm, b.ordr_no
FROM prod a LEFT JOIN ordr_prod b ON a.prod_cd = b.prod_cd
WHERE b.ordr_prod_cnt > 3;
SELECT a.prod_cd, a.prod_nm
FROM prod a
WHERE a.prod_cd IN (SELECT b.prod_cd FROM ordr_prod b where ordr_prod_cnt > 3);
상품(50건)테이블과 주문상품(500건)테이블이 있을 때 아래 각 쿼리의 결과는?
( 주문상품의 모든 상품번호는 상품테이블에 존재, 주문상품 테이블에 없는 상품번호는 총 5개 )
1. SELECT count(*) FROM 상품 A, 주문상품 B ON A.상품번호 = B.상품번호
2. SELECT count(*) FROM 상품 A LEFT OUTER JOIN 주문상품 B ON A.상품번호 = B.상품번호
3. SELECT count(*) FROM 주문상품 A LEFT OUTER JOIN 상품 B ON A.상품번호 = B.상품번호
아래 쿼리는 2016년 상반기에 등록된 상품 중 판매이력이 있는 상품을 조회하는 쿼리입니다.
SELECT a.prod_cd, a.prod_nm
FROM prod a INNER JOIN ordr_prod b ON a.prod_cd = b.prod_cd
WHERE a.prod_rgst_ymdt < ‘2016-07-01 00:00:00’
GROUP BY a.prod_cd, a.prod_nm ;
다른 JOIN 방식을 사용하여 더 빠른 결과를 얻을 수 없는지 생각해 보고, 개선된 쿼리와 실행 플랜을 비교해 보세요. 실행 플랜을 비교해 보았다면, 기존 쿼리에서 어떤 부분이 비효율적인지 생각해 보세요.
👉 GROUP BY를 수행하지만 집계함수(SUM 등)이 없다면 SEMI JOIN으로 변경하기!
👉 1:N 관계를 1:1처럼 풀어주므로 DISK I/O가 확 줄어들 수 있음
❗️ 쿼리를 보고 형태를 찾아내는 것을 연습하는 것이 필요