DuckDB in Action·Manning, 2024· 제4장 정독
4 ADVANCED AGGREGATION AND ANALYSIS OF DATA
원서 58–97쪽

한 행만 보던 함수에게 옆줄을 볼 눈을 주는 일

SQL 함수는 원래 자기 행 하나만 본다. 옆을 보려면 집계를 해야 하는데, 집계를 하면 행이 줄어든다. 제4장은 그 딜레마를 푸는 문법들을 모아놓았다. 윈도 함수는 행 수를 줄이지 않고 옆줄을 보게 하고, 그룹화 집합은 여러 층의 요약을 한 질의에 담고, ASOF 조인은 딱 맞지 않는 시각들을 그럴듯하게 짝지어준다. 저자들이 밝힌 목표는 하나다. 명령형 언어로 쓰면 코드가 한참 길어질 보고서를, 분석 데이터베이스로 어떻게 뽑아내는지 보여주는 것이다.

이 장이 다루는 것
수집하면서 데이터를 준비·정리·집계하기 · 임의 데이터셋의 여러 파티션에 걸쳐 새 집계를 만드는 윈도 함수 · 서브쿼리의 갈래 · 공통 테이블 표현식 · 모든 집계에 필터 적용하기
절 구성
4.1 – 4.10 및 하위 절 여덟 개, 요약
실린 것
목록 34건 · 도판 6점 · 표 1건 · NOTE 4건
이어지는 데이터
제3장의 PVDAQ 태양광 실측값 · readings 151,879행
system_id read_on power rnk 342019-05-08 12:15:00 133900.0001 342019-05-23 10:00:00 133900.0001 342019-05-23 11:30:00 133900.0001 342019-05-28 11:45:00 133900.0001 342020-04-02 11:30:00 133900.0001 342019-05-09 10:30:00 133700.0002 342019-05-10 12:15:00 133700.0002 342019-03-21 13:00:00 133600.0003 342019-04-02 10:30:00 133600.0003 1행 높이의 창이 power 내림차순으로 데이터셋 위를 지나간다 power 값이 떨어지는 여기서 순위가 바뀐다 dense_rank() OVER (ORDER BY power DESC) 동순위 다섯 행 다음이 6위가 아니라 2위다. 순위에 구멍을 남기지 않는 것이 dense_rank의 성질이다.
〈도판 4.1〉발전량 측정값 위에 세울 수 있는 가장 단순한 창.
← 좌우로 밀어 보기 →

저자들이 밝힌 이 장의 목표는 소박하게 적혀 있지만 야심이 크다. DuckDB 같은 분석 데이터베이스를 써서, 명령형 프로그래밍 언어로 쓰면 코드가 상당히 길어질 보고서를 어떻게 만들어내는지에 대한 착상을 주겠다는 것이다. 제3장에서 놓은 토대 위에 쌓아 올리지만, SELECT xzy FROM abc 같은 단순한 문장은 금세 뒤로 남겨두고 떠난다.

그리고 한 문장을 덧붙인다. 현대 SQL을 익히는 데 들이는 시간은 낭비되지 않는다. 여기 소개하는 구성물은 DuckDB를 돌릴 수 있거나 임베드할 수 있는 모든 곳에서 쓸 수 있고, 그러므로 여러분의 응용을 풍부하게 만든다. 문법 하나를 배우면 그것이 얹히는 자리가 함께 늘어난다는 뜻이니, 교재로서 할 수 있는 가장 정직한 권유다.

집계를 하면 옆줄이 보이지만 행이 줄어든다. 행을 지키면 옆줄이 안 보인다. 이 장은 그 둘을 동시에 갖는 문법들의 목록이다. 제4장의 편성이 말하는 것
이 장에 실린 목록 34건
  1. 4.1수집 중 데이터 정리와 변환
  2. 4.2중첩 집계를 계산하는 서브쿼리
  3. 4.3비교의 오른쪽에 쓰인 서브쿼리
  4. 4.4상관 스칼라 서브쿼리
  5. 4.5비상관 서브쿼리를 외부 테이블과 조인
  6. 4.6EXISTS 표현식과 서브쿼리
  7. 4.7IN 표현식과 서브쿼리
  8. 4.8ANY 표현식과 서브쿼리
  9. 4.9ALL 표현식과 서브쿼리
  10. 4.10수집이 타당한지 집계로 점검
  11. 4.11그룹 키 한 벌짜리 평범한 GROUP BY
  12. 4.12GROUPING SETS 명시적으로 쓰기
  13. 4.13GROUP BY ROLLUP
  14. 4.14GROUP BY CUBE
  15. 4.15제대로 된 top-n 질의
  16. 4.16창에 파티션 적용하기
  17. 4.17RANGE 프레임으로 윈도 함수 적용
  18. 4.18복잡한 순서·파티션을 가진 명명 윈도
  19. 4.19창의 앞뒤 값 계산하기
  20. 4.20창 위의 집계 계산하기
  21. 4.21HAVING으로 집계값 기준 필터링
  22. 4.22QUALIFY로 창의 집계값 필터링
  23. 4.23여러 행에 걸친 창에 QUALIFY 쓰기
  24. 4.24무의미한 데이터를 집계에서 배제
  25. 4.25모든 집계에 필터를 걸어 정적 피벗
  26. 4.26DuckDB의 PIVOT 문
  27. 4.27타임스탬프에 내부 조인 쓰기
  28. 4.28타임스탬프에 ASOF JOIN 쓰기
  29. 4.29ASOF JOIN과 윈도로 누적 수익 계산
  30. 4.30사용 가능한 테이블 함수 목록
  31. 4.31날짜 범위를 주도 테이블로 쓰기
  32. 4.32과거 데이터를 미래로 투사하기
  33. 4.33GHI를 담은 Open Meteo JSON 응답
  34. 4.34ASOF JOIN과 LATERAL JOIN 비교
4.1

수집하면서 미리 집계하기Pre-aggregating data while ingesting

예제 시나리오를 이어간다. 제3장 3.4.1절에서 다룬 34번 시스템의 데이터는 정합성에 문제가 좀 있었지만 스키마와 착상에 잘 맞았다. 목표는 15분 간격으로 측정값을 저장하는 것이었음을 기억해두자. 그런데 3.2.1절에서 함께 내려받은 다른 데이터셋을 열어보면, 15분이 아닌 간격으로 오는 것들이 눈에 띈다. 파일을 재빨리 들여다보는 한 방법은 tail 명령—파일의 마지막 n줄을 돌려준다—이고, head도 마찬가지로 쓸 수 있다.

같은 창고에서 나온 파일들이 서로 다르다 4.1
# 10번 시스템의 2020년 파일 — 1분 간격, 16개 컬럼
> duckdb -s ".maxwidth 40" -s "FROM read_csv_auto('2020_10.csv') LIMIT 3"
SiteIDDate-Timemodule_temp_3poa_irradiance
int64timestampdoubledouble
102020-01-23 11:20:0014.971748.36
102020-01-23 11:21:0014.921638.23
102020-01-23 11:22:0014.895467.67
3 rows    16 columns (4 shown)
# 1200번 시스템의 2020년 파일 — 5분 간격, 6개 컬럼. 전체 구조도 다르다.
> duckdb -s ".maxwidth 40" -s "FROM read_csv_auto('2020_1200.csv') LIMIT 3"
SiteIDDate-Timeac_power_meteredpower_factor
int64timestampint64double
12002020-01-01 00:00:00200.029
12002020-01-01 00:05:00200.029
12002020-01-01 00:10:00200.029
3 rows    6 columns (4 shown)
같은 풀에서 나온 데이터 파일들인데도 원천이 다르면 서로 일관되지 않다. 저자들의 한마디가 이 장 전체의 실무 감각을 요약한다. 데이터 분석은 정확히 이런 문제를 다루는 일인 경우가 꽤 잦다.
← 좌우로 밀어 보기 →

해결의 도구는 time_bucket()이다. DuckDB가 날짜·시각·타임스탬프를 위해 제공하는 여러 함수 가운데 하나로, 타임스탬프를 주어진 간격으로 절단하고 선택적인 오프셋에 맞춰 정렬해 시간 바구니(time bucket)를 만든다. 시간 바구니는 센서 측정값 따위를 집계하는 강력한 기제다. GROUP BY와 집계 함수 avg를 함께 쓰면 요구에 맞게 데이터를 준비해 결국 수집할 수 있다. 15분 간격의 바구니를 만들고, 특정 바구니에 떨어지는 모든 측정값의 평균 발전량을 계산한다.

NOTE

이어지는 예제들은 제3장의 예제를 따라와 systems 테이블을 채워두었을 때만 동작한다.

질의를 보면 CASE WHEN THEN ELSE END 구성물이 눈에 들어온다. if/else처럼 동작하는 CASE 문이다. 여기서 하는 일은 평균을 계산하기 전에 값이 0보다 작거나 값이 아예 없는 측정값을 0으로 바꾸는 것이다. 이 데이터셋의 별스러움 가운데 하나인데, 저자들이 붙인 설명이 담백해서 오래 기억에 남는다. 센서가 고장났는지, 아니면 네트워크가 말썽이었는지, 끝내 알 수 없다. 그래도 데이터는 다뤄야 한다. 여기서는 NULL 값을 음수값처럼 취급해 0으로 눌러두는 것이 괜찮다고 판단했다. 계산을 망가뜨리는 경우라면 집계에 FILTER를 걸어보는 편을 고려할 만하고, 그것은 4.6.3절에서 다룬다.

수집하는 동안 데이터를 정리하고 변환하기 목록 4.1
INSERT INTO readings(system_id, read_on, power)
SELECT any_value(SiteId),
        time_bucket(
             INTERVAL '15 Minutes',
             CAST("Date-Time" AS timestamp)
        ) AS read_on,
        avg(
             CASE
                  WHEN ac_power < 0 OR ac_power IS NULL THEN 0
                  ELSE ac_power END)
FROM
     read_csv_auto(
         'https://developer.nrel.gov/api/pvdaq/v3/' ||
         'data_file?api_key=DEMO_KEY&system_id=10&year=2019'
     )
GROUP BY read_on
ORDER BY read_on;
any_value(SiteId)는 CSV 파일에서 SiteId 컬럼의 아무 값이나 하나 고른다. 파일이 시스템별로 나뉘어 있으므로 이 컬럼은 모든 행에서 같은 값이고, 따라서 아무 하나를 골라도 옳다. 집계(avg)를 계산하고 있으므로 any_value()를 씌우는 것이 필수다.
time_bucket이 타임스탬프를 15분 단위로 절단한다. 표준 SQL 문법으로 컬럼을 타임스탬프로 명시적으로 형 변환한 점, 그리고 변환된 값에 별칭 read_on을 붙인 점을 눈여겨볼 만하다. 결과를 그 바구니로 묶었으므로 avg는 앞서 만든 바구니 안의 모든 측정값의 평균을 계산한다. 남은 데이터셋의 수집도 FROM 절의 파일 이름만 바꾸면 동일하다.
NOTE

