🔥 매크로 구현

779자
9분

Swift 5.9부터는 매크로를 사용하여 코드를 더욱 간결하고 표현력 있게 작성할 수 있습니다. 매크로를 구현하려면 두 가지 컴포넌트가 필요한데요. 하나는 매크로 확장을 수행하는 타입이고, 다른 하나는 매크로를 API로 노출하는 라이브러리입니다. 이 두 부분은 매크로를 사용하는 코드와 별도로 빌드되며, 매크로 구현은 매크로 클라이언트를 빌드할 때 실행됩니다.

Swift Package Manager를 사용하여 새 매크로를 만들려면 swift package init --type macro 명령을 실행하면 됩니다. 이렇게 하면 매크로 구현과 선언을 위한 템플릿을 포함한 여러 파일이 생성됩니다.

기존 프로젝트에 매크로를 추가하려면 Package.swift 파일의 시작 부분을 다음과 같이 수정합니다:

  1. swift-tools-version 주석에서 Swift 도구 버전을 5.9 이상으로 설정합니다.
  2. CompilerPluginSupport 모듈을 임포트합니다.
  3. platforms 목록에 macOS 10.15를 최소 배포 대상으로 포함시킵니다.

아래 코드는 Package.swift 파일의 시작 부분 예시를 보여줍니다:

// swift-tools-version: 5.9
 
import PackageDescription
import CompilerPluginSupport
 
let package = Package(
    name: "MyPackage",
    platforms: [ .iOS(.v17), .macOS(.v13)],
    // ...
)
swift

다음으로, 기존 Package.swift 파일에 매크로 구현을 위한 타겟과 매크로 라이브러리를 위한 타겟을 추가합니다. 예를 들어, 아래와 같이 추가할 수 있으며, 이름은 프로젝트에 맞게 변경하면 됩니다:

targets: [
    // 소스 변환을 수행하는 매크로 구현
    .macro(
        name: "MyProjectMacros",
        dependencies: [
            .product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
            .product(name: "SwiftCompilerPlugin", package: "swift-syntax")
        ]
    ),
 
    // 매크로를 API의 일부로 노출하는 라이브러리
    .target(name: "MyProject", dependencies: ["MyProjectMacros"]),
]
swift

위 코드는 두 개의 타겟을 정의합니다: MyProjectMacros는 매크로의 구현을 포함하고, MyProject는 해당 매크로를 사용 가능하게 만듭니다.

매크로 구현은 SwiftSyntax 모듈을 사용하여 AST(Abstract Syntax Tree)를 통해 Swift 코드와 구조화된 방식으로 상호 작용합니다. Swift Package Manager로 새 매크로 패키지를 생성한 경우, 생성된 Package.swift 파일에 SwiftSyntax에 대한 의존성이 자동으로 포함됩니다. 기존 프로젝트에 매크로를 추가하는 경우, Package.swift 파일에 SwiftSyntax에 대한 의존성을 추가해야 합니다:

dependencies: [
    .package(url: "<https://github.com/apple/swift-syntax>", from: "509.0.0")
],
swift

매크로의 역할에 따라 SwiftSyntax에서 제공하는 해당 프로토콜을 매크로 구현이 채택해야 합니다. 예를 들어, 이전 섹션에서 살펴본 #fourCharacterCode 매크로를 구현하는 구조체는 다음과 같습니다:

import SwiftSyntax
import SwiftSyntaxMacros
 
public struct FourCharacterCode: ExpressionMacro {
    public static func expansion(
        of node: some FreestandingMacroExpansionSyntax,
        in context: some MacroExpansionContext
    ) throws -> ExprSyntax {
        guard let argument = node.argumentList.first?.expression,
              let segments = argument.as(StringLiteralExprSyntax.self)?.segments,
              segments.count == 1,
              case .stringSegment(let literalSegment)? = segments.first
        else {
            throw CustomError.message("Need a static string")
        }
 
        let string = literalSegment.content.text
        guard let result = fourCharacterCode(for: string) else {
            throw CustomError.message("Invalid four-character code")
        }
 
        return "\(raw: result) as UInt32"
    }
}
 
private func fourCharacterCode(for characters: String) -> UInt32? {
    guard characters.count == 4 else { return nil }
 
    var result: UInt32 = 0
    for character in characters {
        result = result << 8
        guard let asciiValue = character.asciiValue else { return nil }
        result += UInt32(asciiValue)
    }
    return result
}
enum CustomError: Error { case message(String) }
swift

