배경
Retrofit과 GsonConverter를 사용해서 json을 해당 kotlin data class로 변환하는 작업을 진행했습니다.
분명 data class에서는 불변객체로서 선언되어 있는 프로퍼티를 사용했지만 NullPointerException이 발생하는 것을 확인할 수 있었습니다.
그래서 이 원인을 파악해보고자 내부 구조를 확인해봤습니다.
코드 구성
코드는 간단하게 Gson을 사용해서 다음과 같이 구성했습니다.
data class TestData(
val a: Int,
val b: String,
val c: Float
)
class GsonTest {
@Test
fun test() {
val str = """{"a":1, "b": null}"""
val testData = Gson().fromJson(str, TestData::class.java)
println(testData.a)
println(testData.b)
}
}
원인 파악에 앞서 TestData를 디컴파일 했을 때 b가 어떻게 처리되는지 확인해봤습니다.
public final class TestData {
// ...
// access flags 0x12
private final Ljava/lang/String; b
@Lorg/jetbrains/annotations/NotNull;() // invisible
}
원인 분석
우선 Gson의 fromJson을 살펴보면 다음과 같습니다.
public <T> T fromJson(String json, Class<T> classOfT) throws JsonSyntaxException {
Object object = fromJson(json, (Type) classOfT);
return Primitives.wrap(classOfT).cast(object);
}
public <T> T fromJson(String json, Type typeOfT) throws JsonSyntaxException {
if (json == null) {
return null;
}
StringReader reader = new StringReader(json);
T target = (T) fromJson(reader, typeOfT);
return target;
}
public <T> T fromJson(Reader json, Type typeOfT) throws JsonIOException, JsonSyntaxException {
JsonReader jsonReader = newJsonReader(json);
T object = (T) fromJson(jsonReader, typeOfT);
assertFullConsumption(object, jsonReader);
return object;
}
public <T> T fromJson(JsonReader reader, Type typeOfT) throws JsonIOException, JsonSyntaxException {
boolean isEmpty = true;
boolean oldLenient = reader.isLenient();
reader.setLenient(true);
try {
reader.peek();
isEmpty = false;
TypeToken<T> typeToken = (TypeToken<T>) TypeToken.get(typeOfT);
TypeAdapter<T> typeAdapter = getAdapter(typeToken);
T object = typeAdapter.read(reader);
return object;
} catch {
// ...
}
}
디버깅을 통해 총 4번에 걸쳐 fromJson을 타고 내려오면 저 read() 메소드가 핵심이라는 걸 파악할 수 있습니다.
getAdapter를 통해 얻어지는 Adapter는 ReflectiveTypeAdapterFactory안에 존재하는 Adapter입니다.
// ReflectiveTypeAdapterFactory안에 있는 Adapter의 메소드
@Override public T read(JsonReader in) throws IOException {
if (in.peek() == JsonToken.NULL) {
in.nextNull();
return null;
}
// 1. instance를 생성하는 부분
T instance = constructor.construct();
// 2. 주어진 json을 읽어서 해당 위치에 값을 주입하는 부분
try {
in.beginObject();
while (in.hasNext()) {
String name = in.nextName();
BoundField field = boundFields.get(name);
if (field == null || !field.deserialized) {
in.skipValue();
} else {
field.read(in, instance);
}
}
} catch (IllegalStateException e) {
throw new JsonSyntaxException(e);
} catch (IllegalAccessException e) {
throw new AssertionError(e);
}
in.endObject();
return instance;
}
Adapter는 총 두 부분으로 나뉘게 됩니다.
1. Instance를 생성하는 부분
2. 해당 Instance가 가진 필드에 값을 주입하는 부분
우선 instance를 생성하는 부분부터 확인해보겠습니다.
1. constructor.construct()
constructor.construct()를 호출하는 부분에서 constructor는 ConstructorConstructor.java 클래스를 사용하고 있었습니다.
private <T> ObjectConstructor<T> newUnsafeAllocator(
final Type type, final Class<? super T> rawType) {
return new ObjectConstructor<T>() {
private final UnsafeAllocator unsafeAllocator = UnsafeAllocator.create();
@SuppressWarnings("unchecked")
@Override public T construct() {
try {
Object newInstance = unsafeAllocator.newInstance(rawType);
-> return (T) newInstance;
} catch (Exception e) {
throw new RuntimeException(("Unable to invoke no-args constructor for " + type + ". "
+ "Registering an InstanceCreator with Gson for this type may fix this problem."), e);
}
}
};
}
UnsafeAllocator의 newInstance()가 호출되며 더 깊이 내려가도 제가 원하는 결과를 찾진 못했지만
화살표 체크된 부분에서 newInstance: TestData(a=0, b=null)을 확인할 수 있었습니다.
2. 해당 Instance가 가진 필드에 값을 주입되는 코드
사실상 이 부분은 크게 확인할 필요가 없었습니다.
전체 디버그를 돌렸을 때 json에 선언 되어 있는 그대로 값이 제대로 들어왔기 때문입니다.
코드 분석 정리
여기까지 코드를 분석해봤을 때 나온 결론은 더 이상 깊게 들어가진 못했지만 입력된 rawType에 대한 객체가 생성되며
해당 프로퍼티들은 모두 자바 Primitive의 기본값을 제공받는 것을 알 수 있었습니다. (오라클 링크)
따라서 TestData에서 b는 String으로 선언되어 불변성을 보장하고자 했지만
런타임 시점에 내부 값으로 null이 들어오게 되어 막을 수 없었던 것입니다.
정리
이번 실험을 통해 Data Layer의 dto가 왜 nullable해야 안정적인지 다시 이해할 수 있었고 val 또는 nonnull을 통해 불변성을 보장하며 컴파일 시점에 이걸 막는다고 해도 Reflection을 통한 주입이 이뤄졌을 때 문제가 발생할 수 있다라는 위험성을 인지하게 되었습니다.
틀린 부분은 언제든지 댓글로 알려주시면 감사하겠습니다 :)
'Android' 카테고리의 다른 글
[Android] Compose PreviewParameter 어디에 쓰는거지? (0) | 2024.06.20 |
---|---|
2024 Android 앱 모듈화 가이드 (Modularization) (1) | 2024.04.25 |
[Android] Kakao Login 구현하기3 - MVVM+UiState (1) | 2023.09.30 |
[Android] Kakao Login 구현하기2 - 프로젝트 설정 (0) | 2023.09.30 |
[Android] Kakao Login 구현하기1 - 기본 설정 (1) | 2023.09.30 |
댓글