DEEP
← 목록으로
읽기 9

Full Table Scan


Full Table Scan은 "느린 쿼리의 원인"으로만 알려져 있지만, 실제로는 옵티마이저가 의도적으로 선택하는 경우도 있습니다. 무조건 나쁜 것이 아니라, 언제 문제이고 언제 괜찮은지를 구분하는 것이 핵심입니다.

Full Table Scan이란

Full Table Scan(전체 테이블 스캔)은 데이터베이스가 쿼리 결과를 찾기 위해 테이블의 모든 행을 처음부터 끝까지 하나씩 읽는 조회 방식입니다.

이 글 전체에서 아래 테이블을 예시로 사용합니다.

iduser_idstatusamountcreated_at
11042PAID32,0002026-01-15
22981PENDING8,5002026-02-03
31042PAID15,2002026-03-22
45520CANCELLED41,0002026-04-01
53301PAID22,8002026-04-10
...............
총 2억 건

orders 테이블에서 SELECT * FROM orders WHERE status = 'PAID'를 실행할 때, status 컬럼에 인덱스가 없으면 데이터베이스는 2억 건을 처음부터 끝까지 읽으며 조건에 맞는 행을 골라냅니다. 이것이 Full Table Scan입니다.

왜 발생하는가

1. 인덱스를 쓸 수 없는 경우

가장 큰 원인입니다. orders 테이블에서 예를 들면:

  • 적절한 인덱스가 없다WHERE status = 'PAID'를 실행하는데 status 컬럼에 인덱스가 없으면, 2억 건을 처음부터 끝까지 읽는 수밖에 없습니다.
  • 인덱스를 무력화하는 쿼리 패턴WHERE YEAR(created_at) = 2026처럼 컬럼에 함수를 씌우면, 인덱스가 있어도 정렬 순서가 깨져서 사용할 수 없습니다. LIKE '%검색어'(앞쪽 와일드카드), 암묵적 타입 변환도 같은 이유입니다.
  • 복합 인덱스의 선두 컬럼을 건너뛴 경우(user_id, status) 인덱스에서 WHERE status = 'PAID'만 쓰면, 첫 번째 정렬 기준(user_id)이 빠져 인덱스를 활용할 수 없습니다.

2. 옵티마이저가 의도적으로 선택하는 경우

인덱스가 있어도 옵티마이저가 Full Table Scan을 선택하는 상황이 있습니다.

  • 결과 비율이 높을 때orders 2억 건 중 status = 'PAID'가 1.6억 건(80%)이라면, 인덱스로 1.6억 개의 포인터를 하나씩 따라가며 디스크 곳곳을 랜덤으로 읽는 것보다, 테이블을 처음부터 끝까지 순차적으로 읽는 게 빠릅니다.
  • 테이블이 매우 작을 때 — 수십~수백 행짜리 테이블은 통째로 메모리에 올려도 비용이 거의 없습니다. 인덱스를 거치는 오버헤드가 오히려 낭비입니다.

정리하면: 인덱스가 없어서 발생하면 개발자가 고쳐야 할 문제이고, 있어도 안 쓰는 게 더 싸서 발생하면 옵티마이저의 정상적인 판단입니다.

동작 원리

orders 2억 건에서 SELECT * FROM orders WHERE status = 'PAID'를 인덱스 없이 실행할 때, 데이터베이스 내부에서 일어나는 일을 단계별로 봅니다.

1. 디스크에서 페이지 단위로 읽는다

데이터베이스는 행을 하나씩 읽지 않습니다. 디스크의 최소 읽기 단위인 페이지(보통 8KB~16KB)를 통째로 읽습니다. 한 페이지에 orders 행이 약 100건 들어간다면, 2억 건 = 약 200만 페이지입니다.

Full Table Scan은 이 200만 페이지를 첫 페이지부터 마지막 페이지까지 순서대로 읽습니다. 이것이 Sequential I/O(순차 읽기)입니다.

2. Sequential I/O vs Random I/O

여기서 "왜 빠른 Sequential I/O를 안 쓰고 Random I/O를 쓰지?"라는 의문이 생깁니다. Random I/O는 선택이 아니라, 인덱스의 구조적 결과입니다.

테이블의 행은 삽입 순서대로 페이지에 쌓입니다. status = 'PAID'인 행은 페이지 1에도 있고, 페이지 50에도 있고, 페이지 9999에도 있습니다. 인덱스는 "조건에 맞는 행이 어느 페이지에 있는지"를 가리키는 주소록인데, 그 주소들이 디스크 전체에 흩어져 있으니 여기저기 점프해야 합니다. 이것이 Random I/O입니다.

Sequential I/ORandom I/O
읽기 패턴연속된 페이지를 순서대로흩어진 페이지를 건너뛰며
디스크 헤드한 방향으로 쭉 이동매번 새 위치로 이동 (seek)
처리량 (HDD)~200 MB/s~2 MB/s
SSD에서 차이줄어들지만 Sequential이 여전히 유리seek 비용 감소, 0은 아님

아래 그림은 디스크 위에서 두 방식의 차이를 보여줍니다. 왼쪽(Sequential)은 모든 섹터를 순서대로 읽으며 헤드가 한 바퀴 sweep합니다. 오른쪽(Random)은 필요한 섹터 3개만 읽지만 헤드가 매번 먼 위치로 점프합니다.

Sequential I/O vs Random I/O — 디스크 헤드 이동 패턴 비교

3. 버퍼 풀과의 관계

Full Table Scan은 200만 페이지를 한꺼번에 읽어야 하므로, 버퍼 풀에 이미 있던 다른 테이블의 캐시 페이지를 밀어냅니다. 이를 **버퍼 풀 오염(Buffer Pool Pollution)**이라 합니다.

