Adapter는 한 클래스의 인터페이스를 클라이언트가 기대하는 다른 인터페이스로 변환하는 구조적 디자인 패턴입니다. 모바일 개발에서 Adapter는 Android의 RecyclerView.Adapter와 iOS의 UITableViewDataSource에서 가장 자주 사용됩니다. Google I/O(2024)에 따르면, RecyclerView는 Android 앱의 95%에서 리스트 표시에 사용되며, 각각 고유한 Adapter 구현이 필요합니다.
핵심 포인트
Adapter는 인터페이스 비호환성 문제를 해결하는 구조적 패턴입니다. 객체(Adaptee)를 클라이언트가 기대하는 인터페이스(Target)를 가진 클래스(Adapter)로 래핑합니다. 클라이언트는 Target과 작업하며 Adaptee의 존재를 알지 못합니다. 이는 Wrapper 패턴의 변형입니다.
// 호환되지 않는 인터페이스를 가진 기존 클래스
class LegacyAuthApi {
fun loginWithToken(token: String): Map {
return mapOf("status" to "ok", "user_id" to 42)
}
}
// 대상 인터페이스(클라이언트가 기대하는 것)
interface AuthService {
suspend fun login(credentials: Credentials): Result
}
// Adapter — LegacyAuthApi를 AuthService로 변환
class LegacyAuthAdapter(
private val legacyApi: LegacyAuthApi
) : AuthService {
override suspend fun login(credentials: Credentials): Result {
val token = "${credentials.login}:${credentials.password}"
.encodeToByteArray().let { Base64.encodeToString(it) }
val response = legacyApi.loginWithToken(token)
return if (response["status"] == "ok") {
Result.success(User(response["user_id"] as Int))
} else {
Result.failure(AuthException("Login failed"))
}
}
}LegacyAuthAdapter는 이전 API를 위해 login(credentials) 호출을 loginWithToken(token)으로 변환합니다. 클라이언트(ViewModel)는 AuthService 인터페이스를 통해 작동하며, 그 아래에 최신 Firebase Auth가 있는지 10년 된 레거시 API가 있는지 알지 못합니다. 이를 통해 클라이언트 코드를 변경하지 않고 구현을 교체할 수 있습니다.
RecyclerView.Adapter는 Android에서 Adapter 패턴의 가장 일반적인 구현입니다. 데이터(객체 리스트)를 RecyclerView가 화면에 표시하는 ViewHolder로 변환합니다. ListAdapter(Android Architecture Components)의 도입으로, 패턴은 변경 사항을 애니메이션화하기 위한 내장된 차이 계산 지원을 얻었습니다.
class UserAdapter(
private val onItemClick: (User) -> Unit
) : ListAdapter (DiffCallback()) {
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): UserViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_user, parent, false)
return UserViewHolder(view)
}
override fun onBindViewHolder(
holder: UserViewHolder,
position: Int
) {
holder.bind(getItem(position), onItemClick)
}
class UserViewHolder(itemView: View) :
RecyclerView.ViewHolder(itemView) {
private val tvName = itemView.findViewById(R.id.tvName)
fun bind(user: User, click: (User) -> Unit) {
tvName.text = user.name
itemView.setOnClickListener { click(user) }
}
}
private class DiffCallback : DiffUtil.ItemCallback () {
override fun areItemsTheSame(old: User, new: User) = old.id == new.id
override fun areContentsTheSame(old: User, new: User) = old == new
}
} UserAdapter는 효율적인 리스트 다시 그리기를 위해 DiffUtil과 함께 ListAdapter를 사용합니다. 데이터가 업데이트되면 DiffCallback이 이전 리스트와 새 리스트 간의 차이를 계산하고, RecyclerView는 변경된 항목만 애니메이션화합니다 — 전체 리스트를 다시 그리지 않고. 이는 수동 notifyDataSetChanged()가 필요했던 이전 ListView에 비해 핵심적인 이점입니다.
UITableViewDataSource는 Adapter 패턴과 DataSource 패턴을 동시에 수행합니다. 모델 데이터를 테이블 셀로 변환합니다. iOS 13부터 Apple은 DiffableDataSource를 도입했습니다 — Android의 ListAdapter처럼 변경 사항을 자동으로 계산하고 업데이트를 애니메이션화하는 현대적인 대체품입니다.
class UserTableViewAdapter: NSObject, UITableViewDataSource {
var items: [User] = []
func tableView(_ tableView: UITableView,
numberOfRowsInSection section: Int) -> Int {
items.count
}
func tableView(_ tableView: UITableView,
cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(
withIdentifier: "UserCell", for: indexPath)
let user = items[indexPath.row]
cell.textLabel?.text = user.name
return cell
}
}
// 최신 버전: DiffableDataSource
enum Section { case main }
class ModernUserAdapter {
var dataSource: UITableViewDiffableDataSource<Section, User>!
func configure(for tableView: UITableView) {
dataSource = UITableViewDiffableDataSource(
tableView: tableView
) { tableView, indexPath, user in
let cell = tableView.dequeueReusableCell(
withIdentifier: "UserCell", for: indexPath)
cell.textLabel?.text = user.name
return cell
}
}
func update(with users: [User]) {
var snapshot = NSDiffableDataSourceSnapshot<Section, User>()
snapshot.appendSections([.main])
snapshot.appendItems(users)
dataSource.apply(snapshot, animatingDifferences: true)
}
}ModernUserAdapter는 DiffableDataSource와 함께 데이터가 업데이트될 때 변경 사항을 자동으로 애니메이션화합니다: 항목이 부드러운 애니메이션과 함께 나타나거나, 사라지거나, 이동합니다. iOS의 DiffableDataSource와 Android의 ListAdapter는 다른 API로 동일한 문제를 해결하지만, 동일한 로직(차이 계산 + 자동 애니메이션)을 사용합니다.
Object Adapter는 컴포지션을 사용합니다: 어댑터는 Adaptee 객체에 대한 참조를 가지고 호출을 위임합니다. Class Adapter는 상속을 사용합니다: 어댑터는 Adaptee를 상속받고 동시에 대상 인터페이스를 구현합니다. Java, Swift, Kotlin에서는 다중 상속이 없기 때문에 Class Adapter가 불가능합니다 — Object Adapter만 가능합니다.
| 특성 | Object Adapter | Class Adapter |
|---|---|---|
| 메커니즘 | 컴포지션(Adaptee 포함) | 상속(Adaptee 확장) |
| 유연성 | 높음 — Adaptee를 런타임에 변경 가능 | 낮음 — Adaptee가 컴파일 타임에 고정 |
| 결합도 | 느슨함(인터페이스를 통해) | 강함(상속을 통해) |
| 하위 클래스 적응 | 예 — Adaptee는 모든 하위 클래스 가능 | 아니오 — 특정 상위 클래스만 |
| 사용 가능 언어 | Java, Kotlin, Swift, C++, C# | 다중 상속 언어만(C++) |
모바일 개발에서는 Object Adapter만 사용됩니다. RecyclerView.Adapter는 데이터와 LayoutInflater에 대한 참조를 가지고, UITableViewDataSource는 항목 배열을 가집니다. 컴포지션은 상속에 비해 코드를 더 유연하고 테스트 가능하게 만듭니다.
Adapter는 리스트에만 사용되지 않습니다. 호환되지 않는 구성 요소를 통합하는 문제를 패턴이 해결하는 모바일 개발 실무의 세 가지 시나리오를 살펴보겠습니다.
| 시나리오 | Adaptee | Target | Adapter |
|---|---|---|---|
| 레거시 auth 라이브러리 통합 | LegacyAuthLib(콜백 기반) | AuthService(suspend) | LegacyAuthAdapter |
| 새 모델에 JSON 적응 | GsonParser | JsonParser(Kotlinx Serialization) | GsonAdapter |
| 푸시 서비스를 위한 통합 인터페이스 | FCM, APNs, Huawei Push | PushService token register | PushServiceAdapter |
각 Adapter는 변환 로직을 캡슐화합니다: GsonAdapter는 @SerializedName을 통해 JSON 문자열을 객체로 변환하고, PushServiceAdapter는 다양한 플랫폼에 대한 토큰 등록을 추상화합니다. 클라이언트(비즈니스 로직)는 통합 인터페이스로 작업하며 특정 구현에 의존하지 않습니다.
Adapter 구현 실수는 리스트 지연, 메모리 누수 및 데이터 업데이트 버그로 이어집니다. 세 가지 일반적인 문제를 살펴보겠습니다.
onBindViewHolder는 스크롤 중에 각 셀에 대해 호출됩니다. 이 메서드에서 객체 생성, JSON 파싱 또는 데이터베이스 호출은 프레임 드롭을 유발합니다. 해결책: 모든 무거운 계산을 미리 수행하고, 준비된 데이터를 어댑터에 전달합니다. 이미지에는 캐싱 라이브러리(Glide, Coil, Kingfisher)를 사용합니다.
업데이트할 때마다 notifyDataSetChanged()를 호출하면 전체 리스트를 다시 그려 깜빡임과 입력 포커스 손실을 유발합니다. 해결책: Android에서는 DiffUtil과 함께 ListAdapter를, iOS에서는 DiffableDataSource를 사용합니다. 차이 계산은 최대 1000개 항목 리스트에 대해 <1ms가 소요되며 부드러운 애니메이션을 보장합니다.
어댑터는 RecyclerView/UITableView만큼 오래 살아있습니다. 어댑터에 Bitmap 또는 큰 배열을 저장하면 구성 변경(화면 회전, 테마 변경) 시 메모리 누수가 발생합니다. 해결책: 어댑터는 가벼운 데이터 모델(data class / struct)만 저장하고, 무거운 리소스는 필요에 따라 ViewHolder를 통해 로드해야 합니다.
자주 묻는 질문
Adapter는 Adaptee 인터페이스를 Target 인터페이스로 변환합니다 — 클라이언트는 새로운 상호작용 방식을 얻습니다. Proxy는 원본 객체와 동일한 인터페이스를 제공하지만, 접근 제어, 캐싱 또는 지연 초기화를 추가합니다. Proxy는 인터페이스를 변경하지 않지만, Adapter는 변경합니다.
RecyclerView.Adapter는 데이터(예: User 리스트)를 RecyclerView가 표시할 수 있는 ViewHolder로 변환합니다. RecyclerView는 ViewHolder를 기대하고, 데이터는 List<User> 형식입니다 — Adapter가 하나를 다른 것에 적응시킵니다. 또한, Adapter는 재활용 풀을 통해 ViewHolder의 생명 주기를 관리하여 리스트 성능을 향상시킵니다.
Object Adapter는 컴포지션을 사용합니다: Adaptee에 대한 참조를 가지고 호출을 위임합니다. Class Adapter는 다중 상속을 사용합니다: Adaptee를 상속받고 Target을 구현합니다. Java, Kotlin, Swift에서는 다중 클래스 상속이 없어 Object Adapter만 사용 가능합니다. Class Adapter는 C++에서만 가능합니다.
Adapter는 두 기존 클래스가 함께 작동하도록 해야 할 때, 그중 하나의 인터페이스를 변환하여 사용합니다. Facade는 단순한 인터페이스를 제공하여 복잡한 하위 시스템과의 상호작용을 단순화해야 할 때 사용합니다. Adapter는 호환성을 위해, Facade는 단순화를 위해 사용됩니다.
네, 클로저를 사용하여 가능합니다. 하나의 메서드를 가진 인터페이스 대신 함수를 전달할 수 있습니다. 예: Swift에서 let adapter = { (data: Data) -> [CellConfig] in /* 변환 */ }. 이 접근 방식을 함수형 어댑터라고 합니다. 그러나 2개 이상의 메서드의 경우, 프로토콜/인터페이스가 여전히 선호됩니다 — 계약을 명시적으로 만듭니다.
요약
턴키 방식의 모바일 애플리케이션을 개발해 드립니다
IT Sectr는 2017년부터 스타트업과 기업을 위한 iOS 및 Android 애플리케이션을 만듭니다. 저희가 상담해 드리고 최적의 솔루션을 제안하겠습니다.