iOS 중심으로 시작했던 탭탭 프로젝트를 macOS까지 확장하면서, DesignSystem 모듈의 구조적 한계가 드러났습니다. UIKit 의존 코드가 곳곳에 섞여 있었고, 공용 컴포넌트라고 생각했던 컴포넌트들 역시 실제로는 특정 플랫폼(iOS)에 종속되어 있어 macOS 빌드가 깨지는 문제가 발생했습니다.
처음에는 단순히 #if canImport(UIKit)와 같은 조건부 분기로 간단히 해결할 수 있을 거라 생각했습니다. 실제로 import UIKit 자체는 분기 처리로 막을 수 있었지만, UIViewRepresentable, UIFont, UIScreen처럼 UIKit 타입에 직접 의존하는 코드들이 공용 레이어에 포함되어 있었고, 이것들은 단순한 import 분기만으로는 해결되지 않았습니다.
문제의 본질은, 플랫폼 분기가 아니라, 플랫폼에 의존적인 코드가 공용 레이어에 섞여 있었다는 구조 자체에 있었습니다.
왜 플랫폼별 디자인시스템 모듈을 따로 나누지 않았을까요?
두 개의 디자인 시스템을 모듈로 나누게 되면, 공통으로 사용해야 하는 색상, 타이포그래피, 공용 컴포넌트 로직까지 중복으로 관리해야 했고, 기능이 추가되거나 수정될 때마다 두 모듈을 동시에 관리해야 하기 때문에 유지보수 비용이 증가한다고 판단했습니다.
결국 중요한 것은 모듈을 분리하는 것이 아니라, 하나의 디자인 시스템 안에서 공통 영역과 플랫폼 영역을 명확하게 분리하는 것이라고 판단했습니다.
iOS, macOS, 공용 컴포넌트 파악
가장 먼저 한 작업은, 현재 코드베이스에서 iOS 전용, macOS 전용, 그리고 공용으로 사용 가능한 컴포넌트를 명확하게 분류하는 것이었습니다.
겉으로 보기에는 모두 디자인시시스템 모듈에 들어있는 공용 컴포넌트처럼 보였지만, 실제로 그렇지 않은 경우가 많았습니다..(매우 ㅠㅠ)
UIViewRepresentable, AVKit, UIScreen 등을 사용하는 컴포넌트들은 사실상 iOS 전용이었고, 반대로 macOS에서만 사용하는 컴포넌트들도 존재했습니다. 이런 상태에서는 아무리 프레임워크 조건부 분기를 추가해도 구조적인 문제를 해결할 수 없었습니다.
그래서 각 컴포넌트를 아래와 같은 기준으로 분류하였습니다.
- 이 컴포넌트가 UIKit / AppKit에 의존하는가?
- SwiftUI만으로 동작하는가?
- 특정 플랫폼에서만 의미 있는 UI인가?
이 과정을 통해 컴포넌트를 iOS 전용, macOS 전용, 공용으로 나눌 수 있었습니다.
DesignSystem 레이어 구조 재정의
컴포넌트들을 iOS / macOS / 공용으로 분리한 이후에는, 이를 바탕으로 디자인 시스템 모듈의 레이어 구조를 재정의했습니다.
레이어를 아래와 같이 분리했습니다.
- Foundation
- Shared
- iOS
- macOS
- NameSpace
1. Foundation
디자인 시스템의 가장 하위 레이어로, 색상, 타이포그래피, spacing과 같은 순수 값만 정의합니다. 이 레이어는 플랫폼과 완전히 분리되어야 하기 때문에, UIKit, AppKit의 의존도를 최소화 했습니다.
2. Shared
iOS와 macOS에서 공용으로 사용하는 SwiftUI 컴포넌트를 담당합니다. 이 레이어에서는 플랫폼 의존 코드를 사용하지 않는 것을 원칙으로 하고, 최대한 순수 SwiftUI만으로 UI를 구성했습니다.
3. iOS, macOS
각각의 플랫폼에 종속적인 구현을 담당합니다. UIKit 기반 코드는 iOS 레이어로, AppKit 기반 구현은 macOS 레이어로 분리했습니다.
4. NameSpace
마지막으로 네임스페이스는 색상, 이미지, 폰트 등 디자인 시스템 리소스의 접근을 통일하기 위한 레이어입니다. 매직 스트링을 제거하고 프로젝트 전역에서 일관된 방식으로 리소스를 사용할 수 있도록 하는 역할을 합니다.
명시적 분리
레이어를 나누고 나서 마지막으로 진행한 작업은, 플랫폼에 의존적인 코드들을 명시적으로 분리하는 것이었습니다.
이때 사용한 방법이 #if os(...) 조건부 컴파일입니다.
예를 들어, UIKit이나 AppKit을 사용하는 경우 아래와 같이 명확하게 분리했습니다.
#if os(iOS)
import UIKit
#endif
#if os(macOS)
import AppKit
#endif
또한 Representable과 같이 플랫폼별 타입이 다른 경우에도 동일한 방식으로 처리했습니다.
#if os(iOS)
struct CustomView: UIViewRepresentable { ... }
#elseif os(macOS)
struct CustomView: NSViewRepresentable { ... }
#endif
이처럼 동일한 역할을 하는 컴포넌트라도, 플랫폼에 따라 구현을 분리하고 인터페이스는 동일하게 유지하는 방식으로 구조를 정리했습니다.
#if canImport(...) VS #if os(...)
마지막으로 정리해볼 부분은, 처음에 사용했던 #if canImport(UIKit)과 최종적으로 사용하게 된 #if os(iOS)의 차이입니다.
초기에는 단순히 UIKit import만 막으면 문제가 해결될 것이라고 생각했고, 그래서 아래와 같이 코드를 수정했습니다.
#if canImport(UIKit)
import UIKit
#endif
canImport는 말 그대로 "이 모듈을 import할 수 있는 환경인가?"를 기준으로 분리합니다. 그래서 특정 플랫폼이 아니라, 해당 모듈의 존재 여부에 따라 코드가 포함됩니다.
하지만 문제가 있었습니다. UIkit 을 import하는 것 자체를 막을 수 있었지만, 실제로는 아래와 같은 코드들이 여전히 공용 레이어에 남아있었습니다.
- UIViewRepresentable
- UIViewControllerRepresentable
- UIScreen
- UIFont
이런 타입들은 단순히 import 여부와 상관 없이, iOS에서만 의미를 가지는 구현입니다. 즉, canImport(UIKit)은 import 수준의 분기만 해결할 뿐, 플랫폼 자체의 차이를 해결해주지 못합니다.
반면 #if os(iOS)는 완전히 다른 기준입니다.
#if os(iOS)
// iOS에서만 컴파일되는 코드
#endif
이 방식은 특정 모듈이 아니라, 현재 빌드 대상 플랫폼 자체를 기준으로 분기합니다. 따라서 UIKit 기반 구현, iOS 전용 로직, 화면 관련 API 등을 명확하게 분리할 수 있습니다.
결과적으로 이번 리팩토링에서는 아래와 같은 기준을 잡게 되었습니다.
- #if canImport(...) -> 라이브러리 존재 여부를 확인할 때만 사용
- #if os(...) -> 플랫폼 분기가 필요한 경우 사용
그리고 디자인 시스템에서는 대부분의 경우가 플랫폼 분기에 해당했기 때문에, 최종적으로는 #if os(...)를 사용하는 방식으로 정리하게 되었습니다.
'탭탭 - TapTap > 리팩토링' 카테고리의 다른 글
| [리팩토링] 08. 외부 의존성(LinkNavigator) 제거 (0) | 2026.02.25 |
|---|---|
| [리팩토링] 07. SwiftDataClient 리팩토링 - SwiftDataClient의 비대함 완화 및 명확한 구분 (0) | 2026.02.12 |
| [리팩토링] 06. SwiftDataClient 리팩토링 - 중복 코드 제거 (0) | 2026.02.12 |
| [리팩토링] 05. Tuist 모듈화 개선 (0) | 2026.02.03 |
| [리팩토링] 04. 온보딩 Callback 지옥을 해결해보자 (온보딩 리팩토링). (0) | 2026.01.19 |