테마 선택

📚 지식 노트

AI·하드웨어·주변기기·게임·컴퓨터 시스템·이론 컴퓨터 과학·수학·개발과 운영·보안·응용 분야에 대해 알아 두면 좋은 내용을 정리한 노트다. 위에서 분야를 고르고 아래에서 노트를 고른다.

인덱스와 쿼리 실행

SQL 한 줄이 실행 계획이 되어 돌기까지의 과정이다. 인덱스, 옵티마이저의 비용 계산, 조인 알고리즘을 다룬다.

목차 (6)
  1. SQL이 실행되기까지
  2. 인덱스의 종류
  3. 선택도와 비용 기반 옵티마이저
  4. 조인 알고리즘
  5. EXPLAIN 읽기
  6. 흔한 함정

SQL이 실행되기까지

SQL은 결과의 모양만 말하는 선언형 언어다(관계형 모델 노트). 그래서 데이터베이스 안에서는 문장 하나가 여러 단계를 거쳐 "어떻게 실행할지"를 담은 실행 계획(plan)이 된다.

SQL 글이 파서, 분석과 재작성, 옵티마이저, 실행기를 거쳐 결과 행이 되는 흐름. 옵티마이저는 테이블 통계를 참고한다SQL 글문자열파서구문 트리분석·재작성이름·형 확인, 뷰 펼치기옵티마이저후보 계획마다 비용 계산실행기연산자 트리를 돈다결과 행클라이언트로논리 계획물리 계획통계행 수·값 분포·고유값 수SQL은 "무엇"만 말한다."어떻게"(읽는 순서, 인덱스, 조인 방법)는옵티마이저가 비용을 어림해 고른다.
쿼리 처리의 단계. 옵티마이저가 고른 물리 계획은 연산자(스캔, 조인, 정렬…)의 트리이고, 실행기는 그 트리를 돌며 행을 위로 올려 보낸다.
  1. 파싱: 문법을 검사하고 구문 트리를 만든다.
  2. 분석과 재작성: 테이블·열 이름이 실제로 있는지, 자료형이 맞는지 확인하고 뷰를 그 정의로 펼친다.
  3. 최적화: 같은 답을 내는 여러 계획(테이블 읽는 순서, 인덱스 사용 여부, 조인 방법)의 비용을 통계로 어림해 가장 싼 것을 고른다.
  4. 실행: 계획 트리의 각 노드가 "다음 행을 달라"는 요청에 답하며 행을 하나씩 위로 넘긴다. 이 방식을 반복자 모델(iterator, Volcano 모델)이라 한다. 분석용 엔진은 행 대신 수천 행 묶음을 넘겨(벡터화) 함수 호출 비용을 줄인다.

인덱스의 종류

인덱스는 테이블과 따로 둔 정렬된(또는 해시된) 사본이다. 찾기는 빨라지지만 행을 넣고 고칠 때마다 인덱스도 함께 고쳐야 하고 공간도 차지한다. 그래서 "자주 쓰는 질의의 조건"에 맞춰 골라 만든다.

  • B+ 트리: 기본값. 같음(=)과 범위(<, BETWEEN), 정렬(ORDER BY), 앞부분 일치(LIKE 'abc%')에 쓴다. 구조는 저장 엔진 노트에 있다.
  • 해시: 같음 비교만 한다. 범위나 정렬에는 못 쓴다. 원리는 해싱 노트에 있다.
  • 커버링 인덱스: 질의가 쓰는 열이 모두 인덱스에 있으면 테이블을 읽지 않고 인덱스만으로 답한다. PostgreSQL은 이를 Index-Only Scans라 부르고, INCLUDE로 검색에 안 쓰는 열을 잎에만 얹을 수 있다.
  • 그 밖: 전문 검색용 역색인(GIN), 공간 데이터용 GiST, 아주 큰 표의 물리 순서를 이용한 BRIN 등이 있다. PostgreSQL 문서의 Index Types가 목록을 준다.

