본문으로 건너뛰기 VDA5050 Action 상태 머신 완전 분석 - pick 액션 처리와 blockingType 실전 구현

VDA5050 Action 상태 머신 완전 분석 - pick 액션 처리와 blockingType 실전 구현

VDA5050 Action 상태 머신 완전 분석 - pick 액션 처리와 blockingType 실전 구현

이 글의 핵심

VDA5050 order 메시지에 붙는 action, 특히 pick 액션이 AGV/AMR 내부에서 어떻게 상태 머신으로 처리되는지 심화 분석합니다. actionStatus 전이, blockingType이 주행에 미치는 실제 영향, actionParameters 파싱부터 하드웨어 호출·load 상태 갱신·에러 보고까지 실전 C++ 구현 패턴과 트러블슈팅을 다룹니다.

들어가며: order에 action이 붙는 것과, action이 “잘 실행되는 것”은 다른 문제

VDA5050 프로토콜 완벽 가이드에서는 order 메시지가 node·edge·action으로 구성된 그래프 구조를 가진다는 점과, actionId·actionType·blockingType 같은 필드가 JSON 스키마 상에 어떻게 존재하는지, 그리고 ActionHandler 인터페이스를 라이브러리가 어떻게 노출하는지를 다뤘습니다. 하지만 실제로 AGV를 개발하고 현장에 투입해 본 사람이라면, “JSON 스키마를 이해했다”와 “pick 액션이 창고 바닥에서 실제로 실수 없이 동작한다”는 완전히 다른 난이도의 문제라는 것을 압니다.

pick 액션 하나가 실행되는 동안 AGV 펌웨어 내부에서는 다음과 같은 질문에 답해야 합니다. 지금 이 순간 액션이 정확히 어떤 상태인가, 이 상태를 관제 시스템에 어떻게 보고해야 하는가, HARD 블로킹인 액션이 끝나기 전에 차량이 실수로 다음 구간을 진행해 버리지는 않는가, 픽업이 끝난 뒤 화물 정보를 어떻게 갱신해서 다음 주문 계산에 반영시킬 것인가, 그리고 그리퍼가 응답하지 않을 때 무엇을 해야 하는가. 이 글은 이 질문들에 초점을 맞춰, 이전 글에서 다루지 않은 action 실행 계층의 실전 구현을 파고듭니다.

Action 상태 머신: actionStatus 전이 규칙

VDA5050에서 액션의 생애주기는 actionStatus 필드가 가질 수 있는 값들의 상태 전이로 정의됩니다. 표준이 정의하는 값은 WAITING, INITIALIZING, RUNNING, PAUSED, FINISHED, FAILED이며, AGV 구현체는 매 순간 이 중 정확히 하나의 값으로 액션의 현재 상태를 state 메시지의 actionStates 배열에 채워 보고해야 합니다.

stateDiagram-v2
  [*] --> WAITING: order 수신, action이 아직 도달할 node/edge에 있음
  WAITING --> INITIALIZING: 실행 조건 충족(node 도착, blockingType 순서 도래)
  INITIALIZING --> RUNNING: 하드웨어 제어 API 호출 시작
  RUNNING --> PAUSED: instantActions로 pause 수신 (선택적)
  PAUSED --> RUNNING: instantActions로 resume 수신
  RUNNING --> FINISHED: 하드웨어 완료 콜백 성공
  RUNNING --> FAILED: 하드웨어 오류·타임아웃
  INITIALIZING --> FAILED: 파라미터 검증 실패·하드웨어 초기화 실패
  FINISHED --> [*]
  FAILED --> [*]

각 상태로의 전이는 저절로 일어나지 않고, 명확한 트리거에 의해서만 발생해야 합니다. 트리거를 명확히 정의해 두지 않으면, 같은 코드베이스 안에서도 개발자마다 “언제 RUNNING으로 바꿀지”에 대한 해석이 갈려 상태 보고가 들쭉날쭉해지는 문제가 생깁니다.

상태별 트리거와 보고 의무

