안드로이드 Gson 사용법: 자바 객체와 JSON 변환 정리

1/16/2016 ·impact

안드로이드 Gson 사용법: 자바 객체와 JSON 변환 정리

자바 객체를 JSON으로 내보내고 서버에서 받은 JSON 문자열을 다시 객체로 되돌리는 일은, 안드로이드 앱에 API를 붙이는 순간부터 계속 반복됩니다. Gson은 이 변환을 toJson()fromJson() 두 메서드로 처리하는 자바 라이브러리입니다. 소스 코드를 가지고 있지 않은 클래스나 수정할 수 없는 기존 객체까지 변환 대상으로 삼을 수 있고, 자바 제네릭도 폭넓게 지원합니다.

Gson이 해결하는 문제와 설계 목표

자바 객체를 JSON으로 바꿔 주는 오픈소스는 여럿 있습니다. 다만 대부분은 변환할 클래스에 자바 애노테이션을 붙여야 동작합니다. 외부 라이브러리에서 넘어온 타입처럼 소스 코드에 손을 댈 수 없는 클래스라면 이 방식은 쓸 수 없습니다. 자바 제네릭을 온전히 지원하지 않는 구현도 많습니다. Gson은 이 두 가지를 설계 목표로 잡았습니다.

README의 Goals 절이 꼽는 목표는 다섯 가지입니다.

  • 자바 객체와 JSON을 오가는 간단한 toJson(), fromJson() 메서드
  • 이미 존재하고 수정할 수 없는 객체의 변환 허용
  • 자바 제네릭에 대한 폭넓은 지원
  • 객체에 대한 커스텀 표현
  • 깊은 상속 계층과 제네릭을 많이 쓰는 복잡한 객체까지 지원

애노테이션을 요구하지 않는 이유는 리플렉션에 있습니다. Gson 클래스 Javadoc은 "모르는 클래스에 대해서는 리플렉션으로 클래스의 필드를 살펴 그것으로 JSON 구조를 만든다"고 적었고, 유저 가이드의 Finer Points with Objects 절은 "private 필드를 쓰는 것이 전혀 문제없고 오히려 권장된다", "필드를 포함시키려고 애노테이션을 붙일 필요가 없다"고 못 박습니다. 필드 이름이 그대로 JSON 키가 되니, 애노테이션은 이름이 어긋나거나 특정 필드를 빼야 할 때만 꺼내 쓰는 도구입니다. 서드파티 모델 클래스를 감싸는 별도 DTO도 필요 없습니다.

프로젝트에 추가하고 첫 변환 해보기

배포는 Maven Central을 통해 이뤄집니다. 원문 작성 시점에 안내된 버전은 2.5였고, 지금 README의 의존성 예시는 2.14.0을 가리킵니다. GitHub 릴리스 목록에서도 2.14.0이 최신이고 2026년 4월에 올라왔습니다.

dependencies {
 implementation 'com.google.code.gson:gson:2.14.0'
}

메이븐 프로젝트라면 pom.xml에 같은 좌표를 넣습니다. groupId와 artifactId는 버전과 무관하게 고정이라 숫자만 바꾸면 됩니다.

<dependency>
 <groupId>com.google.code.gson</groupId>
 <artifactId>gson</artifactId>
 <version>2.14.0</version>
</dependency>

버전을 올리기 전에는 README의 Requirements 절을 봐야 합니다. 자바 최소 버전은 2.12.0 이상이 8, 2.9.0부터 2.11.0까지가 7, 2.8.9 이하가 6입니다. 안드로이드 최소 API 레벨은 2.10.1 이하가 19, 2.11.0 이상이 21입니다. 같은 목록에 "2.15.0 이상은 API 레벨 24"라는 줄이 하나 더 있는데, 2.15.0은 아직 릴리스되지 않았습니다. minSdk가 21 이상 24 미만인 앱은 2.15.0이 나와도 그대로는 올릴 수 없다는 뜻입니다.

변환 대상 클래스는 평범한 자바 객체면 충분합니다. 상속해야 할 부모 클래스도, 구현해야 할 인터페이스도 없습니다.

public class User {
 private String name;
 private int age;
 private List<String> tags;

