앞 장에서는 Foundation이 무엇이고 어디서 왔는지, 왜 통째로 다시 쓰였는지를 다뤘다. 이 장은 그 재작성이 남긴 경계선 — FoundationEssentials, FoundationInternationalization, Foundation이라는 세 모듈이 실제로 어디서 갈라지는지 — 를 파고든다. 문항은 네 개뿐이지만 두 번째 질문이 이 챕터의 핵심이다. "FoundationEssentials를 쓰면 iOS 앱이 가벼워진다"는 말은 플랫폼을 명시하지 않으면 거의 항상 틀린 말이 되고, 그 이유를 이 장에서 직접 컴파일해 확인한다.
Q1. FoundationEssentials / FoundationInternationalization / Foundation은 각각 무엇을 담고 언제 import하는가?
세 모듈은 포함관계다. FoundationEssentials는 Data·URL·JSONEncoder/Decoder·Predicate·AttributedString 같은 핵심 값 타입을 ICU(국제화 데이터) 없이 제공한다. FoundationInternationalization은 그 위에 Locale/Calendar/TimeZone의 로케일 인식 동작과 FormatStyle 계열 포매팅을 얹는데, 이때만 ICU를 끌어온다. Foundation은 이 둘을 재노출(re-export)하는 우산 모듈이고, Darwin에서는 여기에 레거시 Objective-C 클래스 표면 전체가 더 붙는다. 이 분할의 진짜 동기는 서버사이드·임베디드 Swift에서 몇 메가바이트짜리 ICU 데이터를 강제로 끌고 오고 싶지 않다는 것이다.
원리
ICU(International Components for Unicode)는 로케일별 달력 규칙, 날짜·숫자 포맷, 정렬 규칙 같은 국제화 데이터를 담은 라이브러리로, 데이터 세트만 수 메가바이트에 달한다. Apple 플랫폼에서는 OS가 이 데이터를 이미 갖고 있어 신경 쓸 일이 없지만, 서버 컨테이너나 임베디드 기기처럼 OS가 그런 걸 보장하지 않는 환경에서는 이 데이터가 고스란히 배포물 크기에 더해진다. "The Future of Foundation"이 재작성 동기 중 하나로 명시한 게 정확히 이 지점이다 — Swift 6 발표 블로그는 "바이너리 크기에 민감한 앱은 ICU 데이터를 생략한 FoundationEssentials 라이브러리를 import할 수 있다"고 못박는다.
내부 동작
세 모듈의 관계를 표로 정리하면 다음과 같다.
| 모듈 | 담는 것 | ICU | 언제 import하나 |
|---|---|---|---|
| FoundationEssentials | Data, URL/URLComponents, JSONEncoder/Decoder, Predicate, AttributedString, UUID, ProcessInfo 등 핵심 값 타입 | 불필요 | 서버·CLI·임베디드처럼 바이너리 크기·의존성에 민감한 타깃 |
| FoundationInternationalization | Locale/Calendar/TimeZone의 로케일 인식 동작, FormatStyle/ParseStrategy 계열 포매팅 | 필요 | 사람이 읽는 날짜·숫자·통화 포맷이 실제로 필요할 때만 |
| Foundation | 위 두 모듈을 재노출 + (Darwin) 레거시 NS* Objective-C 표면 전체 | Internationalization을 경유해 포함 | 대부분의 iOS/macOS 앱 — 사실상 항상 |
즉 Foundation은 별도 구현이 아니라 FoundationEssentials + FoundationInternationalization을 그대로 다시 내보내는(re-export) 계층이고, Darwin에서는 여기에 NSObject 기반 레거시 클래스와 Objective-C 브리징 표면이 더 붙는다(16장에서 다룬다). FoundationEssentials만 import해도 Calendar·Locale 타입 자체는 여전히 쓸 수 있지만, 로케일에 따라 실제로 달라지는 동작(요일 이름, 통화 기호, 복수형 규칙)은 FoundationInternationalization 없이는 온전히 동작하지 않는다.
실험 · 도구
Xcode 15+ SDK가 설치된 환경에서는 다음처럼 좁은 모듈만 단독 import해 컴파일해볼 수 있다.
swift -e 'import FoundationEssentials
let u = URL(string: "https://swift.org")!
print(u.host!)'이 챕터를 쓴 환경(Xcode.app 없이 커맨드라인 툴즈 SDK만 있는 macOS)에서는 위 명령이 error: no such module 'FoundationEssentials'로 실패했다 — FoundationEssentials가 Darwin에서 아무 데서나 바로 손에 잡히는 모듈이 아니라는 걸 직접 겪은 사례다(Q2에서 이 실패를 더 파고든다). SPM 패키지 안에서 swift-foundation을 명시적으로 의존성에 걸면 이 문제 없이 빌드된다.
프로젝트 적용
① 멀티플랫폼 패키지의 핵심 로직 타깃은 FoundationEssentials만 의존하도록 쪼갠다. 로케일 포매팅이 필요한 코드는 별도 타깃으로 분리한다.
let package = Package(
name: "MyKit",
targets: [
// 서버·CLI에서도 그대로 쓸 핵심 로직 — ICU 없이 컴파일된다
.target(
name: "MyKitCore",
dependencies: [] // FoundationEssentials는 툴체인이 암시적으로 제공
),
// 사람이 읽는 포매팅이 필요한 코드만 따로
.target(
name: "MyKitFormatting",
dependencies: ["MyKitCore"]
),
]
)② 소스 파일 안에서는 필요한 만큼만 좁게 import한다. Foundation 전체를 습관적으로 쓰면 로케일 포매팅을 전혀 안 쓰는 파일에서도 ICU 경로가 딸려온다는 신호를 놓치기 쉽다.
// MyKitCore/Parser.swift — 로케일 포매팅이 전혀 필요 없는 순수 파싱 로직
import FoundationEssentials // Data, URL, JSONDecoder만 있으면 충분
struct Parser {
func parse(_ data: Data) throws -> [String: Any] {
try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:]
}
}"FoundationEssentials는 Foundation의 기능 부분집합이라 거기 있는 건 전부 있다"는 정확하지 않다. Darwin의 Foundation에만 있고 FoundationEssentials엔 없는 심볼이 실제로 존재하며, Swift Forums에서 Apple 엔지니어도 이를 미해결 과제로 인정했다. 반대로 "FoundationInternationalization을 import하면 ICU 없이도 로케일 기능을 쓸 수 있다"도 틀렸다 — 그 모듈 자체가 ICU를 끌어오는 지점이다.
이건 요리 재료 세트와 비슷하다. FoundationEssentials는 "기본 재료 세트"(쌀, 소금, 계란)라 어디서든 가볍게 들고 다닐 수 있다. FoundationInternationalization은 여기에 "각 나라 향신료 세트"(ICU 데이터)를 더한 것이다 — 그 나라 맛을 내려면 필요하지만 무겁다. Foundation은 이 둘을 하나의 큰 장바구니에 담아준 것뿐이고, Darwin에서는 그 장바구니에 "예전에 쓰던 그릇들"(레거시 NS* 클래스)까지 같이 들어 있다. 비유가 깨지는 곳: 장바구니(Foundation)를 받아도 실제로 무게(바이너리 크기)가 늘어나는지는 그 장바구니를 내가 직접 들고 다니는지(정적 링크), 아니면 이미 부엌에 늘 있는 재료를 그냥 꺼내 쓰는지(동적 링크)에 달렸다 — 이건 Q2에서 다룬다.
꼬리 질문
FoundationEssentials만 import한 코드가 Foundation을 import한 코드와 섞이면 무슨 일이 일어나는가?
Foundation 안의 어떤 타입이 어느 서브모듈 소속인지 문서만 보고 알 수 있는가?
왜 Calendar와 Locale이라는 "같은 타입"이 두 모듈에 걸쳐 있는가? 아예 통째로 Internationalization에 두지 않은 이유는?
Q2. "FoundationEssentials를 쓰면 iOS 앱이 가벼워진다"가 대체로 틀린 이유는?
iOS·macOS에서 Foundation.framework는 OS와 함께 dyld 공유 캐시에 이미 올라가 있는 시스템 프레임워크라, 앱은 이걸 정적으로 링크하지 않고 동적으로 참조만 한다. 그래서 import Foundation을 import FoundationEssentials로 바꿔도, 이미 OS에 있는 코드를 앱 바이너리에 새로 욱여넣는 게 아니므로 IPA 크기가 의미 있게 줄지 않는다. "바이너리가 가벼워진다"는 이득은 Foundation이 실제로 산출물에 정적 링크되는 Linux 서버·Windows·임베디드 환경에 해당하는 이야기다.
원리
동적 링크(dynamic linking)는 실행 파일이 라이브러리 코드를 자기 안에 복사해 넣지 않고, 실행 시점에 이미 메모리에 있는(혹은 로드될) 공유 라이브러리를 가리키는 참조만 담는 방식이다. 정적 링크(static linking)는 반대로 라이브러리 코드 자체가 실행 파일 안에 그대로 복사돼 들어간다. 어느 쪽이든 "코드가 존재한다"는 사실은 같지만, 그 코드가 내 산출물의 크기에 더해지는지는 완전히 다르다.
내부 동작
Apple 플랫폼은 시스템 프레임워크·라이브러리를 하나의 거대한 파일로 미리 링크해 부팅 시 메모리에 매핑해두는 dyld 공유 캐시를 쓴다. Foundation.framework는 이 캐시 안에 있고, 기기의 모든 프로세스가 이 캐시를 공유해서 읽는다. 앱을 빌드하면 Foundation 심볼에 대한 참조만 앱 바이너리에 남고, 실제 구현 코드는 앱 번들 안 어디에도 없다 — 그래서 import Foundation이든 import FoundationEssentials든, Darwin에서는 최종적으로 같은 공유 캐시 안의 같은 프레임워크를 가리키게 된다.
Linux나 Windows에는 이런 시스템 전역 공유 캐시가 없다. Swift 툴체인이 제공하는 FoundationEssentials/FoundationInternationalization 라이브러리는 빌드 산출물에 직접 링크되며, FoundationInternationalization을 아예 빼고 FoundationEssentials만 쓰면 ICU 데이터를 포함한 그만큼의 코드·데이터가 산출물에서 빠진다. Swift Forums의 "FoundationEssentials on Darwin?" 스레드는 이 지점을 정확히 짚는다 — Apple 플랫폼에서 Foundation은 OS와 함께 배포되고 런치 시점에 동적 링크되므로 앱 바이너리 자체를 부풀리지 않는다는 것, 그리고 macOS에서는 Foundation에만 있고 FoundationEssentials엔 없는 심볼이 실제로 존재해 크로스플랫폼 패키지가 플랫폼별로 다르게 컴파일 에러를 내는 문제가 보고됐다는 것. Apple 엔지니어 Tony Parker는 이 스레드에서 "이해한다, 다만 단기적 해법은 없고 계속 고민 중"이라고 답했다 — 2026년 현재도 미해결 과제로 남아 있다.
실험 · 도구
Darwin에서는 otool -L로 직접 확인할 수 있다. import Foundation만 쓰는 최소 실행 파일을 만들어 링크 테이블을 찍어보면 된다.
// full_test.swift
import Foundation
let u = URL(string: "https://example.com")!
print(u.absoluteString)$ swiftc -O full_test.swift -o full_test
$ ls -l full_test
-rwxr-xr-x 1 user wheel 52248 7 27 18:24 full_test
$ otool -L full_test
full_test:
/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, ...)
/System/Library/Frameworks/Foundation.framework/Versions/C/Foundation (compatibility version 300.0.0, ...)
/usr/lib/swift/libswiftCore.dylib (...)
/usr/lib/swift/libswiftCoreFoundation.dylib (..., weak)
...바이너리는 52,248바이트뿐이고, otool -L은 Foundation.framework가 참조로만 걸려 있음을 그대로 보여준다 — 실제 Foundation 구현 코드는 이 52KB 안 어디에도 없다. 같은 환경에서 import FoundationEssentials만 쓰는 버전을 별도로 컴파일해 비교하려 했으나, 이 머신(Xcode.app 없이 커맨드라인 툴즈 SDK만 설치됨)에서는 error: no such module 'FoundationEssentials'로 실패했다 — 위 포럼 스레드가 지적하는 "FoundationEssentials가 Darwin에서 아직 매끄러운 1급 시민이 아니다"는 문제를 그대로 겪은 것이다. Xcode 15+ 전체 SDK가 있는 환경이라면 같은 비교를 swiftc -O ess_test.swift -o ess_test로 직접 재현하고, 두 바이너리의 ls -l 크기 차이가 (있다면) 몇 바이트 수준에 그치는지 확인해보길 권한다.
프로젝트 적용
① iOS 앱에서 FoundationEssentials로 바꾸는 실익은 "IPA 용량 절감"이 아니라 "레거시 NS* API 표면을 실수로 안 쓰게 강제하는 것", "크로스플랫폼 타깃과 소스를 공유할 때 컴파일이 걸러주는 것" 정도로 기대치를 낮춰야 한다.
// Shared/UserProfile.swift — iOS 앱과 서버 타깃이 공유하는 순수 모델 파일
// 여기서 FoundationEssentials만 쓰면 "실수로 UIKit·NSObject 브리징에 기대는 코드"를
// 컴파일 단계에서 걸러낼 수 있다 — iOS 바이너리 크기와는 무관한 이득이다
import FoundationEssentials
struct UserProfile: Codable, Sendable {
let id: UUID
let name: String
let joinedAt: Date
}② 진짜 크기 이득이 필요한 곳은 Linux 서버 타깃이다. Package.swift에서 그 타깃만 FoundationInternationalization을 빼면 ICU 데이터가 산출물에서 실제로 빠진다.
.executableTarget(
name: "APIServer",
dependencies: ["MyKitCore"]
// FoundationEssentials까지만 — FoundationInternationalization은 안 걸었다
// 로그·API 응답은 ISO 8601 같은 고정 포맷만 쓰고, 사람이 읽는 로케일 포맷은 안 쓴다는 전제
)"FoundationEssentials를 쓰면 iOS 앱 IPA가 작아진다"는 대체로 틀렸다. Foundation은 OS 내장·동적 링크라 어떤 서브모듈을 import하는지가 앱 바이너리 크기에 거의 영향을 주지 않는다. 반대로 "그러니 모듈을 나눠 쓰는 게 iOS에선 아무 의미가 없다"도 과장이다 — API 표면을 좁혀 크로스플랫폼 호환성을 컴파일 타임에 강제하는 이득은 iOS에서도 그대로 남는다.
아파트에 사는 것과 원룸에 혼자 사는 것의 차이라고 생각하면 된다. 아파트(iOS 기기)에는 관리사무소가 미리 설치해둔 큰 정수기(Foundation.framework)가 이미 복도에 있다. 내가 "저는 작은 컵 정수기만 쓸게요"(FoundationEssentials)라고 말해도, 건물 전체 무게(바이너리 크기)는 어차피 안 바뀐다 — 복도 정수기는 이미 거기 있고 내 방 안에 들어오지 않으니까. 반대로 혼자 사는 원룸(Linux 서버)에서는 정수기를 내가 직접 사서 방에 들여놔야 하니, 큰 정수기를 살지 작은 컵 정수기를 살지가 내 방 무게에 그대로 더해진다. 비유가 깨지는 곳: 현실 정수기는 안 쓰면 안 사면 그만이지만, FoundationEssentials 쪽 API가 애초에 없으면 그 기능 자체를 코드로 쓸 수 없다는 게 다르다 — "가벼움"이 아니라 "무엇을 쓸 수 있는가"의 문제로 이어진다.
꼬리 질문
그럼 iOS 앱에서 FoundationEssentials를 쓸 이유가 아예 없는가?
Foundation.framework 자체는 왜 앱마다 매번 새로 로드하지 않고 공유 캐시를 쓰는가?
이 실측(52,248바이트)이 항상 같은 숫자로 나오는가?
Q3. 2026년 현재 Linux·Windows의 Foundation은 어디까지 왔는가? Windows Workgroup은 왜 생겼나?
Linux는 2023~2024년 재작성 이후 Darwin과 같은 swift-foundation 코어를 공유해 실전에 쓸 만한 수준이고, 남은 차이는 Objective-C 런타임에 의존하는 소수 API(오토릴리즈 포인터를 쓰는 일부 레거시 메서드) 정도로 좁혀졌다. Windows는 뒤처져 있어서 2026-01-26 swift.org가 별도로 Windows Workgroup을 신설해 "Foundation과 Dispatch를 Windows 관용구에 맞게 개선"하는 걸 명시적 임무로 정했다 — 즉 2026년 현재도 Windows Foundation은 "완성"이 아니라 "적극적으로 개선 중"인 현재진행형 상태다.
원리
swift-foundation 재작성이 Darwin·Linux·Windows가 같은 핵심 로직을 공유하게 만든 건 맞지만, "같은 로직"과 "같은 완성도"는 다른 이야기다. 파일 시스템 경로 구분자, 프로세스·권한 모델, 네트워킹 스택처럼 OS마다 근본적으로 다른 부분은 아무리 코어를 공유해도 플랫폼별 어댑터 코드가 따로 필요하고, 그 어댑터의 성숙도가 플랫폼마다 다르게 진행된다.
내부 동작
| 플랫폼 | 2026년 현재 상태 | 남은 갭 |
|---|---|---|
| Linux | swift-foundation 코어를 Darwin과 공유, 실전 투입 가능한 수준 | 오토릴리즈 포인터 등 Objective-C 런타임 의존 API 일부 |
| Windows | 2020년부터 공식 지원 플랫폼, 코어는 공유 | 2026-01-26 Windows Workgroup 신설 — Foundation/Dispatch를 Windows 관용구에 맞게 개선하는 게 명시 임무, 진행 중 |
| Android 등 | Apple/swift.org 공식 지원 대상 아님 | Skip 같은 서드파티 이식 프로젝트만 존재 — Apple 공식 로드맵과는 무관 |
Windows Workgroup 신설 발표는 이 개선이 아직 끝나지 않았다는 것 자체를 공식적으로 인정하는 문서다 — 별도 워크그룹을 만들 필요가 있었다는 사실이, "이미 다 됐다"가 아니라 "구조적으로 더 손볼 게 남아 있다"는 신호다. Foundation 자체의 거버넌스는 2026년 기준 Foundation Workgroup(의장 Tina Liu, 투표권자 9인+의장 1인, 분기별 회의)이 맡아 커뮤니티 API 제안 리뷰와 프로젝트 방향을 정한다.
실험 · 도구
이 챕터는 Docker·리눅스 컨테이너를 직접 띄우지 않고 썼다 — 로컬에서 검증 가능한 범위를 넘어서는 주장이라, 위 표는 swift.org 공식 발표문과 GitHub 레포 자체를 1차 출처로 삼았다. 리눅스 환경이 있다면 다음처럼 직접 재현해볼 수 있다.
docker run --rm swift:6.2 swift -e '
import FoundationEssentials
print(URL(string: "https://swift.org")!.host!)
'
// macOS에서 같은 코드를 돌려 두 결과가 바이트 단위로 같은지 비교하면
// "통합 구현"이 실제로 같은 로직을 공유한다는 걸 체감할 수 있다프로젝트 적용
① Linux 서버 타깃을 채택할 때는 "Foundation이 Swift라 이제 다 된다"고 가정하지 말고, 실제로 쓸 API(특히 파일 시스템·프로세스·네트워킹 경계)가 그 플랫폼에서 되는지 먼저 확인한다.
#if os(Linux)
// Linux 전용 경로 확인이 필요한 API는 여기서 미리 검증하고 주석으로 남긴다
// 예: 특정 FileManager 리소스 값이 Linux에서 아직 nil을 돌려주는 경우가 있다
#endif② Windows 타깃을 고려 중이라면 2026년 현재도 "진행 중"이라는 전제 위에서, 필요한 API를 미니 스모크 테스트로 먼저 그 플랫폼에서 직접 빌드해 확인하기 전까지는 완전성을 가정하지 않는다.
// 이 함수만 별도 실행 파일로 만들어 실제 타깃 플랫폼(Windows)에서 빌드해본다
import Foundation
func smokeTest() {
let path = FileManager.default.currentDirectoryPath
print("현재 경로:", path)
// 이 한 줄이 실패 없이 도는지가, 문서보다 훨씬 믿을 만한 확인 방법이다
}
smokeTest()"Swift는 이제 Linux·Windows에서도 iOS와 완전히 동일한 Foundation을 지원한다"는 대체로 맞지만 완전하진 않다. Windows는 2026년 1월에야 전담 워크그룹이 생겨 개선 중인 현재진행형 상태이고, Linux도 Objective-C 런타임 의존 API 쪽은 여전히 갭이 있다. "Android도 공식 지원된다"는 2026년 중반 기준으로 틀렸다 — 서드파티 프로젝트 수준이다.
같은 레시피(swift-foundation 코어)를 세 집 부엌(Darwin, Linux, Windows)에서 쓰기로 했다고 하자. 레시피가 같아도 부엌마다 가스레인지 화력, 오븐 크기, 수도꼭지 위치가 달라서 요리사가 손볼 게 남는다. Linux 부엌은 거의 다 맞춰졌고, Windows 부엌은 아직 손볼 게 많아서 전담 팀(Windows Workgroup)이 따로 붙었다. 비유가 깨지는 곳: 진짜 부엌 개조는 한 번 끝내면 끝이지만, 소프트웨어의 "개선 중"은 계속 움직이는 목표라 이 챕터가 쓰인 2026년 7월 이후로도 계속 바뀔 수 있다.
꼬리 질문
swift-corelibs-foundation, swift-foundation, swift-foundation-icu는 각각 무슨 역할인가?
swift-foundation이 실제 핵심 로직(FoundationEssentials/Internationalization)을 담은 패키지이고, swift-foundation-icu는 그 핵심이 필요로 하는 ICU 데이터를 별도로 패키징한 레포다. swift-corelibs-foundation은 과거엔 Linux용 Foundation을 통째로 독자 재구현하던 프로젝트였지만, 현재는 swift-foundation의 모듈을 그대로 재노출하고 그 위에 레거시 NS* 클래스 기반 API만 얇게 얹는 호환 레이어로 역할이 바뀌었다."코어를 공유한다"는 게 정확히 어떤 버그를 없애준다는 뜻인가?
Windows Workgroup이 신설됐다는 건 Windows Foundation이 실무에 못 쓸 수준이라는 뜻인가?
Q4. 크로스플랫폼 Swift 코드에서 Foundation 의존을 어떻게 다뤄야 하는가?
#if canImport(FoundationEssentials)로 가능하면 좁은 모듈을 우선 import하고 없으면 Foundation으로 폴백하며, 여러 플랫폼이 공유하는 타깃에는 NS* 클래스나 UIKit 연동 같은 Darwin 전용 API를 아예 넣지 않는다. Package.swift에서 Foundation 의존은 Linux에서 결코 "공짜"가 아니라 실제로 산출물에 링크되는 코드이므로, 처음부터 최소 모듈만 쓰는 타깃 구조로 짠다. 그리고 CI가 실제로 Linux에서 빌드·테스트를 돌려야 한다 — Darwin에서 통과했다는 사실은 swift-corelibs 쪽 경로가 동작한다는 걸 증명하지 못한다.
원리
Q1~Q3에서 본 모듈 분할·플랫폼 성숙도 차이는 결국 "코드를 어떻게 짜야 여러 플랫폼에서 실제로 컴파일·동작하는가"라는 실무 문제로 수렴한다. 원칙은 단순하다 — 필요한 것만 좁게 의존하고, 그 의존이 모든 타깃에서 실제로 만족되는지 컴파일 타임과 CI 양쪽에서 확인한다.
내부 동작
canImport()는 컴파일 타임에 특정 모듈을 import할 수 있는지 검사하는 조건부 컴파일 지시문이다. 최신 SDK가 있는 Darwin 환경과 FoundationEssentials를 직접 제공하는 리눅스 툴체인에서는 참이 되고, 그렇지 않은 환경(가령 이 챕터를 쓴 커맨드라인 툴즈뿐인 macOS)에서는 거짓이 되어 Foundation 쪽으로 떨어진다.
#if canImport(FoundationEssentials)
import FoundationEssentials
#else
import Foundation
#endif
// 이 아래 코드는 두 모듈 모두에 있는 API로만 작성해야
// 어느 브랜치로 컴파일되든 동작이 같다는 걸 보장할 수 있다
struct RequestID: Codable, Sendable {
let value: UUID
}타깃 구성 원칙을 표로 정리하면 다음과 같다.
| 타깃 구성 | 권장 의존 | 피해야 할 것 |
|---|---|---|
| 순수 로직·모델 (멀티플랫폼 공유) | FoundationEssentials 우선, 안 되면 조건부 Foundation | NS* 클래스, UIKit/AppKit, ObjC 런타임 의존 API |
| 로케일 포매팅이 필요한 공유 타깃 | FoundationInternationalization | DateFormatter 같은 레거시 클래스에 직접 의존 |
| Darwin 전용 타깃(UI 등) | Foundation — 시스템 프레임워크, 동적 링크라 자유롭게 써도 됨 | — |
| CI | Darwin + Linux 컨테이너 양쪽에서 실제 빌드·테스트 | Darwin 통과만으로 "리눅스도 될 것"이라 가정 |
실험 · 도구
지금 작성 중인 소스가 실제로 어느 모듈에 의존하는지는 swiftc -typecheck를 여러 -D 조합으로 돌려 확인할 수 있고, 패키지 전체는 각 플랫폼용 툴체인으로 swift build를 실제로 돌리는 것만이 확실하다. 이 챕터의 검증 범위(Docker 금지)에서는 아래 스켈레톤을 직접 실행하지는 않았다 — 리눅스 CI가 있다면 그대로 파이프라인에 넣어볼 수 있다.
# GitHub Actions 예 — 개념만
# jobs:
# test-darwin:
# runs-on: macos-latest
# test-linux:
# runs-on: ubuntu-latest
# container: swift:6.2
# 두 잡이 모두 통과해야 "크로스플랫폼"이라 말할 수 있다프로젝트 적용
① Package.swift에서 타깃을 처음부터 분리해, 공유 로직 타깃이 Darwin 전용 API에 접근할 길 자체를 없앤다.
let package = Package(
name: "SharedKit",
platforms: [.iOS(.v17), .macOS(.v14)],
targets: [
// 서버·클라이언트가 공유 — Darwin 전용 API를 쓰면 Linux 빌드에서 즉시 에러가 난다
.target(name: "SharedKitCore"),
// iOS 앱에서만 링크 — 여기서는 Foundation·UIKit을 자유롭게 쓴다
.target(name: "SharedKitUIKit", dependencies: ["SharedKitCore"]),
]
)② "일단 import Foundation을 써두고 안 쓰는 심볼은 링커가 알아서 뺄 것"이라는 가정에 기대지 않는다. Swift 정적 링커가 상당 부분의 미사용 코드를 제거하긴 하지만, import Foundation 자체가 FoundationInternationalization(그리고 ICU)까지 끌어오는 경로를 여는 것이므로, 처음부터 좁게 import하는 편이 그 경로를 아예 안 여는 확실한 방법이다.
// ❌ 나쁜 예 — 습관적으로 전체를 끌어온다, Linux 산출물에 ICU까지 딸려올 수 있다
import Foundation
// ✅ 좋은 예 — 이 파일이 실제로 쓰는 것만 좁게 선언
#if canImport(FoundationEssentials)
import FoundationEssentials
#else
import Foundation
#endif
func normalizedID(_ raw: String) -> UUID? {
UUID(uuidString: raw)
}"Darwin에서 빌드·테스트가 통과했으면 크로스플랫폼 코드로 봐도 된다"는 틀렸다. Darwin은 Foundation.framework(Objective-C 표면 포함)로 컴파일되고, Linux는 swift-corelibs-foundation 경로로 컴파일된다 — 두 경로가 같은 코어를 공유한다고 해도, 실제로 그 플랫폼의 툴체인으로 빌드해보기 전까지는 컴파일조차 되는지 알 수 없다.
이사할 때 "집 전체를 통째로 옮겨주세요"라고 하는 이삿짐센터(import Foundation)와, "이 방 물건만 정확히 이거, 이거요"라고 콕 집어 말하는 것(import FoundationEssentials + 필요한 것만)의 차이다. 통째로 옮기면 필요 없는 가구까지 다 실려 오고, 다음 집(다른 플랫폼)에 그 가구가 안 들어갈 수도 있다. 비유가 깨지는 곳: 실제 이삿짐은 눈으로 보고 뺄 수 있지만, 소프트웨어 의존성은 실제로 그 플랫폼에서 빌드해보기 전까지 뭐가 안 들어가는지 눈에 안 보인다는 게 더 위험하다.
꼬리 질문
`canImport(FoundationEssentials)`가 참인데도 코드가 컴파일 안 되는 경우가 있는가?
canImport는 "그 모듈이 존재하는가"만 확인하지, 그 모듈 안에 내가 쓰려는 특정 심볼이 있는가까지 보장하지 않는다. Q1의 "흔한 오해"에서 다뤘듯 어떤 심볼이 정확히 어느 모듈에 있는지가 문서만으로 완전히 파악되지 않는 상태라, 실제로는 그 브랜치를 각 플랫폼에서 직접 컴파일해 확인하는 과정이 여전히 필요하다.Package.swift의 `platforms` 선언이 크로스플랫폼 안전성을 보장해주는가?
platforms는 "이 패키지가 어느 Apple 플랫폼의 몇 버전 이상을 지원한다"는 최소 배포 버전 선언일 뿐, Linux나 Windows 빌드 가능 여부와는 관계가 없다. Linux/Windows 지원은 그 선언과 무관하게 실제 소스 코드가 그 플랫폼에서 컴파일되는지로만 결정된다.테스트도 Linux CI에서 실제로 돌려야 하는 이유는 무엇인가?
Foundation.framework 구현 경로만 검증한다. Linux는 swift-corelibs-foundation을 거치는 별도 경로이고, 코어 로직을 공유한다 해도 플랫폼별 어댑터 코드(파일 시스템, 프로세스 등)는 그 경로에서만 실행된다. 그 경로에 버그가 있어도 Darwin 테스트로는 절대 드러나지 않는다.