제3장은 WIA-ENE-057 레코드가 저장된 상태에서 어떤 모양인지를 정의하였다. 이 장은 움직이는 레코드를 다룬다. 현장 조사팀, 위성 처리 파이프라인, 대시보드가 관측값을 제출하고, 사막화 위험 평가를 요청하고, 복원 계획을 관리하고, 경보를 구독하고, UNCCD 형식의 보고서를 요청하는 통로가 바로 PHASE-2 REST 인터페이스이다. 이 장은 혼동하기 쉬운 두 문서를 함께 읽는다. spec/PHASE-2-API.md는 리소스와 요청 본문, 응답을 기술한다. spec/PHASE-2-API-INTERFACE.md는 그 바깥을 감싸는 계층, 즉 서명된 매니페스트, 디스커버리 문서, 적합성 등급, 보안 기준선을 기술하며, 이 계층이 있어야 제3자가 구현체가 실제로 무엇을 지원하는지 확인할 수 있다.
읽는 태도는 이 책의 다른 장과 같다. 명세의 JSON 예시는 적힌 그대로, 즉 공통 예시 위치 SAHEL-REGION-001을 사용하여 인용하지만, 그 안의 모든 숫자는 예시 값이며 말리나 다른 어느 지역의 실측값이 아니다. 예시가 내부적으로 서로 맞지 않는 경우에는 그 사실을 밝히고 계산 과정을 보인다. 스스로 재현할 수 없는 숫자를 내놓는 API야말로 데이터 표준이 없애려는 문제이기 때문이다. 함께 보면 좋은 시뮬레이터 탭은 두 개이다. 이 장 아래에 연결된 세 번째 탭(「3. 프로토콜」)은 핵심 엔드포인트와 WebSocket 시연을 보여 주고, 두 번째 탭(「2. 알고리즘」)에는 4.4절에서 다루는 위험 계산기가 있다.
PHASE-2의 모든 리소스는 버전이 붙은 접두 경로 /api/v1 아래에 있다. 경로의 버전은 인터페이스의 메이저 버전이다. 버전 규칙상 기존 클라이언트를 깨뜨려서는 안 되는 마이너·패치 변경은 접두 경로를 바꾸지 않는다. 명세 머리말은 api.wia-ene-057.org 호스트의 기본 URL을 제시하고 뒤쪽 예시 일부도 같은 호스트를 쓰지만, 이는 배포 위치를 보여 주기 위한 예시일 뿐 실제로 운영되는 공개 서비스가 아니며, 이 장은 그 호스트가 존재한다고 가정하지 않는다. 실제 배포에서는 자기 플랫폼의 오리진 뒤에 접두 경로를 붙인다. 예를 들면 https://platform.example.org/api/v1과 같다.
리소스는 모니터링, 평가, 복원, 위치, 경보, 보고서의 여섯 묶음으로 나뉜다. 위성 래스터 수집과 분할 대량 업로드도 같은 접두 경로와 인증을 쓰므로 표에 함께 넣었지만, 전송 수준의 동작은 PHASE-3에 속하므로 제5장에서 다룬다.
| 메서드 | 경로 (/api/v1 아래) | 용도 | 성공 코드 |
|---|---|---|---|
| POST | /monitoring/vegetation | 위치별 NDVI/EVI/LAI 관측값 제출 | 201 |
| GET | /monitoring/vegetation/{locationId}/trend | 한 지표의 시계열과 통계(period, metric) | 200 |
| GET | /monitoring/vegetation?page=&limit= | 식생 레코드 페이지 조회 | 200 |
| POST | /monitoring/soil | 토양 수분 단면, 화학 성분, 황폐화 표지 제출 | 201 |
| GET | /assessment/desertification/{locationId} | 현재 사막화 위험 점수 계산 또는 반환 | 200 |
| POST | /assessment/desertification/batch | 여러 위치를 한 번에 평가하고 요약 반환 | 200 |
| POST | /restoration/plan | 위치에 대한 복원 계획 생성 | 201 |
| PUT | /restoration/plan/{planId}/progress | 계획 대비 진척 보고 | 200 |
| POST | /locations | 모니터링 위치와 기준선 등록 | 201 |
| GET | /locations/{locationId} | 현재 상태, 진행 중 사업, 집계값 조회 | 200 |
| POST | /alerts/subscribe | 경보 구독 생성(웹훅 및/또는 이메일) | 201 |
| POST | /reports/unccd-ldn | 토지황폐화중립 보고서 패키지 생성 | 200 |
| POST | /ingest/satellite | 메타데이터와 함께 GeoTIFF/NetCDF 장면 업로드(제5장) | PHASE-3 참조 |
| POST / GET | /bulk/upload/initiate, …/{uploadId}/chunk, …/complete, …/status | 분할 대량 업로드와 상태 조회(제5장) | PHASE-3 참조 |
표 전체에 세 가지 설계 원칙이 흐른다. 첫째, 위치가 닻이다. 거의 모든 경로가 locationId를 포함하거나 본문에 요구하며, 모니터링·평가·복원 리소스는 모두 등록된 한 장소를 서로 다른 각도에서 보는 창이다. 둘째, 생성은 201, 계산은 200을 돌려준다. 레코드나 계획을 제출하면 고유 식별자를 가진 리소스가 생기지만, 평가나 보고서는 POST로 요청하더라도 계산된 답이다. 셋째, 응답에는 하이퍼미디어 링크가 실린다. 식생 데이터 제출이 성공하면 self, location, trend를 담은 links 객체가 돌아오고, 위치를 등록하면 그 위치의 모니터링·평가 리소스 링크가 돌아온다. PHASE-3는 HATEOAS를 기본 원칙으로 꼽는데, 실무적으로는 클라이언트가 문자열 템플릿으로 URL을 조립하지 않고 서버가 준 링크를 따라가면 된다는 뜻이다. 배포 위치가 옮겨지거나 다음 메이저 버전에서 경로가 바뀔 때 이 차이가 드러난다.
클라이언트를 작성하기 전에 짚어 둘 빈틈이 두 가지 있다. 표에는 DELETE 연산이 없고 개별 토양 레코드를 읽는 GET도 없다. 그러나 PHASE-3는 HTTP 메서드 목록에 DELETE를 넣고 있고, 제5장에서 다룰 이벤트 목록에도 data.deleted 이벤트가 있다. 레코드의 정정이나 철회를 지원하는 구현체라면 이를 명시적으로 노출하고 OpenAPI 문서에 적어야 하며, 클라이언트가 추측하게 두어서는 안 된다. 또 PHASE-2 예시의 레코드 식별자(VEG-20251225-001, SOIL-20251225-001)는 PHASE-1의 패턴(VEG-YYYYMMDD-REGION-NNN, 예: VEG-20251225-SAHEL-001)보다 짧다. 이 책은 PHASE-1 패턴을 따른다. 지역을 담은 식별자는 여러 지역을 운영하는 배포에서도 유일하지만, 짧은 형식은 그렇지 않기 때문이다.
PHASE-2는 두 가지 인증 방식을 제공하며, 둘 다 결국 모든 요청에 Authorization: Bearer … 헤더를 붙이는 것으로 귀결된다.
가장 간단한 방식은 장기 API 키를 베어러 토큰으로 보내는 것이다. 한 기관을 대신해 데이터를 밀어 넣는 현장 서버 한 대에 알맞고, 명세의 모든 요청 예시가 이 방식으로 쓰여 있다. 약점은 스코프가 없다는 점이다. 키를 가진 사람은 키가 폐기될 때까지 키 소유자가 할 수 있는 모든 일을 할 수 있다.
두 번째는 OAuth 2.0 클라이언트 자격 증명 그랜트이다. 기계 클라이언트가 client_id, client_secret, 원하는 스코프를 /oauth/token에 보내면 수명이 짧은 액세스 토큰을 받는다.
POST /oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&client_id=YOUR_CLIENT_ID
&client_secret=YOUR_CLIENT_SECRET
&scope=monitoring:read restoration:write
HTTP/1.1 200 OK
Content-Type: application/json
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "monitoring:read restoration:write"
}
expires_in의 단위는 초이므로 예시 토큰의 수명은 한 시간이다. 올바르게 동작하는 클라이언트는 토큰을 캐시해 두었다가 만료 직전에(마감과 경합하지 않도록 1분 정도 여유를 두고) 갱신하며, 401 응답을 받으면 포기하기 전에 새 토큰을 한 번 발급받아 다시 시도한다. 클라이언트 자격 증명은 서버에만 두어야 하며 모바일 앱이나 브라우저 번들에 넣어서는 안 된다. 사람을 대신해 동작하는 현장 앱은 대화형 흐름으로 토큰을 받아야 하며, 이는 제5장에서 설명한다.
명세가 이름을 붙인 스코프는 monitoring:read와 restoration:write 두 개뿐이다. 「리소스 묶음 뒤에 동작」이라는 패턴을 보여 주기에는 충분하지만 실제 배포에는 부족하다. 위성 장면을 공급하는 협력 기관이 복원 예산을 수정할 수 있어서는 안 되고, 후원 기관 대시보드는 아무것도 제출하지 않고 보고서만 읽을 수 있어야 한다. 아래 표는 같은 패턴을 확장한 실용적인 스코프 구성이며, 표준의 일부가 아니라 이 책의 제안이다.
| 스코프 | 명세 명시 여부 | 허용 범위 |
|---|---|---|
monitoring:read | 명시 | 식생·토양 레코드와 추세 조회 |
monitoring:write | 없음(제안) | 식생·토양 레코드, 위성 장면, 대량 업로드 제출 |
assessment:read | 없음(제안) | 단건·일괄 위험 평가 조회 |
restoration:write | 명시 | 계획 생성과 진척 보고 |
alerts:manage | 없음(제안) | 경보 구독 생성·삭제 |
reports:generate | 없음(제안) | LDN 보고서 패키지 생성·다운로드 |
어떤 구성을 택하든 OpenAPI 문서의 보안 스킴에 목록을 적어 두어야 감사인과 클라이언트 개발자가 같은 목록을 본다. JWT 클레임, 서명 알고리즘, TLS 세션에 대한 토큰 바인딩, 대화형 사용자를 위한 인가 코드 흐름 같은 토큰 자체의 동작은 프로토콜 수준의 주제이므로 제5장에서 다룬다. 다만 명칭에 관한 주의 하나는 여기서 짚어 둔다. 표준 자신의 참조 목록에 등장하기 때문이다. IETF RFC 9700은 OAuth 2.0 보안 모범 사례(Security Best Current Practice)이지 「OAuth 2.1」이 아니다. OAuth 2.1은 이 글을 쓰는 시점에 아직 인터넷 드래프트 단계이다. 자체 문서를 작성할 때에는 그랜트 유형에 대해서는 RFC 6749를, 보안 강화 지침에 대해서는 RFC 9700을 인용하고, 이름을 정확히 적어야 한다.
이 API를 연동할 때 가장 흔한 오류는 서버가 모르는 장소의 데이터를 제출하는 것이다. 명세는 이를 예상하고 전용 오류 코드를 두었다. 등록되지 않은 위치를 지정한 요청은 INVALID_LOCATION으로 실패하며, 오류의 details.suggestion은 「POST /api/v1/locations로 위치를 먼저 등록하라」고 안내한다. 따라서 작업 순서는 고정되어 있다. 등록하고, 제출하고, 읽는다.
등록은 식별자, 사람이 읽을 이름, WGS84 기준 대표 좌표, 면적, 행정 구역 계층, 기준선을 선언한다. 예시의 기준선 블록(vegetationCover, soilHealth, desertificationRisk, baselineYear)은 이후 추세와 LDN 계산이 비교 대상으로 삼는 값이므로, 명세의 짧은 예시가 풍기는 인상보다 훨씬 신중하게 다루어야 한다. 기준선 값마다 출처와 산출 방법을 기록해 두어야 한다. 다른 센서나 다른 위험 모델에서 가져온 기준선을 쓰면 이후의 모든 「변화」가 부분적으로는 그 교체가 만든 허상이 된다.
등록 본문이 대표점 하나(latitude 14.5, longitude −4.0)와 면적 10,000 ha만 담는다는 점도 눈여겨보아야 한다. 대표점과 면적만으로도 목록 관리는 되지만, 위성 래스터를 잘라 내거나 대상지 전체의 평균 NDVI를 계산하기에는 부족하다. 래스터를 처리하는 배포라면 경계 지오메트리도 받아야 한다. 제5장에서 설명하듯 경도·위도 순서의 GeoJSON 폴리곤이 적당하며, 대표점은 라벨 위치로 남겨 둔다.
README는 @wia/desertification-prevention 패키지의 한 줄짜리 SDK 호출 client.submitRecord(record)를 보여 준다. 그러나 이 패키지는 배포되어 있지 않고, README가 가리키는 api/typescript/ 디렉터리도 저장소에 없다. 그래서 아래 예시는 플랫폼 기본 fetch만 사용한다. 공통 예시 위치를 등록하고, 식생 관측값 하나를 제출하고, 90일 NDVI 추세를 읽고, 위험 평가를 요청한다. 값은 명세의 예시 값이다.
// wia-ene-057-client.ts — fetch 기반 최소 클라이언트 (Node 18+ 또는 브라우저)
const BASE = process.env.WIA_BASE_URL ?? "https://platform.example.org/api/v1";
const TOKEN = process.env.WIA_TOKEN!;
interface WiaErrorBody {
error: { code: string; message: string; details?: Record<string, unknown>;
timestamp?: string; requestId?: string };
}
export class WiaApiError extends Error {
constructor(public status: number, public code: string,
public requestId: string | undefined, public details: unknown) {
super(`${status} ${code}`);
}
}
async function call<T>(method: string, path: string, body?: unknown): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
method,
headers: {
"Authorization": `Bearer ${TOKEN}`,
"Accept": "application/json",
...(body ? { "Content-Type": "application/json" } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
const json = text ? JSON.parse(text) : undefined;
if (!res.ok) {
const e = (json as WiaErrorBody | undefined)?.error;
throw new WiaApiError(res.status, e?.code ?? "UNKNOWN", e?.requestId, e?.details);
}
return json as T;
}
export async function run(): Promise<void> {
// 1. 위치 등록(최초 1회)
try {
await call("POST", "/locations", {
locationId: "SAHEL-REGION-001",
name: "Sahel Test Region",
coordinates: { latitude: 14.5, longitude: -4.0, datum: "WGS84" },
area: { size: 10000, unit: "hectares" },
baseline: { vegetationCover: 45.2, soilHealth: 38.5, baselineYear: 2025 },
});
} catch (err) {
// 재실행 시 409 형태의 "이미 등록됨" 응답은 정상으로 본다.
if (!(err instanceof WiaApiError) || err.status !== 409) throw err;
}
// 2. 식생 관측값 제출(예시 값)
const veg = await call<{ data: { recordId: string }; links: Record<string, string> }>(
"POST", "/monitoring/vegetation", {
locationId: "SAHEL-REGION-001",
timestamp: "2025-12-25T10:00:00Z",
sensorData: { ndvi: 0.35, evi: 0.28, lai: 1.2 },
source: "MODIS",
resolution: "250m",
quality: { cloudCover: 5, confidence: 0.92 },
});
console.log("stored", veg.data.recordId, "trend at", veg.links.trend);
// 3. 90일 NDVI 추세 조회
const trend = await call<{ statistics: { trend: string; changeRate: number } }>(
"GET", "/monitoring/vegetation/SAHEL-REGION-001/trend?period=90d&metric=ndvi");
console.log("trend", trend.statistics.trend, trend.statistics.changeRate);
// 4. 현재 위험 평가 요청
const risk = await call<{ riskScore: number; riskCategory: string }>(
"GET", "/assessment/desertification/SAHEL-REGION-001");
console.log("risk", risk.riskScore, risk.riskCategory);
}
이 클라이언트의 두 가지 세부 사항은 의도된 것이다. 오류 처리부는 명세의 오류 봉투를 읽고 requestId를 보존한다. 운영자가 서버 로그에서 실패한 요청을 찾으려면 바로 그 식별자가 필요하기 때문이다. 그리고 등록 단계는 재실행 시의 충돌 응답을 허용한다. 명세의 상태 코드 표에는 409 Conflict가 없으므로 중복 locationId에 서버가 무엇을 돌려줄지는 열려 있다. 구현체는 한 가지 동작을 정해(명시적 코드와 함께 409를 돌려주는 것이 관례적인 선택이다) 문서화해야 한다. 그러지 않으면 클라이언트는 무해한 재등록을 치명적 오류로 처리하게 된다.
추세 예시는 월별 NDVI 값 세 개(0.45, 0.40, 0.35)와 함께 mean 0.40, min 0.35, max 0.45, trend "declining", changeRate −0.033, significance 0.95를 돌려준다. 평균·최솟값·최댓값은 세 점에서 바로 나온다. 변화율은 총 0.10의 하락을 3개월(90일) 창에 나눈 값, 즉 −0.10 ÷ 3 ≈ 월 −0.033과 일치하지만, 응답은 「월당」이라고 밝히지 않는다. 날짜가 붙은 세 점에 기울기를 적합하면 월 약 −0.05가 된다. significance가 무엇인지도 밝히지 않는다. 신뢰수준인지, p값의 여수인지, 특정 추세 검정의 출력인지 알 수 없다. 게다가 점 세 개로는 어떤 추세 검정도 큰 의미를 갖기 어렵다. 따라서 구현체는 API 문서에, 가능하면 응답 자체에 changeRate의 단위와 significance의 산출 방법 및 의미를 밝혀야 한다. 적절한 추세 기법과, 하락을 황폐화라고 부르기 전에 계절성과 강수를 고려해야 하는 이유는 제2장에서 다루었다.
평가 리소스는 표준이 데이터를 기술하는 데서 멈추지 않고 결론을 내리기 시작하는 지점이다. 따라서 이 장에서 가장 꼼꼼히 읽어야 할 부분이다.
GET /assessment/desertification/{locationId}는 종합 riskScore, riskCategory, 그리고 네 항목으로 이루어진 factors 객체를 돌려준다. 각 항목에는 score, weight, status, 그리고 판단 근거가 된 metrics가 들어 있다. 예시의 가중치는 식생 0.30, 토양 0.25, 기후 0.25, 인간 활동 0.20으로 고정되어 있고 합은 1.0이다. 각 요인 점수는 0~100 사이이며 높을수록 나쁘다. 상태가 degraded인 식생 점수 35와 상태가 high-pressure인 인간 활동 점수 90이 나란히 놓인 것이 그 예이다. 가중치를 의미 있게 만드는 자연스러운 해석은, 그리고 사실상 유일한 해석은 가중합이다.
R = Σ wᵢ · sᵢ 단, Σ wᵢ = 1, 0 ≤ sᵢ ≤ 100 ⇒ 0 ≤ R ≤ 100
w = { vegetation: 0.30, soil: 0.25, climate: 0.25, humanActivity: 0.20 }
이 식을 명세 자체의 요인 점수에 적용하면 다음과 같다.
| 요인 | 점수 sᵢ (예시) | 가중치 wᵢ | wᵢ · sᵢ |
|---|---|---|---|
| 식생 | 35.0 | 0.30 | 10.50 |
| 토양 | 72.0 | 0.25 | 18.00 |
| 기후 | 85.0 | 0.25 | 21.25 |
| 인간 활동 | 90.0 | 0.20 | 18.00 |
| 가중합 R | 67.75 | ||
그런데 예시 응답은 riskScore: 68.5를 출력하며, 같은 68.5가 위치 등록 기준선, 위치 상태, 일괄 평가에도 다시 나온다. 0.75의 차이는 반올림으로 설명되지 않는다. 67.75는 67.8이나 68로 반올림될 수는 있어도 68.5가 되지는 않는다. 명세의 모델에 드러나지 않은 항이 있거나, 예시 숫자가 요인들과 무관하게 적혔거나 둘 중 하나이다. 구현자에게 주는 교훈은 실무적이다. API가 돌려주는 종합 점수는 함께 돌려주는 요인들로부터 재현할 수 있어야 한다. 적합성 시험은 응답 본문에서 Σ wᵢ·sᵢ를 다시 계산하여, 보고된 점수와의 차이가 선언된 반올림 폭을 넘으면 실패로 판정해야 한다. 구성 요소로 다시 계산할 수 없는 점수를 본 감사인이나 후원 기관은 보고서의 나머지 부분도 믿을 이유가 없다.
가중합은 쉬운 부분이다. 더 어려운 세 부분은 명세에 없으므로 구현체가 결정하고 공개해야 한다.
ndvi 0.35와 cover 45.2를 지표로 제시하고 35라는 점수에 이르며, 기후 요인은 강수량, 평년 대비 편차 −23.6 %, 가뭄 지수 −1.8을 제시하고 85에 이른다. 정규화 함수는 주어지지 않는다. NDVI를 지역 기준값에 맞춰 척도화하는지, 대상지의 장기 최댓값에 맞추는지, 고정 범위에 맞추는지에 따라 답이 완전히 달라진다. 함수와 기준 데이터, 그 버전을 공개해야 한다. (제2장에서 보았듯 SPI −1.8은 McKee 분류의 「심한 건조(severely dry)」 등급에 해당하므로, 명세의 다른 곳에서 이 값을 중간 정도 가뭄으로 표기한 것을 따라 해서는 안 된다.)medium-risk, high-risk, critical을 붙이지만, 명세는 그 사이의 경계값을 어디에서도 정의하지 않는다. 이 다섯 예시와 들어맞는 구간 설정은 얼마든지 있다. 구현체는 경계값을 설정으로 공개하고 버전을 매기며, 평가마다 경계값 세트의 버전을 함께 돌려주어야 한다. 그래야 구간 변경이 현장의 변화로 오인되지 않는다.projections 블록은 1년·5년 뒤 점수(예시에서는 72.3과 81.5)를 제시한다. 이는 구현체가 돌리는 예측 모델의 출력이며, 가정한 강수·방목·개입 시나리오에 좌우된다. 기대 결과로 제시할 것이 아니라 가정과 함께 모델 전망값으로 표시해야 한다.동반 시뮬레이터는 앞의 두 점을 구체적으로 보여 준다. 위험 계산기(「2. 알고리즘」 탭)는 같은 네 가중치를 쓰지만 정규화는 자체 방식을 쓴다. 식생 점수는 (1 − NDVI) × 100이고, 토양 수분은 30 %를, 연 강수량은 600 mm를 기준으로 점수화하며, 토지 이용 강도는 고정값으로 대응시킨다. 기본 입력값(NDVI 0.35, 토양 수분 12.5 %, 강수량 325 mm, 토지 이용 보통)에서 요인 점수는 65.0, 58.3, 45.8, 30이고 가중합은 약 51.5이며, 시뮬레이터는 30과 60을 경계로 하는 자체 구간에 따라 이를 MEDIUM으로 표시한다. 같은 NDVI 0.35에 대해 명세 예시는 식생 점수 35를, 시뮬레이터는 65를 매긴다. 교육용 장치로서는 어느 쪽도 틀렸다고 할 수 없다. 다만 둘을 나란히 놓으면, 가중치가 똑같은 두 구현체도 결과가 크게 갈릴 수 있으며 정규화 규칙과 구간 표 없이 점수만으로는 아무 의미가 없다는 점이 드러난다.
아래 함수는 표준이 고정한 부분을 구현하고, 고정하지 않은 부분은 명시적인 입력으로 드러낸다. 예시 설정의 경계값은 설명을 위해 임의로 정한 자리표시 값이며, WIA-ENE-057이 정의한 값이 아니다.
// risk-score.ts — 가중 사막화 위험 점수
type Factor = "vegetation" | "soil" | "climate" | "humanActivity";
export const WEIGHTS: Record<Factor, number> = {
vegetation: 0.30, soil: 0.25, climate: 0.25, humanActivity: 0.20,
};
// 구현체가 정하는 값. 예시일 뿐이며 WIA-ENE-057의 일부가 아니다.
export interface Bands { version: string; bands: { min: number; label: string }[] }
export const EXAMPLE_BANDS: Bands = {
version: "example-bands-0.1",
bands: [ // 가장 높은 min부터 차례로 검사
{ min: 80, label: "critical" },
{ min: 60, label: "high-risk" },
{ min: 40, label: "medium-risk" },
{ min: 0, label: "low-risk" },
],
};
export function riskScore(scores: Record<Factor, number>,
weights: Record<Factor, number> = WEIGHTS): number {
const sumW = Object.values(weights).reduce((a, b) => a + b, 0);
if (Math.abs(sumW - 1) > 1e-9) throw new Error(`weights sum to ${sumW}, not 1`);
let r = 0;
for (const f of Object.keys(weights) as Factor[]) {
const s = scores[f];
if (!(s >= 0 && s <= 100)) throw new Error(`${f} score out of range: ${s}`);
r += weights[f] * s;
}
return Math.round(r * 100) / 100; // 소수 둘째 자리 — 문서에 선언
}
export function categorise(r: number, cfg: Bands = EXAMPLE_BANDS) {
const band = cfg.bands.find(b => r >= b.min)!;
return { category: band.label, bandsVersion: cfg.version };
}
// 명세 예시 요인 → 67.75 (명세는 68.5를 출력 — 본문 참조)
const r = riskScore({ vegetation: 35, soil: 72, climate: 85, humanActivity: 90 });
console.log(r, categorise(r));
POST /assessment/desertification/batch는 locationIds 목록과 옵션(includeProjections, 1·5·10년 같은 projectionsYears)을 받아 assessmentId, 위치별 결과 목록, summary를 돌려준다. 예시의 세 점수는 68.5, 52.3, 82.1이고 평균은 202.9 ÷ 3 ≈ 67.63이므로 보고된 averageRisk 67.6과 맞는다. 요약은 또 highRiskCount 2와 criticalCount 1을 보고하는데, 실제로 high-risk로 표시된 위치는 하나, critical은 하나뿐이다. 개수 2는 「고위험」이 「고위험 이상」을 뜻할 때에만 성립한다. 합리적인 관례이지만 반드시 문서로 밝혀야 한다. 그렇지 않으면 같은 요약을 읽는 두 대시보드가 서로 다른 막대그래프를 그린다. 위치 목록이 길 경우 구현체는 목록 크기에 상한을 두고 이를 밝히거나, 202 Accepted를 돌려준 뒤 제5장에서 설명할 assessment.completed 이벤트로 완료를 알려야 한다.
복원은 위치에 연결된 계획 하나와, 그 계획에 대한 일련의 진척 보고로 모델링된다. POST /restoration/plan은 위치, 사업명, 수행 기관, 면적, soil-stabilization·vegetation-recovery 같은 목표, 제약 조건(예산, 기간, 물 가용성), 선호 사항(자생종, 지역 주민 참여, 탄소 격리)을 받는다. 응답은 planId와 단계별 전략을 돌려준다. 반달형 집수구·돌둑·빗물 집수 구조물을 이용한 토양 정비와 물 보전, Acacia senegal·Balanites aegyptiaca 같은 자생종 식재, 그리고 모니터링과 유지 관리가 그 단계이며, 기대 성과·위험 완화책·모니터링 계획이 함께 실린다.
예시의 단계별 비용(100,000 + 250,000 + 150,000)은 명시된 예산 500,000과 정확히 맞는다. 서버가 계획을 돌려주기 전에 해야 할 내부 검증이 바로 이런 것이다. expectedOutcomes 블록은 사정이 다르다. 투자수익률, 회수 기간, 일자리 수, 탄소 수치는 예시 값이며, 이를 어떻게 계산하는지 표준은 정의하지 않는다. 이런 수치를 돌려주는 플랫폼은 산출 방법을 보이거나 아예 빼야 한다. 탄소는 특히 그렇다. 예상 톤수가 발행 가능한 크레딧과 같지 않은 이유는 제6장에서 설명한다.
계획 리소스와 PHASE-1의 restorationProject 레코드는 서로 겹치는 대상을 다른 식별자로 기술한다. API 예시는 REST-PLAN-20251225-001을 부여하지만, PHASE-1 레코드와 위치의 activeProjects 목록은 REST-2025-SAHEL-001을 쓴다. 깔끔한 구현은 계획을 기획 산출물로, 사업 레코드를 운영 레코드로 보고 둘을 명시적으로 연결한다. 예를 들어 사업 레코드에 planId를 저장하고, 계획의 links에 사업 식별자를 돌려준다. 필드 대응은 단순하다. 계획의 단계는 사업의 일정과 마일스톤에, 계획된 수종은 interventions.reforestation.species[]에, 토양·물 활동은 soilConservation.measures[]와 waterManagement.techniques[]에, 예산은 사업의 funding 블록에 대응하며, 이 블록의 재원 합계는 총예산과 같아야 한다.
PUT /restoration/plan/{planId}/progress는 날짜, 현재 단계, 완료율, 활동별 수치(예: 식재 수와 생존율, 목표 대비 조성한 반달형 집수구 수), 성과 지표, 애로 사항을 받는다. 서버는 전체 진척률, onTrack 플래그, 다음 마일스톤으로 답한다.
HTTP는 PUT을 멱등 메서드로 정의한다. 같은 요청을 두 번 보내도 한 번 보낸 것과 서버 상태가 같아야 한다. 진척 보고는 연결이 불안정한 현장 사무소에서 오는 경우가 많고, 응답을 받지 못한 클라이언트는 재시도하므로 이 성질은 매우 유용하다. 그러나 서버가 각 보고를 증분이 아니라 잘 정의된 상태의 대체로 다룰 때에만 성립한다. 다음 두 규칙이 이를 보장한다.
(planId, phase, date)마다 진척 보고를 하나씩 저장하고, 같은 키의 두 번째 PUT은 첫 번째를 대체한다. 정정은 그 날짜의 보고를 다시 보내는 방식으로 한다.현장 담당자와 본부 관리자가 같은 보고를 동시에 고치는 경우에는 HTTP 조건부 요청을 쓴다. 서버는 저장된 보고마다 ETag를 돌려주고, 클라이언트는 이를 If-Match에 담아 보낸다. 그사이 다른 사람이 보고를 바꿨다면 서버는 조용히 덮어쓰지 않고 412 Precondition Failed로 답한다. 멱등이 아닌 POST 엔드포인트(계획을 두 번 만들면 계획이 둘 생긴다)에는 클라이언트가 생성한 멱등 키 헤더를 받아, 같은 키가 반복되면 처음 결과를 돌려주도록 한다. IETF에는 Idempotency-Key 헤더 필드에 관한 인터넷 드래프트가 있다. 어떤 헤더 이름을 쓰든 문서화해야 한다.
POST /alerts/subscribe는 하나 이상의 위치에 대한 관심을 등록한다. 구독자는 rapid-vegetation-decline, critical-soil-moisture, drought-alert, erosion-risk의 네 가지 경보 유형 중에서 고르고, 심각도로 거르며(예: high와 critical만), 전달 방법으로 웹훅 URL, 이메일 주소 또는 둘 다를 지정한다. 응답은 SUB-20251225-001 같은 subscriptionId를 돌려주고, 감시 대상 위치와 경보 유형의 수를 되짚어 준다.
웹훅 페이로드는 PHASE-2 문서에서 내부 정합성이 가장 좋은 예시이며, 경보에 무엇이 담겨야 하는지 보여 주는 좋은 본보기이다.
{
"alertId": "ALERT-20251225-001",
"subscriptionId": "SUB-20251225-001",
"timestamp": "2025-12-25T10:30:00Z",
"severity": "high",
"type": "rapid-vegetation-decline",
"location": { "locationId": "SAHEL-REGION-001", "name": "Sahel Test Region",
"coordinates": { "latitude": 14.5, "longitude": -4.0 } },
"details": {
"metric": "ndvi",
"currentValue": 0.35,
"previousValue": 0.45,
"change": -0.10,
"changePeriod": "90 days",
"threshold": -0.05
},
"message": "NDVI decreased by 22% over 90 days, exceeding alert threshold"
}
details의 모든 숫자는 검산할 수 있다. 0.35 − 0.45 = −0.10이고, −0.10 ÷ 0.45 ≈ −22 %이며, 절대 변화량 −0.10은 임계값 −0.05를 넘는다. 경보를 실행 가능하게 만드는 것이 바로 이 점이다. 수신자는 지표, 비교한 두 값, 기간, 발동한 규칙을 보고, 하락이 실제 신호인지 아니면 예컨대 우기가 끝나면서 생기는 정상적인 변화인지 판단할 수 있다. 「식생 감소」라고만 적힌 경보를 받으면 수신자는 무슨 일이 있었는지 재구성하려고 API를 다시 뒤져야 한다. 경보 유형을 새로 설계할 때에도 지표, 현재값과 기준값, 변화량, 기간, 임계값이라는 같은 모양을 유지하고 임계값의 단위를 밝혀야 한다.
message 필드는 사람을 위한 문장이며, 클라이언트는 구조화된 필드에 따라 동작해야 한다. 경보에도 여느 웹훅과 같은 전달 보장이 필요하다. 중복 제거를 위한 고정 alertId, 수신 측이 내려가 있을 때의 백오프 재시도, 수신 측이 발신자를 검증할 수 있는 서명이 그것이다. 이는 프로토콜 계층의 주제이며, 제5장에서 X-WIA-Signature 헤더와 원본 요청 본문에 대한 타이밍 안전 비교로 이를 검증하는 방법을 보인다. 구독 쪽에서는, 서버가 구독을 활성화하기 전에 웹훅 URL에 실제로 도달할 수 있는지(예: 시험 전송으로) 확인하고, 구독을 조회·삭제하는 방법을 제공하는 것이 바람직하다. 명세에는 아직 이 부분이 없다.
POST /reports/unccd-ldn은 위치 집합, 보고 기간, 보고 유형(예: annual), 출력 형식, 전망 포함 여부를 받는다. 응답에는 위치마다 baseline과 current 블록이 있고, 각 블록에는 LDN의 세 하위 지표인 landCover, productivity, carbonStocks가 들어 있으며, 생성된 패키지의 downloadUrl과 expiresAt 시각이 함께 실린다.
세 하위 지표의 선택은 옳다. 토지 피복, 토지 생산성, 토양유기탄소 저장량은 제1장에서 소개한 SDG 지표 15.3.1의 구성 요소이다. 그러나 응답의 ldnStatus 블록이 보고하는 achieved, progress, neutralityGap 값은 UNCCD의 개념이 아니다. UNCCD 모범 사례 지침에 따르면 세 하위 지표 중 어느 하나라도 기준선 대비 유의한 부정적 변화를 보이면 그 지역은 황폐화된 것으로 판정되며(「하나라도 나쁘면 전체가 나쁘다(one out, all out)」 원칙), 중립 달성 여부는 하나의 진척률이 아니라 황폐화 면적과 개선 면적을 비교하여 평가한다. 플랫폼이 자체 대시보드를 위해 내부 진척 지표를 계산하는 것은 물론 가능하지만, 외부로 보내는 보고서 패키지는 세 하위 지표와 one-out-all-out 판정 결과를 제시하고, 그 밖의 값은 플랫폼 고유 지표라고 표시해야 한다.
downloadUrl은 일종의 권한 증표로 다루어야 한다. 링크를 가진 사람은 누구나 expiresAt까지 패키지를 내려받을 수 있기 때문이다. 수명이 짧은 서명 URL로 발급하고, 가능하면 요청 기관에 묶으며, 다운로드마다 기록을 남긴다. 규모가 큰 보고서도 같은 202 Accepted 비동기 패턴에 잘 맞는다.
PHASE-2의 모든 오류는 같은 봉투를 쓴다. 기계가 읽는 code, 사람이 읽는 message, 자유 형식의 details 객체, timestamp, requestId를 담은 error 객체이다.
HTTP/1.1 404 Not Found
Content-Type: application/json
{
"error": {
"code": "INVALID_LOCATION",
"message": "Location ID 'INVALID-001' not found",
"details": {
"locationId": "INVALID-001",
"suggestion": "Register location first using POST /api/v1/locations"
},
"timestamp": "2025-12-25T10:00:00Z",
"requestId": "req-abc123"
}
}
계약은 code이며 클라이언트는 이 값으로 분기한다. message는 버전에 따라 바뀌거나 번역될 수 있으므로 어떤 클라이언트도 이를 파싱해서는 안 된다. 명세의 부속서 E는 필드의 이름 변경이나 삭제를 「오류 페이로드에서조차」 버전 올림이 필요한 변경으로 본다. 즉 구현체가 돌려주는 오류 코드는 인터페이스의 일부이며 OpenAPI 문서에 들어가야 한다.
API-INTERFACE 문서와 README는 모두 IETF RFC 9457 Problem Details for HTTP APIs를 인용한다. 이 RFC는 미디어 타입 application/problem+json과 type, title, status, detail, instance 멤버, 그리고 확장 멤버로 이루어진 표준 오류 형식을 정의한다. 두 형식은 공존할 수 있다. 실용적인 방법은 호환성을 위해 기본으로는 WIA 봉투를 제공하고, 클라이언트의 Accept 헤더가 application/problem+json을 요청하면 WIA 필드를 확장 멤버로 담은 Problem Details를 돌려주는 것이다.
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
{
"type": "https://platform.example.org/problems/invalid-location",
"title": "Location not registered",
"status": 404,
"detail": "Location ID 'INVALID-001' not found",
"instance": "/api/v1/monitoring/vegetation",
"code": "INVALID_LOCATION",
"requestId": "req-abc123",
"suggestion": "Register location first using POST /api/v1/locations"
}
명세는 여덟 개의 상태 코드를 나열한다. 아래 표는 이 장의 작업 흐름에 필요하지만 명세에는 없는 코드를 덧붙인 것이며, 덧붙인 코드는 권고로 표시하였다.
| 상태 코드 | PHASE-2 명시 여부 | 이 API에서의 전형적 용도 |
|---|---|---|
| 200 OK | 명시 | 조회, 평가, 진척 보고, 동기식 보고서 |
| 201 Created | 명시 | 새 레코드, 위치, 계획, 구독 |
| 202 Accepted | 없음(권고) | 비동기로 처리하는 대규모 일괄 평가와 국가 단위 보고서 |
| 400 Bad Request | 명시 | 검증 실패(예: NDVI가 −1…1 범위 밖) |
| 401 Unauthorized | 명시 | 토큰 누락·무효·만료 |
| 403 Forbidden | 명시 | 유효한 토큰이지만 필요한 스코프나 테넌트 권한 없음 |
| 404 Not Found | 명시 | 알 수 없는 레코드·계획·위치(INVALID_LOCATION) |
| 409 Conflict | 없음(권고) | 위치 등록 시 locationId 중복 |
| 412 Precondition Failed | 없음(권고) | 진척 보고의 If-Match 불일치 |
| 429 Too Many Requests | 명시 | 요청 한도 초과, Retry-After 동봉 |
| 500 Internal Server Error | 명시 | 예상치 못한 실패, 항상 requestId 포함 |
PHASE-2는 세 등급(표준 시간당 1,000건, 프로페셔널 10,000건, 엔터프라이즈 무제한)과 세 응답 헤더(X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset)를 기술한다. 이 등급은 호스팅 서비스를 위한 설계 목표이지 측정된 처리 용량이 아니며, 자체 운영 배포는 자기 값을 정한다. 클라이언트를 돕는 보완점이 두 가지 있다. 첫째, 예시는 X-RateLimit-Reset을 ISO 8601 시각으로 주지만 많은 API는 재설정까지 남은 초를 쓴다. 어느 쪽인지 밝혀야 한다. 둘째, 429 응답에는 HTTP 의미론(RFC 9110)에 정의된 표준 Retry-After 헤더도 함께 보낸다. 범용 HTTP 클라이언트와 프록시는 이미 이 헤더를 이해한다. IETF는 표준 RateLimit 헤더 필드에 관한 인터넷 드래프트도 진행 중이며, 이것이 안정화되면 X- 헤더와 함께 채택할 수 있다.
목록 엔드포인트는 page와 limit을 받아 pagination 블록(page, limit, totalPages, totalRecords, hasNext, hasPrevious)과 처음·이전·다음·마지막 페이지의 links를 돌려준다. 예시는 정합적이다. 레코드 487건을 페이지당 50건으로 나누면 10페이지이다. 클라이언트는 페이지 번호를 직접 계산하지 말고 links.next가 없어질 때까지 따라가야 한다. 모니터링 데이터는 계속 덧붙여지므로 클라이언트가 페이지를 넘기는 동안 페이지 번호가 밀릴 수 있다. 대량 내보내기에는 안정적인 정렬(타임스탬프와 레코드 ID)에 묶인 불투명 커서를 추가하는 것이 좋다.
앞의 리소스들은 구현체가 무엇을 할 수 있는지를 말한다. API-INTERFACE 문서는 구현체가 실제로 무엇을 지원하는지를 검증 가능한 방식으로 밝히는 장치를 더한다. WIA 적합 배포와 예시를 흉내 낸 API를 가르는 것이 바로 이 장치이다.
API-INTERFACE §4는 디스커버리 문서를 well-known URI(RFC 8615) /.well-known/wia/desertification-prevention에 둔다. 이 문서는 지원하는 연산 묶음, OpenAPI 문서의 URL, 매니페스트 서명 키를 선언하며, 그 자신도 같은 Sigstore 키로 서명된다. 배포의 오리진만 아는 클라이언트나 감사인도 이 문서 하나를 가져와 나머지를 모두 알아낼 수 있다. 명세는 위치와 필수 내용은 정하지만 정확한 필드 이름은 정하지 않는다. 최소한의 문서는 다음과 같을 수 있다(필드 이름은 예시).
GET /.well-known/wia/desertification-prevention
{
"standard": "WIA-ENE-057",
"standardSlug": "desertification-prevention",
"apiBase": "https://platform.example.org/api/v1",
"operationGroups": ["monitoring", "assessment", "restoration",
"locations", "alerts", "reports"],
"openapi": "https://platform.example.org/openapi.json",
"manifest": "https://platform.example.org/wia/manifest.json",
"signingKey": { "type": "sigstore", "identity": "release@platform.example.org" }
}
RFC 8615는 새 well-known URI 접미사를 IANA에 등록하도록 요구한다. 자체 생태계 밖에서 이 경로에 의존하는 클라이언트를 만들기 전에 wia 접미사의 등록 상태를 확인해야 한다.
API-INTERFACE §2는 정해진 내용을 담은 서명된 매니페스트를 요구한다. 상수 값 "desertification-prevention"을 갖는 standardSlug, Semantic Versioning 2.0.0 형식의 구현 버전, 구현체의 이름·빌드 다이제스트·SBOM URL, 프로파일 이름과 버전, 요구 사항별 지원 상태, 그리고 Sigstore DSSE 서명이다. 매니페스트는 배포가 선언한 주기에 따라 Rekor 투명성 로그에 고정된다. 부속서 I는 각 요구 사항을 supported, partial(사유 포함), excluded(정당화 포함) 중 하나로 표시하도록 덧붙인다. 서명 전의 예시 매니페스트는 다음과 같다.
{
"standardSlug": "desertification-prevention",
"version": "1.0.0",
"implementation": {
"name": "example-land-monitor",
"build": "sha256:<image digest>",
"sbom": "https://platform.example.org/wia/sbom.cdx.json"
},
"profile": { "named": "WIA-P2-API-Edge-only", "version": 2 },
"requirements": [
{ "id": "P2-LOC-REGISTER", "status": "supported" },
{ "id": "P2-ASSESS-BATCH", "status": "partial",
"reason": "max 50 locations per call" },
{ "id": "P2-REPORT-UNCCD", "status": "excluded",
"justification": "reporting done by partner system" }
]
}
이 예시의 요구 사항 식별자는 설명을 위해 지어낸 것이다. 명세는 아직 번호가 매겨진 요구 사항 목록을 공개하지 않았으므로, 당분간 구현체가 규범 문장에서 직접 목록을 도출하고 그 방법을 밝혀야 한다. 사유를 단 「partial」이 중요한 이유도 여기에 있다. 일괄 평가 크기에 상한을 두었거나 LDN 보고서를 생성하지 않는 배포도 충분히 쓸모가 있다. 사용자에게 해가 되는 것은 갖추지 않은 완전 지원을 암시하는 배포이다.
API-INTERFACE §3은 세 등급, 즉 Surface(데이터 형식 수용, 자체 선언), Verified(연 1회 제3자 감사), Anchored(지속적 증거 패키지)를 정의하고, 구현체가 OpenAPI 문서의 x-wia-conformance-tier 확장 필드로 등급을 선언하도록 요구한다. 등급별로 필요한 증거는 제8장에서 다루며, 여기서는 선언이 어디에 놓이는지만 확인한다.
저장소의 api/openapi.yaml은 출발점으로 쓸 수 없다. OpenAPI 3.1.0을 선언하고 범용 /resource 경로 하나만 둔 일곱 줄짜리 자리표시 파일이며, 이 장의 엔드포인트를 하나도 기술하지 않는다. 여러 부속서가 등급에 관해 「부속서 A」를, 시험 벡터에 관해 tests/phase-vectors/를 가리키지만 둘 다 저장소에 없다. 따라서 구현자는 실제 OpenAPI 3.1 문서를 직접 작성해야 한다. 등급 선언과 완전히 기술된 연산 하나를 담은 뼈대는 다음과 같다.
openapi: 3.1.0
info:
title: Example Land Monitor — WIA-ENE-057 PHASE 2
version: 1.0.0
x-wia-conformance-tier: Surface
servers:
- url: https://platform.example.org/api/v1
components:
securitySchemes:
oauth:
type: oauth2
flows:
clientCredentials:
tokenUrl: https://platform.example.org/oauth/token
scopes:
monitoring:read: Read monitoring records and trends
restoration:write: Create plans and report progress
paths:
/assessment/desertification/{locationId}:
get:
operationId: getRiskAssessment
security: [{ oauth: [monitoring:read] }]
parameters:
- { name: locationId, in: path, required: true,
schema: { type: string, pattern: "^[A-Z0-9-]+$" } }
responses:
"200": { description: Risk assessment with factor breakdown }
"404": { description: INVALID_LOCATION }
명세는 이 확장 필드를 info 아래에 둘지 문서 최상위에 둘지 정하지 않는다. OpenAPI는 두 곳 모두에서 x- 확장을 허용한다. 한 곳을 정하고, 디스커버리 문서가 OpenAPI URL을 가리키게 하여 선언을 기계적으로 찾을 수 있게 해야 한다.
API-INTERFACE의 규칙 두 가지가 이 API의 진화와 보호 방식을 더 규정한다. 버전 관리에 관해서는 문서끼리 어긋난다. §6과 부속서 F는 메이저 버전이 바뀔 때 최소 90일의 병행 기간을 요구하지만, 부속서 H는 180일의 병행 창을 기술한다. 이 불일치가 정리될 때까지는 더 엄격한 쪽을 기준으로 삼아, /api/v2가 나온 뒤에도 /api/v1을 최소 180일 운영하고, 제5장에서 다룰 표준 Deprecation·Sunset 응답 헤더로 지원 중단을 알린다. 보안에 관해서는 §7이 전송 구간 TLS 1.3, 저장 데이터 AES-256-GCM 이상, 역할 기반 접근 제어, 변조 탐지가 가능한 감사 로그를 요구한다. PHASE-3는 다른 곳에서 TLS 1.2를 허용하므로, PHASE-2 엔드포인트에는 TLS 1.3을 요구 사항으로 삼고 그 불일치를 배포 정책에 적어 둔다. §7은 배포 정책에 개인정보에 적용되는 규제 체계를 명시하도록 요구하는데, 복원 레코드에는 지역 주민과 토지 보유자의 이름이 들어갈 수 있으므로 이 요구는 이 분야에서도 실제적인 의미가 있다.
locationId에 매달린다. 먼저 등록하고, 제출하고, 돌아온 링크를 따라간다.code로 분기하고 RFC 9457을 제공하며, 디스커버리 문서와 정직한 서명 매니페스트, 실제 OpenAPI 3.1 문서를 공개한다. 저장소의 자리표시 파일은 그런 문서가 아니다.제5장은 리소스 계층 아래로 내려가, 이러한 요청과 이벤트를 실어 나르는 프로토콜을 다룬다. WebSocket 스트리밍, MQTT 센서 토픽, 위성·대량 수집, OGC 서비스, 웹훅 서명, 전송 보안 기준선이 그 내용이다.