Backend
home
🔍

Oracle SQL 튜닝, 이렇게 접근한다

생성 일시
2026/04/11 06:46
태그
Oracle
게시일
2026/04/11
최종 편집 일시
2026/04/11 06:54
SQL을 작성할 줄 아는 것과 잘 작성하는 것은 다르다. 같은 결과를 돌려주는 쿼리라도 실행 계획에 따라 성능이 수십 배 이상 차이 날 수 있다. Oracle에서 SQL 튜닝은 단순히 인덱스를 추가하는 게 아니라, 옵티마이저가 어떻게 쿼리를 해석하는지 이해하는 데서 시작한다. 이 글에서는 실무에서 자주 쓰는 튜닝 접근법과 쿼리 작성 노하우를 정리한다.

실행 계획(Execution Plan)부터 확인한다

튜닝의 첫 단계는 실행 계획 확인이다. 쿼리가 느리다고 느껴지면 바로 수정하기 전에 반드시 실행 계획을 먼저 본다.
EXPLAIN PLAN FOR SELECT * FROM employees e JOIN departments d ON e.department_id = d.department_id WHERE e.salary > 5000; SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY);
SQL
복사
실행 계획에서 확인해야 할 핵심 항목은 다음과 같다.
FULL TABLE SCAN: 인덱스를 타지 않고 테이블 전체를 읽는 것. 대용량 테이블에서 발생하면 위험하다.
INDEX RANGE SCAN: 인덱스를 범위로 탐색하는 것. 일반적으로 효율적이다.
NESTED LOOP / HASH JOIN / MERGE JOIN: 조인 방식. 데이터 크기와 인덱스 유무에 따라 최적 방식이 달라진다.
Cost / Cardinality: 옵티마이저가 예측한 비용과 예상 행 수. 실제 수치와 크게 다르면 통계 정보가 오래된 것일 수 있다.

인덱스를 제대로 이해하고 쓴다

인덱스는 만능이 아니다. 잘못 쓰면 오히려 성능이 떨어진다.

인덱스가 무력화되는 대표 패턴

-- 컬럼에 함수를 씌우면 인덱스를 타지 않는다 SELECT * FROM employees WHERE TO_CHAR(hire_date, 'YYYY') = '2023'; -- 개선: 범위 조건으로 변경 SELECT * FROM employees WHERE hire_date >= TO_DATE('2023-01-01', 'YYYY-MM-DD') AND hire_date < TO_DATE('2024-01-01', 'YYYY-MM-DD'); -- 묵시적 형변환도 인덱스를 죽인다 SELECT * FROM employees WHERE employee_id = '100'; -- 위험 -- 개선: 타입을 맞춰준다 SELECT * FROM employees WHERE employee_id = 100; -- LIKE 앞쪽 와일드카드는 인덱스를 타지 않는다 SELECT * FROM employees WHERE last_name LIKE '%son'; -- 개선: 앞쪽 고정값이 있어야 인덱스가 동작한다 SELECT * FROM employees WHERE last_name LIKE 'John%';
SQL
복사

복합 인덱스는 선두 컬럼 순서가 중요하다

-- (department_id, salary) 복합 인덱스가 있다고 가정 -- 선두 컬럼(department_id)을 쓰면 인덱스 사용 가능 SELECT * FROM employees WHERE department_id = 10 AND salary > 5000; -- 선두 컬럼 없이 salary만 조건으로 쓰면 인덱스가 동작하지 않는다 SELECT * FROM employees WHERE salary > 5000;
SQL
복사

조인 작성 시 주의할 점

드라이빙 테이블은 결과 행이 적은 쪽으로

옵티마이저가 자동으로 판단하지만, 힌트로 유도할 수도 있다. 일반적으로 WHERE 조건으로 행이 많이 걸러지는 테이블이 드라이빙 테이블이 되는 게 유리하다.
SELECT /*+ LEADING(d) USE_NL(e) */ e.employee_id, e.last_name, d.department_name FROM departments d JOIN employees e ON d.department_id = e.department_id WHERE d.location_id = 1700;
SQL
복사

불필요한 DISTINCT, ORDER BY는 제거한다

-- DISTINCT는 정렬 비용이 발생한다. 중복이 없는 게 확실하면 제거한다 SELECT DISTINCT department_id FROM employees; -- EXISTS로 대체하면 더 효율적인 경우가 많다 SELECT department_id FROM departments d WHERE EXISTS ( SELECT 1 FROM employees e WHERE e.department_id = d.department_id );
SQL
복사

서브쿼리 vs 조인

서브쿼리와 조인 중 어느 게 빠른지는 데이터에 따라 다르지만, 일반적으로 서브쿼리보다 조인이 옵티마이저가 최적화하기 더 좋다.
-- 서브쿼리 방식 SELECT last_name, salary FROM employees WHERE department_id IN ( SELECT department_id FROM departments WHERE location_id = 1700 ); -- 조인 방식으로 변경 (대부분 더 효율적) SELECT e.last_name, e.salary FROM employees e JOIN departments d ON e.department_id = d.department_id WHERE d.location_id = 1700;
SQL
복사
단, 스칼라 서브쿼리에서 캐싱이 발생하는 경우엔 서브쿼리가 더 빠를 수도 있다.