DuckDB에는 날짜·시각 기반 함수가 훨씬 더 많다. 의심스러울 때는 참조 문서를 살펴보면 된다. 거의 어떤 문자열이든 제대로 된 날짜나 타임스탬프로 파싱해낼 수 있다.

이 절은 판단의 문제로 맺는다. 수집을 아예 피하고 외부 파일에 기반해 모든 분석을 인메모리로 할지, 수집하는 동안 어느 정도 집계할지, 아니면 분석할 때만 집계할지. 이 결정은 통상 데이터셋의 크기, 장기 보관에 대한 목표, 이후의 처리 필요 사이의 절충으로 귀결된다. 그러므로 여기서 일반적으로 적용되는 해법을 만들려 하면 반드시 실패한다. 이 시나리오에서는 교육적 목적으로 수집과 집계를 모두 하기로 했고, 공유할 수 있을 만큼 데이터셋을 작게 유지하기로 했다.

4.2

데이터 요약하기Summarizing data

새 데이터셋을 깊이 분석하기 전에 그 특성을 얼마간 알고 싶은 것이 보통이다. 값의 개수—예제에서는 측정값이 몇 개인가—, 수치의 분포와 크기, 그리고 시계열의 간격 크기 같은 것이다. 두 번째가 특히 중요하다. 와트를 다루는지 킬로와트를 다루는지 모른 채로는 보고서가 노골적으로 틀리게 된다. DuckDB에는 어떤 데이터셋에 대해서든 이 정보를 재빨리 내주는 고유 명령 SUMMARIZE가 있다.

SUMMARIZE — 테이블 전체 4.2
D SUMMARIZE readings;
column_namecolumn_typemaxq75count
varcharvarcharvarcharvarcharint64
system_idINTEGER12001200151879
read_onTIMESTAMP2020-06-26 11:00:00151879
powerDECIMAL(10,3)133900.0005125151879
컬럼이 훨씬 많지만 읽기 쉽도록 줄인 것이다. 이제 CLI를 .mode line으로 바꾸고 측정값의 부분집합을 요약해본다.
D .mode line
D SUMMARIZE SELECT read_on, power FROM readings WHERE system_id = 1200;

    column_name = read_on
    column_type = TIMESTAMP
            min = 2019-01-01 00:00:00
            max = 2020-06-26 11:00:00
  approx_unique = 50833
            avg =
            std =
            q25 =
            q50 =
            q75 =
          count = 52072
null_percentage = 0.0%

    column_name = power
    column_type = DECIMAL(10,3)
            min = 0.000
            max = 47873.333
  approx_unique = 6438
            avg = 7122.5597121293595
            std = 11760.089219586542
            q25 = 20
            q50 = 27
            q75 = 9532
          count = 52072
null_percentage = 0.0%
SUMMARIZE는 테이블에 직접 통하지만, 위에서 보듯 질의 결과에도 통한다. 게다가 데이터를 수집하지 않고도 CSV나 Parquet 파일에 대고 바로 돌릴 수 있다.
1200번 시스템의 발전량 분포를 잠깐 읽어볼 만하다. 중앙값(q50)이 27W인데 3분위수(q75)는 9,532W이고 평균은 7,122W다. 절반 이상의 측정값이 사실상 0에 가깝다는 뜻이며, 밤과 흐린 날이 데이터의 절반을 차지하는 태양광 데이터의 성격이 통계 세 줄에 그대로 드러난다. 표준편차가 평균보다 큰 것도 같은 이유다.
← 좌우로 밀어 보기 →
4.3

서브쿼리에 관하여On subqueries

관리하는 시스템들이 생산한 총 전력의 평균을 계산하고 싶다고 하자. 그러려면 집계 함수 둘, avgsum을 적용해야 한다. 그런데 둘을 중첩할 수 없다.

중첩할 수 없는 집계, 그리고 그 우회 목록 4.2
-- 순진한 접근
D SELECT avg(sum(kWh)) FROM v_power_per_day GROUP BY system_id;
Error: Binder Error: aggregate function calls cannot be nested

-- 계산을 단계로 나눈다. 서브쿼리가 그 한 방법이다.
SELECT avg(sum_per_system)
FROM (
    SELECT sum(kWh) AS sum_per_system
    FROM v_power_per_day
    GROUP BY system_id
);
avg(sum_per_system) = 133908.087
이 문장의 안쪽 질의는 두 가지 성질을 갖는다. 여러 행을 돌려준다는 것, 그리고 바깥 질의의 값에 의존하지 않는다는 것이다. 이런 질의를 비상관 서브쿼리라 부른다. 다른 질의 안에 중첩된 질의일 뿐이며, 바깥 질의가 안쪽 질의의 결과 위에서 실행되는 것처럼 동작한다.

다음 질문으로 넘어간다. 어느 날, 어느 시스템에서 가장 많은 전력이 생산되었는가. 한 가지 해법은 WHERE 절의 비교에서 오른쪽 항으로 서브쿼리를 쓰는 것이다.

스칼라 비상관 서브쿼리 목록 4.3
SELECT read_on, power
FROM readings
WHERE power = (SELECT max(power) FROM readings);
read_onpower
timestampdecimal(10,3)
2019-05-08 12:15:00133900.000
2019-05-23 10:00:00133900.000
2019-05-23 11:30:00133900.000
2019-05-28 11:45:00133900.000
2020-04-02 11:30:00133900.000
이 서브쿼리는 첫 번째와 달리 단 하나의 스칼라 값만 돌려준다. 이것을 스칼라 비상관 서브쿼리라 부른다. 결과가 말하는 바는 이렇다. 최대 출력 133,900W가 서로 다른 다섯 시각에 생산되었다.
NOTE

arg_minarg_max는 최소 또는 최대 값이 나타나는 행의 표현식을 계산하는 집계 함수다. 표현식 하나에만 관심이 있다면, 앞에서 다룬 것 같은 과제에는 서브쿼리보다 이 함수를 쓰는 편이 낫다. 그러나 표현식이 둘 이상 필요하거나 최소·최대가 아닌 다른 값을 평가하려 한다면, 조건 안의 서브쿼리를 피해 갈 수 없다.

그러면 시스템별로 최대 발전량과 측정 시각을 알아내려면 어떻게 하는가. 원래의 서브쿼리로는 까다롭다. 그것은 전체 최대 발전량에 대한 값만 보여주기 때문이다. 서브쿼리가 행마다 다른 값을 돌려주어야 하며, 그러려면 바깥 질의의 필드를 안쪽에서 쓰는 상관 서브쿼리를 쓴다.

상관 스칼라 서브쿼리 쓰기 목록 4.4
SELECT system_id, read_on, power
FROM readings r1
WHERE power = (
    SELECT max(power)
    FROM readings r2
    WHERE r2.system_id = r1.system_id   -- 상관을 만드는 조건은 이것이다
)
ORDER BY ALL;
system_idread_onpower
int32timestampdecimal(10,3)
102019-02-23 12:45:001109.293
342019-05-08 12:15:00133900.000
342019-05-23 10:00:00133900.000
342019-05-23 11:30:00133900.000
342019-05-28 11:45:00133900.000
342020-04-02 11:30:00133900.000
12002020-04-16 12:15:0047873.333
상관을 만드는 것은 power 값의 비교가 아니라 system_id의 비교라는 지적이 정확하다. 이것이 스칼라 상관 서브쿼리다. 안쪽 질의는 데이터베이스가 바깥 질의의 행마다 평가해야 하는 방식으로 바깥에 관계된다.
규모의 차이가 눈에 확 들어온다. 10번 시스템의 최대치는 1.1kW인데 34번은 133.9kW다. 백 배가 넘는다. 이 사실이 다음 절에서 파티션이 왜 필요한지의 근거가 된다.
← 좌우로 밀어 보기 →

표현식으로 쓰인 서브쿼리는 조인으로 다시 쓸 수 있다. 중첩 집계의 계산만이 예외다. 방금의 예제라면 이렇게 된다.

비상관 서브쿼리를 외부 테이블과 조인하기 목록 4.5
SELECT r1.system_id, read_on, power
FROM readings r1
JOIN (
    SELECT r2.system_id, max(power) AS value
    FROM readings r2
    GROUP BY ALL
) AS max_power ON (
    max_power.system_id = r1.system_id AND
    max_power.value = r1.power
  )
ORDER BY ALL;
이것이 가독성에 도움이 되는지 아닌지는 읽는 사람이 판단할 몫이라고 저자들은 물러선다. 다른 관계형 데이터베이스에서는 사람들이 이렇게 고쳐 쓰는 일이 잦다. 큰 테이블의 모든 행에 대해 상관 서브쿼리를 평가하는 것이 느릴 수 있기 때문이다.

그런데 DuckDB에서는 그 수고가 필요하지 않다. 이 절에서 가장 중요한 대목이므로 옮겨 적어둘 만하다. DuckDB는 서브쿼리 탈상관(decorrelation) 옵티마이저를 쓴다. 이 옵티마이저는 서브쿼리를 언제나 바깥 질의로부터 독립적으로 만든다. 그래서 사용자는 서브쿼리를 조인으로 손수 고쳐 쓸 걱정 없이 표현력 있는 질의를 자유롭게 쓸 수 있다.

더 나아간 사실이 있다. SQL을 고쳐 써서 어떤 서브쿼리를 손으로 탈상관하는 것이 언제나 가능한 것은 아니다. DuckDB는 내부적으로 모든 서브쿼리를 탈상관하는 특수한 종류의 조인을 쓴다. 실은 DuckDB에는 탈상관되지 않은 서브쿼리를 실행하는 기능 자체가 없다. 이것이 사용자에게는 이득이다. 질의의 가독성과 표현력, 그리고 풀려는 업무 문제에 집중할 수 있게 해주기 때문이다. 어떤 종류의 서브쿼리를 쓰는지 아예 걱정하지 않아도 된다는 것, 그것이 이 엔진이 주는 여유다.

표현식으로서의 서브쿼리 4.3.1

상관이든 비상관이든, JOIN에서 관계로 쓰이지 않는 모든 형태의 서브쿼리는 표현식이다. 따라서 다른 여러 연산자와 함께 쓸 수 있다. = 연산자와 부등 연산자 <, <=, >=, >는 서브쿼리가 정확히 한 행을 돌려주는 스칼라일 것을 요구한다. 스칼라와 비스칼라 서브쿼리를 함께 다룰 때는 추가 연산자를 쓸 수 있다. IN, EXISTS, ANY, ALL이며, 모두 집합 비교를 통해 작동한다.

