Swagger (2) - 실무에 활용 가능한 API 문서 만들기 (feat. Java Reflection)

강지혁·2022년 7월 26일
3

Swagger

목록 보기
2/2

앞선 포스팅에서, 스프링 부트 서버에 스웨거 UI를 제공해주는 springDoc-OpenApi 를 살펴보았습니다.
단지 라이브러리 의존성을 추가하는 것만으로도, 서버의 모든 API가 화면에 잘 렌더링 되는 것을 확인할 수 있었습니다.

그러나 날로 먹는 것에는 언제나 한계가 있는 법이죠..

Swagger가 해결해주지 못하는 것들

정말 깔끔하고 편리한 UI를 제공해주는 Swagger지만,
기본 세팅 만으로는 실무에서 사용하기 애매한 포인트가 몇 가지 존재합니다.
오늘은 그 가운데 크리티컬한 인터페이스 관련 이슈에 대해 이야기해보려 합니다.

인터페이스 타입 직렬화

: 어떤 API의 반환 타입이 인터페이스인 경우, Swagger 스키마에 그 구현체가 담기지 않습니다.

예시와 함께 살펴봅시다.
아래 Response 인터페이스는 두 가지 구현체를 가지고 있습니다.

interface Response

data class ResponseImpl(val int: Int, val str: String): Response

data class ResponseRecursiveImpl(val timestamp: LocalDateTime, val response: ResponseImpl): Response

그리고 이를 반환 타입으로 가지는 API가 존재합니다.

@RestController
class BaseController {
    @GetMapping("/")
    fun request(
        request: Request
    ): Response {
        return listOf(
            ResponseImpl(1, "a"),
            ResponseRecursiveImpl(LocalDateTime.MAX, ResponseImpl(1, "b")),
        ).random()
    }
}

이 API는, Response 인터페이스 타입의 두 구현체 중 하나를 랜덤으로 반환합니다.
클라이언트에서 이 API를 사용하려고 한다면, 당연히 두 구현체의 생김새를 모두 알아야겠죠?

그러나 스웨거는 이를 알아차리지 못합니다.

조금만 생각해보면, 그 이유를 알 수 있습니다.

앞선 포스팅에서, 스웨거의 UI 렌더링이 @Controller를 비롯한 API 관련 어노테이션에서부터 시작된다는 사실을 확인했었습니다.

아래 흐름과 같이 API를 찾는 건데요,

1. @GetMapping("/") 어노테이션을 바탕으로, fun request(..): .. 메소드를 분석한다.
2. 요청으로 Request DTO, 응답으로 Response DTO를 반환함을 파악한다.
3. Request, Response DTO 스키마 형태를 Resolve 후, OpenApi 객체에 담는다.

(자세한 과정은 후술하겠습니다.)

이 과정에서 인터페이스의 SubType을 찾으려면 별도의 패키지 스캐닝이 들어갈 수밖에 없습니다.
패키지 스캐닝 후에 또 별도의 Registry에서 인터페이스의 SubType을 보관한다거나.. 하는 식의 작업이 필요해집니다.

단순히만 생각해봐도, 요청의 Parameter & Response마다 매번 이 작업을 수행하는 것은 극도로 비효율적입니다.
패키지 스캐닝 비용 관점에서 O(n^2)만큼의 부하가 발생하니까요..

@JsonSubTypes

Swagger에서는 이 문제를 해결하기 위해 @JsonSubTypes 어노테이션을 이용합니다.

아래와 같이, Response 인터페이스에 직접 구현체들을 명시해줄 수 있습니다.

@JsonSubTypes(value = [JsonSubTypes.Type(ResponseImpl::class), JsonSubTypes.Type(ResponseRecursiveImpl::class)])
interface Response

매번 존재할 지 모르는 스키마의 SubType을 찾는 대신, 직접 어노테이션을 통해 SubType을 찾아갈 수 있도록 표시를 해두면 그것을 토대로 UI에 나타내주는 것이죠.
위와 같은 처리를 한 후 UI에 들어가보면 스키마가 아래처럼 바뀐 것을 확인할 수 있습니다.

뭔가 구현체를 보여주긴 했습니다.
그런데 여전히 뭔가.. 애매합니다.
1. 원본 Response 인터페이스와 구현체 사이의 상속관계가 드러나지 않고, oneOf로 퉁을 치고 있습니다.
2. RecursiveImpl 프로퍼티 안에 ResponseImpl 타입이 포함되어 있어서 그런지, ResponseImpl은 선택지로 보여주지 않고 있습니다.

