Swift: Macro

틀틀보·2025년 9월 6일

Swift

목록 보기
14/19

Swift 5.9 버전부터 적용된 컴파일 시간에 코드 생성, 검증을 가능하게 해주는 기능

어떻게 동작할까?

SwiftSyntax

텍스트를 추상 구문 트리 (Abstract Syntax Tree, AST) 인 구조화된 데이터로 변환해주는 라이브러리

그냥 텍스트: let a = 1 + 2
SwiftSyntax가 분석한 구조 (AST)

  • 선언문 (Declaration)
    • let 키워드
    • 변수 이름: a
    • 할당 연산자 (=)
    • 이항 연산자 표현식 (+)
      • 좌변: 정수 1
      • 우변: 정수 2

매크로는 이 AST를 직접 탐색하고 조작
AST를 보고 '이항 연산자 표현식' 노드를 통째로 문자열로 변환하여 "1 + 2"를 얻음.

단순 텍스트 검색/치환보다 훨씬 정교하고 안전하다는 이점.

선언부와 구현부로 나눠진 구조

매크로는 하나의 파일에 통째로 들어있지 않고, 두 부분으로 명확하게 나뉘어있음.

선언부

@freestanding(expression)
public macro stringify<T>(_ expression: T) -> (T, String) = #externalMacro(...)
  • 이름: stringify

  • 종류: @freestanding(expression) (표현식 매크로)

  • 입력: T 타입의 표현식 하나

  • 출력: (T, String) 타입의 튜플

  • 실제 위치: #externalMacro(...) (실제 로직은 다른 곳에 있음)

컴파일러는 이 정보로 매크로의 사용법, 매크로의 결과 타입을 미리 인지 가능

구현부

public struct StringifyMacro: ExpressionMacro {
    public static func expansion(...) -> ExprSyntax {
        // ... SwiftSyntax로 코드를 분석하고 ...
        // ... 새로운 코드 조각을 만들어서 반환 ...
    }
}

컴파일러는 선언부를 확인하고 구현부를 찾아가 실행

샌드박스에서의 실행 구조

매크로는 컴파일 과정에 직접 관여하는 강력한 기능이므로, 보안 문제 방지를 위해 샌드박스 격리 환경에서 실행

매크로의 구현부를 샌드박스에서 실행

  • 할 수 없는 것 ❌:

    • 파일 시스템 접근 (파일 읽기/쓰기)

    • 네트워크 통신

    • 컴파일 중인 다른 파일의 정보 접근

  • 할 수 있는 것 ✅:

    • 입력으로 받은 코드 조각(AST)을 분석하기

    • 새로운 코드 조각(AST)을 만들어서 반환하기

매크로가 할 수 있는 일을 코드 변환으로 제한시켜 보안 문제 해결

Macro의 이점

  1. 코드 중복 감소: 반복적으로 작성해야 하는 상용구 코드를 매크로로 대체하여 코드의 양을 줄일 수 있음.

  2. 가독성 향상: 복잡한 로직을 매크로 뒤에 숨겨 코드의 의도를 명확하게 표현

  3. 안전성: 매크로는 컴파일 시점에 Swift 구문으로 확장되고 타입 검사를 거치므로, 텍스트 기반의 코드 생성 방식보다 안전합니다. Xcode에서 매크로가 확장된 코드를 미리 확인하는 기능도 제공하여 디버깅을 용이하게 함.

Swift에서의 매크로

독립 매크로 (Freestanding Macros)

특정 선언에 붙지 않고 독립적으로 사용되는 매크로
새로운 값을 생성하거나 특정 조건을 확인하는데 사용

표현식 매크로 (Expression Macro)

# 기호로 시작하며 새로운 값을 생성

자주 쓰는 매크로
  • #URL: 문자열을 기반으로 컴파일 시점에 URL의 유효성을 검사하고 URL 객체를 생성

    • 런타임에 발생할 수 있는 URL 관련 크래시를 원천적으로 차단하여 앱의 안정성 향상
  • #stringify: 인자로 전달된 코드 표현식을 (결과값, 코드 문자열) 튜플로 변환

    • 디버깅 시 어떤 코드가 어떤 결과를 만들었는지 로그로 남길 때 유용
예시) stringfy 매크로
let a = 10
let b = 20

// #stringify 매크로에 덧셈 표현식을 전달
let (result, code) = #stringify(a + b)

print("The result of '\(code)' is \(result)")

// 컴파일 시 확장된 코드
// 위의 코드는 컴파일 시 아래처럼 변환
let (result, code) = (a + b, "a + b")

print("The result of '\(code)' is \(result)")

선언 매크로 (Declaration Macro)

# 기호로 시작하며 새로운 코드를 생성하는 매크로
변수, 함수, 클래스 등도 가능

비슷한 패턴의 함수들을 수십 개 만들어야 할 때, 코드 중복 없이 자동화 가능