 User() {} // 무인자 생성자
 // 나머지 생성자와 getter는 생략
}
Gson gson = new Gson();

User user = new User("kim", 30, Arrays.asList("android", "java"));
String json = gson.toJson(user);
// {"name":"kim","age":30,"tags":["android","java"]}

User parsed = gson.fromJson(json, User.class);

JSON에 키가 없을 때 필드에 무엇이 남는가

역직렬화할 때 JSON에 없는 키에 해당하는 필드에는 아무 값도 대입되지 않습니다. 그래서 그 자리에 무엇이 남는지는 Gson이 객체를 어떤 방법으로 만들었느냐에 달려 있습니다.

무인자 생성자가 있으면 Gson은 그 생성자를 호출합니다. 필드 초기화식이나 생성자 본문에서 넣은 값이 그대로 살아남습니다. private int age = -1;이라고 써 두면 키가 없을 때 age-1입니다. 무인자 생성자가 없으면 이야기가 달라집니다. 트러블슈팅 가이드의 Default field values not present after deserialization 절은 "Gson이 클래스의 생성자를 호출할 수 없어 JDK Unsafe나 비슷한 수단으로 넘어가고, 생성자도 초기화 코드도 실행하지 않은 채 인스턴스를 만든다"고 설명합니다. 가이드가 요구하는 조건은 두 가지입니다. 클래스가 static일 것(최상위 클래스면 자동으로 만족), 그리고 무인자 생성자를 가질 것. 개발 중에 이 경로를 아예 막아 두고 싶으면 GsonBuilder.disableJdkUnsafe()를 켭니다.

키가 없는 경우와 {"age": null}처럼 명시적으로 null이 온 경우는 동작이 다릅니다. 앞의 경우는 필드를 건드리지 않고, 뒤의 경우는 참조 타입 필드를 null로 덮어씁니다. 기본형 필드에는 명시적 null이 와도 대입하지 않고 넘어갑니다. 레코드 컴포넌트는 다르게 처리돼, 기본형 컴포넌트에 null이 오면 JsonParseException이 납니다.

결국 키 없음과 명시적 null을 구분하려면 초기값이 null이 아니어야 합니다. 무인자 생성자에서 Integer age = SENTINEL; 같은 초기값을 잡아 두는 방법이 하나, 키의 존재 자체가 의미를 갖는 API라면 JsonObject로 먼저 파싱해 has()로 확인하는 방법이 다른 하나입니다.

제네릭 타입과 컬렉션은 TypeToken으로

List<User>처럼 제네릭이 붙은 타입은 List.class만 넘겨서는 원하는 결과를 얻지 못합니다. 자바의 타입 소거 때문에 런타임에 원소 타입 정보가 남지 않고, Gson은 원소를 맵 형태로 채워 넣습니다. 이후 원소를 User로 캐스팅하는 지점에서 ClassCastException이 납니다. 트러블슈팅 가이드도 이 증상을 첫 항목으로 다룹니다.

Gson은 이 문제를 TypeToken으로 풉니다. 익명 하위 클래스를 만들면 제네릭 정보가 클래스 파일에 남고, Gson이 그 정보를 읽어 원소 타입을 복원합니다. 2.10부터는 TypeToken을 그대로 받는 fromJson 오버로드가 생겼고, 가이드는 Type을 받는 쪽보다 이 오버로드를 권합니다.

// Gson 2.10 이상
List<User> users = gson.fromJson(json, new TypeToken<List<User>>() {});

// 그 이전 버전
Type listType = new TypeToken<List<User>>() {}.getType();
List<User> legacyUsers = gson.fromJson(json, listType);

중첩된 제네릭도 방식은 같습니다. 타입 인자를 적은 그대로 넣으면 맵의 값 타입까지 복원됩니다.

Type mapType = new TypeToken<Map<String, List<User>>>() {}.getType();
Map<String, List<User>> grouped = gson.fromJson(json, mapType);

다만 타입 변수를 담는 것은 막혀 있습니다. new TypeToken<List<T>>() {}처럼 쓰면 IllegalArgumentException: TypeToken type argument must not contain a type variable이 납니다. 해당 절은 런타임에 타입 변수의 상한만 남아 디버깅하기 어려운 ClassCastException으로 이어진다고 설명합니다. 이럴 때는 원소 타입을 따로 넘기는 TypeToken.getParameterized(List.class, elementType)를 씁니다. 코틀린이라면 reified 타입 파라미터로 풀 수 있습니다.

