앱이 의존성을 동적으로 링크하거나 정적으로 링크할 때 각각 어떤 비용과 제약이 생기는지, 둘을 섞을 때의 문제, 그리고 Apple의 Mergeable Libraries가 이 트레이드오프를 어떻게 완화하는지 살펴본다.
Swift 포럼의 최근 질문 때문에, 오랫동안 막연히 생각만 하던 이 글을 실제로 쓰게 됐다. 요즘은 앱이 외부 의존성을 갖는 일이 흔하지만, 그 의존성을 정적으로 링크하든 동적으로 링크하든 각각 단점이 따른다. (제목과 같은 얘기지만, 덜 도발적으로 말한 것이다.) 왜 이런 긴장이 생기며, 이를 위해 무엇을 할 수 있을까?
UPDATE: WWDC 2023에서 Apple은 “mergeable libraries”를 발표했다. 이는 충분한 메타데이터를 가진 동적 라이브러리로, 원한다면 클라이언트에 정적으로 링크(병합)할 수 있다. 나는 이것이 여기서 논의하는 거의 모든 트레이드오프를 해결하는 것처럼 보여서 매우 기대하고 있다. 엔지니어 Cyndy Ishida의 훌륭한 발표에서 더 알아볼 수 있다. 글의 끝에서 더 이야기하겠지만, “Mergeable Libraries는 앱을 위한 것이다!”라고 요약하자.
앱이 사용할 수 있는 라이브러리는 네 종류가 있다:
이 글은 세 번째 범주에 초점을 맞출 것이다. 나중에 다른 것들에 대해서도 다시 이야기할지 모르겠다. (어쨌든 두 번째는 iOS나 Android에서는 사실상 허용되지 않는다.) 또한 동일한 문제 중 일부가 적용되긴 하지만, VM/바이트코드가 아니라 컴파일된 네이티브 코드에 대해서만 이야기하겠다.
이름이 암시하듯 차이는 이렇다: 정적 링크는 라이브러리가 미리(앱을 빌드할 때) 앱에 병합되는 것을 뜻하고, 동적 링크는 앱이 실행될 때 라이브러리가 프로세스에 로드되는 것을 뜻한다. 이는 동적 라이브러리의 코드에 메모리를 할당하고(그리고 그 라이브러리의 의존성도 로드하고), 메인 앱이 라이브러리의 API를 사용하려고 하는 지점들을 “수정(fix-up)”해서 로드된 코드 위치를 가리키도록 만드는 것을 의미한다. 거기에 더해 디스크의 추가 파일들을 읽어들이는 기본 작업도 있다.1
이 모든 건 무엇을 위한 걸까? 유연성…인데, 앱은 아마 그걸 쓰지 않는다. 1번과 2번 범주의 라이브러리에서는 동적 링크가 버그 수정과 성능 개선을 위한 라이브러리 업데이트가 있어도 앱이 계속 동작하도록 해 준다. 디버깅 목적의 추가 검사 기능이 들어간 버전으로, 혹은 공개 인터페이스에 호환되는 구현을 제공하는 _다른 벤더_의 라이브러리로 의존성을 _대체_하는 데 쓰일 수도 있다.2
하지만 3번 범주에서는 이런 유연성이 낭비다. 라이브러리는 앱과 함께 배송되며, 라이브러리를 업데이트하고 싶다면 앱 업데이트를 배송하면 된다. (2022년에 그건 해결된 문제라서 다행이다.)
4번 범주에서 동적 로딩의 목적은 다르다. 필요할 때까지 기능을 _로드하지 않는 것_일 수도 있고, 기본 앱을 바꾸지 않고 기능을 추가하는 것—어쩌면 외부 벤더의 기능—일 수도 있다. 이는 근본적으로 동적인 매우 다른 문제 공간이며, 거기서는 _링킹_이 프로세스 간 통신과 비교해 여러 선택지 중 하나일 뿐이다.
정적 링크에는 이런 낭비 작업이 없다. 라이브러리를 가져다가 실행 파일 안에 미리 밀어 넣어서, 처음부터 메인 앱의 일부였던 것처럼 만든다. 앱을 실행할 때가 되면 로더는 그것이 원래는 별도 라이브러리였는지 알 수 없다. 좋게 들리지 않는가?
문제는, 이 방식이 깔끔하게 동작하는 건 실행 파일이 정확히 하나일 때뿐이라는 점이다. 현대의 iOS 앱—예를 들어 Signal—에는 보통 여러 실행 파일이 있다: 메인 앱뿐 아니라, OS가 특정 목적을 위해 띄울 수 있는 _확장(extensions)_도 있다. Signal의 경우 “공유 확장”(표준 시스템 공유 시트와 연동)과 “알림 서비스 확장”(전체 앱을 실행하지 않고도 푸시 알림을 처리)이 있다. 이 둘은 메인 앱이 쓰는 라이브러리의 일부만 사용하며, 실제로 iOS는 처리 시간과 메모리 사용량에 대한 엄격한 제한 때문에 그렇게 하도록 _요구_한다. 하지만 동시에 메인 앱과 공유하는 것도 많다.
모든 것을 정적으로 링크하면 공통 코드의 사본이 세 개가 되어 사용자의 휴대폰 공간을 낭비한다. (빌드 시간 증가도 말할 것도 없다.) 하나의 거대 메가-라이브러리를 정적으로 링크한 뒤 그것을 동적으로 로드하는 방식도 쓸 수 없다. 제한된 확장 컨텍스트에서는 그것이 너무 많은 자원을 쓰기 때문이다.3
정적 링크와 동적 링크를 섞으려 하면 상황은 더 나빠진다. 같은 앱 안에 라이브러리의 사본이 여러 개 생길 수 있다. 앱이 FooKit과 BarKit 두 라이브러리를 사용하고, 둘 다 세 번째 라이브러리인 libCore에 의존한다고 하자. FooKit과 BarKit이 정적으로 링크된다면 문제없다: 앱은 libCore도 “그냥” 링크하면 된다. 반대로 libCore가 동적으로 링크된다면, 앱이 FooKit과 BarKit을 어떻게 사용하든 상관없이 어느 쪽이든 단일 libCore에 대한 외부 참조를 갖게 된다. 하지만 libCore가 정적으로 링크되고 FooKit과 BarKit이 동적으로 링크된다면, libCore의 코드는 FooKit과 BarKit 둘 다에 포함되어 디스크 공간을 낭비하고, 런타임에서 문제를 일으킬 수도 있다.
이의 변형으로, 중간 라이브러리 FooKit이 _하나_뿐이지만, _앱_도 libCore에 의존하는 경우가 있다. 이때 선택지는 두 가지다: FooKit이 자신이 사용하는 부분만이 아니라 libCore의 모든 부분을 포함하도록 하고, 앱에는 libCore의 심볼을 FooKit에서 찾으라고 알려주는 방법. 혹은 위와 같은 방식—libCore를 FooKit에 정적으로 링크한 다음 메인 앱에도 또 다시 링크하는 방법.
때로는 사람들이 의도적으로 이런 일을 한다. libCore가 FooKit과 BarKit의 단지 구현 세부사항일 뿐이기 때문이다. FooKit과 BarKit이 사전 빌드되어 있을 수도 있고, 심지어 서로 다른 벤더에서 왔을 수도 있다. 어쩌면 서로 호환되지 않는 다른 버전의 libCore에 의존할 수도 있다! 이는 타당한 아이디어지만, 항상 잘 지원되지는 않는다. 프로그램 전역에서 유일해야 한다고 기대하는 것(예: Objective‑C 클래스 이름, Swift 맹글드 타입 이름)은 이제 제대로 동작하지 않을 위험이 있다. 반면 Rust의 Cargo는 “호환되지 않는 버전” 사용 사례를 지원하기 위해 의도적으로 이런 방식을 택하며, Rust는 “전역 유일성” 같은 언어 기능을 거의 제공하지 않기 때문에 실제로는 보통 잘 작동한다.
하지만 그것에 기대기 어렵다면, 경험칙은 이렇다: 빌드 그래프의 부분집합 중에서 단 하나의 동적 클라이언트(동적 라이브러리 또는 실행 파일)만 갖는 부분 안에서만 정적 링크를 사용할 수 있다. 즉, 어떤 의존성이 여러 동적 클라이언트에서 사용되는 순간, 중복을 피하기 위해 그 의존성도 동적으로 취급해야 한다.
(Swift의 초기 구현은 이런 이유로 정적 라이브러리를 제대로 염두에 두지 못했다. 불가능하게 만들지는 않았지만, 어쨌든 다들 동적 라이브러리를 쓸 거라는 가정으로 시작했다. 동적 라이브러리는 Objective‑C에도 이미 적용되던 이 이상한 빌드 제약 없이 항상 동작하니까, 정적 라이브러리는 더 특수한 용도일 것이라고 생각했던 것이다. 그런데 동시에 우리(Apple)의 링커 팀은 외부 개발자들이 앱에서 dylib를 더 적게 쓰도록 유도하고 있었다. 로드 시간에 정말로 영향을 주기 때문이다. 이런. 정확히 무엇을 다르게 했어야 했는지는 모르겠지만, 링커 팀과 지금보다 더 많이 이야기했어야 했다.)
동적 라이브러리에는 실제 포맷이 있다. 정적 라이브러리는, 적어도 iOS 그리고 Android 같은 Unix 계열 시스템에서는, 오브젝트 파일 여러 개를 한데 붙여 놓은 것이다. 진짜로! 표준 확장자는 “archive”의 .a다. 기본 포맷은 오늘날 더 흔한 tar보다도 오래됐다(zip은 말할 것도 없다). 그리고 오래됐다고 나쁜 건 아니니, 대신 이런 접근이 공간을 꽤 낭비한다는 점을 불평하겠다. 둘 이상의 오브젝트 파일에 복사되는 함수는, 제대로 된 링킹이 하는 것처럼 중복 제거를 하지 않고 모든 사본을 그대로 보존한다.4
하지만 파일 크기만의 문제가 아니다. Swift의 fileprivate, internal, public은 링커가 지원하는 접근 수준과 대략 대응한다: “이 파일에서만 사용 가능”, “이 라이브러리에서만 사용 가능”, “클라이언트에 공개”. 하지만 정적 라이브러리는 오브젝트 파일을 붙여 놓은 것뿐이므로 “internal”과 “public” 부분을 구분할 수 없다. 이는 단지 비공개 심볼 사용을 막는 문제가 아니라, 컴파일 유닛 간 데드 코드 스트리핑이 어떻게 동작하는지와 관련된다. 물론 그건 정적 라이브러리를 더 큰 프로그램에 링크할 때 가장 유용하지만…
정적 라이브러리를 동적 라이브러리로 만드는 것은 쉽다: 다른 파일 없이 그 라이브러리만 링크하면 된다. 하지만 그러고 나면 더 이상 정적으로 링크할 수 없다. (동적 라이브러리를 다시 오브젝트 파일로 되돌리는 도구를 만드는 것이 본질적으로 불가능한 이유는 떠오르지 않지만, 아무도 그런 걸 만들지 않았다고 생각하고, 만들더라도 확실히 고통스러울 것이다.)
내가 원하는 것은 오브젝트 파일이나 정적 라이브러리처럼 동작하지만, 이미 한 차례 링크 과정을 거친 포맷이다: 중복 제거, 비공개 심볼 제거, 데드 코드 스트리핑, 어쩌면 링크 타임 최적화까지. 오늘 당장 새 포맷 없이도 그런 도구를 만들 수 없는 이유는 없다고 생각하지만, 만약 새 포맷이 있다면 이후 링크 단계를 더 효율적으로 만들기 위한 준비를 더 할 수 있고, 아카이브 포맷을 더 작게 만들 수도 있다.
Keith Smiley가 ld -r가 존재한다는 것을 상기시켜줬다. 이는 동적 링커 ld의 한 모드로, 정적 아카이브나 동적 라이브러리 대신 단일 오브젝트 파일을 만들어 준다. 위에서 말한 것들 상당수를 해낼 수 있다! (예를 들어 -x 옵션은 비공개 심볼을 숨긴다.) 완벽하진 않고 이 옵션의 추가 단점이 있는지 알 만큼 충분히 잘 알지는 못하지만, 오늘날 실제로 존재하는 기능이다!
또한 이는 라이브러리 측 문제가 아니라 클라이언트 측 문제여야 한다는 점을 강조하고 싶다. 구분은 정적/동적 _라이브러리_라기보다 정적/동적 _링킹_이다. 코드를 New Super Library Format DX로 배포한다면, 클라이언트는 그것을 구현 세부사항으로 정적으로 링크하고(안전하다면) 공개 심볼을 모두 숨길 수도 있고, 정적으로 링크하되 공개 심볼을 _재-익스포트_할 수도 있으며, 안전할 때는 정적으로 링크하고 그렇지 않으면 동적으로 링크하도록 빌드 시스템에 맡길 수도 있고, 동적으로 로드 가능한 래퍼로 패키징해 여러 실행 파일에서 사용하거나 플러그인으로 사용할 수도 있다.
하지만 이것으로 정적 링크의 근본적인 “사본 두 개” 문제는 해결되지 않는다. 이를 다루는 유일한 방법은 내가 생각하기로는 동적 링크를 쓰되, 덜 동적으로 만드는 것이다. 앱과 함께 배송된다는 걸 알고 있는 동적 라이브러리를 링크한다면, 올바른 라이브러리를 찾기 위해 파일시스템을 뒤지거나 런타임에 이름으로 심볼을 매칭할 필요가 없다. 그리고 (빌드 타임) 링커와 (런타임) 로더는 이를 고려할 수 있어야 한다.5
그렇다 해도 동적 라이브러리가 정적 링크만큼 빠르게 로드되도록 만들 수는 없을 것이고, 실행 파일이 두 개라면 정적 링크는 언제나 라이브러리 코드의 사본도 두 개를 만들 것이다.
P.S. 이 글을 쓰기 전에 Apple 링커 팀 누구와도 이야기하지 않았다. 그들이 이 문제에 대한 개선을 작업 중일 가능성은 충분히 있다. 사실 모든 플랫폼이 그렇다. 하지만 이것이 현재의 상황이다.
UPDATE: Apple의 Mergeable Libraries는 정말로 New Super Library Format DX처럼 보인다. 이에 대해 동적으로 링크할 수도 있고, 클라이언트 바이너리에 “병합”되도록 해서 사실상 정적 링크처럼 만들 수도 있다. 여기서 남는 유일한 트레이드오프는, 라이브러리가 여러 의존성을 갖기 때문에 동적으로 남겨야 하는 경우가 언제인지에 대한 부분인데, 이는 Apple이 다른 방식으로 계속 해결해 나가길 바란다.5
불행히도 Apple의 새 링커는 오픈 소스가 아니다. lld나 mold/sold에 이 포맷을 가져오자는 논의도, ELF(Linux, Android)에서도 실험해 보려는 논의도 본 적이 없다. 또한 Windows에 대해서는 이런 접근이 아예 옮겨갈 수 있는지조차 알 만큼 충분히 알지 못하지만, 모바일 앱처럼 런치 시간 압박이 Windows 앱에는 없을 거라고는 추측한다. (물론 Android는 수년 전부터 모든 의존성의 Java 클래스들을 미리 병합해 왔지만, 내가 아는 한 그 클래스들의 네이티브 의존성은 여전히 2등 시민이다.)
정적 링크는 일반적으로 빌드 시간이 더 느려서, 편집-빌드-디버그 사이클에서 성가실 수 있다. Mergeable libraries는 디버그 모드에서 동적 라이브러리처럼 동작한다.
정적 링크는 더 많은 최적화를 허용한다. 데드 코드 스트리핑뿐 아니라, 오브젝트 파일을 가로지르는 어떤 종류의 “똑똑한” 최적화(“link-time optimization”)도 라이브러리 경계를 넘어 적용할 수 있다. Mergeable libraries가 LTO와 함께 동작한다고 가정하지만, 확인하진 않았다.
정적 링크는 여러 파일을 디스크에 둘 필요 없이 모든 코드를 단일 실행 파일로 배포할 수 있게 해 준다.
단일 바이너리에 코드가 들어 있으면 절대 포인터 대신 상대 참조를 사용할 수 있는데, 이는 런치 시간의 “fix-up”을 피하게 해 준다. 정적 링크는 이론적으로 라이브러리 경계를 넘어 상대 참조를 허용하지만, 그러려면 클라이언트를 컴파일할 때 이를 약속해야 하거나, 프로그램이 이해할 수 있는 방식으로 절대 참조를 상대 참조로 바꿀 만큼 똑똑한 링커가 필요하다. Mergeable libraries는 병합되었을 때 여기서 정적 라이브러리처럼 동작한다고 가정한다.
같은 이유로, ASLR은 동적 라이브러리에서만 동작한다. 모든 코드가 하나의 실행 파일 안에 있으면, 주소 공간에서 함께 이동하므로 ASLR의 보안 이점이 줄어든다.
동적 라이브러리는 런타임에도 정체성(identity)을 가지므로, “FooKit을 기준으로 상대 경로에 있는 리소스 파일 X.svg 찾기” 같은 일을 할 수 있다. Apple OS는 이를 리소스 네임스페이싱 수단으로 사용하여 FooKit, BarKit, 메인 앱이 모두 “X.svg”라는 이름의 리소스를 갖더라도 충돌을 걱정하지 않게 해 준다. 이는 충분히 유용해서 같은 기본 기능이 SwiftPM에도 추가되었는데, 실제 동적 라이브러리 파일이 아니라 자동 생성된 유일 이름에 기반한다. Mergeable libraries는 원래 라이브러리 이름을 기록하는 방식으로 이를 처리하는 듯하지만, 이는 약간의 추가 간접 계층이다.
동적 라이브러리는 로드될 때 실행되는 코드를 가질 수 있고, 로더는 이를 의존성 순서대로 실행하도록 보장한다. 정적 라이브러리는 추가 동기화 작업 없이는 그 보장이 없다. 하지만 어쨌든 런치/로드 시간에 일을 하는 것은 피해야 한다. Mergeable libraries는 의존성 순서를 보존한다고 가정한다.
역사적으로 정적 라이브러리는 포맷 자체에 의존하는 라이브러리를 명시할 수 없고, 심볼만 명시할 수 있었다. 하지만 많은 환경이 Swift의 “autolinking” 정보처럼 추가 메타데이터를 넣어서 이를 우회하므로, 위에서 포맷 단점으로는 포함하지 않았다. 다만 이런 종류의 것은 심볼의 위치를 바꾸는 일을 방해하긴 한다.
주소 공간 배치 난수화 때문에, 현대 앱은 라이브러리가 앱 실행 때마다 같은 주소에 로드되지 않으므로, 이런 종류의 “fix-up”을 항상 해야 한다.↩︎
Swift는 하위 호환 배포(backwards-deployment) 목적을 위해 동적 라이브러리 로딩의 동적 성질을 사용한다. Swift stdlib이 OS에 들어 있는 Apple 플랫폼에서는 동적 링커가 /usr/lib 안의 libswiftCore.dylib을 찾는다. 더 오래된 OS에서는 거기서 찾지 못하고 앱 번들 안의 사본으로 폴백한다. 이는 OS 버전이 항상 우선하도록 하면서도 하위 호환 배포를 지원하는데, 3번 범주의 라이브러리를 1번 범주의 라이브러리로 바꾸는 한 가지 방법이다.↩︎
이 접근의 변형은, 호출되는 방식에 따라 동작이 달라지는 단일 실행 파일을 만드는 것이다. 간단한 예로, Apple 플랫폼에서 clang++는 clang에 대한 심볼릭 링크이지만, clang++를 사용하면 Clang은 자동으로 여러 C++ 옵션을 활성화한다. (BusyBox 프로젝트는 이를 훨씬 더 밀어붙여, 단일 실행 파일로 수백 개의 명령을 제공한다.) 하지만 실행 파일에 대한 심볼릭 링크는 모든 사용 사례에서 허용되지는 않을 수 있고, 누군가가 미리 심볼릭 링크를 해석(resolve)해 버릴 수도 있어서 다소 취약하다. 하드 링크는 그 문제는 없지만, 아카이빙과 복사 과정에서 보존되지 않는 경우가 많다. 또한 이런 방식으로 실행 파일과 _플러그인_을 결합하는 것은 보통 지원되지 않으며, 같은 라이브러리를 모두 로드하고 싶지 않은 경우의 문제도 여전히 해결하지 못한다.↩︎
왜 함수가 둘 이상의 파일에 복사될까? 특정 오브젝트 파일에 “소속”될 곳이 없기 때문이다. 헤더 파일에 정의된 static C 함수, C++ 템플릿과 Rust 제네릭의 인스턴스화, 그리고 Objective‑C와의 상호운용을 매끄럽게 하기 위해 Swift가 생성하는 암묵적 헬퍼 함수들이 그런 예다.↩︎
Apple의 dyld3 프로젝트는 앱 설치 시(또는 스토어에서 설치되지 않았다면 첫 실행 시)에 이런 정보의 상당 부분을 미리 계산하는 것이 목적이었다. 이는 정말로 도움이 되며, ASLR 이전 시대의 prebinding을 떠올리게 한다. 하지만 이는 사후적인 성격이 강하고, 프로그램이 이미 실행 중일 때 플러그인 스타일로 라이브러리를 로딩하는 경우에는 직접적으로 동작하지 않는다. (비슷한 최적화를 여기에도 적용하는 방법을 찾았을 수도 있지만, 더 까다롭다.)
이 글은 February 21, 2022에 게시되었고 Technical로 분류되어 있다. 태그: Linking