(Kotlin/코틀린) 어댑터 패턴(Adapter Pattern) 완전 정리

개요


1. 왜 어댑터 패턴이 필요한가

// 우리 앱이 사용하는 인터페이스
interface ImageLoader {
    fun load(url: String, target: ImageView)
    fun loadWithPlaceholder(url: String, target: ImageView, placeholder: Int)
    fun cancel(target: ImageView)
}

// 외부 라이브러리 — 인터페이스가 다름
class GlideLibrary {
    fun loadImage(imageUrl: String, into: ImageView) { /* Glide 내부 구현 */ }
    fun loadImageWithFallback(imageUrl: String, fallbackRes: Int, into: ImageView) { /* */ }
    fun cancelRequest(view: ImageView) { /* */ }
}
// ❌ 외부 라이브러리를 직접 사용 — 앱 전체가 GlideLibrary에 종속
class ProfileFragment {
    private val glide = GlideLibrary()

    fun showAvatar(url: String) {
        glide.loadImage(url, binding.ivAvatar)  // Glide 직접 참조
    }
}
// Glide → Coil로 교체 시 모든 호출부 수정 필요

2. 객체 어댑터 (Object Adapter) — 권장

합성(Composition)으로 어댑터를 구현합니다.

// 어댑터 — GlideLibrary를 ImageLoader 인터페이스로 변환
class GlideAdapter(
    private val glide: GlideLibrary = GlideLibrary()  // 변환할 대상을 내부에 보유
) : ImageLoader {

    override fun load(url: String, target: ImageView) {
        glide.loadImage(url, target)                   // 인터페이스 메서드 → 라이브러리 메서드로 변환
    }

    override fun loadWithPlaceholder(url: String, target: ImageView, placeholder: Int) {
        glide.loadImageWithFallback(url, placeholder, target)
    }

    override fun cancel(target: ImageView) {
        glide.cancelRequest(target)
    }
}

// Coil로 교체할 때 — 어댑터만 새로 작성, 호출부는 수정 없음
class CoilAdapter : ImageLoader {
    override fun load(url: String, target: ImageView) {
        target.load(url)  // Coil API
    }

    override fun loadWithPlaceholder(url: String, target: ImageView, placeholder: Int) {
        target.load(url) { placeholder(placeholder) }
    }

    override fun cancel(target: ImageView) {
        target.dispose()
    }
}
// 호출부 — ImageLoader 인터페이스만 알면 됨
class ProfileFragment(
    private val imageLoader: ImageLoader = GlideAdapter()
) : Fragment() {

    fun showAvatar(url: String) {
        imageLoader.load(url, binding.ivAvatar)  // 라이브러리 교체해도 이 코드는 불변 ✅
    }
}

3. 클래스 어댑터 (Class Adapter)

상속으로 어댑터를 구현합니다. Kotlin에서는 인터페이스 다중 구현으로 표현합니다.

interface NewPayment {
    fun processPayment(amount: Int): Boolean
    fun refund(transactionId: String): Boolean
}

// 레거시 결제 클래스
open class LegacyPaymentSystem {
    fun charge(amount: Int): Int = if (amount > 0) 200 else 400  // 200: 성공, 400: 실패
    fun reverseCharge(txId: String): Boolean = true
}

// 클래스 어댑터 — 상속 + 인터페이스 구현
class PaymentAdapter : LegacyPaymentSystem(), NewPayment {
    override fun processPayment(amount: Int): Boolean =
        charge(amount) == 200          // 레거시 메서드를 새 인터페이스 규격으로 변환

    override fun refund(transactionId: String): Boolean =
        reverseCharge(transactionId)
}

객체 어댑터가 합성을 사용해 더 유연합니다. 클래스 어댑터는 상속 제약과 단일 상속 한계가 있어 인터페이스가 명확한 경우에만 사용합니다.


4. 데이터 형식 어댑터

외부 API 응답(DTO)을 내부 도메인 모델로 변환하는 데 활용됩니다.

// 외부 API 응답 형식
data class UserDto(
    val user_id: Int,
    val full_name: String,
    val email_address: String,
    val profile_image: String?,
    val created_at: String
)

// 내부 도메인 모델
data class User(
    val id: Long,
    val name: String,
    val email: String,
    val avatarUrl: String?,
    val joinedAt: LocalDateTime
)

// 어댑터 — DTO → 도메인 변환
fun UserDto.toDomain(): User = User(
    id        = user_id.toLong(),
    name      = full_name,
    email     = email_address,
    avatarUrl = profile_image,
    joinedAt  = LocalDateTime.parse(created_at)
)

fun List<UserDto>.toDomain(): List<User> = map { it.toDomain() }

// 사용
val users: List<User> = apiResponse.map { it.toDomain() }

5. Android 실전 예제 ① — RecyclerView Adapter

Android의 RecyclerView.Adapter가 어댑터 패턴의 대표적인 사례입니다.