actionStatus전이 트리거이 시점에 state.actionStates에 채워야 할 것
WAITINGorder를 수신했지만 아직 해당 node/edge에 도달하지 않았거나, blockingType 순서상 앞선 액션이 끝나지 않음actionId, actionType, actionStatus: "WAITING" (선택적으로 resultDescription은 비워둠)
INITIALIZING차량이 해당 node에 도착했고, 실행 순서상 시작 가능한 시점이 됨actionStatus: "INITIALIZING". 하드웨어 초기화(예: 포크 높이 원점 복귀)가 필요하면 이 구간에서 수행
RUNNING하드웨어 제어 API 호출이 실제로 시작되어 진행 중임을 확인actionStatus: "RUNNING". 필요하다면 resultDescription에 진행률 등 부가 정보
PAUSED관제 시스템이 instantActions로 pause를 명시적으로 요청actionStatus: "PAUSED". 하드웨어에도 실제 일시정지 명령 전달
FINISHED하드웨어로부터 성공 완료 콜백을 수신하고 결과를 검증actionStatus: "FINISHED". pick이라면 이 시점에 state.load도 함께 갱신
FAILED하드웨어 오류, 파라미터 검증 실패, 워치독 타임아웃 중 하나 발생actionStatus: "FAILED". state.errorserrorType/errorLevel/errorReferences 채움

여기서 실무적으로 자주 놓치는 부분은 WAITING을 아예 보고하지 않고 건너뛰는 구현입니다. 표준상 필수는 아니지만, WAITING 상태를 명시적으로 한 번이라도 발행해 두면 관제 시스템 UI에서 “이 액션이 대기 중인지, 아니면 애초에 이 차량이 이 액션 자체를 인지하지 못했는지”를 구분할 수 있어 운영 중 디버깅이 훨씬 쉬워집니다.

blockingType이 주행 로직에 미치는 실제 영향

blockingType은 단순한 메타데이터가 아니라, AGV의 주행 제어 로직이 반드시 참조해야 하는 실행 제약 조건입니다. 값은 NONE, SOFT, HARD 세 가지이며, 각각이 node 통과와 다음 released 구간 진행에 미치는 영향이 다릅니다.

  • NONE: 이 액션은 주행과 완전히 독립적으로 실행됩니다. 차량은 액션의 완료 여부와 무관하게 다음 edge로 즉시 진행할 수 있습니다. 예를 들어 경고등을 켜는 액션처럼, 주행을 막을 이유가 없는 부수 효과성 액션에 적합합니다.
  • SOFT: 차량은 액션이 끝나기를 기다리지 않고 이동을 시작할 수 있지만, 액션 자체는 그 node를 벗어나기 전까지 계속 실행 상태를 유지해야 합니다. 즉 “이동은 먼저 시작해도 되지만, 액션이 끝나지 않은 채로 완전히 이탈해서는 안 되는” 중간 성격의 제약입니다.
  • HARD: 액션이 FINISHED 또는 FAILED로 완전히 종료되기 전까지, 차량은 그 node를 벗어나 다음 released edge로 진행해서는 절대 안 됩니다. pick·drop처럼 화물 상태가 실제로 바뀌는 물리적 액션은 거의 항상 HARD로 지정됩니다.

pick 액션이 HARD여야 하는 이유는 명확합니다. 포크가 파렛트 아래로 완전히 들어가 화물을 들어 올리기 전에 차량이 움직여 버리면 화물이 낙하하거나 랙과 충돌할 수 있습니다. 이는 안전 사고로 직결되는 문제이기 때문에, HARD 블로킹은 AGV의 저수준 주행 제어기(모션 컨트롤러)에서도 소프트웨어적으로 이중 강제해야 하는 조건입니다. 상위 액션 상태 머신이 실수로 released 신호를 잘못 해석하더라도, 모션 컨트롤러 자체가 “현재 HARD 액션이 진행 중”이라는 플래그를 보고 있으면 이동 명령을 물리적으로 거부할 수 있습니다.

한 node에 여러 액션이 있을 때의 실행 순서

VDA5050 order의 actions 배열은 순서가 있는 배열이며, 표준은 이 배열 순서를 실행 순서 해석의 근거로 삼습니다. blockingType이 섞여 있을 때 실행 순서를 결정하는 실무 규칙은 다음과 같습니다.

  1. HARD 액션은 배열에 나온 순서대로 반드시 순차 실행됩니다. 앞선 HARD 액션이 FINISHED/FAILED가 되기 전에는 다음 액션을 시작할 수 없습니다.
  2. NONE 액션은 HARD 액션의 진행과 무관하게 병렬로 시작할 수 있습니다. 예를 들어 “경고등 점멸(NONE)“과 “pick(HARD)“이 같은 node에 있다면, 경고등은 pick과 동시에 켜져도 무방합니다.
  3. SOFT 액션은 뒤따르는 HARD 액션의 시작을 막지 않지만, SOFT 액션 자체가 끝나기 전에 차량이 node를 완전히 벗어나서는 안 됩니다. 따라서 SOFT와 HARD가 섞여 있으면 실제 이탈 시점은 두 액션 중 더 늦게 끝나는 쪽에 좌우됩니다.

