[WWDC17] Behind the Scenes of the Xcode Build Process

Ryan (Geonhee) Son·2021년 10월 10일
2

Study Stack

목록 보기
34/34

Xcode의 빌드 과정을 알아보겠습니다.

다룰 내용

  1. 빌드 과정의 구조
  • Xcode가 빌드 과정을 모델링하고 조직하기 위해 프로젝트 파일 정보를 사용하는 방법
  1. 컴파일러 영역
  • Clang과 Swift가 소스 코드를 오브젝트 파일로 빌드하는 방법
  • 헤더와 모듈의 작동 방법
  • Swift 컴파일 모델이 기초적으로 C, C++, Objective-C와 다른 점
  1. 링커
  • 심볼들이 작성된 소스코드와 어떻게 연관되어 있는지
  • 링커가 컴파일러가 생성한 오브젝트 파일을 받아 애플리케이션 또는 프레임워크와 같이 최종적으로 실행가능하게 이어 붙이는 방법

PetWell이라는 샘플 앱을 사용하여 이 과정을 알아봅니다. 이 앱은 애완동물 사진을 보여주는 앱입니다.

Build process

아래 PetWall 프로젝트에는 앱 타겟, 프레임워크와 Swift 및 Objective-C로 작성된 소스코드 파일이 있습니다.

앱을 빌드할 때 소스 크드와 리소스에서 사용자에게 배포하기 위해 앱 스토어에 업로드하는 패키지로 변환하기까지 여러 단계를 거쳐야합니다.
1. 소스코드를 컴파일하고 링크
2. Headers, asset catalogues, storyboards와 같은 리소스들을 복사하고 처리
3. 코드사인 또는 API 문서화, 코드 린팅, 검증 도구 사용을 위해 shell script 작업

대부분의 이러한 작업들 Clang, LD, AC tool, IB tool, Code sign 등과 같은 커맨드 라인 도구들이 수행합니다.

빌드 과정

빌드 작업들은 종속성 순 (dependency order)으로 실행된다.

이러한 작업들은 Xcode 프로젝트에 따라 특정한 순서로 처리되어야 하지만, 빌드 시스템이 빌드를 수행할 때마다 조직화 작업을 자동으로 수행해줍니다.

빌드 작업이 실행되는 순서는 작업이 소비하는 인풋들과 작업이 생산하는 아웃풋들과 같이 작업의 종속정 정보들로 결정됩니다.

예를 들어, 컴파일 작업은 PetController.m과 같은 소스코드 파일을 소비하여 PetController.o와 같은 오브젝트 파일을 아웃풋으로 만듭니다.

유사하게 링커 작업은 이전 작업으로 컴파일러가 만든 다수의 오브젝트 파일을 소비하여 .app 번들에 들어갈 PetWall 과 같은 실행 가능한 파일 (executable)이나 라이브러리를 아웃풋으로 만듭니다.

여기에서 패턴이 등장하는 것을 알 수 있습니다. 궁극적인 실행 순서를 나타내는 아래의 그래프 구조를 통해 종속성 정보가 흐르는 방법을 확인하실 수 있습니다.

이렇게 그래프에 나타낸 컴파일 작업은 차선과 유사합니다. 컴파일 작업들이 그들의 차선 안에서 완전히 독립적이기에 병렬적으로 실행될 수 있음을 알 수 있습니다.

그리고 링커 작업이 다른 모든 것들을 인풋으로써 받아들이기에 마지막에 와야하는 것도 알 수 있습니다. 이렇게 빌드 시스템이 종속성 정보를 통해 작업이 실행되어야 하는 순서와 어떤 작업들이 병렬적으로 실행되어야 하는지 결정하는 것을 종속성 순서 (dependency order)라 합니다.

빌드 과정의 작동 방법

빌드를 누르면 Xcode 프로젝트 파일로부터 빌드 상세 (build description)을 받아 구문 해석 (parse)하고 프로젝트의 모든 파일, 타겟들, 종속성 관계, 빌드 설정들을 고려합니다. 그리고는 이를 directed graph라 부르는 트리와 유사한 구조로 변환합니다. Directed graph는 프로젝트에 있는 인풋과 아웃풋 파일들의 종속성들과 이들을 처리하기 위해 실행될 작업들을 나타냅니다.

다음으로 Low-level execution engine이 이 그래프를 처리하여 종속성 상세사항 (dependency specifications)을 살펴보고 실행할 작업을 알아냅니다. 실행될 순서나 병렬적으로 실행될 작업들을 파악하여 이들을 실행합니다.

빌드 시스템의 Low-level build execution engine은 llbuild라 하며, 오픈 소스 프로젝트이기에 GitHub에서 소스코드를 확인하실 수 있습니다.

Discovered dependencies

현재로써는 종속성 정보가 많지는 않기에 빌드 시스템이 작업 실행 과정 동안 더 많은 정보들을 찾아야 합니다.

예를 들어, Clang이 Objective-C 파일을 컴파일하면 예상하는대로 오브젝트 파일을 만듭니다. 하지만 해당 소스 파일에 포함된 헤더 파일 목록이 포함된 다른 파일을 생성할 수도 있습니다.

그러면 다음에 빌드할 때 빌드 시스템은 이 파일의 정보를 사용하여 포함된 헤더 파일이 변경되었는지 확인하여 필요하면 소스 파일을 다시 컴파일합니다.

