본문 바로가기
개발 이야기

[iOS] 재사용 가능한 UI 컴포넌트 시스템: Container + Components 아키텍처 개발기 ✨

by Jimmy_iOS 2025. 5. 10.

들어가며: 컴포넌트 기반 설계가 필요했던 이유

안녕하세요! 오늘은 Container + Components 아키텍처를 어떻게 설계하고 개발했는지 이야기해볼게요.

복잡한 앱을 개발하다 보면 다양한 화면에서 일관된 사용자 경험을 제공하는 것이 중요해요. 특히 테이블, 컬렉션, 카드 UI 등이 앱 전반에 걸쳐 필요했는데, 이런 요소들을 효율적으로 관리할 방법이 필요했죠.

어떤 문제들이 있었나요?

개발 과정에서 다음과 같은 문제점들을 경험했어요:

  • 디자인 불일치 🎨: 개발자마다 다른 방식으로 구현해 UI 일관성이 떨어졌어요
  • 코드 중복 📝: 비슷한 기능을 가진 UI 요소마다 중복 코드가 발생했어요
  • 개발 시간 증가 ⏱️: 새로운 UI 요소 추가에 불필요하게 많은 시간이 소요됐어요
  • 유지보수 어려움 🔧: 디자인 변경 시 여러 곳을 수정해야 했어요

이런 문제들을 해결하기 위해 컴포넌트 기반 아키텍처를 적용한 UI 시스템을 설계하게 되었답니다.


Container + Components 아키텍처란 무엇인가요? 🧩

어떤 핵심 원칙을 가지고 있나요?

이 아키텍처는 다음 핵심 원칙을 기반으로 해요:

  1. 영역 분리 🏗️: UI를 헤더, 바디, 푸터로 논리적으로 분리해요
  2. 모듈성 🧱: 독립적인 컴포넌트를 조합해 복잡한 UI를 구성할 수 있어요
  3. 단일 책임 🎯: 각 컴포넌트가 명확한 하나의 책임만 담당해요
  4. 이벤트 위임 📢: 컴포넌트에서 발생한 이벤트를 상위 계층으로 위임해요

컨테이너와 컴포넌트는 어떻게 구분되나요?

이 아키텍처에서는 UI 요소를 두 가지 개념으로 구분해요:

  • 컨테이너(Container) 📦: 전체 UI 구조를 관리하고 컴포넌트들을 조합하는 상위 요소예요
  • 컴포넌트(Component) 🧩: 특정 UI 역할을 담당하는 독립적 모듈이에요 (헤더, 바디, 푸터)

데이터와 이벤트는 어떻게 흐르나요?

컨테이너와 컴포넌트 간의 데이터 흐름은 명확한 방향성을 가지고 있어요:

  • 하향식 데이터 흐름 ⬇️: 뷰 컨트롤러에서 컨테이너로, 컨테이너에서 각 컴포넌트로 데이터가 전달돼요
  • 상향식 이벤트 위임 ⬆️: 컴포넌트에서 발생한 이벤트는 컨테이너를 거쳐 뷰 컨트롤러로 위임돼요
  • 중앙화된 상태 관리 🎛️: 컨테이너가 전체 상태 관리를 담당해요
  • 순환적 업데이트 🔄: 이벤트 처리 후 새로운 데이터로 UI가 업데이트돼요

이런 구조는 UI 요소의 상태와 동작을 예측 가능하게 만들고, 컴포넌트 간 결합도를 낮춰 재사용성을 높일 수 있답니다!


아키텍처 구조는 어떻게 설계했나요? 🛠️

주요 구성 요소는 무엇인가요?

아키텍처는 세 가지 주요 구성 요소로 이루어져 있어요:

/// 모달 상단 영역의 컴포넌트를 위한 클래스
open class HeaderComponent: UIView {}

/// 모달 본문 영역의 컴포넌트를 위한 클래스
open class BodyComponent: UIView {}

/// 모달 하단 영역의 컴포넌트를 위한 클래스
open class FooterComponent: UIView {}

컨테이너는 어떻게 구현했나요?

컨테이너는 컴포넌트들을 관리하고 조합하는 역할을 해요:

public protocol ComponentContainer: UIView {
    var contentStackView: UIStackView { get }
    var headerComponent: HeaderComponent? { get }
    var bodyComponents: [BodyComponent] { get }
    var footerComponent: FooterComponent? { get }

