DuckDB in Action·Manning, 2024· 제6장 정독
6 INTEGRATING WITH THE PYTHON ECOSYSTEM
원서 123–143쪽

오리처럼 헤엄치면 오리다. 테이블처럼 보이면 질의할 수 있다

다섯 장을 CLI로 지나왔다. 제6장은 무대를 파이썬 인터프리터로 옮긴다. 그리고 여기서 DuckDB의 파이썬 API는 Python DB-API를 구현하는 데서 한참 더 나아간다. 내 프로세스 안에서 임베디드 데이터베이스를 쓰는 것에 그치지 않고, 파이썬 객체를 테이블처럼 질의하게 해준다. pandas 데이터프레임이든 Polars든 Arrow 테이블이든 "테이블처럼 보이면" 그것으로 충분하다. 이 장의 제목을 대신할 문장은 저자들이 직접 써두었다. 오리 시험(duck test)을 통과하면 오리라는 것이다.

이 장이 다루는 것
Python DB-API 2.0 구현과 관계형 API의 차이 · pandas 데이터프레임과 Apache Arrow 테이블에서 데이터 들이기 · DuckDB 메서드로 데이터프레임 질의 · 여러 데이터프레임 형식과 Arrow 테이블로 내보내기 · 관계형 API로 질의 조립
절 구성
6.1 – 6.5 및 하위 절 다섯 개, 요약
실린 것
목록 1건 · NOTE 5건 · 사이드바 1건 · 도판 없음
기준 판본
DuckDB Python 패키지 0.10.0 · pandas · Polars · PyArrow
IN con.read_csv(…) read_parquet · read_json con.table("population") pandas DataFrame Polars DataFrame PyArrow Table DuckDBPyRelation 질의 가능한 관계, 그리고 API filter · project · limit aggregate · order OUT .show() .to_table("…") fetchall · fetchone fetchdf() → pandas .pl() → Polars .to_arrow_table() 여기 등장하는 어떤 객체도 데이터를 들고 있지 않다. 명시적 fetch나 show 같은 트리거 메서드를 부를 때까지 질의는 실행되지 않는다. 그러므로 연산의 순서가 성능에 영향을 주지 않는다. 데이터프레임으로 옮기는 순간부터는 사정이 달라진다.
덧붙인 그림 16.1~6.5절이 열거한 입구와 출구를 한자리에 모았다. 이 장에는 원서 도판이 없으므로 이 문서에서 작도한 것이다.
← 좌우로 밀어 보기 →

지금까지는 질의를 관리하고 실행하는 데 줄곧 DuckDB CLI를 썼다. 저자들은 그 도구의 값어치를 인정한다. 즉석 분석과 CLI 기반 파이프라인에 대단히 효과적이다. 그러나 많은 데이터 작업 흐름이 파이썬과 그 생태계를 상당한 정도로 끌어들인다. 예컨대 pandas 데이터프레임은 무시할 수 없다. 이 장에서 배우게 되는 것은 DuckDB의 파이썬 API가 단지 Python DB-API를 구현하는 데서 훨씬 더 나아간다는 사실이다.

파이썬 API는 두 가지를 해준다. 하나는 내 파이썬 프로세스 안에서 임베디드 데이터베이스를 쓰게 하는 것이고, 다른 하나는 파이썬 객체를 테이블처럼 질의하게 하는 것이다. 동시에 질의 결과를 데이터프레임으로 손쉽게 변환할 수 있다. 이 장은 DuckDB 파이썬 패키지에 직접 묶여 오는 통합에 초점을 둔다.

NOTE

널리 쓰이는 파이썬 SQL 툴킷 SQLAlchemy는 다루지 않는다. SQLAlchemy는 여러 다른 데이터베이스를 추상화해 감싸고, 잘 알려진 엔터프라이즈급 영속화 패턴 한 벌을 파이썬에 들여오는데, 그것은 이 책의 범위를 벗어난다. duckdb_engine이라는 이름으로 SQLAlchemy용 드라이버를 얻을 수 있고, SQLAlchemy 기능의 거의 전부를 지원한다.

오리처럼 보이고, 오리처럼 헤엄치고, 오리처럼 꽥꽥거리면 그것은 아마 오리다. 테이블처럼 보이면 질의할 수 있다. 6.2절, 관계의 정의를 대신하는 문장
이 장의 작업 순서 — 번호 붙은 목록은 하나뿐이고, 본문 자체가 파이썬 세션 기록이다
  1. 6.1패키지를 설치하고, 열 필요조차 없는 연결을 확인한다
  2. 6.2CSV를 관계로 들이고, 메서드를 이어 붙여 질의를 조립한다
  3. 6.3pandas 데이터프레임을 테이블처럼 질의한다
  4. 6.4파이썬 함수를 SQL 함수로 등록해 지저분한 데이터를 고친다
  5. 6.5결과를 Polars와 Arrow로 넘기되, 되도록 늦게 넘긴다
6.1

시작하기Getting started

DuckDB 파이썬 패키지를 설치하고 어떤 의존성을 프로그램에 먼저 들여와야 하는지 익히는 것으로 시작한다. 그다음 인메모리 DuckDB 연결을 얻거나 데이터베이스 파일을 여는 서로 다른 선택지를 논한다. 이것을 파악해두는 일이 이 장뿐 아니라 이어지는 장들에도 중요하다. DuckDB와 상호작용하는 파이썬 생태계의 도구를 더 배우게 되기 때문이다.

파이썬 패키지 설치 6.1.1

DuckDB 파이썬 패키지는 PyPI에 배포되어 있다.

python — 설치와 확인 6.1.1
$ pip install duckdb

>>> import duckdb
>>> duckdb.__version__
'0.10.0'
원서 본문은 이 패키지가 "PiPI"에 배포된다고 적었다. 파이썬 패키지 색인의 이름은 PyPI다. 사소한 오식이지만, 검색해 찾아가려는 독자에게는 걸림돌이 된다.

데이터베이스 연결 열기 6.1.2

