D

답사기 · 제3장 정독

D 프롬프트
앞에 서다

파이썬이라는 마차에서 내려 두 발로 서 보는 장이다. 화면에는 대문자 D 하나와 깜빡이는 커서만 남는다. 그리고 이 맨손의 자리에서, 여섯 명의 저자와 다섯 권의 책과 여섯 명의 대출자로 이루어진 작은 장서각 하나를 손수 짓는다.

원전
DuckDB: Up and Running
지은이
Wei-Meng Lee
펴낸곳
O'Reilly Media, 2024
대상
Chapter 3. A Primer on SQL
장서각 네 칸 · SCHEMA OF THE MINI-LIBRARY Authors author_idINTEGER PK nameTEXT NOT NULL nationalityTEXT birth_yearINTEGER Books book_idINTEGER PK titleTEXT NOT NULL author_idINTEGER FK genreTEXT publication_year Borrowings borrowing_idINTEGER PK book_idINTEGER FK borrower_idINTEGER FK borrow_dateDATE return_dateDATE statusTEXT Borrowers borrower_idINTEGER PK nameTEXT emailTEXT member_sinceDATE 화살표는 FOREIGN KEY … REFERENCES 관계다 Borrowings는 두 방향으로 묶여 있다. 어느 책이 누구에게 갔는지를 기록하는 칸이므로, Books와 Borrowers 양쪽의 기본키를 모두 참조한다. 이 장의 모든 조인은 이 세 개의 화살표를 되짚는 일이다.

답사의 도면. 제1장이 기둥의 장, 제2장이 문의 장이었다면 제3장은 칸의 장이다. 네 칸이 서로를 가리키는 방식이 정해지면 그 뒤의 모든 질문은 도면 위에서 길을 찾는 일이 된다. 저자에서 책으로, 책에서 대출 기록으로, 대출 기록에서 사람으로. 유물 하나를 두고 그 출처와 소장 이력을 따라 올라가는 일과 다르지 않다.

이 장이 겨누는 두 곳

WHAT THIS CHAPTER FOCUSES ON

제2장에서는 파이썬을 통해 CSV, Parquet, 엑셀, 데이터베이스 같은 여러 데이터 원천을 DuckDB로 들여오는 법을 익혔다. 그 지식을 갖춘 다음 단계는 DuckDB에 적재된 데이터를 SQL로 다루는 일이다. 결국 DuckDB에서 SQL을 쓴다는 것이 DuckDB의 주요 기능 가운데 하나다. 이 장의 초점은 두 곳이다.

  • 하나 · CLI 파이썬 같은 프로그래밍 언어를 쓰지 않고 DuckDB CLI(명령줄 인터페이스)로 DuckDB 데이터베이스를 다루는 일.
  • 둘 · SQL DuckDB 데이터베이스에서 SQL을 쓰는 일. SQL을 낱낱이 훑는 대신 실용적인 예제를 통한 학습에 초점을 둔다.

이 장의 성격은 앞의 두 장과 다르다. 제1장은 왜 이 도구인지를, 제2장은 무엇을 어떻게 들이는지를 다루었다. 제3장은 손에 익히는 장이다. 그래서 페이지마다 프롬프트 기호 D가 등장한다. 이 기호는 문서의 장식이 아니라 답사자가 지금 어디에 서 있는지를 알리는 표지다.

검토 방법에 관한 밝힘

원서 PDF는 코드와 출력 블록이 지면 폭에서 가로로 잘려 있다. 이 페이지는 앞 장들과 같은 원칙을 따른다. 문맥으로 일의적으로 복원되는 부분은 점선 밑줄, 복원이 불확실한 부분은 로 남긴다.

한 가지가 더 있다. 이 장은 본문에 집계 결과값이 명기되어 있어, 지면에서 잘린 원본 데이터를 역산으로 검증할 수 있다. 그렇게 도출한 값은 표에서 점선을 두른 글자로 표시하고 산출 근거를 함께 적었다. 원서에 인쇄된 값과 검토자가 계산한 값을 섞지 않으려는 조치다.

맨손으로 다루는 법 · DuckDB CLI

USING THE DUCKDB CLI

DuckDB CLI는 명령줄에서 DuckDB를 직접 다루게 해 주는 도구다. 제2장에서는 파이썬으로 DuckDB를 다루었다. 그러나 새 테이블을 만들거나 여러 원천에서 데이터를 들여오거나 데이터베이스 관련 작업을 수행할 때처럼, 데이터베이스를 직접 만지고 싶은 때가 있다. 그런 경우에는 DuckDB CLI를 쓰는 것이 훨씬 효율적이다.

DuckDB CLI는 Windows, macOS, Linux 각 플랫폼에 맞게 미리 컴파일되어 있다. 설치 방법은 설치 안내 페이지를 참고한다. macOS라면 brew 같은 패키지 관리자로 설치할 수 있고, 관리자 권한은 필요하지 않다.

macOS설치
$ brew install duckdb
일러두기 — Homebrew

흔히 “brew”라 부르는 Homebrew는 macOS와 Linux에서 널리 쓰이는 패키지 관리자다. 이들 운영체제에서 소프트웨어 패키지와 의존성을 설치하고 갱신하고 관리하는 과정을 간단하게 만든다. 다음 명령 한 줄로 설치한다.

Shell한 줄
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com
/Homebrew/install/HEAD/install.sh)"

Windows라면 명령 프롬프트에서 Windows 패키지 관리자로 내려받는다.

Windows설치
winget install DuckDB.cli

DuckDB CLI를 내려받았으면 다음 구문으로 쓴다.

Shell구문
$ duckdb [OPTIONS] [FILENAME]

명령줄 인수 옵션의 전체 목록은 DuckDB 웹사이트에서 얻을 수 있다. 또는 -help 옵션으로 옵션 목록을 표시한다. 목록을 한 번 눈에 담아 두면 뒤의 점 명령과 겹치는 자리가 보인다. 출력 모드를 정하는 옵션이 유난히 많다는 사실이 이 도구의 성격을 말해 준다.

Shell-help
$ duckdb -help
Usage: duckdb [OPTIONS] FILENAME [SQL]
FILENAME is the name of an DuckDB database. A new 
if the file does not previously exist.
OPTIONS include:
   -append              append the database to th
   -ascii               set output mode to 'ascii
   -bail                stop after hitting an er
   -batch               force batch I/O
   -box                 set output mode to 'box'
   -column              set output mode to 'colum
   -cmd COMMAND         run "COMMAND" before read
   -c COMMAND           run "COMMAND" and exit
   -csv                 set output mode to 'csv'
   -echo                print commands before exe
   -init FILENAME       read/process named file
   -[no]header          turn headers on or off
   -help                show this message
   -html                set output mode to HTML
   -interactive         force interactive I/O
   -json                set output mode to 'json
   -line                set output mode to 'line
   -list                set output mode to 'list
   -markdown            set output mode to 'markd
   -newline SEP         set output row separator
   -nofollow            refuse to open symbolic l
   -no-stdin            exit after processing opt
   -nullvalue TEXT      set text string for NULL
   -quote               set output mode to 'quote
   -readonly            open the database read-on
   -s COMMAND           run "COMMAND" and exit
   -separator SEP       set output column separat
   -stats               print memory stats before
   -table               set output mode to 'table
   -unredacted          allow printing unredacted
   -unsigned            allow loading of unsigned
   -version             show DuckDB version

이름 없이 들어가기, 이름을 갖고 들어가기

FILENAME 인수를 주지 않으면 DuckDB CLI는 임시 메모리 데이터베이스를 열고, 버전 번호와 연결에 관한 정보, 그리고 D로 시작하는 프롬프트를 표시한다.

Shell이름 없이 실행
$ duckdb
v0.10.1 4a89d97db8
Enter ".help" for usage hints.
Connected to a transient in-memory database.
Use ".open FILENAME" to reopen on a persistent da
D 

메모리 데이터베이스를 만들면 DuckDB CLI를 나갈 때 모든 것이 사라진다. 그러므로 이 선택지는 DuckDB의 동작을 실험해 보려는 경우에만 쓸모가 있다.

