DuckDB in Action·Manning, 2024· 제3장 정독
3 EXECUTING SQL QUERIES
원서 22–57쪽

지붕 위 태양광 계량기가 관계 대수를 가르친다

앞의 두 장이 소개와 설치였다면 제3장은 본론이다. 저자들은 미국 에너지부의 태양광 발전 실측 데이터셋 하나를 들고 와서, 테이블을 정의하고, 지저분한 원자료를 밀어 넣고, 그것을 질의하는 순서로 SQL 전체를 훑는다. 만들어낸 데이터로 질의를 꾸미기보다 먼저 스키마를 세우고 채운 다음 묻는 것이 낫다는 것이 저자들의 주장이며, 그 고집 덕에 이 장은 문법 나열이 아니라 한 편의 작업 기록이 되었다.

이 장이 다루는 것
SQL 문의 갈래와 기본 구조 · 실제 데이터셋을 들이기 위한 테이블과 구조 만들기 · 대용량 데이터 상세 분석의 토대 · DuckDB 고유의 SQL 확장
절 구성
3.1 – 3.5 및 하위 절 열한 개, 요약
실린 것
목록 24건 · 도판 4점 · NOTE·TIP 9건 · 사이드바 1건
데이터셋
미국 에너지부 PVDAQ (Photovoltaic Data Acquisition), CC-BY
readings PK FK system_id INTEGER PK read_on TIMESTAMP power DECIMAL(8,3) systems PK id INTEGER name VARCHAR FOREIGN KEY prices PK id INTEGER · nextval() value DECIMAL(5,2) UK valid_from DATE valid_until DATE 키 설계 systems외부에서 생성된 대리 키 pricesSEQUENCE 기반 대리 키 readings시스템 ID + 측정 시각의 복합 자연 키
〈도판 3.1〉에너지 소비 스키마. 조인을 시연하기 좋도록 정규화한 세 테이블이다.
← 좌우로 밀어 보기 →

CLI를 익혔으니 이제 SQL 두뇌를 간지럽힐 차례라고 저자들은 적었다. 이 장의 예제는 모두 CLI로 돌아가지만, 여기 나오는 모든 예제는 파이썬 클라이언트든 자바 JDBC 드라이버든 지원되는 어느 환경에서나 그대로 적용된다. 기본적이고 꼭 필요한 SQL 문을 빠르게 지나간 뒤 더 진전된 질의로 넘어가며, 공통 테이블 표현식과 윈도 함수 같은 복잡한 주제까지 다룬다. 목표는 분명하다. DuckDB로 가능한 최선의 인메모리 온라인 분석 처리(OLAP)를 위한 질의를 짜는 법을 익히는 것이다.

예제를 돌리려면 제2장에서 본 데이터 수집—특히 CSV 파일을 들이는 법과 컬럼 자동 감지·명시 감지를 다루는 법—을 알고 있어야 하고, 제1장의 데이터 타입 지식도 도움이 된다. 질의부터 곧장 보고 싶다면 SELECT 문을 상세히 다루는 절로 건너뛰라고 저자들은 안내한다.

다만 저자들은 그 순서를 권하지 않는다. 생성했거나 존재하지 않는 데이터를 놓고 질의를 지어내기보다, 먼저 테이블과 구조를 정의하고 데이터로 채운 다음 질의하는 편이 낫다고 보기 때문이다. 이 고집이 이 장의 성격을 정한다. 문법 목록이 아니라, 남의 실측 데이터를 받아 스키마를 세우고 흠을 메우고 마침내 답을 얻어내는 한 편의 작업 기록이 되었다.

아는 만큼 보인다는 말은 데이터에도 그대로 적용된다. 스키마를 먼저 세우는 일은 데이터를 어디까지 볼 것인지를 미리 정하는 일이다. 이 장의 편성이 말하는 것
이 장에 실린 목록 24건
  1. 3.1기본 CREATE TABLE 문
  2. 3.2멱등한 문장으로 readings 만들기
  3. 3.3시퀀스 기반 기본 키로 prices 만들기
  4. 3.4시스템·일자별 발전량 뷰
  5. 3.5다른 관계에서 데이터 삽입
  6. 3.6중복을 제거한 행 집합 삽입
  7. 3.7첫 측정값 내려받아 수집
  8. 3.8충돌 시 새 값 계산
  9. 3.9수집된 데이터 정리
  10. 3.10SELECT 문의 구조
  11. 3.11그룹 집계
  12. 3.12내부 조인
  13. 3.13왼쪽 외부 조인
  14. 3.14완전 외부 조인
  15. 3.15중복 키가 있는 테이블의 내부 조인
  16. 3.16서브쿼리를 CTE로 바꾸기
  17. 3.17여러 겹의 그룹 만들기
  18. 3.18재귀 SQL로 그래프 구조 조회
  19. 3.19이름으로 삽입
  20. 3.20WHERE·GROUP BY·HAVING에서 별칭 쓰기
  21. 3.21비집계 컬럼 전체로 그룹 만들기
  22. 3.22SELECT 절 생략과 정렬 단순화
  23. 3.23관계 표본 추출
  24. 3.24이름 붙은 매개변수 쓰기
3.1

SQL 빠른 복습A quick SQL recap

용어의 층위를 먼저 정리한다. 질의(query)는 여러 문(statement)으로 이루어지고, 문은 다시 절(clause)로 이루어진다. 그리고 CLI나 지원되는 다른 클라이언트에 제출된 질의를 명령(command)이라 부른다. DuckDB CLI에서 명령은 세미콜론으로 끝낸다.

공백은 마음대로 써도 된다. 명령을 아름답게 정렬해도 되고 한 줄에 몰아서 타이핑해도 되며, 어느 쪽을 골라도 상관없다. 그리고 SQL은 키워드와 식별자 모두에 대해 대소문자를 구분하지 않는다. 이 관대함이 SQL을 40년 넘게 살아남게 한 미덕 가운데 하나다.

대부분의 문은 동작을 바꾸는 여러 절을 지원한다. 그중 가장 두드러진 셋이 다음과 같다.

  • WHERE — 최종 결과에 어떤 행을 포함할지에 대한 조건을 붙인다.
  • GROUP BY — 하나 이상의 키로 정의되는 바구니에 여러 값을 집계해 담는다.
  • ORDER BY — 돌려받을 결과의 순서를 지정한다.

이제 이 문들과 절들을 실제 사례로 시연한다. 소재는 태양광 발전이다. 개념마다 구체적인 세부를 붙여, 독자 자신의 작업에 어떻게 적용되는지 감을 갖고 떠나게 하는 것이 이 예제의 목적이다.

3.2

에너지 생산량 분석Analyzing energy production

에너지 소비와 생산은 이미 오래전부터 OLAP 분석의 대상이었다. 15분 간격으로 소비를 측정하는 스마트 계량기는 금속 가공이나 대형 생산 공장 같은 여러 산업에서 진작부터 쓰여 이제 상당히 표준이 되었다. 이 측정값으로 소비 에너지의 값을 매기고, 소비를 예측하고, 그 밖의 일을 한다.

스마트 모니터링 시스템이 퍼지면서 이제 개인 가정에서도 상세한 에너지 검침값을 얻을 수 있게 되었고, 해마다 더 흔해지고 있다. 집에 태양광 발전 설비와 스마트 계량기를 설치했다고 상상해보자. 대기업이 하듯 전기 사용을 얼마간 계획하고 설비의 투자 회수를 예측하고 싶어진다. 그러려면 본격적인 시계열 데이터베이스와 실시간 대시보드까지 갖출 필요는 없다. DuckDB와 이 장의 예제가 자신에게 쓸모 있는 보고서를 만드는 좋은 출발점이 되기를 저자들은 바란다. 이 대목이 이 책 전체의 태도를 압축한다. 큰 장비를 세우지 않고도 할 수 있는 일이 생각보다 많다는 것이다.

출처

미국 에너지부가 PVDAQ(Photovoltaic Data Acquisition)라는 이름으로 공개한 데이터셋이다. 문서는 깃허브에 정리되어 있다.

접근

에너지부 산하 국립재생에너지연구소(NREL)가 분할된 CSV·Parquet 파일을 받아갈 수 있는 간단하고 깔끔한 API를 제공한다. 무료이며 요구하는 개인 정보도 적다.

라이선스

Creative Commons Attribution으로 공개되어 있다. 이 장에서는 접근 편의를 위해 데이터셋의 일부를 이 책의 소스와 함께 원본 그대로 재배포한다.

NOTE — 왜 15분 간격인가

요즘 센서는 훨씬 촘촘한 측정값을 만들어내는데 왜 15분 간격으로 저장하는가. 15분이라는 주기는 앞서 말한 목적—값 매기기, 유리한 구간에 사서 쓰기—에는 충분히 맞고, 동시에 웬만한 현대 관계형 시스템이 어렵지 않게 다룰 만큼 작다는 것이 답이다.

출력이나 소비 전력은 와트(W) 또는 킬로와트(kW) 단위로 측정하고, 보통 킬로와트시(kWh)로 판매한다. 15분 간격은 W에서 kWh로 손쉽게 환산할 수 있으면서도 좋은 발전량 차트를 그리기에 충분히 정확하다.

대부분의 경우 값을 적어도 시간 단위로 매끄럽게 다듬는 편이 좋다. 구름 때문에 생기는 봉우리와 골은 대개 무의미하기 때문이다. 일기 예보 차트를 참고하면서 일별 측정값을 쓰면 일반적으로 좋은 기준 구간이 된다. 주말과 공휴일이 반영되고 자잘한 불규칙이 다듬어지기 때문이다.

데이터셋 내려받기 3.2.1

CSV 파일을 거치지 않고 데이터를 적재하기 위해 DuckDB의 httpfs 확장을 쓴다. CLI에서 install httpfs; load httpfs;를 실행해 설치한다. 다룰 데이터 파일은 두 종류다.

  • systems.csv — PVDAQ가 측정하는 모든 태양광 시스템의 목록이다.
  • 10번·34번·1200번 시스템의 2019년과 2020년 측정값 — URL은 모두 같은 형태를 따르며, system_idyear 질의 문자열 매개변수를 바꿔가며 받는다. 접근하려면 API 키가 필요하고, 여기서는 DEMO_KEY를 쓴다.
데이터 확보 경로 세 가지 3.2.1
# ① NREL API에서 직접 — API 키, 시스템 ID, 연도를 질의 문자열로 넘긴다.
https://developer.nrel.gov/api/pvdaq/v3/data_file?api_key=DEMO_KEY&system_id=34&year=2019

# ② URL 접근이 어려우면 책 소스의 데이터베이스 익스포트를 새 DB로 들여온다.
$ duckdb my_ch03.db
D import database 'ch03_db';

