PHASE 2 문서는 규범적 세부 사항은 적지만 범위만큼은 분명하게 정한다. API는 다섯 개의 작업 그룹을 제공하며, 각 그룹은 OpenAPI 문서로 기술된다. componentCatalog, maintenanceEvent, certificationPackage, traceability, serviceBulletin이 그것이다. 구현체가 다섯 개를 모두 제공할 의무는 없다. 지원하는 부분집합을 상호운용 프로파일(부속서 I, 제8장에서 다룬다)에 선언하면 되므로, 수리 조직과 부품 제작사는 서로 전혀 다른 기능 범위를 노출하면서도 둘 다 적합한 구현이 될 수 있다.
URL이 아니라 업무 기능 단위로 묶은 것은 이 분야에 맞는 선택이다. 부품 데이터를 주고받는 당사자 — 공급자, 정비 조직, 운영자, 인증 당국 — 는 각자 부품 이력의 서로 다른 부분을 책임지며, 필요한 권한도 다르다. 따라서 작업 그룹은 권한 범위(scope), 요청 속도 제한, 적합성 주장의 자연스러운 단위이기도 하다.
| 작업 그룹 | 명세의 기술 | 주로 쓰는 쪽 | 주로 읽는 쪽 |
|---|---|---|---|
componentCatalog | 부품 형식 지정(type designation)의 목록·조회·검색 | 설계 보유자, 공급자 | 운영자, MRO, 부품 유통사 |
maintenanceEvent | 정비 조치의 기록·조회·질의 | MRO, 운영자 운항정비 조직 | 운영자 기술기록 부서, 리스사, 당국 감사자 |
certificationPackage | FAA·EASA·한국 당국에 대한 인증 증거 제출 | 설계 보유자, 설계 승인을 가진 공급자 | 인증 당국 |
traceability | 일련번호로 부품 생애 전체를 조회 | (다른 그룹에서 파생) | 중고 부품을 인수하는 모든 조직, 사고 조사자 |
serviceBulletin | 감항성 개선지시의 발행과 수신 확인 | 당국(AD), 설계 보유자(SB) | 수신 확인과 이행 기록을 남기는 운영자·MRO |
명세 문구 가운데 두 곳은 설계에 반영하기 전에 바로잡아야 한다. 첫째, PHASE 2의 certificationPackage 항목은 한국 당국을 "KCAA"라고 적고 있다. 한국에서 감항 인증을 책임지는 기관은 국토교통부(MOLIT)이며, 항공 조직과 항공기기술기준(KAS)을 통해 이를 수행한다. 당국 식별자에는 이 이름을 써야 한다. 둘째, serviceBulletin 그룹은 "감항성 개선지시(airworthiness directive)"를 발행하고 확인하는 것으로 기술되어 있다. 감항성 개선지시(AD)는 당국이 내리는 법적 구속력 있는 지시이고, 서비스 불리틴(SB)은 설계 보유자의 권고로서 AD나 운영자 자체 정비 프로그램이 인용할 때에만 의무가 된다. 한 그룹이 둘 다 실어 나를 수는 있지만, 이행 의무가 전혀 다르므로 모든 메시지는 자신이 어느 쪽인지 밝혀야 한다. 제6장은 이 구분을 수신 확인 흐름 전체에 걸쳐 따라간다.
명세는 componentCatalog가 형식 지정, 즉 승인된 형상, 적용 재료 규격, 대시 번호 간 호환성 같은 부품번호 수준의 사실을 다룬다고 말한다. 그런데 참조 SDK는 생성·조회·수정 호출을 /components에 붙이고, 그 대상 객체인 AircraftComponent는 일련번호, 제조일, 검사 이력을 가진다. 이것은 형식이 아니라 개체(instance)다.
두 수준은 모두 필요하며, 섞으면 실제 피해가 생긴다. 카탈로그 변경(예를 들어 옛 대시 번호를 대체하는 새 대시 번호)이 옛 형상으로 제작된 모든 일련번호의 이력을 다시 써서는 안 되고, 한 부품의 검사 소견이 카탈로그를 바꿔서도 안 된다. 실무적으로는 자원을 둘로 나눈다. 제작사와 부품번호로 식별되는 카탈로그 자원, 그리고 서버가 부여한 레코드 식별자로 식별되며 제작사·부품번호·일련번호 세 값이 유일 조합을 이루는 부품 자원이다. SDK의 /components 엔드포인트는 두 번째 자원에 해당한다. 이 책이 이를 componentCatalog로 분류하는 것은 명세에 개체 등록을 담당할 다른 그룹이 없기 때문일 뿐이다.
부품 하나를 따라가 보면 그룹들이 어떻게 맞물리는지 보인다. 공급자는 부품과 초도품 증거를 등록하고, 설계 보유자나 공급자가 인증 패키지를 제출한다. 운영자는 카탈로그를 읽어 해당 부품이 자기 기단에 장착 가능한지 확인한다. MRO는 부품을 장탈·검사·수리할 때마다 정비 이벤트를 기록한다. 당국이나 설계 보유자는 부품번호·일련번호 범위를 지정해 불리틴을 발행한다. 중고 부품을 제안받은 조직은 인수 전에 추적성 조회로 전체 이력을 확인한다. 어느 당사자도 두 개가 넘는 그룹에 쓰기 권한을 가질 필요가 없다. 이 관찰이 다음 절의 권한 모델을 이끈다.
PHASE 2는 인증에 OAuth를 쓰고 형식증명 당국 호출자에게는 상호 TLS(mTLS)를 적용한다고 하면서 "OAuth 2.1 (RFC 9700)"을 인용한다. 이 인용은 두 문서를 혼동한 것이다. RFC 9700은 IETF가 2025년 1월 발행한 OAuth 2.0 보안 모범 사례(Best Current Practice for OAuth 2.0 Security)이다. OAuth 2.1은 이와 별개의 통합 작업으로, 이 책을 쓰는 시점에는 아직 인터넷 드래프트 단계였다. 실무적으로는 OAuth 2.0(RFC 6749)과 베어러 토큰(RFC 6750)을 쓰고, RFC 9700의 강화 규칙을 따르는 것으로 읽으면 된다.
명세는 인증 당국 호출자를 콕 집어 상호 TLS를 요구한다. 타당한 판단이다. 이 호출은 형식설계 증거를 실어 나르거나 승인하므로 양쪽 모두 상대가 누구인지 강하게 확신해야 한다. RFC 8705에 따르면 클라이언트는 TLS 핸드셰이크에서 X.509 인증서를 제시하고, 인가 서버는 그 인증서의 해시를 토큰에 기록한다(cnf 클레임의 x5t#S256 멤버). 자원 서버는 다른 인증서로 인증된 연결에서 들어온 토큰을 거부한다. 아래는 그런 클라이언트 자격증명 요청의 예이다. 클라이언트 인증서는 TLS 계층에서 제시되므로 HTTP 본문에는 나타나지 않는다.
POST /oauth2/token HTTP/1.1
Host: auth.mro.example.net
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&client_id=hangar-7-records&scope=maintenance.write%20traceability.read
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
{
"access_token": "eyJhbGciOiJFUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 600,
"scope": "maintenance.write traceability.read"
}
예시처럼 토큰 수명을 10분으로 짧게 잡으면 유출 피해가 줄어든다. Cache-Control: no-store는 토큰이 중간 캐시에 남지 않게 하며, RFC 6749가 토큰 응답에 요구하는 사항이다.
명세는 범위 이름을 정하지 않는다. 작업 그룹을 그대로 반영한 배치가 감사하기 쉽다. catalog.read, catalog.write, maintenance.read, maintenance.write, certification.submit, certification.review, traceability.read, bulletin.issue, bulletin.acknowledge 같은 식이다. 이 이름들은 이 책의 제안이며 WIA-SPACE-016의 일부가 아니다. 어떤 이름을 쓰든 배포 측은 이를 OpenAPI 문서에 공개하여, 예컨대 어떤 MRO 클라이언트도 certification.review를 갖고 있지 않다는 사실을 감사자가 확인할 수 있게 해야 한다.
참조 SDK는 이 가운데 어느 것도 구현하지 않는다. WIAConfig에는 apiKey 문자열 하나만 있고, 비어 있으면 생성자가 "API key is required" 예외를 던진다. 요청 함수가 네트워크 호출을 전혀 하지 않으므로(5.7절) 이 키는 어떤 헤더에도 실리지 않는다. apiKey는 "어떤 자격증명"의 자리표시자로 보고, 이 장 뒤쪽의 전송 계층 스케치처럼 토큰 획득을 함수 하나 뒤에 감추어 두는 것이 좋다. 그러면 고정 키에서 OAuth 토큰으로 옮길 때 코드 한 곳만 바꾸면 된다.
@wia/aircraft-component 패키지(버전 1.0.0, Node.js 18 이상)는 WIAAircraftComponentClient, 보조 클래스 ComponentAnalyzer, 그리고 제4장의 모든 타입을 내보낸다. 클라이언트의 기본 기준 URL은 https://api.wia-standards.org/v1/aircraft-component, 기본 제한 시간은 60,000 ms이다. 기준 URL은 소스 코드에 들어 있는 기본 문자열일 뿐이며, 이 책은 그 주소에서 운영 서비스가 응답한다고 가정하지 않는다. 아래 예시는 모두 독자 자신의 배포 주소를 가리킨다.
| 메서드 | 경로 | SDK 메서드 | 작업 그룹 | 요청 본문 | 응답 data |
|---|---|---|---|---|---|
| GET | /components/{id} | getComponent | componentCatalog | — | AircraftComponent |
| POST | /components | createComponent | componentCatalog | Partial<AircraftComponent> | AircraftComponent |
| PUT | /components/{id} | updateComponent | componentCatalog | Partial<AircraftComponent> | AircraftComponent |
| POST | /components/search | searchComponents | componentCatalog | ComponentSearchQuery | AircraftComponent[] |
| POST | /components/{id}/analysis | performStructuralAnalysis | 명시된 그룹 없음(공학 증거) | { appliedLoad, analyst } | StructuralAnalysis |
| POST | /components/{id}/inspections | addInspection | maintenanceEvent | Omit<Inspection, 'id'> | Inspection |
| GET | /components/{id}/inspections | getInspectionHistory | maintenanceEvent | — | Inspection[] |
| POST | /components/{id}/certify | requestCertification | certificationPackage | { standard } | { requestId } |
| GET | /certifications/{certificateNumber}/verify | verifyCertification | certificationPackage | — | { valid, details } |
이 표를 5.1절과 대조하면 빈자리 두 곳이 드러난다. traceability에 해당하는 SDK 메서드, 즉 일련번호를 받아 부품의 생애를 돌려주는 메서드가 없고, serviceBulletin에 해당하는 메서드도 없다. ComponentSearchQuery가 type, status, manufacturer, certificationRequired, materialType만으로 걸러내므로, 지금은 레코드 식별자를 이미 알고 있어야만 일련번호로 부품을 찾을 수 있다. 출생 이력 추적성을 핵심 약속으로 내건 표준이라면 GET /traceability?manufacturer=…&partNumber=…&serialNumber=… 같은 조회가 구현자가 가장 먼저 추가해야 할 확장이다. 일련번호는 한 제작사의 한 부품번호 안에서만 유일하므로 세 키가 모두 필요하다.
검색은 POST를 쓴다. searchComponents는 필터를 JSON 본문으로 보낸다. 복잡한 필터가 URL과 접근 로그에 남지 않는 대신 HTTP 캐싱은 포기한다. 기록 시스템에서는 대개 옳은 절충이다.
수정은 부분 갱신인데 PUT을 쓴다. updateComponent는 Partial<AircraftComponent>를 받는다. HTTP 의미론(RFC 9110)에서 PUT은 대상의 상태를 동봉된 표현으로 교체하는 것이므로, 일부 필드만 담아 PUT으로 보내면 뜻이 모호해진다. certification이 빠져 있다면 "그대로 둔다"인가 "지운다"인가? 서버는 PUT에 완전한 레코드를 요구하거나, 생략된 멤버는 그대로 두고 null은 삭제로 해석하는 JSON Merge Patch(RFC 7396)를 PATCH로 제공해야 한다. 어느 쪽이든 OpenAPI 문서에 명시해야 한다.
해석은 계산이 아니라 요청이다. performStructuralAnalysis는 작용 하중(kN)과 해석자 신원만 보낸다. 서버 — 정확히는 그 뒤의 응력 해석 팀 — 가 응력, 안전계수, 피로 수명을 담은 StructuralAnalysis를 돌려주는 구조다. 실제 사업에서 이 결과는 자체 문서번호와 개정판을 가진 승인된 응력 보고서에서 나온다. API 레코드는 값을 즉석에서 계산한 것처럼 꾸미지 말고 metadata에 그 보고서를 인용해야 한다.
검사 기록은 추가만 한다. addInspection은 id를 제외하고 보내므로 서버가 식별자를 부여한다. 검사 기록의 수정·삭제 메서드는 없으며, 이는 규제 관행과 일치한다. 정비 기록의 잘못된 기재는 지우는 것이 아니라, 그것을 참조하는 새 기재로 정정한다.
인증은 비동기다. requestCertification은 증명서가 아니라 requestId를 돌려준다. 당국 심사는 어떤 HTTP 제한 시간보다도 훨씬 오래 걸리므로, HTTP에서는 상태 자원을 가리키는 Location 헤더와 함께 202 Accepted를 돌려주는 것이 자연스럽다. 나중에 verifyCertification이 "이 증명서 번호는 진본이며 유효한가?"라는 질문에 답한다. 서류와 함께 부품이 입고될 때 수입 검사자에게 바로 필요한 질문이다.
이력은 페이지 나눔이 없다. getInspectionHistory는 배열을 돌려준다. 수십 년 운용되는 구조 부품은 이력이 길게 쌓일 수 있으므로, 운영용 API는 커서 방식으로 페이지를 나누고 날짜 범위 필터를 제공해야 한다.
아래 코드는 SDK의 공개 인터페이스만 사용한다. 비행조종 계통 피팅 하나를 등록한 다음, 판정이 conditional이고 재검사일이 지정된 와전류 검사 기록을 붙인다. 부품 데이터는 모두 예시값이다. unwrap 도우미는 응답이 성공이면서 동시에 데이터를 담고 있을 때만 진행하도록 한다. 그 이유는 5.7절에서 분명해진다.
import {
WIAAircraftComponentClient,
APIResponse,
} from '@wia/aircraft-component';
const client = new WIAAircraftComponentClient({
apiKey: process.env.WIA_API_KEY ?? '', // 비어 있으면 생성자가 예외를 던진다
baseURL: 'https://mro.example.net/wia/v1/aircraft-component',
timeout: 30_000, // 기본값 60초를 덮어쓴다
debug: false,
});
// 감사 훅: 호출마다 한 번, (모의) 전송이 반환되기 전에 실행된다.
client.on('request', ({ method, path }) => {
console.info(`[audit] ${method} ${path}`);
});
// `success` 만 믿지 말고 `data` 존재까지 확인한다.
function unwrap<T>(res: APIResponse<T>, what: string): T {
if (!res.success || res.data === undefined) {
const code = res.error?.code ?? 'NO_DATA';
throw new Error(`${what} failed: ${code} ${res.error?.message ?? ''}`);
}
return res.data;
}
export async function registerAndInspect(): Promise<void> {
// 1. 일련번호가 있는 부품을 등록한다(모든 값은 예시).
const part = unwrap(
await client.createComponent({
type: 'flight-control',
status: 'maintenance',
partNumber: 'EX-FC-2210-07',
serialNumber: 'SN-000123',
manufacturer: 'Example Actuation Ltd',
manufacturingDate: '2019-04-11',
dimensions: { length: 412, width: 96, height: 88, weight: 3.4 },
material: {
type: 'aluminum-7075',
grade: '7075-T6 (illustrative)',
strength: 570, // MPa, 핸드북상 전형적 인장강도
fatigueLimit: 159, // MPa, 예시값
density: 2810, // kg/m3 (2.81 g/cm3 x 1000)
corrosionResistance: 'medium',
},
inspectionHistory: [],
metadata: { cageCode: 'X0000', cyclesSinceNew: 18250 },
}),
'createComponent',
);
if (!part.id) {
throw new Error('server did not assign a record id');
}
// 2. 조건부 판정의 와전류 검사 기록을 붙인다.
const inspection = unwrap(
await client.addInspection(part.id, {
inspectionDate: '2026-03-02',
inspector: 'NDT-L2-0457',
method: 'eddy-current',
result: 'conditional',
findings: 'Indication at lug bore below reject level; re-inspect',
nextInspectionDate: '2026-09-02',
}),
'addInspection',
);
console.info(`inspection ${inspection.id} stored for ${part.serialNumber}`);
}
예제에는 의도한 점이 두 가지 있다. 핸드북은 보통 밀도를 g/cm³로 적지만, 타입 정의가 요구하는 대로 kg/m³로 표기했다(변환은 제2장 참조). 또 사이클 수와 제작사 CAGE 코드는 1.0 타입에 해당 필드가 없으므로 metadata에 담았다. 제4장은 이를 추가형 확장으로 정식화할 것을 권고한다.
SDK는 모든 결과를 APIResponse<T>로 감싼다. 불리언 success, 타입 T의 선택적 data, 그리고 문자열 code와 사람이 읽는 message를 가진 선택적 error 객체로 이루어진다. 반면 명세는 오류 본문이 RFC 7807을 대체한 HTTP API 문제 상세(Problem Details for HTTP APIs), 즉 RFC 9457을 따른다고 말한다. 두 모델을 서로 다른 계층에 두면 충돌하지 않는다. 문제 상세는 서버와 클라이언트 사이의 전송 형식이고, APIResponse는 SDK가 애플리케이션 코드에 건네는 프로세스 내부 봉투다. 앞의 것을 뒤의 것으로 옮기는 것이 SDK의 일이다.
RFC 9457은 미디어 타입 application/problem+json으로 전달되는 JSON 객체와 다섯 개의 표준 멤버를 정의한다. type(문제 유형을 식별하는 URI, 없으면 about:blank), title(발생할 때마다 바뀌지 않아야 하는 짧은 요약), status(HTTP 상태 코드), detail(이번 발생에 대한 설명), instance(이번 발생을 식별하는 URI)이다. 확장 멤버를 둘 수 있으므로 SDK 방식의 code는 여기에 넣으면 된다.
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
Content-Language: en
{
"type": "https://problems.example.org/wia-aircraft-component/duplicate-serial",
"title": "Serial number already registered for this part number",
"status": 409,
"detail": "S/N SN-000123 is already registered under P/N EX-FC-2210-07 (CAGE X0000).",
"instance": "urn:uuid:5b0e7f5c-2d1a-4d6e-9c3b-8a1f0e2d4c77",
"code": "DUPLICATE_SERIAL",
"existingId": "cmp-7f3a91"
}
type과 code를 둘 다 싣는 것은 의도된 중복이다. type URI는 RFC 9457이 요구하는 안정적이고 전역적으로 유일한 식별자이고, 짧은 code는 switch 문과 로그 검색에 편리하다. 배포 측은 대응표를 한 번 공개하고, 같은 코드를 다른 유형에 재사용해서는 안 된다. PHASE 2의 부속서 E는 관련 규칙을 덧붙인다. 새 오류 코드를 추가하는 것은 호환성을 깨지 않지만, 필드의 이름을 바꾸거나 제거하는 것은 오류 본문에서라도 버전 증가를 요구한다. 따라서 클라이언트 코드는 모르는 code를 만나면 멈추지 말고 해당 HTTP 상태 계열의 일반 실패로 처리해야 한다.
SDK에 등장하는 코드는 REQUEST_FAILED 하나뿐이다. 아래 표는 HTTP 상태 코드와 깔끔하게 대응하고, 부품 기록 서비스가 실제로 마주치는 문제를 포괄하도록 이 책이 제안하는 전체 목록이다.
code | HTTP 상태 | 발생 상황 | 클라이언트 조치 |
|---|---|---|---|
VALIDATION_FAILED | 422 | JSON 형식은 맞지만 스키마 위반(잘못된 열거값, 일련번호 누락, ISO 8601이 아닌 날짜) | 데이터를 고친다. 그대로 재시도하지 않는다 |
UNAUTHENTICATED | 401 | 토큰 없음, 만료, 다른 인증서에 결속됨 | 새 토큰을 받아 한 번 재시도 |
FORBIDDEN_SCOPE | 403 | 토큰은 유효하나 해당 그룹의 범위가 없음 | 중단하고 거버넌스 절차로 권한 요청 |
NOT_FOUND | 404 | 알 수 없는 레코드 식별자나 증명서 번호 | 식별자의 출처를 확인 |
DUPLICATE_SERIAL | 409 | 제작사 + P/N + S/N 조합이 이미 등록됨 | 조사한다. 데이터 이관 오류나 의심 부품의 징후일 수 있다 |
PRECONDITION_FAILED | 412 | If-Match 개정판이 낡음 | 다시 읽고 병합한 뒤 재제출 |
PRECONDITION_REQUIRED | 428 | If-Match 없이 수정 요청 | 개정판 헤더를 추가 |
RATE_LIMITED | 429 | 클라이언트 할당량 초과 | Retry-After만큼 기다린 뒤 재시도 |
REQUEST_FAILED | 502/503 또는 없음(네트워크) | 상위 시스템이나 전송 계층 실패 | 멱등 호출이면 백오프 후 재시도 |
DUPLICATE_SERIAL 행은 특히 강조할 만하다. 대부분의 API에서 충돌은 귀찮은 일에 불과하지만, 여기서는 안전 신호일 수 있다. 서로 다른 두 실물 부품이 같은 신원을 주장하는 것은 비인가 의심 부품의 고전적인 징후이며, 제6장은 이를 그렇게 다룬다. 서버는 기존 레코드를 조용히 돌려주지 말고 호출자 신원과 함께 그 시도를 기록해야 한다.
명세는 속도 제한에 대해 RFC 6585를 가리킨다. 이 RFC는 상태 코드 429 Too Many Requests를 정의하고, 응답에 Retry-After 헤더를 넣을 수 있게 한다. Retry-After의 문법은 HTTP 의미론(RFC 9110)에 정의되어 있으며, 초 단위 숫자나 HTTP 날짜 중 하나를 쓴다.
HTTP/1.1 429 Too Many Requests
Retry-After: 30
Content-Type: application/problem+json
{
"type": "https://problems.example.org/wia-aircraft-component/rate-limited",
"title": "Request rate exceeded for this client",
"status": 429,
"code": "RATE_LIMITED"
}
올바른 클라이언트는 Retry-After가 있으면 따르고, 없으면 무작위 지터를 섞은 지수 백오프를 쓴다. 그래야 장애 뒤에 격납고 단말 여러 대가 동시에 재접속하면서 서버에 한꺼번에 몰리는 일을 막을 수 있다. 재시도는 멱등 호출에서만 안전하다. 중요한 POST를 멱등으로 만드는 방법은 5.6절에서 다룬다.
구현체마다 다섯 그룹 중 지원하는 범위가 다를 수 있으므로, 클라이언트는 데이터를 보내기 전에 서버가 무엇을 제공하는지 물어볼 수 있어야 한다. PHASE 2는 작업 탐색에 IETF의 well-known URI 방식, 즉 RFC 8615를 지정한다. RFC 8615는 사이트 전체 메타데이터를 위해 /.well-known/ 경로 접두어를 예약하고, 각 접미어를 IANA에 등록하도록 요구한다. 명세는 접미어를 정하지 않았으므로 아래의 wia-aircraft-component는 예시다. 운영 배포에서는 WIA 작업반이 등록하는 접미어를 써야 한다.
GET /.well-known/wia-aircraft-component HTTP/1.1
Host: mro.example.net
Accept: application/json
HTTP/1.1 200 OK
Content-Type: application/json
{
"standard": "WIA-SPACE-016",
"document": "PHASE-2-API-INTERFACE",
"specVersion": "1.0.0",
"apiBase": "https://mro.example.net/wia/v1/aircraft-component",
"openapi": "https://mro.example.net/wia/v1/aircraft-component/openapi.json",
"tokenEndpoint": "https://auth.mro.example.net/oauth2/token",
"operationGroups": {
"componentCatalog": { "status": "supported" },
"maintenanceEvent": { "status": "supported" },
"traceability": { "status": "partial",
"reason": "history limited to parts overhauled at this site" },
"serviceBulletin": { "status": "supported" },
"certificationPackage": { "status": "excluded",
"justification": "repair station; does not submit type-design evidence" }
},
"profile": "WIA-P2-API-INTERFACE-Edge-only/2",
"manifest": "https://mro.example.net/wia/v1/aircraft-component/manifest.dsse.json"
}
이 문서는 API와 그 OpenAPI 기술이 어디 있는지, 토큰은 어디서 받는지, 각 그룹이 지원·부분 지원·제외 중 어느 상태인지를 알려준다. 상태 값은 부속서 I가 프로파일 매니페스트에 규정한 세 가지와 같다. 이 문서를 기준으로 작성한 클라이언트는 업무 흐름 중간에 404를 맞고서야 빈자리를 알게 되는 대신, 처음부터 "이 수리 조직은 인증 패키지를 받지 않는다"고 분명하게 실패할 수 있다.
탐색 데이터는 공격자에게 매력적인 표적이다. 탐색 문서를 바꿀 수 있다면 인증 당국 클라이언트를 가짜 토큰 엔드포인트나 가짜 API로 돌려보낼 수 있다. TLS는 전송 중인 문서를 보호하지만, 웹 서버가 침해되었거나 미러가 낡은 사본을 내보내는 경우까지 막지는 못한다. 그래서 PHASE 2는 매니페스트 엔드포인트를 DSSE 봉투를 사용하는 Sigstore로 서명하도록 요구한다.
DSSE(Dead Simple Signing Envelope)는 어떤 형식의 페이로드든 감쌀 수 있다. 봉투에는 payloadType 문자열, base64로 인코딩한 payload, 그리고 각각 keyid와 sig를 가진 하나 이상의 서명이 들어간다. 서명은 페이로드 원문이 아니라, 페이로드 유형과 길이를 바이트열에 결속하는 사전 인증 인코딩(PAE) 위에서 계산된다. 이 덕분에 공격자가 서명된 문서를 다른 종류의 문서로 재해석하게 만들 수 없다.
{
"payloadType": "application/vnd.example.wia-manifest+json",
"payload": "eyJzdGFuZGFyZCI6IldJQS1TUEFDRS0wMTYiLCJzcGVjVmVyc2lvbiI6...",
"signatures": [
{
"keyid": "mro-example-manifest-2026q3",
"sig": "MEUCIQDx...kA=="
}
]
}
Sigstore는 여기에 Rekor 투명성 로그를 더한다. 매니페스트에 서명하면 Rekor에 항목이 기록되고, 검증자는 서명이 유효한지뿐 아니라 특정 시각에 공개적으로 기록되었는지도 확인할 수 있다. 증거 패키지의 SBOM과 프로파일 매니페스트도 같은 Sigstore 키로 서명하므로(부속서 G, I) 검증 절차 하나로 세 가지를 모두 처리한다.
명세는 서명 키를 "배포 정책에 따라" 교체한다고만 말한다. 실행 가능한 정책은 네 요소로 이루어진다. 다음 공개키를 사용 전에 미리 공개하여 검증자가 알 수 있게 한다. 정해진 날짜부터 새 키로 서명하되 옛 공개키는 검증용으로 계속 제공한다. Rekor 타임스탬프로 각 옛 서명이 그 키가 유효할 때 만들어졌음을 입증하여, 키가 퇴역한 뒤에도 과거 매니페스트를 검증할 수 있게 한다. 그리고 키가 침해되었을 때를 위한 긴급 폐기 경로를 두되, 전체 이력이 아니라 침해 시점 이후에 기록된 서명만 무효로 한다. Sigstore는 OIDC 신원에 대해 짧은 수명의 인증서를 발급받는 "키리스(keyless)" 서명도 지원한다. 장기 개인키를 아예 없앨 수 있는 대신 신원 제공자에 의존하게 된다.
클라이언트는 eventemitter3 패키지를 바탕으로 on(event, callback)을 제공한다. 내부적으로 호출이 있을 때마다 { method, path, body }와 함께 'request'를, 전송 계층이 예외를 던지면 'error'를 발생시킨다. 이는 감사 로그, 지표 수집, 디버깅에 쓸모 있는 프로세스 내부 훅이다. 서버 측 이벤트 구독은 아니다. 1.0.0의 어떤 기능도 다른 조직이 정비 이벤트를 기록했거나 불리틴을 발행했다는 사실을 클라이언트에 알려주지 않는다. 그러려면 웹훅이나 메시지 스트림이 필요하며, 명세는 이를 구현자에게 맡긴다. 또 'request' 이벤트는 요청 본문 전체를 받는다는 점에 유의해야 한다. 감사 로그 저장소가 API보다 신뢰 수준이 낮다면, 기록하기 전에 검사자 이름 같은 개인정보를 가려야 한다.
격납고는 네트워크 환경이 열악하다. 정비사의 태블릿이 검사 기록을 올리고, 응답이 도착하기 전에 Wi-Fi가 끊기면 앱은 서버가 기록을 저장했는지 알 수 없다. 평범한 POST를 재시도하면 같은 검사가 두 번 기록될 수 있고, 재시도하지 않으면 한 번도 기록되지 않을 수 있다. 두 결과 모두 정비 기록을 오염시키며, 특히 두 번째는 실제로 수행한 검사를 감춰 버릴 수 있다.
표준적인 해법은 멱등 키다. 클라이언트는 논리적 작업마다 고유한 값(보통 UUID)을 만들어 모든 시도에 함께 보낸다. 서버는 그 키를 요청의 지문, 그리고 그때 만든 응답과 함께 저장한다. 같은 키와 같은 본문으로 재시도하면 저장된 응답을 그대로 재생하고, 같은 키를 다른 본문에 재사용하면 거부한다. IETF HTTP API 작업반에는 바로 이 용도의 Idempotency-Key 헤더를 정의하는 인터넷 드래프트가 있다. 이 책을 쓰는 시점에는 아직 RFC로 발행되지 않았으므로, 아래 예시의 상태 코드는 이 책의 관례다.
POST /components/cmp-7f3a91/inspections HTTP/1.1
Host: mro.example.net
Authorization: Bearer eyJhbGciOiJFUzI1NiIs...
Content-Type: application/json
Idempotency-Key: "0f8e2b9a-6a51-4c1e-a7d3-3b5c9e1d2f40"
{ "inspectionDate": "2026-03-02", "method": "eddy-current", "result": "conditional", ... }
--- 첫 번째 전달 ---
HTTP/1.1 201 Created
Location: /components/cmp-7f3a91/inspections/insp-0192
--- 연결이 끊긴 뒤 재시도: 같은 키, 같은 본문 ---
HTTP/1.1 201 Created
Location: /components/cmp-7f3a91/inspections/insp-0192
(저장된 응답을 재생, 두 번째 기록은 생기지 않음)
--- 같은 키, 다른 본문 ---
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
이 API에서 반드시 키를 붙여야 하는 작업은 세 가지다. addInspection, 앞으로 추가될 모든 maintenanceEvent 쓰기, 그리고 requestCertification이다. 인증 요청이 중복되면 당국의 시간을 낭비하고 감사 추적을 흐린다. 서버는 클라이언트가 오프라인으로 머물 수 있는 가장 긴 기간보다 오래 키를 보관해야 한다. 근무 교대가 끝날 때 하루치 작업을 동기화하는 태블릿이라면 적어도 그만큼의 기간이 필요하다. 멱등 키는 제6장의 "최소 한 번(at-least-once)" 전달 모델에 안전성을 준다. 최소 한 번 전달에 멱등 수신이 더해지면 결과적으로 정확히 한 번 처리한 것과 같아진다.
부품 레코드는 여러 조직이 수정한다. 공급자가 제조일을 정정하는 동안 MRO가 상태를 maintenance로 바꿀 수 있다. 평범한 PUT이라면 두 번째로 쓴 쪽이 첫 번째 변경을 조용히 덮어쓴다. HTTP에는 이를 막는 도구가 이미 있다. 서버는 모든 표현에 ETag를 붙여 돌려주고, 클라이언트는 수정할 때 이를 If-Match에 담아 보낸다. 그사이 레코드가 바뀌었다면 서버는 412 Precondition Failed로 답한다. RFC 6585는 여기에 428 Precondition Required를 더해, 전제 조건 없이 들어온 수정 요청을 서버가 아예 거부할 수 있게 한다.
GET /components/cmp-7f3a91 HTTP/1.1
...
HTTP/1.1 200 OK
ETag: "rev-14"
PUT /components/cmp-7f3a91 HTTP/1.1
If-Match: "rev-14"
Content-Type: application/json
{ ...status 가 "maintenance" 인 전체 레코드... }
HTTP/1.1 412 Precondition Failed (다른 쪽이 rev-15 를 먼저 기록함)
HTTP/1.1 428 Precondition Required (If-Match 헤더 누락)
상태 변경에서 이는 위생 차원을 넘어선다. ComponentStatus가 maintenance에서 production으로 돌아가는 것은 사실상 사용 가능 판정(return to service)이다. 이 판정은 불합격 검사 이전의 사본이 아니라 최신 레코드에 근거해야 한다.
SDK의 기본 제한 시간 60초는 레코드 조회에는 넉넉하지만 큰 인증 패키지를 올리기에는 짧다. 조회와 작은 쓰기에는 짧은 제한 시간을, 증거 파일에는 비동기 업로드 방식(임시 저장소에 올린 뒤 요청에서 참조)을 쓰는 것이 합리적인 분리다. PHASE 2의 부속서 E는 여기서 중요한 운영 메모 두 가지를 덧붙인다. 타임스탬프에 의존하는 기한에는 시계 동기화가 필요하며(대부분 NTPv4로 충분하고, 결정론적 연동이 필요한 곳은 PTP), 구현체는 p95 지연, 오류율, 위반 시간을 담은 월간 오류 예산 요약을 공개하여 운영자가 테넌트 데이터를 노출하지 않고도 제공자를 비교할 수 있게 해야 한다는 것이다. 둘 다 비규범적이지만 도입 비용이 낮다.
@wia/aircraft-component 1.0.0을 설치하고 5.3절의 예제를 돌려보면 모든 호출이 성공한다. 그것이 무언가가 작동한다는 증거는 아니다. 이 절은 그 이유와 대처법을 설명한다.
모든 공개 메서드는 비공개 함수 makeRequest로 끝난다. 이 함수의 본문에는 운영 환경에서는 실제 HTTP 요청을 보낼 것이라는 주석이 있고, 현재는 'request' 이벤트를 발생시킨 뒤 { success: true, data: body }를 돌려준다. 즉 요청 본문 자체를 호출자가 기대하는 타입으로 형변환해 돌려줄 뿐이다. 네트워크 호출도, 헤더도 없고, 설정한 제한 시간과 API 키도 쓰지 않는다. 결과는 쉽게 확인할 수 있다.
const c = await client.createComponent({ partNumber: '', serialNumber: '' });
// { success: true, data: { partNumber: '', serialNumber: '' } } <- id 없음, 검증 없음
const r = await client.requestCertification('cmp-7f3a91', 'CS-25');
// { success: true, data: { standard: 'CS-25' } } <- r.data.requestId 는 undefined
const v = await client.verifyCertification('XX-0000');
// { success: true, data: undefined } <- 아무것도 확인하지 않았는데 "성공"
실습 예제에서 createComponent는 id 없는 부품을 돌려준다. 요청에 식별자가 없었고, 식별자를 부여할 서버도 없었기 때문이다. 명시적 확인이 없다면 다음 호출은 검사 기록을 /components/undefined/inspections로 보내고도 성공을 보고할 것이다. 예제가 식별자를 검사하고 unwrap이 data 존재를 고집하는 이유가 여기에 있다. 이렇게 작성한 코드는 모의 계층이 실제 전송 계층으로 바뀌어도 고칠 필요가 없다.
함께 제공되는 시뮬레이터도 성격이 같다. 통신 프로토콜 탭은 네 개의 엔드포인트 시험 결과를 "200 OK", "201 Created"로 고정해 출력하며, 그중 두 경로(/api/components/AC-001/certification, /api/components/AC-001/stress-analysis)는 SDK의 경로와 아예 다르다. 이는 교육용 삽화이지 시험이 아니다. 마찬가지로 PHASE 2 본문은 api/openapi.yaml을 언급하지만 저장소에는 그 파일이 없다. 파일이 생기기 전까지는 SDK의 타입 정의와 이 장의 엔드포인트 표가 인터페이스 계약에 가장 가까운 문서다.
아래 스케치는 실제 makeRequest가 해야 할 일을 보여준다. 토큰 공급자에서 베어러 토큰을 받아 붙이고, AbortController로 설정된 제한 시간을 적용하고, JSON을 보내고, RFC 9457 문제 응답을 APIResponse로 옮기고, 제한 시간 초과와 그 밖의 네트워크 실패를 구분한다. Node.js 18 이상은 fetch와 AbortController를 전역으로 제공한다. 재시도, 멱등 키, ETag 처리는 이 함수보다 한 계층 위에 둔다.
// 실제 전송 계층의 스케치. @wia/aircraft-component 1.0.0 에는 없는 코드다.
async function httpRequest<T>(
cfg: { baseURL: string; timeout: number },
getToken: () => Promise<string>,
method: string,
path: string,
body?: unknown,
extraHeaders: Record<string, string> = {},
): Promise<APIResponse<T>> {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), cfg.timeout);
try {
const res = await fetch(cfg.baseURL + path, {
method,
signal: ctrl.signal,
headers: {
Authorization: `Bearer ${await getToken()}`,
Accept: 'application/json, application/problem+json',
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
...extraHeaders,
},
body: body === undefined ? undefined : JSON.stringify(body),
});
if (res.ok) {
const data = res.status === 204 ? undefined : ((await res.json()) as T);
return { success: true, data };
}
const type = res.headers.get('content-type') ?? '';
if (type.startsWith('application/problem+json')) {
const p = await res.json();
return {
success: false,
error: { code: p.code ?? String(p.type), message: p.detail ?? p.title ?? '' },
};
}
return { success: false, error: { code: `HTTP_${res.status}`, message: res.statusText } };
} catch (e: any) {
const code = e?.name === 'AbortError' ? 'TIMEOUT' : 'REQUEST_FAILED';
return { success: false, error: { code, message: String(e?.message ?? e) } };
} finally {
clearTimeout(timer);
}
}
// 부품 데이터로 만드는 경로 조각은 반드시 인코딩한다:
const path = `/components/${encodeURIComponent(id)}/inspections`;
마지막 줄은 SDK의 미묘한 결함을 다룬다. SDK는 식별자를 경로에 그대로 끼워 넣는다. 레코드 식별자는 대개 안전하지만, 기존 시스템에서 넘어온 증명서 번호와 일련번호에는 슬래시, 공백 같은 예약 문자가 들어 있을 수 있다. encodeURIComponent를 쓰면 일련번호가 조용히 다른 경로로 바뀌는 일을 막을 수 있다.
SDK에는 정적 도우미 네 개도 들어 있다. 두 개는 단순하며 그 범위 안에서는 옳다. calculateStress(force, area)는 힘을 면적으로 나누고, calculateSafetyFactor(materialStrength, appliedStress)는 둘의 비를 돌려준다. isSafe(safetyFactor, minimumSafetyFactor = 1.5)는 그 비를 기준값과 비교한다. 네 번째인 estimateFatigueLife는 절대 써서는 안 된다.
import { ComponentAnalyzer as CA } from '@wia/aircraft-component';
// 단위는 호출자 책임이다: N / mm^2 = MPa.
const stress = CA.calculateStress(50_000, 400); // 125 (MPa)
const wrong = CA.calculateStress(50, 400); // 0.125 -- kN / mm^2 = GPa 이다!
const fs = CA.calculateSafetyFactor(570, stress); // 4.56
CA.isSafe(fs); // true (기준값 1.5)
// 반면교사: 공학 판단에 쓰지 말 것.
CA.estimateFatigueLife(570, 570); // 1.0e8 cycles at S = Su (물리적으로 불가능)
CA.estimateFatigueLife(285, 570); // 약 8.0e8 사이클
단위. 도우미 함수들은 단위를 가리지 않으므로 호출자가 일관성을 지켜야 한다. StructuralAnalysis.appliedLoad는 kN, calculatedStress는 MPa 단위다. 1 MPa는 1 N/mm²이므로, 면적(mm²)으로 나누기 전에 하중을 뉴턴으로 바꿔야 한다. 이 변환을 잊으면 응력은 천 배 작게, 안전계수는 천 배 크게 나온다.
1.5라는 기준값. isSafe의 기본값은 수송류 기준(14 CFR / CS 25.303)에서 제한하중과 극한하중 사이에 두는 안전계수 1.5를 떠올리게 한다. 그러나 이 계수는 하중에 적용된다. 재료 강도를 응력과 비교해 그 비가 1.5 이상인지 묻는 것은 특정 조건에서만 동등하다. 거동이 선형이고, 응력을 제한하중에서 계산했고, 강도 값이 핸드북의 전형적 인장강도가 아니라 통계적 근거를 가진 설계 허용치여야 한다. 그렇다 해도 제한하중에서 해로운 영구 변형이 없어야 한다는 별도 요구(25.305), 피로, 손상 허용에 대해서는 아무것도 말해주지 않는다. isSafe는 시연용 편의 함수이지 적합성 판정이 아니다.
피로 수명 추정. estimateFatigueLife는 N = 108 − 3·log10(S/Su)를 계산한다. 응력이 극한강도와 같을 때 이 식은 108 사이클을 예측하지만, 실제 시편은 극한강도까지 하중을 받으면 첫 하중에서 파단된다. 극한강도의 절반에서는 약 8 × 108 사이클을 예측하는데, 이 역시 어떤 알루미늄 합금 시험으로도 뒷받침되지 않는 값이다. 식의 모양은 Basquin형 S–N 관계를 닮았지만 상수가 곡선의 엉뚱한 끝에 고정되어 있고, 평균 응력, 응력 집중, 표면 상태, 환경을 모두 무시한다. WIA 레코드의 피로 수명은 시험 기반 S–N 데이터나 승인된 피로·손상 허용 해석(제7장)에서 가져오고 출처를 밝혀야 한다. 이 도우미가 만든 값이 부품 레코드에 나타난다면 그 자체를 결함으로 다뤄야 한다.
| 항목 | 참조 SDK 1.0.0 | 운영에 필요한 수준 |
|---|---|---|
| 제한 시간을 갖춘 실제 HTTP 전송 | 없음(모의) | 필수 |
| RFC 9700을 따르는 OAuth 2.0 토큰, 당국 호출자 mTLS | 없음(쓰이지 않는 API 키) | 필수 |
| RFC 9457 문제 상세 해석 | 없음 | 필수 |
| 제작사 + P/N + S/N 추적성 조회 | 메서드 없음 | 필수 |
| 서비스 불리틴 / AD 작업 | 메서드 없음 | 프로파일이 해당 그룹을 주장하는 경우 |
| 정비·인증 쓰기의 멱등 키 | 없음 | 필수 |
| 수정 시 ETag / If-Match | 없음 | 필수 |
| well-known 탐색과 DSSE 서명 매니페스트 | 없음 | 필수 |
| 공개된 OpenAPI 문서 | 언급만 있고 파일 없음 | 필수 |
| 피로 수명 추정 | 물리적으로 틀린 도우미 | 제거하고 승인된 해석을 인용 |
이런 빈자리가 있다고 SDK가 쓸모없는 것은 아니다. 타입은 PHASE 1 데이터 모델을 충실히 표현하고, 메서드 목록은 자원 배치의 합리적인 초안이며, APIResponse 봉투는 애플리케이션 코드와의 경계로 편리하다. 이 장에서 설명한 대로 전송·보안·신뢰성 계층을 채워 넣는 뼈대로 쓴다면 건전한 출발점이 된다. 제6장은 이제 이 작업들을 실제로 움직여, 부품 하나의 생애 동안 공급자, MRO, 운영자, 당국 사이를 오가는 메시지를 따라간다.