Devin.KR

디버깅 도구 - rqt·ros2 bag·rviz2 맛보기

개발자KR 조회 3

이 장에서 배우는 것

지금까지 두리는 노드 여러 개를 launch 파일 하나로 띄울 수 있게 됐다. 문제는 노드 수가 늘어날수록 "지금 누가 누구에게 무엇을 보내고 있는가"를 눈으로 확인하기가 점점 어려워진다는 점이다. 터미널에 찍히는 로그만으로는 연결 관계를 알기 어렵고, 간헐적으로만 나타나는 문제는 다시 재현하기도 번거롭다. 이 장에서는 ROS 2가 기본으로 제공하는 디버깅 도구 몇 가지를 다룬다.

  • rqt_graph로 노드와 토픽의 연결 관계를 그림으로 확인한다
  • 로그 레벨을 구분해서 평소에는 조용히, 문제가 생기면 자세히 볼 수 있게 만든다
  • ros2 bag으로 실행 중 오간 메시지를 녹화하고 다시 재생한다
  • rviz2로 숫자로만 보던 데이터를 그림으로 확인하는 감을 잡는다

문제 상황

두리 배달 로봇은 배터리 잔량을 재는 노드, 거리 센서 값을 흘리는 노드, 주행을 담당하는 노드, 배달 요청을 받는 서비스 노드까지 launch 파일 하나로 함께 뜨도록 만들어 놓았다. 그런데 어느 날 테스트 중에 두리가 장애물 앞에서 멈춘 채로 배달 요청에도 응답하지 않는 일이 벌어졌다. 터미널에는 에러 메시지가 하나도 없다. 노드는 다 떠 있는 것 같은데, 거리 센서 토픽이 주행 노드까지 실제로 연결돼 있는지 눈으로 확인할 방법이 마땅치 않다.

게다가 이 문제는 매번 일어나지 않는다. 원인을 찾으려면 문제가 났던 순간을 다시 만들어야 하는데, 그때마다 두리를 실제로 장애물 앞에 놓고 움직이게 하는 건 비효율적이다. 필요한 건 세 가지다. 노드들이 어떻게 연결돼 있는지 한눈에 보는 방법, 문제가 났던 순간의 데이터를 저장해뒀다가 다시 재생하는 방법, 그리고 평소에는 조용하다가 필요할 때만 자세한 로그를 볼 수 있는 방법이다.

rqt_graph로 배선 확인하기

ros2 node list와 ros2 topic list는 노드와 토픽의 이름을 텍스트로 보여주지만, "어떤 노드가 어떤 토픽을 구독하는가"까지는 한 줄씩 대조해봐야 한다. 노드가 네다섯 개만 넘어가도 이 대조 작업은 번거로워진다. rqt_graph는 이 관계를 그래프로 그려준다.

rqt_graph

실행하면 별도 창이 뜨고, 타원은 노드를, 사각형은 토픽을 나타내며, 화살표는 발행(→ 토픽) 또는 구독(토픽 →) 방향을 가리킨다. 기본 화면에는 /rosout, /parameter_events처럼 디버깅에 당장 필요 없는 토픽이 숨겨져 있는데, 왼쪽 위 체크박스에서 "Hidden" 항목을 켜면 전부 볼 수 있다.

이 그래프가 특히 쓸모 있는 순간은 토픽 이름이 미묘하게 다를 때다. 발행자와 구독자가 정확히 같은 문자열을 쓰지 않으면 두 노드는 서로 다른 토픽에 대고 이야기하는 셈이라 ROS 2 입장에서는 아무 문제가 없다. 로그에도 에러가 남지 않는다. 그저 메시지가 원하는 곳으로 가지 않을 뿐이다. rqt_graph를 보면 발행 화살표가 이어지지 않고 따로 떨어진 상자가 보이므로 바로 알아챌 수 있다.

발행자와 구독자의 토픽 이름이 다르면 그래프의 화살표가 이어지지 않는다

로그 레벨로 필요한 말만 듣기

rclpy의 로거는 심각도에 따라 다섯 단계를 제공한다. debug, info, warning, error, fatal 순으로 심각해진다. 기본 실행 레벨은 info이므로 debug로 남긴 메시지는 화면에 나타나지 않는다. 평소에는 이 편이 낫다. 원시 센서값까지 전부 찍히면 정작 중요한 경고를 로그 더미 속에서 놓치기 쉽다.

