OpenAPI 문서와 실제 응답 어긋남 잡기: 스펙 기준 응답 검사와 AI 문서 보강 순서
문서에 없는 필드, 다른 에러 형식, 바뀐 타입을 테스트에서 실패로 만드는 방법

핵심 요약
- 문서 어긋남은 스펙과 구현이 따로 있거나, 에러 응답이 문서화되지 않거나, 눈에 안 띄는 타입이 바뀔 때 생긴다.
- OpenAPI 3.1 스키마는 JSON Schema 2020-12의 상위 집합이라 nullable을 type 배열로 표현한다.
- 실제 응답을 상태 코드별 스키마로 검사하면 예시 서버에서 미문서화 필드·에러 형식·타입 변경 6건이 드러났다.
- AI 문서 보강은 응답 검사가 0건인 상태에서 시작하고, 생성된 예제도 스키마로 검증한다.
다른 팀이나 외부에 공개하는 API에서 문서는 계약이다. 그런데 구현은 릴리스마다 바뀌고 문서는 몇 버전 전에 멈춰 있어, 클라이언트 개발자가 사라진 필드를 참조하거나 실제와 다른 에러 형식을 추측으로 처리하는 일이 생긴다. AI로 문서 설명을 그럴듯하게 채워도 구현과 맞지 않으면 의미가 없다. 이 글은 스펙과 실제 응답을 기계적으로 대조해 어긋남을 테스트 실패로 만드는 방법을 먼저 다루고, 그 위에서 AI로 문서를 보강하는 순서를 정리한다. 스펙에서 클라이언트 타입을 생성하는 방법은 OpenAPI로 API 계약 지키기에서 다뤘다.
문서가 구현과 어긋나는 흔한 이유
| 원인 | 예 | 결과 |
|---|---|---|
| 스펙과 구현이 따로 존재 | 구현에 필드를 추가하고 스펙 파일은 그대로 | 문서에 없는 필드가 응답에 섞인다 |
| 성공 응답만 문서화 | 409, 404 응답 형식이 코드에만 있음 | 클라이언트가 에러 처리를 추측한다 |
| 눈에 안 띄는 속성 변경 | 시각 필드를 문자열에서 숫자 타임스탬프로 | 파싱 오류가 운영에서 처음 드러난다 |
가장 안정적인 해결은 요청·응답 타입을 코드에 정의하고 거기서 스펙을 생성하는 것이다(예: FastAPI는 Pydantic 모델로 스펙을 만든다). 다만 생성 방식이어도 핸들러가 모델과 다른 값을 돌려주면 어긋남이 생기므로, 실제 응답을 스펙으로 검사하는 단계는 여전히 필요하다.
OpenAPI 3.1 스키마로 응답 검사하기
OpenAPI 3.1의 Schema Object는 JSON Schema Draft 2020-12의 상위 집합이다. 그래서 nullable 필드는 "type": ["string", "null"]처럼 JSON Schema 방식으로 적을 수 있다. Responses Object는 상태 코드를 키로 쓰며, 키는 "200"처럼 따옴표로 감싸야 한다. 아래는 예시 Users API 스펙이다. 성공 응답과 함께 409·404의 에러 형식도 정의했다.
{
"openapi": "3.1.0",
"info": { "title": "Users API", "version": "1.2.0" },
"paths": {
"/users": {
"post": {
"requestBody": { "content": { "application/json": { "example": { "email": "[email protected]", "display_name": "Kim" } } } },
"responses": {
"201": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/User" } } } },
"409": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
},
"/users/{id}": {
"get": {
"responses": {
"200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/User" } } } },
"404": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
}
},
"components": {
"schemas": {
"User": {
"type": "object",
"required": ["id", "email", "display_name", "created_at"],
"properties": {
"id": { "type": "string" },
"email": { "type": "string" },
"display_name": { "type": ["string", "null"] },
"created_at": { "type": "string" }
},
"additionalProperties": false
},
"Error": {
"type": "object",
"required": ["code", "message"],
"properties": { "code": { "type": "string" }, "message": { "type": "string" } },
"additionalProperties": false
}
}
}
}예시 서버는 실제 서비스에서 흔히 생기는 어긋남 세 가지를 일부러 담았다. 응답에 문서에 없는 plan 필드가 있고, 중복 이메일일 때 { "error": ... } 형식으로 응답하며, 단건 조회에서는 created_at을 숫자 타임스탬프로 돌려준다.
// 문서와 조금씩 어긋난 예시 API 서버
import http from 'node:http';
export function start(port = 0) {
const users = new Map();
return new Promise((resolve) => {
const srv = http.createServer(async (req, res) => {
const send = (status, body) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); };
if (req.method === 'POST' && req.url === '/users') {
let raw = ''; for await (const c of req) raw += c;
const b = JSON.parse(raw);
if ([...users.values()].some((u) => u.email === b.email)) return send(409, { error: 'email already exists' }); // 문서와 다른 에러 형식
const u = { id: String(users.size + 1), email: b.email, display_name: b.display_name, created_at: new Date().toISOString(), plan: 'free' }; // 문서에 없는 필드
users.set(u.id, u);
return send(201, u);
}
const m = req.url.match(/^\/users\/(\w+)$/);
if (req.method === 'GET' && m) {
const u = users.get(m[1]);
return u ? send(200, { ...u, created_at: Date.parse(u.created_at) }) : send(404, { code: 'not_found', message: 'user not found' }); // 200에서 created_at 타입이 다름
}
send(404, { code: 'not_found', message: 'no route' });
}).listen(port, () => resolve(srv));
});
}응답 검사 스크립트
스펙의 요청 예제로 엔드포인트를 호출하고, 응답 상태 코드에 해당하는 스키마로 본문을 검사한다. JSON Schema 전체가 아니라 type(배열 포함), required, properties, additionalProperties만 보는 부분 검사기다. 실제 프로젝트에서는 JSON Schema 검증 라이브러리를 쓴다.
// 사용법: node check-drift.mjs
// openapi.json의 응답 스키마로 실제 응답을 검사한다. JSON Schema 전체가 아니라
// type(배열 포함), required, properties, additionalProperties만 보는 부분 검사기다.
import { readFileSync } from 'node:fs';
import { start } from './server.mjs';
const spec = JSON.parse(readFileSync('openapi.json', 'utf8'));
const resolve = (s) => (s.$ref ? resolve(s.$ref.split('/').slice(1).reduce((o, k) => o[k], spec)) : s);
const typeOf = (v) => (v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v === 'number' ? 'number' : typeof v);
function validate(schema, value, path = '$') {
const s = resolve(schema);
const errs = [];
const types = [].concat(s.type || []);
if (types.length && !types.includes(typeOf(value)) && !(types.includes('integer') && Number.isInteger(value))) {
return [`${path}: 타입 ${typeOf(value)} (스펙 ${types.join('|')})`];
}
if (typeOf(value) === 'object') {
for (const r of s.required || []) if (!(r in value)) errs.push(`${path}.${r}: 필수 필드 없음`);
for (const [k, v] of Object.entries(value)) {
if (s.properties?.[k]) errs.push(...validate(s.properties[k], v, `${path}.${k}`));
else if (s.additionalProperties === false) errs.push(`${path}.${k}: 스펙에 없는 필드`);
}
}
return errs;
}
const srv = await start();
const base = `http://127.0.0.1:${srv.address().port}`;
const call = async (method, path, body) => {
const res = await fetch(base + path, { method, headers: { 'content-type': 'application/json' }, body: body && JSON.stringify(body) });
return { status: res.status, body: await res.json() };
};
const example = spec.paths['/users'].post.requestBody.content['application/json'].example;
const cases = [
['POST /users', 'post', '/users', '/users', example],
['POST /users (중복)', 'post', '/users', '/users', example],
['GET /users/1', 'get', '/users/{id}', '/users/1'],
['GET /users/999', 'get', '/users/{id}', '/users/999'],
];
let drift = 0;
for (const [name, method, specPath, url, body] of cases) {
const r = await call(method.toUpperCase(), url, body);
const resp = spec.paths[specPath][method].responses[String(r.status)];
const errs = resp ? validate(resp.content['application/json'].schema, r.body) : [`응답 코드 ${r.status}가 스펙에 없음`];
drift += errs.length;
console.log(`${errs.length ? 'DRIFT' : 'OK '} ${name} → ${r.status}`);
errs.forEach((e) => console.log(` - ${e}`));
}
srv.close();
console.log(`불일치 ${drift}건`);
process.exitCode = drift ? 1 : 0;$ node check-drift.mjs # 2026-10-09
DRIFT POST /users → 201
- $.plan: 스펙에 없는 필드
DRIFT POST /users (중복) → 409
- $.code: 필수 필드 없음
- $.message: 필수 필드 없음
- $.error: 스펙에 없는 필드
DRIFT GET /users/1 → 200
- $.created_at: 타입 number (스펙 string)
- $.plan: 스펙에 없는 필드
OK GET /users/999 → 404
불일치 6건
exit=1세 가지 어긋남이 모두 6건의 불일치로 드러났다. 특히 409 응답은 성공 경로만 테스트하면 절대 걸리지 않는 부분이다. 스펙에 "additionalProperties": false를 두었기 때문에 plan 같은 미문서화 필드도 잡혔다. 이 옵션을 켜지 않으면 필드 추가는 통과하므로, 공개 API라면 켜 두고 필드를 추가할 때 스펙도 함께 바꾸게 하는 편이 안전하다.
AI로 설명과 예제를 보강하는 순서
- 응답 검사가 0건인 상태를 먼저 만든다. 구조가 맞지 않는 문서에 설명을 보강하면 틀린 내용을 더 그럴듯하게 만들 뿐이다.
- 필드마다 단위와 형식을 AI에 초안으로 쓰게 한다. 예: 시각은 ISO 8601 UTC 문자열, 금액은 최소 단위 정수.
- 요청·응답 예제를 생성하게 하고, 예제 자체도 응답 검사 스크립트로 스키마를 통과하는지 확인한다.
- 에러 응답마다 어떤 조건에서 나오는지 설명을 붙인다. AI가 추측한 정책(재시도 가능 여부, 인증 방식)은 코드나 테스트로 확인되기 전까지 확정하지 않는다.
- 스키마 변경과 문서 변경이 같은 PR에 들어가게 하고, 응답 검사를 CI에서 실행한다.
버전 관리와 변경 기록
스펙 파일을 저장소에서 함께 관리하고 릴리스마다 변경 기록을 남긴다. 필드 추가는 대체로 안전하지만 필드 제거, 타입 변경, 선택 필드의 필수화는 기존 클라이언트를 깨므로 예고와 마이그레이션 안내가 필요하다. 위 예시의 created_at 타입 변경이 바로 그런 경우다. 에러 응답 형식을 서버 전체에서 하나로 맞추는 방법은 Route Handlers와 Server Actions 선택 기준과 서버 액션 에러 핸들링에서 다룬다.
초보자가 자주 실수하는 포인트
- 성공 응답만 테스트해 에러 응답 형식 불일치를 놓친다.
- additionalProperties를 열어 두어 미문서화 필드가 계속 쌓인다.
- 구조가 틀린 문서에 AI로 설명부터 채운다.
- 필드 타입 변경을 예고 없이 배포한다.
체크리스트
- 성공·에러 응답 모두에 스키마가 있다.
- 실제 응답을 스펙으로 검사하는 테스트가 CI에서 돈다.
- 공개 API 스키마에 additionalProperties 정책을 정했다.
- AI가 만든 예제가 스키마 검사를 통과한다.
- 하위 호환을 깨는 변경에 예고와 변경 기록이 있다.
자주 묻는 질문
스펙을 코드에서 생성하면 이 검사가 필요 없지 않나요?
생성 방식이어도 핸들러가 모델과 다른 값을 돌려주거나, 에러 응답을 모델 없이 직접 만들면 어긋남이 생깁니다. 실제 응답을 검사하는 단계는 생성 방식과 함께 두는 것이 안전합니다.
OpenAPI 3.0의 nullable은 3.1에서 어떻게 바꾸나요?
3.1은 JSON Schema 2020-12를 따르므로 type에 "null"을 함께 넣는 방식으로 표현합니다. 이 글의 display_name이 그 예입니다.
참고 자료 · 검증 기준
위 자료와 내용을 대조한 날짜: . 도구·서비스 정책은 이후 바뀔 수 있으므로 적용 전 공식 문서를 다시 확인하세요.
이 글은 위 참고 자료를 바탕으로 정리했으며, 내용은 운영 과정에서 순차적으로 보완될 수 있습니다.