안드로이드에서 흔히 쓰는 Coil이나 Glide는 android.content.Context와 뷰 시스템에 강하게 묶여 있어서 iOS 타겟에는 그대로 가져다 쓸 수 없습니다. Compose Multiplatform으로 화면 코드를 공유해도 이미지 로딩만큼은 안드로이드용 라이브러리와 iOS용 라이브러리를 따로 붙이고, 캐시 정책이나 플레이스홀더 처리도 두 번 짜야 했습니다. Coil 3.0부터는 Kotlin Multiplatform을 정식 지원해서 이 부분까지 commonMain 하나로 합칠 수 있습니다.

왜 필요한가

Coil 2.x까지는 ImageLoader가 안드로이드 Context를 직접 참조하는 구조라 iOS로 옮길 방법이 없었습니다. 그래서 안드로이드는 Coil, iOS는 SwiftUI의 AsyncImage나 Kingfisher를 붙이는 식으로 화면마다 이미지 로딩 코드를 이원화하게 됩니다. 캐시 만료 정책이나 재시도 로직을 바꿀 때도 두 플랫폼 코드를 각각 고쳐야 하니 하나를 빠뜨리는 실수가 생기기 쉽습니다.

Coil3는 ContextPlatformContext라는 멀티플랫폼 추상화로 감싸서 이 문제를 없앴습니다. 안드로이드에서는 PlatformContextContext를 그대로 감싸고, iOS에서는 별도 인자 없이 생성되는 최소 구현체를 씁니다. 덕분에 AsyncImage 컴포저블과 ImageLoader 설정 코드를 commonMain에 한 번만 쓰면 됩니다.

핵심 개념

commonMain에 Coil3 의존성을 추가합니다. 네트워크 요청은 기본 내장이 아니라서 fetcher를 별도로 붙여야 하는데, Ktor 기반 fetcher를 쓰면 프로젝트에 이미 있는 Ktor 클라이언트 엔진을 그대로 재사용할 수 있습니다.

// composeApp/build.gradle.kts
kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation("io.coil-kt.coil3:coil-compose:3.0.4")
            implementation("io.coil-kt.coil3:coil-network-ktor3:3.0.4")
        }
    }
}

앱 시작 시점에 ImageLoader를 한 번 등록해둡니다. SingletonImageLoader.setSafe는 이미 등록돼 있으면 덮어쓰지 않고 무시하기 때문에, 여러 진입점(테스트, 프리뷰 등)에서 중복 호출해도 안전합니다.

// commonMain/di/ImageLoaderSetup.kt
import coil3.ImageLoader
import coil3.PlatformContext
import coil3.SingletonImageLoader
import coil3.network.ktor3.KtorNetworkFetcherFactory

fun initImageLoader(context: PlatformContext) {
    SingletonImageLoader.setSafe { platformContext ->
        ImageLoader.Builder(platformContext)
            .components { add(KtorNetworkFetcherFactory()) }
            .build()
    }
}

안드로이드는 Application.onCreate()에서, iOS는 MainViewController를 생성하기 전 진입 지점에서 initImageLoader를 호출하면 됩니다.

실전 예시

목록 화면에서 원격 썸네일을 그리는 컴포저블입니다.

// commonMain/ui/ItemThumbnail.kt
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter
import coil3.compose.LocalPlatformContext
import coil3.compose.rememberAsyncImagePainter
import coil3.request.ImageRequest
import coil3.request.crossfade

@Composable
fun ItemThumbnail(imageUrl: String, modifier: Modifier = Modifier) {
    val painter = rememberAsyncImagePainter(
        model = ImageRequest.Builder(LocalPlatformContext.current)
            .data(imageUrl)
            .crossfade(true)
            .build(),
    )

    when (painter.state.collectAsState().value) {
        is AsyncImagePainter.State.Error -> Text("이미지를 불러오지 못했습니다")
        else -> AsyncImage(
            model = imageUrl,
            contentDescription = null,
            modifier = modifier.size(64.dp),
        )
    }
}

AsyncImage만 쓰면 로딩·에러 상태를 신경 쓰지 않고 URL 하나로 이미지를 그릴 수 있고, 상태별로 다른 UI가 필요할 때는 rememberAsyncImagePainterpainter.state를 직접 관찰합니다. 두 방식 모두 내부적으로 같은 SingletonImageLoader를 참조하므로 캐시가 공유됩니다.

네트워크 fetcher를 등록하지 않고 AsyncImagehttp로 시작하는 URL을 넘기면 로딩이 끝나지 않고 계속 에러 상태로 빠지는데, 로그에는 눈에 띄는 예외 없이 조용히 실패합니다. KtorNetworkFetcherFactory를 컴포넌트로 추가해야 실제로 네트워크 요청이 나간다는 걸 처음에는 놓치기 쉽습니다.

장단점 정리

장점

  • 이미지 로딩 코드와 캐시 정책을 commonMain 한 곳에서 관리하므로, 안드로이드·iOS 양쪽에 따로 붙이던 라이브러리를 하나로 줄일 수 있음
  • 네트워크 fetcher를 Ktor 기반으로 쓰면 API 호출에 쓰던 것과 같은 엔진·인터셉터 설정을 이미지 요청에도 재사용 가능
  • 메모리·디스크 캐시가 플랫폼 공통으로 동작해서, 같은 URL을 여러 화면에서 그려도 중복 다운로드가 발생하지 않음

단점

  • 네트워크 fetcher가 기본 내장이 아니라서 별도로 추가하지 않으면 원격 이미지가 조용히 실패하는데, 에러 메시지가 뚜렷하지 않아 원인을 찾는 데 시간이 걸림
  • Coil 2.x에서 넘어올 경우 ImageLoader 빌더 API와 컴포넌트 등록 방식이 바뀌어서 기존 설정 코드를 그대로 옮길 수 없음
  • 상대적으로 최근에 나온 버전이라 Coil 2.x보다 커뮤니티 자료나 트러블슈팅 사례가 적음

플랫폼별로 나뉘어 있던 이미지 로딩 스택을 하나로 합치는 작업은 화면 코드 공유보다 체감 범위는 좁지만, 캐시 정책이나 에러 처리를 한 곳에서만 고치면 되게 만들어준다는 점에서 초기에 정리해둘 만합니다.