← Swift & Foundation 로드맵 15 / 33

15 · 모듈 분할과 크로스플랫폼

FoundationEssentials·FoundationInternationalization·Foundation 세 모듈이 실제로 무엇을 가르는지, Darwin에서 동적 링크되는 Foundation이 왜 앱 바이너리 크기를 거의 바꾸지 않는지 직접 측정하고, 2026년 Linux·Windows Foundation의 성숙도까지.
진행률
0 / 0 완료

앞 장에서는 Foundation이 무엇이고 어디서 왔는지, 왜 통째로 다시 쓰였는지를 다뤘다. 이 장은 그 재작성이 남긴 경계선FoundationEssentials, FoundationInternationalization, Foundation이라는 세 모듈이 실제로 어디서 갈라지는지 — 를 파고든다. 문항은 네 개뿐이지만 두 번째 질문이 이 챕터의 핵심이다. "FoundationEssentials를 쓰면 iOS 앱이 가벼워진다"는 말은 플랫폼을 명시하지 않으면 거의 항상 틀린 말이 되고, 그 이유를 이 장에서 직접 컴파일해 확인한다.

Q1. FoundationEssentials / FoundationInternationalization / Foundation은 각각 무엇을 담고 언제 import하는가?

🔑 30초 답변

세 모듈은 포함관계다. FoundationEssentialsData·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하나
FoundationEssentialsData, URL/URLComponents, JSONEncoder/Decoder, Predicate, AttributedString, UUID, ProcessInfo 등 핵심 값 타입불필요서버·CLI·임베디드처럼 바이너리 크기·의존성에 민감한 타깃
FoundationInternationalizationLocale/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 없이는 온전히 동작하지 않는다.

세 모듈의 포함 관계 Foundation Essentials + Internationalization 재노출 (Darwin) 레거시 NS* Objective-C 표면 포함 레거시 NS* Darwin 전용 FoundationInternationalization Locale · Calendar · TimeZone · FormatStyle ICU 필요 FoundationEssentials Data · URL · JSON · Predicate… ICU 없음 ICU 데이터 로케일 규칙
FoundationEssentials가 ICU 없는 핵심을 담고, ICU를 끌어온 FoundationInternationalization이 그 위에 얹히며, Foundation은 이 둘을 재노출하고 Darwin에서는 레거시 NS* 표면을 추가로 얹는다.

실험 · 도구

Xcode 15+ SDK가 설치된 환경에서는 다음처럼 좁은 모듈만 단독 import해 컴파일해볼 수 있다.

FoundationEssentials만 단독으로 import — Xcode SDK 필요
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만 의존하도록 쪼갠다. 로케일 포매팅이 필요한 코드는 별도 타깃으로 분리한다.

Package.swift — 핵심 타깃과 포매팅 타깃을 분리
let package = Package(
    name: "MyKit",
    targets: [
        // 서버·CLI에서도 그대로 쓸 핵심 로직 — ICU 없이 컴파일된다
        .target(
            name: "MyKitCore",
            dependencies: []   // FoundationEssentials는 툴체인이 암시적으로 제공
        ),
        // 사람이 읽는 포매팅이 필요한 코드만 따로
        .target(
            name: "MyKitFormatting",
            dependencies: ["MyKitCore"]
        ),
    ]
)

소스 파일 안에서는 필요한 만큼만 좁게 import한다. Foundation 전체를 습관적으로 쓰면 로케일 포매팅을 전혀 안 쓰는 파일에서도 ICU 경로가 딸려온다는 신호를 놓치기 쉽다.

