hello, robot

로봇 노드에서 에러 처리는 방어 코드 몇 줄이 아니라 정책이에요. 시리얼이 한 번 침묵했을 때 재시도할지 바로 포기할지, 콜백에서 예외가 났을 때 노드가 죽을지 살지, 여섯 관절 중 다섯 개만 응답했을 때 그 반쪽 데이터를 쓸지 버릴지. 전부 코드를 짜기 전에 답이 정해져 있어야 하는 질문이에요.

실패를 적는 세 가지 방법

C++에서 “이 함수는 실패할 수 있다”를 적는 방법은 크게 셋이에요. 에러코드는 실패의 종류를 열거형으로 반환하고, std::optional<T>는 값이 있거나 없거나를 타입으로 적고, 예외는 반환 경로 밖의 별도 통로로 던져요. 셋 다 현업 코드에 공존하고, 실패의 성격에 따라 골라 써요.

상황도구이유
없음이 정상 범주 (파싱·조회 실패)optional이유가 하나뿐, 호출부가 if로 바로 분기
실패 이유를 구분해 전파 (IO 계층)에러코드 enum class비용 일정, C API와 맞음
드물고 그 자리서 처리 불가 (초기화 실패)예외정상 경로를 더럽히지 않고 위로 탈출
고주기 제어 루프 내부에러코드·optionalthrow 비용과 비결정성 회피

에러코드의 힘은 실패 이유를 구분해 전파할 수 있고 비용이 정상 반환과 똑같이 일정하다는 거예요. 약점은 호출자가 반환값을 무시해도 컴파일이 된다는 건데, [[nodiscard]]를 붙이면 무시할 때 경고가 떠서 상당 부분 막혀요.

optional은 실패 이유가 하나뿐이고 그게 “없음”일 때 가장 잘 맞아요. 대신 왜 없는지는 못 실어요. 이유까지 실어야 하면 에러코드로 가거나 값과 에러를 한 타입에 담는 std::expected<T, E>가 그 자리를 맡아요.

읽는 방향으로 정리하면, 시그니처에 optional이 보이면 없을 수 있으니 확인하고 쓰라는 뜻이고, 에러코드 반환이면 종류별로 분기하라는 뜻이고, 둘 다 없으면 예외가 올라올 수 있다는 뜻이에요.

예외는 정상 경로에서 거의 공짜, 던지는 순간 비싸요

던지는 순간 일어나는 일이 스택 되감기예요. throw 지점부터 그걸 잡는 catch까지 함수 호출을 거슬러 올라가면서 그 사이의 지역 객체 소멸자를 생성 역순으로 전부 불러 줘요. C++ 소유권과 수명 — 스마트 포인터에서 본 RAII가 여기서 힘을 내요. 시리얼 핸들을 해제 함수와 함께 타입으로 감싸 뒀다면 예외가 어디서 터지든 되감기가 소멸자를 불러서 포트가 닫히거든요. 예외 안전은 별도의 기술이 아니라 자원을 전부 타입에 태워 둔 코드가 공짜로 얻는 성질이에요.

비용 감각도 잡고 갈 만해요. 현대 컴파일러의 예외 구현은 테이블 기반이라 try 블록에 들어가는 것과 예외가 안 나는 정상 경로는 추가 비용이 거의 없어요. 대신 throw가 실제로 일어나는 순간은 비싸요. 예외 객체 할당과 테이블 탐색, 프레임별 되감기까지 정상 반환의 수백에서 수천 배가 들고 시간이 얼마나 걸릴지 예측하기도 어려워요.

그래서 판단 기준은 빈도예요. 수신 주기마다 몇 번씩 나는 타임아웃을 예외로 돌리면 제어 루프가 절뚝이니 에러코드나 optional로 받고, 몇 시간에 한 번 나는 구조적 붕괴는 예외로 위에 알리는 게 맞아요.

콜백 밖으로 예외를 내보내지 않아요

이 주제에서 가장 중요한 자리예요. C++에서 예외는 잡는 사람이 없으면 프로세스를 즉사시켜요. main까지 올라가도 catch가 없으면 끝이에요. 그런데 로봇 노드의 코드 대부분은 콜백이고, 콜백은 내가 부르는 게 아니라 실행기가 불러요. 콜백 안에 try를 안 쳐 두면 그 예외를 잡아 줄 사람이 호출 경로 어디에도 없어요.

연쇄를 따라가면 이래요. 구독 콜백 안에서 문자열 변환이 깨진 입력을 만나 예외를 던지면 실행기의 spin()까지 올라가고, 프레임워크가 사용자 예외를 대신 삼켜 주지 않으니 프로세스가 통째로 죽어요. 노드가 죽으면 그 노드가 들고 있던 구독과 퍼블리셔와 타이머가 전부 사라지고, 하드웨어는 마지막으로 받은 명령을 유지한 채 굴러가요. 메시지 하나가 로봇을 무명령 상태로 만드는 경로예요.