이 절의 인공적인 예제들은 모두 v = 7을 돌려준다. 값이 같다는 것이 요점이 아니라, 같은 답에 이르는 네 가지 어법을 나란히 놓는 것이 요점이다.

같은 답에 이르는 네 가지 어법 목록 4.6 – 4.9
D .mode line

-- 목록 4.6 · EXISTS — 다른 테이블의 한 행에 존재할 수 있는 값을 가진 행을 모두 고른다.
SELECT * FROM VALUES (7), (11) s(v)
WHERE EXISTS (SELECT * FROM range(10) WHERE range = v);

-- 목록 4.7 · IN — EXISTS는 통상 IN을 쓴 비상관 서브쿼리로 고쳐 쓸 수 있다.
--            바깥 값이 서브쿼리 결과에 한 번이라도 담겨 있으면 참이 된다.
SELECT * FROM VALUES (7), (11) s(v)
WHERE v IN (SELECT * FROM range(10));

-- 목록 4.8 · ANY — IN은 각 값의 '같음' 비교로 동작한다. 부등 조건을 만족하는 값이
--            하나라도 있는지 묻고 싶을 때는 원하는 비교와 함께 ANY를 쓴다.
SELECT * FROM VALUES (7), (11) s(v)
WHERE v <= ANY (SELECT * FROM range(10));

-- 목록 4.9 · ALL — 바깥 값과 안쪽 값 '전부'의 비교가 참일 때 참이 된다.
SELECT * FROM VALUES (7), (11) s(v)
WHERE v = ALL (SELECT 7);

v = 7   -- 네 질의 모두 같은 답
ANY 앞에 붙는 추가 비교를 눈여겨봐야 한다. <=를 빼면 문장이 성립하지 않는다. 그리고 = ANY()IN()으로 바꿔 쓸 수 있지만, ALL 연산자에는 그런 단순화가 없다.
저자들이 IN을 알아둘 값어치로 든 이유가 실용적이다. 서브쿼리에 온갖 최적화를 해주지 않을 수도 있는, DuckDB 아닌 관계형 데이터베이스를 다룰 때 요긴하다는 것이다. 방금 배운 탈상관 옵티마이저의 은혜가 다른 곳에서는 없을 수 있다.
4.4

그룹화 집합Grouping sets

어떤 데이터셋을 볼 때는 속성의 최소·최대값, 혹은 평균의 개요를 얻어두는 것이 늘 도움이 된다. 지워야 할 이상치가 섞여 있을 수도 있고, 단위를 잘못 다뤘을 수도 있다. 그것을 계산하는 가장 쉬운 방법은 GROUP BY 절 없이 한 질의에 몰아넣는 것이다. 그러면 집계가 바구니 하나—테이블 전체—에서 일어난다.

수집이 타당한지 집계로 점검하기 목록 4.10
SELECT count(*),
       min(power) AS min_W, max(power) AS max_W,
       round(sum(power) / 4 / 1000, 2) AS kWh
FROM readings;
count_star()min_Wmax_WkWh
1518790.000133900.000401723.22
단위 환산의 논리를 저자들이 친절히 적어두었다. 15분당 와트 값을 합산하고, 4로 나누어 시간당 와트(Wh)를 얻고, 다시 1,000으로 나누어 kWh에 이른다.
측정값은 타당해 보인다. 최소값 0조차 그렇다. 밤에는 발전이 없을 뿐이다. 데이터의 0을 결측으로 오해하지 않는 이 감각이 도메인 지식이다.

이제 시스템별 kWh와 측정 횟수를 본다. 여러 해를 15분 간격으로 절단해 들여왔으니 연간 대략 35,040개의 측정값이 있어야 한다. GROUP BY system_id, year가 그 가정을 확인해준다. 그런데 그다음 질문이 이 절의 본론이다. 합계는 어떻게 하는가. 연도별, 연도·시스템별, 그리고 전체의 측정 횟수와 총 발전량 말이다. 다시 말해 그룹마다 서로 다른 상세 수준을 보여주는 드릴다운 보고서를 만들 수 있는가.

숫자를 계산기에 하나씩 넣어 손으로 더하거나, 그룹 키 없는 집계 질의를 하나 더 쓸 수도 있다. 그러나 더 나은 선택이 있다. 그룹화 집합이다. 아래에서 네 어법을 갈아 끼워 결과 행이 어떻게 늘어나는지 확인할 수 있다.

← 좌우로 밀어 보기 →

그룹화 집합이 만든 바구니를 정리하면 이렇다.

  • (year, system_id) — 두 값의 조합으로 정의되는 바구니. 예제에서는 여섯 가지 조합이므로 여섯 행이 나온다.
  • (year) — 연도 하나로만 정의되는 바구니. 이 집합에는 없지만 다른 집합에는 있는 키에는 NULL 값이 채워진다. 여기서는 system_id가 그렇다.
  • () — 빈 바구니, 또는 빈 그룹이라 할 수 있다. 다른 모든 키에 NULL 값이 채워진다.

같은 결과를 줄임 절 ROLLUP으로도 얻는다. ROLLUP 절은 앞서 논한 집합들을 n + 1개의 그룹화 집합으로 자동 생성하며, n은 ROLLUP 절의 항 수다. 모든 해에 걸친 시스템별 합계까지 보고 싶다면 GROUP BY CUBE를 쓴다. CUBE는 부분그룹이 아니라 실제 조합(2n개의 그룹화 집합)을 만든다. 예제에서는 (year, system_id), (year), (system), ()를 만든다.

저자들의 맺음이 이 절의 값어치를 정확히 짚는다. 여러 개의 질의가 아니라 하나의 간결한 질의로 발전량 측정값의 완전한 개요를 갖게 되었다. 오는 길에 덧붙인 모든 드릴다운을 그룹화 집합으로 표현할 수 있다. 최소·최대값은 목록을 읽기 좋게 하려고 생략했을 뿐이다.

4.5

윈도 함수Window functions

창, 그리고 창 위에서 적용되는 함수는 현대 SQL과 분석의 본질적인 부분이다. 윈도 함수는 일반적으로 다른 행을 볼 수 있게 해준다. 통상 SQL 함수는 한 번에 현재 행만 볼 수 있고, 집계를 하는 경우가 예외인데 그 경우에는 행의 수가 줄어든다. 여기서 윈도 함수의 결정적 성질이 나온다. 보통의 집계 함수와 달리, 윈도 함수를 써도 행들이 하나의 출력 행으로 묶이지 않는다. 행들은 각자의 정체성을 유지한다.

창은 적용하려는 함수 뒤에 붙는 OVER() 절로 도입한다. 창 자체는 작업 대상이 되는 행들의 정의이며, 정해진 순서를 따라 정해진 크기의 행을 데이터셋 위로 움직여 가는 창이라고 생각하면 된다. 윈도잉이 하는 일은 셋으로 정리된다. 관계를 독립적인 파티션들로 쪼개고, 그 파티션을 선택적으로 정렬하고, 각 행마다 근처 값들의 함수로서 새 컬럼을 계산하는 것이다.

OVER () — 빈 창

모든 행을 보려면 빈 창을 쓴다.

PARTITION BY — 파티션

다른 필드의 값이 같은 모든 행을 보려면 그 필드로 파티션을 만든다.

RANGE · ROWS — 프레임

근처의 행들을 보려면 프레임을 쓴다.

여기서 오해하기 쉬운 지점을 저자들이 못 박아둔다. 창의 크기는 파티션의 크기와 같지 않다. 둘은 독립적으로 정의할 수 있다. 종국에 창의 내용이 함수로 넘어가 새 값이 계산된다. 창의 맥락에서만 동작하는 전용 함수가 몇 개 있지만, 모든 보통의 집계 함수를 윈도 함수로 쓸 수 있다. 그래서 다음과 같은 쓰임이 열린다.

  • 순위 매기기
  • 창마다 독립적인 집계 계산
  • 창마다 누적 합계 계산
  • lag 또는 lead로 앞뒤 행에 접근해 변화량 계산

순진한 top-3의 함정

구체적인 예제를 본다. 가장 많은 전력이 생산된 시스템과 그 시각 상위 셋을 15분 단위로 얻고 싶다고 하자. 순진한 접근은 발전량으로 정렬해 3으로 제한하는 것이다.

LIMIT 3이 답하지 못하는 질문 목록 4.15
-- 순진한 접근
D SELECT * FROM readings ORDER BY power DESC LIMIT 3;
-- → 세 행 모두 power = 133900.000. 서로 다른 날짜의 34번 시스템이다.

-- 제대로 된 top-n 질의
WITH ranked_readings AS (
    SELECT *,
           dense_rank()
             OVER (ORDER BY power DESC) AS rnk
    FROM readings
)
SELECT *
FROM ranked_readings
WHERE rnk <= 3;
생산된 전력의 원값으로는 가장 많이 생산된 시각(즉 1위)만 얻게 되고, 상위 세 개의 발전량 각각에 대한 측정값은 얻지 못한다. 요청받은 것은 후자다.
dense_rank()는 동순위에 대해 순위를 건너뛰지 않고 현재 행의 순위를 계산한다. 다섯 행이 1위라면 그다음으로 많이 생산한 행은 6위가 아니라 여전히 2위다. 구멍이 포함되기를 바란다면—그다음 행이 2위가 아니라 6위이기를 바란다면—rank를 쓴다.
WHERE 절에서 순위를 걸러내는 이 다소 어색한 조건은 4.6.2절에서 QUALIFY를 배울 때 없어진다. 저자들이 미리 예고해두었다.

OVER() 절 안의 창 정의에 들어가는 ORDER 절은 선택이며, 문장 끝의 ORDER BY 절과 달리 질의 결과를 정렬하지 않는다. OVER() 절의 일부로 쓰일 때 ORDER BY윈도 함수가 실행되는 순서를 정의한다. 생략하면 윈도 함수는 임의의 순서로 실행된다. 방금의 예제에서 생략하는 것은 무의미하다. 정렬되지 않은 밀집 순위는 언제나 1이 될 것이기 때문이다.

파티션 정의하기 4.5.1

앞의 순위 매기기는 나아졌지만 아직 특별히 도움이 되지 않는다. 시스템들의 발전량이 자릿수가 다를 만큼 차이가 나기 때문이다. 시스템을 구분하지 않고 순위를 계산하는 것은 원하는 바가 아닐 수 있다. 여기서 실제로 필요한 것은 시스템별 상위 세 측정값이며, 각 시스템이 데이터의 독자적인 파티션을 이룬다. 파티셔닝은 관계를 독립적이고 서로 무관한 조각들로 쪼개고, 그 안에서 윈도 함수가 적용된다.

