
백엔드는 FastAPI로 개발해 AWS EC2에 배포하고, 프론트엔드는 Next.js로 개발해 Vercel에 배포하고 있다. 두 애플리케이션은 저장소도 분리되어 있고, 배포 파이프라인도 각각 따로 구성되어 있다.
처음에는 프론트엔드에서 API 요청과 응답에 필요한 TypeScript 타입을 직접 작성했다. 프로젝트 규모가 작을 때는 이 방식도 충분해 보였다. 하지만 게시글 상태 관리와 관리자 기능이 추가되면서 문제가 생기기 시작했다.
백엔드의 Pydantic schema가 변경되어도 프론트엔드에 수동으로 작성한 타입은 자동으로 바뀌지 않았다. 프론트엔드 타입 검사만 통과하면 실제 API 응답과 타입이 다르더라도 배포 전까지 발견하지 못할 수 있었다.
이번 작업에서는 FastAPI가 생성하는 OpenAPI schema를 기준으로 Next.js의 API 타입을 생성하도록 바꿨다. 이 과정에서 공개 API와 관리자 API의 응답 schema를 분리하고, 운영 환경의 API 문서 endpoint도 함께 정리했다.
기존 구조
현재 프로젝트는 백엔드와 프론트엔드가 분리되어 있다.
백엔드와 프론트엔드가 하나의 저장소에 있다면 프론트엔드 빌드 과정에서 OpenAPI 타입을 생성하도록 묶을 수도 있다. 하지만 이 프로젝트에서는 두 애플리케이션의 저장소와 배포 환경이 분리되어 있다.
Vercel이 프론트엔드를 빌드할 때마다 EC2의 운영 API에서 OpenAPI schema를 가져오게 만들 수도 있지만, 그렇게 하지 않았다. 프론트엔드 빌드가 운영 백엔드 서버 상태에 의존하게 되고, 운영 API의 /openapi.json을 계속 공개해야 하기 때문이다.
대신 다음 흐름을 선택했다.
생성 파일을 저장소에 포함하면 백엔드 schema 변경 내용이 Git diff로 드러난다. API 계약이 바뀌었는지 코드 리뷰 단계에서도 확인할 수 있다.
OpenAPI 기준으로 타입 생성하기
FastAPI는 Pydantic model과 route 정보를 바탕으로 OpenAPI schema를 자동 생성한다.
로컬 백엔드를 실행한 상태에서는 다음 주소에서 schema를 확인할 수 있다.
프론트엔드에서는 openapi-typescript를 사용해 이 schema를 TypeScript 타입으로 변환했다.
package.json에는 타입 생성 명령어를 추가했다.
이후 로컬 백엔드가 실행 중인 상태에서 프론트엔드 프로젝트 루트에서 명령을 실행한다.
그러면 FastAPI의 OpenAPI schema를 기준으로 다음 파일이 생성된다.
프론트엔드에서는 직접 작성한 API 타입 대신 생성된 schema 타입을 사용한다.
관리자 API도 같은 방식으로 연결했다.
이제 프론트엔드 타입은 백엔드 OpenAPI 계약을 기준으로 만들어진다.
타입을 생성하면서 발견한 불일치
OpenAPI 타입 적용은 단순히 기존 타입을 자동 생성 타입으로 교체하는 작업에서 끝나지 않았다. 기존에는 드러나지 않았던 API 계약 불일치가 몇 가지 보였다.
tags optional/required 불일치
프론트엔드에서는 게시글의 tags를 선택값처럼 다루고 있었다.
하지만 백엔드 응답에서는 tags가 항상 배열로 반환되는 필수 필드였다.
두 타입은 비슷해 보이지만 의미가 다르다. tags?: string[]는 tags 필드 자체가 없을 수도 있다는 뜻이고, tags: string[]는 태그가 없더라도 빈 배열이 온다는 뜻이다.
OpenAPI 타입을 적용하자 이 차이가 TypeScript 오류로 드러났다. 이후 프론트엔드도 tags가 항상 존재하는 계약에 맞춰 수정했다.
수동 타입은 실제 API와 조금씩 달라져도 TypeScript 내부에서는 정상적인 타입처럼 취급될 수 있다. OpenAPI 타입 생성은 이런 차이를 배포 전에 드러내는 역할을 했다.
백엔드가 받지 않는 필드를 보내던 문제
관리자 게시글 작성 화면에서는 새 글 생성 요청에 다음과 같은 payload를 보내고 있었다.
하지만 백엔드의 게시글 생성 schema는 is_published, is_temp를 요청 필드로 받지 않는다.
게시글 상태는 endpoint 자체가 결정한다.
즉 새 글 생성 요청에는 title, content, tags만 보내면 된다.
반대로 기존 글 수정은 상태 변경이 필요할 수 있으므로 PostUpdate 타입을 사용한다.
기존에는 백엔드가 추가 필드를 무시했기 때문에 즉시 장애로 이어지지는 않았다. 하지만 프론트엔드와 백엔드가 서로 다른 요청 구조를 예상하고 있었다는 점에서는 제거해야 할 불일치였다.
OpenAPI 타입 생성은 응답 타입을 편하게 작성하는 도구일 뿐 아니라, 실제 전송하는 request payload가 API 계약과 맞는지 확인하는 장치이기도 했다.
Preview 환경에서 운영 도메인으로 이동하던 문제
타입을 정리하는 과정에서 API와 직접적인 관련은 없지만 배포 환경과 관련된 문제도 발견했다.
게시글 카드의 상세 링크가 canonical URL 생성 함수를 사용하고 있었다.
운영 환경에서는 문제가 없어 보였지만 Vercel Preview 환경에서 게시글을 클릭하면 preview 도메인 안에서 모달이 열리지 않고 운영 도메인으로 이동했다.
원인은 내부 이동용 href와 SEO canonical URL을 같은 함수로 처리한 것이었다. 내부 링크는 현재 origin 기준으로 동작해야 하고, SEO canonical은 production URL 기준으로 생성되어야 한다.
그래서 내부 링크 함수는 상대경로만 반환하도록 바꿨다.
SEO metadata에서는 기존처럼 canonical URL을 사용한다.
이렇게 분리하니 Vercel Preview에서는 preview 도메인 안에서 상세 모달이 열리고, production에서는 기존처럼 정상적인 canonical metadata가 유지됐다.
공개 API와 관리자 API schema 분리
기존에는 공개 게시글 조회 API와 관리자 게시글 API가 같은 응답 schema를 공유하고 있었다. 하지만 두 API가 제공해야 하는 정보는 다르다.
공개 API에 필요한 정보는 게시글을 보여주기 위한 데이터다.
관리자 API에는 운영 상태를 확인하기 위한 필드가 추가로 필요하다.
기존 구조에서는 공개 API 응답에도 is_published, is_temp 같은 관리용 필드가 포함되어 있었다. 치명적인 보안 문제는 아니지만, 공개 API가 굳이 알아야 할 정보는 아니었다.
이를 정리하기 위해 백엔드 schema를 분리했다.
공개 목록 응답도 공개 타입 기준으로 분리했다.
공개 API는 PublicPostOut과 PublicPostListResponse를 사용한다.
관리자 API는 AdminPostOut을 사용한다.
schema를 분리한 뒤 OpenAPI 타입을 다시 생성하자 프론트엔드에서도 공개 게시글과 관리자 게시글의 차이가 명확해졌다.
이제 공개 화면에서 is_published 같은 관리자 필드에 접근하면 TypeScript 오류가 발생한다. API의 역할 차이가 문서에만 있는 것이 아니라 코드 수준의 제약으로 반영된 것이다.
운영 환경에서 API 문서 endpoint 비활성화
FastAPI는 기본적으로 다음 endpoint를 제공한다.
개발 환경에서는 API를 확인하고 테스트하기 편리하지만, 운영 환경에서 반드시 공개할 필요는 없었다.
그래서 ENV 값에 따라 문서 endpoint를 활성화하도록 FastAPI 설정을 변경했다.
개발 환경에서는 기존과 동일하게 사용할 수 있다.
운영 환경에서는 해당 endpoint가 생성되지 않는다.
응답은 모두 404 Not Found가 된다.
이 설정만으로 API가 보호되는 것은 아니다. API 보안은 인증과 인가, 입력값 검증, 네트워크 정책, 요청 제한 같은 방식으로 구성해야 한다.
다만 운영에 필요하지 않은 문서와 schema endpoint를 외부에 계속 노출하지 않음으로써 공개되는 API 표면을 줄일 수 있다.
타입 생성은 운영 서버가 아니라 로컬 백엔드의 OpenAPI schema를 기준으로 수행하기 때문에 운영 환경에서 /openapi.json을 비활성화해도 프론트엔드 개발에는 문제가 없다.
타입 생성 파일을 저장소에 커밋한 이유
자동 생성 파일은 일반적으로 Git에서 제외해야 한다고 생각할 수 있다. 하지만 현재 구조에서는 생성된 API 타입을 프론트엔드 저장소에 포함했다.
이유는 세 가지다.
첫째, Vercel 빌드가 백엔드 서버 상태에 의존하지 않는다.
Vercel이 빌드할 때마다 외부 FastAPI 서버에서 /openapi.json을 가져오도록 하면 백엔드 서버가 일시적으로 응답하지 않는 경우 프론트엔드 배포까지 실패할 수 있다.
둘째, 운영 환경의 OpenAPI endpoint를 공개할 필요가 없다.
타입 생성에 운영 API를 사용하지 않으므로 운영 환경에서는 /openapi.json을 비활성화할 수 있다.
셋째, API 계약 변경을 Git diff로 확인할 수 있다.
백엔드 schema가 변경된 뒤 타입을 다시 생성하면 변경 내용이 프론트엔드 Pull Request에 나타난다.
생성 파일이 변경되었다는 것은 API 계약에도 변화가 생겼다는 뜻이다. 이를 통해 코드 리뷰 과정에서 의도하지 않은 schema 변경을 확인할 수 있다.
별도 배포 구조에서의 배포 순서
백엔드와 프론트엔드가 별도로 배포되는 구조에서는 API 변경 순서도 중요하다.
예를 들어 백엔드에서 기존 응답 필드를 즉시 제거한 뒤 프론트엔드를 배포하면, 새로운 프론트엔드 배포가 완료되기 전까지 기존 프론트엔드가 오류를 일으킬 수 있다.
따라서 호환성을 깨는 변경은 단계적으로 진행하는 편이 안전하다.
필드를 추가하는 경우
새 필드를 추가하는 변경은 비교적 단순하다.
필드를 제거하거나 변경하는 경우
기존 필드를 제거하거나 의미를 변경하는 작업은 더 조심해야 한다.
별도 배포 구조에서는 어느 순간에 구버전 프론트엔드와 신버전 백엔드가 동시에 동작할 수 있다는 점을 고려해야 한다.
OpenAPI 타입은 현재 schema의 일치 여부를 확인해 주지만, 배포 시점의 하위 호환성까지 자동으로 보장하지는 않는다. 배포 순서와 호환성은 별도로 설계해야 한다.
OpenAPI 타입 생성은 비즈니스 로직 노출인가
처음에는 OpenAPI schema를 프론트엔드에 가져와도 되는지 고민했다. 백엔드 비즈니스 로직이 노출되는 것은 아닌지 걱정이 됐다.
OpenAPI schema에는 API 경로, HTTP method, 요청 parameter, request body, response 구조와 같은 정보가 포함된다. 하지만 서비스 내부에서 데이터가 어떻게 처리되는지, 인증 토큰을 어떤 방식으로 검증하는지, 데이터베이스 query를 어떻게 작성했는지는 포함되지 않는다.
따라서 OpenAPI를 이용해 프론트엔드 타입을 생성하는 것 자체가 백엔드 비즈니스 로직을 프론트엔드에 노출하는 것은 아니다.
다만 OpenAPI 문서에는 외부에 공개하고 싶지 않은 관리자 endpoint나 내부 API 구조가 포함될 수 있다. 운영 환경에서 문서 endpoint를 공개할 필요가 없다면 비활성화하거나 접근 권한을 제한하는 것이 적절하다.
적용 후 달라진 점
이번 작업 이후 API와 관련된 변경 흐름은 다음과 같이 정리됐다.
이전에는 백엔드 schema를 변경한 뒤 프론트엔드 타입도 사람이 직접 찾아 수정해야 했다. 지금은 타입 생성 명령을 실행하면 변경된 API 계약이 프론트엔드 코드 전체에 반영된다.
잘못된 요청 payload나 존재하지 않는 응답 필드 사용은 TypeScript 검사 단계에서 확인할 수 있다.
마무리
처음에는 프론트엔드 API 타입을 자동 생성하는 정도의 작업이라고 생각했다. 하지만 실제로 적용해 보니 기존에 숨어 있던 여러 불일치를 발견할 수 있었다.
- 백엔드에서는 필수인 필드를 프론트엔드에서는 선택값으로 정의하고 있었다.
- 프론트엔드가 백엔드 schema에 존재하지 않는 필드를 전송하고 있었다.
- 공개 API와 관리자 API가 불필요하게 같은 응답 구조를 사용하고 있었다.
- Vercel Preview 환경의 링크가 운영 도메인을 기준으로 생성되고 있었다.
- 운영 환경에서 사용하지 않는 API 문서 endpoint가 공개되어 있었다.
OpenAPI 타입 생성의 가장 큰 장점은 TypeScript 타입을 작성하는 시간을 줄여 주는 것이 아니었다.
백엔드와 프론트엔드 사이에 암묵적으로 존재하던 약속을 명시적인 API 계약으로 바꾸고, 계약이 달라졌을 때 배포 전에 문제를 확인할 수 있게 해 준다는 점이 더 중요했다.
백엔드와 프론트엔드가 서로 다른 저장소와 배포 환경을 사용하더라도 API 계약을 고정하는 것은 가능하다.
현재 프로젝트에서는 로컬 FastAPI의 OpenAPI schema를 기준으로 타입을 생성하고, 생성 결과를 프론트엔드 저장소에 커밋하는 방식이 가장 단순하고 안정적이었다.
자동 생성된 타입 파일은 관리하기 귀찮은 부수 파일이 아니라, 서로 독립적으로 배포되는 두 애플리케이션 사이에서 변경 사항을 확인하는 안전장치가 되었다.