VDA5050 order 수명주기: 노드·엣지 시퀀스, base와 horizon, order update 이어붙이기

이 글의 핵심

VDA5050의 order는 "경로 한 번 보내고 끝"이 아니라, 같은 orderId 아래에서 orderUpdateId를 올려 가며 경로를 조금씩 확정해 나가는 대화입니다. 노드와 엣지에 sequenceId를 매기는 규칙, 로봇이 실제로 가도 되는 base와 예정 경로인 horizon, 업데이트를 앞 주문의 마지막 base 노드에 이어붙이는 방식, 로봇이 주문을 받아들이거나 거부하는 판단 순서, 그리고 그 결과를 state의 nodeStates·lastNodeId로 보고하는 방법을 메시지 예제와 함께 따라갑니다.

이 글에서 다루는 범위

VDA5050은 무인 운반차(AGV)·자율 이동 로봇(AMR)과 관제 시스템(스펙의 용어로 master control, 3.0부터는 fleet control)이 MQTT로 주고받는 메시지를 표준화한 규격입니다. 토픽 구조와 메시지 종류의 개요는 VDA5050 MQTT 토픽과 메시지 구조에, 노드에 붙은 액션이 실행되는 과정은 VDA5050 Action 상태 머신에 정리해 두었습니다.

이 글은 그 사이에 있는 부분, 즉 order 하나가 로봇에 도착해서 끝날 때까지 무슨 일이 일어나는지를 다룹니다. 통신 모듈을 맡으면 가장 먼저, 가장 자주 부딪히는 곳이 여기입니다. “왜 이 주문이 거부됐나”, “업데이트를 보냈는데 로봇이 결정 지점에서 멈췄다”, “관제 화면에서는 로봇이 엉뚱한 노드에 있다고 나온다” 같은 문제는 대부분 이 규칙을 한쪽이 다르게 해석해서 생깁니다.

예제와 규칙은 현장에서 가장 많이 쓰이는 VDA5050 2.1 기준이고, 2026년 3월에 나온 3.0에서 달라진 곳은 그때마다 표시했습니다. 스펙 원문은 VDA5050 공식 저장소에서 버전별 브랜치로 볼 수 있으니, 이 글을 읽은 뒤 회사 시스템이 쓰는 버전의 원문을 한 번 처음부터 읽어 보는 것을 권합니다.

먼저 알아 둘 용어

용어뜻
node경로 위의 지점. 로봇이 멈추거나 지나가는 위치이고, 액션을 붙일 수 있습니다.
edge두 node를 잇는 방향 있는 구간. 속도 제한·방향·궤적 같은 주행 조건과 액션을 붙일 수 있습니다.
sequenceId한 주문 안의 node와 edge를 통틀어 매기는 순번(0, 1, 2…). 같은 node를 두 번 지날 때 구분하는 용도입니다.
released이 node·edge를 로봇이 실제로 지나가도 되는지. true면 base, false면 horizon입니다.
baserelease된 구간. 로봇이 가도 되는 경로입니다.
horizonrelease되지 않은 구간. 관제 시스템이 “이후 이렇게 갈 예정”이라고 알려 주는 참고용 경로입니다.
decision pointbase의 마지막 node. 업데이트가 오지 않으면 로봇은 여기서 멈춥니다.
orderId / orderUpdateId주문 식별자와, 같은 주문 안에서 업데이트할 때마다 올리는 번호입니다.
stitching node업데이트의 첫 node. 직전 base의 마지막 node와 같아야 하며, 여기서 두 메시지를 이어붙입니다.
lastNodeId / lastNodeSequenceId로봇이 마지막으로 통과한(또는 지금 서 있는) node. state로 보고합니다.
nodeStates / edgeStates아직 지나가야 할 node·edge 목록. 지나갈 때마다 하나씩 빠집니다.
allowedDeviationXY / Thetanode를 “통과했다”고 볼 수 있는 위치·방향 오차 범위입니다.

이 표의 개념은 서로 맞물려 있습니다. 관제 시스템은 경로를 sequenceId가 붙은 node·edge 목록으로 보내면서 앞부분만 release하고(base), 로봇은 base 끝(decision point)까지만 가면서 지나간 node를 nodeStates에서 지우고 lastNodeId로 보고합니다. 관제 시스템은 그 보고를 보고 교통 상황이 허락하면 orderUpdateId를 올린 업데이트로 다음 구간을 release합니다. 이 순환이 VDA5050 order의 전부라고 봐도 됩니다.

order 그래프의 규칙

관제 시스템은 공장 전체의 지도(node와 edge로 된 그래프)를 갖고 있지만, 로봇에게는 그중 이번에 지나갈 경로만 잘라서 보냅니다. 어떤 로봇이 어떤 edge를 지나가도 되는지 같은 제약도 관제 시스템이 걸러서 보내므로 로봇은 받은 경로를 그대로 따라가면 됩니다.

