📦공통된 CollectionView 재사용을 위한 Adpater 패턴 적용기

728x90

 

 

GitHub - EST-iOS-TEAM2/Pinit: ESTSoft 2차 팀프로젝트

ESTSoft 2차 팀프로젝트. Contribute to EST-iOS-TEAM2/Pinit development by creating an account on GitHub.

github.com

 

UI 재사용에 대한 고민

Home, PastPin, Setting에 들어가는 데이터나 동작이 동일하기에 이를 통합해 재사용할 방법을 고민했다.

  1. 어댑터 패턴
  2. collectionView 서브클래싱

 

서브클래싱이 가장 익숙한 방식이었지만, 어댑터 패턴을 선택했다.

그 이유는 데이터가 setting 화면에서는 동일하지 않을 수 있고, 그에따른 확장이 필요한 경우,

datasource나 delegate를 기본을 사용하면서도 다른 동작이 필요할 때 따로 정의해서 주입해줘 선택적으로 사용이 가능하기 때문이다.

그리고 무엇보다 VC에 책임을 덜 수 있는게 좋았다. (MVC라서...)

 

우린 MVC 패턴을 사용했는데 여기서 컬렉션뷰 만들때 레이아웃, 델리게이트, 데이터 소스, 데이터 관리 등 을 VC가 맡았어야하는데, 그걸 Adapter로 분리해서 VC의 책임을 줄인것

근데 결론부터 말하자면 Setting에서는 어댑터를 사용 못했다.. 데이터 모델이 다른걸 감안해 대응해야했지만, 그 사이에 지독한 감기가 걸려서 지금까지 작업을 제대로 할 여력이 안나서…

일단 사용한 어댑터 패턴 코드를 살펴보자

CollectionView Adapter 작성

// MARK: CollectionView Adapter Delegate
protocol PinCollectionViewAdapterDelegate: AnyObject {
    // 선택된 아이템 넘겨줌
    func selectedItem(selected: PinEntity, indexPath: IndexPath)
    // 삭제된 아이템 넘겨줌 (id만 넘겨줄지 고민중 CoreData에서 id에 따라 삭제하는거 말고는 필요 없어서)
    func deletedItem(deleted: PinEntity?, indexPath: IndexPath)
}

// MARK: CollectionView Adapter
final class PinCollectionViewAdapter: NSObject {
    var data: [PinEntity] = [] // 어댑터에서 datasource 관리
    var delegate: PinCollectionViewAdapterDelegate? // CollectionView 선택, 삭제등의 결과를 받기위한 Delegate
    init(
        collectionView: UICollectionView,
        width: CGFloat
    ) {
        super.init()
// 레이아웃 설정
        let layout = UICollectionViewFlowLayout()
        let spacing = 16.0
        let width = (width / 2) - (spacing * 1.5)
        layout.itemSize = .init(width: width, height: width * 1.23)
        layout.minimumInteritemSpacing = spacing
        layout.sectionInset = .init(top: 0, left: spacing, bottom: spacing, right: spacing)
// CollectionView 설정
        collectionView.setCollectionViewLayout(layout, animated: false)
        collectionView.register(PinRecordCell.self, forCellWithReuseIdentifier: "cell")
        collectionView.backgroundColor = .clear
        collectionView.dataSource = self
        collectionView.delegate = self
    }
}

// MARK: CollectionView DataSource
extension PinCollectionViewAdapter: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        data.count
    }

    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as? PinRecordCell
        else { return UICollectionViewCell() }

        cell.thumbnailImageView.image = nil
        cell.configure(model: data[indexPath.row])
        cell.layoutIfNeeded()

        return cell
    }
}

// MARK: CollectionView Delegate
extension PinCollectionViewAdapter: UICollectionViewDelegate {
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        delegate?.selectedItem(selected: data[indexPath.row], indexPath: indexPath) // 선택된 아이템 delegate로 보냄
    }

    func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemsAt indexPaths: [IndexPath], point: CGPoint) -> UIContextMenuConfiguration? {
        // 단일 선택의 컨텍스트 메뉴만 지원할거임
        guard let indexPath = indexPaths.first else { return nil }

        return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { elements in
            let deleteAction = UIAction(title: "삭제", image: UIImage(systemName: "trash"), attributes: .destructive) {[weak self] action in
                let deleted = self?.data.remove(at: indexPath.row)
                self?.delegate?.deletedItem(deleted: deleted, indexPath: indexPath) // 삭제된 아이템 delegate로 보냄
            }
            return UIMenu(title: "", children: [deleteAction])
        }
    }
}

