본문 바로가기
Develop/Spring+JPA

[SpringDoc] 우리 enum이 달라졌어요

by 연로그 2024. 1. 28.
반응형

 

SpringFox에서 SpringDoc으로 마이그레이션 하던 와중, 몇 가지 이슈가 발생했다. (참고글: [SpringDoc] SpringFox -> SpringDoc 마이그레이션 일지) 그중에서도 enum과 관련된 이슈가 많았는데, 어떤 이슈가 발생했고 어떻게 해결했는지 그 방법에 대해 공유하고자 한다.

 

이모티콘 설명
- ❓ 이슈
- ✅ 선택한 방법
- ⬜️ 선택되지 않은 방법

 

 

 

 name()이 아닌 toString() 값이 노출되는 현상

SpringFox에서 SpringDoc으로 전환하자, enum 타입의 필드들이 이상한 형태로 조회되었다. 예를 들어, 아래와 같이 Color라는 enum이 있다고 가정해 보자.

@ToString
public enum Color {
    ORANGE("red", "yellow"),
    GREEN("yellow", "blue"),
    ;

    private final String mainColor;
    private final String subColor;

    Color(String mainColor, String subColor) {
        this.mainColor = mainColor;
        this.subColor = subColor;
    }
}

 

Swagger에서 위 enum을 필드로 가진 TestRequest를 조회해 보았다. 아래와 같이 모든 필드의 데이터가 조회된다.

 

하지만 내가 Swagger에 노출되기 원했던 값은 아래와 같이 name()을 통해 얻을 수 있는 "ORANGE", "GREEN"같은 값이었다.

 

원인은 toString()을 재정의했기 때문이다. 이를 해결하는 방법은 여러 가지가 있다.

 

 

⬜️ 방법 1 - toString() 재정의 제거 또는 name()으로 재정의

롬복의 @ToString 등을 통해서 toString() 메서드를 재정의한 내용을 제거한다. enum은 toString()를 재정의하지 않는다면 name()과 동일한 값을 반환한다. 또는 toString()을 name()의 형태로 재정의하도록 만든다.

@Override
public String toString() {
    return name();
}

 

하지만 이 방법을 선택하지는 않았다. 그 이유는 아래와 같다.

  1. 모든 enum을 탐색하여 toString()이 재정의 되어있는지 확인하고 제거해야 한다.
  2. 앞으로 enum을 생성할 경우, toString()의 재정의를 금지해야 한다.

 

1번의 경우에는 내가 기존 코드를 확인하고 변경하면 되니까 큰 문제가 되지는 않는다. 하지만 2번의 경우에는 몇 가지 걸리는 점이 있다. 첫째로, 모든 개발자가 'enum의 toString()을 재정의하지 않는다.'는 규칙을 기억해야 한다. 현 레포지토리를 여러 팀, 여러 개발자들이 사용하고 있어서 재정의를 하는지 안 하는지 매번 검토하기 어렵다. 또 문서화를 위한 코드 규칙이 생겨난다는 점에서 번거롭다고 느꼈다. 둘째로, 외부 라이브러리의 enum이 toString() 재정의가 되어있고, 해당 enum을 그대로 request/response로 사용하는 경우 문제가 될 수 있다. 외부 라이브러리에 존재하는 코드는 우리가 직접 제어할 수 없다. 외부 라이브러리의 enum을 우리가 관리할 수 있도록 별도의 클래스를 만들 수 있지만, 번거로운 일이다.

 

 

✅ 방법 2 - ModelConverter 구현체 생성

Swagger에서 모델링에 사용되는 ModelConverter라는 인터페이스가 존재한다. 이 인터페이스를 구현하여 enum인 경우에는 toString()이 아니라 name()을 이용하여 값을 생성하도록 만들었다. 이렇게 되면 모든 enum에 설정이 적용되므로, 모든 개발자들이  enum에서는 toString()을 재정의하지 않는다는 규칙을 몰라도 마음껏 편하게 코드를 작성할 수 있다.

 

참고로 아래 코드는 SpringDoc issue에 올라온 코드를 참고한 것이며, 다른 이슈를 야기할 수 있다. 어떤 이슈가 있고 어떻게 코드가 변화했는지는 이 글에서 이어서 작성하겠다.

// enum인 경우 toString()이 아닌 name()을 이용하도록 만드는 ModelConverter
// 아래 코드는 다른 이슈를 야기할 수 있습니다. 포스팅을 끝까지 읽고 최종 코드를 참고해주세요.
@Component
@RequiredArgsConstructor
public class EnumToNameConverter implements ModelConverter {

    private final ObjectMapperProvider springDocObjectMapper;
 
    public EnumToNameConverter(ObjectMapperProvider springDocObjectMapper) {
        this.springDocObjectMapper = springDocObjectMapper;
    }

