kotlin 프로젝트에서 openapi code generator 사용하기

2024. 3. 24. 23:59Java/Spring

서론

현재 참여하고 있는 프로젝트는 쿠버네티스 환경으로, 여러 컴포넌트로 구성된 마이크로서비스 아키텍처입니다. 각 컴포넌트는 서로 다른 네트워크 주소와 포트에서 실행됩니다. 따라서 각 컴포넌트의 API를 호출하려면 다음과 같은 어려움을 겪었습니다. 하지만 분산된 시스템에서 서로 다른 서비스의 API를 호출하는 것은 복잡합니다. 네트워크 주소 관리, 로드 밸런싱, 오류 처리 등 고려해야 할 사항이 많기 때문입니다. 이러한 과정을 자동화하고 개발 효율성을 높이기 위해서 Open Api CodeGenerator를 도입하는 것을 고려했습니다.

Open Api Code Generator는 OpenAPI Spec을 기반으로 프로젝트 코드를 생성해주는 오픈소스 툴입니다. 기본적으로 각 컴포넌트 API를 OpenAPI 스펙으로 정의하여 Api문서 공유가 가능합니다. 그리고 그 스펙을 기반으로 자동으로 Api client 코드를 생성되어 Controller 클래스가 상속받아 사용하여 일관성이 있게 개발가능합니다. 그리고 다른 컴포넌트의 API도 손쉽게 호출할 수 있습니다! 이를 통해 자동으로 생성된 API 클라이언트 코드로 API 테스트를 자동화하여 API 호출 문제를 사전에 예방할 수 있습니다.

현재 팀에서 각 컴포넌트에 대한 Api Spec 작성 및 관리는 Swagger hub 통해 하고 있습니다. 이번 포스트에서는 swagger hub에 있는 Api spec을 이용하여 Api 개발 시 필요한 세팅을 작성하고자 합니다.

환경

기본 프로젝트 환경은 spring boot 2.7, Java Version 11 입니다. 기본 환경 세팅은 제외하고 추가나 필요한 코드 위주로 소개하겠습니다.

OpenAPI Code Generator를 사용하기 위해 build.grardle.kts에 다음과 같이 플러그인 및 dependency 추가가 필요합니다.

plugin{
    id("org.openapi.generator") version "7.4.0"
    ...
}

dependency{
    implementation("org.springframework.boot:spring-boot-starter-validation")

    implementation("com.squareup.okhttp3:okhttp:5.0.0-alpha.12")
    implementation("com.google.code.gson:gson:2.10.1")
    ...
}

현재 Api 개발을 할 대상 컴포넌트를 'my-component'라고 가정합니다. 그리고 다른 컴포넌트들 api spec도 swagger hub에 명세되어 있다고 가정합니다. swagger hub에 my-component 프로젝트를 생성하여 Api spec을 작성합니다. swagger hub를 연동하기 위해 프로젝트 내부에 my-component와 타컴포넌트 api key들을 .swagger/hubapi.json에 추가해주었습니다.

// hubapi.json
{
  "key":"apikey 작성",
  "test.componentA": "apikey 작성" ,
  "test.componentB": "apikey 작성" ,
}

OpenAPI를 통해 code를 생성하는 gradle task를 만듭니다. task에서 사용할 input에 대한 정의를 먼저 합니다. swagger hub에 작성한 api spec을 기준으로 Api 코드를 생성하고자 provide spec 에 작성합니다. 그리고 my-component 컴포넌트의 특정 api에서 타 컴포넌트에 있는 api를 호출하는 경우, 그 컴포넌트들의 정보를 consume spec에 작성합니다.

object UserDefineInputs {
    data class GenerateTaskInput(val name: String, val specUrl: String)

    val provideSpec = GenerateTaskInput("my-component", "https://api.swaggerhub.com/apis/test/my-component/1.0.0")

    val consumeSpecs = listOf(
        GenerateTaskInput("componentA", "https://api.swaggerhub.com/apis/test/component-a/1.0.0"),
        GenerateTaskInput("componentB", "https://api.swaggerhub.com/apis/test/component-b/1.0.0")
    )
}

현재 프로젝트에서 api spec 작성과 관리를 원격 환경인 swagger hub에서 하고 있어 그 기준으로 환경 설정이 됩니다. swagger hub는 유료이기에 open api generator 도입 전에 테스트하고자 하시면 로컬에서 간단히 openapi.yaml을 생성하여 open api generator를 사용할 수 있습니다. 이럴 경우 swagger hub url이 아닌 yaml 파일 위치를 참조할 수 있도록 변경해주셔야 합니다.

