Devin.KR

ROS 2 가 필요한 이유 - 로봇 소프트웨어를 조각내는 법

개발자KR 조회 8

이 장에서 배우는 것

이 책은 작은 배달 로봇 두리를 예제로 삼아 장마다 소프트웨어를 조금씩 키워 나간다. 이번 장은 코드를 거의 쓰지 않는 첫 장이다. 대신 로봇 소프트웨어를 왜 하나의 큰 프로그램으로 짜지 않고 여러 조각으로 나누는지, 그 조각들이 어떻게 서로 말을 맞추는지를 먼저 정리한다. 이 그림이 머릿속에 있어야 다음 장부터 나오는 워크스페이스, 노드, 토픽 같은 개념이 왜 그런 모습을 하고 있는지 이해하기 쉽다.

  • 로봇 소프트웨어를 하나의 프로그램이 아니라 여러 조각으로 나누는 이유를 설명한다
  • 조각들 사이의 통신을 책임지는 미들웨어와 DDS의 역할을 이해한다
  • ROS 2의 배포판, 워크스페이스, 패키지라는 세 가지 개념을 구분한다
  • ros2 명령으로 설치 상태를 확인하는 방법을 익힌다
  • ROS 2 없이도 노드와 토픽 구조의 핵심을 순수 파이썬 코드로 확인한다

문제 상황

두리를 처음 만든 팀은 초음파 센서 값을 읽고, 장애물이 가까우면 멈추고, 아니면 모터를 굴리는 로직을 main.py 한 파일에 순서대로 적어 넣었다. 반복문 한 번에 센서를 읽고, 거리를 계산하고, 모터에 속도를 써 주는 구조다. 처음에는 이 방식이 가장 간단했다.

문제는 로봇이 조금씩 복잡해지면서 나타났다. 센서 제조사를 바꾸면서 드라이버 코드를 새로 짰는데, 같은 파일 안에 모터 제어 코드가 함께 있다 보니 수정할 때마다 전체 프로그램을 다시 빌드하고 다시 테스트해야 했다. 두 사람이 동시에 작업하려 하면 같은 파일을 고치다가 충돌이 났다. 나중에 카메라를 붙이고 주행 기록을 남기는 기능을 추가하려 하니 파일은 더 길어지고, 어디를 고쳐야 무엇이 바뀌는지 알기 어려워졌다.

이 문제의 핵심은 '센서를 읽는 일'과 '판단하는 일'과 '모터를 움직이는 일'이 코드 안에서 강하게 묶여 있다는 점이다. 세 가지 일을 서로 독립된 조각으로 떼어 놓고, 그 조각들이 정해진 방식으로만 데이터를 주고받게 하면 이런 문제를 줄일 수 있다. ROS 2는 바로 이 조각내기와 조각 간 통신을 표준화한 도구 모음이다.

로봇 소프트웨어는 왜 조각나야 하는가

두리의 소프트웨어를 다시 살펴보면 실제로는 성격이 다른 일들이 섞여 있다. 센서 값을 읽는 일은 하드웨어 타이밍에 민감하고, 장애물을 판단하는 일은 순수한 계산이고, 모터를 움직이는 일은 다시 하드웨어와 맞닿아 있다. 이 세 가지를 독립된 실행 단위로 떼어 놓으면 다음과 같은 이점이 생긴다.

먼저 한 조각만 고쳐서 다시 띄울 수 있다. 센서 드라이버를 바꿔도 판단 로직과 모터 제어 코드는 그대로 둔 채 센서 담당 프로그램만 재시작하면 된다. 다음으로 조각마다 다른 언어나 다른 실행 주기를 쓸 수 있다. 판단 로직은 파이썬으로 빠르게 실험하고, 모터 제어처럼 반응 속도가 중요한 부분은 다른 언어로 짜는 식의 조합이 가능하다. 마지막으로 한 조각이 죽더라도 나머지는 계속 움직일 여지가 생긴다. 로그를 남기는 조각이 잠시 멈췄다고 로봇이 그 자리에 멈춰 설 필요는 없다.

ROS 2에서는 이렇게 독립적으로 실행되는 조각을 노드(node)라고 부른다. 노드는 각자 하나의 일만 맡고, 서로 직접 함수를 호출하는 대신 정해진 통로로 데이터를 주고받는다. 이 통로를 토픽(topic)이라고 부르는데, 토픽과 노드를 실제로 만드는 방법은 뒤에서 다룬다. 지금은 '노드는 독립된 실행 단위이고, 토픽은 노드 사이의 통로'라는 관계만 기억해 두면 된다.

