본문 바로가기
안드로이드(kotlin)

일반 안드로이드 프로젝트를 KMP(Kotlin Multi-platform)로 변경하기

by 기계공학 주인장 2025. 4. 13.
반응형

이번 포스팅에서는 기존의 안드로이드 프로젝트를 KMP(Kotlin Multi-platform)으로 바꿔보는 작업을 해보겠습니다.

 

Kotlin 공식 사이트에 있는 가이드 라인을 따라서 실시했습니다.

 

https://www.jetbrains.com/help/kotlin-multiplatform-dev/multiplatform-integrate-in-existing-app.html

 

Make your Android application work on iOS – tutorial | Kotlin Multiplatform Development

 

www.jetbrains.com

 

참고로 Smaple이 있기 때문에 아래의 과정을 따라하는데 별도의 안드로이드 프로젝트가 필요하지 않습니다. 


KMP(Kotlin Multi-platform)로 변경하기 위한 셋업

다음과 같은 프로램들의 셋업이 필요합니다.

 

  • Android Studio
  • Xcode
    • Mac OS를 갖고 있는 PC가 필요합니다.
  • JDK
    • Android Studio를 사용한다면 프로젝트별로 지정되어 사용되고 있습니다.
  • KMP plug-in
    • Android Studio에서 다운로드 할 수 있습니다.

KMP 플러그인

 

Mac OS를 사용중이라면 다음과 같은 커맨드를 사용해서 kdoctor를 설치하고 자동으로 부족한 부분을 확인할 수 있습니다.

 

brew install kdoctor

 

그리고 다음 커맨드를 사용하여 현재 셋업 상태를 확인할 수 있습니다.

 

kdoctor

 


KMP(Kotlin Multi-platform)로 변경하기 위해 샘플 데이터 확인하기

가이드 라인에서 제공하는 기존 안드로이드 프로젝트 샘플은 다음 github에서 확인할 수 있습니다.

 

여기서 master 브랜치를 다운로드 합니다.

 

https://github.com/Kotlin/kmp-integration-sample

 

GitHub - Kotlin/kmp-integration-sample

Contribute to Kotlin/kmp-integration-sample development by creating an account on GitHub.

github.com

 


KMP(Kotlin Multi-platform) 샘플 프로젝트의 구성

샘플 데이터의 구조를 확인하면 일반적인 안드로이드 프로젝트의 모습을 하고 있는 것을 볼 수 있습니다.

 

 


기존 프로젝트에서 어떤 부분을 KMP에서 공통으로 사용할지 정한다.

KMP으로 이전을 하기 전에 기존 코드에서 어느 부분을 공통으로 사용할지 정해야합니다.

 

주로 Android, iOS 둘 다 공통적으로 사용되는 기능적 부분(=흔히 말하는 비즈니스 로직)을 공통 코드로써 생각해야합니다 .

 

Smaple Project에서는 com.jetbrains.simplelogin.androidapp.data/ 안에 있는 파일들이 그 대상이 됩니다.

 

비즈니스 로직이 있는 패키지


KMP에서 사용할 모듈(module) 만들기

다음으로 위에서 공통으로 사용할 코드를 새롭게 저장할 모듈을 만들어야합니다.

 

1. Settings -> Advanced Settings -> 「Enable experimental Multiplatform IDE features」 을 On으로 합니다.

 

2. Android Studio를 끄고 다시 시작합니다.

 

3. File -> New -> New Module로 새로운 모듈을 만듭니다.

 

4. (KMP plun-in)이 제대로 설치되었다면 다음과 같이 Kotlin Multiplatform Shared Module이라는 것을 선택할 수 있습니다.

     그것을 선택하고 다음과 같이 셋업합니다.

     이후 모듈을 생성합니다.

 

libs.versions.toml에서 에러가 발생하면 libs.versions.toml에 플러그인을 정의해주거나

 

build.gradle.kts(Project)에 다음과 같은 코드를 추가하여

 

