🚚 WIA 배송 로봇 표준 전자책 | 제4장 (총 8장)


제4장: 2단계 API 인터페이스 — 인증·로봇·패키지·경로·플릿

제3장에서는 로봇·패키지·경로가 전송될 때 어떤 모양을 갖는지 정했다. 이 장은 그 객체들이 시스템 사이를 어떻게 오가는지를 다룬다. WIA ROB-010의 2단계(Phase 2)는 작은 REST 표면을 정의한다. 토큰 엔드포인트 1개, 로봇 엔드포인트 3개, 패키지 엔드포인트 2개, 경로 엔드포인트 1개, 플릿 엔드포인트 2개이고, 4단계가 충전소 엔드포인트 3개를 더한다. 이것만 있으면 배차 서비스, 가맹점 연동, 운영 콘솔이 제조사별 어댑터를 따로 짜지 않고도 어느 적합 제조사의 로봇과든 함께 동작할 수 있다.

2단계 문서는 짧다. 경로, 요청 예시, 응답 예시는 나열하지만 많은 결정을 구현자에게 맡긴다. 어떤 스코프가 어떤 엔드포인트를 보호하는지, 비동기 명령이 끝났다는 사실을 클라이언트가 어떻게 아는지, 문서에 나오는 두 개 말고 어떤 오류 코드가 있는지, 기계 판독용 OpenAPI 기술서가 본문의 경로와 어떤 관계인지가 모두 정해져 있지 않다. 이 장은 규범 표면을 순서대로 짚으면서 본문이 비어 있는 곳을 표시한다. 그런 곳마다 권고를 제시하고 권고임을 밝혀 둔다. 같은 본문을 따르는 다른 구현과 상호 운용성을 유지하기 위해서이다.

이 장의 예시는 책 전체에서 쓰는 샘플 객체를 그대로 쓴다. 로봇은 550e8400-e29b-41d4-a716-446655440000, 패키지는 7c9e6679-7425-40de-944b-e07fc1f90ae7, 경로는 3b241101-e2bb-4255-8caf-4136c566a962이다. 호스트 api.example.com은 자리표시자이다. 예시 페이로드의 시각·소요 시간·건수는 모두 예시 값이며 성능 기준이 아니다.

4.1 2단계 API의 설계 원칙

2단계는 다섯 가지 원칙으로 시작한다. 원칙마다 설계를 구체적으로 제약한다.

4.1.1 자원 지도

아래 표는 규범 표면 전체와, 이 책이 엔드포인트마다 권고하는 OAuth 스코프를 함께 보여 준다. 2단계는 다섯 개 스코프를 정의하지만 엔드포인트와 연결하지는 않는다. 따라서 오른쪽 열은 권고이며, 사양 §2.2의 스코프 설명을 최대한 문자 그대로 따랐다.

메서드경로용도권고 스코프성공 코드
POST/oauth/token액세스 토큰 발급(클라이언트 자격 증명)— (클라이언트 인증)200
GET/api/v1/robots/{robot_id}현재 RobotStaterobot:read200
POST/api/v1/robots/{robot_id}/commands/start자율 주행 시작robot:control202
POST/api/v1/robots/{robot_id}/commands/emergency_stop즉시 정지robot:control200
POST/api/v1/packages패키지 등록package:write201
GET/api/v1/packages/{package_id}패키지 상태와 추적 이벤트package:read200
POST/api/v1/routes/calculate배차 없이 경로만 계산robot:read (미규정)200
GET/api/v1/fleet/robots필터 조건으로 로봇 목록 조회fleet:admin 또는 robot:read (미규정)200
GET/api/v1/fleet/statistics플릿 전체 요약fleet:admin (미규정)200
GET/api/v1/charging_stations충전소 목록(4단계 §4.1)fleet:admin (미규정)200
GET/api/v1/charging_stations/{station_id}/availability충전소의 빈 슬롯fleet:admin (미규정)200
POST/api/v1/charging_stations/{station_id}/reserve로봇용 슬롯 예약fleet:admin (미규정)미규정(200 또는 201)

"미규정"은 2단계가 아무 지침도 주지 않는다는 뜻이다. 무엇을 택하든 OpenAPI 문서(4.7절)에 공개해서 클라이언트가 시행착오로 알아내지 않아도 되게 해야 한다.

4.1.2 API가 아닌 것

연동 담당자가 흔히 반대로 생각하는 한계 세 가지를 먼저 밝혀 둔다.

  1. API는 안전 채널이 아니다. 0.3 m 앞 장애물을 감지한 로봇을 멈추는 일은 3단계가 정의하는 온보드 기능이며, 모터 차단 목표는 100 ms 미만이다. 인터넷을 거치는 HTTP 왕복으로는 이를 보장할 수 없다. 원격 비상 정지 엔드포인트는 운영자가 거는 트리거를 하나 더할 뿐이고, 로봇 자체의 반사 동작을 대신하지 않는다.
  2. API는 텔레메트리 스트림이 아니다. GET /robots/{robot_id}는 스냅숏을 돌려준다. 연속 상태는 MQTT 토픽 fleet/robots/{robot_id}/state로 흐른다(4단계, 제6장). 플릿 전체를 대상으로 REST 엔드포인트를 매초 폴링하면 요청 한도를 소진하고, 이미 있는 채널을 중복해서 만드는 셈이 된다.
  3. API에는 최종 사용자 신원이 없다. 정의된 권한 부여 방식은 클라이언트 자격 증명 하나뿐이므로 기계 대 기계 접근만 해당된다. 수령인이 휴대폰으로 배송을 조회할 때는 운영사의 자체 애플리케이션을 거친다. 그 애플리케이션이 자기 자격 증명으로 ROB-010 API를 호출하고, 사용자별 접근 검사는 직접 수행한다.

4.2 인증과 권한 부여

4.2.1 클라이언트 자격 증명 흐름

2단계는 OAuth 2.0 클라이언트 자격 증명 방식(RFC 6749 §4.4)을 쓴다. 클라이언트는 식별자와 비밀 값으로 자신을 인증하고 수명이 짧은 액세스 토큰을 받는다. 이후 모든 API 호출에 이 토큰을 Bearer 자격 증명(RFC 6750)으로 실어 보낸다. 아래는 사양의 폼 인코딩 요청을 자리표시자 자격 증명으로 옮긴 것이다.

