포스트

잘못 박힌 Hash Join 힌트로 만든 44배 차이

한 프랜차이즈의 일본법인 POS 부팅 쿼리. USE_HASH 힌트 8개로 떡칠된 SQL 에서 단 한 줄 USE_HASH(PST PRM) 만 제거하여 옵티마이저를 NL chain + INDEX UNIQUE SCAN 으로 유도. Cost 47% 절감, 실행시간 2.23초 → 0.05초 (44.6배), Physical Reads 3,516 → 0.

잘못 박힌 Hash Join 힌트로 만든 44배 차이

한 프랜차이즈의 일본법인 POS 부팅 쿼리 실측 사례입니다. 본문 변환은 회사 코드를 전부 마스킹했습니다.

들어가며

매장이 오픈 준비를 할 때 POS 단말은 부팅 직후 그 매장의 메뉴와 진행 중인 프로모션 정보를 한 번에 끌어옵니다. 그 한 번의 쿼리가 단말당 2.23초가 걸리고 있었습니다. 단말 한 대만 보면 2초가 큰 시간은 아닙니다. 하지만 매장 하나에 단말이 5대 있고, 일본 전역의 매장이 일제히 오픈 준비를 들어가면 그 2.23초 × 수백 단말이 동시에 DB 세션을 점유하는 모양이 됩니다.

쿼리 자체는 간단하지 않습니다. 프로모션 마스터, 프로모션 메뉴 매핑, 프로모션 적용 매장, 프로모션 채널 네 테이블로 만든 inline view 가 있고, 그 위에 메뉴 가격을 합치는 또 다른 inline view, 그 위에 옵션·세트 정보를 붙이는 outer SELECT 가 얹혀 있습니다.

문제 영역만 따로 떼서 보면 답은 한 줄이었습니다. 개발자가 직접 박아둔 USE_HASH(PST PRM) 한 줄을 빼니까 끝이었습니다. 그 한 줄 제거로 cost 4,942 → 2,617 (47% ↓), 실행시간 2.23초 → 0.05초 (44.6배 향상), physical reads 3,516 → 0 으로 줄었습니다.

이 글은 그 한 줄이 어떻게 그렇게 큰 차이를 만들 수 있었는지, 옵티마이저가 무엇을 못 보고 있었는지, 그리고 왜 hint 를 “더 박는” 게 아니라 “잘못 박힌 것을 빼는” 게 정답이었는지에 대해서 기록하고자 합니다.

“Hash Join 은 대량 처리에 빠르다?”

DBA 가 처음 접하는 조인 알고리즘 교과서는 보통 이렇게 설명합니다. NL Join 은 한 쪽이 작을 때 좋고, Hash Join 은 양쪽이 클 때 좋고, Merge Join 은 양쪽이 이미 정렬돼 있을 때 좋다. 이 룰을 그대로 받아 들이면 “대량 데이터를 다루는 시스템이니까 일단 hash 를 박자” 라는 결론이 너무 쉽게 나옵니다.

원본 쿼리에 박혀 있던 hint 는 정확히 그 결론의 결과물처럼 보였습니다. VW_PROMO inline view 안에 USE_HASH(PMN PRM)USE_HASH(PST PRM) 두 개, VW_MENU_UPC inline view 안에 USE_HASH(B A) 한 개, outer SELECT 에는 USE_HASH( B A) USE_HASH(C A) USE_HASH(D A) USE_HASH(E A) USE_HASH(F A) 다섯 개, 총 여덟 개의 USE_HASH 힌트가 거의 모든 조인에 박혀 있었습니다.

Hash Join 활용은 두 가지를 전제해야 한다고 생각합니다. 첫째, build 쪽(보통 작은 쪽) 이 메모리에 올라갈 수 있을 만큼 충분히 작아야 합니다. 둘째, probe 쪽(보통 큰 쪽) 이 build 쪽보다 훨씬 커서 정렬·인덱스 탐색 비용보다 hash table 직접 lookup 이 압도적으로 싸야 합니다. 이 두 전제가 깨지는 케이스를 생각해 봅시다. 예를 들어 build 쪽 row 가 한 자릿수이고, 자식 쪽에는 PK 가 잘 정의되어 있는 경우입니다. 이런 상황에서는 Hash Join 의 hash table 빌드 비용 자체가 NL 의 PK lookup 9번보다 훨씬 비쌉니다.

