최근 회사에서 모듈화를 진행하고 있습니다.
그런데 tuist도 제대로 모르면서 모듈화라뇨.. 신입인 저에겐 공부해야할게 산더미군요
기존 프로젝트는 레이어별 모듈화로 이루어져 있었습니다. App, Domain, Core, Util. 구조 자체는 깔끔해 보였어요.
그런데 프로젝트가 점점 커지면서 문제가 슬슬 보이기 시작했습니다.
빌드 속도가 말도 안되게 느려졌습니다.
Home 화면 버튼 하나를 수정했을 뿐인데, Domain 220개 파일과 Core 154개 파일이 전부 다시 컴파일되는 거예요. 레이어별 모듈화의 구조적 문제입니다. 한 레이어의 어느 파일이 바뀌면, 그 레이어에 의존하는 모든 것이 다시 컴파일되어야 합니다.

주변의 도움도 받고 그러다보니 자연스럽게 이런 이야기가 나오기 시작했습니다.
"이제 Feature별 모듈화를 해야하지 않을까?"
기존에도 tuist를 썼지만 제대로 이해안하고
"xcodeproj 충돌을 막아주고, generate 딸깍으로 Project.swift 기반으로 프로젝트를 자동 생성해주는 도구."
틀린 말은 아닙니다. 그런데 Feature 모듈화를 진행하다 보니, Tuist가 훨씬 더 강력한 역할을 한다는 걸 알게 되었습니다.
Tuist는 전체 모듈 의존성 그래프를 소유하는 빌드 시스템 오케스트레이터입니다.
어떤 모듈이 어떤 모듈에 의존하는지, 어떤 컴파일 플래그가 적용되는지, Swift 버전은 무엇인지 — 이 모든 정보를 Project.swift 한 곳에서 관리하죠.
Tuist의 Binary Cache는 소스가 바뀌지 않은 모듈을 매번 다시 컴파일하지 않도록, 컴파일된 결과물을 캐싱해서 재사용하는 기능입니다.
구체적인 산출물 형태나 전략은 Tuist 버전과 설정에 따라 달라질 수 있지만, 핵심은 “안정된 모듈을 소스 대신 빌드 결과물로 재사용한다”는 점입니다.
XCFramework vs SPM — 뭘 써야 하지?
Feature 모듈화 방식을 고민하다 보니 자연스럽게 이 질문이 나왔습니다.
"XCFramework로 할까, SPM으로 할까?"
처음에는 둘이 같은 레이어의 선택지라고 생각했습니다. 그런데 공부해 보니 완전히 다른 레이어의 개념이었습니다.
SPM은 Swift 패키지의 타깃, 의존성, 소스 위치를 정의하고 빌드까지 연결해주는 패키지 매니저입니다.
Package.swift에 URL이나 local path를 선언하면 해당 패키지를 가져오고, 빌드 시점에 소스 기반으로 컴파일합니다.
XCFramework는 "소스 없이 배포 가능한 컴파일된 결과물 포맷"입니다.
이미 컴파일된 바이너리가 들어있기 때문에, 사용하는 쪽에서는 해당 모듈의 소스를 다시 컴파일하지 않고 링크해서 사용합니다.
그래서 두 가지는 서로 대체 관계가 아닙니다.
한 프로젝트 안에서 공존하는 경우가 많아요.
외부 라이브러리(TCA, Moya 등)는 SPM으로 소스를 가져오고, 내부의 안정된 Domain Feature는 XCFramework로 pre-build해서 팀 전체가 공유하는 방식이죠
XCFramework 관련 자료를 찾다 보면 꼭 이런 설정이 등장합니다.
Build Libraries for Distribution = YES
처음엔 그냥 체크하고 넘어갔습니다.
이게 뭘까요? 모르신다면 이 글을 끝까지 읽어야하는 개발자입니다. that's me ~! 🤷🏻♀️
이 설정을 이해하려면 먼저 .swiftmodule이 무엇인지 알아야 합니다.
.swiftmodule — 컴파일러끼리의 언어