로그 레벨과 기본 노출 여부
레벨의미기본값(info)에서 보임예시
debug원인 분석용 자세한 값안 보임"배터리 원시값 수신: 87"
info정상적인 진행 알림보임"배터리 정상: 87%"
warning정상은 아니지만 즉시 멈출 정도는 아님보임"배터리 부족: 15%"
error어떤 기능이 실패함보임"거리 센서 오류값"
fatal노드가 더 진행할 수 없음보임(프로세스 종료 직전)

문제를 재현하는 순간에는 레벨을 낮춰서 자세히 봐야 한다. 노드를 실행할 때 --ros-args --log-level 옵션을 주면 되는데, 노드 전체에 적용할 수도 있고 특정 노드 이름만 지정할 수도 있다.

ros2 run durii_bringup durii_health_monitor --ros-args --log-level debug
ros2 run durii_bringup durii_health_monitor --ros-args --log-level durii_health_monitor:=debug

반복되는 로그를 매번 다 찍으면 그것대로 화면이 도배된다. 타이머 콜백처럼 자주 불리는 곳에서는 throttle_duration_sec을 넘겨서 일정 시간 동안 같은 자리의 로그를 한 번만 남기게 할 수 있다.

로그 레벨을 올릴수록 화면에 보이는 메시지 종류가 줄어든다

ros2 bag으로 녹화하고 rviz2로 다시 보기

간헐적으로만 나타나는 문제는 그 순간을 저장해두는 게 가장 확실하다. ros2 bag은 지정한 토픽에 오가는 메시지를 파일로 그대로 저장하고, 나중에 그 파일을 다시 재생해서 마치 그 상황이 다시 벌어지는 것처럼 노드들에게 흘려보낸다. 로봇을 다시 움직이지 않아도 같은 데이터로 몇 번이든 디버깅할 수 있다.

ros2 bag record -o durii_bag_2026_09_29 /durii/battery /durii/distance

-o 뒤에는 저장할 디렉터리 이름을 준다. 전체 토픽을 다 담고 싶으면 토픽 이름 대신 -a를 쓴다. 녹화를 멈추려면 Ctrl+C를 누른다. 저장된 내용은 ros2 bag info로 확인하고, ros2 bag play로 재생한다.

ros2 bag play durii_bag_2026_09_29

재생 중에는 다른 노드들이 실제 센서가 붙어 있을 때와 똑같이 그 토픽을 구독해서 반응한다. 여기에 rviz2를 함께 띄우면 숫자로만 보던 값을 그림으로 확인할 수 있다.

rviz2

rviz2를 처음 띄우면 화면은 비어 있다. 왼쪽 아래 Add 버튼으로 보고 싶은 데이터 종류(디스플레이)를 추가하고, 위쪽 Fixed Frame에 기준으로 삼을 좌표계 이름을 넣어야 값이 자리를 잡는다. 좌표계를 제대로 다루는 방법은 다음 장에서 다루므로, 이 장에서는 일단 데이터가 흘러가는지 눈으로 확인하는 수준으로 충분하다. 기록·재생 옵션은 공식 문서의 recording/playback 튜토리얼에 더 자세히 나와 있다.

ros2 bag은 실시간 데이터를 파일로 저장했다가 그대로 다시 재생한다

완성 코드

두 가지를 준비한다. 하나는 배터리와 거리 값을 받아 로그 레벨을 구분해서 남기는 실제 rclpy 노드이고, 다른 하나는 ROS 2 없이도 record/play의 동작 원리를 확인할 수 있는 순수 파이썬 예제다. 첫 번째 파일은 앞 장에서 만든 durii_bringup 패키지에 추가하고 setup.py의 entry_points에 등록한 뒤 콜론(colcon)으로 다시 빌드한다.

durii_health_monitor.py

import rclpy
from rclpy.node import Node
from std_msgs.msg import Int32, Float32


