여러분의 DTO 스타일은 Spring Swagger에 막힙니다.

Eric·2023년 7월 16일
3
post-thumbnail

🤔 이 게시글은 Springfox Swagger가 적용된 프로젝트에서, DTO를 Inner class로 작성할 때 발생하는 문제에 대해 다룹니다. (어그로 죄송합니다 🙏)

깔끔한 코드, 그렇지 못한 문서화?

Spring Boot로 서버 개발을 하다 보면, DTO 클래스를 최대한 깔끔하게 만들기 위해 Inner class를 이용하는 경우도 있습니다. 아래처럼요.

// class를 사용하는 경우가 많지만, 이 경우 inner class를 선언하려면 static을 붙여야 하기에 저는 interface를 선호합니다.
interface CreateUser {
	@Getter
    class Request {
    	private String name;
        private Integer age;
    }
    
    @Getter
    @Builder
    class Response {
    	private Long id;
    }
}

어느 날, 이런 형태의 DTO 구조를 채택하여 프로젝트를 진행하다 한 가지 이상한 일을 겪었습니다.

// HTTP GET /users
public interface ListUser {
    @Getter
    @Builder
    class User {
        private Long id;
        private String name;
    }

    @Getter
    @Builder
    class Response {
        private List<User> users;
    }
}

// HTTP POST /users
public interface CreateUser {
    @Getter
    @Builder
    class Response {
        private Long id;
    }
}

예시로 작성한 코드(Response DTO 부분)와 Springfox Swagger 결과물입니다. Swagger를 통해 프로젝트의 API 문서를 생성하고 나니, Response DTO의 형태가 항상 동일하다는 것을 확인할 수 있습니다.

이렇게 되면, 이러한 DTO 작성 스타일로는 정상적인 개발이 불가능해 보이죠. 해결할 방법은 없는걸까요?

이 문제를 해결하기 위해서는 먼저 SpringFox Swagger 라이브러리의 코드를 분석할 필요가 있습니다.

Springfox Swagger가 작동하는 방법

Swagger가 작동하기 위해서는, DTO의 프로퍼티를 파악하고 이를 식별하기 위한 DTO 이름(식별자)를 부여하는 과정이 필요합니다.

그리고 Springfox는 각 DTO 클래스에 식별자를 부여하기 위해 TypeNameProviderPlugin 인터페이스의 nameFor() 메서드를 이용하여 클래스의 이름을 가져오게 됩니다.

그런데, 문제는 TypeNameProviderPlugin의 기본 구현체인 (실제로는 조금 더 복잡한 구조지만, 간단한 설명을 위해 생략) DefaultTypeNameProvider에 의해서 발생합니다. 이 구현체는 nameFor() 메서드가 Class<?>.getSimpleName()을 호출한 값을 반환하도록 작성되어 있습니다. 아래와 같이 말이죠.

public class DefaultTypeNameProvider implements TypeNameProviderPlugin {

  @Override
  public boolean supports(DocumentationType delimiter) {
    return true;
  }

  @Override
  public String nameFor(Class<?> type) {
    return type.getSimpleName();
  }
}

Java에서 기본 제공되는 본 메서드는, 단순히 선언된 클래스의 이름만을 가져오게 됩니다. 따라서, Inner Class를 이용하여 생성한 DTO들에 대해서 모두 같은 클래스로 식별을 하게 되는 것이죠.

해결 방법

시도 1. Annotation 이용하기 (비권장)

DefaultTypeNameProvider을 상속하여 실질적인 구현체 역할을 하는 ApiModelTypeNameProvider를 살펴보면, 아래와 같이 구현되어 있습니다.

@Component
@Order(SwaggerPluginSupport.SWAGGER_PLUGIN_ORDER)
public class ApiModelTypeNameProvider extends DefaultTypeNameProvider {
  @Override
  public String nameFor(Class<?> type) {
    ApiModel annotation = findAnnotation(
        type,
        ApiModel.class);
    String defaultTypeName = super.nameFor(type);
    if (annotation != null) {
      String value = nullToEmpty(annotation.value());
      if (value.length() == 0) {
        return defaultTypeName;
      }
      return value;
    }
    return defaultTypeName;
  }

  @Override
  public boolean supports(DocumentationType delimiter) {
    return SwaggerPluginSupport.pluginDoesApply(delimiter);
  }
}

@ApiModel이라는 어노테이션을 이용하면 DTO 클래스의 이름을 임의로 지정해 줄 수 있다는 것이죠. 이를 통해 Inner Class를 사용함으로서 발생하는 중복 문제를 해결할 수 있습니다.

시도 2. Plugin 변경하기 (실패)

앞서 언급한 어노테이션 방식을 쓴다면, DTO 클래스마다 일일히 이름을 지정해 주어야 합니다. 게다가 하나라도 실수하게 된다면 디버깅 난이도가 올라갈 것은 너무나도 자명해 보입니다. 하드코딩이니 별로 좋아보이지도 않는군요.

조금 더 깔끔한 방법은 문제의 근원인 구현체를 변경하는 것입니다. TypeNameProviderPlugin이 패키지 이름과 Inner Class 외부의 이름까지 포함한 값을 반환하도록 하여, 식별자에 중복이 일어나지 않도록 하는 것이죠.