경로는 nodes와 edges 두 배열로 오고, 배열 순서가 곧 지나가는 순서입니다. 유효한 주문이 되려면 다음 조건을 지켜야 합니다.

  • node가 하나 이상 있어야 하고, edge 수는 node 수 − 1이어야 합니다. node 하나만 있는 주문(제자리에서 액션만 실행)도 유효하며, 이때 edge 배열은 비워 둡니다.
  • 첫 node는 로봇이 바로 도달할 수 있어야 합니다. 이미 그 node 위에 서 있거나, node의 허용 오차(allowedDeviationXY) 안에 있어야 합니다. 주문의 첫 node까지 가는 경로는 주문에 들어 있지 않으므로, 로봇이 엉뚱한 곳에 있으면 그 주문은 받을 수 없습니다.
  • edge는 양 끝 node가 모두 release되었을 때만 release할 수 있습니다.
  • release되지 않은 node·edge 뒤에 release된 node·edge가 올 수 없습니다. base는 항상 앞쪽에 한 덩어리로 있고, horizon이 그 뒤를 잇습니다.
  • horizon 없이 base만 있는 주문도 유효합니다.

통신 모듈에서 수신 검증을 구현할 때 이 조건을 그대로 코드로 옮기면 됩니다. JSON 스키마 검증만으로는 “edge 수 = node 수 − 1”이나 “release 순서” 같은 의미 규칙을 잡지 못하므로 별도 검사가 필요합니다. 이런 위반은 스키마 위반과 함께 형식 오류(2.x의 validationError)로 거부하는 것이 일반적입니다.

sequenceId 매기는 법

sequenceId는 node와 edge를 번갈아 셉니다. 첫 node가 0, 첫 edge가 1, 두 번째 node가 2, 두 번째 edge가 3…이므로 node는 짝수, edge는 홀수가 됩니다.

node A(0) ── edge e1(1) ── node B(2) ── edge e2(3) ── node A(4)

위처럼 A에서 B로 갔다가 A로 돌아오는 주문에서 nodeId “A”는 두 번 나오지만 sequenceId는 0과 4로 다릅니다. 로봇이 “lastNodeId A, lastNodeSequenceId 4”라고 보고하면 관제 시스템은 로봇이 왕복을 마쳤다는 것을 압니다. nodeId만 보고했다면 출발 직후인지 돌아온 뒤인지 구분할 수 없습니다.

sequenceId에는 지켜야 할 성질이 두 가지 있습니다. 한 번 부여한 값은 order update에서도 바뀌지 않고, 새 orderId가 시작될 때만 0부터 다시 시작합니다. 관제 시스템 쪽에서 업데이트를 만들 때마다 sequenceId를 0부터 새로 매기는 실수가 흔한데, 그러면 로봇은 업데이트의 첫 node를 직전 base의 마지막 node와 맞춰 볼 수 없어 업데이트를 거부합니다.

base, horizon, decision point

관제 시스템이 경로를 한 번에 전부 release하지 않는 이유는 교통 관리 때문입니다. 여러 로봇이 같은 통로와 교차로를 쓰므로, 관제 시스템은 “다음 교차로까지는 비어 있으니 거기까지만 가라”는 식으로 조금씩 길을 열어 줍니다. 이때 미리 release하지 않은 뒷부분을 horizon으로 함께 보내 두면, 로봇은 앞으로의 경로를 알고 속도를 계획할 수 있습니다(예를 들어 곧 회전할 것을 알고 미리 감속).

로봇은 base의 마지막 node, 즉 decision point까지 가서 업데이트가 없으면 멈춥니다. 매끄럽게 움직이게 하려면 관제 시스템이 로봇이 decision point에 닿기 전에 base를 늘려 줘야 합니다. 로봇 쪽에서 base가 얼마 남지 않았다고 판단하면 state의 newBaseRequest를 true로 올려 관제 시스템에 신호를 보낼 수 있습니다. 관제 시스템이 이 신호를 무시하면 로봇은 decision point마다 감속·정지를 반복하며 “뚝뚝 끊기는” 주행을 하게 되는데, 현장에서 로봇이 이유 없이 자꾸 선다는 문의의 흔한 원인입니다.

가장 중요한 규칙은 base는 바꿀 수 없다는 것입니다. MQTT는 비동기이고 공장 무선망은 메시지를 늦게 전달하거나 잃어버릴 수 있으므로, 관제 시스템은 일단 release한 구간은 이미 실행됐다고 가정해야 합니다. 업데이트로 바꿀 수 있는 것은 horizon뿐이고, horizon을 통째로 다른 경로로 바꾸거나 지우는 것은 허용됩니다. base를 바꿔야 하는 일(예: 앞 통로가 막힘)이 생기면 업데이트가 아니라 취소(cancelOrder) 후 새 주문으로 처리합니다.

order update: stitching으로 경로 이어붙이기

