Swift

Swift 6 - @retroactive

GREEN.1229 2024. 9. 12. 07:30

안녕하세요. 그린입니다 🍏
이번 포스팅에서는 Swift 6에서 도입된 @retroactive에 대해 알아보겠습니다 🙋🏻


@retroactive

먼저 retroactive라는 단어 뜻 자체는 소급적이라는 의미를 가지고 있어요.
소급적 임금 적용 등처럼 일상생활에서도 소급적이라는 뜻을 간혹 들어보셨던적이 있을거에요.
소급적이라는 뜻은 지나간 사항에 대해서 지금에서 거슬러 적용시키는것을 의미합니다.
 

결국 우리가 오늘 알아볼 retroactive 어트리뷰트도 어떤것을 소급적으로 적용시킨다는 의미일거라 먼저 추측해봅니다 🙉

 

그럼 본격적으로 왜 나왔고 어떤건지 swift-evolution에서의 warning for Retroactive Conformances of External Types 부분을 같이 살펴보시죠 😄

 


Introduction

Swift에서 Equatable, Hashable, Codable 등과 같은 프로토콜은 다들 알고 계실거에요.
해당 타입을 채택하여 준수하면 공통된 기능을 사용할 수 있죠.
그런데 간혹 다른 모듈에서 제공하는 타입이 이런 프로토콜을 준수하지 않는 경우도 당연히 있습니다.
그럴때는 해당 타입을 확장하던가 하여 프로토콜을 채택하도록 하는 방법이 주로 이용됩니다.
 
여기서 포인트는 Swift 런타임에서 프로토콜 채택은 프로세스 내에서 전역적으로 유일하다는점입니다.
즉, 동일한 프로토콜을 동일한 타입에 대해 두 개 이상의 모듈에서 확장해 채택하면 런타임 에러가 발생합니다.
이런 에러는 라이브러리 사용자에게 특히 더 큰 문제로 다고오고, 추후를 가정할때 가장 큰 단점은 라이브러리가 업데이트되어 해당 프로토콜을 자체적으로 채택하게 되었을때 충돌이 날 수 있죠.
 


Motivation

하나의 예시를 보며 어떤것이 원인이 되어 @retroactive이 나왔는지 보겠습니다.

 

// Not a great implementation, but I suppose it could be useful.
extension Date: Identifiable {
    public var id: TimeInterval { timeIntervalSince1970 }
}

 
이렇게 기존 Date 타입이 존재하는데 이 Date 타입을 확장해 우리는 커스텀하게 Identifiable 프로토콜을 채택하여 사용할 수 있죠.
일반적으로 기본 데이터 타입들의 경우 이런식으로 주로 확장해서 필요한 프로토콜 등을 채택해 구현하곤 하죠.
 
그런데 만약에 Foundation 프레임워크에서 이후 Swift 버전이 한 7~8에서 업데이트되어 Date 기본 타입에 Identifiable 프로토콜을 기본적으로 준수하게 개발되었다고 생각해봅시다.
그럼 우리는 기존 코드에서 Date 타입을 확장해 프로토콜을 채택하고 있었는데 , 이럴 경우 동일한 타입에 대해서 서로 프로토콜 준수가 겹치는 상황이 발생해 런타임 에러가 날 수 있죠.
 

왜냐면, id 값에 대해 다른 정의가 되어 있을 수 있으니까요 🥲

 
사실 컴파일 시 에러가 발생할 수도 있고, 앱은 이미 런타임에서 정의된 어떤것이 적용되는지 예측할 수 없다는것이 문제입니다.
이로인해 예기치 않은 버그가 발생할 수 있는 환경이 충분히 만들어지죠.
 
더 최악의 상황은 해당 프로토콜 준수가 라이브러리 모듈에서 선언된 경우죠.
해당 라이브러리를 사용하는 모든 클라이언트 코드에 해당 익스텐션이 전파되서 같은 문제를 겪게 됩니다.
Library Evolution을 지원하는 프레임워크의 경우엔 클라이언트는 이런 변경 사항이 발생했는지 모를 수도 있고, 라이브러리가 바이너리 형태로 제공되면 문제가 더 심각해질 수 있죠.
즉, 해당 경우엔 클라에서 해당 프로토콜 준수가 어디서부터 왔는지 알 수 없기에 에러 감지도 더 어려워집니다.
 
정리하자면 이러한 시나리오 자체를 봤을때 타입 익스텐션과 함께 적용된 소급 프로토콜 준수의 위험성을 보여주고 있습니다.
특히나 동일 타입에 대해서 다른 모듈에서 동일한 프로토콜을 준수하게 선언될 경우 위험성을 잘 나타내고 있습니다.
 

그럼 어떻게 이 위험성을 타파하게 제안되었는지 볼까요? 😄


Proposed solution

이번 제안 업데이트를 통해 소급 프로토콜 준수 패턴이 문제를 일으킬 수 있고, 지원되지 않는 방식임을 명확히 경고하는 기능을 추가하게 되었습니다.
 

/tmp/retro.swift:3:1: warning: extension declares a conformance of imported type
'Date' to imported protocol 'Identifiable'; this will not behave correctly if
the owners of 'Foundation' introduce this conformance in the future