여러 열을 묶은 복합 인덱스(composite index)는 첫 열로 정렬하고, 첫 열이 같은 것끼리 둘째 열로 정렬한다. 그래서 왼쪽 열부터 조건이 걸려야 연속된 구간으로 좁혀진다. 이를 왼쪽 앞부분 규칙(leftmost prefix)이라 한다.

(도시, 나이) 순으로 정렬된 인덱스 항목 일곱 개. 도시와 나이 범위로 찾으면 두 칸, 도시로만 찾으면 네 칸의 연속 구간이 되지만, 나이만으로 찾으면 맞는 항목이 흩어져 인덱스로 좁힐 수 없다도시 = '서울'AND 나이 BETWEEN 25 AND 36부산25부산31서울22서울28서울35서울41인천30도시나이앞 열 2개로 한 구간도시 = '서울'부산25부산31서울22서울28서울35서울41인천30도시나이앞 열 1개로 한 구간나이 = 30부산25부산31서울22서울28서울35서울41인천30도시나이좁힐 수 없다: 전부 훑기인덱스는 도시로 먼저, 같은 도시 안에서 나이로 정렬된다.성으로 먼저 정렬된 전화번호부에서 이름만으로는 찾을 수 없는 것과 같다.
(도시, 나이) 복합 인덱스. 도시를 같음으로 고정하면 그 안에서 나이가 정렬돼 있어 범위로 좁힐 수 있지만, 도시 조건 없이 나이만 주면 맞는 항목이 인덱스 곳곳에 흩어진다.

범위 조건 뒤의 열은 좁히는 데 쓰이지 않는다. (도시, 나이, 이름) 인덱스에서 도시 = '서울' AND 나이 > 30 AND 이름 = '김하나'는 도시와 나이로 구간을 잡고 이름은 그 구간을 훑으며 거른다. 그래서 같음 조건 열을 앞에, 범위 조건 열을 뒤에 두는 것이 보통 유리하다. Markus Winand의 Use The Index, Luke의 Concatenated Keys와 PostgreSQL 문서 Multicolumn Indexes가 같은 규칙을 설명한다.

선택도와 비용 기반 옵티마이저

인덱스가 있다고 늘 쓰는 것은 아니다. 인덱스로 찾은 행마다 테이블 페이지를 따로 읽어야 하므로, 고르는 행이 많으면 차라리 테이블을 처음부터 끝까지 이어 읽는 편이 싸다. 조건에 맞는 행의 비율을 선택도(selectivity)라 하고, 옵티마이저는 이것을 어림해 계획을 고른다.

비용은 페이지 읽기와 CPU 처리의 가중합이다. PostgreSQL의 기본값은 순차 페이지 읽기 1, 무작위 페이지 읽기 4, 행 하나 처리0.01, 조건 연산 하나 0.0025다(Query Planning 설정). 문서의 예인 345페이지·1만 행 테이블을 모두 훑는 비용은 345 × 1.0 + 10000 × 0.01 = 445이고, WHERE 조건 하나를 붙이면 행마다 연산 비용이 더해져 470가 된다.

선택도 0~3%에서 순차 스캔 비용은 470로 평평하고, 인덱스 스캔 비용은 선택도에 비례해 올라가 약 1.2%에서 만난다040080012000%1%2%3%선택도(조건에 맞는 행의 비율)비용(플래너 단위)약 1.2%에서 역전순차 스캔 470인덱스 스캔행마다 무작위 페이지 1장(random_page_cost 4)이라 가정
순차 스캔과 인덱스 스캔의 비용(단순 모형). 맞는 행마다 무작위 페이지를 하나씩 읽는다고 치면 약 1.2%보다 많이 고를 때 순차 스캔이 싸다. 실제 플래너는 캐시와 물리 순서를 따져 이 갈림길을 더 정교하게 잡는다.

어림의 바탕은 통계다. PostgreSQL은 ANALYZE가 표본을 읽어 열마다 빈 값 비율, 서로 다른 값의 수, 가장 흔한 값 목록(MCV)과 그 빈도, 나머지 값의 히스토그램을 모아 둔다(Statistics Used by the Planner). 나이 < 30의 선택도는 히스토그램에서 30 아래 칸의 비율로, 도시 = '서울'은 MCV 목록의 빈도로 어림한다.

