🤔 이 게시글은 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
라이브러리의 코드를 분석할 필요가 있습니다.
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들에 대해서 모두 같은 클래스로 식별을 하게 되는 것이죠.
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를 사용함으로서 발생하는 중복 문제를 해결할 수 있습니다.
앞서 언급한 어노테이션 방식을 쓴다면, 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의 코드를 며칠 간 분석해 보았지만 아쉽게도 찾지 못했습니다, 혹시 찾으시면 댓글 남겨주세요.. ㅎ)
그렇다면 방법이 없는걸까요?
앞서 언급한 것처럼, 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에 방문하게 될 예정인지라, 이것과 관련한 글들을 시리즈로 올려보겠습니다! 많관부 👋
같은 고민을 했는데 많은 도움이 되는 글입니다. 감사합니다. 👍