alias(libs.plugins.kotlinMultiplatform) apply false
alias(libs.plugins.androidLibrary) apply false

 

플러그인이 특정 모듈에서 명시적으로 실행되게 하면됩니다.

 

5. Sync Project with Gradle Files과Rebuild를 실시하여 문제없이 빌드되는지 확인합니다. 

 

생성된 모듈의 내부를 보면 다음과 같이 되어있는 것을 알 수 있습니다.

 

KMP로 생성한 모듈의 내부


KMP 모듈 등록하기

위에서 KMP에서 사용할 모듈을 만들었기 때문에

 

이번에는 해당 모듈을 사용할 수 있게 등록이 필요합니다.

 

기존 app module의 build.gradle.kts에 다음과 같은 코드를 추가합니다.

 

implementation (project(":app"))

 

 

이후에 모듈을 만들 때 자동으로 생성된 Gretting이라는 클래스 안에 있는 greet 함수를 실행해서

 

지금까지한 모든 셋팅이 정상적으로 적용되었는지 확인합니다. 

 

app/kotlin+java/{package}/data/ui/login/LoginActivity.kt

 

onCreate() 함수 안에 다음과 같은 코드를 넣습니다.

Log.i("Login Activity", "Hello from shared module: " + (Greeting().greet()))

 

(혹시 minSdk가 21로 되어있다면 24로 바꿔주시길 바랍니다.)

 

그리고 프로젝트를 실행하면 다음과 같은 로그가 출력되는 것을 확인할 수 있습니다.

 

 


비즈니스 로직을 KMP 모듈에 넣기

이제 KMP의 모듈 셋업까지 끝났으니 위에서 결정한 data 패키지에 있는 모든 코드를 옮겨줍니다.

 

이렇게 옮기면 각종 경고가 발생하는데 무시하고 그냥 옮기면됩니다.

 

 

일단 위와 같이 모든 코드 파일을 옮기고나면 에러가 발생합니다.

 

에러가 발생하는 이유는 지금 코드는 안드로이드 Native 코드이기 때문에

 

CrossPlatform에서 사용되는 코드로 바꿔야하기 때문입니다.

 

즉, 플랫폼 별로 비즈니스 로직을 조금 바꿔줍니다. 

 

여기서 expect와 actual이라는 기능이 필요합니다.

 

KMP에서 사용되는 기능이며

 

  • expect
    • 일반적으로 공동 부분(common)에서 변수, 함수, 클래스 등의 선언에 사용 된다.
    • 인터페이스처럼 동작하며 플랫폼별 구현이 필요하다.
// commonMain
expect fun getPlatformName(): String

 

  • actual
    • androidMain이나 iosMain에서 expect를 구현할 때 사용된다.
// androidMain
actual fun getPlatformName(): String {
    return "JVM"
}

// iosMain
actual fun getPlatformName(): String {
    return "iOS"
}

 

1. LoginDataSource.kt에 발생 중인 에러를 해결

 

LoginDataSource.kt에 있는 randomUUID 함수의 expect와 actual 정의가 이루어져있지 않기 때문에 에러가 발생중입니다.

 

이를 정의해보겠습니다.

 

2. shared Package의 src/commonMain안에 Utils.kt 파일을 생성하고 다음과 같은 코드 작성

 

3. expect를 작성했으니 src/androidMain 안에 Utils.k 파일을 생성하고 다음과 같은 코드를 작성

 

4. src/iosMain에서도 똑같이 Utils.kt 파일을 작성하고 다음과 같은 코드를 작성

 

5. 플랫폼별 코드를 작성했으니 에러가 발생하는 부분을 수정한다.

기존에 Android Native로 되어있었기 때문에 다음과 같이 수정한다.

 

 

LoginDataValidator.kt에 있던 코드도 Android Native에서만 사용할 수 있는 코드이기 때문에

 

다음과 같이 수정한다.

 

// Before
 private fun isEmailValid(email: String) =   Patterns.EMAIL_ADDRESS.matcher(email).matches()

 