업데이트는 같은 orderId에 orderUpdateId를 1 올린 order 메시지입니다. 핵심 규칙은 업데이트의 첫 node가 직전 메시지 base의 마지막 node(decision point)와 같아야 한다는 것입니다. 이 node를 이음새(stitching node)로 삼아 로봇은 새 메시지를 기존 경로 뒤에 붙입니다.

창고 안에서 A → B → C → D → E로 가는 주문을 예로 들어 보겠습니다. 처음에는 C까지만 release하고 D, E는 horizon으로 보냅니다.

{
  "headerId": 101,
  "timestamp": "2026-10-18T01:00:00.00Z",
  "version": "2.1.0",
  "manufacturer": "RobotCompany",
  "serialNumber": "AMR-0007",
  "orderId": "TO-5521",
  "orderUpdateId": 0,
  "nodes": [
    { "nodeId": "A", "sequenceId": 0, "released": true,  "actions": [] },
    { "nodeId": "B", "sequenceId": 2, "released": true,  "actions": [] },
    { "nodeId": "C", "sequenceId": 4, "released": true,  "actions": [] },
    { "nodeId": "D", "sequenceId": 6, "released": false, "actions": [] },
    { "nodeId": "E", "sequenceId": 8, "released": false, "actions": [
        { "actionId": "act-drop-1", "actionType": "drop", "blockingType": "HARD", "actionParameters": [] }
    ] }
  ],
  "edges": [
    { "edgeId": "A-B", "sequenceId": 1, "released": true,  "startNodeId": "A", "endNodeId": "B", "actions": [] },
    { "edgeId": "B-C", "sequenceId": 3, "released": true,  "startNodeId": "B", "endNodeId": "C", "actions": [] },
    { "edgeId": "C-D", "sequenceId": 5, "released": false, "startNodeId": "C", "endNodeId": "D", "actions": [] },
    { "edgeId": "D-E", "sequenceId": 7, "released": false, "startNodeId": "D", "endNodeId": "E", "actions": [] }
  ]
}

(지면을 줄이려고 nodePosition은 생략했습니다. 자율 주행 로봇이라면 각 node에 x, y, mapId와 필요하면 theta, allowedDeviationXY가 들어갑니다.)

로봇이 B를 지나 C로 가는 동안 관제 시스템은 C-D 구간이 비었다고 판단하고 업데이트를 보냅니다. 첫 node는 decision point였던 C이고, D와 D로 가는 edge를 release하며, E는 여전히 horizon으로 둡니다.

{
  "headerId": 102,
  "orderId": "TO-5521",
  "orderUpdateId": 1,
  "nodes": [
    { "nodeId": "C", "sequenceId": 4, "released": true,  "actions": [] },
    { "nodeId": "D", "sequenceId": 6, "released": true,  "actions": [] },
    { "nodeId": "E", "sequenceId": 8, "released": false, "actions": [
        { "actionId": "act-drop-1", "actionType": "drop", "blockingType": "HARD", "actionParameters": [] }
    ] }
  ],
  "edges": [
    { "edgeId": "C-D", "sequenceId": 5, "released": true,  "startNodeId": "C", "endNodeId": "D", "actions": [] },
    { "edgeId": "D-E", "sequenceId": 7, "released": false, "startNodeId": "D", "endNodeId": "E", "actions": [] }
  ]
}

(헤더의 timestamp·version·manufacturer·serialNumber는 실제 메시지에는 매번 들어가지만 여기서는 생략했습니다.)

이 업데이트에서 눈여겨볼 점이 네 가지 있습니다.

  1. A, B는 다시 보내지 않습니다. base는 바꿀 수 없으므로 이미 release한 node를 재전송하는 것은 규칙 위반입니다. 다시 보내는 base node는 이음새인 C 하나뿐입니다.
  2. C의 sequenceId는 처음 받은 4 그대로입니다. 로봇은 “업데이트의 첫 node가 nodeId C, sequenceId 4인가”를 직전 base의 마지막 node와 비교해 이어붙일 수 있는지 판단합니다.
  3. 이음새 node의 내용은 바꾸지 않습니다. C에 붙은 액션이나 허용 오차를 업데이트에서 다르게 보내도, 로봇은 처음 받은 메시지(orderUpdateId 0)의 C를 기준으로 동작합니다. 이음새 node에서 액션을 바꾸고 싶다면 업데이트로는 안 된다는 뜻입니다.
  4. horizon은 다시 보내는 것이 정상입니다. E는 아직 release되지 않았으므로 업데이트마다 그때의 계획대로 다시 보내거나, 바꾸거나, 빼도 됩니다.

마지막으로 관제 시스템이 orderUpdateId 2로 첫 node D(sequenceId 6), edge D-E, node E를 모두 release하면 로봇은 E까지 가서 drop 액션을 실행하고 주문을 마칩니다.

