본문 바로가기

iOS

(iOS) Swift Macros 찍먹해보기

WWDC23에서 Swift Macros라는 기능이 공개되었다. 주로 프로젝트 진행 중 반복적으로 작성되는 보일러플레이트 코드를 줄이기 위한 목적으로 개발되었다고 한다. 얼마 전 Let Us: Go 행사에서 찍먹톤 행사를 통해 WWDC23에서 발표된 신규 기능에 대해서 접해보는 시간을 가졌는 데 이후 매크로에 대해서 정리해보고 싶었다.

Macros

Macro는 컴파일 시 작성한 소스코드를 변환하는것을 의미하며, 반복적인 코드를 작성하는 것을 피할 수 있도록 한다.

컴파일하는 동안 빌드 전까지 매크로에 대한 코드를 확장(Expand)한다.

매크로로 코드를 확장하는 것은 부가적인 작업이다. 매크로는 새로운 코드를 추가하지만, 기존 코드를 수정하거나 삭제하지는 않는다.

매크로의 입력 및 확장된 매크로에 대한 리턴은 스위프트 문법에 맞는지 체크된다. 마찬가지로 매크로에 전달하는 값(Value)과 매크로를 통해 생성된 코드에서의 값 역시 스위프트의 올바른 타입을 사용하고 있는지를 보장한다.

게다가 만약 매크로의 확장이 에러를 생성한다면, 컴파일러는 이를 컴파일 에러로 판단한다. 따라서 매크로를 사용하면서 좀 더 올바르게 코드를 사용할 수 있도록 도와준다.

 

매크로에는 두가지 형태가 존재한다.

  • Freestanding: 매크로가 특정 선언(Declaration)에 붙어 있지 않고 스스로 존재한다.
  • Attached: 특정 선언에 붙어 코드를 수정한다.

Freestanding Macros