그리고 PetController.h, PetController.d, .n을 통해 .o 파일 까지의 종속성 경로를 볼 수 있습니다.

증분 빌드 (Incremental Builds)

지금까지 빌드 시스템의 주요 업무인 작업을 실행하는 방법에 대해 다루었습니다. 당연히 프로젝트가 커질수록 빌드 과정이 더 길어집니다. 하지만 빌드할 때마다 이런 작업들을 수행하지는 않습니다.

대신에 빌드 시스템은 그래프 상의 작업들의 일부만을 실행합니다. 이전 빌드로부터 변경한 사항에 따라 빌드가 실행되는 것이죠. 이를 증분 빌드 (Incremental build)라 하는데, 증분 빌드가 올바르고 효율적으로 작동하기 위해서는 정확한 종속성 정보를 가지고 있는 것이 매우 중요합니다.

Change Detections and Task Signatures

  • 빌드 그래프의 각 작업은 signature를 가지고 있음 (해시값)
  • signature은 인풋들의 상태 정보 (파일 경로, 수정 시간)와 기타 작업 메타데이터 (사용된 컴파일러의 버전) 로부터 계산됨
  • 빌드 시스템은 작업의 현재와 이전 빌드의 시그니처를 추적
  • 빌드가 수행될 때 재수행 여부를 결정 (signature가 다르면 재수행, 같으면 건너뜀)

빌드 시스템을 돕기 위한 방법

빌드 프로세스는 특정한 순서로 이루어진다고 했지만 이 순서를 조직하는 일은 빌드 시스템이 할 일이므로 관심 대상에서 제외하겠습니다.

대신에 개발자로서 작업들 간의 종속성을 고려하여 빌드 시스템이 그래프의 구조에 따라 최선의 방법으로 실행하는 방법을 알려주는데 집중해봅시다. 이를 통해 빌드 시스템이 작업의 순서를 올바르게 결정하고 멀티코어 하드웨어를 최대한 활용하기 위해 작업을 병렬화할 수 있을 것입니다.

종속성

  1. 빌드 시스템 내에서의 빌드 방식 (Built in)
    빌드 시스템은 컴파일러, 링커, 애셋 카탈로그 및 스토리보드 프로서스 등에 대한 규칙을 가지고 있습니다. 그리고 이러한 규칙은 어떤 종류의 파일이 인풋으로 허용되고 어떤 출력이 생성되는지 정의합니다.

  2. 타겟 종속성 (Target dependencies)
    타겟 종속성은 타겟들이 빌드될 대략적인 순서를 결정합니다. 어떤 경우에 빌드 시스템은 다른 타겟들과 병렬 소스들을 컴파일할 수 있습니다. 이전에 Xcode에서 타겟이 빌드될 때 전체 종속 대상의 컴파일을 완료해야 시작될 수 있었던 점과 대조됩니다.

현재의 Xcode 빌드 시스템에서 타겟은 더 빨리 빌드를 시작할 수 있습니다. 이는 소스 컴파일 단계가 비용 없이 일부 병렬화를 제공하기 위해 더 일찍 시작할 수 있음을 의미합니다. 하지만 실행 스크립트 단계 (run script phases)를 사용하는 경우 이 병렬화가 적용되기 전에 해당 스크립트 단계를 완료합니다.

  1. 암시적 종속성 (Implicit dependencies)
    암시적 종속성은 타겟 종속성과 다소 관련이 있습니다. 예를 들어, 바이너리 빌드 단계가 있는 링크 라이브러리의 타겟을 나열하고 스키마 편집기에서 암시적 종속성이 활성화된 경우 (기본값이 활성화 상태), 빌드 시스템은 타겟 종속성에 나열되지 않은 경우에도 해당 타겟에 대한 암시적 종속성을 설정합니다.

  2. 빌드 단계 종속성 (Build phase dependencies)
    타겟 편집기에는 헤더 복사, 소스 컴파일, 번들 리소스 카피 등과 같이 수많은 빌드 단계가 있습니다.

이러한 단계와 관련된 작업은 일반적으로 단계가 나열된 순서에 따라 실행되는 그룹입니다. 그러나 빌드 시스템이 더 잘 알고 있는 경우 해당 순서를 무시할 수 있습니다. Link Binary With LibrariesCompile Sources 전에 위치시킨다든지 하는 경우에요. 빌드 단계 순서가 잘못되면 빌드 문제나 실패가 발생할 수 있으므로 종속성을 이해하고 빌드 단계가 올바른 순서가 되어 있는지 확인해야 합니다.

  1. 스킴 순서 종속성 (Scheme order dependencies)
    스킴 설정에서 병렬 빌드 체크 박스를 활성화하면 더 나은 빌드 성능을 얻을 수 있으며 스킴에 있는 타겟 순서는 중요하지 않게 됩니다. 그러나 병렬 빌드를 비활성화하면 Xcode는 스킴의 빌드 작업에 나열된 순서대로 대상을 하나씩 빌드합니다. 타겟 종속성은 먼저 빌드할 대상을 결정할 때 여전히 더 높은 우선 순위를 갖고 있습니다. 그러나 그렇지 않으면 Xcode는 그 순서를 존중합니다. 이제 종속성을 올바르게 설정하지 않은 경우에도 예측 가능한 빌드 순서를 제공하므로 이를 사용하기를 희망할 수 있습니다. 그러나 이렇게하면 병렬화가 되지 않기에 빌드 속도가 느려집니다. 따라서 빌드 병렬화 체크 발스를 활성화된 상태로 두고 타겟 종속성을 올바르게 설정하여 순서에 의존하지 않게 만드는 것이 좋습니다.

  1. 커스텀 빌드 스크립트 단계
    마지막 종속성 정보는 개발자가 설정한 내용이 있을 수 있습니다. 커스텀 셸 스크립트 빌드 단계나 빌드 규칙을 만든다면 빌드 시스템에게 인풋과 아웃풋이 무엇인지 알려주어야 합니다. 이는 빌드 시스템이 불필요한 작업 스크립트를 재실행하는 것을 피할 수 있도록 해주고 올바른 순서로 실행했는지를 판단하게 해줄 것입니다. 이러한 파일들의 경로는 스크립트에서 환경 변수로 사용할 수 있습니다.