이음새 규칙은 업데이트를 잃어버렸을 때도 안전장치 역할을 합니다. 무선 구간에서 orderUpdateId 1이 사라지고 2가 먼저 도착했다고 해 봅시다. 로봇이 아는 decision point는 C인데 업데이트 2의 첫 node는 D이므로 이어붙일 수 없고, 로봇은 업데이트를 거부한 채 C에서 멈춥니다. 위험하게 엉뚱한 경로로 달려가는 대신 멈추고 오류를 보고하는 쪽으로 실패하도록 설계된 것입니다. 관제 시스템은 state에서 로봇의 orderUpdateId가 여전히 0이고 C에 멈춰 있다는 것을 보고 상황을 파악할 수 있습니다.

로봇이 주문을 받아들이는 판단 순서

스펙(2.1의 6.6.2절 Figure 8)은 로봇이 order 메시지를 받았을 때 판단하는 순서를 정해 두었습니다. 통신 모듈의 수신 처리 로직이 사실상 이 흐름도를 구현하는 것입니다.

flowchart TD
  R["order 수신"] --> V{"형식이 올바른가?<br/>(스키마·의미 규칙)"}
  V -- 아니오 --> RJ1["거부: validationError"]
  V -- 예 --> N{"orderId가<br/>현재 주문과 다른가?"}
  N -- "예 (새 주문)" --> B{"아직 주문을 실행 중이거나<br/>업데이트를 기다리는 중인가?"}
  B -- 예 --> RJ2["거부"]
  B -- 아니오 --> S{"첫 node가 현재 위치에서<br/>바로 도달 가능한가?"}
  S -- 아니오 --> RJ3["거부"]
  S -- 예 --> ACC["수락: 상태를 새로 채움"]
  N -- "아니오 (업데이트)" --> L{"orderUpdateId가<br/>현재보다 작은가?"}
  L -- 예 --> RJ4["거부: orderUpdateError<br/>(기존 주문 계속 실행)"]
  L -- 아니오 --> EQ{"orderUpdateId가<br/>현재와 같은가?"}
  EQ -- 예 --> IG["무시 (재전송)"]
  EQ -- 아니오 --> RUN{"아직 실행 중이거나<br/>horizon이 남았는가?"}
  RUN -- 예 --> ST1{"첫 node == 현재<br/>decision point?"}
  RUN -- 아니오 --> ST2{"첫 node의 nodeId·sequenceId ==<br/>lastNodeId·lastNodeSequenceId?"}
  ST1 -- 예 --> APP["수락: 상태 뒤에 이어붙임"]
  ST2 -- 예 --> APP
  ST1 -- 아니오 --> RJ5["거부"]
  ST2 -- 아니오 --> RJ5

“실행 중”의 판단 기준은 상태 배열입니다. nodeStates가 비어 있지 않거나, actionStates에 FINISHED도 FAILED도 아닌 액션이 남아 있으면 로봇은 아직 주문을 실행 중이거나 업데이트를 기다리는 중입니다. horizon의 node도 nodeStates에 들어 있으므로, base를 다 지나 decision point에 서 있어도 horizon이 남아 있으면 “업데이트 대기 중”으로 봅니다.

업데이트를 이어붙이는 기준이 두 가지로 나뉘는 것도 이 때문입니다. 주문이 아직 살아 있으면 업데이트의 첫 node가 현재 decision point와 같아야 하고, base와 액션을 모두 끝내고 horizon도 없는 상태라면 첫 node가 lastNodeId·lastNodeSequenceId와 같아야 합니다. 후자는 관제 시스템이 끝난 주문을 같은 orderId로 이어서 연장하는 경우입니다.

이 흐름을 코드로 옮기면 대략 다음과 같습니다. 실제 구현에서는 수락·거부 결과를 state의 errors에 반영하고 즉시 state를 발행하는 부분이 함께 들어갑니다.

// order 수신 판단 (VDA5050 2.1 Figure 8 기준 스케치)
enum class OrderDecision { AcceptNew, AppendUpdate, IgnoreDuplicate, Reject };

OrderDecision decide(const Order& in, const VehicleState& st, std::string& error) {
  if (!validate(in, error)) {            // 스키마 + 의미 규칙 (edge 수, release 순서 등)
    error = "validationError";
    return OrderDecision::Reject;
  }

  if (in.orderId != st.orderId) {        // 새 주문
    if (st.isExecutingOrWaitingForUpdate()) {
      error = kOtherOrderActive;         // 2.x는 이름을 정하지 않음, 통합 시 합의 (3.0: OTHER_ORDER_ACTIVE)
      return OrderDecision::Reject;
    }
    if (!isTriviallyReachable(in.nodes.front(), st.position)) {
      error = kStartNodeOutOfRange;      // 2.x는 이름을 정하지 않음 (3.0: START_NODE_OUT_OF_RANGE)
      return OrderDecision::Reject;
    }
    return OrderDecision::AcceptNew;
  }

  // 같은 orderId: 업데이트
  if (in.orderUpdateId < st.orderUpdateId) {
    error = "orderUpdateError";          // 기존 주문은 계속 실행
    return OrderDecision::Reject;
  }
  if (in.orderUpdateId == st.orderUpdateId) {
    return OrderDecision::IgnoreDuplicate;
  }

  const auto& first = in.nodes.front();
  const bool stitches = st.isExecutingOrWaitingForUpdate()
      ? (first.nodeId == st.decisionPoint().nodeId &&
         first.sequenceId == st.decisionPoint().sequenceId)
      : (first.nodeId == st.lastNodeId &&
         first.sequenceId == st.lastNodeSequenceId);
  if (!stitches) {
    error = kStitchingMismatch;          // 2.x는 이름을 정하지 않음, 통합 시 합의
    return OrderDecision::Reject;
  }
  return OrderDecision::AppendUpdate;
}

