Oracle 의 wide 테이블을 읽어 long 형태로 변환하는 파이프라인을 두 가지 스택으로 구성해 비교한 내용을 정리한다. 전통적인 경로는 oracledb 의 행 단위 fetch 에 pandas 를 붙이는 것이고, 새로운 경로는 Arrow 컬럼형 배치 fetch 에 polars 를 붙이는 것이다.
| 항목 | pandas | polars |
|---|---|---|
| 내부 구조 | NumPy 기반, 단일 스레드 위주 | Rust + Apache Arrow, 멀티 스레드 |
| Oracle 읽기 | pd.read_sql (SQLAlchemy + oracledb) |
pl.read_database_uri(connectorx) · pl.read_database(DBAPI) |
| 지연 연산 | 없음 | LazyFrame 으로 쿼리 최적화·스트리밍 |
| 대용량 처리 | chunksize 분할 반복 |
connectorx 파티션 병렬 읽기 + 스트리밍 |
| 메모리 | object 타입이 많아 상대적으로 큼 | Arrow 컬럼 포맷으로 효율적 |
| 쓰기 | df.to_sql (executemany) |
write_database 또는 oracledb 직접 |
같은 작업을 서로 다른 스택으로 수행해 비교한 스크립트에서 드러난 차이는 다음과 같다.
| 구분 | 경로 A (전통) | 경로 B (Arrow) |
|---|---|---|
| fetch | cursor.fetchmany() — 행을 파이썬 튜플 리스트로 |
conn.fetch_df_batches() — Arrow 컬럼형 배치 |
| DataFrame | pandas | polars |
| 변환 | pd.DataFrame(rows, columns=cols) |
pl.from_arrow(...) |
| unpivot | .melt() |
.unpivot() |
| 후처리 | 정규식 추출, astype int16 · category |
polars 표현식, cast Int16 · Categorical |
| 요구 버전 | 제약 없음 | oracledb 2.4 이상 |
경로 A 는 행마다 파이썬 튜플 객체를 만들어 리스트에 쌓으므로 메모리를 많이 쓰고 GIL 에 묶인다. 경로 B 는 Arrow 컬럼형 버퍼를 그대로 polars 로 넘겨 행 단위 파이썬 객체 생성을 건너뛴다.
측정 시 주의할 점이 하나 있다. 경로 A 는 execute() 와 첫 배치 도착을 별도 단계로 잴 수 있지만, 경로 B 의 fetch_df_batches 는 지연 실행이라 execute 가 첫 배치를 당길 때 수행되므로 "execute + 첫 배치"를 하나로 묶어 측정해야 한다. 두 경로의 결과가 실제로 같은지는 행 수, 라벨 집합, NULL · 0 제거 여부, 합계를 대조해 확인한다.
자격 증명은 코드에 넣지 않고 환경변수에서 읽는다. 비밀번호에 특수문자가 있으면 URL.create 로 안전하게 조립한다.
import os
from sqlalchemy import create_engine, URL
ora_url = URL.create(
"oracle+oracledb",
username=os.environ["ORA_USER"],
password=${MASKED}"ORA_PASSWORD"],
host=os.environ["ORA_HOST"],
port=int(os.environ["ORA_PORT"]),
query={"service_name": os.environ["ORA_SERVICE"]},
)
engine = create_engine(ora_url)
pandas 는 파라미터 바인딩을 쓰고 대용량은 chunksize 로 나눈다.
import pandas as pd
from sqlalchemy import text
query = text("""
SELECT id, dt, region, amount
FROM sales
WHERE dt = :dt AND region = :region
""")
df = pd.read_sql(query, engine, params={"dt": "2026-01-01", "region": "APAC"})
for chunk in pd.read_sql(query, engine, params={"dt": "2026-01-01", "region": "APAC"},
chunksize=100_000):
process(chunk)
polars 는 두 방식이 있고 성능과 보안의 절충이 다르다. connectorx 는 가장 빠르고 파티션 병렬 읽기를 지원하지만 URI 에 자격 증명이 들어간다.
import polars as pl
from urllib.parse import quote
uri = f"oracle://{user}:{quote(pwd)}@{host}:{port}/{service}"
df = pl.read_database_uri(
query="SELECT id, dt, region, amount FROM sales WHERE region = 'APAC'",
uri=uri,
engine="connectorx",
partition_on="id",
partition_num=8,
)
DBAPI 커넥션 방식은 connectorx 보다 느리지만 URI 에 비밀번호를 넣지 않고 바인딩이 자연스럽다.
import oracledb
import polars as pl
conn = oracledb.connect(user=user, password=pwd, dsn=f"{host}:{port}/{service}")
df = pl.read_database(
query="SELECT id, dt, region, amount FROM sales WHERE region = :1",
connection=conn,
execute_options={"parameters": ["APAC"]},
)
conn.close()
# pandas — 즉시 실행
result = (
df[df["amount"] > 0]
.groupby("region", as_index=False)
.agg(total=("amount", "sum"), cnt=("id", "count"))
)
# polars — 표현식 기반, lazy 로 최적화
result = (
df.lazy()
.filter(pl.col("amount") > 0)
.group_by("region")
.agg(
pl.col("amount").sum().alias("total"),
pl.col("id").count().alias("cnt"),
)
.collect(streaming=True)
)
lazy() → collect() 구조는 필터와 프로젝션을 앞으로 밀어내는 최적화를 자동으로 하므로 파이프라인이 복잡할수록 유리하다.
df.to_sql("sales_summary", engine, if_exists="append", index=False,
method="multi", chunksize=10_000)
df.write_database(table_name="sales_summary", connection=str(ora_url),
if_table_exists="append", engine="sqlalchemy")
대량 삽입은 oracledb 의 executemany 로 직접 제어하는 편이 가장 빠르다.
rows = df.rows()
conn = oracledb.connect(user=user, password=pwd, dsn=f"{host}:{port}/{service}")
with conn.cursor() as cur:
cur.executemany(
"INSERT INTO sales_summary (region, total, cnt) VALUES (:1, :2, :3)", rows)
conn.commit()
conn.close()
"polars 는 항상 메모리 효율적"이라는 통념에는 조건이 붙는다. peak 메모리는 오히려 polars 가 클 수 있다.
기본적으로 모든 코어를 써서 병렬 처리하므로 여러 스레드의 중간 버퍼가 한꺼번에 메모리에 올라간다. connectorx 의 partition_num=8 은 8개 쿼리 결과가 동시에 로드·조립된다는 뜻이고, Arrow 재조립(rechunk)까지 겹치면 순간 사용량이 크게 튄다. to_pandas() 왕복은 Arrow 와 NumPy 두 표현을 동시에 들고 있어 데이터가 잠깐 두 배가 된다. lazy 없이 eager 로 체이닝하면 단계마다 완전한 새 프레임이 생긴다.
완화는 POLARS_MAX_THREADS 로 병렬도를 낮추고, lazy() + collect(streaming=True) 로 청크 단위 실행하며, 필요한 컬럼만 select 해 projection pushdown 으로 로드량 자체를 줄이는 것이다. 읽기 단계에서는 partition_num 을 낮추거나 파티션을 끄고 순차 로드한다.
NUMBER — 정밀도가 큰 값은 pandas 에서 float64 로 들어와 손실이 생길 수 있다. polars 는 매핑이 더 정확하지만 NUMBER(38) 같은 값은 문자열이나 Decimal 처리를 검토한다.CLOB · BLOB · LONG — connectorx 가 지원하지 않는 경우가 있어 DBAPI 방식이나 pandas 로 읽는 편이 안전하다.DATE · TIMESTAMP — 타임존 처리가 라이브러리마다 달라 로드 후 타입을 확인한다.데이터 규모가 중간 이하이거나 scikit-learn 같은 기존 생태계와 연동하거나 복잡한 Oracle 타입이 많으면 pandas 가 낫다. 수백만 행 이상이거나 ETL 성능이 중요하거나 메모리 제약으로 스트리밍이 필요하면 polars 가 낫고, 특히 connectorx 의 파티션 병렬 읽기는 대량 추출에서 체감 차이가 크다. polars 로 로드·처리한 뒤 to_pandas() 로 넘기는 하이브리드도 쓰이지만, 변환 시점의 메모리 두 배 문제를 감안해야 한다.