# Swift Macros: 메타프로그래밍 실전 예제 > Swift Macros 완전 가이드: freestanding 및 attached 매크로 작성, swift-syntax를 활용한 AST 조작, 보일러플레이트를 줄이는 실전 예제 제공. - Published: 2026-03-11 - Updated: 2026-05-01 - Author: SharpSkill - Tags: swift, ios, macros, metaprogramming, swift-syntax - Reading time: 16 min --- Swift 5.9와 Xcode 15에서 도입된 Swift Macros는 Swift 코드를 작성하는 방식에 혁신을 가져옵니다. 컴파일 시점에 코드를 생성할 수 있어 정적 타입 안전성을 유지하면서도 보일러플레이트를 제거할 수 있습니다. C 전처리기 매크로와 달리 Swift Macros는 타입 안전하고 컴파일러에 통합되어 있으며 개발 도구에서 완벽하게 지원됩니다. > **이 가이드의 범위** > > 이 가이드는 기본 개념부터 고급 구현까지 Swift Macros 작성 과정을 처음부터 끝까지 다룹니다. 어떤 iOS 프로젝트에도 바로 적용할 수 있는 동작 코드 예제를 함께 제공합니다. ## Swift Macros의 종류 이해하기 Swift는 두 가지 주요 매크로 범주를 제공하며 각 범주는 서로 다른 사용 사례에 적합합니다. freestanding 매크로는 표현식이나 선언으로 단독 동작하고, attached 매크로는 기존 선언에 부착되어 이를 수정하거나 보완합니다. ### freestanding 매크로: 표현식과 선언 freestanding 매크로는 `#` 기호로 시작하며 값을 반환(표현식)하거나 새로운 선언을 생성할 수 있습니다. 다음은 표현식 매크로의 구체적인 예시입니다. ```swift // MacroUsage.swift // Freestanding expression macro - generates a value let buildInfo = #buildDate // Expansion → "2026-03-11 10:30:45" // Freestanding macro with arguments let message = #stringify(1 + 2) // Expansion → "1 + 2 = 3" // Freestanding declaration macro - creates declarations #makeCase("success", "failure", "pending") // Expansion → // case success // case failure // case pending ``` 표현식과 선언의 핵심 차이는 결과에 있습니다. 표현식은 값을 만들어내고, 선언은 구조적 코드(타입, 함수, 변수)를 생성합니다. ### attached 매크로: 다섯 가지 역할 attached 매크로는 `@`로 시작하여 선언 앞에 위치합니다. Swift는 이 매크로에 다섯 가지 역할을 정의합니다. ```swift // AttachedMacroRoles.swift // @attached(peer) - adds declarations at the same level @AddAsync func fetchUser(id: Int) -> User { ... } // Expansion → adds func fetchUserAsync(id: Int) async -> User // @attached(accessor) - adds getters/setters @UserDefault("theme") var currentTheme: String // Expansion → adds get { UserDefaults.standard.string(...) } // @attached(member) - adds members to a type @AutoEquatable struct Point { var x: Int var y: Int } // Expansion → adds static func == (lhs: Point, rhs: Point) -> Bool // @attached(memberAttribute) - applies attributes to members @CodableKeys struct Config { var apiUrl: String var timeout: Int } // Expansion → adds @CodingKey("api_url") before apiUrl // @attached(conformance) / @attached(extension) - adds conformances @Hashable struct User { var id: Int var name: String } // Expansion → adds extension User: Hashable { ... } ``` 이 역할을 조합하면 코드를 동시에 여러 측면에서 변환하는 강력한 매크로를 만들 수 있습니다. ## 매크로를 만들기 위한 프로젝트 구성 Swift Macros를 만들려면 특정한 구조의 Swift Package가 필요합니다. 이 패키지는 Swift 코드를 추상 구문 트리(AST)로 다루는 공식 라이브러리 swift-syntax에 의존합니다. ### Package.swift 구조 ```swift // Package.swift // swift-tools-version: 5.9 import PackageDescription let package = Package( name: "MyMacros", platforms: [.macOS(.v10_15), .iOS(.v13)], products: [ // Library exposing macros to the main project .library( name: "MyMacros", targets: ["MyMacros"] ), // Executable for testing macros .executable( name: "MyMacrosClient", targets: ["MyMacrosClient"] ) ], dependencies: [ // Required dependency for macros .package( url: "https://github.com/apple/swift-syntax.git", from: "509.0.0" ) ], targets: [ // Compiler plugin containing implementation .macro( name: "MyMacrosPlugin", dependencies: [ .product(name: "SwiftSyntax", package: "swift-syntax"), .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), .product(name: "SwiftCompilerPlugin", package: "swift-syntax") ] ), // Target exposing macro declarations .target( name: "MyMacros", dependencies: ["MyMacrosPlugin"] ), // Test client .executableTarget( name: "MyMacrosClient", dependencies: ["MyMacros"] ), // Unit tests .testTarget( name: "MyMacrosTests", dependencies: [ "MyMacrosPlugin", .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax") ] ) ] ) ``` 이 구성은 매크로의 선언(클라이언트 코드가 보는 부분)과 구현(컴파일 시점에 실행되는 부분)을 명확히 분리합니다. > **권장 구성** > > 최소 세 개의 파일이 필요합니다. 선언을 위한 MyMacros.swift, 구현을 위한 MyMacrosPlugin.swift, 테스트를 위한 MyMacrosTests.swift입니다. 이러한 분리는 유지 보수를 한층 수월하게 만듭니다. ## 표현식 매크로 만들기 표현식 매크로는 코드에서 곧바로 사용할 수 있는 값을 생성합니다. 다음은 변수 이름을 포함한 사용자 정의 오류 메시지와 함께 옵셔널을 언래핑하는 `#unwrap` 매크로 예시입니다. ### 매크로 선언 ```swift // MyMacros.swift import Foundation /// Macro that unwraps an optional with an explicit error message /// Usage: let value = #unwrap(optionalValue) /// Expansion: guard let optionalValue else { fatalError("...") }; optionalValue @freestanding(expression) public macro unwrap(_ value: T?) -> T = #externalMacro( module: "MyMacrosPlugin", type: "UnwrapMacro" ) ``` 시그니처는 매크로가 옵셔널을 받아 옵셔널이 아닌 값을 반환한다는 것을 나타냅니다. `#externalMacro`는 플러그인 안의 구현을 가리킵니다. ### swift-syntax로 구현하기 ```swift // UnwrapMacro.swift import SwiftSyntax import SwiftSyntaxMacros import SwiftCompilerPlugin public struct UnwrapMacro: ExpressionMacro { public static func expansion( of node: some FreestandingMacroExpansionSyntax, in context: some MacroExpansionContext ) throws -> ExprSyntax { // Get the first argument passed to the macro guard let argument = node.argumentList.first?.expression else { throw MacroError.missingArgument } // Extract the variable name for the error message let variableName = argument.description.trimmingCharacters( in: .whitespacesAndNewlines ) // Generate the expansion code // Uses an immediately-invoked closure to encapsulate the guard return """ { guard let value = \(argument) else { fatalError("Failed to unwrap '\\(\(literal: variableName))' - value was nil") } return value }() """ } } // Custom errors for macros enum MacroError: Error, CustomStringConvertible { case missingArgument case invalidSyntax(String) var description: String { switch self { case .missingArgument: return "The macro requires an argument" case .invalidSyntax(let message): return "Invalid syntax: \(message)" } } } ``` `expansion` 메서드는 매크로 호출을 나타내는 AST 노드와 컴파일 컨텍스트를 받아 생성된 코드가 담긴 `ExprSyntax`를 반환합니다. ### 플러그인 등록 ```swift // MyMacrosPlugin.swift import SwiftCompilerPlugin import SwiftSyntaxMacros @main struct MyMacrosPlugin: CompilerPlugin { // List all macros provided by this plugin let providingMacros: [Macro.Type] = [ UnwrapMacro.self, // Add other macros here ] } ``` 이 진입점은 플러그인이 제공하는 매크로를 컴파일러에 알립니다. ## attached의 member 매크로 만들기 member 매크로는 기존 타입에 멤버(프로퍼티, 메서드, 중첩 타입)를 추가합니다. 다음은 모든 저장 프로퍼티를 가진 이니셜라이저를 자동으로 생성하는 `@AutoInit` 매크로입니다. ### 선언과 구현 전체 ```swift // MyMacros.swift /// Automatically generates an initializer with all stored properties @attached(member, names: named(init)) public macro AutoInit() = #externalMacro( module: "MyMacrosPlugin", type: "AutoInitMacro" ) ``` ```swift // AutoInitMacro.swift import SwiftSyntax import SwiftSyntaxMacros public struct AutoInitMacro: MemberMacro { public static func expansion( of node: AttributeSyntax, providingMembersOf declaration: some DeclGroupSyntax, in context: some MacroExpansionContext ) throws -> [DeclSyntax] { // Verify the macro is applied to a struct or class guard declaration.is(StructDeclSyntax.self) || declaration.is(ClassDeclSyntax.self) else { throw MacroError.invalidSyntax( "@AutoInit can only be applied to structs and classes" ) } // Collect stored properties let properties = declaration.memberBlock.members .compactMap { $0.decl.as(VariableDeclSyntax.self) } .filter { isStoredProperty($0) } // Generate initializer parameters let parameters = properties.compactMap { property -> String? in guard let binding = property.bindings.first, let identifier = binding.pattern.as(IdentifierPatternSyntax.self), let type = binding.typeAnnotation?.type else { return nil } let name = identifier.identifier.text let typeName = type.description.trimmingCharacters(in: .whitespaces) // Check if the property has a default value if binding.initializer != nil { return "\(name): \(typeName) = \(binding.initializer!.value)" } return "\(name): \(typeName)" } // Generate assignments in the init body let assignments = properties.compactMap { property -> String? in guard let binding = property.bindings.first, let identifier = binding.pattern.as(IdentifierPatternSyntax.self) else { return nil } let name = identifier.identifier.text return "self.\(name) = \(name)" } // Build the complete initializer let initDecl: DeclSyntax = """ public init(\(raw: parameters.joined(separator: ", "))) { \(raw: assignments.joined(separator: "\n ")) } """ return [initDecl] } // Check if a variable is a stored property (not computed) private static func isStoredProperty(_ variable: VariableDeclSyntax) -> Bool { guard let binding = variable.bindings.first else { return false } // A computed property has an accessor block with get/set if let accessor = binding.accessorBlock { // If it's a block with explicit accessors, it's computed if accessor.accessors.is(AccessorDeclListSyntax.self) { return false } } // let or var without accessor = stored property return true } } ``` ### AutoInit 매크로 사용 ```swift // UserModel.swift @AutoInit struct User { let id: UUID var name: String var email: String var isActive: Bool = true } // Automatically generated code: // public init(id: UUID, name: String, email: String, isActive: Bool = true) { // self.id = id // self.name = name // self.email = email // self.isActive = isActive // } // Usage let user = User(id: UUID(), name: "Alice", email: "alice@example.com") // isActive uses the default value ``` 이 매크로는 이니셜라이저 보일러플레이트를 줄여 주므로, 프로퍼티가 많은 데이터 모델에서 특히 가치가 큽니다. ## async 코드 생성을 위한 attached peer 매크로 peer 매크로는 어노테이션이 붙은 선언과 같은 수준에 새로운 선언을 추가합니다. 다음은 completion handler 기반 함수의 async 버전을 생성하는 `@AddAsync` 매크로입니다. ```swift // MyMacros.swift /// Automatically generates an async version of a function with completion handler @attached(peer, names: suffixed(Async)) public macro AddAsync() = #externalMacro( module: "MyMacrosPlugin", type: "AddAsyncMacro" ) ``` ```swift // AddAsyncMacro.swift import SwiftSyntax import SwiftSyntaxMacros public struct AddAsyncMacro: PeerMacro { public static func expansion( of node: AttributeSyntax, providingPeersOf declaration: some DeclSyntax, in context: some MacroExpansionContext ) throws -> [DeclSyntax] { // Verify it's a function guard let funcDecl = declaration.as(FunctionDeclSyntax.self) else { throw MacroError.invalidSyntax( "@AddAsync requires a function" ) } let functionName = funcDecl.name.text let asyncFunctionName = "\(functionName)Async" // Analyze parameters to find the completion handler let parameters = funcDecl.signature.parameterClause.parameters // Filter parameters (exclude completion handler) var regularParams: [String] = [] var completionType: String? = nil for param in parameters { let paramType = param.type.description // Detect a completion handler (closure with Result or simple value) if paramType.contains("->") && paramType.contains("Void") { // Extract the return type from completion completionType = extractCompletionReturnType(from: paramType) } else { let paramName = param.firstName.text let paramSecondName = param.secondName?.text let label = paramSecondName ?? paramName regularParams.append("\(paramName): \(paramType)") } } guard let returnType = completionType else { throw MacroError.invalidSyntax( "No completion handler found" ) } // Generate arguments for internal call let callArgs = parameters.dropLast().map { param in let name = param.firstName.text return "\(name): \(name)" }.joined(separator: ", ") // Generate the async function let asyncFunc: DeclSyntax = """ func \(raw: asyncFunctionName)(\(raw: regularParams.joined(separator: ", "))) async throws -> \(raw: returnType) { try await withCheckedThrowingContinuation { continuation in \(raw: functionName)(\(raw: callArgs.isEmpty ? "" : callArgs + ", ")completion: { result in switch result { case .success(let value): continuation.resume(returning: value) case .failure(let error): continuation.resume(throwing: error) } }) } } """ return [asyncFunc] } // Extract return type from a Result type private static func extractCompletionReturnType(from type: String) -> String { // Simplified pattern - in production, use the AST if let match = type.range(of: #"Result<([^,]+)"#, options: .regularExpression) { var result = String(type[match]) result = result.replacingOccurrences(of: "Result<", with: "") return result.trimmingCharacters(in: .whitespaces) } return "Void" } } ``` ### AddAsync 매크로 시연 ```swift // NetworkService.swift class NetworkService { @AddAsync func fetchUser( id: Int, completion: @escaping (Result) -> Void ) { // Implementation with callback URLSession.shared.dataTask(with: URL(string: "/users/\(id)")!) { data, _, error in if let error = error { completion(.failure(error)) } else if let data = data { let user = try? JSONDecoder().decode(User.self, from: data) completion(.success(user!)) } }.resume() } // Automatically generates: // func fetchUserAsync(id: Int) async throws -> User { // try await withCheckedThrowingContinuation { continuation in // fetchUser(id: id, completion: { result in // switch result { // case .success(let value): // continuation.resume(returning: value) // case .failure(let error): // continuation.resume(throwing: error) // } // }) // } // } } // Modern usage with async/await let user = try await networkService.fetchUserAsync(id: 42) ``` > **peer 매크로의 제약** > > 생성되는 함수의 이름은 `@attached` 속성의 `names:`에 선언해야 합니다. 여기서는 `suffixed(Async)`가 사용되어 원래 이름 뒤에 "Async" 접미사가 붙은 함수가 생성됨을 의미합니다. ## 매크로의 단위 테스트 매크로는 결과적으로 컴파일될 코드를 생성하므로 테스트가 필수입니다. Swift는 이런 테스트를 돕기 위해 `SwiftSyntaxMacrosTestSupport`를 제공합니다. ```swift // MyMacrosTests.swift import SwiftSyntaxMacros import SwiftSyntaxMacrosTestSupport import XCTest @testable import MyMacrosPlugin final class MyMacrosTests: XCTestCase { // Dictionary of macros to test let testMacros: [String: Macro.Type] = [ "unwrap": UnwrapMacro.self, "AutoInit": AutoInitMacro.self, "AddAsync": AddAsyncMacro.self ] func testUnwrapMacroExpansion() throws { assertMacroExpansion( """ let value = #unwrap(optionalString) """, expandedSource: """ let value = { guard let value = optionalString else { fatalError("Failed to unwrap 'optionalString' - value was nil") } return value }() """, macros: testMacros ) } func testAutoInitMacroWithStruct() throws { assertMacroExpansion( """ @AutoInit struct Point { let x: Int var y: Int } """, expandedSource: """ struct Point { let x: Int var y: Int public init(x: Int, y: Int) { self.x = x self.y = y } } """, macros: testMacros ) } func testAutoInitWithDefaultValues() throws { assertMacroExpansion( """ @AutoInit struct Config { var timeout: Int = 30 var retryCount: Int } """, expandedSource: """ struct Config { var timeout: Int = 30 var retryCount: Int public init(timeout: Int = 30, retryCount: Int) { self.timeout = timeout self.retryCount = retryCount } } """, macros: testMacros ) } func testAutoInitFailsOnEnum() throws { assertMacroExpansion( """ @AutoInit enum Status { case active } """, expandedSource: """ enum Status { case active } """, diagnostics: [ DiagnosticSpec( message: "@AutoInit can only be applied to structs and classes", line: 1, column: 1 ) ], macros: testMacros ) } } ``` 이 테스트는 코드의 정확한 확장과 잘못된 사용에 대한 적절한 오류 메시지를 모두 검증합니다. ## 고급 매크로: Observable 프로퍼티 래퍼 이 매크로는 자동 알림이 포함된 프로퍼티 관찰 시스템을 만들기 위해 여러 역할을 결합합니다. ```swift // MyMacros.swift /// Adds automatic property change observation @attached(accessor) @attached(peer, names: prefixed(_)) public macro Observable() = #externalMacro( module: "MyMacrosPlugin", type: "ObservableMacro" ) ``` ```swift // ObservableMacro.swift import SwiftSyntax import SwiftSyntaxMacros // Implements both roles: accessor and peer public enum ObservableMacro {} extension ObservableMacro: AccessorMacro { public static func expansion( of node: AttributeSyntax, providingAccessorsOf declaration: some DeclSyntax, in context: some MacroExpansionContext ) throws -> [AccessorDeclSyntax] { guard let varDecl = declaration.as(VariableDeclSyntax.self), let binding = varDecl.bindings.first, let identifier = binding.pattern.as(IdentifierPatternSyntax.self) else { return [] } let name = identifier.identifier.text let storageName = "_\(name)" // Generate get and set accessors let getter: AccessorDeclSyntax = """ get { access(keyPath: \\.\(raw: name)) return \(raw: storageName) } """ let setter: AccessorDeclSyntax = """ set { withMutation(keyPath: \\.\(raw: name)) { \(raw: storageName) = newValue } } """ return [getter, setter] } } extension ObservableMacro: PeerMacro { public static func expansion( of node: AttributeSyntax, providingPeersOf declaration: some DeclSyntax, in context: some MacroExpansionContext ) throws -> [DeclSyntax] { guard let varDecl = declaration.as(VariableDeclSyntax.self), let binding = varDecl.bindings.first, let identifier = binding.pattern.as(IdentifierPatternSyntax.self), let type = binding.typeAnnotation?.type else { return [] } let name = identifier.identifier.text let storageName = "_\(name)" let typeName = type.description // Generate private storage property let initializer = binding.initializer.map { " \($0)" } ?? "" let storageDecl: DeclSyntax = """ private var \(raw: storageName): \(raw: typeName)\(raw: initializer) """ return [storageDecl] } } ``` ### Observable 패턴 사용 ```swift // ViewModel.swift @Observable class UserViewModel { @Observable var name: String = "" @Observable var age: Int = 0 @Observable var isActive: Bool = true // Generated code for each property: // private var _name: String = "" // var name: String { // get { // access(keyPath: \.name) // return _name // } // set { // withMutation(keyPath: \.name) { // _name = newValue // } // } // } } ``` 애플은 Swift 5.9 이후의 새로운 Observation 프레임워크에서도 동일한 패턴을 사용합니다. ## 매크로 디버깅과 조사 Xcode는 매크로를 디버깅하고 생성된 코드를 이해할 수 있도록 여러 도구를 제공합니다. ### Xcode에서의 확장 ```swift // DebuggingMacros.swift // Right-click on macro call → "Expand Macro" // Displays generated code inline @AutoInit struct Product { let id: UUID var name: String var price: Decimal } // To see the expansion: // 1. Right-click on @AutoInit // 2. Select "Expand Macro" // 3. Generated code displays inline for inspection and debugging ``` ### 개발 중 로깅 ```swift // DebugMacro.swift public struct DebugMacro: ExpressionMacro { public static func expansion( of node: some FreestandingMacroExpansionSyntax, in context: some MacroExpansionContext ) throws -> ExprSyntax { // Print the node's AST to understand the structure print("=== DEBUG MACRO ===") print("Node: \(node)") print("Arguments: \(node.argumentList)") // Complete dump of the syntax tree dump(node) // Continue with normal expansion return "42" } } ``` ### swift-ast-explorer로 AST 살펴보기 온라인 도구 [swift-ast-explorer.com](https://swift-ast-explorer.com)은 임의의 Swift 코드에 대한 구문 트리를 시각적으로 보여 줍니다. 매크로 구현 시 AST 노드를 어떻게 탐색해야 하는지 이해하는 데 매우 유용합니다. ## Swift Macros 모범 사례 유지 보수가 가능한 매크로를 만들려면 몇 가지 관례를 지키고 흔히 발생하는 함정을 피해야 합니다. ### 검증과 오류 메시지 ```swift // ValidationBestPractices.swift public struct ValidatedMacro: MemberMacro { public static func expansion( of node: AttributeSyntax, providingMembersOf declaration: some DeclGroupSyntax, in context: some MacroExpansionContext ) throws -> [DeclSyntax] { // ✅ Validate usage context guard declaration.is(StructDeclSyntax.self) else { // ✅ Clear error messages with possible localization context.diagnose( Diagnostic( node: node, message: MacroDiagnosticMessage( id: "invalid-target", message: "This macro can only be applied to structs", severity: .error ) ) ) return [] } // ✅ Check required arguments guard let arguments = node.arguments else { context.diagnose( Diagnostic( node: node, message: MacroDiagnosticMessage( id: "missing-args", message: "Required arguments missing", severity: .error ) ) ) return [] } // Implementation... return [] } } // Structure for diagnostic messages struct MacroDiagnosticMessage: DiagnosticMessage { let id: String let message: String let severity: DiagnosticSeverity var diagnosticID: MessageID { MessageID(domain: "MyMacros", id: id) } } ``` ### 가독성 좋은 코드 생성 ```swift // ReadableCodeGeneration.swift // ❌ Hard-to-read generated code let badCode: DeclSyntax = "public init(a:Int,b:String,c:Bool){self.a=a;self.b=b;self.c=c}" // ✅ Properly formatted generated code let goodCode: DeclSyntax = """ public init( a: Int, b: String, c: Bool ) { self.a = a self.b = b self.c = c } """ ``` 생성된 코드는 "Expand Macro"를 통해 개발자가 직접 살펴보기 때문에 손으로 작성한 코드만큼 읽기 쉬워야 합니다. ## 결론 Swift Macros는 정적 타입 안전성을 유지하면서 보일러플레이트를 제거할 수 있는 강력한 도구입니다. 이 기술은 다음을 가능하게 합니다. **핵심 포인트:** - ✅ 두 가지 카테고리: freestanding(`#`)과 attached(`@`) - ✅ attached의 다섯 가지 역할: peer, accessor, member, memberAttribute, conformance - ✅ swift-syntax와 AST 조작을 이용한 구현 - ✅ `SwiftSyntaxMacrosTestSupport`를 활용한 필수 테스트 - ✅ 구현을 위해 별도 패키지가 필요 - ✅ Xcode의 "Expand Macro"를 통한 디버깅 - ✅ 개발자 경험에 필수적인 명확한 오류 메시지 Swift Macros는 Equatable, Codable과 같은 conformance 생성, 고급 프로퍼티 래퍼 작성, 콜백 기반 API를 async/await으로 현대화하는 작업에 특히 유용합니다. --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/ko/blog/ios/swift-macros-practical-metaprogramming-examples