노드로 나누면 한 부분이 멈춰도 나머지는 계속 동작한다

위 그림의 왼쪽처럼 한 프로세스 안에 모든 로직을 넣으면 센서 코드의 버그 하나가 모터 제어까지 멈추게 할 수 있다. 오른쪽처럼 노드로 나누면 문제가 생긴 노드만 다시 켜면 되고, 나머지 노드는 영향을 받지 않는다. 다음 표는 이 차이를 항목별로 정리한 것이다.

모놀리식 구조와 노드로 나눈 구조의 차이
항목한 프로그램(모놀리식)노드로 나눈 구조
재시작 범위코드 한 줄만 고쳐도 전체 재시작바뀐 노드만 재시작
사용 언어프로그램 전체가 같은 언어노드마다 다른 언어 선택 가능
협업여러 사람이 같은 파일을 동시에 수정노드 단위로 작업을 나눌 수 있음
오류 전파한 부분의 예외가 전체를 멈춤한 노드가 죽어도 나머지는 계속 동작

노드들이 말을 맞추는 방법: 미들웨어와 DDS

노드를 따로 실행할 수 있다고 해도, 노드끼리 데이터를 주고받을 방법이 없으면 소용이 없다. 이 역할을 하는 소프트웨어 계층을 미들웨어(middleware)라고 부른다. 미들웨어는 '어떤 노드가 어디에서 실행되고 있는지 찾아 주고, 한 노드가 보낸 데이터를 관심 있는 다른 노드에게 전달해 주는' 일을 한다.

ROS 2는 이 미들웨어를 직접 새로 만들지 않고, 산업 현장에서 이미 쓰이던 DDS(Data Distribution Service)라는 표준을 기본으로 채택했다. DDS는 발행-구독(publish-subscribe) 방식으로 동작한다. 한 노드가 특정 이름의 토픽에 데이터를 발행하면, 같은 이름의 토픽을 구독하고 있는 다른 노드들이 그 데이터를 받는다. 발행하는 쪽과 구독하는 쪽은 서로가 어떤 프로그램인지, 어느 컴퓨터에서 돌고 있는지 몰라도 된다.

여기서 중요한 특징은 노드들을 이어 주는 중앙 서버가 없다는 점이다. 노드가 실행되면 DDS가 네트워크에서 같은 토픽을 쓰는 다른 노드를 자동으로 찾아 연결한다. 한 노드가 먼저 켜져 있든 나중에 켜지든, 토픽 이름과 메시지 형식만 맞으면 서로를 찾아 통신을 시작한다. 이 구조 덕분에 노드 하나를 껐다 켜도 나머지 노드를 다시 설정할 필요가 없다.

노드는 중앙 서버 없이 DDS 미들웨어로 서로를 직접 찾는다

토픽으로 데이터를 주고받는 구체적인 방법과 코드는 다음 장 이후에 다룬다. 이번 장에서는 '노드 사이의 통신은 DDS라는 미들웨어가 대신 처리해 주며, 개발자는 토픽 이름과 메시지 형식만 맞추면 된다'는 사실만 기억해 두면 충분하다.

배포판, 워크스페이스, 패키지

ROS 2를 설치하려고 하면 '배포판'이라는 이름을 고르게 된다. ROS 2는 매년 한 번씩 새 배포판(distribution)을 내놓고, 각 배포판에는 도시 이름을 딴 고유 이름이 붙는다. 이 책이 기준으로 삼는 배포판은 Jazzy Jalisco이고, 줄여서 jazzy라고 부른다. 배포판마다 지원하는 운영체제 버전과 포함된 패키지 버전이 다르므로, 예제를 따라 할 때는 책에서 쓰는 배포판과 같은 것을 설치해 두는 편이 문제를 줄인다. 정확한 지원 목록은 필요할 때 ROS 2 Jazzy 공식 문서에서 확인할 수 있다.

