에러 응답 스키마는 한 번만 정의하고, 모든 엔드포인트는 $ref 한 줄로 가리킨다. 이 글은 swagger-php의 PHP 8 어트리뷰트로 그 구조를 만든 기록입니다.

왜 이 주제인가

저는 오랫동안 API 문서를 apidocjs로 썼습니다. 주석으로 쓰고 정적 HTML을 뽑는 방식이라 가볍고 편했습니다. 그런데 제품이 여러 개로 늘고 MSA 구조로 쪼개지면서 문서에 요구하는 게 달라졌습니다. 게이트웨이에 import할 수 있어야 하고, 클라이언트 SDK를 생성할 수 있어야 하고, CI에서 스펙을 기계적으로 검증할 수 있어야 했습니다. 그건 OpenAPI 표준 스펙이어야 가능합니다.

그래서 올해 6월 zircote/swagger-php로 넘어갈 준비를 했습니다. 공통 기반인 ci4-board에도 Swagger를 붙였습니다. 설치와 스캔은 금방 됐는데, 실제로 엔드포인트에 어트리뷰트를 붙이기 시작하자 곧 한 가지 문제가 눈에 들어왔습니다. 에러 응답이었습니다.

인라인 에러 응답의 문제

swagger-php 예제를 그대로 따라 하면 에러 응답을 각 메서드 안에 직접 적게 됩니다.

#[OA\Get(
    path: '/v1/licenses/{id}',
    responses: [
        new OA\Response(response: 200, description: 'OK',
            content: new OA\JsonContent(ref: '#/components/schemas/License')),
        new OA\Response(response: 401, description: 'Unauthorized'),
        new OA\Response(response: 404, description: 'Not Found'),
    ]
)]

엔드포인트가 몇 개일 때는 괜찮습니다. 수십 개가 되면 세 가지가 무너집니다.

  1. 본문이 비어 있다. description만 있고 스키마가 없으면 문서상으로는 401이 무엇을 돌려주는지 알 수 없습니다. 프론트엔드 개발자는 결국 실제로 호출해 보고 형태를 추측합니다.
  2. 복사본끼리 어긋난다. 스키마를 인라인으로 채우기 시작하면, 어느 엔드포인트에는 traceId가 있고 어느 엔드포인트에는 빠지는 일이 생깁니다. 서버 코드는 같은 핸들러가 응답을 만드는데 문서만 제각각이 됩니다.
  3. AI가 매번 새로 지어낸다. 클로드코드에게 엔드포인트를 추가하라고 하면, 에러 응답 부분을 그때그때 그럴듯하게 새로 씁니다. 리뷰할 때마다 이걸 잡아야 했습니다.

해결책은 OpenAPI가 원래 제공하는 components와 $ref입니다. 문제는 PHP 어트리뷰트로 이걸 어디에, 어떻게 선언하느냐였습니다.

출발점: 이미 정해 둔 에러 규약

문서보다 규약이 먼저였습니다. 8월에 클로드코드용 커스텀 스킬 api-error-contract를 만들면서, 모든 서비스의 실패 응답을 RFC 9457 Problem Details 형식으로 통일했습니다. Content-Type은 application/problem+json입니다.

{
  "type": "about:blank",
  "title": "Request validation failed",
  "status": 422,
  "code": "COMMON.VALIDATION.FAILED",
  "instance": "/v1/users",
  "traceId": "01K2N8YQ4B7C3D9E",
  "errors": [
    { "field": "email", "code": "VALIDATION.INVALID", "message": "Must be a valid email address." }
  ]
}

표준 필드(type, title, status, detail, instance) 위에 확장 필드 세 개를 얹었습니다.

필드

필수

역할

code

O

기계 판독용 코드. <SERVICE>.<RESOURCE>.<REASON> 형식, 한 번 배포하면 바꾸지 않음

traceId

O

요청 단위 상관관계 ID. 응답 본문, X-Trace-Id 헤더, 로그에 같은 값

errors

△

필드 단위 오류. validation 실패에서만, 항상 배열

서버 쪽은 이미 정리돼 있었습니다. 컨트롤러는 ApiException을 던지기만 하고, 응답 조립은 ApiExceptionHandler 한 곳에서만 합니다. 서버 코드에 조립 지점이 하나라면, 문서에도 정의 지점이 하나여야 맞습니다. 이 글의 목표가 그것입니다.

1단계: 스키마 컴포넌트 세 개

스키마는 app/OpenApi/Schemas/ 아래 빈 클래스에 어트리뷰트로만 선언합니다. ApiException 클래스에 직접 붙이지 않은 이유는 단순합니다. 예외 클래스는 서버 동작, 스키마 클래스는 문서 계약이라 바뀌는 이유가 다릅니다. 섞어 두면 리팩터링할 때 문서가 같이 흔들립니다.