어떤 프로그래밍 환경에서 데이터베이스를 쓸 때 흔히 떠오르는 물음 가운데 하나가 연결을 어떻게 여는가다. DuckDB의 경우 답이 간단하다. 열 필요가 없다. 임베디드 데이터베이스로서 이미 여러분의 파이썬 프로세스 안에서 돌고 있기 때문이다. 패키지를 설치하고 나면 파이썬 인터프리터에서 duckdb를 그냥 써서 인메모리 데이터베이스와 상호작용할 수 있다.

여기서 이 장의 두 축이 갈라진다. sqlDuckDB의 관계형 파이썬 API로 들어가는 입구이고, executePython DB-API 2.0을 쓴다.

두 개의 입구 — sql과 execute 6.1.2
# ① 관계형 API
>>> result = duckdb.sql('SELECT 42')   # 객체를 정의하지만 아직 질의를 실행하지 않는다
>>> result.show()                    # 질의를 실행하고 결과를 인쇄한다

# ② Python DB-API 2.0. 전통적인 데이터베이스 API처럼 동작한다.
>>> result = duckdb.execute('SELECT 42')  # 즉시 실행하고 연결 객체를 돌려준다
>>> row = result.fetchone()             # 연결에서 한 행을 가져온다
>>> print(row)
①에서 SQL 문을 실행한 대상은 우리가 프로그램에 들여온 duckdb 객체 그 자체다. 전용 연결 객체를 얻거나 쓰지 않았다. duckdb 객체가 기본 인메모리 연결을 제공해준 것이다.
# 물론 전용 연결도 쓸 수 있다.
>>> con = duckdb.connect(database=':memory:')                       # 인메모리 DB 시작
>>> con = duckdb.connect('my-db.duckdb')                            # DB 파일 사용
>>> con = duckdb.connect(database='my-db.duckdb', read_only=True)  # 읽기 전용 여부 제어

# 기본 연결은 이 둘로 얻는다. 데이터베이스 이름이 매개변수에 담겨 있는 경우에 요긴하다.
>>> duckdb.default_connection
>>> duckdb.connect(database=':default:')
여기서 챙겨둘 중요한 사실은 이것이다. 별도 설정이 전혀 없으면 DuckDB는 파이썬 모듈 안에 전역으로 저장된 인메모리 데이터베이스를 쓴다. 전역 duckdb와 전용 연결 객체 모두 데이터베이스와 상호작용하는 여러 메서드를 제공한다.
DB-API에도 쓸 자리가 있다. 준비된 문장(prepared statement)과 이름 붙은 매개변수를 쓰는 질의가 그렇다. 그러나 이 장은 관계형 API에 초점을 둔다. 저자들의 표현이 이 장의 야심을 잘 보여준다. 후자는 데이터를 질의하는 독특한 접근이며, 관계형 데이터베이스와 메모리 안의 질의 가능한 객체 사이의 경계를 흐려 새롭고 흥미로운 해법으로 가는 길을 낸다.
6.2

관계형 API 쓰기Using the relational API

제3·4장에서는 관계라는 수학적 개념을 너무 깊이 파고들지 않고 넘어갈 수 있었다. 이제는 피해 갈 방법이 없다. 이미 배운 대로 테이블만이 아니라 뷰와 함수도 질의할 수 있고, 다른 질의의 결과도 질의할 수 있다. 테이블, 뷰, 사영, 그리고 테이블처럼 행동하는 함수가 모두 관계다.

관계는 본질적으로 테이블의 일반화된 형태이며, 행과 컬럼 대신 튜플과 속성으로 이루어진다. 관계형 데이터베이스에서 튜플은 이름이 붙고 순서가 있는 속성의 목록으로 정의된다. 관계의 각 튜플이 하나의 레코드에 대응하고, 튜플의 각 속성이 하나의 컬럼에 대응한다. 저자들의 정리가 편하다. 관계를 테이블과 거의 같은 것으로 생각하라.

그리고 이 장에서 가장 자주 인용될 문단이 나온다. 요컨대 관계는 오리 시험을 통과한다. 오리처럼 보이고 오리처럼 헤엄치고 오리처럼 꽥꽥거리면 아마 오리다. 테이블처럼 보이면 질의할 수 있다. 데이터베이스 안에서 질의를 돌릴 때는 이것이 대개 자연스럽게 느껴지고 달리 기대하지도 않는다. DuckDB 파이썬 패키지는 이 개념을 파이썬 자체에 들여온다. 서로 다른 객체를 데이터베이스의 테이블이나 뷰인 것처럼 질의하게 해준다. 관계형 스키마를 가진 영속 저장소를 갖는 것과, 그저 관계처럼 행동할 뿐인 객체를 갖는 것 사이의 선이 여기서 상당히 흐려진다.

파이썬 API로 CSV 데이터 들이기 6.2.1

제3장과 비슷하게 닭과 달걀의 문제가 있다. 데이터가 없으면 질의할 것이 없다. 그래서 다시 데이터를 들이는 것으로 시작한다. 다만 이번에는 데이터를 위한 관계형 스키마를 만들지 않고, 제공된 객체를 그대로 관계로 쓴다.

소재는 제2장에서 소개한 Populations CSV 파일이다. 전과 같이 httpfs 확장을 쓰지만, 이번에는 파일 처리에 DuckDB의 SQL 함수 read_csv가 아니라 파이썬 API의 같은 이름 함수 read_csv를 쓴다. 이 방식이 파이썬 기반 작업 흐름과 매끄럽게 통합되며, "파이썬다운(pythonic)" 데이터 조작 방식을 제공해 SQL 데이터베이스와 파이썬 데이터 구조 사이의 틈을 메워준다.

NOTE

예제에서 read_csv를 쓰는 것은 제2장에서 준비한 CSV 파일—나라와 그 관련 통계를 담은—을 재사용하고 싶기 때문이다. 같은 개념과 기법이 read_parquetread_json에도 그대로 적용된다. 이 함수들은 DuckDB에서 SQL 함수로도, 파이썬 함수로도 존재한다.

CSV 파일 질의하기 목록 6.1
import duckdb

con = duckdb.connect(database=':memory:')

