[번역] 우버의 iOS 모노레포

본문 링크

지난 몇 년간, 우버는 엄청난 성장과 550개 이상 도시에 확장하는 경험을 겪었습니다. 2014년에 갓 12명을 넘던 iOS 개발자들이 지금은 수백 명의 모바일 팀이 되었습니다. 그 결과, 모바일 팀의 툴링은 이 크고 유동적인 팀의 요구에 맞추기 위해 엄청난 변화를 겪어야 했습니다.

지난 9월에 @Scale 컨퍼런스에서는 우버 개발팀이 어떻게 변화했는지 보여드렸습니다. 오늘은 더 깊이 파고들어서 왜 하나의 저장소로 모이게 되었는지와 이러한 변화가 어떻게 우버 모바일 개발팀을 더 좋게 만들었는지에 대해 초점을 맞춰보겠습니다. (어떻게 보면 monolithic migration 혹은 microservice infrastructure 아키텍쳐와 대조되어 보일 수 있겠네요)

모노레포 이전 상황

먼저 모노레포 이전의 우버 iOS 개발의 상태를 보도록 하겠습니다. iOS에서는 어플리케이션을 구성하는 일은 오픈 소스 도구인 CocoaPods 하나에 의존하고 있었습니다. CocoaPods은 패키지 매니저이면서 의존성 해결사이고 통합(integration) 도구입니다. 이 도구는 개발자들이 Xcode의 다른 복잡한 프로젝트 설정 없이 빠르게 다른 라이브러리를 자신의 어플리케이션에 통합할 수 있게 해줍니다. 또한 CocoaPods은 의존성 그래프에서 일어날 수 있는 타겟팅 순환 이슈를 컴파일할 때 잡아주었습니다. 게다가 Cocoa 진영에선 가장 유명한 패키지 매니저였기 때문에 저희가 사용하는 라이브러리 중에서는 CocoaPods만 지원하는 경우도 있었습니다. 그리고 도구가 유명했기 때문에 사용자들이 이슈를 발견하면 컨트리뷰터들에 의해서 빠르게 해결됐습니다.

프로젝트가 작았을 땐 CocoaPods가 아주 잘 작동했습니다. 우버의 초반 몇 년 동안 저희가 만든 어플리케이션의 대부분은 하나의 공유된 라이브러리와 다수의 오픈 소스 라이브러리들로 만들었습니다. 의존성 그래프는 대부분 작아서 CocoaPods가 의존성 문제를 빠르게 해결할 수 있었고 개발자들도 고통을 적게 받았습니다. 이러한 이점은 저희가 제품을 만드는 일에만 집중할 수 있게 해줬습니다.

코드베이스 모듈화

2014년 말, 우버 개발팀의 공유 라이브러리는 어떠한 구체적인 규칙을 가지지 않은 코드들이 모인 쓰레기장이 되었습니다. 독립적인 컴포넌트와 클래스를 단 한 번의 임포트로 끝내기 위해서 서로 의존하게 만드는 것이 결국 터진 것이죠. 게다가 그 때는 회사의 미션을 달성하기 위해 유닛 테스트 없이 만든 코드와 제대로 나이를 먹지 못한 API들도 쌓여있었습니다. 저희가 더 잘할 수 있다는 것을 알았기 때문에, 코드베이스를 모듈화하는 것에 노력을 쏟기로 했습니다. 그리고 동시에 네트워킹, 분석 등 어플리케이션의 핵심 영역을 새로 작성하기로 했습니다.

그러기 위해서 저희는 앱의 모든 중요한 부분들을 어떤 우버 앱이든 사용할 수 있는 블록으로 컴포넌트화 했습니다. 저희는 이 프레임워크를 ‘modules’라고 불렀고, 각 모듈은 각자 하나의 저장소를 가졌습니다. 모듈화는 빠른 프로토타이핑이 가능하게 만들었고 심지어는 실제 프로덕션 어플리케이션도 만들 수 있게 되었습니다. 2015년 말, UberEATS독자적인 어플리케이션으로 떼어내기로 했을 때, UberEATS 팀은 저희가 만들었던 새로운 모듈들의 도움을 많이 받았습니다. 개발자들은 플랫폼에서 요구하는 사항을 관리하는 일 대신에 제품에 집중할 수 있는 시간을 더 가질 수 있게 되었습니다. 예를 들면 저희는 디자이너들이 사용할 타이포그래피, 컬러 스킴 그리고 공통으로 쓰이는 UI 엘리먼트들을 구현해놓은 UI 툴킷을 만들었습니다.

