2026년 09월 14일

16

배포된 API 스펙을 저장소로 되돌려오기: OpenAPI 하나를 계약으로 쓰기까지

생산성

ChatGPT Image 2026년 9월 14일 오후 05_29_51

배포된 API 스펙을 저장소로 되돌려오기: OpenAPI를 계약으로 관리하기

1. 도입 배경

정산 시스템에서 계정과목 표시명 필드를 변경하는 작업을 한 적이 있습니다. 응답 필드가 바뀌어 API 코드를 다시 생성하고 화면에 연결했는데, PR에는 이 작업과 관계없는 병원 관리 API 생성물까지 함께 들어갔습니다.

당시에는 Orval이 개발 서버의 OpenAPI URL을 직접 읽고 React Query 훅과 MSW 핸들러를 생성했습니다. 생성된 파일도 저장소에 커밋했습니다. 제가 필요한 API를 수정하는 동안 서버에는 다른 변경도 배포됐고, 재생성은 그 변경까지 가져온 것입니다.

커밋 메시지에 codegen 동반 산출물이라고 적었지만, 리뷰에서는 의도한 변경인지, 별도 동기화 PR로 나누는 것이 나은지 질문이 나왔습니다. 결국 기능 변경과 무관한 생성물을 제거했습니다. 이후 생성 파일에 포맷을 일괄 적용했을 때는 계약 변경 없이 259개 파일이 바뀌기도 했습니다.

CRM에서는 다른 구조를 사용했습니다. OpenAPI 스펙을 Git에 남기고 생성 코드는 제외하며, 배포된 스펙의 변경은 봇 PR로 받았습니다. 동기화 파이프라인은 팀 동료가 설계하고 구현했습니다. 저는 그 위에서 기능을 개발하면서 정산에서 겪었던 문제를 이 구조에 대입해 볼 수 있었습니다.

2. 생성 결과 대신 입력과 생성 규칙을 관리했습니다

CRM은 프로젝트 초기부터 openapi.json을 커밋하고, Orval이 만드는 API 코드와 MSW 핸들러 배럴 파일은 Git에서 제외했습니다. .gitignore에 들어 있는 경로는 다음과 같습니다.

1src/api/generated 2src/mocks/handlers.ts

명령도 스펙을 가져오는 작업과 코드를 생성하는 작업으로 나뉩니다.

1pnpm codegen 2pnpm codegen:sync -- --mode development

codegen은 저장소의 스펙만 읽습니다. codegen:sync는 외부 스펙을 확인하고, 변경이 있으면 파일을 갱신한 뒤 코드를 생성합니다. 개발자는 검토된 스펙을 pull한 뒤 다시 생성하면 됩니다. 현재는 postinstall에도 생성 명령이 연결돼 있어 의존성을 설치할 때 훅과 타입이 만들어집니다.

이 구분 덕분에 CI는 타입 검사에 필요한 코드를 만들려고 백엔드 서버에 접속하지 않아도 됩니다. 의존성이 준비돼 있다면 로컬에서도 서버 없이 생성할 수 있습니다.

정산에서 겪었던 일을 이 구조에 대입하면 리뷰 대상이 달라집니다. 생성물을 함께 커밋할 때는 기능 수정과 원격 스펙의 다른 변경, 생성기와 포맷터가 만든 변경이 섞였습니다. 스펙을 커밋하면 계약 변경을 먼저 보고, 그 계약이 애플리케이션에 미치는 영향은 재생성과 검사 결과로 확인할 수 있습니다.

그렇다고 openapi.json 하나만 있으면 되는 것은 아니었습니다. 저장소를 분리하는 과정에서 Orval의 버전 범위가 새로운 버전을 허용하면서 기존 환경과 다른 버전이 설치됐습니다. query key와 mutation 생성 형태가 달라졌고 타입 검사가 실패했습니다. 이후 기존 환경에서 사용하던 8.8.1을 정확히 고정했습니다.

생성 결과를 저장하지 않는 대신, 생성 조건을 관리합니다.

같은 스펙만으로 기존 생성 결과를 재현한다고 볼 수는 없습니다. 스펙과 함께 생성기 버전, 설정, transformer, lockfile을 관리해야 합니다.

3. 같은 계약으로 훅과 목업을 만들었습니다

OpenAPI를 사용한다고 백엔드를 기다리는 시간이 저절로 줄어들지는 않습니다. 구현이 끝난 뒤 문서를 생성하고 프론트엔드가 그때부터 작업한다면 순서는 그대로입니다. 병렬 개발을 하려면 먼저 사용할 수 있는 계약이 있어야 합니다.

