Todo 앱에 서버 동기화 기능을 붙이면서 네트워킹 라이브러리를 골라야 했습니다. 안드로이드에서는 Retrofit + OkHttp가 익숙하지만, Retrofit은 JVM 전용이라 iOS 타겟에서 그대로 쓸 수 없습니다. KMP에서 공식적으로 밀고 있는 Ktor Client로 네트워킹 레이어를 구성한 과정을 정리합니다.

왜 Retrofit이 아니라 Ktor인가

Retrofit은 어노테이션 기반 인터페이스를 리플렉션과 OkHttp로 구현체를 만드는 방식입니다. 이 구현체 생성 과정이 JVM 바이트코드에 의존하기 때문에 commonMain에 둘 수 없고, iOS에서는 아예 동작하지 않습니다.

Ktor Client는 순수 Kotlin으로 작성된 클라이언트로, 실제 HTTP 통신을 담당하는 부분(엔진)만 플랫폼별로 교체하는 구조입니다. commonMain에는 요청을 만드는 코드만 두고, 엔진만 안드로이드는 OkHttp, iOS는 Darwin(NSURLSession 기반)으로 바꿔 끼우면 됩니다.

핵심 개념: HttpClient와 엔진

Ktor Client는 HttpClient 객체 하나로 요청을 보내고, 이 객체가 내부적으로 사용할 엔진을 생성자에서 받습니다. 엔진은 플랫폼마다 다른 아티팩트로 제공됩니다.

// composeApp/build.gradle.kts
kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation("io.ktor:ktor-client-core:3.0.3")
            implementation("io.ktor:ktor-client-content-negotiation:3.0.3")
            implementation("io.ktor:ktor-serialization-kotlinx-json:3.0.3")
            implementation("io.ktor:ktor-client-logging:3.0.3")
        }
        androidMain.dependencies {
            implementation("io.ktor:ktor-client-okhttp:3.0.3")
        }
        iosMain.dependencies {
            implementation("io.ktor:ktor-client-darwin:3.0.3")
        }
    }
}

엔진 선택은 expect/actual로 분리합니다.

// commonMain/network/PlatformEngine.kt
expect fun platformHttpClientEngine(): HttpClientEngine
// androidMain/network/PlatformEngine.kt
actual fun platformHttpClientEngine(): HttpClientEngine = OkHttp.create()
// iosMain/network/PlatformEngine.kt
actual fun platformHttpClientEngine(): HttpClientEngine = Darwin.create()

실전 예시: Todo API 클라이언트 만들기

commonMain에 HttpClient를 구성하고, JSON 직렬화와 로깅을 플러그인으로 설치합니다.

// commonMain/network/HttpClientFactory.kt
fun createHttpClient(engine: HttpClientEngine): HttpClient = HttpClient(engine) {
    install(ContentNegotiation) {
        json(Json { ignoreUnknownKeys = true })
    }
    install(Logging) {
        level = LogLevel.INFO
    }
    install(HttpTimeout) {
        requestTimeoutMillis = 10_000
    }
}

DTO와 API 호출 코드는 commonMain에 그대로 둘 수 있습니다.

// commonMain/network/TodoDto.kt
@Serializable
data class TodoDto(
    val id: String,
    val title: String,
    val done: Boolean,
)
// commonMain/network/TodoApi.kt
class TodoApi(private val client: HttpClient) {
    suspend fun fetchTodos(): List<TodoDto> =
        client.get("https://api.todoapp.dev/todos").body()

    suspend fun createTodo(title: String): TodoDto =
        client.post("https://api.todoapp.dev/todos") {
            contentType(ContentType.Application.Json)
            setBody(TodoDto(id = "", title = title, done = false))
        }.body()
}

Koin 모듈에 등록해서 플랫폼별 엔진을 주입합니다.

// commonMain/di/NetworkModule.kt
val networkModule = module {
    single { createHttpClient(platformHttpClientEngine()) }
    single { TodoApi(get()) }
}

에러 처리

서버가 4xx/5xx를 반환하면 기본적으로 ClientRequestException, ServerResponseException이 던져집니다. 응답 바디를 그대로 파싱하려 하면 원하는 예외 대신 직렬화 에러가 먼저 발생하는 경우가 있어서 주의가 필요합니다.

kotlinx.serialization.json.internal.JsonDecodingException: Unexpected JSON token at offset 0

이 에러는 서버가 200이 아닌 상태 코드에서 HTML 에러 페이지를 반환했는데, body()로 곧바로 DTO 파싱을 시도해서 발생했습니다. 상태 코드를 먼저 확인하고 파싱하도록 바꿨습니다.

suspend fun fetchTodos(): Result<List<TodoDto>> = runCatching {
    val response = client.get("https://api.todoapp.dev/todos")
    if (!response.status.isSuccess()) {
        error("서버 응답 실패: ${response.status}")
    }
    response.body()
}

장단점 정리

  • 장점: commonMain에서 API 호출 코드를 한 번만 작성하면 안드로이드/iOS 양쪽에서 동일하게 동작합니다. ContentNegotiation 플러그인이 kotlinx.serialization과 바로 연동돼서 별도 변환 코드가 필요 없습니다.
  • 단점: Retrofit·OkHttp 생태계에 익숙하다면 인터셉터, 캐싱 전략 등을 Ktor 방식으로 다시 익혀야 합니다. 플러그인 문서가 Retrofit만큼 자료가 많지 않아서, 공식 문서와 소스 코드를 직접 봐야 하는 경우가 종종 있었습니다.

Todo 앱처럼 API 엔드포인트가 몇 개 안 되는 프로젝트에서는 도입 비용이 크지 않았습니다. iOS 타겟을 처음부터 고려하고 있다면 Ktor로 시작하는 편이 나중에 마이그레이션하는 것보다 수월합니다.