DB Study

⚡ 쿼리 튜닝 팁 모음

현장에서 겪는 느린 쿼리의 8할은 몇 가지 패턴의 반복입니다. before/after 코드 쌍으로 정리한 Oracle 튜닝 실전 치트시트 — 필요할 때 바로 찾아 쓰는 빠른 참조 페이지입니다.

중급 ⏱ 약 40분 (참조용) 🎬 애니메이션 2개 선수 지식: 9~12장
🧭
이 페이지의 구성

① 실행계획으로 문제 찾기(E-Rows vs A-Rows) ② 인덱스를 못 타는 6대 원인과 수정법 ③ 바인드 변수 ④ 조인 튜닝 ⑤ 페이지네이션 ⑥ 대량 작업 ⑦ 통계 관리 ⑧ 자주 쓰는 진단 쿼리 — 각 팁은 before(느림) → after(빠름) 코드 쌍으로 되어 있습니다.

🥇 제1원칙

추측하지 말고 실행계획과 대기 이벤트로 측정한다. 느낌으로 인덱스부터 만들지 않는다.

🥈 제2원칙

인덱스 컬럼은 가공하지 않는다. 형변환·함수·연산이 붙는 순간 인덱스는 무용지물.

🥉 제3원칙

통계가 곧 플랜이다. E-Rows와 A-Rows가 다르면 SQL보다 통계를 먼저 의심한다.

팁 1. 튜닝의 시작은 실행계획 — E-Rows vs A-Rows

느린 SQL을 받았을 때 첫 동작은 언제나 같습니다: 실제 실행 통계가 붙은 실행계획을 봅니다. 예상 계획(EXPLAIN PLAN)이 아니라 실제입니다.

-- ① 문제 SQL에 힌트를 붙여 실행 (또는 ALTER SESSION SET statistics_level=ALL)
SELECT /*+ GATHER_PLAN_STATISTICS */ o.order_no, c.cust_name
FROM   orders o JOIN customers c ON o.cust_id = c.cust_id
WHERE  o.order_status = 'SHIPPED';

-- ② 방금 실행한 커서의 "실제" 계획 조회
SELECT * FROM TABLE(
  DBMS_XPLAN.display_cursor(NULL, NULL, 'ALLSTATS LAST')
);

-- ③ 출력에서 볼 것: E-Rows(예측) vs A-Rows(실제)
--------------------------------------------------------------------
| Id | Operation                    | Name   | E-Rows | A-Rows |
--------------------------------------------------------------------
|  1 |  NESTED LOOPS                |        |     12 |   985K | ← 격차!
|* 2 |   TABLE ACCESS FULL          | ORDERS |     12 |   985K | ← 원흉
|  3 |   TABLE ACCESS BY INDEX ROWID| CUSTO..|      1 |   985K |
--------------------------------------------------------------------

읽는 법은 세 줄로 요약됩니다.

💡
Buffers 컬럼도 함께

ALLSTATS LAST 출력의 Buffers는 단계별 논리 읽기(블록 수)입니다. "어느 단계가 I/O를 다 먹었나"를 A-Rows와 함께 보면 병목 단계가 한눈에 드러납니다. 실행 순서 읽는 법은 10장 참고.

팁 2. 인덱스를 못 타는 6대 원인 — before / after

"인덱스가 있는데 풀스캔이에요"의 원인은 놀랄 만큼 정형화되어 있습니다. 아래 6개 패턴이 대부분을 커버합니다.

2-1. 암시적 형변환 — 조용한 인덱스 킬러 1위

문자 컬럼을 숫자와 비교하면 Oracle은 컬럼 쪽에 TO_NUMBER()를 씌워 비교합니다. 컬럼이 가공되는 순간 그 컬럼의 인덱스는 쓸 수 없습니다. 눈에 안 보이게 일어나므로 가장 위험합니다.

