LLM WikiAccess-protected knowledge portal
← 스터디 홈
3편 · 약 24분

Trino 쿼리 성능 튜닝: EXPLAIN ANALYZE, CBO, 파티셔닝 전략

느린 쿼리를 마주쳤을 때 어디서 시작하는가

Trino 쿼리가 기대보다 느리거나 OOM(Out of Memory)으로 실패하면 막막하다. 어느 단계에서 병목이 생겼는지, 조인 순서가 잘못된 건지, 파일을 너무 많이 읽는 건지 알기 어렵다.

Trino는 두 가지 진단 도구를 제공한다.

  • EXPLAIN: 쿼리를 실제로 실행하지 않고 실행 계획(plan)과 비용 추정치를 보여준다.
  • EXPLAIN ANALYZE: 쿼리를 실제로 실행하고, 각 단계의 실측 통계(행 수, CPU 시간, 소요 시간)를 계획 위에 겹쳐 보여준다.

EXPLAIN 읽기

EXPLAIN
SELECT c.name, SUM(o.amount)
FROM mysql_prod.crm.customers c
JOIN iceberg.warehouse.orders o ON c.id = o.customer_id
WHERE o.order_date >= DATE '2026-01-01'
GROUP BY c.name;

출력 예시(요약):

Fragment 0 [SINGLE]
  Output[columnNames = [name, _col1]]
  └─ Aggregate(FINAL)[name]
     │   _col1 := sum("sum")
     └─ Exchange[GATHER]
        └─ Fragment 1 [HASH(name)]
             Aggregate(PARTIAL)[name]
               sum := sum("amount")
             └─ InnerJoin[("id" = "customer_id")]
                    Cost: {rows: 500000 (40MB), cpu: 2.00G, memory: 50MB, network: 40MB}
                ├─ ScanProject[table = mysql_prod:crm.customers]
                │       Estimates: {rows: 200000 (16MB), ...}
                └─ ScanFilterProject[table = iceberg:warehouse.orders, filterPredicate = ...]
                        Estimates: {rows: 5000000 (400MB), ...}

Fragment: Stage의 표현

출력에 나타나는 Fragment는 실행 계획의 Stage에 해당한다. Fragment 경계에서 Exchange(워커 간 데이터 이동)가 발생한다. Fragment 수가 많다는 것은 셔플이 많다는 뜻이다.

Cost 블록 읽기

Cost: {rows: 500000 (40MB), cpu: 2.00G, memory: 50MB, network: 40MB}
항목의미
rows: 500000이 노드가 출력할 것으로 예상되는 행 수
(40MB)예상 출력 데이터 크기
cpu: 2.00G예상 CPU 비용 (상대적 단위)
memory: 50MB예상 메모리 사용량
network: 40MB예상 네트워크 전송량

이 숫자들은 추정치다. 통계가 없거나 오래됐으면 크게 틀릴 수 있다.


EXPLAIN ANALYZE: 실측 데이터와 비교

EXPLAIN ANALYZE
SELECT c.name, SUM(o.amount)
FROM mysql_prod.crm.customers c
JOIN iceberg.warehouse.orders o ON c.id = o.customer_id
WHERE o.order_date >= DATE '2026-01-01'
GROUP BY c.name;

EXPLAIN ANALYZE는 쿼리를 실제 실행하고 실측 통계를 계획에 붙인다.

EXPLAIN ANALYZE 출력 해독법 실행 계획 노드 (예시) InnerJoin[("id" = "customer_id")] Actual rows output: 487,231 Actual CPU time: 1m 23s (total) Scheduled time: 4m 02s Input rows: 200,000 + 4,920,000 Estimates: {rows: 500000 (40MB)} Actual: rows: 487231 (≈예측과 비슷) 항목별 의미 Actual rows output 이 노드가 실제로 출력한 행 수. Estimates와 크게 차이나면 통계 오류. Actual CPU time 모든 워커의 CPU 시간 합계. 크면 연산이 많다는 뜻 (해시 빌드 등). Scheduled time 워커에 스케줄된 총 시간. CPU << Scheduled → I/O 대기 병목. Input rows (양쪽 입력) 조인 양쪽이 읽은 실제 행 수. 한쪽이 너무 크면 조인 순서 문제. Estimates vs Actual 차이 10배 이상 차이나면 ANALYZE 실행 필요.
EXPLAIN ANALYZE 출력 구조