DuckDB CLI를 나가려면 macOS와 Linux에서는 Ctrl+C를 두 번, Windows에서는 Ctrl+C를 한 번 누른다.

더 흔한 쓰임은 영속 데이터베이스와 함께 쓰는 것이다. 이렇게 하면 세션을 넘어 데이터가 저장되므로, 매번 데이터를 다시 적재하거나 다시 처리하지 않고 오래 쓰고 다시 쓸 수 있다. 다음은 mydb.duckdb라는 영속 데이터베이스와 함께 쓰는 예다.

Shell영속 데이터베이스
$ duckdb mydb.duckdb
v0.10.1 4a89d97db8
Enter ".help" for usage hints.
D 

데이터 들이기

DuckDB CLI 안에서는 먼저 테이블을 만들고 CSV 파일에서 데이터를 들여오는 방식으로 데이터베이스를 채운다. 다음 문장은 DuckDB CLI를 띄운 디렉터리에 airlines.csv 파일이 있다고 가정한다.

DuckDB CLICSV 반입
D CREATE TABLE airlines as FROM airlines.csv;
일러두기

제2장에서 데이터 원천의 파일을 DuckDB로 적재하는 여러 함수를 다루었다. 이 장의 초점은 SQL로 테이블을 다루는 일이므로, 간결함을 위해 CSV 파일을 곧바로 DuckDB로 읽는다.

주의 — 세미콜론

명령이 반드시 세미콜론(;)으로 끝나도록 한다. 이를 빼면 Enter를 눌렀을 때 DuckDB CLI가 뒤이을 문장을 기다린다. 세미콜론이 붙어야 실행된다. 위 문장을 mydb.duckdb 같은 영속 데이터베이스에서 실행하면 그 영속 데이터베이스가 파일 시스템에 생성된다.

이 문장은 데이터베이스에 airlines라는 테이블을 만들고, airlines.csv 파일을 읽어 그 테이블로 들여온다. 테이블의 존재를 확인하려면 show tables 문을 쓴다.

DuckDB CLIshow tables
D show tables;
┌──────────┐
│   name   │
│ varchar  │
├──────────┤
│ airlines │
└──────────┘

CSV 파일이 실제로 적재되었는지는 SELECT 문으로 확인한다. 14행 2열이 돌아온다.

IATA_CODEAIRLINE
UAUnited Air Lines Inc.
AAAmerican Airlines Inc.
USUS Airways Inc.
F9Frontier Airlines Inc.
B6JetBlue Airways
OOSkywest Airlines Inc.
ASAlaska Airlines Inc.
NKSpirit Air Lines
WNSouthwest Airlines Co.
DLDelta Air Lines Inc.
EVAtlantic Southeast Airlines
HAHawaiian Airlines Inc.
MQAmerican Eagle Airlines Inc.
VXVirgin America
14 rows    2 columns
SELECT * FROM airlines; 의 결과. 제2장에서 도판으로만 보였던 이 목록이 여기서는 본문의 박스 출력으로 온전히 인쇄되어 있다

점 하나로 부리는 명령 · Dot Commands

DOT COMMANDS

DuckDB CLI 안에서는 CLI 환경에 고유한 명령을 점(.) 명령으로 실행할 수 있다. 관리 작업을 수행하는 명령 집합이다. 쓸 수 있는 점 명령의 목록을 보려면 .help 명령을 쓴다.

DuckDB CLI.help — 원서 인쇄분
D .help
.bail on|off              Stop after hitting an e
.binary on|off            Turn binary output on o
.cd DIRECTORY             Change the working direc
.changes on|off           Show number of rows chan
.check GLOB               Fail if output since .te
.columns                  Column-wise rendering of
.constant ?COLOR?         Sets the syntax highligh
.constantcode ?CODE?      Sets the syntax highligh
.databases                List names and files of
.dump ?TABLE?             Render database content
.echo on|off              Turn command echo on or
.excel                    Display the output of ne
.exit ?CODE?              Exit this program with
.explain ?on|off|auto?    Change the EXPLAIN forma
.fullschema ?--indent?    Show schema and the cont
.headers on|off           Turn display of headers
.help ?-all? ?PATTERN?    Show help text for PATTE
.highlight [on|off]       Toggle syntax highlighti
.import FILE TABLE        Import data from FILE in
.indexes ?TABLE?          Show names of indexes
.keyword ?COLOR?          Sets the syntax highligh
.keywordcode ?CODE?       Sets the syntax highligh
.lint OPTIONS             Report potential schema
.log FILE|off             Turn logging on or off.
.maxrows COUNT            Sets the maximum number
.maxwidth COUNT           Sets the maximum width i
.mode MODE ?TABLE?        Set output mode
.nullvalue STRING         Use STRING in place of N
.once ?OPTIONS? ?FILE?    Output for the next SQL
.open ?OPTIONS? ?FILE?    Close existing database
.output ?FILE?            Send output to FILE or s
.parameter CMD ...        Manage SQL parameter bin
.print STRING...          Print literal STRING
.prompt MAIN CONTINUE     Replace the standard pro
.quit                     Exit this program
.read FILE                Read input from FILE
.rows                     Row-wise rendering of qu
.schema ?PATTERN?         Show the CREATE statemen
.separator COL ?ROW?      Change the column and ro
.sha3sum ...              Compute a SHA3 hash of d
.shell CMD ARGS...        Run CMD ARGS... in a sys
.show                     Show the current values
.system CMD ARGS...       Run CMD ARGS... in a sys
.tables ?TABLE?           List names of tables mat
.testcase NAME            Begin redirecting output
.timer on|off             Turn SQL timer on or off
.width NUM1 NUM2 ...      Set minimum column width
일러두기 — 세미콜론의 예외

일반적인 DuckDB 질의는 각 문장 끝에 세미콜론을 요구하지만, 점 명령은 그렇지 않다.

원서는 이 가운데 흔히 쓰이는 다섯 개를 골라 실제 사용 장면을 보인다.

.database — 지금 어느 집에 있는가

현재 사용 중인 데이터베이스를 보려면 .database 명령을 쓴다.

DuckDB CLI.database
D .database
mydb: mydb.duckdb
현재 사용 중인 데이터베이스가 mydb.duckdb이며 별칭은 mydb임을 알려 준다

파일 이름 없이 DuckDB CLI를 실행했다면 .database 명령에서 다음이 보인다. 메모리 데이터베이스를 쓰고 있다는 뜻이다.

DuckDB CLI메모리
D .database
memory:

.open — 집을 갈아타기

파일 이름을 지정하지 않고 CLI를 시작했다가, 나중에 기존 또는 새 DuckDB 데이터베이스를 열고 싶어졌다면 .open 명령을 쓴다.

DuckDB CLI.open
% duckdb
v0.10.1 4a89d97db8
Enter ".help" for usage hints.
Connected to a transient in-memory database.
Use ".open FILENAME" to reopen on a persistent da
D .open mydb2.duckdb
D CREATE TABLE airports as FROM airports.csv;
D show tables;
┌──────────┐
│   name   │
│ varchar  │
├──────────┤
│ airports │
└──────────┘
파일 이름 없이 CLI를 띄운 뒤 mydb2.duckdb를 열었다. 새 파일이므로 CLI를 띄운 디렉터리에 생성된다

.open 명령은 기존 데이터베이스를 닫고 새 것을 연다. 현재 데이터베이스를 열어 둔 채로 하나를 더 다루고 싶다면 ATTACH 문을 쓴다. 이 대비가 중요하다. .open은 이사이고 ATTACH는 별채를 붙이는 일이다.

DuckDB CLIATTACH · USE
D ATTACH 'mydb.duckdb';

-- 별칭을 붙일 수도 있다. 생략하면 파일 이름이 기본 별칭이 된다
D ATTACH 'mydb.duckdb' as mydb;

D .database
mydb2: mydb2.duckdb
mydb: mydb.duckdb

