CMP ktor 플러그인 사용해보기

K_Gs·2025년 8월 3일

배경/문제 상황

안드로이드 개발시에는 많은 경우에 보통 retrofit2을 많이 사용해요.

하지만 Compose multiplatform에서는 레트로핏이 kapt기반으로 돌아가기에 사용할 수 없고 다른 라이브러리를 써야합니다.

오늘은 제가 CMP에 ktor와 ktor를 레트로핏과 비슷하게 사용할 수 있도록 한. ktorfit을 적용하며 시도한 내용과 과정을 정리해보려해요.

목표

  • ktor/ktorfit를 통해 네트워크 요청하기
  • ktor에 각종 플러그인 달기

글 분량상 오늘은 httpClient 생성까지만 다뤄요!

왜 ktor, ktorfit?

사실 생각해보면 네트워크 요청 라이브러리는 매우 많이 존재해요. 그럼에도 제가 ktor와 ktorfit을 선택한 이유를 먼저 이야기해볼게요.

일단 ktor에서의 장점을 저는 두가지로 봤어요.

  • 멀티플랫폼에 100% 호환
  • 가벼운 파이프 라인

이외에도 더 있고, 성능이 더 좋다는 이야기도 있지만, 확실한 정보가 아니고 제가 검증하지 않아서 일단 제외했어요.

  1. 다른 라이브러리와 달리 CMP(정확히는 KMP)에 100% 호환되어 하나의 코드 베이스로 모든 플랫폼의 요청을 처리할 수 있고, 이에 따라 테스트코드 또한 하나만 작성하면 돼요. 현재 멀티플랫폼을 지원하는 라이브러리가 많이 없다는 점을 고려하면 ktor를 고를 수 밖에 없게 만들어요.

  2. 요청 파이프라인에서 원하는 플러그인만을 설치하고 빼고 할 수 있어 파이프라인이 훨신 가벼워지게 돼요. 그리고 후술하겠지만 개인적으로는 지원되는 플러그인이 필요한 것들이라 마음에 들기도했어요.

1번이 조금 큰 이유긴합니다. 코드를 두번 짜지 않아도 된다니! 하지만 단순히 그것만 보고 결정한 것은 아니고 여러 조사와 설명을 보고 결정했어요.

다음으로 ktorfit은 깃허브 설명에 다음과 같이 되어있어요.

Ktorfit is an HTTP client/Kotlin Symbol Processor for Kotlin Multiplatform ( Android, iOS, Js, Jvm, Linux) using KSP and Ktor clients inspired by Retrofit

요약하자면 ksp와 ktor를 사용하면서 레트로핏과 동일한 시스템을 가능하게 해주는 라이브러리에요.

사실 보면 ktor 자체가 요청이 간단하고, 편리하여 만약 프로젝트를 처음 진행하거나, 이미 ktor를 사용 중이였다면 크게 고려사항이 아니였겠지만, 저는 기존에 레트로핏을 쓰던 프로젝트를 CMP로 마이그레이션 중이였기에 ktorfit을 사용하면 코드 변경량을 획기적으로 줄이고, 러닝 커브 또한 낮출 수 있었어요.

이러한 이유로 저는 CMP에서 ktor와 ktorfit을 사용하기로 하였답니다!

클라이언트 생성

그냥 ktor를 써도, ktorfit을 써도 기본적으로 ktor의 HttpClient를 생성해야해요.

이 HttpClient는 Http요청을 보내고, 결과를 다루며, 파이프라인(플러그인)을 정의하고, 직렬화를 하는 등 요청과 관련된 모든 기능을 한다고 보면됩니다!

가장 기본적인 HttpClient는 그냥 아래와 같이 생성하면 돼요.

HttpClient { }

엄청 간단하죠? 별도의 기능이 없긴하지만 이것만으로도 요청이 가능합니다. 앞서 말한 가벼운 파이프라인이 여기서 나오는 것 같아요.

하지만, 저희는 여기에 이제 플러그인을 이것 저것 추가할겁니다.

플러그인 1. content negotiation

첫번째 플러그인은 content negotiation이에요. 컨텐츠 중재 쯤으로 해석하면 되려나요?

이 플러그인의 요청을 보낼때 정보를 직렬화하고 응답으로 들어온 정보를 역직렬화해서 객체로 만들어주는 역할을 해요.

사실상 요청과 응답의 메인 기능이죠! 앞서 클라이언트만으로 요청이 가능하다 했지만 사실 이 플러그인은 필수에 가까워요.

