Spring, Jackson 그리고 Kotlin의 ByteCode

조갱·2023년 10월 29일
1

이슈 해결

목록 보기
12/14

kotlin과 byteCode

kotlin은 JAVA와 99%이상 호환된다.
위 사진을 통해 알 수 있듯, *.kt 파일은 kotlin compiler 를 통해 JAVA Byte Code로 변환되고, 이후에 자바와 동일하게 *.class > *.jar 로 변환되어 실행되기 때문이다.

변환 방법은 이곳에서 확인할 수 있다.

Getter, Setter

Java에서는 일반적으로 클래스 내의 필드의 접근제한자를 private로 하고, public 접근제한자로 Getter, Setter를 만드는 것이 일반적이다.

하지만 Kotlin의 Data Class는 자바 바이트코드 변환 과정에서 자동으로 Getter, Setter를 만들어주며, 로직에서도 인스턴스의 필드를 직접적으로 접근하는 것이 일반적이다.

아마 Java를 오래 개발한 사람에게는 꽤나 충격이겠지만, Getter, Setter를 사용하는 이유에 대해 생각해보면 충분히 이해할만 하다.

우선, Getter, Setter를 사용하는 가장 큰 이유는 외부에서 데이터를 함부로 접근하지 못하게 하여 데이터의 무결성을 유지하는 것이다. 즉, 수정을 막는 것인데, kotlin에서는 기본적으로 읽기 전용 필드인 val 키워드를 제공하며, 권장한다.
(일반적으로, 변수는 한번 정의되고 나면 변경될 일이 거의 없기 때문에.)
그리고 데이터 정합성 체크를 위해 Getter, Setter에 로직을 넣을 때도 있는데, Kotlin도 별도로 Getter, Setter를 설정하여 로직을 넣을 수도 있다.

따라서 읽기 전용인 val 키워드로 선언된 필드는 data class를 자바 바이트코드로 변환 시에 Getter만 생성이 되며,
읽기/쓰기인 var 키워드로 선언된 필드는 자바 바이트코드로 변환 시에 Getter, Setter가 모두 생성된다.

부가적인 설명이 길었지만, 오늘 포스팅에서 하고싶은 말은 kotlin은 Java ByteCode로 변환 시에 자동으로 Getter/Setter가 생성된다는 것이다.

Jackson의 동작 과정

Jackson 라이브러리에 브레이크 포인트를 걸면 class가 어떻게 json으로 변환되는지 알 수 있다. (아래 예제는 2.13.1 버전이다.)

Jackson의 동작 과정 - 전처리

POJOPropertiesCollector.java

protected void collectAll(){
	LinkedHashMap<String, POJOPropertyBuilder> props = new LinkedHashMap<String, POJOPropertyBuilder>();

	_addFields(props);
    _addMethods(props);
    if (!_classDef.isNonStaticInnerClass()) {
    	_addCreators(props);
    }

    _removeUnwantedProperties(props);
    _removeUnwantedAccessor(props);

    _renameProperties(props);

    _addInjectables(props);

    for (POJOPropertyBuilder property : props.values()) {
    	property.mergeAnnotations(_forSerialization);
    }

	for (POJOPropertyBuilder property : props.values()) {
    	property.trimByVisibility();
    }

    PropertyNamingStrategy naming = _findNamingStrategy();
    if (naming != null) {
        _renameUsing(props, naming);
    }

    if (_config.isEnabled(MapperFeature.USE_WRAPPER_NAME_AS_PROPERTY_NAME)) {
        _renameWithWrappers(props);
    }

    _sortProperties(props);
    _properties = props;
    _collected = true;
}

이곳에서는 간단하게 변환에 필요한 필드, 메소드들을 전처리로 수집하여 POJOPropertyBuilder 객체로 보관한다. @JsonProperty 등 프로퍼티명의 변경이 필요한 경우도 여기서 전처리된다.

이후에, 전처리된 POJOPropertyBuilder 객체를 json 으로 변환한다.

특히, 필드와 메소드가 수집되는 _addFields(props), _addMethods(props) 메소드를 조금 더 확인해보자.

_addFields(...)

protected void _addFields(Map<String, POJOPropertyBuilder> props) {
	final AnnotationIntrospector ai = _annotationIntrospector;
          
    final boolean pruneFinalFields = !_forSerialization && !_config.isEnabled(MapperFeature.ALLOW_FINAL_FIELDS_AS_MUTATORS);
    final boolean transientAsIgnoral = _config.isEnabled(MapperFeature.PROPAGATE_TRANSIENT_MARKER);

    for (AnnotatedField f : _classDef.fields()) {
        ... // 필터 및 json 변환 대상으로 등록 로직
    }
}