con.execute("INSTALL httpfs")   # httpfs 확장을 설치한다. 한 번만 하면 된다.
con.execute("LOAD httpfs")      # 적재한다. 새 데이터베이스를 초기화할 때마다 해야 한다.

population = \
  con.read_csv("https://bit.ly/3KoiZR0")   # CSV 파일을 읽는다

>>> type(population)
<class 'duckdb.DuckDBPyRelation'>
데이터는 아무것도 인쇄되지 않고 객체 하나가 돌아온다. 그것이 DuckDBPyRelation이며, 저자들의 표현으로는 DuckDB 관계형 API의 중심 조각이다. 질의 가능한 관계이면서 동시에 API라는 이중의 정체가 이 장 전체의 열쇠다.
질의하고 싶다면 파이썬에서 DuckDB에 대고 다른 질의를 실행할 때와 똑같이 execute 메서드를 쓰면 된다. execute는 새 파이썬 DB 연결 객체를 주고, 거기서 결과를 가져온다. 더 남은 결과가 없을 때까지 fetchone으로 하나씩 가져오거나 fetchall로 한꺼번에 가져온다. 다만 fetchall은 형식이 없고 읽기 어려운 파이썬 튜플의 리스트를 돌려주므로 원서도 그 출력을 싣지 않았다.

그러나 관계형 API는 그보다 훨씬 많은 것이며 SQL 문을 쓰는 것의 대안으로 쓸 수 있다. 본질적으로 유창한(fluent) API이며 질의를 점진적으로 구성하게 해준다. DuckDBPyRelation 노드와 관계들로 이루어지고, 그것들은 SQL 질의의 상징적 표상으로 볼 수 있다. 그 노드의 재사용을 지원하고 집합 연산, 필터, 사영, 집계를 지원한다.

그리고 이 API의 성격을 규정하는 한 문장이 여기 있다. 관여하는 어떤 객체도 데이터를 들고 있지 않으며, 명시적 fetch나 보여주기 같은 트리거 메서드가 호출될 때까지 어떤 질의도 실행되지 않는다.

매번 내려받는 관계, 한 번 찍어둔 테이블 6.2.1
>>> (population
      .count("*")      # SQL의 SELECT count(*)에 해당한다
      .show()
    )
count_star()
int64
227
인터넷이 느리면 결과가 돌아오는 데 꽤 오래 걸리는 것을 알아차릴 수 있다. show 함수를 부를 때마다 CSV 파일이 새로 내려받아지기 때문이며, 이상적이지 않다.
# to_table로 DuckDB 테이블에 영속화해 이 문제를 고친다.
# 이 함수는 뒤에서 대신 Create Table as Select 문을 수행해준다.
>>> population.to_table("population")
>>> population_table = con.table("population")
>>> population_table.count("*").show()   # 이번에는 즉시 표시된다
새겨둘 차이가 있다. 목록 6.1에서 만든 population 객체는 언제나 CSV를 새로 내려받아 읽는다. 반면 한 번 만들어진 테이블은 그 시점에 존재했던 데이터의 스냅숏을 표상한다. 그 테이블을 이제 관계형 API로 접근한다는 사실이 이 점을 바꾸지 않는다. 접근할 때마다 다시 계산되는 뷰가 아니다.
NOTE type(population_table)을 불러보면 그것 역시 DuckDBPyRelation임을 알게 되고, 따라서 앞서와 같은 성질과 능력을 갖는다. 파일에서 온 관계와 테이블에서 온 관계가 같은 타입이라는 것이 오리 시험의 실물 증거다.
6.2.2

질의 조립하기Composing queries

지금까지는 주로 관계의 레코드를 세는 법만 살펴보았다. 그것은 능력의 일부만 엿본 것이다. API는 DuckDBPyRelation을 위한 함수 한 벌을 제공한다.

덧붙인 표 1  6.2.2절이 열거한 메서드와 그에 대응하는 SQL. 원서에는 이런 대응표가 없다.
메서드하는 일SQL의 대응
filter주어진 술어 함수를 만족하는 레코드만 포함한다WHERE
project지정한 컬럼만 돌려준다SELECT (사영)
limit첫 n개 레코드를 돌려준다LIMIT
aggregate주어진 집계 표현식을 적용한다SELECT … GROUP BY
order주어진 컬럼으로 레코드를 정렬한다ORDER BY
except_둘째 관계에 없는 첫째 관계의 모든 행을 돌려준다EXCEPT
intersect두 관계에 모두 나타나는 모든 행을 돌려준다INTERSECT
join주어진 키나 조건으로 관계들을 조인한다JOIN
union관계를 결합해 첫째 관계의 모든 행 다음에 둘째 관계의 모든 행을 돌려준다UNION ALL

왜 문자열 이어 붙이기보다 나은가

관계형 API를 빌더로 쓰는 것은 평범한 SQL을 쓸 때 부딪히는 문제 몇 가지를 해결해준다. 질의가 생성되거나 사용자 입력에 기반하는 경우가 많다. 그런데 SQL은 값을 표상하는 매개변수는 허용하지만 테이블이나 컬럼 이름을 매개변수화하는 것은 허용하지 않는다. 그래서 사람들은 동적인 테이블에서 선택하는 질의를 만들기 위해 흔히 어떤 종류의 문자열 이어 붙이기를 쓴다.

관계형 API는 그런 질의를 프로그램적으로 만드는 데 이점을 준다. 문자열 이어 붙이기와 씨름하는 대신 맥락을 아는 전용 메서드를 부를 수 있다. 그로써 유효하지 않은 질의나 SQL 주입에 취약한 질의를 만들 가능성이 줄고, 코드는 더 읽기 쉽고 조합하기 좋아진다.

이제 이 메서드들을 조합하면 무엇을 할 수 있는지 본다. 아래는 원서 6.2절이 차례로 보여주는 일곱 가지 조립을, 파이썬 쪽 코드와 그에 대응하는 SQL을 나란히 놓아 견줄 수 있게 만든 장치다. 결과는 모두 원서에 인쇄된 값을 그대로 옮겼다.

결과
← 좌우로 밀어 보기 →