어댑터 객체를 만들고 초기화할때 사용할 CollectionView를 초기화시 적용시켜주면 된다.

내부 구조는 간단하기도 하고 주석으로 설명이 좀 됐을테니 넘어가기

사실 최종 앱에는 Setting 화면 CollectionView에는 적용을 하지 못했다.. 중간에 감기가 심하게 걸려 프로젝트 막바지까지 수정을 제대로 못했다.

그래서 현업 멘토님께 설명드릴때 했던 여기서 만약 우리가 Setting 화면에도 사용가능하게 변경할려면 어떻게 할지 알아보자.

 

만약 확장을 해본다면,

모델 구조

title, name, date 등은 Setting화면, PinCollectionView 화면에서 동일하게 보여주고 가지고있는 데이터다

이를 Protocol로 만들어 각 모델이 채택하여 구현하게 하고 Adapter에서는 Generic으로 데이터를 받게 한다면!

Protocol을 채택하고 있는 데이터타입이라면 모두 사용가능하게 확장할 수 있다.

final class PinCollectionViewAdapter<T: CollectionData>: NSObject {...} 

셀 생성 Datasource

만약 모두 동일하지만, Datasource에 셀 클래스가 달라져야할 경우는?

  1. 셀 등록 따로 받기
  2. UICollectionViewDiffableDataSource 에서 CellProvider 개념을 이용해 클로저로 이를 구현하기

예시 코드

final class PinCollectionViewAdapter<T: CollectionData>: NSObject
    var data: [T] = []
    var cellProvider: (UICollectionView, IndexPath, T) -> UICollectionViewCell
        ...
    init(
        collectionView: UICollectionView,
        cellProvider: @escaping (UICollectionView, IndexPath, T) -> UICollectionViewCell
    ) {
        self.cellProvider = cellProvider
                ...
    }
        ...
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        cellProvider(collectionView, indexPath, data[indexPath.row])
    }
}

물론 어댑터가 클로저를 가지고 있기에 해제될때 메모리릭을 방지하기위해 클로저 내부에는 weak 캡처를 사용하는 방식을 사용해야한다.

사실 여전히 이게 어댑터 패턴을 쓸 정도인가? 라는 의문이 들었었지만, 멘토링때 이 방식을 설명하며 확장도 고려하고 사용한 이유와 방향성을 설명드리니 좋다고 피드백해주셔서 걱정을 덜었다.

그래도 두 방식에 대해 좀 자세히 알아보고 비교해보는 시간을 따로 가져야할거 같다.

 

근데 어댑터 패턴 설명 쓰면서 느낀건데

DiffableDatasource가 어댑터 패턴 개념을 차용해서 만든거같음

DiffableDatasource 쓰면 collectionView나 TableView에 datasource 설정 안해주고 그냥 DiffableDatasource init할때 collectionView 주입만 해주면 알아서 잘 되는데

이번 어댑터 패턴을 구현해보며 유사하다는걸 느꼈다.

Adapter VS Subclass

Adapter

장점

항목 설명
책임 분리 (Separation of Concerns) ViewController에서 UI 구성, 데이터 처리, 비즈니스 로직을 분리할 수 있어 유지보수와 테스트가 쉬움
재사용성 동일한 UI 구성 로직을 다양한 데이터 타입에 맞춰 재활용 가능 (ex. Generic, Protocol, CellProvider 등)
확장성 다양한 상황에 따라 delegate, datasource, 레이아웃 전략 등을 유연하게 바꿀 수 있음
테스트 용이성 UI 테스트와 비즈니스 로직 테스트를 명확히 분리할 수 있어 단위 테스트 작성에 유리
낮은 결합도 ViewController와 Adapter는 느슨한 연결로 유지되어 독립적인 수정이 가능함

단점

항목 설명
초기 구현 비용 익숙하지 않다면 설계/도입에 시간과 고민이 필요함
추가 타입 증가 작은 프로젝트에서는 불필요한 파일 증가와 복잡성 유발 가능
오버엔지니어링 우려 기능이 단순하거나 하나의 화면에만 사용될 경우 과도한 구조가 될 수 있음

 