-- 먼저 나열된 것이 현재 활성 데이터베이스다. 바꾸려면 USE에 별칭을 준다
D USE mydb2;
주의

파일 이름은 반드시 단일 인용부호나 이중 인용부호로 감싸야 한다.

.table — 칸을 한눈에

데이터베이스들에 있는 모든 테이블을 빠르게 훑으려면 .table 명령을 쓴다. 데이터베이스에 여러 테이블이 있을 때 유용하다.

DuckDB CLI.table
D .table
airlines     airports
두 데이터베이스에 airlines와 airports 두 테이블이 있음을 보인다

.dump — 칸을 문장으로 되돌리기

테이블의 내용을 SQL 문장으로 표현하려면 .dump 명령을 쓴다. DuckDB에 있는 테이블의 내용을 MySQL 같은 다른 데이터베이스의 테이블로 들여와야 할 때 유용하다. 표를 다시 문장으로 되돌린다는 이 발상은, 탁본을 떠서 다른 곳에 새겨 넣는 일과 닮았다.

DuckDB CLI.dump airlines
D .dump airlines
PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION;
CREATE TABLE airlines(IATA_CODE VARCHAR, AIRLINE VARCHAR);
INSERT INTO airlines VALUES('UA','United Air Lines Inc.');
INSERT INTO airlines VALUES('AA','American Airlines Inc.');
INSERT INTO airlines VALUES('US','US Airways Inc.');
INSERT INTO airlines VALUES('F9','Frontier Airlines Inc.');
INSERT INTO airlines VALUES('B6','JetBlue Airways');
INSERT INTO airlines VALUES('OO','Skywest Airlines Inc.');
INSERT INTO airlines VALUES('AS','Alaska Airlines Inc.');
INSERT INTO airlines VALUES('NK','Spirit Air Lines');
INSERT INTO airlines VALUES('WN','Southwest Airlines Co.');
INSERT INTO airlines VALUES('DL','Delta Air Lines Inc.');
INSERT INTO airlines VALUES('EV','Atlantic Southeast Airlines');
INSERT INTO airlines VALUES('HA','Hawaiian Airlines Inc.');
INSERT INTO airlines VALUES('MQ','American Eagle Airlines Inc.');
INSERT INTO airlines VALUES('VX','Virgin America');
COMMIT;
일러두기

덤프하려는 테이블은 현재 사용 중인 데이터베이스에 있어야 한다. 그렇지 않다면 USE 문으로 올바른 데이터베이스로 옮긴다.

.read — 문장을 파일에서 불러 읽기

DuckDB CLI의 .read 명령은 파일에 담긴 SQL 명령을 실행하는 데 쓴다. commands.sql이라는 텍스트 파일에 다음 내용이 있다고 하자.

commands.sql파일 내용
CREATE TABLE airports2 as FROM airports.csv;
SELECT * FROM airports2;
DuckDB CLI.read
D .read commands.sql
┌───────────┬──────────────────────┬───┬─────────
│ IATA_CODE │       AIRPORT        │ … │ COUNTRY
│  varchar  │       varchar        │   │ varchar
├───────────┼──────────────────────┼───┼─────────
│ ABE       │ Lehigh Valley Inte…  │ … │ USA
│ ABI       │ Abilene Regional A…  │ … │ USA
│ ABQ       │ Albuquerque Intern…  │ … │ USA
...
│ YAK       │ Yakutat Airport      │ … │ USA
│ YUM       │ Yuma International…  │ … │ USA
├───────────┴──────────────────────┴───┴─────────
│ 322 rows (40 shown)
airports2 테이블의 내용. 322행 가운데 40행만 표시된다. 점 명령 목록의 .maxrows 기본값이 40이라는 사실이 여기서 확인된다

사라질 것을 남기는 법

PERSISTING THE IN-MEMORY DATABASE ON DISK

DuckDB CLI에서 메모리 데이터베이스를 쓰면서 airports.csvairports 테이블로 적재했다고 하자.

DuckDB CLI메모리에 적재
% duckdb
v0.10.1 4a89d97db8
Enter ".help" for usage hints.
Connected to a transient in-memory database.
Use ".open FILENAME" to reopen on a persistent da
D CREATE TABLE airports as FROM read_csv_auto(airports.csv);

메모리 데이터베이스는 DuckDB CLI를 나가면 없어진다는 것을 기억한다. 남기려면 디스크에 영속화해야 하며, 그러려면 EXPORT DATABASE 문을 쓴다.

DuckDB CLIEXPORT DATABASE
D EXPORT DATABASE 'airports_db';

현재 디렉터리, 곧 CLI를 띄운 곳에 airports_db라는 폴더가 만들어지고 그 안에 세 개의 파일이 놓인다. 흥미로운 점은 이 내보내기가 이진 덩어리 하나가 아니라 읽을 수 있는 세 조각으로 이루어진다는 것이다.

存 · 一
airports.csv
data

테이블을 적재해 온 그 CSV 파일이다. 원자료가 그대로 함께 나간다.

存 · 二
load.sql
how to fill

CSV 파일을 테이블로 적재하는 문장이다.

load.sql
COPY airports FROM 'airports_db/airports.csv
delimiter ',', header 1);
存 · 三
schema.sql
how to build

데이터베이스에 테이블을 만드는 SQL 문장이다.

schema.sql
CREATE TABLE airports(IATA_CODE VARCHAR, AIR
CITY VARCHAR, STATE VARCHAR, COUNTRY VARCHA
LONGITUDE DOUBLE);

airports_db 폴더의 파일들을 새 DuckDB 데이터베이스로 적재하려면 다음 명령을 쓴다. 단, airports_db 폴더가 있는 디렉터리에서 CLI를 실행해야 한다.

DuckDB CLIIMPORT DATABASE
$ duckdb mydb3.duckdb
v0.10.1 4a89d97db8
Enter ".help" for usage hints.
D IMPORT DATABASE 'airports_db';
D show tables;
┌──────────┐
│   name   │
│ varchar  │
├──────────┤
│ airports │
└──────────┘
이제 mydb3.duckdb 파일이 airports 테이블을 담는다

장서각을 짓다 · 네 칸의 설계

DUCKDB SQL PRIMER — CREATING THE TABLES

데이터베이스와 테이블을 관리하는 DuckDB CLI에 익숙해졌으므로, 이제 초점을 SQL로 옮긴다. SQL의 구문을 낱말 단위로 파고드는 대신, 실용적인 예제를 통해 익히는 편이 더 효과적이다. 그래서 이 절에서는 미니 도서관을 위한 데이터베이스를 짓는다. 이 도서관 데이터베이스에는 네 개의 테이블이 있다.

藏 · 一
Authors
저자

저자에 관한 정보를 담는다. 이름, 국적, 출생 연도 등이다.

藏 · 二
Books
도서

책에 관한 정보를 담는다. 제목, 저자, 장르, 출간 연도 등이다.

藏 · 三
Borrowers
대출자

책을 빌리는 사람에 관한 정보를 담는다. 이름, 전자우편, 회원이 된 날짜 등이다.

藏 · 四
Borrowings
대출 기록

사람이 빌린 책을 추적한다. 대출일, 반납일, 대출 상태 등의 세부를 담는다.

일러두기 — 표준과의 관계

DuckDB는 SQL 표준, 특히 SQL:1999와 대체로 호환되며 대부분의 연산에서 통상적인 SQL 구문을 따른다. 따라서 이하의 DuckDB SQL 논의는 대체로 표준 SQL과 같다. 여기서 익히는 것이 DuckDB에만 통하는 방언이 아니라는 뜻이다.

데이터베이스를 만들고 네 칸을 세운다

DuckDB CLI로 데이터베이스를 만든다. 이 예의 도서관 이름은 library.duckdb다.

Shelllibrary.duckdb
% duckdb library.duckdb
v0.10.1 4a89d97db8
Enter ".help" for usage hints.
D 
DuckDB CLICREATE TABLE × 4
D CREATE TABLE Authors (
      author_id INTEGER PRIMARY KEY,
      name TEXT NOT NULL,
      nationality TEXT,
      birth_year INTEGER
  );