GenerateAllSpecs, GenerateProvideSpec, GenerateConsumeSpec task를 만들어 필요에 따라 provide, consume api 코드를 모두 생성/업데이트할 수 있도록 했습니다. 기본적으로 build 시 GenerateAllSpecs task와 동일하게 provide, consume spec 코드가 생성되도록 했습니다. 그리고 src/ 디렉토리에서 api 생성 코드들을 관리하여 버전 관리하고 싶다는 팀원의 니즈로 code generate 되는 위치를 지정합니다.

val generatedPath = "src/generated"
val generatedPathForKotlin = "$generatedPath/kotlin"

tasks.register("GenerateAllSpecs") {

    println(">>> Deleting openapi generated files")
    val genMetaFiles = files("$generatedPath/.openapi-generator")
    delete(genMetaFiles)

    val genSrcFiles = files("./$generatedPath")
    delete(genSrcFiles)

    println(">>> Creating directory 'src/generated' for openapi generated files")
    val directory = file("$generatedPathForKotlin/.generated_files")
    Files.createParentDirs(directory)

    registeredGenerateTasks.forEach { it.taskProvider.get().doWork() }
}

tasks.register("GenerateProvideSpec") {
    registeredGenerateTasks.filter { it.isForProvide }.forEach { it.taskProvider.get().doWork() }
}

tasks.register("GenerateConsumeSpec") {
    registeredGenerateTasks.filter { !it.isForProvide }.forEach { it.taskProvider.get().doWork() }
}

registerGenerateProvideSpec, registerGenerateConsumeSpec 에서 input에 정의한 api spec 문서를 참조하여 open api option에 따라 코드를 생성합니다. 또한 조금 더 빠른 코드 생성을 위해 각 컴포넌트별 gradle task가 만들어질 수 있도록 하였습니다.

data class RegisteredGenerateTask(
    val name: String,
    val isForProvide: Boolean,
    val taskProvider: TaskProvider<GenerateTask>
)

val registeredGenerateTasks = mutableListOf<RegisteredGenerateTask>()

fun registerGenerateTask(
    name: String,
    swaggerUrl: String,
    isForProvide: Boolean,
    configOptions: List<String>
): TaskProvider<GenerateTask> {
    val packageNamePrefix = if (isForProvide) "provide" else "consume"
    val apiPackageName = "${GenerateTaskConfig.PACKAGE_PREFIX}.$packageNamePrefix.test.$name.api".replace("[-]".toRegex(), "_")
    val modelPackageName = "${GenerateTaskConfig.PACKAGE_PREFIX}.$packageNamePrefix.test.$name.model".replace("[-]".toRegex(), "_")
    val taskName = "Generate${packageNamePrefix.capitalize()}_$name"
    val generatorName = if (isForProvide) "kotlin-spring" else "kotlin"

    val newTaskProvider = tasks.register<GenerateTask>(taskName) {
        generatorName.set(generatorName)
        outputDir.set(project.file("./").absolutePath)
        // 로컬에 있는 yaml 파일을 사용할 경우 다음 옵션 사용 inputSpec.set(yamlFilePath)
        remoteInputSpec.set(swaggerUrl)
        apiPackage.set(apiPackageName)
        modelPackage.set(modelPackageName)
        globalProperties.set(GenerateTaskConfig.globalOptions)
        configOptions.set(configOptions)
    }
    registeredGenerateTasks.add(RegisteredGenerateTask(name, isForProvide, newTaskProvider))
    return newTaskProvider
}

GenerateTaskConfig 에 open api code generate 옵션을 두어 관리하고 있습니다. open api code generate 옵션은 default 값이 있고 사용자가 옵션을 지정할 수 있습니다.

object GenerateTaskConfig {
    private const val GENERATED_KOTLIN_SOURCE = "src/generated/kotlin"
    const val PACKAGE_PREFIX = "com.my.test"

    val globalOptions = mapOf(
        "apis" to "",
        "models" to "",
        "verbose" to "true",
        "supportingFiles" to "",
    )
    val serverConfigOptions = mapOf(
        "interfaceOnly" to "true",
        "useSwaggerUI" to "true",
        "useTags" to "true",
        "enumPropertyNaming" to "UPPERCASE",
        "sourceFolder" to GENERATED_KOTLIN_SOURCE
    )
    val clientConfigOptions = mapOf(
        "dateLibrary" to "java8",
        "hideGenerationTimestamp" to "true",
        "sourceFolder" to GENERATED_KOTLIN_SOURCE,
        "serializationLibrary" to "gson",
        "library" to "jvm-okhttp4"
    )
}