배포판을 설치했다고 해서 두리를 위한 코드까지 생기는 것은 아니다. 직접 짜는 노드 코드는 워크스페이스(workspace)라는 별도의 폴더 안에 둔다. 워크스페이스는 여러 개의 패키지(package)를 모아 놓고 한꺼번에 빌드하는 작업 공간이다. 패키지는 노드 코드, 설정 파일, 메시지 정의처럼 관련 있는 파일들을 묶어 놓은 배포 단위라고 생각하면 된다. 워크스페이스를 만들고 그 안에 첫 패키지를 올리는 과정은 다음 장에서 실습으로 다룬다. 지금은 '배포판은 ROS 2 자체의 버전, 워크스페이스는 내 코드를 담는 작업 폴더, 패키지는 그 안에서 코드를 묶는 단위'라는 세 층을 구분해 두면 된다.

설치 확인 명령

ROS 2가 제대로 설치되어 있는지는 터미널에서 몇 가지 명령으로 확인한다. 아래 명령들은 배포판이 설치되어 환경이 소싱된 상태를 가정한 예시 출력이며, 실제 값은 설치 환경에 따라 달라질 수 있다.

$ ros2 --version
ros2 cli version: 0.32.5

$ printenv ROS_DISTRO
jazzy

$ ros2 pkg list | head -n 3
action_msgs
action_tutorials_interfaces
actionlib_msgs

ros2 --version은 명령행 도구 자체가 실행되는지 확인하고, printenv ROS_DISTRO는 현재 터미널에 어느 배포판 환경이 소싱되어 있는지 보여 준다. ros2 pkg list는 현재 환경에서 찾을 수 있는 패키지 목록을 나열하므로, 출력이 비어 있으면 환경 설정 파일을 소싱하지 않았을 가능성이 크다.

설치 확인 명령이 알려 주는 것
명령확인하는 것정상일 때 예시
ros2 --versionros2 명령행 도구 설치 여부ros2 cli version: 0.32.5
printenv ROS_DISTRO현재 터미널에 소싱된 배포판jazzy
ros2 pkg list환경에서 찾을 수 있는 패키지 목록패키지 이름이 여러 줄 출력

완성 코드

이 장은 ROS 2 코드를 아직 쓰지 않는다. 대신 노드와 토픽의 핵심인 '발행하는 쪽과 구독하는 쪽이 서로를 몰라도 데이터가 전달된다'는 개념을, ROS 2 없이도 순수 파이썬만으로 확인해 본다. 아래 코드는 두리의 거리 센서 값을 흉내 낸 값을 간단한 버스에 발행하고, 판단 노드 역할을 하는 객체가 그 값을 구독해 정지 여부를 결정한다. 실제 rclpy로 노드를 만드는 방법은 노드를 다루는 다음다음 장에서 배운다.

duri_bus_sim.py

"""ROS 없이 노드-토픽 구조의 핵심(발행-구독)을 확인하는 최소 예제"""
from dataclasses import dataclass
from typing import Callable, Dict, List


@dataclass
class DistanceMsg:
    meters: float


class SimpleBus:
    def __init__(self) -> None:
        self._subscribers: Dict[str, List[Callable[[DistanceMsg], None]]] = {}

    def subscribe(self, topic: str, callback: Callable[[DistanceMsg], None]) -> None:
        self._subscribers.setdefault(topic, []).append(callback)

    def publish(self, topic: str, msg: DistanceMsg) -> None:
        for callback in self._subscribers.get(topic, []):
            callback(msg)


class DistanceSensorNode:
    def __init__(self, bus: SimpleBus, topic: str) -> None:
        self._bus = bus
        self._topic = topic

    def scan_once(self, meters: float) -> None:
        msg = DistanceMsg(meters=meters)
        print(f"[센서] {meters:.2f} m 거리를 측정해 '{self._topic}' 토픽으로 발행한다")
        self._bus.publish(self._topic, msg)


class StopDecisionNode:
    def __init__(self, bus: SimpleBus, topic: str, stop_below: float) -> None:
        self._stop_below = stop_below
        bus.subscribe(topic, self._on_distance)

    def _on_distance(self, msg: DistanceMsg) -> None:
        if msg.meters < self._stop_below:
            print(f"[판단] {msg.meters:.2f} m 는 {self._stop_below} m 미만이라 '정지'를 결정한다")
        else:
            print(f"[판단] {msg.meters:.2f} m 는 안전 거리라 '주행'을 유지한다")


def main() -> None:
    bus = SimpleBus()
    sensor = DistanceSensorNode(bus, topic="/duri/front_distance")
    StopDecisionNode(bus, topic="/duri/front_distance", stop_below=0.3)

    for meters in (1.20, 0.55, 0.18):
        sensor.scan_once(meters)


if __name__ == "__main__":
    main()

