아티클 목록으로 가기

Jetpack Compose에서 나만의 Landscapist 이미지 플러그인 만들기

skydovesJaewoong Eum (skydoves)||14분 소요

Jetpack Compose에서 나만의 Landscapist 이미지 플러그인 만들기

Landscapist는 Jetpack Compose와 코틀린 멀티플랫폼(Kotlin Multiplatform)을 위한 컴포저블 이미지 로딩 라이브러리입니다. 여러 이미지 컴포저블 중에서도 LandscapistImage가 가장 권장되는 선택지인데, Jetpack Compose와 코틀린 멀티플랫폼을 위해 자체적으로 처음부터 구축한 독립형 로딩 엔진을 사용하기 때문입니다. Glide나 Coil 같은 플랫폼 종속 로더에 의존하지 않으며, 이미지 가져오기(fetching), 캐싱(caching), 디코딩(decoding), 화면 표시까지 모두 내부적으로 처리합니다. 덕분에 Android, iOS, Desktop, Web 전반에서 동일하게 동작합니다. 이에 더해, LandscapistImageImagePlugin sealed 인터페이스를 통한 플러그인 시스템을 제공하여, 이미지 로딩 생명주기 내 5가지 서로 다른 훅 포인트(hook point)에 커스텀 동작을 주입할 수 있습니다. 로더 자체를 수정할 필요 없이, 원하는 시점에 맞춰 자유롭게 동작을 확장할 수 있는 구조입니다.

이번 아티클에서는 ImagePlugin 아키텍처를 깊이 살펴보겠습니다. 5가지 플러그인 타입 각각이 왜 존재하는지, ImagePluginComponent가 DSL을 통해 플러그인을 어떻게 수집하고 디스패치하는지, 그리고 PlaceholderPlugin, ShimmerPlugin, CircularRevealPlugin, PalettePlugin, ZoomablePlugin 같은 내장 플러그인이 이러한 인터페이스를 실제로 어떻게 구현하는지 하나하나 살펴보겠습니다.

플러그인에 LandscapistImage가 적합한 이유

플러그인 시스템을 본격적으로 다루기 전에, LandscapistImage가 왜 플러그인 기반 이미지 로딩에 최적의 토대가 되는지 먼저 이해해 두는 것이 좋습니다.

LandscapistImage는 Glide, Coil, Fresco에 위임하지 않고 자체 독립 엔진(landscapist-core)을 사용합니다. 이는 네트워크 요청부터 메모리 캐싱, 비트맵 디코딩까지 이미지 로딩 파이프라인의 모든 단계가 단일 코틀린 멀티플랫폼 구현으로 제어된다는 뜻입니다. 플러그인 관점에서 이 구조의 이점은 명확합니다. LandscapistImage가 로딩 상태에서 성공 상태로 전환될 때, 비트맵이 사용 가능해지는 정확한 순간을 파악할 수 있습니다. 그리고 해당 비트맵을 어댑터 레이어나 플랫폼 고유 변환 없이 PainterPluginSuccessStatePlugin에 직접 전달합니다. 플러그인이 받는 것은 래핑된 플랫폼 객체가 아니라 실제 ImageBitmap입니다.

또한 LandscapistImage는 모든 Compose Multiplatform 타겟에서 동작합니다. Android용으로 작성한 ShimmerPlugin이 iOS나 Desktop에서도 동일하게 실행됩니다. "이 플러그인은 Glide에서만 동작합니다"라는 문제가 발생하지 않는데, 파이프라인에 Glide 자체가 존재하지 않기 때문입니다.

LandscapistImage 컴포저블의 시그니처를 보면 플러그인이 어디에 위치하는지 확인하실 수 있습니다.

@Composable
public fun LandscapistImage(
  imageModel: () -> Any?,
  modifier: Modifier = Modifier,
  component: ImageComponent = rememberImageComponent {},
  imageOptions: ImageOptions = ImageOptions(),
  loading: @Composable (BoxScope.(LandscapistImageState.Loading) -> Unit)? = null,
  success: @Composable (BoxScope.(LandscapistImageState.Success, Painter) -> Unit)? = null,
  failure: @Composable (BoxScope.(LandscapistImageState.Failure) -> Unit)? = null,
)