PARTITION BY 절로 파티션이 어떻게 구성되는지 정의하지 않으면 관계 전체가 하나의 파티션으로 취급된다. 그리고 결정적인 제약이 하나 있다. 윈도 함수는 평가되고 있는 행이 담긴 파티션 밖의 값에 접근할 수 없다.

system_id read_on power rnk 102019-02-23 12:45:001109.2931 102019-03-01 12:15:001087.9002 첫째 파티션 · 10번 342019-05-08 12:15:00133900.0001 342019-05-23 10:00:00133900.0001 342019-05-23 11:30:00133900.0001 342019-05-28 11:45:00133900.0001 342020-04-02 11:30:00133900.0001 342019-05-09 10:30:00133700.0002 342019-05-10 12:15:00133700.0002 둘째 파티션 · 34번 12002020-04-16 12:15:0047873.3331 12002020-04-02 12:30:0047866.6672 12002020-04-16 13:15:0047866.6672 셋째 파티션 · 1200번 파티션 경계에서 순위가 1로 되돌아간다 dense_rank() OVER (PARTITION BY system_id ORDER BY power DESC)
〈도판 4.2〉창을 적용하기 전에 데이터를 파티션으로 나누기. 34번 시스템은 최고 출력에 다섯 번, 두 번째 출력에 두 번 도달했다.
← 좌우로 밀어 보기 →

순위 매기기만 파티션 안에서 쓸 수 있는 것은 아니다. avg, sum, max, min 같은 집계 함수도 윈도잉 맥락에서 쓸 훌륭한 후보다. 차이는 그것들이 생산되는 행의 수를 바꾸지 않는다는 점이다. 시스템마다 하루의 발전량을 고르면서, 추가 컬럼에 그 시스템의 전체 평균 발전량을 함께 얻고 싶다고 하자. GROUP BY ROLLUP을 떠올릴 수 있고 그 생각이 틀리지도 않다. 그러나 그 그룹화 집합은 꽤 커지고(GROUP BY ROLLUP (system_id, day, kwh)), 평균값을 추가 컬럼이 아니라 추가 행으로 만들어낸다. 찾던 값—시스템별 전체 발전량—은 시스템 값이 있고 날짜 값이 없는 행에서 발견될 것이다.

추가 행을 다루지 않는 다른 방법은 자기 조인이다. 같은 테이블을 다시 조인해 원하는 집계를 키로 묶어 고르는 것이다. 원하는 결과는 나오지만 읽기 어렵고, 테이블 전체가 두 번 스캔되므로 성능도 좋지 않을 것이다. 파티션 창 맥락에서 avg를 쓰는 편이 훨씬 읽기 쉽고 성능도 좋다.

파티션 위의 집계 — 행 수는 그대로 4.5.1
SELECT *,
       avg(kWh)
          OVER (
            PARTITION BY system_id
          ) AS average_per_system
FROM v_power_per_day;
system_iddaykWhaverage_per_system
int32datedoubledouble
102019-01-012.194.444051896207586
102019-01-045.374.444051896207586
····
12002019-07-25232.37170.75771639042347
12002019-04-29210.97170.75771639042347
1587 rows (4 shown)    4 columns
집계—여기서는 avg(kWh)—는 뒤따르는 창 위에서 계산된다. 행의 수를 바꾸지 않으며 모든 행에 그 값이 놓인다. 파티션이 정의한 대로 시스템마다 계산된다. 창 정의 안의 ORDER BY를 생략한 것은, 평균값에는 값이 집계에 들어가는 순서가 무관하기 때문이다.
저자들이 남긴 경험 법칙이 이 절의 결론이다. 행 수를 바꾸지 않고 질의에 집계를 더하려고 앞서 논한 것 같은 자기 조인을 쓸까 고민할 때마다, 아마 윈도 함수를 써야 한다.
← 좌우로 밀어 보기 →
4.5.2

프레이밍Framing

top-n 질의는 스트리밍 서비스를 운영하며 상위 n개 차트를 보여주고 싶을 때 요긴하다. 그러나 이 예제에서 더 흥미로운 질문은 이것이다. 시스템 전체에서 생산된 에너지의 7일 이동평균은 얼마인가. 이 질문에 답하려면 세 가지를 해야 한다.

  • 15분 간격의 측정값을 일 단위로 집계한다 — 그룹화와 합산.
  • 일자와 시스템으로 파티션을 나눈다.
  • 7일 크기의 프레임을 만든다.

여기서 프레이밍이 등장한다. 프레이밍은 함수가 평가되는 각 행에 대해 상대적인 행의 집합을 지정한다. 현재 행으로부터의 거리는 현재 행에 선행하거나 후행하는 표현식으로 주어진다. 이 거리는 정수 개의 행으로 지정할 수도 있고, 정렬 표현식의 값으로부터의 범위 델타 표현식으로 지정할 수도 있다. 이 두 갈래가 곧 ROWSRANGE다.

가독성을 위해, 그리고 예제가 창 정의에 집중하도록, 제3장에서 정의한 뷰 v_power_per_day를 쓴다. 시스템별·일자별 kWh를 돌려주는 그 뷰다. CTE로 표현해도 마찬가지로 쉬웠을 것이다.

RANGE 프레임으로 윈도 함수 적용하기 목록 4.17
SELECT system_id,
       day,
       kWh,
       avg(kWh) OVER (
           PARTITION BY system_id        -- 시스템 id로 정의한 파티션 위를 창이 움직인다
           ORDER BY day ASC              -- 일자 순서로 정렬
           RANGE BETWEEN INTERVAL 3 Days PRECEDING
                      AND INTERVAL 3 Days FOLLOWING   -- 도합 7일 크기
       ) AS "kWh 7-day moving average"
FROM v_power_per_day
ORDER BY system_id, day;
3일 앞, 현재 날, 3일 뒤. 창 정의에 쓸 수 있는 모든 선택지를 한 문장에 동원한 예다. 결과의 행 수는 원본 측정값에 든 온전한 날의 수만큼이므로, 원서도 일부만 보여준다.

말로만 읽으면 프레임은 좀처럼 손에 잡히지 않는다. 아래는 원서 〈도판 4.3〉이 보여주는 10번 시스템의 2019년 1월 첫 15일을 그대로 옮겨놓고, 현재 행을 옮겨가며 프레임이 어디까지 걸치는지, 그 안의 평균이 어떻게 계산되는지 직접 확인할 수 있게 만든 장치다. 프레임 정의도 세 가지로 갈아 끼울 수 있다.

2019-01-04
프레임 안의 행
평균 kWh
원서 도판 4.3 값
← 좌우로 밀어 보기 →

프레임의 성질 하나가 이 장치에서 곧바로 눈에 들어온다. 파티션의 시작과 끝에서는 프레임이 잘린다. 첫 행의 창에는 선행하는 3일이 없으므로 네 행만 들어가고, 그래서 첫 행의 평균은 뒤쪽 값들에 끌려간다. 이동평균 차트의 양 끝이 늘 수상해 보이는 이유가 여기 있다.

명명 윈도 4.5.3

범위를 가진 창을 논하며 방금 배운 대로, 창 정의는 꽤 복잡해질 수 있다. 파티션의 정의, 순서, 그리고 창의 실제 범위를 모두 담을 수 있기 때문이다. 그런데 주어진 창 위에서 집계 하나만 필요한 것이 아닐 때가 있다. 창 정의를 계속 되풀이하는 것은 고된 일이다.

이 도메인—태양광 시스템의 발전량 측정—에서라면 분위수를 써서 계절과 날씨를 함께 담는 보고서를 만들 수 있다. 월별로 7일 창의 분위수를 계산하는 것이다. 넓은 월별 평균으로도 충분할 때가 있지만, 그런 차트는 달마다 변하는 비교적 매끄러운 곡선만 보여준다. 한 주 동안 변하는 날씨를 거치며 발전량의 요동은 더 크다. 이상치와 튀는 값은 분위수로 잡아 표현하는 편이 낫다. 그 결과는 예컨대 움직이는 상자수염 그림을 만드는 데 쉽게 쓸 수 있다.

이상치를 잡고 분위수를 계산하려면 집계 셋(min, max, 분위수)이 필요하고, 창을 매번 정의하고 싶지는 않다. 목록 4.17의 정의를 그대로 가져와 측정값의 을 파티션에 더한다. 그 밖에는 창 정의가 같다. 정의를 FROM 절 뒤로 옮겨 seven_days라는 이름을 준다. 필요한 만큼 여러 집계에서 참조할 수 있다.

복잡한 순서와 파티션을 가진 명명 윈도 목록 4.18
SELECT system_id,
       day,
       min(kWh) OVER seven_days AS "7-day min",
       quantile(kWh, [0.25, 0.5, 0.75])
         OVER seven_days AS "kWh 7-day quartile",
       max(kWh) OVER seven_days AS "7-day max",
FROM v_power_per_day
WINDOW
    seven_days AS (
        PARTITION BY system_id, month(day)
        ORDER BY day ASC
        RANGE BETWEEN INTERVAL 3 Days PRECEDING
                  AND INTERVAL 3 Days FOLLOWING
    )
ORDER BY system_id, day;
system_idday7-day minkWh 7-day quartile7-day max
int32datedoubledouble[]double
102019-01-012.19[2.19, 5.37, 5.55]5.72
102019-01-022.19[4.62, 5.37, 5.55]5.72
102019-01-032.19[3.69, 4.62, 5.55]5.72
102019-01-042.19[3.69, 5.37, 5.72]5.81
102019-01-053.69[4.62, 5.37, 5.72]5.81
·····
12002020-06-22107.68[149.11, 191.61, 214.68]279.8
12002020-06-230.0[107.68, 191.61, 214.68]279.8
12002020-06-240.0[190.91, 191.61, 214.68]279.8
12002020-06-250.0[191.61, 203.06, 214.68]279.8
12002020-06-260.0[0.0, 203.06, 214.68]279.8
1587 rows (10 shown)    5 columns
WINDOW 절은 FROM 절 뒤에 지정해야 하며, 창의 정의 자체는 인라인 창의 문법을 따른다. quantile 함수는 분위수를 계산할 값과 원하는 분위수의 리스트를 받는다. 결과에는 구조화된 컬럼 타입double[]—이 등장한다.
모든 집계를 윈도 함수로 쓸 수 있다는 앞의 말에는 복잡한 통계 함수도 포함된다. 그룹의 정확한 분위수(quantile, quantile_disc)와 보간된 분위수(quantile_cont)가 그렇다. 이 함수들의 구현은 윈도잉에 최적화되어 있어 성능을 걱정하지 않고 쓸 수 있다. 여러 집계를 질의할 때는 명명 윈도를 쓴다.
← 좌우로 밀어 보기 →

