정말 오랜만에 블로그 글을 작성하는 것 같다. ㅎ.ㅎ
마지막 글을 작성한 후부터 8개월이라는 시간이 흘렀고, 그동안 YAPP이라는 IT 동아리에 들어가게 되어가 WeSpot이라는 서비스를 만들게 되었다.
우리팀은 Android, iOS, PM, Designer, Backend로 구성된 팀으로, 3달전쯤 성공적으로 앱을 런칭했다.
WeSpot 서비스는 틴즈를 위한 쪽지/문답 서비스를 제공하는 서비스로, 청소년기 학생들이 같은 학교 친구들과 투표를 할 수 있고, 쪽지를 주고 받을 수 있다.
해당 서비스를 관리하는 입장에서, 투표의 질문지의 경우 우리가 직접 작성해서 DB에 삽입해야하는 번거로움이 있었다.
이런 점들에서 팀원들 간 어드민 서비스가 있으면 좋을 것 같다는 이야기가 많이 나왔고, iOS에서 이슈가 있어 Android 보다 진행속도가 늦었는데, 불가피하게 Android 개발에 여유가 생겼었다.
그래서 .! 말로만 듣던 KMM + CMP을 통해서 직접 iOS, Android가 가능한 어드민 서비스를 만들어보면 좋을 것 같아 시작하게 되었다.
글 아래에서는, 직접 구현하는 과정과 발생했던 문제점들을 작성하고자 한다.
초기 설정
KMM 서비스를 개발하기 위해서는, Kotlin에서 공식으로 지원하는 프로젝트 생성 서비스를 이용하면 간편하다.
Kotlin Wizard라고도 하는데, 원하는 플랫폼을 선택하고 다운로드 받으면, 바로 빌드가 가능한 상태로 프로젝트 샘플을 제공한다.
프로젝트를 열어보면, 신기한 구조로 구성되어 있다.
androidMain, commonMain, iosMain과 같이, commonMain을 제외하고 위자드에서 선택했던 플랫폼들의 각 Main 패키지가 나타나게 된다.
build.gradle을 열어보면 알겠지만, 이 3개의 패키지는 각 다른 의존성을 가질 수 있다.
그리고 유추할 수 있듯 androidMain은 안드로이드 의존성을 가지고, 안드로이드 빌드를 위해 사용되며 iosMain은 KMM 내부의 iOS 의존성을 가지고, iOS 빌드를 위해 사용된다.
그리고 당연하듯 androidx, android ~~로 시작되는 의존성은 androidMain외에서는 설치 및 사용할 수 없다.
그럼 iOS는 어떻게 개발하냐고 ??
JetBrains이 구현한 혹은 서드파티에서 구현한 라이브러리를 많이 사용할 수 밖에 없다.
- Android도 점진적으로 지원하고 있는 것으로 보인다. 해당 앱에서 dataStore를 사용했는데, dataStore도 KMM을 지원한다.
혹은 우리는 expect / actual 키워드를 사용할 수 있다.
expect는 commonMain에서 각 플랫폼 main에서 구현할 함수 인터페이스를 제공한다.
actual은 androidMain/iosMain에서 expect 메소드를 구현할 수 있다.
이를 통해서, expect 메소드를 미리 생성하여 사용하고, 내부 구현은 actual 메소드를 통해서 의존성에 맞는 라이브러리를 적용할 수 있다.
가장 많이 사용하는 예시는 Ktor를 사용할 때를 예시로 들 수 있는데, 우리는 Ktor에서 CIO 엔진을 쓸수도 있겠지만, 조금더 안정적인 Okhttp를 선호할 것이다.
다만, Okhttp는 Android만을 지원하고, iOS는 지원하지 않는다. Ktor에서 iOS는 Darwin 엔진을 가장 많이 사용하는 듯 해보인다.
commonMain에서는 expect 키워드를 활용하여, 아래와 같이 구현할 수 있고
package com.wespot.staff.data.di
import org.koin.core.module.Module
import kotlin.reflect.KClass
expect val dataModule: Module
androidMain에서는 actual 키워드를 활용하여, Okhttp 엔진을 가진 Ktor 객체를 생성할 수 있다.
public actual val dataModule: Module = module {
// Remote
single {
HttpClient(OkHttp) {
defaultKtorConfig()
}
}
그리고 iosMain에서는 actual 키워드를 활용하여, Darwin 엔진을 가진 ktor 객체를 생성할 수 있다.
public actual val dataModule: Module = module {
// Remote
single {
HttpClient(Darwin) {
defaultKtorConfig()
}
}
이런 방식과 같이, 양 플랫폼을 지원하는 라이브러리를 사용하거나, expect/actual 키워드를 활용하여 각 의존성에 맞게 구현할 수 있다.
- droidKaigi의 경우, expect/actual 키워드를 사용해서 Android만 Hilt로 설정하고 나머지는 koin을 적용하고 있었다.
의존성 구성
나는 아래와 같은 의존성들을 활용했다.
기본적으로 Koin을 통해 의존성 관리를 했고, Ktor를 통해 네트워크 연결을 구현했다.
그리고 화면 Navigation을 위해 Decompose라는 라이브러리를 활용했다.
BuildConfig, Firebase의 경우 기존 라이브러리가 사용되지 않아, 서드파티 라이브러리를 적용하여 구현했다.
위에서 사용된 라이브러리에 대한 이야기를 해보고자한다.
Decompose
Decompose에서는 위와 같이 Decompose에 대한 장점을 소개한다.
내가 생각하기에 Decompose의 장점은, NavGraph로 주로 이야기하는 Component 간 처리가 용이한 것 같다.
각 전환되는 Screen을 각 컴포넌트와 매칭하는데, 이 컴포넌트도 계층 관계가 존재한다. 부모 컴포넌트는 자식 컴포넌트만 알고 있고, 상위 컴포넌트는 알지 못한다.
- 이런 기능이 다중 모듈에서 빛을 발하는 것 같은데, 전체 app 모듈에서는 차상위 부모 컴포넌트만 스위칭하고, 부모 컴포넌트 내부에서 자식 컴포넌트 스위칭은 각 모듈에서 담당해서 처리할 수 있다.
가장 먼저 app 모듈 내에 아래와 같이 구현한다.
그 외부에서 화면 전환에 사용하기 위한 퍼블릭 인터페이스를 구현한다.
- stack은 NavigationBackStack의 역할과 같이, 화면 전환시 새로운 RootChild을 push하고, 이전 화면을 불러오는 경우 pop을 수행한다.
- navigateRoot() 함수로 실제 Configuration 간 전환을 수행한다.
- RootChild는 Component 상태를 가지고 있으며, stack 내부에서 관리하는 용도로 사용한다.
interface RootComponent {
val stack: Value<ChildStack<*, RootChild>>
fun navigateRoot(configuration: RootConfiguration)
sealed class RootChild {
class VoteRoot(val component: VoteRootComponent) : RootChild()
class ReportRoot(val component: ReportComponent) : RootChild()
class EntireRoot(val component: EntireRootComponent) : RootChild()
}
}
RootConfiguration을 통해, 화면 전환의 기준이 되는 Configuration을 적용한다.
- 필자는 message, vote, entire 기능 도메인이 나눠져있어서, 해당 기능에 따라 Configuration을 구현했다.
- 여기서의 Configuration은 RootComponent가 가지고 있는 StackNavigation 기준 상태 변수이며, 위에서 설명한 RootChild와 매칭되는 구조이다.
@Serializable
sealed interface RootConfiguration {
@Serializable
data object Message : RootConfiguration
@Serializable
data object Vote : RootConfiguration
@Serializable
data object Entire : RootConfiguration
}
해당 RootComponent의 내부 구현은 다음과 같다.
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.router.stack.ChildStack
import com.arkivanov.decompose.router.stack.StackNavigation
import com.arkivanov.decompose.router.stack.bringToFront
import com.arkivanov.decompose.router.stack.childStack
import com.arkivanov.decompose.router.stack.popTo
import com.arkivanov.decompose.value.Value
class DefaultRootComponent(
componentContext: ComponentContext,
) : RootComponent, ComponentContext by componentContext {
private val navigation = StackNavigation<RootConfiguration>()
override val initialConfiguration: RootConfiguration = RootConfiguration.Vote
override val stack: Value<ChildStack<*, RootChild>> =
childStack(
source = navigation,
serializer = null,
initialConfiguration = initialConfiguration,
handleBackButton = true, // Automatically pop from the stack on back button presses
childFactory = ::createChild,
)
private fun createChild(config: RootConfiguration, componentContext: ComponentContext): RootChild =
when (config) {
is RootConfiguration.Vote -> RootChild.VoteRoot(voteComponent(componentContext))
is RootConfiguration.Message -> RootChild.ReportRoot(messageComponent(componentContext))
is RootConfiguration.Entire -> RootChild.EntireRoot(entireComponent(componentContext))
}
private fun voteComponent(componentContext: ComponentContext): VoteRootComponent =
DefaultVoteRootComponent(componentContext = componentContext)
private fun messageComponent(componentContext: ComponentContext): ReportComponent =
ReportComponent(componentContext = componentContext)
private fun entireComponent(componentContext: ComponentContext): EntireRootComponent =
DefaultEntireRootComponent(componentContext = componentContext)
override fun navigateRoot(configuration: RootConfiguration) {
navigation.bringToFront(configuration)
}
}
StackNavigation 메소드를 통해서 Naivgation을 구현하게 되며, 아래와 같은 프로세스를 가진다.
1. StackNavigation 메소드에 전환을 원하는 RootConfiguration을 전달하면서 요청한다.
2. Stack 내부에서 RootConfiguration과 매칭되는 RootChild를 불러온다.
3. RootChild에 따라서 각 맞는 Screen 함수를 요청하고, Screen 파라미터에 Component를 전달한다.
4. Screen에서 전달받은 Component를 통해 화면전환을 요청한다.
그리고 위 DefaultRootComponent 코드를 확인하자.
- navigation 변수는 RootConfiguration으로 구성된 StackNavigation 객체를 할당한다.
- stack 변수는 RootChild로 구성된 NavigationBackStack을 가지고 있다.
- createChiild 함수에서 RootConfiguration에 맞는 RootChild를 생성한다.
자 이제, 실제 Compose 환경에서 적용해보자.
androidMain/MainActivity.kt
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val root = retainedComponent { DefaultRootComponent(it) }
setContent {
WeSpotStaffApp(root)
}
}
}
iosMain/IosWeSpotStaffApp.kt
fun WeSpotStaffAppController(): UIViewController = ComposeUIViewController {
val root = remember { DefaultRootComponent(DefaultComponentContext(LifecycleRegistry())) }
WeSpotStaffApp(root)
}
여기서 WeSpotStaffApp()은 각 플랫폼별 공통되는 Compose 환경이다.
각 플랫폼별 최상위에서 Decompose Component를 생성해서, Compose 환경에 전달한다.
commomMain/WeSpotStaffApp.kt
@Composable
fun WeSpotStaffApp(
component: RootComponent,
viewModel: RootViewModel = koinViewModel(),
) {
val childStack by component.stack.subscribeAsState()
WeSpotTheme {
Scaffold(
modifier = Modifier.fillMaxSize(),
bottomBar = {
BottomNavigationBar(component = component, child = childStack.active.instance)
}
) {
Box(modifier = Modifier.padding(it)) {
AppNavigation(childStack)
}
}
}
}
@Composable
fun AppNavigation(childStack: ChildStack<*, RootChild>) {
Children(
stack = childStack,
animation = stackAnimation(fade()),
) {
when (val child = it.instance) {
is RootChild.VoteRoot -> VoteNavigation(component = child.component)
is RootChild.ReportRoot -> ReportScreen(component = child.component)
is RootChild.EntireRoot -> EntireNavigation(component = child.component)
}
}
}
NavigationStack은 상태 옵저빙 메소드(subscribeAsState)로 제공하여, 상태 변경이 발생했을 때, 화면을 변경하는 구조로 적용했다.
옵저빙하며, 각 RootChild에 맞는 모듈의 최상위 Naivgation 컴포넌트를 불러오고, RootChild에 맞는 Component를 다시 제공함으로써, 각 기능 모듈에서도 Naivgation이 가능하게 한다.
Decompose를 적용하면서, Staff가 아닌 WeSpot 앱에서도 서드파티 Navigation 라이브러리(ComposeDestination)을 적용했는데,
해당 라이브러리보다 확실히 러닝 커브가 존재하는 것 같았다. 다만 위에서 언급한 대로 Component 간 계층구조, KotlinSerialization을 사용하여 String이 아닌 Arugment를 전달할 수 있다는 측면에서 용이한 것 같다 .
멀티 플랫폼 서비스를 구현하면서,
Multi Platform Application 개발을 진행하며, 아쉬웠던 점이 아직 지원하는 라이브러리가 많이 없어, 서드 파티에 많이 의존해야 했다.
그래도 정말 좋았던 건, 전체 개발 기간이 2주 정도 ? 들었던 것 같다.
직접 스위프트를 배워서 직접 iOS 앱을 빌드해야 했다고 생각한다면 2주는 정말 기적과 같은 기간이다.
그도 그렇것이, 정말 KMM을 도입하기 정말 좋은 조건이였다고 생각하는게, 기존 디자인 시스템과 유틸성 모듈이 구분되어 있어서, 해당 모듈을 드래그 앤 드랍으로 가져와 그대로 사용하기만 하면 되었다.
나와 같이, 기존에 존재하는 모듈을 재사용할 수 있는 환경이라면, KMM을 적극 추천해볼 것 같다.
번외로, iOS 앱 배포시에 정말 많이 고생했다..
안드로이드는 개발자 계정과 관계 없이 자유롭게 테스트 설치 파일을 공유할 수 있는데, 애플은 그게 불가능하다.
개발자 계정이 존재한다는 가정하에, 총 5개의 죽음의 단계가 존재하며 이 단계를 모두 통과해야 한다. (각오한 자만 찾아보시길..)
- 당연하게도 개발자 계정이 없으면 테스트 앱 배포는 꿈에도 못꾼다 ^_^
여기서 다 못 쓴, KMM Convention Plugins, Koin 설정과 같은 세부사항은 직접 레포지토리에서 확인하시면 좋을 것 같습니다 ㅎㅎ
https://github.com/wespot-bff/WeSpot-Staff
GitHub - wespot-bff/WeSpot-Staff: WeSpot-Staff Service For BFF
WeSpot-Staff Service For BFF. Contribute to wespot-bff/WeSpot-Staff development by creating an account on GitHub.
github.com
새로운 기술을 따로 개인적으로 공부하는 것보다 실제 서비스에 적용하면서, 경험해보는 것이 정말 좋은 배움이 되는 것 같다.
올해도 수고 많으셨습니다 ~! 내년에도 잘 부탁드려요 😊