4강 — 배치 처리와 효율화
🎯 학습 목표
- 리뷰 1,000개·10만 개를 한 번에 처리할 때의 문제(시간·손실·비용·Rate Limit)를 설명할 수 있다
- 배치 처리가 무엇이고 왜 안전하고 관리하기 쉬운지 이해할 수 있다
- 트레이드오프 사고로 적절한 배치 크기를 정할 수 있다
- 진행률 표시와 중간 저장(체크포인트)으로 튼튼한 처리를 구현할 수 있다
📖 해설
지난 프로젝트(21차시)에서 감성·에스펙트·키워드를 한꺼번에 뽑는 4차원 멀티태스크 프롬프트를 만들었습니다. 리뷰 5개, 10개는 잘 되죠. 그런데 1,000개라면? 10만 개라면? 오늘은 대량 데이터를 효율적이고 안전하게 처리하는 배치 처리를 배웁니다.
1. 한 번에 처리하려다 다 잃는 시나리오
마케팅팀에서 요청이 왔습니다. "리뷰 1,000개 분석해서 보고서 만들어 주세요. 내일까지요." 여러분은 계산합니다. "리뷰 1개에 2초, 1만 개면 6시간쯤? 돌려놓고 퇴근하면 되겠네."
현실은 이렇습니다. 야근으로 돌려놨는데 8시간째에 멈춰 있습니다. 네트워크 에러로 계속 헛돌고 있었던 거죠. "에러 났으니 다시 실행하면 되지" 했는데 — 앞서 분석한 7,500개 결과를 저장 안 해서 다 날아갔습니다. 처음부터 다시 해야 하는데, 이미 쓴 API 비용은 돌아오지 않고 또 들어갑니다. 시간·결과·비용을 한꺼번에 잃는 거예요.
리뷰 1,000개를 한 번에(1개씩 끝까지) 처리할 때 생기는 문제는 네 가지입니다.
- 시간 — 1개에 2초면 1,000개에 2,000초(약 33분). 양이 늘수록 기하급수.
- 실패 시 손실 — 500번째에서 에러 나면 앞의 499개가 다 날아갈 수 있음.
- 레이트 리미트(Rate Limit) — API 서버가 "너무 빨리 요청하지 마세요" 하고 막음.
- 메모리 — 결과를 계속 메모리에 쌓으면 프로그램이 멈출 수 있음.
비용으로 보면 더 아픕니다. 건당 0.001달러로 잡으면 1만 개에 10달러. 한 번에 성공하면 10달러지만, 8시간째 실패하면 그 10달러가 날아가고 재시도에 또 10달러가 듭니다. 게다가 보이지 않는 비용 — 야근 인건비, 놓친 마감, 떨어진 신뢰도 — 까지 더하면 훨씬 큽니다. API 비용만 봐선 안 되는 이유예요.
2. 배치 처리란 — 작게 나눠서, 안전하게
배치 처리는 데이터를 작은 묶음으로 나눠 처리하는 것입니다. 이 작은 묶음을 배치(batch) 또는 청크(chunk)라고 해요.
이사로 비유해 봅시다. 물건 100개를 한 번에 들면 못 들거나 중간에 떨어뜨립니다. 10개씩 10번 나르면 안전하고, 하나 떨어뜨려도 나머지는 무사하죠. 리뷰도 똑같습니다. 100개씩 10번 처리하면 에러가 나도 그 배치만 손실이고, 나머지는 지킵니다.
이미 우리는 일상에서 배치 처리를 하고 있습니다.
- 편의점 발주 — 삼각김밥을 1년 치 한 번에 시키면 창고가 터지고 유통기한이 지납니다. 매일 적정량씩 발주하죠.
- 유튜브 스트리밍 — 2시간 영상을 다 받고 재생하면 한참 기다립니다. 10초씩 미리 받으며 바로 재생하죠.
- OMR 마킹 — 100문제 다 풀고 한 번에 마킹하면 시간 부족 시 0점. 10문제마다 마킹하면 중간에 끊겨도 점수를 확보합니다.
배치 처리는 특별한 기술이 아니라 이미 쓰고 있는 지혜입니다. 단건 처리와 비교하면 차이가 분명해요.
| 단건 처리 (for로 끝까지) | 배치 처리 (100개씩) | |
|---|---|---|
| 에러 시 | 전체 손실 위험 | 해당 배치만 손실, 앞은 안전 |
| 진행 상황 | 알기 어려움 | 진행률 표시 가능 |
| 중간 저장 | 없음 | 배치마다 저장 → 이어서 재시작 |
3. 배치 크기 결정 — 트레이드오프라는 사고법
여기서 AI식 사고 하나를 배웁니다. 트레이드오프(trade-off) — 한쪽을 얻으면 다른 쪽을 잃는다는 개념입니다. 배치 크기에 딜레마가 있어요.
- 너무 작으면(10개) — 배치마다 저장·준비하는 부가 작업(오버헤드)이 많아져 총 시간이 오히려 늘 수 있음.
- 너무 크면(500개) — 에러 나면 500개를 다시 해야 하고, 메모리 부담도 큼.
보통 50~100개 사이가 효율과 안전의 균형점입니다. 표로 시뮬레이션해 보면 U자 곡선처럼 중간 어딘가에 최적점이 보입니다.
total_items = 1000 # 처리할 총 리뷰 수
api_call_time = 1.5 # 호출당 평균 시간(초)
overhead_per_batch = 0.5 # 배치당 부가 작업 시간(초)
def estimate(batch_size):
"""배치 크기에 따른 배치 수·총 시간·오버헤드 비율·실패 시 평균 손실을 추정."""
num_batches = (total_items + batch_size - 1) // batch_size # 올림 나눗셈
total_time = total_items * api_call_time + num_batches * overhead_per_batch
overhead_ratio = (num_batches * overhead_per_batch) / total_time * 100
avg_loss = batch_size / 2 # 평균적으로 배치 절반쯤에서 실패한다고 가정
return num_batches, total_time/60, overhead_ratio, avg_loss
print(f"{'배치크기':>6}{'배치수':>7}{'총시간(분)':>11}{'오버헤드%':>10}{'실패시손실':>10}")
for size in [10, 25, 50, 100, 200]:
nb, mins, oh, loss = estimate(size)
print(f"{size:>6}{nb:>7}{mins:>11.1f}{oh:>10.1f}{loss:>10.0f}")
출력은 대략 이렇습니다(배치 10은 오버헤드가 높고, 배치 200은 실패 시 손실이 큼 — 50 근처가 균형).
배치크기 배치수 총시간(분) 오버헤드% 실패시손실
10 100 25.8 3.2 5
25 40 25.3 1.3 12
50 20 25.2 0.7 25
100 10 25.1 0.3 50
200 5 25.0 0.2 100
이 고민은 AI가 학습할 때 하는 고민과 똑같습니다. AI도 "배치 사이즈"를 정하는데, 너무 작으면 학습이 불안정하고 너무 크면 시스템 부하가 큽니다. 실험으로 최적점을 찾는 것 — 이게 AI 개발·분석에서 늘 마주치는 사고방식이에요. 직관("대충 100개씩 하면 되겠지")이 아니라 트레이드오프를 이해하면 근거 있는 결정을 내릴 수 있습니다.
4. 데이터를 배치로 나누기 — chunk_list
먼저 리스트를 원하는 크기로 잘라 주는 범용 함수를 만듭니다. yield를 쓰는데, 이건 제너레이터(generator) 문법입니다.
def chunk_list(data, chunk_size):
"""리스트를 chunk_size 크기의 배치로 나눠 하나씩 내보낸다.
예: chunk_list([1..10], 3) → [1,2,3] [4,5,6] [7,8,9] [10]"""
for i in range(0, len(data), chunk_size):
yield data[i:i + chunk_size] # return과 비슷하나, 하나 주고 멈췄다가 다음 호출 때 이어감
sample = list(range(1, 11)) # [1, 2, ..., 10]
for idx, batch in enumerate(chunk_list(sample, 3), 1):
print(f"배치 {idx}: {batch}")
yield는 return처럼 값을 돌려주지만, 한꺼번에 다 만들지 않고 필요할 때마다 하나씩 내보내고 대기합니다. 그래서 데이터가 아무리 커도 메모리를 아껴 쓸 수 있어요. 이 함수 하나로 어떤 리스트든 원하는 크기로 나눌 수 있는 범용 유틸리티입니다.
5. 진행률 표시 — tqdm
대량 작업에서 진행 상황이 안 보이면 정말 답답합니다. 멈춘 건지, 얼마나 남았는지 모르니까요. tqdm은 파이썬에서 가장 유명한 진행 바(progress bar) 라이브러리입니다. 사용법은 놀랍도록 간단해요 — 반복 대상을 tqdm()으로 감싸기만 하면 됩니다.
!pip install tqdm -q
from tqdm import tqdm
import time
def process_with_progress(reviews, batch_size=50):
"""배치로 처리하며 진행 바를 보여준다."""
results = []
batches = list(chunk_list(reviews, batch_size))
# for batch in batches → for batch in tqdm(batches) 로 감싸기만!
for batch in tqdm(batches, desc="배치 처리 중"):
for review in batch:
results.append(analyze_review(review)) # 21차시에 만든 분석 함수
time.sleep(0.1) # 호출 사이 0.1초 대기 = 레이트 리미트 방지
return results
실행하면 60%|██████ | 6/10 [00:12<00:08]처럼 퍼센트·경과·남은 시간·속도가 채워집니다. time.sleep(0.1)은 서버가 빈번한 요청을 차단하지 않도록 호출 간격을 두는 요령이에요.
6. 중간 저장(체크포인트) — 실패해도 이어서
가장 중요한 개념입니다. 각 배치를 끝낼 때마다 결과를 파일에 저장하고, 시작할 때 이전 결과가 있으면 불러와 이어서 처리합니다. 그러면 배치 3에서 에러가 나도, 다시 실행하면 배치 0·1·2는 건너뛰고 에러 난 지점부터 시작합니다.
import os, json
def save_checkpoint(results, path):
"""현재까지 결과를 파일에 저장."""
with open(path, 'w', encoding='utf-8') as f:
json.dump(results, f, ensure_ascii=False, indent=2)
def load_checkpoint(path):
"""이전 결과가 있으면 불러오고, 없으면 빈 리스트."""
if os.path.exists(path):
with open(path, 'r', encoding='utf-8') as f:
return json.load(f)
return []
def process_with_checkpoint(reviews, batch_size=50, path='checkpoint.json'):
"""체크포인트를 활용한 안정적 배치 처리."""
all_results = load_checkpoint(path)
done_ids = {r['review_id'] for r in all_results} # 이미 처리한 리뷰 id 집합
remaining = [r for r in reviews if r['id'] not in done_ids] # 남은 것만 추림
if not remaining:
print("모든 리뷰가 이미 처리되었습니다!")
return all_results
for batch in tqdm(list(chunk_list(remaining, batch_size)), desc="배치 처리 중"):
for review in batch:
result = analyze_review(review['text'])
result['review_id'] = review['id']
all_results.append(result)
time.sleep(0.1)
save_checkpoint(all_results, path) # 배치 끝날 때마다 저장 (여기가 핵심!)
return all_results
핵심은 save_checkpoint를 배치마다 호출하고, 시작 시 이미 처리한 id는 건너뛰는 것입니다. 처음 실행하면 전부 처리하고, 같은 데이터로 다시 실행하면 "모든 리뷰가 이미 처리되었습니다!"가 나오죠.
배치별 파일 방식 — 현장에서 더 많이 쓰는 형태
실무에서는 위처럼 결과를 한 파일에 누적하는 대신, 배치마다 별도 파일로 저장하는 방식을 더 자주 씁니다. 흐름은 이렇습니다.
- 결과를 담을 출력 디렉토리를 만든다(
os.makedirs(..., exist_ok=True)— 이미 있으면 그냥 넘어감). - 각 배치를 처리하기 전에 그 배치의 결과 파일이 이미 있는지 확인한다. 있으면 "완료, 건너뛰기"하고 기존 결과를 불러온다.
- 배치를 끝내면
batch_000.json,batch_001.json… 처럼 배치 번호로 저장한다. - 모든 배치가 끝나면
final_result.json으로 전체를 하나로 합친다.
import os, json
def process_reviews_save(reviews, batch_size=50, out_dir='batches'):
"""배치 결과를 배치별 파일로 저장 → 에러 나도 완료된 배치는 보존, 멈춘 곳부터 재시작."""
os.makedirs(out_dir, exist_ok=True) # 출력 폴더 준비
batches = list(chunk_list(reviews, batch_size))
for i, batch in enumerate(batches):
path = os.path.join(out_dir, f"batch_{i:03d}.json") # batch_000.json, batch_001.json ...
if os.path.exists(path): # 이미 처리한 배치면
continue # 건너뛰기 (재시작 시 이어서)
results = [analyze_review(r['text']) for r in batch]
with open(path, 'w', encoding='utf-8') as f:
json.dump(results, f, ensure_ascii=False, indent=2)
# 마지막에 모든 배치 파일을 하나로 합친다
final = []
for i in range(len(batches)):
with open(os.path.join(out_dir, f"batch_{i:03d}.json"), encoding='utf-8') as f:
final.extend(json.load(f))
with open('final_result.json', 'w', encoding='utf-8') as f:
json.dump(final, f, ensure_ascii=False, indent=2)
return final
배치 3까지 처리하다 에러가 나도, 다시 실행하면 batch_000~002.json은 이미 있으니 건너뛰고 배치 3부터 시작합니다. id 누적 방식이든 배치 파일 방식이든 원리는 같아요 — 완료된 건 보존하고, 멈춘 곳부터 다시 시작한다.
코드가 복잡해 보여도 거의 정형화돼 있습니다. 중요한 건 "대량 데이터는 배치로 처리한다, 진행률을 보여준다, 중간 저장으로 안전망을 만든다"는 흐름을 기억하는 것입니다. 현장에서도 진행률 표시·체크포인트·재시작까지 챙기는 건 한 단계 위의 고급 처리로 칩니다.
🔑 핵심 용어
| 용어 | 쉬운 설명 |
|---|---|
| 배치 처리 | 데이터를 작은 묶음(배치/청크)으로 나눠 처리. 안전·관리 용이 |
| 레이트 리미트(Rate Limit) | "너무 빨리 요청하지 마세요"라는 API 서버의 호출 제한 |
| 트레이드오프 | 한쪽을 얻으면 다른 쪽을 잃음. 배치 크기는 오버헤드↔리스크의 균형 |
| 오버헤드 | 배치마다 드는 저장·준비 등 부가 작업 비용 |
chunk_list / yield |
리스트를 배치로 나누는 함수. 제너레이터로 메모리 절약 |
| 제너레이터(generator) | 값을 한꺼번에 안 만들고 필요할 때 하나씩 내보내는 방식 |
tqdm |
진행 바 라이브러리. 반복을 감싸면 퍼센트·남은 시간 표시 |
| 체크포인트(중간 저장) | 배치마다 결과를 저장해, 실패해도 멈춘 곳부터 재시작 |
time.sleep() |
호출 사이 대기로 레이트 리미트 방지 |
🎮 게임 기획자 시점
배치 처리는 분석을 "일회성 실험"에서 "운영 가능한 파이프라인"으로 바꿉니다. 신작 출시일에 리뷰가 수만 건씩 쏟아져도, 배치+체크포인트가 있으면 밤새 돌려도 한 건도 잃지 않고, 아침에 "어디까지 됐는지"를 진행 바로 확인할 수 있죠. 트레이드오프 감각도 기획에 그대로 옮겨집니다 — 배치 크기를 정하는 일은, 라이브 운영에서 핫픽스를 한 번에 크게 칠지, 작게 자주 칠지 정하는 것과 같은 결의 결정입니다. 크게 가면 빠르지만 실패 시 타격이 크고, 잘게 가면 안전하지만 오버헤드가 늘죠. "직관 대신 트레이드오프로 결정한다"는 태도 자체가 데이터 기반 운영의 핵심입니다.
⚠️ 흔한 오해 / 짚고 갈 점
- 배치는 작을수록 안전하니 무조건 작게? 아닙니다. 너무 작으면 오버헤드가 커져 총 시간이 늘어요. 50~100 근처가 보통 균형점.
- "한 번에 다 돌리고 마지막에 저장"은 위험합니다. 중간에 죽으면 전부 날아갑니다. 배치마다 저장하세요.
- 진행률 표시는 장식이 아닙니다. 멈춘 건지 도는 건지 모르면 대량 작업은 디버깅이 불가능해집니다.
- 체크포인트는 "이어서 시작"이 핵심입니다. 저장만 하고 시작 시 이전 결과를 안 불러오면 의미가 없어요(이미 처리한 id 건너뛰기).
✅ 30초 정리
- 대량 데이터를 한 번에 처리하면 시간·결과·비용을 한꺼번에 잃을 수 있습니다. 배치로 나누세요.
- 배치 크기는 트레이드오프 — 너무 작으면 오버헤드, 너무 크면 손실. 실험으로 최적점(보통 50~100)을 찾습니다.
tqdm으로 진행률, 체크포인트로 중간 저장 — 이 둘이 대량 처리를 튼튼하게 만듭니다.
▶️ 다음 강 연결
배치 처리로 안전망을 깔았지만, API 호출 자체가 실패하는 건 여전히 남습니다 — 네트워크 문제, 서버 과부하, 요청 제한 등. 다음 23차시 에러 처리와 재시도 로직에서는 에러를 종류별로 구분(재시도 가능 vs 불가능)하고, 지수 백오프(Exponential Backoff)로 똑똑하게 다시 시도하며, 로깅으로 실패를 기록하는 법을 배웁니다. 에러가 나도 끄떡없는 코드로 마무리 짓습니다.