우리가 모노레포 대신 서브모듈을 선택한 이유

지난 한 해 저희 iOS 챕터에는 많은 변화가 있었습니다. 특히 제품이 늘어나면서 내부 모듈도 많이 늘었는데요, 오늘은 이 모듈을 관리하는 방식에는 어떤 것이 있고, 저희는 어떤 것을 선택했는지에 대해서 얘기해보도록 하겠습니다.

banner

프로젝트는 각자 타겟이 다른 만큼 환경도 다른데요, 앞으로 설명에서 계속 나올 프로젝트와 모듈을 간단하게 설명드리겠습니다.

프로젝트

  • 프로젝트 A: iOS 12 버전 이상 지원, 함수형 아키텍쳐를 기반으로 만든 Swift 프로젝트
  • 프로젝트 B: iOS 13 버전 이상 지원, SwiftUI + Combine 아키텍쳐 기반으로 만들고 있는 프로젝트

모듈

  • Design: 디자인 시스템의 Foundation, Component를 모아놓은 저장소
  • Core: 앱 개발 시 가장 기본이 되는 View, ViewController를 모아놓은 저장소

지금 상황의 한계

저희는 의존성 관리를 위해 Carthage(+Rome+Carting)을 사용하고 있습니다. 물론 새로운 모듈도 예외없이 Carthage로 관리하고 있었습니다.

그러나 프로젝트와 모듈이 점점 늘어나다보니 문제가 생겼습니다.

프로젝트 A에서 새로운 버튼인 AButton을 만들었다고 해보겠습니다. AButton을 너무 잘 만들어서 프로젝트 B에서도 사용하고 싶으면 어떻게 할까요? 저희가 지금까지 해온 방식은 AButtonCore로 옮기고 이름을 CoreButton으로 변경해서 공통으로 사용할 수 있게 만드는 것이었습니다. 그러면 프로젝트 A에서 CoreButton의 스펙을 변경하고 싶을 때는 어떻게 해야할까요?

저희는 Core로 가서 CoreButton을 수정 후 샘플 프로젝트에서 테스트 완료 후 새로운 버전으로 Carthage에 배포하는 방식을 선택했습니다. 하지만 이 방법에는 치명적인 문제가 있었습니다.

바로 실시간 업데이트가 불가능하다는 점이죠. 아무리 샘플 앱을 실제와 같이 만들었다고 해도 우리가 원하는 스펙과 동일하게 샘플 앱을 만들기는 어렵기 때문에, 완벽한 테스트는 불가능하다고 볼 수 있습니다.

만약 CoreButton의 접근 제한자를 바꾸는 것처럼 아주 사소하지만 중요하고 샘플 앱에선 놓칠 수 있는 변경이 생길 경우 모듈을 다시 배포해야 하는 상황이 생겼습니다.

매번 있는 일은 아니었지만 정말 번거로운 일이었습니다.

선배들의 해결책

이러한 문제는 이미 다른 회사, 다른 챕터에도 존재할거라 생각하고 그분들은 어떻게 문제를 해결했는지 찾아보았습니다.

뤼이드에선 iOS 챕터를 제외하고 모두 모노레포를 사용하고 있었습니다. 물론 각자 생태계별로 구축하는 방법은 다르니 상세 사항은 달랐습니다. (예시: 웹에선 대부분 lerna를 사용해서 모노레포를 관리한다고 합니다.)

다른 회사가 작성한 글 중 가장 기억에 남는 내용은 Uber가 모노레포로 넘어가는 과정을 기록한 글인데요, 이 글은 예전에 번역했을 정도로 유익한 글이었습니다. 게다가 의사결정에 도움도 많이 되었습니다.

Uber의 Faster Together

글을 읽어보면 Uber는 생각했던 것보다 꽤 늦게 모노레포로 넘어갔다는 것을 확인할 수 있습니다.

