인덱스를 쓰지 않고 테이블을 처음부터 끝까지 순차로 읽는 접근 방식. PostgreSQL 실행계획에서는 Seq Scan으로 나온다.
항상 나쁜 게 아니다
| 순차 I/O | 디스크를 앞에서 뒤로 쭉 읽는다 | → 빠르다. 미리읽기(prefetch)도 먹는다 |
|---|---|---|
| 랜덤 I/O | 여기저기 흩어진 위치를 오간다 | → 느리다 |
인덱스 스캔은 인덱스를 읽은 뒤 테이블을 랜덤 접근한다. 조건에 맞는 행이 많으면 랜덤 접근 횟수가 많아져, 순차로 다 읽는 것보다 오히려 느려진다.
전체 100만 행에서
1,000행(0.1%)을 찾는다 → 인덱스 스캔 승
300,000행(30%)을 찾는다 → 풀 스캔 승
작은 테이블(수백~수천 행)도 풀 스캔이 정답이다. 인덱스를 읽는 비용이 테이블을 읽는 비용보다 크기 때문이다.
의도치 않은 풀 스캔의 원인
-- ① 칼럼에 함수·연산을 씌웠다
WHERE YEAR(created_at) = 2026 -- ❌
WHERE created_at >= '2026-01-01'
AND created_at < '2027-01-01' -- ✅ 범위로 푼다
-- ② 앞 와일드카드
WHERE name LIKE '%kim' -- ❌
-- ③ 묵시적 형변환 — 문자열 칼럼에 숫자를 비교
WHERE phone = 01012345678 -- ❌ phone 이 VARCHAR면 인덱스가 깨진다
WHERE phone = '01012345678' -- ✅
①은 표현식 인덱스로도 풀 수 있다.
CREATE INDEX idx_lower_email ON member (lower(email)); -- PostgreSQL
-- 이제 WHERE lower(email) = ? 가 인덱스를 탄다
확인하는 법
EXPLAIN ANALYZE SELECT * FROM member WHERE email = 'a@b.com';
-- Seq Scan on member ... rows=1000000 ← 인덱스를 못 탔다
면접 함정
- ❌ "실행계획에 Seq Scan이 보이면 잘못된 것" → 소량 테이블이나 넓은 조회에서는 정상이자 최적이다.
- ❌ "인덱스를 만들었는데 왜 안 타냐" → 위 세 원인 아니면 카디널리티가 낮은 것이다.
얼마나 읽었는지까지 본다
-- PostgreSQL: BUFFERS 로 실제 읽은 블록 수를 확인한다
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM member WHERE email = 'a@b.com';
-- Seq Scan on member (actual rows=1 loops=1)
-- Buffers: shared read=12500 ← 12,500 블록(약 100MB)을 읽어 1행을 찾았다
actual rows는 1인데 읽은 블록이 수만이면 명백히 인덱스가 필요한 상황이다. 반대로 읽은 블록 대비 반환 행이 많다면 풀 스캔이 정답이다.
MySQL에서의 신호
EXPLAIN SELECT * FROM member WHERE email = 'a@b.com';
-- type: ALL ← 풀 테이블 스캔
-- rows: 998234 ← 훑을 것으로 추정한 행 수
-- Extra: Using where
type: ALL + 큰 rows가 빨간불이다. 다만 작은 테이블에서는 정상이라 테이블 크기와 함께 봐야 한다.
형변환은 눈에 안 띈다
-- phone 이 VARCHAR 인데 숫자를 비교하면 조용히 인덱스가 깨진다
EXPLAIN SELECT * FROM member WHERE phone = 01012345678; -- type: ALL
EXPLAIN SELECT * FROM member WHERE phone = '01012345678'; -- type: ref ✅
에러도 경고도 안 난다. ORM이 파라미터 타입을 잘못 바인딩해 생기는 경우가 실무에서 특히 잦고, 코드만 봐서는 발견되지 않는다.