Blog
sparkllmbatch-inferencepysparkaidata-engineering

Spark + LLM 연동 가이드 - 대규모 데이터에 AI 적용하기

Apache Spark 환경에서 LLM을 활용하는 방법을 정리합니다. Spark UDF로 LLM 호출, 배치 추론, 데이터 라벨링, 텍스트 분류, 요약, 임베딩 생성 파이프라인을 다룹니다.

Data Dynamics2026年4月16日13 min read
This post is not yet translated. The original Korean version is shown below.

수백만 건의 고객 리뷰를 분류하거나, 수십만 개의 로그를 구조화해야 한다고 상상해보세요. 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 서버를 어디에 두느냐에 따라 세 가지 패턴으로 나뉩니다.

Loading diagram…

세 가지 연동 패턴이 있습니다. 팀 규모와 인프라 현황에 따라 골라보세요.

  • 패턴 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


— Data Dynamics 엔지니어링 팀