이 장치를 눌러보면 저자들이 짚은 두 가지가 손에 잡힌다. 첫째, 관계형 API를 쓸 때 연산의 순서는 성능에 영향을 주지 않는다. 중간에 어떤 결과도 실체화되지 않기 때문이다. 이것은 뒤에 논할 pandas 통합과 다르다. 결과 집합이 pandas 데이터프레임으로 변환되면 그 데이터프레임은 pandas의 성질을 갖게 되고, 그것은 통상 값이 즉시 계산된다는 뜻이다.

둘째, show 함수가 호출될 때까지 질의는 실행되지 않으며 메서드가 돌려주는 객체는 여전히 같은 질의 빌더다. 그러므로 예컨대 limit 뒤에 offset을 더 불러 건너뛸 행 수를 지정할 수도 있다.

그리고 over_10m이라는 변수 하나가 이 절의 주인공이 된다. 필터 부분을 복사해 붙이는 대신 잘 정의된 필터를 하나 만들어 여러 질의에서 매끄럽고 일관되게 재사용하는 것이다. over_10m 관계는 테이블이나 뷰, 공통 테이블 표현식이 하는 것과 똑같이 행동한다. 그것으로 지역별 집계를 구동하기도 하고, 필터를 하나 더 얹어 확장하기도 한다. 두 경우 모두 원래 정의를 손대지 않고 재사용했다.

SIDEBAR

패턴 매칭

동유럽 나라를 골라낼 때 쓴 Region ~ '.*EASTERN EUROPE.*'의 물결표는 정규식으로 필터링하는 POSIX 방식 연산자다. DuckDB는 패턴 매칭을 네 가지 방식으로 지원한다.

  1. LIKE — 문자열이 패턴 전체와 일치하는지 검사한다. %_를 와일드카드로 쓸 수 있다.
  2. SIMILAR TO — 문자열이 정규식 전체와 일치하는지 검사한다.
  3. GLOB — 문자열이 GLOB 패턴과 일치하는지 검사한다. 특정 패턴을 따르는 파일 이름을 찾을 때 요긴하다.
  4. 함수를 통한 일반적인 정규식

그리고 SQL 연산자에는 줄임 기호가 있다. ~~가 LIKE, ~가 SIMILAR TO, 그리고 GLOB이다.

원서는 줄임 기호를 "~~가 LIKE, ~가 SIMILAR TO, ~가 GLOB"이라고 적었다. 같은 기호 하나가 서로 다른 두 연산자를 동시에 뜻할 수는 없으니 자체로 모순이다. DuckDB에서 GLOB의 줄임 기호는 물결표 (~~~)이며, 물결표 하나는 정규식 일치에 쓰인다. 판본에 따라 달라질 수 있는 대목이니 현재 문서로 확인하고 쓰는 편이 안전하다.

참고로 예제가 패턴 앞뒤에 .*를 붙인 이유가 여기서 드러난다. SIMILAR TO 계열은 문자열 전체와의 일치를 요구하므로, 부분 일치를 원하면 양쪽을 열어두어야 한다.

6.2.3

SQL로 질의하기SQL querying

관계형 객체는 SQL 테이블과 똑같은 유연함으로 다룰 수 있다. 이 능력이 파이썬 기반 데이터 연산과 SQL식 질의 사이를 유동적으로 넘나들게 해준다. 위 장치의 마지막 탭이 그 실물이다. FROM over_10m이라고 적었지만 over_10m테이블이 아니라 파이썬 변수다. 그런데 질의 안에서는 테이블처럼 취급된다.

관계형 API가 방대한 기능을 제공하기는 하지만, 특히 매개변수화된 질의가 관여하는 어떤 시나리오에서는 그 타고난 능력이 제약처럼 느껴질 수 있다. 그 틈을 메우기 위해 DuckDB는 Python DB-API 2.0을 따르는 execute 함수를 제공한다.

NOTE — 매개변수화된 질의란

문장 텍스트에 자리표(placeholder)를 쓰는 질의다. 문장 텍스트와 자리표에 들어갈 실제 값—매개변수—이 서로 독립적으로 엔진에 전달된다. 매개변수를 쓰는 데는 이점이 여럿 있다.

예컨대 사용자 입력은 언제나 매개변수로 전달해야 하고 절대로 문장 텍스트에 직접 넣어서는 안 된다. 문장의 문자열 조각을 사용자 입력과 그냥 이어 붙이면 이른바 SQL 주입 공격의 위험이 높아진다. 특별히 조작된 텍스트가 질의의 의미를 바꿔버리는 공격이다. 질의 엔진에 매개변수로 전달되면 엔진은 문자열이 결코 질의를 망가뜨리지 않도록 입력을 다룬다.

여기에 하나가 더 붙는다. 매개변수화된 문장은 질의 엔진이 한 번만 파싱하면 될 수 있다. 내용이 상수이고 매개변수만 바뀌기 때문이다.

그리고 이 절의 마지막 문단이 이 장 전체의 지적 정점이다. 저자들의 논리를 그대로 옮기면 이렇다. execute에 넘긴 질의는 이름 붙은 매개변수를 쓰며 여느 준비된 문장과 똑같이 보인다. 그런데 그것이 질의하는 대상은 실제 테이블이 아니라 관계형 객체 over_10m이다. 게다가 그 관계형 객체 자체도 사영할 컬럼이나 질의할 밑단 테이블에 매개변수를 쓰는 방식으로 만들어졌을 수 있다. 그래서 모든 경우에 문자열 이어 붙이기를 피할 수 있다.

관계형 API와 표준 DB-API는 여기서 서로를 보완한다. DB-API는 잘 짜인 SQL만 실행할 수 있고, 그것은 테이블과 컬럼의 매개변수화를 허용하지 않는다. 관계형 API는 그것을 허용하며, 그렇게 만든 관계형 객체는 다시 여느 테이블처럼 질의될 수 있다. 6.2.3절을 맺는 문단

두 API의 한계가 서로의 빈자리를 정확히 메운다는 이 구도는, 설계가 잘된 도구에서만 볼 수 있는 종류의 균형이다. 어느 한쪽이 다른 쪽을 대체하려 하지 않았기에 얻어진 결과다.