Swift 소스를 컴파일하면 .swiftmodule 파일이 생성됩니다. 이건 사람이 읽는 텍스트가 아닙니다.
컴파일러가 읽는 바이너리 메타데이터입니다.
import Domain을 한다면 도메인 모듈의 타입 정보를 어디선가 읽어야 합니다. 소스 파일 전체를 다시 파싱하는건 비효율적이니 미리 정의된 .swiftmodule을 읽어서 음 해당 프로토콜을 이런 public method가 있어~ 하는 거죠
문제는 이 .swiftmodule이 특정 Swift 컴파일러 버전에 강하게 결합되어 있다는 점입니다.
Swift 5.7 Compiler ↔ Swift 5.7 .swiftmodule
컴파일러 버전이 달라지면 내부 바이너리 표현 방식이 바뀌기 때문에, 새 컴파일러는 이전 버전의 .swiftmodule을 읽지 못합니다. 그래서 예전에는 이런 에러가 흔했습니다.
Module compiled with Swift 5.7 cannot be imported by Swift 5.9 compiler
같은 Swift인데 왜 import가 안 되냐고요?
.swiftmodule은 사람이 쓰는 소스 코드가 아니라, 특정 컴파일러 버전의 내부 포맷으로 직렬화된 데이터이기 때문입니다.
.swiftinterface — 텍스트로 쓴 공개 계약서
Apple은 이 문제를 해결하기 위해 새로운 개념을 도입합니다.
"바이너리 메타데이터 말고, 텍스트 기반으로 공개 API를 저장하자."
그게 바로 .swiftinterface입니다.
.swiftinterface는 모듈의 public API를 Swift 소스 문법 그대로 텍스트로 적어둔 파일입니다.
텍스트 기반 interface이기 때문에, 같은 Swift 5 계열의 더 새로운 컴파일러가 이 파일을 읽고 현재 컴파일러 버전에 맞는 module 정보를 다시 만들 수 있습니다.
파일을 열어보면 이런 내용이 들어있습니다.
// swift-interface-format-version: 1.0
// swift-compiler-version: Apple Swift version 5.10
// swift-module-flags: -module-name Domain
public protocol 시간옵저빙Repository {
func observe() -> AsyncStream<현재시간>
}
// internal class 시간옵저빙RepositoryImpl → 여기에 없습니다
두 가지가 중요합니다.
첫째, internal이나 private 심볼은 여기에 없습니다. public API만 노출됩니다.
덕분에 내부 구현(파싱 로직, 캐싱 로직)을 숨길 수 있어요.
둘째, swift-compiler-version이 명시되어 있습니다.
다만 버전 호환 문제의 핵심 원인은 이 주석이 아니라, 기존 .swiftmodule이 특정 Swift 컴파일러 내부 포맷에 강하게 의존했다는 점입니다.
이 개념을 Module Stability라고 부릅니다. "다른 Swift 컴파일러 버전도 이 모듈을 읽을 수 있다"는 보장이에요.
잠깐, ABI는 또 뭐야?

Module Stability를 이해했다고 생각했는데, 자료마다 ABI Stability라는 단어가 함께 나옵니다.
다른 개념인가요?
네, 다릅니다~
그리고 이 둘을 구분하는 게 BUILD_LIBRARY_FOR_DISTRIBUTION을 진짜로 이해하는 핵심입니다.
Module Stability는 컴파일 타임 문제입니다.
"다른 버전의 컴파일러가 이 모듈의 타입 정보를 읽을 수 있는가?"
ABI Stability는 런타임 문제입니다.
"서로 다른 버전으로 컴파일된 바이너리끼리 실제로 함께 실행될 수 있는가?"
API vs ABI — 완전히 다른 레이어
흔히 헷갈리는 개념이라 명확히 짚고 넘어갈게요.
API는 소스 코드 레벨의 계약입니다. 개발자가 코드에서 보는 것들이에요.
func fetchUser(id: String) -> User
ABI는 컴파일된 이후, 기계어 레벨의 계약입니다.
컴파일러와 런타임 사이의 약속이에요.
- 함수를 호출할 때 어느 레지스터에 인자를 넣는가 (calling convention)
- 구조체를 메모리에 어떻게 배치하는가 (memory layout)
- 프로토콜 dispatch를 어떻게 처리하는가 (witness table 구조)
- 심볼 이름을 어떻게 인코딩하는가 (symbol mangling)
API는 같아도 ABI가 다를 수 있습니다.
소스 코드가 동일하더라도, 두 바이너리를 컴파일한 환경이 다르면 런타임에 서로를 올바르게 호출하지 못할 수 있어요.
Swift 초기에는 ABI가 불안정했습니다
Swift 초기에는 이 ABI 규칙이 버전마다 계속 바뀌었습니다. 그래서 앱마다 Swift 런타임을 직접 포함해서 배포해야 했어요.
[My App - Swift 1.x 시절]
├── App Binary
├── Swift Runtime ← 앱이 직접 들고 다님
├── Swift Standard Library
└── Swift Metadata
앱 용량이 커지는 건 물론이고, 이 상태에서는 미리 컴파일된 라이브러리(.xcframework)를 안정적으로 배포하는 게 불가능했습니다.
런타임 규칙이 바뀔 때마다 깨지니까요.
Swift 5에서 ABI가 안정화됩니다
Swift 5에서 Apple은 ABI Stable을 선언합니다.
"Swift 내부 바이너리 규칙을 앞으로 안정적으로 유지하겠다." 👨🏽⚖️
이후부터는 Swift 런타임이 iOS 자체에 내장되었습니다.
[iOS 시스템]
└── Swift Runtime ← OS가 가지고 있음
[My App - Swift 5 이후]
└── App Binary만 ← 런타임 제거, 앱 용량 감소
그리고 이 안정성 덕분에, 미리 컴파일된 XCFramework를 다른 환경에서도 안전하게 사용할 수 있게 되었습니다.
결국 BUILD_LIBRARY_FOR_DISTRIBUTION은 무엇인가??