# ③ MotherDuck의 원격 데이터베이스에 붙는다. 읽기 전용 공유본이다.
D ATTACH 'md:_share/duckdb_in_action_ch3_4/d0c08584-1d33-491c-8db7-cf9c6910eceb';
③번 공유본은 읽기 전용이지만 저자들이 쓴 데이터 전부를 담고 있어, 삽입 같은 조작을 다루지 않는 예제는 모두 따라갈 수 있다. MotherDuck이 제공하는 서비스는 뒤의 장에서 따로 자세히 다룬다.

이 데이터셋을 고른 이유가 분명하다. 영역이 파악하기 쉬우면서도 여러 분석 개념을 도입할 만큼 복잡하고, 실제 현장의 필요가 뒷받침되어 있기 때문이다. 그리고 어떤 분석 과정에서나 그렇듯, 결국 일관되지 않은 데이터를 만나게 된다. 이 데이터셋의 일부 계열도 그렇다. 저자들이 이 사실을 미리 밝혀두는 것은 뒤에 나올 CASE 표현식과 DELETE 문의 예고이기도 하다.

목표 스키마 3.2.2

DuckDB는 관계형 데이터베이스 관리 시스템(RDBMS)이다. 곧 관계(relation)에 저장된 데이터를 관리하는 시스템이라는 뜻이며, 관계란 본질적으로 테이블을 가리키는 수학 용어다. 각 테이블은 이름이 붙은 행의 모음이다. 한 테이블의 각 행은 이름이 붙은 같은 컬럼 집합을 가지고, 각 컬럼은 특정 데이터 타입을 갖는다. 테이블 자체는 스키마 안에 저장되며, 스키마의 모음이 접근할 수 있는 데이터베이스 전체를 이룬다.

NOTE — 대리 키란 무엇인가

테이블의 행을 지목하려면 값이 유일한 컬럼, 또는 모든 행에 걸쳐 유일한 컬럼의 조합이 필요하다. 그런 컬럼을 보통 기본 키(primary key)라 부른다. 그런데 데이터베이스에 저장할 만한 데이터가 모두 유일한 속성을 갖고 있는 것은 아니다. 예컨대 사람의 이름을 유일 키 또는 기본 키로 쓰는 것은 형편없는 선택이다.

그런 경우 스키마 설계자들은 단조 증가하는 시퀀스에 기반한 숫자 컬럼이나, 범용 유일 식별자(UUID)를 담는 컬럼을 대리 키(surrogate key)로 도입한다.

이 데이터셋의 스키마는 몇 개의 테이블로 이루어진다(〈도판 3.1〉). 지원되는 조인을 쉽게 시연할 수 있도록 정규화해두었다. 다룰 세 테이블은 다음과 같다.

  • systems — 발전량을 읽어 들이는 대상 시스템들을 담는다.
  • readings — 그 시스템들에서 실제로 측정한 값을 담는다.
  • prices — 에너지를 판매하는 가격을 담는다. 예제의 가격은 킬로와트시당 유로센트(cents/kWh)로 측정하지만, 킬로와트시당 어떤 단위를 써도 무관하다.

키 설계의 세 갈래를 한 문단으로 정리하면 이렇다. systems 테이블은 CSV 집합에 정의된 ID를 그대로 쓰며, 이를 외부에서 생성된 대리 키로 취급한다. prices 테이블은 SEQUENCE를 쓴다. readings 테이블은 복합 자연 키—측정된 시스템의 ID에 측정 시각을 붙인 것—를 쓴다. 같은 스키마 안에서 세 가지 키 전략을 나란히 보여주는 설계이니, 교재로서는 잘 짜인 배치다.

3.3

데이터 정의어 질의Data definition language queries

테이블을 담은 스키마를 먼저 만들지 않고도 여러 원천에 질의할 수 있다는 것은 이미 보았다. 그러나 DuckDB는 온전한 RDBMS이며, 데이터셋을 수집하기 전에 목표 스키마를 만들기 위해 데이터 정의어(DDL) 질의를 쓴다. 새 테이블은 CREATE TABLE 문으로 만들고, 기존 테이블은 ALTER TABLE 문으로 바꾼다. 테이블이 더 필요하지 않으면 DROP TABLE을 쓴다.

NOTE

DuckDB는 데이터 정의어 절 전체를 지원하지만, 이 장에서는 간결함을 위해 그 일부만 쓴다. 지원되는 모든 절은 문장 관련 공식 문서를 확인해야 한다.

CREATE TABLE 문 3.3.1

감시할 시스템을 담을 테이블부터 만든다. 만들 테이블의 이름과 컬럼 목록은 반드시 지정해야 하고, 문장 전체에 붙는 수정자 같은 나머지 옵션은 선택이다. 컬럼 목록은 컬럼의 이름 다음에 타입, 그리고 선택적인 컬럼 제약을 붙여 정의한다.

기본 CREATE TABLE 문 목록 3.1
CREATE TABLE IF NOT EXISTS systems (
    id          INTEGER PRIMARY KEY,
    name        VARCHAR(128) NOT NULL
);
IF NOT EXISTS는 명령 전체를 멱등하게 만드는 선택 절이다. 테이블이 이미 있어도 실패하지 않는다. PRIMARY KEY는 이 컬럼을 필수 컬럼으로 만들며 기본 키, 따라서 유일 키의 구실을 하게 한다. 인덱스도 함께 추가된다. NOT NULL은 컬럼을 필수로 만든다. 리터럴 NULL 값은 넣을 수 없다.

여기서, 그리고 이 테이블을 토대로 삼는 이후의 예제에서 저자들은 여러 제약을 활용한다. 기본 키와 유일 키, 그리고 외래 키다. CREATE 문의 선택지를 시연하기 위해서만이 아니라, 데이터 정합성에 마음을 쓰기 때문이라고 밝혀두었다.

다만 대가가 있다. DuckDB에서도—거의 모든 다른 데이터베이스와 마찬가지로—제약은 대량의 데이터를 적재할 때 성능에 대체로 부정적으로 작용한다. 그 제약을 뒷받침하는 인덱스를 다시 만들거나 갱신해야 하고, 규칙도 검사해야 하기 때문이다. 정합성 검사가 필요하지 않다면 그 제약들을 생략하라는 것이 저자들의 조언이다. 원칙과 실용의 저울을 감추지 않고 보여주는 문장이다.

NOTE

DuckDB에는 CREATE OR REPLACE TABLE 문도 있다. 기존 테이블을 삭제하고 새 정의로 교체한다. 그러나 저자들은 IF NOT EXISTS 절을 선호한다. 조건 없이 테이블을 삭제하는 것보다 안전하다고 보기 때문이며, 삭제하면 그 안에 있었을 데이터가 그대로 사라지기 때문이다.

readings 테이블의 정의는 모양이 조금 다르다. 복합 기본 키를 쓴다. systems 테이블을 가리키는 참조 컬럼 system_id와, 값이 측정된 날짜와 시각을 담은 타임스탬프 컬럼으로 이루어진 키다. 이런 기본 키 제약은 컬럼 하나에 직접 붙일 수 없고 컬럼 목록 바깥으로 나간다.

멱등한 문장으로 readings 테이블 만들기 목록 3.2
CREATE TABLE IF NOT EXISTS readings (
    system_id   INTEGER NOT NULL,
    read_on     TIMESTAMP NOT NULL,
    power       DECIMAL(10,3) NOT NULL
            DEFAULT 0 CHECK(power >= 0),
    PRIMARY KEY (system_id, read_on),
    FOREIGN KEY (system_id)
            REFERENCES systems(id)
);
데이터 품질을 지키기 위해 여러 절이 쓰였다. 전력 측정값에는 기본값 0이 가정되고, 추가로 컬럼 CHECK 제약을 두어 음수 값이 들어오지 못하게 막는다. 복합 기본 키와 외래 키 제약은 모두 테이블 제약이므로 컬럼 정의 뒤에 놓인다.
이 목록은 powerDECIMAL(10,3)으로 선언하지만, 〈도판 3.1〉의 스키마 그림과 뒤에 나오는 DESCRIBE readings 출력은 모두 DECIMAL(8,3)으로 적혀 있다. 원서 안에서 어긋난 자리이며, 따라 해볼 때는 둘 중 하나로 통일해두는 편이 뒤가 편하다.

마지막은 prices 테이블이다. 이 스크립트에는 실은 명령이 둘 들어 있다. 증가하는 숫자 값을 대리 기본 키로 쓸 것이기 때문이다. nextval() 함수 호출을 담은 DEFAULT 선언으로 이를 구현한다. 이 함수는 시퀀스의 이름을 입력으로 받는다. 시퀀스는 테이블 정의 바깥에 데이터베이스에 저장되는 숫자 값이며, CREATE SEQUENCE로 만든다.

시퀀스 기반 기본 키로 prices 테이블 만들기 목록 3.3
CREATE SEQUENCE IF NOT EXISTS prices_id
    INCREMENT BY 1 MINVALUE 10;

CREATE TABLE IF NOT EXISTS prices (
    id          INTEGER PRIMARY KEY
                  DEFAULT(nextval('prices_id')),
    value       DECIMAL(5,2) NOT NULL,
    valid_from  DATE NOT NULL,
    CONSTRAINT prices_uk UNIQUE (valid_from)
);
10에서 시작해 단조 증가하는 시퀀스를 만들고, 그것을 id 컬럼의 기본값으로 쓴다. 마지막 줄은 valid_from 컬럼에 유일 테이블 제약을 더한다.

그런데 왜 valid_from을 기본 키로 쓰지 않았는가. 저자들의 설명이 이 절에서 가장 읽어둘 만한 대목이다. 초기 응용에서는 판매 가격만 다루겠지만, 앞으로는 구매 가격도 다루게 될 수 있다. 그것을 모델링하는 방법은 여럿이다. 테이블을 하나 더 두거나, prices 테이블에 어떤 값이 판매가인지 구매가인지 명시하는 유형 컬럼을 도입하는 식이다. 그런데 valid_from을 기본 키로 쓰면 유형이 다른 두 가격이 같은 날짜부터 유효할 수 없게 된다. 그러면 단순 기본 키를 복합 키로 바꿔야 한다. 다른 데이터베이스는 기본 키와 유일 키를 삭제하고 다시 만드는 것을 허용할지 모르지만 DuckDB는 허용하지 않으므로, 이 경우 더 큰 이관 작업을 거쳐야 한다.

게다가 기본 키의 값을 갱신하는 일은 그 자체로 비싸다. 인덱스 관점에서만이 아니라 조직적 관점에서도 그렇다. 예컨대 해당 컬럼이 이미 외래 키의 참조 컬럼으로 쓰이고 있는 경우가 그렇다. 모든 제약은 인덱스로 뒷받침되며, 값이 바뀌면 그 인덱스를 재편해야 하는 일이 잦고, 그것은 느리고 비쌀 수 있다. 한 트랜잭션에서 여러 테이블을 갱신하는 것은 흔한 오류의 원천이며, 자주 불일치로 이어진다. 그 위험은 readings 테이블에는 없다. 타임스탬프 컬럼을 기본 키로 삼았는데, 측정값은 본질적으로 불변이기 때문이다.