여덟 개 중 한 개의 hint 가 정확히 그 함정에 빠져 있었습니다. USE_HASH(PST PRM) 였습니다.

원본 쿼리 일부

쿼리 시작 부분입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
WITH VW_PROMO AS (
  SELECT /*+ USE_HASH(PMN PRM) USE_HASH(PST PRM) gather_plan_statistics dev_001 */
         PRM.CMP_CD, PRM.SALES_ORG_CD, PMN.MENU_CD, PMN.KIOSK_MENU_NM,
         PRM.KIOSK_DCPRC_DISP_YN, PST.STOR_CD,
         ROW_NUMBER() OVER (
           PARTITION BY PRM.CMP_CD, PRM.SALES_ORG_CD, PMN.MENU_CD, PST.STOR_CD
           ORDER BY PRM.PRRTY
         ) RN,
         PMN.PRM_DC_TYPE, PMN.DC_RATE_AMT, PRM.UPD_DATE
    FROM RIMS.PRM_MST PRM
    INNER JOIN RIMS.PRM_MENU_MNG PMN
      ON PMN.CMP_CD = PRM.CMP_CD
     AND PMN.SALES_ORG_CD = PRM.SALES_ORG_CD
     AND PMN.PRM_CD = PRM.PRM_CD
     AND PMN.USE_YN = '1'
    INNER JOIN PRM_APPLY_STOR PST
      ON PST.CMP_CD = PRM.CMP_CD
     AND PST.SALES_ORG_CD = PRM.SALES_ORG_CD
     AND PST.PRM_CD = PRM.PRM_CD
     AND PST.USE_YN = '1'
    INNER JOIN PRM_JOIN_CHL CHL
      ON CHL.CMP_CD = PRM.CMP_CD
     AND CHL.SALES_ORG_CD = PRM.SALES_ORG_CD
     AND CHL.PRM_CD = PRM.PRM_CD
     AND CHL.JOIN_CHL_CD = '02'
   WHERE PRM.USE_YN = '1'
     AND PRM.PRGRS_STATUS = 'C'
     AND PRM.BFPSN_CPN_YN = '0'
     AND PRM.PRC_CLASS = '1'
     AND TO_CHAR(RIMS.FN_GET_SYSDATE('XXX'), 'YYYYMMDD')
           BETWEEN PRM.START_DT AND PRM.FNSH_DT
     AND PRM.CMP_CD = 'XXX'
   GROUP BY PRM.CMP_CD, PRM.SALES_ORG_CD, PMN.MENU_CD, PMN.KIOSK_MENU_NM,
            PRM.KIOSK_DCPRC_DISP_YN, PST.STOR_CD, PRM.PRRTY,
            PMN.PRM_DC_TYPE, PMN.DC_RATE_AMT, PRM.UPD_DATE
),

핵심은 첫 줄의 /*+ USE_HASH(PMN PRM) USE_HASH(PST PRM) ... */ 입니다. 개발자는 PRM_MST 와 PRM_MENU_MNG 사이도 hash 로, PRM_MST 와 PRM_APPLY_STOR 사이도 hash 로 강제했습니다. WHERE 절에는 PRM.PRC_CLASS = '1', PRM.PRGRS_STATUS = 'C' 같은 강력한 필터가 걸려 있어서 PRM_MST 는 단 9건으로 좁혀집니다. 그 9건짜리 부모를 hash 의 build 쪽으로 강제로 밀어 넣는 hint 였던 셈입니다.

원본 실행계획

핵심 구간만 발췌했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
Plan hash value: 4132740607

