[Jackson] ObjectMapper 설정 변경하기

0_0_yoon·2023년 4월 1일
1
post-thumbnail

문제상황

응답 객체의 필드 이름을 isSelected(boolean 타입, private, Lombok @Getter 사용)로 지었는데 해당 객체를 사용하는 API 응답 값에서 isSelected 가 아닌 selected 로 출력되는 문제가 있었다.

원인

Jackson(POJOPropertiesCollector) 은 클래스 정보를 조사해서(클래스 파일의 멤버 필드, getter setter 메서드, 생성자 등) 프로퍼티 정보를 저장한다. 그리고 이를 기반으로 직렬화 및 역직렬화 한다. 멤버 필드와 getter 메서드는 접근제어자가 public 인 경우에만 프로터피 정보를 저장하도록 되어있다.(조금 더 자세한 내용은 해결 과정에서 후술) 그러므로 문제 상황에서 멤버 필드의 isSelected 는 프로퍼티로 저장되지 않고 getter 메서드인 isSelected 가 프로퍼티로 저장된다. 이때 getter 메서드의 경우 prefix 를 제거하고 저장하기 때문에 selected 로 출력된 것이다.

해결

해결법에 대한 아이디어는 공식 문서에서 얻을 수 있었다.

기본 ObjectMapper를 완전히 대체하려면 해당 유형의 @Bean을 정의하고 @Primary로 표시하거나 빌더 기반 접근 방식을 선호하는 경우 Jackson2ObjectMapperBuilder @Bean을 정의하십시오. 두 경우 모두 이렇게 하면 ObjectMapper의 모든 자동 구성이 비활성화됩니다.
링크

isSelected 를 출력하기 위해서 두 가지를 설정이 필요하다.
첫 번째 멤버 필드의 접근제어자가 private 인 경우에도 프로퍼티로 저장되도록 해야 할 것,
두 번째 isGetter 메서드의 경우 프로퍼티로 저장되지 않도록 해야 할 것

@Configuration
public class ObjectMapperConfig {
	
    @Bean
    @Primary
    public ObjectMapper customizeObjectMapper() {
        return new ObjectMapper().setVisibility(
                VisibilityChecker.Std
                        .defaultInstance()
                        
                        // public 만 허용하는 설정에서 private 도 허용하도록 설정
                        .withFieldVisibility(Visibility.ANY)
        ).setAccessorNaming(
                new DefaultAccessorNamingStrategy
                        .Provider()
                        
                        // isGetter 메서드는 조사하지 않도록 설정
                        // null 을 넣은 이유는 해결과정 2 번의 findNameForIsGetter 메서드 참고
                        .withIsGetterPrefix(null)
        );
    }

해결 과정

원인을 파악하기 위해 디버거로 Jackson 라이브러리의 직렬화 과정을 살펴봤고 코드의 흐름을 파악한 뒤 변경해야 할 설정 지점을 찾아냈다.

Jackson 라이브러리에서 프로퍼티 정보를 수집하는 역할은 POJOPropertiesCollector(com.fasterxml.jackson.databind.introspect)가 한다.(collectAll 메서드를 호출해서 수집한다)

  1. POJOPropertiesCollector 동작 과정을 살펴보면 필드, 메서드 순서로 프로퍼티 정보를 수집한다. 존재하는 필드, getter, setter 메서드의 이름들을 조사해서 일단 프로퍼티 후보군에(props) 저장한다.(이름이 중복인 경우를 제외하고 일단 다 저장한다, 2번 코드 POJOPropertiesCollector 의 _property 메서드 참고) 그 뒤에 후보 선별 과정을 거친다.
public class POJOPropertiesCollector {

	// 생략
    
	protected void collectAll() {
  	      LinkedHashMap<String, POJOPropertyBuilder> props = new LinkedHashMap<String, POJOPropertyBuilder>();
          
          // 후보 등록
      	  _addFields(props);
      	  _addMethods(props);
      	  
      	  // 생략
          
          // 선별 과정
          _removeUnwantedProperties(props);
          _removeUnwantedAccessor(props);
       
          // 생략
          
    }
    
   	// 생략 
   
    protected void _addMethods(Map<String, POJOPropertyBuilder> props) {
        for (AnnotatedMethod m : _classDef.memberMethods()) {
            int argCount = m.getParameterCount();
            if (argCount == 0) { 
                _addGetterMethod(props, m, _annotationIntrospector);
            } else if (argCount == 1) { 
                _addSetterMethod(props, m, _annotationIntrospector);
            } else if (argCount == 2) { 
                if (Boolean.TRUE.equals(_annotationIntrospector.hasAnySetter(m))) {
                    if (_anySetters == null) {
                        _anySetters = new LinkedList<AnnotatedMethod>();
                    }
                    _anySetters.add(m);
                }
            }
        }
    }
}
  1. 문제상황을 기준으로 _addGetterMethod 메서드를 살펴봤다.
    getter 메서드를 조사할 때 두 가지를 조사하는데 여기서 AccessorNamingStrategy 를 상속받은 DefaultAccessorNamingStrategy 가 사용된다.(해당하는 변수 이름은 _accessorNaming, 아래 코드 참고)
    첫 번째 DefaultAccessorNamingStrategy 의 findNameForRegularGetter 메서드를 호출해서 메서드 이름에 get 이 포함되어 있다면 메서드 이름에서 get 을 제외한 문자열을 반환한다.
    두 번째 같은 객체의 findNameForIsGetter 메서드를 호출해서 메서드 이름에 is 가 포함되어 있다면 메서드 이름에서 is 를 제외한 문자열을 반환한다.
    문제 상황의 경우 boolean 타입, Lombok 의 @Getter 를 사용했으므로 getter 메서드 이름은 isSelected 이다. 따라서 selected 문자열이 반환됨을 예상할 수 있다. 반환된 문자열은 implName 라는 지역 변수에 저장되고 POJOPropertiesCollector 의 _property 메서드를 호출해서 implName 값을 프로퍼티 후보 이름으로 저장한다.
public class POJOPropertiesCollector {