위 코드를 다음과 같이 수정

 

// After
 private fun isEmailValid(email: String) = emailRegex.matches(email)
 
 companion object {
     private val emailRegex =
         ("[a-zA-Z0-9\\+\\.\\_\\%\\-\\+]{1,256}" +
             "\\@" +
             "[a-zA-Z0-9][a-zA-Z0-9\\-]{0,64}" +
             "(" +
             "\\." +
             "[a-zA-Z0-9][a-zA-Z0-9\\-]{0,25}" +
             ")+").toRegex()
 }

 

 

이제 shared 모듈에 에러가 사라졌다면 Run 해서 문제 없이 실행되는지 확인한다.

 


KMP 프로젝트에서 iOS 부분 셋업하기

Android에서 문제 없이 실행되는 것을 알았으니 iOS에서 셋업을 해서 실행해야한다.

 

KMP는 반드시 iOS 프로젝트가 필요하기 때문에 별도의 iOS 프로젝트가 필요하다.

(플러터와 달리 자동으로 iOS 프로젝트까지 생성해주지 않음)

 

1. Xcode를 열고 새로운 프로젝트를 생성

 

 

프로젝트 이름은 아무거나 지정해도 상관없습니다.

 

 

저는 참고로 iosApp 이라는 이름을 사용했습니다.

 

그리고 제일 중요한게 저장 장소입니다.

 

기존 Android 프로젝트의 샘플이 있는 곳 안에서 생성해야합니다.

 

다음과 같이 프로젝트의 루트 디렉토리에 iOS 프로젝트를 생성합니다.

 

 

2. 생성한 iOS 프로젝트 셋업하기

다음과 같이 새로운 Run Script를 만들어줍니다.

 

프로젝트 이름이 simpleLoginOS지만 공식문서를 그대로 가져와서 그럼

 

그리고 다음과 같이 새로운 Run Script를 넣어줍니다.

 

cd "$SRCROOT/.."
./gradlew :shared:embedAndSignAppleFrameworkForXcode

 

 

 

Run Script 작성 완료 후 순서를 Compile Sources 위로 옮겨줍니다.

 

 

그리고 Build Settings의 User Script Sandboxing에 No를 설정합니다.

 

XCode에서 프로젝트를 Build 하여 문제없이 실행되는 것을 확인한다.

 

3. Xcode 프로젝트에서 Shared 모듈 사용하기

새롭게 만든 Xcode 프로젝트의 셋업이 끝났기 때문에 Xcode에서 기존의 Android Studio에 있는 shared 모듈에 접근할 수 있어야합니다.

 

iOS 프로젝트의 루트 디렉토리에 swift 파일을 생성하고 다음과 같은 코드를 넣습니다.

 

참고로 아래의 코드는 iOS의 UI를 생성하는 코드입니다.

 