예시
#createUnitFunctions(units: ["Meters": 1.0, "Kilometers": 1000.0, "Miles": 1609.34])

// 이제 매크로가 생성해준 함수들을 그냥 가져다 쓰기
logInMeters(value: 100)
logInKilometers(value: 5000)
logInMiles(value: 10000)
참고) 예시 선언 매크로 구현 코드
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros

public struct CreateUnitFunctionsMacro: DeclarationMacro {
    public static func expansion(
        of node: some FreestandingMacroExpansionSyntax,
        in context: some MacroExpansionContext
    ) throws -> [DeclSyntax] {

        // 1. 매크로 인자로 전달된 딕셔너리 구문 가져오기
        guard let dictionary = node.argumentList.first?.expression.as(DictionaryExprSyntax.self) else {
            fatalError("컴파일러 에러: 딕셔너리 인자가 필요합니다.")
        }

        // 2. 딕셔너리의 각 요소를 순회하며 [DeclSyntax] 배열을 생성
        let functionDeclarations = dictionary.elements.compactMap { element -> DeclSyntax? in
            // 3. 딕셔너리의 키(String)와 값(Double)을 추출
            guard let unitName = element.keyExpression.as(StringLiteralExprSyntax.self)?.segments.first?.content.text,
                  let unitValue = element.valueExpression.as(FloatLiteralExprSyntax.self)?.literal.text ?? element.valueExpression.as(IntegerLiteralExprSyntax.self)?.literal.text
            else {
                return nil
            }

            // 4. 추출한 정보를 바탕으로 함수 이름과 단위 심볼을 생성
            let functionName = "logIn" + unitName
            let unitSymbol = getUnitSymbol(from: unitName)

            // 5. 생성할 함수 코드를 문자열로 조립
            let functionCode = """
            func \(raw: functionName)(value: Double) {
                print("\\(value / \(raw: unitValue))\(raw: unitSymbol)")
            }
            """

            // 6. 조립된 코드 문자열을 DeclSyntax로 변환
            return DeclSyntax(stringLiteral: functionCode)
        }

        // 7. 생성된 모든 함수 선언 배열을 반환
        return functionDeclarations
    }

    // 단위 이름을 기반으로 짧은 심볼을 반환하는 헬퍼 함수
    private static func getUnitSymbol(from unitName: String) -> String {
        switch unitName.lowercased() {
        case "meters": return "m"
        case "kilometers": return "km"
        case "miles": return "mi"
        default: return unitName
        }
    }
}

첨부 매크로 (Attached Macros)

클래스, 구조체, 함수 등 특정 선언에 @ 기호를 사용하여 첨부되는 매크로
첨부된 대상에 코드를 추가하거나 수정하는 역할

멤버 매크로 (Member Macro)

해당 타입에 새로운 멤버(프로퍼티, 메서드 등)을 추가

자주 쓰는 매크로
  • @Observable: 프로퍼티가 변경될 때마다 뷰를 자동으로 업데이트하는 코드를 생성
    • 복잡한 Combine 코드를 작성할 필요 없이, 단 한 줄로 UIKit과 SwiftUI 뷰의 상태 관리를 구현 가능
예시) Observable 매크로
import SwiftUI
import Combine // Published를 사용하기 위해 Combine을 import

// 1. ObservableObject 프로토콜 채택
class CounterViewModel_Old: ObservableObject {
    // 2. 변경을 감지할 모든 프로퍼티에 @Published 붙이기
    @Published var count = 0
    @Published var anotherNumber = 100 // 이 프로퍼티도 변경되면 뷰가 업데이트

    func increment() {
        count += 1
    }
}

struct ContentView_Old: View {
    // 3. @StateObject 또는 @ObservedObject로 ViewModel을 구독
    @StateObject private var viewModel = CounterViewModel_Old()

    var body: some View {
        VStack(spacing: 20) {
            Text("Count: \(viewModel.count)")
                .font(.largeTitle)

            Button("Increment") {
                viewModel.increment()
            }
        }
    }
}

--------------------------------------------------
import SwiftUI

// 1. 클래스 위에 @Observable 매크로 한 줄 추가
@Observable
class CounterViewModel {
    // @Published 필요 X 프로퍼티를 그냥 선언하면 끝.
    var count = 0
    var anotherNumber = 100

    func increment() {
        count += 1
    }
}

struct ContentView: View {
    // 2. @StateObject 대신 @State로 간단하게 선언
    @State private var viewModel = CounterViewModel()

    var body: some View {
        VStack(spacing: 20) {
            Text("Count: \(viewModel.count)")
                .font(.largeTitle)

            Button("Increment") {
                viewModel.increment()
            }
        }
    }
}

ObservableObject와 @Observable의 차이

  • 추후 글 작성 예정

접근자 매크로 (Accessor Macro)