class DuriiHealthMonitor(Node):

    def __init__(self) -> None:
        super().__init__('durii_health_monitor')
        self._battery_sub = self.create_subscription(
            Int32, '/durii/battery', self._on_battery, 10)
        self._distance_sub = self.create_subscription(
            Float32, '/durii/distance', self._on_distance, 10)
        self.get_logger().info('배터리/거리 감시를 시작한다')

    def _on_battery(self, msg: Int32) -> None:
        self.get_logger().debug(f'배터리 원시값 수신: {msg.data}')
        if msg.data < 20:
            self.get_logger().warning(
                f'배터리 부족: {msg.data}%',
                throttle_duration_sec=5.0)
        else:
            self.get_logger().info(
                f'배터리 정상: {msg.data}%',
                throttle_duration_sec=10.0)

    def _on_distance(self, msg: Float32) -> None:
        self.get_logger().debug(f'거리 원시값 수신: {msg.data:.2f}')
        if msg.data < 0.0:
            self.get_logger().error(
                f'거리 센서 오류값: {msg.data:.2f}')


def main(args: list[str] | None = None) -> None:
    rclpy.init(args=args)
    node = DuriiHealthMonitor()
    try:
        rclpy.spin(node)
    except KeyboardInterrupt:
        pass
    finally:
        node.destroy_node()
        rclpy.shutdown()


if __name__ == '__main__':
    main()

bag_sim.py

"""ros2 bag의 record/play 동작을 순수 파이썬으로 흉내 낸다."""
from dataclasses import dataclass


@dataclass
class Sample:
    offset: float
    topic: str
    data: str


class BagRecorder:

    def __init__(self) -> None:
        self._samples: list[Sample] = []

    def record(self, offset: float, topic: str, data: str) -> None:
        self._samples.append(Sample(offset, topic, data))

    def samples(self) -> list[Sample]:
        return sorted(self._samples, key=lambda s: s.offset)


def play(samples: list[Sample], speed: float = 1.0) -> None:
    for sample in samples:
        played_at = sample.offset / speed
        print(f'[{played_at:5.2f}s] {sample.topic} -> {sample.data}')


def main() -> None:
    recorder = BagRecorder()
    recorder.record(0.0, '/durii/battery', 'battery=87')
    recorder.record(0.5, '/durii/distance', 'distance=1.20')
    recorder.record(1.0, '/durii/battery', 'battery=86')
    recorder.record(1.4, '/durii/distance', 'distance=0.95')
    recorder.record(2.0, '/durii/battery', 'battery=85')

    print('=== 원본 속도로 재생 ===')
    play(recorder.samples(), speed=1.0)

    print('=== 2배속으로 재생 ===')
    play(recorder.samples(), speed=2.0)


if __name__ == '__main__':
    main()

줄별 해설

durii_health_monitor.py

  • _on_battery는 원시값을 debug로 남긴 다음, 기준값(20) 아래면 warning, 아니면 info로 나눈다. 같은 메시지라도 심각도에 따라 다른 레벨을 쓰는 게 핵심이다.
  • throttle_duration_sec은 지정한 시간 동안 같은 로그 호출 지점에서 나온 메시지를 한 번만 실제로 출력하게 막아준다. 배터리처럼 값이 자주 바뀌는 토픽에서 로그 폭주를 막는다.
  • _on_distance는 음수처럼 있을 수 없는 값이 들어오면 error로 남긴다. warning과 달리 error는 센서 자체의 이상을 뜻하므로 구분해서 쓴다.

bag_sim.py

  • Sample은 실제 bag 파일 안에 저장되는 한 건의 메시지를 흉내 낸다. 녹화 시작부터 지난 시간(offset), 토픽 이름, 값을 담는다.
  • BagRecorder.record는 ros2 bag record가 하는 일과 같다. 메시지가 들어온 순간을 그대로 리스트에 쌓아둔다.
  • play는 ros2 bag play의 핵심 동작을 그대로 옮긴 것이다. 저장된 시간 간격을 배속(speed)으로 나눠서, 원본과 같은 순서 그리고 같은 상대적 간격으로 값을 내보낸다.

실행 결과

기본 레벨(info)로 실행하면 debug 로그는 보이지 않는다.

$ ros2 run durii_bringup durii_health_monitor
[INFO] [1738201200.102345] [durii_health_monitor]: 배터리/거리 감시를 시작한다
[INFO] [1738201201.204561] [durii_health_monitor]: 배터리 정상: 87%
[WARN] [1738201210.881023] [durii_health_monitor]: 배터리 부족: 15%