최상위가 배열인 JSON이라면 배열 클래스로 받는 방법도 있습니다. 코드가 짧아 단순 목록에는 이쪽이 편합니다.

User[] array = gson.fromJson(json, User[].class);
List<User> users = new ArrayList<>(Arrays.asList(array));

ArrayList로 감싼 이유가 있습니다. 자바 API 문서에 적힌 대로 Arrays.asList())는 "지정한 배열을 감싼 고정 크기 리스트"를 돌려줍니다. 그대로 받아 두면 add()remove()를 부르는 순간 UnsupportedOperationException이 납니다. 읽기만 하고 끝낼 목록이면 복사하지 않아도 되지만, 나중에 원소를 넣고 뺄 여지가 있으면 위처럼 복사해 둡니다.

필드 이름 매핑과 커스텀 변환

서버 JSON은 스네이크 케이스인데 자바 필드는 카멜 케이스인 경우가 흔합니다. 규칙이 일정하다면 GsonBuilder의 명명 정책으로 한 번에 처리합니다.

Gson gson = new GsonBuilder()
 .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
 .setDateFormat("yyyy-MM-dd HH:mm:ss")
 .serializeNulls()
 .setPrettyPrinting()
 .create();

필드 하나만 규칙에서 벗어난다면 @SerializedName을 붙입니다. 이 애노테이션은 기본 명명 정책을 포함해 Gson 인스턴스에 설정된 어떤 FieldNamingPolicy든 덮어씁니다. 정책과 애노테이션을 한 클래스에 섞어 써도 충돌하지 않고, 애노테이션이 붙은 필드만 예외가 됩니다. 서버가 키 이름을 바꾼 적이 있다면 alternate에 옛 이름을 나열해 두면 양쪽 다 읽힙니다.

public class User {
 @SerializedName(value = "user_name", alternate = {"userName"})
 private String name;

 private int age;

 private transient String cachedToken;
}

serializeNulls()를 켜지 않으면 값이 null인 필드는 결과 JSON에서 아예 빠집니다. 서버가 키의 존재 여부로 동작을 나누는 API라면 이 차이가 그대로 버그가 됩니다. 트러블슈팅 가이드도 Properties missing in JSON 항목으로 따로 다룹니다.

transient 필드가 빠지는 것은 고정된 규칙이 아닙니다. 유저 가이드의 Java Modifier Exclusion 절은 transientstatic이 기본 제외 대상이라고 적으면서 excludeFieldsWithModifiers()로 그 집합을 다시 지정할 수 있다고 안내합니다. excludeFieldsWithModifiers(Modifier.STATIC)처럼 STATIC만 주면 transient 필드가 도로 변환 대상에 들어옵니다.

@Expose로 내보낼 필드만 고르기

화면 전용 필드가 요청 본문에 섞여 나가는 일을 막을 때는 @Expose를 씁니다. 유저 가이드의 Gson's @Expose 절이 밝히듯 이 애노테이션은 excludeFieldsWithoutExposeAnnotation()으로 만든 인스턴스에서만 의미가 있습니다. 빌더에 그 옵션이 없으면 @Expose를 아무리 붙여도 결과가 달라지지 않습니다.

public class User {
 @Expose
 @SerializedName("user_name")
 private String name;

 @Expose
 private int age;

 private String internalMemo; // @Expose 없음 → 제외
}
Gson gson = new GsonBuilder()
 .excludeFieldsWithoutExposeAnnotation()
 .create();

옵션을 켠 뒤에는 @SerializedName이 붙어 있어도 @Expose가 없는 필드는 조용히 빠집니다. 이름 매핑과 노출 여부는 별개의 애노테이션이라 위 예제처럼 둘 다 붙여야 의도한 결과가 나옵니다.

설계 목표에 있는 커스텀 표현은 타입별 어댑터 등록으로 구현합니다. 서버가 날짜를 에포크 밀리초로 주고받는다면 직렬화와 역직렬화를 함께 등록해야 내보낸 값이 그대로 다시 읽힙니다.

