수평 분할의 단위. 각 행 그룹이 컬럼마다 필요한 정보를 담는다.
어떤 데이터셋은 표 형태로 요약되고, 어떤 것은 차트로 그려진다. 그러나 이 장이 다루는 것은 표도 차트도 아니다. 초와 기가바이트다. 앞의 아홉 장이 "무엇을 할 수 있는가"를 보여주었다면 이 장은 "얼마나 걸리는가"를 보여준다. 그래서 이 장의 본문에는 .timer on이 자주 나오고, 인쇄된 숫자 하나하나가 주장의 근거가 된다.
순서는 두 데이터셋에 대해 같다. 준비하고, 들이고, 질의하고, 이식 가능한 형식으로 내보낸다. 다만 두 번째 데이터셋에서는 마지막 단계가 사라진다. 애초에 데이터베이스를 채우지 않고 Parquet 파일을 그 자리에서 질의하기 때문이다. 그 차이가 이 장의 후반부를 이룬다.
이 장에 실린 목록 43건 — 이 책에서 가장 많다
- 10.1XML을 JSON을 거쳐 CSV로 변환
- 10.2Tags 파일의 메타데이터 서술
- 10.3CSV 파일에서 상위 태그 고르기
- 10.4태그 빈도를 버킷으로 나누는 질의
- 10.5users 테이블 만들기
- 10.6posts 테이블 만들기
- 10.7컬럼 일부만 SUMMARIZE
- 10.8평판 상위 사용자
- 10.9일당 평판 증가율 상위 사용자
- 10.10막대 차트로 그린 평판 증가율
- 10.11연도별 활동량 질의
- 10.12SQL 태그 질문의 요일 분포
- 10.13Rust 태그 질문의 요일 분포
- 10.14열거형 타입 만들기 예제
- 10.15tags 테이블에서 태그 열거형 만들기
- 10.16열거형 값 몇 개 고르기
- 10.17tagNames 컬럼 추가
- 10.18tags에서 tagNames 채우기
- 10.19tagEnums 컬럼 추가
- 10.20tagNames에서 tagEnums 직접 채우기
- 10.21java 태그 세기 · 문자열 비교
- 10.22java 태그 세기 · 문자열 리스트
- 10.23java 태그 세기 · 열거형 리스트
- 10.24상위 10 태그 통계 · tags 컬럼
- 10.25상위 10 태그 통계 · tagEnums 컬럼
- 10.26users를 Parquet으로 내보내기
- 10.27posts를 Parquet으로 내보내기
- 10.28users를 다중 스레드로 내보내기
- 10.29Parquet에서 행 수 읽기
- 10.30CSV에서 행 수 읽기
- 10.31데이터베이스 전체를 Parquet으로
- 10.32schema.sql의 내용
- 10.33load.sql의 내용
- 10.34S3 비밀 만들기
- 10.35httpfs 확장 설치와 적재
- 10.36여러 파일에 걸친 뷰 만들기
- 10.37SUMMARIZE 명령
- 10.38걸러내는 뷰 만들기
- 10.39단일 컬럼 요약
- 10.40단일 컬럼 집계
- 10.41다중 컬럼 집계
- 10.42운행 데이터 연도별 집계
- 10.43승객 수별 택시 운행
스택 오버플로 전체를 적재하고 질의하기Loading and querying the full Stack Overflow database
스택 오버플로는 2008년에 만들어진 질의응답 사이트이며 평판(reputation) 체계를 쓴다. 유용한 답변과 콘텐츠에 기여하면 점수와 특권을 얻는다. 저자들이 독자에게 던지는 물음이 다정하다. 그 유용한 사이트 뒤에 있는 시스템과 데이터를 한 번이라도 생각해본 적이 있는가.
데이터셋의 규모는 이렇다. 압축된 CSV 형식으로 11GB이며, 5,800만 게시물과 2,000만 사용자와 6만 5천 태그를 담고 있다. 그리고 저자들이 스스로 등급을 내린다. 완전히 "빅데이터"는 아니지만, DuckDB의 진면목을 시험할 만큼은 크다.
데이터 덤프와 추출 10.1.1
기초적인 탐색이라면 사이트가 제공하는 Stack Exchange Data Explorer로도 된다. 그러나 저자들이 한계를 짚는다. 서비스에 과부하가 걸리지 않도록 실행할 수 있는 질의의 수와 복잡도가 제한되어 있다. 우리는 우리가 돌리는 질의를 더 통제하고 싶으니 원자료가 필요하다.
저자들이 미리 밝혀둔다. "이것이 과정에서 가장 재미있는 부분이 아님을 알고 있으니, 이 절의 모든 걸음을 따라야 한다고 느끼지 않아도 된다." 최종 표 형태 데이터만 원한다면 S3에서 Parquet 파일을 내려받거나(s3://us-prd-motherduck-open-datasets/stackoverflow/parquet/2023-05/) MotherDuck 공유를 붙이면 된다(md:_share/stackoverflow/6c318917-…).
스택 오버플로 데이터가 너무 크다면 math나 biotechnology 같은 더 작은 스택 익스체인지 커뮤니티를 골라도 된다. 제7장에서 붙여본 그 공유가 여기서 되돌아온다.
# 인터넷 아카이브의 스택 익스체인지 덤프. 크리에이티브 커먼즈 라이선스다. $ curl -OL "https://archive.org/download/stackexchange/stackoverflow.com-\ {Comments,Posts,Votes,Users,Badges,PostLinks,Tags}.7z" # 압축된 XML 일곱 개, 합계 27GB 19G stackoverflow.com-Posts.7z 343M stackoverflow.com-Badges.7z 5.2G stackoverflow.com-Comments.7z 117M stackoverflow.com-PostLinks.7z 1.3G stackoverflow.com-Votes.7z 903K stackoverflow.com-Tags.7z 684M stackoverflow.com-Users.7z # 파일은 SQL Server 내보내기 형식이다. Row 원소 하나가 모든 컬럼을 속성으로 갖는다. <row Id="728812" Reputation="41063" CreationDate="2011-04-28T07:51:27.387" DisplayName="Michael Hunger" … Views="7046" UpVotes="4712" DownVotes="24" /> # DuckDB는 아직 XML 파싱을 지원하지 않으므로 외부 도구를 쓴다. # xidel(XML → JSON) → jq(JSON → CSV) → gzip $ 7z e -so stackoverflow.com-Comments.7z | \ xidel -se '//row/[(@Id|@PostId|@Score|@Text|@CreationDate|@UserId)]' - | \ (echo "Id,PostId,Score,Text,CreationDate,UserId" && jq -r '. | @csv') | gzip -9 > Comments.csv.gz # 끝나면 압축 CSV 일곱 개, 합계 11GB 5.0G Comments.csv.gz 452M Badges.csv.gz 3.2G Posts.csv.gz 137M PostLinks.csv.gz 1.6G Votes.csv.gz 1.1M Tags.csv.gz 613M Users.csv.gz
데이터 모델The data model
탐색을 시작하기 전에 데이터 모델을 본다. 내려받아 변환한 파일에 대응하는 엔티티가 이렇다.
- 질문 —
postTypeId=1인 Post. title, body, creationDate, ownerUserId, parentId, acceptedAnswerId, answerCount, tags, upvotes, downvotes, views, comments를 갖는다. 최대 여섯 개의 태그가 질문의 주제를 정의한다. - 사용자 — displayName, aboutMe, reputation, 마지막 로그인 날짜 등.
- 답변 —
postTypeId=2인 Post. 자기 ownerUserId, upvotes, downvotes, comments를 갖는다. 답변 가운데 하나가 정답으로 채택될 수 있다. - 댓글 — 질문과 답변이 각각 text, ownerUserId, score를 가진 댓글을 가질 수 있다.
- 배지 — 기여에 대해 사용자가 얻는 것. class 컬럼을 갖는다.
- 게시물 연결 — Post가 다른 Post에 연결될 수 있다. 중복이나 관련 질문을
PostLinks로 잇는다.
그리고 이 절에서 가장 실무적인 한 문장. 파일에는 인덱스나 외래 키에 관한 어떤 정보도 없다. 그 참조를 우리가 손으로 다시 세워야 한다. 관계형 데이터베이스의 스키마가 아니라 표 일곱 장을 받은 것이며, 그것들을 어떻게 잇는지는 도메인 지식에 달렸다.
관계를 정리하면 이렇다. Post(질문 또는 답변)는 ownerId로 그것을 쓴 User에 연결된다. Comment와 Vote와 Answer는 postId로 원래 Post를 가리킨다. 채택된 Answer-Post는 Question-Post에서 acceptedAnswerId로 이어진다. Badge는 userId로 User에, PostLink는 postId와 relatedPostId로 두 Post를 잇는다.
원서의 이 도판은 사용자의 질문과 채택된 답변이 보이는 스택 오버플로 사이트의 화면 갈무리다. 데이터 모델의 각 필드가 화면의 어느 자리에 나타나는지 대조해보게 하려는 것이다. 화면 갈무리는 이 문서에서 재현하지 않고 서술로 대신한다.
CSV 파일 데이터 탐색하기Exploring the CSV file data
데이터를 준비했으니 익숙한 영역으로 돌아왔다. DuckDB의 read_csv 함수로 압축된 gzip CSV 파일에서 데이터를 곧바로 적재할 수 있다. 그리고 read_csv가 컬럼 타입을 자동으로 추론하려 하는데, 저자들이 스택 오버플로 데이터셋에서 잘 작동한다고 확인했다.
D SELECT count(*) FROM read_csv('Tags.csv.gz'); -- 6만 5천 개에 조금 못 미친다 D DESCRIBE(FROM read_csv('Tags.csv.gz'));
| column_name | column_type |
|---|---|
| varchar | varchar |
| Id | BIGINT |
| TagName | VARCHAR |
| Count | BIGINT |
| ExcerptPostId | BIGINT |
| WikiPostId | BIGINT |
D SELECT TagName, Count FROM read_csv('Tags.csv.gz', column_names=['Id', 'TagName', 'Count']) ORDER BY Count DESC LIMIT 5;
| TagName | Count |
|---|---|
| varchar | int64 |
| javascript | 2479947 |
| python | 2113196 |
| java | 1889767 |
| c# | 1583879 |
| php | 1456271 |
D SELECT cast(pow(10,floor(log(Count)/log(10))) AS INT) AS bucket, count(*) FROM read_csv('Tags.csv.gz', column_names=['Id', 'TagName', 'Count']) WHERE Count > 0 -- 사용 횟수가 0인 태그를 걸러낸다 GROUP BY bucket ORDER BY bucket ASC;
| bucket | count_star() | 분포 |
|---|---|---|
| int32 | int64 | — 이 문서에서 더한 열 |
| 1 | 6238 | █████ |
| 10 | 23018 | ███████████████████ |
| 100 | 23842 | ████████████████████ ← 최빈 |
| 1000 | 9126 | ███████ |
| 10000 | 1963 | █ |
| 100000 | 252 | · |
| 1000000 | 25 | · |
| 합계 64,464 · 태그 총수 약 65,000 → 사용 횟수 0인 태그가 약 500개 | ||
log(Count)/log(10)의 나눗셈이 DuckDB에서는 아무 일도 하지 않는다.
DuckDB의 log는 상용로그이므로 log(10)은 1이다.
본문의 계산 예시(log(112)/log(10) = 2.049)도 상용로그를 전제하고 있으니 스스로 그것을 확인해준다.
자연로그를 쓰는 다른 데이터베이스로 옮길 때를 대비한 방어적 표기로 읽을 수는 있으나,
DuckDB에서는 log(Count)만으로 충분하다.
데이터를 DuckDB로 적재하기Loading the data into DuckDB
길이 둘이다. 테이블을 먼저 만들고 데이터를 들이거나, 데이터를 읽으면서 테이블을 즉석에서 만드는 것이다. 전자가 더 명시적이고 컬럼 이름과 타입을 정의하게 해주지만, 미리 데이터의 스키마를 알고 적어내야 한다. 그리고 파일 구조나 컬럼 타입이 바뀌면 CREATE TABLE 문도 고쳐야 하며, 그러지 않으면 적재가 실패한다.
저자들이 CREATE OR REPLACE TABLE을 쓰는 이유도 실무적이다. 시험을 위해 사이에 테이블을 지우지 않고도 적재를 여러 번 돌릴 수 있게 하려는 것이다. 여기서는 후자를 택한다. 관련 컬럼 이름을 고르고, 타입은 CSV를 읽으면서 추론되게 하고, "거기 있는 것"을 얻는다.
D CREATE OR REPLACE TABLE users AS SELECT * FROM read_csv('Users.csv.gz', auto_detect=true, column_names=['Id', 'Reputation', 'CreationDate', 'DisplayName', 'LastAccessDate', 'AboutMe', 'Views', 'UpVotes', 'DownVotes']); D SELECT count(*) FROM users; -- 대략 2,000만 사용자 D CREATE OR REPLACE TABLE posts AS FROM read_csv('Posts.csv.gz', auto_detect=true, column_names=[ 'Id', 'PostTypeId', 'AcceptedAnswerId', 'ParentId', 'CreationDate', 'Score', 'ViewCount', 'Body', 'OwnerUserId', 'LastEditorUserId', 'LastEditorDisplayName', 'LastEditDate', 'LastActivityDate', 'Title', 'Tags', 'AnswerCount', 'CommentCount', 'FavoriteCount', 'CommunityOwnedDate', 'ContentLicense' ]); D SELECT count(*) FROM posts; -- 5,800만 게시물 D select column_name, column_type from (show table posts); -- Id · PostTypeId · AcceptedAnswerId · CreationDate · Score · ViewCount · Body -- OwnerUserId · LastEditorUserId · LastEditorDisplayName · LastEditDate -- LastActivityDate · Title · Tags · AnswerCount · CommentCount · FavoriteCount -- CommunityOwnedDate · ContentLicense -- 19 rows 2 columns
Tags 컬럼은 텍스트 컬럼이며 최대 여섯 개의 스택 오버플로 태그를 꺾쇠괄호로 감싸 담는다.
예컨대 <sql><performance><duckdb>다. 이 표기가 10.1.7절 전체의 발단이 된다.
ParentId이며, 출력의 마지막 줄이 19 rows라고 못 박는다.
그리고 이것이 조판 실수가 아님을 같은 장의 목록 10.32가 확증한다.
내보낸 schema.sql의 CREATE TABLE posts에도 ParentId가 없다.
ParentId는 답변이 자기 질문을 가리키는 컬럼이며,
〈도판 10.2〉가 그 관계를 화살표로 그려놓았다.
이 컬럼이 없으면 답변과 질문을 잇는 조인을 쓸 수 없다.
추출 단계(목록 10.1의 Posts 판본)에서 그 속성을 뽑지 않았을 가능성이 크니,
답변–질문 관계를 분석하려는 독자는 추출 명령의 속성 목록부터 확인해야 한다.
| column_name | column_type | max | approx_unique | avg |
|---|---|---|---|---|
| varchar | varchar | varchar | varchar | varchar |
| Id | BIGINT | 21334825 | 20113337 | 11027766.241 |
| Reputation | BIGINT | 1389256 | 26919 | 94.752717160 |
| CreationDate | TIMESTAMP | 2023-03-05 | 19557978 | |
| Views | BIGINT | 2214048 | 7452 | 11.630429738 |
| UpVotes | BIGINT | 591286 | 6227 | 8.7674283438 |
| DownVotes | BIGINT | 1486341 | 2930 | 1.1697560125 |
SUMMARIZE를 컬럼 일부에만 걸었다(목록 10.7).
모든 컬럼에 걸면 몇 초가 걸리고 출력이 거대해진다. 컬럼이 많고 SUMMARIZE가 지표를 많이 계산하기 때문이다.
복잡한 질의를 쓰지 않고도 이 통계를 얻는다는 것이 이 절의 요령이다.
ATTACH 'md:_share/stackoverflow/…' AS stackoverflow`;
복사해 붙이면 구문 오류가 난다. 백틱을 지우면 된다.
큰 테이블에서 빠른 탐색 질의Fast exploratory queries on large tables
이 절의 설정이 구체적이다. 우리가 스택 오버플로 분석가이고, 상위 사용자가 누구이며 그들이 여전히 활동 중인지 확인하고 싶다. 아니라면 플랫폼으로 돌아오도록 설득할 방법을 생각해낼 수 있을지도 모른다. 데이터를 왜 보는지가 있으면 질의가 자연스럽게 따라 나온다.
D .timer on D SELECT DisplayName, Reputation, LastAccessDate FROM users ORDER BY Reputation DESC LIMIT 5; Run Time (s): real 0.126 user 2.969485 sys 1.696962
| DisplayName | Reputation | LastAccessDate |
|---|---|---|
| varchar | int64 | timestamp |
| Jon Skeet | 1389256 | 2023-03-04 19:54:19.74 |
| Gordon Linoff | 1228338 | 2023-03-04 15:16:02.617 |
| VonC | 1194435 | 2023-03-05 01:48:58.937 |
| BalusC | 1069162 | 2023-03-04 12:49:24.637 |
| Martijn Pieters | 1016741 | 2023-03-03 19:35:13.76 |
D SELECT DisplayName, reputation, round(reputation/day(today()-CreationDate)) as rate, -- 일당 평판 day(today()-CreationDate) as days, CreationDate FROM users WHERE reputation > 1_000_000 -- 숫자 리터럴에 밑줄을 쓸 수 있다 ORDER BY rate DESC; Run Time (s): real 0.006 user 0.007980 sys 0.001260
| DisplayName | reputation | rate | days | 막대 (bar 150–300, 폭 35) |
|---|---|---|---|---|
| varchar | int64 | double | int64 | varchar |
| Gordon Linoff | 1228338 | 294.0 | 4181 | █████████████████████████████████ |
| Jon Skeet | 1389256 | 258.0 | 5383 | █████████████████████████ |
| VonC | 1194435 | 221.0 | 5396 | ████████████████ |
| BalusC | 1069162 | 211.0 | 5058 | ██████████████ |
| T.J. Crowder | 1010006 | 200.0 | 5059 | ███████████ |
| Martijn Pieters | 1016741 | 197.0 | 5164 | ██████████ |
| Darin Dimitrov | 1014014 | 189.0 | 5360 | █████████ |
| Marc Gravell | 1009857 | 188.0 | 5380 | ████████ |
bar(rate,150,300,35)는 값과 최소·최대와 폭을 받아 검은 블록으로 그린 문자열을 돌려준다(목록 10.10).
읽기 쉽게 만들기 위해 기존 질의를 공통 테이블 표현식(CTE)으로 감싸고 바깥 질의에서 bar를 썼다.
제3장에서 배운 CTE가 여기서는 가독성을 위한 도구로 쓰인다.
1_000_000처럼 숫자 리터럴에 밑줄을 넣어 자릿수를 끊는 표기가 조용히 등장한다.
이 책이 앞서 소개하지 않은 DuckDB 편의 기능인데, 백만을 눈으로 확인해야 하는 자리에서 값을 한다.
D SELECT year(CreationDate) AS year, round(count(*)/1000000,2) as postM, -- 게시물(백만) round(count_if(postTypeId = 1)/1000000,2) as questionM, -- 질문(백만) round(count_if(postTypeId = 2)/1000000,2) as answerM, -- 답변(백만) round(count_if(postTypeId = 1)/count_if(postTypeId = 2),2) as ratio, round(avg(ViewCount)) as avgViewCount, max(AnswerCount) as maxAnswerCount FROM posts GROUP BY year ORDER BY year DESC LIMIT 10; Run Time (s): real 5.977 … (첫 실행) Run Time (s): real 0.039 … (두 번째 실행)
| year | postM | questionM | answerM | ratio | avgViewCount | maxAnswers |
|---|---|---|---|---|---|---|
| int64 | double | double | double | double | double | int64 |
| 2023 | 0.53 | 0.27 | 0.26 | 1.03 | 44.0 | 15 |
| 2022 | 3.35 | 1.61 | 1.74 | 0.93 | 265.0 | 44 |
| 2021 | 3.55 | 1.55 | 2.0 | 0.78 | 580.0 | 65 |
| 2020 | 4.31 | 1.87 | 2.44 | 0.77 | 847.0 | 59 |
| 2019 | 4.16 | 1.77 | 2.39 | 0.74 | 1190.0 | 60 |
| 2018 | 4.44 | 1.89 | 2.55 | 0.74 | 1648.0 | 121 |
| 2017 | 5.02 | 2.11 | 2.9 | 0.73 | 1994.0 | 65 |
| 2016 | 5.28 | 2.2 | 3.07 | 0.72 | 2202.0 | 74 |
| 2015 | 5.35 | 2.2 | 3.14 | 0.7 | 2349.0 | 82 |
| 2014 | 5.34 | 2.13 | 3.19 | 0.67 | 2841.0 | 92 |
postM과 questionM + answerM을 맞춰보면 2014년만 0.02 어긋난다(5.34 vs 5.32).
각 값을 독립적으로 반올림한 뒤 더했기 때문이며, 제4장 4.4절에서 본 것과 같은 현상이다.
반올림은 마지막에 한 번만 하는 편이 안전하다.
평일에 글을 올리는가Posting on weekdays
물음이 소박하고 좋다. 사람들은 직장에서 질문에 답하려고만 플랫폼을 쓰는가, 아니면 주말에도 쓰는가. 저자들은 이 질문을 먼저 던진 사람의 분석을 재현해보려 한다고 밝히며 출처를 댄다.
본문은 그 사람의 이름을 "Evalina Gabova"로 적고 다시 "Evalina의 분석"이라고 부른다. 그런데 함께 실린 주소는 evelinag.com이다. 이름의 철자가 주소와 어긋난다. 주소를 믿는다면 Evelina가 맞는 표기다. 남의 분석을 재현하는 절에서 그 사람의 이름이 틀리는 것은 아쉬운 일이니, 인용할 때는 주소를 따라가 확인하는 편이 좋다.
D SELECT count(*) as freq, dayname(CreationDate) AS day, bar(freq, 0, 150000, 20) AS plot FROM posts WHERE posttypeid = 1 AND tags LIKE '%<sql>%' GROUP BY all ORDER BY freq DESC; Run Time (s): real 0.303 … (질문 2,350만 건 처리)
| freq | day | plot — SQL | freq | plot — Rust |
|---|---|---|---|---|
| int64 | varchar | bar(0–150000, 20) | int64 | bar(0–10000, 20) |
| 119825 | Wednesday | ███████████████ | 5205 | ██████████ |
| 119514 | Thursday | ███████████████ | 5160 | ██████████ |
| 115575 | Tuesday | ███████████████ | 5167 | ██████████ |
| 103937 | Monday | █████████████ | 5054 | ██████████ |
| 103445 | Friday | █████████████ | 5009 | ██████████ |
| 47390 | Sunday | ██████ | 4784 | █████████ |
| 47139 | Saturday | ██████ | 4667 | █████████ |
| SQL 656,825건 · Rust 35,046건 → 18.7배 · 주말 낙폭 SQL 58.0% vs Rust 7.7% | ||||
bar의 최대값을 150,000과 10,000으로 다르게 준 것도 그래서다.
같은 눈금으로 그리면 Rust 막대가 보이지 않는다.
태그에 열거형 쓰기Using enums for tags
이 절의 전제가 이 장 전체의 주제를 압축한다. 더 큰 데이터셋에서 DuckDB를 쓸 때는 작은 데이터셋에서라면 필요하지 않았을 최적화를 택하게 되는 일이 있다. 그 예가 게시물에 배정된 태그 이름이 저장되고 처리되는 방식이다.
DuckDB에는 열거형(enum) 타입이 있다. 이름 붙은 값의 고정된 집합을 표상하며 내부적으로는 정수로 저장된다. 그래서 많은 양의 문자열 값보다 저장과 처리에 효율적이다.
문제는 이렇다. tags 테이블이 따로 있지만 게시물 안에도 최대 여섯 개의 태그가 저장되며, 각 태그가 꺾쇠괄호로 감싸여 있다. <sql><duckdb><performance>는 그 게시물이 sql, duckdb, performance 태그를 갖는다는 뜻이다. 이것은 분석 질의에 특히 효율적이지 않고, 여러 태그로 게시물을 찾기 어렵게 만든다.
-- 목록 10.14 · 열거형 타입 만들기의 기본형 D CREATE TYPE weekday AS enum ( 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'); -- 값은 문자열로도 접근하고( 'saturday' ) 타입을 붙여서도 접근한다( saturday::weekday ) -- 타입 자체는 NULL 값으로 가리킨다( null::weekday ) -- 목록 10.15 · tags 테이블의 값에서 태그 열거형을 만든다 D CREATE TYPE tag AS enum (SELECT DISTINCT tagname FROM tags); -- 목록 10.16 · 열거형 값 몇 개 들여다보기 D select enum_range(null::tag)[0:5]; [textblock, idioms, haskell, flush, etl] -- 목록 10.17 · 중간 단계로 문자열 배열 컬럼을 먼저 더한다 D ALTER TABLE posts ADD tagNames VARCHAR[]; -- 목록 10.18 · '<sql><duckdb>'에서 앞뒤 한 글자를 떼고 '><'로 쪼갠다 D UPDATE posts SET tagNames = split(tags[2:-2],'><') WHERE posttypeid = 1; -- Run Time (s): real 51.120 … (질문 2,350만 건) -- 목록 10.19 · 열거형 배열 컬럼을 더한다 D ALTER TABLE posts ADD tagEnums tag[]; -- 목록 10.20 · 문자열 배열을 열거형 배열에 그냥 대입한다 D UPDATE posts SET tagEnums = tagNames WHERE posttypeid = 1;
list_transform으로 하나씩 변환하는 수고가 필요하지 않다.
enum_code는 열거형의 숫자 코드를,
enum_range는 그 타입의 모든 값을 리스트로 돌려주고,
enum_first·enum_last·enum_range_boundary는 첫 값과 끝 값과 범위를 준다.
그리고 DuckDB의 열거형은 필요할 때마다 문자열 타입으로 자동 캐스팅되므로,
어떤 문자열 함수에도 쓸 수 있고 열거형과 문자열을 비교할 수 있다.
51.120초를 두고 "1분이 넘게 걸린다"고 적었다.
1분은 60초이니 아직 9초 남았다. 오래 걸린다는 요지는 맞지만 수치와 표현이 어긋난다.
enum_range(null:enum_type)처럼 콜론을 하나만 쓴 표기가 함수 설명에 나오는데,
같은 절의 목록 10.16은 null::tag로 콜론 둘을 쓴다. 후자가 맞다.
그리고 [0:5]의 슬라이싱도 눈에 걸린다. 제4장 4.10절이 DuckDB의 리스트는 1부터 센다고 못 박았으니
관례로는 [1:5]가 자연스럽다.
SET tagEnums = list_transform(tagNames, x -> upper(x)::tag)를 제시한다.
그런데 tag 열거형은 tags 테이블의 소문자 태그 이름으로 만들어졌다.
upper('java')는 'JAVA'이고 그 값은 열거형에 없으므로 캐스팅이 실패한다.
변환한 값을 열거형에 담으려면 변환된 값으로 만든 별도의 열거형 타입이 먼저 있어야 한다.
문자열 배열을 남겨둔 이유가 실험을 위해서다. 문자열 리스트와 열거형 리스트의 동작과 성능을 견줄 수 있다. 아래는 이 장에 인쇄된 계측값을 한자리에 모은 장치다. 여섯 가지 대조를 눌러보면, 무엇을 바꿀 때 얼마가 줄어드는지가 눈에 들어온다. 배수는 원서의 숫자에서 계산한 것이다.
- 무엇을 바꿨는가
- —
- 왜 빨라지는가
- —
저자들이 이 절을 맺는 판단이 이 장에서 가장 값진 문장이다. 열거형이 단순한 카운트에서는 차이가 무시할 만하지만 리스트의 값 여럿을 다루는 복잡한 연산에서는 표상의 변화가 문제가 된다. 그리고 그 대가를 분명히 적는다.
여기에 한 문장이 더 붙는데, 제8장을 읽은 독자에게는 무게가 다르게 다가온다. 준비 작업도 질의에 맞는 데이터 형태를 보장하기 위해 데이터 처리 파이프라인에 통합되어야 한다. 곧 열거형 변환은 손으로 한 번 하는 일이 아니라 dbt 모델이나 Dagster 애셋이 되어야 한다는 뜻이다. 최적화가 코드가 되고, 코드가 파이프라인이 된다.
질의 계획과 실행Query planning and execution
이 장에서 다루는 큰 데이터셋에서는 효율적인 질의 실행이 어느 때보다 중대하다. DuckDB의 질의 실행 엔진은 현대적 하드웨어와 최신 데이터베이스 연구·구현 기법을 써서 빠르고 효율적이도록 설계되었다. 이 절은 그 내부를 열어 보인다.
계획기와 최적화기 10.2.1
DuckDB가 Postgres에서 파생된 유연한 파서로 SQL 질의를 파싱하면, 그 결과인 추상 구문 트리(AST)를 여러 단계에 걸쳐 변환한다. 파스 단계에서는 잘못 쓴 키워드나 빠진 괄호 같은 구문 오류를 잡아낼 수 있다.
- 바인더 binder — 테이블, 뷰, 타입, 컬럼 이름 같은 요소를 해소한다. 쓰인 요소가 데이터베이스에 존재하는지, 올바르게 쓰였는지 검사한다.
- 계획 생성기 plan generator — 그것을 스캔·필터·사영 같은 논리 질의 연산자로 이루어진 기본 논리 질의 계획으로 바꾼다.
- 최적화기 optimizer — 계획 과정에서 저장된 데이터와 인덱스의 통계를 쓴다. 그것이 타입 변환, 조인 순서 최적화, 부질의 평탄화 같은 여러 연산을 돕는다.
- 물리 계획기 physical planner — 논리 계획을 통계와 캐싱과 그 밖의 요인을 고려해 환경에 가장 알맞은 물리 연산으로 다듬는다.
부질어 평탄화(subquery flattening)가 목록에 있는 것이 반갑다. 제4장 4.3절에서 상관 부질의가 어떻게 조인으로 풀리는지 보았는데, 그것을 하는 자리가 바로 여기다.
런타임과 벡터화Runtime and vectorization
DuckDB의 런타임은 컬럼 저장이라는 본성에 기반한 벡터화·병렬화 아키텍처로 작동한다. 저장 형식은 데이터를 행 그룹(row group)—곧 데이터의 수평 분할—에 담는다. 수평 분할은 데이터를 샤딩하는 전략이며, 각 분할이 같은 스키마를 가지고 데이터의 특정 부분집합을 담는다. 그리고 숫자가 하나 나온다. DuckDB 데이터베이스 형식의 행 그룹 하나는 최대 122,880행으로 이루어진다.
병렬 파이프라인을 통과하는 값 덩어리의 배치 크기다.
122,880 ÷ 2,048 = 정확히 60. 저장의 단위와 실행의 단위가 딱 맞물린다. 이 문서에서 계산한 값이다.
컬럼 중심 접근이 주는 이점을 저자들이 세 가지로 짚는다. 컬럼을 고르거나 데이터를 걸러내고 스캔하고 정렬할 때 특히 유리하고, CPU가 한 연산자의 처리를 메모리 안에 유지하게 해주며, CPU 분기 예측을 최적화하고 필요한 데이터를 모두 CPU 캐시에 두게 해준다.
그리고 행 기반 엔진과의 근본적인 차이를 한 문장으로 규정한다. DuckDB는 디스크 저장이나 데이터 전송(I/O) 최적화가 아니라 효율적인 데이터 연산에 맞춰 미세 조정되어 있다. 어디에 힘을 쏟았는지를 밝히는 문장이며, 제7장에서 클라우드 업로드가 70MB/s로 묶였던 것과 나란히 놓고 볼 만하다.
실행 런타임에서 모든 데이터 타입이 벡터, 곧 값의 압축된 배열로 표상된다. 이 타입별 벡터 구현은 숫자·문자열·배열 같은 여러 데이터 타입과 값에 맞게 최적화되어 있고, 압축과 메타데이터와 추가 인덱스를 써서 데이터 선택과 처리를 단순하게 만든다.
데이터가 시스템을 흐를 때 이 벡터들은 푸시 기반(push-based)으로 계획 연산자 사이를 매끄럽게 옮겨간다. 실행 모델의 중심은 파이프라인 설계이며, 연산자가 원천(source)이거나 싱크(sink)이거나 둘 다일 수 있다. 그리고 병렬화는 모설(morsel) 접근으로 이루어진다. 값의 덩어리—2,048개 값의 배치—를 여러 병렬 파이프라인으로 처리하며, 각 파이프라인의 시작과 끝에 병렬성을 인식하는 연산자를 둔다.
마지막에 저자들이 혼동을 미리 막는다. DuckDB는 벡터화 계산도 쓰는데, 그것은 단일 명령 다중 데이터(SIMD)를 써서 하나의 CPU 명령으로 여러 값을 처리하는 것이다. 이것을 데이터 벡터와 혼동해서는 안 된다. 같은 "벡터"라는 낱말이 두 층위에서 다른 것을 가리키므로, 이 한 문장이 없으면 독자가 헷갈릴 자리다.
EXPLAIN과 EXPLAIN ANALYZE로 계획 들여다보기Visualizing query plans with Explain and Explain Analyze
최적화기와 계획기가 만든 질의 계획을 우리도 볼 수 있다. 어떤 SQL 문 앞에 EXPLAIN을 붙이면 원래 질의가 변환된 연산자의 트리가 나온다. 대응은 이렇다.
- ORDER BY와 LIMIT →
TOP_N— 전체를 정렬하지 않고 상위 N개만 유지한다. - SELECT →
PROJECTION - GROUP BY →
PERFECT_HASH_GROUP_BY - SELECT + FROM →
SEQ_SCAN. 추정 카디널리티(EC)가 함께 붙는다.
그리고 읽는 방향을 알려준다. 계획은 아래에서 위로 실행된다. 저장된 데이터의 SEQ_SCAN에서 시작해, 앞선 연산자의 결과 덩어리에 연산자를 적용해 나간다.
D EXPLAIN ANALYZE SELECT year(CreationDate) AS year, count(*), round(avg(ViewCount)), max(AnswerCount) FROM posts GROUP BY year ORDER BY year DESC LIMIT 10;
| 연산자 (위 → 아래) | 하는 일 | 내보낸 행 | 시간 |
|---|---|---|---|
| 아래에서 위로 실행된다 | rows | s | |
| TOP_N | Top 10 · year DESC | 10 | 0.00 |
| PROJECTION | count_star() · round(avg) · max | 16 | 0.00 |
| PROJECTION | __internal_decompress_integral_bigint(#0, 2008) | 16 | 0.00 |
| PERFECT_HASH_GROUP_BY | #0 · count_star() · avg(#1) · max(#2) | 16 | 0.55 |
| PROJECTION | __internal_compress_integral_utinyint(year(CreationDate), 2008) | 58329356 | 0.39 |
| SEQ_SCAN | posts · CreationDate, ViewCount, AnswerCount · EC: 81865857 | 58329356 | 0.98 |
| 연산자 시간 합 1.92s · 벽시계 real 0.199s → 9.6배 차이 | |||
__internal_compress_integral_utinyint(year(CreationDate), 2008)은
연도에서 2008을 빼 1바이트 정수(utinyint)로 좁힌다.
스택 오버플로가 2008년에 만들어졌으니 모든 연도가 0에서 15 사이의 오프셋이 된다.
HASH_GROUP_BY가 아니라 PERFECT_HASH_GROUP_BY다.
압축이 저장 공간을 줄이는 데서 그치지 않고 알고리즘의 선택을 바꾼 것이며,
이것이 컬럼 저장 엔진의 진짜 이점이다.
결과 행이 16인 것도 2008년부터 2023년까지 열여섯 해와 정확히 맞는다.
스택 오버플로 데이터를 Parquet으로 내보내기Exporting the Stack Overflow data to Parquet
테이블을 보관과 더 쉬운 저장, 다른 방식의 처리를 위해 Parquet 파일로 내보낼 수 있다. 저자들이 근거를 되짚는다. 컬럼 형식인 Parquet은 더 잘 압축되고, 스키마를 포함하며, 컬럼 선택과 조건 푸시다운으로 최적화된 읽기를 지원한다. 이 절이 확인하려는 것은 셋이다. 내보내기가 얼마나 걸리는가, 어떤 최적화를 적용할 수 있는가, 데이터베이스 전체를 어떻게 내보내는가.
지원되는 압축 형식은 셋이다. UNCOMPRESSED, SNAPPY, ZSTD. 그리고 이 절의 예제는 모두 SNAPPY를 쓴다. 그 선택이 뒤에서 뜻밖의 결과를 낳는다.
-- 목록 10.26 · users D COPY (FROM users) TO 'users.parquet' (FORMAT PARQUET, CODEC 'SNAPPY', ROW_GROUP_SIZE 100000); -- Run Time (s): real 10.582 … -- 목록 10.27 · posts D COPY (FROM posts) TO 'posts.parquet' (FORMAT PARQUET, CODEC 'SNAPPY', ROW_GROUP_SIZE 100000); -- Run Time (s): real 57.314 … -- 목록 10.28 · 정렬해서 스레드마다 한 파일씩 쓴다 D COPY ( SELECT * FROM users ORDER BY LastAccessDate DESC ) TO 'users.parquet' (FORMAT PARQUET, CODEC 'SNAPPY', PER_THREAD_OUTPUT TRUE); -- 목록 10.29 · 10.30 · 같은 답을 두 형식에서 얻는 시간 D SELECT count(*) FROM read_parquet('users.parquet'); 19942787 (0.008s) D SELECT count(*) FROM read_csv_auto('Users.csv.gz'); 19942787 (약 7s) -- 목록 10.31 · 데이터베이스 전체를 내보낸다 D EXPORT DATABASE 'target_directory' (FORMAT PARQUET); -- Parquet 파일들 + schema.sql + load.sql 이 만들어진다 D IMPORT DATABASE 'source_directory';
schema.sql(목록 10.32)과 load.sql(목록 10.33)이 서로 맞지 않는다.
load.sql에는 COPY post_links FROM …이 있는데
schema.sql에는 post_links를 만드는 CREATE TABLE이 없다.
스키마에 있는 테이블은 posts·comments·badges·users·tags·votes 여섯 개이고 적재는 일곱 개다.
그대로 IMPORT DATABASE를 하면 없는 테이블에 넣으려다 실패한다.
schema.sql에서 "테이블, 뷰, 열거형이 만들어진다"고 적었다.
그런데 인쇄된 schema.sql에는 CREATE TYPE이 한 줄도 없고,
10.1.7절에서 공들여 더한 tagNames와 tagEnums 컬럼도 없다.
posts 테이블이 열아홉 컬럼 그대로다.
내보내기를 그 절 이전 상태에서 했거나, 열거형이 실제로는 내보내지지 않는다는 뜻이다.
어느 쪽이든 "열거형이 만들어진다"는 설명은 실린 출력과 어긋난다.
이 절은 "컬럼 형식인 Parquet은 더 잘 압축된다"는 전제에서 출발한다. 그런데 이 장에는 같은 일곱 테이블의 크기가 두 번 실려 있다. 10.1.1절의 gzip -9로 누른 CSV와, 이 절의 SNAPPY로 누른 Parquet이다. 두 목록을 나란히 놓고 더해보면 결과가 전제를 뒤집는다.
| 테이블 | CSV.gz (10.1.1절) | Parquet SNAPPY (10.3절) | 변화 |
|---|---|---|---|
| Comments | 5.0 GB | 6.9 GB | +38.0% |
| Posts | 3.2 GB | 4.0 GB | +25.0% |
| Votes | 1.6 GB | 2.2 GB | +37.5% |
| Users | 613 MB | 734 MB | +19.7% |
| Badges | 452 MB | 518 MB | +14.6% |
| PostLinks | 137 MB | 164 MB | +19.7% |
| Tags | 1.1 MB | 1.6 MB | +45.5% |
| 합계 | 11.00 GB | 14.52 GB | +31.9% |
일곱 테이블이 하나도 빠짐없이 커졌고, 합계로는 32% 커졌다. 한두 테이블의 예외가 아니라 전면적이다.
이유는 압축 형식의 성격에 있다. SNAPPY는 압축률이 아니라 속도에 최적화된 코덱이고, 비교 대상인 CSV는 gzip -9, 곧 가장 센 설정으로 눌린 것이다. 저자들이 앞 절에서 그 옵션을 직접 썼다. 애초에 공정한 비교가 아니다.
그러니 "Parquet이 더 잘 압축된다"는 말은 같은 급의 코덱을 쓸 때 성립한다. 이 절이 지원 형식으로 열거한 ZSTD를 쓰면 사정이 달라질 것이다. 이 대조가 알려주는 것은 Parquet이 나쁘다는 것이 아니라, Parquet의 이점은 크기가 아니라 다른 데—스키마 내장과 메타데이터 통계와 컬럼 단위 읽기—에 있다는 사실이다. 그리고 그 이점은 같은 절의 875배가 이미 증명해두었다.
그리고 다음 절로 넘어가는 문장이 도발적이다. "그런데 더 크게 갈 수 있을까, 이를테면 수십억 레코드로?" 뉴욕시 택시 데이터셋은 새 데이터베이스 시스템이 빅데이터를 감당할 수 있는지 보려 할 때 으레 찾는 데이터셋이다.
Parquet 파일에서 뉴욕 택시 데이터셋 탐색하기Exploring the New York Taxi dataset from Parquet files
뉴욕시 택시 데이터셋은 NYC 택시·리무진 위원회가 발행하고 관리한다. 승하차 날짜와 시각과 위치, 운행 거리, 항목별 요금, 요율 유형, 지불 유형, 기사가 보고한 승객 수를 담는다. Parquet 형식으로 발행되며 2009년 1월부터 달마다 파일 하나씩 계속 갱신된다.
규모가 이 책의 최대다. 집필 시점에 17억 행이 넘고, Parquet 파일 175개에 28GB다. 저자들이 모아 MotherDuck이 호스팅하는 S3 버킷에 올려두었다.
28GB ÷ 17억 행. 컬럼이 열아홉 개이니 컬럼값 하나당 0.92바이트다. 이 문서에서 계산한 값이다.
달마다 하나. 글로브 와일드카드 하나로 전부 훑는다.
메타데이터만 읽으므로 데이터를 내려받지 않는다.
이 절의 방침이 앞 절과 다르다. Parquet 파일을 질의의 원천으로 쓰며, DuckDB가 실제로 데이터베이스를 채우지 않고도 조건 푸시다운과 사영 푸시다운으로 이 파일들의 질의를 최적화할 수 있음을 보인다. 쓸 자리도 구체적이다. S3나 구글 클라우드 스토리지 같은 클라우드 저장 버킷에 있는 데이터를 한 번만 분석할 때이며, 예컨대 웹사이트나 애플리케이션의 접근 로그나 다운로드 로그를 분석하는 데 쓸 수 있다.
이만한 양의 데이터에서는 질의 계산을 데이터가 사는 곳으로 옮기고 결과만 네트워크로 전송하는 편이 유익하다. 파일을 로컬 기계에 내려받았다면 거기서 DuckDB를 실행하면 되고, 그렇지 않다면 데이터에 가능한 한 가까운 클라우드 인스턴스에서 DuckDB를 돌리는 것이 합리적이다. S3에 파일이 있다면 같은 리전의 EC2 인스턴스나 MotherDuck 같은 호스팅 서비스가 그것이다.
그러지 않으면 이그레스 비용과 네트워크 전송 시간과 지연을 모두 물어야 한다. 단 Parquet 메타데이터만으로 충족되는 질의는 예외다. 제7장의 MD_RUN 논의가 여기서 인프라 선택의 문제로 되돌아온다.
-- 목록 10.34 · 자기 버킷을 쓸 때만 필요하다. 이 버킷은 공개 읽기가 가능하다. D CREATE [PERSISTENT] SECRET ( TYPE S3, KEY_ID 'AKIA...', SECRET ''Sr8VSfK...', -- 겹따옴표가 잘못 들어가 있다 REGION 'us-east-1'); -- 목록 10.35 · S3 접근에 쓰이는 확장. 필요하면 자동 적재된다. D INSTALL httpfs; LOAD httpfs; -- 파일 하나. 메타데이터로 답하므로 600ms. D SELECT count(*) FROM 's3://us-prd-md-duckdb-in-action/nyc-taxis/yellow_tripdata_2022-06.parquet'; 3,558,124 rows in 600 ms -- 글로브 와일드카드. 파일 175개, 17억 행을 11초에 센다. D SELECT count(*) FROM 's3://us-prd-md-duckdb-in-action/nyc-taxis/yellow_tripdata_*.parquet'; 1,721,158,822 rows in 11s -- 특정 파일 몇 개만 고르려면 read_parquet를 직접 부른다. D SELECT count(*) FROM read_parquet([ '…/yellow_tripdata_2021-06.parquet', '…/yellow_tripdata_2022-06.parquet']); 6,392,388 -- 목록 10.36 · 2020년 이후 파일만 덮는 뷰. 즉시 돌아온다. 데이터를 읽지 않는다. D CREATE OR REPLACE VIEW allRidesView AS FROM 's3://us-prd-md-duckdb-in-action/nyc-taxis/yellow_tripdata_202*.parquet'; -- 약 1억 1,800만 행
read_parquet 호출로 변환된다.
다만 특정한 파일 집합을 고르려면 그 함수를 직접 불러야 한다.
SECRET ''Sr8VSfK...'는 홑따옴표가 셋이므로 구문 오류가 난다.
SECRET 'Sr8VSfK...'여야 한다.
httpfs도 그런 형태로 없었다.
소수점 자리 하나가 6년을 옮겨놓은 셈이다.
D FROM parquet_schema('…/yellow_tripdata_2022-06.parquet') SELECT name, type;
| name | type | name | type |
|---|---|---|---|
| varchar | varchar | varchar | varchar |
| schema | (루트) | payment_type | INT64 |
| VendorID | INT64 | fare_amount | DOUBLE |
| tpep_pickup_datetime | INT64 | extra | DOUBLE |
| tpep_dropoff_datetime | INT64 | mta_tax | DOUBLE |
| passenger_count | DOUBLE | tip_amount | DOUBLE |
| trip_distance | DOUBLE | tolls_amount | DOUBLE |
| RatecodeID | DOUBLE | improvement_surcharge | DOUBLE |
| store_and_fwd_flag | BYTE_ARRAY | total_amount | DOUBLE |
| PULocationID | INT64 | congestion_surcharge | DOUBLE |
| DOLocationID | INT64 | airport_fee | DOUBLE |
| 20 rows — 루트 하나와 컬럼 열아홉 개 | |||
DESCRIBE도 되지만 parquet_schema는 Parquet의 물리 타입을 보여준다.
INT64인 것은 Parquet이 타임스탬프를 정수로 저장하기 때문이며 논리 타입이 따로 붙는다.
store_and_fwd_flag가 BYTE_ARRAY인 것은 문자열의 물리 표현이다.
그리고 passenger_count가 DOUBLE이다.
사람 수를 실수로 담는 것인데, 뒤의 통계에서 그 대가가 드러난다.
read_parquet의 union_by_name = true 옵션으로 하나의 결과로 합칠 수 있다.
존재하지 않거나 이름이 다른 컬럼은 NULL로 채워진다.
제5장에서 배운 그 옵션이 175개 파일을 다루는 이 절에서 실전 무기가 된다.
데이터 분석하기Analyzing the data
SUMMARIZE를 뷰 전체에 걸면 통계 정보를 모두 얻기 위해 실제 데이터를 읽어야 하므로 시간이 걸린다. 데이터에 가까운 AWS EC2 인스턴스에서 돌려도 1억 1,800만 행에서 결과를 내는 데 30초가 넘게 걸렸다. 그리고 그 결과가 이 데이터셋의 실상을 드러낸다.
| column_name | type | min | max | approx_unique |
|---|---|---|---|---|
| varchar | varchar | varchar | varchar | varchar |
| VendorID | BIGINT | 1 | 6 | 4 |
| tpep_pickup_datetime | TIMESTAMP | 2001-01-01 | 2098-09-11 | 59777057 |
| tpep_dropoff_datetime | TIMESTAMP | 2001-01-01 | 2098-09-11 | 60115892 |
| passenger_count | DOUBLE | 0.0 | 112.0 | 12 |
| trip_distance | DOUBLE | -30.62 | 389678.46 | 14254 |
| RatecodeID | DOUBLE | 1.0 | 99.0 | 7 |
| store_and_fwd_flag | VARCHAR | N | Y | 2 |
| PULocationID | BIGINT | 1 | 265 | 264 |
| DOLocationID | BIGINT | 1 | 265 | 265 |
| payment_type | BIGINT | 0 | 5 | 6 |
| fare_amount | DOUBLE | -133391414 | 998310.03 | 17618 |
| extra | DOUBLE | -27.0 | 500000.8 | 671 |
| mta_tax | DOUBLE | -0.55 | 500000.5 | 82 |
| tip_amount | DOUBLE | -493.22 | 133391363 | 9064 |
| tolls_amount | DOUBLE | -99.99 | 956.55 | 3897 |
| improvement_surcharge | DOUBLE | -1.0 | 1.0 | 5 |
| total_amount | DOUBLE | -2567.8 | 1000003.8 | 36092 |
| congestion_surcharge | DOUBLE | -2.5 | 3.0 | 16 |
| airport_fee | INTEGER | -2 | 2 | 5 |
airport_fee만 INTEGER인 것도 눈에 걸린다.
방금 parquet_schema가 2022년 6월 파일에서 DOUBLE이라고 보여주었다.
뷰가 2020년 이후 파일 전부를 덮으므로, 어느 해의 파일이 이 컬럼을 다른 타입으로 담았거나
아예 없어서 합쳐진 결과의 타입이 달라진 것이다. 175개 파일의 스키마가 한결같지 않다는 증거다.
PULocationID는 고유값이 264, DOLocationID는 265다.
범위가 둘 다 1–265이니, 승차지로는 한 번도 쓰이지 않은 구역이 하나 있다.
공항 도착 전용 구역 같은 것을 짐작할 수 있다. 근사값이므로 단정할 수는 없다.
-- 목록 10.38 · 음수 거리를 걸러내도록 뷰를 다시 정의한다 D CREATE OR REPLACE VIEW allRidesView AS FROM '…/yellow_tripdata_202*.parquet' WHERE trip_distance > 0; -- 목록 10.39 · 컬럼 하나만 요약하면 3초대로 줄어든다 D SUMMARIZE (SELECT trip_distance FROM allRidesView); count = 115976028 · avg = 5.4306541 · std = 536.03 · q50 = 1.827 (real 3.033) -- 목록 10.40 · 집계 함수만 쓰면 1초 아래로 떨어진다 D SELECT min(trip_distance), max(trip_distance), avg(trip_distance), stddev(trip_distance), count(trip_distance) AS nonNull, count(*) as total, 1-(nonNull/total) AS nullPercentage FROM allRidesView; (real 0.747) -- 목록 10.41 · 컬럼 여럿에 한꺼번에 — 정규식과 람다를 둘 다 쓴다 D SELECT count(*), min(columns(*)), max(columns(*)), round(avg(columns('_(distance|amount|tax|surcharge|fee)')),2), round(stddev(columns(c -> c SIMILAR TO '.+(distance|amount|tax|surcharge|fee)')),2) FROM allRidesView; (real 6.813)
min과 max에는 맞지만 avg와 stddev에는 맞지 않는다.
Parquet의 컬럼 통계에는 최소·최대·널 개수는 있으나 합계나 제곱합은 없다.
평균과 표준편차를 얻으려면 그 컬럼의 값을 실제로 다 읽어야 한다.
columns('정규식')과 columns(c -> c SIMILAR TO '패턴')이다.
저자들이 "시연 목적으로" 둘을 다 썼다고 밝혀두었다.
제3장 3.5.1절의 COLUMNS 람다가 열아홉 컬럼의 통계를 한 문장에 담는 도구로 자란 셈이다.
SUMMARIZE를 SELECT의 원천으로 쓸 수 있다.
SELECT column_name, column_type, count, max FROM SUMMARIZE allRidesView;처럼
관심 있는 컬럼만 고를 수 있다.
다만 저자들이 솔직하게 덧붙인다. "집필 시점에도 나머지 컬럼 통계는 여전히 계산하므로 시간이 절약되지는 않는다."
택시 데이터셋 활용하기Making use of the taxi dataset
이번 설정은 도시 사람들이 도시를 어떻게 이동하는지 이해하려는 뉴욕의 도시계획가다. 앞의 질의에서 운행 유형에 상당한 차이가 있다는 것을 배웠다. 어떤 것은 장거리 여행처럼 보이고 어떤 것은 아주 짧은 셔틀 운행이다. 그것이 해마다 어떻게 달라지는지 파고든다.
D SELECT year(tpep_pickup_datetime) AS year, round(avg(trip_distance)) AS dist, round(avg(fare_amount),2) AS fare, round(AVG(fare_amount/trip_distance),2) AS rate, count(*) AS trips FROM allRidesView GROUP BY year HAVING year BETWEEN 2020 AND 2024 -- 그룹화 키이므로 HAVING에서 걸러야 한다 ORDER BY year; Run Time (s): real 1.831 …
| year | dist | fare | rate | trips | fare÷dist |
|---|---|---|---|---|---|
| int64 | double | double | double | int64 | 이 문서에서 더한 열 |
| 2020 | 4.0 | 12.49 | 7.57 | 24316408 | 3.12 |
| 2021 | 7.0 | 13.42 | 7.09 | 30496201 | 1.92 |
| 2022 | 6.0 | 14.69 | 8.47 | 39081642 | 2.45 |
| 2023 | 4.0 | 19.15 | 9.82 | 22080786 | 4.79 |
| 운행 합계 115,975,037 · 뷰 115,976,028 → 엉뚱한 연도의 행 991개 | |||||
WHERE에는 year 별칭이 아직 없다.
rate 컬럼을 그대로 "마일당 요금"으로 읽으면 안 된다.
avg(fare_amount/trip_distance)는 비의 평균이지 평균의 비가 아니다.
2020년을 보면 평균 요금 12.49를 평균 거리 4.0으로 나눈 값은 3.12인데 인쇄된 rate는 7.57이다. 2.4배 차이다.
네 해 모두 2.05배에서 3.70배까지 벌어진다.
0.1마일에 5달러를 낸 짧은 운행의 마일당 50달러가 평균을 끌어올리기 때문이다.
마일당 요금을 알고 싶다면 sum(fare_amount)/sum(trip_distance)가 맞다.
round(avg(trip_distance))가 소수점 없이 반올림되어 4·7·6·4가 되었다.
그런데 목록 10.39가 전체 평균이 5.43마일이라고 알려주었으니,
이 반올림이 연도별 차이를 지워버린다. 요금은 소수 두 자리로 반올림했는데 거리만 정수인 것도 고르지 않다.
2021년의 7.0이 유독 큰 것이 실제 변화인지 반올림의 결과인지 이 표만으로는 알 수 없다.
-- 목록 10.43 · 승객 수별 운행 D SELECT passenger_count, count(*) FROM allRidesView WHERE passenger_count < 10 GROUP BY passenger_count ORDER BY count(*) DESC; Run Time (s): real 0.897 …
| passenger_count | count_star() | 분포 |
|---|---|---|
| double | int64 | 이 문서에서 더한 열 |
| 1.0 | 5366688 | ████████████████████ |
| 2.0 | 1580495 | ██████ |
| 3.0 | 370175 | █ |
| 4.0 | 193333 | ▏ |
| 5.0 | 149485 | ▏ |
| 0.0 | 118751 | ▏ |
| 6.0 | 102836 | ▏ |
| 8.0 | 65 | · |
| 7.0 | 60 | · |
| 9.0 | 40 | · |
| 합계 7,881,928 — 뷰 115,976,028의 6.8%에 불과하다 | ||
passenger_count가 DOUBLE이라 값이 1.0, 2.0으로 찍히는 것이
앞에서 짚은 물리 타입의 대가다. 사람 수인데 실수다.
그리고 승객 0명인 운행이 118,751건인 것도 눈에 걸린다.
기사가 입력하지 않은 것으로 보이며, 8명과 7명보다 훨씬 많다.
WHERE passenger_count < 10뿐이다.
그리고 합계를 내보면 그 누락이 드러난다. 7,881,928은 뷰 전체 1억 1,600만의 6.8%다.
승객 수 조건만으로는 거의 모든 행이 남아야 하니 이 숫자가 나올 수 없다.
거리 필터가 실제로는 적용되었으나 인쇄된 질의에서 빠진 것으로 보아야 앞뒤가 맞는다.
따라 하는 독자는 AND trip_distance > 10을 더해야 같은 표를 얻는다.
저자들이 이 절과 이 장을 함께 맺는다. 이것은 이 데이터셋으로 돌릴 수 있는 질의의 표면을 긁은 것에 지나지 않지만, 쓰임을 더 깊이 파기보다 이 정도 데이터 양에서 질의의 성능을 살펴보는 데 초점을 두려 했다. 그리고 앞의 장들을 가리킨다. 이런 종류의 데이터셋과 원천 위에 앱이나 API나 대시보드를 지을 수도 있고, 노트북에서 분석해 결과를 내보내 더 처리할 수도 있다. 제9장이 지은 것이 이 데이터 위에도 올라갈 수 있다는 말이다.
제10장이 남긴 아홉 문장Summary
SUMMARIZE절로 데이터셋의 개요를 얻는 것이 데이터셋 탐색을 시작하는 좋은 방법이다.- 문자열을 열거형으로 바꾸는 것은 질의를 빠르게 하는 데 유용한 기법이다.
- DuckDB의 현대적 분석 아키텍처는 벡터 표상과 질의 내 병렬성을 활용한다.
- 실행 중에 SQL 질의는 실행 계획으로 바뀌며,
EXPLAIN으로 들여다볼 수 있다. - 클라우드 버킷에 있는 데이터셋은 저장된 곳에 가까운 기계에서 분석해 네트워크 전송 비용과 지연을 피해야 한다.
- DuckDB는 조건 푸시다운과 사영 푸시다운으로 Parquet 파일의 질의를 최적화할 수 있으며, 실제로 데이터베이스를 채우지 않아도 된다.
- 파일을 읽을 때 Parquet 메타데이터를 쓸 수 있다는 점이 대용량 데이터에서 대단히 유용하다. 질의에 필요하지 않은 데이터를 네트워크로 읽는 일을 피하기 때문이다.
SELECT절 안의 컬럼 표현식이 여러 집계를 많은 컬럼에 한꺼번에 적용하게 해준다.- DuckDB는 수억, 심지어 수십억 레코드를 담은 데이터셋도 편안히 질의할 수 있다.
이 장은 성능을 다루면서 단 한 번도 "빠르다"로 끝내지 않는다. 언제나 초와 배수를 적고, 왜 그런지를 붙이고, 공정하지 않은 비교에는 공정하지 않다고 스스로 밝힌다. 행 수 세기의 875배 앞에서 "이것은 공정한 싸움이 아니다"라고 적은 대목, 열거형의 1.4배 앞에서 "이 정도 짧은 실행 시간에서는 덜 중요하다"고 적은 대목이 그렇다.
그래서 이 장에서 걸린 어긋남들도 대체로 계측값 자체가 아니라 그것을 서술한 문장에 있다. 51.120초를 "1분이 넘는다"고 부르고, 2,000만 행을 "2,800만"이라 하고, 비율이 1을 넘는 해가 하나인데 "최근 2년"이라 하고, 평균과 표준편차를 메타데이터로 답한다고 한다. 숫자는 정확하고 문장이 성급하다. 계측값을 인쇄해두었기 때문에 우리가 그것을 확인할 수 있다는 사실이, 역설적으로 이 장의 미덕이다.
그리고 마지막 장에 어울리는 대조가 하나 남는다. 제2장의 227행에서 이 장의 17억 행까지 일곱 자릿수를 건너왔는데, 그동안 도구는 바뀌지 않았다. 같은 단일 바이너리, 같은 FROM 절, 같은 SUMMARIZE다. 달라진 것은 어디에 시간이 흐르는지를 우리가 알게 되었다는 것뿐이다. 그것이 이 장의 제목이 말하는 "고려사항"의 뜻이다.