POST /oauth/token HTTP/1.1
Host: api.example.com
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&client_id=dispatch-svc&client_secret=<secret>&scope=robot:read%20robot:control%20package:write
{
    "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
    "token_type": "Bearer",
    "expires_in": 3600,
    "scope": "robot:read robot:control package:write"
}

실무에서 중요한 점이 세 가지 있다. 첫째, 스코프는 공백으로 구분하며 폼 본문에서는 %20 또는 +로 인코딩된다. 둘째, 응답의 scope는 실제로 부여된 범위를 알려 준다. 권한 부여 서버는 요청보다 적게 부여할 수 있으므로, 클라이언트는 둘을 비교해서 배송 도중에 403을 만나기 전에 미리 실패 처리해야 한다. 셋째, expires_in의 단위는 초이다. 사양의 3600은 예시 값이지 고정 수명이 아니다. 클라이언트는 응답마다 이 값을 읽고, 예를 들어 수명의 80~90 %가 지났을 때 미리 갱신해서 만료 직전의 토큰으로 요청이 나가지 않게 해야 한다.

RFC 6749는 비밀 값을 본문에 넣는 대신 HTTP Basic으로 클라이언트를 인증하는 방식도 허용한다. 적합 서버는 최소한 2단계가 보여 준 본문 방식을 받아들여야 한다. 더 높은 보증이 필요한 배치는 OAuth 2.0 보안 모범 사례(RFC 9700)를 따라 비대칭 클라이언트 인증과 발신자 제약 토큰(상호 TLS 또는 DPoP)을 쓸 수 있다. 이는 2단계와 충돌하지 않는 추가 사항이다.

4.2.2 스코프와 최소 권한

다섯 개 스코프는 표면을 읽기·제어·관리로 나눈다.

스코프사양의 설명유출 시 위험
robot:read로봇 상태와 텔레메트리 읽기로봇 위치 추적(간접적으로는 배송 추적)
robot:control제어 명령 전송공격자가 로봇을 출발시키거나 멈춤 — 물리 세계에 영향
package:read패키지 정보 조회수령인 이름·전화번호·주소 노출
package:write패키지 생성과 관리허위 작업 등록, 하차 지점 조작
fleet:admin플릿 관리와 설정플릿 전체 가시성과 설정 변경

일반적인 배치에는 여러 종류의 클라이언트가 있다. 각 클라이언트는 업무에 필요한 스코프만 가져야 한다.

클라이언트하는 일부여할 스코프주지 않을 스코프
공개 배송 조회 대시보드 백엔드패키지 위치 표시package:read나머지 전부
가맹점 연동(음식점·상점)주문 등록, 상태 확인package:write, package:readrobot:control, fleet:admin
배차 서비스로봇 배정, 주행 시작robot:read, robot:control, package:readfleet:admin
운영 콘솔플릿 감시, 로봇 정지, 충전 관리robot:read, robot:control, fleet:adminpackage:write(운영팀이 주문을 만드는 일은 드묾)
분석 작업플릿 통계 조회fleet:admin 또는 더 좁은 사용자 정의 읽기 스코프robot:control

분석 작업 행은 빈틈을 하나 드러낸다. fleet:admin에는 읽기와 설정이 섞여 있다. 읽기 전용 플릿 스코프가 필요하면 fleet:read 같은 스코프를 추가해도 된다. 스코프를 추가해도 기존 클라이언트는 깨지지 않는다. 새 스코프를 문서화하고, fleet:admin은 상위 집합으로 계속 받아들이면 된다.

패키지 스코프에는 테넌트 경계도 필요한데, 스코프만으로는 이 경계가 생기지 않는다. package:read를 가진 가맹점은 자기 패키지만 보아야 한다. 다중 테넌트 배치(프로파일은 제7장에서 설명)에서는 서버가 클라이언트 신원에서 테넌트를 도출하고 모든 조회를 그것으로 거른다. 가맹점이 다른 가맹점의 패키지를 요청하면 403이 아니라 404를 돌려준다. 403은 그 식별자가 존재한다는 사실을 알려 주기 때문이다.

4.2.3 401과 403

두 코드는 의미가 다르고, 클라이언트도 다르게 대응해야 한다.

4.3 로봇 제어

4.3.1 로봇 상태 읽기

GET /api/v1/robots/{robot_id}는 로봇의 현재 RobotState 문서, 즉 제3장의 1단계 스키마를 돌려준다. 서버는 이를 최신 MQTT 메시지로 조립해도 된다. 다만 결과는 robot-state.json에 대해 검증을 통과해야 하며, 필수 필드 robot_id, timestamp, version, position, battery_level을 갖춰야 한다.

GET /api/v1/robots/550e8400-e29b-41d4-a716-446655440000 HTTP/1.1
Authorization: Bearer <access_token>

HTTP/1.1 200 OK
Content-Type: application/json

{
    "robot_id": "550e8400-e29b-41d4-a716-446655440000",
    "timestamp": "2025-12-26T10:30:15Z",
    "version": "1.0.0",
    "position": { "latitude": 37.5665, "longitude": 126.9780,
                  "accuracy": 2.5, "source": "gps" },
    "heading": 45.0,
    "speed": 2.5,
    "battery_level": 87.0,
    "mode": "autonomous",
    "cargo": { "occupied_slots": 3, "total_slots": 5 }
}

이 응답에서 틀리기 쉬운 점이 두 가지 있다. timestamp는 API가 응답한 시각이 아니라 로봇에서 상태를 관측한 시각이며, "마지막 확인" 표시에는 이 값을 써야 한다. 10분째 오프라인인 로봇에도 옛 타임스탬프를 담은 200을 돌려준다. 새 시각을 지어내서도 안 되고 404를 돌려줘서도 안 된다. 둘째, cargo 객체는 1단계가 참조만 하고 정의하지 않은 CargoStatus를 가리킨다(제3장 3.7절). 참고할 수 있는 것은 사양 예시의 occupied_slots/total_slots 모양뿐이므로, 이를 자체 정의로 채택하고 그 사실을 문서화하는 것이 합리적이다.

4.3.2 주행 시작: 왜 202인가

POST /api/v1/robots/550e8400-e29b-41d4-a716-446655440000/commands/start HTTP/1.1
Authorization: Bearer <access_token>
Content-Type: application/json