프로젝트의 타겟 종속성 auto-link에 의존하지 마세요. 이 설정을 사용하면 링크 라이브러리의 빌드 단계에서 명시적으로 링크하지 않고도 가져온 모듈에 해당하는 프레임워크에 컴파일러가 자동으로 링크할 수 있습니다. 그러나 자동 링크는 빌드 시스템 수준에서 해당 프레임워크에 대한 종속성을 설정하지 않는다는 점에 유의하여야 합니다. 따라서 연결을 시도하기 전에 의존하는 대상이 실제로 구축된다는 보장은 없습니다.

Add Explicit Dependencies

따라서 플랫폼 SDK의 프레임워크에 대해서만 이 기능에 의존해야 합니다. Foundation 및 UIKit과 마찬가지로 빌드가 시작되기 전에 이미 존재한다는 것을 알고 있기 때문입니다. 자체 프로젝트의 타겟에 대해 명시적으로 라이브러리의 종속성을 추가해야 합니다.

Create Workspace and Project References

의존하는 다른 프로젝트의 타겟을 나타내기 위해 다른 Xcode 프로젝트를 프로젝트의 파일 탐색기로 끌어다 놓아 프로젝트 참조를 생성할 수도 있습니다.

결론적으로, 정확한 종속성 정보를 사용하면 빌드 시스템이 빌드를 더 잘 병렬화할 수 있고 매번 일관된 결과를 얻을 수 있으므로 빌드 시간을 줄이고 개발에 더 많은 시간을 할애할 수 있습니다.

Clang Builds

살펴볼 두 가지 기능들:

  1. Header maps (Xcode 빌드 시스템과 Clang 컴파일러 간의 정보 소통을 위해 사용)
  2. Clang modules (빌드 가속을 위해 사용)

현재 Swift만 사용하시는 분들도 계시겠지만, Swift는 내부적으로 Clang을 사용합니다.

Clang

Clang은 Apple의 공식 C 컴파일러이자 C, C++ 및 대부분의 프레임워크에 사용되는 Objective-C와 같은 C 언어군을 위한 컴파일러입니다. 앞서 말씀드린 바와 같이 컴파일러는 여러 인풋 파일을 받아 하나의 출력 파일을 생성한 다음 추후 링커가 이를 사용합니다.

iOS API들에 접근하거나 구현한 코드에 접근하고자 한다면 헤더 파일을 포함해야 합니다. 헤더 파일은 약속이라고 할 수 있습니다.

어딘가에 이것의 구현부가 존재한다는 것을 약속하는 것입니다. 물론 구현부 파일만 업데이트하고 헤더 파일을 업데이트하지 않는다면 약속을 깨뜨리는 것입니다. 컴파일러가 약속을 신뢰하기 때문에 컴파일 시에는 break가 일어나지 않습니다.

이는 링크 중에 break가 일어납니다. 컴파일러는 하나 이상의 헤더 파일을 가지고 있으며 이는 모든 컴파일러의 호출을 야기합니다.

예시 앱을 통해 헤더 파일을 다루는 방법을 알아보겠습니다.

PetWall은 여러 언어가 공존하는 애플리케이션입니다. 애플리케이션 자체는 Swift로 작성되어 있으며 Objective-C로 작성된 프레임워크를 사용하고 있습니다. 그리고 C++로 작성된 support library를 가지고 있습니다. 시간이 지나 애플리케이션이 성장하여 파일을 찾기 용이하도록 다시 조직하였다고 해봅시다. cat과 연관된 파일들을 모두 하위 폴더에 옮기는거죠. 그래도 구현부 파일을 변경할 필요는 없습니다.

Clang이 헤더 파일을 찾는 방법

Clang은 헤더 파일을 어떻게 찾을까요? 간단한 예시를 보시겠습니다.

아래 코드는 구현부 파일 중 하나로 cat.h라 부르는 헤더 파일을 포함하였습니다.

Clang이 무엇을 하는지 파악하려면 빌드 로그를 살펴보면 됩니다. 빌드 로그를 살펴보면 특정한 파일을 컴파일할 때 Xcode 빌드 시스템이 무엇을 하였는지 알 수 있습니다.

이를 verbose를 의미하는 -v 옵션을 붙여 터미널에 붙여넣으면 Clang이 많은 정보를 알려줄 것입니다.