거부할 때 지켜야 할 공통 원칙은 받은 주문을 내부 버퍼에 넣지 않는 것입니다. 업데이트를 거부했다면 기존 주문을 그대로 들고 계속 실행합니다. 거부 사유는 state의 errors에 WARNING 수준으로 넣고, 원인을 찾을 수 있도록 errorReferences에 문제가 된 headerId, 토픽, orderId, orderUpdateId(액션 문제라면 actionId)를 담습니다. 이 경고는 로봇이 새 주문을 받아들일 때까지 계속 보고합니다.

2.x가 이름을 붙여 둔 거부 경고는 형식 오류인 validationError, 수행할 수 없는 액션이나 쓸 수 없는 필드가 든 주문인 orderError, 예전 orderUpdateId가 온 경우의 orderUpdateError, 취소할 주문이 없을 때의 noOrderToCancel입니다. 그 밖의 거부 사유는 현장마다 이름이 달라서, 관제 시스템과 로봇이 어떤 errorType 문자열을 쓸지 통합 단계에서 맞춰 두어야 합니다. 3.0은 이 부분을 정리해 VALIDATION_FAILURE, INVALID_ORDER_ACTION, UNSUPPORTED_PARAMETER, OUTDATED_ORDER_UPDATE, SAME_ORDER_UPDATE_ID, OTHER_ORDER_ACTIVE, START_NODE_OUT_OF_RANGE, NO_ROUTE_TO_TARGET, UNKNOWN_MAP_ID, MOBILE_ROBOT_NOT_AVAILABLE처럼 상황별 이름을 정해 두었습니다.

진행 상황 보고: nodeStates와 lastNodeId

로봇은 주문 진행을 state 메시지로 보고합니다. 진행 상황은 두 가지로 표현됩니다. 아직 지나가야 할 것은 nodeStates·edgeStates 배열에, 마지막으로 지나간 곳은 lastNodeId·lastNodeSequenceId에 담습니다. 이미 지나간 node를 목록으로 쌓아 보고하지는 않습니다.

앞의 예제에서 로봇이 A 위에 서 있는 상태로 orderUpdateId 0을 받았다면, 첫 node A는 이미 도달한 것이므로 곧바로 통과 처리됩니다.

{
  "orderId": "TO-5521",
  "orderUpdateId": 0,
  "lastNodeId": "A",
  "lastNodeSequenceId": 0,
  "nodeStates": [
    { "nodeId": "B", "sequenceId": 2, "released": true },
    { "nodeId": "C", "sequenceId": 4, "released": true },
    { "nodeId": "D", "sequenceId": 6, "released": false },
    { "nodeId": "E", "sequenceId": 8, "released": false }
  ],
  "edgeStates": [
    { "edgeId": "A-B", "sequenceId": 1, "released": true },
    { "edgeId": "B-C", "sequenceId": 3, "released": true },
    { "edgeId": "C-D", "sequenceId": 5, "released": false },
    { "edgeId": "D-E", "sequenceId": 7, "released": false }
  ],
  "actionStates": [
    { "actionId": "act-drop-1", "actionType": "drop", "actionStatus": "WAITING" }
  ],
  "driving": true,
  "newBaseRequest": false
}

(batteryState, errors, operatingMode, safetyState 같은 필수 필드는 생략했습니다.)

horizon에 있는 E의 drop 액션도 주문을 받는 순간 actionStates에 WAITING으로 들어간다는 점을 보세요. 2.1의 수락 판단에서 “실행 중인가”를 actionStates로도 보는 이유가 이것입니다. 로봇이 B를 통과하면 B와 A-B가 배열에서 빠지고 lastNodeId가 B(2)로 바뀌며, 이 변화 자체가 state를 발행할 이벤트입니다.

업데이트 1을 받아들이면 로봇은 nodeStates의 D와 C-D를 released: true로 바꿉니다. 관제 시스템은 state의 orderUpdateId가 1로 바뀐 것을 보고 업데이트가 전달됐다는 것을 확인합니다. order 토픽은 QoS 0이라 MQTT 수준의 전달 확인이 없으므로, 관제 시스템이 주문 전달을 확인하는 유일한 수단이 state의 orderId·orderUpdateId라는 점을 기억해 두면 좋습니다. 일정 시간 안에 state에 반영되지 않으면 관제 시스템이 같은 주문을 다시 보내고, 로봇은 같은 ID라서 무시하는 구조입니다.