    func configureHeader()
    func configureBody()
    func configureFooter()
}

주요 컨테이너 클래스에는 어떤 것들이 있나요?

컨테이너의 종류에 따라 다양한 구현체를 만들었어요:

  • TableComponentContainer: 모든 컨테이너의 기본이 되는 클래스예요
  • RatioTableContainerView: 행과 열로 구성된 테이블 UI를 위한 컨테이너예요
  • CardCollectionContainerView: 카드 형태의 UI를 위한 컨테이너예요

💡 핵심 포인트: 컨테이너는 헤더, 바디, 푸터 영역을 구성하는 메서드를 제공하며, 이 메서드들을 오버라이드해 다양한 UI를 만들 수 있어요!

컴포넌트 생명주기는 어떻게 관리하나요?

컴포넌트는 다음과 같은 생명주기를 가지고 있어요:

  1. 초기화: 컨테이너와 컴포넌트 생성, 초기 속성 설정
  2. 구성: 컴포넌트 조합, 레이아웃 설정, 이벤트 핸들러 연결
  3. 대기: 사용자 상호작용 또는 데이터 변경 대기
  4. 데이터 처리: 데이터 업데이트 및 UI 반영
  5. 이벤트 처리: 사용자 상호작용에 응답
  6. 소멸: 컴포넌트 해제 및 메모리 정리

데이터 모델은 어떻게 구성했나요? 📊

어떤 데이터 모델을 사용하나요?

컴포넌트에 표시될 데이터를 위한 다양한 모델을 설계했어요:

// 셀 컨텐츠 정보를 위한 프로토콜
public protocol CellContentInfo {
    var textColor: UIColor { get }
    var font: UIFont { get }
}

// 텍스트 기반 컨텐츠
public struct TextInfo: CellContentInfo {
    public let text: String
    public let font: UIFont
    public let textColor: UIColor
}

// 아이콘 기반 컨텐츠
public struct IconInfo: CellContentInfo {
    public let iconName: String
    public let font: UIFont
    public let textColor: UIColor
}

테이블 행 데이터는 어떻게 구성되나요?

테이블 형태의 UI를 위한 데이터 모델도 준비했어요:

public struct CustomTableRow {
    let contentInfos: [[CellContentInfo]]  // 각 열이 여러 줄의 컨텐츠를 가질 수 있어요
    let onTap: (() -> Void)?

    // 단일 라인 초기화
    public init(singleLineValues: [CellContentInfo], onTap: (() -> Void)? = nil)

    // 다중 라인 초기화
    public init(contentInfos: [[CellContentInfo]], onTap: (() -> Void)? = nil)
}

필터와 버튼은 어떻게 모델링했나요?

검색과 필터링을 위한 모델도 정의했어요:

// 필터 옵션
public struct FilterOption {
    public let id: String
    public let title: String
    public var isSelected: Bool
}

// 필터 정보
public struct TableFilterInfo {
    public let id: String
    public let title: String
    public let type: FilterType  // .single 또는 .multiple
    public var options: [FilterOption]
}

// 버튼 정보
public struct ButtonInfo {
    public let title: String
    public let isPrimary: Bool
    public let action: () -> Void
}

실제로 어떻게 사용하나요? 💻

테이블 컨테이너는 어떻게 사용하나요?

// 필터 설정
let filters = [
    TableFilterInfo(
        id: "status",
        title: "상태",
        type: .single,
        options: [
            FilterOption(id: "all", title: "전체", isSelected: true),
            FilterOption(id: "active", title: "활성"),
            FilterOption(id: "inactive", title: "비활성")
        ]
    )
]

// 테이블 헤더 설정
let tableHeader = TableHeaderInfo(
    titles: ["이름", "상태", "등록일"],
    alignments: [.left, .center, .right]
)

// 행 데이터 생성
let rows = [
    CustomTableRow(singleLineValues: [
        TextInfo(text: "사용자 1", font: .bodyMedium, textColor: .text(.default)),
        TextInfo(text: "활성", font: .bodyMedium, textColor: .text(.success)),
        TextInfo(text: "2023-05-01", font: .bodyMedium, textColor: .text(.default))
    ], onTap: {
        print("Row 1 tapped")
    })
    // 추가 행...
]

