Swift 매크로 직접 만들기: @freestanding, @attached 커스텀 매크로 완벽 가이드 (Swift 6, 2026)

Swift 매크로를 처음부터 직접 만드는 방법을 @freestanding, @attached 예제와 SwiftSyntax 구현, 테스트, iOS·macOS·visionOS 배포까지 Xcode 16, Swift 6.2 기준 실전 코드로 정리한 가이드.

Swift 매크로 만들기 완벽 가이드 2026

업데이트: 2026년 7월 28일

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개 역할로 세분화된다.
  • 매크로 패키지는 반드시 SwiftSyntaxMacrosSwiftCompilerPlugin 라이브러리를 의존성으로 가지는 플러그인 타깃과 이를 #externalMacro로 노출하는 라이브러리 타깃, 두 개의 타깃으로 구성된다.
  • Swift 6.2 툴체인에서는 swift-syntax 601.0.0 이상을 사용해야 하며, macOS 호스트에서만 매크로가 실행되지만 결과 코드는 iOS, watchOS, tvOS, visionOS 어디로든 배포된다.
  • 매크로 테스트는 SwiftSyntaxMacrosTestSupportassertMacroExpansion으로 원본 소스와 확장 결과를 문자열 비교하는 방식이며, 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이다.

매크로는 이미 SwiftUI 생태계 곳곳에 있다. Swift @Observable 매크로 완벽 가이드에서 다룬 @Observable, SwiftData 완벽 가이드에서 소개한 @Model, 그리고 Swift Testing 프레임워크 완벽 가이드에서 다룬 @Test가 모두 매크로다. 우리가 만들 것은 정확히 같은 종류의 API다.

@freestanding과 @attached의 차이점

@freestanding#url("...")처럼 독립된 표현식이나 선언 위치에서 호출되는 매크로이고, @attached@LoggedEvent class Foo처럼 기존 선언에 붙어 그 선언을 변형하는 매크로다. 둘 다 SwiftSyntax 트리를 조작하지만, 입력과 반환 시그니처가 다르다.

구분 @freestanding @attached
호출 형태 #macroName(args) @MacroName 선언 앞에
역할(role) 종류 expression, declaration, codeItem peer, member, accessor, memberAttribute, extension
구현 프로토콜 ExpressionMacro, DeclarationMacro PeerMacro, MemberMacro, AccessorMacro, ExtensionMacro
대표 예시 #Preview, #URL, #warning @Observable, @Model, @Test
기존 선언 접근 인수(arguments)만 접근 가능 붙은 선언 전체(declaration)에 접근 가능
주요 사용 시나리오 컴파일 타임 검증, 리터럴 생성 보일러플레이트 자동 생성, 프로토콜 준수

실무 판단 기준은 간단하다. "무언가 새 값이 필요하다"면 @freestanding, "기존 타입에 무언가 추가하고 싶다"면 @attached다. 두 매크로를 하나의 심볼에 동시에 붙여 하이브리드로 만드는 것도 가능한데, @Observable이 실제로 @attached(member, names: ...)@attached(memberAttribute)@attached(extension, conformances: Observable)을 함께 붙인 다중 역할 매크로다.

매크로 패키지 프로젝트 구조 만들기

Swift 매크로는 반드시 Swift Package로 만들어야 한다. Xcode에서 "File → New → Package…"를 열고 "Swift Macro" 템플릿을 선택하면 아래와 비슷한 Package.swift가 생성된다. 여기서는 MyMacros라는 패키지를 예로 들겠다.

// swift-tools-version: 6.0
import PackageDescription
import CompilerPluginSupport

let package = Package(
    name: "MyMacros",
    platforms: [
        .macOS(.v14),
        .iOS(.v17),
        .tvOS(.v17),
        .watchOS(.v10),
        .visionOS(.v1)
    ],
    products: [
        .library(name: "MyMacros", targets: ["MyMacros"]),
        .executable(name: "MyMacrosClient", targets: ["MyMacrosClient"])
    ],
    dependencies: [
        .package(url: "https://github.com/swiftlang/swift-syntax.git",
                 from: "601.0.0")
    ],
    targets: [
        // 1) 매크로 구현 (컴파일러 플러그인, macOS 호스트에서 실행)
        .macro(
            name: "MyMacrosPlugin",
            dependencies: [
                .product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
                .product(name: "SwiftCompilerPlugin", package: "swift-syntax")
            ]
        ),
        // 2) 사용자에게 노출되는 API (얇은 래퍼)
        .target(name: "MyMacros", dependencies: ["MyMacrosPlugin"]),
        // 3) 데모 실행 파일
        .executableTarget(name: "MyMacrosClient", dependencies: ["MyMacros"]),
        // 4) 테스트
        .testTarget(
            name: "MyMacrosTests",
            dependencies: [
                "MyMacrosPlugin",
                .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax")
            ]
        )
    ]
)

