본문 바로가기

iOS

[UIKit/SwiftUI] Design System framework

반응형

 

현재 회사에서 다양한 비슷한 애플리케이션이 출시되었으며, 추가적인 출시를 고려하고 있습니다.

모두 커머스 앱 기반으로 메인 앱을 포크 하여 기본 작업을 진행했고, 앱 사용자 특성에 따라 결정된 디자인을 적용했습니다.

 

현재 애플리케이션들만 관리한다면, 그래도 그나마 괜찮겠지만(?)

다양한 애플리케이션들이 추가될 것이기에, 유지보수와 개발 용이성을 위해 디자인 시스템을 도입하게 되었습니다.

 

본 포스팅에서는 디자인 시스템을 위한 구조를 어떤 방식으로 설계했고, 적용했는지 설명하도록 하겠습니다.

 

1.  프레임워크와 프로덕트의 책임과 역할

먼저 프레임워크와 프로덕트의 책임과 역할을 분리하고자 했습니다.

그러기 위해서 현재 각 뷰들이 어떤 식으로 구성되는지 파악하는 것이 필요합니다.

 

MVVM 채택하고 있는 현재 구조에서 View는 ViewModel에서 어떤 로직을 거치는가는 알 필요 없이,
UI 구성을 위해 추상화된 ViewModel의 데이터에 의존하여 View를 구성합니다.

따라서 프레임워크에서는 UI에 대한 요소 디자인 특성을 포함하여 UI를 구성하는데 필요한 추상화된 데이터가 필요합니다.

 

비즈니스 로직을 포함할 순 없을까?

비즈니스 로직을 프레임워크에서 직접적으로 관리하기 위해서는, 프로덕트에 대한 의존성이 필연적으로 필요하게 됩니다.

고차원인 디자인 시스템 프레임워크에서 저차원 수준인 프로덕트에 의존한다면 현재 존재하는, 현재 상태의 애플리케이션에서는 적합해 보일 수 있지만, 다양한 애플리케이션이 추가되고 상태가 변경됨에 따라 점점 더 변경에 취약해지게 될 것입니다.

따라서 이와 같은 의존성을 역전하기 위해 비즈니스 로직은 각 프로덕트에 맡기고, 프레임워크는 인터페이스에 의존하는 형태로 가는 것이 합리적이라고 판단했습니다.

 

정리하면 다음과 같습니다.

 

 

프레임워크의 책임과 역할

  • 컴포넌트에 필요한 UI 요소를 적용한다.
  • UI 구성은 인터페이스(ViewModel, Design Interface)에 의존하여 처리한다. (의존성 역전)

 

프로덕트의 책임과 역할

  • UI 구성에 필요한 로직을 수행한다.
  • ViewModel, Design 인터페이스를 채택하여 뷰의 추상화된 정보를 제공한다.

 

2. SwiftUI & UIKit 모두를 위한 구조

Deployment target을 iOS 13.0으로 올린 것도 있지만, 앞으로 출시될 애플리케이션들에도 적용될 공통 Framework 개발 이기에 SwiftUI를 배제할 수 없다고 판단했습니다.

 

그리고 SwiftUI와 UIKit을 혼재되어 있는 상태이기에, 모든 방법으로 컴포넌트를 제공할 수 있어야 합니다.

이를 위한 방법으로는 두 가지 케이스 모두를 위한 공통 인터페이스를 먼저 만들고,

UIKit과 SwiftUI를 분리하여 각각 인터페이스를 만들어 각 UI Framework에 적합한 구현 로직을 추가하는 것입니다.

 

 

ViewModel, FixedDesign

ViewModel은 MVVM에서와 동일하게 뷰를 추상화해 놓은 것이고, FixedDesign는 뷰의 디자인 특성입니다.

FixedDesign과 ViewModel을 분리한 이유는, 고정적인 특성을 초기에 셋업 할 수 있도록 하기 위함입니다.

 

 

Actions

위 책임, 역할 설명에서도 ViewModel과 Design에 대한 설명은 있지만, 마지막 Actions는 없는 것을 알 수 있습니다.