먼저 모든 에러의 공통 뼈대인 ProblemDetails입니다.

<?php

namespace App\OpenApi\Schemas;

use OpenApi\Attributes as OA;

#[OA\Schema(
    schema: 'ProblemDetails',
    required: ['type', 'title', 'status', 'code', 'traceId'],
    properties: [
        new OA\Property(property: 'type', type: 'string', format: 'uri', example: 'about:blank'),
        new OA\Property(property: 'title', type: 'string', example: 'Access token has expired'),
        new OA\Property(property: 'status', type: 'integer', example: 401),
        new OA\Property(property: 'code', type: 'string', pattern: '^[A-Z_]+(\\.[A-Z_]+){2}$',
            example: 'AUTH.TOKEN.EXPIRED'),
        new OA\Property(property: 'detail', type: 'string'),
        new OA\Property(property: 'instance', type: 'string', example: '/v1/licenses/1234'),
        new OA\Property(property: 'traceId', type: 'string', example: '01K2N8YQ4B7C3D9E'),
    ],
    type: 'object'
)]
final class ProblemDetails {}

code에 pattern을 건 게 작은 장치입니다. 명명 규칙 <SERVICE>.<RESOURCE>.<REASON>을 문서 안에 기계가 읽을 수 있는 형태로 남겨 두면, 나중에 contract test나 SDK 쪽에서 그대로 검증에 쓸 수 있습니다.

다음은 필드 단위 오류 한 건입니다.

#[OA\Schema(
    schema: 'FieldError',
    required: ['field', 'code', 'message'],
    properties: [
        new OA\Property(property: 'field', type: 'string', example: 'email'),
        new OA\Property(property: 'code', type: 'string', example: 'VALIDATION.INVALID'),
        new OA\Property(property: 'message', type: 'string', example: 'Must be a valid email address.'),
    ],
    type: 'object'
)]
final class FieldError {}

마지막으로 validation 실패 전용 ValidationProblem입니다. 여기서 allOf를 씁니다. ProblemDetails를 복사하지 않고 "그것 전부 + errors 배열"이라고 선언합니다.

#[OA\Schema(
    schema: 'ValidationProblem',
    allOf: [
        new OA\Schema(ref: '#/components/schemas/ProblemDetails'),
        new OA\Schema(
            required: ['errors'],
            properties: [
                new OA\Property(property: 'errors', type: 'array',
                    items: new OA\Items(ref: '#/components/schemas/FieldError')),
            ]
        ),
    ]
)]
final class ValidationProblem {}

이렇게 하면 ProblemDetails에 필드를 하나 추가해도 ValidationProblem은 자동으로 따라옵니다. 서버 쪽에서 ValidationException extends ApiException인 관계가 문서에서도 그대로 표현됩니다.

2단계: 응답 컴포넌트와 problem+json 함정

스키마만 재사용하면 절반입니다. 엔드포인트마다 new OA\Response(response: 401, description: ..., content: ...)를 여전히 반복해야 하니까요. OpenAPI는 응답 자체도 #/components/responses/ 아래에 등록해 재사용할 수 있습니다.

swagger-php에서는 연산(OA\Get 등) 바깥, 클래스 레벨에 OA\Response를 붙이고 response에 숫자 대신 이름 문자열을 주면 컴포넌트로 등록됩니다. 저는 이것들을 ErrorResponses 클래스 하나에 모았습니다.

<?php

namespace App\OpenApi;

use OpenApi\Attributes as OA;

#[OA\Response(
    response: 'Unauthorized',
    description: '인증 실패 (토큰 없음·만료·서명 불일치)',
    headers: [new OA\Header(header: 'X-Trace-Id', schema: new OA\Schema(type: 'string'))],
    content: new OA\MediaType(
        mediaType: 'application/problem+json',
        schema: new OA\Schema(ref: '#/components/schemas/ProblemDetails')
    )
)]
#[OA\Response(
    response: 'NotFound',
    description: '리소스 없음',
    headers: [new OA\Header(header: 'X-Trace-Id', schema: new OA\Schema(type: 'string'))],
    content: new OA\MediaType(
        mediaType: 'application/problem+json',
        schema: new OA\Schema(ref: '#/components/schemas/ProblemDetails')
    )
)]
#[OA\Response(
    response: 'ValidationFailed',
    description: '요청 검증 실패',
    headers: [new OA\Header(header: 'X-Trace-Id', schema: new OA\Schema(type: 'string'))],
    content: new OA\MediaType(
        mediaType: 'application/problem+json',
        schema: new OA\Schema(ref: '#/components/schemas/ValidationProblem')
    )
)]
// Forbidden, Conflict, TooManyRequests, InternalError 도 같은 형태
final class ErrorResponses {}

함정: JsonContent를 쓰면 안 된다