------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| Id  | Operation                               | Name                   | Starts | E-Rows |E-Bytes| Cost (%CPU)| E-Time   | A-Rows |   A-Time   | Buffers | Reads  |  OMem |  1Mem | Used-Mem |
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT                        |                        |      1 |        |       |  4942 (100)|          |    200 |00:00:02.23 |   13719 |   3516 |       |       |          |
|*  1 |  VIEW                                   |                        |      1 |   6126 |    10M|  4942   (1)| 00:00:01 |    200 |00:00:02.23 |   13719 |   3516 |       |       |          |
|   2 |   COUNT                                 |                        |      1 |        |       |            |          |    200 |00:00:02.23 |   13719 |   3516 |       |       |          |
|*  3 |    FILTER                               |                        |      1 |        |       |            |          |    200 |00:00:02.23 |   13719 |   3516 |       |       |          |
|*  4 |     HASH JOIN RIGHT OUTER               |                        |      1 |   6126 |  2381K|  4942   (1)| 00:00:01 |   1318 |00:00:02.23 |   13719 |   3516 |  1356K|  1356K| 1532K (0)|
|*  5 |      TABLE ACCESS BY INDEX ROWID BATCHED| MST_MENU_SOLDOUT       |      1 |   4229 |   173K|  1220   (0)| 00:00:01 |   5881 |00:00:00.01 |    1969 |      0 |       |       |          |
|*  6 |       INDEX RANGE SCAN                  | MST_MENU_SOLDOUT_IDX01 |      1 |   4238 |       |    18   (0)| 00:00:01 |   5881 |00:00:00.01 |      24 |      0 |       |       |          |
|*  7 |      HASH JOIN RIGHT OUTER              |                        |      1 |   5102 |  1773K|  3722   (1)| 00:00:01 |   1318 |00:00:02.23 |   11750 |   3516 |  1055K|  1055K| 1352K (0)|
|*  8 |       VIEW                              |                        |      1 |      9 |   927 |  2414   (1)| 00:00:01 |     49 |00:00:02.19 |    7245 |   3495 |       |       |          |
|*  9 |        WINDOW NOSORT                    |                        |      1 |      9 |  1269 |  2414   (1)| 00:00:01 |     49 |00:00:02.19 |    7245 |   3495 | 73728 | 73728 |          |
|  10 |         SORT GROUP BY                   |                        |      1 |      9 |  1269 |  2414   (1)| 00:00:01 |     49 |00:00:02.19 |    7245 |   3495 |  6144 |  6144 | 6144  (0)|
|* 11 |          HASH JOIN                      |                        |      1 |      9 |  1269 |  2413   (1)| 00:00:01 |     49 |00:00:02.19 |    7245 |   3495 |  1090K|  1090K|  679K (0)|
|* 12 |           HASH JOIN                     |                        |      1 |      1 |   105 |  2345   (1)| 00:00:01 |      3 |00:00:02.19 |    6997 |   3495 |  1185K|  1185K|  989K (0)|
|  13 |            NESTED LOOPS                 |                        |      1 |      1 |    77 |    17   (0)| 00:00:01 |      9 |00:00:00.01 |    1384 |      0 |       |       |          |
|* 14 |             TABLE ACCESS FULL           | PRM_MST                |      1 |      1 |    57 |    17   (0)| 00:00:01 |      9 |00:00:00.01 |    1373 |      0 |       |       |          |
|* 15 |             INDEX UNIQUE SCAN           | PK_PRM_JOIN_CHL        |      9 |      1 |    20 |     0   (0)|          |      9 |00:00:00.01 |      11 |      0 |       |       |          |
|* 16 |            TABLE ACCESS BY INDEX ROWID  | PRM_APPLY_STOR         |      1 |   2335 | 65380 |  2327   (1)| 00:00:01 |   1994 |00:00:02.26 |    5613 |   3495 |       |       |          |
|* 17 |             INDEX SKIP SCAN             | PK_PRM_APPLY_STOR      |      1 |   2386 |       |  2209   (1)| 00:00:01 |   2006 |00:00:02.13 |    3608 |   3495 |       |       |          |   ← 한 행이 2.13초, Buffers 3,608, Reads 3,495
|* 18 |           TABLE ACCESS FULL             | PRM_MENU_MNG           |      1 |  14201 |   499K|    68   (0)| 00:00:01 |  14602 |00:00:00.01 |     248 |      0 |       |       |          |
|* 19 |       HASH JOIN OUTER                   |                        |      1 |   5102 |  1260K|  1308   (1)| 00:00:01 |   1318 |00:00:00.04 |    4505 |     21 |  1400K|  1067K| 2579K (0)|
                                                                                                                          ⋮
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
(이후 행은 외곽의 HASH JOIN OUTER, HASH JOIN RIGHT OUTER 와 MST_MENU/MST_MENU_PRC 의 full scan 들)