2015년 초에는 5개의 모듈이 있었습니다. 2017년 초에는 앱 내에서 공유되는 라이브러리가 40개가 넘었으며 이는 프레임워크 단계(ex. 네트워킹, 로깅 등)의 라이브러리부터 제품에 특화된(ex. 매핑과 결제) 라이브러리까지 포함했습니다. 저희의 세 어플리케이션(Rider, Driver 그리고 EATS)는 이 공유 인프라에 의존했습니다. 모듈의 버그 수정이나 퍼포먼스 향상은 즉각적으로 어플리케이션에 적용됐는데, 이는 정말 엄청난 이득이었습니다. 결과적으로, 저희의 모듈화 시도는 엄청난 성공을 이뤘습니다.

하지만 모듈이 5개에서 40여개로 늘어나면서 문제가 생겼습니다. 처음엔 도움이 됐던 Cocoapods이 모듈의 수가 늘어나니 오히려 발목을 잡았습니다. 그 시기에는 150명이 넘는 개발자들이 iOS 팀에 합류하게 되었고 이는 저희 어플리케이션과 모듈이 계속해서 바뀔 거라는 의미였습니다.

변화가 필요한 시기

회사가 성장할수록 개발팀은 변화가 필요함을 느꼈습니다. 라이브러리가 빠르게 많이 추가돼다보니 CocoaPods가 해결해줄 수 있는 최대 의존성까지 빠르게 닿았습니다. 프로젝트 초반엔 pod install이 10초도 안 걸렸는데 그때는 몇 분이 걸렸습니다. 의존성 그래프는 매우 복잡해서 CocoaPods가 이를 해결하고 모듈을 포함해주기를 기다리는 것을 다 합치면 매일 몇 시간이 걸렸습니다. 이 시간 낭비는 CI 인프라가 생기는 순간부터 더욱 나빠졌습니다.

또한 저장소가 하나가 아니라는 점도 부담이었습니다. 하나의 모듈은 하나의 저장소에 담겨있었고 각 모듈은 다른 모듈에 의존성을 가질 수 있었습니다. 그러다보니 모듈 하나에서 일어나는 변화에도 어플리케이션의 Podfile을 업데이트해야 했습니다. 변화가 클 경우엔 그 변화가 일어난 모듈에 의존성을 가지는 모든 모듈을 업데이트해야 했습니다.

앱에서 모듈을 사용하기 위해 모든 모듈에 각자 버전을 지정해줬어야 했기 때문에 저희는 모듈을 태깅하는 의미론적인(semantic) 버저닝 컨벤션을 도입했습니다. 의미론적인 버저닝은 모듈 하나로 생각하면 간단한 개념이지만, 실제로는 컴파일러 세팅에 따라 엄청난 변화가 일어나는 경우도 있었습니다.

겉보기론 위험하지 않은 코드 변화가 에러 또는 경고(저희는 CI에서 경고를 에러로 취급합니다)를 발생시키기도 했습니다. 다음과 같은 코드를 예로 들 수 있겠네요. (간결하게 쓰기 위해 보일러플레이트 코드는 제거했습니다)

public enum KittenType {
   case regular
   case munchkin
}
public protocol KittenProtocol {
   public var type: KittenType { get }
   public var name: String { get set }
}
public struct Kitten: KittenProtocol { }
public protocol Kittens {
   var kittens: Set<Kitten> { get }
   func contains(aKitten ofType: KittenType) -> Bool
}

겉보기론 KittenProtocol에 새로운 속성을 추가하는 것이 문제 없을 것 같습니다. 하지만 프로토콜의 접근 제한 단계를 모듈 바깥에서도 접근할 수 있게 한다면 발생할 것입니다. (이 모듈을 ‘UberKittens’라고 부르겠습니다. 적절한 이유가 있기 때문이죠) 새로운 속성을 추가하는 것은 엄청난 변화입니다. 왜냐하면 프로토콜의 속성은 반드시 그들의 클래스 혹은 구조체에서 구현돼야 하기 때문입니다.