class EpochMillisDateAdapter implements JsonSerializer<Date>, JsonDeserializer<Date> {
 @Override
 public JsonElement serialize(Date src, Type type, JsonSerializationContext context) {
 return new JsonPrimitive(src.getTime());
 }

 @Override
 public Date deserialize(JsonElement json, Type type, JsonDeserializationContext context) {
 return new Date(json.getAsJsonPrimitive().getAsLong());
 }
}
Gson gson = new GsonBuilder()
 .registerTypeAdapter(Date.class, new EpochMillisDateAdapter())
 .create();

한쪽만 등록하면 반대 방향은 기본 동작이나 setDateFormat()으로 지정한 형식을 따라갑니다. 문자열로 나가고 숫자로 들어오는 식으로 어긋나기 쉽습니다.

.jrxml이 JSON 키 이름을 그대로 들고 있는 Spring 4 + JasperReports JsonDataSource 구성에서는 자바 필드명이 코드 밖에서까지 의미를 갖습니다. 이럴 때 @SerializedName으로 키를 고정해 두면 자바 쪽 이름을 바꿔도 리포트가 깨지지 않습니다.

안드로이드에서 쓸 때 주의할 점

README의 경고부터 옮깁니다. "Gson은 안드로이드에서 JSON을 다루는 데 권장되는 라이브러리가 아닙니다. Gson 런타임의 열린 리플렉션은 안드로이드 릴리스 앱이 수행해야 하는 축소·최적화·난독화 패스와 잘 맞지 않습니다." 같은 문서는 코틀린의 non-null 타입이나 기본 인자를 가진 생성자도 지원하지 않는다고 밝혀 둡니다. 대안으로는 리플렉션 대신 코드 생성을 쓰는 Kotlin Serialization을 제시합니다. 코틀린으로 새로 짜는 코드라면 여기서 Kotlin Serialization을 쓸지 정하면 되고, 이미 Gson으로 돌아가는 코드가 있다면 아래가 점검 목록입니다.

Gson 인스턴스는 스레드에 안전해 여러 스레드에 걸쳐 자유롭게 재사용해도 됩니다. 타입별로 얻은 TypeAdapter를 인스턴스가 캐시하기 때문에, Javadoc은 static final 필드에 담아 하나를 재사용하면 성능에 도움이 된다고 덧붙입니다.

R8과 ProGuard는 필드 이름을 바꾸고, 코드에서 참조되지 않는 필드와 클래스는 지우거나 합칩니다. 모델 클래스의 필드는 Gson이 리플렉션으로만 읽으니 정적 분석에는 아무도 쓰지 않는 코드로 보입니다. 릴리스 빌드에서만 값이 전부 null로 들어오거나 JSON 키가 a, b 같은 이름으로 바뀌는 증상이 여기서 나옵니다. R8이 무인자 생성자를 제거하고 클래스를 abstract로 만들어 버리면 JsonIOException: Abstract classes can't be instantiated!가 뜹니다.

Gson 2.11.0부터는 라이브러리 jar 안에 gson.pro 규칙 파일이 들어 있습니다. 트러블슈팅 가이드는 이것을 "최신 Gson 버전(2.11.0 이상)이 지정하는 기본 R8 설정"이라고 부릅니다. Signature 속성 보존, TypeToken과 그 익명 하위 클래스 보존, @SerializedName이 붙은 필드와 그 클래스의 무인자 생성자 보존이 여기 들어 있습니다. 규칙 파일 첫머리 주석은 "이 규칙들은 완전하지 않으며 사용자가 자기 클래스에 대한 규칙을 추가해야 할 것"이라고 미리 밝혀 둡니다. 안드로이드가 아닌 프로젝트에서 ProGuard를 돌리거나 2.11.0 미만을 쓴다면, 가이드가 이 파일의 규칙을 자기 설정 파일로 복사하라고 안내합니다.