비용 기반 최적화는 IBM System R의 1979년 논문 「Access Path Selection in a Relational Database Management System」(Selinger 등)이 틀을 잡았다. 조인 순서를 동적 계획법으로 고르고, 정렬된 결과처럼 뒤에서 쓸모 있는 성질("흥미로운 순서")을 가진 부분 계획을 함께 남기는 방식은 지금 옵티마이저에도 그대로 남아 있다. 약점도 그대로다. 열끼리 상관이 있으면(도시 = '서울'과 지역번호 = '02'처럼) 독립이라 가정한 곱셈이 행 수를 크게 틀리고, 조인이 겹칠수록 오차가 곱해진다. 계획이 갑자기 나빠지면 통계가 오래됐거나 상관을 놓친 경우가 많다.

조인 알고리즘

두 테이블 R(바깥)과 S(안쪽)를 잇는 방법은 크게 셋이다. 디스크 기반 비용은 페이지 I/O 수로 센다. 아래 숫자는 Carnegie Mellon 대학 데이터베이스 강의의 조인 강의 노트와 같은 예(R 1000페이지·10만 행, S 500페이지·4만 행)로 계산했다.

  • 중첩 루프(nested loop): R의 행마다 S를 훑는다. 그대로 하면 50,001,000번, 페이지 단위로 묶어도 501,000번이다. 버퍼 100장을 R 묶음에 쓰면 6,500번까지 준다. S의 조인 열에 인덱스가 있으면 R의 행마다 인덱스를 찾는 인덱스 중첩 루프가 되어, R이 작을 때 가장 빠르다.
  • 정렬-병합(sort-merge): 두 쪽을 조인 열로 정렬한 뒤 지퍼처럼 맞물려 훑는다. 정렬에 6,000번, 병합에 1,500번으로 7,500번. 이미 정렬된 입력(인덱스 순서)이면 정렬 비용이 빠진다.
  • 해시 조인(hash join): 작은 쪽으로 해시 테이블을 만들고 큰 쪽으로 찾는다. 메모리에 다 안 들어가면 두 쪽을 같은 해시로 조각내 디스크에 썼다가 조각끼리 조인한다(Grace 해시 조인). 3 × (1000 + 500) = 4,500번. 같음 조건에만 쓴다.
