Devin.KR

서비스 - 요청하고 응답받기

개발자KR 조회 4

이 장에서 배우는 것

앞 장에서는 두리가 주고받을 메시지의 생김새를 직접 설계했다. 이번 장에서는 그 메시지를 실어 나르는 통신 방식을 하나 더 배운다. 지금까지 써 온 토픽은 누가 듣고 있는지 신경 쓰지 않고 값을 흘려보내는 방식이었다. 하지만 "지금 이 도크를 써도 되는가"처럼 반드시 답을 받아야 하는 질문도 있다. 이런 요청-응답 상황을 다루는 통신 방식이 서비스(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 부르기

토픽 구독 콜백 안에서 서비스를 동기처럼 호출하면 실행자가 멈춰 응답을 받지 못한다.

콜백 안에서 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초마다 이 서비스를 호출해서 값을 가져오는 방식과, 두리가 배터리 값을 1초마다 토픽으로 발행하는 방식을 비교하고 어느 쪽이 더 적절한지 이유를 설명하라.
  2. dock_manager_node.py의 _handle_reserve 콜백에서 return response를 실수로 지웠다고 하자. 클라이언트 쪽에서는 어떤 현상이 나타나는가?
  3. dock_requester_node.py의 reserve 메서드에서 wait_for_service 호출을 지우면 어떤 상황에서 문제가 생기는가? 코드로 재현할 필요는 없고 시나리오만 서술하라.
  4. dock_reservation_sim.py의 call_with_timeout 함수에서 max_attempts를 1로 바꾸면 로봇 3에 대한 출력이 어떻게 달라지는지 실행 없이 손으로 계산해 답하라.

정답과 해설

  1. 서비스로 폴링하면 관제 노드가 요청을 보내지 않는 순간의 값은 알 수 없고, 매 호출마다 요청-응답 왕복 비용이 든다. 게다가 배터리 값을 여러 노드가 동시에 구독해야 할 수도 있다. 계속 바뀌는 값을, 누가 얼마나 구독할지 모르는 상황에서는 토픽으로 흘리는 쪽이 맞다. 서비스는 "지금 당장 정확히 한 번 결과가 필요한 요청"에 적합하다.
  2. _handle_reserve가 아무것도 반환하지 않으면 클라이언트의 future가 완료되지 않는다. reserve 메서드는 spin_until_future_complete가 timeout_sec만큼 기다리다가 future.done()이 False인 채로 빠져나와 결국 "요청 시간 초과" 오류로 끝난다.
  3. wait_for_service 없이 바로 call_async를 부르면, 서비스 서버가 아직 뜨지 않았거나 이름을 잘못 적었어도 예외 없이 future가 반환된다. 이후 spin_until_future_complete가 timeout_sec만큼 그냥 기다리다 실패로 끝나므로, 서비스가 없다는 사실을 미리 알지 못한 채 매번 대기 시간을 다 소모하게 된다.
  4. max_attempts가 1이면 for 루프는 한 번만 돈다. handle_request가 여전히 실패를 돌려주므로 "시도 1/1 실패: 로봇 3: 사용 가능한 도크 없음" 한 줄만 찍히고, 루프가 끝난 뒤 반환되는 최종 메시지는 그대로 "로봇 3: 타임아웃, 요청 포기"다. 시도 실패 줄만 한 번으로 줄어들 뿐 최종 결과 문구는 바뀌지 않는다.

댓글 0

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

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