DuckDB in Action·Manning, 2024· 제5장 정독
5 EXPLORING DATA WITHOUT PERSISTENCE
원서 98–122쪽

데이터베이스에 아무것도 담지 않고 데이터베이스를 쓰는 법

제3장은 스키마를 세우고 데이터를 밀어 넣는 이야기였다. 제5장은 그 정반대를 한다. DuckDB에 데이터를 영속화하지 않은 채로 질의하는 기법이며, 저자들 스스로 "데이터베이스로서는 꽤 별스럽고 직관에 어긋나 보인다"고 인정한다. 그러나 알맞은 상황에서는 요긴하다. 한 형식을 다른 형식으로 옮기는 일이라면, 그 도중에 중간 저장 모델까지 만들어둘 이유가 없다. 이 장은 데이터가 고유 형식으로 저장되어 있지 않아도 DuckDB의 분석 엔진이 힘을 낸다는 것을 실물로 보여준다.

이 장이 다루는 것
CSV를 Parquet으로 변환 · 파일 형식과 데이터 스키마 자동 추론 · 중첩 JSON 질의를 단순하게 하는 뷰 · Parquet 메타데이터 탐색 · SQLite 같은 다른 데이터베이스 질의
절 구성
5.1 – 5.7 및 하위 절 하나, 요약
실린 것
목록 2건 · NOTE 6건 · 도판 없음
다루는 데이터
프리미어리그 슛 기록(xG) JSON · ATP 테니스 랭킹 CSV 8개 · 유럽 축구 SQLite 300MB
SAME DATA · 3,113,261 ROWS · THREE COSTUMES CSV × 8 files 69.6 MB 스키마 없음 · 전부 문자열로 시작 · 표본을 떠서 타입을 짐작한다 Parquet × 1 36 MB 스키마 내장 · 물리 타입 + 논리 타입 · 조건 푸시다운 XLSX × 1 299 MB 쓰는 데 1분 남짓 · 날짜·타임스탬프는 문자열로 바꿔야 한다 0 100 MB 200 MB 300 MB Parquet은 비정규화로 선수 정보를 310만 행에 되풀이해 담았는데도 CSV 원본의 절반이다. 컬럼 저장과 압축이 그 중복을 흡수해버린다.
덧붙인 그림 15.4·5.7절의 실측 크기를 한자리에 모았다. 이 장에는 원서 도판이 없으므로 이 문서에서 작도한 것이다.
← 좌우로 밀어 보기 →

이 장의 예제에 쓰이는 JSON과 CSV 원천은 예제 저장소의 ch05 폴더에 있다. 저자들은 이 장의 예제를 위해 DuckDB CLI를 띄우기 전에 저장소의 루트로 이동해 있다고 가정한다. 사소해 보이는 이 전제가 실은 중요하다. 이 장의 모든 질의가 상대 경로로 파일을 가리키기 때문이며, 뒤에서 보게 되듯 그 경로가 한 번 어긋나 있는 자리도 있다.

저자들이 이 장에서 보여주려는 것은 둘이다. 하나는 여러 흔한 데이터 형식—JSON, CSV, Parquet—과 SQLite 같은 다른 데이터베이스까지 질의하는 법이고, 다른 하나는 그렇게 해도 성능이 무너지지 않는다는 사실이다. 앞의 네 장이 SQL을 가르쳤다면 이 장은 그 SQL을 어디에 대고 쏠 수 있는가를 넓힌다.

데이터를 옮기는 일이라면, 옮기는 도중에 집을 지을 이유가 없다. 제5장의 취지
이 장의 작업 순서 — 번호 붙은 목록은 둘뿐이고, 본문 자체가 하나의 작업 기록이다
  1. 5.2파일 형식과 스키마를 자동으로 알아맞히게 하고, 마음에 안 들면 덮어쓴다
  2. 5.3중첩 JSON을 배열에서 행으로, 구조체에서 컬럼으로 해체한다
  3. 5.4CSV 여덟 개를 조인하고 타입을 고쳐 Parquet 한 개로 쓴다
  4. 5.5그 Parquet의 스키마와 행 그룹 메타데이터를 파헤친다
  5. 5.6SQLite 파일을 붙여 뷰로 삼고, 약한 타입의 흠을 손으로 메운다
  6. 5.7Excel을 읽고 쓴다. 그리고 웬만하면 CSV로 바꿔 쓰라고 권고받는다
5.1

왜 데이터를 영속화하지 않고 데이터베이스를 쓰는가Why use a database without persisting any data?

영속화하지 않고 데이터를 탐색하고 분석하는 것이 이치에 맞는 경우가 있다. 원격에 저장된 데이터를 다룰 때다. 예컨대 파일이 아마존 S3에 놓여 있다고 하자. 이 데이터로 운영 파이프라인을 지을지 아직 알 수 없으니, 제3장에서 했던 것처럼 데이터 모델을 정의하는 데 한참을 쓰고 원격 데이터를 DuckDB의 저장 형식으로 수집해 넣고 싶지는 않다. 또는 프라이버시 때문에 영속화하고 싶지 않은 데이터일 수도 있다.

그러면서도 앞 장들에서 배운 DuckDB와 SQL을 활용해 데이터의 모양과 크기를 파악하고는 싶다. 여기에 요긴한 사실이 하나 붙는다. 파일 형식과 저장 위치에 따라, DuckDB는 파일 내용 전부를 내려받을 필요조차 없을 수 있다. 나중 단계에 가서 데이터를 DuckDB로 수집하기로 정할 수도 있다. 곧, 영속화는 선택이고 그 선택을 뒤로 미룰 수 있다는 것이다.

이미 갖고 있는 데이터베이스를 그대로 쓰기

웬만하면 인프라에 이미 어떤 종류의 데이터베이스가 있을 것이다. DuckDB는 다른 여러 데이터베이스의 저장 시스템을 쓸 수 있다. 가장 두드러진 것이 SQLite와 Postgres 통합이다. 전자는 SQLite 저장 파일에 직접 동작하고, 후자는 Postgres 클라이언트–서버 프로토콜의 이진 전송 모드로 동작한다. 어느 경우든 데이터는 DuckDB 프로세스 안에 없지만, 그럼에도 DuckDB의 빠른 질의 엔진과 SQL 지원의 이점을 누릴 수 있다.

그리고 저자들이 던지는 숫자 하나가 이 절의 백미다. TPC-H 벤치마크 질의 22개 가운데 많은 경우, Postgres 통합을 쓴 DuckDB가 Postgres 자신보다 빨랐다. 그리고 자체 저장을 쓸 때는 모든 질의에서 더 빨랐다. 남의 창고에 든 데이터를 남의 프로토콜로 꺼내 오면서도 주인보다 빨리 계산한다는 이야기이니, 엔진의 값어치를 이보다 선명하게 보여주는 문장을 찾기 어렵다.

NOTE — TPC-H 벤치마크란

의사결정 지원 벤치마크다. 업무 지향의 즉석 질의 한 벌과 동시적 데이터 수정으로 구성된다. 판매 주문을 위한 전형적인 별 모양 스키마(star schema)를 쓰며, 판매와 품목 라인이 팩트 테이블이고 제품과 고객 같은 차원 테이블 몇 개가 딸린다.

이 벤치마크는 대량의 데이터를 살펴보고, 높은 복잡도의 질의를 실행하고, 결정적인 업무 질문에 답을 주는 의사결정 지원 시스템을 예시한다. TPC-H는 분석 분야에서 가장 흔히 쓰이는 벤치마크지만, 많은 사람이 읽기 질의만 돌리고 명세에 함께 들어 있는 갱신은 돌리지 않는다.

5.2

파일 형식과 스키마 추론Inferring file type and schema

DuckDB에는 파일 또는 파일 집합을 처리하기 아주 쉽게 만들어주는 기능이 둘 있다. 읽고 있는 것이 어떤 종류의 파일인지—파일 형식 자동 추론—와 그 파일 안 데이터의 스키마를 모두 알아낸다.

파일 형식 자동 추론