// 데이터 모델
data class Product(val id: Long, val name: String, val price: Int, val imageUrl: String)

// RecyclerView.Adapter — List<Product>를 RecyclerView가 이해하는 형태로 변환
class ProductAdapter(
    private val onItemClick: (Product) -> Unit
) : RecyclerView.Adapter<ProductAdapter.ViewHolder>() {

    private var items: List<Product> = emptyList()

    fun submitList(newItems: List<Product>) {
        items = newItems
        notifyDataSetChanged()
    }

    inner class ViewHolder(
        private val binding: ItemProductBinding
    ) : RecyclerView.ViewHolder(binding.root) {

        fun bind(product: Product) {
            binding.tvName.text           = product.name
            binding.tvPrice.text          = "${product.price}원"
            binding.root.setOnClickListener { onItemClick(product) }
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val binding = ItemProductBinding.inflate(
            LayoutInflater.from(parent.context), parent, false
        )
        return ViewHolder(binding)
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) =
        holder.bind(items[position])

    override fun getItemCount() = items.size
}

6. Android 실전 예제 ② — ListAdapter (DiffUtil)

class ProductListAdapter(
    private val onItemClick: (Product) -> Unit,
    private val onFavoriteClick: (Product) -> Unit
) : ListAdapter<Product, ProductListAdapter.ViewHolder>(DiffCallback()) {

    inner class ViewHolder(
        private val binding: ItemProductBinding
    ) : RecyclerView.ViewHolder(binding.root) {

        fun bind(product: Product) {
            with(binding) {
                tvName.text   = product.name
                tvPrice.text  = "${product.price}원"
                ivFavorite.setImageResource(
                    if (product.isFavorite) R.drawable.ic_favorite_filled
                    else R.drawable.ic_favorite_outline
                )
                root.setOnClickListener      { onItemClick(product) }
                ivFavorite.setOnClickListener { onFavoriteClick(product) }
            }
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
        ViewHolder(ItemProductBinding.inflate(LayoutInflater.from(parent.context), parent, false))

    override fun onBindViewHolder(holder: ViewHolder, position: Int) =
        holder.bind(getItem(position))

    class DiffCallback : DiffUtil.ItemCallback<Product>() {
        override fun areItemsTheSame(old: Product, new: Product) = old.id == new.id
        override fun areContentsTheSame(old: Product, new: Product) = old == new
    }
}

// Fragment에서 사용
val adapter = ProductListAdapter(
    onItemClick     = { product -> viewModel.handleIntent(ProductIntent.OpenDetail(product.id)) },
    onFavoriteClick = { product -> viewModel.handleIntent(ProductIntent.ToggleFavorite(product.id)) }
)
binding.recyclerView.adapter = adapter
viewModel.state.observe(viewLifecycleOwner) { adapter.submitList(it.products) }

7. Android 실전 예제 ③ — 레거시 콜백 → Flow 어댑터

콜백 기반 API를 Flow로 변환하는 어댑터입니다.

// 레거시 콜백 인터페이스
interface LocationCallback {
    fun onLocationReceived(lat: Double, lng: Double)
    fun onError(error: String)
}

class LegacyLocationManager {
    fun startTracking(callback: LocationCallback) { /* 내부 구현 */ }
    fun stopTracking() { /* 내부 구현 */ }
}

// 어댑터 — 콜백을 Flow로 변환
fun LegacyLocationManager.asFlow(): Flow<Pair<Double, Double>> = callbackFlow {
    val callback = object : LocationCallback {
        override fun onLocationReceived(lat: Double, lng: Double) {
            trySend(Pair(lat, lng))
        }
        override fun onError(error: String) {
            close(Exception(error))
        }
    }
    startTracking(callback)
    awaitClose { stopTracking() }
}

// 사용 — 콜백 대신 Flow로 구독
viewModelScope.launch {
    locationManager.asFlow()
        .catch { error -> _state.update { it.copy(error = error.message) } }
        .collect { (lat, lng) ->
            _state.update { it.copy(latitude = lat, longitude = lng) }
        }
}

8. 어댑터 패턴 vs 관련 패턴

패턴 목적 차이점
어댑터 인터페이스 변환 기존 인터페이스를 다른 인터페이스로 감쌈
데코레이터 기능 추가 인터페이스 동일, 기능만 추가
파사드 인터페이스 단순화 여러 인터페이스를 하나로 통합
프록시 접근 제어 인터페이스 동일, 접근 방식만 변경

9. 정리

항목 내용
목적 호환되지 않는 인터페이스를 함께 동작하도록 변환
구현 방식 객체 어댑터(합성, 권장) / 클래스 어댑터(상속)
Kotlin 활용 확장 함수로 간결하게 변환 메서드 추가
Android 사례 RecyclerView.Adapter, ListAdapter, 콜백→Flow 변환, DTO→도메인 변환
핵심 효과 호출부 수정 없이 내부 구현(라이브러리) 교체 가능

참고



Related Posts