본문 바로가기
개발 이야기

[iOS] UIFusionKit을 활용해 SwiftUI와 UIKit 통합하기

by Jimmy_iOS 2025. 5. 5.

UIFusionKit을 활용해 SwiftUI와 UIKit 통합하기: 비동기 상태 관리의 재발견 ✨

들어가며: 두 세계 사이의 간극 🌉

안녕하세요! 오늘은 UIFusionKit이라는 프레임워크를 개발하게 된 이야기를 나눠보려고 합니다.

 

iOS 개발 환경은 지난 몇 년간 큰 변화를 겪었어요.

2019년 SwiftUI의 등장은 선언적 UI 패러다임이라는 새로운 바람을 불러왔고,
2021년 Swift Concurrency(async/await)의 도입은 비동기 프로그래밍 방식을 근본적으로 변화시켰습니다.

그런데 이런 변화가 오히려 개발자들에게 새로운 고민거리를 안겨주기도 했어요. 🤔

 

어떤 문제가 있었나요?

많은 iOS 프로젝트가 UIKit으로 구축된 레거시 코드SwiftUI로 개발되는 새로운 코드가 공존하는 하이브리드 상태에 놓이게 되었습니다.
두 프레임워크 간의 상태 관리와 데이터 흐름 통합이 중요한 과제로 떠올랐죠.

또한, 비동기 작업 처리에 있어 기존의 콜백 기반 접근법과 새로운 async/await 접근법을 어떻게 조화롭게 사용할 것인가 하는 문제도 있었습니다.

 

UIFusionKit은 어떤 목표로 개발되었나요?

이러한 문제를 해결하고자 UIFusionKit을 개발하게 되었는데요, 다음과 같은 목표를 가지고 설계했습니다:

  1. UIKit과 SwiftUI 코드 사이의 간극 해소 🤝
  2. Swift Concurrency를 활용한 현대적인 비동기 상태 관리 패턴 제공
  3. 단방향 데이터 흐름으로 예측 가능한 상태 변화 보장 🔄
  4. 점진적인 코드베이스 마이그레이션 지원 🚀

직면한 문제들 🧩

비동기 작업 처리의 복잡성은 무엇이었나요?

기존 iOS 프로젝트를 개발하면서 가장 큰 어려움 중 하나는 비동기 작업 처리의 복잡성이었어요.
네트워크 요청, 데이터베이스 작업, 사용자 인증 등 비동기 작업이 증가하면서 다음과 같은 문제점이 대두되었습니다:

  1. 콜백 지옥(Callback Hell) 😱: 중첩된 비동기 작업이 콜백 형태로 구현되어 코드 가독성과 유지보수성이 떨어졌어요.
  2. 에러 처리의 일관성 부재 ⚠️: 각 비동기 작업마다 다른 방식의 에러 처리로 인한 혼란이 있었습니다.
  3. 상태 관리의 어려움 🔄: 비동기 작업의 상태(로딩, 성공, 실패)를 일관되게 관리하기 어려웠어요.
  4. 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 패턴을 설계했어요. 이 패턴은 다음과 같은 핵심 원칙을 기반으로 합니다:

  1. 단방향 데이터 흐름 🔄: 사용자 입력(Input) → 액션 변환(transform) → 액션 수행(perform) → 상태 업데이트 → UI 반영
  2. 비동기 작업의 구조화 ⚡: async/await를 활용한 명확한 비동기 코드 구조화
  3. 중앙화된 에러 처리 🛡️: 모든 에러를 일관된 방식으로 처리
  4. 프레임워크 독립성 🔓: 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 패턴은 다음과 같은 흐름으로 동작합니다:

  1. Input 정의 📥: 사용자 입력이나 외부 이벤트를 나타내는 열거형을 정의합니다.
  2. Action 정의 🎯: 실제로 수행할 작업을 나타내는 열거형을 정의해요.
  3. transform 구현 🔄: Input을 Action 배열로 변환하는 로직을 구현합니다.
  4. perform 구현 ⚙️: Action을 수행하고 상태를 업데이트하는 로직을 구현해요.
  5. 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 개발자 커뮤니티에 기여하고, 더 나은 코드 작성 방식을 제시할 수 있기를 기대합니다! 😊