TIP

데이터베이스에 있는 기존 시퀀스는 SELECT sequence_name FROM duckdb_sequences();로 살펴본다.

※ 원서의 이 대목은 FROMFRIN으로 잘못 인쇄되어 있다. 그대로 붙여 넣으면 파서 오류가 난다.

ALTER TABLE 문 3.3.2

스키마를 정의하는 일은 복잡한 과제이고, 조직들은 보통 여기에 많은 공을 들인다. 그러나 스키마가 모든 만일의 사태를 아우르고 처음부터 완전히 옳은 경우는 좀처럼 만나지 못한다. 요구는 늘 변한다. 예컨대 가격의 유효 기간을 담아야 한다는 요구가 생기면 컬럼이 하나 더 필요해진다. 그런 경우 ALTER TABLE 문을 쓴다.

컬럼 추가와 CTAS 3.3.2
-- DDL 관련 문장 상당수가 IF NOT EXISTS 절을 지원한다.
-- 기존 스키마를 다룰 때 오류를 덜 내게 해준다.
ALTER TABLE prices
ADD COLUMN IF NOT EXISTS valid_until DATE;

-- CTAS(Create table as select): 테이블의 모양과 내용을 한 번에 복제한다.
CREATE TABLE prices_duplicate AS
SELECT * FROM prices;
ALTER TABLE로는 컬럼을 DROP·RENAME할 수 있고 테이블 이름도 RENAME할 수 있다. 기본값 같은 일부 컬럼 옵션은 바꿀 수 있지만, 제약을 추가·삭제·변경하는 것은 집필 시점에 지원되지 않는다. 그렇게 하려면 테이블을 다시 만들어야 한다.
CTAS에 LIMIT 0 절을 더하면 데이터 없이 테이블의 스키마만 복사하고, 조건을 담은 WHERE 절을 더하면 모양과 함께 일부 데이터까지 복사한다.

CREATE VIEW 문 3.3.3

CREATE VIEW 문은 질의에 대한 뷰를 정의한다. 본질적으로 그 질의를 표현하는 문장을 모든 조건과 변환을 포함해 저장하는 것이다. 질의될 때 뷰는 다른 어떤 테이블이나 관계와 똑같이 동작하며, 추가 조건과 변환을 얹을 수 있다.

어떤 데이터베이스는 뷰를 실체화(materialize)하고 어떤 데이터베이스는 하지 않는다. DuckDB는 뷰를 질의하면 그 밑에 깔린 문장을 실행한다. 성능 문제에 부딪히면 CTAS 문으로 뷰의 데이터를 임시 테이블에 실체화하는 것을 고려할 만하다. 그리고 뷰를 질의할 때 WHERE 절에 쓰는 추가 술어는 흔히 푸시다운 술어로 활용된다. 그 술어가 뷰를 정의하는 밑단 질의에 더해지며, 데이터를 적재한 뒤에 걸러내는 필터로 쓰이지 않는다는 뜻이다. 뷰가 게으른 껍데기가 아니라 최적화의 통로가 된다는 점을 짚어둘 만하다.

이 시나리오에서 요긴한 뷰는 시스템별·일별로 생산된 에너지를 kWh로 알려주는 뷰다. 이 뷰가 그 값을 계산하는 논리를 필요한 그룹화 문장과 함께 감싸준다. 저자들은 뷰의 값어치를 이렇게 정리한다. 뷰는 데이터베이스 안에 API를 만드는 훌륭한 방법이다. 그 API는 즉석 질의와 응용 프로그램을 모두 상대할 수 있고, 밑단 계산이 바뀌어도 같은 구조로 뷰를 다시 만들면 바깥의 어떤 응용에도 영향을 주지 않는다.

GROUP BY 절은 관계형 세계에서 없이 지내기 어려운 절 가운데 하나이며, 왜 그렇게 중요한지는 이 장 뒤에서 살펴본다. 이 예제에서는 GROUP BY 절이 시스템과 일자별로 생산된 총 전력을 계산한다는 것만 이해하면 된다. 선택 목록에 쓰인 sum 함수는 이른바 집계 함수로, 한 그룹에 속한 값들을 집계한다.

시스템·일자별 발전량 뷰 만들기 목록 3.4
CREATE OR REPLACE VIEW v_power_per_day AS
SELECT system_id,
       date_trunc('day', read_on)        AS day,
       round(sum(power) / 4 / 1000, 2)  AS kWh,
FROM readings
GROUP BY system_id, day;
15분 간격의 와트 값을 4로 나누어 시간 단위로 환산하고, 1000으로 나누어 킬로와트로 바꾼다. 뷰가 만들어지는 데는 밑단 테이블이 비어 있어도 상관없다. 존재하기만 하면 된다. readings 테이블은 만들었지만 아직 데이터를 넣지 않았으므로 지금 질의하면 빈 결과가 돌아온다.
AS kWh, 뒤의 쉼표가 눈에 걸린다. 오타처럼 보이지만 오타가 아니다. DuckDB는 선택 목록 마지막의 후행 쉼표를 허용한다. 컬럼을 덧붙이고 지우는 편집을 편하게 해주는 배려이며, 원서가 별말 없이 이 문법을 써 보인 자리다.

DESCRIBE 문 3.3.4

거의 보편적으로, 관계형 데이터베이스는 데이터베이스 스키마를 질의하기 위해 DESCRIBE 문을 지원한다. 가장 기본적인 구현에서는 보통 테이블과 뷰에 대해 동작한다. 그런데 DuckDB의 DESCRIBE관계인 모든 것에 대해 동작한다. 뷰, 질의, 집합, 그 밖의 것까지 모두 해당한다.

DESCRIBE는 관계인 모든 것에 통한다 3.3.4
-- ① 테이블
D DESCRIBE readings;
column_namecolumn_typenullkeydefaultextra
varcharvarcharvarcharvarcharvarcharint32
system_idINTEGERNOPRI
read_onTIMESTAMPNOPRI
powerDECIMAL(8,3)NO0
-- ② 임의의 테이블에서 골라낸 컬럼 부분집합, 곧 새 튜플
D DESCRIBE SELECT read_on, power FROM readings;

-- ③ 즉석에서 구성한 튜플
D DESCRIBE VALUES (4711, '2023-05-28 11:00'::timestamp, 42);
column_namecolumn_typenullkeydefaultextra
varcharvarcharvarcharvarcharvarcharvarchar
col0INTEGERYES
col1TIMESTAMPYES
col2INTEGERYES
②의 결과에서는 null 컬럼이 모두 YES로 바뀌고 key가 비어 있다. 질의의 결과 튜플은 원 테이블의 제약을 물려받지 않는다. ③은 테이블도, 파일도 없이 값의 나열만으로 관계를 만들어 그 모양을 물은 것이다. 이름 없는 컬럼에는 col0, col1, col2가 붙는다.
← 좌우로 밀어 보기 →
TIP — 관계형 모델의 뿌리

관계형 데이터베이스는 관계형 모델, 그리고 결국 관계 대수에 기반한다. 관계형 모델은 1970년 에드거 F. 코드(Edgar F. Codd)가 처음 서술했다. 요컨대 모든 데이터는 관계로 묶인 튜플의 집합으로 저장된다. 튜플은 속성의 순서 있는 목록이며, 테이블의 컬럼 목록이라고 생각하면 된다. 그러면 테이블은 튜플 집합의 관계다. 뷰도 튜플의 관계이고, 질의의 결과도 그렇다.

그래프 데이터베이스는 관계형 데이터베이스와 달리 개체 사이의 실제 관계를 저장한다. 이 책에서는 관계라는 말을 관계형 모델에서 정의된 뜻으로 쓴다.

TIP — 모양이 의심스러울 때는

데이터의 모양이 확실하지 않은 모든 상황에서 DESCRIBE 문을 쓴다. 로컬 파일이든 원격 파일이든 온갖 종류의 관계에 통한다. 다만 쓰는 파일의 종류가 DuckDB의 최적화 효율을 좌우한다. 예컨대 Parquet 형식의 원격 파일은 아주 빠르게 서술되지만, CSV 형식의 파일은 스키마를 함께 지니고 있지 않아 엔진이 내용을 표집해야 하므로 서술에 더 오래 걸리는 일이 잦다.

3.4

데이터 조작어 질의Data manipulation language queries

데이터베이스의 맥락에서 데이터를 삽입하고 삭제하고 수정하고 읽는 모든 문장을 데이터 조작어(DML)라 부른다. 읽는 것까지 조작에 넣는다는 점이 처음에는 낯설지만, 관계를 새 관계로 변환하는 일이 곧 조작이라고 보면 자연스럽다. 이 절은 데이터 질의로 들어가기 전에 INSERTDELETE 문을 먼저 다룬다. UPDATE 문은 여기서 자세히 파지 않는다.

그럴 수 있는 이유가 있다. SQL 질의의 아름다움은 그것들이 아주 자연스럽게 조합된다는 데 있다. 예컨대 WHERE 절에 대해 배운 모든 것은 INSERT, DELETE, UPDATE, SELECT 문에서 쓰이는 그 절에도 그대로 적용된다.

INSERT 문 3.4.1

데이터를 만들 때는 INSERT 문을 쓴다. 데이터 삽입은 단순한 "쏘고 잊는" 문장에서부터 충돌을 완화하고 높은 데이터 품질을 보장하는 복잡한 문장까지 폭이 넓다. 저자들은 단순하고 순진한 쪽에서 출발한다. INSERT 문은 어디에 넣을지를 먼저 지정하고 무엇을 넣을지를 그다음에 지정한다. 어디는 테이블 이름이고, 무엇은 컬럼 값의 목록일 수 있지만 테이블의 컬럼 타입과 순서에 맞아야 한다.

순진한 삽입과 그 대가 3.4.1
-- 숫자 둘과 문자열 둘. 뒤의 둘은 자동으로 DATE로 형 변환된다.
D INSERT INTO prices
  VALUES (1, 11.59, '2018-12-01', '2019-01-01');