Predicate Information (발췌):
  17 - access("PST"."CMP_CD"='XXX' AND "PST"."SALES_ORG_CD"='1000'
              AND "PST"."STOR_CD"='00000286')
       filter("PST"."STOR_CD"='00000286')

Before plan — Id 17 의 INDEX SKIP SCAN 한 행이 쿼리 시간의 95.5% 를 점유합니다.

한 행에 비용이 어떻게 95% 까지 몰리는가

Top SELECT STATEMENT (Id 0) 의 A-Time 이 00:00:02.23 입니다. 그 아래로 비용을 따라 내려가 보면, Id 17 의 INDEX SKIP SCAN PK_PRM_APPLY_STOR 가 단독으로 A-Time 00:00:02.13, Buffers 3,608, Reads 3,495 를 쓰고 있습니다.

  • 전체 시간 대비: 2.13 / 2.23 = 95.5%
  • 전체 buffers 대비: 3,608 / 13,719 = 26.3%
  • 전체 reads 대비: 3,495 / 3,516 = 99.4%

쿼리가 일으킨 거의 모든 physical I/O 가 이 한 행에서 발생했습니다. 단말이 부팅 호출을 동시에 일으키면 이 SKIP SCAN 의 buffer 폭증이 DB 단의 latch contention 으로 번질 수 있는 상황이었습니다.

단일 operation (Id 17, INDEX SKIP SCAN) 이 쿼리 시간의 95.5% (2.13초 / 2.23초) 와 physical reads 의 99.4% (3,495 / 3,516) 를 차지합니다. POS 단말이 부팅 호출을 동시 발생시키면 이 한 행의 buffer 폭증이 DB 단의 latch contention 으로 번질 수 있습니다.

Hash Join 이 만든 SKIP SCAN

PRM_APPLY_STOR 테이블의 primary key 는 PK_PRM_APPLY_STOR 이고 컬럼 순서는 (CMP_CD, SALES_ORG_CD, PRM_CD, STOR_CD) 입니다. 4컬럼 복합 인덱스입니다.

이 인덱스를 UNIQUE SCAN 또는 RANGE SCAN 으로 쓰려면 옵티마이저가 선두 컬럼부터 차례대로 값을 알고 있어야 합니다. 1번째 (CMP_CD) 를 알면 1컬럼 RANGE, 1~2번째를 알면 2컬럼 RANGE, 1~3번째를 알면 3컬럼 RANGE, 1~4번째 모두 알면 UNIQUE 입니다. 중간 컬럼이 비어 있는데 뒤쪽 컬럼만 알고 있으면, 옵티마이저는 차선책으로 INDEX SKIP SCAN 을 시도합니다. 비어 있는 컬럼의 distinct 값마다 인덱스 sub-tree 를 한 번씩 타고 들어가는, 비싸지만 작동은 하는 접근 방식입니다.

원본 plan 의 Id 17 의 Predicate Information 을 보면 옵티마이저가 PRM_APPLY_STOR 에 들어갈 때 알고 있던 값이 무엇이었는지 정확히 나옵니다.

1
2
3
17 - access("PST"."CMP_CD"='XXX' AND "PST"."SALES_ORG_CD"='1000'
            AND "PST"."STOR_CD"='00000286')
     filter("PST"."STOR_CD"='00000286')