조인 알고리즘별 페이지 I/O: 단순 중첩 루프 50,001,000회, 블록 중첩 루프(버퍼 3장) 501,000회, 블록 중첩 루프(버퍼 100장) 6,500회, 정렬-병합 7,500회, 해시(Grace) 4,500회10^310^410^510^610^710^8단순 중첩 루프약 1.4시간블록 중첩 루프(버퍼 3장)약 50.1초블록 중첩 루프(버퍼 100장)약 650ms정렬-병합약 750ms해시(Grace)약 450ms가로축은 페이지 I/O 수(로그 눈금), 막대 끝은 I/O 한 번에 0.1ms로 친 시간이다.R 1000페이지·10만 행, S 500페이지·4만 행, 버퍼 100페이지. 결과를 쓰는 비용은 뺐다.
같은 조인을 알고리즘만 바꿔 계산한 페이지 I/O 수. 단순 중첩 루프(약 1.4시간)와 해시 조인(약 450ms)은 만 배 넘게 차이 난다.
해시 조인 단계 그림: 고객 세 행을 버킷 세 개에 나눠 담은 뒤, 주문 다섯 행을 하나씩 자기 버킷에서 찾아 짝 네 쌍을 낸다고객(만들기 쪽)주문(찾기 쪽)해시 테이블(열쇠 mod 3)결과(고객–주문)버킷 0버킷 1버킷 2A열쇠 1B열쇠 2C열쇠 4u열쇠 2v열쇠 4w열쇠 7x열쇠 1y열쇠 2① 만들기: 고객을 한 번 읽는다A열쇠 1B열쇠 2C열쇠 4u열쇠 2v열쇠 4w열쇠 7x열쇠 1y열쇠 2A·1① 만들기: 고객을 한 번 읽는다A열쇠 1B열쇠 2C열쇠 4u열쇠 2v열쇠 4w열쇠 7x열쇠 1y열쇠 2A·1B·2① 만들기: 고객을 한 번 읽는다A열쇠 1B열쇠 2C열쇠 4u열쇠 2v열쇠 4w열쇠 7x열쇠 1y열쇠 2A·1C·4B·2① 만들기: 고객을 한 번 읽는다A열쇠 1B열쇠 2C열쇠 4u열쇠 2v열쇠 4w열쇠 7x열쇠 1y열쇠 2A·1C·4B·2B–u (열쇠 2)② 찾기: 주문을 한 번 읽는다A열쇠 1B열쇠 2C열쇠 4u열쇠 2v열쇠 4w열쇠 7x열쇠 1y열쇠 2A·1C·4B·2B–u (열쇠 2)C–v (열쇠 4)② 찾기: 주문을 한 번 읽는다A열쇠 1B열쇠 2C열쇠 4u열쇠 2v열쇠 4w열쇠 7x열쇠 1y열쇠 2A·1C·4B·2B–u (열쇠 2)C–v (열쇠 4)② 찾기: 주문을 한 번 읽는다A열쇠 1B열쇠 2C열쇠 4u열쇠 2v열쇠 4w열쇠 7x열쇠 1y열쇠 2A·1C·4B·2B–u (열쇠 2)C–v (열쇠 4)A–x (열쇠 1)② 찾기: 주문을 한 번 읽는다A열쇠 1B열쇠 2C열쇠 4u열쇠 2v열쇠 4w열쇠 7x열쇠 1y열쇠 2A·1C·4B·2B–u (열쇠 2)C–v (열쇠 4)A–x (열쇠 1)B–y (열쇠 2)② 찾기: 주문을 한 번 읽는다A열쇠 1B열쇠 2C열쇠 4u열쇠 2v열쇠 4w열쇠 7x열쇠 1y열쇠 2A·1C·4B·2B–u (열쇠 2)C–v (열쇠 4)A–x (열쇠 1)B–y (열쇠 2)끝: 두 테이블을 한 번씩만 읽었다
1 / 10

만들기 단계. 작은 쪽 테이블을 한 번 읽으며 조인 열쇠의 해시(열쇠 mod 3)로 버킷에 나눠 담는다.

해시 조인. 만들기 단계에서 작은 쪽을 해시 테이블에 담고, 찾기 단계에서 큰 쪽의 각 행은 자기 버킷만 본다. 두 테이블을 한 번씩만 읽으므로 비용이 두 크기의 합에 비례한다. 같음 조건(=)에만 쓸 수 있다.

옵티마이저는 테이블 크기, 조인 조건 종류, 정렬된 입력이 있는지, 메모리(PostgreSQL의 work_mem)를 보고 고른다. 결과가 몇 행뿐인 OLTP 질의는 인덱스 중첩 루프, 큰 테이블끼리의 분석 질의는 해시 조인이 흔히 뽑힌다.

EXPLAIN 읽기

옵티마이저가 무엇을 골랐는지는 EXPLAIN으로 본다. 출력은 계획 트리를 들여쓰기로 보여 주고, 가장 안쪽 노드부터 실행되어 바깥으로 행을 올린다. 아래는 PostgreSQL 문서 Using EXPLAIN의 예다.

