개인 프로젝트지만 백엔드(Spring Boot)와 프론트(Next.js)가 분리된 저장소로 돌아간다. 이런 구조에서 가장 자주 깨지는 것은 API 계약이다. 백엔드가 필드 하나를 바꾸면 프론트는 런타임에서야 알게 된다.
그래서 OpenAPI 스펙을 단일 진실 공급원으로 두고, 스펙에서 양쪽 코드를 생성하는 구조로 정리했다. 인프라 쪽에서 GitOps 기반 배포 자동화를 정리한 적이 있는데, Git이 배포 상태의 기준이 되는 그 구조와 발상이 같다. 기준이 되는 문서를 하나 정하고 나머지를 거기서 파생시킨다는 점에서 그렇다. 이 글은 그 구성과, 서브모듈이 세 겹으로 얽힌 상태에서 작업 순서를 어떻게 고정했는지에 대한 기록이다.
레포 구성: 스펙이 두 곳에 붙어 있는 이유
최상위 모노레포는 서브모듈만 담는다. 코드는 없다.
okdohyuk/ (모노레포)
├── .claude/ # 작업 하네스
├── okdohyuk-api/ # [submodule] Spring Boot 백엔드
│ └── api-spec/ # [nested submodule] OpenAPI 스펙 ← 편집 정본
└── okdohyuk-front/ # [submodule] Next.js 프론트
└── api-spec/ # [nested submodule] 같은 스펙 저장소같은 스펙 저장소가 백엔드와 프론트 양쪽에 각각 nested submodule로 붙어 있다. 처음에는 모노레포 최상위에 한 벌만 두는 게 깔끔해 보였지만, 그러면 문제가 생긴다.
각 서브모듈은 독립적으로 클론되어 CI에서 빌드된다. 백엔드 저장소만 클론했을 때도 ./gradlew build가 되어야 하고, 프론트만 클론했을 때도 yarn generate-api가 되어야 한다. 스펙이 상위에만 있으면 각 저장소가 자기 힘으로 빌드되지 않는다.
대신 규칙을 하나 둔다. 편집 정본은 백엔드 쪽 api-spec 이고, 프론트는 백엔드가 확정한 커밋을 포인터로 당겨오기만 한다.
스펙 하나로 양쪽 코드를 만든다
백엔드는 OpenAPI Generator Gradle 플러그인으로 Spring 인터페이스(stub)를 생성한다.
task(taskName, type: GenerateTask) {
generatorName = "spring"
inputSpec = "$rootDir/api-spec/reference/Blog.yaml"
outputDir = "$buildDir/generated"
apiPackage = "dev.okdohyuk.spec.api"
modelPackage = "dev.okdohyuk.spec.model"
typeMappings = [ OffsetDateTime: "LocalDateTime" ]
configOptions = [
useTags : "true",
useSpringBoot3 : "true",
openApiNullable : "true",
unhandledException: "true",
]
}도메인별 YAML마다 태스크가 하나씩 생기고, compileJava 시점에 인터페이스와 모델이 build/generated에 만들어진다. 컨트롤러는 그 인터페이스를 구현한다.
프론트는 같은 YAML로 typescript-axios 클라이언트를 생성한다.
openapi-generator-cli generate \
-i api-spec/reference/Blog.yaml \
--generator-name typescript-axios \
-o src/spec/api/Blog \
--skip-validate-spec핵심은 생성 결과가 커밋되는 위치다. 백엔드는 build/ 아래(빌드 산출물, 미커밋), 프론트는 src/spec/api/ 아래(커밋)다. 양쪽 모두 사람이 손대지 않는다.
순서를 고정한다
스펙이 바뀌는 작업은 세 저장소를 순서대로 건드려야 한다. 이 순서를 문서가 아니라 규칙으로 못박았다. 규칙을 어디에 두고 어떻게 강제하는지는 Claude Code 하네스로 개인 프로젝트 운영하기에 정리했다.
[S0] 세 레포 모두 동일한 브랜치명을 쓴다 (예: feat/blog-like)
[S1] api-spec 서브모듈
git switch -c feat/blog-like
reference/Blog.yaml 수정 → 커밋 → push
→ 커밋 SHA를 다음 단계로 전달
[S2] 백엔드 레포 (okdohyuk-api)
git switch -c feat/blog-like
git -C api-spec checkout <S1 커밋> # 포인터 갱신
./gradlew compileJava # stub 재생성
controller/service/mapper/Flyway 구현
git add api-spec <구현 파일> → 커밋 → push
[S3] 프론트 레포 (okdohyuk-front) — 반드시 S2 이후
git switch -c feat/blog-like
git -C api-spec fetch
git -C api-spec checkout <S1과 동일한 커밋>
yarn generate-api # client 재생성
훅/페이지/컴포넌트 구현
git add api-spec <구현 파일> → 커밋 → push
[S4] 모노레포 — 통합 스냅샷이 필요할 때만
git add okdohyuk-api okdohyuk-front → 커밋⚠ 여기서 실제로 걸렸던 지점이 두 가지다.
1) 프론트가 먼저 움직이면 안 된다. 백엔드가 스펙을 아직 push하지 않았으면 프론트에서 그 커밋을 fetch할 수 없다. 로컬에만 있는 커밋으로 포인터를 맞추면 다른 머신이나 CI에서 서브모듈 체크아웃이 실패한다.
2) 스펙 저장소의 머지 시점이다. 스펙 브랜치를 먼저 머지해두면 양쪽 저장소의 PR이 같은 커밋을 가리키게 하기 쉽다. 반대로 스펙을 머지하지 않은 채 양쪽 PR을 열면, 나중에 스펙 브랜치가 리베이스될 때 포인터가 전부 어긋난다.
포인터 정합성은 QA 항목이다
서브모듈이 얽힌 구조에서 가장 흔한 사고는 drift 다. 백엔드의 api-spec 포인터와 프론트의 api-spec 포인터가 다른 커밋을 가리키는 상태다.
이 상태에서도 양쪽은 각자 잘 빌드된다. 컴파일도 통과한다. 그래서 배포하고 나서야 필드 하나가 없다는 걸 알게 된다.
그래서 검증을 사람 기억에 맡기지 않고 항목으로 만들었다.
# 두 값이 같아야 한다
git -C okdohyuk-api/api-spec rev-parse HEAD
git -C okdohyuk-front/api-spec rev-parse HEAD경계면 검증에서 이 비교를 필수 항목으로 두고, spec ↔ 컨트롤러 ↔ 생성된 클라이언트의 요청·응답 형태를 함께 대조한다.
생성된 코드의 경계를 넘지 않는다
생성 코드 기반 개발에서 배운 것이 하나 있다. 생성된 인터페이스가 이미 표현한 것을 구현체에서 다시 선언하면 안 된다.
실제로 이런 일이 있었다. 조회 API를 호출하니 500이 떨어졌다.
jakarta.validation.ConstraintDeclarationException: HV000151원인은 이랬다. 스펙에서 생성된 인터페이스의 메서드 파라미터에는 이미 검증 제약(@Min, @Size 등)이 붙어 있다. 그런데 구현 메서드가 같은 파라미터에 제약을 다시 선언했다. Bean Validation은 상속 계층에서 파라미터 제약을 재선언하거나 강화하는 것을 금지한다(계약 강화 금지). 그래서 런타임에 터진다.
고약한 점은 이렇다.
- 컴파일은 통과한다. 문법적으로는 아무 문제가 없다.
- 인증이 걸린 엔드포인트는 재현되지 않는다. 인터셉터가 401로 먼저 끊어버려 검증 단계까지 가지 않는다.
- 그래서 테스트에서도 안 잡히고, 같은 패턴이 6개 컨트롤러에 잠복해 있었다.
이후 구현 규칙에 "컨트롤러 구현 메서드에 파라미터 검증 어노테이션을 붙이지 않는다"를 금지 항목으로 넣고, 코드 리뷰 체크리스트에 Blocker로 올렸다.
같은 백엔드에서 겪은 다른 런타임 이슈로는 카테고리 계층형 쿼리의 N+1이 있다. 둘 다 컴파일 타임에는 아무 문제가 없었고, 실제로 호출해봐야 드러났다는 점이 같다.
📌 교훈을 일반화하면 이렇다. 생성 코드와 손으로 쓴 코드의 경계에서는 "누가 이 사실을 선언하는가"를 한쪽으로 정해야 한다. 양쪽에 적으면 중복이 아니라 충돌이 된다.
트레이드오프
✅ 이 구조가 공짜는 아니다.
- 작업 한 번에 저장소 세 개를 건드린다. PR도 두세 개가 열린다. 작은 필드 추가에도 절차가 붙는다.
- 서브모듈은 여전히 헷갈린다. 포인터를 커밋하는 것을 잊으면 다른 머신에서 빌드가 깨진다.
- 클론이 번거롭다.
git clone --recurse-submodules를 잊으면 빈 디렉터리만 받게 된다.
덧붙이면 이 API가 실제로 굴러가는 환경은 홈 네트워크 젠킨스 무중단 배포 구축기로 만들어 뒀다. 저장소가 여러 개여도 배포 파이프라인은 하나로 묶여 있다.
그럼에도 유지하는 이유는 하나다. 계약이 코드보다 먼저 존재하고, 양쪽 타입이 그 계약에서 기계적으로 나오기 때문에 "프론트와 백엔드가 서로 다른 모양을 가정하는 상황"이 구조적으로 생기지 않는다.
정리
- 스펙 저장소는 백엔드·프론트 양쪽에 nested submodule로 붙여 각 저장소가 독립적으로 빌드되게 한다.
- 편집 정본은 한 곳(백엔드 쪽)으로 정하고, 순서는 spec → 백엔드 → 프론트로 고정한다.
- 두 저장소의 스펙 포인터가 같은지를 검증 항목으로 만든다.
- 생성 코드가 선언한 것을 구현체에서 다시 선언하지 않는다.