여기서 알 수 있는 사실은, _addFields(...)메소드는 class에 정의된 field들을 몽땅 가져와서 특정 필터 조건을 걸어서 json 변환 대상으로 등록한다는 것이다.

_addMethods(...)

protected void _addMethods(Map<String, POJOPropertyBuilder> props) {
    for (AnnotatedMethod m : _classDef.memberMethods()) {
        int argCount = m.getParameterCount();
        if (argCount == 0) { // getters (including 'any getter')
            _addGetterMethod(props, m, _annotationIntrospector);
        } else if (argCount == 1) { // setters
            _addSetterMethod(props, m, _annotationIntrospector);
        } else if (argCount == 2) { // any getter?
            if (Boolean.TRUE.equals(_annotationIntrospector.hasAnySetter(m))) {
                if (_anySetters == null) {
                    _anySetters = new LinkedList<AnnotatedMethod>();
                }
                _anySetters.add(m);
            }
        }
    }
}

여기서는 클래스에 정의된 모든 메소드를 가져와서,
파라미터 개수에 따라 GetterMethod와 SetterMethod로 나눈다.
참고로, Setter의 경우 설정할 value를 받기 때문에 인자가 최소 1개 이상 생긴다.
이제 여기에 있는 _addGetterMethod(...)_addSetterMethod(...)를 살펴보자.

_addGetterMethod(...)

protected void _addGetterMethod(Map<String, POJOPropertyBuilder> props, AnnotatedMethod m, AnnotationIntrospector ai) {
    ... //로직

    PropertyName pn = ai.findNameForSerialization(m);
    boolean nameExplicit = (pn != null);

    if (!nameExplicit) { // no explicit name; must consider implicit
        implName = ai.findImplicitPropertyName(m);
        if (implName == null) {
            implName = _accessorNaming.findNameForRegularGetter(m, m.getName());
        }
        if (implName == null) { // if not, must skip
            implName = _accessorNaming.findNameForIsGetter(m, m.getName());
            if (implName == null) {
                return; // RegularGetter, IsGetter를 못찾으면 대상으로 등록 안함
            }
            ... // 로직
        } else {
            ... // 로직
        }
    } else { 
        ... // 로직
    }
        
    implName = _checkRenameByField(implName);
    boolean ignore = ai.hasIgnoreMarker(m);
    _property(props, implName).addGetter(m, pn, nameExplicit, visible, ignore);
}

여기서 확인할 수 있는 부분은
_accessorNaming.findNameForRegularGetter(...)
_accessorNaming.findNameForIsGetter(...)
의 값이 없으면 GetterMethod 대상으로 등록하지 않는 것이다. (중간 주석 참조)

findNameForRegularGetter(...)
findNameForIsGetter(...)
의 구현체는 DefaultAccessorNamingStrategy.java 에서 구현하고 있다.
이 둘을 또 타고 들어가서 살펴보자.

findNameForRegularGetter(...)

_getterPrefix : "get"

@Override
public String findNameForRegularGetter(AnnotatedMethod am, String name) {
    if ((_getterPrefix != null) && name.startsWith(_getterPrefix)) {
    	... // 로직
    }
}

findNameForIsGetter(...)

_isGetterPrefix: "is"

@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;
}

여기까지 확인과정이 정말 오래걸렸지만, 간단하게 얘기하면 이렇다.

클래스에 존재하는 모든 필드와 메소드명을 가져와서
필드는 json 변환 대상으로 등록하고,
메소드는 is, get으로 시작하는 애들을 json 변환 대상으로 등록한다.

궁금한 점

kotlin에서 필드와 get{필드}명 (1)

앞서 말했듯, kotlin에서 데이터 클래스의 필드는 자동으로 getter가 생성된다.
그러면, userId 라는 필드와 getUserId() 이라는 메소드를 둘 다 만들면 어떻게 될까?
kotlin 코드 자체로는 문제가 없으며, IDE에서 에러도 발생하지 않는다.

결과를 먼저 말하면, 컴파일이 안된다.

Platform declaration clash: The following declarations have the same JVM signature (getUserName()Ljava/lang/String;):
    fun `<get-userName>`(): String defined in velog.JacksonTest
    fun getUserName(): String defined in velog.JacksonTest

원본 코틀린 코드
IDE 상에서 에러가 발생하지 않는다.