자 그래서 아직 저 설정이 뭐였는지 설명이 부족하죠?
이 옵션을 켜면 두 가지 일이 동시에 일어납니다.
첫째, .swiftinterface 파일이 생성됩니다.
컴파일 결과물로 .swiftmodule (바이너리) 대신 사람이 읽을 수 있는 텍스트 형태의 공개 API 계약서가 만들어집니다.
이게 Module Stability를 제공합니다.
둘째, -enable-library-evolution 플래그가 켜집니다.
이 플래그는 컴파일러에게 "이 모듈의 ABI를 미래 버전과도 호환되도록 보수적으로 만들어라"고 지시합니다.
구조체의 레이아웃이나 public API 접근 방식 등을 미래 변경에 대비할 수 있는 방식으로 컴파일합니다.
예를 들어 public struct가 기본적으로 resilient하게 취급되어, 라이브러리 작성자가 나중에 일부 구현을 바꿔도 기존 클라이언트 바이너리와의 호환성을 유지할 수 있게 합니다.
두 가지가 동시에 필요한 이유가 있습니다.
.swiftinterface만 있으면 컴파일 타임에 타입 체크는 통과하지만,
실제로 링크된 바이너리가 런타임에 ABI 불일치로 깨질 수 있습니다.
반대로 -enable-library-evolution만 있고 .swiftinterface가 없으면 다른 버전 컴파일러가 모듈을 읽지 못합니다.
둘 다 있어야 컴파일 타임과 런타임 모두에서 안전한 배포가 가능합니다.
전체 흐름을 다시 보면
신기하게도 시작은 그냥 "빌드가 느리다"는 현실적인 불편함이었습니다. 그런데 꼬리를 계속 잡다 보니 결국 여기까지 오게 되었습니다.
빌드 느려짐
→ Feature 모듈화 필요
→ XCFramework vs SPM 고민
→ Tuist Binary Cache 이해
→ BUILD_LIBRARY_FOR_DISTRIBUTION 설정 등장
→ .swiftinterface와 Module Stability
→ ABI Stability와 Swift Runtime 역사
그리고 이 흐름을 이해하고 나면, 예전에는 그냥 체크하던 설정들이 완전히 다르게 보이기 시작합니다
References
'iOS' 카테고리의 다른 글
| Swift 6 Concurrency Q&A 정리: isolation부터 Sendable까지 (0) | 2026.04.25 |
|---|---|
| 푸시 알람을 어떻게 설계할까? (2) | 2025.12.21 |
| iOS 이미지 포맷과 압축 방식 - 사진은 HEIC인데 스크린샷은 왜 PNG일까? (0) | 2025.12.17 |
| UIKit / SwiftUI에서는 무슨 디자인 패턴을 사용해야할까? (0) | 2025.10.17 |
| URLSession 한 줄 뒤에 숨겨진 것들: DNS부터 전자기파까지 (2) | 2025.10.11 |