이제 헤더맵(headermaps)을 살펴보겠습니다.

헤더맵은 헤더 파일들의 위치를 알기 위해 Xcode 빌드 시스템이 사용하는 것입니다. 이제 가장 중요한 두 헤더맵 파일을 보겠습니다.

처음 두 엔트리들은 프레임워크 이름을 헤더에 넣어줍니다. 이 두 헤더들은 공개 (public) 헤더들입니다. 이 기능에 의존하면 안됩니다. 그 이유는 기존 프로젝트가 계속 작동하도록 유지하고 있을 때 Clang 모듈에 문제가 있을 수 있으므로 자체 프레임워크에서 공개 또는 비공개 헤더 파일을 포함할 때 항상 프레임워크 이름을 지정하는 것이 좋습니다.

세 번째 엔트리는 프로젝트 헤더입니다. 이 경우에 필수는 아닙니다.

헤더맵의 목적은 소스코드로 포인트 백 (point back) 해주는 것이니까요. 공개 및 비공개 헤더들로 동일한 작업을 수행합니다.

Clang이 소스코드 경로에 있는 파일들이 유용한 에러와 경고 메시지들을 만들고 빌드 디렉토리의 다른 위치에 있을 수 있는 잠재적 복사본이 아닌 파일을 생성할 수 있도록 소스코드로 포인트 백 시킵니다.

헤더맵과 관련된 흔한 프로젝트 이슈

  • 프로젝트에 헤더를 넣지 않음 (헤더는 프로젝트의 일부가 아님)
    • 이는 소스 경로에 있지만 프로젝트 자체는 아니라 프로젝트에 추가하여야 함
  • 헤더를 같은 이름으로 지음
    • 같은 이름이면 서로를 가리게 되므로 겹치지 않는 고유의 이름을 지어야 함

이는 시스템 헤더에도 적용됩니다. 프로젝트에 시스템 헤더와 동일한 이름을 가진 로컬 헤더가 있으면 시스템 헤더를 가리게 되므로 이 상황을 피해야 합니다.

시스템 헤더를 찾는 방법

아래는 PetWall의 다른 예시인데, 여기에 SDK에 있는 Foundation.h 헤더 파일을 넣었습니다.

자신의 헤더 파일을 찾을 때 이전에 했던 것과 같은 일을 할 수 있지만, 현재 우리는 시스템 헤더를 찾고 있습니다. 헤더맵은 자체적인 헤더만을 대상으로 하기에 이를 무시할 수 있습니다.

포함하고 있는 경로에 초점을 맞춰보면, 기본적으로 SDK에 있는 두 개의 경로를 볼 수 있습니다. 첫 번째는 사용자가 넣은 것, 시스템 라이브러리 프레임워크가 넣은 것입니다. 이는 일반적인 포함 경로입니다.

사용자측에 Foundation/Foundation.h search term을 넣어서는 결과가 없어 찾을 수 없습니다.

시스템 라이브러리 프레임워크에서는 작동 방식이 사용자측과 달리 프레임워크의 종류와 존재 여부를 식별한 후 헤더 파일의 헤더 경로를 찾습니다.

이 때 헤더 파일이 존재하지 않는다면 비공개 헤더 경로에서 헤더 파일을 찾습니다. Apple은 SDK에 비공개 헤더를 넣지 않지만, 다른 프레임워크들은 공개 및 비공개 헤더들을 넣을 수 있습니다.

헤더 파일을 찾는데 실패했지만 더 이상 경로를 탐색하게 하지 않습니다. 프레임워크를 이미 찾은 상태이기 때문이죠. 프레임워크를 찾았으므로 프레임워크 경로에 헤더가 있을 것이라 예상합니다. 하지만 이를 찾지 못하면 탐색을 중지합니다.

모든 헤더들이 넣어지고 전처리가 된 후 구현부 파일의 생김새가 궁금하다면 Xcode가 전처리 파일을 만들게 하면 됩니다. 이는 매우 큰 아웃풋 파일을 만들 것입니다.

Foundation.h는 시스템에서 매우 기초적인 헤더입니다. 직접적으로 넣거나 다른 헤더 파일을 통해 간접적으로 넣는 방식으로 많이 적용되고 있습니다. 이는 컴파일러가 매번 이 헤더를 찾기 위해 호출한다는 것을 의미합니다.

Clang은 한 줄의 include 구문을 위해 800 개 이상의 헤더 파일을 탐색하여 처리해야 합니다. 이는 구문 분석되고 검증되어야할 소스코드가 9 MB가 넘는다는 것입니다. 그리고 이러한 일이 컴파일러가 호출될 때마다 일어난다는 것이죠. 이는 많은 일이며 불필요한 일입니다.

이를 개선시킬 수 있는 방법 중 하나는 미리 컴파일된 (precompiled) 헤더 파일을 사용하는 것입니다. 하지만 더 좋은 것이 있는데, 이것이 Clang modules입니다.

Clang Modules

Clang 모듈은 프레임워크당 헤더를 한 번 찾아 구문분석한 후 이 정보를 디스크에 저장하여 캐시된 상태로 재사용될 수 있도록 만들어 빌드 시간을 개선합니다.

이를 위해 Clang 모듈은 특정 속성을 가져야 합니다.

Context-free