component 매개변수가 플러그인 시스템의 진입점(entry point)입니다. rememberImageComponent { ... } 블록을 전달하면, 블록 안에 추가한 모든 플러그인이 올바른 생명주기 단계에서 자동으로 디스패치됩니다. 일회성 커스터마이징에는 loading, success, failure 람다를 여전히 사용할 수 있지만, 재사용 가능하고 조합 가능한 대안으로서 플러그인이 훨씬 강력합니다.

핵심 과제: 이미지 로딩을 수정하지 않고 확장하기

흔히 마주하는 시나리오를 생각해 보겠습니다. LandscapistImage로 이미지를 로딩하면서 로딩 중에는 시머(shimmer) 효과를, 성공 시에는 원형 공개(circular reveal) 애니메이션을, 그리고 로드된 이미지에서 지배적인 색상을 추출하고 싶은 경우입니다. 플러그인 시스템이 없다면 이 모든 것을 수동으로 관리해야 합니다.

LandscapistImage(
  imageModel = { imageUrl },
  loading = {
    // 시머 UI를 수동으로 구성
  },
  success = { imageState, painter ->
    // 원형 공개 애니메이션을 수동으로 처리
    // 팔레트 색상을 수동으로 추출
  },
  failure = {
    // 에러 플레이스홀더를 수동으로 표시
  },
)

이 접근 방식에는 두 가지 문제가 있습니다. 첫째, 각 동작이 호출 지점에 강하게 결합됩니다. 동일한 시머를 10개의 다른 화면에서 사용하고 싶다면 로직을 10번 복사해야 합니다. 새로운 동작을 추가하려면 모든 호출 지점을 수정해야 합니다. 둘째, 서로 다른 개념적 레이어에서 동작하는 행위들(UI 렌더링, Painter 변환, 사이드 이펙트)이 동일한 람다 안에 뒤섞여 있어, 언제 무엇이 실행되는지 파악하기 어렵습니다.

플러그인 시스템은 이 두 가지 문제를 모두 해결합니다. 각 동작을 자기 완결적이고 재사용 가능한 컴포넌트로 변환하며, 해당 컴포넌트가 어떤 생명주기 단계에 속하는지 선언적으로 명시합니다. 이렇게 플러그인들을 선언적으로 조합하면 디스패치 메커니즘이 나머지를 알아서 처리합니다.

ImagePlugin 소개

ImagePlugin 시스템은 하나의 원칙을 중심으로 설계되었습니다. 이미지 로딩 동작의 추가나 제거가 코드 한 줄을 추가하거나 제거하는 것만큼 간단해야 한다는 것입니다. 실제로 + 연산자로 플러그인을 부착하고, 해당 줄을 삭제하여 분리할 수 있습니다.

LandscapistImage(
  imageModel = { imageUrl },
  component = rememberImageComponent {
    +ShimmerPlugin()              // 로딩 중 시머 부착
    +CircularRevealPlugin()       // 성공 시 공개 애니메이션 부착
    +PalettePlugin { palette -> } // 색상 추출 부착
  },
)

시머가 더 이상 필요 없다면 +ShimmerPlugin() 줄만 제거하면 됩니다. 나머지 플러그인은 아무런 변경 없이 정상적으로 동작합니다. 정리 코드도 필요 없고, 조건 분기를 업데이트할 필요도 없으며, 공유 상태를 풀어낼 필요도 없습니다. 각 플러그인은 자기 완결적으로 자체 설정을 보유하고, 자체 UI를 렌더링하거나 사이드 이펙트를 수행하며, 리스트 내 다른 플러그인에 대한 의존성이 전혀 없습니다.