❌ before WHERE cust_no = 100 (cust_no는 VARCHAR2) WHERE TO_NUMBER(cust_no) = 100 Oracle이 몰래 컬럼을 가공한다 🌲 cust_no 인덱스 사용 불가 🚫 TABLE ACCESS FULL 1000만 행 전부 변환·비교 응답 8초 · CPU 급증 ✅ after WHERE cust_no = '100' ← 타입 일치 컬럼이 가공되지 않음 = 인덱스 사용 가능 🌲 INDEX RANGE SCAN 블록 3~4개만 읽음 BY INDEX ROWID 해당 행만 접근 응답 0.01초 — SQL 한 글자('')가 만든 800배 차이
-- ❌ before: cust_no VARCHAR2(10) — 숫자와 비교
SELECT * FROM customers WHERE cust_no = 100;
--   → filter(TO_NUMBER("CUST_NO")=100) : TABLE ACCESS FULL

-- ✅ after: 타입을 맞춘다
SELECT * FROM customers WHERE cust_no = '100';
--   → access("CUST_NO"='100') : INDEX RANGE SCAN
🚨
Predicate Information을 반드시 본다

DBMS_XPLAN 출력 하단의 Predicate Information에서 filter(TO_NUMBER(...)), INTERNAL_FUNCTION(...)이 보이면 암시적 형변환이 일어난 것입니다. 실행계획 본문만 보고 지나치기 쉬운, 그러나 가장 확실한 물증입니다.

2-2. 컬럼 가공 — 함수는 컬럼이 아니라 상수 쪽에

-- ❌ before: 인덱스 컬럼을 SUBSTR로 가공
SELECT * FROM orders WHERE SUBSTR(order_no, 1, 4) = '2026';
-- ✅ after: 컬럼은 그대로 두고 LIKE 범위 조건으로
SELECT * FROM orders WHERE order_no LIKE '2026%';

-- ❌ before: 날짜 컬럼을 TRUNC로 가공 (당일 주문 조회)
SELECT * FROM orders WHERE TRUNC(order_dt) = DATE '2026-07-11';
-- ✅ after: 컬럼은 그대로, 조건을 범위로 변형
SELECT * FROM orders
WHERE  order_dt >= DATE '2026-07-11'
AND    order_dt <  DATE '2026-07-11' + 1;

원칙은 하나입니다: 가공은 상수/변수 쪽에서 하고, 컬럼은 날것으로 남긴다. 도저히 컬럼 가공을 피할 수 없는 요건(대소문자 무시 검색 등)이라면 함수기반 인덱스가 차선책입니다. 참고로 파티션 키 가공이 pruning을 깨는 것(11장)도 정확히 같은 원리입니다.

2-3. 선행 와일드카드 LIKE '%…'

-- ❌ before: 시작점이 없어 B*Tree 탐색 불가 → FULL SCAN
SELECT * FROM customers WHERE cust_name LIKE '%길동';

-- ✅ after 1: 가능하다면 전방 일치로 요건 변경
SELECT * FROM customers WHERE cust_name LIKE '홍길%';
-- ✅ after 2: 부분 일치 검색이 핵심 요건이면 Oracle Text 인덱스 등 전문 검색 도구를 검토

B*Tree는 "왼쪽부터 정렬"된 자료구조입니다. 첫 글자를 모르면 탐색 시작점을 정할 수 없습니다. 중간/후방 일치가 진짜 요건이라면 B*Tree로 해결하려 하지 말고 전문 검색(Oracle Text, 또는 Elasticsearch 같은 별도 엔진)을 검토하세요.

2-4. NOT / != — 부정형은 범위를 못 좁힌다

-- ❌ before: "SHIPPED가 아닌 것" = 나머지 전부 → 인덱스로 좁힐 수 없음
SELECT * FROM orders WHERE order_status != 'SHIPPED';

-- ✅ after: 실제로 원하는 값을 긍정형으로 나열
SELECT * FROM orders WHERE order_status IN ('NEW', 'PAYING', 'PACKING');