레벨을 debug로 낮추면 원시값까지 함께 보인다.

$ ros2 run durii_bringup durii_health_monitor --ros-args --log-level debug
[INFO] [1738201200.102345] [durii_health_monitor]: 배터리/거리 감시를 시작한다
[DEBUG] [1738201201.203102] [durii_health_monitor]: 배터리 원시값 수신: 87
[INFO] [1738201201.204561] [durii_health_monitor]: 배터리 정상: 87%
[DEBUG] [1738201202.301044] [durii_health_monitor]: 거리 원시값 수신: 1.20
[DEBUG] [1738201210.879654] [durii_health_monitor]: 배터리 원시값 수신: 15
[WARN] [1738201210.881023] [durii_health_monitor]: 배터리 부족: 15%

ros2 bag으로 녹화·재생·정보 확인은 대략 이런 식으로 보인다(수치는 실제 녹화 상황마다 달라진다).

$ ros2 bag record -o durii_bag_2026_09_29 /durii/battery /durii/distance
[INFO] [rosbag2_recorder]: Recording...
[INFO] [rosbag2_recorder]: Subscribed to topic '/durii/battery'
[INFO] [rosbag2_recorder]: Subscribed to topic '/durii/distance'

$ ros2 bag info durii_bag_2026_09_29
Files:             durii_bag_2026_09_29_0.db3
Storage id:        sqlite3
Duration:          12.4s
Messages:          143
Topic information: Topic: /durii/battery | Type: std_msgs/msg/Int32 | Count: 62
                    Topic: /durii/distance | Type: std_msgs/msg/Float32 | Count: 81

$ ros2 bag play durii_bag_2026_09_29
[INFO] [rosbag2_player]: Set rate to 1
[INFO] [rosbag2_player]: Playback until timeout reached or player is stopped.

rqt_graph와 rviz2는 GUI 창이므로 터미널에는 별다른 텍스트가 남지 않는다. rqt_graph를 실행하면 노드는 타원, 토픽은 사각형으로 그려진 창이 뜨고, rviz2를 실행하면 빈 3D 화면과 함께 Add 버튼, Fixed Frame 입력창이 있는 창이 뜬다.

순수 파이썬 예제는 ROS 2 없이도 바로 돌아간다.

$ python3 bag_sim.py
=== 원본 속도로 재생 ===
[ 0.00s] /durii/battery -> battery=87
[ 0.50s] /durii/distance -> distance=1.20
[ 1.00s] /durii/battery -> battery=86
[ 1.40s] /durii/distance -> distance=0.95
[ 2.00s] /durii/battery -> battery=85
=== 2배속으로 재생 ===
[ 0.00s] /durii/battery -> battery=87
[ 0.25s] /durii/distance -> distance=1.20
[ 0.50s] /durii/battery -> battery=86
[ 0.70s] /durii/distance -> distance=0.95
[ 1.00s] /durii/battery -> battery=85

실무에서 자주 틀리는 것

로그를 전부 info로만 찍는다

레벨을 나누지 않으면 평소엔 안 봐도 될 원시값까지 항상 쏟아지고, 정작 급한 경고는 그 사이에 묻힌다.

# 틀린 코드
self.get_logger().info(f'배터리 원시값 수신: {msg.data}')
self.get_logger().info(f'배터리 부족: {msg.data}%')
# 고친 코드
self.get_logger().debug(f'배터리 원시값 수신: {msg.data}')
self.get_logger().warning(f'배터리 부족: {msg.data}%')

토픽 이름을 하드코딩하면서 오타를 낸다

문자열이 한 글자만 달라도 ROS 2는 에러를 내지 않는다. 그저 서로 다른 토픽이 되어 메시지가 오가지 않을 뿐이다. rqt_graph로 보면 화살표가 이어지지 않은 상자가 바로 눈에 띈다.

# 틀린 코드
self._battery_sub = self.create_subscription(
    Int32, '/duri/battery', self._on_battery, 10)
# 고친 코드
self._battery_sub = self.create_subscription(
    Int32, '/durii/battery', self._on_battery, 10)

타이머 콜백에서 로그를 매 주기마다 찍는다

10Hz 타이머라면 1초에 열 번씩 같은 로그가 쌓여 정작 중요한 메시지를 찾기 어려워진다.

