들어가며: 컴포넌트 기반 설계가 필요했던 이유
안녕하세요! 오늘은 Container + Components 아키텍처를 어떻게 설계하고 개발했는지 이야기해볼게요.
복잡한 앱을 개발하다 보면 다양한 화면에서 일관된 사용자 경험을 제공하는 것이 중요해요. 특히 테이블, 컬렉션, 카드 UI 등이 앱 전반에 걸쳐 필요했는데, 이런 요소들을 효율적으로 관리할 방법이 필요했죠.
어떤 문제들이 있었나요?
개발 과정에서 다음과 같은 문제점들을 경험했어요:
- 디자인 불일치 🎨: 개발자마다 다른 방식으로 구현해 UI 일관성이 떨어졌어요
- 코드 중복 📝: 비슷한 기능을 가진 UI 요소마다 중복 코드가 발생했어요
- 개발 시간 증가 ⏱️: 새로운 UI 요소 추가에 불필요하게 많은 시간이 소요됐어요
- 유지보수 어려움 🔧: 디자인 변경 시 여러 곳을 수정해야 했어요
이런 문제들을 해결하기 위해 컴포넌트 기반 아키텍처를 적용한 UI 시스템을 설계하게 되었답니다.
Container + Components 아키텍처란 무엇인가요? 🧩
어떤 핵심 원칙을 가지고 있나요?
이 아키텍처는 다음 핵심 원칙을 기반으로 해요:
- 영역 분리 🏗️: UI를 헤더, 바디, 푸터로 논리적으로 분리해요
- 모듈성 🧱: 독립적인 컴포넌트를 조합해 복잡한 UI를 구성할 수 있어요
- 단일 책임 🎯: 각 컴포넌트가 명확한 하나의 책임만 담당해요
- 이벤트 위임 📢: 컴포넌트에서 발생한 이벤트를 상위 계층으로 위임해요
컨테이너와 컴포넌트는 어떻게 구분되나요?
이 아키텍처에서는 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를 만들 수 있어요!
컴포넌트 생명주기는 어떻게 관리하나요?
컴포넌트는 다음과 같은 생명주기를 가지고 있어요:
- 초기화: 컨테이너와 컴포넌트 생성, 초기 속성 설정
- 구성: 컴포넌트 조합, 레이아웃 설정, 이벤트 핸들러 연결
- 대기: 사용자 상호작용 또는 데이터 변경 대기
- 데이터 처리: 데이터 업데이트 및 UI 반영
- 이벤트 처리: 사용자 상호작용에 응답
- 소멸: 컴포넌트 해제 및 메모리 정리
데이터 모델은 어떻게 구성했나요? 📊
어떤 데이터 모델을 사용하나요?
컴포넌트에 표시될 데이터를 위한 다양한 모델을 설계했어요:
// 셀 컨텐츠 정보를 위한 프로토콜
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. 다양한 화면에서 일관된 레이아웃은 어떻게 보장했나요?
문제 🤔: 디바이스 크기와 방향에 따라 레이아웃이 일관되지 않는 문제가 있었어요.
해결 💡: 헤더-바디-푸터 구조의 명확한 규칙을 정의하고, 자동 레이아웃 제약조건을 표준화하여 모든 환경에서 일관된 모습을 보장했어요!
주의해야 할 점은 무엇인가요? ⚠️
컴포넌트 기반 아키텍처를 적용할 때 주의할 점들이에요:
- 컴포넌트 간 의존성 최소화 🔄: 컴포넌트 간 직접 참조를 피하고 컨테이너를 통해 통신해요
- 메모리 누수 방지 🧹: 이벤트 핸들러 설정 시
[weak self]
를 사용해 참조 순환을 방지해요 - 데이터 일관성 유지 📋: 컨테이너와 컴포넌트 간 데이터 동기화를 항상 확인해요
- 이벤트 처리 체계화 🔔: 모든 이벤트 핸들러를 적절히 설정하고 관리해요
// 잘못된 구현 - 직접 참조
class BadComponent: BodyComponent {
var otherComponent: OtherComponent? // 직접 참조 ❌
}
// 올바른 구현 - 이벤트 위임
class GoodComponent: BodyComponent {
var onEvent: (() -> Void)? // 이벤트 위임 ✅
}
어떤 교훈을 얻었나요? 🧠
Container + Components 아키텍처 개발을 통해 얻은 가장 큰 교훈은 "적절한 추상화와 명확한 경계 설정의 중요성"이에요. 컴포넌트의 책임 범위를 명확히 정의하고, 컨테이너와 컴포넌트 간의 상호작용을 표준화함으로써 복잡한 UI 요소도 체계적으로 관리할 수 있게 되었어요.
앞으로 어떤 방향으로 발전시킬 계획인가요?
향후 발전 방향으로는:
- 더 다양한 컴포넌트 개발 🧩: 복잡한 인터랙션을 지원하는 특수 목적 컴포넌트 추가
- SwiftUI 통합 ⚡: UIKit 기반 컴포넌트와 SwiftUI의 원활한 통합
- 애니메이션 시스템 강화 ✨: 다양한 애니메이션 옵션과 전환 효과 제공
- 분석 기능 추가 📊: 사용자 인터랙션 데이터 수집 및 분석 기능 추가
- 테스트 자동화 🧪: 컴포넌트 기반 UI 테스트 자동화 시스템 구축
마치며 🎁
Container + Components 아키텍처는 단순한 UI 라이브러리를 넘어 개발 방식과 협업 문화를 변화시켰어요.
디자이너와 개발자가 공통의 언어로 소통하며, 일관된 사용자 경험을 효율적으로 구현할 수 있게 되었답니다.
이런 컴포넌트 기반 아키텍처는 초기에는 설계와 구현에 더 많은 노력이 필요하지만, 장기적으로는 개발 생산성, 코드 품질, 유지보수성을 크게 향상시키는 투자임을 확인할 수 있었어요.
"한 번 작성하고, 어디서나 사용한다"는 철학이 실현된 사례로, 앞으로도 이러한 접근 방식을 다른 UI 요소에도 확장 적용할 계획입니다! 😊