Gson은 어떻게 동작할까?

Arakene·2024년 12월 2일

iOS와 달리 Android는 내부 소스코드를 구경해볼 수 있다는 점이 참 좋은 것 같다. 물론 모든 코드를 볼 수 없기도하고 본다고 모두 완벽하게 이해하진 못하더라도 어떻게 동작하는지에 대해 이리저리 뜯어볼 수 있는게 재밌긴하다.

먼저 알아두면 좋을 것 같은 것들

TypeToken

직렬화, 역직렬화에 사용될 클래스 타입에 대한 제네릭 정보를 가지고 있다. 해당 클래스를 만들도록 강제함으로써 런타임에 타입 정보에 대해 알 수 있다.

List<String>에 대한 타입정보를 만들고 싶다면

val listType = object : TypeToken<List<String>>() {}

이렇게 생성할 수 있다.

다만 TypeToken은 제네릭 타입을 받는데 여기에 타입 변수를 집어넣는 것은 피해야한다고 한다. 런타임 시 type erasure(타입소거)에 의해서 런타임에 제네릭 타입에 대한 정보를 얻을 수 없어 Gson이 원하는 대로 동작하지 않고 컴파일 타임에서는 문제없어보이지만 런타임에는 ClassCastException을 발생시킬 수 있다.

만약 런타임에만 타입정보에 접근할 수 있다면 getParameterized(Type, Type...)을 통해 명시적으로 생성할 수 있다.

TypeAdapter

Gson의 유연함을 담당하는 코어 부분이다. 기본적으로 다양한 어뎁터를 가지고있지만 커스텀해서 사용할 수도 있다.
TypeToken을 통해 직접적인 파싱을 할 때 사용할 어뎁터를 가져오게 된다. TypeAdapter를 생성할 때 리플렉션을 사용하게 된다.
기본저으로 Gson은 class to json을 내장된 타입 어뎁터를 사용해서 변환한다. 반약 타입이 없다면

public class PointAdapter extends TypeAdapter<Point> {   public Point read(JsonReader reader) throws IOException {     if (reader. peek() == JsonToken. NULL) {       reader. nextNull();       return null;     }     String xy = reader. nextString();     String[] parts = xy. split(",");     int x = Integer. parseInt(parts[0]);     int y = Integer. parseInt(parts[1]);     return new Point(x, y);   }   public void write(JsonWriter writer, Point value) throws IOException {     if (value == null) {       writer. nullValue();       return;     }     String xy = value. getX() + "," + value. getY();     writer. value(xy);   } }

공식예제에 나와있듯 어뎁터를 커스텀해서 추가할 수도 있다.
Gson.class에서 생성자 부분을 가보면 다양한 타입어뎁터를 추가하고 있는것을 볼 수 있다.
너무 많아서 일부분만 가져오면

factories.add(TypeAdapters.JSON_ELEMENT_FACTORY);
factories.add(ObjectTypeAdapter.getFactory(objectToNumberStrategy));
factories.add(excluder);
factories.addAll(factoriesToBeAdded);
factories.add(TypeAdapters.STRING_FACTORY);

이와 같이 factories라는 리스트에 담고 있다. 해당 리스트는 나중에 다시 등장한다.

JsonWriter

json을 토큰형식으로 스트림에 기록한다. json의 구조는 객체, 배열, 기본 데이터 타입으로 구성된다 이런 구성타입을 나타내기 위한 게 토큰이다.

객체를 시작하는 "{"
키 "name"
키-값 구분자 ":"
값 "arakene"
객체끝 "{"
배열 시작 "\["
배열 끝 "\]"

등이 있다.
한 토큰씩 스트림에 기록함으로써 구조적 일관성과 메모리 효율성을 유지한다고 하는데 이부분은 아직 잘 이해가 가진 않는다.
각각의 JsonWriter는 하나의 JSON Stream에 데이터를 작성하게 된다. 한 번에 하나의 json 데이터 구조만 작성 가능하다는 제약이다. 만약 {"name": "Alice", "age": 30} {"name": "Bob", "age": 25} 같이 두 객체를 한번에 작성하려하면 오류가 발생한다.
간단한 예시로는

 val writer = JsonWriter(outputStream)