-- 같은 문장을 한 번 더 실행하면, 마땅히 실패한다.
D INSERT INTO prices
>   VALUES (1, 11.59, '2018-12-01', '2019-01-01');
Error: Constraint Error: Duplicate key "id: 1" violates primary key
constraint. If this is an unexpected constraint violation please
double check with the known index limitations section in our
documentation (https://duckdb.org/docs/sql/indexes).
이 질의는 두 가지로 취약하다. 첫째, 컬럼의 순서에 의존하므로 대상 테이블이 바뀌는 순간 문장이 깨진다. 둘째, 1을 유일 키로 명시했으므로 두 번째 실행은 기본 키가 유일해야 한다는 제약을 위반한다.

스키마가 주어진 이상 충돌 자체를 막을 수는 없었지만, 표준이 아닌 ON CONFLICT 절을 써서 아무것도 하지 않게 완화할 수 있다. DO NOTHING 절은 기본적으로 기본 인덱스—여기서는 id 컬럼—를 대상으로 삼는다. 여전히 취약하지만 최소한 이제 이 문장은 멱등해진다.

다만 저자들은 여기에 정직한 경고를 붙인다. 이 경우 멱등성은 생각만큼 유용하지 않을 수 있다. 오류는 나지 않지만, 기대한 결과도 얻지 못할 가능성이 크기 때문이다. 오류를 삼키는 것이 문제 해결과 같지 않다는 이 지적은 새겨둘 만하다. 더 나은 해법은 넣고 싶은 컬럼을 모두 명시하고 ID에 명시적 값을 쓰지 않는 것이다. 이미 시퀀스와 기본값을 정의해두었으니 ID는 알아서 생성된다.

멱등성에서 병합까지 3.4.1
-- ① 충돌을 무시한다. 멱등하지만 여전히 취약하다.
INSERT INTO prices
VALUES (1, 11.59, '2018-12-01', '2019-01-01')
ON CONFLICT DO NOTHING;

-- ② 컬럼을 명시하고 ID는 시퀀스에 맡긴다. 이것이 나은 해법이다.
INSERT INTO prices(value, valid_from, valid_until)
VALUES (11.47, '2019-01-01', '2019-02-01'),
       (11.35, '2019-02-01', '2019-03-01'),
       (11.23, '2019-03-01', '2019-04-01'),
       (11.11, '2019-04-01', '2019-05-01'),
       (10.95, '2019-05-01', '2019-06-01');

-- ③ 유효 시작일의 유일 키에서 충돌하면 값을 갱신한다.
INSERT INTO prices(value, valid_from, valid_until)
VALUES (11.47, '2019-01-01', '2019-02-01')
ON CONFLICT (valid_from)
  DO UPDATE SET value = excluded.value;
③처럼 테이블에 제약이 여럿(기본 키와 유일 키)일 때는 어느 키에서 충돌 완화가 일어날지 반드시 명시해야 한다. 유효 기간 날짜에 유일 키를 걸어둔 덕에, 오류를 삼키는 대신 업무적으로 의미 있는 방식으로 반응할 수 있게 되었다.

물론 SELECT 문의 결과를 INSERT 문의 입력으로 쓸 수도 있다. 이 문장을 INSERT 절로 들어가는 파이프라인이라고 생각하면 된다.

다른 관계에서 데이터 삽입 목록 3.5
INSERT INTO prices(value, valid_from, valid_until)
SELECT * FROM 'prices.csv' src;
prices.csv의 모든 데이터를 골라 나타난 순서대로 삽입한다. 파일은 이 책 깃허브 저장소의 ch03 폴더에 있다.

이제 systems 테이블을 채우고 첫 측정값 묶음을 적재한다. INSERT 문을 제대로 쓰려면 CSV 데이터가 어떻게 생겼는지부터 알아야 한다. 여기서 앞 절의 교훈—DESCRIBE는 관계인 모든 것에 통한다—이 그대로 쓰인다. 이 경우 그 관계는 CSV 파일을 읽는 것으로 정의된다.

systems.csv의 모양 살피기 3.4.1
D INSTALL 'httpfs';
D LOAD 'httpfs';
D DESCRIBE SELECT * FROM
      'https://oedi-data-lake.s3.amazonaws.com/pvdaq/csv/systems.csv';
column_namecolumn_typenullkeydefaultextra
varcharvarcharvarcharvarcharvarcharvarchar
system_idBIGINTYES
system_public_nameVARCHARYES
site_idBIGINTYES
site_public_nameVARCHARYES
site_locationVARCHARYES
site_latitudeDOUBLEYES
site_longitudeDOUBLEYES
site_elevationDOUBLEYES
타입 힌트를 하나도 주지 않았을 때 DuckDB의 눈에 systems.csv는 이렇게 보인다. 여덟 컬럼 중 system_idsystem_public_name만 쓰면 충분하다.
← 좌우로 밀어 보기 →

그런데 이 파일에는 중복이 있어서 삽입이 실패한다. 중복을 걸러내는 가장 쉬운 방법은 SELECT 문의 컬럼 절에 DISTINCT 키워드를 적용하는 것이다. 이렇게 하면 선택한 모든 컬럼에 걸쳐 유일한 집합이 보장된다.

중복을 제거한 행 집합을 다른 테이블에서 삽입 목록 3.6
INSTALL 'httpfs';
LOAD 'httpfs';

INSERT INTO systems(id, name)
SELECT DISTINCT system_id, system_public_name
FROM 'https://oedi-data-lake.s3.amazonaws.com/pvdaq/csv/systems.csv'
ORDER BY system_id ASC;

3.2.1절에서 시스템을 고른 데에는 이유가 있었다. 34번 시스템의 데이터셋으로 시작하는 것은 그것이 15분 간격 측정이라는 요건에 맞기 때문이다. 다만 다뤄야 할 흠이 있다. 출력 전력이 때때로 NULL이거나 음수다. 빠진 값을 0으로 대체하기 위해 CASE 표현식을 쓴다. 그리고 이 URL은 .csv.parquet처럼 낯익은 확장자를 쓰지 않아 어떤 종류의 파일이나 구조가 뒤에 있는지 DuckDB에게 분명하게 알려주지 못한다. 그래서 read_csv_auto 함수를 써야 한다. 제2장의 짧은 링크 문제가 여기서 실전으로 되돌아온다.

첫 측정값 묶음 내려받아 수집하기 목록 3.7
INSERT INTO readings(system_id, read_on, power)
SELECT SiteId, "Date-Time",
        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=34&year=2019'
     );

-- 들어온 데이터를 하루치만 훑어본다.
D SELECT * FROM readings
  WHERE date_trunc('day', read_on) = '2019-08-26' AND power <> 0;
system_idread_onpower
int32timestampdecimal(10,3)
342019-08-26 05:30:001700.000
342019-08-26 05:45:003900.000
342019-08-26 06:00:008300.000
···
342019-08-26 17:30:005200.000
342019-08-26 17:45:002200.000
342019-08-26 18:00:00600.000
51 rows (6 shown)    3 columns
새벽 5시 30분에 1.7kW로 깨어나 정오를 지나 저녁 6시에 0.6kW로 잦아드는 하루가 표 한 장에 담긴다. 측정값이 들어왔으니 목록 3.4의 뷰 v_power_per_day도 이제 값을 돌려준다.
system_iddaykWh
int32datedouble
342019-08-26716.9
하루 716.9kWh. 뷰가 날짜를 일 단위로 잘라내고 그날의 측정값 총합을 집계하는 논리를 감싸준 결과다. 뷰는 이런 논리를 감싸두는 훌륭한 수단이라는 앞 절의 주장이 여기서 실물로 확인된다.
← 좌우로 밀어 보기 →

2020년치 질의는 URL 매개변수만 빼면 사실상 같다. 그렇다면 인라인 테이블 구실을 하는 range 함수로 파일 이름 목록을 생성하면 되지 않을까. 저자들도 그 생각을 해보고, 되지 않는다고 솔직하게 적었다.

이론적으로는 맞지만 아직 되지 않는 질의 3.4.1
SELECT *
FROM (
    SELECT 'https://' || years.range || '.csv' AS v
    FROM range(2019,2021) years
) urls, read_csv_auto(urls.v);
이유가 둘이다. 첫째, 이른바 테이블 함수의 구현 제약 때문에 집필 시점에는 상수 매개변수만 받는다. 둘째, read_csvread_parquet는 입력 매개변수를 보고 주어진 파일을 읽어서 스키마를 알아내므로, 닭과 달걀의 문제가 남는다. 스키마를 알려면 파일을 읽어야 하고, 파일 이름은 질의가 돌아야 정해진다.

데이터 병합 3.4.2

중복이 있거나 이미 데이터베이스에 있는 항목을 담은 데이터셋을 마주하는 일이 잦다. 새 데이터를 다듬고 정리하는 것이 유일한 목적이라면 충돌을 무시해도 되지만, 때로는 새 데이터를 기존 데이터에 병합하고 싶어진다. 이 목적을 위해 DuckDB는 ON CONFLICT DO UPDATE 절을 제공한다. 다른 데이터베이스에서는 MERGE INTO로 알려진 그것이다.

예제의 상황은 이렇다. 같은 시스템에 대해 서로 다른 계량기에서 온 측정값이 여럿 있을 수 있고, 그 평균 측정값을 계산하고 싶다. 충돌 시 아무것도 하지 않는 대신 DO UPDATE를 쓴다.

충돌 시 새 값 계산하기 목록 3.8
INSERT INTO readings(system_id, read_on, power)
  VALUES (10, '2023-06-05 13:00:00', 4000);

INSERT INTO readings(system_id, read_on, power)
  VALUES (10, '2023-06-05 13:00:00', 3000)
ON CONFLICT(system_id, read_on) DO UPDATE
SET power = CASE
  WHEN power = 0 THEN excluded.power
  ELSE (power + excluded.power) / 2 END;
두 번째 시도는 기본 키가 아니라 system_id와 read_on의 복합 키에서 충돌한다. 갱신 절은 필요한 만큼 여러 컬럼을 갱신할 수 있어 사실상 병합(merge/upsert)이 되며, CASE 문 같은 복잡한 표현식도 허용된다. 원래 데이터셋의 컬럼은 별칭 excluded로 참조한다.
결과는 3500이 된다. 4000과 3000의 평균이다. 충돌을 오류로 볼 것인가 정보로 볼 것인가. 이 절의 요지는 후자를 택할 문법이 준비되어 있다는 것이다.
NOTE

DuckDB는 INSERT OR REPLACEINSERT OR IGNORE를 각각 ON CONFLICT DO UPDATEON CONFLICT DO NOTHING의 짧은 대안으로 제공한다. 다만 INSERT OR REPLACE는 앞의 예제처럼 기존 값을 결합하는 능력이 없고, 충돌 대상을 정의할 수도 없다.

DELETE 문 3.4.3

쓰고 있는 데이터 원천에는 이상치가 몇 개 있다. 시각의 서로 다른 분(minute)에 측정된 측정값 무리를 들여왔는데, 그것들을 데이터셋에 두고 싶지 않다. 가장 쉬운 처리는 DELETE 문을 적용해 없애버리는 것이다.

수집된 데이터 정리하기 목록 3.9
DELETE FROM readings
WHERE date_part('minute', read_on) NOT IN (0,15,30,45);
부정된 IN 연산자로 삭제할 행을 걸러낸다. 이 연산자는 왼쪽 표현식이 오른쪽 표현식 집합에 들어 있는지를 검사한다. date_part는 날짜와 타임스탬프를 다루는 DuckDB의 여러 내장 함수 중 하나로, 타임스탬프에서 한 부분—여기서는 분—을 뽑아낸다.
저자들은 여기에 한마디를 덧붙인다. 이런 별스러움과 불일치는 미리 알고 있으면 수집한 뒤에 처리할 필요가 없다. 예제처럼 시간 기반 데이터라면 수집 문장에서 time_bucket 함수를 활용해 쓸 수도 있었다. 가져온 뒤에야 알아차렸고, 그 사실을 짚어둘 만하다고 생각했다는 고백이 미덥다.
3.4.4

