Devin.KR

워크스페이스와 패키지 - colcon 으로 빌드하기

개발자KR 조회 3

이 장에서 배우는 것

앞 장에서는 로봇 소프트웨어를 왜 노드 단위로 쪼개야 하는지를 다뤘다. 이번 장에서는 그 조각들을 실제로 담을 그릇을 만든다. ROS 2 에서 코드는 아무 폴더에나 두고 python3 로 실행하는 것이 아니라, 워크스페이스(workspace)라는 상위 폴더 아래 패키지(package) 단위로 정리하고 colcon 이라는 도구로 빌드한다. 두리 로봇의 소프트웨어도 이번 장부터 이 구조 위에 쌓인다.

  • 워크스페이스의 src·build·install·log 폴더가 각각 무슨 일을 하는지 설명할 수 있다
  • ros2 pkg create 로 ament_python 패키지를 만들고 결과물을 읽을 수 있다
  • package.xml 과 setup.py 의 역할을 구분하고, 어디를 고쳐야 할지 판단할 수 있다
  • colcon build 와 source install/setup.bash 를 순서대로 실행하고 그 이유를 설명할 수 있다
  • 빌드나 실행이 안 될 때 흔한 원인 네 가지를 스스로 짚어낼 수 있다

문제 상황

두리 로봇 프로젝트를 두 사람이 나눠 맡았다고 하자. 한 사람은 배터리 잔량을 읽는 코드를, 다른 사람은 바퀴 모터를 제어하는 코드를 짠다. 처음에는 각자 홈 폴더 아래 아무 데나 battery.py, motor.py 를 만들고 python3 battery.py 로 돌려보는 식으로 개발했다. 코드가 한두 파일일 때는 문제가 없었다.

그런데 두 코드를 합쳐서 같은 프로그램 안에서 서로의 함수를 불러 써야 하는 순간, 문제가 터진다. 어느 폴더에서 실행하느냐에 따라 import 가 되기도 하고 안 되기도 한다. 노트북을 옮겨서 실행하면 경로가 또 달라진다. 다른 사람 컴퓨터에서는 "파일을 찾을 수 없다"는 오류가 난다. 코드는 똑같은데 실행 위치와 환경이 다르다는 이유만으로 동작이 갈린다.

ROS 2 는 이 문제를 파일을 어디에 두고 어떻게 빌드·설치할지에 대한 규칙으로 해결한다. 그 규칙을 지키는 최소 단위가 패키지이고, 패키지를 모아서 빌드하는 공간이 워크스페이스다. 규칙을 따르면 ros2 run 패키지이름 실행파일이름 한 줄로 어느 컴퓨터에서든 같은 결과를 얻는다.

워크스페이스란 무엇인가

워크스페이스는 하나 이상의 패키지를 모아두고 함께 빌드하는 상위 폴더다. 특별한 이름 규칙은 없지만 이 책에서는 ~/duri_ws 를 쓴다. 워크스페이스를 처음 만들 때 사람이 손으로 준비하는 폴더는 src/ 하나뿐이고, 그 안에 패키지 소스를 둔다. 나머지 build/, install/, log/ 는 colcon build 를 실행하면 colcon 이 알아서 만든다.

src 의 소스는 build 를 거쳐 install 로 만들어지고, ros2 run 전에 source 해야 하는 곳은 install 뿐이다

네 폴더는 역할이 뚜렷하게 나뉜다. 아래 표로 정리한다.

워크스페이스 폴더가 하는 일
폴더내용만드는 주체손으로 편집하는가
src/패키지 소스 코드사람그렇다, 여기만 편집한다
build/빌드 중간 산출물colcon아니다
install/실행 파일과 setup.bashcolcon아니다
log/빌드·테스트 로그colcon오류 확인 용도로만 읽는다

build 와 install 은 colcon 이 다시 만들어낼 수 있는 산출물이므로 깃 저장소에는 src 만 올리고 나머지는 .gitignore 에 넣는다. 빌드가 이상하게 꼬였을 때 build 와 install 을 통째로 지우고 다시 colcon build 하는 것도 흔한 대처법이다.

패키지 만들기: ros2 pkg create

