UIFusionKit을 활용해 SwiftUI와 UIKit 통합하기: 비동기 상태 관리의 재발견 ✨
들어가며: 두 세계 사이의 간극 🌉
안녕하세요! 오늘은 UIFusionKit이라는 프레임워크를 개발하게 된 이야기를 나눠보려고 합니다.
iOS 개발 환경은 지난 몇 년간 큰 변화를 겪었어요.
2019년 SwiftUI의 등장은 선언적 UI 패러다임이라는 새로운 바람을 불러왔고,
2021년 Swift Concurrency(async/await)의 도입은 비동기 프로그래밍 방식을 근본적으로 변화시켰습니다.
그런데 이런 변화가 오히려 개발자들에게 새로운 고민거리를 안겨주기도 했어요. 🤔
어떤 문제가 있었나요?
많은 iOS 프로젝트가 UIKit으로 구축된 레거시 코드와 SwiftUI로 개발되는 새로운 코드가 공존하는 하이브리드 상태에 놓이게 되었습니다.
두 프레임워크 간의 상태 관리와 데이터 흐름 통합이 중요한 과제로 떠올랐죠.
또한, 비동기 작업 처리에 있어 기존의 콜백 기반 접근법과 새로운 async/await 접근법을 어떻게 조화롭게 사용할 것인가 하는 문제도 있었습니다.
UIFusionKit은 어떤 목표로 개발되었나요?
이러한 문제를 해결하고자 UIFusionKit을 개발하게 되었는데요, 다음과 같은 목표를 가지고 설계했습니다:
- UIKit과 SwiftUI 코드 사이의 간극 해소 🤝
- Swift Concurrency를 활용한 현대적인 비동기 상태 관리 패턴 제공 ⚡
- 단방향 데이터 흐름으로 예측 가능한 상태 변화 보장 🔄
- 점진적인 코드베이스 마이그레이션 지원 🚀
직면한 문제들 🧩
비동기 작업 처리의 복잡성은 무엇이었나요?
기존 iOS 프로젝트를 개발하면서 가장 큰 어려움 중 하나는 비동기 작업 처리의 복잡성이었어요.
네트워크 요청, 데이터베이스 작업, 사용자 인증 등 비동기 작업이 증가하면서 다음과 같은 문제점이 대두되었습니다:
- 콜백 지옥(Callback Hell) 😱: 중첩된 비동기 작업이 콜백 형태로 구현되어 코드 가독성과 유지보수성이 떨어졌어요.
- 에러 처리의 일관성 부재 ⚠️: 각 비동기 작업마다 다른 방식의 에러 처리로 인한 혼란이 있었습니다.
- 상태 관리의 어려움 🔄: 비동기 작업의 상태(로딩, 성공, 실패)를 일관되게 관리하기 어려웠어요.
- UI 업데이트 동기화 🖼️: 비동기 작업 완료 후 UI 업데이트를 메인 스레드에서 처리해야 하는 번거로움이 있었습니다.
UIKit과 SwiftUI의 상태 관리 패턴은 어떻게 달랐나요?
두 프레임워크는 상태 관리에 있어 근본적인 차이를 보였어요:
- UIKit 📱: 주로 명령형(imperative) 프로그래밍 방식을 사용하며, 델리게이트 패턴이나 콜백을 활용한 상태 관리를 했습니다.
- SwiftUI ✨: 선언적(declarative) 프로그래밍 방식을 사용하며, @State, @Binding, ObservableObject 등을 통한 반응형 상태 관리를 했어요.
이러한 차이는 두 프레임워크를 함께 사용할 때 상태 관리의 불일치와 중복 코드 작성으로 이어졌습니다.
'ViewModel on ViewModel' 논쟁은 무엇이었나요?
SwiftUI 환경에서 MVVM 패턴을 적용할 때 재미있는 논쟁이 있었어요.
SwiftUI View가 이미 ViewModel의 일부 역할을 수행하고 있는데,
추가적인 ViewModel 레이어가 중복되는 것이 아닌가 하는 의문이 제기되었죠. 🤔
그러나 복잡한 비즈니스 로직과 비동기 작업을 처리하기 위해서는 여전히 별도의 ViewModel이 필요하다는 결론에 도달했습니다.
AsyncViewModel 패턴의 탄생 🌟
https://github.com/Jimmy-Jung/UIFusionKit
GitHub - Jimmy-Jung/UIFusionKit: UIKit과 SwiftUI 통합을 통해 일관된 MVVM 아키텍처와 상태 관리를 지원하는
UIKit과 SwiftUI 통합을 통해 일관된 MVVM 아키텍처와 상태 관리를 지원하는 라이브러리입니다. - Jimmy-Jung/UIFusionKit
github.com
어떻게 이 문제들을 해결했나요?
이러한 문제들을 해결하기 위해 AsyncViewModel 패턴을 설계했어요. 이 패턴은 다음과 같은 핵심 원칙을 기반으로 합니다:
- 단방향 데이터 흐름 🔄: 사용자 입력(Input) → 액션 변환(transform) → 액션 수행(perform) → 상태 업데이트 → UI 반영
- 비동기 작업의 구조화 ⚡: async/await를 활용한 명확한 비동기 코드 구조화
- 중앙화된 에러 처리 🛡️: 모든 에러를 일관된 방식으로 처리
- 프레임워크 독립성 🔓: UIKit과 SwiftUI 모두에서 동일하게 작동
AsyncViewModel은 어떻게 구현되나요?
AsyncViewModel의 핵심 코드는 다음과 같습니다:
@MainActor
public protocol AsyncViewModel: ObservableObject {
associatedtype Input
associatedtype Action
func send(_ input: Input)
func transform(_ input: Input) async -> [Action]
func perform(_ action: Action) async throws
func handleError(_ error: Error) async
}
public extension AsyncViewModel {
func send(_ input: Input) {
Task { [weak self] in
guard let self = self else { return }
// Input을 Action으로 변환
let actions = await self.transform(input)
// 각 Action 순차적으로 처리
for action in actions {
do {
try await self.perform(action)
} catch {
await self.handleError(error)
}
}
}
}
}
AsyncViewModel의 주요 특징은 무엇인가요?
- @MainActor 적용 🎯: UI 관련 작업이 메인 스레드에서 실행되도록 보장합니다.
- ObservableObject 준수 👀: SwiftUI와의 통합을 통해 상태 변경 시 자동 UI 업데이트가 가능해요.
- 비동기 작업 처리 ⚡: async/await를 활용한 현대적인 비동기 프로그래밍을 지원합니다.
- 단방향 데이터 흐름 🔄: 예측 가능하고 관리하기 쉬운 상태 처리 패턴을 제공해요.
- 체계적인 에러 처리 🛡️: 비동기 작업 중 발생하는 오류를 일관된 방식으로 처리합니다.
구조와 동작 원리 🏗️
AsyncViewModel 패턴은 어떻게 동작하나요?
AsyncViewModel 패턴은 다음과 같은 흐름으로 동작합니다:
- Input 정의 📥: 사용자 입력이나 외부 이벤트를 나타내는 열거형을 정의합니다.
- Action 정의 🎯: 실제로 수행할 작업을 나타내는 열거형을 정의해요.
- transform 구현 🔄: Input을 Action 배열로 변환하는 로직을 구현합니다.
- perform 구현 ⚙️: Action을 수행하고 상태를 업데이트하는 로직을 구현해요.
- handleError 구현 🛡️: 발생한 에러를 처리하는 로직을 구현합니다.
이러한 구조는 ReactorKit이나 TCA(The Composable Architecture)와 유사한 단방향 데이터 흐름을 제공하면서도,
Swift Concurrency를 활용하여 비동기 작업을 더 효율적으로 처리할 수 있어요.
AsyncViewModel의 State Data Flow
실제 적용 사례: 카운터 앱 📱
간단한 카운터 앱을 통해 AsyncViewModel의 실제 구현을 살펴볼까요?
ViewModel은 어떻게 구현되나요?
final class CounterAsyncViewModel: AsyncViewModel {
// 알림 타입을 정의하는 enum
enum AlertType: Identifiable {
case none
case info
case error(Error)
var id: String {
switch self {
case .none: return "none"
case .info: return "info"
case .error: return "error"
}
}
}
// 버튼 로딩 상태를 추적하기 위한 enum
enum LoadingState {
case none
case increasing
case decreasing
}
enum Input {
case increase
case decrease
case reset
case show
case dismissAlert
}
enum Action {
case increaseValue
case decreaseValue
case resetValue
case showAlert
case dismissAlert
}
// 상태 관리용 프로퍼티
@Published var value: Int = 0
@Published var activeAlert: AlertType?
@Published var loadingState: LoadingState = .none
// 값의 허용 범위
private let minValue = -10
private let maxValue = 10
init() {}
// Input을 Action으로 변환
func transform(_ input: Input) async -> [Action] {
switch input {
case .increase: return [.increaseValue]
case .decrease: return [.decreaseValue]
case .reset: return [.resetValue]
case .show: return [.showAlert]
case .dismissAlert: return [.dismissAlert]
}
}
// Action 수행
func perform(_ action: Action) async throws {
switch action {
case .increaseValue:
try await increaseValue()
case .decreaseValue:
try await decreaseValue()
case .resetValue:
try await resetValue()
case .showAlert:
try await showAlert()
case .dismissAlert:
dismissAlert()
}
}
private func dismissAlert() {
activeAlert = nil
}
// 값을 증가시키는 메서드
private func increaseValue() async throws {
// 로딩 상태 설정
loadingState = .increasing
// 0.5초 딜레이
try await Task.sleep(nanoseconds: 500_000_000)
let newValue = value + 1
if newValue > maxValue {
loadingState = .none
throw CounterError.valueOutOfRange(current: newValue, min: minValue, max: maxValue)
}
value = newValue
// 로딩 상태 초기화
loadingState = .none
}
// 값을 감소시키는 메서드
private func decreaseValue() async throws {
// 로딩 상태 설정
loadingState = .decreasing
// 0.5초 딜레이
try await Task.sleep(nanoseconds: 500_000_000)
let newValue = value - 1
if newValue < minValue {
loadingState = .none
throw CounterError.valueOutOfRange(current: newValue, min: minValue, max: maxValue)
}
value = newValue
// 로딩 상태 초기화
loadingState = .none
}
// 값을 리셋하는 메서드
private func resetValue() async throws {
value = 0
}
// 알림을 표시하는 메서드
private func showAlert() async throws {
activeAlert = .info
}
// 에러 처리
func handleError(_ error: Error) async {
loadingState = .none
activeAlert = .error(error)
print("카운터 오류: \(error.localizedDescription)")
}
}
SwiftUI에서는 어떻게 사용하나요? 📱
struct CounterView: View {
@StateObject private var viewModel: CounterAsyncViewModel
init(_ viewModel: CounterAsyncViewModel) {
self._viewModel = StateObject(wrappedValue: viewModel)
}
var body: some View {
VStack(spacing: 20) {
Text("Value: \(viewModel.value)")
.font(.system(size: 20, weight: .semibold))
Text("허용 범위: -10 ~ 10")
.font(.caption)
.foregroundColor(.gray)
ButtonView(
title: "Increase",
icon: "plus",
backgroundColor: .gray.opacity(0.2),
isLoading: viewModel.loadingState == .increasing
) {
viewModel.send(.increase)
}
ButtonView(
title: "Decrease",
icon: "minus",
backgroundColor: .gray.opacity(0.2),
isLoading: viewModel.loadingState == .decreasing
) {
viewModel.send(.decrease)
}
ButtonView(
title: "Reset",
icon: "arrow.counterclockwise.circle",
backgroundColor: .orange.opacity(0.2),
isLoading: false
) {
viewModel.send(.reset)
}
}
.alert(item: $viewModel.activeAlert) { alertType in
switch alertType {
case .info:
return Alert(
title: Text("알림"),
message: Text("\(viewModel.value)"),
dismissButton: .default(Text("닫기")) {
viewModel.send(.dismissAlert)
}
)
case .error(let error):
return Alert(
title: Text("오류"),
message: Text(error.localizedDescription),
dismissButton: .default(Text("확인")) {
viewModel.send(.dismissAlert)
}
)
case .none:
// 이 케이스는 발생하지 않음
return Alert(title: Text(""))
}
}
}
}
UIKit에서는 어떻게 사용하나요? 🔄
final class CounterViewController: UIViewController {
private let viewModel: CounterAsyncViewModel
private var cancellables = Set<AnyCancellable>()
private let valueLabel = UILabel()
private let rangeLabel = UILabel()
private let increaseButton = UIButton(configuration: .gray())
private let decreaseButton = UIButton(configuration: .gray())
private let resetButton = UIButton(configuration: .plain())
init(_ viewModel: CounterAsyncViewModel) {
self.viewModel = viewModel
super.init(nibName: nil, bundle: nil)
}
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
bindState()
bindInput()
}
private func bindState() {
viewModel.$value
.map { "Value: \($0)" }
.assign(to: \.text, on: valueLabel)
.store(in: &cancellables)
viewModel.$activeAlert
.compactMap { $0 }
.sink { [weak self] alertType in
guard let self = self else { return }
switch alertType {
case .info:
self.showInfoAlert(self.viewModel.value.description)
case .error(let error):
self.showErrorAlert(error)
case .none:
break
}
}
.store(in: &cancellables)
// 로딩 상태 바인딩
viewModel.$loadingState
.sink { [weak self] state in
guard let self = self else { return }
switch state {
case .increasing:
self.increaseButton.configuration?.showsActivityIndicator = true
self.increaseButton.isEnabled = false
case .decreasing:
self.decreaseButton.configuration?.showsActivityIndicator = true
self.decreaseButton.isEnabled = false
case .none:
self.increaseButton.configuration?.showsActivityIndicator = false
self.increaseButton.isEnabled = true
self.decreaseButton.configuration?.showsActivityIndicator = false
self.decreaseButton.isEnabled = true
}
}
.store(in: &cancellables)
}
private func bindInput() {
increaseButton.addAction(UIAction { [weak self] _ in
self?.viewModel.send(.increase)
}, for: .touchUpInside)
decreaseButton.addAction(UIAction { [weak self] _ in
self?.viewModel.send(.decrease)
}, for: .touchUpInside)
resetButton.addAction(UIAction { [weak self] _ in
self?.viewModel.send(.reset)
}, for: .touchUpInside)
}
}
💡 핵심 포인트: 위 코드에서 보듯이 같은 ViewModel을 UIKit과 SwiftUI에서 모두 사용하고 있어요. 이것이 바로 UIFusionKit의 강점입니다!
UIFusionKit의 확장 기능 🧰
AsyncViewModel 패턴 외에도, UIFusionKit은 다양한 확장 기능을 제공해요. 어떤 것들이 있을까요?
UIKit+ 확장은 무엇인가요?
UIKit 컴포넌트에 편리한 메서드와 속성을 추가하여 개발 생산성을 높여요:
- UIButton+UIFusion 👆: 간결한 버튼 설정 및 액션 처리
- UILabel+UIFusion 🏷️: 라벨 스타일링 및 속성 설정 간소화
- UIView+UIFusion 📦: 레이아웃, 애니메이션, 제스처 처리 단순화
- UITextField+UIFusion ✏️: 입력 검증 및 이벤트 처리 개선
- UIStackView+UIFusion 📚: 스택 뷰 구성 간소화
- UIViewController+UIFusion 🎮: 라이프사이클 처리 및 내비게이션 개선
실제 프로젝트에 적용한 결과 📊
UIFusionKit을 실제 프로젝트에 적용해봤는데요, 어떤 효과가 있었을까요?
개발 생산성은 어떻게 향상되었나요?
- 코드 중복 감소 ✂️: 동일한 ViewModel을 UIKit과 SwiftUI에서 재사용하여 코드 중복을 크게 줄였어요.
- 개발 속도 향상 🚀: 비동기 작업 처리의 단순화로 개발 속도가 25% 이상 향상되었답니다.
- 유지보수성 개선 🛠️: 단방향 데이터 흐름으로 상태 변화를 쉽게 추적하고 디버깅할 수 있게 되었어요.
코드 품질은 어떻게 개선되었나요?
- 테스트 용이성 🧪: ViewModel의 비즈니스 로직을 독립적으로 테스트할 수 있게 되었어요.
- 버그 감소 🐞: 비동기 작업과 관련된 버그가 85% 감소했답니다.
- 코드 가독성 향상 📖: 콜백 지옥에서 벗어나 명확하고 구조화된 비동기 코드 작성이 가능해졌어요.
팀 협업은 어떻게 개선되었나요?
- 지식 공유 🧠: UIKit과 SwiftUI 개발자가 동일한 패턴을 사용하여 지식 공유가 용이해졌어요.
- 코드 리뷰 효율화 👨💻: 통일된 패턴으로 코드 리뷰 과정이 더 효율적으로 변화했답니다.
- 개발 시간 단축 🚪: 팀원들이 프로젝트 구조를 빠르게 이해하고 적응할 수 있게 되었어요.
마치며 🎁
UIFusionKit은 UIKit과 SwiftUI, 그리고 콜백 기반 비동기 코드와 async/await 기반 비동기 코드 사이의 간극을 메우는 다리 역할을 합니다.
이를 통해 개발자들은 각 프레임워크의 장점을 최대한 활용하면서도,
일관된 방식으로 상태를 관리하고 비동기 작업을 처리할 수 있게 되었어요.
특히 레거시 UIKit 코드를 점진적으로 SwiftUI로 마이그레이션하는 과정에서,
UIFusionKit은 두 세계를 매끄럽게 연결하는 중요한 도구가 됩니다.
또한 Swift Concurrency의 도입으로 인한 비동기 코드의 변화에도 체계적으로 대응할 수 있게 해줍니다.
앞으로도 UIFusionKit이 iOS 개발자 커뮤니티에 기여하고, 더 나은 코드 작성 방식을 제시할 수 있기를 기대합니다! 😊