Swift Macro (WWDC23)

마이노·2024년 4월 11일
2

15주 글쓰기 🐣

목록 보기
7/9
post-thumbnail

Macro

컴파일러를 수정하지 않고도 Swift 패키지에 배포할 수 있는 방식으로 반복적인 상용구를 제거하여 Swift에 자신만의 기능을 추가할 수 있습니다.


목표

  1. 매크로를 사용할때 매우 명확해야 한다

    매크로에는 두가지 종류가 있습니다. 첫번째 독립형 매크로는 코드에서 다른 항목을 대신합니다. 항상 파운드 기호로 시작합니다.

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

    두번째 첨부된 매크로(Attached macro)는 코드 선언의 속성으로 사용됩니다.

    @AddCompletionHandler func sendRequest() async throws -> Response

    항상 at(@)기호로 시작됩니다.


    Swift에는 이미 (#)와 (@) 기호를 사용하여 특별한 컴파일러의 동작을 나타내고 있습니다. 매크로는 이를 확장 가능하게 만듭니다. # 또는 @ 표시가 되지 않으면 관련된 매크로가 없다고 확신할 수 있어요.


  1. 매크로에 전달된 코드와 매크로에서 다시 전송된 코드가 모두 완전하고 실수가 없는지 확인하는 것

    인수는 완전한 표현식이어야 하므로 “1 + “를 매크로에 전달할 수 없어요.

    함수 인수와 마찬가지로 매크로 인수와 결과도 유형 검사를 받기 때문에 잘못된 유형의 인수를 전달할 수도 없어요.
    매크로 구현은 유효성을 검사하고 문제가 있는 경우 컴파일러 경고나 오류를 띄울 수 있어서 내가 매크로를 올바르게 쓰고 있는지 확인하는 과정이 매우 간단하고 쉽습니다.

  1. 매크로는 확장이 예측 가능하고 추가적인 방식으로 프로그램에 통합되어야 함
    매크로는 프로그램에서 보이는 코드에만 추가할 수 있어요.

    제거하거나 변경할 수 없습니다.

    따라서 someUnknownMacro가 무엇을 하는지 모르더라도 그다음 함수 호출인 finishDoingThingy에 대한 호출을 삭제하거나 새 함수로 이동하지 않는다는 것을 확신할 수 있습니다. 그 결과 매크로를 사용하는 코드를 훨씬 쉽게 읽을 수 있습니다.

  1. 매크로는 마법이 아니다.

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

    매크로를 우클릭해서 확장되는 내용을 직접 볼 수 있습니다. 또한 확장된 내용에 중단점을 설정하거나 디버거를 통해 한 단계씩 실행할 수 있습니다. 매크로를 확장한 부분의 코드가 컴파일되지 않으면 확장에서 오류가 있는 위치, 소스 코드에서 해당 확장이 있는 위치 모두를 확인할 수 있어요.

매크로에 대한 단위 테스트를 작성하여 예상대로 작동하는지 확인할 수도 있습니다. Apple은 그렇게 하도록 적극 권장합니다. 이러한 목표를 통해 개발자가 Swift 매크로를 쉽게 이해하고 유지 관리할 수 있습니다.

매크로 수행 방법

Swift가 Xcode 매크로 패키지 템플릿의 “stringify” 매크로와 같이 코드에서 매크로를 호출하는 것을 확인하면 코드에서 해당 사용을 추출하여

#stringify(a + b)를 추출하고

해당 매크로에 대한 구현이 포함된 특수 컴파일러 플러그인으로 보냅니다.

이 플러그인은 보안 샌드박스에서 별도의 프로세스로 실행이 되고 사용자가 작성한 Swift 코드를 포함합니다.

플러그인은 매크로 사용을 처리하고 매크로에 의해 생성된 새로운 코드 조각인 “확장”을 컴파일러에게 반환하게 됩니다.

컴파일러는 플러그인으로부터 받은 확장을 프로그램에 추가하고 코드 + 확장을 함께 컴파일합니다.


따라서 프로그램을 실행하게 되면 매크로를 호출하는 대신 확장을 직접 작성한 것처럼 작동하게 됩니다.

그렇다면 Swift는 stringify 매크로가 존재한다는 것을 어떻게 알았을까요?

바로바로 매크로 선언에서 나왔습니다. 짜잔~

매크로 선언은 매크로에 대한 API를 제공하는데요, 자신의 모듈에서 바로 선언을 작성하거나 라이브러리, 프레임워크에서 가져올 수 있습니다.

함수선언과 마찬가지로 이름, 매개변수 수, 유형 등을 지정합니다.

또한 매크로의 역할을 지정하는 하나 이상의 속성도 있어요. @freestanding(expression)

이로써 매크로가 어떤 역할인지 생각하지 않고 무작정 매크로를 작성하는 것은 불가능하다는 것을 알 수 있습니다.


매크로의 역할

역할(role)은 매크로에 대한 규칙들의 집합입니다. 매크로를 적용하는 위치와 방법, 매크로가 확장되는 코드의 종류, 해당 확장이 코드에 들어가게 되는 위치를 제어합니다.

독립형(freestanding) 매크로를 생성하는 역할에는 표현식(expression)과 선언(declaration) 총 2가지 역할이 있습니다.

또한 첨부된(attached) 매크로를 생성하는 5가지 역할이 있습니다.

1. freestanding (expression)

표현식은 실행하고 결과를 생성하는 코드 단위입니다.
= 등호 뒤의 산술은 표현식입니다. 표현식은 재귀적인 구조를 가지고 있습니다. 어떤 말이냐하면은 더 작은 표현식으로 구성되는 경우가 많아요. 뒤 곱하는 수식을 제외하고 (x + width)만으로도 표현이 됩니다. width도 마찬가지 아닐까요? freestanding 매크로는 표현식으로 확장되는 매크로를 나타냅니다.

let image = downloadedImage!
어떠한 값을 언래핑해야 한다고 해봅시다.

팀에 따라 너무 위험한 결과를 초래할 수도 있다고 생각되기에 guard let 구문 혹은 if let 구문을 작성해야 함을 우리는 잘 알고 있죠.
그러나 guard let을 사용한 다음 else 분기에서 preconditionFailure를 호출하는 것과 같은 대부분의 대안은 다소 지나친 형식입니다.

guard let image = downloadedImage else {
		preconditionFailure("downloadedImage was already checked")
}

이를 매크로를 이용해 밸런스를 유지하는 코드를 작성할 수 있습니다.

매크로가 값을 계산하고 값을 반환하기를 원하기 때문에 이를 독립형 매크로로 만들어 줍니다. unwrap이라는 이름을 지정해주고 전달된 값은 Optional이지만 반환된 값은 Non-Optional 유형을 지정해줍니다.

또한 언래핑이 실패할 경우 출력될 메세지를 뿌리기 위한 message도 받아야 합니다.

따라서 우리는 함수처럼 호출하는 매크로로 끝나지만 이는 클로저에 감싸진 guard let을 포함하는 표현식으로 확장됩니다.

오류메세지에는 일반 함수에서는 불가능한 변수 이름도 포함할 수 있어요.

2. freestanding (declaration)

함수, 변수 또는 유형과 같은 하나 이상의 선언으로 확장됩니다.
2D 배열 유형이 필요한 통계 분석을 작성한다고 생각해봅시다.

배열의 모든 행이 동일한 수의 열을 가지기를 원한다고 합니다.Array(Array)… 를 원하지 않습니다.

대신 요소를 평면적인 1차원 배열에 저장하고, 개발자가 전달한 2차원 인덱스에서 1차원 인덱스를 계산하려고 합니다.

그렇다면 다음과 같이 코드를 작성할 수 있습니다.

makeIndex 함수를 통해 2D 인덱스에 필요한 두 개의 정수를 가져온 다음 약간의 산술 연산을 수행하여 이를 1차원 인덱스로 변환할 수 있습니다.

3차원 배열이 필요하다면 어떻게 할까요?

2D 배열과 거의 동일하게 작성됨을 알 수 있습니다. 다만 지수가 몇 개 더 있고 계산도 더 복잡해졌습니다.

이후 4D,5D,6D 배열이 필요하다면요?

형태는 거의 동일하지만 제네릭, 프로토콜 확장, 하위 클래스 등등 Swift 기능을 사용할 만큼 가깝지는 않은 배열 유형에서 헤매게 될 것입니다. 이러한 케이스에 선언 매크로를 사용하여 생성할 수 있습니다. 구조체는 각각의 “선언”(declaration)이기 때문입니다.

따라서 N차원 배열 유형의 구조체를 생성할 것이므로 makeArrayND라는 이름으로 매크로를 선언해보겠습니다.


차원 수를 Int의 매개변수로 전달하고 결과 유형을 선언하지 않습니다. 왜냐면 이 매크로는 다른 코드에서는 사용되는 결과를 계산하는 것이 아니라 프로그램에 선언을 추가하기 때문입니다.

이제 2,3,4,5차원을 매크로로 호출할 수 있으며 각 호출은 올바른 수의 크기에 대한 올바른 계산을 통해 다차원 배열 유형으로 확장됩니다.

attached

첨부된(attached) 매크로는 이름에서도 알 수 있듯이 특정 선언에 첨부됩니다. 독립형 매크로에는 전달된 인수만 제공되지만 attached 매크로는 연결된 선언에 엑세스 할 수 있어요. 해당 선언을 검사하고 내부에서 이름, 유형 등등의 정보를 가져옵니다.

1. peer

피어 매크로는 변수, 함수, 타입뿐만 아니라 import 및 연산자 선언과 같은 모든 선언에 첨부할 수 있으며 새로운 선언을 함께 insert할 수도 있습니다. 따라서 메서드나 속성에 이를 사용하면 해당 유형의 멤버가 생성되지만 최상위 함수나 타입에 사용하면 새로운 최상위 선언이 됩니다.

일부 클라이언트가 여전히 이전 동시성 기술을 사용하고 있다는 것을 알고 Completion Handler를 사용하는 API버전을 제공하려고 한다고 해보겠습니다.

async 키워드를 제거하고 Completion Handler 매개변수를 추가하고 결과 유형을 매개변수 목록으로 이동하고 분리된 작업에서 비동기 버전을 호출하기만 하면 됩니다.

하지만 이 작업을 많이 수행하고 있으므로 직접 작성하고 싶지 않다면요? 피어매크로에게 떠넘기기~


AddCompletionHandler 라는 매크로를 선언하고 completion 처리의 인수 label에 대한 매개변수를 제공한다음 해당 매크로를 메서드의 비동기 버전에 연결합니다.

매크로는 원본과 동일한 Completion Handler 기반 서명(signature equivalent to the original)을 생성하고 메소드 본문을 작성하며 Completion Handler에 대한 추가 텍스트가 포함된 문서 주석도 첨부해줍니다.

2. accessor

변수 및 첨자에 추가할 수 있으며 “get”, “set”, “willSet” 또는 “didSet”과 같은 접근자를 추가할 수 있습니다. 어떻게 유용하다는 걸까요? 기본적으로 dictionary를 감싸고 속성을 사용해 해당 내용에 접근할 수 있는 여러 유형이 있다고 해봅시다.

예를 들어 이 Person 구조체를 사용하면 “name”, “height”, “birth_date” field에 access할 수 있지만 이 세가지 field외에 dictionary에 다른 정보가 있는 경우 해당 정보는 그대로 유지되고 프로그램은 이를 무시합니다.

이 세 가지 속성에는 computed getter 및 setter가 필요하지만 직접 작성하는 것은 매우 반복적이며 propertywrapper는 사용되는 유형의 다른 속성에 액세스할 수 없기 때문에 사용할 수 없습니다.

dictionary에 밑줄이 있는 “birth_date” 철자가 있기 때문에 key 매개변수를 제공하는 DictionaryStorage macro를 만들어보겠습니다. 키를 생략할 수도 있습니다. 기본값으로 nil이 설정되어 매크로가 속성 이름을 key의 이름으로 사용하게 됩니다.

이제 계산 프로퍼티 블록을 작성하는 대신 각 속성 앞에 “@DictionaryStorage”를 넣으면 매크로가 getter, setter를 생성해줄거에요.

여기까지는 좋은 개선점처럼 보이지만 DictionaryStorage 속성이 반복되고 있습니다.

일부 내장 속성을 사용하면 전체 유형이나 확장에 적용하여 이러한 상황을 해결할 수 있습니다.

이제 소개 할 memberAttribute입니다.

3. memberAttribute

매크로는 유형이나 확장에 연결되며, 연결된 항목의 멤버들에게 속성을 추가할 수 있습니다.


새 매크로를 선언하는 대신 accessor 함께 memberAttribute 역할을 추가해주었습니다.

Role composition

Swift가 어떤 역할을 사용할지 알 수 없는 곳이기 때문에 두개의 독립된 역할을 제외한 모든 역할 조합을 구성할 수 있습니다. Swift는 적용한 모든 역할을 확장하지만 적어도 하나의 역할은 해당 역할에서 작동해야 합니다. 따라서 DictionaryStorage 유형에 역할을 연결하면 연결한 역할을 확장하게 됩니다.

이를 함수에 연결하게 되면 어떻게 될까요? DictionaryStorage에는 함수에 연결할 수 있는 역할이 없기 때문에 당연하게도 컴파일 오류가 발생합니다.

그 결과 모든 속성에 별도로 연결하는 대신 전체 유형에 연결할 수 있습니다.

(확장을 해보면 요렇게 숨어있어요!)

전체 유형에 연결이 되면 birthDate는 그럼 @DictionaryStorage를 한번 붙였는데@DictionaryStorage * 2 가 되지 않을까요? 같은 속성이나 같은 특정 멤버를 건너뛰는 논리가 매크로에 있기때문에 걱정안해도 됩니다.

여전히 제거할 수 있는 상용구가 많이 남아있는 것으로 보입니다. init과 저장속성을 고쳐봅시다.

DictionaryRepresentable 프로토콜에 필요하며 프로퍼티는 accessor가 사용하지만 DictionaryStorage를 사용하는 모든 유형에서 정확히 동일합니다. (??)

(These are required by the "DictionaryRepresentable" protocol, and the property is used by the accessors, but they're exactly the same in any type that uses DictionaryStorage.)

이를 연결된 구성원(member) 역할을 사용하여 개선할 수 있습니다.

4. member

memberAttribute 매크로와 마찬가지로 이러한 매크로를 유형 및 확장에 적용할 수 있지만 기존 멤버에 속성을 추가하는 대신 완전히 새로운 멤버를 추가할 때 사용합니다.

따라서 메서드, 속성, 초기화 프로그램 등을 추가할 수 있습니다.

클래스와 구조체에 저장된 속성을 추가하거나 열거형 케이스를 추가할 수도 있습니다.

다시한번 역할을 추가하여 기존에 존재하던 2개의 역할과 함께 구성해줍니다. 새로운 역할은 이니셜라이저와 dictionary라는 속성을 추가합니다.

역할을 추가할 때 순서가 중요한가요? 아니요!

각각은 다른 사람이 제공한 확장 없는 선언의 원래 버전을 볼 수 있습니다. 따라서 컴파일러가 매크로를 언제 확장하더라도 동일한 내용이 표시됩니다.

멤버역할이 추가되면 더 이상 두 멤버를 작성할 필요조차 없습니다.
(이니셜라이저와 저장속성이 사라진 모습)

다음으로는 마지막 상용구로 프로토콜을 제거해볼께요.

5. conformance

유형이나 확장에 적합성을 추가할 수 있습니다. (공식문서에는 Extension 이라는 이름으로 존재합니다.)


마지막으로 역할을 추가해주어 나머지 세개의 역할과 함께 구성해보도록 할게요. 이 역할은 DictionaryRepresentation에 적합성을 추가합니다. 따라서 이제는 수동으로 작성할 필요가 없어졌습니다.

첨부 매크로들을 지나오며 반복적이고 크고 다루기 힘든 유형을 선택해 매크로로 여러 역할로 이동하여 구조체 내 남은 내용이 간결하게 지정되도록 개선하였습니다.

DictionaryStorage를 사용할 수 있는 유형이 매우매우 많다면요? 10개 20개라면..? 지루하고 반복적으로 계속 작성했어야했겠죠?


매크로의 구현

구현은 등호 뒤에 있으며 항상 또 다른 매크로입니다.

때로는 매개변수를 재배열하거나, 추가 매개변수를 리터럴로 지정해 작성한 또다른 매크로일 수 있습니다.

그러나 일반적으로 외부 매크로를 사용하게 됩니다.

외부 매크로는 컴파일러 플러그인에 의해 구현되는 매크로를 말합니다. 우리는 이미 위에서 컴파일러 플러그인에 대해 얘기했던 것을 기억하실거에요. 컴파일러는 매크로가 사용되는 것을 보면 별도의 프로세스에서 플러그인을 시작하고 매크로 확장을 요청한다고 말했습니다.

#externalMacro는 해당 관계를 정의합니다. 컴파일러가 실행해야 하는 플러그인과 해당 플러그인 내부의 유형 이름을 지정합니다. 따라서 Swift가 이 매크로를 확장하면 MyLibMacros라는 플러그인을 시작하고 StringifyMacro라는 유형에 확장을 요청합니다.

따라서 매크로 선언은 다른 API와 함께 일반 라이브러리에 들어가지만 매크로 구현은 별도의 컴파일러 플러그인 모듈에 들어갑니다.

그리고 #externalMacro는 선언과 이를 구현하는 유형 사이의 링크를 생성합니다.

이제 위에서 열심히 여러 역할을 추가해가며 썼던 DictionaryStorage가 어떻게 구현되었는지 살펴보겠습니다.

해당 매크로에는 저장된 속성과 초기화 프로그램을 유형에 추가하는 부착된 멤버 역할이 있었던 것을 기억하시나요?(member) 이제 살펴보겠습니다.


먼저 맨 위에서 SwiftSyntax라는 라이브러리를 가져오는 것 부터 시작합니다.

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


SwiftSyntax는 항상 최신 상태로 유지하므로 Swift 컴파일러가 수행하는 모든 기능을 지원합니다.

SwiftSyntax는 소스 코드를 특별한 트리 구조로 나타냅니다.

예를 들어 Person 구조체는 StructDeclSyntax 라는 유형의 인스턴스로 표시됩니다. 해당 인스턴스에는 속성이 있으며 각 속성은 구조체 선언의 일부를 나타내고 있습니다.

속성 목록은 attributes 속성에 있습니다.

실제 키워드인 struct는 structKeyword에 있는 것을 볼 수 있습니다.

구조체의 이름은 identifier에 있습니다. 그리고 중괄호가 있는 본문과 구조체의 멤버는 memberBlock에 있습니다.

struct Person { ... }

// struct -> structKeyword
// Person -> identifier
// { ... } -> memberBlock

또한 일부 구조체 선언에 있는 것을 나타내는 modifier와 같은 속성도 있습니다. 이러한 속성의 일부 구문 노드를 “토큰”이라고 합니다. 이는 이름, 키워드, 약간의 구두점 같은 소스 파일의 특정 텍스트 부분을 나타내며 해당 텍스트와 공백 및 주석과 같은 주변의 정보만 포함하게 됩니다.

syntax tree를 충분히 살펴본다면 소스파일의 모든 바이트를 포괄하는 토큰 노드를 찾을 수 있습니다. 그러나 attributes 속성의 AttributeListSyntax 노드 및 memberBlock 속성의 MemberDeclBlockSyntax 노드와 같은 일부 노드는 토큰이 아니에요. 이들은 자체 속성에 하위 노드를 가지고 있기 때문입니다.

예를 들어 memberBlack 속성 내부를 살펴보면 여는 중괄호에 대한 토큰, 멤버 목록에 대한 MemberDeclListSyntax 노드, 닫는 중괄호에 대한 토큰을 찾을 수 있습니다.

(구문트리에 대한 더욱 자세한 설명은 다른 포스팅에서 찾아뵙겠습니다 )

두번째로 import한 SwiftSyntaxMacro는 매크로 작성에 필요한 프로토콜과 타입을 제공해줍니다.

마지막 세번째는 SwiftSyntaxBuilder 입니다. 새로 생성된 코드를 나타내기 위해 구문 트리를 편리하게 작성을 도와주는 API를 제공합니다.

매크로를 사용하지 않고도 작성할 수 있지만 SwiftSyntaxBuilder가 매우 편리하기 때문에 권장하고 있습니다.

이제 이러한 3가지의 라이브러리를 가져왔으므로 플러그인이 제공해야 하는 DictionaryStorageMacro 유형을 실제로 작성해보겠습니다.

우선 MemberMacro 프로토콜을 준수하고 있어요. 각 역할에는 역할에 맞는 프로토콜이 있으며 구현은 매크로가 제공하는 각 역할에 대한 프로토콜을 준수해야한다고 합니다.

DictionaryStorage 매크로에는 4개의 역할이 존재하죠? 따라서 DictionaryStorageMacro 유형에는 4개의 프로토콜을 준수해야 해요.

다시 본문으로 이동해서! expansion 메서드가 보입니다. 이 메서드는 MemberMacro 프로토콜의 필수구현 메서드로서 매크로가 사용될 때 멤버 역할을 확장하기 위해 Swift 컴파일러가 호출하는 것입니다.

지금 이 메서드는 정적 메서드임을 유의하겠습니다. 모든 확장 메서드는 정적이므로 Swift는 실제로 DictionaryStorageMacro 유형의 인스턴스를 생성하지 않습니다. 단지. 메서드의 컨테이너로 사용합니다.

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

멤버 매크로는 형식에 멤버로 추가할 선언 목록으로 확장되기 때문에 DeclSyntax 노드의 배열을 반환해야합니다.

리턴되는 본문은 어떠한가요? 해당 배열이 생성되는 것을 볼 수 있어요. 매크로에 추가할 이니셜라이저와 저장속성이 보입니다.

문자열처럼 보이지만 실제로는 그렇지 않아요. 이 문자열 리터럴은 DeclSyntax가 예상되는 곳에 작성하기 때문에 Swift는 실제로 이를 소스 코드의 일부로 취급하고 Swift parser에게 이를 DeclSyntax 노드로 전환하도록 요청할 것입니다. 이를 SwiftSyntaxBuilder가 제공합니다. (편안)

extension DictionaryStorageMacro: ConformanceMacro,
								  MemberAttributeMacro,
                                  AccessorMacro { ... }

이후 다른 세가지 역할에 대한 프로토콜을 준수하고 매크로를 구현할 수 있습니다.

이 매크로를 올바르게 사용하면 당연히 잘 작동합니다. 잘못사용하게 된다면 어떻게 될까요? 예를들어 구조체가 아닌 열거형에 적용하려고 한다면요? member역할은 dictionary 속성을 추가하려고 시도할 것입니다.

열거형은 저장된 속성을 가질 수 없으므로 속성 포함 오류를 생성합니다. Swift가 이 코드의 컴파일을 중지한다는 것은 좋은 일이지만 오류메세지가 사용자로 하여금 혼란을 줄 수 있습니다.

우리는 앞서 말한 매크로의 목표 중 하나는 입력의 실수를 감지하고 사용자 정의 오류를 보낼 수 있도록 하는 것이라고 했습니다.

구조체에만 적용할 수 있다는 오류메세지로 수정한다면 사용자는 매크로에 의한 오류인지 아닌지 명확하게 무엇이 잘못되었는지를 알 수 있게 됩니다.


바로 확장 방법에 대한 매개변수로 이를 해결할 수 있습니다.

첫번째 attribute를 살펴볼께요. 이는 개발자가 매크로를 사용하기 위해 작성한 실제 DictionaryStorage 속성입니다.

두번째 인수는 선언(declaration)이라고 하며 DeclGroupSyntax를 준수하는 유형이에요. 구조체, 열거형, 클래스, actor, protocol 및 확장에 대한 노드가 모두 준수하는 프로토콜입니다. 따라서 이 매개변수는 개발자가 속성을 첨부한 선언을 제공합니다.

마지막 매개변수 context는 MacroExpansionContext를 준수하는 유형이에요. 컨텍스트 개체는 매크로 구현이 컴파일러와 통신하려고 할 때 사용됩니다. 오류 및 경고 표시를 포함하여 몇몇의 작업들을 수행할 수 있습니다.

따라서 우리는 세가지 매개변수를 모두 사용하여 오류를 발생시킵니다.

우리는 어떠한 문제점이 있었나요? 열거형에서 사용하려고 했습니다. 구조체로 선언을 했으면서도요.

따라서 declaration 매개변수 유형을 확인하여 이를 수행하면 됩니다.

구조체인 경우 해당 유형은 StructDeclSyntax가 되고 열거형이라면? EnumDeclSyntax가 됩니다.

따라서 선언 매개변수에 is 메소드를 호출하고 StructDeclSyntax를 전달하는 guard let 구문을 작성하겠습니다.

지금은 빈배열을 반환하지만 실제로 원하는 것은 오류를 보내는 것이에요. 후에 살펴보겠습니다.

enum MacroError: Error {
		case notAStruct
}

... MacroError.notAStruct

이를 수행하는 가장 쉬운 방법은 일반적인 Swift 오류를 발생시키는 것입니다. 그러나 이는 제어가 불가능해요.

조금 복잡하지만 강력한 오류를 만들어보겠습니다.

첫번째 단계는 Diagnostic(진단) 이라는 유형의 인스턴스를 만드는 것입니다.

이는 약간의 컴파일러 전문 용어로서 부러진 다리를 엑스레이로 보는 의사가 골절을 “진단” 하는 것처럼 부러진 코드의 구문 트리를 보는 컴파일러나 매크로는 오류나 경고를 “진단”합니다.

Diagnostic에는 최소한 두가지 정보가 포함됩니다.

  1. node: 오류가 발생한 구문 노드로 컴파일러는 잘못된 것으로 표시해야 할 줄을 알고 있습니다. 여기서는 사용자가 작성한 DictionarySotrage 속성을 가리키고 싶습니다. 이 속성은 다행히도 메소드에 전달된 속성 매개변수에 의해 제공됩니다.

  2. message: 컴파일러가 생성하기를 원하는 실제 메세지입니다. 사용자 정의 유형을 만든다음 해당 유형의 인스턴스를 전달하여 이를 제공합니다.

MyLibDiagnostic 유형은 이 모듈이 생성할 수 있는 모든 진단을 정의합니다. 열거형을 사용하고 각 진단에 대한 사례를 제공하기로 선택했지만 원하는 경우에는 다른 종류의 유형을 사용할 수도 있어요. 마치 Swift 오류와 유사하게 작동합니다.

이는 DiagnosticMessage 프로토콜을 준수하며 진단에 대한 정보를 제공하는 여러 속성들을 가지고 있습니다.

가장 중요한 severity를 살펴볼께요. 이는 “심각도” 를 나타냅니다. 진단된 결과가 오류인지 경고인지 지정합니다.

그런다음 실제 오류 메세지를 생성하는 message 속성과 diagnosticID 속성이 보입니다.

diagnosticID에 도메인에는 플러그인의 모듈 이름을, ID에는 유니크한 문자열을 사용해야 합니다. 현재는 열거형의 원시값을 사용하고 있지만 편의를 위한 것입니다.

이후 메세지를 가지고 진단을 생성할 수 있습니다.

context.diagnose(structError)

그런 다음 진단을 위해 컨텍스트를 알려주면 완료됩니다.

추가로 Xcode의 Fix 버튼을 통해 자동으로 적용되는 진단에 Fix를 추가할 수 있어요. 또한 강조 표시를 추가하고 코드의 다른 위치를 가리키는 메모를 첨부할 수도 있습니다. 따라서 이는 개발자에게 최고의 오류경험을 제공할 수 있습니다.

우리는 매크로가 올바르게 적용되었는지 확인한 후에도 실제로 확장을 생성해야 합니다.

SwiftSyntax는 이를 수행하기 위한 여러 가지 도구를 제공합니다. 구문 노드는 변경할 수 없지만 새 노드를 생성하거나 기존 노드의 수정된 버전을 반환하는 API가 다수 존재합니다.

SwiftSyntaxBuilder 라이브러리는 하위 노드 중 일부가 후행 클로저로 지정되는 SwiftUI 스타일 구문 빌더를 추가합니다.

예를 들어 다차원 배열 매크로를 위에서 만들어봤었습니다. 이를 생성하는 유형에 적합한 매개변수 수를 생성할 수도 있어요. 또한 DictionaryStorage 속성과 초기화 프로그램을 만드는 데 사용한 문자열 보간법도 지원합니다.

이러한 모든 기능은 다양한 상황에서 유용하며 특히 복잡한 매크로에서 여러 기능을 결합할 때 능력을 발휘합니다.

문자열 보간법의 강력한 기능을 살펴보기 위해 초기에 살펴보았던 unwrap macro를 만들어보겠습니다. guard let 구문의 코드를 샘플로 가져와서 명령문 구문 노드를 반환하는 메서드에 넣습니다.

가장 먼저 할 일은 올바른 메세지 문자열을 추가하는 것입니다.메시지 문자열은 임의의 표현식이므로 이를 ExprSyntax 노드로 전달한 다음 보간합니다. 이와 같은 일반적인 보간법은 코드에 구문 노드를 추가할 수는 있지만 일반 문자열은 추가할 수 없습니다. 이는 실수로 잘못된 코드를 넣는 것을 방지하는 일종의 안전기능 입니다.

guard let 조건은 단지 변수 이름이라는 점을 제외하면 비슷하므로 표현식이 아닌 토큰입니다.

우리는 표현식을 보간한 것처럼 TokenSyntax 매개변수를 추가하고 이를 보간합니다.

오류 메세지에 래핑이 해제되는 표현식을 추가하면 더 까다로운 경우가 있습니다. 우리가 만든 매크로의 특징 중 하나는 실패할 때 wrapping을 풀려고 했던 코드를 print한다는 것입니다. 이는 구문 노드의 문자열화된 버전을 포함하는 문자열 리터럴을 생성해야 함을 의미합니다.

명령문 구문 리터럴에서 접두사를 가져와 일반 문자열인 변수로 가져오는 것부터 시작해볼께요.

해당 문자열을 모간할 것이지만 “literal: ” 로 시작하는 특별한 보간을 사용할 것입니다. 이렇게 하면 SwiftSyntax는 문자열의 내용을 문자열 리터럴로 추가합니다.

이는 매크로, 숫자, bool, array, dict, optional로 계산된 다른 종류의 정보에서 리터럴을 만드는 데도 작동해요.

변수에 문자열을 작성했으므로 메세지에 올바른 코드가 포함되도록 변경할 수 있습니다. 원래 표현식에 대한 매개변수를 추가하고 해당 description 속성을 문자열에 추가해주기만 하면 끝입니다.

literal 보간법은 문자열에 특수문자가 포함되어 있는지 자동으로 감지하고 escape를 추가하거나 원시 literal로 전환하여 코드가 유효한지 확인합니다.

이제 오류가 발생한 파일과 라인을 오류에 첨부하는 방법을 알아보겠습니다. 컴파일러는 기본적으로 매크로가 확장되는 소스 위치를 매크로에 알려주지 않기 때문에 약간 까다롭습니다. 그러나 매크로 확장 컨텍스트에는 컴파일러가 소스 위치 정보를 포함하는 리터럴로 변환하는 특수 구문 노드를 생성하는 데 사용할 수 있는 API가 있습니다.

매크로 확장 컨텍스트에 대한 또 다른 인수를 추가하고 location 메서드를 사용합니다. 제공한 노드의 위치에 대한 구문 노드를 생성할 수 있는 개체를 반환합니다.

노드가 컴파일러가 전달한 노드가 아니라 매크로가 생성한 노드인 경우 nil을 반환하지만 originalWrapped는 사용자가 작성한 인수 중 하나이므로 해당 위치는 절대 nil이 될 수 없습니다. 그 결과 안전하게 언래핑이 가능하게 된 것입니다.

이를 통해 파일과 줄 번호를 구문 노드에 삽입해주면 끝입니다.

잘 작동하도록 만드는 방법

이전에 unwrap 매크로를 살펴볼 때 간단한 변수 이름을 unwrapping한 예를 살펴봤었습니다.

그러나 다음과 같다면..?

더 복잡한 표현식을 풀려고 하면 매크로가 다르게 확장되야 합니다.

표현식의 결과를 wrappedValue 라는 변수에 캡쳐한 다음 이를 풀어내는 코드를 생성합니다.

만약 메세지에 wrappedValue라는 변수를 사용하려고 한다면 어떻게 될까요??

컴파일러가 wrappedValue를 찾으면 결국 더 가까운 값을 찾게 되므로 실제로 의미한 값 대신 해당 값을 사용하게 됩니다.

사용자가 실수로라도 사용하지 않을 네이밍을 짓는다면 괜찮지 않을까요? 아뇨 차라리 이를 불가능하게 막는 편이 매우매우 적합해 보입니다. 이것이 매크로 확장 컨텍스트의 “makeUniqueName” 메소드가 수행하는 작업이였습니다.

사용자 코드나 다른 매크로 확장에서 사용되지 않는 것이 보장되는 변수 이름을 반환하기 때문에 메세지 문자열이 실수로 이를 참조하지 않도록 할 수 있어요.

이런 일이 발생하지 않도록 Swift에서는 왜 자동으로 중지하지 않을까요? 일부 언어에는 매크로 내부의 이름이 외부의 이름과 구별되어 서로 충돌할 수 없는 소위 위생적인(Medically) 매크로 시스템이 있다고 합니다. 많은 매크로가 자체 외부의 이름을 사용해야 한다는 것을 Swift는 알았기 때문이라고 설명합니다.

Dictionary를 생각해볼께요. 매크로 내부의 dictionary와 외부의 dictionary가 다른 의미를 갖는 경우 이니셜라이저와 저장속성을 추가하는 매크로 작업을 수행하기 매우 어려울 것입니다.

때로는 매크로가 아닌 코드에서 액세스 할 수 있는 완전히 새로운 이름을 도입하고 싶을 수 있습니다. peer, member, declaration 매크로는 기본적으로 이를 위해 존재합니다. 하지만 그렇게 해야할 때에는 추가하는 이름을 선언해야 컴파일러가 알 수 있을거에요. 또한 이 매크로들은 역할 속성 내에서 이를 수행합니다.

우리는 실제로 names라는 선언을 사용하고 있었습니다.

사용할 수 있는 이름 지정자는 총 다섯가지가 존재합니다.

  1. overloaded

    매크로가 연결된 항목과 정확히 동일한 기본 이름을 사용하여 매크로가 선언을 추가해야함을 의미합니다.

  2. prefixed(<some prefix>)

    매크로가 지정된 접두사가 추가된 것을 제외하고 동일한 기본 이름을 가진 선언을 추가한다는 것을 의미합니다.

  3. suffixed(<some suffix>)

    prefixed와 동일하지만 접미사를 사용합니다.

  4. named(<some name>)

    특정 고정 기본 이름을 사용하여 선언을 추가한다는 의미입니다.

  5. arbitrary(임시)

    매크로가 이런 규칙을 사용하여 설명할 수 없는 다른 이름으로 선언을 추가한다는 의미입니다.

보통은 arbitrary를 사용하는 것이 일반적이라고 설명합니다.

이러한 이름 지정을 통해 컴파일러와 코드 완성과 같은 도구들의 속도가 빨라집니다.

주의사항

매크로는 컴파일러가 제공하는 정보만 사용해야 합니다. 컴파일러는 매크로 구현이 순수 함수이고, 제공된 데이터가 변경되지 않은 경우에는 확장도 변경이 불가능하다고 가정합니다.

이를 억지로 우회한다면 일관되지 않은 동작이 나타날 수 있습니다. 매크로 시스템은 이를 알고 규칙을 위반할 수 있는 일부 동작을 방지하도록 설계되었어요.

예를들어 컴파일러 플러그인은 매크로 구현이 디스크의 파일을 읽거나 네트워크에 액세스하는 것을 중지하는 샌드박스에서 실행됩니다. 하지만 샌드박스가 모든 부적절한 행동을 차단하지는 않습니다. API를 사용해 날짜를 얻고, 한 확장의 정보를 전역 변수에 저장해 다른 확장에서도 사용할 수 있습니다.

그러나 이러한 작업을 수행하면 매크로가 오작동할 수 있습니다.

Test

매크로 플러그인은 일반적인 Swift 모듈이므로 이에 대한 일반적인 단위 테스트를 작성할 수 있고 작성해야 합니다.

SwiftSyntaxMacrosTestSupport의 assertMacroExpansion은 매크로가 올바른 확장을 생성하는지 확인하는 기능을 제공합니다.

매크로의 예와 확장해야 하는 코드를 제공하면 일치하는지 확인할 수 있습니다.

마치며

컴파일러를 수정하지 않고도 Swift 패키지에 배포할 수 있는 방식으로 반복적인 상용구를 제거하여 Swift에 자신만의 기능을 추가할 수 있습니다.

일반적으로 라이브러리에서 다른 API와 함께 매크로를 선언하지만 실제로는 안전한 샌드박스에서 Swift 코드를 실행하는 별도의 플러그인에서 구현합니다.

매크로의 역할은 매크로를 사용할 수 있는 위치와 해당 확장이 프로그램의 나머지 부분에 통합되는 방식을 나타냅니다.

그리고 매크로에 대한 단위 테스트를 작성하여 예상대로 작동하는지 확인할 수 있고 이를 반드시 작성해주어야 합니다.


40분짜리 영상들을 멈춰가며 받아 적고 이해하며 포스팅을 하기위해 영상시청만 3일정도로 많은시간이 걸린 것 같습니다. 🥲 매끄럽지 않은 번역에 이해가 안되서 여러번 다른 번역기들도 써보며 이해를 해보며 최대한 작성해보았습니다.

여러편으로 나눠서 작성해보면 어떨까? 싶었습니다. 저를 잘안다고.. macro[1]에서 끝나버리고 방치되면 어떨까 싶은 마음에 많이 무리하더라도 꼭 한번에 포스팅하자 싶었습니다.

사실 이번에 WWDC 영상을 보기 전에는 매크로가 저에게는 아직 멀고 생소하고 어려운 고인물전용 인것처럼 보였습니다. 저는 이해하기까지 다른 사람들에 비해 상당히 많은 시간이 소요되는 편인데요 이를 감안하더라도 영상에서 너무 친절하게 설명해주어서 길을 잃지 않고 차근차근 잘 따라갈 수 있었던 것 같아요.

다음에는 해당 영상의 심화편인 Write Swift Macro를 보며 딥다이브해보겠습니다. 😆

Expand on Swift macros - WWDC23 - Videos - Apple Developer

profile
아요쓰 정벅하기🐥

4개의 댓글

comment-user-thumbnail
2024년 4월 12일

Macro Documentation도 있더라고요
https://docs.swift.org/swift-book/documentation/the-swift-programming-language/macros/

Objective-C에 #define이 있어서 freestanding은 어떤 건지 바로 와닿았는데 attached는 좀 낯선 개념이네요

struct 딱 열었는데 애노테이션 달랑 하나 있으면 막막할 것 깉기도 하고요 ㅋㅋㅋㅋ

매크로의 역할 5가지를 서브 타이틀로 해서 목차에 나오도록 하면 다시 찾아보기 좋을 것 같습니다

WWDC는 transcript(대본?)도 텍스트로 제공해서 받아 적으실 필요는 없으셨을텐데...ㅠ

1개의 답글
comment-user-thumbnail
2024년 4월 13일

이번에 @Published와 @ObservedObject대신 @Observer가 등장하게된 이유가 macro의 등장떄문이라고 하더라고요 앞으로 macro의 등장으로 많은 변화가 생기지않을까 예상해봅니다 ㅎㅎ

1개의 답글