A.3 ROS2 블랙보드
1. ROS2 블랙보드 아키텍처 개요 및 설계 패러다임
로봇 운영체제(ROS2) 환경에서 블랙보드(Blackboard) 아키텍처는 분산된 노드(Node) 간의 상태 정보, 센서 데이터, 그리고 제어 변수를 중앙 집중적으로 관리하고 공유하기 위한 메모리 기반의 데이터 교환 소프트웨어 설계 패턴(Software Design Pattern)이다.
이는 행동 트리(Behavior Tree)의 구성 요소로 널리 알려져 있으나, 본질적으로는 다수의 독립적인 ’지식 소스(Knowledge Sources, 즉 ROS2 노드 또는 내부 스레드)’가 비동기적으로 협력하여 문제를 해결하도록 지원하는 범용적이고 독립적인 아키텍처이다.
1.1 통신 미들웨어(DDS)와 블랙보드의 구조적 차별성
ROS2의 기본 통신 미들웨어인 DDS(Data Distribution Service) 기반의 퍼블리시-서브스크라이브(Publish-Subscribe) 모델과 블랙보드 패턴은 데이터 교환 방식과 생명 주기 측면에서 학술적, 구조적인 차이를 갖는다.
- 데이터 흐름 및 구동 패러다임:
- DDS (Pub/Sub): 메시지 기반의 이벤트 주도형(Event-driven) 스트림이다. 데이터가 생성될 때마다 네트워크를 통해 푸시(Push)되며, 수신자는 콜백(Callback) 함수를 통해 반응한다.
- 블랙보드: 메모리 주소 기반의 상태 주도형(State-driven) 아키텍처이다. 데이터의 생산과 소비가 시간적으로 분리되어 있으며, 노드는 필요할 때 능동적으로 블랙보드 메모리에 접근하여 최신 상태를 읽어온다(Polling/Pull).
- 데이터 저장 형태 및 수명 주기:
- DDS (Pub/Sub): 데이터는 네트워크 큐(Queue) 및 버퍼(Buffer)에 임시 보관되며, QoS(Quality of Service) 설정(History, Durability)에 따라 수명이 결정된다.
- 블랙보드: 중앙 집중형 키-값(Key-Value) 기반 메모리 저장소에 보관된다. 데이터는 프로세스 수명 주기와 동일하게 유지되거나, 명시적으로 덮어쓰기(Overwrite) 또는 삭제(Clear)되기 전까지 영구적으로 상태를 보존한다.
- 시스템 결합도(Coupling):
- DDS (Pub/Sub): 토픽(Topic) 이름과 정형화된 인터페이스(Message Type)에 의한 느슨한 결합(Loose Coupling)을 형성한다.
- 블랙보드: 식별자(String Key)에 의해 데이터 공유 메모리에 접근하며, 내부적으로 타입 소실(Type Erasure) 기법을 사용하여 데이터 구조에 대한 결합도를 더욱 낮춘다.
1.2 소프트웨어 공학적 설계 패러다임 및 채택 당위성
분산 로봇 제어 시스템에서 블랙보드 아키텍처를 채택하는 것은 다음과 같은 소프트웨어 공학적 목적을 달성하기 위함이다.
- 공간적·시간적 분리 (Spatial and Temporal Decoupling):
데이터를 생산하는 노드(예: 센서 드라이버, 퍼셉션 알고리즘)와 이를 소비하는 노드(예: 제어기, 경로 계획기)는 서로의 존재나 실행 타이밍을 알 필요가 없다. 블랙보드라는 중간 매개체를 통해 데이터 통신이 이루어지므로, 특정 노드의 크래시(Crash)나 지연이 즉각적인 시스템 전체의 통신 마비로 이어지는 것을 방지한다.
- 데이터 추상화 (Data Abstraction) 및 통합:
다수의 퍼셉션 노드에서 산출된 이기종 데이터(예: 비전 기반 객체 인식 결과, 라이다 기반 장애물 거리, IMU 센서 데이터)를 단일한 데이터 컨테이너로 추상화한다. 제어 노드는 수많은 네트워크 토픽을 개별적으로 구독하고 콜백을 관리할 필요 없이, 블랙보드에서 필요한 키(Key) 값만 즉각적으로 참조하여 연산 복잡도를 낮춘다.
- 전역 상태 동기화 (Global State Synchronization):
복잡한 다중 에이전트 시스템이나 계층적 제어 구조에서 로봇의 현재 임무 상태, 배터리 잔량, 에러 플래그 등의 전역(Global) 컨텍스트를 유지하는 싱글톤(Singleton) 저장소 역할을 수행한다. 이를 통해 시스템 내의 모든 활성 모듈이 ’세상에 대한 동일한 인식(Consistent World View)’을 공유하도록 수학적으로 보장한다.
- 동적 구성 (Dynamic Configuration) 지원:
컴파일 타임(Compile-time)에 결정되는 정적 변수 할당과 달리, 런타임(Run-time)에 새로운 키-값 쌍을 생성하고 매핑할 수 있다. 이는 로봇이 새로운 임무를 할당받거나 환경 변화에 적응해야 할 때, 소스 코드의 재컴파일 없이 데이터 흐름의 경로(리매핑)를 변경할 수 있는 극대화된 유연성을 제공한다.
2. 블랙보드의 구조 및 컴포넌트
ROS2 환경에서 블랙보드(Blackboard) 아키텍처는 다수의 독립적인 연산 주체들이 비동기적으로 데이터를 교환하고 시스템의 전역 상태(Global State)를 유지하기 위한 공유 메모리 기반의 설계 패턴이다. 이 시스템은 수학적 데이터 구조와 소프트웨어 공학적 동시성(Concurrency) 제어 메커니즘이 결합된 형태로, 크게 지식 소스, 데이터 컨테이너, 그리고 동시성 제어 셸로 구성된다.
2.1 지식 소스 (Knowledge Sources)
지식 소스는 블랙보드 시스템에 참여하여 실제 연산을 수행하는 독립적인 ROS2 모듈(Node 또는 Composable Node)이다.
-
정의: 로봇의 특정 도메인(예: 컴퓨터 비전, 경로 계획, 모터 제어)에 대한 전문적인 지식과 알고리즘을 캡슐화한 실행 단위이다.
-
역할 분리:
-
생산자 (Producer): 센서 데이터를 처리하거나 제어 목표를 산출하여 그 결과를 블랙보드에 기록(Write)한다.
-
소비자 (Consumer): 자신의 연산에 필요한 환경 상태나 제어 변수를 블랙보드로부터 참조(Read)한다.
-
비동기적 상호작용: 지식 소스들은 서로의 존재나 메모리 주소를 직접 알지 못하며(Decoupling), 오직 블랙보드라는 중앙 매개체를 통해서만 비동기적으로 상태를 교환한다.
2.2 데이터 컨테이너 및 저장 구조 (Data Container and Storage)
블랙보드의 핵심은 이기종(Heterogeneous) 데이터를 효율적으로 적재하고 검색하기 위한 메모리 자료구조이다.
- 키-값 쌍 (Key-Value Pair) 모델: 데이터는 고유한 문자열 식별자인 ’키(Key)’와 실제 데이터인 ’값(Value)’의 쌍으로 매핑된다. C++ 환경에서는 평균적으로 O(1)의 탐색 시간 복잡도를 보장하는 해시 테이블(Hash Table)인
std::unordered_map이 주로 사용된다. - 타입 소실 (Type Erasure): ROS2 시스템은
int,double과 같은 기본 자료형부터geometry_msgs::msg::PoseStamped와 같은 복잡한 사용자 정의 구조체까지 다양한 타입의 데이터를 다룬다. 이들을 단일 컨테이너에 저장하기 위해 컴파일 타임의 타입 정보를 지우고 런타임에 다형성을 제공하는 C++17의std::any(또는std::variant) 클래스가 활용된다. 이를 통해 메모리 레이아웃의 유연성을 확보한다.
2.3 동시성 제어 및 동기화 셸 (Concurrency Control Shell)
ROS2의 멀티스레드 실행기(Multi-Threaded Executor) 환경에서는 여러 지식 소스가 동일한 블랙보드 메모리 영역에 동시에 접근할 수 있으므로, 데이터 경합(Race Condition)과 메모리 오염(Memory Corruption)을 방지하기 위한 엄격한 스레드 안전성(Thread Safety) 보장 메커니즘이 필수적이다.
- 읽기-쓰기 락 (Read-Write Lock) 모델: 단순한 상호 배제 락(
std::mutex)은 시스템의 병렬 처리 성능을 심각하게 저하시킨다. 따라서 접근 패턴에 따라 락의 수준을 다르게 적용하는std::shared_mutex가 채택된다. - 공유 락 (Shared Lock): 데이터를 읽기(Read)만 하는 경우 적용된다. 다수의 지식 소스가 동시에 락을 획득하여 병렬적으로 데이터를 참조할 수 있다.
- 독점 락 (Exclusive Lock): 데이터를 쓰거나(Write) 삭제할 때 적용된다. 특정 스레드가 이 락을 획득하면, 연산이 완료될 때까지 다른 모든 읽기 및 쓰기 스레드의 접근이 차단되어 데이터의 무결성을 보장한다.
2.4 인터페이스 및 런타임 타입 안정성 (Interface & RTTI)
블랙보드는 지식 소스가 데이터를 안전하게 입출력할 수 있도록 템플릿(Template) 기반의 접근 포트(Port) 인터페이스를 제공해야 한다.
- 타입 캐스팅 (Type Casting):
std::any컨테이너에서 데이터를 추출할 때, 지식 소스가 요청한 C++ 자료형<T>로의 변환을 수행한다 (std::any_cast). - 런타임 타입 검사 (Run-Time Type Checking): 블랙보드에 저장된 원본 데이터의 타입과 지식 소스가 요청한 타입이 불일치할 경우, 메모리 접근 오류로 인한 시스템 크래시(Crash)가 발생할 수 있다. 인터페이스는 이를 방지하기 위해 런타임 타입 정보(RTTI)를 사전 검증하고, 불일치 시
std::bad_any_cast예외를 포착하여 안전한 에러 코드(예:false반환 또는 로깅)로 처리하는 방어적 프로그래밍(Defensive Programming) 구조를 갖추어야 한다.
3. ROS2 환경에서의 아키텍처적 구현 모델
ROS2(Robot Operating System 2) 생태계에서 블랙보드(Blackboard) 패턴을 구현할 때, 시스템의 물리적/논리적 토폴로지(Topology)와 요구되는 실시간성(Real-time Constraints)에 따라 아키텍처 모델이 결정된다. 이는 크게 단일 프로세스 메모리를 공유하는 ‘인트라 프로세스(Intra-Process)’ 모델과, 네트워크 미들웨어를 통해 데이터를 분산하는 ‘인터 프로세스(Inter-Process)’ 모델로 대별된다.
3.1 인트라 프로세스 (Intra-Process) 로컬 블랙보드
하나의 운영체제(OS) 프로세스 내부에서 여러 개의 컴포저블 노드(Composable Nodes)가 스레드(Thread) 형태로 구동되거나, 단일 실행기(Executor) 내에서 복수의 논리적 모듈이 동작할 때 채택되는 아키텍처이다.
- 동작 원리: 데이터는 호스트 프로그램의 힙(Heap) 메모리 영역에 싱글턴(Singleton) 객체나 의존성 주입(Dependency Injection)을 통해 생성된 중앙 해시 테이블(
std::unordered_map)에 저장된다. - 통신 오버헤드: 네트워크 소켓 통신이나 직렬화/역직렬화(Serialization/Deserialization) 과정이 전혀 발생하지 않는다. 데이터 접근은 포인터(Pointer) 역참조 및 해시 버킷 조회를 통해 이루어지므로 시간 복잡도 O(1)의 극단적으로 짧은 지연 시간(Latency)을 보장한다.
- 동시성 제어: 여러 스레드가 동시에 블랙보드에 접근하므로
std::shared_mutex와 같은 읽기-쓰기 락(Read-Write Lock)을 통한 상호 배제(Mutual Exclusion)가 필수적으로 요구된다. - 학술적 적용처: 100Hz 이상의 고주파수 제어가 필요한 모터 서보 루프(Servo Loop), 또는 센서 데이터의 복사 오버헤드(Zero-copy)를 제거해야 하는 단일 로봇 내의 긴밀하게 결합된(Tightly-coupled) 인지-제어 파이프라인에 적합하다.
- 구조적 한계: 공유 메모리에 의존하므로 호스트 프로세스에 크래시(Crash)가 발생하면 블랙보드 내의 모든 상태 데이터가 즉시 소멸(Volatile)된다.
3.2 인터 프로세스 (Inter-Process) 전역 블랙보드
물리적으로 분리된 여러 프로세스, 혹은 네트워크 상의 서로 다른 로봇 및 엣지(Edge) 서버 간에 상태 정보를 공유하기 위해 ROS2의 DDS(Data Distribution Service) 미들웨어를 확장하여 사용하는 아키텍처이다. 시스템의 규모와 목적에 따라 세 가지 하위 구현 방식으로 나뉜다.
3.2.1 파라미터 서버 (Parameter Server) 기반 구현
- 동작 원리: ROS2 노드가 기본적으로 제공하는 전역 파라미터 서버를 블랙보드의 키-값(Key-Value) 저장소로 활용한다. 파라미터 이벤트 콜백(
OnSetParametersCallback)을 통해 상태 변화를 감지한다. - 특성: 동적 재구성(Dynamic Reconfiguration)에는 유리하나, 데이터 갱신 시 발생하는 서비스 통신 오버헤드가 커서 고빈도의 센서 데이터 공유에는 부적합하다. 임무 목표(Goal)나 전역 설정값 공유에 한정적으로 사용된다.
3.2.2 지연 전달 (Transient Local) QoS 기반 구현
- 동작 원리: ROS2 토픽(Topic)의 QoS(Quality of Service) 프로필 중 내구성(Durability)을
Transient Local로 설정한다. 퍼블리셔(지식 소스)는 데이터를 발행하고, 미들웨어는 최신 데이터를 내부 캐시에 유지(Latch)한다. - 특성: 나중에 네트워크에 참여한(Late-joining) 서브스크라이버 노드도 즉각적으로 최신 블랙보드 상태를 읽을 수 있다. 사실상 ROS2 토픽 네트워크를 분산 키-값 저장소처럼 취급하는 방식이다.
- 학술적 적용처: 로봇의 현재 상태 기계(State Machine) 단계나 글로벌 에러 플래그와 같이 갱신 빈도는 낮으나 모든 노드가 즉각적으로 최신 상태를 동기화해야 하는 경우에 타당하다.
3.2.3 외부 KVS (Key-Value Store) 연동 브리지 구현
- 동작 원리: Redis, etcd와 같은 고성능 인메모리(In-memory) 데이터베이스를 시스템 외부에 구축하고, ROS2 노드는 해당 DB의 클라이언트(Client) 라이브러리를 통해 데이터를 읽고 쓴다.
- 특성: 단일 로봇의 범위를 넘어선 함대 관리(Fleet Management) 및 다중 에이전트 시스템(Multi-Agent System)에서 로봇 간의 전역 블랙보드 역할을 수행한다. 데이터의 영속성(Persistence)과 결함 허용성(Fault Tolerance)을 확보할 수 있다.
- 구조적 한계: 외부 네트워크 스택을 거치므로 이더넷 대역폭 및 트래픽 상태에 따라 접근 시간(Access Time)의 비결정성(Non-determinism)이 발생한다. 하드 실시간(Hard Real-time) 제어에는 사용할 수 없다.
4. 메모리 안전성 및 동시성(Concurrency) 관리
ROS2(Robot Operating System 2)의 런타임 환경은 기본적으로 비동기적(Asynchronous)이며 콜백 주도형(Callback-driven) 아키텍처를 따른다. 다중 스레드 실행기(Multi-Threaded Executor)가 도입된 시스템에서 여러 컴포저블 노드(Composable Nodes)가 단일 블랙보드 인스턴스 메모리에 동시다발적으로 접근할 경우, 데이터 경합(Race Condition), 데이터 오염(Data Corruption), 그리고 교착 상태(Deadlock)와 같은 치명적인 동시성 결함이 발생한다.
따라서 블랙보드 아키텍처는 운영체제(OS) 수준의 스레드 동기화 기법과 C++ 언어 명세에 기반한 메모리 안전성 장치를 수학적이고 구조적으로 보장해야 한다.
4.1 스레드 안전성(Thread Safety)과 락(Lock) 최적화 모델
멀티스레드 환경에서 공유 메모리인 블랙보드에 대한 접근을 제어하기 위해 상호 배제(Mutual Exclusion) 기법이 적용된다. 단순한 std::mutex의 전면적인 사용은 시스템의 병렬 처리 성능을 심각하게 저하시키므로, 읽기 및 쓰기 연산의 빈도 비대칭성을 고려한 최적화 모델이 요구된다.
- 읽기-쓰기 락(Read-Write Lock) 패턴의 적용: 블랙보드 데이터는 일반적으로 한 번 기록(Write)된 후 여러 노드에 의해 다수 조회(Read)되는 특성을 갖는다. 이를 위해 C++17 표준의
std::shared_mutex를 도입한다. - 공유 락(Shared Lock): 데이터 조회 연산 시
std::shared_lock<std::shared_mutex>를 획득한다. 이는 다수의 스레드가 동시에 블랙보드 데이터를 읽는 것을 허용하여 병목(Bottleneck) 현상을 제거한다. - 독점 락(Exclusive Lock): 데이터 갱신 및 쓰기 연산 시
std::unique_lock<std::shared_mutex>를 획득한다. 쓰기 스레드가 락을 획득하면, 연산이 완료될 때까지 다른 모든 읽기/쓰기 스레드의 접근이 물리적으로 차단(Blocking)되어 데이터의 원자성(Atomicity)을 보장한다. - 우선순위 역전(Priority Inversion) 방지: 실시간 운영체제(RTOS/PREEMPT_RT) 커널 위에서 ROS2를 구동할 때, 낮은 우선순위의 스레드가 락을 점유하여 높은 우선순위의 제어 스레드가 지연되는 현상을 방지해야 한다. 블랙보드 내부의 임계 구역(Critical Section)은 데이터 복사(Memory Copy) 및 포인터 할당 수준으로 극단적으로 짧게 유지되어야 하며, I/O 연산이나 장시간의 계산 로직이 임계 구역 내에 포함되어서는 절대 안 된다.
4.2 동적 타입 안정성(Type Safety) 및 RTTI 검증 메커니즘
범용적인 블랙보드는 정수형, 부동소수점, 제어 구조체, ROS2 사용자 정의 메시지 등 이기종의 데이터를 단일 컨테이너에 저장해야 한다. 이를 위해 C++의 타입 은닉(Type Erasure) 기법인 std::any가 주로 활용되나, 이는 런타임 타입 오류의 위험성을 내포하고 있다.
- 메모리 덤프 및 강제 형변환의 위험성:
void*기반의 C언어식 포인터 캐스팅은 메모리 세그멘테이션 결함(Segmentation Fault)을 유발할 수 있으므로 현대 C++ 아키텍처에서는 완전히 배제된다. - RTTI(Run-Time Type Information) 기반 동적 검증: 데이터 소비자(Consumer) 노드가 블랙보드에서 값을 읽어올 때, 기대하는 자료형 T_{expected}와 실제로 블랙보드에 적재된 자료형 T_{stored}가 정확히 일치하는지 런타임에 엄격히 검사해야 한다.
std::any_cast<T>를 호출할 때, 컴파일러는 내부적으로typeid연산자를 통한 RTTI 비교를 수행한다.- 타입이 불일치할 경우
std::bad_any_cast예외(Exception)가 투척(Throw)된다. 블랙보드의 인터페이스 설계 시 이러한 예외를 내부의try-catch블록으로 안전하게 포획(Catch)하고, 호출자 노드에게 오류 상태 코드를 반환하도록 설계하여 로봇 메인 프로세스의 비정상 종료(Crash)를 원천 차단해야 한다.
4.3 데이터 무결성(Data Integrity) 및 수명 주기(Lifecycle) 관리
포인터나 참조자(Reference)를 통해 블랙보드에 데이터를 저장할 경우, 댕글링 포인터(Dangling Pointer) 문제와 메모리 누수(Memory Leak)가 발생하여 시스템의 장기 운용 안정성을 훼손한다.
- 값 복사(Value Semantics) 원칙: 블랙보드에 저장되는 데이터는 원칙적으로 값 복사 방식을 따라야 한다. 즉, 데이터를 기록하는 노드의 지역 변수 수명 주기가 종료되더라도 블랙보드 내부의 데이터는 독립적인 메모리 공간에 안전하게 복제되어 유지되어야 한다.
- 스마트 포인터(Smart Pointer)의 선별적 적용: 라이다 포인트 클라우드(Point Cloud)나 고해상도 비전 데이터와 같이 메모리 복사 오버헤드가 막대한 대용량 페이로드(Payload)의 경우, 값 복사 대신
std::shared_ptr<const T>형태의 불변(Immutable) 스마트 포인터로 캡슐화하여 블랙보드에 적재한다. - 데이터의 소유권(Ownership)은 참조 카운팅(Reference Counting) 메커니즘에 의해 관리되며, 이를 참조하는 모든 읽기 스레드의 사용이 종료되는 시점에 힙(Heap) 메모리가 안전하게 해제된다.
- 포인터에
const지시자를 강제함으로써, 블랙보드에서 데이터를 읽어간 특정 노드가 임의로 원본 메모리를 변조(Modification)하여 다른 노드에 영향을 미치는 부수 효과(Side Effect)를 컴파일 타임(Compile-time)에 차단한다.
4.4 교착 상태(Deadlock) 회피 아키텍처
블랙보드 시스템 내에서 다중 스레드가 여러 자원(Key)을 동시에 요구할 때 교착 상태가 발생할 가능성이 존재한다.
- 락 획득 순서의 정형화: 단일 블랙보드 인스턴스 내에서는 단일
std::shared_mutex로 전체 해시 맵(Hash Map) 컨테이너를 보호하는 굵은 입도(Coarse-grained) 락킹 방식이 보편적이며, 이는 단일 자원 점유이므로 원천적으로 교착 상태의 위험을 낮춘다. - 원자적 트랜잭션(Atomic Transaction): 두 개 이상의 키(Key)를 동시에 읽거나 갱신해야 하는 복합 연산이 요구될 경우, 블랙보드 외부의 노드 비즈니스 로직에서 개별적으로
get()과set()을 교차 호출하는 대신, 블랙보드 내부 인터페이스 차원에서 복수 키에 대한 원자적 트랜잭션 메서드를 제공하여 락킹이 한 번만 발생하도록 설계해야 한다.
5. 범용 ROS2 블랙보드 C++ 설계 및 구현 명세
본 장에서는 BehaviorTree.CPP 프레임워크에 종속되지 않고, ROS2의 단일 프로세스(Intra-Process) 내에서 다수의 컴포저블 노드(Composable Nodes) 또는 다중 스레드 콜백(Multi-threaded Callbacks) 간에 데이터를 안전하게 교환할 수 있는 범용 블랙보드의 C++ 아키텍처 명세를 기술한다.
이 설계는 동시성 제어(Concurrency Control), 타입 안정성(Type Safety), 그리고 시간 복잡도 최적화를 수학적이고 공학적인 원리에 기반하여 구현한다.
5.1 아키텍처 요구사항 및 설계 원칙
- 시간 복잡도 (Time Complexity): 제어 주기 지연을 최소화하기 위해 데이터의 삽입 및 탐색은 평균적으로 O(1)의 시간 복잡도를 보장해야 한다. 이를 위해 해시 테이블(Hash Table) 자료구조인
std::unordered_map을 채택한다. - 타입 소실 및 안정성 (Type Erasure & Safety): 블랙보드는 정수형(int), 실수형(double)뿐만 아니라
sensor_msgs::msg::Image나nav_msgs::msg::Path와 같은 복잡한 ROS2 커스텀 메시지 구조체도 저장할 수 있어야 한다. C++17의std::any를 통해 타입 소실(Type Erasure)을 구현하고, 메모리 접근 시 런타임 타입 정보(RTTI)를 검증하여std::bad_any_cast예외로 인한 프로세스 중단을 방지한다. - 다중 독자-단일 저자 (Multiple-Readers Single-Writer) 락 모델: ROS2
MultiThreadedExecutor환경에서 다수의 노드가 센서 데이터를 동시에 읽는 상황(Read)은 빈번하나, 특정 상태를 갱신하는 쓰기(Write) 작업은 상대적으로 적다. 단순 상호 배제(std::mutex)를 사용할 경우 읽기 작업까지 직렬화되어 병목이 발생하므로, C++17의std::shared_mutex를 적용하여 동시성 처리량을 극대화한다.
5.2 범용 ROS2 블랙보드 C++ 소스 코드 명세
다음은 위 요구사항을 충족하는 스레드 안전한(Thread-safe) 싱글톤(Singleton) 패턴 기반의 블랙보드 구현 명세이다.
#ifndef ROS2_UNIVERSAL_BLACKBOARD_HPP
#define ROS2_UNIVERSAL_BLACKBOARD_HPP
#include <iostream>
#include <string>
#include <unordered_map>
#include <any>
#include <shared_mutex>
#include <stdexcept>
#include <rclcpp/rclcpp.hpp>
class Ros2UniversalBlackboard
{
public:
// 싱글톤(Singleton) 인스턴스 반환 메커니즘
// 프로세스 내 전역적인 데이터 저장소 역할을 수행한다.
static Ros2UniversalBlackboard& getInstance()
{
static Ros2UniversalBlackboard instance;
return instance;
}
// [쓰기 연산] 블랙보드 데이터 적재 (독점적 쓰기 락 적용)
// 시간 복잡도: 평균 O(1)
template <typename T>
void set(const std::string& key, const T& value)
{
std::unique_lock<std::shared_mutex> lock(mutex_);
storage_[key] = value;
}
// [읽기 연산] 블랙보드 데이터 추출 및 타입 검증 (공유 읽기 락 적용)
// 시간 복잡도: 평균 O(1)
template <typename T>
bool get(const std::string& key, T& out_value) const
{
std::shared_lock<std::shared_mutex> lock(mutex_);
auto it = storage_.find(key);
if (it == storage_.end()) {
return false; // 해당 키가 블랙보드에 존재하지 않음
}
try {
// RTTI를 활용한 안전한 타입 캐스팅 시도
out_value = std::any_cast<T>(it->second);
return true;
} catch (const std::bad_any_cast& e) {
// 타입 불일치 시 ROS2 로깅 시스템을 통한 경고 및 안전한 연산 종료
RCLCPP_WARN(rclcpp::get_logger("UniversalBlackboard"),
"[Type Mismatch] Key: '%s'. Exception: %s", key.c_str(), e.what());
return false;
}
}
// [평가 연산] 특정 키의 존재 여부 확인
bool hasKey(const std::string& key) const
{
std::shared_lock<std::shared_mutex> lock(mutex_);
return storage_.find(key) != storage_.end();
}
// [삭제 연산] 특정 키-값 쌍 삭제
bool remove(const std::string& key)
{
std::unique_lock<std::shared_mutex> lock(mutex_);
return storage_.erase(key) > 0;
}
// [초기화 연산] 블랙보드 메모리 전체 소거
void clear()
{
std::unique_lock<std::shared_mutex> lock(mutex_);
storage_.clear();
}
private:
// 싱글톤 패턴 강제를 위한 생성자 은닉 및 복사/대입 방지
Ros2UniversalBlackboard() = default;
~Ros2UniversalBlackboard() = default;
Ros2UniversalBlackboard(const Ros2UniversalBlackboard&) = delete;
Ros2UniversalBlackboard& operator=(const Ros2UniversalBlackboard&) = delete;
// 데이터 저장 컨테이너 및 동기화 객체
std::unordered_map<std::string, std::any> storage_;
mutable std::shared_mutex mutex_; // const 멤버 함수(get, hasKey)에서의 락 획득을 위한 mutable 선언
};
#endif // ROS2_UNIVERSAL_BLACKBOARD_HPP
5.3 아키텍처 메커니즘 분석 및 공학적 타당성
5.3.1 메모리 동기화 및 락(Lock) 메커니즘의 수학적 이점
해시 테이블 연산 중 버킷(Bucket) 재해싱(Rehashing)이 발생할 경우, 기존 메모리 주소가 무효화되므로 쓰기 작업 중의 읽기 접근은 치명적인 세그멘테이션 결함(Segmentation Fault)을 유발한다.
본 명세에서는 get() 메서드 실행 시 std::shared_lock을 획득하여 N개의 스레드가 동시에 데이터를 조회하더라도 락 경합(Lock Contention) 오버헤드가 발생하지 않도록 설계하였다. 반면 set(), remove(), clear() 메서드는 std::unique_lock을 통해 쓰기 연산 시 다른 모든 읽기/쓰기 스레드의 접근을 엄격히 차단(Block)하여 데이터 무결성을 보증한다.
5.3.2 ROS2 콜백 그룹(Callback Group)과의 결합 무결성
ROS2의 Reentrant Callback Group을 사용하는 다수의 퍼셉션 노드들이 비동기적으로 이 블랙보드에 접근할 수 있다. 예를 들어, 카메라 콜백 스레드가 set("obstacle_dist", 1.5)를 수행하는 동시에 제어 루프 스레드가 get("obstacle_dist", dist)를 호출하더라도, 커널 수준의 상호 배제가 보장되므로 데이터 오염(Data Corruption) 없이 결정론적(Deterministic)인 상태 공유가 가능하다.
5.3.3 직렬화(Serialization) 오버헤드의 원천 제거
전통적인 ROS2 Topic(DDS) 통신은 동일 프로세스 내부라도 메시지 직렬화 및 역직렬화 과정을 거치거나 포인터 소유권(Ownership) 관리를 요구한다. 제시된 블랙보드 아키텍처는 C++ 메모리 힙(Heap) 영역 내에서 std::any를 통한 직접적인 값 복사(또는 참조 전달)를 수행하므로, 메가바이트(MB) 단위의 점군(Point Cloud) 데이터나 이미지 텐서(Tensor)를 식별자(Key) 하나로 즉각적으로 교환할 수 있는 극단적인 통신 최적화를 달성한다.
6. 분산 시스템에서의 전역 블랙보드 동기화
단일 프로세스를 넘어 다수의 로봇(Multi-Agent)이나 독립된 컴퓨터 노드들이 협력하는 분산 시스템(Distributed System)에서는, 각 노드가 물리적으로 동일한 메모리(RAM)를 공유할 수 없다. 따라서 전역 블랙보드(Global Blackboard)는 필연적으로 네트워크를 통해 각 노드의 로컬 메모리에 상태를 복제(State Replication)하는 분산 데이터베이스(Distributed Database)의 형태를 취해야 한다.
6.1 DDS Transient Local QoS 기반의 상태 복제 메커니즘
ROS2의 기반 통신 미들웨어인 DDS(Data Distribution Service)는 분산 환경에서의 상태 동기화를 위해 다양한 서비스 품질(Quality of Service, QoS) 프로필을 제공한다. 전역 블랙보드의 상태 복제를 수학적, 논리적으로 무결하게 유지하기 위해서는 지연 참여자(Late Joiner) 문제를 해결하는 Transient Local 정책이 핵심적으로 요구된다.
6.1.1 지연 참여자(Late Joiner) 문제와 학술적 해결책
Volatile정책의 한계: ROS2의 기본 데이터 영속성(Durability) 정책인Volatile은 퍼블리셔(Publisher)가 메시지를 발행하는 그 순간에 네트워크에 연결되어 있는 서브스크라이버(Subscriber)에게만 데이터를 전송한다. 만약 로봇 B가 부팅 지연으로 인해 로봇 A가 블랙보드에 전역 상태를 기록한 직후에 네트워크에 참여(Late Join)하게 된다면, 로봇 B는 이전의 블랙보드 상태를 알 수 없어 치명적인 **상태 불일치(State Inconsistency)**에 빠진다.Transient Local정책의 도입: 이 정책을 적용하면, 퍼블리셔를 관장하는 DDS 미들웨어 계층이 이전에 발행된 메시지들을 자체 메모리 버퍼에 보관(History)한다. 새로운 서브스크라이버가 네트워크에 검색(Discovery)되어 연결이 수립되는 즉시, DDS는 퍼블리셔의 개입 없이 버퍼에 저장된 과거의 상태 메시지들을 새로운 서브스크라이버에게 자동으로 재전송한다. 이를 통해 모든 노드가 참여 시점과 무관하게 동일한 전역 블랙보드 초기 상태를 동기화받을 수 있다.
6.1.2 블랙보드 동기화를 위한 최적화된 QoS 프로필 명세
전역 블랙보드의 특성상 데이터의 유실은 허용되지 않으며, 가장 최신의 상태만이 유효하다. 이를 수식화하여 다음과 같은 QoS 파라미터를 강제한다.
| QoS 파라미터 | 설정 값 | 공학적/수학적 타당성 |
|---|---|---|
| Durability | TRANSIENT_LOCAL | 지연 참여자(Late Joiner)에게 블랙보드의 최종 상태를 자동 복제하기 위함. |
| Reliability | RELIABLE | UDP 멀티캐스트 환경에서 발생하는 패킷 드롭(Packet Drop)에 대해 수신 확인(ACK) 및 재전송(Retransmission)을 보장하여 상태 데이터의 무결성을 확보함. |
| History | KEEP_LAST | 큐(Queue)의 오버플로우를 방지하고 메모리 공간을 상수 시간 공간 O(1)으로 유지함. |
| Depth | 1 | 과거의 이력(History)은 제어 논리에서 무의미하며, 오직 t_{current} 시점의 최신 전역 상태(Snapshot)만이 필요하므로 큐 깊이를 1로 최소화함. |
6.1.3 분산 상태 복제의 아키텍처 모델: 스냅샷(Snapshot) 브로드캐스트
DDS에서 특정 식별자(Key)별로 Transient Local을 개별 적용하려면 커스텀 IDL(Interface Definition Language) 파일에 @key 어노테이션을 선언해야 하는 복잡성이 수반된다.
가장 실용적이고 범용적인 소프트웨어 아키텍처는 블랙보드의 전체 키-값 쌍을 JSON 등의 포맷으로 직렬화(Serialization)하여 단일 상태 스냅샷(Snapshot)으로 만들고, 내부 상태가 변경될 때마다 이 스냅샷을 Depth=1의 Transient Local 토픽으로 발행하는 것이다.
6.2 C++ 기반 분산 전역 블랙보드(Distributed Blackboard) 구현 명세
아래는 rclcpp를 활용하여 Transient Local QoS를 적용하고, 내부 해시맵(Hash Map) 상태를 네트워크 상의 다른 로봇들과 동기화하는 분산 블랙보드 노드의 C++ 설계 명세이다.
#include <iostream>
#include <string>
#include <unordered_map>
#include <shared_mutex>
#include <rclcpp/rclcpp.hpp>
#include <std_msgs/msg/string.hpp>
#include <nlohmann/json.hpp> // 상태 직렬화를 위한 JSON 라이브러리
class DistributedBlackboard : public rclcpp::Node
{
public:
DistributedBlackboard(const std::string& node_name)
: Node(node_name),
node_uuid_(std::to_string(std::chrono::system_clock::now().time_since_epoch().count()))
{
// 1. 블랙보드 동기화를 위한 엄격한 QoS 프로필 선언
rclcpp::QoS qos_profile(1); // History: KEEP_LAST, Depth: 1
qos_profile.transient_local(); // 핵심: 지연 참여자를 위한 데이터 보존
qos_profile.reliable(); // 핵심: 패킷 유실 방지
// 2. 전역 상태 퍼블리셔 및 서브스크라이버 생성
state_pub_ = this->create_publisher<std_msgs::msg::String>("/global_blackboard_state", qos_profile);
state_sub_ = this->create_subscription<std_msgs::msg::String>(
"/global_blackboard_state", qos_profile,
std::bind(&DistributedBlackboard::onStateReceived, this, std::placeholders::_1)
);
RCLCPP_INFO(this->get_logger(), "Distributed Blackboard Node [%s] Initialized with Transient Local QoS.", node_name.c_str());
}
// [쓰기 연산] 로컬 메모리를 갱신하고 분산 네트워크에 스냅샷을 전파함
template <typename T>
void setGlobal(const std::string& key, const T& value)
{
std::unique_lock<std::shared_mutex> lock(mutex_);
// 로컬 맵 갱신
storage_[key] = value;
// 전체 상태를 JSON으로 직렬화 (Serialization)
nlohmann::json state_json;
state_json["origin_uuid"] = node_uuid_; // 무한 루프(Echo) 방지용 식별자
state_json["timestamp"] = this->now().nanoseconds();
state_json["data"] = storage_; // nlohmann::json이 지원하는 기본 타입의 경우 자동 매핑됨
// Transient Local 토픽 발행
std_msgs::msg::String msg;
msg.data = state_json.dump();
state_pub_->publish(msg);
}
// [읽기 연산] 네트워크 통신 없이 로컬 메모리에서 즉각 추출 (O(1) 시간 복잡도)
template <typename T>
bool getGlobal(const std::string& key, T& out_value) const
{
std::shared_lock<std::shared_mutex> lock(mutex_);
auto it = storage_.find(key);
if (it == storage_.end()) {
return false;
}
// JSON 파싱된 데이터를 역직렬화하여 반환
out_value = it->second.get<T>();
return true;
}
private:
// 네트워크로부터 다른 노드가 발행한(또는 늦게 접속하여 받은) 전역 상태를 수신하는 콜백
void onStateReceived(const std_msgs::msg::String::SharedPtr msg)
{
try {
nlohmann::json received_json = nlohmann::json::parse(msg->data);
// 자신이 발행한 메시지가 루프백(Loopback)된 경우 무시하여 처리량 최적화
if (received_json["origin_uuid"] == node_uuid_) {
return;
}
// 쓰기 락 획득 후 수신된 스냅샷으로 로컬 상태 동기화 (State Replication)
std::unique_lock<std::shared_mutex> lock(mutex_);
auto data_map = received_json["data"];
for (auto& el : data_map.items()) {
storage_[el.key()] = el.value();
}
RCLCPP_DEBUG(this->get_logger(), "Global state synchronized from network.");
} catch (const nlohmann::json::exception& e) {
RCLCPP_ERROR(this->get_logger(), "State replication failed: JSON parse error.");
}
}
std::string node_uuid_; // 현재 프로세스의 고유 식별자
mutable std::shared_mutex mutex_; // 동시성 제어 락
std::unordered_map<std::string, nlohmann::json> storage_; // 로컬 캐시 메모리
rclcpp::Publisher<std_msgs::msg::String>::SharedPtr state_pub_;
rclcpp::Subscription<std_msgs::msg::String>::SharedPtr state_sub_;
};
6.3 아키텍처의 한계 및 동시성 충돌(Conflict) 해결 방안
위 명세는 Transient Local을 활용해 완벽한 초기 동기화(Initial Synchronization)를 보장하지만, 분산 시스템의 CAP 정리(CAP Theorem)에 따라 네트워크 분할(Partition) 시 일관성(Consistency) 유지에 공학적 한계가 존재한다.
두 대의 로봇이 거의 동일한 시점(\Delta t \to 0)에 같은 키(Key)에 대해 서로 다른 값으로 setGlobal()을 호출할 경우, 네트워크 지연에 의해 **쓰기 충돌(Write Conflict)**이 발생한다. 이를 학술적으로 제어하기 위해서는 다음과 같은 결함 허용(Fault Tolerance) 알고리즘이 추가적으로 통합되어야 한다.
- 최종 작성자 승리 (Last-Writer-Wins, LWW): 상태 스냅샷에 포함된
timestamp를 비교하여, 로컬에 저장된 타임스탬프보다 수신된 타임스탬프가 더 최신인 경우에만 덮어쓰기(Overwrite)를 허용한다. NTP(Network Time Protocol) 또는 PTP(Precision Time Protocol)를 통한 로봇 간 물리적 시간 동기화가 선행되어야 한다. - 논리적 시계 (Logical Clocks): 물리적 시간 동기화가 불가능한 환경에서는 램포트 타임스탬프(Lamport Timestamp)나 벡터 시계(Vector Clock) 알고리즘을 페이로드에 포함시켜 이벤트의 선후 인과관계(Causality)를 수학적으로 증명하고 병합(Merge) 충돌을 회피한다.
6.4 외부 인메모리 데이터베이스(Redis 등)와 ROS2 브리지(Bridge) 통합 설계
다중 로봇 시스템(Multi-Agent System, MAS)의 규모가 수십 대 이상으로 확장되거나, 클라우드 관제 서버와의 광역 네트워크(WAN) 통신이 요구될 경우, 순수 DDS(Data Distribution Service) 미들웨어에만 의존하는 전역 블랙보드(Global Blackboard) 동기화는 대역폭 포화(Bandwidth Saturation) 및 검색(Discovery) 프로토콜의 과부하를 초래한다.
이러한 분산 시스템의 확장성(Scalability) 한계를 수학적, 구조적으로 해결하기 위해, 중앙 집중형 인메모리 데이터베이스(In-Memory Database, 주로 Redis)를 전역 상태 저장소로 활용하고 이를 로컬 ROS2 네트워크와 연결하는 ROS2-Redis 브리지(Bridge) 아키텍처가 채택된다.
6.4.1 학술적 배경 및 아키텍처 토폴로지 (Topology)
Redis는 단일 스레드 이벤트 루프(Single-threaded Event Loop) 기반의 초고속 키-값 저장소(Key-Value Store)로, RAM 상에서 O(1)의 시간 복잡도로 데이터를 읽고 쓰며 자체적인 Pub/Sub 메커니즘을 제공한다.
통합 아키텍처는 다음과 같은 3계층(3-Tier) 토폴로지로 구성된다.
- 로컬 제어 계층 (Local Control Layer): 각 로봇 내부의 행동 트리(Behavior Tree)와 제어 노드들이 5장에서 명세한 인트라 프로세스 로컬 블랙보드를 통해 초고속으로 상태를 공유한다.
- 브리지 계층 (Bridge Layer): 각 로봇의 운영체제 상에서 독립적인 ROS2 노드로 구동되는
Redis Bridge모듈이다. 로컬 블랙보드의 변경 사항을 감지하여 Redis 서버로 송신하고, 역으로 Redis 서버의 전역 상태 변경 알림을 수신하여 로컬 블랙보드를 갱신한다. - 전역 데이터 계층 (Global Data Layer): 클라우드 또는 엣지(Edge) 서버에 호스팅되는 Redis 클러스터로, 전체 로봇 함대(Fleet)의 상태(State)를 영속화(Persistence)하고 동기화하는 단일 진실 공급원(Single Source of Truth, SSOT) 역할을 수행한다.
6.4.2 데이터 직렬화(Serialization) 및 스키마 매핑(Schema Mapping)
ROS2의 메시지 객체(C++ 구조체)는 메모리 상에 패딩(Padding)이 포함된 바이너리 형태이므로 외부 데이터베이스에 직접 저장할 수 없다. 따라서 의미론적 무결성(Semantic Integrity)을 유지하기 위한 직렬화 파이프라인이 필수적이다.
- 데이터 포맷: JSON(JavaScript Object Notation) 또는 MessagePack(바이너리 JSON)을 사용하여 타입 소실(Type Erasure)된 데이터를 바이트 스트림(Byte Stream)으로 변환한다.
- Redis 자료구조 매핑:
- 단순 상태 변수(예: 배터리 잔량): Redis
String(SET key value) - 복합 상태 변수(예: 로봇의 전체 텔레메트리): Redis
Hash(HSET robot:1:status battery 80 mode "AUTO") - 전역 이벤트 큐: Redis
List(LPUSH global_events "EMERGENCY_STOP")
6.4.3 동기화 지연 시간(Latency)의 수학적 모델링
브리지 아키텍처를 도입할 경우, 로봇 A의 로컬 상태가 클라우드의 Redis를 거쳐 로봇 B의 로컬 상태로 동기화되는 총 종단 간 지연 시간(End-to-End Latency) T_{total}은 다음과 같이 모델링된다.
T_{total} = T_{DDS} + T_{serialize} + T_{network\_tx} + T_{redis\_op} + T_{network\_rx} + T_{deserialize}
- T_{DDS}: 로컬 ROS2 통신 지연
- T_{serialize}, T_{deserialize}: JSON 직렬화/역직렬화 오버헤드
- T_{network}: 로봇과 Redis 서버 간의 TCP/IP 네트워크 전송 시간
- T_{redis\_op}: Redis 서버의 내부 연산 시간 (보통 1ms 미만)
지연 시간에 가장 큰 영향을 미치는 것은 T_{network}이다. 이를 통제하기 위해 브리지는 모든 변경 사항을 즉각 전송하지 않고, 특정 주파수(예: 10Hz)로 변경된 키를 모아 Redis 파이프라이닝(Pipelining)을 통해 일괄 전송(Batching)하여 TCP 오버헤드를 최소화해야 한다.
6.4.4 ROS2-Redis 브리지 C++ 설계 및 구현 명세
아래는 C++ Redis 클라이언트 라이브러리인 redis-plus-plus(sw/redis++)와 nlohmann/json을 활용하여, 로봇의 로컬 상태를 전역 Redis 서버와 양방향 동기화하는 ROS2 브리지 노드의 아키텍처 명세이다.
#include <iostream>
#include <string>
#include <chrono>
#include <thread>
#include <rclcpp/rclcpp.hpp>
#include <sw/redis++/redis++.hpp>
#include <nlohmann/json.hpp>
// 5장에서 구현한 로컬 블랙보드 싱글톤 헤더 포함
#include "ros2_universal_blackboard.hpp"
class RedisBridgeNode : public rclcpp::Node
{
public:
RedisBridgeNode(const std::string& node_name, const std::string& redis_uri, const std::string& robot_id)
: Node(node_name), robot_id_(robot_id)
{
try {
// Redis 서버 커넥션 풀(Connection Pool) 초기화
redis_ = std::make_unique<sw::redis::Redis>(redis_uri);
// Redis Pub/Sub 커넥션 분리 (수신 대기용)
subscriber_ = std::make_unique<sw::redis::Subscriber>(redis_->subscriber());
RCLCPP_INFO(this->get_logger(), "Connected to Redis Server: %s", redis_uri.c_str());
} catch (const sw::redis::Error& e) {
RCLCPP_FATAL(this->get_logger(), "Redis connection failed: %s", e.what());
throw;
}
// 1. [Publish] 로컬 블랙보드 -> Redis 전역 상태 갱신 타이머 (예: 10Hz)
sync_timer_ = this->create_wall_timer(
std::chrono::milliseconds(100),
std::bind(&RedisBridgeNode::syncToRedis, this)
);
// 2. [Subscribe] Redis 전역 상태 -> 로컬 블랙보드 갱신 스레드
// ROS2의 메인 스레드를 블로킹하지 않기 위해 독립된 스레드에서 백그라운드 구동
redis_listener_thread_ = std::thread(&RedisBridgeNode::listenFromRedis, this);
}
~RedisBridgeNode()
{
if (redis_listener_thread_.joinable()) {
subscriber_->unsubscribe("global_blackboard_updates");
redis_listener_thread_.join();
}
}
private:
// 로컬 데이터를 직렬화하여 Redis로 송신
void syncToRedis()
{
auto& blackboard = Ros2UniversalBlackboard::getInstance();
nlohmann::json local_state;
// 예시: 로컬 블랙보드에서 특정 키(robot_pose)를 추출하여 직렬화
// 실제 구현 시에는 블랙보드의 변경된(Dirty) 플래그가 있는 데이터만 추출하여 최적화
std::string pose_data;
if (blackboard.get("robot_pose", pose_data)) {
local_state["robot_pose"] = pose_data;
}
if (local_state.empty()) return;
local_state["origin"] = robot_id_;
local_state["timestamp"] = this->now().nanoseconds();
try {
// Redis HSET을 통한 상태 저장 (영속성)
std::string redis_key = "robot_state:" + robot_id_;
redis_->hset(redis_key, "data", local_state.dump());
// Redis PUBLISH를 통한 타 로봇으로의 즉각적인 이벤트 브로드캐스트
redis_->publish("global_blackboard_updates", local_state.dump());
} catch (const sw::redis::Error& e) {
RCLCPP_WARN(this->get_logger(), "Redis Write Failed: %s", e.what());
}
}
// Redis로부터 변경 사항을 수신하여 로컬 블랙보드에 적용
void listenFromRedis()
{
// 전역 업데이트 채널 구독
subscriber_->subscribe("global_blackboard_updates");
// 콜백 등록
subscriber_->on_message([this](std::string%20channel,%20std::string%20msg) {
try {
nlohmann::json received_state = nlohmann::json::parse(msg);
// 자신이 발행한 메시지는 무시 (Echo 방지)
if (received_state["origin"] == robot_id_) return;
auto& blackboard = Ros2UniversalBlackboard::getInstance();
// 수신된 JSON 데이터를 파싱하여 로컬 블랙보드에 쓰기(set) 연산 수행
if (received_state.contains("robot_pose")) {
std::string foreign_pose = received_state["robot_pose"];
std::string blackboard_key = "foreign_pose:" + received_state["origin"].get<std::string>();
blackboard.set(blackboard_key, foreign_pose);
RCLCPP_DEBUG(this->get_logger(), "Synchronized foreign state to local blackboard.");
}
} catch (const nlohmann::json::exception& e) {
RCLCPP_ERROR(this->get_logger(), "JSON Parse Error on Redis Subscribe: %s", e.what());
}
});
// 블로킹 루프 (타임아웃 적용하여 종료 시그널 대응)
while (rclcpp::ok()) {
try {
subscriber_->consume();
} catch (const sw::redis::TimeoutError& e) {
continue; // 정상적인 타임아웃, 루프 계속
} catch (const sw::redis::Error& e) {
RCLCPP_ERROR(this->get_logger(), "Redis Subscriber Error: %s", e.what());
std::this_thread::sleep_for(std::chrono::seconds(1)); // 재연결 대기
}
}
}
std::string robot_id_;
std::unique_ptr<sw::redis::Redis> redis_;
std::unique_ptr<sw::redis::Subscriber> subscriber_;
rclcpp::TimerBase::SharedPtr sync_timer_;
std::thread redis_listener_thread_;
};
6.4.5 아키텍처의 공학적 타당성 및 결함 허용성(Fault Tolerance)
위 명세는 ROS2의 콜백 스레드와 네트워크 통신(Redis I/O) 스레드를 명확히 분리하여, 네트워크 지연이 로봇의 실시간 제어 루프를 차단(Blocking)하는 것을 원천 방지한다.
또한, hset을 통한 데이터 저장과 publish를 통한 실시간 알림을 결합하였다. 이를 통해 로봇 B가 네트워크 단절로 인해 publish 메시지를 일시적으로 수신하지 못하더라도, 네트워크가 복구된 후 Redis의 hget 명령을 통해 데이터베이스에 안전하게 영속화(Persisted)된 로봇 A의 최종 상태 스냅샷을 다시 동기화받을 수 있는 구조적 결함 허용성을 제공한다.
6.5 분산 환경에서의 네트워크 단절(Network Partition) 대응 및 분산 락(Distributed Lock) 제어
다중 로봇 시스템(Multi-Agent System)이나 엣지-클라우드(Edge-Cloud)가 연동된 분산 환경에서 전역 블랙보드(Global Blackboard)를 운용할 때, 네트워크는 결코 신뢰할 수 있는(Reliable) 매체가 아니다. 무선 통신 음영 지역 진입, 라우터 장애, 대역폭 포화 등으로 인한 **네트워크 단절(Network Partition)**은 필연적으로 발생한다.
이러한 분산 시스템의 근본적인 한계(CAP 정리) 속에서, 공유 자원(예: 좁은 복도 진입 권한, 특정 객체의 조작 권한)에 대한 상호 배제(Mutual Exclusion)를 보장하고 시스템의 물리적 충돌을 방지하기 위한 **분산 락(Distributed Lock)**과 스플릿 브레인(Split-Brain) 대응 아키텍처를 명세한다.
6.5.1 분산 락(Distributed Lock)의 수학적/공학적 필요성
단일 프로세스 내부의 로컬 블랙보드에서는 운영체제(OS)가 제공하는 커널 수준의 락(std::shared_mutex)을 통해 100%의 확정성(Determinism)으로 상호 배제를 달성할 수 있다. 그러나 물리적으로 분리된 분산 환경에서는 스레드 간의 메모리 공유가 불가능하므로, 전역 상태를 갱신(Write)하기 위한 네트워크 기반의 통제 메커니즘이 필요하다.
단순한 상태 복제(State Replication) 상황에서 두 로봇이 동시에 동일한 키(Key)의 값을 덮어쓰려 하는 **경쟁 상태(Race Condition)**를 수학적으로 방지하기 위해, 임대(Lease) 기반의 분산 락 모델을 도입한다.
6.5.2 임대(Lease) 기반 분산 락 모델과 시간 제약 조건
분산 락 시스템에서 가장 치명적인 결함은 락을 획득한 노드가 네트워크에서 단절되거나 크래시(Crash)되어 락을 영원히 반환하지 않는 **교착 상태(Deadlock)**이다. 이를 방지하기 위해 락의 소유권에 유효 시간(Time-To-Live, TTL)을 부여하는 임대(Lease) 아키텍처를 적용한다.
임대 기반 모델은 다음의 수학적 시간 제약을 충족해야 한다.
T_{lease} > T_{exec} + \Delta_{network} + \Delta_{clock\_drift}
- T_{lease}: 분산 락 서버(또는 합의 알고리즘)가 승인한 락의 유효 시간
- T_{exec}: 클라이언트 로봇이 임계 구역(Critical Section, 예: 공유 자원 조작)에서 연산을 수행하는 최악 실행 시간(WCET)
- \Delta_{network}: 네트워크 패킷의 최대 왕복 지연 시간(Round Trip Time)
- \Delta_{clock\_drift}: 로봇 간의 물리적 시스템 클럭 오차(Clock Skew)
만약 네트워크 단절로 인해 로봇이 T_{lease} 내에 락 연장(Heartbeat) 요청을 보내지 못하면, 락 관리자는 해당 락을 강제로 회수(Revoke)하여 다른 로봇이 자원을 사용할 수 있도록 가용성(Availability)을 확보한다.
6.5.3 네트워크 단절 및 스플릿 브레인(Split-Brain) 대응 전략
네트워크가 두 개 이상의 서브넷으로 분할(Partition)되었을 때, 분리된 그룹들이 서로 자신이 정상적인 네트워크라고 판단하여 전역 블랙보드를 독립적으로 갱신하는 현상을 **스플릿 브레인(Split-Brain)**이라고 한다.
이를 해결하기 위해 쿼럼(Quorum, 정족수) 기반의 합의와 결함 허용(Fault Tolerance) 로직을 아키텍처에 통합한다.
- DDS Liveliness QoS 기반의 단절 감지:
분산 블랙보드 노드들은 DDS의 LIVELINESS QoS(특히 MANUAL_BY_TOPIC)를 활용하여 주기적으로 자신의 생존을 증명한다. 만약 허용된 기간(Lease Duration) 내에 특정 로봇의 상태 갱신이 수신되지 않으면, 미들웨어 계층에서 즉각적으로 LivelinessChanged 이벤트를 발생시켜 애플리케이션 계층에 네트워크 단절을 보고한다.
- 쿼럼(Quorum) 기반의 쓰기 권한 통제:
총 N대의 로봇으로 구성된 군집에서, 전역 블랙보드의 특정 상태를 갱신하기 위해서는 최소 \lfloor N/2 \rfloor + 1 대의 로봇과 통신이 연결되어 있어야만(Majority) 분산 락 획득을 허용한다. 소수파(Minority)로 전락한 네트워크 파티션 내의 로봇들은 락 획득이 거부되므로 스플릿 브레인을 구조적으로 차단할 수 있다.
- 지역 자율성(Local Autonomy)으로의 폴백(Fallback):
단절이 감지된 로봇은 전역 블랙보드의 데이터를 ‘신뢰할 수 없음(Stale)’ 상태로 마킹하고, 행동 트리(Behavior Tree) 상에서 전역 임무 수행을 즉각 중단(Halt)한다. 이후 로컬 센서(LiDAR, Vision)와 로컬 블랙보드만을 활용하는 **예비 생존 모드(Fallback Survival Mode)**로 전환하여 충돌을 방지한다.
6.5.4 C++ 분산 락 클라이언트 및 네트워크 워치독(Watchdog) 구현 명세
아래는 ROS2 액션(Action) 또는 서비스(Service)를 활용하여 중앙 집중형(또는 리더 노드) 락 매니저로부터 임대(Lease) 기반의 분산 락을 획득하고, 네트워크 상태를 감시하는 클라이언트 아키텍처의 C++ 설계 명세이다.
#include <iostream>
#include <string>
#include <chrono>
#include <mutex>
#include <rclcpp/rclcpp.hpp>
// 가상의 분산 락 서비스 타입 (인터페이스 명세 용도)
// Request: string lock_key, double ttl_seconds
// Response: bool success, string lock_token
#include "custom_interfaces/srv/acquire_distributed_lock.hpp"
#include "custom_interfaces/srv/release_distributed_lock.hpp"
class DistributedLockClient : public rclcpp::Node
{
public:
DistributedLockClient(const std::string& node_name)
: Node(node_name), has_active_lock_(false)
{
acquire_client_ = this->create_client<custom_interfaces::srv::AcquireDistributedLock>("/dlm/acquire");
release_client_ = this->create_client<custom_interfaces::srv::ReleaseDistributedLock>("/dlm/release");
// 네트워크 단절 감시를 위한 내부 워치독(Watchdog) 타이머
watchdog_timer_ = this->create_wall_timer(
std::chrono::milliseconds(100),
std::bind(&DistributedLockClient::watchdogCheck, this)
);
}
// [락 획득 연산] TTL(Time-To-Live)을 동반한 분산 락 요청
bool acquireLock(const std::string& resource_key, double ttl_seconds)
{
if (!acquire_client_->wait_for_service(std::chrono::seconds(1))) {
RCLCPP_ERROR(this->get_logger(), "Network Partition Detected: Lock Server unreachable.");
return false;
}
auto request = std::make_shared<custom_interfaces::srv::AcquireDistributedLock::Request>();
request->lock_key = resource_key;
request->ttl_seconds = ttl_seconds;
auto future_result = acquire_client_->async_send_request(request);
// 동기적 대기 (실제 구현에서는 코루틴이나 비동기 콜백 사용 권장)
if (rclcpp::spin_until_future_complete(this->get_node_base_interface(), future_result) ==
rclcpp::FutureReturnCode::SUCCESS)
{
auto response = future_result.get();
if (response->success) {
std::lock_guard<std::mutex> lock(internal_mutex_);
has_active_lock_ = true;
current_lock_key_ = resource_key;
lock_token_ = response->lock_token;
// 로컬 시스템 클럭 기반 락 만료 시간 기록
lock_expiration_time_ = this->now() + rclcpp::Duration::from_seconds(ttl_seconds);
RCLCPP_INFO(this->get_logger(), "Distributed Lock acquired for key: %s", resource_key.c_str());
return true;
}
}
return false;
}
// [락 해제 연산]
void releaseLock()
{
std::lock_guard<std::mutex> lock(internal_mutex_);
if (!has_active_lock_) return;
auto request = std::make_shared<custom_interfaces::srv::ReleaseDistributedLock::Request>();
request->lock_key = current_lock_key_;
request->lock_token = lock_token_;
// 해제 요청은 비동기 발송 후 로컬 상태를 즉시 클리어함 (가용성 우선)
release_client_->async_send_request(request);
has_active_lock_ = false;
current_lock_key_.clear();
lock_token_.clear();
RCLCPP_INFO(this->get_logger(), "Distributed Lock released locally.");
}
// 현재 락이 물리적으로, 그리고 네트워크 논리적으로 유효한지 확인
bool isLockValid() const
{
std::lock_guard<std::mutex> lock(internal_mutex_);
if (!has_active_lock_) return false;
// 로컬 클럭 기준 TTL 초과 여부 확인
if (this->now() > lock_expiration_time_) {
return false;
}
return true;
}
private:
// 고주파수 워치독 타이머를 통한 락의 유효성 백그라운드 검사
void watchdogCheck()
{
std::lock_guard<std::mutex> lock(internal_mutex_);
if (has_active_lock_ && this->now() > lock_expiration_time_) {
RCLCPP_FATAL(this->get_logger(),
"[CRITICAL] Lock TTL expired for key: %s due to Network Partition or delay. Aborting critical section!",
current_lock_key_.c_str());
// 락 상태 무효화
has_active_lock_ = false;
// 시스템 레벨의 Fallback 또는 비상 정지(Emergency Stop) 트리거
triggerFailsafeMode();
}
}
void triggerFailsafeMode()
{
// 전역 블랙보드 상태를 신뢰할 수 없는 것으로 마킹하고 로컬 자율성 모드로 전환하는 로직
// 예: 로봇의 모터 정지, 현재 진행 중인 행동 트리(Behavior Tree) 노드 Halt 처리
}
rclcpp::Client<custom_interfaces::srv::AcquireDistributedLock>::SharedPtr acquire_client_;
rclcpp::Client<custom_interfaces::srv::ReleaseDistributedLock>::SharedPtr release_client_;
rclcpp::TimerBase::SharedPtr watchdog_timer_;
mutable std::mutex internal_mutex_;
bool has_active_lock_;
std::string current_lock_key_;
std::string lock_token_;
rclcpp::Time lock_expiration_time_;
};
6.5.5 아키텍처의 공학적 타당성
위 명세에 기술된 임대(Lease) 기반 분산 락과 워치독(Watchdog)의 결합은, 로봇이 네트워크 분할(Network Partition)로 인해 락 관리 서버와의 통신이 두절되더라도 시스템을 안전하게 통제할 수 있도록 보장한다.
네트워크가 단절되면 로봇은 락 갱신(Renew)에 실패하게 되고, 워치독 타이머는 로컬 클럭을 참조하여 T_{lease} 만료 시점을 즉각적으로 감지한다. 이 시점에 로봇은 외부 서버의 명시적인 해제(Release) 응답이 없더라도 즉시 스스로의 락을 폐기(Revoke)하고 페일세이프(Failsafe) 모드로 진입한다. 이는 무선 네트워크의 본질적인 불안정성 속에서도 물리적 로봇 제어의 안전 필수(Safety-Critical) 요건을 수학적으로 만족시키는 핵심 설계 패턴이다.
7. 하드 실시간(Hard Real-time) 및 Micro-ROS 환경의 경량화 설계
Micro-ROS가 탑재되는 마이크로컨트롤러(MCU)나 실시간 운영체제(RTOS) 환경에서는 메모리(RAM/ROM)가 수십~수백 킬로바이트(KB) 수준으로 극도로 제한되어 있으며, 제어 주기의 지연(Latency)이 치명적인 물리적 실패로 직결될 수 있습니다. 이러한 하드 실시간(Hard Real-time) 시스템에서 범용 운영체제(Linux 등) 수준의 동적 메모리 할당 기반 블랙보드를 사용하는 것은 구조적으로 불가능합니다.
7.1 std::any 및 std::unordered_map 동적 할당 배제 (Heap-less Architecture)
표준 C++ 라이브러리(STL)의 컨테이너들은 런타임 유연성을 제공하기 위해 내부적으로 운영체제의 힙(Heap) 영역에 메모리를 동적으로 할당(new, malloc)하고 해제(delete, free)합니다. 그러나 하드 실시간(Hard Real-time) 로봇 제어 시스템에서는 이러한 동적 할당이 다음과 같은 두 가지 치명적인 결함을 유발합니다.
- 메모리 단편화 (Memory Fragmentation): 장기간 구동 시 가용 메모리가 조각나서, 전체 여유 공간은 충분함에도 불구하고 연속된 메모리 블록을 할당받지 못해 시스템 패닉(Out-Of-Memory, OOM)이 발생합니다.
- 시간적 비결정성 (Time Non-determinism): 메모리 할당자(Allocator)가 가용 블록을 탐색하는 시간은 O(1)을 보장하지 않으며, 커널 락(Kernel Lock) 경합으로 인해 제어 주기에 수 밀리초(ms) 이상의 지터(Jitter)를 발생시킵니다.
따라서 RTOS 환경의 블랙보드는 std::unordered_map과 std::any를 배제하고, 모든 메모리를 BSS(Block Started by Symbol) 영역이나 스택(Stack)에 정적으로 사전 할당(Pre-allocation)하는 Heap-less 아키텍처로 재설계되어야 합니다.
7.1.1 std::unordered_map의 한계와 정적 배열(Static Array) 기반 탐색
std::unordered_map은 해시 충돌(Hash Collision)을 해결하기 위해 내부적으로 연결 리스트(Linked List)의 노드를 동적으로 생성합니다. 또한, 키(Key)로 사용되는 std::string 역시 문자열 길이가 길어지면 힙 메모리를 할당합니다.
설계 대안:
- 컴파일 타임 해싱 (Compile-time Hashing): 문자열 키를 런타임에 처리하지 않고, C++의
constexpr를 활용하여 컴파일 타임에 32비트 정수(Hash ID)로 변환합니다. 이를 통해 문자열 메모리 할당을 원천 차단합니다. - 고정 크기 배열 (Fixed-size Array):
std::array를 사용하여 블랙보드의 최대 수용량(Capacity)을 컴파일 타임에 고정합니다. 데이터 탐색은 해시 테이블 대신 배열의 선형 탐색(Linear Search) O(N)이나 이진 탐색(Binary Search) O(\log N)을 사용합니다. MCU 환경에서는 보통 저장되는 키의 개수가 수십 개 미만이므로, 캐시 적중률(Cache Hit Ratio)이 높은 연속된 배열 탐색이 동적 해시맵보다 물리적인 실행 속도가 더 빠릅니다.
7.1.2 std::any의 동적 할당 한계와 std::variant 기반 고정 메모리
std::any는 작은 크기의 객체에 대해서는 SSO(Small Space Optimization)를 통해 동적 할당을 피하지만, 객체의 크기가 내부 버퍼(보통 16~32 바이트)를 초과하면 힙 메모리를 강제로 할당합니다. 로봇의 커스텀 메시지가 이를 초과할 경우 시스템 지연이 발생합니다.
설계 대안:
std::variant의 도입: 저장될 가능성이 있는 모든 데이터 타입(예:int,double,bool, 특정 ROS2 커스텀 메시지)을 컴파일 타임에 명시하는std::variant를 사용합니다.std::variant는 내부에 선언된 타입 중 가장 크기가 큰 타입(Maximum Size)과 메모리 정렬(Alignment) 요구사항에 맞추어 스택 또는 정적 데이터 영역에 고정된 크기의 메모리 블록만을 할당합니다. 따라서 런타임에 어떤 타입이 들어오더라도 동적 할당이 절대 발생하지 않습니다.
7.1.3 Heap-less Blackboard C++ 구현 명세
아래는 동적 메모리 할당(Heap Allocation)을 1바이트도 수행하지 않으며, new 키워드가 내부적으로도 호출되지 않는 RTOS 타겟의 정적 블랙보드 구현 명세입니다.
#include <array>
#include <variant>
#include <string_view>
#include <cstdint>
// 1. 컴파일 타임 문자열 해싱 함수 (FNV-1a 알고리즘)
// 문자열 키를 힙 할당 없이 런타임/컴파일타임에 32비트 정수로 변환합니다.
constexpr uint32_t hashString(std::string_view str) {
uint32_t hash = 0x811C9DC5;
for (char c : str) {
hash ^= static_cast<uint32_t>(c);
hash *= 0x01000193;
}
return hash;
}
// 2. 허용 가능한 데이터 타입들을 명시적으로 제한 (Type Bounding)
// 로봇 제어에 필요한 자료형을 사전에 정의하여 최대 메모리 크기를 컴파일 타임에 고정합니다.
struct Vector3 { double x, y, z; };
using BlackboardValue = std::variant<int, double, bool, Vector3>;
// 3. 힙 할당이 배제된 정적 블랙보드 클래스
template <size_t MAX_CAPACITY>
class HeaplessBlackboard {
private:
struct Entry {
uint32_t key_hash;
BlackboardValue value;
bool is_active = false;
};
// 힙이 아닌 BSS 영역이나 스택에 생성되는 고정 크기 메모리 블록
std::array<Entry, MAX_CAPACITY> storage_{};
size_t current_size_ = 0;
public:
HeaplessBlackboard() = default;
// [쓰기 연산] O(N) 시간 복잡도. 할당자(Allocator) 호출 없음.
void set(std::string_view key, const BlackboardValue& value) {
uint32_t hash = hashString(key);
// 이미 존재하는 키인지 확인
for (size_t i = 0; i < current_size_; ++i) {
if (storage_[i].is_active && storage_[i].key_hash == hash) {
storage_[i].value = value;
return;
}
}
// 새로운 키 추가 (Capacity 초과 검사 필수)
if (current_size_ < MAX_CAPACITY) {
storage_[current_size_].key_hash = hash;
storage_[current_size_].value = value;
storage_[current_size_].is_active = true;
current_size_++;
} else {
// RTOS 환경에서는 예외(Exception) 대신 에러 코드 반환 또는 시스템 Halt를 수행함
// throw std::bad_alloc(); (사용 금지)
}
}
// [읽기 연산] O(N) 시간 복잡도. 포인터 기반으로 복사 오버헤드 없이 읽기 수행.
template <typename T>
bool get(std::string_view key, T& out_value) const {
uint32_t hash = hashString(key);
for (size_t i = 0; i < current_size_; ++i) {
if (storage_[i].is_active && storage_[i].key_hash == hash) {
// std::variant의 타입 검사 및 추출
if (std::holds_alternative<T>(storage_[i].value)) {
out_value = std::get<T>(storage_[i].value);
return true;
}
return false; // 타입 불일치 (Type Mismatch)
}
}
return false; // 키를 찾을 수 없음
}
};
// --- 사용 예시 (컴파일 시점에 메모리 레이아웃 확정) ---
// 최대 64개의 항목을 담을 수 있는 전역 블랙보드를 정적(Static) 메모리에 인스턴스화
static HeaplessBlackboard<64> g_rtos_blackboard;
7.1.4 아키텍처의 학술적 및 공학적 의의
이 Heap-less 아키텍처는 Micro-ROS 에이전트가 탑재된 STM32 또는 ESP32와 같은 마이크로컨트롤러 환경에서 **절대적인 메모리 안전성(Memory Safety)과 시간 결정론(Time Determinism)**을 보장합니다.
- 메모리 파편화 0%: 데이터의 삽입 및 수정 과정에서
storage_배열 내의 바이트(Byte)만 갱신될 뿐, 메모리 단편화가 수학적으로 발생할 수 없습니다. 시스템이 1년을 가동하더라도 메모리 상태는 부팅 직후와 동일한 무결성을 유지합니다. - 캐시 친화성 (Cache Friendliness): 연결 리스트를 사용하는 해시맵 구조는 메모리가 파편화되어 있어 캐시 미스(Cache Miss) 확률이 높습니다. 반면,
std::array를 활용한 선형 배열 구조는 CPU 캐시 라인(Cache Line)에 연속적으로 적재되므로, 요소의 개수가 적을 경우 해시맵의 O(1) 탐색보다 배열의 O(N) 탐색이 실제 하드웨어 클럭(Clock) 기준으로는 더 빠른 실행 속도를 나타냅니다.
7.2 정적 메모리 풀(Static Memory Pool) 기반의 데이터 접근 시간 결정론(Determinism) 확보
블랙보드 아키텍처가 하드 실시간성을 만족하기 위해서는 **데이터의 읽기/쓰기 연산에 소요되는 최악 실행 시간(Worst-Case Execution Time, WCET)이 수학적으로 증명 가능한 상수 시간 O(1)으로 고정(Deterministic)**되어야 합니다. 이를 달성하기 위한 핵심 기법이 ’정적 메모리 풀(Static Memory Pool)’의 도입입니다.
7.2.1 동적 메모리 할당(Dynamic Allocation)의 치명적 한계
표준 C++의 new, malloc 또는 내부적으로 힙(Heap) 영역을 사용하는 컨테이너(std::string, std::unordered_map, std::any)를 런타임에 사용할 경우 다음과 같은 공학적 결함이 발생합니다.
- 시간 비결정성 (Time Non-determinism): 운영체제의 메모리 할당자는 가용 메모리 블록을 찾기 위해 자유 공간 리스트(Free List)를 순회합니다. 메모리 상태에 따라 이 탐색 시간이 기하급수적으로 변동(Jitter)하여 제어 주기의 데드라인을 위반하게 됩니다.
- 메모리 단편화 (Memory Fragmentation): 잦은 할당과 해제가 반복되면 메모리 공간이 조각나게 되어, 총 가용 메모리가 충분함에도 불구하고 연속된 메모리 블록을 할당하지 못해 시스템 패닉(Out-Of-Memory, OOM) 및 크래시(Crash)를 유발합니다.
7.2.2 정적 메모리 풀(Static Memory Pool) 아키텍처 설계
위의 문제를 원천적으로 차단하기 위해, 블랙보드의 메모리 아키텍처를 런타임(Runtime)이 아닌 컴파일 타임(Compile Time) 또는 초기화(Initialization) 단계에 확정하는 설계 패턴을 적용합니다.
- 메모리 사전 할당 (Pre-allocation): 로봇이 구동되는 동안 블랙보드가 저장해야 할 최대 데이터의 종류와 크기를 사전에 계산하여, BSS(Block Started by Symbol) 영역이나 전역 데이터(Data Segment) 영역에 고정된 크기의 바이트 배열(Byte Array)을 통째로 할당합니다.
- 식별자(Key)의 정수화 (Integer Mapping):
std::string을 키(Key)로 사용한 해시 테이블 검색은 해시 충돌(Collision) 해결 과정에서 비결정적 루프를 유발합니다. 따라서 모든 블랙보드 식별자를 열거형(Enum) 상수나constexpr정수 인덱스로 맵핑하여, 메모리 주소 오프셋(Offset) 계산만으로 데이터에 직접 접근하도록 설계합니다. - 타입 소실의 정적 대체:
std::any대신std::memcpy를 활용한 고정 크기 직렬화 버퍼나, C++17의std::variant를 사용하여 힙 할당 없이 타입 안정성을 보장합니다.
7.2.3 C++ 기반 정적 메모리 풀 블랙보드 구현 명세 (RTOS 타겟)
다음은 Micro-ROS 및 RTOS 환경에서 사용할 수 있도록 힙(Heap) 메모리 할당을 100% 배제하고, O(1)의 확정적 접근 시간을 보장하는 정적 블랙보드 C++ 설계 명세입니다.
#include <cstdint>
#include <cstring>
#include <array>
#include <atomic>
#include <cassert>
// 1. 블랙보드 데이터 식별자를 열거형(Enum)으로 사전 정의하여 상수 시간 인덱싱 보장
enum class BBKey : uint8_t {
OBSTACLE_DISTANCE = 0,
BATTERY_VOLTAGE,
IS_EMERGENCY_STOP,
TARGET_VELOCITY,
MAX_KEYS // 배열 크기 할당을 위한 카운터
};
// 2. 단일 데이터 블록의 최대 크기 정의 (예: 16바이트)
constexpr size_t MAX_DATA_SIZE = 16;
class RtosStaticBlackboard
{
public:
// 싱글톤 패턴: 전역 데이터 영역(Data Segment)에 정적으로 인스턴스화 됨
static RtosStaticBlackboard& getInstance()
{
static RtosStaticBlackboard instance;
return instance;
}
// [쓰기 연산] 동적 할당 없이 메모리 복사만 수행
// 시간 복잡도: 엄격한 O(1)
template <typename T>
bool set(BBKey key, const T& value)
{
// 컴파일 타임에 데이터 크기 제약 검증
static_assert(sizeof(T) <= MAX_DATA_SIZE, "Data type exceeds maximum static block size.");
uint8_t index = static_cast<uint8_t>(key);
if (index >= static_cast<uint8_t>(BBKey::MAX_KEYS)) {
return false;
}
// ISR(인터럽트) 환경을 고려한 락 프리(Lock-free) 설계가 필요할 수 있으나,
// 본 명세에서는 단순화를 위해 스핀락(Spinlock) 형태의 원자적 플래그 사용
while (lock_flags_[index].test_and_set(std::memory_order_acquire)) {
// RTOS 환경에 따라 taskYIELD() 등으로 대체 가능
}
std::memcpy(memory_pool_[index].data(), &value, sizeof(T));
data_sizes_[index] = sizeof(T);
is_set_[index] = true;
lock_flags_[index].clear(std::memory_order_release);
return true;
}
// [읽기 연산] 해시 검색 없이 인덱스로 직접 메모리 주소 접근
// 시간 복잡도: 엄격한 O(1)
template <typename T>
bool get(BBKey key, T& out_value)
{
static_assert(sizeof(T) <= MAX_DATA_SIZE, "Data type exceeds maximum static block size.");
uint8_t index = static_cast<uint8_t>(key);
if (index >= static_cast<uint8_t>(BBKey::MAX_KEYS) || !is_set_[index]) {
return false;
}
// 타입 안전성 검증 (저장된 크기와 요청한 크기 비교)
if (data_sizes_[index] != sizeof(T)) {
return false; // 타입 크기 불일치 오류
}
while (lock_flags_[index].test_and_set(std::memory_order_acquire)) {
// Spin wait
}
std::memcpy(&out_value, memory_pool_[index].data(), sizeof(T));
lock_flags_[index].clear(std::memory_order_release);
return true;
}
private:
RtosStaticBlackboard()
{
for (auto& flag : lock_flags_) {
flag.clear();
}
is_set_.fill(false);
}
~RtosStaticBlackboard() = default;
// 복사 및 대입 연산자 삭제 (싱글톤 강제)
RtosStaticBlackboard(const RtosStaticBlackboard&) = delete;
RtosStaticBlackboard& operator=(const RtosStaticBlackboard&) = delete;
// --- 정적 메모리 풀 영역 (BSS/Data Segment에 할당) ---
// 실제 데이터가 저장되는 고정 크기 바이트 버퍼의 2차원 배열
std::array<std::array<uint8_t, MAX_DATA_SIZE>, static_cast<size_t>(BBKey::MAX_KEYS)> memory_pool_;
// 저장된 데이터의 실제 크기 (타입 검증용)
std::array<size_t, static_cast<size_t>(BBKey::MAX_KEYS)> data_sizes_;
// 데이터 갱신 여부 추적
std::array<bool, static_cast<size_t>(BBKey::MAX_KEYS)> is_set_;
// 아이템별 개별 락 (전체 락으로 인한 병목 방지)
std::array<std::atomic_flag, static_cast<size_t>(BBKey::MAX_KEYS)> lock_flags_;
};
7.2.4 아키텍처의 학술적/공학적 의의
- 결정론적 실행 시간(Deterministic Execution Time) 확보: 위 설계에서
get()및set()연산은 CPU의 기본 레지스터 연산(인덱스 캐스팅)과 메모리 블록 복사(std::memcpy)만으로 구성됩니다. 분기(Branch)나 시스템 콜(System Call)이 개입하지 않으므로, 최악 실행 시간(WCET)을 나노초(ns) 단위로 정확히 예측하고 계산할 수 있습니다. - 메모리 단편화 원천 차단: 메모리 할당/해제 함수가 완전히 배제되었으므로, 로봇이 수만 시간을 연속으로 가동하더라도 메모리 단편화나 누수(Leak)가 수학적으로 발생할 수 없습니다. 이는 항공우주 및 의료용 로봇 소프트웨어 검증 표준(예: DO-178C, IEC 62304)에서 요구하는 핵심 안전 규격입니다.
- 캐시 친화성(Cache Friendliness) 극대화: 데이터가 분절된 힙 영역이 아닌 연속된 배열 블록(
std::array)에 적재되므로, CPU의 공간적 지역성(Spatial Locality) 원리에 의해 L1/L2 캐시 적중률(Cache Hit Ratio)이 비약적으로 상승하여 전반적인 제어 루프 속도가 향상됩니다.
7.3 락 프리(Lock-free) 자료구조 및 원자적(Atomic) 연산을 통한 문맥 교환(Context Switching) 오버헤드 제거
ROS2의 다중 스레드 환경에서 전역 블랙보드(Global Blackboard)에 접근하기 위해 std::mutex나 std::shared_mutex를 사용하는 것은 일반적인 컴퓨팅 환경에서는 타당하나, 하드 실시간(Hard Real-time) 환경에서는 다음과 같은 시스템 프로그래밍 계층의 치명적인 결함을 유발한다.
7.3.1 상호 배제(Mutex)의 학술적 한계 및 운영체제 오버헤드
- 문맥 교환 (Context Switching) 지연: 스레드가 락(Lock)을 획득하지 못하고 블로킹(Blocking) 상태로 전이될 때, 운영체제는 현재 스레드의 레지스터 상태를 TCB(Task Control Block)에 저장하고 다른 스레드를 적재하는 문맥 교환을 수행한다. 이 과정에서 CPU 캐시 무효화(Cache Invalidation) 및 파이프라인 플러시(Pipeline Flush)가 발생하여 수십~수백 \mu s의 비결정적 지연이 추가된다.
- 우선순위 역전 (Priority Inversion): 우선순위가 낮은 태스크가 블랙보드의 쓰기 락을 점유한 상태에서 선점(Preemption)당할 경우, 우선순위가 높은 하드 실시간 제어 태스크(예: 행동 트리 틱 엔진)가 락을 획득하지 못해 데드라인(Deadline)을 위반하는 현상이다. 우선순위 상속(Priority Inheritance) 프로토콜로 완화할 수 있으나, 본질적인 대기 시간 오버헤드는 제거할 수 없다.
7.3.2 비교-교환 (Compare-And-Swap, CAS) 기반의 원자적 연산
락 프리 아키텍처는 운영체제의 커널(Kernel) 개입 없이, CPU 하드웨어 명령어 집합(Instruction Set)에서 제공하는 원자적 연산을 통해 동시성(Concurrency)을 제어한다. 핵심 수학적 매커니즘은 Compare-And-Swap (CAS) 알고리즘이다.
CAS 연산은 메모리 주소 M, 기대하는 기존 값 E, 그리고 새로운 값 N을 인자로 받아 다음 논리를 하드웨어 수준에서 불가분하게(Indivisibly) 수행한다.
- M의 현재 값을 읽어 E와 비교한다.
- 두 값이 일치하면 M의 값을 N으로 교체하고
true를 반환한다. - 일치하지 않으면 교체하지 않고
false를 반환한다.
이러한 비블로킹(Non-blocking) 알고리즘은 스레드가 중단되지 않고 실패 시 즉각 재시도(Spin)하거나 다른 논리로 우회할 수 있게 하여, 전체 시스템 중 적어도 하나의 스레드는 항상 진행(System-wide Progress)됨을 수학적으로 보장한다.
7.3.3 메모리 오더링 (Memory Ordering) 최적화
C++의 std::atomic은 기본적으로 가장 엄격한 순차적 일관성(Sequential Consistency, std::memory_order_seq_cst)을 적용하여 CPU의 메모리 배리어(Memory Barrier)를 강제하므로 버스(Bus) 트래픽 오버헤드가 발생한다.
행동 트리의 블랙보드 통신은 단방향 데이터 흐름(센서 \rightarrow 블랙보드 \rightarrow 제어 노드)이 주를 이루므로, 획득-해제 의미론(Acquire-Release Semantics)을 적용하여 락 프리 연산을 한 단계 더 최적화한다.
- 쓰기 연산 (Writer):
std::memory_order_release를 사용하여, 이 연산 이전에 발생한 모든 메모리 쓰기가 타 스레드에게 가시적(Visible)이도록 보장한다. - 읽기 연산 (Reader):
std::memory_order_acquire를 사용하여, 이후의 메모리 읽기가 타 스레드의release연산 이후의 최신 값을 관측하도록 보장한다.
7.3.4 C++ 기반 락 프리 블랙보드 포트(Port) 구현 명세
아래는 RTOS 및 Micro-ROS 환경에서 센서 데이터를 행동 트리 틱(Tick) 엔진으로 전달할 때, 뮤텍스(Mutex) 없이 상수 시간 O(1)의 결정론적 속도를 보장하는 락 프리 원자적 컨테이너의 C++ 설계 명세이다.
#include <atomic>
#include <type_traits>
#include <stdexcept>
#include <iostream>
// 하드 실시간 환경을 위한 락 프리(Lock-free) 블랙보드 데이터 컨테이너
template <typename T>
class LockFreeBlackboardEntry
{
// 원자적 연산이 불가능한 복잡한 객체(동적 할당 포함)의 사용을 컴파일 타임에 차단
static_assert(std::is_trivially_copyable_v<T>,
"Type T must be trivially copyable for lock-free atomic operations.");
public:
LockFreeBlackboardEntry() = default;
explicit LockFreeBlackboardEntry(T initial_value) : data_(initial_value)
{
// 대상 아키텍처(ARM, x86 등)에서 해당 타입이 내부 락(Spinlock) 없이
// 순수 하드웨어 원자적 명령으로 처리되는지 런타임 검증
if (!data_.is_always_lock_free) {
throw std::runtime_error("The specified type is not inherently lock-free on this hardware architecture.");
}
}
// [쓰기 연산] 뮤텍스 및 컨텍스트 스위칭 없음
// 퍼셉션 노드(센서 콜백)에서 호출
void store(const T& value)
{
// Release semantics: 이 store 연산 이전에 메모리에 기록된 데이터가
// 타 스레드(acquire)에게 가시적으로 노출됨을 보장함
data_.store(value, std::memory_order_release);
}
// [읽기 연산] 뮤텍스 및 대기 시간(Blocking) 없음
// 행동 트리 제어 노드(Tick Engine)에서 호출
T load() const
{
// Acquire semantics: 타 스레드의 release 연산 이후에 기록된
// 최신 메모리 상태를 안전하게 읽어옴을 보장함
return data_.load(std::memory_order_acquire);
}
// [비교-교환 연산] 상태 기계(State Machine)의 플래그 제어 등에 사용
bool compare_exchange(T& expected, T desired)
{
// CAS(Compare-And-Swap) 알고리즘을 통한 원자적 상태 변이
return data_.compare_exchange_strong(expected, desired,
std::memory_order_acq_rel,
std::memory_order_acquire);
}
private:
std::atomic<T> data_;
};
7.3.5 아키텍처의 학술적/공학적 의의
이러한 락 프리(Lock-free) 아키텍처는 블랙보드의 읽기 및 쓰기 연산을 순수 CPU 명령어 수 개 수준으로 압축한다. 메모리 할당(Allocation)이나 운영체제 시스템 콜(System Call)이 일절 개입하지 않으므로, 행동 트리의 틱 지연(Tick Latency)이 수 나노초(ns) 단위의 상수 시간으로 결정론적(Deterministic)으로 고정된다.
특히 sensor_msgs::msg::BatteryState와 같은 단순 구조체나 센서 플래그, 로봇의 현재 상태 열거형(Enum) 등을 교환할 때 적용되며, 우선순위 역전과 문맥 교환 오버헤드를 원천 차단하여 안전 필수(Safety-Critical) 시스템의 최악 실행 시간(WCET) 검증을 수학적으로 가능하게 하는 핵심 소프트웨어 공학 패턴이다. 단, 점군(Point Cloud) 데이터와 같이 std::is_trivially_copyable_v를 만족하지 않는 대용량 비연속 데이터의 경우, 이 메커니즘 대신 단일 생산자-단일 소비자(SPSC) 락 프리 링 버퍼(Ring Buffer) 패턴으로 우회하여 설계해야 한다.
8. 반응형(Reactive) 블랙보드와 이벤트 주도(Event-Driven) 아키텍처 통합
전통적인 행동 트리(Behavior Tree)나 상태 기계(FSM)는 시스템의 제어 주파수(예: 100Hz)에 맞추어 매 틱(Tick)마다 블랙보드의 데이터를 반복적으로 읽어와 조건(Condition)을 평가하는 폴링(Polling) 방식을 취한다. 그러나 대부분의 센서 데이터나 상태 변수는 매 제어 주기마다 변경되지 않으므로, 이러한 동기적 폴링은 CPU 사이클의 낭비와 불필요한 락(Lock) 경합을 유발한다.
이러한 비효율성을 수학적으로 제거하고 시스템의 반응성(Reactivity)을 극대화하기 위해, 소프트웨어 공학의 **옵저버 패턴(Observer Pattern)**을 블랙보드 아키텍처에 통합하여 상태 변화 시점에만 연산을 수행하는 이벤트 주도(Event-Driven) 메커니즘을 명세한다.
8.1 옵저버 패턴(Observer Pattern) 기반의 블랙보드 콜백(Callback) 등록 메커니즘
옵저버 패턴은 객체의 상태 변화를 관찰하는 관찰자(Observer)들의 목록을 객체(Subject, 여기서는 블랙보드)에 등록하여, 상태 변화가 있을 때마다 메서드 등을 통해 객체가 직접 목록의 각 관찰자에게 통지(Notify)하도록 하는 디자인 패턴이다.
8.1.1 학술적 배경 및 시간 복잡도(Time Complexity) 분석
- 폴링(Polling) 모델의 한계: N개의 조건 노드가 각각 f_{tick}의 주파수로 블랙보드를 조회할 때, 상태 변화 유무와 관계없이 발생하는 초당 총 접근 횟수는 N \times f_{tick}이다. 이는 시스템 규모가 커질수록 락 경합(Lock Contention) 확률을 기하급수적으로 증가시킨다.
- 이벤트 주도(Event-Driven) 모델의 최적화: 특정 키(Key)에 변경 이벤트가 발생할 확률 변수를 \lambda (상태 전이 빈도)라고 할 때, 옵저버 패턴을 적용하면 CPU 연산 횟수는 N \times \lambda로 감소한다. 일반적으로 로봇 제어에서 \lambda \ll f_{tick} 이므로, 불필요한 연산 오버헤드가 O(1) 수준으로 수렴하게 된다.
8.1.2 아키텍처 설계 제약 및 교착 상태(Deadlock) 방지 원칙
블랙보드 내부에 콜백(Callback) 함수를 등록하고 실행할 때 가장 주의해야 할 공학적 결함은 **교착 상태(Deadlock)**이다.
만약 블랙보드가 쓰기 락(std::unique_lock)을 획득한 상태에서 등록된 콜백 함수를 동기적으로 실행(Execute)하고, 그 콜백 함수 내부에서 다시 블랙보드의 다른 키를 읽거나(get) 쓰려고(set) 시도한다면, 동일한 스레드가 이미 선점한 락을 다시 요청하게 되어 시스템이 영구적으로 정지한다.
이를 방지하기 위해 락-프리 콜백 실행(Lock-free Callback Execution) 또는 콜백 복사 후 지연 실행(Copy-then-Execute) 패턴을 반드시 적용해야 한다.
8.1.3 C++ 기반 반응형 블랙보드(Reactive Blackboard) 구현 명세
다음은 5장에서 명세한 범용 블랙보드에 옵저버 패턴을 통합하여, 교착 상태 없이 콜백을 안전하게 통지하는 C++ 아키텍처이다.
#include <iostream>
#include <string>
#include <unordered_map>
#include <vector>
#include <any>
#include <functional>
#include <shared_mutex>
#include <mutex>
#include <rclcpp/rclcpp.hpp>
// 콜백 함수 시그니처 정의: (변경된 키 이름, 변경된 데이터)
using BlackboardCallback = std::function<void(const std::string&, const std::any&)>;
class ReactiveBlackboard
{
public:
static ReactiveBlackboard& getInstance()
{
static ReactiveBlackboard instance;
return instance;
}
// [콜백 등록] 특정 키(Key)의 상태 변화를 구독(Subscribe)함
void observe(const std::string& key, BlackboardCallback callback)
{
std::unique_lock<std::shared_mutex> lock(observer_mutex_);
observers_[key].push_back(std::move(callback));
}
// [쓰기 연산 및 통지] 데이터를 갱신하고 등록된 옵저버들에게 알림(Notify)
template <typename T>
void set(const std::string& key, const T& value)
{
std::vector<BlackboardCallback> callbacks_to_invoke;
std::any value_to_notify = value;
// 1. 데이터 갱신 (Data Mutex 사용)
{
std::unique_lock<std::shared_mutex> data_lock(data_mutex_);
storage_[key] = value_to_notify;
}
// 2. 실행할 콜백 목록 복사 (Observer Mutex 사용)
// 교착 상태(Deadlock)를 방지하기 위해 락을 유지한 채로 콜백을 실행하지 않는다.
{
std::shared_lock<std::shared_mutex> observer_lock(observer_mutex_);
auto it = observers_.find(key);
if (it != observers_.end()) {
callbacks_to_invoke = it->second; // 콜백 벡터 복사
}
}
// 3. 락이 모두 해제된 안전한 상태에서 콜백 실행 (통지)
// 이로 인해 콜백 내부에서 다시 set()이나 get()을 호출하더라도 교착 상태가 발생하지 않음.
for (const auto& cb : callbacks_to_invoke) {
try {
cb(key, value_to_notify);
} catch (const std::exception& e) {
// 특정 콜백의 실패가 시스템 전체 프로세스나 다른 콜백의 실행을 중단시키지 않도록 격리
RCLCPP_ERROR(rclcpp::get_logger("ReactiveBlackboard"),
"Exception in observer callback for key '%s': %s", key.c_str(), e.what());
}
}
}
// [읽기 연산] 기존과 동일
template <typename T>
bool get(const std::string& key, T& out_value) const
{
std::shared_lock<std::shared_mutex> data_lock(data_mutex_);
auto it = storage_.find(key);
if (it == storage_.end()) {
return false;
}
try {
out_value = std::any_cast<T>(it->second);
return true;
} catch (const std::bad_any_cast& e) {
return false;
}
}
private:
ReactiveBlackboard() = default;
~ReactiveBlackboard() = default;
ReactiveBlackboard(const ReactiveBlackboard&) = delete;
ReactiveBlackboard& operator=(const ReactiveBlackboard&) = delete;
// 데이터와 옵저버를 분리하여 독립적인 락(Lock)을 운용함
std::unordered_map<std::string, std::any> storage_;
mutable std::shared_mutex data_mutex_;
std::unordered_map<std::string, std::vector<BlackboardCallback>> observers_;
mutable std::shared_mutex observer_mutex_;
};
8.1.4 ROS2 생태계에서의 활용 명세 (예시)
위 아키텍처가 적용되면, ROS2의 하위 제어 노드나 액션 클라이언트는 무한 루프나 타이머 폴링 없이, 이벤트가 발생하는 즉시 비동기적으로 동작을 수행할 수 있다.
// ROS2 노드 초기화 단계에서의 콜백 등록 예시
void setupRobotController()
{
auto& blackboard = ReactiveBlackboard::getInstance();
// 'emergency_stop' 변수가 변경될 때만 즉각적으로 트리거되는 콜백 등록
blackboard.observe("emergency_stop", [](const%20std::string&%20key,%20const%20std::any&%20value) {
try {
bool is_e_stop_active = std::any_cast<bool>(value);
if (is_e_stop_active) {
// 모터 드라이버에 하드웨어 인터럽트 수준의 즉각적인 정지 명령 하달
executeEmergencyBrake();
}
} catch (const std::bad_any_cast&) {
// 타입 오류 처리
}
});
}
8.1.5 공학적 결론 및 타당성
본 명세는 데이터 컨테이너(data_mutex_)와 옵저버 컨테이너(observer_mutex_)의 제어권을 공간적으로 분리하고, 통지(Notify) 단계를 임계 구역(Critical Section) 외부로 배출(Eviction)하는 아키텍처를 채택하였다. 이를 통해 로봇 제어 소프트웨어의 반응 시간(Reaction Time)을 커널 수준의 이벤트 스케줄링 지연 시간(수십~수백 \mu s) 수준으로 단축시키며, 동시에 다중 스레드 환경에서의 수학적 무결성을 보장한다.
8.2 폴링(Polling) 방식 대비 CPU 점유율 및 제어 지연 시간(Latency)의 수학적 비교 분석
로봇 소프트웨어 아키텍처에서 블랙보드의 상태 변화를 감지하고 후속 제어 로직을 트리거(Trigger)하는 메커니즘은 크게 폴링(Polling) 방식과 이벤트 주도(Event-Driven, 또는 Reactive) 방식으로 대별된다. 시스템의 한정된 컴퓨팅 자원을 최적화하고 하드 실시간성(Hard Real-time)을 보장하기 위해서는, 두 방식의 CPU 점유율(CPU Utilization, U)과 제어 지연 시간(Control Latency, L)을 수학적으로 모델링하여 아키텍처 설계의 타당성을 검증해야 한다.
8.2.1 시스템 환경의 수학적 모델링
블랙보드 내 특정 데이터(예: 장애물 감지 플래그)의 상태 변화 이벤트(Event) 발생을 평균 도착률이 \lambda (events/sec)인 포아송 프로세스(Poisson Process)로 가정한다.
- \lambda: 블랙보드 데이터의 평균 갱신 주파수 (Hz)
- f_{poll}: 폴링 방식에서의 주기적 상태 검사 주파수 (Hz)
- T_{poll}: 폴링 주기 (T_{poll} = 1 / f_{poll})
- C_{check}: 1회의 상태 검사(블랙보드 Read 락 획득 및 조건 평가)에 소요되는 CPU 연산 시간
- C_{exec}: 상태 변화가 감지되었을 때 실제 비즈니스 로직(제어 명령 생성 등)을 수행하는 데 소요되는 CPU 연산 시간
- C_{notify}: 이벤트 주도 방식에서 조건 변수(Condition Variable)나 콜백(Callback)을 통해 스레드를 깨우는(Wake-up) 데 소요되는 문맥 교환(Context Switching) 및 스케줄링 오버헤드
8.2.2 CPU 점유율 (U)의 수학적 비교
CPU 점유율 U는 단위 시간(1초) 동안 해당 작업을 처리하기 위해 소비된 활성 연산 시간의 합으로 정의된다.
8.2.2.1 폴링(Polling) 방식의 CPU 점유율
폴링 방식은 이벤트 발생 여부와 무관하게 매 주기마다 C_{check}의 연산을 강제한다. 따라서 단위 시간당 총 CPU 점유율 U_{poll}은 상태 검사 오버헤드와 실제 처리 연산의 합으로 구성된다.
U_{poll} = f_{poll} \cdot C_{check} + \lambda \cdot C_{exec}
(단, f_{poll} \ge \lambda를 가정하며, 하나의 폴링 주기 내에 다수의 이벤트가 발생하더라도 최신 상태만을 1회 처리한다고 가정할 때 실질적인 실행 횟수는 \min(f_{poll}, \lambda)가 된다. 보수적 최악 실행 분석을 위해 위 식을 사용한다.)
8.2.2.2 이벤트 주도(Event-Driven) 방식의 CPU 점유율
이벤트 주도 방식은 블랙보드의 상태가 쓰기 연산(Write)에 의해 갱신될 때만 스레드를 활성화한다. 불필요한 C_{check} 연산이 완전히 제거되며, 대신 OS 커널 수준의 알림 오버헤드 C_{notify}가 추가된다.
U_{react} = \lambda \cdot (C_{notify} + C_{exec})
8.2.2.2 CPU 점유율 비교 및 아키텍처 선택 기준
두 방식의 점유율 차이 \Delta U는 다음과 같다.
\Delta U = U_{poll} - U_{react} = f_{poll} \cdot C_{check} - \lambda \cdot C_{notify}
대부분의 ROS2 제어 환경에서 센서 갱신 빈도 \lambda가 제어 루프 주파수 f_{poll}보다 현저히 낮을 때(\lambda \ll f_{poll}), \Delta U > 0이 되어 폴링 방식은 막대한 CPU 사이클의 낭비(Busy-waiting)를 초래한다. 반면, 센서 노이즈 등으로 인해 이벤트가 폭주하여 \lambda가 극단적으로 높아지면 문맥 교환 오버헤드(C_{notify})가 누적되어 스래싱(Thrashing)이 발생할 수 있다.
8.2.3. 제어 지연 시간 (L)의 확률론적 비교
제어 지연 시간 L은 블랙보드에 새로운 데이터가 기록된 시점 t_{event}부터, 해당 데이터를 처리하는 후속 로직이 실제로 실행을 시작하는 시점 t_{reaction}까지의 시간 간격으로 정의된다. (L = t_{reaction} - t_{event})
8.2.3.1 폴링(Polling) 방식의 지연 시간
이벤트 도착이 폴링 주기 T_{poll} 내에서 균등 분포(Uniform Distribution) U(0, T_{poll})를 따른다고 가정할 때, 이벤트가 발생한 후 다음 폴링 틱(Tick)이 도달할 때까지의 대기 시간의 기댓값 \mathbb{E}[L_{poll}]은 다음과 같다.
\mathbb{E}[L_{poll}] = \int_{0}^{T_{poll}} \frac{t}{T_{poll}} dt = \frac{T_{poll}}{2} = \frac{1}{2 f_{poll}}
안전 필수(Safety-Critical) 시스템에서 고려해야 하는 최악 제어 지연 시간(Worst-Case Control Latency) L_{poll}^{WC}는 폴링 검사가 직방으로 끝난 직후 이벤트가 발생한 경우이므로, 전체 폴링 주기와 일치한다.
L_{poll}^{WC} = T_{poll} = \frac{1}{f_{poll}}
즉, 지연 시간을 줄이기 위해 f_{poll}을 높여야 하며, 이는 곧 \Delta U를 기하급수적으로 증가시켜 2장의 CPU 점유율 최적화와 정면으로 상충(Trade-off)하는 모순을 발생시킨다.
8.2.3.2 이벤트 주도(Event-Driven) 방식의 지연 시간
이벤트 주도 방식에서 지연 시간은 폴링 주기와 완전히 무관하며, 오직 하드웨어 인터럽트 처리 시간 및 실시간 운영체제(RTOS)의 스케줄링 지연(Scheduling Latency)에 의존한다.
\mathbb{E}[L_{react}] \approx L_{react}^{WC} = C_{notify}
최신 PREEMPT_RT 패치가 적용된 Linux 커널이나 FreeRTOS 환경에서 C_{notify}는 통상적으로 수 마이크로초(\mu s) 이내의 상수로 수학적 상한(Upper Bound)이 보장된다.
8.2.3 종합 결론 및 공학적 적용
위의 수학적 분석을 통해, ROS2 블랙보드 시스템에서 상태 갱신 빈도(\lambda)가 낮고 빠른 응답성(Low Latency)이 요구되는 예외 처리(Exception Handling, 예: 충돌 감지 플래그) 데이터의 경우 이벤트 주도 방식을 채택하는 것이 절대적으로 타당하다. 이는 O(\mu s) 단위의 확정적(Deterministic) 지연 시간을 보장하면서도 CPU 점유율을 최적화한다.
반면, 고주파수(예: 1000Hz)로 갱신되는 모터 엔코더 데이터 등 \lambda가 매우 큰 환경에서는 C_{notify}의 잦은 발생이 시스템 부하를 가중시킬 수 있다. 이러한 데이터는 제어 주파수 f_{poll}에 맞춰 블랙보드 값을 읽어가는 동기식 폴링 방식을 유지하는 것이 문맥 교환(Context Switching) 오버헤드를 줄이는 공학적 최적해(Optimal Solution)가 된다.
8.3 이벤트 폭주(Event Storm) 방지를 위한 디바운싱(Debouncing) 및 스로틀링(Throttling) 적용
블랙보드의 상태가 갱신될 때마다 등록된 콜백(Callback)을 즉각적으로 실행하는 순수 이벤트 주도(Event-Driven) 아키텍처는 고주파수 센서 데이터(예: 1000Hz로 갱신되는 IMU 데이터)가 유입될 때 치명적인 이벤트 폭주(Event Storm) 현상을 유발한다. 이는 콜백 큐(Queue)를 범람시키고, CPU 점유율을 100%로 고착시켜 로봇의 핵심 제어 루프를 마비시키는 주요 원인이 된다.
이를 소프트웨어적으로 방어하기 위해, 시간 기반의 신호 처리 기법인 스로틀링(Throttling)과 디바운싱(Debouncing)을 블랙보드의 이벤트 디스패처(Event Dispatcher) 계층에 통합해야 한다.
8.3.1 스로틀링(Throttling)과 디바운싱(Debouncing)의 수학적 모델링 및 용도
두 기법은 모두 과도한 이벤트 발생을 억제하지만, 그 수학적 동작 원리와 적용되는 도메인(Domain)이 명확히 다르다.
- 스로틀링 (Throttling):
- 정의: 지정된 시간 주기 T_{throttle} 내에는 단 하나의 이벤트(콜백)만 실행되도록 억제한다.
- 동작 조건: 현재 이벤트 발생 시점 t_{current}와 마지막 실행 시점 t_{last}의 차이가 주기를 초과할 때만 통과시킨다. (t_{current} - t_{last} \ge T_{throttle})
- 적용 도메인: 연속적으로 변화하는 아날로그 성격의 데이터 스트림(예: 로봇의 Odometry 갱신, 맵 데이터 변화 알림)을 일정한 최대 주파수(Max Frequency)로 다운샘플링(Down-sampling)하여 UI를 갱신하거나 하위 제어기에 전달할 때 사용된다.
- 디바운싱 (Debouncing):
- 정의: 이벤트가 연속적으로 발생하는 동안에는 실행을 지연시키고, 이벤트 발생이 완전히 멈춘 후 지정된 대기 시간 T_{debounce}가 경과했을 때 비로소 단 한 번 콜백을 실행한다.
- 동작 조건: 이벤트가 발생할 때마다 타이머를 0으로 초기화(Reset)하며, 타이머가 만료(Expired)될 때 연산을 수행한다.
- 적용 도메인: 물리적 스위치의 접점 바운싱(Bouncing) 현상 제거나, 노이즈로 인해 특정 인식 플래그(예:
is_human_detected)가true/false를 빠르게 오가는 채터링(Chattering) 현상을 제거하여 안정적인 상태 전이(State Transition)를 확정 지을 때 사용된다.
8.3.2 C++ 아키텍처 설계 명세: 시간 기반 이벤트 필터링 래퍼
다음은 ROS2 블랙보드 시스템에 콜백을 등록할 때, 원본 콜백 함수를 감싸(Wrapping) 스로틀링과 디바운싱 로직을 동적으로 부여하는 제어 클래스의 C++ 구현 명세이다.
이 설계는 std::chrono를 활용하여 커널 인터럽트 없이 사용자 공간(User Space)에서 시간 복잡도 O(1)로 이벤트를 필터링한다.
#include <iostream>
#include <functional>
#include <chrono>
#include <mutex>
#include <thread>
#include <atomic>
#include <rclcpp/rclcpp.hpp>
// 블랙보드 이벤트 콜백 타입 정의
using BlackboardCallback = std::function<void(const std::string& key)>;
class EventFilter
{
public:
/**
* @brief 스로틀링(Throttling) 래퍼 함수
* @param callback 원본 실행 콜백
* @param interval_ms 최소 실행 보장 주기 (밀리초)
* @return 필터링이 적용된 새로운 콜백 함수
*/
static BlackboardCallback createThrottled(BlackboardCallback callback, uint32_t interval_ms)
{
// 캡처된 상태를 유지하기 위해 공유 포인터 사용
auto last_exec_time = std::make_shared<std::chrono::steady_clock::time_point>(
std::chrono::steady_clock::time_point::min());
auto mutex = std::make_shared<std::mutex>();
return [callback, interval_ms, last_exec_time, mutex](const%20std::string&%20key) {
std::lock_guard<std::mutex> lock(*mutex);
auto now = std::chrono::steady_clock::now();
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(now - *last_exec_time).count();
// 마지막 실행 이후 interval_ms 이상 경과했을 때만 원본 콜백 실행
if (elapsed >= interval_ms) {
callback(key);
*last_exec_time = now;
} else {
// 주기를 만족하지 못한 이벤트는 무시(Drop) 처리됨
}
};
}
/**
* @brief 디바운싱(Debouncing) 래퍼 함수 (비동기 처리)
* @param callback 원본 실행 콜백
* @param delay_ms 이벤트 중단 후 대기 시간 (밀리초)
* @return 필터링이 적용된 새로운 콜백 함수
*/
static BlackboardCallback createDebounced(BlackboardCallback callback, uint32_t delay_ms)
{
// 타이머 취소 및 갱신을 위한 공유 상태
auto last_event_time = std::make_shared<std::chrono::steady_clock::time_point>(
std::chrono::steady_clock::now());
auto is_timer_running = std::make_shared<std::atomic<bool>>(false);
auto mutex = std::make_shared<std::mutex>();
return [callback, delay_ms, last_event_time, is_timer_running, mutex](const%20std::string&%20key) {
{
std::lock_guard<std::mutex> lock(*mutex);
*last_event_time = std::chrono::steady_clock::now(); // 이벤트 발생 시마다 시간 갱신
}
// 백그라운드 스레드가 이미 실행 중이 아니라면 새로 생성하여 대기
bool expected = false;
if (is_timer_running->compare_exchange_strong(expected, true)) {
std::thread([callback, key, delay_ms, last_event_time, is_timer_running, mutex]() {
while (true) {
std::chrono::steady_clock::time_point target_time;
{
std::lock_guard<std::mutex> lock(*mutex);
target_time = *last_event_time + std::chrono::milliseconds(delay_ms);
}
auto now = std::chrono::steady_clock::now();
if (now >= target_time) {
// 대기 시간이 온전히 경과했으므로 콜백 실행
callback(key);
*is_timer_running = false; // 타이머 상태 초기화
break;
}
// 남은 시간만큼 대기 (Sleep)
std::this_thread::sleep_for(target_time - now);
}
}).detach(); // 스레드 분리
}
};
}
};
8.3.3 아키텍처의 공학적 통합 예시
퍼셉션 노드에서 블랙보드의 target_distance 값을 1000Hz로 갱신하더라도, 옵저버(Observer) 패턴에 위 래퍼를 씌워 등록하면 제어 계층을 안전하게 보호할 수 있다.
// 1. 과도한 콜백 호출을 방지하기 위해 100ms 주기로 스로틀링된 콜백 등록 (최대 10Hz)
auto throttled_cb = EventFilter::createThrottled(
[](const%20std::string&%20key) {
std::cout << "[Throttled] Updating UI for: " << key << std::endl;
}, 100);
blackboard.addObserver("target_distance", throttled_cb);
// 2. 센서 노이즈로 인한 플래그 깜빡임을 방지하기 위해 500ms 디바운싱된 콜백 등록
auto debounced_cb = EventFilter::createDebounced(
[](const%20std::string&%20key) {
std::cout << "[Debounced] Target detection confirmed state transition." << std::endl;
}, 500);
blackboard.addObserver("is_target_detected", debounced_cb);
8.3.4 아키텍처의 학술적/공학적 의의
이 필터링 계층은 로봇 운영체제의 리소스 고갈(Resource Exhaustion)을 방지하는 필수적인 방어벽 역할을 수행한다. 스로틀링은 데이터의 최신성(Recency)을 어느 정도 포기하는 대신 처리 대역폭(Bandwidth)을 고정하며, 디바운싱은 지연(Latency)을 감수하더라도 데이터의 상태 결정성(Determinism)을 확보한다. 시스템 엔지니어는 공유되는 데이터의 물리적 의미에 따라 이 두 가지 기법을 취사선택하여 이벤트 주도 아키텍처의 무결성을 완성해야 한다.
9. 데이터 직렬화(Serialization) 및 상태 영속성(Persistence)
안전 필수(Safety-Critical) 로봇 제어 시스템에서 블랙보드(Blackboard)는 런타임 중 휘발성 메모리(RAM)에 데이터를 저장합니다. 시스템 크래시(Crash) 발생 시 이전 상태를 복원하거나, 오프라인에서 로봇의 의사결정 과정을 디버깅하기 위해서는 메모리 상의 데이터를 디스크(예: SQLite, rosbag2)에 기록할 수 있는 영속성(Persistence) 아키텍처가 필수적입니다.
로봇 소프트웨어 공학에서 블랙보드(Blackboard)는 런타임(Runtime) 중 지속적으로 변동하는 전역 상태(Global State)를 담고 있는 휘발성(Volatile) 메모리 컨테이너다. 시스템 크래시(Crash) 발생 시 원인 규명을 위한 사후 분석(Post-mortem Analysis)이나, 이전 임무의 상태를 복원(Reconstruction)하기 위해서는 메모리 상의 데이터를 비휘발성 저장 매체에 시간의 흐름에 따라 기록하는 시계열 로깅(Time-series Logging) 아키텍처가 필수적으로 요구된다.
9.1 타입 소실(Type Erasure) 데이터의 런타임 직렬화/역직렬화 파이프라인
ROS2 블랙보드 아키텍처는 유연성을 위해 std::any를 활용하여 데이터의 실제 타입을 은닉(Type Erasure)합니다. 그러나 이로 인해 직렬화(Serialization) 단계에서 치명적인 공학적 난제에 직면하게 됩니다.
9.1.1 학술적 배경: 타입 소실과 정적 직렬화의 모순
C++는 자바(Java)나 파이썬(Python)과 달리 런타임 리플렉션(Runtime Reflection)을 완벽히 지원하지 않습니다. std::any 객체는 자신이 담고 있는 데이터의 std::type_info를 알 수 있지만, 런타임에 이 정보를 바탕으로 특정 타입의 직렬화 함수(예: serialize<T>())를 동적으로 호출할 수는 없습니다.
즉, 컴파일 타임(Compile-time)에 타입이 결정되어야 하는 템플릿 기반의 직렬화 라이브러리를 타입이 소실된 런타임(Run-time) 객체에 직접 적용할 수 없는 모순이 발생합니다.
9.1.2 런타임 타입 레지스트리 (Runtime Type Registry) 패턴
이 문제를 해결하기 위해 함수 포인터(또는 std::function) 기반의 런타임 타입 레지스트리 패턴을 도입합니다.
- 콜백 바인딩 (Callback Binding): 컴파일 타임에 시스템에서 사용될 모든 데이터 타입(예:
int,std::string,geometry_msgs::msg::Pose)에 대한 직렬화/역직렬화 람다(Lambda) 함수를 생성합니다. - 타입 인덱스 매핑 (Type Index Mapping): 생성된 함수들을
std::type_index를 키(Key)로 하는 해시맵(Hash Map)에 등록(Register)합니다. - 런타임 디스패치 (Runtime Dispatch): 블랙보드에서
std::any객체를 꺼낼 때, 해당 객체의type()연산자를 호출하여std::type_index를 얻고, 레지스트리에서 대응하는 직렬화 함수를 찾아 O(1)의 시간 복잡도로 실행합니다.
9.1.3 JSON 기반 직렬화 아키텍처 설계
상태 영속성과 사후 감사(Post-audit)의 가독성을 높이기 위해, 직렬화 포맷으로는 바이너리 스트림보다 구조화된 텍스트 포맷인 JSON(JavaScript Object Notation)을 채택하는 것이 로봇 소프트웨어 공학에서 유리합니다. C++의 nlohmann::json 라이브러리를 활용하여 파이프라인을 구축합니다.
9.1.4 C++ 구현 명세: 타입 소실 기반 직렬화기 (Type-Erased Serializer)
다음은 std::any로 은닉된 데이터를 런타임에 JSON으로 직렬화하고, 다시 std::any로 역직렬화하는 레지스트리 클래스의 C++ 설계 명세입니다.
#include <iostream>
#include <string>
#include <unordered_map>
#include <any>
#include <functional>
#include <typeindex>
#include <stdexcept>
#include <nlohmann/json.hpp>
// 직렬화/역직렬화 함수 시그니처 정의
using SerializeFunc = std::function<nlohmann::json(const std::any&)>;
using DeserializeFunc = std::function<std::any(const nlohmann::json&)>;
class BlackboardSerializer
{
public:
// 싱글톤 패턴 적용
static BlackboardSerializer& getInstance()
{
static BlackboardSerializer instance;
return instance;
}
// [등록 연산] 컴파일 타임에 알고 있는 타입 T에 대한 직렬화/역직렬화 규칙 등록
template <typename T>
void registerType(
const std::string& type_name,
std::function<nlohmann::json(const T&)> serialize_fn,
std::function<T(const nlohmann::json&)> deserialize_fn)
{
std::type_index type_idx(typeid(T));
// std::any를 구체적 타입 T로 캐스팅하여 직렬화하는 래퍼 함수 생성
serializers_[type_idx] = [serialize_fn](const%20std::any&%20any_val) -> nlohmann::json {
return serialize_fn(std::any_cast<T>(any_val));
};
// JSON을 구체적 타입 T로 변환 후 std::any로 감싸는 래퍼 함수 생성
deserializers_[type_name] = [deserialize_fn](const%20nlohmann::json&%20json_val) -> std::any {
return std::any(deserialize_fn(json_val));
};
// 역직렬화 시 JSON에 저장된 문자열 타입 이름으로 매핑하기 위한 사전
type_name_to_index_[type_name] = type_idx;
type_index_to_name_[type_idx] = type_name;
}
// [런타임 직렬화] std::any 객체를 받아 JSON 객체로 변환
nlohmann::json serialize(const std::any& any_val) const
{
if (!any_val.has_value()) {
return nlohmann::json(); // null 반환
}
std::type_index type_idx(any_val.type());
auto it = serializers_.find(type_idx);
if (it == serializers_.end()) {
throw std::runtime_error("Serialization failed: Unregistered type.");
}
nlohmann::json result;
result["type"] = type_index_to_name_.at(type_idx);
result["data"] = it->second(any_val); // 동적 디스패치 실행
return result;
}
// [런타임 역직렬화] JSON 객체를 받아 std::any 객체로 복원
std::any deserialize(const nlohmann::json& json_val) const
{
if (json_val.is_null()) {
return std::any();
}
std::string type_name = json_val["type"];
auto it = deserializers_.find(type_name);
if (it == deserializers_.end()) {
throw std::runtime_error("Deserialization failed: Unregistered type '" + type_name + "'.");
}
return it->second(json_val["data"]); // 동적 디스패치 실행
}
private:
BlackboardSerializer() = default;
std::unordered_map<std::type_index, SerializeFunc> serializers_;
std::unordered_map<std::string, DeserializeFunc> deserializers_;
std::unordered_map<std::string, std::type_index> type_name_to_index_;
std::unordered_map<std::type_index, std::string> type_index_to_name_;
};
9.1.5 파이프라인 통합 및 사용 예시 (Integration)
시스템 초기화 단계(예: ROS2 메인 함수 내부)에서 예상되는 모든 데이터 타입의 직렬화 규칙을 BlackboardSerializer에 등록해야 합니다.
// 1. 시스템 초기화 시 타입 등록 (Registration)
auto& serializer = BlackboardSerializer::getInstance();
// 기본 자료형(double) 등록
serializer.registerType<double>(
"double",
[](const%20double&%20val) { return nlohmann::json(val); },
[](const%20nlohmann::json&%20j) { return j.get<double>(); }
);
// 커스텀 자료형(ROS2 Pose) 등록 예시
serializer.registerType<geometry_msgs::msg::Pose>(
"geometry_msgs/Pose",
[](const%20geometry_msgs::msg::Pose&%20pose) {
return nlohmann::json{
{"x", pose.position.x}, {"y", pose.position.y}, {"z", pose.position.z}
};
},
[](const%20nlohmann::json&%20j) {
geometry_msgs::msg::Pose pose;
pose.position.x = j["x"];
pose.position.y = j["y"];
pose.position.z = j["z"];
return pose;
}
);
// 2. 런타임 실행 단계 (Runtime Execution)
std::any erased_data = 3.14159; // 타입 소실 발생
// 직렬화 수행 (결과는 디스크에 저장 가능)
nlohmann::json serialized_json = serializer.serialize(erased_data);
/*
serialized_json 내부 형태:
{
"type": "double",
"data": 3.14159
}
*/
// 디스크에서 읽어온 JSON을 다시 std::any로 역직렬화
std::any restored_data = serializer.deserialize(serialized_json);
double final_value = std::any_cast<double>(restored_data);
9.1.6 아키텍처의 공학적 타당성
이러한 타입 소실 기반 직렬화 파이프라인은 ROS2 블랙보드가 가진 ’유연성(Type Erasure)’과 로깅/복구를 위한 ‘형식 엄격성(Type Strictness)’ 사이의 충돌을 완벽하게 해결합니다.
특히, serialize()와 deserialize() 호출 과정에서 무거운 분기문(if-else if)이나 dynamic_cast를 거치지 않고 해시 테이블 룩업(Hash Table Lookup)을 통한 O(1)의 상수 시간 복잡도를 보장하므로, 실시간 제어 루프 내부에서 주기적으로 블랙보드 전체의 스냅샷(Snapshot)을 캡처하는 데 소요되는 오버헤드를 수학적으로 최소화할 수 있습니다.
9.2 SQLite 또는 rosbag2를 활용한 블랙보드 상태의 시계열 로깅(Time-series Logging)
블랙보드의 데이터는 C++ std::any를 통해 타입 소실(Type Erasure) 상태로 저장되므로, 이를 디스크에 기록하기 위해서는 공통된 규격으로의 직렬화(Serialization) 파이프라인이 선행되어야 한다. 직렬화된 데이터는 시스템의 요구사항에 따라 rosbag2를 통한 ROS2 네이티브 로깅 방식과 SQLite3를 통한 직접 임베디드(Direct Embedded) 로깅 방식으로 설계될 수 있다.
9.2.1 런타임 직렬화 파이프라인 (Serialization Pipeline)
어떤 로깅 백엔드(Backend)를 사용하든, 동적 타입을 가진 블랙보드 데이터를 문자열이나 바이트 배열로 변환해야 한다. 가독성과 이종 시스템 간의 호환성을 위해 JSON(JavaScript Object Notation) 포맷을 중간 표현(Intermediate Representation, IR)으로 채택하는 것이 학술적으로 널리 쓰이는 표준이다.
- 변환 메커니즘: 블랙보드의
set()함수가 호출되어 상태가 갱신될 때마다, 갱신된(Key, Value)쌍과 당시의 고정밀 타임스탬프(Nanoseconds)를 캡처하여 JSON 객체로 직렬화한다. - 타입 복원:
std::any에 저장된 원시 타입(Primitive Type)이나 표준 컨테이너는 C++ RTTI(type_id)를 검사하여 JSON의 Number, String, Array로 매핑한다.
9.2.2 rosbag2 기반의 상태 로깅 아키텍처
ROS2의 표준 데이터 기록 도구인 rosbag2 (기본 스토리지 플러그인: MCAP 또는 SQLite3)를 활용하는 방식이다.
- 동작 원리: 블랙보드 노드 내부에 전용 퍼블리셔(Publisher)를 생성한다. 블랙보드의 데이터가 갱신되거나 일정한 주기(예: 10Hz)가 될 때마다 직렬화된 JSON 문자열을
std_msgs::msg::String타입으로 패키징하여/blackboard/state_log토픽으로 발행(Publish)한다. - 장점: * LiDAR, 카메라 영상 등 다른 ROS2 센서 토픽들과 완벽한 **시간 동기화(Time Synchronization)**가 이루어진다.
- 로봇 구동 후
ros2 bag play명령어를 통해 블랙보드의 상태 변화를 런타임과 동일하게 재현(Replay)할 수 있다. - 단점: DDS 미들웨어의 네트워크 스택을 거치므로 메모리 복사 및 스케줄링 오버헤드가 발생한다.
9.2.3 직접 임베디드 SQLite3 로깅 아키텍처 (Low-Overhead)
DDS 미들웨어의 개입을 완전히 배제하고, 블랙보드 프로세스 내부에서 SQLite3 C/C++ API를 직접 호출하여 로컬 파일 시스템에 시계열 데이터를 기록하는 방식이다.
- 동작 원리: 블랙보드 메모리 공간과 디스크 I/O 공간을 물리적으로 분리하기 위해 **비동기 로거 스레드(Asynchronous Logger Thread)**를 운용한다. 메인 스레드는 상태 갱신 시
std::queue에 로그 이벤트를 밀어넣고(Push) 즉시 반환(Return)하며, 백그라운드 스레드가 이를 일괄적으로(Batch) 디스크의 SQLite 데이터베이스에INSERT한다. - 장점:
- DDS 오버헤드가 원천 차단되어 고주파수(High-frequency) 제어 루프의 지터(Jitter)를 유발하지 않는다.
- SQL 쿼리문을 통해 “배터리 전압이 11.0V 이하로 떨어진 시점“과 같은 **의미론적 질의(Semantic Query)**가 즉각적으로 가능하다.
9.2.4 C++ 기반 비동기 SQLite3 블랙보드 로거 구현 명세
다음은 메인 블랙보드 연산(Read/Write)의 시간 복잡도 O(1)을 저해하지 않으면서, 상태 변화를 SQLite 데이터베이스에 시계열로 기록하는 비동기 로깅 시스템의 C++ 아키텍처 명세이다.
#include <iostream>
#include <string>
#include <queue>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <sqlite3.h>
#include <nlohmann/json.hpp>
// 블랙보드 상태 변경 이벤트를 담는 구조체
struct BlackboardLogEvent {
int64_t timestamp_ns;
std::string key;
std::string value_json;
};
class AsyncSQLiteLogger {
public:
AsyncSQLiteLogger(const std::string& db_path) : running_(true) {
// 1. SQLite 데이터베이스 초기화 및 테이블 생성
int rc = sqlite3_open(db_path.c_str(), &db_);
if (rc) {
throw std::runtime_error("Can't open database: " + std::string(sqlite3_errmsg(db_)));
}
const char* create_table_sql =
"CREATE TABLE IF NOT EXISTS blackboard_log ("
"id INTEGER PRIMARY KEY AUTOINCREMENT, "
"timestamp_ns INTEGER NOT NULL, "
"key TEXT NOT NULL, "
"value_json TEXT NOT NULL"
");"
"CREATE INDEX IF NOT EXISTS idx_timestamp ON blackboard_log(timestamp_ns);";
char* err_msg = nullptr;
sqlite3_exec(db_, create_table_sql, 0, 0, &err_msg);
if (err_msg) {
sqlite3_free(err_msg);
throw std::runtime_error("SQL error: Failed to create table");
}
// 성능 최적화를 위한 WAL(Write-Ahead Logging) 모드 및 동기화 완화 설정
sqlite3_exec(db_, "PRAGMA journal_mode=WAL;", 0, 0, 0);
sqlite3_exec(db_, "PRAGMA synchronous=NORMAL;", 0, 0, 0);
// 2. 백그라운드 기록 스레드 시작
writer_thread_ = std::thread(&AsyncSQLiteLogger::writerLoop, this);
}
~AsyncSQLiteLogger() {
{
std::lock_guard<std::mutex> lock(queue_mutex_);
running_ = false;
}
cv_.notify_one(); // 대기 중인 스레드 깨우기
if (writer_thread_.joinable()) {
writer_thread_.join();
}
sqlite3_close(db_);
}
// [로깅 인터페이스] 메인 스레드에서 호출되며, 디스크 I/O 없이 큐에 삽입 후 즉시 반환됨
void logStateChange(int64_t timestamp_ns, const std::string& key, const nlohmann::json& value) {
BlackboardLogEvent event{timestamp_ns, key, value.dump()};
{
std::lock_guard<std::mutex> lock(queue_mutex_);
log_queue_.push(event);
}
cv_.notify_one(); // 백그라운드 스레드에 작업 알림
}
private:
// 백그라운드 디스크 I/O 처리 루프
void writerLoop() {
sqlite3_stmt* insert_stmt;
const char* insert_sql = "INSERT INTO blackboard_log (timestamp_ns, key, value_json) VALUES (?, ?, ?);";
sqlite3_prepare_v2(db_, insert_sql, -1, &insert_stmt, nullptr);
while (true) {
std::queue<BlackboardLogEvent> local_queue;
{
std::unique_lock<std::mutex> lock(queue_mutex_);
cv_.wait(lock, [this] { return !log_queue_.empty() || !running_; });
if (!running_ && log_queue_.empty()) {
break;
}
// 큐 잠금을 최소화하기 위해 로컬 큐로 데이터를 스왑(Swap)
std::swap(local_queue, log_queue_);
}
// 트랜잭션(Transaction) 묶기: 다수의 INSERT를 한 번의 디스크 동기화로 일괄 처리(Batching)
sqlite3_exec(db_, "BEGIN TRANSACTION;", 0, 0, 0);
while (!local_queue.empty()) {
auto& event = local_queue.front();
// SQL Bind 연산
sqlite3_bind_int64(insert_stmt, 1, event.timestamp_ns);
sqlite3_bind_text(insert_stmt, 2, event.key.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(insert_stmt, 3, event.value_json.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_step(insert_stmt);
sqlite3_reset(insert_stmt);
local_queue.pop();
}
sqlite3_exec(db_, "COMMIT;", 0, 0, 0);
}
sqlite3_finalize(insert_stmt);
}
sqlite3* db_;
std::thread writer_thread_;
std::queue<BlackboardLogEvent> log_queue_;
std::mutex queue_mutex_;
std::condition_variable cv_;
bool running_;
};
9.2.4.1 공학적 검증 및 최적화 포인트
- 생산자-소비자(Producer-Consumer) 패턴: 블랙보드의
set()함수(생산자)는 락(Lock)을 획득하여 큐에 데이터를 밀어 넣는 작업만 수행하므로 시간 복잡도는 극도로 낮다. 실제 무거운 디스크 I/O 작업은writerLoop()(소비자)에서 처리되므로 실시간 제어 루프를 방해하지 않는다. - 트랜잭션 배칭(Transaction Batching): SQLite3는 기본적으로 매
INSERT마다 디스크 쓰기를 수행하여 성능이 저하된다. 위 코드는 큐에 쌓인 여러 이벤트를 한 번의BEGIN ... COMMIT트랜잭션으로 묶어 처리함으로써 로깅 처리량을 수천 배 향상시킨다.
9.2.5 아키텍처 비교 및 채택 기준 (Trade-off Analysis)
실제 로봇 시스템을 설계할 때 두 아키텍처 중 하나를 선택하기 위한 공학적 기준은 다음과 같다.
| 비교 항목 | rosbag2 기반 로깅 | 직접 임베디드 SQLite3 로깅 |
|---|---|---|
| 목적 | 디버깅 및 전체 시스템(센서 등)과의 통합 재생(Replay) | 데이터 마이닝, 고성능 로깅, 특정 이벤트의 의미론적 검색 |
| 시스템 오버헤드 | 중간 ~ 높음 (DDS 네트워크 스택 통과 및 메시지 할당) | 매우 낮음 (백그라운드 스레드 및 일괄 트랜잭션 처리) |
| 시간 동기화 | 타 ROS2 노드(LiDAR, TF 등) 데이터와 자동으로 동기화됨 | 독립적인 시스템 클럭(timestamp_ns)에 의존함 |
| 데이터 검색 효율 | 낮음 (순차적 재생만 지원, 특정 쿼리 검색 불가) | 높음 (SQL SELECT 문을 통한 즉각적인 조건부 필터링 가능) |
| 아키텍처 결합도 | ROS2 프레임워크에 강하게 결합됨 | 순수 C++ 구현으로 프레임워크 종속성 낮음 |
결론적으로, 로봇의 주행 기록이나 센서 데이터와 함께 블랙보드 상태를 시간 순으로 **재생(Play)**하며 디버깅하는 것이 주 목적이라면 rosbag2 방식이 타당하다. 반면, 장기간 자율 주행을 수행하며 수백 기가바이트(GB)의 로그를 쌓고, 사후에 “특정 임무가 실패한 원인이 되는 블랙보드 변수“를 빠르게 데이터베이스에서 **검색(Query)**하여 분석하는 데이터 마이닝이 목적이라면 직접 임베디드 SQLite3 아키텍처를 채택하는 것이 공학적으로 압도적인 이점을 제공한다.
9.3 시스템 크래시(Crash) 발생 시 이전 상태(State)의 안전한 복원(Reconstruction) 기법
안전 필수(Safety-Critical) 로봇 제어 시스템에서 운영체제(OS)의 패닉(Panic), 세그멘테이션 결함(Segmentation Fault), 또는 예기치 않은 전원 차단이 발생할 경우, RAM(Random Access Memory)에 상주하던 전역 블랙보드(Global Blackboard)의 상태 데이터는 즉각적으로 소실(Volatile Loss)된다.
시스템이 재부팅되거나 워치독(Watchdog)에 의해 제어 프로세스가 재시작되었을 때, 로봇이 임무를 초기 상태(Initial State)부터 다시 시작하는 것은 공학적으로 비효율적이며 때로는 물리적 위험을 초래한다. 따라서 크래시 직전의 상태를 영구 저장 장치(Persistent Storage)로부터 안전하게 불러와 실행 컨텍스트(Execution Context)를 복원(Reconstruction)하는 수학적이고 구조적인 아키텍처가 필수적이다.
9.3.1 학술적 배경: 크래시 일관성(Crash Consistency) 및 복원 목표
상태 복원 아키텍처는 데이터베이스 시스템의 원리를 차용하며, 다음 두 가지 정량적 지표를 최적화하도록 설계된다.
- 복구 지점 목표 (Recovery Point Objective, RPO): 크래시 발생 시 허용 가능한 최대 데이터 손실량(시간 단위). 로봇의 제어 주기(예: 10ms)에 근접할수록 우수하나, 디스크 I/O 오버헤드와 트레이드오프(Trade-off) 관계를 갖는다.
- 복구 시간 목표 (Recovery Time Objective, RTO): 시스템 재시작 후 블랙보드 상태를 완전히 복원하여 제어 루프를 재가동하는 데 걸리는 시간.
상태 복원은 단순한 파일 덮어쓰기로 해결될 수 없다. 크래시가 ’디스크 쓰기 작업 도중’에 발생할 경우 데이터 파일이 절반만 기록되어 오염(Corruption)되는 찢어진 쓰기(Torn Write) 현상이 발생하기 때문이다.
9.3.2 WAL (Write-Ahead Logging) 및 검사점(Checkpointing) 아키텍처
블랙보드의 상태 영속성(Persistence)을 확보하면서도 실시간 제어 스레드의 지연(Latency)을 최소화하기 위해 **기록 선행 로그(Write-Ahead Logging, WAL)**와 검사점(Checkpointing) 메커니즘을 결합하여 도입한다.
- 순차적 델타 로깅 (Sequential Delta Logging - WAL):
- 블랙보드의 특정 키(Key) 데이터가 갱신(
set)될 때마다, 전체 상태를 디스크에 저장하지 않는다. 대신 변경된 데이터의 델타(Delta: 키, 타입, 직렬화된 값, 타임스탬프)만을 로그 파일의 끝에 순차적으로 추가(Append-only)한다. - 순차 쓰기는 디스크의 탐색 시간(Seek Time)을 유발하지 않으므로 O(1)의 시간 복잡도로 매우 빠르게 수행된다.
- 주기적 검사점 (Periodic Checkpointing):
- 로그 파일이 무한히 커지는 것을 방지하고 RTO를 단축하기 위해, 백그라운드 스레드가 주기적으로(예: 1분마다 또는 특정 임무 완수 시) 블랙보드의 전체 스냅샷(Snapshot)을 새로운 파일로 덤프(Dump)한다.
- 덤프가 성공적으로 완료되면 기존의 WAL 로그 파일은 삭제(Truncate)된다.
- 원자적 파일 교체 (Atomic File Rename):
- 검사점 스냅샷을 작성할 때는 항상 임시 파일(예:
blackboard.ckpt.tmp)에 먼저 기록한다. 기록이 완전히 끝난 후, OS 수준의 원자적 시스템 콜(예: POSIXrename())을 사용하여 기존 파일을 덮어쓴다. 이를 통해 찢어진 쓰기(Torn Write)를 원천적으로 방지한다.
9.3.3 데이터 무결성 검증 (Integrity Verification)
크래시 후 복원 단계(Reconstruction Phase)에서 읽어들인 로그 데이터가 오염되지 않았음을 수학적으로 증명하기 위해 해시(Hash) 기반의 체크섬(Checksum)을 사용한다.
각 로그 엔트리 e_i는 페이로드 D_i와 함께 순환 중복 검사(CRC32) 알고리즘을 통한 해시값 H(D_i)를 꼬리표(Footer)로 갖는다. 복원 시 H_{calc}(D_i) \neq H_{stored}(D_i)일 경우, 해당 엔트리부터는 크래시 시점에 오염된 데이터로 간주하여 복원을 중단(Discard)하고 이전까지의 유효한 상태만 시스템에 적재한다.
9.3.4 C++ 기반 영속성(Persistence) 블랙보드 구현 명세
아래는 C++ 파일 입출력(I/O)과 JSON 직렬화(Serialization)를 활용하여 WAL 방식의 복구 메커니즘을 구현한 PersistentBlackboard 설계 명세이다. (단일 프로세스 로컬 블랙보드 기준)
#include <iostream>
#include <fstream>
#include <string>
#include <unordered_map>
#include <any>
#include <shared_mutex>
#include <filesystem>
#include <nlohmann/json.hpp>
// 간단한 CRC32 해시 함수 (무결성 검증용)
uint32_t calculate_crc32(const std::string& data) {
uint32_t crc = 0xFFFFFFFF;
for (char c : data) {
crc ^= static_cast<uint8_t>(c);
for (int i = 0; i < 8; ++i) {
crc = (crc & 1) ? (crc >> 1) ^ 0xEDB88320 : crc >> 1;
}
}
return ~crc;
}
class PersistentBlackboard
{
public:
PersistentBlackboard(const std::string& wal_file_path)
: wal_file_path_(wal_file_path)
{
// 1. 부팅 시 이전 상태 복원(Reconstruction) 수행
recoverState();
// 2. WAL 파일을 Append 모드로 개방 (쓰기 지연 최소화)
wal_stream_.open(wal_file_path_, std::ios::app | std::ios::binary);
if (!wal_stream_.is_open()) {
throw std::runtime_error("Failed to open WAL file for Blackboard.");
}
}
~PersistentBlackboard()
{
if (wal_stream_.is_open()) {
wal_stream_.close();
}
}
// 데이터 쓰기 및 WAL 로깅
template <typename T>
void set(const std::string& key, const T& value)
{
std::unique_lock<std::shared_mutex> lock(mutex_);
// 1. 로컬 메모리 갱신
storage_[key] = value;
// 2. JSON 직렬화 (이 명세에서는 기본 자료형 변환만 추상화하여 가정)
nlohmann::json log_entry;
log_entry["k"] = key;
log_entry["v"] = value; // 타입에 따른 직렬화 특화(Specialization)가 필요함
std::string payload = log_entry.dump();
uint32_t checksum = calculate_crc32(payload);
// 3. WAL 파일에 순차 기록 (페이로드 길이 + 페이로드 + 체크섬)
uint32_t length = payload.size();
wal_stream_.write(reinterpret_cast<const char*>(&length), sizeof(length));
wal_stream_.write(payload.data(), length);
wal_stream_.write(reinterpret_cast<const char*>(&checksum), sizeof(checksum));
// 실시간성을 위해 강제 플러시(Flush) (단, 성능 트레이드오프 고려 필요)
wal_stream_.flush();
}
template <typename T>
bool get(const std::string& key, T& out_value) const
{
std::shared_lock<std::shared_mutex> lock(mutex_);
auto it = storage_.find(key);
if (it == storage_.end()) return false;
try {
out_value = std::any_cast<T>(it->second);
return true;
} catch (const std::bad_any_cast&) {
return false;
}
}
private:
// 시스템 크래시 이후 부팅 시 호출되는 복구 루틴
void recoverState()
{
if (!std::filesystem::exists(wal_file_path_)) return;
std::ifstream input_wal(wal_file_path_, std::ios::binary);
if (!input_wal.is_open()) return;
int recovered_count = 0;
while (input_wal.peek() != EOF) {
uint32_t length = 0;
input_wal.read(reinterpret_cast<char*>(&length), sizeof(length));
if (input_wal.eof()) break; // 크래시로 인한 잘린 데이터
std::string payload(length, '\0');
input_wal.read(&payload[0], length);
uint32_t stored_checksum = 0;
input_wal.read(reinterpret_cast<char*>(&stored_checksum), sizeof(stored_checksum));
// 무결성 검증
if (calculate_crc32(payload) != stored_checksum) {
// 체크섬 불일치: 디스크 쓰기 중 크래시 발생 지점. 이후 로그 폐기
std::cerr << "[Recovery] WAL corruption detected. Halting recovery at valid state.\n";
break;
}
// 역직렬화 및 메모리 적재
try {
auto log_entry = nlohmann::json::parse(payload);
std::string key = log_entry["k"];
// 구현 편의상 int형으로 캐스팅 (실제로는 타입 메타데이터 기반 팩토리 패턴 필요)
int value = log_entry["v"];
storage_[key] = value;
recovered_count++;
} catch (...) {
std::cerr << "[Recovery] JSON parsing error in WAL.\n";
break;
}
}
std::cout << "[Recovery] Successfully restored " << recovered_count << " state changes.\n";
}
std::string wal_file_path_;
std::ofstream wal_stream_;
std::unordered_map<std::string, std::any> storage_;
mutable std::shared_mutex mutex_;
};
9.3.5 아키텍처의 공학적 의의
이러한 상태 복원(State Reconstruction) 아키텍처는 행동 트리 기반의 ROS2 로봇이 물리적 전원 단절이나 소프트웨어 결함으로부터 자가 복구(Self-Healing)할 수 있는 수학적 기반을 제공한다.
O(1)의 WAL 기록 방식은 로봇의 실시간 제어 스레드 데드라인(Deadline)을 위반하지 않으며, 무결성 검증 파이프라인은 오염된 데이터를 바탕으로 로봇이 오작동(Malfunction)하는 것을 구조적으로 차단하여 시스템의 결함 허용성(Fault Tolerance)을 극대화한다.
9.4 지식 소스(Knowledge Source)별 읽기/쓰기 권한(Permission)의 엄격한 분리
전통적인 블랙보드(Blackboard) 아키텍처는 시스템 내의 모든 컴포넌트(지식 소스)가 중앙 데이터 저장소에 자유롭게 접근하여 데이터를 읽고(Read) 쓸(Write) 수 있는 개방형(Open) 구조를 취한다. 그러나 자율주행 차량이나 수술용 로봇과 같은 안전 필수(Safety-Critical) 환경에서는 이러한 구조가 치명적인 보안 및 안정성 결함을 유발한다.
예를 들어, 해킹되거나 오작동하는 비전 인식 노드(Vision Node)가 배터리 관리 시스템(BMS)이 관장해야 할 battery_level 키(Key)의 값을 임의로 조작(Overwrite)할 경우, 전체 로봇 시스템의 생존성이 위협받는다. 따라서 소프트웨어 공학의 **최소 권한의 원칙(Principle of Least Privilege, PoLP)**을 블랙보드 메모리 접근 계층에 수학적이고 구조적으로 강제해야 한다.
9.4.1 학술적 배경: 접근 제어 행렬(Access Control Matrix)과 역량(Capability) 모델
시스템의 보안 정책은 주체(Subject, 지식 소스 S)와 객체(Object, 블랙보드의 데이터 키 K) 간의 권한(Privilege, P)을 정의하는 수학적 매핑으로 정형화된다. 접근 제어 행렬 M은 다음과 같이 정의된다.
M : S \times K \rightarrow \mathcal{P}(\{READ, WRITE\})
SROS2가 네트워크 계층(DDS)에서의 패킷 접근을 통제한다면, 단일 프로세스(Intra-Process) 내에서 구동되는 로컬 블랙보드는 힙(Heap) 메모리 접근을 통제해야 하므로 역량 기반 보안(Capability-based Security) 모델을 도입한다. 각 지식 소스는 초기화(Initialization) 시점에 중앙 인가자(Authenticator)로부터 위조 불가능한 암호학적 토큰(Token)이나 전용 핸들(Handle)을 발급받아야만 특정 키에 접근할 수 있다.
9.4.2 아키텍처 설계 원칙
- 네임스페이스(Namespace) 기반 권한 분리: 블랙보드의 키(Key)를 도메인별로 계층화(예:
sensor.lidar.scan,system.battery.level)하고, 각 지식 소스는 자신에게 할당된 네임스페이스 영역에만 쓰기(Write) 권한을 갖는다. - 불변성(Immutability) 강제: 한 번 생성된 접근 권한 토큰은 런타임(Runtime)에 변경되거나 다른 스레드로 양도(Delegation)될 수 없다.
- Fail-Safe 기본 정책: 명시적으로 허용되지 않은 모든 읽기/쓰기 요청은 거부(Deny)되며, 보안 예외(Security Exception)를 발생시킨다.
9.4.3 C++ 기반 보안 블랙보드 핸들(Secure Blackboard Handle) 구현 명세
전역 블랙보드 인스턴스에 직접 접근하는 것을 차단하고, 각 노드(지식 소스)마다 고유한 권한이 부여된 프록시(Proxy) 객체인 SecureBlackboardHandle을 제공하는 아키텍처이다.
#include <iostream>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <memory>
#include <stdexcept>
#include <rclcpp/rclcpp.hpp>
// 권한 비트마스크 정의
enum class AccessMode {
NONE = 0,
READ = 1,
WRITE = 2,
READ_WRITE = 3
};
// [프록시 클래스] 각 지식 소스(노드)에 주입되는 보안 핸들
class SecureBlackboardHandle
{
public:
SecureBlackboardHandle(const std::string& owner_id,
const std::unordered_map<std::string, AccessMode>& permissions,
class UniversalBlackboard* backend)
: owner_id_(owner_id), permissions_(permissions), backend_(backend) {}
// 쓰기(Write) 연산 전 권한 검증
template <typename T>
bool secureSet(const std::string& key, const T& value)
{
if (!hasPermission(key, AccessMode::WRITE)) {
RCLCPP_FATAL(rclcpp::get_logger("SecurityGuard"),
"[SECURITY VIOLATION] Node '%s' attempted unauthorized WRITE on key '%s'",
owner_id_.c_str(), key.c_str());
return false; // 권한 없음으로 인한 연산 거부
}
// 실제 백엔드 블랙보드에 데이터 기록 (5장 명세의 set 함수 호출)
backend_->set(key, value);
return true;
}
// 읽기(Read) 연산 전 권한 검증
template <typename T>
bool secureGet(const std::string& key, T& out_value) const
{
if (!hasPermission(key, AccessMode::READ)) {
RCLCPP_ERROR(rclcpp::get_logger("SecurityGuard"),
"[SECURITY VIOLATION] Node '%s' attempted unauthorized READ on key '%s'",
owner_id_.c_str(), key.c_str());
return false;
}
return backend_->get(key, out_value);
}
private:
bool hasPermission(const std::string& key, AccessMode required_mode) const
{
auto it = permissions_.find(key);
if (it == permissions_.end()) return false;
// 비트 연산을 통한 권한 확인
return (static_cast<int>(it->second) & static_cast<int>(required_mode)) != 0;
}
std::string owner_id_;
std::unordered_map<std::string, AccessMode> permissions_;
UniversalBlackboard* backend_; // 원본 전역 블랙보드 포인터
};
// [중앙 인가자] 시스템 초기화 시 각 노드에 권한을 부여하고 핸들을 생성하는 팩토리
class BlackboardSecurityManager
{
public:
static std::unique_ptr<SecureBlackboardHandle> allocateHandle(
const std::string& node_id,
UniversalBlackboard* backend)
{
std::unordered_map<std::string, AccessMode> perms;
// 시스템 아키텍처 명세에 따른 하드코딩된 권한 주입 (또는 보안 XML 파싱)
if (node_id == "battery_manager_node") {
perms["system.battery.level"] = AccessMode::WRITE;
perms["system.power_mode"] = AccessMode::READ_WRITE;
}
else if (node_id == "navigation_node") {
perms["system.battery.level"] = AccessMode::READ; // 배터리 잔량은 읽기만 가능
perms["nav.current_pose"] = AccessMode::WRITE;
}
else {
RCLCPP_WARN(rclcpp::get_logger("SecurityGuard"), "Unknown node id: %s", node_id.c_str());
}
return std::make_unique<SecureBlackboardHandle>(node_id, perms, backend);
}
};
9.4.4 아키텍처 검증 및 공학적 타당성
위 C++ 명세는 객체 지향 프로그래밍의 **프록시 패턴(Proxy Pattern)**을 활용하여 원본 메모리 공간(UniversalBlackboard)을 캡슐화(Encapsulation)한다.
각 ROS2 컴포저블 노드(지식 소스)는 UniversalBlackboard의 인스턴스에 직접 접근할 수 없으며, 오직 BlackboardSecurityManager가 발행한 SecureBlackboardHandle만을 의존성 주입(Dependency Injection) 방식으로 전달받아 사용해야 한다.
이러한 구조는 메모리 접근 권한을 컴파일 타임 및 초기화 타임에 확정 지음으로써, 런타임에 발생할 수 있는 버퍼 오버플로우(Buffer Overflow) 공격이나 권한 상승(Privilege Escalation)을 구조적으로 원천 차단한다. 또한, 비인가 접근 시도 발생 시 즉각적으로 RCLCPP_FATAL 로그를 남기도록 설계되어 있어, 고장 감지 및 격리(Fault Detection and Isolation, FDI) 시스템과 연동 시 치명적인 소프트웨어 결함을 신속하게 추적할 수 있는 확고한 감사 트레일(Audit Trail)을 제공한다.
10. 메모리 오염(Memory Corruption) 방어 및 블랙보드 데이터 무결성 암호화 검증
안전 필수(Safety-Critical) 로봇 시스템(예: 수술용 로봇, 자율주행 차량, 방산 무기 체계)이 네트워크에 연결됨에 따라, 해커의 침입이나 악의적인 내부 프로세스에 의한 메모리 변조 공격(Memory Tampering Attack) 위험이 기하급수적으로 증가하고 있습니다. SROS2가 네트워크 구간(In-Transit)의 패킷을 암호화하여 보호한다면, 본 명세는 로봇의 두뇌인 ‘행동 트리 블랙보드(In-Memory)’ 자체를 방어하는 아키텍처를 다룹니다.
비인가된 ROS2 노드나 버퍼 오버플로우(Buffer Overflow) 결함을 가진 컴포넌트가 블랙보드의 중요 제어 변수(예: is_human_detected = false로 강제 조작)를 오염시킬 경우, 시스템은 치명적인 오작동을 일으킵니다. 이를 방어하기 위해 불변성(Immutability) 강제 아키텍처와 암호화 해시 함수 기반의 데이터 무결성(Integrity) 검증 파이프라인을 도입해야 합니다.
10.1 메모리 오염(Memory Corruption) 방어 아키텍처
블랙보드는 본질적으로 여러 노드가 접근하는 공유 메모리이므로, 포인터(Pointer) 조작 오류나 허가되지 않은 덮어쓰기(Overwrite)로부터 원천적으로 격리되어야 합니다.
- 상태 불변성(State Immutability) 강제: C++의 원시 포인터(
Raw Pointer)나 가변 참조자(Mutable Reference)를 통한 데이터 공유를 엄격히 금지합니다. 블랙보드에 저장되는 모든 복잡한 데이터 구조체는std::shared_ptr<const T>형태로 캡슐화되어야 합니다. 이를 통해 데이터를 읽어간 지식 소스(노드)가 실수로 메모리 번지수의 값을 변조하는 것을 언어적 수준에서 차단합니다. - 메모리 샌드박싱(Sandboxing) 및 RTTI 방어: 해커가 타입 스푸핑(Type Spoofing) 공격을 통해 악성 페이로드를 주입하는 것을 막기 위해,
std::any에 데이터를 삽입할 때 사전 정의된 타입 레지스트리(Type Registry)와 런타임 타입 정보(RTTI)를 해시값으로 비교하여 승인된 데이터 구조체만 메모리에 적재되도록 필터링합니다.
10.2 암호화 기반 데이터 무결성(Integrity) 검증 메커니즘
블랙보드 메모리 공간에 대한 권한 탈취(Privilege Escalation) 공격이 성공하여 데이터가 변조되더라도, 제어 노드가 이를 읽기(Read) 전에 ’조작된 데이터’임을 수학적으로 판별하여 기각(Reject)해야 합니다. 이를 위해 해시 기반 메시지 인증 코드(HMAC, Hash-based Message Authentication Code)를 도입합니다.
데이터 메시지 m과 비밀키 K가 주어질 때, 데이터의 무결성을 증명하는 서명 태그 t는 다음과 같이 연산됩니다.
t = HMAC(K, m) = H((K \oplus opad) \parallel H((K \oplus ipad) \parallel m))
(단, H는 SHA-256과 같은 암호학적 해시 함수, opad와 ipad는 패딩 상수, \parallel는 연쇄(Concatenation) 연산을 의미한다.)
- 쓰기(Write) 연산: 인가된 노드가 블랙보드에 데이터를 기록할 때, 자신의 고유한 대칭키 K를 사용하여 데이터 m의 직렬화(Serialization)된 바이트 배열에 대한 서명 태그 t를 생성하고 데이터와 함께 블랙보드에 저장합니다.
- 읽기(Read) 연산: 행동 트리 제어 노드가 데이터를 읽어올 때, 동봉된 서명 태그 t를 추출하고 동일한 알고리즘으로 t'를 자체 연산합니다. t == t'가 수학적으로 성립할 때만 데이터의 무결성을 신뢰하고 제어 로직에 반영합니다. 불일치할 경우 메모리 오염(Memory Corruption)으로 간주하고 해당 데이터를 폐기한 뒤 예외 복구(Fallback) 모드로 전환합니다.
10.2.1 C++ 구현 명세: HMAC 기반 무결성 검증 블랙보드
OpenSSL 라이브러리의 HMAC-SHA256 알고리즘을 활용하여, 데이터 기록 시 서명을 생성하고 읽기 시 무결성을 수학적으로 검증하는 안전한 블랙보드 설계 명세입니다.
#include <iostream>
#include <string>
#include <vector>
#include <unordered_map>
#include <shared_mutex>
#include <openssl/hmac.h>
#include <openssl/sha.h>
#include <rclcpp/rclcpp.hpp>
// 메모리에 저장될 보안 데이터 캡슐화 구조체
struct SecurePayload {
std::any data;
std::vector<uint8_t> hmac_signature;
};
class SecureRos2Blackboard
{
public:
static SecureRos2Blackboard& getInstance()
{
static SecureRos2Blackboard instance;
return instance;
}
// [쓰기 연산] 직렬화된 데이터 바이트 배열(serialized_bytes)에 대한 HMAC 서명 생성 및 적재
template <typename T>
bool set_secure(const std::string& key, const T& value,
const std::vector<uint8_t>& serialized_bytes,
const std::string& secret_key)
{
std::unique_lock<std::shared_mutex> lock(mutex_);
SecurePayload payload;
payload.data = std::make_shared<const T>(value); // 불변성(Immutability) 강제
payload.hmac_signature = generate_hmac(serialized_bytes, secret_key);
storage_[key] = payload;
return true;
}
// [읽기 연산] 데이터 추출 전 HMAC 서명 무결성 검증
template <typename T>
bool get_secure(const std::string& key, std::shared_ptr<const T>& out_value,
const std::vector<uint8_t>& serialized_bytes_to_verify,
const std::string& secret_key) const
{
std::shared_lock<std::shared_mutex> lock(mutex_);
auto it = storage_.find(key);
if (it == storage_.end()) {
return false;
}
// 1. 저장되어 있는 페이로드 및 서명 추출
const SecurePayload& payload = it->second;
// 2. 검증을 위한 HMAC 재연산
std::vector<uint8_t> expected_hmac = generate_hmac(serialized_bytes_to_verify, secret_key);
// 3. 서명 일치 여부 확인 (메모리 오염/변조 검증)
// 상수 시간(Constant-time) 비교를 수행하여 타이밍 공격(Timing Attack) 방어
if (!CRYPTO_memcmp(payload.hmac_signature.data(), expected_hmac.data(), SHA256_DIGEST_LENGTH) == 0) {
RCLCPP_FATAL(rclcpp::get_logger("SecureBlackboard"),
"[SECURITY BREACH] Memory corruption or tampering detected at key: '%s'!", key.c_str());
return false; // 무결성 위반 시 데이터 접근 전면 차단
}
// 4. 서명이 유효한 경우에만 안전하게 타입 캐스팅 후 반환
try {
out_value = std::any_cast<std::shared_ptr<const T>>(payload.data);
return true;
} catch (const std::bad_any_cast& e) {
RCLCPP_ERROR(rclcpp::get_logger("SecureBlackboard"), "Type spoofing detected.");
return false;
}
}
private:
SecureRos2Blackboard() = default;
// OpenSSL을 이용한 HMAC-SHA256 해시 생성 함수
std::vector<uint8_t> generate_hmac(const std::vector<uint8_t>& data, const std::string& key) const
{
unsigned int len = SHA256_DIGEST_LENGTH;
std::vector<uint8_t> hash(len);
HMAC(EVP_sha256(),
key.c_str(), key.length(),
data.data(), data.size(),
hash.data(), &len);
return hash;
}
std::unordered_map<std::string, SecurePayload> storage_;
mutable std::shared_mutex mutex_;
};
10.2.2 아키텍처의 학술적/공학적 의의
이 보안 아키텍처는 **“Trust, but Verify (신뢰하되 검증하라)”**라는 정보 보안의 핵심 원칙을 로봇 메모리 계층에 적용한 것입니다.
- 내부자 위협(Insider Threat) 방어: 운영체제(OS) 수준에서 프로세스 권한이 탈취되어
std::unordered_map의 메모리 주소에 직접적인 헥스(Hex) 변조 공격이 가해지더라도, 공격자가 비밀키(Secret Key)를 알지 못하는 이상 유효한 HMAC 서명을 생성할 수 없습니다. - 페일세이프(Failsafe) 보장: 행동 트리의 제어 노드(예:
CheckBattery또는IsHumanDetected)가 오염된 데이터를 읽어 치명적인 오판을 내리기 직전에 암호학적 검증이 이를 차단합니다. - 단일 책임 원칙 고도화: 보안 로직을 블랙보드 컨테이너 내부에 캡슐화함으로써, 비즈니스 로직(행동 트리 노드 구현부)을 수정하지 않고도 시스템 전반의 메모리 신뢰도(Memory Reliability)를 군사/의료 규격 수준으로 끌어올릴 수 있습니다.
10.3 결함 주입(Fault Injection) 공격 시나리오에 대한 블랙보드 격리(Isolation) 대응
안전 필수(Safety-Critical) 로봇 시스템이 네트워크에 연결되거나 서드파티(Third-party) 플러그인 노드를 수용하게 되면서, 단일 지식 소스(Knowledge Source)의 오작동이나 악의적인 해킹으로 인한 결함 주입(Fault Injection) 공격의 위험성이 대두되었습니다.
블랙보드 아키텍처는 시스템의 전역 상태(Global State)를 중앙 집중적으로 관리하므로, 하나의 손상된(Compromised) 노드가 임계 변수(예: 장애물 거리, 목표 좌표)에 오염된 데이터를 덮어쓰게 되면 로봇 전체의 물리적 충돌이나 시스템 패닉(Crash)으로 직결됩니다. 이를 방어하기 위해 블랙보드 시스템은 데이터를 무비판적으로 수용하는 ’수동적 저장소’에서, 데이터를 검증하고 악의적 접근을 차단하는 **‘능동적 격리 구역(Isolation Zone)’**으로 진화해야 합니다.
10.3.1 학술적 위협 모델링 (Threat Modeling)
블랙보드를 향한 결함 주입 공격은 크게 세 가지 벡터(Vector)로 모델링됩니다.
- 자료형 혼란 공격 (Type Confusion Attack): C++의
std::any가 가진 타입 소실(Type Erasure) 특성을 악용하여, 정상적으로double타입이 저장되어야 할 키(Key)에 거대한std::vector나 잘못된 커스텀 구조체를 강제로 주입합니다. 이를 읽으려는 제어 노드가std::bad_any_cast예외를 처리하지 못하면 시스템이 강제 종료(Denial of Service, DoS)됩니다. - 경계값 위반 (Boundary Violation / Fuzzing): 자료형은 일치하나 논리적으로 불가능한 값(예: 배터리 잔량 -100%, 이동 속도 999m/s)이나 NaN(Not a Number)을 주입하여 하위 제어기의 수학적 연산(예: 0으로 나누기) 결함을 유도합니다.
- 고주파수 덮어쓰기 (High-frequency Overwrite / Flooding): 초당 수만 번의 쓰기(Write) 연산을 발생시켜 블랙보드의 상호 배제 락(
std::shared_mutex)을 지속적으로 점유함으로써, 정상적인 제어 노드가 데이터를 읽지 못하게 만드는 타이밍(Timing) 공격입니다.
10.3.2 데이터 DMZ 및 프록시(Proxy) 격리 아키텍처
위협을 원천 차단하기 위해, 지식 소스(노드)가 전역 블랙보드 메모리에 직접 접근(Direct Memory Access)하는 것을 엄격히 금지합니다. 대신, 블랙보드 검증 프록시(Blackboard Validation Proxy) 패턴을 도입하여 데이터가 실제 코어 메모리에 적재되기 전 거쳐야 하는 비무장지대(DMZ, Demilitarized Zone)를 형성합니다.
- 계약 기반 프로그래밍 (Contract-based Programming): 모든 블랙보드 키(Key)는 사전에 허용되는 자료형(Type), 최소/최대 경계값(Bounds), 그리고 최대 쓰기 주파수(Max Update Rate)에 대한 계약(Contract) 명세서를 가져야 합니다.
- 격리 및 폐기 (Quarantine & Drop): 프록시는 주입된 데이터가 계약을 위반할 경우 해당 쓰기 연산을 즉시 폐기(Drop)하고, 위반 횟수가 임계치를 초과하면 해당 노드의 접근 식별자를 영구적으로 격리(Quarantine)하여 블랙보드 접근을 차단합니다.
10.3.3 C++ 구현 명세: 결함 허용 및 격리형 블랙보드 가드 (Blackboard Guard)
다음은 앞서 언급한 세 가지 위협(타입 혼란, 경계값 위반, 고주파수 플러딩)을 메모리 레벨에서 차단하는 **능동적 보안 프록시(Security Proxy)**의 C++ 아키텍처 명세입니다.
#include <iostream>
#include <string>
#include <unordered_map>
#include <any>
#include <mutex>
#include <chrono>
#include <typeindex>
#include <rclcpp/rclcpp.hpp>
// 데이터 계약(Contract) 구조체 정의
struct DataContract {
std::type_index type_id; // 허용되는 정확한 자료형
double min_value; // 숫자형 데이터의 최소값
double max_value; // 숫자형 데이터의 최대값
double min_interval_ms; // Flooding 방지를 위한 최소 쓰기 간격 (ms)
// 마지막으로 성공적으로 쓰기가 수행된 타임스탬프 (보안 모니터링용)
std::chrono::time_point<std::chrono::steady_clock> last_update_time;
int violation_count = 0; // 공격/오류 누적 횟수
};
class SecureBlackboardGuard
{
public:
SecureBlackboardGuard(rclcpp::Node::SharedPtr ros_node)
: ros_node_(ros_node) {}
// 1. 보안 계약 등록 (시스템 초기화 시점에만 수행)
template <typename T>
void registerContract(const std::string& key, double min_val, double max_val, double interval_ms)
{
std::lock_guard<std::mutex> lock(guard_mutex_);
contracts_[key] = DataContract{
std::type_index(typeid(T)),
min_val, max_val, interval_ms,
std::chrono::steady_clock::now(),
0
};
}
// 2. 검증 기반의 안전한 데이터 쓰기 (Proxy Write)
template <typename T>
bool secureSet(const std::string& key, const T& value)
{
std::lock_guard<std::mutex> lock(guard_mutex_);
// 계약 존재 여부 확인 (미등록 키는 즉시 차단)
auto it = contracts_.find(key);
if (it == contracts_.end()) {
RCLCPP_WARN(ros_node_->get_logger(), "[SECURITY] Unregistered key access attempt: %s", key.c_str());
return false;
}
DataContract& contract = it->second;
// 노드 격리(Quarantine) 상태 확인
if (contract.violation_count > MAX_VIOLATIONS) {
RCLCPP_ERROR(ros_node_->get_logger(), "[SECURITY] Key '%s' is QUARANTINED due to repeated attacks.", key.c_str());
return false;
}
// 방어 1: 자료형 혼란(Type Confusion) 검증
if (std::type_index(typeid(T)) != contract.type_id) {
handleViolation(key, contract, "Type Confusion Attack Detected");
return false;
}
// 방어 2: 고주파수 덮어쓰기(Flooding/DoS) 검증
auto now = std::chrono::steady_clock::now();
double elapsed_ms = std::chrono::duration<double, std::milli>(now - contract.last_update_time).count();
if (elapsed_ms < contract.min_interval_ms) {
handleViolation(key, contract, "High-Frequency Write Attack (Flooding) Detected");
return false;
}
// 방어 3: 경계값(Boundary) 위반 검증 (산술 타입인 경우에만 컴파일 타임에 분기)
if constexpr (std::is_arithmetic_v<T>) {
if (value < contract.min_value || value > contract.max_value) {
handleViolation(key, contract, "Boundary Violation (Out of Range) Detected");
return false;
}
}
// 모든 보안 검증 통과 -> 실제 코어 메모리(가상의 storage)에 데이터 적재
core_storage_[key] = value;
contract.last_update_time = now;
// 정상 갱신 시 위반 카운트 차감 (Recovery mechanism)
if (contract.violation_count > 0) contract.violation_count--;
return true;
}
private:
void handleViolation(const std::string& key, DataContract& contract, const std::string& reason)
{
contract.violation_count++;
RCLCPP_ERROR(ros_node_->get_logger(), "[FAULT INJECTION BLOCKED] Key: %s | Reason: %s | Violations: %d",
key.c_str(), reason.c_str(), contract.violation_count);
}
static constexpr int MAX_VIOLATIONS = 5; // 격리 임계값
rclcpp::Node::SharedPtr ros_node_;
std::mutex guard_mutex_;
std::unordered_map<std::string, DataContract> contracts_;
std::unordered_map<std::string, std::any> core_storage_; // 보호받는 실제 블랙보드 코어
};
10.3.4 아키텍처의 공학적 의의
이러한 격리형 블랙보드 프록시 아키텍처는 시스템 프로그래밍 관점에서 다음과 같은 강력한 방어 기제를 제공합니다.
- 결정론적 방어 (Deterministic Defense): 런타임에 데이터가
core_storage_에 적재되기 전if constexpr와std::type_index를 통해 수학적, 타입적 무결성이 컴파일 타임과 런타임 양단에서 완벽히 교차 검증됩니다. - 연쇄 장애(Cascading Failure) 차단: 특정 센서 드라이버가 고장나 쓰레기 값(Garbage Value)을 초당 1,000번씩 쏟아내더라도, 프록시의
min_interval_ms검증 로직에 의해 즉각 폐기(Drop)됩니다. 따라서 하위의 자율 주행 제어기나 안전 감시(Safety Watchdog) 노드들은 오염된 데이터를 전혀 읽을 수 없으며, 로봇의 물리적 오작동이 원천적으로 차단됩니다. - 오류 자가 격리 (Self-Quarantine): 임계치(
MAX_VIOLATIONS)를 초과한 공격 포인트는 논리적으로 차단되어 블랙보드의 락(Lock) 자원을 고갈시키지 못하게 만듭니다. 이는 다중 에이전트 시스템(MAS)이나 제로 트러스트(Zero Trust) 로봇 아키텍처를 구축하기 위한 필수적인 소프트웨어 보안 패턴입니다.