KittenType에 새로운 case를 추가하는 것도 위와 같은 설정에선 엄청난 변화입니다. 이 enum을 만들었던 곳에서 switch 문을 사용하고 있었다면 새로운 case에 대해선 대응하지 못할 것이기 때문입니다.

위 이슈는 아주 작고 UberKittens 모듈의 사용자 누구든 쉽게 해결할 수 있습니다. 하지만 이러한 변화를 의미론적인 버저닝 세계에서 안전하게 만드는 것은 메이저 버전 업데이트를 하는 것입니다. 하루에 수백 명의 개발자가 수백개의 변화를 만듭니다. 모든 잠재적인 엄청난 변화를 잡아내는 것은 불가능합니다. 게다가 내가 쓰고 있는 라이브러리가 의존성을 가지는 라이브러리를 업데이트하면 해결해야 할 경고도 수십 개 일 것입니다.

저희는 개발팀이 버전 넘버 고민 없이 변화를 빠르게 만들 수 있기를 원했습니다. 버전이 충돌하는 것을 해결하는 일은 개발자에게 있어선 아주 짜증 나는 일이고 앞서 말씀드렸듯이 CocoaPods는 지금의 복잡한 의존성 관계에선 아주 느려져서 사용할 수 없었습니다. 또한 저희는 개발자가 의존성 그래프를 보며 모듈을 업데이트하는 것에 시간을 낭비하지 않기를 원했습니다. 개발자는 가능한 한 최소한의 커밋으로 무엇이든 원하는게 있다면 만들 수 있어야 한다고 생각했습니다.

그래서 해결책이 뭐냐구요? 바로 모노레포(monolithic repository)입니다.

모노레포 계획하기

물론 모노레포는 새로운 아이디어가 아닙니다. 다른 큰 테크 회사들이 성공적으로 적용했습니다. 여러분의 모든 코드를 하나의 저장소에 밀어 넣는 것은 부작용(VCS 퍼포먼스, 모든 타겟에 영향을 끼치는 파손 등)이 일어날 수 있는 반면에 좋은 점은 훨씬 큰데, 이는 개발 워크플로우가 어떤지에 달려있습니다. 모노레포를 적용하면서 개발자들은 엄청난 변화가 일어난 부분을 atomic 하게 하나의 커밋으로 처리할 수 있게 되었습니다. 고민해야 할 버전 번호가 사라졌으니 의존성 그래프 해결은 더 쉬워졌습니다. 회사엔 수많은 팀에 수백 명의 개발자가 속해있기 때문에 모든 iOS 코드를 한 곳으로 모아서 찾기 쉽게 만들었습니다.

저희는 이러한 이점들을 놓치고 싶지 않았기 때문에 모노레포로 가야 한다는 것은 알고 있었습니다. 하지만 그러한 작업을 위해 어떤 툴링이 필요한지 알 수가 없었습니다. 처음엔 CocoaPods로 이루어진 모노레포를 만들려고 했습니다. 하지만 이는 개발자가 개개인이 모든 어플리케이션과 모든 모듈의 모든 코드 변화에 대해서 빌드를 해야 한다는 것을 의미했고 코드 리뷰도 동일했습니다. 저희는 바뀐 것만 빌드하는 더 똑똑한 방법을 원했지만 바뀐 부분만 알아서 빌드하게 하는 것은 수천 시간을 투자가 필요하다고 판단했습니다.

운이 좋게도 그러한 작업(그리고 그 이상!)을 할 수 있는 도구가 저희에게 와주었고, 이 도구는 Buck이라 부릅니다.

Buck이 시간을 아껴주었습니다

Buck은 모노레포를 위해 만들어진 빌드 도구이며 코드를 빌드하며 유닛 테스트를 실행하고 빌드 아티팩트를 다른 기기로 배포해서 다른 개발자들의 오래된 코드를 컴파일하는 시간을 아껴주고 새로운 코드를 작성하는 시간을 늘려주는 역할을 합니다. Buck은 작고 재사용이 가능한 모듈을 위해 만들어졌고 한곳에 모여있는 코드를 똑똑하게 분석해서 새로운 부분만 빌드해줍니다. 이 도구는 속도를 위해 만들어졌기 때문에 멀티 코어 CPU를 사용하는 개발자들의 랩탑에선 여러 개의 모듈을 동시에 빌드할 수 있는 이득이 있었습니다. 게다가 Buck은 동시에 타겟에 대해 유닛 테스트도 진행할 수 있었습니다!

