Context 클래스 계층 구조: ContextWrapper, ContextImpl, 그리고 ContextThemeWrapper
Context 클래스 계층 구조: ContextWrapper, ContextImpl, 그리고 ContextThemeWrapper
모든 안드로이드 개발자는 Context를 끊임없이 사용합니다. 컴포저블 안에서 LocalContext.current를 읽고, 이미지 로더나 DataStore를 만들 때 누수를 피하려고 getApplicationContext()를 호출하며, stringResource()로 문자열을 해석하고, context.getSystemService()로 시스템 서비스를 요청합니다. 표면이 워낙 익숙하다 보니 그 아래의 구조는 무심코 지나치기 쉽습니다. Activity도 Context이고, Application도 Context이며, Service도 Context이지만, 이들은 서로 다른 수명, 서로 다른 테마, 서로 다른 리소스 구성을 가집니다. 이 큰 추상 API를 셋이 제각기 다시 구현할 수는 없습니다. 더 깊은 질문은 실제로 무엇이 Context를 구현하는지, 그리고 프레임워크가 왜 그 구현을 단일 클래스가 아니라 래퍼 클래스들의 체인에 나누어 두었는지입니다.
이 글에서는 Context의 내부 구조를 깊이 있게 파고듭니다. 추상 클래스인 Context 계약, ContextWrapper가 attachBaseContext를 통해 베이스에 어떻게 위임하는지, 왜 ContextImpl이 실제 작업을 하는 단 하나의 클래스이며 무엇을 들고 있는지, ContextThemeWrapper가 컨텍스트별 테마와 리소스를 어떻게 더하는지, Activity가 attach()에서 이 세 계층을 어떻게 합성하는지, getSystemService가 SystemServiceRegistry를 통해 어떻게 해석되는지, 그리고 createConfigurationContext 같은 파생 컨텍스트가 앱의 나머지로 새어 나가지 않으면서 어떻게 만들어지는지를 살펴봅니다.
근본적인 문제: 하나의 인터페이스, 여러 컴포넌트 타입
context.getString(R.string.title)이나 context.getSystemService(Context.WINDOW_SERVICE)를 호출할 때, 여러분은 서로 무관한 일을 잔뜩 하는 인터페이스와 대화하는 셈입니다. 리소스 조회, 에셋 접근, 테마 해석, 시스템 서비스 접근, 파일 및 데이터베이스 경로, 권한 검사, Activity 시작, Service 바인딩이 모두 여기 들어 있습니다. 이제 이 동일한 API를, 프로세스 전체 수명 동안 살아 있는 Application도, 자체 테마와 윈도우에 묶인 구성을 가진 Activity도, 테마도 윈도우도 없는 Service도 모두 만족시켜야 한다고 생각해 보세요.
순진한 설계라면 Context API를 Activity, Application, Service에 각각 따로 구현할 것입니다. 그것은 곧장 실패합니다. 리소스를 로드하거나 시스템 서비스를 가져오는 로직은 어느 컴포넌트가 요청하든 동일하므로, 그것을 모든 컴포넌트 클래스에 복사해 넣는다면 크고 복잡한 구현을 세 벌이나 동기화해야 한다는 뜻이 됩니다. 또한 나머지 전부를 다시 구현하지 않고서는, 로케일 재정의처럼 동작 하나만 조정하기 위해 Context를 감싸는 일도 불가능해집니다.
프레임워크는 이를 분리로 해결합니다. Context API를 실제로 구현하는 클래스는 정확히 하나, ContextImpl입니다. 컴포넌트들은 이를 상속하지 않습니다. 대신 ContextImpl을 들고 모든 호출을 그쪽으로 넘기는 데코레이터인 ContextWrapper를 상속합니다. 테마가 필요한 컴포넌트는 한 계층을 더 끼워 넣는 ContextThemeWrapper를 상속합니다. 그 결과 Activity, Application, Service는 단일 구현을 재사용하면서도 그 자체가 Context로 사용될 수 있습니다.
Context: 추상 계약
Context 자체는 추상 클래스입니다. getResources(), getAssets(), getTheme(), getSystemService(String), getApplicationContext(), startActivity(Intent), 그리고 파일 및 데이터베이스 접근자를 포함해 거의 모든 메서드가 추상입니다. Context는 계약과 WINDOW_SERVICE, LAYOUT_INFLATER_SERVICE 같은 잘 알려진 서비스 이름 상수를 정의하지만, 동작은 전혀 제공하지 않습니다.
Context가 API만 선언하고 그 외에는 아무것도 하지 않기 때문에, 흥미로운 질문은 결코 "Context가 무엇을 하는가"가 아닙니다. "이 추상 호출들의 반대편에 있는 객체가 무엇인가"입니다. 앱 코드에서 들고 있는 어떤 Context든, 답은 거의 언제나 래퍼 한두 개를 거쳐 도달하는 ContextImpl입니다.
ContextWrapper: 베이스에 위임하는 데코레이터
ContextWrapper는 여러분의 컴포넌트 클래스가 실제로 상속하는 계층입니다. 그 일 전부는 또 다른 Context를 들고 그쪽으로 넘기는 것입니다. 클래스의 핵심을 보면 다음과 같습니다.
public class ContextWrapper extends Context {
@UnsupportedAppUsage
Context mBase;
public ContextWrapper(Context base) {
mBase = base;
}
protected void attachBaseContext(Context base) {
if (mBase != null) {
throw new IllegalStateException("Base context already set");
}
mBase = base;
}
}
베이스가 설정되는 경로는 두 가지입니다. ContextWrapper가 생성자를 통해 받거나, 프레임워크가 나중에 attachBaseContext를 통해 설치할 수 있습니다. attachBaseContext의 가드가 바로 베이스를 한 번만 설정할 수 있는 이유입니다. mBase가 null이 아니게 되면, 두 번째 attach는 IllegalStateException("Base context already set")을 던집니다. 이 점은 Activity처럼 프레임워크가 무인자 경로로 인스턴스화한 뒤 나중에 attach() 동안 베이스 컨텍스트를 붙이는 컴포넌트에서 중요합니다. 또한 컨텍스트를 감싸려고 Activity에서 attachBaseContext()를 오버라이드할 때(앱별 로케일을 위한 흔한 기법) 감싼 컨텍스트로 super.attachBaseContext()를 정확히 한 번만 호출해야 하는 이유이기도 합니다.
ContextWrapper의 모든 기능 메서드는 그저 넘깁니다. 대표적인 예 두 가지를 보겠습니다.
@Override
public Context getApplicationContext() {
return mBase.getApplicationContext();
}
@Override
public Object getSystemService(String name) {
return mBase.getSystemService(name);
}
구조를 눈여겨보세요. ContextWrapper는 자기만의 동작을 더하지 않습니다. 그 가치가 간접화(indirection) 그 자체인 데코레이터입니다. 여러분의 Activity가 ContextWrapper이기 때문에 이를 서브클래싱할 수 있고, 실제 작업이 mBase 뒤에 있기 때문에 프레임워크는 여러분의 컴포넌트가 눈치채지 못한 채 그 베이스를 교체하거나 감쌀 수 있습니다. 위임은 ContextThemeWrapper 같은 서브클래스가 오버라이드하기로 선택한 몇몇을 제외하면 모든 메서드에 대해 기계적입니다.
ContextImpl: 실제로 작업이 일어나는 곳
ContextImpl은 mBase의 반대편에 있는 클래스입니다. android.app의 패키지 프라이빗이라 앱 코드에서는 이름조차 부를 수 없지만, 여러분이 건드리는 모든 Context는 결국 여기로 귀결됩니다. 넘기는 대신, 작업을 수행하는 데 필요한 상태를 들고 있습니다.
class ContextImpl extends Context {
@UnsupportedAppUsage
final @NonNull LoadedApk mPackageInfo;
private final @NonNull ResourcesManager mResourcesManager;
@UnsupportedAppUsage
private Resources mResources;
private Context mOuterContext;
final Object[] mServiceCache = SystemServiceRegistry.createServiceCache();
}
각 필드는 하나의 책임에 대응됩니다.
mPackageInfo: 로드된 APK를 나타내는LoadedApk입니다.ClassLoader, 애플리케이션 정보, 리소스 디렉터리, 그리고 싱글톤Application에 대한 참조를 소유합니다.mResourcesManager와mResources: 리소스 스택입니다.ResourcesManager는 주어진 구성에 맞춰 재정렬(rebase)된Resources객체를 내어 주는 프로세스 전역 캐시이고,mResources는 이 특정 컨텍스트가 해석에 사용하는 객체입니다.mOuterContext: 가장 바깥 래퍼에 대한 역참조입니다.ContextImpl이Activity의 베이스일 때mOuterContext는 그Activity를 가리킵니다. 시스템 서비스는 이를 사용하여 숨겨진 구현이 아니라 외부로 노출되는 컨텍스트를 받습니다.mServiceCache:SystemServiceRegistry로 인덱싱된, 이미 생성된 시스템 서비스의 컨텍스트별 캐시입니다.