컴포넌트를 만들 경우, 공통적으로 사용자 액션에 대한 처리가 필요한데 이는 일반적으로 약속된 규정에 따릅니다.

예를 들어, 상품 관련 뷰가 있을 경우, 이미지 클릭 시 확대 처리라든가 장바구니 담기 버튼 클릭으로 장바구니에 담는 처리라든가 액션 처리는 정해져 있습니다.

 

이와 같이 커뮤니케이션을 위해 UIKit에서는 일반적으로 Delegate 패턴을 자주 사용했었는데,

이는 SwiftUI와 함께 사용하기에는 적합하지 않다고 생각이 들었습니다.

그 이유는 class와 struct의 차이로 인한 문제가 발생하기 때문입니다.

 

UIKit에서는 retain cycle을 피하기 위해 Delegate Protocol을 class only protocol (AnyObject) 타입으로 설정합니다.

하지만 SwiftUI View는 구조체이기 때문에 위임 처리를 받을 수 없게 됩니다.

그렇다고 class only protocol을 빼서, UIKit 간의 커뮤니케이션 시 retain cycle 발생하는 것을 용인할 수도 없습니다.

 

다른 방법으로 클로져를 사용하는 방식을 택했습니다. (Actions)

Actions는 팀내 규칙으로 커뮤니케이션 용도의 클로저 집합을 사용하는 것으로 규정했습니다.

따라서 class only protocol이 필요치 않고, SwiftUI, UIKit 상호 간 사용에도 문제가 발생하지 않습니다.

 

3.  추가 기능

컴포넌트는 ViewModel, FixedDesign을 통해 구성됩니다. 컴포넌트(View)를 구성하는데 필요한 정보들을 통해 유용한 기능들을 추가할 수 있습니다.

 

상호 변환

각 컴포넌트는 SwiftUI, UIKit 모두에서 사용될 수 있기 때문에, 하나의 컴포넌트를 만들면 모든 케이스에서 사용 가능해야 합니다.
따라서 SwiftUI로 작업하면 이를 UIKit으로, UIKit으로 작업할 경우 이를 SwiftUI로 변환 가능해야 합니다.

 

이미 애플에서 UIKit, SwiftUI 간 상호 변환할 수 있도록 장치가 마련되어 있습니다.

이러한 장치들을 현재 설계한 구조에 녹여낸다면 UIKit, SwiftUI 간 변환을 손쉽게 할 수 있습니다.

 

 

UIKit, SwiftUI 상관없이 Component Interface 기반이기 때문에 ViewModel, FixedDesign, Actions가 필요한 것은 동일합니다.

 

때문에 만약 SwiftUI Component Interface 기반으로 컴포넌트를 만들었다면,

해당 컴포넌트의 ViewModel, FixedDesign, Actions를 활용하여 UIKit Component Inteface 기반의 컴포넌트로 만들 수 있습니다. (반대의 경우도 마찬가지)

 

정리하면 다음과 같습니다. 

  • UIKit/SwiftUI Component Interface 기반으로 컴포넌트를 제공할 수 있음
  • UIKit/SwiftUI Component Inferface 기반의 컴포넌트를 SwiftUI/UIKit Component Interface 컴포넌트로 변환할 수 있음

 

Container Cell

대다수의 애플리케이션에서 리스트 형태를 굉장히 많이 필요로 합니다. 따라서 UIKit 사용하는 대다수의 애플리케이션에서는 UICollectionView, UITableView를 사용하고 있을 것입니다.

따라서 컴포넌트를 View 형태로만 제공한다면, 프로덕트 내에서는 높은 확률로 Cell에 래핑 하여 사용하는 경우가 생길 것입니다.

 

하지만 프레임워크 내에서는 이미 컴포넌트를 구성하는데 필요한 정보들을 알고 있습니다.

이 정보들을 활용하여 프로덕트 내에서는 래핑 등의 추가 작업 없이 Cell를 사용할 수 있도록 모듈화 할 수 있습니다.

 

 

위 그림은 UIKit Component Interface를 기반으로 도식화한 내용이며, 기본적인 내용은 위에서 설명한 내용과 동일합니다.