6.3

pandas 데이터프레임 질의하기Querying pandas DataFrames

데이터 생태계에서 DuckDB가 얻은 인기는 튼튼한 질의 엔진에 크게 힘입은 것이다. 파일 기반 연산에만 국한되지 않고, 이 엔진은 여러 데이터 인프라 도구의 인메모리 데이터 구조와 매끄럽게 통합된다. 그 영역에서 두드러진 도구 하나가 pandas다. 데이터 공동체에서 널리 쓰이는, 자리를 굳힌 오픈소스 데이터 분석 라이브러리다. DuckDB와 pandas의 결합은 데이터베이스 엔진의 능력과 데이터 조작 라이브러리의 유연함을 잇는다.

데이터프레임 하나를 테이블처럼 6.3
$ pip install pandas

>>> import duckdb
>>> import pandas as pd

# 이 책의 저자들에 대한 정보를 담은 데이터프레임을 만든다.
>>> people = pd.DataFrame({
        "name": ["Michael Hunger", "Michael Simons", "Mark Needham"],
        "country": ["Germany", "Germany", "Great Britain"]
    })

# 등록도 변환도 없다. 변수 이름을 테이블 이름처럼 쓴다.
>>> duckdb.sql("""
    SELECT *
    FROM people
    WHERE country = 'Germany'
    """)
namecountry
varcharvarchar
Michael HungerGermany
Michael SimonsGermany
DuckDB의 두드러진 기능 하나가 pandas 데이터프레임과 직접 상호작용하는 능력이다. 데이터프레임은 앞서 다룬 어떤 관계와도 똑같이 취급된다. 그러므로 여느 데이터베이스 테이블에 하듯 people에 SQL식 질의를 돌릴 수 있다.
저자들의 정리가 이 절의 핵심이다. 관계가 테이블로 만들어졌든, 파일에서 왔든, pandas 데이터프레임으로 표상되었든 상관없다.
돌려받는 그릇 고르기 — 커서냐 데이터프레임이냐 6.3
# 독일에 살지 않는 사람을 매개변수화된 질의로 찾는다.
# fetchall도 fetchone도 아니라 fetchdf를 쓴다는 점을 눈여겨볼 것.
>>> params = {"country": "Germany"}
>>> duckdb.execute("""
    SELECT *
    FROM people
    WHERE country <> $country
    """, params).fetchdf()

           name        country
0  Mark Needham  Great Britain

# 관계형 API가 더 맞는다면 데이터프레임을 그렇게 질의해도 된다.
>>> (duckdb.sql("FROM people")          # 데이터프레임에서 새 관계형 객체를 만든다
      .filter("country <> 'Germany'")
      .show()
    )
fetchdf는 Python DB-API 커서가 아니라 결과를 데이터프레임으로 돌려준다. 커서로 행을 훑을지 데이터프레임을 쓸지는 쓰임에 달렸다. 커서가 더 가볍고, 데이터프레임은 pandas로 이어지는 후속 분석에 훨씬 효율적으로 통합된다. DuckDB가 결과 집합에서 데이터프레임을 만들어주는 지루한 일을 대신해준다.
원서에 실린 이 출력은 이름 열이 "Mark"로만 적혀 있다. 데이터프레임에 넣은 값은 "Mark Needham"이고 pandas가 기본 설정에서 문자열을 그렇게 자르지도 않는다. 조판 과정에서 잘려나간 것으로 보인다.
여기서 확장되는 능력이 하나 더 있다. DuckDB는 Polars 데이터프레임과 PyArrow 테이블 같은 다른 인메모리 데이터 구조도 질의하게 해준다. 기제는 pandas에서 본 것과 마찬가지이며, 상세한 예제와 구현은 책의 깃허브 저장소에 있다.
NOTE

SQLAlchemy 드라이버 duckdb_engine도 데이터프레임 질의를 지원한다. 드라이버 인스턴스에 데이터프레임을 등록해서 쓸 수 있다.

6.4

사용자 정의 함수User-defined functions

데이터를 다루는 사람이라면 기존 데이터베이스 함수가 내 특정한 필요를 채워주지 못하는 상황을 자주 만난다. 그것을 알기에 DuckDB는 강력한 기능을 갖췄다. 파이썬 패키지 안에서 사용자 정의 함수(UDF)를 만드는 능력이다.

UDF의 아름다움은 SQL 언어의 고유 능력을 확장하는 데 있다. 사용자가 자기만의 맞춤 함수를 정의할 수 있고, 파이썬 라이브러리의 방대한 생태계에서 이득을 본다. 복잡한 데이터 조작이든 수학적 계산이든 외부 도구와 API와의 통합이든, 가능성이 사실상 무한해진다. 실용적으로 말하면 이렇다. DuckDB에서 SQL 질의를 쓰다가 어떤 특정 함수가 있었으면 하고 바란 적이 있다면, 이제 파이썬으로 그것을 만들어 SQL 코드 안에서 불러 쓸 수 있다.

공백 서른다섯 칸의 문제

데이터 수집은 어떤 분석 파이프라인에서나 기본 단계이지만 그 자체의 어려움을 함께 데려온다. 원자료는 지저분할 수 있고, 의미 있는 분석을 하기 전에 바로잡아야 할 오류를 만나는 일이 드물지 않다. 이 장 처음에 수집한 CSV 파일에도 손봐야 할 별스러움이 하나 나타났다.

문제는 데이터셋의 Region 필드에 있다. 이 필드가 지나친 공백 문자로 채워져 있어 데이터 처리와 분석이 번거로워진다. 문제의 크기를 눈으로 보기 위해 유일한 Region 값과 각각의 총 문자 길이를 구해본다.

일곱 글자가 서른다섯 글자로 기록되어 있다 6.4
>>> con.sql("""
    select DISTINCT Region, length(Region) AS numChars
    from population
    """)