2번은 블로그 작성하면서 깨달았습니다.. 이거 버그 아닌가 (?)

더 근본적인 문제 : 귀찮다

일단 위에 문제도 문젠데, @JsonSubType을 명시하는 작업은 한계가 명확합니다.
만약 Interface 타입 구현체가 20개면 어떡할까요?
그 와중에 하나의 구현체를 더 추가해야 한다면!?

생각만 해도 챙기기 귀찮습니다..

차라리 Package Scanning을 하자

실무에서 수작업 기반의 노션 API를 Swagger로 옮기는 작업을 진행했습니다.
진행 과정에서 지향했던 점이 딱 한 가지 있는데요, 그것은 바로 Zero 공수 였습니다.
API 문서 작업에 들어가는 비용을 정말 '최소화'하려던 것이었죠.
(이러한 이유로 통합 테스트 작성이 강제되는 Spring Rest Docs가 선택지에서 제외되었습니다.)

앞서 말한 인터페이스 이슈는, 어찌됐든 해결하지 않으면 안되는 문제지만, @JsonSubTypes를 사용하기에는 한계가 너무 뚜렷이 보였습니다.

저는 이 문제를 해결하기 위해, 차라리 Swagger의 스키마 생성 시점에 개입하자 라는 판단을 내렸습니다.

스키마 생성 과정에 끼어들기

스키마 생성 과정에 끼어들어서, 스키마 타입이 인터페이스인 경우에는, 그 SubType을 별도로 보관하고 있도록 구현해봅시다.
그러려면 스키마 생성 과정에 참여하는 스웨거 컴포넌트를 찾아야 합니다.

이걸 찾기가 빡셌습니다 ㅎㅎ 😅
OpenApi 객체를 만들어나가는 과정에서, OpenApi의 Component 프로퍼티 내부에 있는 Schema 프로퍼티를 추적해나가야 하는데요, 문제는 Component 객체 자체가 여기저기서 재사용 가능한 것들의 집합이다 보니, 실제로 오만군데에서 업데이트가 일어납니다.

(전 포스팅에서 살펴본) OpenApiResource 클래스의 getOpenApi 메소드를 시작으로, Schema 업데이트 과정을 뒤지다 보면, 아래와 같은 일급 컬렉션을 만날 수 있습니다.

package io.swagger.v3.core.converter;

public class ModelConverters { 
    // 싱글톤 객체임
    private static final ModelConverters SINGLETON = new ModelConverters();
    // converter 컬렉션을 가지고 있음
	private final List<ModelConverter> converters;
    
    // 뭔가 스키마를 만든다
    public ResolvedSchema resolveAsResolvedSchema(AnnotatedType type) {
    
    	// 이 친구는 뭐지!?
        ModelConverterContextImpl context = new ModelConverterContextImpl(
                converters);

        ResolvedSchema resolvedSchema = new ResolvedSchema();
        resolvedSchema.schema = context.resolve(type);
        resolvedSchema.referencedSchemas = context.getDefinedModels();

        return resolvedSchema;
    }
}

public class ModelConverterContextImpl implements ModelConverterContext { 

	@Override
    public Schema resolve(AnnotatedType type) {

        // ..
        
        // Converter 객체를 chaining 하면서 스키마를 만들어낸다..!
        Iterator<ModelConverter> converters = this.getConverters();
        Schema resolved = null;
        if (converters.hasNext()) {
            ModelConverter converter = converters.next();
            LOGGER.trace("trying extension {}", converter);
            resolved = converter.resolve(type, this, converters);
        }
        if (resolved != null) {
            modelByType.put(type, resolved);

            Schema resolvedImpl = resolved;
            if (resolvedImpl.getName() != null) {
                modelByName.put(resolvedImpl.getName(), resolved);
            }
        } else {
            processedTypes.remove(type);
        }

        return resolved;
}

public interface ModelConverter {

