Retrofit에서 Ktor로: 안드로이드 HTTP 클라이언트 마이그레이션

2026. 1. 8. 17:11·Android

Intro

현재 진행 중인 안드로이드 프로젝트를 KMP(Kotlin Multiplatform)로 전환하기로 결정하면서, Android 의존성을 가진 라이브러리를 멀티플랫폼 환경에서 작동하는 라이브러리로 교체해야 했다. 그중에서도 HTTP 클라이언트를 Retrofit에서 Ktor로 마이그레이션한 과정을 작성하려고 한다.

 

Retrofit은 OkHttp 위에 구축된 고수준 추상화 라이브러리이다. 인터페이스만 정의하면 구현체를 자동으로 만들어주는 편리함 때문에, KMP를 고려하지 않았던 프로젝트 초기에는 당연한 선택지였다.

 

Ktor은 JetBrains에서 개발한 Kotlin과 Coroutines 기반의 비동기 프레임워크로, 멀티플랫폼 네트워킹을 지원한다.

API 요청을 위해 Retrofit은 인터페이스만 만들면 되지만, Ktor는 요청 함수 내부를 직접 구현해야 한다는 단점이 있다.

 

Retrofit:

interface GameApiService {
    @GET("api/v1/games")
    suspend fun getGames(
        @Query("date") date: String
    ): List<GameResponse>
}

Ktor:

class GameApiService(private val client: HttpClient) {
    suspend fun getGames(date: String): GameResponse {
        val response = client.get("api/v1/games") {
            parameter("date", date)
        }
        return response.body() 
    }
}

 

코드 자체가 어려운 건 아니지만, Retrofit의 방식에 익숙한 안드로이드 개발자에게는 다소 번거로운 작업이다. 나 역시 프로젝트에서 사용 중인 40여 개의 API를 일일이 재작성할 생각을 하니 정말 귀찮았다.

 

그래서 찾아낸 해결책이 바로 Ktorfit이다.

 

Ktorfit은 이름에서 느껴지듯, Ktor 위에서 Retrofit 스타일의 코드를 작성할 수 있게 해주는 라이브러리다. KSP를 통해 컴파일 타임에 Ktor 코드를 자동으로 생성해 준다.

기존 Retrofit 코드를 거의 그대로 유지할 수 있다는 장점 때문에 Ktor + Ktorfit 조합을 선택했다.


의존성 설정

Ktor 및 Ktorfit을 위해 추가한 의존성이다. 필요에 따라 달라질 수 있으므로 공식 문서를 참고하여 필요한 의존성을 추가하면 된다.

[libraries]
# Ktor Core & Engine
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }

# Ktor Plugins
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-client-auth = { module = "io.ktor:ktor-client-auth", version.ref = "ktor" }
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }

# Ktorfit
ktorfit-lib = { module = "de.jensklingenberg.ktorfit:ktorfit-lib", version.ref = "ktorfit" }

[plugins]
ktorfit = { id = "de.jensklingenberg.ktorfit", version.ref = "ktorfit" }

 

 

HttpClient 기본 구성

Ktor Client 설정은 Plugin을 넣는 방식으로 이루어진다. 내가 설정한 기본적인 Ktor HttpClient 구성이다.

val httpClient: HttpClient =
    // Client Engine
    HttpClient(OkHttp) {
        // Response Validation
        expectSuccess = true

        // Default Request
        defaultRequest {
            contentType(ContentType.Application.Json)
        }

        // Content Negotiation
        install(ContentNegotiation) {
            json(
                Json {
                    ignoreUnknownKeys = true
                    isLenient = true
                    encodeDefaults = true
                    prettyPrint = true
                },
            )
        }

        // Logging
        install(Logging) {
            level = if (BuildConfig.DEBUG) LogLevel.ALL else LogLevel.NONE
            logger = Logger.ANDROID
        }

        // Timeout
        install(HttpTimeout) {
            requestTimeoutMillis = 30_000 // 요청~응답 전체 시간
            connectTimeoutMillis = 10_000 // 서버 연결 대기 시간
            socketTimeoutMillis = 10_000  // 패킷 간 대기 시간
        }
    }

 

Client Engine

HttpClient 클래스의 파라미터를 통해 엔진을 지정할 수 있다. 엔진은 플랫폼마다 다르니, 여기에서 필요한 엔진을 확인할 수 있다. KMP 프로젝트라면 expect/actual 로 플랫폼별 엔진을 다르게 주입하면 된다. Android는 Okhttp, iOS는 Darwin 엔진을 주로 사용한다고 한다.

 

Response Validation

Ktor는 기본적으로 HTTP 상태 코드로 응답을 검증하지 않는다. 즉, 404나 500 에러가 와도 Ktor는 이를 실패로 간주하지 않고, 파싱을 시도한다. 이 과정에서 에러 바디를 정상 응답 DTO로 파싱하려다 JsonConvertException이 발생해 에러 핸들링 로직이 꼬일 수 있다.

 

expectSuccess = true 로 설정하면 상태 코드에 따라 다음과 같은 예외가 발생한다:

  • 3xx: RedirectResponseException
  • 4xx: ClientRequestException
  • 5xx: ServerResponseException

 

Default Request

Content-Type 헤더를 설정하지 않으면 Content-Type: null 로 요청을 보내 데이터를 무슨 타입(JSON, XML 등)으로 보내야 할지 명시적으로 판단하지 못해서 에러가 발생할 수 있다. JSON 형식으로 요청을 보낸다고 명시하기 위해 헤더에 Content-Type: application/json 를 추가하도록 설정했다.

 

Content Negotiation

