1. 의존성 관리에 관심을 가지게 된 계기
우리가 만든 UX/UI로 앱을 만들고 싶다는 상상 하나로 개발을 시작한 디자인 전공생이 벌써 3년차 iOS 개발자가 되었습니다. 그간 많은 것들을 보고 배웠지만 특히 이해가 잘 가지 않는 분야가 있었는데요. 바로 의존성 관리(의존성 주입, 역전)였습니다. 의존성 관리에 대해 어떤 계기로 관심을 가지게 되었는지 적어보고자 합니다.
작년까지만 해도 의존성 관리에 대해서는 굉장히 소홀했던 것 같습니다. 사실 의존성 관리에 대한 고민을 깊게 하지 않았고, 어떻게 풀어내야 할 지도 감이 오지 않았습니다.
그랬던 제가 의존성 관리에 관심을 가지게 된 계기는 모듈화와 TDD였습니다. 회사에서 테스트 코드를 작성하지 않고 일정에 치여 개발을 하다보니 사이드 이펙트와 이슈에서 자유로울 수 없었습니다. 최대한 사이드 이펙트를 줄이고, 레거시에 신규 요구사항을 반영할 때 정상 동작을 하는 지 검증하기 위해서 테스트 코드를 작성하기 시작했고, 그러면서 자연스레 Testable한 코드와 의존성 관리에 대해 관심이 생겼는데요. 이번 포스트에서는 TDD보다는 모듈화를 통해 의존성 관리에 필요성을 느낀 경험을 중심으로 서술하고자 합니다.
2. 서비스 모듈화 고민
그동안 모든 신규 기능 개발을 App Target에서 하면서 점점 떨어지는 개발 생산성에 대해 고민하게 되었습니다. 바로 아주 느려(터)진 빌드속도입니다. 현재 회사 프로젝트는 대부분의 서비스가 App Target에 묶여있습니다. 그러다 보니, 신규 기능뿐만 아니라 UI 코드 한 줄만 바꿔도 전체 App Target을 빌드하면서 소비하는 시간이 너무나 많았던 거죠. 그러다보니 빠르게 확인이 필요한 UI같은 경우에는 '한 줄 바꾸고 5분 기다리고..'를 반복하게 되면서 큰 답답함을 느꼈습니다. App Target이 아니라 별도의 Target에서 개발한다면 이런 불편함에서 자유로울 수 있지 않을까 싶었어요.
회사 프로젝트를 제 공부를 위한 시험대(?)로 사용할 수 없기때문에 개발 중인 사이드 프로젝트에 모듈화를 선행했습니다. 먼저 App Target에 묶여있던 코드를 하나씩 분리했습니다. 가장 쉬운 3rd party Library 부터 시작했는데요. 기존에는 의존성 툴로 Pod을 사용하고 있었는데, iOS 프로젝트인 만큼 애플이 제공하는 의존성 관리 툴을 경험해보고 싶어 SPM으로 변경했습니다. SPM을 지원하지 않는 3rd party Library는 직접 깃헙에서 포킹해서 SPM용으로 변경해서 포함하기도 했습니다.
의존성을 분리하고 각 모듈로 만드는 데 약 5일 정도의 시간이 걸렸습니다. 많은 양의 코드는 아니었기 때문에 매우 빠르게 완성이 되었죠. 12월 23일에 사이드 프로젝트가 앱스토어에 출시된 상황이었기 때문에, 나름(?) 라이브 서비스였는데요. 특히 의존성을 분리하면서 기존 Pod에서 사용하던 버전과 약간 변경사항이 있어서 되던 빌드가 안되기도 했고, 한 번에 모든 Pod 라이브러리를 SPM으로 바꿨다가 오류 폭탄으로 다시 Revert 하기도 했습니다. 어느 부분에서 오류가 나는지 트래킹하면서 모듈화 하기 위해 차근차근 하나의 라이브러리를 떼어내고 붙이고를 반복하기로 마음 먹었습니다.
2-1. 모듈화 과정
- Pod 라이브러리 삭제
- 삭제한 라이브러리를 SPM으로 설치
- 클린빌드/ Derived Data 삭제
- XCode 재시작
- Build 후 에러 수정
위 작업을 정말 무한 반복했습니다. SPM의 의존성 그래프를 XCode가 잘 생성하지 못하는지 인식하지 못하는 경우가 종종(?)이 아니라 매우 많이 있었습니다. 그래서 클린 빌드도 모자라 Derived Data도 수동으로 삭제했고 그래도 간혹 XCode가 인식하지 못해서 재시작까지 했습니다.😭 위와 같은 과정을 겪고 아래처럼 Diary, Platform, Profile로 총 3가지 모듈로 분리에 성공(?)했습니다. 특정 어느 순간 부터는 빌드가 되지 않을 정도로 수 십개의 의존성이 거미줄처럼 엮여 있었는데요. 사실 마지막 3일정도는 아예 빌드를 성공한 적도 없었습니다. '빌드 성공' 팝업이 뜨는 순간 소리 질렀던 기억이 납니다. 유레카악!!
3. 모듈화 과정에서 보이는 의존성 관리
위에 서술한 것처럼 의존성에 대한 고민을 하지 않았던 지난 시간이 무색하게, 모듈화를 진행하면서 자연스레 의존성을 정리하는 제 자신을 보았는데요. 항상 '의존성 역전/ 의존성 주입'에 대한 다양한 아티클은 접했지만 봐도 잘 이해가 되지 않았거든요. 여러 아티클을 노션에 스크랩 해놓고 몇 번이고 다시 읽고, 심지어 깜지 쓰듯 노션에 직접 써내려가며 공부했었습니다. 그래도 직접 경험하지 않으니 피부에 와닿지 않았던 것 같습니다. 그게 벌써 1년차였으니 2년동안 의존성에 대한 추가적인 고민을 하지 못한 제 자신도 반성하는 시간이기도 했구요.
모듈화를 직접 진행 하다보니, 필연적으로 의존성에 대해 고민이 필요했습니다. Interactor(ViewModel)뿐만 아니라 단순히 ViewController 간 Navigating을 위한 Router/ViewController 의존성부터 고민의 시작이었습니다. A 스크린에서 B 스크린으로 이동하고 싶은데, 모듈화를 하다 보니 A와 B는 서로 모르는 존재가 되어버린 것이죠. 이전에는 하나의 App Target에 묶여있었으니 그냥 B 스크린의 이름을 불러서 소환하면 되었는데 말이예요. 아예 서로의 이름 자체를 모르는 상황이 연출되다 보니, 이전처럼 고민 없이 의존성을 마구 엮는 행위를 할 수도 없었습니다. 의존성 관리에 대해 필연적으로 고민해야 하는 이런 구조가 협업할 때 일관적인 구조를 만들때 참 매력적이라는 생각이 들었습니다.
3-1. A가 B를 알게하는 과정
모듈화가 처음인 저에게 각각 다른 모듈에 있는 A와 B를 연결해주는 작업이 참 낯설었습니다. 하지만, SPM의 Package.swift에서 각각의 모듈의 의존성을 편리하게 관리할 수 있게 도와주고 있기 때문에 A가 B를 알게 하는 것은 생각보다 빠르게 해결될 것 같았습니다. 아래처럼 메뉴얼의 홈 화면을 맡고 있는 DiaryHome의 의존성을 설정해주었습니다.
.target(
name: "DiaryHome",
dependencies: [
"DiarySearch",
"DiaryWriting",
"DiaryDetail",
"DiaryBottomSheet",
.product(name: "RIBs", package: "RIBs"),
.product(name: "MenualEntity", package: "Platform"),
.product(name: "MenualRepository", package: "Platform"),
.product(name: "SnapKit", package: "SnapKit"),
.product(name: "Then", package: "Then"),
.product(name: "RxSwift", package: "RxSwift"),
.product(name: "RxRelay", package: "RxSwift"),
.product(name: "RxCocoa", package: "RxSwift"),
.product(name: "RealmSwift", package: "realm-swift"),
]
)
하지만, 분명 의존성을 연결 해주었는데 A는 B의 이름을 부르지 못했습니다. 😭 바로 접근 지정자때문이었습니다. 이전까지는 항상 접근지정자를 생략하고, 꼭 숨겨야 하는 함수만 private으로 설정하곤 했는데요. 모듈이 달라지다 보니 public 접근지정자를 명시하지 않으면, 다른 모듈에서는 코드 접근이 불가능했던 것이었습니다. Class와 생성자와 overriding 함수는 public으로, 로직과 외부로 노출이 필요하지 않은 UI, 지역변수는 모두 private으로 변환하는 작업을 진행했습니다.
// 다른 모듈에서도 접근가능하도록 public 지정
public class Moments: UIView {
// 접근이 가능한 변수만 public으로 오픈
public var tagTitle: String = "" {
didSet { setNeedsLayout() }
}
public var momentsTitle: String = "" {
didSet { setNeedsLayout() }
}
public var icon: String = "" {
didSet { setNeedsLayout() }
}
public init() { ... }
public override func layoutSubviews() { ... }
// 직접 접근이 불필요한 변수 및 함수는 private으로 숨기기
private let momentsText = MomentsText().then { }
private let momentsImageView = UIImageView().then { ... }
private func setViews() { ... }
}
3-2. 그리고 배운 것
A가 B의 이름을 부를 수 있기까지 많은 고민이 필요했는데, 그 과정 속에서 아래와 같은 고민을 할 수 있었습니다.
- 항상 비어있던 접근지정자를 Private, Internal, Public 중 무엇으로 지정해야 하는 지에 대한 고민
- 순환 의존성을 가지지 않도록 의존성 그래프를 상상하는 과정
- 각 모듈이 꼭 필요한 의존성만 가질 수 있도록 의존성을 최소화 하는 과정
- 의존성을 갖더라도 최대한 추상화를 할 수 있도록 설계하는 과정
모듈화를 진행하다보니 피부로 와닿지 않았던 의존성 관리에 대해 깊게 생각하는 계기가 되었습니다. 모듈화를 마친 이후부터는 회사 프로젝트와 개인 프로젝트에서 모두 의존성을 최대한 끊어내고, Testable한 코드를 작성할 수 있도록 매번 고민하는 습관이 생겼습니다. 독립적인 모듈로 동작할 수 있는 서비스의 경우에는 모듈로 꺼내어 테스트 코드를 작성하기도 했는데요. 해당 모듈에 수많은 요구사항이 반영되었지만 정상 동작이라는 것을 유닛 테스트로 검증이 가능했고, 그만큼 테스트 커버리지가 높은 로직일 수록 사이드 이펙트의 빈도가 점차 줄어 가는 것이 체감되었습니다.
4. 성장 🌲
의존성을 최소화하고 Testable한 코드를 만드는 것. 아직 사이드 프로젝트인 메뉴얼에서도 의존성을 내부에서 직접 생성하는 경우, 의존성 관리에 대한 고민이 되지 않아 추상화가 되지 않은 채 직접 Class를 참조해 의존성이 생긴 코드가 여전히 많습니다. 그리고 최종적인 목적이 Testable한 코드가 아닌, 직접 테스트 코드를 작성하는 것까지 한 묶음이라고 생각하는데요. 아직 테스트 코드로 검증 가능한 부분 역시 부족한 상황입니다. 개인 프로젝트도 이런 상황인데, 수 십명의 개발자가 하나의 프로젝트에서 개발하고 있는 회사에서는 아주 작은 단위부터 차근차근 개선해야겠다는 생각을 하게 되었습니다.
이러한 고민조차 하지 않았던 저는 지난 날에 비해 성장한 것을 느꼈습니다. 사이드 프로젝트에서 그리고 현업에서 어떤 부분에서 성장한 것이 체감 되는지 적어보았습니다.
4-1. 사이드 프로젝트
최근 백업 기능과 복원 기능을 메뉴얼에서 추가했는데요. 유저가 작성한 소중한 일기가 제 불찰로 사라질 수 있는 상황이기 때문에, 여러 엣지 케이스를 포함해 유닛 테스트만 2주일 넘게 작성했었습니다. 당시에는 기능 개발보다 테스트와 검증에 더욱 많은 시간을 쏟는 것이 약간 지루하다고 생각했는데요. 당장 신기능 개발은 완료 했는데, 출시를 하지 못하는 상황이니까요. 하지만, 신규 기능이 출시 될 때마다, Realm Class가 추가되어 백업 가능한 범위가 추가될 때마다 이 유닛테스트는 제게 큰 힘이 되어주고 있습니다.
Backup 기능은 추후 확장성을 대비해, Realm을 json으로 변경하여 백업하고 있는데요. Mock 데이터를 생성하고 실제로 잘 json이 저장되어 있는 지, 원하는 대로 json이 잘 저장이 되었는 지부터 실제 복원하면 원하는 대로 복원이 완료되었는지 End-To-End로 모두 테스트 하고 있습니다. 아래 코드는 백업 로직을 진행했을때 파일매니저로 유저의 로컬 환경에 json이 잘 저장 되어 있는 지를 체크하는 코드입니다. (나머지 코드는 너무 길어서, https://github.com/JJIKKYU/Menual에 오픈소스로 공개되어 있습니다)
/// 백업 파일이 정상적으로 저장되는지 확인
/// - 파일이 있거나 없거나, 기본적으로 모든 백업파일은 1:1로 생성되므로, 생성이 되지 않으면 fail
func testAlreadyBackupFileExist() {
// given
let fileManager = FileManager.default
let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0]
let checkFileNameArr: [String] = [
"diary",
"moments",
"tempSave",
"diarySearch",
"password"
]
// when
_ = sut.backUp()
// then
for name in checkFileNameArr {
let fileURL = documentsURL.appendingPathComponent("\(name).json")
var exist: Bool = false
if fileManager.fileExists(atPath: fileURL.path) {
exist = true
}
XCTAssertEqual(exist, true)
}
}
RIBs로 개발된 메뉴얼이지만, RIBs에 대해 온전히 이해하지는 못했습니다. Presentable, Interactable, dependency.. 하지만, 모듈화를 진행하면서 의존성을 추상화하고 역전시키는 경험을 하게 되면서, RIBs에 존재하던 'able' 붙은 Presentable, Interactable 등..에 대해 더욱 깊게 알게 되는 계기가 되었습니다. 사실 이미 RIBs는 의존성을 관리할 수 있게 강제하고 있었던 것이었죠. 한자 공부할 때, 따라 쓸 쑤 있도록 되어있는 한자 공책처럼 RIBs는 의존성 관리를 위한 청사진을 이미 그려주고 있었습니다.
4-2. 현업
회사 프로젝트에서 레거시를 개발할 때, 신규 기능을 개발할 때 생각하는 시간이 더 많아졌습니다. 코딩을 처음 배웠을 때, 변수명을 무엇으로 해야할 지 한참을 고민했던 것처럼 말이죠. 항상 비어있던 접근 지정자를 고민하는 시간, Protocol로 Class를 추상화 하는 시간, 의존성 관리를 위한 고민의 시간 등이 대표적입니다.
최근에는 동료들에게도 의존성 관리를 위한 제 선한 영향력(?)을 행사하고 있습니다. 굳이 동료를 붙잡고 "이것봐봐요!" 하지 않아도, 자연스레 제 코드를 동료가 리뷰할 때 "이 코드는 왜 들어간거야?" 라는 질문, 그리고 제 답변으로도 충분히 동료들에게도 큰 인사이트가 되고 있다고 믿습니다. 회사 프로젝트에는 Object-C로 된 코드도 많고, 레거시도 여전히 많아 의존성 관리에 취약할 수밖에 없거든요. 더불어 몰아치는 일정에 레거시를 리팩토링할 수 있는 시간도 여의치 않았구요.
그럼에도 작은 부분부터 의존성을 끊어내고, 추상화를 진행했어요. 그리고 모듈화가 가능한 독립적인 기능을 한다면 App Target에서 끊어내 코드 1줄을 위한 수백만 줄의 코드가 빌드되는 현상을 막아 개발 생산성을 높였습니다. 그렇게 하나의 서비스를 모듈화를 진행하고 4월 양산 버전에 포함이 되었는데요. 이전까지 진행했던 리팩토링과 다르게 이슈를 최소화하고, 빠른 빌드 속도로 개발 생산성까지 챙길 수 있었습니다.
4-3. 마무리
의존성 관리에 대한 많은 솔루션이 있습니다. 특히 iOS에서는 어떻게 더욱 효율적으로 의존성을 관리할 수 있고, 모듈화를 통해 얻을 수 있는 이점이 무엇인지 공부하고자 합니다. 사이드 프로젝트에서 App Target에 있던 모듈을 각각의 모듈로 분리했을 때 가장 큰 이점은 역시 빌드 속도와 생산성이었습니다. 이를 현업에서는 어떻게 타협하며 적용할 수 있을지, Testable한 코드를 위한 습관은 무엇인지 되돌아 보는 시간이 필요할 것 같습니다.