RegionnumChars
varcharint64
LATIN AMER. & CARIB 23
ASIA (EX. NEAR EAST) 29
EASTERN EUROPE 35
WESTERN EUROPE 35
NEAR EAST 35
C.W. OF IND. STATES 20
SUB-SAHARAN AFRICA 35
OCEANIA 35
NORTHERN AFRICA 35
BALTICS 35
NORTHERN AMERICA 35
11 rows    2 columns
후행 공백이 많다는 것이 numChars 컬럼으로 확인된다. 위 표에서 연한 색으로 칠한 부분이 공백이다. BALTICS는 일곱 글자뿐인데 후행 공백을 포함한 총 길이가 서른다섯 글자다. 길이가 20이나 23, 29로 어중간한 것들도 섞여 있어 규칙성마저 없다.
물론 DuckDB에는 내장 SQL 함수 trim이 있다. 그런데도 저자들은 자기 버전을 만든다. 구현 세부에 정신을 팔지 않고 UDF를 어떻게 정의하는지에 초점을 맞출 수 있는 쉬운 예제이기 때문이다. 교재로서 옳은 선택이다.
← 좌우로 밀어 보기 →
함수를 정의하고 등록하고 확인하기 6.4
# 타입 주석을 붙인 파이썬 함수를 정의한다.
def remove_spaces(field:str) -> str:
  if field:                              # 필드가 널이 아닌지 확인한다
    return field.lstrip().rstrip()       # 값의 앞뒤에서 공백을 떼어낸다
  else:
    return field

# DuckDB에 등록한다.
>>> con.create_function('remove_spaces', remove_spaces)

# 등록을 확인하려면 duckdb_functions SQL 함수를 질의한다.
>>> con.sql("""
    SELECT function_name, function_type, parameters, parameter_types, return_type
    from duckdb_functions()
    where function_name = 'remove_spaces'
    """)
function_namefunction_typeparametersparameter_typesreturn_type
varcharvarcharvarchar[]varchar[]varchar
remove_spacesscalar[col0][VARCHAR]VARCHAR
이제 함수를 정의한 그 연결에서 내려지는 어떤 SQL 문에서든 이 함수를 부를 수 있다. con.sql("select length(remove_spaces(' foo '))")처럼 length로 감싸 부른 이유가 실용적이다. 그러지 않으면 파이썬 프로그램의 출력에서 앞뒤 공백이 잘렸는지 알아보기 어렵기 때문이다.
SQL은 타입이 있는 언어이므로 DuckDB는 함수의 매개변수 타입과 반환 타입을 모두 알아야 한다. DuckDB는 통상 파이썬 타입 주석에서 그 타입들을 추론할 수 있다. 그러나 그 추론이 늘 정확하지는 않을 수 있다. 코드에 파이썬 타입 힌트를 쓰지 않기로 했거나, 타입 힌트 없이 배포된 서드파티 라이브러리의 함수라면 그렇다.
← 좌우로 밀어 보기 →

타입을 명시하는 것은 명료함을 위해, 타입 추론의 함정을 막기 위해, 그리고 서로 다른 환경에서 일관된 동작을 보장하기 위해 이롭다. 함수를 명시적 타입으로 다시 정의하려면 충돌을 피하기 위해 먼저 앞서 등록한 판본을 제거해야 한다.

타입을 명시해 다시 등록하기 6.4
>>> con.remove_function('remove_spaces')

>>> from duckdb.typing import *

>>> con.create_function(
      'remove_spaces',
      remove_spaces,
      [(VARCHAR)],   # VARCHAR 입력 매개변수 하나
      VARCHAR        # VARCHAR를 반환한다
    )
이런 정의에서 명시적이 되는 것은 함수를 어떻게 써야 하고 무엇을 기대할 수 있는지를 규정하는 분명한 계약이 되어, 시스템과 개발자가 서로 발을 맞추게 해준다.
[(VARCHAR)]의 괄호는 파이썬에서 아무 일도 하지 않는다. 튜플이 아니라 그냥 괄호로 감싼 표현식이므로 이 리스트는 [VARCHAR]와 동일하다. 매개변수가 여럿일 때 튜플로 묶어야 한다고 오해할 만한 표기이니, 읽을 때 괄호에 뜻을 부여하지 않는 편이 좋다.
고치기 전과 후를 한 표에 놓고 확인하기 6.4
>>> con.sql("""
    SELECT DISTINCT Region, length(Region) AS len1,
            remove_spaces(Region) AS cleanRegion,
            length(cleanRegion) AS len2
    FROM population
    WHERE len1 BETWEEN 20 AND 30
    LIMIT 3
    """)
Regionlen1cleanRegionlen2
varcharint64varcharint64
ASIA (EX. NEAR EAST) 29ASIA (EX. NEAR EAST)20
LATIN AMER. & CARIB 23LATIN AMER. & CARIB19
C.W. OF IND. STATES 20C.W. OF IND. STATES19
len1len2의 차이를 한눈에 보면 함수가 실제로 그 부당한 공백을 떼어냈음이 분명해진다. WHERE 절에서 선택 목록의 별칭 len1을 쓴 것이 제3장 3.5.3절에서 배운 DuckDB의 확장이다.
# 함수가 기대대로 동작한다는 확신이 생겼으니 원본 데이터셋을 갱신한다.
>>> con.sql("""
    UPDATE population
    SET Region = remove_spaces(Region);
    """)
RegionnumChars
varcharint64
ASIA (EX. NEAR EAST)20
EASTERN EUROPE14
NORTHERN AFRICA15
OCEANIA7
WESTERN EUROPE14
SUB-SAHARAN AFRICA18
LATIN AMER. & CARIB19
C.W. OF IND. STATES19
NEAR EAST9
NORTHERN AMERICA16
BALTICS7
11 rows    2 columns
공백이 사라졌다. 서른다섯 칸으로 부풀어 있던 열한 개 지역명이 제 길이를 찾았다. 더 깔끔한 데이터셋을 갖게 되었고, 각 Region 값을 둘러싼 여분의 공백이 제거되어 데이터에 더 나은 구조와 균일함이 생겼다.
← 좌우로 밀어 보기 →

쉼표를 점으로 바꾸는 것과 로케일을 이해하는 것