패키지는 워크스페이스 src 아래에 두는 최소 빌드 단위다. Python 코드만 담을 패키지는 ament_python 빌드 타입으로 만든다.

cd ~/duri_ws/src
ros2 pkg create --build-type ament_python --license Apache-2.0 duri_pkg

이 명령은 duri_pkg 폴더 아래에 package.xml, setup.py, setup.cfg, duri_pkg/__init__.py, resource/duri_pkg, test/ 를 채워 넣는다. 이 중 사람이 직접 신경 쓰는 파일은 package.xml 과 setup.py 두 개다.

package.xml 은 패키지의 이름·버전·설명·라이선스, 그리고 다른 패키지에 대한 의존성을 적는 메타데이터 파일이다. rosdep 이나 colcon 은 이 파일을 읽어서 "이 패키지를 빌드하려면 무엇이 먼저 준비돼야 하는지"를 판단한다. setup.py 는 ament_python 패키지에서 실제로 파이썬 코드를 어떻게 설치하고, ros2 run 으로 부를 실행 이름을 무슨 함수에 연결할지를 정한다. C++ 패키지라면 setup.py 대신 CMakeLists.txt 가 이 역할을 맡지만, 이 책은 rclpy 만 다루므로 ament_python 만 쓴다.

colcon build 와 소싱

패키지 소스를 다 준비했으면 워크스페이스 루트에서 빌드한다. src 안이 아니라 src 를 담고 있는 폴더에서 실행해야 한다.

cd ~/duri_ws
colcon build --packages-select duri_pkg

--packages-select 는 워크스페이스에 패키지가 여러 개일 때 지정한 것만 빌드해 시간을 아낀다. 옵션 없이 colcon build 만 실행하면 src 아래 모든 패키지를 빌드한다.

빌드가 끝나면 install 폴더가 생기고, 그 안에 setup.bash 가 들어 있다. 이 파일을 source 해야 셸이 ros2 run duri_pkg ... 같은 명령에서 duri_pkg 를 찾을 수 있다. 그런데 그전에 ROS 2 설치 자체의 setup.bash 도 source 돼 있어야 한다. ROS 2 설치본을 밑바탕(underlay), 내 워크스페이스를 덧씌우는 층(overlay) 이라고 부르는데, 반드시 밑바탕을 먼저 source 한 뒤 덧씌우는 층을 source 해야 두 곳의 패키지가 모두 눈에 들어온다.

ROS 2 설치를 먼저 source 하고 내 워크스페이스 install 을 나중에 source 해야 ros2 run 이 패키지를 찾는다
source /opt/ros/jazzy/setup.bash
source ~/duri_ws/install/setup.bash

새 터미널을 열 때마다 이 두 줄이 필요하다는 점이 처음에는 번거롭게 느껴지지만, 반대로 생각하면 워크스페이스마다 독립된 환경을 만들 수 있다는 뜻이기도 하다. colcon 과 빌드 시스템에 대한 더 자세한 옵션은 colcon 공식 문서에서 확인할 수 있다.

완성 코드

아래는 duri_pkg 패키지를 이루는 파일과, ROS 2 없이도 colcon 이 빌드 순서를 정하는 방식을 확인할 수 있는 순수 파이썬 보조 스크립트다. 패키지 파일들은 ROS 2 Jazzy 환경에서 colcon build 로 빌드하는 것을 전제로 하고, 보조 스크립트는 python3 하나만 있으면 바로 실행된다.

duri_pkg/package.xml

<?xml version="1.0"?>
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
<package format="3">
  <name>duri_pkg</name>
  <version>0.0.1</version>
  <description>두리 배달 로봇의 첫 ROS 2 패키지</description>
  <maintainer email="wowpressdev@gmail.com">두리 개발팀</maintainer>
  <license>Apache-2.0</license>

  <test_depend>ament_copyright</test_depend>
  <test_depend>ament_flake8</test_depend>
  <test_depend>ament_pep257</test_depend>
  <test_depend>python3-pytest</test_depend>

  <export>
    <build_type>ament_python</build_type>
  </export>
</package>

duri_pkg/setup.py

from setuptools import find_packages, setup

package_name = "duri_pkg"