하지만... 한가지 문제가 있습니다.

@Component
@Order(SwaggerPluginSupport.SWAGGER_PLUGIN_ORDER)

SpringFox 개발진들이 어째서인지 @Order 어노테이션을 통해 본 구현체를 직접 바꿀 수 없도록 작성해 놓았기 때문입니다.

이 문제를 해결하기 위해, 본 인터페이스를 사용하는 TypeNameExtractor 클래스를 분석해 봐야겠군요.

@Autowired
  public TypeNameExtractor(
      TypeResolver typeResolver,
      @Qualifier("typeNameProviderPluginRegistry")
          PluginRegistry<TypeNameProviderPlugin, DocumentationType> typeNameProviders,
      EnumTypeDeterminer enumTypeDeterminer) {

    this.typeResolver = typeResolver;
    this.typeNameProviders = typeNameProviders;
    this.enumTypeDeterminer = enumTypeDeterminer;
  }
  
  private String modelName(
      ModelContext context,
      Map<String, String> knownNames) {
    if (!isMapType(asResolved(context.getType())) && knownNames.containsKey(context.getTypeId())) {
      return knownNames.get(context.getTypeId());
    }
    TypeNameProviderPlugin selected = typeNameProviders.getPluginOrDefaultFor(
        context.getDocumentationType(),
        new DefaultTypeNameProvider());
    String modelName = selected.nameFor(((ResolvedType) context.getType()).getErasedType());
    LOG.debug(
        "Generated unique model named: {}, with model id: {}",
        modelName,
        context.getTypeId());
    return modelName;
  }

TypeNameExtractor의 생성자 및 관련 코드입니다. 이를 통해 몇가지 정보를 알아낼 수 있겠군요.

  • typeNameProviders를 통해 Plugin들을 가져온다.
  • 플러그인이 없는 경우, type.getSimpleName()을 사용하는 문제의 기본 플러그인을 사용한다.

하지만 typeNameProviderPluginRegistry 빈은 이미 선언되어 있어 변경하는 것이 어려워 보입니다. (Springfox Swagger의 코드를 며칠 간 분석해 보았지만 아쉽게도 찾지 못했습니다, 혹시 찾으시면 댓글 남겨주세요.. ㅎ)

그렇다면 방법이 없는걸까요?

시도 3. TypeNameExtractor 상속하기 (성공)

앞서 언급한 것처럼, TypeNameExtractor가 명시된 Bean을 종속성으로 가지고 있기에 종속성을 변경할수는 없습니다. 하지만 그 종속성을 사용하는 Bean을 변경한다면, 주입되는 종속성을 변경할 수 있겠죠.

따라서, TypeNameExtractor의 constructor를 오버라이딩하여 typeNameProviderPluginRegistry이 아닌 커스텀 Plugin registry를 사용하도록 만들어 보겠습니다.

SwaggerCustomTypeResolveConfiguration.java:

@Configuration
public class SwaggerCustomTypeResolveConfiguration {
	@Bean
    public PluginRegistry<TypeNameProviderPlugin, DocumentationType> customTypeNameResolvers() {
        return PluginRegistry.of(new DefaultTypeNameProvider() { // DefaultTypeNameProvider를 상속하여, 불필요한 코드 작성을 줄임
            @Override
            public String nameFor(Class<?> type) {
                return type.getName();
            }
        });
    }

    @Bean
    @Primary // 기존 TypeNameExtractor 대신 해당 Bean을 우선적으로 사용하도록 설정!
    public TypeNameExtractor customTypeNameExtractor(
            TypeResolver resolver,
            @Qualifier("customTypeNameResolvers") PluginRegistry<TypeNameProviderPlugin, DocumentationType> customTypeNameResolvers,
            EnumTypeDeterminer determiner) {
        return new TypeNameExtractor(resolver, customTypeNameResolvers, determiner);
    }
}

이후 코드를 실행하게 되면, 아래와 같이 정상적으로 작동하는 것을 확인할 수 있습니다!

🤔 결론

오늘은 DTO를 Inner Class로 구성했을 때, Springfox Swagger에서 발생하는 문제를 해결해 보았습니다. 일반적으로 이런 라이브러리들은 네이밍 Strategy를 설정할 수 있도록 해주기 마련인데, 굳이 이런 복잡한 방법을 통해서만 해결할 수 있도록 해놓은 게 좀 이상하다는 생각이 들긴 하네요.

원래는 하루만에 작성해서 올리려 한 글인데, typeNameProviderPluginRegistry에 막혀서 이리저리 미루다 보니 1달이 지나고 나서야 완성했네요 😢 그래도 완성할 수 있어서 다행입니다.

다음 주(7월 23일) 부터는 미국 Silicon Valley에 방문하게 될 예정인지라, 이것과 관련한 글들을 시리즈로 올려보겠습니다! 많관부 👋

profile
DBA & Backend Engineer

1개의 댓글

comment-user-thumbnail
2023년 11월 6일

같은 고민을 했는데 많은 도움이 되는 글입니다. 감사합니다. 👍

답글 달기