한달전부터 Manifest-for-android-interview라는 책으로 북 스터디를 진행하고 있다.
면접 질문 모음집? 같은 느낌의 책이고, 안드로이드 전반에 걸친 질문들이 수록되어 있는 책이다.
android GDE이신 sky-doves 님이 집필하신 책인데, 난이도(깊이)는 중-중상 정도 되는 것 같다.
스터디를 진행하면서 아쉬웠던 점은 책에 적혀있는 지식들로만 스터디가 진행된다는 점이였다.
중소기업 면접이라면 단순히 A가 무엇인가요?라고 묻고 끝나는 경우가 많겠지만,
근거 있는 기술 사용을 중요하게 여기는 대기업들의 면접을 가게 될 경우에는 작은 주제라도 꼬리 질문이 깊이 들어올 수 있기 때문에 피상적인 학습은 오히려 면접 상황에서 독이 될 수 있다.
그래서 매 스터디 회차마다 공용 리포지토리에 고민해 볼 만한 거리를 이슈로 등록하고 토론하려고 노력중인데, 오늘은 그 중에서 Bundle에 관련된 주제에 대해 발표하다 등록했던 이슈에 대해 풀어보고자 한다.
구글은 왜 Bundle이라는 데이터 구조를 새로 만든걸까?
안드로이드에는 모바일 환경에 맞게 최적화된 API가 몇몇 존재하는데, 이들은 물론 "모바일 환경에 맞게 최적화되었기 때문에" 좋지만,
이런 대표적인 장점에 가려져 있는 수면 아래의 장점들에 대해서도 면밀히 살펴보자.
Bundle이란?
Bundle은 Activity/Fragment/Service 같은 컴포넌트 간에 데이터를 전달할 때 사용하는 Key-Value 쌍 데이터 구조이다.
Bundle은 안드로이드 운영 체제가 쉽게 관리하고 전송할 수 있는 형식으로 데이터를 직렬화하도록 설계되었다.
근데, Bundle을 굳이 만든 이유가 뭘까?
그냥 Map을 사용하면 안되나?
Key-Value 쌍 데이터 구조라면, 우리가 잘 아는 Map 또한 동일한 Key-Value 쌍 데이터 구조인데 Map을 냅두고 Bundle을 만든 이유가 뭘까?
(1) 타입 캐스팅
컴포넌트 간에 데이터를 전달할 때는 String, Int부터 시작해서 UserModel, CommentModel 등의 데이터 클래스 등 다양한 타입의 데이터를 저장해야 한다.
이 때 Map을 사용할 경우, 아래 코드와 같이 다양한 케이스에 대한 처리가 별도로 필요하며, 런타임-안전하지 않다.
fun main() {
val dataMap = mutableMapOf<String, Any>()
dataMap["a"] = 1
// 다른 컴포넌트에 전달 후 값 꺼내기
val prevData: Any? = dataMap["a"]
// Key 값인 a와 매칭되는 Value가 Int 타입일 경우, Int로 캐스팅
// Key 값인 a와 매칭되는 Value가 존재하지 않거나(null) 다른 타입일 경우, null 반환
val prevIntData1: Int? = prevData as? Int
// Key 값인 a와 매칭되는 Value가 존재하지 않을 경우(null), NullPointerException 발생
// Key 값인 a와 매칭되는 value가 Int 타입이 아닐 경우, ClassCastException 발생
val prevIntData2: Int = prevData as Int
}
하지만 Bundle을 사용하면 이런 별도의 처리가 필요하지 않게 된다.
fun main() {
val dataBundle = Bundle()
dataBundle.putInt("a", 1)
// 다른 컴포넌트에 전달 후 값 꺼내기
// Key 값인 a에 해당하는 값이 존재하지 않더라도, 기본 값인 0을 반환, 기본 값을 바꾸려면 getInt(key, defaultValue) 오버로드 메서드를 사용하면 됨
// Key 값인 a에 해당하는 값이 다른 타입이더라도, 예외가 발생하지 않고 0을 반환
val prevData: Int = dataBundle.getInt("a")
}
Bundle처럼 다양한 값을 받으려면 Map은 무조건 Any 타입을 사용해야하기 때문에 값을 꺼낼 때 캐스팅이 필요하고 이 과정에서 런타임 예외가 발생할 수 있다. 타입 안전성을 지원하지 않으면 런타임 예외 발생 시에 앱이 즉시 크래시되기 때문에 사용자 경험에 치명적인 영향을 미치게 되는데, 이런 점에서 Bundle은 이러한 런타임 예외를 컴파일 단계에서 미리 차단할 수 있다는 장점이 가장 크다.
또한 Bundle은 별도의 구현 없이 Parcelable, Serializable 같은 객체를 직렬화할 수 있지만, Map은 직렬화를 직접 구현해야 한다는 차이도 있다.
게다가 as를 통한 캐스팅이나 Any 같은 슈퍼 타입을 사용한 캐스팅과 리플렉션 Api의 사용은 불필요한 오버헤드를 발생시킨다. 규모가 커질수록 런타임 성능이 감소할 수 있으므로 이를 최소화해야 하고, 그 과정에서 Jvm/ART 레벨의 최적화가 더 잘 적용될 수 있다.
메모리 관점에서의 장점
Map에 할당된 데이터들은 JVM 메모리에 올라가 메모리 사용량이 올라간다.
하지만 Bundle에 저장된 데이터는 내부 Parcel 객체에 담아뒀다가 특정 데이터에 대해 get() 메서드가 호출되어 참조될 때까지 언패킹(역직렬화)을 지연시킬 수 있다는 장점이 있다.
이러한 특성은 Bundle에 저장해야 하는 데이터가 대용량일 경우 특히 유용한데, 메모리에 미리 올리지 않고 필요한 순간에 언패킹하기 때문에 그 과정에서 발생하는 오버헤드를 최소화할 수 있기 때문이다.
이 메커니즘은 Bundle의 상위 클래스인 BaseBundle 내부 코드를 통해 이해할 수 있다.
BaseBundle - Bundle에 데이터를 저장할 때의 내부 동작
우선 Parcel 객체에 대한 이해가 필요하다.
Parcel 객체는 네이티브 메모리(네이티브 힙)에 바이트 버퍼(직렬화 버퍼)를 가지고 있다.
우리가 Bundle 객체의 putXXX() 메서드를 호출하면, 내부적으로 Parcel 객체의 writeXXX() 메서드를 호출하게 되는데, 이 때 네이티브 버퍼에 이 값을 직렬화해서 바이트 형태로 저장하게 되는 것이다.
그리고 내부적으로 직렬화되어 저장된 이 데이터는 getXXX() 메서드를 통해 접근하기 전까지는 네이티브 메모리 상에만 존재한다.(접근할 때까지 JVM 메모리에 올라가지 않는다)
BaseBundle 클래스는 내부적으로 이 Parcel 객체를 가지고 있으며, 여기에 데이터를 직렬화하여 보관한다.
writeParcel() 메서드가 호출된 후에 바로 언패킹하지 않고 이 객체에 바이트 버퍼로만 전달하게 된다.
그리고 이를 Jvm 메모리(힙)에 올리기 위해 ArrayMap<String, Object> 객체 또한 가지고 있다.
get()/put() 메서드 호출 시 Parcel 버퍼를 읽어서 ArrayMap에 Key-Object 구조로 복원(역직렬화)하는 과정을 거친다.
이후 모든 Key-Value 쌍이 ArrayMap으로 복원되면, Parcel 객체에 할당된 메모리를 해제해서 네이티브 힙을 반환할 수 있게 된다.
Bundle의 상위 클래스인 BaseBundle
아래 코드는 BaseBundle 코드 중 일부이다. lazy unparcelling에 대한 이해를 도울 수 있는 부분만 발췌하였다.
public class BaseBundle {
// Invariant - exactly one of mMap / mParcelledData will be null
// (except inside a call to unparcel)
@UnsupportedAppUsage
ArrayMap<String, Object> mMap = null;
/*
* If mParcelledData is non-null, then mMap will be null and the
* data are stored as a Parcel containing a Bundle. When the data
* are unparcelled, mParcelledData will be set to null.
*/
@UnsupportedAppUsage
volatile Parcel mParcelledData = null;
/**
* Whether {@link #mParcelledData} was generated by native code or not.
*/
private boolean mParcelledByNative;
/**
* Flag indicating if mParcelledData is only referenced in this bundle.
* mParcelledData could be referenced elsewhere if mMap contains lazy values,
* and bundle data is copied to another bundle using putAll or the copy constructors.
*/
boolean mOwnsLazyValues = true;
/** Tracks how many lazy values are referenced in mMap */
private int mLazyValues = 0;
/**
* As mParcelledData is set to null when it is unparcelled, we keep a weak reference to
* it to aid in recycling it. Do not use this reference otherwise.
* Is non-null iff mMap contains lazy values.
*/
private WeakReference<Parcel> mWeakParcelledData = null;
/**
* The ClassLoader used when unparcelling data from mParcelledData.
*/
private ClassLoader mClassLoader;
.
.
.
}
(1) ArrayMap<String, Object>
Parcel에 담겨 네이티브 힙에 존재하는 직렬화된 데이터를 역직렬화하여 담을 Map이다.
주석을 보면 mMap과 mParcelledData 중 하나만 null이 되지만, unparcel 과정 중에는 그렇지 않을 수도 있다고 설명하고 있다.
// Invariant - exactly one of mMap / mParcelledData will be null
// (except inside a call to unparcel)
@UnsupportedAppUsage
ArrayMap<String, Object> mMap = null;
(2) Parcel
데이터를 직렬화하여 네이티브 힙에 저장하는 객체이다.
이 객체가 non-null이라면, mMap은 null이 되고 데이터가 unparcelled되면 이 객체는 null이 된다.
/*
* If mParcelledData is non-null, then mMap will be null and the
* data are stored as a Parcel containing a Bundle. When the data
* are unparcelled, mParcelledData will be set to null.
*/
@UnsupportedAppUsage
volatile Parcel mParcelledData = null;
(3) mParcelledByNative
mParcelledData가 Java(kotlin)이 아닌 네이티브 쪽에서 만들어졌는지를 표시하는 필드이다.
/**
* Whether {@link #mParcelledData} was generated by native code or not.
*/
private boolean mParcelledByNative;
(4) mOwnsLazyValues
/**
* Flag indicating if mParcelledData is only referenced in this bundle.
* mParcelledData could be referenced elsewhere if mMap contains lazy values,
* and bundle data is copied to another bundle using putAll or the copy constructors.
*/
boolean mOwnsLazyValues = true;
이 Bundle 객체가 가지고 있는 직렬화 버퍼(mParcelledData)를 "이 Bundle만 참조하고 있는지"를 나타내는 내부 플래그 필드이다.
이 플래그가 존재하는 이유도 알게되면 조금 재밌다.
Bundle은 lazy unparcelling을 사용한다. unparcel() 시에 즉시 역직렬화하지 않고, mMap 안에 LazyValue를 넣어두고 값에 접근할 때만 디코딩을 수행하게 된다.
그리고 이 LazyValue들은 직렬화 버퍼(바이트 버퍼, mParcelledData)에 의존한다.
하지만 이 때 이 Bundle을 복사하게 되면, LazyValue 객체도 그대로 복사되어 2개 이상의 Bundle이 동일한 직렬화 버퍼(mParcelledData)를 참조하게 된다.
그래서 이 플래그를 통해 조건문을 분기하여 플래그가 true일 때에만 안전하게 직렬화 버퍼를 해제할 수 있도록 구현되어 있다.
(5) mLazyValues
몇 개의 LazyValue들이 mMap에서 참조중인지를 나타내는 필드이다.
0보다 크면 직렬화 버퍼가 아직 필요하다고 판단하고, 0일 경우 모든 값이 디코딩되어 LazyValue가 없으므로 버퍼를 안전하게 해제할 수 있다고 판단할 수 있다.
이 필드를 통해서 메모리 사용을 최소화하고 버퍼 관리를 안전하게 처리할 수 있다.
/** Tracks how many lazy values are referenced in mMap */
private int mLazyValues = 0;
(6) mWeakParcelledData
WeakReference로 래핑한 Parcel 객체이다.
unparcel 이후에 직렬화 버퍼에 대한 강한 참조를 null로 해제하지만, LazyValue들이 여전히 해당 직렬화 버퍼를 참조하고 있으므로
해당 직렬화 버퍼를 추후에 재활용하기 위해 약한 참조를 따로 보관한다.
이를 통해 Bundle이 Parcel의 참조를 유지하지 못하도록 할 수 있다. 그래서 메모리가 부족할 경우 GC가 Parcel을 회수할 수 있고, 약한 참조를 통해 있으면 재활용할 수 있다.
/**
* As mParcelledData is set to null when it is unparcelled, we keep a weak reference to
* it to aid in recycling it. Do not use this reference otherwise.
* Is non-null iff mMap contains lazy values.
*/
private WeakReference<Parcel> mWeakParcelledData = null;
(7) mClassLoader
직렬화 버퍼의 데이터를 역직렬화하는 데 사용되는 ClassLoader 객체를 저장해 둔 필드이다.
Parcel에는 클래스 이름 String 값만 저장되는데, unparcelling 시에 Class.forName(name, false, mClassLoader)로 클래스를 찾아 인스턴스를 만들게 된다.
/**
* The ClassLoader used when unparcelling data from mParcelledData.
*/
private ClassLoader mClassLoader;
요약
사용자가 저장한 데이터는 직렬화되어 Bundle 내부의 Parcel 객체에 담긴다.
이 때, 데이터들은 네이티브 힙에 위치하므로 역직렬화를 통해 Jvm 힙에 올려야 한다.
역직렬화된 데이터들을 담는 그릇이 바로 ArrayMap<String, Object> 객체이다.
그리고! 여기서 Parcel이라는 안드로이드 전용 Api를 사용하기 때문에 unparcelling이라고 하지만,
일반 용어로는 deserialization이다. (역직렬화)
따라서 언파셀링 == 역직렬화라고 생각하면 될 것 같다.
일반적으로는 Parcel 객체가 null이면 ArrayMap 객체가 non-null이고, 반대도 동일하다.
하지만 Parcel 객체의 unparcelling 과정 중에는 LazyValue가 직렬화 버퍼에 존재하므로 둘 다 non-null일 수 있다.(예외적 상황)
unparcelling 과정에서 mClassLoader 객체로 인스턴스를 생성하고, \
mLazyValues라는 카운터 필드를 통해 완료 여부를 판단하고,
LazyValue가 존재할 때 Bundle이 복사되어 여러 LazyValue들이 한 버퍼를 참조하면 해제를 방지한다.
그러다 mLazyValues가 0이 되면 버퍼를 안전하게 해제하는 방식으로 동작한다.
'개발 > Android' 카테고리의 다른 글
| [안드로이드] MacOS Automator+Shell script로 피그마 리소스 처리 자동화하기 (0) | 2025.12.24 |
|---|---|
| [KMP] kotlinx-datetime 라이브러리 활용하기 (1) | 2025.11.04 |
| [안드로이드] Glide 캐시 내부 구현 살펴보기 (2) | 2025.06.16 |
| [안드로이드] Parcelable을 사용하는 이유(feat.직렬화) (1) | 2025.04.15 |
| [안드로이드] Context와 메모리 누수 (0) | 2025.04.07 |