Swift Macros: 메타프로그래밍 실전 예제
Swift Macros 완전 가이드: freestanding 및 attached 매크로 작성, swift-syntax를 활용한 AST 조작, 보일러플레이트를 줄이는 실전 예제 제공.

Swift 5.9와 Xcode 15에서 도입된 Swift Macros는 Swift 코드를 작성하는 방식에 혁신을 가져옵니다. 컴파일 시점에 코드를 생성할 수 있어 정적 타입 안전성을 유지하면서도 보일러플레이트를 제거할 수 있습니다. C 전처리기 매크로와 달리 Swift Macros는 타입 안전하고 컴파일러에 통합되어 있으며 개발 도구에서 완벽하게 지원됩니다.
이 가이드는 기본 개념부터 고급 구현까지 Swift Macros 작성 과정을 처음부터 끝까지 다룹니다. 어떤 iOS 프로젝트에도 바로 적용할 수 있는 동작 코드 예제를 함께 제공합니다.
Swift Macros의 종류 이해하기
Swift는 두 가지 주요 매크로 범주를 제공하며 각 범주는 서로 다른 사용 사례에 적합합니다. freestanding 매크로는 표현식이나 선언으로 단독 동작하고, attached 매크로는 기존 선언에 부착되어 이를 수정하거나 보완합니다.
freestanding 매크로: 표현식과 선언
freestanding 매크로는 # 기호로 시작하며 값을 반환(표현식)하거나 새로운 선언을 생성할 수 있습니다. 다음은 표현식 매크로의 구체적인 예시입니다.
// 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는 이 매크로에 다섯 가지 역할을 정의합니다.
// @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-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 매크로 예시입니다.
매크로 선언
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<T>(_ value: T?) -> T = #externalMacro(
module: "MyMacrosPlugin",
type: "UnwrapMacro"
)시그니처는 매크로가 옵셔널을 받아 옵셔널이 아닌 값을 반환한다는 것을 나타냅니다. #externalMacro는 플러그인 안의 구현을 가리킵니다.
swift-syntax로 구현하기
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를 반환합니다.
플러그인 등록
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
]
}이 진입점은 플러그인이 제공하는 매크로를 컴파일러에 알립니다.
iOS 면접 준비가 되셨나요?
인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.
attached의 member 매크로 만들기
member 매크로는 기존 타입에 멤버(프로퍼티, 메서드, 중첩 타입)를 추가합니다. 다음은 모든 저장 프로퍼티를 가진 이니셜라이저를 자동으로 생성하는 @AutoInit 매크로입니다.
선언과 구현 전체
/// Automatically generates an initializer with all stored properties
@attached(member, names: named(init))
public macro AutoInit() = #externalMacro(
module: "MyMacrosPlugin",
type: "AutoInitMacro"
)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 매크로 사용
@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 매크로입니다.
/// Automatically generates an async version of a function with completion handler
@attached(peer, names: suffixed(Async))
public macro AddAsync() = #externalMacro(
module: "MyMacrosPlugin",
type: "AddAsyncMacro"
)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 매크로 시연
class NetworkService {
@AddAsync
func fetchUser(
id: Int,
completion: @escaping (Result<User, Error>) -> 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)생성되는 함수의 이름은 @attached 속성의 names:에 선언해야 합니다. 여기서는 suffixed(Async)가 사용되어 원래 이름 뒤에 "Async" 접미사가 붙은 함수가 생성됨을 의미합니다.
매크로의 단위 테스트
매크로는 결과적으로 컴파일될 코드를 생성하므로 테스트가 필수입니다. Swift는 이런 테스트를 돕기 위해 SwiftSyntaxMacrosTestSupport를 제공합니다.
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
)
}
}이 테스트는 코드의 정확한 확장과 잘못된 사용에 대한 적절한 오류 메시지를 모두 검증합니다.
iOS 면접 준비가 되셨나요?
인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.
고급 매크로: Observable 프로퍼티 래퍼
이 매크로는 자동 알림이 포함된 프로퍼티 관찰 시스템을 만들기 위해 여러 역할을 결합합니다.
/// Adds automatic property change observation
@attached(accessor)
@attached(peer, names: prefixed(_))
public macro Observable() = #externalMacro(
module: "MyMacrosPlugin",
type: "ObservableMacro"
)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 패턴 사용
@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에서의 확장
// 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개발 중 로깅
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은 임의의 Swift 코드에 대한 구문 트리를 시각적으로 보여 줍니다. 매크로 구현 시 AST 노드를 어떻게 탐색해야 하는지 이해하는 데 매우 유용합니다.
Swift Macros 모범 사례
유지 보수가 가능한 매크로를 만들려면 몇 가지 관례를 지키고 흔히 발생하는 함정을 피해야 합니다.
검증과 오류 메시지
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)
}
}가독성 좋은 코드 생성
// ❌ 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으로 현대화하는 작업에 특히 유용합니다.
연습을 시작하세요!
면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.
태그
공유
관련 기사

Swift에서 Combine vs async/await: 점진적 마이그레이션 패턴
Swift에서 Combine에서 async/await로 마이그레이션하는 완전한 가이드: 점진적 전략, 브리징 패턴, iOS 코드베이스의 패러다임 공존.

2026년 iOS 접근성 면접 질문: VoiceOver와 Dynamic Type
iOS 면접 대비를 위한 핵심 접근성 질문: VoiceOver, Dynamic Type, 시맨틱 traits, 접근성 감사.

StoreKit 2 인터뷰: 구독 관리 및 영수증 검증
StoreKit 2, 구독 관리, 영수증 검증, 인앱 구매 구현에 관한 iOS 인터뷰 질문을 실용적인 Swift 코드 예제와 함께 마스터하십시오.