← 전체 해설 목록  ·  week-03 · 5강

5강 — 에러 처리와 재시도 로직

🎯 학습 목표

📖 해설

지난 시간(22차시) 배치 처리로 대량 데이터를 안전하게 나눴습니다. 그런데 API 호출 자체가 실패하면? 네트워크 문제, 서버 과부하, 요청 제한 — 호출은 항상 성공하지 않습니다. 오늘은 에러가 나도 끄떡없는 튼튼한 코드를 만들어 3일 차를 마무리합니다.

1. 마인드셋부터 — 에러는 반드시 난다

에러가 나면 보통 "내 코드가 잘못됐나? 어제는 됐는데 왜?" 하고 당황합니다. 올바른 생각은 이렇습니다. 네트워크는 원래 불안정하고, 서버는 가끔 과부하가 걸립니다. 에러는 "언제 날지"가 아니라 "반드시 난다"고 전제해야 합니다.

운전으로 비유해 봅시다. 사고가 안 나길 바라며 안전벨트를 안 매는 사람과, 사고 날 수도 있으니 매는 사람이 있죠. API 호출도 같습니다. 중요한 건 에러가 나냐 안 나냐가 아니라, 에러가 나면 어떻게 대응하느냐입니다. 실무에서 한 번씩 겪는 일들이에요.

예외 처리가 없으면 API 호출이 실패하는 순간 프로그램이 그냥 멈춥니다. 처리 중이던 배치 결과가 날아가고, 왜·어디서 실패했는지도 모르며, 재시작도 비효율적이죠. 그래서 에러를 예상하고 대비하는 게 핵심입니다.

2. HTTP 상태 코드 — 숫자만 봐도 원인을 안다

API 응답에는 상태 코드가 따라옵니다. 서버의 응답 상태를 숫자로 표현한 것이죠. 레스토랑 주문으로 비유하면 쉽습니다.

코드 레스토랑 비유 의미
200 "주문하신 음식 나왔습니다" 성공
400 "손님, 그런 메뉴 없어요" 잘못된 요청(클라이언트=손님 잘못)
429 "너무 빨리 주문하셔서 잠깐 기다려 주세요" 요청을 너무 많이 보냄
500 "죄송해요, 주방에 문제가 생겼어요" 서버 문제(손님 잘못 아님)

외울 건 큰 분류뿐입니다 — 200번대는 정상, 400번대는 클라이언트(사용자) 문제, 500번대는 서버 문제. 400번대와 500번대는 대응 방법이 다르기 때문에 이 구분이 중요합니다. 앞자리 숫자만 봐도 왜 실패했는지 감이 오죠.

3. 재시도 가능한 에러 vs 불가능한 에러

상태 코드에 따라 대응이 갈립니다.

유형 코드 의미 재시도?
Rate Limit 429 요청이 너무 많음 가능 (잠깐 기다렸다가)
Server Error 500, 502, 503, 504 서버 문제 가능 (일시적일 수 있음)
Timeout — 응답 시간 초과 가능 (네트워크 지연)
Bad Request 400 요청 형식 오류 불가능 (요청을 고쳐야 함)
Unauthorized 401 인증 실패 불가능 (API 키 확인)
Forbidden 403 권한 없음 불가능 (접근 권한 확인)
Not Found 404 엔드포인트 없음 불가능 (URL 확인)
Unprocessable 422 데이터 형식 오류 불가능 (입력 데이터 수정)

원리는 간단합니다. 시간이 지나면 풀릴 문제(429·5xx·타임아웃)는 재시도하고, 요청 자체가 잘못된 문제(4xx 대부분)는 몇 번을 해도 똑같이 실패하므로 바로 건너뛰고 로그에 기록합니다. 코드로 옮기면 "재시도 가능한 코드 집합"을 두고 판별하면 돼요.

RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}   # 재시도해 볼 만한 코드들

def is_retryable_error(status_code):
    """이 상태 코드가 재시도 가능한 에러인지 판단."""
    return status_code in RETRYABLE_STATUS_CODES

for code in [200, 400, 429, 500, 401]:
    print(code, "→", "재시도 가능" if is_retryable_error(code) else "재시도 불가")

4. 지수 백오프 — 점점 더 기다리며 재시도