DuckDB로는 CSV, JSON, Parquet 같은 지원 형식의 내용을 FROM 'flights.csv';만큼 간단하게 질의할 수 있다. 이 기능은 별도 설정 없이 제공되며, 앞 장들에서 언급한 모든 SQL 절과 함수를 지원한다. 그런 질의를 내리면 DuckDB는 먼저 그것이 현재 스키마의 테이블이나 뷰를 질의하는 것이 아니라는 사실을 알아낸다. 파일이 파일시스템에 존재하면 확장자로 파일 형식을 판정하고, 그 데이터 형식을 처리하는 법을 아는 적절한 함수를 호출해 파일을 읽는다.

그 함수들은 동작과 형식 양쪽에 대해 합리적인 기본값을 제공한다. 동작이라면 데이터 타입을 결정하기 전에 떠볼 표본의 개수 같은 것이고, 형식이라면 날짜와 시각 형식 같은 것이다. 그 기본값이 맞지 않으면 테이블 함수 read_csv_auto, read_json_auto, read_parquet_auto를 찾아보면 된다. 앞의 질의를 내릴 때 내부적으로 호출되는 바로 그 함수들이다. 세부를 하나씩 바꿀 인자를 잔뜩 제공하면서도 컬럼 이름과 객체 구조 같은 것은 여전히 자동으로 도출해준다. 그러니 FROM 'a_file.csv'FROM 'data*.json'이라고만 쓰는 대신, 필요한 인자를 붙여 FROM read_json_auto('data*.json')이라고 쓰게 된다.

스키마 자동 추론

DuckDB는 처리하라고 시킨 어떤 데이터 원천이든 그 스키마를 자동으로 추론한다. Parquet 같은 형식이라면 쉽다. 파일에 내장된 스키마가 있어 DuckDB가 그것을 쓸 수 있기 때문이다. CSV나 JSON 같은 다른 형식이라면 설정 가능한 개수의 표본 객체로부터 스키마를 추론한다. DuckDB는 CSV 파일의 방언까지 추론하고, 머리글 행이 있는지도 감지한다.

추론 결과가 온전히 마음에 들지 않으면 컬럼 전부, 또는 일부만의 타입을 덮어쓸 수 있다. 그럴 때는 밋밋한 파일 이름이 아니라 "자동이 아닌" 함수—read_csv 같은 것—를 쓴다.

자동 추론에 손을 얹기 5.2
FROM read_csv(
   'flights.csv',          -- 읽을 파일
   auto_detect=true,        -- 모든 인자에 자동 감지를 쓴다
   columns={
     'FlightDate': 'DATE',
     'UniqueCarrier': 'VARCHAR',
     'OriginCityName': 'VARCHAR',
     'DestCityName': 'VARCHAR'
   }                        -- 열거한 컬럼은 주어진 데이터 타입으로 변환되도록 보장한다
);
auto_detectcolumns를 함께 쓰는 것이 요령이다. 나머지는 알아서 하게 맡기고 확신이 있는 컬럼만 못 박아둔다.

여러 파일을 한 번에 읽을 때

DuckDB는 서로 다른 종류의 여러 파일—CSV, Parquet, JSON 등—을 동시에 읽을 수 있다. glob 문법을 쓰거나 읽을 파일의 목록을 주면 된다. 그런데 여러 파일에서 읽을 때 DuckDB는 그 파일들의 스키마를 결합해야 한다. 파일마다 자기 스키마를 가질 수 있고 그것이 다른 파일과 다를 수 있기 때문이다. 통합 방법은 둘이다.

  • 컬럼 위치로 통합(기본값) — DuckDB는 주어진 첫 파일의 스키마를 읽고, 뒤따르는 파일들의 컬럼을 위치로 통합한다. 모든 파일이 같은 이름의 같은 스키마를 동일한 위치에 갖고 있는 한 올바르게 작동한다.
  • 컬럼 이름으로 통합 — 그렇지 않다면 read_xxx 함수의 union_by_name 옵션을 쓴다. 위치가 아니라 모든 이름을 읽어 스키마를 구성하게 해준다.

CSV 파싱에 관한 노트 5.2.1

저자들의 첫 문장이 솔직하다. CSV 파싱은 놀랄 만큼 어렵다. 형식이 단순하고 첫눈에는 명료해 보이는데도 그렇다. DuckDB는 read_csv_auto를 쓰거나 read_csvauto_detect를 참으로 준 모든 경우에 표본 추출을 쓴다. 파일에서 일정 개수의 행—기본값 20,480행—을 읽어 다음을 감지한다.

  • CSV 파일의 방언 — 구분자, 인용 규칙, 이스케이프 등
  • 각 컬럼의 타입
  • 파일에 머리글 행이 있는지

이 셋 가운데 저자들이 가장 중요하다고 보는 것은 타입 감지다. 이후 어느 단계에서든 좋은 데이터 품질을 보장하는 데 가장 큰 요인이라는 것이다. DuckDB는 다음 타입들을 내림차순 우선순위로 판정하려 시도한다.

TYPE DETECTION · DESCENDING PRIORITY BOOLEAN BIGINT DOUBLE TIME DATE TIMESTAMP VARCHAR 위로 갈수록 더 구체적인 타입이다. DuckDB는 위에서부터 차례로 시도한다. 결국 모든 것은 VARCHAR로 캐스팅될 수 있다. 그래서 이 타입의 우선순위가 가장 낮다. 다른 무엇으로도 캐스팅되지 못한 컬럼이 여기로 떨어진다.
덧붙인 그림 25.2.1절이 열거한 일곱 타입의 우선순위를 사다리로 옮긴 것이다.
← 좌우로 밀어 보기 →

read_csv_autoread_csv의 동작을 제어하고 싶거나 제어해야 할 가능성이 상당히 높다. DuckDB 문서는 가장 중요한 인자들을 별도 절에 열거해두었고, 이 매개변수들은 대응하는 내보내기 함수에도 적용된다. 저자들이 도움이 되었다고 꼽은 인자는 다음과 같다.

  • names — 머리글 행이 없을 때 컬럼 이름을 설정한다.
  • dateformat · timestampformat · decimal_separator — 날짜와 숫자 형식을 지정한다.
  • filename — 여러 파일을 한 번에 다룰 때. 처리한 파일의 이름을 담은 인공 컬럼을 결과에 더해준다.

그리고 문서에 접근할 수 없는 처지에 놓였다면, DuckDB에게 자기 함수의 인자 목록을 내놓으라고 질의할 수 있다.

문서 없이 인자 목록 얻기 5.2.1
SELECT distinct function_name,
       unnest(parameters) as parameter
FROM duckdb_functions()
WHERE function_name = 'read_csv'   -- read_json 같은 다른 함수도 마찬가지다
ORDER BY parameter;
제3장 3.5.6절에서 read_json_auto의 열일곱 매개변수를 이 방법으로 세어보았다. 같은 수법이 여기서 되풀이된다. 도구가 자기 사용법을 담고 있다는 것은 오프라인에서 일할 때 큰 미덕이다.
NOTE

JSON과 Parquet 처리도 설정할 수 있다. 다만 형식마다 초점이 다르다. JSON에서 가장 관련 있는 옵션은 날짜와 숫자의 형식 지정이고, Parquet은—특히 쓸 때—행 그룹의 크기와 압축에 대해 얼마간 생각을 요구한다.

5.3

중첩 JSON 해체하기Shredding nested JSON

DuckDB에 내장된 JSON 확장에는 JSON 문자열을 만들고 읽고 조작하는 함수들이 있다. 그 일은 타입과 컬럼 이름을 자동으로 감지한 다음 JSON 안의 값들을 DuckDB의 벡터로 변환하는 방식으로 이루어진다.

소재는 프리미어리그 축구 경기에서 나온 슛을 표상하는 JSON 파일 한 벌이다. understatapi 라이브러리로 만든 것이며, 원천 데이터는 JSON lines 형식으로 한 줄에 한 경기다. 파일은 xg라는 하위 디렉터리에 있는데, xg는 understat 사이트에서 빌려온 용어 expected goals(기대 득점)를 뜻한다.