EXPLAIN 출력 네 줄: 비트맵 힙 스캔 아래에 비트맵 인덱스 스캔이 자식으로 있고, 각 노드에 비용 범위, 어림 행 수, 행 너비가 붙어 있다Bitmap Heap Scan on tenk1  (cost=5.06..224.98 rows=100 width=244)  Recheck Cond: (unique1 < 100)  ->  Bitmap Index Scan on tenk1_unique1  (cost=0.00..5.04 rows=100 width=0)        Index Cond: (unique1 < 100)①②③④① cost=시작..전체: 첫 행을 내기까지의 비용 .. 모든 행을 내기까지의 비용(플래너 단위)② rows: 이 노드가 낼 것으로 어림한 행 수, width: 행 하나의 평균 바이트③ ->: 자식 노드. 안쪽 노드가 행을 만들어 바깥 노드로 올린다(아래에서 위로 읽는다)④ Index Cond는 인덱스로 좁힌 조건, Recheck·Filter는 행을 읽은 뒤 다시 거르는 조건EXPLAIN ANALYZE는 실제로 실행해 실제 시간과 행 수를 함께 보여 준다. 어림과 실제가 크게 다르면 통계부터 본다.
EXPLAIN 출력의 칸. 비트맵 인덱스 스캔이 조건에 맞는 행의 위치를 모아 페이지 순서로 정렬하고, 바깥의 비트맵 힙 스캔이 그 페이지만 읽는다. 무작위 읽기를 줄이는 중간 방식이다.
  • 노드 이름이 접근 방법이다. Seq Scan은 전체 훑기, Index Scan은 인덱스 순서로 행 읽기, Index Only Scan은 테이블을 안 읽는 커버링, Bitmap Heap Scan은 그 중간이다. 조인은 Nested Loop·Hash Join·Merge Join으로 나온다.
  • EXPLAIN ANALYZE는 질의를 실제로 실행해 노드마다 실제 시간, 실제 행 수, 반복 횟수(loops)를 함께 적는다. 어림 행 수와 실제 행 수가 열 배 넘게 다른 노드가 보통 문제의 출발점이다. 쓰기 질의에 쓰면 실제로 고쳐지므로 트랜잭션 안에서 실행하고 되돌린다.
  • BUFFERS 옵션을 더하면 노드마다 버퍼 풀에서 찾은 페이지(hit)와 디스크에서 읽은 페이지(read) 수가 나온다(EXPLAIN 명령).

흔한 함정

인덱스가 있는데도 안 쓰이거나, 질의 하나하나는 빠른데 화면이 느린 경우는 대개 몇 가지 유형으로 나뉜다.

  • 인덱스 열을 함수로 감싸기: WHERE lower(email) = 'a@b.c'나 WHERE date(created_at) = '2026-10-30'는 인덱스에 든 값이 아니라 함수 결과를 비교하므로 일반 인덱스를 못 쓴다. 범위로 바꾸거나(created_at >= '2026-10-30' AND created_at < '2026-10-31') 같은 식으로 표현식 인덱스를 만든다. Use The Index, Luke의 Obfuscated Conditions 장이 이런 경우를 모았다.
  • 형 변환: 문자열 열을 숫자와 비교하면 열 쪽이 변환되어 함수로 감싼 것과 같아질 수 있다. 상수의 형을 열에 맞춘다.
  • 앞이 열린 LIKE: LIKE '%abc'는 정렬 순서를 쓸 수 없다. 전문 검색 인덱스나 트라이그램 인덱스가 필요하다.
  • 큰 OFFSET: OFFSET 100000 LIMIT 20은 앞의 10만 행을 읽고 버린다. 마지막으로 본 키 다음부터 읽는 키 기반 페이지 나누기(WHERE id > ? ORDER BY id LIMIT 20)가 일정한 속도를 낸다.
  • N+1 질의: 목록을 한 번 읽고 항목마다 딸린 데이터를 다시 묻는 패턴이다. ORM이 연관 객체를 늦게 불러올 때 흔히 생긴다. 질의 하나는 1ms여도 100개면 100ms다.
글 100개의 작성자를 가져올 때 글마다 묻는 방식은 왕복 101번에 약 101ms, 한 번에 묻는 방식은 왕복 2번에 약 2ms글마다 묻기: 질의 101번 ≈ 101msSELECT * FROM 글 LIMIT 100; 다음에 SELECT * FROM 사용자 WHERE id = ?; 를 100번한 번에 묻기: 질의 2번 ≈ 2msSELECT * FROM 사용자 WHERE id IN (…100개…); 또는 글과 사용자를 조인해 한 번에목록·묶음 질의항목마다 한 번씩 · 칸 하나가 왕복 1ms(같은 데이터센터 안이라 가정)
N+1 질의와 한 번에 묻기. 데이터베이스 안의 일은 비슷해도 네트워크 왕복 수가 50배 차이 난다. ORM의 즉시 불러오기(eager loading)나 IN·조인으로 묶는다.