D CREATE TABLE Borrowers (
      borrower_id INTEGER PRIMARY KEY,
      name TEXT NOT NULL,
      email TEXT,
      member_since DATE
  );

D CREATE TABLE Books (
      book_id INTEGER PRIMARY KEY,
      title TEXT NOT NULL,
      author_id INTEGER NOT NULL,
      genre TEXT,
      publication_year INTEGER,
      FOREIGN KEY (author_id) REFERENCES Authors(author_id)
  );

D CREATE TABLE Borrowings (
      borrowing_id INTEGER PRIMARY KEY,
      book_id INTEGER NOT NULL,
      borrower_id INTEGER NOT NULL,
      borrow_date DATE,
      return_date DATE,
      status TEXT,
      FOREIGN KEY (book_id) REFERENCES Books(book_id),
      FOREIGN KEY (borrower_id) REFERENCES Borrowers(borrower_id)
  );

SQL에서 테이블을 만들려면 CREATE TABLE 문 뒤에 테이블 이름과, 데이터 유형 및 선택적 제약을 갖춘 열의 목록을 적는다.

FOREIGN KEYREFERENCES 키워드는 테이블 사이의 관계를 세우는 데 쓰인다. 테이블 데이터의 참조 무결성을 강제한다. 예컨대 Books 테이블을 만들 때 쓴 다음 문장을 보자.

SQL참조 무결성
FOREIGN KEY (author_id) REFERENCES Authors(author_id)

이 문장은 author_id 열의 값이 Authors 테이블의 author_id 열을 참조한다는 뜻이다. 달리 말해, Books 테이블의 레코드에 지정하는 author_idAuthors 테이블의 author_id 열에 반드시 있어야 함을 보장한다.

테이블이 만들어졌는지 확인한다.

DuckDB CLIshow tables
D show tables;
┌────────────┐
│    name    │
│  varchar   │
├────────────┤
│ Authors    │
│ Books      │
│ Borrowers  │
│ Borrowings │
└────────────┘

칸의 내부를 들여다보는 세 가지 방법

테이블의 스키마를 보려면 DESCRIBE 문을 쓴다. Authors 테이블의 스키마를 보자.

DuckDB CLIDESCRIBE
D DESCRIBE Authors;
┌─────────────┬─────────────┬─────────┬─────────┬
│ column_name │ column_type │  null   │   key   │
│   varchar   │   varchar   │ varchar │ varchar │
├─────────────┼─────────────┼─────────┼─────────┼
│ author_id   │ INTEGER     │ NO      │ PRI     │
│ name        │ VARCHAR     │ NO      │         │
│ nationality │ VARCHAR     │ YES     │         │
│ birth_year  │ INTEGER     │ YES     │         │
└─────────────┴─────────────┴─────────┴─────────┴

-- SHOW 문으로도 같은 일을 한다
D SHOW Authors;

데이터베이스 전체의 스키마를 보려면 .schema 명령을 쓴다. 앞서 CREATE TABLE 문에서 잘린 부분들이 이 출력에서 서로 맞춰진다.

DuckDB CLI.schema
D .schema
CREATE TABLE Authors(author_id INTEGER PRIMARY KEY, name VARCHAR
NOT NULL, nationality VARCHAR, birth_year INTEGER);
CREATE TABLE Books(book_id INTEGER PRIMARY KEY, title VARCHAR NOT NULL,
author_id INTEGER NOT NULL, genre VARCHAR, publication_year INTEGER,
FOREIGN KEY (author_id) REFERENCES Authors(author_id));
CREATE TABLE Borrowers(borrower_id INTEGER PRIMARY KEY, name VARCHAR
NOT NULL, email VARCHAR, member_since DATE);
CREATE TABLE Borrowings(borrowing_id INTEGER PRIMARY KEY, book_id
INTEGER NOT NULL, borrower_id INTEGER NOT NULL, borrow_date DATE,
return_date DATE, status VARCHAR, FOREIGN KEY (book_id) REFERENCES
Books(book_id), FOREIGN KEY (borrower_id) REFERENCES
Borrowers(borrower_id));
TEXT로 선언한 열이 VARCHAR로 저장되어 있음이 여기서 드러난다

칸을 허무는 법과 허물 수 없는 이유

DuckDB에서 테이블을 지우려면 DROP TABLE 문을 쓴다. 이제 필요 없어진 OverdueBorrowers 테이블을 만들었다가 지우는 예다.

DuckDB CLIDROP TABLE
D CREATE TABLE OverdueBorrowers (
          borrower_id INTEGER PRIMARY KEY,
          name TEXT NOT NULL,
          email TEXT,
          member_since DATE
      );

D DROP TABLE OverdueBorrowers;

다른 테이블이 참조하는 테이블을 지우려 하면 오류가 난다. 예컨대 Authors 테이블을 지우려 하면 다음 오류를 보게 된다.

DuckDB CLI오류
Catalog Error: Could not drop the table because t
main key table of the table "Books"
Authors 테이블의 author_id 열이 Books 테이블에서 참조되고 있기 때문이다

참조 무결성은 편의가 아니라 제약이다. 그러나 이 제약이 도면을 지켜 준다. 서가 하나를 함부로 빼내면 그것을 가리키던 목록 카드가 허공을 가리키게 되므로, 데이터베이스는 그 일을 미리 막는다.

채우고 고치고 지우다

WORKING WITH TABLES — CRUD

데이터베이스와 그 안의 테이블을 만드는 법을 보았으므로, 이제 표본 레코드로 테이블을 채울 차례다. 이하에서 익히는 것은 일곱 가지다. 레코드 채우기, 레코드 갱신, 레코드 삭제, 테이블 질의, 테이블 조인, 데이터 집계, 데이터 분석.

일러두기 — CRUD

앞의 네 연산은 보통 CRUD 연산이라 불린다. 생성(create), 조회(retrieve), 갱신(update), 삭제(delete)다.

Authors — 여섯 명의 저자

SQLINSERT INTO Authors
D INSERT INTO Authors (author_id, name, nationality, birth_year)
  VALUES
      (1, 'Jane Austen', 'British', 1775),
      (2, 'Charles Dickens', 'British', 1812),
      (3, 'Agatha Christie', 'British', 1890),
      (4, 'J.K. Rowling', 'British', 1965),
      (5, 'Tolkien', 'British', 1892);

-- 한 행만 넣는 문장
D INSERT INTO Authors (author_id, name, nationality, birth_year)
  VALUES (6, 'Mark Twain', 'American', 1835);
각 값은 괄호로 감싸고 쉼표로 나눈다. 다섯 행을 한 번에 넣은 뒤 여섯 번째를 따로 넣었다
author_idnamenationalitybirth_year
1Jane AustenBritish1775
2Charles DickensBritish1812
3Agatha ChristieBritish1890
4J.K. RowlingBritish1965
5TolkienBritish1892
6Mark TwainAmerican1835
SELECT * FROM Authors; 의 결과

Borrowers — 여섯 명의 대출자