R8 / ProGuard 절이 요구하는 것은 두 가지입니다. 모든 객체에 무인자 생성자를 두고 최상위 또는 static 클래스로 만들 것, 모든 필드에 @SerializedName()을 붙일 것. 그리고 축소를 적용한 뒤 반드시 직접 테스트하라고 못 박습니다. 리플렉션을 아예 걷어내는 선택지도 같은 절에 있습니다. 모든 타입에 TypeAdapterTypeAdapterFactory를 붙이고 GsonBuilder.addReflectionAccessFilter()BLOCK_ALL 필터를 걸면 리플렉션을 아예 쓰지 못하게 막을 수 있습니다.

생성자가 지워진 경우에 가이드가 제시하는 규칙은 클래스를 하나씩 콕 집어 지정하는 형태입니다.

# 역직렬화하는 클래스의 무인자 생성자 보존
-keepclassmembers class com.example.MyClass {
 <init>();
}

안드로이드에서는 규칙 파일을 유지하는 대신 클래스나 생성자에 @Keep을 붙여도 됩니다. 커스텀 R8 설정을 관리하는 것보다 쉬울 수 있다고 가이드가 직접 권합니다. 예외 메시지의 클래스 이름이 이미 난독화돼 있다면 안드로이드 문서의 retrace로 되돌려 봅니다.

응답이 큰 경우에는 본문을 문자열로 전부 읽어 들이지 말고 스트림을 그대로 넘깁니다. JsonReader가 토큰 단위로 훑기 때문에 메모리 부담이 적습니다.

try (Reader reader = new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8)) {
 User user = gson.fromJson(reader, User.class);
}

파싱이 실패하면 JsonSyntaxException이 납니다. Gson 소스가 내부에서 올라온 MalformedJsonException을 이 예외로 감싸 던지는 구조입니다. 트러블슈팅 가이드의 MalformedJsonException 절이 드는 원인 중 후행 쉼표 다음으로 눈여겨볼 것은 "API가 레이트 리밋에 걸리거나 다른 오류가 났을 때 JSON 대신 HTML 에러 페이지를 돌려주는 경우"입니다. 예외 메시지에는 line/column과 JSONPath 위치가 함께 들어 있으니 그것부터 읽고, 실제 응답 본문을 로그로 남겨 둡니다. 앞단 프록시가 끼어 있으면 백엔드가 죽은 동안 프록시의 오류 페이지가 그대로 클라이언트까지 내려오는데, 죽은 노드를 후보에서 빼는 방법은 Nginx 액티브 헬스체크 패치에 정리해 뒀습니다.

어떤 문서를 언제 여는가

원문이 소개한 문서들은 지금 저장소 안의 파일입니다. 성격이 뚜렷하게 나뉘니 상황에 맞는 것만 열면 됩니다.

  • Troubleshooting.md — 손에 예외 메시지가 있을 때. 소제목이 예외 문구 그대로라 검색이 빠름
  • UserGuide.md — 옵션 이름이 기억나지 않을 때. 제외 규칙, @Expose, 명명 정책 예제
  • GsonDesignDocument.md — 원문의 design document. 설계에서 마주친 문제와 다른 자바 JSON 라이브러리와의 비교
  • Releases — 버전을 올리기 직전. 오래된 릴리스는 CHANGELOG.md
  • Javadoc — 메서드 시그니처와 @since 확인
  • Maven Central — 최신 버전 번호와 배포 이력

라이선스는 Apache 2.0입니다.

정리

  • 애노테이션 없이 리플렉션으로 필드 매핑. 소스를 고칠 수 없는 클래스도 변환 대상
  • 무인자 생성자는 사실상 필수. 없으면 JDK Unsafe 경로로 빠져 초기값이 통째로 날아감
  • 제네릭 타입은 TypeToken, 최상위가 배열이면 User[].class. Arrays.asList() 결과는 고정 크기
  • transient 제외는 기본값일 뿐이며 excludeFieldsWithModifiers()로 재지정 가능
  • @ExposeexcludeFieldsWithoutExposeAnnotation()과 짝. @SerializedName은 명명 정책보다 우선
  • 어댑터는 양방향을 다 등록해야 왕복이 맞음
  • 안드로이드에서는 인스턴스 재사용, 모든 필드 @SerializedName, 무인자 생성자 keep, 축소 후 실제 테스트
  • 최신 릴리스는 2.14.0, 배포는 Maven Central, 라이선스는 Apache 2.0