파이썬 예외 처리의 함정: 조용한 실패가 만드는 재앙

지난주 서비스 장애가 발생했습니다. API는 HTTP 200 상태 코드와 함께 정상적으로 응답했지만, 반환된 데이터는 모두 비어 있었습니다. 30분간의 원인 분석 끝에 except Exception 구문이 진짜 오류를 삼켜버린 것이 원인이라는 결론에 도달했습니다.

상황은 이랬습니다. 특정 API는 데이터베이스에서 사용자 프로필 정보를 가져와 JSON 형식으로 변환한 뒤 반환하는 역할을 했습니다. 코드는 대략 다음과 같은 구조였습니다.

@api.route('/user/profile')
def get_profile(user_id):
    try:
        user_record = db.find_user_by_id(user_id)
        settings = json.loads(user_record.settings_json)
        return {'status': 'ok', 'data': settings}
    except Exception:
        return {'status': 'ok', 'data': {}}

데이터베이스 연결에 문제가 생기면 find_user_by_idNone을 반환합니다. 이후 None.settings_json에 접근하면서 AttributeError가 발생하는데, 이것이 except Exception에 잡혀 빈 딕셔너리가 반환된 것입니다. API 상태 코드는 200이었기에 프론트엔드는 문제가 없다고 판단했고, 모니터링 시스템도 경고를 보내지 않았습니다. 로그에는 아무것도 남지 않았습니다.

진짜 문제는 어디에 있는가

except Exception 자체가 잘못된 것은 아닙니다. 진짜 문제는 이를 사용할 때 아무런 흔적도 남기지 않는, 즉 '조용한 실패(silent failure)'를 유발하는 점에 있습니다. 코드는 조용히 실패했고, 그 누구도 문제의 실마리를 찾을 수 없었습니다.

더 심각한 사례도 있습니다.

def process_transaction(tx_id):
    try:
        txn = db.query(Transaction).get(tx_id)
        txn.calculate_fee()
        txn.apply_loyalty_points()
        txn.update_user_balance()
        notify_settlement_system(txn.id)
    except Exception:
        pass

이 코드에서 어떤 단계에서든 오류가 발생하면 모든 로직이 pass로 넘어갑니다. 수수료 계산, 포인트 적립, 잔액 업데이트, 정산 시스템 통보 등 모든 중요 작업이 실패했지만, 이 함수를 호출한 쪽은 그 사실을 전혀 알 수 없습니다.

현재의 작성 방식

핵심 원칙은 하나입니다: 처리할 수 있는 예외는 처리하고, 처리할 수 없는 예외는 그대로 두어라. 절대 실패를 숨기지 마십시오.

@api.route('/user/profile')
def get_profile(user_id):
    user_record = db.find_user_by_id(user_id)
    if not user_record:
        logger.warning(f'User {user_id} not found, returning default profile.')
        return {'status': 'ok', 'data': get_default_profile()}

    try:
        settings = json.loads(user_record.settings_json)
    except (json.JSONDecodeError, AttributeError) as e:
        logger.error(f'Failed to parse settings for user {user_id}: {e}')
        settings = get_default_profile()

    return {'status': 'ok', 'data': settings}

변경은 크지 않지만, 모든 예외는 명확한 타입으로 분리되었고, 로그가 기록되며, 장애 발생 시를 위한fallback(대체) 전략이 존재합니다.

3단계 예외 처리 원칙

팀 내에 다음과 같은 간단한 규칙을 도입했고, 긍정적인 효과를 보고 있습니다.

1단계: 예상 가능한 예외, 정확하게捕捉

try:
    db_conn = psycopg2.connect(conn_string)
except psycopg2.OperationalError:
    return {'error': '데이터베이스 연결에 실패했습니다. 잠시 후 다시 시도해주세요.'}, 503
except psycopg2.InterfaceError:
    logger.critical('Database interface error, may need intervention.')
    return {'error': '데이터베이스 인터페이스 오류가 발생했습니다.'}, 500

except는 구체적인 장애 시나리오에 대응하며, 호출자에게 다른 메시지를 제공합니다.

2단계: 예상 밖의 예외, 기록 후 재발생

try:
    result = process_data(payload)
except ValueError as e:
    # 입력 값 검증과 같은 알려진 문제
    logger.warning(f'Invalid input data: {e}')
    raise
except Exception as e:
    # 예기치 않은 상황, 기록은 하되 삼키지는 않음
    logger.exception(f'Unexpected error during data processing: {e}')
    raise

logger.exception은 전체 traceback을 자동으로 포함시켜, logger.error(e)보다 훨씬 유용합니다.

3단계: 전역 안전망, 최외곽에서만 사용

@app.errorhandler(Exception)
def handle_unexpected_error(e):
    logger.exception(f'Unhandled exception caught by global handler: {e}')
    return {'error': '서버에 일시적인 문제가 발생했습니다.'}, 500

이것은 최후의 안전망입니다. 앞단계에서 처리되지 않은 예외만이 이곳으로 도달합니다. 로그가 있기 때문에 최소한 무슨 일이 일어났는지는 알 수 있습니다.

로그는 "오류 발생" 한 줄로 끝내지 마라

예외 처리만으로는 부족합니다. 로그의 내용 또한 유용해야 합니다. "ERROR: something went wrong"와 같은 한 줄짜리 로그 때문에 문제 추적이 어려워지는 경우를 너무 많이 봤습니다.

컨텍스트를 포함하세요.

logger.error(
    'Transaction processing failed',
    extra={
        'transaction_id': txn_id,
        'user_id': user.id,
        'step': 'apply_loyalty_points',
        'payload_size': len(payload),
    }
)

파이썬의 logging 모듈은 extra 필드를 지원합니다. 이 필드를 구조화된 로거(예: python-json-logger)와 함께 사용하면 ELK 스택(Elasticsearch, Logstash, Kibana)에서 검색이 매우 용이해집니다.

주의해야 할 함정

반복문 내에서 try/except를 사용할 때, 전체 반복문을 감싸지 않도록 주의해야 합니다.

# 나쁜 예: 하나의 파일 처리 실패가 나머지 모든 작업을 중단시킴
try:
    for file_path in file_paths:
        process_file(file_path)
except Exception:
    pass

# 좋은 예: 각각 처리하고, 실패한 것만 기록
failed_files = []
for file_path in file_paths:
    try:
        process_file(file_path)
    except Exception as e:
        logger.error(f"Failed to process {file_path}: {e}")
        failed_files.append(file_path)

100개의 파일을 처리해야 할 때, 3번째 파일에서 실패하면 두 번째 방식은 나머지 97개의 파일은 정상적으로 처리합니다. 하지만 첫 번째 방식은 전체 작업이 즉시 중단됩니다.

태그: python Exception-Handling logging error-handling best-practices

8월 15일 16:42에 게시됨