velopert / velog-client

https://velog.io/
Other
675 stars 131 forks source link

자동 비공개가 되는데 어디가 문제일까요? #551

Closed rlarjsdn3 closed 3 months ago

rlarjsdn3 commented 3 months ago

글을 길게 작성했는데 자동 비공개가 되네요

뭐가 문제인지 모르겠는데 한번 살펴봐주실 수 있나요?

Swift Macro(이하 `매크로`)는 WWDC23에 발표된 Swift의 새로운 기능 중 하나로 이름에서 알 수 있듯이, 다른 언어와 마찬가지로 컴파일 단게에서 **코드 블록을 확장하는 기능**을 제공합니다. 

Swift의 매크로는 다른 언어의 매크로와 결이 무척이나 다릅니다. Swift의 매크로는 단순히 코드를 다른 코드로 전개하는 걸 넘어 처음 버전의 선언과 선언 위치 및 방법에 따라 유동적으로 반응합니다. 이를 위해 `Syntax Tree`라는 **코드 블록 트리**라는 개념을 도입했으며, 개발자는 이러한 `SyntaxTree`를 구성해 새로운 코드로 전개하는 게 가능해집니다.

아울러, **[부록] 라이브러리 알아보기**을 첨부해 Swift의 매크로가 어떻게 동작하는지 깊게 탐구해보고자 하였습니다.

잘 익혀두면 지루한 보일러 플레이트(BoilerPlate) 코드를 제거할 수 있을 뿐만 아니라 읽기도 쉬워집니다. Swift의 매크로는 마법 그 자체입니다. 충분히 논의할 가치가 있는 놀라운 도구입니다.

## 기본 개념

