인덱스는 테이블의 검색 속도를 향상시키기 위한 데이터 구조이다.
전체 테이블을 스캔하지 않고, 조건절에 맞는 행을 빠르게 탐색할 수 있다.
인덱스는 결국 찾는 과정을 줄이기 위한 도구이다.
데이터베이스의 저장하는 양이 커질 수록 탐색의 범위도 늘어나게 되는데, 이 과정을 “사전의 목차” 처럼 정렬해 놓고 모든 테이블을 읽지 않고도 원하는 값을 O(log N) 시간 안에 찾을 수 있다.
그렇다고 모든 컬럼 인덱스를 적용하면?
모든 칼럼에 인덱스를 적용하면 성능은 오히려 나빠진다. INSERT 시 삽입된 데이터 외에도 인덱스 정보도 다시 수정하기 때문에 쓰기 성능이 매우 나빠진다. 또한 인덱스도 별도로 저장해야하기 때문에 디스크 공간의 낭비가 발생한다.
옵티마이저는 SQL 실행 시 인덱스를 사용할지 결정하고, 사용하기로 했다면 어떤 방식으로 인덱스를 읽을지 선택한다.
→ “인덱스를 어떻게 탈 것인가”를 정의하는 단계이다.
B-Tree 인덱스에서 조건에 해당하는 키 값 구간만 찾아서, 그 구간에 속한 리프 노드들만 순차적으로 읽는 방식
- 예:
WHERE age BETWEEN 20 AND 30,WHERE name LIKE 'J%'- 인덱스의 특정 구간만 순차적으로 탐색
- 가장 일반적이고 효율적인 스캔 방식
특징:
인덱스 키가 유일한 경우, 단 하나의 ROWID만 찾는 방식
(= 조건이 무조건 한 행만 반환하는 경우)
- 예:
WHERE id = 100(PK, UNIQUE INDEX)- 인덱스 트리를 통해 정확히 한 행만 즉시 접근 가능
특징:
인덱스 전체를 처음부터 끝까지 읽는 방식
- 예: 인덱스는 있지만 WHERE 조건이 없을 때
SELECT * FROM users ORDER BY name;
특징:
Index Fast Full Scan
→ 인덱스 전체를 읽지만, 정렬 순서를 보장하지 않고 빠르게 읽는 방식
- 특징
- 인덱스 블록을 병렬/랜덤 읽기 방식으로 가져오기 때문에 Index Full Scan보다 빠르다.
- 예시
SELECT COUNT(*) FROM users;
- 정렬이 필요하지 않은 COUNT 연산에 효과적으로 사용된다.
결합 인덱스의 첫 번째 컬럼을 조건절에 사용하지 않았을 때
옵티마이저가 첫 컬럼 값을 반복 대입하면서 인덱스를 ‘부분적으로’ 사용하는 기술
- 예: 인덱스
(gender, age)에서WHERE age = 30
gender = 'F' 인 구간에서 age=30 스캔
gender = 'M' 인 구간에서 age=30 스캔
gender = 'U' 인 구간에서 age=30 스캔 (있다면)
- 옵티마이저가 첫 컬럼(`gender`)의 값을 내부적으로 반복 추정하여 스캔
→ **첫 컬럼을 내부에서 “카테고리”처럼 돌려가며 여러 번 Range Scan 수행**
특징:
인덱스가 존재해도 Oracle Database 옵티마이저가 인덱스를 사용하지 않고 풀 테이블 스캔이나 다른 접근 경로를 선택하는 경우가 있다.
| 구분 | 설명 |
|---|---|
| 선두(Leading) 컬럼 미사용 | 복합 인덱스의 첫 번째 컬럼이 WHERE 조건절에 포함되지 않으면 인덱스 탐색 불가 |
| 부정 조건 (NOT, !=, <>) | 옵티마이저가 검색 범위를 좁히기 어렵다고 판단 |
| 함수 또는 연산 사용 | 인덱스 컬럼에 함수나 계산식을 적용하면 인덱스 구조를 직접 활용할 수 없음 |
| 암시적 형변환 발생 | 문자열-숫자 비교 등에서 타입이 변환되면 인덱스 사용 불가 |
와일드카드 선두 사용 (LIKE '%abc') | 인덱스는 왼쪽부터 정렬되어 있으므로, 첫 글자가 고정되지 않으면 사용 불가 |
| 데이터 대부분을 반환하는 조건 | 인덱스를 써도 읽는 양이 많으면 오히려 비효율 → 풀스캔 선택 |
| 최신 통계 정보 부재 | 옵티마이저가 인덱스 효율을 잘못 계산함 |
선두 컬럼(Leading Column)을 사용하지 않은 경우
복합 인덱스는 왼쪽부터 정렬되어 있기 때문에 첫 번째 컬럼이 WHERE 조건절에 등장하지 않으면 인덱스를 타고 내려갈 출발점을 찾을 수 없다.
예시
// 인덱스: (name, age)
WHERE age = 26; // 인덱스 사용 불가
WHERE name = 'max'; // 사용 가능
WHERE name='max' AND age=26; // 사용 가능
이유 : 복합 인덱스는 전화번호부처럼 앞글자부터 찾아야 한다. 첫 컬럼을 건너뛰면 구조적으로 탐색 불가능하다.
함수 또는 계산식을 인덱스 컬럼에 적용한 경우
인덱스는 “원본 값의 정렬”을 기준으로 구성된다.
하지만 함수가 적용된 순간 전혀 다른 값이 되기 때문에 인덱스의 정렬 정보를 사용할 수 없다.
WHERE LOWER(name) = 'max' // 인덱스 사용 불가
WHERE SUBSTR(phone, 2) = '010' // 사용 불가
문자열 ↔ 숫자 비교처럼 타입이 맞지 않으면
DB가 내부적으로 컬럼에 자동 변환 함수(TO_NUMBER / TO_CHAR) 를 적용한다.
이것도 결국 “함수 적용”이므로 인덱스를 깨뜨린다.
// user_id NUMBER 컬럼
WHERE user_id = '100' // 인덱스 미사용 (암시적 TO_NUMBER(user_id))
이유 : 함수가 들어가면 인덱스가 보관한 정렬 정보가 무효화된다.
와일드카드 선두 사용 (LIKE '%ABC')
인덱스는 문자열의 앞부분부터 정렬되기 때문에 앞이 '%'로 시작하면 DB가 “시작 위치”를 판단할 수 없다. → 결국 전체 스캔 필요.
WHERE name LIKE '%AM' // 인덱스 미사용
WHERE name LIKE 'MA%' // Range Scan 사용 가능
이유 : 형변환은 숨어 있는 함수 적용이다. 결국 인덱스를 못 타게 만든다
데이터 대부분을 반환하는 조건
인덱스를 타면 “ROWID → 테이블 랜덤 읽기”가 발생한다.
하지만 결과가 너무 많으면 랜덤 IO가 폭발한다.
그래서 옵티마이저는 판단한다:
어차피 대부분 읽을 거면 인덱스보다 Full Scan이 빠르다.
WHERE status IN ('A', 'B', 'C') // 결과가 전체의 70% 이상일 때
이유 : 인덱스는 왼쪽이 고정되어야 한다. 앞이 '%'면 시작점을 알 수 없어 전체 스캔으로 간다.
통계 정보가 오래되었거나 없는 경우
옵티마이저는 테이블의 카디널리티(행 수), 분포도, 블록 수, 인덱스 구조 등을 기반으로 “인덱스를 타는 게 빠른지”를 계산한다.
통계가 오래되면 잘못된 판단을 하고 인덱스를 타지 않거나, 반대로 불필요하게 타는 상황도 생긴다.
최근에 테이블 데이터 폭증했는데 통계 미갱신
인덱스가 사실상 비효율인데 옵티마이저는 아직 효율적이라고 착각
이유 : 인덱스는 ‘소수의 데이터 찾기’에 최적화. 많이 읽을 거면 차라리 테이블 한 번에 스캔하는 게 더 빠르다.”
둘 이상의 테이블을 논리적으로 결합해 데이터를 조회하는 SQL 연산.
테이블 간 공통된 컬럼(보통 PK–FK 관계) 을 기준으로 데이터를 합친다.
| 구분 | 기준 | 설명 | 결과 |
|---|---|---|---|
| INNER JOIN | 교집합 | 두 테이블에서 조건이 일치하는 행만 반환 | 공통된 데이터만 |
| LEFT OUTER JOIN | 왼쪽 기준 | 왼쪽 테이블의 모든 행 + 오른쪽 일치하는 행 | 일치하지 않으면 NULL |
| RIGHT OUTER JOIN | 오른쪽 기준 | 오른쪽 테이블의 모든 행 + 왼쪽 일치하는 행 | 일치하지 않으면 NULL |
| FULL OUTER JOIN | 합집합 | 양쪽 테이블의 모든 행 반환 | 일치하지 않으면 NULL |
| CROSS JOIN | 모든 조합 | 조건 없이 모든 행을 곱집합으로 결합 | N×M개 행 |
| SELF JOIN | 자기 자신 | 같은 테이블끼리 조인 (별칭 필수) | 계층, 상하관계 표현 시 사용 |