프리스탠딩 매크로를 호출하기 위해서는 해시 사인(#)을 매크로 이름 앞에 붙인다. 그리고 필요한 인자를 이름 뒤 가로() 안에 넣는다.

func myFunction() {
    print("Currently running \(#function)")
    #warning("Something's wrong")
}

첫 번째 줄의 #function은 function이라는 이름의 매크로를 Swift Standard Library로부터 호출한다. 컴파일 시 이 매크로에 대한 구현체를 호출하며, #function이라는 코드를 호출된 메서드의 이름으로 바꾸어준다.

만약 myFunction() 메소드를 호출하면 “Currently running myFunction()”이라는 문자열을 출력한다.

두 번째로 #warning은 warning(_:)라는 매크로를 호출하는데, 이는 컴파일 타임의 경고를 출력한다.

 

Freestanding 매크로는 #function처럼 값을 생성하거나 #wanring처럼 컴파일 타임의 액션을 수행한다. 

Attached Macros

attached매크로를 호출하기 위해서는 Freestanding과 달리 (#) 이 아닌 (@) 사인을 이름 앞에 붙여 사용한다.

해당 매크로가 붙어있는 선언 코드를 수정하는데, 새로운 메서드를 추가하거나 특정 프로토콜 적용 코드를 추가하게 된다.

 

아래처럼 매크로를 사용하지 않는 코드가 있다고 해보자

struct SundaeToppings: OptionSet {
    let rawValue: Int
    static let nuts = SundaeToppings(rawValue: 1 << 0)
    static let cherry = SundaeToppings(rawValue: 1 << 1)
    static let fudge = SundaeToppings(rawValue: 1 << 2)
}

이 코드에서 `SundaeToppings`이니셜라이저가 반복적으로 사용되고 있다. 또한 라인 마지막에 숫자를 잘못 입력하는 실수를 할 수도 있다.

아래는 매크로를 사용하는 버전이다.

@OptionSet<Int>
struct SundaeToppings {
    private enum Options: Int {
        case nuts
        case cherry
        case fudge
    }
}

오우 일단 외쳐 순대 토핑 갓 한국(이때까지만 해도 한국의 순대인 줄 알았다..)

 

이 순대(사실 순대가 아니라 선대이 - 아이스크림 이었다.) 토핑은 @OptionSet라는 스위프트 표준 라이브러리 매크로를 사용한다. 이 매크로는 아래의 private enum으로부터 케이스들을 읽은 후 각 옵션에 대한 상수를 만들며 이때 각각 OptionSet 프로토콜 코드를 적용한다.

저 매크로를 우클릭 해서 Expand Macro 메뉴를 클릭하면

최종적으로 확장된 코드를 보여준다! - 엑스코드 짱

이렇게 컴파일 타임에 매크로를 통해 확장된 코드가 실행된다고 한다.

 

Macro Declarations

매크로의 선언은 매크로의 이름, 파라미터, 그리고 어디에 위치할지를, 그리고 어떤 종류의 코드를 생성하는지를 명시해주어야 한다.

그리고 macro 키워드를 사용한다. 예를 들어 앞의 예제에서의 @OptionSet의 선언 코드는 아래와 같다.

public macro OptionSet<RawType>() = #externalMacro(module: "SwiftMacros", type: "OptionSetMacro")

먼저 매크로의 이름과 인자를 특정한다.

  • name: OptionSet
  • parameters: 없음

두 번째로 `#externalMacro(module:type:) 매크로를 사용하는데, 어디에 이 매크로에 대한 구현체가 있는지를 알려준다.

  • 모듈명: SwiftMacros
  • 매크로명: OptionSetMacro

다른 클래스, 구조체와 마찬가지로 attached 매크로인 OptionSet은 대문자 카멜 케이스를 사용한다.

Note

매크로는 항상 public으로 정의된다. 다른 모듈에서도 매크로를 사용할 수 있게끔 하기 위해서다.

사실 이 부분에서 왜 public으로 해야 하나 싶었는데.. 매크로는 항상 별도의 모듈로 존재해야 한다. 그래서 다른 모듈에서 접근할 수 있도록 접근 제어자를 public으로 설정해주어야 한다.

 

좀 더 구체적인 예시를 봐보자.

@attached(member)
@attached(conformance)
public macro OptionSet<RawType>() =
        #externalMacro(module: "SwiftMacros", type: "OptionSetMacro")

총두 개의 @attached 어트리뷰트가 작성되어 있는데, 각각이 매크로의 역할을 정의한다.

  • member: 적용하고자 하는 타입에 새로운 멤버를 추가함
  • conformance: 적용하고자 하는 타입에 특정 프로토콜을 준수하도록 함.

이제 여기까지 간단한 매크로의 정의에 대해서 살펴보았다. 이제 직접 매크로를 만들고 정의해서 사용할 수 있도록 해보자.

매크로 만들어보기

매크로의 역할에 더해 매크로 선언에서는 매크로가 생성하고자 하는 심벌에 대한 정보를 명시해 줄 수 있다.

일단 메크로는 현재 Xcode 15 베타버전에서 생성 가능하며 File → New → Package.. 를 통해 생성할 수 있다.

일단 매크로를 적용해 보기 위해서는 두 개의 컴포넌트를 만들어야 한다

  • 매크로 확장을 수행할 타입
  • API로서 매크로를 정의할 라이브러리

생성된 매크로 프로젝트의 Package.swift파일을 보면 다음과 같이 구성되어 있다.

// swift-tools-version: 5.9
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription
import CompilerPluginSupport

let package = Package(
    name: "Ever",
    platforms: [.macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .watchOS(.v6), .macCatalyst(.v13)],
    products: [
        // Products define the executables and libraries a package produces, making them visible to other packages.
        .library(
            name: "Ever",
            targets: ["Ever"]
        ),
        .executable(
            name: "EverClient",
            targets: ["EverClient"]
        ),
    ],
    dependencies: [
        // Depend on the latest Swift 5.9 prerelease of SwiftSyntax
        .package(url: "https://github.com/apple/swift-syntax.git", from: "509.0.0-swift-5.9-DEVELOPMENT-SNAPSHOT-2023-04-25-b"),
    ],
    targets: [
        // Targets are the basic building blocks of a package, defining a module or a test suite.
        // Targets can depend on other targets in this package and products from dependencies.
        // Macro implementation that performs the source transformation of a macro.
        .macro(
            name: "EverMacros",
            dependencies: [
                .product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
                .product(name: "SwiftCompilerPlugin", package: "swift-syntax")
            ]
        ),

        // Library that exposes a macro as part of its API, which is used in client programs.
        .target(name: "Ever", dependencies: ["EverMacros"]),

        // A client of the library, which is able to use the macro in its own code.
        .executableTarget(name: "EverClient", dependencies: ["Ever"]),

        // A test target used to develop the macro implementation.
        .testTarget(
            name: "EverTests",
            dependencies: [
                "EverMacros",
                .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"),
            ]
        ),
    ]
)

의존성으로 swift-syntax를 사용하게 되어있는데, 이는 매크로 생성 시 Swift 컴파일 과정에 생성되는 AST(Abstract Syntax Tree)를 사용하기 때문에 추가해줘야 한다.

그리고 패키지 프로젝트 구성을 봐보면 아래와 같다.

각각에 대한 파일 설명은 아래와 같다.

  • Ever: EverMacro를 정의하는 역할
  • EverClient: 실제 EverMacro를 사용하는 클라이언트로서의 역할.
  • EverMacros: 매크로에 대한 구현체가 여기서 작성됨.

처음 EverMacro 파일을 열어보면 아래와 같이 StringfyMacro가 정의되어 있다.

/// Implementation of the `stringify` macro, which takes an expression
/// of any type and produces a tuple containing the value of that expression
/// and the source code that produced the value. For example
///
///     #stringify(x + y)
///
///  will expand to
///
///     (x + y, "x + y")
public struct StringifyMacro: ExpressionMacro {
    public static func expansion(
        of node: some FreestandingMacroExpansionSyntax,
        in context: some MacroExpansionContext
    ) -> ExprSyntax {
        guard let argument = node.argumentList.first?.expression else {
            fatalError("compiler bug: the macro does not have any arguments")
        }

        return "(\(argument), \(literal: argument.description))"
    }
}

그리고 아래 플러그인을 정의하고 매크로에 대한 목록을 반환한다.

@main
struct EverPlugin: CompilerPlugin {
    let providingMacros: [Macro.Type] = [
        StringifyMacro.self,
    ]
}

그리고 Ever 파일을 보면 매크로가 프리스탠딩 매크로이고, stringfy라는 이름으로 사용할 수 있게끔 코드가 작성되어 있다.

/// A macro that produces both a value and a string containing the
/// source code that generated the value. For example,
///
///     #stringify(x + y)
///
/// produces a tuple `(x + y, "x + y")`.
@freestanding(expression)
public macro stringify<T>(_ value: T) -> (T, String) = #externalMacro(module: "EverMacros", type: "StringifyMacro")

이렇게 하면 실제로 매크로를 사용하는 MacroClient에서는 #stringfy(10 + 20)라고 코드를 작성하면  결괏값 및 결과값 문자열을 반환받을 수 있다.

기존에 작성되어 있던 매크로 코드처럼 다른 매크로 코드도 작성해 보자.

다시 EverMacro.swift(프로젝트 생성 할 때 작성한 이름에 따라 다름)로 돌아와서.. stringfy매크로를 만든 것처럼 URL 입력 시 유효성 검증 후 언래핑해주는 매크로도 작성해 볼 수 있다.

 

유효성 검증 실패 시 반환한 에러를 정의하자.

enum URLMacroError: Error, CustomStringConvertible {
    case requiresStaticStringLiteral
    case malformedURL(urlString: String)

    var description: String {
        switch self {
        case .requiresStaticStringLiteral:
            return "#URL requires a static string literal"
        case .malformedURL(let urlString):
            return "The input URL is malformed: \(urlString)"
        }
    }
}

그리고 URLMacro도 작성해 주자.

public struct URLMacro: ExpressionMacro {
    public static func expansion(of node: some FreestandingMacroExpansionSyntax, in context: some MacroExpansionContext) throws -> ExprSyntax {
        guard
            /// 1. Grab the first (and only) Macro argument.
            let argument = node.argumentList.first?.expression,
            /// 2. Ensure the argument contains of a single String literal segment.
            let segments = argument.as(StringLiteralExprSyntax.self)?.segments,
            segments.count == 1,
            /// 3. Grab the actual String literal segment.
            case .stringSegment(let literalSegment)? = segments.first
        else {
            throw URLMacroError.requiresStaticStringLiteral
        }
        
        /// 4. Validate whether the String literal matches a valid URL structure.
        guard let _ = URL(string: literalSegment.content.text) else {
            throw URLMacroError.malformedURL(urlString: "\(argument)")
        }
        
        return "URL(string: \(argument))!"
    }
}

어떻게 node로부터 너런 정보를 빼낼지 모르기 때문에 바로 위처럼 코드를 작성하기 어려울 수 있다. 

이럴 경우 node가 어떻게 구성되어 있는지를 확인하기 위해 매크로 기능을 활용(?) 해볼 수 있다.

public struct URLMacro: ExpressionMacro {
    public static func expansion(
        of node: some FreestandingMacroExpansionSyntax,
        in context: some MacroExpansionContext
    ) throws -> ExprSyntax {
        return "\(raw: node.debugDescription)"
    }
}

이렇게 코드를 작성해 주게 되면 실제 매크로 사용할 때 엑스코드로 확장된 코드를 보면 아래와 같이 node 구조를 보여준다(당연히 컴파일되지 않는다)

그냥 print로 출력하면 되지 않겠냐 싶겠지만.. 컴파일 타임에 수행되는 탓인지 프린트로는 출력이 되지 않는다.

따라서 argumentList로부터 expression을 바라보고 segment 목록에서 content값을 가져오면 입력한 urlString을 받아올 수 있다.

 

다시 EverMacro.swift로 돌아와서 이전에 플러그인에 stringfy를 반환했던 것처럼 URL매크로도 추가해 주자.

@main
struct EverPlugin: CompilerPlugin {
    let providingMacros: [Macro.Type] = [
        StringifyMacro.self,
        URLMacro.self,
    ]
}

마지막으로 Ever.swift에서 매크로가 freestanding매크로인 것을 정의해 주면 사용할 수 있다.

@freestanding(expression)
public macro URL(_ stringLiteral: String) -> URL = #externalMacro(module: "EverMacros", type: "URLMacro")

EverClient에서 매크로 확장 기능을 사용해 보면 아래와 같이 언래핑 코드가 추가되는 것을 확인할 수 있다.

 

이번에는 간단하게 freestanding매크로만 추가해 봤지만, attached매크로도 작성해 보고 프로젝트에 어떻게 활용해 볼지 고민해 보면 좋을 것 같다.

 

References

https://docs.swift.org/swift-book/documentation/the-swift-programming-language/macros/

https://www.avanderlee.com/swift/macros/

https://github.com/apple/swift-evolution/blob/main/proposals/0389-attached-macros.md