CMP에서 Context를 어떻게 사용해?

K_Gs·2025년 7월 27일
post-thumbnail

배경

CMP(compose multiplatform) 개발을 하다보면 대부분의 경우 공통 로직 및 화면으로 처리가 되지만, 일부는 각 플랫폼의 맞는 로직을 직접 작성해주어야해요.

호출코드가 플랫폼 마다 다른 파일 선택기 같은 경우가 그렇죠.

CMP에서는 이를 편하게 구현하기 위해 expect/actual 형식을 제공해요.

expect : 공통 코드에서 호출하는 함수 혹은 클래스 ( 인터페이스 느낌 )
actual : expect 함수가 호출되었을때 실행 중인 플랫폼에 따라 실제로 호출되는 함수 혹은 클래스 (구현체 느낌)

expect인 함수, 클래스의 패키지 구조와 동일한 곳에 동일한 선언을 가지는 함수, 클래스를 두게되면 자동으로 매칭 시켜서 호출하게 되는 거죠!

문제 상황

아주 편리한 기능인데, 구현을 하던 중 저는 문제를 하나 발견했어요.

위의 파일 선택기 같은 구현은 iOS,Desktop은 그냥 하면 되는데 Android에서는 Context를 필요로 해요.

Android에만 Context를 제공해주면 안되나 싶지만, 위에서 이야기하였듯 expect/actual 형태는 선언이 동일해야 하고, 그렇다고 별도로 Android 만 Context를 주기 위해선 공용 코드에서 코드를 분기 처리해야해서 바람직하지 않아요.

오늘은 이런 Context를 필요로하는 함수를 구현하는 과정을 기록해보려해요.

목표

  • Composable인 곳에서도, Composable이 아닌 곳에서도, Android의 Context를 사용할 수 있게 한다.
  • iOS, Desktop의 actual과 호환되야한다.

해결 시도

목표에 Composable인 곳에서도 Android의 Context를 사용할 수 있게 한다 하였지만, 만약 expect/actual이 Composable이라면 LocalContext.current 를 통해 쉽게 Context를 얻어올 수 있어요.

그렇기에 아래 시도는 onClick과 같이 Composable 함수를 호출 할 수 없는 환경에서의 고민이라 봐주시면 될 것 같아요!

상황 1: composable이 아닌 곳에서 context를 얻을 수 없다.

또한, Context를 object와 같은 전역에서 접근할 수 있는 곳에 저장하는 건 메모리릭의 위험이 있어 시도하지 않았어요.

DI로 직접 Context 주입받기

가장 처음으로 시도한 것은 DI를 통해 Context를 직접 주입 받는 방식이였어요.

CMP에서 쓰이는 DI 라이브러리인 Koin을 사용하면 Android Context를 DI 모듈에 선언하고 주입 받을 수 있어요.

//DI 모듈에 선언
val targetModule  = module {
    single<Context> { androidContext() }
}

//MainApplication
androidContext(this@MainApplication)

이렇게하면 코드에서 get()이나 by inject()를 통해 주입 받을 수 있는거죠.

기존의 Hilt에서 Koin으로 마이그레이션 한지 얼마 안되어 조금 긴가민가 하긴했지만, 그래도 다량의 문서와 정보를 찾아서 되겠구나 판단 한 뒤 바로 적용했어요.

expect fun filePikcer()

actual fun filePicker(){
	val context: Context = get<Context>(Context::class.java)
    //... context 사용
}

이제 되겠구나 하며 코드를 실행해보았는데, 로그캣상에서 에러 스택 트레이스가 가득 차서 시작 지점이 보이지 않을 정도로 찍히기 시작했어요.

스택 트레이스릃 확인하고 여러번의 테스트를 통해 아래와 같은 생각을 하였어요.

  1. DI 쪽에서 발생한 오류이다.
  2. expect 호출시에만 발생하기에 get에서 문제가 생긴거다.
  3. 스택트레이스의 양으로 보아 무한루프, 순환참조 등이 발생했다.

이후 더 찾아보았을때 expect/actual은 koin의 DI를 담당하는 koin context가 접근할 수 없는 구역이라는 것을 알게 되었어요.

상황 2: DI를 직접 사용할 수는 없다.

class를 통해 주입하기

DI를 직접 주입 할 수 없으니 다음으로는 생성자로 받아보자는 생각을 하였어요. 단 Android에서만 Context가 필요하니 인터페이스를 만들고 각 플랫폼에서 이를 구현체하는 방식으로 하기로 했습니다.