각 JSON 문서의 상위 구조는 CLI에서 질의 하나로 살펴볼 수 있다. 여기서 한 파일만이 아니라 여러 파일을 한꺼번에 질의할 수 있다는 사실이 시연된다. xg/shots_*.jsonxg 디렉터리에서 접두사 shots_와 접미사 .json을 가진 모든 파일을 찾아내는 와일드카드 표현이다.

그런데 예제 파일들의 스키마가 조금씩 달라서 위치로든 이름으로든 통합이 잘 되지 않는다. 그래서 그것을 직접 처리하고 기본값—위치로 통합—을 고수한다. JSON 객체를 별도 행으로 펼쳐낸 다음, 그 과정에서 드러나는 적합성 문제를 손으로 고칠 것이다. 필드가 많아 기본 duckbox 표현 모드로는 잘려 나가므로 line 모드를 쓴다.

NOTE

기본적으로 unnest는 JSON 객체의 첫 번째 층만 풀어낸다. 깊이 중첩된 객체를 풀고 싶다면 recursive := true 매개변수를 쓸 수 있다.

해체는 네 걸음으로 나뉘고, 걸음마다 데이터의 모양이 바뀐다. 아래에서 걸음을 하나씩 눌러 그 모양의 변화를 좇을 수 있다. 표본 행은 원서에 실린 그것—2015년 12월 6일 뉴캐슬–리버풀 경기의 43분 헤더—을 그대로 옮겼다.

데이터의 모양
← 좌우로 밀어 보기 →

2단계에서 눈여겨볼 대목이 있다. 각 행의 원천은 JSON 객체지만 DuckDB가 돌려주는 것은 DuckDB의 구조체(struct)다. JSON과 얼마간 닮았지만 JSON으로 파싱되지는 않는다. 둘을 같은 것으로 여기고 JSON 함수를 들이대면 낭패를 본다.

그리고 뷰를 만드는 이유를 저자들이 정직하게 밝힌다. 뷰는 물리적으로 실체화되지 않고 매번 밑에 깔린 질의를 실행한다. 그러니 뷰를 정의해서 얻는 이득은 성능이 아니라 매번 파일의 전체 위치를 적어내지 않아도 되는 줄임말이다. 이 장에서 뷰의 값어치가 그것 하나로 충분하다는 것이 요점이다.

추론이 어긋났을 때 — CAST와 try_cast

DuckDB의 추론은 완벽하지 않았다. X, Y, xG는 원본 JSON에서 좌표이고 모두 수치 타입—예컨대 DOUBLE—이어야 한다. 그렇지 않으면 그 필드에 수치 연산을 할 수 없다. 고치는 방법은 추론된 STRUCT 타입을 가져와 문제되는 필드의 타입 정의를 바꾸고, 각 행을 그 새 STRUCT로 캐스팅하는 것이다. 목록 5.1이 그것이다.

여기에 저자들이 붙인 조건절이 실무의 무게를 담고 있다. 이 경우처럼 운이 좋으면 타입 전체를 그냥 캐스팅해도 된다. DuckDB의 표본 추출이 틀렸던 것이니까. 그러나 DuckDB의 표본 추출이 옳았고 정말로 문자열 아닌 타입으로 자동 캐스팅될 수 없는 필드가 있다면, 그 뷰에 대한 질의는 골칫거리가 된다. 수치 타입에는 놀랄 일이 숨어 있을 수 있고, 저자들의 경험으로는 타임스탬프의 표현이 다루기 가장 어려운 경우가 잦다. 날짜와 타임스탬프 양쪽에 대해 충분한 ISO 표준이 있는데도 그렇다는 대목에서 쓴웃음이 난다.

더 조심할 점이 있다. DuckDB는 JSON 구조의 속성 이름은 검증하지만, 뷰가 정의되는 순간에 각 값을 캐스팅해보지는 않는다. 그러므로 기대한 형식에 맞지 않는 데이터가 입력에 잠복해 있을 수 있다. 게다가 파이프라인에 새 파일을 더하면 새로운 부적합 데이터가 나타날 수 있다. 그때 SELECT row.x FROM shots를 질의하면 Error: Conversion Error: Could not convert string 'abc' to DOUBLE 같은 오류로 끝난다. 이것이 정확히 원하는 바일 때도 있고, 아닐 때도 있다.

대처하는 길은 둘이다.

  • 필드 수준으로 내려가 하나씩 고친다 — 5.4절에서 그렇게 한다. 이 길을 택한다면 캐스팅 전에 DuckDB가 제공하는 날짜 형식화 함수 가운데 아무거나 하나를 반드시 쓴다. 다만 이런 탐색적 쓰임에서 필드 수준까지 내려가는 것은 노력이 많이 드는 일이고, 그만한 값어치가 없을 수 있다.
  • cast를 try_cast로 바꾼다 — 저자들의 권고다. DOUBLE로 캐스팅될 수 없는 데이터를 가진 필드는 이제 리터럴 NULL 값으로 돌아온다. 오류로 멈추는 대신 빈칸으로 남기는 쪽을 고르는 것이다.
STRUCT 전체에 CAST를 걸어 추론된 타입 고치기 목록 5.1
CREATE OR REPLACE VIEW shots AS
FROM (
  FROM 'xg/shots_*.json'
  SELECT unnest(h) AS row
  UNION ALL
  FROM 'xg/shots_*.json'
  SELECT unnest(a) AS row
)
SELECT CAST(ROW AS STRUCT(
  id BIGINT, "minute" BIGINT, result VARCHAR,
  X DOUBLE, Y DOUBLE, xG DOUBLE,          -- 여기가 달라진 타입이다
  player VARCHAR, h_a VARCHAR, player_id BIGINT,
  situation VARCHAR, season BIGINT, shotType VARCHAR,
  match_id BIGINT, h_team VARCHAR, a_team VARCHAR,
  h_goals BIGINT, a_goals BIGINT, date TIMESTAMP,
  player_assisted VARCHAR, lastAction VARCHAR)) AS row;
본문은 "cast 함수에서 try_cast로 바꾸기를 권한다"면서 목록 5.1이 try_cast(row AS STRUCT(…)) AS row의 사용을 상세히 보여준다고 적었다. 그러나 목록 5.1에 실제로 인쇄된 것은 CAST다. 권고와 예제가 어긋나 있으니, 따라 할 때는 이 자리의 CASTtry_cast로 바꿔 쓰는 편이 본문의 취지에 맞는다.

구조체를 컬럼으로 펼치기

이 지점에서 뷰에 곧바로 질의를 쓸 수도 있지만, DuckDB가 지원하는 깔끔한 기능이 하나 더 있다. 구조체를 컬럼으로 펼치는 것이다. 중첩된 필드를 가진 컬럼 하나가 아니라, 필드마다 자기 컬럼이 되는 것이다. 그러면 처음부터 그 컬럼을 쉽게 지목할 수 있고 어떤 함수나 집계든 적용할 수 있다.

원본 JSON 파일의 리스트를 펼치던—평탄화하던—방식과 비교해볼 만하다. [1, 2, 3] 같은 구조를 1, 2, 3 세 행으로 바꾸는 일이었다. 구조체와 맵 모양 타입에도 같은 것이 지원되며, 그것을 펼치면 속성 또는 키마다 컬럼 하나가 나온다.

익명 구조체를 세 컬럼으로 5.3
D SELECT unnest({'x' :1, 'y':2, 'z': 3});
xyz
int32int32int32
123
-- 구조체를 변수로 참조할 수 있을 때는 * 연산자가 줄임말이 된다. 결과는 위와 같다.
D WITH src AS (SELECT {'x' :1, 'y':2, 'z': 3} AS row)
  SELECT row.* FROM src;

-- 이 수법으로 리스트에서 뽑아낸 행들까지 평탄화한 새 뷰를 만든다.
CREATE OR REPLACE VIEW shotsFlattened AS (
   SELECT row.*      -- .* 문법이 구조체의 최상위 필드마다 컬럼 하나를 만든다
   FROM shots
);
이 기법은 구조체의 원소에 접근할 때마다 이름을 한정해야 하는 번거로움에서 벗어나게 해준다. row.X라고 쓰던 것을 그냥 X로 쓸 수 있게 되는 것이다.

