Intent가 컴포넌트 사이를 이어주는 메시지라고 정리했다. 이번 글에서는 그 Intent에 실제 데이터를 실어서 Activity끼리 주고받는 방법, 그리고 커스텀 객체를 넘길 때 필요한 직렬화(Serializable/Parcelable)를 다룬다.
안드로이드에서 하나의 화면(Activity)에서 다른 화면으로 넘어갈 때는 Intent를 사용한다. 이때 단순히 화면만 전환하는 게 아니라, 데이터도 같이 실어 보낼 수 있다. 예를 들어 목록 화면에서 항목을 하나 클릭해서 상세 화면으로 넘어간다고 하면, "어떤 항목을 클릭했는지"에 대한 정보를 같이 넘겨줘야 상세 화면이 뭘 보여줄지 알 수 있다.
Intent(this, DetailActivity::class.java)
.apply {
putExtra("movie", dummy[position])
}
putExtra에 키("movie")와 값(dummy[position])을 넣어서 Intent에 데이터를 실은 다음, 이 Intent로 startActivity를 호출하면 새로 열리는 DetailActivity가 이 데이터에 접근할 수 있다. 받는 쪽에서는 넘겨받은 데이터의 타입에 맞는 get<타입>Extra 메서드를 쓴다.
val movie: Movie = intent?.getParcelableExtra("movie") ?: throw IllegalArgumentException()
여기서 짚고 넘어가야 할 게 있다. 지금 다루는 Intent를 통한 데이터 전달은 서로 다른 두 Activity 사이의 데이터 전달이다. 다음편에서 다룰 onSaveInstanceState는 같은 Activity가 재생성될 때 이전 상태를 이어받는 것이다. 전달 대상도 다르고 시점도 다르다. 나도 처음 배울 때 이 둘을 같은 걸로 뭉뚱그려서 이해했다가 나중에 헷갈렸던 경험이 있어서, 이름이 비슷해 보여도 목적이 다른 개념이라는 걸 먼저 못박고 시작하겠다.
위 코드에서 Movie라는 커스텀 객체를 통째로 putExtra에 넣고, 받는 쪽에서는 getParcelableExtra로 꺼냈다. 그런데 왜 그냥 Movie 객체를 변수에 담아 넘기듯이 넘길 수 없고, Parcelable이라는 걸 거쳐야 할까?
Intent는 내부적으로 Bundle이라는 데이터 컨테이너를 사용한다. Bundle은 단순히 메모리 안에서 값을 담아두는 자료구조가 아니라, 프로세스 경계를 넘나들 수 있는 Binder IPC(Inter-Process Communication, 프로세스 간 통신) 구조를 전제로 설계되어 있다. Activity를 시작할 때 실제로는 안드로이드 시스템(정확히는 ActivityManagerService)이 중간에 개입해서 새 Activity를 띄우는데, 이 과정이 프로세스 경계를 넘는 경우가 있다. 그래서 Bundle에 담기는 데이터는 "메모리 주소를 그대로 복사"하는 방식으로는 전달할 수 없고, 바이트 단위로 직렬화(serialize, 객체를 전송·저장 가능한 형태로 변환하는 것)해서 보낸 다음 반대편에서 역직렬화(deserialize)해서 복원하는 절차를 거쳐야 한다.
일반 자바/코틀린 객체는 이런 직렬화 규칙이 없다. 그래서 안드로이드는 "이 객체는 직렬화가 가능하다"는 걸 명시적으로 보장하는 인터페이스를 요구한다. 그게 바로 Serializable과 Parcelable이다. 둘 중 하나를 구현해야만 Bundle(따라서 Intent)에 커스텀 객체를 담아 넘길 수 있다.
Serializable과 Parcelable은 둘 다 "이 객체는 직렬화할 수 있다"는 걸 표시하는 인터페이스라는 점에서 기능은 같다. 하지만 구현 방식과 성능에서 차이가 크다.
| 구분 | Serializable | Parcelable |
|---|---|---|
| 구현 방법 | 인터페이스만 구현하면 끝 | 인터페이스 구현 + 추가 코드 작성 필요 |
| 성능 | 상대적으로 느림 | 상대적으로 빠름 |
| 코드 작성량 | 추가 코드 작성 불필요 | writeToParcel(), describeContents(), CREATOR 등 보일러플레이트 필요 |
| 의존성 | Java 표준 API | 안드로이드 의존성 필요 |
표만 외우면 "그냥 Serializable이 편해 보이는데 왜 Parcelable을 쓰라는 거지"라는 생각이 들 수 있다. 그래서 성능 차이가 왜 나는지 원리를 짚어보겠다.
Serializable은 리플렉션(reflection, 런타임에 클래스의 필드나 구조를 분석하는 기법)을 이용해서 객체를 분석하고 직렬화한다. 클래스에 어떤 필드가 있는지, 타입이 뭔지를 실행 중에 하나하나 들여다보면서 처리하기 때문에 느리고, 이 과정에서 임시 객체가 많이 만들어져서 가비지 컬렉션(GC, 더 이상 쓰지 않는 메모리를 회수하는 작업) 부담도 커진다. 반면 Parcelable은 리플렉션을 쓰지 않는다. 클래스를 만들 때 "이 필드를 이 순서로 읽고 쓴다"는 로직을 직접 정의해두기 때문에, 실행 시점에 구조를 분석할 필요 없이 메모리에 필드를 바로 읽고 쓴다. 그만큼 빠르다.
Serializable은 원래 자바 표준 API로, 안드로이드 전용으로 최적화된 게 아니다. 그런데 안드로이드는 Activity 간, 프로세스 간 데이터 전달이 매우 빈번한 플랫폼이라서, 이 경로에 최적화된 자체 인터페이스로 Parcelable을 따로 만들어 제공한다. 그래서 안드로이드 공식 문서도 IPC 상황에서는 Parcelable 사용을 권장한다.
다만 표에서 본 것처럼 Parcelable은 직접 구현하면 writeToParcel(), describeContents(), CREATOR 필드까지 보일러플레이트 코드를 다 작성해야 한다는 단점이 있다. 이 단점을 없애주는 게 다음에 볼 @Parcelize 플러그인이다.
Parcelable을 직접 구현하면 코드가 이렇게 길어진다.
// Before: Parcelable 직접 구현
class Movie(val title: String, val year: Int) : Parcelable {
constructor(parcel: Parcel) : this(
parcel.readString() ?: "",
parcel.readInt()
)
override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.writeString(title)
parcel.writeInt(year)
}
override fun describeContents(): Int = 0
companion object CREATOR : Parcelable.Creator<Movie> {
override fun createFromParcel(parcel: Parcel): Movie = Movie(parcel)
override fun newArray(size: Int): Array<Movie?> = arrayOfNulls(size)
}
}
필드가 두 개뿐인 클래스인데도 이만큼 써야 한다. 필드가 늘어날수록 이 보일러플레이트도 같이 늘어난다. 코틀린은 이 문제를 kotlin-parcelize라는 공식 플러그인으로 해결한다. build.gradle.kts에 플러그인을 추가한다.
plugins {
id("kotlin-parcelize")
}
그리고 클래스에 @Parcelize 어노테이션을 붙이고 Parcelable을 상속하기만 하면, writeToParcel()이나 CREATOR 같은 코드를 컴파일 시점에 자동으로 만들어준다.
// After: @Parcelize 적용
import kotlinx.parcelize.Parcelize
import android.os.Parcelable
@Parcelize
data class Movie(val title: String, val year: Int) : Parcelable
코드 한 줄로 줄었다. 여기서 주의할 점은, 직렬화 대상 프로퍼티는 반드시 주 생성자(primary constructor)에 선언되어야 한다는 것이다. 클래스 본문에 따로 선언한 프로퍼티는 자동으로 직렬화되지 않고 경고가 뜬다. 원시 타입, String, CharSequence, List/Set/Map 같은 컬렉션, Bundle, 그리고 이미 Serializable이나 Parcelable을 구현한 타입이라면 nullable 버전이나 배열까지도 기본으로 지원한다. 이 기본 지원 범위를 벗어나는 커스텀 타입을 다뤄야 한다면 Parceler 인터페이스를 직접 구현해서 @TypeParceler로 연결하는 방법도 있는데, 이건 심화 내용이라 이런 게 있다는 정도만 알아두면 된다.
Intent로 데이터 넘기는 것과 onSaveInstanceState로 상태 저장하는 것을 같은 것으로 착각. 앞서 못박았듯, 전자는 서로 다른 두 Activity 사이의 데이터 전달이고 후자는 같은 Activity가 재생성될 때 이전 상태를 이어받는 것이다. 목적도 시점도 다르다.
Serializable과 Parcelable 이름이 비슷해서 아무거나 골라도 되는 줄 아는 착각. 둘 다 직렬화를 위한 인터페이스라는 점은 같지만, 리플렉션 사용 여부 때문에 성능 차이가 있고, 안드로이드 공식 권장은 Parcelable, 그중에서도 @Parcelize다.
이번 글에서는 서로 다른 두 Activity 사이에서 Intent로 데이터를 주고받는 방법과, 커스텀 객체를 넘길 때 필요한 Serializable/Parcelable 직렬화를 정리했다. Parcelable이 리플렉션 없이 직접 정의한 읽기/쓰기 로직으로 동작해서 더 빠르고, @Parcelize를 쓰면 그 보일러플레이트도 거의 없앨 수 있다는 게 핵심이었다.