// 컨테이너 생성
let tableContainer = RatioTableContainerView(
    title: "사용자 목록",
    badges: [
        BadgeView(text: "전체", type: .default),
        BadgeView(text: "활성", type: .success)
    ],
    countSuffix: "명",
    showViewModeButton: true,

    filters: filters,
    searchPlaceholder: "이름으로 검색",

    tableHeader: tableHeader,
    rows: rows,
    rowHeight: 40,
    separatorStyle: .singleLine,
    separatorColor: UIColor.border(.gray),
    columnWidthRatios: [0.4, 0.3, 0.3],
    enablePagination: true,

    footerButtons: [
        ButtonInfo(title: "추가", isPrimary: true) { /* 추가 버튼 액션 */ }
    ],

    onSearchTextChanged: { text in /* 검색어 변경 처리 */ },
    onFilterChanged: { filterId, optionIds in /* 필터 변경 처리 */ },
    onLoadMore: { /* 추가 데이터 로드 처리 */ }
)

카드 컬렉션 컨테이너는 어떻게 사용하나요?

// 카드 컨테이너 생성
let cardContainer = CardCollectionContainerView(
    title: "과목 목록",
    badges: [
        BadgeView(text: "전체", type: .default),
        BadgeView(text: "수학", type: .primary)
    ],
    countSuffix: "개",
    showViewModeButton: true,

    filters: filters,
    searchPlaceholder: "과목명으로 검색",

    rows: rows,
    cardHeight: 120,
    cardSpacing: 16,
    enablePagination: true,
    viewMode: .card,

    footerButtons: [
        ButtonInfo(title: "추가", isPrimary: true) { /* 추가 버튼 액션 */ }
    ]
)

데이터 관리와 이벤트 처리는 어떻게 하나요? 🔄

데이터를 어떻게 업데이트하나요?

컨테이너가 제공하는 메서드로 데이터를 쉽게 업데이트할 수 있어요:

// 전체 데이터 업데이트
tableContainer.updateData(newRows)

// 데이터 추가
tableContainer.appendRows(additionalRows)

// 로딩 상태 설정
tableContainer.setLoading(true)

// 빈 상태 메시지 설정
tableContainer.setEmptyStateMessage("검색 결과가 없습니다")

필터와 검색은 어떻게 관리하나요?

필터와 검색 기능도 컨테이너에서 쉽게 관리할 수 있어요:

// 검색어 가져오기
let searchText = tableContainer.getSearchText()

// 필터 업데이트
tableContainer.updateFilter(newFilter)

// 선택된 필터 옵션 가져오기
let selectedOptions = tableContainer.getSelectedOptions(for: "status")

뷰 모드와 배지는 어떻게 관리하나요?

UI 상태도 간편하게 제어할 수 있어요:

// 뷰 모드 설정 (리스트/카드)
tableContainer.setViewMode(.card)

// 배지 비활성화
tableContainer.disableBadge(at: 1, animated: true)

// 배지 텍스트 업데이트
tableContainer.updateBadgeText("신규: 5", at: 0, animated: true)

이벤트는 어떻게 처리하나요?

이벤트 위임 패턴을 통해 컴포넌트에서 발생한 이벤트를 컨테이너로 전달해요:

// 검색어 변경 이벤트
onSearchTextChanged: { text in 
    // 검색어 변경 처리 
},

// 필터 변경 이벤트
onFilterChanged: { filterId, optionIds in 
    // 필터 변경 처리 
},

// 더 보기 이벤트
onLoadMore: { 
    // 추가 데이터 로드 처리 
}

새로운 컴포넌트는 어떻게 추가하나요? 🔨

커스텀 컴포넌트 만들기

기본 컴포넌트 클래스를 상속해 새로운 컴포넌트를 쉽게 만들 수 있어요:

public final class CustomHeaderComponent: HeaderComponent {
    // 헤더 컴포넌트 구현...
}

public final class CustomBodyComponent: BodyComponent {
    // 바디 컴포넌트 구현...
}

커스텀 컨테이너 만들기

기존 컨테이너를 확장하거나 새로운 컨테이너를 구현할 수 있어요:

public final class CustomContainerView: TableComponentContainer {
    // 필요한 프로퍼티 정의

    public override func configureHeader() {
        // 헤더 컴포넌트 설정
    }