줄별 해설

  • DistanceMsg는 거리 값 하나만 담는 데이터 클래스다. 실제 ROS 2에서 메시지 타입이 하는 역할을 최소한으로 흉내 낸 것이다.
  • SimpleBus는 토픽 이름을 키로 삼아 구독 콜백을 저장하는 딕셔너리를 갖는다. subscribe는 특정 토픽에 콜백을 등록하고, publish는 그 토픽에 등록된 콜백을 모두 호출한다. DDS가 하는 일을 아주 단순화하면 이 두 메서드에 가깝다.
  • DistanceSensorNode는 자신이 어떤 토픽에 발행할지만 알고 있을 뿐, 누가 그 값을 구독하는지는 전혀 모른다. scan_once를 호출할 때마다 메시지를 만들어 버스에 발행한다.
  • StopDecisionNode는 생성자에서 버스에 자신을 구독자로 등록한다. 이 노드 역시 어떤 노드가 데이터를 보내는지 몰라도, 정해진 토픽 이름만 알면 값을 받을 수 있다.
  • main 함수는 버스 하나에 센서 역할과 판단 역할을 연결한 뒤, 거리 값 세 개를 순서대로 흘려보낸다. 두 노드는 서로의 존재를 직접 참조하지 않고 오직 토픽 이름 /duri/front_distance로만 연결되어 있다.

이 예제는 하나의 파이썬 프로세스 안에서 함수 호출로 흉내 낸 것이라서, 실제 DDS처럼 여러 프로세스나 여러 컴퓨터에 걸쳐 동작하지는 않는다. 하지만 '발행자와 구독자가 서로를 몰라도 토픽 이름만으로 연결된다'는 핵심 아이디어는 그대로 확인할 수 있다.

실행 결과

터미널에서 다음과 같이 실행하면 세 번의 발행과 세 번의 판단이 순서대로 출력된다.

python3 duri_bus_sim.py
[센서] 1.20 m 거리를 측정해 '/duri/front_distance' 토픽으로 발행한다
[판단] 1.20 m 는 안전 거리라 '주행'을 유지한다
[센서] 0.55 m 거리를 측정해 '/duri/front_distance' 토픽으로 발행한다
[판단] 0.55 m 는 안전 거리라 '주행'을 유지한다
[센서] 0.18 m 거리를 측정해 '/duri/front_distance' 토픽으로 발행한다
[판단] 0.18 m 는 0.3 m 미만이라 '정지'를 결정한다

실제 ROS 2 환경에서 ros2 run으로 두 노드를 각각 다른 터미널에서 실행하면, 센서 노드의 로그와 판단 노드의 로그가 서로 다른 창에 찍힌다. 위 예제는 이해를 돕기 위해 한 프로세스, 한 터미널에 로그를 몰아서 출력한 것뿐이다.

실무에서 자주 틀리는 것

노드를 그냥 '함수 여러 개'로 착각한다

노드는 독립적으로 실행되고 종료될 수 있는 단위여야 의미가 있다. 아래처럼 한 프로세스 안에서 함수만 나눠 놓고 '노드 세 개'라고 부르면, 조각내기의 장점(부분 재시작, 부분 장애 격리)을 하나도 얻지 못한다.

# 틀린 접근: 한 프로세스 안에서 함수만 분리
def sensor_step(): ...
def decide_step(): ...
def motor_step(): ...

def main():
    while True:
        sensor_step()
        decide_step()
        motor_step()
# 고친 접근: 각자 독립 실행되는 노드로 분리하고
# 토픽으로만 데이터를 주고받는다(구체적인 노드 작성법은 다음 장부터)
# ros2 run duri_bringup sensor_node
# ros2 run duri_bringup decision_node
# ros2 run duri_bringup motor_node

배포판 환경을 섞어서 소싱한다

여러 배포판을 함께 설치해 두고 터미널마다 다른 배포판의 setup.bash를 겹쳐서 소싱하면, ros2 pkg list에 엉뚱한 버전의 패키지가 섞여 나오거나 노드가 서로를 찾지 못하는 문제가 생긴다.

# 틀린 예: 두 배포판을 한 터미널에서 모두 소싱
source /opt/ros/humble/setup.bash
source /opt/ros/jazzy/setup.bash
# 고친 예: 이번 세션에서 쓸 배포판 하나만 소싱
source /opt/ros/jazzy/setup.bash
printenv ROS_DISTRO   # jazzy 인지 확인

