spring-graphql에서 InvalidPropertyException 문제

환경

  • JDK 11
  • spring-boot 2.7.0
  • spring-graphql:1.0.0

문제 발생

자산 관리 솔루션에서 자산 관련 API를 제공해주는 서비스에서 아래와 같은 실물 자산의 목록을 가져오는 API를 호출하는 과정에서 다음과 같은 문제가 발생하였다.

@QueryMapping("tangibleAssetsGet")
fun findTangibleAssets(@Argument page: PageInput)
# mongodb-search.graphqls
scalar Long

input PageInput {
    pageNumber: Long!
    pageSize: Long!
    sort: [SortInput]! = []
    searches: [SearchInput]! = []
}

input SearchInput {
    by: String!
    type: PropertyType!
    operator: SearchOperatorType!
    value: [String!]!
}
org.springframework.beans.InvalidPropertyException: Invalid property 'searches[0].value[256]' of bean class [io.github.yearnlune.search.graphql.PageInput]: Invalid list index in property path 'value[256]'; nested exception is java.lang.IndexOutOfBoundsException: Index 256 out of bounds for length 256

위의 오류에서 확인할 수 있는 것은 IndexOutOfBoundsException로 할당된 컬렉션 크기가 256이지만, 이를 초과하여 문제가 발생하였다. 실제로, SearchInput.value 값들이 1,000여 개 정도로 확인되었다. 하지만 SearchInput.value의 최대 크기를 256으로 Validation을 설정한 적이 없어 어떠한 문제가 있는지 확인하기로 하였다.

문제 원인

원인의 발생은 spring-graphql에서 @Argument를 바인딩해주는 GraphQlArgumentBinder에서 시작되었다. 객체로 바인딩하기 위해 사용된 DataBinder에서 기본 컬렉션의 크기인 DEFAULT_AUTO_GROW_COLLECTION_LIMIT의 값 256을 그대로 사용하였기 때문에 생긴 문제였다.

// GraphQlArgumentBinder
public Object bind(
  DataFetchingEnvironment environment, @Nullable String argumentName, ResolvableType targetType
) throws BindException {

    Object rawValue = (argumentName != null ?
        environment.getArgument(argumentName) : environment.getArguments());

    if (rawValue == null) {
        return wrapAsOptionalIfNecessary(null, targetType);
    }

    Class<?> targetClass = targetType.resolve();
    Assert.notNull(targetClass, "Could not determine target type from " + targetType);

    DataBinder binder = new DataBinder(null, argumentName != null ? argumentName : "arguments");
    BindingResult bindingResult = binder.getBindingResult();
    Stack<String> segments = new Stack<>();

    try {
        // From Map
        if (rawValue instanceof Map) {
            Object target = createValue((Map<String, Object>) rawValue, targetClass, bindingResult, segments);
            return wrapAsOptionalIfNecessary(target, targetType);
        }
    }
    finally {
        checkBindingResult(bindingResult);
    }
  }
public class DataBinder implements PropertyEditorRegistry, TypeConverter {

    public static final int DEFAULT_AUTO_GROW_COLLECTION_LIMIT = 256;
}

문제 해결

spring-graphql : ~1.0.1

spring-graphql:1.0.1 버전부터 기본 컬렉션 크기를 1024로 적용하였으며, 해당 크기를 변경할 수 있게 변경되었다. 해당 버전이 적용된 spring-boot-starter-graphql:2.7.2 이후 버전을 적용하여 최대 크기를 변경 및 적용할 수 있다.

// ~1.0.1
public class GraphQlArgumentBinder {
  
    private static final int DEFAULT_AUTO_GROW_COLLECTION_LIMIT = 1024;
}
@Configuration
class GraphqlConfig {

    @Bean
    fun annotatedControllerConfigurer(): AnnotatedControllerConfigurer {
        return AnnotatedControllerConfigurer()
            .apply { setDataBinderInitializer { it.autoGrowCollectionLimit = 4096 } }
    }
}

spring-graphql : ^1.1.0

spring-graphql:1.1.0 버전부터 spring-context의 DataBinder를 사용하지 않고, BeanUtils를 활용하도록 변경되었다. 그리하여, 해당 버전이 적용된 spring-boot-starter-graphql:3.0.0부터는 위와 같은 문제가 발생하지 않는다. 하지만 Spring Boot 3.0.0 버전은 JDK 17부터 지원하기 때문에 잘 고려하여 적용해야 한다.

@Nullable
private Object bindMapToObjectViaConstructor(
  Map<String, Object> rawMap, Constructor<?> constructor, ResolvableType ownerType, 
  ArgumentsBindingResult bindingResult) {

    String[] paramNames = BeanUtils.getParameterNames(constructor);
    Class<?>[] paramTypes = constructor.getParameterTypes();
    Object[] constructorArguments = new Object[paramTypes.length];

    for (int i = 0; i < paramNames.length; i++) {
        String name = paramNames[i];

        ResolvableType targetType = ResolvableType.forType(
            ResolvableType.forConstructorParameter(constructor, i).getType(), ownerType);

        constructorArguments[i] = bindRawValue(
            name, rawMap.get(name), !rawMap.containsKey(name), targetType, paramTypes[i], bindingResult);
    }

    try {
        return BeanUtils.instantiateClass(constructor, constructorArguments);
    }
    catch (BeanInstantiationException ex) {
        // Ignore, if we had binding errors to begin with
        if (bindingResult.hasErrors()) {
            return null;
        }
        throw ex;
    }
}

Leave a comment