그 중 하나가 context-free입니다. 아래 두 코드들에 PetKit 모듈을 임포트하였지만 그 전에 두 가지 다른 매크로 정의가 넣었습니다. 이 헤더들을 임포트하는데 기존 모델을 사용했다면 이들이 포함되었다는 것을 의미합니다. 전처리기는 이 정의를 따라 헤더 파일을 적용할 것입니다. 하지만 이렇게 하면 모듈들은 각 헤더 케이스에 따라 다를 것이므로 재사용할 수 없습니다. 그래서 모듈들을 사용하려면 이렇게 해서는 안됩니다. 대신에 모듈이 context와 관련된 모든 정보들을 무시하도록하여 모든 구현부 파일에 재사용될 수 있도록 합니다.

Self-contained

또 다른 하나의 요건은 모듈이 self-contained하여야 한다는 것입니다. 이는 모든 종속성을 나타내야 한다는 것입니다. 임포트 작업을 위해 어떠한 추가 헤더 파일도 추가할 걱정을 하지 않아도 되며 한 번 모듈을 임포트하면 작동한다는 의미입니다.

그럼 Clang은 모듈을 빌드 여부를 어떻게 알까요? NSString.h의 사례를 살펴봅시다. 먼저 Clang은 프레임워크에서 특정 헤더를 찾습니다. Foundation.framwork 경로죠.

다음으로 Clang 컴파일러는 헤더의 경로에 상대적인 모듈 경로와 모듈 맵을 찾습니다. 모듈 맵은 특정 헤더 파일들의 집합이 모듈로 변환되는 방식을 나타냅니다.

Foundation이라는 모듈 이름과 어떤 헤더들이 이 모듈의 일부인지 나타내고 있습니다. 여기에는 Foundation.h 헤더라는 하나의 모듈만 가지고 있는데 이는 umbrella 키워드를 가진 특수한 헤더입니다. 이는 NSString.h이 모듈의 일부인지 알아내기 위해 Clang이 이 특정한 헤더 파일을 살펴보아야 한다는 것입니다.

이제 NSString.h가 foundation 모듈의 일부임을 알았습니다. Clang은 텍스트 임포트를 모듈 임포트로 업그레이드할 수 있으며 이를 위해 foundation 모듈을 빌드해야 합니다. 그렇다면 Foundation 모듈을 어떻게 빌드할까요? 우선 이를 위해 별도의 Clang 위치를 만듭니다. 그리고 이 Clang 위치는 foundation 모듈의 모든 헤더 파일들을 가지고 있습니다. 우리는 원래 (original) 컴파일러 호출에서 기존 context를 전달하지 않았으므로 context-free입니다.

실제로 전달해야할 것은 Clang에 전달한 커맨드 라인 전달인자입니다. foundation 모듈을 빌드하는 동안 프레임워크 자체는 추가적인 프레임워크를 포함하므로 이 모듈 또한 빌드해야 합니다.

하지만 여기에서 이점을 찾을 수 있습니다. 임포트한 것들 중 일부는 동일할 수 있으므로 해당 모듈을 재사용하면 되는 것입니다. 이러한 모듈은 모듈 캐시라고 하는 디스크에 저장됩니다.

앞서 언급하였듯이 커맨드 라인 전달인자들은 모듈을 만들 때 전달되는데, 이는 이 전달인자들이 모듈의 내용에 영향을 미칠 수 있다는 것을 의미합니다.

결과적으로 이 모든 전달인자들을 해시하고 이러한 특정 컴파일러 호출을 위해 생성한 모듈을 해당 해시와 일치하는 경로에 저장해야 합니다. 다른 제한 파일에 대한 컴파일러 전달인자를 변경하는 경우, 예를 들어 enable cat이라고 하면 이는 다른 해시이며, Clang이 해당 해시와 일치하는 해당 디렉토리에 모든 모듈의 인풋들을 다시 빌드해야 합니다. 따라서 모듈 캐시를 최대한 재사용하기 위해서는 전달인자를 동일하게 유지해야 합니다.

시스템 프레임워크의 모듈을 찾아 빌드하는 방법은 알았는데 자체적인 프레임워크는 어떨까요? 기존 cat 예시로 돌아가봅시다. 이번에는 모듈을 켰습니다.

만약 헤더맵을 다시 사용한다면, 헤더맵은 소스 경로로 포인트 백 시킬 것입니다. 하지만 소스 경로를 보면 문제가 있습니다. 모듈 경로가 없거든요. 프레임워크 같아 보이지도 않기 때문에 Clang은 이 경우 어떻게 해야할지 모를 것입니다.

이 문제를 해결하기 위해 Clang의 Virtual File System이라 부르는 새로운 개념을 소개합니다. 이는 Clang이 모듈을 빌드할 수 있도록 프레임워크의 가상 추상화결과물을 만듭니다.

하지만 추상화 결과물은 경로로 파일을 포인팅시키기 때문에 다시 Clang은 소스코드에 대한 에러를 만들 것입니다. 앞서 말씀 드렸듯이 프레임워크 이름을 명시하지 않으면 이슈가 발생할 수 있습니다. 어떤 문제가 발생할 수 있는지 예시를 보여 드리겠습니다. 두 개의 임포트가 있습니다. 첫 번째는 PetKit 모듈이며 두 번째는 PetKit 모듈의 일부이지만 프레임워크 이름을 명시하지 않았기에 Clang은 알아채지 못할 것입니다. 이 경우 중복 정의 에러가 발생할 수 있습니다. 동일한 헤더를 임포트하면 발생하는 것이죠.