Full Table Scan 한 번이 끝나면 자주 쓰던 다른 쿼리들도 캐시 미스가 발생해 전체 시스템 성능이 일시적으로 떨어질 수 있습니다.

MySQL(InnoDB)은 이 문제를 완화하기 위해 버퍼 풀을 young/old 영역으로 나누고, Full Table Scan으로 읽힌 페이지는 old 영역에만 넣어 빨리 퇴출되게 합니다.

4. 필터링은 읽은 뒤에 일어난다

WHERE status = 'PAID'라는 조건이 있어도, 데이터베이스는 먼저 페이지를 읽고 그 다음에 각 행이 조건을 만족하는지 검사합니다. 2억 건을 전부 읽은 뒤 1.6억 건을 반환하고 4천만 건을 버리는 구조입니다. 읽기 자체를 줄여주지는 않습니다.

인덱스는 이 "읽기 자체"를 줄여주는 것이고, Full Table Scan은 줄일 수 없는 것입니다.

판단 기준

문제가 되는 경우

1. 소량 결과를 찾으려는데 Full Table Scan이 발생할 때

1건을 찾는데 2억 건을 읽는다
SELECT * FROM orders WHERE id = 58291042

1건만 찾으면 되는데 2억 건을 전부 읽습니다. 인덱스가 있으면 3~4번의 페이지 읽기로 끝날 일을, 200만 페이지를 순회하는 셈입니다.

2. 자주 실행되는 쿼리에서 발생할 때

관리자가 하루 한 번 돌리는 리포트 쿼리라면 Full Table Scan이 큰 문제가 되지 않습니다. 그러나 매 API 요청마다 호출되는 쿼리가 Full Table Scan이면, 초당 수백 번의 Full Scan이 겹쳐 디스크와 버퍼 풀을 동시에 압박합니다.

3. 버퍼 풀 오염이 연쇄 영향을 줄 때

Full Table Scan 한 번이 버퍼 풀의 캐시 페이지를 밀어내면, 다른 쿼리들도 캐시 미스가 발생합니다. 단일 쿼리의 문제가 시스템 전체 성능 저하로 번질 수 있습니다.

괜찮은 경우

1. 테이블이 작을 때

수백~수천 건짜리 코드 테이블, 설정 테이블은 Full Table Scan이 인덱스보다 빠릅니다. 테이블 전체가 몇 개의 페이지에 들어가니 순차로 읽어도 순식간입니다. 이런 테이블에 인덱스를 거는 건 오히려 쓰기 성능만 떨어뜨립니다.

2. 결과 비율이 높을 때

orders 2억 건 중 status = 'PAID'가 1.6억 건(80%)이라면, "빠르게 많이 읽기 vs 느리게 적게 읽기" 트레이드오프에서 Sequential I/O 쪽이 이깁니다. 옵티마이저가 Full Table Scan을 선택하는 건 정상입니다.

3. 일회성 분석/배치 쿼리일 때

야간 배치, 마이그레이션, 통계 집계처럼 한 번 돌리고 끝나는 쿼리는 Full Table Scan이 자연스럽습니다. 이런 용도로 인덱스를 추가하면 평소 쓰기(INSERT/UPDATE) 성능이 떨어지는 부작용이 생깁니다.

트레이드오프

기준Full Table ScanIndex Scan
읽기 비용테이블 전체 (고정)조건 매칭분만 (가변)
I/O 패턴SequentialRandom
버퍼 풀 영향오염 위험 높음필요한 페이지만 캐싱
쓰기 비용없음인덱스 유지 비용 (INSERT/UPDATE/DELETE마다)
디스크 공간없음인덱스 자체가 추가 공간 차지
손익분기점결과 비율 1525% 이상이면 유리결과 비율 1525% 이하일 때 유리

인덱스도 공짜가 아니다

orders 테이블에 인덱스를 하나 추가하면:

  • INSERT할 때 — 테이블에 행 하나 추가 + 인덱스 트리에도 항목 추가. 인덱스가 3개면 쓰기 비용이 대략 3배 가까이 늘어납니다.
  • UPDATE할 때 — 인덱스 컬럼 값이 바뀌면 인덱스 트리에서 기존 위치 삭제 + 새 위치 삽입.
  • 디스크 공간 — 2억 건 테이블의 인덱스 하나가 수 GB를 차지할 수 있습니다.

Full Table Scan을 없애려고 인덱스를 무분별하게 추가하면, 읽기는 빨라지지만 쓰기가 느려지는 다른 문제가 생깁니다.

실무 판단 기준

인덱스를 추가해야 하는 경우:

  • 자주 실행되는 쿼리가 소량 결과를 반환하는데 Full Table Scan이 발생
  • EXPLAIN으로 확인했을 때 type: ALL(Full Table Scan)이 나오고 rows가 테이블 전체에 가까움

Full Table Scan을 그대로 두어도 되는 경우:

  • 테이블이 작거나, 결과 비율이 높거나, 일회성 쿼리
  • 이미 인덱스가 여러 개인 테이블에서 쓰기 성능이 병목

마무리

Full Table Scan은 그 자체로 좋거나 나쁜 것이 아닙니다. 얼마나 자주, 얼마나 큰 테이블에서, 결과가 전체 대비 몇 %인가에 따라 문제가 될 수도, 정상일 수도 있습니다. EXPLAIN으로 실행 계획을 확인하고, Sequential I/O와 Random I/O의 트레이드오프를 이해하면 Full Table Scan 앞에서 당황하지 않을 수 있습니다.

참고 자료

최근 글