이러한 구조 덕분에 플러그인 시스템은 내장 옵션을 넘어 더 넓은 활용이 가능합니다. 팀에서 이미지 로딩 생명주기와 연동되는 커스텀 동작이 필요한 경우, 이미지 로드 시 분석 로그를 남기거나, 실패 시 도메인 특화 에러 화면을 표시하거나, 브랜드 고유의 시각적 변환을 적용하는 등의 작업에 활용할 수 있습니다. 팀원 누구나 커스텀 ImagePlugin을 구현하여 +로 부착하고, 요구사항이 변경되면 나중에 제거할 수 있습니다. 플러그인 경계가 커스텀 동작을 이미지 로딩 파이프라인으로부터 격리하기 때문에, 플러그인을 추가하거나 제거해도 로더 자체가 깨질 위험이 없습니다.

플러그인 시스템은 5가지 타입의 플러그인을 지원하며, 각 타입은 이미지 로딩 생명주기의 서로 다른 시점을 대상으로 합니다. 이제 이러한 타입을 정의하는 인터페이스를 살펴보겠습니다.

ImagePlugin sealed 인터페이스

플러그인 시스템의 기반은 5개의 서브타입을 가진 sealed 인터페이스입니다. 각 서브타입은 이미지 로딩 생명주기의 특정 시점에 대응하며, 해당 시점에 적합한 매개변수를 전달받습니다. ImagePlugin 인터페이스를 살펴보겠습니다.

@Immutable
public sealed interface ImagePlugin {

  public interface PainterPlugin : ImagePlugin {
    @Composable
    public fun compose(imageBitmap: ImageBitmap, painter: Painter): Painter
  }

  public interface LoadingStatePlugin : ImagePlugin {
    @Composable
    public fun compose(
      modifier: Modifier,
      imageOptions: ImageOptions,
      executor: @Composable (IntSize) -> Unit,
    ): ImagePlugin
  }

나머지 3가지 타입도 동일한 패턴을 따릅니다.

  public interface SuccessStatePlugin : ImagePlugin {
    @Composable
    public fun compose(
      modifier: Modifier,
      imageModel: Any?,
      imageOptions: ImageOptions,
      imageBitmap: ImageBitmap?,
    ): ImagePlugin
  }

  public interface FailureStatePlugin : ImagePlugin {
    @Composable
    public fun compose(
      modifier: Modifier,
      imageOptions: ImageOptions,
      reason: Throwable?,
    ): ImagePlugin
  }

  public interface ComposablePlugin : ImagePlugin {
    @Composable
    public fun compose(content: @Composable () -> Unit)
  }
}

단일 onStateChanged 콜백 대신 5개의 별도 타입으로 나눈 이유는, 각 생명주기 단계의 요구사항이 근본적으로 다르기 때문입니다. 각 타입이 어떤 역할을 하는지 자세히 살펴보겠습니다.

PainterPlugin은 로드된 ImageBitmap과 현재 Painter를 전달받아 새로운 Painter를 반환합니다. 시각적 출력 자체를 변환하는 곳으로, 반환 타입이 중요합니다. Painter를 입력받아 Painter를 출력하는 구조이므로 여러 Painter 플러그인을 체이닝할 수 있습니다. 가령, 원형 공개(circular reveal)가 원본 Painter를 래핑하고, 블러가 다시 그 위를 래핑하여 데코레이션 체인을 형성합니다. LandscapistImage가 내부 캐시에서 디코딩된 ImageBitmap을 직접 제공하기 때문에, Painter 플러그인은 변환 작업에 항상 원본 비트맵 데이터에 접근할 수 있습니다.

LoadingStatePluginModifier, ImageOptions, 그리고 executor 콜백을 전달받습니다. executor는 전체 이미지가 로드되는 동안 저해상도 썸네일을 렌더링하기 위해 LandscapistImage가 제공하는 컴포저블 람다입니다. ThumbnailPlugin 같은 플러그인은 이 executor를 사용하여 로딩 파이프라인에서 동일 이미지의 축소 버전을 요청합니다. ShimmerPlugin 같은 다른 로딩 플러그인은 executor를 완전히 무시하고 자체 UI를 렌더링합니다.