![](https://velog.velcdn.com/images/rlarjsdn3/post/7bff5605-fda6-43a1-85fa-05e806ee370b/image.png)

**매크로는 코드를 컴파일 할 때 새로운 코드를 전개해 지루한 보일러 플레이트 코드 제거에 도움을 줍니다.** 컴파일하는 동안 코드를 빌드하기 전에 코드에 모든 매크로를 확장합니다. 매크로는 새로운 코드 블록을 추가하는 방식으로 작동하며, 기존의 코드를 절대로 삭제하거나 수정하지 않습니다. 이는 매크로는 항상 부가적인(Addictive) 방식으로 작동함을 의미합니다.

**매크로는 C언어의 매크로가 가진 한계를 극복합니다.** C언어의 매크로는 전처리(Preprocessing)를 할 때 매개변수의 타입을 검사해주지 못합니다. 게다가, 매크로의 크기가 커지면 커질수록 코드 가독성이 떨어지는 문제가 있으며, 디버깅또한 매우 어렵다는 문제가 있습니다. Swift의 매크로는 이 같은 문제를 해결하였습니다. 매크로 매개변수의 타입이 올바르지 않다면 컴파일 에러를 내보냅니다. 매크로 확장 결과를 빠르게 소스 코드에서 볼 수 있을 뿐만 아니라 디버깅또한 가능합니다. 

**매크로는 컴파일러를 수정할 필요없이 패키지 속에 배포하는 방식으로 작동합니다.** 다시 말해, 우리가 직접 라이브러리를 만들어야 합니다. 이는 소스파일에 직접 매크로를 구현하는 C언어나 C++와 달리 무척이나 귀찮은 작업입니다. 하지만, 이 덕분에 매크로는 다양한 프로젝트에 쉽게 적용할 수 있을 뿐더러 다른 개발자와 공유하기도 간편하다는 장점이 있습니다. 

### 작동 방식

![](https://velog.velcdn.com/images/rlarjsdn3/post/1455da81-999b-4aa6-846c-3694bf854dcc/image.png)

각 매크로는 어떻게 확장될 수 있을까요? Swift 컴파일러는 컴파일 도중 매크로를 인식하면 해당 매크로를 매크로의 구현을 포함하는 컴파일러 플러그인(Compiler Pulgin)에 보냅니다. 이후 컴파일러 플러그인은 매크로 전개식을 코드에 추가하고 전개된 결과를 컴파일합니다. 

![](https://velog.velcdn.com/images/rlarjsdn3/post/a0a3ae87-90bf-4be4-b0b3-d59f6d0769d5/image.png)

조금 더 자세하게 톺아보겠습니다. Swift 컴파일러는 매크로를 컴파일러 플러그인에게 보내게 되면 컴파일러 플러그인은 제일 먼저 매크로 표현식을 포함한 모든 코드를 `SyntaxTree`로 파싱합니다. 이후 매크로 구현에서 개발자는 새로운 `SyntaxTree`로 변환하고 다시 코드로 직렬화해 새로운 코드로 전개하는 과정를 거치게 됩니다.

이 과정을 이해하기 위해 `#URL` 매크로를 `URL(string:)!`로 확장하는 단계를 살펴보겠습니다. 

```swift
let url = #URL("htts://www.apple.com/kr")

Swift 컴파일러는 컴파일 도중 #URL 매크로를 인식하면 해당 매크로를 매크로의 구현을 포함하는 컴파일러 플러그인에 보냅니다. 컴파일러 플러그인은 제일 먼저 매크로 표현식을 포함한 모든 코드를 SyntaxTree로 파싱합니다.

- SourceFileSyntax
├─statements: CodeBlockItemListSyntax
│ ╰─[0]: CodeBlockItemSyntax
│   ╰─item: MacroExpansionExprSyntax
│     ├─pound: pound
│     ├─macroName: identifier("URL")
│     ├─leftParen: leftParen
│     ├─arguments: LabeledExprListSyntax
│     │ ╰─[0]: LabeledExprSyntax
│     │   ╰─expression: StringLiteralExprSyntax
│     │     ├─openingQuote: stringQuote
│     │     ├─segments: StringLiteralSegmentListSyntax
│     │     │ ╰─[0]: StringSegmentSyntax
│     │     │   ╰─content: stringSegment("https://www.apple.com/kr")
│     │     ╰─closingQuote: stringQuote
│     ├─rightParen: rightParen
│     ╰─additionalTrailingClosures: MultipleTrailingClosureElementListSyntax
╰─endOfFileToken: endOfFile

이후 매크로 구현에서 위에서 파싱된 SyntaxTree를 매크로의 구현대로 새로운 Syntax Tree로 변환하고 다시 코드로 직렬화합니다.

- SourceFileSyntax
├─statements: CodeBlockItemListSyntax
│ ╰─[0]: CodeBlockItemSyntax
│   ╰─item: ForceUnwrapExprSyntax
│     ├─expression: FunctionCallExprSyntax
│     │ ├─calledExpression: DeclReferenceExprSyntax
│     │ │ ╰─baseName: identifier("url")
│     │ ├─leftParen: leftParen
│     │ ├─arguments: LabeledExprListSyntax
│     │ │ ╰─[0]: LabeledExprSyntax
│     │ │   ├─label: identifier("string")
│     │ │   ├─colon: colon
│     │ │   ╰─expression: StringLiteralExprSyntax
│     │ │     ├─openingQuote: stringQuote
│     │ │     ├─segments: StringLiteralSegmentListSyntax
│     │ │     │ ╰─[0]: StringSegmentSyntax
│     │ │     │   ╰─content: stringSegment("https://www.apple.com/kr")
│     │ │     ╰─closingQuote: stringQuote
│     │ ├─rightParen: rightParen
│     │ ╰─additionalTrailingClosures: MultipleTrailingClosureElementListSyntax
│     ╰─exclamationMark: exclamationMark
╰─endOfFileToken: endOfFile
let url = URL(string: "https://www.apple.com/kr")!

그리고 Swift 컴파일러는 직렬화된 SyntaxTree를 바탕으로 코드를 전개합니다. Swift 컴파일러는 전개된 코드가 문제가 없다면 컴파일을 계속합니다. 전개된 결과는 위 예제와 같습니다.

디자인 철학

매크로는 명확성, 완전성, 부가성, 투명성을 염두에 두고 디자인되었습니다.

① 명확성

#makeSomeFunction
@Codable
struct MemberResponse {
    var name: String
    @Codable(key: "day_of_birth") var dayOfBirth: String
    var age: Int
}

매크로를 쓸 때는 명확해야 합니다. 매크로는 항상 #@ 기호로 시작하며 해당 기호가 보이지 않는다면 매크로가 전혀 쓰이지 않았다고 생각해도 좋습니다. Swift에서 해당 기호는 @available이나 #warning과 같이 컴파일러의 동작을 의미합니다. 매크로도 마찬가지 의미를 지닙니다.

② 완전성

public macro convertStringToInt(_ value: String) -> Int = ...
let value = #convertStringToInt(19980321) // 🚨Cannot convert value of type 'Int' to expected argument type 'String'

컴파일러 플러그인에게 넘긴 매크로와 이것으로부터 넘어온 코드는 완전해야 합니다. Swift 컴파일러는 매크로를 감지하면 매크로 매개변수의 타입이 올바른지 검사합니다. 매크로는 함수 인자와 마찬가지로 똑같이 타입 검사를 받습니다. Swift 컴파일러는 확장된 코드 블록도 마찬가지로 검증하고 잘못된 경우 컴파일러 경고나 오류 메시지를 내서 올바르게 쓰고 있는지 확신할 수 있습니다.

③ 부가성

struct SomeStruct {

    func someFunction() { ... }

    #makeAnotherFunction

}

매크로는 항상 부가적인 방식으로 확장되어야 합니다. 매크로는 새로운 코드 블록을 추가하는 방식으로 작동하며, 기존의 코드를 절대로 삭제하거나 수정하지 않습니다. 위 예제에서 #makeAnotherFunction 매크로의 용도를 모르더라도 이 매크로가 절대로 someFunction() 함수를 지우거나 수정하지 않으리라는 걸 알 수 있습니다.

④ 투명성

final class DependencyValueMacroTests: XCTestCase {

    func testDendencyValue() throws {
        assertMacroExpansion(
            """
            extension DependencyValues {
                @DependencyValue(for: WeatherClientKey.self)
                var weatherClient: WeatherClient
            }
            """,
            expandedSource:
            """
            extension DependencyValues {
                var weatherClient: WeatherClient {
                    get {
                        self[WeatherClientKey.self]
                    }
                    set {
                        self[WeatherClientKey.self] = newValue
                    }
                }
            }
            """,
            macros: testMacros
        )
    }

}

매크로는 투명하게 공개되어야 합니다. 매크로에 의해 전개된 코드는 매크로 사용 위치에서 우클릭 > Expand a Macro 로 코드가 어떻게 전개되었는지 확인할 수 있습니다. 게다가 전개된 코드에 중단점(Break Point)를 추가해 디버깅도 용이하게 할 수 있죠. 또한 매크로에 대한 단위 테스트(Unit Test)도 가능해 매크로가 예상대로 작동하는지 확인할 수 있습니다.

역할(Role)

매크로 역할(role)은 매크로에 대한 일련의 규칙을 의미합니다. 매크로를 어디에, 어떻게 적용할지와 어떤 코드로 전개되는지, 이 전개식이 코드의 어디에 삽입될지를 결정합니다. 매크로 역할은 매크로 선언에 작성하며 매크로 선언에서 매크로 역할만 보더라도 해당 매크로가 어떤 역할을 할지 예측하고 알맞은 위치에 코드를 전개하도록 도와주는 게 매크로 역할입니다.

매크로 역할은 크게 독립 매크로(freestanding)과 첨부 매크로(attached)로 나뉩니다. 그리고 각 매크로는 member, extension 등 세부 역할로도 나뉠 수 있습니다. 첨부 매크로는 한가지 세부 역할뿐만 아니라 여러 세부 역할도 동시에 겸할 수 있습니다. 매크로 하나가 새로운 프로퍼티와 프로토콜 준수도 동시에 추가할 수 있는 거죠.

그렇다면 세부 역할을 동시에 겸하는 매크로는 무엇이 먼저 전개될까요?

  • 정답은 상관없다입니다. Swift 컴파일러는 서로 다른 역할을 가진 매크로가 언제, 어떤 순서로 전개되든 최종 결과만 보게 됩니다. 매크로는 다른 매크로에 영향을 받지 않고 선언된 코드의 처음 버전만을 보고 코드를 전개합닌다.

freestanding

독립 매크로는 독립적으로 사용되며 표현식(Expression) 또는 선언식(Declaration) 코드를 전개합니다. 독립 매크로는 어느 선언과도 관련이 없으며 다른 선언에 영향을 끼치지도 않습니다. 독립 매크로는 # 기호로 시작하며 매개변수를 포함할 수 있습니다.

expression

@freestanding(expression)
public macro unwrap<T>(_ T?, deafult: T) -> T = ...
let optionalValue: Int? = 32
let value = #unwrap(optionalValue, default: -1)
// begin exansion of "#unwrap(_:default:)"
{
    guard 
        let value = optionalValue 
    else { return -1 }
    return value
}()
// end expansion of "#unwrap(_:default:)"

freestanding(expression) 매크로는 값을 반환하는 코드 조각(Piece of Code)을 생성합니다. 위 예제는 첫 번째 인자를 옵셔널 바인딩(Optional Binding)한 결과를 반환하고, 실패한다면 두 번째 인자를 반환하는 코드 조각을 전개하는 방법을 보여줍니다.

declaration

@freestanding(declaration, names: named(MyEnum))
public macro makeCustomEnum(_: String...) = ...
#makeCustomEnum("zizi_Kim", "jenny_kim", "mimi_ma")
// begin expansion of "#makeCustomEnum(_:)"
enum MyEnum {
    case zizi_kim
    case jenny_kim
    case mimi_ma
}
// end expansion of "#makeCustomEnum(_:)"

freestanding(declaration) 매크로는 하나 또는 여러 개의 선언을 생성합니다. 선언은 클래스, 구조체나 열거형이 될 수 있습니다. 위 예제는 첫 번째 인자로 열거형의 이름을 받고, 두 번째 가변 인자로 주어진 인자를 열거형의 케이스(Case)로 두는 열거형을 전개하는 방법을 보여줍니다.

attached

첨부 매크로는 어느 선언과 멤버에 첨부되어 해당 선언과 멤버에 프로퍼티 옵저버(Observer), 어트리뷰트(Attribute) 및 확장(Extension) 코드를 전개합니다. 첨부 매크로는 첨부된 선언과 멤버에 영향을 끼칩니다. 첨부 매크로는 @ 기호로 시작하며 매개변수를 포함할 수 있습니다.

peer

@attached(peer, names: overloaded)
public macro AddCompletionHandler() = ...
@AddCompletionHandler
func request(_ url: URL) async throws -> Data { ... }

// Begin expansion of "@AddCompletionHandler"
func request(_ url: URL, completionHandler: @escaping (Result<Data, Error>) -> Void) { ... }
// End expansion of "@AddCompletionHandler"

attached(peer) 매크로는 첨부된 선언과 더불어 새로운 선언을 생성합니다. 위 예제는 func request(_ url:) async throws 함수에 async throws 키워드를 없애고 completionHandler:라는 새로운 매개변수를 추가해 전통적인 비동기 처리 함수로 전개하는 방법을 보여줍니다.

매크로 선언을 보면 names: 매개변수가 보입니다. 이 매개변수에 코드를 전개할 때 생성될 멤버의 이름을 이름 지정자(names)로 알려줘야 합니다. 위 예제에서는 overloaded로 되어 있는데, 이는 첨부된 선언과 똑같은 이름을 가진 선언을 전개한다는 걸 의미합니다. 이러한 이름 지정자는 overloaded 뿐만 아니라 named, arbitrary 등 존재합니다.

이름 지정자
내용 예시
overloaded 첨부된 선언과 똑같은 이름을 가진 선언을 전개
(첨부 매크로 전용)
@attached(peer, names: overloaded)
suffixed(_:) 첨부된 선언 이름 접미사에 가 붙는 선언을 전개, $로 시작할 수 있음
(첨부 매크로 전용)
@attached(peer, names: suffixed(_peer))
prefixed(_:) 첨부된 선언 이름 접두사에 가 붙는 선언을 전개, $로 시작할 수 있음
(첨부 매크로 전용)
@attached(peer, names: prefixed(_peer))
named(_:) 첨부된 선언에 의 이름을 가진 선언을 전개 #attached(memeber, names: named(stroage), named(init(_:)))
arbitray 위 이름 지정자로 묘사할 수 없는 기타 이름을 가진 선언을 전개 @attached(member, names: arbitrary)

일반적으로 arbitrary 이름 저장자를 흔하게 사용하지만, 이름 지정자 중 하나를 사용할 수 있다면 이를 권장합니다.

member

@attached(member, names: named(storage), named(init(_:)))
public macro DictionaryStorage() = ...
@DictionaryStorage
struct DictionaryStorage { 
    // Begin expansion of "@DictionaryStroage"
    var storage: [String: Any]
    init(_ straoge: [String: Any]) {
        self.storage = storage
    }
    // End expansion of "@DictionaryStorage"
}

attached(member) 매크로는 첨부된 선언에 새로운 맴버를 생성합니다. 위 예제는 DictionaryStorage 구조체에 새로운 프로퍼티와 이니셜라이저를 전개하는 방법을 보여줍니다.

memberAttribute

@attached(memberAttribute)
public macro Inlinable() = ...
@Inlinable
struct FourBasicOperations<T: Numeric> {
    // Begin expansion of "@makeInlinable"
    @inlinable
    // End expansion of "@makeInlinable"
    func add(_ x: T, _ y: T) -> T {
        return x + y
    }

    // Begin expansion of "@makeInlinable"
    @inlinable
    // End expansion of "@makeInlinable"
    func minus(_ x: T, _ y: T) -> T {
        return x - y
    }
}

attached(member) 매크로는 첨부된 멤버에 새로운 어트리뷰트를 생성합니다. 어트리뷰트는 @DictionaryStorageKey(_:)와 같이 매크로가 될 수도 있고, @dynamicMemberLookup과 같이 속성이 될 수 있습니다. 위 예제는 각 함수마다 @inlinable 속성을 전개하는 방법을 보여줍니다.

accessor

@attached(accessor)
public macro DictionaryStorageKey() = ...
struct DictionaryStorage {

    ...

    @DictionaryStorageKey
    var name: String
    // Begin expansion of "@DictionaryStorageKey"
    { 
        get { self.storage["name"] as! String }
        set { self.storage["name"] = newValue as Any }
    }
    // End expansion of "@DictionaryStorageKey"

}

attached(accessor) 매크로는 첨부된 프로퍼티에 프로퍼티 옵저버를 생성합니다.** 프로퍼티 옵저버는 get, set, willSetdidSet이 될 수 있습니다. 위 예제는 각 프로퍼티마다 get, set 옵저버를 전개하는 방법을 보여줍니다.

extension

@attached(extension, conformances: Reactor)
public macro Reactor() = ...
@Reactor
struct CameraReactor { }
// Begin expansion of "@Reactor"
extension CameraReactor: Reactor { }
// End expansion of "@Reactor"

attached(extensions) 매크로는 첨부된 선언에 새로운 확장을 생성합니다. 위 예제는 Reactor** 프로토콜을 준수하는 새로운 확장을 전개하는 방법을 보여줍니다.

코드 블록 트리(SyntaxTree)

앞서 설명드렸듯이, Swift 컴파일러는 매크로를 컴파일러 플러그인에게 보내게 되면 컴파일러 플러그인은 제일 먼저 매크로 표현식을 포함한 모든 코드를 SyntaxTree로 파싱합니다. 이후 매크로 구현에서 개발자는 새로운 SyntaxTree로 변환하고 다시 코드로 직렬화해 새로운 코드로 전개하는 과정을 거치게 됩니다.

@Codable
struct Member {
    var name, dayOfBirth: String
    var age: Int
}

위는 Codable 프로토콜을 준수하는 코드를 전개하는 간단한 예제입니다.

(그림)

위 예제에서 선언된 구조체는 StructDeclSyntax로 표현됩니다. 그리고 @Codable와 같은 @이나 #으로 시작하는 특수 기호는 AttributeSyntax, 구조체 구현부(Body)는 MemberBlockSyntax로 표현됩니다. 구조체 구현부에 선언된 프로퍼티는 VariableDeclSyntax로 표현됩니다.

(그림)

이 뿐만이 아닙니다. VariableDeclSyntax에서 각 선언 블록(name과 dayOfBirth: String) ParrternBindingSyntax로 쪼개져 표현됩니다. SyntaxTreeSyntax의 가장 작은 단위인 TokenSyntax까지 쪼개집니다. TokenSyntax는 그 텍스트(identifier)와 공백(Trivia)과 같은 주변 정보를 포함합니다.

(그림)

위 예제를 SyntaxTree로 파싱한 결과는 아래와 같습니다.

- SourceFileSyntax
├─statements: CodeBlockItemListSyntax
│ ╰─[0]: CodeBlockItemSyntax
│   ╰─item: StructDeclSyntax
│     ├─attributes: AttributeListSyntax
│     │ ╰─[0]: AttributeSyntax
│     │   ├─atSign: atSign
│     │   ╰─attributeName: IdentifierTypeSyntax
│     │     ╰─name: identifier("Codable")
│     ├─modifiers: DeclModifierListSyntax
│     ├─structKeyword: keyword(SwiftSyntax.Keyword.struct)
│     ├─name: identifier("Member")
│     ╰─memberBlock: MemberBlockSyntax
│       ├─leftBrace: leftBrace
│       ├─members: MemberBlockItemListSyntax
│       │ ├─[0]: MemberBlockItemSyntax
│       │ │ ╰─decl: VariableDeclSyntax
│       │ │   ├─attributes: AttributeListSyntax
│       │ │   ├─modifiers: DeclModifierListSyntax
│       │ │   ├─bindingSpecifier: keyword(SwiftSyntax.Keyword.var)
│       │ │   ╰─bindings: PatternBindingListSyntax
│       │ │     ├─[0]: PatternBindingSyntax
│       │ │     │ ├─pattern: IdentifierPatternSyntax
│       │ │     │ │ ╰─identifier: identifier("name")
│       │ │     │ ╰─trailingComma: comma
│       │ │     ╰─[1]: PatternBindingSyntax
│       │ │       ├─pattern: IdentifierPatternSyntax
│       │ │       │ ╰─identifier: identifier("dayOfBirth")
│       │ │       ╰─typeAnnotation: TypeAnnotationSyntax
│       │ │         ├─colon: colon
│       │ │         ╰─type: IdentifierTypeSyntax
│       │ │           ╰─name: identifier("String")
│       │ ╰─[1]: MemberBlockItemSyntax
│       │   ╰─decl: VariableDeclSyntax
│       │     ├─attributes: AttributeListSyntax
│       │     ├─modifiers: DeclModifierListSyntax
│       │     ├─bindingSpecifier: keyword(SwiftSyntax.Keyword.var)
│       │     ╰─bindings: PatternBindingListSyntax
│       │       ╰─[0]: PatternBindingSyntax
│       │         ├─pattern: IdentifierPatternSyntax
│       │         │ ╰─identifier: identifier("age")
│       │         ╰─typeAnnotation: TypeAnnotationSyntax
│       │           ├─colon: colon
│       │           ╰─type: IdentifierTypeSyntax
│       │             ╰─name: identifier("Int")
│       ╰─rightBrace: rightBrace
╰─endOfFileToken: endOfFile

이러한 SyntaxTree는 개발자가 매크로를 더욱 치밀하게 전개하는 기틀을 마련해줍니다. Swift의 매크로는 특정 Struct에만 적용하게 하거나, 프로퍼티 초기화 유무에 따라 전개를 달리하는 등 단순히 코드를 다른 코드로 전개하는 걸 넘어 처음 버전의 선언과 선언 위치 및 방법에 따라 유동적으로 반응합니다.

예를 들어, 아래와 같이 매크로를 열거형이 아닌 구조체클래스에 적용하면 컴파일러 경고가 발생하게 할 수 있습니다.

public struct RawRepresentableMacro: MemberMacro {

    public static func expansion(
        of node: AttributeSyntax,
        providingMembersOf declaration: some DeclGroupSyntax,
        in context: some MacroExpansionContext
    ) throws -> [DeclSyntax] {
        guard
            let _ = declaration.as(EnumDeclSyntax.self)
        else {
            throw MacroError.message("해당 매크로는 Enum에만 적용할 수 있습니다.")
        }

        ...
    }
}

DeclGroupSyntax 프르토콜은 ClassDeclSyntax, EnumDeclSyntax, ExtensionDeclSyntax, ProtocolDeclSyntaxStructDeclSyntax가 준수합니다. DeclGroupSyntax 타입인 declaration 인자가 EnumDeclSyntax로 다운-캐스팅에 실패한다면 예외를 던집니다.

아울러, 개발자는 처음 버전의 선언에서 원하는 (타입 등) Syntax를 추출해 새로운 SyntaxTree를 작성할 수 있습니다. 아래는 이를 보여주는 간단한 예제입니다.

public struct RawRepresentableMacro: MemberMacro {

    public static func expansion(
        of node: AttributeSyntax,
        providingMembersOf declaration: some DeclGroupSyntax,
        in context: some MacroExpansionContext
    ) throws -> [DeclSyntax] {

        ...

        guard
            case let .argumentList(arguments) = node.arguments,
            let type = arguments.first?
                .expression
                .as(MemberAccessExprSyntax.self)?
                .base
        else { return [] }

        return [
            """
            var rawValue: \(type.trimmed)
            """,
            """
            init?(rawValue: \(type.trimmed)) {
                self.rawValue = rawValue
            }
            """
        ]
    }
}

AttributeSyntax 타입인 node 인자로부터 첫 번째 인자를 추출하고, 추출된 type으로 새로운 SyntaxTree로 변환해 반환합니다.

굉장히 매력적이지 않나요? SwiftSyntax가 아니라면 불가능했을 일입니다. 아무 조건도 따지지 않고 매크로를 전개하는 C언어의 매크로와 달리 Swift의 매크로가 더 강력하다는 사실을 부정할 수 없습니다.

예제

간단한 예제로 매크로를 톺아보겠습니다. Swift의 매크로는 패키지 속에 배포하는 방식으로 작동되기에 새로운 매크로 패키지를 만들어야 합니다. File > New > Pacage..를 눌러 새로운 Swift Macro를 만듭니다.

새로운 패키지를 만들면 여러 타겟으로 나누어진 템플릿을 제공합니다. 각 타겟의 역할은 아래와 같습니다.

물론 입맛에 맞게 타겟 구조와 이름을 바꿀 수 있습니다만, 지금은 주어진 템플릿에 따르도록 하겠습니다. 이제 구조체init?(rawValue: T)var rawValue: T 멤버를 추가하고, RawRepresentable 프로토콜을 준수하도록 전개하는 매크로를 작성해보겠습니다.

선언

클라이언트에게 매크로를 노출시키기 위해 매크로 선언을 작성해야 합니다. Zizi.swift 파일에서 @RawRepresnetable 매크로 선언을 작성하겠습니다.

@attached(member, names: named(init), named(rawValue))
@attached(extension, conformances: RawRepresentable)
public macro RawRepresentable<T>(_ type: T.Type) = ...

우리는 새로운 멤버 추가를 위해 attached(member)와 프로토콜 준수성 추가를 위해 attached(extension)을 매크로 역할을 부여하겠습니다. 그리고 제네릭 변수 T의 타입을 지정하기 위해 T.Type 타입의 매개변수 하나를 받겠습니다.

그리고 = 오른쪽에는 #externalMacro(module:type:)으로 매크로 선언과 실질적인 구현을 서로 이어줄 수 있습니다.

#externalMacro(module: "ZiziMacros", type: "RawRepresentableMacro")

매크로 확장을 수행하려면 ZiziMacors 모듈에서 RawRepresentableMacro 타입을 보라고 컴파일러에 알려줍니다. 선언 작성이 끝났으니 이제 매크로 구현을 작성하겠습니다.

전체 코드

@attached(member, names: named(init), named(rawValue))
@attached(extension, conformances: RawRepresentable)
public macro RawRepresentable<T>(_ type: T.Type) = #externalMacro(
    module: "MacrosImplementation",
    type: "RawRepresentableMacro"
)

구현

어떻게 코드를 전개할지 표현하기 위해 매크로 구현을 작성해야 합니다. 우리는 매크로 선언에서 attached(member)attached(extensions) 매크로 역할을 부여했습니다. 따라서 우리는 매크로를 구현할 때 MemberMacroExtensionMacro 프로토콜을 준수하고 필요한 함수를 작성하겠습니다.

먼저 init?(rawValue: T)var rawValue: T 멤버를 추가하기 위해 MemberMacro 프로토콜부터 구현하겠습니다.

import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros

public struct RawRepresentableMacro { }

extension RawRepresentableMacro: MemberMacro {

    public static func expansion(
        of node: AttributeSyntax,
        providingMembersOf declaration: some DeclGroupSyntax,
        in context: some MacroExpansionContext
    ) throws -> [DeclSyntax] {
        // 구조체가 아닌 열거형이나 클래스에 매크로가 적용되었다면
        guard
            let _ = declaration.as(StructDeclSyntax.self)
        else { return [] } // 빈 코드 전개

        // 매크로에 입력된 매개변수를 가져오고,
        // 첫 번째 매개변수로 전달된 `T.self` 중 `T` 토큰 가져오기 
        guard
            case let .argumentList(arguments) = node.arguments,
            let type = arguments.first?
                .expression
                .as(MemberAccessExprSyntax.self)?
                .base
        else { return [] } // 빈 코드 전개

        // 코드 전개식 반환
        return [
            """
            var rawValue: \(type.trimmed)
            """,
            """
            init?(rawValue: \(type.trimmed)) {
                self.rawValue = rawValue
            }
            """
        ]
    }

}

다음으로 RawRepresentable 프로토콜 준수성을 추가하기 위해 ExtensionMacro 프로토콜을 구현하겠습니다.

extension RawValueMacro: ExtensionMacro {

    public static func expansion(
        of node: AttributeSyntax,
        attachedTo declaration: some DeclGroupSyntax,
        providingExtensionsOf type: some TypeSyntaxProtocol,
        conformingTo protocols: [TypeSyntax],
        in context: some MacroExpansionContext
    ) throws -> [ExtensionDeclSyntax] {
        // `ExtensionDeclSyntax` 생성
        let rawRepresentableExtension = try ExtensionDeclSyntax("extension \(type.trimmed): RawRepresentable { }")

        // 코드 전개식 반환
        return [rawRepresentableExtension]
    }

}

짜잔⭐️ 이렇게 매크로 하나가 완성되었습니다. Zizi-Client.swift에서 잘 작동하는 모습을 볼 수 있습니다.

@RawRepresentable(Int.self)
struct Key { }
// Begin expansion for "@RawRepresentable(Int.self)"
{
    var rawValue: Int
    init?(rawValue: Int) {
        self.rawValue = rawValue
    }
}
// End expansion for "@RawRepresentable(Int.self)"

let key = Key(rawValue: 10 )
print("Key-rawValue: \(String(describing: key?.rawValue))")

전체 코드

public struct RawRepresentableMacro { }

extension RawRepresentableMacro: MemberMacro {

    public static func expansion(
        of node: AttributeSyntax,
        providingMembersOf declaration: some DeclGroupSyntax,
        in context: some MacroExpansionContext
    ) throws -> [DeclSyntax] {
        guard
            let _ = declaration.as(StructDeclSyntax.self)
        else { return [] }

        guard
            case let .argumentList(arguments) = node.arguments,
            let type = arguments.first?
                .expression
                .as(MemberAccessExprSyntax.self)?
                .base
        else { return [] }

        return [
            """
            var rawValue: \(type.trimmed)
            """,
            """
            init?(rawValue: \(type.trimmed)) {
                self.rawValue = rawValue
            }
            """
        ]
    }

}

extension RawRepresentableMacro: ExtensionMacro {

    public static func expansion(
        of node: AttributeSyntax,
        attachedTo declaration: some DeclGroupSyntax,
        providingExtensionsOf type: some TypeSyntaxProtocol,
        conformingTo protocols: [TypeSyntax],
        in context: some MacroExpansionContext
    ) throws -> [ExtensionDeclSyntax] {
        let rawRepresentableExtension = try ExtensionDeclSyntax("extension \(type.trimmed): RawRepresentable { }")

        return [rawRepresentableExtension]
    }

}

마무리

Swift의 매크로는 코드를 컴파일 할 때 새로운 코드를 전개해 지루한 보일러 플레이트 코드 제거에 도웁을 줍니다. 매크로는 SwiftSyntax로 코드 블록 트리를 나타내며, 새로운 SwiftSyntax를 구성해 새로운 코드를 전개할 수 있습니다. 이러한 특징은 C언어의 매크로와 차별되는 기능으로 C언어의 매크로가 가진 한계를 극복합니다.

Swift의 매크로는 분명 학습 난이도 높다고 할 수 있습니다. 하지만, C언어나 C++의 매크로를 경험하신 분이라면 분명 Swift의 매크로에 큰 매력을 느끼시리라 생각합니다. Swift의 매크로는 매크로 역할에 따라 어디에, 어떻게 적용할지와 어떤 코드로 전개되는지, 이 전개식이 코드의 어디에 삽입될지를 결정하고, 이는 더 큰 확장성을 의미하기 때문입니다.

Swift의 매크로의 이러한 확장성은 다른 언어에서는 보기 힘든 매력적인 것이기도 합니다. Swift의 매크로는 마법 그 자체입니다. 충분히 논의할 가치가 있는 놀라운 도구입니다.

참고 자료

[부록] 라이브러리 알아보기

매크로를 구현하기 위해 필요한 매크로는 크게 3가지가 있습니다. 이 뿐만 아니라 SwiftParser, SwiftDiagnostic 등 다양한 라이브러리가 있습니다. 라이브러리는 [오픈 소스]()이기에 누구나 기여하고 코드를 살펴볼 수 있습니다. 천천히 둘러보시면 SwiftSyntax를 이해하는 데 많은 도움이 됩니다.

SwiftSyntax

SwiftSyntax는 앞서 살펴 보았던 SyntaxTree 파싱을 돕는 라이브러리입니다. 자세한 내용은 코드 블록 트리(SyntaxTree)를 참조하세요.

SwiftSyntaxMacros

/// Describes a macro that adds accessors to a given declaration.
public protocol AccessorMacro: AttachedMacro {
    /// Expand a macro that's expressed as a custom attribute attached to
    /// the given declaration. The result is a set of accessors for the
    /// declaration.
    static func expansion(
        of node: AttributeSyntax,
        providingAccessorsOf declaration: some DeclSyntaxProtocol,
        in context: some MacroExpansionContext
    ) throws -> [AccessorDeclSyntax]
}

SwiftSyntaxMacros는 매크로 작성에 필요한 프로토콜을 제공합니다.

SwiftSyntaxBuilder

// Return Type: [DeclSyntax]
return [
    """
    var rawValue: \(type.trimmed)
    """,
    """
    init?(rawValue: \(type.trimmed)) {
        self.rawValue = rawValue
    }
    """
]

SwiftSyntaxBuilderSyntaxTree를 편리하게 구성하도록 도와주는 API를 제공합니다. 이게 없어도 매크로를 작성할 수 있지만 정말 편리하기 때문에 적극적으로 이용해 보시는 걸 추천드립니다. 사실 우리는 앞서 예제에서 이 라이브러리를 이미 활용했습니다.

// SyntaxExpressibleByStringInterpolationConformances.swift
extension DeclSyntax: SyntaxExpressibleByStringInterpolation {}

// Syntax+StringInterpolation.swift
extension SyntaxExpressibleByStringInterpolation {
  public init(stringLiteral value: String) {
    var interpolation = SyntaxStringInterpolation()
    interpolation.appendLiteral(value)
    self.init(stringInterpolation: interpolation)
  }
}

어떻게 이게 가능할까요? `DeclSyntax`는 `SyntaxExpressibleByStringInterpolation` 프로토콜을 준수하기 때문에 문자열 리터럴(String Literal)을 반환값으로 넘겨주면 `DeclSyntax` 노드로 바꿔 반환합니다.

```swift
// Return Type: [ExtensionDeclSyntax]
let rawRepresentableExtension = try ExtensionDeclSyntax("extension \(type.trimmed): RawRepresentable { }")

ExtensionDeclSyntax도 마찬가지로 DeclSyntaxParseable 프로토콜을 준수하기 때문에 이니셜라이저(Intializer)로 문자열 리터럴을 넘겨주면 ExtensionDeclSyntax노드로 바꿉니다.

import SwiftSyntax

/// Adds an initializer that allows the creation of declaration from string
/// interpolations.
///
/// - Warning: This protocol is considered an implementation detail. Do not rely
///   on it, only the initializer that it adds.
public protocol DeclSyntaxParseable: DeclSyntaxProtocol {}
public extension DeclSyntaxParseable {
  /// Create a syntax node from the given string interpolation.
  ///
  /// This initializer throws if the syntax node was not able to be parsed as
  /// this type, e.g. when calling `ClassDeclSyntax("actor Foo {})`.
  ///
  /// If there are syntax errors in the string, the initializer will return a
  /// node that contains errors without throwing.
  init(_ stringInterpolation: SyntaxNodeString) throws {
    let node: DeclSyntax = "\(stringInterpolation)"
    if let castedDecl = node.as(Self.self) {
      self = castedDecl
    } else {
      throw SyntaxStringInterpolationError.producedInvalidNodeType(expectedType: Self.self, actualNode: node)
    }
  }
}

아울러, SwiftSyntaxBuilder는 각 Syntax@resultBuilder를 구현해놓았기 때문에 SyntaxTree를 선언적인 방식으로 작성하도록 해줍니다.

let variable = try VariableDeclSyntax("var value: \(supersetType)") {
    try SwitchExprSyntax("switch self") { 
        for element in elements {
            SwitchCaseSyntax(
                """
                case .\(element.name):
                    return .\(element.name)
                """
            )
        }
    }
}

위 예제는 VariableDeclSyntaxinit(_ header: SyntaxNodeString, @CodeBlockItemListBuilder accessor: () throws -> CodeBlockItemListSyntax) throws를 호출해 선언적인 방식으로 VariableDeclSyntax를 구성하는 방법을 보여줍니다. SwitchExprSyntax도 마찬가지로 선언적인 방식으로 작성되어 있습니다.

// ResultBuilder.swift

@resultBuilder
public struct SwitchCaseListBuilder {
  /// The type of individual statement expressions in the transformed function,
  /// which defaults to Component if buildExpression() is not provided.
  public typealias Expression = SwitchCaseListSyntax.Element

  /// The type of a partial result, which will be carried through all of the
  /// build methods.
  public typealias Component = [Expression]

  /// The type of the final returned result, which defaults to Component if
  /// buildFinalResult() is not provided.
  public typealias FinalResult = SwitchCaseListSyntax

  /// Required by every result builder to build combined results from
  /// statement blocks.
  public static func buildBlock(_ components: Self.Component...) -> Self.Component {
    return components.flatMap {
      $0
    }
  }

  ...

  /// Enables support for 'for..in' loops by combining the
  /// results of all iterations into a single result.
  public static func buildArray(_ components: [Self.Component]) -> Self.Component {
    return components.flatMap {
      $0
    }
  }

만약 SwiftSyntaxBuilder의 도움을 받지 않으면 어떻게 매크로를 작성해야 할까요? 아래 예제는 앞서 작성한 @RawRepresentable 매크로를 SwiftSyntax만으로 작성하는 방법을 보여줍니다.

public struct RawRepresentableMacro { }

extension RawRepresentableMacro: MemberMacro {

    public static func expansion(
        of node: AttributeSyntax,
        providingMembersOf declaration: some DeclGroupSyntax,
        in context: some MacroExpansionContext
    ) throws -> [DeclSyntax] {
        guard
            let _ = declaration.as(StructDeclSyntax.self)
        else {
            throw CustomError.message("해당 매크로는 Struct에만 적용할 수 있습니다.")
        }

        guard
            case let .argumentList(arguments) = node.arguments,
            let type = arguments.first?
                .expression
                .as(MemberAccessExprSyntax.self)?
                .base
        else { return [] }

        let varDecl = VariableDeclSyntax(
            bindingSpecifier: .keyword(.var),
            bindings: PatternBindingListSyntax(
                arrayLiteral: PatternBindingSyntax(
                    pattern: IdentifierPatternSyntax(
                        identifier: .identifier("rawValue")
                    ),
                    typeAnnotation: TypeAnnotationSyntax(
                        type: IdentifierTypeSyntax(
                            name: .identifier(type.description)
                        )
                    )
                )
            )
        )

        let initDecl = InitializerDeclSyntax(
            initKeyword: .identifier("init?"),
            signature: FunctionSignatureSyntax(
                parameterClause: FunctionParameterClauseSyntax(
                    parameters: FunctionParameterListSyntax(
                        arrayLiteral: FunctionParameterSyntax(
                            firstName: .identifier("rawValue"),
                            type: IdentifierTypeSyntax(
                                name: .identifier(type.description)
                            )
                        )
                    )
                )
            ),
            body: CodeBlockSyntax(
                statements: CodeBlockItemListSyntax(
                    arrayLiteral: CodeBlockItemSyntax(
                        item: .expr(
                            ExprSyntax(
                                InfixOperatorExprSyntax(
                                    leftOperand: MemberAccessExprSyntax(
                                        base: DeclReferenceExprSyntax(
                                            baseName: .identifier("self")
                                        ),
                                        period: .periodToken(),
                                        declName: DeclReferenceExprSyntax(
                                            baseName: .identifier("rawValue")
                                        )
                                    ),
                                    operator: AssignmentExprSyntax(
                                        equal: .equalToken()
                                    ),
                                    rightOperand: DeclReferenceExprSyntax(
                                        baseName: .identifier("rawValue")
                                    )
                                )
                            )
                        )
                    )
                )
            )
        )

        return [
            DeclSyntax(varDecl),
            DeclSyntax(initDecl)
        ]

   }

}

단 두 줄을 작성하는데 어마무시한 코드 줄 수가 필요하죠? SwiftSyntaxBuidler는 보다 직관적인이고 편리하게 SwiftSyntax를 구성하도록 도와주는 고마운 녀석입니다.