Swift 매크로(Swift Macros)는 컴파일 타임에 코드를 생성해 반복적인 보일러플레이트를 제거하는 Swift 5.9 이상의 메타프로그래밍 기능이며, @freestanding과 @attached 두 가지 역할로 나뉜다. @Observable, @Model, @Test가 모두 이 기술 위에 서 있다. 이 글은 SwiftSyntax를 활용해 커스텀 Swift 매크로를 처음부터 직접 만드는 방법을 iOS, macOS, visionOS 배포 관점에서 정리한 실전 가이드다. Xcode 16 이상, Swift 6.2 툴체인을 기준으로 한다.
Swift 매크로는 @freestanding(#macro 형태 호출)과 @attached(선언에 붙는 형태) 두 종류이며, 후자는 다시 member, peer, accessor, extension, memberAttribute 등 5개 역할로 세분화된다.
매크로 패키지는 반드시 SwiftSyntaxMacros와 SwiftCompilerPlugin 라이브러리를 의존성으로 가지는 플러그인 타깃과 이를 #externalMacro로 노출하는 라이브러리 타깃, 두 개의 타깃으로 구성된다.
Swift 6.2 툴체인에서는 swift-syntax 601.0.0 이상을 사용해야 하며, macOS 호스트에서만 매크로가 실행되지만 결과 코드는 iOS, watchOS, tvOS, visionOS 어디로든 배포된다.
매크로 테스트는 SwiftSyntaxMacrosTestSupport의 assertMacroExpansion으로 원본 소스와 확장 결과를 문자열 비교하는 방식이며, Swift Testing의 #expect와도 호환된다.
Xcode 15부터 매크로 확장 결과는 우클릭 → "Expand Macro"로 즉시 확인 가능하며, 크래시 대신 DiagnosticMessage로 컴파일 에러를 사용자에게 전달하는 것이 정석이다.
Swift 매크로란 무엇인가
Swift 매크로는 SE-0389 Attached Macros와 SE-0382 Expression Macros를 통해 Swift 5.9에 정식 도입된, 컴파일 타임에 실행되는 코드 생성 시스템이다. C 매크로처럼 텍스트를 치환하는 방식이 아니라, 컴파일러가 소스 코드를 SwiftSyntax 트리로 파싱한 뒤 매크로 구현체(플러그인 프로세스)에 넘겨 새로운 트리를 돌려받는다. 반환된 트리는 다시 타입 검사기로 들어가므로, 잘못된 코드를 뱉으면 그대로 컴파일 에러가 난다.
내가 실무에서 처음 매크로를 붙였던 건 iPad용 앱의 결제 이벤트 로깅 코드였다. 40여 개의 이벤트 각각에 ObservableObject 프로퍼티, willSet, 그리고 분석 서버 전송 코드를 붙이는 일이 지겨웠고, @LoggedEvent 하나로 대체하고 나니 유지보수 관점에서 완전히 다른 파일이 됐다. iOS, iPadOS, macOS Catalyst에서 동일하게 동작한다는 점이 특히 컸다. Mac Catalyst에서 매크로 런타임 부담이 있지 않을까 걱정했지만, 매크로는 빌드 타임에만 실행되므로 런타임 오버헤드는 정확히 0이다.
@freestanding은 #url("...")처럼 독립된 표현식이나 선언 위치에서 호출되는 매크로이고, @attached는 @LoggedEvent class Foo처럼 기존 선언에 붙어 그 선언을 변형하는 매크로다. 둘 다 SwiftSyntax 트리를 조작하지만, 입력과 반환 시그니처가 다르다.
실무 판단 기준은 간단하다. "무언가 새 값이 필요하다"면 @freestanding, "기존 타입에 무언가 추가하고 싶다"면 @attached다. 두 매크로를 하나의 심볼에 동시에 붙여 하이브리드로 만드는 것도 가능한데, @Observable이 실제로 @attached(member, names: ...)와 @attached(memberAttribute)와 @attached(extension, conformances: Observable)을 함께 붙인 다중 역할 매크로다.
매크로 패키지 프로젝트 구조 만들기
Swift 매크로는 반드시 Swift Package로 만들어야 한다. Xcode에서 "File → New → Package…"를 열고 "Swift Macro" 템플릿을 선택하면 아래와 비슷한 Package.swift가 생성된다. 여기서는 MyMacros라는 패키지를 예로 들겠다.
구조가 두 층이라는 점이 중요하다. 사용자는 import MyMacros만 하고, 이 라이브러리는 #externalMacro(module: "MyMacrosPlugin", type: "URLMacro")를 통해 실제 구현 타입을 가리키는 얇은 껍데기 역할만 한다. 이 분리 덕분에 iOS 앱은 매크로 구현 자체를 링크하지 않는다. Xcode가 처음 매크로를 컴파일할 때 "Trust & Enable" 다이얼로그가 뜨는데, 이건 매크로 플러그인이 로컬 프로세스로 실행되기 때문에 요구되는 신뢰 확인이다. CI에서는 defaults write com.apple.dt.Xcode IDESkipMacroFingerprintValidation -bool YES로 자동 승인 처리가 가능하다.
첫 번째 @freestanding 매크로: #URL 예제
가장 직관적인 예시로 #URL("https://example.com")을 만들어 보자. URL(string:)이 옵셔널을 반환하는 게 짜증날 때, 컴파일 타임에 유효성을 검증해서 URL(non-optional)을 돌려주는 매크로다. iOS 15의 Foundation.URL(string:)은 여전히 nullable을 반환하지만, 리터럴 URL은 컴파일 타임에 잡을 수 있다.
이제 사용자 코드에서 let api = #URL("https://api.example.com")이라고 쓰면 URL(string: "https://api.example.com")!로 확장된다. 오타를 내면 컴파일 자체가 실패한다. 이 패턴은 정규표현식, 파일 경로, JSON 스키마 검증 등 "리터럴에 대한 컴파일 타임 검사가 필요한 모든 곳"에 재사용 가능하다.
@attached(member)로 자동 프로퍼티 생성
@attached(member)는 붙은 타입 내부에 새 멤버를 추가한다. 실무 예시로 구조체 안에 정적 상수로 정의된 사례들을 배열로 노출하는 @AllValues 매크로를 만들어 보자. CaseIterable이 이미 있지 않느냐고 물을 수 있는데, 우리 예제는 열거형이 아닌 구조체에도 사용할 수 있다는 점이 다르다.
// 사용자 API
@attached(member, names: named(allValues))
public macro AllValues() =
#externalMacro(module: "MyMacrosPlugin", type: "AllValuesMacro")
// 사용 예
@AllValues
struct FeatureFlag {
static let onboarding = FeatureFlag(id: "onboarding")
static let liquidGlass = FeatureFlag(id: "liquid_glass")
let id: String
}
// 확장 결과:
// static let allValues: [FeatureFlag] = [.onboarding, .liquidGlass]
구현체는 MemberMacro 프로토콜을 채택한다. 붙은 선언에서 정적 let 프로퍼티를 스캔한 뒤 배열 리터럴을 생성한다.
public struct AllValuesMacro: MemberMacro {
public static func expansion(
of node: AttributeSyntax,
providingMembersOf declaration: some DeclGroupSyntax,
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
// 정적 저장 프로퍼티만 필터링
let staticMembers = declaration.memberBlock.members
.compactMap { $0.decl.as(VariableDeclSyntax.self) }
.filter { $0.modifiers.contains { $0.name.text == "static" } }
.flatMap { $0.bindings }
.compactMap { $0.pattern.as(IdentifierPatternSyntax.self)?.identifier.text }
let elements = staticMembers.map { ".\($0)" }.joined(separator: ", ")
let selfType = declaration.as(StructDeclSyntax.self)?.name.text
?? declaration.as(ClassDeclSyntax.self)?.name.text
?? "Self"
return [
"static let allValues: [\(raw: selfType)] = [\(raw: elements)]"
]
}
}
names: 인수에 named(allValues)를 명시해야 컴파일러가 이 매크로가 생성할 심볼을 미리 알 수 있다. 이걸 생략하면 정적 검사 단계에서 심볼을 찾지 못해 에러가 난다. 여러 이름을 생성하려면 names: named(a), named(b)처럼 나열하거나 arbitrary를 쓴다.
@attached(peer)와 @attached(accessor) 활용
@attached(peer)는 붙은 선언 옆에 새로운 선언을 추가한다. 예를 들어 함수에 붙여 async 버전을 자동 생성하거나, 프로퍼티에 붙여 백킹 스토리지를 만들 수 있다. Combine의 @Published가 하는 일을 흉내내 보자.
@attached(peer, names: prefixed(_))
public macro Backed() =
#externalMacro(module: "MyMacrosPlugin", type: "BackedMacro")
// 사용:
class Store {
@Backed var count: Int = 0
}
// 확장 결과: private var _count: Int = 0 을 추가
public struct BackedMacro: PeerMacro {
public static func expansion(
of node: AttributeSyntax,
providingPeersOf declaration: some DeclSyntaxProtocol,
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
guard let variable = declaration.as(VariableDeclSyntax.self),
let binding = variable.bindings.first,
let name = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier.text,
let typeAnnotation = binding.typeAnnotation?.type
else { return [] }
let initValue = binding.initializer?.value.description ?? "nil"
return [
"private var _\(raw: name): \(typeAnnotation) = \(raw: initValue)"
]
}
}
@attached(accessor)는 저장 프로퍼티를 계산 프로퍼티로 변환하는 역할이다. SwiftUI의 @Environment가 내부적으로 이 방식으로 get과 set을 주입한다. 아래는 UserDefaults에 값을 위임하는 @UserDefault 예시다.
@attached(accessor)
public macro UserDefault(_ key: String) =
#externalMacro(module: "MyMacrosPlugin", type: "UserDefaultMacro")
public struct UserDefaultMacro: AccessorMacro {
public static func expansion(
of node: AttributeSyntax,
providingAccessorsOf declaration: some DeclSyntaxProtocol,
in context: some MacroExpansionContext
) throws -> [AccessorDeclSyntax] {
guard case let .argumentList(args) = node.arguments,
let key = args.first?.expression.as(StringLiteralExprSyntax.self)
else { return [] }
return [
"""
get { UserDefaults.standard.value(forKey: \(key)) as? Value ?? defaultValue }
""",
"""
set { UserDefaults.standard.set(newValue, forKey: \(key)) }
"""
]
}
}
iPad와 Mac Catalyst 앱에서 이 패턴을 실무에 넣어 봤는데, 접근자 매크로의 진가는 여러 프로퍼티에 반복 적용할 때 나온다. 10개의 설정 프로퍼티에 @UserDefault를 붙이면 각각 UserDefaults 접근 로직 20줄을 절약한다. visionOS에서는 UserDefaults.standard 대신 앱 그룹 컨테이너의 UserDefaults(suiteName:)을 쓰는 경우가 많은데, 매크로 인수로 suite 이름을 받도록 확장하면 그대로 대응된다.
@attached(extension)으로 프로토콜 준수 자동화
@attached(extension)은 익스텐션 전체를 생성하는 매크로다. conformances: 파라미터로 자동 채택할 프로토콜을 선언할 수 있다. @Model이 PersistentModel과 Observable을 자동 채택시키는 방식이 이것이다. Codable을 자동 채택하는 @AutoCodable을 만들어 보자.
@attached(extension, conformances: Codable, names: named(init(from:)), named(encode(to:)))
public macro AutoCodable() =
#externalMacro(module: "MyMacrosPlugin", type: "AutoCodableMacro")
public struct AutoCodableMacro: ExtensionMacro {
public static func expansion(
of node: AttributeSyntax,
attachedTo declaration: some DeclGroupSyntax,
providingExtensionsOf type: some TypeSyntaxProtocol,
conformingTo protocols: [TypeSyntax],
in context: some MacroExpansionContext
) throws -> [ExtensionDeclSyntax] {
// 이미 Codable을 명시적으로 채택했다면 빈 배열 반환
guard !protocols.isEmpty else { return [] }
let extensionDecl: DeclSyntax = """
extension \(type.trimmed): Codable {}
"""
return [extensionDecl.cast(ExtensionDeclSyntax.self)]
}
}
protocols 파라미터가 비어 있는 케이스가 중요하다. 컴파일러가 "이 타입은 이미 Codable을 채택하고 있으니 매크로는 아무 것도 안 해도 된다"고 알려주는 신호다. 이 조기 반환을 빼먹으면 중복 채택으로 컴파일 에러가 난다. iOS와 macOS에서 동일하게 걸리지만, visionOS의 크로스컴파일 캐시에서 특히 자주 놓치는 부분이다.
SwiftSyntax로 매크로 디버깅하는 방법
매크로 디버깅은 처음엔 답답하다. 이유는 매크로가 별도 프로세스에서 실행되기 때문이다. 실전에서 통하는 세 가지 방법을 정리한다.
1. Xcode의 Expand Macro 뷰
사용처에서 매크로 이름 위에 우클릭 → "Expand Macro"를 선택하면 확장 결과가 별도 창으로 열린다. Xcode 15부터 지원되며 Xcode 26에서는 스와이프 제스처로 원본과 확장을 오갈 수 있다. 가장 빠른 피드백 루프다.
2. SwiftSyntax Explorer 활용
SwiftSyntax 트리는 노드 이름이 길어서 처음엔 어디에 무엇이 있는지 찾기 어렵다. Swift AST Explorer에 소스를 붙여넣으면 트리 구조와 각 노드 타입이 즉시 보인다. 매크로 코드를 작성하기 전에 내가 다룰 문법 노드가 정확히 무슨 타입인지부터 확인하는 습관을 들이면 시간을 아낀다.
3. context.diagnose로 사용자에게 오류 전달
throw는 컴파일 에러를 내지만 위치 정보가 부정확할 수 있다. 정확한 위치와 함께 에러를 내려면 MacroExpansionContext의 diagnose를 사용한다.
let diagnostic = Diagnostic(
node: Syntax(node),
message: SimpleDiagnosticMessage(
message: "이 매크로는 struct에만 사용할 수 있습니다",
diagnosticID: MessageID(domain: "MyMacros", id: "notStruct"),
severity: .error
)
)
context.diagnose(diagnostic)
return []
Swift Testing으로 매크로 테스트 작성하기
매크로 테스트는 SwiftSyntaxMacrosTestSupport의 assertMacroExpansion이 표준이다. 이 함수는 XCTest 기반이지만 Swift Testing과도 병용 가능하다. Tests/MyMacrosTests/URLMacroTests.swift를 아래처럼 작성한다.
@Test와 @Suite는 Swift Testing 프레임워크의 매크로들이다. 매크로 테스트가 매크로로 작성된다는 게 얼핏 이상하지만, 앞서 말한 대로 매크로는 런타임 코드가 아니므로 순환은 없다. Swift 6.2 툴체인에서는 swift test 한 번으로 CI에서 그대로 돌린다. Xcode Cloud, GitHub Actions의 macos-14 또는 macos-15 러너에서 모두 동일하게 통과한다.
iOS, macOS, visionOS 크로스 플랫폼 배포
내가 실제 앱 하나를 iOS, iPadOS, macOS Catalyst, visionOS로 동시 배포하면서 겪은 이슈를 정리한다. 매크로는 플러그인 실행과 확장 코드 실행을 구분해서 생각해야 한다.
플러그인 실행: 무조건 macOS 호스트
Swift 매크로 플러그인은 빌드가 실행되는 머신에서만 돈다. 개발 머신이 Mac이면 문제가 없다. Xcode Cloud와 GitHub Actions의 macos-14 이상 러너에서도 그대로 동작한다. Linux 러너에서는 Swift 5.9 이상 툴체인이 있으면 매크로 플러그인 자체는 빌드되지만, Apple 플랫폼용 크로스컴파일은 macOS 호스트가 필요하다. Swift 서버 프로젝트에 매크로를 넣을 계획이라면 CI 매트릭스를 별도로 준비해야 한다.
확장된 코드: 모든 Apple 플랫폼
매크로가 만들어낸 결과 코드는 그냥 Swift 코드다. 따라서 Package.swift의 platforms:만 잘 맞추면 iOS 17, macOS 14, tvOS 17, watchOS 10, visionOS 1 어디로든 배포된다. 다만 매크로가 특정 플랫폼 전용 API(예: UIKit)에 의존하는 코드를 생성한다면, #if canImport(UIKit)를 확장 결과에 함께 넣어야 한다. iPad와 Mac에서만 UIKit이 있고 visionOS에서는 조금 다르게 생겼다는 점을 기억하자. watchOS에서는 UIViewController가 없으므로 뷰컨트롤러를 언급하는 매크로 결과는 #if !os(watchOS)로 감싸야 한다.
Xcode 버전 매트릭스
매크로 API의 이름이 Swift 5.9에서 5.10으로 넘어가면서 몇 개 바뀌었고, Swift 6에서 다시 소소한 조정이 있었다. Xcode 15.3 이상을 최소 요구 사항으로 잡으면 대부분 호환이 맞는다. Apple 공식 매크로 가이드가 API 시그니처 기준으로 정확하다.
Swift 매크로의 컴파일 성능과 제약
매크로가 컴파일을 느리게 만들지 여부는 매크로 코드의 복잡도와 사용 횟수에 달렸다. 내 경험상 500개 정도의 @Observable가 걸린 프로젝트에서 클린 빌드가 약 8~12% 늘어났다. Xcode 26의 매크로 확장 결과 캐시가 상당히 좋아져서, 증분 빌드에서는 영향이 거의 없다.
주요 제약
타입 정보 접근 불가: 매크로는 문법(syntax) 트리만 본다. 부모 타입의 다른 프로퍼티 타입이나 상속 관계에는 접근할 수 없다.
순환 매크로 금지: A 매크로가 만든 코드가 B 매크로 호출을 포함하고, B가 다시 A를 부르면 컴파일러가 명시적으로 거부한다.
이름 명시 필수:names:에 선언한 이름 외의 심볼을 생성하면 룩업 단계에서 에러가 난다.
플러그인 프로세스 실패: 매크로 구현이 크래시하면 컴파일 전체가 실패한다. fatalError 대신 throw와 diagnose를 쓰자.
자주 묻는 질문
Swift 매크로는 어느 Swift 버전부터 사용할 수 있나요?
Swift 5.9(Xcode 15) 이상에서 사용 가능합니다. @attached(extension)과 conformance 통합은 Swift 5.10, 그리고 다중 역할 매크로의 안정성 개선은 Swift 6에서 완료됐습니다. 실무 프로젝트라면 Xcode 15.3과 Swift 5.10을 최소 기준으로 잡는 것이 안전합니다.
Swift 매크로가 런타임 성능에 영향을 주나요?
런타임 오버헤드는 정확히 0입니다. 매크로는 컴파일 타임에만 실행되며, 최종 바이너리에는 매크로가 생성한 일반 Swift 코드만 남습니다. 성능에 영향을 주는 것은 빌드 시간뿐이며, 이 또한 Xcode 26의 캐시 개선으로 대폭 완화됐습니다.
매크로와 프로퍼티 래퍼(property wrapper)의 차이는 무엇인가요?
프로퍼티 래퍼는 특정 프로퍼티의 접근자만 감싸는 런타임 기법이고, 매크로는 컴파일 타임에 여러 종류의 코드를 생성할 수 있는 범용 도구입니다. 매크로는 새 프로퍼티 추가, 확장 생성, 프로토콜 채택, 표현식 생성 등 프로퍼티 래퍼로는 불가능한 일들을 합니다. SwiftUI 팀도 @Observable을 프로퍼티 래퍼가 아닌 매크로로 만든 이유가 이것입니다.
Linux나 Windows에서 Swift 매크로를 사용할 수 있나요?
매크로가 확장한 결과 코드는 Swift가 지원하는 모든 플랫폼에서 동작합니다. 매크로 플러그인도 macOS, Linux, Windows 어디에서든 빌드/실행이 가능하지만, Apple 플랫폼(iOS/watchOS/tvOS/visionOS)을 대상으로 하는 크로스컴파일은 여전히 macOS 호스트가 필요합니다. Linux CI에서 서버용 Swift 코드에 매크로를 쓰는 것은 문제없습니다.
기존 @Observable이나 @Model 매크로의 소스 코드는 어디서 볼 수 있나요?
@Observable은 오픈소스로 공개된 Swift 저장소의 ObservationMacros에서 확인할 수 있습니다. @Model과 SwiftData 매크로는 클로즈드 소스지만, Xcode의 "Expand Macro"로 확장 결과를 관찰하며 구조를 역추적할 수 있습니다. 실무 매크로를 설계할 때 이 둘의 API 패턴을 참고하는 것이 가장 실용적인 학습 경로입니다.
SwiftUI NavigationStack과 NavigationPath로 값 기반 라우팅부터 딥링크, 상태 복원, TabView 조합, @Observable Router 패턴, VoiceOver 접근성까지 iOS 26 최신 코드와 실전 예제로 한 번에 정리한 가이드입니다.