setup(
    name=package_name,
    version="0.0.1",
    packages=find_packages(exclude=["test"]),
    data_files=[
        ("share/ament_index/resource_index/packages", ["resource/" + package_name]),
        ("share/" + package_name, ["package.xml"]),
    ],
    install_requires=["setuptools"],
    zip_safe=True,
    maintainer="두리 개발팀",
    maintainer_email="wowpressdev@gmail.com",
    description="두리 배달 로봇의 첫 ROS 2 패키지",
    license="Apache-2.0",
    entry_points={
        "console_scripts": [
            "workspace_info = duri_pkg.workspace_info:main",
        ],
    },
)

duri_pkg/setup.cfg

[develop]
script-dir=$base/lib/duri_pkg
[install]
install-scripts=$base/lib/duri_pkg

duri_pkg/duri_pkg/workspace_info.py

def main():
    print("두리 워크스페이스 점검")
    print("이 메시지가 보이면 colcon build 와 setup.bash 가 정상 동작한 것이다")


if __name__ == "__main__":
    main()

resource/duri_pkg 와 duri_pkg/__init__.py 는 ros2 pkg create 가 만들어 준 빈 파일 그대로 둔다. resource/duri_pkg 는 ROS 2 가 "이 이름의 패키지가 설치돼 있다"고 인식하게 해 주는 표식 파일이라 내용이 없어도 된다.

check_build_order.py — 순수 파이썬 보조 예제

"""colcon 이 패키지 빌드 순서를 정하는 방식을 흉내 낸 보조 스크립트.

실제 colcon 은 각 패키지의 package.xml 에 적힌 <depend> 태그로
의존성 그래프를 만들고, 위상 정렬로 빌드 순서를 정한다.
이 스크립트는 그 과정을 딕셔너리 몇 개로 재현한다.
"""

from collections import deque


def topological_build_order(dependencies):
    in_degree = {pkg: 0 for pkg in dependencies}
    for pkg, deps in dependencies.items():
        for dep in deps:
            in_degree[pkg] += 1

    graph = {pkg: [] for pkg in dependencies}
    for pkg, deps in dependencies.items():
        for dep in deps:
            graph[dep].append(pkg)

    queue = deque(pkg for pkg, deg in in_degree.items() if deg == 0)
    order = []

    while queue:
        pkg = queue.popleft()
        order.append(pkg)
        for nxt in graph[pkg]:
            in_degree[nxt] -= 1
            if in_degree[nxt] == 0:
                queue.append(nxt)

    if len(order) != len(dependencies):
        remaining = set(dependencies) - set(order)
        raise ValueError(f"순환 의존성 발견: {sorted(remaining)}")

    return order


def main():
    dependencies = {
        "duri_msgs": [],
        "duri_driver": ["duri_msgs"],
        "duri_pkg": ["duri_msgs", "duri_driver"],
    }

    order = topological_build_order(dependencies)
    print("colcon build 가 예상하는 순서:")
    for i, pkg in enumerate(order, start=1):
        print(f"  {i}. {pkg}")

    broken = dict(dependencies)
    broken["duri_msgs"] = ["duri_pkg"]
    try:
        topological_build_order(broken)
    except ValueError as e:
        print()
        print(f"의존성을 서로 걸면 이런 오류가 난다: {e}")


if __name__ == "__main__":
    main()

줄별 해설

package.xml 에서 <name> 은 ros2 pkg create 뒤에 준 이름과 반드시 같아야 하고, 폴더 이름과도 같아야 한다. <export><build_type>ament_python</build_type></export> 는 colcon 에게 "이 패키지는 setup.py 방식으로 빌드하라"고 알리는 부분이다. 이 태그가 없거나 값이 틀리면 colcon 이 CMakeLists.txt 를 찾다가 실패한다.

setup.py 의 data_files 는 두 가지를 설치 폴더에 등록한다. 하나는 ament 색인(resource index)에 패키지 이름을 올려 ROS 2 도구들이 패키지를 인식하게 하는 것이고, 다른 하나는 package.xml 사본을 share 폴더에 두는 것이다. entry_points 의 "workspace_info = duri_pkg.workspace_info:main" 은 "ros2 run duri_pkg workspace_info 라고 치면 duri_pkg/workspace_info.py 의 main 함수를 실행하라"는 매핑이다. 왼쪽 workspace_info 가 실행 이름, 오른쪽이 실제 모듈 경로와 함수다. setup.cfg 의 script-dir·install-scripts 는 이 실행 파일을 install/lib/duri_pkg/ 아래에 두라는 지시로, ros2 run 이 패키지 이름만으로 실행 파일을 찾을 수 있게 해 준다.