writer.setIndent("  ") // JSON 포맷팅: 들여쓰기 설정
writer.beginObject() // JSON 객체 시작: {

// 문자열 값 추가
writer.name("name").value("Alice")

// 숫자 값 추가
writer.name("age").value(30)

// 불리언 값 추가
writer.name("isStudent").value(false)

// 배열 추가
writer.name("subjects").beginArray() // 배열 시작: [
writer.value("Math")
writer.value("Science")
writer.value("History")
writer.endArray() // 배열 끝: ]

writer.endObject() // JSON 객체 끝: }
writer.close() // JsonWriter 닫기

이렇게 스트림에 json형식으로 만들게 된다.
만약 endObject() 시작과 끝의 형식이 없다면 IllegalStateException에러를 반환한다.

Write는 어떻게?

toJson(Object src, Type typeOfSrc, JsonWriter writer) 에서 진행하게 되는데 위에서 나온 TypeAdapter를 모아둔 리스트가 여기서 등장한다.
변환하려는 클래스의 TypeToken을 키로 어뎁터를 가져오게 된다. 이미 캐싱된 어뎁터가 있다면 가져오고 없다면 새로운 key -> TypeToken to value -> TypeAdapter 형식을 가진 맵을 생성한다. 이때 가지고있는 타입이 없다면 위 factories의 리스트를 돌며 새로 어뎁터를 생성한다.
타입에 맞는 어뎁터를 가져왔다면 writer를 통해서 객체를 encoded value를 스트림에 작성한다.
toJson의 내부코드에서 첫번째 라인인 TypeAdapter<Object> adapter = this.getAdapter(TypeToken.get(typeOfSrc)); 에서 어뎁터 초기화
adapter.write(writer, src); 이 부분을 통해서 json 형식으로 만들게 된다.

Read는 어떻게?

jsonWriter와 비슷한 구조로 돌아가게 된다.
1. 토큰으로 이루어진 스트림을 StringReader 객체로 변환
2. 전달받은 제네릭 타입 T에 대한 타입어뎁터 가져오기
3. 어뎁터로 reader 복원시켜 오브젝트로 전환 및 return

JsonReader는?

위에서 writer가 생성한 토큰 스트림을 읽어들여 오브젝트를 만들어주는 역할을 한다.
한 계층에 여러 토큰들이 있다면 dfs 알고리즘을 통해 순회하게 된다. 그리고 토큰 스트림을 처리할 때 재귀적으로 파싱하는 방법을 사용한다.
객체나 배열같은 경우 시작 토큰인 "{", "\["를 처리할 때 루프를 통해서 끝부분 토큰을 받을때까지 읽어들인다.
중첩된 객체나 배열을 처리할 때 재귀적으로 호출하며 중첩구조를 모두 파싱하고 올라오는 방식이다.
만약 설정되지않은 이름이 나온 경우 strict parser의 경우 exception과 함께 파싱에 실패하게 된다.
Lenient parser의 경우에는 skipValue()를 통해 중첩된 구조를 포함해 모두 건너뛰어야만 한다.
만약 읽은 값이 null이라면 우선 peek() 함수를 통해 먼저 null인지를 체크한 뒤 nextNull() 또는 skipValue()를 처리해주어야 한다.
마지막으로 writer와 마찬가지로 Single Json Stream을 사용하며 thread-safe하지 않다

Number handling

jsonReader는 Int값을 String으로, String값을 Int로 읽을 수 있다. 예를 들어 배열에 [1, "1"]가 있다면 둘 모두 nextInt, nextString 어느 방식으로 읽을 수 있다.
이런 동작을 허용하는 이유는 정밀도 손실을 방지하기 위해 큰 숫자는 문자열로 저장하고 읽는 것을 권장하기 때문이다.
특이 JavaScript는 double이 유일한 숫자형 데이터타입인데 매우 큰 숫자 (ex- 9007199254740993)는 정확하게 표한할 수 없다고 한다.

Non-Execute Prefix

웹 서버가 private한 정보를 json으로 넘겨주는 경우 CSRF 공격 대상이 될 수 있다. 따라서 )]}'\n" 이 prefix를 붙여 방지할 수 있다고 한다.

profile
안녕하세요 삽질하는걸 좋아하는 4년차 안드로이드 개발자입니다.

0개의 댓글