Swift

Expand on Swift macros (feat. WWDC 2023)

GREEN.1229 2023. 6. 10. 09:37

안녕하세요. 그린입니다🍏

이번 포스팅에서는 Swift 5.9에서 소개된 매크로 기능에 대해 알아보겠습니다🙌

 

Why macros?

우선 매크로를 왜 만들었는지 그 애플의 얘기를 들어볼까요?

Swift는 표현적인 코드와 API를 작성하는것에 능합니다.

그렇기에 사용자가 반복적인 상용구 작성을 피하는데 도움이 되는 result builders와 같은 기능들을 제공하곤 하죠.

즉 이렇게 코드를 생성하면 Codable이 어떻게 작동하는지 정확히 알 필요 없이 Codable을 사용할 수 있고 Codable 지원을 추가하는것이 코드를 늘리면서까지 작성할 가치가 있는지 판단하고 결정할 필요가 없게 됩니다.

 

여기까지는 Swift 언어를 사용하면서 프로퍼티 래퍼라던지 Result builders라던지 등 많이 접해봤던 기능입니다🙃

많기도 하네요ㅎㅎ..

 

간단한 코드를 작성하면 컴파일러가 자동으로 더 복잡한 내부에 있는 코드 조각으로 확장해주죠.

 

그렇지만 기존 기능이 원하는 기능을 수행할 수 없다면요?

Swift는 오픈소스임으로 컴파일러에 기능을 추가해줄 수 있습니다.

그러나 공식적으로 사용되기까지는 애플의 Swift팀 리더와도 회의를 거쳐야하는 등이 필요하기에 매크로가 나왔습니다🙌

 

즉, 컴파일러를 수정하지 않고 Swift 패키지에 배포할 수 있는 방식으로 보일러 플레이트 코드를 제거하여 Swift에 기능을 추가할 수 있습니다.
또한 다른 개발자들과도 공유할 수 있죠.

 

이렇게 매크로가 만들어진 배경을 보았습니다.
그런데 다른 언어에서도 매크로가 항상 좋은것만은 아니였기에 Swift 매크로는 이러한 문제들을 피하는 방식이 많이 다릅니다.

 

어떤 원칙으로 매크로를 설계했을까요?

 

Design philosophy

Swift 매크로를 설계하면서 가장 염두에 두었던 몇가지 원칙들에 대해서 살펴보시죠.

 

1️⃣ 매크로 사용을 매우 명확하게 해줘야 합니다.

두 종류의 매크로가 있습니다.

Freestanding(독립형) 매크로는 코드에서 다른 항목을 대체합니다.

return #unwrap(icon, message: "sould be in the app bundle")

항상 # 기호로 시작하죠.

 

Attached 매크로는 코드의 선언에서 속성으로 사용됩니다.

@AddCompletionHandler func sendRequest() async throws -> Response

항상 @ 기호로 시작하죠.

 

즉 항상 매크로는 #, @를 붙이고 이 표시가 없다면 매크로가 없는것입니다.

 

2️⃣ 매크로에 전달된 코드와 매크로에서 다시 전송된 코드가 모두 완전해야하고 실수를 체크해줘야 합니다.

#unwrap(1 + )

이와 같은 매크로는 완전한 식이 아님으로 매크로에 전달할 수 없죠.

@AddCompletionHandler(parameterName: 42) func sendRequest async throws -> Response

이와 같은 매크로도 인자가 String이여야 하는데 Int로 보내기에 당연히 타입 체크를 함으로 매크로로 전달할 수 없죠.

 

또한 이렇게 타입이 다를때도 컴파일러는 체크하여 매크로를 올바르게 사용하는지 확인할 수 있습니다.

 

3️⃣ 매크로 확장이 예측 가능하고 부가적인 방식으로 프로그램에 통합되어야 합니다.

매크로는 코드에서 보이는 부분에만 추가하고 제거할 수 있습니다.

 

4️⃣ 매크로는 마법이 아닙니다.

매크로는 프로그램에 더 많은 코드를 추가할 뿐이며 Xcode에서 바로 볼 수 있습니다.

즉 아래와 같이 매크로를 사용하였더 하더라도 감춰져있는것이 아니고 단순 축약된 형태입니다.

그렇기에 매크로 코드를 바로 열어볼 수 도 있죠.

심지어 브레이크 포인트를 찍으면서 디버깅도 가능합니다.

 

그럼 매크로를 왜 사용하고 어떤 목표로 만들었는지를 보았으니 매크로가 작동하는 방법을 보시죠🙌

 

