5.9 에러 처리(Error Handling)와 안전성 보장

5.9 에러 처리(Error Handling)와 안전성 보장

“네트워크는 언제나 실패한다.”
분산 시스템의 제1법칙이다. 케이블은 뽑힐 것이고, 공유기는 재부팅 될 것이며, 방화벽은 주기적으로 세션을 끊어버릴 것이다.

C++와 같은 구시대의 언어에서는 이런 에러들을 try-catch 문의 더미 속에 숨기거나 NULL 포인터 예외를 뱉으며 장렬히 산화(Segfault)했다. 하지만 Rust는 ResultOption이라는 강제적인 방패를 통해, 엔지니어가 에러를 우회하는 것을 문법적으로 금지한다.
이 챕터에서는 Zenoh의 독자적인 ZError를 어떻게 해체하여 장애의 근원을 포렌식하고, 네트워크가 터졌을 때 프로그램이 패닉(Panic)에 빠지지 않고 마치 바퀴벌레처럼 끈질기게 다시 망에 달라붙는(Reconnection) 데브옵스 레벨의 부활 시나리오를 심도 깊게 다룬다.

1. ZError 타입 분석 및 Result 열거형 패턴 매칭

zenoh::open().await.unwrap() 처럼 뒤에 .unwrap()을 붙이는 행위는 시연용 코드에서나 허락되는 수치스러운 짓이다. 프로덕션 환경에서 언랩(Unwrap)을 남발하는 건 러시아 룰렛을 당기는 것과 같다. ZError의 속을 까서 원인을 분석해야 한다.

1.0.1 [Runbook] 에러 원인 식별 및 패턴 매칭 전술

Zenoh의 모든 실패는 단 하나의 최상위 에러 객체인 ZError로 귀결되며, 내부 열거형(Enum)과 문자열 슬라이스로 그 원인을 섬세하게 표출한다.

에러를 파고드는 match 방어막 구현

use zenoh::prelude::r#async::*;
use zenoh::errors::ZErrorKind;

#[tokio::main]
async fn main() {
    let config = Config::default();

    // unwrap() 절대 금지! match 로 모든 경우의 수를 통제한다.
    let session_result = zenoh::open(config).res().await;

    match session_result {
        Ok(session) => {
            println!("접속 성공!");
            // 비즈니스 로직 실행
        },
        Err(e) => {
            // [포렌식] ZError의 내장을 뜯고 어떤 부품이 고장났는지 확인
            match e.kind() {
                ZErrorKind::IoError => {
                    eprintln!("[치명적] 네트워크 선이 뽑혔거나 TCP 포트가 막혔습니다. 재시도 타이머를 가동합니다.");
                }
                ZErrorKind::InvalidConfig => {
                    eprintln!("[개발자 실수] json5 설정 파일의 문법이 틀렸습니다: {}", e);
                    std::process::exit(1); // 이건 재시도해도 안 되니까 즉시 강제 종료
                }
                _ => {
                    eprintln!("[알 수 없는 장애] 원인 추적 요망: {:?}", e);
                }
            }
        }
    }
}

[아키텍처 팁] anyhow 크레이트와의 융합
로봇의 다른 하드웨어 제어 에러와 Zenoh 통신 에러를 한꺼번에 묶어서 상위 스레드로 던질(Propagate) 때는 anyhow::Result 를 쓰는 것이 현명하다. ZError는 표준 std::error::Error 트레이트를 이미 상속받고 있으므로 호환성이 완벽하다.

2. 네트워크 단절 감지 및 재연결(Reconnection) 자동화 전략

“라우터랑 연결이 끊겼는데요?”
“껐다 켜보세요.”
로봇과 드론이 돌아다니는 백엔드 시스템에서 이런 대응 매뉴얼은 최악의 하수다. 통신이 터지면 그 즉시 자체적으로 백오프(Backoff)를 타면서 클라우드 라우터에 머리를 계속 들이박는 좀비 같은 좀비 커넥션 매니저를 구현해야 한다.

2.0.1 [Runbook] 지수 백오프(Exponential Backoff) 재연결 스크립트

개발자가 직접 1초, 2초, 4초 대기하는 와일 루프(while)를 짤 수 있지만, Zenoh Config 객체에 이미 지능형 백오프 재연결 알고리즘이 탑재되어 있다!

1. 설정 기반의 인프라급 자동화
네트워크가 불안정한 모바일 기기(LTE/5G)라면 코어 단에서 재연결 주기를 세팅하라.

use zenoh::config::Config;

let mut config = Config::default();
config.insert_json5("connect/endpoints", "[\"tcp/cloud.com:7447\"]").unwrap();

// 끊어짐을 감지하면 즉시(100ms) 1차 시도
config.insert_json5("connect/backoff/initial_delay", "100").unwrap();
// 실패할 때마다 대기 시간을 2배씩 늘림 (100ms -> 200ms -> 400ms)
config.insert_json5("connect/backoff/multiplier", "2.0").unwrap();
// 아무리 길어져도 5초 간격으로는 지속적으로 클라우드를 찔러본다.
config.insert_json5("connect/backoff/max_delay", "5000").unwrap();

2. 끊어짐 상태 수동 감지 런북 (루프 보호막)
간혹 응용 레벨에서 put을 치다가 Err가 떨어졌을 때만 능동적으로 대처해야 할 때가 있다.