합의한 요청·응답 구조를 OpenAPI로 준비하면 프론트엔드는 훅과 목업을 생성해 화면을 만들고, 백엔드는 같은 계약에 맞춰 서버를 구현할 수 있습니다.

1openapi.json 2└── Orval 3 ├── TypeScript 모델과 API 요청 함수 4 ├── TanStack Query 훅 5 ├── MSW 핸들러 6 └── Zod 스키마

CRM의 Orval 설정에서 출력 설정만 분리하면 다음과 같습니다.

1const output = { 2 mode: 'tags-split', 3 schemas: 'src/api/generated/models', 4 client: 'react-query', 5 mock: true, 6 override: { 7 mutator: { 8 path: './src/api/client.ts', 9 name: 'customClient', 10 }, 11 }, 12};

요청 함수와 타입은 스펙에서 생성하고, 인증이나 공통 오류 처리는 customClient에 연결합니다. 생성 후에는 스크립트가 태그별 .msw.ts 파일을 모아 핸들러 배럴을 만듭니다. 다음 명령으로 실행하면 이 핸들러와 도메인별 목업을 사용해 화면을 개발할 수 있습니다.

1pnpm dev -- --mode msw

같은 입력에서 출발한다는 점은 수동으로 작성한 타입과 목업이 따로 변하는 여지를 줄여 줍니다. 특정 화면 상태나 저장 후 재조회처럼 생성된 임의 데이터만으로 확인하기 어려운 동작은 도메인별 핸들러로 보완했습니다.

물론 필요한 계약이 제때 준비되지 않는 경우도 있었습니다. 고객 요약 탭을 만들 때 특이사항 등록·수정·삭제에 사용할 API가 스펙에 없었습니다. 조회 데이터에는 날짜와 순번이 있었지만 단일 식별자는 없었습니다.

이를 바탕으로 날짜와 순번을 복합키로 가정하고, 임시 요청 함수를 notes-contract.ts에 모았습니다. 실제 요청은 보내지 않도록 NOTES_API_READY = false로 저장과 삭제 버튼을 잠갔습니다. 모달은 열 수 있게 두어 입력 화면과 이탈 동작을 확인했습니다.

이후 정식 스펙이 들어왔을 때는 여러 부분이 달랐습니다. 서버는 복합키 대신 단일 식별자를 제공했습니다. 수정·삭제에 필요한 식별자를 받으려면 기존 상세 응답 대신 전용 목록 API를 사용해야 했습니다. 시안에 맞춰 잡았던 본문 상한도 실제 계약과 달랐습니다.

임시 요청 함수와 실행 잠금을 제거하고 생성 훅으로 연결하면서 읽기 경로, 폼 제한, 목업과 테스트를 함께 수정했습니다. 상수까지 담고 있던 파일 자체를 없앤 것은 아니지만, 가정했던 요청 코드는 걷어낼 수 있었습니다.

이 경험에서는 가정이 맞았는지보다 틀렸을 때 어디를 교체해야 하는지가 더 실질적인 문제였습니다. 화면을 먼저 만드는 데는 도움이 됐지만, 그 상태를 API 연동 완료로 취급하지 않고 요청 실행을 막아 둔 것이 필요했습니다.

4. 목업과 HTTP 클라이언트의 동작도 확인했습니다

요약 탭의 API를 연결한 뒤에는 목업에서 다른 문제가 나왔습니다. 처음에는 기존 API를 조합해 요약 화면을 만들었기 때문에 해당 요청을 처리하는 임시 핸들러도 작성했습니다. 이후 전용 요약 API로 옮겼지만, 더 이상 사용하지 않는 핸들러가 남아 있었습니다.

리뷰에서 그중 하나가 관리내역 탭의 목업을 가리고 있다는 지적을 받았습니다. 요약 핸들러가 먼저 등록돼 같은 요청에 응답하면서 뒤의 관리내역 전용 핸들러까지 요청이 도달하지 않았습니다. 관리내역 탭에는 검색과 페이지네이션을 지원하는 데이터셋 대신 하드코딩된 한 건만 나타났습니다.

제가 더 이상 호출하지 않는 요청이어도 등록된 핸들러는 다른 화면의 요청에 계속 응답하고 있었습니다. 사용하지 않는 핸들러와 상태를 제거하고, 관리내역 조회가 해당 도메인의 전체 데이터셋을 반환하는 통합 테스트를 추가했습니다. 생성 핸들러를 기본으로 두고 필요한 시나리오만 덮는 구조에서도 직접 작성한 핸들러의 범위와 수명은 따로 관리해야 했습니다.