    public override func configureBody() {
        // 바디 컴포넌트 설정
    }

    public override func configureFooter() {
        // 푸터 컴포넌트 설정
    }
}

커스텀 컬렉션 컨테이너는 어떻게 구현하나요?

특별한 UI 요구사항이 있을 때는 다음과 같은 단계로 컬렉션 컨테이너를 구현할 수 있어요:

 

1. 커스텀 바디 컴포넌트 만들기

public final class CustomCardComponent: BodyComponent {
    // 필요한 프로퍼티 정의
    private let collectionView: UICollectionView
    private var items: [YourDataModel] = []

    // 이벤트 핸들러 정의
    public var onItemSelected: ((String) -> Void)?

    // 초기화 메서드
    public init(
        items: [YourDataModel],
        cardHeight: CGFloat,
        cardSpacing: CGFloat,
        onItemSelected: ((String) -> Void)? = nil
    ) {
        // 컬렉션 뷰 설정
        let layout = UICollectionViewFlowLayout()
        layout.scrollDirection = .vertical
        layout.minimumLineSpacing = cardSpacing

        collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)

        // 속성 초기화
        self.items = items
        self.onItemSelected = onItemSelected

        super.init(frame: .zero)

        setupCollectionView()
    }

    // UI 설정
    private func setupCollectionView() {
        // 컬렉션 뷰 설정 코드
    }

    // 데이터 업데이트 메서드
    public func updateData(_ items: [YourDataModel]) {
        self.items = items
        collectionView.reloadData()
    }

    // 필수 초기화 메서드
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

2. 컨테이너 구현하기

public final class CustomCardCollectionView: TableComponentContainer {
    // 이벤트 핸들러 전달 구현
    public var onItemSelected: ((String) -> Void)? {
        didSet {
            (bodyComponents[1] as? CustomCardComponent)?.onItemSelected = onItemSelected
        }
    }

    // 컴포넌트 설정 메서드
    public override func configureHeader() {
        headerComponent = BadgeHeaderComponent(
            title: title,
            badges: badges,
            countSuffix: countSuffix
        )
    }

    public override func configureBody() {
        // 검색 필터 컴포넌트
        let searchFilter = SearchFilterComponent(/* 설정 */)

        // 커스텀 카드 컴포넌트
        let cardComponent = CustomCardComponent(/* 설정 */)

        // 바디 컴포넌트 설정
        bodyComponents = [
            searchFilter,
            cardComponent
        ]
    }
}

3. 편의 메서드 추가하기

extension CustomCardCollectionView {
    // 내부 컴포넌트 접근 프로퍼티
    private var cardComponent: CustomCardComponent? {
        return bodyComponents[1] as? CustomCardComponent
    }

    // 데이터 업데이트 메서드
    public func updateItems(_ items: [YourDataModel]) {
        cardComponent?.updateData(items)
        updateBadges(items.count)
    }
}

💡 핵심 포인트: 복잡한 UI 요소도 작은 컴포넌트로 나누고 조합해 단계적으로 구현할 수 있어요!


성능과 메모리는 어떻게 관리하나요? 🚀

성능 최적화를 위한 전략은 무엇인가요?

성능을 높이기 위해 다음과 같은 전략을 사용했어요:

  • 컴포넌트 재사용 🔄: 동일한 컴포넌트를 재사용해 메모리 사용을 최적화했어요
  • 지연 로딩 ⏳: 대용량 데이터는 페이지네이션으로 필요할 때만 로드해요
  • 부분 업데이트 🔍: 변경된 부분만 업데이트해 불필요한 렌더링을 방지해요

메모리 관리는 어떻게 하나요?

메모리 누수를 방지하기 위한 방법들이에요:

// 이벤트 핸들러 설정 시 메모리 누수 방지
container.onItemSelected = { [weak self] itemId in
    guard let self = self else { return }
    self.handleItemSelection(itemId)
}

// 컴포넌트 해제 시 이벤트 핸들러 제거
deinit {
    onItemSelected = nil
    onFilterChanged = nil
}

어떤 성과를 얻었나요? 📈

정량적으로 어떤 개선이 있었나요?

