Trino 메모리 관리와 Resource Groups
Trino 의 메모리 모델(per-node·cluster·heap headroom)과 spill-to-disk, 그리고 Resource Groups 로 워크로드를 격리해 한 쿼리가 클러스터 전체를 점유하지 못하게 하는 방법을 실전 설정과 함께 정리합니다.
월요일 아침, 누군가의 무거운 쿼리 한 방에 Trino 클러스터 전체가 먹통이 된 경험, 여러분도 한 번쯤 있지 않으신가요? 혹은 정기 배치가 SLA 를 넘겨서 울리는 알람을 받아 본 적은요? 두 상황 모두 원인을 파고들면 메모리 모델과 Resource Groups 를 제대로 쓰지 않아서 생기는 경우가 대부분이거든요. 구조를 이해하면 이런 장애를 사전에 막을 수 있습니다.
이 글에서 배우는 것
- Trino 메모리의 세 가지 경계(per-node · cluster · heap headroom)가 무엇인지
- spill-to-disk 가 OOM 대신 "느려지기"를 선택하는 원리
- Resource Groups 로 배치/임시/대시보드 쿼리를 격리하는 방법
- 스케줄링 정책과 글로벌 가드레일로 폭주 쿼리를 막는 설정
- 증상별 처방표와 튜닝 체크리스트
1. Trino 메모리 모델 — 세 개의 경계
Trino 가 메모리를 어떻게 나눠 쓰는지 먼저 이해해 봅시다. 경계가 세 겹으로 이뤄져 있거든요.
[ 워커 노드의 JVM 힙 ]
├── heap-headroom-per-node (Trino 가 손대지 않는 예약 영역: GC, 버퍼)
└── 쿼리용 메모리 풀
└── query.max-memory-per-node (한 쿼리가 이 노드에서 쓸 수 있는 상한)
[ 클러스터 전체 ]
└── query.max-memory (한 쿼리가 모든 워커에서 합산해 쓸 수 있는 상한)| 설정 | 범위 | 의미 |
|---|---|---|
query.max-memory-per-node | 워커 1대 | 한 쿼리가 그 노드에서 쓸 수 있는 최대 메모리 |
query.max-memory | 클러스터 | 한 쿼리가 전체 워커에서 합산해 쓸 수 있는 최대 메모리 |
memory.heap-headroom-per-node | 워커 1대 | Trino 가 쿼리에 할당하지 않고 남겨두는 힙 (GC·시스템용) |
세 값 사이에는 다음과 같은 관계식이 성립합니다(대략):
query.max-memory-per-node < (JVM 힙 - heap-headroom-per-node)
query.max-memory ≈ query.max-memory-per-node × 워커 수 (이하)예를 들어 워커 힙이 24GB 이고 headroom 이 5GB 라면, 쿼리용 풀은 약 19GB 가 됩니다. query.max-memory-per-node 를 12GB 로 두면, 한 쿼리가 한 노드에서 최대 12GB 까지 쓸 수 있고 나머지 7GB 는 다른 쿼리들이 나눠 쓰는 구조죠.
# etc/config.properties (워커)
query.max-memory-per-node=12GB
memory.heap-headroom-per-node=5GB
# 코디네이터
query.max-memory=240GB쿼리가 이 상한을 넘으면 EXCEEDED_LOCAL_MEMORY_LIMIT 또는 EXCEEDED_GLOBAL_MEMORY_LIMIT 오류와 함께 그 쿼리만 실패합니다. 상한이 있기에, 한 쿼리가 클러스터 전체를 비정상 동작으로 이끌지 못하도록 안전하게 막아 주는 거예요.
한 문장으로: Trino 메모리 경계는 "한 쿼리가 혼자 다 먹는" 상황을 막아 주는 울타리입니다.
2. Spill-to-Disk — OOM 대신 느려지기
메모리 상한에 걸렸다고 해서 쿼리를 무조건 죽일 필요는 없습니다. 중간 데이터를 디스크로 흘려보내는(spill) 방식으로 끝까지 실행하게 할 수 있거든요. 조인·집계·정렬·윈도우 연산에서 동작합니다.
# etc/config.properties
spill-enabled=true
spiller-spill-path=/data1/trino-spill,/data2/trino-spill # 여러 디스크 분산 권장
max-spill-per-node=200GB
query-max-spill-per-node=100GB트레이드오프는 분명합니다. 다음 표를 보면 한눈에 들어오죠.
| spill 끄기 | spill 켜기 | |
|---|---|---|
| 메모리 초과 시 | 쿼리 실패 | 디스크로 흘려 완주 |
| 성능 | 빠름(메모리 내) | 느려짐(디스크 I/O) |
| 적합 | 저지연 대화형 | 대형 배치·ETL |
spill 은 "느려지더라도 끝나게" 하는 안전장치입니다. 모든 쿼리를 메모리로 처리하는 것이 이상적이지만, 가끔 튀는 대용량 쿼리 때문에 클러스터가 죽는 것보다는 낫습니다. spill 경로는 빠른 로컬 SSD 여러 개로 분산하는 것을 추천합니다.
spill 로도 감당이 안 될 때의 근본 대응은 결국 쿼리를 개선하는 것입니다 — SELECT * 대신 필요한 컬럼만 뽑고, 큰 조인의 build side 를 줄이고, 사전 집계와 파티션 프루닝을 적극 활용해 봅시다.
3. 문제: 메모리 상한만으로는 부족하다
query.max-memory 는 "한 쿼리"의 상한을 제한하지만, 여러분이 실제로 겪는 문제는 보통 여러 쿼리가 동시에 충돌하는 경합 상황이거든요.
- 분석가 30명이 동시에 무거운 쿼리를 던지면, 각각은 상한 안이라도 클러스터 메모리가 고갈됩니다.
- 우선순위가 낮은 임시 쿼리가 동시성을 점유해 SLA 가 걸린 정기 배치가 큐에서 밀립니다.
- 한 BI 도구의 대시보드 새로고침 폭주가 전체 응답성을 떨어뜨립니다.
이 경합 문제를 구조적으로 해결하는 것이 바로 Resource Groups 입니다. Cloudera 를 쓰시던 분이라면 Impala 의 Admission Control / 리소스 풀에 대응하는 개념이라고 보면 됩니다.
4. Resource Groups — 워크로드 격리
Resource Groups 를 사용하면 들어오는 쿼리를 그룹으로 분류하고, 각 그룹마다 동시성·메모리·큐 한도를 별도로 부여할 수 있습니다. 아래 설정 예시를 함께 살펴봅시다.
# etc/resource-groups.properties
resource-groups.configuration-manager=file
resource-groups.config-file=/etc/trino/resource-groups.json{
"rootGroups": [
{
"name": "global",
"softMemoryLimit": "80%",
"hardConcurrencyLimit": 100,
"maxQueued": 1000,
"schedulingPolicy": "weighted",
"subGroups": [
{
"name": "batch",
"softMemoryLimit": "60%",
"hardConcurrencyLimit": 20,
"maxQueued": 500,
"schedulingWeight": 3
},
{
"name": "adhoc",
"softMemoryLimit": "30%",
"hardConcurrencyLimit": 40,
"maxQueued": 200,
"schedulingWeight": 1
},
{
"name": "dashboard",
"softMemoryLimit": "20%",
"hardConcurrencyLimit": 50,
"maxQueued": 100,
"schedulingWeight": 2
}
]
}
],
"selectors": [
{ "user": "etl-svc", "group": "global.batch" },
{ "source": "superset", "group": "global.dashboard" },
{ "group": "global.adhoc" }
]
}주요 속성
| 속성 | 의미 |
|---|---|
hardConcurrencyLimit | 동시에 실행되는 쿼리 최대 수 |
maxQueued | 대기 큐 최대 길이. 초과하면 쿼리 거부 |
softMemoryLimit | 이 그룹이 쓸 수 있는 메모리(%, 또는 절대값). 초과 시 신규 쿼리 대기 |
schedulingPolicy | 큐에서 다음 쿼리를 고르는 정책 |
schedulingWeight | weighted 정책에서 그룹 간 자원 배분 가중치 |
셀렉터 — 쿼리를 그룹에 매칭
selectors 는 위에서부터 첫 번째로 매칭되는 규칙으로 쿼리를 그룹에 배정합니다. user, source(클라이언트가 보낸 소스명), clientTags, queryType 등 다양한 기준으로 분류할 수 있거든요.
이렇게 구성해 두면 분석가들의 임시 쿼리(adhoc)가 아무리 쏟아져도 배치(batch)의 동시성·메모리를 침범하지 못합니다. SLA 가 걸린 작업을 지키는 핵심 장치이니 꼭 설정해 두세요.
한 문장으로: Resource Groups 는 "내 그룹 몫은 내 것, 남의 몫은 넘보지 않는" 자원 울타리입니다.
5. 스케줄링 정책 고르기
그룹 내 쿼리를 어떤 순서로 실행할지도 여러분이 결정할 수 있습니다. 상황에 맞는 정책을 골라 보세요.
| 정책 | 동작 | 적합 |
|---|---|---|
fair | 같은 그룹 내 쿼리를 공평하게 (FIFO 기반) | 일반적인 기본값 |
weighted | schedulingWeight 비율로 하위 그룹에 자원 배분 | 그룹 간 우선순위 차등 |
weighted_fair | 가중치 + 공평성 절충 | 다수 그룹의 균형 |
query_priority | 쿼리의 query_priority 세션 값으로 우선순위 | 동적 우선순위 제어 |
위 예시는 weighted 정책을 사용해서 batch:dashboard:adhoc = 3:2:1 비율로 자원을 우선 배분하고 있습니다.
6. 쿼리 레벨 제어 — 추가 안전장치
Resource Groups 만으로는 부족할 때를 대비해, 폭주 쿼리를 잡아주는 글로벌·세션 수준의 설정도 챙겨 두면 좋습니다.
# etc/config.properties — 비정상적으로 무거운/느린 쿼리 차단
query.max-execution-time=2h
query.max-scan-physical-bytes=5TB
query.max-stage-count=150-- 세션 단위 조정 (특정 쿼리만)
SET SESSION query_max_memory_per_node = '20GB';
SET SESSION query_max_run_time = '30m';query.max-scan-physical-bytes 는 실수로 거대 풀스캔을 날렸을 때 일정 바이트를 초과하는 순간 쿼리를 끊어 주는 든든한 가드레일입니다. 실수 한 번이 클러스터를 흔드는 걸 막아 주죠.
7. 진단 — 무엇을 보고 튜닝하나
설정을 마쳤다면 이제 실제로 무엇이 메모리를 잡아먹고 있는지 들여다볼 차례입니다. Trino 는 진단 도구를 여러 가지 제공하거든요.
7.1 시스템 테이블
-- 현재 실행/대기 중인 쿼리와 메모리 사용
SELECT query_id, state, resource_group_id,
query, total_memory_reservation
FROM system.runtime.queries
WHERE state IN ('RUNNING', 'QUEUED')
ORDER BY total_memory_reservation DESC;
-- 리소스 그룹별 현황
SELECT * FROM system.runtime.resource_group_pools;7.2 EXPLAIN ANALYZE
실제 실행 후 단계별 시간·메모리·행 수를 확인할 수 있습니다. 어디서 병목이 생기는지 한눈에 보이거든요.
EXPLAIN ANALYZE
SELECT user_id, count(*)
FROM iceberg.analytics.events
WHERE event_time >= TIMESTAMP '2026-06-01 00:00:00 UTC'
GROUP BY user_id;조인 순서가 나쁘거나 한 스테이지가 메모리를 독점하는지 여기서 바로 드러납니다. 조인 품질이 낮아 보이면 테이블 통계를 갱신해 보세요.
ANALYZE iceberg.analytics.events;7.3 Web UI
코디네이터 Web UI 의 Query Detail 에서 스테이지별 메모리·실행 시간·spill 발생 여부를 시각적으로 확인할 수 있습니다. "Peak Memory" 가 상한에 근접하는 쿼리가 보인다면 그 쿼리가 튜닝 1순위입니다.
8. 증상별 처방 빠른 표
장애 상황에서 빠르게 원인을 좁히고 싶을 때 이 표를 펼쳐 보세요.
| 증상 | 원인 후보 | 처방 |
|---|---|---|
EXCEEDED_LOCAL_MEMORY_LIMIT | 한 노드에서 쿼리가 상한 초과 | spill 켜기, query.max-memory-per-node 상향, 쿼리 최적화 |
EXCEEDED_GLOBAL_MEMORY_LIMIT | 클러스터 상한 초과 | query.max-memory 점검, 워커 증설, 쿼리 분할 |
| 워커 OOMKill / GC 폭주 | 힙 ≈ 컨테이너 limit, headroom 부족 | headroom 상향, 힙 < limit 보장 |
| 배치가 임시 쿼리에 밀림 | 워크로드 격리 없음 | Resource Groups 로 batch/adhoc 분리 |
| 대시보드 폭주로 전체 저하 | dashboard 동시성 무제한 | dashboard 그룹에 동시성·큐 한도 |
| 거대 풀스캔 사고 | 가드레일 없음 | query.max-scan-physical-bytes 설정 |
| 쿼리는 도는데 느림 | 조인 순서·통계 부재 | ANALYZE, EXPLAIN ANALYZE 로 진단 |
9. 튜닝 체크리스트
클러스터를 운영에 올리기 전에, 혹은 장애 이후 점검할 때 이 체크리스트를 하나씩 확인해 봅시다.
- JVM 힙 < 컨테이너/노드 메모리 (headroom 20~30%)
-
query.max-memory-per-node< (힙 − headroom) - spill 활성화 + 빠른 로컬 디스크 다중 경로
- Resource Groups 로 batch/adhoc/dashboard 격리
- 셀렉터로 사용자·소스별 그룹 매칭
-
query.max-scan-physical-bytes등 글로벌 가드레일 - 핵심 테이블 정기
ANALYZE - Web UI/system 테이블로 peak memory 상위 쿼리 점검
10. 정리
| 계층 | 도구 | 막아주는 것 |
|---|---|---|
| 단일 쿼리 메모리 | query.max-memory(-per-node) | 한 쿼리의 클러스터 독점 |
| 완주 보장 | spill-to-disk | OOM 으로 인한 실패 |
| 워크로드 경합 | Resource Groups | 그룹 간 자원 침범, SLA 침해 |
| 폭주 사고 | 글로벌 쿼리 제한 | 실수성 거대 스캔·무한 실행 |
| 실행 품질 | ANALYZE + EXPLAIN ANALYZE | 나쁜 조인 순서·느린 쿼리 |
Trino 안정화의 핵심은 두 가지입니다. 첫째, 메모리 모델을 이해해서 힙·headroom·쿼리 상한을 일관되게 잡고 spill 로 안전망을 두는 것. 둘째, Resource Groups 로 워크로드를 격리해서 한 쿼리·한 팀이 전체를 마비시키지 못하게 하는 것. 이 두 가지가 갖춰지면 Trino 클러스터는 부하가 몰려도 "느려질지언정 멈추지 않는" 탄탄한 상태가 됩니다.
마치며 — 핵심 요약
이 글에서 다룬 내용을 한 번 더 정리해 봤습니다.
- 메모리 경계 세 겹 —
heap-headroom,max-memory-per-node,max-memory를 일관되게 잡는 것이 출발점입니다. - spill-to-disk — OOM 으로 쿼리가 죽는 대신 "느려지더라도 완주"하는 안전망입니다. 빠른 로컬 SSD 여러 경로로 분산하세요.
- Resource Groups — 배치·임시·대시보드를 그룹으로 나누면 어느 한 팀이 클러스터 전체를 점유하는 일이 사라집니다.
- 글로벌 가드레일 —
query.max-scan-physical-bytes같은 한도를 설정해 실수성 풀스캔 사고를 방어하세요. EXPLAIN ANALYZE+ANALYZE— 느린 쿼리의 뿌리를 캐내는 가장 확실한 도구입니다. 꾸준히 활용해 보세요.
이 다섯 가지를 갖추면 Trino 클러스터는 부하가 몰려도 "느려질지언정 멈추지 않는" 탄탄한 상태가 됩니다. 여러분의 클러스터가 다음 월요일 아침에도 조용하기를 바랍니다!
이 글은 Trino 440번대 기준으로 작성되었습니다. Trino 클러스터의 메모리·동시성 튜닝이나 워크로드 격리 설계가 필요하시면 언제든지 문의해 주세요.
— Data Dynamics 엔지니어링 팀