SuccessStatePlugin은 원본 imageModel과 함께 로드된 imageBitmap을 전달받습니다. 이 플러그인은 로드 성공 후에 실행되며, 주요 목적은 UI 렌더링이 아닌 사이드 이펙트(side effect) 수행입니다. imageModel 매개변수가 포함된 이유는, 팔레트 캐싱 같은 사이드 이펙트에서 결과를 원본 이미지 URL과 연관시켜야 하는 경우가 있기 때문입니다.

FailureStatePlugin은 실패 원인인 Throwable을 전달받습니다. 커스텀 실패 플러그인에서 이 매개변수를 활용하면 실패 유형에 따라 다른 에러 이미지를 표시할 수 있습니다. 가령, IOException에는 네트워크 에러 아이콘을, IllegalArgumentException에는 포맷 에러 아이콘을 보여주는 식입니다.

ComposablePlugin은 전체 이미지 콘텐츠를 컴포저블 람다로 전달받습니다. 다른 4가지 타입과 달리, 이 플러그인은 특정 생명주기 상태에서 동작하지 않습니다. 대신, 어떤 상태에서 생성되었든 관계없이 렌더링된 이미지 콘텐츠를 감쌉니다. 따라서 제스처 처리, 오버레이, 접근성 래퍼 등 이미지 전체에 적용되는 동작에 적합합니다.

핵심 포인트를 정리하면, sealed 인터페이스 구조 덕분에 모든 플러그인은 컴파일 타임에 자신이 속하는 생명주기 단계를 반드시 선언해야 합니다. 플러그인이 언제 실행되는지에 대한 모호함이 없으며, 선택한 단계에 맞는 올바른 compose 시그니처를 구현하도록 컴파일러가 강제합니다.

플러그인 수집 방식: ImagePluginComponent

플러그인에는 수집하여 이미지 컴포저블에서 사용할 수 있게 해 주는 컨테이너가 필요합니다. ImagePluginComponent 클래스는 플러그인을 순서가 있는 리스트로 수집하는 DSL을 제공합니다.

@Stable
public class ImagePluginComponent(
  internal val mutablePlugins: MutableList<ImagePlugin> = mutableListOf(),
) : ImageComponent {

  public val plugins: List<ImagePlugin>
    inline get() = mutablePlugins

  public fun add(imagePlugin: ImagePlugin): ImagePluginComponent = apply {
    mutablePlugins.add(imagePlugin)
  }

  public operator fun ImagePlugin.unaryPlus(): ImagePluginComponent = add(this)
}

unaryPlus 연산자가 +Plugin() 문법을 가능하게 하는 핵심입니다. +ShimmerPlugin()이라고 작성하면, 코틀린이 ImagePluginComponent 내부에 정의된 unaryPlus 확장 함수를 호출하고, 내부적으로 add()를 호출하여 플러그인을 가변 리스트에 추가합니다. 이 연산자가 컴포넌트 스코프 안에서만 정의되어 있기 때문에, 플러그인 설정 블록 외부에서의 의도치 않은 사용을 방지합니다. 코틀린의 수신 객체 기반 DSL 설계를 잘 활용한 부분입니다.

rememberImageComponent 함수는 리컴포지션(Recomposition)을 거쳐도 이 컴포넌트를 생성하고 기억합니다.

@Composable
public fun rememberImageComponent(
  block: @Composable ImagePluginComponent.() -> Unit,
): ImagePluginComponent {
  val imageComponent = imageComponent(block)
  return remember { imageComponent }
}

remember 호출은 플러그인 리스트가 한 번만 구성되고 캐시되도록 보장합니다. 성능 면에서 중요한데, 이것이 없으면 매 리컴포지션마다 플러그인 리스트와 모든 플러그인 인스턴스가 다시 생성될 것입니다. 사용 방법은 다음과 같습니다.

LandscapistImage(
  imageModel = { imageUrl },
  component = rememberImageComponent {
    +ShimmerPlugin()
    +CircularRevealPlugin(duration = 500)
    +PalettePlugin(paletteLoadedListener = { palette -> })
  },
)