loop {
    match publisher.put("robot/1/alive").res().await {
        Ok(_) => { 
            /* 정상 송신 */ 
            tokio::time::sleep(std::time::Duration::from_secs(1)).await;
        },
        Err(e) => {
            eprintln!("라우터 박리 증상 감지. 전송 큐를 로컬에 보관하고 3초간 대기합니다...");
            // 이때 프로그램이 죽어버리지 않고, 로컬 메모리 버퍼(Vec)에 임시 저장하는 로직을 태운다.
            local_buffer.push(data);
            tokio::time::sleep(std::time::Duration::from_secs(3)).await;
        }
    }
}

Zenoh는 하부에서 소켓이 끊어지더라도 객체 자체(session, publisher)가 패닉을 내고 폭발하지 않는다. 밑에서 열심히 백오프 재연결을 시도 중이며, 연결이 복구되면 즉각 다음 put 통신부터 패킷이 다시 빨려 들어가기 시작한다.

3. 패닉(Panic) 방지를 위한 안전한 예외 처리 및 복구 기법

어떻게든 죽지 않게 짜도, 예측 못한 스레드 경합(Race Condition)이나 다른 라이브러리 충돌 때문에 특정 워커 스레드가 panic!()을 내며 폭사하는 경우가 있다.
Rust의 멀티플렉싱 비동기 생태계(Tokio)에서는 하나의 자식 태스크가 죽는다고 시스템 전체가 다운되지는 않지만, 좀비 포트가 남거나 메모리가 묶이는 상황을 우아하게 복구(Recovery)시켜야 한다.

3.0.1 [Runbook] 비동기 태스크 포획 및 패닉 복구

tokio::spawn으로 띄운 백그라운드 태스크의 죽음(JoinError)을 잡아채는 그물을 만들어야 한다.

use tokio::task;

#[tokio::main]
async fn main() {
    let session = zenoh::open(Config::default()).res().await.unwrap();
    let s_clone = session.clone();

    // 심장 역할을 하는 통신 스레드 기동
    let handle = task::spawn(async move {
        let publisher = s_clone.declare_publisher("robot/vital").res().await.unwrap();
        
        // ... 모종의 이유로 강제 패닉 발생 가정!
        panic!("카메라 메모리가 터져서 스레드를 강제 종료합니다!");
    });

    // 메인 관제 스레드에서 자식의 목숨을 모니터링한다
    match handle.await {
        Ok(_) => println!("통신 스레드 정상 종료"),
        Err(join_err) => {
            if join_err.is_panic() {
                eprintln!("[치명적 재난] 패닉 발생 감지됨! 시스템 리로딩 절차를 밟습니다.");
                // 1. 여기서 Zenoh 코어 세션을 우아하게 닫고 자원을 즉시 회수한다.
                let _ = session.close().res().await;
                // 2. 물리적 서보 모터(Hardware)의 비상 락(Brake)을 건다.
                trigger_hardware_emergency_brake();
            } else if join_err.is_cancelled() {
                println!("태스크가 정상적으로 캔슬되었습니다.");
            }
        }
    }
}

이처럼 패닉을 두려워하지 않고(Fearless) 그것을 잡아채어 session.close() 로 뒤를 닦아낸 뒤 모터를 정지시키는 그물(Catch) 설계야말로, 당신이 시스템을 엔터프라이즈 레벨로 격상시켰음을 증명하는 금자탑이다.

4. tracing 크레이트를 활용한 Zenoh 내부 로그 수집 및 디버깅

“로봇이 보냈다고 기록이 뜨는데 클라우드에서는 받은 기록이 없습니다.”
이럴 때 println! 만 쳐다보고 있으면 밤을 새워도 버그의 원인을 잡을 수 없다. Zenoh는 그 심장 깊은 곳의 패킷 라우팅 트리부터 TCP 소켓 버퍼 상태까지 모든 정보를 tracing 이라는 Rust 표준급 로그 파이프라인으로 끊임없이 토해낸다.

4.0.1 [Runbook] 프로파일링(Profiling) 로그 투시경 장착

내 프로그램의 로그와 Zenoh 내부 런타임 엔진의 로그를 동시에 버무려서(Combine) ELK 스택 같은 관제 시스템으로 밀어버리는 방법.

1. 의존성 주입

[dependencies]
zenoh = "0.10.1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

2. 구독 파이프라인 활성화 (main 함수 상단)
앱 부팅 직후 단 한 번만 선언하면, 애플리케이션 전체에 우아한 색상의 로그 시스템이 주입된다.

use tracing::{info, debug, error};

#[tokio::main]
async fn main() {
    // 1. RUST_LOG 환경변수를 읽고, 없으면 기본적으로 "info" 레벨로 세팅
    tracing_subscriber::fmt()
        .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
        .with_file(true)     // 어느 소스 파일에서 터졌는지 표시
        .with_line_number(true) // 몇 번째 줄인지 표시
        .with_thread_ids(true)  // 어떤 톡(tokio) 스레드인지 표시 (멀티스레드 디버깅의 꽃)
        .init();

    info!("Zenoh 통신 데몬 부팅 완료");

    // 이렇게 세팅하고 나서 이 명령어를 터미널에서 치면 Zenoh 코어 라우팅 테이블이 모두 보인다.
    // RUST_LOG="info,zenoh::routing=debug" cargo run
    
    let session = zenoh::open(Config::default()).res().await.unwrap();
    
    debug!("현재 할당받은 Zenoh 아이디: {}", session.info().zid().await);
}

이제 “네트워크가 왜 튀는지” 불평하지 말고 터미널에 RUST_LOG=trace를 켜라.
나의 put 명령이 zenoh-transport 스택을 지나 리눅스 커널의 TCP 패킷으로 조각나서(Fragmented) 철사(Wire)를 타는 모든 기적의 과정이 밀리초 단위로 아름답게 형상화될 것이다.