알고 있던 값: CMP_CD, SALES_ORG_CD, STOR_CD 세 컬럼. WHERE 절의 상수 조건들이고, STOR_CD 는 POS 단말이 자신의 매장 번호로 박은 바인드 값입니다. 그런데 PK 의 3번째 컬럼인 PRM_CD 는 알고 있지 않습니다. PRM_MST 와 join 이 일어나는 시점에 PRM_CD 가 따라 들어와야 하는데, hint USE_HASH(PST PRM) 가 그 순서를 막아 놨습니다. Hash Join 은 양쪽을 동시에 빌드/프로브 하는 구조이지 PRM_MST 의 PRM_CD 를 PRM_APPLY_STOR 의 인덱스 진입 키로 전달 하지 않습니다.

요약하면 이런 그림입니다.

1
2
3
4
5
6
7
8
9
PK_PRM_APPLY_STOR
( CMP_CD , SALES_ORG_CD , PRM_CD , STOR_CD )
   ↑           ↑            ↑          ↑
  ='XXX'     ='1000'     ??? 빠짐    ='00000286'
  (상수)      (상수)     (driving 없음)  (바인드)

→ 옵티마이저: 3번째 컬럼 미지수, 4번째 컬럼만 가지고 있음
→ 결정: INDEX SKIP SCAN
→ 결과: A-Rows 2,006 / Buffers 3,608 / Reads 3,495

A-Rows 가 2,006 인 게 인상적입니다. 최종적으로 이 단계에서 살아남는 row 는 본문 위쪽의 NL Join 결과(Id 13~15) 가 만들어낸 9건과 join 되어 단 3건이지만, SKIP SCAN 은 그 9건이 정해지기 전에 PRM_CD 차원으로 인덱스를 한참 휘젓고 다닙니다.

PRM_CD 를 전달 해 줄 수 있는 유일한 방법은 NL Join 입니다. 부모(PRM_MST) 의 각 row 가 자식(PRM_APPLY_STOR) 의 인덱스 진입 키로 풀려서 한 건씩 lookup 하는 구조. PRM_MST 는 WHERE 절 필터(PRC_CLASS='1', PRGRS_STATUS='C', BFPSN_CPN_YN='0', CMP_CD='XXX') 와 날짜 범위로 좁혀져서 단 9건 만 나옵니다. 9건짜리 부모로 NL driving 을 돌리는 비용은 9 × (PK lookup 3블록) = 약 27 블록 정도입니다. Hash Join 의 hash table 빌드 + SKIP SCAN probe 가 쓴 3,608 buffers 와는 100배 차이의 규모입니다.

옵티마이저는 이미 이 사실을 알고 있었습니다. cost 모델을 돌려 보면 NL + UNIQUE_SCAN 쪽이 훨씬 싸다는 결론이 나옵니다. 다만 개발자가 박아둔 USE_HASH(PST PRM) 가 그 결론을 강제로 막고 있었을 뿐입니다.

수정

원본과 수정본은 힌트 제거 뿐입니다.

1
2
- SELECT /*+ USE_HASH(PMN PRM) USE_HASH(PST PRM) */
+ SELECT /*+ USE_HASH(PMN PRM) */

USE_HASH(PMN PRM) 는 그대로 남겼습니다. 그 hint 가 막고 있던 조인은 PRM_MST 와 PRM_MENU_MNG 사이로, Hash 가 실제로 적합한 케이스이기 때문입니다 (PRM_MENU_MNG 는 14,201 rows). 외곽의 HASH JOIN RIGHT OUTER, HASH JOIN OUTER 같은 다른 큰 조인들도 손대지 않았습니다. 비효율 핵심 한 군데, USE_HASH(PST PRM) 한 줄만 제거했습니다.

USE_NL 힌트를 추가 하지도 않았습니다. INDEX 힌트로 강제하지도 않았습니다. 그저 잘못 박힌 hint 하나를 빼는 것만으로 옵티마이저가 “그러면 NL 이 더 싸다” 는 자기 결론을 따라가도록 길을 열어줬을 뿐입니다.

수정 실행계획

같은 구간(Id 0~19) 을 발췌해 비교 가능하게 두었습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
Plan hash value: 47371715