3개의 플러그인, 3개의 서로 다른 생명주기 단계, 모두 깔끔한 DSL로 하나의 컴포넌트에 조합됩니다. 블록 내 순서가 각 플러그인 타입 내에서의 실행 순서를 결정합니다.

내장 플러그인: 기초부터 고급까지

이제 내장 플러그인들이 이러한 인터페이스를 어떻게 구현하는지 살펴보겠습니다. 각 예제는 플러그인이 LandscapistImage 로딩 파이프라인과 상호 작용하는 서로 다른 패턴을 보여줍니다.

PlaceholderPlugin: 가장 단순한 LoadingStatePlugin

PlaceholderPlugin은 가장 직관적인 구현입니다. 로딩이 진행되는 동안 제공한 이미지 소스를 사용하여 정적 이미지를 표시합니다. PlaceholderPlugin.Loading 클래스를 살펴보겠습니다.

public data class Loading(val source: Any?) :
  PlaceholderPlugin(),
  ImagePlugin.LoadingStatePlugin {

  @Composable
  override fun compose(
    modifier: Modifier,
    imageOptions: ImageOptions,
    executor: @Composable (IntSize) -> Unit,
  ): ImagePlugin = apply {
    if (source != null) {
      ImageBySource(
        source = source,
        modifier = modifier,
        alignment = imageOptions.alignment,
        contentDescription = imageOptions.contentDescription,
        contentScale = imageOptions.contentScale,
        colorFilter = imageOptions.colorFilter,
        alpha = imageOptions.alpha,
      )
    }
  }
}

source 매개변수는 ImageBitmap, ImageVector, 또는 Painter를 받을 수 있습니다. compose 함수는 부모 LandscapistImage에서 전달받은 모든 이미지 옵션을 그대로 넘겨 ImageBySource를 렌더링합니다. 여기서 중요한 세부 사항은 플러그인이 contentScale, alignment 등의 표시 옵션을 자동으로 상속받기 때문에, 플레이스홀더가 최종 이미지와 시각적으로 일관되게 보인다는 점입니다. apply를 사용하여 자기 자신을 반환하는 패턴은 모든 상태 플러그인에서 공통으로 사용되는 관례입니다.

실패 시의 변형은 동일한 구조를 따르되, FailureStatePlugin을 구현합니다.

public data class Failure(val source: Any?) :
  PlaceholderPlugin(),
  ImagePlugin.FailureStatePlugin {

  @Composable
  override fun compose(
    modifier: Modifier,
    imageOptions: ImageOptions,
    reason: Throwable?,
  ): ImagePlugin = apply {
    if (source != null) {
      ImageBySource(
        source = source,
        modifier = modifier,
        alignment = imageOptions.alignment,
        contentDescription = imageOptions.contentDescription,
        contentScale = imageOptions.contentScale,
        colorFilter = imageOptions.colorFilter,
        alpha = imageOptions.alpha,
      )
    }
  }
}

reason: Throwable? 매개변수가 전달되지만 여기서는 사용하지 않는 점에 주목하세요. 내장 PlaceholderPlugin.Failure는 에러 유형에 관계없이 동일한 정적 이미지를 표시합니다. 커스텀 실패 플러그인에서는 이 매개변수를 검사하여 다른 이미지를 표시할 수 있습니다. 가령, 연결 에러에는 네트워크 아이콘을, 디코드 실패에는 깨진 이미지 아이콘을 보여주는 식입니다. 이처럼 내장 옵션을 넘어 직접 플러그인을 만들어야 하는 경우에 확장의 가치가 드러납니다.

ShimmerPlugin: 애니메이션 로딩 상태 추가하기

ShimmerPluginLoadingStatePlugin이지만, 정적 이미지 대신 콘텐츠가 로딩 중임을 사용자에게 알려주는 애니메이션 시머 효과를 표시합니다.