CPU time vs Scheduled time 해석

Actual CPU timeScheduled time보다 훨씬 작으면, 워커가 CPU를 쓰지 않고 기다리고 있다는 뜻이다. 이는 I/O 병목(S3에서 데이터를 느리게 읽거나, MySQL에서 결과가 늦게 오거나)을 나타낸다.

Actual CPU time: 30s
Scheduled time: 5m 10s
→ CPU 효율 ≈ 10%. I/O 병목이 거의 모든 시간을 차지함.

반대로 CPU time ≈ Scheduled time이면 연산이 병목이다. 조인 알고리즘 변경, 집계 순서 조정, 더 많은 워커 추가 등이 도움된다.


비용 기반 최적화(CBO)

CBO가 결정하는 것

Trino CBO는 쿼리 실행 전, 통계 정보를 바탕으로 다음을 최적화한다.

  1. 조인 순서: 여러 테이블 조인 시 어떤 순서로 조인할지.
  2. 조인 알고리즘: Broadcast Join vs Partitioned (Hash) Join.
  3. 집계 위치: Partial 집계를 워커에서 먼저 하고 이후 Final 집계를 할지.
  4. 푸시다운 여부: 필터/집계를 커넥터로 내릴지.
테이블 통계 수집
ANALYZE or 커넥터 자동
비용 추정
rows·bytes·카디널리티
조인 순서 결정
최소 비용 순열 선택
실행 계획 확정
Broadcast or Hash Join
CBO 동작 조건
통계가 있어야 함
SHOW STATS FOR t
+ join_reordering_strategy
= AUTOMATIC (기본값)
+ join_distribution_type
= AUTOMATIC (기본값)
CBO 의사결정 흐름

통계 확인과 갱신

-- 현재 통계 상태 확인
SHOW STATS FOR iceberg.warehouse.orders;

출력 예:

column_name  | data_size | distinct_values_count | nulls_fraction | row_count
-------------|-----------|----------------------|----------------|----------
customer_id  | 39321600  | 850000               | 0.0            | NULL
amount       | 39321600  | 50000                | 0.02           | NULL
order_date   | 9830400   | 365                  | 0.0            | NULL
             |           |                      |                | 4920000

row_count가 NULL이 아닌 숫자가 마지막 행에 있으면 통계가 있다는 뜻이다. distinct_values_count가 NULL이면 카디널리티 통계가 없어 CBO 정확도가 낮아진다.

-- 통계 갱신 (Iceberg/Hive)
ANALYZE iceberg.warehouse.orders;

-- 특정 컬럼만 (대용량 테이블에서 시간 절약)
ANALYZE iceberg.warehouse.orders
  WITH (columns = ARRAY['customer_id', 'order_date', 'amount']);

MySQL/PostgreSQL 커넥터는 자동으로 DB 내장 통계를 사용한다. Kafka 커넥터는 통계를 제공하지 않는다.

Broadcast Join vs Hash Join

가장 중요한 CBO 결정 중 하나다.

방식동작언제 유리한가단점
Broadcast Join작은 테이블을 모든 워커에 복사 후 로컬 조인한쪽이 수십 MB 이하일 때큰 테이블엔 메모리 폭발
Hash Join양쪽을 조인 키 해시로 파티셔닝 후 같은 파티션끼리 조인양쪽 모두 클 때Exchange(셔플) 비용 발생

CBO가 잘못된 선택을 하거나 통계가 없을 때 힌트로 강제할 수 있다.

-- Broadcast 강제
SELECT /*+ BROADCAST(small_table) */ ...
FROM big_table
JOIN small_table ON ...

-- Hash Join 강제 (기본 동작이지만 명시 가능)
SET SESSION join_distribution_type = 'PARTITIONED';

파티셔닝 전략

Iceberg 테이블의 파티셔닝은 Trino 쿼리 성능에 직접 영향을 미친다. 파티션 프루닝이 잘 동작하면 읽는 파일 수가 줄어든다.

파티션 프루닝이 동작하는 조건

-- ✅ 프루닝 동작: 상수 조건
WHERE order_date = DATE '2026-07-01'
WHERE order_date BETWEEN DATE '2026-07-01' AND DATE '2026-07-31'

-- ✅ 프루닝 동작: IN 절
WHERE region IN ('KR', 'JP', 'SG')