재시도를 한다면, 얼마나 기다렸다가 할까요? 지수 백오프(Exponential Backoff)는 재시도할 때마다 대기 시간을 점점 늘리는 전략입니다.

전화 다시 걸기로 비유해 봅시다. 상대가 안 받는데 1초마다 다시 걸면 서로 피곤하죠. 1분 뒤, 안 되면 2분 뒤, 또 안 되면 5분 뒤 — 이렇게 간격을 늘리면 상대가 바쁠 때 여유를 줍니다. API도 같아요. 에러 나면 1초 → 2초 → 4초 → 8초로 늘립니다. 왜냐하면 서버가 과부하 상태일 수 있고, 바로바로 재시도하면 부담만 더 주기 때문입니다. 간격을 늘리면 서버가 회복할 시간을 줍니다.

공식은 대기시간 = 기본시간 × 2^(시도횟수)입니다. "지수(exponential)"라는 이름이 여기서 나왔죠. 무한정 기다릴 순 없으니 최대 재시도 횟수(보통 3~5회)도 정해 둡니다.

import random

def calculate_backoff(attempt, base_delay=1, max_delay=60):
    """지수 백오프 대기 시간(초)을 계산. attempt는 0부터 시작.
    1, 2, 4, 8, 16 ... 으로 늘어나되 max_delay를 넘지 않음."""
    delay = base_delay * (2 ** attempt)            # 핵심: 2의 거듭제곱으로 증가
    jitter = random.uniform(0, 0.5) * delay        # 약간의 무작위(여러 요청이 동시에 몰리는 걸 분산)
    return min(delay + jitter, max_delay)          # 상한선 적용

total = 0
for attempt in range(5):
    d = calculate_backoff(attempt)
    total += d
    print(f"시도 {attempt+1}: {d:.1f}초 대기 (누적 {total:.1f}초)")
# 예시: 1.2 / 2.1 / 4.7 / 9.0 / 16.4 초 (jitter로 매번 조금씩 다름)

jitter(약간의 무작위)는 여러 클라이언트가 동시에 똑같은 타이밍에 재시도해 서버를 다시 덮치는 걸 막아 줍니다. 같은 원리를 우리는 일상에서도 씁니다 — 택배 재배송(5분마다 X, 1시간 뒤 → 내일 → 무인함), 친구 집 초인종(1초마다 X, 잠깐 뒤 문자 → 더 뒤에 연락). 그리고 22차시의 중간 저장도 사실 게임의 세이브 포인트와 같은 발상이었죠.

5. 재시도가 포함된 안전한 호출

이제 try/except로 호출을 감싸고, 에러 종류를 판별해 재시도 로직을 넣습니다. 직접 만든 예외 클래스(APIError)로 에러 정보를 깔끔하게 담아 둡니다.

import time

class APIError(Exception):
    """API 호출 관련 커스텀 예외 — 상태 코드와 재시도 가능 여부를 함께 담는다."""
    def __init__(self, message, status_code=None, retryable=False):
        self.message = message
        self.status_code = status_code
        self.retryable = retryable
        super().__init__(message)

def call_llm_with_retry(system_prompt, user_message, max_retries=3):
    """재시도 로직이 포함된 LLM 호출. 성공 시 응답 텍스트, 끝내 실패 시 APIError."""
    last_error = None
    for attempt in range(max_retries + 1):       # 최초 1회 + 재시도 max_retries회
        try:
            response = client.chat.completions.create(
                model=MODEL,
                messages=[
                    {"role": "system", "content": system_prompt},
                    {"role": "user", "content": user_message},
                ],
                timeout=30,                       # 응답이 30초를 넘으면 타임아웃
            )
            return response.choices[0].message.content
        except Exception as e:
            last_error = e
            text = str(e).lower()
            # 에러 메시지로 종류를 판별 (재시도 가능한가?)
            retryable = ("429" in str(e) or "rate" in text                    # 레이트 리미트
                         or any(c in str(e) for c in ["500", "502", "503", "504"])  # 서버 오류
                         or "timeout" in text)                                # 타임아웃
            if retryable and attempt < max_retries:
                delay = calculate_backoff(attempt)
                print(f"  에러(시도 {attempt+1}/{max_retries+1}): {delay:.1f}초 후 재시도")
                time.sleep(delay)
            else:
                break                              # 재시도 불가 or 횟수 초과 → 중단
    raise APIError(f"API 호출 실패: {last_error}")