While our team and our projects were small, CocoaPods served us well. … The dependency graphs were usually small, so CocoaPods could resolve our dependencies very quickly and with minimal pain for our developers.

Uber는 CocoaPods를 이용해서 모듈을 관리했고 그 과정도 꽤 쾌적했다고 합니다.

But as we scaled from five to over forty modules … At the same time, more than 150 engineers had joined our wider iOS team …

40개 이상의 모듈과 150명 이상의 iOS 개발자가 생겼을 때에서야 문제를 느꼈다는 것을 알 수 있습니다.

Uber에 비해 저희는 프로젝트와 모듈을 합쳐도 10개가 넘지 않고 개발자도 10명이 넘지 않으니 모노레포로 가는 것은 지금의 iOS 챕터에겐 과하다고 생각했고, 분명 의존성 관리 도구만으로도 해결할 수 있는 방법이 있을거라 확신했습니다.

모노레포를 선택하지 않은 이유

Uber의 글을 통해서 어느정도 결정했지만, 모노레포로 넘어가지 않은 이유를 좀 더 확실히 정리하고 싶었습니다.

무엇이 올바른 브랜치 관리 방식인가

사내에서 모노레포를 채택한 챕터에 물어봤는데, 각자의 브랜치 관리 방식이 달랐습니다.

어떤 챕터는 서비스가 n개일 때 n개의 master 브랜치가 생기는 방식인 Git Flow를 사용하고 있었고, 또 어떤 챕터에선 trunk라는 하나의 줄기 브랜치에 모든 개발자가 커밋하는 Trunk Based Development를 사용하고 있었습니다.

그 때 저희는 Feature Toggle 방식을 사용해서 브랜치를 관리하는 것에 대해 얘기하고 있었습니다. 그래서 모노레포를 사용함에 있어서 어느 방식이 옳은지 알 수가 없었습니다.

애매한 CI/CD 정책

저희는 CI/CD를 위해 맥 미니를 하나 구매해서 azure pipelines의 self-hosted 서비스를 사용하고 있습니다.

모노레포를 사용할 경우 yaml 파일을 어떻게 수정해야할지 걱정됐는데 다행히 CI는 생태계별로 크게 다르지 않아서 선례가 존재했습니다.

Azure DevOps YAML build for Mono Repository with multiple projects - DEV Community 👩‍💻👨‍💻

그렇지만 모듈과 프로젝트가 모아놓은 모노레포에서 어떤 프로젝트는 CI 돌린 후 테스트 플라이트에 올려야 하고, 또 어떤 프로젝트는 CI만 돌려야하며, 모듈에 따라 CI를 돌릴지 말지도 달랐기 때문에 예외 사항이 너무 많았습니다.

확실하지 않은 공통 모듈의 배포 방법

모노레포로 간다면 프로젝트 A 단일 저장소와 나머지의 모노레포로 만들 예정이었습니다. 모듈 중에 Design이라는 디자인 시스템은 프로젝트 A프로젝트 B 둘 다 사용하고 있었습니다.

Carthage는 태그에 달린 내용으로 버전을 관리하니 Design이 1.0.0 버전으로 배포되면 Core도 1.0.0 버전으로 배포가 됩니다.

반대로 Core가 1.1.0 으로 배포되면 Design도 1.1.0으로 배포가 될 것입니다.

그러면 프로젝트 A 입장에선 Design에 변경이 하나도 없는데 마이너 버전이 올라가기 때문에 혼란이 생길 것입니다.

즉, 쓸모없는 배포가 생길 거라고 예상했습니다.

게다가 디자인 시스템인 Design은 Dropbox나 Stack Overflow, GitHub이 했던 것처럼 외부에 공개할 가능성도 있는데 모노레포로 합친다면 나중에 다시 쪼개는 일에 비용이 점점 커질거라는 생각도 했습니다. 참고 링크: Adele – Design Systems and Pattern Libraries Repository

대안을 찾아봅시다