    @Override
    public Schema<?> resolve(AnnotatedType type, ModelConverterContext context, Iterator<ModelConverter> chain) {
        ObjectMapper mapper = springDocObjectMapper.jsonMapper();
        JavaType javaType = mapper.constructType(type.getType());
        Schema<?> schema = chain.hasNext() ? chain.next().resolve(type, context, chain) : null;

        // enum 타입인 경우
        if (javaType != null && javaType.isEnumType()) {
            var enumClass = (Class<Enum>) javaType.getRawClass();
            var enumConstants = enumClass.getEnumConstants();
            var newSchema = schema != null ? (StringSchema) schema : new StringSchema();

            // enum item 에 name() 값 추가
            newSchema._enum(new ArrayList<>());
            for (var en : enumConstants) {
                String enumValue = en.name();
                newSchema.addEnumItem(enumValue);
            }

            return newSchema;
        }
        return schema;
    }
}

 

 

 

 @JsonFormat(shape = OBJECT)를 무시하는 현상

간혹 enum의 필드들을 모두 응답값으로 보내는 케이스도 존재한다. 예를 들어 Color.ORANGE를 응답하는 경우, ORANGE가 가진 속성인 "red", "yellow의 값도 모두 응답해야 한다고 가정해 본다면, 아래와 같이 @JsonFormat(shape = JsonFormat.Shape.OBJECT)와 같은 태그를 붙이면 간단히 해결된다.

@JsonFormat(shape = JsonFormat.Shape.OBJECT)
public enum Color {
    ORANGE("red", "yellow"),
    GREEN("yellow", "blue"),
    ;

    ...
}

 

하지만 Swagger에서는 이 설정을 무시하고 그냥 String 형태로 반환해 버린다.

 

