Trino SQL 실전 패턴 — Window, UNNEST, Lambda, 그리고 UDF
Trino 에서 실무 분석에 자주 쓰는 SQL 패턴을 정리합니다. 윈도우 함수로 순위·세션화, UNNEST 로 배열/맵 펼치기, 람다와 고차 함수로 컬렉션 가공, 페더레이션 조인, 그리고 SQL·Python UDF 까지.
Trino 는 ANSI SQL 을 충실히 따르면서도, 배열·맵·JSON 같은 반정형 데이터와 고차 함수를 다루는 강력한 확장을 제공합니다. 이 도구들을 익혀 두면 애플리케이션 코드로 후처리하던 일을 SQL 한 방에 끝낼 수 있거든요.
이 글은 Trino 에서 실무 분석에 자주 쓰는 SQL 패턴을 윈도우 함수 → UNNEST → 람다/고차 함수 → 페더레이션 → UDF 순으로, 바로 복사해 쓸 수 있는 예제와 함께 정리합니다.
이 글에서 배우는 것
- 윈도우 함수로 순위, 누적합, 이동평균, 세션화를 SQL 만으로 구현하는 방법
- UNNEST 와 고차 함수(transform, filter, reduce)로 배열·맵을 자유롭게 가공하는 법
- 페더레이션 쿼리로 이종 시스템 데이터를 한 SQL 에 묶는 법
- SQL UDF 와 Python UDF 로 반복 로직을 캡슐화하는 법
- 성능을 망치는 안티패턴과 그 대안
1. 윈도우 함수 — 행을 넘나드는 계산
윈도우 함수는 GROUP BY 처럼 행을 합치지 않으면서, 각 행에서 "관련된 행들의 집합"을 계산해 줍니다. 순위·누적합·이동평균처럼 "이웃 행을 보면서 계산"해야 하는 경우에 딱 맞는 도구죠.
한 문장으로: 윈도우 함수는 GROUP BY 없이 집계 결과를 각 행에 그대로 붙여 주는 함수입니다.
그룹별 순위 / Top-N
-- 카테고리별 매출 상위 3개 상품
SELECT category, product, revenue
FROM (
SELECT category, product, revenue,
row_number() OVER (PARTITION BY category ORDER BY revenue DESC) AS rn
FROM sales
)
WHERE rn <= 3;| 함수 | 동작 |
|---|---|
row_number() | 동순위 없이 1,2,3… |
rank() | 동순위 허용, 다음 순위 건너뜀(1,1,3) |
dense_rank() | 동순위 허용, 건너뛰지 않음(1,1,2) |
누적 합계 / 이동 평균
SELECT
d,
revenue,
sum(revenue) OVER (ORDER BY d ROWS UNBOUNDED PRECEDING) AS running_total,
avg(revenue) OVER (ORDER BY d ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS ma7
FROM daily_sales;ROWS BETWEEN ... PRECEDING AND CURRENT ROW 프레임으로 이동 윈도우를 정의하면 됩니다. 7일 이동평균, 누적합 같은 시계열 지표를 다룰 때 꼭 기억해 두세요.
LAG / LEAD — 이전/다음 행 참조
-- 직전 이벤트와의 시간 간격
SELECT
user_id, event_time,
event_time - lag(event_time) OVER (PARTITION BY user_id ORDER BY event_time) AS gap
FROM events;세션화(Sessionization) 패턴
LAG + 조건부 누적합을 조합하면 "30분 이상 비활동 시 새 세션" 같은 세션 구분을 SQL 만으로 구현할 수 있습니다. 별도 파이프라인 없이 쿼리 하나로 세션 ID 를 붙일 수 있어서 편리하죠.
WITH marked AS (
SELECT user_id, event_time,
CASE WHEN event_time - lag(event_time)
OVER (PARTITION BY user_id ORDER BY event_time)
> INTERVAL '30' MINUTE
THEN 1 ELSE 0 END AS is_new_session
FROM events
)
SELECT user_id, event_time,
sum(is_new_session) OVER (PARTITION BY user_id ORDER BY event_time) AS session_id
FROM marked;2. UNNEST — 배열/맵을 행으로 펼치기
Trino 는 ARRAY, MAP, ROW 같은 복합 타입을 1급으로 다룹니다. 중첩된 컬렉션을 행 단위로 펼쳐야 할 때 UNNEST 를 사용하면 됩니다.
-- 한 주문에 여러 상품(배열)이 든 행을 상품 단위 행으로
SELECT order_id, item
FROM orders
CROSS JOIN UNNEST(items) AS t(item);
-- 인덱스(순번)와 함께 펼치기 (WITH ORDINALITY)
SELECT order_id, idx, item
FROM orders
CROSS JOIN UNNEST(items) WITH ORDINALITY AS t(item, idx);
-- MAP 펼치기 (키, 값)
SELECT id, k, v
FROM tbl
CROSS JOIN UNNEST(attributes) AS t(k, v);반대로 행을 배열로 다시 모으고 싶다면 array_agg 를 쓰면 됩니다.
SELECT order_id, array_agg(item ORDER BY item) AS items
FROM order_items
GROUP BY order_id;3. JSON 다루기
로그나 이벤트 페이로드가 JSON 문자열로 들어오는 경우가 흔하죠. Trino 는 JSON 을 직접 파싱하는 함수를 제공하므로, 별도 전처리 없이 SQL 안에서 필드를 꺼낼 수 있습니다.
SELECT
json_extract_scalar(payload, '$.user.id') AS user_id,
json_extract_scalar(payload, '$.event.type') AS event_type,
cast(json_extract(payload, '$.items') AS ARRAY(VARCHAR)) AS items
FROM raw_events;| 함수 | 반환 |
|---|---|
json_extract_scalar | 스칼라 값(문자열) |
json_extract | JSON 조각(추가 파싱용) |
json_parse / cast(... AS JSON) | 문자열 → JSON 타입 |
4. 람다와 고차 함수 — 컬렉션을 SQL 로 가공
Trino 의 진짜 강점 중 하나가 바로 이 고차 함수입니다. 배열을 굳이 행으로 풀지 않아도, 함수를 직접 넘겨서 변형·필터·집계를 한 번에 처리할 수 있거든요.
SELECT
transform(prices, x -> x * 1.1) AS with_tax, -- map
filter(scores, x -> x >= 60) AS passed, -- filter
reduce(nums, 0, (s, x) -> s + x, s -> s) AS total, -- fold/sum
array_sort(tags) AS sorted_tags,
any_match(flags, x -> x = true) AS has_true,
cardinality(filter(scores, x -> x >= 60)) AS pass_count
FROM t;| 고차 함수 | 역할 |
|---|---|
transform(arr, x -> ...) | 원소 변환(map) |
filter(arr, x -> ...) | 조건 필터 |
reduce(arr, init, (s,x)->..., s->...) | 누적(fold) |
any_match / all_match / none_match | 조건 충족 여부 |
zip_with(a, b, (x,y)->...) | 두 배열 결합 |
예를 들어 UNNEST + GROUP BY 로 풀어야 했던 "배열에서 60점 이상 개수"도 cardinality(filter(...)) 한 줄로 끝낼 수 있습니다.
5. 페더레이션 — 카탈로그를 넘나드는 조인
Trino 를 Trino 답게 만드는 기능이 바로 페더레이션입니다. 서로 다른 시스템에 흩어진 데이터를 한 쿼리에서 조인할 수 있어, 별도 ETL 없이 바로 분석에 들어갈 수 있죠.
한 문장으로: 카탈로그 이름만 앞에 붙이면 이종 시스템끼리 JOIN 이 됩니다.
-- 오브젝트 스토리지의 이벤트(Iceberg) × RDBMS 의 고객 마스터(PostgreSQL)
SELECT e.event_type, c.tier, count(*) AS cnt
FROM iceberg.analytics.events e
JOIN postgresql.crm.customers c ON e.user_id = c.id
WHERE e.event_time >= TIMESTAMP '2026-06-01 00:00:00 UTC'
GROUP BY e.event_type, c.tier;
-- 한 시스템의 데이터를 다른 시스템으로 적재 (CTAS 페더레이션)
CREATE TABLE iceberg.analytics.customer_snapshot AS
SELECT * FROM postgresql.crm.customers;성능 팁을 하나 기억해 두세요. RDBMS 커넥터는 predicate/aggregate pushdown 덕분에 WHERE 조건과 집계를 소스 DB 에서 직접 처리합니다. 작은 차원 테이블에는 broadcast 조인이 자연스럽고, 큰 테이블끼리 조인할 때는 통계가 있어야 CBO 가 올바른 분배 방식을 선택합니다.
6. 자주 쓰는 실무 함수 모음
-- 안전한 형변환 (실패 시 NULL)
try_cast(value AS INTEGER)
-- NULL 안전 비교 / 치환
coalesce(a, b, 0)
nullif(a, 0) -- a=0 이면 NULL (0으로 나누기 방지)
-- 날짜 (단위가 첫 인자!)
date_add('day', 7, current_date)
date_diff('hour', t1, t2)
date_trunc('month', event_time)
format_datetime(event_time, 'yyyy-MM-dd')
-- 문자열
split(path, '/') -- 배열로
regexp_extract(ua, '(\d+)\.(\d+)', 1)
url_extract_host(url)
-- 근사 집계 (대용량에서 빠름)
approx_distinct(user_id) -- 근사 distinct count
approx_percentile(latency, 0.95) -- p95
approx_distinct/approx_percentile는 대규모 데이터에서 정확한count(distinct)보다 훨씬 빠르고 메모리를 적게 씁니다. 대시보드 지표처럼 약간의 오차가 허용되는 곳에서 적극 활용하세요.
7. UDF — 표준 함수로 부족할 때
여러 쿼리에서 반복되는 로직이 있다면 UDF 로 캡슐화해 봅시다. 팀 전체가 동일한 로직을 안전하게 재사용할 수 있습니다.
SQL UDF (인라인 / 세션·카탈로그 등록)
-- 쿼리 안에서 즉석 정의 (WITH FUNCTION)
WITH FUNCTION to_won(usd DOUBLE)
RETURNS DOUBLE
RETURN usd * 1330.0
SELECT product, to_won(price_usd) AS price_krw
FROM catalog.schema.products;복잡한 로직도 SQL UDF 로 표현할 수 있고, 카탈로그에 영구 등록해 두면 팀 전체가 일관되게 재사용할 수 있습니다.
CREATE FUNCTION catalog.schema.mask_email(email VARCHAR)
RETURNS VARCHAR
RETURN regexp_replace(email, '(^.).*(@.*$)', '$1***$2');Python UDF
표준 SQL 함수로 표현하기 어려운 로직은 Python UDF 로 작성할 수 있습니다(샌드박스 실행). 문자열 정규화나 커스텀 파싱처럼 절차적 코드가 더 자연스러운 작업에서 특히 유용하죠.
CREATE FUNCTION catalog.schema.py_slug(s VARCHAR)
RETURNS VARCHAR
LANGUAGE PYTHON
WITH (handler = 'slug')
AS $$
def slug(s):
return "-".join(s.lower().split()) if s else None
$$;주의: UDF, 특히 Python UDF 는 강력하지만 옵티마이저 입장에서 블랙박스입니다. WHERE 절에서 컬럼을 UDF 로 감싸면 pushdown·프루닝이 깨질 수 있으니, 대용량 필터 조건에는 표준 함수를 우선하고 UDF 는 투영(SELECT) 단계에서 쓰는 것이 안전합니다.
8. 안티패턴 — 성능을 망치는 SQL
| 안티패턴 | 문제 | 대안 |
|---|---|---|
WHERE CAST(ts AS DATE) = ... | pushdown·프루닝 깨짐 | 범위 비교 ts >= ... AND ts < ... |
WHERE year(ts) = 2026 | 함수 래핑 → 풀스캔 | 범위 비교 |
SELECT * | 불필요 컬럼까지 스캔/전송 | 필요한 컬럼만 |
count(distinct big_col) 남발 | 메모리·시간 폭증 | approx_distinct |
| 큰 테이블 먼저 조인 | 중간 결과 폭증 | 통계 + CBO, 작은 쪽 먼저 |
| WHERE 에 UDF 래핑 | 옵티마이저 블랙박스 | SELECT 단계로 이동 |
9. 정리
| 도구 | 쓰임 | 대표 함수/구문 |
|---|---|---|
| 윈도우 함수 | 순위·누적·이동평균·세션화 | row_number, sum() OVER, lag/lead |
| UNNEST | 배열/맵 → 행 | CROSS JOIN UNNEST ... WITH ORDINALITY |
| 고차 함수 | 배열 변환/필터/집계 | transform, filter, reduce |
| JSON | 반정형 파싱 | json_extract_scalar |
| 페더레이션 | 이종 소스 조인 | catalog.schema.table 조인 |
| 근사 집계 | 대용량 고속 지표 | approx_distinct, approx_percentile |
| UDF | 로직 캡슐화 | WITH FUNCTION, CREATE FUNCTION |
Trino SQL 의 핵심은 "애플리케이션으로 빼던 후처리를 SQL 안으로 끌어오는 것"입니다. 윈도우 함수와 고차 함수에만 익숙해져도 파이프라인 코드가 크게 줄고, 페더레이션과 근사 집계는 다른 엔진으로는 번거로운 분석을 한 줄로 만들어 줍니다. 다만 WHERE 절에서 함수나 UDF 로 컬럼을 감싸면 프루닝이 깨지니, "필터는 표준 함수로, 가공은 자유롭게"를 꼭 기억해 두세요.
마치며 — 핵심 요약
- 윈도우 함수는 행을 유지하면서 이웃 행을 참조합니다.
PARTITION BY+ORDER BY+ 프레임 구문을 조합하면 순위·누적합·이동평균·세션화까지 한 쿼리로 처리할 수 있습니다. - UNNEST 로 배열·맵을 행으로 펼치고,
array_agg로 다시 배열로 모을 수 있습니다.WITH ORDINALITY옵션으로 순번도 함께 얻을 수 있죠. - 고차 함수 (
transform,filter,reduce,any_match등)를 사용하면 UNNEST + GROUP BY 없이 배열을 그 자리에서 변환·필터·집계할 수 있습니다. - 페더레이션 쿼리는
catalog.schema.table표기만으로 이종 시스템을 한 SQL 에 묶습니다. ETL 없이 바로 분석이 가능해지죠. approx_distinct/approx_percentile같은 근사 집계 함수는 대규모 데이터에서 정확한 집계보다 훨씬 빠르고 메모리 효율적입니다.- WHERE 절에서 컬럼을 함수나 UDF 로 감싸면 pushdown 과 파티션 프루닝이 깨집니다. 필터 조건에는 반드시 표준 범위 비교를 쓰세요.
이 패턴들을 하나씩 실제 쿼리에 적용해 보면, 복잡한 파이프라인 코드가 깔끔한 SQL 몇 줄로 줄어드는 경험을 하게 될 겁니다.
이 글은 Trino 440번대 기준으로 작성되었습니다. 복잡한 분석 쿼리 설계나 SQL 성능 튜닝이 필요하시면 언제든 문의해 주세요.
— Data Dynamics 엔지니어링 팀