Tuist Duplicate Symbols Error 몰아내기

그래서 나는 뭘 할 수 있는데?

Gordon Choi
12 min readMar 17, 2024

여는 말: 아니 이게 무슨 에러야

최근 개발 동아리 DDD 10기 활동을 거쳐서 팀 프로젝트 ‘맛나’를 개발하고 있다. iOS 개발 담당은 2명. Tuist를 활용해 세팅을 했다. 협업시에 왜 모듈화가 좋은지 피부로 느낄 기회가 온 것이다! 프로젝트 및 모듈 초기 세팅을 할 때는 생각만큼 장밋빛은 아니었지만, 초기 세팅이 끝나고 나니 생각만큼 장밋빛인 프로젝트 경험이었다. PR을 주고받으면서 pbxproj 파일에서 발생하는 git conflict를 걱정할 필요도 없었고, feature 단위로 나누어 둔 일감을 완전히 독립적으로 개발할 수 있어서 아주 편했다.

그런데 웬걸, 에러는 생각보다 빨리 찾아왔다. UI 컴포넌트를 개발하던 팀메이트에게서 연락이 왔다.

프레임워크 충돌이 났네요.

UI 컴포넌트에 사용하기 위해 외부 의존성을 가져왔는데, 다른 Feature에서 불러와서 사용하려다 보니 Duplicate Symbols Error가 뜹니다.

이 때는 대략 정신이 멍해진다

이 이야기를 들었던 시간이 새벽이었던 것으로 기억한다. 처음 보는 에러를 접하고 나니 정신이 없었다. 쉬다가 급하게 컴퓨터를 켜고 데모 앱에서 해당 컴포넌트를 가져와서 써 봤다. 그런데 데모 앱에서는 잘 돌아가는 것이 아닌가? 그 때부터 이게 무슨 에러인지, 왜 일어났는지를 알기 위한 기나긴 고민의 시간이 시작되었다.

In a nutshell

일단 해결해야 한다면? 에러 해결 과정 미리보기

  • 에러의 원인은 App 타겟 설정에서 static framework에 대해 all_load 플래그를 걸어놓았기 때문이었다. 하위 모듈에서 import한 외부 의존성의 심볼을 app 타겟에도 그대로 모두 import시키면서 일어난 현상이었다.
  • 에러 해결은 Tuist 프로젝트 상에서 외부 의존성 관리 방법을 바꿈으로써 수행했다. Xcode Swift Package Manager를 활용하는 방식에서 Tuist External Dependencies를 사용하는 방식으로 변경했다.
  • 해당 방법을 사용함으로써 Duplicate Symbols Error를 해결했다. 외부 의존성을 fetch한 이후 Tuist Project로 만들어서 Tuist가 내부적으로 연결해 주기 때문에, 내부 모듈과 외부 라이브러리 관리의 일관성을 꾀할 수 있다는 장점이 있다.

Duplicate Symbols Error, 넌 누구냐?

미봉책

일단 당장 돌아가기 위해서 수행한 대책. Shared 레이어에 속한 모듈을 Feature에 Import하고, Feature 모듈을 App 모듈에 Import했을 때 일단 에러가 났다. 그래서 필자는 App 모듈에 외부 의존성을 직접 적용하면 에러가 일어나지 않지 않을까, 라는 가설을 세웠다. Duplicate Symbol이라는 에러 이름에서 유추해 본 결정이었다. 유추한 과정에 대해서는 후술한다. 일단은 먹혀들었고, 무시무시한 에러는 사그라들었다.

원인 탐구 — 유추해보기

사실 위의 해결책을 떠올린 것도 이름 때문이었다. Duplicate Symbols. Duplicate는 아무튼 복사/중복되어 있다는 뜻이고, Symbol은 함수 심볼에 대해 이야기하니까, 아마 작동하는 무언가라는 뜻일테지, 라고 생각했다. 일단 외부 의존성을 Import하는 위치가 메인 앱 딱 하나라면 이 상황은 벌어지지 않을 것이라고 생각했다. 일단은 생각한 대로 됐다. 일차적인 가설의 검증을 위해 테스트용으로 Import해 두었던 Kingfisher를 통해 동일한 상황을 만들어 봤더니, 숫자는 다르지만 똑같은 에러가 떴다. 이거구나.

이제 모든 것을 의심해 볼 차례다. static framework로 정의되어 있는 Feature 모듈은 static framework로 정의되어 있는 Shared 모듈에 의존한다. 한편 해당 Shared 모듈은 외부 의존성을 가져다 쓰고 있다. 이 과정에서 등장하는 모든 것에 대해 생각하고 유추해본다.

