iOS Modular Architecture 를 향한 여정 Part 2 — 프로젝트 모듈화, 레거시와 공존하기
안녕하세요, 29CM iOS 개발자 김우성입니다.
오랜만에 글을 쓰게 되었는데요, 지난 Part 1 글에 이어서 이번 글에서는 ‘프로젝트 모듈화, 레거시와 공존하기’를 주제로 글을 써보려고 합니다.
모바일 앱 모듈화를 시작하면 보통 거대한 앱 타겟에서부터 모듈화를 시작하게 되기에 초기에는 모듈화 속도를 내기 위해 Bottom-up 으로 분리를 해나가는데요, 이 때 과거의 레거시가 자주 병목이 되곤 합니다.
레거시 코드는 과거에 회사의 비즈니스를 지속하게 해 주었던 좋은 친구들이지만 모듈화 관점에서는 작업 난이도를 높히는 요인 중 하나인데요, 이번 글을 통해 저희가 어떻게 레거시와 공존해나갔는지 실제 사례와 함께 하나씩 소개드리겠습니다.
1. Protocol 을 활용한 레거시 격리
저희 팀에서는 작업 초기 최하단 Shared 레어어를 분리하고 이어서 API 로직들을 분리하고 있었습니다. 이를 모듈로 분리하는 과정에서 API 캐시 사용 여부를 받아올 수 있는 오래된 유틸 클래스(무려 이름도 Common.. )가 가장 먼저 문제가 되었습니다.
Common 이라는 거대한 유틸 클래스는 태초(?)부터 존재했던 클래스로, 여러 이유로 분리가 정말 쉽지 않았습니다 😂
// Common.swift
// App
class Common {
...
// 태초부터 존재(?)했던 코드로 히스토리 파악이 쉽지 않습니다.
private var _useCacheApi: Bool = true
var useCacheAPI: BOOL { return _useCacheApi }
...
}
// BannerAPI.swift
// App
enum BannerAPI: DefaultTargetType {
...
var useCache: Bool {
return Common.shared.useCacheApi
}
}
네트워킹 로직을 분리해야 이를 의존하는 다음 로직들을 분리할 수 있었기에 Common 을 먼저 분리하고 네트워킹을 분리하는 식으로 진행을 하거나(정석이지만 시간 소요가 큼), 아니면 다른 효율적인 방법을 찾아볼 필요가 있었죠.
그래서 Protocol 기반의 추상화를 통해 이 문제를 해결해 보기로 했습니다. Networking 레이어로 옮길 파일 입장에서 필요한 정보들을 Protocol 로 먼저 정의하고(가능하면 잘게 나누는게 좋습니다), 우선 이를 기반으로 모듈 내에서 컴파일이 되도록 코드를 수정합니다.
// NetworkingAPICacheProtocol.swift
// AppCore_Networking
// 앱 타겟에서 못 옮기고 있는 Common.shared.useCacheApi 를 추상화합니다.
// 마이그레이션 후 앱 타겟에서 코드를 제거하고 이 프로토콜도 제거하면 됩니다.
public protocol NetworkingAPICacheProtocol {
var useCacheApi: Bool { get }
}
// Common.swift
// App
class Common {
...
}
// 향후 쉬운 분리를 위해 extension으로 처리합니다.
extension Common: NetworkingAPICacheProtocol {
var useCacheApi: Bool { return _useCacheApi } // 구현부는 우선 그대로 두고 인터페이싱에만 집중합니다.
}
// BannerAPI.swift
// AppCore_Networking
enum BannerAPI: DefaultTargetType {
...
var useCache: Bool {
let networkingApiCache = DI.synchronize().resolve(NetworkingAPICacheProtocol.self)
return networkingApiCache?.useCacheApi ?? true
}
}
그리고 모듈이 이 의존성을 resolve 만 할 수 있으면 모듈로 기능할 수 있게 되는데요,저희는 많이 사용되는 DI Container 인 Swinject 로 해결했습니다.
그러면 DI Container 에 의존성 등록을 해 주어야 하는데, Common 은 앱 타겟에 있으니 앱 타겟에 만들어 두었던 NetworkingAssembly 에서 의존성 주입 처리를 해 주면 됩니다.
// NetworkingAssembly.swift
// App
final class NetworkingAssembly: Assembly {
...
private func registerNetworkingAPICache(container: Container) {
container.register(NetworkingAPICacheProtocol.self) { _ in Common.shared }
}
}
이상적인 방향이라 볼 수는 없으나 이렇게 workaround 를 하면 레거시 로직으로 인한 커플링 이슈를 완화하면서 모듈 분리에 먼저 집중할 수 있게 됩니다. 먼저 분리하고, 리팩토링은 나중에 하는 등의(혹은 상황에 따라 그냥 안하거나) 선택을 할 수 있어 장기전인 모듈화를 조금 더 수월하게 할 수 있게 됩니다.
다른 예시를 보겠습니다. 저희 팀에는 오래전부터 ‘로그인한 유저 정보’를 의미하는 UserModel 이라고 하는 모델 클래스가 있었는데요, 문제는 이 클래스가 로그인과 관련된 수많은 로직을 가지고 있었습니다. (지양해야 하는 방향이겠지만 레거시 코드인지라 가슴으로 받아들입니다 😅)
UserModel 을 Entity 모듈로 분리를 해야 이 모델을 의존하는 다른 클래스들을 앱 타겟에서 분리할 수 있었는데요, 로그인 관련 비즈니스 로직은 여러 의존성들이 복잡하게 엮어 당장 앱 타겟에서 분리하기가 쉽지 않았던 상황이었어요.
// UserModel.swift
// App
class UserModel {
var userID: String?
var userName: String?
...
func clearUserInfo() { ... }
func resetUserInfo() { ... }
func updateUserInfo(...) { ... }
...
}
그래서 앞 예시와는 조금 다른 결로, 당장 분리하기가 어려운 비즈니스 로직만을 앱 타겟에 남겨 두고 옮길 수 있는 로직만 옮기는 방향으로 처리를 했습니다. 그리고 로그인 유저
라는 맥락에 맞게 리네이밍도 같이 진행했어요.
// CurrentUserManagerProtocol.swift
// AppCore_Entity
public protocol CurrentUserManagerProtocol {
func clearUserInfo()
func resetUserInfo()
func updateUserInfo(...)
}
// CurrentUser.swift
// AppCore_Entity
public class CurrentUser {
var userID: String?
var userName: String?
...
}
// CurrentUser+Extension.swift
// App
extension CurrentUser: CurrentUserManagerProtocol {
func clearUserInfo() { ... }
func resetUserInfo() { ... }
func updateUserInfo(...) { ... }
}
위 방식을 통해 다른 의존성 때문에 쉽게 분리할 수 없는 CurrentUser 의 비즈니스 로직들은 우선 앱 타겟에 그대로 격리하고 모델은 Entity 모듈로 옮길 수 있게 됩니다.
뿐만 아니라 향후에는 CurrentUserManagerProtocol
의 구현체로 CurrentUserManager
를 새로 구현하면서 모듈로 분리하게 되면 CurrentUser 모델에 직접 의존하지 않도록 개선하는 것도 가능해 집니다.
2. ViewController 추상화를 통한 레거시 격리
하위 레이어를 어느정도 분리한 뒤에 마주하는 대표적인 병목은 ViewController 입니다. 예컨대 앱 타겟에 레거시 A 화면과 레거시 B 화면이 있는데, 새로 만든 C 화면은 모듈로 분리하고 싶은데 C 화면에서 A 화면과 B 화면을 표시해야 하는 경우 처럼 화면간 의존이 생기는 경우가 빈번합니다.
이럴 때 유용한 방법으로, ViewController 를 추상화하고, 이를 생성하는 팩토리도 같이 추상화를 해서 팩토리를 의존성으로 주입하는 방법을 사용합니다.
특정 ViewController 의 인스턴스를 만들기 위해선 어떤 정보가 필요할까요? 이 ViewController 가 가져야 하는 의존성과, 그리고 인스턴스 생성에 필요한 Runtime parameters (저희는 Payload 라 명명한) 정보가 필요합니다.
그렇다면 이 추상화 된 팩토리가 Payload 를 파라미터로 받아서 ViewControllerType 을 반환하는 클로저를 내포하고 있다면 이 추상화 팩토리 의존성과 함께 뷰컨트롤러 인스턴스를 만드는 것이 가능해 집니다. 팩토리 입장에서는 클로져 내부가 어떻게 되어 있든(블랙박스) 외부에서 주입받아 사용할 수만 있으면 되는 것이구요.
이런 방식을 통해 새로 제작하는 C 화면에서 레거시 A 화면 / B 화면을 생성하도록 해 볼 것인데요, 아래 코드로 보시겠습니다.
// ViewControllerType.swift
// AppCore_UI
// 추상화 된 ViewController
public protocol ViewControllerType where Self: UIViewController {}
/// 추상화 된 ViewController 를 생성하기 위한 추상 팩토리 타입
public protocol ViewControllerFactoryType {
/// ViewController 를 생성하는 데 필요한 런타임 파라미터
associatedtype Payload
/// 런타임 파라미터를 받아 ViewController 를 생성하는 클로저
typealias FactoryClosure = (Payload) -> ViewControllerType
var factoryClosure: FactoryClosure { get }
/// ViewControllerType 생성 인터페이스
func create(payload: Payload) -> ViewControllerType
}
위와 같이 추상화 뷰컨트롤러/뷰컨트롤러 팩토리를 먼저 UI 레이어에 선언을 해 둡니다. 그리고 레거시 A 화면 클래스가 ViewControllerType 를 준수하도록 만들어 주시면 추상화 팩토리를 통해 인스턴스를 생성할 수 있게 됩니다.
// LegacyA_ViewControllerType.swift
// AppFeature_Domain (혹은 더 아래 레이어에 위치)
protocol LegacyA_ViewControllerType: ViewControllerType {}
struct LegacyA_ViewControllerFactoryType: ViewControllerFactoryType {
struct Payload {
let paramA: String
let paramB: SomeClass
}
var factoryClosure: FactoryClosure
init(_ factoryClosure: @escaping FactoryClosure) {
self.factoryClosure = factoryClosure
}
}
// LegacyA_ViewController.swift
// App
final class LegacyA_ViewController: UIViewController, LegacyA_ViewControllerType {
struct Payload {
let paramA: String
let paramB: SomClass
}
...
init(payload: payload) {
...
}
}
물론 현실 세계에서는 파라미터 뿐만 아니라 유저 액션에 대한 옵저버라던지 더 많은 Payload 가 있어야겠지만 예시 코드인지라 최대한 단순화했습니다.
뷰컨트롤러 코드는 앱 타겟에 있기에, 하위의 C 피쳐 모듈에서 사용하려면 LegacyA_ViewControllerFactoryType
를 주입받아야만 하고, 그러기 위해 DI Container 에 의존성을 등록해 주어야 합니다.
// LegacyA_Assembly.swift
// App
final class LegacyA_Assembly: Assembly {
...
private func registerLegacyA(container: Container) {
container.register(LegacyA_ViewControllerFactoryType.self) { _ in
// 추상 뷰컨트롤러 팩토리 생성 시 `factoryClosure` 주입
LegacyA_ViewControllerFactoryType { payload in
return LegacyA_ViewController(payload: .init(
paramA: payload.paramA,
paramB: payload.paramB,
...
)
}
}
}
}
이처럼 뷰컨트롤러가 위치하는 모듈에서 추상화 뷰컨트롤러 팩토리를 DI에 등록해 준 뒤, 새 모듈에서 개발하는 C 화면에서는 팩토리를 주입 받아서 사용하도록 구현해 주면 됩니다.
// C_ViewController.swift
// FeatureC
final class C_ViewController: UIViewController {
struct Dependency {
let legacyA_ViewControllerFactory: LegacyA_ViewControllerFactoryType
}
...
func presentLegacyA_ViewController() {
let legacyA_ViewController = self.dependency.legacyA_ViewControllerFactory.create(payload: .init(
paramA: "parameter",
paramB: SomeClass(...),
))
self.present(legacyA_ViewController, animated: true)
}
}
위 예시에서는 레거시에 대해서만 언급을 했지만, 새로 만드는 화면들에서도 동일하게 활용이 가능합니다.
피쳐 C 화면의 추상 클래스는 FeatureC_Interface 모듈에 선언이 되어 있고 마찬가지로 피쳐 D 화면의 추상 클래스는 FeatureD_Interface 모듈에 선언을 하게 되는데요, 새로 만들 피쳐 모듈의 구현체 모듈에서도 특정 피쳐의 인터페이스 타겟만 의존성으로 가지게 하면 마찬가지로 동일하게 사용할 수 있습니다.
// Project.swift (Tuist)
// FeatureD
let implementTargets = Project.implementTargets(
...
dependencies: [
...
.workspace.app.feature.featureC.interface, // Dynamic Framework 로 되어 있는 피쳐 C 모듈의 인터페이스 타겟
]
)
let project = Project(
...
)
// D_ViewController.swift
// FeatureD
final class D_ViewController: UIViewController {
struct Dependency {
let c_viewControllerFactory: C_ViewControllerFactoryType
}
...
func presentC_ViewController() {
let c_ViewController = self.dependency.c_viewControllerFactory.create(payload: .init(
...
))
self.present(c_ViewController, animated: true)
}
}
마치며
이번 글에서는 프로젝트 모듈화를 해나가면서 마주치는 여러 레거시들과 어떻게 공존해나갔는지에 대해 두 가지 형태로 소개를 드리게 되었는데요, 저희 팀에서는 이런 workaround 들을 활용해 모듈화에서 마주치는 여러 병목들을 전부 해소하지 않고서도 모듈을 점진적으로 분리해나가는 방향을 주로 택하고 있습니다.
현재 저희는 Shared / ThirdParty / AppCore / AppFeature / App 다섯 개의 레이어로 아래와 같이 모듈화를 해나가고 있습니다. 현 Phase 의 아키텍쳐가 어느정도 안정화가 될 즈음에는 하나의 레이어를 더 추가하게 될지도 모르겠네요.
저희 팀은 비즈니스에 강하게 포커스가 된 팀인지라 아직도 많은 모듈화 여정이 남기는 했습니다만, 그럼에도 아래와 같이 하나씩 점진적 개선을 통해 팀의 비즈니스 속도를 늦추지 않도록 팀의 생산성을 계속해서 일정 이상 유지하는 것이 목표입니다.
이 여정에 대한 글을 쓰면서 3부작을 생각했었는데요, Tuist 도입은 이미 팀 동료이신 형석님께서 쓰신 글이 있는지라 Part 3에서는 Tuist 내용은 제외하고 아키텍쳐 레이어에 대한 글을 써볼까 합니다. 다만 아직 고민이 완전히 끝나진 않은 단계여서, 충분히 팀 내에서 고민이 완료된 이후 찾아뵙게 될 것 같네요 😃
이번 글이 팀에서 모듈화 병목을 해소하는데 도움이 되길 바라며, 향후 Part 3 글에서 또 찾아뵙겠습니다!
[함께 성장할 동료를 찾습니다]
29CM(무신사)는 3년 연속 거래액 2배의 성장을 이루었습니다.
앞으로도 더 큰 성장을 만들기 위해 여러 스쿼드에서 OKR을 기반으로 여러 피쳐들을 만들어 가고 있으며, 이번 글과 같이 팀 성장을 위한 인프라/플랫폼 업무를 하기도 합니다.
함께 성장하고 유저 가치를 만들어낼 동료 개발자분들을 찾고 있습니다.
많은 지원 부탁드려요!
🚀 29CM 채용 페이지 : https://www.29cmcareers.co.kr/