[BOM-1141] chore: OpenAPI 코드 생성 기반 구축#769
Hidden character warning
Conversation
There was a problem hiding this comment.
이 디렉터리는 실제 OpenAPI spec 원본을 포함한 submodule 경로입니다.
의미:
- backend는 이 경로의 openapi.yaml을 generator 입력으로 사용합니다.
- backend 레포 안에 별도의 수동 openapi.yaml 원본을 유지하지 않습니다.
- spec version 관리는 artifact version이 아니라 submodule commit hash가 담당합니다.
There was a problem hiding this comment.
이 workflow 파일들에서는 actions/checkout 시 submodule을 함께 checkout 하도록 바꿨습니다.
왜 필요한가:
- 로컬에서는
git submodule update --init --recursive를 통해 spec가 내려오지만, - CI/CD는 별도 checkout 환경이기 때문에 submodule을 명시하지 않으면
openapi-spec/openapi.yaml이 존재하지 않습니다. - 그러면 openApiGenerate가 실행되기 전에 spec 파일 자체를 못 찾게 됩니다.
즉 이번 변경은
“로컬에서는 되는데 CI에서는 안 되는” 상태를 막기 위한 것입니다.
특히 이 기반 작업은 spec가 없는 상태에선 아무것도 못 하기 때문에,
workflow에서 submodule checkout을 빠뜨리면 기반 구조 자체가 무너집니다.
There was a problem hiding this comment.
처음에는 spec 레포에서 OpenAPI 문서를 관리하고, 그 결과물을 artifact처럼 만들어서 backend에서 가져다 쓰는 방식도 생각했습니다. 예를 들어 spec 레포에서 openapi.yaml이나 generated 결과물을 별도 산출물로 만들어 두고, backend는 빌드할 때 그 파일을 내려받아 사용하는 구조입니다. 이 방식은 겉으로 보면 레포 간 의존관계가 깔끔해 보일 수 있지만, 실제로는 관리해야 할 단계가 꽤 많아집니다.
먼저 spec가 바뀌면, 단순히 문서만 수정하는 것으로 끝나지 않고 **“이 변경으로 새로운 artifact를 언제, 어떤 이름과 버전으로 발행할 것인지”**를 정해야 합니다. 그리고 backend 쪽에서는 다시 **“지금 어떤 artifact 버전을 쓰고 있는지”, “새 spec를 반영하려면 버전을 어디서 올려야 하는지”, “빌드 환경에서 그 artifact를 어떻게 안정적으로 받아올 것인지”**를 관리해야 합니다. 즉 spec 수정, artifact 생성, artifact 배포, backend 반영이라는 흐름이 따로 생기게 됩니다.
문제는 지금 단계에서는 이 흐름을 운영할 기반이 아직 없다는 점이었습니다. artifact 저장소를 둘지, 버전 규칙은 어떻게 가져갈지, spec가 자주 바뀔 때 backend는 어떤 기준으로 따라갈지 같은 것들을 함께 정해야 하는데, 이걸 다 설계하지 않은 상태에서 artifact 방식부터 도입하면 오히려 구조만 무거워질 수 있다고 봤습니다. 쉽게 말하면, **“파일 하나를 가져오기 위해 별도의 배포 체계 하나를 더 만드는 느낌”**에 가까웠습니다.
반면 submodule 방식은 훨씬 단순합니다. spec 원본은 그대로 spec 레포에 두고, backend 레포는 그 레포의 특정 커밋을 가리키기만 하면 됩니다. 그러면 backend 입장에서는 “지금 어떤 spec 기준으로 코드를 생성하고 있는지”가 git commit 단위로 바로 보입니다. 새로운 spec를 반영하고 싶으면 submodule이 가리키는 커밋만 올리면 되고, artifact를 새로 발행하거나 배포 경로를 관리할 필요가 없습니다.
정리하면, artifact 방식은 장기적으로 체계를 잘 갖추면 사용할 수 있는 방식이지만, 지금 단계에서는 운영해야 할 개념과 절차가 너무 많았습니다. 그래서 현재는 spec 원본을 직접 참조하면서도 기준 시점을 명확히 고정할 수 있는 submodule 방식이 더 단순하고 현실적하다고 판단했습니다.
There was a problem hiding this comment.
이 파일에서는 OpenAPI 관련 세부 설정을 직접 길게 넣기보다,
별도 gradle script를 apply하는 방식으로 정리했습니다.
현재 역할:
- OpenAPI Generator 플러그인 선언
- Spotless 플러그인 선언
- 공통 dependency / build 설정 유지
- OpenAPI codegen용 script, generated source formatting용 script를 분리 적용
왜 이렇게 했는가:
- build.gradle.kts 안에 codegen 설정, formatting 설정, template 보정 로직이 다 몰리면 가독성이 급격히 나빠집니다.
- 특히 이번 기반은 “일반 애플리케이션 빌드”가 아니라 “spec-driven codegen 파이프라인”을 추가하는 작업이라, 빌드 설정의 관심사를 분리하는 게 중요했습니다.
즉 메인 build 파일은 가능한 얇게 유지하고,
- 생성 설정은 openapi-generator.gradle
- 생성 코드 포맷은 openapi-generated-format.gradle로 나눠서 읽히게 했습니다.
| def openApiConfigOptions = [ | ||
| interfaceOnly : "true", | ||
| useTags : "true", | ||
| useSpringBoot3 : "true", | ||
| useJakartaEe : "true", | ||
| useResponseEntity : "false", | ||
| useSpringController : "false", | ||
| openApiNullable : "false", | ||
| skipDefaultInterface : "true", | ||
| dateLibrary : "java8", | ||
| documentationProvider: "springdoc", | ||
| annotationLibrary : "swagger2", | ||
| ] |
There was a problem hiding this comment.
서버 전체 stub를 생성하는 게 아니라, 현재 컨트롤러가 구현할 interface와 model만 생성합니다.
즉 generator가 애플리케이션 구조를 덮어쓰는 게 아니라, 기존 Spring 서버 위에 계약 layer만 공급하는 방식입니다.
| tasks.register("verifyOpenApiSpec") { | ||
| doLast { | ||
| if (!openApiSpecFile.asFile.exists()) { | ||
| throw new GradleException( | ||
| "openapi-spec/openapi.yaml not found. Initialize or update the spec submodule first " + | ||
| "(for example: git submodule update --init --recursive backend/bom-bom-server/openapi-spec)." | ||
| ) | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
openapi-spec/openapi.yaml이 없으면 애매하게 generator 내부 에러가 나는 대신 “submodule을 먼저 초기화하라”는 명확한 메시지로 fail 하도록 했습니다.
| // submodule에서 관리하는 OpenAPI 원본 스펙 경로 | ||
| def openApiSpecFile = layout.projectDirectory.file("openapi-spec/openapi.yaml") | ||
| // OpenAPI Generator가 Java 코드를 생성할 위치 | ||
| def generatedOpenApiDir = layout.buildDirectory.dir("generated/openapi") |
There was a problem hiding this comment.
출력 위치를 build/generated/openapi로 두고, 이를 sourceSets.main.java에 추가했습니다.
There was a problem hiding this comment.
이 파일은 OpenAPI Generator가 API interface 생성 시 직접 읽는 엔트리 템플릿입니다.
이번 PR에서의 역할:
- API 메서드 시그니처 생성 책임을 partial로 분리했습니다.
- 즉 api.mustache 자체는 엔트리로 두되, 복잡한 파라미터 조합 로직은 다른 mustache 파일에서 처리합니다.
왜 필요한가:
- generator 기본 API 템플릿은 현재 서버 구조 기준으로는 너무 일반적입니다.
@LoginMember, Pageable, additional provided args 같은 서버 전용 파라미터를 그대로 표현하기 어렵습니다.
즉 이 파일은
“OpenAPI Generator가 직접 찾는 진입점”이면서, “복잡한 서버 시그니처 생성 로직을 partial로 위임하는 허브” 역할을 합니다.
There was a problem hiding this comment.
이 partial은 @LoginMember 관련 로직만 분리한 템플릿입니다.
지원하는 vendor extension:
- x-needs-login-member
- x-login-member-anonymous
- x-login-member-allow-invalid-token
- x-login-member-id
처리 결과:
@LoginMemberMember member@LoginMemberLong memberId- anonymous = true
- allowInvalidToken = true
같은 조합을 생성합니다.
왜 별도 파일로 뺐는가:
- 로그인 멤버 주입은 서버 고유 규칙이고,
- 앞으로 인증 정책이 바뀔 때 가장 자주 손볼 가능성이 높은 부분입니다.
- 이걸 API 전체 템플릿과 분리해두면, 인증 관련 커스터마이징을 독립적으로 수정할 수 있습니다.
즉 이 파일은 “서버 인증/인가 컨텍스트를 generated API 시그니처에 주입하는 규칙 모음”입니다.
There was a problem hiding this comment.
이 파일은 model 파일의 package/import 구조를 담당하는 래퍼 템플릿입니다.
왜 필요한가:
- 기본 generator의 model 템플릿은 일반 Java class 기준입니다.
- 그런데 이번에는 generated model을 record로 출력하도록 바꿨기 때문에, class 기준으로 계산된 import 구조를 그대로 쓰면 노이즈가 커집니다.
역할:
- generator가 계산한 import를 받되
- record 기반 output에 필요한 validation/generated import 구조를 정리
- enum, additionalProperties, oneOf interface, 일반 record model 분기를 이어줌
즉 이 파일은 “record 기반 generated model(DTO)을 위한 상단 래퍼”라고 보면 됩니다.
There was a problem hiding this comment.
이 파일은 실제 generated model 본문을 Java record로 출력하는 핵심 템플릿입니다.
하는 일:
각 필드를 record component로 생성
@NotNull@Valid@DateTimeFormat@Schema@JsonProperty
를 record component에 직접 부착
즉 이 파일은 “generated model을 class가 아니라 계약 중심의 immutable DTO 형태로 출력하기 위한 템플릿”입니다.
There was a problem hiding this comment.
이 partial은 generated API method의 파라미터 목록을 조합하는 핵심 템플릿입니다.
처리하는 항목:
- 로그인 멤버 주입
- OpenAPI에 선언된 query/path/header/body/form/cookie parameter
- Spring Pageable
- vendor extension 기반 추가 서버 인자
- HttpServletResponse response
- HttpServletRequest request
- HttpSession session
왜 분리했는가:
- 이 로직을 api.mustache 안에 그대로 두면 유지보수가 어렵습니다.
- 특히 로그인 멤버 주입 여부, pageable 주입 여부, 일반 파라미터 유무에 따라 comma 처리와 줄바꿈이 복잡해집니다.
즉 이 파일은 “API 메서드 파라미터 시그니처를 현재 Spring 서버 스타일에 맞게 합성하는 레이어”입니다.
|
다른건 뭐 큰 문제 없을 것 같습니다. 다만 로그인에 대한 설명이 더 있으면 좋겠습니다. 코드만 봐서는 잘 모르겠습니다. |
|
이거 PR 제목에 티켓이 빠졌습니다. |
이건 제가 PetController랑 Article 목록 조회 API의 Member 가져오는 방식을 다르게 해서 PR 올려두고 비교해보실 수 있도록 하겠습니다. => 올렸습니다~!
백엔드 코드에서 controller, api 인터페이스(ex.ArticleControllerApi), dto가 자동생성되기 때문에 spec 레포에서 spec을 잘 작성하면 문제없을 것으로 보입니다. 그래서 spec 레포에 백엔드 커스텀 vendor(mustache로 작성된 파일)을 확인하고 그에 맞게 typespec을 통해 spec 명세를 작성하는 skill을 만들어 PR을 올려두었습니다. 우선, 가장 주의할 점은 자동 생성된 controller, api interface, dto 코드를 직접 수정해서 맞추려고 하면 안 된다는 점입니다. 생성 결과는 spec을 기준으로 다시 덮어써지기 때문에, 필요한 변경이 있으면 백엔드 코드를 먼저 손보는 것이 아니라 spec 레포의 명세나 vendor extension 쪽을 수정해야 합니다. 즉 생성된 코드는 수정 대상이라기보다 결과물에 가깝고, 실제 원본은 spec이라고 보는 것이 맞습니다. 현재 확인한 구체적인 예외 사항은 페이지네이션 API입니다. x-spring-paginated: true를 붙이면 요청 쪽 Pageable 파라미터는 생성되지만, 응답이 Page 형태로 자동 생성되지는 않습니다. 예를 들어 목록 조회 API에서 아래처럼 구현할 수는 있습니다. @GetMapping
public PageArticleResponse getArticles(
@LoginMember Member member,
@Valid @ModelAttribute ArticlesOptionsRequest request,
@PageableDefault(sort = "arrivedDateTime", direction = Direction.DESC) Pageable pageable
) {
return articleService.getArticles(member, request, pageable);
}여기서 Pageable pageable은 x-spring-paginated: true 덕분에 생성되지만, 반환 타입은 Page로 자동 해석되지 않고 spec에 정의한 PageArticleResponse 같은 DTO를 기준으로 생성됩니다. 즉 페이지네이션 API라는 사실만으로 응답 구조까지 자동으로 만들어지는 것은 아니고, content, totalElements, totalPages, number 같은 페이징 응답 필드는 spec 레포에서 PageXXXResponse 형태로 직접 정의해줘야 합니다. |
There was a problem hiding this comment.
처음에는 spec 레포에서 OpenAPI 문서를 관리하고, 그 결과물을 artifact처럼 만들어서 backend에서 가져다 쓰는 방식도 생각했습니다. 예를 들어 spec 레포에서 openapi.yaml이나 generated 결과물을 별도 산출물로 만들어 두고, backend는 빌드할 때 그 파일을 내려받아 사용하는 구조입니다. 이 방식은 겉으로 보면 레포 간 의존관계가 깔끔해 보일 수 있지만, 실제로는 관리해야 할 단계가 꽤 많아집니다.
먼저 spec가 바뀌면, 단순히 문서만 수정하는 것으로 끝나지 않고 **“이 변경으로 새로운 artifact를 언제, 어떤 이름과 버전으로 발행할 것인지”**를 정해야 합니다. 그리고 backend 쪽에서는 다시 **“지금 어떤 artifact 버전을 쓰고 있는지”, “새 spec를 반영하려면 버전을 어디서 올려야 하는지”, “빌드 환경에서 그 artifact를 어떻게 안정적으로 받아올 것인지”**를 관리해야 합니다. 즉 spec 수정, artifact 생성, artifact 배포, backend 반영이라는 흐름이 따로 생기게 됩니다.
문제는 지금 단계에서는 이 흐름을 운영할 기반이 아직 없다는 점이었습니다. artifact 저장소를 둘지, 버전 규칙은 어떻게 가져갈지, spec가 자주 바뀔 때 backend는 어떤 기준으로 따라갈지 같은 것들을 함께 정해야 하는데, 이걸 다 설계하지 않은 상태에서 artifact 방식부터 도입하면 오히려 구조만 무거워질 수 있다고 봤습니다. 쉽게 말하면, **“파일 하나를 가져오기 위해 별도의 배포 체계 하나를 더 만드는 느낌”**에 가까웠습니다.
반면 submodule 방식은 훨씬 단순합니다. spec 원본은 그대로 spec 레포에 두고, backend 레포는 그 레포의 특정 커밋을 가리키기만 하면 됩니다. 그러면 backend 입장에서는 “지금 어떤 spec 기준으로 코드를 생성하고 있는지”가 git commit 단위로 바로 보입니다. 새로운 spec를 반영하고 싶으면 submodule이 가리키는 커밋만 올리면 되고, artifact를 새로 발행하거나 배포 경로를 관리할 필요가 없습니다.
정리하면, artifact 방식은 장기적으로 체계를 잘 갖추면 사용할 수 있는 방식이지만, 지금 단계에서는 운영해야 할 개념과 절차가 너무 많았습니다. 그래서 현재는 spec 원본을 직접 참조하면서도 기준 시점을 명확히 고정할 수 있는 submodule 방식이 더 단순하고 현실적하다고 판단했습니다.
| uses: actions/checkout@v4 | ||
| with: | ||
| ref: ${{ env.WORKING_BRANCH }} | ||
| submodules: recursive |
There was a problem hiding this comment.
recursive는 submodule 안에 또 다른 submodule이 있을 때 그것까지 전부 따라가서 초기화/업데이트하라는 뜻
There was a problem hiding this comment.
OpenAPI Generator가 생성 클래스에 붙이는 @generated 애노테이션 형식을 오버라이드하는 partial입니다. 기본 출력보다 줄바꿈을 명확하게 맞춰, generated 클래스 상단 메타데이터가 너무 길게 한 줄로 보이지 않도록 정리하는 역할입니다.
가독성을 목적으로 만든 mustache입니다
There was a problem hiding this comment.
query parameter를 어떤 형태의 메서드 파라미터로 생성할지 결정하는 템플릿입니다. 특히 query model은 개별 @RequestParam이 아니라 @ModelAttribute로 묶어 기존 백엔드 스타일과 맞추고, scalar query parameter는 기본 Spring RequestParam 방식으로 생성되도록 분기하는 역할을 합니다.
fa3eadb to
6205829
Compare
- api.mustache: springDocDocumentationProvider 조건부 ParameterObject import 추가 - pojo.mustache: required primitive 타입(int/long/float/double/boolean)은 @NotNull 제외 및 unboxed 타입으로 생성 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- api.mustache: useBeanValidation 블록에서 constraints.* 제거 (필요 시 generator가 specific import로 처리) - openapi-generated-format.gradle: googleJavaFormat 후 메서드 마지막 파라미터 줄의 ); 를 새 줄로 이동하는 규칙 추가 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
6205829 to
4aeb821
Compare
📌 What
❓ Why
🔧 How
bom-bom-api-spec를backend/bom-bom-server/openapi-specsubmodule로 연결했습니다.@LoginMember,Pageable, record 기반 model 생성, generated import 정리를 다룰 수 있도록 기반을 정리했습니다.date-time은LocalDateTime으로 매핑했습니다.자세한건 코드에 직접 코멘트 남겼습니다.
참고만 해주시면 될거 같습니다.. 앞으로 계속 해가면서 디벨롭 해야할거 같습니다.
👀 Review Point (Optional)
spec 레포 재오의 PR 참고하시면 tsp로 작성한 명세 보실 수 있습니다.
🥸 mustache에 대한 설명
mustache는 “spec에 어떤 값을 적어두면, 그걸 최종 Java 코드로 어떻게 생성할지”를 정하는 템플릿입니다.예를 들어 로그인한 사용자를 컨트롤러 파라미터로 받고 싶을 때, OpenAPI 기본 기능만으로는
@LoginMember Member member같은 백엔드 전용 표현을 직접 만들 수 없습니다.그래서 TypeSpec에서는 operation에 대응되는 vendor extension이 들어가도록 decorator를 붙여 표현합니다.
예를 들어 이런 식으로 작성하면
generator는
x-needs-login-member: true를 읽고,login_member_parameter.mustache같은 템플릿을 통해 최종적으로 이런 코드를 생성합니다.만약 로그인 사용자 전체 객체가 아니라 ID만 필요하도록 아래처럼 적어두면
생성 결과는
처럼 바뀝니다.
즉 spec에는 “이 API는 로그인 멤버가 필요하다”는 표시를 적고, mustache 템플릿은 그 표시를 보고 실제 Java 파라미터 코드를 만들어주는 역할이라고 보면 됩니다.