data class JacksonTest(
    val userId: String,
    val userName: String,
) {
    fun getUserId(): String {
        return userId;
    }
}

변환된 자바 바이트 코드

... // 중략
public final class JacksonTest {
   @NotNull
   private final String userId;
   @NotNull
   private final String userName;

   @NotNull
   public final String getUserId() {
      return this.userId;
   }

   @NotNull
   public final String getUserId() {
      return this.userId;
   }
   
   ... // 중략
}

컴파일 과정에서 변환된 자바 바이트 코드에서 메소드명이 중복되는 것을 알 수 있다.

kotlin에서 필드와 get{필드}명 (2)

그러면, getUserId 에 파라미터를 넣음으로써, 자바 바이트코드로 변환될 때 오버로딩을 적용한다면 어떻게 될까?

원본 코틀린 코드

data class JacksonTest(
    val userId: String,
    val userName: String,
) {
    fun getUserId(additionalMessage: String): String {
        return userId + additionalMessage;
    }
}

fun main() {
    val jacksonTestModel = JacksonTest(
        userId = "gildong1234",
        userName = "홍길동",
    )
    val result = mapper.writeValueAsString(jacksonTestModel)
    println(result)
}

변환된 자바 바이트 코드

... // 중략
public final class JacksonTest {
   @NotNull
   private final String userId;
   @NotNull
   private final String userName;

   @NotNull
   public final String getUserId(@NotNull String additionalMessage) {
      Intrinsics.checkNotNullParameter(additionalMessage, "additionalMessage");
      return this.userId + additionalMessage;
   }

   @NotNull
   public final String getUserId() {
      return this.userId
   }
   ... // 중략
}

컴파일 오류는 나지 않지만, 결과를 확인해보자.

{"userId":"gildong1234","userName":"홍길동"}

위에 jackson 구조에서 확인했듯, 파라미터 개수에 따라 getter, setter를 구분하게 되는데
이 경우는 파라미터가 1개 생기면서 setter로 작용했기 때문에 json 변환 대상에서 제외됐다.

JsonProperty

@JsonProperty어노테이션을 사용하여 json으로 변환될 프로퍼티명을 바꿀 수 있다.

원본 코틀린 코드

data class JacksonTest(
    @JsonProperty(value= "id")
    val userId: String,
    val userName: String,
) {
    fun getId(): String {
        return userId
    }
}

이렇게 필드명과 get{필드} 명이 겹치지 않지만, json 변환 시에 property가 겹치면 어떻게 될까?

Exception in thread "main" com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Conflicting getter definitions for property "id": velog.JacksonTest#getId() vs velog.JacksonTest#getUserId()
	at com.fasterxml.jackson.databind.exc.InvalidDefinitionException.from(InvalidDefinitionException.java:77)
	at com.fasterxml.jackson.databind.SerializerProvider.reportBadDefinition(SerializerProvider.java:1300)
	at com.fasterxml.jackson.databind.SerializerProvider._createAndCacheUntypedSerializer(SerializerProvider.java:1447)
	at com.fasterxml.jackson.databind.SerializerProvider.findValueSerializer(SerializerProvider.java:544)
	at com.fasterxml.jackson.databind.SerializerProvider.findTypedValueSerializer(SerializerProvider.java:822)
	at com.fasterxml.jackson.databind.ser.DefaultSerializerProvider.serializeValue(DefaultSerializerProvider.java:308)
	at com.fasterxml.jackson.databind.ObjectMapper._writeValueAndClose(ObjectMapper.java:4568)
	at com.fasterxml.jackson.databind.ObjectMapper.writeValueAsString(ObjectMapper.java:3821)
	at velog.JacksonTestKt.main(JacksonTest.kt:32)
	at velog.JacksonTestKt.main(JacksonTest.kt)

컴파일은 되지만 런타임 시 에러가 발생한다.

발생했던 이슈

최근에 코드 리팩토링 후, 문의가 들어왔다.
state 는 잘못 수정된게 맞고,
firstName은 코드 수정 내역을 보면,

get메소드가 응답값 json으로 변환되는줄 모르고, 사용되는곳이 없어서 삭제한 것이 문제였다.
(필드만 json으로 변환되는줄 알았다.)

이 이슈를 확인하고, 머리속으로는 'getXXX메소드가 jackson에서 json으로 변환되는데, 내가 삭제해서 그랬구나'는 알았지만, 이 기회에 jackson이 어떤 원리로 json으로 변환하는지 더 깊게 살펴봤다.

profile
A fast learner.

0개의 댓글