---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| Id  | Operation                               | Name                   | Starts | E-Rows |E-Bytes| Cost (%CPU)| E-Time   | A-Rows |   A-Time   | Buffers |  OMem |  1Mem | Used-Mem |
---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT                        |                        |      1 |        |       |  2617 (100)|          |    200 |00:00:00.05 |    8129 |       |       |          |
|*  1 |  VIEW                                   |                        |      1 |   6126 |    10M|  2617   (1)| 00:00:00 |    200 |00:00:00.05 |    8129 |       |       |          |
|   2 |   COUNT                                 |                        |      1 |        |       |            |          |    200 |00:00:00.05 |    8129 |       |       |          |
|*  3 |    FILTER                               |                        |      1 |        |       |            |          |    200 |00:00:00.05 |    8129 |       |       |          |
|*  4 |     HASH JOIN RIGHT OUTER               |                        |      1 |   6126 |  2381K|  2617   (1)| 00:00:00 |   1318 |00:00:00.05 |    8129 |  1356K|  1356K| 1468K (0)|
|*  5 |      TABLE ACCESS BY INDEX ROWID BATCHED| MST_MENU_SOLDOUT       |      1 |   4229 |   173K|  1220   (0)| 00:00:00 |   5881 |00:00:00.01 |    1969 |       |       |          |
|*  6 |       INDEX RANGE SCAN                  | MST_MENU_SOLDOUT_IDX01 |      1 |   4238 |       |    18   (0)| 00:00:00 |   5881 |00:00:00.01 |      24 |       |       |          |
|*  7 |      HASH JOIN RIGHT OUTER              |                        |      1 |   5102 |  1773K|  1396   (1)| 00:00:00 |   1318 |00:00:00.04 |    6160 |  1055K|  1055K| 1324K (0)|
|*  8 |       VIEW                              |                        |      1 |      6 |   618 |    88   (2)| 00:00:00 |     49 |00:00:00.01 |    1655 |       |       |          |
|*  9 |        WINDOW NOSORT                    |                        |      1 |      6 |   846 |    88   (2)| 00:00:00 |     49 |00:00:00.01 |    1655 | 73728 | 73728 |          |
|  10 |         SORT GROUP BY                   |                        |      1 |      6 |   846 |    88   (2)| 00:00:00 |     49 |00:00:00.01 |    1655 |  6144 |  6144 | 6144  (0)|
|* 11 |          HASH JOIN                      |                        |      1 |      6 |   846 |    87   (0)| 00:00:00 |     49 |00:00:00.01 |    1655 |  1090K|  1090K|  662K (0)|
|  12 |           NESTED LOOPS                  |                        |      1 |      1 |   105 |    19   (0)| 00:00:00 |      3 |00:00:00.01 |    1407 |       |       |          |   ← NL chain 시작
|  13 |            NESTED LOOPS                 |                        |      1 |      1 |   105 |    19   (0)| 00:00:00 |      3 |00:00:00.01 |    1404 |       |       |          |
|  14 |             NESTED LOOPS                |                        |      1 |      1 |    77 |    17   (0)| 00:00:00 |      9 |00:00:00.01 |    1384 |       |       |          |
|* 15 |              TABLE ACCESS FULL          | PRM_MST                |      1 |      1 |    57 |    17   (0)| 00:00:00 |      9 |00:00:00.01 |    1373 |       |       |          |
|* 16 |              INDEX UNIQUE SCAN          | PK_PRM_JOIN_CHL        |      9 |      1 |    20 |     0   (0)|          |      9 |00:00:00.01 |      11 |       |       |          |
|* 17 |             INDEX UNIQUE SCAN           | PK_PRM_APPLY_STOR      |      9 |      1 |       |     1   (0)| 00:00:00 |      3 |00:00:00.01 |      20 |       |       |          |   ← UNIQUE SCAN, Buffers 20
|* 18 |            TABLE ACCESS BY INDEX ROWID  | PRM_APPLY_STOR         |      3 |      1 |    28 |     2   (0)| 00:00:00 |      3 |00:00:00.01 |       3 |       |       |          |
|* 19 |           TABLE ACCESS FULL             | PRM_MENU_MNG           |      1 |  14201 |   499K|    68   (0)| 00:00:00 |  14602 |00:00:00.01 |     248 |       |       |          |
                                                                                                                       ⋮
