SQL Tuning summary No.3
SORT 연산
- oracle은 가공된 데이터 집합이 필요할 때, PGA와 Temp 테이블스페이스를 활용한다.
- sort merge join
- hash join
- group by
- order by
- sort는 PGA의 Sort Area에서 먼저 이뤄지고, 그거로도 부족하면 Temp 테이블스페이스를 활용한다.
- Sort Area에서만 끝나면 메모리 소트(Internal sort), 디스크까지 활용하면 디스크 소트(External sort)라고 한다.
- sort가 이뤄지는 방식은 아래 이미지와 같다.
소트할 대상을 SGA 버퍼캐시를 통해 읽는다.
sort Area에서 정렬을 해본다.
안되면 Temp 테이블스페이스에 임시 세그먼트를 만들어 저장한다.
temp에 저장해 둔 집합을 sort run이라고 부른다.
클라이언트에 전달할 때는 PGA에 있는 걸 먼저 준다.
그 다음 temp에 있는 것을 PGA에 merge해서 PGA에서 client로 전달한다.
- 소트 연산은 메모리 집약적이며, CPU 집약적이다.
- 거기다 PGA가 작으면 disk I/O까지 일어나니 쿼리 성능에 핵심적이다.
- 될 수 있다면 sort를 발생시키지 않는게 좋고, 필요하면 메모리로 끝내야 한다.
SORT 연산 byte 최소화
- sort area에서 sort를 해야 한다면, byte를 적게 잡아야 한다.
- 그래야 메모리 안에서 sort가 이뤄진다. sort area를 벗어나면 바로 disk I/O가 돼버린다.
- LPAD 등으로 가공하면서 공백이 늘면 그만큼 byte도 늘어난다.
SELECT LPAD(상품번호, 30) || LPAD(상품명, 30) || LPAD(고객ID, 10) || LPAD(고객명, 20) || TO_CHAR(주문일시, 'yyyymmdd hh24:mi:ss') FROM 주문상품 WHERE 주문일시 BETWEEN :start and :end ORDER BY 상품번호 - 차라리 아래처럼 sort 자체는 기본 field로 해야한다.
- 그래야 sort byte가 적어져 sort area에서 sort가 끝난다.
SELECT LPAD(상품번호, 30) || LPAD(상품명, 30) || LPAD(고객ID, 10) || LPAD(고객명, 20) || TO_CHAR(주문일시, 'yyyymmdd hh24:mi:ss') FROM ( SELECT 상품번호, 상품명, 고객ID, 고객명, 주문일시 FROM 주문상품 WHERE 주문일시 BETWEEN :start and :end ORDER BY 상품번호 ) - 마찬가지 이유로 필요한 필드만 적어야 sort byte를 최소화한다.
SELECT 계좌번호, 총예수금 FROM 예수금원장 ORDER BY 총예수금 DESC - sort area를 최소화하는 기술로 top n sort도 있다.
- top n sort는 top n stopkey보단 비효율적이지만, sort 용량을 크게 줄여주는 데서 의미가 있다.
- top n stopkey를 유도하는 SQL패턴을 그대로 사용하면 된다.
- top n sort는 딱 가져올 만큼의 공간만 존재하면 된다.
- 10개라면 10개를 저장한 공간만 있으면 총데이터 집합이 엄청 커도 10개만 가져오고 멈춘다.
- 따라서 sort area에 들어가는 건 10개의 byte뿐이다.
- 따라서 top n sort의 경우 대부분 trace에 pw,pr이 0으로 찍힌다. disk I/O를 안타는 셈이다.
SELECT * FROM ( SELECT ROWNUM no, a.* FROM ( SELECT 거래일시, 체결건수, 체결수량, 거래대금 FROM 종목거래 WHERE 종목코드 = 'KR123456' AND 거래일시 >= '20180304' ORDER BY 거래일시 ) a WHERE ROWNUM <= (:page * 10) ) WHERE no >= (:page - 1) * 10 + 1
- 절대 아래처럼 바꾸면 안 된다.
- 보기에 간단하지만, 이러면 top n sort가 안 먹힌다.
- 그럼 전체를 긁고서 sort를 하니까 공간이 부족해 disk I/O가 일어날 수 있다.
SELECT * FROM ( SELECT ROWNUM no, a.* FROM ( SELECT 거래일시, 체결건수, 체결수량, 거래대금 FROM 종목거래 WHERE 종목코드 = 'KR123456' AND 거래일시 >= '20180304' ORDER BY 거래일시 ) a ) WHERE no BETWEEN (:page -1) * 10 + 1 and (:page * 10) - 참고로 mysql은 top n sort를 지원해주지 않는다.
- top n sort처럼 처리되지 않고 위의 절대 바꾸며 안되는 패턴처럼 처리된다.
SELECT 거래일시, 체결건수, 체결수량, 거래대금 FROM 종목거래 WHERE 종목코드 = 'KR123456' AND 거래일시 >= '2018-03-04' ORDER BY 거래일시 LIMIT :offset, 10; - max 대신 window function을 쓰는 것도 좋은 습관이다.
- 그러면 top n sort 알고리즘이 발동해 sort 부하가 적다.
- 그만큼 disk I/O가 덜 생겨서 빨라지게 된다는 의미다.
- 실험을 위해 아래같이 sort_area 메모리공간을 줄여보자.
alter session set workarea_size_policy = manual; alter session set sort_area_size = 524288; - 아래는 max를 썼다.
- 그럼 disk I/O가 많다.
SELECT 장비번호, 변경일자, 변경순번, 상태코드, 메모 FROM (SELECT 장비번호, 변경일자, 변경순번, 상태코드, 메모 , MAX(변경순번) OVER(PARTITION BY 장비번호) 최종변경순번 FROM 상태변경이력 WHERE 변경일자 = :upd_dt) WHERE 변경순번 = 최종변경순번 - rank 같은 window function을 쓰면 훨씬 disk I/O가 줄어든다.
SELECT 장비번호, 변경일자, 변경순번, 상태코드, 메모 FROM (SELECT 장비번호, 변경일자, 변경순번, 상태코드, 메모 , RANK(변경순번) OVER(PARTITION BY 장비번호 ORDER BY 변경순번 DESC) 최종변경순번 FROM 상태변경이력 WHERE 변경일자 = :upd_dt) WHERE 변경순번 = 최종변경순번
SORT execution plan
- sort operation은 아래와 같다.
- sort Aggregate
- 집계함수를 사용한 것이다.
- 실제 데이터 정렬은 없다.
- sum, max, min, avg 등이 예시다.
- sort order by
- 데이터를 정렬한다.
- sort group by
- 데이터를 정렬한다.
- 그룹 개수가 적으면 수억 row가 있어도 temp table space를 쓰지 않는다.
- order by가 없으면 대부분 hash group by로 처리된다.
- sort group by와 다른 점은 해싱알고리즘이라는 점 말고 없다.
- hash과 sort나 group by는 결과가 정렬됨을 보장하지 않는다.
- sort unique
- 서브쿼리를 unnest할 때, 서브쿼리가 1대 다 중 다쪽일 때
- 1쪽 집합인데 조인컬럼에 unique index가 없을 때 나타난다
- 메인쿼리와 조인하기 전에 중복 레코드를 제거해야 하기 때문이다.
- 아니면 union, minus, intersect, distinct를 써도 나타난다.
- distinct의 경우 hash unique가 더 많이 쓰인다.
- sort join
- sort merge join을 수행할 때 나타난다.
- window sort
- 윈도우 함수를 수행할 때 나타난다.
- sort Aggregate
- 참고로 SORT GROUP BY에서도 index를 활용해 SORT를 하지 않을 수 있다.
- 이 경우 region이 선두컬럼이어야 한다.
SELECT region, avg(age), count(*) FROM customer GROUP BY region
SORT를 피하는 SQL 1 -EXISTS/UNION ALL
- union, minus, distinct는 최대한 자제해야 한다.
- union보다는 union all로 쓸 수 있는지 확인해봐야 한다.
- distinct보다는 exists를 사용할 수 있는지 확인해봐야 한다.
- 대량데이터가 아니라면 hash join이 아니라 NL join을 써야 한다.
- union의 예시를 들어보자.
SELECT 결제번호, 결제수단코드, 주문번호, 결제금액, 결제일자, 주문일자
FROM 결제
WHERE 결제일자 ='20180316'
UNION
SELECT 결제번호, 결제수단코드, 주문번호, 결제금액, 결제일자, 주문일자
FROM 결제
WHERE 주문일자 = '20180316'
- 아래와 같이 union all과 <> 연산자를 이용해 sort가 일어나지 않게 바꾼다.
SELECT 결제번호, 결제수단코드, 주문번호, 결제금액, 결제일자, 주문일자
FROM 결제
WHERE 결제일자 ='20180316'
UNION ALL
SELECT 결제번호, 결제수단코드, 주문번호, 결제금액, 결제일자, 주문일자
FROM 결제
WHERE 결제일자 = '20180316'
AND 결제일자 <> '20180316'
- 만약 결제일자가 null 허용 컬럼이면 아래와 같이 바꿔준다.
SELECT 결제번호, 결제수단코드, 주문번호, 결제금액, 결제일자, 주문일자
FROM 결제
WHERE 결제일자 ='20180316'
UNION ALL
SELECT 결제번호, 결제수단코드, 주문번호, 결제금액, 결제일자, 주문일자
FROM 결제
WHERE 결제일자 = '20180316'
AND (결제일자 <> '20180316' or 결제일자 is null) /** LNNVL(결제일자 ='20180316') */
- distinct를 제거하는 쿼리 튜닝도 보자.
SELECT DISTINCT p.상품번호, p.상품명, p.상품가격
FROM 상품 p, 계약 c
WHERE p.상품유형코드 = :pclscd
AND c.상품번호 = p.상품번호
AND c.계약일자 BETWEEN :dt1 and :dt2
AND c.계약구분코드 = :ctpcd
- 위의 쿼리 대신 exists를 활용해보자.
- exists를 이용하면 데이터를 모두 읽지 않아도 된다.
- distinct를 사용하지 않았으니 부분범위 처리도 가능하다.
SELECT p.상품번호, p.상품명, p.상품가격
FROM 상품 p
WHERE p.상품유형코드 = :pclscd
AND EXISTS (
SELECT 'x' FROM 계약 c
WHERE c.상품번호 = p.상품번호
AND c.계약일자 BETWEEN :dt1 and :dt2
AND c.계약구분코드 = :ctpcd
)
- minus를 튜닝해보자.
SELECT ST.상황접수번호, ST.관제일련번호, ST.상황코드
FROM 관제진행상황 ST
WHERE 상황코드 = '0001'
AND 관제일시 BETWEEN :V_TIMEEFROM || '000000' AND :V_TIMET0 || '235959'
MINUS
SELECT ST.상황접수번호, ST.관제일련번호 ,ST.상황코드, ST.관제일시
FROM 관제진행상황 ST, 구조활동 RPT
WHERE 상황코드 = '0001'
AND 관제일시 BETWEEN :V_TIMEEFROM || '000000' AND :V_TIMET0 || '235959'
AND RPT.출동센터ID = :V_CNTR_ID
AND ST.상황접수번호 = PRT.상황접수번호
ORDER BY 상황접수번호, 관제일시
- 아래와 같이 바꿀 수 있다.
- not exists를 이용하면 데이터를 모두 읽지 않아도 된다.
- distinct를 사용하지 않았으니 부분범위 처리도 가능하다.
SELECT ST.상황접수번호, ST.관제일련번호 ,ST.상황코드, ST.관제일시
FROM 관제진행상황 ST, 구조활동 RPT
WHERE 상황코드 = '0001'
AND 관제일시 BETWEEN :V_TIMEEFROM || '000000' AND :V_TIMET0 || '235959'
AND NOT EXISTS (
SELECT 'X' FROM 구조활동
WHERE 출동센터ID = :V_CNTR_ID
AND 상황접수번호 = ST.상황접수번호
)
ORDER BY ST.상황접수번호, ST.관제일시
- join에 관해서도 이야기해보자.
- 계약 table의 index가 (지점ID, 계약일시)기 때문에 order by가 생략될 수 있다.
- 그러나 hash join이 되면서 SORT ORDER BY 연산이 일어났다.
SELECT c.계약번호, c.상품코드, p.상품명, p.상품구분코드
FROM 계약 c, 상품 p
WHERE c.지점ID = :brch_id
AND p.상품코드 = c.상품코드
ORDER BY c.계얄일시 DESC
- 이를 NL join으로 바꿔준다.
SELECT /*+ leading(c) use_nl(p) */ c.계약번호, c.상품코드, p.상품명, p.상품구분코드
FROM 계약 c, 상품 p
WHERE c.지점ID = :brch_id
AND p.상품코드 = c.상품코드
ORDER BY c.계얄일시 DESC
SORT를 피하는 SQL 2 -TOP N쿼리
- top n 쿼리는 아래 두개로 나뉜다.
- top n stopkey
- top n stopkey는 정렬이 필요 없다.
- 거기다가 부분범위 처리만 하기에 전체 scan도 하지 않는다.
- 조건으론 index 구성 컬럼이 where 혹은 order by에 있기만 하면 된다.
- 다만 없거나, order by에 index 비구성 컬럼이 들어가면 top n sort가 된다.
- top n sort
- top n sort는 정렬은 필요하다.
- 따라서 전체 scan은 필수적이다.
- 다만 전체 index record를 정렬하지 않고 rownum의 갯수만큼만 정렬하면 된다.
- top n stopkey
SELECT * FROM (
SELECT 거래일시, 체결건수, 체결수량, 거래대금
FROM 종목거래
WHERE 종목코드 = 'KR123456'
AND 거래일시 >= '20180304'
ORDER BY 거래일시
)
WHERE ROWNUM <= 10
- 실행계획은 아래와 같이 나타난다.
- Sort Order by는 사라지고 Count Stopkey가 생겨났다.
- COUNT STOPKEY만 있고 SORT ORDER BY STOPKEY가 없으면 top n stopkey로 가장 좋은 경우다.
- COUNT STOPKEY가 있고 SORT ORDER BY STOPKEY가 있으면 top n sort로 그래도 괜찮은 경우다.
- COUNT가 있고 SORT ORDER BY가 있으면 top n query 어느것도 이용하지 못한 경우다.
- sort를 하게 되는데, 필요한만큼만 덜어서 PGA를 채우지 못하기 때문에 disk I/O가 일어날 확률이 높아진다.
- COUNT가 있고 SORT ORDER BY가 없는 경우도 top n query 어느것도 이용하지 못한 경우다.
- 그래도 sort는 하지 않기 때문에 disk I/O가 일어날 확률은 낮다. COUNT + SORT ORDER BY보단 성능이 낫다.
- 실제 웹의 페이징처리를 top n query를 적용하면 보통 아래와 같다.
- top n 쿼리므로 ROWNUM으로 지정한 건수만큼 결과 레코드를 얻으면 거기서 멈춘다.
- 뒤쪽 페이지로 이동할수록 읽는 데이터량도 많아지지만, 보통 앞쪽만 확인하니 문제가 거의 없다.
SELECT * FROM ( SELECT ROWNUM no, a.* FROM ( /** SQL Body */ ) a WHERE ROWNUM <= (:page * 10) ) WHERE no >= (:page - 1)*10 + 1 - 위와 같은 sql은 아래와 같은 원칙을 구현한 것이다.
- 부분범위 처리가 가능하도록 SQL을 작성한다.
- 작성한 SQL문을 SQL BODY쪽에 집어넣는다.
- ORDER BY도 필수적으로 들어가야 한다.
- 이게 뜻하는 바는 아래와 같다.
- 인덱스 사용 가능하도록 조건절을 구사한다.
- 조인은 NL조인 위주로 처리한다.
- ORDER BY절이 있어도 소트 연산을 생략할 수 있게 인덱스를 구성한다.
- 해당 원칙을 지켜 만든 SQL을 만든다면 아래와 같은 형태일 것이다.
SELECT * FROM ( SELECT ROWNUM no, a.* FROM ( /** SQL Body */ SELECT 거래일시, 체결건수, 체결수량, 거래대금 FROM 종목거래 WHERE 종목코드 = 'KR123456' AND 거래일시 >= '20180304' ORDER BY 거래일시 ) a WHERE ROWNUM <= (:page * 10) ) WHERE no >= (:page - 1)*10 + 1 - 페이징 처리의 안티 패턴은 rownum을 지워버리는 것이다.
- rownum을 쓸데없다고 생각하고 지우는 순간, top n query 알고리즘은 멈춘다.
- 더 문제는 top n sort 마저도 작동하지 않아 pr, pw를 해야할 수도 있다는 사실이다.
SELECT * FROM ( SELECT ROWNUM no, a.* FROM ( SELECT 거래일시, 체결건수, 체결수량, 거래대금 FROM 종목거래 WHERE 종목코드 = 'KR123456' AND 거래일시 >= '20180304' ORDER BY 거래일시 ) a ) WHERE no BETWEEN (:page -1) * 10 +1 and (:page * 10) - rownum을 지우면 아래와 같이 실행계획이 바뀐다.
-
count 옆에 stopkey가 없다. 소트연산은 생략되어도 top n query가 작동하진 않는다.
- stopkey를 못 하면 아래와 같이 되어버린다.
-
전체범위를 전부 훑어야 되는 것이다.
- 또 다른 안티 패턴은 아래와 같이 rownum의 <= 조건에서 rownum을 별칭으로 사용하는 것이다.
- 그럼 top n query 알고리즘은 멈춘다. 따라서 전체범위를 훑게 된다.
SELECT * FROM ( SELECT ROWNUM no, a.* FROM ( /** SQL Body */ SELECT 거래일시, 체결건수, 체결수량, 거래대금 FROM 종목거래 WHERE 종목코드 = 'KR123456' AND 거래일시 >= '20180304' ORDER BY 거래일시 ) a WHERE no <= (:page * 10) ) WHERE no >= (:page - 1)*10 + 1 - 또 다른 안티 패턴은 아래와 같이 body SQL에서 형변환을 해버리는 것이다.
- 그럼 가공된 컬럼이 되어, 정렬이 필요해지고 top n query 알고리즘은 멈춘다. 따라서 전체범위를 훑게 된다.
SELECT * FROM ( SELECT ROWNUM no, a.* FROM ( /** SQL Body */ SELECT 거래일시, 체결건수, 체결수량, TO_NUMBER(거래대금) FROM 종목거래 WHERE 종목코드 = 'KR123456' AND 거래일시 >= '20180304' ORDER BY 거래일시 ) a WHERE no <= (:page * 10) ) WHERE no >= (:page - 1)*10 + 1 - 그럴 땐 아래와 *를 사용하지 않고 column들을 모두 전부 쓰는 수밖에 없다.
SELECT * FROM ( SELECT ROWNUM no ,거래일시 ,체결건수 ,체결수량 ,TO_NUMBER(거래대금) FROM ( /** SQL Body */ SELECT 거래일시, 체결건수, 체결수량, 거래대금 FROM 종목거래 WHERE 종목코드 = 'KR123456' AND 거래일시 >= '20180304' ORDER BY 거래일시 ) a WHERE ROWNUM <= (:page * 10) ) WHERE no >= (:page - 1)*10 + 1 - 거래PK는 (거래일자, 계좌번호, 거래순번)로 이뤄졌다.
- 거래_X01은 (계좌번호, 거래순번, 결제구분코드)로 이뤄졌다.
- 그 상황에서 아래 SQL을 실행하면 TOP N sort으로 타다. 즉 sort by 연산을 생략하지 못한다.
- 부분범위 처리는 불가능하다는 의미다. 전체를 다 읽어야 하니 오래걸린다.
SELECT * FROM ( SELECT 계좌번호, 거래순번, 주문금액, 주문수량, 결제구분코드 FROM 거래 WHERE 거래일자 = :ord_dt ORDER BY 계좌번호, 거래순번, 결제구분코드 ) WHERE ROWNUM <=50
- 위의 SQL의 실행계획은 아래와 같다.
- 어차피 거래_X01 index는 쓸수가 없다. where 조건문에 거래일자가 들어갔기 때문이다.
- 그럼 거래PK를 쓰는데, 하필 결제구분코드가 더 들어갔다.
- 그래서 sort order by 연산이 진행된 것이다.
- 반대로 말하면 결제구분코드만 없으면 된다. 실제로 필요하지 않다면 없애자.
- index가 (거래일자, 계좌번호, 거래순번)이고 거래일자가 = 조건이다.
- 그럼 거래일자 데이터를 계좌번호, 거래순번 순으로 정렬하면 중복 레코드가 없다.
- 다시 말하면 결제구분코드는 없어도 된다는 의미다. 없애면 부분범위 처리가 가능해져 매우 빨라진다.
- top n은 max, min 연산을 대체하는 데도 사용할 수 있다.
- 특히 index 중 하나가 빠져도 작동해서 유연하다.
- (DEPTNO, SAL)로 구성해도 TOP N stopkey로 작동한다.
- 아래에서 MGR이 index 구성으로 없어도 되기에 좀 더 유연하게 index를 구성할 수 있다.
- 다시말해 선두컬럼만 where 조건문에 있고, order by에 들어가는 건 index 구성 순서를 따른 column이어야 한다.
SELECT * FROM ( SELECT SAL FROM EMP WHERE DEPTNO = 30 AND MGR = 7698 ORDER BY SAL DESC ) WHERE ROWNUM <= 1;
-
실행계획은 아래와 같이 나타난다.
- MGR 컬럼이 index에 없으니 DEPTNO column만이 index access조건이다.
- 따라서 원래는 DEPTNO = 30을 만족하는 모든 index record를 읽어야 한다.
- 그러나 top n stopkey의 경우, 조건을 만족하는 모든 index 레코드를 읽지 않는다.
- DEPTNO = 30을 만족하는 가장 오른쪽부터 역순으로 스캔하며 table을 access한다.
- 그 중 MGR = 7698 조건을 만족하는 table record를 하나 찾는 순간 바로 멈춘다.
- min으로 하고 싶다면 DESC가 아니라 AESC로 주면 된다.
- 다른 예시도 들어보자.
- 아래와 같은 쿼리가 있다고 해보자.
- index 구성이 (customer_no, login_date)기 때문에 order by에 login_date가 빠져도 정렬이 가능하다.
SELECT from login where and rownum <= 1 - 다만 위와 같은 sql은 top n stopkey 알고리즘이 작동하지 않는다.
- 아래와 같이 만들어줘야 top n stopkey 알고리즘이 작동하게 된다.
- 만약 저기서 order by에 index가 아닌 column을 추가하게 되면 top n sort로 변경된다.
SELECT * FROM ( SELECT auto_login_yn, login_date, auto_login_token, customer_grade, customer_name FROM 종목거래 WHERE customer_no = #{customer_no} ORDER BY login_date ) WHERE ROWNUM <= 1 - top n sort를 응용하면 더보기도 만들어줄 수 있다.
- index 구성이 모자라서 top n stopkey로 작동은 못해도 top n sort로는 작동가능하다.
SELECT CASE WHEN COUNT(1) = 11 THEN 1 ELSE 0 END AS HAS_MORE FROM ( SELECT board_sqno FROM board WHERE board_clf = #{board_clf} AND board_display = 'Y' <if test = "newNo != null" > AND board_sqno < #{newNo} AND board_display_fix_yn <> 'Y' </if> ORDER BY board_display_fix_yn DESC , board_display_order ASC , board_sqno DESC ) WHERE rownum <= 11 - top n 쿼리가 좋은 점은 index 구성이 조금 불완전함에도 작동하기 때문이다.
- 아래 board table은 (board_sqno, board_clf)밖에 없지만, 그럼에도 아래 쿼리는 top n sort로 작동한다.
- count stopkey와 sort oredr by stopkey 지시어가 execution plan에 모두 나오는 것을 확인할 수 있다.
- 그러나 굳이 이렇게 길게 쓸 필요가 없다. inline view를 쓸데없이 길게 쓴 것에 불과하다.
- inline view를 가공하지 않게 하려고 inline view를 덧씌우는 inline view를 쓰는 것인데, 형변환이 없어 불필요하다.
SELECT b.* , TO_CHAR(TO_DATE(b.registered_datetime, 'YYYYMMDDHH24MISS'), 'YYYY-MM-DD') AS rgdm , CASE WHEN(TO_CHAR(SYSDATE,'YYYYMMDD') - TO_CHAR(TO_DATE(b.rgdm, 'YYYYMMDDHH24MISS'), 'YYYYMMDD')) <= 7 THEN 'Y' ELSE 'N' END AS boolYN FROM ( SELECT a.board_sqno , a.board_title , a.image_path , a.rgdm , changed_date FROM ( SELECT board_sqno , board_title , board_title , image_path , registered_date , changed_date FROM board WHERE board_display_yn = 'Y' ORDER BY fix_exps_yn DESC , fix_exps_sq ASC , board_sqno DESC ) a ) b WHERE ROWNUM <= 10; - 아래와 같이 간결하게 바꿔주자.
SELECT b.* , TO_CHAR(TO_DATE(b.registered_datetime, 'YYYYMMDDHH24MISS'), 'YYYY-MM-DD') AS rgdm , CASE WHEN(TO_CHAR(SYSDATE,'YYYYMMDD') - TO_CHAR(TO_DATE(b.rgdm, 'YYYYMMDDHH24MISS'), 'YYYYMMDD')) <= 7 THEN 'Y' ELSE 'N' END AS boolYN FROM ( SELECT board_sqno , board_title , board_title , image_path , registered_date , changed_date FROM board WHERE board_display_yn = 'Y' ORDER BY fix_exps_yn DESC , fix_exps_sq ASC , board_sqno DESC ) b WHERE ROWNUM <= 10; - 위에서 말했던 것처럼 AS는 함부로 쓰면 안된다.
- 아래와 같이 ROWNUM을 별칭으로 바꾸는 순간 STOPKEY가 전부 사라진다.
- 일반적인 sort 연산을 수행하게 바뀌어버리는 것이다.
SELECT b.* , TO_CHAR(TO_DATE(b.registered_datetime, 'YYYYMMDDHH24MISS'), 'YYYY-MM-DD') AS rgdm , CASE WHEN(TO_CHAR(SYSDATE,'YYYYMMDD') - TO_CHAR(TO_DATE(b.rgdm, 'YYYYMMDDHH24MISS'), 'YYYYMMDD')) <= 7 THEN 'Y' ELSE 'N' END AS boolYN FROM ( SELECT ROWNUM AS RN , board_sqno , board_title , board_title , image_path , registered_date , changed_date FROM board WHERE board_display_yn = 'Y' ORDER BY fix_exps_yn DESC , fix_exps_sq ASC , board_sqno DESC ) b WHERE b.RN <= 10;
SORT를 피하는 SQL 3 -first row
- 위에서는 MAX, MIN을 TOP N 쿼리로 구해왔다.
- 그런데 만약 MAX, MIN으로 쓴다면 그 땐 SORT ORDER BY 연산이 일어난다.
- 전체 데이터를 정렬하진 않지만, 전체 데이터를 읽으면서 값을 비교한다.
-
아래와 같이 sort aggregate라는 연산이 실행계획에 보인다.
- index가 정렬되어 있다면, 전체 데이터를 읽지 않아도 된다.
- index를 맨 왼쪽으로 내려가면 첫번쨰 읽는 게 최소값이다.
- index를 맨 오른쪽으로 내려가면 첫번째 읽는 게 최대값이다.
- index leaf block은 양방향리스트니 왼쪽이나 오른쪽이나 가는 방향을 선택할 수 있다.
- 단 위처럼 전체 데이터를 읽지 않으려면 조건이 있다.
- 조건절 컬럼이 index에 있어야 한다.
- max/min 컬럼이 index에 있어야 한다.
- 아래의 sql을 보자.
- deptno, mgr, sal이 모두 index에 포함되어 있으면 전체 데이터를 읽지 않아도 된다.
- 아래는 (deptno, mgr, sal)로 index를 구성한 경우다.
- 이 경우 deptno와 mgr이 index access조건이다.
- 안타깝게도 TOP N 처럼 MGR이 index에 없어도 되진 않다.
- MAX 함수를 사용하면 index 효과를 톡톡히 보기 위해 MGR도 반드시 추가되어야 한다.
SELECT MAX(SAL) FROM EMP WHERE DEPTNO = 30 AND MGR = 7698;
- 만약 알고리즘이 제대로 작동했다면 아래와 같이 FIRST ROW라는 실행계획이 추가된다.
-
매우 효율적인 min,max 연산이 이뤄졌다는 의미다.
- index를 조금 바꿔서 (deptno, sal, mgr)로 구성해보자.
- 이 경우 deptno이 index access조건이다.
- mgr은 index filter조건이다.
- 그러나 First row key연산인 점은 변하지 않는다.
- deptno = 30인 index record 범위에서 가장 오른쪽으로 내려가 가장 큰 SAL 값을 읽는다.
- 거기서부터 스캔을 시작해 MGR = 7698을 만족하는 레코드를 찾으면 멈춘다.
- 그림으로 이해하면 아래와 같다.
-
조건절 선두컬럼은 index access고, 후행은 index filter인 상황이다.
- index를 조금 바꿔서 (sal, deptno, mgr)로 구성해보자.
- 이 경우 deptno와 mgr 모두 선두컬럼이 아니다.
- 따라서 index range scan이 아니라 index full scan이 일어난다.
- deptno와 mgr 모두 index filter 조건이된다.
- 그러나 First row key연산인 점은 변하지 않는다.
- index record 범위에서 가장 오른쪽에서 스캔을 시작한다.
- DEPTNO = 30이면서 MGR = 7698인 조건을 찾으면 멈춘다.
- 그림으로 이해하면 아래와 같다.
-
조건절 모두가 index filter인 상황이다.
- index를 조금 바꿔서 (deptno, sal)로 구성해보자.
- 이경우 조건절에 쓰인 mgr 컬럼이 없다.
- DEPTNO = 30이면서 MAX(SAL)인 값은 오른쪽에서부터 시작해 쉽게 찾는다.
- 그러나 mgr 컬럼이 index에 없어 MGR = 7698 조건은 테이블에서 필터링해야한다.
- 따라서 First row key연산이 작동하지 않는다.
- 그림으로 이해하면 아래와 같다.
- index column에 조건절이 없어 first row가 작동하지 않았다.
SORT 알고리즘의 꽃 -이력조회
- 이력조회에 first row stopkey나 top n stopkey 알고리즘을 이용하는 게 필요하다.
- 아래와 같이 장비 table이 있다고 해보자.
- 장비번호 PK
- 장비명
- 장비구분코드
- 상태코드
- 최종변경일자
- 상태변경 이력 table은 아래와 같다.
- 장비번호 PK1
- 변경일자 PK2
- 변경순번 PK3
- 상태코드
- 메모
- 가장 단순하게 이력데이터를 조회해보자.
- 스칼라 서브쿼리에서 장비번호와 변경일자가 쓰이는데, 선두컬럼이기에 first row stopkey 알고리즘이 발동한다.
SELECT 장비번호, 장비명, 상태코드, (SELECT MAX(변경일자) FROM 상태변경이력 WHERE 장비번호 = P.장비번호) 최종변경일자 FROM 장비 P WHERE 장비구분코드 = 'A001' - 위의 기능에서 최종변경순번도 가져오게 추가되었다.
- inline view에서 최종변경일자와 최종변경순번을 겹쳐 가져오려 한다.
- 그런 시도는 좋지만, 그때문에 index 컬럼이 가공돼 index access조건으로 타지 못하게 됐다.
SELECT 장비번호, 장비명, 상태코드 , SUBSTR(최종이력, 1, 8) 최종변경일자 , TO_NUMBER(SUBSTR(최종이력, 9,4)) 최종변경순번 FROM ( SELECT 장비번호, 장비명, 상태코드 , (SELECT MAX(H.변경일자 || LPAD(H.변경순번, 4)) FROM 상태변경이력 H WHERE 장비번호 = P.장비번호) 최종이력 FROM 장비 P WHERE 장비구분코드 = 'A001' ) - 따라서 아래와 같은 쿼리로 바꿔주는 게 낫다.
- 스칼라 서브쿼리가 많아서 비효율적일 거 같지만 index access로 작동하는 게 더 빠르다.
SELECT 장비번호, 장비명, 상태코드 ,(SELECT MAX(H.변경일자) FROM 상태변경이력 H WHERE 장비번호 = P.장비번호) 최종변경일자 ,(SELECT MAX(H.변경순번) FROM 상태변경이력 H WHERE 장비번호 = P.장비번호 AND 변경일자 = (SELECT MAX(H.변경일자) FROM 상태변경이력 H WHERE 장비번호 = P.장비번호)) 최종변경순번 FROM 장비 P WHERE 장비구분코드 = 'A001' - 그런데 최종변경순번에다가 또 다른 field도 가져와야 한다면?
- 최종상태코드를 가져오려면 아래와 같이 길어진다.
- 다음은 더 길어질 것이다.
SELECT 장비번호, 장비명, 상태코드 ,(SELECT MAX(H.변경일자) FROM 상태변경이력 H WHERE 장비번호 = P.장비번호) 최종변경일자 ,(SELECT MAX(H1.변경순번) FROM 상태변경이력 H1 WHERE 장비번호 = P.장비번호 AND 변경일자 = (SELECT MAX(H2.변경일자) FROM 상태변경이력 H2 WHERE 장비번호 = P.장비번호)) 최종변경순번 ,(SELECT H1.상태코드 FROM 상태변경이력 H1 WHERE 장비번호 = P.장비번호 AND 변경일자 = (SELECT MAX(H2.변경일자) FROM 상태변경이력 H2 WHERE 장비번호 = P.장비번호) AND 변경순번 = (SELECT MAX(H3.변경순번) FROM 상태변경이력 H3 WHERE 장비번호 = P.장비번호 AND 변경일자 = (SELECT MAX(H4.변경일자) FROM 상태변경이력 H4 WHERE 장비번호 = P.장비번호))) 최종상태코드 FROM 장비 P WHERE 장비구분코드 = 'A001' - 단순한 쿼리를 위해 전통적으로는 아래와 같이 썼다.
- INDEX_DESC라 인덱스를 역순으로 읽는것이고, 첫 레코드에서 바로 멈춘다.
- 다만 아래와 같은 방법은 index 구성이 완벽해야 한다.
SELECT 장비번호, 장비명 , SUBSTR(최종이력, 1, 8) 최종변경일자 , TO_NUMBER(SUBSTR(최종이력, 9, 4)) 최종변경순번 , SUBSTR(최종이력, 13) 최종상태코드 FROM ( SELECT 장비번호, 장비명 , (SELECT /*+ INDEX_DESC(X 상태변경이력_PK) */ 변경일자 || LPAD(변경순번, 4) || 상태코드 FROM 상태변경이력 X WHERE 장비번호 = P.장비번호 AND ROWNUM <=1) 최종이력 FROM 장비 P WHERE 장비구분코드 = 'A001' ) - 11g에서는 아래와 같이 쓰면 된다.
- WHERE 장비번호 =P.장비번호가 서브쿼리로 들어가있다.
- 그러나 실제로는 inline view로 들어가서 조건절이 작동한다.
- prdicated pushing이다.
SELECT 장비번호, 장비명 , SUBSTR(최종이력, 1, 8) 최종변경일자 , TO_NUMBER(SUBSTR(최종이력, 9, 4)) 최종변경순번 , SUBSTR(최종이력, 13) 최종상태코드 FROM ( SELECT 장비번호, 장비명 , (SELECT 변경일자 || LPAD(변경순번, 4) || 상태코드 FROM (SELECT 장비번호, 변경일자, 변경순번, 상태코드 FROM 상태변경이력 ORDER BY 변경일자 DESC, 변경순번 DESC) /**11g에서 가능한 방식*/ WHERE 장비번호 = P.장비번호 AND ROWNUM <=1 ) 최종이력 FROM 장비 P WHERE 장비구분코드 = 'A001' ) - 12c에서는 아래와 같이 쓰면 된다.
SELECT 장비번호, 장비명 , SUBSTR(최종이력, 1, 8) 최종변경일자 , TO_NUMBER(SUBSTR(최종이력, 9, 4)) 최종변경순번 , SUBSTR(최종이력, 13) 최종상태코드 FROM ( SELECT 장비번호, 장비명 , (SELECT 변경일자 || LPAD(변경순번, 4) || 상태코드 FROM (SELECT 장비번호, 변경일자, 변경순번, 상태코드 FROM 상태변경이력 ORDER BY 변경일자 DESC, 변경순번 DESC WHERE 장비번호 = P.장비번호) /**12c에서 가능한 방식*/ AND ROWNUM <=1 ) 최종이력 FROM 장비 P WHERE 장비구분코드 = 'A001' ) - 이력 조회를 하는 서브쿼리에 윈도우 함수를 사용할 수 있다.
- 하지만 top n stopkey가 작동하지 않는다.
- 따라서 index로 sort를 생략하는 경우는 절대 사용하면 안 된다.
- 아래는 쿼리를 모르는 사람들이 짜는 anti 패턴이다.
SELECT 장비번호, 장비명 , SUBSTR(최종이력, 1, 8) 최종변경일자 , TO_NUBMER(SUBSTR(최종이력, 9,4)) 최종변경순번 , SUBSTR(최종이력, 13) 최종상태코드 FROM ( SELECT 장비번호, 장비명 , (SELECT 변경일자 || LPAD(변경순번, 4) || 상태코드 FROM (SELECT 변경일자, 변경순번, 상태코드 , ROW_NUMBER() OVER (ORDER BY 변경일자 DESC, 변경순번 DESC) NO FROM 상태변경이력 WHERE 장비번호 = P.장비번호) WHERE NO = 1) 최종이력 FROM 장비 P WHERE 장비구분코드 = 'A001' ) - 12c의 row limit기능을 써도 사실 위와 같은 window 함수를 쓰는 것이다.
- 따라서 stopkey 알고리즘이 발동하지 않는다.
SELECT 장비번호, 장비명 , SUBSTR(최종이력, 1, 8) 최종변경일자 , TO_NUBMER(SUBSTR(최종이력, 9,4)) 최종변경순번 , SUBSTR(최종이력, 13) 최종상태코드 FROM ( SELECT 장비번호, 장비명 , (SELECT 변경일자 || LPAD(변경순번, 4) || 상태코드 FROM 상태변경이력 WHERE 장비번호 = P.장비번호 ORDER BY 변경일자 DESC, 변경순번 DESC FETCH FIRST 1 ROWS ONLY) 최종이력 FROM 장비 P WHERE 장비구분코드 = 'A001' ); - 페이징 처리에 활용할 때 윈도우 함수를 쓰기도 한다.
- 하지만 쓰지 않는 경우도 자주 발생한다. 따라서 index/index_desc hint를 자주 써야 한다.
- sort 생략 가능한 index가 없으면 top n stopkey가 아니라 top n sort가 작동해버린다.
SELECT 변경일자, 변경순번, 상태코드 FROM ( SELECT 변경일자, 변경순번, 상태코드 , ROW_NUBMER() OVER(ORDER BY 변경일자, 변경순번) NO FROM 상태변경이력 WHERE 장비번호 = :eqp_no) WHERE NO BETWEEN 1 AND 10; - 장비번호가 특정번호일 때는 index 조회가 효과적이다.
- 하지만 전체에 관해 조회할 때는 index를 쓰면 random access에 따른 손해가 크다.
- 이럴 땐 window function을 쓰는 게 더 나은 선택이다.
SELECT P.장비번호, P.장비명 , H.변경일자 AS 최종변경일자 , H.변경순번 AS 최종변경순번 , H.상태코드 AS 최종상태코드 FROM 장비 P , (SELECT 장비번호, 변경일자, 변경순번, 상태코드 , ROW_NUMBER() OVER(PARTITION BY 장비번호 ORDER BY 변경일자 DESC, 변경순번 DESC) RNUM FROM 상태변경이력) H WHERE H.장비번호 = P.장비번호 AND H.RNUM = 1;