iOS Modular Architecture 를 향한 여정 Part 2 — 프로젝트 모듈화, 레거시와 공존하기

Wooseong Kim
29CM TEAM
Published in
16 min readApr 21, 2023
https://www.freepik.com/free-vector/house-construction-set_26763763.htm

안녕하세요, 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 의 아키텍쳐가 어느정도 안정화가 될 즈음에는 하나의 레이어를 더 추가하게 될지도 모르겠네요.

저희 팀은 비즈니스에 강하게 포커스가 된 팀인지라 아직도 많은 모듈화 여정이 남기는 했습니다만, 그럼에도 아래와 같이 하나씩 점진적 개선을 통해 팀의 비즈니스 속도를 늦추지 않도록 팀의 생산성을 계속해서 일정 이상 유지하는 것이 목표입니다.

현재 저희 팀은 AS-IS 에서 TO-BE 로 가는 단계입니다.

이 여정에 대한 글을 쓰면서 3부작을 생각했었는데요, Tuist 도입은 이미 팀 동료이신 형석님께서 쓰신 글이 있는지라 Part 3에서는 Tuist 내용은 제외하고 아키텍쳐 레이어에 대한 글을 써볼까 합니다. 다만 아직 고민이 완전히 끝나진 않은 단계여서, 충분히 팀 내에서 고민이 완료된 이후 찾아뵙게 될 것 같네요 😃

이번 글이 팀에서 모듈화 병목을 해소하는데 도움이 되길 바라며, 향후 Part 3 글에서 또 찾아뵙겠습니다!

[함께 성장할 동료를 찾습니다]
29CM(무신사)는 3년 연속 거래액 2배의 성장을 이루었습니다.

앞으로도 더 큰 성장을 만들기 위해 여러 스쿼드에서 OKR을 기반으로 여러 피쳐들을 만들어 가고 있으며, 이번 글과 같이 팀 성장을 위한 인프라/플랫폼 업무를 하기도 합니다.

함께 성장하고 유저 가치를 만들어낼 동료 개발자분들을 찾고 있습니다.
많은 지원 부탁드려요!

🚀 29CM 채용 페이지 : https://www.29cmcareers.co.kr/

--

--

Wooseong Kim
29CM TEAM

Software Craftman @ 29CM. Love books, coffee, simplicity. An enthusiastic follower of Steve Jobs.