# 틀린 코드
def _on_timer(self) -> None:
    self.get_logger().info(f'현재 거리: {self._last_distance:.2f}')
# 고친 코드
def _on_timer(self) -> None:
    self.get_logger().info(
        f'현재 거리: {self._last_distance:.2f}',
        throttle_duration_sec=2.0)

같은 이름으로 ros2 bag record를 반복 실행한다

ros2 bag record는 이미 있는 디렉터리를 덮어쓰지 않고 에러를 내며 멈춘다. 실행할 때마다 새 이름을 지어야 한다.

# 틀린 명령
ros2 bag record -o durii_bag /durii/battery /durii/distance
# 다음 날 다시 녹화하면 이미 있는 디렉터리라 에러가 난다
ros2 bag record -o durii_bag /durii/battery /durii/distance
# 고친 명령
ros2 bag record -o durii_bag_2026_09_29 /durii/battery /durii/distance

한눈에 보기

이 장에서 다룬 디버깅 도구
도구확인 대상핵심 명령언제 쓰는가
rqt_graph노드-토픽 연결rqt_graph배선이 맞는지 눈으로 볼 때
로그 레벨코드가 남기는 말의 양--ros-args --log-level평소엔 조용히, 문제 생기면 자세히
ros2 bag실행 중 오간 메시지ros2 bag record/play/info간헐적 문제를 다시 재생해서 볼 때
rviz2센서·상태 데이터의 그림rviz2숫자보다 그림으로 확인하고 싶을 때

연습 문제

  1. rqt_graph를 실행했더니 durii_health_monitor 노드가 그래프에 아예 나타나지 않는다. 가능한 원인 한 가지와 확인 방법을 서술하라.
  2. 두리가 장애물을 못 피하는 문제가 간헐적으로만 발생한다. 이 문제를 재현해서 원인을 좁히려면 어떤 두 가지 도구를 어떤 순서로 쓰면 좋을지 서술하라.
  3. 10Hz로 도는 타이머 콜백 안에서 매번 self.get_logger().info(...)를 호출하는 코드를 봤다. 문제점과 고치는 방법을 서술하라.
  4. ros2 bag record -o durii_bag를 두 번째로 실행했더니 에러가 났다. 원인과 해결책을 서술하라.

정답과 해설

  1. 가장 흔한 원인은 노드가 아예 실행되지 않았거나, 실행은 됐지만 다른 프로세스에서 예외로 종료됐거나, 다른 ROS_DOMAIN_ID에서 떠 있는 경우다. 먼저 노드를 실행한 터미널에 에러가 없는지 보고, ros2 node list로 노드가 목록에 있는지 확인한다. 목록에 없다면 노드 자체가 죽은 것이고, 목록에는 있는데 rqt_graph에만 안 보인다면 Hidden 항목이 꺼져 있거나 도메인 ID가 다른 rqt_graph 창을 보고 있는 것이다.
  2. 먼저 문제가 나는 순간에 필요한 토픽들을 ros2 bag record로 녹화해서 상황을 저장해둔다. 그다음 ros2 bag play로 그 상황을 다시 재생하면서, 이번에는 로그 레벨을 debug로 올려 각 노드가 값을 어떻게 처리하는지 자세히 살펴본다. 녹화해두면 같은 상황을 몇 번이든 다시 만들 수 있어서 로봇을 매번 움직일 필요가 없다.
  3. 문제점은 로그 폭주다. 1초에 열 번씩 같은 자리에서 로그가 남으면 화면이 금방 그 메시지로 뒤덮이고 다른 중요한 로그를 찾기 어려워진다. throttle_duration_sec 인자를 추가해서 일정 시간 동안 같은 호출 지점의 로그를 한 번만 실제로 남기도록 고친다.
  4. ros2 bag record는 지정한 디렉터리가 이미 있으면 덮어쓰지 않고 에러를 내며 멈춘다. 첫 녹화에서 만든 durii_bag 디렉터리가 그대로 남아 있는데 같은 이름으로 다시 녹화를 시도했기 때문이다. 날짜나 상황을 담은 새 이름(예: durii_bag_2026_09_29)을 매번 지어주거나, 기존 디렉터리를 다른 곳으로 옮긴 뒤 다시 녹화한다.

댓글 0

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

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