이 규칙을 하나의 표로 정리하면 다음과 같습니다.

조합시작 시점node/edge 이탈 가능 시점
HARD 하나만node 도착 즉시해당 HARD가 FINISHED/FAILED된 이후
HARD 여러 개배열 순서대로 순차마지막 HARD가 종료된 이후
NONE + HARD동시에 시작 가능HARD 종료 시점 (NONE은 이탈을 막지 않음)
SOFT + HARD동시에 시작 가능max(SOFT 종료 시점, HARD 종료 시점)

pick 액션의 실전 처리 흐름

이제 실제로 차량 소프트웨어 내부에서 pick 액션이 어떻게 처리되는지 단계별로 따라가 보겠습니다. 이 흐름은 libVDA5050++ 같은 라이브러리를 쓰든, 직접 프로토콜을 구현하든 공통적으로 필요한 로직입니다.

  1. node 도착 감지: 주행 제어기가 목표 node의 좌표에 정지 오차 범위 내로 도달했음을 확인하고, 이를 액션 실행 계층에 이벤트로 전달합니다.
  2. actions 배열 순회: 도착한 node에 정의된 actions 배열을 순회하며, 각 액션의 blockingType에 따라 앞서 정리한 순서 규칙대로 실행 대상을 선별합니다.
  3. actionParameters 파싱: pick 액션이라면 actionParameters 배열에서 lhd(하물을 들어 올릴 방향, load handling device), stationType, loadId 같은 키-값 쌍을 찾아 파싱합니다. 이 파싱 단계에서 필수 파라미터가 누락되었거나 값의 형식이 잘못되었다면, 하드웨어를 호출하기 전에 즉시 FAILED로 전이시켜야 합니다. 잘못된 파라미터로 하드웨어를 호출하는 것은 예측 불가능한 물리적 동작으로 이어질 수 있어 가장 위험한 실패 패턴 중 하나입니다.
  4. 하드웨어 제어 API 호출: 파싱이 끝나면 actionStatusINITIALIZING에서 RUNNING으로 전이시키고, 포크리프트·컨베이어·그리퍼 등 실제 하드웨어 드라이버에 비동기 명령을 내립니다. 이 호출은 반드시 논블로킹이어야 합니다. 동기 호출로 구현하면 액션 상태 머신 전체를 처리하는 스레드가 하드웨어 응답을 기다리는 동안 다른 액션의 상태 갱신이나 state 메시지 발행이 지연될 수 있습니다.
  5. 완료/실패 판정: 하드웨어가 완료 콜백을 보내오면, 단순히 “명령이 접수됨”이 아니라 실제로 화물이 안정적으로 실렸는지(예: 포크 하중 센서 값)까지 검증한 뒤에만 FINISHED로 전이시킵니다. 명령 접수와 물리적 완료를 혼동하는 것은 흔한 설계 실수입니다.
  6. actionState 갱신 및 발행: 최종 상태를 actionStates 배열에 반영하고, pick이 성공했다면 state.load도 같은 갱신 사이클 안에서 함께 업데이트한 뒤 state 메시지를 발행합니다.

edge action과 node action이 섞여 있을 때의 순서

VDA5050에서는 node뿐 아니라 edge에도 actions를 붙일 수 있습니다. 실행 순서 규칙은 다음과 같이 정리됩니다.

  • 특정 edge를 주행하는 동안 실행되는 edge action은, 그 edge의 시작 node에 있는 HARD 액션이 모두 끝난 뒤에 시작됩니다(HARD 액션이 끝나야 애초에 그 edge로 진입할 수 있으므로 자연스러운 결과입니다).
  • edge action이 끝나기 전에는, 그 edge의 도착 node에 있는 액션이 시작되지 않습니다. 즉 순서는 항상 출발 node의 액션 → edge의 액션 → 도착 node의 액션입니다.
  • edge action의 blockingType이 HARD라면, 차량은 그 edge 위에서 정지한 채로 액션이 끝나기를 기다려야 하며 도착 node로의 이동을 계속하지 않습니다.