SELECT a.id, a.name, b.category
FROM A a
JOIN B b ON a.b_id = b.id;

SELECT a.name, b.category
FROM A a
LEFT JOIN B b ON a.b_id = b.id;

SELECT a.name, b.category
FROM A a
RIGHT JOIN B b ON a.b_id = b.id;

SELECT a.name, b.category
FROM A a
FULL OUTER JOIN B b ON a.b_id = b.id;

SELECT a.name, b.category
FROM A a
CROSS JOIN B b;

// A 테이블에는 상품 목록이 저장되어 있고, 일부 상품은 상위 상품(parent_id) 을 가진다.
SELECT a.product AS item, b.product AS parent_item
FROM A a
JOIN A b ON a.parent_id = b.id;
같은 테이블에서 상품과 그 상품의 상위 상품을 함께 조회하는 자기 조인이다.
조인에는 세 가지 기법이 존재한다.
다양한 상황에서 최적의 성능을 내기 위한 적절한 JOIN 계획을 세워 동작을 수행한다.
- 일반적으로는 옵티마이저가 계획을 세워 동작을 수행하지만, 항상 최적화된 계획만 세우지 않는다.
- 그런 경우는 수동적으로 JOIN 계획을 세워 넣어줘야하는 경우가 있다.
두 테이블 중 작은 테이블(Outer)의 각 행을 기준으로,
큰 테이블(Inner) 을 반복 탐색하면서 일치하는 데이터를 찾는 조인 방식이다.
이때 선행 테이블이(Outer Table) 작고, 후행 테이블(Inner Table)이 커야 효과적이다.
→ 인덱스가 없는 경우는 효율이 급격히 떨어져서 거의 선택하지 않는다.
동작 방식
=, <, >)으로 일치하는 행 탐색특징
인덱스 필수
작은 데이터에 적합
→ 작은 테이블에는 매우 빠르지만. 대량 데이터 처리에는 비효율적인 구조이다.
조인하려는 두 개의 테이블을 조인 칼럼을 기준으로 오름차순 정렬한 후, 두개의 테이블에서 정렬된 조인 대상 키를 스캔하면서 조인 결과를 생성하는 방식
동작 방식
A의 test_id = 10 ←→ B의 test_id = 10 일치 하면 매칭 결과 생성
A, B 모두 다음 키로 이동 …
반복
→ 정렬되어 있으므로 순차 비교만으로 조인 가능
→ 인덱스 기반의 랜덤 엑세스 없음
특징
❓왜 동등 조건 필요이 필요할까?
정렬된 두 테이블을 병합하려면, “기준이 되는 정확히 동일한 값”이 있어야 하기 때문이다.
- 다른 비동등 조건(<,>,!=) 을 사용하면 정렬된 순서를 활용하기 어려우며 이는 조인의 성능을 저하시킬 수 있다.
❓ FULL OUTER JOIN은 왜 = 조건 없이 가능할까?
FULL OUTER JOIN은 양쪽 테이블의 모든 행을 다 포함 하는 조인 방식이다.
조건에 맞지 않아도 결과에 들어와야 하기 때문이다.
Build Input 테이블의 조인 컬럼에 Hash Function(해시 함수) 를 이용해서 Hash Key(테이블의 인덱스 역할) 을 생성 후, Hash Table(해시 맵) 을 생성한다.
그리고 Probe Input 테이블의 조인 조건에 맞는 해시 테이블을 탐색하며, 해시 함수를 적용하여 해시 키를 생성하고, 해시 테이블에서 같은 해시 키를 찾아서 조인을 진행하는 방식이다.
- 조인 대상의 두 테이블 중 데이터가 더 작은 테이블을 Build Input(빌드 입력) 이라 하고,
- 큰 테이블을 Probe Input(프로브 입력) 이라고 한다.
> **❓ 왜 작은 테이블로 Hash Table(해시 맵) 을 생성할까?**
작은 테이블을 메모리에 올려서(+ cpu 기반 주소 접근) 빠르게 탐색하는게 해시 맵의 핵심이다.
따라서 Build 테이블이 크면 메모리에 다 올리지 못하고 오히려 성능이 저하될 수 있다.
>
동작 방식
특징
❓ 얼마나 빠를까?
데이터의 양이나 특정 조건에 대해서 차이가 있기 때문에 정량적 수치로는 보장할 수 는 없지만,
데이터 양이 커질수록 Nested Loop은 탐색 비용이 기하급수적으로 증가하지만, 해시 조인은 비교적 완만하게 증가한다.
해시 조인은 Sort Merge 조인보다 대량 정렬 부담이 작다.
❓ 해시 충돌은 오류 아닌가?
Hash Join에서는 충돌을 허용하면서도 정확한 조인을 보장하는 구조로 설계.
- DB 해시 조인에서는 해시 함수가 단순히 버킷(bucket) 을 분류하기 위한 역할만 한다.
- “완벽히 구분하지 않아도, 같은 해시값끼리 모아놓고 나중에 진짜 비교하면 된다”
해시 조인 종류
아래와 같이 4가지 계획이 존재하며, 옵티마이저가 Build Input 크기에 따라 1번 부터 4번까지 순차적으로 점차 전환하며 실행한다.
실행 계획이란 사용자가 SQL을 실행하여 데이터를 추출하려고 할 때 옵티마이저가 수립하는 작업 절차이다.
옵티마이저는 쿼리를 보고 가장 빠를거 같은 경로를 선택한다.
동작 시나리오
// EMP : 사원의 기본 정보가 담김
// DEPT : 부서 정보가 담김(EMP와 1:N 관계를 가지는 참조 테이블)
// SALGRADE : 급여 범위를 등급으로 나누어 놓은 테이블(lookup)
SELECT e.empno, e.ename, d.dname, s.grade
FROM emp e
JOIN dept d ON e.deptno = d.deptno
JOIN salgrade s
ON e.sal BETWEEN s.losal AND s.hisal
WHERE e.job = 'SALESMAN';
DBMS가 만든 작업 순서(Operation) 리스트
Id, Operation, Name으로 구성됨.
- Operation = 어떤 작업인지
- Name = 테이블 또는 인덱스 이름
- Id = 스텝 고유 번호 (이게 트리 구조에서 노드 번호)

