iOS
UIKit 환경에서 SwiftUI 적용기: 여기어때 Home 화면 편
레이너Raynor(최성욱) / iOS 개발팀여기어때
2024년 12월 5일
원문에서 보기 ↗안녕하세요. 여기어때컴퍼니 iOS개발팀 레이너입니다.
여기어때 앱은 오랜 시간 UIKit 기반으로 안정적으로 구축되어 왔는데요, 최근 몇 년간 SwiftUI가 점차 성숙해지면서 앱 주요 화면을 SwiftUI로 전환해 보는 시도를 시작하게 되었습니다. SwiftUI는 선언형 구성을 통해 코드를 간결하게 만들고, 유지 보수도 더 쉬워질 가능성을 제공해 주죠. 이번 변화의 첫 발걸음으로 Home 화면을 SwiftUI로 재구성해, 기존 UIKit 방식과의 차이를 실험해 보고자 했습니다.
이번 글에서는 SwiftUI를 적용하면서 경험한 다양한 인사이트와 해결해야 했던 과제들, 그리고 전환을 통해 얻은 변화와 기대 효과를 공유드리려 합니다. SwiftUI 도입을 고민 중이신 분들께 저희의 경험이 작은 도움이 되기를 바랍니다.
1. SwiftUI 도입 배경과 목표
여기어때 앱은 그동안 다양한 기능을 추가하며 점진적으로 UIKit 기반의 안정적인 구조를 다져왔습니다. 하지만 앱의 규모가 커지고, 유지보수의 복잡도가 높아지면서 보다 간결하고 선언형 방식에 적합한 프레임워크의 필요성을 느끼기 시작했습니다. SwiftUI는 Apple이 제안한 선언형 UI 프레임워크로, 코드 작성이 간편할 뿐만 아니라, UI와 로직이 보다 명확하게 분리되어 관리됩니다.
SwiftUI의 적용을 통해 기대한 목표는 크게 두 가지입니다. 첫 번째는 코드의 단순화입니다. SwiftUI는 UI 상태에 따라 UI를 자동으로 갱신해주는 구조를 갖추고 있어, 기존에 UIKit에서 다뤘던 수많은 이벤트 관리 코드나 인터페이스 상태 관리에 관한 로직을 간소화할 수 있습니다. 두 번째는 향후 확장성과 유지 보수성 향상입니다. SwiftUI는 시간이 지나면서 Apple 생태계 내에서 더 확장될 것으로 기대되고 있어, 여기어때 앱의 다른 화면에도 SwiftUI를 점차 확장해 나가는 데 이번 초기 도입 경험이 좋은 밑거름이 될 것으로 기대하고 있습니다.
2. UIKit과 SwiftUI의 차이점
UIKit과 SwiftUI는 UI를 구성하는 방식부터 완전히 다른 접근법을 사용합니다. UIKit은 명령형 방식으로, 모든 UI 요소의 변화에 대해 직접적인 업데이트를 가해야 하는 반면, SwiftUI는 선언형 방식으로 앱 상태 변화에 따라 UI가 자동으로 재구성됩니다.
이 차이는 코드 구성 방식에 큰 영향을 미쳤습니다. UIKit에서는 뷰 컨트롤러에서 UI를 갱신하기 위한 상태를 추적하고 그에 맞는 이벤트를 처리해야 했습니다. 반면, SwiftUI는 상태(State)와 데이터 바인딩을 통해 화면의 일관된 상태를 관리합니다. 이러한 구조 덕분에 개발자는 더 적은 코드로도 동일한 인터페이스를 구현할 수 있고, UI 상태와 비즈니스 로직을 깔끔하게 분리할 수 있습니다.
3. Home 화면에서 SwiftUI 적용 과정
1) SwiftUI에 ReactorKit을 사용하기
여기어때 앱은 기존에 UIKit 기반의 ReactorKit을 활용하여 상태를 관리하고 로직을 분리해왔습니다. 하지만 SwiftUI는 UIKit과 근본적으로 다른 선언형 UI 패러다임을 사용하기 때문에, 기존 ReactorKit 코드를 수정 없이 SwiftUI에 통합하기 위해 방법론에 대한 고민이 필요했습니다.
이에 ReactorKit-SwiftUI 라이브러리를 채택하여 이 문제를 해결했습니다. 이 라이브러리는 기존 ReactorKit 코드를 그대로 유지하면서 SwiftUI의 상태 관리와 연동을 가능하게 해줍니다. ObservableObject와 ReactorKit의 상태 및 액션 흐름을 연결하여, SwiftUI에서도 ReactorKit의 장점을 그대로 활용할 수 있었습니다.
예를 들어, SwiftUI 뷰는 Reactor와 연결되어 다음과 같은 방식으로 상태와 액션을 처리합니다
import SwiftUIReactorKit
struct ReskinHomeView: ReactorView {
var reactor: ReskinHomeReactor
func body(reactor: ReskinHomeReactor.ObservableObject) -> some View {
ScrollView(showsIndicators: false) {
VStack(spacing: 0) {
if let uiModels = reactor.state.uiModels {
ForEach(uiModels, id: \.id) { model in
// 각 모델에 따른 뷰들 생성
}
}
}
}
}
}
이 방식을 통해 기존 UIKit 기반 ReactorKit 코드를 최소한의 수정으로 SwiftUI에서 재사용할 수 있었습니다. 또한, ReactorKit-SwiftUI는 RxSwift와 SwiftUI 간의 연동 문제를 해결하며 개발 속도를 크게 향상시켰습니다.
하지만 앞으로는 RxSwift 의존도를 줄이고, 애플의 공식 프레임워크인 Combine으로 전환하기 위한 새로운 방식을 모색하고 있습니다. Combine은 SwiftUI와 자연스럽게 통합되어 있으며, 애플 생태계와의 높은 호환성을 제공한다는 장점이 있습니다.
따라서, 이번 도입은 SwiftUI 환경에서 기존 코드를 최대한 활용하며 빠르게 전환하기 위한 과도기적 접근 방식으로 보실 수 있습니다. 향후 단계에서는 Combine을 기반으로 더 단순하고 네이티브한 구현을 목표로 리팩토링을 진행할 계획입니다. 이는 코드 유지 보수성과 일관성을 높이고, 애플 생태계의 최신 기술을 최대한 활용하기 위함입니다.
이번 경험은 RxSwift와 SwiftUI의 연동에서 시작해 Combine으로의 전환을 준비하는 과정으로서 중요한 학습과 실험의 기회가 되었습니다.
2) UIKit 생명주기와 SwiftUI의 차이 해결
SwiftUI는 UIKit과는 다른 선언형 패러다임을 기반으로 동작하기 때문에, UIKit에서 익숙했던 뷰 컨트롤러 생명주기 이벤트 (viewDidLoad, viewWillAppear, viewDidAppear 등)를 바로 사용할 수 없습니다. 이는 특히 기존의 ReactorKit과 같은 상태 관리 도구를 SwiftUI에서 사용할 때, 특정 시점에 액션을 트리거해야 하는 경우에 문제로 작용했습니다.
이를 해결하기 위해 생명주기 이벤트를 SwiftUI에서 쉽게 사용할 수 있도록 LifeCycleModifier를 구현하여 사용했습니다. 아래는 해당 구현의 예제입니다
public extension View {
func onWillAppear(_ perform: @escaping () -> Void) -> some View {
modifier(LifeCycleModifier(onWillAppear: perform))
}
func onDidAppear(_ perform: @escaping () -> Void) -> some View {
modifier(LifeCycleModifier(onDidAppear: perform))
}
func onDidLoad(_ perform: @escaping () -> Void) -> some View {
modifier(LifeCycleModifier(onDidLoad: perform))
}
func onLifeCycleEvents(onWillAppear: (() -> Void)? = nil,
onDidAppear: (() -> Void)? = nil,
onDidLoad: (() -> Void)? = nil) -> some View {
modifier(LifeCycleModifier(onWillAppear: onWillAppear,
onDidAppear: onDidAppear,
onDidLoad: onDidLoad))
}
}
struct LifeCycleModifier: ViewModifier {
var onWillAppear: (() -> Void)?
var onDidAppear: (() -> Void)?
var onDidLoad: (() -> Void)?
func body(content: Content) -> some View {
content.background(UIViewLifeCycleHandler(onWillAppear: onWillAppear,
onDidAppear: onDidAppear,
onDidLoad: onDidLoad))
}
}
struct UIViewLifeCycleHandler: UIViewControllerRepresentable {
typealias UIViewControllerType = UIViewController
var onWillAppear: (() -> Void)?
var onDidAppear: (() -> Void)?
var onDidLoad: (() -> Void)?
func makeUIViewController(context: UIViewControllerRepresentableContext<Self>) -> UIViewControllerType {
context.coordinator
}
func updateUIViewController(_: UIViewControllerType, context _: UIViewControllerRepresentableContext<Self>) {}
func makeCoordinator() -> Self.Coordinator {
Coordinator(onWillAppear: onWillAppear,
onDidAppear: onDidAppear,
onDidLoad: onDidLoad)
}
class Coordinator: UIViewController {
let onWillAppear: (() -> Void)?
let onDidAppear: (() -> Void)?
let onDidLoad: (() -> Void)?
init(onWillAppear: (() -> Void)? = nil, onDidAppear: (() -> Void)? = nil, onDidLoad: (() -> Void)? = nil) {
self.onWillAppear = onWillAppear
self.onDidAppear = onDidAppear
self.onDidLoad = onDidLoad
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
onWillAppear?()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
onDidAppear?()
}
override func viewDidLoad() {
super.viewDidLoad()
onDidLoad?()
}
}
}
이렇게 구현한 LifeCycleModifier는 UIKit의 생명주기 이벤트를 SwiftUI에 통합하는 브릿지 역할을 수행합니다. 이를 통해 특정 시점에서 ReactorKit 액션을 실행하거나 초기 설정 코드를 작성하는 작업이 가능해졌습니다.
ReskinHomeView()
.onDidLoad {
print("뷰가 로드된 후 한 번만 실행됩니다.")
//홈 데이터 load
reactor.action.onNext(.loadHome)
}
.onWillAppear {
print("뷰가 화면에 나타나기 직전 실행됩니다.")
}
.onDidAppear {
print("뷰가 화면에 완전히 나타난 후 실행됩니다.")
//화면 보여질때마다 GTM 전송, 탭바 노출 코드 작성
}
이와 같은 접근은 UIKit과 SwiftUI의 차이를 메우는 과도기적 해결책으로, 기존 UIKit 생명주기 이벤트를 SwiftUI 환경에서도 간편하게 사용할 수 있도록 만들어 줍니다.
이 방식을 통해 다음과 같은 효과를 얻을 수 있었습니다.
- 코드 재사용성 극대화: UIKit의 생명주기 이벤트를 최소한의 수정으로 SwiftUI에서도 활용 가능
- SwiftUI 도입 초기 안정성 확보: 기존 프로젝트와의 호환성을 유지하며 SwiftUI 도입을 시도
- 개발 속도 향상: 특정 시점에서 실행해야 하는 로직을 직관적으로 구현 가능
3) 복잡한 CMS 데이터 구조와 앱 모델의 모듈화
홈 화면 데이터를 구성하는 CMS는 매우 복잡한 트리 구조를 갖고 있습니다. 데이터는 상위 모델에서부터 하위 모듈과 컴포넌트에 이르기까지 다층적으로 나뉘며, 각 단계마다 세부 속성이 추가됩니다. 이를 간략화한 구조는 다음과 같습니다.
HomCMSModel
├── code: Int
├── data: Struct
│ ├── id: Int
│ ├── exhibitYn: Bool
│ ├── ... 생략 ...
│ └── modules: [Array] // 여러 모듈 정보 (배열)
│ ├── id: Int
│ ├── exhibitYn: Bool
│ ├── typeLabel: String // 모듈 타입(category 외 13개 모듈들이 존재)
│ ├── ... 생략 ...
│ └── components: [Array]
│ ├── id: Int
│ ├── exhibitYn: Bool
│ ├── typeLabel: String // 컴포넌트 타입(title 외 8개 컴포넌트들이 존재)
│ └── ... 생략 ...
이와 같은 복잡한 트리 구조에서 각 모듈 및 컴포넌트는 typeLabel을 통해 구별됩니다. 예를 들어, typeLabel이 category인 모듈 외에도 총 13개의 모듈 타입이 존재하고, components 배열 내에서도 title, product와 같은 다양한 컴포넌트 타입이 포함되어 있습니다. 이는 전체 데이터를 매우 복잡하게 만들지만, 이를 각 모듈에 맞는 앱 모델로 변환하여 보다 효율적으로 관리할 수 있습니다.
앱 모델의 모듈화
복잡한 CMS 데이터를 효율적으로 관리하기 위해, 각 모듈에 맞는 앱 모델을 별도로 설계하였습니다. 각 모델은 필요한 데이터만을 필터링하여 화면에 표시될 콘텐츠를 구성하는 데 필요한 정보만을 담고 있습니다. 이 방법으로 CMS의 복잡성을 줄이고, 앱 내에서 필요한 정보에만 집중할 수 있습니다.
예를 들어, 두 가지 주요 앱 모델을 통해 각 모듈에 맞는 데이터를 처리합니다.
//홈 카테고리 모델
struct HomeCategoryAppModel: HomeAppModel, Identifiable, Equatable {
var id: UUID = UUID()
var colSize: Int? // 열 개수
var rowSize: Int? // 행 개수
var items: [HomeCategoryItem]?
}
//홈 상품 모델
struct HomeProductAppModel: HomeAppModel, Identifiable, Equatable {
var id: UUID = UUID()
var landingValue: String? //서버 URL path
var productItems: [HomeProductItem]?
var checkIn: String?
var checkOut: String?
}
이러한 방식으로 각 모듈 및 컴포넌트에 대한 앱 모델을 정의하고, CMS 데이터를 보다 쉽게 관리하고 화면에 맞게 변환할 수 있습니다. 이 접근법을 통해 앱의 성능과 유지보수성을 크게 향상시킬 수 있었습니다.
CMS 데이터를 앱 모델로 변환하기
복잡한 CMS 데이터 구조를 효율적으로 처리하기 위해, HomeCMSConverter를 사용하여 CMS 데이터를 HomeCategoryAppModel, HomeProductAppModel 등으로 변환합니다. HomeCMSConverter는 CMS 데이터를 받아 적절한 앱 모델로 변환하는 핵심적인 역할을 합니다. 예를 들어, category 유형의 모듈은 HomeCategoryAppModel로 변환되고, hotel, pension와 같은 다른 유형은 HomeProductAppModel로 변환됩니다.
class HomeCMSConverter {
init() {}
func convertToAppModel(cmsScreen: CMSScreen?) -> [HomeAppModel] {
var appModels: [HomeAppModel] = []
if let moduleList: [CMSModule] = cmsScreen?.modules {
appModels = makeModuleAppModel(modules: moduleList)
}
return appModels
}
private func makeModuleAppModel(modules: [CMSModule]) -> [HomeAppModel] {
var results: [HomeAppModel] = []
for (moduleIndex, module) in modules.enumerated() {
guard module.exhibitYn == true else { continue }
guard let moduleTypeLabel = module.typeLabel,
let moduleType = HomeModuleType(rawValue: moduleTypeLabel) else { continue }
switch moduleType {
case .category:
if let colSize = module.colSize,
let rowSize = module.rowSize,
let components = module.components {
var items: [HomeCategoryItem] = []
for component in components {
items.append(HomeCategoryItem(componentName: component.name,
contentId: component.image?.contents?.first?.id,
...)
}
let homeCategoryAppModel = HomeCategoryAppModel(colSize: colSize,
rowSize: rowSize,
items: items,
...)
results.append(homeCategoryAppModel)
}
case .hotel, .pension, ...:
results.append(contentsOf: makeComponentAppModel(moduleIndex: moduleIndex, module: module, components: module.components))
// 그 외 case들 생략
}
}
return results
}
private func makeComponentAppModel(moduleIndex: Int, module: CMSModule, components: [CMSComponent]?) -> [HomeAppModel] {
guard let components = components else { return [] }
var results: [HomeAppModel] = []
for (componentIndex, component) in components.enumerated() {
guard component.exhibitYn == true else { continue }
guard let moduleTypeLabel = module.typeLabel,
let moduleType = HomeModuleType(rawValue: moduleTypeLabel),
let componetTypeLable = component.typeLabel,
let componentType = HomeModuleComponentType(rawValue: componetTypeLable) else { continue }
switch componentType {
case .product:
if let landingValue = component.product?.contents?.first?.landingValue {
let productAppModel = HomeProductAppModel(landingValue: landingValue, ...)
results.append(productAppModel)
}
// 그 외 case들 생략
}
}
return results
}
}
HomeCMSConverter는 CMS 데이터를 HomeAppModel 객체로 변환하며, 각 moduleType과 componentType에 맞는 앱 모델을 생성하고, exhibitYn 값에 따라 적절한 데이터를 필터링하여 변환합니다. 이를 통해 CMS 데이터의 복잡성을 해결하고, 앱 내에서 효율적으로 사용할 수 있게 합니다.
4) 새롭게 재구성된 Home 화면과 UX 개선
새롭게 재구성된 Home 화면은 CMS에서 변환된 HomeAppModel들을 기반으로 뷰를 동적으로 생성합니다. 각 모델은 화면에 표시되는 다양한 UI 요소들로 변환되며, 예를 들어 HomeCategoryAppModel은 카테고리 뷰로, HomeProductAppModel은 상품 뷰로 변환됩니다. 이러한 방식으로 각 모듈에 맞는 화면을 효율적으로 구성할 수 있습니다.
//모듈 구성
ScrollView(showsIndicators: false) {
VStack(spacing: 0) {
if let uiModels = uiModels { // uiModels 는 [HomeAppModel]
ForEach(uiModels, id: \.id) { model in
viewForModel(model, reactor: reactor)
}
}
}
}
//각 모듈에 맞는 뷰 생성
@ViewBuilder
func viewForModel(_ model: HomeAppModel, reactor: ReskinHomeReactor.ObservableObject) -> some View {
switch model {
case let categoryAppModel as HomeCategoryAppModel:
HomeCategoryView(categoryAppModel)
case let productAppModel as HomeProductAppModel:
HomeProductView(productAppModel)
.task {
if let landingValue = productAppModel.landingValue {
// 서버로 상품 데이터 요청 -> 응답값 productAppModel.productItems 에 업데이트
reactor.action.onNext(.getProductData(module: productAppModel, landingValue: landingValue))
}
}
// 그 외 case들 생략
}
}
특징과 개선점
- 모듈화된 화면 구성 CMS에서 변환된 데이터를 기반으로, 모델과 뷰를 매칭하여 화면을 구성하는 방식은 각 모듈의 독립성을 유지하며, 필요 시 특정 뷰를 추가하거나 제거하는 작업을 단순화했습니다.
- 비동기 데이터 갱신
HomeProductAppModel과 같은 동적 데이터를 포함한 모듈은 화면에 표시되는 순간 API를 호출하여 데이터를 갱신합니다. 이렇게 최신 데이터를 제공함으로써 사용자 경험을 향상시켰으며, 화면 로딩 시간의 균일성을 확보할 수 있었습니다. - UI 최적화
@ViewBuilder와task를 활용한 비동기 작업 처리로 코드 간소화 와 UI 반응성 개선을 동시에 달성했습니다. 특히, 복잡한 데이터 구조에서도 유연한 UI 구성을 지원하여 SwiftUI의 장점을 극대화했습니다.