Load 상태 보고: pick 성공 이후 관제 시스템에 무엇을 알려야 하는가

pick 액션이 FINISHED로 전이되는 순간, AGV는 state 메시지의 load 배열을 갱신해 화물의 존재와 속성을 관제 시스템에 알려야 합니다. 이 정보가 정확하지 않으면 관제 시스템이 다음 order를 잘못 계산하게 됩니다.

{
  "load": [
    {
      "loadId": "pallet-77",
      "loadType": "EPAL",
      "loadPosition": "front-fork",
      "boundingBoxReference": { "x": 0.0, "y": 0.0, "z": 0.0 },
      "weight": 480.5
    }
  ]
}
  • loadId: actionParameters로 전달받은 값을 그대로 반영하거나, 만약 바코드·RFID 스캐너로 실제 화물 ID를 재확인했다면 그 값으로 대체합니다. 관제 시스템이 재고 추적 시스템과 연동되어 있다면 이 값의 정확도가 특히 중요합니다.
  • loadType / loadPosition: 파렛트 규격이나 적재 위치(전방 포크, 후방 컨베이어 등)를 명시해, 관제 시스템이 이후 drop 액션의 실행 가능 여부(예: 특정 스테이션이 특정 loadType만 받는 경우)를 판단할 수 있게 합니다.
  • weight: 로드셀 등으로 측정한 실제 하중입니다. 이 값은 다음 경로 계획에서 속도 제한이나 경사로 통과 가능 여부를 계산하는 데 쓰일 수 있습니다.

이 값들이 왜 중요한지는 관제 시스템의 다음 동작을 생각해보면 명확해집니다. 관제 시스템은 state.load가 비어 있는지 채워져 있는지를 보고 “이 차량이 지금 화물을 싣고 있는가”를 판단하며, 이를 근거로 다음 order에 drop 목적지를 포함할지, 아니면 다음 pick 목적지로 보낼지를 결정합니다. load가 갱신되지 않은 채로 actionStateFINISHED가 되면, 관제 시스템은 차량이 빈 상태라고 오판해 이미 화물을 실은 차량에게 또 다른 pick 명령을 내리는 논리적 모순이 발생할 수 있습니다.

에러 처리: FAILED 전이와 관제 시스템의 대응

pick 액션이 실패하면 actionStatusFAILED로 전이시키는 것과 별개로, state.errors 배열에 문제 상황을 구조화해서 담아야 합니다.

{
  "errors": [
    {
      "errorType": "pickFailed",
      "errorLevel": "FATAL",
      "errorReferences": [
        { "referenceKey": "actionId", "referenceValue": "pick-001" },
        { "referenceKey": "nodeId", "referenceValue": "node-B" }
      ],
      "errorDescription": "그리퍼가 15초 내 완료 신호를 반환하지 않음 (타임아웃)"
    }
  ]
}
  • errorLevel: WARNING은 액션 자체는 계속 진행 가능하거나 재시도 여지가 있는 경미한 문제(예: 센서 값이 일시적으로 불안정)에 사용합니다. 이 경우 actionStatus는 여전히 RUNNING으로 유지될 수도 있습니다.
  • errorLevel: FATAL은 액션이 더 이상 진행될 수 없어 FAILED로 전이해야 하는 상황에 사용합니다. FATAL 에러가 보고되면 관제 시스템은 해당 order를 더 이상 신뢰할 수 없는 것으로 간주해야 합니다.

관제 시스템은 FATAL 에러를 수신하면 보통 다음 두 가지 중 하나로 대응합니다.

  1. instantActions로 같은 pick 액션을 새 actionId로 재시도 요청: 일시적인 하드웨어 문제(그리퍼 걸림 등)로 판단되면, 사람이 개입하거나 자동 복구 절차 후 동일한 파라미터로 재시도를 지시합니다. 이때 반드시 새로운 actionId를 부여해, 앞서 실패한 액션의 상태와 혼동되지 않도록 해야 합니다.
  2. cancelOrder instantAction으로 현재 order 전체를 취소: 재시도가 불가능하거나 안전상 위험하다고 판단되면 order 자체를 취소하고, 사람의 개입이나 새로운 경로 재계산을 거쳐 처음부터 다시 order를 발행합니다.

타임아웃 처리 패턴