환경을 소싱하지 않고 rclpy를 바로 import한다

ROS 2를 설치만 해 두고 터미널에서 환경 설정 파일을 소싱하지 않으면, 파이썬이 rclpy 패키지를 찾지 못해 실행이 즉시 실패한다.

# 틀린 예: 소싱 없이 바로 실행
$ python3 -c "import rclpy"
ModuleNotFoundError: No module named 'rclpy'
# 고친 예: 배포판 환경을 먼저 소싱한다
$ source /opt/ros/jazzy/setup.bash
$ python3 -c "import rclpy"

DDS 통신을 신경 쓰지 않아도 된다고 생각한다

DDS는 기본적으로 로컬 네트워크에서 노드를 자동으로 찾는다. 같은 컴퓨터나 같은 네트워크에 여러 사람이 ROS 2를 동시에 켜 두면, 서로 다른 실습용 로봇의 토픽이 뒤섞여 보일 수 있다. 이런 상황은 ROS_DOMAIN_ID 환경 변수로 통신 범위를 나눠서 피한다.

# 틀린 예: 모두가 기본 도메인 0번을 그대로 사용
$ ros2 topic list
# 옆자리 로봇의 토픽까지 함께 보임
# 고친 예: 실습마다 고유한 도메인 번호를 지정
$ export ROS_DOMAIN_ID=42
$ ros2 topic list

한눈에 보기

이 장에서 등장한 용어
용어뜻이 장의 예
노드독립적으로 실행되는 소프트웨어 조각센서 노드, 판단 노드, 모터 노드
토픽노드 사이에서 메시지가 오가는 통로 이름/duri/front_distance
미들웨어노드를 찾아 연결하고 메시지를 전달하는 계층DDS
DDSROS 2가 기본으로 쓰는 발행-구독 통신 표준중앙 서버 없이 노드를 자동 탐색
배포판ROS 2 자체의 연간 릴리스 버전Jazzy Jalisco
워크스페이스내 패키지를 모아 빌드하는 작업 폴더다음 장에서 만들 예정
패키지노드 코드와 설정을 묶은 배포 단위다음 장에서 만들 예정

연습 문제

  1. 두리의 main.py 한 파일짜리 프로그램을 노드 단위로 나눈다면 최소 몇 개의 노드가 필요한지, 그 근거를 함께 설명하라.
  2. ROS 2가 중앙 서버 없이도 노드들이 서로를 찾을 수 있는 이유를 미들웨어 관점에서 설명하라.
  3. printenv ROS_DISTRO의 결과가 비어 있을 때 의심해야 할 것을 두 가지 쓰라.
  4. 완성 코드의 SimpleBus를 실제 ROS 2의 토픽 통신과 비교했을 때 빠져 있는 기능을 최소 두 가지 들라.

정답과 해설

  1. 최소 세 개다. 센서 읽기, 장애물 판단, 모터 제어는 각각 성격이 다른 일(하드웨어 입력, 순수 계산, 하드웨어 출력)이고, 하나가 바뀔 때 나머지에 영향을 주지 않으려면 독립된 실행 단위로 나눠야 한다. 로그 기록이나 카메라 처리를 추가한다면 노드 수는 더 늘어날 수 있다.
  2. ROS 2는 미들웨어로 DDS를 쓰는데, DDS는 노드가 실행되는 순간 네트워크에서 같은 토픽을 쓰는 다른 노드를 자동으로 찾는 탐색 기능을 내장하고 있다. 노드들은 이 기능 덕분에 서로의 위치나 실행 순서를 미리 약속해 둘 필요 없이, 토픽 이름과 메시지 형식만 맞으면 연결된다.
  3. 먼저 해당 터미널에서 배포판의 setup.bash를 소싱하지 않았을 가능성이 있다. 다음으로 ROS 2 자체가 설치되어 있지 않거나, 설치 경로가 /opt/ros/jazzy와 다른 위치일 가능성이 있다.
  4. 적어도 두 가지가 빠져 있다. 첫째, SimpleBus는 같은 파이썬 프로세스 안에서만 동작하므로 서로 다른 프로세스나 컴퓨터에 있는 노드 사이의 통신은 처리하지 못한다. 둘째, 메시지를 네트워크로 보내기 위한 직렬화나 QoS(전달 신뢰성, 큐 크기 등 통신 품질 설정) 같은 기능이 전혀 없다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.