제4장

제4장: API 인터페이스 — 엔드포인트와 SDK

제2장과 제3장은 홍수 관측값, 예측 결과, 경보가 데이터로서 어떤 모양인지를 정의하였다. 이 장은 그 객체들이 어떻게 오가는지를 다룬다. WIA-ENE-033의 PHASE-2가 규정한 오퍼레이션 그룹, 참조 TypeScript SDK가 실제로 호출하는 /api/v1/... 경로, 전송 계층의 헤더와 오류 규약, 디스커버리 문서, 그리고 같은 호출을 감싼 셸 CLI가 대상이다. 명세 본문과 README, SDK가 서로 어긋나는 곳에서는 그 사실을 밝히고, 구현자가 감사 과정에서 근거를 댈 수 있는 규칙을 제안한다.

4.1 오퍼레이션 그룹과 실제 경로

PHASE-2는 URL을 나열하지 않는다. 대신 다섯 개의 오퍼레이션 그룹을 정의하고, 각 그룹이 OpenAPI 문서에 기술된다고 적는다. 규범적 어휘는 이 그룹이다. 구현체는 상호운용 프로필에서 각 그룹을 지원(supported), 부분 지원(partial), 제외(excluded) 중 무엇으로 선언할지 밝힌다. 구체적인 경로는 다른 두 곳, 즉 README의 엔드포인트 목록과 api/typescript/src/index.ts의 FloodPredictionSDK 클래스에서 나온다. PHASE-2가 가리키는 OpenAPI 파일(api/openapi.yaml)은 공개 저장소에 존재하지 않으므로, v1.0에서 전송 계약을 가장 정밀하게 드러내는 것은 SDK다.