픽업 도구가 응답하지 않는 상황은 현장에서 가장 흔하게 마주치는 실패 케이스입니다. RUNNING으로 전이하는 순간 워치독 타이머를 시작하고, 하드웨어의 완료 콜백이 타이머 만료 전에 도착하지 않으면 다음 순서로 처리합니다.

  1. 하드웨어에 안전 정지/리셋 명령을 먼저 내려 물리적으로 위험한 상태(예: 포크가 중간 높이에서 멈춘 상태)를 벗어나게 합니다.
  2. actionStatusFAILED로 전이시킵니다.
  3. errorTypepickTimeout 같은 명확한 값으로, errorLevelFATAL로 채워 보고합니다.
  4. 워치독 타이머 자체는 액션별로 독립적으로 관리해, 한 액션의 타임아웃이 다른 액션의 타이머에 영향을 주지 않도록 합니다.

C++ 구현: pick 액션을 처리하는 상태 머신

이전 글에서는 ActionHandlerstart/pause/resume/stop 인터페이스를 개념적으로만 스케치했습니다. 여기서는 실제로 actionType을 디스패치하고, actionParameters를 파싱하고, 하드웨어 호출을 비동기로 처리하며 콜백을 통해 상태를 라이브러리에 보고하는 좀 더 완성도 있는 구현 패턴을 보여줍니다. 하드웨어 호출은 std::asyncstd::future를 이용한 비동기 처리 패턴과 동일한 사고방식을 따릅니다.

#include <future>
#include <atomic>
#include <chrono>
#include <optional>
#include <string>
#include <unordered_map>
#include <vector>

enum class ActionStatus { Waiting, Initializing, Running, Paused, Finished, Failed };

struct ActionParameter {
  std::string key;
  std::string value; // 실제로는 variant로 문자열/숫자/불리언을 모두 받는 것이 안전
};

struct PickActionRequest {
  std::string actionId;
  std::string nodeId;
  std::vector<ActionParameter> parameters;
};

// 하드웨어 드라이버가 제공한다고 가정하는 비동기 그리퍼 제어 인터페이스
struct GripperResult {
  bool success{false};
  double measuredWeightKg{0.0};
  std::string failureReason;
};
class GripperDriver {
 public:
  // 논블로킹 호출: 즉시 future를 반환하고 별도 스레드/이벤트 루프에서 실제 제어를 수행
  std::future<GripperResult> pickAsync(const std::string& loadId,
                                        const std::string& handlingDirection);
};

class PickActionStateMachine {
 public:
  explicit PickActionStateMachine(GripperDriver& driver) : driver_(driver) {}

  // 1. actionType 디스패치 진입점 -- 상위 라이브러리가 actionType == "pick"일 때 호출
  void start(const PickActionRequest& req) {
    actionId_ = req.actionId;
    status_ = ActionStatus::Initializing;

    // 2. actionParameters 파싱: 필수 키가 없으면 하드웨어 호출 전에 즉시 실패 처리
    auto lhd = findParam(req.parameters, "lhd");
    auto loadId = findParam(req.parameters, "loadId");
    if (!lhd || !loadId) {
      failWith("missingParameter", "필수 파라미터 lhd 또는 loadId 누락");
      return;
    }

    // 3. 하드웨어 호출은 비동기로 시작하고, RUNNING으로 전이한 뒤 워치독을 건다
    status_ = ActionStatus::Running;
    deadline_ = std::chrono::steady_clock::now() + std::chrono::seconds(15);
    pending_ = driver_.pickAsync(*loadId, *lhd);
  }

  // 4. 상위 라이브러리의 주기적 폴링 루프(또는 별도 워치 스레드)에서 반복 호출
  //    future가 준비됐는지, 타임아웃이 지났는지를 매 틱마다 확인한다
  void poll() {
    if (status_ != ActionStatus::Running) return;

    if (std::chrono::steady_clock::now() >= deadline_) {
      failWith("pickTimeout", "그리퍼가 제한 시간 내 응답하지 않음");
      return;
    }
    if (pending_.wait_for(std::chrono::milliseconds(0)) != std::future_status::ready) {
      return; // 아직 진행 중
    }

    GripperResult result = pending_.get();
    if (!result.success) {
      failWith("pickFailed", result.failureReason);
      return;
    }

    // 5. 완료 판정: 명령 접수가 아니라 실제 하중 측정값까지 확인 후 FINISHED로 전이
    status_ = ActionStatus::Finished;
    reportedLoadWeightKg_ = result.measuredWeightKg;
    // 이 시점에 state.load 갱신과 actionState 보고를 같은 트랜잭션으로 묶어야 한다
  }