extension Date: Identifiable {

 
이렇게 이제 컴파일 타임에 워닝이 나게 되는것이죠.
 
그래서 우리는 이 경고를 없애기 위해서 프로토콜 준수가 필요하다면 새로운 어트리뷰트를 추가하여 무시할 수 있습니다.
 

바로 @retroactive죠!

 

extension Date: @retroactive Identifiable {
    // ...
}

 
해당 어트리뷰트를 사용함으로 프로토콜 준수가 의도적으로 소급 적용된것임을 명시해주는것입니다.
즉, 경고를 나타내지 않는 방법으로 해당 어트리뷰트가 사용된 경우에 컴파일러가 소급 적용된것으로 의미하고 경고를 띄우지 않는것입니다.
 
이제 Swift 6에서는 컴파일러가 프로토콜 계층 구조에 있는 모든 프로토콜에 대해 명시적으로 @retroactive 어트리뷰트 준수를 강제하게 합니다.
명확히 선언해야 하죠.
(근데, 컴파일러가 자동 수정 기능을 제공하고 있어서 어렵지 않게 수정할 수 있어요 😄)
 

해당 기능을 통해서 기존 코드를 수정하지 않아도 코드의 유연성과 확장성을 높여줍니다 😁

 

만약 추후에 Swift 버전이 업데이트되어 기본 타입에 우리가 소급 적용한 프로토콜이 기본적으로 제공되게 되면 어떻게 될까요?

 
@retroactive가 붙어 컴파일러가 중복 구현을 감지해 경고를 띄울거에요.
코드는 여전히 동작하지만 이런 불필요한 어트리뷰트 코드를 제거하라고 제안하게 됩니다.
그럼 그때되서 @retroactive 어트리뷰트를 제거하면 코드가 더 깔끔해지고 잠재적인 충돌을 방지할 수 있습니다.
 
결국, Swift의 설계 철학상 기존 코드의 호환성을 해치지 않으려고 노력하기에 직접적인 에러는 발생하지 않을 가능성이 높아요.
그러나 코드의 명확성과 최적화를 위해서 Swift가 공식적으로 해당 기능을 지원하게 되면 @retroactive를 제거하는게 바람직하다고 봅니다 👍
 


Detailed design

경고가 발생하는 조건들을 디테일하게 볼까요?

 
1️⃣ 확장되는 타입이 다른 모듈에서 선언
2️⃣ 프로토콜이 다른 모듈에서 선언
 

즉, 타입과 프로토콜이 각각 다른 모듈에서 선언된 경우 소급 프로토콜 준수가 발생하며 그에 따라 워닝이 뜹니다.

 

위 조건에 걸리더라도 몇가지 예외 케이스에서는 경고가 발생하지 않습니다.

 
1️⃣ Clang 모듈에서 선언된 타입과 Swift 오버레이
- 타입이 Clang 모듈(C, Objective-C 헤더 파일 기반 모듈)에서 선언되었고, 확장이 해당 모듈의 Swift 오버레이에서 선언된 경우
2️⃣ Bridging Header 혹은 -import-objc-header 플래그를 통해 선언된 타입
- 이 경우엔 소급 준수일 수 있지만, 내부적으로 __ObjC라는 암묵적인 모듈에 추가되므로, 클라이언트에서 이를 관리한다고 가정
3️⃣ @_originallyDefined(in:) 속성 사용
- 타입이 한 모듈에서 선언되었지만, 해당 속성을 사용해 원래는 다른 모듈에서 정의되었다고 명시하는 경우
4️⃣ 같은 패키지 내 모듈에서 선언된 타입
- 이 경우엔 동일한 프로토콜 준수가 중복될 경우 링크 또는 로드 타임에 감지
 

그리고 안전한 경우도 있습니다.

 
1️⃣ 외부 타입이 현재 모듈에서 선언된 프로토콜을 준수하는 경우
2️⃣ 외부 타입을 확장하되 프로토콜 준수를 추가하지 않는 경우
 

당연하겠죠?

 
@retroactive 어트리뷰트는 확장에서 프로토콜 준수를 도입할때만 사용할 수 있고, 반드시 소급 준수가 필요한 경우에만 사용해야 합니다.
소급 준수가 아닌 경우엔 당연히 @retroactive를 사용할 수 없으며, 소급 준수가 발생하는 경우에만 적용할 수 있습니다.
 


Source compatibility

@retroactive 어트리뷰트는 새로운 속성이고 Swift의 이후 모든 버전에서 사용할 수 있는 추가적인 기능입니다.
그러나 이전 버전의 Swift에서는 해당 속성을 사용할 수 없기에, 소급 프로토콜 준수에 대한 경고를 무시할 수 있는 다른 대체 방법도 존재합니다.
 
타입의 전체 경로를 명시하는 방법입니다.
경고를 무시하려면 익스텐션 시 타입의 전체 경로를 명시하면 됩니다.
 

예시를 볼까요?

 

extension Foundation.Date: Swift.Identifiable {
    // ...
}

 
해당 방식은 여러 Swift 버전을 사용해야하는 프로젝트에서 특히 유용합니다.
완전히 경로를 명시하는 방법으로 해결할 수 있어요.
이를 통해서 새로운 컴파일러 버전에 의존하지 않고 구버전에서도 빌드가 가능하도록 설정할 수 있어요.
 

즉, 호환성이 유지됩니다 😄

 


마무리

Xcode 16에서 Swift 6에 따라 마이그레이션하다 워닝을 통해 @retroactive 존재에 대해 알게되었습니다.
마이그레이션을 더 하다보면 더 많은것이 나올것 같네요 😲
 


레퍼런스

swift-evolution/proposals/0364-retroactive-conformance-warning.md at main · swiftlang/swift-evolution

This maintains proposals for changes and user-visible enhancements to the Swift Programming Language. - swiftlang/swift-evolution

github.com