Translation model

Swift 매크로가 작동하는 방식에 대해 살펴보겠습니다.

코드에서 매크로를 호출하면 Swift 컴파일러가 추출하여 해당 매크로에 대한 구현을 포함한 특수한 컴파일러 플러그인으로 보냅니다.

컴파일러 플러그인은 암호화된 샌드박스에서 별도의 프로세스로 실행되며 매크로 작성자가 작성한 커스텀한 Swift 코드를 포함합니다.

그럼 이제 해당 매크로에 의해 생성된 코드를 반환해줍니다.

그럼 그걸 Swift 컴파일러가 처리해주는것이죠.

 

그럼 어떻게 처음 코드에서 #stringify라는 매크로가 존재하는것을 컴파일러가 알았을까요?

 

답은 매크로 선언에 있습니다.

이렇게 매크로의 역할 지정과 일반 함수처럼 매개변수 및 반환 타입을 갖도록 선언해주죠.

그렇기에 이 선언들이 먼저 선행되어 구현되있기에 컴파일러가 알 수 있게 되는것입니다.

 

그럼 이제 매크로를 좀 더 다양하게 알아보면서 어떻게 구현하는지 보겠습니다🕺🏻

 

Macro roles

매크로가 프로젝트의 다른 코드와 상호 작용할 수 있는 구체적인 방법을 다뤄볼까요?

우선 매크로는 두 종류가 있다고 했던것 기억하시나요?

그걸 생각하고 전체 사용 가능한 매크로의 롤을 보겠습니다.

freestanding 매크로 2종류와 attaced 매크로 5종류로 나눠져있습니다.

 

그럼 우선 freestanding부터 볼까요?

 

freestanding

@freestanding(expresiion)

표현식은 실행하고 결과를 생성하는 코드 단위입니다.

해당 매크로는 표현식을 가지고 있고 함축적으로 표현해줄 수 있습니다.

unwrap 매크로에서는 이렇게 회색박스 부분 즉, 표현식이 구현되어 있는것이죠.

정리하면 표현식을 대체해주는 매크로입니다.

 

@freestanding(declaration)

함수, 변수 혹은 타입과 같은 하나 이상의 선언으로 확장합니다.

만약 이렇게 2D~5D까지 기능이 동일한데 인덱스 몇개들이 더 필요한 친구들이 있죠.

 

이런 구조체들은 선언 매크로를 사용하여 만들 수 있습니다.

N차원 배열 유형을 통합할 수 있죠.

보면 리턴을 선언하진 않습니다.

이 매크로는 다른 코드에서 사용되는 결과를 계산하는것이 아닌 프로그램에 선언을 추가하는것이기 때문이죠.

즉 이렇게 매크로를 사용할 수 있습니다.

 

이제 그럼 attached 매크로의 종류를 살펴보겠습니다🙌

@attached(peer)

변수, 함수, 타입뿐 아니라 import 및 오퍼레이터와 같은 모든 선언에 사용할 수 있으며 새 선언에서도 함께 넣을 수 있습니다.

이렇게 오버라이드되는 코드가 있을때 기존 코드들을 다 변경하면서 하기란 리스크가 있습니다.

이렇게 해당 매크로를 설정할 수 있죠.

그럼 이렇게 사용을 해줄 수 있습니다.

 

@attached(accessor)

변수 및 서브스크립트에 붙일 수 있으며 get, set, willSet, didSet과 같은 접근자에도 붙일 수 있습니다.

해당 구현에서 연산 프로퍼티를 매번 작성하는것은 귀찮고 또한 프로퍼티 래퍼를 사용할 수도 없죠.

이렇게 DictionaryStorage로 매크로를 둘 수 있습니다.

그럼 이제 매번 작성하는 대신 매크로를 붙여 자동으로 접근자를 생성해줄 수 있습니다.

 

@attached(memberAttribute)

타입이나 extension에 연결되고 연결된 어떤 요소들에도 추가할 수 있습니다

아까 위에서 살펴본 매크로에 다른 특성도 붙일 수 있습니다.

 

매크로는 이렇게 어떤 역할을 사용할지 모르는곳들이 있기에 두개의 freestanding 매크로를 제외한 어떠한 조합도 구성할 수 있죠!

그리고 꼭 해당 매크로가 작동할 수 있어야 합니다.

만약 위 DictionaryStorage 매크로를 함수에 연결하면 역할이 없기에 컴파일 오류가 발생합니다.

이렇게 해당 매크로를 타입에 연결하면 모든 속성에 별도로 연결하지 않고 한번에 끝낼 수 있죠.

 

