안녕하세요. 주코입니다.
최근, 개발에 도움을 줄 수 있는 많은 기술들이 등장하고 업그레이드 되고 있습니다.
OpenApiGenerator 는 러닝 커브가 상당히 있는 편이지만, 한번 익혀두고 설정만 해둔다면 어디서든 편리하게 이용할 수 있는 도구입니다.
이번 포스팅을 통해 OpenApiGenerator 를 소개하고 어떻게 활용하면 좋을지에 대해 다같이 고민을 해 보았으면 좋겠습니다.
OpenApiGenrator 공식 사이트: 링크
이 포스팅을 같이 참고하면 좋은 사이트: 링크
OpenApiGenerator 란?
OpenApi(Swagger) 명세서(API 스펙)를 기반으로 각종 코드 파일을 자동 생성해주는 도구입니다.
즉, 서버에서 제공하는 API문서(JSON 또는 YAML)만 있으면 아래와 같은 것들을 자동으로 만들어 줍니다.
- API 요청/응답 DTO
- Service Interface (Retrofit Interface)
- JWT Token Interceptor
- Error 모델
- Mock 데이터
- 문서화 파일
등
이번 포스팅에서는 API Service Interface와 DTO를 다룹니다.
장점
- 수동으로 API 클라이언트를 만들 필요없이 Generator가 자동으로 생성 시켜준다. (클라이언트 휴먼 에러 대폭 감소)
- API 명세가 바뀌면 코드도 자동 갱신을 시킬 수 있다. (유지보수 효율 증가)
- 일관된 코딩 스타일을 유지할 수 있다.
이 Generator를 사용한다면 백엔드가 API를 바꿀때마다 클라이언트 개발자들은 DTO를 수정하고, Service Interface를 수정하고, enum을 추가하는등 이런 자잘한 작업을 할 필요없이
명세서가 변경되면 다시 한 번 generate 시키는 것으로 모든 코드를 갱신해줍니다.
또는 GitHub CI와 같이 사용한다면, 모든 코드를 자동으로 갱신해줍니다.
즉, 클라이언트 개발자들은 템플릿만 하나 잘 작성해둔다면 일관성 있게 Generator가 자동으로 코드를 작성해줍니다.
단점 & 불편한점
1. API 문서는 정확해야한다.
클라이언트는 API 문서를 안봐도 될 정도로 휴먼에러가 없거나 대폭 감소하게 됩니다.
다만, 백엔드의 휴먼에러는 그대로 남아 있기 때문에 무엇보다 정확한 명세서가 필수 조건 입니다.
2. 러닝커브가 크다.
build.gradle 설정 자체는 간단하나, 우리 프로젝트에 맞게 커스텀이 들어가야합니다.
문제는, Generator의 template은 mutache로 이루어져 있기 때문에 이에 따른 러닝커브가 매우 높습니다.
또한, 서비스 흐름에 따라 지속적으로 유지보수가 필요합니다.
3. 특정 모듈에 특정 DTO 설정이 불가능하다.
정확히는 특정 API는 가져올 수 있지만, 특정 API의 DTO는 설정이 불가합니다.
예를들어 Auth 관련 Controller가 있다면, 해당 Auth 태그로 Auth 관련 API Service Interface는 설정이 가능하지만,
Auth 관련 DTO만 따로 설정하는것이 불가능합니다.
이건 현재 명백한 Generator의 한계점이며 멀티 모듈 아키텍처에선 사용하기 매우 까다롭습니다.
Android에 OpenApiGenrator 적용하기
적용하기전 Swagger 명세서가 필요합니다.
테스트용 명세서가 필요하다면 여기 를 클릭하세요. (테스트용 Swagger 명세서 입니다.)
1. build.gradle 설정 하기
자동으로 생성할 모듈에다가 아래와같이 설정을 해 줍니다.
plugins {
...
}
openApiGenerate {
generatorName.set("kotlin") // 언어 설정
inputSpec.set("${rootDir}/api-specs/pool.yaml") // Swagger에 명세된 API 파일 모음 위치, json 파일도 무관합니다.
outputDir.set("$projectDir") // 코드를 적용할 위치
library.set("jvm-retrofit2") // Retrofit2 기반 클라이언트 생성
templateDir.set("${projectDir.path}/templates") // 사용할 템플릿 위치
additionalProperties.set(
mapOf(
"useCoroutines" to "true", // Retrofit 인터페이스를 suspend 함수로 생성할지
"serializationLibrary" to "kotlinx_serialization", // JSON 직렬화 라이브러리 선택
"hideGenerationTimestamp" to "true", // 생성된 파일 상단에 timestamp 주석을 숨김
"dateLibrary" to "string", // 시간 관련 타입을 어떤 타입으로 생성 할지
"sourceFolder" to "src/main/java", // 생성된 파일을 어느 폴더에 넣을지
"packageName" to "com.pool.poolaos.data.remote.generated", // 패키지 전체 기본
"apiPackage" to "com.pool.poolaos.data.remote.generated.api", // 생성되는 Service Interface(=API) 코드 패키지
"modelPackage" to "com.pool.poolaos.data.remote.generated.model", // DTO(Model)들이 생성될 패키지
"useFullyQualifiedName" to "false", // import를 풀네임(FQN)으로 할지 여부
"explicitTypes" to "false" // 변수 타입을 항상 명시할지 여부 (true면 타입 강제 표기)
)
)
globalProperties.set(
mapOf(
"apis" to "", // 비워두면 전체 API 생성, Tag(Auth, Comment 등)설정시 특정 API만 생성
"models" to "", // 비원두면 전체 DTO 생성, Tag 설정시 해당 네이밍으로 DTO 설정
"apiDocs" to "false", // API 문서 파일 설명서 생성 여부
"modelDocs" to "false", // DTO 문서 파일 설명서 생성 여부
)
)
}
android {
...
}
tasks.named("preBuild").configure { dependsOn("openApiGenerate") }
dependencies {
...
}
2. Swagger에서 API 명세서를 yaml 파일로 가져오기

