매크로
매크로 (Macros)
매크로를 쓰면 코드를 컴파일할 때 생성할 수 있어요. 같은 코드를 반복해서 손으로 적어야 하는 귀찮음을 줄여주는 게 매크로의 핵심 역할이에요.
컴파일할 때 Swift는 코드에 있는 매크로를 보통 코드를 빌드하기 전에 미리 펼쳐서(expand) 처리해요.
여기서 중요한 점 하나를 짚고 갈게요. 매크로를 확장하는 것은 항상 더해지는(덧셈) 작업이에요. 매크로는 새로운 코드를 추가할 뿐, 기존 코드를 지우거나 수정하지 않아요.
매크로의 입력과 매크로 확장의 출력은 모두 문법적으로 올바른 Swift 코드인지 검사되고, 매크로에 넘기는 값이나 매크로가 만든 코드 안의 값도 타입이 맞는지 확인돼요. 또 매크로 구현이 확장 중에 오류를 만나면, 컴파일러는 그걸 컴파일 오류로 처리해요. 이런 보장 덕분에 매크로를 쓰는 코드를 더 쉽게 추론할 수 있고, 매크로를 잘못 쓰거나 매크로 구현에 버그가 있는 경우 같은 문제도 더 빨리 찾아낼 수 있어요.
Swift의 매크로는 두 종류가 있어요.
- **독립 매크로(Freestanding macros)**는 어떤 선언에 붙지 않고 홀로 나타나요.
- **첨부 매크로(Attached macros)**는 붙어 있는 선언을 수정해요.
첨부 매크로와 독립 매크로는 호출하는 방식이 조금 다르지만, 둘 다 매크로 확장 모델은 같고 구현하는 방법도 같아요. 두 종류를 각각 자세히 살펴볼게요.
독립 매크로 (Freestanding Macros)
독립 매크로를 호출할 때는 이름 앞에 숫자 기호(#)를 쓰고, 이름 뒤 괄호 안에 매크로에 넘길 인자를 적어요. 예를 들면 이런 모습이에요.
func myFunction() {
print("Currently running \(#function)")
#warning("Something's wrong")
}
첫 번째 줄의 #function은 Swift 표준 라이브러리의 function() 매크로를 호출해요. 이 코드를 컴파일하면 Swift가 그 매크로의 구현을 호출해서, #function을 현재 함수의 이름으로 바꿔줘요. 코드를 실행해서 myFunction()을 호출하면 "Currently running myFunction()"이라고 출력돼요. 두 번째 줄의 #warning은 Swift 표준 라이브러리의 warning(_:) 매크로를 호출해서, 커스텀 컴파일 타임 경고를 만들어내요.
독립 매크로는 #function처럼 값을 만들어내거나, #warning처럼 컴파일 타임에 어떤 동작을 실행할 수 있어요.
첨부 매크로 (Attached Macros)
첨부 매크로를 호출할 때는 이름 앞에 골뱅이 기호(@)를 쓰고, 이름 뒤 괄호 안에 매크로에 넘길 인자를 적어요.
첨부 매크로는 붙어 있는 선언을 수정해요. 그 선언에 코드를 추가하죠. 새 메서드를 정의하거나 프로토콜을 준수(conformance)하도록 만드는 일 같은 걸요.
예를 들어 매크로를 쓰지 않는 다음 코드를 볼게요.
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
}
}
이 버전의 SundaeToppings는 @OptionSet 매크로를 호출해요. 매크로는 private 열거형의 case 목록을 읽어서 각 옵션의 상수 목록을 생성하고, OptionSet 프로토콜을 준수하도록 추가해요.
비교를 위해 @OptionSet 매크로가 펼쳐진 모습을 볼게요. 이 코드를 직접 작성하는 건 아니고, Swift에게 매크로 확장을 보여 달라고 특별히 요청했을 때에만 볼 수 있는 코드예요.
struct SundaeToppings {
private enum Options: Int {
case nuts
case cherry
case fudge
}
typealias RawValue = Int
var rawValue: RawValue
init() { self.rawValue = 0 }
init(rawValue: RawValue) { self.rawValue = rawValue }
static let nuts: Self = Self(rawValue: 1 << Options.nuts.rawValue)
static let cherry: Self = Self(rawValue: 1 << Options.cherry.rawValue)
static let fudge: Self = Self(rawValue: 1 << Options.fudge.rawValue)
}
extension SundaeToppings: OptionSet { }
private 열거형 뒤의 모든 코드는 @OptionSet 매크로에서 나온 거예요. 매크로로 모든 static 변수를 생성하는 SundaeToppings 버전이, 앞에서 손으로 쓴 버전보다 훨씬 읽기 쉽고 유지보수하기도 쉬워요.
매크로 선언 (Macro Declarations)
대부분의 Swift 코드에서는 함수나 타입 같은 심볼을 구현할 때 별도의 선언이 따로 없어요. 하지만 매크로는 선언과 구현이 분리돼 있어요. 매크로의 선언은 이름, 받는 매개변수, 어디에 쓸 수 있는지, 어떤 종류의 코드를 생성하는지를 담고 있고, 매크로의 구현은 Swift 코드를 생성해서 매크로를 확장하는 코드를 담고 있어요.
매크로 선언은 macro 키워드로 시작해요. 예를 들어 앞에서 쓴 @OptionSet 매크로 선언의 일부는 이렇게 생겼어요.
public macro OptionSet<RawType>() =
#externalMacro(module: "SwiftMacros", type: "OptionSetMacro")
첫 번째 줄은 매크로의 이름과 인자를 나타내요. 이름은 OptionSet이고 인자는 받지 않아요. 두 번째 줄은 Swift 표준 라이브러리의 externalMacro(module:type:) 매크로를 사용해서 매크로 구현이 어디에 있는지를 Swift에 알려줘요. 이 경우 SwiftMacros 모듈에 OptionSetMacro라는 타입이 있고, 이 타입이 @OptionSet 매크로를 구현해요.
OptionSet은 첨부 매크로이므로, 구조체나 클래스 이름처럼 UpperCamelCase로 이름을 써요. 독립 매크로는 변수나 함수 이름처럼 lowerCamelCase로 써요.
Note: 매크로는 항상
public으로 선언해요. 매크로를 선언하는 코드가 매크로를 사용하는 코드와 다른 모듈에 있기 때문에, nonpublic 매크로를 적용할 자리가 애초에 없거든요.
매크로 선언은 매크로의 **역할(roles)**을 정의해요. 역할이란 이 매크로를 소스 코드의 어느 위치에서 호출할 수 있는지, 어떤 종류의 코드를 생성할 수 있는지를 말해요. 모든 매크로는 하나 이상의 역할을 갖고, 역할은 매크로 선언 맨 앞의 attribute로 적어요. @OptionSet의 역할 attribute까지 포함한 선언을 조금 더 볼게요.
@attached(member)
@attached(extension, conformances: OptionSet)
public macro OptionSet<RawType>() =
#externalMacro(module: "SwiftMacros", type: "OptionSetMacro")
@attached attribute가 이 선언에 두 번 나타나는데, 매크로 역할 하나당 한 번씩이에요. 첫 번째 @attached(member)는 이 매크로가 적용 대상 타입에 새 멤버를 추가한다는 뜻이에요. @OptionSet 매크로는 OptionSet 프로토콜이 요구하는 init(rawValue:) 이니셜라이저와 함께 몇몇 추가 멤버를 만들어요. 두 번째 @attached(extension, conformances: OptionSet)는 @OptionSet이 OptionSet 프로토콜에 대한 준수를 추가한다는 걸 알려줘요. @OptionSet 매크로는 매크로를 적용한 타입을 확장해서 OptionSet 프로토콜을 준수하게 만들어요.
독립 매크로는 @freestanding attribute를 써서 역할을 지정해요.
@freestanding(expression)
public macro line<T: ExpressibleByIntegerLiteral>() -> T =
/* ... location of the macro implementation... */
위의 #line 매크로는 expression 역할을 갖고 있어요. 표현식 매크로(expression macro)는 값을 만들어내거나, 경고를 생성하는 것 같은 컴파일 타임 동작을 수행해요.
매크로 선언은 역할 외에도 매크로가 생성하는 심볼의 이름 정보를 제공해요. 매크로 선언이 이름 목록을 제공하면, 매크로가 그 이름들을 사용한 선언만 만든다는 게 보장돼요. 그래서 생성된 코드를 이해하고 디버그하기가 더 쉬워져요. @OptionSet의 전체 선언을 볼게요.
@attached(member, names: named(RawValue), named(rawValue),
named(`init`), arbitrary)
@attached(extension, conformances: OptionSet)
public macro OptionSet<RawType>() =
#externalMacro(module: "SwiftMacros", type: "OptionSetMacro")
위 선언에서 @attached(member) 매크로는 names: 라벨 뒤에 @OptionSet 매크로가 생성하는 각 심볼에 대한 인자를 담고 있어요. 매크로는 RawValue, rawValue, init이라는 이름의 심볼에 대한 선언을 추가하는데, 이 이름들은 사전에 알 수 있으므로 매크로 선언에서 명시적으로 나열돼요.
매크로 선언은 이름 목록 뒤에 arbitrary도 포함하는데, 이건 매크로를 사용할 때까지 이름을 모르는 선언도 생성할 수 있게 해줘요. 예를 들어 @OptionSet 매크로를 위의 SundaeToppings에 적용하면, 매크로는 열거형 case에 해당하는 타입 프로퍼티 nuts, cherry, fudge를 생성해요.
역할의 전체 목록을 포함한 자세한 내용은 doc:Attributes의 doc:Attributes#attached와 doc:Attributes#freestanding을 참고하세요.
매크로 확장 (Macro Expansion)
매크로를 쓰는 Swift 코드를 빌드하면, 컴파일러가 매크로의 구현을 호출해서 매크로를 펼쳐요.
구체적으로 Swift는 다음과 같은 방식으로 매크로를 확장해요.
- 컴파일러가 코드를 읽어서, 구문의 인메모리 표현을 만들어요.
- 컴파일러가 인메모리 표현의 일부를 매크로 구현에 보내고, 구현이 매크로를 확장해요.
- 컴파일러가 매크로 호출을 확장된 형태로 교체해요.
- 컴파일러는 확장된 소스 코드를 사용해서 컴파일을 계속 진행해요.
구체적인 단계를 따라가 보기 위해 다음을 생각해 볼게요.
let magicNumber = #fourCharacterCode("ABCD")
#fourCharacterCode 매크로는 네 글자로 된 문자열을 받아, 그 문자열 안의 ASCII 값들을 이어 붙인 것에 해당하는 부호 없는 32비트 정수를 반환해요. 일부 파일 형식은 이런 정수로 데이터를 식별하는데, 압축돼 있으면서도 디버거에서 여전히 읽을 수 있기 때문이에요. 이 매크로를 구현하는 방법은 아래 doc:Macros#Implementing-a-Macro 단원에서 보여드릴게요.
위 코드의 매크로를 확장하기 위해 컴파일러는 Swift 파일을 읽고, 그 코드의 인메모리 표현을 만들어요. 이걸 추상 구문 트리(abstract syntax tree), 줄여서 AST라고 불러요. AST는 코드의 구조를 명확히 드러내기 때문에, 컴파일러나 매크로 구현처럼 그 구조와 상호작용하는 코드를 작성하기가 더 쉬워져요. 약간의 세부 사항을 생략해서 단순화한 위 코드의 AST 표현을 볼게요.
위 그림은 이 코드의 구조가 메모리에서 어떻게 표현되는지 보여줘요. AST의 각 요소는 소스 코드의 한 부분에 해당해요. "Constant declaration" AST 요소 밑에는 자식 요소 두 개가 있는데, 상수 선언의 두 부분, 즉 이름과 값을 나타내요. "Macro call" 요소는 매크로의 이름과 매크로에 전달되는 인자 목록을 나타내는 자식 요소들을 가져요.
이 AST를 만드는 과정의 일부로 컴파일러는 소스 코드가 유효한 Swift인지 검사해요. 예를 들어 #fourCharacterCode는 인자를 하나만 받는데, 그 인자는 문자열이어야 해요. 정수 인자를 넘기거나, 문자열 리터럴 끝의 따옴표(")를 빠뜨리면 이 과정에서 오류가 나요.
컴파일러는 코드에서 매크로를 호출하는 위치를 찾아서, 그 매크로를 구현하는 외부 바이너리를 로드해요. 매크로 호출마다 컴파일러는 AST의 일부를 그 매크로의 구현에 전달해요. 그 부분 AST의 표현은 이래요.
#fourCharacterCode 매크로의 구현은 이 부분 AST를 입력으로 읽어서 매크로를 확장해요. 매크로 구현은 입력으로 받은 부분 AST에만 작동해요. 즉 매크로는 앞뒤에 어떤 코드가 오든 항상 같은 방식으로 확장돼요. 이 제약 덕분에 매크로 확장을 이해하기 쉬워지고, Swift가 변경되지 않은 매크로의 확장을 건너뛸 수 있어서 코드를 더 빨리 빌드하는 데도 도움이 돼요.
Swift는 매크로를 구현하는 코드를 제한해서, 매크로 작성자가 실수로 다른 입력을 읽지 못하게 막아요.
- 매크로 구현에 전달되는 AST에는 매크로를 나타내는 AST 요소만 들어 있고, 그 앞뒤에 오는 코드는 들어 있지 않아요.
- 매크로 구현은 샌드박스 환경에서 실행되며, 파일 시스템이나 네트워크에 접근할 수 없어요.
이런 보호 장치 외에도 매크로 작성자는 매크로의 입력 밖에 있는 어떤 것도 읽거나 수정하지 않을 책임이 있어요. 예를 들어 매크로 확장은 그날의 시간에 의존하면 안 돼요.
#fourCharacterCode의 구현은 확장된 코드를 담은 새 AST를 생성해요. 이 코드가 컴파일러에 반환하는 것은 이래요.
컴파일러가 이 확장 결과를 받으면, 매크로 호출을 담은 AST 요소를 매크로 확장을 담은 요소로 교체해요. 매크로 확장 후에도 컴파일러는 프로그램이 여전히 문법적으로 올바른 Swift인지, 타입이 모두 맞는지 다시 검사해요. 그렇게 해서 보통 때처럼 컴파일할 수 있는 최종 AST가 생겨요.
이 AST는 다음과 같은 Swift 코드에 해당해요.
let magicNumber = 1145258561 as UInt32
이 예시의 입력 소스 코드에는 매크로가 하나뿐이지만, 실제 프로그램에는 같은 매크로의 인스턴스가 여러 개 있거나 서로 다른 매크로에 대한 호출이 여러 개 있을 수 있어요. 컴파일러는 매크로를 한 번에 하나씩 확장해요.
매크로 안에 다른 매크로가 있으면 바깥쪽 매크로가 먼저 확장돼요. 이렇게 하면 안쪽 매크로가 확장되기 전에 바깥쪽 매크로가 안쪽 매크로를 수정할 수 있어요.
매크로 구현하기 (Implementing a Macro)
매크로를 구현하려면 구성 요소 두 개를 만들어요. 하나는 매크로 확장을 수행하는 타입이고, 다른 하나는 매크로를 API로 노출하도록 선언하는 라이브러리예요. 매크로 구현은 매크로를 쓰는 클라이언트를 빌드하는 과정의 일부로 실행되기 때문에, 매크로와 그 클라이언트를 함께 개발하더라도 이 두 부분은 매크로를 사용하는 코드와 별도로 빌드돼요.
Swift Package Manager로 새 매크로를 만들려면 swift package init --type macro를 실행해요. 그러면 매크로 구현과 선언을 위한 템플릿을 포함한 여러 파일이 생성돼요.
기존 프로젝트에 매크로를 추가하려면 Package.swift 파일의 시작 부분을 이렇게 수정해요.
swift-tools-version주석에서 Swift tools 버전을 5.9 이상으로 설정해요.CompilerPluginSupport모듈을 임포트해요.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)],
// ...
)
다음으로 기존 Package.swift 파일에 매크로 구현 타깃과 매크로 라이브러리 타깃을 추가해요. 이름을 프로젝트에 맞게 바꿔서 아래처럼 추가하면 돼요.
targets: [
// Macro implementation that performs the source transformations.
.macro(
name: "MyProjectMacros",
dependencies: [
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
.product(name: "SwiftCompilerPlugin", package: "swift-syntax")
]
),
// Library that exposes a macro as part of its API.
.target(name: "MyProject", dependencies: ["MyProjectMacros"]),
]
위 코드는 타깃 두 개를 정의해요. MyProjectMacros는 매크로 구현을 담고, MyProject는 그 매크로들을 사용할 수 있게 해줘요.
매크로의 구현은 SwiftSyntax 모듈을 사용해서 AST를 통해 Swift 코드와 구조적으로 상호작용해요. Swift Package Manager로 새 매크로 패키지를 만들었다면 생성된 Package.swift 파일에 SwiftSyntax에 대한 의존성이 자동으로 포함돼요. 기존 프로젝트에 매크로를 추가한다면 Package.swift 파일에 SwiftSyntax 의존성을 추가해요.
dependencies: [
.package(url: "https://github.com/swiftlang/swift-syntax", from: "509.0.0")
],
매크로의 역할에 따라, 매크로 구현이 준수하는 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 Package Manager 프로젝트에 추가한다면, 매크로 타깃의 진입점 역할을 하고 타깃이 정의하는 매크로를 나열하는 타입도 추가해요.
import SwiftCompilerPlugin
@main
struct MyProjectMacros: CompilerPlugin {
var providingMacros: [Macro.Type] = [FourCharacterCode.self]
}
#fourCharacterCode 매크로는 표현식을 만들어내는 독립 매크로이므로, 이를 구현하는 FourCharacterCode 타입은 ExpressionMacro 프로토콜을 준수해요. ExpressionMacro 프로토콜의 요구사항은 하나뿐인데, AST를 확장하는 expansion(of:in:) 메서드예요. 매크로 역할과 그에 해당하는 SwiftSyntax 프로토콜 목록은 doc:Attributes의 doc:Attributes#attached와 doc:Attributes#freestanding을 참고하세요.
#fourCharacterCode 매크로를 확장하기 위해 Swift는 이 매크로를 사용하는 코드의 AST를 매크로 구현을 담은 라이브러리로 보내요. 라이브러리 안에서 Swift는 FourCharacterCode.expansion(of:in:)을 호출하고, AST와 컨텍스트를 메서드의 인자로 전달해요. expansion(of:in:)의 구현은 #fourCharacterCode에 인자로 전달된 문자열을 찾아서, 그에 해당하는 32비트 부호 없는 정수 리터럴 값을 계산해요.
위 예시에서 첫 번째 guard 블록은 AST에서 문자열 리터럴을 추출해서 그 AST 요소를 literalSegment에 할당해요. 두 번째 guard 블록은 private 함수 fourCharacterCode(for:)를 호출해요. 매크로를 잘못 쓰면 두 블록 모두 오류를 던지는데, 그 오류 메시지는 잘못된 호출 지점의 컴파일 오류가 돼요. 예를 들어 매크로를 #fourCharacterCode("AB" + "CD")처럼 호출하면 컴파일러가 "Need a static string" 오류를 보여줘요.
expansion(of:in:) 메서드는 AST에서 표현식을 나타내는 SwiftSyntax 타입인 ExprSyntax 인스턴스를 반환해요. 이 타입은 StringLiteralConvertible 프로토콜을 준수하므로, 매크로 구현은 문자열 리터럴을 가벼운 문법으로 사용해 결과를 만들 수 있어요. 매크로 구현에서 반환하는 모든 SwiftSyntax 타입은 StringLiteralConvertible을 준수하므로, 어떤 종류의 매크로든 구현할 때 이 방식을 쓸 수 있어요.
매크로 개발하고 디버그하기 (Developing and Debugging Macros)
매크로는 테스트를 이용한 개발에 아주 잘 맞아요. 매크로는 외부 상태에 의존하지 않고, 외부 상태를 바꾸지도 않으면서 AST 하나를 다른 AST로 변환하거든요. 게다가 문자열 리터럴로 구문 노드를 만들 수 있어서 테스트의 입력을 준비하기도 쉽고, AST의 description 프로퍼티를 읽어서 기댓값과 비교할 문자열을 얻을 수도 있어요. 예를 들어 앞 단원의 #fourCharacterCode 매크로를 테스트하면 이래요.
let source: SourceFileSyntax =
"""
let abcd = #fourCharacterCode("ABCD")
"""
let file = BasicMacroExpansionContext.KnownSourceFile(
moduleName: "MyModule",
fullFilePath: "test.swift"
)
let context = BasicMacroExpansionContext(sourceFiles: [source: file])
let transformedSF = source.expand(
macros:["fourCharacterCode": FourCharacterCode.self],
in: context
)
let expectedDescription =
"""
let abcd = 1145258561 as UInt32
"""
precondition(transformedSF.description == expectedDescription)
위 예시는 precondition으로 매크로를 테스트하지만, 테스팅 프레임워크를 써도 돼요.
더 알아보기
- Attributes — 매크로 역할과 관련 attribute의 전체 목록 (attached, freestanding)
- SwiftSyntax — 매크로 구현에서 AST를 다루는 데 쓰는 공식 라이브러리
- SE-0397: Freestanding Declaration Macros
- SE-0389: Attached Macros