@attached(member)

메서드, 프로퍼티, 이니셜라이저에 추가할 수 있습니다.

이렇게 DictionaryStorage 매크로에 같이 구성해볼께요.

이 매크로는 이니셜라이저와 dictionary라는 프로퍼티를 추가합니다.

따라서 이 매크로를 타입 선언 시 붙이기만 한다면 자동으로 dictionary 프로퍼티와 이니셜라이저가 추가됩니다.

 

자 이제 여기까지 왔으면 DictionaryRepresentable 프로토콜이 필요 없어 보이지 않나요?
어떻게 해줄 수 있을까요?

 

@attached(conformance)

타입이나 extension에 연결해줄 수 있습니다.

해당 DictionaryStorage 매크로에 붙여줍니다.

그럼 이제 사용부에선 프로토콜을 수동으로 채택하지 않아도 되죠.

자동으로 추가됩니다.

 

지금까지 매크로의 종류와 선언을 간단히 봤는데 구체적인 구현이 빠졌습니다.
그럼 이제 매크로를 실제 구현해볼까요?🙌

 

Macro implementation

매크로를 구현하는 구체적인 방법을 보시죠!

매크로 구현 시 이렇게도 사용할 수 있지만 보통은 아래와 같이 외부 매크로를 사용하죠.

컴파일러 플러그인과의 관계는 위에서 살펴보았습니다.

그 원리로 동작하죠.

즉 externalMacro가 그 사이 링크를 만들어주는것입니다.

 

그럼 DictionaryStorage를 계속 다뤘으니 그걸 연결해서 볼까요?

 

해당 매크로의 기본 구현입니다.

하나씩 살펴보죠.

 

우선 import 부분부터 보시죠!

 

SwiftSyntax

Swift 소스 코드를 구문 분석, 검사, 조작 및 생성하는데 도움을 주는 Swift 프로젝트에서 유지 관리하는 패키지입니다.

Swift 컴파일러가 수행하는 모든 기능을 지원해줍니다.

SwiftSyntax는 소스코드를 이렇게 트리 형태로 분석하고 나타냅니다.

 

SwiftSyntaxMacros

해당 라이브러리는 매크로 작성에 필요한 프로토콜과 유형을 제공해줍니다.

 

SwiftSyntaxBuilder

해당 라이브러리는 새로 생성된 코드를 나타내는 구문 트리를 구성하기 위한 편리한 API를 제공합니다.

사용을 안하고도 작성할 수 있지만 매크로를 만들다보면 유용하고 편리하기에 사용하는것이 좋습니다.

 

자 그럼 이제 필요한 라이브러리들을 보았으니 실제 구현부를 보겠습니다.

 

Macro Protocol

해당 매크로 구현에 MemberMacro라는 프로토콜을 채택하고 있죠?

이렇게 구현하고자하는 매크로마다 매칭되는 프로토콜이 있어요!

해당하는 프로토콜을 만들고자하는 매크로에 채택하면 됩니다.

 

이를 통해 구현해야하는 expansion 메서드는 static 메서드이기에 실제로 Swift는 DictionaryStorageMacro 타입의 인스턴스를 생성하지 않습니다.

expansion 메서드는 소스 코드에 삽입된 SwiftSyntax 노드를 반환합니다.

여기서는 MemberMacro를 채택했기에 expansion 메서드는 DeclSyntax 노드 array를 반환합니다.

 

그렇기에 return 본문을 보면 배열이 생성되는것을 볼 수 있어요!

일반적인 문자열로 보이지만 실제로는 그렇지 않습니다.

해당 문자열은 DeclSyntax가 예상되는 곳에 작성됨으로 Swift는 실제로 이를 소스 코드의 조각으로 파악하고 Swift pharser에 DeclSyntax 노드로 전환하도록 요청합니다.

요것이 바로 SwiftSyntaxBuilder 라이브러리가 제공해주는 API입니다.

 

그리고 이렇게 아까 필요했던 여러 프로토콜을 extension으로 채택해줍니다!

 

만약 만들어진 이 매크로를 구조체 대신 열거 타입에 적용하면 어떻게 될까요?

 

열거형에선 저장 프로퍼티를 포함해선 안되기에 컴파일 오류를 뱉게되죠!

즉 이렇게 Swift의 목표중 하나는 매크로 사용에서 실수가 있더라도 감지하고 오류를 내뱉을 수 있어야 하는 것입니다.

 