사용은 아래와 같이 한답니다.!

HttpClient {
  install(ContentNegotiation) {
    json(Json {
      ignoreUnknownKeys = true
      isLenient = true
      prettyPrint = true
   })
}

가장 단순한 사용법은 json() 까지인데요, 제가 사용을 위해 몇가지 플래그를 추가했습니다.

  • ignoreUnknownKeys: 역직렬화할 객체에 없는 필드가 들어오면 무시
  • isLenient: 따옴표 없는 키와 값 허용
  • prettyPrint: 프린트시 더 이쁘게 보여줌

또 보통 JSON을 많이 사용하지만 이외에 다양한 포맷을 지원해요.

플러그인 2. logging

다음으로는 로깅이에요. 로깅은 http 요청시 정보와, 응답을 출력해서 보여주는 역할을 해요.

사용법은 아래와 같습니다.

install(Logging)

엄청 간단하죠? SLF4J를 기반으로 돌아가기에 관련 파일, 라이브러리를 추가해야하긴하지만, 그런것을 고려해도 엄청 간단합니다.

저는 여기서 실제 배포시 로깅이 사용자에게 노출되면 위험하기에 앱의 빌드 플래그에 따라 로깅 레벨을 다르게 해주었어요.

install(Logging) {
  level = if (getBuildType() == DEBUG_TYPE) {
    LogLevel.ALL
      } else {
    LogLevel.NONE
  }
}

이렇게 하게되면 디버그 빌드시에는 모든 로그가 찍히고, 릴리즈 빌드시에는 아무런 로그도 찍히지 않게됩니다!

플러그인 3. Time out

다음은 많은 라이브러리에도 기본으로 있는 타임아웃이에요.
요청, 소켓, 연결 등을 얼마나 오래 유지하고 기다릴지를 정하는 플러그인 입니다.

사용법은 단순하게 얼마나 오래 유지할지를 밀리세컨드 단위로 적으면 됩니다.

install(HttpTimeout) {
  requestTimeoutMillis = 30_000
  connectTimeoutMillis = 10_000
  socketTimeoutMillis = 10_000
}

이 부분은 앱마다 정책이 다를 것 같아요. 저희는 파일을 보내기에 요청 타임아웃을 조금 길게 잡았습니다.

플러그인 3. http cookies

여기서부터 제가 ktor의 플러그인이 마음에 드는 부분이에요.
ktor는 자체적으로 쿠키와 헤더를 통한 auth를 제공해줍니다.
다른 라이브러리에도 있을 수 있지만, 상당히 편리하고 간단해보였어요.

일단 먼저 쿠키는 그냥 진짜 간단하게 인메모리에 저장하고 보내겠다 하면 아래와 같이 하면 됩니다.

install(HttpCookies)

너무 간단하지 않나요?!

하지만 로그인할때 쿠키에 리프레시 토큰을 넘겨주고 이후에는 별도로 주지 않는 경우 이 쿠키를 받아서 내부 db에 저장해야합니다.

그럴때는 쿠키스토리지 인터페이스를 구현하면 됩니다.

public interface CookiesStorage : Closeable {
    public suspend fun get(requestUrl: Url): List<Cookie>
    public suspend fun addCookie(requestUrl: Url, cookie: Cookie)
}
  • addCookie에 쿠키들을 직렬화해서 내부 db에 저장하고
  • get에서 직렬화된 쿠키를 역직렬화해서 반환하면 되는거죠!

이후 이를 구현한 구현체를 플러그인에 등록합니다.

install(HttpCookies) {
  storage = CookieStorage(secureStorage)
}

이러면 자동으로 http 요청시 내부 db에서 가져다 요청에 쿠키를 집어넣고, 응답에서 쿠키를 받으면 자동으로 쿠키를 저장하게 됩니다.

플러그인 4. auth

다음으로 가장 마음에 들었던 기능인 auth에요.

auth 플러그인은 사용시 자동으로 헤더에 토큰을 집어넣어줘요.

단순히 집어넣는 것 뿐만 아니라,

  • 401에러 발생시 리프레시 토큰 사용 및 액세스 토큰 요청
  • 액세스 토큰 저장
  • 액세스 토큰 발급 이후 재시도
  • 토큰 삽입을 제외한 요청 지정

이런 기능 등을 제공해요.

여러 토큰 형식이 있지만 이 아래부터는 bearer를 기준으로 사용법을 설명해볼게요.

기본 형태는 아래와 같이 생겼어요.

install(Auth) {
  bearer {
    loadTokens {} //토큰 불러오기
	refreshTokens {} //리프레시 토큰 사용 및 재요청
    sendWithoutRequest {} // 토큰 삽입 제외
  }
}

순서대로 알아볼게요.

  • loadTokens : http 요청시 자동으로 붙여넣을 토큰을 지정해요.

저같은 경우 내부 db에 액세스 토큰을 저장하기에 이 함수 안에는 내부 db에서 토큰을 가져오고 지정해주는 코드를 넣었어요.

loadTokens {
  val access = secureStorage.getString("token").orEmpty()
  val refresh = ""
  BearerTokens(accessToken = access, refreshToken = refresh) 
  //이 둘을 사용한다 알림
}

보면 리프레시 토큰을 공백으로 둔 걸 볼 수 있는데, 이는 리프레시 토큰이 앞서 말한 쿠키에서 처리되기 때문이에요.

만약 헤더에 넣는다면 여기서 지정하면 됩니다.

  • refreshTokens: 401 에러 발생시의 동작을 지정해요.

앞서 자동으로 액세스 토큰을 발급한다 적었지만, 사실 요청 코드 자체는 적어줘야하고, 앞서 토큰을 지정한 것 처럼 재지정을 해줘야합니다.

하지만 이후 재요청을 자동으로 하고,401의 처리를 간단하게 할 수 있는건 여전히 큰 메리트죠!

저는 client라는 내부 자기 참조 httpClient를 통해 리프레시 토큰을 요청했어요.

단, 이 경우 후술할 sendWithoutRequest를 잘 이용해서 반복 오류가 안생기도록 해줘야해요.

refreshTokens {
  val serverUrl = getServerURL()
  val response: HttpResponse = client.post("${serverUrl}/api/oauth/refresh")
  val authHeader = response.headers[HttpHeaders.Authorization]?: error("No Authorization header")
  val newToken = authHeader.substringAfter("Bearer ").trim()
  secureStorage.putString("token", newToken)
  BearerTokens(accessToken = newToken, refreshToken = "")
}

쉽게 요청하고, 액세스 토큰 받은 후, 이를 저장 및 플러그인에 알리는 방식입니다.

  • sendWithoitRequest: Auth토큰을 보내지 않을 요청을 지정해요.

이 함수는 요청 정보를 제공하는데요, 이것을 보고(가령 요청 url) auth 플러그인을 넣지 않을 요청을 지정할 수 있습니다.

모든 요청은 이 함수를 거치는데 만약 여기서 ture가 반환되면 그 함수는 auth 플러그인을 이용하지 않습니다.

sendWithoutRequest { request ->
  request.url.encodedPath !in publicEndpoints
}

저는 간단하게 auth가 필요없는 엔드포인트를 리스트로 저장해서 사용했어요.


httpClient가 완성되었어요!

이제 이것을 가져다 어디로, 어떤 정보로 요청을 보낼지 지정하면 됩니다. 이 내용은 다음 글에서 작성해볼게요!

트러블 슈팅

왜 토큰 갱신이 안되지?

플러그인을 적용하고 테스트를 하였을때 한번의 세션(앱이 켜진 상태)에서 A계정 로그아웃 -> B계정 로그인 등의 사유로 새롭게 토큰이 발급 되었을 때 분명 토큰이 새로 발급 되었음에도 이전 토큰으로 계속 요청이 가는 문제가 있었어요.

처음에는 토큰을 제대로 저장하지 못하는 것이 아닌가 생각하였지만, 확인 결과 토큰은 항상 바르게 내부 DB에 저장되고 있었어요.

내부 DB가 문제가 아니라는 것을 알게 된 후, 디버깅 포인트를 찍어가며 확인한 결과 Auth 플러그인이 토큰을 캐싱하고 있다는 사실을 알게 되었어요.

Auth플러그인은 클라이언트 생성 시. 최초 1회 토큰을 가져와 저장하고, 이후에는 리프레시 토큰 사용등의 이유가 아니라면 계속 캐시된 토큰을 사용해요. 즉, 토큰이 새로 발급되었더라도 이를 플러그인에 알리지 않으면 플러그인은 계속 이전의 토큰을 사용하는 거죠!

그래서 토큰을 발급 받은 이후 클라이언트에게 이를 통보하도록 하여 문제를 해결하였습니다.

val provider = httpClient.authProviders
    .filterIsInstance<BearerAuthProvider>()
        .first()
provider.clearToken()
profile
아직도 모르는게 많으니, 알아가고 싶은 것도 많다

0개의 댓글