모노레포를 쓰지 않는 것은 결정났습니다. 그러면 어떤 것이 모노레포의 대안이 될 수 있을까요? 저는 최대한 기존에 쓰던 도구 범위 안에서 해결하고 싶었습니다.

CocoaPods

Uber의 글에서도 나왔듯이 CocoaPods로 관리하는 방식이 가장 대중적인 것 같습니다.

Modular Architecture on iOS and how I decreased build time by 50%.

하지만 저희는 Carthage+Rome+Carting의 편함으로 인해서 빌드 시간을 무진장 많이 아끼고 있었기 때문에 CocoaPods를 사용할 엄두가 나지 않았습니다.

Carthage는 모듈이 늘어나도 미리 빌드하는 시간만 늘어날 뿐 Rome를 통해 의존성을 다운받으면 1분안에 무조건 끝나기 때문에 CocoaPods이 실시간으로 빌드하는 것을 참을 수 없었습니다.

Carthage --use-submodules

게다가 기존에 해온 것(1, 2)이 있기 때문에 Carthage를 버리고 싶지 않았고, 변하지 않는 외부 라이브러리를 빌드하지 않는 데에서 나오는 시간적 이득을 포기할 수 없던 저희는 Carthage로 모듈을 관리할 수 있는 방법이 없는지 찾아보았습니다.

그러던 중 Carthage--use-submodules라는 옵션이 있는 것을 알게 되었습니다.

How to use Carthage with submodules correctly? · Issue #435 · Carthage/Carthage

약간의 고난이 있었지만 다음의 fastlane 명령어만 실행하면 올바른 서브모듈 설정을 할 수 있게 되었습니다.

lane :update_submodules do
  carthage(
    command: "update",
    dependencies: ["core-ios", "design-ios"],
    use_submodules: true,
    use_binaries: false,
    platform: "iOS",
    no_build: true
  )
end

결과로 나오는 워크스페이스는 아래와 같은 모양을 가집니다.

workspace

서브모듈 설정하기

서브모듈을 위해 작업했던 내역을 기록합니다.

  1. 서브모듈을 설정하고자 하는 프로젝트로 갑니다. 여기서는 ProjectA라고 부르겠습니다.
  2. File > Save As Workspace를 통해 프로젝트를 워크스페이스로 변경합니다.
  3. Cartfile에 서브모듈로 넣고 싶은 저장소를 추가합니다. 여기에 CoreDesign같은 모듈이 들어갑니다.
  4. 다음 명령어를 실행합니다.
    carthage update --platform ios --no-use-binaries --use-submodules
    
  5. 워크스페이스에 CoreDesign을 임포트합니다.
  6. 빌드 후 앱이 잘 돌아간다면 일단 성공입니다.
  7. CoreDesign으로 가서 테스트를 위한 코드를 추가합니다.
    open func helloWorld() {
        print("Hello Submodule World")
    }
    
  8. ProjectA로 돌아와서 새로 생긴 helloworld()를 호출할 수 있으면 성공입니다.

잘 작동하는지 확인하기

코드 리뷰 시에 이미 워크스페이스 작업돼있는 상태에서 서브모듈이 잘 불러와지는지 확인하기 위해 저는 다음과 같은 명령어를 사용했습니다.

git clone git@github.com:pilgwon/project-a.git
cd project-a
git checkout use-carthage-submodules
fastlane download_dependency
fastlane update_submodules
xcodebuild -workspace project-a-ios.xcworkspace -scheme Core build
xcodebuild -workspace project-a-ios.xcworkspace -scheme Design build
xcodebuild -workspace project-a-ios.xcworkspace -scheme Project\ A build
open santa-act-ios.xcworkspace

fastlane download_dependency는 이전에 썼던 블로그 포스트에서 나온 내용인데 Fastfile에 있는 명령어입니다. 내용은 다음과 같습니다.

