Spark + LLM 연동 가이드 - 대규모 데이터에 AI 적용하기
Apache Spark 환경에서 LLM을 활용하는 방법을 정리합니다. Spark UDF로 LLM 호출, 배치 추론, 데이터 라벨링, 텍스트 분류, 요약, 임베딩 생성 파이프라인을 다룹니다.
수백만 건의 고객 리뷰를 분류하거나, 수십만 개의 로그를 구조화해야 한다고 상상해보세요. LLM 하나로는 감당이 안 됩니다 — API를 순서대로 호출하면 며칠이 걸리거든요. 이때 Spark가 등장합니다. 수십 개의 실행기가 병렬로 LLM을 호출하면 처리량이 몇십 배 올라갑니다.
Apache Spark의 분산 처리 능력과 LLM의 언어 이해 능력을 결합하면 대규모 텍스트 데이터를 효율적으로 처리할 수 있습니다. 이 글에서는 Spark 환경에서 LLM을 활용하는 실전 방법을 다룹니다.
이 글에서 배우는 것
- Spark + LLM 연동이 필요한 활용 시나리오와 아키텍처 패턴
- UDF와 mapPartitions를 이용한 LLM 호출 구현 방법
- 대규모 임베딩 생성 파이프라인 설계
- 성능 최적화 전략과 비용 추정 방법
- 실전 코드 예시 (감성 분류, 로그 구조화)
1. Spark + LLM 연동의 필요성
활용 시나리오
"LLM으로 할 수 있는 일" 중 데이터 규모가 커지면 Spark가 필요해집니다. 아래 표는 그 경계선을 보여줍니다 — 수만 건은 단순 스크립트로 처리할 수 있지만, 수백만 건부터는 분산 처리 없이는 현실적이지 않습니다.
| 시나리오 | 데이터 규모 | LLM 역할 | 예시 |
|---|---|---|---|
| 대량 텍스트 분류 | 수백만 행 | 감성 분석, 카테고리 분류 | 고객 리뷰 분류 |
| 배치 요약 | 수만~수십만 건 | 문서 요약 | 뉴스 기사 요약 |
| 데이터 라벨링 | 수십만 행 | 자동 레이블 생성 | 학습 데이터 구축 |
| 임베딩 생성 | 수백만 행 | 벡터 변환 | RAG용 벡터 DB 구축 |
| 데이터 정제 | 수백만 행 | 주소 표준화, 오타 수정 | 데이터 품질 향상 |
| 정보 추출 | 수십만 건 | NER, 관계 추출 | 비정형 데이터 구조화 |
아키텍처 패턴
Spark 클러스터의 각 Executor가 LLM 서버를 병렬로 호출하는 구조입니다. LLM 서버를 어디에 두느냐에 따라 세 가지 패턴으로 나뉩니다.
세 가지 연동 패턴이 있습니다. 팀 규모와 인프라 현황에 따라 골라보세요.
- 패턴 A: 외부 API 호출 (OpenAI, Claude)
- 패턴 B: 클러스터 내 vLLM 서버
- 패턴 C: 각 Executor에서 Ollama 실행
2. Spark UDF로 LLM 호출
기본 UDF 구현
Spark UDF(User Defined Function)는 DataFrame의 각 행에 함수를 적용하는 가장 단순한 방법입니다. LLM 호출을 UDF로 감싸면 기존 Spark 코드에 최소한의 변경만으로 LLM을 붙일 수 있습니다.
from pyspark.sql import SparkSession
from pyspark.sql.functions import udf, col
from pyspark.sql.types import StringType
import requests
spark = SparkSession.builder.appName("SparkLLM").getOrCreate()
# LLM 호출 UDF
@udf(returnType=StringType())
def classify_sentiment(text):
"""텍스트 감성 분류 (Ollama 로컬 서버)"""
if not text:
return "unknown"
try:
response = requests.post(
"http://ollama-server:11434/api/generate",
json={
"model": "llama3.1:8b",
"prompt": f'다음 리뷰의 감성을 "긍정", "부정", "중립" 중 하나로 분류하세요. 답만 쓰세요.\n\n리뷰: {text}\n\n감성:',
"stream": False,
"options": {"temperature": 0.0, "num_predict": 10}
},
timeout=30
)
return response.json()["response"].strip()
except Exception as e:
return f"error: {str(e)}"
# 적용
df = spark.read.parquet("s3://data-lake/reviews/")
result = df.withColumn("sentiment", classify_sentiment(col("review_text")))
result.write.parquet("s3://data-lake/reviews_classified/")배치 최적화: mapPartitions 활용
기본 UDF는 건별로 API를 호출하기 때문에 연결 비용이 큽니다. mapPartitions를 쓰면 파티션 단위로 연결을 재사용하고, 여러 건을 묶어 한 번에 처리할 수 있어 처리량이 크게 올라갑니다.
팁:
mapPartitions에서requests.Session()을 파티션당 한 번만 생성하면 커넥션 풀이 재사용되어 네트워크 오버헤드가 줄어듭니다.
from pyspark.sql import Row
import requests
def process_partition(partition):
"""파티션 단위 배치 처리 (연결 재사용)"""
session = requests.Session() # 커넥션 풀 재사용
batch = []
results = []
for row in partition:
batch.append(row)
if len(batch) >= 10: # 10건씩 배치
prompts = [r.review_text for r in batch]
classifications = batch_classify(session, prompts)
for r, cls in zip(batch, classifications):
results.append(Row(id=r.id, review_text=r.review_text, sentiment=cls))
batch = []
# 나머지 처리
if batch:
prompts = [r.review_text for r in batch]
classifications = batch_classify(session, prompts)
for r, cls in zip(batch, classifications):
results.append(Row(id=r.id, review_text=r.review_text, sentiment=cls))
session.close()
return iter(results)
def batch_classify(session, texts):
"""vLLM 배치 API 호출"""
response = session.post(
"http://vllm-server:8000/v1/chat/completions",
json={
"model": "meta-llama/Llama-3.1-8B-Instruct",
"messages": [{"role": "user", "content": f"Classify sentiments: {texts}"}],
"temperature": 0.0
}
)
return parse_classifications(response.json())
# 적용
result_rdd = df.rdd.mapPartitions(process_partition)
result_df = spark.createDataFrame(result_rdd)3. 대규모 임베딩 생성
Spark + 임베딩 모델 파이프라인
수백만 개의 문서를 벡터로 변환해 RAG용 벡터 DB를 구축하는 시나리오를 생각해봅시다. 모델을 각 Executor에서 한 번씩만 로드하는 것이 핵심입니다 — 매 행마다 모델을 새로 로드하면 시간이 폭발적으로 늘어납니다.
from sentence_transformers import SentenceTransformer
from pyspark.sql.types import ArrayType, FloatType
import numpy as np
# 브로드캐스트 모델 (각 Executor에서 1번만 로드)
model_broadcast = None
def get_model():
global model_broadcast
if model_broadcast is None:
model_broadcast = SentenceTransformer("BAAI/bge-m3")
return model_broadcast
@udf(returnType=ArrayType(FloatType()))
def generate_embedding(text):
"""텍스트를 벡터로 변환"""
if not text:
return None
model = get_model()
embedding = model.encode(text, normalize_embeddings=True)
return embedding.tolist()
# 대규모 임베딩 생성
df = spark.read.parquet("s3://data-lake/documents/")
embedded = df.withColumn("embedding", generate_embedding(col("content")))
# 벡터 DB에 적재할 수 있는 형태로 저장
embedded.write.parquet("s3://data-lake/embeddings/", mode="overwrite")파티션 기반 배치 임베딩
GPU가 있다면 배치로 묶어서 한 번에 임베딩하는 것이 훨씬 효율적입니다. GPU는 병렬 행렬 연산에 최적화되어 있어서, 64개씩 묶어 처리하면 1개씩 처리할 때보다 훨씬 빠릅니다.
def embed_partition(partition):
"""파티션 단위 배치 임베딩 (훨씬 효율적)"""
model = SentenceTransformer("BAAI/bge-m3")
rows = list(partition)
if not rows:
return iter([])
texts = [r.content for r in rows]
# 배치로 한 번에 임베딩 (GPU 활용 극대화)
embeddings = model.encode(texts, batch_size=64, normalize_embeddings=True)
results = []
for row, emb in zip(rows, embeddings):
results.append(Row(id=row.id, content=row.content, embedding=emb.tolist()))
return iter(results)
# 적용 (파티션 수 = GPU 수에 맞춰 조정)
df = df.repartition(8) # 8개 파티션 = 8개 GPU
result = spark.createDataFrame(df.rdd.mapPartitions(embed_partition))4. 실전 활용 사례
실제로 많이 쓰이는 두 가지 패턴을 살펴봅시다. 프롬프트 구조를 보면 LLM에게 어떻게 지시하면 원하는 형태로 데이터를 뽑아낼 수 있는지 감이 옵니다.
고객 리뷰 자동 분석
# 1. 감성 분류 + 핵심 키워드 추출 + 요약
pipeline_prompt = """다음 고객 리뷰를 분석하세요.
리뷰: {review}
JSON으로 반환:
{{"sentiment": "긍정/부정/중립", "keywords": ["키워드1", "키워드2"], "summary": "한줄 요약", "category": "배송/품질/가격/서비스/기타"}}
"""비정형 로그 구조화
# 서버 로그를 구조화된 데이터로 변환
log_prompt = """다음 로그 라인을 분석하여 JSON으로 변환하세요.
로그: {log_line}
{{"timestamp": "", "level": "", "service": "", "message": "", "error_type": "", "stack_trace": ""}}
"""5. 성능 최적화 전략
한 문장으로: 건별 API 호출을 배치로 바꾸고, 파티션 수를 LLM 서버 수에 맞추는 것만으로 처리량이 5~10배 올라갑니다.
성능과 비용을 동시에 잡는 핵심 전략들을 정리합니다.
| 전략 | 설명 | 효과 |
|---|---|---|
| 배치 처리 | 건별 API 호출 대신 배치 요청 | 처리량 5~10x 향상 |
| 파티션 최적화 | LLM 서버 수에 맞춰 파티션 조정 | GPU 활용률 극대화 |
| 캐싱 | 동일 입력 결과 캐시 | 중복 호출 제거 |
| 비동기 호출 | asyncio + aiohttp 활용 | 네트워크 대기 감소 |
| 로컬 모델 | vLLM/Ollama를 클러스터 내 배포 | 네트워크 지연 제거 |
| 양자화 모델 | Q4 모델로 빠른 추론 | 비용 절감 |
| 단순 프롬프트 | 최소한의 프롬프트로 토큰 절약 | API 비용 절감 |
비용 추정
"그래서 실제로 돈이 얼마나 들까요?" — 100만 건 기준으로 세 가지 방식을 비교해봤습니다. API 호출, 자체 GPU 서버, CPU 서버 각각의 현실적인 비용과 시간을 확인해보세요.
[100만 건 텍스트 분류 비용 추정]
OpenAI API (gpt-4o-mini):
입력: 평균 200 토큰 × 100만 = 200M 토큰 × $0.15/1M = $30
출력: 평균 10 토큰 × 100만 = 10M 토큰 × $0.60/1M = $6
총 비용: ~$36
자체 호스팅 (LLaMA 3.1 8B, A100 1대):
처리 속도: ~1,000건/분 (배치)
총 시간: ~17시간
GPU 비용: ~$34 (A100 $2/시간)
Ollama (Q4, CPU 서버):
처리 속도: ~100건/분
총 시간: ~7일
비용: 서버 운영비만
참고: 대규모 배치 처리에서는 자체 호스팅이 API 대비 비용 효율적입니다. 하지만 초기 세팅과 운영 부담을 고려하여 판단하세요.
마치며 — 핵심 요약
- Spark + LLM 연동의 핵심은 "건별 순차 호출" 대신 "파티션 단위 병렬 호출"로 전환하는 것입니다.
- UDF는 빠르게 붙이는 방법,
mapPartitions는 커넥션 재사용으로 처리량을 최적화하는 방법입니다. - 임베딩 생성 시 모델을 Executor마다 한 번만 로드하고,
batch_size=64같이 배치로 처리하면 GPU 활용률이 극대화됩니다. - 비용 결정의 기준: 작업이 반복적이고 규모가 크면 자체 GPU 호스팅이 유리하고, 일회성이거나 규모가 작으면 API가 낫습니다.
- 로컬 LLM(vLLM·Ollama)을 클러스터 안에 두면 네트워크 지연이 사라져 처리량이 크게 올라갑니다.
- 처음 시작한다면 단순한 UDF → 성능 측정 →
mapPartitions전환 순서로 점진적으로 최적화하세요.
References
- Apache Spark Documentation — https://spark.apache.org/docs/latest/
- vLLM Documentation — https://docs.vllm.ai/
- Sentence-Transformers — https://www.sbert.net/
- Databricks. "LLM Inference at Scale with Spark" — https://www.databricks.com/
— Data Dynamics 엔지니어링 팀