흐름은 이렇습니다 — 호출 시도 → 성공하면 반환 / 실패하면 재시도 가능한지 판단 → 가능하면 백오프 후 다시, 불가능하거나 횟수 초과면 실패로 종료. 재시도 가능한 에러만 다시 시도하고 나머지는 즉시 포기하는 게 포인트예요.

6. 로깅 — 왜 실패했는지 남기기

규모가 큰 프로그램에서는 에러가 어디서 났는지 찾는 게 정말 어렵습니다. 그래서 성공·실패를 기록하는 로깅이 프로덕션급 프로그램에서 매우 중요합니다. 처리 결과를 누적하고 파일로 저장하는 로거 클래스를 둡니다.

from datetime import datetime
import json

class ProcessingLogger:
    """처리 결과(성공/실패)를 기록하고 요약·저장하는 로거."""
    def __init__(self, log_file='processing_log.json'):
        self.log_file = log_file
        self.logs = []
        self.success_count = 0
        self.failure_count = 0

    def log_success(self, item_id, result):
        self.logs.append({"timestamp": datetime.now().isoformat(),
                          "status": "success", "item_id": item_id,
                          "result_preview": str(result)[:100]})
        self.success_count += 1

    def log_failure(self, item_id, error_message, input_data=None):
        self.logs.append({"timestamp": datetime.now().isoformat(),
                          "status": "failure", "item_id": item_id,
                          "error": error_message,
                          "input_preview": str(input_data)[:100] if input_data else None})
        self.failure_count += 1

    def save(self):
        total = len(self.logs)
        with open(self.log_file, 'w', encoding='utf-8') as f:
            json.dump({"summary": {"total": total,
                                   "success": self.success_count,
                                   "failure": self.failure_count},
                       "logs": self.logs}, f, ensure_ascii=False, indent=2)

    def print_summary(self):
        total = len(self.logs) or 1
        print(f"총 {len(self.logs)}건 / 성공 {self.success_count} "
              f"({self.success_count/total*100:.1f}%) / 실패 {self.failure_count}")

나중에 로그 파일을 열면 언제·어떤 에러가·어떤 리뷰에서 났는지 다 보입니다. 그래야 실패 원인을 분석하고 고칠 수 있죠. (파이썬 표준 logging 모듈로 logging.warning(...)을 파일에 남기는 방법도 있습니다 — 원리는 같아요.)

7. 전체 통합 — 배치 + 재시도 + 로깅

이제 22차시의 배치/체크포인트에 오늘의 재시도·로깅을 결합하면 거의 완전한 수준의 파이프라인이 됩니다. 각 리뷰를 "안전하게" 분석하는 함수를 만들고, 배치 루프에서 호출합니다.

def analyze_review_safe(review, logger):
    """재시도 포함 분석. 성공하면 결과, 실패하면 None(+로그 기록)."""
    system_prompt = ('당신은 게임 리뷰 분석가입니다. '
                     'JSON으로만 응답: {"sentiment": "positive/negative/neutral", "keywords": ["..."]}')
    try:
        raw = call_llm_with_retry(system_prompt, f"분석해주세요: {review['text']}", max_retries=2)
        result = json.loads(clean_json_response(raw))   # 3강의 정제 함수 재사용
        result['review_id'] = review['id']
        logger.log_success(review['id'], result)
        return result
    except (APIError, json.JSONDecodeError) as e:
        logger.log_failure(review['id'], str(e), review['text'])
        print(f"  리뷰 {review['id']} 실패: {e}")
        return None

def process_reviews_robust(reviews, batch_size=2):
    """배치 + 재시도 + 로깅을 결합한 안정적 처리."""
    logger = ProcessingLogger('review_processing_log.json')
    results = []
    for batch in tqdm(list(chunk_list(reviews, batch_size)), desc="배치 처리 중"):
        for review in batch:
            r = analyze_review_safe(review, logger)
            if r:
                results.append(r)        # 실패(None)는 건너뛰고 나머지는 계속
            time.sleep(0.5)
    logger.save()
    logger.print_summary()
    return results, logger