다양한 출처의 데이터를 다루면 지역적 차이에서 비롯되는 어려움이 흔히 끼어든다. 그 훌륭한 예가 소수의 표기다. 유럽 지역에서는 다른 지역이 쓰는 마침표(.) 대신 쉼표(,)를 소수 구분자로 쓰는 것이 통상이다. 데이터를 데이터베이스에 들일 때 이런 로케일별 표기법은 복잡함을 불러오며, 시스템의 로케일이 데이터의 형식과 맞지 않으면 더욱 그렇다.

이 데이터셋에서도 소수를 표상하는 필드에서 그런 어려움을 만났다. 유럽식 형식 때문에 DuckDB가 그것들을 VARCHAR로 해석했고, 수치 분석을 방해할 수 있다. 바로잡기 위해 파이썬의 광범위한 라이브러리 생태계를 활용한다. locale 모듈이 이 특정한 어려움에 해법을 준다.

파이썬 표준 라이브러리를 SQL 함수로 들여오기 6.4
from duckdb.typing import *
import locale

def convert_locale(field:str) -> float:
  locale.setlocale(locale.LC_ALL, 'de_DE')
  return locale.atof(field)

>>> con.create_function('convert_locale', convert_locale)

>>> con.sql("""
    SELECT "Coastline (coast/area ratio)" AS coastline,
            convert_locale(coastline) as cleanCoastline,
            "Pop. Density (per sq. mi.)" as popDen,
            convert_locale(popDen) as cleanPopDen
    FROM population
    LIMIT 5
    """)
coastlinecleanCoastlinepopDencleanPopDen
varchardoublevarchardouble
0,000.048,048.0
1,261.26124,6124.6
0,040.0413,813.8
58,2958.29290,4290.4
0,000.0152,1152.1
원래 값과 정제된 값의 구분이 분명하다. 함수가 0,00 같은 값을 0.0으로 성공적으로 변환했다. 그리고 저자들이 붙인 단서가 이 절의 요점이다. 쉼표를 점으로 무턱대고 갈아 끼운 것이 아니라, 지금 다루는 것이 지역화 문제라는 의미론적 자각을 갖고 그렇게 한 것이다.
원서의 그 문장은 "쉼표(comma)를 점으로 무턱대고 갈아 끼운 것이 아니라"가 되어야 하는데 "콜론(colon)을 점으로"라고 적혀 있다. 다루는 문제는 소수 구분자이니 콜론이 등장할 자리가 없다. 오식이다.
# 함수의 동작에 확신이 생기면 변경을 데이터셋에 영구히 반영하는 것이 현명하다.
# ALTER TABLE 절은 컬럼 타입 변경과 값 갱신을 동시에 하게 해준다.
>>> con.sql("""
    ALTER TABLE population
    ALTER "Coastline (coast/area ratio)"
    SET DATA TYPE DOUBLE            -- 데이터 타입을 double로 갱신한다
    USING
      convert_locale("Coastline (coast/area ratio)")   -- 이 컬럼의 모든 값을 우리 함수로 갱신한다
    """)
SET DATA TYPE … USING …이 이 대목의 백미다. 타입만 바꾸면 기존 문자열을 변환할 수 없고, 값만 바꾸면 컬럼 타입이 여전히 VARCHAR로 남는다. 한 문장이 둘을 함께 처리한다. 이 과정은 지역적 데이터의 미묘함을 이해하고 그것에 적응하는 일의 중요성을 강조하며, 파이썬의 방대한 라이브러리 생태계와 SQL 기반 데이터 조작 사이의 틈을 메우는 DuckDB의 유연함을 드러낸다.
이어지는 NOTE는 아직 정제가 필요한 컬럼으로 Pop. Density (per sq. mi.), Coastline (coast/area ratio), Birthrate, Deathrate 넷을 든다. 그런데 Coastline (coast/area ratio)은 바로 위의 ALTER TABLE이 방금 처리한 컬럼이다. 목록에서 빠져야 할 이름이 남아 있다. 실제로 남은 것은 Pop. Density, Birthrate, Deathrate 셋이다.
← 좌우로 밀어 보기 →
6.5

Apache Arrow와 Polars와의 상호운용Interoperability with Apache Arrow and Polars

데이터 분석의 영역에서 적응성은 튼튼한 시스템을 규정하는 성질이다. 서로 다른 형식이나 플랫폼 사이를 매끄럽게 넘나드는 능력이 효율적인 데이터 조작과 저장과 시각화를 가능하게 한다. DuckDB의 강점 하나는 인메모리든 외부든 다양한 데이터 형식과 상호작용하는 능력이다. 이 상호운용성은 특히 다른 도구와 통합하거나 결과를 후속 분석을 위해 내보낼 때 값을 헤아릴 수 없이 요긴하다.

Polars, 그리고 그 밑에 깔린 Arrow

데이터 과학 생태계에 도구가 넘쳐나지만, 더 나은 성능이나 독특한 기능을 제공하는 라이브러리가 끊임없이 등장한다. 그 떠오르는 별 하나가 Polars다. pandas가 오랫동안 파이썬 데이터 분석의 사실상 표준이었지만, Polars는 흥미로운 대안으로 스스로를 내세운다. 성능 특성으로 알려진 언어인 Rust로 개발되었고, 빠르면서 메모리 효율적인 데이터프레임 연산을 제공한다.

그런데 이 절에서 정말 중요한 것은 Polars가 아니라 그 밑에 깔린 것이다. Polars가 쓰는 메모리 모델은 Apache Arrow에 기반한다. Arrow는 인메모리 데이터를 위한 언어 횡단 개발 플랫폼으로, 평면 데이터와 계층 데이터 양쪽에 대해 표준화되고 언어에 독립적인 컬럼 메모리 형식을 규정한다. 그리고 그 결과가 이것이다. Arrow는 무복사(zero-copy) 읽기를 허용하며, 언어와 시스템 사이에 직렬화 부담 없이 빠른 데이터 접근과 교환을 가능하게 한다.

사실 Arrow를 인메모리 형식으로 쓰는 프레임워크가 Polars만은 아니다. pandas 데이터프레임과 NumPy, PySpark 같은 다른 파이썬 라이브러리도 그렇다. 그러니 Arrow는 경쟁하는 도구들이 딛고 선 공통의 바닥인 셈이며, DuckDB가 그 바닥에 직접 닿는다는 것이 이 절의 요지다.

Polars로 넘기기 — 그리고 언제 넘길 것인가 6.5
# 실행 중인 파이썬 인터프리터를 나가지 않고 다른 셸에서 실행할 것.
# 앞서 정의한 객체와 관계를 그대로 살려두어야 한다.
$ pip install polars pyarrow

>>> import polars
>>> population_table = con.table("population")

>>> (population_table
      .limit(5)                                      # 관계형 API로 첫 다섯 행을 고른다
      .pl()                                          # population 테이블을 Polars 데이터프레임으로 변환한다
      [["Country", "Region", "Population"]]     # 세 컬럼을 뽑는다
    )

shape: (5, 3)
┌────────────────┬──────────────────────┬────────────┐
│ Country        │ Region               │ Population │
│ ---            │ ---                  │ ---        │
│ str            │ str                  │ i64        │
╞════════════════╪══════════════════════╪════════════╡
│ Afghanistan    │ ASIA (EX. NEAR EAST) │ 31056997   │
│ Albania        │ EASTERN EUROPE       │ 3581655    │
│ Algeria        │ NORTHERN AFRICA      │ 32930091   │
│ American Samoa │ OCEANIA              │ 57794      │
│ Andorra        │ WESTERN EUROPE       │ 71201      │
└────────────────┴──────────────────────┴────────────┘
DuckDB에서 Polars로의 이행이 매끄럽다. 그 데이터프레임은 Polars가 제공하는 어떤 계산에도 참여할 수 있다. 그런데 코드에서는 그 이행을 가능한 한 늦추고 싶어질 것이다.
저자들이 반례를 직접 든다. 데이터프레임으로 먼저 변환한 다음 head(5)로 첫 다섯 행을 얻을 수도 있었다. 그러면 모든 행이 파이썬 런타임으로 실체화된 뒤에 클라이언트 쪽 변환이 따라오게 된다. 순서 하나가 310만 행을 옮길지 다섯 행을 옮길지를 가른다.
그래서 권고가 분명하다. Polars든 pandas든, 관계형 API 또는 데이터베이스 API를 가능한 한 오래 고수하고, 외부 데이터와 결합해야 하거나 SQL로는 계산이 아예 불가능한 경우에만 데이터프레임으로 변환하라.
← 좌우로 밀어 보기 →
Arrow 테이블로 바꾸어 Arrow의 계산 API 쓰기 6.5
>>> arrow_table = population_table.to_arrow_table()

>>> import pyarrow.compute as pc

>>> (arrow_table
      .filter(pc.field("Region") == "NEAR EAST")          # NEAR EAST 지역의 행만 포함한다
      .select(["Country", "Region", "Population"])         # 세 컬럼을 돌려준다
      .slice(length=5)                                    # 첫 다섯 행을 돌려준다
    )

pyarrow.Table
Country: string
Region: string
Population: int64
------
Country: [["Bahrain ","Cyprus ","Gaza Strip ","Iraq ","Israel "]]
Region: [["NEAR EAST","NEAR EAST","NEAR EAST","NEAR EAST","NEAR EAST"]]
Population: [[698585,784301,1428757,26783383,6352117]]
DuckDB 테이블을 Arrow 형식으로 변환한 다음에는 Arrow 파이썬 API의 계산 능력을 끌어 쓸 수 있는 처지가 된다. 필터링, 컬럼 선택, 행 제한이라는 세 연산을 Arrow 쪽 문법으로 그대로 수행했다. 열거된 나라들—바레인, 키프로스, 가자 지구, 이라크, 이스라엘—은 NEAR EAST 지역을 대표한다.
출력에서 Country 값에 후행 공백이 남아 있다는 점이 눈에 걸린다. 6.4절에서 다듬은 것은 Region이었고 Country는 손대지 않았으니, 데이터가 정직하게 그대로 나온 것이다. 정제는 컬럼 단위의 노동이라는 사실이 이 한 줄에 남아 있다.
저자들의 맺음말이 이 절의 값어치를 말한다. 이 예제들은 Arrow와 Polars의 능력을 보여줄 뿐 아니라 데이터 저장과 계산 사이의 갈라진 틈을 잇는 잠재력도 보여준다. 어떤 결과든 Apache Arrow 객체로 변환할 수 있다는 것은 Arrow에 기반한 모든 스트리밍, 직렬화, 프로세스 간 통신(IPC)을 쓸 가능성을 열어준다.
← 좌우로 밀어 보기 →
SUMMARY

제6장이 남긴 여덟 문장Summary

  1. DuckDB의 파이썬 API는 데이터베이스 안의 테이블·뷰와 밖의 객체 사이의 선을 흐린다.
  2. DuckDB는 여러 종류의 객체를 균일한 방식으로 질의할 수 있게 해준다.
  3. Polars 데이터프레임, pandas 데이터프레임, Apache Arrow 테이블과 그 밖의 원천을 SQL 질의 안의 테이블인 것처럼 다룰 수 있다.
  4. 관계형 API는 재사용 가능한 질의 조각을 활용해 유지보수하기 좋은 애플리케이션을 쓰기 쉽게 만든다.
  5. 관계형 API는 뷰가 정적으로 하는 일을 동적인 관계로 비슷하게 재사용하게 해준다.
  6. 사용자 정의 함수는 파이썬 생태계의 라이브러리 전체의 기능을 SQL 질의 안에 매끄럽게 통합해 구현하게 해주어, 어떤 데이터 분석 경험에든 더 큰 유연함과 맞춤형 작업 흐름을 가져다준다.
  7. 데이터는 pandas 데이터프레임, Polars 데이터프레임, Apache Arrow 테이블을 포함한 여러 형식으로 내보낼 수 있다.
  8. 데이터프레임과 Apache Arrow 테이블과 그 밖의 원천을 똑같은 방식으로 다루는 DuckDB의 변환 능력 덕에, 여러 플랫폼 사이를 넘나드는 일이 훨씬 쉬워진다.