제3장과 제4장은 WIA-ENE-040 데이터가 저장된 상태에서 어떤 모습인지를 다루었다. 하나는 Phase 1 메시지 봉투이고, 다른 하나는 탐사·잠수·관찰·시료·열수공·측심으로 이루어진 과학 기록이다. 이 장은 그 데이터가 프로그램 사이를 어떻게 오가는지를 다룬다. 표준은 두 가지 HTTP 인터페이스를 정의한다. 첫째는 Phase 2의 운용 API다. 잠수가 진행되는 동안 잠수정을 조회하고, 명령을 보내고, 텔레메트리를 스트리밍하고, 시료를 등록한다. 둘째는 ENE-040 §13의 기록 API로, 오래 남는 과학 기록을 저장하고 검색한다. 표준은 이와 함께 기록 API를 호출하는 TypeScript SDK(@wia/ene-040)와 Bash CLI(deep-sea-exploration.sh)도 제공한다.
이 장은 네 가지 원천을 나란히 놓고 읽는다. Phase 2 문서, 핵심 명세 §13, SDK 소스, CLI 스크립트다. 넷은 대부분 일치한다. 어긋나는 곳에서는 양쪽을 모두 보여 주고 해법을 권고한다. 원천의 호스트 이름(api.wia-dse.org, api.wia.org/ene-040)은 예시일 뿐 등록된 서비스가 아니므로, 이 장의 예제는 https://api.example.org와 가상의 식별자를 쓴다.
두 API는 경로 접두어로 구분된다. 운용 자원은 /v1/… 아래에 있다(예: /v1/vehicles). 기록 자원은 /api/v1/… 아래에 있다(예: /api/v1/expeditions). 이 장은 한 호스트가 둘을 함께 제공한다고 가정한다. 두 API 모두 주 버전을 경로에 넣으므로(Phase 2 §1.2), 이관 기간에는 앞으로 나올 /v2가 /v1과 나란히 운영될 수 있다.
아래 표는 원천에 등장하는 모든 엔드포인트를 모은 것이다. "계층" 열은 제2장에서 구분한 두 기록 계층 중 어디에 속하는지를 나타낸다. 마지막 묶음은 SDK나 CLI는 호출하지만 §13에는 없는 경로다. 이 경로들은 배포된 도구의 확장이며 명세 본문에는 들어 있지 않다.
| 메서드 | 경로 | 계층 | 용도 |
|---|---|---|---|
| GET | /v1/vehicles | 운용 | 접근 가능한 잠수정 목록(필터 status, type, page, limit) |
| GET | /v1/vehicles/{vehicleId} | 운용 | 텔레메트리 스냅숏과 경보를 포함한 잠수정 상태 |
| POST | /v1/vehicles/{vehicleId}/commands | 운용 | 잠수정 명령 발행 |
| GET | /v1/vehicles/{vehicleId}/commands/{commandId} | 운용 | 명령 실행 상태 |
| WSS | /v1/telemetry/stream | 운용 | 실시간 텔레메트리 스트림 |
| GET | /v1/telemetry/history | 운용 | 시간 구간별 과거 텔레메트리 |
| POST / GET | /v1/missions, /v1/missions/{missionId} | 운용 | 임무 생성, 일정·진행률 조회 |
| POST | /v1/vehicles/{vehicleId}/sensors/{sensorId}/configure | 운용 | 샘플링 주기·보정값·경보 설정 |
| GET | /v1/vehicles/{vehicleId}/sensors/{sensorId}/data | 운용 | 센서 측정값(limit ≤ 10,000) |
| POST | /v1/samples | 운용 | 채취 시점의 시료 등록 |
| GET / POST / PUT / DELETE | /api/v1/expeditions[/{expeditionId}] | 기록 | 탐사 CRUD |
| GET / POST / PUT | /api/v1/expeditions/{id}/dives, /api/v1/dives[/{diveId}] | 기록 | 잠수 기록 |
| GET | /api/v1/dives/{diveId}/observations|samples|track | 기록 | 잠수별 관찰·시료·항적 |
| GET / POST / PUT | /api/v1/observations[/{observationId}] | 기록 | 필터를 지원하는 생물 관찰 기록 |
| GET / POST / PUT | /api/v1/samples[/{sampleId}], /api/v1/samples/available | 기록 | 시료 목록과 가용성 |
| POST | /api/v1/samples/{sampleId}/request | 기록 | 시료 이용 요청 |
| GET / POST / PUT | /api/v1/vents[/{ventId}] | 기록 | 열수 분출공 데이터베이스 |
| POST / GET | /api/v1/bathymetry/upload, /{surveyId}, /grid, /{surveyId}/3d-model | 기록 | 멀티빔 조사 업로드, 격자, 3차원 모델 |
| WS | /dives/{diveId}/live | 기록 | 실시간 잠수 추적(§13.2) |
| POST / GET | /api/v1/search, /api/v1/statistics | SDK/CLI 전용 | 유형 통합 검색, 데이터베이스 통계 |
| GET / POST | /api/v1/submersibles, /api/v1/geological/rocks, /api/v1/geological/cores, /api/v1/edna | SDK/CLI 전용 | 잠수정 등록부, 암석 시료, 퇴적물 코어, eDNA 시료 기록 |
Phase 2 §2.2는 모든 요청에 두 가지 자격 증명 중 하나를 요구한다. 하나는 Authorization: Bearer …에 담는 OAuth 2.0 베어러 토큰이고, 다른 하나는 X-API-Key: …에 담는 정적 키다. 베어러 토큰은 만료되고 제7장의 RBAC 역할에 대응하는 범위(scope)를 담을 수 있으므로, 사람이 직접 쓰는 경우와 기관 통합 로그인에 알맞다. API 키는 선상 로거나 야간 내보내기처럼 사람 없이 도는 작업에 알맞다.
그런데 배포된 도구는 이 가운데 하나만 쓴다. SDK와 CLI는 모두 설정된 API 키를 Authorization: Bearer <key>에 넣으며, X-API-Key는 보내지 않는다. 따라서 공식 도구와 함께 동작하려는 서버는 베어러 토큰 자리에 온 API 키도 받아들여야 한다. 가장 간단한 방법은 키 접두어로 구분하는 것이다(명세 예시의 키는 wia_dse_ak_로 시작한다). 그 값을 JWT 검증이 아니라 키 검증으로 보내면 된다. 키를 X-API-Key로만 받는 서버는 SDK 호출을 전부 401로 거부하게 된다.
| 헤더 | 방향 | 근거 | 규칙과 참고 |
|---|---|---|---|
X-WIA-Version | 양방향 | Phase 2 §2.3, SDK, CLI | 명세는 1.0, SDK와 CLI는 1.0.0을 보낸다. 서버는 주 버전만 비교해 둘 다 버전 1로 취급해야 한다. |
X-Request-ID | 양방향 | Phase 2 §2.3 | 클라이언트가 만든 UUID v4를 서버가 그대로 돌려준다. 오류 본문의 requestId에도 나타난다. SDK는 이 헤더를 보내지 않으므로, 없으면 서버가 생성해야 한다. |
X-WIA-Standard | 요청 | SDK, CLI | 항상 ENE-040이다. Phase 2의 요구 사항은 아니며, 여러 WIA 표준을 한 게이트웨이로 받을 때 유용하다. |
X-RateLimit-Limit / -Remaining / -Reset | 응답 | Phase 2 §2.3, §8 | 현재 구간의 한도, 남은 호출 수, 초기화 시각(유닉스 초)이다. |
Content-Type / Accept | 양방향 | Phase 2 §2.3 | application/json |
모든 실패는 error라는 키 하나만 가진 JSON 객체로 반환된다. 그 안에는 기계가 읽는 code, 사람이 읽는 message, 자유 형식의 details, timestamp, requestId, documentation 링크가 들어 있다. Phase 2는 일곱 개의 표준 코드를 나열한다. 아래 표에는 재시도 지침을 덧붙였다. 링크가 선박 위성 회선일 수도 있는 환경에서 클라이언트가 가장 먼저 판단해야 하는 것은 다시 시도해서 나아질 수 있는지이기 때문이다.
| 코드 | HTTP | 의미 | 클라이언트 조치 |
|---|---|---|---|
VALIDATION_ERROR | 400 | 본문 형식 오류 또는 범위를 벗어난 매개변수 | 요청을 고친다. 그대로 재시도하지 않는다. |
AUTHENTICATION_FAILED | 401 | 자격 증명 누락·만료·미등록 | 토큰을 한 번 갱신하고, 그래도 실패하면 멈춘다. |
AUTHORIZATION_DENIED | 403 | 자격 증명은 유효하나 권한 부족 또는 엠바고 기록 | 재시도하지 않고 접근 권한을 요청한다. |
RESOURCE_NOT_FOUND | 404 | 존재하지 않는 ID | 재시도하지 않는다. |
RATE_LIMIT_EXCEEDED | 429 | 한도 소진 | X-RateLimit-Reset까지 기다렸다가 재시도한다. |
INTERNAL_ERROR | 500 | 서버 결함 | 멱등 요청만 백오프를 두고 재시도한다. |
SERVICE_UNAVAILABLE | 503 | 과부하 또는 점검 | 지수 백오프와 지터를 두고 재시도한다. |
그런데 명세 자신의 예시는 목록에 없는 여덟 번째 코드 INVALID_DEPTH_PARAMETER를 쓰며, 그 HTTP 상태도 정하지 않았다. 이 책은 이런 도메인 전용 코드를 VALIDATION_ERROR의 세분류로 보고 상태 400으로 반환하기를 권한다. 그러면 일곱 개의 일반 코드만 아는 클라이언트도 상태 코드로 오류를 올바르게 분류할 수 있고, 세부 코드를 아는 클라이언트는 더 정확한 메시지를 보여 줄 수 있다. 전체 본문은 5.2절에 있다.
목록 엔드포인트는 1부터 시작하는 page와 limit를 받는다. /v1/vehicles의 경우 Phase 2는 기본값 50, 최댓값 100을 정한다. 문제는 두 계층의 응답 형태가 다르다는 점이다. Phase 2는 {"data": [...], "pagination": {"page", "limit", "total", "totalPages"}}를 반환한다. SDK는 기록 API의 모든 목록을 PaginatedResponse<T>, 즉 {"items", "total", "page", "limit", "hasMore"}로 정의한다. §13에는 응답 본문이 없으므로 기록 API의 구체적인 계약은 SDK 형태뿐이다. 기록 서버는 이 형태를, 운용 서버는 Phase 2 형태를 따르고, 둘을 모두 쓰는 클라이언트는 경계에서 형태를 맞춘다(hasMore = page < totalPages).
Phase 2는 "멱등성: 불안정한 네트워크를 위한 안전한 재시도"를 설계 원칙으로 내세우지만, 그 장치는 정의하지 않는다. HTTP에서 GET, PUT, DELETE는 정의상 멱등이다(RFC 9110). POST는 그렇지 않은데, 운용 API에서 가장 파급이 큰 호출인 명령 발행, 임무 생성, 시료 등록이 모두 POST다.
육상 클라이언트가 보낸 COLLECT_SAMPLE이 잠수정에 도착했는데, 선박 회선이 끊겨 응답이 사라졌다고 하자. 클라이언트는 "도착하지 않았다"와 "응답만 잃었다"를 구별할 수 없어 다시 보내고, 잠수정은 채취를 두 번 할 수 있다. 정립된 해법은 클라이언트가 논리적 작업마다 고유한 키를 만들어 매 시도에 함께 보내는 것이다. 서버는 첫 응답을 그 키와 함께 일정 기간 보관하고, 같은 키가 다시 오면 저장된 응답을 그대로 돌려준다. IETF HTTPAPI 작업반은 바로 이 목적의 Idempotency-Key 요청 헤더를 초안으로 작성해 두었다. 이 책은 ENE-040 서버가 모든 POST에서 이 헤더를 받아들이기를 권한다. 명령의 경우, 서버는 첫 사본이 아직 종료 상태에 이르지 않았다면 두 번째 사본을 잠수정에 전달하지 않아야 한다.
GET /v1/vehicles는 호출자가 볼 수 있는 잠수정을 반환한다. 각 항목에는 식별 블록(vehicleId, name, type, 제조사와 모델), status, 마지막으로 알려진 location, capabilities 블록(maxDepth, 매니퓰레이터, 카메라, 센서 목록, 단위가 명시된 탑재 용량), currentMission, health가 들어 있다. status 필터는 OPERATIONAL, MAINTENANCE, DEPLOYED를 받는다.
두 계층이 갈라지는 곳은 type 필터다. Phase 2는 대문자 ROV, AUV, SUBMERSIBLE, GLIDER를 쓴다. 기록 API가 쓰는 SDK의 SubmersibleType 열거형은 소문자 hov, rov, auv, lander, crawler다. 양쪽에 모두 있는 것은 ROV와 AUV뿐이다. "SUBMERSIBLE"은 대체로 hov에 해당하고, 글라이더·랜더·크롤러는 각각 한쪽 목록에만 있다. 두 등록부 사이에서 잠수정을 옮기는 통합 작업에는 명시적인 대응표가 필요하며, SubmersibleType에 glider를 추가하자는 제안은 제8장 정오표에 있다.
GET /v1/vehicles/{vehicleId}는 여기에 telemetry 스냅숏(선수각·종동요·횡동요를 포함한 위치, 환경, 전력, 하위 시스템 상태)과 alerts 배열을 더한다. Phase 1 측정값과 달리 이 값들에는 단위가 없다. 명세 예시에서 수심 3547.2에 붙은 압력 354.72는 수심을 정확히 10으로 나눈 값이므로, 단위는 아마 어림 규칙으로 계산한 bar일 것이다. 제1장의 정수압 계산으로는 이 수심에서 게이지압 약 356.6 bar(절대압 357.6 bar)가 나온다. 이 스냅숏은 표시용 값으로 다루어야 한다. 기록으로 남는 측정값은 unit, sensorId, calibrationDate가 붙은 Phase 1 OCEANOGRAPHIC_DATA로 들어온다. 서버는 기존 maxDepthUnit처럼 pressureUnit 필드를 두어 단위를 밝혀야 한다.
POST /v1/vehicles/{vehicleId}/commands는 명령 이름, parameters 객체, priority(Phase 1 봉투와 같은 LOW/NORMAL/HIGH/CRITICAL 척도), acknowledgementRequired를 받는다. 항법 요청은 다음과 같다.
POST /v1/vehicles/ROV-EXAMPLE-01/commands
Authorization: Bearer <token>
X-WIA-Version: 1.0
X-Request-ID: 6f1c2a4e-8b1d-4c5e-9a47-2d0f3b8e7c11
Idempotency-Key: 0b8f7d2e-3c9a-4f61-a2d4-5e6b7c8d9f01
Content-Type: application/json
{
"command": "NAVIGATE_TO_WAYPOINT",
"parameters": {
"waypoint": { "latitude": 12.3456, "longitude": 143.2101, "depth": 3550.0, "altitude": 10.0 },
"speed": 0.5,
"speedUnit": "m/s",
"obstacleAvoidance": true,
"timeout": 300,
"timeoutUnit": "seconds"
},
"priority": "NORMAL",
"acknowledgementRequired": true
}
명세는 명령 열한 개를 정의한다. 이 명령들은 실수로 두 번 실행되었을 때의 피해가 서로 다르다. 아래 표의 마지막 열은 재시도와 확인 절차를 정하는 데 참고하도록 이 책이 분류한 것이며, 명세의 일부가 아니다.
| 명령 | 분류 | 중복 실행 시 영향 |
|---|---|---|
NAVIGATE_TO_WAYPOINT, HOVER_AT_POSITION | 이동 | 대개 무해하다. 같은 목표가 다시 설정될 뿐이다. |
SURFACE, EMERGENCY_ASCENT | 회수 | 무해하다. 단, 긴급 상승은 대기열 뒤에서 기다려서는 안 된다. |
ACTIVATE_SENSOR, DEACTIVATE_SENSOR | 탑재 장비 상태 | 잠수정이 "상태 전환"이 아니라 "상태 지정"으로 처리하면 무해하다. |
CAPTURE_IMAGE, RECORD_VIDEO | 영상 | 매체와 저장 공간이 추가로 쓰인다. |
COLLECT_SAMPLE | 시료 채취 | 심각하다. 실물 시료가 하나 더 생기거나 관리 기록이 중복된다. |
DEPLOY_INSTRUMENT, RETRIEVE_INSTRUMENT | 계측기 | 심각하다. 두 번째 설치가 일어나거나 빈 자리를 집으려 한다. |
서버는 명령을 전달하기 전에 잠수정의 capabilities와 대조해 검증한다. 명세의 오류 예시는 수심 검사에서 무엇이 반환되는지를 보여 준다.
HTTP/1.1 400 Bad Request
Content-Type: application/json
{
"error": {
"code": "INVALID_DEPTH_PARAMETER",
"message": "Depth value exceeds vehicle maximum operating depth",
"details": { "requestedDepth": 7000, "maxDepth": 6000, "unit": "meters" },
"timestamp": "2026-03-04T02:10:00.000Z",
"requestId": "6f1c2a4e-8b1d-4c5e-9a47-2d0f3b8e7c11",
"documentation": "https://api.example.org/docs/errors/INVALID_DEPTH_PARAMETER"
}
}
명세가 보여 주는 상태는 두 가지다. POST에 대한 즉시 응답은 ACKNOWLEDGED로, commandId, estimatedCompletionTime, 그리고 acknowledgement 블록을 담는다. 이 블록에는 잠수정이 명령을 받은 시각, 검증 여부, 실행 시작 시각이 기록된다. 이후 GET …/commands/{commandId}는 IN_PROGRESS와 함께 progress 백분율, currentStep 설명, errors 배열을 반환한다. 명세에는 종료 상태가 하나도 없다. 그래서 클라이언트는 언제 폴링을 멈출지 알 수 없고, 웹훅 수신자는 command.completed만으로 성공과 실패를 구별할 수 없다. 이 책은 다음의 닫힌 상태 집합을 제안하며, 이것이 현재 명세 본문이 아니라 추가 제안임을 밝혀 둔다.
ACKNOWLEDGED: 잠수정이 명령을 받아 검증했다.IN_PROGRESS: 실행이 시작되었으며 progress가 의미를 가진다.COMPLETED: 목표를 달성했다.FAILED: 실행이 중단되었고 errors에 사유가 있다.CANCELLED: 운용자나 더 높은 우선순위의 명령으로 대체되었다.TIMED_OUT: 명령 자체의 timeout이 완료 전에 만료되었다.acknowledgementRequired: true에는 시간 문제도 있다. 서버가 잠수정의 확인이 올 때까지 HTTP 응답을 붙잡고 있으면, 요청 속도는 잠수정까지 가는 경로의 속도로 제한된다. 광케이블 테더로 연결된 ROV라면 빠르다. 그러나 음향 모뎀으로 닿는 AUV라면, 제6장에서 보듯 수 km 거리의 왕복 한 번에 수 초가 걸리고, 프레임을 하나 잃으면 재전송 시간이 더해진다. 이런 잠수정에 대해서는 서버가 즉시 202 Accepted와 대기 상태(예: QUEUED, 역시 제안)를 돌려주는 편이 낫다. 그런 다음 모뎀이 잠수정의 응답을 전달하면 ACKNOWLEDGED로 넘어간다. 나머지는 클라이언트가 폴링이나 웹훅으로 알게 된다.
클라이언트 운용 API 선상 게이트웨이 잠수정
| POST /commands | | |
|----------------------->| capabilities 대조 검증 | |
| |----------------------->| 테더 또는 음향 |
| | |--------------------->|
| | |<------- ACK ---------|
|<-- ACKNOWLEDGED, commandId (또는 202 + QUEUED) --| |
| | | |
| GET /commands/{id} | | |
|----------------------->| | |
|<-- IN_PROGRESS, progress 45 --------------------- |
| |<------------- 진행 상황 / 결과 ---------------|
| | | |
|<== 웹훅: command.completed (status COMPLETED | FAILED ...) == |
실시간 텔레메트리는 wss://…/v1/telemetry/stream 웹소켓으로 제공되며, 질의 매개변수 vehicleId와 dataRate로 연다(명세 예시는 10Hz를 요청한다). 각 프레임은 streamId, vehicleId, timestamp, sequenceNumber, 그리고 data 블록(위치, 센서, 전력)을 가진 JSON 객체다. 이 프레임은 Phase 1 봉투가 아니다. messageType, sourceId, priority, checksum이 없고, 값에는 잠수정 스냅숏처럼 단위가 없다. 이것은 화면 표시용 피드다. 기록으로 남는 데이터는 Phase 1 메시지로 보관소에 들어간다(제3장).
요청한 주기는 상한일 뿐 약속이 아니다. 테더로 연결된 ROV는 광케이블로 10 Hz를 공급할 수 있지만, 음향으로 닿는 AUV는 Phase 3 MAC 일정이 슬롯을 줄 때만 보고하며 제6장의 슈퍼프레임은 수 초 길이다. 서버는 받은 것을 잠수정 쪽 timestamp와 함께 전달할 뿐, 주기를 채우려고 위치를 보간해서는 안 된다.
Phase 2는 스트림 인증 방법을 정하지 않았고, 브라우저는 웹소켓 핸드셰이크에 Authorization 헤더를 붙일 수 없다. 오래 쓰는 API 키를 질의 문자열에 넣어서는 안 된다. 프록시 로그에 남기 때문이다. 인증된 REST 호출로 받은 일회용 단기 티켓, Sec-WebSocket-Protocol에 실은 토큰, 세션 쿠키 가운데 하나를 쓰고, 브라우저가 아닌 클라이언트는 일반 Authorization 헤더를 보내면 된다.
sequenceNumber로 클라이언트는 세 가지 경우를 구별한다. 새 값이 직전 값보다 정확히 1 크면 순서대로 온 프레임이다. 직전 값보다 작거나 같으면 중복이거나 재연결 뒤의 재전송이므로 버린다. 1보다 크게 건너뛰면 프레임을 잃은 것이다. 누락이 생기면 클라이언트는 이를 무시하지 말고 과거 조회 엔드포인트로 빠진 구간을 요청해야 한다. Phase 1은 순번이 출처별로 단조 증가해야 한다고 규정하지만, 잠수정이 재부팅하면 카운터가 초기화될 수 있다. 따라서 번호 하나보다는 (streamId, sequenceNumber) 쌍을 키로 쓰는 것이 안전하다. streamId가 바뀌면 클라이언트는 상태를 초기화한다.
// 누락 구간 보충과 상한을 둔 지수 재연결을 갖춘 스트림 소비자.
// getStreamTicket()은 배포 환경마다 다르다("웹소켓 인증" 참고).
type Frame = { streamId: string; vehicleId: string; timestamp: string; sequenceNumber: number; data: unknown };
const BASE = 'https://api.example.org';
let streamId: string | null = null;
let lastSeq = -1;
let lastTs: string | null = null;
let attempt = 0;
async function backfill(vehicleId: string, from: string, to: string): Promise<void> {
const q = new URLSearchParams({ vehicleId, startTime: from, endTime: to });
const res = await fetch(`${BASE}/v1/telemetry/history?${q}`, {
headers: { Authorization: `Bearer ${process.env.WIA_TOKEN}`, 'X-WIA-Version': '1.0' },
});
if (res.ok) mergeHistory((await res.json()).data); // mergeHistory: 응용 코드
}
async function connect(vehicleId: string): Promise<void> {
const ticket = await getStreamTicket(vehicleId);
const url = `wss://api.example.org/v1/telemetry/stream?vehicleId=${encodeURIComponent(vehicleId)}` +
`&dataRate=10Hz&ticket=${encodeURIComponent(ticket)}`;
const ws = new WebSocket(url);
ws.onopen = () => { attempt = 0; };
ws.onmessage = (ev) => {
const f: Frame = JSON.parse(String(ev.data));
if (f.streamId !== streamId) { streamId = f.streamId; lastSeq = -1; } // 새 스트림: 초기화
if (f.sequenceNumber <= lastSeq) return; // 중복 또는 재전송
if (lastSeq >= 0 && f.sequenceNumber > lastSeq + 1 && lastTs) {
void backfill(vehicleId, lastTs, f.timestamp); // 빈 구간 보충
}
lastSeq = f.sequenceNumber;
lastTs = f.timestamp;
render(f); // 응용 코드
};
ws.onclose = () => {
const delay = Math.min(30_000, 500 * 2 ** attempt++) * (0.5 + Math.random() / 2);
setTimeout(() => void connect(vehicleId), delay);
};
}
GET /v1/telemetry/history는 필수인 vehicleId, startTime, endTime와 선택인 parameters(depth,temperature 같은 목록), interval(정수 초)을 받는다. 응답에는 데이터 행과 CSV 내보내기용 downloadUrl이 들어 있다. interval은 초 단위 정수이므로, 10 Hz 피드의 누락 구간을 1초보다 촘촘하게 보충하려면 이 값을 생략하고 서버가 보관하는 원래 해상도를 받는 수밖에 없다. 명세는 그 해상도를 밝히지 않으므로 서버가 문서로 알려야 한다.
ENE-040 §13.2는 두 번째 실시간 피드인 /dives/{diveId}/live를 정의한다. 이 피드는 잠수정이 아니라 잠수를 따라가며, 대중 홍보용 화면이나 육상의 연구자를 위한 것이다. 두 스트림은 형태가 달라서, 둘을 모두 받는 클라이언트에는 변환 계층이 필요하다.
| 항목 | Phase 2 /v1/telemetry/stream | §13.2 /dives/{diveId}/live |
|---|---|---|
| 기준 키 | vehicleId | diveId |
| 명세 예시의 스킴 | wss://(암호화) | ws://. 배포 시에는 wss://로 |
| 위치 키 | position.latitude / longitude, 수심은 position 안 | position.lat / lon, depth는 최상위 |
| 순서 | sequenceNumber | 없음. timestamp로만 정렬 |
| 추가 필드 | 전력 블록 | speed, camera, on_bottom 같은 잠수 status |
| 단위 | 암묵적 | 암묵적 |
여기서 두 가지 권고가 나온다. 첫째, 두 피드 모두 TLS로만 제공해야 한다. 잠수 스트림은 민감 서식지의 위치를 실시간으로 드러낼 수 있고, 제4장은 그런 좌표를 비공개 등급에 둔다. 둘째, 잠수 스트림에도 순번을 넣어서, 그 수신자도 위와 같은 논리로 누락을 찾을 수 있게 해야 한다.
POST /v1/missions는 다음 항목으로 임무를 만든다. 이름, 설명, vehicleId, 계획된 시작·종료 시각, missionType(예시는 SCIENTIFIC_RESEARCH), objectives 목록, 과업 목록(SYSTEM_CHECK, BATHYMETRIC_SURVEY, SAMPLE_COLLECTION)을 지닌 순서 있는 waypoints, 그리고 수석 연구자와 허가 번호를 담은 permissions 블록이다. 응답은 PLANNED 상태의 missionId를 돌려준다. 이후 GET /v1/missions/{missionId}는 계획 일정과 실제 일정, 경유점 진행률, 데이터 집계를 보고한다.
세 가지를 눈여겨보아야 한다. 첫째, 임무 본문에는 운용 계층과 과학 기록을 잇는 ID(제2장)인 expeditionId와 diveId가 없다. 확장 필드로 추가해야 하며, 그렇지 않으면 텔레메트리와 잠수 기록을 잠수정과 시간 구간으로만 결합할 수 있다. 둘째, permissions.permits는 탐사 기록의 permits[](제4장) 식별자를 그대로 써야 한다. 셋째, 예시에는 imagesCaptures라는 철자 오류 키가 있다. 서버는 imagesCaptured를 내보내고 이행 기간에는 둘 다 받아들여야 한다(제8장 정오표).
POST …/sensors/{sensorId}/configure는 단위를 붙인 samplingRate, averagingWindow, 보정 오프셋과 계수, alarms를 설정한다. 예시의 경보 임계값(temperatureMax: 50, pressureMax: 650)에는 단위가 없다. 서버는 단위를 필수로 요구하거나, 최소한 경보가 Phase 1 단위(섭씨, bar)를 쓴다고 문서에 밝혀야 한다. 여기서 보정값을 바꾸면 이후 측정값이 보고하는 calibrationDate도 갱신되어야 한다. 그렇지 않으면 제3장의 출처 추적 사슬이 끊긴다.
GET …/sensors/{sensorId}/data는 startTime, endTime, 최대 10,000인 limit를 받는다. 10 Hz라면 10,000개는 17분이 채 안 되는 분량이므로, 한 잠수를 내보내려면 시간 구간을 나누어 여러 번 요청해야 한다. 가장 간단하면서 올바른 방식은 한 구간을 요청하고, 마지막 행의 시각을 다음 startTime으로 삼은 뒤, 겹치는 한 행을 제거하는 것이다.
POST /v1/samples는 실물이 기록에 처음 들어오는 순간이다. 본문에는 sampleType, missionId와 vehicleId, collectionTimestamp, 위치, 설명, 잠수정 시료함의 containerId, 채집자(이름과 ORCID), 그리고 첫 번째 chainOfCustody 항목(예: 잠수정 조종사가 수행한 COLLECTED)이 들어간다. 응답은 sampleId를 부여하고 qrCode 이미지 URL과 trackingUrl을 돌려준다. 그래서 시료가 갑판을 떠나기 전에 라벨을 인쇄할 수 있다.
기록 API에도 별도의 /api/v1/samples가 있다. 두 엔드포인트가 한 실물에 두 개의 신원을 만들어서는 안 된다. 권장하는 흐름은 잠수 중에 운용 API로 한 번만 등록하는 것이다. 이후 선상 시스템이 같은 sampleId로 기록 계층 항목(제4장의 관리 이력 기록)을 만들고, 보관 위치·큐레이터·가용성을 덧붙인다. 이후의 모든 인계는 사슬을 덮어쓰지 않고 뒤에 이어 붙인다.
Phase 2 §9는 여섯 가지 이벤트 유형과 하나의 페이로드 형태를 정의한다. 페이로드는 eventId, eventType, timestamp, 그리고 관련 ID를 담은 data 객체로 이루어진다.
| 이벤트 | 발생 시점 | 주요 구독자 |
|---|---|---|
vehicle.status.changed | 잠수정이 OPERATIONAL, DEPLOYED, MAINTENANCE 사이를 오갈 때 | 선단 대시보드, 정비 관리 |
mission.started / mission.completed | 임무의 실제 시작·종료가 기록될 때 | 데이터 관리 파이프라인(보관 시작, 보관 마감) |
sample.collected | 시료가 등록될 때 | 시료 큐레이터, 라벨 프린터, 기록 계층 동기화 |
alert.critical | 잠수정이 CRITICAL 경보를 올릴 때 | 당직 알림 |
command.completed | 명령이 종료 상태에 이를 때 | 명령을 보낸 클라이언트(폴링 대신) |
명세는 구독 방법, 전달 인증 방법, 수신자가 멈췄을 때의 처리를 정하지 않았다. 이 책이 권하는 운용 방식은 다음과 같다. 전달은 최소 1회(at-least-once)로 보고 eventId로 중복을 걸러 낸다. 각 전달에는 구독별 비밀키로 타임스탬프와 원본 본문에 대한 HMAC-SHA256 서명을 붙이고, 타임스탬프가 너무 오래된 전달은 거부한다. 수신자는 2xx를 빨리 응답하고 실제 작업은 대기열에 넣는다. 발신자는 2xx가 아닌 응답에 대해 정해진 기간 동안 백오프를 두고 재시도한다. 웹훅 페이로드에는 비공개 좌표를 절대 넣지 않는다. ID만 보내고 수신자가 자기 자격 증명으로 세부 정보를 가져가게 하면, 제7장의 접근 규칙이 그대로 적용된다.
Phase 2 §8은 자격 증명마다 등급별 한도를 둔다.
| 등급 | 시간당 요청 수 | 순간 한도 |
|---|---|---|
| Free | 1,000 | 분당 100 |
| Research | 10,000 | 분당 500 |
| Institution | 100,000 | 분당 2,000 |
| Enterprise | 무제한 | 개별 협의 |
간단한 계산으로 웹훅이 왜 중요한지 알 수 있다. 대시보드가 명령 하나의 상태를 2초마다 폴링하면 시간당 1,800회를 호출한다. 명령 하나만으로도, 다른 호출이 전혀 없어도 Free 등급을 넘는다. command.completed 구독 하나가 이 호출을 모두 대신한다. 429를 받은 클라이언트는 X-RateLimit-Reset 시각(유닉스 초)까지 쉬되 무작위 지터를 더해야 한다. 그래야 여러 클라이언트가 같은 초에 한꺼번에 돌아오지 않는다.
기록 API는 제4장의 모델을 그대로 따른다. 탐사는 잠수를 소유하고(GET /api/v1/expeditions/{id}/dives), 잠수는 관찰·시료·GeoJSON 항적을 하위 자원으로 노출한다. 관찰, 시료, 열수공은 최상위 컬렉션이기도 하다. "300 °C가 넘는 모든 블랙 스모커"처럼 대부분의 질문이 여러 탐사를 가로지르기 때문이다. §13에서 DELETE가 있는 것은 탐사뿐이며, 나머지 기록은 지우지 않고 PUT으로 바로잡되 버전 이력을 남겨야 한다.
| 컬렉션 | 필터 | 예 | 참고 |
|---|---|---|---|
| 관찰 | depthZone, taxon | ?depthZone=hadal&taxon=Amphipoda | 아래의 구역 체계 경고 참고 |
| 관찰 | minDepth, maxDepth | ?minDepth=6000&maxDepth=11000 | 미터, 아래쪽이 양수 |
| 관찰 | diveId, habitat | ?habitat=trench | SDK 필터, HabitatType 값 |
| 시료 | expeditionId, sampleType, availability | /api/v1/samples/available | 가용성은 별도 경로도 있다 |
| 열수공 | region, ventType, minTemp, maxTemp | ?minTemp=300&ventType=black_smoker | °C. 최고 온도인지 최근 측정 유체 온도인지 정의할 것 |
| 측심 | bbox, resolution | /grid?bbox=lon1,lat1,lon2,lat2&resolution=10m | 경도가 먼저. 해상도는 접근 등급에 따라 제한된다 |
depthZone 필터는 주의가 필요하다. 제1장에서 설명했듯 표준에는 두 가지 구역 체계가 있다. §13 예시는 저서 구역인 hadal로 거르고, §12의 관찰 기록도 "depthZone": "hadal"을 저장한다. 반면 SDK의 DepthZone 열거형과 CLI 도움말에는 수층 구역 값(hadopelagic, abyssopelagic 등)만 있다. 서버는 어느 체계로 색인하는지 밝혀야 하고, 가능하면 두 체계를 모두 수심 구간으로 대응시켜 받아들여야 한다. 확실한 결과를 원하는 클라이언트는 수심 구간을 함께 보내면 된다. depthZone=hadopelagic&minDepth=6000은 서버가 어느 어휘로 저장했든 초심해 수심의 기록을 돌려준다.
bbox의 순서는 최소 경도, 최소 위도, 최대 경도, 최대 위도로, GeoJSON과 같다. 날짜변경선을 넘는 상자(서태평양 해구에서 실제로 생긴다)는 최소 경도가 최대 경도보다 크게 표기하는 것이 흔한 관례다. 이를 지원하지 않는 서버는 빈 결과가 아니라 VALIDATION_ERROR를 돌려주어야 한다. resolution 매개변수는 제4장의 접근 등급과 맞물린다. 공개 등급 호출자가 10m를 요청하면, 공개 격자 해상도의 결과를 안내와 함께 주거나 403을 주어야 한다. 제한 등급 격자를 주어서는 안 된다.
POST /api/v1/samples/{sampleId}/request는 §13에서 유일한 업무 흐름 엔드포인트다. SDK는 requester, institution, purpose를 보낸다. 응답 형태는 정해져 있지 않다. 쓸모 있는 서버라면 요청 식별자와 상태(접수, 검토 중, 승인, 거절)를 돌려주고 큐레이터에게 알린다. 제2장의 법적 틀이 운용으로 바뀌는 곳이 여기다. 접근 및 이익 공유 조건이 있으면 승인 시 시료와 함께 따라가는 조건을 기록한다. 운송과 실험실 취급은 표준의 범위 밖이다.
SDK와 CLI는 §13에 없는 엔드포인트 두 개를 호출한다. 하나는 POST /api/v1/search로, 본문은 {"query": "...", "type", "dateRange", "depthRange", "geographic"}이며 type은 expedition, dive, observation, sample, vent 중 하나다. 다른 하나는 GET /api/v1/statistics다. 공식 도구가 이 둘에 의존하지만 응답 본문은 어디에도 정의되어 있지 않으며, 제8장은 이들을 개정 항목으로 올려 둔다. 측심 엔드포인트는 MultibeamSurvey를 업로드받아 surveyId로 돌려주고, 경계 상자의 격자 수심과 3차원 모델을 제공한다. 모델 형식은 정해져 있지 않으므로 서버가 콘텐츠 유형으로 밝혀야 한다.
@wia/ene-040SDK는 MIT 라이선스의 @wia/ene-040 1.0.0으로 배포된다. 내장 fetch와 AbortSignal.timeout을 쓰므로 Node.js 18 이상이 필요하며, 런타임 의존성은 없다. 프로젝트 README는 다른 패키지를 설명한다. @wia/deep-sea-exploration 패키지의 WIADeepSeaClient 클래스와 client.vehicles.navigateTo 같은 메서드다. 이 가운데 어느 것도 배포된 코드에 없으므로, 이 책은 코드를 기준으로 설명한다. SDK는 기록 API만 다루며, /v1/… 운용 엔드포인트를 부르는 메서드는 하나도 없다.
생성자는 { apiKey, endpoint, timeout?, retries?, debug? }를 받는다. 기본값은 30,000 ms, 3, false다. 모든 메서드가 이미 /api/v1로 시작하는 경로를 덧붙이므로, endpoint는 https://api.example.org처럼 호스트 루트여야 한다. 클라이언트는 고정된 헤더 네 개를 보낸다. Content-Type: application/json, Authorization: Bearer <apiKey>, X-WIA-Standard: ENE-040, X-WIA-Version: 1.0.0이다.
어떤 메서드도 API 실패나 네트워크 실패에서 예외를 던지지 않는다. 모두 ApiResponse<T>, 즉 { success, data?, error?: { code, message, details? }, metadata?: { timestamp, version } }로 완료된다. HTTP 오류는 code: "HTTP_<status>"(예: HTTP_404)가 되고, 요청 도중 예외가 나는 모든 경우(DNS 실패, 시간 초과, 중단)는 NETWORK_ERROR가 된다. SDK 위에 짓는 코드는 success로 분기해야 하며, 확인하지 않은 채 data를 읽어서는 안 된다.
1.0.0 버전을 한 줄씩 읽어 보면 설정이 암시하는 것과 다른 동작이 일곱 가지 있으며, 각각 간단한 우회 방법이 있다.
retries는 받기만 하고 쓰지 않는다. 호출마다 정확히 한 번만 시도한다. 아래 예제처럼 직접 감싸서 재시도하되, 서버가 멱등 키를 지원하지 않는다면 멱등 메서드만 재시도한다.data.message를 읽지만 Phase 2 봉투에서는 그 값이 data.error.message에 들어 있다. 그래서 error.message는 대개 HTTP 상태 문구로 대체된다. 전체 본문은 error.details에 남아 있고, details.error.code에 RATE_LIMIT_EXCEEDED 같은 세부 코드가 있다.NETWORK_ERROR가 된다. 응답은 상태를 확인하기 전에 response.json()으로 파싱된다. 성공한 DELETE의 204 No Content나 프록시가 돌려준 HTML 오류 페이지는 파싱 예외를 일으키고, 호출은 네트워크 오류를 보고한다. 따라서 deleteExpedition은 성공한 뒤에도 실패를 보고할 수 있으니, 이어서 GET으로 확인해야 한다.encodeURIComponent를 거쳐야 한다.PaginationParams는 page와 limit를 필수로 선언하므로, 타입 검사를 통과하려면 필터 객체에 둘 다 넣어야 한다.debug: true는 요청 본문을 로그에 남긴다. 본문에는 제한·비공개 등급 좌표가 들어 있을 수 있다. 운영 환경에서는 debug를 끄거나, 기록 전에 가려야 한다.calculatePressure()는 틀렸다. 제1장에서 보았듯 mpa 값은 열 배 작고, bar 값은 10 m당 1기압을 정확한 값처럼 쓴다. 대신 제1장의 수정된 pressureAt()을 쓴다.| 영역 | 메서드 | 경로 |
|---|---|---|
| 탐사 | listExpeditions, getExpedition, createExpedition, updateExpedition, deleteExpedition | /api/v1/expeditions |
| 잠수 | listDives, getDive, createDive, updateDive, getDiveTrack | /api/v1/dives |
| 관찰 | listObservations, getObservation, submitObservation, updateObservation, getDiveObservations | /api/v1/observations |
| 시료 | listSamples, getSample, registerSample, updateSample, requestSample, getAvailableSamples | /api/v1/samples |
| 열수공 | listVents, getVent, registerVent, updateVent | /api/v1/vents |
| 지질 | registerRockSample, getRockSample, registerSedimentCore, getSedimentCore | /api/v1/geological/… |
| 측심 | uploadBathymetry, getBathymetrySurvey, getBathymetryGrid, get3DModel | /api/v1/bathymetry |
| eDNA 기록 | registerEDNASample, getEDNASample, submitEDNAAnalysis | /api/v1/edna |
| 잠수정 | registerSubmersible, getSubmersible, listSubmersibles | /api/v1/submersibles |
| 공통 | search, getStatistics | /api/v1/search, /api/v1/statistics |
listSamples와 getSample은 BiologicalSample로 타입이 지정되어 있지만, 컬렉션에는 다른 종류의 시료도 들어 있을 수 있다. 이 목록에서 암석이나 퇴적물 항목을 다루는 코드는 선언을 믿지 말고 실행 시점에 타입을 좁혀야 한다. GET /api/v1/dives/{diveId}/samples를 부르는 메서드는 없다. 필터를 건 listSamples를 쓰거나 경로를 직접 호출한다.
getDepthZone(depthM)은 수층 체계의 DepthZone을 반환한다. 200 m보다 얕으면 표준의 심해 범위 밖인 epipelagic을 돌려주므로, 그런 기록을 데이터셋에 넣을지 호출자가 판단해야 한다.calculateDistance(lat1, lon1, lat2, lon2)는 반지름 6,371 km 구면의 하버사인 대원 거리를 km로 돌려준다. 수심은 무시하므로, 가까운 두 지점의 수심 차까지 반영하려면 같은 단위로 맞춰 피타고라스 정리로 합친다.isValidCoordinates(lat, lon)은 −90…90과 −180…180 범위를 검사한다. 위치 결정이 안 되었을 때 흔히 나타나는 0,0도 통과시키므로 이 값은 따로 다루어야 한다.calculateDescentTime(depthM, rate?)는 기본 시간당 1,000 m로 계산한 분 단위를 반환한다(6,000 m면 360분). 이 기본값은 계획용 가정이므로 잠수정의 실제 속도를 넘겨야 한다.isValidSubmersibleType, isValidDepthZone, isValidHabitatType은 문자열을 열거형과 대조한다.import { DeepSeaExplorationSDK, DepthZone, ApiResponse, OrganismObservation } from '@wia/ene-040';
const sdk = new DeepSeaExplorationSDK({
apiKey: process.env.WIA_API_KEY ?? '',
endpoint: 'https://api.example.org', // 호스트 루트: SDK 경로가 이미 /api/v1로 시작한다
timeout: 20_000,
});
// SDK는 `retries`를 무시하므로 여기서 재시도한다. GET만: 반복해도 안전하다.
const RETRYABLE = new Set(['NETWORK_ERROR', 'HTTP_429', 'HTTP_502', 'HTTP_503', 'HTTP_504']);
async function withRetry<T>(call: () => Promise<ApiResponse<T>>, attempts = 4): Promise<ApiResponse<T>> {
let res = await call();
for (let i = 1; i < attempts && !res.success && RETRYABLE.has(res.error?.code ?? ''); i++) {
const wait = Math.min(30_000, 1_000 * 2 ** i) * (0.5 + Math.random() / 2);
await new Promise((r) => setTimeout(r, wait));
res = await call();
}
return res;
}
// Phase 2 봉투는 세부 코드를 중첩한다. SDK는 본문 전체를 `details`에 둔다.
const serverCode = (res: ApiResponse<unknown>): string =>
res.error?.details?.error?.code ?? res.error?.code ?? 'UNKNOWN';
export async function hadalObservations(): Promise<OrganismObservation[]> {
const out: OrganismObservation[] = [];
for (let page = 1; ; page++) {
const res = await withRetry(() =>
sdk.listObservations({
depthZone: DepthZone.HADOPELAGIC,
minDepth: 6000, // 서버가 저서 체계 'hadal'로 저장했어도 확실하다
page,
limit: 100,
})
);
if (!res.success || !res.data) {
throw new Error(`observations page ${page}: ${serverCode(res)} (${res.error?.message})`);
}
out.push(...res.data.items);
if (!res.data.hasMore) return out;
}
}
deep-sea-exploration.shCLI는 curl을 감싼 Bash 스크립트 하나이며, jq가 설치되어 있으면 이것으로 출력을 정리한다. 환경 변수 WIA_API_ENDPOINT와 WIA_API_KEY를 읽는다. config 명령은 두 값을 묻고(키는 화면에 표시하지 않는다) 권한 600으로 ~/.wia/deep-sea-exploration.conf에 저장한다. 스크립트는 실행할 때마다 이 파일을 source하므로 파일이 셸 코드로 실행된다. 소유자 전용으로 두고, 믿을 수 없는 곳에서 복사해 오지 말아야 한다. 저장된 값은 환경 변수보다 우선한다.
기본값에는 함정이 하나 있다. 내장된 엔드포인트는 https://api.wia.org/ene-040/v1인데, 모든 명령이 /api/v1/로 시작하는 경로를 덧붙인다. 그 결과 URL에 버전이 두 번 들어가고(…/ene-040/v1/api/v1/expeditions), §13대로 구성된 서버와는 맞지 않는다. SDK와 똑같이 엔드포인트를 호스트 루트로 지정해야 한다. export WIA_API_ENDPOINT=https://api.example.org.
| 명령 | 인자 | 요청 |
|---|---|---|
config | (대화형) | 없음. 설정 파일을 쓴다 |
expeditions list | get | [PAGE] [LIMIT] | EXPEDITION_ID | GET /api/v1/expeditions… |
dives list | get | EXPEDITION_ID [PAGE] [LIMIT] | DIVE_ID | GET …/expeditions/{id}/dives, GET /api/v1/dives/{id} |
observations list | get | [DEPTH_ZONE] [PAGE] [LIMIT] | OBSERVATION_ID | GET /api/v1/observations… |
samples list | get | [PAGE] [LIMIT] | SAMPLE_ID | GET /api/v1/samples… |
vents list | get | [REGION] [PAGE] [LIMIT] | VENT_ID | GET /api/v1/vents… |
submersibles list | [TYPE] [PAGE] [LIMIT] | GET /api/v1/submersibles… |
search | QUERY | POST /api/v1/search |
stats | 없음 | GET /api/v1/statistics |
help, version | 없음 | 없음 |
목록 명령의 기본값은 1페이지, 10건이다. 스크립트 머리말은 bathymetry 명령도 안내하지만 실제로는 구현되어 있지 않다. 또 submersibles 도움말은 열거형에 있는 crawler를 빠뜨렸다.
아래 기록은 예시다. ID는 가상이며, 응답 본문은 서버에 따라 달라진다.
$ export WIA_API_ENDPOINT=https://api.example.org
$ ./deep-sea-exploration.sh config # 대화형. 키는 화면에 나타나지 않는다
API Endpoint [https://api.example.org]:
API Key:
[✓] Configuration saved to /home/analyst/.wia/deep-sea-exploration.conf
[✓] Configuration complete!
$ ./deep-sea-exploration.sh dives list EXP-2026-017 1 2
[ℹ] Listing dives for expedition: EXP-2026-017
{
"items": [
{ "diveId": "DIVE-ROV-2026-031", "timeline": { "bottomTime": 14220 }, "navigation": { "maxDepth": 5980.4 } },
{ "diveId": "DIVE-ROV-2026-032", "timeline": { "bottomTime": 12960 }, "navigation": { "maxDepth": 6210.9 } }
],
"total": 7,
"page": 1,
"limit": 2,
"hasMore": true
}
$ ./deep-sea-exploration.sh observations list hadopelagic 1 50 > hadal.txt
스크립트에서는 세 가지 성질이 문제를 일으킨다. 첫째, 상태 줄([ℹ] Listing …)이 JSON보다 먼저 표준 출력으로 나간다. 그래서 곧바로 jq에 파이프로 넘기면 파싱에 실패한다. 첫 줄을 버리거나, 더 나은 방법으로 print_info가 표준 오류로 쓰도록 고친다. 둘째, curl은 -s로만 실행되고 -f가 없다. 게다가 결과를 local response=$(…)로 받기 때문에 종료 상태가 가려진다. 결국 401이나 500이 나도 종료 코드는 0이고, 오류 JSON이 데이터처럼 출력되며, set -e도 소용이 없다. error 키를 직접 확인해야 한다. 셋째, search는 질의어를 틀에 그대로 붙여 JSON 본문을 만든다. 따라서 큰따옴표가 들어간 질의는 잘못된 JSON이 된다. 단순한 낱말 이상을 검색하려면 jq로 본문을 만들어 엔드포인트를 직접 호출한다.
# 1) 상태 줄을 건너뛰고 API 오류에서는 실패하는 탭 구분 열수공 목록
./deep-sea-exploration.sh vents list "" 1 100 \
| tail -n +2 \
| jq -er 'if .error then error(.error.code) else .items[] | [.ventId, .ventField, .ventType, .location.depth] | @tsv end'
# 2) 안전한 검색: 본문은 jq가 만들고, curl -f가 HTTP 오류를 0이 아닌 종료 코드로 바꾼다
jq -n --arg q 'Amphipoda "hadal" trench' '{query: $q, type: "observation"}' \
| curl -sf -X POST "$WIA_API_ENDPOINT/api/v1/search" \
-H "Authorization: Bearer $WIA_API_KEY" \
-H 'Content-Type: application/json' \
-H 'X-WIA-Standard: ENE-040' -H 'X-WIA-Version: 1.0.0' \
--data-binary @- \
| jq '.'
WIA-ENE-040의 두 HTTP 인터페이스는 규약을 공유하지만, 기록의 생애에서 서로 다른 순간을 맡는다. 운용 API(/v1)는 잠수 그 자체를 다룬다. 잠수정, 명령, 실시간 텔레메트리, 임무, 센서, 갑판 위의 시료 등록이 여기에 속한다. 기록 API(/api/v1)는 탐사, 잠수, 관찰, 시료, 열수공, 조사로 이루어진 과학 기록을 보관한다. SDK와 CLI는 기록 쪽만 구현한다.
제6장은 선상 게이트웨이 아래로 내려가, 이 명령과 텔레메트리를 수층 너머로 실어 나르는 Phase 3 음향 스택을 다룬다.