ContentView.swift
import SwiftUI
import Shared
 
 struct ContentView: View {
     @State private var username: String = ""
     @State private var password: String = ""
 
     @ObservedObject var viewModel: ContentView.ViewModel
 
     var body: some View {
         VStack(spacing: 15.0) {
             ValidatedTextField(titleKey: "Username", secured: false, text: $username, errorMessage: viewModel.formState.usernameError, onChange: {
                 viewModel.loginDataChanged(username: username, password: password)
             })
             ValidatedTextField(titleKey: "Password", secured: true, text: $password, errorMessage: viewModel.formState.passwordError, onChange: {
                 viewModel.loginDataChanged(username: username, password: password)
             })
             Button("Login") {
                 viewModel.login(username: username, password: password)
             }.disabled(!viewModel.formState.isDataValid || (username.isEmpty && password.isEmpty))
         }
         .padding(.all)
     }
 }
 
 struct ValidatedTextField: View {
     let titleKey: String
     let secured: Bool
     @Binding var text: String
     let errorMessage: String?
     let onChange: () -> ()
 
     @ViewBuilder var textField: some View {
         if secured {
             SecureField(titleKey, text: $text)
         }  else {
             TextField(titleKey, text: $text)
         }
     }
 
     var body: some View {
         ZStack {
             textField
                 .textFieldStyle(RoundedBorderTextFieldStyle())
                 .autocapitalization(.none)
                 .onChange(of: text) { _ in
                     onChange()
                 }
             if let errorMessage = errorMessage {
                 HStack {
                     Spacer()
                     FieldTextErrorHint(error: errorMessage)
                 }.padding(.horizontal, 5)
             }
         }
     }
 }
 
 struct FieldTextErrorHint: View {
     let error: String
     @State private var showingAlert = false
 
     var body: some View {
         Button(action: { self.showingAlert = true }) {
             Image(systemName: "exclamationmark.triangle.fill")
                 .foregroundColor(.red)
         }
         .alert(isPresented: $showingAlert) {
             Alert(title: Text("Error"), message: Text(error), dismissButton: .default(Text("Got it!")))
         }
     }
 }
 
 extension ContentView {
 
     struct LoginFormState {
         let usernameError: String?
         let passwordError: String?
         var isDataValid: Bool {
             get { return usernameError == nil && passwordError == nil }
         }
     }
 
     class ViewModel: ObservableObject {
         @Published var formState = LoginFormState(usernameError: nil, passwordError: nil)
 
         let loginValidator: LoginDataValidator
         let loginRepository: LoginRepository
 
         init(loginRepository: LoginRepository, loginValidator: LoginDataValidator) {
             self.loginRepository = loginRepository
             self.loginValidator = loginValidator
         }
 
         func login(username: String, password: String) {
             if let result = loginRepository.login(username: username, password: password) as? ResultSuccess  {
                 print("Successful login. Welcome, \(result.data.displayName)")
             } else {
                 print("Error while logging in")
             }
         }
 
         func loginDataChanged(username: String, password: String) {
             formState = LoginFormState(
                 usernameError: (loginValidator.checkUsername(username: username) as? LoginDataValidator.ResultError)?.message,
                 passwordError: (loginValidator.checkPassword(password: password) as? LoginDataValidator.ResultError)?.message)
         }
     }
 }

 

그리고 main이 되는 swift 파일에 다음과 같은 코드를 적습니다.

 

참고로 위에서 작성한 ContentView UI를 표시하게 하는 역할을 합니다.

 

iosAppApp.swift
import SwiftUI
 import Shared
 
 @main
 struct SimpleLoginIOSApp: App {
     var body: some Scene {
         WindowGroup {
             ContentView(viewModel: .init(loginRepository: LoginRepository(dataSource: LoginDataSource()), loginValidator: LoginDataValidator()))
         }
     }
 }

 

이제 Xcode에서 코드를 다시 Build 하면 다음과 같은 화면을 볼 수 있습니다.

 


Android Studio에서 iOS 프로젝트 실행하기

KMP 프로젝트는 Android Studio에서도 실행할 수 있습니다.

 

1. Android Studio를 열고 Run | Edit configurations를 클릭합니다.

 

2. 다음과 같이 셋업합니다.

 

3. 해당 셋팅으로 Android Studio에서 Run을 하면 똑같이 실행되는 것을 볼 수 있습니다.


일반 안드로이드 프로젝트를 KMP(Kotlin Multi-platform)로 변경하기 정리

  1. KMP plug-in 등 KMP 프로젝트에 필요한 셋업 실시
  2. KMP plug-in을 사용하여 iOS와 공동으로 사용할 모듈 생성
  3. 공동으로 사용할 코드를 새로운 모듈로 옮기기
  4. 공동으로 사용할 코드를 expect와 actual로 각각 정의
  5. 새로운 Xcode 프로젝트를 KMP 프로젝트 안에 생성
  6. 새로운 Xcode 프로젝트를 셋업(설정 및 UI 등)
  7. Android Studio에서도 사용할 수 있게 Configuration 셋업

 

반응형


"이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다."


댓글