---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
(이후 행은 Before 와 동일한 외곽 구조)

Predicate Information (발췌):
  17 - access("PST"."CMP_CD"='XXX' AND "PST"."SALES_ORG_CD"='1000'
              AND "PST"."PRM_CD"="PRM"."PRM_CD"
              AND "PST"."STOR_CD"='00000286')

After plan — Id 12, 13, 14 의 3단 NESTED LOOPS 체인이 PRM_MST → PRM_JOIN_CHL → PRM_APPLY_STOR → PRM_MENU_MNG 순으로 PK 를 타고 진행합니다.

원본의 Id 12 HASH JOIN 자리가 세 개의 NESTED LOOPS (Id 12, 13, 14) 체인으로 바뀌었습니다. 그리고 Id 17INDEX SKIP SCANINDEX UNIQUE SCAN 으로 바뀌었습니다. Predicate Information 한 줄에 모든 메커니즘이 적혀 있습니다.

원본 Predicate 와 수정 Predicate 의 차이는 정확히 한 항목입니다.

1
2
3
4
  17 - access("PST"."CMP_CD"='XXX' AND "PST"."SALES_ORG_CD"='1000'
+             AND "PST"."PRM_CD"="PRM"."PRM_CD"
              AND "PST"."STOR_CD"='00000286')
- filter("PST"."STOR_CD"='00000286')

"PST"."PRM_CD"="PRM"."PRM_CD" 가 추가됐습니다. 옵티마이저가 PRM_MST 의 PRM_CD 를 NL 의 driving 으로 받아 PRM_APPLY_STOR 의 PK 3번째 컬럼을 채웠습니다. 4컬럼 PK 가 모두 채워졌으니 SKIP SCAN 이 아니라 UNIQUE SCAN. Starts = 9 (NL 의 9 row 가 한 번씩 호출), 한 번 호출당 buffers 약 2 블록, 총 buffers 20.

수치 비교

ALLSTATS LAST 출력 결과 비교입니다.

Hash Join → NL Join 유도 시 4개 지표 비교 (Before = 100%)

전체 쿼리 수준

지표Before (Hash)After (NL)변화변화율
Plan hash value413274060747371715
Cost (CPU%)4,942 (100%)2,617 (100%)−2,325−47.04%
A-Time (실측)00:00:02.2300:00:00.05−2.18초44.6배 향상
Buffers (logical reads 총)13,7198,129−5,590−40.75%
Reads (physical reads)3,5160−3,516100% 제거

핵심 Operation 비교

항목Before (Id 17, SKIP SCAN)After (Id 17, UNIQUE SCAN)변화
접근 방식INDEX SKIP SCANINDEX UNIQUE SCAN종류 자체가 바뀜
Starts19NL 의 driving row 수와 일치
Cost2,20912,209배 ↓
A-Rows2,0063668배 ↓ (불필요 스캔 제거)
A-Time00:00:02.1300:00:00.01213배 ↓
Buffers3,60820180.4배 ↓
Reads (physical)3,4950100% 제거

결론

변환의 본질이 hint 를 더 박는 게 아니라 hint 를 덜 박는 데 있었습니다. USE_HASH 가 8개에서 7개로 줄었을 뿐, USE_NL 도 INDEX 힌트도 추가되지 않았습니다. 옵티마이저는 처음부터 NL + UNIQUE_SCAN 이 더 싸다는 cost 결론을 내릴 수 있었지만, USE_HASH(PST PRM) 한 줄이 그 결론을 강제로 막고 있었습니다.

Hash Join 이 빠른 것이 아니라, 조인 양쪽 카디널리티가 그 모양일 때 Hash 가 빠른 것입니다. hint 가 옵티마이저의 옳은 결론을 막고 있지 않은지에 대한 확인부터가 튜닝의 출발점인 것 같습니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.