이전에도 Buck에 대해 많이 들어왔지만, 공식적으로 iOS와 Objective-C 프로젝트를 지원하기 전까지는 사용할 수 있는 방법이 없었습니다. 페이스북이 2015년 @Scale 컨퍼런스에서 Buck의 iOS 지원에 대해 발표했을 때, 우리 어플리케이션에 Buck을 사용할 수 있다는 사실에 기뻤습니다.

초기 테스트에선 Buck이 CI의 빌드와 테스트 성능을 훌륭하게 향상해준다는 결과가 나왔습니다. 보통 xcodebuild를 사용할 때 가장 좋은 접근 방식은 빌드 혹은 테스트 전에 항상 클린해주는 것입니다. CI 호스트는 커밋 히스토리를 보며 빌드하기 때문에 지속적으로 캐시가 남아있습니다. 이러한 이유로 xcodebuild 캐시는 불안정하다는 결론을 지었습니다. (저희는 CI 안정성이 최우선이었습니다) 하지만 빌드 전에 클린해야 하는 경우 새로운 변경 점만 빌드할 수 없기 때문에 CI 작업이 불필요하게 느려집니다. 그래서 저희 어플리케이션의 빌드 시간은 성장과 함께 급등했습니다. 수백 명의 개발자 각자의 시간을 합치면 매일매일 CI 빌드를 기다리는 데에 몇 시간을 낭비했다는 것입니다.

Buck은 이러한 문제를 reliable cache로 해결했습니다. (distributed 옵션도 있습니다) Buck은 공격적으로 빌드된 아티팩트들을 캐싱했고 가능한 한 많은 코어를 사용해서 빌드했습니다. 타겟이 빌드되면 그 타겟의 코드가 변하기 전까지는 다시 빌드하지 않았습니다. 모든 것을 캐싱하는 덕분에 빌드하고 테스트할 저장소를 지정할 수 있게 된다는 뜻입니다.

저희 CI 기기는 이 캐싱 아키텍쳐의 이득을 많이 봤습니다. 요즘은 개발자가 빌드가 필요한 코드 변화를 넣으면 빌드 아티팩트가 그 기기와 다른 기기의 추후 빌드에 배포됩니다. 개발자들은 CI에서 이미 빌드해놓은 아티팩트를 사용해서 시간을 아낄 수 있게 되었습니다. 최근엔 Buck의 HTTP 캐싱 API를 팀에서 사용할 수 있게 오픈 소스로 배포했습니다.

Buck에는 또 다른 장점이 존재합니다. Buck을 사용해서 Xcode 프로젝트 파일을 생성하면 개발자들을 짜증 나게 하는 머지 컨플릭트를 제거할 수 있습니다. 이는 우버의 모든 iOS 어플리케이션이 공통된 프로젝트 세팅을 공유할 수 있게 해주었습니다. 숨겨진 Xcode 프로젝트 파일 대신에 읽기 쉬운 설정 파일이 생겼기 때문에 개발자들은 이 세팅을 통해 어떠한 변화에도 쉽게 코드 리뷰를 할 수 있게 되었습니다.

게다가 Buck은 빌드와 테스트에 특화된 완벽한 도구이기 때문에 개발자는 Xcode의 로딩 없이 자신이 작성한 코드가 타당한지 확인할 수 있습니다. xctool에 있는 테스트를 커맨드 하나로 실행할 수 있습니다. 더 좋은 점은 개발자 모두가 Xcode를 포기하는 순간 Atom 텍스트 에디터의 디버깅 및 자동 완성 지원 애드온인 Nuclide를 사용할 수 있다는 점입니다.

정말 큰 모바일 마이그레이션