@Immutable
public data class ShimmerPlugin(
  val shimmer: Shimmer = Shimmer.Flash(
    baseColor = Color.DarkGray,
    highlightColor = Color.LightGray,
  ),
) : ImagePlugin.LoadingStatePlugin {

  @Composable
  override fun compose(
    modifier: Modifier,
    imageOptions: ImageOptions,
    executor: @Composable (IntSize) -> Unit,
  ): ImagePlugin = apply {
    ShimmerContainer(
      modifier = modifier,
      shimmer = shimmer,
    )
  }
}

구조 자체는 PlaceholderPlugin과 동일합니다. 차이점은 무엇을 합성하느냐에 있습니다. ImageBySource 대신, 기본 색상과 하이라이트 색상을 커스터마이징할 수 있는 ShimmerContainer를 렌더링합니다. 이 점이 플러그인 인터페이스가 각 구현을 단일 책임에 집중시키는 방식을 잘 보여줍니다. PlaceholderPlugin은 정적 이미지를, ShimmerPlugin은 애니메이션을 렌더링하지만, 둘 다 동일한 compose 시그니처를 통해 같은 로딩 상태 생명주기에 훅을 겁니다.

또한 ShimmerPluginexecutor 매개변수를 완전히 무시하는 점도 눈여겨볼 부분입니다. executor는 로딩 중 저해상도 썸네일을 렌더링하려는 플러그인(ThumbnailPlugin 등)을 위한 것이지만, ShimmerPlugin에는 불필요합니다. 이것이 인터페이스 설계의 장점입니다. 플러그인은 필요할 수 있는 모든 컨텍스트를 전달받되, 자신의 동작에 관련된 것만 자유롭게 사용할 수 있습니다.

CircularRevealPlugin: Painter 변환하기

CircularRevealPluginPainterPlugin 타입을 보여주며, 상태 플러그인과는 근본적으로 다른 레벨에서 동작합니다. 특정 생명주기 단계에서 UI를 합성하는 대신, 이미지를 렌더링하는 Painter를 변환합니다.

@Immutable
public data class CircularRevealPlugin(
  public val duration: Int = DefaultCircularRevealDuration,
  public val onFinishListener: CircularRevealFinishListener? = null,
) : ImagePlugin.PainterPlugin {

  @Composable
  override fun compose(imageBitmap: ImageBitmap, painter: Painter): Painter {
    return painter.rememberCircularRevealPainter(
      imageBitmap = imageBitmap,
      durationMs = duration,
      onFinishListener = onFinishListener,
    )
  }
}

compose 함수는 현재 painter를 전달받아 rememberCircularRevealPainter를 사용하여 원형 공개 애니메이션 Painter로 래핑합니다. 원본 Painter가 기반으로 전달되기 때문에, 애니메이션은 기존 렌더링 동작을 교체하는 것이 아니라 데코레이션합니다. LandscapistImage가 처음 성공 상태로 전환될 때, 원형 공개 Painter가 애니메이션을 시작하며 이미지를 중앙에서 바깥으로 점차적으로 드러냅니다.

BlurTransformationPlugin도 동일한 패턴을 따릅니다.

@Immutable
public data class BlurTransformationPlugin(
  public val radius: Int = 10,
) : ImagePlugin.PainterPlugin {

  @Composable
  override fun compose(imageBitmap: ImageBitmap, painter: Painter): Painter {
    return painter.rememberBlurPainter(
      imageBitmap = imageBitmap,
      radius = radius,
    )
  }
}

둘 다 Painter를 입력받아 새로운 Painter를 반환합니다. PainterPlugin이 체인을 형성하기 때문에(composePainterPlugins 디스패치 함수에서 볼 수 있듯이), 여러 Painter 변환을 쌓을 수 있습니다. CircularRevealPluginBlurTransformationPlugin을 모두 추가하면, 블러가 원형 공개 위에 적용되고, 원형 공개가 원본 Painter를 래핑합니다. 순서를 뒤집으면 시각적 결과가 달라지는데, 이미 블러 처리된 이미지 위에 공개 애니메이션이 재생됩니다. 이처럼 플러그인 등록 순서가 시각적 결과에 직접적인 영향을 미친다는 점을 이해해 두시면 좋습니다.