SELECT 문The SELECT statement

이 절은 SELECT 문과, 수집한 데이터를 질의하는 일에 집중한다. 이 문장은 데이터베이스에서 데이터를 행으로 가져오거나, 중첩해 쓰이면 덧없는(ephemeral) 관계를 만든다. 그 관계는 다시 질의할 수도 있고, 앞에서 본 것처럼 데이터를 삽입하는 데 쓸 수도 있다.

SELECT 문의 구조 목록 3.10
SELECT   select_list
FROM     tables
WHERE    condition
GROUP BY groups
HAVING   group_filter
ORDER BY order_expr
LIMIT    n
절의 본질적 목록과 그 정통 순서다. 표준과 DuckDB 고유 방언 양쪽에 절이 더 있고, 그중 몇은 다음 장에서 다룬다. 저자들이 가장 중요하다고 꼽는 것은 JOIN과 함께 쓰이는 FROM, WHERE, GROUP BY 셋이다. 질의의 원천을 정의하고, 읽는 질의와 쓰는 질의를 모두 걸러내며, 종국에 그것들의 모양을 다시 잡는 절들이기 때문이다. ORDER처럼 이름 그대로 순서를 세우는 나머지 절들은 이해하기가 훨씬 쉽다.

SELECT 절과 FROM 절

데이터를 읽는 모든 표준 SQL 문은 SELECT 절로 시작한다. SELECT 절은 종국에 행으로 돌아올 컬럼이나 표현식을 정의한다. 원천 테이블에서 모든 것을 가져오려면 *를 쓴다.

NOTE

SELECT 절은 때로 사영(projection)이라 불린다. 어떤 컬럼을 돌려줄지 고르는 일이기 때문이다. 역설적이게도 행의 선택(selection)은 WHERE 절에서 일어난다.

두 절은 서로를 보완하므로 어느 쪽을 먼저 설명해도 좋고 함께 설명해도 좋다. FROM 절은 나머지 질의가 작동할 데이터의 원천을 지정하며, 대부분의 질의에서 그것은 하나 이상의 테이블이다. FROM 절에 테이블이 둘 이상 나열되거나 추가로 JOIN 절이 쓰이면, 그것을 테이블을 서로 조인한다고 말한다.

여기서 LIMIT 절이 등장한다. 돌려받을 행의 수를 제한한다. 밑에 깔린 데이터셋을 모를 때는 돌려받는 데이터의 양을 제한하는 것이 흔히 현명하다. 대량의 네트워크 트래픽을 일으키거나 클라이언트가 응답하지 않는 상태에 빠지는 일을 피할 수 있다.

두 행만 보기 3.4.4
D SELECT *
  FROM prices
  LIMIT 2;
idvaluevalid_fromvalid_until
int32decimal(5,2)datedate
111.592018-12-012019-01-01
1011.472019-01-012019-02-01
ORDER 절이 없으면 순서는 실제로 정의되지 않으며 각자의 인스턴스에서 다를 수 있다. id가 1에서 곧바로 10으로 건너뛰는 것은 시퀀스를 MINVALUE 10으로 시작했기 때문이다. 그리고 DuckDB의 방언은 이 코드를 FROM prices; 한 줄로 줄이게 해준다.

WHERE 절

WHERE 절은 질의에 조건을 붙여 데이터를 걸러낸다. 그 조건은 하나 이상의 표현식으로 짜인다. SELECT, DELETE, UPDATE 문으로 선택한 데이터는 그 술어에 맞아야 연산에 포함된다. 논리적으로 WHERE 절은 FROM 절 바로 뒤, 또는 앞서는 DELETE·UPDATE 문 바로 뒤에 적용된다.

임의의 LIMIT을 제대로 된 조건으로 바꾸기 3.4.4
-- 스타 셀렉트의 경우 SELECT *를 생략하고 FROM 절로 시작할 수 있다. DuckDB의 확장이다.
D FROM prices
  WHERE valid_from BETWEEN
    '2020-01-01' AND '2020-12-31';
idvaluevalid_fromvalid_until
int32decimal(5,2)datedate
158.602020-11-012023-01-01
178.642020-10-012020-11-01
····
259.722020-02-012020-03-01
269.872020-01-012020-02-01
11 rows (4 shown)    4 columns
BETWEEN 키워드는 x <= v AND v <= y의 줄임이다. 양 끝을 포함한다는 점을 늘 기억해두는 편이 좋다.
원서 본문은 이 질의가 "특정 연도(2000)의 가격만" 포함한다고 적었으나, 질의와 결과는 모두 2020년의 것이다. 인쇄상의 오식이다.
← 좌우로 밀어 보기 →

GROUP BY 절

하나 이상의 컬럼으로 그룹을 만들면 그 컬럼들의 유일한 값마다 출력 행이 하나 생긴다. 그 필드들이 일치하는 모든 행을 함께 묶는 것이다. 이어서 묶인 값들이 count, sum, avg, min, max 같은 집계 함수로 집계되어, 그 그룹에 대한 단 하나의 값이 만들어진다. 하루 평균 측정 횟수나 주별 고객 합계 같은 것을 계산할 때 쓸모가 있다.

한 가지 짚어둘 규칙이 있다. GROUP BY 절이 지정되면, 선택 목록에 집계가 하나도 없어도 그 질의는 언제나 집계 질의다. 그리고 DuckDB에는 집계 함수의 일부가 아닌 모든 컬럼으로 그룹을 만들어주는 편리한 확장 GROUP BY ALL이 있다.

SOURCE ROWS idyearvalue 10201911.47 11201911.35 12201911.23 13201911.11 2720199.97 2019 33201910.79 그룹 1 · 2019 · 12행 1520208.60 1720208.64 1820208.77 1920208.90 2020 2520209.72 2620209.87 그룹 2 · 2020 · 11행 집계 적용 그룹마다 적용한 min() · max() yearminimum_pricemaximum_price 20199.9711.47 20208.609.87 그룹마다 적용한 count() · avg() yearnum_pricesavg_price 20191210.715 2020119.183636363636364 그룹의 유일한 값마다 출력 행 하나. 스물세 행이 두 행으로 접힌다. 2020년 평균값의 자릿수가 그대로 드러난 것은 double 연산의 결과다.
〈도판 3.2〉데이터셋을 연도로 묶기. 같은 그룹에 네 가지 집계를 적용한 결과를 나란히 놓았다.
← 좌우로 밀어 보기 →

고를 수 있는 집계 함수는 많다. 앞서 다룬 비교적 표준적인 것들에 더해, 저자들이 자주 요긴하다고 꼽는 것들은 다음과 같다.

  • list — 각 그룹의 모든 값을 리스트 구조로 집계한다.
  • any_value — 그룹화에 쓰이지 않은 컬럼에서 아무 값이나 하나 고른다.
  • first, last — 결과가 정렬된 경우 그룹화에 쓰이지 않은 컬럼에서 첫 값이나 마지막 값을 고른다.
  • arg_max, arg_min — 어떤 표현식이 최대 또는 최소 값을 갖는 행에서 그 표현식의 값을 찾는, 흔한 과제를 해결한다.
  • bit_and, bit_or, bit_xor 등 — 집합에 대해 동작하는 비트 연산이다.
  • median, 분위수 계산, 공분산 계산, 일반 회귀 — 통계 집계의 빠짐없는 집합이다.

이제 가격 예제로 돌아간다. 앞에서는 WHERE 절로 한 해의 가격을 찾았다. 그것도 흥미롭지만, 연도별 최저·최고 가격을 알아내는 것은 어떤가.

그룹 집계 목록 3.11
SELECT date_part('year', valid_from) AS year,
       min(value) AS minimum_price,
       max(value) AS maximum_price
FROM prices
WHERE year BETWEEN 2019 AND 2020
GROUP BY year
ORDER BY year;
valid_from은 날짜인데 관심 있는 것은 연도뿐이다. date_part 함수가 그것을 뽑아낸다. 별칭 없이 쓰면 결과 컬럼의 이름이 date_part('year', valid_from)이 되어 읽기도 나쁘고 참조하기도 번거롭다. 그래서 AS 키워드로 별칭 year를 도입한다.
여기서 DuckDB의 관대함이 드러난다. 선택 목록에서 준 별칭을 WHERE 절과 GROUP BY 절에서 다시 쓸 수 있다. 이것은 SQL 표준과 다르며, 대단히 요긴하다. 표준을 따르는 다른 데이터베이스에서는 컬럼 정의를 그대로 되풀이해 적어야 한다.
TIP — 날짜 부분을 다루는 두 갈래

DuckDB는 날짜 부분을 다루는 선택지를 준다. 앞에서처럼 범용 date_part 함수를 쓰고 부분을 매개변수로 지정할 수 있다. 'day', 'hour', 'minute' 등 관련된 모든 부분에 식별자가 있다. 그리고 그 전부가 전용 함수로도 존재하므로, 목록 3.11에서는 year(valid_from)이라고 써도 되었다.

어느 쪽을 고르는가. 범용 함수는 부분이 문장 안의 다른 표현식에서 파생될 때, 또는 이식성 있는 SQL을 쓰려 할 때 유용하다. 전용 함수는 읽기가 더 쉽다.

VALUES 절

VALUES 절은 정해진 개수의 행을 지정하는 데 쓴다. 데이터를 삽입할 때 이미 보았으며, 그것이 상당히 흔한 사용처다. 그러나 DuckDB에서는 다른 몇몇 데이터베이스보다 훨씬 다재다능하다. 독립된 문장으로도, FROM 절의 일부로도, 행과 컬럼의 개수에 제한 없이 쓸 수 있다. 조건에 넣을 씨앗 데이터를 마련하는 것 같은 몇 가지 상황에서 요긴하다.

괄호 하나가 바꾸는 것 3.4.4
-- ① 두 컬럼을 가진 한 행
D VALUES (1,2);
-- ② 여러 튜플을 그냥 나열하면 여러 행이 된다. 괄호로 더 감쌀 필요가 없다.
D VALUES (1,2), (3,4);
-- ③ 그런데 감싸면, 구조화 타입을 담은 컬럼 둘의 한 행이 된다.
D VALUES ((1,2), (3,4));
col0col1
struct(v1 integer, v2 integer)struct(v1 integer, v2 integer)
{'v1': 1, 'v2': 2}{'v1': 3, 'v2': 4}
-- FROM 절에서 쓰면 결과 타입과 컬럼에 이름을 붙일 수 있다. 테이블 이름 t는 임의로 고른 것이다.
D SELECT *
  FROM (VALUES
      (1, 'Row 1', now()),
      (2, 'Row 2', now())
  ) t(id, name, arbitrary_column_name);