-- ❌ 프루닝 안 됨: 함수 변환
WHERE DATE_TRUNC('month', order_date) = DATE '2026-07-01'
WHERE YEAR(order_date) = 2026

함수를 파티션 컬럼에 적용하면 Trino는 프루닝을 건너뛰고 모든 파일을 읽는다. 파티션 컬럼을 직접 조건에 쓰도록 SQL을 바꾸는 것이 더 낫다.

-- 월별 조회: DATE_TRUNC 대신 범위 조건
WHERE order_date >= DATE '2026-07-01'
  AND order_date <  DATE '2026-08-01'

파티션 설계 선택지

파티션 세분도(Granularity) 선택 파티션 세분도 거침 (연별) 세밀 (시간별) 성능 파티션 프루닝 이득 파일 수 증가 (메타데이터 오버헤드) 최적점 (일/주 파티션) 연(year) 월(month) 일(day) 시간(hour) 분(minute)
Iceberg 파티션 설계 트레이드오프

실무 권장: 일(day) 파티션이 대부분의 이벤트 테이블에 무난한 선택이다. 조회 범위가 주로 최근 수일~수주라면 충분한 프루닝 효과를 얻으면서 파일 수도 관리 가능하다.

Iceberg Partition Evolution

Iceberg의 강점 중 하나는 테이블을 재작성하지 않고도 파티션 방식을 바꿀 수 있다는 것이다.

-- 기존: 월별 파티션
CREATE TABLE orders (
    id BIGINT,
    order_date DATE,
    ...
) WITH (
    partitioning = ARRAY['month(order_date)']
);

-- 파티션을 일별로 변경 (기존 데이터는 유지)
ALTER TABLE orders
SET PROPERTIES partitioning = ARRAY['day(order_date)'];

변경 이후 신규 데이터는 일별 파티션에, 기존 데이터는 원래 월별 파티션에 그대로 있다. Trino는 이 혼재 상태를 투명하게 처리한다.


세션 속성을 이용한 성능 조정

쿼리 수준에서 동작을 조정하는 주요 세션 속성이다.

-- 조인 알고리즘 고정
SET SESSION join_distribution_type = 'BROADCAST';  -- 또는 'PARTITIONED', 'AUTOMATIC'

-- 조인 순서 재정렬 전략
SET SESSION join_reordering_strategy = 'AUTOMATIC'; -- 또는 'ELIMINATE_CROSS_JOINS', 'NONE'

-- 집계 푸시다운 활성화 (MySQL/PostgreSQL)
SET SESSION mysql_prod.aggregation_pushdown_enabled = true;

-- 해시 조인 파티션 수 조정 (기본 100)
SET SESSION hash_partition_count = 200;

-- 스필 활성화 (메모리 부족 시 디스크 사용)
SET SESSION spill_enabled = true;

실무 진단 순서

EXPLAIN ANALYZE
실행
가장 느린 Stage
찾기 (Scheduled time)
CPU vs Scheduled
비율 확인
CPU << Scheduled
(I/O 병목)
→ 파티션 프루닝 동작 확인 → 파일 크기/수 확인 (compaction) → 커넥터 pushdown 여부 점검
CPU ≈ Scheduled
(연산 병목)
→ 조인 순서/알고리즘 점검 → Estimates vs Actual 비교 → ANALYZE로 통계 갱신
OOM / 스필 발생 → 큰 조인 빌드 쪽 확인 → 불필요한 컬럼 제거 (SELECT *) → spill_enabled 활성화 or 메모리 증설
느린 쿼리 진단 워크플로

자주 발생하는 성능 문제와 해결

문제 1: 잘못된 조인 순서

-- 문제: CBO가 큰 테이블을 Broadcast 대상으로 선택
EXPLAIN ANALYZE
SELECT ...
FROM big_table   -- 1억 행
JOIN small_table -- 1만 행
ON big_table.id = small_table.ref_id;

EXPLAIN 결과에서 big_table이 Broadcast 쪽(Hash Build)으로 잡혀 있다면 워커 메모리가 폭발한다. 두 가지 방법으로 해결한다.

-- 방법 1: 힌트로 Broadcast 대상 지정
SELECT /*+ BROADCAST(small_table) */ ...

-- 방법 2: 통계 갱신 후 CBO에게 맡기기
ANALYZE iceberg.warehouse.big_table;
ANALYZE iceberg.warehouse.small_table;

문제 2: 통계 없이 수십억 행 조인

