EXPLAIN PLAN FOR
SELECT o.order_id, c.customer_nm
FROM orders o
JOIN customer c ON c.customer_id = o.customer_id
WHERE o.order_dt >= SYSDATE - 7;
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY);가볍게 확인할 때 씁니다. 운영에서 실행하기 부담스러운 쿼리의 계획을 미리 볼 때 유용합니다.
DB·SQL 실무 가이드 · Part 7
느린 이유를 추측하지 않고 근거로 확인하기
작성 기준2026년 7월버전과 지원 현황은 이후 달라질 수 있으니 공식 문서를 함께 확인하세요.
이 파트에서 다루는 내용
쿼리가 느릴 때 코드를 눈으로 보며 원인을 짐작하는 것은 대부분 빗나갑니다. 옵티마이저가 실제로 어떤 경로를 골랐는지는 실행계획을 봐야 알 수 있습니다.
실행계획을 읽을 줄 알면 튜닝이 시행착오에서 진단으로 바뀝니다. 이 파트는 트랙 B에서 가장 중요합니다.
EXPLAIN PLAN은 쿼리를 실행하지 않고 옵티마이저가 세울 계획만 보여 줍니다. 빠르고 안전하지만 실제와 다를 수 있습니다. 바인드 변수 값이나 실행 시점의 상황이 반영되지 않기 때문입니다.
실제로 무엇이 일어났는지 보려면 쿼리를 실행한 뒤 그 커서의 계획을 조회해야 합니다. 튜닝할 때는 이쪽을 봅니다.
EXPLAIN PLAN FOR
SELECT o.order_id, c.customer_nm
FROM orders o
JOIN customer c ON c.customer_id = o.customer_id
WHERE o.order_dt >= SYSDATE - 7;
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY);가볍게 확인할 때 씁니다. 운영에서 실행하기 부담스러운 쿼리의 계획을 미리 볼 때 유용합니다.
-- 통계를 수집하도록 힌트를 붙여 실제로 실행
SELECT /*+ GATHER_PLAN_STATISTICS */
o.order_id, c.customer_nm
FROM orders o
JOIN customer c ON c.customer_id = o.customer_id
WHERE o.order_dt >= SYSDATE - 7;
-- 직전에 실행한 커서의 실제 계획
SELECT * FROM TABLE(
DBMS_XPLAN.DISPLAY_CURSOR(NULL, NULL, 'ALLSTATS LAST')
);ALLSTATS LAST 옵션이 핵심입니다. 예상 건수와 실제 건수를 나란히 보여 줍니다. V$ 뷰 조회 권한이 필요해서 Part 1에서 SELECT_CATALOG_ROLE 을 부여했습니다.
SET AUTOTRACE ON EXPLAIN STATISTICS
SET LINESIZE 200
SELECT COUNT(*) FROM orders WHERE status_cd = 'DONE';
SET AUTOTRACE OFFAUTOTRACE 는 PLUSTRACE 롤이 필요합니다. 계획과 함께 읽은 블록 수(consistent gets)를 보여 주는데, 이 수치가 튜닝 전후를 비교하는 가장 확실한 지표입니다. 실행 시간은 서버 부하에 따라 흔들리지만 블록 수는 일정합니다.
실행계획은 트리 구조를 들여쓰기로 표현한 것입니다. 읽는 순서에 규칙이 있습니다.
가장 깊이 들여쓰기된 것부터, 같은 깊이면 위에서 아래로 읽습니다. 안쪽 단계의 결과가 바깥 단계의 입력이 됩니다.
-------------------------------------------------------------------
| Id | Operation | Name | Rows | Cost |
-------------------------------------------------------------------
| 0 | SELECT STATEMENT | | 1200 | 340 |
| 1 | NESTED LOOPS | | 1200 | 340 |
| 2 | TABLE ACCESS BY INDEX ROWID| ORDERS | 1200 | 120 |
|* 3 | INDEX RANGE SCAN | IDX_ORDERS_DT | 1200 | 8 |
| 4 | TABLE ACCESS BY INDEX ROWID| CUSTOMER | 1 | 1 |
|* 5 | INDEX UNIQUE SCAN | PK_CUSTOMER | 1 | 0 |
-------------------------------------------------------------------
읽는 순서: 3 -> 2 -> 5 -> 4 -> 1 -> 0
3. IDX_ORDERS_DT 인덱스를 범위 스캔해 1200건의 ROWID를 얻는다
2. 그 ROWID로 ORDERS 테이블에서 실제 행을 읽는다
5. 각 행의 customer_id 로 PK_CUSTOMER 를 찾는다 (1200번 반복)
4. 찾은 ROWID로 CUSTOMER 테이블 행을 읽는다
1. 둘을 NESTED LOOPS 로 결합한다* 표시가 붙은 단계는 아래 Predicate Information 에 조건이 나옵니다. access 는 인덱스로 찾아간 조건, filter 는 읽은 뒤 걸러낸 조건입니다. filter 로 많이 버려지고 있다면 인덱스 설계를 다시 봅니다.
앞 테이블의 각 행마다 뒤 테이블을 찾습니다. 앞의 결과가 적고 뒤에 좋은 인덱스가 있을 때 빠릅니다. 앞이 많으면 반복 횟수가 폭증합니다.
작은 쪽으로 해시 테이블을 만들고 큰 쪽을 훑으며 맞춥니다. 대량 데이터 조인의 기본이고 등호 조건에서만 쓸 수 있습니다.
양쪽을 정렬한 뒤 나란히 병합합니다. 이미 정렬돼 있거나 부등호 조인일 때 선택됩니다. 실무에서 보는 빈도는 낮습니다.
대량 조인에 NESTED LOOPS 가 잡혔다면 옵티마이저가 앞 테이블 건수를 적게 예상한 것입니다. 통계정보를 먼저 의심합니다.
옵티마이저는 통계정보를 근거로 각 단계에서 몇 건이 나올지 예상하고, 그 예상을 바탕으로 조인 방식과 접근 경로를 정합니다.
그래서 예상이 크게 빗나가면 계획 전체가 잘못됩니다. 10건 나올 줄 알고 NESTED LOOPS 를 골랐는데 실제로 10만 건이면 그 조인은 10만 번 반복됩니다.
ALLSTATS LAST 로 본 계획에는 E-Rows(예상)와 A-Rows(실제)가 나란히 나옵니다. 두 값이 자릿수 단위로 벌어지는 단계가 있으면 거기가 원인입니다.
---------------------------------------------------------------
| Id | Operation | Name | E-Rows | A-Rows | A-Time |
---------------------------------------------------------------
| 1 | NESTED LOOPS | | 12 | 84210 | 00:00:47|
|* 2 | TABLE ACCESS FULL | ORDERS | 12 | 84210 | 00:00:01|
|* 3 | INDEX UNIQUE SCAN | PK_CUST | 1 | 1 | 00:00:46|
---------------------------------------------------------------
2번 단계: 12건을 예상했지만 실제 84,210건
-> 그 예상 때문에 NESTED LOOPS 를 선택
-> 3번 단계가 84,210번 반복되며 47초 소요이 경우 힌트로 HASH JOIN 을 강제하기 전에, 왜 12건으로 예상했는지를 먼저 봅니다. 통계가 오래됐거나, 조건이 복잡해 옵티마이저가 추정하지 못한 것입니다. 원인을 고치는 편이 오래갑니다.
힌트로 조인 방식이나 인덱스를 강제할 수 있습니다. 당장 급한 장애를 막을 때는 유효한 카드입니다.
다만 힌트는 그 시점의 데이터 분포를 전제로 굳혀 놓는 것입니다. 데이터가 늘거나 분포가 바뀌면 그 힌트가 오히려 발목을 잡습니다. 인덱스명을 박아 둔 힌트는 인덱스가 바뀌면 조용히 무시됩니다.
순서는 이렇습니다. 통계 확인, 쿼리 형태 개선, 인덱스 설계 검토, 그래도 안 되면 힌트입니다.
-- 특정 인덱스 사용
SELECT /*+ INDEX(o IDX_ORDERS_DT) */ * FROM orders o WHERE ...;
-- 전체 스캔 강제
SELECT /*+ FULL(o) */ * FROM orders o WHERE ...;
-- 조인 방식 지정
SELECT /*+ USE_HASH(o c) */ ... FROM orders o JOIN customer c ...;
-- 조인 순서 고정
SELECT /*+ LEADING(c o) */ ... FROM orders o JOIN customer c ...;
-- 결과 일부만 빨리 (온라인 조회 화면)
SELECT /*+ FIRST_ROWS(50) */ ... FROM orders WHERE ...;힌트의 별칭은 쿼리에서 쓴 별칭과 정확히 같아야 합니다. 다르면 힌트가 조용히 무시되고 아무 오류도 안 납니다. 힌트를 넣은 뒤에는 반드시 실행계획으로 적용 여부를 확인합니다.
Tibero는 Oracle 힌트 문법을 대부분 지원하지만, 옵티마이저가 같은 계획을 세운다는 보장은 없습니다. 문법이 통해도 실행계획은 반드시 다시 확인합니다. 이관 직후에는 잘 돌다가 데이터가 쌓인 뒤 특정 쿼리만 느려지는 형태로 나타나는 경우가 있습니다.