파티션 안에서 앞뒤 행에 접근하기 4.5.4

순위 매기기는 이미 논했고 누적 합계의 예는 4.8절에서 보겠지만, 파티션 안에서 행 사이를 앞뒤로 뛰어다니는 능력은 아직 쓰지 않았다. 변화량을 계산해보는데, 요즘 시세만큼 좋은 예가 또 있겠는가.

제3장에서 만든 prices 테이블은 독일에서 에너지를 계통에 되팔 때의 판매 가격(ct/kWh)을 담고 있다. 그 판매 가격이 재생에너지 장려 정책의 유인 축소에 반응해 이후 하락했다고 하자. 이제 재생에너지에 대한 보상이 시간에 따라 얼마나 변했는지 알고 싶다. 차이를 계산하려면 n번째 행의 가격 값이 필요하고, 그것을 n-1번째 행의 값과 비교해야 한다. 테이블의 행은 본질적으로 한 행씩 고립되어 처리되므로 창 없이는 불가능하다. 그러나 정렬 가능한 아무 컬럼에나 창을 걸치면 lag()lead()로 현재 창 밖의 행에 접근할 수 있다. 어제의 가격을 골라 오늘 가격과 비교할 수 있게 되는 것이다.

lag 함수는 파티션 안에서 현재 행에 선행하는 행의 표현식 값을 주거나, 그런 행이 없으면 NULL을 준다. 파티션의 첫 행이 그 경우다. lead는 반대로 동작하니 파티션의 마지막 행에서 NULL을 돌려준다. DuckDB에서 두 함수는 몇 행을 뒤로 또는 앞으로 갈지뿐 아니라 기본값까지 지정할 수 있는 여러 오버로드를 갖는다. 그렇지 않다면 NULL 값이 실용적이지 않을 때 coalesce를 쓰는 것도 선택이 된다.

NOTE

coalesce 함수는 NULL이 아닌 첫 번째 인자를 돌려준다.

창의 앞 값 가져와 변화량 계산하기 목록 4.19
SELECT valid_from,
       value,
       lag(value)
         OVER validity AS "Previous value",
       value - lag(value, 1, value)
         OVER validity AS Change
FROM prices
WHERE date_part('year', valid_from) = 2019
WINDOW validity AS (ORDER BY valid_from)
ORDER BY valid_from;
valid_fromvaluePrevious valueChange
datedecimal(5,2)decimal(5,2)decimal(6,2)
2019-01-0111.470.00
2019-02-0111.3511.47-0.12
2019-03-0111.2311.35-0.12
2019-04-0111.1111.23-0.12
····
2019-09-0110.3310.48-0.15
2019-10-0110.1810.33-0.15
2019-11-0110.0810.18-0.10
2019-12-019.9710.08-0.11
12 rows (8 shown)    4 columns
lag(value)는 한 행 뒤로 뛰어 value 컬럼을 집어온다. 첫 행에는 선행 행이 없으므로 NULL이 놓인다. 변화량은 lag(value, 1, value)—앞 행이 없으면 같은 값을 기본값으로 삼는다—를 써서 현재 행의 가격과 그 앞 행 가격의 차이로 계산했다. 그래서 첫 행의 변화량이 NULL이 아니라 0.00이다.
본문에 어긋난 자리가 있다. 이 절의 앞에서는 판매 가격이 하락했다고 설정했는데, 목록 4.19를 소개하는 문장에서는 현재 가격이 새 규제에 반응해 올랐다고 적었다. 데이터와 결과는 모두 하락을 보여주고 총 변화량도 음수이니, 후자가 오식이다.
← 좌우로 밀어 보기 →

2019년 가격의 변화량을 계산하려면 CTE를 써야 한다. 윈도 함수 호출을 집계 함수 안에 중첩할 수 없기 때문이다. 4.3절에서 본 "집계는 중첩할 수 없다"의 사촌 격인 제약이다.

창 위의 집계 계산하기 목록 4.20
WITH changes AS (
    SELECT value - lag(value, 1, value) OVER (ORDER BY valid_from) AS v
    FROM prices
    WHERE date_part('year', valid_from) = 2019
    ORDER BY valid_from
)
SELECT sum(changes.v) AS total_change
FROM changes;
찾던 2019년 독일의 가격 차이가 나온다. 개인이 생산한 재생에너지에 대한 보상이 1.50 ct/kWh 깎였다. 11.47에서 9.97로, 열두 달 동안 열한 번의 인하가 쌓인 결과다.
4.6

WHERE 절 밖의 조건과 필터링Conditions and filtering outside the WHERE clause

계산된 집계도, 윈도 함수의 결과도 표준 WHERE 절로는 걸러낼 수 없다. 그런데 그 필터링은 다음 같은 질문에 답하기 위해 필요하다. 집계값이 x를 넘는 그룹을 고르려면 HAVING 절을 써야 하고, 며칠의 범위에서 특정 값을 넘는 데이터를 고르려면 QUALIFY 절을 써야 한다. 여기에 더해, 값이 집계 함수에 애초에 들어가지 못하게 걸러내려면 FILTER 절이 필요하다.

표 4.1  필터링 절과 그것을 쓸 자리
쓰는 자리효과
HAVINGGROUP BY그룹에 대해 계산된 집계를 기준으로 행을 걸러낸다.
QUALIFY임의의 윈도 표현식을 참조하는 FROM 절 뒤그 창에서 계산된 무엇이든 기준으로 행을 걸러낸다.
FILTER임의의 집계 함수 뒤집계로 넘겨지는 값을 걸러낸다.

HAVING 절 쓰기 4.6.1

발전량이 900kWh를 넘는 날을 모두 주시오! 제3장에서 WHERE 절과 GROUP BY의 작동을 배웠으니 그 둘을 조합해볼 만하다. 그런데 실패한다.

WHERE가 집계를 담을 수 없는 이유 목록 4.21
-- 시도
SELECT system_id,
       date_trunc('day', read_on)              AS day,
       round(sum(power) / 4 / 1000, 2)       AS kWh,
FROM readings
WHERE kWh >= 900
GROUP BY ALL;
Error: Binder Error: Referenced column "kWh" not found in FROM clause!
-- (DuckDB 0.10 이전의 문구. 최신 판본은 "WHERE 절은 집계를 담을 수 없다"고 알려준다.)

-- 조건을 GROUP BY 뒤의 HAVING으로 옮긴다.
SELECT system_id,
       date_trunc('day', read_on)              AS day,
       round(sum(power) / 4 / 1000, 2)       AS kWh,
FROM readings
GROUP BY ALL
HAVING kWh >= 900
ORDER BY kWh DESC;
system_iddaykWh
int32datedouble
342020-05-12960.03
342020-06-08935.33
342020-05-23924.08
342019-06-09915.4
342020-06-06914.98
342020-05-20912.65
342019-05-01912.6
342020-06-16911.93
342020-06-07911.73
342020-05-18907.98
342019-04-10907.63
342019-06-22906.78
342020-05-19906.4
이유가 명료하다. 계산된 컬럼 kWh는 WHERE 절이 적용될 시점에 아직 알려져 있지 않고, 그 시점에 알 수도 없다. 마찬가지로 계산된 컬럼인 day와는 사정이 다르다. WHERE 절에서 행을 고르는 것, 다시 말해 행을 걸러내는 것은 애초에 어떤 행이 집계될지를 바꾸는 일이기 때문이다. 그러므로 집계 이후에 적용되는 다른 절이 필요하다.
열세 날 모두 34번 시스템이다. 그리고 5월과 6월에 몰려 있다. 북반구 태양광의 성수기가 표로 드러난다.
← 좌우로 밀어 보기 →

QUALIFY 절 쓰기 4.6.2

윈도 함수의 결과가 어떤 필터에 맞는 행만 돌려받고 싶다고 하자. 그 필터를 WHERE 절에 넣을 수 없다. 창에 포함되어야 할 행들을 걸러내버릴 것이고, 우리는 윈도 함수의 결과를 써야 하기 때문이다. 그런데 HAVING도 쓸 수 없다. 윈도 함수는 집계보다 먼저 평가되기 때문이다. 그래서 QUALIFY가 윈도 함수의 결과에 필터를 걸게 해준다.

CTE 없이 다시 쓴 top-3, 그리고 임계값 걸기 목록 4.22 · 4.23
-- 목록 4.22 — 윈도 함수를 도입할 때는 CTE로 결과를 걸러야 했다. QUALIFY로 훨씬 간결해진다.
SELECT dense_rank() OVER (ORDER BY power DESC) AS rnk, *
FROM readings
QUALIFY rnk <= 3;

-- 목록 4.23 — 7일 이동평균이 875kWh를 넘은 날만. QUALIFY 절은 윈도 함수를 이름으로 참조할 수 있다.
SELECT system_id,
       day,
       avg(kWh) OVER (
            PARTITION BY system_id
            ORDER BY day ASC
            RANGE BETWEEN INTERVAL 3 Days PRECEDING
                       AND INTERVAL 3 Days FOLLOWING
       ) AS "kWh 7-day moving average"
FROM v_power_per_day
QUALIFY "kWh 7-day moving average" > 875
ORDER BY system_id, day;
system_iddaykWh 7-day moving average
int32datedouble
342020-05-21887.4628571428572
342020-05-22884.7342857142858
342020-06-09882.4628571428572
7일 평균 발전량은 태양광 설비의 효율을 잘 보여주는 지표이니, 어떤 문턱에 도달한 날을 물을 만하다. 예제 데이터에서는 서반구 태양광 발전의 전형적인 '좋은 날' 세 날이 나온다.
← 좌우로 밀어 보기 →

FILTER 절 쓰기 4.6.3

집계나 평균, 값의 개수를 계산하려는데 포함하고 싶지 않은 행이 있다는 것을 알아차릴 때가 있다. 그런데 복잡한 질의에서는 다른 필드를 계산하기 위해 그 행들을 남겨두어야 할 수도 있다. 예컨대 이따금 음수로 나타나는 잘못된 측정값이 있다고 하자. 측정값의 총 개수와 센서의 평균 측정값을 함께 계산하고 싶다. 잘못된 측정값을 WHERE 절에서 걸러내면 총 개수를 계산할 수 없다. 그러나 모든 측정값을 그냥 평균 내면 잘못된 음수값이 섞여 들어간다. 이런 유형의 문제를 풀려면 집계의 일부로 FILTER 표현식을 쓴다.

