본문 바로가기
Android

[Android/Kotlin] Boolean type의 property, 함부로 네이밍하지 마라 (feat. DocumentSnapshot.toObject())

by 옹구스투스 2024. 9. 30.
반응형

피곤해서 그런지 암만 생각해도 좋은 글 제목이 생각나지 않아 부득이하게 어그로성 제목을 달아 죄송합니다. 허허

1.이슈

Firestore에 MyData란 Document가 있고, 좋아요를 눌렀는지에 대한 Boolean 값이 field로 있다고 할 때,

Android에서 이 document data를 toObject()로 우리가 원하는 DTO로 직렬화한다고 해보자.

//Firestore document
MyData
 - isLiked = true (boolean)

//Android-Kotlin DTO
data class MyData(
	val isLiked: Boolean = false,
}

myDataDocument.toObject<MyData>()

MyData에 isLiked는 true값이 정상적으로 들어가길 기대하겠지만

true값이 들어가지 않는다.

//Firestore document
MyData
 - liked = true (boolean)

//Android-Kotlin DTO
data class MyData(
	val liked: Boolean = false,
}

myDataDocument.toObject<MyData>()

isLiked가 아닌 liked로 바꾸면 정상적으로 true가 들어간다.

이유가 뭘까?

toObject()의 동작 방식을 알아보자.

2.뜯어보기

//firestore.Firestore.kt
/**
 * Returns the contents of the document converted to a POJO or null if the document doesn't exist.
 *
 * @param T The type of the object to create.
 * @return The contents of the document in an object of type T or null if the document doesn't
 * ```
 *     exist.
 * ```
 */
inline fun <reified T> DocumentSnapshot.toObject(): T? = toObject(T::class.java)

//firestore.DocumentSnapshot.java
/**
* Returns the contents of the document converted to a POJO or {@code null} if the document
* doesn't exist.
*
* @param valueType The Java class to create
* @return The contents of the document in an object of type T or {@code null} if the document
*     doesn't exist.
*/
@Nullable
public <T> T toObject(@NonNull Class<T> valueType) {
return toObject(valueType, ServerTimestampBehavior.DEFAULT);
}

java-Kotlin 모두 지원하니 역시 kotlin으로 래핑만하고 Java기반으로 동작한다.

주석을 보면 Data를 역직렬화할 타입 T는 POJO를 기대하고 있다.

 

좀 더 들어가보자.

//firestore.util.CustomClassMapper.java
private static <T> T deserializeToClass(Object o, Class<T> clazz, DeserializeContext context) {
  if (o == null) {
    return null;
  } else if (clazz.isPrimitive()
      || Number.class.isAssignableFrom(clazz)
      || Boolean.class.isAssignableFrom(clazz)
      || Character.class.isAssignableFrom(clazz)) {
    return deserializeToPrimitive(o, clazz, context);
  } else if (String.class.isAssignableFrom(clazz)) {
    return (T) convertString(o, context);
  } else if (Date.class.isAssignableFrom(clazz)) {
    return (T) convertDate(o, context);
  } else if (Timestamp.class.isAssignableFrom(clazz)) {
    return (T) convertTimestamp(o, context);
  } else if (Blob.class.isAssignableFrom(clazz)) {
    return (T) convertBlob(o, context);
  } else if (GeoPoint.class.isAssignableFrom(clazz)) {
    return (T) convertGeoPoint(o, context);
  } else if (DocumentReference.class.isAssignableFrom(clazz)) {
    return (T) convertDocumentReference(o, context);
  } else if (clazz.isArray()) {
    throw deserializeError(
        context.errorPath, "Converting to Arrays is not supported, please use Lists instead");
  } else if (clazz.getTypeParameters().length > 0) {
    throw deserializeError(
        context.errorPath, "Class " + clazz.getName() + " has generic type parameters");
  } else if (clazz.equals(Object.class)) {
    return (T) o;
  } else if (clazz.isEnum()) {
    return deserializeToEnum(o, clazz, context);
  } else {
    return convertBean(o, clazz, context);
  }
}

위 코드를 보면 T가 우리가 정의한 클래스인 경우 convertBean 함수를 타게 된다.

즉, toObject() 함수는 T에 POJO를 기대하고 Java Beans 규칙을 따른다는 것을 알 수 있다.

Java Beans Convention
클래스의 property 들은 get, set 또는 표준 명명법을 따르는 메소드를 사용해 접근 할 수 있어야 한다.
//firestore.util.CustomClassMapper.java

//1
private static <T> T convertBean(Object o, Class<T> clazz, DeserializeContext context) {
	//BeanMapper를 이용해 Bean과 data를 매핑
    BeanMapper<T> mapper = loadOrCreateBeanMapperForClass(clazz); 
    if (o instanceof Map) {
      return mapper.deserialize(expectMap(o, context), context);
    } else {
      throw deserializeError(
          context.errorPath,
          "Can't convert object of type " + o.getClass().getName() + " to type " + clazz.getName());
    }
  }