PalettePlugin: 성공 후 사이드 이펙트 처리하기

PalettePluginSuccessStatePlugin을 구현하며, 이미지가 성공적으로 로드된 후에만 실행됩니다. 앞서 살펴본 플러그인과 달리, PalettePlugin은 어떤 UI도 렌더링하지 않습니다. 로드된 비트맵에서 지배적인 색상을 추출하여 컴포저블 트리의 나머지 부분에서 사용할 수 있도록 노출하는 것이 목적입니다.

@Immutable
public data class PalettePlugin(
  private val imageModel: Any? = null,
  private val useCache: Boolean = true,
  private val interceptor: PaletteBuilderInterceptor? = null,
  private val paletteLoadedListener: PaletteLoadedListener? = null,
) : ImagePlugin.SuccessStatePlugin {

  private val bitmapPalette = BitmapPalette(
    imageModel = imageModel,
    useCache = useCache,
    interceptor = interceptor,
    paletteLoadedListener = paletteLoadedListener,
  )

compose 함수에서 팔레트 생성이 트리거됩니다.

  @Composable
  override fun compose(
    modifier: Modifier,
    imageModel: Any?,
    imageOptions: ImageOptions,
    imageBitmap: ImageBitmap?,
  ): ImagePlugin =
    apply {
      if (LocalInspectionMode.current) return@apply

      imageBitmap?.let {
        bitmapPalette.applyImageModel(this.imageModel ?: imageModel)
        bitmapPalette.generate(it)
      }
    }
}

이 코드는 SuccessStatePlugin이 시각적 렌더링에 국한되지 않음을 보여줍니다. compose 함수가 사이드 이펙트를 수행하여, 로드된 비트맵에서 색상 팔레트를 생성하고 paletteLoadedListener를 통해 알립니다. LocalInspectionMode 검사는 IDE 프리뷰 시 팔레트 생성을 건너뛰는 역할인데, 프리뷰 모드에서는 실제 비트맵이 사용 불가능하기 때문입니다. useCache 플래그는 동일한 이미지가 변경 없이 리컴포지션될 때 중복 팔레트 생성을 방지합니다.

LandscapistImage가 내부 디코딩 파이프라인에서 imageBitmap을 직접 제공하므로, PalettePlugin은 이미지를 다시 디코딩하거나 플랫폼 고유 API에서 요청할 필요가 없습니다. 독립형 엔진의 이점 중 하나로, 비트맵이 이미 Compose ImageBitmap으로 사용 가능한 상태여서 바로 분석에 활용할 수 있습니다.

ZoomablePlugin: 전체 콘텐츠 감싸기

ZoomablePluginComposablePlugin을 구현하며, 다른 4가지 플러그인 타입과는 다른 범위(scope)를 가집니다. 특정 로딩 상태에서 동작하거나 Painter를 변환하는 대신, 렌더링된 이미지 콘텐츠 전체를 감쌉니다.

@Immutable
public data class ZoomablePlugin(
  public val state: ZoomableState? = null,
  public val enabled: Boolean = true,
  public val onTransformChanged: ((ContentTransformation) -> Unit)? = null,
) : ImagePlugin.ComposablePlugin {

  @Composable
  override fun compose(content: @Composable () -> Unit) {
    val zoomableState = state ?: rememberZoomableState()
    val transformation = zoomableState.transformation
    onTransformChanged?.invoke(transformation)

    ZoomableContent(
      zoomableState = zoomableState,
      config = zoomableState.config,
      enabled = enabled,
      content = content,
    )
  }
}

content 람다에는 Painter 플러그인의 수정 사항을 포함하여, LandscapistImage가 현재 상태에 대해 렌더링한 모든 것이 담겨 있습니다. ZoomableContent가 이 콘텐츠를 핀치 투 줌(pinch-to-zoom), 팬(pan), 더블 탭(double-tap) 등의 제스처를 처리하는 제스처 디텍터로 감쌉니다. 사용자는 호출 지점에서 별도 코드를 추가하지 않아도 이미지를 보고 자유롭게 상호 작용할 수 있습니다.