{
    "destination": { "latitude": 37.5700, "longitude": 126.9850 },
    "route_preference": "fastest",
    "max_speed_ms": 2.5
}

HTTP/1.1 202 Accepted
Content-Type: application/json

{
    "command_id": "cmd-2025-12-26-001",
    "status": "queued",
    "estimated_arrival": "2025-12-26T10:45:00Z"
}

202 Accepted는 "받았고 검증했지만 아직 끝나지 않았다"는 뜻이다. 클라우드는 시작 명령을 HTTP 한 번의 교환 안에 끝낼 수 없다. 로봇은 지연이 들쭉날쭉한 셀룰러 링크에 있을 수 있고, 경로를 계획하고, 배터리·적재함 닫힘·활성 고장 여부 같은 자기 상태를 점검하고, 행동 상태를 바꿔야 한다. 그동안 HTTP 연결을 붙잡고 있으면 서버 자원이 묶이고 클라이언트에서 시간 초과가 난다. 그래서 서버는 명령을 대기열에 넣고 식별자를 돌려준 뒤, 결과는 클라이언트가 나중에 알아내게 한다.

요청 본문에는 별도 스키마가 없지만 각 필드가 1단계에 대응한다. destination은 Position(최소한 위도와 경도)이고, route_preference는 Route의 열거형(fastest, shortest, safest, energy_efficient)을 쓰며, max_speed_ms는 navigation_constraints.max_speed_ms 필드와 같다. 본문은 이 1단계 정의로 검증한다. route_preference가 "quickest"라면 fastest로 조용히 바꿔 처리하지 말고 400을 돌려줘야 한다.

클라이언트는 결과를 어떻게 아는가? 2단계에는 명령 상태 엔드포인트가 없다. 규범 표면만 쓰는 클라이언트에게는 간접 신호가 두 가지 있을 뿐이다.

둘 다 간접적이다. mode는 여러 이유로 바뀌기 때문에 특정 변화와 특정 명령을 확실히 연결할 수 없다. 서버를 직접 운영한다면 명시적인 자원을 추가하는 편이 낫다. 예를 들어 GET /api/v1/robots/{robot_id}/commands/{command_id}가 queued, accepted, rejected, executing, completed, failed와 사유를 돌려주게 하고, 202 응답의 Location 헤더에 그 URL을 담는다. 비동기 작업에 쓰는 통상적인 HTTP 패턴이다. 이것은 확장이므로 OpenAPI 문서에 확장으로 표시한다. 2단계만 아는 클라이언트는 그냥 무시할 것이다.

예시의 command_id는 cmd-2025-12-26-001이다. 1단계는 명령 ID의 형식을 정하지 않으므로 스키마 위반은 아니다. 하지만 날짜에 세 자리 일련번호를 붙이는 방식은 번호가 소진되고 서버 간에 충돌하므로 UUID가 낫다.

4.3.3 비상 정지: 왜 200인가

POST /api/v1/robots/550e8400-e29b-41d4-a716-446655440000/commands/emergency_stop HTTP/1.1
Authorization: Bearer <access_token>

HTTP/1.1 200 OK
Content-Type: application/json

{
    "command_id": "cmd-2025-12-26-002",
    "status": "executed",
    "stopped_at": "2025-12-26T10:31:00Z"
}

비상 정지는 동기적으로 응답하는 유일한 명령이며, 이유는 운영에 있다. 버튼을 누른 운영자는 로봇이 멈췄는지를 알아야 한다. status: "executed"와 stopped_at 시각이 담긴 200은 서버의 수신 확인이 아니라 로봇의 확인이다. 이 의미를 지키려면 서버는 로봇이 정지를 보고한 뒤에만 200을 돌려줘야 한다.

어려운 것은 로봇에 닿지 못할 때이다. 2단계는 이 경우를 다루지 않는다. 다음은 권고 동작이다.

정지가 로봇에게 무엇을 뜻하는지도 짚어 둔다. 3단계(제5장)에 따르면 비상 정지 뒤 로봇은 수동 해제가 있어야 다시 움직인다. 따라서 호출자가 robot:control을 가졌더라도 운영자가 정지를 해제하기 전까지 들어오는 start 명령은 거부해야 한다.

4.3.4 재시도와 멱등성

네트워크는 서버가 처리를 마친 뒤, 클라이언트가 응답을 받기 전에 끊기기도 한다. start 요청이 시간 초과되면 클라이언트는 로봇이 명령을 받았는지 알 수 없다. 무작정 재시도하면 목적지가 조금 다른 두 번째 시작 명령이 대기열에 들어갈 수도 있다. HTTP에서 POST는 멱등이 아니며, 2단계도 이를 보완하는 장치를 두지 않는다. 권고 패턴은 다음과 같다.

4.4 패키지

4.4.1 패키지 생성

POST /api/v1/packages HTTP/1.1
Authorization: Bearer <access_token>
Content-Type: application/json

{
    "recipient": { "name": "Recipient Example", "phone": "+82-10-0000-0000",
                   "address": "Example-ro 1, Jung-gu, Seoul" },
    "pickup_location":  { "latitude": 37.5665, "longitude": 126.9780 },
    "dropoff_location": { "latitude": 37.5700, "longitude": 126.9850 },
    "package_details": { "weight_kg": 2.5, "category": "food",
                         "temperature_controlled": true },
    "access_control": { "access_code": "4821" },
    "priority": "express"
}

HTTP/1.1 201 Created
Location: /api/v1/packages/7c9e6679-7425-40de-944b-e07fc1f90ae7
Content-Type: application/json

{
    "package_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
    "tracking_number": "WIA1234567890",
    "status": "pending",
    "estimated_delivery": "2025-12-26T11:00:00Z",
    "created_at": "2025-12-26T10:30:00Z"
}

이 예시는 사양 본문과 두 군데가 다르며, 둘 다 의도한 것이다. 사양의 201 응답은 "package_id": "pkg-2025-12-26-0001"을 돌려주지만 1단계 Package 스키마는 package_id를 format: uuid로 선언한다. 형식 검사를 켠 검증기는 이 값을 거부하므로(제3장 3.7절) 적합 서버는 여기서처럼 UUID를 발급해야 한다. 또 응답에 새 자원을 가리키는 Location 헤더를 넣었다. HTTP 의미론(RFC 9110)이 201 응답에 권하는 방식이다. 비용은 들지 않고, 클라이언트가 URL을 직접 조립하지 않아도 된다.