일단 첫 번째 이슈 'name()이 아닌 toString() 값이 노출되는 현상'을 해결하기 위해 ModelConverter의 구현체를 적용했다면, 이부터 의심해야한다. enum인 경우는 모두 name()값을 이용하도록 구현하였으므로 name()값이 노출되는건 당연한 일이다. 하지만 ModelConverter를 제거해도 위 현상은 해결되지 않는다. 왜냐하면 애초에 SpringDoc에서 @JsonFormat을 지원하지 않기 때문이다. (참고: https://github.com/swagger-api/swagger-core/issues/3691)

 

 

✅ 방법 - ModelConverter 구현체 생성

첫번째 이슈 'name()이 아닌 toString() 값이 노출되는 현상'를 해결하기 위해 적용했던 ModelConverter의 구현체의 코드를 약간 수정했다. @JsonFormat(shape = JsonFormat.Shape.OBJECT)이 적용되어 있다면, Object 형태로 보이도록 개선하였다.

 

// 아래는 설명에 필요한 코드만 작성하였습니다. 전체 코드는 포스팅 최하단을 참고해주시길 바랍니다.
@Component
public class EnumToNameConverter implements ModelConverter {
 
    // ...
 
    @Override
    public Schema<?> resolve(AnnotatedType type, ModelConverterContext context, Iterator<ModelConverter> chain) {
        // ...
        
        // enum 타입인 경우
        if (javaType != null && javaType.isEnumType()) {
            var enumClass = (Class<Enum>) javaType.getRawClass();
 
            // @JsonFormat(shape = OBJECT) 로 설정한 경우
            var jsonFormat = clazz.getAnnotation(JsonFormat.class);
            if (jsonFormat != null && jsonFormat.shape() == JsonFormat.Shape.OBJECT) {
                // Schema 를 ObjectSchema 로 변경 (SpringDoc 에서는 지원하지 않아 직접 구현 - https://github.com/swagger-api/swagger-core/issues/3691)
                var newSchema = new ObjectSchema();
                copySchemaProperties(schema, newSchema);
 
                var enumConstant = enumClass.getEnumConstants()[0];
                var enumInfo = mapper.convertValue(enumConstant, new TypeReference<Map<String, Object>>() {});
                var enumProperties = toProperties(enumInfo);
                newSchema.properties(enumProperties);
 
                return newSchema;
            }
             
            // ...
            return newSchema;
        }
        return schema;
    }
 
    private void copySchemaProperties(Schema<?> schema, ObjectSchema newSchema) {
        if (schema == null) {
            return;
        }
 
        newSchema.description(schema.getDescription());
        newSchema.nullable(schema.getNullable());
        // 기타 정보들 schema에서 newSchema로 복제
    }
 
    private Map<String, Schema> toProperties(Map<String, Object> enumInfos) {
        var enumProperties = new HashMap<String, Schema>();
        for (var entry : enumInfos.entrySet()) {
            var key = entry.getKey();
            var value = new Schema().type(entry.getValue().getClass().getSimpleName().toLowerCase());
            enumProperties.put(key, value);
        }
        return enumProperties;
    }
}

 

 

 

 Error creating bean with name 'XXX' - bean 등록 실패 에러

우리 레포지토리는 멀티 모듈 형태를 갖고 있다. Swagger 설정(SpringDoc 도입)은 공통 모듈에 적용해두고 있으며, 위 문제들을 해결하기 위해 생성한 ModelConverter 구현체 역시 공통 모듈에 존재한다. 한데 갑자기 일부 모듈에서 bean 등록 에러가 발생했다.

 

원인을 파악하다 보니, 배치나 카프카 컨슘 등이 목적인 Swagger가 필요하지 않은 모듈에서 발생하는 것을 발견했다. 위 문제들을 해결할 때, ModelConverter 구현체를 만들어서 빈으로 등록했었다. 이때 ObjectMapperProvider를 주입받았는데, 이는 SpringDoc에서 사용하는 ObjectMapper를 동일하게 사용하기 위함이었다.

 

ObjectMapperProvider는 어디서 bean으로 등록되는 걸까? ObjectMapperProvider의 생성자를 호출하는 코드를 살펴보자. SpringDocConfiguration에서 bean으로 등록하는 부분이 있다. 

@Bean
@ConditionalOnMissingBean
@Lazy(false)
ObjectMapperProvider springDocObjectMapperProvider(SpringDocConfigProperties springDocConfigProperties) {
    return new ObjectMapperProvider(springDocConfigProperties);
}

 

그리고 SpringDocConfiguration은 springdoc.api-docs.enabled 설정이 없거나 true이고, 웹 애플리케이션일 때 적용된다.

@Lazy(false)
@Configuration(proxyBeanMethods = false)
@ConditionalOnProperty(name = SPRINGDOC_ENABLED, matchIfMissing = true) // SPRINGDOC_ENABLED = "springdoc.api-docs.enabled"
@ConditionalOnWebApplication
public class SpringDocConfiguration { ... }

 

위 조건을 충족시키지 않은 케이스에서 ObjectMapperProvider를 주입받으려고 하니 bean 생성에 실패했던 것이다.

 

 

✅  방법 - ObjectMapperProvider 주입 제거

코드 상에서는 ObjectMapper를 가져오기 위해 ObjectMapperProvider를 가져오고자 했다. 이를 ObjectMapper로 직접 주입받아오면 문제가 되지 않는다. 

// 아래는 설명에 필요한 코드만 작성하였습니다. 전체 코드는 포스팅 최하단을 참고해주시길 바랍니다.
@Component
@RequiredArgsConstructor
public class EnumToNameConverter implements ModelConverter {

    private final ObjectMapper objectMapper;
 
    public EnumToNameConverter(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }
    ...
}

 

 

 

💻 최종 코드

최종적으로 ModelConverter의 구현체는 아래와 같은 코드가 되었다.

@Component
@RequiredArgsConstructor
public class EnumToNameConverter implements ModelConverter {

    private final ObjectMapper mapper;

    @Override
    public Schema<?> resolve(AnnotatedType type, ModelConverterContext context, Iterator<ModelConverter> chain) {
        JavaType javaType = mapper.constructType(type.getType());
        Schema<?> schema = chain.hasNext() ? chain.next().resolve(type, context, chain) : null;

        // enum 타입인 경우
        if (javaType != null && javaType.isEnumType()) {
            var enumClass = (Class<Enum>) javaType.getRawClass();

            // @JsonFormat(shape = OBJECT) 로 설정한 경우
            var jsonFormat = clazz.getAnnotation(JsonFormat.class);
            if (jsonFormat != null && jsonFormat.shape() == JsonFormat.Shape.OBJECT) {
                // Schema 를 ObjectSchema 로 변경 (SpringDoc 에서는 지원하지 않아 직접 구현 - https://github.com/swagger-api/swagger-core/issues/3691)
                var newSchema = new ObjectSchema();
                copySchemaProperties(schema, newSchema);

                var enumConstant = enumClass.getEnumConstants()[0];
                var enumInfo = mapper.convertValue(enumConstant, new TypeReference<Map<String, Object>>() {});
                var enumProperties = toProperties(enumInfo);
                newSchema.properties(enumProperties);

                return newSchema;
            }

            var enumConstants = enumClass.getEnumConstants();
            var newSchema = schema != null ? (StringSchema) schema : new StringSchema();

            // enum item 에 name() 값 추가
            newSchema._enum(new ArrayList<>());
            for (var en : enumConstants) {
                String enumValue = en.name();
                newSchema.addEnumItem(enumValue);
            }

            return newSchema;
        }
        return schema;
    }

    private void copySchemaProperties(Schema<?> schema, ObjectSchema newSchema) {
        if (schema == null) {
            return;
        }

        newSchema.format(schema.getFormat());
        newSchema.description(schema.getDescription());
        newSchema.title(schema.getTitle());
        newSchema.deprecated(schema.getDeprecated());
        newSchema.name(schema.getName());
        newSchema.extensions(schema.getExtensions());
        newSchema.nullable(schema.getNullable());
        newSchema.readOnly(schema.getReadOnly());
        newSchema.writeOnly(schema.getWriteOnly());
        newSchema.discriminator(schema.getDiscriminator());
        newSchema.deprecated(schema.getDeprecated());
    }

    private Map<String, Schema> toProperties(Map<String, Object> enumInfos) {
        var enumProperties = new HashMap<String, Schema>();

        for (var entry : enumInfos.entrySet()) {
            var key = entry.getKey();
            var value = entry.getValue();
            var valueType = value.getClass().getSimpleName().toLowerCase();

            var schema = new Schema()
                .type(valueType)
                .example(value);

            enumProperties.put(key, schema);
        }

        return enumProperties;
    }
}

 

반응형