스위프트 5.0과 라이브러리 진화 지원과 함께 도입된 @frozen 속성은 API 확장성 및 바이너리 안정성 간의 긴장을 해결하기 위해 설계되었습니다. 이 메커니즘 이전에는 탄력적인 라이브러리의 모든 공용 열거형이 암묵적으로 비동결(non-frozen)로 간주되어, 컴파일러가 향후 버전에서 알려지지 않은 케이스가 추가될 수 있다고 가정해야 했습니다. 이 가정 때문에 컴팩트하고 고정 크기의 레이아웃을 생성할 수 없었고, 클라이언트 코드에서 방어적 프로그래밍 패턴을 강제했습니다. 이 속성은 열거형의 케이스 목록이 영원히 변경되지 않을 것이라는 공식적인 보장을 제공하여, 공격적인 최적화를 가능하게 합니다.
문제는 라이브러리가 이 속성 없이 열거형을 게시할 때 발생합니다. 스위프트는 그때 열거형을 탄력적인 것으로 간주해야 하며, 향후 케이스 식별자 및 관련 값 레이아웃을 수용하기 위해 메모리 표현에서 변수 공간을 예약해야 합니다. 이는 클라이언트의 스위치가 @unknown default 케이스를 포함해야 하며, 이로 인해 모든 논리 상태가 처리되었는지에 대한 컴파일 타임 검증이 비활성화됩니다. 이러한 기본값 없이 라이브러리에 케이스를 추가하면 새 식별자 값을 처리할 코드가 없는 사전 컴파일된 클라이언트 바이너리에서 정의되지 않은 동작을 초래하여 크래시나 메모리 손상을 일으킬 수 있습니다.
해결책은 @frozen 주석이 영구적인 계약을 설정하는 데 있습니다. 열거형을 동결(frozen)로 표시함으로써, 라이브러리 저자는 케이스 세트가 절대 변경되지 않을 것이라고 약속하고, 컴파일러는 고정된 정수 태그를 할당하고 안정적이고 컴팩트한 메모리 레이아웃을 사용할 수 있게 됩니다. 이는 컴파일러가 식별자의 모든 가능한 비트 패턴이 알려진 케이스에 해당함을 증명할 수 있으므로, 기본 케이스 없이 포괄적인 스위치 문을 가능하게 합니다. 그 결과, ABI 안정성은 열거형의 크기와 정렬이 라이브러리 버전 전반에 걸쳐 동일하게 유지된다는 것을 보장하며, 클라이언트 코드는 점프 테이블 최적화 및 모든 상태에 대한 필수 처리를 혜택으로 받을 수 있습니다.
// -enable-library-evolution으로 컴파일된 라이브러리 내 @frozen public enum LoadState { case idle case loading case loaded(Data) } // 별도의 모듈 내 클라이언트 코드 func updateUI(for state: LoadState) { switch state { case .idle: print("대기 중") case .loading: print("스피너") case .loaded: print("내용") // 컴파일러가 포괄성을 검증; 기본값 필요 없음 } }
물류 회사의 플랫폼 팀은 .truck, .air, .ship 케이스가 있는 TransportMode 열거형을 노출하는 스위프트 패키지를 배송 중이었습니다. 그들은 향후 릴리스에서 .drone 및 .rail을 추가할 것으로 예상하여, 초기 라이브러리를 @frozen 속성 없이 배포했습니다. 클라이언트 팀은 soon Xcode가 @unknown default 절이 없는 스위치를 컴파일하는 것을 거부하여, 물류 비용 계산에서 .ship을 처리하지 않는 논리 오류를 숨기고 있다고 보고했습니다.
팀은 이를 해결하기 위해 세 가지 아키텍처 접근 방식을 고려했습니다.
첫째, 그들은 비동결 상태를 유지하고 클라이언트가 @unknown default 핸들러를 작성하게 하여 경고를 기록하도록 하기 위해 엄청난 린팅을 투자할 수 있었습니다. 이는 주요 버전 업그레이드 없이 운송 모드를 추가할 수 있는 유연성을 유지했지만, 컴파일 타임 포괄성 검사를 영구적으로 비활성화했습니다. 또한 각 열거형 인스턴스가 변동성 메타데이터를 포함하여 운전자의 장치로 전송되는 직렬화된 경로 패킷을 부풀리게 했습니다.
둘째, 그들은 열거형을 정수 상수로 지원되는 RawRepresentable 구조체로 교체할 수 있었습니다. 이는 고정 메모리 레이아웃을 제공하고 이진 호환성을 깨지 않고 새로운 모드를 추가할 수 있게 했지만, 스위프트의 패턴 매칭 기능을 완전히 희생하게 됐습니다. 개발자들은 장황한 if-else 체인에 강제로 들어가야 했고, 컴파일러는 중요한 경로 찾기 알고리즘에서 모든 가능한 운송 상태가 처리되었음을 더 이상 확인할 수 없었습니다.
셋째, 그들은 열거형에 @frozen을 적용하고 기존 세 개의 케이스에 전념하며, 향후 확장을 위한 별도의 ExtendedTransportMode 래퍼를 만들 수 있었습니다. 이 방법은 탄력성 오버헤드를 제거하고 포괄적인 스위치 컴파일을 가능하게 하며, 모든 클라이언트가 모든 현재 모드를 명시적으로 처리하도록 보장합니다. 대가로는 원래 열거형을 수정하는 영구적인 제한과 기본 추가를 위한 버전 관리가 필요합니다.
그들은 세 번째 솔루션을 선택했습니다. TransportMode를 동결한 후, 그들은 자신의 분석 대시보드에서 컴파일 중에 두 개의 처리되지 않은 스위치 케이스를 즉시 발견했습니다. 변동성 메타데이터의 제거로 전송된 경로 객체의 크기가 18% 감소했으며, 명시적인 아키텍처 경계가 핵심 운송 로직과 실험적 모드 간의 더 깨끗한 분리를 강요했습니다.
비동결된 공용 열거형에 케이스를 추가하면 클라이언트 소스 코드가 여전히 성공적으로 컴파일되더라도 이진 호환성이 깨지는 이유는 무엇인가요?
스위프트가 탄력적인 모듈을 컴파일할 때, 비동결된 열거형은 미래의 케이스 식별자를 위한 공간을 예약하는 가변 너비 표현을 사용합니다. 라이브러리에서 이후에 케이스를 추가하게 되면, 열거형의 런타임 레이아웃이 변경됩니다. 예를 들어, 식별자 정수가 새로운 태그를 수용하기 위해 8비트에서 16비트로 확장될 수 있습니다. 사전 컴파일된 클라이언트 바이너리는 이전 레이아웃을 예상하며, 원래 태그 범위만 고려하는 점프 테이블이나 조건부 분기를 포함하고 있습니다. 이러한 바이너리가 새로운 식별자 값을 만나면, 잘못된 코드 경로를 실행하거나 예상되는 페이로드 경계를 넘어 메모리를 읽을 수 있어 크래시가 발생할 수 있습니다. 소스 수준의 @unknown default 절은 이를 막을 수 없습니다.
@frozen은 간접 케이스나 동적 유형의 연관값을 포함하는 열거형과 어떻게 상호작용하나요?
@frozen은 케이스의 수와 정체성이 변하지 않음을 보장하지만, 연관값의 크기를 동결하지는 않습니다. 케이스가 비동결된 구조체나 클래스 참조의 페이로드를 포함하는 경우, 열거형의 ABI 안정성은 고정된 식별자 태그를 참조하지만, 페이로드 저장소는 여전히 포인터나 값 목격 테이블을 통해 동적 크기를 사용할 수 있습니다. 후보자들은 종종 @frozen이 페이로드 크기를 포함한 전체 메모리 풋프린트를 고정하는 것으로 잘못 가정합니다. 실제로 이 최적화는 주로 태그에 적용되며, 연관값은 여전히 해당 유형이 스스로 탄력적이거나 알려지지 않은 크기를 포함하는 경우 런타임 레이아웃 계산이 필요할 수 있습니다.
동결된 열거형을 비탄력적 모듈 내에서 선언할 수 있으며, 그렇게 할 경우 장기적인 영향을 어떻게 되나요?
네, @frozen은 라이브러리 진화가 비활성화된 일반 애플리케이션 타깃 내의 열거형에도 적용할 수 있습니다. 이 맥락에서, 이 속성은 의도의 문서로 기능하며, 모듈 내의 모든 열거형은 탄력성 경계의 부재 덕분에 효과적으로 동결됩니다. 그러나 후보자들은 종종 @frozen이 영구적인 ABI 계약이라는 사실을 간과합니다. 모듈이 나중에 탄력적인 라이브러리 프레임워크로 추출되면, 열거형을 동결 해제하거나 확장할 수 없으며 기존 클라이언트와의 바이너리 호환성이 깨질 수 있습니다. 초기 개발 중 열거형을 동결된 것으로 명시하는 것은 프로젝트 아키텍처가 진화할 때 우발적인 ABI 위반으로부터 코드베이스를 보호하는 것입니다.