DuckDB in Action·Manning, 2024· 제10장 정독 · 마지막 장
10 PERFORMANCE CONSIDERATIONS FOR LARGE DATASETS
원서 222–261쪽

노트북 한 대로 17억 행을 상대하는 마지막 장

이 책의 데이터셋은 대체로 작거나 중간 크기였다. 저자들은 그것이 이상한 일이 아니라고 말한다. 일상 업무에서 마주치는 데이터셋을 대표하기 때문이다. 그러나 거대한 데이터셋은 실제로 존재한다. 그래서 이 장의 동기가 한 문장으로 정리된다. 그런 것을 만났을 때 다른 데이터 처리 도구를 써야 한다고 생각하게 만들고 싶지 않다. 소재는 둘이다. 5,800만 게시물의 스택 오버플로 덤프17억 행의 뉴욕 택시 데이터다. 각각을 준비하고 적재하고 질의하고 내보내면서, 어디에서 시간이 흐르고 무엇이 그것을 줄이는지를 계측값으로 보여준다.

이 장이 다루는 것
대용량 데이터를 DuckDB로 들이도록 준비하기 · 메타데이터 질의와 탐색적 데이터 분석(EDA) · 전체 데이터베이스를 동시적으로 Parquet으로 내보내기 · 여러 컬럼에 집계를 걸어 통계 분석 가속 · EXPLAIN과 EXPLAIN ANALYZE로 질의 계획 이해하기
절 구성
10.1 – 10.4 및 하위 절 열여섯 개, 요약
실린 것
목록 43건 · 도판 4점(그중 하나는 화면 갈무리) · NOTE 5건 · 사이드바 1건
계측 환경
10코어 랩톱 · 저자들이 권하는 최소 사양은 4–10코어, 8–64GB RAM
SCALE LADDER · 이 책이 지나온 규모 (로그 눈금) 제2장 · 나라 인구 CSV 227행 제4장 · 태양광 측정값 151,879행 제8장 · ATP 테니스 경기 191만 행 제7장 · 공유된 스택 오버플로 5,833만 행 제10장 · 스택 오버플로 전체 5,833만 게시물 · 11GB 제10장 · 뉴욕 택시 전체 17억 행 10²10³10⁴10⁵10⁶10⁷10⁸10⁹10¹⁰ 제2장의 227행에서 이 장의 17억 행까지, 일곱 자릿수를 건너왔다. 그런데 도구는 바뀌지 않았다. 같은 단일 바이너리, 같은 SQL이다. 17억 행이 28GB에 담긴다. 행당 17.5바이트, 컬럼값 하나당 0.92바이트다. 컬럼 저장과 압축이 무엇을 하는지 이 한 줄이 말해준다. 저자들의 판정은 이렇다. "완전히 '빅데이터'는 아니지만, DuckDB의 진면목을 시험할 만큼은 크다."
덧붙인 그림 1이 책이 지나온 데이터셋의 규모를 한자리에 모았다. 원서에는 이런 그림이 없다.
← 좌우로 밀어 보기 →

어떤 데이터셋은 표 형태로 요약되고, 어떤 것은 차트로 그려진다. 그러나 이 장이 다루는 것은 표도 차트도 아니다. 초와 기가바이트다. 앞의 아홉 장이 "무엇을 할 수 있는가"를 보여주었다면 이 장은 "얼마나 걸리는가"를 보여준다. 그래서 이 장의 본문에는 .timer on이 자주 나오고, 인쇄된 숫자 하나하나가 주장의 근거가 된다.

순서는 두 데이터셋에 대해 같다. 준비하고, 들이고, 질의하고, 이식 가능한 형식으로 내보낸다. 다만 두 번째 데이터셋에서는 마지막 단계가 사라진다. 애초에 데이터베이스를 채우지 않고 Parquet 파일을 그 자리에서 질의하기 때문이다. 그 차이가 이 장의 후반부를 이룬다.

거대한 데이터셋은 실제로 존재한다. 그런 것을 만났을 때 다른 데이터 처리 도구를 써야 한다고 여러분이 생각하게 만들고 싶지 않다. 제10장 도입, 이 장의 동기
이 장에 실린 목록 43건 — 이 책에서 가장 많다
  1. 10.1XML을 JSON을 거쳐 CSV로 변환
  2. 10.2Tags 파일의 메타데이터 서술
  3. 10.3CSV 파일에서 상위 태그 고르기
  4. 10.4태그 빈도를 버킷으로 나누는 질의
  5. 10.5users 테이블 만들기
  6. 10.6posts 테이블 만들기
  7. 10.7컬럼 일부만 SUMMARIZE
  8. 10.8평판 상위 사용자
  9. 10.9일당 평판 증가율 상위 사용자
  10. 10.10막대 차트로 그린 평판 증가율
  11. 10.11연도별 활동량 질의
  12. 10.12SQL 태그 질문의 요일 분포
  13. 10.13Rust 태그 질문의 요일 분포
  14. 10.14열거형 타입 만들기 예제
  15. 10.15tags 테이블에서 태그 열거형 만들기
  16. 10.16열거형 값 몇 개 고르기
  17. 10.17tagNames 컬럼 추가
  18. 10.18tags에서 tagNames 채우기
  19. 10.19tagEnums 컬럼 추가
  20. 10.20tagNames에서 tagEnums 직접 채우기
  21. 10.21java 태그 세기 · 문자열 비교
  22. 10.22java 태그 세기 · 문자열 리스트
  23. 10.23java 태그 세기 · 열거형 리스트
  24. 10.24상위 10 태그 통계 · tags 컬럼
  25. 10.25상위 10 태그 통계 · tagEnums 컬럼
  26. 10.26users를 Parquet으로 내보내기
  27. 10.27posts를 Parquet으로 내보내기
  28. 10.28users를 다중 스레드로 내보내기
  29. 10.29Parquet에서 행 수 읽기
  30. 10.30CSV에서 행 수 읽기
  31. 10.31데이터베이스 전체를 Parquet으로
  32. 10.32schema.sql의 내용
  33. 10.33load.sql의 내용
  34. 10.34S3 비밀 만들기
  35. 10.35httpfs 확장 설치와 적재
  36. 10.36여러 파일에 걸친 뷰 만들기
  37. 10.37SUMMARIZE 명령
  38. 10.38걸러내는 뷰 만들기
  39. 10.39단일 컬럼 요약
  40. 10.40단일 컬럼 집계
  41. 10.41다중 컬럼 집계
  42. 10.42운행 데이터 연도별 집계
  43. 10.43승객 수별 택시 운행
10.1

스택 오버플로 전체를 적재하고 질의하기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로도 된다. 그러나 저자들이 한계를 짚는다. 서비스에 과부하가 걸리지 않도록 실행할 수 있는 질의의 수와 복잡도가 제한되어 있다. 우리는 우리가 돌리는 질의를 더 통제하고 싶으니 원자료가 필요하다.

NOTE — 지루한 부분은 건너뛰어도 된다