  ActionStatus status() const { return status_; }
  std::optional<double> loadWeightKg() const {
    return status_ == ActionStatus::Finished
               ? std::optional<double>(reportedLoadWeightKg_)
               : std::nullopt;
  }
  const std::string& lastErrorType() const { return lastErrorType_; }

 private:
  static std::optional<std::string> findParam(
      const std::vector<ActionParameter>& params, const std::string& key) {
    for (const auto& p : params) {
      if (p.key == key) return p.value;
    }
    return std::nullopt;
  }

  void failWith(std::string errorType, std::string description) {
    status_ = ActionStatus::Failed;
    lastErrorType_ = std::move(errorType);
    lastErrorDescription_ = std::move(description);
    // 안전을 위해 하드웨어에 정지/리셋 명령을 별도로 내리는 로직이 여기 추가되어야 한다
  }

  GripperDriver& driver_;
  std::string actionId_;
  std::atomic<ActionStatus> status_{ActionStatus::Waiting};
  std::future<GripperResult> pending_;
  std::chrono::steady_clock::time_point deadline_;
  double reportedLoadWeightKg_{0.0};
  std::string lastErrorType_;
  std::string lastErrorDescription_;
};

이 코드에서 눈여겨볼 설계 지점은 세 가지입니다.

첫째, start()는 절대 블로킹 호출을 하지 않습니다. driver_.pickAsync(...)는 즉시 std::future를 반환하며, 실제 그리퍼 제어는 드라이버 내부의 별도 스레드나 이벤트 루프에서 진행됩니다. 이렇게 하지 않으면 이 액션 하나가 상태 머신 전체를 처리하는 스레드를 붙잡아, 같은 시점에 병렬로 실행되어야 하는 NONE 액션이나 다른 node의 상태 갱신, state 메시지 발행 주기가 모두 지연되는 문제가 생깁니다.

둘째, poll()은 완료 여부와 타임아웃 여부를 같은 틱에서 함께 확인합니다. 타임아웃 검사를 완료 확인보다 먼저 실행 순서로 두는 이유는, 워치독이 실제 안전장치로 기능하려면 하드웨어 응답을 무한정 기다리는 코드 경로가 존재해서는 안 되기 때문입니다. 상위 라이브러리(예: libVDA5050++의 스핀 루프)가 이 poll()을 주기적으로 호출하도록 통합하면, 개별 액션 구현체가 스레드를 직접 만들지 않고도 비동기 흐름과 타임아웃을 함께 관리할 수 있습니다.

셋째, failWith() 내부에 하드웨어 정지·리셋 명령을 함께 내려야 한다는 주석을 남겨 두었습니다. 상태 머신 상의 FAILED 전이와 실제 물리 장치의 상태는 별개의 것입니다. 소프트웨어가 FAILED를 보고했다고 해서 그리퍼가 저절로 안전한 상태로 돌아가지는 않으므로, 실패 처리 경로에는 항상 하드웨어를 안전 상태로 되돌리는 명령이 짝을 이루어야 합니다.

시퀀스 다이어그램: order 수신부터 다음 구간 진행까지

지금까지 설명한 흐름 전체를 하나의 시퀀스로 묶으면 다음과 같습니다. HARD 블로킹 액션이 끝나기 전까지 차량이 다음 released edge로 넘어가지 않는다는 제약이 어디에서 강제되는지 특히 주의 깊게 보시기 바랍니다.

sequenceDiagram
  participant MC as Master Control
  participant AGV as AGV 상태 머신
  participant Motion as 모션 컨트롤러
  participant HW as 그리퍼/포크 하드웨어

  MC->>AGV: order (node-B에 pick 액션, blockingType=HARD)
  AGV->>Motion: node-B로 주행 명령
  Motion-->>AGV: node-B 도착 이벤트
  AGV->>AGV: actionStatus WAITING -> INITIALIZING
  AGV->>AGV: actionParameters 파싱 (lhd, loadId)
  AGV->>HW: pickAsync(loadId, lhd) 비동기 호출
  AGV->>AGV: actionStatus INITIALIZING -> RUNNING
  AGV->>MC: state (actionStates: RUNNING)
  Note over Motion: HARD 블로킹 -- 다음 edge 진행 보류
  HW-->>AGV: 완료 콜백 (하중 측정 성공)
  AGV->>AGV: actionStatus RUNNING -> FINISHED
  AGV->>AGV: state.load 갱신 (loadId, weight)
  AGV->>MC: state (actionStates: FINISHED, load 갱신됨)
  AGV->>Motion: 다음 released edge 진행 허가
  Motion->>Motion: edge-B-C 주행 시작