노드 통과와 액션 실행 시점

node를 언제 “통과했다”고 볼지는 로봇이 정합니다. 기준은 로봇의 제어점이 node의 allowedDeviationXY 안에, 방향이 allowedDeviationTheta 안에 들어왔는가이고, 다음 edge에 corridor가 있으면 그 경계도 만족해야 합니다. 통과를 판정하면 다음이 한꺼번에 일어납니다.

  • 그 node를 nodeStates에서 지우고 lastNodeId·lastNodeSequenceId를 그 node로 바꿉니다.
  • 그 node에 붙은 액션을 시작합니다.
  • 그 node로 들어오던 edge를 떠난 것이므로, edge를 edgeStates에서 지우고 그 edge에서 돌던 액션을 끝냅니다.
  • 다음 edge에 들어간 것이므로 그 edge의 액션을 시작합니다. 단, node의 액션 때문에 로봇이 멈춰야 한다면(blockingType이 SOFT나 HARD) 다시 움직이기 시작할 때 edge에 들어간 것으로 봅니다.

edge 액션과 node 액션은 수명이 다릅니다. edge 액션은 그 edge를 지나는 동안에만 유효해서, edge를 떠나면 끝나고 들어가기 전 상태로 돌아갑니다(구간 경고등 같은 용도). node 액션은 필요한 만큼 계속 돌 수 있고, pick처럼 스스로 끝나거나 경고등 켜기·끄기처럼 짝으로 보내도록 설계합니다.

한 node에 액션이 여러 개면 배열 순서가 실행 순서이고, 병렬 실행 여부는 blockingType이 정합니다. 2.x의 세 가지는 주행과 다른 액션을 모두 허용하는 NONE, 다른 액션은 허용하지만 주행은 막는 SOFT, 혼자만 실행되며 주행도 막는 HARD입니다. 3.0은 여기에 “다른 액션과 병렬로는 안 되지만 주행은 허용”하는 SINGLE을 추가했습니다. 액션 상태 전이와 blockingType 처리의 세부는 Action 상태 머신 글에서 다룹니다.

주문 취소: cancelOrder

base를 바꿔야 하는 상황이면 관제 시스템은 instantActions 토픽으로 cancelOrder 액션을 보냅니다. 로봇은 가능한 한 빨리 멈추는데, 그 시점은 로봇의 능력에 따라 다릅니다. 바닥 태그로 위치를 잡는 선 유도식 차량은 다음 node에서, 경로 중간에 설 수 있는 자율 주행 로봇은 그 자리에서 멈추는 것이 일반적입니다(3.0은 자율 주행 로봇이라면 다음 node까지 가지 말고 가능한 한 빨리 서라고 명확히 했습니다).

취소 처리 중에는 액션 상태를 다음처럼 보고합니다.

  • 아직 시작하지 않은 액션은 취소하고 FAILED로 보고합니다.
  • 실행 중인 액션도 가능하면 취소하고 FAILED로 보고합니다. 중간에 멈출 수 없는 액션(들어 올리던 짐을 내려놓는 중 등)은 끝날 때까지 RUNNING으로, 끝나면 결과에 따라 FINISHED나 FAILED로 보고합니다.
  • cancelOrder 액션 자신은 다른 액션이 정리되는 동안 RUNNING이고, 모든 움직임과 액션이 멈추면 FINISHED가 됩니다.
  • orderId와 orderUpdateId는 지우지 않고 그대로 둡니다. nodeStates·edgeStates는 비워집니다.

취소할 주문이 없는데 cancelOrder가 오면 그 액션을 FAILED로 보고하고, noOrderToCancel(3.0: NO_ORDER_TO_CANCEL) 경고를 instantAction의 actionId를 참조로 담아 보고합니다.

취소 후에는 새 주문을 받을 수 있습니다. 새 주문의 첫 node도 “바로 도달 가능”해야 하므로 시작점을 정하는 방법이 문제가 되는데, 스펙은 경로 중간에 설 수 있는 로봇이라면 다음 두 방법을 모두 받아들이도록 정합니다.

  1. 로봇이 지금 서 있는 위치에 임시 node를 만들어 첫 node로 보냅니다.
  2. 직전 주문의 마지막 통과 node를 첫 node로 보내되, allowedDeviationXY를 로봇의 현재 위치가 들어갈 만큼 크게 잡습니다. 로봇은 이 node를 즉시 통과한 것으로 처리합니다.

선 유도식 차량은 node 위에서만 위치를 알 수 있으므로, 새 주문은 로봇이 지금 서 있는 node에서 시작해야 합니다. 3.0은 취소한 주문에 업데이트를 보내지 말라고 명시하고, 그런 업데이트가 오면 ORDER_UPDATE_FOLLOWING_CANCEL 경고를 보고하도록 했습니다.

state 발행과 연결 관리 규칙