lane :upload_dependency do
  rome(
    command: "upload",
    platform: "iOS",
    concurrently: "true",
  )
  sh("cd ..;carting update")
end

lane :download_dependency do
  rome(
    command: "download",
    platform: "iOS",
    concurrently: "true"
  )
  sh("cd ..;carting update")
end

여기까지 Carthage--use-submodules 옵션을 사용해서 서브모듈 작업을 하는 방법이었습니다.

CI/CD 사용법

개발할 땐 실시간 업데이트의 편의를 위해 서브모듈이 포함된 워크스페이스를 사용하지만, CI에선 다른 서브모듈이 필요없고 주인공인 ProjectA.xcodeproj 프로젝트 파일만 바라봅니다.

만약 fastlane gym을 통해 앱 배포를 관리한다면 문제가 생길수도 있습니다. 프로젝트를 기반으로 빌드할지 워크스페이스를 기반으로 빌드할지 정해주지 않으면 에러가 생길 수 있기 때문입니다.

저희의 경우는 Design을 직접 빌드해주지 않으면 Design-Swift.h 파일이 생성되지 않는 문제 때문에 프로젝트를 기반으로 fastlane gym 빌드를 하도록 수정했습니다.

gym(
  project: "projectA.xcodeproj",
  scheme: "Project A PRD",
  export_options: {
    method: "app-store",
    provisioningProfiles: { ... }
  }
)

이것 이외엔 CI/CD 작업에선 문제가 생기지 않았네요.

Carthage --no-build 활용하기

fastlane update_submodules의 코드엔 no-build 설정이 true이고, 서브모듈 설정하는 과정에선 따로 no-build 옵션을 사용하지 않는 것을 확인하실 수 있으실 겁니다.

기존에는 no-build 옵션을 사용하지 않고 서브모듈을 업데이트했습니다. 그런데 Core의 경우 RxSwift가 의존성으로 존재해서 항상 10분 정도 걸렸습니다. 저희는 이걸 기다릴 필요가 없는 시간이라 판단했고, 이를 해결하기 위해 서브모듈인 Core의 의존성을 찾을 때 ProjectA/Carthage/Checkouts/Core/Carthage/Build/iOS에서만 찾지 않고 상위 폴더인 ProjectA/Carthage/Builds/iOS에서도 찾도록 만들어주었습니다. 그랬더니 서브모듈이 의존성을 제대로 잘 찾게 되었습니다!

framework-path

그 결과 더 이상 서브모듈을 따로 빌드할 필요가 없어졌고, 덕분에 서브모듈 스크립트 실행 시간이 10분에서 1분 내외가 되었습니다.

GUI/CLI에서 서브모듈 사용하는 법

GUI로 서브모듈 사용하는 법은 GitKraken에 설명이 잘 돼 있습니다. https://support.gitkraken.com/working-with-repositories/submodules/

CLI로 서브모듈 사용하는 법은 Git 사이트를 참고해주세요. https://git-scm.com/book/en/v2/Git-Tools-Submodules

마치며

주변에서 모노레포를 많이 채택해서 잘 사용하고 계셔서 모노레포로 고민없이 넘어갈까 생각도 많이 했지만, 이번에 의사결정을 위해 정보를 모으면서 많이 배운 것 같습니다.

아직 서브모듈이 완벽하지는 않아서 모든 프로젝트에 적용하지는 않았지만 따로 배포 없이 실시간으로 모듈을 수정할 수 있으면서 Carthage의 좋은 점은 그대로 가져갈 수 있다는 것이 정말 최고입니다.

그리고 따로 모듈을 클론 해 둘 필요 없이 지금 개발하는 저장소에서 서브모듈을 통해 Git 관련 작업은 모두 할 수 있어서 GitKraken에 열어놓은 탭도 많이 줄었습니다.

서브모듈에 관해서 제가 모은 정보는 여기까지입니다. 읽어주셔서 감사합니다!