5.11 테스트(Testing) 및 CI/CD 파이프라인 통합
네트워크 통신이 엮인 코드를 cargo test 로 단순히 치면 무슨 일이 일어날까?
스레드가 꼬이고, 남의 포트를 물고 늘어져 OS 레벨의 데드락(Deadlock)이 걸리며, CI 봇(Bot) 서버가 뻗어버릴 것이다.
Zenoh와 같이 하드코어 비동기 네트워크 엔진을 기반으로 하는 분산 시스템을 테스트하는 것은 단순한 로직 검증과는 차원이 다른 무대다. 실시간으로 소켓이 붙고 떨어지는 인프라를 단위 테스트(Session Mocking) 단위로 통제하고, 그것을 GitHub Actions 같은 깡통 컨테이너에서 무사히 통과시키려면 고도의 테스트 엔지니어링 전술이 필요하다.
이 챕터에서는 클라우드 배포 직전에 버그를 모조리 잡아내어 시스템의 숨통을 보호하는 자동화 테스트 런북을 기재한다.
1. 단위 테스트(Unit Test)를 위한 Zenoh 통신 Mocking 전략
cargo test 를 칠 때마다 진짜 라우터를 띄우고 이더넷을 태울 수는 없다. 테스트는 철저히 CPU 메모리 안에서만, 밀리초 단위로 끝내야 한다.
1.0.1 [Runbook] Zenoh 하부 소켓 절단(Mock) 전술
Zenoh는 굳이 트레이트(Trait) 기반의 복잡한 DI(의존성 주입) 패턴으로 코드를 더럽힐 필요가 없다.
Zenoh의 메모리 내부 로컬 라우팅(In-Memory Routing) 모드를 활용하면, OS 소켓을 전혀 열지 않고도 가짜 세션을 띄울 수 있다!
1. 격리된 샌드박스 설정 (Mem-Transport)
테스트 코드 블록 안에서 Config를 짤 때, 외부 네트워크와의 통로를 완전히 폐쇄한다.
#[cfg(test)]
mod tests {
use super::*;
use zenoh::config::Config;
async fn create_mock_session() -> zenoh::Session {
let mut config = Config::default();
// 1. 멀티캐스트 스카우트 기능 완전 정지
config.scouting.multicast.set_enabled(Some(false)).unwrap();
// 2. 외부 접속 기능 정지
config.insert_json5("connect/endpoints", "[]").unwrap();
// 3. 내 귀를 닫기 (루프백조차 쓰지 않음)
config.insert_json5("listen/endpoints", "[]").unwrap();
// 이렇게 생성된 Session은 컴퓨터 외부나 OS 소켓을 전혀 오염시키지 않는
// "순수 프로세스 내부 캐시용 세션"으로 구동된다.
zenoh::open(config).res().await.unwrap()
}
#[tokio::test]
async fn test_virtual_publish_subscribe() {
let session = create_mock_session().await;
let publisher = session.declare_publisher("test/mock").res().await.unwrap();
let mut sub = session.declare_subscriber("test/mock").res().await.unwrap();
publisher.put("fake_data").res().await.unwrap();
// 진짜 네트워크를 타지 않고 인메모리 루프백으로 1μs 만에 도달!
let received = sub.recv_async().await.unwrap();
assert_eq!(received.value.payload.contiguous().as_ref(), b"fake_data");
}
}
이 방식을 사용하면 CI 인스턴스에서 병렬로 테스트 파일 10개가 돌아가더라도, 포트(7447) 번호가 충돌하여 Address already in use 에러가 뜨는 끔찍한 현상을 원천 차단할 수 있다.
2. 로컬 루프백 네트워크를 활용한 비동기 통합 테스트(Integration Test) 작성
단위 테스트(Unit Test)로 알고리즘을 검증했다면, 이제 tests/ 폴더 안에서 진짜 라우터 기반의 TCP/UDP 통합 테스트(Integration Test)를 치뤄야 한다.
이 단계는 Mocking이 아니라, 실제 멀티스레드 기반의 Publisher와 Subscriber가 서로 다른 Session을 파고 로컬 이더넷 망(127.0.0.1) 위에서 싸우는 모의 시가전이다.
2.0.1 [Runbook] 진짜 네트워크 소켓을 이용한 멀티 노드 시뮬레이션
통합 테스트의 핵심은 “타이밍(Timing)” 제어다.
Publisher가 노드를 띄우기도 전에 Subscriber가 쿼리를 날리면 테스트는 어이없이(Flaky) 깨진다.
1. 엄격한 순차 기동 스크립트 작성
#[cfg(test)]
mod integration_tests {
use zenoh::config::Config;
use tokio::time::{sleep, Duration};
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn test_real_tcp_routing() {
// [노드 A] 브레인 라우터 구동
// 테스트 전용 임의의 TCP 포트(예: 17447) 할당으로 병렬 테스트 충돌 방지
let mut router_cfg = Config::default();
router_cfg.insert_json5("listen/endpoints", "[\"tcp/127.0.0.1:17447\"]").unwrap();
router_cfg.set_mode(Some(zenoh::config::WhatAmI::Router)).unwrap();
let _router_session = zenoh::open(router_cfg).res().await.unwrap();
// 라우터가 포트를 점유하고 소켓을 열 때까지 100ms 대기 (Flaky 테스트 방지턱)
sleep(Duration::from_millis(100)).await;
// [노드 B] 데이터를 받는 클라이언트 기동
let mut sub_cfg = Config::default();
sub_cfg.insert_json5("connect/endpoints", "[\"tcp/127.0.0.1:17447\"]").unwrap();
let sub_session = zenoh::open(sub_cfg).res().await.unwrap();
let mut subscriber = sub_session.declare_subscriber("drone/cmd").res().await.unwrap();
sleep(Duration::from_millis(50)).await;
// [노드 C] 데이터를 쏘는 클라이언트 기동
tokio::spawn(async move {
let mut pub_cfg = Config::default();
pub_cfg.insert_json5("connect/endpoints", "[\"tcp/127.0.0.1:17447\"]").unwrap();
let pub_session = zenoh::open(pub_cfg).res().await.unwrap();
pub_session.put("drone/cmd", "TAKEOFF").res().await.unwrap();
// 전송 완료 후 세션 우아하게 종료
pub_session.close().res().await.unwrap();
});
// 결과 검증: tokio::select 를 이용해 Timeout 데드락 보호막 치기
tokio::select! {
result = subscriber.recv_async() => {
let msg = String::from_utf8_lossy(&result.unwrap().value.payload.contiguous()).to_string();
assert_eq!(msg, "TAKEOFF");
}
_ = sleep(Duration::from_secs(3)) => {
panic!("통합 테스트 타임아웃! 라우팅이 실패하여 패킷이 증발했습니다.");
}
}
}
}
통합 테스트는 이렇게 tokio::select!를 이용해 자체적인 “수명(Timeout)“을 할당해 주어야 한다. 그렇지 않으면 CI 파이프라인이 좀비 프로세스 때문에 6시간씩(Maximum Run Time) 돌다가 통째로 터지는 비용 청구(AWS Bill) 참사를 겪게 된다.
3. GitHub Actions 기반의 자동화 빌드 및 테스트 환경 구축
가장 완벽하게 작성된 Zenoh 애플리케이션이라 할지라도, 동료 개발자가 더러운 코드(Memory Leak)를 커밋하는 순간 무너진다.
따라서 PR(Pull Request)이 올라올 때마다 냉혹한 잣대(CI/CD)로 빌드와 테스트를 심판해야 한다.
3.0.1 [Runbook] 프로덕션 CI 워크플로우 템플릿
GitHub Actions 러너(Runner)는 빈약한 우분투 깡통이다.
수많은 라이브러리가 얽힌 분산 시스템을 여기다 매번 구워서 테스트하면 30분이 걸리지만, 스캐시(sccache) 와 의존성 캐싱을 바르면 2분 컷으로 줄일 수 있다.
.github/workflows/zenoh-ci.yml 파일 작성법
name: Zenoh Distributed System CI
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
env:
CARGO_TERM_COLOR: always
# 의존성 빌드 속도를 400% 펌핑하는 캐시 엔진 활성화
RUSTC_WRAPPER: sccache
jobs:
build_and_test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
# 캐시 마법을 설치하여 동일한 Zenoh 컴파일 재반복 방지 (시간 단축의 핵심)
- name: Rust Cache Setup
uses: Swatinem/rust-cache@v2
- name: install sccache
run: cargo install sccache --locked
- name: Build Code (Check Phase)
run: cargo check --workspace --all-features
# 통합 테스트에서 에러나 데드락이 났을 때 로그를 보기 위한 환경변수 주입
- name: Run Tests (Unit & Integration)
env:
RUST_LOG: info,zenoh=debug
# --nocapture 를 줘야 테스트 실패 시 프린트된 포렌식 로그가 보인다.
run: cargo test --workspace -- --nocapture
[DevOps 인스펙션]
이 YML 스크립트는 단순 작동 유무를 표기하는 것을 넘어선다. RUST_LOG=info 주입은 당신이 잠든 새벽에 봇이 테스트하다 죽어버렸을 때, 아침에 일어나서 GitHub Action 로그 창을 열면 “어느 TCP 포트가 막혀서 Zenoh가 죽었는지” 힌트를 직설적으로 뱉게 만드는 관제탑의 역할을 대신한다.
4. Rust clippy와 fmt를 이용한 코드 품질 강제화
C/C++에서는 선배의 코드 스타일 마찰 때문에 회의 시간이 피로해지지만, Rust 생태계에서는 이 논쟁을 기계(Compiler)에게 맡긴다.
분산 프로그래밍에서는 단순한 띄어쓰기가 문제가 아니라, clone()을 남발하는 행위나 불안전한 비동기 스코프 락(Lock)을 쥐는 행위가 성능과 직결되므로 Clippy 보루를 반드시 세워야 한다.
4.0.1 [Runbook] Linter를 이용한 무결성 수비대 구축
CI/CD 파이프라인(zenoh-ci.yml) 파일 하단 혹은 커밋 훅(Git Hook)에 다음 스크립트를 박아 넣어, 팀원의 뇌동매매식 코딩을 완벽하게 차단하라.
1. 포맷팅 억제 (fmt)
아무리 성능이 좋아도 들여쓰기가 망가진 코드는 본선(Main)에 올릴 수 없게 거절(Reject)한다.
## 한 치의 오차도 용납하지 않고 포맷이 어긋나면 빌드를 터뜨린다.
cargo fmt --all -- --check
2. 잔소리봇 Clippy 극대화
Clippy는 구문 에러를 넘어 “Zenoh의 로컬 변수인 session을 여기서 복제(clone)하면 성능 오버헤드가 좀 있는데 그냥 참조로 넘기지 그래?“라고 코칭을 해주는 1급 아키텍트다.
## 기본 경고는 물론 사소한 컨벤션 차이조차 ERROR로 간주하여 강제 기각!
cargo clippy --all-targets --all-features -- -D warnings
[아키텍처 인스펙션: 허용 범위 지정]
간혹 복잡한 매크로나 자동 생성된 직렬화 코드(Serde) 안에서 Clippy가 과도한 태클을 거는 경우가 있다. 이럴 때는 무지성으로 룰 전체를 끄지 말고, 문제의 함수 상단에만 마이크로 방어막을 친다.
#[allow(clippy::needless_borrow)] // 이 함수 한정으로 깐깐한 검열 무시
fn process_rare_packet(data: &String) {
// ...
}
빌드(Build), 테스트(Test), 린트(Lint) 이 3가지 톱니바퀴가 로컬 터미널과 클라우드 배포 파이프라인에서 완벽하게 동일한 결과물(Reproducibility)을 낼 때, 비로소 “우리 시스템은 프로덕션-레디(Production-Ready) 상태다“라고 자부할 수 있다.