제2장과 제3장은 DAC 레코드가 어떤 모양인지를 정의했다. 이 장은 그 레코드가 시스템 사이를 어떻게 오가는지를 다룬다. 설비의 엣지 수집기는 몇 분마다 포집 데이터를 보내야 하고, 저장소 운영사는 주입 실적을 보고해야 한다. 검증기관은 1년치 이력을 한꺼번에 내려받으려 하고, 등록부는 크레딧으로 바꿀 수 있는 검증된 수량을 필요로 한다. 이들이 인터페이스에 바라는 것은 결국 같다. 어디에 있는지 찾을 수 있어야 하고, 누가 호출하는지 증명할 수 있어야 하며, 자원 구성이 예측 가능해야 하고, 문제가 생겼을 때 분명한 답을 받아야 한다.
WIA 표준은 이 인터페이스를 두 곳에 나누어 기술하며, 두 곳은 끝내 하나로 합쳐지지 않았다. PHASE-4-INTEGRATION §2에는 구체적인 API, 즉 OAuth 토큰 엔드포인트, 7개의 REST 연산, GraphQL 스키마, WebSocket 채널이 있다. 내용이 같은 범용 문안을 공유하는 PHASE-1-DATA-FORMAT과 PHASE-2-API-INTERFACE에는 발견(discovery), 적합성 등급, 시간, 토큰 바인딩 규칙이 있다. 배포된 api/openapi.yaml은 어느 쪽도 반영하지 않는다. /resource 경로 하나만 있는 7줄짜리 골격일 뿐이다. 이 장은 클라이언트가 실제로 마주치는 순서대로 조각을 맞춘다. 발견, 인증, REST, GraphQL, WebSocket, 이 모두를 기술해야 할 OpenAPI 문서, 그리고 마지막으로 레코드의 신뢰성을 지탱하는 시간·식별자 규칙이다.
이 장의 모든 호스트 이름은 example.com 주소이다. 명세는 기준 URL을 api.wia.org로 적고 있으나, 이는 공개된 서비스가 아니다. 포집 수치는 모두 제2장의 가상 시설 dac-example-reykjanes-01을 기준으로 한 예시 값이다.
클라이언트 (엣지 수집기, 검증기관, 등록부)
|
| 1. GET /.well-known/wia/direct-air-capture (RFC 8615)
| -> 연산 그룹, OpenAPI URL, 서명 키, 적합성 등급
| 2. 발견 문서 서명 검증 (Sigstore 키)
| 3. POST /auth/token grant_type=client_credentials
| -> access_token (Bearer), expires_in, scope
| 4. 토큰을 붙여 REST / GraphQL / WebSocket 호출
| GET /facilities ... 읽기
| POST /facilities/{id}/capture 쓰기
| wss facility:{id}:capture 구독
v
DAC 데이터 서비스 --> PHASE-2-DATA 스키마로 레코드 검증
클라이언트가 DAC 데이터 서비스의 토큰 엔드포인트나 OpenAPI 문서 위치를 코드에 고정해 두어서는 안 된다. RFC 8615가 정의한 /.well-known/ 경로 접두사는 바로 이 용도, 즉 어느 호스트에서든 그 호스트에 관한 메타데이터를 찾을 수 있는 고정 위치를 위한 것이다. 이 표준은 그 아래에 /.well-known/wia/direct-air-capture라는 자기 위치를 둔다. 여기서 쓰이는 것은 명세 본문에 나오는 문서 번호 WIA-ENE-050이 아니라 슬러그 direct-air-capture라는 점에 주의해야 한다. 두 식별자의 관계는 제1장에서 설명했다. 기계가 해석하는 곳에서는 언제나 슬러그를 쓴다.
명세는 발견 문서에 세 가지 항목을 요구한다. 서비스가 지원하는 연산 그룹, OpenAPI 문서의 URL, 매니페스트 서명 키이다. 또 발견 응답 자체도 매니페스트와 같은 Sigstore 키로 서명하도록 요구한다. 그러나 JSON 구조는 제시하지 않는다. 아래 구조는 이 책의 제안이다. 필수 항목을 모두 담고, 클라이언트가 첫 호출 전에 반드시 알아야 하는 토큰 엔드포인트와 적합성 등급을 추가했다.
GET /.well-known/wia/direct-air-capture HTTP/1.1
Host: dac-data.example.com
Accept: application/json
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: max-age=3600
{
"standardSlug": "direct-air-capture",
"specVersion": "1.0.0",
"operationGroups": ["facility", "capture", "storage", "verification", "credits"],
"openapi": "https://dac-data.example.com/openapi.json",
"tokenEndpoint": "https://dac-data.example.com/auth/token",
"graphql": "https://dac-data.example.com/graphql",
"websocket": "wss://dac-data.example.com/ws",
"conformanceTier": "Surface",
"manifest": "https://dac-data.example.com/manifest.json",
"signingKey": {
"type": "sigstore-keyless",
"identity": "release@dac-data.example.com",
"issuer": "https://issuer.example.com"
}
}
operationGroups의 값들은 이 책의 제안이며, 4.3절의 자원 계열과 대응한다. 연산 그룹이라는 개념이 쓸모 있는 이유는 모든 서비스가 모든 기능을 구현하지는 않기 때문이다. 저장소 운영사는 storage만 열어 둘 수 있고, 읽기 전용 공개 대시보드는 facility와 capture만 열어 둘 수 있다. 목록을 읽는 클라이언트는 404를 돌려줄 엔드포인트를 부르기 전에 일찍, 분명하게 실패할 수 있다.
서명된 발견 문서가 막는 공격은 구체적이다. 침해되었거나 잘못 설정된 프록시가 클라이언트를 다른 토큰 엔드포인트나 다른 OpenAPI 문서로 보내는 경우이다. 명세는 서명을 어떻게 싣는지 정하지 않았다. 현실적인 선택지는 문서 옆에 분리 서명을 게시하는 방식(예: …/direct-air-capture.sig)과 JSON을 DSSE 봉투로 감싸는 방식 두 가지이다. 어느 쪽이든 클라이언트는 문서 안의 URL을 신뢰하기 전에, 서비스의 서명 매니페스트에 기록된 신원과 서명을 대조해야 한다. 검증한 문서는 Cache-Control 수명보다 오래 캐시하지 않으며, 서명 실패는 경고가 아니라 치명적 오류로 다룬다.
표준은 세 가지 적합성 등급을 정의한다. Surface(자체 증명), Verified(연 1회 제3자 감사), Anchored(Annex G에 따른 지속적 증거 패키지)이다. 각 등급의 요건은 제8장에서 다룬다. API와 관련된 규칙은, 서비스가 자기 등급을 OpenAPI 문서의 확장 필드 x-wia-conformance-tier로 선언한다는 것이다. OpenAPI는 문서의 대부분 위치에서 x-로 시작하는 확장 필드를 허용하며, 이 필드가 놓일 자연스러운 자리는 4.6절에서 보듯 info 객체이다. 위의 제안처럼 발견 문서에도 등급을 싣는다면 두 값은 반드시 일치해야 한다. 불일치를 발견한 클라이언트는 둘 중 낮은 쪽을 믿어야 한다. 높은 등급 주장에는 증거가 필요하지만 낮은 등급 주장에는 필요하지 않기 때문이다.
DAC 데이터 서비스를 호출하는 쪽은 거의 모두 기계이다. 설비의 엣지 수집기, 저장소 운영사의 시스템, 검증기관의 분석 파이프라인, 등록부가 그렇다. 그래서 PHASE-4 §2.1.1은 OAuth 2.0 클라이언트 자격증명(client credentials) 그랜트를 규정한다. 최종 사용자도, 브라우저 리디렉션도 없다. 클라이언트는 자기 자신으로 인증하고 액세스 토큰을 받는다.
명세는 토큰 요청을 JSON 본문으로 보여 준다. 이는 실제 상호운용성 문제이다. OAuth 2.0 프레임워크(RFC 6749)는 토큰 요청을 application/x-www-form-urlencoded로 정의하며, 표준 OAuth 클라이언트 라이브러리와 인가 서버도 이 인코딩을 기대한다. RFC 6749는 또 클라이언트 비밀을 본문에 넣기보다 HTTP Basic 인증으로 보내도록 권고한다. 아래 요청은 RFC 6749를 따른다. 서버는 호환을 위해 JSON 형식도 받아 줄 수 있지만, 클라이언트는 표준 형식으로 보내야 한다.
POST /auth/token HTTP/1.1
Host: dac-data.example.com
Authorization: Basic ZWRnZS1yZXlramFuZXMtMDE6c2VjcmV0
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&scope=read%3Afacility%20write%3Acapture
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
{
"access_token": "eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K2p3dCJ9...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "read:facility write:capture"
}
예시의 Basic 자격증명은 자리표시자이다. 받은 토큰은 RFC 6750에 따라 모든 호출에 Authorization: Bearer …로 실어 보낸다. expires_in 3600초는 명세 예시에서 온 값으로, 합리적인 기본값일 뿐 요건은 아니다. 클라이언트는 401 응답을 기다리지 말고 만료 직전에 새 토큰을 받아 두는 편이 낫다. 일괄 전송 도중에 쓰기가 실패하면 조금 일찍 갱신하는 것보다 복구가 훨씬 까다롭다.
명세가 이름을 붙인 스코프는 read:facility와 write:capture 두 개뿐이다. 온전한 서비스에는 더 많은 스코프가 필요하다. 저장 이벤트를 쓰거나 크레딧을 발행하는 권한은 센서 데이터를 제출하는 권한과 성격이 전혀 다르기 때문이다. 아래 표는 명세의 두 스코프를 그대로 두고, 같은 동작:자원 형식으로 이 책이 권고하는 스코프를 덧붙였다.
| 스코프 | 출처 | 허용 범위 | 대표적 보유자 |
|---|---|---|---|
read:facility | PHASE-4 §2.1.1 | 시설 목록·상세 조회, 포집 데이터 조회 | 대시보드, 검증기관, 등록부 |
write:capture | PHASE-4 §2.1.1 | 클라이언트에 연결된 시설의 포집 레코드 제출 | 설비 엣지 수집기 |
write:storage | 이 책의 권고 | 저장 이벤트 제출 | 저장소 운영사, 설비 운영사 |
read:verification | 이 책의 권고 | 검증 보고서 조회 | 등록부, 구매자, 감사인 |
write:verification | 이 책의 권고 | 검증 보고서 제출 | 인정받은 검증기관만 |
issue:credits | 이 책의 권고 | 검증된 보고서를 근거로 크레딧 발행 요청 | 등록부 연동 시스템(설비는 불가) |
이 표 뒤에는 두 가지 설계 원칙이 있다. 첫째, 스코프만으로는 결코 충분하지 않다. write:capture를 가진 클라이언트는 서버 쪽에서 자신이 쓸 수 있는 시설 ID에 묶여 있어야 한다. 그렇지 않으면 어느 설비든 다른 설비의 데이터를 제출할 수 있다. 둘째, 증거를 쓰는 클라이언트와 크레딧을 만드는 클라이언트가 같아질 수 없도록 스코프를 나눈다. 포집 데이터를 만들어 내는 시스템이 그 데이터를 돈으로 바꾸는 스코프까지 가져서는 안 된다. 이것이 MRV가 기대는 직무 분리를 API 형태로 옮긴 것이다(제6장).
PHASE-4 §9.1은 네 가지 방식을 나열한다. OAuth 2.0, 기계 간 통신용 API 키, 고보안 연결용 mTLS, 무상태 인증용 JWT이다. 그러나 이들은 같은 층위의 대안이 아니다. JWT는 토큰 형식이며, OAuth 서버가 쓸 수도 쓰지 않을 수도 있다. API 키는 수명이 긴 공유 비밀이다. 단순하지만 스코프를 담지 못하고 스스로 만료되지도 않으며, 유출된 키는 누군가 알아챌 때까지 유효하다. 읽기 전용 공개 엔드포인트라면 API 키로도 충분하다. 증거를 쓰는 모든 경로에는 OAuth 토큰이 더 낫다.
명세의 시간·신원 절은 시간 제한 토큰을 "토큰 바인딩을 위해 TLS 세션의 exporter 값(RFC 8446 §7.5)에 대조해 검증한다"고 덧붙인다. 의도는 타당하다. 로그 파일에서 도난당한 토큰은 공격자에게 쓸모가 없어야 한다. 그러나 이 문장은 실제로 바인딩을 수행하는 구체적 프로토콜을 지정하지 않는다. 이를 수행하는 IETF 메커니즘은 두 가지이다. RFC 8705는 상호 TLS 클라이언트 인증과 인증서 바인딩 액세스 토큰을 정의한다. 토큰에 클라이언트 인증서의 해시가 기록되고, 리소스 서버는 그 인증서를 제시하지 않는 연결에서 토큰을 거부한다. RFC 9449(DPoP)는 토큰을 클라이언트가 보유한 키에 묶고, 매 요청마다 서명된 헤더로 보유를 증명한다. 이미 장치 인증서를 가진 설비 엣지 장치라면 대개 RFC 8705 쪽이 더 간단하다. 이 책은 exporter 문장만 인용하지 말고, 실제로 쓰는 메커니즘을 프로파일 매니페스트에 명시할 것을 권고한다.
바로잡아야 할 인용도 하나 있다. 명세와 README는 RFC 9700을 "OAuth 2.1"이라고 설명한다. RFC 9700은 OAuth 2.0 보안 모범 사례(Security Best Current Practice)이다. OAuth 2.1은 OAuth 2.0과 그 보안 지침을 통합하는 별개의 IETF 문서이다. 기계 간 DAC 서비스에서는 실질적 결과가 같다. 두 문서 모두 짧은 수명의 토큰, 가능한 곳에서의 발신자 제약 토큰, URL에 비밀을 싣지 않기라는 같은 실무를 가리키기 때문이다.
PHASE-4 §9.2는 기본 요청 한도로 공개 API 시간당 100회, 인증 클라이언트 시간당 1,000회, 파트너 시간당 10,000회를 제시하고, WebSocket은 "무제한(공정 사용)"으로 적는다. 이 값들은 예시 설정이지 요건이 아니며, 서비스는 스스로 한도를 정해야 한다. 여기서는 간단한 계산이 도움이 된다. 1분마다 레코드 하나를 제출하는 엣지 수집기는 시간당 60회를 호출하므로 인증 클라이언트 예시 한도에 한참 못 미친다. 1년치 시간별 데이터를 페이지당 50건씩 내려받는 검증기관은 약 175회의 요청이 필요하다. 한도가 더 중요한 쪽은 두 번째 유형의 클라이언트이다. 어떤 한도를 정하든, 초과 호출은 HTTP 429와 Retry-After 헤더(RFC 9110)로 거부하고, 클라이언트가 계획을 세울 수 있도록 한도를 OpenAPI 문서에 공개해야 한다.
PHASE-4 §2.1.2의 REST 인터페이스에는 7개의 연산이 있다. 표 4-2는 각 연산에 요구해야 할 스코프와 그 페이로드를 정의하는 PHASE-2-DATA 스키마를 함께 정리한 것이다. 스코프 열은 명세 규정 스코프와 표 4-1의 권고를 합친 것이다.
| 메서드와 경로 | 용도 | 스코프 | 요청 | 응답 data |
|---|---|---|---|---|
GET /facilities | 시설 목록; 필터 country, status | read:facility | 쿼리 매개변수 | 시설 요약 배열 |
GET /facilities/{facilityId} | 시설 하나 | read:facility | 경로 매개변수 | 시설 정보(PHASE-2-DATA §2.1) |
GET /facilities/{facilityId}/capture | 포집 이력; start, end, interval | read:facility | 쿼리 매개변수 | 포집 레코드 또는 집계 배열 |
POST /facilities/{facilityId}/capture | 포집 레코드 제출 | write:capture | 실시간 포집 레코드(§3.1) | recordId, status |
POST /storage/events | 저장 이벤트 제출 | write:storage | 저장 이벤트(§5.1) | eventId, status: pending-verification |
GET /verification/reports | 보고서 목록; facilityId, year | read:verification | 쿼리 매개변수 | 검증 보고서 배열(§6.1) |
POST /credits/issue | 검증된 보고서를 근거로 크레딧 발행 | issue:credits | 발행 요청 | 탄소제거 크레딧 요약(§7.1) |
표를 보면 두 가지 빈틈이 바로 드러난다. 검증 보고서를 제출하는 엔드포인트가 없고, PHASE-2-DATA §4의 흡착제 레코드를 다루는 엔드포인트도 없다. 앞의 것은 실제 누락이다. 이대로라면 보고서는 별도 경로로만 시스템에 들어올 수 있다. 이 책은 write:verification 스코프를 요구하는 POST /verification/reports를 권고한다. 뒤의 것은 많은 운영사에게 의도된 선택이다. 흡착제 조성은 영업 비밀일 수 있으며, 이에 대한 등급별 접근은 제7장에서 다룬다.
목록 엔드포인트는 country와 status 필터를 받는다. 국가 값은 시설 레코드의 location.country와 같은 ISO 3166-1 alpha-2 코드이고, status는 operational.status 열거형의 값을 받는다. 서버는 알 수 없는 값을 빈 목록으로 답하지 말고 400 오류로 거부해야 한다. 빈 목록은 올바른 질문에 대한 정상적인 답이며, 클라이언트는 그것을 오타와 구별할 수 없다.
포집 이력 엔드포인트는 start, end, interval을 받는다. 명세는 interval=hourly를 쓰지만 다른 값은 나열하지 않는다. 제2장의 측정 주기(1~5분 샘플링, 시간별 집계, 일별 요약)에 맞춘 raw, hourly, daily가 합리적인 값 집합이다. 시간 구간은 반개구간, 즉 start는 포함하고 end는 제외하는 것으로 해석해야 한다. 명세 예시는 end=…T23:59:59Z를 쓰는데, 초 단위 타임스탬프에서는 문제가 없지만 소수 초가 있는 경우 그날 마지막 1초 안에 찍힌 레코드를 조용히 빠뜨린다. 반개구간이라면 하루는 간단히 start=2025-06-01T00:00:00Z&end=2025-06-02T00:00:00Z이다.
GET /v1/facilities/dac-example-reykjanes-01/capture?start=2025-06-01T00:00:00Z&end=2025-06-01T03:00:00Z&interval=hourly HTTP/1.1
Host: dac-data.example.com
Authorization: Bearer eyJhbGciOi...
HTTP/1.1 200 OK
Content-Type: application/json
{
"success": true,
"data": [
{ "timestamp": "2025-06-01T00:00:00Z",
"capture": { "hourlyTotal": 0.12 },
"energy": { "hourlyTotal": 0.242, "specificEnergy": 2.02 } },
{ "timestamp": "2025-06-01T01:00:00Z",
"capture": { "hourlyTotal": 0.12 },
"energy": { "hourlyTotal": 0.241, "specificEnergy": 2.01 } },
{ "timestamp": "2025-06-01T02:00:00Z",
"capture": { "hourlyTotal": 0.11 },
"energy": { "hourlyTotal": 0.240, "specificEnergy": 2.18 } }
],
"metadata": { "timestamp": "2025-06-01T09:12:44Z", "version": "1.0.0", "requestId": "req-7f3c" },
"pagination": { "page": 1, "limit": 50, "total": 3 }
}
수치는 예시이며 제2장의 시설과 일치하도록 골랐다. 시간당 약 0.12 t은 하루 2.9 t에 해당하고, 한 시간 동안의 에너지 0.242 MWh를 0.12 t으로 나누면 약 2.0 MWh/t이 된다. 집계 행은 PHASE-4 예시가 도입했지만 PHASE-2-DATA 포집 스키마에는 정의되지 않은 hourlyTotal 필드를 쓴다. 집계는 원시 포집 레코드와 다른 객체이므로 자기 스키마를 가져야 한다. 그렇지 않으면 응답을 포집 레코드 스키마로 검사하는 검증기가 필수 필드 currentRate가 없다는 이유로 집계를 거부한다. 4.6절에서 이를 CaptureAggregate로 정의한다.
PHASE-2-DATA §8.1의 봉투는 페이지 번호 방식(page, limit, total)을 쓴다. 짧고 잘 바뀌지 않는 시설 목록에는 이 방식이 잘 맞는다. 그러나 클라이언트가 읽는 동안에도 계속 늘어나는 포집 이력에는 맞지 않는다. 3쪽과 4쪽을 읽는 사이에 새 레코드가 들어오면, 오프셋 기반 페이지 나누기는 행을 중복하거나 건너뛸 수 있다. 이 책은 시계열 엔드포인트에 키셋 페이지 나누기를 권고한다. 서버가 마지막으로 반환한 타임스탬프와 레코드 ID를 담은 불투명한 next 커서를 돌려주고, 클라이언트는 그 커서를 다시 보낸다. 호환을 위해 페이지 번호 필드는 봉투에 남겨 둘 수 있지만, 시계열 엔드포인트에서 total은 선택 항목으로 두어야 한다. 늘어나는 테이블을 요청마다 세는 일은 비싸고, 그 숫자는 보내는 순간 이미 낡은 값이 된다.
엣지 수집기는 포집 레코드를 한 건씩 POST /facilities/{facilityId}/capture로 보낸다. 본문은 제2장의 실시간 포집 레코드이다. 서버는 레코드를 스키마로 검증하고, 본문의 facilityId가 경로의 값과 같은지, 그리고 클라이언트가 그 시설에 쓸 권한이 있는지 확인한 뒤, 서버가 부여한 recordId를 돌려준다. 명세의 응답은 HTTP 상태 코드 없이 "status": "accepted"만 쓴다. 이 책은 레코드를 동기적으로 저장했다면 Location 헤더와 함께 201 Created를, 대기열에만 넣었다면 202 Accepted를 권고한다.
원격지 설비의 네트워크는 믿을 만하지 않다. 엣지 수집기는 직전 POST가 도착했는지 모르는 상황을 종종 겪는다. 무작정 재시도하면 같은 측정값이 두 번 저장되고, 설비는 실제보다 많은 CO2를 포집한 것처럼 보인다. 제거량 원장에서 이보다 나쁜 실패는 없다. 방어 수단은 두 가지이며, 서비스는 둘 다 써야 한다.
facilityId, timestamp) 쌍은 포집 레코드를 식별한다. 서버는 이를 고유 키로 강제하고, 같은 쌍과 동일한 내용의 두 번째 POST는 원래의 recordId를 돌려주는 무동작으로 처리해야 한다. 같은 쌍인데 내용이 다른 두 번째 POST는 충돌(409)이며, 덮어쓰지 말고 조사해야 한다.Idempotency-Key 헤더로 보낸다. 서버는 키와 결과를 함께 저장하고, 반복 요청에는 저장된 결과를 그대로 돌려준다. 이 헤더는 IETF HTTPAPI 워킹그룹의 인터넷 드래프트에 기술되어 있다. 아직 RFC는 아니지만, 같은 이중 지불 문제를 겪는 결제 API에서 널리 구현되어 있다.POST /storage/events는 eventId와 pending-verification 상태를 돌려준다. 이 상태값은 API 전체에서 가장 중요한 단어이다. 저장 이벤트 제출은 주장이지 사실이 아니다. 이벤트는 검증 보고서가 그것을 참조할 때 비로소 증거가 된다(제3장). 따라서 올바른 HTTP 상태는 202 Accepted이다. 클라이언트, 특히 대시보드는 대기 중인 이벤트를 "저장된 톤수" 합계에 절대 더해서는 안 된다.
POST /credits/issue는 verificationReportId, 수량, 빈티지, 등록부를 받아 creditId와 일련번호를 돌려준다. 서버는 참조된 보고서의 결론(conclusion)이 verified 또는 verified-with-conditions가 아니면 요청을 거부해야 하고, 요청 수량에 그 보고서로 이미 발행된 크레딧을 더한 값이 보고서의 verifiedAmount를 넘어도 거부해야 한다. 이 검사가 API 수준에서 이중 발행을 막는 방어선이다. 새 크레딧을 기록하는 것과 같은 데이터베이스 트랜잭션 안에서 실행해야 한다. 그렇지 않으면 병렬로 들어온 두 요청이 각각 검사를 통과할 수 있다. 같은 Idempotency-Key를 가진 중복 발행 요청에는 두 번째 크레딧이 아니라 첫 번째 크레딧을 돌려주어야 한다.
또 이 엔드포인트가 발행하는 크레딧은 WIA 시스템 자체 기록 안의 크레딧이라는 점에 유의해야 한다. Puro.earth, Verra, Gold Standard, ACR 같은 실제 등록부에는 각자의 발행 절차가 있으며, WIA 일련번호가 등록부 일련번호를 대신하지 않는다. 그 대응 관계는 제6장에서 다룬다.
제3장은 명세의 오류 객체(success: false와 error.code, message, details로 이루어지며 예로 INVALID_FACILITY_ID가 있다)를 README가 인용하는 RFC 9457 Problem Details와 비교한 뒤, 모든 오류에 RFC 9457(application/problem+json)을 쓰고 WIA 오류 코드는 확장 멤버로 유지하라고 권고했다. RFC 9457에서는 HTTP 상태 코드가 1차 의미를 전달하므로, 상황과 상태 코드의 대응은 모든 엔드포인트에서 일관되어야 한다. 명세는 상태 코드에 대해 아무 말도 하지 않으므로, 표 4-3에 이 책의 권고를 정리한다.
| 상황 | 상태 | 오류 코드 예 |
|---|---|---|
| 본문이 스키마 검증에 실패; 알 수 없는 필터 값 | 400 | VALIDATION_FAILED, INVALID_FACILITY_ID |
| 토큰 없음, 만료, 무효 | 401 | INVALID_TOKEN |
| 토큰은 유효하나 스코프나 시설 연결이 허용하지 않음 | 403 | INSUFFICIENT_SCOPE |
| 시설, 보고서, 이벤트가 존재하지 않음 | 404 | NOT_FOUND |
| 같은 자연 키에 다른 내용; 발행량이 검증량 초과 | 409 | DUPLICATE_RECORD, ISSUANCE_EXCEEDS_VERIFIED |
| 형식은 맞지만 정합성 검사 실패(제2장 §2.7) | 422 | INCONSISTENT_RECORD |
| 요청 한도 초과 | 429 | RATE_LIMITED |
INVALID_FACILITY_ID를 제외한 코드는 이 책의 제안이다. 400과 422를 나누는 것이 유용하다. 400은 유효한 레코드로 읽을 수조차 없다는 뜻이고, 422는 읽을 수는 있지만 스스로 모순된다는 뜻이다. 예를 들어 specificEnergy가 허용 오차 안에서 에너지를 포집량으로 나눈 값과 같지 않은 경우이다. 엣지 수집기는 400이나 422를 받은 레코드를 그대로 재전송해서는 안 되며, 격리하고 경보를 올려야 한다. 429와 5xx 응답은 백오프를 두고 재시도할 수 있다.
PHASE-4 §2.2는 GraphQL이 제공하는 세 가지 루트 타입으로 스키마를 정의한다. Query는 시설, 포집 데이터, 저장 이벤트, 검증 보고서, 크레딧을 다룬다. Mutation에는 submitCaptureData, submitStorageEvent, issueCredits가 있다. Subscription에는 captureDataUpdated와 storageEventCreated가 있다. 객체 타입(Facility, CaptureData 등)은 PHASE-2-DATA 스키마를 그대로 반영하며, 명세의 목록은 "# Additional types…"로 끝난다. 즉 GraphQL 스키마도 OpenAPI 골격과 같은 방식으로 미완성이며, 두 인터페이스가 서로 어긋나지 않으려면 같은 JSON Schema에서 생성해야 한다.
GraphQL이 제 몫을 하는 곳은 클라이언트가 모양이 다른 관련 데이터를 한 번의 왕복으로 받아야 할 때이다. 시설 정보, 최신 포집 레코드, 마지막 저장 이벤트를 함께 보여 주는 모니터링 대시보드는 REST로는 세 번 호출해야 한다. GraphQL로는 질의 하나면 된다.
query FacilityOverview($id: ID!) {
facility(facilityId: $id) {
facilityId
name
capacity { annualTonsCO2 }
realtimeData {
timestamp
capture { currentRate efficiency }
energy { currentConsumption specificEnergy }
}
}
storageEvents(facilityId: $id) {
eventId
amount
storageMethod
}
}
주의할 점이 몇 가지 있다. 첫째, 4.2절의 권한 규칙은 엔드포인트 단위가 아니라 필드 단위로 지켜져야 한다. read:facility만 가진 클라이언트가 시설 질의 안에 검증 보고서를 중첩해서 접근할 수 있어서는 안 된다. 둘째, 공개 GraphQL 엔드포인트에는 질의 깊이와 비용 한도가 필요하다. 깊게 중첩된 질의 하나가 REST 호출 수천 번만큼 비쌀 수 있는데, 4.2.4절의 요청 단위 한도로는 이를 잡아내지 못한다. 셋째, 뮤테이션에도 REST POST와 같은 멱등성 보호가 필요하다. GraphQL에는 이를 위한 표준 헤더가 없으므로, 각 뮤테이션에 idempotencyKey 인자를 두는 것이 일반적인 해법이다. 마지막으로, facilities 질의는 페이지 인자를 받는데 storageEvents 질의는 페이지 인자 없이 단순 목록을 돌려준다. 늘어날 수 있는 목록은 모두 페이지로 나누어야 한다.
실무에서는 많은 서비스가 쓰기에는 REST를 주 인터페이스로, GraphQL은 읽기에 최적화된 뷰로 다루는 편이 낫다. 멱등성, 감사 로그, 적합성 증거가 가장 중요한 곳이 쓰기이며, 쓰기는 고정된 소수의 REST 연산으로 두어야 시험하고 감사하기 쉽다.
PHASE-4 §2.3은 실시간 모니터링용 WebSocket 엔드포인트를 정의한다. 클라이언트는 연결한 뒤 베어러 토큰을 담은 인증 메시지를 보내고, 이어서 facility:{facilityId}:capture라는 이름의 채널에 구독 메시지를 보낸다. 서버는 시설 ID, 타임스탬프, 포집·에너지 값을 담은 capture_update 메시지를 밀어 보낸다. FOUNDATION §5.2.2는 갱신 주기를 1~60초(설정 가능)로, 측정에서 API까지의 지연 목표를 5초 미만으로 정한다. 요청 한도와 마찬가지로 이 지연 수치도 예시 목표일 뿐, 모든 배포가 지켜야 하는 요건은 아니다.
토큰을 URL이 아니라 첫 메시지에 싣는 것은 좋은 선택이다. URL은 프록시와 서버 로그에 남으며, 쿼리 문자열 속 토큰은 함께 새어 나간다. 서버는 몇 초 안에 유효한 auth 메시지가 오지 않으면 연결을 끊어야 하고, 클라이언트가 구독할 때 REST API와 같은 스코프·시설 연결 규칙을 적용해야 한다.
명세의 클라이언트 예제는 ws.on('open', …)과 ws.on('message', …)를 쓴다. 이는 Node.js의 ws 라이브러리 API이며, onopen과 addEventListener를 쓰는 브라우저의 WebSocket 객체와는 다르다. 아래 예제는 브라우저와 최신 Node.js 양쪽에서 동작한다. 또 명세가 빠뜨린 두 가지, 재연결과 누락분 보충(backfill)을 더했다.
// 예시 클라이언트: 재연결과 누락분 보충을 갖춘 실시간 포집 갱신
function watchCapture(facilityId, getToken, onRecord) {
let lastTs = null; // 마지막으로 처리한 레코드의 타임스탬프
let delay = 1000; // 재연결 백오프(ms)
async function backfill() {
if (!lastTs) return;
const url = `https://dac-data.example.com/v1/facilities/${facilityId}/capture`
+ `?start=${encodeURIComponent(lastTs)}&interval=raw`;
const res = await fetch(url, { headers: { Authorization: `Bearer ${await getToken()}` } });
const body = await res.json();
for (const rec of body.data) if (rec.timestamp > lastTs) handle(rec);
}
function handle(rec) {
lastTs = rec.timestamp; // 정밀도가 같은 UTC ISO 8601 문자열은 올바르게 정렬된다
onRecord(rec);
}
async function connect() {
const ws = new WebSocket('wss://dac-data.example.com/ws');
ws.addEventListener('open', async () => {
delay = 1000;
ws.send(JSON.stringify({ type: 'auth', token: await getToken() }));
ws.send(JSON.stringify({ type: 'subscribe', channel: `facility:${facilityId}:capture` }));
await backfill(); // 직전 연결 끊김이 남긴 공백을 메운다
});
ws.addEventListener('message', (ev) => {
const msg = JSON.parse(ev.data);
if (msg.type === 'capture_update' && (!lastTs || msg.timestamp > lastTs)) {
handle({ timestamp: msg.timestamp, facilityId: msg.facilityId, ...msg.data });
}
});
ws.addEventListener('close', () => {
setTimeout(connect, delay);
delay = Math.min(delay * 2, 60000);
});
}
connect();
}
이 코드에 담긴 원칙은 일반적이다. WebSocket 스트림은 낮은 지연을 위한 편의 수단이지 진실의 기록이 아니다. 클라이언트 연결이 끊긴 동안 보낸 메시지는 사라진다. 진실의 기록은 REST 이력 엔드포인트이며, 그래서 재연결할 때마다 클라이언트가 마지막으로 처리한 타임스탬프부터 누락분을 조회하고, 중복은 타임스탬프로 걸러 낸다. 문자열 비교가 통하는 것은 모든 타임스탬프가 같은 정밀도의 UTC이기 때문이며, 이를 강제해야 하는 이유는 4.7절에서 설명한다. 더 견고한 설계는 서버가 채널별 일련번호를 메시지마다 붙여, 클라이언트가 공백을 직접 감지하게 하는 것이다. 이 책은 선택 필드 seq로 이를 추가할 것을 권고한다.
설비와 저장소 사이의 기계 간 원격 측정에는 MQTT라는 대안이 있으며, 제7장에서 다룬다. WebSocket은 대시보드와 운영자 콘솔에 가장 잘 맞는다.
지금까지 다룬 모든 내용이 시험 가능한 형태가 되는 곳이 OpenAPI 문서이다. 코드 생성기는 이 문서로 클라이언트를 만들고, 계약 시험은 서버를 이 문서에 대조하며, 적합성 등급도 이 문서에 선언된다. 제8장에서 다룰 Annex G의 증거 패키지에도 반드시 포함되어야 한다. 배포된 api/openapi.yaml은 이 가운데 어느 용도도 감당하지 못한다. OpenAPI 3.1.0, example.com 서버, 그리고 스키마 없는 GET과 POST를 가진 /resource 경로 하나만 선언하고 있다.
온전한 문서는 이미 살펴본 원천에서 기계적으로 도출할 수 있다. 절차는 다음과 같다.
components/schemas에 복사한다. OpenAPI 3.1은 표준이 쓰는 JSON Schema 2020-12와 정렬되어 있으므로 스키마를 수정 없이 쓸 수 있다. 수정된 부분집합을 쓰던 OpenAPI 3.0에서는 불가능했던 일이다.data를 열어 둔 채 한 번만 정의하고, 각 응답은 allOf로 조합한다. 제3장에서 권고한 RFC 9457 문제 객체도 미디어 타입 application/problem+json으로 한 번만 정의해 모든 오류 응답에서 참조한다.POST /verification/reports를 더한다.oauth2 방식 하나를 정의하고, 각 연산에 필요한 스코프를 적는다.info에 x-wia-conformance-tier를 두고, 필요하면 연산별 요청 한도 정보를 둔다.아래 발췌는 읽기 연산 하나와 쓰기 연산 하나에 대해 결과가 어떤 모양인지 보여 준다. 축약한 것이며, 전체 구성요소 스키마는 제2장과 제3장의 것이다.
openapi: 3.1.0
info:
title: WIA Direct Air Capture Data API (example deployment)
version: 1.0.0
x-wia-conformance-tier: Surface
jsonSchemaDialect: https://json-schema.org/draft/2020-12/schema
servers:
- url: https://dac-data.example.com/v1
components:
securitySchemes:
oauth:
type: oauth2
flows:
clientCredentials:
tokenUrl: https://dac-data.example.com/auth/token
scopes:
read:facility: Read facilities and capture data
write:capture: Submit capture records
write:storage: Submit storage events
read:verification: Read verification reports
write:verification: Submit verification reports
issue:credits: Issue credits against a verified report
parameters:
FacilityId:
name: facilityId
in: path
required: true
schema: { type: string, pattern: "^dac-[a-z0-9-]+$" }
schemas:
CaptureRecord: { $ref: "./schemas/capture-record.json" } # PHASE-2-DATA 3.1
CaptureAggregate:
type: object
required: [timestamp, capture]
properties:
timestamp: { type: string, format: date-time }
capture:
type: object
properties: { hourlyTotal: { type: number, minimum: 0 } }
energy:
type: object
properties:
hourlyTotal: { type: number, minimum: 0 }
specificEnergy: { type: number, minimum: 0 }
Envelope:
type: object
required: [success]
properties:
success: { type: boolean }
metadata:
type: object
properties:
timestamp: { type: string, format: date-time }
version: { type: string }
requestId: { type: string }
paths:
/facilities/{facilityId}/capture:
parameters:
- $ref: "#/components/parameters/FacilityId"
get:
operationId: listCapture
security: [ { oauth: [read:facility] } ]
parameters:
- { name: start, in: query, required: true, schema: { type: string, format: date-time } }
- { name: end, in: query, schema: { type: string, format: date-time } } # optional; defaults to now
- { name: interval, in: query, schema: { type: string, enum: [raw, hourly, daily] } }
responses:
"200":
description: Capture records (raw) or aggregates (hourly, daily)
content:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/Envelope"
- properties:
data:
type: array
items:
oneOf:
- $ref: "#/components/schemas/CaptureRecord"
- $ref: "#/components/schemas/CaptureAggregate"
post:
operationId: submitCapture
security: [ { oauth: [write:capture] } ]
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/CaptureRecord" }
responses:
"201": { description: Stored; Location header points to the record }
"409": { description: Same facilityId and timestamp with different content }
"422": { description: Record fails a consistency check }
세 가지 세부 사항에 주의해야 한다. 시설 ID의 pattern은 따옴표로 감쌌다. YAML은 정규식에 쓰이는 일부 문자를 특별하게 해석하기 때문이다. 원시 레코드와 집계 사이의 oneOf는 두 스키마가 같은 객체에 동시에 맞지 않을 때만 동작한다. 원시 레코드는 currentRate를 요구하고 집계에는 그 필드가 없지만, 집계 스키마가 currentRate를 금지하지 않으면 둘 다 맞는 객체가 생길 수 있으므로, 엄격한 구현이라면 집계에서 currentRate를 금지하거나 anyOf를 써야 한다. 마지막으로, 경로는 /facilities/…이고 버전 /v1은 서버 URL에 두어 명세의 기준 URL 구조를 따랐다. 그래야 폐기 기간 동안 미래의 /v2를 나란히 운영할 수 있다. 명세가 그 기간을 여러 값으로 제시한다는 점을 포함해, 기간을 얼마로 잡을지는 제8장에서 논의한다.
이 문서가 갖추어지면 나머지 도구는 저절로 따라온다. 계약 시험 도구는 시험 벡터를 실서비스에 재생할 수 있고, 린터는 모든 연산에 보안 요구사항이 있는지 검사할 수 있으며, 같은 스키마로 4.4절의 GraphQL 타입도 생성할 수 있다.
README는 @wia/direct-air-capture를 설치하고 client.submitRecord(record)를 호출하는 TypeScript 빠른 시작을 보여 주며, SDK 위치로 api/typescript/를 가리킨다. 그러나 이 디렉터리는 저장소에 포함되어 있지 않다. 따라서 아래 예제는 기존 패키지의 문서가 아니라, 그런 클라이언트가 해야 할 일을 보여 주는 스케치로 읽어야 한다. 메서드 이름보다 중요한 세 가지 동작을 담았다. 토큰 캐시, 논리적 제출마다의 멱등성 키, 그리고 재시도해도 되는 오류만 재시도하는 것이다.
// 예시일 뿐이다: @wia/direct-air-capture SDK는 언급만 되어 있고 배포되지 않았다.
import { Client } from '@wia/direct-air-capture';
const client = new Client({
baseUrl: 'https://dac-data.example.com/v1',
tokenUrl: 'https://dac-data.example.com/auth/token',
clientId: process.env.DAC_CLIENT_ID!,
clientSecret: process.env.DAC_CLIENT_SECRET!,
scopes: ['write:capture'],
});
const record = {
timestamp: '2025-06-01T10:30:00Z',
facilityId: 'dac-example-reykjanes-01',
capture: { currentRate: 2.9, dailyTotal: 1.27 },
energy: { currentConsumption: 242, dailyTotal: 2.54, specificEnergy: 2.0 },
};
try {
// 클라이언트는 facilityId + timestamp로 멱등성 키를 만들고,
// 만료 전에 토큰을 갱신하며, 429/5xx는 백오프를 두고 재시도한다.
const { recordId } = await client.submitRecord(record);
console.log('stored', recordId);
} catch (err) {
// 400/422: 재시도하지 않는다. 레코드를 보관해 점검하고 경보를 올린다.
quarantine(record, err);
}
수치는 예시이며 서로 맞아떨어진다. 하루가 10.5시간 지난 UTC 10:30까지, 하루 2.9 t으로 가동하는 시설은 약 1.27 t을 포집했다. 242 kW로 같은 시간 동안 약 2.54 MWh를 썼으므로 2.0 MWh/t이 된다.
포집에서 크레딧에 이르는 사슬의 모든 단계는 사건의 순서에 의존한다. 저장 이벤트는 그 CO2를 만들어 낸 포집보다 뒤에 와야 한다. 검증 보고서는 시작과 끝이 있는 기간을 다룬다. 크레딧의 빈티지는 연도이다. 시계가 서로 어긋나거나 타임스탬프가 모호하면 이 관계를 하나도 검사할 수 없다.
명세는 NTPv4(RFC 5905) 스트라텀 2 이상의 시계 동기화를 요구하고, Annex E는 결정론적 인터록이 필요한 현장에 PTP(IEEE 1588)를 권한다. 데이터 교환에서 타임스탬프를 안전하게 비교하려면 세 가지 규칙이 필요하다.
Z 접미사를 쓴다. 현지 시각이나 현지 오프셋은 쓰지 않는다. PHASE-2-DATA는 "ISO 8601"이라고 적지만, JSON Schema의 date-time 형식이 쓰는 ISO 8601 프로파일은 RFC 3339이며 이를 따라야 한다."…10:30:00Z"는 "…10:30:00.500Z"보다 뒤로 정렬되는데, 문자 Z가 문자 .보다 뒤에 오기 때문이다.포집 레코드의 timestamp는 설비가 부여하는 측정 시각이다. 봉투의 metadata.timestamp는 서버가 부여하는 응답 시각이다. 둘 다 서버가 레코드를 받아 저장한 시각은 아니다. 이 세 번째 시각이 감사에는 꼭 필요하다. 한 달치 데이터가 꾸준히 들어왔는지, 아니면 검증 직전에 한꺼번에 올라왔는지를 보여 주기 때문이다. 이 책은 서버가 모든 레코드에 receivedAt 값을 저장하고 조회 시 노출할 것을 권고한다. 또 측정 시각이 수신 시각보다 시계 허용 오차 이상 늦은 레코드에는 표시를 달아야 한다. 그런 레코드는 시계가 틀린 장치에서 온 것이다.
같은 구분이 매니페스트 수준에서도 나타난다. CLI 명령 direct-air-capture.sh manifest-template이 출력하는 매니페스트는 issuedAt이 2026-04-26T00:00:00Z로 고정되어 있다. 이는 템플릿의 자리표시자이지 현재 시각이 아니다. 템플릿을 그대로 게시하는 구현자는 자신이 기술하는 빌드와 무관한 발행일을 주장하는 매니페스트에 서명하게 된다. 더 나쁜 경우, issuedAt으로 매니페스트를 정렬해 최신본을 찾는 소비자가 엉뚱한 것을 고를 수 있다. issuedAt은 매니페스트를 실제로 생성하고 서명하는 시점에 설정하고, 구현체가 처리하는 데이터 레코드의 타임스탬프와 절대 혼동하지 말아야 한다. CLI 전체는 제8장에서 따라가 본다.
API를 넘나드는 식별자는 네 종류이며, 각각 주인이 하나씩 있다. facilityId(패턴 ^dac-[a-z0-9-]+$)는 등록 시 부여되며 재사용되지 않는다. recordId와 eventId는 서버가 제출을 받아들일 때 부여하며, 클라이언트는 이를 전달 증명으로 보관한다. 봉투 메타데이터의 requestId는 HTTP 교환 한 번을 식별한다. 서버 감사 로그에서 요청을 찾는 가장 빠른 방법이므로 클라이언트는 이를 기록해 두어야 한다. Idempotency-Key는 클라이언트가 부여하며, 재시도를 가로질러 하나의 논리적 작업을 식별한다. 이 역할들을 섞으면, 예컨대 requestId를 멱등성 키로 쓰면 재시도가 깨진다. 재시도한 요청은 요청 ID가 새로 붙는 새 교환이지만, 논리적 작업은 같기 때문이다.
/.well-known/wia/direct-air-capture(RFC 8615)에 있으며 서명되어야 한다. 이 장은 JSON 구조를 제안했고, 등급은 x-wia-conformance-tier로 선언한다.Idempotency-Key를 통한 중복 방지가 필요하다. 저장 이벤트는 보고서가 참조하기 전까지 pending-verification이다. 크레딧 발행은 한 트랜잭션 안에서 검증량과 대조해야 한다.issuedAt은 서로 다른 세 가지이다.