응답 형식도 맞춰야 했습니다. 공용 HTTP 클라이언트는 서버의 { success, data } 응답에서 data를 꺼내 반환합니다. Orval에서는 반환 타입을 여기에 맞추려고 스키마의 공통 래퍼를 제거했는데, 같은 변환이 MSW 생성에도 적용됐습니다.

생성 목업이 payload만 반환하면 클라이언트는 그 안에서 다시 data를 찾다가 null을 반환했습니다. 필요한 도메인에서는 다음 형식으로 감싸는 공용 함수를 사용했습니다.

1return HttpResponse.json({ 2 success: true, 3 data, 4});

생성 타입과 런타임 반환값의 차이는 요약 카드 리뷰에서도 나왔습니다. 응답 타입은 non-nullable이었지만, HTTP 파이프라인은 응답에 data가 없으면 null로 폴백하고 있었습니다. 내부의 타입 단언 때문에 TypeScript는 이 경로를 드러내지 못했습니다.

타입 검사는 통과했지만 해당 응답을 넣으면 카드가 에러 경계로 떨어졌습니다. 필드 접근 전에 null 상태를 처리하고 같은 응답으로 다시 실패하지 않도록 회귀 테스트를 추가했습니다.

같은 OpenAPI에서 생성했다는 것만으로 HTTP 동작까지 맞는 것은 아니었습니다. 애플리케이션이 사용하는 반환 타입과 네트워크로 전달되는 응답 형식, 그 응답을 변환하는 클라이언트를 함께 확인해야 했습니다.

5. 배포된 스펙을 PR로 가져왔습니다

커밋된 스펙으로 개발하면 같은 브랜치의 개발자는 같은 입력을 사용할 수 있습니다. 하지만 백엔드가 다른 계약으로 배포됐을 때 저장소의 스펙이 저절로 바뀌지는 않습니다.

CI도 이 차이를 알 수 없습니다. 커밋된 스펙으로 생성한 타입과 애플리케이션 코드가 맞으면 검사는 통과합니다. 배포된 서버와 저장소가 서로 다른 계약을 보고 있어도 저장소 안의 일관성은 유지될 수 있습니다.

팀은 이 공백을 확인하기 위해 spec:diff를 추가했습니다. 경로의 존재 여부뿐 아니라 요청·응답 스키마의 일부 구조 변화도 비교합니다. 다만 개발 서버가 네트워크 접근 제한으로 보호돼 있어 GitHub Actions에서 라이브 스펙을 읽기 어려웠습니다.

이후 배포 과정에서 발행한 스펙을 S3에서 읽는 구조로 바뀌었습니다. 백엔드 쪽은 Jenkins 배포 후 health check를 거친 컨테이너에서 OpenAPI를 가져와 환경별 경로에 발행하고, 프론트엔드 저장소에 이벤트를 보내는 규칙을 담당했습니다. 프론트엔드는 S3 객체와 이벤트를 받아 계약 변경을 PR로 제안합니다.

1Backend 배포 23컨테이너 health check 45환경별 S3에 OpenAPI와 배포 출처 발행 67repository_dispatch 89Frontend GitHub Actions 1011저장소 스펙과 비교 1213반영할 변경이 있으면 Bot PR 생성 또는 갱신 1415코드 생성과 PR 검사 1617개발자가 계약과 소비 코드를 검토한 뒤 머지

수신 이벤트는 openapi-spec-uploaded이고, payload의 환경이 development일 때 자동 동기화를 진행합니다. 정기 실행과 수동 실행도 지원합니다. staging과 production 스펙은 각각의 환경 비교에 사용합니다.

S3를 사용하면 CI가 라이브 API의 접근 경로와 실행 상태에 직접 의존하지 않아도 됩니다. 객체 메타데이터에는 백엔드 커밋과 빌드 정보가 남으므로 어떤 배포에서 나온 스펙인지 추적할 수 있습니다.

프론트엔드가 소비하는 기준은 여전히 Git의 openapi.json입니다. S3는 배포된 계약을 전달하는 upstream이고, 저장소에는 검토해서 받아들인 스냅샷을 남깁니다. 설치할 때마다 S3의 최신 파일을 읽으면 코드 생성이 네트워크와 자격증명에 의존하고, 같은 커밋에서도 조회 시점에 따라 입력이 달라질 수 있기 때문입니다.