중첩 JSON 탐색은 질의 하나로 맺는다. 2022 시즌 기대 득점이 가장 높은 팀들을 찾는 질의다. 이 지표와 관련된 데이터를 제공하는 것이 곧 understat의 사명이기 때문이다.

2022 시즌 기대 득점 상위 열 팀 5.3
SELECT CASE
       WHEN h_a = 'h' AND result <> 'OwnGoal' THEN h_team
       WHEN h_a = 'a' AND result = 'OwnGoal' THEN h_team
       ELSE a_team
       END AS team,
       round(sum(xg), 2) AS totalXG,
       count(*) FILTER(WHERE result IN ('Goal', 'OwnGoal')) AS goals
FROM shotsFlattened
WHERE season = 2022
GROUP BY ALL
ORDER BY totalXG DESC
LIMIT 10;
teamtotalXGgoals
varchardoubleint64
Manchester City45.6653
Arsenal41.245
Liverpool39.9634
Newcastle United38.1733
Brighton34.237
Manchester United34.0332
Tottenham33.4940
Brentford31.732
Fulham30.1232
Leeds27.3226
10 rows    3 columns
CASE 절의 둘째 갈래가 이 질의의 묘미다. 원정 선수의 자책골(OwnGoal)은 홈 팀의 기대 득점으로 셈한다. 슛을 찬 쪽이 아니라 득점의 이득을 보는 쪽에 값을 붙이는 것이며, 나머지는 모두 ELSE a_team으로 떨어진다. 축구의 규칙 하나가 SQL의 조건 세 줄로 번역되었다.
프리미어리그를 따라온 사람이라면 리그 최강 맨체스터 시티가 2022년 가장 높은 xG를 기록한 것에 놀라지 않을 것이다. 그런데 표를 옆으로 읽으면 다른 이야기가 보인다. 리버풀은 xG 39.96에 실제 득점 34, 토트넘은 xG 33.49에 득점 40이다. 기대보다 덜 넣은 팀과 더 넣은 팀이 나란히 있다. 이 표가 왜 필요한지가 그 두 줄에 있다.
← 좌우로 밀어 보기 →
NOTE — 기대 득점(xG)이란

팀과 선수의 경기력을 평가하게 해주는 축구 지표다. 축구처럼 득점이 적은 경기에서는 최종 스코어가 각 팀의 경기력을 늘 분명하게 보여주지는 않는다. 그래서 점점 더 많은 스포츠 분석가들이 xG 같은 진전된 모델로 향한다. 한 경기에서 만들어낸, 그리고 내준 득점 기회의 질을 통계적으로 측정한 값이다.

저자들의 맺음말이 정확하다. 본질적으로 이것은 DuckDB 같은 분석 데이터베이스가 대단히 요긴할 수 있는 문제 영역이다.

5.4

CSV를 Parquet으로 옮기기Translating CSV to Parquet

데이터 엔지니어링의 흔한 과제가 한 데이터 형식을 다른 형식으로 변환하는 일이다. 이 분야의 초기 도구들은 목표 형식으로 변환하기 전에 원천 데이터셋 전체를 메모리에 적재해야 한다고 가정했다. DuckDB는 메모리 한도를 설정해 원천 데이터 행을 제한된 개수만 메모리에 적재하게 해준다. 큰 데이터셋을 다룰 때나 자원이 제한된 기계를 쓸 때 요긴하다.

왜 Parquet인가

Parquet은 Apache Spark 같은 빅데이터 처리 프레임워크를 위해 설계된, 널리 쓰이는 컬럼 저장 파일 형식이다. 효율적인 압축과 인코딩 기법으로, 메타데이터 없는 텍스트 기반 형식—CSV나 JSON—에 비해 저장 요구량을 줄이고 질의 성능을 높이는 이득을 준다.

여기에 하나가 더 붙는다. 이 파일 형식은 조건 푸시다운과 사영 푸시다운을 쓴다. 선택적 질의의 실행을 가능하게 하고 데이터 전송을 최소화하는데, 분산 환경에서 특히 이롭다. 이 기제로 저장 계층에게 질의와 일치 조건에 관련된 선택된 컬럼이나 특정 구간만 가져오라고 말할 수 있고, 나머지 저장 데이터는 완전히 손대지 않은 채로 남겨둘 수 있다.

여덟 개의 CSV, 310만 행

저장소의 ch05 디렉터리로 이동하면 atp_ 접두사 파일들을 담은 atp 디렉터리가 보인다. 1970년대까지 거슬러 올라가는 프로 테니스 선수들의 랭킹 데이터다. 그리고 선수 메타데이터를 담은 atp_players.csv 파일 하나가 따로 있다.