Clang은 이러한 이슈를 해결하기 위해 내부적으로 바쁘게 작동합니다. 하지만 고칠 수 없죠. 이제 context를 바꾸는 조그마한 변화를 주어봅시다.

모듈 임포트는 context를 무시하기 때문에 이에 영향받지 않을 것입니다. cat 임포트는 여전히 이 변경 사항을 관찰할 헤더의 텍스트 임포트일 뿐입니다.

중복 정의가 없을 수 있지만 이제 모순된 정의를 가질 수 있습니다. 이 문제는 Clang이 해결할 수 없는 일이죠. 그래서 앞서 말씀 드렸듯이 항상 공개 또는 비공개 헤더를 임포트할 때 프레임워크 이름을 명시하기를 권장 드립니다.

Swift Builds

Swift와 빌드 시스템이 프로젝트에서 선언한 것들을 찾기 위해 협업하는 모습을 살펴보겠습니다.

이전 내용을 잠시 돌이켜보면, Clang은 각 Objective-C 파일을 따로 컴파일한다고 했습니다. 이 말은 다른 파일에 있는 클래스를 참조하고 싶다면 해당 클래스가 선언된 헤더를 임포트 해야 한다는 것입니다.

하지만 Swift는 헤더를 작성할 필요가 없도록 설계되었습니다. 이는 초보자들이 언어를 시작하는 것을 용이하게 해주고 별도의 파일에 동일한 선언을 반복하는 것을 방지하게 해주죠. 하지만 이는 컴파일러가 추가적으로 장부 관리 작업 (bookkepping)을 수행해야 한다는 것이기도 합니다. 이제 장부 관리 작업이 이루어지는 방식을 살펴보도록 하겠습니다.

PetWall 앱으로 돌아가 봅시다. 앱에는 ViewController에 Swift로 작성된 view, Objective-C AppDelegate, Swift unit test들이 있습니다.

PetViewController라는 최상단에 위치한 파일을 컴파일하기 위해 컴파일러는 네 개의 다른 작업을 수행하여야 합니다.

  • Swift 타겟과 Objective-C로부터 오는 두 가지에서 선언문들을 찾아야 합니다.
  • Objective-C와 다른 Swift 타겟들에서 선언문들을 찾아 사용할 수 있게끔 파일의 내용을 설명하는 인터페이스를 만들어야 합니다.

예시를 통해 더 자세히 살펴보겠습니다. PetViewController.swift를 컴파일할 때, 컴파일러는 호출 가능 여부를 판단하기 위해 PetView의 이니셜라이저의 타입을 탐색합니다. 하지만 이 작업을 하기 전에 이니셜라이저 선언이 제대로 되어 있는지 확인하기 위해PetView.swift를 구문 분석하여 검증할 필요가 있습니다. 컴파일러는 이니셜라이저의 구현부 (body)까지 확인할 필요는 없다는 것을 알기에 이 부분을 체크하지는 않지만 파일의 인터페이스 부분을 처리하기 위해 몇 가지 작업을 더 수행하여야 합니다.

Clang과는 달리 Swift 파일을 컴파일할 때 컴파일러는 타겟 안의 모든 Swift 파일을 구문 분석합니다. 이 파일들 중 일부가 인터페이스와 관련이 있는지를 판단하기 위해서죠.

Xcode 9에서는 컴파일러가 각 파일을 따로 컴파일하여 증분 빌드 시 빌드를 반복했을 때 반복되는 작업이 있었습니다. 이는 파일들이 병렬적으로 컴파일 될 수 있도록 했지만 컴파일러가 반복적으로 각 파일을 파싱하도록 강요했습니다. 구현한 것으로 .o를 만들려고 하면 선언문을 찾기 위해 여러 번 인터페이스를 찾아야 했죠.

Xcode 10에서는 이러한 간접 비용 (overhead)를 줄였습니다. 가능한 한 많은 작업을 공유하는 그룹으로 파일을 결합하여 수행합니다. 최대한의 병렬 처리도 여전히 사용할 수 있습니다. 이는 그룹 내에서 파싱 결과를 재사용하고 그룹 간에서만 반복 작업이 일어납니다. 그룹의 수는 일반적, 상대적으로 적으므로 증분 디버그 빌드를 상당히 빠르게 만들어 줄 수 있습니다.

이제 Swift 코드는 Swift 코드만을 호출하지 않습니다. Objective-C도 호출할 수 있죠. PetWall 예시 앱으로 돌아가보면, Objective-C로 작성된 UIKit과 같은 시스템 프레임워크도 있으니까요.

Swift는 다른 많은 언어들과는 다른 접근 방식을 취합니다. 외부의 다른 함수 인터페이스를 제공할 필요도 없죠. 예를 들어 Objective-C API를 사용하려면 일반적으로 이에 대한 Swift 선언을 작성해야 하지만 Swift 컴파일러인 swiftc는 내부에 Clang의 많은 부분을 포함하고 이를 라이브러리로 사용하고 있기 때문에 이를 통해 Objective-C 프레임워크를 직접 가져올 수 있습니다.

