이번 글에서는 Activity를 포함한 여러 컴포넌트가 공통으로 의존하는 개념인 Context를 정리한다.
Context는 한마디로 "지금 내가 어떤 실행 환경에 있는지"에 대한 정보를 담고 있는 창구다. 안드로이드 앱을 만들다 보면 리소스 파일(문자열, 이미지, 색상 등)을 읽어야 하고, 시스템이 제공하는 서비스(알림, 위치, 진동 등)를 써야 하고, 다른 화면을 띄우거나 브로드캐스트를 보내야 하는 순간이 온다. 이런 일들을 하려면 지금 내 코드가 어떤 앱, 어떤 프로세스, 어떤 컴포넌트 안에서 실행되고 있는지에 대한 정보가 필요한데, 그 정보와 접근 권한을 한데 묶어서 제공하는 것이 Context다.
Context는 추상 클래스(abstract class)다. 즉 Context 자체는 직접 인스턴스를 만들 수 없고, 이를 구현한 다른 클래스를 통해 사용하게 된다. Activity, Service, BroadcastReceiver, ContentProvider 같은 컴포넌트들은 모두 이 Context를 상속하거나 내부에 갖고 있어서, getSystemService()로 시스템 서비스를 얻거나 getResources()로 리소스에 접근하거나 startActivity()로 다른 화면을 띄우는 등의 동작을 할 수 있다.
여기서 흔히 하는 오해가 하나 있다. Context를 그냥 Activity의 다른 이름 정도로 생각하는 것이다. 하지만 실제로는 Activity, Service, Application이 각각 자기만의 Context 인스턴스를 따로 갖고 있다. Activity는 그중 하나의 종류일 뿐이고, 뒤에서 다루겠지만 Activity Context와 Application Context는 서로 다른 인스턴스이기 때문에 아무 데서나 바꿔 써도 되는 게 아니다.
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// this는 현재 Activity의 Context다
val activityContext: Context = this
// getApplicationContext()는 앱 전체 생명주기를 따르는 별개의 Context다
val appContext: Context = applicationContext
}
}
this와 applicationContext가 겉보기엔 둘 다 "Context 타입 객체"라서 똑같아 보이지만, 실제로는 서로 다른 대상을 가리킨다. 이 차이가 왜 생기는지, 그리고 왜 중요한지를 이해하려면 Context가 내부적으로 어떻게 만들어지는지부터 봐야 한다.
Activity가 Context의 기능(리소스 접근, 시스템 서비스 호출 등)을 쓸 수 있는 이유를 "Activity가 Context를 상속받아서"라고만 설명하면 절반만 맞은 얘기다. 더 정확히 들어가 보면, Activity는 ContextThemeWrapper → ContextWrapper → Context 순서로 상속하는 클래스다. 그런데 실제로 리소스를 읽고 시스템 서비스를 호출하는 진짜 구현체는 ContextImpl이라는 별도의 클래스다. ContextImpl도 Context를 구현하지만, Activity가 상속하는 클래스 계층과는 다른 갈래에 있다. 즉 Activity와 ContextImpl은 둘 다 "Context 계열"이지만 서로 부모-자식 관계가 아니라 형제뻘에 가깝다.
그러면 Activity는 어떻게 ContextImpl의 기능을 쓸 수 있을까? 여기서 등장하는 게 조합(composition) 구조다. ContextWrapper는 mBase라는 필드를 갖고 있는데, 이 필드에 실제 일을 처리하는 ContextImpl 인스턴스를 담아둔다. 그리고 ContextWrapper가 제공하는 메서드들(getResources(), getSystemService() 등)은 사실 자기가 직접 로직을 수행하는 게 아니라, 전부 mBase에 담긴 ContextImpl에게 그대로 위임(delegate)한다. Activity가 시스템에 의해 생성되는 시점에 프레임워크가 attachBaseContext()라는 메서드를 통해 이 ContextImpl 인스턴스를 Activity(정확히는 그 조상인 ContextWrapper)에게 심어준다.
비유하자면 이렇다. Activity는 "리셉션 데스크"이고, ContextImpl은 그 뒤에서 실제로 서류를 처리하는 "직원"이다. 손님(개발자 코드)이 리셉션 데스크에 요청을 하면, 데스크는 자기가 직접 서류를 처리하는 게 아니라 뒤에 있는 직원에게 그대로 넘기고, 직원이 처리한 결과를 다시 손님에게 전달해줄 뿐이다. "Activity가 Context 기능을 상속받아서 쓴다"보다는 "Activity가 내부에 진짜 일꾼을 하나 데리고 있다가, 요청이 오면 그 일꾼에게 시킨다"에 가까운 그림이다.
이 구조를 알고 나면 Activity Context와 Application Context가 왜 다른 인스턴스인지도 이해가 된다. Activity, Service, Application처럼 시스템이 개별적으로 생성하는 컴포넌트는 각각 자기 몫의 ContextImpl을 새로 발급받는다. 그래서 한 앱 안에 Activity가 여러 개 떠 있으면, 그만큼 서로 다른 ContextImpl 인스턴스가 존재하는 것이고, Application을 대표하는 Context는 또 별도의 인스턴스다. this(Activity Context)와 applicationContext(Application Context)가 다른 값을 가리키는 이유가 바로 이것이다.
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 서로 다른 컴포넌트가 서로 다른 ContextImpl을 갖고 있기 때문에
// 아래 두 Context는 같은 객체가 아니다
println(this == applicationContext) // false
}
}
이 둘의 차이를 알고 나면 자연스럽게 세 가지 질문이 따라온다. 첫째, 무조건 Application Context를 쓰면 안 되는 이유가 뭘까. 둘째, Application Context가 지원하지 않는 기능은 뭘까. 셋째, 생명주기에 의존적인 Activity Context를 써야 하는 순간은 언제일까. 세 질문 모두 결국 같은 원리 하나로 답이 나온다 — Application Context는 UI 작업을 하도록 설계되지 않았다는 것이다.
Application Context는 앱이 실행되는 동안 하나만 존재하고, 특정 화면(윈도우)에 소속되어 있지 않다. 반면 다이얼로그를 띄우거나, 테마가 적용된 레이아웃을 inflate하거나, 화면 전환 애니메이션을 보여주는 작업은 전부 "지금 화면에 떠 있는 특정 윈도우"를 전제로 한다. 이 전제가 깨지면 문제가 생긴다. 가장 자주 겪는 예가 AlertDialog다.
// 크래시가 나는 예시
fun showBadDialog(context: Context) {
AlertDialog.Builder(context)
.setTitle("알림")
.setMessage("Application Context로 다이얼로그를 띄우면?")
.show()
}
// Application Context를 넘기면 WindowManager.BadTokenException 발생
showBadDialog(applicationContext)
// Activity Context를 넘기면 정상 동작
showBadDialog(this) // this: Activity
AlertDialog는 특정 윈도우에 자신을 붙여야 화면에 표시할 수 있는데, Application Context에는 붙일 윈도우 자체가 없다. 그래서 WindowManager.BadTokenException이라는 예외가 발생한다. 마찬가지로 Application Context의 LayoutInflater로 뷰를 inflate하면, Activity에 적용된 테마(다크 모드, 커스텀 스타일 등)가 반영되지 않는 경우가 생긴다. Application Context는 앱 전역 테마만 알고 있지, 특정 Activity에 설정된 테마는 모르기 때문이다. 화면 전환도 마찬가지다. Application Context에서 startActivity()를 호출하려면 FLAG_ACTIVITY_NEW_TASK 플래그를 반드시 붙여야 하는데, 이는 새로운 태스크로 화면을 띄워야 하기 때문이다. 그 결과 원래 Activity에서 startActivity()를 호출했을 때와는 화면 전환 애니메이션이나 백스택 동작이 달라진다.
그래서 기준은 명확하다. 다이얼로그 표시, 레이아웃 inflate, 화면 전환처럼 "지금 보이는 화면"과 관련된 작업, 그리고 화면이 사라지면 같이 정리되어야 하는 짧은 수명의 작업은 Activity Context를 쓴다. 반대로 싱글턴 객체, 리포지토리, 데이터베이스 인스턴스처럼 앱이 켜져 있는 동안 계속 살아 있어야 하고 특정 화면에 종속되면 안 되는 작업은 Application Context를 쓴다. "메모리 누수가 무서우니까 무조건 Application Context"라는 규칙은 절반만 맞는 얘기다 — UI 작업에까지 그 규칙을 적용하면 크래시나 예상치 못한 동작으로 이어진다.
첫 번째는 Activity Context를 전역으로 저장해두는 것이다. static 필드나 싱글턴 객체에 Activity Context(예: this)를 담아두면, 그 Activity가 화면에서 사라져 onDestroy()가 호출된 뒤에도 static 필드가 여전히 그 Activity를 참조하고 있기 때문에 가비지 컬렉터가 회수하지 못한다. Activity 하나가 통째로 메모리에 남으면 그 안에 딸려 있는 뷰 트리, 리소스까지 전부 함께 새어나간다(메모리 누수). 화면 하나를 들어갔다 나올 때마다 이 현상이 반복되면 앱이 점점 무거워지다가 결국 OutOfMemoryError로 죽는다.
두 번째는 앞서 다룬 것처럼 Application Context를 UI 작업에 무분별하게 쓰는 것이다. 메모리 누수를 피하려고 습관적으로 Application Context만 쓰다가, 다이얼로그가 필요한 순간에도 그대로 넘겨서 크래시를 만나는 경우다.
세 번째는 Context 객체를 직접 생성하려고 시도하는 것이다. Context는 추상 클래스이고, 실제 구현체인 ContextImpl은 프레임워크 내부에서만 다루는 클래스라서 애초에 개발자가 직접 인스턴스화할 방법이 마땅치 않다. 그래서 이 항목은 "실수로 직접 만들다가 사고가 난다"기보다는, Context가 필요할 때는 항상 시스템이 이미 만들어서 건네준 것(Activity 자신, applicationContext, getSystemService()가 반환하는 값 등)을 재사용해야 한다는 원칙으로 이해하는 게 맞다. 리플렉션이나 편법으로 Context를 새로 찍어내려는 시도는 애초에 안드로이드가 의도한 사용법이 아니다.
세 가지를 관통하는 공통점은 하나다. Context는 "누가 만들었고 얼마나 오래 살아야 하는지"가 이미 정해져 있는 객체이기 때문에, 그 수명과 용도를 무시하고 아무 데서나 아무거나 갖다 쓰면 메모리 누수나 크래시로 이어진다는 것이다.
Context가 곧 Activity 아닌가? 아니다. Activity, Service, Application, BroadcastReceiver 등은 각각 자기만의 Context 인스턴스를 따로 갖고 있다. Activity는 Context를 쓸 수 있는 여러 컴포넌트 중 하나일 뿐이지, Context 자체와 동의어가 아니다.
Application Context를 쓰면 무조건 안전한 거 아닌가? 아니다. 메모리 누수를 피하려고 무조건 Application Context를 쓰는 습관을 들이면, 다이얼로그 표시나 테마 적용 레이아웃 inflate 같은 UI 작업에서 크래시나 예상치 못한 동작을 만나게 된다. 수명이 짧고 UI와 관련된 작업은 Activity Context, 앱 전역에서 오래 살아야 하는 객체(싱글턴, 리포지토리 등)에 넘겨줄 Context는 Application Context, 이게 기준이다.
Activity가 ContextImpl을 상속받아서 Context 기능을 쓰는 거 아닌가? 아니다. 앞서 봤듯 Activity(정확히는 그 조상 클래스인 ContextWrapper)는 ContextImpl을 상속하는 게 아니라, ContextImpl 인스턴스를 필드로 들고 있다가 모든 요청을 그쪽에 위임하는 구조다. 상속으로 기능을 물려받는 게 아니라, 내부에 진짜 일을 처리하는 담당자를 하나 두고 그 담당자에게 시키는 구조에 가깝다.
Context는 앱이 리소스와 시스템 서비스에 접근하는 창구이고, 그 실체는 상속이 아니라 조합 구조로 만들어진다 — Activity는 ContextImpl이라는 실제 일꾼을 내부에 담아두고 위임할 뿐이다. 이 구조를 알면 Activity Context와 Application Context가 왜 다른 인스턴스이고 왜 용도가 다른지가 자연스럽게 이해된다. static 필드에 Activity Context를 담아두거나, Application Context를 UI 작업에 무분별하게 쓰거나, Context를 직접 생성하려는 세 가지 실수만 피해도 Context 관련 메모리 누수와 크래시는 대부분 예방할 수 있다.