통신 모듈이 지켜야 하는 전송 규칙을 한곳에 모으면 다음과 같습니다.

항목규칙
QoSorder, instantActions, state, factsheet, visualization은 QoS 0. connection만 QoS 1
Retainconnection은 retain으로 발행. order·instantActions는 retain하지 않음 (재접속 때 예전 명령이 다시 전달되는 것을 막기 위해)
state 주기관련 이벤트가 생길 때마다, 그리고 늦어도 30초마다 한 번. 동시에 생긴 이벤트는 한 메시지로 묶음
headerId토픽별로 따로 세며, 보낼 때마다 1씩 증가 (상대가 받았는지와 무관)
timestampISO 8601 UTC. 2.x는 2017-04-15T11:40:03.12Z처럼 1/100초, 3.0은 1/1000초
connection접속할 때 Last Will을 CONNECTIONBROKEN으로 등록하고, 접속 직후 ONLINE 발행. 정상 종료할 때는 OFFLINE을 보낸 뒤 MQTT disconnect
keep-alive브로커와의 heartbeat 간격은 15초 정도 권장
연결이 끊겼을 때로봇은 주문 정보를 유지하고 마지막으로 release된 node까지는 계속 실행

여기서 자주 생기는 오해가 몇 가지 있습니다.

headerId는 확인 응답이 아닙니다. 토픽마다 송신 측이 1씩 올리는 번호일 뿐이라, 상대가 받았는지는 알 수 없습니다. 받는 쪽에서 번호가 건너뛰면 그 사이 메시지가 사라졌다는 단서는 되지만, 그것으로 재전송을 요청하는 구조는 아닙니다. 프로세스를 재시작하면 headerId가 0부터 다시 시작할 수 있으므로, 관제 시스템이 headerId가 줄어든 메시지를 버리도록 구현하면 재시작한 로봇의 state를 놓치게 됩니다.

connection 토픽으로 로봇 상태를 판단하지 않습니다. 스펙은 connection을 MQTT 연결 수준의 확인용이라고 못 박고, 로봇의 건강 상태는 state로 판단하라고 합니다. Last Will은 접속할 때 미리 등록해 두는 메시지라서 그 안의 timestamp와 headerId는 항상 오래된 값이라는 점도 알아 두면 좋습니다. 또 Last Will은 연결이 비정상적으로 끊길 때만 브로커가 보내므로, 로봇이 정상 종료하면서 OFFLINE을 직접 보내지 않으면 관제 시스템은 로봇이 아직 온라인이라고 생각합니다.

driving은 바퀴 이동과 회전만 뜻합니다. 포크를 올리는 동작 같은 다른 움직임은 포함하지 않습니다. 관제 화면에서 “로봇이 멈춰 있다”를 driving: false만으로 판단하면 리프트가 움직이는 중인 로봇 옆으로 다른 로봇을 보내는 계획이 나올 수 있으므로, actionStates를 함께 봐야 합니다.

운전 모드가 바뀌면 주문 처리가 달라집니다. operatingMode가 AUTOMATIC·SEMIAUTOMATIC일 때만 관제 시스템이 로봇을 제어하고, MANUAL·SERVICE·TEACHIN에서는 주문을 보내지 않습니다. 특히 MANUAL로 들어가거나 나올 때 로봇은 주문을 모두 지웁니다. 작업자가 로봇을 수동으로 옮긴 뒤 자동으로 돌려놓으면 이전 주문은 없어진 상태이므로, 관제 시스템은 새 주문을 처음부터 보내야 합니다.

3.0에서 통신 모듈에 영향이 큰 변화

3.0은 문서 구조부터 다시 짰을 만큼 변화가 크지만, 통신 모듈 입장에서 먼저 확인할 것은 다음 정도입니다.

  • 용어와 토픽: AGV는 mobile robot, master control은 fleet control로 바뀌었고, 토픽 예시가 vda5050/v3/...가 되었습니다. zoneSet(구역 정보 전달)과 responses(로봇의 요청에 대한 관제 응답) 토픽이 선택 사항으로 추가되었습니다.
  • idle 정의: nodeStates·edgeStates가 비어 있고 모든 액션이 FINISHED나 FAILED인 상태를 idle로 정의하고, 새 주문은 idle일 때만 받는다고 명시했습니다. 업데이트는 idle이든 실행 중이든 받을 수 있습니다.
  • 에러 체계: errorType이 확장 가능한 열거형이 되었고, 앞에서 본 것처럼 거부 사유별 이름이 정해졌습니다. 에러 수준에 CRITICAL, URGENT가 추가되었습니다.
  • 액션: blockingType SINGLE, 재시도하거나 건너뛸 수 있는 액션 상태 RETRIABLE, 액션별 pauseAllowed·cancelAllowed가 생겼습니다. actionStates가 actionStates·instantActionStates·zoneActionStates로 나뉘었습니다. startPause·stopPause·cancelOrder는 모든 로봇이 반드시 지원해야 합니다.
  • cancelOrder: 취소할 주문을 가리키는 orderId를 선택적으로 넣을 수 있고, 현재 주문과 다르면 NO_ORDER_TO_CANCEL로 실패합니다.
  • 운전 모드: 브로커에는 접속했지만 아직 주문을 받을 준비가 안 된 STARTUP, 주문을 지우지 않고 자율 주행을 잠시 끼어들어 멈추는 INTERVENED 등이 추가되었습니다.
  • 필드 변경: edge의 rotationAllowed가 반대 의미의 reachOrientationBeforeEntering으로 바뀌었고, allowedDeviationXY가 타원으로 확장되었습니다. 2.x 메시지를 3.0으로 변환하는 게이트웨이를 만든다면 이런 반전·확장 필드를 특히 조심해야 합니다.