로케일 포매팅이 필요 없는 파일은 Essentials만으로 충분
// 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이 FoundationEssentials를 재노출하는 우산이라 같은 타입(Data, URL 등)을 가리키기 때문이다. 다만 어느 한쪽 모듈에만 있는 심볼(Darwin Foundation 전용 API, 혹은 반대로 아직 Foundation에 안 딸려 온 Essentials 전용 API)을 서로 다른 파일에서 각각 가정하면, 크로스플랫폼 빌드에서만 컴파일 에러가 갈리는 상황이 생길 수 있다.
쉽게 말하면 같은 재료(Data, URL)를 쓰는 두 요리사는 서로 잘 협업하지만, 한 요리사만 갖고 있는 특별한 향신료를 다른 요리사가 당연히 있는 줄 알고 레시피에 적으면 그 요리사의 부엌(플랫폼)에서만 요리가 안 되는 것과 같다.
Foundation 안의 어떤 타입이 어느 서브모듈 소속인지 문서만 보고 알 수 있는가?
2026년 현재도 완전히는 아니다. Swift Forums에서 Apple 엔지니어 Tony Parker 본인이 "이해한다, 다만 단기적 해법은 없고 계속 고민 중"이라고 답했을 정도로, 어떤 심볼이 FoundationEssentials에 있고 없는지를 문서만으로 파악하기 어렵다는 게 공식적으로 인정된 상태다. 실무에서는 직접 import해서 컴파일해보는 게 가장 빠르다.
쉽게 말하면 마트에 어떤 재료가 "기본 코너"에 있고 어떤 게 "수입 코너"에 있는지 안내판이 완벽하지 않아서, 직접 가서 찾아봐야 확실히 아는 것과 같다.
왜 Calendar와 Locale이라는 "같은 타입"이 두 모듈에 걸쳐 있는가? 아예 통째로 Internationalization에 두지 않은 이유는?
타입 자체(구조체 정의, 그레고리력 기준의 기본 산술 같은 것)는 ICU 없이도 동작할 수 있어 FoundationEssentials에 둘 수 있지만, 실제 로케일에 따라 달라지는 세부 동작(요일 이름, 비-그레고리력 계산, 복수형 규칙)은 ICU 데이터가 있어야만 정확하다. 타입과 "그 타입의 로케일 인식 동작"을 분리해 최소한의 타입만 가볍게 쓸 수 있는 길을 열어둔 것이다.
쉽게 말하면 달력이라는 "물건"(타입)은 가볍게 들고 다닐 수 있지만, 그 달력에 "이 나라 공휴일까지 표시해줘"(로케일 인식 동작)라고 하려면 그 나라 정보가 담긴 두꺼운 책(ICU 데이터)이 따로 필요한 것과 같다.

Q2. "FoundationEssentials를 쓰면 iOS 앱이 가벼워진다"가 대체로 틀린 이유는?

🔑 30초 답변

iOS·macOS에서 Foundation.framework는 OS와 함께 dyld 공유 캐시에 이미 올라가 있는 시스템 프레임워크라, 앱은 이걸 정적으로 링크하지 않고 동적으로 참조만 한다. 그래서 import Foundationimport 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년 현재도 미해결 과제로 남아 있다.

같은 import, 다른 링크 — 그래서 크기 효과가 다르다 Darwin (iOS·macOS) Linux (서버) 앱 바이너리 52,248B 실측 — Foundation 코드 없음 동적 링크(참조만) Foundation.framework dyld 공유 캐시 OS가 미리 로드, 모든 프로세스가 공유 import 대상 안 바꿔도 여기 크기는 그대로 앱 바이너리 크기 = 앱 코드 + 링크된 모든 것 FoundationEssentials 코드 바이너리 안에 실제로 포함 (ICU까지 더하면 더 커짐)
Darwin에서는 앱 바이너리가 Foundation.framework를 dyld 공유 캐시에 참조만 하지만, Linux에서는 링크된 코드가 바이너리 안에 실제로 들어가 크기에 더해진다.

실험 · 도구

Darwin에서는 otool -L로 직접 확인할 수 있다. import Foundation만 쓰는 최소 실행 파일을 만들어 링크 테이블을 찍어보면 된다.

실측 대상 — import Foundation 최소 실행 파일
// full_test.swift
import Foundation
let u = URL(string: "https://example.com")!
print(u.absoluteString)
컴파일 후 크기와 링크 테이블 — 실제로 얻은 출력 그대로 (Swift 6.2.1 / arm64 / macOS 26 SDK)
$ 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 -LFoundation.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 표면을 실수로 안 쓰게 강제하는 것", "크로스플랫폼 타깃과 소스를 공유할 때 컴파일이 걸러주는 것" 정도로 기대치를 낮춰야 한다.

iOS 앱에서는 바이너리 크기 목적이 아니라 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 데이터가 산출물에서 실제로 빠진다.