요청 본문은 서버가 부여하는 필드를 뺀 Package 문서이다. 1단계 Package 스키마는 package_id를 필수로 두지만, 생성 시점의 클라이언트에게는 아직 ID가 없다. 실용적인 방법은 파생 스키마 "PackageCreate"로 요청을 검증하는 것이다. 속성은 같게 두고 package_id, tracking_number, status를 필수 목록에서 빼되, 클라이언트가 보내면 거부한다. 저장된 문서는 전체 Package 스키마로 검증한다.

업무 영역에서 오는 1단계 제약은 서버에서 검사한다. weight_kg는 음수가 아니어야 하고(스키마의 최솟값은 0이며, 0까지 거부하는 것은 합리적인 자체 규칙이다), category는 열거형(food, retail, medical, document, other) 안에 있어야 하며, priority는 normal, express, urgent 중 하나, access_code는 ^[0-9]{4,8}$와 일치해야 한다. 서버는 운영상 검사를 더할 수 있다. 하차 지점이 서비스 구역 밖이거나 무게가 구역 내 어느 로봇의 적재 능력도 넘는 경우 같은 것이다. 이런 경우 패키지를 영영 pending에 머물게 하지 말고 명시적 코드를 담은 400을 돌려줘야 한다(4.6절).

접근 코드 다루기. access_code는 적재함을 여는 값이므로 스택 전체에서 비밀로 다뤄야 한다. 생성 시에는 받는다. 그러나 이 201 응답이든 이후의 GET 응답이든 어떤 응답에도 되돌려 보내지 않는다. 요청 로그에도 남기지 않으며, 로봇에는 인증된 명령 채널로만 전달한다. 수령인 연락처도 마찬가지이다. 전화번호와 주소는 한국의 개인정보 보호법, GDPR 및 그와 비슷한 법률에서 개인정보에 해당한다. 최소 수집, package:read와 테넌트 필터링을 통한 접근 통제, 보존 기간이 끝나면 삭제라는 일반 원칙을 적용한다.

4.4.2 상태와 추적 이벤트 읽기

GET /api/v1/packages/7c9e6679-7425-40de-944b-e07fc1f90ae7 HTTP/1.1
Authorization: Bearer <access_token>

HTTP/1.1 200 OK
Content-Type: application/json

{
    "package_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
    "status": "in_transit",
    "assigned_robot": "550e8400-e29b-41d4-a716-446655440000",
    "current_location": { "latitude": 37.5680, "longitude": 126.9810 },
    "estimated_delivery": "2025-12-26T10:55:00Z",
    "tracking_events": [
        { "timestamp": "2025-12-26T10:30:00Z", "event": "created" },
        { "timestamp": "2025-12-26T10:35:00Z", "event": "assigned_to_robot" },
        { "timestamp": "2025-12-26T10:40:00Z", "event": "picked_up" }
    ]
}

이 응답에는 두 어휘가 섞여 있다. status는 1단계 열거형을 쓰고, tracking_events 항목은 별도 이벤트 이름을 쓰는데 사양에는 그중 세 개만 나온다. 이벤트를 사용자용 문구로 바꾸는 클라이언트에게는 전체 목록이 필요하므로 서버가 목록을 공개해야 한다. 아래 표는 이벤트 이름을 그 이벤트가 만드는 상태 값에 맞춰 정렬한 것이다. 앞의 세 이름은 사양에서 왔고 나머지는 이 책의 제안이다.

추적 이벤트출처결과 status
created2단계 예시pending
assigned_to_robot2단계 예시assigned
picked_up2단계 예시picked_up
departed제안in_transit
arrived제안arrived
delivered제안delivered
delivery_failed제안failed
returned_to_sender제안returned

두 어휘를 일관되게 유지하는 서버 규칙이 두 가지 있다. 첫째, status는 항상 가장 최근 이벤트가 만든 상태와 같아야 한다. 따로 저장하지 말고 도출해야 한다. 그러지 않으면 둘이 어긋난다. 둘째, 1단계 수명 주기에서 허용되지 않는 전이를 일으키는 이벤트, 예를 들어 pending에서 곧바로 delivered로 가는 이벤트는 거부하고 409 Conflict를 돌려준다. 409는 2단계 목록에 없지만, 요청이 자원의 현재 상태와 충돌할 때 쓰는 표준 HTTP 코드이다. 이벤트에 필드를 몇 개 더하면 쓸모가 커진다. 이벤트를 만든 로봇, 그때의 위치, 실패 시 사유 코드 같은 것이다. 선택 필드 추가는 비파괴적 변경이다.

4.5 경로·플릿·충전소

4.5.1 경로 계산

POST /api/v1/routes/calculate는 입력이 구조화된 본문이라서 POST를 쓸 뿐, 성격은 조회이다. 경로를 계산하기만 하고 배차는 하지 않으며 어떤 로봇도 움직이지 않는다. 그래서 견적 화면("이 배송은 얼마나 걸릴까?")이나 계획 도구에서 부담 없이 호출할 수 있다.

POST /api/v1/routes/calculate HTTP/1.1
Authorization: Bearer <access_token>
Content-Type: application/json

{
    "start":       { "latitude": 37.5665, "longitude": 126.9780 },
    "destination": { "latitude": 37.5700, "longitude": 126.9850 },
    "route_preference": "fastest",
    "constraints": { "sidewalk_only": true, "avoid_roads": true }
}

HTTP/1.1 200 OK
Content-Type: application/json

{
    "route_id": "3b241101-e2bb-4255-8caf-4136c566a962",
    "total_distance_meters": 2300,
    "estimated_duration_seconds": 720,
    "battery_usage_percent": 8,
    "waypoints": [
        { "position": { "latitude": 37.5665, "longitude": 126.9780 }, "waypoint_type": "start" },
        { "position": { "latitude": 37.5680, "longitude": 126.9810 }, "waypoint_type": "intermediate" },
        { "position": { "latitude": 37.5700, "longitude": 126.9850 }, "waypoint_type": "destination" }
    ]
}