메이저 버전이 다르면 토픽 경로부터 분리되므로, 2.x 로봇과 3.0 로봇을 한 현장에서 함께 쓰려면 관제 시스템이 두 버전을 모두 말하거나 중간에서 변환하는 게이트웨이가 필요합니다. 그 구조는 AGV·AMR 플릿 게이트웨이 개발 가이드를 참고하세요.

통신 모듈에서 자주 생기는 문제

증상흔한 원인
업데이트를 보냈는데 로봇이 decision point에서 멈춤업데이트의 첫 node가 직전 base의 마지막 node와 다름. sequenceId를 업데이트마다 0부터 새로 매기거나, 이미 지나간 base node까지 다시 보낸 경우가 많음
업데이트가 거부되고 orderUpdateError가 뜸관제 시스템이 재시작 후 orderUpdateId를 0부터 다시 셈. 주문별 orderUpdateId는 관제 시스템이 영속 저장해야 함
같은 액션이 두 번 실행됨재전송된 같은 orderUpdateId의 주문을 무시하지 않고 새로 처리함. orderId·orderUpdateId 비교로 중복을 걸러야 함
새 주문이 계속 거부됨이전 주문의 horizon이 남아 있거나 액션이 WAITING·RUNNING으로 남아 로봇이 “실행 중”으로 판단. horizon만 남은 주문은 cancelOrder로 정리해야 새 주문을 받음
관제 화면의 로봇 위치가 한 칸 늦음node 통과 시점에 state를 발행하지 않고 30초 주기 발행만 기다림
수동 운전 후 로봇이 아무 주문도 받지 않음MANUAL 전환으로 주문이 지워졌는데 관제 시스템은 이전 주문이 살아 있다고 보고 업데이트만 보냄
첫 주문이 거부됨로봇 위치가 첫 node의 허용 오차 밖이거나, 로봇이 아직 위치를 초기화하지 않음(initPosition 필요)

업무 적응을 위한 학습 순서

도메인을 처음 맡았다면 다음 순서로 보는 것이 효율적입니다.

  1. 스펙 원문의 order·state 절을 끝까지 읽습니다. 회사 시스템이 쓰는 버전의 VDA5050_EN.md에서 2.x 기준 6.6절(order)과 6.10절(state)이 이 글의 원문입니다. 그림(Figure 4~8, 14, 15)을 꼭 함께 보세요.
  2. 실제 메시지를 봅니다. 개발 브로커에서 mosquitto_sub -t 'uagv/v2/+/+/#' -v처럼 전체 토픽을 구독해 두고, 관제 화면에서 주문을 하나 내려 order와 state가 오가는 모습을 시간 순서대로 따라가 보면 위의 규칙이 실제로 어떻게 보이는지 한눈에 들어옵니다(토픽 앞부분은 회사 설정에 맞추세요).
  3. JSON 스키마로 검증해 봅니다. 공식 저장소의 json_schemas 폴더에 메시지별 스키마가 있습니다. 실제 로그를 스키마로 검증해 보면 우리 시스템이 표준에서 벗어난 곳(필드 이름, 빠진 필수 필드)을 빨리 찾을 수 있습니다.
  4. factsheet를 읽습니다. 로봇이 지원하는 액션, 최대 node·edge 개수(protocolLimits), 지원 필드(protocolFeatures)가 들어 있어서, 관제 시스템이 보내면 안 되는 것을 판단하는 기준이 됩니다.
  5. 가상 로봇으로 실험합니다. 실제 로봇 없이 order를 받아 nodeStates를 줄여 가며 state를 내는 간단한 MQTT 클라이언트를 만들어 보면, 이음새 규칙이나 거부 흐름을 손으로 확인할 수 있습니다.

로봇이 실제로 어떻게 경로를 따라가는지(위치 추정, 경로 계획, ROS 2)도 함께 알아 두면 order의 node 좌표와 허용 오차가 왜 그런 값인지 이해하기 쉬워집니다. 소프트웨어 엔지니어가 알아야 할 AMR 기초와 AMR 경로 계획에서 미션 실행까지를 이어서 읽어 보세요.

참고 자료

같이 보면 좋은 글