안드로이드에서는 Log.d, Log.e로 Logcat에 로그를 남기고 iOS에서는 NSLog나 os_log를 씁니다. commonMain에 공통 로직을 작성하다 로그 한 줄이 필요해지면 이 차이가 바로 걸립니다. expect fun log(message: String)을 선언하고 각 플랫폼에 actual을 구현하는 방식으로 시작할 수는 있지만, 로그 레벨(디버그/정보/에러)과 태그까지 챙기려면 함수 시그니처가 금방 지저분해집니다. 이 문제를 Touchlab에서 만든 Kermit 라이브러리로 해결한 과정을 정리합니다.
핵심 개념
Kermit은 Logger라는 단일 진입점을 commonMain에 제공하고, 실제 출력을 담당하는 LogWriter만 플랫폼별로 교체하는 구조입니다. Ktor의 엔진 교체 방식과 비슷하지만, Kermit은 플랫폼별 LogWriter를 라이브러리가 이미 구현해서 platformLogWriter()로 제공하기 때문에 별도의 expect/actual 코드를 직접 작성할 필요가 없습니다.
// composeApp/build.gradle.kts
kotlin {
sourceSets {
commonMain.dependencies {
implementation("co.touchlab:kermit:2.0.4")
}
}
}
의존성만 commonMain에 추가하면 끝입니다. 안드로이드는 내부적으로 Logcat에, iOS는 NSLog에 출력하도록 platformLogWriter()가 알아서 분기합니다.
실전 예시
앱 시작 시점에 Logger를 한 번 초기화하고, 최소 로그 레벨과 기본 태그를 지정합니다.
// commonMain/logging/AppLogger.kt
import co.touchlab.kermit.Logger
import co.touchlab.kermit.Severity
import co.touchlab.kermit.platformLogWriter
fun initLogger(isDebug: Boolean) {
Logger.setLogWriters(platformLogWriter())
Logger.setMinSeverity(if (isDebug) Severity.Debug else Severity.Info)
}
initLogger는 앱 진입점에서 한 번만 호출합니다. 안드로이드는 Application.onCreate, iOS는 Compose Multiplatform 진입점인 MainViewController 생성 직전에 호출하면 됩니다.
레이어별로 태그를 구분하고 싶을 때는 Logger.withTag로 자식 로거를 만듭니다.
// commonMain/data/UserRepository.kt
import co.touchlab.kermit.Logger
class UserRepository(private val api: UserApi) {
private val logger = Logger.withTag("UserRepository")
suspend fun fetchUser(id: String): User {
logger.d { "fetchUser 시작: id=$id" }
return try {
api.getUser(id).also {
logger.d { "fetchUser 성공: ${it.name}" }
}
} catch (e: Exception) {
logger.e(e) { "fetchUser 실패: id=$id" }
throw e
}
}
}
logger.d { "..." }처럼 람다로 메시지를 받는 API라서, Severity.Info 이상만 출력하도록 설정한 릴리즈 빌드에서는 디버그 로그 문자열 자체가 만들어지지 않습니다. 문자열 템플릿 계산 비용을 신경 쓰지 않아도 되는 부분입니다.
겪은 문제: 초기화 순서를 놓치면 로그가 조용히 사라짐
UserRepository처럼 클래스 프로퍼티로 Logger.withTag(...)를 호출해서 로거를 만들어 뒀는데, 앱을 켜자마자 나와야 할 로그가 하나도 찍히지 않는 문제가 있었습니다. Koin 모듈에서 UserRepository를 싱글턴으로 등록해 뒀는데, 이 모듈이 초기화되는 시점이 initLogger() 호출보다 앞서 있었습니다.
// commonMain/di/AppModule.kt
val appModule = module {
single { UserRepository(get()) } // Koin 초기화 시점에 Logger.withTag 호출됨
}
fun bootstrap(isDebug: Boolean) {
startKoin { modules(appModule) } // initLogger보다 먼저 실행되고 있었음
initLogger(isDebug)
}
Logger.withTag로 만든 자식 로거는 전역 Logger 설정을 그대로 참조하기 때문에 withTag 호출 순서 자체는 문제가 아니었습니다. 진짜 원인은 setLogWriters가 아직 호출되지 않은 상태에서 로그 호출이 먼저 일어났다는 것입니다. LogWriter가 설정되기 전까지는 아무 곳에도 출력되지 않고 로그가 그대로 버려지는데, 예외도 던지지 않아서 원인을 찾는 데 시간이 걸렸습니다. bootstrap에서 initLogger(isDebug)를 startKoin 호출보다 먼저 실행하도록 순서를 바꾸고 나서 해결됐습니다.
장단점 정리
장점
- 플랫폼별
LogWriter를 직접 구현할 필요 없이platformLogWriter()하나로 안드로이드/iOS 모두 커버 Logger.withTag로 클래스·모듈 단위 태그를 붙이면 Logcat에서 필터링하기 편함- 람다 기반 메시지라 릴리즈 빌드에서 불필요한 문자열 연산이 없음
단점
Logger초기화 순서를 놓치면 로그가 조용히 사라지는데, 예외도 없어서 원인을 찾기까지 시간이 걸림- 크래시 리포팅 도구(Crashlytics 등)와 연동하려면
LogWriter를 커스텀 구현해서 추가로 붙여야 함
로그 포맷과 필터링을 매번 플랫폼별로 신경 쓰던 코드베이스라면, Kermit 도입만으로 commonMain 코드의 로깅 관련 분기를 상당히 걷어낼 수 있습니다.