idnamearbitrary_column_name
int32varchartimestamp with time zone
1Row 12023-06-02 13:44:30.309+02
2Row 22023-06-02 13:44:30.309+02
괄호 한 겹 차이로 두 행구조체 두 개를 담은 한 행으로 바뀐다. 이렇게 만든 이름 붙은 인라인 테이블은 다음 절에서 조인 논리를 설명하는 데 그대로 쓰인다.
← 좌우로 밀어 보기 →
3.4.4 · JOIN

조인 절The JOIN clause

단일 Parquet 파일이나 CSV 파일을 분석할 때는 JOIN 절 없이도 넘어갈 수 있다. 그러나 이 절은 건너뛰지 말라고 저자들은 당부한다. 조인은 두 테이블 또는 관계를 잇는 데 쓰이는 관계형 연산의 근본이다. 두 관계를 조인의 왼쪽과 오른쪽이라 부르며, 먼저 나열된 테이블이 왼쪽이다. 이 연결은 그전까지 이어져 있지 않던 정보를 결합한 새 관계를 표상하며, 그로써 새로운 통찰을 준다.

본질적으로 조인은 양쪽에서 짝이 맞는 행의 쌍을 만든다. 짝짓기는 보통 왼쪽 테이블의 키 컬럼이 오른쪽 테이블의 컬럼과 같은지에 기반한다. 테이블을 조인하기 위해 외래 키 제약이 필요하지는 않다. 저자들은 JOIN .. ON보다 SQL 표준 정의인 JOIN .. USING에 기반한 조인을 선호하며, 이 책 나머지에서도 그렇게 쓴다. 그렇다고 그것만 되는 것은 아니다. FROM 절에 테이블을 그냥 나열하고 WHERE 절에서 키 컬럼을 비교하는 것으로도 조인을 표현할 수 있다.

NOTE — 왜 벤 다이어그램을 쓰지 않는가

조인을 설명하는 데 벤 다이어그램을 쓰지 않는 이유는, 조인 연산이 순수한 집합 연산이 아니기 때문이다. 순수한 집합 연산이라면 벤 다이어그램이 훌륭한 선택이었을 것이다. SQL에도 집합 연산은 있다. UNION, INTERSECT, EXCEPT가 그것이고 DuckDB는 전부 지원한다.

반면 조인은 모두 관계 대수의 데카르트 곱에 기반한다. 쉽게 말해 모든 것을 다른 모든 것과 붙여놓고 나서 걸러내는 것이다. 본질적으로 서로 다른 모든 조인은 CROSS JOIN에서 파생될 수 있다. 내부 조인은 거기에 어떤 조건으로 필터를 걸고, 왼쪽 또는 오른쪽 외부 조인은 합집합을 더한다. 조인에서 집합 기반 연산이라 할 것은 그것이 전부다.

아래는 그 설명을 손으로 확인해보는 장치다. 왼쪽과 오른쪽 관계는 목록 3.12–3.14가 쓴 값을 그대로 옮겼다. 조인 종류를 갈아 끼우면 결과 행이 어떻게 달라지는지, 그리고 NULL이 어느 자리에 채워지는지 눈으로 좇을 수 있다.

왼쪽 관계  l(id, nameA)
오른쪽 관계  r(id, nameB)
결과
← 좌우로 밀어 보기 →

두 관계에 이름이 같은 컬럼이 있으면 USING 절로 그것을 지정할 수 있다. USING 절은 지정한 컬럼을 두 관계에서 찾아내며, ON 절로 직접 ON tab1.col = tab2.col이라 쓰는 것과 똑같이 동작한다. 목록 3.12–3.14의 세 문장은 JOIN, LEFT OUTER JOIN, FULL OUTER JOIN만 바뀌고 나머지는 같다.

외부 조인은 지정한 쪽의 행 가운데 상대에 짝이 없는 것에 대해 NULL 값을 보충해준다. 데이터베이스에 발전 시스템이 여럿 있다고 생각해보자. 어떤 것에는 다른 테이블에 공급업체 정보를 저장해두었고 어떤 것에는 없다. 모든 시스템의 목록을 공급업체가 있으면 그것과 함께, 없으면 빈 컬럼과 함께 내놓아야 하는 과제라면 외부 조인을 쓴다.

왼쪽 외부 조인과 오른쪽 외부 조인은 모두 세 행을 돌려준다. 네 행을 되찾으려면 완전 외부 조인을 써야 한다. 그렇다고 언제나 외부 조인을 쓰라고 권하는 것은 잘못이다. 외부 조인은 내부 조인이 줄 행을 언제나 포함하지만, 내부 조인은 다른 테이블에 짝이 없는 행을 걸러내며 그것이 요구 사항인 경우가 잦다. 외부 조인은 보통 반드시 있어야 하는 데이터를 선택적인 데이터로 살찌우고 싶을 때 적절하다. 두 조인의 용도를 이보다 간결하게 정리하기 어렵다.

예제의 스키마에서는 사정이 조금 다르다. systems 테이블에는 id 컬럼을 정의했고 readings의 외래 키 컬럼은 system_id이니, 이름이 다르므로 ON 절을 써야 한다. 그 컬럼으로 조인하면 조인은 언제나 짝이 맞는 행을 만들어낸다. 조인 컬럼(id)이 system_id에 정의한 외래 키가 참조하는 컬럼이기 때문이다. 곧 systems에 대응 항목이 없는 readings 행은 존재할 수 없다.

이름이 다른 키로 조인하기 3.4.4
SELECT name, count(*) as number_of_readings
FROM readings JOIN systems ON id = system_id
GROUP BY name;
NOTE — 데카르트 곱

데카르트 곱은 두 원소 집합에서 첫 집합의 각 원소를 두 번째 집합의 각 원소와 결합해 만들 수 있는 모든 순서쌍의 목록을 서술하는 수학 용어다. 데카르트 곱의 크기는 각 집합의 크기의 곱과 같다.

조인 종류는 더 있다. 모든 튜플의 데카르트 곱을 만드는 CROSS JOIN이 있고, 유효 기간이 제한된 가격을 다룰 때 요긴해지는 ASOF(as of) 조인이 있다. ASOF 조인은 시간적 유효성—사실은 <=, <, >, >= 같은 부등 조건을 갖는 무엇이든—에 기반해 한 테이블의 행을 다른 테이블의 행과 맞춰준다. 자세한 내용은 다음 장에서 다룬다.

SIDEBAR

COPY TO 명령

CSV 파일을 중심으로 데이터 파이프라인을 짓다 보면, 파일마다 공통 컬럼 하나씩을 가진 채 데이터가 여러 파일에 흩어져 있는 일이 잦다. 이 파일들을 공통 컬럼을 중복하지 않고 정확히 한 개 파일로 줄이고 싶다면, 내부 조인과 COPY TO 명령으로 쉽게 이룰 수 있다. 후자는 어떤 관계든 받아서 지정한 형식으로 파일에 복사한다.

네 개의 CSV를 공통 컬럼으로 접어 하나로 사이드바
$ duckdb -c "COPY (SELECT * FROM 'production.csv'
        JOIN 'consumption.csv' USING (ts)
        JOIN 'export.csv'      USING (ts)
        JOIN 'import.csv'      USING (ts) )
     TO '/dev/stdout' (HEADER)"
공유 컬럼 ts로 네 CSV 파일을 조인하고, SELECT *에서 공유 컬럼은 한 벌만 남기고, 결과를 표준 출력으로 복사한다. USING을 쓰면 조인 컬럼이 하나로 접히는 성질이 여기서 그대로 활용된다.

경고 — 키가 중복될 때

저자들은 이 절을 경고에 가까운 이야기로 맺는다. 내부 조인과 외부 조인의 예제에서는 키 컬럼의 값이 다른 테이블에서 발견되지 않을 때 무슨 일이 생기는지만 논했다. 그러면 조인 컬럼 가운데 하나가 같은 값을 여러 번 담고 있을 때는, 그것도 한쪽에서 또는 양쪽에서 그럴 때는 어떻게 되는가. 위의 탐색기에서 〈중복 키 데이터로 바꾸기〉를 눌러보면 직접 확인할 수 있다. 왼쪽에서 id가 2인 행이 둘, 오른쪽에서 3인 행이 둘이다.

원서 본문은 이 결과가 "앞서와 달리 네 행이 아니라 여섯 행"이 된다고 적었지만, 곧이어 실린 출력은 다섯 행이다. 곱을 따져보면 id 1에서 1×1, id 2에서 2×1, id 3에서 1×2이니 합이 다섯이다. 출력이 옳고 본문이 오식이다.

이것은 스키마를 정의할 때 대비할 수 있는 일이다. 통상 조인은 미리 알고 있는 컬럼에서 일어난다. 예제라면 systems 테이블의 idreadingssystem_id 쌍이 그렇다. systems 테이블에서 그 컬럼은 기본 키로 정의되어 있으므로 언제나 유일한 값이며, 따라서 그 테이블에 한 번만 나타날 수 있다. readings 테이블에서는 외래 키로 정의되어 있으므로 다른 쪽에 반드시 존재해야 한다. 외래 키는 보통 데이터베이스에 이른바 인덱스를 만들며, 모든 행을 훑지 않고 빠르게 조회할 수 있게 해 조인의 성능을 좋게 한다.

그리고 외래 키는 유일하지 않으며 대부분의 모델에서 유일할 필요도 없다. 예제에서 시스템이 readings 테이블—오른쪽—에 여러 번 나타나는 것은 당연히 기대되는 바다. 발전 설비가 평생 한 번만 전기를 만들기를 바라는 것이 아니라면 말이다. 마지막 한 문장의 능청스러움이 이 책의 성격을 잘 보여준다.

3.4.4 · WITH

공통 테이블 표현식The WITH clause

WITH 절은 공통 테이블 표현식(CTE)이라는 이름으로도 알려져 있다. CTE는 본질적으로 특정 질의 안으로 범위가 제한된 뷰다. 뷰처럼, 질의 논리의 일부를 독립된 문장으로, 적어도 더 큰 질의 안의 격리된 부분으로 감싸두고 싶을 때 쓴다. 뷰를 만들어도 전혀 문제는 없지만, 그 결과가 더 큰 질의의 특정 문맥에서만 필요하다면 뷰까지 만들고 싶지는 않을 것이다.

게다가 CTE에는 뷰에 없는 특성이 하나 있다. 뷰는 다른 뷰를 참조할 수 있지만 중첩될 수는 없다. CTE는 같은 WITH 절에 정의된 다른 CTE를 참조할 수 있다. 그로써 질의 논리를 점진적으로 쌓아 올릴 수 있다.

WITH 절은 FROM 절에 서브쿼리를 정의하는 반패턴을 막아준다. FROM 절의 원천 관계로 놓인 서브쿼리는 구문적으로도 의미적으로도 타당하다. 그 결과가 그 자체로 관계이기 때문이다. 그러나 읽기가 어려운 일이 잦다. 게다가 중첩된 서브쿼리는 자기 자신을 참조할 수 없다.

