🚚 WIA 배송 로봇 표준 전자책 | 제3장 (총 8장)
제2장에서는 배송 로봇이 원시 센서 신호를 바탕으로 자신의 위치와 주변 상황을 추정하는 과정을 살펴보았다. 이 장은 그다음 단계, 곧 그 추정값을 다른 제조사의 소프트웨어가 추측 없이 읽을 수 있는 형태로 기록하는 방법을 다룬다. WIA ROB-010의 1단계는 표준 전체의 데이터 계층이다. 2단계의 REST 호출, 3단계의 내비게이션 행동, 4단계의 플릿 텔레메트리는 모두 같은 몇 가지 문서, 즉 RobotState, Package, Route, 그리고 로봇 상태 안에 담기는 SensorReadings를 주고받는다. 이 문서들이 모호하면 그 위에 쌓인 모든 계층이 같은 모호함을 물려받는다.
이 장은 각 스키마를 필드 단위로 살펴보면서 명세가 실제로 무엇을 제약하고 무엇을 열어 두었는지 설명하고, 이어서 운영 환경에 투입하기 전에 구현자가 반드시 메워야 할 공백을 정리한다. 표준에 없는 정의를 이 책이 제안하는 경우에는 본문에서 그 사실을 분명히 밝힌다. 그러한 제안은 '구현자 프로파일'로 표시하며 WIA ROB-010 v1.0.0의 일부가 아니다.
1단계 명세는 네 가지 설계 원칙을 내세운다. 상호운용성(어떤 배송 로봇 시스템에도 통하는 단일 형식), 확장성(새로운 로봇 부류와 기능을 수용할 여지), 정밀성(타임스탬프와 신뢰도 값을 갖춘 세밀한 상태 표현), 완전성(내비게이션·패키지·텔레메트리·플릿 운영을 하나의 문서 체계로 포괄)이다. 원칙 자체는 타당하지만, 이 원칙이 시험 가능한 요구사항이 되는 것은 표준이 데이터 모델을 산문 표가 아니라 JSON Schema 문서로 표현했기 때문이다. 스키마는 실행할 수 있다. 검증기는 페이로드를 수용하거나 위반 목록을 돌려주며, 같은 스키마를 돌리는 두 제조사는 같은 판정에 이른다.
1단계의 모든 스키마는 "$schema": "https://json-schema.org/draft/2020-12/schema"를 선언한다. 이는 json-schema.org 프로젝트가 발행한 JSON Schema 2020-12 판을 사용한다는 뜻이다. 이 표준에서 구조를 떠받치는 키워드는 몇 개에 불과하다.
| 키워드 | ROB-010에서의 역할 | 1단계 명세의 예 |
|---|---|---|
$schema | 방언(2020-12)을 지정하여 검증기가 올바른 규칙을 적용하게 한다 | robot-state.json의 루트 |
$id | 스키마마다 정규 URI를 부여하며, 상대 참조를 해석하는 기준이 된다 | https://wiastandards.com/schemas/delivery-robot/v1/package.json |
$defs | 문서 안에서 재사용할 하위 스키마를 담는다 | Position, SensorReadings |
$ref | 문서 내부(#/$defs/...) 또는 다른 문서의 하위 스키마를 가리킨다 | {"$ref": "#/$defs/Position"} |
required, enum, minimum/maximum, pattern, minItems | 값에 대한 실제 제약 | battery_level 0–100, access_code 패턴 |
format | 널리 알려진 문자열 형식(uuid, date-time, uri)을 지정한다 | robot_id, timestamp |
$id 값은 https://wiastandards.com/schemas/delivery-robot/v1/<name>.json이라는 한 가지 패턴을 따르며, 경로에 주 버전이 들어 있다. 이 URI는 다운로드 위치가 아니라 식별자로 다루어야 한다. 검증기는 검토를 거쳐 고정해 둔 로컬 사본에서 스키마를 읽어야 하며, 실행 중에 네트워크로 받아 와서는 안 된다. 웹 서버에 닿지 못해 자기 텔레메트리를 검증하지 못하는 로봇은 데이터 계약을 가용성 의존성으로 바꾸어 버린 셈이다.
첫째, 2020-12 판에서 format은 기본적으로 주석(annotation)이다. 기본 어휘를 따르는 검증기는 어떤 필드가 UUID라고 표시되어 있다는 사실만 보고할 뿐 실제로 검사하지는 않는다. 대부분의 라이브러리는 형식 검사를 단언(assertion)으로 바꾸는 선택지를 제공한다(Python jsonschema의 형식 검사기 객체, Ajv의 ajv-formats 플러그인 등). 1단계는 format에 크게 의존한다. robot_id, package_id, route_id는 모두 "format": "uuid"이고, 모든 타임스탬프는 "format": "date-time"이다. 형식 단언을 켜지 않으면 "robot_id": "robot-07"인 페이로드도 통과한다. 3.8절에서 실제 검증기 출력으로 이를 확인한다.
둘째, 1단계의 어떤 스키마도 additionalProperties나 unevaluatedProperties를 지정하지 않는다. 따라서 정의되지 않은 필드가 허용된다. 확장성을 위한 의도된 설계이지만, 그 결과 "heaading"처럼 철자가 틀린 선택 필드는 조용히 수용되고 본래 의도한 heading은 그냥 빠진 것으로 처리된다. 상호운용 시스템에서 흔히 쓰는 실용 규칙은 생산할 때는 엄격하게(미지 속성을 금지하는 더 엄격한 스키마 사본으로 자기 출력을 검사), 소비할 때는 관대하게(다른 제조사의 미지 필드는 받아들이되 기록) 하는 것이다.
RobotState($id가 robot-state.json으로 끝나는 스키마)는 로봇이 스스로에 대해 보고하는 스냅숏이다. 어디에 있는지, 어떻게 움직이는지, 에너지가 얼마나 남았는지, 무엇을 하고 있고 무엇을 싣고 있는지를 담는다. 이 문서는 2단계 GET /api/v1/robots/{robot_id}의 응답 본문이자 4단계 MQTT 토픽 fleet/robots/{robot_id}/state의 페이로드이므로, 하나의 정의가 요청·응답 경로와 스트리밍 경로를 모두 떠받친다.
| 필드 | 타입과 제약 | 필수 | 구현 참고 |
|---|---|---|---|
robot_id | 문자열, format: uuid | 예 | 설명은 UUID v4라고 하지만 uuid 형식은 버전 자리를 검사하지 않는다 |
timestamp | 문자열, format: date-time | 예 | 전송 시각이 아니라 상태를 포착한 시각이며, Z를 붙인 UTC로 쓴다 |
version | 문자열, 패턴 ^\d+\.\d+\.\d+$ | 예 | 펌웨어가 아니라 스키마의 버전이다 |
position | $ref Position | 예 | 3.3절 참조 |
battery_level | 수, 0–100 | 예 | 충전 상태 백분율 |
heading | 수, 0–360 | 아니요 | 도 단위, 0 = 북, 90 = 동(시계 방향) |
speed | 수, ≥ 0 | 아니요 | 초당 미터, 크기만 표현한다 |
mode | 열거형 autonomous, manual, idle, charging, error | 아니요 | 3단계 행동 상태보다 거칠다(제5장) |
cargo | $ref CargoStatus | 아니요 | 참조만 있고 정의가 없다(3.7절) |
route | $ref ActiveRoute | 아니요 | 참조만 있고 정의가 없다(3.7절) |
sensors | $ref SensorReadings | 아니요 | 3.6절 참조 |
errors | $ref Error의 배열 | 아니요 | Error는 정의되지 않았다(3.7절) |
robot_id는 패키지 배정, 경로 계산, 플릿 통계, 감사 기록 등 생태계의 모든 데이터를 잇는 결합 키이다. 따라서 로봇을 처음 가동하는 시점에 한 번 생성하여 비휘발성 메모리에 저장해야 한다. 펌웨어를 다시 올린 뒤 식별자를 새로 만들면 그 로봇을 기록해 온 모든 시스템에서 이력이 끊긴다. 설명대로 버전 4 UUID를 요구해야 하는 배치라면, uuid 형식이 모든 UUID 버전을 받아들이므로 엄격한 생산자용 스키마에 패턴을 추가하여 강제한다.
"robot_id": {
"type": "string",
"format": "uuid",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
}
date-time 형식은 ISO 8601의 RFC 3339 프로파일을 가리킨다. 이 프로파일은 완전한 날짜, 완전한 시각, 명시적인 시차를 요구한다. 2025-12-26T10:40:12.250Z는 유효하지만 2025-12-26 10:40:12(T도 시차도 없음)는 유효하지 않다. 항상 UTC로 내보내야 한다. 지역 시차도 문법상 허용되지만, 여러 도시에 걸친 플릿에서 로그를 맞춰 보는 작업을 오류투성이로 만든다. 타임스탬프는 바탕이 된 측정이 이루어진 순간이어야 한다. 통신이 끊긴 동안 상태를 쌓아 두었다가 나중에 올리는 경우, 수신 측은 늦게 도착한 옛 데이터와 새 데이터를 구별할 수 있어야 한다. 포착 시각과 전송 시각을 섞으면 이 구별이 사라진다. RFC 3339는 소수 초를 허용하며, 실제로 쓸 가치가 있다. 보행 속도의 로봇은 초당 1 m 넘게 움직이므로 초 단위 타임스탬프로는 아차 사고를 재구성하기에 너무 거칠다.
heading은 항법 관례, 즉 북쪽에서 시계 방향으로 잰 각도(도)를 쓴다. ROS 2 기반 로봇은 보통 REP-103 관례(동쪽에서 반시계 방향, 라디안)로 요(yaw)를 다루며, 변환식은 제2장에 있다. 스키마는 0과 360을 모두 허용하는데 두 값은 같은 방향이다. 생산할 때는 반열린 구간 [0, 360)으로 정규화해야 방위각을 비교하는 소비자가 360도짜리 가짜 도약을 보지 않는다. speed는 최솟값이 0이고 방향이 없으므로 후진은 음수 속도가 아니라 방위각으로 표현한다. 좁은 충전 베이를 빠져나올 때처럼 실제로 후진하는 플랫폼이라면, 검증에 실패할 음수를 내보내는 대신 보고 방식을 문서로 정해 두어야 한다.
battery_level은 0부터 100까지의 백분율이다. 스키마는 계산 방법을 규정하지 않는다. 배터리 관리 시스템은 일반적으로 전류 적산과 전압 모델을 조합하여 충전 상태를 추정하는데, 원시 전압을 0–100에 선형으로 대응시킨 값은 부하가 걸릴 때 전혀 다르게 움직인다. 3단계는 배터리가 15% 아래로 떨어지면 고장 모드 대응을 시작하고 4단계는 약 20–30%에서 선제 충전을 계획하므로, 이 숫자의 의미는 안전과 배차에 직결된다. 어떤 추정값을 보고하는지 구현 문서에 밝혀 두어야 한다.
mode의 다섯 값은 세부 작업이 아니라 누가 또는 무엇이 제어하고 있는지를 나타낸다. 횡단보도를 건너는 로봇, 문 앞에서 기다리는 로봇, 물건을 건네는 로봇은 모두 autonomous이다. 더 세밀한 상태는 3단계 행동 상태 기계에 속하며, 대응표는 제5장에 있다. 실무에서는 두 가지 규칙이 유용하다. 첫째, error는 사람의 개입 없이는 계속할 수 없을 때만 내보낸다. 복구 가능한 경고는 모드를 autonomous로 둔 채 errors 배열에 넣는다. 둘째, 원격이든 현장이든 운영자가 주행을 지시하는 동안에는 반드시 manual을 내보낸다. 사고 분석은 소프트웨어가 운전했는지 사람이 운전했는지를 아는 데서 시작하기 때문이다.
Position은 $defs에 한 번 정의되어 로봇 상태와 경로의 모든 경유지에서 재사용된다. 필수 필드는 latitude(−90~90)와 longitude(−180~180)뿐이다. 선택 필드로 altitude(해발 고도, 미터), accuracy(미터, 0 이상), 그리고 gps, rtk-gps, slam, visual-odometry, fused 중 하나를 고르는 열거형 source가 있다.
명세에는 세 가지 맥락이 빠져 있으며, 구현마다 이를 확정해야 한다.
위도 1도는 어디서나 약 111 km이므로 소수 자릿수가 표현할 수 있는 가장 작은 간격을 결정한다. 경도 1도의 길이는 위도의 코사인에 비례해 줄어들므로 중위도에서는 동서 방향 분해능이 조금 더 촘촘하다.
| 소수 자릿수 | 위도 간격 | 대략의 분해능 | 적합한 용도 |
|---|---|---|---|
| 4 | 0.0001° | 약 11 m | 구역·블록 수준에 한정 |
| 5 | 0.00001° | 약 1.1 m | 미터급 GNSS와 부합 |
| 6 | 0.000001° | 약 0.11 m | 보도 차로 수준, 3단계의 0.5 m 목표와 부합 |
| 7 | 0.0000001° | 약 1.1 cm | RTK급 측위와 부합 |
정확도가 뒷받침하지 못하는 자릿수를 더 내보내도 정보는 늘지 않지만, 그보다 적게 자르면 정보를 버리게 된다. 배송 로봇이라면 소수 6~7자리가 무난한 기본값이다. source는 실제로 게시하는 추정값을 기술해야 한다. 확장 칼만 필터가 RTK 보정, 바퀴 주행거리계, LiDAR 스캔 정합을 결합한다면 게시되는 위치는 fused이며, 개별 입력은 위치 객체가 아니라 진단 정보에 담아야 한다.
Package 스키마(package.json)는 배송할 물품과 인계 규칙을 기술한다. 필수 필드는 package_id, recipient, pickup_location, dropoff_location이며 나머지는 선택이다. 패키지는 POST /api/v1/packages로 생성되고 GET /api/v1/packages/{package_id}로 조회된다(제4장).
| 필드 | 타입과 제약 | 참고 |
|---|---|---|
package_id | 문자열, format: uuid(필수) | 내부 키, 3.7절의 식별자 함정 참조 |
tracking_number | 문자열 | 판매자나 운송사가 부여하는 외부용·사람용 식별자 |
sender, recipient | $ref Contact(recipient는 필수) | Contact는 명세에 정의되지 않았다 |
pickup_location, dropoff_location | $ref Location(필수) | Location은 명세에 정의되지 않았다 |
package_details | 객체 | weight_kg, dimensions_cm, category, fragile, temperature_controlled, temperature_range_celsius |
access_control | 객체 | access_code, qr_code, biometric_required, signature_required |
status | 여덟 가지 상태의 열거형 | 수명 주기는 3.4.3절 |
priority | 열거형 normal, express, urgent, 기본값 normal | 배차의 입력값(제6장) |
estimated_delivery, delivered_at | 문자열, format: date-time | 추정과 사실이므로 어느 한쪽으로 다른 쪽을 덮어쓰지 않는다 |
weight_kg와 dimensions_cm의 세 값은 0 이상의 수이다. 이 값들은 배차 시 패키지가 로봇 적재함에 들어가는지 미리 확인하기 위해 존재하므로, 선택 필드이기는 해도 채워 두는 편이 이득이다. category는 food, retail, medical, document, other 중 하나이다. 이 범주는 경로 선택과 취급을 위한 힌트일 뿐 법적 분류가 아니다. 의약품을 운반하는 플랫폼은 이 스키마와 별개로 해당 지역의 규정을 따라야 한다.
temperature_controlled가 참이면 temperature_range_celsius가 min과 max를 준다. JSON Schema로는 'min이 max를 넘으면 안 된다'를 표현하기 어려우므로 이 검사는 애플리케이션 코드가 맡는다. 또한 온도 관리 대상인데 범위가 없는 패키지를 어떻게 처리할지 일찍 정해 두는 것이 좋다. 생성 시점에 거부하는 편이, 능동 냉각 장치가 없는 로봇이 그 패키지를 받아 버리게 두는 것보다 안전하다.
access_control 객체는 로봇이 패키지를 내주는 방법을 알려 준다. access_code는 4~8자리 숫자 문자열(^[0-9]{4,8}$)이다. 반드시 문자열로 유지해야 한다. 0427 같은 코드는 어느 구성 요소든 정수로 바꾸는 순간 앞자리 0을 잃고, 그러면 패턴이 이를 정당하게 거부한다. qr_code는 스캔 방식 인계를 위한 토큰이며, biometric_required와 signature_required는 더 강한 수령인 확인을 요구하는 플래그이다.
접근 코드는 비밀 정보이다. 네 자리 코드는 가능한 값이 10,000개뿐이므로, 보호력은 길이가 아니라 추측 시도를 제한하는 데서 나온다. 권장 관행은 다음과 같다.
access_code를 RobotState, 텔레메트리, 플릿 대시보드, 애플리케이션 로그에 절대 복사하지 않는다. 인증된 명령 채널을 통해서만 적재함 제어기로 보낸다.delivered, failed, returned 이후에는 만료시킨다.status 열거형은 pending, assigned, picked_up, in_transit, arrived, delivered, failed, returned의 여덟 상태를 나열한다. 명세는 상태를 나열할 뿐 어떤 전이가 허용되는지는 정의하지 않는다. 아래 그림과 표는 상태 이름에 대한 이 책의 권장 해석이며, 구현자 프로파일로 다루어야 한다.
[pending]
| 로봇 선정
v
[assigned]
| 적재 완료, 덮개 잠금
v
[picked_up]
| 픽업 지점 출발
v
[in_transit]
| 하차 지점 도착
v
[arrived] ----- 수령인 확인 -----> [delivered] (종료)
|
| assigned ~ arrived 어느 단계에서든 실패 가능
v
[failed] ------ 출발지·허브 복귀 --> [returned] (종료)
| 이전 상태 | 다음 상태 | 전이 조건(권장) |
|---|---|---|
pending | assigned | 배차 시스템이 로봇을 선정함 |
assigned | picked_up | 패키지를 적재함에 넣고 덮개를 잠금 |
picked_up | in_transit | 로봇이 픽업 지점을 떠남 |
in_transit | arrived | 로봇이 하차 지점에 도달함 |
arrived | delivered | 수령인이 인증되고 적재함이 비워짐 |
assigned … arrived | failed | 수령인 부재, 로봇 고장, 접근 실패, 경로 차단 |
failed | returned | 패키지가 출발지나 허브로 돌아옴 |
assigned | pending | 운영 정책: 적재 전에 로봇 배정을 철회함 |
전이는 서버에서 강제하고, 그 밖의 전이는 검증 오류로 거부해야 한다. 특히 delivered_at은 delivered로 전이하는 순간 정확히 한 번 기록하고 이후에는 고치지 않는다. 실패한 패키지의 재시도(failed에서 assigned로)를 허용할지는 사업상 결정이다. 허용한다면 시도 횟수를 함께 기록해야 한다. 그렇지 않으면 수명 주기 이력이 순환하는 것처럼 보인다.
Route(route.json)는 순서가 있는 경유지 목록으로 표현한 계획 경로이다. 필수 필드는 route_id(UUID)와 waypoints이며, waypoints에는 최소 두 개의 항목이 있어야 한다. 각 경유지에는 position(로봇과 같은 Position 정의)과 start, intermediate, destination, charging_station 중 하나인 waypoint_type이 필요하고, arrival_time과 dwell_time_seconds를 선택적으로 담을 수 있다.
경로 수준의 필드로는 total_distance_meters와 estimated_duration_seconds(둘 다 0 이상), route_preference(fastest, shortest, safest, energy_efficient), 그리고 max_speed_ms, avoid_roads, sidewalk_only, elevator_allowed를 담는 객체 navigation_constraints가 있다. 경로 선호는 제5장에서 설명하는 전역 경로 계획기의 비용 함수에 들어가는 입력이고, 제약 조건은 계획기가 다른 항목과 맞바꾸어서는 안 되는 절대 한계이다.
start이고 마지막 항목이 destination이어야 한다고는 하지 않는다. 아래처럼 JSON Schema 2020-12로 일부는 표현할 수 있으나, '마지막 항목은 목적지여야 한다'는 직접 표현할 수 없으므로 애플리케이션 검사가 필요하다.max_speed_ms에는 최솟값도 최댓값도 없다. 프로파일에 exclusiveMinimum: 0을 추가하고, 플랫폼이 인증받은 속도로 상한을 둔다.arrival_time은 목록을 따라 감소하지 않아야 하고, total_distance_meters는 연속한 경유지 사이 직선거리의 합 이상이어야 한다. 둘 다 계획기 버그를 잡아내는 값싼 건전성 검사이다.constraints 객체를 쓰지만 1단계는 이를 navigation_constraints라고 부른다. API 경계에서 둘을 명시적으로 대응시켜야 한다(제4장).더 엄격한 생산자 프로파일은 첫 경유지를 고정하고 목적지를 정확히 하나로 요구할 수 있다.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://schemas.example.com/delivery-robot/profile/v1/route-strict.json",
"allOf": [{ "$ref": "https://wiastandards.com/schemas/delivery-robot/v1/route.json" }],
"properties": {
"waypoints": {
"prefixItems": [
{ "properties": { "waypoint_type": { "const": "start" } } }
],
"contains": { "properties": { "waypoint_type": { "const": "destination" } } },
"minContains": 1,
"maxContains": 1
},
"navigation_constraints": {
"properties": { "max_speed_ms": { "exclusiveMinimum": 0 } }
}
}
}
이 프로파일은 allOf로 공식 스키마를 참조하고 제약만 덧붙인다는 점에 주목하자. 어떤 필드도 재정의하지 않으므로, 프로파일에 대해 유효한 문서는 언제나 WIA ROB-010에 대해서도 유효하다.
SensorReadings는 로봇 상태 안의 선택 객체 sensors이다. 네 가지 구성원을 갖는다.
| 구성원 | 구조 | 프로파일에서 정해야 할 미결 사항 |
|---|---|---|
lidar | 스캔의 배열: sensor_id, timestamp, scan_rate_hz, x·y·z·intensity를 갖는 points[] | 점의 좌표계와 단위(센서 좌표계의 미터가 일반적), 강도 척도는 제조사마다 다름 |
cameras | 배열: sensor_id, timestamp, image_url, resolution, class·confidence(0–1)·bounding_box(정수 x, y, width, height)를 갖는 detections[] | 픽셀 원점(왼쪽 위가 일반적 관례), 클래스 어휘 |
ultrasonic | 배열: sensor_id, distance_cm(0 이상) | '반향 없음'의 보고 방식(특수값을 보내지 말고 항목을 생략) |
imu | acceleration {x, y, z}, gyroscope {roll, pitch, yaw} | 단위가 명시되지 않음. 자이로스코프는 각속도를 재지만 축 이름은 각도처럼 읽힘 |
IMU 항목은 특히 주의해야 한다. 가속도는 자연스럽게 m/s² 단위이다(정지한 로봇은 중력 때문에 수직축에서 약 9.8을 읽는다). 반면 gyroscope 객체는 보통 각도를 뜻하는 롤·피치·요라는 이름을 쓰는데, 자이로스코프가 내는 것은 각속도이다. 구현이 각속도(예: 제2장이 권고하는 초당 라디안)를 보고하는지 적분된 자세 각도를 보고하는지 정하고 문서로 남겨야 한다. 잘못 추측한 소비자는 완만한 회전을 격렬한 기울어짐으로 오독한다. 마찬가지로 카메라 detections의 클래스 이름은 자유 문자열이다. 여러 제조사가 같은 플릿에 데이터를 보낸다면 작은 공용 어휘(pedestrian, cyclist, vehicle, wheelchair, animal, static_obstacle)에 합의하고, 제조사별 이름을 엣지에서 여기에 대응시켜야 한다.
스키마는 JSON으로 전체 점군을 담는 것을 허용하지만, 그렇게 하는 것이 좋은 경우는 드물다. 제2장(2.7.2절)의 예시 계산이 보여 주듯이 LiDAR 하나의 점군을 JSON으로 스트리밍하면 초당 수십 메가바이트 규모가 되며, 이는 일상 모니터링을 위해 셀룰러 상향 링크와 플릿 데이터베이스가 감당할 양을 훨씬 넘는다. 이미지도 같은 논리가 적용된다. 카메라 객체가 픽셀 대신 image_url을 담는 이유가 여기에 있다.
실용적인 패턴은 탐지 결과와 요약을 RobotState에 게시하고, 원시 데이터는 로봇의 링 버퍼에 보관했다가 비상 정지, 충돌 경보, 운영자 요청 같은 사건이 있을 때만 해당 구간을 올리는 것이다. 이는 개인정보 보호에도 도움이 된다. 거리의 카메라 영상에는 얼굴과 번호판이 담기며, 제6장에서 로봇을 떠나기 전에 이를 흐리게 처리하는 방법을 설명한다.
1단계 6.3절은 고빈도 텔레메트리에 Protocol Buffers를, 자주 바뀌는 데이터에는 델타 갱신을 권장한다. 표준은 메시지 정의도 델타 형식도 정의하지 않으므로, 구체적인 선택은 모두 구현자 프로파일의 몫이다. 합리적인 방법 하나는 JSON 스키마를 정본 모델로 유지하면서 고속 경로용으로 동등한 Protocol Buffers 메시지를 생성하고, 두 인코딩이 같은 JSON으로 왕복 변환되는지 시험하는 것이다. 델타에 대해서는 기존 표준을 쓰면 형식을 새로 발명하지 않아도 된다. JSON Merge Patch(RFC 7386)는 '이 필드들만 바뀌었다'를 부분 문서로 표현한다. 아래는 예시이다.
{
"robot_id": "550e8400-e29b-41d4-a716-446655440000",
"timestamp": "2025-12-26T10:40:13.250Z",
"position": { "latitude": 37.566818, "longitude": 126.980117 },
"speed": 1.3
}
두 가지를 조심해야 한다. Merge Patch에서 null은 '이 필드를 삭제하라'는 뜻이므로 값을 null로 설정하는 데 쓸 수 없고, 배열은 원소 단위로 고쳐지지 않고 통째로 교체된다. 또한 델타는 그 자체로는 유효한 RobotState가 아니다(version과 battery_level이 없다). 따라서 패치가 아니라 병합한 결과를 검증해야 하며, 델타를 놓친 소비자가 다시 동기화할 수 있도록 전체 상태를 주기적으로 보내야 한다.
WIA ROB-010 v1.0.0에는 인쇄된 그대로는 스키마를 쓸 수 없는 곳이 몇 군데 있다. 이를 미리 알고 있는 구현자는 통합 시험에서 뒤늦게 발견하는 대신 문서화된 프로파일로 메울 수 있다.
| 문제 | 위치 | 영향 | 권장 처리 |
|---|---|---|---|
CargoStatus, ActiveRoute, Error가 참조만 되고 정의되지 않음 | robot-state.json | $ref를 해석할 수 없다. Ajv는 스키마 컴파일을 거부하고, Python jsonschema는 검증이 그 필드에 이르렀을 때 오류를 낸다 | 프로파일 문서에 정의하고 그 사실을 밝힌다 |
Contact, Location이 참조만 되고 정의되지 않음 | package.json | 같은 해석 실패 | 위와 같음 |
$defs가 없는 route.json에서 #/$defs/Position을 사용 | route.json | 로컬 참조가 아무것도 가리키지 않음 | Position을 경로 스키마에 묶어 넣거나 절대 URI로 참조한다 |
2단계 예시가 package_id pkg-2025-12-26-0001, route_id route-2025-12-26-001을 반환 | 2단계 §4.1, §5.1 | UUID가 아니므로 형식 단언을 켜면 format: uuid에 실패 | UUID를 발급하고, 사람이 읽을 참조는 tracking_number에 둔다 |
format이 기본적으로 주석 | 모든 스키마 | 잘못된 식별자와 타임스탬프가 조용히 통과 | 모든 검증기에서 형식 단언을 켠다 |
access_code라는 비밀이 일반 필드에 저장됨 | package.json | 로그와 텔레메트리로 새어 나갈 위험 | 출력 시 가리고, 범위(scope)로 제한한다 |
2단계의 GET /api/v1/robots/{robot_id} 예시는 "cargo": {"occupied_slots": 3, "total_slots": 5}를 반환하고, 패키지 생성 예시는 name, phone, address를 갖춘 recipient와 위도·경도만 담은 위치를 보낸다. 이 예시들과 모순되지 않는 최소 프로파일은 아래와 같다. 이는 이 책의 제안이며 WIA ROB-010의 본문이 아니다. 감사자가 무엇을 추가했는지 정확히 볼 수 있도록, 각자의 판을 적합성 문서와 함께 공개해야 한다.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://schemas.example.com/delivery-robot/profile/v1/common.json",
"$comment": "Implementer profile: fills definitions referenced but not defined by WIA ROB-010 v1.0.0",
"$defs": {
"Contact": {
"type": "object",
"required": ["name"],
"properties": {
"name": { "type": "string", "minLength": 1 },
"phone": { "type": "string", "pattern": "^\\+?[0-9 ()-]{6,20}$" },
"email": { "type": "string", "format": "email" },
"address": { "type": "string" }
}
},
"Location": {
"type": "object",
"required": ["latitude", "longitude"],
"properties": {
"latitude": { "type": "number", "minimum": -90, "maximum": 90 },
"longitude": { "type": "number", "minimum": -180, "maximum": 180 },
"address": { "type": "string" },
"floor": { "type": "string" },
"handover_notes": { "type": "string", "maxLength": 500 }
}
},
"CargoStatus": {
"type": "object",
"required": ["occupied_slots", "total_slots"],
"properties": {
"occupied_slots": { "type": "integer", "minimum": 0 },
"total_slots": { "type": "integer", "minimum": 1 },
"lid_locked": { "type": "boolean" },
"compartment_temperature_celsius": { "type": "number" },
"package_ids": { "type": "array", "items": { "type": "string", "format": "uuid" } }
}
},
"ActiveRoute": {
"type": "object",
"required": ["route_id"],
"properties": {
"route_id": { "type": "string", "format": "uuid" },
"next_waypoint_index": { "type": "integer", "minimum": 0 },
"remaining_distance_meters": { "type": "number", "minimum": 0 },
"eta": { "type": "string", "format": "date-time" }
}
},
"Error": {
"type": "object",
"required": ["code", "severity", "timestamp"],
"properties": {
"code": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]*$" },
"severity": { "type": "string", "enum": ["info", "warning", "critical"] },
"component": { "type": "string" },
"message": { "type": "string" },
"timestamp": { "type": "string", "format": "date-time" }
}
}
}
}
이 프로파일의 설계 선택 두 가지는 설명할 가치가 있다. Location은 위도와 경도를 최상위에 두어 2단계 요청 본문이 수정 없이 검증되게 하고, 실내 배송에 필요한 floor 문자열을 추가했다. 고도만으로는 문이 몇 층에 있는지 알 수 없기 때문이다. CargoStatus는 패키지 식별자는 나열하되 접근 코드를 담을 필드는 일부러 두지 않았다. 그래야 로봇 상태가 실수로라도 코드를 싣는 일이 생기지 않는다. occupied_slots는 total_slots를 넘으면 안 되며, 이 관계는 온도 범위와 마찬가지로 코드에서 강제한다.
프로파일을 연결하는 방법은 두 가지이다. 공식 스키마마다 묶음 사본을 만들어 그 $defs에 정의를 복사하면 $ref 문자열이 인쇄된 그대로 해석된다. 또는 공식 파일은 건드리지 않고, $defs 항목이 프로파일을 가리키는 래퍼 스키마를 등록할 수 있다. 묶음 방식은 임베디드 검증기에 간편하고, 래퍼 방식은 표준 본문과 구현자 추가분의 경계를 깔끔하게 유지한다. 어느 쪽이든 정확한 파일 해시를 증거 패키지에 기록해야 한다(제7장).
2단계 예시는 pkg-2025-12-26-0001 같은 날짜 기반 식별자를 쓴다. 읽기에는 편하지만 package_id와 route_id의 format: uuid 제약을 위반한다. 게다가 '날짜+일련번호' 방식은 사업 물량을 드러내고, 두 시스템이 같은 날짜에 식별자를 만들면 충돌한다. package_id와 route_id에는 UUID를 쓰고, 사람이 전화로 불러 줄 만한 값은 형식 제약이 없는 tracking_number에 넣는다. UUID가 아닌 package_id를 돌려주는 응답은, 설령 명세 자체의 예시에 나온 형태라 해도 적합성 결함으로 다루어야 한다.
로봇 상태는 모두 ^\d+\.\d+\.\d+$와 일치하는 version을 담는다. 1단계 6.2절은 시맨틱 버전 관리(Semantic Versioning)를 적용한다. 부 버전과 수 버전 갱신은 하위 호환을 유지하고, 호환을 깨는 변경은 새 주 버전으로 가며, 버전은 모든 메시지에 포함된다. 이 패턴에서 두 가지가 따라 나온다. 시맨틱 버전 관리가 허용하는 1.1.0-rc.1 같은 사전 배포 표기는 거부되므로, 시험 빌드도 세 부분짜리 숫자만 보고해야 한다. 또한 $id 경로에는 v1만 있으므로 주 버전이 스키마 파일을 선택하고, 부 버전과 수 버전은 소비자에게 참고 정보가 된다.
비규범 부속서 E는 이와 모순되는 규칙을 덧붙인다. 필드 이름 변경이나 삭제는 '오류 페이로드에서라도 부 버전 올림을 반드시(MUST) 유발한다'는 것이다. 시맨틱 버전 관리에서 필드의 이름을 바꾸거나 없애는 것은 기존 소비자를 깨뜨리는 변경이므로 주 버전 변경이 필요하다. 6.2절이 규범 본문이고 부속서 E는 명시적으로 비규범이므로, 이 책은 이름 변경과 삭제를 모두 주 버전 변경으로 다루기를 권장한다. 새 선택 필드나 새 오류 코드 같은 추가 변경은 부 버전에 머물며, 소비자는 모르는 필드를 무시해야 한다. additionalProperties: false가 없다는 점이 이를 가능하게 한다.
아래 예시는 이 책 전체에서 반복해 쓰는 식별자, 즉 로봇 550e8400-e29b-41d4-a716-446655440000, 패키지 7c9e6679-7425-40de-944b-e07fc1f90ae7, 경로 3b241101-e2bb-4255-8caf-4136c566a962를 사용한다. cargo, route, errors 구성원은 3.7.1절의 프로파일을 따르고, 나머지 필드는 공개된 1단계 명세를 그대로 따른다. 값은 예시이다.
{
"robot_id": "550e8400-e29b-41d4-a716-446655440000",
"timestamp": "2025-12-26T10:40:12.250Z",
"version": "1.0.0",
"position": {
"latitude": 37.566812,
"longitude": 126.980104,
"altitude": 38.2,
"accuracy": 0.35,
"source": "fused"
},
"heading": 62.5,
"speed": 1.4,
"battery_level": 71.5,
"mode": "autonomous",
"cargo": {
"occupied_slots": 1,
"total_slots": 2,
"lid_locked": true,
"package_ids": ["7c9e6679-7425-40de-944b-e07fc1f90ae7"]
},
"route": {
"route_id": "3b241101-e2bb-4255-8caf-4136c566a962",
"next_waypoint_index": 2,
"remaining_distance_meters": 640
},
"sensors": {
"ultrasonic": [
{ "sensor_id": "us-front-left", "distance_cm": 182 },
{ "sensor_id": "us-front-right", "distance_cm": 240 }
],
"cameras": [
{
"sensor_id": "cam-front",
"timestamp": "2025-12-26T10:40:12.200Z",
"image_url": "https://media.example.com/frames/cam-front/000481.jpg",
"resolution": { "width": 1280, "height": 720 },
"detections": [
{
"class": "pedestrian",
"confidence": 0.91,
"bounding_box": { "x": 612, "y": 204, "width": 88, "height": 301 }
}
]
}
],
"imu": {
"acceleration": { "x": 0.12, "y": -0.03, "z": 9.79 },
"gyroscope": { "roll": 0.003, "pitch": -0.007, "yaw": 0.054 }
}
},
"errors": []
}
카메라 타임스탬프가 상태 타임스탬프보다 50 ms 이르다는 점에 주목하자. 이는 정상이다. 탐지 결과는 융합 상태가 조립되기 조금 전에 포착한 프레임에서 계산되었기 때문이다. 센서별 타임스탬프를 유지하면 소비자가 탐지 결과를 포착 당시의 로봇 자세에 맞춰 정렬할 수 있으며, 로봇이 1.4 m/s로 움직이는 상황에서는 이것이 중요하다. 이 프로파일에서 자이로스코프 값은 초당 라디안 단위의 각속도(제2장이 권고하는 REP-103 규약)이며, 이 사실을 프로파일 문서에 함께 기록해 둔다.
다음 페이로드에는 실제 통합 현장에서 자주 나타나는 유형의 오류 여섯 개를 일부러 넣었다. UUID 대신 제조사식 로봇 이름, 두 부분짜리 버전, 열거형 밖의 측위 출처, 음수 속도, 100을 넘는 배터리 값, 그리고 모드 자리에 쓴 행동 상태이다.
{
"robot_id": "robot-07",
"timestamp": "2025-12-26T10:30:15Z",
"version": "1.0",
"position": { "latitude": 37.5665, "longitude": 126.978, "source": "gnss" },
"heading": 45.0,
"speed": -0.2,
"battery_level": 104,
"mode": "navigating"
}
다음 Python 스크립트는 형식 단언을 켠 상태로 파일을 로봇 상태 스키마에 대해 검증한다. 널리 쓰이는 jsonschema 패키지를 사용한다. 이 패키지의 date-time 검사에는 선택 의존성(rfc3339-validator)이 필요하며, 이것이 없으면 형식 검사기를 넘겨도 타임스탬프는 검사되지 않는다는 점에 유의해야 한다.
import json
import sys
from jsonschema import Draft202012Validator, FormatChecker
with open("robot-state.bundled.json", encoding="utf-8") as f:
schema = json.load(f)
with open(sys.argv[1], encoding="utf-8") as f:
document = json.load(f)
Draft202012Validator.check_schema(schema) # 스키마 자체가 유효한 2020-12 문서인지 확인
validator = Draft202012Validator(schema, format_checker=FormatChecker())
errors = sorted(validator.iter_errors(document), key=lambda e: list(e.absolute_path))
for err in errors:
path = "/".join(str(p) for p in err.absolute_path) or "(root)"
print(f"{path} -> {err.message}")
print("valid" if not errors else f"{len(errors)} error(s)")
sys.exit(1 if errors else 0)
jsonschema 4.x로 위의 유효하지 않은 페이로드를 검사하면 다음과 같이 출력된다.
battery_level -> 104 is greater than the maximum of 100
mode -> 'navigating' is not one of ['autonomous', 'manual', 'idle', 'charging', 'error']
position/source -> 'gnss' is not one of ['gps', 'rtk-gps', 'slam', 'visual-odometry', 'fused']
robot_id -> 'robot-07' is not a 'uuid'
speed -> -0.2 is less than the minimum of 0
version -> '1.0' does not match '^\\d+\\.\\d+\\.\\d+$'
6 error(s)
스크립트에서 format_checker=FormatChecker()를 빼면 robot_id 줄이 사라지고 오류는 다섯 개만 보고된다. 3.1.2절에서 설명한 '조용한 주석' 동작 그대로이다. 이는 검증 파이프라인의 좋은 회귀 시험이 된다. 이 페이로드를 부정 시험 벡터로 보관하고, uuid 오류가 반드시 나타나는지 단언하면 된다.
TypeScript 서비스에서는 Ajv로 같은 일을 할 수 있다. 2020-12 방언은 별도의 진입점에서 제공되고, 형식 검사는 ajv-formats 플러그인이 맡는다.
import Ajv2020 from "ajv/dist/2020";
import addFormats from "ajv-formats";
import robotStateSchema from "./schemas/robot-state.bundled.json";
const ajv = new Ajv2020({ allErrors: true, strict: true });
addFormats(ajv); // "uuid", "date-time", "uri", "email"을 단언으로 바꾼다
const validateRobotState = ajv.compile(robotStateSchema);
export function assertRobotState(payload: unknown): void {
if (!validateRobotState(payload)) {
const details = (validateRobotState.errors ?? [])
.map((e) => `${e.instancePath || "(root)"} ${e.message}`)
.join("; ");
throw new Error(`RobotState rejected: ${details}`);
}
}
allErrors: true를 주면 Ajv는 첫 위반에서 멈추지 않고 모든 위반을 보고한다. 적합성 보고서에 필요한 것이 바로 이것이다. strict: true는 모르는 키워드가 든 스키마를 거부하게 하므로, 구현자 프로파일 파일의 오타도 잡아낸다.
검증은 모든 신뢰 경계에서 하되, 실패 방식은 경계의 성격에 맞춰야 한다. 로봇에서는 개발·시험 빌드에서 나가는 상태를 모두 검증하고, 운영 빌드에서도 최소한 표본 검증을 한다. 필드 하나를 망가뜨리는 펌웨어 회귀는 플릿에 닿기 전에 잡는 편이 훨씬 싸다. API 게이트웨이에서는 들어오는 요청 본문을 모두 검증하고, 유효하지 않으면 2단계 오류 봉투로 거부하면서 details에 문제 경로를 적는다. 데이터를 버리는 것 자체가 위험인 텔레메트리 수집 경로에서는 유효하지 않은 메시지를 폐기하지 말고 격리한 뒤 개수를 센다. 1단계 6.1절의 권고대로 검증 실패를 기록하되, 다른 모든 기록과 같은 가림 규칙을 적용하여 거부된 패키지 본문이 접근 코드를 로그에 흘리지 않게 해야 한다.
이 장을 짧은 점검표로 마무리한다.
CargoStatus, ActiveRoute, Error, Contact, Location을 정의하고 경로 스키마의 Position 참조를 해결하는 프로파일을 공개한다.date-time 지원을 포함한다.package_id와 route_id에는 UUID를 발급하고, 사람이 읽을 참조는 tracking_number에 둔다.access_code를 텔레메트리와 로그에서 배제한다.적합한 문서를 손에 넣었으니, 다음 장에서는 이를 네트워크 위로 옮긴다. 인증, 2단계의 로봇·패키지·경로·플릿 엔드포인트, 그리고 무엇이 잘못되었는지 알려 주는 오류 봉투가 그 주제이다.
← 제2장: 위치·인지의 기초 — 로봇 상태를 숫자로 표현하기 | 인덱스로 돌아가기 | 제4장: 2단계 API 인터페이스 — 인증·로봇·패키지·경로·플릿 →