각 코드에 대한 설명은 다음과 같습니다:

public struct FourCharacterCode: ExpressionMacro {
swift
  • FourCharacterCode 구조체는 ExpressionMacro 프로토콜을 채택합니다.
public static func expansion(
    of node: some FreestandingMacroExpansionSyntax,
    in context: some MacroExpansionContext
) throws -> ExprSyntax {
swift
  • expansion(of:in:) 메서드는 AST를 확장하는 역할을 합니다.
  • 매크로가 사용된 코드의 AST와 컨텍스트가 메서드의 인자로 전달됩니다.
guard let argument = node.argumentList.first?.expression,
      let segments = argument.as(StringLiteralExprSyntax.self)?.segments,
      segments.count == 1,
      case .stringSegment(let literalSegment)? = segments.first
else {
    throw CustomError.message("Need a static string")
}
swift
  • 첫 번째 guard 블록은 AST에서 #fourCharacterCode에 전달된 문자열 리터럴을 추출하여 literalSegment에 할당합니다.
  • 매크로 사용이 잘못된 경우 에러를 던집니다.
let string = literalSegment.content.text
guard let result = fourCharacterCode(for: string) else {
    throw CustomError.message("Invalid four-character code")
}
swift
  • 두 번째 guard 블록은 fourCharacterCode(for:) 함수를 호출하여 문자열에 해당하는 32비트 부호 없는 정수 리터럴 값을 계산합니다.
return "\(raw: result) as UInt32"
swift
  • expansion(of:in:) 메서드는 AST에서 표현식을 나타내는 ExprSyntax 인스턴스를 반환합니다.
  • ExprSyntax 타입은 StringLiteralConvertible 프로토콜을 채택하므로, 문자열 리터럴을 사용하여 결과를 생성할 수 있습니다.

기존 Swift Package Manager 프로젝트에 이 매크로를 추가하는 경우, 매크로 타겟의 진입점 역할을 하고 타겟에서 정의한 매크로를 나열하는 타입을 추가해야 합니다:

import SwiftCompilerPlugin
 
@main
struct MyProjectMacros: CompilerPlugin {
    var providingMacros: [Macro.Type] = [FourCharacterCode.self]
}
swift

#fourCharacterCode 매크로를 확장하기 위해 Swift는 이 매크로를 사용하는 코드의 AST를 매크로 구현이 포함된 라이브러리로 전송합니다. 라이브러리 내에서 Swift는 FourCharacterCode.expansion(of:in:) 메서드를 호출하고, AST와 컨텍스트를 메서드의 인자로 전달합니다. expansion(of:in:) 메서드의 구현은 #fourCharacterCode에 인자로 전달된 문자열을 찾아 해당하는 32비트 부호 없는 정수 리터럴 값을 계산합니다.

매크로 구현 시 유의할 점은 매크로가 잘못 사용된 경우 에러를 던지는 것입니다. 에러 메시지는 잘못된 호출 위치에서 컴파일러 에러가 됩니다. 예를 들어, #fourCharacterCode("AB" + "CD")와 같이 매크로를 호출하려고 하면 컴파일러는 "Need a static string"이라는 에러를 표시합니다.

expansion(of:in:) 메서드는 ExprSyntax 인스턴스를 반환하는데, 이는 AST에서 표현식을 나타내는 SwiftSyntax의 타입입니다. 이 타입은 StringLiteralConvertible 프로토콜을 채택하므로, 매크로 구현에서는 문자열 리터럴을 사용하여 간편하게 결과를 생성할 수 있습니다. 매크로 구현에서 반환하는 모든 SwiftSyntax 타입은 StringLiteralConvertible을 채택하므로, 어떤 종류의 매크로를 구현할 때도 이 접근 방식을 사용할 수 있습니다.

매크로를 활용하면 반복적인 코드를 줄이고 더 간결하고 표현력 있는 코드를 작성할 수 있습니다. SwiftSyntax와 함께 사용하면 강력한 코드 생성과 변환 도구를 만들 수 있죠. 특히 도메인 특화 언어(DSL)를 만들거나 코드를 자동으로 최적화할 때 매우 유용합니다.