1단계 Route 스키마와 비교하면 세 가지가 눈에 띈다. 첫째, 요청은 제약 객체를 constraints라고 부르지만 Route 스키마에서는 navigation_constraints이다. 멤버(max_speed_ms, avoid_roads, sidewalk_only, elevator_allowed)는 같다. 너그러운 서버라면 요청에서는 두 이름을 모두 받아들이되, 저장하는 Route 문서에는 항상 navigation_constraints로 써야 한다. 둘째, 사양 예시는 "route_id": "route-2025-12-26-001"을 돌려주는데, 패키지 ID와 마찬가지로 format: uuid를 위반한다. 위 예시는 UUID를 썼다. 셋째, battery_usage_percent는 Route의 속성이 아니다. 1단계가 추가 속성을 금지하지 않으므로 돌려줘도 되지만, "추가 속성 금지" 프로파일로 엄격하게 검증하는 클라이언트는 거부할 것이다. 서버가 덧붙이는 추정치로 취급해야 한다. 예시의 8 %는 실제 소모량과 무관하다.

계산된 경로가 로봇이 실제로 달릴 경로라는 보장은 없다. 로봇의 계획기(제5장)는 상황이 바뀌면 경로를 다시 계획하고, start는 route_id가 아니라 목적지를 받는다. "정확히 이 경로로 가라"가 필요하다면 확장으로 시작 본문에 선택 필드 route_id를 받고, 계산된 경로가 얼마 동안 유효한지를 정의한다.

4.5.2 로봇 목록과 페이지 매기기

GET /api/v1/fleet/robots?status=active&zone=gangnam&page=1&per_page=20 HTTP/1.1
Authorization: Bearer <access_token>

HTTP/1.1 200 OK
Content-Type: application/json

{
    "total_count": 24,
    "page": 1,
    "per_page": 20,
    "robots": [
        {
            "robot_id": "550e8400-e29b-41d4-a716-446655440000",
            "status": "active",
            "battery_level": 87,
            "packages": 3,
            "location": { "latitude": 37.5665, "longitude": 126.9780 }
        }
    ]
}

목록 항목은 RobotState 문서가 아니라 요약이고, 필드 이름도 따로 쓴다(position 대신 location, mode 대신 status). status 필터 값 active는 1단계 mode 열거형에 없으며, 2단계도 허용 값을 나열하지 않는다. 플릿 통계 엔드포인트를 보면 active, idle, charging이라는 어휘가 암시된다. 합리적인 대응은 이렇다. autonomous 또는 manual 모드인 로봇은 active, idle은 idle, charging은 charging으로 두고, 운영자가 고장 로봇을 빨리 찾을 수 있도록 error 구분을 따로 둔다. 이 대응표는 공개한다. zone은 자유 형식 식별자이며 의미는 배치마다 다르다.

페이지 번호 방식은 집합이 바뀌면 흔들린다. 두 페이지를 요청하는 사이에 idle에서 active로 바뀐 로봇은 두 번 나오거나 아예 빠질 수 있다. 안정적인 키(robot_id)로 정렬하고, total_count는 근삿값으로 다루며, 서버에서 per_page 상한을 둔다. 대규모 플릿에는 불투명 커서 매개변수가 더 견고한 확장이다.

4.5.3 플릿 통계

GET /api/v1/fleet/statistics는 total_robots, 상태별 대수, deliveries_today, success_rate, average_delivery_time_minutes를 담은 요약을 돌려준다. 사양 예시의 값(성공률 98.7 등)은 자리표시자일 뿐 성능 주장이 아니다. 상호 운용성에는 숫자보다 정의가 중요하다. "오늘"은 무엇이고 어느 시간대 기준인가? success_rate는 백분율(0~100)인가 비율(0~1)인가? 반송된 패키지는 실패로 세는가? 4단계 §6.1은 배송 성공률을 성공 배송 수 ÷ 전체 배송 수로 정의한다. 이 공식을 쓰고, 예시에 맞춰 백분율로 돌려주고, 시간 창을 문서화한다. 제7장은 같은 통계를 운영자 KPI로 쓴다.

4.5.4 충전소 엔드포인트

4단계 §4.1은 같은 /api/v1 접두사 아래 경로 세 개를 더한다. 충전소 목록, 충전소별 가용 슬롯 확인, 슬롯 예약이다. 사양에는 경로와 한 줄 설명만 있고 본문 형태, 응답 형태, 상태 코드는 없다. 이 뒤에 있는 프로토콜(도킹, AprilTag 정밀 위치 맞춤, 배터리 20~30 %에서 선제 충전)은 제6장에서 다룬다. API 계층에서 권고할 것은 다음과 같다. 예약에 식별자와 만료 시각을 준다. 이미 찬 슬롯이면 409를 돌려준다. 예약 호출은 시작 명령과 같은 방식으로 멱등하게 만든다. 시간 초과 뒤에 재시도한 배차기가 로봇 한 대에 슬롯 두 개를 잡는 일이 없어야 하기 때문이다.

4.6 오류와 요청 한도

4.6.1 오류 봉투

2xx가 아닌 모든 응답은 같은 구조를 갖는다.

{
    "error": {
        "code": "ROBOT_NOT_FOUND",
        "message": "Robot with ID 550e8400-e29b-41d4-a716-446655440000 not found",
        "details": { "robot_id": "550e8400-e29b-41d4-a716-446655440000" },
        "timestamp": "2025-12-26T10:30:00Z",
        "request_id": "req-2025-12-26-001"
    }
}

멤버마다 역할이 다르다. code는 프로그램용이다. 클라이언트가 분기에 쓰는 안정적인 대문자 식별자이다. message는 사람용이다. 문구나 언어가 바뀔 수 있으므로 클라이언트가 이를 해석해서는 절대 안 된다. details는 문제가 된 필드나 찾지 못한 ID 같은 구조화된 맥락을 담는다. request_id는 클라이언트의 오류 보고를 서버 로그와 이어 준다. 같은 값을 성공 응답을 포함한 모든 응답의 헤더(예: X-Request-Id)에도 넣어서 지원 담당자가 어떤 호출이든 추적할 수 있게 한다.