선택적 state 매개변수가 이 플러그인을 특히 유용하게 만드는 포인트입니다. 외부에서 ZoomableState를 전달하면 컴포저블 트리의 다른 부분에서 현재 확대/축소 수준, 팬 오프셋, 회전 값을 읽을 수 있습니다. 가령, 줌 퍼센트 표시기를 보여주거나 "줌 초기화" 버튼을 추가하거나, 여러 이미지 간 줌을 동기화하는 등의 활용이 가능합니다. state를 전달하지 않으면 플러그인이 내부적으로 생성하여, 단순한 사용 사례는 단순하게 유지합니다.

ComposablePlugin은 컴포저블 계층 내에서 이미지 콘텐츠가 어떻게 표현되는지에 대한 전체적인 제어권을 가지는 가장 유연한 플러그인 타입입니다. 오버레이, 제스처 디텍터, 컨텍스트 메뉴 등 이미지를 감싸는 모든 종류의 컴포저블 래퍼를 ComposablePlugin으로 구현할 수 있습니다.

모두 결합하기

5가지 플러그인 타입이 모두 준비되었으니, LandscapistImage로 완전한 이미지 로딩 경험을 단 몇 줄로 구성할 수 있습니다.

LandscapistImage(
  imageModel = { imageUrl },
  component = rememberImageComponent {
    +ShimmerPlugin()
    +CircularRevealPlugin(duration = 350)
    +PalettePlugin(
      paletteLoadedListener = { palette ->
        dominantColor = palette.dominantSwatch?.rgb
      },
    )
    +PlaceholderPlugin.Failure(source = errorImage)
    +ZoomablePlugin()
  },
)

각 플러그인이 하나의 관심사를 담당합니다. ShimmerPlugin은 로딩 중 애니메이션 플레이스홀더를 표시하고, CircularRevealPlugin은 이미지 도착 시 Painter에 애니메이션을 적용하며, PalettePlugin은 로딩 후 색상을 추출하고, PlaceholderPlugin.Failure는 실패 시 에러 이미지를 표시하며, ZoomablePlugin은 전체 콘텐츠를 줌 제스처로 감쌉니다. 5개의 플러그인, 5개의 서로 다른 생명주기 훅, 수동 상태 관리 코드는 없습니다.

플러그인 시스템이 특정 이미지 로더 위의 Landscapist 레이어에서 동작하기 때문에, 동일한 컴포넌트 설정이 GlideImage, CoilImage, FrescoImage에서도 동작합니다. 다만 LandscapistImage는 서드파티 로더 의존성이 없는 독립적이고 멀티플랫폼 지원 엔진이라는 추가적인 이점을 제공합니다. 작성한 플러그인, 로더, UI 모두 단일 코틀린 멀티플랫폼 코드베이스에서 실행됩니다.

이번 아티클에서는 ImagePlugin sealed 인터페이스와 5개의 서브타입, ImagePluginComponent가 DSL을 통해 플러그인을 수집하는 방식, 그리고 PlaceholderPlugin, ShimmerPlugin, CircularRevealPlugin, PalettePlugin, ZoomablePlugin 같은 내장 플러그인이 각 플러그인 타입을 실제로 어떻게 구현하는지 살펴보았습니다.

이 플러그인 아키텍처를 이해하면 실용적인 활용 범위가 넓어집니다. 각 플러그인이 자기 완결적이고 + 한 줄로 부착할 수 있기 때문에, 점점 복잡해지는 시나리오에서도 자유롭게 동작을 조합할 수 있습니다. 시머 플레이스홀더와 원형 공개, 팔레트 추출을 결합하는 경우에도 기능 수에 비례하여 복잡성이 증가하지 않습니다. 요구사항이 변경되면 플러그인 한 줄을 제거하여 분리하고, 그 자리에 새로운 플러그인을 부착하면 됩니다. 이러한 유연한 구조 덕분에 이미지 로딩 관련 요구사항 변경에 빠르게 대응하실 수 있습니다.

아티클 목록으로 가기