
안드로이드 개발을 하다 보면 Context가 필요한 상황이 정말 자주 발생합니다. HTTP 응답에서 특정 에러 코드를 받았을 때 다이얼로그를 띄워야 하거나, GTM 이벤트 발생 시 토스트 메시지를 보여줘야 하는 경우들이 대표적입니다. 이런 UI 작업들은 모두 Activity의 Context를 필요로 하는데, 이 Context를 어떻게 효율적으로 관리할 것인가가 항상 고민이었습니다.
실무에서 많이 사용하는 방법 중 하나가 Activity Context를 싱글톤으로 관리하는 것입니다. 안티패턴이라는 걸 알면서도 빠른 문제 해결을 위해 이런 코드를 작성하게 됩니다.
object LastActivityManager {
lateinit var lastActivity: AppCompatActivity
}
그리고 각 액티비티에서 이 싱글톤을 업데이트하기 위해 BaseActivity를 만들어서 사용합니다.
abstract class BaseActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setLastActivity()
}
override fun onResume() {
super.onResume()
setLastActivity()
}
private fun setLastActivity() {
LastActivityManager.lastActivity = this
}
}
처음에는 onCreate()에서만 Context를 설정했었는데, A 액티비티에서 B 액티비티로 이동했다가 다시 A로 돌아왔을 때 여전히 B 액티비티의 Context를 참조하는 문제가 발생했습니다. 이를 해결하기 위해 onResume()에도 추가했지만, 이렇게 되니 개발자가 기억해야 할 규칙이 늘어나고 실수 가능성도 높아졌습니다.
또한 BaseActivity를 도입하는 것 자체가 코드 구조를 복잡하게 만들고, 무엇보다 Activity가 소멸된 후에도 싱글톤이 해당 Activity를 참조하고 있어서 메모리 누수가 발생할 위험이 있었습니다. 이런 문제들을 경험하면서 더 나은 방법을 찾아보게 되었습니다.
여러 시행착오를 거쳐 Kotlin의 코루틴과 Flow를 활용한 GlobalEventBus를 구현했습니다. Activity Context를 직접 저장하는 대신 이벤트를 발행하고 구독하는 방식으로 접근한 것입니다.
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.mapNotNull
object GlobalEventBus {
// 이벤트 버스의 전용 코루틴 스코프
private val busScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
// 이벤트를 방출하는 SharedFlow
private val _events = MutableSharedFlow<Event<GlobalEvent>>()
val events = _events.asSharedFlow()
// 이벤트 발행 함수
fun postEvent(event: GlobalEvent) {
busScope.launch {
_events.emit(Event(event))
}
}
fun subscribeEvent(
lifecycleOwner: LifecycleOwner,
lifecycleState: Lifecycle.State = Lifecycle.State.STARTED,
onEvent: (GlobalEvent) -> Unit
) {
lifecycleOwner.lifecycleScope.launch {
lifecycleOwner.repeatOnLifecycle(lifecycleState) {
events
.mapNotNull { event ->
event.getContentIfNotHandled()
}.collect { content ->
onEvent(content)
}
}
}
}
// 생명주기 없이 이벤트를 구독하는 함수 (서비스 등에서 사용)
fun subscribeWithoutLifecycle(
scope: CoroutineScope,
onEvent: (GlobalEvent) -> Unit
) {
scope.launch {
events.collect { event ->
event.getContentIfNotHandled()?.let { content ->
onEvent(content)
}
}
}
}
}
이벤트는 sealed interface로 타입 안전하게 정의했습니다.
sealed interface GlobalEvent {
data class ShowToast(val message: String) : GlobalEvent
data object ShowErrorAccountBlockedGlobalDialog : GlobalEvent
data object ShowErrorTokenExpireGlobalDialog : GlobalEvent
}
그리고 이벤트가 중복으로 처리되는 것을 방지하기 위해 Event 래퍼 클래스를 구현했습니다.
open class Event<out T>(val content: T) {
@Suppress("MemberVisibilityCanBePrivate")
var hasBeenHandled = false
private set
fun getContentIfNotHandled(): T? {
return if (hasBeenHandled) {
null
} else {
hasBeenHandled = true
content
}
}
fun peekContent(): T = content
}
코루틴 스코프를 구성할 때 SupervisorJob()을 사용했는데, 이는 자식 코루틴 중 하나가 실패해도 다른 코루틴들은 계속 실행되도록 하기 위함입니다. 이벤트 버스의 특성상 한 이벤트 처리 실패가 전체 시스템에 영향을 주면 안 되기 때문입니다.
디스패처는 Dispatchers.Default를 선택했습니다. 이벤트 전파는 주로 CPU 바운드 작업이고, Main 디스패처 사용 시 UI 스레드 블로킹 우려가 있으며, IO 디스패처는 네트워크나 파일 작업에 최적화되어 있어 단순 이벤트 전파에는 적합하지 않다고 판단했습니다.
repeatOnLifecycle은 액티비티나 프래그먼트의 생명주기를 고려해서 특정 상태에서만 이벤트를 수신하도록 보장하고, 메모리 누수를 방지하는 데 중요한 역할을 합니다.
현재 진행 중인 프로젝트에서는 싱글 액티비티 아키텍처를 사용하고 있어서 메인 액티비티에서 GlobalEventBus를 구독하도록 구현했습니다.
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// ...
setupGlobalEventSubscription()
}
private fun setupGlobalEventSubscription() {
GlobalEventBus.subscribeEvent(this) { event ->
when (event) {
is GlobalEvent.ShowToast -> {
Toast.makeText(this, event.message, Toast.LENGTH_SHORT).show()
}
is GlobalEvent.ShowErrorAccountBlockedGlobalDialog -> {
showAccountBlockedDialog()
}
is GlobalEvent.ShowErrorTokenExpireGlobalDialog -> {
showTokenExpireDialog()
}
}
}
}
// 다이얼로그 표시 메서드들...
}
HTTP Interceptor에서는 다음과 같이 이벤트를 발행합니다.
class AuthInterceptor : Interceptor {
private fun handleHttpErrorRes(raw: String) {
val httpError = JsonUtil.gson.fromJson(raw, HttpError::class.java)
when {
httpError.isTokenExpireError() -> {
GlobalEventBus.postEvent(GlobalEvent.ShowErrorTokenExpireDialog)
}
httpError.isAccountBlockingError() -> {
GlobalEventBus.postEvent(GlobalEvent.ShowErrorAccountBlockedDialog)
}
}
}
}
이 패턴을 도입한 후 여러 측면에서 개선을 경험했습니다. 가장 큰 변화는 Activity Context를 싱글톤으로 저장하는 안티패턴을 완전히 제거할 수 있었다는 점입니다. UI 관련 작업이 해당 Activity나 Fragment에서 처리되면서 관심사 분리가 더욱 명확해졌습니다.
개발자 실수 가능성도 현저히 줄어들었습니다. Context 세팅을 잊거나 잘못 처리할 위험이 사라졌고, repeatOnLifecycle을 통해 앱의 생명주기에 맞춰 이벤트가 안전하게 처리됩니다. 새로운 글로벌 이벤트 타입 추가도 매우 간단해졌고, Event 클래스가 이벤트 중복 처리를 효과적으로 방지해줍니다.
이 패턴은 다음과 같은 상황에서 특히 유용합니다. Activity Context를 비-UI 클래스에서 필요로 할 때, 전역적인 이벤트 표시가 필요할 때, 네트워크 요청 결과에 따른 사용자 알림이 필요할 때, 그리고 여러 모듈 간 느슨한 결합을 유지하면서 통신해야 할 때입니다.
특히 중간 규모 이상의 프로젝트에서 여러 개발자가 함께 작업할 때 이 패턴의 장점이 더욱 두드러집니다. 명확한 규칙과 구조를 제공하므로 팀 내 코드 일관성을 유지하는 데도 도움이 됩니다.
Context를 싱글톤으로 관리하는 방식은 단기적으로는 편리해 보이지만 장기적으로는 여러 문제를 야기할 수 있습니다. 실제 프로젝트에서 이런 문제들을 겪어보면서 GlobalEventBus 패턴이 더 안전하고 유지보수하기 좋은 대안이라는 것을 확인할 수 있었습니다.
Flow와 코루틴을 활용한 이 접근 방식은 안드로이드의 최신 권장사항을 따르면서도 실질적인 문제를 해결합니다. 특히 MVI 아키텍처나 Clean Architecture를 사용하는 프로젝트에서는 이 패턴이 더욱 자연스럽게 통합될 수 있을 것으로 생각합니다. 비슷한 고민을 하고 계신 개발자분들께 도움이 되었으면 좋겠습니다.