workspace_info.py 는 아직 rclpy 를 쓰지 않는다. 이번 장의 목표는 빌드·설치 파이프라인이 제대로 도는지 확인하는 것이지 노드를 만드는 것이 아니기 때문이다. rclpy 로 실제 노드를 만드는 방법은 다음 장에서 다룬다.

보조 스크립트의 topological_build_order 는 Kahn 알고리즘으로 위상 정렬을 한다. in_degree 는 각 패키지가 "몇 개의 다른 패키지를 먼저 기다려야 하는지"를 센 값이고, graph 는 반대로 "이 패키지가 끝나면 다음에 무엇을 풀어줄 수 있는지"를 담는다. in_degree 가 0인 패키지부터 큐에 넣고 하나씩 꺼내면서 뒤따르는 패키지의 대기 수를 줄여 나가면, 의존하는 패키지보다 의존받는 패키지가 항상 먼저 나온다. 마지막에 order 길이가 전체 패키지 수와 다르면 큐에 한 번도 못 들어온 패키지가 있다는 뜻이고, 이는 서로가 서로를 기다리는 순환 의존성이 있다는 신호라서 ValueError 를 던진다.

실행 결과

ROS 2 Jazzy 환경에서 패키지를 만들고 빌드하면 대략 다음과 같은 흐름을 본다. 정확한 문구는 colcon·ROS 2 버전에 따라 조금씩 다를 수 있다.

$ cd ~/duri_ws/src
$ ros2 pkg create --build-type ament_python --license Apache-2.0 duri_pkg
creating folder ./duri_pkg
creating ./duri_pkg/package.xml
creating ./duri_pkg/setup.py
creating ./duri_pkg/setup.cfg
creating folder ./duri_pkg/duri_pkg
creating ./duri_pkg/duri_pkg/__init__.py
creating folder ./duri_pkg/resource
creating ./duri_pkg/resource/duri_pkg
creating folder ./duri_pkg/test

$ cd ~/duri_ws
$ colcon build --packages-select duri_pkg
Starting >>> duri_pkg
Finished <<< duri_pkg [0.42s]

Summary: 1 package finished [0.55s]

$ source /opt/ros/jazzy/setup.bash
$ source install/setup.bash
$ ros2 run duri_pkg workspace_info
두리 워크스페이스 점검
이 메시지가 보이면 colcon build 와 setup.bash 가 정상 동작한 것이다

보조 스크립트는 ROS 2 없이 그 자리에서 확인할 수 있다.

$ python3 check_build_order.py
colcon build 가 예상하는 순서:
  1. duri_msgs
  2. duri_driver
  3. duri_pkg

의존성을 서로 걸면 이런 오류가 난다: 순환 의존성 발견: ['duri_driver', 'duri_msgs', 'duri_pkg']

실무에서 자주 틀리는 것

src 안에서 colcon build 를 실행한다

워크스페이스 루트가 아니라 src 안에서 빌드를 시도하면 colcon 이 패키지를 못 찾거나 엉뚱한 위치에 build·install 을 만든다.

$ cd ~/duri_ws/src
$ colcon build
# src 안에서 실행 — 패키지를 찾지 못하거나 잘못된 위치에 결과물이 생긴다
$ cd ~/duri_ws
$ colcon build --packages-select duri_pkg

install/setup.bash 를 source 하지 않고 ros2 run 을 실행한다

새 터미널을 열고 ROS 2 설치만 source 한 채로 바로 실행하면 워크스페이스에 있는 패키지는 보이지 않는다.

$ source /opt/ros/jazzy/setup.bash
$ ros2 run duri_pkg workspace_info
Package 'duri_pkg' not found
$ source /opt/ros/jazzy/setup.bash
$ source ~/duri_ws/install/setup.bash
$ ros2 run duri_pkg workspace_info