4.1절로 돌아가 보면, 우리는 실은 NULL 값을 평균에 끌어들이는 바로 그 문제에 놓여 있었다. 대개 원하는 바가 아니다. NULL 값을 0으로 눌러두는 대신, 평균에서 아예 걸러낼 수 있다.

무의미한 데이터를 집계에서 배제하기 목록 4.24
INSERT INTO readings(system_id, read_on, power)
SELECT any_value(SiteId),
       time_bucket(
           INTERVAL '15 Minutes',
           CAST("Date-Time" AS timestamp)
       ) AS read_on,
       coalesce(avg(ac_power)
         FILTER (
           ac_power IS NOT NULL AND      -- NULL이거나 0보다 작은 값은
           ac_power >= 0                -- 더 이상 평균에 들어가지 않는다
         ),0 )
FROM
    read_csv_auto(
        'https://developer.nrel.gov/api/pvdaq/v3/' ||
        'data_file?api_key=DEMO_KEY&system_id=10&year=2019'
    )
GROUP BY read_on
ORDER BY read_on
ON CONFLICT DO NOTHING;   -- 이미 채워진 DB에서 돌려도 실패하지 않게
coalesce를 왜 쓰는지 궁금할 만하다. 데이터가 전부 걸러지면 집계에 아무것도 들어가지 않고, 표현식 전체가 NULL로 바뀐다. 그러면 readings 테이블의 제약을 위반하게 된다. 그래서 0으로 받쳐둔다.
늘 그렇듯 여기에 하나의 옳은 접근은 없다. 목록 4.1의 해법을 선호해도 괜찮고 목록 4.24를 선호해도 괜찮다. 다만 이 경우에는 FILTERcoalesce를 조합한 해법이 조금 더 낫다고 저자들은 말한다. 의도가 조금 더 분명하기 때문이라는 이유가 좋다. 0으로 누른 값과 애초에 세지 않은 값은 다른 이야기다.
4.7

PIVOT 문The PIVOT statement

한 질의에 집계를 여럿 담을 수 있고, 그 각각에 개별적으로 필터를 걸 수 있다. 그러면 다음 같은 과제에 답할 수 있다. 시스템별·연도별 에너지 생산량 보고서를 원하는데, 연도는 컬럼이 되어야 한다! 시스템별 집계도 쉽고 연도별 집계도 쉽다. 두 키로 묶는 것도 어렵지 않다. 그러나 그렇게 하면 연도가 행으로 나오고 컬럼으로 나오지 않는다. 원하는 것은 세 행에 2019와 2020이라는 두 컬럼, 스프레드시트 프로그램에서 보던 그 모양이다. 그런 표를 재조직하는 과정을 피벗이라 부른다.

LONG system_idyear("day")sum(kWh) 1020191549.280000000001 102020677.1900000000003 342019205742.59999999992 342020101033.75000000001 1200201962012.109999999986 1200202030709.329999999998 1 PIVOT (FROM v_power_per_day) 모든 컬럼을 고른다. FROM은 생략할 수도 있다. 2 ON year(day) 이 표현식의 서로 다른 값 전부가 컬럼이 된다. 3 USING sum(kWh) 컬럼에 대해 계산할 집계다. PIVOTED system_id20192020 101549.280000000001677.1900000000003 34205742.59999999992101033.75000000001 120062012.10999999998630709.329999999998
〈도판 4.4〉발전량 값을 연도로 피벗하기. 단계 번호는 목록 4.26의 절 순서와 같다.
← 좌우로 밀어 보기 →
정적 피벗과 동적 피벗 목록 4.25 · 4.26
-- 목록 4.25 — 집계를 여럿 정의하고 각 컬럼에 필요 없는 값을 걸러낸다.
SELECT system_id,
       sum(kWh) FILTER (WHERE year(day) = 2019)
            AS 'kWh in 2019',
       sum(kWh) FILTER (WHERE year(day) = 2020)
            AS 'kWh in 2020'
FROM v_power_per_day
GROUP BY system_id;

-- 목록 4.26 — DuckDB의 PIVOT 절. 임의의 표현식으로 동적 피벗이 된다.
PIVOT (FROM v_power_per_day)
ON year(day)
USING sum(kWh);
정적 방식에는 흠 하나가 있다. 컬럼이 사실상 하드코딩되어, 연도가 추가될 때마다 그 질의를 다시 손봐야 한다. 원하는 컬럼 집합이 변하지 않을 것이 확실하거나, 다른 형태의 피벗을 지원하지 않을 수도 있는 다른 데이터베이스를 겨냥한다면 정적 방식이 맞는 해법일 수 있다.
같은 값을 두 곳에서 재보면 미세하게 다르다. 4.4절의 목록 4.11은 34번 시스템 2019년을 205741.9로, 4.7절은 205742.59999999992로 적는다. 전자는 원시 power를 합산해 한 번 반올림한 값이고, 후자는 뷰가 하루마다 이미 반올림해둔 kWh를 다시 합산한 값이다. 365일에 걸친 ±0.005의 반올림이 쌓여 0.7의 차이를 만든다. 어느 쪽도 틀리지 않았으나, 같은 보고서에 나란히 실으면 곤란해질 숫자다.

셀 값에 집계를 쓰는 경우, ON 절에 속하지 않은 모든 컬럼이 그 집계의 그룹 키로 쓰인다. 그런데 집계를 쓰지 않아도 된다. 그러면 흥미로운 일이 벌어진다. PIVOT v_power_per_day ON day1,382행 545컬럼의 결과를 만든다. 왜 그런가. v_power_per_day에는 (system_id, kWh)의 서로 다른 값이 1,382개 있어 그것이 행을 이루고, year(day)가 아니라 day로 컬럼을 만들라고 했으니 기록된 543개의 서로 다른 날이 컬럼이 되고, 여기에 system_idkWh 두 컬럼이 더해진다.

그러면 셀에는 무엇이 들어 있는가. 수많은 0과 몇 개의 1이다. USING 절이 없으면 DuckDB는 해당 값이 없던 날의 셀에 0을, 있던 날의 셀에 1을 채운다. 그러므로 모든 날을 표 형태로 보고 싶다면 first 집계를 쓰는 편이 좋다.

날짜를 컬럼으로, 그리고 집계 둘을 한꺼번에 4.7
-- 수백 컬럼을 인쇄하지 않도록 며칠만 일부러 골랐다.
PIVOT (
    FROM v_power_per_day WHERE day BETWEEN '2020-05-30' AND '2020-06-02'
)
ON DAY USING first(kWh);
system_id2020-05-302020-05-312020-06-012020-06-02
int32doubledoubledoubledouble
104.243.784.475.09
34732.5790.33796.55629.17
1200280.4282.25288.29152.83
저자들의 표현대로, 어떤 스프레드시트 예술가라도 흡족해할 결과다.
-- USING 절에 집계를 여럿 계산할 수도 있고, 피벗에 컬럼을 여럿 쓸 수도 있다.
PIVOT v_power_per_day
ON year(day)
USING round(sum(kWh)) AS total, max(kWh) AS best_day;
system_id2019_total2019_best_day2020_total2020_best_day
int32doubledoubledoubledouble
101549.07.47677.06.97
34205743.0915.4101034.0960.03
120062012.0337.2930709.0343.43
연도별 총 발전량에 가장 좋았던 날까지 두 컬럼을 더했다. 컬럼 이름이 2019_total, 2019_best_day처럼 피벗 값과 집계 별칭의 조합으로 자동 생성되는 점을 눈여겨볼 만하다.
앞의 질의들은 모두 DuckDB 고유의 PIVOT 변종을 쓴다. DuckDB의 문법은 피벗할 행을 정적으로 열거하는 일을 완전히 없애주므로 피벗 문장을 훨씬 쉽고 오류 적게 쓰게 해준다. DuckDB는 더 표준적인 SQL 형태의 PIVOT도 지원한다. 그러나 PIVOT 절의 지원은 데이터베이스마다 크게 다르고, 다른 대상 데이터베이스가 표준의 정확히 같은 맛을 가질 가능성은 낮다. 그래서 이 경우에는 더 이식성 있는 SQL을 바라기보다 읽기 쉬운 고유 문법을 쓰는 편을 택한다고 저자들은 밝힌다.
← 좌우로 밀어 보기 →
4.8

ASOF JOIN 쓰기Using the ASOF JOIN

변동성 있는 상품을 하루 중 임의의 시각에 판다고 상상해보자. 가격은 어떤 간격—15분이라 하자—으로 예측할 수 있고, 그것이 얻을 수 있는 정밀도의 한계다. 그런데 사람들은 아무 때나 상품을 요구한다. 그래서 다음 같은 상황이 벌어진다. 가격 표에 한 시간짜리 항목 넷, 판매 표에 열두 항목이 있는데, 이를 순진하게 조인하면 열두 건의 판매 가운데 넉 건만 결과로 나온다.

INNER JOIN · 4건만 성립 p 1:00 p 1:15 p 1:30 p 1:45 v 1:00 v 1:05 v 1:10 v 1:15 v 1:20 v 1:25 v 1:30 v 1:35 v 1:40 v 1:45 v 1:50 v 1:55 1:00 → 1:00 1:15 → 1:15 1:30 → 1:30 1:45 → 1:45 1:05 · 1:10 유실 1:20 · 1:25 유실 1:35 · 1:40 유실 1:50 · 1:55 유실 ASOF JOIN · p <= v · 12건 모두 성립 p 1:00 p 1:15 p 1:30 p 1:45 v 1:00 · 가격 1:00 v 1:05 · 가격 1:00 v 1:10 · 가격 1:00 v 1:15 · 가격 1:15 v 1:20 · 가격 1:15 v 1:25 · 가격 1:15 v 1:30 · 가격 1:30 v 1:35 · 가격 1:30 v 1:40 · 가격 1:30 v 1:45 · 가격 1:45 v 1:50 · 가격 1:45 v 1:55 · 가격 1:45 가격 하나가 뒤따르는 판매 셋을 맡는다. 판매 시점 이전에 유효했던 가장 가까운 가격을 고르기 때문이다.
〈도판 4.5 · 4.6〉시계열 데이터의 내부 조인이 잘못되는 모습과, ASOF JOIN이 정확히 맞지 않는 시각들을 짝지어주는 모습.
← 좌우로 밀어 보기 →
두 글자를 바꾸면 네 건이 열두 건이 된다 목록 4.27 · 4.28
WITH prices AS (
   SELECT range AS valid_at,
          random()*10 AS price
   FROM range(
        '2023-01-01 01:00:00'::timestamp,
        '2023-01-01 02:00:00'::timestamp, INTERVAL '15 minutes')
),
sales AS (
   SELECT range AS sold_at,
          random()*10 AS num
   FROM range(
       '2023-01-01 01:00:00'::timestamp,
       '2023-01-01 02:00:00'::timestamp, INTERVAL '5 minutes')
)
SELECT sold_at, valid_at AS 'with_price_at', round(num * price,2) as price
FROM sales
ASOF JOIN prices                        -- 목록 4.27은 그냥 JOIN이었다
     ON prices.valid_at <= sales.sold_at;  -- 목록 4.27은 = 였다