Subclass

장점

항목 설명
구현 간단 익숙하고 빠르게 UI를 구성 가능. 초반 러닝커브가 없음
작은 화면에 적합 단일 기능, 고정된 구조의 화면에서는 빠르게 개발 가능
뷰 중심의 커스터마이징 커스텀 제스처나 스크롤 제어 등을 쉽게 직접 제어 가능

단점

항목 설명
책임 과다 (Massive ViewController) 셀 구성, 데이터 관리, 이벤트 처리 모두 VC에 몰리기 쉬움
재사용 어려움 구조적 재사용보다는 복사/붙여넣기로 처리하게 될 가능성 있음
확장성 낮음 같은 기능이라도 데이터 타입이나 셀이 조금만 달라도 중복 로직이 발생함
테스트 어려움 많은 로직이 VC에 엮여있어 테스트 코드 작성이 복잡함

✍️ 요약

어댑터 패턴이 더 적합한 경우

  • 데이터 모델이 다양한 화면에서 공유되며, 동일한 UI구성이 반복될 때
  • 한 번 만든 Cell, Adapter로 다양한 ViewController에서 재사용하려는 경우
  • ViewController가 너무 많은 역할을 갖고 있을 때 책임 분리가 필요할 경우

서브클래싱이 더 적합한 경우

  • UI가 단순하고 하나의 화면에만 특화된 로직일 때
  • 구현이 직관적이고 빠르게 끝나야 할 때
  • 재사용보단 캡슐화된 형태로 독립적인 구성이 필요할 때
항목 Adapter 패턴 UICollectionView 서브클래싱
책임 분리 VC의 역할을 명확히 분리 (DataSource, Delegate 외부로) 대부분 VC 안에 뷰와 데이터 로직이 섞이기 쉬움
유연성 (확장성) 다양한 데이터 모델과 셀에 대응 가능 (제네릭, 클로저 등 활용) 뷰 중심 구조로 다른 데이터 모델에 유연하게 대응하기 어려움
재사용성 여러 화면에서 동일 로직으로 재사용 가능 서브클래싱한 View 자체를 재사용하는 방식이라 제약 있음
구현 난이도 클로저, 제네릭 등을 이해하고 설계해야 함 익숙한 방식으로 빠르게 구현 가능
테스트 용이성 독립 객체로 테스트 수월함 VC 내부에 의존해 테스트가 어려움
학습 곡선 디자인 패턴 이해 필요 UIKit 개발 경험이 있으면 바로 사용 가능

결론

  • 단기 프로젝트 또는 간단한 개발에는: 서브클래싱이 빠르고 직관적임
  • 장기 유지보수, 확장이 필요할때: Adapter 패턴이 명확한 책임 분리와 재사용성 확보 측면에서 유리함

확장 적용 예시 코드!

일단 알아보기 쉽게 사용하는 방법 및 구성을 샘플로 작성해봤다!

protocol CollectionData{
    var title: String { get set }
}

struct A: CollectionData {
    var title: String
    var age: Int
}

struct B: CollectionData {
    var title: String
    var height: Double
}

class ViewController: UIViewController {
    var collectionView: UICollectionView = UICollectionView()
    var adapter: PinCollectionViewAdapter<A>
    var coordinator: HomeCoordinator?

    override func viewDidLoad() {
        super.viewDidLoad()

        adapter = PinCollectionViewAdapter<A>(collectionView: collectionView, cellProvider: { collectionView, indexPath, data in
            guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath)
            else { return UICollectionViewCell() }
            return cell
        })
    }
}

final class PinCollectionViewAdapter<T: CollectionData>: NSObject, UICollectionViewDataSource {
    var data: [T] = []
    var cellProvider: (UICollectionView, IndexPath, T) -> UICollectionViewCell

    init(
        collectionView: UICollectionView,
        cellProvider: @escaping (UICollectionView, IndexPath, T) -> UICollectionViewCell
    ) {
        self.cellProvider = cellProvider

    }

    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        cellProvider(collectionView, indexPath, data[indexPath.row])
    }
}

진짜 마지막 짧은 요약!!

  • 서브클래싱은 구현 편의에 유리하고, 어댑터는 역할 분리와 재사용에 유리하다.
  • 둘 다 “완전한 분리”는 아니며 트레이드오프가 존재한다.

 

728x90