2단계에 나오는 코드는 ROBOT_NOT_FOUND와 RATE_LIMIT_EXCEEDED 두 개뿐이다. 실제 구현에는 더 필요하다. 아래 목록은 사양의 명명 방식에 맞춰 이름을 지은 제안이다. 2단계 부속서 E는 오류 코드 추가를 비파괴적 변경으로 보므로, 클라이언트는 모르는 코드도 처리하도록 작성해야 한다. 모르는 코드는 HTTP 상태 분류로 대체해서 판단한다.

HTTP 상태2단계에서의 의미code 예클라이언트 대응
200 OK성공(조회, 경로 계산, 확인된 비상 정지)—본문 사용
201 Created자원 생성(패키지)—새 ID / Location 저장
202 Accepted비동기 처리 대기열 등록(시작)—결과를 따로 추적
400 Bad Request잘못된 매개변수나 본문VALIDATION_FAILED, INVALID_ROUTE_PREFERENCE, OUTSIDE_SERVICE_AREA(제안)요청을 고침. 그대로 재시도하지 않음
401 Unauthorized토큰 없음 또는 무효TOKEN_EXPIRED, INVALID_TOKEN(제안)토큰 1회 갱신 후 1회 재시도
403 Forbidden토큰은 유효하나 허용되지 않음INSUFFICIENT_SCOPE, ESTOP_NOT_CLEARED(제안)재시도하지 않고 보고
404 Not Found없거나 보이지 않는 자원ROBOT_NOT_FOUND(사양), PACKAGE_NOT_FOUND(제안)재시도하지 않음
429 Too Many Requests요청 한도 초과RATE_LIMIT_EXCEEDED(사양)retry_after까지 기다린 뒤 재시도
500 Internal Server Error서버 측 실패INTERNAL_ERROR, ROBOT_UNREACHABLE(제안)멱등 호출은 백오프 후 재시도. 비상 정지 실패는 상위 보고

서버는 409 Conflict(상태 충돌, 이미 찬 충전 슬롯)와 503 Service Unavailable(계획된 점검)을 추가해도 아무것도 깨뜨리지 않는다. 상태 분류로 대체하는 클라이언트라면 올바르게 처리하기 때문이다. ESTOP_NOT_CLEARED에는 409보다 403이 맞다. 사람이 개입하기 전에는 요청을 반복해도 달라지지 않기 때문이다.

4.6.2 요청 한도

사양의 기본 한도는 API 키당 시간당 1,000회이다. 남은 한도는 X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset 세 헤더가 알려 준다. 한도를 다 쓰면 서버는 다음과 같은 본문으로 429를 돌려준다.

{
    "error": {
        "code": "RATE_LIMIT_EXCEEDED",
        "message": "API rate limit exceeded. Retry after 2025-12-26T11:00:00Z",
        "retry_after": "2025-12-26T11:00:00Z"
    }
}

정해지지 않은 세부 사항이 몇 가지 있고, 꼼꼼한 구현은 하나하나 명시적으로 정한다.

4.6.3 클라이언트 재시도 정책

잘 만든 클라이언트는 재시도가 도움이 될 때만 재시도하고, 많은 클라이언트가 동시에 서버를 다시 두드리지 않도록 간격을 벌린다. 아래 Python 함수는 앞 표의 정책을 구현한다. 429에서는 retry_after를 따르고, 401에서는 토큰을 한 번 갱신하고, 500과 네트워크 오류에서는 무작위 지터를 넣은 지수 백오프로 재시도하며(멱등 요청에 한함), 그 밖의 4xx에서는 즉시 포기한다.

import random, time
import requests
from datetime import datetime, timezone

RETRYABLE_STATUS = {429, 500, 503}

def call(session, method, url, token_source, *, json=None,
         idempotent=False, idem_key=None, max_attempts=5):
    refreshed = False
    for attempt in range(max_attempts):
        headers = {"Authorization": f"Bearer {token_source.get()}"}
        if idem_key:
            headers["Idempotency-Key"] = idem_key
        try:
            r = session.request(method, url, json=json, headers=headers, timeout=10)
        except requests.ConnectionError:
            if not (idempotent or idem_key):
                raise                      # 결과를 알 수 없음: 호출자가 판단
            time.sleep(backoff(attempt))
            continue

        if r.status_code < 400:
            return r.json() if r.content else None
        err = r.json().get("error", {}) if r.content else {}

        if r.status_code == 401 and not refreshed:
            token_source.refresh(); refreshed = True
            continue
        if r.status_code == 429:
            time.sleep(seconds_until(err.get("retry_after"), r.headers))
            continue
        if r.status_code in RETRYABLE_STATUS and (idempotent or idem_key):
            time.sleep(backoff(attempt))
            continue
        raise ApiError(r.status_code, err.get("code"), err.get("message"),
                       err.get("request_id"))
    raise ApiError(0, "RETRIES_EXHAUSTED", url, None)

def backoff(attempt, base=0.5, cap=30.0):
    return random.uniform(0, min(cap, base * 2 ** attempt))   # "full jitter"

def seconds_until(retry_after, headers):
    if retry_after:                                           # 본문의 ISO 8601
        t = datetime.fromisoformat(retry_after.replace("Z", "+00:00"))
        return max(0.0, (t - datetime.now(timezone.utc)).total_seconds())
    return float(headers.get("Retry-After", 1))

class ApiError(Exception):
    def __init__(self, status, code, message, request_id):
        super().__init__(f"{status} {code}: {message} (request_id={request_id})")
        self.status, self.code, self.request_id = status, code, request_id

멱등 키가 없는 start 명령은 연결 오류 뒤에 재시도하지 않는다. 결과를 알 수 없고, 중복 배차는 눈에 보이는 실패보다 나쁘다. 호출자가 로봇 상태를 읽고 판단해야 한다. 같은 이유로 4.3.4절의 Idempotency-Key 패턴은 도입할 가치가 있다. 이 패턴을 갖추면 명령도 조회만큼 안전하게 재시도할 수 있다.

4.7 OpenAPI 초안과 2단계 본문 맞추기

저장소에는 api/openapi.yaml이 들어 있다. 유일한 기계 판독용 인터페이스 산출물인데 2단계와 맞지 않는다.