먼저 Tuist에서 외부 의존성을 관리하는 방법에 대해 의심해 보았다. 이 때 프로젝트에서 사용하고 있는 방법은 Xcode의 Swift Package Manager를 사용하는 방식이었다. 이외의 Tuist상에서의 의존성 관리 방법으로는 Tuist + Swift Package, Tuist + Carthage가 있다. 이 두 가지는 Tuist에서 프레임워크 패키지를 풀어헤쳐서 Tuist Framework로 만드는 과정이라, 편의상 Tuist External Dependencies라고 부르고자 한다. 실제로 Dependencies 파일을 정의한 후 같은 init에서 처리하기도 하고. 이외에 .xcframework 파일을 직접 가져와서 사용하는 방식도 있다. 이에 대해서는 닫는 말에서 살짝 언급해보도록 하겠다.

처음 필자에게 Tuist를 써볼 것을 적극 권장하신 분께서는 프로젝트에 주로 Tuist External Dependencies를 사용했다. 필자가 Xcode Swift Package Manager를 사용한 단순하지만 중요한 이유는, 퍼스트 파티를 이용하는 방법이기 때문. 가능한 범위 내에서 퍼스트 파티를 지향하는 것이 소프트웨어의 안정성을 가장 잘 보장할 수 있다고 생각했다. 하지만 이번만큼은 필자의 이런 판단이 틀린 것이었을까?

App으로 빌드되지 않는 기능 모듈을 Static Framework로 만든 것에 대해 의심해 보았다. 먼저 iOS에서 Static Framework와 Dynamic Framework의 차이를 간단히 짚어보자.

  • Static Framework: 앱 바이너리를 빌드할 때 프레임워크의 코드가 모두 복사/포함되어 빌드된다.
  • Dynamic Framework: 별도의 .dylib 파일을 통해 실행 가능한 바이너리를 만든다. 앱 바이너리에 프레임워크의 코드가 포함되지 않고 따로 만들어진다.

이렇게 간단하게 설명하고 넘어갈 소재가 아니므로, 추후 별도의 글로 함께 알아보도록 하자. 아무튼, 이렇게 설정해 둔 것이 잘못된 것이었을까? 의심했다. 이에 관한 고민 또한 후술하겠다.

문제 상황을 다시 떠올려 보니, Manna 앱 프로젝트에서 빌드했던 파트너는 오류가 났지만, 필자는 MannaDemo에서 실행했을 때 오류가 나지 않았다. 둘의 차이를 다시 살펴보니, 데모 앱의 project 파일에서 필자가 미리 설정해 둔 Tuist Setting을 쓰지 않고 기본값을 쓴 것을 발견했다! 왠지 여기에 단서가 있을 느낌이 들었다.

Tuist Settings는 .xcconfig로 대표되는 프로젝트 빌드 세팅에 대응한다. Xcode상에서 보통 GUI 기반으로 설정하고, xml 파일을 통해 설정할 수도 있다. Tuist는 Settings 객체를 통해 이를 미리 정해 두고 각 모듈에 적용할 수 있다. 그렇다면 여기서 문제 될 법 한 표현을 찾아 보면 되지 않을까 했다. 특별히 수상한 건 없어 보이는데..

어 잠깐, 너 뭐냐?

사건의 냄새가 난다

원인 탐구 — 사실은 이렇습니다

상술한 all-load 옵션은 Xcode 프로젝트의 링커 옵션 중 하나이다. Static Framework를 링크할 때 사용되는 옵션이며, Static Framework에 포함되는 모든 심볼을 앱의 코드에 더한다는 뜻이다. 그렇다면 문제가 왜 발생했는지 설명이 된다. Feature는 Shared를 의존하면서 Shared가 의존하고 있는 외부 의존성의 모든 심볼을 모듈에 로드했고, 이 모듈을 의존하는 앱 모듈은 다시 외부 의존성을 다 가져오고, … 이러면 겹치는 심볼이 생기는 이유가 납득이 된다.

그러면 all-load를 하지 않으면 되지 않는가! 이것도 맞다. 그러나 all-load함으로써 혹시 발생할 수 있는 문제, 즉 의존하는 모듈 간의 심볼 누락 문제를 미연에 방지하고 싶었다. 특히 이후에 Firebase Crachlytics 등 Obj-C와 연관된 라이브러리를 사용하고 싶었기 때문에 더 그랬다. 모듈 구조를 가지고 작업을 할 때는 의존하는 모듈이 작업 과정에 따라 바뀔 수도 있기 때문에, 그런 면에서 불안함을 느꼈던 것 같다.

