ROS 2 · 기본
노드·토픽·서비스 첫걸음
서비스 - 요청하고 응답받기
서비스 서버·클라이언트, 동기·비동기 호출, 타임아웃, 토픽과 서비스 선택 기준표
개발자KR · 원고 갱신
이 장에서 배우는 것
앞 장에서는 두리가 주고받을 메시지의 생김새를 직접 설계했다. 이번 장에서는 그 메시지를 실어 나르는 통신 방식을 하나 더 배운다. 지금까지 써 온 토픽은 누가 듣고 있는지 신경 쓰지 않고 값을 흘려보내는 방식이었다. 하지만 "지금 이 도크를 써도 되는가"처럼 반드시 답을 받아야 하는 질문도 있다. 이런 요청-응답 상황을 다루는 통신 방식이 서비스(service)다.
- 서비스 서버와 클라이언트를 rclpy로 각각 작성한다
- 동기적으로 보이는 호출과 완전한 비동기 호출의 차이를 구분한다
- 서비스 요청에 타임아웃을 걸어 응답이 오지 않는 상황에 대비한다
- 토픽과 서비스 중 어느 쪽을 써야 하는지 판단 기준을 세운다
문제 상황
두리가 두 대로 늘었다고 하자. 배터리가 부족해지면 로봇은 충전 도크로 이동해야 하는데, 도크는 두 자리뿐이다. 처음에는 각 로봇이 "나 지금 충전하러 간다"는 내용을 토픽으로 발행하는 방식으로 구현했다. 그런데 두 로봇의 배터리가 비슷한 시점에 줄어들면, 둘 다 같은 도크로 향하다가 현장에서 부딪히는 일이 생겼다. 토픽은 누가 받았는지, 그 값을 보고 상대가 어떤 결정을 내렸는지 확인해 주지 않기 때문이다. 필요한 것은 "이 도크, 내가 써도 되는가"라고 묻고 "된다" 또는 "안 된다"라는 답을 그 자리에서 받는 통신이다. 이런 요청-응답 구조가 서비스다.
요청과 응답이 짝을 이루는 통신
서비스는 서버 노드가 create_service로 콜백을 등록해 두고, 클라이언트 노드가 create_client로 만든 객체를 통해 요청을 보내는 구조다. 토픽과 가장 다른 점은, 하나의 요청은 반드시 하나의 응답과 짝을 이룬다는 것이다. 서버가 응답을 만들어 돌려주기 전까지 클라이언트는 그 요청이 어떻게 처리됐는지 알 수 없고, 반대로 서버 입장에서는 요청을 보낸 클라이언트가 구체적으로 누구인지 신경 쓸 필요가 없다.
서비스 인터페이스는 메시지 타입을 다룬 앞 장에서 쓴 .msg 파일과 비슷하지만, 요청 부분과 응답 부분을 --- 한 줄로 나눠 쓴다는 점이 다르다. 이 파일 하나로 Request 클래스와 Response 클래스 두 개가 함께 만들어진다. 이번 장 예제에서는 로봇 번호와 사용 시간(분)을 요청으로 보내고, 배정 성공 여부와 도크 번호를 응답으로 받는 ReserveDock 서비스를 만든다.
자세한 인터페이스 정의 방법과 빌드 절차는 메시지 타입을 다룬 앞 장에서 이미 살펴봤으므로 이번 장에서는 반복하지 않는다. 공식 튜토리얼도 참고할 수 있다. rclpy 서비스 튜토리얼
동기 호출, 비동기 호출, 그리고 타임아웃
진짜 동기 호출은 위험하다
rclpy의 클라이언트 객체에는 call(request)이라는 메서드도 있다. 이 메서드는 응답이 올 때까지 호출한 스레드를 그대로 막아 버린다. 문제는 이 호출을 노드 자신의 콜백(토픽 구독, 타이머 등) 안에서 실행할 때 생긴다. 그 콜백을 실행 중인 실행자(executor)는 이미 이 호출 하나를 처리하느라 묶여 있는데, 정작 응답을 받아서 처리해 줄 다른 스레드가 없다. 결과적으로 아무 일도 일어나지 않는 교착 상태(deadlock)에 빠진다. 그래서 콜백 안에서는 call()을 쓰지 않는다.
spin_until_future_complete로 동기처럼 쓰기
노드의 콜백이 아니라 main() 함수처럼 실행자가 아직 돌고 있지 않은 지점에서는 call_async()로 받은 future를 rclpy.spin_until_future_complete(node, future, timeout_sec=...)에 넘기는 방식을 쓴다. 이 함수는 future가 완료되거나 지정한 시간이 지날 때까지 실행자를 돌리다가 돌아온다. 호출부에서 결과를 바로 이어서 쓸 수 있어서 동기 호출처럼 코드를 짤 수 있다.
완전한 비동기 처리: add_done_callback
노드가 이미 다른 콜백(배터리 토픽 구독 등) 안에서 서비스를 호출해야 한다면 add_done_callback을 쓴다. call_async()가 돌려준 future에 콜백 함수를 등록해 두면, 호출한 자리에서는 바로 다음 줄로 넘어가고 실행자는 계속 다른 일을 처리한다. 응답이 도착하면 등록해 둔 콜백이 그 때 실행된다.
future = self._client.call_async(request)
future.add_done_callback(self._on_reserve_done)
동기처럼 쓰는 방식과 완전 비동기 방식은 쓰는 자리가 다르다. 정리하면 다음과 같다.
| 호출 방식 | 코드 패턴 | 실행자 차단 여부 | 언제 쓰나 |
|---|---|---|---|
| 진짜 동기 | client.call(request) | 호출한 스레드를 그대로 막음 | 되도록 쓰지 않는다 (콜백 안이면 교착 위험) |
| 동기처럼 쓰는 비동기 | call_async() + spin_until_future_complete() | 호출 지점에서만 대기 | 독립 스크립트성 클라이언트에서 결과를 바로 써야 할 때 |
| 완전 비동기 | call_async() + add_done_callback() | 막지 않음, 응답은 나중에 콜백으로 | 다른 콜백 안에서 서비스를 호출해야 할 때 |
타임아웃은 두 군데에 걸 수 있다. 서버가 아직 뜨지 않았을 수 있으므로 wait_for_service(timeout_sec=...)로 먼저 확인하고, 요청을 보낸 뒤에는 spin_until_future_complete의 timeout_sec으로 응답 대기 시간을 제한한다. 시간이 지나도 future.done()이 False라면 응답이 오지 않은 것이므로 별도로 처리해야 한다.
완성 코드
duri_interfaces/srv/ReserveDock.srv
uint8 robot_id
uint16 minutes
---
bool granted
uint8 dock_number
string message
duri_interfaces/CMakeLists.txt (일부)
rosidl_generate_interfaces(${PROJECT_NAME}
"msg/BatteryState.msg"
"srv/ReserveDock.srv"
)
duri_dock/duri_dock/dock_manager_node.py
import rclpy
from rclpy.node import Node
from duri_interfaces.srv import ReserveDock
class DockManagerNode(Node):
def __init__(self):
super().__init__('dock_manager')
self._free_docks = [1, 2]
self._srv = self.create_service(
ReserveDock, 'reserve_dock', self._handle_reserve
)
self.get_logger().info(
f'충전 도크 관리 서비스 시작 (도크 {len(self._free_docks)}개)'
)
def _handle_reserve(self, request, response):
if not self._free_docks:
response.granted = False
response.dock_number = 0
response.message = f'로봇 {request.robot_id}: 사용 가능한 도크 없음'
return response
dock = self._free_docks.pop(0)
response.granted = True
response.dock_number = dock
response.message = (
f'로봇 {request.robot_id}: 도크 {dock}번 배정, '
f'{request.minutes}분간 사용'
)
self.get_logger().info(response.message)
return response
def main():
rclpy.init()
node = DockManagerNode()
try:
rclpy.spin(node)
except KeyboardInterrupt:
pass
finally:
node.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()
duri_dock/duri_dock/dock_requester_node.py
import sys
import rclpy
from rclpy.node import Node
from duri_interfaces.srv import ReserveDock
class DockRequesterNode(Node):
def __init__(self):
super().__init__('dock_requester')
self._client = self.create_client(ReserveDock, 'reserve_dock')
def reserve(self, robot_id, minutes, timeout_sec=3.0):
if not self._client.wait_for_service(timeout_sec=timeout_sec):
self.get_logger().error('서비스 응답 없음: reserve_dock')
return None
request = ReserveDock.Request()
request.robot_id = robot_id
request.minutes = minutes
future = self._client.call_async(request)
rclpy.spin_until_future_complete(self, future, timeout_sec=timeout_sec)
if not future.done():
self.get_logger().error('요청 시간 초과')
return None
return future.result()
def main():
rclpy.init()
node = DockRequesterNode()
robot_id = int(sys.argv[1]) if len(sys.argv) > 1 else 1
minutes = int(sys.argv[2]) if len(sys.argv) > 2 else 15
result = node.reserve(robot_id, minutes)
if result is not None:
node.get_logger().info(result.message)
node.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()
두 노드는 앞 장들에서 다룬 방식대로 duri_dock 패키지의 setup.py entry_points에 dock_manager, dock_requester 실행 파일로 등록한다.
dock_reservation_sim.py (ROS 없이 흐름만 확인)
from dataclasses import dataclass
@dataclass
class ReserveDockRequest:
robot_id: int
minutes: int
@dataclass
class ReserveDockResponse:
granted: bool
dock_number: int
message: str
class DockManager:
def __init__(self, dock_count):
self.free_docks = list(range(1, dock_count + 1))
def handle_request(self, request):
if not self.free_docks:
return ReserveDockResponse(
False, 0, f'로봇 {request.robot_id}: 사용 가능한 도크 없음'
)
dock = self.free_docks.pop(0)
return ReserveDockResponse(
True,
dock,
f'로봇 {request.robot_id}: 도크 {dock}번 배정, {request.minutes}분간 사용',
)
def call_with_timeout(manager, request, max_attempts=2):
for attempt in range(1, max_attempts + 1):
response = manager.handle_request(request)
if response.granted:
return response
print(f' 시도 {attempt}/{max_attempts} 실패: {response.message}')
return ReserveDockResponse(False, 0, f'로봇 {request.robot_id}: 타임아웃, 요청 포기')
def main():
manager = DockManager(dock_count=2)
requests = [
ReserveDockRequest(robot_id=1, minutes=15),
ReserveDockRequest(robot_id=2, minutes=10),
ReserveDockRequest(robot_id=3, minutes=20),
]
for req in requests:
print(f'로봇 {req.robot_id} 요청 전송')
result = call_with_timeout(manager, req)
print(f' 결과: {result.message}\n')
if __name__ == '__main__':
main()
줄별 해설
dock_manager_node.py에서 self._free_docks = [1, 2]는 두 대의 도크를 정수 목록으로 표현한 것이다. create_service(ReserveDock, 'reserve_dock', self._handle_reserve)는 ReserveDock 타입의 요청이 reserve_dock이라는 이름으로 들어오면 _handle_reserve를 부르라고 등록하는 줄이다. _handle_reserve는 rclpy가 미리 만들어 건네주는 response 객체의 필드를 채우고 반드시 그 객체를 return해야 한다. 이 반환값이 곧 클라이언트가 받는 응답이다.
dock_requester_node.py의 reserve 메서드는 세 단계로 이뤄진다. wait_for_service로 서버가 떠 있는지 먼저 확인하고, ReserveDock.Request()로 요청 객체를 만들어 필드를 채운 뒤, call_async와 spin_until_future_complete로 응답을 기다린다. 마지막에 future.done()을 확인하는 이유는 timeout_sec이 지나도 spin_until_future_complete가 예외 없이 그냥 돌아오기 때문이다. 확인하지 않으면 응답이 없는데도 future.result()를 부르다 오류가 난다.
dock_reservation_sim.py는 rclpy 없이 같은 흐름을 흉내 낸 것이다. DockManager.handle_request가 서버의 콜백 역할을, call_with_timeout이 클라이언트의 재시도 및 타임아웃 처리 역할을 한다. free_docks 목록이 비어 있으면 매 시도마다 배정에 실패하고, max_attempts번 시도한 뒤에는 포기 메시지를 돌려준다.
실행 결과
워크스페이스를 빌드하고 두 터미널에서 각각 서버와 클라이언트를 실행하면 이렇게 보인다.
$ colcon build --packages-select duri_interfaces duri_dock
$ source install/setup.bash
$ ros2 run duri_dock dock_manager
[INFO] [dock_manager]: 충전 도크 관리 서비스 시작 (도크 2개)
[INFO] [dock_manager]: 로봇 1: 도크 1번 배정, 15분간 사용
$ ros2 run duri_dock dock_requester 1 15
[INFO] [dock_requester]: 로봇 1: 도크 1번 배정, 15분간 사용
ROS 없이 흐름만 확인하고 싶다면 시뮬레이션 스크립트를 그대로 실행하면 된다.
$ python3 dock_reservation_sim.py
로봇 1 요청 전송
결과: 로봇 1: 도크 1번 배정, 15분간 사용
로봇 2 요청 전송
결과: 로봇 2: 도크 2번 배정, 10분간 사용
로봇 3 요청 전송
시도 1/2 실패: 로봇 3: 사용 가능한 도크 없음
시도 2/2 실패: 로봇 3: 사용 가능한 도크 없음
결과: 로봇 3: 타임아웃, 요청 포기
실무에서 자주 틀리는 것
콜백 안에서 spin_until_future_complete 부르기
토픽 구독 콜백 안에서 서비스를 동기처럼 호출하면 실행자가 멈춰 응답을 받지 못한다.
def battery_low_callback(self, msg):
if msg.percent < 20:
request = ReserveDock.Request()
request.robot_id = self._robot_id
request.minutes = 30
future = self._client.call_async(request)
rclpy.spin_until_future_complete(self, future) # 교착 상태
response = future.result()
def battery_low_callback(self, msg):
if msg.percent < 20:
request = ReserveDock.Request()
request.robot_id = self._robot_id
request.minutes = 30
future = self._client.call_async(request)
future.add_done_callback(self._on_dock_reserved)
def _on_dock_reserved(self, future):
response = future.result()
self.get_logger().info(response.message)
wait_for_service 없이 바로 호출하기
서버가 아직 떠 있지 않은 상태에서 바로 요청을 보내면 타임아웃 시간을 그냥 흘려보내고서야 문제를 알게 된다.
future = self._client.call_async(request)
rclpy.spin_until_future_complete(self, future, timeout_sec=3.0)
if not self._client.wait_for_service(timeout_sec=3.0):
self.get_logger().error('reserve_dock 서비스가 아직 없음')
return None
future = self._client.call_async(request)
rclpy.spin_until_future_complete(self, future, timeout_sec=3.0)
서비스 콜백에서 response 반환을 빠뜨리기
필드는 채웠지만 반환문이 없으면 클라이언트는 응답을 영영 받지 못한다.
def _handle_reserve(self, request, response):
response.granted = True
response.dock_number = 1
response.message = '배정 완료'
# return 을 빼먹음
def _handle_reserve(self, request, response):
response.granted = True
response.dock_number = 1
response.message = '배정 완료'
return response
계속 바뀌는 값을 서비스로 매번 요청하기
배터리 잔량처럼 계속 바뀌는 값을 서비스로 매번 물어보면 호출 사이의 값을 놓치고 왕복 비용만 쌓인다.
def timer_callback(self):
future = self._battery_client.call_async(GetBattery.Request())
rclpy.spin_until_future_complete(self, future, timeout_sec=1.0)
percent = future.result().percent
self.get_logger().info(f'배터리 {percent}%')
def battery_callback(self, msg):
self.get_logger().info(f'배터리 {msg.percent}%')
한눈에 보기
| 기준 | 토픽 | 서비스 |
|---|---|---|
| 통신 방향 | 발행자 → 여러 구독자, 응답 없음 | 클라이언트 ↔ 서버, 1:1 요청-응답 |
| 호출 시점 | 주기적으로 계속 흘려보냄 | 필요할 때만 요청 |
| 결과 확인 | 받았는지 보장하지 않음 | 응답으로 성공·실패를 즉시 확인 |
| 두리 예시 | 배터리 잔량, 초음파 거리값 스트림 | 충전 도크 예약, 경로 재계산 요청 |
연습 문제
- 두리가 "지금 배터리 몇 퍼센트야"를 토픽 대신 서비스로 구현했다고 하자. 관제 노드가 1초마다 이 서비스를 호출해서 값을 가져오는 방식과, 두리가 배터리 값을 1초마다 토픽으로 발행하는 방식을 비교하고 어느 쪽이 더 적절한지 이유를 설명하라.
dock_manager_node.py의_handle_reserve콜백에서return response를 실수로 지웠다고 하자. 클라이언트 쪽에서는 어떤 현상이 나타나는가?dock_requester_node.py의reserve메서드에서wait_for_service호출을 지우면 어떤 상황에서 문제가 생기는가? 코드로 재현할 필요는 없고 시나리오만 서술하라.dock_reservation_sim.py의call_with_timeout함수에서max_attempts를 1로 바꾸면 로봇 3에 대한 출력이 어떻게 달라지는지 실행 없이 손으로 계산해 답하라.
정답과 해설
- 서비스로 폴링하면 관제 노드가 요청을 보내지 않는 순간의 값은 알 수 없고, 매 호출마다 요청-응답 왕복 비용이 든다. 게다가 배터리 값을 여러 노드가 동시에 구독해야 할 수도 있다. 계속 바뀌는 값을, 누가 얼마나 구독할지 모르는 상황에서는 토픽으로 흘리는 쪽이 맞다. 서비스는 "지금 당장 정확히 한 번 결과가 필요한 요청"에 적합하다.
_handle_reserve가 아무것도 반환하지 않으면 클라이언트의 future가 완료되지 않는다.reserve메서드는spin_until_future_complete가timeout_sec만큼 기다리다가future.done()이False인 채로 빠져나와 결국 "요청 시간 초과" 오류로 끝난다.wait_for_service없이 바로call_async를 부르면, 서비스 서버가 아직 뜨지 않았거나 이름을 잘못 적었어도 예외 없이 future가 반환된다. 이후spin_until_future_complete가timeout_sec만큼 그냥 기다리다 실패로 끝나므로, 서비스가 없다는 사실을 미리 알지 못한 채 매번 대기 시간을 다 소모하게 된다.max_attempts가 1이면for루프는 한 번만 돈다.handle_request가 여전히 실패를 돌려주므로 "시도 1/1 실패: 로봇 3: 사용 가능한 도크 없음" 한 줄만 찍히고, 루프가 끝난 뒤 반환되는 최종 메시지는 그대로 "로봇 3: 타임아웃, 요청 포기"다. 시도 실패 줄만 한 번으로 줄어들 뿐 최종 결과 문구는 바뀌지 않는다.
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.