파일 크기와 행 수 — 셸이 알려주는 것과 알려주지 못하는 것 5.4
$ du -h atp/*.csv
2,1M      atp/atp_players.csv
 20M      atp/atp_rankings_00s.csv
 20M      atp/atp_rankings_10s.csv
3,3M      atp/atp_rankings_20s.csv
412K      atp/atp_rankings_70s.csv
5,7M      atp/atp_rankings_80s.csv
 16M      atp/atp_rankings_90s.csv
2,1M      atp/atp_rankings_current.csv
본질적으로 정규화된 테이블을 보고 있는 것이다. 관계형 모델에서 하듯 선수가 그들의 랭킹과 독립적으로 저장되어 있다. 최종 Parquet 파일은 비정규화된 데이터를 담아야 하므로, 파이프라인 안에서 랭킹과 선수를 조인하게 된다.
# du, 윈도 탐색기, macOS 파인더 같은 흔한 파일 탐색 프로그램은
# 파일 안의 행 수나 레코드 수 같은 직접적인 통찰을 주지 못한다. 그래서 질의로 센다.
D SELECT filename, count(*)
  FROM read_csv_auto(
    'atp/atp_rankings_*.csv',
    filename=true          -- 각 파일의 이름을 결과 행에 더한다
  )
  GROUP BY ALL
  ORDER BY ALL;
filenamecount_star()
varcharint64
atp/atp_rankings_00s.csv920907
atp/atp_rankings_10s.csv915618
atp/atp_rankings_20s.csv149977
atp/atp_rankings_70s.csv20726
atp/atp_rankings_80s.csv284809
atp/atp_rankings_90s.csv725606
atp/atp_rankings_current.csv95618
일곱 파일에 걸쳐 300만을 조금 넘는 레코드가 있다. 정확히는 3,113,261행이다. 그중 몇 파일은 거의 100만 행에 가깝고, 가장 작은 파일은 2만 행뿐이다. 그러므로 Parquet 파일의 행 그룹 크기는 뒤에서 비교적 크게 잡아도 안전하다고 가정할 수 있다.
NOTE 어떤 시스템에서는 줄을 세는 wc -l atp/atp_rankings_*.csv 같은 유틸리티를 쓸 수도 있다. 그러나 저자들의 대꾸가 이 책의 성격을 잘 보여준다. "그러면 무슨 재미가 있겠는가?"
← 좌우로 밀어 보기 →

행 그룹 크기라는 저울

행 수를 미리 알아두는 것이 왜 중요한가. 컬럼 형식으로서 Parquet 파일은 행들을 묶어 그룹으로 만들기 때문이다. 한 그룹의 행 수를 알맞게 정하는 일이 중요하고, 양쪽으로 다 위험이 있다.

행 그룹이 너무 작으면

압축이 덜 효과적이다. 파일의 상당 부분이 행 그룹 머리글에 잡아먹히고, 압축은 더 큰 블록에서 잘 작동한다. 파일이 커지고 머리글을 읽는 데 처리 시간이 더 든다.

행 그룹이 너무 크면

DuckDB가 읽기를 병렬화할 수 없다. 그래서 성능이 떨어질 수 있다.

그래서 여기서는

310만 행이라는 양을 감안해 ROW_GROUP_SIZE 100000이 합리적으로 보인다고 판단한다. 근거 없는 기본값이 아니라 미리 센 행 수에 근거한 선택이다.

CSV 파일과 달리 Parquet 파일은 전용 데이터 타입을 가진 자기 서술적 스키마를 담고 있다. CSV라면 기본적으로 모든 것이 통상 문자열이고 각 컬럼을 표본으로 떠서 실제로 어떤 데이터가 들었는지 판정해야 하는데, Parquet 파일은 이 정보를 이미 담고 있다.

다만 Parquet이 원래 저장하는 데이터 타입은 몇 개뿐이며, 이를 물리 데이터 타입이라 부른다. 그럼에도 주석을 덧붙임으로써 더 많은 타입을 표상할 수 있고, 그것을 논리 타입이라 부른다. 예컨대 Parquet 파일은 날짜를 수치로 저장하지만, 추가 메타데이터가 읽는 쪽에게 그 수치를 날짜로 해석하라고 알려준다.

8자리 숫자를 날짜로 되돌리기

값어치 있는 Parquet 파일을 만들려면 먼저 CSV 파일을 살펴, 다루는 각 컬럼이 가장 구체적이고 정밀한 데이터 타입을 담고 있는지 확인해야 한다. 이 타입들이 목표 파일의 스키마에 쓰이기 때문이다.

20000110은 숫자가 아니라 날짜다 5.4
D SELECT * FROM 'atp/atp_rankings_*.csv' LIMIT 5;
ranking_daterankplayerpoints
int64int64int64int64
2000011011017364135
2000011021023382915
2000011031019482419
2000011041030172184
2000011051028562169
ranking_date가 DuckDB에게는 수치로 인식되었다. 자세히 보면 %Y%m%d로 형식화된 날짜임이 상당히 분명하다. ISO 날짜 형식처럼 보이지만 아니다. 이 형식의 날짜로는 정렬 같은 일부 연산만 할 수 있고, 그 밖에는 다루기가 어색하며 SQL 날짜 조작 함수에 인자로 넘길 수 없다. 무엇보다 Parquet에서 제대로 된 논리 데이터 타입으로 번역되지도 않는다.
D SELECT * REPLACE (
    cast(strptime(ranking_date::VARCHAR, '%Y%m%d') AS DATE)
      AS ranking_date
  )
  FROM 'atp/atp_rankings_*.csv'
  LIMIT 5;
ranking_daterankplayerpoints
dateint64int64int64
2000-01-1011017364135
2000-01-1021023382915
2000-01-1031019482419
2000-01-1041030172184
2000-01-1051028562169
strptime은 문자 인자 둘—파싱할 문자열과 형식—을 받는다. ranking_date는 int64이므로 DuckDB가 암묵적으로 문자열로 캐스팅해 넘긴다. strptime의 결과는 다시 날짜로 캐스팅되어 시각 정보가 떨어져 나간다. 첫 컬럼만 바뀌었고 나머지는 그대로다.
NOTE read_csv_auto('atp/atp_rankings_*.csv', dateformat='%Y%m%d')로 가능한 모든 컬럼에 그 날짜 형식을 지정할 수도 있었다. 그런데 저자들은 이를 "대담한 수"라고 부른다. 8자리 숫자라면 어떤 것이든 그 형식에 들어맞기 때문이다. 그래서 컬럼 하나씩 고치기로 정했다. 자동화의 편의와 오작동의 위험을 재보는 이 판단이 이 절에서 가장 배울 만한 대목이다.
← 좌우로 밀어 보기 →

지금은 각 행이 어느 선수를 가리키는지 모른다. atp_players.csv 파일을 조인해 알아낼 수 있다. 선수 CSV 파일의 dob—생년월일—필드도 %Y%m%d 형식의 문자열이라는 문제를 함께 고친다.

선수 이름까지 붙이기 5.4
SELECT * EXCLUDE (
           player,          -- 간결함을 위해 일부 컬럼을 제외한다
           wikidata_id,
           name_first,
           name_last, player_id, hand, ioc
         )
         REPLACE (
          cast(strptime(ranking_date::VARCHAR, '%Y%m%d') AS DATE) AS ranking_date,
          cast(strptime(dob, '%Y%m%d') AS DATE) AS dob
         ),
         name_first || ' ' || name_last AS name
FROM 'atp/atp_rankings_*.csv' rankings
JOIN (FROM 'atp/atp_players.csv' ) players
  ON players.player_id = rankings.player
ORDER BY ranking_date DESC
LIMIT 5;
ranking_daterankpointsdobheightname
dateint64int64dateint64varchar
2022-12-26168202003-05-05185Carlos Alcaraz
2022-12-26260201986-06-03185Rafael Nadal
2022-12-26358201998-12-22183Casper Ruud
2022-12-26455501998-08-12193Stefanos Tsitsipas
2022-12-26548201987-05-22188Novak Djokovic
EXCLUDE로 일곱 컬럼을 빼고, REPLACE로 두 날짜를 고치고, 문자열 연접으로 name을 새로 만든다. 제3장 3.5.1절의 확장 셋이 한 문장에서 함께 일하는 모습이다.
← 좌우로 밀어 보기 →

이제 결과를 Parquet 파일로 내보낸다. 이 명령은 스크립트로 짜인 데이터 파이프라인의 일부로 돌리게 될 것이므로, 먼저 .exit로 CLI를 나간다. 내보내는 데이터 양은 메모리에 넉넉히 들어가지만 memory_limit 설정으로 DuckDB가 쓰는 메모리를 제한할 수 있다. 메모리가 제한된 파이프라인이나 서버리스 환경에서 이 능력은 값지다. 기본적으로 DuckDB는 사용 가능한 전체 램의 80%를 쓴다.

한 줄의 셸 명령으로 Parquet 만들기 5.4
$ duckdb -s "SET memory_limit='100MB';
COPY (
  SELECT * EXCLUDE (player, wikidata_id)
           REPLACE (
             cast(strptime(ranking_date::VARCHAR, '%Y%m%d') AS DATE)
               AS ranking_date,
             cast(strptime(dob, '%Y%m%d') AS DATE) AS dob
           )
  FROM 'atp/atp_rankings_*.csv' rankings
  JOIN (
    FROM 'atp/atp_players.csv'
  ) players ON players.player_id = rankings.player
)
TO 'atp_rankings.parquet'
(FORMAT PARQUET, CODEC 'SNAPPY', ROW_GROUP_SIZE 100000);"

$ du -h *.parquet
36M     atp_rankings.parquet
-s 플래그는 명령을 넘겨 종료 전에 실행하게 한다. 몇 초면 끝난다. 압축 알고리즘으로는 GZIP보다 SNAPPY를 골랐다. 후자가 일반적으로 더 높은 압축률을 이루지만 전자는 속도에 최적화되어 있고, 파일을 만들 때의 주된 관심사가 속도였기 때문이다. 여기서 개발한 완전한 문장에는 LIMIT 절이 더 이상 없다. 조사와 탐색에는 유용했지만 결국은 모든 행을 처리해야 한다.
내보내는 문장의 EXCLUDE(player, wikidata_id) 둘뿐임을 눈여겨봐야 한다. 바로 앞의 탐색 질의는 일곱 컬럼을 빼고 name을 새로 만들었지만, 실제로 만들어지는 Parquet에는 name 컬럼이 없고 name_first·name_last·hand·ioc·player_id가 그대로 들어간다. 5.5절의 DESCRIBE 결과가 그 사실을 확인해준다. 따라 하다가 name을 찾으면 없다.
NOTE 같은 질의 결과를 CSV와 JSON 형식으로도 내보내 파일 크기의 차이를 직접 보라고 저자들은 권한다.
5.5

Parquet 파일 분석하고 질의하기Analyzing and querying Parquet files

Parquet 파일은 데이터 처리 파이프라인에서 널리 쓰일 뿐 아니라 DuckDB 안에서 질의할 훌륭한 데이터 원천이기도 하다. 메타데이터에 스키마를 제공하므로 CSV나 JSON 파일보다 데이터베이스에 훨씬 가깝다.

그래서 저자들은 상황을 하나 상정한다. atp_rankings.parquet 파일을 내가 만든 것이 아니라 남에게서 받았을 수도 있다. 구조도 내용도 모르는데 그것으로 보고서를 만들라는 과제를 받았다. 이 절은 그 처지에서 시작한다.

받은 파일의 겉면 — DESCRIBE 5.5
D DESCRIBE FROM 'atp/atp_rankings.parquet';
column_namecolumn_typenullkeydefaultextra
varcharvarcharvarcharvarcharvarcharvarchar
ranking_dateDATEYES
rankBIGINTYES
pointsBIGINTYES
player_idBIGINTYES
name_firstVARCHARYES
name_lastVARCHARYES
handVARCHARYES
dobDATEYES
iocVARCHARYES
heightBIGINTYES
10 rows    6 columns
ranking_datedob를 자세히 봐야 한다. 둘 다 DATE 타입이니 앞 절에서 한 타입 강제 변환이 성공했다는 뜻이다. 8자리 정수로 남겨두었다면 여기서 BIGINT로 보일 것이었다.
경로가 어긋나 있다. 5.4절은 파일을 TO 'atp_rankings.parquet'—현재 디렉터리—에 썼고 du -h *.parquet도 그 자리에서 찾았다. 그런데 5.5절의 모든 질의는 'atp/atp_rankings.parquet'를 읽는다. 따라 할 때는 둘 중 하나로 맞춰야 파일을 찾지 못한다는 오류를 만나지 않는다.
← 좌우로 밀어 보기 →

이 스키마만으로도 데이터를 질의하고 분석하기에는 충분하다. 그러니 여기서 멈추고 파일을 테이블처럼 취급해 제3·4장의 SQL 지식을 적용해도 된다. 그러나 정말로 자신이 DuckDB로 그 파일을 만들었다면 더 깊이 들어가고 싶어질 것이다.

Parquet에는 물리 타입이 몇 개뿐임을 기억해두자. 불리언, 여러 크기의 숫자, 바이트 배열이며, 그 타입들로부터 더 "고수준"의 무엇으로 변환해야 한다. 거대한 데이터셋을 다룰 때는 숫자가 특별한 관심사가 될 수 있다. Parquet에는 정수를 위해 int32, int64, int96이 있다. 더 작은 수치 타입에서 더 많은 벡터 기반 연산을 병렬로 실행할 수 있으므로, 최적 성능이 목표라면 데이터를 담을 수 있는 가장 작은 데이터 타입을 쓰려 해야 한다.

파일 속에 든 스키마 — parquet_schema 5.5
-- 이 함수는 필드를 많이 돌려주므로 먼저 DESCRIBE를 앞에 붙여 목록을 얻는다.
D DESCRIBE FROM parquet_schema('atp/atp_rankings.parquet');
-- → file_name, name, type, type_length, repetition_type, num_children,
--   converted_type, scale, precision, field_id, logical_type  (11개 필드)

-- 이 경우 가장 흥미로운 것은 이름과 타입이다.
D FROM parquet_schema('atp/atp_rankings.parquet')
  SELECT name, type, converted_type, logical_type;
nametypeconverted_typelogical_type
varcharvarcharvarcharvarchar
duckdb_schema
ranking_dateINT32DATE
rankINT64INT_64
pointsINT64INT_64
player_idINT64INT_64
name_firstBYTE_ARRAYUTF8
name_lastBYTE_ARRAYUTF8
handBYTE_ARRAYUTF8
dobINT32DATE
iocBYTE_ARRAYUTF8
heightINT64INT_64
11 rows    4 columns
type 필드는 디스크에서 실제로 쓰인 타입을 서술하며, 가능한 한 최소가 되도록 의도된 것이다. converted_typelogical_type 필드는 그 타입을 어떻게 해석해야 하는지에 대한 서술을 담는다. 예컨대 ranking_dateINT32로 저장되지만 처리할 때는 DATE로 취급되어야 한다. converted_type은 Parquet에서 폐기 예정이지만, 보다시피 하위 호환을 위해 여전히 기록된다.
첫 행 duckdb_schema가 비어 있는 것은 그것이 스키마 트리의 뿌리 노드이기 때문이다. Parquet의 스키마는 평평한 목록이 아니라 트리이며, 열 개의 컬럼이 그 뿌리의 자식으로 달려 있다. 그래서 11행이다.
← 좌우로 밀어 보기 →

이 메타데이터에서 눈에 걸리는 것이 있다. rank, points, player_id, height모두 64비트 정수로 표상되어 있다. 부호 있는 64비트 정수의 최댓값은 9,223,372,036,854,775,807이다. 포인트나 키 필드의 값이 그만한 공간을 필요로 한다면 놀라운 일이겠다. 그러니 실제 최댓값을 질의로 확인해본다.

64비트가 정말 필요한가 5.5
D from 'atp/atp_rankings.parquet'
  select max(rank), max(points), max(player_id), max(height);
max(rank)max(points)max(player_id)max(height)
int64int64int64int64
227116950211767211
어느 값도 32비트 정수의 상한에조차 근접하지 않는다. 32비트의 상한은 21억을 넘는데 가장 큰 값이 21만이다. 그러므로 Parquet으로 내보내기 전에 필드를 INT32로 캐스팅해 이후의 연산을 더 최적화할 수 있다. 키가 211cm인 선수가 있다는 사실도 덤으로 알게 된다.
NOTE — 연습 과제 그 필드들에 int32를 쓰면서 Parquet으로 내보내는 방법을 알아내 보라. 요컨대 관련 필드를 캐스팅하면 된다. CAST(points AS INTEGER) 또는 points::integer로 쓸 수 있고, INTEGER가 Parquet의 int32에 대응한다. DuckDB는 TINYINT(int8), SMALLINT(int16), INTEGER(int32), BIGINT(int64)와 그 부호 없는 변종들, 그리고 HUGEINT(int128)를 지원한다.
← 좌우로 밀어 보기 →

Parquet 파일 자체의 구조도 parquet_metadata 함수로 탐색할 수 있다. 이 함수는 행 그룹마다, 컬럼마다 레코드 하나를 돌려준다. 컬럼을 많이 돌려주므로 표 형태로 인쇄되지 않도록 다시 line 모드를 쓴다.

행 그룹 하나, 컬럼 하나의 이력서 5.5
D .mode line
D FROM parquet_metadata('atp/atp_rankings.parquet')
  LIMIT 1;

              file_name = atp/atp_rankings.parquet
           row_group_id = 0
     row_group_num_rows = 20726
  row_group_num_columns = 10
        row_group_bytes = 2374571
              column_id = 0
            file_offset = 0
             num_values = 20726
         path_in_schema = ranking_date
                   type = INT32
              stats_min = 1973-08-27
              stats_max = 1979-12-26
       stats_null_count = 0
   stats_distinct_count =
        stats_min_value = 1973-08-27
        stats_max_value = 1979-12-26
            compression = SNAPPY
              encodings = PLAIN
      index_page_offset =
 dictionary_page_offset =
       data_page_offset = 4
  total_compressed_size = 5479
total_uncompressed_size = 82934
이 레코드는 파일 첫 행 그룹(row_group_id = 0)ranking_date 컬럼에 대한 것이다. stats_min에서 이 행 그룹의 최솟값이 1973년 8월 27일이고, stats_max에서 최댓값이 1979년 12월 26일임을 알게 된다.
그리고 이 통계가 무엇을 위한 것인지가 이 절의 결론이다. DuckDB는 질의를 실행할 때 이 메타데이터를 쓴다. 예컨대 ranking_date가 1980년 이후인 레코드를 찾는 질의를 썼다면, 이 행 그룹의 모든 값을 안전하게 무시할 수 있다. 최근 값이 1979년임을 알고 있기 때문이다. Parquet은 데이터베이스 엔진에게 많은 정보를 제공해, 엔진이 최선의 방식으로 질의를 최적화할 수 있게 해주는 훌륭한 파일 형식이다.
두 숫자가 눈에 걸린다. 첫째, 행 그룹의 행 수가 20,726이다. ROW_GROUP_SIZE 100000으로 지정했는데도 그렇고, 이 숫자는 1970년대 CSV 파일의 행 수와 정확히 같다. 요청한 크기가 상한일 뿐 원천의 경계에서 끊길 수 있다는 뜻이다. 둘째, 이 컬럼의 비압축 82,934바이트가 압축 5,479바이트로 줄었다. 약 15배다. 같은 날짜가 20,726행에 되풀이되는 컬럼이니 압축이 잘 먹는 것이 당연하고, encodings = PLAIN인데도 이 정도라는 점이 SNAPPY의 값어치를 말해준다.
← 좌우로 밀어 보기 →
5.6

SQLite와 다른 데이터베이스 질의하기Querying SQLite and other databases

DuckDB의 또 하나 흥미로운 기능은 다른 데이터베이스에 붙여 그 내용을 질의할 수 있다는 것이다. 그중 하나가 임베디드 OLTP 데이터베이스 SQLite다. 까다로운 분석 질의를 쓰면서 DuckDB 질의 엔진의 이점을 누리고 싶다면, 기존 SQLite 파일을 DuckDB에서 질의하고 싶어질 만하다.

다만 저자들이 미리 경고를 둔다. SQL 표준이 데이터 타입과 동작을 부분적으로 정의하기는 하지만, 벤더마다의 구현 사이에는 이름과 의미론에서 많은 차이가 있을 수 있다. 외래 데이터베이스를 DuckDB에 붙이는 일은 때로 그 어긋남을 우회해야 한다는 뜻이다. 자동 추론이 통하는 일이 잦지만, 통하지 않을 때도 있다. 그런 이치로, 외래 저장소를 질의할 때도 앞 절의 CSV에서 했던 것과 같은 변환 작업을 해야 할 수 있다. 그리고 이 절은 정확히 그 "통하지 않을 때"의 기록이다.

소재는 Kaggle의 유럽 축구 데이터베이스다. 25,000경기가 넘는 유럽 프로 축구 경기와 선수, 팀 속성 데이터를 담고 있으며, 300MB SQLite 데이터베이스로 제공된다. 예제 저장소에는 압축된 형태로 들어 있으니 먼저 풀어야 한다.

붙이기 — 그리고 곧바로 만나는 오류 5.6
$ unzip database.sqlite.zip

D INSTALL sqlite;
D LOAD sqlite;

-- TYPE sqlite는 파일 확장자에서 추론될 수도 있다.
D ATTACH 'database.sqlite' AS fifa (TYPE sqlite);
D USE fifa;

-- SQLite의 테이블들은 DuckDB에 뷰로 등록된다. .tables 명령으로도 볼 수 있다.
D PRAGMA show_tables;
name
varchar
Country
League
Match
Player
Player_Attributes
Team
Team_Attributes
sqlite_sequence
-- 다 잘 붙은 것처럼 보인다. Player 뷰를 질의해본다.
D FROM Player LIMIT 5;
Error: Invalid Error: Mismatch Type Error: Invalid type in column "height":
column was declared as integer, found "182.88" of type "float" instead.
저자들은 이것이 현재 DuckDB 판본(0.10)의 버그일 수 있고 앞으로 고쳐질 수 있다고 적으면서도, "실은 꽤 흥미로운 문제다"라고 덧붙인다. 그 진단이 이 절의 알맹이다.
← 좌우로 밀어 보기 →

약한 타입과 강한 타입이 만나는 자리

진단은 이렇다. SQLite는 약한 타입(weakly typed) 데이터베이스 시스템이다. 데이터를 저장할 때 타입이 강제되지 않는다는 뜻이다. 반면 DuckDB는 강한 타입 시스템이며 모든 컬럼이 정의된 타입을 갖기를 요구한다. DuckDB는 SQLite의 타입 체계에 충실하려 하는데, 이 경우에는 그것이 잘 통하지 않았다. height 컬럼이 SQLite에서 INT로 정의되어 있는데도 일부 float 값을 담고 있기 때문이다.

DESCRIBE Player;로 각 필드의 타입을 확인하면 heightBIGINT로 나오는데, 앞의 질의에서 그것이 float 값을 담고 있음을 이미 보았다. 저자들은 weight 필드에도 문제가 있을 수 있다고 짚으면서 한마디 붙인다. "아마 그 필드에 소수값이 없어서 운이 좋았을 뿐일지도 모른다." 약한 타입의 데이터는 이렇게 운에 의존하는 코드를 만든다.

손으로 뷰를 다시 세우기 5.6
-- 쓰고 있는 데이터베이스는 떼어낼 수 없으므로 먼저 다른 데이터베이스로 옮긴다.
D USE memory;
D DETACH fifa;

-- 모든 SQLite 컬럼을 VARCHAR로 변환해 변환 오류를 만나지 않게 한다.
D SET GLOBAL sqlite_all_varchar=true;

-- sqlite_scan으로 레코드를 모두 가져와 각 필드를 손으로 올바른 타입으로 캐스팅한다.
D USE main;
D CREATE OR REPLACE VIEW Player AS
  FROM sqlite_scan('database.sqlite', 'Player')
  SELECT * REPLACE (
    id :: BIGINT AS id,
    player_api_id :: BIGINT AS player_api_id,
    player_fifa_api_id :: BIGINT AS player_fifa_api_id,
    birthday :: DATE AS birthday,
    height :: FLOAT AS height,
    weight :: FLOAT AS weight
  );

D FROM Player SELECT * EXCLUDE player_fifa_api_id LIMIT 5;
idplayer_api_idplayer_namebirthdayheightweight
int64int64varchardatefloatfloat
1505942Aaron Appindangye1992-02-29182.88187.0
2155782Aaron Cresswell1989-12-15170.18146.0
3162549Aaron Doran1991-05-13170.18163.0
430572Aaron Galindo1982-05-08182.88198.0
523780Aaron Hughes1979-11-08182.88154.0
이번에는 통한다. 그리고 ID들이 제대로 된 int64 컬럼이 되었다. sqlite_all_varchar로 일단 모든 것을 문자열로 받아낸 다음 필요한 것만 골라 캐스팅한다는 것이 외래 저장소를 다루는 이 절의 요령이다.
데이터 자체에 눈길이 간다. 182.88과 170.18은 각각 72인치와 67인치를 센티미터로 옮긴 값이다. 그런데 같은 행의 weight는 187.0과 146.0이다. 축구 선수의 체중이 187kg일 수는 없으니 파운드다. 곧 이 데이터셋은 키는 미터법, 체중은 야드파운드법으로 섞여 있다. 원서는 두 필드를 나란히 캐스팅하면서 이 점을 언급하지 않으므로, 보고서를 만들 때 단위를 확인해야 한다.
← 좌우로 밀어 보기 →

이제 sqlite_all_varcharfalse로 되돌리고 나머지 뷰들을 손으로 만든다. Player_Attributes, Country, League, Match, Team, Team_Attributes 여섯 개를 각각 sqlite_scan으로 감싼다. 그리고 두 SQLite 테이블을 조인해 가장 최근 평가에 기반한 최고 선수들을 찾는 질의를 쓴다.

SQLite 두 테이블을 조인해 최고 평점 열 명 찾기 5.6
D SET GLOBAL sqlite_all_varchar=false;

D CREATE OR REPLACE VIEW Player_Attributes AS
  FROM sqlite_scan('database.sqlite', 'Player_Attributes');
-- Country, League, Match, Team, Team_Attributes도 같은 방식으로 만든다.

D SELECT player_name, arg_max(overall_rating, date) AS overall_rating
  FROM Player
  JOIN Player_Attributes PA ON PA.player_api_id = Player.player_api_id
  WHERE overall_rating is not null
  GROUP BY ALL
  ORDER BY overall_rating DESC, player_name
  LIMIT 10;
player_nameoverall_rating
varcharint64
Lionel Messi94
Cristiano Ronaldo93
Luis Suarez90
Manuel Neuer90
Neymar90
Arjen Robben89
Zlatan Ibrahimovic89
Andres Iniesta88
Eden Hazard88
Mesut Oezil88
10 rows    2 columns
제4장에서 배운 arg_max가 여기서 제 일을 한다. arg_max(overall_rating, date)날짜가 가장 늦은 행의 평점을 골라준다. 선수마다 여러 시점의 평가가 있는 테이블에서 "가장 최근 평점"을 뽑는 정석이다.
NOTE DuckDB에는 Postgres 데이터베이스를 질의하는 postgres 확장도 있다. 설치는 SQLite 확장과 비슷하게 INSTALL postgres; LOAD postgres;를 세션에서 실행하면 된다. 그다음 ATTACH 명령으로 Postgres 데이터베이스에 접속하고 인스턴스의 접속 정보를 주어야 한다. 어떤 Postgres 테이블을 질의하든 완전히 불투명하게 처리되며, DuckDB가 제공하는 모든 SQL 기능이 작동한다.
← 좌우로 밀어 보기 →
5.7

Excel 파일 다루기Working with Excel files

DuckDB는 Microsoft Office Open XML(OOXML, 확장자 .xlsx)로 저장된 Excel 파일을 읽고 쓸 수 있다. 이 형식은 2007년부터 마이크로소프트 오피스가 써왔고 LibreOffice와 구글 시트를 포함한 다른 응용들도 지원한다. 그런데 조건이 하나 붙는다. DuckDB에 spatial 확장이 설치되어 있어야 한다. 이 확장은 주로 공간 데이터를 다루는 데 쓰이지만, 그 밑에 깔린 기계 장치가 OOXML도 지원하기 때문이다. 지리 정보 확장으로 엑셀을 읽는다는 것이 조금 우습지만, 형식을 다루는 코드가 어디서 재활용되는지를 보여주는 사례로는 흥미롭다.

spatial 확장 설치하고 Excel 읽기 목록 5.2
INSTALL spatial;
LOAD spatial;

-- Excel 파일을 읽으려면 st_read 함수를 쓴다. 아래는 첫 시트를 읽는다.
D SELECT ranking_date, rank, name_last
  FROM st_read('atp_rankings.xlsx')
  ORDER BY ranking_date limit 5;
ranking_daterankname_last
datedoublevarchar
1973-08-27129.0Gonzalez
1973-08-27114.0Ulrich
1973-08-276.0Rosewall
1973-08-2719.0Emerson
1973-08-2782.0Phillips Moore
INSTALL 문은 한 번만 필요하고, LOAD 문은 확장을 쓰려는 세션마다 필요하다.
타입을 감지하는 기계 장치가 DuckDB에 직접 구현된 것이 아니라 쓰이는 확장 안에 구현되어 있다. 그래서 CSV와 JSON 파일의 타입 감지보다 최적화가 덜 되어 있다. 결과에서 날짜 컬럼은 올바르게 읽히지만 rank는 정수여야 하는데 일반적인 double로 잡혔다. 5.2.1절의 정연한 우선순위 사다리와 견주면 그 차이가 대비된다.
Excel 파일에는 수식이 흔히 들어 있다. 이것들은 기본적으로 원시 수식 문자열로 읽히며 평가되지 않는다. 어떤 저작 도구는 수식과 함께 마지막 값을 저장하는데, 그런 경우에는 그 값을 읽을 수 있다.
← 좌우로 밀어 보기 →

쓰기 지원은 제한적이다. 날짜와 타임스탬프 같은 일부 데이터 타입은 지원되지 않아 문자열로 캐스팅하거나 문자열로 형식화해야 한다. 5.4절에서 만든 Parquet과 같은 데이터를 Excel로 내보내면 어떤 일이 벌어지는지가 이 절의 마지막 교훈이다.

299MB의 대가 5.7
COPY (
  SELECT * EXCLUDE (player, wikidata_id)
           REPLACE (
             strftime(strptime(ranking_date, '%Y%m%d'), '%Y-%m-%d')
               AS ranking_date,             -- 날짜를 문자열로 되돌려야 한다
             strftime(strptime(dob, '%Y%m%d'), '%Y-%m-%d') AS dob
           )
  FROM 'atp/atp_rankings_*.csv' rankings
  JOIN (
    FROM 'atp/atp_players.csv'
  ) players ON players.player_id = rankings.player
  ORDER BY ranking_date ASC
)
TO 'atp_rankings_full.xlsx' WITH (FORMAT GDAL, DRIVER 'xlsx');
저자의 기계에서 1분 남짓 걸리고 299MB라는 묵직한 Excel 파일이 나온다. 같은 데이터의 Parquet 파일은 36MB일 뿐이다. 여덟 배가 넘는다. 본질적으로 5.4절에서 Parquet 하나를 만들 때 쓴 것과 같은 문장인데, 옷만 바꿨더니 몸집이 이렇게 된 것이다.
목표 파일이 이미 존재하면 GDAL Error (1): File extension should be XLSX라는 오류 메시지가 인쇄된다. 저자들의 평가가 담백하다. "조금 오해를 부르는 메시지이며, 확장의 앞으로의 판본에서 고쳐질 수 있다." 확장자가 문제라고 말하지만 실제 원인은 파일이 이미 있다는 것이다.
그래서 저자들의 총평은 이렇다. 일반적으로, 가능하다면 Excel 파일을 DuckDB로 처리하기 전에 CSV로 내보내기를 권한다. DuckDB와의 전반적인 통합이 그쪽을 더 다루기 쉽게 만들어주기 때문이다. 이 장이 형식의 우열을 가리는 장은 아니지만, 마지막 문장이 사실상 순위를 매기고 있다.
덧붙인 표  이 장이 다룬 다섯 원천을 한자리에 정리한 것이다. 원서에는 이런 표가 없다.
원천스키마타입 감지DuckDB의 창구이 장의 소감
CSV없음20,480행 표본 · 7단계 우선순위 read_csv_auto · read_csv 파싱이 놀랄 만큼 어렵다. 그래도 인자가 가장 풍부하다.
JSON없음표본 객체로 추론 · 중첩은 unnest read_json_auto · JSON 확장 날짜·숫자 형식 지정이 핵심 옵션이다. 구조체는 JSON이 아니다.
Parquet내장불필요 · 물리 타입 + 논리 타입 read_parquet · parquet_schema · parquet_metadata 데이터베이스에 가장 가깝다. 행 그룹 통계로 질의를 건너뛴다.
SQLite있으나 약함SQLite 선언을 따르므로 어긋날 수 있다 ATTACH · sqlite_scan 강한 타입과 약한 타입이 부딪힌다. 뷰로 손수 메운다.
XLSX없음확장 안에 구현 · 최적화가 덜 되었다 spatial 확장의 st_read 쓰기가 제한적이고 파일이 여덟 배 크다. CSV로 바꾸는 편이 낫다.
SUMMARY

제5장이 남긴 여섯 문장Summary

  1. 파일에 저장되어 있든 파이프라인을 통해 흐르든, 데이터베이스 저장 기능을 쓰지 않고도 DuckDB의 강력한 질의 언어와 엔진으로 데이터를 처리할 수 있다.
  2. DuckDB의 질의 엔진은 서로 다른 형식의 파일이나 다른 데이터베이스의 저장소 같은 여러 원천과 함께 쓸 수 있다. DuckDB는 JSON, CSV, Parquet 파일에 대해 올바른 내용과 데이터 타입을 추론하는 일을 훌륭히 해낸다.
  3. DuckDB의 JSON 처리 능력은 복잡하고 비정규화된 JSON 문서조차 질의하고 정규화하게 해주어, 그것들이 어떤 질의에서든 자연스러운 표 형태 데이터 원천처럼 느껴지게 만든다.
  4. DuckDB로 하는 데이터 변환—필터링, 타입 변환, 평탄화, 다른 원천을 조인해 풍부하게 하기—은 DuckDB에 영속화할 것을 요구하지 않는다.
  5. 벡터 기반 DuckDB 질의 엔진은 일부 워크로드와 질의를 대단히 효율적으로 처리하므로, 외부 데이터베이스 저장소와 함께 쓰는 것만으로도 성능 이점을 준다. 외부 데이터베이스의 어떤 능력도 잃지 않고, 서로 다른 두 데이터셋을 동기화해 유지할 필요도 없다.
  6. 뷰는 외부 데이터 타입에 필요한 변환을 감싸두는 요긴한 도구다.