그러면 Static Framework를 사용하지 않으면 되지 않는가! 이 부분에 대해서는 생각이 있었다. 모듈화해서 작업하고 있지만 본디 하나의 앱이기 때문에, 바이너리로 말아 줄 때는 모든 코드가 포함되어야 한다는 생각이었다. 앱 바이너리의 크기는 좀 커지겠지만 런타임에서 성능상 이점을 가져갈 수 있다는 생각도 있었다. 다만 지금 생각하면 얼마나 성능상의 이점을 볼 수 있을까 싶기도 하고. 그래서 향후 Dynamic Framework로 바꾸고 한번 테스트해 볼 생각이 들었다. 물론 일단 지금은 이 심볼 문제를 해결해야지!

해결

우리가 내린 솔루션은 외부 의존성 관리의 방법을 변경하는 것이었다. Xcode의 Swift Package Manager를 이용하는 방법에서 Tuist External Dependencies를 이용하는 방법으로 바꾸었다. 지금껏 작업하면서 Tuist 기능 모듈간에는 상술한 Duplicate Symbols Error가 발생하지 않았기 때문에, Tuist가 알아서 외부 의존성을 Tuist Project로 각각 Resolve해 줄 거라고 생각했다. 결과는? 일단 예상대로 잘 작동했다. 모듈을 만들 때 remote와 dependencies 두 파라미터가 필요했던 코드에서 dependencies 파라미터 하나만 필요한 코드로 바뀐 건 좋지만, tuist generate 하기 전에 tuist fetch를 반드시 해야 되게 된 것은 조금 귀찮아진 부분.이래서 makefile 콘솔 프리셋을 많이들 만드는 건가 싶기도 했다.

더 깊게 파서 알아보고 싶은 것이 많았지만, 일단은 프로젝트의 진행을 위해 “야매”가 아닌 방법 중 가장 빠른 방법으로 해결하고 작업을 시작해야만 했다. 그래서 정리해 봤다. 앞으로 뭘 더 알아봐야 할 지.

  • Static Framework와 Dynamic Framework 선택의 명확한 기준
  • Mach-O
  • .xcconfig, all-load, Swift 앱 바이너리 빌드의 흐름

어째 문제를 해결하려고 알아보다가, 알아볼 것만 더 늘어난 느낌이 든다. 삽질을 막 하고 보니 뒤에 흙더미가 쌓인 걸 보는 기분이랄까.

닫는 말: 삽질은 깊이 파 보기 때문에 삽질이다

상술한 여러 지점들을 보면, 앱이 빌드되어 배포되는 과정에서 각각의 지점에서 발생하는 차이 중 하나가 결국 오류를 만들어냈다고 생각한다. 덕분.. 이라고 하기는 조금 뭣하지만, Tuist나 Xcode의 설정에 따라 앱이 어떻게 하나의 실행 가능한 파일로 만들어지는지에 대한 이해가 조금은 증진된 느낌이 들었다. 삽질을 하면서, (해당 시점에서는) 의도치 않게 깊은 곳까지 들어갔다 나와 본 것 같은 기분이었다..

경험이 재산이라는 말은 에러 케이스를 겪으며 특히 더 와닿는 것 같다. 조금 여담이긴 하지만, 이 문제 이전에 CocoaPods만을 지원하는 프레임워크를 Tuist를 사용한 프로젝트에 어떻게 연동시킬 것인가? 에 대해 골몰하기도 했다. 이는 또한 위에서 소개한 Tuist의 외부 의존성 관리 방법 중, xcframework 파일을 직접 사용하는 것으로 해결했다. 로컬에 받은 xcframework 파일을 원격 저장소에 푸시했다가 저장소에 Obj-C 코드가 엄청나게 늘어나는 헛웃음나는 해프닝도 있었다. 삽질은 깊이 파 보기 때문에 삽질이라는 말과 결부시키면, 결국 삽으로 파낸 흙의 양이 곧 재산으로써 남는 경험의 양이 아닐까 생각한다. 이렇게 생각하면 마냥 마음이 무겁지만은 않을지도.

각각의 케이스에서 발생하는 에러와 그에 대한 해결법은 언뜻 보기에 매우 지엽적인 것처럼 느껴질 수 있다. 하지만 적어도 자신이 개발할 때 유사한 사례를 또 마주한다면, 그에 대해서는 더 빠르게 대응할 수 있다는 뜻이기도 하다. 경험을 통해 개발 효율성을 끌어올린다고도 할 수 있는 부분. 그리고 또 희망적인 부분은, 지엽적인 것처럼 보이는 여러 개개인의 에러 케이스가 생각보다 공통적인 맥락을 가지고 있다는 것. 바꾸어 말하면, 이 에러로 고통받는 사람은 생각보다 많다는 의미이다. 필자가 오늘의 글을 작성한 이유 또한 바로 그러한 분들을 위해서다. 물론 미래의 필자가 이런 일이 있었던 것을 잊는다면, 미래의 필자에게도 또한 도움이 될 것이기도 하다.

오늘도 한다 삽질을. Photo by Andres Siimon from Unsplash

--

--