interface FilePicker {
  //...
}

class FilePickerAndroid(context: Context): FilePicker {
   //...
}

이제 이거를 DI를 통해 주입 받는거죠.

val targetModule  = module {
    single<Context> { androidContext() }
    single<FilePicker> { FilePickerAndroid(get()) }

함수 호출시에 expect라면 koin context가 도달 할 수 없는 것이 문제였으니, expect를 사용하지 않도록 우회 한 것입니다.

잘 작동하니 좋긴한데...

문제점은 이렇게 하게 되면 context가 필요한 기능을 하나 만들때마다, 인터페이스를 만들고, 구현체를 3개 만들어야했어요.

아니면 하나의 인터페이스에 context가 필요한 기능을 모두 몰아 넣으면 되겠지만, 이것은 유지보수상 절대 안된다 생각하였구요.

상황 3: 각 기능에 대해 인터페이스/구현체를 만들기에는 코드가 너무 많아진다

그래서 다시한번 시도를 하게 됩니다.

그냥 함수에서 인자로 받기

사실 가장 간단한 방식은 그냥 expect에서 부터 context를 인자로 받고 호출시에 주입해 주는 방식입니다.

expect fun filePikcer(context: Context)

actual fun filePicker(context: Context){
    //... context 사용
}

단지 이 방식은 공통 코드에서 호출시 Context라는 것이 아예 없어(Android에만 존재) 사용할 수 없는게 문제죠.

상황 4: 함수에서 그냥 주입받자니 다른 플랫폼에 컨텍스트가 없다

Context를 어떻게할까.. 고민 중 아이디어를 하나 떠올렸습니다.

해결

래핑하면 되겠네?

Context를 래핑하는 클래스를 만들어 각 플랫폼에서 구현하도록 하면 공통으로 사용할 수 있는 Context가 생기게 됩니다.

내부에는 Android만 실제 Context를 받고 나머지는 껍데기만 존재합니다.

interface PlatformContext

class AndroidPlatformContext(val context: Context): PlatformContext

class IosPlatformContext: PlatformContext

그리고 filePicker 또한 이 플랫폼 Context를 받도록 하면 공통 코드에서 사용할 수 있게 됩니다.

expect fun filePikcer(context: PlatformContext)

이렇게 해서 상황 4를 해결했습니다!

또한 이제 context가 필요한 경우 이 PlatformContext를 넘기면 되니 상황 3 또한 해결되게 됩니다.

그런데 PlatformContext는 그럼 어디서 줄까요?

저는 Composable인 expect/actual 함수를 하나 만들었습니다.

@Composable
expect fun getPlatformContext(): PlatformContext
 
@Composable
actual fun getPlatformContext(): PlatformContext {
    val context = LocalContext.current
    return AndroidPlatformContext(context)
}
  
@Composable
actual fun getPlatformContext(): PlatformContext{
    return IosPlatformContext()
}

상황 1에서 이야기하였듯 composable이 아닌 곳에서는 context를 얻을 수 없지만, composable이라면 쉽게 context를 얻을 수 있습니다.

즉, 미리 Composable인 코드 상에서 PlatformContext를 얻어두면 onClick과 같은 Composable이 아닌 곳에서도 이를 사용할 수 있는 것이지요!

//loginScreen
val platformContext = getPlatformContext()

LoginBtn(
  color = ForeverColors.kakao,
  text = stringResource(Res.string.login_kakao_text)
){
  tryKakaoLogin(platformContext)
}

이렇게해서 DI를 쓰지 않고 Context를 전달 할 수 있게 되었습니다. 상황 2까지 다 해결되었네요!

얻은 점

  • CMP와 같은 멀티 플랫폼에서는 네이티브의 경우보다 추상화와 구조설계가 더 중요하다는 것을 알게되었어요. 실제로 구조에 대한 고민을 통해 문제를 이쁘게 해결한 것 같아요!
  • DI와 expect/actual은 함께 쓰기 어렵다는 걸 알게되었어요. 이는 Koin에 대한 이해도도 있어야 할 것 같아서, 앞으로는 단순하게 사용하는게 아니라 Koin 공부도 앞으로 해봐야 할 것 같아요.
profile
아직도 모르는게 많으니, 알아가고 싶은 것도 많다

0개의 댓글