그럼 Objective-C 선언은 어디에서 올까요? 아래와 같이 임포터가 타겟의 타입에 따라 헤더를 찾습니다.

  • 어떤 타겟이라도 Objective-C 프레임워크를 임포트하면 해당 프레임워크의 Clang 모듈 맵을 통해 드러난 선언문을 찾습니다.
  • Swift와 Objective-C 코드가 혼합된 프레임워크에서는 임포터는 umbrella 헤더에서 선언문을 찾습니다.
    • Umbrella 헤더는 공개 인터페이스를 정의합니다. 이 방식으로 프레임워크 내부의 Swift 코드는 동일한 프레임워크의 공개된 Objective-C 코드를 호출할 수 있습니다.
  • 애플리케이션과 unit test 번들에서는 타겟의 브리징 헤더 (bridging header)에 임포트들을 추가하여 선언이 Swift로 호출되도록 합니다. 이제 임포터가 선언을 가져오면 이들을 더 관용적으로 만들기 위해 변경합니다.

예를 들어, Swift의 내장 에러 처리 언어 기능을 사용하기 위해 쓰로잉 메서드들로 NSError를 사용하는 Objective-C 메서드를 임포트할 수 있습니다.

이 과정에서 동사와 전치사 다음에 오는 매개변수 타입 이름을 제거합니다. 예를 들어, drawPet atPoint 메서드에서는 pet이라는 단어가 있습니다. 동사 draw 다음에 오는 Pet 타입의 매개변수, 그리고 마찬가지로 전치사 at 다음에 오는 CGPoint 타입의 매개변수에 대한 단어 point가 있습니다. 이 메서드를 Swift로 가져올 때 단순히 draw(_:at:)으로 생략되어 표현됩니다.

이것은 어떻게 작동할까요? 컴파일러가 흔한 영어 동사와 전치사 목록을 가지고 있다는 것을 아시면 놀라실지도 모르겠습니다.

하드 코딩된 목록이며 인간이 사용하는 언어이기에 지저분하여 종종 단어를 놓칠 때도 있습니다. 더욱이 Swift의 명명 규칙 (컨벤션)에 맞추기 위해 임포터는 품사를 기반으로 단어를 제거하여 메서드의 이름도 바꿉니다. 종종 목록에 단어가 없어 놓치는 경우도 있는데, 이 경우에는 NS_SWIFT_NAME 어노테이션을 이용하여 컴파일러에게 원하는 메서드를 임포트 해달라고 할 수 있습니다.

Objective-C 헤더가 Swift에 임포트 되는 방법을 알고 싶다면 Xcode의 related items 팝업을 보시면 됩니다.

이건 좌측 상단에서 찾을 수 있는데, generated interfaces를 선택하시면 다른 Swift 버전에서의 인터페이스를 볼 수 있습니다.

반대로 Objective-C가 Swift를 임포트하는 상황에서는 Swift가 임포트한 Objective-C의 헤더를 만듭니다. 이 방법으로 Swift에서 클래스를 작성한 것을 Objective-C가 호출해서 사용할 수 있는 것이죠.

  • 컴파일러는 NSObject를 확장한 Swift 클래스와 @objc 어노테이션이 있는 요소들로부터 Objective-C 선언을 만듭니다.
  • 유닛 테스트에 있는 앱의 경우 헤더는 공용 및 internal 선언 모두 포함하고 있게 됩니다. 이것이 앱의 Objective-C 부분으로부터 Swift를 사용할 수 있게 해줍니다.
  • 프레임워크는 빌드 프로덕트에 포함되어 있고 프레임워크의 공용 인터페이스 부분이기 때문에 생성된 헤더가 공용 선언문만을 제공합니다.

오른쪽에서는 컴파일러가 모듈 이름인 PetWall을 포함하는 Swift 클래스에 약간 변형된 이름에 Objective-C 클래스를 바인딩하는 것을 볼 수 있습니다.
두 모듈이 동일한 이름의 클래스를 정의했을 때 런타임 충돌을 방지하기 위해 @objc 어노테이션을 통해 Objective-C 클래스에 다른 이름을 사용하도록 Swift에게 요청할 수 있습니다.

이름이 충돌되지 않도록 하는 것은 개발자의 책임입니다. @objc(name)처럼 새로운 이름을 전달하여 충돌을 방지할 수 있습니다.

컴파일러는 다른 Swift 타겟들의 인터페이스를 만들 때도 유사한 접근 방식을 취합니다.

Swift에서 모듈은 배포 가능한 선언 단위를 의미합니다. 그리고 이러한 선언문을 이용하려면 모듈을 임포트해야 합니다. 예를 들어, Objective-C 모듈이나 XCTest 모듈을 가져올 수 있겠죠.

Xcode에서 각 Swift 타겟은 앱 타겟을 포함하여 별도의 모듈을 생성합니다. 이것이 유닛 테스트에서 테스트하기 위해 앱의 메인 모듈을 임포트해야하는 이유입니다. 모듈을 가져올 때 컴파일러는 사용할 때 타입을 확인하기 위해 특수한 Swift 모듈 파일을 역직렬화(deserialize)합니다.