부정형 자체가 문법 위반은 아닙니다 — 문제는 "제외한 나머지 전부"가 대개 대부분의 행이라서 인덱스가 의미를 잃는다는 점입니다. 반대로 원하는 값 목록이 소수이고 선택도가 높다면 IN으로 뒤집는 순간 인덱스가 살아납니다.

2-5. OR 조건 — UNION ALL로 분리하거나 IN으로

-- ❌ before: 서로 다른 컬럼의 OR — 옵티마이저가 풀스캔을 택하기 쉬움
SELECT * FROM orders
WHERE  cust_id = :cid OR order_no = :ono;

-- ✅ after: 각각 자기 인덱스를 타도록 UNION ALL로 분리
SELECT * FROM orders WHERE cust_id = :cid
UNION ALL
SELECT * FROM orders WHERE order_no = :ono AND (cust_id != :cid OR cust_id IS NULL);

-- 같은 컬럼의 OR라면 그냥 IN으로 (인덱스 정상 사용)
SELECT * FROM orders WHERE order_status IN ('NEW', 'PAYING');
⚠️
UNION ALL 분리 시 중복 주의

두 조건을 모두 만족하는 행이 두 번 나오지 않도록, 위 예시처럼 두 번째 블록에서 첫 조건을 배제하는 술어를 넣어야 합니다(결과 요건에 따라 UNION으로 중복 제거하는 방법도 있으나 정렬 비용이 추가됩니다).

2-6. 통계 부족 — 인덱스가 아니라 지도가 문제

-- 증상: SQL도 인덱스도 멀쩡한데 풀스캔/이상한 조인 선택
-- 확인: 통계 수집 시점과 실제 행 수 비교
SELECT table_name, num_rows, last_analyzed
FROM   user_tables WHERE table_name = 'ORDERS';

SELECT COUNT(*) FROM orders;   -- num_rows와 크게 다르면 → 팁 7로

팁 3. 바인드 변수 — 하드파싱 폭탄 방지

Oracle은 SQL 텍스트가 완전히 같아야 공유 풀의 커서(파싱 결과 + 실행계획)를 재사용합니다. 값을 문자열로 이어붙인 리터럴 SQL은 값마다 다른 SQL이 되어, 실행할 때마다 하드 파싱(최적화 전 과정)을 반복합니다.

❌ 리터럴 폭탄 ✅ 바인드 변수 WHERE cust_id = 100 WHERE cust_id = 101 WHERE cust_id = 102 … ×10,000 텍스트가 전부 다름 → 하드 파싱 ×10,000 공유 풀에 일회용 커서 1만 개 적체 CPU 소모 + 라이브러리 캐시 래치 경합 📈 WHERE cust_id = :cid 값이 바뀌어도 텍스트는 하나 하드 파싱 1번 + 소프트 파싱 9,999번 커서 1개 재사용 — 공유 풀 평화 OLTP 다건 실행 SQL의 기본기 단, 분포가 심하게 치우친 컬럼은 bind peeking 부작용 주의 (아래 콜아웃)
⚠️
예외: 데이터 skew + bind peeking

값 분포가 극단적으로 치우친 컬럼(예: 상태값의 99%가 '완료')은 첫 실행 값 기준으로 만든 플랜이 캐시되어(bind peeking) 다른 값에서 재앙이 될 수 있습니다. 이런 컬럼은 히스토그램을 수집해 Adaptive Cursor Sharing이 동작할 조건을 만들거나, 값 유형별로 SQL을 분리하는 것도 실무적 해법입니다. 소수의 무거운 배치/리포트 SQL은 애초에 리터럴이 나을 수 있습니다 — "다건 반복 실행 SQL에 바인드"가 정확한 원칙입니다. (10장 10.7절)

팁 4. 조인 튜닝 요점 — 작은 집합을 먼저, 방법은 규모로

조인 튜닝의 대원칙 두 가지:

