SQL은 누구나 작성할 수 있지만 실제로 그 쿼리가 내부에서 어떻게 실행되는지까지 이해하는 경우는 많지 않다. SELECT 한 줄이 실행될 때 내부에서는 어떤 일이 일어날까? 왜 어떤 쿼리는 빠르고, 어떤 쿼리는 느릴까?
이 질문에 답하려면 SQL 엔진이라는 개념을 이해해야 한다.
SQL 엔진이란
SQL 엔진은 SQL 쿼리를 입력받아 논리적 질의를 물리적 실행 단위로 변환하고 이를 실제 자원을 사용해 수행하는 실행 계층이다.
•
SQL을 내부 표현(논리 계획)으로 변환
•
논리 계획을 물리 계획으로 변환
•
물리 연산 단위(operator)로 분해
•
연산을 스케줄링하고 실행
•
중간 결과를 메모리/디스크에서 관리
즉 SQL 엔진은 선언형 질의를 실제 실행 가능한 계산 그래프로 컴파일하고 실행하는 시스템이다.
이 구조를 이해하기 위해 먼저 SQL 엔진이 내부적으로 어떤 모델을 사용하는지부터 살펴볼 필요가 있다.
SQL 엔진의 내부 모델: Logical Plan vs Physical Plan
SQL 엔진은 쿼리를 두 단계의 Plan으로 변환한다.
Logical Plan — 무엇을 할 것인가
논리 계획은 쿼리의 의미를 표현한다. A와 B를 조인하고, 특정 조건으로 필터링하고, 결과를 집계한다는 식의 선언이다. 어떤 알고리즘을 쓸지, 어떤 순서로 데이터를 읽을지는 아직 결정되지 않는다. 관계 대수(Relational Algebra)에 기반한 트리 구조로 표현되며 이 시점에서 엔진은 쿼리가 무엇을 원하는지만 파악한다.
Physical Plan — 어떻게 실행할 것인가
물리 계획은 논리 계획의 각 연산을 실제 실행 방법으로 구체화한다.
•
Join → Hash Join / Nested Loop Join / Sort-Merge Join 중 선택
•
데이터 접근 → Full Table Scan / Index Scan 중 선택
이 단계에서 실제 CPU 연산 방식과 I/O 패턴이 결정된다.
의미(What)와 실행(How)을 분리해야 최적화가 가능하기 때문에 두 계획을 분리한다. 논리 계획이 고정된 상태에서 물리 계획을 교체하면 쿼리의 결과는 동일하게 유지하면서 성능만 바꿀 수 있다.
SQL은 어떻게 실행되는가: 컴파일 단계
SQL 엔진은 쿼리를 실행 가능한 형태로 변환하기 위해 컴파일 과정을 거친다.
SQL → Parsing → Logical Plan → Optimization → Physical Plan
Python
복사
1. Parsing
SQL 문자열을 구문 분석해 AST(Abstract Syntax Tree)로 변환한다. 이 단계에서는 문법 오류를 잡아낸다. SELCT 같은 오타나 괄호 불일치가 여기서 걸러진다. 또한 테이블명, 컬럼명이 실제로 존재하는지 스키마와 대조하는 semantic validation도 이 단계에 포함된다.
2. Logical Plan 생성
AST를 관계 대수 기반의 연산자 트리로 변환한다. FROM, WHERE, GROUP BY, SELECT 절은 각각 Scan, Filter, Aggregate, Project 연산자에 대응된다. 이 시점에서 쿼리는 트리 형태의 논리 계획으로 표현된다.
3. Optimization
옵티마이저가 논리 계획을 등가 변환 규칙에 따라 재작성한다. 이 단계가 SQL 엔진에서 가장 복잡하고 중요한 부분이다.
대표적인 최적화 기법은 다음과 같다.
•
Predicate Pushdown
◦
필터 조건을 가능한 한 앞쪽으로 밀어 불필요한 데이터를 조기에 제거
Before: Scan(1억) → Join → Filter(country='KR') → 결과
After: Scan(1억) → Filter(country='KR') → Join(10만) → 결과
Python
복사
•
Projection Pruning
◦
실제로 필요한 컬럼만 읽도록 함
◦
컬럼 지향 스토리지(Parquet, ORC 등)에서는 읽지 않는 컬럼은 I/O 자체가 발생하지 않기 때문에 효과가 큼
•
Join Reordering
◦
통계 정보(row count, cardinality)를 기반으로 조인 순서를 바꿔 중간 결과 크기를 최소화
◦
중간 결과가 작을수록 이후 연산 비용이 줄어듬
orders (1억) → users (1만) → products (500)
옵티마이저 판단:
products (500) → users (1만) → orders (1억)
Python
복사
◦
조인 순서 하나로 실행 시간이 10배 이상 달라질 수 도 있음
•
Constant Folding: WHERE year = 2020 + 4 같은 표현식을 컴파일 타임에 미리 계산
옵티마이저는 대부분 비용 기반(Cost-Based)으로 동작하는 경우가 많다. 각 실행 계획의 예상 비용(I/O, CPU, 메모리)을 추정하고 그 중 최소 비용 계획을 선택한다. 이때 통계가 오래됐거나 부정확하면 옵티마이저가 잘못된 판단을 내리기도 한다. 느린 쿼리를 디버깅할 때 실행 계획을 먼저 확인하는 이유가 여기에 있다.
4. Physical Plan 생성
최적화된 논리 계획을 실제 실행 가능한 물리 계획으로 변환한다. 각 논리 연산자가 구체적인 알고리즘으로 매핑된다. Join 하나를 예로 들면, 두 테이블의 크기, 인덱스 유무, 정렬 여부에 따라 Hash Join, Nested Loop Join, Sort-Merge Join 중 하나가 선택된다.
Execution: 물리 계획의 실행 단계
컴파일이 끝나면 SQL 엔진은 물리 계획을 기반으로 실행을 수행한다. 이때 실행은 operator 단위로 이루어진다.
operator는 데이터를 입력받아 특정 연산을 수행하고 결과를 출력하는 단위 컴포넌트로 대표적인 operator는 다음과 같다.
Operator | 역할 |
Scan | 스토리지에서 데이터를 읽어 메모리로 올림 |
Filter | 조건에 맞지 않는 row를 제거 |
Project | 필요한 컬럼만 선택하거나 표현식을 계산 |
Join | 두 데이터셋을 특정 키 기준으로 결합 |
Aggregate | GROUP BY, COUNT, SUM 등 집계 연산을 수행 |
Sort | 결과 정렬 |
이 operator들은 독립적으로 존재하는 것이 아니라 파이프라인 형태로 연결된 데이터 흐름을 구성한다. 예를 들어 하나의 쿼리는 다음과 같은 흐름으로 실행된다.
Scan → Filter → Join → Aggregate
Python
복사
이 구조는 트리 혹은 DAG 형태로 표현되며 SQL 엔진은 이 그래프를 따라 데이터를 처리한다.
데이터 처리 방식: Pipelining vs Materialization
실행 과정에서 operator 간에 데이터를 어떻게 전달하는지가 성능에 직접적인 영향을 미친다.
•
Pipelined Execution
◦
operator 간에 row를 즉시 전달
◦
중간 결과를 별도로 저장하지 않아 메모리 사용량이 낮음
◦
Scan → Filter처럼 앞 단계 결과를 그대로 흘려보낼 수 있는 구간에서 사용
•
Materialized Execution
◦
중간 결과를 메모리나 디스크에 전부 저장한 뒤 다음 단계로 넘김
◦
전체 데이터를 한 번에 봐야 결과를 낼 수 있는 연산은 materialization이 필수
◦
데이터가 크면 메모리를 초과해 디스크로 spill이 발생
두 방식의 차이가 가장 잘 드러나는 Hash Join을 예시로 들어보자.
SELECT u.name, COUNT(o.id)
FROM orders o JOIN users u ON o.user_id = u.id
GROUP BY u.name
SQL
복사
위 쿼리의 operator 흐름:
Scan(orders) ──┐
├──→ Hash Join ──→ Aggregate
Scan(users) ──┘
SQL
복사
Pipelined: 데이터가 operator 사이를 흘러다님
Scan → Filter 구간
orders row 1 읽음 → 즉시 Filter로 넘김
orders row 2 읽음 → 즉시 Filter로 넘김
orders row 3 읽음 → 즉시 Filter로 넘김
...
SQL
복사
•
row 하나 읽고 바로 다음 단계로 가서 중간에 쌓이는게 없음.
Materialized: 다음 단계로 넘기기 전에 전부 모아야 함
Hash Join 구간
[Build phase]
users row 전체를 메모리에 해시 테이블로 적재
→ 다 올라올 때까지 대기
[Probe phase]
orders row 하나씩 → 해시 테이블에서 매칭
SQL
복사
•
users 테이블이 전부 메모리에 올라와야 probe를 시작할 수 있음
리소스 관리
SQL 엔진은 실행 과정에서 다음과 같은 리소스 관리를 수행한다.
•
메모리 버퍼 관리: operator마다 할당된 메모리 내에서 데이터를 처리
•
디스크 spill 처리: 메모리가 부족하면 중간 결과를 디스크에 기록하고 이후 다시 읽음
•
병렬 실행: 독립적인 연산을 여러 thread/worker에 분산해 동시에 실행
•
데이터 분배: 분산 환경에서는 partitioning과 shuffle을 통해 데이터를 적절한 노드에 배분
정리
지금까지의 내용을 하나로 정리하면 다음과 같다.
SQL → Logical Plan → Physical Plan → Operator Graph → Execution
SQL은 선언형 언어지만 SQL 엔진은 이를 실행 가능한 형태로 변환하고 실제 연산으로 수행한다. 이 컴파일-실행 구조를 이해하면 느린 쿼리의 원인을 추적하거나 실행 계획을 읽는 것이 훨씬 수월해진다.
하지만 실제 엔진들은 이 구조를 구현하는 방식에서 큰 차이를 보인다. 특히 데이터 저장소와 분리된 분산 SQL 엔진은 기존 데이터베이스와는 다른 방식으로 실행을 수행한다.
다음 글에서는 이러한 분산 SQL 엔진이 어떻게 동작하는지, 그리고 어떤 문제를 해결하기 위해 등장했는지 살펴본다.