그렇기에 좀 더 구체적인 에러 메시지를 생성하도록 구현을 수정할 수 있어요.

 

expansion을 구현할때 인자로 받는 AttributeSyntax와 DeclGroupSyntax를 볼 수 있습니다.

따라서 이 매개변수들이 개발자가 attribue를 붙인 선언을 제공합니다.

또한 MacroExpansionContext라는 인자를 통해 매크로 구현이 컴파일러와 통신하려고 할때 사용됩니다.

따라서 이 세가지를 이용해 에러 및 경고 표시 등 몇가지 작업을 수행할 수 있죠.

구조체와 열거형은 이렇게 사용하는 Syntax가 다릅니다.

 

이렇게 구현에서 실제로 구조체가 아니면 에러를 지정해줄 수 있어요.

Diagnostic 타입의 인스턴스를 만들어서요!

node와 message 인자를 통해 만들어줍니다.

message로 실제 컴파일 실패 시 오류 메시지를 정할 수 있죠.

이렇게 커스텀하게 만들 수 있습니다.

 

자 이렇게 매크로를 작동시키는 방법에 대해 알아봤으니 확인을 해봐야겠죠?

 

Writing correct macro

매크로가 올바르게 작동하는지 확인하는 방법을 보겠습니다.

예를들어 네이밍에서 모호할 수가 있습니다.

해당 unwrap 매크로에서 wrappedValue라는 메시지 변수를 이용하려고하면 화살표처럼 컴파일러가 실제로 의미하는 값 대신 저 값을 사용합니다.

이러한 모호함을 매크로의 expansion 컨텍스트의 makeUniqueName 메서드가 해결할 수 있어요.

이렇게 사용자 코드나 다른 매크로 expansion에서 사용되지 않는 변수 이름을 반환하기에 메시지 문자열이 실수로 참조하지 않도록 할 수 있습니다.

다른 언어에서는 보통 매크로 내부의 네이밍은 외부의 네미이과 구별되기에 서로 충돌할 수 없습니다.

그러나 Swift는 그렇지 않죠.

DictionaryStorage 매크로를 보면 내부 dictionary와 외부의 dictionary가 다른것을 의미하면 동작하기 힘들거에요.

 

그래서 이거 위에서 본적 있는 선언입니다.

named 인자를 받죠.

 

사용할 수 있는 네이밍 지정자는 5가지가 있어요.

overloaded는 매크로가 사용된것과 정확히 동일한 기본 네이밍을 가진 선언의 매크로가 추가됨을 의미하죠.

prefixed는 지정된 접두사가 추가된 경우를 제외하고 매크로가 기본 네이밍이 동일한 선언을 추가함을 의미합니다.

suffixed는 위 prefixed와 동일하지만 접두사 대신 접미사를 제외하고는 동일합니다.

named는 매크로가 특정된 고정 기본 네이밍으로 선언됨을 의미합니다.

arbitary는 매크로가 이러한 규칙을 사용할 수 없는 설명할 수 없는 임의의 다른 이름으로 선언을 추가함을 의미합니다.

 

보통 arbitary를 사용하는것이 일반적이나 다른 4가지를 사용할 수 있으면 그렇게 해주는것이 좋습니다.

 

마지막으로 매크로는 컴파일러가 제공하는 정보만 사용해야 합니다.

날짜와 같은 API를 사용할 수 있지만 제대로 작동하지 않을 수 있습니다.

즉 매크로는 그 자체로 순수해야 합니다.

즉 위 날짜 매크로 같은것은 하지 않는것이 좋습니다.

외부에서 가져오면 안된다는것이죠.

 

마지막으로 매크로를 테스트 해볼까요?

 

Testing your macros

매크로 플러그인은 일반적인 Swift 모듈일뿐이며 그렇기에 XCTest와 같은 일반적인 유닛 테스트를 사용할 수 있습니다.

XCTest 작성 시 위처럼 SwiftSyntaxMacrosTestSupport를 import해줘야 합니다.

그리고 테스트 코드를 구현해주면 되죠.

 

마무리

이렇게 처음 도입된 매크로라는 기능을 살펴보았는데요.

매크로의 트리 구조나 이런걸 더 자세히 보려면 Write macro 섹션을 살펴보면 좋을것 같아요!

아직 처음이라 어렵네요🥲

 

참고 자료

https://developer.apple.com/wwdc23/10167

 

Expand on Swift macros - WWDC23 - Videos - Apple Developer

Discover how Swift macros can help you reduce boilerplate in your codebase and adopt complex features more easily. Learn how macros can...

developer.apple.com