20.5.1 Rust 기반 Zenoh 애플리케이션 트러블슈팅

20.5.1 Rust 기반 Zenoh 애플리케이션 트러블슈팅

Zenoh는 애초에 태생부터 Rust로 작성되었고, 가장 완벽한 형태의 API를 Rust 개발자들에게 제공한다. 컴파일러가 강력한 대여 검사기(Borrow Checker)로 포인터 결함을 원천 차단해 주기 때문에, Rust 클라이언트가 C/C++처럼 메모리를 침범하여 죽는 일은 없다.

그러나 Rust 특유의 까다로운 소유권(Ownership) 규칙과 깊이 얽힌 비동기(Async/Await) 환경 디자인 때문에, 프로그램이 죽지는 않더라도 영원히 교착 상태(Deadlock)에 빠지거나 퓨처(Future)가 블로킹되어 통신이 그 자리에서 굳어버리는 치명적인 논리적 장애가 자주 발생한다. 이 섹션에서는 Rust의 완벽함을 맹신하다 빠지는 교착의 함정을 해부한다.

1. 메모리 누수(Memory Leak) 및 스레드 데드락(Deadlock) 추적

Rust는 가비지 컬렉터(GC)가 없는 대신 컴파일러가 해제를 보장하지만, 개발자가 구조를 잘못 짜면 레퍼런스 누수(Leak)와 락(Lock) 경합이 발생한다.

1.0.1 Arc 스마트 포인터의 순환 참조 (Circular Reference)

로봇 컨트롤러 안에서 Zenoh Session과 커스텀 State 객체를 Arc<Mutex<State>>로 공유하며 콜백에 넘겨주는 패턴이 흔히 쓰인다. 이때 세션 객체가 스테이트를 쥐고 있고, 스테이트가 세션의 PublisherArc로 서로 가리키고 있으면(순환 참조), 애플리케이션이 종료되더라도 변수의 래퍼런스 카운트(Ref Count)가 0으로 떨어지지 않아 Drop(소멸자)이 호출되지 않는다. 즉 좀비 자원으로 남아 메모리가 줄줄이 새면서 OS를 갉아먹는다.

디버깅 & 조치:

  • 무조건 객체 그래프를 트리(Tree) 계층 구조로 강제화하고, 양방향 공유가 필요할 때는 하나를 Arc 대신 생명 주기에 관여하지 않는 Weak 포인터로 끊어내어라.

1.0.2 세션 콜백 함수 내의 뮤텍스 교착(Deadlock)

로봇 상태(State) 구조체를 락(Mutex::lock())으로 잠그고 업데이트 중인데, 락을 쥔 그 틈에 Zenoh에서 Put 이벤트가 Subscribe 콜백 안에서 트리거되어 다시 State 락에 접근(Double Lock)을 시도하면? 프로그램은 아무런 에러 로그도 없이 0% CPU를 소모하며 영구적인 동면에 빠져버린다.

// [최악의 데드락 시나리오]
let state_lock = state.lock().unwrap(); // 1. 여기서 락을 확보함 
let session = zenoh::open(config).res().unwrap();
session.declare_subscriber("cmd/motor").callback(move |sample| {
    // 2. 외부 비동기 스레드에서 다시 락을 요구함. 여기서 프로그램이 영원히 멈춤!
    let modify_state = state.lock().unwrap(); 
}).res().unwrap();

디버깅 & 조치:
콜백(Callback) 핸들러 안에서는 어떤 상태 락(Lock)도 절대 오래 쥐어서는 안 된다. 콜백에서는 채널(mpsc::channel)을 통해 통신 이벤트만 메인 워커 스레드로 빠르게 던져주고(Fire and Forget) 치고 빠지도록 아키텍처를 Actor 모델 기반으로 전면 리팩토링하라.

2. 비동기(Async) 런타임 충돌 및 퓨처(Future) 블로킹 문제

Rust 클라이언트는 백그라운드에서 강력한 비동기 작업 처리를 요구한다. 주로 Tokiosmol 같은 비동기 런타임 생태계 위에서 Zenoh API가 돌아가는데, 비동기 세계의 법칙을 어기는 순간 시스템 성능은 극단적으로 추락한다.

2.0.1 비동기 환경 내 블로킹 함수(Sync calls) 호출로 인한 워커 스레드 고갈

대규모 Zenoh 망에서는 초당 천 개의 비동기 Future 태스크가 도약한다. 그런데 어떤 초보 엔지니어가 비동기 블록 async { ... } 내부에 데이터베이스에 이미지를 쓰는 동기 버퍼 코딩(std::fs::File::write_all)이나 리눅스 sleep(1) 같은 무거운 동기(Sync) I/O 텍스트를 섞어 넣었다 치자.

Tokio의 스레드 폴 크기는 코어 수(예: 8개) 정도로 작다. 8개의 스레드가 무거운 파일 I/O를 하느라 붙잡혀 버리면(Blocked), 나머지 992개의 Zenoh 네트워크 태스크(z_put, z_get 등)는 스레드를 할당받지 못하고 영문도 모른 채 메모리에 누적되며 네트워크 대역폭 허갈을 맞이한다.

해결 방안:
async 컨텍스트 내부에서 단 10ms 이상 걸리는 무거운 물리 동기 연산(디스크 작업, 무거운 JSON 파싱, 이미지 행렬 연산)을 수행해야 한다면, 절대로 메인 런타임을 물고 늘어지지 마라. 반드시 tokio::task::spawn_blocking 랩퍼(Wrapper)로 분리하여 별도의 OS 스레드 풀에서 돌린 뒤 결과값만 .await로 받아오게 수술해야 한다.

2.0.2 비동기 Drop 블로킹 이슈

Rust 클라이언트 애플리케이션을 끌 때(Shutdown 절차), Zenoh Session 객체가 스코프를 벗어나 Drop 되며 내부적으로 라우터에 “나 나간다“는 클로즈(Close) 패킷을 보내려 한다.
그런데 Tokio 런타임이 무너지면서 다른 소켓과 함께 엉켜버려, Drop을 호출하는 도중에 시스템이 panic! ("Cannot drop a runtime in a context where blocking is not allowed")을 내뿜고 파열될 수 있다.

해결 방안:
애플리케이션이 ctrl+c를 선언하여 내려갈 때 메인 비동기 루프를 끄기 직전에, 선제적으로 session.close().res().await API를 통해 명시적인(Graceful) 비동기 자원 해제 절차를 밝고, 그 뒤에 전체 런타임을 소멸시키도록 클로징 아키텍처를 순차적으로 재배열하라.