-- ❌ before: 1000만 건 orders를 driving으로 NL → 인덱스 탐색 1000만 회
SELECT o.order_no, c.cust_name
FROM   orders o, customers c
WHERE  o.cust_id = c.cust_id
AND    c.vip_grade = 'PLATINUM';   -- 플래티넘 고객은 단 50명!

-- ✅ after: "50명"이 driving이 되도록 힌트로 유도 (통계가 정확하면 대개 자동 선택)
SELECT /*+ LEADING(c) USE_NL(o) INDEX(o orders_cust_id_ix) */
       o.order_no, c.cust_name
FROM   customers c, orders o
WHERE  o.cust_id = c.cust_id
AND    c.vip_grade = 'PLATINUM';
-- 고객 50명 → 각자의 주문만 인덱스로 — NL 반복이 50회로 축소

-- 대량 결합(월 배치 등)이라면 반대로 Hash를 명시할 수도:
--   /*+ LEADING(c) USE_HASH(o) FULL(o) */
💡
힌트보다 먼저 물어볼 것

"왜 옵티마이저는 스스로 그 순서를 안 골랐지?" — 답은 대부분 E-Rows 오차(통계·히스토그램)입니다. 통계를 고치면 힌트 없이도 올바른 플랜이 나오고, 데이터가 변해도 스스로 적응합니다. 힌트는 원인 규명 후의 마지막 수단 + 긴급 처방용으로 아껴두세요.

팁 5. 페이지네이션 — 정렬을 인덱스에게 맡겨라

목록 화면의 "1~20번째만"을 구현하는 두 가지 방법:

-- 전통 패턴 (11g 이하): ROWNUM 이중 래핑
SELECT * FROM (
  SELECT a.*, ROWNUM rn FROM (
    SELECT order_no, order_dt, amount
    FROM   orders
    ORDER BY order_dt DESC          -- 정렬 먼저!
  ) a
  WHERE ROWNUM <= 20                 -- 상위 20건에서 컷 (COUNT STOPKEY)
)
WHERE rn > 10;                       -- 11~20건만

-- 12c+ 표준 문법: OFFSET / FETCH (내부적으로 유사하게 변환됨)
SELECT order_no, order_dt, amount
FROM   orders
ORDER BY order_dt DESC
OFFSET 10 ROWS FETCH NEXT 10 ROWS ONLY;

진짜 성능 포인트는 문법이 아니라 정렬 생략입니다. ORDER BY 컬럼에 인덱스가 있으면 옵티마이저는 인덱스를 정렬된 순서 그대로 읽다가 20건에서 멈춥니다(실행계획에 SORT 없이 COUNT STOPKEY). 인덱스가 없으면? 조건에 맞는 전체를 정렬한 뒤 20건을 버리는, 페이지가 뒤로 갈수록/데이터가 쌓일수록 느려지는 쿼리가 됩니다.

⚠️
ROWNUM의 함정 — 정렬 전에 자르지 마라

WHERE ROWNUM <= 20 ORDER BY order_dt처럼 한 블록에 쓰면 아무 행이나 20건을 먼저 뽑고 나서 그 20건을 정렬합니다. 결과 자체가 틀립니다. ROWNUM은 반드시 정렬이 끝난 인라인 뷰 바깥에서 적용해야 하며, 위의 이중 래핑이 바로 그 이유입니다. 또한 깊은 페이지(OFFSET 10000)는 어차피 1만 건을 읽고 버리므로, 무한 스크롤에는 "마지막 값 기준" 조건(WHERE order_dt < :last_dt) 방식이 낫습니다.

팁 6. 대량 작업 — 건별 처리를 버려라

6-1. 커밋은 모아서

-- ❌ before: 루프 안 건별 커밋 → log file sync 폭탄 (12장 사례 그대로)
FOR r IN (SELECT ...) LOOP
  UPDATE ...; COMMIT;
END LOOP;

-- ✅ after: N건 단위 배치 커밋 (또는 작업 전체에 1회)
FOR r IN (SELECT ...) LOOP
  UPDATE ...;
  cnt := cnt + 1;
  IF MOD(cnt, 10000) = 0 THEN COMMIT; END IF;
