Demystify explicitly built modules (feat. WWDC 2024)
안녕하세요. 그린입니다 🍏
이전 포스팅인 Swift 6의 WWDC 2024 영상을 보는 도중 빌드 모듈에 대해 간단히 소개하고 더 딥하게 알아보자고 했던 부분이 있습니다 🙋🏻
요기서 요런 얘기를 간단히 했었죠ㅎㅎ
간단히 그때처럼 요약하자면, 빌드 모듈을 기존 묵시적인것에서 명시적으로 선택할 수 있음을 의미합니다.
Xcode 16에서부터 이 빌드 설정에서 명시적 모듈 빌드 모드를 활성화 할 수 있죠.
그래서, 이번 포스팅에서는 이 명시적으로 구축된 모듈에 대해 어떤것이고 어떻게 되는것인지 알아보려 합니다 🙋🏻
명시적으로 빌드된 모듈로 Xcode 16에서 빌드가 어떻게 바뀌는지 알아봐야겠죠?
더 나아가, 모듈을 사용해 코드를 빌드하는 방법, 명시적으로 빌드된 모듈이 컴파일 작업에서 투명성을 개선하는 방법, 대상 간에 모듈을 공유해 빌드를 최적화하는 방법을 알아볼거에요 😃
Demystify explicitly built modules
해당 세션에서는 명시적으로 빌드된 모듈이라고 불리는 Xcode에서 Swift 및 Clang 모듈을 빌드하는 새로운 방식을 소개합니다.
Module
우선 모듈이 무엇인지 부터 알아야겠죠?
모듈은 라이브러리 혹은 프레임워크의 인터페이스를 설명하는 코드 배포 단위입니다.
Swift 타겟에선 모듈을 함께 나타내는 여러 Swift 파일이 있고 일반적으로 단일 타겟 혹은 프레임워크의 모든 Swift 소스 파일은 동일한 모듈의 일부죠.
즉, Swift 소스 파일이 모듈을 구성합니다.
import와 같이 모듈은 다른 모듈을 가져와서 전체 프로젝트에 대한 비순환 모듈 그래프를 형성할 수도 있어요.
Swift 컴파일러는 사용자가 작성한 외부 인터페이스를 가져와 인터페이스만 포함된 텍스트화 .swiftinterface 파일로 요약합니다.
Object-C의 모듈은 다르게 표현됩니다.
Swift와 달리 C 언어 계열에선 모듈의 인터페이스가 직접 작성됩니다.
헤더 개념부터 시작해 해당 헤더가 모듈을 구성하는 방식을 설명하는 모듈 맵이라는 파일을 추가하죠.
위는 UIKit 모듈의 예시입니다.
UIKit의 헤더로 시작한 다음 UIKit의 모듈 맵을 추가합니다.
이 모듈 맵이 컴파일러에게 UIKit 모듈에 대한 몇 가지 다른 정보를 제공해줍니다.
즉, 우측을 보면 이건 프레임워크 모듈이고 이름은 UIKit이죠.
소스 코드 자체가 이름을 정의하지 않기 때문에 이 이름을 통해서 @import가 작동하는 것입니다.
UIKit.h엔 해당 모듈의 일부인 모든 헤더가 포함되어 있다는걸 나타내며 마지막으로 해당 모듈이 가져오는 모든 모듈은 UIKit을 가져오는 코드에서 사용할 수 있다고 정의해둔것입니다.
위 예시 코드 파일을 보면 Swift 모듈인 SwiftUI를 가져오고 프로젝트 자체에서 Objective-C로 구현된 Clang 모듈인 ResearchKit 모듈을 가져오고 있습니다.
그 다음으로 위 코드처럼 Objective-C 파일이 존재한다고 봅시다.
여기에선 프로젝트 헤더를 import 해오고 UIKit도 임포트하고 있습니다.
또한, #include된 곳은 Clang이 모듈을 임포트해서 변환하는 SDK 헤더 일부가 포함되어 있습니다.
모듈을 사용하면 컴파일러가 서로 다른 소스 파일 간 인터페이스 구문 분석을 공유할 수 있습니다.
즉, 프로젝트 소스를 컴파일할 때 컴파일러가 읽을 수 있도록 각 모듈을 바이너리 파일로 분리해 컴파일 한 후에 참조될때 마다 해당 모듈의 공용 인터페이스를 가져오는 방식으로 수행됩니다.
Swift에선 이 컴파일된 형식이 *.swiftmodule 파일로 표시되고, Clang에선 *.pcm 혹은 미리 컴파일된 모듈 파일로 표시됩니다.
그러나 이런 재사용이 발생하려면 먼저 해당 모듈을 찾아 컴파일 해야합니다.
Using Modules
코드를 빌드하는데 모듈이 어떻게 사용되는지 알아봅니다.
컴파일러는 import를 만나면 먼저 import가 참조하는 모듈을 검색한 후 해당 모듈의 컴파일된 표현을 가져옵니다.
UIKit import를 만나면 SDK에서 먼저 UIKit의 모듈 맵을 찾습니다.
그 다음으로 UIKit용으로 컴파일된 .pcm 파일을 찾아야합니다.
만약 해당 파일이 아직 존재하지 않다면 아래와 같은 방법을 사용할 수 있습니다.
먼저, 암시적으로 빌드된 모듈입니다.
여기선 컴파일러는 Xcode가 자신의 존재를 인식하지 못한 채 모듈 빌드를 관리하기 위해 서로 조정합니다.
이것이 Swift와 Clang이 도입된 후 모듈을 구축한 방식입니다.
Swift, C, Objective-C 코드가 포함된 프로젝트가 암시적으로 빌드된 모듈을 사용할 때 빌드에서 장기 실행 작업이 많이 포함되는 경우가 많습니다.
여기서 각 행은 Xcode가 빌드 작업을 실행할 수 있는 별도의 실행 레인을 나타냅니다.
빌드 시스템은 컴파일을 하고 각 별도 컴파일러는 암시적으로 모듈을 발견해 빌드하거나 이미 존재하는 모듈을 로드하죠.
암시적으로 빌드된 모듈을 사용하면 암시적으로 모듈을 빌드하는 하나의 컴파일 작업으로 끝날 순 있지만 해당 모듈이 필요한 다른 작업은 차단되어 빌드될때까지 기다릴 수 있습니다.
당연히 이건 빌드의 여러 부분에서 발생할 수 있죠.
Xcode 16은 명시적으로 빌드된 모듈을 사용해 이를 변경합니다.
Xcode가 컴파일러와 협력해 모듈을 검색하고 빌드하죠.
명시적으로 빌드된 모듈은 모듈 빌드의 암시적 작업을 가져와 이를 명시적인 빌드 시스템 작업으로 끌어올려줍니다.
요런식으로 말이죠.
이 작업을 위해선 Xcode는 각 소스 파일의 컴파일을 스캔, 모듈 구축, 원본 코드 구축의 세 단계로 나눕니다.
Xcode는 각 소스 파일을 스캔해 전체 프로젝트에 대한 모듈 그래프를 구축하고 대상 간 모듈을 공유하는것을 시작으로 합니다.
모듈 그래프를 구성하면서 모듈 컴파일 작업 디스패치를 시작할 수도 있죠.
이는 의존하는 컴파일된 모듈과 함께 직접 제공되는 빌드 로그의 명시적 작업이 됩니다.
마지막으로 의존하는 컴파일된 모듈을 추가하기 위해 수정된 후 원본 컴파일 작업을 실행하게 됩니다.
명시적으로 빌드된 모듈의 경우엔 이제 타임라인에서 명시적 검색 작업, 모듈 컴파일 작업 및 원본 소스 파일 작업이 포함되지만 시간은 훨씬 단축되게 됩니다.
이를 통해서 빌드 시스템은 사용 가능한 실행 레인을 효율적으로 활용하게 됩니다.
명시적 모듈 빌드 방식에는 이점들이 있어요 😄
1️⃣ 빌드가 더 안정적
- 컴파일러는 빌드 그래프를 통해 매번 동일한 방식으로 실행되며, 실패한 작업을 별도로 다시 실행하기만 하면 모든 빌드 실패를 재현할 수 있습니다.
2️⃣ 효율적인 빌드
- 다른곳에서 암시적 상태가 유지되지 않기에 클린 빌드가 이제 모듈을 다시 빌드한다는것을 의미합니다.
- 빌드 시스템은 모듈 그래프를 완전히 인식하기에 모듈 빌드를 기다리는 컴파일 작업으로 인해 실행 레인이 차단되는 대신 더 많은 정보를 바탕으로 스케쥴을 선택할 수 있습니다.
3️⃣ Xcode에서 디버깅 시 Swift 모듈을 디버거에게 전달하는것
- 암시적으로 빌드된 모듈을 사용해 프로젝트를 빌드할 경우 Xcode 빌드와 디버거는 완전히 별도의 모듈 그래프를 가집니다.
- 명시적 빌드 모듈에선 같이 재사용 가능합니다.
즉, p나 po와 같은 LLDB 표현식을 활용할 때와 같이 디버거가 Swift 타입에 대해 알아야 할 때 모듈을 다시 빌드하는것을 방지해주죠.
Module build log
명시적으로 빌드된 모듈은 빌드 로그에 작업이 표시되는 방식에도 영향을 줍니다.
Xcode 16에선 명시적으로 빌드된 모듈이 모든 C 및 Objetive-C 코드에 사용되며 Swift에 대한 프리뷰로 활성화 될 수 있습니다.
프로젝트 설정에서 먼저 Explicitly Built Modules를 Yes로 활성화 해줍니다.
이제 빌드를 하고 빌드 로그를 보면 많은 스캔 작업들이 보입니다.
이는 프로젝트의 각 소스 파일에 대해 한번 실행되고 빌드 시스템에 대한 모듈 import 그래프를 생성합니다.
이는 내장된 작업이고 새 프로세스를 생성하지 않습니다.
이를 통해서 빌드 시스템은 스캔 중인 소스 파일 간 정보를 캐싱할 수 있죠.
그 다음 작업으론 컴파일 모듈 작업을 합니다.
특정 프로젝트나 타겟에 연결된 이런 작업은 찾을 수 없지만, 대신 대상 간 공유될 수 있는 최상위 작업입니다.
이를 위해 빌드 시스템은 각각에 대해 별도의 컴파일러 프로세스를 생성합니다.
지정된 모듈을 컴파일된 모듈 파일로 빌드하죠.
이는 암시적으로 빌드된 모듈에서 일반 컴파일 중 발생했지만, 명시적인 현재는 분할된 작업이 됩니다.
이제 모듈을 빌드하는 동안 발생하는 진단들은 모듈을 먼저 빌드하는데 발생한 소스 파일이 아닌 여기에 첨부됩니다.
단일 모듈이 여러번 빌드 되는것을 볼 수 있어요.
왜냐하면 서로 다른 타겟의 일부 빌드 설정엔 빌드할 모듈의 서로 다른 변형이 필요하기 때문이죠.
같은것 같지만 자세히 살펴보면 아래와 같이 Xcode에서 다른 해시를 가집니다.
해당 해시는 모듈 변형을 빌드하는데 필요한 명령줄 인수 집합을 나타내요.
이는 암시적 모듈 빌드에서 발생했긴 하지만, 빌드 시스템에 노출되지 않아서 몰랐던거죠.
컴파일러는 모듈 빌드 방법에 영향을 주지 않는 인수를 제거하기 위해 스캔중에 이 인수 목록을 최적화합니다.
Optimize your build
우선, 프로젝트에서 구축한 모듈에 대한 추가 정보를 수집하고 싶다면 이렇게 해봅니다.
내 프로젝트의 모든 모듈을 다시 빌드하기 위해 빌드 폴더를 정리합니다.
즉, 클린 빌드를 하란 소리죠.
그 다음 Build With Timing Summary로 빌드를 사용해 성능에 대한 추가 정보를 수집할 수 있습니다.
여러 모듈 변형은 호환되지 않는 빌드 설정이 있는 다양한 소스 파일로 인해 발생합니다.
위의 예시처럼 C 소스 파일과 Objecive-C 소스 파일은 일부 모듈을 매우 다르게 구문을 분석합니다.
즉, 불필요한 모듈 변형으로 인해 빌드에서 수행해야 하는 추가 작업이 생성되죠.
모듈 변형의 일반적인 소스론 추가 전처리기 매크로, 자동 참조 계산 비활성화, 언어 버전 등등이 있습니다.
앞서 빌드가 완료되었으면 이제 빌드 로그 필터에서 "modules report"를 입력해봅니다.
아래 예시처럼 Clang 모듈 보고서를 선택하면 UIKit의 변형 2개와 기타 여러 모듈이 있음을 볼 수 있죠.
만약 많은 변형이 있었던 원인 중 매크로도 있다면 이렇게 해볼 수 있어요.
프로젝트 타겟 Build Settings에서 macros를 검색합니다.
여기엔 ResearchKit에서 다른 타겟에 없는 추가 ENABLE_FEATURE 매크로가 존재하죠.
이걸 상위로 올려봅니다.
즉, 타겟에서 제거하고 프로젝트로 올리는것이죠.
다시 클린 빌드를 하고 Build With Timing Summary로 빌드를 해봅니다.
이제 이전 보다, 빌드 로그에 UIKit 변형이 줄어들어 표시된걸 볼 수 있습니다.
즉, 프로젝트 설정을 통합함으로 이런 개별 그래프를 하나로 병합할 수 있습니다.
언어 표준도 타겟 수준에서 설정하는 대신에 프로젝트 혹은 작업 공간 수준으로 이동해야 합니다.
이렇게 한다면 소스 파일 간에 모듈을 최대한 공유할 수 있어요.
이것이 바로 명시적으로 구축된 모듈입니다.
마무리
정리해보면 명시적으로 빌드된 모듈을 사용하면 빌드 시스템이 모듈 빌드를 제어할 수 있다는것이 핵심입니다.
모듈 빌드의 컴파일 시간이 컴파일 작업에 포함되지 않고 자체 작업으로 표시되기에 빌드 로그가 다르게 보이게 됩니다.
또한, 프로젝트 전체에서 설정을 균일하게 가져가 빌드되는 모듈 변형 수를 줄일 수 있어요.
특정 소스 파일에 대해 빌드된 모듈을 대상 간에 공유하는것이죠.