쿼리가 시작하자마자 Exchange 단계에서 매우 오랫동안 머물면, Stage 간 데이터 이동이 병목일 가능성이 높다. CBO가 통계 없이 Partitioned Join을 선택했다면 Exchange 비용이 크다.

-- 통계 상태 점검
SHOW STATS FOR iceberg.warehouse.orders;
-- row_count가 NULL이면 통계 없음 → ANALYZE 실행

문제 3: SELECT * 로 불필요한 컬럼 전달

-- 나쁜 예: 200개 컬럼 테이블에서 전체 읽기
SELECT * FROM iceberg.warehouse.wide_events
WHERE event_date = DATE '2026-07-01';

-- 좋은 예: 필요한 컬럼만
SELECT user_id, event_type, created_at
FROM iceberg.warehouse.wide_events
WHERE event_date = DATE '2026-07-01';

Trino는 컬럼 프루닝(column pruning)을 지원해 필요한 컬럼만 커넥터에서 읽는다. Parquet/ORC의 열지향 포맷 특성상 불필요한 컬럼을 건너뛰는 효과가 크다.

문제 4: 대용량 DISTINCT 또는 GROUP BY

-- 카디널리티가 수천만인 컬럼에 DISTINCT
SELECT COUNT(DISTINCT user_id) FROM events;

이 패턴은 모든 user_id를 Exchange하고 전역에서 DISTINCT 처리해 메모리 압력이 크다. 이 경우 HyperLogLog 근사 집계를 쓸 수도 있다.

-- HLL 근사치 (±2% 오차, 훨씬 빠르고 메모리 적음)
SELECT APPROX_DISTINCT(user_id) FROM events;

웹 UI에서 확인하는 지표

Trino 웹 UI (http://coordinator:8080)에서 EXPLAIN 없이도 진행 중인 쿼리를 분석할 수 있다.

UI 항목의미경고 신호
Queued 시간실행 슬롯 대기워커 부족, 쿼리 동시성 제한 도달
Split 처리 속도 (Rows/sec)커넥터 I/O 처리량느리면 커넥터 병목 또는 S3 API 제한
Memory (워커별)각 워커의 메모리 사용한쪽 워커만 높으면 데이터 스큐
Blocked 상태Exchange 버퍼 포화셔플이 많거나 메모리 부족
Failed tasks워커 장애 또는 OOM로그에서 세부 오류 확인

튜닝 체크리스트

  1. EXPLAIN ANALYZE로 가장 오래 걸리는 Stage와 CPU/Scheduled 비율을 확인한다.
  2. SHOW STATS FOR 로 통계 상태를 점검하고, 오래됐으면 ANALYZE를 실행한다.
  3. 파티션 컬럼에 함수를 사용한 조건이 없는지 확인한다 (프루닝 방해).
  4. SELECT * 대신 필요한 컬럼만 명시한다.
  5. 조인 양쪽의 크기를 확인하고, 작은 테이블이 Broadcast 대상인지 확인한다.
  6. VARCHAR 컬럼 필터가 MySQL 커넥터에서 푸시다운되지 않음을 기억한다.
  7. OOM이 발생하면 spill_enabled = true 설정 후 재시도한다.
  8. 대규모 DISTINCT는 APPROX_DISTINCT로 대체 가능한지 검토한다.

References

  • Trino Cost in EXPLAIN 공식 문서, https://trino.io/docs/current/optimizer/cost-in-explain.html
  • Trino Query Optimizer 공식 문서, https://trino.io/docs/current/optimizer.html
  • Trino CBO 소개 블로그 (2019), https://trino.io/blog/2019/07/04/cbo-introduction.html
  • Starburst: Trino Query Plan Syntax, https://www.starburst.io/resources/query-plan-syntax/
  • Medium — Simon Thelin: Query Plans in Trino, https://medium.com/@simon.thelin90/query-plans-analyse-sql-performance-in-trino-97ac1e8f8044
  • CelerData: Trino Query Optimization Best Practices, https://celerdata.com/glossary/trino-query-optimization
  • Alluxio Blog: Speed Trino Queries with Performance-Tuning Tips, https://www.alluxio.io/blog/speed-trino-queries-with-these-performance-tuning-tips
  • Lester Martin: Trino Query Plan Analysis Video Series, https://lestermartin.blog/2025/04/22/trino-query-plan-analysis-video-series/