한 건이 실패해도 None을 반환하고 로그에 남길 뿐, 전체는 멈추지 않고 계속 돕니다. 이게 22차시 체크포인트(완료분 보존)와 합쳐지면, 대량 분석을 밤새 돌려도 안심할 수 있는 구조가 됩니다.

OpenRouter 무료 한도 참고: 무료 모델은 하루 약 50회 호출로 제한됩니다. 실습 중 이 한도를 넘겨 멈출 수 있는데, 당황하지 말고 다음 날 다시 하거나, 집중적으로 테스트하려면 1회 10달러 결제 시 하루 1,000회로 늘어납니다. (이런 한도 초과도 위 재시도/로깅으로 깔끔히 기록됩니다.)

🔑 핵심 용어

용어 쉬운 설명
HTTP 상태 코드 응답 상태를 나타내는 숫자. 200 성공 / 400 클라이언트 / 500 서버
429 (Rate Limit) 요청이 너무 많음. 기다렸다 재시도 가능
재시도 가능/불가 429·5xx·타임아웃은 재시도, 4xx 대부분은 건너뛰고 로그
지수 백오프 재시도 간격을 1·2·4·8초로 늘려 서버에 회복 시간을 줌
jitter 동시 재시도 충돌을 막는 약간의 무작위 대기
try/except 에러를 잡아 프로그램이 멈추지 않게 하는 예외 처리
커스텀 예외(APIError) 에러 정보(코드·재시도 여부)를 담는 직접 만든 예외 클래스
로깅(logging) 성공·실패를 기록해 나중에 원인 분석. 프로덕션의 필수
방어적 프로그래밍 "에러는 난다"고 전제하고 미리 대비하는 코딩 태도

🎮 게임 기획자 시점

에러 처리는 분석 파이프라인의 운영 안정성(SLA) 그 자체입니다. 리뷰 자동 분석을 라이브 대시보드에 물려 두려면, 어쩌다 한 번 나는 429·타임아웃에 전체가 멈춰선 안 되죠 — 재시도·로깅이 곧 "새벽에 깨지 않아도 되는 시스템"을 만듭니다. 발상 자체도 기획과 닮았습니다. 지수 백오프는 서버가 힘들 때 부하를 빼주는 설계인데, 이건 게임에서 서버 혼잡 시 매칭·접속을 점진적으로 푸는 운영과 같은 사고예요. 그리고 로그는 사후 분석 데이터 — 어떤 입력에서 왜 실패했는지가 남아야, 분석 파이프라인 자체를 개선할 수 있습니다. "에러가 안 나길 바라는" 게 아니라 "에러가 나도 굴러가게" 설계하는 태도가, 일회성 스크립트와 운영 가능한 제품을 가릅니다.

⚠️ 흔한 오해 / 짚고 갈 점

✅ 30초 정리

  1. 에러는 반드시 난다고 전제하세요. 핵심은 막는 게 아니라 나도 괜찮게 대응하는 것(방어적 프로그래밍).
  2. 상태 코드로 원인을 읽고(2xx/4xx/5xx), 재시도 가능(429·5xx·타임아웃)만 지수 백오프로 다시 시도합니다.
  3. 로깅으로 실패를 기록해 분석·개선하고, 배치·체크포인트와 합쳐 끄떡없는 파이프라인을 완성합니다.

▶️ 다음 강 연결

이로써 3일 차 학습이 끝났습니다. 프롬프트 고급 기술(1강) → 감성 심화(2강) → 키워드·토픽(3강) → 배치 처리(4강) → 에러 처리(5강)까지, LLM으로 리뷰를 분석하는 본론을 모두 다뤘어요. 다음 24차시는 마무리 프로젝트(영상 없음) — 대량 분석 실행입니다. 지금까지의 배치 처리·에러 처리·로깅을 통합해 실제 리뷰 데이터 전체를 분석하고, 결과를 저장하고 통계까지 확인합니다. 여기서 나온 구조화된 분석 결과가, 곧 4일 차부터 만들 대시보드/앱의 입력이 됩니다.

전체📚 강의 목록