    /**
     * ❗️이 객체의 resolve 함수 체이닝을 통해 스키마를 만드는군❗️
     * @param type
     * @param context
     * @param chain   the chain of model converters to try if this implementation cannot process
     * @return null if this ModelConverter cannot convert the given Type
     */
    Schema resolve(AnnotatedType type, ModelConverterContext context, Iterator<ModelConverter> chain);
}


// SpringDocConfiguration.java
// 위의 ModelConverter를 커스텀 Bean으로 넣어줄 수 있다!

	@Bean
	@Lazy(false)
	ModelConverterRegistrar modelConverterRegistrar(Optional<List<ModelConverter>> modelConverters) {
		return new ModelConverterRegistrar(modelConverters.orElse(Collections.emptyList()));
	}

위 코드 흐름을 보면, 다음부터 할 일은 명확해 보입니다.

  1. 이제 커스텀 컨버터를 등록하고, resolve 과정에 인터셉트합니다.
  2. 만약 파라미터로 들어온 타입이 인터페이스 타입이면, 서브타입을 전부 찾아냅니다.
    • 패키지 스캐닝을 위해 Java Reflection 라이브러리를 이용했습니다.
  3. 찾아낸 서브타입을 '어딘가에' 저장해뒀다가, OpenApi 객체의 Schema 프로퍼티에 꽂아줍니다.
    • 로컬 캐시를 이용하기로 했습니다.

@Component
class CustomAdditionalModelsConverter(
    val objectMapper: ObjectMapper,
) : ModelConverter {

	// 3번을 위한 빈 로컬 캐시
    // 타입 이름 - 서브 타입 스키마 매핑
    val schemaLocalCache = mutableMapOf<String, MutableSet<ResolvedSchema>>()
    
    override fun resolve(
        type: AnnotatedType,
        context: ModelConverterContext?,
        chain: Iterator<ModelConverter>,
    ): Schema<*>? {
        val resultSchema = if (chain.hasNext()) chain.next().resolve(type, context, chain) else null
        return resultSchema?.also {
            val kClass = objectMapper.constructType(type.type).rawClass.kotlin
            saveSubTypesToLocalCacheIfIsInterface(kClass)
        }
    }
}

어떤 타입이 인터페이스 타입인지 파악할 때, 주의해야 하는 것이 하나 있습니다.
바로 List<*>와 같이 런타임에 타입 정보가 사라지는 제네릭 인터페이스들인데요,
이런 친구들은 제외해줄 필요가 있습니다.
저는 아래와 같은 방법으로 패키지를 보고 구분해주었습니다.

    private val KClass<*>.isMyAbstract: Boolean get() {
        val isAbstract = isAbstract || isSealed || java.isInterface
        val isCashMission = qualifiedName!!.startsWith(BASE_PACKAGE_NAME)
        return isAbstract && isCashMission
    }

이를 바탕으로, 어떤 스키마의 타입이 인터페이스 & 추상 클래스 타입인 경우,
로컬 캐시에 정보를 기록해주도록 코드를 구현하면 됩니다.
이 때 순환 참조로 무한 루프가 도는 일이 생기지 않도록 조심해야 합니다.
이미 로컬 캐시에 key가 저장되어 있다면 스킵해줍니다.

private fun saveSubTypesToLocalCacheIfIsInterface(kotlinType: KClass<*>) {
        if (
            !kotlinType.isMyAbstract ||
            schemaLocalCache.containsKey(kotlinType.simpleName)
        ) return

        schemaLocalCache[kotlinType.simpleName!!] = mutableSetOf()

        // insert all subType schemas into local cache
        fetchAllSubTypes(kotlinType).forEach { subClass ->
            val resolvedSchema = ModelConverters.getInstance().readAllAsResolvedSchema(subClass.java)
            schemaLocalCache[kotlinType.simpleName!!]!!.add(resolvedSchema)
        }

        // check For member properties
        kotlinType.memberProperties
            .mapNotNull { property -> property.returnType.classifier as? KClass<*> }
            .filter { kClass -> kClass.isMyAbstract }
            .forEach { kClass -> saveSubTypesToLocalCacheIfIsInterface(kClass) }
    }