위 빨간색으로 표시된 링크로 들어갑니다.
위 링크의 PetStore는 json 파일로 되어있어, build.gradle의 openApiGenerator 에서 inputSpec을 수정해줘야합니다. (위치 또한 잘 설정 하셔야합니다.)
백엔드 설정에 따라 json 일수도, yaml 일수도 있습니다.
해당 링크를 들어가면 아래와 같은 화면이 나옵니다.

해당 소스를 가져와서 json 또는 yaml 파일로 만들어야합니다.
복사/붙여넣기 또는 크롬설정 -> 도구 더보기 -> 개발자 도구에서 다운로드하면 아주 편리합니다.
3. 해당 파일을 프로젝트에 넣습니다.
저는 프로젝트 루트에서 api-specs 폴더를 만들어서 해당 파일을 넣었습니다.
4. template 설정을 해줍니다.
링크 에서 해당 template을 다운로드 받을 수 있습니다.
api, data_class, enum_class mustache 파일을 모두 다운 받습니다.
테스트용으론 좋지만, 저는 해당 템플릿을 조금 다듬어서 사용중입니다.
혹시나 사용할지도 모를 여러분들을 위해 공유 합니다. (저희 프로젝트에서 사용하는 형식대로 커스텀한거라 참고정도만 하세요!)
// api.mustache
package {{apiPackage}}
import retrofit2.http.*
import com.pool.poolaos.data.remote.model.BaseResponse
{{#imports}}{{#-first}}import {{modelPackage}}.*{{/-first}}{{/imports}}
{{#operations}}
interface {{classname}} {
{{#operation}}
@{{httpMethod}}("{{path}}")
suspend fun {{operationId}}(
{{#hasPathParams}}
{{#pathParams}}
@Path("{{baseName}}") {{paramName}}: String,
{{/pathParams}}
{{/hasPathParams}}
{{#hasQueryParams}}
{{#queryParams}}
@Query("{{baseName}}") {{paramName}}: {{dataType}}{{^required}}? = null{{/required}},
{{/queryParams}}
{{/hasQueryParams}}
{{#hasBodyParam}}
{{#bodyParam}}@Body {{baseName}}: {{baseType}}{{/bodyParam}},
{{/hasBodyParam}}
): BaseResponse<{{#returnType}}{{{returnType}}}{{/returnType}}{{^returnType}}Unit{{/returnType}}>
{{/operation}}
}
{{/operations}}
// data_class.mustache
import kotlinx.serialization.*
{{^discriminator}}@Serializable
{{/discriminator}}
{{#isDeprecated}}
@Deprecated(message = "This schema is deprecated.")
{{/isDeprecated}}
{{#discriminator}}interface{{/discriminator}}{{^discriminator}}data class{{/discriminator}} {{classname}}{{^discriminator}} (
{{#parentModel}}{{#vars}}
{{^isFreeFormObject}}{{#isDate}}@Serializable(with = LocalDateSerializer::class)
{{/isDate}}{{#required}}@SerialName(value = "{{{vendorExtensions.x-base-name-literal}}}") @Required override {{>modelMutable}} {{{name}}}: {{#isEnum}}{{#isListContainer}}{{#isList}}collections.List{{/isList}}{{^isList}}Array{{/isList}}<{{classname}}.{{{nameInPascalCase}}}>{{/isListContainer}}{{^isListContainer}}{{classname}}.{{{nameInPascalCase}}}{{/isListContainer}}{{/isEnum}}{{^isEnum}}{{#isString}}String{{/isString}}{{#isInteger}}Int{{/isInteger}}{{#isLong}}Long{{/isLong}}{{#isBoolean}}Boolean{{/isBoolean}}{{#isFloat}}Float{{/isFloat}}{{#isDouble}}Double{{/isDouble}}{{^isString}}{{^isInteger}}{{^isLong}}{{^isBoolean}}{{^isFloat}}{{^isDouble}}{{{dataType}}}{{/isDouble}}{{/isFloat}}{{/isBoolean}}{{/isLong}}{{/isInteger}}{{/isString}}{{/isEnum}}{{#isNullable}}?{{/isNullable}}{{/required}}{{^required}}@SerialName(value = "{{{vendorExtensions.x-base-name-literal}}}") override {{>modelMutable}} {{{name}}}: {{#isEnum}}{{#isListContainer}}{{#isList}}collections.List{{/isList}}{{^isList}}Array{{/isList}}<{{classname}}.{{{nameInPascalCase}}}>{{/isListContainer}}{{^isListContainer}}{{classname}}.{{{nameInPascalCase}}}{{/isListContainer}}{{/isEnum}}{{^isEnum}}{{#isString}}String{{/isString}}{{#isInteger}}Int{{/isInteger}}{{#isLong}}Long{{/isLong}}{{#isBoolean}}Boolean{{/isBoolean}}{{#isFloat}}Float{{/isFloat}}{{#isDouble}}Double{{/isDouble}}{{^isString}}{{^isInteger}}{{^isLong}}{{^isBoolean}}{{^isFloat}}{{^isDouble}}{{{dataType}}}{{/isDouble}}{{/isFloat}}{{/isBoolean}}{{/isLong}}{{/isInteger}}{{/isString}}{{/isEnum}}?{{/required}},{{/isFreeFormObject}}
{{/vars}}
{{/parentModel}}{{#vars}}
{{^isFreeFormObject}}{{#isDate}}@Serializable(with = LocalDateSerializer::class)
{{/isDate}}{{^isInherited}}{{#required}}@SerialName(value = "{{{vendorExtensions.x-base-name-literal}}}") @Required {{>modelMutable}} {{{name}}}: {{#isEnum}}{{#isListContainer}}{{#isList}}collections.List{{/isList}}{{^isList}}Array{{/isList}}<{{classname}}.{{{nameInPascalCase}}}>{{/isListContainer}}{{^isListContainer}}{{classname}}.{{{nameInPascalCase}}}{{/isListContainer}}{{/isEnum}}{{^isEnum}}{{#isString}}String{{/isString}}{{#isInteger}}Int{{/isInteger}}{{#isLong}}Long{{/isLong}}{{#isBoolean}}Boolean{{/isBoolean}}{{#isFloat}}Float{{/isFloat}}{{#isDouble}}Double{{/isDouble}}{{^isString}}{{^isInteger}}{{^isLong}}{{^isBoolean}}{{^isFloat}}{{^isDouble}}{{{dataType}}}{{/isDouble}}{{/isFloat}}{{/isBoolean}}{{/isLong}}{{/isInteger}}{{/isString}}{{/isEnum}}{{#isNullable}}?{{/isNullable}}{{/required}}{{^required}}@SerialName(value = "{{{vendorExtensions.x-base-name-literal}}}") {{>modelMutable}} {{{name}}}: {{#isEnum}}{{#isListContainer}}{{#isList}}collections.List{{/isList}}{{^isList}}Array{{/isList}}<{{classname}}.{{{nameInPascalCase}}}>{{/isListContainer}}{{^isListContainer}}{{classname}}.{{{nameInPascalCase}}}{{/isListContainer}}{{/isEnum}}{{^isEnum}}{{#isString}}String{{/isString}}{{#isInteger}}Int{{/isInteger}}{{#isLong}}Long{{/isLong}}{{#isBoolean}}Boolean{{/isBoolean}}{{#isFloat}}Float{{/isFloat}}{{#isDouble}}Double{{/isDouble}}{{^isString}}{{^isInteger}}{{^isLong}}{{^isBoolean}}{{^isFloat}}{{^isDouble}}{{{dataType}}}{{/isDouble}}{{/isFloat}}{{/isBoolean}}{{/isLong}}{{/isInteger}}{{/isString}}{{/isEnum}}?{{/required}},{{/isInherited}}{{/isFreeFormObject}}
{{/vars}}
){{/discriminator}}{{#parent}} : {{parent}}{{/parent}}{{#vendorExtensions.x-has-data-class-body}} {
{{/vendorExtensions.x-has-data-class-body}}
{{#discriminator}}{{#vars}}{{#required}}
{{#description}}
/* {{{description}}} */
{{/description}}
@SerialName(value = "{{{vendorExtensions.x-base-name-literal}}}") @Required public {{>modelMutable}} {{{name}}}: {{#isEnum}}{{#isListContainer}}{{#isList}}collections.List{{/isList}}{{^isList}}Array{{/isList}}<{{classname}}.{{{nameInPascalCase}}}>{{/isListContainer}}{{^isListContainer}}{{classname}}.{{{nameInPascalCase}}}{{/isListContainer}}{{/isEnum}}{{^isEnum}}{{#isString}}String{{/isString}}{{#isInteger}}Int{{/isInteger}}{{#isLong}}Long{{/isLong}}{{#isBoolean}}Boolean{{/isBoolean}}{{#isFloat}}Float{{/isFloat}}{{#isDouble}}Double{{/isDouble}}{{^isString}}{{^isInteger}}{{^isLong}}{{^isBoolean}}{{^isFloat}}{{^isDouble}}{{{dataType}}}{{/isDouble}}{{/isFloat}}{{/isBoolean}}{{/isLong}}{{/isInteger}}{{/isString}}{{/isEnum}}{{#isNullable}}?{{/isNullable}}
{{/required}}{{^required}}
{{#description}}
/* {{{description}}} */
{{/description}}
@SerialName(value = "{{{vendorExtensions.x-base-name-literal}}}") public {{>modelMutable}} {{{name}}}: {{#isEnum}}{{#isListContainer}}{{#isList}}collections.List{{/isList}}{{^isList}}Array{{/isList}}<{{classname}}.{{{nameInPascalCase}}}>{{/isListContainer}}{{^isListContainer}}{{classname}}.{{{nameInPascalCase}}}{{/isListContainer}}{{/isEnum}}{{^isEnum}}{{#isString}}String{{/isString}}{{#isInteger}}Int{{/isInteger}}{{#isLong}}Long{{/isLong}}{{#isBoolean}}Boolean{{/isBoolean}}{{#isFloat}}Float{{/isFloat}}{{#isDouble}}Double{{/isDouble}}{{^isString}}{{^isInteger}}{{^isLong}}{{^isBoolean}}{{^isFloat}}{{^isDouble}}{{{dataType}}}{{/isDouble}}{{/isFloat}}{{/isBoolean}}{{/isLong}}{{/isInteger}}{{/isString}}{{/isEnum}}?{{/required}}{{/vars}}{{/discriminator}}
{{#hasEnums}}
{{#vars}}
{{#isEnum}}
@Serializable
enum class {{{nameInPascalCase}}}(val value: {{^isContainer}}{{#isString}}String{{/isString}}{{#isInteger}}Int{{/isInteger}}{{#isLong}}Long{{/isLong}}{{#isBoolean}}Boolean{{/isBoolean}}{{#isFloat}}Float{{/isFloat}}{{#isDouble}}Double{{/isDouble}}{{^isString}}{{^isInteger}}{{^isLong}}{{^isBoolean}}{{^isFloat}}{{^isDouble}}{{{dataType}}}{{/isDouble}}{{/isFloat}}{{/isBoolean}}{{/isLong}}{{/isInteger}}{{/isString}}{{/isContainer}}{{#isContainer}}String{{/isContainer}}) {
{{#allowableValues}}
{{#enumVars}}
@SerialName(value = "{{&name}}") {{&name}}("{{&name}}"){{^-last}},{{/-last}}
{{/enumVars}}
{{/allowableValues}}
}
{{/isEnum}}
{{/vars}}
{{/hasEnums}}
{{#vendorExtensions.x-has-data-class-body}}
}
{{/vendorExtensions.x-has-data-class-body}}
5. 템플릿 적용하기
이제, 설정하고 있는 모듈에 templates 라는 이름으로 폴더를 만들고 해당 템플릿들을 추가합니다.
그럼 AndroidStudio 에선 mustache 플러그인을 설치해 줘야합니다.

위 와같이 Install Handlebars/Mustache plugin이 뜨는데 클릭해줍니다.

그리고 해당 플러그인을 설치해줍니다.
만약, 해당 경고가 안나온다면 이미 설치가 되어있는거겠죠!

마지막으로 "licenseInfo" 라는 네이밍으로 mustache 파일을 만들어줍니다. 해당 파일은 비워둡니다.
만약 해당 파일을 추가하지 않는다면 DTO 파일에 라이센스 관련 주석 설명이 계속 추가가 됩니다.
6. 결과
이제 빌드를 하면 코드가 자동으로 생성됩니다!

마무리
오늘은 수동으로 OpenAPIGenerator를 설정하는법을 알아보았습니다.
제가 커스텀한 템플릿은 저희 프로젝트 Response 구조상 BaseResponse 모델이 필요하기에, 해당 설정이 되어있는 상태입니다.
또, 만약 이 템플릿을 적용하지 않는다면 String 같은 타입이 kotlin.String 처럼 타입이 설정 될텐데 이런 kotlin 같은 불필요한 설정은 제거한 상태입니다.
아직, 완벽한 템플릿은 아니기에 참고정도 하시면 좋을듯 합니다.
자동으로 코드를 생성 하기위해선 이 방식과는 조금 다르게 URL 설정을 해줘야합니다.
해당 방식은 다음 포스팅에서 진행해 보도록 하겠습니다.
'Android Studio > - Programming' 카테고리의 다른 글
| [코틀린] HashMap Deep-Dive (0) | 2025.07.12 |
|---|---|
| [안드로이드] MVVM 패턴이란? (0) | 2024.05.21 |
| [안드로이드] 4대 컴포넌트 (0) | 2024.05.13 |
| [안드로이드/viewBinding] 뷰바인딩은 무엇이고 어떻게 사용할까? (0) | 2024.04.30 |
| [안드로이드] 액티비티 생명주기 ( Activity Lifecycle ) (0) | 2024.04.24 |
주코딩의 개발 노트!
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!