PySpark JDBC 대량 읽기·쓰기 — 단일 커넥션 병목과 운영 DB 보호
Spark 로 RDBMS 에서 수억 행을 읽을 때 단일 커넥션으로 한 익스큐터만 일하는 병목, partitionColumn 병렬 읽기, fetchsize·batchsize 튜닝, 그리고 운영 DB 를 마비시키지 않는 쓰기 전략을 정리합니다.
Spark 로 PostgreSQL·MySQL·Oracle 같은 RDBMS 에서 데이터를 읽고 쓰는 일은 흔합니다. 그런데 순진하게 spark.read.jdbc(...) 를 호출하면 단 하나의 커넥션으로 한 익스큐터만 일하고, 나머지 클러스터는 놀게 됩니다. 수억 행을 단일 스레드로 읽으니 몇 시간이 걸립니다. 반대로 병렬도를 무작정 높이면 운영 DB 가 커넥션 폭주로 마비됩니다.
이 글은 JDBC 대량 읽기를 병렬화하는 법, 핵심 튜닝 파라미터, 그리고 운영 DB 를 보호하면서 쓰는 전략을 정리합니다.
1. 기본 동작의 함정 — 단일 파티션
# 위험: 파티션 옵션 없이 읽으면 단일 커넥션 = 단일 태스크
df = (spark.read
.format("jdbc")
.option("url", "jdbc:postgresql://db:5432/app")
.option("dbtable", "events")
.option("user", "trino").option("password", "...")
.load())
# → 익스큐터 1대만 일함, 1억 행을 한 스레드로 ☠️파티션 옵션을 안 주면 Spark 는 쿼리 결과를 하나의 파티션으로 가져옵니다. 클러스터가 아무리 커도 소용없습니다.
2. 병렬 읽기 — partitionColumn
읽기를 병렬화하려면 Spark 에게 "어떤 컬럼으로, 몇 개로 쪼개서" 읽을지 알려줘야 합니다. Spark 는 이 정보로 범위를 나눈 여러 쿼리를 동시에 던집니다.
df = (spark.read
.format("jdbc")
.option("url", "jdbc:postgresql://db:5432/app")
.option("dbtable", "events")
.option("user", "trino").option("password", "...")
# 병렬 읽기 4종 세트
.option("partitionColumn", "id") # 분할 기준 (숫자/날짜, 인덱스 있는 컬럼)
.option("lowerBound", "1") # 최솟값
.option("upperBound", "100000000") # 최댓값
.option("numPartitions", "32") # 병렬도 = 동시 커넥션 수
.load())Spark 는 이를 이렇게 변환합니다:
파티션 1: WHERE id >= 1 AND id < 3125000
파티션 2: WHERE id >= 3125000 AND id < 6250000
...
파티션 32: WHERE id >= 96875000 AND id <= 100000000
→ 32개 커넥션이 동시에 각 범위를 읽음| 옵션 | 의미 | 주의 |
|---|---|---|
partitionColumn | 분할 기준 컬럼 | 인덱스 있는 숫자/날짜 |
lowerBound/upperBound | 범위 경계 | 데이터 분포와 맞아야 |
numPartitions | 병렬도(=동시 커넥션) | DB 부하와 직결 |
핵심:
lowerBound/upperBound는 필터가 아니라 범위 분할용입니다. 이 범위를 벗어난 데이터도 첫/마지막 파티션에 포함됩니다. 다만 경계가 실제 분포와 크게 어긋나면 파티션이 불균등해져 스큐가 납니다.
3. 분할 컬럼 스큐 — 가장 흔한 실패
partitionColumn 값이 고르게 분포하지 않으면, 어떤 파티션은 텅 비고 어떤 파티션은 거대해집니다.
id 가 1~100M 인데 실제로는 99%가 1~1M 에 몰려 있다면
→ 첫 파티션만 거대, 나머지 31개는 거의 빈 채로 끝남 → 병렬화 무의미대응:
- 균등 분포하는 컬럼을 고르세요(auto-increment ID, 균등한 타임스탬프).
- 분포가 치우치면
lowerBound/upperBound를 실제 분포에 맞추거나, 해시 기반 분할 컬럼을 미리 만듭니다. - 분포를 모르면 DB 에서 분위수를 먼저 조회해 경계를 정합니다.
4. fetchsize — 행을 한 번에 얼마나 가져오나
fetchSize 는 JDBC 드라이버가 한 번의 네트워크 왕복에 가져오는 행 수입니다. 기본값이 작으면(드라이버마다 다름, 일부는 10) 왕복이 폭증해 느립니다.
.option("fetchsize", "10000") # 한 번에 1만 행씩 가져옴| fetchsize | 효과 |
|---|---|
| 너무 작음 | 네트워크 왕복 폭증 → 느림 |
| 너무 큼 | 익스큐터 메모리 압박 |
| 적정(1k~10k) | 왕복·메모리 균형 |
PostgreSQL 은 fetchsize 를 설정해야 커서 기반 스트리밍이 동작합니다(안 하면 전체 결과를 클라이언트 메모리로). MySQL 은 별도 옵션이 필요할 수 있습니다. 드라이버별 동작을 확인하세요.
5. 운영 DB 보호 — 병렬도의 두 얼굴
numPartitions 는 양날의 검입니다. 높이면 빠르지만, 그만큼 운영 DB 에 동시 커넥션·쿼리 부하가 갑니다.
numPartitions = 64 → 운영 DB 에 64개 동시 풀스캔 쿼리
→ 운영 트랜잭션과 자원 경합, 커넥션 풀 고갈, DB 마비 가능운영 DB 보호 원칙:
| 원칙 | 방법 |
|---|---|
| 읽기 복제본 사용 | 운영 primary 대신 read replica 연결 |
| 병렬도 제한 | numPartitions 를 DB 가 감당할 수준으로 |
| 한가한 시간대 | 야간 배치로 대량 추출 |
| 커넥션 한도 인지 | DB 의 max_connections 대비 여유 |
| 쿼리 타임아웃 | DB 측 statement_timeout 으로 폭주 차단 |
철칙: 대량 JDBC 추출은 운영 primary 가 아니라 read replica 를 가리키세요. 분석 풀스캔이 운영 트랜잭션을 방해하면 안 됩니다. (Trino 페더레이션에서도 같은 원칙 — 별도 글 "Trino 페더레이션 실전".)
6. 대량 쓰기 — batchsize 와 멱등성
Spark → RDBMS 쓰기도 튜닝이 필요합니다.
(df.write
.format("jdbc")
.option("url", "...")
.option("dbtable", "target")
.option("batchsize", "5000") # INSERT 배치 크기
.option("numPartitions", "16") # 동시 쓰기 커넥션
.option("isolationLevel", "NONE") # 대량 적재 시 (신중히)
.mode("append")
.save())| 옵션 | 역할 |
|---|---|
batchsize | 배치 INSERT 행 수(왕복↓) |
numPartitions | 동시 쓰기 커넥션 |
truncate | overwrite 시 DROP 대신 TRUNCATE |
쓰기의 함정:
- 멱등성:
append재실행은 중복을 만듭니다. 재처리가 있으면 staging 테이블 적재 후 DB 쪽 MERGE/UPSERT 로, 또는 키 충돌 처리를 설계하세요. - 운영 부하: 대량 쓰기도 DB 에 부담. 병렬도·배치 크기를 조절.
- 대안: 대량을 RDBMS 에 직접 쓰기보다, Lakehouse(Iceberg/Delta)에 쓰고 DB 는 작은 결과만 받는 구조가 보통 낫습니다.
7. 패턴 선택
| 상황 | 권장 |
|---|---|
| 대량 읽기(전체 추출) | partitionColumn 병렬 + replica + 야간 |
| 증분 읽기 | WHERE 로 변경분만(dbtable 에 서브쿼리) |
| 대량 결과 쓰기 | Lakehouse 에 쓰기(DB 직접 쓰기 지양) |
| 소량 결과 쓰기 | batchsize 적정 + 멱등 설계 |
| 반복 조인 분석 | DB 스냅샷을 Lakehouse 로 적재 후 분석 |
# 증분 읽기: dbtable 에 서브쿼리로 변경분만
.option("dbtable", "(SELECT * FROM events WHERE updated_at >= '2026-06-01') AS t")8. 정리
| 영역 | 핵심 |
|---|---|
| 병렬 읽기 | partitionColumn + bounds + numPartitions |
| 분할 컬럼 | 균등 분포·인덱스 있는 컬럼 |
| fetchsize | 네트워크 왕복 줄이기 |
| DB 보호 | read replica, 병렬도 제한, 야간 |
| 쓰기 | batchsize, 멱등 설계, Lakehouse 우선 |
JDBC 대량 I/O 의 핵심은 두 가지 균형입니다 — 충분히 병렬화해 클러스터를 활용하되, 운영 DB 를 마비시키지 않는 선을 지키는 것. partitionColumn 으로 병렬 읽기를 켜고 fetchsize 로 왕복을 줄이되, read replica 를 쓰고 병렬도를 DB 가 감당할 수준으로 제한하세요. 그리고 대량 결과는 RDBMS 에 직접 쓰기보다 Lakehouse 에 적재하는 것이, 운영 DB 와 분석을 분리하는 가장 건강한 아키텍처입니다.
이 글은 Spark 3.5 기준으로 작성되었습니다. RDBMS-Lakehouse 연동이나 대량 추출 파이프라인 설계가 필요하시면 언제든 문의해 주세요.
— Data Dynamics 엔지니어링 팀