ASOF—"as of"로 읽는다—JOIN은 부등식으로 조인하며, JOIN 컬럼이 정확히 같지 않은 틈에 대해 '충분히 좋은' 값을 골라준다. 고칠 것은 둘뿐이다. JOINASOF JOIN으로 바꾸고, 부등 연산자를 주는 것. prices.valid_at <= sales.sold_at이라는 조건은 판매 시점에 또는 그 이전에 유효했던 모든 가격을 총액 계산에 쓸 수 있다는 뜻이다.
DuckDB가 판매 시점에 가장 가까운 가격을 고르는 것을 눈여겨볼 만하다. 그리고 이제 기대한 열두 행을 얻는다.

ASOF JOIN은 주식 시세, 가격, IoT 센서처럼 시계열 데이터를 다룰 때 자주 쓰인다. 예제에서라면 변하는 판매 가격을 시스템의 측정값과 조인해 임의 시점의 가격을 계산하는 데 쓸 수 있다. 마지막 예제는 다시 태양광 예제 데이터를 쓰면서, ASOF JOIN이 이 장에서 배운 다른 구성물—가격이 서로 다른 판매 기간의 누적 수익을 구하는 창—과 함께 쓰일 수 있음을 보여준다.

ASOF JOIN과 윈도 함수로 누적 수익 계산하기 목록 4.29
SELECT power.day,
       power.kWh,
       prices.value as 'ct/kWh',
       round(sum(prices.value * power.kWh)
           OVER (ORDER BY power.day ASC) / 100, 2)
           AS 'Accumulated earnings in EUR'
FROM v_power_per_day power
    ASOF JOIN prices
    ON prices.valid_from <= power.day
WHERE system_id = 34
ORDER BY day;
daykWhct/kWhAccumulated earnings in EUR
datedoubledecimal(5,2)double
2019-01-01471.411.4754.07
2019-01-02458.5811.47106.67
2019-01-03443.6511.47157.56
2019-01-04445.0311.47208.6
····
2020-06-23798.859.1731371.86
2020-06-24741.159.1731439.83
2020-06-25762.69.1731509.76
2020-06-2611.989.1731510.86
543 rows (8 shown)    4 columns
ORDER BY power.day ASC만 있고 프레임 절이 없는 OVER처음부터 현재 행까지의 누적이 된다. 한 해 반 동안 34번 시스템이 벌어들인 돈이 31,510.86유로다. 그리고 ct/kWh 컬럼이 11.47에서 9.17로 내려앉은 것이 4.5.4절에서 본 가격 인하의 결과다.
도메인이 무엇이든—예제의 센서 측정값이든, 환자의 심박 모니터 판독값이든, 주식시장의 변동이든— 특정 시각에 기록된 값은 한동안 유효했던 특정 키 값과 조인되어 풍부해지는 일이 잦다. ASOF를 지원한다는 것은 타임스탬프가 완벽하게 정렬되어 있지 않은 모든 시나리오를 가능하게 한다는 뜻이다.
← 좌우로 밀어 보기 →
4.9

테이블 함수 쓰기Using table functions

SQL의 대부분 함수는 매개변수를 받아 하나의 값을 돌려준다. 반면 테이블 함수는 하나의 값이 아니라 행의 모음을 돌려준다. 그러므로 테이블이 나타날 수 있는 어느 자리에나 나타날 수 있다. 기능에 따라서는 파일이나 URL 같은 외부 자원에 접근해 그것을 표준 SQL 문장의 일부인 관계로 바꿔놓을 수 있다. 테이블을 만들어내는 함수라는 개념을 지원하는 관계형 데이터베이스가 DuckDB만은 아니지만, DuckDB는 많은 쓰임새를 감당하는 인상적인 테이블 함수 집합을 갖고 온다.

사용 가능한 테이블 함수 목록 얻기 목록 4.30
SELECT DISTINCT ON(function_name) function_name
FROM duckdb_functions()
WHERE function_type = 'table'      -- FROM 절이 테이블 생산 함수를 호출하는 가장 흔한 자리다
ORDER BY function_name;
이 장의 예제와 데이터 수집 과정에서 read_csv*, read_parquet 등을 이미 폭넓게 써왔다. 공간(spatial) 확장 같은 추가 확장도 외부 자원을 읽어 관계형 데이터를 만들어내는 테이블 함수 목록에 더해질 수 있다. 자기 자신의 함수 목록을 자기 자신에게 질의해 얻는다는 점이 재미있다.

range(start, stop)generate_series(start, stop)는 대단히 요긴한 테이블 함수 둘이다. 둘 다 startstop 사이 범위의 값 목록을 만든다. 차이는 경계에 있다.

  • start는 두 함수 모두 포함한다.
  • stop은 range에서는 배제, generate_series에서는 포함이다.
  • 둘 다 세 번째 매개변수 step으로 걸음 크기를 정하는 오버로드를 제공하며, 기본값은 1이다.
  • stop만 받고 start를 0으로 기본 설정하는 변종도 있다.

보통 함수로 쓰이면 유용한 구성물이지만, 테이블처럼 질의할 때 훨씬 강력해진다. 1에서 5 사이 숫자 목록이 필요하고 하드코딩하고 싶지 않다면 SELECT generate_series(1, 5);를 쓴다. 숫자도 도움이 되지만 이 함수들은 시간 데이터에도 통한다. 다만 시간 데이터를 쓸 때는 start와 end 매개변수를 모두 지정해야 한다. 둘 다 합리적인 기본값이 없기 때문이다.

이제 실용으로 옮긴다. 예제 데이터의 측정값은 2020년 중반에 끝난다. 이에 기반한 보고서는 한 해 전체를 의도했다면 조기에 끊긴다.

범위를 주도 테이블로 세워 빈 달을 만들기 목록 4.31
-- 그냥 집계하면 6월에서 끊긴다.
SELECT strftime(day, '%Y-%m') AS month, avg(kwh)
FROM v_power_per_day WHERE year(day) = 2020
GROUP BY ALL ORDER BY month;
-- → 2020-01 … 2020-06, 여섯 행뿐

-- 한 해 전체를 덮고 빠진 값은 비워두는 한 방법
WITH full_year AS (
    SELECT generate_series AS day       -- 연초부터 연말까지 1일 간격의 범위
    FROM generate_series(
        '2020-01-01'::date,
        '2020-12-31'::date, INTERVAL '1 day')
)
SELECT strftime(full_year.day, '%Y-%m') AS month,
       avg(kWh) FILTER (kWh IS NOT NULL) AS actual
FROM full_year                                  -- 테이블 함수의 출력을 주도 테이블로
LEFT OUTER JOIN v_power_per_day per_day        -- 관심 있는 값을 외부 조인
  ON per_day.day = full_year.day
GROUP BY ALL ORDER BY month;
monthactual
varchardouble
2020-01222.13169014084508
2020-02133.52356321839076
2020-03207.86670454545455
2020-04309.7838888888888
2020-05349.57537634408607
2020-06337.80820512820515
2020-07
2020-08
2020-09
2020-10
2020-11
2020-12
12 rows    2 columns
이제 한 해 전체의 보고서가 되었다. 안타깝게도 2020년 6월 이후의 값이 없다는 사실까지 함께 드러난다. 차트를 만들어야 하는 처지라면 미래의 달들을 어떻게 표현할지 고민하게 되는데, 빈칸이 있는 열두 행은 없는 것을 없다고 말해주는 표다.
FILTER (kWh IS NOT NULL)WHERE가 없다. 목록 4.25는 FILTER (WHERE ...)로 썼다. DuckDB는 두 표기를 모두 받아들이지만, 표준 SQL의 표기는 FILTER (WHERE ...)다. 원서 안에서 두 표기가 섞여 있으니, 이식성을 생각한다면 WHERE를 붙여 쓰는 편이 안전하다.
← 좌우로 밀어 보기 →

이 착상을 한 걸음 더 밀면, 전년 같은 달의 값을 써서 생산량을 예측할 수 있다. 그러려면 v_power_per_day1년의 오프셋을 두고 한 번 더 조인한다.

과거 데이터를 미래로 투사하기 목록 4.32
WITH full_year AS (
    SELECT generate_series AS day
    FROM generate_series(
        '2020-01-01'::date,
        '2020-12-31'::date, INTERVAL '1 day')
)
SELECT strftime(full_year.day, '%Y-%m') AS month,
       round(avg(present.kWh) FILTER (present.kWh IS NOT NULL),3) AS actual,
       round(avg(past.kWh) FILTER (past.kWh IS NOT NULL), 3) AS forecast,
FROM full_year
LEFT OUTER JOIN v_power_per_day present
  ON present.day = full_year.day
LEFT OUTER JOIN v_power_per_day past          -- 생성된 계열의 값에서 1년을 빼서
  ON past.day = full_year.day - INTERVAL '1 year'  -- 두 번째로 조인한다
GROUP BY ALL ORDER BY month;
monthactualforecast
varchardoubledouble
2020-01222.132161.593
2020-02133.524111.073
2020-03207.867150.652
2020-04309.784316.178
2020-05349.575325.369
2020-06337.808351.607
2020-07334.323
2020-08314.929
2020-09289.605
2020-10253.829
2020-11191.384
2020-12164.886
12 rows    3 columns
actualforecast 두 컬럼에 round를 불러 어수선함을 줄였다. kWh 값에 소수 세 자리를 넘는 것은 별 뜻이 없기 때문이다. 그리고 이 변경은 FILTER 절이 함수 호출 안에도 나타날 수 있음을 보여준다. 그것은 컬럼 전체가 아니라 avg 집계에 속하기 때문이다.
결과가 훨씬 보기 좋아졌고, 올해와 지난해의 비교를 사실상 공짜로 얻었다. 같은 뷰를 오프셋만 달리해 두 번 조인한다는 이 착상은 예측이라기엔 소박하지만, SQL 한 문장으로 얻을 수 있는 것치고는 값싸고 정직하다.
← 좌우로 밀어 보기 →
4.10

LATERAL 조인 쓰기Using LATERAL joins

