부제: Partition Wise Join의 성능

 

DBA나 튜닝 컨설턴트들에게 PARTITION WISE JOIN(이후 PWJ)를 설명해 보라고 하면, 언제나 PARALLEL PQ_DISTRIBUTE 힌트를 언급한다. 이상한 일이다. PWJ PARALLEL + PQ_DISTRIBUTE 힌트 조합과 상관없이 독립적으로 존재한다. 그럼에도 불구하고 PWJ를 설명하기 위해 두 힌트를 항상 끌어들인다. 마치 PWJ 기능이 두 힌트에 종속되기라도 하는 것처럼 말이다. 그 이유는 메뉴얼을 포함한 거의 모든 튜닝책에 위의 두 힌트를 사용해서 PWJ를 설명하고 있기 때문이다.

 

 

말도 안 되는 소리
이제는 PWJ를 설명 할 때, PARALLEL PQ_DISTRIBUTE 힌트와 연계하여 설명하지 말기 바란다. 이렇게 연계하여 설명하는 것은 근본원리를 모르면서 활용하려고 하는 것이다. 더 잘못된 것은 성능이 개선되는 이유는 조인할 PARTITION을 미리 짝지어 놓았기 때문(Partition Pair)에 조인이 빠르다고 설명한다. 잘못된 설명이다. 성능문제의 대부분이 두 가지 관점(BLOCK I/O PGA 사용량)으로 결정된다는 것은 상식이다. 그런데 두 가지 관점을 설명하지도 않고 PARTITION을 미리 짝지어 놓았기 때문에 빠르다는 것은 어불성설이다.

 

 

지금까지의 PWJ의 정의를 과감히 버려라. 그리고 지금부터 PWJ를 재정의 해보자. PWJ를 다시 설명하지 않으면 다음의 세가지를 알 수 없다.

 

1. PARALLEL 힌트와 PQ_DISTRIBUTE 힌트를 사용하지 않고 PWJ를 설명 할 수 있다.
2. PWJ
를 사용함으로써 개선된 성능을 BLOCK I/O로 나타낼 수 있다.
3. PWJ
를 사용함으로써 개선된 성능을 PGA 사용량으로 나타낼 수 있다.

 

1. 번은 개념을 설명하는 것이다. 2,3번은 성능향상을 수치로 나타낼 수 있느냐는 것이다. 아마 기존의 상식으로는 힘들것이다. 따라서 이글의 목적은 위의 세 가지를 이해하고 나타낼 수 있는 능력을 기르는 것이다

 

 

먼저 파티션 테이블 두 개(SALES_PT,SALES_ORDER_PT)를 만들고, 파티션이 되지 않은 테이블 두 개(SALES_NO_PT, SALES_ORDER_NO_PT)를 만든다. SALES_PT SALES_NO_PT의 차이점은 전자는 파티셔닝이 적용되고 후자는 적용되지 않았다는 것이다. SALES_ORDER_PT SALES_ORDER_NO_PT의 차이점도 마찬가지다.

 

 

테이블 생성 스크립트 다운로드

 

Table Generation.SQL

환경: 오라클 11.2.0.1 
     
모든 테이블은 대략 92만 건이다.
      SALES_PT, SALES_ORDER_PT 두 테이블의 파티션 키 컬럼은 TIME_ID 이다. 

 

 

먼저 파티션이 되지 않은 테이블 두 개를 이용하여 HASH JOIN으로 실행해보자.

 

SELECT /*+ LEADING(T)  */ COUNT(*)                                                       
  FROM SALES_NO_PT S, SALES_ORDER_NO_PT T                                                
 WHERE T.ORDER_DT = TO_DATE('20010101', 'YYYYMMDD')                                      
   AND S.PROD_ID = T.PROD_ID                                                             
   AND S.CUST_ID = T.CUST_ID                                                             
   AND S.TIME_ID = T.TIME_ID                                                             
   AND S.CHANNEL_ID = S.CHANNEL_ID                                                       
   AND S.PROMO_ID = T.PROMO_ID  ;                                                          
                                                                      

------------------------------------------------------------------------------------------
| Id  | Operation           | Name              | Starts | A-Rows |   A-Time   | Buffers |
------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT    |                   |      1 |      1 |00:00:05.20 |    9898 |
|   1 |  SORT AGGREGATE     |                   |      1 |      1 |00:00:05.20 |    9898 |
|*  2 |   HASH JOIN         |                   |      1 |    154 |00:00:05.20 |    9898 |
|*  3 |    TABLE ACCESS FULL| SALES_ORDER_NO_PT |      1 |     88 |00:00:03.12 |    5457 |
|   4 |    TABLE ACCESS FULL| SALES_NO_PT       |      1 |    918K|00:00:01.20 |    4441 |
------------------------------------------------------------------------------------------

                                                                                                                                                                                     

Predicate Information (identified by operation id):                                                         
---------------------------------------------------                                                         
   2 - access("S"."PROD_ID"="T"."PROD_ID" AND "S"."CUST_ID"="T"."CUST_ID" AND "S"."TIME_ID"="T"."TIME_ID" AND
              "S"."PROMO_ID"="T"."PROMO_ID")                                                                
   3 - filter("T"."ORDER_DT"=TO_DATE(' 2001-01-01 00:00:00', 'syyyy-mm-dd hh24:mi:ss'))                     

 

 

파티션이 아님으로 PWJ가 발생하지 않았다. ID 4 BLOCK I/O(Buffers) 4441임을 주목하라. 또한 후행테이블의 건수(A-Row) 918K임을 기억하라. 이제 PWJ 를 실행할 차례다.

 

 

SELECT /*+ LEADING(T) */ COUNT(*)

  FROM SALES_PT S, SALES_ORDER_PT T

 WHERE T.ORDER_DT = TO_DATE('20010101', 'YYYYMMDD')

  AND S.PROD_ID = T.PROD_ID

  AND S.CUST_ID = T.CUST_ID

  AND S.TIME_ID = T.TIME_ID

  AND S.CHANNEL_ID = S.CHANNEL_ID

AND S.PROMO_ID = T.PROMO_ID ;

 

 

--------------------------------------------------------------------------------------------------------

| Id  | Operation            | Name           | Starts | Pstart| Pstop | A-Rows |   A-Time   | Buffers |

--------------------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT     |                |      1 |       |       |      1 |00:00:00.18 |    6263 |

|   1 |  SORT AGGREGATE      |                |      1 |       |       |      1 |00:00:00.18 |    6263 |

|   2 |   PARTITION RANGE ALL|                |      1 |     1 |    28 |    154 |00:00:00.18 |    6263 |

|*  3 |    HASH JOIN         |                |     28 |       |       |    154 |00:00:00.18 |    6263 |

|*  4 |     TABLE ACCESS FULL| SALES_ORDER_PT |     28 |     1 |    28 |     88 |00:00:00.10 |    5947 |

|   5 |     TABLE ACCESS FULL| SALES_PT       |      1 |     1 |    28 |  60608 |00:00:00.02 |     316 |

--------------------------------------------------------------------------------------------------------

 

Outline Data

-------------

 

  /*+

      BEGIN_OUTLINE_DATA

      ...생략

      PX_JOIN_FILTER(@"SEL$1" "S"@"SEL$1")

      END_OUTLINE_DATA

  */

 

Predicate Information (identified by operation id):

---------------------------------------------------

   3 - access("S"."TIME_ID"="T"."TIME_ID" AND "S"."PROD_ID"="T"."PROD_ID" AND "S"."CUST_ID"="T"."CUST_ID" AND

              "S"."PROMO_ID"="T"."PROMO_ID")

   4 - filter("T"."ORDER_DT"=TO_DATE(' 2001-01-01 00:00:00', 'syyyy-mm-dd hh24:mi:ss'))

 

 

조인된 두 테이블은 모두 파티셔닝 되었다. 따라서 PARTITION RANGE ALL이 두 번 나와야 됨에도 불구하고 ID 2을 보면 단 한번만 나온다. PWJ가 실행되었다는 증거다. PWJ가 실행되지 않은 경우와 BLOCK I/O를 비교해 보면 14배 이상 차이가 난다. 바로 이 것이 FULL PWJ가 실행되면 성능에 유리한 이유다. FULL PWJ가 실행되면 후행 테이블의 파티션을 모두 읽을 필요가 없다. 왜냐하면 이미 선행테이블(BUILD INPUT)을 읽는 과정에서 어떤 파티션을 액세스 할 것인지 결정 되었기 때문이다. 따라서 후행테이블의 건수도 918K건이 아니라 59K(60608)에 불과한 것이다.

 

 

위의 Outline Data를 보고 혹자는 'PX_JOIN_FILTER 때문에 PARTITION PRUNING이 일어난 것이다' 고 의혹을 제기한다. 하지만 PX_JOIN_FILTER FULL PWJ는 아무 상관이 없다. 왜냐하면 아래와 같이 NO_PX_JOIN_FILTER 힌트를 사용해서 Filter를 제거해도 PWJ의 효과가 유지되기 때문이다. 

 

 

SELECT /*+ LEADING(T) NO_PX_JOIN_FILTER(S) */ COUNT(*)                                                   

  FROM SALES_PT S, SALES_ORDER_PT T                                                                     

 WHERE T.ORDER_DT = TO_DATE('20010101', 'YYYYMMDD')                                                     

  AND S.PROD_ID = T.PROD_ID                                                                              

  AND S.CUST_ID = T.CUST_ID                                                                             

  AND S.TIME_ID = T.TIME_ID                                                                              

  AND S.CHANNEL_ID = S.CHANNEL_ID                                                                       

  AND S.PROMO_ID = T.PROMO_ID      ;                                                                     

                                                                                                      

--------------------------------------------------------------------------------------------------------

| Id  | Operation            | Name           | Starts | Pstart| Pstop | A-Rows |   A-Time   | Buffers |

--------------------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT     |                |      1 |       |       |      1 |00:00:00.18 |    6263 |

|   1 |  SORT AGGREGATE      |                |      1 |       |       |      1 |00:00:00.18 |    6263 |

|   2 |   PARTITION RANGE ALL|                |      1 |     1 |    28 |    154 |00:00:00.18 |    6263 |

|*  3 |    HASH JOIN         |                |     28 |       |       |    154 |00:00:00.18 |    6263 |

|*  4 |     TABLE ACCESS FULL| SALES_ORDER_PT |     28 |     1 |    28 |     88 |00:00:00.10 |    5947 |

|   5 |     TABLE ACCESS FULL| SALES_PT       |      1 |     1 |    28 |  60608 |00:00:00.02 |     316 |

--------------------------------------------------------------------------------------------------------

                                                                                                        

Outline Data                                                                                             

-------------                                                                                           

                                                                                                         

  /*+                                                                                                   

      BEGIN_OUTLINE_DATA                                                                                

      IGNORE_OPTIM_EMBEDDED_HINTS                                                                       

      OPTIMIZER_FEATURES_ENABLE('11.2.0.1')                                                             

      DB_VERSION('11.2.0.1')                                                                             

      ALL_ROWS                                                                                          

      OUTLINE_LEAF(@"SEL$1")                                                                            

      FULL(@"SEL$1" "T"@"SEL$1")                                                                        

      FULL(@"SEL$1" "S"@"SEL$1")                                                                        

      LEADING(@"SEL$1" "T"@"SEL$1" "S"@"SEL$1")                                                          

      USE_HASH(@"SEL$1" "S"@"SEL$1")                                                                    

      END_OUTLINE_DATA                                                                                   

  */                                                                                                    

                                                                                                        

Predicate Information (identified by operation id):                                                     

---------------------------------------------------                                                     

   3 - access("S"."TIME_ID"="T"."TIME_ID" AND "S"."PROD_ID"="T"."PROD_ID" AND "S"."CUST_ID"="T"."CUST_ID"

               AND "S"."PROMO_ID"="T"."PROMO_ID")                                                       

   4 - filter("T"."ORDER_DT"=TO_DATE(' 2001-01-01 00:00:00', 'syyyy-mm-dd hh24:mi:ss'))

 

 

 

위의 SQL에서 NO_PX_JOIN_FILTER를 사용하여 FILTER를 제거시켰지만 여전히 PWJ가 실행되었다. 따라서 PX_JOIN_FILTER FULL PWJ의 원리가 아니다. PWJ의 성능향상 원리는 T.ORDER_DT = TO_DATE('20010101', 'YYYYMMDD') 조건에 있다. 다시 말해 이 조건 때문에 후행 테이블은 2001년도 1분기 파티션만 읽으면 되는 것이다. 중요한 점은 T.ORDER_DT 컬럼이 Partition Key 컬럼이 아님에도 성능이 향상되었다는 점이다. 아래의 SQL이 그것을 증명한다.

 

 

SELECT /*+ FULL(SALES) */ COUNT(*)                  

  FROM SALES_PT PARTITION (ST_Q1_2001);

 

-----------------------------------------------------------------------------------------------------

| Id  | Operation               | Name     | Starts | Pstart| Pstop | A-Rows |   A-Time   | Buffers |

-----------------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT        |          |      1 |       |       |      1 |00:00:00.05 |     316 |

|   1 |  SORT AGGREGATE         |          |      1 |       |       |      1 |00:00:00.05 |     316 |

|   2 |   PARTITION RANGE SINGLE|          |      1 |    17 |    17 |  60608 |00:00:00.04 |     316 |

|   3 |    TABLE ACCESS FULL    | SALES_PT |      1 |    17 |    17 |  60608 |00:00:00.02 |     316 |

-----------------------------------------------------------------------------------------------------

 

 

PWJ의 성능개선 원리는 선행집합의 Filter에 있다
FULL PWJ가 발생했을 때와 같이 정확히 316 BLOCK 만 읽었다. PWJ가 실행됨으로써 BLOCK I/O 관점의 성능개선사항은 명확해졌다. 선행집합의 FILTER가 후행집합의 BLOCK I/O를 결정한다는 것이다. 다시 말해 선행집합은 T.ORDER_DT = 상수조건에 상관없이 모든 파티션을 ACCESS 해야 한다. 하지만 후행집합은 T.ORDER_DT = 상수조건에 만족하는 파티션만 ACCESS 하는 것이 PWJ의 성능개선 원리이다.

 

 

Partial PWJ의 성능개선 원리도 FULL PWJ와 같다
FULL PWJ의 성능개선 원리와 Partial PWJ의 성능개선 원리는 같다. 하지만 처리방식이 다르다. Partial PWJ는 내부적으로Bloom Pruning을 이용한다. Bloom Filter를 이용하여 후행테이블의 조인건수를 줄일 수 있는데, Bloom Pruning도 같은 메커니즘을 이용하여 후행 테이블의 파티션 Access 개수를 최소화 하는 것이다. 아래의 SQL Partial PWJ 예제이며, Bloom Pruning을 이용하여 후행집합의 파티션 Access 개수를 최소화 하고 있다. 

 

 

ALTER SESSION SET "_bloom_pruning_enabled" = TRUE;

 

SELECT /*+ LEADING(T) USE_HASH(S) */ COUNT(*)

  FROM SALES_PT S, SALES_ORDER_NO_PT T

 WHERE T.ORDER_DT = TO_DATE('20010101', 'YYYYMMDD')

  AND S.PROD_ID = T.PROD_ID

  AND S.CUST_ID = T.CUST_ID

  AND S.TIME_ID = T.TIME_ID

  AND S.CHANNEL_ID = S.CHANNEL_ID

  AND S.PROMO_ID = T.PROMO_ID        ;

---------------------------------------------------------------------------------------------------------------

| Id  | Operation                     | Name             | Starts| Pstart| Pstop | A-Rows|   A-Time  | Buffers|

---------------------------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT              |                  |      1|       |       |      1|00:00:00.64|    5773|

|   1 |  SORT AGGREGATE               |                  |      1|       |       |      1|00:00:00.64|    5773|

|*  2 |   HASH JOIN                   |                  |      1|       |       |    154|00:00:00.64|    5773|

|   3 |    PART JOIN FILTER CREATE    | :BF0000          |      1|       |       |     88|00:00:00.52|    5457|

|*  4 |     TABLE ACCESS FULL         | SALES_ORDER_NO_PT|      1|       |       |     88|00:00:00.52|    5457|

|   5 |    PARTITION RANGE JOIN-FILTER|                  |      1|:BF0000|:BF0000|  60608|00:00:00.05|     316|

|   6 |     TABLE ACCESS FULL         | SALES_PT         |      1|:BF0000|:BF0000|  60608|00:00:00.03|     316|

---------------------------------------------------------------------------------------------------------------

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     

Predicate Information (identified by operation id):                                   
---------------------------------------------------                                   
   2 - access("S"."TIME_ID"="T"."TIME_ID" AND "S"."PROD_ID"="T"."PROD_ID"             
                AND "S"."CUST_ID"="T"."CUST_ID" AND "S"."PROMO_ID"="T"."PROMO_ID")    
   4 - filter("T"."ORDER_DT"=TO_DATE(' 2001-01-01 00:00:00', 'syyyy-mm-dd hh24:mi:ss'))
                                                  

 

                                                                                          

FULL PARTITION WISE JOIN PARTIAL PARTITION WISE JOIN의 성능개선 원리는 같으며, BLOCK I/O도 동일하다. 다른 점은 PARTIAL PARTITION WISE JOINBloom Pruning를 이용했다는 것뿐이다. 다시 말해, 아래처럼 Bloom Pruning 기능을 사용할 수 없게 된다면 PARTIAL PARTITION WISE JOIN시 성능향상(후행 집합의 BLOCK I/O 감소)을 기대할 수 없다.   

 

 

ALTER SESSION SET "_bloom_pruning_enabled" = false;

 

SELECT /*+ LEADING(T) USE_HASH(S) */ COUNT(*)

  FROM SALES_PT S, SALES_ORDER_NO_PT T

 WHERE T.ORDER_DT = TO_DATE('20010101', 'YYYYMMDD')

  AND S.PROD_ID = T.PROD_ID

  AND S.CUST_ID = T.CUST_ID

  AND S.TIME_ID = T.TIME_ID

  AND S.CHANNEL_ID = S.CHANNEL_ID

  AND S.PROMO_ID = T.PROMO_ID     ;

------------------------------------------------------------------------------------------------------------
| Id  | Operation             | Name              | Starts | Pstart| Pstop | A-Rows |   A-Time   | Buffers |
------------------------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT      |                   |      1 |       |       |      1 |00:00:01.22 |   10341 |
|   1 |  SORT AGGREGATE       |                   |      1 |       |       |      1 |00:00:01.22 |   10341 |
|*  2 |   HASH JOIN           |                   |      1 |       |       |    154 |00:00:01.22 |   10341 |
|*  3 |    TABLE ACCESS FULL  | SALES_ORDER_NO_PT |      1 |       |       |     88 |00:00:00.54 |    5457 |
|   4 |    PARTITION RANGE ALL|                   |      1 |     1 |    28 |    918K|00:00:00.31 |    4884 |
|   5 |     TABLE ACCESS FULL | SALES_PT          |     28 |     1 |    28 |    918K|00:00:00.15 |    4884 |
------------------------------------------------------------------------------------------------------------
                                                                                                            
Predicate Information (identified by operation id):                                                         
---------------------------------------------------                                                                                                             
   2 - access("S"."PROD_ID"="T"."PROD_ID" AND "S"."CUST_ID"="T"."CUST_ID" AND "S"."TIME_ID"="T"."TIME_ID" AND
              "S"."PROMO_ID"="T"."PROMO_ID")                                                                
   3 - filter("T"."ORDER_DT"=TO_DATE(' 2001-01-01 00:00:00', 'syyyy-mm-dd hh24:mi:ss'))                     

 

 

_bloom_pruning_enabled 파라미터를 false로 놓고 실행하자 SALES_PT 테이블의 모든 파티션을 Access하게 되었다. 이에 따라 성능도 저하된다. PARTIAL PARTITION WISE JOIN Bloom Pruning를 이용하고 있다는 증거이다.

 

 

Partition PairPWJ의 성능개선 원리가 아니다
많은 책에서 PWJ를 설명하기 위해 Partition Pair라는 용어를 사용하고 있다. 하지만 Partition Pair PWJ의 원리가 아니라, 조인 시 선행집합에 PARTITION PRUNING이 발생한 것뿐이다. 아래의 SQL을 실행시켜 보자.

 

 

SELECT /*+ LEADING(T) */ COUNT(*)

  FROM SALES_PT S, SALES_ORDER_PT T

 WHERE T.ORDER_DT = TO_DATE('20010101', 'YYYYMMDD')

  AND T.TIME_ID BETWEEN TO_DATE('20010101', 'YYYYMMDD') AND TO_DATE('20011231', 'YYYYMMDD')

  AND S.PROD_ID = T.PROD_ID

  AND S.CUST_ID = T.CUST_ID

  AND S.TIME_ID = T.TIME_ID

  AND S.CHANNEL_ID = S.CHANNEL_ID

  AND S.PROMO_ID = T.PROMO_ID     ;

-------------------------------------------------------------------------------------------------------------

| Id  | Operation                 | Name           | Starts | Pstart| Pstop | A-Rows |   A-Time   | Buffers |
-------------------------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT          |                |      1 |       |       |      1 |00:00:00.08 |    1984 |
|   1 |  SORT AGGREGATE           |                |      1 |       |       |      1 |00:00:00.08 |    1984 |
|   2 |   PARTITION RANGE ITERATOR|                |      1 |    17 |    20 |    154 |00:00:00.08 |    1984 |
|*  3 |    HASH JOIN              |                |      4 |       |       |    154 |00:00:00.08 |    1984 |
|*  4 |     TABLE ACCESS FULL     | SALES_ORDER_PT |      4 |    17 |    20 |     88 |00:00:00.02 |    1668 |
|*  5 |     TABLE ACCESS FULL     | SALES_PT       |      1 |    17 |    20 |  60608 |00:00:00.02 |     316 |
-------------------------------------------------------------------------------------------------------------
                                                                                                            
Predicate Information (identified by operation id):                                                         
---------------------------------------------------                                                         
   3 - access("S"."TIME_ID"="T"."TIME_ID" AND "S"."PROD_ID"="T"."PROD_ID" AND "S"."CUST_ID"="T"."CUST_ID" AND
              "S"."PROMO_ID"="T"."PROMO_ID")                                                                
   4 - filter(("T"."ORDER_DT"=TO_DATE(' 2001-01-01 00:00:00', 'syyyy-mm-dd hh24:mi:ss')                     
                AND "T"."TIME_ID"<=TO_DATE(' 2001-12-31 00:00:00', 'syyyy-mm-dd hh24:mi:ss')))              
   5 - filter("S"."TIME_ID"<=TO_DATE(' 2001-12-31 00:00:00', 'syyyy-mm-dd hh24:mi:ss'))                     

 

 

 

T.TIME_ID 조건에 의해서 선행집합에 PARTITION PRUNING이 발생하여 전체 파티션이 아닌 2001년도 파티션 4개만 읽으면 된다. 이에 따라 선행 테이블의 I/O 5947에서 1668로 줄어들었다. 비록 선행집합의 I/O가 줄어들었지만, PWJ의 성능 개선 원리인 '선행집합의 FILTER 조건이 후행집합의 BLOCK I/O를 결정한다'는 변치 않는다. 즉 선행테이블의 두 조건인 T.ORDER_DT = 조건과 T.TIME_ID BETWEEN 조건의 교집합이 후행테이블의 ACCESS 범위가 되는 것이다. 만약 PWJ의 성능개선 원리가 없고 Partition Pair만 존재했다면 후행집합이 2001년에 해당하는 파티션 4개를 모두 읽어야 할 것이다. 하지만 위에서 보는 것처럼 후행집합은 단 하나의 파티션만 Access 한다.

 

물론 아무런 조건 없이 Partition Key 조건만 있다면 아래처럼 Partition Pair가 되기는 한다.

 

 

SELECT /*+ LEADING(T) USE_HASH(S) */ COUNT(*)

  FROM SALES_PT S, SALES_ORDER_PT T

 WHERE T.TIME_ID BETWEEN TO_DATE('20010101', 'YYYYMMDD') AND TO_DATE('20011231', 'YYYYMMDD')

  AND S.PROD_ID = T.PROD_ID

  AND S.CUST_ID = T.CUST_ID

  AND S.TIME_ID = T.TIME_ID

  AND S.CHANNEL_ID = S.CHANNEL_ID

  AND S.PROMO_ID = T.PROMO_ID     ;

-------------------------------------------------------------------------------------------------------------
| Id  | Operation                 | Name           | Starts | Pstart| Pstop | A-Rows |   A-Time   | Buffers |
-------------------------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT          |                |      1 |       |       |      1 |00:00:00.61 |    3022 |
|   1 |  SORT AGGREGATE           |                |      1 |       |       |      1 |00:00:00.61 |    3022 |
|   2 |   PARTITION RANGE ITERATOR|                |      1 |    17 |    20 |    464K|00:00:00.58 |    3022 |
|*  3 |    HASH JOIN              |                |      4 |       |       |    464K|00:00:00.50 |    3022 |
|*  4 |     TABLE ACCESS FULL     | SALES_ORDER_PT |      4 |    17 |    20 |    259K|00:00:00.05 |    1668 |
|*  5 |     TABLE ACCESS FULL     | SALES_PT       |      4 |    17 |    20 |    259K|00:00:00.05 |    1354 |
-------------------------------------------------------------------------------------------------------------
                                                                                                            
Predicate Information (identified by operation id):                                                         
---------------------------------------------------                                                         
   3 - access("S"."TIME_ID"="T"."TIME_ID" AND "S"."PROD_ID"="T"."PROD_ID" AND "S"."CUST_ID"="T"."CUST_ID" AND
              "S"."PROMO_ID"="T"."PROMO_ID")                                                                
   4 - filter("T"."TIME_ID"<=TO_DATE(' 2001-12-31 00:00:00', 'syyyy-mm-dd hh24:mi:ss'))                     
   5 - filter("S"."TIME_ID"<=TO_DATE(' 2001-12-31 00:00:00', 'syyyy-mm-dd hh24:mi:ss'))                     

 

 

 

T.ORDER_DT 조건을 삭제하고 T.TIME_ID 조건만 있으므로 정확히 두 테이블은 Pair이다. 즉 두 테이블은 2001년 파티션을 각각 4개씩 읽었다. PWJ 발생시 BLOCK I/O 관점의 성능개선 원리는 FULL PWJ이냐 아니면 Partial PWJ이냐에 따라 변치 않으며 아래처럼 정의 할 수 있다.

 

1. 선행집합의 파티션 ACCESS 범위는 PARTITION KEY 조건에 의한 Partition Pruning에 의해 결정된다. 하지만 이 기능은 PWJ의 장점이 아니다. 왜냐하면 조인이 아닌 경우도 실행되기 때문이다.


2. 후행집합의 파티션 ACCESS 범위는 선행집합의 모든 FILTER에 의해 결정된다. 이 기능이야 말로 Partition Wise Join의 장점이다. 왜냐하면 조인에 의해서 성능이 향상되기 때문이다 

                                                                                                                                                                                                                                                                                    

 

PWJ 성능향상의 두 번째 원리 

이제 BLOCK I/O관점에서 성능개선 원리가 밝혀졌으므로, PGA 사용량 관점에서 PWJ의 성능개선 원리를 나타내 보자.

 

 

SELECT /*+ LEADING(T)  */ COUNT(*)

  FROM SALES_NO_PT S, SALES_ORDER_NO_PT T

 WHERE S.PROD_ID = T.PROD_ID

   AND S.CUST_ID = T.CUST_ID

   AND S.TIME_ID = T.TIME_ID

   AND S.CHANNEL_ID = S.CHANNEL_ID

   AND S.PROMO_ID = T.PROMO_ID   ;

-----------------------------------------------------------------------------------------------------
| Id  | Operation           | Name              | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |
-----------------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT    |                   |      1 |      1 |00:00:04.22 |    9898 |          |
|   1 |  SORT AGGREGATE     |                   |      1 |      1 |00:00:04.22 |    9898 |          |
|*  2 |   HASH JOIN         |                   |      1 |   1418K|00:00:04.00 |    9898 |   54M (0)|
|   3 |    TABLE ACCESS FULL| SALES_ORDER_NO_PT |      1 |    918K|00:00:00.40 |    5457 |          |
|   4 |    TABLE ACCESS FULL| SALES_NO_PT       |      1 |    918K|00:00:00.36 |    4441 |          |
-----------------------------------------------------------------------------------------------------
                                                                                                    
Predicate Information (identified by operation id):                                                 
---------------------------------------------------                                                 
   2 - access("S"."PROD_ID"="T"."PROD_ID" AND "S"."CUST_ID"="T"."CUST_ID"                           
               AND "S"."TIME_ID"="T"."TIME_ID" AND "S"."PROMO_ID"="T"."PROMO_ID")                   

  

 

PWJ가 실행되지 않는 경우 PGA 54MB나 소모하였다. 이제 PWJ를 실행시켜 성능이 얼마나 개선되는지 알아보자.

 

  

SELECT /*+ LEADING(T)  */ COUNT(*)

  FROM SALES_PT S, SALES_ORDER_PT T

 WHERE S.PROD_ID = T.PROD_ID

   AND S.CUST_ID = T.CUST_ID

   AND S.TIME_ID = T.TIME_ID

   AND S.CHANNEL_ID = S.CHANNEL_ID

   AND S.PROMO_ID = T.PROMO_ID   ;

--------------------------------------------------------------------------------------------------------------

| Id  | Operation            | Name           | Starts | Pstart| Pstop| A-Rows |   A-Time  | Buffers|Used-Mem |

---------------------------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT     |                |      1 |       |      |      1 |00:00:04.48|   10795|         |

|   1 |  SORT AGGREGATE      |                |      1 |       |      |      1 |00:00:04.48|   10795|         |

|   2 |   PARTITION RANGE ALL|                |      1 |     1 |    28|   1418K|00:00:04.26|   10795|         |

|*  3 |    HASH JOIN         |                |     28 |       |      |   1418K|00:00:03.69|   10795|5008K (0)|

|   4 |     TABLE ACCESS FULL| SALES_ORDER_PT |     28 |     1 |    28|    918K|00:00:00.34|    5947|         |

|   5 |     TABLE ACCESS FULL| SALES_PT       |     16 |     1 |    28|    918K|00:00:00.34|    4848|         |

---------------------------------------------------------------------------------------------------------------

                                                                                                                                                                                                                                                                                                            

Predicate Information (identified by operation id):                             
---------------------------------------------------                             
   3 - access("S"."TIME_ID"="T"."TIME_ID" AND "S"."PROD_ID"="T"."PROD_ID"       
               AND "S"."CUST_ID"="T"."CUST_ID" AND "S"."PROMO_ID"="T"."PROMO_ID")

  

 

PWJ를 실행하니 PGA 사용량이 11배나 줄어들었다. PWJ란 덩치가 큰 테이블 두 개를 조인시켜야 될 때, 작은 여러 개의 파티션으로 쪼개서 각각 조인시킴으로써 조인의 성능을 향상 시키는 것이다. 이렇게 되면 당연히 PGA 사용량이 급격히 줄어들 것이다.

 

 

한가지 주의 사항이 있다. PWJ가 발생하려면 파티션 기준 컬럼으로 양측 집합을 조인해야 한다. 그런데 파티션 Key 조인 컬럼을 아래처럼 가공하게 되면 PWJ가 발생되지 않으므로 주의하기 바란다 

 

  

 

SELECT /*+ LEADING(T) */ COUNT(*)

  FROM SALES_PT S, SALES_ORDER_PT T

 WHERE T.ORDER_DT = TO_DATE('20010101', 'YYYYMMDD')

  AND S.PROD_ID = T.PROD_ID

  AND S.CUST_ID = T.CUST_ID

  AND S.TIME_ID + 1 = T.TIME_ID + 1  --파티션 기준컬럼 가공

  AND S.CHANNEL_ID = S.CHANNEL_ID

  AND S.PROMO_ID = T.PROMO_ID     ;

 

---------------------------------------------------------------------------------------------------------------

| Id  | Operation             | Name           | Starts| Pstart| Pstop|A-Rows |   A-Time   |Buffers |Used-Mem |

---------------------------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT      |                |      1|       |      |     1 |00:00:02.23 |  10831 |         |

|   1 |  SORT AGGREGATE       |                |      1|       |      |     1 |00:00:02.23 |  10831 |         |

|*  2 |   HASH JOIN           |                |      1|       |      |   154 |00:00:02.23 |  10831 |1210K (0)|

|   3 |    PARTITION RANGE ALL|                |      1|     1 |    28|    88 |00:00:00.10 |   5947 |         |

|*  4 |     TABLE ACCESS FULL | SALES_ORDER_PT |     28|     1 |    28|    88 |00:00:00.10 |   5947 |         |

|   5 |    PARTITION RANGE ALL|                |      1|     1 |    28|   918K|00:00:00.70 |   4884 |         |

|   6 |     TABLE ACCESS FULL | SALES_PT       |     28|     1 |    28|   918K|00:00:00.33 |   4884 |         |

---------------------------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):                                                                  

---------------------------------------------------                                                                   

   2 - access("S"."PROD_ID"="T"."PROD_ID" AND "S"."CUST_ID"="T"."CUST_ID" AND                                        

              INTERNAL_FUNCTION("S"."TIME_ID")+1=INTERNAL_FUNCTION("T"."TIME_ID")+1 AND "S"."PROMO_ID"="T"."PROMO_ID")

   4 - filter("T"."ORDER_DT"=TO_DATE(' 2001-01-01 00:00:00', 'syyyy-mm-dd hh24:mi:ss'))

 

 

 

FULL PWJ를 가능하게 하는 파라미터는 _full_pwise_join_enabled 이며  Default True이다. Partial PWJ를 가능하게 하는 기능은 Bloom Pruning이며 파라미터는 _bloom_pruning_enabled이다. 이 파라미터의 Default값은 True이다.

 

 

결론

PARTITION WISE JOIN을 설명하는데 PARALLEL + PQ_DISTIRBUTE 힌트 조합은 필요 없다PARALLEL Operation을 사용할 때 옵티마이저가 잘못된 분배방식을 사용하여 PARTITION WISE JOIN이 실행되지 못할 수 있다. 이때 사용할 수 있는 힌트가 PQ_DISTIRBUTE이며, 이것은 활용법일 뿐이다.

 

 
PARTITION PAIR라는 용어 때문에 미리 짝지어 놓았다고 상상함으로써, 마치 조인되는 양측 Partition이 항상 Pair하게 I/O를 한다고 잘못 생각하게 만든다. PWJ의 성능상 장점은 선행집합의 FILTER에 의해서 후행집합에 Access할 파티션의 개수가 줄어든다는 것이다. 따라서 I/O PAIR하게 발생되지 않는다. 'PARTITION PAIR로 동작한다'라는 개념은 선행집합에 조건이 하나도 없는 경우이거나 혹은 Partition Key로만 조건이 들어오는 경우뿐이다. 다시 말해 선행집합에 Partition Key조건 이외의 조건이 있다면 PARTITION PAIR를 보장하지 않는다
.   

 

 

PWJ가 실행될 때 성능개선사항 세 가지

첫 번째, 조인 선행집합에 Partition Key 조건이 있으면 Partition Access 범위도 줄어듦으로 BLOCK I/O량도 줄어든다.

 

두 번째, 선행집합의 모든 FILTER(Partition Key 조건을 포함한)에 의해서 후행집합의 Partition Access 범위가 줄어듦으로 BLOCK I/O량이 줄어든다.

 

세 번째, JOIN시 큰 테이블을 상대적으로 작은 파티션으로 쪼개어 각각 조인함으로써 PGA 사용량이 감소된다. 

 

 

첫 번째 개선사항을 정확히 말하면 PWJ의 기능이 아니라 일반적인 Partition Pruning에 의한 성능향상이다. Partition Wise Join의 성능개선은 조인에 의해서 성능이 향상되어야 함으로 두 번째, 세 번째가 진정한 PWJ의 성능개선사항이다.

 

이제 필자의 차례는 끝나고, 여러분의 차례이다. 주위 사람들에게 PWJ의 정의를 다시 알려주기 바란다.

 

Posted by extremedb
,

SQL에서 DISTINCT의 위치는 중요하다. DISTINCT가 메인쿼리에 위치하면 조인이 모두 처리된 후 DISTINCT가 실행된다.
그 반대로 각각의 집합을 DISTINCT 한 후에 조인한다면 양측 집합의 건수가 줄어들므로 조인의 부하가 줄어든다. 그런 관점에서 보면 아래의 SQL은 최악이다.
 

환경: ORACLE 11.2

SELECT /*+ qb_name(MAIN) LEADING(S@INLINE) USE_NL(C@MAIN) */
       DISTINCT c.channel_id, c.channel_desc, s.prod_id, s.promo_id
   FROM channels c,
        (SELECT /*+ qb_name(INLINE) NO_MERGE */
                s.channel_id, s.prod_id, promo_id
           FROM sales_t s
          WHERE prod_id BETWEEN 13 AND 15) s
  WHERE c.channel_id = s.channel_id ;


 
---------------------------------------------------------------------------------------------------------
| Id  | Operation                     | Name        | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |
---------------------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT              |             |      1 |     22 |00:00:00.22 |   22222 |          |
|   1 |  HASH UNIQUE                  |             |      1 |     22 |00:00:00.22 |   22222 | 1271K (0)|
|   2 |   NESTED LOOPS                |             |      1 |  17778 |00:00:00.21 |   22222 |          |
|   3 |    NESTED LOOPS               |             |      1 |  17778 |00:00:00.16 |    4444 |          |
|*  4 |     TABLE ACCESS FULL         | SALES_T     |      1 |  17778 |00:00:00.11 |    4440 |          |
|*  5 |     INDEX UNIQUE SCAN         | CHANNELS_PK |  17778 |  17778 |00:00:00.03 |       4 |          |
|   6 |    TABLE ACCESS BY INDEX ROWID| CHANNELS    |  17778 |  17778 |00:00:00.03 |   17778 |          |
---------------------------------------------------------------------------------------------------------
 
Predicate Information (identified by operation id):
---------------------------------------------------
   4 - filter(("PROD_ID">=13 AND "PROD_ID"<=15))
   5 - access("C"."CHANNEL_ID"="S"."CHANNEL_ID")

 

위의 SQL을 보면 인라인뷰 S에 미리 건수를 줄이지 않아서 조인이 17778번 발생하였다. 다시 말해 조인하기 전에 인라인뷰 S DISTINCT 작업이 있었다면 조인을 22번만 하면 된다따라서 전체 DISTINCT 작업은 필요 없다. 아래는 튜닝된 SQL이다.

SELECT /*+ qb_name(main) */
       c.channel_id, c.channel_desc, s.prod_id, s.promo_id
   FROM channels c,
        (SELECT /*+ qb_name(inline) */
                DISTINCT s.channel_id, s.prod_id, promo_id
           FROM sales_t s
          WHERE prod_id BETWEEN 13 AND 15) s
  WHERE c.channel_id = s.channel_id ; 


 
--------------------------------------------------------------------------------------------------------
| Id  | Operation                    | Name        | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |
--------------------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT             |             |      1 |     22 |00:00:00.12 |    4466 |          |
|   1 |  NESTED LOOPS                |             |      1 |     22 |00:00:00.12 |    4466 |          |
|   2 |   NESTED LOOPS               |             |      1 |     22 |00:00:00.12 |    4444 |          |
|   3 |    VIEW                      |             |      1 |     22 |00:00:00.12 |    4440 |          |
|   4 |     HASH UNIQUE              |             |      1 |     22 |00:00:00.12 |    4440 | 1264K (0)|
|*  5 |      TABLE ACCESS FULL       | SALES_T     |      1 |  17778 |00:00:00.11 |    4440 |          |
|*  6 |    INDEX UNIQUE SCAN         | CHANNELS_PK |     22 |     22 |00:00:00.01 |       4 |          |
|   7 |   TABLE ACCESS BY INDEX ROWID| CHANNELS    |     22 |     22 |00:00:00.01 |      22 |          |
--------------------------------------------------------------------------------------------------------

Predicate Information (identified by operation id):
---------------------------------------------------
   5 - filter(("PROD_ID">=13 AND "PROD_ID"<=15))
   6 - access("C"."CHANNEL_ID"="S"."CHANNEL_ID")

 

미리 건수를 줄였으므로 22번만 조인하여 BLOCK I/O 22222에서 4466으로 약 4~5배 줄어들었다. 이런 SQL 튜닝은 오라클 11.2에서는 더 이상 필요 없다. 아래의 SQL을 보자.

SELECT /*+ qb_name(main) */
       DISTINCT c.channel_id, c.channel_desc, s.prod_id, s.promo_id
   FROM channels c,
        (SELECT /*+ qb_name(inline) */
                s.channel_id, s.prod_id, promo_id
           FROM sales_t s
          WHERE prod_id BETWEEN 13 AND 15) s
  WHERE c.channel_id = s.channel_id ;


 
-------------------------------------------------------------------------------------------------------------
| Id  | Operation                     | Name            | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |
-------------------------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT              |                 |      1 |     22 |00:00:00.09 |    4466 |          |
|   1 |  HASH UNIQUE                  |                 |      1 |     22 |00:00:00.09 |    4466 | 1218K (0)|
|   2 |   NESTED LOOPS                |                 |      1 |     22 |00:00:00.09 |    4466 |          |
|   3 |    NESTED LOOPS               |                 |      1 |     22 |00:00:00.09 |    4444 |          |
|   4 |     VIEW                      | VW_DTP_2F839831 |      1 |     22 |00:00:00.09 |    4440 |          |
|   5 |      HASH UNIQUE              |                 |      1 |     22 |00:00:00.09 |    4440 | 1283K (0)|
|*  6 |       TABLE ACCESS FULL       | SALES_T         |      1 |  17778 |00:00:00.08 |    4440 |          |
|*  7 |     INDEX UNIQUE SCAN         | CHANNELS_PK     |     22 |     22 |00:00:00.01 |       4 |          |
|   8 |    TABLE ACCESS BY INDEX ROWID| CHANNELS        |     22 |     22 |00:00:00.01 |      22 |          |
-------------------------------------------------------------------------------------------------------------

Outline Data
-------------
  /*+
      BEGIN_OUTLINE_DATA
      ...생략 
      PLACE_DISTINCT(@"SEL$8FA4BC11" "S"@"INLINE")--> 2 DISTINCT를 추가한 뷰 VW_DTP_2F839831를 만듦
      ...생략
      MERGE(@"INLINE")                            --> 1 먼저 MERGE를 진행함
      ...생략
      END_OUTLINE_DATA
  */
 
Predicate Information (identified by operation id):
---------------------------------------------------
   6 - filter(("PROD_ID"<=15 AND "PROD_ID">=13))
   7 - access("C"."CHANNEL_ID"="ITEM_1")

 

SQL이 비효율 적으로 작성되었지만 Logical Optimizer가 Distinct를 추가하여 쿼리를 재 작성하였다. 이 쿼리변환을 Distinct Placement(DP) 라고 한다. DP는 주의해야 될 점이 있다. 인라인뷰 S를 해체(MERGE)하고 Distinct를 추가한 인라인뷰를 새로 만든다. 따라서 인라인뷰 S NO_MERGE 힌트를 사용한다면 결코 DP가 발생하지 않는다. 이 글에서 소개된 첫 번째 SQL NO_MERGE 힌트가 사용됨으로써 DP가 발생되지 않은 것이다.

DP는 약간의 비효율이 있다. 즉 필요 없는 전체 Distinct 작업이 수행된다. 실행계획을 보면 HASH UNIQUE가 두 번 존재하는데, 마지막 전체 Distinct(id 1)는 필요 없다.  SQL을 아래처럼 재 작성 하였기 때문에 불필요한 HASH UNIQUE가 추가된 것이다.

SELECT  DISTINCT              --> 필요 없는 DISTINCT 
        C.CHANNEL_ID CHANNEL_ID,
        C.CHANNEL_DESC CHANNEL_DESC,
        VW_DTP_2F839831.ITEM_2 PROD_ID,
        VW_DTP_2F839831.ITEM_3 PROMO_ID
   FROM (SELECT DISTINCT
                
S.CHANNEL_ID ITEM_1,
                 S.PROD_ID ITEM_2,
                 S.PROMO_ID ITEM_3
            FROM TLO.SALES_T S
           WHERE S.PROD_ID <= 50
             AND S.PROD_ID >= 13
             AND 50 >= 13) VW_DTP_2F839831,
        TLO.CHANNELS C
  WHERE C.CHANNEL_ID = VW_DTP_2F839831.ITEM_1
;

 

따라서 아직까지는 사람이 튜닝하는 것을 따라올 수 없다.

힌트는 PLACE_DISTINCT/NO_PLACE_DISTINCT를 사용할 수 있으며 _optimizer_distinct_placement 파라미터로 기능을 컨트롤 할 수 있다. 이 파리미터의 Default값은 True이다. DP Cost Based Query Transformation에 속한다. Search Type Iteration이 존재하기 때문이다. 10053 Trace의 내용을 보면 더 확실히 알 수 있다.

 

****************************************
Cost-Based Group-By/Distinct Placement
****************************************
GBP/DP: Checking validity of GBP/DP for query block SEL$8FA4BC11 (#1)
GBP: Checking validity of group-by placement for query block SEL$8FA4BC11 (#1)
GBP: Bypassed: Query has invalid constructs.
DP: Checking validity of distinct placement for query block SEL$8FA4BC11 (#1)

DP: Using search type: linear
DP: Considering distinct placement on query block SEL$8FA4BC11 (#1)
DP: Starting iteration 1, state space = (1) : (0)
DP: Original query
******* UNPARSED QUERY IS *******
SELECT /*+ QB_NAME ("INLINE") QB_NAME ("MAIN") */ DISTINCT "C"."CHANNEL_ID" "CHANNEL_ID","C"."CHANNEL_DESC" "CHANNEL_DESC","S"."PROD_ID" "PROD_ID","S"."PROMO_ID" "PROMO_ID" FROM "TLO"."CHANNELS" "C","TLO"."SALES_T" "S" WHERE "C"."CHANNEL_ID"="S"."CHANNEL_ID" AND "S"."PROD_ID">=13 AND "S"."PROD_ID"<=15
FPD: Considering simple filter push in query block SEL$8FA4BC11 (#1)
"C"."CHANNEL_ID"="S"."CHANNEL_ID" AND "S"."PROD_ID">=13 AND "S"."PROD_ID"<=15
try to generate transitive predicate from check constraints for query block SEL$8FA4BC11 (#1)
finally: "C"."CHANNEL_ID"="S"."CHANNEL_ID" AND "S"."PROD_ID">=13 AND "S"."PROD_ID"<=15 AND 13<=15

FPD:   transitive predicates are generated in query block SEL$8FA4BC11 (#1)
"C"."CHANNEL_ID"="S"."CHANNEL_ID" AND "S"."PROD_ID">=13 AND "S"."PROD_ID"<=15 AND 13<=15
DP: Costing query block.
CBQT: Looking for cost annotations for query block SEL$8FA4BC11, key = SEL$8FA4BC11_00000000_0
CBQT: Could not find stored cost annotations.
kkoqbc: optimizing query block SEL$8FA4BC11 (#1)

...생략
kkoqbc: finish optimizing query block SEL$8FA4BC11 (#1)
CBQT: Saved costed qb# 1 (SEL$8FA4BC11), key = SEL$8FA4BC11_00000000_0
DP: Updated best state, Cost = 1237.16

먼저 DP가 실행될 수 있는지 Validity Checking을 한다. DP를 실행하는데 문제가 없다면 Iteration 1 에서 변환되지 않은
SQL(Original query)을 보여주고 Cost를 구한다그결과 변환되지 않은 쿼리의 Cost1237.16이다. 이제 변환된 SQL COST
구할 차례이다
.
  

DP: Starting iteration 2, state space = (1) : (1)
DP: Using DP transformation in this iteration.
Registered qb: SEL$2F839831 0x11c3c2dc (QUERY BLOCK TABLES CHANGED SEL$8FA4BC11)
---------------------
QUERY BLOCK SIGNATURE
---------------------
  signature (): qb_name=SEL$2F839831 nbfros=2 flg=0
    fro(0): flg=0 objn=75859 hint_alias="C"@"MAIN"
    fro(1): flg=5 objn=0 hint_alias="VW_DTP_2F839831"@"SEL$2F839831"

Registered qb: SEL$DC663686 0x11c3b800 (SPLIT/MERGE QUERY BLOCKS SEL$2F839831)
---------------------
QUERY BLOCK SIGNATURE
---------------------
  signature (): qb_name=SEL$DC663686 nbfros=1 flg=0
    fro(0): flg=0 objn=76170 hint_alias="S"@"INLINE"

Registered qb: SEL$7323A7B6 0x11c3c2dc (VIEW ADDED SEL$2F839831)
---------------------
QUERY BLOCK SIGNATURE
---------------------
  signature (): qb_name=SEL$7323A7B6 nbfros=2 flg=0
    fro(0): flg=0 objn=75859 hint_alias="C"@"MAIN"
    fro(1): flg=1 objn=0 hint_alias="VW_DTP_2F839831"@"SEL$2F839831"

Registered qb: SEL$10E34D75 0x11c3c2dc (DISTINCT PLACEMENT SEL$8FA4BC11; SEL$8FA4BC11; "S"@"INLINE")
---------------------
QUERY BLOCK SIGNATURE
---------------------
  signature (): qb_name=SEL$10E34D75 nbfros=2 flg=0
    fro(0): flg=0 objn=75859 hint_alias="C"@"MAIN"
    fro(1): flg=1 objn=0 hint_alias="VW_DTP_2F839831"@"SEL$2F839831"

Iteration 2에는 DP가 적용된 SQL Cost를 구한다. 여기서 DP가 수행되는 절차를 QUERY BLOCK SIGNATURE에서 볼 수 있다. 먼저 VIEW MERGE가 발생된다.(MERGE QUERY BLOCKS 부분 참조) 그 후 SALES 테이블이 포함된 뷰를 메인쿼리에 추가한다.(VIEW ADDED 부분 참조). 마지막으로 추가된 인라인뷰에 Distinct를 추가한다. (DISTINCT PLACEMENT 부분 참조)

 

DP: Transformed query
******* UNPARSED QUERY IS *******
SELECT /*+ QB_NAME ("INLINE") QB_NAME ("MAIN") */ DISTINCT "C"."CHANNEL_ID" "CHANNEL_ID","C"."CHANNEL_DESC" "CHANNEL_DESC","VW_DTP_2F839831"."ITEM_2" "PROD_ID","VW_DTP_2F839831"."ITEM_3" "PROMO_ID" FROM  (SELECT DISTINCT "S"."CHANNEL_ID" "ITEM_1","S"."PROD_ID" "ITEM_2","S"."PROMO_ID" "ITEM_3" FROM "TLO"."SALES_T" "S" WHERE "S"."PROD_ID"<=15 AND "S"."PROD_ID">=13) "VW_DTP_2F839831","TLO"."CHANNELS" "C" WHERE "C"."CHANNEL_ID"="VW_DTP_2F839831"."ITEM_1"
FPD: Considering simple filter push in query block SEL$10E34D75 (#1)
"C"."CHANNEL_ID"="VW_DTP_2F839831"."ITEM_1"
try to generate transitive predicate from check constraints for query block SEL$10E34D75 (#1)
finally: "C"."CHANNEL_ID"="VW_DTP_2F839831"."ITEM_1"

...생략
kkoqbc: finish optimizing query block SEL$10E34D75 (#1)
CBQT: Saved costed qb# 2 (SEL$DC663686), key = SEL$DC663686_00001000_2
CBQT: Saved costed qb# 1 (SEL$10E34D75), key = SEL$10E34D75_00000008_0
DP: Updated best state, Cost = 1236.23
DP: Doing DP on the preserved QB.

이제 쿼리변환이 끝났으므로 변경된 SQL을 보여주고 Costing을 시작한다. DP가 적용된 SQL Cost 1236.23임으로 원본 쿼리의 Cost에 비해 저렴하다. 따라서 DP가 선택된다.(Doing DP 부분 참조)

 

이로써 졸저 The Logical Optimizer의 416페이지 미해결 과제에서 약속한 것을 지켰다. DP의 예제가 발견되면 블로그와 책에 반영하기로 약속 했었다. 출력을 해서 책의 416페이지에 끼워넣기 바란다. 2011년에 DP를 발견했지만 여러가지 문제로 반영하지 못하다가 이제서야 올리게 되었다. 사과드린다.

Posted by extremedb
,

집계함수 내부에 Distinct를 사용할 수 있다는 것은 많은 사람들이 알고 있다. 하지만 실제로 그렇게 사용했을 때 내부적으로 무슨 일이 일어나는지 아는 사람은 드물다. 한걸음 더 나아가서 COUUNT(COL) 대신에 COUNT(Distinct COL)를 사용했다면 분명히 추가적인 부하가 존재할 것인데, 그 부하를 어떻게 해결할 것인가를 아는 사람은 거의 없을 것이다. 만약 그렇다면 SQL을 실행할 때 마다 성능이 느려질 것이고 문제를 해결할 수 없을 것이다. 여러분들에게는 그런 일이 발생하지 않는다. 이미 이 글을 읽고 있기 때문이다.

이 글은 위에서 언급된 두 가지 문제를 다룬다. 즉 내부적으로 어떤 변화가 발생하는지 알아보고, 추가적인 부하를 어떻게 없앨 수 있는지도 연구해보자. 

SQL 변경에 따른 내부적인 변화를 알아보는 가장 좋은 방법은 비교하는 것이다. 다시 말해, COUUNT(COL)로 실행했을 때의 일량과 COUNT(Distinct COL)로 사용했을 때의 일량을 비교해 보는 것이다. 따라서 우리는 SQL 두 개를 실행한 다음 각각의 작업량(실행통계)을 비교할 것이다.

환경: 오라클 11.2.0.1

CREATE TABLE SALE_T AS SELECT * FROM SALES;                                             
                                                                                        
SELECT /*+ NO_USE_HASH_AGGREGATION */                                                   
        S.PROD_ID                                                                       
       ,COUNT(S.CHANNEL_ID)                                                             
       ,SUM(S.AMOUNT_SOLD)                                                              
       ,SUM(S.QUANTITY_SOLD)                                                            
  FROM SALE_T S                                                                         
 GROUP BY S.PROD_ID;                                                                    
                                                                                        
-----------------------------------------------------------------------------------------
| Id  | Operation          | Name   | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |
-----------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT   |        |      1 |     72 |00:00:01.12 |    4440 |          |
|   1 |  SORT GROUP BY     |        |      1 |     72 |00:00:01.12 |    4440 | 6144  (0)|
|   2 |   TABLE ACCESS FULL| SALE_T |      1 |    918K|00:00:00.32 |    4440 |          |
-----------------------------------------------------------------------------------------

위의 SQL이 실행되는데 시간이 1.12초 걸렸고 PGA 6144 Byte를 소모하였다. 그런데 아래처럼 COUNT DISTINCT를 추가를 추가한다면 어떻게 될까?

 SELECT /*+ NO_QUERY_TRANSFORMATION */                                                  
        S.PROD_ID                                                                       
       ,COUNT(DISTINCT S.CHANNEL_ID)                                                    
       ,SUM(S.AMOUNT_SOLD)                                                              
       ,SUM(S.QUANTITY_SOLD)                                                            
  FROM SALE_T S                                                                         
 GROUP BY S.PROD_ID;                                                                    
                                                                                        
-----------------------------------------------------------------------------------------
| Id  | Operation          | Name   | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |
-----------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT   |        |      1 |     72 |00:00:02.20 |    4440 |          |
|   1 |  SORT GROUP BY     |        |      1 |     72 |00:00:02.20 |    4440 |14336  (0)|
|   2 |   TABLE ACCESS FULL| SALE_T |      1 |    918K|00:00:00.33 |    4440 |          |
-----------------------------------------------------------------------------------------
 

작업량이 증가된 이유
Distinct 만 추가했을 뿐인데 시간이 약 두 배나 걸리고 PGA도 약 두 배로 사용하였다. 그 이유는 Operation에는 나오지 않지만 내부적으로 SORT UNIQUE가 실행되기 때문이다. PROD_ID별로 SORT GROUP BY를 했음에도 CHANNEL_ID 별로 SORT UNIQUE를 다시 실행해야 한다. 92만 건의 데이터를 CHANNEL_ID 별로 SORT한 후에 중복을 제거하는 작업이 Distinct에 의해서 추가된 것이다. 그렇기 때문에 FULL TABLE SCAN의 수행시간은 거의 같지만 SORT GROUP BY의 수행시간이 0.8초에서 1.87초로 늘어나고 PGA사용량도 두 배가 된 것이다.
 

비효율을 제거하는 방법
첫 번째 의문점인 집계함수에 Distinct가 추가되면 어떤 일이 발생하는지 알아냈다. 그렇다면 두 번째 문제인 비효율(추가적인 Sort와 중복제거)을 없애는 방법은 무엇일까? SQL을 아래처럼 튜닝 할 수 있을 것이다. 

SELECT /*+ NO_USE_HASH_AGGREGATION */                                                     
        PROD_ID,                                                                          
        COUNT(S.CHANNEL_ID),                                                              
        SUM(S.AMOUNT_SOLD),                                                               
        SUM(S.QUANTITY_SOLD)                                                              
 FROM  (SELECT /*+ NO_USE_HASH_AGGREGATION */                                             
                 S.CHANNEL_ID ,                                                           
                 S.PROD_ID ,                                                              
                 SUM(S.AMOUNT_SOLD) AMOUNT_SOLD,                                          
                 SUM(S.QUANTITY_SOLD) QUANTITY_SOLD                                       
           FROM SALE_T S                                                                  
          GROUP BY PROD_ID, CHANNEL_ID) S                                                 
GROUP BY S.PROD_ID ;                                                                      
                                                                                          
-------------------------------------------------------------------------------------------
| Id  | Operation            | Name   | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |
-------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT     |        |      1 |     72 |00:00:01.39 |    4440 |          |
|   1 |  SORT GROUP BY NOSORT|        |      1 |     72 |00:00:01.39 |    4440 |          |
|   2 |   VIEW               |        |      1 |    228 |00:00:01.39 |    4440 |          |
|   3 |    SORT GROUP BY     |        |      1 |    228 |00:00:01.39 |    4440 |18432  (0)|
|   4 |     TABLE ACCESS FULL| SALE_T |      1 |    918K|00:00:00.33 |    4440 |          |
------------------------------------------------------------------------------------------- 

비록 PGA 사용량은 약간 늘어났지만 수행시간은 DISTINCT가 없는 SQL과 비슷해졌다. 먼저 PROD_ID, CHANNEL_IDGROUP BY 되었기 때문에 인라인뷰 외부에서는 Distinct를 할 필요가 없다. 다른 말로 표현하면 먼저 GROUP BY했기 때문에 PROD_ID 별로는 CHANNEL_ID UNIQUE 하다. 따라서 인라인뷰 외부에서는 Distinct가 필요 없게 된 것이다.

더 좋은 것은 실행계획의 Id 1을 보면 SORT GROUP BY NOSORT가 나온다. NOSORT가 나온 이유는 인라인뷰가 이미 PROD_ID SORT 되어있기 때문에 더 이상의 SORT는 필요 없기 때문이다. 따라서 추가적인 Group By의 부하는 거의 없다. 이렇게 튜닝하면 Distinct에 의한 SORT UNIQUE의 부하가 대부분 사라진다.

옵티마이저가 사람을 대신한다
집계함수에 Distinct를 사용한다면 무조건 위의 SQL처럼 튜닝 해야 하는가? 그건 아니다. 오라클 11.2를 사용한다면 Logical Optimizer SQL을 자동으로 변경시켜 준다. 아래의 튜닝 되지 않은 SQL을 실행시켜보자.
 

 SELECT /*+ NO_USE_HASH_AGGREGATION(@"SEL$5771D262")  */                                    
        S.PROD_ID                                                                           
       ,COUNT(DISTINCT S.CHANNEL_ID)                                                        
       ,SUM(S.AMOUNT_SOLD)                                                                  
       ,SUM(S.QUANTITY_SOLD)                                                                
  FROM SALE_T S                                                                             
 GROUP BY S.PROD_ID   

---------------------------------------------------------------------------------------------
| Id  | Operation            | Name     | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |
---------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT     |          |      1 |     72 |00:00:01.39 |    4440 |          |
|   1 |  SORT GROUP BY NOSORT|          |      1 |     72 |00:00:01.39 |    4440 |          |
|   2 |   VIEW               | VW_DAG_0 |      1 |    228 |00:00:01.39 |    4440 |          |
|   3 |    SORT GROUP BY     |          |      1 |    228 |00:00:01.39 |    4440 |18432  (0)|
|   4 |     TABLE ACCESS FULL| SALE_T   |      1 |    918K|00:00:00.32 |    4440 |          |
---------------------------------------------------------------------------------------------
                                                                                            
Outline Data                                                                                
-------------                                                                               
  /*+                                                                                       
      BEGIN_OUTLINE_DATA                                                                    
      …생략                                                                                
      TRANSFORM_DISTINCT_AGG(@"SEL$1")                                                      
      …생략                                                                                
      END_OUTLINE_DATA                                                                      
  */                                                                                        
  

오라클이 내부적으로 TRANSFORM_DISTINCT_AGG  힌트를 사용하였고 SQL을 자동으로 변경하였다. 실행계획도 튜닝된 SQL과 같다. 11.2 버전부터는 집계함수내부에 Distinct가 존재하면 Logical Optimizer SQL을 변경시킴으로써 성능이 향상되는 것이다. 이 기능을 Distinct To Aggregation이라고 부른다. 

아래는 10053 Trace 파일의 내용이다. 내용이 많지만 개념은 간단하다. 쿼리변환 전의 SQL을 보여주고 쿼리변환 후의 SQL을 보여준다. 그리고 두 개의 SQL 사이에는 쿼리블럭 SEL$1Distinct To Aggregation 기능에 의해서 두 개로 찢어지는 과정(SPLIT QUERY BLOCK)을 보여준다.

 

DAGG_TRANSFORM: transforming query block SEL$1 (#0)
qbcp (before transform):******* UNPARSED QUERY IS *******
SELECT "S"."PROD_ID" "PROD_ID",COUNT(DISTINCT "S"."CHANNEL_ID") "COUNT(DISTINCTS.CHANNEL_ID)",SUM("S"."AMOUNT_SOLD") "SUM(S.AMOUNT_SOLD)",SUM("S"."QUANTITY_SOLD") "SUM(S.QUANTITY_SOLD)" FROM "TLO"."SALE_T" "S" GROUP BY "S"."PROD_ID"
pgactx->ctxqbc (before transform):******* UNPARSED QUERY IS *******
SELECT "S"."PROD_ID" "PROD_ID",COUNT(DISTINCT "S"."CHANNEL_ID") "COUNT(DISTINCTS.CHANNEL_ID)",SUM("S"."AMOUNT_SOLD") "SUM(S.AMOUNT_SOLD)",SUM("S"."QUANTITY_SOLD") "SUM(S.QUANTITY_SOLD)" FROM "TLO"."SALE_T" "S" GROUP BY "S"."PROD_ID"
Registered qb: SEL$5771D262 0xea51918 (SPLIT QUERY BLOCK FOR DISTINCT AGG OPTIM SEL$1; SEL$1)
---------------------
QUERY BLOCK SIGNATURE
---------------------
  signature (): qb_name=SEL$5771D262 nbfros=1 flg=0
    fro(0): flg=0 objn=76169 hint_alias="S"@"SEL$1"

Registered qb: SEL$C33C846D 0xde78e84 (MAP QUERY BLOCK SEL$5771D262)
---------------------
QUERY BLOCK SIGNATURE
---------------------
  signature (): qb_name=SEL$C33C846D nbfros=1 flg=0
    fro(0): flg=5 objn=0 hint_alias="VW_DAG_0"@"SEL$C33C846D"

qbcp (after transform):******* UNPARSED QUERY IS *******
SELECT "VW_DAG_0"."ITEM_2" "PROD_ID",COUNT("VW_DAG_0"."ITEM_1") "COUNT(DISTINCTS.CHANNEL_ID)",SUM("VW_DAG_0"."ITEM_4") "SUM(S.AMOUNT_SOLD)",SUM("VW_DAG_0"."ITEM_3") "SUM(S.QUANTITY_SOLD)" FROM  (SELECT /*+ NO_USE_HASH_AGGREGATION */ "S"."CHANNEL_ID" "ITEM_1","S"."PROD_ID" "ITEM_2",SUM("S"."QUANTITY_SOLD") "ITEM_3",SUM("S"."AMOUNT_SOLD") "ITEM_4" FROM "TLO"."SALE_T" "S" GROUP BY "S"."CHANNEL_ID","S"."PROD_ID") "VW_DAG_0" GROUP BY "VW_DAG_0"."ITEM_2" 

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               
Distinct To Aggregation 쿼리변환은Heuristic Query Transformation에 속한다. _optimizer_distinct_agg_transform 파라미터로 이 기능을 제어할 수 있고 Defaulttrue이다. 힌트로는 TRANSFORM_DISTINCT_AGG / NO_TRANSFORM_DISTINCT_AGG 를 사용할 수 있다.

이제 우리는 집계함수에 Distinct가 추가되면 SORT UNIQUE의 부하로 성능이 느려짐을 안다. Distinct 대신에 Group By를 사용하여 그 부하를 대부분 없애는 방법도 알게 되었다. 하지만 이제는 이런 일들을 옵티마이저가 대신하게 되었다. 이런 기능들이 계속 추가된다면 언젠가는 튜너라는 직업이 사라지 않을까? 만약 튜너가 없어진다면, 그 후에 옵티마이저를 연구하는 사람까지 사라질 것이다. 왜냐하면 옵티마이저를 연구하는 사람은 튜너를 위해 존재하기 때문이다.


PS
다들 잘 지내시죠? 개인 사정으로 지난 2년간 뵙지 못했습니다. 5월달에 글을 한 두개 더 올릴 생각 입니다. 기대해 주세요. 5월 중순 부터는 바빠서 글쓰기가 힘들 것 같습니다. 

그럼 건강하세요.

Posted by extremedb
,

Window 7을 사용하는 사람들은 ReadyBoost 기능을 잘 알 것이다. 부팅 시에 필요한 정보를 성능이 우수한 Flash Disk(USB)에 넣음으로써 부팅속도를 향상시키는 것이다. 즉 부팅 시에 성능이 느린 Hard Disk를 사용하지 않으므로 성능이 향상된다. 이와 비슷한 기능이 오라클에도 있다.

 

Oracle 11.2부터 Database Smart Flash Cache라는 것이 추가되었다. 이 개념은 buffer cache Aging Out되어 Disk로 내려가는 단점을 보완한 것이다. buffer cache에서 Aging Out 되더라도 성능이 우수한 Flash Disk(SSD)로 내려가는 개념이다. 따라서 SSD Aging out된 블록을 다시 읽을 때 기존 Disk보다 빠른 성능을 낼 수 있다. Database Smart Flash Cache 기능이 추가되어 메모리 구조도가 약간 변경되었다.
 

그림의 출처: Database Administrator's Guide 11g Release 2


Flash Disk
2 cache로 사용하는 셈이다. 하지만 Flash Disk에서 조차 aging out될 때는 Disk로 내려가는 것은 막을 수 없다.

 

제약사항

반드시 솔라리스나 오라클 엔터프라이즈 리눅스를 사용해야 한다.

 

어떨 때 사용해야 되나?

아래의 세 가지 경우를 모두 만족하면 Database Smart Flash Cache를 고려해야 한다.


첫 번째, AWR이나 Statspack에서 Buffer Pool Advisory를 참조하여 buffer cache가 부족한 경우

두 번째, db file sequential read Top Wait Eevnt일 경우

세 번째, 여분의 CPU가 있는 경우

 

위의 세 가지 경우를 모두 만족해야 되는 이유는 기존의 Disk에도 약간의 Cache기능이 있으므로, 위의 조건과 같이 buffer cache가 부족하거나 aging out이 많이 발생하는 경우만 성능향상의 폭이 크기 때문이다.

 

All or Nothing

RAC인 경우 하나의 SSD Disk를 다른 노드에 공유할 수 없다. , 노드별로 Smart Flash Cache를 별도로 설정해야 한다. 또한 하나의 노드에 Database Smart Flash Cache를 설정하였으면 나머지 노드에도 모두 Smart Flash Cache를 설정해야 한다.

 

Database Smart Flash Cache Size 설정

Flash Cachebuffer cache 2~10배 정도를 권고한다. Flash Cache의 Sizebuffer cache 2배보다 작으면 효과를 볼 수 없다.

 

Parameters

db_flash_cache_file:

Flash Cache로 사용할 파일의 경로를 설정한다. 만약 파일이 없으면 오라클이 startup시에 생성한다. 반드시 SSD내의 경로를 사용해야 한다. 그렇지 않으면 성능이 떨어진다.
:/dev/fioa1


db_flash_cache_size:

Giga 단위로 기술해야 한다. 이 파라미터를 0으로 설정하면 Database Smart Flash Cache기능이 Disable된다. 이 파라미터는 scope = memory 옵션을 사용할 수 없다. 따라서 이 파라미터를 적용하려면 Shutdown Startup이 필요하다.

: 16G

 

Buffer Cache 튜닝

aging out이 발생하여 buffer cache에서 Flash Cache로 밀려날 때에도 블록의 메타정보는 그대로 남게 된다. 그 메타정보는 어림잡아 한 블럭당 100 byte 정도이다. 만약 RAC라면 한 블럭당 200 byte를 차지한다. 이 메타정보는 블록이 flash cache로 이동될 때 flash cache내의 address 정보를 가지고 있을 것으로 추측된다. 만약 Flash Cache를 사용할 것이라면 buffer cache block 100 byte 정도(RAC라면 200 byte) 줄어들므로 그만큼 더 잡아주기 바란다.

 

필자의 여건상 Database Smart Flash Cache를 테스트 해볼 수 없다. 누가(SSD를 보유한 사람이) 테스트를 하여 후기를 올려주었으면 한다. 테스트 시나리오는 아래와 같다. Flash Cache의 적용 전/후의 성능을 비교하는 것이다. 나라면 이렇게 테스트 할 것이다.

 

Database Smart Flash Cache 미적용 시나리오

1. buffer cache를 아주 작게 잡는다.

2. 큰 테이블 두 개에 각각 사이즈가 큰 인덱스를 하나씩 만들어 Nested Loop Join을 시키면 aging out이 발생될 것이다.

 

Database Smart Flash Cache 적용 시나리오

1. buffer cache를 아주 작게 잡는다.

2. Database Smart Flash Cache기능을 Setup 한다.

3. 큰 테이블 두 개에 각각 사이즈가 큰 인덱스를 하나씩 만들어 Nested Loop Join을 시키면 aging out이 발생될 것이다.

 

측정항목

AWRdb file sequential read의 부하가 얼마나 줄어드는지 관찰한다.

Nested Loop Join의 전체건 처리 속도는 얼마나 빨라지는지

 

이상으로 Oracle 11.2에 추가된 Database Smart Flash Cache 기능에 대해 살펴보았다.  한가지 이상한 점은 Database Smart Flash Cache 기능을 HP IBM 서버 등에는 사용할 수 없다는 것이다. 요즘 Oracle HP와 사이가 좋지 않다. 오라클의 가격정책도 이런 사실을 증명해준다. 돌아올 수 없는 강을 건넌 것인가? 아니면 Oracle 다음 버전(V 12)에서 HP IBM서버도 지원 할 것인가? 나는 전자라고 생각한다.

 

Reference:

Oracle Database 11g Release 1 (11.2.0.1) New Features- 1.8.1.1 Database Smart Flash Cache

Oracle Database Administrator's Guide 11g Release 2-Configuring Database Smart Flash

Posted by extremedb
,

일반적인 의견
흔히 Sort Merge Join에 대해 다음과 같이 이야기 한다. “조인에 참여하는 양측 집합에 Sort가 발생하므로 대용량 집합간의 조인에는 불리하다. 그러나 조인 되는 양쪽 집합에 적절한 인덱스가 있다면 Sort가 발생되지 않으므로 성능이 좋다.일견 일리가 있는 말이다. 하지만 이 정도는 튜닝에 입문하는 단계에서 언급되는 정도일 뿐이다. 2단을 외운다고 해서 구구단을 모두 안다고 할 수는 없다. 튜닝에 입문하는 사람과는 반대로 경력이 있는 사람들은 좀더 구체적인 사실들을 알고 있다. Sort Merge Join과 관련된 튜닝을 많이 해보고, Merge Join에 대해 여기저기 튜닝서적들을 탐독한다. 그 결과 다음과 같은 섣부른 결론을 내리는 사람이 많이 있다.

 

1. 양쪽 집합이 Full Table Scan을 사용하면 조인순서에 상관없이 일량이 동일하므로 처리시간도 동일하다.

2. 조인순서에 상관없이 Sort량은 동일하다.

3. 부분범위처리가 안 된다.

4. Full Scan이 발생하면 인덱스를 사용할 수 없으므로 항상 Sort 작업을 동반한다.

5. Sort Merge Join 대신 Cartesian Merge Join이 나오면 조인조건이 빠진 악성 SQL이다.

6. 조인컬럼 기준으로 Sort되므로 Order by절과 조인 컬럼이 일치해야만 Sort가 발생하지 않는다.

 

완벽하지 않거나 잘못된 결론

혹시 당신도 Sort Merge Join에 대해서 1~6번이 옳다고 생각하는가? 위의 List는 깊이 고민해 보지 않고 내린 결론이다. 물론 1~6 번이 옳은 경우도 있다. 하지만 그것은 잘못된 것 혹은 완벽하지 않은 결론이다. 왜냐하면 간단한 테스트로 1~6번이 잘못된 개념임을 증명하거나, 1~6번에 해당하지 않는 경우를 증명할 수 있기 때문이다. 지금부터 시작해 보자.

 

먼저 실습용 테이블을 생성한다.

<환경: Oracle 11.2.0.1>

 

CREATE TABLE SALES_T AS SELECT * FROM SALES;

 

 

1. 조인순서에 상관없이 처리시간이 동일할까?

 

ALTER SYSTEM FLUSH BUFFER_CACHE; 

  

SELECT /*+ leading(s) full(p) full(s) use_merge(p) */

       s.*, p.prod_id

  FROM sales_t s, products p

 WHERE p.prod_name = 'CD-R with Jewel Cases, pACK OF 12'

   AND p.prod_id = s.prod_id ;

 

-----------------------------------------------------------------------------------------------------

| Id  | Operation           | Name     | Starts | A-Rows |   A-Time   | Buffers | Reads  | Used-Mem |

-----------------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT    |          |      1 |  22189 |00:00:07.74 |    4447 |   4439 |          |

|   1 |  MERGE JOIN         |          |      1 |  22189 |00:00:07.74 |    4447 |   4439 |          |

|   2 |   SORT JOIN         |          |      1 |    590K|00:00:04.75 |    4440 |   4433 |   43M (0)|

|   3 |    TABLE ACCESS FULL| SALES_T  |      1 |    918K|00:00:01.23 |    4440 |   4433 |          |

|*  4 |   SORT JOIN         |          |    590K|  22189 |00:00:01.29 |       7 |      6 | 2048  (0)|

|*  5 |    TABLE ACCESS FULL| PRODUCTS |      1 |      1 |00:00:00.01 |       7 |      6 |          |

-----------------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   4 - access("P"."PROD_ID"="S"."PROD_ID")

       filter("P"."PROD_ID"="S"."PROD_ID")

   5 - filter("P"."PROD_NAME"='CD-R with Jewel Cases, pACK OF 12')

 

Sales_t 집합을 선행으로 실행하니 Scan한 블록수는 4447 이며 Sort량은 43M + 2048 이다. 그리고 처리시간은 7 74이다. 그리고 조인시도(Merge)횟수는 59만 번이다. 그러면 이제 조인 순서만 바꿔보자. 과연 처리시간이 동일 할까?

 

ALTER SYSTEM FLUSH BUFFER_CACHE; 

 

SELECT /*+ leading(p) full(p) full(s) use_merge(s) */

       s.*, p.prod_id

  FROM sales_t s, products p

 WHERE p.prod_name = 'CD-R with Jewel Cases, pACK OF 12'

   AND p.prod_id = s.prod_id ;

 

-----------------------------------------------------------------------------------------------------

| Id  | Operation           | Name     | Starts | A-Rows |   A-Time   | Buffers | Reads  | Used-Mem |

-----------------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT    |          |      1 |  22189 |00:00:02.52 |    4447 |   4439 |          |

|   1 |  MERGE JOIN         |          |      1 |  22189 |00:00:02.52 |    4447 |   4439 |          |

|   2 |   SORT JOIN         |          |      1 |      1 |00:00:00.03 |       7 |      6 | 2048  (0)|

|*  3 |    TABLE ACCESS FULL| PRODUCTS |      1 |      1 |00:00:00.03 |       7 |      6 |          |

|*  4 |   SORT JOIN         |          |      1 |  22189 |00:00:02.44 |    4440 |   4433 |   43M (0)|

|   5 |    TABLE ACCESS FULL| SALES_T  |      1 |    918K|00:00:01.25 |    4440 |   4433 |          |

-----------------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   3 - filter("P"."PROD_NAME"='CD-R with Jewel Cases, pACK OF 12')

   4 - access("P"."PROD_ID"="S"."PROD_ID")

       filter("P"."PROD_ID"="S"."PROD_ID")

 

일량이 같은데 수행시간은 세배이상 빠르다. 그 이유는?

조인 순서를 바꾸어 Products를 선행집합으로 Sort Merge Join을 해도 Scan한 블록 수와 Sort량은 완전히 같다. 일량이 같으므로 처리시간도 같을 것으로 생각해서는 안 된다. 처리시간이 2 52로 무려 세배이상 빨라졌다. 그 이유는 조인시도(Merge)횟수가 단 한번이기 때문이다. 이와는 대조적으로 Sales_t가 선행집합인 경우는 Merge 횟수가 무려 59만 번에 이르므로 성능이 느릴 수 밖에 없는 것이다. 그러므로 다음과 같은 결론을 낼 수 있다.

 

양쪽 집합이 Full Table Scan을 사용하면 조인순서에 상관없이 일량이 동일하므로 처리시간도 동일하다 ( X )

-->일량은 동일하더라도 Merge 횟수가 달라지면 처리시간이 달라진다 ( O )

 

 

2. 조인순서에 상관없이 Sort량이 동일할까?

 

실습을 위해 테이블을 두 개 만든다.

 

CREATE TABLE TAB1 NOLOGGING AS

SELECT ROWNUM AS SALES_NO, A.* FROM SALES A;

 

CREATE INDEX IDX_TAB1_01 ON TAB1 (PROD_ID, SALES_NO); 

 

CREATE TABLE TAB2 NOLOGGING AS

SELECT A.*, B.SEQ

  FROM TAB1 A,

       (SELECT LEVEL AS SEQ

          FROM DUAL

       CONNECT BY LEVEL <= 5) B  ;

    

CREATE INDEX IDX_TAB2_01 ON TAB2 (PROD_ID, SALES_NO, SEQ);

 

SELECT /*+ LEADING(A) INDEX(A)  INDEX(B) USE_MERGE(B) */

       B.*, A.CHANNEL_ID AS  CHAN

  FROM TAB1 a, TAB2 b

 WHERE A.SALES_NO = B.SALES_NO

   AND A.PROD_ID = 22

   AND B.PROD_ID = 22  ;

 

---------------------------------------------------------------------------------------------------------

| Id  | Operation                     | Name        | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |

---------------------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT              |             |      1 |  17205 |00:00:00.22 |   17355 |          |

|   1 |  MERGE JOIN                   |             |      1 |  17205 |00:00:00.22 |   17355 |          |

|   2 |   TABLE ACCESS BY INDEX ROWID | TAB1        |      1 |   3441 |00:00:00.01 |      94 |          |

|*  3 |    INDEX RANGE SCAN           | IDX_TAB1_01 |      1 |   3441 |00:00:00.01 |      14 |          |

|*  4 |   SORT JOIN                   |             |   3441 |  17205 |00:00:00.16 |   17261 | 1054K (0)|

|   5 |    TABLE ACCESS BY INDEX ROWID| TAB2        |      1 |  17205 |00:00:00.11 |   17261 |          |

|*  6 |     INDEX RANGE SCAN          | IDX_TAB2_01 |      1 |  17205 |00:00:00.02 |      56 |          |

---------------------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   3 - access("A"."PROD_ID"=22)

   4 - access("A"."SALES_NO"="B"."SALES_NO")

       filter("A"."SALES_NO"="B"."SALES_NO")

   6 - access("B"."PROD_ID"=22)

 

건수가 적은 tab1을 선행집합으로 하여 실행하였다. 선행집합은 적절한 인덱스 덕분으로 Sort가 발생하지 않았다. 하지만 후행집합 tab2 17205건을 Sort하여 1054K PGA를 사용하였다. 이제 선행집합을 바꿔서 실행하여 Sort량이 같은지 검증해보자.

 

SELECT /*+ LEADING(B) INDEX(A)  INDEX(B) USE_MERGE(A) */

       B.*, A.CHANNEL_ID AS  CHAN

  FROM TAB1 a, TAB2 b

 WHERE A.SALES_NO = B.SALES_NO

   AND A.PROD_ID = 22

   AND B.PROD_ID = 22  ;

 

---------------------------------------------------------------------------------------------------------

| Id  | Operation                     | Name        | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |

---------------------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT              |             |      1 |  17205 |00:00:00.34 |   17354 |          |

|   1 |  MERGE JOIN                   |             |      1 |  17205 |00:00:00.34 |   17354 |          |

|   2 |   TABLE ACCESS BY INDEX ROWID | TAB2        |      1 |  17205 |00:00:00.15 |   17263 |          |

|*  3 |    INDEX RANGE SCAN           | IDX_TAB2_01 |      1 |  17205 |00:00:00.04 |      58 |          |

|*  4 |   SORT JOIN                   |             |  17205 |  17205 |00:00:00.08 |      91 |83968  (0)|

|   5 |    TABLE ACCESS BY INDEX ROWID| TAB1        |      1 |   3441 |00:00:00.01 |      91 |          |

|*  6 |     INDEX RANGE SCAN          | IDX_TAB1_01 |      1 |   3441 |00:00:00.01 |      12 |          |

---------------------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   3 - access("B"."PROD_ID"=22)

   4 - access("A"."SALES_NO"="B"."SALES_NO")

       filter("A"."SALES_NO"="B"."SALES_NO")

   6 - access("A"."PROD_ID"=22)

 

Sort Merge Join도 조인순서가 중요하다

선행집합을 tab2 로 바꾸어 실행하였다. 이번에는 tab1 3441건만 Sort하였으므로 PGA 83K 만 사용하였다. Tab2 Sort하는 경우는 PGA 1054K 나 사용하였으므로 12배 이상 차이가 난다. 다시 말해, 적절한 인덱스가 존재하는 경우는 Filtering 된 건수가 적은 집합을 후행집합으로 하는 것이 Sort의 부하를 줄일 수 있다.

 

하나는 안다. 하지만 둘은?

Sort의 부하를 12배 이상 줄였으므로 만족해서는 안 된다. Sort량이 극적으로 줄어도 속도는 오히려 떨어질 수 있다. 위의 실행계획 두 가지의 처리속도를 비교해보면 Sort량이 많은 것이 오히려 더 빠르다. 그 이유는 건수가 적은 집합을 후행으로 놓으면 선행집합이 건수가 많아지므로 Merge 시도횟수가 증가하기 때문이다. Sort의 부하와 Merge 횟수를 모두 고려해야 최적의 튜닝을 할 수 있다.

 

튜닝의 목적이 무엇인가?

예를 들면, 배치 SQL을 튜닝할 때 응답시간을 단축시키려면 작은 집합을 선행집합으로 하여 Merge 횟수를 줄여야 한다. 이와는 반대로 Sort의 부하가 커서 multi-pass가 나오는 경우라면 작은 집합을 후행집합으로 하여 Sort의 부하를 줄여야 한다. , 응답시간 단축이냐 아니면 Sort량을 감소가 목적이냐에 따라서 튜닝방법이 달라져야 한다.

 

이 테스트를 통하여 다음을 증명해 보았다.

 

조인순서에 상관없이 Sort량이 동일하다 ( X )

-->적절한 인덱스를 사용하는 경우, Sort량은 Join 순서에 의해 달라진다 ( O )

 

참고사항: 첫 번째 조건이 참이 되려면 두 가지 전제가 필요하다. 전체범위를 처리해야 하며, 양측집합이 Full Scan인 경우에 해당한다.

 

 

3. Merge Join은 부분범위처리가 안 될까?

 

SELECT *

  FROM (SELECT /*+ LEADING(B) INDEX(A)  INDEX(B) USE_MERGE(A) */

               B.*, A.CHANNEL_ID AS  CHAN

          FROM TAB1 a, TAB2 b

         WHERE A.SALES_NO = B.SALES_NO

           AND A.PROD_ID = 22

           AND B.PROD_ID = 22

         ORDER BY B.PROD_ID, B.SALES_NO, B.SEQ )

WHERE ROWNUM <= 1 ;

 

-----------------------------------------------------------------------------------------------------------

| Id  | Operation                       | Name        | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |

-----------------------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT                |             |      1 |      1 |00:00:00.02 |      95 |          |

|*  1 |  COUNT STOPKEY                  |             |      1 |      1 |00:00:00.02 |      95 |          |

|   2 |   VIEW                          |             |      1 |      1 |00:00:00.02 |      95 |          |

|   3 |    MERGE JOIN                   |             |      1 |      1 |00:00:00.02 |      95 |          |

|   4 |     TABLE ACCESS BY INDEX ROWID | TAB2        |      1 |      1 |00:00:00.01 |       4 |          |

|*  5 |      INDEX RANGE SCAN           | IDX_TAB2_01 |      1 |      1 |00:00:00.01 |       3 |          |

|*  6 |     SORT JOIN                   |             |      1 |      1 |00:00:00.02 |      91 |83968  (0)|

|   7 |      TABLE ACCESS BY INDEX ROWID| TAB1        |      1 |   3441 |00:00:00.02 |      91 |          |

|*  8 |       INDEX RANGE SCAN          | IDX_TAB1_01 |      1 |   3441 |00:00:00.01 |      12 |          |

-----------------------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   1 - filter(ROWNUM<=1)

   5 - access("B"."PROD_ID"=22)

   6 - access("A"."SALES_NO"="B"."SALES_NO")

       filter("A"."SALES_NO"="B"."SALES_NO")

   8 - access("A"."PROD_ID"=22)

 

 

단 한 건만 읽는다

인라인뷰 외부에서 ROWNUM <= 1 조건을 사용하자, 후행집합은 전체 건을 읽었지만 선행집합은 정확히 한 건만 읽었다. 선행집합이 전체범위로 처리되었다면 17205건을 읽었을 것이다. 즉 선행집합에 대해서는 완벽히 부분범위로 처리된다. 만약 후행집합이 몇 건 안 된다면 부분범위처리의 효율은 더욱 높아진다.

이제 Rownum 조건을 10, 100으로 변경해 가면서 실행계획을 관찰 해보자.

 

ROWNUM <= 10 조건일 때의 실행계획  

-----------------------------------------------------------------------------------------------------------

| Id  | Operation                       | Name        | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |

-----------------------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT                |             |      1 |     10 |00:00:00.02 |     105 |          |

|*  1 |  COUNT STOPKEY                  |             |      1 |     10 |00:00:00.02 |     105 |          |

|   2 |   VIEW                          |             |      1 |     10 |00:00:00.02 |     105 |          |

|   3 |    MERGE JOIN                   |             |      1 |     10 |00:00:00.02 |     105 |          |

|   4 |     TABLE ACCESS BY INDEX ROWID | TAB2        |      1 |     10 |00:00:00.01 |      14 |          |

|*  5 |      INDEX RANGE SCAN           | IDX_TAB2_01 |      1 |     10 |00:00:00.01 |       4 |          |

|*  6 |     SORT JOIN                   |             |     10 |     10 |00:00:00.02 |      91 |83968  (0)|

|   7 |      TABLE ACCESS BY INDEX ROWID| TAB1        |      1 |   3441 |00:00:00.02 |      91 |          |

|*  8 |       INDEX RANGE SCAN          | IDX_TAB1_01 |      1 |   3441 |00:00:00.01 |      12 |          |

-----------------------------------------------------------------------------------------------------------

 

ROWNUM <= 100 조건일 때의 실행계획 

-----------------------------------------------------------------------------------------------------------

| Id  | Operation                       | Name        | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |

-----------------------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT                |             |      1 |    100 |00:00:00.03 |     195 |          |

|*  1 |  COUNT STOPKEY                  |             |      1 |    100 |00:00:00.03 |     195 |          |

|   2 |   VIEW                          |             |      1 |    100 |00:00:00.03 |     195 |          |

|   3 |    MERGE JOIN                   |             |      1 |    100 |00:00:00.03 |     195 |          |

|   4 |     TABLE ACCESS BY INDEX ROWID | TAB2        |      1 |    100 |00:00:00.01 |     104 |          |

|*  5 |      INDEX RANGE SCAN           | IDX_TAB2_01 |      1 |    100 |00:00:00.01 |       4 |          |

|*  6 |     SORT JOIN                   |             |    100 |    100 |00:00:00.03 |      91 |83968  (0)|

|   7 |      TABLE ACCESS BY INDEX ROWID| TAB1        |      1 |   3441 |00:00:00.02 |      91 |          |

|*  8 |       INDEX RANGE SCAN          | IDX_TAB1_01 |      1 |   3441 |00:00:00.01 |      12 |          |

-----------------------------------------------------------------------------------------------------------

 

Rownum 조건을 10, 100으로 변경하자 tab2를 정확히 10, 100건만 읽는다. 도대체 누가 “Sort Merge Join은 부분범위처리가 안 된다라는 말을 한 것일까?

 

Sort Merge Join은 부분범위처리가 안 된다 ( X )

-->적절한 인덱스가 있다면 선행집합은 부분범위처리가 가능하다 ( O )

 

 

4. Full Scan을 하면 인덱스를 사용할 수 없으므로 항상 Sort 작업이 발생할까?

 

SELECT /*+ leading(s) full(p) full(s) use_merge(p) */

       s.*, p.prod_id

  FROM sales_t s, products p

 WHERE p.prod_id = 119

   AND p.prod_id = s.prod_id;

 

---------------------------------------------------------------------------------------------

| Id  | Operation            | Name     | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |

---------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT     |          |      1 |  22189 |00:00:00.29 |    4450 |          |

|   1 |  MERGE JOIN CARTESIAN|          |      1 |  22189 |00:00:00.29 |    4450 |          |

|*  2 |   TABLE ACCESS FULL  | SALES_T  |      1 |  22189 |00:00:00.06 |    4443 |          |

|   3 |   BUFFER SORT        |          |  22189 |  22189 |00:00:00.07 |       7 | 2048  (0)|

|*  4 |    TABLE ACCESS FULL | PRODUCTS |      1 |      1 |00:00:00.01 |       7 |          |

---------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   2 - filter("S"."PROD_ID"=119)

   4 - filter("P"."PROD_ID"=119)

 

Sort Join이 사라진 이유

힌트를 주어 sales_t products 모두 full table scan을 발생시켰다. 그러자 product 테이블 쪽은 buffer sort가 존재하지만 sales_t 테이블 쪽은 Sort가 사라졌다. Sort가 사라질 수 있는 이유는 product 쪽에 unique 조건(prod_id = 119)에 의해서 집합이 항상 한 건임을 보장하기 때문이다. , 집합이 한 건뿐이므로 조인이 필요 없어지는 것이다. 바로 이것이 Sort Merge Join Cartesian Merge Join 으로 바뀔 수 있는 이유이다. 반대로 이야기하면, 위의 SQL에서 unique 조건이 없다면 Cartesian Merge Join buffer sort는 결코 발생하지 않는다.

 

Full Scan이 발생하면 인덱스를 사용할 수 없으므로 항상 Sort 작업을 동반한다 ( X )

-->Full Scan이 발생해도 Unique 조건이 들어오면 Sort Join Operation이 사라진다 ( O )

 

 

5. Sort Merge Join 대신에 Cartesian Merge Join이 나오면 조인조건이 빠진 악성 SQL일까?

 

위에서 Unique 조건 때문에 Sort Merge Join Cartesian Merge Join으로 바뀐다고 했다. 이 현상은 아주 바람 직한 것이다. 왜냐하면 불필요한 Sort를 없애버리기 때문이다. 따라서 Cartesian Merge Join이라고 해서 항상 실수로 조인을 하지 않은 악성 SQL은 아니다.

 

이번에는 Unique 인덱스를 사용하는 경우를 보자.

 

SELECT /*+ leading(p) full(s) use_merge(s) */

       s.*, p.prod_id

  FROM sales_t s, products p

 WHERE p.prod_id = 119

   AND p.prod_id = s.prod_id;

 

-------------------------------------------------------------------------------------

| Id  | Operation            | Name        | Starts | A-Rows |   A-Time   | Buffers |

-------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT     |             |      1 |  22189 |00:00:00.14 |    4444 |

|   1 |  MERGE JOIN CARTESIAN|             |      1 |  22189 |00:00:00.14 |    4444 |

|*  2 |   INDEX UNIQUE SCAN  | PRODUCTS_PK |      1 |      1 |00:00:00.01 |       1 |

|*  3 |   TABLE ACCESS FULL  | SALES_T     |      1 |  22189 |00:00:00.05 |    4443 |

-------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   2 - access("P"."PROD_ID"=119)

   3 - filter("S"."PROD_ID"=119)

 

Unique 인덱스를 사용하자 Sort가 사라졌고, 심지어 Buffer Sort도 사라졌다. 따라서 성능도 최적이 되었다. 그러므로 MERGE JOIN CARTESIAN 이라는 operation 만 보고 조인절이 빠졌다거나 악성 SQL 이라고 판단해서는 안 된다.

 

만약 조인 순서가 바뀌면 buffer sort가 나타나므로 주의해야 한다. 아래의 SQL을 보자.

 

SELECT /*+ leading(s) full(s) use_merge(p) */

       s.*, p.prod_id

  FROM sales_t s, products p

 WHERE p.prod_id = 119

   AND p.prod_id = s.prod_id;

 

------------------------------------------------------------------------------------------------

| Id  | Operation            | Name        | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |

------------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT     |             |      1 |  22189 |00:00:00.28 |    4444 |          |

|   1 |  MERGE JOIN CARTESIAN|             |      1 |  22189 |00:00:00.28 |    4444 |          |

|*  2 |   TABLE ACCESS FULL  | SALES_T     |      1 |  22189 |00:00:00.06 |    4443 |          |

|   3 |   BUFFER SORT        |             |  22189 |  22189 |00:00:00.07 |       1 | 2048  (0)|

|*  4 |    INDEX UNIQUE SCAN | PRODUCTS_PK |      1 |      1 |00:00:00.01 |       1 |          |

------------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   2 - filter("S"."PROD_ID"=119)

   4 - access("P"."PROD_ID"=119)

 

카테시안 조인도 순서대로 실행해야 한다

Buffer sort 뿐만 아니라 merge 횟수도 22189번이나 시도되어 성능이 저하되었다. 위의 실행계획에서 볼 수 있듯이 CARTESIAN MERGE JOIN 도 조인의 순서가 중요하므로 실행계획을 유심히 살펴야 한다.

 

카테시안 조인의 발생조건

Unique 컬럼에 조건이 Equal로 들어오면 옵티마이져가 성능향상을 위해서 조인절을 삭제한다. 만약 Unique 컬럼이라도 Equal 조건이 아니라 Range 조건이라면 위의 CARTESIAN MERGE JOIN 실행계획이 나타나지 않는다. 아래의 SQL이 그것을 증명한다.

 

SELECT /*+ leading(s) full(s) use_merge(p) */

       s.*, p.prod_id

  FROM sales_t s, products p

 WHERE p.prod_id >= 119

   AND p.prod_id < 120

   AND p.prod_id = s.prod_id;

  

-----------------------------------------------------------------------------------------------

| Id  | Operation           | Name        | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |

-----------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT    |             |      1 |  22189 |00:00:00.35 |    4441 |          |

|   1 |  MERGE JOIN         |             |      1 |  22189 |00:00:00.35 |    4441 |          |

|   2 |   SORT JOIN         |             |      1 |  22189 |00:00:00.16 |    4440 | 1117K (0)|

|*  3 |    TABLE ACCESS FULL| SALES_T     |      1 |  22189 |00:00:00.06 |    4440 |          |

|*  4 |   SORT JOIN         |             |  22189 |  22189 |00:00:00.08 |       1 | 2048  (0)|

|*  5 |    INDEX RANGE SCAN | PRODUCTS_PK |      1 |      1 |00:00:00.01 |       1 |          |

-----------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   3 - filter(("S"."PROD_ID">=119 AND "S"."PROD_ID"<120))

   4 - access("P"."PROD_ID"="S"."PROD_ID")

       filter("P"."PROD_ID"="S"."PROD_ID")

   5 - access("P"."PROD_ID">=119 AND "P"."PROD_ID"<120)

 

카테시안 조인이 더 빠르다

비록 SQL의 결과는 같지만 sort join operation에 의해서 PGA를 소모한다. where절의 prod_idequal 조건이냐 아니면 Range조건이냐에 따라서 성능이 좌우된다. , 성능이 나쁜 Sort Merge Join으로 풀리느냐 아니면, 추가적인 Sort가 없어서 성능이 우수한 CARTESIAN MERGE JOIN으로 풀리느냐는 where 조건에 따라 좌우된다. Unique 컬럼에 = 조건인지 아닌지에 따라 Sort의 부하가 좌우되는 것이다.

 

만약 Unique 컬럼에 = 조건이 들어오면 옵티마이져가 hash join을 선택하는 경우가 있을까?

 

SELECT /*+ leading(p) use_hash(s) */

       s.*, p.prod_id

  FROM sales_t s, products p

 WHERE p.prod_id = 119

   AND p.prod_id = s.prod_id;

 

------------------------------------------------------------------------------------

| Id  | Operation          | Name        | Starts |  A-Rows |   A-Time   | Buffers |

------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT   |             |      1 |   22189 |00:00:00.10 |    4444 |

|   1 |  NESTED LOOPS      |             |      1 |   22189 |00:00:00.10 |    4444 |

|*  2 |   INDEX UNIQUE SCAN| PRODUCTS_PK |      1 |       1 |00:00:00.01 |       1 |

|*  3 |   TABLE ACCESS FULL| SALES_T     |      1 |   22189 |00:00:00.06 |    4443 |

------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   2 - access("P"."PROD_ID"=119)

   3 - filter("S"."PROD_ID"=119)

 

Hash join은 실행할 수 없다

Unique 컬럼에 = 조건이 들어오면 결코 hash join을 선택하지 않는다. 강제로 힌트를 사용해도 merge join이나 nested loop join을 선택한다. 왜냐하면 Hash Join은 반드시 Equal Join이 필요한데, 조인절이 삭제되어 hash join이 발생될 수 없기 때문이다.

 

Sort Merge Join 대신 Cartesian Merge Join이 나오면 조인조건이 빠진 악성 SQL이다 ( X )

-->Unique 조건이 Equal로 들어오고 같은 컬럼으로 조인하면 옵티마이저는 성능향상을 위해 조인절을 삭제한다 ( O )

 

 

6. 조인컬럼 기준으로 Sort되므로 Order by절과 조인 컬럼이 일치할 때만 Sort가 발생되지 않는다. 정말 그럴까?

 

Sort의 기준이 조인컬럼이라는 말이 항상 참일까? 아래의 SQL을 보자.

 

SELECT /*+ LEADING(B) FULL(A)  FULL(B) USE_MERGE(A) */

       B.*, A.CHANNEL_ID AS  CHAN

  FROM TAB1 a, TAB2 b

 WHERE A.SALES_NO = B.SALES_NO

   AND A.PROD_ID = 22

   AND B.PROD_ID = 22

 ORDER BY B.PROD_ID, B.SALES_NO, B.SEQ ;

 

-----------------------------------------------------------------------------------------

| Id  | Operation            | Name | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |

-----------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT     |      |      1 |  17205 |00:00:03.34 |   32327 |          |

|   1 |  SORT ORDER BY       |      |      1 |  17205 |00:00:03.34 |   32327 | 1180K (0)|

|   2 |   MERGE JOIN         |      |      1 |  17205 |00:00:03.29 |   32327 |          |

|   3 |    SORT JOIN         |      |      1 |  17205 |00:00:02.62 |   27257 | 1054K (0)|

|*  4 |     TABLE ACCESS FULL| TAB2 |      1 |  17205 |00:00:02.53 |   27257 |          |

|*  5 |    SORT JOIN         |      |  17205 |  17205 |00:00:00.59 |    5070 |83968  (0)|

|*  6 |     TABLE ACCESS FULL| TAB1 |      1 |   3441 |00:00:00.52 |    5070 |          |

-----------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   4 - filter("B"."PROD_ID"=22)

   5 - access("A"."SALES_NO"="B"."SALES_NO")

       filter("A"."SALES_NO"="B"."SALES_NO")

   6 - filter("A"."PROD_ID"=22)

 

Order by 절에 조인컬럼(SALES_NO) 이외의 것들이 있으므로 SORT ORDER BY operation이 추가로 발생하여 성능이 저하되었다. 이제 조인컬럼으로만 order by를 해보자.

 

 

SELECT /*+ LEADING(B) FULL(A)  FULL(B) USE_MERGE(A) */

       B.*, A.CHANNEL_ID AS  CHAN

  FROM TAB1 a, TAB2 b

 WHERE A.SALES_NO = B.SALES_NO

   AND A.PROD_ID = 22

   AND B.PROD_ID = 22

 ORDER BY B.SALES_NO;

 

----------------------------------------------------------------------------------------

| Id  | Operation           | Name | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |

----------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT    |      |      1 |  17205 |00:00:02.69 |   32331 |          |

|   1 |  MERGE JOIN         |      |      1 |  17205 |00:00:02.69 |   32331 |          |

|   2 |   SORT JOIN         |      |      1 |  17205 |00:00:02.49 |   27257 | 1054K (0)|

|*  3 |    TABLE ACCESS FULL| TAB2 |      1 |  17205 |00:00:02.41 |   27257 |          |

|*  4 |   SORT JOIN         |      |  17205 |  17205 |00:00:00.11 |    5074 |83968  (0)|

|*  5 |    TABLE ACCESS FULL| TAB1 |      1 |   3441 |00:00:00.04 |    5074 |          |

----------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   3 - filter("B"."PROD_ID"=22)

   4 - access("A"."SALES_NO"="B"."SALES_NO")

       filter("A"."SALES_NO"="B"."SALES_NO")

   5 - filter("A"."PROD_ID"=22)

 

참고사항으로 알아두자. Order by절에 prod_id가 추가되어도 위의 실행계획은 같다. 왜냐하면 prod_id는 상수 22로 고정되어 있으므로 Sort가 필요 없기 때문이다.

 

조인컬럼으로 sort를 하니 SORT ORDER BY operation이 사라져 버렸다. 얼핏 보면 Sort의 기준은 조인컬럼인 것처럼 보인다. 하지만 이 조건을 항상 만족하려면 Full Scan을 해야 한다는 전제조건이 붙어야 한다. 그러면 이제 Full Scan 대신에 인덱스를 사용해보자.

 

 

SELECT /*+ LEADING(A) INDEX(A)  INDEX(B) USE_MERGE(B) */

       B.*, A.CHANNEL_ID AS  CHAN

  FROM TAB1 a, TAB2 b

 WHERE A.SALES_NO = B.SALES_NO

   AND A.PROD_ID = 22

   AND B.PROD_ID = 22

 ORDER BY B.PROD_ID, B.SALES_NO, B.SEQ ;

 

----------------------------------------------------------------------------------------------------------

| Id  | Operation                      | Name        | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |

----------------------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT               |             |      1 |  17205 |00:00:02.21 |   17352 |          |

|   1 |  SORT ORDER BY                 |             |      1 |  17205 |00:00:02.21 |   17352 | 1117K (0)|

|   2 |   MERGE JOIN                   |             |      1 |  17205 |00:00:02.16 |   17352 |          |

|   3 |    TABLE ACCESS BY INDEX ROWID | TAB1        |      1 |   3441 |00:00:00.31 |      91 |          |

|*  4 |     INDEX RANGE SCAN           | IDX_TAB1_01 |      1 |   3441 |00:00:00.05 |      12 |          |

|*  5 |    SORT JOIN                   |             |   3441 |  17205 |00:00:01.80 |   17261 | 1054K (0)|

|   6 |     TABLE ACCESS BY INDEX ROWID| TAB2        |      1 |  17205 |00:00:01.75 |   17261 |          |

|*  7 |      INDEX RANGE SCAN          | IDX_TAB2_01 |      1 |  17205 |00:00:00.06 |      56 |          |

----------------------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   4 - access("A"."PROD_ID"=22)

   5 - access("A"."SALES_NO"="B"."SALES_NO")

       filter("A"."SALES_NO"="B"."SALES_NO")

   7 - access("B"."PROD_ID"=22)

 

인덱스를 사용했음에도 추가적인 Sort가 발생하는 이유

이런! 인덱스를 사용했지만, SORT ORDER BY가 발생하였다. 왜 그럴까? 인덱스를 사용할 때 Sort의 기준은 선행집합의 인덱스 컬럼이다. , 선행집합의 인덱스컬럼이 order by절에 나온다면 Sort가 발생하지 않는다. 위의 SQL에서 선행집합의 인덱스컬럼은 PROD_ID + SALES_NO 이다. 따라서 B.SEQ 컬럼 때문에 Sort가 발생한 것이다. 그러면 이제 Sort를 없애기 위하여 선행집합을 바꿔보자.

 

SELECT /*+ LEADING(B) INDEX(A)  INDEX(B) USE_MERGE(A) */

       B.*, A.CHANNEL_ID AS  CHAN

  FROM TAB1 a, TAB2 b

 WHERE A.SALES_NO = B.SALES_NO

   AND A.PROD_ID = 22

   AND B.PROD_ID = 22

 ORDER BY B.PROD_ID, B.SALES_NO, B.SEQ ;

 

---------------------------------------------------------------------------------------------------------

| Id  | Operation                     | Name        | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |

---------------------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT              |             |      1 |  17205 |00:00:03.09 |   17387 |          |

|   1 |  MERGE JOIN                   |             |      1 |  17205 |00:00:03.09 |   17387 |          |

|   2 |   TABLE ACCESS BY INDEX ROWID | TAB2        |      1 |  17205 |00:00:02.58 |   17296 |          |

|*  3 |    INDEX RANGE SCAN           | IDX_TAB2_01 |      1 |  17205 |00:00:00.14 |      91 |          |

|*  4 |   SORT JOIN                   |             |  17205 |  17205 |00:00:00.39 |      91 |83968  (0)|

|   5 |    TABLE ACCESS BY INDEX ROWID| TAB1        |      1 |   3441 |00:00:00.32 |      91 |          |

|*  6 |     INDEX RANGE SCAN          | IDX_TAB1_01 |      1 |   3441 |00:00:00.06 |      12 |          |

---------------------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   3 - access("B"."PROD_ID"=22)

   4 - access("A"."SALES_NO"="B"."SALES_NO")

       filter("A"."SALES_NO"="B"."SALES_NO")

   6 - access("A"."PROD_ID"=22)

 

선행집합의 인덱스 컬럼과 Order By절의 컬럼이 동일하다. 그리고 인덱스와 Order by절의 컬럼순서도 동일하다. 이 두 가지 조건을 만족하므로 추가적인 SORT ORDER BY operation이 발생하지 않았다. Order By 뿐만 아니라 Group By도 마찬가지이다. 이제 Order by Group By를 동시에 사용해보자.

 

SELECT /*+ LEADING(B) INDEX(A)  INDEX(B) NO_PLACE_GROUP_BY USE_MERGE(A) */

       B.PROD_ID, B.SALES_NO, COUNT(*)

  FROM TAB1 a, TAB2 b

 WHERE A.SALES_NO = B.SALES_NO

   AND A.PROD_ID = 22

   AND B.PROD_ID = 22

 GROUP BY B.PROD_ID, B.SALES_NO, B.SEQ

ORDER BY B.PROD_ID, B.SALES_NO ;

 

------------------------------------------------------------------------------------------------

| Id  | Operation            | Name        | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |

------------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT     |             |      1 |  17205 |00:00:00.30 |      70 |          |

|   1 |  SORT GROUP BY NOSORT|             |      1 |  17205 |00:00:00.30 |      70 |          |

|   2 |   MERGE JOIN         |             |      1 |  17205 |00:00:00.25 |      70 |          |

|*  3 |    INDEX RANGE SCAN  | IDX_TAB2_01 |      1 |  17205 |00:00:00.02 |      58 |          |

|*  4 |    SORT JOIN         |             |  17205 |  17205 |00:00:00.08 |      12 |57344  (0)|

|*  5 |     INDEX RANGE SCAN | IDX_TAB1_01 |      1 |   3441 |00:00:00.01 |      12 |          |

------------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   3 - access("B"."PROD_ID"=22)

   4 - access("A"."SALES_NO"="B"."SALES_NO")

       filter("A"."SALES_NO"="B"."SALES_NO")

   5 - access("A"."PROD_ID"=22)

 

Order By/Group By절의 컬럼이 모두 선행집합의 인덱스 컬럼과 순서가 같으므로 추가적인 Sort가 전혀 발생하지 않았다. 따라서 다음과 같은 결론을 낼 수 있다.

 

조인컬럼 기준으로 Sort되므로 Order by절과 조인 컬럼이 일치해야만 Sort가 발생하지 않는다. ( X )

-->Full table scan일 때는 조인컬럼 기준으로 sort 되는 것이 옳다. 하지만, index를 사용한다면 조인컬럼 뿐만 아니라, 선행집합의 인덱스 컬럼과 order by/group by절을 일치시켜도 Sort가 발생하지 않는다 ( O )

 

 

결론

6가지의 오만과 편견 중에 하나라도 얻은 것이 있다면 성공이다. 다시 한번 여섯 가지를 정리하기 바란다.

 

양쪽 집합이 Full Table Scan을 사용하면 조인순서에 상관없이 일량이 동일하므로 처리시간도 동일하다 ( X )

-->일량은 동일하더라도 Merge 횟수가 달라지면 처리시간이 달라진다  ( O )

 

조인순서에 상관없이 Sort량이 동일하다 ( X )

-->적절한 인덱스를 사용하는 경우, Sort량은 Join 순서에 의해 달라진다 ( O )

 

Sort Merge Join은 부분범위처리가 안 된다 ( X )

-->적절한 인덱스가 있다면 선행집합은 부분범위처리가 가능하다 ( O )

 

Full Scan이 발생하면 인덱스를 사용할 수 없으므로 항상 Sort 작업을 동반한다 ( X )

-->Full Scan이 발생해도 Unique 조건이 들어오면 Sort Join Operation이 사라진다 ( O )

 

Sort Merge Join 대신 Cartesian Merge Join이 나오면 조인조건이 빠진 악성 SQL이다 ( X )

-->Unique 조건이 Equal로 들어오고 같은 컬럼으로 조인하면 옵티마이저는 성능향상을 위해 조인절을 삭제한다 ( O )

 

조인컬럼 기준으로 Sort되므로 Order by절과 조인 컬럼이 일치해야만 Sort가 발생하지 않는다. ( X )

-->Full table scan일 때는 조인컬럼 기준으로 sort 되는 것이 옳다. 하지만, index를 사용한다면 조인컬럼 뿐만 아니라, 선행집합의 인덱스 컬럼과 order by/group by절을 일치시켜도 Sort가 발생하지 않는다 ( O )


PS
요즘 워낙 바빠서 예전에 미리 글을 써놓지 않았더라면 글을 하나도 올리지 못할뻔 하였다. 
Posted by extremedb
,

영화 다이하드 4.0을 보면 파이어 세일이라는 용어가 나온다. 이 용어는 3단계의 해킹과 크래킹 과정을 의미한다. 그 과정을 통해 가공할 만한 전상장애를 일으켜 국가를 붕괴시켜버리는 것이다. 1단계는 교통기관 시스템 마비, 2단계는 금융·통신 전산 장애, 3단계는 가스·수도·전기·원자력 시스템을 점령하는 것이다. 모든 것이 컴퓨터 속의 소프트웨어로 컨트롤 되고 있으므로 위의 3단계와 같은 전산장애만 일으킨다면 국가가 무너지는 것은 불을 보듯 뻔한 것이다.

 

국가가 아니라 당신이 위험하다
현실에서의 전산장애는 영화처럼 1,2,3 단계가 동시에 발생하지는 않으므로 국가가 아니라 개인과 기업을 위협한다. 현실에서 실제로 발생하는 전산장애를 생각해보자. 오늘이 만기일이라고 가정하자. 오후 4시에 송금을 하려고 했는데 은행에 전산장애가 발행해 송금이 불가능 하다면 기업이 부도가 날 수 있고 개인이라면 신용불량자가 될 수 있다. 이로 인해 부도난 기업의 CEO는 자살할 수도 있다. 30분의 전산장애로 기업과 개인의 운명이 바뀔 수 있는 것이다.
 
늑장 대응을 도마 위로 올려보자
의도적인 해킹과 크래킹 등을 통한 전산장애는 보안 솔루션을 도입하면 전산장애를 최소화 할 수는 있다. 하지만 전산장애를 완벽히 막을 수는 없다. 왜냐하면 해킹뿐 아니라 자연재해, 인재(사람의 실수)등은 예고되지 않을뿐더러, 막기도 힘들다. 따라서 전산장애 발생의 확률을 최대한 낮추는 것과 전산장애를 만났을 때 빨리 대처하는 것이 최선이다. 전산장애를 만났을 때 빨리 해결해야만 당신의 피해가 최소화되는 것이다. 그런데 많은 기업에서는 왜 전산장애에 늑장 대응을 하는 것일까? 오늘은 이 부분을 다루려고 한다.


전산 장애를 해결하는데 시간이 오래 걸리는 이유는 장애가 발생했을 때 신속히 전산 담당자들에게 알리는 경보 시스템이 없다거나, 장애 복구 시스템(DR)이 없거나, 담당자가 없어서 원인을 분석할 수 없기 때문이 아니다. 물론 그런 경우도 있겠지만 기본적인 것들을 준비하지 않고서 장애를 빨리 해결하려는 것은 당치도 않은 욕심이다. 많은 기업들이 기본적인 것들은 준비하고 있다. 그럼에도 불구하고 장애가 빨리 해결되지 않는 이유는 세 가지로 볼 수 있다. 이 세 가지 문제들은 지난 몇 십 년간 방치되어 왔다. 장애를 빨리 해결하려면 이제는 더 이상 방치하면 안될 것이다. 다행히, 일부의 기업에서 이런 문제를 간파하여 장애시의 Down Time을 최소화 하고 있지만 아직 갈 길은 멀다

늑장의 원인 세 가지는 무엇인가?
많은 기업들이 간과하는 세 가지 문제는 간단한 것이다. 첫 번째, 장애상황에서 대응을 못하도록 정신적인 고통을 주는 사람이 당신의 조직에 있다는 것을 알고 있는가? 말도 안 된다고과연 그런가두 번째, 장애상황을 극복하려면 해당분야의 전문 지식이 필요하다. 그런데 전문 지식을 가질 수 없게 만드는 사람이 여러분의 주위에 있다면 당신은 믿겠는가? 세 번째, 일단 장애를 해결했으면 앞으로 비슷한 유형의 장애는 발생하지 않게 해법을 만들어야 한다. 하지만 많은 경우에 해법이라는 것이 임시방편이므로 같은 유형의 장애를 또 만나게 된다는 것을 알고 있는가? 다시 말해, 소를 잃었음에도, 외양간을 고치지 않고 있다. 여러분이 근무하는 곳은 어떤가? 위의 세 가지 일이 발생하지 않는다고 장담할 수 있는가? 이제부터 세 가지에 대해 좀더 상세히 알아보자.


 

1. 심리적 관점

병풍치기가 무엇인가?
장애가 나면 언제나 볼 수 있는 풍경이 있다. 조치를 하고 있는 전산담당자의 바로 뒤편에 관리자들이 주~욱 둘러선다. 장애 때문에 발을 동동 구를 수도 있고, 언제 장애가 풀릴 것인지 궁금해서 그럴 수도 있고, 담당자가 얼마나 빠른 조치를 하는지 보려는 사람도 있을 것이다. 필자는 이런 현상을 “장애시의 병풍치기”라고 한다. 병풍치기만 없어져도 장애가 빨리 해결될 수 있다. 수십 명이 담당자의 뒤편에 병풍을 치고 있는데, 이 사람들은 감시자인가? 아니면 담당자의 도우미인가? 도우미는 경험 많은 사람 한 명, 그리고 관련분야의 지식이 풍부한 전문가 한 명, 총 두 명이면 충분하다. 이 두 명도 뒤편에서 병풍을 칠 것이 아니라 담당자의 왼편과 오른편에서 모니터를 같이 바라보아야 한다. 문제를 해결하려는 동료의 관점에서 말이다

장애를 해결하는 당사자의 심리
서버 담당자가 장애를 해결하기 위해 특정 명령어를 날리려고 한다. 그런데 1년에 한번 쓸까 말까 한 명령어이므로 기억하기 힘들다. 그래서 매뉴얼을 보거나 인터넷으로 명령어를 찾아야 한다. 그런데 병풍이 쳐져 있으면 책을 보거나, 인터넷을 뒤져보기 힘들다. 왜냐하면 병풍이 쳐져 있을 때, 담당자는 다음처럼 생각하기 때문이다. 내가 명령어를 찾으려고 인터넷을 뒤지면 내 뒤에 있는 관리자들이 어떻게 생각 할까? “서버 담당자 맞아? 어떻게 명령어도 모를 수가 있지?” 라고 생각하겠군. 

Walking Dictionary?
이런 생각은 담당자의 머릿속에만 존재하는 것이 아니라 여지없이 현실로 나타난다. 장애 담당자가 명령어를 인터넷에서 찾기라도 하면, 기다렸다는 듯이 뒤편의 사람들이 수군거리기 시작한다. 도대체 누가 모든 명령어를 외우고 다닌단 말인가? 다른 분야의 전문가들에게 물어보라. 의사가 의학서적을 뒤지는 이유, 변호사가 법률서적을 뒤지는 이유, 영문학 번역가가 영어사전을 뒤지는 이유를 물어보라.

병풍은 장애당당자를 어떻게 방해하나?
뒤편의 사람들이 수군거리기 시작하고, 장애로 인해 타격을 받는 부서의 사람들은 계속 발을 동동 구르고 있고, 전산부서의 관리자들은 사태가 언제쯤 해결될지 계속해서 담당자에게 질문을 해대고…… 전형적인 병풍치기 광경이다. 전산당당자들이 이런 상황에서 제대로 일을 할 수 있을까? 담당자의 실력을 의심하는것, 발을 동동 구르는 것, 담당자에게 계속 질문을 해대는 것, 모두가 담당자를 방해하는 것이다.

역지사지 (易地思之)
장애 담당장의 뒤편에 서있을 뿐, 그 사람의 실력을 의심한 적은 없다고 혹자는 생각 할 수도 있다. 하지만 불행히도 그런 생각과는 아무 상관이 없다. 무슨 말이냐 하면, 어떤 사람이 술집에서 음란한 이야기를 했다고 치자. 그 자리에 같이 있던 여성이 나중에 수치심을 느끼고 그 사람을 신고할 수 있다. 그 사람은 재미로 이야기 한 것이지만, 그 여성이 어떻게 받아들이느냐에 따라서 신고할 수도 아닐 수도 있는 것이다. 마찬가지로 장애를 복구하는 담당자가 뒤편에서 팔짱을 끼고 있는 사람을 어떻게 받아들이느냐가 중요한 것이다. 장애 담당자의 대부분은 뒤에 서있는 사람을 불편하게 생각한다는 것을 알고 있는가?


장애보고는 단 한번으로 족하다

내가 만나본 최악의 상황은 전산 담당자가 똑 같은 장애상황을 다섯 번이나 보고한 것이다. 서로 다른 병풍이 다섯 번이나 나타난 것이다. 즉 관리자A가 나타나서 어떻게 된 일인지 질문을 하면 담당자는 장애 상황에 대해 답변을 해야 한다. 그런데 관리자가 어디 한 명뿐인가? 관리자 B, 관리자 C, 관리자 D, …… 계속해서 관리자나 장애와 관련된 사람들이 나타나고 담당자는 계속 설명한다. 조금이라도 빨리 장애를 복구해야할 담당자가 일은 하지 않고 5분간 말만하고 있다. 장애가 어떻게 발생된 것인지 궁금하겠지만 조금이라도 장애시간을 줄이려면 담당자에게 질문하지 말아야 한다. 필자 또한 이런 질문을 한 적이 있다. 명백한 나의 실수였다.   

조선시대에 왕은 일반인이 알아볼 수 없도록 변장을 하고, 백성들이 어떻게 사는지 살펴보곤 하였다. 변장을 하지 않으면 어떤 식으로든 백성들에 민폐를 끼치기 때문이다. 조선시대의 왕처럼 전산실을 넘나드는 사람들도 장애를 복구하는 사람에게 어떠한 방해도 끼쳐서는 안 된다. 이 둘간의 차이점은 민폐를 끼치지 않으려는 방법이 서로 다른 것이다. 변장을 하는 것과 병풍을 치우는 것.

그림의 출처 http://www.chosun.com/site/data/html_dir/2007/10/04/2007100400081.html

병풍을 치워라
병풍치기는 담당자를 얼어붙게 만들며, 뒤편의 사람들에게 지속적으로 신경을 써야 하므로 장애상황에 집중할 수 없게 한다. 장애상황에서 빨리 해결하도록 담당자를 돕고 싶으면 병풍을 없애라. 담당자에게 장애보고를 받아야 한다고? 그렇게 하면 뭐가 달라지는가? 선 조치 후 보고를 하면 왜 안 되는가? 장애의 해결이 먼저인가? 아니면 보고가 먼저인가? 물론 관리자의 의사결정이 필요한 경우는 있다. 그러면 담당자가 자연스럽게 물어볼 것이다. 그때 결정 해주면 된다. 장애의 해결을 막는 병풍은 필요 없다. 담당자는 누구보다 장애에 대해 큰 스트레스를 받는다. 이 상태에서 병풍까지 만들어 심리적으로 괴롭히면 문제를 해결하는데 방해가 될 뿐이다.


2. 조직문화
원인을 파악하고 해결책을 구하는데 며칠이 걸린다면 이미 끝난 게임이다. 이런 일이 발생하지 않으려면 평소에 관련분야의 매뉴얼과 책을 읽어야 장애 시에 빠른 원인 파악과 조치가 가능하다. 전산 담당자는 이런 사실을 알고 있으므로 평소에 책과 매뉴얼을 읽으려고 노력한다. 책꽂이에 관련분야의 책이 꼽혀있는 이유 중 하나도 그것 때문이다. 하지만 지난 몇 십 년간 책꽂이가 전시용이라는 사실을 알고 있는가?


전문가로 키울 수 없는 회사 분위기
전산 담당자가 업무시간 중에 책을 읽으려고 하면 난리가 난다. 일은 안하고 책이나 본다는 것. 얼마나 무책임한 관리자인가? 회사를 위해 매뉴얼과 책을 읽는다는 생각은 하지 못하는 것일까? 평소에 지식을 충분히 습득하고 있으면, 장애가 발생했을 때 당황하지 않을뿐더러 장애 시간을 줄일 수 있다. 이런 것이 개인적인 일인가? 아니면 회사를 위한 일인가? 관리자는 전산담당자에게 매뉴얼과 책을 읽을 수 있는 분위기를 조성해야 한다.

 

혹시 인터넷을 금지하고 있는가? 이런 상황 또한 전문가가 만들어지기 힘든 환경이다. 많은 기업들이 제품매뉴얼을 책으로 배포하는 대신에 인터넷으로 대체하고 있다. 이런 상황에서 인터넷을 금지한다는 것은 시대착오적인 발상이다. 물론 책과 인터넷이 업무와 전혀 상관없는 일에 몰두하게 만든다면 방해가 될 수도 있다. 그런 경우까지 방치해야 한다고 주장하는 것은 아니다.

 

지식장려상을 만들어라

농담이라도업무시간 중에 책 보지 말고 시스템 모니터링을 하라라는 이야기는 하지 않아야 한다. 직원은 관리자의 눈치를 보게 마련이다. 또한 그 들은 바보가 아니다. 장애의 상황에서 시스템을 분석하지 않고 만화책을 보겠는가? 절대 그럴 일은 없다. 오히려 전문 지식의 습득을 장려하고, 정기적으로 세미나를 열어서 개인들이 공부한 것들을 조직원들에게 공유하는 것으로 회사문화를 바꾸기 바란다. 전산장애시 빠르고, 정확한 대응을 할 수 있는 전문가는 번쩍 하고 태어나는 것이 아니라, 천천히 만들어지는 것이므로.


3.
인과관계와 장기적 해법

원인의 인과관계를 끝까지 밝혀라
실제로 전산장애가 발생하면, 가정 먼저 해야 할 것은 원인 파악이다. 그 원인이라는 것이 발견하기 쉽다면 다행이지만 그렇지 않은 경우라면 시간이 많이 걸린다. 예를 들면, 단순히 Disk가 꽉 차서 서비스가 안 된다고 판단할 수 있다. 그렇다면 Disk만 추가하면 될까? 하지만 그런 단편적인 생각은 또 다른 재앙을 부를 수 있다. 좀더 면밀히 분석한 결과 Disk가 꽉 차는 현상은 Hacking에 의한 것으로 판명될 수 있다. 원인을 파악했다고 생각하지 말고 한번 더 생각하기 바란다. 즉 원인의 원인은 무엇인지 파악해야 한다.

해법인가 임시방편인가?
원인의 인과관계를 밝혔다면 근본적인 해결책을 생각해야 한다. Disk를 추가하고, Hacker IP를 잡아내어 접속하는 것을 막았다고 하자. 여기가 끝일까? 이런 일은 임시방편에 불과하다. Hacker IP를 바꿔서 공격해 온다면? 불과 몇 초 후에 시스템은 다시 뚫릴 수 있다. Hacker IP를 잡아내어 접속하는 것을 막았다고 안심하지 말고, 방화벽의 허점을 강화 할 것인지, 추가적인 보안 솔루션을 구매할 것인지를 고려하라는 것이다. 지금 당장 장애를 해결하기 위한 조치(임시방편)는 중요하다. 하지만 여기서 멈추면 안 된다. 근본적인 해결방법이 없는지 생각해 보아야 한다. 그렇지 않으면 비슷한 패턴의 장애를 또 맞을 수 있다.

 

결론
의도적이지 않은 장애는 두 가지로 구분할 수 있다. 인재(사람이 실수로 장애를 발생시킴)와 자연재해이다. 자연재해는 대비는 할 수 있지만 발생을 막을 수는 없다. 하지만, 인재는 발생을 최소화 할 수도 있고, 피해를 최소화 할 수도 있다. 그렇게 하기 위해서는 무엇보다 사람에 집중해야 한다. 즉 담당자에게 심리적 안정감을 주고 (1) 조직문화를 올바른 방향으로 유도(2)하는 것이 좋은 방법이다. 이와는 반대로 문제해결에만 집착하는 것은 발등에 덜어진 불을 끌 수는 있으나 장기적으로는 현명한 방법이 아니다. 하지만 안타깝게도 많은 기업들은 오직 문제해결의 방법(3 )에만 집중할 것이다. 1, 2, 3번을 평등하게 바라보아야 한다.

다시 한번 말하지만, 장애상황에서 담당자의 뒤를 받쳐야 하는 것은 병풍이 아니라 심리적 안정감과 풍부한 전문지식을 권장하는 조직문화이다. 이 두 가지가 장애상황을 더 빨리 해결할 수 있고, 장애를 예방할 수 있는 방법일 뿐만 아니라, 전산장애로 인한 개인과 기업의 피해를 최소화 할 수 있는 근본적인 해결책임은 물론이다. 

'IT' 카테고리의 다른 글

페이스북이 트위터보다 좋은 세가지 이유  (13) 2010.08.31
Posted by extremedb
,

더미 테이블을 사용해서 장애를 만나는 경우

더미 테이블을 사용하는 이유

더미 테이블을 사용하지 않는 방법

 

포장마차에서 지인에게 재미있는 이야기를 들었다. 물론 공장 이야기 이다. 나는 이야기를 재미있게 들었지만, 지인의 입장에서는 머리가 쭈뼛쭈뼛 서는 심각한 일이었다. 사건은 2011년 겨울에 시작된다.

 

2011 1 1일 이른 아침, 갑자기 잘 돌아가던 시스템에 몇몇 프로그램들이 작동하지 않는 장애를 만났다. Y2K 버그도 아니고 2011 1 1일에 장애라니? 서버와 네트워크 그리고 Database는 정상이므로 관심의 화살은 개발팀으로 집중되었다. 개발팀에서 장애 프로그램을 조사해보니 지난 한 달간 프로그램 수정이 없다고 하였다. 결국 모든 것이 정상인데 프로그램만 돌아가지 않는 상황이다. 귀신이 곡할 노릇이 아닌가? 빨리 정상적인 서비스를 해야 하므로 1, 1초가 아쉬운 시점이었다. 모두들 땀을 흘리며 원인을 찾고 있었다. 프로그램 담당자는 장애를 일으킨 사람을 찾으면 죽여버리겠다고 소리쳤다.

 

여러분은 이런 장애에서 안전한가?

다행히 오래 걸리지 않고 원인을 찾았다. 돌아가지 않는 프로그램들의 공통점은 더미테이블을 사용한다는 것이었다. 즉 Copy_ymd를 사용한 것이다. 그 테이블을 조사해보니 일자가 2010년 까지만 들어가 있었다. 그래서 2011년이 되자마자 장애가 발생한 것이었다. 다시 말해, Copy_ymd 테이블에 2011년 데이터가 없으므로, 이 테이블과 조인하면 한 건도 나오지 않는 것이다. 생각해보니, 모든 시스템에 이런 일이 발생할 수 있다. 이야기를 듣는 필자의 간담이 갑자기 서늘해진다.

 

시스템을 구축한 업체에게 항의하려고 문서를 찾아보니 2001년에 Open한 시스템으로 2001년 기준으로 미래의 일자를 10년치 넣어 놓았다. 소프트웨어의 라이프 사이클을 고려한다면, 10년이면 충분하다고 생각했을 것이다. 하지만 운이 없게도 차세대 프로젝트를 하지 않고 10년간 유지보수를 하면서 사용한 것이다. 그리고 인수인계서에 2011년이 되기 전에 몇 년치의 데이터를 더 넣어놓으라고 명시되어 있었다. 시스템을 구축한 업체에게 항의할 수 도 없는 일이었다. 인수인계서를 보는 사람이 한 명이라도 있었을까?

 

왜 더미 테이블을 사용할까?

데이터베이스에 관심이 있는 개발자라면 Copy_ymd, Copy_ym, Copy_y, Copy_t 등 네 개의 더미테이블을 알 것이다. 많은 시스템에 이런 더미 테이블들이 있다. 과거에는 이런 테이블들을 사용해야만 했다. 하지만 2011년의 시점에서 새로운 프로젝트를 할 때 이런 테이블들이 필요할까? 필요한지 아닌지를 알려면 먼저 더미테이블의 용도를 알아야 한다. 이 테이블들의 용도 중에서 대표적인 것은 아래와 같이 세 가지로 볼 수 있다.

 

1. Copy: 같은 집합을 여러 번 복제하여 원하는 결과집합을 구한다.

2. 데이터 체크: 일자의 경우 입력된 값이 올바른지 확인한다. 예를 들면, 2 30일은 잘못된 일자이다.

3. 인덱스의 효율적 사용: 인덱스의 첫 번째 컬럼 혹은 중간 컬럼이 Where 조건에 사용되지 않을 때 더미 테이블을 이용하여 IN으로 공급해주면 인덱스를 효율적으로 사용할 수 있다.

 

물론, 다른 용도로 더미테이블을 사용할 수 도 있지만, 대부분은 위의 세가지 경우 때문에 더미테이블이 필요하다. 가끔 기준일자를 관리하는 테이블을 볼 수 있는데, 이것은 더미테이블이 아니라 business에 필요한 것이다. 더미테이블은 업무적인 것이 아니라, 성능적인 관점, 혹은 관리적인 목적으로 사용되는 것이다. 업무적인 데이터가 없으므로 차세대 시스템을 구축할 때 더미 테이블은 분석 대상에서 빠져도 된다. 이런 이유 때문에 모델러들도 더미테이블을 중요하게 생각하지 않는다.

 

더미 테이블의 단점

위의 세 가지를 더미 테이블을 사용하지 않고 처리할 수 있다면 굳이 사용할 필요는 없다. 왜냐하면 아래와 같은 단점이 있기 때문이다.

 

첫 번째, 더미 테이블이라고 해도 시스템 속성을 추가해야만 한다. 시스템 속성이란 입력자, 입력일시, 수정자, 수정일시 등을 의미한다. 모든 테이블에 이런 컬럼들이 4 ~ 6개 정도 존재한다. 많은 기업들이 메타시스템을 사용하고 있다. 메타시스템에 테이블에 시스템 속성이 없으면 등록할 수가 없는 경우가 많다. 심지어 자동으로 시스템속성을 추가하는 메타시스템도 있다.

 

그런데 더미테이블은 튜닝의 목적이 있으므로 매우 가벼워야 한다. 생각해보라. Copy_t에 존재하는 숫자컬럼의 length3 byte에 불과한데 시스템 속성 네 개가 48 byte를 차지한다. 3 byte를 위해서 건건이 48 byte를 낭비해야 한다. 테이블이 무거워 질 수 밖에 없다. 더미 테이블은 메타시스템으로 관리하지 말고 엑셀로 관리하면 된다고? 왜 추가적인 관리를 해야만 하는가?

 

두 번째, 누가 더미 테이블을 중요하게 생각하는가? 더미 테이블을 인수인계 시 중요항목으로 관리되고 있는가? 2011년이 가까이 다가와도, Copy_ymd에 데이터를 넣어줄 생각을 하는 사람은 아무도 없었다. 왜냐하면 10년간 담당자가 세 번이나 바뀌었고, 더미테이블은 인수인계 시 중요관심사가 아니었기 때문이다. 결국 더미테이블을 신경 쓰는 사람은 아무도 없을 수 있다. 시스템은 이렇게 중요 테이블이 아니더라도 조그만 블랙홀이 생기면 장애를 맞는다. 이런 일이 발생할 수 밖에 없는 걸까?

 

세 번째, 관리해야 할 DB 서버가 많다면 위험이 증가한다. DB 팀이 관리하는 DB30개라고 가정하자. 지금 30개의 DB에 대해서 더미테이블을 관리하고 있는가? Copy_ymd에 추가적인 데이터를 insert 해야 하는 시기를 알고 있는가? 관리하고 있지 않다면 장애를 맞을 가능성이 높다. 그렇다면, 신경 쓰지 않아도 되도록, 시간이 되면 자동으로 insert되는 프로그램을 고려해 보아야 하는가? 아니면 시스템마다 더미테이블 들을 뒤져서 안전하게 100년치를 넣을 것인가? 왜 그래야 하는가? 아예 더미테이블을 사용하지 않으면 될 것을

 

지금은 운영 중이기 때문에 SQL을 바꾸는 것이 어렵다고 하더라도, 차세대 시스템을 구축할 때는 테이블을 관리할 필요도 없고, 장애도 일으키지 않는 방법이 무엇인지 고려하기 바란다. 방법은 얼마든지 있다. 이미 똑똑한 개발자들은 아래의 방법을 사용하고 하고 있다.

 

1. Copy

Copy_t 대신에 Rollup, Cube, Grouping Sets를 활용하면 원하는 집합을 만들 수 있다. 사용방법은 해당 을 참고하라. 물론 내부적으로 쿼리변환이 발생되어 UNION ALL로 풀릴 수도 있으므로 성능이 저하되는지 실행계획의 확인은 필요하다. 이런 경우에도 COPY_T는 필요 없으며 DUAL + CONNECT BY LEVEL을 사용하면 된다. 또한 LEAD/LAG를 사용한다면 복제하지 않고도 전/후의 데이터를 비교할 수 있다.

 

2. 데이터 체크

데이터를 Insert 하기 전에 일자 컬럼을 체크하려고, DBMS Call을 해야만 하나? 다시 말해, 무슨 이유 때문에 DB에 불필요한 부하를 주어야 하는가? 비슷한 노력을 들이고도 DBMS Call을 하지 않을 수 있다. 화면 단에서 Java Script로 처리하던지, 아니면 Constraint를 걸면 Insert할 때에 자동으로 체크 되므로 별도의 DBMS Call은 필요 없다. Constraint에 대해서는 관련 을 참조하라.

 

3. 인덱스의 효율적 사용

INDEX SKIP SCAN 기능이 추가되었기 때문에 IN 서브쿼리를 사용해야 되는 경우는 많이 한정 되었다. 또한 IN 서브쿼리를 사용한다고 하더라도 Copy_t, Copy_ymd 대신에 Dual + Connect By를 사용하면, Pseudo 컬럼인 Level을 사용할 수 있다. 물론 주의사항은 있다. 해당 을 참조하라.

 

3번에 대해서 어느 개발자가 다음과 같이 질문한다.

 

질문1

개발자: 인덱스가 거래일자 + 고객번호 입니다. 거래일자에 Between 조건이 들어오고 고객번호에 = 조건이 들어온다고 칩시다. 인덱스의 선두 컬럼이 Range 조건이므로 똑똑한 고객번호를 인덱스로 액세스 할 수 없습니다. 이럴 때, Copy_ymd가 있어서 거래일자를 IN 서브쿼리로 공급할 수 있었습니다. 그런데 Copy_ymd 테이블 없이 Dual + Connect By + Level로 처리가 가능 한가요? Copy_tLevel로 처리가 가능하지만 일자는 Range 조건으로 만들기 힘들 것 같은데요.

필자: 됩니다.

개발자: 어떻게요?

 

질문2

개발자: INDEX SKIP SCAN은 인덱스가 A+B+C 로 되어있고, A 혹은 B Where 조건에서 생략될 때만 사용할 수 있는 것 아닙니까? , A 컬럼에 Range 조건이 오고 B = 조건이 오면 INDEX SKIP SCAN을 사용할 수 없는 걸로 알고 있습니다만.

필자: 꼭 그런 것은 아닙니다. A 컬럼에 조건이 Between이나 LIKE 조건이 오고 B 컬럼에 = 조건이 오더라도 INDEX SKIP SCAN이 발생합니다. , 선두나 중간 컬럼의 조건이 생략될 때만 INDEX SKIP SCAN이 발생하는 것은 아니며, 선두나 중간 컬럼에 조건이 Range로 들어올 때도 발생합니다.  

개발자: 그럴 리가요?

 

이제부터 두 가지 질문에 대해 대답해보자. 먼저 Sales 테이블에 인덱스를 하나 만들고 Copy_ymd를 만들자.

 

CREATE INDEX IDX_SALES_01 ON SALES (time_id, cust_id, prod_id) ;

 

CREATE TABLE COPY_YMD AS

SELECT TO_CHAR(ROWNUM + TO_DATE('19800101', 'YYYYMMDD'), 'YYYYMMDD') AS YMD_CHAR,

       ROWNUM + TO_DATE('19800101', 'YYYYMMDD') AS YMD_DT

  FROM SALES

WHERE ROWNUM <= 14600;

 

ALTER TABLE COPY_YMD ADD CONSTRAINT PK_COPY_YMD

PRIMARY KEY (YMD_CHAR) USING INDEX; 

 

CREATE UNIQUE INDEX IDX_COPY_YMD_01 ON COPY_YMD(YMD_DT);

 

Sales 테이블의 인덱스는 Time_id _+ cust_id + Prod_id 이다. 해당 매출테이블의 transaction이 많아서 인덱스를 변경할 수도, 생성할 수도 없는 상황이라고 가정한다. 이제 테스트를 시작해보자.

 

참고로 아래의 힌트는 INDEX SKIP SCAN을 방지할 목적으로 사용한 것이다. INDEX SKIP SCAN이 나오기 전에는 이렇게 INDEX RANGE SCAN으로 수행되었다.

 

SELECT /*+ NO_INDEX_SS(S IDX_SALES_01) INDEX_RS_ASC(S IDX_SALES_01) */ s.*

  FROM sales s

 WHERE time_id BETWEEN TO_DATE('20011001', 'YYYYMMDD')

                   AND TO_DATE('20011130', 'YYYYMMDD')

   AND cust_id = 53;

 

----------------------------------------------------------------------------------------------------

| Id  | Operation                          | Name         | Starts | A-Rows |   A-Time   | Buffers |

----------------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT                   |              |      1 |      6 |00:00:00.01 |     209 |

|   1 |  TABLE ACCESS BY GLOBAL INDEX ROWID| SALES        |      1 |      6 |00:00:00.01 |     209 |

|*  2 |   INDEX RANGE SCAN                 | IDX_SALES_01 |      1 |      6 |00:00:00.01 |     203 |

----------------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   2 - access("TIME_ID">=TO_DATE(' 2001-10-01 00:00:00', 'syyyy-mm-dd hh24:mi:ss') AND "CUST_ID"=53

              AND "TIME_ID"<=TO_DATE(' 2001-11-30 00:00:00', 'syyyy-mm-dd hh24:mi:ss'))

       filter("CUST_ID"=53)

 

과거에는 선두컬럼이 Between이나 Like등의 Range 조건이 들어오면 위의 실행통계에서 볼 수 있듯이 비효율이 심했다. 고작 6건을 출력하기 위해 209 블록이나 Scan했다. 왜냐하면, 똑똑한 조건인 고객번호가 선두컬럼의 Range 조건 때문에 Access 조건이 못되고 Filter로 빠졌기 때문이다. 이런 비효율을 없애기 위해 예전에는 아래와 같이 더미테이블을 이용한 서브쿼리를 사용하였다.

 

SELECT /*+ LEADING(C@SUB) USE_NL(S) */ s.*

  FROM sales s

 WHERE time_id IN ( SELECT /*+ QB_NAME(SUB) */ ymd_dt

                      FROM copy_ymd c

                     WHERE ymd_dt BETWEEN TO_DATE('20011001', 'YYYYMMDD')

                                      AND TO_DATE('20011130', 'YYYYMMDD') )

   AND cust_id = 53;

 

--------------------------------------------------------------------------------------------------------

| Id  | Operation                           | Name            | Starts | A-Rows |   A-Time   | Buffers |

--------------------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT                    |                 |      1 |      6 |00:00:00.01 |     136 |

|   1 |  NESTED LOOPS                       |                 |      1 |      6 |00:00:00.01 |     136 |

|   2 |   NESTED LOOPS                      |                 |      1 |      6 |00:00:00.01 |     130 |

|*  3 |    INDEX RANGE SCAN                 | IDX_COPY_YMD_01 |      1 |     61 |00:00:00.01 |       4 |

|*  4 |    INDEX RANGE SCAN                 | IDX_SALES_01    |     61 |      6 |00:00:00.01 |     126 |

|   5 |   TABLE ACCESS BY GLOBAL INDEX ROWID| SALES           |      6 |      6 |00:00:00.01 |       6 |

--------------------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   3 - access("YMD_DT">=TO_DATE(' 2001-10-01 00:00:00', 'syyyy-mm-dd hh24:mi:ss') AND

              "YMD_DT"<=TO_DATE(' 2001-11-30 00:00:00', 'syyyy-mm-dd hh24:mi:ss'))

   4 - access("TIME_ID"="YMD_DT" AND "CUST_ID"=53)

       filter(("TIME_ID"<=TO_DATE(' 2001-11-30 00:00:00', 'syyyy-mm-dd hh24:mi:ss') AND

              "TIME_ID">=TO_DATE(' 2001-10-01 00:00:00', 'syyyy-mm-dd hh24:mi:ss')))

 

서브쿼리를 사용하자 고객번호를 Access 조건으로 사용할 수 있게 되었다. 이에 따라 서브쿼리를 사용하지 않은 경우(209 블럭)보다는 Scan량이 줄어 136 블록이 되었지만 약간의 비효율이 있다. Copy_ymd 때문에 4블럭을 Scan 하였다. 이것을 해결하려면 아래처럼 Dual + Connect By Level을 사용하면 된다. 위의 SQL과 아래의 SQL의 답은 같으며 아래의 SQL은 질문1의 답변에 해당한다.  

 

SELECT s.*

  FROM sales s,

      ( SELECT TO_DATE('20011001', 'YYYYMMDD') + LEVEL - 1 AS time_id

          FROM dual

       CONNECT BY LEVEL <= TO_DATE('20011130', 'YYYYMMDD') - TO_DATE('20011001', 'YYYYMMDD') + 1) d

 WHERE s.time_id = d.time_id

   AND s.cust_id = 53; 

 

-----------------------------------------------------------------------------------------------------

| Id  | Operation                           | Name         | Starts | A-Rows |   A-Time   | Buffers |

-----------------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT                    |              |      1 |      6 |00:00:00.01 |     132 |

|   1 |  NESTED LOOPS                       |              |      1 |      6 |00:00:00.01 |     132 |

|   2 |   NESTED LOOPS                      |              |      1 |      6 |00:00:00.01 |     126 |

|   3 |    VIEW                             |              |      1 |     61 |00:00:00.01 |       0 |

|   4 |     CONNECT BY WITHOUT FILTERING    |              |      1 |     61 |00:00:00.01 |       0 |

|   5 |      FAST DUAL                      |              |      1 |      1 |00:00:00.01 |       0 |

|*  6 |    INDEX RANGE SCAN                 | IDX_SALES_01 |     61 |      6 |00:00:00.01 |     126 |

|   7 |   TABLE ACCESS BY GLOBAL INDEX ROWID| SALES        |      6 |      6 |00:00:00.01 |       6 |

-----------------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   6 - access("S"."TIME_ID"=INTERNAL_FUNCTION("D"."TIME_ID") AND "S"."CUST_ID"=53)

 

Dual을 사용했기 때문에 Block I/O가 없어졌다. 하지만 여기서 만족하면 안 된다. 왜냐하면 쓸모 없는 조인이 61번이나 시도되었고 이에 따라 126블록을 Scan하였기 때문이다. 따라서 SQL을 아래처럼 바꾸어야 한다.

 

SELECT /*+ INDEX_SS(S IDX_SALES_01) */ s.*

  FROM sales s

 WHERE time_id BETWEEN TO_DATE('20011001', 'YYYYMMDD')

                   AND TO_DATE('20011130', 'YYYYMMDD')

   AND cust_id = 53;

 

----------------------------------------------------------------------------------------------------

| Id  | Operation                          | Name         | Starts | A-Rows |   A-Time   | Buffers |

----------------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT                   |              |      1 |      6 |00:00:00.01 |      70 |

|   1 |  TABLE ACCESS BY GLOBAL INDEX ROWID| SALES        |      1 |      6 |00:00:00.01 |      70 |

|*  2 |   INDEX SKIP SCAN                  | IDX_SALES_01 |      1 |      6 |00:00:00.01 |      64 |

----------------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   2 - access("TIME_ID">=TO_DATE(' 2001-10-01 00:00:00', 'syyyy-mm-dd hh24:mi:ss') AND "CUST_ID"=53

              AND "TIME_ID"<=TO_DATE(' 2001-11-30 00:00:00', 'syyyy-mm-dd hh24:mi:ss'))

       filter("CUST_ID"=53)

 

불필요한 조인도 없어졌으며 Block I/O도 서브쿼리를 사용할 때에 비해서 약 절반으로 줄어들었다. 이것이 질문 2에 대한 대답이다.

 

참고사항

위의 SQL들을 보면 인덱스가 cust_id + time_id로 되어 있는 것이 최적이지만 막상 튜너가 현장에 투입되면 인덱스를 변경/생성/삭제 하기는 대단히 어려우므로 위의 방법을 잘 알아놓아야 한다.

 

결론

Copy_ymd, Copy_ym, Copy_y, Copy_t는 구시대의 유물이다. 성능에도 좋지 않으며, 코드가 길어지고, 장애가 발생할 수 있음에도 여러 가지 이유를 대어 차세대 시스템에 더미 테이블들이 또 포함될 수 있다. 안타깝게도 관행이나 표준으로 생각하는 사람이 많기 때문이다. 이제는 바뀔 때가 되었다. 지금 운영되는 모든 시스템에서 더미테이블을 사용하는 SQL을 모조리 조사해서 고치라는 이야기가 아니다. 그렇게 하기는 힘들 것이다. 다만 모든 더미테이블을 찾아서 미래의 데이터를 미리 그리고 넉넉히 넣자는 이야기 이다. 그리고 앞으로 시작될 프로젝트에서 더미테이블을 사용하지 않았으면 하는 것이 나의 바램이다. 당신이 발 뻗고 잘 수 있도록

'Oracle > SQL Tuning' 카테고리의 다른 글

Sort 부하를 좌우하는 두 가지 원리  (11) 2011.03.29
SQL튜닝 방법론  (20) 2011.01.27
Pagination과 분석함수의 위험한 조합  (26) 2010.12.23
오라클의 Update문은 적절한가?  (15) 2010.04.14
Connect By VS ANSI SQL  (7) 2010.02.11
Posted by extremedb
,

▶적절한 인덱스가 없을 때 Sort의 부하를 줄이는 방법

Pagination에서 Sort의 부하 줄이기

주의사항

 

Order by 절에 의한 Sort의 부하는 성능에 치명적이다. Block I/O의 최소화는 분명 튜닝의 핵심이다. 하지만, Block I/O를 최소화 해도 Sort의 부하가 심하다면 결코 만족스런 성능을 내지 못한다. 특히페이징 처리용 SQL에는 Sort를 대신할 수 있는 인덱스가 있어야 성능을 확보할 수 있다고 많은 튜너들이 주장한다. 맞는 말이다. 그렇게만 된다면 Sort가 전혀 발생하지 않을 테니까. 하지만, 다음과 같은 어려움도 있다.

 

인덱스 최적화가 힘든 이유
첫 번째, 인덱스를 모든 조회화면의 기준에 맞게 만들려면 테이블마다 많은 수의 인덱스가 필요할 것이다. 두 번째, 운영중인 환경에서 인덱스를 생성 혹은 변경하기는 매우 어렵다. 따라서, 인덱스를 만들기 어렵다면, Sort의 부하를 최소화하는 다른 방법은 없는지를 고려해야 한다. 분명히 방법은 있다. 이 방법을 알지 못한다면 오직 인덱스에만 목숨을 거는 사람이 될 가능성이 높다. 오늘은 인덱스를 전혀 만들지 않은 상태에서 Sort의 부하를 최소화 하는 방법에 대해 알아볼 것이다.

 

단 한 가지 개념만 안다면, Order By에 의한 Sort의 부하를 이해한 것이다. Sort의 부하량은 면적에 비례한다는 것. 이 개념은 아래와 같이 표현할 수 있다. 참고로 는 비례한다는 의미이다.

 

Sort의 부하량(PGA 사용량) ∝ 세로(결과 건수) X 가로(컬럼 Size 합계)

 

공식의 이해가 부족하다
주위의 지인들에게 위의 식을 질문한 결과 거의 모두가 세로에 대해서는 정확히 이해하고 있었다. , Sort할 건수가 많아지면 Sort의 부하가 증가 한다는 것이다. 이에 반해서 가로에 대해서는 정확한 이해를 하는 사람이 드물었다. 대부분, Order By절에 의해 Sort의 부하가 발생하므로 Order By절에 존재하는 컬럼 Size의 합계가 가로라고 생각하는 것이다. 다시 말해, Order By절의 컬럼이 세 개라면, 세 컬럼의 Size를 합친 것이 가로라는 것이다. 과연 그럴까?


위의 주장을 검증하기 위해 테이블을 하나 만들고, 추가적으로 컬럼을 3개 만들자.

 

CREATE TABLE SALES_T NOLOGGING AS SELECT * FROM SALES;

ALTER TABLE SALES_T ADD (char_100  CHAR(100)  DEFAULT 'a' NOT NULL );

ALTER TABLE SALES_T ADD (char_1000 CHAR(1000) DEFAULT 'a' NOT NULL );

ALTER TABLE SALES_T ADD (char_2000 CHAR(2000) DEFAULT 'a' NOT NULL );

 

추가된 컬럼은 모두 Char Type이며 Default 값이 ‘a’ 이다. Char Type이므로 Default값인 ‘a’가 들어오는 경우 컬럼 size는 각각 100, 1000, 2000 바이트씩 채워진다. 이제 이 컬럼들을 이용하여 SQL을 각각 실행해보자. 100 byte, 1000 byte, 2000 byte 컬럼으로 각각 Sort하여 Sort의 부하가 어떻게 달라지는지 알아보자.

 

CREATE TABLE SORT_100 NOLOGGING AS

SELECT /*+ full(p) full(c) */

       s.prod_id, p.prod_name, s.cust_id, c.cust_first_name,

       c.cust_last_name, s.time_id, s.channel_id, s.char_100

  FROM sales_t s, customers c, products p

 WHERE s.cust_id = c.cust_id

   AND s.prod_id = p.prod_id

   AND s.prod_id = 30

 ORDER BY s.char_100 ;

 

------------------------------------------------------------------------------------------------

| Id  | Operation              | Name      | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |

------------------------------------------------------------------------------------------------

|   0 | CREATE TABLE STATEMENT |           |      1 |      0 |00:00:01.41 |    7323 |          |

|   1 |  LOAD AS SELECT        |           |      1 |      0 |00:00:01.41 |    7323 |  521K (0)|

|   2 |   SORT ORDER BY        |           |      1 |  29282 |00:00:01.23 |    5915 | 4708K (0)|

|*  3 |    HASH JOIN           |           |      1 |  29282 |00:00:01.15 |    5915 | 3471K (0)|

|   4 |     TABLE ACCESS FULL  | CUSTOMERS |      1 |  55500 |00:00:00.21 |    1468 |          |

|   5 |     NESTED LOOPS       |           |      1 |  29282 |00:00:00.66 |    4447 |          |

|*  6 |      TABLE ACCESS FULL | PRODUCTS  |      1 |      1 |00:00:00.01 |       7 |          |

|*  7 |      TABLE ACCESS FULL | SALES_T   |      1 |  29282 |00:00:00.59 |    4440 |          |

------------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   3 - access("S"."CUST_ID"="C"."CUST_ID")

   6 - filter("P"."PROD_ID"=30)

   7 - filter("S"."PROD_ID"=30)

 

100 byte컬럼으로 Sort하니 PGA4.7MB 사용하였다. 이제 100 byte보다 10배나 큰 1000 byte 컬럼으로 Sort 하여 PGA 사용량을 비교해보자.

 

CREATE TABLE SORT_1000 NOLOGGING AS

SELECT /*+ full(p) full(c) */

       s.prod_id, p.prod_name, s.cust_id, c.cust_first_name,

       c.cust_last_name, s.time_id, s.channel_id, s.char_1000

  FROM sales_t s, customers c, products p

 WHERE s.cust_id = c.cust_id

   AND s.prod_id = p.prod_id

   AND s.prod_id = 30

 ORDER BY s.char_1000 ;

 

------------------------------------------------------------------------------------------------

| Id  | Operation              | Name      | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |

------------------------------------------------------------------------------------------------

|   0 | CREATE TABLE STATEMENT |           |      1 |      0 |00:00:02.17 |   13162 |          |

|   1 |  LOAD AS SELECT        |           |      1 |      0 |00:00:02.17 |   13162 |  521K (0)|

|   2 |   SORT ORDER BY        |           |      1 |  29282 |00:00:01.75 |    5915 |   30M (0)|

|*  3 |    HASH JOIN           |           |      1 |  29282 |00:00:01.56 |    5915 | 3486K (0)|

|   4 |     TABLE ACCESS FULL  | CUSTOMERS |      1 |  55500 |00:00:00.22 |    1468 |          |

|   5 |     NESTED LOOPS       |           |      1 |  29282 |00:00:01.05 |    4447 |          |

|*  6 |      TABLE ACCESS FULL | PRODUCTS  |      1 |      1 |00:00:00.02 |       7 |          |

|*  7 |      TABLE ACCESS FULL | SALES_T   |      1 |  29282 |00:00:00.98 |    4440 |          |

------------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   3 - access("S"."CUST_ID"="C"."CUST_ID")

   6 - filter("P"."PROD_ID"=30)

   7 - filter("S"."PROD_ID"=30)

 

Sort 컬럼의 size 100에서 1000 byte로 늘리자 PGA 사용량도 4.7 MB에서 30 MB로 크게 늘었다. 내친김에 Order By절을 2000 byte 컬럼으로 바꿔서 PGA 사용량이 얼마나 늘어나는지 테스트 해보자.

 

CREATE TABLE SORT_2000 NOLOGGING AS

SELECT /*+ full(p) full(c) */

       s.prod_id, p.prod_name, s.cust_id, c.cust_first_name,

       c.cust_last_name, s.time_id, s.channel_id, s.char_2000

  FROM sales_t s, customers c, products p

 WHERE s.cust_id = c.cust_id

   AND s.prod_id = p.prod_id

   AND s.prod_id = 30

 ORDER BY s.char_2000 ;

 

------------------------------------------------------------------------------------------------

| Id  | Operation              | Name      | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |

------------------------------------------------------------------------------------------------

|   0 | CREATE TABLE STATEMENT |           |      1 |      0 |00:00:03.16 |   19298 |          |

|   1 |  LOAD AS SELECT        |           |      1 |      0 |00:00:03.16 |   19298 |  521K (0)|

|   2 |   SORT ORDER BY        |           |      1 |  29282 |00:00:02.06 |    5915 |   58M (0)|

|*  3 |    HASH JOIN           |           |      1 |  29282 |00:00:01.74 |    5915 | 3515K (0)|

|   4 |     TABLE ACCESS FULL  | CUSTOMERS |      1 |  55500 |00:00:00.24 |    1468 |          |

|   5 |     NESTED LOOPS       |           |      1 |  29282 |00:00:01.19 |    4447 |          |

|*  6 |      TABLE ACCESS FULL | PRODUCTS  |      1 |      1 |00:00:00.02 |       7 |          |

|*  7 |      TABLE ACCESS FULL | SALES_T   |      1 |  29282 |00:00:01.12 |    4440 |          |

------------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   3 - access("S"."CUST_ID"="C"."CUST_ID")

   6 - filter("P"."PROD_ID"=30)

   7 - filter("S"."PROD_ID"=30)

 

예상대로 Sort 대상 컬럼을 1000에서 2000 byte로 바꾸자 PGA 사용량이 30 MB에서 58MB 로 약 두 배 늘었다. 위의 결과를 언뜻 보면, Order By절에 존재하는 컬럼 Size의 합계가 가로라고 생각할 수 있다. 왜냐하면, Sort 대상컬럼의 Size에 비례하여 PGA 사용량이 증가되었다고 판단하기 때문이다. 하지만 이런 생각은 절반만 옳고 나머지 절반은 틀렸다. 제대로 된 식은 다음과 같다.

 

Sort의 부하를 좌우하는 원리

Sort
의 부하량(PGA 사용량) ∝ 세로 X 가로

세로: SQL의 결과 건수

가로: Order by 절의 컬럼 size + Order by 절을 제외한 나머지 컬럼의 size

 

근거 있는 주장인가?
이 공식이 글 전체의 핵심이다. 하지만, 많은 사람들이 위와 같은 가로 세로 개념을 주장할 수 있는 근거가 무엇인지 궁금해한다. 이제 가로가 Order by 절의 컬럼 size + 나머지 컬럼의 size라는 주장에 대한 근거를 보자.

 

SELECT s.channel_id, s.char_2000

  FROM sales_t s

 WHERE s.prod_id = 30

 ORDER BY s.channel_id;

 

------------------------------------------------------------------------------------------

| Id  | Operation          | Name    | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |

------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT   |         |      1 |  29282 |00:00:00.84 |    4440 |          |

|   1 |  SORT ORDER BY     |         |      1 |  29282 |00:00:00.84 |    4440 |   56M (0)|

|*  2 |   TABLE ACCESS FULL| SALES_T |      1 |  29282 |00:00:00.68 |    4440 |          |

------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   2 - filter("S"."PROD_ID"=30)

 

Order By절에는 Size가 작은 channel_id 컬럼 뿐이다. 그런데 PGA 사용량은 56 MB나 된다. 세로가 3만 건도 안 되는 집합을 Sort하는데 그 부하는 56 MB나 된다. 이상하지 않은가? 과부하의 이유는 Select절의 char_2000 컬럼 때문이다. 이 컬럼을 Select 절에서 제거하고 다시 실행해 보자.

 

SELECT s.channel_id

  FROM sales_t s

 WHERE s.prod_id = 30

 ORDER BY s.channel_id;

 

------------------------------------------------------------------------------------------

| Id  | Operation          | Name    | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |

------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT   |         |      1 |  29282 |00:00:00.64 |    4440 |          |

|   1 |  SORT ORDER BY     |         |      1 |  29282 |00:00:00.64 |    4440 |  424K (0)|

|*  2 |   TABLE ACCESS FULL| SALES_T |      1 |  29282 |00:00:00.58 |    4440 |          |

------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   2 - filter("S"."PROD_ID"=30)

 

Order By절 이외의 컬럼에 주목하라 
Select
절의 char_2000 컬럼을 제거하자 Sort의 부하는 424K로 급격히 줄어들었다. 왜냐하면, Sort Area에는 Order By절의 컬럼을 Sort할뿐만 아니라 나머지 컬럼 List Loading 되기 때문이다. Order By절 뿐만 아니라, Select 절에도 size가 큰 컬럼이 있다면 성능이 급격히 저하됨을 알 수 있다. 지금까지 Sort부하량 공식에 의해 가로는 Order By절 컬럼 Size + Sort 대상 이외의 컬럼 Size가 됨을 증명해 보았다.

 

이제 이 개념을 실제 SQL 프로그래밍에 적용해보자. 아래는 전형적인 Pagination SQL이다. 아래의 예제에서 처음의 약속을 지키기 위해 인덱스를 만들지도, 사용하지도 않을 것이다.
 

SELECT *

  FROM (SELECT a.*, ROWNUM rnum

          FROM (SELECT s.prod_id, p.prod_name, p.prod_category_desc, s.cust_id,

c.cust_first_name, c.cust_last_name, s.time_id, s.channel_id,

                       s.char_100, s.char_1000, s.char_2000

                  FROM sales_t s, customers c, products p

                 WHERE s.cust_id = c.cust_id

                   AND s.prod_id = p.prod_id

                   AND s.channel_id = 3

                 ORDER BY c.cust_first_name, c.cust_last_name, p.prod_category_desc, s.time_id ) a

         WHERE ROWNUM <= :v_max_row ) --> 200 대입

 WHERE rnum >= :v_min_row ;           --> 1 대입

 

--------------------------------------------------------------------------------------------------

| Id  | Operation                | Name      | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |

--------------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT         |           |      1 |    200 |00:00:08.71 |    5915 |          |

|*  1 |  VIEW                    |           |      1 |    200 |00:00:08.71 |    5915 |          |

|*  2 |   COUNT STOPKEY          |           |      1 |    200 |00:00:08.71 |    5915 |          |

|   3 |    VIEW                  |           |      1 |    200 |00:00:08.71 |    5915 |          |

|*  4 |     SORT ORDER BY STOPKEY|           |      1 |    200 |00:00:08.71 |    5915 | 3321K (0)|

|*  5 |      HASH JOIN           |           |      1 |    540K|00:00:06.30 |    5915 | 1176K (0)|

|   6 |       TABLE ACCESS FULL  | PRODUCTS  |      1 |     72 |00:00:00.01 |       7 |          |

|*  7 |       HASH JOIN          |           |      1 |    540K|00:00:03.91 |    5908 | 3568K (0)|

|   8 |        TABLE ACCESS FULL | CUSTOMERS |      1 |  55500 |00:00:00.21 |    1468 |          |

|*  9 |        TABLE ACCESS FULL | SALES_T   |      1 |    540K|00:00:01.14 |    4440 |          |

--------------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   1 - filter("RNUM">=:V_MIN_ROW)

   2 - filter(ROWNUM<=:V_MAX_ROW)

   4 - filter(ROWNUM<=:V_MAX_ROW)

   5 - access("S"."PROD_ID"="P"."PROD_ID")

   7 - access("S"."CUST_ID"="C"."CUST_ID")

   9 - filter("S"."CHANNEL_ID"=3)

 

페이징 처리된 SQL Sort 부하량은 3321K 이다. 이제 Sort의 부하를 줄이기 위해 select 절의 모든 컬럼을 제거하자.

 

SELECT s.prod_id, p.prod_name, p.prod_category_desc, s.cust_id,

c.cust_first_name, c.cust_last_name, s.time_id, s.channel_id,

        s.char_100, s.char_1000, s.char_2000

  FROM (SELECT a.*, ROWNUM rnum

          FROM (SELECT s.rowid as s_rid, p.rowid as p_rid, c.rowid as c_rid

                  FROM sales_t s, customers c, products p

                 WHERE s.cust_id = c.cust_id

                   AND s.prod_id = p.prod_id

                   AND s.channel_id = 3

                 ORDER BY c.cust_first_name, c.cust_last_name, p.prod_category_desc, s.time_id ) a

         WHERE ROWNUM <= :v_max_row ) a,   --> 200 대입   

       sales_t s, customers c, products p

   WHERE s.rowid  = a.s_rid

   and c.rowid = a.c_rid

   and p.rowid = a.p_rid

   and rnum >= :v_min_row ;                --> 1 대입

 

-------------------------------------------------------------------------------------------------------

| Id  | Operation                     | Name      | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |

-------------------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT              |           |      1 |    200 |00:00:06.51 |    6168 |          |

|   1 |  NESTED LOOPS                 |           |      1 |    200 |00:00:06.51 |    6168 |          |

|   2 |   NESTED LOOPS                |           |      1 |    200 |00:00:06.51 |    5969 |          |

|   3 |    NESTED LOOPS               |           |      1 |    200 |00:00:06.51 |    5918 |          |

|*  4 |     VIEW                      |           |      1 |    200 |00:00:06.51 |    5915 |          |

|*  5 |      COUNT STOPKEY            |           |      1 |    200 |00:00:06.51 |    5915 |          |

|   6 |       VIEW                    |           |      1 |    200 |00:00:06.51 |    5915 |          |

|*  7 |        SORT ORDER BY STOPKEY  |           |      1 |    200 |00:00:06.51 |    5915 |96256  (0)|

|*  8 |         HASH JOIN             |           |      1 |    540K|00:00:05.81 |    5915 | 1193K (0)|

|   9 |          TABLE ACCESS FULL    | PRODUCTS  |      1 |     72 |00:00:00.01 |       7 |          |

|* 10 |          HASH JOIN            |           |      1 |    540K|00:00:03.65 |    5908 | 4514K (0)|

|  11 |           TABLE ACCESS FULL   | CUSTOMERS |      1 |  55500 |00:00:00.22 |    1468 |          |

|* 12 |           TABLE ACCESS FULL   | SALES_T   |      1 |    540K|00:00:01.06 |    4440 |          |

|  13 |     TABLE ACCESS BY USER ROWID| CUSTOMERS |    200 |    200 |00:00:00.01 |       3 |          |

|  14 |    TABLE ACCESS BY USER ROWID | PRODUCTS  |    200 |    200 |00:00:00.01 |      51 |          |

|  15 |   TABLE ACCESS BY USER ROWID  | SALES_T   |    200 |    200 |00:00:00.01 |     199 |          |

-------------------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   4 - filter("RNUM">=:V_MIN_ROW)

   5 - filter(ROWNUM<=:V_MAX_ROW)

   7 - filter(ROWNUM<=:V_MAX_ROW)

   8 - access("S"."PROD_ID"="P"."PROD_ID")

  10 - access("S"."CUST_ID"="C"."CUST_ID")

  12 - filter("S"."CHANNEL_ID"=3)

 

 

Trade Off가 유리한 경우

Sort 부하량이 3321K에서 96K로 약 34.5배 줄어들었다. 이렇게 ROWID만 남기고 select 절의 모든 컬럼을 제거해도 결과는 같다. 왜냐하면, Sort된 상태로 rowid가 보관되어있기 때문이다. 페이징 처리가 모두 끝나고 200건에 대해서만 rowid로 테이블에 접근하기 때문에 테이블의 중복사용에 의한 비효율은 매우 적다. Buffers 항목을 비교해보면 5915 블록에서 6168 블록으로 비효율은 253 블록(4%) 밖에 차이가 나지 않는다. 하지만 Sort의 부하는 34.5배나 줄어들었다. 약간의 Block I/O를 손해 보더라도 Sort의 부하가 아주 큰 경우는 같은 블록을 중복해서 읽어야 함을 알 수 있다.

 

장점 + 장점

이렇게 Rowid를 제외한 Select List를 인라인뷰 외부로 빼면, Sort 부하의 최소화 이외에 또 다른 효과를 누릴 수도 있다. 인덱스만 읽고 테이블로의 접근을 하지 않을 수 있다. Where 조건에 최적화된 인덱스가 존재하고, 그 인덱스가 Order By절 컬럼을 포함 한다면 인라인뷰 내부에서는 테이블 접근을 하지 않는다. 물론 Select List의 모든 컬럼들을 가져오려면 테이블을 접근해야 한다. 하지만 위의 예제처럼 Rowid를 사용했다면 페이징 처리가 끝난 후에, 특정 페이지에 해당하는 건들만 테이블로 접근할 수 있으므로 Random Access 도 최소화 된다. Sort를 최소화 하려고 했더니 Block I/O를 최소화 하는것까지 덤으로 얻을 수 있는 것이다.    

 

주의사항

SELECT 절에 상수나 변수 심지어 NULL이 오더라도 PGA 사용량은 증가하므로 주의해야 한다.
 

SELECT s.cust_id

  FROM sales_t s

 ORDER BY s.cust_id;

 

------------------------------------------------------------------------------------------

| Id  | Operation          | Name    | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |

------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT   |         |      1 |    918K|00:00:03.38 |    4440 |          |

|   1 |  SORT ORDER BY     |         |      1 |    918K|00:00:03.38 |    4440 |   13M (0)|

|   2 |   TABLE ACCESS FULL| SALES_T |      1 |    918K|00:00:01.38 |    4440 |          |

------------------------------------------------------------------------------------------

 

Select 절에 다른 컬럼이 없기 때문에 PGA 13MB 사용 하였다. 이번에는 Select절에 Null을 추가해보자.

 

SELECT s.cust_id, null

  FROM sales_t s

 ORDER BY s.cust_id;

 

------------------------------------------------------------------------------------------

| Id  | Operation          | Name    | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |

------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT   |         |      1 |    918K|00:00:03.48 |    4440 |          |

|   1 |  SORT ORDER BY     |         |      1 |    918K|00:00:03.48 |    4440 |   17M (0)|

|   2 |   TABLE ACCESS FULL| SALES_T |      1 |    918K|00:00:01.37 |    4440 |          |

------------------------------------------------------------------------------------------

 

Select List Null을 추가하자 PGA사용량이 4MB 증가했다. 오라클은 Null이나 상수도 컬럼처럼 취급함을 알 수 있다. 따라서 Order by절이 있는 인라인뷰의 Select List에 상수나 변수 혹은 Null을 집어넣는 것은 Sort의 부하를 증가시킨다. 상수나 변수는 Order By가 있는 인라인뷰에 넣지 말고 외부로 빼서 사용하면 된다.

결론
Sort의 부하를 최소화 하려면 Order By절의 컬럼에만 집중해서는 안되며, 전체 컬럼 List를 바라보아야 한다. 또한 프로그래밍을 할 때 상수 하나, 변수 하나의 위치도 고려해야 최적의 성능을 가진 프로그램이 됨을 알 수 있다. 즉 Sort의 최적화는 튜닝의 문제일 뿐만 아니라 프로그래밍의 문제인 것이다. 이점은 Sort 부하량이 무엇으로 결정되는지 개발자도 알아야 하는 이유가 된다.

면적은 가로와 세로로 구성된다.

Sort의 부하량은 면적의 크기에 비례한다.

Sort의 부하량 ∝ 결과 건수 X 전체 컬럼 Size

'Oracle > SQL Tuning' 카테고리의 다른 글

COPY_T 테이블 필요한가?  (6) 2011.04.04
SQL튜닝 방법론  (20) 2011.01.27
Pagination과 분석함수의 위험한 조합  (26) 2010.12.23
오라클의 Update문은 적절한가?  (15) 2010.04.14
Connect By VS ANSI SQL  (7) 2010.02.11
Posted by extremedb
,

11.2.0.2에서 파티션 생성시 용량부족 현상

Partition Large Extents란 무엇인가

Deferred Segment Creation과 Partition Large Extents의 관계 


오라클 11.2.0.2 에서 황당한 일이 발생했다. DBA는 데이터가 없는 빈 테이블을 파티션으로 생성하려고 했다. 그런데 느닷없이 용량부족 에러가 발생한 것이다. 디스크용량은 10 Giga Bytes나 여유가 있으므로 이런 에러메시지를 만날 이유가 없다. 아무리 파티션이 많은 테이블이라도 기껏해야 100MB 정도의 공간만 있으면 테이블을 문제없이 생성했던 DBA로서는 이유를 알 수 없었다. 어찌되었건 문제를 해결해야 하므로 디스크를 30 Giga Bytes 추가했더니 더 이상 에러가 발생하지 않았다고 한다.

 

데이터가 한 건도 없는 파티션 테이블 하나 생성하는데 10 Giga Bytes 이상의 저장공간이 필요하다니? 이런 사실이 이상하다고 생각한다면 오라클 11.2.0.2의 새 기능을 모르고 있는 것이다. 새 기능을 모르면 원인을 파악 할 수 없고, 문제를 해결할 수도 없다. 이 글은 오라클의 새 기능이 어떻게 용량부족 에러를 발생시키는지, 그 에러를 예방하는 방법은 무엇인지 설명한다.

 

해당 테이블 설명

생성하려는 파티션 테이블은 보관기간이 10년이고, 거래일자로 월별 Range 파티션이 되어있고, 서브파티션은 Hash 파티션이 12개씩 잡혀있었다. 그 테이블을 Create 하려면 Disk가 얼마나 필요할까? dba_segments에서 해당 테이블의 용량을 조회 해보니 빈 테이블 하나가 11.52 Giga Bytes나 차지하고 있었다. 이에 따라 다음과 같이 추론 할 수 있다.

 

주 파티션( 10 x 12 개월 ) x 서브 파티션( 12 Hash ) x 8MB = 11520 MB

 

데이터도 없는데 8MB를 소모하는 이유

위의 계산식을 본다면 데이터가 한 건도 없음에도 불구하고, 오라클이 파티션 하나당 8MB를 할당했다는 결론이 나온다. 이런 현상은 11.2.0.2부터 파티션 테이블에서만 발생한다. 파티션 테이블을 만들었을 뿐, 데이터는 한 건도 없다. 그럼에도 불구하고 각 파티션 마다 저장공간을 8MB씩 낭비한 셈이다. 오라클은 왜 이런 무모한 결정을 했을까? 언제나 그렇듯이 비밀은 매뉴얼에 있는 경우가 많다. 아래의 내용이 저장공간을 낭비하는 이유이다.

 

Note:

Beginning with release 11.2.0.2 of Oracle Database, the default size of the first extent of any new segment for a partitioned table is 8 MB instead of 64 KB. This helps improve performance of inserts and queries on partitioned tables.

 

매뉴얼 참조: Estimating the Space Use of a Table

 

위의 글을 해석해보면 성능 향상을 위한 목적임을 알 수 있다. , 처음에 8MB 만큼 공간을 미리 할당하면, 추후에 INSERT가 들어올 때 공간을 다시 할당할 필요가 없으므로 속도가 좋아진다는 것. 그리고 자잘한 extent로 쪼개짐을 방지하여 쿼리의 성능을 높이자는 것이다. 이를 partition large extent 라고 한다. 이론적으로는 무모한 결정이 아님을 알 수 있다.

 

성능 때문에 용량 문제가 발생해도 좋은가?

하지만 이 개념 때문에 파티션 개수가 많은 테이블을 생성하지 못하는 문제가 생긴다. 즉 빈 공간이 90 기가 바이트라고 해도, 파티션 개수가 많은 테이블들은 몇 개도 생성하지 못한다는 결론이 나온다. 이런 기능을 성능이 빨라진다고 해서 오라클이 무조건 적용 할 수 있을까? 오라클은 무언가 믿는 구석이 있었기 때문에 자신 있게 적용할 수 있었다. 그것이 무엇일까? 11.2 매뉴얼을 읽어본 사람이라면 추측 할 수 있을 것이다.

 

빗나간 예측
Understand Deferred Segment Creation note 부분을 참조하면 오라클이 자신감 있게 파티션 마다 8MB를 할당할 수 있었던 이유가 나온다. Deferred Segment Creation(이하 DSC)이라는 기능 때문이다. DSC 기능 덕분에 테이블이 생성될 때는 물리적인 Disk 공간을 차지하지 않는다. 다시 말해, 테이블을 만들 때 공간을 할당하는 것이 아니라, 추후에 데이터가 insert 될 때 각 파티션에 대해 8MB씩 할당한다. DSC는 버전 11.2.0.2 부터 일반테이블 뿐만 아니라, 파티션 테이블에도 적용이 가능해졌다. 오라클은 파티션마다 8MB씩 할당해야 되지만 DSC 기능이 있으므로, 파티션을 생성할 때는 용량문제가 없을 것이라고 생각했을 것이다.

 

하지만, 항상 문제가 되는 것은 이론이 아니라 현실에서 발생한다. 현실에서는 여러 가지 버그 때문에 DSC를 사용할 수 없다. 따라서 해당 파라미터인 DEFERRED_SEGMENT_CREATION False로 놓을 수 밖에 없다. DSC를 사용할 수 없으므로 테이블을 만들 때 각 파티션마다 물리적인 디스크 공간을 8MB씩 차지하게 된다. 따라서, 파티션 개수가 많은 테이블을 만들 때 DSC 기능이 꺼져 있다면 용량부족 현상이 발생할 수 밖에 없다. 오라클로서도 어쩔 수 없는 일이다. 버그를 예상할 수는 없는 노릇 아닌가? DSC의 버그가 해결되면 이런 문제는 자연스럽게 사라질 것이다.

 

DSC에 관한 버그는 조동욱 님의 글 Deferred Segment Creation 재미있는 두가지 버그를 참고하기 바란다.

 

해결방법

DSC는 현재 버그가 있으므로, 기능을 사용하지 못한다. 따라서 무조건 각 파티션에 8MB씩 할당이 되므로 엄청난 Disk 공간이 필요한 것이다. 파티션 테이블을 11.2.0.2 이상에서 생성할 계획이라면 수동으로 STORAGE (INITIAL 65536) 를 주면 된다. 이렇게 하면 처음에 8MB가 아니라 64KB만 공간을 차지한다. 이렇게 설정하면 이전 버전 11.2.0.1처럼 사용하는 것이다.

 

관련 파라미터

_partition_large_extents Default 값은 True이다. True로 되어있으면 각 파티션에 64kb가 아니라 8MB가 할당된다. 이 파라미터는 버전 11.2.0.1cell_partition_large_extents 파라미터를 대체한다. _index_partition_large_extents 도 위의 파라미터와 같은 역할을 한다. 다만 파티션이 아니라 파티션 인덱스라는 점만 다르다. 두 가지 파라미터 _partition_large_extents_index_partition_large_extents 모두 11.2.0.2에서 소개되었다.

 

결론

11.2.0.2에서 버그 때문에 DSC 기능을 막아 놓았다면, 파티션 테이블을 생성할 때 STORAGE 절에 적절한 INITIAL 값을 주기 바란다. 그렇지 않으면 Disk 공간을 많이 차지하게 된다. 특히 각 파티션당 용량이 많지 않지만 관리목적상 파티션을 하는 경우는 INITIAL 값을 주는 것이 유리하다. 이렇게 하면 저장공간을 낭비하지 않고 테이블을 관리 할 수 있다. 또한 개발 DB는 운영 DB에 비해 Disk가 부족하므로 이런 사실을 잘 알아야 에러가 나더라도 당황하지 않는다. 개인적인 목적으로 PC나 노트북등에 오라클을 설치하는 경우도 Disk가 넉넉하지 않을 것이므로 마찬가지이다.

 

오라클 매뉴얼은 여러 번 보아야 한다. 예를 들어 11.2.0.1 매뉴얼을 이미 정독한 사람은 11.2.0.2 매뉴얼을 볼 필요가 없다고 생각 할 수 있다. 결코 그렇지 않다. 이 글에서 나타난 문제만 보아도 11.2.0.2 매뉴얼을 보았다면 전혀 문제가 되지 않을 것이다. 따라서 이전 매뉴얼을 정독 했더라도 11.2.0.2 버전의 새로운 기능은 익혀야 한다. 물론 매뉴얼 전체를 다시 볼 필요는 없다. 예를 들어 Admin가이드의 경우 매뉴얼의 첫 부분인 What's New in Oracle Database Administrator's Guide? 만 참조하면 무슨 기능이 추가된 것인지 쉽게 파악 할 수 있다. 어디에 무슨 내용이 있는지 안다면, 문제의 절반은 해결한 셈이다.

 

관련문서

매뉴얼: Oracle® Database Administrator's Guide 11g Release 2 (11.2)
오라클 노트: 1295484.1

'Oracle > Partition' 카테고리의 다른 글

Oracle 10g Partitioning 가이드  (12) 2009.08.05
Posted by extremedb
,

창의성을 얻는 방법의 문제점

같은 결과를 놓고 왜 해석이 다를까?

▶창의성을 얻는 방법 세 가지

 

많은 사람들이 창의성을 원하고 있다. 이에 따라 여러 가지 창의성 관련서적이 나오고 있다. 그 서적들에서 창의성을 키우는 방법으로 다음과 같은 것들을 공통적으로 이야기 한다. 아마도 새로운 것을 발견할 때 사용하는 방법인 모양이다.

틀을 깨라

반대로 생각하라

다른 관점으로 생각하라


원하는 것만 보게 된다
하지만 위의 말들을 이해하더라도 창의성을 확보할 수 없다. 왜냐하면, 사람은 보고 싶은 것만 보고, 듣고 싶은 것만 듣기 때문이다. 만약 당신도 그렇다면, 옆의 그림에서 컵만 보일 가능성이 높다.


물론 일부의 사람들은 정확한 관찰을 통해 남들이 못보는 것을 관찰 할 수 있다. 하지만 많은 사람들은 그림을 있는 그대로 보지 못한다. 왜냐하면 대부분의 창의성 서적에는 아래의 것들은 언급되지 않기 때문이다.

 

무엇의 틀을 깨라는 것인지?

무엇을 반대로 생각하라는 것인지?

무엇을 다른 관점으로 생각하라는 것인지?

 

이 글의 목적
어떻게 하라라는 말만 있고 '무엇을 하라'는 말은 없다. 다시 말해, 목적어가 빠졌다. 도대체 무엇을 하라는 것인가? 이것이 창의성과 관련된 많은 책이 실용서가 될 수 없는 이유이다. 특히 이공계에서 필요한 창의성은 거의 얻을 수 없다. 이 글에서는 무엇을 할 것인지에 관해서 논의한다.


그림의 출처
: http://kr.blog.yahoo.com/ism_kihan/34



무엇을 어떻게 할 것인지 알아야만 창의성을 확보할 수 있다. 질문을 다음처럼 바꾼다면 위의 그림에서 컵 이외의 것을 볼 수 있을 것이다.

 

지금 보고 있는 것이 컵이라는 틀을 깨라

지금 보고 있는 것이 컵이라고? 반대로 생각하라

지금 보고 있는 것이 컵이라고? 다른 관점으로 생각해보라



이렇게 생각한다면 위의 컵은 다른 것으로 보일 수 있다. 만약 여전히 컵 이외의 것을 못 보는 사람들은, 글을 계속 읽어 나가면 직관적으로 알 수 있으므로 걱정하지 말기 바란다.

본다는 것은 의미를 부여하는 것
아는 사람이 많겠지만, 위와 아래의 두 가지 그림들은 '루빈의 컵'이라는 유명한 심리학자가 설명한 것이다. 아래의 그림에서 두 사람만 보았다면 이 또한 착각이다. 다섯 명의 사람을 발견할 수 있어야 한다. 이런 착시현상으로 놀라운 사실을 얻을 수 있다. 본다는 것은 우리가 시각적으로 보는 것이 아니라, 우리가 의미를 부여하는 것이다. 다시 말해, 카메라로 사진을 찍는 것(망막으로 보는것)은 아무 의미도 없으며, 우리가 그 사진에 의미를 부여하는 순간 사실이 된다는 것이다. 바로 이점이 사람과 컴퓨터의 차이이다. 컴퓨터로 그림을 저장하거나 스캔할 수 있지만, 그 그림에 의미를 부여하지 못한다. 물론, 인지과학자들이 인공지능 분야에서 많은 노력을 하고 있지만, 아직까지 갈길이 멀다.


그림의 출처
: http://blog.daum.net/visitor/15709185


착시일 뿐인가?
혹자는 이 현상을 두고 착시현상을 유발하는 그림일 뿐이라고 이야기 할 수도 있다. 정말 그럴까? 그렇다면 다행이지만 실제로는 그렇지 않다. 예를 들면, 기원전부터 현재 21세기까지 수많은 과학자들이 있었다. 하지만, 위대하고 유명한 과학자의 수는 많지 않은 이유는 무엇일까? 그것은 착시현상이 위의 그림들뿐만 아니라 과학적인 실험과 관찰에서도 나타나기 때문이다.

실제 과학에서의 창의성

갈릴레이, 다윈, 아인슈타인, 테슬라의 공통점은 무엇일까? 위대한 과학자라는 공통점을 제외하면 창의성이 뛰어난 사람이다. 그럼 네 명의 과학자가 무슨 일을 어떻게 했는지 살펴보자.

 

갈릴레이는 천동설을 거부하고 망원경을 이용하여, 지동설을 증명하였다.

다윈은 창조론을 거부하고, 진화론을 주장하였다.

아인슈타인은 뉴턴역학의 시간과 공간은 변하지 않는다는 이론을 거부하고, 상대성이론을 주장하였다.

테슬라는 에디슨의 직류시스템에 만족하지 않고, 교류시스템을 발명하였다.

 

위의 업적을 본다면 네 사람은 기존의 이론을 더욱 발전시키거나, 변형시켜서 위대한 발견이나 발명을 한 사람들이다. 그렇다면 위의 네 사람은 어떻게 창의적인 작업을 할 수 있었을까? 그것은 바로 이론 적재성 관찰이라는 착시현상을 극복 했기 때문에 창의성을 발휘할 수 있었다.

 

이론 적재성 관찰이란 무엇인가?

이론 적재성 관찰 (theory-laden observation)이란 기존의 이론을 과신하여 이론의 틀 안에서만 실험과 관찰을 하는 것이다. 즉 실험을 할 때 개인의 주관적인 배경지식 때문에, 이론에 실험결과를 맞추어 버리는 것이다. 바로 이 문제 때문에 어떠한 실험을 하더라도, 항상 이론에 종속적인 결론이 나오게 된다. 이론 적재성 관찰을 하는 경우 심각한 오류에 빠질 수 있다. 왜냐하면, 실험을 할 때 이론과 상반되는 결과가 나오면 자신이 실험을 잘못 했다고 생각하기 때문이다. 사실은, 실험이 잘못된 것이 아니라 기발하고, 새로운 것을 발견한 경우에도, 잘못된 실험을 했다고 착각하는 것이다. 만약 위에서 언급한 네 명의 과학자가 이론의 틀이라는 색안경을 끼고 관찰을 했다면 위대한 과학자가 될 수 있었을까
 
같은 결과를 놓고, 해석이 다른 이유
관찰의 이론 적재성을 최초로 주장한 사람은 과학철학자인 핸슨이다. 이 개념으로 인해 관찰의 객관성이 과학지식의 발전에 중요한 역활을 한다고 믿고 있던 귀납주의와 반증주의에게 치명타를 남긴다. 그의 개념이 집약된 책이 Patterns of Discovery(과학적 발견의 패턴)이다. 그 책에서 예를 든 티코 브라헤와 케플러의 해돋이 장면에 관한 대화는 잘 알려져 있다: 티코 브라헤는 “태양이 떠오르는 군”이라고 말한다. 그런데, 케플러는 반대로 “지구가 내려가고 있군”이라고 말한다. 대부분의 과학자는 티코 브라헤 처럼 이론 적재성 관찰을 한다고 핸슨은 말한다. 그도 그럴것이 어느 누가 함부로 기존의 이론을 뒤집을 수 있겠는가? 위에서 언급한 네 명의 과학자는 예외에 속한다. 대담하게 이론을 거부했기 때문이다.


그림의 출처: http://nircissus.tistory.com/417

해돋이를 정확히 해석할 수 있을까?
동일한 해돋이 광경을 보았지만, 서로 다른 해석을 하는 이유는 관찰을 할 때 한 명은 이론의 틀을 벗어나지 못했고, 다른 한 명은 이론을 배제한 관찰을 했기 때문이다. 이론을 배제한 관찰을 할 때만 정확하고, 공정한 해석이 가능할 뿐만 아니라, 새로운 것을 발견할 수 있다

이론을 배제한 과학적 관찰법의 가치
관찰이 이론의 틀 안에서만 해석된다는 이 개념의 여파는 대단했다. , 의도적으로 이론을 무시하고 관찰한다면 새로운 이론을 발견할 수 있다는 것이다. 이 개념은 20세기 초반의 과학적 방법론인 귀납주의와 반증주의의 단점을 극복하였다. 귀납법과 반증법은 가설을 검증하는 방법일뿐, 가설을 발견하는 방법인 핸슨의 과학적 관찰법과는 다르다. 가설을 발견이라도 해야 검증 할 것이 아닌가? 따라서 논리실증주의자와 비판적 합리주의자가 신주 모시듯 했던 귀납법, 반증법이라는 것들은 과학적 관찰법이 선행되지 않으면 의미가 없다

20세기의 가장 위대한 저작물 중 하나인 쿤의 The Structure of Scientific Revolutions(과학 혁명의 구조)도 핸슨이 주장한 이론 적재성 관찰의 영향을 받았다. 쿤은 이론 적재성 관찰을 하는 사람을 정상과학자로 분류하고, 이론을 배제한 관찰을 하여 새로운 패러다임으로 이론을 만들고, 동료과학자의 지지를 받는 과학자를 과학 혁명가로 분류했다. 패러다임이라는 의미를 과학 혁명과 관련된 의미로 최초로 정의한 사람이 토머스 쿤이다. 과학혁명의 구조가 나오기 전 까지 패러다임의 의미는 지금과 달랐다.

귀추법은 발견의 방법
핸슨은 관찰뿐만 아니라 과학적 사실이라고 믿는 것, 원인과 결과, 이론의 생성과정에서도 여러  사례를 들어 선이론 적재성을 증명하였다. 또한 그때까지만 해도 이상적인 과학방법이라고 생각했던 가설-연역법도 가설이 어떻게 생성되는 지 설명하지 못한다고 지적하였다. 즉 과학적인 이론을 만들 때 가설부터 생성하지 않는다는 것이다. 핸슨에 의하면, 과학자는 가설이아니라 실험데이터로부터 시작하며데이터를 짜 맞추어 이해될 수 있는 개념적 패턴을 만듦으로써 가설이 생성된다 라고 이야기 한다. 즉 물리학자가 가설을 생성하는 방법은 오직 실험데이터에 대해 지적으로 들어맞는 개념적 패턴을 추구하는 것이다. 이것을 귀추법이라 하며, 현재로써는 오직 귀추법만이 실험결과에 새로운 의미를 부여하여 가설을 생성하는 방법이라고 할 수 있다.


틀을 어떻게 깰 것인가?
이제 우리는 최초에 언급한 세가지 질문에 대해 아래와 같이 답할 수 있다.

무엇의 틀을 깨라는 것인지?                  à 이론의 틀을 깨라

무엇을 반대로 생각하라는 것인지         à 이론과 반대로 생각하라

무엇을 다른 관점으로 생각하라는 것인지? à 이론과 다른 관점으로 생각하라

 

이론을 많이 알수록 관찰의 이론 적재성은 심해진다. 그럼 이론은 모를수록 좋은 것인가?

기본은 되어 있어야 한다

이렇게 글을 써 놓으니, 이론이 필요 없다고 느낄 수 있다. 결코 그렇지 않다. 정석을 모르고 좋은 바둑을 둘 수 있을까? 이론이 얼마나 중요한지는 이미 언급했던 창의성을 키우는 방법을 다시 음미해 보면 알 수 있다.

 

이론의 틀을 깨라                  à 이론의 틀이 어디까지인지 알아야 깰 것 아닌가?

이론을 반대로 생각하라          à 이론과 반대로 생각하려면 이론을 알아야 한다.

이론을 다른 방향으로 생각하라 à 이론을 알아야 그것을 비틀어 생각할 것이 아닌가?

 

독자가 속한 분야의 이론을 많이 알수록 창의성을 증가시킨다.(창조의 조건 4장 참조) 이론이 방해가 되는 이유는 오직 이론 적재성 관찰을 할 때이다. 필자도 천성적으로 창의적인 사람이 아니므로 연구를 할 때는 이론이라는 색안경을 끼지 않도록 노력하고 있다.

 

두 가지를 동시에 사용하라

이 글의 최초에 언급한 무엇을 해야 하는가가 이제 명확해 졌는가? 같은 결과를 놓고 왜 해석이 다른지 이해가 되는가? 가설이 어떻게 만들어 질 수 있는지 알았는가? 이제 여러분이 속해있는 분야에서 창의적인 결과물을 만들고 싶다면 책을 읽을 때, 사고할 때, 실험과 관찰을 할 때 1 이론을 배제하고, 2 실험 데이터에 대해 잘 설명되는 패턴을 도출하기 바란다. 관찰을 할때 이론의 틀을 버리고, 가설을 만들 때 귀추법을 사용한다면, 그 두 가지를  동시에 할 수 있다면, 새로운 것을 발견할 가능성이 훨씬 높다. 이 두가지 방법은 필자가 자주 사용하는 것이다.  

결론
나는 우리 이공계가 실력이 없어서 창의력을 발휘하지 못한다고 생각하지 않는다. 만약 다른 선진국의 이공계보다 창의성이 없다면, 그 이유는 새로운 것을 발견하지 못하기 때문이 아닐까? 해당분야의 이론을 잘 습득하고, 두 가지 방법(관찰에서 이론을 배재할 것, 데이터에 새로운 패턴을 적용하여 의미를 부여할 것) 을 활용한다면 그 들보다 못할 이유는 없다고 본다.

창의성, 독창성이라는 것은 거대하고, 위대한 것만 있는 것은 아니다. 다시 말해, 발견의 방법(창의성)을 이론과 같이 큰 것에만 적용할 것이 아니라, 작은 것에도 적용할 수 있다는 것이다. 각 분야의 이론에는 수많은 개념이 있다. 이론에 달려있는 수많은 개념에 대해서도 위에서 언급한 두 가지를 적용한다면 새로운 사실을 발견할 확률은 매우 높아진다. 그렇게 하여 각자 자기의 분야에서 조그만 것을 발견했다 하더라도, 세상이 알아주지 않더라도, 한발자국 앞으로 나아갔다면, 그것은 의미 있는 일이다.

 




참조서적



좌로부터 핸슨의 '과학적 발견의 패턴', 쿤의 '과학혁명의 구조', 테레사 M. 아마빌레의 '창조의 조건' 이다. 세권 모두 쉽게 이해되는 책은 아니다. 과학방법론(과학철학)에 대한 입문서로는 쿤 & 포퍼 (정재승)가 적당하다.


Posted by extremedb
,

부서-사원 모델에 대한 단상

조회관계란 무엇인가
전달관계와 조회관계의 차이점


거의 모든 프로젝트 현장에서 위와 같은 잘못된 모델이 등장한다. 위의 부서-사원 모델에서 틀린 곳을 발견할 수 있는가? 잘못된 점을 발견하지 못했다면, 여러분도 잘못된 모델링을 하고 있을 가능성이 높다. 이 글은 부서와 사원 사이에 존재하는 관계선이 잘못되었다는 것을 이야기 하고자 한다.

 

어느 설계자가 논리모델링을 하고 있었다. 그가 작업중인 위의 모델을 보는 순간 기대를 했지만 역시나 실망했다. 왜냐하면 부서엔티티에서 사원엔티티로 관계선을 긋는 것을 보았기 때문이다. 부서와 사원은 가장 기본적인 엔티티 아닌가? 핵심엔티티임에도 불구하고 관계선을 마음 내키는 대로 긋고 있는 것이다. 그래서 내가 그 설계자에게 다음과 같이 질문을 하였다.

 

필자: 사원엔티티와 부서엔티티의 관계는 사원이 현재 소속된 부서를 의미합니까?

설계자: 네 당연히 사원의 현 소속부서를 의미합니다.

필자: 그렇다면 부서와 사원 사이에 관계선을 긋지 마시고, 그냥 부서코드속성을 추가하세요.

설계자: 왜요? 부서와 사원은 1:N 관계이므로, 당연히 그어야 되는 것 아닌가요?

 

설계자의 잘못이 아니다

그렇다. 당연히 그렇게 생각할 것이다. 모델링공부를 많이 한 사람일수록 반사적으로 부서와 사원 사이에 관계선을 그어 버린다. 잘못된 관행이 온 세상을 덮고 있다. 왜냐하면, 영문원서를 포함하여 거의 모든 국내의 모델링 책에 부서-사원관계를 그어버리는 잘못을 범했기 때문이다. 교과서가 잘못되어 있는데 학생이 제대로 된 사고를 할 수 있을까? 부서와 사원간에는 이런 식으로 관계를 맺으면 안 된다.

 

위의 모델이 잘못되었다는 것을 증명하려면 모델링에 대한 약간의 배경지식이 필요하다. 지금부터 설명되는 내용은 모델링의 기본이므로 반드시 알고 있어야 하는 것들이다.

 

관계의 방향

엔티티간의 관계선은 Role, Cardinality, 관계속성의null 여부, 그리고 데이터의 전달방향을 나타낸다. 물론 관계선에는 이런 개념 이외에도 중요한 개념이 더 있지만, 오늘은 모델링 책에 자주 언급되고 있지 않는 데이터의 흐름(전달)과 그 방향에 관해 논의해보자.

 

물은 중력의 법칙 때문에 위에서 밑으로 흐른다. 물의 흐름과 데이터의 흐름도 비슷하다. 집합끼리의 관계에 의해서 최상위 부모로부터 최하위 자식까지 데이터는 순차적으로 물 흐르듯 흘러간다. 다시 말해, 데이터의 방향은 부모로부터 자식으로 흘러가는 것이다. 이런 일이 가능한 이유는 관계속성 때문이다.

 

관계속성의 정의

관계선을 긋는 순간 자식엔티티에 부모의 식별자가 상속된다. ER-WIN이나 파워디자이너 등의 모델링 툴을 써본 사람이라면 관계선을 긋는 순간 자식엔티티에 부모식별자에 해당하는 속성이 생성됨을 알 것이다. 이를 관계속성이라 한다. 관계선이 데이터의 이동 통로라고 비유한다면, 관계속성은 이동된 데이터의 도착장소이다. 부모집합은 관계속성이 존재함으로써 자식에 데이터를 전달(연결)할 수 있다. 쉬운 말로 표현하면, 관계속성은 부모식별자의 데이터를 자식의 공간에 집어넣어, 두 집합간의 관계를 완성한다.

 

고객, 상품, 주문, 주문상품을 통하여 데이터가 어떻게 흘러가는지 살펴보자. 아래의 그림에서 FK라고 표시된 것이 관계속성이다. 각 엔티티의 상세한 속성이 더 존재하지만 편의상 몇 개씩만 나타내었다.




우리의 예상대로 고객-->주문 , 주문-->주문상품 , 상품-->주문상품으로 데이터가 흘러감을 직관적으로 알 수 있다. 모델링을 해본 사람이라면 지극히 당연하다고 생각 할 것이다. 관계속성이 존재함으로 데이터가 흘러간다는 규칙은 업무에 무관하게 적용되는 모델링의 기본 법칙이다

 

관계선을 그으면 안 되는 경우

데이터가 전달되지 않을 때는 관계선을 그으면 안 된다. 관계속성이 생성되기 때문이다. 관계속성이 있다는 것은 데이터를 전달하겠다는 의미인데, 데이터가 전달되지 않는 경우에는 관계속성이 필요 없다.

 

 "부모 식별자의 데이터가 자식으로 전달(이행)되는 경우만 관계선을 그어라."

 

위에서 보는 것처럼 이 글에서 언급한 관계선의 규칙은 매우 간단하다. 하지만 아주 쉽고, 당연한 규칙이라도 실무에서는 지켜지지 않는 경우가 많다. 도대체, 언제쯤 대부분의 설계자들이 모델링의 기본규칙을 지키는 날이 올까? 만약 그랬다면 이 글의 서두에서 언급한 대화는 일어나지 않을 것이다.

 

여기까지가 배경지식이다. 지금부터 이미 언급된 부서-사원간의 관계가 왜 잘못된 것인지 알아보자. 결론부터 이야기 하자면 위에서 언급한 관계선의 규칙에서 벗어나기 때문이다.

 

현 소속부서의 함정

내가 본 많은 수의 부서-사원 모델은 관계명에현 소속부서로서가 명시되어 있다. 현 소속부서로서? 현 소속부서라는 것은 논리적으로 존재할 수 없다. 현 소속부서가 존재한다면 그것은 발령의 마지막 값을 물리적으로 역정규화 한 것이다.

 

관계선의 검증방법

관계선은 업무규칙을 나타내기도 하지만 데이터집합의 원천을 표시하는 용도가 있다. 즉 부서와 사원 사이에 관계선을 그렸다면, 사원엔티티의 부서코드 값들은 부서엔티티로부터 온 것인가? 라고 검증해보아야 한다. 실제로 부서와 사원의 관계선을 검증해보면, 사원의 부서코드는 부서집합으로부터 온 것이 아니다.. 인사시스템의 발령에서 사원의 부서코드로 데이터가 insert 혹은 update된다. 인사시스템의 발령이 데이터의 원천이므로 부서와 사원은 아무런 관계가 없다. 따라서 관계선을 삭제해야 하며 아래와 같이 나타내야 한다.

 



위의 그림은 인사시스템의 모델이다. 물론 발령에는 직무코드, 직위코드와 관련된 추가적인 관계가 존재한다. 하지만 부서와 사원에 집중하기 위해 생략하였다. 위의 모델에서는 당연하게도, 부서와 사원은 관계선이 없다. 즉 현 소속부서라는 집합은 인사발령에서 사원 별로 가장 최근 값만 추출한 것이다. 그럼 인사시스템이 아닌 다른 시스템이라면 어떻게 될까? 아래의 모델을 보자.

 

인사시스템이 아닌 타 시스템이라면 인사시스템의 부서와 사원테이블을 1:1로 중복시켜 놓을 것이다. 관계선에 D라고 표시한 것은 추출관계를 나타낸 것이다. 부서코드, 직위코드, 직무코드는 인사시스템의-발령엔티티에서 역정규화 된 것임을 알 수 있다. 가끔 인사시스템에서 부서코드, 직위코드, 직무코드를 미리 역정규화 해놓는 경우도 있다. 미리 역정규화 해놓은 것을 타시스템으로 1:1 Copy 해도 역정규화 되었다는 사실은 변하지 않는다위의 모델을 자세히 보아도 부서와 사원은 아무런 관계가 없음을 알 수 있다.

 

부서와 사원간의 FK는 필요 없다

부서와 사원간에 FK를 생성하려는 사람이 있다. 부서와 사원간에 FK에 의한 참조무결성은 쓸모가 없다. 데이터가 부모에서 자식으로 전달될 때, 올바른 값으로 전달(insert 혹은 update) 되었는지 체크하는 것이 참조무결성이다. 그런데 부서-사원간의 관계에서는 부모인 부서로부터 자식으로 데이터가 이행되지 않는다. 오히려 사원의 자식인 인사발령에서 데이터가 거꾸로 전달된다. 따라서 FK는 의미가 없다.

 

현소속부서라는 집합의 정합성을 검증하려면, 인사시스템의 발령에서 사원 별로 가장 최근 값만 추출하여 정합성을 검증해야 한다. 사원의 부서코드뿐만 아니라 직위코드, 직무코드도 마찬가지 방법으로 검증해야 한다. 실제로도 정합성 체크는 이렇게 하고 있다.

 

조회관계(Read Only Relationship)란 무엇인가?

조회관계란 데이터의 부모자식간의 데이터 전달이 목적이 아니라, 오직 두 집합을 연결하여 조회하려는 목적으로 탄생된 관계이다. 그래서 조회관계를 Read Only Relationship으로 부를 수 있다. 부서와 사원간의 관계 역시 역정규화에 의한 조회관계이다. 오직 사원의 자식인 인사-발령에서 현소속부서라는 데이터가 전달되므로, 부모인 부서집합에서의 전달관계는 없다. 하지만, 사원의 입장에서 현소속부서명과 부서의 위치를 알려고 하면, 부서와 사원을 연결할 수 있어야 조회가 가능하다. 특정 사원의 부서명을 조회하려면 부서와 조인하라.”는 정보가 필요하다. 따라서 조회관계라 하더라도 모델상에 어떤 식으로든 나타내어야 한다.

 
역정규화는 조회관계를 발생시킨다

조회관계는 역정규화를 하는 경우에 나타난다. 예를 들어 영국 프리미엄 리그를 보면, 리그와 팀을 배정해야 경기를 할 수 있다. 만약, 팀 엔티티의 속성에 리그코드가 있다면 그것 또한 역정규화에 의한 조회관계이다. 왜냐하면, 새로운 리그가 시작되기 전에, 지난 리그의 팀 성적에 따라서 각 팀들을 리그에 배정하는 작업이 존재할 것이기 때문이다. (예를 들면, 맨유는 1부 리그에 배정되었다.) 각 팀들을 리그에 배정하는 엔티티는 인사시스템의 발령에 해당한다. 물리모델단계에서 배정 엔티티의 리그코드는 역정규화 되어 팀 엔티티로 들어갈 수 있다. 하지만, 역정규화는 성능과 개발생산성을 위한 작업이므로 개념이나 논리모델에서 보다는 물리설계단계에서 나타내는 것이 적합하다. 

 

전달관계와 조회관계의 차이

일반적으로 우리가 알고 있는 관계는 전달관계이다. 전달관계는 부모식별자의 데이터를 자식에 전달하는 역할과, 두 집합을 조인하여 조회하는 역할을 모두 수행한다. 따라서 전달관계는 조회관계의 기능을 포함한다. 전달관계와 반대로 조회관계는 오직 두 집합을 연결하여 데이터를 조회하는 목적 밖에 없다.

 

조회관계를 어떻게 표현할 것인가

조회관계는 관계속성으로 데이터가 전달되지 않으므로 의미가 없다고 생각 할 수도 있다. 하지만 위에서 설명 한 것처럼 “특정 사원의 부서명을 조회하려면 부서와 조인하라.”는 정보를 인식할 수 있어야 한다. 따라서 아래와 같이 나타내는 것을 권장한다.

 

부서와 사원 사이의 관계선에 P를 표시하여 가상의 관계임을 나타내었다. 가상관계는 관계속성을 만들지 않는다. 또한 관계명에 조회관계라는 것을 명시해주어, 데이터가 부서에서 사원으로 전달되지 않음을 나타내었다. 그리고 부서코드와, 직위코드, 직무코드는 인사시스템의 발령테이블에서 역정규화된 된 속성이라는 것을 속성의 정의란에 나타내 주어야 한다. 회색부분은 외부(External) 엔티티를 나타낸 것이다. 외부엔티티를 사용하여 데이터의 원천을 나타내주면, 개발자가 데이터를 이행할 때 쉽게 참조할 수 있다. 즉 위의 모델을 그려놓으면인사시스템에서 데이터가 바뀌면 내 시스템의 부서와 사원 데이터를 동기화 해야 하겠군하고 명확히 판단할 수 있다.

 

위의 모델을 그림으로써 얻을 수 있는 정보는 세 가지이며, 다음과 같다.

1. 부서와 사원간의 관계는 전달관계가 아니라 조회관계이다.

2. 부서와 사원엔티티는 인사시스템이 원천이다.

3. 부서코드와, 직위코드, 직무코드에 해당하는 데이터는 인사시스템의 발령데이터가 원천이다.

 

또한 위의 세가지 정보로 다음과 같이 두 가지 장점을 얻을 수 있다.

1. 차세대 프로젝트의 데이터를 이행하는 사람은 데이터의 원천이 인사시스템의 부서, 사원, 발령 모델임을 인식하므로 매핑정의서를 쉽게 작성할 수 있다.

2. 개발자는 인사시스템의 부서나 사원, 발령의 데이터가 변경되면, 인사시스템이 아닌 타시스템의 부서와 사원도 동기화 해야 한다는 사실을 쉽게 알 수 있다. 물론, 동기화 프로그램의 작성도 모델을 참조할 수 있으므로 그만 큼 쉬워진다.

 

하지만 현실은……

실무에서는 거의 100% 아래와 같이 모델링 한다.



위의 모델에서는 어떤 정보와 어떤 장점을 얻을 수 있나? 거의 없다. 한가지 얻을 수 있는 것은 부서집합의 부서코드 데이터가 사원에 전달된다는 거짓정보이다. 이 정보에 의해서 사원의 부서코드는 부서로부터 상속되었다는 잘못된 생각을 하는 사람이 있다. 이에 따라 불필요한 FK를 생성하는 사람도 있다. 다시 한번 말하지만 사원의 부서코드는 부서 엔티티로부터 상속된 것이 아니라 인사시스템의 발령엔티티로부터 온 것이다. 

 

조회관계와 전달관계는 확실히 구분하기 바란다. 그렇게 하면, 모든 것이 드러나고 명확해진다.

 

반박의 논리

이 글을 몇몇 모델러에게 보여주었더니 반발이 있었다. 그런데 이상한 것은 대부분 반대입장만 표현하고, 반대의 적절한 이유가 없다는 것이다. 예를 들면, “내가 지금까지 이렇게 사용했어도 문제가 없었다혹은 “~책에 그렇게 하라고 되어있다가 대표적인 이유였다. 그런 것들은 이유가 될 수 없다. 반박을 하려면 이유가 있어야 한다. 이 글의 논리 중에 어느 부분이, 어떻게 잘못되었다고 지적 할 수 있는 능력이 필요하다. 그나마 이유를 댄 사람들은 아래와 같다.

 

전달관계로 표현해도 문제가 발생하지 않는다는 의견에 대해

그렇다. 전달관계로 표현해도 FK만 생성하지 않는다면 성능저하와 같은 문제는 발생하지 않는다. 하지만 프로젝트의 개발과정에서 여러 사람이 불편을 겪을 것이다. 조회관계의 개념을 모른다면 역정규화 되었다는 사실을 인식하기가 어렵다. 그러므로 인사시스템의 발령데이터가 변경되면, 트리거 성으로 타시스템에 동기화 해야 된다는 사실을 발견하는데 시간이 더 걸릴 것이다. 데이터를 이행하기 위한 매핑정의서를 작성할 때도 마찬가지로 어려움이 예상된다. 만약 모델러가 이런 모든 정보들을 안다고 해도 다른 사람들까지 모두 안다고 생각하면 안 된다. 모델은 정확히, 그리고 자세히 표현할수록 여러 사람이 얻는 이익이 많다.

 

우리회사는 발령이 없다는 의견에 대해

영세한 업체라면 발령이라는 엔티티가 없을 것이다. 인정한다. 그런 경우에는 부서-사원은 1:N로 직접적인 관계가 있으므로 관계선을 그어야 한다. 하지만 인사발령이 없는 영세한 업체라면 SI 프로젝트를 하지도 않을 것이며, 설계자나 모델러를 쓰지도 않을 것이다. 따라서 대부분의 경우 부서-사원의 현소속부서 전달관계가 존재한다면 잘못된 것이다.

 

우리 시스템에는 발령이 필요 없다는 의견에 대해

맞는 말이다. 인사시스템을 제외하면, 발령이라는 엔티티는 필요 없을 것이다. 하지만, 그 이유로 부서-사원간에 관계선을 긋는 것은 말이 안 된다. 발령이 필요 없다는 이유로, 존재하지도 않는 부서-사원간의 전달관계와 그에 따른 관계속성을 만든다는 것은 적절하지 않다. 사원의 부서코드는 전달관계속성이 아니라 역정규화된 추출속성이라는 엄연한 진실을 가리는 것이다.

 

모델링툴에서 조회관계를 표현하지 못한다는 의견에 대해

가장 그럴 듯한 반박논리다. 조회관계를 표현하는 기능이 없으므로 전달관계선을 그어야 한다는 것이다. 모델링툴 때문에 전달관계선을 긋는 것 보다는, 관계를 맺지 말고 누구나 볼 수 있도록 코멘트(Text Box)로 조회관계임을 기술하는 것이 더 나아 보인다. 어쩔 수 없이 전달관계선을 그을 수도 있다. 하지만, 개념을 알고 행동하는 것과 개념을 모르고 행동하는 것은 큰 차이가 있다. 다시 말해, 조회관계란 개념을 알고 있지만, 모델링툴에 기능이 없기 때문에 눈물을 머금고 관계선을 그은 것이라면 정당한 이유가 된다. 만약 그것이 아니라 부서-사원의 관계를 습관적으로 그은 것이거나 부서-사원의 관계가 1:N이라고 잘못 인식하고 관계를 맺었다면 옳지 않은 행동을 한 것이다.

 

현재 가장 대중적으로 사용하고 있는 ER-WIN이나 파워디자이너 같은 모델링툴은 조회관계를 나타낼 수 없다. 툴의 설계자가 조회관계라는 개념을 것을 모르니 당연한 것이다. 따라서 관계를 맺으면 무조건 전달관계와 전달관계속성을 만들어 버린다. 그에 따라 툴에서 제공하는 테이블 생성용 스크립트를 받아보면 예외 없이 FK를 생성해 버린다. 부모로부터 데이터가 전달되지 않으므로 참조무결성을 보장할 필요가 없는 데이터임에도 쓸데없이 FK를 생성하여 속도만 저하시킨다.

 

조회관계를 표현하는데 가장 유리한 모델링툴은 DA#이다. 필자가 가장 애용하는 툴이기도 하다. 이 툴에서 가상관계를 이용하면 관계속성을 만들지 않는다. 물론 가상관계가 아니라 조회관계를 표현할 수 있으면 좋겠지만, 그런 기능은 없으므로 현재로써는 가상관계로 처리하는 것이 최선이다. 위에서 그린 ERDDA#으로 표현한 것이다.

 

관계의 분류

관계는 여러 가지로 분류할 수 있다. 흔히 통용되는 관계분류의 예제는, 직접/간접 관계와 식별/비식별 관계이다. 직접관계란 1촌끼리의 관계를 의미한다. 즉 나와 아버지의 관계(1)이다. 관계형 데이터 모델에서는 직접관계만 표현하면 된다. 하지만 가끔 물리설계시에 SQL의 성능을 높이기 위해 할아버지가 직접 손자와 관계를 맺는 일도 있다. 이를 간접관계라고 한다. 식별관계는 부모의 식별자가 자식식별자의 일부로 상속되는 경우이다. 이와는 반대로 비식별관계는 부모의 식별자가 자식에 일반속성으로 상속된다. 이상으로 일반적인 관계의 분류방법을 알아보았다.

 

이 글에서 언급한 관계분류방법은 일반적으로 통용되는 분류방법과 다르다. 분류의 초점을 데이터의 전달유무에 맞추었다. 즉 전달/조회 관계로 새롭게 분류해 보았다. 직접/간접관계 분류법과 전달/조회관계 분류법은 많이 다르지만, 관계의 역정규화라는 점에서는 유사하다. 하지만, 데이터의 전달유무에서는 차이가 있다. 간접관계는 부모식별자의 데이터가 직접 자식으로 상속되는 전달관계이다. 이와는 반대로 조회관계는 부모로부터 데이터를 받지 않는다.  

개념이나 논리모델에서 조회관계 표현방법
될 수 있으면, 역정규화된 관계는 개념/논리모델단계에서 나타내지 말고, 물리설계단계에서 나타내기 바란다. 물리설계단계에서는 조회관계임을 명시하거나, 간접관계임을 나타내어 관계가 역정규화 되었음을 나타낼 수 있다. '그럼 개념이나 논리에서는 어떻게 나타내야 하는가?' 라고 질문할 수 있다. 개념모델단계에서는 부서와 사원 사이의 관계를 M:N으로 나타내면 되고, 논리모델단계라면 M:N 관계를 풀어서 외부엔티티인 인사발령을 표현해주면 된다. 논리모델에서 외부엔티티를 사용하는 것은, 물리모델링시에 조회관계(역정규화)로 나타낼 것임을 예고하는 것이다.  ( 2011.02.28 추가 )

 

결론

조회관계는 부모로부터 자식으로 데이터를 전달하지 못하고, 오직 조회를 목적으로 두 집합을 연결한다.

전달관계는 부모로부터 자식으로 데이터를 전달하고, 조회를 목적으로 두 집합을 연결도 한다.

부모로부터 자식으로 데이터가 이행되는 경우만 전달관계선을 그어야 한다.

부모로부터 자식으로 데이터의 전달이 끊기면 조회관계이다.

조회관계는 관계속성이나 FK를 생성하지 않는다.

역정규화에 의해서 조회관계가 발생된다.

부서--<사원은 조회관계이다.

Posted by extremedb
,

분석함수의 비효율을 찾는 방법
분석함수의 성능개선 원리 

분석함수의 실행계획 3부 - 심화과정의 모범답안

이번 글이 분석함수의 실행계획 시리즈의 마지막이다. 1
2에서 분석함수의 실행계획에 대해서 알아보았다
이 글(3부)은 새로 작성되었으며
, 이전 글( 문제 )은 삭제하지 않고 그대로 두었음을 밝혀둔다. 나중에라도 문제를 풀어볼 사람은 이 글(답안)을 보지 말고, 이전 글을 보기 바란다. 각각의 답안은 오렌지색으로 표시해 두었으므로 쉽게 채점할 수 있을 것이다.


분석함수의 다섯 가지 원리
많은 수의 개발자, DBA, 튜너들은 분석함수를 만나면 식은 땀을 흘린다. 왜냐하면, 분석함수의 비효율을 어떻게 찾을 수 있는지, 또 그 비효율은 어떻게 제거 할 수 있는지 알 수 없기 때문이다. 기껏해야 분석함수의 over절을 만족하는 적절한 인덱스를 생성하는 정도이다. 어쩌면 이런 결과들은 당연하다고 볼 수 있다. 매뉴얼이나 튜닝 책에 분석함수의 비효율을 발견하는 방법과 개선방법에 대한 언급이 별로 없기 때문이다. 이런 어려움을 겪고 있는 여러 사람들의 요구에 의해서 이 글이 만들어졌다. 분석함수의 비효율을 찾고, 성능을 향상시킬 수 있는 다섯 가지 방법을 공개하니, 많은 사람들이 적용하여 더 이상 식은 땀은 흘리지 않기 바란다. 

SQL 두 개에 5가지 비효율이 있다. 이것들을 제거하라
이 글에서 설명되는 두 개의 SQL은 길이가 매우 짧다그 중 하나는 비효율을 찾아내기 쉽고나머지 하나는 어렵다. 두 개의 SQL에는 총 5가지의 튜닝 포인트가 있다. 각각의 포인트는 20점이며 5가지를 모두 맞추는 경우는 100점이 된다. 채점 시 중요한 점은, 독자들이 직접 튜닝한 SQL이 모범답안과 같은지 비교하는 것이 아니라는 것이다. 그것 보다는 비효율을 몇 개 잡아내었는지, 또한 각각의 비효율을 개선시킬 수 있는 방법은 몇 개나 생각했는지를 채점하는 것이다. 이렇게 채점하면, 내가 아는 것은 무엇이고, 모르는 것은 무엇인지 명확히 드러난다, 무엇을 더 공부해야 하는지 알 수 있다.


다른 사람들은 몇 점 정도 받았나?
몇몇 지인들에게 퀴즈를 내본 결과 100점은 아무도 없었다. 80점이 평균적인 튜너의 수준이라는 이야기 이다. 개발자의 경우는 60점이면 상위 수준이다. 점수에 대해 오해는 하지 말기 바란다. 이 퀴즈로 받은 점수는 분석함수의 성능과 관련된 것일 뿐, 전반적인 SQL 튜닝능력에 대한 점수가 아니다.

이제 실습을 위한 테이블과 인덱스를 생성하자. 실습환경은 Oracle 10g R2, Oracle 11g R1, Oracle 11g R2로 한정한다.


CREATE TABLE SALES_T NOLOGGING AS SELECT * FROM SALES;

 

ALTER TABLE SALES_T ADD CONSTRAINT PK_SALES_T

PRIMARY KEY (PROD_ID, CUST_ID, CHANNEL_ID, TIME_ID) USING INDEX;

 

개발자가 작성한 문제의 SQL 1

 

SELECT *

  FROM ( SELECT /*+  INDEX(T PK_SALES_T) */ PROD_ID, CUST_ID, TIME_ID, CHANNEL_ID, AMOUNT_SOLD,

                MAX(TIME_ID) OVER( PARTITION BY CUST_ID ,CHANNEL_ID ) AS MAX_TIME

           FROM SALES_T T

          WHERE PROD_ID = 30 )

 WHERE TIME_ID = MAX_TIME;

 

업무설명 : 상품번호 30인 데이터에 대하여 고객별, 채널 별로 가장 최근의 판매량을 나타내시오.

             단 고객별, 채널별로 가장 최근의 데이터는 2건 이상일 수 있음.

 

-----------------------------------------------------------------------------------------------

| Id  | Operation                     | Name       | A-Rows |   A-Time   | Buffers | Used-Mem |

-----------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT              |            |  12649 |00:00:00.31 |   28337 |          |

|*  1 |  VIEW                         |            |  12649 |00:00:00.31 |   28337 |          |

|   2 |   WINDOW BUFFER               |            |  29282 |00:00:00.27 |   28337 | 1495K (0)|

|   3 |    TABLE ACCESS BY INDEX ROWID| SALES_T    |  29282 |00:00:00.17 |   28337 |          |

|*  4 |     INDEX RANGE SCAN          | PK_SALES_T |  29282 |00:00:00.03 |     118 |          |

-----------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   1 - filter("TIME_ID"="MAX_TIME")

   4 - access("PROD_ID"=30)

 

WHERE 절에 TIME_ID = MAX_TIME 이 존재하는 이유는 고객별, 채널별로 MAX(TIME_ID)에 해당하는 데이터가 두건 이상일 수 있기 때문이다. 이런 경우는 자주 발생한다. 이해를 돕기 위해 비슷한 예를 들어보자. 부서별로 최대급여를 받는 사람들을 출력하고자 할 때, 1번 부서의 최대급여는 1억 원이라고 하자. 그런데 그 부서에서 1억 원(최대급여)을 받는 사람은 강호동, 유재석 둘이라는 이야기 이다.

 

위의 SQL은 답이 정확하다. 하지만 성능은 비효율이 있어서 별로 신통치 못하다. 이제 문제를 보자. SQL에 여전히 남아있는 비효율을 개선하는 것이 문제이다.

 

문제1) 위의 SQL 에서 성능상 문제점을 발견하고 개선하시오. 문제점은 두 가지(분석함수의 관점, 일반적인 SQL튜닝의 관점) 이다. 단 인덱스를 추가로 생성하거나 변경해서는 안 된다.

 

문제 1의 답안

비효율 1 Sample 답안이다. 점수에 반영되지 않는다.

 

문제1의 비효율 1 : SALES_T 테이블에 Random Access가 불필요하게 많이 발생한다. 12649 번만 테이블로 액세스 하면 되지만 실제로는 29282 번 액세스함으로써 비효율이 발생되었다.

 

문제1의 비효율 1의 해결방법 : PK인덱스에 AMOUNT_SOLD가 포함되어 있지 않으므로 Select 절에서 AMOUNT_SOLD를 빼면 인덱스만 액세스 하게 됨. 이때 Rowid를 추가로 Select 해야 한다. 이후에 이미 인덱스에서 Filter Rowid로 테이블을 액세스하면 Random Access는 정확히 12649번 만 시도한다. 아래에 SQL이 있으므로 참조하시오.

 

문제1의 비효율1이 해결된 SQL과 실행계획 제시:

 

SELECT /*+ LEADING(S) USE_NL(S1) */

        S.PROD_ID, S.CUST_ID, S.TIME_ID, S.CHANNEL_ID, S1.AMOUNT_SOLD

  FROM ( SELECT /*+  INDEX(T PK_SALES_T) */ PROD_ID, CUST_ID, TIME_ID, CHANNEL_ID,

                MAX(TIME_ID) OVER( PARTITION BY CUST_ID ,CHANNEL_ID ) AS MAX_TIME,

                ROWID AS RID

           FROM SALES_T T

          WHERE PROD_ID = 30 ) S,

       SALES_T S1

 WHERE S.TIME_ID = S.MAX_TIME

   AND S1.ROWID = S.RID;

  

------------------------------------------------------------------------------------------------------

| Id  | Operation                   | Name       | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |

------------------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT            |            |      1 |  12649 |00:00:02.72 |   11237 |          |

|   1 |  NESTED LOOPS               |            |      1 |  12649 |00:00:02.72 |   11237 |          |

|*  2 |   VIEW                      |            |      1 |  12649 |00:00:00.18 |     118 |          |

|   3 |    WINDOW BUFFER            |            |      1 |  29282 |00:00:00.13 |     118 | 1432K (0)|

|*  4 |     INDEX RANGE SCAN        | PK_SALES_T |      1 |  29282 |00:00:00.03 |     118 |          |

|   5 |   TABLE ACCESS BY USER ROWID| SALES_T    |  12649 |  12649 |00:00:02.48 |   11119 |          |

------------------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   2 - filter("S"."TIME_ID"="S"."MAX_TIME")

   4 - access("PROD_ID"=30)

 

이렇게 해서 Sample 답안을 작성해 보았다. 이 방법은 분석함수의 튜닝방법이 아니라 예외적으로 SQL 튜닝방법론에서 언급되었던 방법을 사용하였다. 물론 여기서 페이징처리를 한다면 추가적인 성능향상이 가능하지만 Sample이므로 여기서 멈추기로 한다위의 답안은 Sample 이므로 점수에서 빠진다. 또한 이 부분은 분석함수의 튜닝 방법이 아니다. 하지만 앞으로 풀게 될 문제들은 모두 분석함수와 관련된 문제들이다.

 

 

문제1의 비효율 2

비효율 1을 제거하니 Random Access는 최소화 되었다. 하지만 실행계획을 보면 쓸데없이 PGA 1432K만큼 사용하고 있다. 즉 불필요한 WINDOW BUFFER Operation 때문에 sort가 발생하였다.

 

문제1의 비효율 2 해결방법 

고객별 채널별로 최종일자의 데이터를 구하는 것이므로, Rank를 사용하면 적절한 인덱스가 존재하므로 sort를 하지 않는다. Rank Row_number와는 다르게 1등이 두 명 이상일 수 있으므로 업무조건을 만족한다.

 

문제1의 비효율 2이 해결된 SQL과 실행계획 제시.

 

SELECT /*+ LEADING(A) USE_NL(B) */ A.*, B.AMOUNT_SOLD

  FROM ( SELECT /*+  INDEX_DESC(T PK_SALES_T) */ PROD_ID, CUST_ID, TIME_ID, CHANNEL_ID,

                RANK() OVER(PARTITION BY CUST_ID, CHANNEL_ID ORDER BY TIME_ID DESC) RNK,

                ROWID AS RID

           FROM SALES_T T

          WHERE PROD_ID = 30 ) A,

       SALES_T B

 WHERE A.RNK = 1

   AND A.RID = B.ROWID;

 

---------------------------------------------------------------------------------------------------------

| Id  | Operation                      | Name       | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |

---------------------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT               |            |      1 |  12649 |00:00:00.32 |   11240 |          |

|   1 |  NESTED LOOPS                  |            |      1 |  12649 |00:00:00.32 |   11240 |          |

|*  2 |   VIEW                         |            |      1 |  12649 |00:00:00.20 |     120 |          |

|*  3 |    WINDOW NOSORT               |            |      1 |  29282 |00:00:00.16 |     120 |          |

|*  4 |     INDEX RANGE SCAN DESCENDING| PK_SALES_T |      1 |  29282 |00:00:00.06 |     120 |          |

|   5 |   TABLE ACCESS BY USER ROWID   | SALES_T    |  12649 |  12649 |00:00:00.05 |   11120 |          |

---------------------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   2 - filter("A"."RNK"=1)

   3 - filter(RANK() OVER ( PARTITION BY "CUST_ID","CHANNEL_ID" ORDER BY INTERNAL_FUNCTION("TIME_ID") DESC )<=1)

   4 - access("PROD_ID"=30)

 

이제 문제2를 풀어보자.

 

개발자가 작성한 문제의 SQL 2

 

SELECT *

  FROM ( SELECT /*+ INDEX(T PK_SALES_T) */ PROD_ID, CUST_ID, TIME_ID, CHANNEL_ID, AMOUNT_SOLD,

                ROW_NUMBER()     OVER ( PARTITION BY PROD_ID ORDER BY CUST_ID,CHANNEL_ID,TIME_ID NULLS FIRST) AS RN,

                SUM(AMOUNT_SOLD) OVER ( ORDER BY CUST_ID,CHANNEL_ID,TIME_ID,PROMO_ID   ) AS SUM_AMT

           FROM SALES_T T

          WHERE PROD_ID = 30 )

 WHERE RN <= 10 ;

 

---------------------------------------------------------------------------------------------------------

| Id  | Operation                      | Name       | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |

---------------------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT               |            |      1 |     10 |00:00:00.43 |   28337 |          |

|*  1 |  VIEW                          |            |      1 |     10 |00:00:00.43 |   28337 |          |

|*  2 |   WINDOW SORT PUSHED RANK      |            |      1 |  29282 |00:00:00.40 |   28337 | 1999K (0)|

|   3 |    WINDOW SORT                 |            |      1 |  29282 |00:00:00.30 |   28337 | 1684K (0)|

|   4 |     TABLE ACCESS BY INDEX ROWID| SALES_T    |      1 |  29282 |00:00:00.18 |   28337 |          |

|*  5 |      INDEX RANGE SCAN          | PK_SALES_T |      1 |  29282 |00:00:00.04 |     118 |          |

---------------------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   1 - filter("RN"<=10)

   2 - filter(ROW_NUMBER() OVER ( PARTITION BY "PROD_ID" ORDER BY "CUST_ID","CHANNEL_ID",INTERNAL_FUNCTION("TIME_ID")

              NULLS FIRST)<=10)

   5 - access("PROD_ID"=30)

 

문제2) 위의 SQL 에서 분석함수의 성능상 문제점을 발견하고 개선하시오. 분석함수와 관련된 성능상 문제점은 4가지 이다. 비효율들을 발견하고 이를 모두 개선해야 한다. 단 인덱스를 추가로 생성하거나 변경하면 안 된다.

튜닝의 결과부터 이야기 하자면, Sort량은 3683 K 에서 0 K 로 줄어들었고 Block I/O량은 28337 에서 15 로 줄어들었다. 성능이 몇 배나 개선되었는지 계산하기도 힘들 정도이다. 결과건수가 많아 질수록 이 차이는 점점 더 벌어질 것이다. 성능이 어떻게 개선된 것일까? 지금부터 비효율을 하나씩 벗겨내고 개선방법을 도출해보자.
 
 

문제 2의 답안

문제2의 비효율 1

NULLS FIRST를 사용하면 인덱스를 Ascending으로 사용하면 null 값은 가장 마지막에 출력된다. null 값이 마치 가장 큰 값인 것처럼 저장 되는 것이다. 하지만 NULLS FIRST를 명시하는 순간 sort가 뒤바뀌어 가장 먼저 출력해야 한다. 즉 인덱스를 사용했지만 NULLS FIRST 때문에 추가적인 sort가 필요하다는 이야기 이다. 이러한 문제는 인덱스를 Descending 하게 사용할 때, NULLS LAST를 명시해도 똑같이 발생한다.

 

문제2의 비효율 1 해결방법

문제의 SQL을 보면 PARTITION BY ORDER BY의 컬럼들은 PK 컬럼들이므로 null 일수 없다. 따라서 NULLS FIRST삭제해도 무방하다. 다시 작성한 SQL은 다음과 같다.

 

문제2의 비효율 1이 해결된 SQL과 실행계획 제시

 

SELECT *

  FROM ( SELECT /*+ INDEX(T PK_SALES_T) */ PROD_ID, CUST_ID, TIME_ID, CHANNEL_ID, AMOUNT_SOLD,

                ROW_NUMBER()     OVER (PARTITION BY PROD_ID ORDER BY CUST_ID,CHANNEL_ID,TIME_ID ) AS RN,

                SUM(AMOUNT_SOLD) OVER ( ORDER BY CUST_ID,CHANNEL_ID,TIME_ID,PROMO_ID  ) AS SUM_AMT

           FROM SALES_T T

          WHERE PROD_ID = 30 )

 WHERE RN <= 10 ;

 

---------------------------------------------------------------------------------------------------------

| Id  | Operation                      | Name       | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |

---------------------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT               |            |      1 |     10 |00:00:00.45 |   28337 |          |

|*  1 |  VIEW                          |            |      1 |     10 |00:00:00.45 |   28337 |          |

|*  2 |   WINDOW NOSORT                |            |      1 |  29282 |00:00:00.42 |   28337 |          |

|   3 |    WINDOW SORT                 |            |      1 |  29282 |00:00:00.32 |   28337 | 1684K (0)|

|   4 |     TABLE ACCESS BY INDEX ROWID| SALES_T    |      1 |  29282 |00:00:00.17 |   28337 |          |

|*  5 |      INDEX RANGE SCAN          | PK_SALES_T |      1 |  29282 |00:00:00.03 |     118 |          |

---------------------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   1 - filter("RN"<=10)

   2 - filter(ROW_NUMBER() OVER ( PARTITION BY "PROD_ID" ORDER BY "CUST_ID","CHANNEL_ID","TIME_ID")<=10)

   5 - access("PROD_ID"=30)

 

ROW_NUMBER에 해당하는 Operation WINDOW SORT PUSHED RANK에서 WINDOW NOSORT로 바뀌었다. 그 영향으로 ROW_NUMBER Sort가 전혀 발생하지 않는다.

 

 

문제2의 비효율 2

비효율 1의 튜닝의 결과를 놓고 보면, ROW_NUMBER SUM 분석함수의 Operation이 실행계획에 각각 존재한다. 즉 분석함수가 따로 따로 두 번 실행되는 것이다. 분석함수의 종류는 두 개(row_number sum)라 하더라도, 분석함수와 관련된 Operation이 한번만 나와야 한다는 말이다. 또한 분석함수 SUM order by절에 PROMO_ID가 존재하여 불필요한 sort가 발생하였다.

 

문제2의 비효율 2 해결방법

이 비효율을 없애면 마치 분석함수가 하나뿐인 것처럼 만들 수 있다. Partition 절과 Order 절을 일치시키면 된다. 아래의 SQL Partition 절과 Order 절을 일치시키기 위해 SUM 분석함수에 PARTITION BY 절을 추가해야 한다. 물론 ROW_NUMBER함수에서 Partition By절을 삭제할 수 있지만, 그와 관련된 이야기는 이후 단계에서 논의 되므로, 여기서는 그냥 Partition By절을 추가하기로 한다. 그리고 WINDOW SORT를 유발하는 PROMO_ID도 삭제해야 한다.

 

문제2의 비효율 2가 해결된 SQL과 실행계획 제시

 

SELECT *

  FROM ( SELECT /*+ INDEX(T PK_SALES_T) */ PROD_ID, CUST_ID, TIME_ID, CHANNEL_ID, AMOUNT_SOLD,

                ROW_NUMBER()     OVER (PARTITION BY PROD_ID ORDER BY CUST_ID,CHANNEL_ID,TIME_ID ) AS RN,

                SUM(AMOUNT_SOLD) OVER (PARTITION BY PROD_ID ORDER BY CUST_ID,CHANNEL_ID,TIME_ID ) AS SUM_AMT

           FROM SALES_T T

          WHERE PROD_ID = 30 )

 WHERE RN <= 10 ;  

 

--------------------------------------------------------------------------------------------------------

| Id  | Operation                     | Name       | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |

--------------------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT              |            |      1 |     10 |00:00:00.26 |   28337 |          |

|*  1 |  VIEW                         |            |      1 |     10 |00:00:00.26 |   28337 |          |

|   2 |   WINDOW BUFFER               |            |      1 |  29282 |00:00:00.23 |   28337 | 1495K (0)|

|   3 |    TABLE ACCESS BY INDEX ROWID| SALES_T    |      1 |  29282 |00:00:00.11 |   28337 |          |

|*  4 |     INDEX RANGE SCAN          | PK_SALES_T |      1 |  29282 |00:00:00.03 |     118 |          |

--------------------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   1 - filter("RN"<=10)

   4 - access("PROD_ID"=30)

 

만약 해결방법 두 가지(Partition By 추가, PROMO_ID 삭제)중 한가지만 적용했다면 10점으로 처리하면 되므로 채점은 가능하다. 아래의 설명부분과 SQL은 답안이 아니며, 원리를 설명하는 부분이다.

 

Operation 통합의 원리
분석함수 SUM
Partition By절을 추가하고, order by절에 PROMO_ID를 삭제하니, 예상대로 분석함수 두 개의 Plan이 하나로 합쳐졌다. 여기서 알 수 있는 점은 두 가지 이다. 첫 번째, Partition By Order By를 통일 시키면 Operation이 통합되어 분석함수 하나만 실행시키는 결과를 얻는다. 두 번째, WINDOW NOSORT(ROW_NUMBER) + WINDOW BUFFER(SUM) 가 합쳐져서 WINDOW BUFFER가 되었다는 점이다. 좋지 않은 Operation으로 통합된다는 것을 알 수 있다. 기준은 다음과 같다.

 

 WINDOW NOSORT + WINDOW SORT    => WINDOW SORT

 WINDOW NOSORT + WINDOW BUFFER  => WINDOW BUFFER

 WINDOW BUFFER + WINDOW SORT    => WINDOW SORT


PROMO_ID
를 삭제해도 되는 이유는 ORDER BY에서 모든 PK 컬럼을 사용하였기 때문이다. PK 컬럼들로 ORDER BY절에서 모두 SORT 되었으므로, 나머지 컬럼으로 아무리 SORT 해보아야 결과는 동일하다는 것이다. Order By절에서 PROMO_ID를 삭제하자 WINDOW SORT WINDOW BUFFER 로 바뀌었다. 이에 따라 PGA사용량이 1747K에서 1495K 약간 줄어들었다
 


만약 Partition By만 추가하고 PROMO_ID를 삭제하지 않으면 아래와 같이 sort의 비효율이 발생한다.

 

SELECT *

  FROM ( SELECT /*+ INDEX(T PK_SALES_T) */ PROD_ID, CUST_ID, TIME_ID, CHANNEL_ID, AMOUNT_SOLD,

                ROW_NUMBER()     OVER (PARTITION BY PROD_ID ORDER BY CUST_ID,CHANNEL_ID,TIME_ID ) AS RN,

                SUM(AMOUNT_SOLD) OVER (PARTITION BY PROD_ID ORDER BY CUST_ID,CHANNEL_ID,TIME_ID,PROMO_ID  ) AS SUM_AMT

           FROM SALES_T T

          WHERE PROD_ID = 30 )

 WHERE RN <= 10 ; 

 

--------------------------------------------------------------------------------------------------------

| Id  | Operation                     | Name       | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |

--------------------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT              |            |      1 |     10 |00:00:00.33 |   28337 |          |

|*  1 |  VIEW                         |            |      1 |     10 |00:00:00.33 |   28337 |          |

|   2 |   WINDOW SORT                 |            |      1 |  29282 |00:00:00.30 |   28337 | 1747K (0)|

|   3 |    TABLE ACCESS BY INDEX ROWID| SALES_T    |      1 |  29282 |00:00:00.17 |   28337 |          |

|*  4 |     INDEX RANGE SCAN          | PK_SALES_T |      1 |  29282 |00:00:00.03 |     118 |          |

--------------------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

 

   1 - filter("RN"<=10)

   4 - access("PROD_ID"=30)

  

Order By절이 다르지만, 통합이 가능한 이유
PROMO_ID
를 추가하자, WINDOW SORT가 발생하여 PGA 사용량이 늘어났다. 분석함수 SUM ORDER BY절을 보면 PROMO_ID가 있는데 ROW_NUMBER 분석함수에는 PROMO_ID가 없다. ORDER BY는 완벽히 같지 않아도 Operation이 통합 될 수 있다는 점을 알 수 있다. ORDER BY의 마지막 항목이 달라도 Operation이 통합 될 수 있는 원리는 간단하다. Sort 작업을 A + B + C로 이미 했다면 Sort A + B 로 다시 해야 할 필요가 없기 때문이다. A + B + C Sort 하는 분석함수 기준으로 A + B Sort 하는 분석함수가 통합된다는 의미이다.  

 

오해하지 말 것

서로 다른 종류의 분석함수를 실행했을 때, Partition By의 컬럼과 Order By의 컬럼을 각각 똑같이 맞추어야 하는 것은 아니다. Partition ByOrder By의 컬럼을 각각 맞추는 것이 아니라, OVER절 전체의 컬럼을 일치시키면 된다. 아래의 SQL이 좋은 예제이다.

 

SELECT *

  FROM ( SELECT /*+ INDEX(T PK_SALES_T) */ PROD_ID, CUST_ID, TIME_ID, CHANNEL_ID, AMOUNT_SOLD,

                ROW_NUMBER()     OVER (PARTITION BY PROD_ID  ORDER BY CUST_ID, CHANNEL_ID,TIME_ID ) AS RN,

                SUM(AMOUNT_SOLD) OVER (PARTITION BY PROD_ID, CUST_ID  ORDER BY CHANNEL_ID,TIME_ID ) AS SUM_AMT

           FROM SALES_T T

          WHERE PROD_ID = 30 )

 WHERE RN <= 10 ;

 

--------------------------------------------------------------------------------------------------------

| Id  | Operation                     | Name       | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |

--------------------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT              |            |      1 |     10 |00:00:00.32 |   28337 |          |

|*  1 |  VIEW                         |            |      1 |     10 |00:00:00.32 |   28337 |          |

|   2 |   WINDOW BUFFER               |            |      1 |  29282 |00:00:00.28 |   28337 | 1495K (0)|

|   3 |    TABLE ACCESS BY INDEX ROWID| SALES_T    |      1 |  29282 |00:00:00.14 |   28337 |          |

|*  4 |     INDEX RANGE SCAN          | PK_SALES_T |      1 |  29282 |00:00:00.04 |     118 |          |

--------------------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   1 - filter("RN"<=10)

   4 - access("PROD_ID"=30)

 

위의 SQL을 보면 분석함수 두 개의 Partition By의 컬럼과 Order By 컬럼이 모두 다르다. 하지만 실행계획상의 operation은 하나로 통합되었다. OVER절을 바라볼 때 Partition ByOrder By를 삭제하고 컬럼들만 남긴다면, operation이 통합이 될지, 아닐지 판단할 수 있다. 예컨대, over절의 컬럼은 두 개의 분석함수 모두 PROD_ID + CUST_ID + CHANNEL_ID + TIME_ID 로 똑같으므로 operation이 통합된다.

 

중요한 것은 컬럼의 순서이다. Partition By Order By를 삭제하고 순수하게 over절의 컬럼들만 남겼을 때, 컬럼의 순서가 같아야 operation이 통합된다. 컬럼의 순서가 다르면 통합되지 않는다. 위의 분석함수 둘은 over절의 컬럼순서가 PROD_ID + CUST_ID + CHANNEL_ID + TIME_ID로 같음을 알 수 있다. OVER절 전체의 컬럼순서가 같다면, 컬럼의 개수와는 상관없이 통합이 가능하다. 예를 들어, 분석함수를 두 개 사용하고 컬럼순서가 하나는 A+B+C 이고 다른 하나는 A+B 라면, 컬럼순서가 동일하므로 통합이 가능하다하지만 A+B+C A+C의 조합은 컬럼순서가 다르므로 통합이 불가능하다.

 

문제2의 비효율 3

비효율 2의 튜닝결과를 보면 Ranking family를 사용하고, 인라인뷰 외부에서 분석함수를 Filter로 처리했음에도 불구하고 STOPKEY가 작동하지 않는다. 이에 따라 부분범위처리가 되지 못하고 불필요한 데이터를 모두 Scan해야 한다.

 

문제2의 비효율 3의 해결방법

STOPKEY 작동되도록 하려면 ROW_NUMBER OVER절에서 불필요한 PARTITION BY PROD_ID를 제거하면 된다. PROD_ID는 이미 WHERE 절의 Equal 조건에 의하여 상수화 되었기 때문에 PARTITION BY는 필요 없다.

 

문제2의 비효율 3이 해결된 SQL과 실행계획 제시

 

SELECT *

  FROM ( SELECT /*+ INDEX(T PK_SALES_T) */ PROD_ID, CUST_ID, TIME_ID, CHANNEL_ID, AMOUNT_SOLD,

                ROW_NUMBER()     OVER (                     ORDER BY CUST_ID,CHANNEL_ID,TIME_ID ) AS RN,

                SUM(AMOUNT_SOLD) OVER (PARTITION BY PROD_ID ORDER BY CUST_ID,CHANNEL_ID,TIME_ID ) AS SUM_AMT

           FROM SALES_T T

          WHERE PROD_ID = 30 )

 WHERE RN <= 10 ; 

 

---------------------------------------------------------------------------------------------------------

| Id  | Operation                      | Name       | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |

---------------------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT               |            |      1 |     10 |00:00:00.16 |   28337 |          |

|*  1 |  VIEW                          |            |      1 |     10 |00:00:00.16 |   28337 |          |

|*  2 |   WINDOW NOSORT STOPKEY        |            |      1 |     10 |00:00:00.16 |   28337 |          |

|   3 |    WINDOW BUFFER               |            |      1 |     11 |00:00:00.16 |   28337 | 1495K (0)|

|   4 |     TABLE ACCESS BY INDEX ROWID| SALES_T    |      1 |  29282 |00:00:00.11 |   28337 |          |

|*  5 |      INDEX RANGE SCAN          | PK_SALES_T |      1 |  29282 |00:00:00.03 |     118 |          |

---------------------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   1 - filter("RN"<=10)

   2 - filter(ROW_NUMBER() OVER ( ORDER BY "CUST_ID","CHANNEL_ID","TIME_ID")<=10)

   5 - access("PROD_ID"=30)

 

아래의 설명부분과 SQL은 답안이 아니며 원리를 설명하는 부분이다.

 

Ranking family에서 불필요한 Partition By를 삭제하자 WINDOW NOSORT STOPKEY가 발생되었다. 하지만 실행계획을 자세히 보면 비효율이 존재한다. WINDOW BUFFER 때문에 29282건을 모두 읽은 후에 STOPKEY가 발생되었다. 처음부터 11건만 읽는 방법은 없는 것일까? 그렇다고 위의 SQL에서 분석함수 SUM PARTITION BY도 삭제한다면 비효율 2의 실행계획으로 돌아가 버린다. 비효율 2에서 이미 설명했던 원칙(좋지 않은 Operation으로 통합되는 원칙) 때문에 WINDOW NOSORT STOPKEY WINDOW BUFFER에 묻혀버리는 것이다.

 

만약 WINDOW BUFFER(분석함수 SUM)가 없다면 WINDOW NOSORT STOPKEY가 완벽히 동작한다. 아래의 SQL로 증명할 수 있다.

 

SELECT *

  FROM ( SELECT /*+ INDEX(T PK_SALES_T) */ PROD_ID, CUST_ID, TIME_ID, CHANNEL_ID, AMOUNT_SOLD,

                ROW_NUMBER() OVER ( ORDER BY CUST_ID,CHANNEL_ID,TIME_ID ) AS RN

           FROM SALES_T T

          WHERE PROD_ID = 30 )

 WHERE RN <= 10 ;  

 

--------------------------------------------------------------------------------------------------------

| Id  | Operation                     | Name       | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |

--------------------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT              |            |      1 |     10 |00:00:00.01 |      15 |          |

|*  1 |  VIEW                         |            |      1 |     10 |00:00:00.01 |      15 |          |

|*  2 |   WINDOW NOSORT STOPKEY       |            |      1 |     10 |00:00:00.01 |      15 |          |

|   3 |    TABLE ACCESS BY INDEX ROWID| SALES_T    |      1 |     11 |00:00:00.01 |      15 |          |

|*  4 |     INDEX RANGE SCAN          | PK_SALES_T |      1 |     11 |00:00:00.01 |       4 |          |

--------------------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   1 - filter("RN"<=10)

   2 - filter(ROW_NUMBER() OVER ( ORDER BY "CUST_ID","CHANNEL_ID","TIME_ID")<=10)

   4 - access("PROD_ID"=30)

 

분석함수 SUM만 제거한다면 STOPKEY가 비효율 없이 작동하여 정확히 11건만 읽었다. 위의 실행통계와 비효율 3이 튜닝된 SQL의 실행통계를 비교해보라. Buffers Used-Mem 항목을 본다면 비교할 수 없을 정도이다.  또한 WINDOW STOPKEY의 활용범위는 ROWNUM 보다 넓다. 물론 ROW_NUMBER는 ROWNUM 처럼 사용 할 수도 있다. 하지만, RANK나 DENSE_RANK의 경우로 본다면 추가적인 활용법이 있다. 예를 들어, 학교에서 수학성적으로 전교석차 2등 까지 출력한다고 치자. 그러면 1등과 2등이 각각 두 명 이상일 수 있다. 이런 경우는  ROWNUM으로는 구현할 수 없다. RANK나 DENSE_RANK가 WINDOW NOSORT STOPKEY로 성능을 충족시켜면서, 업무요구사항을 만족시키는 유일한 방법이다.

Sort량 2K vs 1999K의 비밀

비효율 3에서는 Partition By를 삭제하여 Sort가 전혀 발생하지 않는 경우를 보았다. 만약 Sort를 동반하는 경우라면, Partition By를 삭제함에 따라 Sort의 일량에 어떤 영향을 끼칠까? 아래의 SQL을 보자.
 

SELECT *

  FROM (SELECT /*+ INDEX(T PK_SALES_T) */

               PROD_ID, CUST_ID, TIME_ID, CHANNEL_ID, AMOUNT_SOLD,

               ROW_NUMBER ()     OVER (ORDER BY CUST_ID, CHANNEL_ID, TIME_ID NULLS FIRST) AS RN,

               SUM (AMOUNT_SOLD) OVER (ORDER BY CUST_ID, CHANNEL_ID, TIME_ID, PROMO_ID) AS SUM_AMT

          FROM SALES_T T

         WHERE PROD_ID = 30)

 WHERE RN <= 10 ;

 

---------------------------------------------------------------------------------------------------------

| Id  | Operation                      | Name       | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |

---------------------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT               |            |      1 |     10 |00:00:00.34 |   28337 |          |

|*  1 |  VIEW                          |            |      1 |     10 |00:00:00.34 |   28337 |          |

|*  2 |   WINDOW SORT PUSHED RANK      |            |      1 |     11 |00:00:00.34 |   28337 | 2048  (0)|

|   3 |    WINDOW SORT                 |            |      1 |  29282 |00:00:00.30 |   28337 | 1684K (0)|

|   4 |     TABLE ACCESS BY INDEX ROWID| SALES_T    |      1 |  29282 |00:00:00.18 |   28337 |          |

|*  5 |      INDEX RANGE SCAN          | PK_SALES_T |      1 |  29282 |00:00:00.04 |     118 |          |

---------------------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   1 - filter("RN"<=10)

   2 - filter(ROW_NUMBER() OVER ( ORDER BY "CUST_ID","CHANNEL_ID",INTERNAL_FUNCTION("TIME_ID") NULLS FIRST)<=10)

   5 - access("PROD_ID"=30)

위의 SQL은 개발자가 작성한 원본 SQL에서 ROW_NUMBER 함수의 Partition By절을 삭제하고 실행한 것이다. 이 경우는 over절의 NULLS FIRST 때문에 Sort가 발생되는 경우이다. 이 때에도 Sort가 최소화 되어 PGA 사용량 2K에 불과하다. 개발자가 작성한 원본 실행통계의 Sort량이 1999K임을 감안하면 Partition By의 존재유무는 성능에 지대한 영향을 끼친다고 할 수 있다. Sort량이 이렇게 큰 차이가 나는 이유는 파티션을 삭제한 효과 + RN <= 10 조건 때문이다. 그 filter 조건에 의해서 10개의 배열만 관리하면 되므로 PGA 사용량은 최소화 된다. WINDOW SORT PUSHED RANK의 이러한 성격은 인덱스를 사용하지 않는 경우에도 동일하게 나타난다위의 SQL에서 힌트를 FULL(T)로 바꾸고 실행하여도 PGA 사용량은 동일하다. 

위의 SQL 처럼 ROW_NUMBER 함수의 NULLS FIRST 보다 Partition By절을 먼저 삭제한 사람은 이 원리를 답안으로 사용해도 무방하다하지만 비효율 3에서 설명되었던, Partition By 삭제에 의한 NOSORT STOPKEY 유도 원리는 WINDOW SORT PUSHED RANK와 성격이 다르므로 반드시 알아두어야 한다.    

 

문제2의 비효율 4

진퇴양란, 포기할 것인가?

비효율 3의 튜닝결과를 보면 STOPKEY를 발생시키려고 ROW_NUMBER Partition By를 제거하니 Operation이 두 개로 분리되어 다시 비효율이 발생하였다. 그렇다고 분석함수 SUM을 삭제하자니 업무요구사항이 만족되지 않는다. 어떻게든 STOPKEY를 발생시켜서 29282건을 모두 읽는 비효율을 제거해야 한다.

 

분석함수 SUM을 삭제하는 것은 답이 아니다

보통 이런 경우(진퇴양란)에서는 둘 중에 한가지를 선택한다. 튜닝을 포기하거나 장애물을 제거한다. 열정이 있는 개발자들은 장애물을 제거할 것이다. 분석함수 SUM을 삭제하고, 조회화면에서 프로그램 스크립트를 작성하여 분석함수 SUM의 로직을 대신한다. 물론 이렇게 하는 것은 목적을 달성하는 것이므로, 포기하는 것보다는 만족스럽다. 하지만 나는 포기하지도 말고, 프로그래밍 언어의 도움도 받지 말라고 주장한다. SQL이 아닌 다른 언어의 도움으로 비효율을 해결한 것은 이 문제의 정답이 아니며, SQL만으로 해결할 수 있다.

 

문제2의 비효율 4 해결방법

일부 개발자는 분석함수 SUM을 제거하고 프로그래밍 언어의 도움을 받아 해결하였다. 하지만 우리는 분석함수 SUM을 물리적으로 삭제하는 것이 아니라 실행계획상에서만 삭제하고자 한다. 방법은 SUM/MIN/MAX/AVG 등의 분석함수에 WINDOW의 범위를 명시적으로 작성하면 된다. 물론 범위를 명시적으로 작성해도 답은 동일하다.

 

문제2의 비효율 4가 해결된 SQL과 실행계획 제시

 

SELECT *

  FROM ( SELECT /*+ INDEX(T PK_SALES_T) */ PROD_ID, CUST_ID, TIME_ID, CHANNEL_ID, AMOUNT_SOLD,

                ROW_NUMBER()     OVER (ORDER BY CUST_ID,CHANNEL_ID,TIME_ID ) AS RN,

                SUM(AMOUNT_SOLD) OVER (ORDER BY CUST_ID,CHANNEL_ID,TIME_ID

                                       ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS SUM_AMT

           FROM SALES_T T

          WHERE PROD_ID = 30 )

 WHERE RN <= 10 ; 

 

--------------------------------------------------------------------------------------------------------

| Id  | Operation                     | Name       | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |

--------------------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT              |            |      1 |     10 |00:00:00.01 |      15 |          |

|*  1 |  VIEW                         |            |      1 |     10 |00:00:00.01 |      15 |          |

|*  2 |   WINDOW NOSORT STOPKEY       |            |      1 |     10 |00:00:00.01 |      15 |          |

|   3 |    TABLE ACCESS BY INDEX ROWID| SALES_T    |      1 |     11 |00:00:00.01 |      15 |          |

|*  4 |     INDEX RANGE SCAN          | PK_SALES_T |      1 |     11 |00:00:00.01 |       4 |          |

--------------------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   1 - filter("RN"<=10)

   2 - filter(ROW_NUMBER() OVER ( ORDER BY "CUST_ID","CHANNEL_ID","TIME_ID")<=10)

   4 - access("PROD_ID"=30)

 

분석함수 SUM을 제거한 것은 아니지만, WINDOW의 범위를 주어 동일한 효과를 얻었다. 정확히 11건만 읽었으므로, 깔끔하게 모든 문제가 정리되었다. Window의 범위를 명시적으로 작성하는 것이 얼마나 중요한지 알 수 있다.

 

여기까지가 답안이다. 이제 어떻게 된 것인지 알아보기 위해 분석함수 ROW_NUMBER를 삭제해 보자.

 

SELECT *

  FROM ( SELECT /*+ INDEX(T PK_SALES_T) */ PROD_ID, CUST_ID, TIME_ID, CHANNEL_ID, AMOUNT_SOLD,

                SUM(AMOUNT_SOLD) OVER (ORDER BY CUST_ID,CHANNEL_ID,TIME_ID

                                       ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS SUM_AMT

           FROM SALES_T T

          WHERE PROD_ID = 30 ) ;

         

 

--------------------------------------------------------------------------------------------------------

| Id  | Operation                     | Name       | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |

--------------------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT              |            |      1 |  29282 |00:00:00.36 |   28340 |          |

|   1 |  VIEW                         |            |      1 |  29282 |00:00:00.36 |   28340 |          |

|   2 |   WINDOW NOSORT               |            |      1 |  29282 |00:00:00.30 |   28340 |          |

|   3 |    TABLE ACCESS BY INDEX ROWID| SALES_T    |      1 |  29282 |00:00:00.20 |   28340 |          |

|*  4 |     INDEX RANGE SCAN          | PK_SALES_T |      1 |  29282 |00:00:00.03 |     121 |          |

--------------------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   4 - access("PROD_ID"=30)

 

그룹분석함수와 Ranking Family를 같이 사용해도 STOPKEY가 발생한다

그룹분석함수임에도 SORT가 전혀 발생하지 않았다. SUM/MIN/MAX/AVG 등의 분석함수에 WINDOW의 범위를 지정해주면 WINDOW BUFFERWINDOW NOSORT로 바뀐다는 이야기 이다. 이것은 의미 있는 발견이다. 왜냐하면 이때 까지는 SUM/MIN/MAX/AVG 등의 분석함수 때문에 적절한 인덱스를 사용하였지만 WINDOW BUFFER가 발생하여 STOPKEY를 발생시키지 못했다. 그런데 이제 WINDOW NOSORT가 가능해짐으로 Ranking Family STOPKEY가 정상적으로 작동하기 때문이다.

이제 적절한 인덱스가 있고, SUM등의 그룹분석함수를 사용한다면, 다음과 같은 결론을 내릴 수 있다

 

SUM등의 그룹분석함수를 사용한다면, WINDOW의 범위를 지정해주어야 한다. 그렇게 한다면 Sort가 전혀 발생하지 않을 뿐만 아니라, Ranking Family 분석함수를 rownum 처럼 사용할 때, STOPKEY를 발생시켜 성능이 개선된다.”


분석함수를 만날 때의 자세
2
번 문제의 원본 SQL 매우 짧은 코드이지만, 비효율은 4개나 된다튜닝을 완료했다고 자신하는 순간이라도마지막 남은 분석함수의 비효율 하나 때문에 SQL의 성능향상은 도루묵이 될 수 있다. 이런 사실은 SQL을 튜닝 할 때, 분석함수를 만나면 안테나를 세워야 하는 이유가 된다

미래지향

다섯 가지 비효율에 대해 80점 혹은 그 이상을 받은 사람은 분석함수에 관한 튜닝은 상당한 수준에 있다고 생각한다. 반대로, 80점이 안 된다고 실망하지 말자. 왜냐하면 퀴즈에서 실패했더라도, 실무튜닝에서 100점을 받으면 그만이다. 물론 이렇게 되려면, SQL과 실행계획을 보고 비효율을 읽어내는 능력이 필요하다

마지막으로 다섯 가지의 원리로 이 글 전부를 정리 해보자. 다섯 가지의 원리는 대부분 아래의 구조로 되어있다.

1) 소제목
2) ~ 를 하라.
3) 그렇게 하면 ~ 효과가 있다.
 

분석함수의 성능향상 원리


1. min/max 분석함수를 Ranking family로 대체하는 원리

적절한 인덱스를 사용하면서, 최종일자에 해당하는 데이터를 구할 때, MAX(최종일자) 분석함수를 사용하지 말고 Ranking family를 사용하라. 그렇게 하면 WINDOW BUFFER WINDOW NOSORT로 바꾸어 sort를 방지한다.

 

2. sort의 기준을 유지하는 원리
적절한 인덱스가 존재하는 경우, 분석함수의 ORDER BY절에 NULL FIRST LAST를 삭제할 수 있는지 검토하라그렇게 하면 인덱스 사용에 의한 Sort 기준이 유지되므로, 추가적인 sort를 방지한다주의사항은 NULL FIRST LAST를 사용한다고 해서 항상 Sort의 기준이 바뀌는 것은 아니며, 아래의 경우만 해당한다.

            ex1) Index ASC로 사용 + 분석함수의 ORDER BY절에 NULL FIRST 는 추가적인 sort발생

               ex2) Index DESC로 사용 + 분석함수의 ORDER BY절에 NULL LAST 는 추가적인 sort발생

 

3. 여러 개의 분석함수를 하나의 Operation으로 통합하는 원리

적절한 인덱스가 존재하고, 분석함수를 여러 개 사용할 때, 가능하면 OVER절의 Partition By Order By절을 일치시켜라. 그렇게 하면 여러 개의 OPERATION이 통합된다. 다시 말해, 분석함수를 하나만 실행하는 효과를 얻어서 실행시간을 단축시킨다이때 ORDER BY는 완전히 같지 않아도 Operation은 통합될 수 있다단 아래와 같이 좋지 않은 Operation 을 기준으로 통합된다.

         ex1) WINDOW NOSORT + WINDOW SORT   => WINDOW SORT

         ex2) WINDOW NOSORT + WINDOW BUFFER => WINDOW BUFFER

         ex3) WINDOW BUFFER + WINDOW SORT   => WINDOW SORT
 
ORDER BY가 다를 때, Operation이 통합될 수 있는 조건이 있다. 그 세부내용은 본문을 참조하라.

 

4. Ranking Family를 이용한 TOP SQL에서 Sort 최소화의 원리

Ranking Family 분석함수를 인라인뷰 외부에서 Rownum 처럼 Filter로 사용했을 때, 불필요한 Partition By절을 삭제하라. 그렇게 하면 두 가지 경우에 성능이 향상된다. 주의사항은 Partition By절 전체를 제거해야 한다는 것이다. 만약 Partition By절에 컬럼이 하나라도 있으면 Sort가 대량으로 발생된다. 각각의 파티션 값마다 sort를 해야 하므로 어쩔 수 없는 일이다.

첫 번째, 적절한 인덱스가 없어서 FULL SCAN을 하거나 혹은 OVER절의 NULL FIRST LAST등의 원인으로 sort가 발생될 때 이다. 이럴 경우 Partition By을 제거하면 필요한 개수 + 1 만큼만 sort가 발생하므로 성능이 향상된다. 하지만 Rownum과는 달리 STOPKEY가 발생되지 않으므로 비효율은 존재한다. 이 때 발생되는 OperationWINDOW SORT PUSHED RANK 이다.

 

두 번째, 적절한 인덱스가 있어서 sort가 발생되지 않는 경우이다. 이 경우는 첫 번째의 경우와는 달리 sort가 전혀 발생하지 않으며, Rownum 처럼 STOPKEY를 발생시켜 부분범위 처리의 효과를 얻는다. 다시 말해, 분석함수를 Rownum 처럼 사용할 수 있다. 이 때 발생되는 OperationWINDOW NOSORT STOPKEY 이다. 이 기능은 10g R2에서 추가 되었다

WINDOW NOSORT STOPKEY는 주의사항이 있다그룹분석함수의 OVER절과 Ranking Family OVER절의 컬럼순서가 다르면 Operation이 하나로 통합되지 못한다이 경우는 성능이 저하된다왜냐하면 WINDOW SORT 혹은 WINDOW BUFFER 가 먼저 실행되고 그 후에 WINDOW NOSORT STOPKEY가 발생하기 때문에 Block I/O sort량을 감소시키지 못한다. 이런 비효율 때문에 분석함수 여러 개를 하나의 Operation으로 통합하는 원리(원리 3) 역할이 중요한 것이다. 이와 관련된 예제는 비효율3이 해결된 SQL과 실행계획을 참조하기 바란다. 

 

5. 그룹분석함수의 NO-SORT 원리

이 원리는 장점이 두 가지 이다. Sort를 방지하고, STOPKEY를 작동시킨다.

 

적절한 인덱스가 존재하고, sum/min/max/avg등의 Group 분석함수를 사용할 경우 명시적으로 WINDOW의 범위를 지정하라. 그렇게 하면 WINDOW BUFFER Operation WINDOW NOSORT로 바꾸어 불필요한 sort를 방지한다. 물론 여기서 이야기 하는 그룹분석함수는 현재 row 까지의 누적집계(Cumulative total) 이다.

 

더욱 좋은 것은, Ranking family Rownum 처럼 사용하고 있다면, STOPKEY를 사용할 수 있게 한다. Group 분석함수를 사용하면, WINDOW BUFFER가 발생하고, sort가 발생되는데, 이 때문에 STOPKEY를 작동 시킬 수 없다. 그런데 WINDOW의 범위를 지정하여 WINDOW NOSORT로 바꾸면 sort가 발생하지 않기 때문에 STOPKEY가 작동한다. WINDOW의 범위를 지정하는 방법은 아래와 같다. 범위를 생략해도 결과는 같지만, 성능이 저하된다.

 ex) SUM(AMOUNT_SOLD) OVER(ORDER BY CUST_ID,CHANNEL_ID,TIME_ID ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)

참고: Ranking family를 사용하여 WINDOW STOPKEY가 발생하는 경우는 SORT가 발생하지 않는 경우(NOSORT)뿐이다그룹분석함수에 의해서 추가적인 SORT를 해야 한다면, 전체범위로 처리됨으로 STOPKEY가 발생하지 않는다.


위의 다섯 가지 원리가 적용되지 않은 것이 각각의 비효율이다. 즉 위의 다섯 가지 원리를 비효율의 해결방법으로 작성한 사람은 100점이다.


자동튜닝기능이 아쉬워

이 글의 결론을 보고 많은 이들이 두 가지를 아쉬워할 것이다. 예컨대, 분석함수 SUM WINDOW의 범위를 생략했더라도 자동으로 ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW를 추가해 준다면 얼마나 좋을까? 불필요한 Partition By절을 수동으로 삭제해야 하는 것 또한 마찬가지이다. 아직까지 옵티마이저는 사람을 따라올 수 없다. 사람의 손길을 여전히 필요로 한다따라서 전문 개발자라면 Partition By Order By 혹은 Window의 범위지정 등의 코딩을 할 때 내부적으로 어떤 일이 발생하는지 알아야 한다. 그냥 코딩을 하는 사람과 그것이 성능상 어떤 의미를 지니는지 알고 개발하는 사람과는 차이가 크다

 

이상으로 분석함수의 내부에 대해 3회에 걸쳐 알아보았다. 1/2부의 내용과 위의 결론 다섯 가지를 완벽히 이해한다면, 분석함수를 사용한 SQL과 실행계획, 그리고 인덱스 구조만 보고도 순간적으로 비효율을 잡아낼 수 있다분석함수의 비효율을 만나면 번개가 치듯이 생각이 번쩍 들것이다실행계획을 보고 비효율의 냄새를 맡을 줄 아는 능력은 중요하다. 

마지막으로, 분석함수의 실행계획에 관한 글을 의뢰한 개발자에게 감사를 표한다. 아무래도 내가 그 개발자에게 맥주를 사는 것이 더 좋을 것 같다. 왜냐하면 그 사람 덕분에 좋은 것(Window의 범위 지정의 효과)을 발견할 수 있었기 때문이다. 두서 없는 긴 글을 읽어주신 독자 여러분들께 감사 드린다 

------------------------------------------------------------------------------------------------------------------------------------------------

이번 글은 여기까지 이다. 아래의 글은 이전에 올렸던 글의 정답보다 더 좋은 것이 발견되었음을 알리는 것이다. 

 

분석함수의 실행계획을 정리하면서 Group(SUM/MIN/MAX/AVG) 분석함수에 WINDOW의 범위를 명시적으로 작성하는 것이 얼마나 중요한지 필자도 깨닫게 되었다. 예컨대, Pagination 분석함수의 위험한 조합 글에서 도출된 정답보다 더 좋은 것은 WINDOW의 범위를 명시하는 것이다. 따라서 분석함수를 밖으로 빼는 것보다, 아래와 같이 window의 범위를 지정해 주는 것이 더 유리하다.

 

SELECT *

  FROM (SELECT s.*, ROWNUM rnum

          FROM (SELECT   /*+ INDEX(S PK_SALES_T) */

                         s.prod_id, s.cust_id,  s.channel_id, s.time_id, amount_sold,

                         sum (amount_sold) OVER (PARTITION BY s.cust_id ORDER BY s.channel_id, s.time_id

ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS sum_amt

                    FROM sales_t s

                   WHERE s.prod_id = :v_prod_id  --> 30 대입

                ORDER BY s.cust_id, s.channel_id, s.time_id

               ) s

         WHERE ROWNUM <= :v_max_row  --> 20 대입

       )

 WHERE rnum >= :v_min_row ;          --> 1 대입

 

----------------------------------------------------------------------------------------------------------

| Id  | Operation                       | Name       | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |

----------------------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT                |            |      1 |     20 |00:00:00.01 |      24 |          |

|*  1 |  VIEW                           |            |      1 |     20 |00:00:00.01 |      24 |          |

|*  2 |   COUNT STOPKEY                 |            |      1 |     20 |00:00:00.01 |      24 |          |

|   3 |    VIEW                         |            |      1 |     20 |00:00:00.01 |      24 |          |

|   4 |     WINDOW NOSORT               |            |      1 |     20 |00:00:00.01 |      24 |          |

|   5 |      TABLE ACCESS BY INDEX ROWID| SALES_T    |      1 |     20 |00:00:00.01 |      24 |          |

|*  6 |       INDEX RANGE SCAN          | PK_SALES_T |      1 |     20 |00:00:00.01 |       4 |          |

----------------------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   1 - filter("RNUM">=:V_MIN_ROW)

   2 - filter(ROWNUM<=:V_MAX_ROW)

   6 - access("S"."PROD_ID"=:V_PROD_ID)

 

20건에 대해서 조차 SORT가 전혀 발생하지 않는다. 따라서 Pagination 분석함수의 위험한 조합 이라는 글은 수정되어야만 한다. 내가 작성한 글을 내가 뒤집었다. 내 것이라도 비판적으로 바라보아야 한다. 최종이론이란 없는 것이므로
 

------------------------------------------------------------------------------------------------------------------------------------------------

PS: 몇분이 구두로 혹은 메일로 괴상한 질문을 해서 답글을 남긴다.

Question: 어떻게 이런 책과 글을 쓰며, 주제는 어떻게 잡으며, 연구는 어떻게 하는가?  

--> 이상한 질문이다. 내가 용빼는 재주라도 있나?

Answer:

필자의 머리가 좋아서 글을 쓸 수 있는 게 아니다오히려 그 반대이다. 똑똑한 다른 사람이라면, 내가 연구랍시고 소비한 시간의 절반만 투자해도 이 글을 썼을 것이다나는 다른 사람과 달리 영민하지 못하여 하찮은 문제에도 많은 시간을 소모하며, 실패하면 다시 조금씩 앞으로 전진 할 수 밖에 없다그렇다고 다른 재주가 있느냐 하면 그렇지 않다. 방향감각이 둔하여 연구가 막다른 길로 빠진 적이 여러 번일 뿐만 아니라 남들이 가지고 있는 창의성, 순발력, 영어실력문장력, 준수한 외모를 나는 가지지 못했고, 심지어 노래방에서는 박자를 못 맞추는 절대음감까지 그 어느 것도 잘하는 게 없다아무리 생각해도 세상은 불공평 한 듯 하다

내가 할 수 있는 일은 나의 유일한 단점이자 장점을 살리는 수뿐이다실수를 하더라도, 거북이 근성으로 시간이 나는 대로 조금씩 연구를 하여 이런 글을 쓰는 수 밖에 없다. 글의 주제와 관련된 것도 특별한 것은 없다. 소주한잔 마시면서 어떤 글을 쓸지 동료들과 소통하거나, 독자들이 필요한 글을 나에게 요청한다내가 직접 글감을 고른 적은 5번중의 한번 정도이다글의 주제가 정해지면 무작정 글을 쓴다. 글을 쓰다가 모르는 것이 있으면(모르는 게 많다) 그에 맞춰 test(연구)를 한다. 궁금증이 해결되면 다시 글을 쓴다. 이것이 끝이다.

 

이 글도 위에서 설명한 절차와 똑같이 진행되었다. 단언하건대, 분석함수의 성능에 대해 이 문서만큼 깊이 다룬 책이나 매뉴얼은 지금까지 없다이런 일이 가능한 이유는 내가 아둔하고, 모르는 게 많은 거북이라서 그런 것이 아닐까?  

Posted by extremedb
,

필자가 제안한 SQL 튜닝방법론을 읽어 보았다면 다음을 기억할 것이다.


“SQL
튜닝방법론은 온라인 SQL 90% 만 해결할 수 있으며,
나머지 10%는 그때 그때 마다 다르게(On the fly 모드) 처리된다. 또한 그것들은 책이나 매뉴얼에 나와있지 않기 때문에 경험치 이거나 실험과 연구의 결과로 알아내는 것들이다.”

이번 글은 방법론에서 다룰 수 없는, 나머지 10%에 해당하는 경험치와 연구의 결과이다.


분석함수의 나머지 10%

오늘은 SQL 튜닝방법론에서 언급한 나머지 10%를 만났을 때 어떻게 해야 하는지 알아보자. 이번 글이 분석함수의 실행계획 시리즈의 마지막이다. 12에서 분석함수의 실행계획에 대해서 알아보았다. 하지만 두 개의 글로도 분석함수와 관련된 성능이슈를 모두 알 수는 없다. 왜냐하면 분석함수와 관련된 세밀한 튜닝포인트가 더 있기 때문이다. 이번에 다루게 될 실전 튜닝은 단순히 적절한 인덱스를 생성하여 비효율을 없애는 것이 아니다. 적절한 인덱스를 생성하는 것은 1부와 2부만 이해해도 충분하며, 이 글에서는 1부와 2부에서 언급하지 않은 것들을 설명하게 된다. 따라서 이 글에서 제시된 문제를 해결하지 못하면 분석함수를 튜닝 할 수 없는 경우를 많이 만날 것이다.

 

퀴즈는 SQL 두 개이며, 5가지 비효율을 개선해야 한다

이번에는 실전에서 발생할 수 있는 비효율에 대해서 두 가지 관점으로 논의한다. 분석함수를 사용할 때, 실행계획을 보고 비효율을 잡아내는 능력을 향상시키고, 실제로 SQL튜닝을 해보는 것이다. 반드시 문제를 직접 풀어보기 바란다. 당신이 튜너의 입장이 되어, 성능이 좋지 않은 SQL을 두 개 받았다고 가정하고 튜닝문제를 풀어보기 바란다. 두 가지 SQL 중 하나는 쉽고 나머지 하나는 어렵다. 두 개의 SQL에는 5가지의 튜닝 포인트가 있다. 각각의 포인트는 20점이며 5가지를 모두 맞추는 경우는 100점이 된다. 채점 시 중요한 점은 SQL을 맞추는 것이 아니라 비효율을 몇 개 잡아내었는지, 또한 각각의 비효율을 개선시킬 수 있는 방법은 몇 개나 생각했는지를 채점하는 것이다. 

  

이제 실습을 위한 테이블과 인덱스를 생성하자.

CREATE TABLE SALES_T NOLOGGING AS SELECT * FROM SALES;

 

ALTER TABLE SALES_T ADD CONSTRAINT PK_SALES_T

PRIMARY KEY (PROD_ID, CUST_ID, CHANNEL_ID, TIME_ID) USING INDEX;

 

개발자가 작성한 문제의 SQL 1

 

SELECT *

  FROM ( SELECT /*+  INDEX(T PK_SALES_T) */ PROD_ID, CUST_ID, TIME_ID, CHANNEL_ID, AMOUNT_SOLD,

                MAX(TIME_ID) OVER( PARTITION BY CUST_ID ,CHANNEL_ID ) AS MAX_TIME

           FROM SALES_T T

          WHERE PROD_ID = 30 )

 WHERE TIME_ID = MAX_TIME;

 

업무설명 : 상품번호 30인 데이터에 대하여 고객별, 채널 별로 가장 최근의 판매량을 나타내시오.

             단 고객별, 채널별로 가장 최근의 데이터는 2건 이상일 수 있음.

-----------------------------------------------------------------------------------------------

| Id  | Operation                     | Name       | A-Rows |   A-Time   | Buffers | Used-Mem |

-----------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT              |            |  12649 |00:00:00.31 |   28337 |          |

|*  1 |  VIEW                         |            |  12649 |00:00:00.31 |   28337 |          |

|   2 |   WINDOW BUFFER               |            |  29282 |00:00:00.27 |   28337 | 1495K (0)|

|   3 |    TABLE ACCESS BY INDEX ROWID| SALES_T    |  29282 |00:00:00.17 |   28337 |          |

|*  4 |     INDEX RANGE SCAN          | PK_SALES_T |  29282 |00:00:00.03 |     118 |          |

-----------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   1 - filter("TIME_ID"="MAX_TIME")

   4 - access("PROD_ID"=30)

 

WHERE 절에 TIME_ID = MAX_TIME 이 존재하는 이유는 고객별, 채널별로 MAX(TIME_ID)에 해당하는 데이터가 두건 이상일 수 있기 때문이다. 이런 경우는 자주 발생한다. 이해를 돕기 위해 비슷한 예를 들어보자. 부서별로 최대급여를 받는 사람들을 출력하고자 할 때, 1번 부서의 최대급여는 1억 원이라고 하자. 그런데 그 부서에서 1억 원(최대급여)을 받는 사람은 강호동, 유재석 둘이라는 이야기 이다.

 

위의 SQL은 답이 정확하다. 하지만 성능은 비효율이 있어서 별로 신통치 못하다. 이제 문제를 보자. SQL의 비효율을 개선하는 것이 문제이다.

 

문제1) 위의 SQL 에서 성능상 문제점을 발견하고 개선하시오. 문제점은 두 가지(분석함수의 관점, 일반적인 SQL튜닝의 관점) 이다. 단 인덱스를 추가로 생성하거나 변경해서는 안 된다.

아래처럼 정답을 제시하면 된다.

 

문제1의 비효율 1 : 구체적인 설명

문제1의 비효율 1의 해결방법 : 구체적인 설명 

문제1의 비효율 1이 해결된 SQL과 실행계획 제시

 

문제1의 비효율 2 : 구체적인 설명

문제1의 비효율 2의 해결방법 : 구체적인 설명 

문제1의 비효율 2이 해결된 SQL과 실행계획 제시

 

이제 실제로 답안을 작성해보자.(답안 Sample)

 

문제1의 비효율 1 : SALES_T 테이블에 Random Access가 불필요하게 많이 발생한다. 12649 번만 테이블로 액세스 하면 되지만 실제로는 29282 번 액세스함으로써 비효율이 발생되었다.

 

문제1의 비효율 1의 해결방법 : PK인덱스에 AMOUNT_SOLD가 포함되어 있지 않으므로 Select 절에서 AMOUNT_SOLD를 빼면 인덱스만 액세스 하게 됨. 이때 Rowid를 추가로 Select 해야 한다. 이후에 이미 인덱스에서 Filter Rowid로 테이블을 액세스하면 Random Access정확히 12649번 만 시도한다. 아래에 SQL이 있으므로 참조하시오.

 

문제1의 비효율1이 해결된 SQL과 실행계획 제시:

 

SELECT /*+ LEADING(S) USE_NL(S1) */

        S.PROD_ID, S.CUST_ID, S.TIME_ID, S.CHANNEL_ID, S1.AMOUNT_SOLD

  FROM ( SELECT /*+  INDEX(T PK_SALES_T) */ PROD_ID, CUST_ID, TIME_ID, CHANNEL_ID,

                MAX(TIME_ID) OVER( PARTITION BY CUST_ID ,CHANNEL_ID ) AS MAX_TIME,

                ROWID AS RID

           FROM SALES_T T

          WHERE PROD_ID = 30 ) S,

       SALES_T S1

 WHERE S.TIME_ID = S.MAX_TIME

   AND S1.ROWID = S.RID;

  

------------------------------------------------------------------------------------------------------

| Id  | Operation                   | Name       | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |

------------------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT            |            |      1 |  12649 |00:00:02.72 |   11237 |          |

|   1 |  NESTED LOOPS               |            |      1 |  12649 |00:00:02.72 |   11237 |          |

|*  2 |   VIEW                      |            |      1 |  12649 |00:00:00.18 |     118 |          |

|   3 |    WINDOW BUFFER            |            |      1 |  29282 |00:00:00.13 |     118 | 1432K (0)|

|*  4 |     INDEX RANGE SCAN        | PK_SALES_T |      1 |  29282 |00:00:00.03 |     118 |          |

|   5 |   TABLE ACCESS BY USER ROWID| SALES_T    |  12649 |  12649 |00:00:02.48 |   11119 |          |

------------------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   2 - filter("S"."TIME_ID"="S"."MAX_TIME")

   4 - access("PROD_ID"=30)

 

이렇게 해서 Sample 답안을 작성해 보았다. 이 방법은 분석함수의 튜닝방법이 아니라 예외적으로 SQL 튜닝방법론에서 언급되었던 방법을 사용하였다. 물론 여기서 페이징처리를 한다면 추가적인 성능향상이 가능하지만 Sample이므로 여기서 멈추기로 한다. 위의 답안은 Sample 이므로 점수에서 빠진다. 또한 이 부분은 분석함수의 튜닝 방법이 아니다. 하지만 앞으로 풀게 될 문제들은 모두 분석함수와 관련된 문제들이다.

 

이제 시작해보자.

 

문제1에서 분석함수와 관련된 비효율이 하나 있다. 이와 관련된 비효율, 해결방법, 그리고 해결된 SQL과 실행계획을 제시해야 한다.

 

문제1의 비효율 2 : 구체적인 설명

문제1의 비효율 2 해결방법 : 구체적인 설명 

문제1의 비효율 2가 해결된 SQL과 실행계획 제시

 

이제 좀더 어려운 문제를 풀어보자. 아래의 문제를 100% 맞춘 사람은 아무도 없었다.

 

문제2) 아래의 SQL 에서 성능상 문제점을 발견하고 개선하시오. 성능상 문제점은 4가지 이다. 4가지의 비효율을 발견하고 이를 모두 개선해야 한다. 단 인덱스를 추가로 생성하거나 변경하면 안 된다.

SELECT *

  FROM

  ( SELECT /*+ INDEX(T PK_SALES_T) */ PROD_ID, CUST_ID, TIME_ID, CHANNEL_ID, AMOUNT_SOLD,

                ROW_NUMBER()     OVER ( PARTITION BY PROD_ID ORDER BY CUST_ID,CHANNEL_ID,TIME_ID NULLS FIRST) AS RN,

                SUM(AMOUNT_SOLD) OVER ( ORDER BY CUST_ID,CHANNEL_ID,TIME_ID,PROMO_ID   ) AS SUM_AMT

           FROM SALES_T T

          WHERE PROD_ID = 30 )

 WHERE RN <= 10 ;

 

---------------------------------------------------------------------------------------------------------

| Id  | Operation                      | Name       | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |

---------------------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT               |            |      1 |     10 |00:00:00.43 |   28337 |          |

|*  1 |  VIEW                          |            |      1 |     10 |00:00:00.43 |   28337 |          |

|*  2 |   WINDOW SORT PUSHED RANK      |            |      1 |  29282 |00:00:00.40 |   28337 | 1999K (0)|

|   3 |    WINDOW SORT                 |            |      1 |  29282 |00:00:00.30 |   28337 | 1684K (0)|

|   4 |     TABLE ACCESS BY INDEX ROWID| SALES_T    |      1 |  29282 |00:00:00.18 |   28337 |          |

|*  5 |      INDEX RANGE SCAN          | PK_SALES_T |      1 |  29282 |00:00:00.04 |     118 |          |

---------------------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   1 - filter("RN"<=10)

   2 - filter(ROW_NUMBER() OVER ( PARTITION BY "PROD_ID" ORDER BY "CUST_ID","CHANNEL_ID",INTERNAL_FUNCTION("TIME_ID")

              NULLS FIRST)<=10)

   5 - access("PROD_ID"=30)

 

아래처럼 정답을 제시하면 된다.

 

문제2의 비효율 1 : 구체적인 설명

문제2의 비효율 1의 해결방법 : 구체적인 설명 

문제2의 비효율 1이 해결된 SQL과 실행계획 제시

 

문제2의 비효율 2 : 구체적인 설명

문제2의 비효율 2의 해결방법 : 구체적인 설명 

문제2의 비효율 2가 해결된 SQL과 실행계획 제시

 

문제2의 비효율 3 : 구체적인 설명

문제2의 비효율 3의 해결방법 : 구체적인 설명 

문제2의 비효율 3이 해결된 SQL과 실행계획 제시

 

문제2의 비효율 4 : 구체적인 설명

문제2의 비효율 4의 해결방법 : 구체적인 설명 

문제2의 비효율 4가 해결된 SQL과 실행계획 제시

 

문제의 구성
이렇게 하여 총 다섯 문제가 출제되었다. 문제 1에는 비효율이 하나이고 문제 2에는 비효율이 넷이다. 다섯 가지 비효율 모두 분석함수와 관련된 것들이다. 실습환경은 Oracle 10g R2, Oracle 11g R1, Oracle 11g R2로 한정한다. 답안은 2011년 2 18일 공개될 예정이다. 답안을 바로 공개하지 않는 이유는 튜닝실습을 유도하기 위함이다. SQL 두개를 분석하는데 이틀이면 넉넉한 시간이므로 충분히 생각하고 답을 작성하기 바란다. 자신이 튜너가 되었다고 가정하고 비효율을 모두 찾아보길 바란다.


다른 사람들은 몇 점 정도 받았나?

몇몇 지인들에게 퀴즈를 내본 결과 100점은 아무도 없었다. 80점이 평균적인 튜너의 수준이라는 이야기 이다. 개발자의 경우는 60점을 받더라도 실망해서는 안 된다. 개발자가 60점이면 괜찮은 수준이다. 점수에 대해 오해는 하지 말기 바란다. 이 퀴즈로 받은 점수는 분석함수와 관련된 것일 뿐, 전반적인 SQL 튜닝능력에 대한 점수가 아니다. 또한 100 점 이상을 받을 수도 있다. 필자가 알고 있는 튜닝포인트는 5가지뿐이다. 여러분들이 6~7가지 이상의 비효율과 튜닝 포인트를 잡아내어 필자를 능가하는 점수를 받는 것도 가능하다.


행운을 빈다.

 

Posted by extremedb
,

빠른 이해를 위해서 이전 을 먼저 보고 이 글을 읽기 바란다. 이 글의 1부에서 WINDOW SORT, WINDOW SORT PUSHED RANK, WINDOW NOSORT, WINDOW NOSORT STOPKEY OPERATION들을 정리해 보았다. 이번에는 나머지 네 가지 Operation에 관해 알아볼 것이다. 이번 시간을 마치면 분석함수의 실행계획은 모두 정리된다.

 

WINDOW (SORT)  

WINDOW (SORT PUSHED RANK)

WINDOW (NOSORT)

WINDOW (NOSORT STOPKEY)   <-- 저번 시간에는 여기까지

WINDOW (BUFFER)

WINDOW (BUFFER PUSHED RANK)

WINDOW (CHILD PUSHED RANK)

WINDOW (IN SQL MODEL) SORT <-- 이번 시간에는 여기까지

 

5. WINDOW BUFFER

WINDOW BUFFER는 기본적으로 Ranking Family를 사용하지 않는 경우에 발생한다. 또한 인덱스가 적절하거나, 혹은 OVER절에 ORDER BY구문이 없는 경우에 발생한다. where절의 filter 조건이 있다면, 전체 건을 Sort 하지 않지만 최종 결과 건(Window의 범위)에 대해서는 sort가 필요하다.

 

SELECT PROD_ID,

       SUM(AMOUNT_SOLD) OVER() AS SUM_AVG

  FROM SALES_T

WHERE PROMO_ID = 33;

 

---------------------------------------------------------------------------------

| Id  | Operation          | Name    | A-Rows |   A-Time   | Buffers | Used-Mem |

---------------------------------------------------------------------------------

|   0 | SELECT STATEMENT   |         |   2074 |00:00:00.04 |    4440 |          |

|   1 |  WINDOW BUFFER     |         |   2074 |00:00:00.04 |    4440 |59392  (0)|

|*  2 |   TABLE ACCESS FULL| SALES_T |   2074 |00:00:00.03 |    4440 |          |

---------------------------------------------------------------------------------

 

위의 결과를 보면 2074건에 대하여 Sort가 발생하였다. 하지만 아래는 다르다.

 

SELECT PROD_ID, CUST_ID, TIME_ID, CHANNEL_ID, AMOUNT_SOLD,

       SUM(AMOUNT_SOLD) OVER(ORDER BY NULL) AS RN

  FROM SALES_T ;

 

---------------------------------------------------------------------------------

| Id  | Operation          | Name    | A-Rows |   A-Time   | Buffers | Used-Mem |

---------------------------------------------------------------------------------

|   0 | SELECT STATEMENT   |         |    918K|00:00:03.69 |    4440 |          |

|   1 |  WINDOW BUFFER     |         |    918K|00:00:03.69 |    4440 |   35M (0)|

|   2 |   TABLE ACCESS FULL| SALES_T |    918K|00:00:00.96 |    4440 |          |

---------------------------------------------------------------------------------

 

WHERE 절이 없으므로 WINDOW의 범위는 전체이다. 전체 건( 92만건) SORT해야 하므로 PGA 35M나 사용하였다. OVER절에 ORDER BY가 없지만, 전체 건을 SORT 했으므로 WINDOW BUFFER WINDOW SORT Operation이 발생한 것과 마찬가지 이다.

 

이제 인덱스를 사용하면서 OVER절에 ORDER BY가 있는 경우를 살펴보자.

 

SELECT /*+ INDEX(S PK_SALES_T) */

         SUM(AMOUNT_SOLD) OVER(ORDER BY S.CUST_ID, S.CHANNEL_ID, S.TIME_ID ) AS "누적금액",

        S.*

  FROM SALES_T S

 WHERE S.PROD_ID = 22

 ORDER BY S.CUST_ID, S.CHANNEL_ID, S.TIME_ID ;

 

----------------------------------------------------------------------------------------------

| Id  | Operation                    | Name       | A-Rows |   A-Time   | Buffers | Used-Mem |

----------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT             |            |   3441 |00:00:00.06 |    2911 |          |

|   1 |  WINDOW BUFFER               |            |   3441 |00:00:00.06 |    2911 |  206K (0)|

|   2 |   TABLE ACCESS BY INDEX ROWID| SALES_T    |   3441 |00:00:00.05 |    2911 |          |

|*  3 |    INDEX RANGE SCAN          | PK_SALES_T |   3441 |00:00:00.04 |      17 |          |

----------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   3 - access("S"."PROD_ID"=22)

 

인덱스 (PROD_ID + CUST_ID + CHANNEL_ID + TIME_ID)컬럼과 OVER절의 ORDER BY구문의 컬럼순서가 동일하므로 WINDOW SORT 가 발생하지 않고 WINDOW BUFFER 가 발생하였다. SQL의 마지막에 위치한 ORDER BY 구문은 인덱스 컬럼과 일치하므로 별도의 SORT가 필요하지 않다.

 

6. WINDOW BUFFER PUSHED RANK

OPERATION RANK가 있으므로 Ranking Family를 사용한 것이다. OPERATION을 볼 수 있으면 좋겠지만 10g에서 사라졌다. Oracle 9i 까지는 적절한 인덱스가 있는 경우에 Ranking Family를 사용하고, /인라인 뷰 외부에서 분석함수를 Filter로 사용한다면 WINDOW BUFFER PUSHED RANK가 발생하였다. 하지만 10g부터는 더 효율적인 WINDOW NOSORT(10g R1) 혹은 WINDOW NOSORT STOPKEY(10g R2)로 대체되었다. 참고로 야함나르 독자님에 의하면 버전 9.2.0.7에서도 WINDOW NOSORT가 발생한다고 한다. ()

 

9i에서 직접 테스트 해보면 좋겠지만 환경이 그렇지 못하므로 Julian Dyke의 테스트 내용을 인용한다.

 

This example was developed using Oracle 9.2.0.1 on Windows 2000

 

This example requires the following table definition

 

    CREATE TABLE t1 (c1 NUMBER NOT NULL,c2 NUMBER);

 

    CREATE INDEX i1 ON t1 (c1);

The table must be analysed

 

    ANALYZE TABLE t1 COMPUTE STATISTICS;

The statement

 

    SELECT c2,r1

FROM ( SELECT c2,RANK () OVER (ORDER BY c1) AS r1

               FROM t1 )

    WHERE r1 < 10;

 

9i의 실행계획

0     SELECT STATEMENT Optimizer=CHOOSE

1   0   VIEW

2   1     WINDOW (BUFFER PUSHED RANK)

3   2       TABLE ACCESS (BY INDEX ROWID) OF 'T1'

4   3         INDEX (FULL SCAN) OF 'I1' (NON-UNIQUE)

 

테스트의 출처 http://www.juliandyke.com/Optimisation/Operations/WindowBufferPushedRank.html

 

 

10g R2의 실행계획

---------------------------------------------------------------------------

| Id  | Operation                     | Name | Rows  | Bytes | Cost (%CPU)|

---------------------------------------------------------------------------

|   0 | SELECT STATEMENT              |      |     1 |    26 |     1   (0)|

|*  1 |  VIEW                         |      |     1 |    26 |     1   (0)|

|*  2 |   WINDOW NOSORT STOPKEY       |      |     1 |    26 |     1   (0)|

|   3 |    TABLE ACCESS BY INDEX ROWID| T1   |     1 |    26 |     1   (0)|

|   4 |     INDEX FULL SCAN           | I1   |     1 |       |     1   (0)|

---------------------------------------------------------------------------

 

10g R2에서는 WINDOW BUFFER PUSHED RANK 대신에 WINDOW NOSORT STOPKEY 가 발생하므로 SORT가 전혀 발생하지 않으며 STOPKEY가 발생하여 필요한 부분만 SCAN된다. 따라서 TOP SQL이나 페이징 처리시 상당한 성능개선 효과를 볼 수 있다.

 

7. WINDOW CHILD PUSHED RANK

Ranking Family를 사용하고 인라인 뷰 외부에서 분석함수를 Filter로 사용한다면 실행계획에 ~PUSHED RANK 혹은 ~STOPKEY가 발생한다고 하였다. WINDOW CHILD PUSHED RANK도 마찬가지이다. 단 하나의 차이점은 Parallel Query를 사용했다는 것이다.

 

SELECT *

  FROM (SELECT /*+ FULL(T) PARALLEL(T 4) */  PROMO_ID,

               RANK() OVER(ORDER BY PROD_ID) AS RN

          FROM SALES_T T)

WHERE RN = 1

 

--------------------------------------------------------------------------------------------

| Id  | Operation                    | Name     | A-Rows |   A-Time   | Buffers | Used-Mem |

--------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT             |          |   6002 |00:00:01.84 |       5 |          |

|*  1 |  VIEW                        |          |   6002 |00:00:01.84 |       5 |          |

|*  2 |   WINDOW SORT PUSHED RANK    |          |   6003 |00:00:01.83 |       5 |  142K (0)|

|   3 |    PX COORDINATOR            |          |   6006 |00:00:01.81 |       5 |          |

|   4 |     PX SEND QC (RANDOM)      | :TQ10000 |      0 |00:00:00.01 |       0 |          |

|*  5 |      WINDOW CHILD PUSHED RANK|          |      0 |00:00:00.01 |       0 |96256  (0)|

|   6 |       PX BLOCK ITERATOR      |          |      0 |00:00:00.01 |       0 |          |

|*  7 |        TABLE ACCESS FULL     | SALES_T  |      0 |00:00:00.01 |       0 |          |

--------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   1 - filter("RN"=1)

   2 - filter(RANK() OVER ( ORDER BY "PROD_ID")<=1)

   5 - filter(RANK() OVER ( ORDER BY "PROD_ID")<=1)

   7 - access(:Z>=:Z AND :Z<=:Z)

 

ID 5번에 WINDOW CHILD PUSHED RANK가 발생했음에도 ID 2번에 WINDOW SORT PUSHED RANK가 발생한 이유는 QUERY COORDINATOR가 개별 PARALLEL SLAVE들을 취합해야 하기 때문이다.

 

8. WINDOW IN SQL MODEL SORT

MODEL절에서 분석함수를 사용하는 경우에 발생한다.

 

SELECT PROD_ID,a1,a2

  FROM (SELECT PROD_ID,SUM(AMOUNT_SOLD) a1

          FROM SALES_T

         GROUP BY PROD_ID )

       MODEL DIMENSION BY (PROD_ID)

       MEASURES (a1,0 a2)

       RULES ( a2[any] = SUM (a1) OVER () ) ;  --> 분석함수 사용

 

------------------------------------------------------------------------------------------

| Id  | Operation                   | Name    | A-Rows |   A-Time   | Buffers | Used-Mem |

------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT            |         |     72 |00:00:01.91 |    4440 |          |

|   1 |  SQL MODEL ORDERED          |         |     72 |00:00:01.91 |    4440 |  405K (0)|

|   2 |   HASH GROUP BY             |         |     72 |00:00:01.91 |    4440 | 2516K (0)|

|   3 |    TABLE ACCESS FULL        | SALES_T |    918K|00:00:00.93 |    4440 |          |

|   4 |   WINDOW (IN SQL MODEL) SORT|         |     72 |00:00:00.01 |       0 | 2048  (0)|

------------------------------------------------------------------------------------------

 

위와 아래의 SQL은 같은 결과를 출력한다. 그리고 PGA 사용량도 비슷하다.

 

SELECT PROD_ID,SUM(AMOUNT_SOLD) a1, sum(SUM(AMOUNT_SOLD)) OVER() as a2

  FROM SALES_T

 GROUP BY PROD_ID ;

 

----------------------------------------------------------------------------------

| Id  | Operation           | Name    | A-Rows |   A-Time   | Buffers | Used-Mem |

----------------------------------------------------------------------------------

|   0 | SELECT STATEMENT    |         |     72 |00:00:01.91 |    4440 |          |

|   1 |  WINDOW BUFFER      |         |     72 |00:00:01.91 |    4440 | 2048  (0)|

|   2 |   HASH GROUP BY     |         |     72 |00:00:01.91 |    4440 | 2515K (0)|

|   3 |    TABLE ACCESS FULL| SALES_T |    918K|00:00:00.93 |    4440 |          |

----------------------------------------------------------------------------------

 

단순히 전체 합계를 출력하는 것이라면 MODEL절 보다는 분석함수를 사용하는 것이 조금 유리하다. SQL MODEL ORDERED OPERATION이 없기 때문이다. 참고로 MODEL절이 무엇인지 궁금한 사람은 SQL 포기하지마라 , Model 절에 대하여 라는 두 가지 글을 보기 바란다.

 

결론

 

WINDOW (SORT) :

-FTS를 사용하거나 인덱스가 적절하지 않은 경우 발생 

WINDOW (SORT PUSHED RANK) :

-위와 같으나 Ranking Family를 사용해야 하며, /인라인뷰 외부에서 분석함수를 filter로 사용하는 경우 발생

WINDOW (NOSORT) :

-인덱스가 적절한 경우나, OVER절에 ORDER BY가 없는 Ranking Family를 사용한 경우 발생

WINDOW (NOSORT STOPKEY) :

-위와 같으나 Ranking Family를 사용해야 하며, /인라인뷰 외부에서 분석함수를 filter로 사용하는 경우 발생

-10g R2 신기능

WINDOW (BUFFER) :

    - Ranking Family를 사용하지 않고 ORDER BY가 없는 경우이거나 적절한 인덱스를 사용하는 경우 발생

WINDOW (BUFFER PUSHED RANK) :

- Ranking Family를 사용하고, /인라인뷰 외부에서 분석함수를 filter로 사용해야 하며, 8i~9i 에서만 발생됨.

- 비효율을 개선하여 10g R1에서는 WINDOW (NOSORT)가 발생하며, 10g R2에서는 WINDOW (NOSORT STOPKEY)가  발생됨

WINDOW (CHILD PUSHED RANK) :

   - Ranking Family를 사용하고, /인라인뷰 외부에서 분석함수를 filter로 사용해야 하며, Parallel Query를 사용하는 경우 발생

WINDOW (IN SQL MODEL) SORT

   - Model 절에서 분석함수로 Rule을 정하는 경우 발생

 

심화학습이 필요한 이유

저번 글과 이번 글에서 분석함수를 사용할 때 나올 수 있는 실행계획을 정리해 보았다. 이제 분석함수의 실행계획을 보면 어떤 환경에서 실행되었는지, 어떤 비효율이 있는지 감을 잡을 수 있을 것이다. 다음 글에서는 위의 결론을 이용하여 좀더 고차원적인 심화과정에 도전할 것이다. 다음 글의 목적은 실전에서 분석함수를 사용할 때, 비효율을 잡아내는 능력을 향상시키고, 실제로 성능개선을 해보는 것이다.

Posted by extremedb
,

Oracle Data Access Pattern

Partition Access Pattern

Oracle 조인 방법 25가지

 

필자는 위의 글들을 이미 게시하였다. 그래서 Access Path Join Method는 정리가 되었고, 블로그로 배포되었으므로 그것으로 끝이라고 생각했다. 하지만 어느 개발자와 대화를 해보니 그런 생각이 짧았음을 알게 되었다.

 

 

개발자 : 위의 3가지 문서는 잘 보고 있습니다. 하지만 Access Path에 관해서는 큰 구멍이 있습니다.


필자 : ? 무슨 말인가요?


개발자 : 분석함수는 Access Path를 변경시킵니다. 즉 분석함수를 어떻게 사용하느냐에 따라 실행계획의 Operation이 변경되므로 여타의 함수와는 엄연히 다릅니다. 그러므로 수석님께서 분석함수와 관련된 Access Path를 정리해 주셨으면 합니다.


필자 : …. 프로젝트도 있고, 블로그도 관리해야 하고, 책도 써야 하고, 개인적인 공부도 해야 하는데요. 시간이 나질 않습니다. (! 말을 하자마자 실수였다는 것을 알아차렸다. 블로그 관리라니……)


개발자 : 지금 하시고 계신 네 가지 이외의 일을 해달라는 게 아닙니다. 분석함수의 실행계획을 정리하셔서 블로그에 올리시면, 그게 블로그 관리 아닌가요?


필자 : …..

 

 

이번에는 공짜가 아니다

이렇게 해서 이 글을 쓰게 되었다. 해당개발자는 언제 나에게 맥주 한잔 사기 바란다. 이런 식으로 말려들어서 글을 작성한 적이 한두번이 아니다. ^^

 

분석함수의 내부

분석함수를 이용하여 답을 잘 내었으므로 그것으로 만족하는 사람들이 있다. 답을 내는 것은 표면적인 것(문법)을 이해한 것이다. 하지만 그것만 알아서는 내부적인 매커니즘을 이해할 수 없다. 즉 분석함수를 사용함에 따라 Access Path(Operation)가 어떻게 변경되는지도 알아야 모두를 아는 것이다. 분석함수의 내부적인 Operation을 모른다면 SQL 작성은 할지 몰라도, 성능을 향상시킬 수는 없다. 오늘의 목표는 분석함수의 내부를 정복함으로써 튜닝을 가능하게 하는 것이다.

 

분석함수의 실행계획은 아래와 같이 총 8가지로 예상된다. 만약, 아래의 List 이외의 것을 발견하면 이 글을 보는 독자들을 위해 알려주기 바란다. 글이 너무 길어지므로, 이번 시간에는 8개중에 위의 4개를 알아보고자 한다. 어떤 경우에 아래의 4가지 실행계획이 발생하는지 알아보자.

 

WINDOW (SORT)  

WINDOW (SORT PUSHED RANK)

WINDOW (NOSORT)

WINDOW (NOSORT STOPKEY)   <-- 이번 시간에는 여기까지

WINDOW (BUFFER)

WINDOW (BUFFER PUSHED RANK)

WINDOW (CHILD PUSHED RANK)

WINDOW (IN SQL MODEL) SORT

 

환경: Oracle 11.2.0.1

테스트를 위해 테이블과 인덱스를 만든다.

 

CREATE TABLE SALES_T NOLOGGING AS SELECT * FROM SALES;

 

CREATE INDEX IX_PROD ON SALES_T( PROD_ID );

 

1. WINDOW SORT

분석함수를 사용하면 기본적으로 SORT가 발생한다. 이때 SORT를 대체할 수 있는 적절한 인덱스가 없다면 WINDOW SORT가 발생한다. 아래의 경우가 대표적이다.

 

-인덱스와 분석함수 OVER절의 컬럼이 일치하지 않을 때 발생한다.

-분석함수를 사용하고 Full Table Scan을 사용할 때 발생한다.

 

먼저 인덱스와 OVER 절의 컬럼이 일치하지 않는 경우를 살펴보자.

 

SELECT PROD_ID, CUST_ID, TIME_ID, CHANNEL_ID, AMOUNT_SOLD,

       ROW_NUMBER() OVER(ORDER BY CUST_ID) AS RN

  FROM SALES_T

 WHERE PROD_ID = 30;

 

-------------------------------------------------------------------------------------------

| Id  | Operation                    | Name    | A-Rows |   A-Time   | Buffers | Used-Mem |

-------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT             |         |  29282 |00:00:00.18 |     424 |          |

|   1 |  WINDOW SORT                 |         |  29282 |00:00:00.18 |     424 | 1558K (0)|

|   2 |   TABLE ACCESS BY INDEX ROWID| SALES_T |  29282 |00:00:00.09 |     424 |          |

|*  3 |    INDEX RANGE SCAN          | IX_PROD |  29282 |00:00:00.03 |      60 |          |

-------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

 

   3 - access("PROD_ID"=30)

 

 인덱스는 PROD_ID 로 구성되었지만, OVER절은 CUST_ID로 되어 서로 다르므로 실행계획의 Id 2번에 WINDOW SORT가 발생하였다.

 

이제 분석함수를 사용하면서 FTS(Full Table Scan)이 발생하는 경우를 보자.

 

SELECT PROD_ID,

       SUM(AMOUNT_SOLD) OVER() AS SUM_AVG

  FROM SALES_T

WHERE PROMO_ID = 33

ORDER BY CUST_ID;

 

---------------------------------------------------------------------------------

| Id  | Operation          | Name    | A-Rows |   A-Time   | Buffers | Used-Mem |

---------------------------------------------------------------------------------

|   0 | SELECT STATEMENT   |         |   2074 |00:00:00.04 |    4440 |          |

|   1 |  WINDOW SORT       |         |   2074 |00:00:00.04 |    4440 |  102K (0)|

|*  2 |   TABLE ACCESS FULL| SALES_T |   2074 |00:00:00.04 |    4440 |          |

---------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

 

   2 - filter("PROMO_ID"=33)

 

WHERE 절이 PROD_ID가 아니라 PROMO_ID이므로 FTS가 발생하였다. 이때 분석함수를 사용하면 WINDOW SORT가 발생된다.

이때 ORDER BY절이 있더라도 별도의 SORT ORDER BY operation이 발생하지 않는다. 분석함수가 SORT를 대신하기 때문이다.  이때 SORT의 일량은 동일하다. 즉 분석함수와 ORDER BY절이 동시에 존재한다면, SORT의 일량은 분석함수가 없고 ORDER BY만 존재하는 SQL과 동일 하다. 아래의 SQL이 이 사실을 증명한다.

 

SELECT PROD_ID, AMOUNT_SOLD

  FROM SALES_T

WHERE PROMO_ID = 33

ORDER BY CUST_ID;

 

---------------------------------------------------------------------------------

| Id  | Operation          | Name    | A-Rows |   A-Time   | Buffers | Used-Mem |

---------------------------------------------------------------------------------

|   0 | SELECT STATEMENT   |         |   2074 |00:00:00.04 |    4440 |          |

|   1 |  SORT ORDER BY     |         |   2074 |00:00:00.04 |    4440 |  102K (0)|

|*  2 |   TABLE ACCESS FULL| SALES_T |   2074 |00:00:00.04 |    4440 |          |

---------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

 

   2 - filter("PROMO_ID"=33)

 

위에서 설명된 SQL의 분석함수 + ORDER BY SORT 일량과 ORDER BY만 사용한 SORT의 일량은 102K로 동일함을 알 수 있다. 즉 분석함수와 ORDER BY를 같이 사용하던지, 아니면 ORDER BY만 사용하던지 간에 SORT의 부하는 동일하다는 이야기 이다.

 

2.WINDOW SORT PUSHED RANK

위에서 적당한 인덱스가 없을 때, 분석함수를 사용하면 WINDOW SORT가 발생한다고 하였다. 이번에는 WINDOW SORT에 대해 좀더 깊이 들어가 보자.

 

SELECT *

  FROM ( SELECT PROD_ID, CUST_ID, TIME_ID, CHANNEL_ID, AMOUNT_SOLD,

                ROW_NUMBER() OVER(ORDER BY CUST_ID) AS RN

           FROM SALES_T

          WHERE PROD_ID = 30 )

 WHERE RN <= 10;

 

--------------------------------------------------------------------------------------------

| Id  | Operation                     | Name    | A-Rows |   A-Time   | Buffers | Used-Mem |

--------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT              |         |     10 |00:00:00.13 |     424 |          |

|*  1 |  VIEW                         |         |     10 |00:00:00.13 |     424 |          |

|*  2 |   WINDOW SORT PUSHED RANK     |         |     11 |00:00:00.13 |     424 | 2048  (0)|

|   3 |    TABLE ACCESS BY INDEX ROWID| SALES_T |  29282 |00:00:00.10 |     424 |          |

|*  4 |     INDEX RANGE SCAN          | IX_PROD |  29282 |00:00:00.03 |      60 |          |

--------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   1 - filter("RN"<=10)

   2 - filter(ROW_NUMBER() OVER ( ORDER BY "CUST_ID")<=10)

   4 - access("PROD_ID"=30)

 

이 글에서 처음 소개된 SQL과 같지만, 인라인뷰 외부에서 분석함수 컬럼을 filter 조건으로 사용하였다. 즉 상위 10건만 보자고 한 것이다. 이런 경우 WINDOW SORT PUSHED RANK operation이 발생하며 SORT의 부하는 제한된다. 다시 말해 RN <= 10 조건에 의해 전체를 SORT할 필요가 없이 10건의 배열을 만들고, 그 배열만 SORT 하면 된다. 이 글에서 최초로 설명된 SQL SORT 일량과 WINDOW SORT PUSHED RANK의 일량을 비교해보면 1558K 2K 로 천지차이이다. 분석함수 중에 순위를 구하는 것(RANK, DENSE_RANK, ROW_NUMBER)을 인라인뷰 외수에서 filter로 사용하면 적절한 인덱스가 없더라도 Sort의 부하는 최소화 된다는 것을 기억하자.

 

이런 사실은 분석함수를 사용하지 않더라도 동일하게 발생한다. 즉 위에서 실행된 SQL SORT량과 아래의 SORT이 동일하다. 아래는 분석함수를 사용하지 않고 ROWNUM을 사용하였다.

 

SELECT *

  FROM ( SELECT /*+ INDEX(T IX_PROD) */ PROD_ID, CUST_ID, TIME_ID, CHANNEL_ID, AMOUNT_SOLD

           FROM SALES_T T

          WHERE PROD_ID = 30

          ORDER BY CUST_ID)

 WHERE ROWNUM <= 10;

 

---------------------------------------------------------------------------------------------

| Id  | Operation                      | Name    | A-Rows |   A-Time   | Buffers | Used-Mem |

---------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT               |         |     10 |00:00:00.13 |     424 |          |

|*  1 |  COUNT STOPKEY                 |         |     10 |00:00:00.13 |     424 |          |

|   2 |   VIEW                         |         |     10 |00:00:00.13 |     424 |          |

|*  3 |    SORT ORDER BY STOPKEY       |         |     10 |00:00:00.13 |     424 | 2048  (0)|

|   4 |     TABLE ACCESS BY INDEX ROWID| SALES_T |  29282 |00:00:00.09 |     424 |          |

|*  5 |      INDEX RANGE SCAN          | IX_PROD |  29282 |00:00:00.03 |      60 |          |

---------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   1 - filter(ROWNUM<=10)

   3 - filter(ROWNUM<=10)

   5 - access("PROD_ID"=30)

 

WINDOW SORT PUSHED RANK 대신에 SORT ORDER BY STOPKEY operation이 사용되었지만 하는 일과 원리는 동일하므로 SORT량도 동일하다.

 

3.WINDOW NOSORT

적절한 인덱스가 없을 때 분석함수를 사용하면 WINDOW SORT가 발생한다고 하였다. 이번에는 동일한 SQL을 실행하되 적절한 인덱스를 만들고 실행해보자.

 

CREATE INDEX PK_SALES_T ON SALES_T(PROD_ID, CUST_ID, CHANNEL_ID, TIME_ID);

 

SELECT /*+ INDEX(T PK_SALES_T) */ PROD_ID, CUST_ID, TIME_ID, CHANNEL_ID, AMOUNT_SOLD,

       ROW_NUMBER() OVER(ORDER BY CUST_ID) AS RN

  FROM SALES_T T

 WHERE PROD_ID = 30;

 

----------------------------------------------------------------------------------------------

| Id  | Operation                    | Name       | A-Rows |   A-Time   | Buffers | Used-Mem |

----------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT             |            |  29282 |00:00:00.34 |   28352 |          |

|   1 |  WINDOW NOSORT             |            |  29282 |00:00:00.34 |   28352 |          |

|   2 |   TABLE ACCESS BY INDEX ROWID| SALES_T    |  29282 |00:00:00.24 |   28352 |          |

|*  3 |    INDEX RANGE SCAN          | PK_SALES_T |  29282 |00:00:00.07 |     133 |          |

----------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

 

   3 - access("PROD_ID"=30)

 

WHERE절과 OVER절의 ORDER BY에 만족하는 인덱스를 생성하였더니 SORT가 전혀 발생하지 않는다. 또한 OPERATIONWINDOW SORT에서 WINDOW NOSORT로 바뀌었다. 적절한 인덱스가 왜 필요한지 알 수 있는 대목이다. 참고로 OVER 절의 ORDER BY 구문은 PROD_ID , CUST_ID 로 바꾸어도 동일한 결과를 얻을 수 있다.

 

또 다른 경우를 보자. 분석함수를 사용하고, Full Table Scan을 사용하더라도 WINDOW NOSORT가 발생할 수 있다.

 

SELECT PROD_ID, CUST_ID, TIME_ID, CHANNEL_ID, AMOUNT_SOLD,

       ROW_NUMBER() OVER(ORDER BY NULL) AS RN

  FROM SALES_T ;

 

---------------------------------------------------------------------------------

| Id  | Operation          | Name    | A-Rows |   A-Time   | Buffers | Used-Mem |

---------------------------------------------------------------------------------

|   0 | SELECT STATEMENT   |         |    918K|00:00:04.81 |    4898 |          |

|   1 |  WINDOW NOSORT   |         |    918K|00:00:04.81 |    4898 |          |

|   2 |   TABLE ACCESS FULL| SALES_T |    918K|00:00:01.99 |    4898 |          |

---------------------------------------------------------------------------------

 

FTS를 사용했지만 분석함수에 ORDER BY절이 없으므로 SORT는 발생하지 않는다. 무작위로 10건을 추출하는 경우에 사용할 수 있다. 물론 ROWNUM을 사용해도 동일한 효과를 낼 것이다.

 

4.WINDOW NOSORT STOPKEY

인덱스의 컬럼순서와 분석함수 OVER절에 존재하는 ORDER BY 컬럼순서가 동일하며, Ranking Family 함수를 인라인뷰 외부에서 filter 조건으로 사용할 때 발생한다.

 

SELECT *

  FROM ( SELECT PROD_ID, CUST_ID, TIME_ID, CHANNEL_ID, AMOUNT_SOLD,

                ROW_NUMBER() OVER(ORDER BY PROD_ID) AS RN

           FROM SALES_T

          WHERE PROD_ID = 30 )

 WHERE RN <= 10;

 

--------------------------------------------------------------------------------------------

| Id  | Operation                     | Name    | A-Rows |   A-Time   | Buffers | Used-Mem |

--------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT              |         |     10 |00:00:00.01 |       6 |          |

|*  1 |  VIEW                         |         |     10 |00:00:00.01 |       6 |          |

|*  2 |   WINDOW NOSORT STOPKEY    |         |     10 |00:00:00.01 |       6 |          |

|   3 |    TABLE ACCESS BY INDEX ROWID| SALES_T |     11 |00:00:00.01 |       6 |          |

|*  4 |     INDEX RANGE SCAN          | IX_PROD |     11 |00:00:00.01 |       4 |          |

--------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

 

   1 - filter("RN"<=10)

   2 - filter(ROW_NUMBER() OVER ( ORDER BY "PROD_ID")<=10)

   4 - access("PROD_ID"=30)

 

WINDOW NOSORT STOPKEY는 부분범위처리에 최적화되어 있다

분석함수를 Rownum처럼 사용하였기 때문에 WINDOW NOSORT STOPKEY가 발생하였다. 1건을 더 읽었지만 비효율은 느낄 수 없는 정도이다. WINDOW NOSORT STOPKEY와 이미 위에서 설명된 WINDOW SORT PUSHED RANK와의 차이는 단순히 SORT의 유무뿐만이 아니다. Block I/O량도 차이가 난다. WINDOW SORT PUSHED RANK는 전체 건을 읽은 후에 다 버리고 10건만 출력한다. 이에 반해 WINDOW NOSORT STOPKEY 11건을 읽고 한 건만 버리므로 부분범위처리의 효율이 좋다. 2번과 4번의 Block I/O량을 비교해보기 바란다. 몇 십 배나 차이가 난다..    

 

참고:  Ranking Family 분석함수란 RANK, DENSE_RANK, ROW_NUMBER를 말한다.

 

결론


WINDOW (SORT) :

-FTS를 사용하거나 인덱스가 적절하지 않은 경우 발생 

WINDOW (SORT PUSHED RANK) :

-위와 같으나 Ranking Family를 사용해야 하며, 인라인뷰 외부에서 분석함수를 filter로 사용하는 경우 발생

WINDOW (NOSORT) :

-인덱스가 적절한 경우나, OVER절에 ORDER BY가 없는 Ranking Family를 사용한 경우 발생

WINDOW (NOSORT STOPKEY) :

-위와 같으나 Ranking Family를 사용해야 하며, 인라인뷰 외부에서 분석함수를 filter로 사용하는 경우 발생

 

실행계획만 보고도 SQL을 어느 정도 짐작 할 수 있다라고 주장하는 튜닝의 고수가 있다. 필자는 때에 따라 그럴 수도, 아닐 수도 있다고 생각한다. 하지만 최소한 분석함수에 관해서는 어느 정도 가능하다고 생각한다. 분석함수의 내부를 이해했다면 충분히 그럴 수 있다. 이제 위의 결론을 이용하여, 실행계획만 보고도 어떤 분석함수를 어떤 상황에서 사용한 것인지 짐작 할 수 있겠는가? 대충 짐작 할 수 있다면 성공한 것이다.

 

다음 글(2 )에서 소개될 내용

WINDOW (BUFFER)

WINDOW (BUFFER PUSHED RANK)

WINDOW (CHILD PUSHED RANK)

WINDOW (IN SQL MODEL) SORT

Posted by extremedb
,

SQL 튜닝책을 세 권정도 읽은 신입사원이 SQL 튜닝방법론을 요청하였다. 이유는 튜닝책에 방법론이 없다는 것이다. 튜닝 방법론이란 “SQL을 튜닝 해달라고 요청 받았을 때 내가 무엇 무엇을 해야 하나?” 이다. SQL 튜닝시의 To-Do 리스트(체크리스트)를 요구한 것이다.

 

SQL 튜닝을 자주 하면서도, 그 안에 몇 가지 작업이 있는지 생각하지 못했다. 누가 그랬던가? 일상을 낯설게 느껴보라고… SQL 튜닝요청을 받았을 때 내가 어떤 일을 하는지 가르쳐 주면 되겠구나 하는 생각이 들었다. 그 결과 7가지 방법이 결론으로 도출되었다. 만약 7가지 방법을 모두 적용할 수 있는 경우임에도 불구하고 하나라도 빠진다면 최적화된 SQL을 만들 수 없다.

 

아래는 필자와 신입사원의 대화이다.

 

신입사원 : SQL 튜닝의 원칙 몇 가지를 저에게 일러 주실 수 있나요? 튜닝책도 몇 가지 보았고, 강의도 많이 들었지만 이 원칙만 지키면 100점 만점에 90점은 맞는다.” 는 원칙 같은 것은 없더군요. 저는 이제 입문하는 단계이므로 모든 경우에 100점을 맞을 필요는 없습니다.

 

필자 : 온라인 SQL이냐 대용량 배치 SQL이냐에 따라 튜닝방법이 달라지므로 설명하기가 힘들군요.

 

신입사원 : 걱정 하실 것 없습니다. 대용량 배치는 프로그램이 많지 않으므로 제외하고, 온라인 SQL 튜닝 원칙을 몇 가지 일러주세요.

 

필자 : 온라인 SQL이라 하더라도 관점에 따라 튜닝방법이 다릅니다. 예를 들어 Peak Time Insert 문이나 Update , Select문이 집중적으로 몰릴 때의 튜닝방법이 있고, 단순히 SQL 하나에 에 집중해서 응답시간을 최소화 하는 튜닝방법이 있습니다.

 

신입사원 : 그런 것을 지금 모두 알아야 할 필요는 없습니다. 제가 튜닝 프로젝트에 투입되었다고 가정하고, 성능이 느린 Select문 하나를 받았을 때 튜닝을 어떻게 해야 하는지에 대해서만 설명해주시면 됩니다.

 

고단수 신입사원

이렇게 해서 신입사원에게 말려들게 되었다. , 초보라도 몇 가지 원칙만 지키면 온라인 Select문에 대한 튜닝을 100점 만점에 90점을 맞을 수 있는 방법을 요구하는 것이다. 사실 이런 질문에 가장 적합한 답변은 “SQL 튜닝책을 읽어보라는 것이다. 그런데 신입사원이 필자와 대화과정(튜닝책도 몇 가지 보았고 ~)에서 이런 답변을 못하도록 교묘히 막고 있다. 고단수이다. 몇 가지 방법만 알게 된다면 90점을 받는다고? 처음부터 그런 방법은 없다고 할 걸 그랬나? 후회가 된다. 어찌되었든 약속처럼 되어버렸으므로 이 글을 쓰게 되었다. …..머리가 아파온다.

 

온라인 Select문 튜닝 방법론

온라인 SQL의 튜닝방법은 여러 가지가 있을 수 있다. 하지만 그 중에서 가장 기초적이고, 기본적인 방법을 공개한다. 아래의 7가지 항목을 점검하고 약한 곳을 보강하면 된다. 이 글은 SQL 튜닝책을 두 권 정도 본 사람들을 위한 것이다. 튜닝에 자신있는 사람들은 볼 필요가 없다.

 

1. 적절한 인덱스를 사용하여 Block I/O를 최소화 하라

조인이 없는 경우는 적절한 인덱스를 사용하는 것 만으로도 상당한 효과를 볼 수 있다. 조인이 있는 경우는 특히 Driving(선행) 집합에 신경을 써야 한다. 왜냐하면 Nested Loop 조인을 사용했고, 선행집합의 건수가 많다면, 후행집합의 조인의 시도횟수가 증가하므로 성능이 느려진다. 따라서 적절한 인덱스를 이용하여 선행집합의 건수를 줄인다면, 혹은 가장 적은 집합을 선행으로 놓는다면, 후행집합으로의 조인건수는 줄어든다. 물론 이때에도 후행집합의 적절한 인덱스는 필수 조건이다. Driving 집합의 Block I/O를 줄이기 위하여 최적화된 인덱스가 없다면 생성하고, 있다면 그것을 사용하라. 다시 말해 최적의 Access Path를 만들어라.

 

운영중인 시스템이라면 최적의 Access Path를 위해 인덱스를 변경하거나 생성할 때는 주의해야 한다. 현재 튜닝하고 있는 SQL에 최적화된 인덱스를 생성하더라도 다른 SQL에 악영향을 줄 수 있기 때문이다. 인덱스를 생성하거나 변경할 때는 그 테이블을 사용하는 다른 SQL의 실행계획이 변경되지 않는지 각별히 신경을 써야 한다. 이런 이유 때문에 개발과정에서 효율적인 인덱스 설계가 중요시 된다.

 

2. 조인방법과 조인순서를 최적화 하라

온라인에서 사용하는 Select문은 좁은 범위를 검색하는 경우가 많다. 이럴 때는 대부분 Nested Loop Join이 유리하다. 그러므로 조인건수가 소량인 SQL Hash Join이나 Sort Merge Join이 발견되면 Nested Loop Join으로 변경하는 것이 더 유리한지 검토해야 한다. 물론 여기서도 Nested Loop 조인에 관해서만 다룬다.

 

Nested Loop 조인에서 가장 중요한 것은 조인순서이다. From절에 테이블(집합)이 두 개라면 후행집합의 관점에서는 적절한 인덱스만 존재한다면 그것으로 족하다. 만약 From절에 테이블(집합)이 세 개 이상이라면 조인순서를 변경할 수 있는지에 대한 두 가지 원리를 사용하라. 두 가지 원리는 아래의 단락에서 소개된다. 아무리 조인할 집합이 많다고 하더라도 이 두 가지의 원리는 동일하게 적용될 수 있다. 두 가지 원리를 이용할 때 필요하다면 Leading 힌트를 사용해야 한다.

 

첫 번째, 후행집합에 적절한 인덱스가 없는 경우에 조인순서를 바꾸면, 최적의 인덱스를 사용할 수 있는 경우가 많다. 예컨대, 튜닝전의 조인순서가 Aà B à C 라고 하면, 중간집합인 B에 적절한 인덱스가 없고 오히려 C에 적절한 인덱스가 존재하는 경우가 있다. 이럴 때는 B에 인덱스를 무작정 생성하지 말고, 조인순서를 A à C à B로 바꿀 수 있는지, 바꾸는 것이 더 효율적인지 검증하라. 조인순서만 바꾸어 주어도 일량이 획기적으로 줄어드는 경우가 많다. 만약 조인순서를 바꿀 수 없거나, C를 중간집합으로 하는 것이 비효율적이라면, B를 중간집합으로 유지하고 적절한 인덱스를 사용해야 한다.

 

두 번째, 조인되는 집합 중 특정 인덱스에서 Block I/O가 증가하는 경우에 조인순서의 변경을 검토하라. 이때 10046 Trace DBMS_XPLAN.Display_Corsor를 이용하면 조인집합들의 Block I/O량을 관찰할 수 있다. 예를 들어, 튜닝전의 조인순서가 Aà B à C 라고 하고, 집합 B에서 Block I/O량이 증가하면 A à C à B로 바꾸면 일량이 줄어드는 경우가 많다. C를 먼저 조인(Filter)하여 선행집합(B의 입장에서는 C가 선행이다)의 건수를 줄이고 B에 조인하면 성능이 향상된다.

 

3. Table Access(Random Access)를 최소화 하라

Random Access rowid로 테이블을 엑세스하는 것을 말한다. 1번과 2번을 최적화 했다면 Random Access도 자동으로 많이 줄어들었을 것이다. 하지만 그것이 끝은 아니다. 여전히 성능이 만족스럽지 못하다면 Random Access 횟수를 줄이는 것을 간과해서는 안 된다.

 

인덱스를 사용하면 rowid가 자동으로 획득된다. 만약 인덱스에 없는 컬럼을 Select 해야 한다면 rowid로 테이블을 엑세스 해야 한다. 이때 테이블로 엑세스 해야 할 건수가 많고, 인덱스의 컬럼순으로 테이블이 sort되어있지 않다면 성능이 매우 저하된다. 왜냐하면 테이블이 인덱스 기준으로 sort되어 있지 않기 때문에 테이블을 방문할 때마다 서로 다른 블럭을 읽어야 하기 때문이다.

 

비유적으로 설명해보자. 우리가 심부름을 할 때 세 군대의 상점(A,B,C)을 들러야 한다고 치자. 그 상점들이 모두 한 건물 내부에 존재한다면 얼마나 좋겠는가? 그 심부름은 매우 빠른 시간에 끝날 것이다. 하지만 반대로 상점 A는 부산에 있고 상점 B는 대구에 있고, 상점 C는 서울에 있다면? 만약 당신의 성격이 매우 좋아서 그 심부름을 한다고 해도 시간이 많이 걸릴 것이다. Random Access도 마찬가지이다. 인덱스의 rowid로 테이블을 방문할 때, 테이블이 인덱스기준으로 sort되어 상점처럼 다닥다닥 붙어있다면 성능은 매우 빠르고, 흩어져 있을수록 성능이 느려진다. (오라클에서는 테이블이 인덱스 기준으로 sort 되어 있는 정도를 Clustering Factor라고 한다.) 바로 이런 이유 때문에 index scan보다는 Table Scan이 느린 것이다. 따라서 우리는 Random Access의 부하를 최소화 해야 한다.

 

Random Access의 부하를 줄이는 방법은 네 가지이다. 첫 번째, 테이블의 종류를 변경하는 방법이다. IOT나 클러스터를 이용하면 Clustering Factor가 극단적으로 좋아진다. 또한 파티션을 이용하면 같은 범위의 데이터를 밀집시킬 수 있다. 두 번째, 효율적인 인덱스를 사용하거나 조인방법과 순서를 조정하여 Table Access를 최소화 하는 방법이다. 이 방법은 1번과 2번에서 이미 설명 되었다. 세 번째, 인덱스에 컬럼을 추가하여 Table Access를 방지하는 방법이다. 예를 들어 Select절의 특정 컬럼 때문에 테이블이 엑세스 된다면, 인덱스의 마지막에 그 컬럼을 추가하면 된다. 네 번째, 인덱스만 엑세스 하고 테이블로의 엑세스는 모든 조인을 끝내고 마지막에 시도하여 Random Access의 횟수를 줄이는 방법이다. 해당 을 참조하라. 

 

4. Sort Hash 작업을 최소화 하라

1,2,3번을 통하여 최적의 Access Path Join을 사용했다면, Block I/O의 관점에서는 튜닝이 끝난 것이다. 하지만 1,2,3번이 모두 해결되었다 해도 Order by Group By 때문에 성능이 저하 될 수 있다. 특히 결과가 많은 경우, sort는 치명적이다.

 

인덱스가 sort 되어있다는 특성을 이용하면 order by 작업을 대신할 수 있다.  Group By sort 가 발생하는데 group by 단위와 인덱스의 컬럼이 동일 하다면 sort는 발생하지 않는다. 최적의 인덱스를 사용하면 Access Path를 개선하는 효과뿐만 아니라 Sort의 부하도 없어진다.

Union All
을 제외한 집합연산(Union, Minus, Intersect)를 사용하면 Sort Unique 혹은 Hash Unique가 발생한다. Union Union All로 바꿀 수 없는지 검토해야 하고, Minus Not Exists 서브쿼리를 이용하여 Anti Join으로 바꿀 수 없는지 고려해야 한다. Intersect는 교집합이므로 조인으로 바꿀 수 있는지 검토해야 한다. 아주 가끔 Distinct를 사용한 SQL이 눈에 뛰는데 이 또한 Sort Unique 혹은 Hash Unique를 발생시킨다. 모델러나 설계자에게 문의하여 Distinct를 제거할 방법이 없는지 문의해야 한다.

 

Oracle 10g부터는 Hash Group By가 발생할 수 있는데, 이미 적절한 인덱스를 사용하는 경우라면 Hash Group By를 사용할 필요는 없다. 이런 경우 NO_USE_HASH_AGGREGATION 힌트를 사용하면 Sort Group By로 바꿀 수 있다. 이렇게 해주면 실행계획에 “SORT GROUP BY NOSORT” Operation이 발생하며, Sort Hashing 작업이 전혀 발생하지 않는다. Group By의 부하를 해결하는 또 하나의 방법은 스칼라 서브쿼리를 사용하는 것이다. 조인을 사용하면 Sum 값을 구하기 위해 Group By가 필수적이다. 하지만 스칼라 서브쿼리를 사용하면 Group By를 사용하지 않고도 sum 이나 Min/Max 값을 구할 수 있다. 또한 분석함수의 Ranking Family(rank, dens_rank, row_number)를 최적화된 인덱스와 같이 사용하면 Group By Sort를 하지 않고도 Min/Max 값을 구할 수 있다. 이때는 실행계획에 “WINDOW NOSORT” Operation이 발생한다. 관련 글을 참조하기 바란다.

 

5. 한 블록은 한번만 Scan하고 끝내라

같은 데이터를 반복적으로 Scan하는 SQL이 의외로 많다. 대표적인 경우가 Union All로 분리되었지만 실제로는 그럴 필요가 없는 경우이다. 예를 들어 Where 절에 구분코드가 1일 때 , 2일 때, 3일 때 별로 SQL이 나누어져 있는 경우이다. Where 절을 구분코드 in (1,2,3) 으로 처리하고, Select절에서 Decode Case 문을 사용하여 구분코드별로 처리해준다면 Union All은 필요 없다. Union All을 사용하는 또 한가지의 경우는 Sub Total(소계) Grand Total(총계)를 구해야 하는 경우이다. 이 경우도 Rollup/Cube Grouping Sets Group By절에 사용한다면 소계나 총계를 위한 별도의 Select문을 실행 시킬 필요는 없다. 1~4번의 과정은 SQL문의 변경이 없거나 최소화 된다. 하지만 5번의 경우는 SQL을 통합시켜야 하기 때문에 시간이 많이 소모되며, 많은 사고가 요구되는 창조적인 과정이다. 여기까지 했다면 진행되었다면 원본 SQL 자체의 튜닝은 완료 된 셈이다.

 

6. 온라인의 조회화면이라면 페이징처리는 필수이다

부분범위 처리를 해야 한다. 물론 전체 건을 처리해야 하는 경우는 있을 것이다. 하지만 조회화면이라면 몇 십만 건 혹은 몇 만 건이나 되는 결과를 모두 볼 수 없다. 따라서 볼 수 있는 단위로 끊어서 출력해야 한다. 예를 들어 결과 건수가 10만 건이라고 해도 최초의 50건을 화면에 먼저 뿌린다면 1,2,3,4 번에서 설명했던 모든 부하(Block I/O의 부하, 조인의 부하, Random Access의 부하, Sort의 부하)를 한꺼번에 감소시킬 수 있다. 따라서 가능하면 개발자를 설득하거나 책임자를 설득하여 페이징 처리를 하는 것이 바람직하다.

 

페이징 처리를 해도 효과를 볼 수 없는 몇 가지 예외가 있다. 분석함수를 사용하거나, Connect By + Start With를 사용한다면 페이징 처리의 효과는 없다. 분석함수의 경우 인라인뷰의 외부로 뺄 수 있다면 부분범위 처리가 가능하다. 이에 관해서는 해당 을 참조하기 바란다. Connect By + Start With를 사용한 경우는 부분범위처리가 불가능하다. 하지만 11g R2의 신기능인 Recursive With절을 사용한다면 페이징 처리의 효과를 볼 수 있다. 이때, Recursive With절에 Search(Order By절과 같은 기능)을 사용한다면 Connect By와 마찬가지로 페이징 처리의 효과가 없으니 주의해야 한다. 즉 인덱스의 구성을 적절히 하여 Sort를 대신해야 한다. Recursive With가 무엇인지 궁금한 사람은 관련 을 참조하기 바란다.

 

7. 답이 틀리면 안 된다. SQL을 검증하라

7번은 SQL 자체를 튜닝하는 것은 아니다. 하지만 7번을 튜닝 방법에 추가한 이유는 있다. 튜닝을 하였음에도 답이 틀린다면, 튜닝을 하지 않은 것 보다 못하다. 그러므로 튜닝 후에 답이 옳은지 항상 검증해야 한다. 1~ 7번 중에 가장 중요한 것이 7번이다.

 

방법론 정리

1.     적절한 인덱스를 사용하여 Block I/O를 최소화 하라.

2.     조인방법과 조인순서를 최적화 하라.

3.     Table Access(Random Access)를 최소화 하라

4.     Sort Hash 작업을 최소화 하라

5.     한 블록은 한번만 Scan하고 끝내라

6.     온라인의 조회화면이라면 페이징처리는 필수이다

7.     답이 틀리면 안 된다. SQL을 검증하라

 

방법론의 의미

만약 1~7번을 모두 적용할 수 있는 경우임에도 불구하고 하나라도 빠진다면 그것은 최적화된 SQL이 아니다. 물론 튜닝을 할 때 위의 1~6번을 항상 적용할 수 있는 것은 아니다. 경우에 따라서는 하나만 적용될 수도 있고, 두 개만 적용할 수 있는 SQL도 있다. 하지만 1~6번을 모두 적용할 수 있는지 꼼꼼히 살펴야 한다.

 

이 글은 튜닝 입문하여 관련 책들을 몇 권 본 사람들을 위한 기본적인 튜닝방법에 관한 것이다. 1번부터 7번까지의 방법은 기본 중에 기본이다. 이것들만 알아도 온라인 조회화면에서 사용하는 SQL을 튜닝하는데 어려움이 없을 것이다. 다시 말해 90%는 해결 할 수 있다. 그렇다면 나머지 10%? 그것들은 그때 그때 마다 다르게(On the fly 모드) 처리된다. 또한 그것들은 책이나 매뉴얼에 나와있지 않기 때문에 경험치 이거나 실험과 연구의 결과로 알아내는 것들이다.

 

일상을 낯설게 느껴보니 좋은 점이 많다. 언제 필자의 다른 일상(모델링, 시스템분석/진단)에 대한 방법론도 만들어 보려고 한다.


'Oracle > SQL Tuning' 카테고리의 다른 글

COPY_T 테이블 필요한가?  (6) 2011.04.04
Sort 부하를 좌우하는 두 가지 원리  (11) 2011.03.29
Pagination과 분석함수의 위험한 조합  (26) 2010.12.23
오라클의 Update문은 적절한가?  (15) 2010.04.14
Connect By VS ANSI SQL  (7) 2010.02.11
Posted by extremedb
,

select /*+ full(a) full(b)  leading(a) use_hash(b) */

a.col1, b.col2

  from tab1 a,

       tab1 b

  where a.col1 = b.col2 ;

 

오해와 현실

위의 SQL을 보면 from 절의 두 테이블은 동일하다. 그리고 건수가 많아서 힌트를 주었으므로, 둘 다 full table scan을 할 것이다. 따라서 위의 SQL을 실행하고 결과를 본다면, a b의 일량(block I/O)은 동일하다.”라고 알고 있는 사람이 많이 있다. a를 읽었더니 block I/O 량이 1000 블럭이라면 b를 읽을 때도 1000 블럭이 나올 것이라는 이야기다. 이런 주장이 사실일까? 결론부터 말하자면 사실이 아니다. b쪽이 더 많은 블럭을 scan 해야 한다. 그래서 b쪽을 scan할 때 더 느리다. b쪽에 더 많은 일량이 나온다면 버그라고 생각하는 사람도 있지만, 버그가 아니라 정상적인 결과이다.

 

이 글의 목적

위의 결론에 따르면 후행테이블을 scan 할 때 심각한 성능저하가 발생 할 수 있다. 이런 현상을 주위의 지인들에게 질문한 결과 적절한 이유나 원인을 말하는 사람은 거의 없었다. 성능문제의 원인을 모르면 튜닝을 할 수 없다. 그러므로 이 글에서는 성능이 저하되는 이유를 독자에게 제시하고, 비효율을 해결 할 수 있는 방법을 설명한다. 또한 이런 문제가 발생하지 않는 예외적인 경우도 살펴본다.

 

이제 테스트를 진행하기 위해 테이블을 하나 만들자.

 

create table test1 as

select lpad(level, 5, '0') as num,

       lpad(level, 60, '0') as num_txt

  from dual

connect by level <= 50000 ;

 

인덱스가 없음으로 앞으로 모든 실행계획은 full table scan이 될 것이다. 정확한 분석을 위해 test1 테이블의 full table scan 일량(logical reads)을 알아보자.

 

select count(*)

  from test1;

 

-----------------------------------------------------------------------------

| Id  | Operation          | Name  | Starts | A-Rows |   A-Time   | Buffers |

-----------------------------------------------------------------------------

|   0 | SELECT STATEMENT   |       |      1 |      1 |00:00:00.01 |     504 |

|   1 |  SORT AGGREGATE    |       |      1 |      1 |00:00:00.01 |     504 |

|   2 |   TABLE ACCESS FULL| TEST1 |      1 |  50000 |00:00:00.06 |     504 |

----------------------------------------------------------------------------- 

 

full table scan의 결과 일량은 504 블럭이다. 따라서 test1 테이블의 데이터가 변경되지 않는다면 항상 504 블럭이 나와야 한다. 정말 그렇게 될까?

 

아래 SQL의 조인 순서는 a--> b 이다.

 

select /*+ leading(a b) */ a.num

  from test1 a,

       test1 b

  where a.num = b.num

    and a.num > '00100'

    and substr(b.num_txt,  -5) > '00100'; --> substr의 인자 -5는 마지막 다섯 자리라는 뜻이다.

 

-----------------------------------------------------------------------------

| Id  | Operation          | Name  | Starts | A-Rows |   A-Time   | Buffers |

-----------------------------------------------------------------------------

|   0 | SELECT STATEMENT   |       |      1 |  49900 |00:00:00.45 |    5998 |

|*  1 |  HASH JOIN         |       |      1 |  49900 |00:00:00.45 |    5998 |

|*  2 |   TABLE ACCESS FULL| TEST1 |      1 |  49900 |00:00:00.06 |     504 |

|*  3 |   TABLE ACCESS FULL| TEST1 |      1 |  49900 |00:00:00.15 |    5494 |

-----------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   1 - access("A"."NUM"="B"."NUM")

   2 - filter("A"."NUM">'00100')

   3 - filter((SUBSTR("B"."NUM_TXT",(-5))>'00100' AND "B"."NUM">'00100'))

 

무려 11배나 차이가 난다

선행테이블은 정상적으로 504블록이 나왔다. 하지만 이상하게도 선행테이블과 동일한 테이블인 후행테이블( b )의 일량이 약 11배나 많다. 수행시간도 후행테이블이 더 느리다. 같은 테이블을 동일한 방법으로 scan 했는데 왜 Block I/O 수가 11배나 차이가 날까?

 

힌트를 주어 조인 순서를 바꿔보자.

 

select /*+ leading(b a) */ a.num

  from test1 a,

       test1 b

  where a.num = b.num

    and a.num > '00100'

    and substr(b.num_txt,  -5) > '00100';

 

-----------------------------------------------------------------------------

| Id  | Operation          | Name  | Starts | A-Rows |   A-Time   | Buffers |

-----------------------------------------------------------------------------

|   0 | SELECT STATEMENT   |       |      1 |  49900 |00:00:00.34 |    5998 |

|*  1 |  HASH JOIN         |       |      1 |  49900 |00:00:00.34 |    5998 |

|*  2 |   TABLE ACCESS FULL| TEST1 |      1 |  49900 |00:00:00.11 |     504 |

|*  3 |   TABLE ACCESS FULL| TEST1 |      1 |  49900 |00:00:00.06 |    5494 |

-----------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   1 - access("A"."NUM"="B"."NUM")

   2 - filter((SUBSTR("B"."NUM_TXT",(-5))>'00100' AND "B"."NUM">'00100'))

   3 - filter("A"."NUM">'00100')

  

array size가 원인이다

이번에는 반대로 a의 일량이 b보다 11배 많게 나왔다. 즉 일관성 있게 후행테이블의 일량이 11배가 많다. 그 이유는 툴(오렌지) array size 10 으로 되어있었기 때문이다. 다른 말로 바꾸면 array size 10 이기 때문에 49900건을 모두 출력하려면 4990 fetch 해야 한다. 즉 위의 일량 5494는 원래의 블록 수인 504 fetch 회수(4990 블럭)을 더한 것이다. 여기까지는 이해가 될 것인데 문제는 fetch 할 때마다 한 블록을 더 읽어야 하는가?이다.

 

Fetch 할 때마다 이전에 읽었던 1블럭을 더 읽어야 한다

한 블록에 20건이 들어있다고 가정하고, Array size 10 이라고 치자. 그러면 한 블럭의 데이터(20)를 모두 출력 하려면 동일한 블럭을 반복적으로 두 번 fetch 해야 한다. 바로 이것이 fetch 할 때마다 이미 읽었던 블럭(직전에 fetch 했던 block중 마지막 block)을 다시 Scan 할 수 밖에 없는 이유이다.

 

비효율을 없애려면 array size를 적정 수준으로 늘려라

 

set arraysize 100 --array size 100으로 변경

 

select /*+ leading(a b) */ a.num

  from test1 a,

       test1 b

  where a.num = b.num

    and a.num > '00100'

    and substr(b.num_txt,  -5) > '00100';

 

-----------------------------------------------------------------------------

| Id  | Operation          | Name  | Starts | A-Rows |   A-Time   | Buffers |

-----------------------------------------------------------------------------

|   0 | SELECT STATEMENT   |       |      1 |  49900 |00:00:00.38 |    1507 |

|*  1 |  HASH JOIN         |       |      1 |  49900 |00:00:00.38 |    1507 |

|*  2 |   TABLE ACCESS FULL| TEST1 |      1 |  49900 |00:00:00.06 |     504 |

|*  3 |   TABLE ACCESS FULL| TEST1 |      1 |  49900 |00:00:00.11 |    1003 |

-----------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   1 - access("A"."NUM"="B"."NUM")

   2 - filter("A"."NUM">'00100')

   3 - filter((SUBSTR("B"."NUM_TXT",(-5))>'00100' AND "B"."NUM">'00100'))

  

array size를 올리자 logical read 5494 에서 1003 으로 변경되었다. 5배 이상 일량(logical reads )이 줄어들었다. 하지만 아직도 원래의 블록 수인 504 보다배정도 많다. 

 

set arraysize 1000 --array size 1000으로 변경

 

select /*+ leading(a b) */ a.num

  from test1 a,

       test1 b

  where a.num = b.num

    and a.num > '00100'

    and substr(b.num_txt,  -5) > '00100';

 

-----------------------------------------------------------------------------

| Id  | Operation          | Name  | Starts | A-Rows |   A-Time   | Buffers |

-----------------------------------------------------------------------------

|   0 | SELECT STATEMENT   |       |      1 |  49900 |00:00:00.34 |    1058 |

|*  1 |  HASH JOIN         |       |      1 |  49900 |00:00:00.34 |    1058 |

|*  2 |   TABLE ACCESS FULL| TEST1 |      1 |  49900 |00:00:00.06 |     504 |

|*  3 |   TABLE ACCESS FULL| TEST1 |      1 |  49900 |00:00:00.09 |     554 |

-----------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

 

   1 - access("A"."NUM"="B"."NUM")

   2 - filter("A"."NUM">'00100')

   3 - filter((SUBSTR("B"."NUM_TXT",(-5))>'00100' AND "B"."NUM">'00100'))

 

array size1000으로 올리자 logical read 1003 에서 554로 변경되었다. 이 정도면 원래의 블럭수인 504와 비슷하다. 554와 504의 차이는 50 블럭이므로 fetch를 50번 했다는 것을 알 수 있다.

 

해결방법
테스트의 결과는 fetch
가 발생할 때마다 직전 블럭을 읽어야 함을 알 수 있다. 따라서 array size를 적절히 늘리면 fetch 회수가 줄어들므로 이전 블럭을 읽는 횟수도 같이 줄어든다. 이에 따라 성능도 향상된다. 하지만 array size를 늘려도 선행테이블은 logical read의 변화가 없다. 왜냐하면 선행테이블은 fetch에 영향을 끼치지 못하며, 후행 테이블이 scan 되어 조인에 성공될 때만 데이터가 client로 전송(fetch) 되기 때문이다.

조인이 없을 때도 비효율은 발생한다
이런 현상은 full table scan과 해시조인의 조합에서만 발생하는 것은 아니다. 조인 없이 from 절에 테이블이 하나뿐일 때도 동일하게 발생한다. 아래의 SQL이 전형적인 예제이다.

 

array  size 10일 때       

 

select num

  from test1;

 

Trace Version   : Oracle Database 11g Enterprise Edition Release 11.2.0.1.0 - Production

Environment     : Array Size = 10

                  Long  Size = 80

********************************************************************************

 

Call     Count CPU Time Elapsed Time       Disk      Query    Current       Rows

------- ------ -------- ------------ ---------- ---------- ---------- ----------

Parse        1    0.000        0.000          0          0          0          0

Execute      1    0.000        0.000          0          0          0          0

Fetch     5001    0.328        0.219          0       5504          0      50000

------- ------ -------- ------------ ---------- ---------- ---------- ----------

Total     5003    0.328        0.219          0       5504          0      50000

 

Misses in library cache during parse: 0

Optimizer goal: ALL_ROWS

Parsing user: SYS (ID=0)

 

Rows     Row Source Operation

-------  ---------------------------------------------------

      0  STATEMENT

  50000   TABLE ACCESS FULL TEST1 (cr=5504 pr=0 pw=0 time=67049 us cost=143 size=300000 card=50000)

 

fetch를 5001 번 했기 때문에 원래의 블럭수( 504 )에 비해 logical read량도 약 5000 블럭이 늘었다. 
 


array
 size
100일 때

 

Trace Version   : Oracle Database 11g Enterprise Edition Release 11.2.0.1.0 - Production

Environment     : Array Size = 100

                  Long  Size = 80

********************************************************************************

 

Call     Count CPU Time Elapsed Time       Disk      Query    Current       Rows

------- ------ -------- ------------ ---------- ---------- ---------- ----------

Parse        1    0.000        0.000          0          0          0          0

Execute      1    0.000        0.000          0          0          0          0

Fetch      501    0.063        0.041          0       1004          0      50000

------- ------ -------- ------------ ---------- ---------- ---------- ----------

Total      503    0.063        0.041          0       1004          0      50000

 

Misses in library cache during parse: 1

Optimizer goal: ALL_ROWS

Parsing user: SYS (ID=0)

 

Rows     Row Source Operation

-------  ---------------------------------------------------

      0  STATEMENT

  50000   TABLE ACCESS FULL TEST1 (cr=1004 pr=0 pw=0 time=75254 us cost=143 size=300000 card=50000)

 

Array size 10인 경우(5504)에 비해 일량이 약 5배 정도 감소했다. 그 이유는 fetch 회수가 10배로 줄어들었기 때문이다.

 


array  size
1000 일 때

 

Trace Version   : Oracle Database 11g Enterprise Edition Release 11.2.0.1.0 - Production

Environment     : Array Size = 1000

                  Long  Size = 80

 

********************************************************************************

 

Call     Count CPU Time Elapsed Time       Disk      Query    Current       Rows

------- ------ -------- ------------ ---------- ---------- ---------- ----------

Parse        1    0.000        0.000          0          0          0          0

Execute      1    0.000        0.000          0          0          0          0

Fetch       51    0.031        0.016          0        554          0      50000

------- ------ -------- ------------ ---------- ---------- ---------- ----------

Total       53    0.031        0.017          0        554          0      50000

 

Misses in library cache during parse: 1

Optimizer goal: ALL_ROWS

Parsing user: SYS (ID=0)

 

Rows     Row Source Operation

-------  ---------------------------------------------------

      0  STATEMENT

  50000   TABLE ACCESS FULL TEST1 (cr=554 pr=0 pw=0 time=50383 us cost=143 size=300000 card=50000)        

 

무작정 크게 한다고 좋아지지 않는다

array size 1000으로 변경하니 array size가 10인 경우(5504 블럭)에 비해 일량이 약 10배 정도 감소했다. 하지만 array size 100 인 경우와 비교해 보면 일량이 고작 2배 정도만 줄어들었다. 다시 말해 여기서 array size를 더 크게 하더라도 얻는 이익은 별로 없다는 것이다. 따라서 무작정 array size를 늘려서는 안 된다. 메모리에 부하를 줄 뿐만 아니라 한번에 많은 데이터가 client로 전송되므로 네트웍 I/O가 과도 하게 늘어날 수 있다. 따라서 clientfetch 할 건수가 많고, 네트웍 망의 성능이 좋다면 1000~ 2000 정도를 유지하는 것이 적당하다. 물론 조회 프로그램에서는 페이징 처리를 하는 것이 가장 좋지만, 업무적으로 전체 건을 볼 수 밖에 없는 경우는 array size를 적절히 조절하는 것이 대안이 될 수 있다.


성능문제의 발생조건 
fetch의 비효율은 select문에서만 발생한다. 즉 insert–select CTAS(create table as select) 그리고 merge 문 등에서는 이런 종류의 성능저하가 발생하지 않는다. 왜냐하면 DML문은 select문과 달리 조회(데이터를 clientfetch) 할 필요가 없고, commit이 되면 바로 종료되기 때문이다.

모든 규칙에 예외는 있다

full table scan + sort merge join 의 조합에서는 fetch의 비효율이 발생하지 않는다. 왜냐하면 full table scan + sort merge join 조합은 hash join의 조합과 달라서 모든 데이터를 sort 해야하기 때문이다. 모든 데이터를 sort하려면 어차피 모든 블럭을 scan해야 하므로 fetch를 여러번 해야만 하는 array size를 사용할 필요가 없는 것이다.  그리고 fetch를 여러번 하지 않기 때문에 항상 일량이 일정하다.

또 다른 예외의 경우는
 1 블럭에 1 row만 저장되는 경우이다. 이런 경우는 블럭을 한번만 엑세스 해도 그 블럭의 모든 데이터를 한번에 fetch 할 수 있으므로, 같은 블록을 반복해서 읽을 필요가 없다. 따라서 array size를 변경해도 일량이 달라지지 않는다.

 

호기심이 있는 독자는 아래의 테이블을 만들고 위의 테스트를 똑같이 진행 해보기 바란다. 위의 test 결과와는 다를 것이다.

 

drop table test1 ;

 

create table test1 as

select lpad(level, 5, '0') as num,

       lpad(level, 7000, '0') as num_txt

  from dual

connect by level <= 50000 ;

 
array size 항상 나쁜가?
우리는 array size가 있음으로 해서 부분범위처리를 할 수있다. full table scan을 동반하는 해시조인의 경우에도 중간에 효율적으로 멈출 수 있다. 예를 들어 결과건수가 1억건이며, 만건을 먼저 조회한 후에 다음 만건을 보고 싶다고 할때, 운반단위(array size)가 1000 이라면 10번 fetch 하면 멈출 수 있다. 반면에 array size가 없다면 중간에 멈출 수 없으므로 1억건을 모두 fetch 한후에나 결과를 화면에서 볼 수 있다.

결론

같은 테이블을 두 번 full table scan 하고, 그 둘을 해시조인하면 대부분의 경우 후행 테이블의 I/O량이 더 많다. 그래서 후행테이블을 scan 할 때가 더 느리다. 왜냐하면 직전 fetch 때에 이미 읽었던 block의 데이터가 모두 fetch 되지 않을 수 있으므로 그 블럭을 한번 더 읽어보아야 확인 할 수 있기 때문이다. 이런 비효율이 많이 발생하는 경우는 array size가 작기 때문이다. 따라서 적절한 array size로 늘려주면 성능문제를 해결 할 수 있다. 

fetch의 비효율은 full table scan이나 full table scan + hash join 조합을 사용할 때만 발생하는 것은 아니다. index scan을 할때도 똑같이 비효율이 발생한다.(주1)  즉 fetch의 비효율 문제는 인덱스를 사용할때나 테이블을 scan할때를 가리지 않고 모두 발생한다. 이런 사실들로 미루어 볼때, 위에서 언급한 몇가지의 예외를 제외한다면, 우리는 다음과 같은 결말을 낼 수 있다.

"select문의 결과건수가 많음에도 불구하고, 페이징 처리가 되지 않고, array size가 작은 조회용 프로그램이라면 fetch의 비효율은 존재한다."



주1 : 인덱스 사용시 fetch의 비효율 문제는 이미 책으로 정리가 되어 있으므로 필자가 언급하지 않는다. 이 문제에  관심이 있는 사람은 조동욱 님의 책 Optimizing Oracle Optimizer를 참조하기 바란다.

Posted by extremedb
,

글의 순서

1.쉽게 이해되는 글을 작성하는 방법

2.은유는 본능이다

3.은유의 사용규칙
4.은유를 사용해야 하는 과학적 이유

5.은유는 당신의 뇌를 속인다

 

이공계 출신은 특히 은유를 많이 사용해야 한다. 그렇지 않으면 이해와 소통이 부족할 것이다.” 나는 이렇게 주장한다. 뭔 헛소린가 하고 생각할 것이다. 왜냐하면 감성을 자제하고, 이성을 강조하는 과학적인 글에서는 은유가 적합하지 않다고 생각하기 때문이다. 반면에 은유는 감성적인 것이므로 문학적인 글에서 사용해야 한다고 생각한다. 이런 생각이 누구의 주장으로부터 나온 것인지 모르겠지만, 명백한 오류이다. 왜냐하면 이공계의 말과 글에서 은유를 통제한 결과는 처참하기 때문이다. 즉 쉽게 이해할 수 없는, 어려운 말과 글을 양산하고 말았다. 서로 이해 할 수 없으면 소통을 할 수 없는 것은 당연한 것이다. 은유는 가장 강력한 이해의 도구인데 안타깝게도 언제부턴가 이공계에서 사용하지 않고 있다.

 

흔히 글쓰기 책에서 글을 이해하기 쉽게 써라라고 언급된다. 하지만 어떤 글이 이해하기 쉬운 글인지는 언급하지 않는다. 또한 회의를 할 때 쉬운 말로 설명하라고한다. 도대체 어떠한 말과 글을 사용해야 쉽게 이해가 되는 것일까? 대답은 의외로 간단하다. 은유를 사용하면 어렵고 추상적인 개념을 쉽게 이해할 수 있다. 왜냐하면 은유는 어려운 개념을 쉬운 개념으로 대체(mapping)하기 때문이다. Post에서는 이해하기 힘든 말과 글에서 은유의 사용법을 제시한다. 또한 이해하기 힘든 글에서 은유를 써야 하는 과학적인 근거도 제시한다. 하지만 과학적인 근거보다 더 중요한 것은 은유는 인간의 본능이라는 점이다. 즉 은유를 사용하지 않는 것은 인간의 본성을 외면하는 것이다.

 

정의만으로는 상태나 특징을 설명할 수 없다

많은 이들이 개념을 이해시키기 위해 은유는 사용하지 않고, ‘사전적 정의(definition)’를 사용한다. 하지만 어떤 개념의 상태나 개념의 특징을 설명하고자 할 때는 정의는 사용할 수 없다. 왜냐하면 정의라는 것은 너무 사전적이라 일반화 시킬 수 밖에 없기 때문이다. 예컨대 뇌 과학에서 이야기 하는 사랑의 특징에 대해 논의 해보자. ‘뇌 과학에 따르면 사랑이라는 감정은 2 ~ 3년 간만 지속된다.’ 라는 사실을 설명할 때 사랑에 관한 사전적 정의를 사용할 수 없다. 오히려 사랑은 한 순간의 불꽃이다혹은 콩깍지가 끼는 기간은 짧다처럼 은유로 말하는 것이 더 적합하다.

 

은유와 구체적인 예제를 추가하라

개념을 정의하는 문장을 더 보자. Trickle Down이란 넘쳐흐르는 물이 바닥을 적신다라는 뜻이다. 이렇게 정의를 해도 Trickle Down이 구체적으로 무엇인지 쉽게 와 닿지 않는다. 이때 가장 좋은 것이 은유와 구체적인 예제로 정의부분을 감싸는 것이다. 아래의 예제는 은유 à 정의 à예제 순서로 구성된다.

 

Trickle Down Effect대기업과 중소기업의 Win Win 전략이다(은유)

Trickle Down이란 넘쳐흐르는 물이 바닥을 적신다라는 의미이다.(정의) 대기업이 탄생하면 여러 종류의 중소규모의 하청업체들도 생겨나는데, 대기업이 흑자가 나서 생산량을 늘리면 중소기업의 생산량도 늘어나므로 바닥경제도 살아나게 된다. 이것이 Trickle Down의 대표적인 예이다. 반대로 대기업의 생산량이 줄어들면 중소기업도 치명타를 입는다.(예제) ….. 이후 생략

 

이해하기 쉬운 글의 구조: 은유로 시작하고 예제로 끝낸다

위의 글처럼 나는 종종 은유를 제목/소제목으로 자주 사용한다. 왜냐하면 제목/소제목은 명확하고, 간결해야 하며, 많은 의미를 함축해야 하는데, 이때 가장적절 한 것이 은유이다. 시간이 얼마나 중요한지 설명을 하려면 A4지 한 장으로 설명을 하여도 부족할 것이다. 하지만 은유는 시간은 금이다라는 단 한 줄로 많은 의미를 함축한다. 예를 들면 시간을 낭비하지 마라, 인생은 짧다, 소중한 것부터 시간을 투자하라, 등등 셀 수 없이 많은 의미가 함축되어있다. 따라서 제목/소제목에 가장 적합한 것이 은유이다. 은유-->정의-->예제의 3단계를 거치면 아무리 어려운 개념이라 하더라도 독자를 이해시킬 수 있다. 은유는 꼭 제목/소제목으로 써야 하는 것은 아니며, 단락의 첫 문장으로 사용해도 무방하다.

 

은유는 본능이다

우리는 매일 은유를 사용한다. 하지만 은유를 사용한다고 느끼지 못한다. 예를 들어보자.

 

l  여기에 많은 시간을 투자할 수 없다.  

l  내게 시간을 빌려주실 수 있습니까?  

l  너는 나의 시간을 낭비하고 있다.     

l  시간을 절약해라.

 

위의 모든 예제는 시간은 돈은유를 간접적으로 표현한 것이다. 우리는 위의 표현들을 자주 사용하지만, 그것을 은유라고 생각하지는 않는다. 왜냐하면 무의식적으로 튀어 나오는 말이며, 우리가 의도적으로 은유를 쓰지 않았기 때문이다. 하지만 당신이 위의 말을 사용했다면 시간은 돈이라는 은유를 본능적으로 사용한 것이다. 이런 류의 말과 글은 많이 나타난다. ‘토론은 전쟁이라는 은유를 간접적으로 사용하는 예를 보자.

 

l  어제 회의실에서 그가 내 의견을 공격했다.

l  너의 논증은 허술해서 반대 세력에 쉽게 무너질 것이다.

l  그의 이론을 무너뜨리기 쉽지 않다.

l  너의 논증을 강화해야 반론에 견딜 수 있다.

l  그녀의 의견은 견고해서 내가 이길 수 없다.

 

만약 당신이 위의 예제들이 이상하게 느껴지지 않는다면, 당신이 은유를 자주 사용했거나, 상대방이 은유를 자주 사용했기 때문이다. 이런 식의 간접은유는 우리가 본능적으로 매일 사용하고 있다.

 

은유는 모호한 개념을 명확히 만든다

우리가 시간’(time) 이라는 개념은 명확히 그리고 직관적으로 알 수 있다. 그런데 좀더 추상적인 개념인 시간의 가치’(시간이 얼마나 중요한지)는 훨씬 어렵게 느껴진다. ‘시간의 가치를 말로 설명할 때 가장 좋은 것은 은유이다. 예를 들어보자. ‘시간은 돈이다’, ‘시간은 금이다라고 표현한다면 시간의 가치는 직관적으로 이해할 수 있다. 추상적이고 복잡한 대상을 명확하고 구체적인 대상으로 변경시키는 것이 은유이다. 즉 은유가 손에 잡히지 않는 어려운 개념을 이해하기 쉽게 만들어 준다는 이야기이다. 은유를 사용하지 않고 시간의 가치를 설명해 보라. 아마 쉽지 않을 것이다. 또한 은유를 사용한 경우보다 이해하기 힘들 것이다.

 

은유를 사용할 때의 규칙: 어려운 A는 쉬운 B이다

은유는 기본적으로 어려운 개념을 쉬운 것으로 대체시키는 것이다. 따라서 ‘A B이다처럼 사용해야 한다. 더 중요한 것은 어려운 개념은 A에 위치시키고 쉬운 개념을 B에 위치시켜야 한다. 은유를 사용하면서 가장 많이 저지르는 오류가 A B의 위치가 뒤바뀌거나, A B에 모두 어려운 개념을 사용하거나, A B에 모두 쉬운 개념을 위치시키는 경우이다. 다시 말해 A에는 추상적이거나, 모호하거나, 명확하지 않거나, 어려운 개념을 나타내야 한다. 이와는 반대로 B에는 구체적이거나, 직관적이거나, 명확하거나, 쉬운 개념을 나타내야 한다. 이를 어기는 사례를 자주 볼 수 있다.

 

은유를 사용해야 하는 과학적인 이유

인간이 개념을 이해하려고 할 때 어떤 과정을 거치는지 인지과학에서 사용하는 인지모형으로 설명해보자. 인지모형을 이해하면 이공계의 언어에 왜 은유를 써야만 하는지 알 수 있게 된다. 여기서는 글을 읽고 개념을 이해하는 과정을 다룬다. 하지만 글뿐만이 아니라 말도 동일한 과정을 거친다. 말과 글은 감각기관(시각과 청각)만 다를 뿐 개념이해의 원리는 같다.

 

인지모형: 집중 à 눈으로 읽기( 문장입력 ) à 멘탈모델작성 à 이해

글을 읽고 개념을 이해를 하려면 먼저 주의를 집중해야 한다. 그리고 문장을 눈으로 보아야 한다. 이를 문장입력이라고 한다. 눈으로 입력된 문장은 뇌로 이동된다. 뇌 속으로 이동된 정보를 토대로 멘탈모델(mental model)을 작성하게 된다. 멘탈모델은 이 글은 이러 저러한 개념이다라는 의미를 이미지(심상)로 만드는 것이다. 즉 글 전체의 의미를 머릿속에서 예측하는 작업이 멘탈모델을 작성하는 단계이다. 이 단계에서는 뇌에 저장되어 있는 기억과 경험들을 이용하여 글의 의미를 예측한다. 이때 글의 내용이 이미 알고 있는 개념이라면 멘탈모델이 쉽게 작성된다. 멘탈모델이 작성 완료되면 연이어 다음문장을 읽고 동일한 단계(멘탈모델 작성)를 거친다. 모든 글을 다 읽을 때까지 위의 과정이 반복된다.

 

이해되지 않으면 문장을 다시 읽어야 한다

하지만 모르는 개념이 있거나, 명확하지 않고, 복잡한 개념이 있는 경우는 우리의 기억 속에서 찾을 수 없으므로 멘탈모델을 작성하기 힘들다. 우리가 글을 이해하기 힘들 때 이미 읽었던 문장을 반복적으로 읽는 이유는 멘탈모델을 작성하기 어렵기 때문이다. 글을 다시 읽고 이해가 될 때까지 멘탈모델작성을 반복적으로 시도한다이와 반대로 문장을 한번만 읽고 멘탈모델이 성공적으로 작성된 경우는 반복 없이 다음문장으로 건너뛰기 때문에 고속으로 처리된다. 이 모든 것을 그림으로 정리한 것이 아래의 인지모형이다.

 


 

멘탈모델작성은 가장 무거운 작업이다

멘탈모델은 구성통합모형이라고도 부른다. 멘탈모델을 작성하려면 다시 네 가지 세부적인 작업(문장 parsing, 미시명제 표상형성, 거시명제 이해, 상황모형 생성)이 실행 되어야 하기 때문에 힘든 작업이다. 그래서 이 작업을 최소화 시켜야 한다. 쉬운 개념을 이용하여 한번에 이해될 수 있도록 글을 써야 하는 이유도 멘탈모델을 작성할 때의 부하 때문이다. 멘탈모델의 네 가지 세부적인 작업을 구체적으로 알고 싶은 사람은 관련서적인 이해:인지 패러다임(Walter Kintsch ) 을 참조하기 바란다.

 

은유는 뇌를 속이는 것

은유를 사용한다는 것은 어려운 개념을 쉬운 개념으로 대체하여 이해하는 것이다. 따라서 어려운 개념을 설명하더라도 은유를 사용하면 우리의 기억과 경험을 이용하여 멘탈모델을 빠르게 작성할 수 있다. 즉 고속처리가 가능하다. 정확히 말하면 은유는 뇌를 속이는 것이다. 실제로는 어려운 개념이지만 속임수(은유)를 사용하여 쉬운 개념으로 속인다. 이렇게 하면 우리의 기억에서 관련 개념을 쉽게 참조할 수 있으므로 멘탈모델을 한 번 만 작성하고 다음문장을 읽을 수 있다.

 

결론

말과 글을 상대방이 이해하기 쉽게 만들려면 자신만의 은유를 개발하라. 당신이 만든 은유는 말과 글에 독창성을 부여할 것이다. 당신만의 새로운 은유를 만드는 것은 규칙(어려운 A 쉬운 B)만 지킨다면 전혀 어렵지 않다. 나는 이 글을 쓰기 위해 Trickle Down(A) win win 전략(B)을 은유로 사용했다. 이렇게 사용하는 것은 창조적인 기술을 요구하지 않는다. 왜냐하면 은유는 우리에게 잠재되어 있는 본능이기 때문이다.

 

은유-->정의-->예제의 구조는 어려운 개념을 이해시키는데 적합하다. 어차피 언어(말과 글)란 소통을 위해서 존재하는 것이다. 소통이 되려면 내 생각을 상대방에게 이해시켜야 한다. 서로의 생각을 모른다면 소통할 수 없다. 즉 이해가 소통의 필수 조건이며, 이해가 힘들 때 가장 좋은 처방이 은유이다. 은유는 어렵고 복잡한 개념을 우리가 이미 알고 있는 개념에 빗대어 쉬운 것으로 변경시켜 준다. 따라서 인문계열이든 이공계열이든 은유를 자제할 하등의 이유가 없다. 오히려 복잡한 개념을 가진 과학적인 말과 글에서 상대방을 이해를 시키는 것이 목적이라면 은유를 사용하라. 그대가 시인이 아니라고 할지라도.

 

 

 

PS : 참조서적

은유에 대해 더 깊은 공부를 원하는 사람은 아래의 책을 참조하라.

1.과학의 언어(Carol Reeves ) : 과학적인 글에서 은유가 어떻게 사용되는지 나타낸다.

2.삶으로서의 은유(George Lakoff) : 일상에서 은유가 어떻게 사용되는지 분석한다. 

 

인지과학에 관심이 있는 사람은 아래의 책을 참조하라.

3.인지과학 - 과거 현재 미래(이정모 저)

 

인간이 말과 글을 어떻게 이해하는지에 관심이 있으면 아래의 책을 참조하라. 책이 두 권으로 되어있다.

4.이해:인지 패러다임(Walter Kintsch )

 

위에서 설명한 인지모형과정이라는 것도 물리적으로는 신경세포(뉴런)의 상호작용이다. 뉴런은 뇌의 네트워크 망인(시냅스)를 이용한다. 뇌 과학의 기본을 알려면 아래의 책을 참조하기 바란다. 아주 얇은 그림책이다.

5. 구조(뉴턴코리아)

 

참고사항

은유에 대하여 한 권의 책을 꼽으라면 단연 삶으로서의 은유이다
2,3,4
번 책은 모두 인지과학에 관련된 책이다. 위의 책 중에 이해: 인지 패러다임이라는 책은 전문가도 어려워하므로 무작정 사지 말고 서점에서 난이도를 확인하고 구입하기 바란다. 필자의 경우 두 번 읽고 겨우 이해 하였다이 책을 정복하려면 인지과학과 인지심리학, 그리고 뇌과학 입문서를 탐독하고 보는 것이 바람직하다.


Posted by extremedb
,

-대표적인 페이징 처리방법

-누적집계가 필요할 때 페이징(부분범위) 처리방법

-Pagination의 단점을 이용하는 방법

 

주의사항

이 글에서 사용되는 분석함수는 현재 row 까지의 누적집계(Cumulative total) 이다. 이와 반대로 전체집계(Grand Total)나 그룹집계(Sub total)는 부분범위처리를 할 수 없다. 왜냐하면 데이터를 모두 읽어야만 결과를 낼 수 있기 때문이다. 하지만 누적집계는 데이터가 sort 되어 있고, 이미 출력된 컬럼들의 값을 알 수 있다면 부분범위처리가 가능하다. 우리는 이점을 이용할 것이다.

 

상황

Time Out이 발생하여 개발자가 종이 한 장을 들고 급하게 뛰어왔다.

 

개발자: 페이징 처리를 했고, 최적의 인덱스도 존재하고, 그 인덱스를 잘 타는데도 Time Out이 발생합니다.

필자  : 그럴 리가요?

개발자: SQL입니다. 한번 봐주세요.

필자  : ….분석함수 때문에 전체 건을 읽고, 전체 건을 sort하는 군요. 페이징 처리방법을 약간 변경하면 됩니다.

개발자: 이 방법은 SQL 작성 가이드에 나온 방법입니다. 이 방법을 쓰지 않으면 사수에게 혼납니다.

필자  : 이 방법을 사용하지 말라는 이야기가 아니라, 분석함수의 위치만 옮기라는 이야기 입니다.

개발자: 그렇군요. 감사합니다.

 

이렇게 해서 장애상황은 없어졌다. 이후에 SQL 작성가이드에 페이징 처리시 누적집계가 있는 경우의 처리방법을 추가하였다고 한다.

 

Pagination SQL

개발자가 사용한 페이징 처리용 SQL은 아래와 같았다.

 

SELECT *

  FROM (SELECT a.*, ROWNUM rnum

          FROM (         ) a --> 여기에 order by 가 포함된 SQL 을 넣는다.

         WHERE ROWNUM <= :v_max_row )

 WHERE rnum >= :v_min_row ;

        

 

인라인뷰 a SQL을 넣기만 하면 페이징 처리가 된다. 물론 조회시 정렬이 필요하다면 order by가 포함된 SQL을 넣어야 한다. 이 방법은 토마스 카이트가 제시하였다. 이 기법은 약간의 비효율이 있다. 첫 페이지에서는 최적이지만, 뒤쪽 페이지를 읽을 때는 이전 페이지의 데이터를 모두 scan 해야 한다.(화면에 출력되지는 않는다.) 하지만 경험적으로 볼 때 비효율이 크지 않다. 왜냐하면 우리가 구글이나 네이버로 검색을 할 때 통상적으로 앞쪽의 몇 페이지만 보고 검색을 끝내기 때문이다. 만약 네이버에서 트위터라는 단어로 검색을 했더니 5729 페이지가 나왔다고 치면, 대부분 첫 페이지 혹은 두 번째, 세 번째 페이지에서 찾고자 하는 정보를 볼 수 있을 것이다. 5729 페이지를 모두 넘겨본 사람은 거의 없을 것이다. (만약 있다면 존경스럽다.) 따라서 위의 방법을 사용한다고 해도 성능저하는 거의 발생하지 않는다.

 

그런데 인라인뷰 a에 포함될 SQL에 누적집계용 분석함수가 포함될 때는 위의 방법에 약간의 변형을 가해야 한다. 그렇지 않고 위의 방법을 그대로 사용하면 심각한 성능저하가 발생할 수 있다. 즉 분석함수가 존재한다면 위의 방법은 무늬만 페이징 처리가 되며 실제로는 전체범위를 처리하여 Time Out이 발생 할 수 있다. 이 글에서는 누적집계용 분석함수가 있는 경우에 기존방법의 문제점을 제시하고 효과인 페이징 처리방법에 대해 논의 한다.

 

테스트를 위해 테이블과 인덱스를 생성한다.

 

CREATE TABLE sales_t AS SELECT * FROM sales;

 

CREATE INDEX ix_prod ON sales_t (prod_id);

 

먼저 인라인뷰 a 에 들어갈 SQL을 보자.

 

SQL1

 

SELECT   /*+ INDEX(S IX_PROD) */

         s.prod_id, s.cust_id,  s.channel_id, s.time_id, amount_sold,

         SUM (amount_sold) OVER (PARTITION BY s.cust_id ORDER BY s.channel_id, s.time_id) AS sum_amt

    FROM sales_t s

   WHERE s.prod_id = :v_prod_id  --> 30 대입

ORDER BY s.cust_id, s.channel_id, s.time_id ;

 

 

----------------------------------------------------------------------------------------------------

| Id  | Operation                    | Name    | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |

----------------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT             |         |      1 |  29282 |00:00:00.12 |     424 |          |

|   1 |  WINDOW SORT                 |         |      1 |  29282 |00:00:00.12 |     424 | 1621K (0)|

|   2 |   TABLE ACCESS BY INDEX ROWID| SALES_T |      1 |  29282 |00:00:00.10 |     424 |          |

|*  3 |    INDEX RANGE SCAN          | IX_PROD |      1 |  29282 |00:00:00.03 |      60 |          |

----------------------------------------------------------------------------------------------------

 

고객별로 channel_idtime_id sort하여 누적합계를 구하는 SQL이다. 위의 SQL은 페이징 처리(부분범위 처리)가 되지 않은 것이다. 따라서 29282건이 결과로 출력되었고 424 블럭을 Scan 하였다. WINDOW SORT라는 operation이 존재하는 이유는 분석함수 때문이다. SQL order by가 있지만 별도의 SORT ORDER BY operation이 존재하지 않는다. 그 이유는 WINDOW SORT order by가 할 일을 대신해 주고 있기 때문이다. WINDOW SORT operation 때문에 PGA 1621K만큼 사용하였다.

 

이제 페이징 처리를 해보자. 먼저 차이를 보여주기 위하여 분석함수를 제거하고 페이징 처리를 하였다.

 

SQL2

 

SELECT *

  FROM (SELECT a.*, ROWNUM rnum

          FROM (SELECT   /*+ INDEX(S IX_PROD) */

                         s.prod_id, s.cust_id,  s.channel_id, s.time_id, amount_sold

                    FROM sales_t s

                   WHERE s.prod_id = :v_prod_id  --> 30 대입

                ORDER BY s.cust_id, s.channel_id, s.time_id

               ) a

         WHERE ROWNUM <= :v_max_row --> 20 대입

       )

 WHERE rnum >= :v_min_row ;         --> 1 대입

 

-------------------------------------------------------------------------------------------------------

| Id  | Operation                       | Name    | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |

-------------------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT                |         |      1 |     20 |00:00:00.02 |     424 |          |

|*  1 |  VIEW                           |         |      1 |     20 |00:00:00.02 |     424 |          |

|*  2 |   COUNT STOPKEY                 |         |      1 |     20 |00:00:00.02 |     424 |          |

|   3 |    VIEW                         |         |      1 |     20 |00:00:00.02 |     424 |          |

|*  4 |     SORT ORDER BY STOPKEY       |         |      1 |     20 |00:00:00.02 |     424 | 2048  (0)|

|   5 |      TABLE ACCESS BY INDEX ROWID| SALES_T |      1 |  29282 |00:00:00.13 |     424 |          |

|*  6 |       INDEX RANGE SCAN          | IX_PROD |      1 |  29282 |00:00:00.03 |      60 |          |

-------------------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   1 - filter("RNUM">=:V_MIN_ROW)

   2 - filter(ROWNUM<=:V_MAX_ROW)

   4 - filter(ROWNUM<=:V_MAX_ROW)

   6 - access("S"."PROD_ID"=:V_PROD_ID)

 

페이징 처리를 하였음에도 똑같이 전체 블록인 424 블럭을 scan 하였다. 그 이유는 전체 건을 읽어서 정렬작업을 해야 하기 때문이다. 반면에 PGA의 사용은 2048에 불과하다. 왜냐하면 부분범위를 처리할 때는 전체 건을 sort하는 것이 아니라, 20 row 짜리 배열을 만들고 그 배열만 관리하면 되기 때문이다. 자세한 내용은 관련 을 참조하라.

 

이제 분석함수를 추가해 보자.

 

SELECT *

  FROM (SELECT a.*, ROWNUM rnum

          FROM (SELECT   /*+ INDEX(S IX_PROD) */

                         s.prod_id, s.cust_id,  s.channel_id, s.time_id, amount_sold,

                         SUM(amount_sold) OVER (PARTITION BY s.cust_id ORDER BY s.channel_id, s.time_id) AS sum_amt

                    FROM sales_t s

                   WHERE s.prod_id = :v_prod_id  --> 30 대입

                ORDER BY s.cust_id, s.channel_id, s.time_id

               ) a

         WHERE ROWNUM <= :v_max_row --> 20 대입

       )

 WHERE rnum >= :v_min_row ;         --> 1 대입

 

-------------------------------------------------------------------------------------------------------

| Id  | Operation                       | Name    | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |

-------------------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT                |         |      1 |     20 |00:00:00.03 |     424 |          |

|*  1 |  VIEW                           |         |      1 |     20 |00:00:00.03 |     424 |          |

|*  2 |   COUNT STOPKEY                 |         |      1 |     20 |00:00:00.03 |     424 |          |

|   3 |    VIEW                         |         |      1 |     20 |00:00:00.03 |     424 |          |

|   4 |     WINDOW SORT                 |         |      1 |     20 |00:00:00.03 |     424 | 1621K (0)|

|   5 |      TABLE ACCESS BY INDEX ROWID| SALES_T |      1 |  29282 |00:00:00.15 |     424 |          |

|*  6 |       INDEX RANGE SCAN          | IX_PROD |      1 |  29282 |00:00:00.03 |      60 |          |

-------------------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   1 - filter("RNUM">=:V_MIN_ROW)

   2 - filter(ROWNUM<=:V_MAX_ROW)

   6 - access("S"."PROD_ID"=:V_PROD_ID)

 

성능저하의 원인은 분석함수

분석함수를 사용하자 PGA사용량이 급격히 늘었다. 분석함수가 없는 경우와 비교해보면 무려 791배나 차이가 난다. SQL1 PGA 사용량과 위 실행계획의 PGA 사용량을 비교해 보면 분석함수의 PGA 사용량은 페이징 처리를 하지 않았을 때와 똑같다. 즉 페이징 처리를 하였지만 분석함수의 영향으로 전체범위 처리가 되어버린 것이다. 바로 이점이 페이징 처리를 하였음에도 Time-Out이 발생하는 이유였다. 어떻게 하면 비효율을 제거할 수 있을까? 아래의 SQL이 정답이다.

 

SELECT *

  FROM (SELECT s.*, ROWNUM rnum,

               SUM (amount_sold) OVER (PARTITION BY s.cust_id ORDER BY s.channel_id, s.time_id) AS sum_amt

          FROM (SELECT   /*+ INDEX(S IX_PROD) */

                         s.prod_id, s.cust_id,  s.channel_id, s.time_id, amount_sold

                    FROM sales_t s

                   WHERE s.prod_id = :v_prod_id  --> 30 대입

                ORDER BY s.cust_id, s.channel_id, s.time_id

               ) s

         WHERE ROWNUM <= :v_max_row --> 20 대입

       )

 WHERE rnum >= :v_min_row ;         --> 1 대입

 

 

--------------------------------------------------------------------------------------------------------

| Id  | Operation                        | Name    | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |

--------------------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT                 |         |      1 |     20 |00:00:00.02 |     424 |          |

|*  1 |  VIEW                            |         |      1 |     20 |00:00:00.02 |     424 |          |

|   2 |   WINDOW BUFFER                  |         |      1 |     20 |00:00:00.02 |     424 | 2048  (0)|

|*  3 |    COUNT STOPKEY                 |         |      1 |     20 |00:00:00.02 |     424 |          |

|   4 |     VIEW                         |         |      1 |     20 |00:00:00.02 |     424 |          |

|*  5 |      SORT ORDER BY STOPKEY       |         |      1 |     20 |00:00:00.02 |     424 | 2048  (0)|

|   6 |       TABLE ACCESS BY INDEX ROWID| SALES_T |      1 |  29282 |00:00:00.14 |     424 |          |

|*  7 |        INDEX RANGE SCAN          | IX_PROD |      1 |  29282 |00:00:00.04 |      60 |          |

--------------------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   1 - filter("RNUM">=:V_MIN_ROW)

   3 - filter(ROWNUM<=:V_MAX_ROW)

   5 - filter(ROWNUM<=:V_MAX_ROW)

   7 - access("S"."PROD_ID"=:V_PROD_ID)

 

분석함수는 인라인뷰 밖으로 빼라

분석함수를 뷰의 외부로 위치를 바꾸자 PGA를 거의 사용하지 않는다. 분석함수가 추가되었음에도 PGA 사용량이 분석함수를 사용하지 않은 경우(SQL2)와 비슷하다. 그 이유는 20건에 대해서만 분석함수가 실행되었기 때문이다. ID 2번에서 사용한 PGA SORT를 위한 것이 아니다. 왜냐하면 이미 인라인뷰 내에서 SORT가 되었으므로 같은 작업을 반복할 필요가 없기 때문이다. 이런 경우는 order by절의 컬럼과 분석함수 OVER절의 컬럼이 일치한 경우만 나타난다. 이에 따라 OperationWINDOW SORT가 아니라 WINDOW BUFFER로 바뀌었다. 20 row로 구성된 배열만 관리하면 된다. Order by 작업 또한 전체 건을 sort하지 않고 페이징 처리된 20건에 대해서 배열만 관리한 것이다.

 

절반의 성공

위의 실행계획이 best 인가 하면 그렇지는 않다. 왜냐하면 페이징 처리가 되지 않은 SQL1의 실행계획을 보면 29282건을 모두 읽었고, 페이징 처리가 된 위의 SQL 또한 마찬가지 이다. 다시 말해 위의 SQL은 결과적으로 20건만 출력되므로 비효율적인 전체범위를 처리한 것이다. PGA 사용(Sort)의 관점에서는 부분범위 처리가 되었지만 Block I/O의 관점에서는 전체범위를 처리하고 말았다.

 

이제 Block I/O 문제를 해결하기 위해 인덱스를 생성해보자.


CREATE UNIQUE INDEX PK_SALES_T ON SALES_T(PROD_ID, CUST_ID, CHANNEL_ID, TIME_ID);


이제 위의 인덱스를 이용하여 페이징 처리되지 않은 SQL을 실행해 보자.

 

SELECT /*+ INDEX(S PK_SALES_T) */

       s.prod_id, s.cust_id,  s.channel_id, s.time_id, amount_sold,

       SUM (amount_sold) OVER (PARTITION BY s.cust_id ORDER BY s.channel_id, s.time_id) AS sum_amt

  FROM sales_t s

 WHERE s.prod_id = :v_prod_id  --> 30 대입

 ORDER BY s.cust_id, s.channel_id, s.time_id ;

 

-------------------------------------------------------------------------------------------------------

| Id  | Operation                    | Name       | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |

-------------------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT             |            |      1 |  29282 |00:00:00.11 |   28337 |          |

|   1 |  WINDOW BUFFER               |            |      1 |  29282 |00:00:00.11 |   28337 | 1495K (0)|

|   2 |   TABLE ACCESS BY INDEX ROWID| SALES_T    |      1 |  29282 |00:00:00.12 |   28337 |          |

|*  3 |    INDEX RANGE SCAN          | PK_SALES_T |      1 |  29282 |00:00:00.03 |     118 |          |

-------------------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   3 - access("S"."PROD_ID"=:V_PROD_ID)

 

28337 블록을 scan 하였고 PGA 1495K나 사용하였다. WINDOW BUFFER operation을 본다면 전체 건을 sort한 것은 아니다. 하지만 배열(WINDOW)의 크기가 20건이 아니라 29282건이나 되므로 전체 건을 sort한 경우와 PGA 사용량이 비슷해져 버렸다. 전체 건을 sort SQL1 PGA 사용량이 1621K 이므로 비슷하다고 할 수 있다.

 

페이징 처리를 해도...

이런 현상은 페이징 처리를 해도 분석함수를 인라인뷰 외부로 이동하지 않으면 마찬가지로 발생한다. 아래의 SQL을 보자.

 

SELECT *

  FROM (SELECT a.*, ROWNUM rnum

          FROM (SELECT   /*+ INDEX(S PK_SALES_T) */

                         s.prod_id, s.cust_id,  s.channel_id, s.time_id, amount_sold,

                         SUM (amount_sold) OVER (PARTITION BY s.cust_id ORDER BY s.channel_id, s.time_id) AS sum_amt

                    FROM sales_t s

                   WHERE s.prod_id = :v_prod_id  --> 30 대입

                ORDER BY s.cust_id, s.channel_id, s.time_id

               ) a

         WHERE ROWNUM <= :v_max_row --> 20 대입

       )

 WHERE rnum >= :v_min_row ;         --> 1 대입

 

----------------------------------------------------------------------------------------------------------

| Id  | Operation                       | Name       | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |

----------------------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT                |            |      1 |     20 |00:00:00.04 |   28337 |          |

|*  1 |  VIEW                           |            |      1 |     20 |00:00:00.04 |   28337 |          |

|*  2 |   COUNT STOPKEY                 |            |      1 |     20 |00:00:00.04 |   28337 |          |

|   3 |    VIEW                         |            |      1 |     20 |00:00:00.04 |   28337 |          |

|   4 |     WINDOW BUFFER               |            |      1 |     20 |00:00:00.04 |   28337 | 1495K (0)|

|   5 |      TABLE ACCESS BY INDEX ROWID| SALES_T    |      1 |  29282 |00:00:00.13 |   28337 |          |

|*  6 |       INDEX RANGE SCAN          | PK_SALES_T |      1 |  29282 |00:00:00.03 |     118 |          |

----------------------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   1 - filter("RNUM">=:V_MIN_ROW)

   2 - filter(ROWNUM<=:V_MAX_ROW)

   6 - access("S"."PROD_ID"=:V_PROD_ID)

 

부분범위 처리가 아니라 전체범위 처리이다

많은 이들이 착각하는 것이 위의 SQL이다. 다시 말해 “order by와 분석함수의 over절에 최적화된 인덱스를 생성하면 부분처리가 되겠지라고 생각한다. 하지만 사실은 이와 다르다. 인덱스의 영향으로 Plan상에 sort order by window sort operation이 없으므로 부분범위 처리가 된 것으로 판단하면 안 된다. 20건을 읽은 것이 아니라 전체 건인 29282건을 읽었으며 PGA 사용량도 전체 건을 sort했던 경우(SQL1)와 비슷하다.

 

이런 상황에서도 해결방법은 분석함수를 밖으로 빼는 것이다. 아래의 SQL을 보자.

 

SELECT *

  FROM (SELECT s.*, ROWNUM rnum,

               SUM (amount_sold) OVER (PARTITION BY s.cust_id ORDER BY s.channel_id, s.time_id) AS sum_amt

          FROM (SELECT   /*+ INDEX(S PK_SALES_T) */

                         s.prod_id, s.cust_id,  s.channel_id, s.time_id, amount_sold

                    FROM sales_t s

                   WHERE s.prod_id = :v_prod_id  --> 30 대입

                ORDER BY s.cust_id, s.channel_id, s.time_id

               ) s

         WHERE ROWNUM <= :v_max_row --> 20 대입

       )

 WHERE rnum >= :v_min_row ;         --> 1 대입

 

 

----------------------------------------------------------------------------------------------------------

| Id  | Operation                       | Name       | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |

----------------------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT                |            |      1 |     20 |00:00:00.01 |      23 |          |

|*  1 |  VIEW                           |            |      1 |     20 |00:00:00.01 |      23 |          |

|   2 |   WINDOW BUFFER                 |            |      1 |     20 |00:00:00.01 |      23 | 2048  (0)|

|*  3 |    COUNT STOPKEY                |            |      1 |     20 |00:00:00.01 |      23 |          |

|   4 |     VIEW                        |            |      1 |     20 |00:00:00.01 |      23 |          |

|   5 |      TABLE ACCESS BY INDEX ROWID| SALES_T    |      1 |     20 |00:00:00.01 |      23 |          |

|*  6 |       INDEX RANGE SCAN          | PK_SALES_T |      1 |     20 |00:00:00.01 |       3 |          |

----------------------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):

---------------------------------------------------

   1 - filter("RNUM">=:V_MIN_ROW)

   3 - filter(ROWNUM<=:V_MAX_ROW)

   6 - access("S"."PROD_ID"=:V_PROD_ID)

 

정확히 20건에 대해서만 WINDOW BUFFER operation 이 발생하였다. 이에 따라 PGA 사용량도 최적이 되었다. 또한 Block I/O 관점에서도 최상이다. 28337 Block scan한 것이 아니라 고작 23 Block scan 하였다. 분석함수의 위치가 성능에 얼마나 큰 영향을 미치는지 알 수 있는 장면이다.

 

결론

페이징 처리가 되었음에도 Time Out이 발생한다면 누적집계용 분석함수를 의심해보아야 한다. 만약 분석함수가 존재한다면 인라인뷰 밖으로 빼야 한다. 그렇게 한다면 분석함수의 실행이 최소화되며 이에 따라 성능이 향상된다. 또한 order by와 분석함수에 최적화된 인덱스를 만든다면 전체 건을 읽지 않아도 되며 sort의 부하 또한 없어질 것이다. 다시 말해 비효율이 없는 페이징 처리가 가능하다.

 

원리는 따로 있다

이 글의 결론까지 보았음에도 한가지 의문점을 떠올리지 못한다면 핵심원리를 놓친 것이다. 의문점이란 분석함수를 인라인뷰 밖으로 빼도 답이 달라지지 않는가?” 이다. 분석함수를 인라인뷰 밖으로 빼는 방법이 가능한 이유가 뭐라고 생각하는가? 답을 보기 전에 잠시 이유를 생각해보기 바란다. 답은 아래에 있다.

 

답을 보려면 아래의 글을 마우스로 드래그 하시오

 

이 글의 처음에 언급했던 페이징 처리시 약간의 비효율 있다고 했는데 이것이 원리이다. Tomas Kyte가 제시한 pagination 방법을 사용하면 뒤쪽 페이지를 읽을 때는 이전 페이지의 데이터를 모두 scan 해야 만 한다. 이 비효율을 이용하는 것이 핵심이다. 왜냐하면 한 페이지의 누적집계를 구하려면 이전 페이지의 값들을 모두 알아야 하기 때문이다. 예를 들어 홍길동 고객의 실적이 1 페이지와 2 페이지에 걸쳐서 나온다고 할 때, 1 페이지 있는 홍길동의 실적과 2페이지에 있는 홍길동의 실적을 더해야만 2 페이지의 누적집계를 구할 수 있다. 그런데 위의 방법을 사용하면 분석함수를 인라인뷰 밖으로 빼더라도 이전 페이지의 값을 보존하기 때문에 누적집계의 값은 정확하다.

 

 페이징 처리시 누적집계용 분석함수를 인라인뷰 밖으로 빼라고 누군가에게 guide할 때 단점(비효율)을 장점으로 이용했음을 같이 알려주기 바란다. 그것이 원리이자 핵심이기 때문이다.

 

PS

즐거운 성탄절을 보내시기 바랍니다.

지난 1년간 이 블로그를 이용해 주셔서 감사합니다.


'Oracle > SQL Tuning' 카테고리의 다른 글

Sort 부하를 좌우하는 두 가지 원리  (11) 2011.03.29
SQL튜닝 방법론  (20) 2011.01.27
오라클의 Update문은 적절한가?  (15) 2010.04.14
Connect By VS ANSI SQL  (7) 2010.02.11
USE_CONCAT 힌트 제대로 알기  (5) 2009.07.17
Posted by extremedb
,

과학과 철학의 간격

Modeling 2010. 12. 15. 16:49

-개발 방법론 VS 모델링 방법론

최근에 어느 모델러에게 “~개발 방법론 때문에 미치겠다.”는 말을 들었다. SI 프로젝트에 참여하면 여러 가지 방법론이 등장한다. 하지만 그것들은 개발 방법론 혹은 관리방법론이기 때문에 분석과 사고, 그리고 통찰이 중요한 모델링을 수행하는 데는 오히려 방해가 되는 경우가 있다. 왜냐하면 이런 방법론들은 복잡한 건물을 세우는 방법, 건물 내부를 채우는 방법, 그리고 최소의 인력으로 최소의 시간을 들여 프로젝트를 마치는 방법에 관심이 집중되어 있기 때문이다. 이런 방법들은 프로젝트의 일정이 진행되는 입장에서 보면 관리적이며 과학적인 방법이다. 하지만 데이터 모델링에는 그런 과학적 방법이 얼마나 보탬이 될지는 미지수 이다.

 

데이터의 품질은 어떻게 보장되나?

많은 이들이 데이터의 품질에 목을 매고 있지만 개발 방법론에 따라 데이터 품질이 좌지우지 되는 것은 아니다. 왜냐하면 많은 경우에 데이터의 품질이란 데이터 모델의 품질에 따라 좌우된다. 그러므로 각종 개발 방법론들이 데이터 모델의 품질을 향상시키는 쪽으로 방법론이 진화되었으면 한다. 진화가 그리 어려운 것은 아니다.

 

진화방법 1

모델러는 일이 어떻게 진행될지 명확히 설명해야 하며, 산출물을 제대로 작성하고, 일정을 지켜야 한다. 모델러들 또한 고집이 있다. 전문가이기 때문에 개발자에게 배우지 않으려는 특징이 있다. 개발자들에게 UML을 배울 수 있다면 배워야 한다. 항상 모델러가 선생님이 되어야 하는 것이 아니다. UML과 모델링 툴의 사용법과 표기법이 다르다고 해서 그 사상이나 엔티티의 내부 구조가 달라지는 것이 아니다. 그러므로 모델러들도 특정 모델링 Tool에만 집착하면 안되며 UML로 모델링 할 수 있는 능력을 키워야 한다. 다른 Tool이 있음에도 내가 사용법을 모른다고 해서 고객이 몇 백만 원 혹은 몇 천만 원 하는 특정 Tool을 사야만 할까?

 

진화방법 2

반면에 단위 일정 내에서는 모델러에게 특정 방법을 강요해서는 안 된다. 즉 인정 될 수 있는 범위 내에서는 자유를 주어야 한다는 이야기이다. 왜냐하면 그들만의 방법이 따로 있기 때문이다. 화가가 그림(ERD)을 그리는데 특정 방법론을 강요할 수 있을까? 또한 장인이 작품(모델)을 만드는데 대규모 공장의 논리를 강요할 수 있을까 


강요와 허용의 차이

많은 경우에 모델러를 PL(관리자)로 생각하기 때문에 강요가 발생한다. 모델러는 PM이나 PL이 아니다. 물론 한사람이 다하는 곳도 있지만 그것은 예외이다. 그림을 그리는 방법에 대해서는 완전한 자유를 주어야 한다. 이와 반대로 구조물의 단순성 및 명료성 자체를 논하는 과정(철학적 과정)이 아니라, 복잡한 구조물을 세울 때(개발진행과정)에는 과학적 개발 방법이 꼭 필요하다. 즉 모델러에게 프로젝트의 관리 및 개발방법을 무시해도 된다는 특권을 주라는 이야기가 아니다. 다만 그들에게 좋은 그림을 그릴 수 있도록 자유로운 사고와 통찰을 허하라. 그렇게 해야만 데이터 모델이 단순해지고 명료해진다. 그리고 무엇보다 중요한 데이터의 품질이 향상된다.

 

이런 생각을 하는 사람은 나뿐인가? 만약 그렇다면 100년 전에 생존했던 어느 철학자가 필자의 의견을 지지했던 것처럼 보이는 이유는 무엇일까?

우리 문명은 '진보'라는 단어를 특징으로 한다. 진보는 우리 문명이 가진 특징 가운데 하나이기보다는 그것의 형태이다. 전형적으로 볼 때 그것은 쌓아 올리는 것이다. 그것은 점점 더 복잡해지는 구조물을 세우는 데 매몰되어 있다. 또 명료성 조차도 목적 자체가 아니라 이 목적을 이루기 위한 수단으로서 추구된다. 내게는 그와 반대로 명료성, 명석성이 그 자체로서 귀중하다.

 

나는 건물을 쌓는 데는 관심이 없고, 지을 수 있는 건물의 기초를 명료하게 보는 데에 관심이 있다.

 

그러니 내가 겨누는 과녁은 과학자들의 것과 같지 않으며, 나의 사고방식은 그들의 것과 다르다.

 

-비트겐슈타인 <철학적 언급들>의 서문 초고

Posted by extremedb
,