개인 로컬 백엔드의 스펙도 같은 이유로 분리했습니다. localdev 모드에서는 Git에서 제외한 openapi.localdev.json에 스펙을 받고, 생성할 때만 입력 경로를 바꿉니다. 개인 작업 상태가 공유 계약 파일에 섞이지 않도록 한 것입니다.

6. 비교 결과와 타입 검사의 역할을 나눴습니다

동기화 스크립트는 수집한 스펙에 출처 정보를 기록합니다. 여기에는 수집 시각도 들어가므로 파일을 그대로 비교하면 계약이 같아도 차이가 생깁니다. 동등성 비교에서는 info.x-spec-source를 제외하고 객체 키 순서를 정규화합니다. 같으면 openapi.json을 다시 쓰지 않습니다.

비교 결과를 전달하는 방식에도 후속 수정이 필요했습니다. 처음에는 봇 본문과 PR Check의 비교 방향이 달라 새 API가 제거 위험으로 표시됐습니다. 이후 PR 이벤트의 base SHA → head SHA로 방향을 고정했습니다.

종료 코드 0을 “스펙이 일치한다”로 읽었던 문제도 있었습니다. 실제로는 구조가 달라도 위험 규칙에 걸리지 않으면 0이 나올 수 있었습니다. 그래서 비교 상태를 별도로 전달하도록 바꿨습니다.

상태의미
clean정규화한 스펙이 같음
drift차이가 있지만 정의된 위험 규칙에는 걸리지 않음
risk위험으로 분류한 변화가 있음

명령 실행 실패는 이 상태들과 구분합니다. 프로세스가 정상 종료했는지, 스펙이 같은지, 위험으로 분류한 변화가 있는지는 서로 다른 정보였습니다.

계약 비교와 별도로 PR Check는 새 스펙에서 생성한 코드가 애플리케이션과 맞는지 검사합니다. 의존성 설치의 postinstall에서 코드를 생성한 뒤 타입 검사와 테스트를 진행합니다. 스펙 변경은 전체 테스트 대상으로 분류하며, 선행 타입 검사가 실패하면 뒤의 테스트는 실행되지 않습니다.

1openapi.json 변경 23Orval 재생성 45요청·응답 타입 변경 67기존 필드를 읽거나 요청을 만드는 코드의 타입 오류

실제로 담당자 관련 계약이 바뀌면서 응답의 기존 필드가 사라지고 요청에는 직원 식별자가 필수로 추가된 일이 있었습니다. 타입 검사는 옛 필드를 읽는 화면과 요청을 만드는 코드를 드러냈습니다. 대응 PR에서는 표시 이름과 제출 식별자를 구분하고 payload 매핑과 입력 검증을 수정했습니다.

반대로 타입은 유지하면서 식별자가 가리키는 대상만 바뀐 경우도 있었습니다. 같은 number라도 관리자 식별자와 직원 식별자는 교환해서 사용할 수 없습니다. 이런 의미 변화는 타입 검사나 구조 비교만으로 알아내기 어렵습니다.

검사마다 확인하는 대상이 다릅니다.

계약 비교는 스펙 사이의 차이를 보고, 타입 검사는 생성 타입과 소비 코드의 관계를 봅니다. 실제 응답의 의미와 런타임 동작까지 확인하려면 BE와의 확인이나 별도의 테스트가 필요합니다.

7. 봇 브랜치와 사람의 작업 경계를 확인했습니다

봇은 bot/chore/openapi-sync 브랜치에서 openapi.json만 커밋하고 main을 대상으로 PR을 관리합니다. 반영할 차이가 없으면 새 PR을 만들지 않습니다. 기존 PR은 갱신하며, 스펙이 같아도 main이 앞서갔다면 최신 기준으로 브랜치를 다시 구성할 수 있습니다.

봇의 역할은 변경을 제안하는 데까지입니다. 개발자의 feature branch를 직접 갱신하거나 자동으로 머지하지 않습니다. 기존 화면을 수정해야 하는 변경이라면 스펙과 대응 코드를 함께 검토해야 하기 때문입니다.

이 경계는 Git hook에도 영향을 줬습니다. 저장소의 pre-push hook은 타입 검사를 수행합니다. 호환되지 않는 스펙을 가져온 봇에게 같은 hook을 적용하면 PR을 만들기도 전에 막힙니다. 개발자에게 알려야 할 변경이 전달되지 않으므로, 봇의 push에서는 해당 hook을 건너뛰고 생성된 PR의 CI가 검증하도록 했습니다.