entry_points 의 함수 이름을 실제 코드와 다르게 적는다

setup.py 에 적은 함수 이름과 workspace_info.py 에 실제로 정의한 함수 이름이 다르면 빌드는 되지만 실행할 때 오류가 난다.

entry_points={
    "console_scripts": [
        "workspace_info = duri_pkg.workspace_info:run",
    ],
},
# workspace_info.py 에는 run 이 아니라 main 이 정의돼 있다
entry_points={
    "console_scripts": [
        "workspace_info = duri_pkg.workspace_info:main",
    ],
},

setup.py 를 고치고 다시 빌드하지 않는다

entry_points 나 package.xml 을 수정한 뒤 colcon build 를 다시 하지 않으면 install 폴더에는 여전히 예전 설정이 남아 있다.

$ vim setup.py   # entry_points 이름을 고침
$ ros2 run duri_pkg workspace_info
No executable found  # install 은 아직 옛 설정 그대로다
$ vim setup.py
$ colcon build --packages-select duri_pkg
$ ros2 run duri_pkg workspace_info

한눈에 보기

빌드 파이프라인 한눈에 보기
항목역할실행 위치비고
ros2 pkg create패키지 뼈대 생성워크스페이스 src/--build-type ament_python 지정
package.xml메타데이터·의존성 선언패키지 루트이름은 폴더명과 일치해야 한다
setup.py설치 규칙·entry_points패키지 루트ros2 run 이 부를 이름을 매핑
colcon buildsrc 를 build·install 로 변환워크스페이스 루트--packages-select 로 범위 지정
source install/setup.bashoverlay 를 셸에 반영새 터미널마다underlay 를 먼저 source 해야 한다

연습 문제

  1. duri_pkg 를 colcon build 하기 전에 ros2 run duri_pkg workspace_info 를 실행하면 어떤 메시지가 나오고, 그 이유는 무엇인가.
  2. 다음 setup.py 조각은 ros2 run duri_pkg workspace_info 가 실패하게 만든다. 무엇이 문제이고 어떻게 고쳐야 하는가.
    entry_points={
        "console_scripts": [
            "workspace_info = duri_pkg.workspace:main",
        ],
    },
  3. 워크스페이스에 duri_msgs 와 duri_pkg 두 패키지가 있고, duri_pkg 의 package.xml 에 <depend>duri_msgs</depend> 가 적혀 있다. colcon build 는 둘 중 어느 것을 먼저 빌드하며, 그 이유는 무엇인가.
  4. 새 터미널을 열 때마다 /opt/ros/jazzy/setup.bash 와 install/setup.bash 를 순서대로 source 해야 하는 이유를 underlay·overlay 개념으로 설명하라.

정답과 해설

  1. install 폴더 자체가 아직 없으므로 "Package 'duri_pkg' not found" 류의 오류가 난다. ros2 run 은 source 된 setup.bash 들이 등록해 둔 경로에서 패키지를 찾는데, 빌드 전에는 그 경로에 아무것도 없기 때문이다.
  2. entry_points 오른쪽이 duri_pkg.workspace:main 인데 실제 파일은 duri_pkg/workspace_info.py 다. 모듈 경로를 duri_pkg.workspace_info:main 으로 고치고 다시 colcon build 해야 한다.
  3. duri_msgs 를 먼저 빌드한다. package.xml 의 <depend> 는 colcon 이 의존성 그래프를 만들 때 쓰는 정보이고, duri_pkg 가 duri_msgs 를 가리키고 있으므로 duri_msgs 의 빌드가 끝난 뒤에야 duri_pkg 를 빌드할 수 있다. 보조 스크립트의 위상 정렬이 이 과정을 그대로 흉내 낸 것이다.
  4. ROS 2 설치(underlay)는 rclpy 를 비롯한 기본 도구들을 셸에 등록하고, 워크스페이스 install(overlay)은 그 위에 duri_pkg 처럼 내가 만든 패키지를 얹는다. 밑바탕이 먼저 갖춰져 있지 않으면 overlay 가 의존하는 기본 도구 자체를 찾지 못하고, overlay 를 source 하지 않으면 내가 만든 패키지는 끝내 보이지 않는다.

댓글 0

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

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