저자들이 미리 밝혀둔다. "이것이 과정에서 가장 재미있는 부분이 아님을 알고 있으니, 이 절의 모든 걸음을 따라야 한다고 느끼지 않아도 된다." 최종 표 형태 데이터만 원한다면 S3에서 Parquet 파일을 내려받거나(s3://us-prd-motherduck-open-datasets/stackoverflow/parquet/2023-05/) MotherDuck 공유를 붙이면 된다(md:_share/stackoverflow/6c318917-…).

스택 오버플로 데이터가 너무 크다면 math나 biotechnology 같은 더 작은 스택 익스체인지 커뮤니티를 골라도 된다. 제7장에서 붙여본 그 공유가 여기서 되돌아온다.

shell — 27GB XML을 11GB CSV로 옮기는 길목록 10.1
# 인터넷 아카이브의 스택 익스체인지 덤프. 크리에이티브 커먼즈 라이선스다.
$ 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
예제 파일에 담긴 사용자가 Id 728812의 Michael Hunger, 곧 이 책의 저자 자신이다. 평판 41,063에 업보트 4,712. 자기 데이터를 예제로 내놓는 이 태도가 이 책의 성격을 보여준다.
인터넷 아카이브의 대역폭이 제한되어 있어 내려받기가 답답하고 느린 과정일 수 있고, 접속이 자주 끊길 가능성이 높다. 19GB짜리 파일 하나가 있으니 그럴 만하다.
저자들이 JSON을 곧바로 쓰지 않은 이유를 밝혀두었다. xidel의 JSON 파일을 DuckDB의 내장 기능으로 바로 적재할 수도 있었지만, JSON을 지원하지 않는 다른 도구를 위해 CSV를 갖고 있는 편이 요긴할 수 있다. 게다가 키 이름이 되풀이되지 않으므로 파일이 조금 더 작다.
압축률의 변화가 인상적이다. 27GB의 XML이 11GB의 CSV가 되었고, Posts는 19GB에서 3.2GB로 6분의 1이 되었다. XML은 행마다 속성 이름을 되풀이하는데 CSV는 머리글에 한 번만 적는다. 형식을 바꾸는 것만으로 6배를 줄인 셈이니, 이 장 후반부의 Parquet 논의를 미리 예고하는 대목이다.
← 좌우로 밀어 보기 →
10.1.2

데이터 모델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로 잇는다.

그리고 이 절에서 가장 실무적인 한 문장. 파일에는 인덱스나 외래 키에 관한 어떤 정보도 없다. 그 참조를 우리가 손으로 다시 세워야 한다. 관계형 데이터베이스의 스키마가 아니라 표 일곱 장을 받은 것이며, 그것들을 어떻게 잇는지는 도메인 지식에 달렸다.

Users displayNameaboutMe reputationupVotes · downVotes creationDate · views Posts postTypeId 1 = 질문postTypeId 2 = 답변 title · body · tagsscore · viewCount answerCount · commentCountacceptedAnswerId parentId · ownerId Tags tagName · count excerptPostId · wikiPostId Comments text · score creationDate · userId Votes voteTypeId · creationDate PostLinks postId · relatedPostId Badges class · date · tagBased ownerIduserId tagNamepostId postIdpostId parentId · acceptedAnswerId Posts 하나가 질문도 답변도 된다. 답변은 parentId로 질문을 가리키고, 질문은 acceptedAnswerId로 채택 답변을 가리킨다.
〈도판 10.2〉스택 오버플로 데이터 모델. 원서 그림의 구성을 옮겨 새로 작도했다. 원서 〈도판 10.1〉은 사이트 화면 갈무리이므로 재현하지 않았다.
← 좌우로 밀어 보기 →

관계를 정리하면 이렇다. Post(질문 또는 답변)는 ownerId로 그것을 쓴 User에 연결된다. Comment와 Vote와 Answer는 postId로 원래 Post를 가리킨다. 채택된 Answer-Post는 Question-Post에서 acceptedAnswerId로 이어진다. Badge는 userId로 User에, PostLink는 postIdrelatedPostId로 두 Post를 잇는다.

〈도판 10.1〉 · 화면 갈무리

원서의 이 도판은 사용자의 질문과 채택된 답변이 보이는 스택 오버플로 사이트의 화면 갈무리다. 데이터 모델의 각 필드가 화면의 어느 자리에 나타나는지 대조해보게 하려는 것이다. 화면 갈무리는 이 문서에서 재현하지 않고 서술로 대신한다.

10.1.3

CSV 파일 데이터 탐색하기Exploring the CSV file data

데이터를 준비했으니 익숙한 영역으로 돌아왔다. DuckDB의 read_csv 함수로 압축된 gzip CSV 파일에서 데이터를 곧바로 적재할 수 있다. 그리고 read_csv가 컬럼 타입을 자동으로 추론하려 하는데, 저자들이 스택 오버플로 데이터셋에서 잘 작동한다고 확인했다.

duckdb — 압축 파일을 풀지 않고 곧바로 묻는다목록 10.2 · 10.3
D SELECT count(*) FROM read_csv('Tags.csv.gz');   -- 6만 5천 개에 조금 못 미친다

D DESCRIBE(FROM read_csv('Tags.csv.gz'));
column_namecolumn_type
varcharvarchar
IdBIGINT
TagNameVARCHAR
CountBIGINT
ExcerptPostIdBIGINT
WikiPostIdBIGINT
TagName과 Count가 가장 인기 있는 태그를 알아내는 데 필요한 필드다. 저자들의 평이 담백하다. "압축된 큰 파일에서도 그저 완벽하게 빠르게 도는 질의"다.
D SELECT TagName, Count
  FROM read_csv('Tags.csv.gz', column_names=['Id', 'TagName', 'Count'])
  ORDER BY Count DESC LIMIT 5;
TagNameCount
varcharint64
javascript2479947
python2113196
java1889767
c#1583879
php1456271
제7장 7.3.6절에서 MotherDuck의 AI가 자연어 질문에 답으로 내놓았던 그 숫자들과 한 자리도 다르지 않다. 같은 데이터셋이므로 당연하지만, 세 장을 건너 같은 값이 되돌아오는 것을 확인하는 재미가 있다. 그때는 LLM이 만든 SQL이 무엇인지 되물어야 했는데, 여기서는 우리가 직접 쓴다.
← 좌우로 밀어 보기 →
duckdb — 10의 거듭제곱으로 버킷을 나눈다목록 10.4
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;
112를 예로 든 여섯 걸음이 본문에 적혀 있다. 각 개수의 10을 밑으로 한 로그를 구한다(log(112)/log(10) = 2.049). 그것이 개수의 자릿수 규모를 준다. 정수 부분을 얻는다(floor(2.049) = 2.0). 다시 10의 거듭제곱으로 되돌린다(pow(10,2.0) = 100.0). 결과로 원래 값을 10의 거듭제곱 단위로 얻는다. 그룹화를 위해 정수로 캐스팅한다.
bucketcount_star()분포
int32int64— 이 문서에서 더한 열
16238█████
1023018███████████████████
10023842████████████████████ ← 최빈
10009126███████
100001963
100000252·
100000025·
합계 64,464 · 태그 총수 약 65,000 → 사용 횟수 0인 태그가 약 500개
결과가 거듭제곱 법칙 분포를 따른다. 쓰임이 적은 태그의 수가 많고, 개수가 큰 태그는 적어서 100만 번 넘게 쓰인 태그는 스물다섯 개로 줄어든다.
본문은 "유일한 예외는 한 번만 언급된 드문 태그이며, 예상대로 그 수가 더 적다"고 적었다. 그런데 표를 보면 최빈 버킷은 1이 아니라 100이고, 버킷 10(23,018)도 버킷 100(23,842)보다 적다. 곧 왼쪽 끝에서 꺾이는 지점이 하나가 아니라 둘이다. 차이가 3.5%로 작아 눈에 잘 띄지 않지만, 순수한 거듭제곱 법칙이라면 단조 감소해야 한다.
log(Count)/log(10)의 나눗셈이 DuckDB에서는 아무 일도 하지 않는다. DuckDB의 log상용로그이므로 log(10)은 1이다. 본문의 계산 예시(log(112)/log(10) = 2.049)도 상용로그를 전제하고 있으니 스스로 그것을 확인해준다. 자연로그를 쓰는 다른 데이터베이스로 옮길 때를 대비한 방어적 표기로 읽을 수는 있으나, DuckDB에서는 log(Count)만으로 충분하다.
← 좌우로 밀어 보기 →
10.1.4

데이터를 DuckDB로 적재하기Loading the data into DuckDB

길이 둘이다. 테이블을 먼저 만들고 데이터를 들이거나, 데이터를 읽으면서 테이블을 즉석에서 만드는 것이다. 전자가 더 명시적이고 컬럼 이름과 타입을 정의하게 해주지만, 미리 데이터의 스키마를 알고 적어내야 한다. 그리고 파일 구조나 컬럼 타입이 바뀌면 CREATE TABLE 문도 고쳐야 하며, 그러지 않으면 적재가 실패한다.

저자들이 CREATE OR REPLACE TABLE을 쓰는 이유도 실무적이다. 시험을 위해 사이에 테이블을 지우지 않고도 적재를 여러 번 돌릴 수 있게 하려는 것이다. 여기서는 후자를 택한다. 관련 컬럼 이름을 고르고, 타입은 CSV를 읽으면서 추론되게 하고, "거기 있는 것"을 얻는다.

duckdb — users와 posts 들이기목록 10.5 · 10.6
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
NOTE Tags 컬럼은 텍스트 컬럼이며 최대 여섯 개의 스택 오버플로 태그를 꺾쇠괄호로 감싸 담는다. 예컨대 <sql><performance><duckdb>다. 이 표기가 10.1.7절 전체의 발단이 된다.
컬럼 이름을 스무 개 주었는데 만들어진 테이블은 열아홉 개다. 사라진 것은 표시해둔 ParentId이며, 출력의 마지막 줄이 19 rows라고 못 박는다. 그리고 이것이 조판 실수가 아님을 같은 장의 목록 10.32가 확증한다. 내보낸 schema.sqlCREATE TABLE posts에도 ParentId가 없다.
무게가 가볍지 않은 누락이다. ParentId는 답변이 자기 질문을 가리키는 컬럼이며, 〈도판 10.2〉가 그 관계를 화살표로 그려놓았다. 이 컬럼이 없으면 답변과 질문을 잇는 조인을 쓸 수 없다. 추출 단계(목록 10.1의 Posts 판본)에서 그 속성을 뽑지 않았을 가능성이 크니, 답변–질문 관계를 분석하려는 독자는 추출 명령의 속성 목록부터 확인해야 한다.
column_namecolumn_typemaxapprox_uniqueavg
varcharvarcharvarcharvarcharvarchar
IdBIGINT213348252011333711027766.241
ReputationBIGINT13892562691994.752717160
CreationDateTIMESTAMP2023-03-0519557978
ViewsBIGINT2214048745211.630429738
UpVotesBIGINT59128662278.7674283438
DownVotesBIGINT148634129301.1697560125
제4장에서 배운 SUMMARIZE를 컬럼 일부에만 걸었다(목록 10.7). 모든 컬럼에 걸면 몇 초가 걸리고 출력이 거대해진다. 컬럼이 많고 SUMMARIZE가 지표를 많이 계산하기 때문이다. 복잡한 질의를 쓰지 않고도 이 통계를 얻는다는 것이 이 절의 요령이다.
읽어볼 만한 값이 있다. 평판의 평균이 94.75인데 최대가 138만이다. 고유값 근사가 26,919에 불과한 것도 눈에 걸리는데, 2,000만 사용자의 평판이 스물일곱 가지쯤의 값에 몰려 있다는 뜻이다. 대부분이 1이나 한 자리 수라는 이야기다.
MotherDuck 공유를 붙이는 명령에 백틱 하나가 잘못 남아 있다. ATTACH 'md:_share/stackoverflow/…' AS stackoverflow`; 복사해 붙이면 구문 오류가 난다. 백틱을 지우면 된다.
← 좌우로 밀어 보기 →
10.1.5

큰 테이블에서 빠른 탐색 질의Fast exploratory queries on large tables

이 절의 설정이 구체적이다. 우리가 스택 오버플로 분석가이고, 상위 사용자가 누구이며 그들이 여전히 활동 중인지 확인하고 싶다. 아니라면 플랫폼으로 돌아오도록 설득할 방법을 생각해낼 수 있을지도 모른다. 데이터를 왜 보는지가 있으면 질의가 자연스럽게 따라 나온다.

duckdb — 2,000만 행을 0.126초에 정렬한다목록 10.8 · 10.9
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
DisplayNameReputationLastAccessDate
varcharint64timestamp
Jon Skeet13892562023-03-04 19:54:19.74
Gordon Linoff12283382023-03-04 15:16:02.617
VonC11944352023-03-05 01:48:58.937
BalusC10691622023-03-04 12:49:24.637
Martijn Pieters10167412023-03-03 19:35:13.76
분석할 행이 2,000만인데 0.126초에 끝난다. 사용자 시간이 2.97초, 시스템 시간이 1.70초인 것이 여러 스레드가 동시에 일했다는 증거다. 벽시계 시간보다 CPU 시간이 크면 병렬 실행이 있었다는 뜻이다.
스택 오버플로에 익숙하다면 전설적인 Jon Skeet이 첫자리인 것에 놀라지 않을 것이다. 그런데 저자들이 곧바로 조건을 붙인다. "Jon은 스택 오버플로를 오랫동안 써왔으니 그 평판을 쌓을 시간이 넉넉했다." 총량이 아니라 속도로 보면 어떻게 되는가. 그것이 다음 질의다.
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
DisplayNamereputationratedays막대 (bar 150–300, 폭 35)
varcharint64doubleint64varchar
Gordon Linoff1228338294.04181█████████████████████████████████
Jon Skeet1389256258.05383█████████████████████████
VonC1194435221.05396████████████████
BalusC1069162211.05058██████████████
T.J. Crowder1010006200.05059███████████
Martijn Pieters1016741197.05164██████████
Darin Dimitrov1014014189.05360█████████
Marc Gravell1009857188.05380████████
Jon이 2위로 내려앉고 Gordon Linoff가 첫자리를 차지한다. Gordon은 4,181일 만에 122만을 쌓았고 Jon은 5,383일에 138만이다. 여덟 사람의 값을 모두 검산해보면 인쇄된 rate가 나눗셈과 정확히 맞는다. 예컨대 1228338 ÷ 4181 = 293.79 → 294이고, 1009857 ÷ 5380 = 187.71 → 188이다.
저자들이 붙인 해석이 이 데이터의 성질을 말해준다. "스택 오버플로의 평판은 사람들이 과거 답변에 업보트할 때도 쌓인다. 여러분이 준 답변이 유용할수록 시간이 흐르며 사람들이 업보트할 가능성이 높다." 곧 평판은 재고가 아니라 이자가 붙는 자산이다.
bar(rate,150,300,35)값과 최소·최대와 폭을 받아 검은 블록으로 그린 문자열을 돌려준다(목록 10.10). 읽기 쉽게 만들기 위해 기존 질의를 공통 테이블 표현식(CTE)으로 감싸고 바깥 질의에서 bar를 썼다. 제3장에서 배운 CTE가 여기서는 가독성을 위한 도구로 쓰인다.
1_000_000처럼 숫자 리터럴에 밑줄을 넣어 자릿수를 끊는 표기가 조용히 등장한다. 이 책이 앞서 소개하지 않은 DuckDB 편의 기능인데, 백만을 눈으로 확인해야 하는 자리에서 값을 한다.
← 좌우로 밀어 보기 →
duckdb — 5,800만 행의 연도별 활동량목록 10.11
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 …  (두 번째 실행)
yearpostMquestionManswerMratioavgViewCountmaxAnswers
int64doubledoubledoubledoubledoubleint64
20230.530.270.261.0344.015
20223.351.611.740.93265.044
20213.551.552.00.78580.065
20204.311.872.440.77847.059
20194.161.772.390.741190.060
20184.441.892.550.741648.0121
20175.022.112.90.731994.065
20165.282.23.070.722202.074
20155.352.23.140.72349.082
20145.342.133.190.672841.092
5,800만 행에 걸친 통계인데 첫 실행 5.977초, 두 번째 실행 0.039초다. 저자들의 설명은 "디스크에서 데이터가 적재된 뒤"라는 것이다. 곧 운영체제의 페이지 캐시가 일한 것이며, 150배 차이가 난다. 성능을 재려면 어느 실행을 재는지부터 정해야 한다는 교훈이 여기 있다.
읽어낸 것들. 2023년 데이터는 완전하지 않으므로 그해 전체를 비교할 수 없다. 게시물 총수는 2014년부터 꾸준히 줄고 있다. 새 게시물의 조회 수가 적은 것은 존재한 기간이 상대적으로 짧기 때문이고, 게시물당 최대 답변 수가 해가 갈수록 조금씩 줄어드는 것도 오래된 질문이 답을 모을 시간이 더 많았기 때문이다. 숫자의 방향을 데이터의 나이로 설명해내는 이 절제가 좋다.
다만 한 문장이 표와 어긋난다. 본문은 "질문 대 답변의 비율도 나빠져서, 최근 2년은 답변보다 질문이 더 많다"고 적었다. 비율이 1을 넘으면 질문이 더 많다는 뜻인데, 표에서 1을 넘는 해는 2023년(1.03) 하나뿐이다. 2022년은 0.93으로 여전히 답변이 더 많다. 게다가 그 하나뿐인 2023년은 본문 자신이 "완전하지 않다"고 밝힌 해다. 추세가 나빠지고 있다는 진단은 표가 뒷받침하지만, "최근 2년"이라는 범위는 뒷받침하지 않는다.
postMquestionM + answerM을 맞춰보면 2014년만 0.02 어긋난다(5.34 vs 5.32). 각 값을 독립적으로 반올림한 뒤 더했기 때문이며, 제4장 4.4절에서 본 것과 같은 현상이다. 반올림은 마지막에 한 번만 하는 편이 안전하다.
← 좌우로 밀어 보기 →
10.1.6

평일에 글을 올리는가Posting on weekdays

물음이 소박하고 좋다. 사람들은 직장에서 질문에 답하려고만 플랫폼을 쓰는가, 아니면 주말에도 쓰는가. 저자들은 이 질문을 먼저 던진 사람의 분석을 재현해보려 한다고 밝히며 출처를 댄다.

살펴본 김에

본문은 그 사람의 이름을 "Evalina Gabova"로 적고 다시 "Evalina의 분석"이라고 부른다. 그런데 함께 실린 주소는 evelinag.com이다. 이름의 철자가 주소와 어긋난다. 주소를 믿는다면 Evelina가 맞는 표기다. 남의 분석을 재현하는 절에서 그 사람의 이름이 틀리는 것은 아쉬운 일이니, 인용할 때는 주소를 따라가 확인하는 편이 좋다.

duckdb — SQL과 Rust, 두 태그의 요일 분포목록 10.12 · 10.13
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만 건 처리)
freqdayplot — SQLfreqplot — Rust
int64varcharbar(0–150000, 20)int64bar(0–10000, 20)
119825Wednesday███████████████5205██████████
119514Thursday███████████████5160██████████
115575Tuesday███████████████5167██████████
103937Monday█████████████5054██████████
103445Friday█████████████5009██████████
47390Sunday██████4784█████████
47139Saturday██████4667█████████
SQL 656,825건 · Rust 35,046건 → 18.7배 · 주말 낙폭 SQL 58.0% vs Rust 7.7%
SQL 질문은 대부분 평일, 특히 주중에 올라오고 주말에 가장 적다. 저자들의 해석은 "사람들이 주로 일 때문에 SQL을 다루고 근무 시간에 질문한다는 것을 시사할 수 있다"는 것이다. 2,350만 건의 질문을 처리하는 데 0.3초가 걸렸다.
Rust는 회사에 SQL만큼 뿌리내리지 않은 비교적 새로운 언어다. 그 질문은 주중에 고르게 분포하고 주말에 약간 떨어질 뿐이다. 두 표를 나란히 놓고 재보면 대비가 선명해진다. SQL은 평일 평균 112,459건에서 주말 평균 47,264건으로 58.0% 떨어지는데, Rust는 5,119건에서 4,726건으로 7.7%만 떨어진다. 업무로 쓰는 언어와 취미로 배우는 언어가 요일 분포에서 갈린다는 이야기다.
저자들이 마지막에 덧붙인 한 줄. "Rust에 대한 질문이 SQL보다 10배에서 20배 적다는 점도 짚어두어야 한다." 두 표의 합계로 재보면 18.7배이니 범위 안에 들어온다. bar의 최대값을 150,000과 10,000으로 다르게 준 것도 그래서다. 같은 눈금으로 그리면 Rust 막대가 보이지 않는다.
← 좌우로 밀어 보기 →
10.1.7

태그에 열거형 쓰기Using enums for tags

이 절의 전제가 이 장 전체의 주제를 압축한다. 더 큰 데이터셋에서 DuckDB를 쓸 때는 작은 데이터셋에서라면 필요하지 않았을 최적화를 택하게 되는 일이 있다. 그 예가 게시물에 배정된 태그 이름이 저장되고 처리되는 방식이다.

DuckDB에는 열거형(enum) 타입이 있다. 이름 붙은 값의 고정된 집합을 표상하며 내부적으로는 정수로 저장된다. 그래서 많은 양의 문자열 값보다 저장과 처리에 효율적이다.

문제는 이렇다. tags 테이블이 따로 있지만 게시물 안에도 최대 여섯 개의 태그가 저장되며, 각 태그가 꺾쇠괄호로 감싸여 있다. <sql><duckdb><performance>는 그 게시물이 sql, duckdb, performance 태그를 갖는다는 뜻이다. 이것은 분석 질의에 특히 효율적이지 않고, 여러 태그로 게시물을 찾기 어렵게 만든다.

duckdb — 열거형을 만들고 게시물에 심는 여섯 걸음목록 10.14 – 10.20
-- 목록 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;
마지막 걸음이 이 절에서 가장 기분 좋은 대목이다. 문자열 배열을 열거형 배열에 대입하면 DuckDB가 값을 열거형 타입으로 자동 캐스팅한다. 저자들의 감탄이 짧고 정직하다. "그것이 정말 도움이 되고 사용자 친화적이다." 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'이고 그 값은 열거형에 없으므로 캐스팅이 실패한다. 변환한 값을 열거형에 담으려면 변환된 값으로 만든 별도의 열거형 타입이 먼저 있어야 한다.
← 좌우로 밀어 보기 →

문자열 배열을 남겨둔 이유가 실험을 위해서다. 문자열 리스트와 열거형 리스트의 동작과 성능을 견줄 수 있다. 아래는 이 장에 인쇄된 계측값을 한자리에 모은 장치다. 여섯 가지 대조를 눌러보면, 무엇을 바꿀 때 얼마가 줄어드는지가 눈에 들어온다. 배수는 원서의 숫자에서 계산한 것이다.

무엇을 바꿨는가
왜 빨라지는가
← 좌우로 밀어 보기 →

저자들이 이 절을 맺는 판단이 이 장에서 가장 값진 문장이다. 열거형이 단순한 카운트에서는 차이가 무시할 만하지만 리스트의 값 여럿을 다루는 복잡한 연산에서는 표상의 변화가 문제가 된다. 그리고 그 대가를 분명히 적는다.

이런 종류의 최적화에는 준비와 저장 양쪽에서 미리 더 많은 노력을 들여야 한다. 분석 질의를 여러 번 돌릴 때는 그럴 값어치가 있고, 한 번뿐인 작업이라면 덜하다. 10.1.7절을 맺는 문단

여기에 한 문장이 더 붙는데, 제8장을 읽은 독자에게는 무게가 다르게 다가온다. 준비 작업도 질의에 맞는 데이터 형태를 보장하기 위해 데이터 처리 파이프라인에 통합되어야 한다.열거형 변환은 손으로 한 번 하는 일이 아니라 dbt 모델이나 Dagster 애셋이 되어야 한다는 뜻이다. 최적화가 코드가 되고, 코드가 파이프라인이 된다.

10.2

질의 계획과 실행Query planning and execution

이 장에서 다루는 큰 데이터셋에서는 효율적인 질의 실행이 어느 때보다 중대하다. DuckDB의 질의 실행 엔진은 현대적 하드웨어와 최신 데이터베이스 연구·구현 기법을 써서 빠르고 효율적이도록 설계되었다. 이 절은 그 내부를 열어 보인다.

계획기와 최적화기 10.2.1

DuckDB가 Postgres에서 파생된 유연한 파서로 SQL 질의를 파싱하면, 그 결과인 추상 구문 트리(AST)를 여러 단계에 걸쳐 변환한다. 파스 단계에서는 잘못 쓴 키워드나 빠진 괄호 같은 구문 오류를 잡아낼 수 있다.

  • 바인더 binder — 테이블, 뷰, 타입, 컬럼 이름 같은 요소를 해소한다. 쓰인 요소가 데이터베이스에 존재하는지, 올바르게 쓰였는지 검사한다.
  • 계획 생성기 plan generator — 그것을 스캔·필터·사영 같은 논리 질의 연산자로 이루어진 기본 논리 질의 계획으로 바꾼다.
  • 최적화기 optimizer — 계획 과정에서 저장된 데이터와 인덱스의 통계를 쓴다. 그것이 타입 변환, 조인 순서 최적화, 부질의 평탄화 같은 여러 연산을 돕는다.
  • 물리 계획기 physical planner — 논리 계획을 통계와 캐싱과 그 밖의 요인을 고려해 환경에 가장 알맞은 물리 연산으로 다듬는다.

부질어 평탄화(subquery flattening)가 목록에 있는 것이 반갑다. 제4장 4.3절에서 상관 부질의가 어떻게 조인으로 풀리는지 보았는데, 그것을 하는 자리가 바로 여기다.

파서 계획기 최적화기 물리 계획기 SQL Statement 최적화되지 않은논리 계획 최적화된논리 계획 물리 계획 최적화기 안에서 조인 순서 최적화와 부질의 평탄화가 일어난다. 제4장의 상관 부질의가 조인으로 풀리는 자리가 여기다.
〈도판 10.3〉질의 처리 파이프라인. 원서 그림의 구성을 옮겨 새로 작도했다.
← 좌우로 밀어 보기 →
10.2.2

런타임과 벡터화Runtime and vectorization

DuckDB의 런타임은 컬럼 저장이라는 본성에 기반한 벡터화·병렬화 아키텍처로 작동한다. 저장 형식은 데이터를 행 그룹(row group)—곧 데이터의 수평 분할—에 담는다. 수평 분할은 데이터를 샤딩하는 전략이며, 각 분할이 같은 스키마를 가지고 데이터의 특정 부분집합을 담는다. 그리고 숫자가 하나 나온다. DuckDB 데이터베이스 형식의 행 그룹 하나는 최대 122,880행으로 이루어진다.

122,880행 그룹 하나의 최대 행 수

수평 분할의 단위. 각 행 그룹이 컬럼마다 필요한 정보를 담는다.

2,048모설 하나의 값 개수

병렬 파이프라인을 통과하는 값 덩어리의 배치 크기다.

60행 그룹당 벡터 개수

122,880 ÷ 2,048 = 정확히 60. 저장의 단위와 실행의 단위가 딱 맞물린다. 이 문서에서 계산한 값이다.

컬럼 중심 접근이 주는 이점을 저자들이 세 가지로 짚는다. 컬럼을 고르거나 데이터를 걸러내고 스캔하고 정렬할 때 특히 유리하고, CPU가 한 연산자의 처리를 메모리 안에 유지하게 해주며, CPU 분기 예측을 최적화하고 필요한 데이터를 모두 CPU 캐시에 두게 해준다.

그리고 행 기반 엔진과의 근본적인 차이를 한 문장으로 규정한다. DuckDB는 디스크 저장이나 데이터 전송(I/O) 최적화가 아니라 효율적인 데이터 연산에 맞춰 미세 조정되어 있다. 어디에 힘을 쏟았는지를 밝히는 문장이며, 제7장에서 클라우드 업로드가 70MB/s로 묶였던 것과 나란히 놓고 볼 만하다.

실행 런타임에서 모든 데이터 타입이 벡터, 곧 값의 압축된 배열로 표상된다. 이 타입별 벡터 구현은 숫자·문자열·배열 같은 여러 데이터 타입과 값에 맞게 최적화되어 있고, 압축과 메타데이터와 추가 인덱스를 써서 데이터 선택과 처리를 단순하게 만든다.

데이터가 시스템을 흐를 때 이 벡터들은 푸시 기반(push-based)으로 계획 연산자 사이를 매끄럽게 옮겨간다. 실행 모델의 중심은 파이프라인 설계이며, 연산자가 원천(source)이거나 싱크(sink)이거나 둘 다일 수 있다. 그리고 병렬화는 모설(morsel) 접근으로 이루어진다. 값의 덩어리—2,048개 값의 배치—를 여러 병렬 파이프라인으로 처리하며, 각 파이프라인의 시작과 끝에 병렬성을 인식하는 연산자를 둔다.

MORSEL RUNTIME · 파이프라인 셋과 스레드 셋 0 TableScan 1 Join, Filter 2 Join, Filter, Projection Thread A Thread B Thread C TableScan Join+Filter Join+Filter +Projection 작은 사각형 하나가 모설 하나, 곧 값 2,048개의 배치다. 스레드마다 다른 파이프라인 단계를 동시에 밀어낸다. SIMD와 데이터 벡터는 다른 것이다. 전자는 CPU 명령 하나로 여러 값을 처리하는 기법이고, 후자는 연산자 사이를 흐르는 값의 묶음이다. 저자들이 "혼동하지 말라"고 못 박아둔 구분이다.
〈도판 10.4〉모설 런타임. 원서 그림의 구성을 옮겨 새로 작도했다. 모설을 표상하는 작은 사각형은 이 문서에서 덧붙였다.
← 좌우로 밀어 보기 →

마지막에 저자들이 혼동을 미리 막는다. DuckDB는 벡터화 계산도 쓰는데, 그것은 단일 명령 다중 데이터(SIMD)를 써서 하나의 CPU 명령으로 여러 값을 처리하는 것이다. 이것을 데이터 벡터와 혼동해서는 안 된다. 같은 "벡터"라는 낱말이 두 층위에서 다른 것을 가리키므로, 이 한 문장이 없으면 독자가 헷갈릴 자리다.

10.2.3

EXPLAIN과 EXPLAIN ANALYZE로 계획 들여다보기Visualizing query plans with Explain and Explain Analyze

최적화기와 계획기가 만든 질의 계획을 우리도 볼 수 있다. 어떤 SQL 문 앞에 EXPLAIN을 붙이면 원래 질의가 변환된 연산자의 트리가 나온다. 대응은 이렇다.

  • ORDER BY와 LIMITTOP_N — 전체를 정렬하지 않고 상위 N개만 유지한다.
  • SELECTPROJECTION
  • GROUP BYPERFECT_HASH_GROUP_BY
  • SELECT + FROMSEQ_SCAN. 추정 카디널리티(EC)가 함께 붙는다.

그리고 읽는 방향을 알려준다. 계획은 아래에서 위로 실행된다. 저장된 데이터의 SEQ_SCAN에서 시작해, 앞선 연산자의 결과 덩어리에 연산자를 적용해 나간다.

duckdb — EXPLAIN ANALYZE, 아래에서 위로 읽는다10.2.3
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;
연산자 (위 → 아래)하는 일내보낸 행시간
아래에서 위로 실행된다rowss
TOP_NTop 10 · year DESC100.00
PROJECTIONcount_star() · round(avg) · max160.00
PROJECTION__internal_decompress_integral_bigint(#0, 2008)160.00
PERFECT_HASH_GROUP_BY#0 · count_star() · avg(#1) · max(#2)160.55
PROJECTION__internal_compress_integral_utinyint(year(CreationDate), 2008)583293560.39
SEQ_SCANposts · CreationDate, ViewCount, AnswerCount · EC: 81865857583293560.98
연산자 시간 합 1.92s · 벽시계 real 0.199s → 9.6배 차이
본문은 압축·해제 연산자를 "데이터 압축과 해제에 쓰이는 내부 연산자"라고만 지나가는데, 이 두 줄이 이 계획에서 가장 흥미로운 자리다. __internal_compress_integral_utinyint(year(CreationDate), 2008)연도에서 2008을 빼 1바이트 정수(utinyint)로 좁힌다. 스택 오버플로가 2008년에 만들어졌으니 모든 연도가 0에서 15 사이의 오프셋이 된다.
그리고 그 압축이 바로 위 연산자의 이름을 결정한다. 키의 정의역이 1바이트 범위로 좁아지면 충돌 없는 완전 해시 테이블을 쓸 수 있다. 그래서 그냥 HASH_GROUP_BY가 아니라 PERFECT_HASH_GROUP_BY다. 압축이 저장 공간을 줄이는 데서 그치지 않고 알고리즘의 선택을 바꾼 것이며, 이것이 컬럼 저장 엔진의 진짜 이점이다. 결과 행이 16인 것도 2008년부터 2023년까지 열여섯 해와 정확히 맞는다.
연산자별 시간을 그냥 더하면 안 된다. 0.98 + 0.39 + 0.55 = 1.92초인데 벽시계 시간은 0.199초다. 9.6배 차이가 나는 것은 그 시간들이 여러 스레드에 걸쳐 누적된 CPU 시간이기 때문이다. 본문이 이 점을 짚지 않으니, EXPLAIN ANALYZE의 숫자를 순차적 소요 시간으로 읽으면 오해한다.
추정 카디널리티(EC) 81,865,857 대 실제 58,329,356이다. 최적화기가 1.40배 과대추정했다. 이 정도 오차로도 계획이 옳게 나왔지만, 조인 순서가 걸린 질의라면 추정 오차가 계획을 뒤집을 수 있다. EC와 실제 행 수를 나란히 보는 것이 EXPLAIN ANALYZE를 읽는 첫 요령이다.
저자들이 이 도구의 쓸모를 정리해둔다. 특히 느린 질의에서 어느 연산자가 가장 많은 시간을 쓰고 어느 것이 가장 많은 중간 행을 돌려주는지 알아내는 데 도움이 된다. 그리고 그 정보로 인덱스를 더하거나, 질의 힌트를 주거나, 부질의를 쓰거나, 연산 순서를 바꿔 최적화할 수 있다.
← 좌우로 밀어 보기 →
10.3

스택 오버플로 데이터를 Parquet으로 내보내기Exporting the Stack Overflow data to Parquet

테이블을 보관과 더 쉬운 저장, 다른 방식의 처리를 위해 Parquet 파일로 내보낼 수 있다. 저자들이 근거를 되짚는다. 컬럼 형식인 Parquet은 더 잘 압축되고, 스키마를 포함하며, 컬럼 선택과 조건 푸시다운으로 최적화된 읽기를 지원한다. 이 절이 확인하려는 것은 셋이다. 내보내기가 얼마나 걸리는가, 어떤 최적화를 적용할 수 있는가, 데이터베이스 전체를 어떻게 내보내는가.

지원되는 압축 형식은 셋이다. UNCOMPRESSED, SNAPPY, ZSTD. 그리고 이 절의 예제는 모두 SNAPPY를 쓴다. 그 선택이 뒤에서 뜻밖의 결과를 낳는다.

duckdb — 내보내고, 병렬로 내보내고, 전부 내보낸다목록 10.26 – 10.33
-- 목록 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';
NOTE 직렬 내보내기가 테이블마다 10초에서 60초 걸렸고, 저자들은 이것을 단일 스레드에서 약 70MB/s의 쓰기 출력이라고 환산한다. 검산해보면 users는 734MB ÷ 10.582s = 71.0MB/s, posts는 4.0GB ÷ 57.314s = 71.5MB/s다. 두 테이블에서 같은 수치가 나오니 환산이 옳다.
다중 스레드로 쓰면 DuckDB가 스레드마다 파일 하나를 만든다. 10코어 시스템에서 users는 10초에서 1.7초로, posts는 57초에서 11초로 나아졌다. 배수로는 6.2배와 5.2배이니 10코어 대비 병렬 효율이 62%와 52%다. 완전한 선형 확장은 아니지만, 쓰기 작업에서 이 정도면 충분히 좋다.
목록 10.28이 정렬을 먼저 하는 이유도 밝혀두었다. Parquet 파일을 읽을 때 정렬된 필드의 질의가 더 빨라질 수 있기 때문이다. 기제는 제5장에서 배운 것이다. 각 행 그룹의 최소·최대 통계조건에 맞지 않는 행 그룹을 아예 건너뛴다. 정렬되어 있으면 그 최소·최대 구간이 좁아지므로 건너뛸 수 있는 행 그룹이 늘어난다.
행 수를 세는 데 Parquet은 0.008초, gzip CSV는 약 7초가 걸린다. 875배다. 그런데 저자들이 곧바로 정직하게 덧붙인다. "이것은 공정한 싸움이 아니다. Parquet 파일은 전체를 스캔하지 않고 메타데이터로 답할 수 있기 때문이다." 도구의 우위를 자랑하면서 그 우위가 어디서 오는지 밝히는 이 태도가 이 책의 미덕이다.
본문이 users 테이블을 두고 "2,800만 행"이라고 적는데, 목록 10.29가 방금 19,942,787을 돌려주었고 10.1.4절도 "대략 2,000만"이라고 했다. 같은 "2,800만"이 10.1.7절에도 나와 게시물 수를 가리키는데, 거기서도 질문은 2,350만이고 전체 게시물은 5,833만이니 어느 쪽도 아니다. 같은 잘못된 수치가 두 자리에 등장한다.
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절에서 공들여 더한 tagNamestagEnums 컬럼도 없다. posts 테이블이 열아홉 컬럼 그대로다. 내보내기를 그 절 이전 상태에서 했거나, 열거형이 실제로는 내보내지지 않는다는 뜻이다. 어느 쪽이든 "열거형이 만들어진다"는 설명은 실린 출력과 어긋난다.
← 좌우로 밀어 보기 →
살펴본 김에 — Parquet이 더 잘 압축된다는 말을 파일 크기로 확인해보면

이 절은 "컬럼 형식인 Parquet은 더 잘 압축된다"는 전제에서 출발한다. 그런데 이 장에는 같은 일곱 테이블의 크기가 두 번 실려 있다. 10.1.1절의 gzip -9로 누른 CSV와, 이 절의 SNAPPY로 누른 Parquet이다. 두 목록을 나란히 놓고 더해보면 결과가 전제를 뒤집는다.

덧붙인 표  원서 두 곳에 흩어진 파일 크기를 한자리에 모아 대조했다. 원서에는 이런 표가 없다.
테이블CSV.gz (10.1.1절)Parquet SNAPPY (10.3절)변화
Comments5.0 GB6.9 GB+38.0%
Posts3.2 GB4.0 GB+25.0%
Votes1.6 GB2.2 GB+37.5%
Users613 MB734 MB+19.7%
Badges452 MB518 MB+14.6%
PostLinks137 MB164 MB+19.7%
Tags1.1 MB1.6 MB+45.5%
합계11.00 GB14.52 GB+31.9%

일곱 테이블이 하나도 빠짐없이 커졌고, 합계로는 32% 커졌다. 한두 테이블의 예외가 아니라 전면적이다.

이유는 압축 형식의 성격에 있다. SNAPPY는 압축률이 아니라 속도에 최적화된 코덱이고, 비교 대상인 CSV는 gzip -9, 곧 가장 센 설정으로 눌린 것이다. 저자들이 앞 절에서 그 옵션을 직접 썼다. 애초에 공정한 비교가 아니다.

그러니 "Parquet이 더 잘 압축된다"는 말은 같은 급의 코덱을 쓸 때 성립한다. 이 절이 지원 형식으로 열거한 ZSTD를 쓰면 사정이 달라질 것이다. 이 대조가 알려주는 것은 Parquet이 나쁘다는 것이 아니라, Parquet의 이점은 크기가 아니라 다른 데—스키마 내장과 메타데이터 통계와 컬럼 단위 읽기—에 있다는 사실이다. 그리고 그 이점은 같은 절의 875배가 이미 증명해두었다.

이 스택 오버플로 예제에서 보듯 중간 규모의 데이터셋은 DuckDB에게 아무 문제가 아니다. 현대적 랩톱이나 데스크톱의 메모리와 CPU 자원을 가진 단일 기계에서 매우 합리적인 시간에 들이고 질의하고 처리하고 내보낼 수 있다. 10.3절을 맺는 문단 · 저자들이 든 사양은 4–10코어, 8–64GB RAM

그리고 다음 절로 넘어가는 문장이 도발적이다. "그런데 더 크게 갈 수 있을까, 이를테면 수십억 레코드로?" 뉴욕시 택시 데이터셋은 새 데이터베이스 시스템이 빅데이터를 감당할 수 있는지 보려 할 때 으레 찾는 데이터셋이다.

10.4

Parquet 파일에서 뉴욕 택시 데이터셋 탐색하기Exploring the New York Taxi dataset from Parquet files

뉴욕시 택시 데이터셋은 NYC 택시·리무진 위원회가 발행하고 관리한다. 승하차 날짜와 시각과 위치, 운행 거리, 항목별 요금, 요율 유형, 지불 유형, 기사가 보고한 승객 수를 담는다. Parquet 형식으로 발행되며 2009년 1월부터 달마다 파일 하나씩 계속 갱신된다.

규모가 이 책의 최대다. 집필 시점에 17억 행이 넘고, Parquet 파일 175개에 28GB다. 저자들이 모아 MotherDuck이 호스팅하는 S3 버킷에 올려두었다.

17.5 바이트행 하나가 차지하는 크기

28GB ÷ 17억 행. 컬럼이 열아홉 개이니 컬럼값 하나당 0.92바이트다. 이 문서에서 계산한 값이다.

175Parquet 파일 개수

달마다 하나. 글로브 와일드카드 하나로 전부 훑는다.

11초17억 행을 세는 시간

메타데이터만 읽으므로 데이터를 내려받지 않는다.

이 절의 방침이 앞 절과 다르다. Parquet 파일을 질의의 원천으로 쓰며, DuckDB가 실제로 데이터베이스를 채우지 않고도 조건 푸시다운과 사영 푸시다운으로 이 파일들의 질의를 최적화할 수 있음을 보인다. 쓸 자리도 구체적이다. S3나 구글 클라우드 스토리지 같은 클라우드 저장 버킷에 있는 데이터를 한 번만 분석할 때이며, 예컨대 웹사이트나 애플리케이션의 접근 로그나 다운로드 로그를 분석하는 데 쓸 수 있다.

TIP — 계산을 데이터가 사는 곳으로 옮긴다

이만한 양의 데이터에서는 질의 계산을 데이터가 사는 곳으로 옮기고 결과만 네트워크로 전송하는 편이 유익하다. 파일을 로컬 기계에 내려받았다면 거기서 DuckDB를 실행하면 되고, 그렇지 않다면 데이터에 가능한 한 가까운 클라우드 인스턴스에서 DuckDB를 돌리는 것이 합리적이다. S3에 파일이 있다면 같은 리전의 EC2 인스턴스나 MotherDuck 같은 호스팅 서비스가 그것이다.

그러지 않으면 이그레스 비용과 네트워크 전송 시간과 지연을 모두 물어야 한다.Parquet 메타데이터만으로 충족되는 질의는 예외다. 제7장의 MD_RUN 논의가 여기서 인프라 선택의 문제로 되돌아온다.

duckdb — 버킷에 붙고, 세고, 스키마를 본다목록 10.34 – 10.35 · 10.4.2 – 10.4.3
-- 목록 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만 행
파일 이름 확장자로부터의 추론이 자동으로 작동한다. 파일 하나든 URL이든 여러 파일에 맞는 글로브 와일드카드 패턴이든 마찬가지다. 내부적으로 read_parquet 호출로 변환된다. 다만 특정한 파일 집합을 고르려면 그 함수를 직접 불러야 한다.
두 파일을 합친 값에서 한 파일을 빼면 2021년 6월이 2,834,264행이라는 것이 나온다. 2022년 6월의 3,558,124행보다 72만 행 적다. 2021년 중반이 아직 대유행에서 회복하는 중이었음을 이 뺄셈 하나가 보여준다.
목록 10.34에 겹따옴표가 잘못 들어가 있다. SECRET ''Sr8VSfK...'는 홑따옴표가 셋이므로 구문 오류가 난다. SECRET 'Sr8VSfK...'여야 한다.
본문이 비밀 기능을 두고 "duckdb 버전 0.1.0부터 사용 가능"이라고 적었다. 0.10.0의 오기다. 제5장 5.5.2절이 "0.10.0 이전 판본에서는 SECRET 객체를 하나만 가질 수 있다"고 적어두었으니 같은 책이 스스로 확증한다. 0.1.0은 2019년 무렵의 판본이며 비밀도 httpfs도 그런 형태로 없었다. 소수점 자리 하나가 6년을 옮겨놓은 셈이다.
뷰는 물리적으로 실체화되지 않고 뷰에 대한 질의가 실행될 때마다 밑에 깔린 질의를 돌린다. 그리고 제5장에서는 없던 설명이 하나 붙는다. 바깥 질의 전체의 계획과 최적화에 통합되며, 공통 테이블 표현식(CTE)과 아주 비슷하다. 곧 뷰가 최적화의 장벽이 아니라는 뜻이니, 조건 푸시다운이 뷰를 뚫고 파일까지 내려간다.
뷰를 쓰는 두 번째 이유도 밝혀두었다. 데이터에 대한 안정된 인터페이스를 제공하므로, 밑에 깔린 데이터가 바뀌면 뷰를 갱신해 그 변화를 반영하되 뷰를 쓰는 질의는 고치지 않아도 된다. 제8장에서 dbt 소스가 하던 구실을 뷰가 SQL 안에서 한다.
← 좌우로 밀어 보기 →
duckdb — parquet_schema로 컬럼과 타입 보기10.4.3
D FROM parquet_schema('…/yellow_tripdata_2022-06.parquet') SELECT name, type;
nametypenametype
varcharvarcharvarcharvarchar
schema(루트)payment_typeINT64
VendorIDINT64fare_amountDOUBLE
tpep_pickup_datetimeINT64extraDOUBLE
tpep_dropoff_datetimeINT64mta_taxDOUBLE
passenger_countDOUBLEtip_amountDOUBLE
trip_distanceDOUBLEtolls_amountDOUBLE
RatecodeIDDOUBLEimprovement_surchargeDOUBLE
store_and_fwd_flagBYTE_ARRAYtotal_amountDOUBLE
PULocationIDINT64congestion_surchargeDOUBLE
DOLocationIDINT64airport_feeDOUBLE
20 rows — 루트 하나와 컬럼 열아홉 개
저자들이 이 함수를 고른 이유를 밝혀두었다. 렌더링되는 필드를 통제할 수 있고, 우리는 주로 이름과 타입에 관심이 있으니 정확히 그것이 필요하다. DESCRIBE도 되지만 parquet_schemaParquet의 물리 타입을 보여준다.
타입 세 가지가 눈에 걸린다. 승하차 시각이 INT64인 것은 Parquet이 타임스탬프를 정수로 저장하기 때문이며 논리 타입이 따로 붙는다. store_and_fwd_flagBYTE_ARRAY인 것은 문자열의 물리 표현이다. 그리고 passenger_countDOUBLE이다. 사람 수를 실수로 담는 것인데, 뒤의 통계에서 그 대가가 드러난다.
NOTE 읽으려는 Parquet 파일들의 스키마가 서로 다르면 read_parquetunion_by_name = true 옵션으로 하나의 결과로 합칠 수 있다. 존재하지 않거나 이름이 다른 컬럼은 NULL로 채워진다. 제5장에서 배운 그 옵션이 175개 파일을 다루는 이 절에서 실전 무기가 된다.
← 좌우로 밀어 보기 →
10.4.5

데이터 분석하기Analyzing the data

SUMMARIZE를 뷰 전체에 걸면 통계 정보를 모두 얻기 위해 실제 데이터를 읽어야 하므로 시간이 걸린다. 데이터에 가까운 AWS EC2 인스턴스에서 돌려도 1억 1,800만 행에서 결과를 내는 데 30초가 넘게 걸렸다. 그리고 그 결과가 이 데이터셋의 실상을 드러낸다.

duckdb — SUMMARIZE가 드러낸 데이터 품질목록 10.37 · 33.907s
column_nametypeminmaxapprox_unique
varcharvarcharvarcharvarcharvarchar
VendorIDBIGINT164
tpep_pickup_datetimeTIMESTAMP2001-01-012098-09-1159777057
tpep_dropoff_datetimeTIMESTAMP2001-01-012098-09-1160115892
passenger_countDOUBLE0.0112.012
trip_distanceDOUBLE-30.62389678.4614254
RatecodeIDDOUBLE1.099.07
store_and_fwd_flagVARCHARNY2
PULocationIDBIGINT1265264
DOLocationIDBIGINT1265265
payment_typeBIGINT056
fare_amountDOUBLE-133391414998310.0317618
extraDOUBLE-27.0500000.8671
mta_taxDOUBLE-0.55500000.582
tip_amountDOUBLE-493.221333913639064
tolls_amountDOUBLE-99.99956.553897
improvement_surchargeDOUBLE-1.01.05
total_amountDOUBLE-2567.81000003.836092
congestion_surchargeDOUBLE-2.53.016
airport_feeINTEGER-225
표시해둔 값들이 이 데이터셋의 오염 지도다. 2098년 9월에 출발한 택시가 있고, 승객 112명을 태운 택시가 있으며, 거리가 −30.62마일인 운행과 389,678마일—지구를 열다섯 바퀴 도는 거리—을 달린 운행이 있다. 공항 수수료가 −2에서 2 사이인 것은 그럴 만하지만, MTA 세금이 50만 달러인 것은 그렇지 않다.
가장 눈에 걸리는 것은 요금 −133,391,414달러와 팁 +133,391,363달러다. 부호만 다른 거울상 한 쌍이고 차이가 51달러다. 정정 처리나 오버플로가 만든 짝으로 보이는데, 뒤에서 이 한 쌍의 정체가 밝혀진다.
airport_feeINTEGER인 것도 눈에 걸린다. 방금 parquet_schema가 2022년 6월 파일에서 DOUBLE이라고 보여주었다. 뷰가 2020년 이후 파일 전부를 덮으므로, 어느 해의 파일이 이 컬럼을 다른 타입으로 담았거나 아예 없어서 합쳐진 결과의 타입이 달라진 것이다. 175개 파일의 스키마가 한결같지 않다는 증거다.
PULocationID는 고유값이 264, DOLocationID는 265다. 범위가 둘 다 1–265이니, 승차지로는 한 번도 쓰이지 않은 구역이 하나 있다. 공항 도착 전용 구역 같은 것을 짐작할 수 있다. 근사값이므로 단정할 수는 없다.
← 좌우로 밀어 보기 →
duckdb — 거리 필터 하나가 걷어낸 것들목록 10.38 – 10.41
-- 목록 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)
거리가 음수인 행 약 200만 개가 걸러졌다. 정확히는 1억 1,800만에서 115,976,028로 줄었으니 2,023,972행이다. 그리고 통계가 온전해진다. 최소가 0.01마일이 되고 중위값은 1.83마일이다. 표준편차가 여전히 536인 것은 389,678마일 하나가 남아 있기 때문이다.
거리 필터 하나가 다른 컬럼의 극단값까지 함께 걷어냈다. 목록 10.41의 출력을 앞의 SUMMARIZE와 대조해보면 그것이 드러난다. 요금 최소가 −133,391,414에서 −2,564.0으로, 팁 최대가 +133,391,363에서 1,400.16으로, extra 최대가 500,000.8에서 113.01로, mta_tax 최대가 500,000.5에서 53.16으로 바뀐다.
−1억 3천만 요금과 +1억 3천만 팁이 거리가 0 이하인 행에 실려 있었다는 뜻이다. 아까 거울상 한 쌍으로 보였던 그것이 같은 행 또는 같은 무리에 뭉쳐 있었다. 데이터 오염이 컬럼별로 흩어져 있지 않고 행 단위로 뭉쳐 있다는 것이 이 대조의 발견이며, 조건 하나로 여러 컬럼이 함께 정화되는 이유이기도 하다. 정제할 때 어느 컬럼을 볼지가 아니라 어느 행을 뺄지를 먼저 물어야 한다.
본문은 목록 10.40이 빠른 까닭을 "실제 데이터를 읽을 필요가 없기 때문"이라고 적었다. 이 설명은 minmax에는 맞지만 avgstddev에는 맞지 않는다. Parquet의 컬럼 통계에는 최소·최대·널 개수는 있으나 합계나 제곱합은 없다. 평균과 표준편차를 얻으려면 그 컬럼의 값을 실제로 다 읽어야 한다.
그러면 0.747초가 3.033초보다 빠른 진짜 이유는 무엇인가. 둘이다. 첫째, 컬럼 하나만 읽는다. 이것이 이 절이 내세운 사영 푸시다운이며 열아홉 컬럼 가운데 하나만 네트워크로 가져온다. 둘째, SUMMARIZE가 계산하는 분위수와 근사 고유값을 계산하지 않는다. 전자는 t-digest, 후자는 HyperLogLog를 돌려야 하니 값이 비싸다. 메타데이터가 아니라 읽는 양과 계산의 종류가 차이를 만든다.
목록 10.41이 같은 집계를 같은 뜻의 두 표기로 보여주는 것이 친절하다. columns('정규식')columns(c -> c SIMILAR TO '패턴')이다. 저자들이 "시연 목적으로" 둘을 다 썼다고 밝혀두었다. 제3장 3.5.1절의 COLUMNS 람다가 열아홉 컬럼의 통계를 한 문장에 담는 도구로 자란 셈이다.
SIDEBAR DuckDB 0.10.0부터 SUMMARIZESELECT의 원천으로 쓸 수 있다. SELECT column_name, column_type, count, max FROM SUMMARIZE allRidesView;처럼 관심 있는 컬럼만 고를 수 있다. 다만 저자들이 솔직하게 덧붙인다. "집필 시점에도 나머지 컬럼 통계는 여전히 계산하므로 시간이 절약되지는 않는다."
← 좌우로 밀어 보기 →
10.4.6

택시 데이터셋 활용하기Making use of the taxi dataset

이번 설정은 도시 사람들이 도시를 어떻게 이동하는지 이해하려는 뉴욕의 도시계획가다. 앞의 질의에서 운행 유형에 상당한 차이가 있다는 것을 배웠다. 어떤 것은 장거리 여행처럼 보이고 어떤 것은 아주 짧은 셔틀 운행이다. 그것이 해마다 어떻게 달라지는지 파고든다.

duckdb — 연도별 거리와 요금, 그리고 승객 수목록 10.42 · 10.43
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 …
yeardistfareratetripsfare÷dist
int64doubledoubledoubleint64이 문서에서 더한 열
20204.012.497.57243164083.12
20217.013.427.09304962011.92
20226.014.698.47390816422.45
20234.019.159.82220807864.79
운행 합계 115,975,037 · 뷰 115,976,028 → 엉뚱한 연도의 행 991개
HAVING에서 걸러야 하는 이유를 주석이 밝힌다. 연도가 그룹화 키이기 때문이다. 제4장 4.6절이 가르친 적용 순서가 여기서 실무적 제약이 된다. WHERE에는 year 별칭이 아직 없다.
연도를 2020–2024로 제한한 이유도 밝혀두었다. 어떤 해의 항목이 잘못된 날짜 값을 가졌으며, 특히 2098년, 2028년, 2001년, 2008년이다. 네 해의 행을 모두 더해 뷰의 총계와 비교해보면 걸러진 것이 991행이다. 1억 1,600만 가운데 0.00085%가 전체 집계를 망칠 수 있었다.
저자들이 읽어낸 것. 평균 요금이 시간이 갈수록 오르는 것은 인플레이션으로 돈의 가치가 떨어지기 때문이고, 2020년의 추가 하락은 코로나19 대유행이 원인일 가능성이 크다. 그리고 전체 데이터셋 분석에서는 우버와 리프트 같은 승차 서비스가 등장한 2010년대 중반에도 비슷한 하락이 관찰된다고 덧붙인다.
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_countcount_star()분포
doubleint64이 문서에서 더한 열
1.05366688████████████████████
2.01580495██████
3.0370175
4.0193333
5.0149485
0.0118751
6.0102836
8.065·
7.060·
9.040·
합계 7,881,928 — 뷰 115,976,028의 6.8%에 불과하다
거듭제곱 법칙 분포이며 대부분의 운행이 승객 한 명뿐이다. passenger_countDOUBLE이라 값이 1.0, 2.0으로 찍히는 것이 앞에서 짚은 물리 타입의 대가다. 사람 수인데 실수다. 그리고 승객 0명인 운행이 118,751건인 것도 눈에 걸린다. 기사가 입력하지 않은 것으로 보이며, 8명과 7명보다 훨씬 많다.
본문은 이 목록을 소개하며 "10마일보다 긴 운행에, 승객 10명 미만으로 초점을 맞춘다"고 적었다. 그런데 인쇄된 질의에는 거리 조건이 없다. WHERE passenger_count < 10뿐이다. 그리고 합계를 내보면 그 누락이 드러난다. 7,881,928은 뷰 전체 1억 1,600만의 6.8%다. 승객 수 조건만으로는 거의 모든 행이 남아야 하니 이 숫자가 나올 수 없다. 거리 필터가 실제로는 적용되었으나 인쇄된 질의에서 빠진 것으로 보아야 앞뒤가 맞는다. 따라 하는 독자는 AND trip_distance > 10을 더해야 같은 표를 얻는다.
← 좌우로 밀어 보기 →

저자들이 이 절과 이 장을 함께 맺는다. 이것은 이 데이터셋으로 돌릴 수 있는 질의의 표면을 긁은 것에 지나지 않지만, 쓰임을 더 깊이 파기보다 이 정도 데이터 양에서 질의의 성능을 살펴보는 데 초점을 두려 했다. 그리고 앞의 장들을 가리킨다. 이런 종류의 데이터셋과 원천 위에 앱이나 API나 대시보드를 지을 수도 있고, 노트북에서 분석해 결과를 내보내 더 처리할 수도 있다. 제9장이 지은 것이 이 데이터 위에도 올라갈 수 있다는 말이다.

SUMMARY

제10장이 남긴 아홉 문장Summary

  1. SUMMARIZE 절로 데이터셋의 개요를 얻는 것이 데이터셋 탐색을 시작하는 좋은 방법이다.
  2. 문자열을 열거형으로 바꾸는 것은 질의를 빠르게 하는 데 유용한 기법이다.
  3. DuckDB의 현대적 분석 아키텍처는 벡터 표상과 질의 내 병렬성을 활용한다.
  4. 실행 중에 SQL 질의는 실행 계획으로 바뀌며, EXPLAIN으로 들여다볼 수 있다.
  5. 클라우드 버킷에 있는 데이터셋은 저장된 곳에 가까운 기계에서 분석해 네트워크 전송 비용과 지연을 피해야 한다.
  6. DuckDB는 조건 푸시다운과 사영 푸시다운으로 Parquet 파일의 질의를 최적화할 수 있으며, 실제로 데이터베이스를 채우지 않아도 된다.
  7. 파일을 읽을 때 Parquet 메타데이터를 쓸 수 있다는 점이 대용량 데이터에서 대단히 유용하다. 질의에 필요하지 않은 데이터를 네트워크로 읽는 일을 피하기 때문이다.
  8. SELECT 절 안의 컬럼 표현식이 여러 집계를 많은 컬럼에 한꺼번에 적용하게 해준다.
  9. DuckDB는 수억, 심지어 수십억 레코드를 담은 데이터셋도 편안히 질의할 수 있다.
이 장을 덮으며 — 계측값이 논증이 되는 방식

이 장은 성능을 다루면서 단 한 번도 "빠르다"로 끝내지 않는다. 언제나 초와 배수를 적고, 왜 그런지를 붙이고, 공정하지 않은 비교에는 공정하지 않다고 스스로 밝힌다. 행 수 세기의 875배 앞에서 "이것은 공정한 싸움이 아니다"라고 적은 대목, 열거형의 1.4배 앞에서 "이 정도 짧은 실행 시간에서는 덜 중요하다"고 적은 대목이 그렇다.

그래서 이 장에서 걸린 어긋남들도 대체로 계측값 자체가 아니라 그것을 서술한 문장에 있다. 51.120초를 "1분이 넘는다"고 부르고, 2,000만 행을 "2,800만"이라 하고, 비율이 1을 넘는 해가 하나인데 "최근 2년"이라 하고, 평균과 표준편차를 메타데이터로 답한다고 한다. 숫자는 정확하고 문장이 성급하다. 계측값을 인쇄해두었기 때문에 우리가 그것을 확인할 수 있다는 사실이, 역설적으로 이 장의 미덕이다.

그리고 마지막 장에 어울리는 대조가 하나 남는다. 제2장의 227행에서 이 장의 17억 행까지 일곱 자릿수를 건너왔는데, 그동안 도구는 바뀌지 않았다. 같은 단일 바이너리, 같은 FROM 절, 같은 SUMMARIZE다. 달라진 것은 어디에 시간이 흐르는지를 우리가 알게 되었다는 것뿐이다. 그것이 이 장의 제목이 말하는 "고려사항"의 뜻이다.