이 다이어그램에서 핵심은 “다음 released edge 진행 허가”가 FINISHED 전이 및 state.load 갱신 이후에 나간다는 순서입니다. 만약 이 순서가 뒤바뀌어 모션 컨트롤러가 state 발행을 기다리지 않고 내부적으로 먼저 이동을 허가해 버리면, 관제 시스템 화면에는 여전히 액션이 RUNNING으로 보이는데 차량은 이미 다음 구간으로 이동하는 모순이 발생합니다.

트러블슈팅

현장에서 pick 액션과 관련해 반복적으로 마주치는 문제들과 그 원인, 대응 방법을 정리했습니다.

증상흔한 원인대응
HARD 블로킹인데 차량이 액션 종료 전에 다음 edge로 넘어감액션 상태 머신과 모션 컨트롤러가 서로 다른 모듈로 분리되어 있는데, blockingType 플래그가 모션 컨트롤러까지 전파되지 않음액션 상태 머신이 RUNNING으로 전이하는 즉시 모션 컨트롤러에 “이 node 이탈 금지” 플래그를 명시적으로 세팅하고, FINISHED/FAILED 전이 시에만 해제하도록 두 모듈 사이 계약을 명확히 함
actionParameters 키 이름 불일치로 파싱 실패관제 시스템 벤더와 AGV 벤더가 lhd, load_id, loadID 등 서로 다른 키 표기를 사용통합 전 양쪽이 사용할 정확한 키 이름·대소문자·타입을 문서로 합의하고, 파싱 실패 시 어떤 키가 왜 없었는지를 resultDescription에 남겨 디버깅 시간을 줄임
FINISHED 보고 후에도 관제 시스템에서 load가 갱신되지 않음actionState 갱신과 state.load 갱신이 서로 다른 비동기 경로에서 처리되어 발행 순서가 어긋남두 갱신을 같은 락 구간(원자적 트랜잭션)에서 처리하고, 하나의 state 발행 사이클에 함께 반영
동일한 pick 액션이 재전송되어 중복 실행됨네트워크 재연결이나 QoS 0 환경에서 관제 시스템이 같은 order를 재발행actionId 단위로 처리 이력을 유지해, 이미 시작/완료한 actionId가 다시 오면 재실행하지 않고 마지막 상태를 재발행하는 멱등 처리 적용
워치독 타임아웃이 너무 자주 발생함타임아웃 값이 하드웨어의 실제 평균 소요 시간을 고려하지 않고 임의로 짧게 설정됨실제 하드웨어의 p99 소요 시간을 현장 데이터로 측정해 타임아웃 값을 재산정하고, 액션 타입별로 다른 타임아웃을 적용
그리퍼 실패 후 재시도가 계속 실패로만 이어짐실패 처리 경로에서 하드웨어를 안전 상태로 리셋하지 않은 채 같은 파라미터로 재시도만 반복failWith 경로에 하드웨어 리셋/원점 복귀 절차를 반드시 포함시키고, 재시도 전에 리셋 완료를 확인

정리

VDA5050의 pick 액션은 JSON 스키마 상의 필드 몇 개를 채우는 문제가 아니라, actionStatus의 명확한 전이 규칙, blockingType이 주행 로직에 강제하는 안전 제약, 하드웨어 호출을 비동기로 처리하면서도 타임아웃과 실패를 놓치지 않는 상태 머신 설계, 그리고 성공 이후 load 상태를 정확히 갱신해 관제 시스템의 다음 판단에 신뢰할 수 있는 정보를 제공하는 일까지 아우르는 실전 문제입니다. 특히 HARD 블로킹 제약을 액션 상태 머신뿐 아니라 모션 컨트롤러 수준에서도 이중으로 강제하는 것, 그리고 FINISHED 보고와 load 갱신을 원자적으로 묶는 것은 현장에서 발생하는 대부분의 사고와 데이터 불일치 문제를 예방하는 핵심 포인트입니다.

같이 보면 좋은 글