ROS 2 Humble을 설치했는데 rclpy를 찾을 수 없다고 나올 때: 터미널마다 source한 setup 파일과 셸 종류로 import 성공 여부 세어 보기
ROS 2 Humble을 설치했는데 rclpy를 찾을 수 없다고 나올 때 처음 드는 의문은 하나다. 설치가 잘못됐을까, 아니면 지금 연 터미널의 환경이 문제일까? Robotics Stack Exchange의 107736번 질문(Installed ROS2 Humble but rclpy not found, 2024년 1월 작성, 2026-10-18 기준 조회 5621회)도 여기서 출발한다. 질문자는 Windows 11 WSL2의 Ubuntu 22.04에 공식 문서대로 Humble을 설치했고 Gazebo와 RViz는 동작했다. 그런데 rclpy를 쓰면 오류가 났고, 다시 설치해도 그대로였다.
답은 오류의 마지막 줄에서 갈린다. No module named 'rclpy'라면 그 터미널의 Python이 ROS 패키지 폴더를 모르는 상태다. 이 경우 터미널마다 어떤 셸에서 어떤 setup 파일을 source했는지(현재 셸에 ROS 환경 설정을 읽어 들였는지) 세어 보면 원인이 좁혀진다. 그런데 질문 107736의 실제 오류는 No module named 'rclpy._rclpy_pybind11'였다. rclpy는 찾았지만 지금 실행한 Python 인터프리터(interpreter, python3 명령이 실제로 실행하는 Python 프로그램)의 버전에 맞는 C 확장 파일이 없다는 뜻이어서, source를 다시 해도 풀리지 않는다. 제목의 ‘셸과 setup 파일 세기’는 첫 번째 갈래의 답이다. 두 번째 갈래를 가리려면 python3가 무엇을 가리키는지 적는 칸이 하나 더 필요하다.
이 글을 따라오는 데는 터미널에서 명령을 실행하고, echo $?로 직전 명령의 종료 코드(exit status, 0이면 성공)를 읽을 줄 알면 충분하다. 다 읽고 나면 열린 터미널마다 source 여부, 셸, setup 파일, python3가 가리키는 인터프리터의 네 칸을 적어 어느 창에서 import rclpy가 될지 예측하고, 그 예측을 종료 코드로 확인할 수 있다. 실측은 Humble이 아닌 ROS 2 Jazzy 공식 컨테이너에서 했다. setup 파일 템플릿은 두 배포판이 같은 원문이지만, Humble의 Python 3.10과 파일 이름 태그는 공식 문서와 Ubuntu 패키지 정보에서 가져온 값이다(확인일 2026-10-18).
마지막 줄이 rclpy인가, rclpy._rclpy_pybind11인가
import rclpy 한 줄은 두 단계를 거친다. Python이 먼저 rclpy라는 패키지 폴더를 찾고, 곧바로 그 안의 C 확장을 불러온다. rclpy의 __init__.py가 rclpy.signals를 가져오고, 그 모듈이 implementation_singleton을 거쳐 _rclpy_pybind11을 로드하기 때문이다(init.py L47-L51, signals.py L17, implementation_singleton.py L29-L32, Humble 브랜치 기준). 그래서 오류 문구를 보면 어느 단계에서 멈췄는지 알 수 있다.
ModuleNotFoundError: No module named 'rclpy': 첫 단계에서 멈췄다. ROS의site-packages폴더가 이 Python의 모듈 검색 경로에 없다. 그 터미널에서 source를 하지 않았거나, 셸에 맞지 않는 setup 파일을 골라 source가 중간에 실패한 경우가 대표적이다.No module named 'rclpy._rclpy_pybind11'와 “The C extension … isn’t present on the system”: 두 번째 단계에서 멈췄다. 뒤 문장은 ROS의 보조 패키지 rpyutils가 덧붙인다(import_c_library.py L41-L57). 이 문장이 보인다면 rclpy와 rpyutils는 이미 찾았다는 뜻이다. 없는 것은 지금 인터프리터에 맞는 이름의 C 확장 파일 하나다.
질문 107736의 메시지가 찾던 파일은 /opt/ros/humble/lib/python3.10/site-packages/_rclpy_pybind11.cpython-311-x86_64-linux-gnu.so였다. 폴더는 Python 3.10용인데 파일 이름에는 3.11을 뜻하는 cpython-311이 붙어 있다. Python 3.11 인터프리터가 Humble의 3.10용 패키지를 읽고 있었다는 해석이 가장 잘 맞는다. ROS 경로가 검색 경로에 있었으니 source도 됐을 가능성이 높다. 이 질문에는 두 번째 답변도 달렸는데, anaconda(데이터 분석용 Python 배포판)를 지우고 Ubuntu 기본 Python만 남기니 해결됐다는 개인 경험이다. 질문자 본인은 새 배포판에 다시 설치해서 해결했지만 원인은 모른다고 적었다. 그러니 이 글도 질문자의 원인을 확정하지 않고, 같은 증상을 스스로 가르는 방법만 다룬다.
source는 지금 열린 터미널 하나의 환경변수만 바꾼다
셸(shell)은 터미널에 입력한 명령을 해석해 실행하는 프로그램이다. Ubuntu에서 터미널을 열면 보통 bash가 뜨고, /bin/sh는 더 작은 셸인 dash로 연결돼 있다. 셸이 프로그램을 실행할 때는 환경변수(environment variable)를 함께 넘긴다. PATH=/usr/bin:/bin처럼 이름=값 꼴로 된 설정이며, 터미널 창마다, 정확히는 셸 프로세스마다 따로 있다.
source /opt/ros/humble/setup.bash의 source(POSIX sh에서는 .)는 스크립트를 새 프로세스로 띄우지 않고 지금 셸 안에서 실행한다(bash 매뉴얼 Bash Builtins). 그래서 스크립트가 바꾼 환경변수가 그 셸에 남는다. setup 파일(setup file)은 이 방식으로 PATH, AMENT_PREFIX_PATH, ROS_DISTRO, PYTHONPATH 같은 값을 채운다. rclpy와 직접 닿는 값은 PYTHONPATH다. Python이 기본 위치보다 먼저 모듈을 찾아볼 폴더 목록인데(Python 명령줄 문서), ROS의 Python 환경 훅(hook, 패키지마다 붙은 환경 설정 조각)이 여기 맨 앞에 <설치 경로>/lib/python3.X/site-packages를 붙인다(pythonpath.sh.in L3). Humble을 apt로 설치했다면 /opt/ros/humble/lib/python3.10/site-packages가 붙는다.
source를 그 터미널의 주소록에 ROS 주소를 적어 넣는 일로 생각하면 쉽다. 새 터미널은 빈 주소록으로 시작하므로, 공식 문서는 새 셸을 열 때마다 이 명령을 실행하라고 한다(Configuring environment L54-L66). 이 비유는 첫 번째 갈래까지만 맞는다. 인터프리터 불일치는 주소는 맞게 적혔는데 찾아간 집의 문 규격이 다른 상황에 가깝고, 그 규격이 무엇인지는 비유로 설명되지 않는다. 그 부분은 뒤에서 파일 이름으로 확인한다.
결국 import rclpy가 되느냐는 설치 폴더에 파일이 있느냐가 아니라 두 가지로 정해진다. 그 터미널의 PYTHONPATH, 그리고 그 터미널에서 python3가 가리키는 인터프리터다. 같은 PC에서 한 창은 되고 옆 창은 안 되는 일이 생기는 이유다.
setup 파일이 셸마다 다른 것은 자기 위치를 찾는 문법 때문이다
공식 설치 문서는 bash를 쓰지 않는다면 .bash를 셸에 맞게 바꾸라고 하고, 가능한 값으로 setup.bash, setup.sh, setup.zsh를 든다(Humble 설치 문서 L84-L117). 세 파일이 하는 일은 같고 첫 몇 줄만 다르다.
setup.bash는 자기 위치를${BASH_SOURCE[0]}로 찾는다. 지금 source 중인 파일 이름이 담긴 bash 전용 배열 변수다. 그 폴더로 이동해 절대 경로를 얻은 뒤 같은 폴더의setup.sh를.으로 불러온다(setup.bash L3-L11).setup.zsh는 같은 일을 zsh 전용 표현${(%):-%N}으로 한다.%N은 zsh가 지금 읽고 있는 스크립트의 이름이다(setup.zsh L3-L6, zsh Prompt Expansion).setup.sh는 위치를 계산하지 않는다. 빌드할 때 정해진 설치 경로를: ${AMENT_CURRENT_PREFIX:=...}꼴의 기본값으로 쓰기 때문에 POSIX sh에서도 돈다(setup.sh.in L5-L8).
셸과 파일이 어긋나면 위치 계산부터 깨진다. dash에서 setup.bash를 읽으면 dash가 모르는 표현인 ${BASH_SOURCE[0]}를 값으로 펼치는 단계에서 확장 오류(Bad substitution)가 나고, bash 내장 명령 builtin도 없다. 위치가 빈 값이 되니 엉뚱한 곳에서 setup.sh를 찾다가 실패하고, PYTHONPATH는 끝까지 바뀌지 않는다.
그다음에 셸이 끝나는지는 대화형(interactive)이냐 비대화형(non-interactive)이냐에 달렸다. 대화형은 사람이 프롬프트에서 한 줄씩 입력하는 상태이고, 비대화형은 sh script.sh나 sh -c로 명령 묶음을 실행하는 상태다. POSIX 규격 2.8.1의 표는 확장 오류와 특수 내장 명령(special built-in, .처럼 셸이 직접 처리하는 몇몇 명령)의 오류에서 비대화형 셸은 종료하고 대화형 셸은 종료하지 않는다고 정한다(POSIX Shell Command Language 2.8.1).
그런데 setup.bash에서 Bad substitution이 나는 자리는 위치를 계산하는 명령 치환(command substitution, $(...)나 백틱 안의 명령을 따로 실행해 그 출력을 값으로 쓰는 문법) 안이다(setup.bash L3-L11). 명령 치환은 서브셸(subshell, 셸이 따로 띄운 별도 실행 환경)에서 돌기 때문에, 그 오류로 끝나는 것은 서브셸뿐이고 바깥 셸은 빈 위치를 받아 다음 줄로 넘어간다. 비대화형 셸이 실제로 끝나는 곳은 그 빈 위치로 . "/setup.sh"를 실행하다 파일을 열지 못하는 마지막 단계다. .의 실패가 특수 내장 명령 오류이기 때문이다. 대화형 셸은 어느 오류에서도 끝나지 않는다. 오류 몇 줄을 찍고 ROS 환경 없이 프롬프트로 돌아오고, 그다음에 친 python3가 No module named 'rclpy'를 낸다.
zsh에서 setup.bash를 source하면 어떨까. zsh에는 BASH_SOURCE가 없으니 위치가 빈 문자열이 되고, dirname ""의 결과는 현재 폴더를 뜻하는 .이다. 현재 폴더가 마침 /opt/ros/humble이 아니라면 ./setup.sh를 찾지 못해 실패할 것이다. 이 예측은 템플릿 원문에서 추론한 것이고, 실험 이미지에 zsh가 없어 재지 못했다. zsh 사용자는 처음부터 setup.zsh를 고르면 이 문제를 겪지 않는다.
지금 셸이 무엇인지 확인할 때 echo $SHELL은 믿을 수 없다. SHELL은 사용자가 선호하는 로그인 셸을 적어 둔 변수라서(POSIX XBD 8, bash 매뉴얼 Bash Variables), bash 창에서 sh를 실행해도 값이 그대로 남는다. Linux에서는 readlink /proc/$$/exe가 지금 실행 중인 셸 프로그램을 보여 준다.
환경을 비운 새 셸 여섯 개에서 import 성공을 세어 봤다
위 설명대로라면 셸과 setup 파일의 조합만 바꿔도 import rclpy의 성패를 미리 맞힐 수 있어야 한다. 그래서 공식 ros:jazzy-ros-base 컨테이너에서 조합별로 자식 셸을 하나씩 띄워 확인했다. 실험을 자동으로 돌리는 HuntLab 하네스(harness)는 이미 setup.bash를 source한 상태로 실험 스크립트를 실행한다. 그대로 자식 셸을 띄우면 물려받은 PYTHONPATH 때문에 source하지 않은 경우까지 성공해 버리므로, 환경변수를 PATH와 HOME만 남기고 비워서 ‘방금 연 터미널’을 흉내 냈다. 각 셸은 source 명령, PYTHONPATH 출력, python 명령을 차례로 실행한다. 대화형 경우만은 의사 터미널(pseudo-terminal, pty: 프로그램에 사람이 키보드로 치는 터미널처럼 보이게 하는 장치) 위에서 실행했다. 성공 여부는 셸의 종료 코드와 python이 찍는 표식으로 판정했고, 예측은 실행 전에 실험 계획에 적어 두었다.
측정 환경: ros:jazzy-ros-base 고정 이미지 ros@sha256:066420e07f60aa18262f2479981def87ebcfcec42eefb0c0c57c4a46098348ca, ROS 2 Jazzy, Python 3.12.3, rclpy 7.1.12-1noble.20260902.053513, 네트워크 없음, 1 CPU·1 GiB. RMW(ROS middleware interface, 노드 사이 통신 구현을 갈아 끼우는 계층)는 하네스 기록상 기본값 rmw_fastrtps_cpp다. 같은 스크립트를 5회 실행했고 5회 모두 정상 종료했다. sh는 /usr/bin/dash였고 zsh는 이미지에 없었다.
| 경우 | 셸 | source | python 명령 | 예측 | 실측(5회 모두 같음) |
|---|---|---|---|---|---|
| A | bash | 없음 | python3 |
실패 | 종료 1, No module named 'rclpy' |
| B | bash | setup.bash |
python3 |
성공 | 종료 0 |
| C | sh(dash), 비대화형 | setup.bash |
python3 |
source에서 셸 종료 | 종료 2, Bad substitution, python 실행 안 됨 |
| C2 | sh(dash), 대화형(pty) | setup.bash |
python3 |
셸은 남고 import 실패 | 종료 1, 오류 3줄 뒤 No module named 'rclpy' |
| D | sh(dash) | setup.sh |
python3 |
성공 | 종료 0 |
| E | bash | setup.sh |
python3 |
성공 | 종료 0 |
| F | bash | setup.bash |
python3 -I |
실패 | 종료 1, PYTHONPATH에 ROS 경로가 있는데도 No module named 'rclpy' |
| I | bash, 환경 상속 | 없음 | python3 |
성공 | 종료 0 |
| J | bash | setup.bash |
rclpy.init() → create_node → shutdown() |
성공 | 종료 0 |
환경을 비운 새 셸 A–F 6개 가운데 import에 성공한 것은 3개(B, D, E)였고, 대조군까지 합친 9개 중에서는 5개였다. 9개 모두 실행 전 예측과 같았고, 예측과 다르게 나온 경우는 없었다. 아래는 첫 번째 실행의 원본 출력 일부다.
{"kind": "case", "case": "C_sh_setup.bash", "shell": "sh", "interactive": false, "clean_env": true, "source": ". /opt/ros/jazzy/setup.bash", "python": "python3", "exit_code": 2, "shell_survived_source": false, "ros_site_in_pythonpath": false, "import_ok": false, "api_ok": false, "error_class": "bad_substitution", "stderr_lines": 3, "stderr_first": "sh: 1: /opt/ros/jazzy/setup.bash: Bad substitution", "stderr_last": "sh: 11: .: cannot open /setup.sh: No such file", "stderr_all": ["sh: 1: /opt/ros/jazzy/setup.bash: Bad substitution", "sh: 6: /opt/ros/jazzy/setup.bash: builtin: not found", "sh: 11: .: cannot open /setup.sh: No such file"], "elapsed_ms": 1.9}
{"kind": "case", "case": "C2_sh_interactive_setup.bash", "shell": "sh", "interactive": true, "clean_env": true, "source": ". /opt/ros/jazzy/setup.bash", "python": "python3", "exit_code": 1, "shell_survived_source": true, "ros_site_in_pythonpath": false, "import_ok": false, "api_ok": false, "error_class": "no_module_rclpy", "stderr_lines": 6, "stderr_first": "sh: 1: /opt/ros/jazzy/setup.bash: Bad substitution", "stderr_last": "ModuleNotFoundError: No module named 'rclpy'", "stderr_all": ["sh: 1: /opt/ros/jazzy/setup.bash: Bad substitution", "sh: 6: /opt/ros/jazzy/setup.bash: builtin: not found", "sh: 11: .: cannot open /setup.sh: No such file", "Traceback (most recent call last):", " File \"<string>\", line 1, in <module>", "ModuleNotFoundError: No module named 'rclpy'"], "elapsed_ms": 16.6}
{"kind": "case", "case": "F_bash_setup.bash_python_-I", "shell": "bash", "interactive": false, "clean_env": true, "source": "source /opt/ros/jazzy/setup.bash", "python": "python3 -I", "exit_code": 1, "shell_survived_source": true, "ros_site_in_pythonpath": true, "import_ok": false, "api_ok": false, "error_class": "no_module_rclpy", "stderr_lines": 3, "stderr_first": "Traceback (most recent call last):", "stderr_last": "ModuleNotFoundError: No module named 'rclpy'", "stderr_all": [], "elapsed_ms": 201.0}
{"kind": "shell_check", "case": "K_SHELL_vs_running", "SHELL": "/bin/bash", "running_exe": "/usr/bin/dash", "same": false}
{"kind": "summary", "fresh_shell_cases": 6, "fresh_shell_import_ok": 3, "all_cases": 9, "all_import_ok": 5, "prediction_matches": 9, "mismatched_cases": [], "total_seconds": 1.76}
C와 C2는 같은 파일을 같은 셸로 읽었고 오류 세 줄도 똑같다. 오류가 세 줄이라는 것 자체가, 앞의 두 오류 뒤에도 셸이 계속 진행했다는 뜻이다. 셋째 줄 cannot open /setup.sh는 위치 계산이 빈 값이 되어 설치 폴더 대신 /setup.sh를 찾았다는 흔적이다. 차이는 그 셋째 줄에서 생긴다. 비대화형 C는 .이 /setup.sh를 열지 못한 그 지점에서 종료 코드 2로 끝나 python까지 가지 못했고, pty 위의 대화형 C2는 거기서도 살아남아 python을 실행한 끝에 No module named 'rclpy'를 냈다. 실행 전 계획에는 C의 종료를 Bad substitution 때문으로 적어 두었다. 예측한 결과(셸 종료, python 미실행)는 맞았지만, 로그로 보면 셸이 끝난 지점은 첫째 줄이 아니라 셋째 줄이다. 사람이 터미널에 직접 입력하는 상황은 C2 쪽에 가깝다. 오류 줄 다음에 프롬프트가 다시 뜨니 source가 끝난 것처럼 보일 수 있다. setup.sh를 고른 D와 E는 고정 경로 덕분에 dash와 bash 양쪽에서 성공했다.
F는 첫 번째 갈래의 원인이 정확히 PYTHONPATH라는 것을 보여 주는 대조군이다. 표식 줄에는 ROS 경로가 찍혔지만, -I(격리 모드, PYTHON으로 시작하는 환경변수를 모두 무시하는 옵션)를 주자 같은 셸에서도 rclpy를 찾지 못했다. I는 반대 방향의 대조군이다. source를 하지 않아도 이미 source한 프로세스의 환경을 물려받으면 import가 된다. 그러니 한 창에서 됐다는 사실이 다른 창도 된다는 증거가 되지는 않는다. K는 SHELL=/bin/bash를 준 채 dash를 실행한 결과로, $SHELL은 bash를 가리켰지만 실제로 돌던 셸은 /usr/bin/dash였다.
C2를 재는 데는 시행착오가 있었다. 처음에 sh -i -c '...'로 실행하자 명령 문자열 전체가 한 번에 읽히는 입력이라서, dash가 오류 뒤 남은 입력을 버리고 그대로 끝났다. 명령을 파이프로 넣었을 때도 비대화형 C와 같은 결과가 나왔다. 미리 읽혀 버퍼에 있던 다음 줄까지 버려진 것으로 추정하지만 따로 검증하지는 않았다. 세 번째 시도에서 Python 표준 라이브러리 pty로 의사 터미널을 만들어 한 줄씩 입력하자 예측대로 동작했다.
재현 스크립트와 실행별 원본 출력은 실험 공개 폴더에 있다. 이 글의 실측 숫자는 모두 같은 커밋의 results.json 하나에서 가져왔다. Docker가 설치된 x86_64 Linux PC라면 다음 순서로 다시 실행할 수 있다. 내려받은 스크립트의 SHA-256은 results.json의 script_sha256과 같아야 한다.
mkdir rclpy-shell-check && cd rclpy-shell-check
curl -fsSLO https://raw.githubusercontent.com/sungpyo9053/blog/33064efd9a3cf07c0676e1b8dc207f1fbdf8b8d5/experiments/ros2-installed-rclpy-not-found-source-setup-shell/experiment.py
sha256sum experiment.py # a8b509d6fb1fdf5dbf12b4c210a100a46b733a201ef64c2e76812d90299652c2 이어야 한다
docker run --rm --network none --cpus 1 --memory 1g -v "$PWD":/work ros@sha256:066420e07f60aa18262f2479981def87ebcfcec42eefb0c0c57c4a46098348ca bash -c "source /opt/ros/jazzy/setup.bash && python3 /work/experiment.py"
출력은 경우마다 JSON 한 줄이다. 마지막 summary 줄의 mismatched_cases가 빈 목록이면 결과가 예측과 같다는 뜻이다. elapsed_ms와 total_seconds는 PC마다 달라진다. 하네스는 여기에 /tmp를 메모리 기반 임시 파일 시스템(tmpfs)으로 붙이고 프로세스 수를 제한하는 옵션을 더 넣어 실행했지만, 판정에 쓰는 자식 셸은 두 경우 모두 스크립트가 직접 /tmp에서 띄운다.
파일 이름의 cpython 번호가 인터프리터 불일치를 드러낸다
C 확장(C extension module)은 C/C++로 빌드해 Python에서 import하는 .so 파일이다. rclpy에서는 _rclpy_pybind11이 그 파일이다. 빌드에 쓴 Python 버전에 묶여 있어서 파일 이름에 버전 태그가 붙는다. 3.10용이라면 _rclpy_pybind11.cpython-310-x86_64-linux-gnu.so 같은 이름이 된다. PEP 3149에 따르면 인터프리터는 확장 모듈 foo를 import할 때 세 이름을 이 순서로 찾는다. 자기 태그가 붙은 이름(3.10 인터프리터라면 foo.cpython-310-x86_64-linux-gnu.so), 안정 ABI(stable ABI, 여러 Python 버전이 함께 지키기로 한 컴파일된 코드의 호환 규약)에 맞춰 빌드한 foo.abi3.so, 하위 호환을 위해 남겨 둔 태그 없는 foo.so다(PEP 3149). 첫 번째 이름의 끝부분이 sysconfig.get_config_var('EXT_SUFFIX') 값이고, 인터프리터가 인식하는 끝부분 전체는 importlib.machinery.EXTENSION_SUFFIXES에 들어 있다. 세 이름 어디에도 다른 Python 버전의 태그는 들어가지 않으므로, 3.11 인터프리터에게 cpython-310이 붙은 파일은 같은 폴더에 있어도 확장 모듈이 아니다.
Humble deb 패키지는 Ubuntu 22.04(Jammy)용이고(REP 2000), Jammy의 python3 패키지는 3.10.6이다(Ubuntu Packages jammy python3). REP 2000에 적힌 ‘Python 3.6’은 최소 언어 요구일 뿐, 바이너리를 빌드한 버전이 아니다. 따라서 Humble의 rclpy 폴더에는 cpython-310 파일이 들어 있을 것으로 본다(패키지 정보 기준).
여기에 conda나 다른 버전의 Python이 끼어들면 무슨 일이 생길까. PYTHONPATH는 어느 인터프리터든 똑같이 읽는다. 그래서 3.11 인터프리터도 순수 Python으로 된 rclpy 폴더까지는 찾아 들어간다. 하지만 폴더에 cpython-310 파일만 있다면 cpython-311 태그, .abi3.so, 태그 없는 .so 중 어느 이름도 맞지 않으니 C 확장을 불러오는 단계에서 멈춘다. 두 번째 갈래의 오류가 나는 지점이 바로 여기다. 실험 컨테이너에는 Python이 하나뿐이어서 다른 버전으로 import하는 장면은 재현하지 못했고, 대신 파일 이름을 확인했다.
{"kind": "file_check", "case": "G_ext_suffix_match", "ext_suffix": ".cpython-312-x86_64-linux-gnu.so", "rclpy_dir": "/opt/ros/jazzy/lib/python3.12/site-packages/rclpy", "pybind_so_files": ["_rclpy_pybind11.cpython-312-x86_64-linux-gnu.so"], "abi_tags_present": ["cpython-312"], "matching_file_exists": true, "cpython-310_exists": false, "cpython-311_exists": false}
{"kind": "file_check", "case": "H_rpyutils_message_path", "message_path": "/opt/ros/jazzy/lib/python3.12/site-packages/_rclpy_pybind11.cpython-312-x86_64-linux-gnu.so", "exists": false}
Jazzy 컨테이너의 EXT_SUFFIX는 .cpython-312-x86_64-linux-gnu.so였고, rclpy 폴더의 _rclpy_pybind11 파일은 이 태그가 붙은 하나뿐이었다. cpython-310이나 cpython-311 파일은 없었다. rpyutils 오류 메시지가 가리키는 위치(site-packages 바로 아래)에는 정상 설치인데도 파일이 없었다.
실제로 쓸모가 큰 것은 두 번째 줄이다. 오류 메시지의 경로는 rpyutils 파일이 있는 폴더의 한 단계 위에 확장 이름을 붙여 계산한 값이다(import_c_library.py L41-L57 중 L44-L45). 실제 파일이 있는 site-packages/rclpy/와는 다른 곳이다. 메시지 경로를 그대로 ls하면 정상 설치에서도 파일이 없다고 나와서, 설치가 깨졌다고 오해하기 쉽다. 봐야 할 곳은 rclpy/ 폴더 안의 파일 이름이고, 비교할 것은 그 cpython 번호와 지금 인터프리터의 EXT_SUFFIX다. 공식 Troubleshooting도 접두·접미는 같고 Python 버전만 다른 파일이 있다면 다른 인터프리터를 쓰고 있는 것이라고 진단한다(Installation troubleshooting L53-L62). conda base가 켜진 (base) 프롬프트에서 같은 오류를 만난 사례는 ros2/rclpy#1194에 사용자 보고로 남아 있다.
한 터미널을 진단할 때는 다음 명령을 위에서부터 실행한다.
readlink /proc/$$/exe # 지금 실행 중인 셸
printenv ROS_DISTRO # source가 됐다면 humble
printenv PYTHONPATH | tr ':' '\n' | grep /opt/ros # 비어 있으면 갈래 ①
command -v python3 # conda 경로가 나오면 갈래 ② 의심
python3 -c 'import sys, sysconfig; print(sys.executable, sys.version.split()[0], sysconfig.get_config_var("EXT_SUFFIX"))'
ls /opt/ros/humble/lib/python3.10/site-packages/rclpy/ | grep _rclpy_pybind11
python3 -c 'import rclpy'; echo $? # 0이면 import 성공
Humble 경로로 적은 블록이다. 각 줄이 읽는 값(현재 셸, PYTHONPATH, EXT_SUFFIX, rclpy 폴더의 파일 이름, import 종료 코드)은 위 실험도 같은 방식으로 읽었다. 다만 이 블록을 Humble에서 그대로 실행한 기록은 없다. Jazzy라면 humble을 jazzy로, python3.10을 python3.12로 바꾼다. 다섯째 줄의 EXT_SUFFIX와 여섯째 줄 파일 이름의 cpython 번호가 다르면 두 번째 갈래다.
고치는 방법은 공식 권고를 따른다. 같은 인터프리터를 쓰고, apt 바이너리를 쓴다면 PATH에 conda 경로가 들어가지 않게 한다. 필요하면 .bashrc에서 conda를 켜는 줄을 주석 처리하라고도 적혀 있다(Installation troubleshooting L99-L104). 당장은 그 터미널에서 conda deactivate를 한 뒤 다시 확인하거나, /usr/bin/python3로 직접 실행하면 된다. 검색하면 나오는 두 가지 방법은 권하지 않는다. update-alternatives로 시스템 기본 python3를 바꾸면 Ubuntu 시스템 도구 전체가 쓰는 인터프리터까지 바뀐다. apt 설치 위에 rclpy만 소스로 빌드하는 방법은 질문 107736과 rclpy#1194에서 모두 빌드 단계에서 막혔다.