4.3절에서 상관·비상관 서브쿼리를 배웠고, 목록 4.5는 비상관 서브쿼리가 바깥 질의와 한 번 조인될 수 있음을 보여주었다. 성능 관점에서는 그것이 이로울 수 있다. 서브쿼리는 한 번만 평가되고, 그다음 조인이 기억된 값들에 대해 다른 테이블의 행마다 수행되기 때문이다.

그런데 때로는 바깥 질의의 값마다 안쪽 질의를 정확히 평가하고 싶다. 여기서 LATERAL JOIN이 등장한다. 저자들이 준 비유가 명료하다. 바깥 질의를 제어 구조로 삼는 for 루프의 안쪽 블록이라고 생각하면 된다. 배열을 펼치는 일, 데이터를 부채처럼 넓히는 일 따위를 LATERAL로 다룰 수 있다.

예제의 소재가 이 장에서 가장 구체적이다. 태양의 세기, 구체적으로는 그 에너지가 하루의 특정 시각에 내 자리에 얼마나 도달하는가에 관심이 있다고 하자. 과거든 미래든. Open Meteo는 폭넓은 날씨 데이터를 제공하는 무료 API를 내놓고 있고, 그중에 전천 일사량(GHI, global horizontal irradiance)이 있다. 지면과 수평인 표면이 위에서 받는 단파 복사의 총량이며, 태양광 설비에 특히 관심 있는 값이고 W/m² 단위로 측정된다.

GHI를 담은 Open Meteo JSON 응답의 일부 목록 4.33
{
     "latitude": 50.78,
     "longitude": 6.0799994,
     "utc_offset_seconds": 7200,
     "timezone": "Europe/Berlin",
     "timezone_abbreviation": "CEST",
     "elevation": 178.0,
     "hourly_units": {
        "time": "iso8601",
        "shortwave_radiation_instant": "W/m\u00b2"
     },
     "hourly": {
          "time": [
              "2023-08-26T00:00", "2023-08-26T01:00", "2023-08-26T02:00",
              "2023-08-26T03:00", "2023-08-26T04:00", "2023-08-26T05:00"
          ],
          "shortwave_radiation_instant": [
              0.0, 0.0, 0.0, 0.0, 0.0, 9.1
        ]
     }
}
구조를 눈여겨봐야 한다. API는 두 개의 개별 배열을 담은 JSON 객체를 만들어낸다. 하나는 타임스탬프, 다른 하나는 선택한 값이다. 관심 있는 것은 후자의 배열이며, 주어진 사실들에 대해 특정 값을 뽑아내고 싶다. 시각과 값이 같은 행에 나란히 있지 않고 순서로만 대응한다는 점이 이 과제의 어려움이다.

첫눈에는 SQL로 그 배열에서 아침, 정오, 저녁 시각을 골라내는 일이 벅찬 과제처럼 보인다. LATERAL이 이 과제를 어떻게 푸는지 보자. 제1장에서 DuckDB가 JSON을 처리할 수 있다고 읽었고 제5장에서 더 자세히 살펴볼 것이지만, 지금은 FROM 절에서 다른 어떤 테이블처럼 JSON 파일에서 고를 수 있다는 것만 알면 된다.

7일 × 3시각의 색인으로 배열에서 값 뽑기 4.10
INSTALL json;
LOAD json;

WITH days AS (
  SELECT generate_series AS value FROM generate_series(7)
), hours AS (
  SELECT unnest([8, 13, 18]) AS value
), indexes AS (
  SELECT days.value * 24 + hours.value AS i
  FROM days, hours
)
SELECT date_trunc('day', now()) - INTERVAL '7 days' +
          INTERVAL (indexes.i || ' hours') AS ts,   -- 시각 색인에서 날짜를 되살린다
       ghi.v AS 'GHI in W/m^2'
FROM indexes,
LATERAL (
  SELECT hourly.shortwave_radiation_instant[i+1]   -- DuckDB(그리고 SQL 일반)에서 배열은 1부터다
            AS v
  FROM 'code/ch04/ghi_past_and_future.json'          -- 문자열이 JSON 파일임을 자동 감지해
) AS ghi                                             -- 적재하고 파싱한다
ORDER BY ts;
tsGHI in W/m^2
timestamp with time zonedouble
2023-08-26 08:00:00+0236.0
2023-08-26 13:00:00+02490.7
2023-08-26 18:00:00+022.3
2023-08-27 08:00:00+02243.4
2023-08-27 13:00:00+02124.3
··
2023-09-01 13:00:00+02392.0
2023-09-01 18:00:00+020.0
2023-09-02 08:00:00+02451.0
2023-09-02 13:00:00+02265.0
2023-09-02 18:00:00+020.0
24 rows (10 shown)    2 columns
색인은 날짜 번호 × 24 + 하루의 시각이며, 그 색인이 서브쿼리를 구동하는 lateral driver다. 27일 정오의 일사량(124.3)이 아침(243.4)의 절반인 것을 보면 그날 낮에 구름이 들었음을 짐작할 수 있다. 저자들의 소감이 담백하다. 2023년 8월 말 아헨은 이랬다. 태양광에 좋은 달은 아니었다.
본문은 골라낼 시각을 "8시, 13시, 그리고 19시(오후 7시)"라고 적었지만, 코드는 unnest([8, 13, 18])이고 결과의 저녁 시각도 18:00이다. 본문의 19시가 오식이다.
← 좌우로 밀어 보기 →

서브쿼리는 구동하는 바깥 테이블의 행마다 0개, 1개, 또는 더 많은 행을 만들어낼 수 있다. 앞의 예제에서는 바깥 행마다 한 행을 만들었다. 서브쿼리가 더 많은 행을 만들면 바깥 행의 값이 CROSS JOIN과 비슷한 방식으로 되풀이된다. 서브쿼리가 아무 값도 만들지 않으면 조인도 값을 만들지 않는다. 이 경우에는 OUTER JOIN을 적용해야 하며, 그 지점에서는 LATERAL 키워드만으로는 충분하지 않고 온전한 JOIN 문법을 써야 한다.

LATERAL에 OUTER JOIN을 붙이는 문법 4.10
-- 문법을 보여주는 것 말고는 값어치가 거의 없는 인공적인 질의다.
-- 두 질의 모두 1부터 4까지의 계열을 만들되, 바깥은 걸음 1, 안쪽은 걸음 2다.
SELECT i, j
FROM generate_series(1, 4) t(i)
LEFT OUTER JOIN LATERAL (
  SELECT * FROM generate_series(1, 4, 2) t(j)
) sq ON sq.j = i
ORDER BY i;
ij
int64int64
11
2
33
4
조건이 이제 바깥에 놓였고 달리 표현할 수도 없지만, 그럼에도 여전히 상관 서브쿼리다. 이 문장 하나가 LATERAL의 정체를 정확히 짚는다. 문법의 자리가 아니라 평가의 방식이 상관을 만든다.

4.8절의 가격 문제도 서브쿼리와 LATERAL JOIN으로 풀 수 있다. 요컨대 서브쿼리는 판매 시점에 시간적으로 가장 가까운 유효 기간을 가진 가격 행을 돌려주어야 한다. 그것이 되려면 보통의 JOIN을 쓸 수 없다. 서브쿼리가 들어오는 날짜마다 다른 값을 만들어야 하기 때문이다. 따라서 보통이라면 JOIN의 일부였을 날짜 컬럼이 서브쿼리 안으로 옮겨가야 하고, 그럼으로써 조인된 서브쿼리는 상관되거나, 곧 바깥 질의에 lateral하게 조인된다.

목록 4.29의 ASOF JOIN을 LATERAL JOIN과 비교하기 목록 4.34
SELECT power.day, power.kWh,
       prices.value as 'EUR/kWh'
FROM v_power_per_day power,
    LATERAL (                                -- 서브쿼리를 lateral로 표시해 상관을 허용한다
        SELECT *
        FROM prices
        WHERE prices.valid_from <= power.day  -- 부등식으로 상관시킨다
        ORDER BY valid_from DESC limit 1
    ) AS prices
WHERE system_id = 34
ORDER BY day;
ASOF JOIN은 가장 가까운 값을 자동으로 골라주지만, LATERAL을 쓸 때는 값을 우리가 직접 정렬해야 한다. ORDER BY valid_from DESC limit 1이 손으로 쓴 "as of"인 것이다.
컬럼 별칭이 'EUR/kWh'로 적혀 있다. 그런데 prices.value는 목록 4.29에서 'ct/kWh'로 이름 붙였고, 4.5.4절도 이 값이 유로센트 단위라고 밝혔다. 11.47이라는 값 자체가 kWh당 11.47유로일 수 없다. 단위 이름의 오식이다.
저자들의 결론이 실용적이다. DuckDB로 시계열 관련 계산을 한다면 거의 확실히 ASOF JOIN을 쓸 것이다. LATERAL은 이식성을 생각할 때 매력적이며, ASOF JOIN보다 LATERAL을 지원하는 데이터베이스가 아마 더 많다. 데이터셋을 부채처럼 펼쳐 더 많은 행을 만들고 싶은 상황에 LATERAL을 쓴다.
SUMMARY

제4장이 남긴 여덟 문장Summary

  1. SQL 표준은 1992년의 마지막 대규모 개정(SQL-92) 이후 크게 발전했다. DuckDB는 CTE(SQL:1999), 윈도 함수(SQL:2003), 리스트 집계(SQL:2016) 등 현대 SQL을 폭넓게 지원한다.
  2. 그룹화 집합은 여러 그룹에 걸친 집계 계산을 가능하게 해 서로 다른 상세 수준으로 드릴다운하게 해준다. ROLLUPCUBE로 그룹 키의 부분그룹이나 조합을 생성할 수 있다.
  3. DuckDB는 명명 윈도와 범위를 포함해 윈도 함수를 온전히 지원하며, 누적 합계와 순위 같은 쓰임을 가능하게 한다.
  4. 통계 계산과 보간을 포함한 모든 집계 함수가 윈도 맥락에서 쓰이도록 최적화되어 있다.
  5. HAVINGQUALIFY는 집계와 창이 계산된 뒤에 그것을 고르는 데 쓰고, FILTER는 원치 않는 데이터가 집계에 들어가는 것을 막는다.
  6. DuckDB에는 시계열 데이터가 관여하는 쓰임새에 필수적인 ASOF JOIN이 들어 있다.
  7. DuckDB는 데이터를 부채처럼 펼치는 데 도움이 되고 어느 정도 루프를 모방할 수 있는 LATERAL 조인도 지원한다.
  8. 결과는 단순화된 DuckDB 고유의 PIVOT으로든, 더 정적인 표준 SQL 방식으로든 피벗할 수 있다.