WITH절(CTE)로 가독성과 성능을 동시에

WITH절은 복잡한 쿼리를 분리해서 읽기 쉽게 만들고, 동일한 결과를 여러 번 참조할 때 반복 연산을 줄일 수 있다.
WITH high_salary_emp AS ( SELECT employee_id, last_name, salary, department_id FROM employees WHERE salary >= 8000 ), dept_stats AS ( SELECT department_id, AVG(salary) AS avg_salary, COUNT(*) AS emp_count FROM high_salary_emp GROUP BY department_id ) SELECT h.last_name, h.salary, d.avg_salary, d.emp_count FROM high_salary_emp h JOIN dept_stats d ON h.department_id = d.department_id ORDER BY h.salary DESC;
SQL
복사

페이징 쿼리는 ROWNUM보다 ROW_NUMBER()를 쓴다

Oracle 12c 이전에는 ROWNUM으로 페이징을 처리했지만, 가독성이 떨어지고 실수하기 쉽다. 12c 이상에서는 FETCH FIRST를 쓰는 게 훨씬 깔끔하다.
-- 구식 방법 (Oracle 11g 이하 호환) SELECT * FROM ( SELECT e.*, ROWNUM AS rn FROM ( SELECT employee_id, last_name, salary FROM employees ORDER BY salary DESC ) e WHERE ROWNUM <= 20 ) WHERE rn >= 11; -- 권장 방법 (Oracle 12c 이상) SELECT employee_id, last_name, salary FROM employees ORDER BY salary DESC OFFSET 10 ROWS FETCH NEXT 10 ROWS ONLY; -- ROW_NUMBER()로 유연하게 처리 SELECT * FROM ( SELECT employee_id, last_name, salary, ROW_NUMBER() OVER (ORDER BY salary DESC) AS rn FROM employees ) WHERE rn BETWEEN 11 AND 20;
SQL
복사

통계 정보는 주기적으로 갱신한다

옵티마이저는 통계 정보를 기반으로 실행 계획을 수립한다. 통계 정보가 오래되면 잘못된 실행 계획을 선택할 수 있다.
-- 특정 테이블 통계 수집 EXEC DBMS_STATS.GATHER_TABLE_STATS('HR', 'EMPLOYEES'); -- 스키마 전체 통계 수집 EXEC DBMS_STATS.GATHER_SCHEMA_STATS('HR'); -- 통계 정보 확인 SELECT table_name, num_rows, last_analyzed FROM user_tables WHERE table_name = 'EMPLOYEES';
SQL
복사

힌트(Hint)는 최후의 수단으로

힌트는 옵티마이저에게 실행 계획을 강제하는 방법이다. 하지만 데이터가 바뀌면 힌트가 오히려 독이 될 수 있으므로, 통계 수집이나 인덱스 조정으로 해결이 안 될 때 마지막으로 쓴다.
SELECT /*+ FULL(e) */ employee_id, last_name FROM employees e; SELECT /*+ INDEX(e emp_salary_idx) */ employee_id, last_name FROM employees e WHERE salary > 5000; SELECT /*+ USE_HASH(e d) */ e.last_name, d.department_name FROM employees e JOIN departments d ON e.department_id = d.department_id; SELECT /*+ PARALLEL(e, 4) */ COUNT(*) FROM employees e;
SQL
복사

실무에서 자주 쓰는 분석 함수

분석 함수(Window Function)는 GROUP BY 없이 집계를 할 수 있어 복잡한 리포팅 쿼리를 단순하게 만든다.
-- 부서 내 급여 순위 SELECT employee_id, last_name, department_id, salary, RANK() OVER (PARTITION BY department_id ORDER BY salary DESC) AS rank_in_dept, DENSE_RANK() OVER (PARTITION BY department_id ORDER BY salary DESC) AS dense_rank_in_dept, ROW_NUMBER() OVER (PARTITION BY department_id ORDER BY salary DESC) AS row_num_in_dept FROM employees; -- 누적 합계와 이동 평균 SELECT employee_id, hire_date, salary, SUM(salary) OVER (ORDER BY hire_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS cumulative_salary, AVG(salary) OVER (ORDER BY hire_date ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS moving_avg_3 FROM employees ORDER BY hire_date; -- 부서 내 전체 평균과 개인 급여 비교 SELECT employee_id, last_name, department_id, salary, AVG(salary) OVER (PARTITION BY department_id) AS dept_avg, salary - AVG(salary) OVER (PARTITION BY department_id) AS diff_from_avg FROM employees;
SQL
복사

마치며

Oracle SQL 튜닝은 한 번에 완성되는 작업이 아니다. 실행 계획을 읽는 습관, 인덱스가 무력화되는 패턴 파악, 통계 정보 관리, 그리고 적절한 조인 방식 선택이 쌓여야 실력이 늘어난다. 쿼리를 작성할 때 "이 쿼리가 어떤 실행 계획을 탈까"를 먼저 생각하는 습관이 튜닝의 출발점이다.