	// 생략
    
	protected void _addGetterMethod(Map<String, POJOPropertyBuilder> props,
            AnnotatedMethod m, AnnotationIntrospector ai) {
         
        // 생략
        
        if (!nameExplicit) {
            implName = ai.findImplicitPropertyName(m);
            if (implName == null) {
                implName = _accessorNaming.findNameForRegularGetter(m, m.getName());
            }
            if (implName == null) {
                implName = _accessorNaming.findNameForIsGetter(m, m.getName());
                if (implName == null) {
                    return;
                }
                visible = _visibilityChecker.isIsGetterVisible(m);
            } else {
                visible = _visibilityChecker.isGetterVisible(m);
            }
        } 
        
        // 생략
        
        _property(props, implName).addGetter(m, pn, nameExplicit, visible, ignore);
    }
    
    protected POJOPropertyBuilder _property(Map<String, POJOPropertyBuilder> props,
            String implName) {
        POJOPropertyBuilder prop = props.get(implName);
        if (prop == null) {
            prop = new POJOPropertyBuilder(_config, _annotationIntrospector, _forSerialization,
                    PropertyName.construct(implName));
            props.put(implName, prop);
        }
        return prop;
    }
}


public class DefaultAccessorNamingStrategy extends AccessorNamingStrategy {

	// 생략
    
    @Override
    public String findNameForRegularGetter(AnnotatedMethod am, String name) {
        if ((_getterPrefix != null) && name.startsWith(_getterPrefix)) {
            if ("getCallbacks".equals(name)) {
                if (_isCglibGetCallbacks(am)) {
                    return null;
                }
            } else if ("getMetaClass".equals(name)) {
                if (_isGroovyMetaClassGetter(am)) {
                    return null;
                }
            }
            return _stdBeanNaming
                    ? stdManglePropertyName(name, _getterPrefix.length())
                    : legacyManglePropertyName(name, _getterPrefix.length());
        }
        return null;
    }
    
    @Override
    public String findNameForIsGetter(AnnotatedMethod am, String name) {
        if (_isGetterPrefix != null) {
            final Class<?> rt = am.getRawType();
            if (rt == Boolean.class || rt == Boolean.TYPE) {
                if (name.startsWith(_isGetterPrefix)) {
                    return _stdBeanNaming
                            ? stdManglePropertyName(name, 2)
                            : legacyManglePropertyName(name, 2);
                }
            }
        }
        return null;
    }
}
  1. 2번에서 프로퍼티 후보 이름을 저장할 때 그 후보에 관한 여러 가지 정보들을 같이 저장한다.(실제로 후보 정보는 POJOPropertyBuilder 에 저장된다) 그중에 visible 이라는 변수를 눈여겨보자.(2 번의 _addGetterMethod 메소드 참고)
public class POJOPropertyBuilder extends BeanPropertyDefinition implements Comparable<POJOPropertyBuilder> {

	protected final PropertyName _name;
    
    // 사용되지 않는 후보 정보는 null 값이 들어간다.
    protected Linked<AnnotatedField> _fields;
    protected Linked<AnnotatedMethod> _getters;
    
    // 생략
    
	public void addField(AnnotatedField a, PropertyName name, boolean explName, boolean visible, boolean ignored) {
        _fields = new Linked<AnnotatedField>(a, _fields, name, explName, visible, ignored);
    }

    public void addGetter(AnnotatedMethod a, PropertyName name, boolean explName, boolean visible, boolean ignored) {
        _getters = new Linked<AnnotatedMethod>(a, _getters, name, explName, visible, ignored);
    }
    
    //생략
    
}
  • visible 의 경우 POJOPropertiesCollector 가 필드와 메서드를 조사할 때 값이 할당된다. VisibilityChecker(POJOPropertiesCollector 가 멤버 필드로 가지고 있다) 가 미리 저장돼있는 값을 기준(아래의 사진 참고)으로 프로터피 후보들을 판별한 뒤 결과를 visible 지역변수에 저장한다.(2번 코드의 _addGetterMethod 메소드 참고) 아래의 두 번째 사진을 보면 4개의 프로퍼티 후보가 저장돼있는데 이 중에 isSelectd 이름의 후보는 visible 값이 false 임을 확인할 수 있다.(멤버 필드인 isSelected 의 접근 제어자가 private 이고 설정된 값은 public 만 허용하고 있으므로 false 다)

  1. 후보 등록을 마치고 POJOPropertiesCollector 의 _removeUnwantedProperties 메서드를 호출한다.(1 번 코드의 collectAll() 참고) 후보들이 가지고 있는 visible 값들을 확인해서 true 값이 없을 때 후보군에서 제거한다.
protected void _removeUnwantedProperties(Map<String, POJOPropertyBuilder> props) {
        Iterator<POJOPropertyBuilder> it = props.values().iterator();
        while (it.hasNext()) {
            POJOPropertyBuilder prop = it.next();

            if (!prop.anyVisible()) {
                it.remove();
                continue;
            }
            
            // 생략
         }
}
  1. 위와 같은 과정으로 내가 의도했던 isSelected 는 POJOPropertiesCollector 에 의해 삭제되고(_removeUnwantedProperties 메서드로 삭제된다) selected 가 응답 값으로 나오게 됐다.
profile
꾸준하게 쌓아가자

1개의 댓글

comment-user-thumbnail
2023년 7월 27일

👍

답글 달기