openapi: 3.1.0
info: { title: WIA-ROBOT-005 Delivery Robot, version: 1.0.0 }
servers: [{ url: https://api.example.com/wia-robot-005/v1 }]
paths:
  /robot: { get: { responses: { '200': { description: OK } } } }
  /mission:
    post: { responses: { '201': { description: Created } } }
  /telemetry/{robotId}:
    post: { ... responses: { '202': { description: Accepted } } }
  /incident:
    post: { responses: { '201': { description: Created } } }
항목api/openapi.yaml2단계 본문
식별자WIA-ROBOT-005(README와 OpenAPI가 쓰는 별칭)WIA ROB-010
OpenAPI 버전3.1.0"OpenAPI 3.0 사양 제공"
기본 경로/wia-robot-005/v1/api/v1
경로/robot, /mission, /telemetry/{robotId}, /incidentrobots, commands, packages, routes, fleet(4.1.1절)
스키마없음 — 응답에 설명만 있음$id를 가진 1단계 JSON Schema
보안선언 없음스코프 다섯 개를 가진 OAuth 2.0 클라이언트 자격 증명

WIA ROB-010과 WIA-ROBOT-005는 같은 표준을 가리킨다. 이 책은 네 개의 규범 단계 문서가 모두 쓰는 ROB-010을 쓴다. 초안의 경로 네 개는 README의 개념(미션 매니페스트, 텔레메트리 프레이밍, 사고 보고)에서 나왔다. 어느 단계 문서도 이를 정의하지 않으며, 제8장에서 확장 후보로 다룬다. 2단계 표면에 대해서는 완전한 OpenAPI 문서를 직접 작성하고, 본문이 3.0이라고 하더라도 OpenAPI 3.1을 택하기를 권한다. OpenAPI 3.1은 Schema Object를 1단계가 쓰는 방언인 JSON Schema 2020-12에 맞췄다. 따라서 1단계 스키마를 OpenAPI 3.0의 제한된 스키마 부분집합으로 바꾸지 않고 $id로 그대로 참조할 수 있다.

아래 조각은 조회 엔드포인트 하나, 비동기 명령 하나, 공통 오류 봉투의 작성 패턴을 보여 준다. 부속서 E가 요구하는 대로 배치의 운영 범위도 OpenAPI 문서 안에 선언했다. x-wia-deployment-profile은 벤더 확장 이름의 예시이며, 사양이 정한 이름은 없다.

openapi: 3.1.0
info:
  title: WIA ROB-010 Delivery Robot API (Phase 2)
  version: 1.0.0
  x-wia-deployment-profile: multi-tenant
servers:
  - url: https://api.example.com/api/v1
components:
  securitySchemes:
    oauth:
      type: oauth2
      flows:
        clientCredentials:
          tokenUrl: https://api.example.com/oauth/token
          scopes:
            robot:read: Read robot state and telemetry
            robot:control: Send control commands to robots
            package:read: View package information
            package:write: Create and manage packages
            fleet:admin: Fleet management and configuration
  schemas:
    RobotState:
      $ref: https://wiastandards.com/schemas/delivery-robot/v1/robot-state.json
    ErrorEnvelope:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code: { type: string, pattern: "^[A-Z][A-Z0-9_]*$" }
            message: { type: string }
            details: { type: object }
            timestamp: { type: string, format: date-time }
            request_id: { type: string }
            retry_after: { type: string, format: date-time }
  responses:
    Error:
      description: Standard error envelope
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
paths:
  /robots/{robot_id}:
    get:
      security: [{ oauth: [robot:read] }]
      parameters:
        - { name: robot_id, in: path, required: true,
            schema: { type: string, format: uuid } }
      responses:
        "200":
          description: Current robot state
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RobotState" }
        "401": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "429": { $ref: "#/components/responses/Error" }
  /robots/{robot_id}/commands/start:
    post:
      security: [{ oauth: [robot:control] }]
      parameters:
        - { name: robot_id, in: path, required: true,
            schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [destination]
              properties:
                destination:
                  $ref: https://wiastandards.com/schemas/delivery-robot/v1/robot-state.json#/$defs/Position
                route_preference:
                  enum: [fastest, shortest, safest, energy_efficient]
                max_speed_ms: { type: number, exclusiveMinimum: 0 }
      responses:
        "202": { description: Command queued }
        "400": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }

조각에 대해 두 가지를 덧붙인다. #/$defs/Position을 가리키는 $ref는 1단계에서처럼 Position이 RobotState 스키마의 $defs 아래에 정의되어 있다고 가정한다. 스키마를 다르게 묶어 두었다면 포인터를 고친다. 또 서버 URL이 이미 /api/v1로 끝나므로 경로는 그에 대한 상대 경로로 적었다. servers: /에 전체 경로를 쓰는 문서도 똑같이 유효하다. 한 가지 형식을 골라 일관되게 쓰면 된다. 제7장은 이 문서를 증거 패키지의 일부로 다루며, 심사자는 이를 실제 운영 중인 서비스와 대조한다.

4.8 종합: 클라이언트 흐름 따라가기

4.8.1 순서

아래 그림은 배차 서비스가 배송 한 건에 대해 하는 호출을 보여 준다. 비동기 구간은 202 응답과 로봇 상태가 바뀌는 시점 사이에 있다.

Client                     API server                   Robot
  |  POST /oauth/token        |                            |
  |-------------------------->|                            |
  |  200 {access_token}       |                            |
  |<--------------------------|                            |
  |  POST /packages           |                            |
  |-------------------------->|                            |
  |  201 {package_id}         |                            |
  |<--------------------------|                            |
  |  POST /robots/{id}/commands/start                      |
  |-------------------------->|  command (queued)          |
  |  202 {command_id}         |--------------------------->|
  |<--------------------------|                            | plan, self-check
  |                           |  state: mode=autonomous    |
  |  GET /robots/{id}  (poll) |<---------------------------|
  |-------------------------->|                            |
  |  200 {mode, route, ...}   |                            |
  |<--------------------------|                            |
  |  GET /packages/{pkg} (poll, or subscribe via Phase 4)  |
  |-------------------------->|                            |
  |  200 {status: in_transit} |                            |
  |<--------------------------|                            |

4.8.2 curl로 해 보기

API=https://api.example.com
ROBOT=550e8400-e29b-41d4-a716-446655440000

# 1. 토큰 (client_id / client_secret은 발급 시스템에서 받은 값)
TOKEN=$(curl -s -X POST "$API/oauth/token" \
  -d grant_type=client_credentials \
  -d client_id="$CLIENT_ID" -d client_secret="$CLIENT_SECRET" \
  --data-urlencode "scope=robot:read robot:control package:write package:read" \
  | jq -r .access_token)

# 2. 패키지 등록
PKG=$(curl -s -X POST "$API/api/v1/packages" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d @package.json | jq -r .package_id)

# 3. 주행 시작 (멱등 키는 권고 확장)
curl -s -X POST "$API/api/v1/robots/$ROBOT/commands/start" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"destination":{"latitude":37.5700,"longitude":126.9850},
       "route_preference":"safest"}'

# 4. 로봇이 autonomous 모드를 보고할 때까지 폴링 (약 60초 뒤 포기)
for i in $(seq 1 12); do
  MODE=$(curl -s "$API/api/v1/robots/$ROBOT" \
    -H "Authorization: Bearer $TOKEN" | jq -r .mode)
  echo "mode=$MODE"
  [ "$MODE" = "autonomous" ] && break
  [ "$MODE" = "error" ] && { echo "start failed"; exit 1; }
  sleep 5
done

# 5. 패키지 확인
curl -s "$API/api/v1/packages/$PKG" -H "Authorization: Bearer $TOKEN" | jq .status

폴링 루프는 1분에 12회로 일부러 느슨하게 잡았다. 운영 환경의 배차 서비스라면 폴링하지 않고 4단계 텔레메트리 스트림이나 명령 상태 자원으로 변화를 알게 될 것이다.

4.8.3 최소 TypeScript 클라이언트

README에는 npm install @wia/delivery-robot과 createMission을 호출하는 RobotMissionClient 클래스로 된 TypeScript 빠른 시작이 실려 있다. 이 책은 그 코드를 예시로만 본다. 그 코드는 2단계 경로가 아니라 README의 미션 개념을 대상으로 하며, 그런 인터페이스를 가진 패키지가 공개되어 있다고 가정해서는 안 된다. 아래 클라이언트는 표준 fetch API만으로 2단계 표면과 직접 통신한다. 토큰을 캐시하고, 오류 봉투를 타입이 있는 예외로 바꾸고, 이 장의 엔드포인트를 메서드로 노출한다.

type Position = { latitude: number; longitude: number };
type RoutePreference = "fastest" | "shortest" | "safest" | "energy_efficient";

export class Rob010Error extends Error {
  constructor(public status: number, public code: string,
              public requestId?: string, public retryAfter?: string) {
    super(`${status} ${code}`);
  }
}

export class Rob010Client {
  private token?: { value: string; expiresAt: number };

  constructor(private base: string, private clientId: string,
              private clientSecret: string, private scopes: string[]) {}

  private async accessToken(): Promise<string> {
    const now = Date.now();
    if (this.token && now < this.token.expiresAt) return this.token.value;
    const body = new URLSearchParams({
      grant_type: "client_credentials",
      client_id: this.clientId,
      client_secret: this.clientSecret,
      scope: this.scopes.join(" "),
    });
    const r = await fetch(`${this.base}/oauth/token`, { method: "POST", body });
    if (!r.ok) throw new Rob010Error(r.status, "TOKEN_REQUEST_FAILED");
    const t = await r.json();
    // 공지된 수명의 85 % 시점에 갱신
    this.token = { value: t.access_token, expiresAt: now + t.expires_in * 850 };
    return t.access_token;
  }

  private async req<T>(method: string, path: string, body?: unknown,
                        headers: Record<string, string> = {}): Promise<T> {
    const r = await fetch(`${this.base}/api/v1${path}`, {
      method,
      headers: {
        Authorization: `Bearer ${await this.accessToken()}`,
        ...(body ? { "Content-Type": "application/json" } : {}),
        ...headers,
      },
      body: body ? JSON.stringify(body) : undefined,
    });
    if (r.status === 401) this.token = undefined;     // 다음 호출에서 강제 갱신
    if (!r.ok) {
      const e = (await r.json().catch(() => ({}))).error ?? {};
      throw new Rob010Error(r.status, e.code ?? "UNKNOWN", e.request_id, e.retry_after);
    }
    return (r.status === 204 ? undefined : await r.json()) as T;
  }

  getRobot(robotId: string) {
    return this.req<{ mode: string; battery_level: number; timestamp: string }>(
      "GET", `/robots/${robotId}`);
  }

  startNavigation(robotId: string, destination: Position,
                  pref: RoutePreference = "safest", idemKey = crypto.randomUUID()) {
    return this.req<{ command_id: string; status: string }>(
      "POST", `/robots/${robotId}/commands/start`,
      { destination, route_preference: pref }, { "Idempotency-Key": idemKey });
  }

  emergencyStop(robotId: string) {
    return this.req<{ status: string; stopped_at: string }>(
      "POST", `/robots/${robotId}/commands/emergency_stop`);
  }

  createPackage(pkg: Record<string, unknown>) {
    return this.req<{ package_id: string; status: string }>("POST", "/packages", pkg);
  }

  getPackage(packageId: string) {
    return this.req<{ status: string; tracking_events: unknown[] }>(
      "GET", `/packages/${packageId}`);
  }
}

이 클라이언트는 정책 결정을 일부러 호출자에게 남긴다. 스스로 재시도하지 않는다. 호출자는 4.6.3절의 재시도 정책으로 메서드를 감싸고, 어떤 호출을 반복해도 안전한지 직접 정할 수 있다. 또 emergencyStop이 돌려주는 것은 로봇의 확인이라는 점에 유의한다. 호출자는 여기서 나는 모든 예외를 "정지 미확인"으로 취급하고, 오류를 기록하는 데서 그치지 말고 사람에게 알려야 한다.

이 표면이 갖춰지면 남는 질문은 로봇이 start 명령을 받아들인 뒤 무엇을 하느냐이다. 어떻게 계획하는지, 보도에서 어떤 규칙을 지키는지, 문제가 생기면 어떻게 대응하는지가 그것이며, 이는 제5장에서 다루는 3단계 내비게이션 프로토콜의 내용이다.


← 제3장: 1단계 데이터 형식 — RobotState·Package·Route 스키마 | 인덱스로 돌아가기 | 제5장: 3단계 내비게이션 프로토콜 — 계획·교통 규칙·비상 대응 →