저장 프로퍼티(stored property)에 붙어서 getter와 setter를 추가하거나 수정

예시) 사용자 정의 @Clamped 매크로
  • 프로퍼티의 값이 특정 범위를 벗어나지 않도록 강제하는 @Clamped 매크로 (직접 만든 매크로)
struct Player {
    // 체력은 항상 0에서 100 사이여야 함
    @Clamped(range: 0...100)
    var health: Int = 100
}

var player = Player()
player.health = 150
print(player.health) // 100

player.health = -50
print(player.health) // 0
-----------------------------------------------------
struct Player {
    private var _health: Int = 100 // 매크로가 생성한 비공개 저장소

    var health: Int {
        // ▼▼▼ 매크로가 생성한 접근자(accessor) ▼▼▼
        get { _health }
        set {
            _health = max(0, min(100, newValue))
        }
        // ▲▲▲ 매크로가 생성한 접근자(accessor) ▲▲▲
    }
}

멤버 속성 매크로 (Member Attribute Macro)

타입에 붙어 내부의 모든 멤버에게 특정 속성을 일괄적으로 추가

예시) 사용자 정의 @AllPublished 매크로
  • 내부의 모든 프로퍼티에 @Published를 붙여주는 매크로 (직접 만든 매크로)
import Combine

@AllPublished
class UserProfileViewModel {
    var username: String = "Guest"
    var score: Int = 0
    // isPremium은 Published 되면 안 됨
    @NotPublished var isPremium: Bool = false
}
------------------------------------------------------
import Combine

class UserProfileViewModel {
    // ▼▼▼ 매크로가 속성을 추가함 ▼▼▼
    @Published var username: String = "Guest"
    @Published var score: Int = 0
    // ▲▲▲ 매크로가 속성을 추가함 ▲▲▲

    var isPremium: Bool = false // 이 속성은 @NotPublished 때문에 제외됨
}

피어 매크로 (Peer Macro)

함수나 프로퍼티에 붙어서 그 선언과 동일한 레벨(동료, peer)에 새로운 선언을 추가

예시) 사용자 정의 @AddAsync 매크로
  • completion handler 기반의 비동기 함수의 async/await 버전을 자동으로 생성해주는 매크로 (직접 만든 매크로)
@AddAsync
func fetchUserData(id: String, completion: @escaping (Result<String, Error>) -> Void) {
    // ...네트워크 통신 후 completion 호출...
    completion(.success("사용자 데이터"))
}
-------------------------------------------------
// ▼▼▼ 매크로가 생성한 '동료(peer)' 함수 ▼▼▼
func fetchUserData(id: String) async throws -> String {
    try await withCheckedThrowingContinuation { continuation in
        fetchUserData(id: id) { result in
            continuation.resume(with: result)
        }
    }
}
// ▲▲▲ 매크로가 생성한 '동료(peer)' 함수 ▲▲▲

// async/await 버전 함수 사용 가능
Task {
    if let data = try? await fetchUserData(id: "123") {
        print(data) // "사용자 데이터"
    }
}

확장 매크로 (Extension Macro)

타입에 붙어서 extension 블록 전체를 추가
주로 프로토콜 채택 및 관련 구현을 자동화하는 데 사용

예제) 사용자 정의 @AutoEquatable 매크로
  • 타입의 모든 저장 프로퍼티를 비교하여 Equatable 프로토콜을 자동으로 준수하게 만드는 매크로 (직접 만든 매크로)
@AutoEquatable
struct User {
    let id: UUID
    let name: String
    var score: Int
}

let user1 = User(id: UUID(), name: "Steve", score: 100)
let user2 = User(id: user1.id, name: "Steve", score: 100)
print(user1 == user2) // true
-------------------------------------------------

struct User {
    let id: UUID
    let name: String
    var score: Int
}

// ▼▼▼ 매크로가 생성한 extension 블록 ▼▼▼
extension User: Equatable {
    static func == (lhs: User, rhs: User) -> Bool {
        lhs.id == rhs.id &&
        lhs.name == rhs.name &&
        lhs.score == rhs.score
    }
}
// ▲▲▲ 매크로가 생성한 extension 블록 ▲▲▲

매크로 사용 시 유의할 점

  • 빌드 시간 증가: 매크로는 컴파일 시 코드를 생성하는 추가적인 단계를 거치므로 빌드 시간이 늘어 날 수 있음.

  • 복잡성: 매크로를 사용하는 것은 쉽지만, 직접 만드는 것은 SwiftSyntax에 대한 이해가 필요

참고
https://docs.swift.org/swift-book/documentation/the-swift-programming-language/macros/

https://github.com/swiftlang/swift-evolution/blob/main/proposals/0382-expression-macros.md

https://developer.apple.com/videos/play/wwdc2023/10166/

profile
안녕하세요! iOS 개발자입니다!

0개의 댓글