처음에는 습관대로 content: new OA\JsonContent(ref: ...)로 썼습니다. 스펙은 잘 생성됐고 Swagger UI에도 예쁘게 나왔습니다. 그런데 생성된 YAML을 보면 미디어 타입이 application/json으로 박혀 있습니다. JsonContent는 application/json 고정 축약형이기 때문입니다.

서버는 application/problem+json을 보내는데 문서는 application/json이라고 말하는 셈입니다. 사람이 읽을 때는 티가 안 나지만, 이 스펙으로 SDK를 생성하거나 게이트웨이에서 응답 검증을 켜면 바로 어긋납니다. 그래서 에러 응답만은 OA\MediaType으로 미디어 타입을 명시합니다. 성공 응답은 지금처럼 JsonContent를 써도 됩니다.

traceId를 헤더로도 문서화

규약상 traceId는 본문과 X-Trace-Id 헤더 양쪽에 나갑니다. 처음에는 헤더도 #/components/headers/에 등록해 참조하려 했습니다. 그런데 응답의 headers는 헤더 이름을 맵 키로 갖기 때문에, 참조만 걸면 이름이 어디로 들어가는지 생성 결과가 애매해집니다. 그래서 헤더는 ErrorResponses 안에 인라인으로 적었습니다. 반복은 있지만 그 반복이 이 파일 한 곳에 갇혀 있으니 문제가 되지 않습니다.

3단계: 컨트롤러에서는 $ref 한 줄

이제 엔드포인트의 에러 응답은 참조 한 줄씩입니다.

#[OA\Post(
    path: '/v1/licenses/{id}/activate',
    summary: '라이선스 활성화',
    security: [['bearerAuth' => []]],
    tags: ['Licenses'],
    responses: [
        new OA\Response(response: 200, description: 'OK',
            content: new OA\JsonContent(ref: '#/components/schemas/License')),
        new OA\Response(response: 401, ref: '#/components/responses/Unauthorized'),
        new OA\Response(response: 404, ref: '#/components/responses/NotFound'),
        new OA\Response(response: 409, ref: '#/components/responses/Conflict'),
        new OA\Response(response: 422, ref: '#/components/responses/ValidationFailed'),
    ]
)]
public function activate(string $id): ResponseInterface

여기서 response: 401은 상태 코드, ref는 컴포넌트 이름입니다. 컴포넌트를 상태 코드가 아니라 의미(Unauthorized, NotFound)로 이름 붙인 덕분에, 같은 ProblemDetails 본문을 여러 상태 코드에서 문맥에 맞게 골라 쓸 수 있습니다.

문자열 오타를 상수로 막기

'#/components/responses/NotFuond' 같은 오타는 PHP 입장에서는 그냥 문자열이라 아무 경고도 없습니다. 스펙 생성은 성공하고, 깨진 참조는 Swagger UI를 열어 봐야 드러납니다. 그래서 참조 경로를 상수로 모았습니다.

<?php

namespace App\OpenApi;

final class Ref
{
    public const PROBLEM            = '#/components/schemas/ProblemDetails';
    public const VALIDATION_PROBLEM = '#/components/schemas/ValidationProblem';

    public const UNAUTHORIZED      = '#/components/responses/Unauthorized';
    public const FORBIDDEN         = '#/components/responses/Forbidden';
    public const NOT_FOUND         = '#/components/responses/NotFound';
    public const CONFLICT          = '#/components/responses/Conflict';
    public const VALIDATION_FAILED = '#/components/responses/ValidationFailed';
    public const TOO_MANY_REQUESTS = '#/components/responses/TooManyRequests';
    public const INTERNAL_ERROR    = '#/components/responses/InternalError';
}

어트리뷰트 인자에는 클래스 상수를 쓸 수 있으므로, 컨트롤러는 이렇게 바뀝니다.

new OA\Response(response: 404, ref: Ref::NOT_FOUND),
new OA\Response(response: 422, ref: Ref::VALIDATION_FAILED),

이제 오타는 PhpStorm에서 바로 빨간 줄이 되고, PHPStan이 정의되지 않은 상수로 잡습니다. 문서 계약이 정적 분석의 보호를 받게 된 것입니다.

클로드코드에게 알려 주기

이 구조의 숨은 수확은 AI 쪽이었습니다. api-error-contract 스킬과 프로젝트 CLAUDE.md에 한 줄을 추가했습니다.

에러 응답 문서는 인라인으로 쓰지 않는다. App\OpenApi\Ref의 상수만 참조한다. 새 에러 유형이 필요하면 ErrorResponses에 먼저 추가한다.

그 뒤로 클로드코드가 엔드포인트를 추가할 때 에러 응답을 새로 지어내는 일이 사라졌습니다. 고를 수 있는 선택지가 상수 목록으로 좁혀지니 결과물이 일정해집니다.

