(Kotlin/코틀린) 어댑터 패턴(Adapter Pattern) 완전 정리
개요
- 구조 패턴(Structural Pattern) 중 어댑터 패턴(Adapter Pattern) 을 다룹니다.
- 어댑터 패턴은 호환되지 않는 인터페이스를 함께 동작할 수 있도록 변환 하는 패턴입니다.
- 이 글에서는 다음을 설명합니다.
- 어댑터 패턴이 필요한 이유
- 객체 어댑터 vs 클래스 어댑터
- Kotlin 실전 구현
- Android 실전 예제 (RecyclerView, 외부 SDK, 레거시 API)
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→도메인 변환 |
| 핵심 효과 | 호출부 수정 없이 내부 구현(라이브러리) 교체 가능 |
- 어댑터 패턴은 “인터페이스가 맞지 않는 두 코드를 연결하는 변환기” 입니다.
- 외부 라이브러리, 레거시 코드, 서드파티 SDK를 사용할 때 인터페이스 격리 레이어로 활용하면 교체 비용을 크게 줄일 수 있습니다.
참고
- Design Patterns — GoF (Gang of Four)
- 팩토리 패턴 포스팅 보기
- Android RecyclerView 공식 문서