이 장이 겨누는 두 곳
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 같은 패키지 관리자로 설치할 수 있고, 관리자 권한은 필요하지 않다.
$ brew install duckdb흔히 “brew”라 부르는 Homebrew는 macOS와 Linux에서 널리 쓰이는 패키지 관리자다. 이들 운영체제에서 소프트웨어 패키지와 의존성을 설치하고 갱신하고 관리하는 과정을 간단하게 만든다. 다음 명령 한 줄로 설치한다.
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com
/Homebrew/install/HEAD/install.sh)"Windows라면 명령 프롬프트에서 Windows 패키지 관리자로 내려받는다.
winget install DuckDB.cli
DuckDB CLI를 내려받았으면 다음 구문으로 쓴다.
$ duckdb [OPTIONS] [FILENAME]명령줄 인수 옵션의 전체 목록은 DuckDB 웹사이트에서 얻을 수 있다. 또는 -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로 시작하는 프롬프트를 표시한다.
$ 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라는 영속 데이터베이스와 함께 쓰는 예다.
$ duckdb mydb.duckdb v0.10.1 4a89d97db8 Enter ".help" for usage hints. D
데이터 들이기
DuckDB CLI 안에서는 먼저 테이블을 만들고 CSV 파일에서 데이터를 들여오는 방식으로 데이터베이스를 채운다. 다음 문장은 DuckDB CLI를 띄운 디렉터리에 airlines.csv 파일이 있다고 가정한다.
D CREATE TABLE airlines as FROM airlines.csv;
제2장에서 데이터 원천의 파일을 DuckDB로 적재하는 여러 함수를 다루었다. 이 장의 초점은 SQL로 테이블을 다루는 일이므로, 간결함을 위해 CSV 파일을 곧바로 DuckDB로 읽는다.
명령이 반드시 세미콜론(;)으로 끝나도록 한다. 이를 빼면 Enter를 눌렀을 때 DuckDB CLI가 뒤이을 문장을 기다린다. 세미콜론이 붙어야 실행된다. 위 문장을 mydb.duckdb 같은 영속 데이터베이스에서 실행하면 그 영속 데이터베이스가 파일 시스템에 생성된다.
이 문장은 데이터베이스에 airlines라는 테이블을 만들고, airlines.csv 파일을 읽어 그 테이블로 들여온다. 테이블의 존재를 확인하려면 show tables 문을 쓴다.
D show tables; ┌──────────┐ │ name │ │ varchar │ ├──────────┤ │ airlines │ └──────────┘
CSV 파일이 실제로 적재되었는지는 SELECT 문으로 확인한다. 14행 2열이 돌아온다.
| IATA_CODE | AIRLINE |
|---|---|
| UA | United Air Lines Inc. |
| AA | American Airlines Inc. |
| US | US Airways Inc. |
| F9 | Frontier Airlines Inc. |
| B6 | JetBlue Airways |
| OO | Skywest Airlines Inc. |
| AS | Alaska Airlines Inc. |
| NK | Spirit Air Lines |
| WN | Southwest Airlines Co. |
| DL | Delta Air Lines Inc. |
| EV | Atlantic Southeast Airlines |
| HA | Hawaiian Airlines Inc. |
| MQ | American Eagle Airlines Inc. |
| VX | Virgin America |
| 14 rows 2 columns | |
점 하나로 부리는 명령 · Dot Commands
DOT COMMANDS
DuckDB CLI 안에서는 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 명령을 쓴다.
D .database mydb: mydb.duckdb
파일 이름 없이 DuckDB CLI를 실행했다면 .database 명령에서 다음이 보인다. 메모리 데이터베이스를 쓰고 있다는 뜻이다.
D .database memory:
.open — 집을 갈아타기
파일 이름을 지정하지 않고 CLI를 시작했다가, 나중에 기존 또는 새 DuckDB 데이터베이스를 열고 싶어졌다면 .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 │ └──────────┘
.open 명령은 기존 데이터베이스를 닫고 새 것을 연다. 현재 데이터베이스를 열어 둔 채로 하나를 더 다루고 싶다면 ATTACH 문을 쓴다. 이 대비가 중요하다. .open은 이사이고 ATTACH는 별채를 붙이는 일이다.
D ATTACH 'mydb.duckdb'; -- 별칭을 붙일 수도 있다. 생략하면 파일 이름이 기본 별칭이 된다 D ATTACH 'mydb.duckdb' as mydb; D .database mydb2: mydb2.duckdb mydb: mydb.duckdb -- 먼저 나열된 것이 현재 활성 데이터베이스다. 바꾸려면 USE에 별칭을 준다 D USE mydb2;
파일 이름은 반드시 단일 인용부호나 이중 인용부호로 감싸야 한다.
.table — 칸을 한눈에
데이터베이스들에 있는 모든 테이블을 빠르게 훑으려면 .table 명령을 쓴다. 데이터베이스에 여러 테이블이 있을 때 유용하다.
D .table airlines airports
.dump — 칸을 문장으로 되돌리기
테이블의 내용을 SQL 문장으로 표현하려면 .dump 명령을 쓴다. DuckDB에 있는 테이블의 내용을 MySQL 같은 다른 데이터베이스의 테이블로 들여와야 할 때 유용하다. 표를 다시 문장으로 되돌린다는 이 발상은, 탁본을 떠서 다른 곳에 새겨 넣는 일과 닮았다.
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이라는 텍스트 파일에 다음 내용이 있다고 하자.
CREATE TABLE airports2 as FROM airports.csv; SELECT * FROM airports2;
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)
사라질 것을 남기는 법
PERSISTING THE IN-MEMORY DATABASE ON DISK
DuckDB CLI에서 메모리 데이터베이스를 쓰면서 airports.csv를 airports 테이블로 적재했다고 하자.
% 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 문을 쓴다.
D EXPORT DATABASE 'airports_db';
현재 디렉터리, 곧 CLI를 띄운 곳에 airports_db라는 폴더가 만들어지고 그 안에 세 개의 파일이 놓인다. 흥미로운 점은 이 내보내기가 이진 덩어리 하나가 아니라 읽을 수 있는 세 조각으로 이루어진다는 것이다.
airports.csv
data테이블을 적재해 온 그 CSV 파일이다. 원자료가 그대로 함께 나간다.
load.sql
how to fillCSV 파일을 테이블로 적재하는 문장이다.
COPY airports FROM 'airports_db/airports.csv… delimiter ',', header 1);
schema.sql
how to build데이터베이스에 테이블을 만드는 SQL 문장이다.
CREATE TABLE airports(IATA_CODE VARCHAR, AIR… CITY VARCHAR, STATE VARCHAR, COUNTRY VARCHA… LONGITUDE DOUBLE);
airports_db 폴더의 파일들을 새 DuckDB 데이터베이스로 적재하려면 다음 명령을 쓴다. 단, airports_db 폴더가 있는 디렉터리에서 CLI를 실행해야 한다.
$ duckdb mydb3.duckdb v0.10.1 4a89d97db8 Enter ".help" for usage hints. D IMPORT DATABASE 'airports_db'; D show tables; ┌──────────┐ │ name │ │ varchar │ ├──────────┤ │ 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다.
% duckdb library.duckdb v0.10.1 4a89d97db8 Enter ".help" for usage hints. D
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 KEY와 REFERENCES 키워드는 테이블 사이의 관계를 세우는 데 쓰인다. 테이블 데이터의 참조 무결성을 강제한다. 예컨대 Books 테이블을 만들 때 쓴 다음 문장을 보자.
FOREIGN KEY (author_id) REFERENCES Authors(author_id)
이 문장은 author_id 열의 값이 Authors 테이블의 author_id 열을 참조한다는 뜻이다. 달리 말해, Books 테이블의 레코드에 지정하는 author_id가 Authors 테이블의 author_id 열에 반드시 있어야 함을 보장한다.
테이블이 만들어졌는지 확인한다.
D show tables; ┌────────────┐ │ name │ │ varchar │ ├────────────┤ │ Authors │ │ Books │ │ Borrowers │ │ Borrowings │ └────────────┘
칸의 내부를 들여다보는 세 가지 방법
테이블의 스키마를 보려면 DESCRIBE 문을 쓴다. Authors 테이블의 스키마를 보자.
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 문에서 잘린 부분들이 이 출력에서 서로 맞춰진다.
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));
칸을 허무는 법과 허물 수 없는 이유
DuckDB에서 테이블을 지우려면 DROP TABLE 문을 쓴다. 이제 필요 없어진 OverdueBorrowers 테이블을 만들었다가 지우는 예다.
D CREATE TABLE OverdueBorrowers ( borrower_id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT, member_since DATE ); D DROP TABLE OverdueBorrowers;
다른 테이블이 참조하는 테이블을 지우려 하면 오류가 난다. 예컨대 Authors 테이블을 지우려 하면 다음 오류를 보게 된다.
Catalog Error: Could not drop the table because t… main key table of the table "Books"
참조 무결성은 편의가 아니라 제약이다. 그러나 이 제약이 도면을 지켜 준다. 서가 하나를 함부로 빼내면 그것을 가리키던 목록 카드가 허공을 가리키게 되므로, 데이터베이스는 그 일을 미리 막는다.
채우고 고치고 지우다
WORKING WITH TABLES — CRUD
데이터베이스와 그 안의 테이블을 만드는 법을 보았으므로, 이제 표본 레코드로 테이블을 채울 차례다. 이하에서 익히는 것은 일곱 가지다. 레코드 채우기, 레코드 갱신, 레코드 삭제, 테이블 질의, 테이블 조인, 데이터 집계, 데이터 분석.
앞의 네 연산은 보통 CRUD 연산이라 불린다. 생성(create), 조회(retrieve), 갱신(update), 삭제(delete)다.
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_id | name | nationality | birth_year |
|---|---|---|---|
| 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 |
| 6 | Mark Twain | American | 1835 |
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_id | name | member_since | |
|---|---|---|---|
| 1 | John Smith | john.smith@example.com | 지면 잘림 |
| 2 | Emma Johnson | emma.johnson@example.… | 지면 잘림 |
| 3 | Michael Brown | michael.brown@example… | 지면 잘림 |
| 4 | Sophia Wilson | sophia.wilson@example… | 지면 잘림 |
| 5 | William Taylor | william.taylor@exampl… | 지면 잘림 |
| 6 | Jane Doe | jane.doe@example.com | 2020년대 |
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_id | title | author_id | genre | publication_year |
|---|---|---|---|---|
| 1 | Pride and Prejudice | 1 | Classic | 1813 |
| 2 | Oliver Twist | 2 | Novel | 1837 |
| 3 | Murder on the Orient Express | 3 | Mystery | 1934 |
| 4 | Harry Potter and the Philosopher's Stone | 4 | Fantasy | 1997 |
| 5 | The Hobbit | 5 | Fantasy | 1937 |
원서는 뒤에서 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 — 여섯 건의 대출
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_id | book_id | borrower_id | borrow_date | return_date | status |
|---|---|---|---|---|---|
| 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 |
갱신 — 반납 처리
테이블의 특정 행을 갱신하려면 SET과 WHERE 같은 키워드와 함께 UPDATE 문을 쓴다. 이 예에서는 특정 책의 반납 상태를 고친다. borrowing_id가 3인 기록의 status를 “Returned”로, return_date를 2022-04-05로 바꾼다.
D UPDATE Borrowings SET return_date = '2022-04-05', status = 'Returned' WHERE borrowing_id = 3;
삭제 — 세 가지 조건 표현
레코드를 지우려면 WHERE 키워드와 함께 DELETE 문을 써서 조건을 지정한다. 원서는 같은 레코드(Jane Doe)를 지우는 세 가지 방식을 나란히 보인다.
-- ① 이름으로 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_id | name | |
|---|---|---|
| 1 | John Smith | john.smith@example.com |
| 2 | Emma Johnson | emma.johnson@example.… |
| 3 | Michael Brown | michael.brown@example… |
| 4 | Sophia Wilson | sophia.wilson@example… |
| 5 | William Taylor | william.taylor@exampl… |
묻는 법 · SELECT를 조금 더 깊이
QUERYING TABLES
여기까지 SELECT 문으로 테이블에 질의하는 법을 보았다. 이제 SELECT 문을 더 자세히 들여다보고 더 정교한 질의를 수행한다.
백 년 넘게 전에 태어난 저자
D SELECT * FROM Authors WHERE (YEAR(CURRENT_DATE) - birth_year) > 100;
| author_id | name | nationality | birth_year |
|---|---|---|---|
| 1 | Jane Austen | British | 1775 |
| 2 | Charles Dickens | British | 1812 |
| 3 | Agatha Christie | British | 1890 |
| 5 | Tolkien | British | 1892 |
| 6 | Mark Twain | American | 1835 |
CURRENT_DATE 함수는 현재 날짜를 돌려준다. 원서 집필 시점은 2024-04-18이다. YEAR 함수는 현재 날짜에서 연도를 뽑는다. 따라서 위 문장은 현재 연도를 얻어 각 저자의 출생 연도를 빼고, 결과가 100보다 큰 모든 행을 돌려준다.
이 대목에서 짚어 둘 것이 있다. 이 질의의 결과는 실행하는 날에 따라 달라진다. 책에 인쇄된 결과는 2024년의 결과다. 시간이 흐르면 J.K. Rowling도 이 목록에 들어온다. 데이터가 아니라 질문이 시간에 매여 있는 경우다.
Fantasy 장르의 책
D SELECT * FROM Books WHERE genre = 'Fantasy';
| book_id | title | author_id |
|---|---|---|
| 4 | Harry Potter and the Ph… | 4 |
| 5 | The Hobbit | 5 |
2022년 이후에 회원이 된 대출자
Borrowers 테이블의 member_since 열 같은 날짜 열에서는 날짜 비교를 직접 수행할 수 있다. 2022년 1월 1일 이후에 회원이 된 대출자를 찾는다.
D SELECT * FROM Borrowers WHERE member_since >= '2022-01-01';
| borrower_id | name |
|---|---|
| 1 | John Smith |
| 3 | Michael Brown |
| 4 | Sophia Wilson |
| 5 | William Taylor |
잇는 법 · 다섯 가지 조인
JOINING TABLES
데이터베이스에서 데이터를 뽑을 때 여러 테이블에서 정보를 가져와야 하는 일이 잦다. 그러려면 조인을 수행해야 한다. 조인은 공통 열이나 테이블 사이의 관계에 근거해 서로 다른 테이블의 데이터를 결합하게 해 준다. DuckDB가 지원하는 조인의 종류는 다섯 가지다.
LEFT JOIN
왼쪽 테이블의 모든 행을 포함한다. 오른쪽에 짝이 없으면 그 열은 NULL이 된다.
RIGHT JOIN
오른쪽 테이블의 모든 행을 포함한다. 왼쪽에 짝이 없으면 그 열이 NULL로 채워진다.
INNER JOIN
양쪽에 짝이 있는 행만 돌려준다. 겹치는 부분만 남는다.
FULL JOIN
양쪽 테이블의 모든 행을 포함한다. 짝이 없는 자리는 NULL로 채운다.
CROSS JOIN
양쪽의 모든 조합을 만든다. 원서는 종류만 열거하고 예제는 두지 않는다.
왼쪽 조인 — 어느 쪽을 왼쪽에 두는가
Books와 Authors 테이블을 써서 각 책의 제목과 그 저자를 나열한다.
D SELECT b.book_id, b.title, a.name FROM Books b LEFT JOIN Authors a ON b.author_id = a.author_id;
| book_id | title |
|---|---|
| 1 | Pride and Prejudice |
| 2 | Oliver Twist |
| 3 | Murder on the Orient Express |
| 4 | Harry Potter and the Philosopher's St… |
| 5 | The Hobbit |
이 질의에서 LEFT JOIN Authors a ON b.author_id = a.author_id는 Books 테이블(b)과 Authors 테이블(a) 사이의 왼쪽 외부 조인을 수행한다. 왼쪽 테이블인 Books의 모든 행이 결과 집합에 포함되고, Authors의 일치하는 행이 조건에 따라 붙는다. 어떤 책에 대응하는 저자가 없으면 Authors 쪽 열, 여기서는 a.name이 결과에서 NULL이 된다.
이제 테이블의 순서를 뒤집어 Authors를 왼쪽에 둔다. 여기서 왼쪽 조인의 성질이 뚜렷이 드러난다.
D SELECT a.name, b.book_id, b.title FROM Authors a LEFT JOIN Books b on a.author_id = b.author_id;
| name | book_id | title |
|---|---|---|
| Jane Austen | 1 | Pride and Prejudice |
| Charles Dickens | 2 | Oliver Twist |
| Agatha Christie | 3 | Murder on the Orien… |
| J.K. Rowling | 4 | Harry Potter and th… |
| Tolkien | 5 | The Hobbit |
| Mark Twain | NULL | NULL |
같은 LEFT JOIN이 어느 테이블을 왼쪽에 두었는지에 따라 다섯 행과 여섯 행으로 갈린다. 조인에서 좌우는 문법상의 순서가 아니라 무엇을 빠뜨리지 않을 것인가에 대한 선언이다. 장서 목록을 기준으로 볼 것인가, 저자 명부를 기준으로 볼 것인가. 이것은 기술적 선택이 아니라 관점의 선택이다.
오른쪽 조인
오른쪽 조인은 왼쪽 조인과 비슷하지만, 왼쪽 테이블에 일치하는 행이 없어도 오른쪽 테이블의 모든 행을 포함한다. 달리 말해 오른쪽 테이블의 모든 행이 결과 집합에 나타나며, 짝이 없는 왼쪽 열은 NULL로 채워진다. 오른쪽 테이블의 데이터를 우선하고 그 모든 행을 출력에 포함하려 할 때 유용하다.
D SELECT b.book_id, b.title, a.name FROM Books b RIGHT JOIN Authors a ON b.author_id = a.author_id;
내부 조인
내부 조인은 테이블 사이의 관련 열에 근거해 두 개 이상의 테이블의 행을 결합한다. 지정한 조인 조건의 열이 일치하는 행만 가져온다. 저자가 일치하는 제목만 나열하려면 내부 조인을 쓴다.
D SELECT b.book_id, b.title, a.name FROM Books b INNER JOIN Authors a ON b.author_id = a.author_id;
전체 조인
전체 조인의 결과는 조인 조건에 일치하는 것이 있든 없든 두 테이블의 행을 모두 포함한다.
D SELECT b.book_id, b.title, a.name FROM Books b FULL JOIN Authors a ON b.author_id = a.author_id;
여러 테이블의 조인 — 도면을 세 칸 이상 건너다
이제 여러 테이블을 이어 복잡한 관계를 탐색하고 데이터베이스에서 종합적인 데이터를 가져온다. 먼저 John Smith가 빌린 모든 책을 찾는다.
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 열에 근거한 Books와 Borrowings 사이, 하나는 borrower_id 열에 근거한 Borrowings와 Borrowers 사이다. 그 결과를 Borrowers 테이블의 name 열로 여과한다.
다음으로 대출된 모든 책을 찾아 대출자의 이름과 책 제목을 함께 나열한다.
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_name | book_title |
|---|---|
| Michael Brown | Pride and Prejudice |
| Sophia Wilson | Oliver Twist |
| Emma Johnson | Murder on the Orient Express |
| Michael Brown | Harry Potter and the Philosoph… |
| William Taylor | The Hobbit |
| John Smith | Pride and Prejudice |
결과가 위와 같은 순서로 나오지 않을 수 있다. 일관된 순서로 표시하려면 질의에 ORDER 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 테이블에 한 번 더 조인한다. 네 테이블이 모두 한 문장에 모인다.
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이 빌린 책과 그 대출일, 각 책의 반납 상태를 본다.
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_id | title |
|---|---|
| 1 | Pride and Prejudice |
| 4 | Harry Potter and the Philosopher's S… |
셈하는 법 · 여러 행을 한 값으로
AGGREGATING DATA
SQL의 집계는 여러 행의 데이터를 하나의 값으로 요약하는 과정을 말한다. 몇 가지 예로 익힌다.
대출자별 대출 권수
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_name | books_borrowed |
|---|---|
| Emma Johnson | 1 |
| John Smith | 1 |
| Michael Brown | 2 |
| Sophia Wilson | 1 |
| William Taylor | 1 |
이 문장은 borrower_id 열에 근거해 Borrowings와 Borrowers 사이의 내부 조인을 수행한다. COUNT 함수로 Borrowings 테이블의 book_id 항목 수를 세고 books_borrowed라는 별칭을 만든다. 마지막으로 Borrowers 테이블의 name 열로 묶고 정렬한다.
가장 많이 대출된 책
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로 최상단 한 행만 남긴다. 이 방식에는 한 가지 결함이 숨어 있다. 동률이 있을 때 하나만 나온다. 원서는 이 장의 마지막 절에서 그 결함을 정면으로 다룬다.
저자들의 평균 나이, 책들의 평균 출간 연도, 최고 연장자
-- 저자 전체의 평균 나이 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 │ └─────────────┴────────────┴───────┘
이 세 값이 이 페이지의 검산 근거다. 평균 나이 162.5는 여섯 저자의 나이 249, 212, 134, 59, 132, 189의 평균과 정확히 일치하고, 이는 CURRENT_DATE의 연도가 2024라는 사실과 Authors 표 전체를 동시에 확인해 준다. 평균 출간 연도 1903.6은 앞서 Books 표에서 잘린 세 값을 역산하는 데 쓰였다. 집계값은 요약이지만, 요약이 원자료를 되짚는 실마리가 되기도 한다.
살피는 법 · 연체와 최다 대출
ANALYTICS
앞 절들에서 익힌 기법으로 여러 테이블에 흥미로운 분석을 수행할 수 있다. 연체 도서 찾기에서 시작한다. 대출 기간이 최대 14일이라 가정하고, 책을 늦게 반납한 대출자의 이름을 찾는다. 아울러 반납된 책이 며칠 연체되었는지도 알아낸다.
여기서는 INNER JOIN 문으로 Borrowings, Books, Borrowers 세 테이블을 이어 책 이름과 대출자 이름, 반납일을 얻는다. 며칠 연체되었는지는 DATEDIFF 함수로 반납일과 대출일의 차이를 계산하고 거기서 14일을 뺀다.
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_name | book_title | borrow_date | return_date | days_overdue |
|---|---|---|---|---|
| John Smith | Pride and Prejudice | 2022-04-10 | 2022-04-25 | 1 |
| William Taylor | The Hobbit | 2022-03-30 | 2022-04-20 | 7 |
질의를 서가에 꽂아 두기 — VIEW
앞의 SQL 문은 뷰로 저장할 수 있다. 뷰란 본질적으로 저장된 질의이며, 이후 사용자가 마치 테이블인 것처럼 참조할 수 있다. CREATE 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를 뺀다.
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_id | title | borrower_name |
|---|---|---|
| 1 | Pride and Prejudice | Michael Brown |
| 2 | Oliver Twist | Sophia Wilson |
| 3 | Murder on the Orient Express | Emma Johnson |
현재 날짜를 기준으로 계산하므로 overdue 열의 값이 상당히 크다. 특정한 날, 예컨대 2022-06-10을 기준으로 연체 도서를 알고 싶다면 CURRENT_DATE() 함수를 특정 날짜로 바꾼다.
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_id | title | borrower_name | borrow_date | overdue |
|---|---|---|---|---|
| 1 | Pride and Prejudice | Michael Brown | 2022-04-26 | 31 |
| 2 | Oliver Twist | Sophia Wilson | 2022-04-15 | 42 |
| 3 | Murder on the Orient Express | Emma Johnson | 2022-03-20 | 68 |
여기서 배울 것은 함수 하나가 아니다. CURRENT_DATE를 고정된 날짜로 바꾸는 이 사소한 치환이, 재현 불가능한 질의를 재현 가능한 질의로 바꾼다. 답사 기록에 날짜를 적어 두는 일과 같은 이치다.
최다 대출 도서 — 세 겹으로 겹친 SELECT
마지막으로 가장 많이 대출된 책을 찾는다. 제목과 누가 빌렸는지, 언제 대출되고 언제 반납되었는지를 함께 보이려 한다. 앞서 LIMIT 1로 처리했던 것을 이번에는 동률까지 감당하도록 다시 쓴다.
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_id | book_name | borrower_name |
|---|---|---|
| 1 | Pride and Prejudice | John Smith |
| 1 | Pride and Prejudice | Michael Brown |
이 문장에는 세 개의 중첩 SELECT가 들어 있다. 각 단계가 무엇을 하는지 안에서 밖으로 따라간다.
왜 이렇게 겹쳐 쌓는가. LIMIT 1은 최댓값을 가진 항목이 둘 이상일 때 하나만 남긴다. 반면 이 구조는 최댓값을 먼저 구해 두고 그 값과 같은 것을 모두 고르는 방식이므로, 대출 횟수가 동률인 책이 여럿이어도 빠뜨리지 않는다. 원서가 이 절에서 굳이 세 겹을 쌓은 까닭이 여기 있다.
- ① 최내부 SELECT Borrowings 테이블의 각 book_id에 대한 대출 횟수를 센다. book_id로 묶어 책별 대출 횟수를 돌려준다.
- ② 중간 SELECT 최내부 질의의 결과에서 최대 대출 횟수(MAX(num_borrowings))를 가져온다. 어떤 책이든 가진 최대 대출 횟수를 얻는다.
- ③ 외부 WHERE 절 IN 조건으로 Borrowings 테이블에서 대출 횟수가 이 최댓값과 같은 book_id를 걸러 낸다. 곧 대출 횟수가 가장 많은 책을 모두 찾아내며, 대출 횟수가 같은 책들을 함께 다루기 위한 것이다.
- ④ 외부 SELECT 마지막으로 이 최다 대출 도서들의 세부를 Borrowings, Books, Borrowers 세 테이블의 내부 조인으로 가져온다. book_id와 borrower_id로 데이터를 이어, 책 제목과 대출자 이름, 대출일, 반납일 같은 필드를 얻는다.