    private fun fetchAllSubTypes(kClass: KClass<*>): Set<KClass<*>> {
        return Reflections(BASE_PACKAGE_NAME).getSubTypesOf(kClass.java).map { jvmType -> jvmType.kotlin }.toSet()
    }

이런 식으로, 스키마 생성 과정에 잠입해서 몰래 인터페이스 타입 기록을 저장할 수 있습니다.
이제 저장해둔 정보를 꺼내서, OpenApi 객체에 끼워넣어주면 됩니다.
이 과정은 쉽게 구현할 수 있습니다.

OpenApiCustomizer를 사용하면 됩니다.

@Configuration
class SwaggerConfig(
	// 로컬 캐시 접근을 위해 빈을 주입받습니다.
	private val modelConverter: CustomAdditionalModelsConverter,
) {
	@Bean
    fun openApiCustomizer() = OpenApiCustomiser { openApi ->
        val openApiSchemas = openApi.components.schemas
        val localCache = modelConverter.schemaLocalCache

        // add additionally scanned subType schemas to OpenApi Schema List
        val resolvedSubTypeSchemas = localCache.values.flatten()
        resolvedSubTypeSchemas.forEach { resolvedSchema ->
            openApiSchemas.putIfAbsent(resolvedSchema.schema.name, resolvedSchema.schema)
            resolvedSchema.referencedSchemas.forEach(openApiSchemas::put)
        }

        // add reference schemas to interface schemas
        localCache.forEach { (interfaceSchemaName, resolvedSubTypeSchemas) ->
            val childSchemas = resolvedSubTypeSchemas.map { resolvedSchema -> Schema<Any>().apply { `$ref` = resolvedSchema.schema.name } }
            val navigatingSchema = Schema<Any>().anyOf(childSchemas)
            openApiSchemas[interfaceSchemaName] = navigatingSchema
        }

        localCache.clear()
    }
 }

(참고) 위 코드에서, 인터페이스 스키마의 anyOf 프로퍼티에 서브타입 스키마에 대한 $ref만 가지고 있는 스키마들을 주입해주고 있는 모습을 확인할 수 있습니다. 여기서 $ref는 그 레퍼런스와 같은 의미로 사용되는데요, 스키마 사이의 관계를 표현해줄 때, 중복 선언할 필요 없이 다른 스키마로의 참조를 지정해주기만 하면 (포인터처럼) 손쉽게 정보를 확인할 수 있습니다.

이 커스터마이저를 OpenApi 객체에 등록해주고, 원하는 대로 작동하는지 확인해봅시다.


아주 나이스하게 변경되었네요!! ☺️
코드 동작대로, Response 인터페이스의 anyOf 항목에 두 구현체가 나타나고 있는 모습을 확인할 수 있습니다.

생각해볼 거리

  • 이런 식으로 Bean에 mutable state를 두고, 다른 곳에서 참조하는 방식이 안전한지 100% 확신은 없습니다.
    • 다만 아예 Static하게 두는 것보다는 안전하겠죠..!?
  • 우리가 앞서 예상했던 것처럼, 이 방식은 Java Reflection 라이브러리의 패키지 스캐닝 시간만큼 렌더링이 더 오래 걸리는 효과를 가져옵니다.
    • 다만 스웨거 관련 Bean들은 lazy 설정이 되어 있기 때문에, 앱 부팅 시간에 영향을 끼치지는 않습니다.

마무리

오늘은 스웨거를 아무 설정 없이 날로(?) 먹었을 때 발생할 수 있는 타입 관련 고민거리와,
이를 나름의 방식으로 해결해본 경험에 대해 공유해보았습니다.

역시 세상에 마법은 없구나..를 새삼 느끼게 되네요 ㅎㅎ

그 외 Swagger 커스텀

스웨거에는, 기본 설정에 다른 수많은 추가 기능을 덧붙일 수 있습니다.

  • 인증 설정
    • 직접 인증 헤더를 추가할 수도 있고, OAuth 설정도 가능
  • 예외 시나리오 설정
    • @ApiResponses 활용
  • 서버 설정

이외에도 스웨거의 기능은 생각보다 아주 다양하고 강력합니다.

저도 글로벌 헤더 설정 및 서버 base URL 설정을 통해 개발환경에서 클라이언트 분들이 API 테스트를 먼저 해볼 수 있게끔 환경을 구성해두었습니다.

기회가 된다면 이러한 기능을 비롯해 스웨거의 다른 피처들에 대해서도 한 번 다루어보겠습니다 :)

편한게 최고.. 스웨거 최고..


포스팅 관련 소스코드는 아래에서 확인하실 수 있습니다.
https://github.com/Jhvictor4/Swagger-Interface.git

0개의 댓글