서브쿼리를 CTE로 바꾸기 목록 3.16
-- 전: FROM 절 안의 서브쿼리
SELECT max_power.v, read_on
FROM (
  SELECT max(power) AS v FROM readings
) max_power
JOIN readings ON power = max_power.v;

-- 후: 같은 질의를 WITH 절로 끌어올리고 이름을 준다. JOIN 문은 그대로다.
WITH max_power AS (
  SELECT max(power) AS v FROM readings
)
SELECT max_power.v, read_on
FROM max_power
JOIN readings ON power = max_power.v;
이 정도로 단순한 질의 하나라면 서브쿼리를 쓰든 CTE를 쓰든 큰 차이가 없다. 차이는 질문이 복잡해지는 순간 벌어진다.

그렇다면 시스템별·시간별 평균 발전량의 최대치를 묻는다면 어떤가. maxavg 같은 집계 함수는 중첩할 수 없다. 곧 avg(max(v))라고 쓸 수 없으므로 집계를 따로따로 써야 한다. 여기서 CTE가 참으로 필요해진다.

그 전에 함수 둘을 짚어둔다. 어떤 컬럼의 최소·최대 값을 담은 행이 무엇인지 묻는 일은 아주 흔한 과제이므로, DuckDB에는 그것을 수행하는 내장 함수 arg_maxarg_min이 있다. 두 번째 매개변수의 최소 또는 최대 값이 처음 나타나는 행에서, 첫 번째 매개변수로 정의된 표현식을 계산해준다.

arg_max — 한 행만 돌려주는 이유 3.4.4
SELECT max(power), arg_max(read_on, power) AS read_on
FROM readings;
이 질의는 최대 발전량이 나온 데이터셋의 한 행을 만들어낸다. 목록 3.16의 질의가 돌려주는 다섯 행이 아니다. arg_max는 최대값과 일치하는 첫 값에서 멈추지만, 조인은 해당하는 모든 행을 포함하기 때문이다.

이제 두 겹의 집계다. 먼저 시스템과 시간별 평균 발전량으로 측정값을 묶는 복잡한 논리—첫 번째 집계—를 per_hour라 이름 붙인 CTE에 감싸고, 그 CTE를 받아 두 번째 집계를 계산한다.

여러 겹의 그룹 만들기 목록 3.17
WITH per_hour AS (
    SELECT system_id,
           date_trunc('hour', read_on) AS read_on,
           avg(power) / 1000 AS kWh
    FROM readings
    GROUP BY ALL
)
SELECT name,
       max(kWh),
       arg_max(read_on, kWh) AS 'Read on'
FROM per_hour
   JOIN systems s ON s.id = per_hour.system_id
WHERE system_id = 34
GROUP by s.name;
namemax(kWh)Read on
varchardoubletimestamp
[34] Andre Agassi Preparatory Academy123.752020-04-09 11:00:00
CTE에 제대로 된 이름을 주고, 안쪽에서 시간별 평균이라는 첫 집계를 만들고, 바깥에서 찾던 중첩 집계를 계산하고, CTE를 FROM 절의 주도 테이블로 쓴다. GROUP BY ALL은 집계에 속하지 않은 모든 컬럼으로 그룹을 만드는 DuckDB의 확장이다.
← 좌우로 밀어 보기 →
NOTE — 숫자가 실제와 맞는지 확인해보았다

저자들은 이 건물을 직접 찾아보았고, 측정값과 수치가 맞아떨어졌다고 적었다. 설비를 다룬 자료에 따르면 "2010년 4월과 2011년 7월 사이에 Bombard가 라스베이거스 아가시 아카데미의 다섯 개 건물 옥상과 세 개의 태양광 지지 구조물에 2,249장의 Sharp 240와트 태양광 모듈을 설치했다"고 한다.

질의가 뱉은 숫자를 현실의 설비 명세와 대조해보는 이 짧은 확인 절차가, 이 장을 단순한 문법 교재에서 한 걸음 위로 올려놓는다.

RECURSIVE — 뷰도 서브쿼리도 못 하는 일

CTE는 뷰와 서브쿼리가 하지 못하는 멋진 일을 하나 더 할 수 있다. WITH 절에는 RECURSIVE라는 추가 키워드가 있어서, CTE를 뒤따르는 다른 CTE와 FROM 절에서만이 아니라 자기 자신 안에서도 참조할 수 있게 해준다. 재귀 CTE를 작동시키려면 재귀의 초기 씨앗이 있어야 한다. 트리 구조라면 쉽다. 부모 행이 없는 행을 가져와 UNION 절의 한쪽 잎으로 삼으면 된다.

재귀 SQL로 그래프 모양 구조 조회하기 목록 3.18
CREATE TABLE IF NOT EXISTS src (
    id INT PRIMARY KEY,
    parent_id INT, name VARCHAR(8)
);

INSERT INTO src (VALUES
    (1, null, 'root1'),
    (2,    1, 'ch1a'),
    (3,    1, 'ch2a'),
    (4,    3, 'ch3a'),
    (5, null, 'root2'),
    (6,    5, 'ch1b')
);

WITH RECURSIVE tree AS (
    SELECT id,
           id AS root_id,          -- 리스트 리터럴로 새 목록을 초기화한다.
           [name] AS path            -- 여기까지가 재귀의 초기 씨앗이다.
    FROM src WHERE parent_id IS NULL

    UNION ALL

    SELECT src.id,
           root_id,
           list_append(tree.path, src.name) AS path
    FROM src
      JOIN tree ON (src.parent_id = tree.id)
)                                -- 주어진 부모 id를 가진 src 항목이 더 없을 때까지 재귀 조인한다.
SELECT path FROM tree;
path
varchar[]
[root1]
[root2]
[root1, ch1a]
[root1, ch2a]
[root2, ch1b]
[root1, ch2a, ch3a]
뿌리에서 출발해 해당하는 잎까지 올라가는 여러 갈래의 경로가 결과로 나온다. 예제는 list_append로 이름을 경로에 쌓아 올린다. list_prepend를 쓰고 매개변수를 뒤집으면 잎에서 뿌리로 향하는 경로를 만들 수 있다.
저자들이 남긴 연습 과제. 트리에서 가장 긴 경로를 계산해보라. 재귀 CTE는 그대로 두고, SELECT 문에서 배운 arg_max 함수를 리스트에 대한 length 집계와 함께 쓰면 된다.
← 좌우로 밀어 보기 →
3.5

DuckDB 고유의 SQL 확장DuckDB-specific SQL extensions

DuckDB를 만든 이들의 목표 가운데 하나는 SQL을 더 접근하기 쉽고 사람에게 친절하게 만드는 것이다. 그 방법의 하나로, 흔한 과제를 쉽게 처리하게 해주는 추가 문법을 자신들의 SQL 구현에 더해왔다. 이 절이 그 추가분을 소개한다.

SELECT를 다루는 법 3.5.1

SELECT *는 양날의 검이다. 적어내기 쉽고, 결과 튜플에는 실제로 필요한 것이 웬만하면 다 들어 있다. 관계의 모든 컬럼을 선택하는 데 딸린 문제로 저자들은 넷을 든다.

  • 결과 튜플의 불안정성 — 테이블 정의가 바뀔 수 있다. 컬럼이 더해지거나 없어지면 결과의 모양이 함께 바뀐다.
  • 메모리 압박 — 데이터베이스 서버나 프로세스에 더 큰 부담을 준다.
  • 네트워크 트래픽 — DuckDB는 임베디드 데이터베이스라 네트워크가 개입하지 않지만, 임베디드가 아닌 데이터베이스에서는 스타 셀렉트가 트래픽을 늘린다.
  • 인덱스 온리 스캔의 무산 — 인덱스 온리 스캔은 질의가 인덱스를 쓸 수 있고 그 인덱스에 있는 컬럼만 돌려주어 다른 입출력을 모두 피할 수 있을 때 일어난다. 대부분의 경우 바람직한 거동인데, 스타 셀렉트는 그것을 막아버릴 수 있다.

그러니 SELECT * 질의를 너무 많이 하지 않는 것이 좋지만, 때로는 필요하다. 그래서 DuckDB는 키워드 둘을 더해 그것을 더 안전하게 쓸 수 있게 했다. EXCLUDEREPLACE다. 그리고 정말로 모든 컬럼을 원하는 것이 확실하다면, SELECT 절을 아예 생략하고 FROM 절로 시작하는 단순화된 형태를 쓸 수 있다.

EXCLUDE와 REPLACE 3.5.1
-- 예전 방식: 필요한 컬럼을 모두 열거한다. 컬럼이 몇 개를 넘어가면 금세 고되고 오류가 잦아진다.
SELECT value, valid_from, valid_until FROM prices;

-- EXCLUDE: 관심 없는 컬럼만 열거하면 된다. 원하는 만큼 제외할 수 있다.
SELECT * EXCLUDE (id)
FROM prices;

-- REPLACE: 뷰 전체를 고쳐 쓰지 않고, kWh 컬럼만 반올림한 값으로 갈아 끼운다.
SELECT * REPLACE (round(kWh)::int AS kWh)
FROM v_power_per_day;
system_iddaykWh
int32dateint32
12002019-08-29289
···
102020-03-190
1587 rows (2 shown)    3 columns
EXCLUDE는 순수한 SELECT *의 유연함을 거의 그대로 누리면서 별의 가독성을 지키고, 필요 없는 것에 손대지 않게 보장해준다. REPLACEx AS y 꼴의 짝을 하나 이상 받는다. x는 원래 선택 목록의 컬럼을 참조해 함수와 변환을 적용할 수 있는 표현식이고, y는 원래 선택 목록에 쓰인 이름이다. 결과의 구조는 그대로이고 kWh 컬럼만 정수 컬럼이 되었다.
← 좌우로 밀어 보기 →

COLUMNS — 컬럼을 동적으로 사영하고 걸러내기

prices 테이블에는 가격의 유효성에 관한 정보를 담은 컬럼이 둘 있다. COLUMNS 표현식은 정규식에 기반해 하나 이상의 컬럼을 사영하고, 걸러내고, 집계하는 데 쓸 수 있다.

COLUMNS 표현식의 네 가지 쓰임 3.5.1
-- ① 유효성에 관한 컬럼만 사영한다.
D SELECT COLUMNS('valid.*') FROM prices LIMIT 3;

-- ② 동적으로 고른 컬럼들에 함수를 한꺼번에 적용해, 그만큼의 계산 컬럼을 얻는다.
D SELECT max(COLUMNS('valid.*')) FROM prices;
max(prices.valid_from)max(prices.valid_until)
datedate
2023-01-012024-02-01
-- ③ AND로 길게 이어붙인 조건을 한 줄로 줄인다. 두 컬럼이 모두 범위 안에 들어야 한다.
D FROM prices WHERE COLUMNS('valid.*') BETWEEN '2020-01-01' AND '2021-01-01';