END LOOP;
COMMIT;

6-2. INSERT는 direct-path로

-- ✅ APPEND 힌트: 버퍼 캐시를 우회해 HWM 위에 블록을 통째로 기록
INSERT /*+ APPEND */ INTO sales_hist
SELECT * FROM sales WHERE sale_date < DATE '2026-01-01';
COMMIT;   -- direct-path 후에는 커밋 전까지 같은 세션도 그 테이블 조회 불가

direct-path insert는 기존 빈 공간을 재활용하지 않고 HWM(High Water Mark) 위에 이어 쓰는 대신, 블록 단위 기록으로 대량 적재가 훨씬 빠릅니다. 대상 테이블에는 배타적 락이 걸려 동시 DML과 공존할 수 없으니 배치 전용 기법입니다.

6-3. PL/SQL은 BULK COLLECT / FORALL

-- ❌ before: 행마다 SQL엔진↔PL/SQL엔진을 오가는 컨텍스트 스위치 100만 번
FOR r IN (SELECT id, amt FROM src) LOOP
  INSERT INTO dst VALUES (r.id, r.amt);
END LOOP;

-- ✅ after: 배열로 묶어서 왕복 횟수를 1만분의 1로
DECLARE
  TYPE t_rows IS TABLE OF src%ROWTYPE;
  v_rows t_rows;
  CURSOR c IS SELECT * FROM src;
BEGIN
  OPEN c;
  LOOP
    FETCH c BULK COLLECT INTO v_rows LIMIT 10000;  -- 1만 건씩 메모리로
    EXIT WHEN v_rows.COUNT = 0;
    FORALL i IN 1..v_rows.COUNT                     -- 1만 건을 한 번에 INSERT
      INSERT INTO dst VALUES v_rows(i);
    COMMIT;
  END LOOP;
  CLOSE c;
END;
/
💡
그 전에 — SQL 한 문장으로 되지 않는가?

가장 빠른 PL/SQL 루프는 루프를 없앤 것입니다. INSERT ... SELECT, MERGE, UPDATE ... WHERE EXISTS 한 문장으로 표현 가능한 로직이라면 그것이 항상 1등입니다. BULK COLLECT는 "행별 절차 로직이 정말 필요할 때"의 차선책입니다.

팁 7. 통계 관리 — DBMS_STATS 기본기와 히스토그램

-- 기본형: 대량 변경 배치의 마지막 단계에 넣어두면 사고가 준다
BEGIN
  DBMS_STATS.GATHER_TABLE_STATS(
    ownname          => USER,
    tabname          => 'ORDERS',
    cascade          => TRUE,                          -- 인덱스 포함
    method_opt       => 'FOR ALL COLUMNS SIZE AUTO',   -- 히스토그램 자동 판단
    estimate_percent => DBMS_STATS.AUTO_SAMPLE_SIZE     -- 샘플링도 자동
  );
END;
/

-- 특정 skew 컬럼에 히스토그램을 명시적으로 만들고 싶다면
--   method_opt => 'FOR COLUMNS order_status SIZE 254'

-- 히스토그램 보유 현황 확인
SELECT column_name, num_distinct, histogram
FROM   user_tab_col_statistics WHERE table_name = 'ORDERS';

히스토그램은 "값별 분포"를 담는 추가 통계입니다. 히스토그램이 없으면 옵티마이저는 균등 분포를 가정합니다 — order_status의 99%가 '완료'인 테이블에서 = '오류' 조건의 cardinality를 "행수 ÷ 값 종류수"로 계산해 크게 틀리는 식입니다. SIZE AUTO는 "그 컬럼이 WHERE에 쓰인 적이 있고 분포가 치우쳤을 때" 자동으로 만들어 줍니다.

⚠️
통계 수집의 타이밍