consume한 컴포넌트들의 api를 사용하기 위해 api client를 이용하는 데 어려움을 겪어 clientConfigOptions를 설정하는데 어려움이 많았습니다.

특정 팀원이 date가 다른 경우가 발생했었는데 OpenAPI Code generator의 dateLibrary 옵션 영향으로 발생했었던 거였습니다. 그래서 dateLibrary를 java8로 명시하여 해결하였습니다.

kotlin-openapi-generator 공식문서를 통해 client 기본 library가 jvm-okhttp4 인 것을 확인했습니다. jvm-okhttp4라이브러리는 serialization 시 Moshi를 사용하고 있었습니다. OpenAPI Code generator는 다양한 설정을 통해 클라이언트 생성에 사용되는 라이브러리를 조정이 가능하여 dependency에 추가한 Gson을 이용하여 serialize 되도록 하였습니다. 즉, clientConfigOptions에 library와 serializationLibrary를 명시하여 해결하였습니다. 참고로 Java spring boot 일 경우 지원되는 library, serializationLibrary가 다르니 공식문서를 꼭 참조하시길 추천드립니다.

설정 옵션에 따라 다음과 같이 client 에 있는 api를 호출할 때 필요한 infrastructure 코드들이 생성이 됩니다.

앞서 정의한 registerGenerateTask 를 호출하여 spec 을 참조하여 code generate하는 task을 생성합니다.

UserDefineInputs.provideSpec.run { registerGenerateTask(name, specUrl, true, GenerateTaskConfig.serverConfigOptions) }
UserDefineInputs.consumeSpecs.forEach { spec ->
    spec.run {
        registerGenerateTask(name, specUrl, false, GenerateTaskConfig.clientConfigOptions)
    }
}

로컬환경 IntelliJ IDEA에서 OpenAPI code generate task를 실행할 때 Gradle의 기본 Java 힙 메모리(512Mb)가 부족하여 클라이언트 생성에 실패할 수 있습니다. 원활하게 code generate를 하기 위해 Gradle 설정 파일인 gradle.properties를 수정하여 Java 힙 메모리 크기를 늘립니다.

org.gradle.jvmargs=-Xmx8192m

적용

1) Swagger hub에 명세한 API 문서에 따라 API 개발

앞서 Swagger hub에 my-component에 명세했었습니다. GenerateAllSpec task를 실행하게 되변 my-component, 즉 provide spec을 참조하여 MyApi가 생성됩니다. 이 때, Swagger hub에 명세한 response 클래스도 생성되어 API 클래스의 메서드 return 타입은 response가 됩니다. Controller 클래스에서 MyApi 클래스를 상속하여, MyApi에 명세된 메서드들을 override하여 개발하게 됩니다.

@RestController
class MyApiController(
    private val systemService: SystemService
    private val personService: PersonService
) : MyApi {
    private companion object : LoggerCreator()

    override fun systemHealthCheck(): ResponseEntity<DefaultResponse> = systemService.systemHealthCheck()

    override fun getAllPersons(): List<Person>? = personService.getAllPersons()

    ...
}

2) 타컴포넌트 API 호출

component A, component B 등 consume한 컴포넌트들의 client를 이용하기 위해, ApiConstants 의 companion object에 호출할 api를 작성합니다.companion object를 사용하면 해당 객체 내의 속성은 클래스 로딩 시점에 초기화되며, 이후에는 해당 클래스의 인스턴스나 다른 객체에서 접근하더라도 초기화 과정이 다시 일어나지 않습니다. 따라서 companion object 내에서 초기화된 api들은 클래스가 로드될 때 한 번만 호출되어 초기화됩니다. 이후에는 항상 같은 인스턴스를 반환하게 됩니다. 즉, 싱글톤으로 사용이 가능합니다.

object ApiConstants : LoggerCreator() {
    init {
        log.info("[INITIALIZED] api list for openapi consume project")
    }
    val aaApi = ComponentATestAApi()  
    val abApi = ComponentATestBApi()
    val baApi = ComponentBTestAApi()
}
@Service
class serviceService(){
    fun hello(){
        println("hello to componentA")
        ApiConstants.aaApi.hello()
        println("hello to componentB")
        ApiConstants.baApi.hello()
    }

    fun bye(){
        println("bye bye to componentA")
        ApiConstants.abApi.bye()
    }
}

참조