SQLINSERT INTO Borrowers
D INSERT INTO Borrowers (borrower_id, name, email, member_since)
  VALUES
      (1, 'John Smith', 'john.smith@example.com'
      (2, 'Emma Johnson', 'emma.johnson@example.c
      (3, 'Michael Brown', 'michael.brown@example
      (4, 'Sophia Wilson', 'sophia.wilson@example
      (5, 'William Taylor', 'william.taylor@examp
      (6, 'Jane Doe', 'jane.doe@example.com', '20
borrower_idnameemailmember_since
1John Smithjohn.smith@example.com지면 잘림
2Emma Johnsonemma.johnson@example.…지면 잘림
3Michael Brownmichael.brown@example…지면 잘림
4Sophia Wilsonsophia.wilson@example…지면 잘림
5William Taylorwilliam.taylor@exampl…지면 잘림
6Jane Doejane.doe@example.com2020년대
member_since 열의 값은 원서 지면에서 잘려 확인되지 않는다. 뒤의 질의에서 이 열이 2022-01-01과 비교되므로 값들이 그 전후에 걸쳐 있음만 알 수 있다

Books — 다섯 권의 책

SQLINSERT INTO Books
D INSERT INTO Books (book_id, title, author_id, genre, publication_year)
  VALUES
      (1, 'Pride and Prejudice', 1, 'Classic', 18
      (2, 'Oliver Twist', 2, 'Novel', 1837),
      (3, 'Murder on the Orient Express', 3, 'Mys
      (4, 'Harry Potter and the Philosopher''s St
      (5, 'The Hobbit', 5, 'Fantasy', 1937);
book_idtitleauthor_idgenrepublication_year
1Pride and Prejudice1Classic1813
2Oliver Twist2Novel1837
3Murder on the Orient Express3Mystery1934
4Harry Potter and the Philosopher's Stone4Fantasy1997
5The Hobbit5Fantasy1937
점선을 두른 값은 원서 지면에서 잘려 검토자가 역산한 값이다. 근거는 각 값에 붙여 두었다
검산 — 어떻게 역산했는가

원서는 뒤에서 AVG(publication_year)의 결과를 1903.6으로 인쇄한다. 다섯 권이므로 출간 연도의 총합은 9518이다. 지면에 온전히 인쇄된 두 권(1837, 1937)을 빼면 나머지 세 권의 합은 5744가 된다. 세 책의 실제 출간 연도 1813, 1934, 1997의 합이 정확히 5744다. 같은 방식으로 AVG(YEAR(CURRENT_DATE) - birth_year)의 인쇄값 162.5도 검산되어, Authors 표의 여섯 출생 연도와 원서 집필 시점의 연도가 2024라는 사실이 함께 확인된다.

Books 테이블의 author_id 열이 Authors 테이블의 author_id 열을 참조한다는 것을 기억한다. 따라서 author_id 값은 유효한 저자 식별자여야 하며, 이 예에서는 1부터 6까지다.

Borrowings — 여섯 건의 대출

SQLINSERT INTO Borrowings
D INSERT INTO Borrowings (borrowing_id, book_id, borrower_id,
  borrow_date, return_date, status)
  VALUES
      (1, 1, 1, '2022-04-10', '2022-04-25', 'Returned'),
      (2, 3, 2, '2022-03-20', NULL, 'On Loan'),
      (3, 4, 3, '2022-04-05', NULL, 'On Loan'),
      (4, 2, 4, '2022-04-15', NULL, 'On Loan'),
      (5, 5, 5, '2022-03-30', '2022-04-20', 'Returned'),
      (6, 1, 3, '2022-04-26', NULL, 'On Loan');
borrowing_idbook_idborrower_idborrow_datereturn_datestatus
1112022-04-102022-04-25Returned
2322022-03-20NULLOn Loan
3432022-04-05NULLOn Loan
4242022-04-15NULLOn Loan
5552022-03-302022-04-20Returned
6132022-04-26NULLOn Loan
SELECT * FROM Borrowings; 의 결과. book_id는 1~5, borrower_id는 1~6의 범위여야 한다

갱신 — 반납 처리

테이블의 특정 행을 갱신하려면 SETWHERE 같은 키워드와 함께 UPDATE 문을 쓴다. 이 예에서는 특정 책의 반납 상태를 고친다. borrowing_id가 3인 기록의 status를 “Returned”로, return_date를 2022-04-05로 바꾼다.

SQLUPDATE
D UPDATE Borrowings
  SET return_date = '2022-04-05',
      status = 'Returned'
  WHERE borrowing_id = 3;
대출일과 반납일이 같은 날이 된다. 이 갱신 뒤 미반납 도서는 borrowing_id 2, 4, 6의 세 건으로 줄어들며, 뒤의 연체 분석이 이 상태를 전제한다

삭제 — 세 가지 조건 표현

레코드를 지우려면 WHERE 키워드와 함께 DELETE 문을 써서 조건을 지정한다. 원서는 같은 레코드(Jane Doe)를 지우는 세 가지 방식을 나란히 보인다.

SQLDELETE — 세 가지 방식
-- ① 이름으로
D DELETE FROM Borrowers
  WHERE name = 'Jane Doe';

-- ② 식별자로. 더 흔한 방식이다
D DELETE FROM Borrowers
  WHERE borrower_id = 6;

-- ③ 이름에 'Jane'이 들어간 레코드를 지운다
D DELETE FROM Borrowers
  WHERE name LIKE '%Jane%';

% 기호는 와일드카드로, name 열에서 “Jane” 앞뒤의 임의 문자와 일치한다. 문자열 어디에든 “Jane”이 들어간 행을 지울 수 있게 해 준다. 다만 셋째 방식은 편리한 만큼 위험하다. 이름에 Jane이 들어간 다른 사람까지 함께 지워진다는 사실을 잊으면 안 된다.

borrower_idnameemail
1John Smithjohn.smith@example.com
2Emma Johnsonemma.johnson@example.…
3Michael Brownmichael.brown@example…
4Sophia Wilsonsophia.wilson@example…
5William Taylorwilliam.taylor@exampl…
삭제 후의 Borrowers 테이블. 다섯 명이 남는다

묻는 법 · SELECT를 조금 더 깊이

QUERYING TABLES

여기까지 SELECT 문으로 테이블에 질의하는 법을 보았다. 이제 SELECT 문을 더 자세히 들여다보고 더 정교한 질의를 수행한다.

백 년 넘게 전에 태어난 저자

SQL날짜 함수와 산술
D SELECT *
  FROM Authors
  WHERE (YEAR(CURRENT_DATE) - birth_year) > 100;
author_idnamenationalitybirth_year
1Jane AustenBritish1775
2Charles DickensBritish1812
3Agatha ChristieBritish1890
5TolkienBritish1892
6Mark TwainAmerican1835
1965년생인 J.K. Rowling만 빠진다

CURRENT_DATE 함수는 현재 날짜를 돌려준다. 원서 집필 시점은 2024-04-18이다. YEAR 함수는 현재 날짜에서 연도를 뽑는다. 따라서 위 문장은 현재 연도를 얻어 각 저자의 출생 연도를 빼고, 결과가 100보다 큰 모든 행을 돌려준다.

이 대목에서 짚어 둘 것이 있다. 이 질의의 결과는 실행하는 날에 따라 달라진다. 책에 인쇄된 결과는 2024년의 결과다. 시간이 흐르면 J.K. Rowling도 이 목록에 들어온다. 데이터가 아니라 질문이 시간에 매여 있는 경우다.

Fantasy 장르의 책

SQL문자열 비교
D SELECT *
  FROM Books
  WHERE genre = 'Fantasy';
book_idtitleauthor_id
4Harry Potter and the Ph…4
5The Hobbit5
두 행이 돌아온다. 앞의 Books 표에서 4번 책의 장르를 Fantasy로 확정한 근거가 이 결과다

2022년 이후에 회원이 된 대출자

Borrowers 테이블의 member_since 열 같은 날짜 열에서는 날짜 비교를 직접 수행할 수 있다. 2022년 1월 1일 이후에 회원이 된 대출자를 찾는다.

SQL날짜 비교
D SELECT *
  FROM Borrowers
  WHERE member_since >= '2022-01-01';
borrower_idname
1John Smith
3Michael Brown
4Sophia Wilson
5William Taylor
Emma Johnson이 빠진다. 곧 그의 member_since는 2022-01-01 이전이다. 지면에서 잘린 member_since 값에 대해 이 결과가 유일하게 알려 주는 사실이다

잇는 법 · 다섯 가지 조인

JOINING TABLES

데이터베이스에서 데이터를 뽑을 때 여러 테이블에서 정보를 가져와야 하는 일이 잦다. 그러려면 조인을 수행해야 한다. 조인은 공통 열이나 테이블 사이의 관계에 근거해 서로 다른 테이블의 데이터를 결합하게 해 준다. DuckDB가 지원하는 조인의 종류는 다섯 가지다.

LEFT JOIN

왼쪽 테이블의 모든 행을 포함한다. 오른쪽에 짝이 없으면 그 열은 NULL이 된다.

RIGHT JOIN

오른쪽 테이블의 모든 행을 포함한다. 왼쪽에 짝이 없으면 그 열이 NULL로 채워진다.

INNER JOIN

양쪽에 짝이 있는 행만 돌려준다. 겹치는 부분만 남는다.

FULL JOIN

양쪽 테이블의 모든 행을 포함한다. 짝이 없는 자리는 NULL로 채운다.

CROSS JOIN

양쪽의 모든 조합을 만든다. 원서는 종류만 열거하고 예제는 두지 않는다.

왼쪽 조인 — 어느 쪽을 왼쪽에 두는가

BooksAuthors 테이블을 써서 각 책의 제목과 그 저자를 나열한다.

SQLBooks를 왼쪽에
D SELECT b.book_id, b.title, a.name
  FROM Books b
  LEFT JOIN Authors a ON b.author_id = a.author_id;
book_idtitle
1Pride and Prejudice
2Oliver Twist
3Murder on the Orient Express
4Harry Potter and the Philosopher's St…
5The Hobbit
다섯 행. name 열은 원서 지면에서 잘려 나타나지 않는다

이 질의에서 LEFT JOIN Authors a ON b.author_id = a.author_idBooks 테이블(b)과 Authors 테이블(a) 사이의 왼쪽 외부 조인을 수행한다. 왼쪽 테이블인 Books의 모든 행이 결과 집합에 포함되고, Authors의 일치하는 행이 조건에 따라 붙는다. 어떤 책에 대응하는 저자가 없으면 Authors 쪽 열, 여기서는 a.name이 결과에서 NULL이 된다.

이제 테이블의 순서를 뒤집어 Authors를 왼쪽에 둔다. 여기서 왼쪽 조인의 성질이 뚜렷이 드러난다.

SQLAuthors를 왼쪽에
D SELECT a.name, b.book_id, b.title
  FROM Authors a
  LEFT JOIN Books b on a.author_id = b.author_id;
namebook_idtitle
Jane Austen1Pride and Prejudice
Charles Dickens2Oliver Twist
Agatha Christie3Murder on the Orien…
J.K. Rowling4Harry Potter and th…
Tolkien5The Hobbit
Mark TwainNULLNULL
이번에는 여섯 행이다. 마지막 행의 Mark Twain은 Books 테이블에 등재된 책이 없으므로 book_id와 title이 모두 NULL이다

같은 LEFT JOIN이 어느 테이블을 왼쪽에 두었는지에 따라 다섯 행과 여섯 행으로 갈린다. 조인에서 좌우는 문법상의 순서가 아니라 무엇을 빠뜨리지 않을 것인가에 대한 선언이다. 장서 목록을 기준으로 볼 것인가, 저자 명부를 기준으로 볼 것인가. 이것은 기술적 선택이 아니라 관점의 선택이다.

오른쪽 조인

오른쪽 조인은 왼쪽 조인과 비슷하지만, 왼쪽 테이블에 일치하는 행이 없어도 오른쪽 테이블의 모든 행을 포함한다. 달리 말해 오른쪽 테이블의 모든 행이 결과 집합에 나타나며, 짝이 없는 왼쪽 열은 NULL로 채워진다. 오른쪽 테이블의 데이터를 우선하고 그 모든 행을 출력에 포함하려 할 때 유용하다.

SQLRIGHT JOIN
D SELECT b.book_id, b.title, a.name
  FROM Books b
  RIGHT JOIN Authors a ON b.author_id = a.author_id;
결과는 Authors 테이블의 모든 저자를 담는다. 여섯째 행은 book_id와 title이 빈 채로 나온다

내부 조인

내부 조인은 테이블 사이의 관련 열에 근거해 두 개 이상의 테이블의 행을 결합한다. 지정한 조인 조건의 열이 일치하는 행만 가져온다. 저자가 일치하는 제목만 나열하려면 내부 조인을 쓴다.

SQLINNER JOIN
D SELECT b.book_id, b.title, a.name
  FROM Books b
  INNER JOIN Authors a ON b.author_id = a.author_id;
다섯 행. 대응하는 저자가 있는 책만 결과에 포함된다. Mark Twain은 나타나지 않는다

전체 조인

전체 조인의 결과는 조인 조건에 일치하는 것이 있든 없든 두 테이블의 행을 모두 포함한다.

SQLFULL JOIN
D SELECT b.book_id, b.title, a.name
  FROM Books b
  FULL JOIN Authors a ON b.author_id = a.author_id;
이 데이터에서는 여섯 행이 되고, 마지막 행은 Books 쪽이 비어 있다. 짝이 있는 행은 맞추고 없는 자리는 NULL로 채운다

여러 테이블의 조인 — 도면을 세 칸 이상 건너다

이제 여러 테이블을 이어 복잡한 관계를 탐색하고 데이터베이스에서 종합적인 데이터를 가져온다. 먼저 John Smith가 빌린 모든 책을 찾는다.

SQL3중 조인 + 여과
D SELECT b.title AS book_title
  FROM Books b
  INNER JOIN Borrowings br ON b.book_id = br.book_id
  INNER JOIN Borrowers bw ON br.borrower_id = bw.borrower_id
  WHERE bw.name = 'John Smith';
┌─────────────────────┐
│     book_title      │
│       varchar       │
├─────────────────────┤
│ Pride and Prejudice │
└─────────────────────┘

이 문장은 두 번의 내부 조인을 수행한다. 하나는 book_id 열에 근거한 BooksBorrowings 사이, 하나는 borrower_id 열에 근거한 BorrowingsBorrowers 사이다. 그 결과를 Borrowers 테이블의 name 열로 여과한다.

다음으로 대출된 모든 책을 찾아 대출자의 이름과 책 제목을 함께 나열한다.

SQL대출자 · 책 제목
D SELECT bw.name AS borrower_name, b.title AS book_title
  FROM Borrowings br
  INNER JOIN Books b ON br.book_id = b.book_id
  INNER JOIN Borrowers bw ON br.borrower_id = bw.borrower_id;
borrower_namebook_title
Michael BrownPride and Prejudice
Sophia WilsonOliver Twist
Emma JohnsonMurder on the Orient Express
Michael BrownHarry Potter and the Philosoph…
William TaylorThe Hobbit
John SmithPride and Prejudice
여섯 건의 대출 기록이 그대로 여섯 행이 된다. 이번에는 특정 대출자로 여과하지 않았다
일러두기 — 순서는 보장되지 않는다

결과가 위와 같은 순서로 나오지 않을 수 있다. 일관된 순서로 표시하려면 질의에 ORDER BY 문을 추가한다.

SQLORDER BY
SELECT bw.name AS borrower_name, b.title AS book_title
FROM Borrowings br
INNER JOIN Books b ON br.book_id = b.book_id
INNER JOIN Borrowers bw ON
    br.borrower_id = bw.borrower_id
ORDER BY bw.name, b.title;

결과는 대출자 이름, 그다음 책 제목의 알파벳 순으로 정렬된다. 정렬을 명시하지 않은 질의의 결과 순서를 신뢰하지 않는 습관은 SQL을 다루는 사람이 가장 먼저 들여야 할 습관 가운데 하나다.

결과에 저자의 이름까지 넣으려면 author_id 열에 근거해 Authors 테이블을 Books 테이블에 한 번 더 조인한다. 네 테이블이 모두 한 문장에 모인다.

SQL네 테이블 조인
D SELECT bw.name AS borrower_name, b.title AS book_title, a.name
  FROM Borrowings br
  INNER JOIN Books b ON br.book_id = b.book_id
  INNER JOIN Borrowers bw ON br.borrower_id = bw.borrower_id
  INNER JOIN Authors a ON b.author_id = a.author_id;
대출자 이름과 책 제목의 조합은 앞의 결과와 같고, 여기에 저자 이름 열이 더해진다

Michael Brown이 빌린 책과 그 대출일, 각 책의 반납 상태를 본다.

SQL특정 대출자의 이력
D SELECT b.book_id, b.title, br.borrow_date, br.status
  FROM Borrowings br
  INNER JOIN Books b ON br.book_id = b.book_id
  INNER JOIN Borrowers bw ON br.borrower_id = bw.borrower_id
  WHERE bw.name = 'Michael Brown';
book_idtitle
1Pride and Prejudice
4Harry Potter and the Philosopher's S…
Michael Brown은 두 권을 빌렸다. 이 사람이 뒤의 집계에서 유일하게 2건을 기록하는 대출자다

셈하는 법 · 여러 행을 한 값으로

AGGREGATING DATA

SQL의 집계는 여러 행의 데이터를 하나의 값으로 요약하는 과정을 말한다. 몇 가지 예로 익힌다.

대출자별 대출 권수

SQLCOUNT · GROUP BY · ORDER BY
D SELECT bw.name AS borrower_name, COUNT(br.book_id) AS books_borrowed
  FROM Borrowings br
  INNER JOIN Borrowers bw ON br.borrower_id = bw.borrower_id
  GROUP BY bw.name
  ORDER BY bw.name;
borrower_namebooks_borrowed
Emma Johnson1
John Smith1
Michael Brown2
Sophia Wilson1
William Taylor1
이름의 알파벳 순으로 정렬된 결과. 합이 6으로 Borrowings의 행 수와 맞는다

이 문장은 borrower_id 열에 근거해 BorrowingsBorrowers 사이의 내부 조인을 수행한다. COUNT 함수로 Borrowings 테이블의 book_id 항목 수를 세고 books_borrowed라는 별칭을 만든다. 마지막으로 Borrowers 테이블의 name 열로 묶고 정렬한다.

가장 많이 대출된 책

SQLORDER BY … DESC LIMIT 1
D SELECT b.book_id, b.title AS book_name, COUNT(*) AS num_borrowings
  FROM Borrowings br
  INNER JOIN Books b ON br.book_id = b.book_id
  GROUP BY b.book_id, b.title
  ORDER BY num_borrowings DESC
  LIMIT 1;
┌─────────┬─────────────────────┬────────────────
│ book_id │      book_name      │ num_borrowings
│  int32  │       varchar       │     int64
├─────────┼─────────────────────┼────────────────
│       1 │ Pride and Prejudice │              2

ORDER BY num_borrowings DESC는 대출 횟수를 기준으로 내림차순 정렬하므로 대출이 가장 많은 책이 첫 행에 온다. LIMIT 1로 최상단 한 행만 남긴다. 이 방식에는 한 가지 결함이 숨어 있다. 동률이 있을 때 하나만 나온다. 원서는 이 장의 마지막 절에서 그 결함을 정면으로 다룬다.

저자들의 평균 나이, 책들의 평균 출간 연도, 최고 연장자

SQLAVG · MIN · 부질의
-- 저자 전체의 평균 나이
D SELECT AVG(YEAR(CURRENT_DATE) - birth_year) AS average_age_of_authors
  FROM Authors;
┌────────────────────────┐
│ average_age_of_authors │
│         double         │
├────────────────────────┤
│                  162.5 │
└────────────────────────┘

-- 모든 책의 평균 출간 연도
D SELECT AVG(publication_year) AS avg_publication_year
  FROM Books;
┌──────────────────────┐
│ avg_publication_year │
│        double        │
├──────────────────────┤
│               1903.6 │
└──────────────────────┘

-- 가장 나이 많은 저자
D SELECT name, birth_year, YEAR(CURRENT_DATE) - birth_year AS age
  FROM Authors
  WHERE birth_year = (SELECT MIN(birth_year) FROM Authors);
┌─────────────┬────────────┬───────┐
│    name     │ birth_year │  age  │
│   varchar   │   int32    │ int64 │
├─────────────┼────────────┼───────┤
│ Jane Austen │       1775 │   249 │
└─────────────┴────────────┴───────┘
결과에서 보듯 Jane Austen은 2024년 기준으로 249세다

이 세 값이 이 페이지의 검산 근거다. 평균 나이 162.5는 여섯 저자의 나이 249, 212, 134, 59, 132, 189의 평균과 정확히 일치하고, 이는 CURRENT_DATE의 연도가 2024라는 사실과 Authors 표 전체를 동시에 확인해 준다. 평균 출간 연도 1903.6은 앞서 Books 표에서 잘린 세 값을 역산하는 데 쓰였다. 집계값은 요약이지만, 요약이 원자료를 되짚는 실마리가 되기도 한다.

살피는 법 · 연체와 최다 대출

ANALYTICS

앞 절들에서 익힌 기법으로 여러 테이블에 흥미로운 분석을 수행할 수 있다. 연체 도서 찾기에서 시작한다. 대출 기간이 최대 14일이라 가정하고, 책을 늦게 반납한 대출자의 이름을 찾는다. 아울러 반납된 책이 며칠 연체되었는지도 알아낸다.

여기서는 INNER JOIN 문으로 Borrowings, Books, Borrowers 세 테이블을 이어 책 이름과 대출자 이름, 반납일을 얻는다. 며칠 연체되었는지는 DATEDIFF 함수로 반납일과 대출일의 차이를 계산하고 거기서 14일을 뺀다.

SQLDATEDIFF · 연체 반납
D SELECT bw.name AS borrower_name, b.title AS book_title, br.borrow_date,
         br.return_date,
         DATEDIFF('day', br.borrow_date, br.return_date) - 14 AS days_overdue
  FROM Borrowings br
  INNER JOIN Books b ON br.book_id = b.book_id
  INNER JOIN Borrowers bw ON br.borrower_id = bw.borrower_id
  WHERE br.return_date IS NOT NULL AND
        DATEDIFF('day', br.borrow_date, br.return_date) > 14;
borrower_namebook_titleborrow_datereturn_datedays_overdue
John SmithPride and Prejudice2022-04-102022-04-251
William TaylorThe Hobbit2022-03-302022-04-207
John Smith는 하루 늦게, William Taylor는 일주일 늦게 반납했다. 오른쪽 두 열은 원서 지면에서 잘려 검토자가 산출한 값이며, 본문의 서술과 일치한다

질의를 서가에 꽂아 두기 — VIEW

앞의 SQL 문은 로 저장할 수 있다. 뷰란 본질적으로 저장된 질의이며, 이후 사용자가 마치 테이블인 것처럼 참조할 수 있다. CREATE VIEW 문으로 만든다.

SQLCREATE VIEW
D CREATE VIEW overdue_borrowings AS
  SELECT bw.name AS borrower_name,
         b.title AS book_title,
         br.borrow_date,
         br.return_date,
         DATEDIFF('day', br.borrow_date, br.return_date) - 14 AS days_overdue
  FROM Borrowings br
  INNER JOIN Books b ON br.book_id = b.book_id
  INNER JOIN Borrowers bw ON br.borrower_id = bw.borrower_id
  WHERE br.return_date IS NOT NULL
     AND DATEDIFF('day', br.borrow_date, br.return_date) > 14;

-- 만들어진 뷰는 DuckDB 데이터베이스에 영속된다
D SELECT * FROM overdue_borrowings;
이제 누가 책을 늦게 반납했는지 한 문장으로 본다

아직 돌아오지 않은 책

늦게 반납한 사람을 알았으니, 이번에는 어느 책이 연체 중이며 며칠인지 본다. 반납일이 NULL인 기록을 대상으로, 대출일과 현재 날짜의 차에서 14를 뺀다.

SQLCURRENT_DATE 기준
D SELECT b.book_id, b.title, bw.name AS borrower_name,
         DATEDIFF('day', br.borrow_date, CURRENT_DATE) - 14 AS overdue
  FROM Borrowings br
  INNER JOIN Books b ON br.book_id = b.book_id
  INNER JOIN Borrowers bw ON br.borrower_id = bw.borrower_id
  WHERE br.return_date IS NULL AND
        DATEDIFF('day', br.borrow_date, CURRENT_DATE) > 14;
book_idtitleborrower_name
1Pride and PrejudiceMichael Brown
2Oliver TwistSophia Wilson
3Murder on the Orient ExpressEmma Johnson
세 권이다. borrowing_id 3이 앞서 UPDATE로 반납 처리되었으므로 Harry Potter는 목록에서 빠진다

현재 날짜를 기준으로 계산하므로 overdue 열의 값이 상당히 크다. 특정한 날, 예컨대 2022-06-10을 기준으로 연체 도서를 알고 싶다면 CURRENT_DATE() 함수를 특정 날짜로 바꾼다.

SQL특정 날짜 기준
D SELECT b.book_id, b.title, bw.name AS borrower_name,
         DATEDIFF('day', br.borrow_date, '2022-06-10') - 14 AS overdue
  FROM Borrowings br
  INNER JOIN Books b ON br.book_id = b.book_id
  INNER JOIN Borrowers bw ON br.borrower_id = bw.borrower_id
  WHERE br.return_date IS NULL AND
        DATEDIFF('day', br.borrow_date, '2022-06-10') > 14;
book_idtitleborrower_nameborrow_dateoverdue
1Pride and PrejudiceMichael Brown2022-04-2631
2Oliver TwistSophia Wilson2022-04-1542
3Murder on the Orient ExpressEmma Johnson2022-03-2068
같은 세 권이지만 이번에는 연체 일수가 유한한 값으로 읽힌다. overdue 열은 원서 지면에서 잘려 검토자가 산출한 값이다

여기서 배울 것은 함수 하나가 아니다. CURRENT_DATE를 고정된 날짜로 바꾸는 이 사소한 치환이, 재현 불가능한 질의를 재현 가능한 질의로 바꾼다. 답사 기록에 날짜를 적어 두는 일과 같은 이치다.

최다 대출 도서 — 세 겹으로 겹친 SELECT

마지막으로 가장 많이 대출된 책을 찾는다. 제목과 누가 빌렸는지, 언제 대출되고 언제 반납되었는지를 함께 보이려 한다. 앞서 LIMIT 1로 처리했던 것을 이번에는 동률까지 감당하도록 다시 쓴다.

SQL중첩 SELECT 3단
D SELECT b.book_id, b.title AS book_name,
         bw.name AS borrower_name,
         br.borrow_date AS loan_date, br.return_date
  FROM Borrowings br
  INNER JOIN Books b ON br.book_id = b.book_id
  INNER JOIN Borrowers bw ON br.borrower_id = bw.borrower_id
  WHERE b.book_id IN (
      SELECT book_id
      FROM Borrowings
      GROUP BY book_id
      HAVING COUNT(*) = (
          SELECT MAX(num_borrowings)
          FROM (
              SELECT COUNT(*) AS num_borrowings
              FROM Borrowings
              GROUP BY book_id
          ) AS counts
      )
  )
  GROUP BY b.book_id, b.title, br.borrower_id, bw.name, br.borrow_date,
           br.return_date;
book_idbook_nameborrower_name
1Pride and PrejudiceJohn Smith
1Pride and PrejudiceMichael Brown
두 행. 가장 많이 대출된 책의 대출 기록이 대출자별로 펼쳐진다

이 문장에는 세 개의 중첩 SELECT가 들어 있다. 각 단계가 무엇을 하는지 안에서 밖으로 따라간다.

④ 외부 SELECT — 세 테이블을 조인해 제목·대출자·대출일·반납일을 가져온다 ③ 외부 WHERE … IN — 대출 횟수가 최댓값과 같은 book_id만 남긴다 ② 중간 SELECT — MAX(num_borrowings)로 최대 대출 횟수를 얻는다 ① 최내부 SELECT — book_id별로 대출 횟수를 센다 SELECT COUNT(*) AS num_borrowings FROM Borrowings GROUP BY book_id → book 1: 2건 · book 2: 1건 · book 3: 1건 · book 4: 1건 · book 5: 1건 → 최댓값 2 → 해당 book_id: 1

왜 이렇게 겹쳐 쌓는가. LIMIT 1은 최댓값을 가진 항목이 둘 이상일 때 하나만 남긴다. 반면 이 구조는 최댓값을 먼저 구해 두고 그 값과 같은 것을 모두 고르는 방식이므로, 대출 횟수가 동률인 책이 여럿이어도 빠뜨리지 않는다. 원서가 이 절에서 굳이 세 겹을 쌓은 까닭이 여기 있다.

  • ① 최내부 SELECT Borrowings 테이블의 각 book_id에 대한 대출 횟수를 센다. book_id로 묶어 책별 대출 횟수를 돌려준다.
  • ② 중간 SELECT 최내부 질의의 결과에서 최대 대출 횟수(MAX(num_borrowings))를 가져온다. 어떤 책이든 가진 최대 대출 횟수를 얻는다.
  • ③ 외부 WHERE 절 IN 조건으로 Borrowings 테이블에서 대출 횟수가 이 최댓값과 같은 book_id를 걸러 낸다. 곧 대출 횟수가 가장 많은 책을 모두 찾아내며, 대출 횟수가 같은 책들을 함께 다루기 위한 것이다.
  • ④ 외부 SELECT 마지막으로 이 최다 대출 도서들의 세부를 Borrowings, Books, Borrowers 세 테이블의 내부 조인으로 가져온다. book_idborrower_id로 데이터를 이어, 책 제목과 대출자 이름, 대출일, 반납일 같은 필드를 얻는다.

맺음말 · SUMMARY

여기서 쌓는 기초는 여러 데이터베이스 플랫폼에 두루 쓰인다. 특히 조인의 종류에 각별히 주의를 두어야 한다. 데이터셋을 뜻 있는 방식으로 결합하는 길이 거기서 열린다.

제3장은 DuckDB를 폭넓게 살핀 장이다. CLI와 데이터 반입 기법에서 출발해, 점 명령과 데이터베이스 영속화를 다루어 데이터 관리와 접근성을 높였다. 이어지는 절은 SQL 입문 구실을 하며, 데이터베이스와 테이블 생성, 데이터 질의, 데이터 가공을 위한 여러 조인 유형을 아울렀다. 여기에 데이터 집계와 SQL 문을 이용한 진전된 분석 기법을 익혀, DuckDB 데이터베이스에서 값진 통찰을 끌어낼 수 있게 되었다.

CLI와 데이터 반입 기법을 이해하는 일은 DuckDB 환경을 효율적으로 관리하는 데 결정적이다. 점 명령은 작업 흐름을 간결하게 만드는 강력한 능력을 제공하므로 익숙해져야 한다. 데이터베이스 영속화도 또 하나의 핵심 영역이다. 데이터가 저장되고 접근 가능하도록 보장하면 데이터 손실을 막고 큰 데이터셋을 관리하는 능력이 향상된다.

SQL에 관해서는, 여기서 쌓는 토대가 여러 데이터베이스 플랫폼에서 두루 쓸모가 있다. 데이터베이스와 테이블을 만들고 다루는 일을 연습한다. 기초에 해당하는 기량이다. 조인의 여러 유형에는 각별히 주의를 둔다. 데이터셋을 뜻 있게 결합해 더 깊은 분석의 가능성을 열어 준다. 이 기량들이 복잡한 분석을 수행하고 데이터에서 값진 통찰을 끌어내어 더 나은 의사 결정으로 이끈다.

답사를 마치며 이 장의 성격을 다시 적어 둔다. 제3장에는 성능 수치가 없다. 7.5초와 0.5초 같은 대비도, 4.2GB와 280MB 같은 극적인 격차도 없다. 대신 여섯 명의 저자와 다섯 권의 책과 여섯 건의 대출 기록이 있다. 열 몇 줄의 데이터로 조인의 좌우가 갈리고, 세 겹의 중첩이 동률을 건져 내고, CURRENT_DATE 하나가 질의를 시간에 묶는다. 작은 것으로 큰 원리를 보이는 방식이야말로 좋은 입문서의 태도이며, 답사기가 기와 한 장으로 건물 전체를 말하는 방식과 같다.