구조가 두 층이라는 점이 중요하다. 사용자는 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은 컴파일 타임에 잡을 수 있다.

먼저 Sources/MyMacros/URL.swift에 사용자 API를 정의한다.

import Foundation

/// 컴파일 타임에 URL 리터럴 유효성을 검증하는 매크로.
/// - Returns: 검증된 non-optional `URL`
@freestanding(expression)
public macro URL(_ string: String) -> URL =
    #externalMacro(module: "MyMacrosPlugin", type: "URLMacro")

이제 Sources/MyMacrosPlugin/URLMacro.swift에 구현체를 작성한다. ExpressionMacro 프로토콜을 채택하고, expansion(of:in:) 하나를 구현하면 된다.

import SwiftCompilerPlugin
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros
import Foundation

public struct URLMacro: ExpressionMacro {
    public static func expansion(
        of node: some FreestandingMacroExpansionSyntax,
        in context: some MacroExpansionContext
    ) throws -> ExprSyntax {
        // 1) 인수를 문자열 리터럴로 파싱
        guard let argument = node.arguments.first?.expression,
              let stringLiteral = argument.as(StringLiteralExprSyntax.self),
              stringLiteral.segments.count == 1,
              case let .stringSegment(segment) = stringLiteral.segments.first
        else {
            throw MacroError.notAStaticString
        }

        // 2) 실제 URL로 파싱 시도 — 실패 시 컴파일 에러 발생
        let raw = segment.content.text
        guard URL(string: raw) != nil else {
            throw MacroError.invalidURL(raw)
        }

        // 3) 안전이 검증됐으므로 강제 언랩 코드를 생성
        return "URL(string: \(literal: raw))!"
    }
}

enum MacroError: Error, CustomStringConvertible {
    case notAStaticString
    case invalidURL(String)

    var description: String {
        switch self {
        case .notAStaticString:
            return "#URL은 정적 문자열 리터럴만 받을 수 있습니다"
        case .invalidURL(let raw):
            return "'\(raw)'는 유효한 URL이 아닙니다"
        }
    }
}

마지막으로 Sources/MyMacrosPlugin/Plugin.swift에 플러그인 진입점을 두고 매크로 타입을 등록한다.

import SwiftCompilerPlugin
import SwiftSyntaxMacros

@main
struct MyMacrosPlugin: CompilerPlugin {
    let providingMacros: [Macro.Type] = [
        URLMacro.self
    ]
}

이제 사용자 코드에서 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가 내부적으로 이 방식으로 getset을 주입한다. 아래는 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: 파라미터로 자동 채택할 프로토콜을 선언할 수 있다. @ModelPersistentModelObservable을 자동 채택시키는 방식이 이것이다. 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는 컴파일 에러를 내지만 위치 정보가 부정확할 수 있다. 정확한 위치와 함께 에러를 내려면 MacroExpansionContextdiagnose를 사용한다.

let diagnostic = Diagnostic(
    node: Syntax(node),
    message: SimpleDiagnosticMessage(
        message: "이 매크로는 struct에만 사용할 수 있습니다",
        diagnosticID: MessageID(domain: "MyMacros", id: "notStruct"),
        severity: .error
    )
)
context.diagnose(diagnostic)
return []

Swift Testing으로 매크로 테스트 작성하기

매크로 테스트는 SwiftSyntaxMacrosTestSupportassertMacroExpansion이 표준이다. 이 함수는 XCTest 기반이지만 Swift Testing과도 병용 가능하다. Tests/MyMacrosTests/URLMacroTests.swift를 아래처럼 작성한다.

import Testing
import SwiftSyntaxMacros
import SwiftSyntaxMacrosTestSupport
@testable import MyMacrosPlugin

let testMacros: [String: Macro.Type] = [
    "URL": URLMacro.self
]

@Suite("URLMacro")
struct URLMacroTests {

    @Test func validURLExpands() throws {
        assertMacroExpansion(
            #"let x = #URL("https://apple.com")"#,
            expandedSource: #"let x = URL(string: "https://apple.com")!"#,
            macros: testMacros
        )
    }

    @Test func invalidURLProducesDiagnostic() throws {
        assertMacroExpansion(
            #"let x = #URL("not a url")"#,
            expandedSource: #"let x = #URL("not a url")"#,
            diagnostics: [
                DiagnosticSpec(
                    message: "'not a url'는 유효한 URL이 아닙니다",
                    line: 1,
                    column: 9
                )
            ],
            macros: testMacros
        )
    }
}

@Test@SuiteSwift 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.swiftplatforms:만 잘 맞추면 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 대신 throwdiagnose를 쓰자.

자주 묻는 질문

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 패턴을 참고하는 것이 가장 실용적인 학습 경로입니다.

Hiroshi Sato
저자 소개 Hiroshi Sato

Apple Platforms specialist building for iOS, macOS, visionOS, and the occasional watchOS app nobody asked for.