사람 커밋을 보호하기 위한 장치도 있었습니다. 봇 브랜치에서 봇과 다른 author email을 발견하면 자동 갱신을 보류하고, push에는 예상한 SHA를 명시한 --force-with-lease를 사용했습니다. 그런데 실제로 사람이 올린 대응 커밋이 동기화 과정에서 사라졌습니다.

당시 대응 커밋들의 author email은 빈 문자열이었습니다. workflow는 git log --format='%ae'로 이메일을 모은 뒤 awkNF 조건으로 빈 줄을 제외했습니다. 사람 커밋이 존재했지만 사람 커밋 목록에는 들어가지 않는 구조였습니다. 실행 로그에서도 해당 커밋을 포함한 head가 스펙 커밋으로 교체된 것을 확인할 수 있었습니다.

force-with-lease가 사람 커밋의 보존까지 판단하지는 않습니다.

lease는 원격 브랜치가 예상한 SHA에서 바뀌었는지를 확인합니다. 예상한 SHA 자체에 이미 사람의 커밋이 포함돼 있다면, 원격이 그대로라는 이유로 push는 허용될 수 있습니다.

해당 대응은 별도 작업 브랜치에서 봇의 스펙 커밋과 소비 코드 수정을 함께 올리는 방식으로 마무리됐습니다. 자동화가 재구성하는 브랜치와 사람이 작업을 쌓는 브랜치를 구분해야 하는 이유가 실제 사건으로 드러났습니다.

8. 회고

정산 프로젝트의 협업 개선 기록에는 FE 5명과 BE 6명이 계약을 공유하며 개발하고, 스펙 불일치로 인한 재작업이 스프린트당 34건에서 0건으로 줄었다는 결과가 남아 있습니다. 주 23회 바뀌던 계약을 각자 확인하던 방식에서 합의한 계약과 변경 내용을 함께 보는 방식으로 옮겼습니다.

CRM에서 확인한 변화는 그 과정을 코드로 따라갈 수 있다는 점입니다. 스펙 변경이 Git에 남고 배포 출처가 기록되며, 계약 차이와 소비 코드의 오류를 PR에서 함께 확인할 수 있습니다. 두 프로젝트의 성과를 같은 수치로 설명할 수는 없지만, 계약을 공유하는 절차가 개발 도구와 어떻게 연결되는지는 이 구현에서 볼 수 있었습니다.

물론 OpenAPI가 실제 동작을 잘못 표현하면 생성 타입과 목업도 같은 오류를 공유합니다. 자체 diff는 oneOf, anyOf나 일부 응답 형식을 다루지 않으며, 사용 여부 표시도 직접 import를 찾는 수준이라 실제 실행 경로를 보장하지 않습니다. 배포 스펙의 차이 보고는 현재 비차단으로 운영합니다.

oasdiff 도입도 검토됐지만 로컬과 CI에서 같은 버전을 사용하고 기존 보고서에 연결하는 작업이 남아 있어 보류됐습니다. 스펙을 먼저 합의하는 시간과 PR을 검토하는 시간도 여전히 필요합니다.

다시 보강한다면 author 정보가 비어 있는 경우부터 봇의 자동 갱신을 보류하겠습니다. 작성자를 확인할 수 없다는 이유로 봇의 작업이라고 취급해서는 안 되기 때문입니다. 목업의 응답이 실제 HTTP 클라이언트를 통과했을 때 어떤 값이 되는지도 함께 검사하고 싶습니다.

정산에서 무관한 생성물을 걷어낼 때는 그 PR의 범위를 정리하는 것으로 작업을 마쳤습니다. CRM에서는 그보다 앞단에서 무엇을 저장하고 무엇을 재생성할지 나누는 구조를 볼 수 있었습니다. 그 위에서 기능을 만들다 보니 가정 계약, 목업의 등록 범위, HTTP 클라이언트의 반환값처럼 생성만으로 해결되지 않는 부분도 만났습니다.

지금은 스펙 파일을 받았는지만 확인하지 않습니다. 어느 배포에서 왔는지, 어떤 생성 조건을 거쳤는지, 목업과 실제 클라이언트가 같은 응답을 다루는지까지 이어서 봅니다. OpenAPI를 계약으로 쓴다는 말이 실제 코드에서 어디까지 성립하는지는 그 연결 지점마다 달랐기 때문입니다.

HS
@Hyungseok Kwon
Journey To Solution