Linux 서버 타깃 — 로케일 포매팅이 필요 없다면 Internationalization을 아예 뺀다
.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를 쓸 이유가 아예 없는가?
크기 목적으로는 없다고 봐도 무방하지만, 완전히 무의미하진 않다. 서버·클라이언트가 모델 코드를 공유하는 멀티플랫폼 패키지에서 iOS 타깃도 같은 소스를 쓰게 하려면, 그 소스가 Linux에서도 컴파일되도록 처음부터 FoundationEssentials 범위 안에서 작성해야 한다. 이건 iOS 바이너리를 위한 최적화가 아니라 "이 코드가 다른 플랫폼에서도 그대로 컴파일된다"는 걸 컴파일러가 강제해주는 것이다.
쉽게 말하면 다이어트(용량 절감)를 위해서가 아니라, 여러 나라에서 통용되는 레시피(코드)를 짜기 위해 일부러 특정 나라에서만 나는 재료(Darwin 전용 API)를 빼고 요리하는 것과 같다.
Foundation.framework 자체는 왜 앱마다 매번 새로 로드하지 않고 공유 캐시를 쓰는가?
같은 프레임워크를 프로세스마다 각자 디스크에서 읽고 링크하면 부팅·앱 실행마다 반복 비용이 든다. dyld 공유 캐시는 시스템 프레임워크들을 미리 하나로 링크해 메모리에 매핑해두고, 모든 프로세스가 같은 물리 메모리 페이지를 가리키게 해 이 비용을 없앤다. 앱 입장에서는 그 코드가 이미 메모리에 있으니 "링크"가 사실상 주소만 채워 넣는 매우 가벼운 작업이 된다.
쉽게 말하면 아파트 주민 100명이 각자 정수기를 사는 대신, 관리사무소가 복도에 정수기 하나를 놔두고 다 같이 쓰게 하는 것과 같다. 다 같이 쓰니 훨씬 효율적이다.
이 실측(52,248바이트)이 항상 같은 숫자로 나오는가?
아니다. 이 숫자는 이 챕터를 작성한 특정 환경(Swift 6.2.1, arm64, macOS 26 SDK, 최적화 빌드)에서 나온 값이라, Swift 버전·SDK·아키텍처·최적화 옵션이 바뀌면 달라진다. 이 실험에서 중요한 건 정확한 바이트 수가 아니라 "그 바이트 수 안에 Foundation 구현 코드가 없고, otool -L에 참조만 남는다"는 구조 자체다.
쉽게 말하면 오늘 잰 내 몸무게 숫자 자체보다, "내가 먹은 밥이 내 몸이 아니라 냉장고에 남아있다"는 사실이 이 실험의 핵심이라는 뜻이다.

Q3. 2026년 현재 Linux·Windows의 Foundation은 어디까지 왔는가? Windows Workgroup은 왜 생겼나?

🔑 30초 답변

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년 현재 상태남은 갭
Linuxswift-foundation 코어를 Darwin과 공유, 실전 투입 가능한 수준오토릴리즈 포인터 등 Objective-C 런타임 의존 API 일부
Windows2020년부터 공식 지원 플랫폼, 코어는 공유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차 출처로 삼았다. 리눅스 환경이 있다면 다음처럼 직접 재현해볼 수 있다.

Linux 컨테이너에서 확인하는 법 (이 챕터에서 직접 실행하지는 않았다)
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만 얇게 얹는 호환 레이어로 역할이 바뀌었다.
쉽게 말하면 swift-foundation은 요리 본체, swift-foundation-icu는 향신료 세트, swift-corelibs-foundation은 그 요리를 예전 그릇(NS* 클래스)에 담아주는 역할로 나뉜 것이다.
"코어를 공유한다"는 게 정확히 어떤 버그를 없애준다는 뜻인가?
2023년 이전에는 Darwin Foundation(Objective-C)과 Linux swift-corelibs-foundation(독자 Swift/C 재구현)이 완전히 다른 코드베이스였다. 같은 API를 두 번 구현했으니 동작이 플랫폼마다 미묘하게 어긋나는 일이 흔했고, 그 버그를 두 곳에서 각각 고쳐야 했다. 지금은 두 플랫폼이 물리적으로 같은 소스 코드(swift-foundation)를 컴파일해 쓰므로, 이 "이중 구현으로 인한 divergence" 자체가 애초에 발생할 수 없는 구조가 됐다.
쉽게 말하면 예전엔 같은 요리를 두 요리사가 각자 감으로 따라 만들어서 맛이 자꾸 달랐는데, 이제는 한 명이 만든 요리를 그대로 나눠 담는 것과 같다.
Windows Workgroup이 신설됐다는 건 Windows Foundation이 실무에 못 쓸 수준이라는 뜻인가?
그렇게까지 단정할 근거는 없다. 2020년부터 공식 지원 플랫폼이었고 코어 로직도 이미 공유되고 있으므로 기본기는 갖춰져 있다. 워크그룹 신설은 "전혀 안 된다"가 아니라 "Windows 개발자 관용구(경로, 프로세스 모델 등)에 맞춰 다듬을 여지가 아직 많이 남아 있어서 전담 조직을 붙였다"는 뜻으로 읽는 게 정확하다. 실무 판단은 실제 쓸 API를 직접 빌드해서 확인하는 수밖에 없다.
쉽게 말하면 새로 이사 온 집이 살 수는 있지만 아직 손볼 곳이 많아서 인테리어 팀을 불렀다는 뜻이지, 그 집이 무너지고 있다는 뜻은 아니다.