-- ④ 정규식이 낯설면 람다 함수로도 쓴다. LIKE의 % 와일드카드를 그대로 활용할 수 있다.
D FROM prices
  WHERE COLUMNS(col -> col LIKE 'valid%')
  BETWEEN '2020-01-01' AND '2021-01-01';

-- 그리고 EXCLUDE·REPLACE와도 결합한다. 생성된 id를 뺀 모든 컬럼의 최대값.
D SELECT max(COLUMNS(* EXCLUDE id)) FROM prices;
이름이 비슷한 컬럼이 많은 테이블이라면 이 기법의 이득이 크다. 측정값이나 계측 테이블이 그런 형태이며, 측정마다 여러 가지 검침값을 만들어내는 IoT 센서를 떠올려보면 쓸모가 곧 보인다.
많은 사람에게 .*보다 익숙한 것은 LIKE 연산자와 함께 쓰는 %_ 와일드카드다. %는 문자 0개, 1개 또는 여러 개를 뜻하고 밑줄은 정확히 한 개를 뜻한다. 다행히 COLUMNS는 람다 함수를 지원한다.
← 좌우로 밀어 보기 →
TIP — 람다 함수

람다 함수는 이리저리 넘겨가며 코드에서 쓸 수 있는, 자체로 완결된 기능 덩어리다. 프로그래밍 언어에 따라 이름이 다르다. 자바·코틀린·파이썬에서는 람다 표현식, 스위프트에서는 클로저, C에서는 블록이라 부른다.

이름으로 삽입 3.5.2

목록 3.6을 기억할 것이다. 거기서는 INSERT INTO target(col1, col2) SELECT a, b FROM src 꼴의 문장으로 systems 테이블을 채웠다. 작동은 하지만 유지하기에 취약하다. INSERT 문은 선택된 컬럼이 대상 테이블에 정의된 것과 같은 순서이기를 요구하거나, 컬럼 이름을 두 번—INTO 절에 한 번, 선택 목록에 한 번—되풀이하기를 요구하기 때문이다.

DuckDB는 이 문제를 풀기 위해 BY NAME 절을 제공한다. 원천의 컬럼 이름에서 대상의 컬럼 이름으로 가는 대응 관계를 한자리에 모아둘 수 있다.

이름으로 삽입하기 목록 3.19
INSERT INTO systems BY NAME
SELECT DISTINCT
       system_id AS id,
       system_public_name AS NAME
FROM 'https://oedi-data-lake.s3.amazonaws.com/pvdaq/csv/systems.csv'
ON CONFLICT DO NOTHING;
BY NAME 키워드는 뒤따르는 선택 절의 컬럼을 대상 테이블의 컬럼에 이름으로 맞추라는 뜻이다. 삽입에 컬럼을 더하든 빼든 이제 질의 한 곳만 고치면 된다. 다만 널 금지 컬럼 같은 제약은 여전히 충족해야 한다.

어디서나 별칭에 접근하기 3.5.3

알아채지 못했을 수도 있지만, 앞의 여러 예제는 표준이어야 마땅한데 표준이 아닌 어떤 성질의 혜택을 이미 받고 있었다. 컬럼에 별칭을 도입하는 순간, 뒤따르는 절들에서 그 별칭에 접근할 수 있다는 성질이다.

WHERE·GROUP BY·HAVING 절에서 별칭 쓰기 목록 3.20
SELECT system_id > 10 AS is_not_system10,
       date_trunc('month', read_on) AS month,
       sum(power) / 1000 / 1000 AS power_per_month
FROM readings
WHERE is_not_system10 = TRUE
GROUP BY is_not_system10, month
HAVING power_per_month > 100;
비집계를 가리키는 별칭 is_not_system10을 컬럼 정의를 되풀이하지 않고 WHEREGROUP BY에서 쓴다. 후자는 다른 많은 관계형 데이터베이스에서는 불가능하다. 집계에 준 별칭 power_per_month도 마찬가지로 HAVING 절에서 접근할 수 있다.

관련된 모든 컬럼으로 묶고 정렬하기 3.5.4

GROUP BY 절을 다룬 절에서 논한 대로, 집계가 아닌 모든 컬럼은 GROUP BY 절에 열거되어야 한다. 비집계 컬럼이 많으면 이는 고통스러운 경험이 되는데, DuckDB는 GROUP BY ALL을 허용해 그 고통을 덜어준다. 정렬에도 비슷한 개념이 있다. ORDER BY ALL포함된 컬럼들로 왼쪽에서 오른쪽으로 결과를 정렬한다. 스타 셀렉트의 경우 컬럼의 순서는 물론 테이블이나 뷰의 정의가 정한다.

GROUP BY ALL과 ORDER BY ALL 목록 3.21 · 3.22
-- 목록 3.21 — 비집계 값 전체로 그룹 집합을 만든다.
CREATE OR REPLACE VIEW v_power_per_day AS
SELECT system_id,
       date_trunc('day', read_on)        AS day,
       round(sum(power) / 4 / 1000, 2)  AS kWh,
FROM readings
GROUP BY ALL;

-- 먼저 system_id로, 다음 day로 정렬한다.
SELECT system_id, day FROM v_power_per_day ORDER BY ALL;

-- 목록 3.22 — SELECT 절을 생략하고 정렬을 단순화한다.
-- 시스템, 일자, 그다음 kWh 순으로 정렬된 일별 발전량을 돌려준다. DuckDB에서 유효한 SQL이다.
FROM v_power_per_day ORDER BY ALL;
목록 3.4와 목록 3.21을 나란히 놓아보면 확장의 값어치가 한눈에 보인다. GROUP BY system_id, dayGROUP BY ALL로 줄었다. 컬럼을 하나 더 추가할 때 그룹 절을 고쳐야 한다는 사실을 잊어 생기는 오류가 그만큼 줄어든다.

데이터 표본 추출 3.5.5

큰 데이터셋을 다룰 때는 전부를 훑는 대신 표본을 얻고 싶은 일이 잦다. 34번 시스템의 측정값만 들여왔다 해도 데이터베이스에는 5만 건이 넘는 레코드가 있다. 0이 아닌 발전량 측정값의 개요를 n 퍼센트 또는 n개 행의 표본으로 얻을 수 있다.

관계에서 표본 추출하기 목록 3.23
SELECT power
FROM readings
WHERE power <> 0
USING SAMPLE 10%        -- 데이터 크기의 약 10%에 해당하는 표본을 얻는다.
  (bernoulli);          -- 쓸 표본 추출 방법을 지정한다.
임의의 LIMIT을 다루는 것보다 훨씬 쉽고 유연하며, 더 낫고 더 믿을 만한 개요를 준다. 다만 표본 추출 자체는 확률적 방법을 쓰므로, 추가 REPEATABLE 절로 씨앗을 지정하지 않으면 매번 달라진다. 퍼센트로 준 비율은 정확히 맞히려는 것이 아니다. 예제에서는 0이 아닌 약 2만 행 가운데 2천 행 정도의 편차가 났다.

표본 추출 방법의 선택에 관한 설명이 이 절의 알맹이다. 세 가지가 있다.

system

특정 비율을 지정하면 시스템 표본 추출이 적용되어, 각 벡터를 같은 확률로 포함한다. 튜플 대신 벡터 단위로 표본을 뽑는 것은 대단히 효율적이며 추가 부담이 없다.

bernoulli

대안으로 튜플 단위로 동작한다. 벡터 하나는 대략 2,048개 튜플 크기이므로 시스템 방식은 작은 데이터셋에 맞지 않는다. 데이터가 전부 포함되거나 전부 걸러지기 때문이다. 발전량이 0보다 큰 10만 건 정도에서도 저자들은 더 고르게 분포하는 표본을 위해 bernoulli를 권한다.

reservoir

고정된 표본 크기에는 저수지(reservoir) 방법이 쓰인다. 요청한 개수만큼 저수지를 먼저 채운 뒤 나머지를 흘려보내면서 저수지의 원소를 무작위로 교체한다. 흥미로운 기법이니 표본 문서를 더 읽어볼 만하다.

선택적 매개변수를 갖는 함수 3.5.6

DuckDB의 몇몇 함수—예컨대 read_json_auto—는 필수 매개변수와, 합리적인 기본값을 갖는 선택적 매개변수 하나 이상을 함께 갖는다. 방금 언급한 함수는 매개변수가 열일곱 개다. 그 목록은 다음 질의로 얻는다. 오버로드가 여러 개 있어 타입이 다른 것들이 섞이므로 DISTINCT를 쓴다.

이름 붙은 매개변수 쓰기 목록 3.24
-- 매개변수 목록을 얻는다.
SELECT DISTINCT unnest(parameters)
FROM duckdb_functions()
WHERE function_name = 'read_json_auto';

-- dateformat만 지정하고 싶다면 name=value 문법을 쓴다.
$ echo '{"foo": "21.9.1979"}' > 'my.json'
$ duckdb -s \
  "SELECT * FROM read_json_auto(
     'my.json',
     dateformat='%d.%M.%Y'
  )"
foo
date
1979-01-21
dateformat 매개변수를 썼기 때문에 DuckDB가 ISO 형식이 아닌 문자열을 제대로 된 날짜로 해석해냈다. 이름 붙은 선택적 인자를 지원한다는 것이 이 목록의 요지다.
그런데 출력을 자세히 보면 원자료의 월은 9인데 결과의 월은 01이다. DuckDB의 형식 지정자에서 %M분(minute)이고 월은 %m이므로, 월이 파싱되지 않고 기본값 1로 남은 것이다. 원서에 실린 출력 자체가 그 증거다. 따라 해볼 때는 dateformat='%d.%m.%Y'로 고쳐 쓰는 편이 맞다.
← 좌우로 밀어 보기 →
SUMMARY

제3장이 남긴 일곱 문장Summary

  1. SQL 질의는 여러 문으로 이루어지고, 문은 다시 절로 이루어진다. 질의는 데이터 정의어(DDL)데이터 조작어(DML)로 갈린다.
  2. DML 질의는 행의 생성, 읽기, 갱신, 삭제를 아우른다.
  3. 데이터 조작은 영속 상태를 바꾸는 일만이 아니라 기존 관계를 새 관계로 변환하는 일이기도 하다. 그러므로 데이터를 읽는 것도 DML에 속한다.
  4. CREATE TABLECREATE VIEW 같은 DDL 질의로 영속 스키마를 만든다. 이는 여느 관계형 데이터베이스와 같으며, DuckDB를 디스크 기반으로 띄웠는지 인메모리로 띄웠는지와 무관하다.
  5. 엄격한 스키마는 데이터 불일치를 더 잘 드러낸다. 불일치가 있는 데이터를 무턱대고 수집하려 하면 제약 오류로 실패한다.
  6. 제약 오류는 행을 만들거나 갱신할 때 ON CONFLICT로 정의한 적절한 조치로 완화할 수 있다.
  7. DuckDB는 SELECT * EXCLUDE()SELECT * REPLACE() 같은 혁신, 그리고 더 직관적인 별칭 사용으로 SQL을 한결 쓰기 쉽게 만든다.