SwiftUI로 새롭게 개편된 홈 화면
5) 스크롤 시 버벅임 문제 해결
스크롤 시 발생한 버벅임 문제는 주로 뷰 업데이트가 과도하게 일어나면서 발생했습니다. 각 뷰가 표시될 때마다 body가 계속해서 업데이트되면서 성능이 저하되었습니다. 이를 해결하기 위해, Equatable을 사용하여 불필요한 리렌더링을 방지했습니다. 이 방식은 뷰의 상태 변화가 없을 때는 body가 다시 계산되지 않도록 하여, 스크롤 시 프레임 드롭 없이 원활한 사용자 경험을 제공할 수 있었습니다.
struct HomeProductView: View, Equatable {
var productItems: [HomeProductItem]?
static func == (lhs: HomeProductView, rhs: HomeProductView) -> Bool {
return lhs.productItems == rhs.productItems
}
var body: some View {
// ....
}
}
4. SwiftUI 전환 후 성과와 개선 사항
SwiftUI로 홈 화면을 전환한 후, 다음과 같은 성과를 얻을 수 있었습니다.
1) 개발 생산성 향상
- 선언형 UI 구조를 활용하여 UI 요소와 로직을 보다 직관적으로 관리할 수 있었습니다.
- 코드가 간결해지고, 기존 UIKit 기반의 반복적인 작업이 줄어들어 개발 속도가 빨라졌습니다.
2) 동적 UI 구성과 유지보수 용이성
- CMS 데이터를 기반으로 동적으로 UI를 구성함으로써 화면의 확장성과 유연성이 향상되었습니다.
- 새로운 요구사항이나 UI 변경 사항도 간단한 데이터 수정으로 처리할 수 있게 되어 유지보수 시간이 크게 단축되었습니다.
3) 퍼포먼스 개선
- 데이터 로딩 최적화: 뷰가 생성될 때 API에서 데이터를 동적으로 가져오는 방식으로 변경하여, 데이터 로드 흐름을 더 직관적으로 개선했습니다.
- 스크롤 성능 향상: SwiftUI로 전환 후, 스크롤 시 버벅임 없이 부드럽고 안정적인 UI 반응을 제공하게 되었습니다.
개선해야 할 점
1) 복잡한 UI 최적화
- VStack 사용으로 퍼포먼스 안정성을 확보했지만, 데이터 양이 많아질 경우 로드 효율성을 개선할 필요가 있습니다.
2) SwiftUI 한계 보완
- 일부 UIKit 기능의 SwiftUI 전환 과정에서 부족한 부분이 있어 혼합 사용이 불가피했으며, 이를 완전한 전환으로 개선해야 할 필요가 있습니다.
마치며
UIKit에서 SwiftUI로 전환하면서 UI의 반응성과 효율성이 개선되었습니다. 특히 데이터와 UI의 동적 연결을 통해, 사용자 경험을 더욱 자연스럽게 구현할 수 있었습니다. 뷰가 표시될 때 API 요청을 한 번만 수행하도록 설계하여 불필요한 요청을 줄이고, 이를 통해 전체적인 성능 최적화와 리소스 관리의 효율성을 높였습니다. 이 변화는 사용자가 직접적으로 체감하는 부분이 크지 않을 수 있으나, 개발 과정에서는 유지보수성과 확장성이 크게 향상된 것을 확인할 수 있었습니다.
이번 전환 작업이 SwiftUI로의 변경을 고려하는 분들에게 도움이 되기를 바랍니다. 감사합니다.