클라이언트와 서버 간의 media type을 협상하기 위한 설정이다. JsonBuilder를 통해 직렬화 설정을 커스텀할 수 있다. Content-Type으로 application/json가 오면 JSON으로 변환하도록 하기 위해 설정했다. 이때 kotlinx.serialization으로 직렬화한다.

 

Logging

HTTP 요청 및 응답을 로깅한다. OkHttp의 HttpLoggingInterceptor 로 구현했던 것을 Ktor의 Logging 플러그인을 사용하여 설정할 수 있다.

 

 

Ktorfit 설정

위에서 만든 HttpClient를 Ktorfit에 연결해주면 Retrofit처럼 쓸 수 있다.

val ktorfit: Ktorfit =
    Ktorfit
        .Builder()
        .baseUrl(url = baseUrl, checkUrl = false)
        .httpClient(client)
        .build()

val gameApiService: GameApiService = ktorfit.createGameApiService()

 

 

JWT 인증 로직

가장 까다로웠던 부분이 바로 인증(Authentication)과 토큰 갱신(Refresh) 로직이었다.

Retrofit에서 OkHttp의 Interceptor와 Authenticator로 나눠 구현했던 기능을 Ktor는 Auth 플러그인 하나로 처리할 수 있다.

val httpClient: HttpClient =
    HttpClient(OkHttp) {
        // ...

        install(Auth) {
            // Configure bearer authentication
            bearer {
                loadTokens {
                    // Load tokens from a local storage and return them as the 'BearerTokens' instance
                }

                refreshTokens {
                    // Refresh tokens and return them as the 'BearerTokens' instance
                }

                sendWithoutRequest { ... }
            }
        }
    }

 

loadTokens

저장된 Access 토큰과 Refresh 토큰을 불러오는 데 사용한다.

로컬 저장소에서 캐시된 토큰을 불러와 BearerTokens 인스턴스로 반환하는 로직을 정의한다.

 

refreshTokens

토큰이 만료되었을 때 새로운 토큰을 어떻게 가져올지 명시한다.

만료된 토큰으로 요청을 보내 401 Unauthorized 응답을 받으면 refreshTokens 를 호출해 새로운 토큰을 가져온다.

새로운 토큰으로 BearerTokens 를 반환하면 Ktor가 자동으로 원래 요청을 재시도한다.

 

sendWithoutRequest

요청에 Authorization 헤더를 붙일지 조건을 설정할 수 있다.

  • true 반환: 토큰을 Authorization 헤더에 붙여 요청을 보냄
  • false 반환: 토큰 없이 요청을 보냄

기본적으로 true로 설정되어 있어 별도의 조건을 설정하지 않으면 모든 요청에 헤더가 추가된다. 로그인이나 회원가입, 토큰 재발급 요청에는 토큰을 붙이면 안되므로 해당 조건을 추가했다.


clearToken (optional)

Ktor는 loadTokens 로 불러온 토큰을 내부적으로 캐싱하여 캐시된 토큰을 반환한다. 따라서 로그아웃을 하거나 외부에서 토큰이 변경되었을 때, 이 캐시를 비워줘야 다음 요청 시 loadTokens 가 다시 호출되어 최신 상태를 반영할 수 있다.

 

 

마이그레이션 포인트

  1. 동기 vs 비동기
    • OkHttp의 Interceptor는 동기 방식이라 DataStore 같은 저장소에 접근하려면 runBlocking을 써야 했다.
    • Ktor의 loadTokens, refreshTokens는 suspend 함수여서 runBlocking 없이 코루틴 흐름을 유지할 수 있다.
  2. sendWithoutRequest의 편리함
    • 기존에는 '인증이 필요한 Client'와 '인증이 필요 없는 Client' 두 개를 만들어야 했다.
    • Ktor는 sendWithoutRequest 블록에서 URL 경로 등을 확인해 토큰 전송 여부를 결정할 수 있어, 하나의 Client로 모든 요청을 관리할 수 있다.

마무리

Retrofit으로 구현된 로직을 Ktor로 마이그레이션하면서 두 방식을 비교할 수 있었다. 특히 Ktor의 장점을 확실히 느낄 수 있었고, 마이그레이션이 아니라 처음부터 Ktor로 구현했다면 이를 전혀 알지 못했을 것 같다는 생각에 의미있는 과정이었다.

 

물론 트러블 슈팅도 꽤나 많이 했다. 위에서 작성한 각종 처리들이 에러를 겪고 해결하는 과정에서 알게 된 내용들이다. Ktor 지금도 계속 활발하게 업데이트 중이라 그런지 Gemini가 틀리는 경우도 꽤 있었고, 공식 문서를 꼼꼼히 확인해야 했다.

 

 

참고 문서

  • Ktor Client - Response Validation
  • Ktor Client - Serialization
  • Ktor Client - Bearer Auth
  • Ktorfit Official Docs

'Android' 카테고리의 다른 글

[Android] Safely Refreshing Tokens with Authenticator and Mutex  (0) 2025.09.10
[Android] Improving BottomNavigation Fragment Switching Performance  (0) 2025.08.13
[Android] When Exactly Is onPause() Called?  (3) 2025.07.22
'Android' 카테고리의 다른 글
  • [Android] Safely Refreshing Tokens with Authenticator and Mutex
  • [Android] Improving BottomNavigation Fragment Switching Performance
  • [Android] When Exactly Is onPause() Called?
jiyuneel
jiyuneel
  • jiyuneel
    Yuun
    jiyuneel
  • 전체
    오늘
    어제
    • 분류 전체보기 (6)
      • Android (4)
      • Kotlin (2)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    Android
    Android Studio
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
jiyuneel
Retrofit에서 Ktor로: 안드로이드 HTTP 클라이언트 마이그레이션
상단으로

티스토리툴바