void on_scan(const LaserScan& msg) {
    try {
        process_scan(msg);                          // 실제 일은 안쪽 함수에
    } catch (const std::exception& e) {
        log_error("scan callback: %s", e.what());   // 원인은 반드시 기록
        publish_stop();                             // 안전 상태로
    }
}

같은 구조를 파이썬 서비스 서버에서 본 기록이 서비스 콜백 예외가 노드를 멈추는 경로와 죽지 않는 서버예요. 거기서는 spin이 멈추고 클라이언트가 무한 대기했는데, C++에서는 한 발 더 나가 프로세스 자체가 사라진다는 점만 달라요.

잡은 다음의 정책은 노드 역할에 따라 갈려요. 센서 노드라면 그 메시지 하나만 버리고 계속 도는 게 보통 맞고, 안전에 직결된 노드라면 정지 명령을 낸 뒤 명시적으로 종료하는 게 맞을 수도 있어요. 요점은 계속 살든 죽든 정한 방식으로 하는 것이지 스택 되감기에 우연히 맡기는 게 아니라는 거예요. 잡고 나서 로그 없이 넘어가는 건 최악이고요. 원인이 사라져서 다음 실패 때 처음부터 다시 추적해야 하거든요.

소멸자에서 새는 예외는 한층 급해요. 되감기 중이라는 건 이미 예외 하나가 날아가는 중이라는 뜻인데 그 와중에 소멸자가 또 던지면 예외가 둘이 되고, C++은 이 상황을 처리하지 않고 즉시 종료해요. 그래서 소멸자 안의 정리 작업은 실패해도 삼키고 로그만 남기고, 실패를 알리고 싶으면 명시적인 close() 메서드를 따로 둬요.

재시도는 한 계층에서만

실패를 분류하는 게 먼저예요. 타임아웃 한 번이나 체크섬 불일치처럼 다시 하면 될 가능성이 높은 일시적 실패가 있고, 장치 노드가 사라진 것처럼 다시 해도 소용없는 지속적 실패가 있어요. 일시적이면 재시도가 맞고 지속적이면 즉시 실패로 위에 알리는 게 맞아요. 지속적 실패를 재시도로 덮으면 로봇이 몇 초씩 무반응인 채로 “왜 가끔 늦지”라는 더 어려운 문제로 둔갑해요.

재시도에는 주의할 점이 셋 있어요. 백오프 없이 연타하면 가뜩이나 힘든 버스에 트래픽만 보태고, 재시도를 여러 계층에서 하면 3회씩 세 겹이 27회가 되어 지연이 폭발해요. 그리고 블로킹 재시도가 제어 루프 스레드를 세우면 그게 원래 실패보다 큰 사고예요. 재시도하는 동안에도 주기는 계속 돌아야 하니 C++ 동시성 — 센서 수신과 제어 루프에서 나눈 수신 스레드 분리가 전제가 돼요.

부분 실패는 하드웨어 특유의 문제예요. 여섯 관절에 상태를 물었는데 다섯 개만 답한 상황이요. 판단 기준은 이 데이터가 다음에 어디로 가느냐예요. 제어기로 들어갈 입력이라면 하나라도 빠졌을 때 이번 사이클을 통째로 버리는 게 기본이에요. 다섯 관절은 지금 값이고 한 관절은 옛날 값인 포즈로 궤적을 풀면 로봇이 실제와 다른 자세를 기준으로 움직이거든요. 반대로 대시보드에 그릴 값이라면 다섯 개라도 보여 주는 편이 나아요.

assert와 런타임 검증은 잘못의 주인으로 가릅니다

assert는 디버그 빌드에서 조건이 거짓이면 그 자리에서 멈추고 파일과 줄 번호를 찍어요. 핵심 성질은 릴리즈 빌드에서 통째로 사라진다는 거예요. 그래서 assert는 “여기서 이 조건이 거짓이면 내 코드 어딘가가 버그”인 내부 계약에만 써요. 외부에서 온 값 검증에 쓰면 릴리즈에서 검사가 증발해서, 개발 중엔 잘 잡히다가 현장에서만 뚫리는 최악의 배치가 돼요.

토픽으로 들어온 명령, 파라미터 파일, 시리얼로 올라온 센서 값은 빌드 모드와 무관하게 항상 검사해야 해요. 가르는 기준을 한 줄로 적으면, 이 값이 틀렸을 때 고칠 사람이 나면 assert이고 환경이나 사용자면 런타임 검증이에요.

에러 처리는 결국 도구 선택과 정책 결정의 조합이에요. 재시도인가 즉시 실패인가, 반쪽 데이터를 쓰나 버리나. 그중에서도 콜백 밖으로 예외를 내보내지 않는다는 규칙 하나만 지켜도 노드가 메시지 하나에 즉사하는 사고의 대부분이 막혀요.