야간 자동 수집만 믿으면 "낮에 1억 건을 지운 직후"부터 밤까지는 낡은 통계로 달립니다. 데이터 규모·분포를 크게 바꾸는 작업 뒤에는 즉시 수동 수집이 원칙입니다. 반대로, 잘 도는 시스템에서 이유 없이 통계를 다시 수집하면 플랜이 바뀌는 위험도 있으니(플랜 안정성), 중요 시스템은 수집 → 검증의 절차를 갖추세요.

팁 8. 자주 쓰는 진단 쿼리 모음

장애 대응 시 복사해 쓰는 세 가지입니다. 배경 개념은 12장 참고.

8-1. 지금 활동 중인 세션과 대기 이벤트

SELECT s.sid, s.serial#, s.username, s.sql_id,
       s.event, s.state, s.seconds_in_wait, s.blocking_session
FROM   v$session s
WHERE  s.status = 'ACTIVE'
AND    s.username IS NOT NULL
ORDER BY s.seconds_in_wait DESC;

8-2. 무거운 SQL Top-N (v$sql)

-- 누적 경과시간 기준 Top 10 — "실행당" 지표도 함께 봐야 한다
SELECT *
FROM (
  SELECT sql_id,
         executions,
         ROUND(elapsed_time/1e6, 1)                   AS elapsed_sec_total,
         ROUND(elapsed_time/NULLIF(executions,0)/1e3, 1) AS ms_per_exec,
         buffer_gets, disk_reads,
         SUBSTR(sql_text, 1, 80) AS sql_head
  FROM   v$sql
  ORDER BY elapsed_time DESC
)
WHERE ROWNUM <= 10;

8-3. 락 대기 체인 — 누가 누구를 막고 있나

-- 블로킹 당하는 세션과 그 원인 세션을 한 줄로
SELECT w.sid       AS waiting_sid,
       w.event     AS waiting_event,
       w.sql_id    AS waiting_sql,
       b.sid       AS blocker_sid,
       b.username  AS blocker_user,
       b.status    AS blocker_status,
       b.last_call_et AS blocker_idle_sec
FROM   v$session w
JOIN   v$session b ON b.sid = w.blocking_session
WHERE  w.blocking_session IS NOT NULL;
-- blocker가 INACTIVE + idle 수천 초 = "커밋 안 하고 퇴근" 패턴 (8장 참고)

✍️ 이해도 체크

VARCHAR2 컬럼 cust_no에 인덱스가 있는데 WHERE cust_no = 12345 쿼리가 풀스캔을 합니다. 실행계획의 Predicate Information에는 filter(TO_NUMBER("CUST_NO")=12345)가 보입니다. 올바른 해결책은?
✅ 문자 컬럼 vs 숫자 비교에서 Oracle은 컬럼 쪽에 TO_NUMBER를 씌우므로(암시적 형변환) 인덱스를 못 씁니다. 근본 해결은 타입을 맞추는 것입니다. 함수기반 인덱스는 기술적으로 가능해도 숫자로 변환 불가한 값이 있으면 에러가 나는 등 원인을 덮는 우회일 뿐이고, REBUILD는 무관합니다.
ALLSTATS LAST 실행계획에서 어떤 인덱스 스캔 단계가 E-Rows=5, A-Rows=1,200,000으로 나왔고 그 위의 NESTED LOOPS가 전체 시간의 대부분을 차지합니다. 우선순위가 가장 높은 조치는?
✅ E-Rows 5 vs A-Rows 120만 — 옵티마이저가 24만 배 속아서 NL을 골랐습니다. 추정이 정확해지면 옵티마이저 스스로 Hash Join 등 적절한 플랜으로 바꿉니다. 병렬은 자원으로 문제를 덮는 미봉책이고, USE_NL은 오히려 잘못된 선택을 고정합니다.
📚
원문으로 더 깊이

Oracle Database 19c 공식 문서: SQL Tuning Guide, Optimizer Statistics Concepts, Influencing the Optimizer (힌트)