안녕하세요! 앱개발팀의 레이몬드입니다. 2024년 한 해동안 모듈화를 경험하면서 느낀점들과 후회하는 것들을 글로 써보고자 합니다.
2023년 초부터 논의해 오던 모듈화를 2024년에 본격적으로 진행했고 지금도 모듈화 작업을 이어 나가고 있습니다. 1년동안 진행한 작업들을 되돌아 보니 생각보다는 많은 작업들을 진행했더라구요. 많은 모듈화 관련 포스트들이 모듈화의 필요성에 대해 다루고 있어서 비슷한 내용보다는 다른 관점인 모듈화를 진행하면서 느낀점들과 아쉬웠던 것들을 정리하고자 합니다.
모듈화에 대해 단순히 성공적인 결과로 이어진 과정이 아니라, 그 속에서 어떤 어려움과 도전이 있었는지를 공유하고자 합니다.
모듈화를 진행하면서 목표로 설정한 구조는 아래 그림과 같습니다. 처음에는 단일 프로젝트로만 구성된 상태였고 지금은 피처 모듈들을 원활하게 분리하기 위한 기반 모듈들이 어느정도 구성된 상태입니다.
크게 3가지 레이어로 구성했습니다.
위에서 소개한 Core 레이어에 대한 설명 중 의아한 부분이 하나 있습니다. Core 레이어에 모델과 레포지토리가 포함되어 있다는 점입니다. 앱 전반적으로 사용되는 모델이나 레포지토리가 아니라면, 기본적으로 피처에서 사용하는 모델과 레포지토리는 해당 피처 모듈 내부에 포함되는 것이 맞다고 생각합니다. 하지만 실제 모듈화를 진행하는 과정에서 몇 가지 이유로 인해 별도 모듈로 분리할 필요성이 생겼고, 이에 따라 분리를 진행하게 되었습니다.
모듈화 시작할 당시, 어디서부터 손을 대야 할지 막막함을 느꼈습니다. 그래서 빈 껍데기 형태의 피처 모듈을 먼저 생성한 후, 관련된 파일들을 모듈 내부로 옮겨보는 작업을 시도했습니다. 그런데 이 과정에서 코드 간의 의존성이 강하게 얽혀 있어, 피처 모듈을 완전히 독립적으로 분리하기 어려운 상황임을 깨닫게 되었습니다.
이러한 경험을 바탕으로, 피처 모듈을 완전히 분리하려면 먼저 의존성을 정리하는 것이 선행되어야 한다는 결론을 내렸습니다. 이후 의존성이 비교적 적은 코드부터 점진적으로 분리하는 방법을 고민했고, 그 과정에서 모델과 레포지토리가 가장 적합한 대상으로 보였습니다.
이에 따라, 우선 모델과 레포지토리를 Core 레이어의 ZCore 모듈로 이동시키는 방향을 선택했습니다. 이렇게 하면 의존성을 미리 분리할 수 있고, 이후 필요에 따라 피처 모듈로 옮길 때도 큰 문제 없이 이동할 수 있을 것이라 판단했습니다.
지그재그는 이미 많은 사용자들이 있는 서비스이므로 앱 업데이트 시 가장 중요하게 생각하는 것은 안정성입니다. 기술 과제로 인해 버그가 발생하는 것을 최소화해야 하죠. (물론 다른 서비스들도 마찬가지일 것입니다.) 버그 발생률을 줄이기 위해서는 영향 범위를 최소화해야 했습니다. 하지만 처음에는 이러한 점을 깊이 고려하지 못한 채, 욕심을 부려 한 번에 많은 파일을 이동시켰습니다. 많은 파일을 이동시키다 보니 예상치 못한 오류들이 발생하기도 했고 리뷰 과정에서 팀원들의 부담도 커졌습니다.
특히, 1번에서 설명했던 모델들을 ZCore 모듈로 옮기는 작업이 가장 대표적인 예였습니다. 이미 지그재그 프로젝트는 상당한 규모를 가지고 있었고, 그만큼 모델 파일도 많았습니다. 그런데 모든 모델을 한꺼번에 ZCore로 이동하면서 파일 변경량이 과도하게 커지는 문제가 발생했습니다.
이로 인해 다음과 같은 어려움이 있었습니다
변경된 파일이 너무 많아 PR 크기가 커짐: PR이 커질수록 코드 리뷰어의 부담이 커졌고, 리뷰 자체가 지연되는 문제가 발생했습니다.
모든 관련 파일에 import를 추가해야 하는 부담: 모델을 사용하는 파일마다 import ZCore
를 추가해야 했고, 예상보다 변경해야 할 부분이 많아졌습니다.
예상치 못한 의존성 문제 발생: 기존에는 같은 모듈 내에서 사용하던 모델들이 ZCore로 이동하면서, 예상하지 못했던 의존성 문제가 드러나기도 했습니다.
결과적으로 작업량이 증가하면서 예상보다 시간이 많이 소요되었고, 팀원들에게도 부담을 주는 작업이 되어버렸습니다.
비록 부담이 컸던 작업이었지만, 모듈화의 효과는 분명히 있었습니다. 모델들이 별도의 모듈로 이동하면서 의존성이 정리되었고, 이후 다른 파일들을 분리하는 작업이 이전보다 훨씬 수월해졌습니다. 이 경험을 바탕으로, 앞으로 유사한 작업을 다시 수행해야 할 경우에는 더 나은 접근 방식을 적용해 영향 범위를 최소화하려고 합니다. 아래와 같은 방식으로 진행하면 더 효과적일 것 같습니다.
✅ 더 나은 접근 방식
이런 방식을 적용하면, 영향 범위를 기존보다 훨씬 줄일 수 있고 PR 크기도 작아져 리뷰어들에게도 부담을 덜 줄 수 있을 것이라 생각합니다.
모듈화를 시작하면서 네이밍 규칙을 처음부터 정하지 않았던 것이 큰 실수였습니다. 모듈을 하나씩 나누다 보니 네이밍이 일관되지 않은 상황이 발생했습니다.
모듈이 앞으로 더 늘어날 것을 생각해 지금이라도 네이밍 규칙을 정해야 한다고 판단했습니다. 그래서 Feature Layout 모듈을 제외한 나머지 Layer 모듈들에는 Z prefix를 붙여 일관되게 통일했어요. 예를 들어 ZCore
, ZUI
, ZMarketingLogger
와 같이 명명했습니다.
네이밍만 변경한 작업이라 로직에 영향이 없을 줄 알았고, 코드 리뷰와 개발자 자체 검증(QA팀의 검증 없음)만으로 간단히 진행했습니다. 하지만 예상치 못한 런타임 크래시가 발생했습니다.
문제는 UI 파일들이 모여있는 ZUI 모듈에 존재하던 레거시 xib 파일 때문이었습니다. xib 파일은 모듈 이름이 변경될 때 Inspectors에서 수동으로 설정을 변경해야 하는데, 이를 간과했죠.
코드 검색 기능으로 모든 파일의 네이밍을 변경했지만 xib 파일내의 속성까지는 검색되지 않아 기존 설정이 남아 있었고, 결국 앱 실행 시 크래시가 발생했습니다.
이 사건을 겪으면서 잘 사용하지 않던 xib의 속성에 대해서 알게 되었고 이참에 xib 파일을 빠르게 제거해야겠다는 생각도 들었습니다. 무엇보다 처음부터 네이밍 규칙을 잘 정하고 파일들을 옮겼더라면 이런 문제가 발생하지 않았을 거란 교훈을 얻었습니다.
모듈화 초기에는 앱 실행시간에 대한 우려 없이 모듈을 나누기 시작했습니다. 하지만 초기 단계에서 나눈 모듈들은 대부분 Core Layer에 속했고, 여러 모듈에서 공통으로 사용되다 보니 자연스럽게 Dynamic Framework 형태로 구성될 수밖에 없었습니다.
문제는 Dynamic Framework의 수가 늘어나면서 점점 앱 실행시간이 길어지기 시작한 점이었습니다. 특히 현재 상황에서는 나중에 Static Framework로 이동할 코드들조차 Core Layer에 모여 있기 때문에 일시적으로 앱 실행시간이 늘어난 것이 아닌가 하는 생각도 들었죠.
하지만 앞으로 Feature 모듈이 분리되고 Core Layer가 최적화되면 실행시간이 모듈화 이전 수준까지 줄어들지에 대한 의문이 여전히 남아 있습니다.
지금은 Core Layer에 있는 특정 모듈들을 static으로 변경해보거나 Dynamic framework로 사용하는 CocoaPod을 SPM으로 변경해보는 것으로 앱 실행시간을 줄여보고 있습니다.
이 문제는 단순히 현재 상태를 유지하는 것이 아니라, 모듈화가 발전해 나가는 과정에서 다양한 방안을 고민하고 실험하며 개선해 나가야 할 과제라고 생각합니다.
Core Layer의 모듈들을 분리하면서 코드가 역할과 의존성에 따라 정리되는 느낌이 들어 나름 청소가 잘 되고 있다고 생각했습니다. 하지만 진행하다 보니 역할이 모호한 코드들이 하나둘씩 눈에 띄기 시작했어요. 대표적인 예로 다음과 같은 코드가 있습니다.
enum UserAccountInitType: String, Decodable {
case general = "GENERAL"
case kakao = "KAKAO"
case apple = "APPLE"
case google = "GOOGLE"
case facebook = "FACEBOOK"
}
extension UserAccountInitType {
var localizedName: String? {
switch self {
case .general: return nil
case .kakao: return Strings.Common.kakao
case .apple: return Strings.Common.apple
case .google: return Strings.Common.google
case .facebook: return Strings.Common.facebook
}
}
UserAccountInitType은 사용자가 회원가입이나 로그인을 할 때 선택할 수 있는 방식을 enum
형태로 정의한 모델입니다.localizedName
은 화면에서 각 회원가입/로그인 타입에 맞게 노출되는 텍스트입니다.
여기서 문제는 enum
타입 자체는 모델에 속하기 때문에 ZCore 모듈에 넣는 것이 타당하지만, localizedName
변수는 문자열 리소스를 담고 있기 때문에 ZResource 모듈에 속한다고도 볼 수 있다는 점이었어요.
이런 경우처럼 역할이 애매한 코드가 발생하면 모듈을 나누는 기준이 모호해지기 시작합니다. localizedName
을 ZCore에 두자니 문자열 리소스와의 의존성이 혼재되고, ZResource에 두자니 모델과의 분리가 어색해져 결국 ZCore와 ZResource 간의 불필요한 의존 관계가 생기게 되죠.
이러한 상황을 해결하기 위해 여러 방법을 고민 중입니다:
아직 어떤 방법이 최선인지 확답을 내리기 어렵지만, 이런 고민을 하나씩 해결해 나가면서 더 명확한 구조를 만들어 가려고 합니다.
모듈화 작업을 통해 다음과 같은 교훈을 얻었습니다:
올해 iOS 모듈화 작업은 많은 도전과 성취를 안겨준 경험이었습니다. 이번 글은 모듈화를 경험하며 느꼈던 점들을 간략하게 적어보았는데요 다음번에는 모듈화를 진행했던 방법들에 대하여 하나씩 적어보겠습니다. 예) 코드 결합도를 해결하기 위한 방법들
앞으로도 더 효율적이고 유연한 코드를 만들기 위해 노력하겠습니다. 혹시 모듈화에 대해 고민하고 계시다면, 이 글이 작은 도움이 되었으면 좋겠습니다. 감사합니다!
✏️ 편집자주: 지그재그 코드 베이스는 벌써 10년이 됐습니다. 파일 개수만 수천개에 이르는 큰 프로젝트를 유지 보수하는 일은 쉽지 않습니다. 하지만 어렵다고 방치하면 나중에 더 큰 어려움이 되어 되돌아옵니다. 지그재그 앱 개발팀은 Swift 전환에 이어, 모듈화까지, 새로 시작하고 싶은 유혹을 참고, 달리는 자동차를 멈추지 않고 바퀴를 교체해오고 있습니다.