//2
      // Add any public getters to properties (including isXyz())
      for (Method method : clazz.getMethods()) {
        if (shouldIncludeGetter(method)) {
        // Bean의 getter method들을 돌면서 getter의 변수명을 체크
          String propertyName = propertyName(method);
          addProperty(propertyName);
          method.setAccessible(true);
          if (getters.containsKey(propertyName)) {
            throw new RuntimeException(
                "Found conflicting getters for name "
                    + method.getName()
                    + " on class "
                    + clazz.getName());
          }
          getters.put(propertyName, method);
          applyGetterAnnotations(method);
        }
      }

//3
    private static String propertyName(Method method) {
      String annotatedName = annotatedName(method);
      // @get:PropertyName() 같은 어노테이션을 사용했다면 그 이름을, 아니라면 getther에서 변수명 따오기
      return annotatedName != null ? annotatedName : serializedName(method.getName());
    }

//firestore.util.CustomClassMapper.java
    private static String serializedName(String methodName) {
      String[] prefixes = new String[] {"get", "set", "is"};
      String methodPrefix = null;
      for (String prefix : prefixes) {
        if (methodName.startsWith(prefix)) {
          methodPrefix = prefix;
        }
      }
      if (methodPrefix == null) {
        throw new IllegalArgumentException("Unknown Bean prefix for method: " + methodName);
      }
      //getter/setter에서 prefix인 get/set/is를 제외한 나머지를 변수명으로 채택
      String strippedName = methodName.substring(methodPrefix.length());

      // Make sure the first word or upper-case prefix is converted to lower-case
      char[] chars = strippedName.toCharArray();
      int pos = 0;
      while (pos < chars.length && Character.isUpperCase(chars[pos])) {
        chars[pos] = Character.toLowerCase(chars[pos]);
        pos++;
      }
      return new String(chars);
    }
  }

 

여기가 핵심 코드인데, 코드 블럭 내에 주석을 중심으로 보면 좋을 것 같다.

내용인 즉, property를 매핑할 때 getter/setter에서 프로퍼티명을 따오는데,

이때 로직은 get/set/is의 prefix을 substring하고 뒷부분을 프로퍼티명으로 채택하는 것이다.

 

그래서 뭐가 문제인 것인가??

 

3.관련 개념

Kotlin의 프로퍼티가 Java에서 동작할 때는 getter/setter가 필요하고, 개발자가 직접 만들지 않아도 된다.

그렇다는 것은 어떠한 규칙에 의해 getter/setter가 생성되는 것이므로 이 규칙을 알아야 할 필요가 있다.

//kotlin
var firstName: String

//java
private String firstName;

public String getFirstName() {
    return firstName;
}

public void setFirstName(String firstName) {
    this.firstName = firstName;
}

Calling Kotlin from Java 문서에 따르면 kotlin property는 java로 컴파일되면서

getter에는 get, setter에는 set(only var)이라는 prefix가 붙은 method가 생성된다.

//kotlin
val isLiked: Boolean

//java
private boolean isLiked;

public boolean isLiked(){
	return isLiked;
    
public boolean setLiked(boolean var1){ //매개변수 네이밍은 신경쓰지 맙시다!
	this.isLiked = var1
}

 

하지만 특수한 경우가 있는데,

Boolean 타입의 property면서 변수명에 is라는 prefix가 붙은 경우는

get/set이 붙지 않고 is/set이 붙는다.

4.결론

//Firestore document
MyData
 - isLiked = true (boolean)

//Android-Kotlin DTO
data class MyData(
	val isLiked: Boolean = false,
}

myDataDocument.toObject<MyData>()

val isLiked: Boolean
getter: isLiked()
setter: setLiked(boolean var1)

val liked: Boolean
getter: getLiked()
setter: setLiked(boolean var1)

다시 이 사례를 보면, 

isLiked가 toObject의 BeanMapper에서는 프로퍼티명이 liked로 인식되기 때문에 Data의 field명과 DTO의 property명이 일치하지 않아 true라는 데이터가 들어가지 않고 기본 값인 false가 찍히는 것이다.

5.해결

//Firestore document
MyData
 - isLiked = true (boolean)

//Android-Kotlin DTO
data class MyData(
	@get:PropertyName("isLiked") val isLiked: Boolean = false,
}

//Firestore document
MyData
 - liked = true (boolean)

//Android-Kotlin DTO
data class MyData(
	val liked: Boolean = false,
}

따라서 이렇게 두 가지 해결책이 있을 것 같은데, 프로젝트의 컨벤션이나 상황에 맞춰서 선택하여 사용하자.

Before

2022.08.31 - [Android] - [Android/Kotlin] Retrofit2은 어떻게 Interface의 구현 없이 사용할 수 있을까?

2021.12.18 - [언어/Kotlin&Java] - [Kotlin/Java] Kotlin/java의 sort 동작 방식 (2022.10.18 수정)

반응형

댓글