예를 들어 아래의 유닛 테스트에서 컴파일러는 컨트롤러를 제대로 생성하는지 확인하기 위해 PetWall Swift 모듈의 일부인 PetViewController를 로드할 것입니다. 이것은 앞서 보여 드렸던 컴파일러가 타겟 내 선언문을 찾는 방식과 유사합니다. 컴파일러는 Swift 파일을 직접 파싱하지 않고 모듈을 요약하는 파일을 로드합니다.

컴파일러는 생성된 Objective-C 헤더처럼 많은 Swift 모듈 파일을 생성합니다. 하지만 텍스트가 아니라 바이너리 표현 방식을 사용합니다. 여기에는 Objective-C의 정적 인라인 함수 또는 C++의 헤더 구현과 같은 인라인이 가능한(inlineable) 함수의 구현부 (bodies)가 포함됩니다. 하지만 이는 비공개 선언문들의 이름과 타입을 포함하고 있습니다. 이렇게 하면 디버거에서 참조할 수 있어 정말 편리하지만 비공개 변수를 아무렇게나 지어서는 안된다는 것을 시사합니다.

증분 빌드의 경우 컴파일러는 부분적인 Swift 모듈 파일을 생성한 다음 전체 모듈의 내용을 나타내는 단일 파일로 병합합니다. 이 병합 프로세스를 통해 단일 Objective-C 헤더를 생성할 수도 있습니다. 이는 링커가 개체 파일을 단일 실행 파일로 합칠 때 수행하는 작업과 유사합니다.

Linking

Xcode 빌드 프로세스의 마지막 단계인 링커를 살펴봅시다.

링커

  • 실행가능한 Mach-O를 빌드하는 마지막 작업
  • 모든 컴파일러의 결과물(.o 파일)을 합쳐 하나의 파일로 만듦
    • 컴파일러가 생성한 코드를 이동하거나 수정함
  • 두 종류의 인풋 파일을 받음
    • 오브젝트 파일 (.o)
    • 라이브러리 (.dylib, .tbd, .a)

심볼

  • 코드나 데이터의 조각을 참조하기 위한 이름
  • 코드 조각들은 다른 심볼들을 참조할 수 있음 (다른 함수를 부르는 함수를 작성 등)
  • 심볼은 링커의 작동 방식에 영향을 주는 속성을 가질 수 있음 (weak, availability symbol 등)
  • 언어들은 데이터를 "mangling"하여 심볼로 인코딩함 (C++, Swift)

오브젝트 파일

  • 개별 컴파일러 액션의 결과물
  • 코드와 데이터 조각을 가진 실행할 수 없는 Mach-O 파일 (missing된 일부분을 링커가 이어 붙여 고쳐야 함)
    • 각 조각은 심볼로 표현됨
    • 조각들은 "정의되지 않은" 심볼들을 참조할 수 있음 (한 .o 파일의 함수가 다른 .o 파일의 함수를 참조할 때)

라이브러리

  • 타겟의 일부로서 빌드되지 않은 심볼들을 정의
    • Dylibs: 동적 라이브러리
      • 실행 파일이 사용할 코드와 데이터 조각을 노출하는 Mach-O 파일
    • TBDs: 텍스트 기반 동적 라이브러리 (Text Based Dylib Stubs)
      • 심볼만 가지고 있음
    • 정적 아카이브
      • "AR" 툴으로 빌드된 .o 파일의 모음
      • 참조하는 심볼이 있는 .o 파일만 앱에 포함됨

예시

아래 그림에서 AAC 사운드 파일이 될 "purr.aac"를 찾아볼 수 있지만 purrFile은 나타나지 않음. 이는 static이기 때문입니다 (nonexported name).

Cat purr는 아래와 같이 심볼이 만들어집니다.

이제 변수를 playSound(_:)에 전달합니다. 어셈블리어의 명령어가 두 개인데, 이는 "purr.aac"는 문자열 이름이라 실제 주소를 가지고 있지 않기 때문에 이후에 찾기 위함입니다. 링커가 들어와서 이후에 고칠 심볼릭 오프셋, 심볼릭 값 페이지와 페이지 오프를 남겨둡니다.

마지막으로 이 문자열을 x0에 로드했으므로 playSound(_:)를 호출할 수 있습니다. playSound(_:)를 호출하지 않고 __Z9playSoundPKc를 호출하죠. 이는 "mangle"된 심볼을 나타내는건데, Objective-C++이면서 C++에도 동일한 playSound 함수가 있기 때문입니다.

이제 .o 파일이 있습니다. 이를 통해 빌드 시스템은 모든 .o 파일들을 링커에 인풋으로 전달할 것입니다. 그리고 링커는 파일을 생성하기 시작합니다. 이 경우 PetWall 내부에 포함된 프레임워크인 PetKit을 빌드합니다. Text segment라 부르는 것을 만들 것입니다. Text segment는 애플리에키션에 대한 모든 코드를 보관하는 곳인데, 이번에는 cat.o를 가져와서 복사합니다. 두 부분으로 나눌 건데, 하나는 해당 문자열용이고, 또 다른 하나는 실행 코드용입니다.

이제 이들의 절대 주소를 알고 있으므로 링커가 특정 오프셋에서 로드하도록 cat.o를 다시 작성할 수 있습니다. 명령어를 삭제하거나 만들 수 는 없기 때문에 두 번째 명령어를 아무것도 하지 않는 null로 대체합니다. 그런 다음 분기합니다.

profile
합리적인 해법 찾기를 좋아합니다.

0개의 댓글