프레임워크 내에서 UIKit Component Interface를 채택하여 컴포넌트(Some Component)를 제공하고,

프로덕트에서는 컴포넌트에 필요한 ViewModel, FixedDesign을 주입하여 컴포넌트를 사용하는 방식입니다.

 

Container Cell Interface는 UIKit Component Interface (UIView) 타입의 컴포넌트를 subView로 가지고 있는 인터페이스입니다.

Container TableView/CollectionView Cell은 위 인터페이스를 채택한 각 타입의 Cell입니다.

 

Container Cell이 구성되는 로직은 다음과 같습니다.

 

 

외부에서 Container Cell을 구성하라는 메시지를 보내게 되면, 이를 하위 요소인 컴포넌트에 그대로 전달하여 전체 Cell을 구성하는 형태입니다.

 

Container Cell Interface는 UIKit Component Interface를 사용한다고 했는데, 그렇다면 SwiftUI Component Interface 기반으로 컴포넌트를 만들 경우에는 어떻게 해야 할까요?

 

SwiftUI Converter를 활용하여 간단하게 처리할 수 있습니다.

SwiftUI Component Interface 기반의 컴포넌트는 SwiftUI Converter를 통해 UIKit Component Interface 기반의 구조로 변환할 수 있습니다.

이를 활용하여 예를 들어 SomeComponent가 SwiftUI 기반일 경우, SwiftUI Converter를 통해 다음과 같이 Cell로 사용할 수 있습니다.

 

typealias SwiftUIComponent = SomeComponent<ViewModel, FixedDesign>
typealias UIKitComponent = SwiftUIComponent.UIKit // SwiftUI Converter 활용하여 UIKit으로 변환

typealias CollectionViewCell = ContainerCollectionViewCell<UIKitComponent>
typealias TableViewViewCell = ContainerTableViewCell<UIKitComponent>

 

결론적으로 컴포넌트를 구성하는데 필요한 정보를 알고 있음으로써 UIKit, SwiftUI 어떠한 방식을 기반으로 컴포넌트를 제공하더라도

그 해당 컴포넌트를 Cell로 사용할 수 있습니다.

 

지금까지 Container Cell에 대한 설명을 했습니다.

동일한 방식이라 설명을 생략했지만, UICollectionViewReusableView 또한 Cell과 동일하게 적용할 수 있습니다.

 

추가적으로 컴포넌트를 구성하는 데이터와 Constraint 기반으로 컴포넌트 사이즈를 제공하는 기능을 추가하여 편의성을 제공했습니다.

특히 CollectionView와 TableView에서 size, height 계산이 필요할 때 유용하게 사용할 수 있습니다.

Constraint 기반 사이즈 계산하는 방식은 [UIKit] Dynamic cell sizing - systemLayoutSizeFitting 내용을 참고하시기 바랍니다.

 

4. 마치며

내용을 정리하면 다음과 같습니다.

  • 프레임워크와 프로덕트 간 역할과 책임을 나누어 작업
  • 프레임워크:  UI 요소, 구성은 인터페이스에 의존하여 처리
  • 프로덕트: UI 구성에 필요한 ViewModel, Design 인터페이스를 채택하여 뷰의 추상화된 정보 제공
  • 컴포넌트가 인터페이스 기반으로 구성하는 것을 활용하여
    • UIKit, SwiftUI 상호 변환 기능 추가
    • Container Cell/ReusableView 기능 추가
    • 컴포넌트 Size 계산 기능 추가


애플리케이션들을 위한 공통 디자인 시스템 구조 설계라는 특이한(?) 경험

그리고 SwiftUI / UIKit 모두를 위한 설계를 해보는 흥미로운 개발을 진행할 수 있어서 좋았습니다.

점차 UIKit에서 SwiftUI로 전환이 이루어질 거고, UIKit을 혼재하고 있는 상황에서 SwiftUI와 함께 사용되는 것을 피하긴 어려울 것입니다.

따라서 UIKit과 SwiftUI 함께 사용할 것을 고민하실 때, 이 내용이 도움이 되었으면 합니다.

긴 글 읽어주셔서 감사합니다.

반응형