실행 화면이 아니라 원리 설명용 그림이다.
터미널마다 네 칸을 적으면 import될 창의 수가 나온다
실측 전 후보 단계에서 만든 산술 예제가 있다(example.py, 검산 기록 verification.json). 하루에 터미널 6개를 열었다고 가정하고 source 여부와 셸·setup 파일의 짝을 센다. 6, 4 같은 숫자는 계산을 따라가기 쉽게 고른 설명용 값이다. 아래 표는 그 개수에 맞춰 터미널을 하나씩 펼쳐 놓은 것이다.
| 터미널 | source 여부 | 셸 | setup 파일 | python3 |
예측 |
|---|---|---|---|---|---|
| 1 | 함 | bash | setup.bash |
/usr/bin/python3 |
가능 |
| 2 | 함 | bash | setup.bash |
/usr/bin/python3 |
가능 |
| 3 | 함 | bash | setup.bash |
/usr/bin/python3 |
가능 |
| 4 | 함 | zsh | setup.bash |
/usr/bin/python3 |
불가(짝 불일치) |
| 5 | 안 함 | bash | – | /usr/bin/python3 |
불가(갈래 ①) |
| 6 | 안 함 | bash | – | /usr/bin/python3 |
불가(갈래 ①) |
source한 터미널만 세면 4개가 준비된 것처럼 보인다. 하지만 source하지 않은 2개와 짝이 어긋난 1개를 빼면 6 − 2 − 1 = 3개, 비율로는 3/6 = 0.5다. 극단적으로 6개 모두 source했더라도 전부 zsh에서 setup.bash를 골랐다면 6 − 0 − 6 = 0개가 된다. source했다는 체크 표시만으로는 import를 장담할 수 없다. 이 계산은 python3 -I example.py로 검산한 사칙연산이며(Python 3.12.3, 종료 코드 0), ROS나 셸을 실행한 결과가 아니다. 4번 줄의 ‘불가’도 앞에서 말한 zsh 추론을 모델에 넣은 가정이다. 앞 절 실측의 ‘새 셸 6개 중 3개’와는 숫자만 같을 뿐, 조합(bash·dash와 setup 파일, -I)이 다른 별개의 결과다.
후보 단계의 예제는 앞의 세 칸만 셌고 python3 칸이 없었다. 위 표의 python3 열은 그 빈자리를 보이려고 이 글에서 채워 넣은 것이다. 3번 터미널에서 conda base가 켜져 python3가 conda의 3.11을 가리킨다면, source와 짝이 모두 맞아도 두 번째 갈래로 실패한다. 그러면 가능한 터미널은 6 − 2 − 1 − 1 = 2개다. 이 한 줄은 이 글에서 덧붙인 계산이며 검산 기록에는 없다.
직접 판단해 볼 두 장면
장면 1. 프롬프트 앞에 (base)가 붙은 터미널에서 Humble을 source했는데, No module named 'rclpy._rclpy_pybind11'이 나왔다. 메시지가 찾는 파일 이름에는 cpython-311이 들어 있다. 무엇부터 할까?
오류 문구가 두 번째 갈래이고, (base)는 conda가 켜져 있다는 표시다. 다시 source해도 결과는 바뀌지 않는다. command -v python3와 EXT_SUFFIX를 확인해 conda의 3.11이면 conda deactivate 뒤 같은 import를 다시 실행한다. 종료 코드가 0으로 바뀌면 원인은 인터프리터였다. 계속 쓸 터미널이라면 공식 권고대로 PATH에서 conda를 빼는 쪽으로 정리한다.
장면 2. 팀원이 sh start_robot.sh로 스크립트를 실행한다. 첫 줄은 . /opt/ros/humble/setup.bash, 둘째 줄은 python3 my_node.py다. 어떻게 될까?
실측 C와 같은 조건이다. 비대화형 dash는 Bad substitution을 찍은 뒤에도 진행하다가, .이 /setup.sh를 열지 못하는 지점에서 종료한다. 그래서 python은 실행되지도 않고, Jazzy 실측에서는 종료 코드 2였다. 첫 줄을 . /opt/ros/humble/setup.sh로 바꾸거나(실측 D), bash start_robot.sh로 실행하면서 source /opt/ros/humble/setup.bash를 쓰면 된다(실측 B).
시작 파일에 한 줄 넣는 계산은 대화형 bash에서만 맞는다
같은 예제는 반복 비용도 센다. 5일 동안 하루 6개 터미널을 손으로 source하면 5 × 6 = 30번이다. ~/.bashrc에 source /opt/ros/humble/setup.bash 한 줄을 한 번 넣으면 30 − 1 = 29번이 줄어든다. 공식 문서도 echo "source /opt/ros/{DISTRO}/setup.bash" >> ~/.bashrc로 이 방법을 안내한다(Configuring environment L95).
다만 29라는 숫자는 6개 터미널이 모두 대화형 bash일 때만 맞는다. Ubuntu 기본 ~/.bashrc는 첫머리에서 대화형이 아니면 바로 return한다. 새 사용자의 ~/.bashrc 원본인 ubuntu:22.04 이미지의 /etc/skel/.bashrc 6–9행을 열어 확인한 내용이다. bash script.sh나 cron처럼 대화형이 아닌 실행, 그리고 다른 셸은 이 줄을 읽지 않으므로 스크립트 안에서 따로 source해야 한다. zsh를 쓴다면 ~/.zshrc에 source /opt/ros/humble/setup.zsh를 넣는다.
어느 쪽을 고를지에 대한 HuntLab의 판단은 이렇다. bash 하나로 Humble만 쓴다면 시작 파일에 넣는 편이 낫다. 여러 배포판이나 작업공간을 번갈아 쓰거나 conda를 함께 쓴다면, 자동 source가 지금 어느 환경이 켜져 있는지를 가려 버린다. 이럴 때는 터미널마다 명시적으로 source하는 편이 오류 원인을 찾기 쉽다. 공식 Configuring environment 문서는 두 방법을 나란히 제시할 뿐 어느 쪽도 권하지 않는다.
재설치 전에 볼 순서와 이 측정이 답하지 않는 것
재설치를 고민하기 전에 확인할 순서는 짧다. 오류의 마지막 줄로 갈래를 고르고, 그 터미널에서 진단 명령으로 셸, PYTHONPATH, python3, 파일 이름 태그를 본 다음, python3 -c 'import rclpy'; echo $?가 0인지 확인한다. 질문 107736처럼 재설치한 뒤에도 같은 오류가 반복된다면 인터프리터가 그대로였을 가능성부터 의심할 만하다.
이 실측이 다루지 못한 범위도 분명히 해 둔다. 공식 문서의 확인 절차인 ros2 run demo_nodes_cpp talker와 ros2 run demo_nodes_py listener는 ros-humble-desktop을 설치했을 때 해 볼 수 있는 예제다(위 설치 문서 L100). ros-base 이미지에는 demo_nodes_py가 없어 실행하지 못했다. ros-base만 설치한 PC에서 listener가 패키지를 찾지 못한다면 rclpy import와는 다른 문제다. listener는 Ctrl+C로 끝낼 때까지 도는 프로그램이어서, 정상 여부도 종료 코드보다 “I heard”가 찍히는 출력으로 판단해야 한다. 실측 J의 init·노드 생성·shutdown은 그 대신 넣은 최소 검사일 뿐, 공식 listener 테스트와 같은 검증이 아니다.
C2는 pty 위에서 돌린 대화형 dash이므로 GUI 터미널 에뮬레이터, VS Code 같은 IDE의 내장 터미널, WSL2가 셸 환경을 물려주는 방식까지 대신하지 않는다. rpyutils의 다른 문구인 “failed to be imported while being present”(파일은 있는데 로드에 실패한 경우)도 이 글의 범위 밖이다. 이 글은 개발 PC의 소프트웨어 환경 진단만 다룬다. 실물 로봇이나 시뮬레이터는 실행하지 않았고, J에서 만든 노드도 아무것도 발행하거나 구동하지 않는다.
설치 직후의 /opt/ros/humble 하나를 넘어 직접 빌드한 작업공간까지 source하게 되면 확인할 것이 하나 늘어난다. 두 setup 파일의 차이와 source 순서는 새 터미널에서 local_setup.bash만 source해도 underlay 패키지가 보일까에서 같은 ‘환경을 비운 셸’ 방식으로 세어 봤다.
제휴·협찬은 없다.
참고 링크
모두 2026-10-18에 확인했다. GitHub 링크는 Humble 브랜치의 고정 커밋이다.
- ROS 2 Humble — Ubuntu (deb packages) 설치 문서: source 명령, 셸별 setup 파일, desktop 설치 조건의 talker/listener 예제
- ROS 2 Humble — Installation troubleshooting: 인터프리터 불일치 진단, conda와 apt 바이너리 혼용 주의
- ament_package setup.bash 템플릿, setup.sh.in 템플릿: 셸별 자기 위치 계산과 고정 설치 경로
- rpyutils import_c_library.py: “isn’t present” 메시지와 기대 경로 계산
- PEP 3149: C 확장 파일 이름의 버전 태그와 import할 때 찾는 이름의 순서
- Python importlib 문서 — EXTENSION_SUFFIXES: 인터프리터가 확장 모듈로 인식하는 파일 이름 끝부분 목록
- POSIX Shell Command Language 2.8.1: 대화형·비대화형 셸의 오류 처리 차이