저희는 어떻게 모노레포로 마이그레이션 했을까요? 답은 수많은 시운전(dry run)입니다. 대부분의 작업이 반복적이고 결정론적이어서 저희가 작성한 스크립트가 하드 캐리 했습니다. 저희의 CocoaPods podspec 파일을 이루는 모든 모듈을 예로 들 수 있겠습니다. 저희는 이 podspec 파일을 내부의 프라이빗 저장소로 배포해서 CocoaPods를 사용할 경우에만 접근하도록 만들었습니다. 그러한 podspec 은 Buck 파일(BUCK이라고 부릅니다)과 1대1 대응을 할 수 있어서, podspec을 대체할 수 있는 BUCK 파일을 만드는 스크립트를 작성했습니다.

또한 심볼릭 링크를 사용하여 제안된 저장소 구조를 모방한 테스트 목적의 더미 모노레포를 만들었습니다. 이는 저희가 모노레포 구조와 설정뿐만 아니라 모듈 업데이트까지도 쉽게 테스트할 수 있게 되었습니다.

하지만 모듈이 빠르게 변했기 때문에 테스트 레포지토리를 쓸모없게 만든다는 것을 깨달았습니다. 모든 모듈에 대해 BUCK 파일을 만들었음에도 불구하고, 개발자들은 여전히 podspec 파일을 사용하고 있었습니다. 그래서 Buck 파일과 podspec 파일을 동기화할 수 있는 방법을 찾아야 했습니다. 이를 위해 저희는 모든 모듈의 변화를 테스트 저장소에 적용해서 테스트 저장소의 코드가 고장 나면 개발자에게 바로 알리도록 하였습니다.

이 설정을 통해 개발자들은 새로운 ‘Buck World’에 적응하는 것에 도움을 받았으며 BUCK 파일도 항상 최신으로 유지할 수 있게 되었습니다. 마이그레이션 바로 전 주에는 어플리케이션에도 적용해서 개발자들이 Buck 우주에 적응할 수 있도록 했습니다.

실제로 모노레포를 만드는 것은 도전적인 작업이 필요합니다. 저희도 만들어야 한다는 것은 알았지만 문제는 언제였습니다. 처음엔 모든 툴링과 인프라를 새로운 설정으로 옮기는 그 주의 주말에 하려고 했습니다. 하지만 설정으로 옮기는 작업이 일주일만 더 빨랐어도 더 힘들었을 것입니다. 모든 저장소에 대해 단계별로 재현 가능했기 때문에 마이그레이션도 스크립트로 작업할 수 있었습니다. 저희가 모노레포를 만들 때 따른 단계는 다음과 같습니다.

  1. 임시 디렉토리에 머지될 저장소를 클론합니다.
  2. 그 저장소에 있는 모든 파일을 모노레포의 대응하는 위치로 옮깁니다.
  3. 변경된 사항을 지정된 이름의 리모트 브랜치에 커밋 후 푸시합니다.
  4. 모노레포에 리모트 브랜치로 머지될 저장소를 추가합니다.
  5. 리모트 브랜치를 머지합니다.
  6. 머지된 리모트 브랜치를 저장소에서 삭제합니다.
  7. 머지된 저장소의 어떤 커밋이 HEAD로 표현할지 정합니다. 숨겨진 파일로 커밋합니다. 이 파일을 모노레포를 업데이트할 때 사용합니다.
  8. 다음 저장소에 대해 1-7 스텝을 반복합니다.

모노레포를 만들었을 때 저희는 이 저장소를 계속해서 최신 상태로 유지할 방법을 찾아야 했습니다. 저희는 스텝 7에서 만들었던 숨겨진 파일을 사용했습니다. 이 파일은 모노레포가 원래 저장소에서 업데이트됐을 때 HEAD sha를 대표하고 있기 때문에 저희는 이 매시간 스크립트를 돌려 최신 업데이트 sha를 HEAD로 업데이트하는 git patch를 생성합니다. 그리고 모노레포의 올바른 주소에 patch를 적용합니다.

자기 자신을 마이그레이션하는 것은 꽤 간단하며 저희는 이 작업을 주말동안 끝냈습니다. 이 작업을 하는 동안 모든 저장소를 git 레이어에서 차단해두고 모든 CI 작업의 xcodebuild 대신에 Buck 커맨드로 변경했으며 Xcode 프로젝트 파일과 podspec을 삭제했습니다. 지난 몇 달 동안 프로젝트, CI 작업 그리고 릴리즈 파이프라인을 테스트하는 것에 쏟아부었기 때문에 2016년 5월에 모노레포를 런칭할 땐 자신이 있었습니다.