컴포넌트 기반 아키텍처 도입으로 얻은 성과들이에요:

  • 코드 중복 70% 감소 ✂️: 컴포넌트 재사용으로 중복 코드가 크게 줄었어요
  • UI 개발 시간 65% 단축 ⏱️: 새로운 UI 유형 개발이 3일에서 1일 이내로 줄었어요
  • 버그 발생률 50% 감소 🐞: 표준화된 컴포넌트로 예측 가능한 동작을 보장할 수 있었죠
  • 유지보수 시간 60% 절감 🔧: 중앙화된 컴포넌트로 변경 사항을 한 번에 적용할 수 있게 됐어요

어떤 문제들을 해결했나요?

1. 컴포넌트 간 의존성 관리는 어떻게 해결했나요?

문제 🤔: 컴포넌트 간 직접적인 참조로 의존성 문제가 발생했어요.

해결 💡: 이벤트 위임 패턴을 도입하여 컴포넌트에서 발생한 이벤트를 컨테이너로 위임하고, 컨테이너가 다른 컴포넌트에 필요한 액션을 전달하는 방식으로 변경했어요.

2. 다양한 화면에서 일관된 레이아웃은 어떻게 보장했나요?

문제 🤔: 디바이스 크기와 방향에 따라 레이아웃이 일관되지 않는 문제가 있었어요.

해결 💡: 헤더-바디-푸터 구조의 명확한 규칙을 정의하고, 자동 레이아웃 제약조건을 표준화하여 모든 환경에서 일관된 모습을 보장했어요!


주의해야 할 점은 무엇인가요? ⚠️

컴포넌트 기반 아키텍처를 적용할 때 주의할 점들이에요:

  1. 컴포넌트 간 의존성 최소화 🔄: 컴포넌트 간 직접 참조를 피하고 컨테이너를 통해 통신해요
  2. 메모리 누수 방지 🧹: 이벤트 핸들러 설정 시 [weak self]를 사용해 참조 순환을 방지해요
  3. 데이터 일관성 유지 📋: 컨테이너와 컴포넌트 간 데이터 동기화를 항상 확인해요
  4. 이벤트 처리 체계화 🔔: 모든 이벤트 핸들러를 적절히 설정하고 관리해요
// 잘못된 구현 - 직접 참조
class BadComponent: BodyComponent {
    var otherComponent: OtherComponent? // 직접 참조 ❌
}

// 올바른 구현 - 이벤트 위임
class GoodComponent: BodyComponent {
    var onEvent: (() -> Void)? // 이벤트 위임 ✅
}

어떤 교훈을 얻었나요? 🧠

Container + Components 아키텍처 개발을 통해 얻은 가장 큰 교훈은 "적절한 추상화와 명확한 경계 설정의 중요성"이에요. 컴포넌트의 책임 범위를 명확히 정의하고, 컨테이너와 컴포넌트 간의 상호작용을 표준화함으로써 복잡한 UI 요소도 체계적으로 관리할 수 있게 되었어요.

앞으로 어떤 방향으로 발전시킬 계획인가요?

향후 발전 방향으로는:

  1. 더 다양한 컴포넌트 개발 🧩: 복잡한 인터랙션을 지원하는 특수 목적 컴포넌트 추가
  2. SwiftUI 통합 ⚡: UIKit 기반 컴포넌트와 SwiftUI의 원활한 통합
  3. 애니메이션 시스템 강화 ✨: 다양한 애니메이션 옵션과 전환 효과 제공
  4. 분석 기능 추가 📊: 사용자 인터랙션 데이터 수집 및 분석 기능 추가
  5. 테스트 자동화 🧪: 컴포넌트 기반 UI 테스트 자동화 시스템 구축

마치며 🎁

Container + Components 아키텍처는 단순한 UI 라이브러리를 넘어 개발 방식과 협업 문화를 변화시켰어요.

디자이너와 개발자가 공통의 언어로 소통하며, 일관된 사용자 경험을 효율적으로 구현할 수 있게 되었답니다.

 

이런 컴포넌트 기반 아키텍처는 초기에는 설계와 구현에 더 많은 노력이 필요하지만, 장기적으로는 개발 생산성, 코드 품질, 유지보수성을 크게 향상시키는 투자임을 확인할 수 있었어요.

"한 번 작성하고, 어디서나 사용한다"는 철학이 실현된 사례로, 앞으로도 이러한 접근 방식을 다른 UI 요소에도 확장 적용할 계획입니다! 😊