Q4. 크로스플랫폼 Swift 코드에서 Foundation 의존을 어떻게 다뤄야 하는가?

🔑 30초 답변

#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 우선, 안 되면 조건부 FoundationNS* 클래스, UIKit/AppKit, ObjC 런타임 의존 API
로케일 포매팅이 필요한 공유 타깃FoundationInternationalizationDateFormatter 같은 레거시 클래스에 직접 의존
Darwin 전용 타깃(UI 등)Foundation — 시스템 프레임워크, 동적 링크라 자유롭게 써도 됨
CIDarwin + Linux 컨테이너 양쪽에서 실제 빌드·테스트Darwin 통과만으로 "리눅스도 될 것"이라 가정

실험 · 도구

지금 작성 중인 소스가 실제로 어느 모듈에 의존하는지는 swiftc -typecheck를 여러 -D 조합으로 돌려 확인할 수 있고, 패키지 전체는 각 플랫폼용 툴체인으로 swift build를 실제로 돌리는 것만이 확실하다. 이 챕터의 검증 범위(Docker 금지)에서는 아래 스켈레톤을 직접 실행하지는 않았다 — 리눅스 CI가 있다면 그대로 파이프라인에 넣어볼 수 있다.

CI 매트릭스에 Linux 빌드를 실제로 포함시키는 것이 핵심 (개념 스켈레톤, 이 챕터에서 실행하지 않음)
# GitHub Actions 예 — 개념만
# jobs:
#   test-darwin:
#     runs-on: macos-latest
#   test-linux:
#     runs-on: ubuntu-latest
#     container: swift:6.2
# 두 잡이 모두 통과해야 "크로스플랫폼"이라 말할 수 있다

프로젝트 적용

Package.swift에서 타깃을 처음부터 분리해, 공유 로직 타깃이 Darwin 전용 API에 접근할 길 자체를 없앤다.

타깃 분리로 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하는 편이 그 경로를 아예 안 여는 확실한 방법이다.

필요한 것만 정확히 import — "일단 Foundation" 습관 피하기
// ❌ 나쁜 예 — 습관적으로 전체를 끌어온다, 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 지원은 그 선언과 무관하게 실제 소스 코드가 그 플랫폼에서 컴파일되는지로만 결정된다.
쉽게 말하면 상품 포장지에 "성인 전용"이라고 써 붙인다고 그 상품이 저절로 안전해지지 않는 것처럼, platforms 선언은 최소 버전 표시일 뿐 실제 동작을 보장하지 않는다.
테스트도 Linux CI에서 실제로 돌려야 하는 이유는 무엇인가?
Darwin에서 통과한 테스트는 Darwin의 Foundation.framework 구현 경로만 검증한다. Linux는 swift-corelibs-foundation을 거치는 별도 경로이고, 코어 로직을 공유한다 해도 플랫폼별 어댑터 코드(파일 시스템, 프로세스 등)는 그 경로에서만 실행된다. 그 경로에 버그가 있어도 Darwin 테스트로는 절대 드러나지 않는다.
쉽게 말하면 오른손으로 글씨를 잘 쓴다고 왼손으로도 잘 쓴다는 보장은 없다 — 왼손도 직접 써봐야 아는 것과 같다.

출처 · 참고자료