4단계: 생성하고, 기계로 검증하기

스캔 경로에 app/OpenApi를 넣는다

스펙은 spark 커맨드 php spark api:docs로 생성합니다. 여기서 한 번 막혔습니다. 기존 커맨드가 Controllers와 Entities만 스캔하고 있어서, 새로 만든 app/OpenApi/의 컴포넌트가 스펙에 아예 들어가지 않았습니다. 컨트롤러의 $ref는 존재하지 않는 대상을 가리키게 됩니다.

$openapi = \OpenApi\Generator::scan([
    APPPATH . 'Controllers',
    APPPATH . 'Entities',
    APPPATH . 'OpenApi',   // 공통 컴포넌트 — 빠뜨리면 참조가 전부 깨진다
]);
file_put_contents(FCPATH . 'docs/openapi.yaml', $openapi->toYaml());

Spectral로 깨진 참조 잡기

생성이 성공했다고 스펙이 유효한 건 아닙니다. 해결되지 않는 $ref는 Spectral 린트가 잡아 줍니다.

npx @stoplight/spectral-cli lint public/docs/openapi.yaml

테스트로 규약 강제하기

상수와 린트로도 막지 못하는 게 하나 남습니다. 누군가(사람이든 AI든) 4xx·5xx 응답을 다시 인라인으로 쓰는 경우입니다. 문법적으로는 완벽하게 유효하니까요. 그래서 생성된 스펙을 읽어 규약을 검사하는 PHPUnit 테스트를 하나 두었습니다.

public function testErrorResponsesUseSharedComponents(): void
{
    $spec = \OpenApi\Generator::scan([APPPATH . 'Controllers', APPPATH . 'OpenApi']);
    $doc  = json_decode($spec->toJson(), true);

    foreach ($doc['paths'] as $path => $ops) {
        foreach ($ops as $method => $op) {
            foreach ($op['responses'] ?? [] as $status => $res) {
                if ((int) $status < 400) {
                    continue;
                }
                $this->assertArrayHasKey('$ref', $res,
                    strtoupper($method) . " {$path} {$status}: 에러 응답은 #/components/responses 를 참조해야 함");
            }
        }
    }
}

실패 메시지에 메서드·경로·상태 코드를 넣어 두면, 클로드코드가 테스트 실패를 보고 스스로 어디를 고쳐야 하는지 압니다. 훅으로 테스트가 자동 실행되니 규약 위반은 커밋 전에 걸립니다.

CI에서 문서 미갱신 잡기

마지막으로 CI에서 스펙을 다시 생성해, 커밋된 파일과 다르면 실패시킵니다. 자체 호스팅 러너에서 도는 기존 파이프라인에 세 줄을 더했습니다.

- run: php spark api:docs
- run: git diff --exit-code public/docs/openapi.yaml || (echo "API 문서 미갱신" && exit 1)
- run: npx @stoplight/spectral-cli lint public/docs/openapi.yaml

돌아보며

정리하면 구조는 이렇습니다.

위치

담당

바뀌는 이유

app/OpenApi/Schemas/

ProblemDetails, FieldError, ValidationProblem

에러 본문 규약이 바뀔 때

app/OpenApi/ErrorResponses.php

재사용 응답 컴포넌트

새 에러 유형이 생길 때

app/OpenApi/Ref.php

참조 경로 상수

위 두 곳에 추가가 있을 때

컨트롤러

Ref::NOT_FOUND 같은 한 줄

엔드포인트가 바뀔 때

작업하면서 배운 것은 세 가지입니다.

  1. 규약이 먼저, 문서는 그 다음이다. api-error-contract로 에러 형식을 먼저 고정해 두지 않았다면, 문서의 $ref는 아무것도 통일하지 못했을 겁니다. 문서는 이미 존재하는 합의를 기계가 읽을 수 있게 옮기는 일입니다.
  2. 서버와 문서의 구조를 맞춘다. 서버는 ApiExceptionHandler 한 곳에서 에러를 조립하고, 문서는 ErrorResponses 한 곳에서 에러를 정의합니다. 두 구조가 대칭이면 한쪽을 고칠 때 다른 쪽 어디를 봐야 할지 바로 보입니다.
  3. 문자열 계약은 기계가 지키게 한다. 사람의 주의력이나 AI의 기억에 맡기면 언젠가 어긋납니다. 상수, 정적 분석, 테스트, CI 순으로 그물을 겹쳐 두면 어느 하나를 빠져나가도 다음에서 걸립니다.

apidocjs에서 @apiDefine과 @apiUse로 하던 재사용을 OpenAPI에서는 components와 $ref로 합니다. 개념은 같지만, 결과물이 표준 스펙이라는 점에서 쓸 수 있는 곳이 훨씬 넓어졌습니다.