결론: 모든 iOS 코드를 한곳에 모으세요

저희는 모노레포를 통해 모든 iOS 코드를 한곳에 모았습니다. 저장소를 디렉토리 구조로 보여드리면 다음과 같습니다.

├── apps
│   ├── iphone-driver
│   ├── iphone-eats
│   ├── iphone-rider
├── libraries
│   ├── analytics
│   ├── …
│   └── utilities
└── vendor
   ├── fbsnapshottestcase
   ├── …
   └── ocmock

지금은 분석 모듈의 큰 규모의 API 변화가 모듈의 모든 고객의 업데이트를 요구합니다. Buck은 분석 모듈과 의존성을 가지는 모듈의 빌드와 테스트를 할 것입니다. 역으로 말하면 버전 컨플릭트가 전혀 없고 항상 마스터가 초록색이라는 것입니다.

하지만 그중에서도 가장 좋은 점은 Buck 캐시로 바꾼 것입니다.

xcodebuild 빌드와 buck 빌드 단계를 비교해보겠습니다. 모든 CI 작업 이전에 캐시를 클린하지 않는 것의 가장 큰 이점을 보았습니다. 믿을 수 있는 캐시로 바꾸면서 빌드된 아티팩트를 CI 호스트간 공유할 수 있게 되었습니다. 또한 Buck은 테스트 결과를 캐싱할 수 있어서 모듈이 이미 테스트 됐다는 것을 알고 코드 변화에 영향을 받지 않았다면 테스트를 넘깁니다.

종합적으로 보면 저희의 마이그레이션은 성공이었습니다. 하지만 피할 수 없는 몇몇 비용은 있는데, git 퍼포먼스가 떨어지고 master 브랜치가 바뀌는 순간 모든 코드가 영향을 받는다는 사실입니다.

예를 들어, 저희는 CI에서 완벽하게 타당하다고 통과한 커밋도 master 브랜치에 rebase 할 때는 실패한다고 생각하기로 했습니다. 처음 모노레포를 런칭했을 때 저희는 종일 실패하는 커밋에 대해 계속해서 지켜보았습니다. 심한 날에는 커밋의 10%가 revert됐는데, 이는 수많은 개발자의 시간을 낭비하게 했습니다. 저희는 이 문제를 해결하기 위해 저장소 머지와 푸시 작업 사이의 시스템인 Submit Queue를 사용합니다. (Arcanist를 사용한 이후로는 Uber에서 ‘land’라고 부릅니다) 개발자들이 그들의 커밋을 land 하려고 하면 Submit Queue에 쌓입니다. 이 시스템은 한 번에 하나의 커밋을 작업하고 유닛 테스트를 실행하고 코드를 빌드한 후 반대로 rebase합니다. 아무 일도 일어나지 않는다면 master에 머지합니다. Submit Queue가 생긴 이후에 master 성공 확률이 99%로 급상승했습니다.

결론

새로운 툴체인으로의 이사는 저희에게 새로운 아이디어를 실험할 수 있는 환경을 제공하며 빠르고 더 나은 어플리케이션을 만들 수 있게 되었습니다. 최근에는 페이스북의 Buck 팀과 함께 Buck의 Swift 완벽 지원과 macOS 그리고 iOS Dynamic Frameworks지원에 대해 작업하고 있습니다.

코드를 한 곳으로 모으고 어플리케이션을 Buck으로 옮기는 것은 저희가 개발하는 과정을 현대화하는 과정이라고 생각합니다. Uber의 안드로이드 팀 또한 Buck을 적용했으며, 그들도 빠르고 재현 가능한 빌드의 이점을 누리고 있습니다. 하지만 여기가 끝이 아닙니다. 저희 팀은 다가오는 Buck iOS의 새로운 기능에 대해 작업을 하고 있으며, 저희의 오픈 소스에 대한 노력도 모바일 커뮤니티에 공헌할 수 있기를 바랍니다.