오퍼레이션 그룹 (PHASE-2)명세상 목적v1.0 실제 경로관찰
gaugeIngest관측소 관측값(수위, 유량) 제출없음SDK·README·CLI 어디에도 관측값을 쓰는 경로가 없다. /monitoring 아래의 조회 경로만 있다.
forecastQuery앙상블 홍수 예측 결과 조회/flood/predict, /flood/prediction/{id}, /flood/predictions, /flood/risk-map/{regionId}예측은 POST로 생성하고 ID나 목록으로 다시 읽는다.
warningPublish구독자에게 홍수 경보 발행, CAP 1.2 병행 발행 의무(MUST)/alert/create, /alert/active, /alert/{id}, /alert/{id}/update, /alert/{id}/cancelSDK는 WIA 고유의 FloodAlert만 반환하며 CAP를 돌려주는 경로는 없다.
simulationSubmit과거 재현(hindcast) 또는 가상 시나리오 제출/flood/simulationSDK에서는 동기 호출이며 작업(job)이나 상태 리소스가 없다.
evacuationRoute다각형 영역에 대한 대피 경로 조회/evacuation/routes[/{id}], /evacuation/shelters[/{id}]SDK는 다각형이 아니라 출발점 한 곳을 기준으로 조회한다.
(명명된 그룹 없음)—/monitoring/*, /analytics/*실시간 모니터링과 KPI 보고는 경로로만 존재한다.

여기서 실무적 결론 두 가지가 나온다. 첫째, 관측소 데이터를 수집하는 배포는 당분간 자체 수집 경로를 정의하거나, PHASE-2 §12.1에 적힌 텔레메트리 프로토콜(MQTT, HTTP, WebSocket으로 JSON·XML·CSV 전달)에 기대야 한다. 이때 프로필 매니페스트에는 gaugeIngest를 근거와 함께 partial 또는 excluded로 표시해야 하며, 아무도 시험할 수 없는 인터페이스를 지원한다고 주장해서는 안 된다. 둘째, 모니터링과 분석 경로는 소속시킬 오퍼레이션 그룹이 없다. 신중한 프로필이라면 이들을 확장으로 나열하고, 어느 SDK 메서드를 제공하는지 밝힌다.

기준 URL 문제

SDK는 요청 URL을 config.endpoint + path로 만들며, 모든 경로가 이미 /api/v1로 시작한다. 그런데 README는 SDK를 endpoint: 'https://api.wia.org/ene-033/v1'로 설정하고, CLI도 같은 문자열을 기본값으로 쓴다. 둘을 합치면 버전 경로가 두 번 들어간다. README의 cURL 예제는 /api/v1 없이 /ene-033/v1/flood/predict를 쓰는 세 번째 형태다.

// README 예제: endpoint 에 이미 버전 경로가 들어 있다
endpoint = "https://api.wia.org/ene-033/v1"
path     = "/api/v1/flood/predict"        // SDK 안에 고정된 경로
request  = "https://api.wia.org/ene-033/v1/api/v1/flood/predict"   // 버전이 두 번 붙는다

// 권장: endpoint 에는 오리진(과 게이트웨이 접두어)만 넣는다
endpoint = "https://flood.example.go.kr"
request  = "https://flood.example.go.kr/api/v1/flood/predict"

이 책이 채택하는 규칙은 다음과 같다. endpoint에는 오리진과 운영자가 덧붙이는 게이트웨이 접두어만 넣고, API 버전은 절대 넣지 않는다. 버전은 SDK가 붙이는 경로와 X-WIA-Version 헤더에 들어 있다. README 형태도 받아야 하는 서버라면 게이트웨이에서 경로를 재작성할 수 있지만, 그 형태를 공식 경로로 알려서는 안 된다. 또한 api.wia.org는 예제에만 등장하며 실제 운영 중인 서비스라는 근거가 저장소에 없다. 그래서 이 장의 예제는 자리표시용 호스트 flood.example.go.kr를 쓴다.

4.2 예측 API

예측 그룹은 인터페이스의 중심이다. SDK 메서드 다섯 개가 경로 다섯 개에 대응한다.

메서드와 경로SDK 메서드요청응답 데이터그룹
POST /api/v1/flood/predictpredictFloodRiskFloodPredictionRequestFloodPredictionResultforecastQuery
GET /api/v1/flood/prediction/{id}getPrediction경로 매개변수FloodPredictionResultforecastQuery
POST /api/v1/flood/simulationrunSimulationFloodPredictionRequest & { scenarios: string[] }FloodPredictionResultsimulationSubmit
GET /api/v1/flood/risk-map/{regionId}getRiskMap경로 매개변수타입 없음 (any)forecastQuery
GET /api/v1/flood/predictionslistPredictionsPaginationParams + regionId, riskLevelPaginatedResponse<FloodPredictionResult>forecastQuery

예측 생성

SDK 타입에서 필수 필드는 location과 timeHorizon뿐이다. include* 플래그, resolution, runSimulation, simulationScenarios는 선택 필드인 반면, PHASE-1 데이터 포맷은 자료원 플래그를 필수로 표시한다(차이는 제3장에서 다루었다). 따라서 서버는 플래그가 빠졌을 때 적용할 기본값을 명시적으로 문서화하고, 실제로 무엇을 썼는지 되돌려 주어야 한다. 반환하는 contributingFactors가 그 역할을 할 수 있다. 빠진 includeDamStatus를 조용히 false로 처리하면 상류 댐의 방류를 무시한 예측이 나올 수 있다.

location은 완전한 Location 객체이며, regionId·coordinates와 함께 name도 필수다. README의 SDK 예제는 name을 빠뜨려 타입 검사를 통과하지 못한다. 유효한 최소 본문은 다음과 같다.

{
  "location": {
    "regionId": "REG-EXAMPLE-001",
    "name": "예시 하천변 행정구역",
    "coordinates": { "latitude": 37.52, "longitude": 127.04 }
  },
  "timeHorizon": "6h",
  "resolution": "medium",
  "includePrecipitation": true,
  "includeRiverLevels": true,
  "includeDamStatus": true,
  "includeDrainage": true,
  "includeTopography": false,
  "runSimulation": false
}

SDK가 검사 도우미를 이미 제공하더라도 서버는 두 값을 반드시 검증해야 한다. timeHorizon은 1h, 6h, 24h, 72h 중 하나(isValidTimeHorizon), resolution은 coarse, medium, fine 중 하나(isValidResolution)여야 한다. 48h를 보낸 클라이언트는 가장 가까운 지원 범위로 바꾼 결과가 아니라, 이유가 설명된 4xx 응답을 받아야 한다.

같은 호출을 HTTP로 직접 보내면 다음과 같다. curl -s만 쓰면 상태 코드가 사라지므로 스크립트에서는 -w 옵션을 유지하는 편이 좋다.

# 위 요청 본문을 먼저 predict-request.json 으로 저장한다.
# -w 는 응답 본문만으로는 알 수 없는 HTTP 상태 코드를 함께 출력한다.
curl -sS -X POST "https://flood.example.go.kr/api/v1/flood/predict" \
  -H "Authorization: Bearer $WIA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "X-WIA-Standard: ENE-033" \
  -H "X-WIA-Version: 1.0.0" \
  --data @predict-request.json \
  -w '\nHTTP %{http_code}\n'

조회, 목록, 위험 지도

getPrediction은 predictionId로 저장된 결과를 읽는다. 서버는 한 번 반환한 결과를 변경하지 않는 것이 바람직하다. 새 관측값으로 상황이 바뀌면 기존 결과를 고치지 말고 새 ID로 새 예측을 발행한다. 그래야 감사 추적이 유지되고, 어떤 경보가 어떤 예측에 근거했는지 언제든 거슬러 올라갈 수 있다.

listPredictions는 전달된 모든 매개변수를 쿼리 문자열로 직렬화한다. { page: 1, limit: 20, riskLevel: RiskLevel.LEVEL_3 }로 호출하면 ?page=1&limit=20&riskLevel=LEVEL-3이 된다. 페이지 번호가 0부터인지 1부터인지는 v1.0 어디에도 정해져 있지 않다. 하나를 골라 프로필에 공표하고, PaginatedResponse에 실제로 적용한 page를 돌려주어 클라이언트가 불일치를 알아챌 수 있게 한다.

getRiskMap의 반환 타입은 any다. 명세가 위험 지도 스키마를 정의하기 전까지는, 각 피처가 riskLevel 속성을 갖는 GeoJSON FeatureCollection을 반환하는 것이 합리적이다. 속성값은 표준의 다른 모든 객체와 같은 LEVEL-0…LEVEL-4 토큰을 쓴다.

시뮬레이션

runSimulation은 FloodPredictionRequest에 필수 필드 scenarios: string[]를 더한 타입을 받는다. 요청 타입에는 이미 선택 필드 simulationScenarios가 있으므로, 한 본문에 같은 의미의 목록이 두 개 실릴 수 있다. 이 책의 규칙은 이 경로에서는 scenarios가 우선하고 simulationScenarios는 무시한다는 것이다. 시나리오 이름은 자유 문자열이지만, 결과의 scenarios 객체는 bestCase, worstCase, mostLikely라는 고정 멤버를 가지므로 서버는 이 셋에 대응시킬 수 없는 이름을 거부해야 한다.

2차원 침수 계산은 SDK의 기본 타임아웃 30초를 훨씬 넘길 수 있다. v1.0에는 작업 리소스가 없으므로 긴 시뮬레이션은 타임아웃까지 클라이언트를 붙잡아 두거나, 아니면 짧게 끝나도록 제한해야 한다. 무거운 모델을 쓰는 배포라면 POST에 빠르게 응답하며 저장된 결과를 알려 주고, 클라이언트가 getPrediction으로 폴링하게 하는 방식을 택하되, SDK는 이 동작을 모르므로 반드시 문서화해야 한다.

4.3 모니터링 API

모니터링 그룹은 현재 관측값과 요약 대시보드를 제공한다. 목록형 메서드는 모두 선택적 regionId를 쿼리 매개변수로 받는다.

경로 (GET)SDK 메서드응답 데이터
/api/v1/monitoring/riversmonitorRiversRiverLevelData[]
/api/v1/monitoring/rivers/{riverId}getRiverStatusRiverLevelData
/api/v1/monitoring/damsmonitorDamsDamStatusData[]
/api/v1/monitoring/dams/{damId}getDamStatusDamStatusData
/api/v1/monitoring/rainfallmonitorRainfall타입 없음 (any)
/api/v1/monitoring/drainagemonitorDrainageDrainageData[]
/api/v1/monitoring/dashboardgetMonitoringDashboardMonitoringDashboard

강우 엔드포인트가 눈에 띈다. 타입 정의에는 PrecipitationData가 완전히 정의되어 있는데 monitorRainfall은 any를 반환한다. 우량계나 레이더 격자 하나당 원소 하나인 PrecipitationData[]를 반환하면 다른 경로와 일관성이 생기고, 클라이언트는 contributingFactors 안에서 이미 받고 있는 것과 같은 타입을 그대로 쓸 수 있다.

MonitoringDashboard는 overview(활성 경보 수, 고위험 지역 수, 대피 인원, 운영 중인 대피소 수), 지역별 riskLevel과 대피 진행률을 담은 regions 목록, 센서 현황(전체·가동·오프라인·점검 중), 가동률·지연 시간·데이터 품질을 담은 system 블록으로 이루어진다. 초 단위의 refreshInterval은 서버가 클라이언트에게 알려 주는 적정 폴링 주기다. 클라이언트는 이를 지켜야 한다. 호출이 폭증하는 때가 바로 홍수 상황이기 때문이다.

SDK는 폴링만 지원한다. PHASE-2 §12.1은 수문 관측망의 프로토콜로 MQTT와 WebSocket을 들지만, API 소비자를 위한 구독 인터페이스는 정의되어 있지 않다. 따라서 하류 소비자에게 밀어 주는 전달은 모니터링 API가 아니라 CAP 피드와 제5장에서 다룰 경보 배포 채널의 몫이다.

주의: 모니터링 데이터는 경보가 아니다

RiverLevelData.status.condition이 danger라는 것은 기준 수위를 넘었다는 관측일 뿐, 발령된 경보가 아니다. 소비자는 이를 대중에게 경보처럼 보여 주어서는 안 된다. 발령 주체와 법적 의미를 갖는 것은 경보 경로가 반환하는 객체뿐이다.

4.4 경보·대피 API

경보의 생애 주기

경보 경로는 FloodAlert 객체의 생성·조회·변경·해제 주기를 구현한다.

메서드와 경로SDK 메서드본문응답 데이터
POST /api/v1/alert/createcreateAlertOmit<FloodAlert, 'alertId' | 'timestamp'>FloodAlert
GET /api/v1/alert/activegetActiveAlerts— (선택 regionId)FloodAlert[]
GET /api/v1/alert/{id}getAlert—FloodAlert
PUT /api/v1/alert/{id}/updateupdateAlertPartial<FloodAlert>FloodAlert
POST /api/v1/alert/{id}/cancelcancelAlert{ reason?: string }없음 (void)

생성 본문에서는 alertId와 timestamp가 빠지고 서버가 둘을 부여한다. 올바른 설계다. 경보가 언제 효력을 가졌는지를 정하는 권한은 클라이언트가 아니라 발령 시스템에 있기 때문이다. 서버는 또한 issuer.organizationId가 인증된 호출자와 일치하는지 확인해야 한다. 그렇지 않으면 키를 가진 누구나 다른 기관의 이름으로 경보를 낼 수 있다.

변경 경로는 부분 본문을 PUT으로 보내는데, 이는 사실상 병합 패치(merge-patch) 의미다. 구현자는 이를 문서에 밝히고 배열 병합 방식도 정해야 한다. 새 regionIds 목록을 담은 부분 본문은 보통 기존 목록에 덧붙이라는 뜻이 아니라 교체하라는 뜻이다. alert.level의 변경은 운영상 대중에게 보내는 새로운 메시지다. 서버는 이전 판을 보존하고, CAP 쪽에서는 원래 식별자를 참조하는 Update 메시지를 내보내야 한다.

해제는 DELETE가 아니라 선택적 사유를 담은 POST다. 이 구분은 중요하다. 해제된 경보도 감사 추적을 위해 ID로 계속 조회할 수 있어야 하고, CAP 피드는 하류 시스템이 경보를 거둬들이도록 Cancel 메시지를 실어야 한다. SDK에서는 reason이 선택이지만 서버에서 필수로 요구하면 나중에 허위 경보를 검토할 때 도움이 된다.

재시도와 중복 경보

POST /alert/create는 멱등적이지 않다. 서버가 경보를 확정한 뒤에 요청이 타임아웃되면, 단순한 재시도가 두 번째 경보를 만들고 주민은 같은 경보를 두 번 받게 된다. v1.0은 멱등 키를 정의하지 않는다. 그 전까지 생성 요청에서 NETWORK_ERROR를 받은 클라이언트는 다시 시도하기 전에 getActiveAlerts(regionId)를 호출해 자신이 이미 발령했을 수 있는 경보가 있는지 먼저 확인해야 한다. 서버 쪽에서는 클라이언트가 생성한 요청 식별자를 헤더로 받아, 같은 식별자가 두 번 오면 기존 경보를 반환하는 방식으로 도울 수 있다. 이는 확장이므로 확장으로 선언해야 한다.

CAP 1.2 의무

PHASE-2는 warningPublish 오퍼레이션이 WIA 고유 봉투에 더해 CAP 1.2 형식으로도 경보를 내보내야 한다(MUST)고 규정한다. 그런데 경보 경로 중 CAP를 반환하는 것은 없고 SDK에도 해당 메서드가 없다. 따라서 이 요구사항은 충족되더라도 SDK 바깥에서 충족된다. 적합한 배포라면 생성·변경·해제가 있을 때마다 CAP 1.2 문서를 발행하고(CAP 피드에는 미디어 타입 application/cap+xml을 쓰는 것이 일반적인 관행이다), 그 피드를 디스커버리 매니페스트(§4.7)에서 연결해야 한다. 필드 매핑은 제3장에서 다루었다. 여기서 강조할 점은 CAP 변환이 별도의 수작업이 아니라 경보 API의 발행 부수 효과여야 한다는 것이다.

대피 경로

메서드와 경로SDK 메서드매개변수 / 본문응답 데이터
GET /api/v1/evacuation/routesgetEvacuationRouteslatitude, longitude, 선택 type = shelter | high-groundEvacuationRoute[]
GET /api/v1/evacuation/routes/{routeId}getEvacuationRoute—EvacuationRoute
GET /api/v1/evacuation/sheltersgetEvacuationShelters선택 regionIdEvacuationShelter[]
GET /api/v1/evacuation/shelters/{shelterId}getShelterCapacity—EvacuationShelter
PUT /api/v1/evacuation/shelters/{shelterId}updateShelterStatusPartial<EvacuationShelter>EvacuationShelter

README는 대피소 수용 현황을 위한 별도 경로 GET /api/v1/evacuation/capacity를 나열한다. SDK에는 이 경로가 없다. SDK의 getShelterCapacity는 /evacuation/shelters/{shelterId}에서 대피소 객체 전체를 읽으며, 그 capacity 멤버에 total, current, available이 들어 있다. SDK 쪽이 더 나은 설계다. 수용 인원은 대피소의 식별자, 운영 상태, 접근성 정보와 떨어지면 의미가 없기 때문이다. README 경로를 제공하고 싶은 서버는 shelterId별 capacity 객체를 돌려주는 별칭으로 둘 수 있지만, 클라이언트는 SDK 경로를 기준으로 삼아야 한다.

경로 조회는 점 기준이다. 클라이언트가 출발 좌표를 보내면, 반환되는 각 EvacuationRoute에는 경로와 거리, 예상 소요 시간, safetyScore, 위험 요소(수심이 기록된 침수 구간, 통제 도로), 대안 경로가 담긴다. PHASE-2는 evacuationRoute를 경보 구역 전체에서 빠져나가는 경로처럼 "다각형에 대한" 조회로 기술한다. v1.0의 이 경로에는 다각형을 실을 자리가 없으므로, 다각형 조회는 프로필에 선언하는 확장이 된다. 홍수 상황에서 경로 정보는 빠르게 낡으므로 서버는 캐시 수명을 짧게 두고, 구간 상태가 바뀔 때마다 updates를 채워야 한다.

대피소 변경은 주민을 어디로 보낼지를 바꾸는 쓰기 작업이다. 경보 생성과 같은 수준의 권한 관리가 필요하며, 보통 대피소 운영자와 재난 대응 부서로 제한한다.

4.5 분석 API

메서드와 경로SDK 메서드입력응답 데이터
GET /api/v1/analytics/kpigetKPIDashboard선택 regionId, startDate, endDateKPIDashboard
POST /api/v1/analytics/reportgenerateReport{ regionId, period: DateRangeFilter }FloodResponseReport
GET /api/v1/analytics/report/{reportId}getReport—FloodResponseReport
GET /api/v1/analytics/eventslistHistoricalEventsPaginationParams, DateRangeFilter, regionIdPaginatedResponse<FloodPredictionResult>

KPIDashboard는 예측 KPI(예측 범위별 정확도, 선행 시간, 허위 경보율과 미탐지율)와 대응 KPI(전달 시간과 성공률, 대피 완료율, 피해 지표)를 묶는다. 제7장은 이를 보고용 객체로 다룬다. API 수준에서는 세 가지 세부 사항이 중요하다.

PaginatedResponse는 items, total, page, limit, hasMore를 반환한다. 오프셋 방식 페이지 나누기는 단순하지만, 홍수 상황 중 새 예측이 들어오면 결과가 밀린다. 따라서 특정 기간을 내보내는 분석 소비자는 과거의 고정된 endDate로 조회 범위를 묶어, 조회 도중 페이지 구성이 바뀌지 않게 해야 한다.

4.6 인증·헤더·오류

세 자료원이 가장 크게 갈리는 곳이 여기다. 아래 표에 나란히 정리한다.

항목PHASE-2 / PHASE-4 본문참조 SDKCLI
클라이언트 인증PHASE-2: 기관 호출자에 mTLS를 더한 "OAuth 2.1 (RFC 9700)", PHASE-4: OAuth 2.0 + MFA정적 키를 쓰는 Authorization: Bearer <apiKey>같은 베어러 키, WIA_API_KEY 또는 설정 파일에서 읽음
표준 헤더규정 없음X-WIA-Standard: ENE-033, X-WIA-Version: 1.0.0, Content-Type: application/json같은 헤더 네 개
오류 본문Problem Details (RFC 9457)본문의 message를 읽고, 없으면 statusText 사용원본 본문을 그대로 출력
호출 한도RFC 6585의 429와 재시도 대기(retry-after) 의미HTTP_429로 보고, 응답 헤더는 노출되지 않음상태 코드를 받지 않음
타임아웃 / 재시도—기본 타임아웃 30초, retries 옵션(기본 3)은 있으나 쓰이지 않음타임아웃 설정 없음
응답 형태—서버 본문은 객체 그 자체, SDK가 ApiResponse<T>로 감쌈서버 본문 자체가 { success, data }이기를 기대

인증

먼저 바로잡을 것이 있다. RFC 9700은 OAuth 2.0 보안 모범 사례(Security Best Current Practice)이며, OAuth 2.1은 아직 IETF 인터넷 드래프트 단계로 RFC 번호가 없다. PHASE-2는 "RFC 9700에 따라 배치한 OAuth 2.0"으로 읽는 것이 합리적이다. 실무적으로는 대화형 클라이언트에 PKCE를 적용한 인가 코드 방식, 기계 호출자에는 클라이언트 자격 증명 방식을 쓰고, 가능하면 발신자 제약 토큰을 쓰며, 암묵적(implicit) 그랜트는 쓰지 않는다는 뜻이다. 기관 호출자의 mTLS 요구사항은 RFC 8705(OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens)로 충족할 수 있다.

SDK의 정적 베어러 키는 개발용이나 소규모 단일 테넌트 배포를 위한 편의 수단이다. 이 키는 OAuth 액세스 토큰이 쓰는 것과 같은 Authorization: Bearer 헤더에 실리므로, SDK를 고치지 않고도 수명이 짧은 토큰으로 바꿀 수 있다. 현재 액세스 토큰을 apiKey로 넘기고, 토큰이 갱신되면 클라이언트를 새로 만들면 된다. 어떤 방식을 쓰든 서버는 자격 증명의 권한을 역할별로 나누어야 한다. 모니터링 데이터를 읽는 키로 /alert/create나 PUT /evacuation/shelters/{id}를 호출할 수 있어서는 안 된다.

응답 봉투

모든 SDK 메서드는 ApiResponse<T>를 반환한다. 구성은 success, 선택적 data, 선택적 error(code, message, details), metadata(timestamp, version)다. 이 봉투는 SDK가 만든다. 2xx 응답이면 JSON 본문 전체가 data가 된다. 반면 CLI는 서버의 원본 본문에 jq -e '.success'를 실행하고 .data.predictionId를 읽으므로, 서버가 봉투를 직접 보내기를 기대한다. 서버는 둘을 동시에 만족시킬 수 없다. 객체를 그대로 보내면 CLI가 실패로 판정하고, 봉투를 보내면 SDK 사용자는 data.data를 받게 된다.

이 책은 SDK의 해석, 즉 전송 계층에는 리소스 객체를 그대로 싣는 방식을 권한다. 이유는 세 가지다. RFC 9457 오류 처리와 맞고(성공 여부는 상태 코드가 전한다), OpenAPI 문서가 응답을 기술하는 일반적인 방식과 맞으며, SDK가 더 완성된 산출물이기 때문이다. 이 경우 CLI에는 §4.8에서 설명할 작은 수정이 필요하다. 어느 쪽을 택하든 프로필 매니페스트에 적어 두어야 한다. 같은 이유로 README의 SDK 예제도 주의해야 한다. 예제는 prediction.riskLevel을 출력하지만 결과는 prediction.data.riskLevel에 있다.

SDK의 오류 처리

SDK의 요청 도우미는 두 종류의 오류 코드를 만든다. 서버가 2xx가 아닌 상태로 응답하면 HTTP_<status>, 예외가 발생하면 NETWORK_ERROR다. 그런데 실제로는 네트워크 오류가 아닌 여러 상황이 두 번째 범주로 들어간다.

HTTP 오류일 때 SDK는 data.message를 메시지로 쓴다. RFC 9457을 따르는 서버는 설명을 title과 detail에 담으므로, SDK는 상태 설명 문구(reason phrase)로 되돌아가는데, 전송이 HTTP/2이면 이 문구가 비어 있을 수 있다. 문제 객체 전체는 여전히 error.details에서 볼 수 있다. 잘못된 예측 범위에 대한 문제 응답은 다음과 같은 모양이다.

HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json

{
  "type": "https://flood.example.go.kr/problems/invalid-time-horizon",
  "title": "Unsupported time horizon",
  "status": 422,
  "detail": "timeHorizon '48h' is not one of 1h, 6h, 24h, 72h",
  "instance": "/api/v1/flood/predict"
}

현재 SDK를 위해 서버가 확장 멤버로 message를 함께 넣을 수 있다(RFC 9457은 확장 멤버를 허용한다). 자체 SDK 빌드를 관리하는 배포라면 처리기를 더 관대하게 고칠 수도 있다. 아래 스케치는 본문을 먼저 텍스트로 읽고, 빈 본문을 허용하며, Retry-After를 드러낸다.

// 더 관대한 응답 처리기의 스케치 (v1.0 SDK 에는 없다).
const text = await response.text();                 // 본문이 있다고 가정하지 않는다
let body: any = undefined;
if (text.length > 0) {
  try { body = JSON.parse(text); } catch { body = { raw: text.slice(0, 500) }; }
}
if (!response.ok) {
  return {
    success: false,
    error: {
      code: `HTTP_${response.status}`,
      // RFC 9457 은 detail/title 을 쓴다. 기존 서버를 위해 message 도 본다
      message: body?.detail ?? body?.title ?? body?.message ?? response.statusText,
      details: { body, retryAfter: response.headers.get('Retry-After') },
    },
  };
}
return { success: true, data: body as T };           // 204 이면 data 는 undefined

유지보수자를 위한 참고 사항이 하나 더 있다. 공개된 소스를 tsc로 타입 검사하면 types.ts 끝부분에서 TS2484 오류("Export declaration conflicts with exported declaration") 네 건이 보고된다. 마지막의 export type { Timestamp, Coordinates, Location, GeoJSON } 블록이 이미 내보낸 이름을 다시 내보내기 때문이다. 이 블록을 지우면 공개 API를 바꾸지 않고 오류가 사라진다.

종합 예제: 예측, 판단, 경보

다음 TypeScript 코드는 지금까지의 내용을 하나로 묶는다. 6시간 예측을 요청하고, 일시적 실패만 지수 백오프로 재시도하며, 위험 단계를 숫자로 비교하고, 무엇이든 발령하기 전에 사람이 판단하는 지점에서 멈추며, 확인되지 않은 생성 요청은 재시도가 아니라 대조가 필요한 상황으로 다룬다. 식별자, 호스트 이름, 연락처 번호는 자리표시자다.

import {
  FloodPredictionSDK,
  FloodPredictionResult,
  RiskLevel,
  AlertSeverity,
  AlertUrgency,
  AlertCertainty,
  FloodEventType,
  formatRiskLevel,
} from '@wia/ene-033';

const sdk = new FloodPredictionSDK({
  apiKey: process.env.WIA_API_KEY ?? '',
  endpoint: 'https://flood.example.go.kr', // 오리진만 넣는다. /api/v1 은 SDK 가 붙인다
  timeout: 15_000,                         // 기본값은 30 000 ms
});

// 단계는 'LEVEL-n' 문자열이 아니라 숫자로 비교한다.
const LEVEL_RANK: Record<RiskLevel, number> = {
  [RiskLevel.LEVEL_0]: 0,
  [RiskLevel.LEVEL_1]: 1,
  [RiskLevel.LEVEL_2]: 2,
  [RiskLevel.LEVEL_3]: 3,
  [RiskLevel.LEVEL_4]: 4,
};

// 애플리케이션 로직까지 닿지 못한 실패만 재시도할 가치가 있다.
function isTransient(code: string | undefined): boolean {
  return code === 'NETWORK_ERROR' || code === 'HTTP_429' ||
         code === 'HTTP_502' || code === 'HTTP_503' || code === 'HTTP_504';
}

async function predictWithRetry(attempts = 3): Promise<FloodPredictionResult> {
  for (let i = 1; ; i++) {
    const res = await sdk.predictFloodRisk({
      location: {
        regionId: 'REG-EXAMPLE-001',
        name: '예시 하천변 행정구역',
        coordinates: { latitude: 37.52, longitude: 127.04 },
      },
      timeHorizon: '6h',
      resolution: 'medium',
      includePrecipitation: true,
      includeRiverLevels: true,
      includeDamStatus: true,
      includeDrainage: true,
    });
    if (res.success && res.data) return res.data;
    if (!isTransient(res.error?.code) || i >= attempts) {
      throw new Error(`predict failed: ${res.error?.code} ${res.error?.message}`);
    }
    await new Promise((r) => setTimeout(r, 2 ** i * 1000)); // 2초, 4초, ...
  }
}

export async function assessAndWarn(
  officerApproves: (p: FloodPredictionResult) => Promise<boolean>,
): Promise<string | null> {
  const p = await predictWithRetry();
  const label = formatRiskLevel(p.riskLevel);
  console.log(`${p.predictionId}: ${label.en}, ` +
              `probability ${p.probability}%, confidence ${p.confidence}%`);

  if (!p.evacuationRequired || LEVEL_RANK[p.riskLevel] < 3) return null;
  if (!(await officerApproves(p))) return null;       // 사람이 판단하는 지점

  const onset = new Date();
  const expiry = new Date(onset.getTime() + 6 * 3_600_000);
  const created = await sdk.createAlert({
    issuer: {
      organizationId: 'ORG-EXAMPLE-DISTRICT',
      name: '예시구 재난안전과',
      authorityLevel: 'municipal',
    },
    alert: {
      level: p.riskLevel,
      severity: LEVEL_RANK[p.riskLevel] >= 4 ? AlertSeverity.EXTREME : AlertSeverity.SEVERE, // LEVEL-4는 extreme (제3장 예시 대응표)
      urgency: AlertUrgency.EXPECTED,
      certainty: AlertCertainty.LIKELY,
    },
    affectedAreas: {
      regionIds: p.affectedAreas,
      geoJson: p.inundationArea.geoJson,
      population: p.inundationArea.affectedPopulation,
      buildings: p.inundationArea.affectedBuildings,
    },
    event: {
      type: FloodEventType.RIVER_FLOOD,
      onset: onset.toISOString(),
      expiry: expiry.toISOString(),
      peakTime: p.peakTime,
    },
    instructions: {
      ko: '하천 수위 상승. 저지대 주민은 지정 대피소로 이동하십시오.',
      en: 'River level rising. Residents of low-lying areas move to designated shelters.',
      actions: ['evacuate-low-lying', 'avoid-underground'], // 배포가 정의한 행동 코드. 자유 문장이 아님
      evacuationRequired: true,
      evacuationZones: p.affectedAreas,
    },
    contact: {
      emergency: '119',
      information: '+82-2-000-0000',            // 자리표시자
      website: 'https://flood.example.go.kr/alerts',
    },
  });

  if (!created.success || !created.data) {
    // 생성 요청을 그대로 재시도하지 않는다. 경보가 이미 만들어졌을 수 있다.
    // 다시 발령하기 전에 getActiveAlerts() 로 대조한다.
    throw new Error(`alert not confirmed: ${created.error?.code}`);
  }
  return created.data.alertId;
}

세 가지는 의도한 설계다. LEVEL_RANK 맵이 있는 이유는 RiskLevel 값을 >=로 비교하면 문자열 비교가 되기 때문이다. LEVEL-0부터 LEVEL-4까지는 한 자리 숫자라 사전순 정렬이 우연히 맞을 뿐이며, 단계가 늘어나면 조용히 틀어진다. 판정은 evacuationRequired와 단계를 함께 보며 어느 한쪽만 믿지 않는다. 그리고 officerApproves 콜백은 경보 발령 결정이 책임 있는 사람에게 속하는 지점을 표시한다. 프로토콜이 어떤 경우를 자동으로 허용하고 어떤 경우를 수동 검토로 보내는지는 제5장에서 설명한다.

4.7 디스커버리

PHASE-2는 오퍼레이션 디스커버리에 RFC 8615의 well-known URI 방식을 쓰며 경로는 /.well-known/wia/ene-033이라고 규정한다. 또한 매니페스트 엔드포인트는 DSSE 봉투를 쓰는 Sigstore로 서명하고, 서명 키는 배포 정책에 따라 교체한다고 적는다. 경로와 서명 방식 외에 매니페스트의 필드는 v1.0에 정해져 있지 않다. 다만 부속서 I는 프로필 매니페스트가 표현해야 할 것을 밝힌다. 각 요구사항이나 그룹을 supported, partial(사유 포함), excluded(근거 포함)로 표시하는 것이다. 부속서 J는 여기에 배포 토폴로지도 매니페스트에 선언한다고 덧붙인다.

다음은 이 규칙들과 이 장에서 논의한 공백을 합친 예시 디스커버리 문서다. 필드 이름은 이 책의 제안이며 규범 문구가 아니다.

{
  "standard": "WIA-ENE-033",
  "phase": "PHASE-2-API-INTERFACE",
  "specVersion": "1.0",
  "apiBase": "https://flood.example.go.kr/api/v1",
  "profile": "WIA-P2-API-INTERFACE-Edge-only/1",
  "topology": "single-tenant-edge",
  "operationGroups": {
    "gaugeIngest":      { "status": "excluded", "justification": "관측값은 기관의 기존 텔레메트리 경로로 들어온다" },
    "forecastQuery":    { "status": "supported" },
    "warningPublish":   { "status": "partial",  "reason": "CAP 1.2 피드는 별도 발행, links.cap 참조" },
    "simulationSubmit": { "status": "excluded", "justification": "2차원 침수 모델 미도입" },
    "evacuationRoute":  { "status": "supported" }
  },
  "auth": ["bearer-api-key"],
  "links": {
    "openapi": "https://flood.example.go.kr/openapi.json",
    "cap": "https://flood.example.go.kr/cap/active.xml"
  }
}

이런 문서가 쓸모 있는 이유는 여럿이다. API 기준 주소를 알려 주므로 §4.1의 추측이 필요 없어진다. gaugeIngest와 CAP 의무에 대해 솔직한데, 이는 부속서 I가 명시적으로 요구하는 태도다("부분 적합을 완전 적합으로 잘못 표시하지 않고"). 부속서 I의 명명 규칙에 따라 주 버전이 붙은 프로필 이름을 쓴다. 그리고 소비자가 다음에 필요로 하는 두 산출물, 즉 OpenAPI 문서와 CAP 피드를 연결한다. 서명된 DSSE 봉투는 이 JSON을 페이로드로 감싼다. 매니페스트를 근거로 행동하는 클라이언트, 예컨대 대중에게 중계할 CAP 피드를 매니페스트에서 찾는 클라이언트는 이를 믿기 전에 서명과 Rekor 투명성 로그 항목을 검증해야 한다.

매니페스트는 작고 캐시 가능하게 유지한다. 매니페스트는 요청마다가 아니라 배포가 바뀔 때 바뀌며, 매니페스트를 가져오지 못했다고 해서 경보 경로 자체가 막혀서는 안 된다.

4.8 명령줄 도구(CLI)

cli/flood-prediction.sh는 SDK와 같은 헤더를 보내는 curl 기반 Bash 래퍼다. 환경 변수 WIA_API_ENDPOINT와 WIA_API_KEY를 읽거나, config 명령이 ~/.wia/flood-prediction.conf(권한 600으로 저장)에 기록한 값을 쓴다. jq가 설치되어 있으면 출력을 보기 좋게 정리한다.

명령옵션호출 경로
config대화형 입력—
predict-risk--region, --horizon (기본 24h)POST /flood/predict
monitor-river--river-id, --thresholdGET /monitoring/rivers/{id}
monitor-dam--dam-idGET /monitoring/dams/{id}
create-alert--region, --level (기본 LEVEL-3), --messagePOST /alert/create
get-alerts--regionGET /alert/active
evacuation-route--location, --destination-typeGET /evacuation/routes
shelter-status--regionGET /evacuation/shelters
dashboard--regionGET /monitoring/dashboard
kpi--region, --start-date, --end-dateGET /analytics/kpi

전형적인 운영자 세션은 다음과 같다. 출력된 값은 예시이며, 제3장 예시와 같은 관측소 기준 수위 사다리를 썼다(attention 3.5 m, warning 4.5 m, danger 5.0 m). 4.0 m는 관측소 기준 수위가 아니라 운영자가 지정한 --threshold 옵션 값이다.

$ export WIA_API_ENDPOINT="https://flood.example.go.kr"   # 오리진만
$ ./cli/flood-prediction.sh config                         # 입력을 받아
                                                           # ~/.wia/flood-prediction.conf (권한 600) 에 저장
$ ./cli/flood-prediction.sh monitor-river --river-id HAN-001 --threshold 4.0
[ℹ] Monitoring river: HAN-001

Current Level: 4.2m
Warning Level: 4.5m
Danger Level: 5.0m
Status: attention

[⚠] ⚠️  Water level (4.2m) exceeds threshold (4.0m)!
{ ...응답 JSON 전체... }

$ ./cli/flood-prediction.sh get-alerts --region REG-EXAMPLE-001
$ ./cli/flood-prediction.sh kpi --region REG-EXAMPLE-001 \
      --start-date 2026-07-01T00:00:00Z --end-date 2026-07-31T23:59:59Z

CLI는 기본 동작 점검(스모크 테스트)이나 터미널 앞의 운영자에게 유용하지만, v1.0 상태로는 실제 경보 발령에 써서는 안 된다. 스크립트를 읽어 보면 이유가 드러난다.

모두 몇 줄씩이면 고칠 수 있다. 좌표를 옵션으로 받고, heredoc 대신 jq -n --arg로 JSON을 만들고, 헤더는 -H @file이나 표준 입력으로 읽는 설정 파일을 통해 curl에 넘기고, -w로 상태 코드를 받고, 안내만 있는 명령을 지우면 된다. 그 전까지 CLI는 주로 읽기용 진단 도구로 취급한다.

이 장의 정리

WIA-ENE-033 v1.0의 인터페이스는 층으로 나누어 보면 가장 잘 이해된다. PHASE-2는 어휘를 제공한다. 다섯 개의 오퍼레이션 그룹, OAuth 기반 인증, RFC 9457 오류, RFC 8615 디스커버리, 서명된 매니페스트, CAP 1.2 의무가 그것이다. SDK는 코드가 실제로 쓰는 경로와 타입을 제공한다. CLI와 README는 같은 호출을 얇게 감싼, 부분적으로 서로 어긋나는 래퍼다. 감사를 견디려는 배포라면 열린 쟁점을 명시적으로 정리해야 한다. 버전을 뺀 오리진 기준 URL, 전송 계층의 순수 리소스 객체, 문서화된 페이지 시작 번호, 경보 생성의 멱등 전략, 매니페스트에서 연결한 CAP 피드, 그리고 gaugeIngest를 비롯해 제공하지 않는 기능에 대한 솔직한 partial·excluded 표시가 그것이다. 제5장은 전송에서 예측 노드 내부로 시선을 옮겨, 모델과 앙상블, 그리고 예측이 경보로 바뀌는 판정 규칙을 다룬다.