STEP 1 - EMP 테이블에서 시작하는 첫 번째 Nested Loop
노드 5 : PK_EMP
노드 4 : TABLE ACCESS BY INDEX ROWID EMP
노드 3 : NESTED LOOPS
→ 이 박스 전체는 “EMP를 기반으로 조인을 시작하는 루틴”을 의미함.
💡왜 EMP가 첫 번째 드라이빙 테이블인가? (Optimizer Reason)
- WHERE 조건(e.job='SALESMAN')의 선택도가 높음
- 먼저 EMP를 읽으면 이후 조인 비용이 대폭 감소.
- EMP는 WHERE 조건으로 row 수가 가장 많이 줄어든다.
- 드라이빙 테이블로 쓰기 최적.
STEP 2 - EMP + DEPT 조인 루틴
노드 6 : PK_DEPTNO
노드 7 : TABLE ACCESS BY INDEX ROWID DEPT
노드 2 : NESTED LOOPS
→ 이 박스는 “EMP + DEPT 조인 결과를 만드는 단계”.
💡왜 DEPT는 인덱스를 사용해 Nested Loop로 조인되었는가? (Optimizer Reason)
- DEPT는 PK(DEPTNO)에 인덱스가 기본적으로 존재
- EMP에서 이미 row 수가 크게 줄었기 때문에
드라이빙 테이블의 row가 적고, 조인 대상이 PK 인덱스를 가질 때 최적
→ 이 조건에 딱 들어맞는다!
STEP 3 - SALGRADE + FILTER + 최종 SELECT
노드 8 : SALGRADE TABLE FULL SCAN
노드 1 : FILTER
노드 0 : SELECT STATEMENT
→ 이 박스는 “조인의 마지막 단계 + 필터링 + 결과 출력”을 의미함.
💡 왜 SALGRADE는 Full Scan으로 선택되었는가? (Optimizer Reason)
- SALGRADE는 극히 작은 정적 테이블(5~6행)
- 인덱스 탐색보다 Full Scan이 오히려 빠름
- 조건이 BETWEEN 범위 매칭
- 이런 조건은 인덱스의 선택도가 낮다.
방법은 3가지다: