지금까지는 질의를 관리하고 실행하는 데 줄곧 DuckDB CLI를 썼다. 저자들은 그 도구의 값어치를 인정한다. 즉석 분석과 CLI 기반 파이프라인에 대단히 효과적이다. 그러나 많은 데이터 작업 흐름이 파이썬과 그 생태계를 상당한 정도로 끌어들인다. 예컨대 pandas 데이터프레임은 무시할 수 없다. 이 장에서 배우게 되는 것은 DuckDB의 파이썬 API가 단지 Python DB-API를 구현하는 데서 훨씬 더 나아간다는 사실이다.
파이썬 API는 두 가지를 해준다. 하나는 내 파이썬 프로세스 안에서 임베디드 데이터베이스를 쓰게 하는 것이고, 다른 하나는 파이썬 객체를 테이블처럼 질의하게 하는 것이다. 동시에 질의 결과를 데이터프레임으로 손쉽게 변환할 수 있다. 이 장은 DuckDB 파이썬 패키지에 직접 묶여 오는 통합에 초점을 둔다.
널리 쓰이는 파이썬 SQL 툴킷 SQLAlchemy는 다루지 않는다. SQLAlchemy는 여러 다른 데이터베이스를 추상화해 감싸고, 잘 알려진 엔터프라이즈급 영속화 패턴 한 벌을 파이썬에 들여오는데, 그것은 이 책의 범위를 벗어난다. duckdb_engine이라는 이름으로 SQLAlchemy용 드라이버를 얻을 수 있고, SQLAlchemy 기능의 거의 전부를 지원한다.
이 장의 작업 순서 — 번호 붙은 목록은 하나뿐이고, 본문 자체가 파이썬 세션 기록이다
- 6.1패키지를 설치하고, 열 필요조차 없는 연결을 확인한다
- 6.2CSV를 관계로 들이고, 메서드를 이어 붙여 질의를 조립한다
- 6.3pandas 데이터프레임을 테이블처럼 질의한다
- 6.4파이썬 함수를 SQL 함수로 등록해 지저분한 데이터를 고친다
- 6.5결과를 Polars와 Arrow로 넘기되, 되도록 늦게 넘긴다
시작하기Getting started
DuckDB 파이썬 패키지를 설치하고 어떤 의존성을 프로그램에 먼저 들여와야 하는지 익히는 것으로 시작한다. 그다음 인메모리 DuckDB 연결을 얻거나 데이터베이스 파일을 여는 서로 다른 선택지를 논한다. 이것을 파악해두는 일이 이 장뿐 아니라 이어지는 장들에도 중요하다. DuckDB와 상호작용하는 파이썬 생태계의 도구를 더 배우게 되기 때문이다.
파이썬 패키지 설치 6.1.1
DuckDB 파이썬 패키지는 PyPI에 배포되어 있다.
$ pip install duckdb >>> import duckdb >>> duckdb.__version__ '0.10.0'
데이터베이스 연결 열기 6.1.2
어떤 프로그래밍 환경에서 데이터베이스를 쓸 때 흔히 떠오르는 물음 가운데 하나가 연결을 어떻게 여는가다. DuckDB의 경우 답이 간단하다. 열 필요가 없다. 임베디드 데이터베이스로서 이미 여러분의 파이썬 프로세스 안에서 돌고 있기 때문이다. 패키지를 설치하고 나면 파이썬 인터프리터에서 duckdb를 그냥 써서 인메모리 데이터베이스와 상호작용할 수 있다.
여기서 이 장의 두 축이 갈라진다. sql은 DuckDB의 관계형 파이썬 API로 들어가는 입구이고, execute는 Python DB-API 2.0을 쓴다.
# ① 관계형 API >>> result = duckdb.sql('SELECT 42') # 객체를 정의하지만 아직 질의를 실행하지 않는다 >>> result.show() # 질의를 실행하고 결과를 인쇄한다 # ② Python DB-API 2.0. 전통적인 데이터베이스 API처럼 동작한다. >>> result = duckdb.execute('SELECT 42') # 즉시 실행하고 연결 객체를 돌려준다 >>> row = result.fetchone() # 연결에서 한 행을 가져온다 >>> print(row)
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와 전용 연결 객체 모두 데이터베이스와 상호작용하는 여러 메서드를 제공한다.
관계형 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 데이터베이스와 파이썬 데이터 구조 사이의 틈을 메워준다.
예제에서 read_csv를 쓰는 것은 제2장에서 준비한 CSV 파일—나라와 그 관련 통계를 담은—을 재사용하고 싶기 때문이다. 같은 개념과 기법이 read_parquet과 read_json에도 그대로 적용된다. 이 함수들은 DuckDB에서 SQL 함수로도, 파이썬 함수로도 존재한다.
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'>
execute 메서드를 쓰면 된다.
execute는 새 파이썬 DB 연결 객체를 주고, 거기서 결과를 가져온다.
더 남은 결과가 없을 때까지 fetchone으로 하나씩 가져오거나 fetchall로 한꺼번에 가져온다.
다만 fetchall은 형식이 없고 읽기 어려운 파이썬 튜플의 리스트를 돌려주므로 원서도 그 출력을 싣지 않았다.
그러나 관계형 API는 그보다 훨씬 많은 것이며 SQL 문을 쓰는 것의 대안으로 쓸 수 있다. 본질적으로 유창한(fluent) API이며 질의를 점진적으로 구성하게 해준다. DuckDBPyRelation 노드와 관계들로 이루어지고, 그것들은 SQL 질의의 상징적 표상으로 볼 수 있다. 그 노드의 재사용을 지원하고 집합 연산, 필터, 사영, 집계를 지원한다.
그리고 이 API의 성격을 규정하는 한 문장이 여기 있다. 관여하는 어떤 객체도 데이터를 들고 있지 않으며, 명시적 fetch나 보여주기 같은 트리거 메서드가 호출될 때까지 어떤 질의도 실행되지 않는다.
>>> (population .count("*") # SQL의 SELECT count(*)에 해당한다 .show() )
| count_star() |
|---|
| int64 |
| 227 |
# to_table로 DuckDB 테이블에 영속화해 이 문제를 고친다. # 이 함수는 뒤에서 대신 Create Table as Select 문을 수행해준다. >>> population.to_table("population") >>> population_table = con.table("population") >>> population_table.count("*").show() # 이번에는 즉시 표시된다
type(population_table)을 불러보면 그것 역시 DuckDBPyRelation임을 알게 되고,
따라서 앞서와 같은 성질과 능력을 갖는다. 파일에서 온 관계와 테이블에서 온 관계가 같은 타입이라는 것이
오리 시험의 실물 증거다.
질의 조립하기Composing queries
지금까지는 주로 관계의 레코드를 세는 법만 살펴보았다. 그것은 능력의 일부만 엿본 것이다. API는 DuckDBPyRelation을 위한 함수 한 벌을 제공한다.
| 메서드 | 하는 일 | 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 관계는 테이블이나 뷰, 공통 테이블 표현식이 하는 것과 똑같이 행동한다. 그것으로 지역별 집계를 구동하기도 하고, 필터를 하나 더 얹어 확장하기도 한다. 두 경우 모두 원래 정의를 손대지 않고 재사용했다.
패턴 매칭
동유럽 나라를 골라낼 때 쓴 Region ~ '.*EASTERN EUROPE.*'의 물결표는 정규식으로 필터링하는 POSIX 방식 연산자다. DuckDB는 패턴 매칭을 네 가지 방식으로 지원한다.
- LIKE — 문자열이 패턴 전체와 일치하는지 검사한다.
%와_를 와일드카드로 쓸 수 있다. - SIMILAR TO — 문자열이 정규식 전체와 일치하는지 검사한다.
- GLOB — 문자열이 GLOB 패턴과 일치하는지 검사한다. 특정 패턴을 따르는 파일 이름을 찾을 때 요긴하다.
- 함수를 통한 일반적인 정규식
그리고 SQL 연산자에는 줄임 기호가 있다. ~~가 LIKE, ~가 SIMILAR TO, 그리고 GLOB이다.
원서는 줄임 기호를 "~~가 LIKE, ~가 SIMILAR TO, ~가 GLOB"이라고 적었다. 같은 기호 하나가 서로 다른 두 연산자를 동시에 뜻할 수는 없으니 자체로 모순이다. DuckDB에서 GLOB의 줄임 기호는 물결표 셋(~~~)이며, 물결표 하나는 정규식 일치에 쓰인다. 판본에 따라 달라질 수 있는 대목이니 현재 문서로 확인하고 쓰는 편이 안전하다.
참고로 예제가 패턴 앞뒤에 .*를 붙인 이유가 여기서 드러난다. SIMILAR TO 계열은 문자열 전체와의 일치를 요구하므로, 부분 일치를 원하면 양쪽을 열어두어야 한다.
SQL로 질의하기SQL querying
관계형 객체는 SQL 테이블과 똑같은 유연함으로 다룰 수 있다. 이 능력이 파이썬 기반 데이터 연산과 SQL식 질의 사이를 유동적으로 넘나들게 해준다. 위 장치의 마지막 탭이 그 실물이다. FROM over_10m이라고 적었지만 over_10m은 테이블이 아니라 파이썬 변수다. 그런데 질의 안에서는 테이블처럼 취급된다.
관계형 API가 방대한 기능을 제공하기는 하지만, 특히 매개변수화된 질의가 관여하는 어떤 시나리오에서는 그 타고난 능력이 제약처럼 느껴질 수 있다. 그 틈을 메우기 위해 DuckDB는 Python DB-API 2.0을 따르는 execute 함수를 제공한다.
문장 텍스트에 자리표(placeholder)를 쓰는 질의다. 문장 텍스트와 자리표에 들어갈 실제 값—매개변수—이 서로 독립적으로 엔진에 전달된다. 매개변수를 쓰는 데는 이점이 여럿 있다.
예컨대 사용자 입력은 언제나 매개변수로 전달해야 하고 절대로 문장 텍스트에 직접 넣어서는 안 된다. 문장의 문자열 조각을 사용자 입력과 그냥 이어 붙이면 이른바 SQL 주입 공격의 위험이 높아진다. 특별히 조작된 텍스트가 질의의 의미를 바꿔버리는 공격이다. 질의 엔진에 매개변수로 전달되면 엔진은 문자열이 결코 질의를 망가뜨리지 않도록 입력을 다룬다.
여기에 하나가 더 붙는다. 매개변수화된 문장은 질의 엔진이 한 번만 파싱하면 될 수 있다. 내용이 상수이고 매개변수만 바뀌기 때문이다.
그리고 이 절의 마지막 문단이 이 장 전체의 지적 정점이다. 저자들의 논리를 그대로 옮기면 이렇다. execute에 넘긴 질의는 이름 붙은 매개변수를 쓰며 여느 준비된 문장과 똑같이 보인다. 그런데 그것이 질의하는 대상은 실제 테이블이 아니라 관계형 객체 over_10m이다. 게다가 그 관계형 객체 자체도 사영할 컬럼이나 질의할 밑단 테이블에 매개변수를 쓰는 방식으로 만들어졌을 수 있다. 그래서 모든 경우에 문자열 이어 붙이기를 피할 수 있다.
두 API의 한계가 서로의 빈자리를 정확히 메운다는 이 구도는, 설계가 잘된 도구에서만 볼 수 있는 종류의 균형이다. 어느 한쪽이 다른 쪽을 대체하려 하지 않았기에 얻어진 결과다.
pandas 데이터프레임 질의하기Querying pandas DataFrames
데이터 생태계에서 DuckDB가 얻은 인기는 튼튼한 질의 엔진에 크게 힘입은 것이다. 파일 기반 연산에만 국한되지 않고, 이 엔진은 여러 데이터 인프라 도구의 인메모리 데이터 구조와 매끄럽게 통합된다. 그 영역에서 두드러진 도구 하나가 pandas다. 데이터 공동체에서 널리 쓰이는, 자리를 굳힌 오픈소스 데이터 분석 라이브러리다. DuckDB와 pandas의 결합은 데이터베이스 엔진의 능력과 데이터 조작 라이브러리의 유연함을 잇는다.
$ 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' """)
| name | country |
|---|---|
| varchar | varchar |
| Michael Hunger | Germany |
| Michael Simons | Germany |
people에 SQL식 질의를 돌릴 수 있다.
# 독일에 살지 않는 사람을 매개변수화된 질의로 찾는다. # 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가 결과 집합에서 데이터프레임을 만들어주는 지루한 일을 대신해준다.
SQLAlchemy 드라이버 duckdb_engine도 데이터프레임 질의를 지원한다. 드라이버 인스턴스에 데이터프레임을 등록해서 쓸 수 있다.
사용자 정의 함수User-defined functions
데이터를 다루는 사람이라면 기존 데이터베이스 함수가 내 특정한 필요를 채워주지 못하는 상황을 자주 만난다. 그것을 알기에 DuckDB는 강력한 기능을 갖췄다. 파이썬 패키지 안에서 사용자 정의 함수(UDF)를 만드는 능력이다.
UDF의 아름다움은 SQL 언어의 고유 능력을 확장하는 데 있다. 사용자가 자기만의 맞춤 함수를 정의할 수 있고, 파이썬 라이브러리의 방대한 생태계에서 이득을 본다. 복잡한 데이터 조작이든 수학적 계산이든 외부 도구와 API와의 통합이든, 가능성이 사실상 무한해진다. 실용적으로 말하면 이렇다. DuckDB에서 SQL 질의를 쓰다가 어떤 특정 함수가 있었으면 하고 바란 적이 있다면, 이제 파이썬으로 그것을 만들어 SQL 코드 안에서 불러 쓸 수 있다.
공백 서른다섯 칸의 문제
데이터 수집은 어떤 분석 파이프라인에서나 기본 단계이지만 그 자체의 어려움을 함께 데려온다. 원자료는 지저분할 수 있고, 의미 있는 분석을 하기 전에 바로잡아야 할 오류를 만나는 일이 드물지 않다. 이 장 처음에 수집한 CSV 파일에도 손봐야 할 별스러움이 하나 나타났다.
문제는 데이터셋의 Region 필드에 있다. 이 필드가 지나친 공백 문자로 채워져 있어 데이터 처리와 분석이 번거로워진다. 문제의 크기를 눈으로 보기 위해 유일한 Region 값과 각각의 총 문자 길이를 구해본다.
>>> con.sql(""" select DISTINCT Region, length(Region) AS numChars from population """)
| Region | numChars |
|---|---|
| varchar | int64 |
| 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로 어중간한 것들도 섞여 있어 규칙성마저 없다.
trim이 있다. 그런데도 저자들은 자기 버전을 만든다.
구현 세부에 정신을 팔지 않고 UDF를 어떻게 정의하는지에 초점을 맞출 수 있는 쉬운 예제이기 때문이다.
교재로서 옳은 선택이다.
# 타입 주석을 붙인 파이썬 함수를 정의한다. 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_name | function_type | parameters | parameter_types | return_type |
|---|---|---|---|---|
| varchar | varchar | varchar[] | varchar[] | varchar |
| remove_spaces | scalar | [col0] | [VARCHAR] | VARCHAR |
con.sql("select length(remove_spaces(' foo '))")처럼 length로 감싸 부른 이유가 실용적이다.
그러지 않으면 파이썬 프로그램의 출력에서 앞뒤 공백이 잘렸는지 알아보기 어렵기 때문이다.
타입을 명시하는 것은 명료함을 위해, 타입 추론의 함정을 막기 위해, 그리고 서로 다른 환경에서 일관된 동작을 보장하기 위해 이롭다. 함수를 명시적 타입으로 다시 정의하려면 충돌을 피하기 위해 먼저 앞서 등록한 판본을 제거해야 한다.
>>> con.remove_function('remove_spaces') >>> from duckdb.typing import * >>> con.create_function( 'remove_spaces', remove_spaces, [(VARCHAR)], # VARCHAR 입력 매개변수 하나 VARCHAR # VARCHAR를 반환한다 )
[(VARCHAR)]의 괄호는 파이썬에서 아무 일도 하지 않는다. 튜플이 아니라 그냥 괄호로 감싼 표현식이므로
이 리스트는 [VARCHAR]와 동일하다. 매개변수가 여럿일 때 튜플로 묶어야 한다고 오해할 만한 표기이니,
읽을 때 괄호에 뜻을 부여하지 않는 편이 좋다.
>>> 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 """)
| Region | len1 | cleanRegion | len2 |
|---|---|---|---|
| varchar | int64 | varchar | int64 |
| ASIA (EX. NEAR EAST) | 29 | ASIA (EX. NEAR EAST) | 20 |
| LATIN AMER. & CARIB | 23 | LATIN AMER. & CARIB | 19 |
| C.W. OF IND. STATES | 20 | C.W. OF IND. STATES | 19 |
len1과 len2의 차이를 한눈에 보면 함수가 실제로 그 부당한 공백을 떼어냈음이 분명해진다.
WHERE 절에서 선택 목록의 별칭 len1을 쓴 것이 제3장 3.5.3절에서 배운 DuckDB의 확장이다.
# 함수가 기대대로 동작한다는 확신이 생겼으니 원본 데이터셋을 갱신한다. >>> con.sql(""" UPDATE population SET Region = remove_spaces(Region); """)
| Region | numChars |
|---|---|
| varchar | int64 |
| ASIA (EX. NEAR EAST) | 20 |
| EASTERN EUROPE | 14 |
| NORTHERN AFRICA | 15 |
| OCEANIA | 7 |
| WESTERN EUROPE | 14 |
| SUB-SAHARAN AFRICA | 18 |
| LATIN AMER. & CARIB | 19 |
| C.W. OF IND. STATES | 19 |
| NEAR EAST | 9 |
| NORTHERN AMERICA | 16 |
| BALTICS | 7 |
| 11 rows 2 columns | |
Region 값을 둘러싼 여분의 공백이 제거되어
데이터에 더 나은 구조와 균일함이 생겼다.
쉼표를 점으로 바꾸는 것과 로케일을 이해하는 것
다양한 출처의 데이터를 다루면 지역적 차이에서 비롯되는 어려움이 흔히 끼어든다. 그 훌륭한 예가 소수의 표기다. 유럽 지역에서는 다른 지역이 쓰는 마침표(.) 대신 쉼표(,)를 소수 구분자로 쓰는 것이 통상이다. 데이터를 데이터베이스에 들일 때 이런 로케일별 표기법은 복잡함을 불러오며, 시스템의 로케일이 데이터의 형식과 맞지 않으면 더욱 그렇다.
이 데이터셋에서도 소수를 표상하는 필드에서 그런 어려움을 만났다. 유럽식 형식 때문에 DuckDB가 그것들을 VARCHAR로 해석했고, 수치 분석을 방해할 수 있다. 바로잡기 위해 파이썬의 광범위한 라이브러리 생태계를 활용한다. locale 모듈이 이 특정한 어려움에 해법을 준다.
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 """)
| coastline | cleanCoastline | popDen | cleanPopDen |
|---|---|---|---|
| varchar | double | varchar | double |
| 0,00 | 0.0 | 48,0 | 48.0 |
| 1,26 | 1.26 | 124,6 | 124.6 |
| 0,04 | 0.04 | 13,8 | 13.8 |
| 58,29 | 58.29 | 290,4 | 290.4 |
| 0,00 | 0.0 | 152,1 | 152.1 |
0,00 같은 값을 0.0으로 성공적으로 변환했다.
그리고 저자들이 붙인 단서가 이 절의 요점이다.
쉼표를 점으로 무턱대고 갈아 끼운 것이 아니라, 지금 다루는 것이 지역화 문제라는 의미론적 자각을 갖고 그렇게 한 것이다.
# 함수의 동작에 확신이 생기면 변경을 데이터셋에 영구히 반영하는 것이 현명하다. # ALTER TABLE 절은 컬럼 타입 변경과 값 갱신을 동시에 하게 해준다. >>> con.sql(""" ALTER TABLE population ALTER "Coastline (coast/area ratio)" SET DATA TYPE DOUBLE -- 데이터 타입을 double로 갱신한다 USING convert_locale("Coastline (coast/area ratio)") -- 이 컬럼의 모든 값을 우리 함수로 갱신한다 """)
Coastline (coast/area ratio)은 바로 위의
ALTER TABLE이 방금 처리한 컬럼이다. 목록에서 빠져야 할 이름이 남아 있다.
실제로 남은 것은 Pop. Density, Birthrate, Deathrate 셋이다.
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가 그 바닥에 직접 닿는다는 것이 이 절의 요지다.
# 실행 중인 파이썬 인터프리터를 나가지 않고 다른 셸에서 실행할 것. # 앞서 정의한 객체와 관계를 그대로 살려두어야 한다. $ 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 │ └────────────────┴──────────────────────┴────────────┘
head(5)로 첫 다섯 행을 얻을 수도 있었다.
그러면 모든 행이 파이썬 런타임으로 실체화된 뒤에 클라이언트 쪽 변환이 따라오게 된다.
순서 하나가 310만 행을 옮길지 다섯 행을 옮길지를 가른다.
>>> 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]]
Country 값에 후행 공백이 남아 있다는 점이 눈에 걸린다.
6.4절에서 다듬은 것은 Region이었고 Country는 손대지 않았으니, 데이터가 정직하게 그대로 나온 것이다.
정제는 컬럼 단위의 노동이라는 사실이 이 한 줄에 남아 있다.
제6장이 남긴 여덟 문장Summary
- DuckDB의 파이썬 API는 데이터베이스 안의 테이블·뷰와 밖의 객체 사이의 선을 흐린다.
- DuckDB는 여러 종류의 객체를 균일한 방식으로 질의할 수 있게 해준다.
- Polars 데이터프레임, pandas 데이터프레임, Apache Arrow 테이블과 그 밖의 원천을 SQL 질의 안의 테이블인 것처럼 다룰 수 있다.
- 관계형 API는 재사용 가능한 질의 조각을 활용해 유지보수하기 좋은 애플리케이션을 쓰기 쉽게 만든다.
- 관계형 API는 뷰가 정적으로 하는 일을 동적인 관계로 비슷하게 재사용하게 해준다.
- 사용자 정의 함수는 파이썬 생태계의 라이브러리 전체의 기능을 SQL 질의 안에 매끄럽게 통합해 구현하게 해주어, 어떤 데이터 분석 경험에든 더 큰 유연함과 맞춤형 작업 흐름을 가져다준다.
- 데이터는 pandas 데이터프레임, Polars 데이터프레임, Apache Arrow 테이블을 포함한 여러 형식으로 내보낼 수 있다.
- 데이터프레임과 Apache Arrow 테이블과 그 밖의 원천을 똑같은 방식으로 다루는 DuckDB의 변환 능력 덕에, 여러 플랫폼 사이를 넘나드는 일이 훨씬 쉬워진다.