개요
클래스에서
mGbnCd
라는 필드를 사용하고 이에 대한 응답을 확인해보니 mgbnCd
로 나타나는 이상한 현상의 원인을 확인, 이해하고 해결 방안을 찾아본다.
현상
데이터 처리를 위한 클래스를 하나 생성하고, 다음과 같이 코드값에 대한 필드를 추가했다.
@Getter @Setter @ToString public class ExampleClass { private String gbnCd; private String mGbnCd; }
Lombok을 사용하여 Getter, Setter를 간편하게 생성했기 때문에 실제로 빌드된 클래스는 다음과 같은 접근자를 가지게 될 것이다.
public class ExampleClass { private String gbnCd; private String mGbnCd; public String getGbnCd() { return this.gbnCd; } public String getMGbnCd() { return this.gbnCd; } // Setters and ToString ... }
위 클래스가 API 응답에 그대로 실려나가야 하므로 예상되는 Json 응답의 형태는 다음과 같다.
{ "gbnCd": "example1", "mGbnCd": "example2" }
그러나, 실제로 전달받은 응답값은 다음과 같다.
{ "gbnCd": "example1", "mgbnCd": "example2" }
어디에서 왜 무슨일이 벌어지는 것인가?
Introspection
자바 클래스를 Json으로 전환하기 위한 라이브러리들이 자바 클래스의 필드 자체에는 접근할 수 없으므로 일반적으로 Getter를 통해서 필드 이름을 유추하는 수밖에 없다. 그리고 이렇게 이름을 유추하는 과정에서
getMGbnCd
접근자에 대한 필드 이름이 mgbnCd
로 유추된 것이다. 즉, 내가 본래 의도한 바와 다르게 필드 이름이 유추된 것이다. 그러면 접근자를 통해서 필드 이름을 유추하는 방법이 대체 어떻게 되어있을까?이는 자바 빈에 대한 스펙을 정의하는 문서 JavaBeans API specification에 명시되어있다.
8장의 내용을 살펴보면, introspection이라는 절차에 대해서 안내하고 있다.
At runtime and in the builder environment we need to be able to figure out which properties, events, and methods a Java Bean supports. We call this process introspection.
그리고 8.8장에서 대문자 규칙에 대해서 상세하게 정의하고 있다.
8.8 Capitalization of inferred names. When we use design patterns to infer a property or event name, we need to decide what rules to follow for capitalizing the inferred name. If we extract the name from the middle of a normal mixedCase style Java name then the name will, by default, begin with a capital letter. Java programmers are accustomed to having normal identifiers start with lower case letters. Vigorous reviewer input has convinced us that we should follow this same conventional rule for property and event names. Thus when we extract a property or event name from the middle of an existing Java name, we normally convert the first character to lower case. However to support the occasional use of all upper-case names, we check if the first two characters of the name are both upper case and if so leave it alone. So for example, -FooBah
becomesfooBah
-Z
becomesz
-URL
becomesURL
We provide a method Introspector.decapitalize which implements this conversion rule.
이러한 규칙에 따라서 자바 내부에서 Getter에 대한 필드 이름을 얻는 메서드가 생성되어있다.
if (name.startsWith(GET_PREFIX)) { // Simple getter pd = new PropertyDescriptor(this.beanClass, name.substring(3), method, null); } else if (resultType == boolean.class && name.startsWith(IS_PREFIX)) { // Boolean getter pd = new PropertyDescriptor(this.beanClass, name.substring(2), method, null); }
PropertyDescriptor(Class<?> bean, String base, Method read, Method write) throws IntrospectionException { if (bean == null) { throw new IntrospectionException("Target Bean class is null"); } setClass0(bean); setName(Introspector.decapitalize(base)); setReadMethod(read); setWriteMethod(write); this.baseName = base; }
이렇게 내부 스펙에 따라서
getMGbnCd
에 대한 필드 이름은 MGbnCd
로 유추되었다. 잠깐만? 아직도 Json의 필드 이름 mgbnCd
와 다르다?!Jackson
앞서 규칙대로라면
MGbnCd
라는 필드가 유추되어야 하는데, 그렇지 않은 이유는 아마도 직렬화 과정에서 사용되는 필드 유추 방식이 자바 빈 스펙과 다르기 때문일 것이다. 그래서 Jackson의 ObjectMapper에서 필드 이름을 유추하는 과정을 추적해보았다.@Override public BasicBeanDescription forSerialization(SerializationConfig config, JavaType type, MixInResolver r) { // minor optimization: for some JDK types do minimal introspection BasicBeanDescription desc = _findStdTypeDesc(config, type); if (desc == null) { // As per [databind#550], skip full introspection for some of standard // structured types as well desc = _findStdJdkCollectionDesc(config, type); if (desc == null) { desc = BasicBeanDescription.forSerialization(collectProperties(config, type, r, true)); } } return desc; } ... protected POJOPropertiesCollector collectProperties(MapperConfig<?> config, JavaType type, MixInResolver r, boolean forSerialization) { final AnnotatedClass classDef = _resolveAnnotatedClass(config, type, r); final AccessorNamingStrategy accNaming = type.isRecordType() ? config.getAccessorNaming().forRecord(config, classDef) : config.getAccessorNaming().forPOJO(config, classDef); return constructPropertyCollector(config, classDef, type, forSerialization, accNaming); }
ObjectMapper의
writeValue
메서드를 추적해보니 serializer의 설정 과정에서 위와 같이 자바 빈에 대한 정보를 처리하는 것을 파악할 수 있었다. 일반적인 POJO
에 대한 처리를 좀 더 따라가보자.protected DefaultAccessorNamingStrategy(MapperConfig<?> config, AnnotatedClass forClass, String mutatorPrefix, String getterPrefix, String isGetterPrefix, BaseNameValidator baseNameValidator) { _config = config; _forClass = forClass; _stdBeanNaming = config.isEnabled(MapperFeature.USE_STD_BEAN_NAMING); _isGettersNonBoolean = config.isEnabled(MapperFeature.ALLOW_IS_GETTERS_FOR_NON_BOOLEAN); _mutatorPrefix = mutatorPrefix; _getterPrefix = getterPrefix; _isGetterPrefix = isGetterPrefix; _baseNameValidator = baseNameValidator; } @Override public String findNameForIsGetter(AnnotatedMethod am, String name) { if (_isGetterPrefix != null) { if (_isGettersNonBoolean || _booleanType(am.getType())) { if (name.startsWith(_isGetterPrefix)) { return _stdBeanNaming ? stdManglePropertyName(name, _isGetterPrefix.length()) : legacyManglePropertyName(name, _isGetterPrefix.length()); } } } return null; }
/** * Feature that may be enabled to enforce strict compatibility with * Bean name introspection, instead of slightly different mechanism * Jackson defaults to. * Specific difference is that Jackson always lower cases leading upper-case * letters, so "getURL()" becomes "url" property; whereas standard Bean * naming <b>only</b> lower-cases the first letter if it is NOT followed by * another upper-case letter (so "getURL()" would result in "URL" property). *<p> * Feature is disabled by default for backwards compatibility purposes: earlier * Jackson versions used Jackson's own mechanism. * * @since 2.5 */ USE_STD_BEAN_NAMING(false),
POJO의 accessor, 즉 Getter와 같은 접근자에 대한 이름 처리 방식을 정의하고 있는데, 놀랍게도
USE_STD_BEAN_NAMING
방식에 대한 기본값이 false
였다. 해당 옵션의 설명에 잘 나와있듯이, Jackson 라이브러리의 기본적인 필드 이름에 대한 유추는 대문자로 시작되어도 소문자로 처리하는 것이었다.Specific difference is that Jackson always lower cases leading upper-case letters, so "getURL()" becomes "url" property; whereas standard Bean naming only lower-cases the first letter if it is NOT followed by another upper-case letter (so "getURL()" would result in "URL" property
이는 Jackson의 구버전에 대한 하위 호환성을 제공하기 위해서 자바 빈 스펙에 대한 처리 방식을 옵션으로 제공하기 위함이다. 그래서 자바 빈 스펙대로 처리하려면 다음과 같이 ObjectMapper에 설정을 추가해야 한다.
ObjectMapper mapper = JsonMapper.builder() .enable(MapperFeature.USE_STD_BEAN_NAMING) .build(); ExampleClass temp = new ExampleClass("example1", "example2"); log.info(mapper.writeValueAsString(temp)); // {"gbnCd":"example1","MGbnCd":"example2"}
그리고 위 옵션은 Jackson 라이브러리 버전 3부터는 기본값이 빈 스펙을 따르도록 변경될 예정이라고 한다.
Remove `MapperFeature. USE_STD_BEAN_NAMING`
자, 그래서 스펙대로 처리하면
MGbnCd
로 잘 나타나고, 그렇지 못했기 때문에 mgbnCd
로 처리됨을 알았다. 그럼에도 불구하고 하나의 의문이 더 남았다. 도대체 원래의 필드명인 mGbnCd
로 유추하려면 어떻게 해야할까?Lombok
자바 빈 스펙에 따르면 접근자
getMGbnCd
로부터 필드 이름 MGbnCd
이 유추됨을 알았다. 그렇다면 반대로 스펙에 따라 정상적인 필드 이름이 유추되도록 접근자 이름을 정의할 수도 있다. 본래 우리가 원하는 필드 이름 mGbnCd
가 나타나도록 하기 위해서 Getter의 이름은 getmGbnCd
가 되어야 한다!log.info(Introspector.decapitalize("GbnCd")); // gbnCd log.info(Introspector.decapitalize("MGbnCd")); // MGbnCD log.info(Introspector.decapitalize("mGbnCd")); // mGbnCd
그리고 실제로 IntelliJ, Eclipse 등의 Getter, Setter 자동 생성 기능을 사용해보면
mGbnCd
필드에 대한 접근자 이름이 다음과 같이 생성된다.public class ExampleClass { private String gbnCd; private String mGbnCd; public String getGbnCd() { return gbnCd; } public String getmGbnCd() { return mGbnCd; } }
Lombok에서 접근자 이름 생성 규칙이 무언가 다르게 동작하는 것을 알았다. 그러면 Lombok에서는 대체 접근자의 이름을 어떻게 생성하길래 그런걸까?
public enum CapitalizationStrategy { BASIC { @Override public String capitalize(String in) { if (in.length() == 0) return in; char first = in.charAt(0); if (!Character.isLowerCase(first)) return in; boolean useUpperCase = in.length() > 2 && (Character.isTitleCase(in.charAt(1)) || Character.isUpperCase(in.charAt(1))); return (useUpperCase ? Character.toUpperCase(first) : Character.toTitleCase(first)) + in.substring(1); } }, BEANSPEC { @Override public String capitalize(String in) { if (in.length() == 0) return in; char first = in.charAt(0); if (!Character.isLowerCase(first) || (in.length() > 1 && Character.isUpperCase(in.charAt(1)))) return in; boolean useUpperCase = in.length() > 2 && Character.isTitleCase(in.charAt(1)); return (useUpperCase ? Character.toUpperCase(first) : Character.toTitleCase(first)) + in.substring(1); } }, ; public static CapitalizationStrategy defaultValue() { return BASIC; } public abstract String capitalize(String in); }
CapitalizationStrategy 규칙에 따라서 두 가지 생성 방법이 존재한다. 각각의 생성 규칙을 적용해보면 다음과 같이 이름이 변경되는 것을 확인할 수 있다.
log.info(CapitalizationStrategy.BASIC.capitalize("mGbnCd")); // MGbnCd log.info(CapitalizationStrategy.BEANSPEC.capitalize("mGbnCd")); // mGbnCd
여기에 접근자에 따른 prefix를 붙이기 때문에 Lombok의 기본 규칙을 따르면
getMGbnCd
접근자가 생성되는 것이고, 자바 빈 스펙에 따르도록 하려면 CapitalizationStrategy.BEANSPEC
을 사용하도록 옵션을 제공해줘야 한다.public static String buildAccessorName(AST<?, ?, ?> ast, String prefix, String suffix) { CapitalizationStrategy capitalizationStrategy = ast.readConfigurationOr(ConfigurationKeys.ACCESSORS_JAVA_BEANS_SPEC_CAPITALIZATION, CapitalizationStrategy.defaultValue()); return buildAccessorName(prefix, suffix, capitalizationStrategy); }
public static final ConfigurationKey<CapitalizationStrategy> ACCESSORS_JAVA_BEANS_SPEC_CAPITALIZATION = new ConfigurationKey<CapitalizationStrategy>("lombok.accessors.capitalization", "Which capitalization strategy to use when converting field names to accessor names and vice versa (default: basic).") {};
위와 같이
lombok.accessors.capitalization
옵션을 통해서 자바 빈 스펙을 사용하도록 정의할 수가 있으므로 이 옵션에 대한 값을 beanspec
으로 제공하면 된다. 실제로 Lombok 홈페이지에서도 관련 내용을 확인할 수 있다.
결론
자바 빈 스펙을 따르지 않았던 Jackson과 Lombok 라이브러리에 의해서 원하지 않는 형태로 Json 필드 이름이 처리되는 불상사가 일어났다. 이를 해결하기 위한 두 가지 방법이 있다.
- 자바 빈 스펙을 따르도록 Jackson과 Lombok 라이브러리를 설정한다.
- 라이브 시스템에서 미칠 영향도를 완전히 파악할 수 없기 때문에 조심해야 한다.
- 필드 이름의 두 번째 글자가 대문자인 경우가 흔하지는 않을 것이므로 @JsonProperty 등의 어노테이션을 통해서 Jackson이 Serialize하는 필드 이름을 강제로 알맞은 것으로 바꿔준다.
- 매번 어노테이션을 이용하는 것은 귀찮을 수 있다.
역시 표준은 중요하다.
참고
- JDK : 21
- Lombok : 1.18.30
- Jackson : 2.15.4