콘텐츠 대표 이미지 - 스위프트 메타프로그래밍과 Property Wrapper 커스텀 속성 완전 정복 가이드
🛠 Swift · 프로그램개발

스위프트 메타프로그래밍과 Property Wrapper 커스텀 속성 완전 정복 가이드

코드가 코드를 만드는 마법 🪄 — Property Wrapper로 Swift 개발 레벨업!

야 솔직히 말해서 Swift 공부하다가 @Published, @State, @Binding 이런 거 처음 봤을 때 "이게 뭔데?? 골뱅이가 왜 이렇게 많아??" 했던 사람 손 🙋 ㅋㅋㅋ
근데 이게 다 Property Wrapper라는 Swift의 핵심 기능이고, 더 나아가면 메타프로그래밍이라는 개념이랑 연결돼.
오늘은 이 두 개를 제대로 파헤쳐볼 건데, 진짜 실무에서 쓸 수 있는 수준으로 설명해줄게. 준비됐지? 고고 🚀

💡 이 글에서 다루는 것들
메타프로그래밍의 개념 → Swift에서의 메타프로그래밍 → Property Wrapper 기초 → 커스텀 Property Wrapper 만들기 → 실전 활용 패턴 → 고급 테크닉
메타프로그래밍 개념 지도 메타 프로그래밍 Property Wrapper 매크로 (Macro) 리플렉션 (Mirror) 제네릭 (Generics) Swift의 메타프로그래밍 생태계 — 코드가 코드를 제어한다 🪄
1. 메타프로그래밍이 뭔데? 🤔

메타프로그래밍(Metaprogramming)은 한마디로 "코드가 코드를 다루는 것"이야.
일반 프로그래밍이 데이터를 처리하는 거라면, 메타프로그래밍은 프로그램 자체를 데이터처럼 다루거나, 코드를 생성·변환·분석하는 기법이야.

쉽게 말하면 이런 거야 ㅋㅋ:
"야 나 매번 이 코드 반복 쓰기 귀찮은데, 자동으로 만들어주는 코드 없냐?"
→ 그게 바로 메타프로그래밍임 😎

메타프로그래밍의 핵심 목표는 크게 세 가지야:

1
코드 중복 제거 — 반복되는 패턴을 추상화해서 한 번만 작성
예: 모든 프로퍼티에 로깅 기능 추가할 때 일일이 안 써도 됨
2
컴파일 타임 최적화 — 런타임이 아닌 컴파일 시점에 코드 생성
예: Swift Macro가 컴파일 시 코드를 자동 생성
3
DSL(도메인 특화 언어) 구현 — SwiftUI의 선언형 문법처럼 특정 도메인에 맞는 언어 설계
예: SwiftUI의 @ViewBuilder, @resultBuilder
✅ Swift에서 메타프로그래밍 도구들:
Property Wrapper (Swift 5.1+) · Result Builder (Swift 5.4+) · Mirror/Reflection · Swift Macro (Swift 5.9+) · Generics & Protocol

오늘 우리가 집중할 건 이 중에서 Property Wrapper야. 실무에서 제일 많이 쓰이고, 이해하면 SwiftUI 코드가 갑자기 다 읽히기 시작하거든 ㅋㅋㅋ


2. Property Wrapper 기초 — 골뱅이(@)의 정체 🔍

Swift 5.1부터 도입된 Property Wrapper는 프로퍼티에 반복적으로 적용되는 로직을 캡슐화하는 기능이야.
쉽게 말하면 "프로퍼티에 씌우는 래퍼(포장지)"인데, 이 포장지 안에 공통 로직을 넣어두고 여러 프로퍼티에 재사용하는 거야.

Property Wrapper가 없던 시절의 고통 😭
// Property Wrapper 없이 UserDefaults 저장하는 코드
// 매번 이렇게 써야 했음... 진짜 노가다 ㅠㅠ

class UserSettings {
    var username: String {
        get {
            return UserDefaults.standard.string(forKey: "username") ?? ""
        }
        set {
            UserDefaults.standard.set(newValue, forKey: "username")
        }
    }
    
    var isDarkMode: Bool {
        get {
            return UserDefaults.standard.bool(forKey: "isDarkMode")
        }
        set {
            UserDefaults.standard.set(newValue, forKey: "isDarkMode")
        }
    }
    
    var fontSize: Double {
        get {
            return UserDefaults.standard.double(forKey: "fontSize")
        }
        set {
            UserDefaults.standard.set(newValue, forKey: "fontSize")
        }
    }
    // ... 이게 10개 20개면?? 😱
}

보이지? 같은 패턴이 계속 반복되잖아. 이게 5개, 10개, 20개 되면 진짜 손목 나가는 거야 ㅋㅋㅋ
이걸 Property Wrapper로 해결하면 이렇게 돼:

// Property Wrapper 사용 후 — 완전 깔끔 ✨

@propertyWrapper
struct UserDefault<T> {
    let key: String
    let defaultValue: T
    
    var wrappedValue: T {
        get {
            return UserDefaults.standard.object(forKey: key) as? T ?? defaultValue
        }
        set {
            UserDefaults.standard.set(newValue, forKey: key)
        }
    }
}

class UserSettings {
    @UserDefault(key: "username", defaultValue: "")
    var username: String
    
    @UserDefault(key: "isDarkMode", defaultValue: false)
    var isDarkMode: Bool
    
    @UserDefault(key: "fontSize", defaultValue: 16.0)
    var fontSize: Double
}
🎯 핵심 포인트: 반복 코드가 Property Wrapper 하나로 압축됨!
코드 줄 수가 확 줄고, 실수할 여지도 줄어들고, 유지보수도 쉬워짐 💪

3. Property Wrapper 구조 완전 해부 🔬

Property Wrapper를 만들려면 @propertyWrapper 어트리뷰트를 struct, class, enum에 붙이고, 반드시 wrappedValue 프로퍼티를 정의해야 해.
추가로 projectedValue도 선택적으로 정의할 수 있어. 이게 $ 접두사로 접근하는 그거야!

기본 구조
@propertyWrapper
struct MyWrapper<T> {
    
    // 1️⃣ 필수: wrappedValue — 실제 값을 저장/반환하는 핵심
    private var value: T
    
    var wrappedValue: T {
        get { value }
        set { value = newValue }
    }
    
    // 2️⃣ 선택: projectedValue — $변수명으로 접근
    var projectedValue: String {
        return "현재 값: \(value)"
    }
    
    // 3️⃣ 이니셜라이저
    init(wrappedValue: T) {
        self.value = wrappedValue
    }
}

// 사용법
struct Example {
    @MyWrapper var name: String = "Swift"
    
    func show() {
        print(name)        // wrappedValue 접근
        print($name)       // projectedValue 접근 → "현재 값: Swift"
    }
}
컴파일러가 실제로 하는 일 (이게 진짜 중요함!)

우리가 @MyWrapper var name: String = "Swift" 라고 쓰면, 컴파일러는 내부적으로 이렇게 변환해:

// 우리가 쓴 코드
@MyWrapper var name: String = "Swift"

// 컴파일러가 실제로 생성하는 코드 (개념적 표현)
private var _name: MyWrapper<String> = MyWrapper(wrappedValue: "Swift")

var name: String {
    get { _name.wrappedValue }
    set { _name.wrappedValue = newValue }
}

var $name: String {  // projectedValue
    get { _name.projectedValue }
}
✅ 이게 메타프로그래밍이야! 우리가 @MyWrapper 한 줄 쓰면, 컴파일러가 자동으로 저 코드를 생성해주는 거거든. 코드가 코드를 만드는 것 😎
wrappedValue vs projectedValue 비교
구분 접근 방법 용도 예시
wrappedValue 변수명 그대로 실제 값 접근 name
projectedValue $변수명 추가 정보/기능 제공 $name
_변수명 언더스코어 접두사 Wrapper 인스턴스 자체 _name
Property Wrapper 동작 원리 👨‍💻 개발자가 쓰는 코드 @UserDefault( key: "name", defaultValue: "" ) var username: String 컴파일러 변환 ⚙️ ⚙️ 컴파일러 생성 코드 private var _username: UserDefault<String> var username: String { get { _username .wrappedValue } set { _username .wrappedValue = newValue } } var $username: ... { ... } 실행 결과 ✅ ✅ 결과 자동 저장/불러오기 UserDefaults 연동 코드 중복 제거 🎉 마법처럼! @propertyWrapper 한 줄이 수십 줄의 보일러플레이트를 대체한다
4. 커스텀 Property Wrapper 실전 레시피 🍳

이제 진짜 실무에서 쓸 수 있는 커스텀 Property Wrapper들을 만들어볼 거야.
각각 다른 상황에서 쓰이는 패턴들이니까 잘 봐둬 👀

① Clamped — 값 범위 제한 Wrapper

슬라이더 값이나 퍼센트 값처럼 특정 범위 안에 있어야 하는 값들 있잖아.
매번 min(max(value, 0), 100) 이런 거 쓰기 귀찮으면 이걸 써:

@propertyWrapper
struct Clamped<T: Comparable> {
    private var value: T
    private let range: ClosedRange<T>
    
    var wrappedValue: T {
        get { value }
        set { value = min(max(newValue, range.lowerBound), range.upperBound) }
    }
    
    init(wrappedValue: T, _ range: ClosedRange<T>) {
        self.range = range
        self.value = min(max(wrappedValue, range.lowerBound), range.upperBound)
    }
}

// 사용 예시
struct GameCharacter {
    @Clamped(0...100) var health: Int = 100
    @Clamped(0.0...1.0) var opacity: Double = 1.0
    @Clamped(1...5) var starRating: Int = 3
}

var hero = GameCharacter()
hero.health = 150    // 자동으로 100으로 클램핑됨 ✅
hero.health = -50    // 자동으로 0으로 클램핑됨 ✅
print(hero.health)   // 0
hero.opacity = 2.5   // 자동으로 1.0으로 클램핑됨 ✅
② Trimmed — 문자열 자동 공백 제거 Wrapper

사용자 입력 받을 때 앞뒤 공백 제거하는 거 맨날 까먹잖아 ㅋㅋㅋ 이걸로 해결:

@propertyWrapper
struct Trimmed {
    private var value: String = ""
    
    var wrappedValue: String {
        get { value }
        set { value = newValue.trimmingCharacters(in: .whitespacesAndNewlines) }
    }
    
    init(wrappedValue: String) {
        self.value = wrappedValue.trimmingCharacters(in: .whitespacesAndNewlines)
    }
}

struct UserProfile {
    @Trimmed var name: String = ""
    @Trimmed var email: String = ""
    @Trimmed var bio: String = ""
}

var profile = UserProfile()
profile.name = "  김스위프트  "
print(profile.name)  // "김스위프트" — 공백 자동 제거! ✨
profile.email = "  swift@example.com  "
print(profile.email) // "swift@example.com"
③ Logged — 값 변경 자동 로깅 Wrapper

디버깅할 때 프로퍼티 값이 언제 바뀌는지 추적하고 싶을 때 진짜 유용해:

@propertyWrapper
struct Logged<T: CustomStringConvertible> {
    private var value: T
    private let label: String
    
    var wrappedValue: T {
        get { value }
        set {
            print("[\(label)] 변경: \(value) → \(newValue)")
            value = newValue
        }
    }
    
    // projectedValue로 현재 값의 문자열 표현 제공
    var projectedValue: String {
        return "\(label): \(value)"
    }
    
    init(wrappedValue: T, label: String) {
        self.value = wrappedValue
        self.label = label
    }
}

struct AppState {
    @Logged(label: "사용자 점수") var score: Int = 0
    @Logged(label: "레벨") var level: Int = 1
}

var state = AppState()
state.score = 100  // 콘솔: [사용자 점수] 변경: 0 → 100
state.score = 250  // 콘솔: [사용자 점수] 변경: 100 → 250
state.level = 2    // 콘솔: [레벨] 변경: 1 → 2

print($state.score) // projectedValue: "사용자 점수: 250"
④ Validated — 유효성 검사 Wrapper (projectedValue 활용)

이메일, 전화번호 같은 거 유효성 검사할 때 쓰는 패턴이야.
projectedValue로 유효성 상태를 같이 제공하는 게 포인트!

@propertyWrapper
struct Validated {
    private var value: String
    private let validator: (String) -> Bool
    
    var wrappedValue: String {
        get { value }
        set { value = newValue }
    }
    
    // $변수명으로 유효성 여부 확인
    var projectedValue: Bool {
        return validator(value)
    }
    
    init(wrappedValue: String, validator: @escaping (String) -> Bool) {
        self.value = wrappedValue
        self.validator = validator
    }
}

struct SignUpForm {
    @Validated(validator: { email in
        let pattern = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}"
        return email.range(of: pattern, options: .regularExpression) != nil
    })
    var email: String = ""
    
    @Validated(validator: { $0.count >= 8 })
    var password: String = ""
    
    var isValid: Bool {
        return $email && $password  // projectedValue 사용!
    }
}

var form = SignUpForm()
form.email = "swift@example.com"
form.password = "mypassword123"

print($form.email)    // true — 유효한 이메일
print(form.isValid)   // true — 폼 전체 유효성 ✅
⚠️ 주의: Property Wrapper는 프로토콜 요구사항으로 직접 선언할 수 없어.
즉, protocol P { @MyWrapper var value: Int } 이런 건 안 됨!
프로토콜에서는 일반 프로퍼티로 선언하고, 구현체에서 Wrapper를 적용해야 해.

5. SwiftUI Property Wrapper 완전 정리 🍎

SwiftUI 쓰면서 이 골뱅이들 맨날 보잖아. 이제 이게 다 Property Wrapper라는 걸 알았으니까, 각각이 어떻게 구현되어 있는지 개념적으로 이해해보자!

Wrapper 용도 projectedValue ($) 특징
@State 뷰 내부 상태 관리 Binding<T> 뷰가 소유, 변경 시 뷰 재렌더링
@Binding 부모 뷰 상태 참조 Binding<T> 소유권 없음, 참조만
@ObservedObject 외부 객체 관찰 ObservedObject<T>.Wrapper ObservableObject 프로토콜 필요
@StateObject 외부 객체 소유 및 관찰 ObservedObject<T>.Wrapper 뷰가 생명주기 관리
@EnvironmentObject 환경 객체 주입 ObservedObject<T>.Wrapper 뷰 계층 전체에서 공유
@Environment 시스템 환경값 접근 - colorScheme, locale 등
@Published Combine 퍼블리셔 생성 Publisher<T, Never> ObservableObject 내부에서 사용
@AppStorage UserDefaults 연동 Binding<T> 앱 재시작 후에도 유지
@State의 내부 동작 이해하기
// @State의 개념적 구현 (실제 구현은 더 복잡함)
@propertyWrapper
struct State<T> {
    private var value: T
    
    var wrappedValue: T {
        get { value }
        nonmutating set {
            // 실제로는 SwiftUI 렌더링 엔진에 변경 알림
            value = newValue
            // → 뷰 재렌더링 트리거!
        }
    }
    
    // $변수명으로 Binding 반환
    var projectedValue: Binding<T> {
        Binding(
            get: { self.wrappedValue },
            set: { self.wrappedValue = $0 }
        )
    }
    
    init(wrappedValue: T) {
        self.value = wrappedValue
    }
}

// 실제 사용
struct CounterView: View {
    @State private var count: Int = 0  // wrappedValue
    
    var body: some View {
        VStack {
            Text("\(count)")           // count 직접 접근
            Stepper("카운트", value: $count)  // $count = Binding<Int>
        }
    }
}
✅ $변수명이 Binding을 반환하는 이유가 바로 이거야!
$count는 State<Int>의 projectedValue인 Binding<Int>를 반환하는 거임.
이제 SwiftUI 코드 읽을 때 "아 이게 projectedValue구나!" 하고 이해되지? ㅋㅋㅋ

6. Property Wrapper 고급 테크닉 🚀
① 여러 Wrapper 조합하기

Property Wrapper는 여러 개를 중첩해서 쓸 수 있어. 단, 순서가 중요해!

// 여러 Wrapper 중첩 — 바깥쪽부터 안쪽으로 적용됨
struct UserInput {
    // Trimmed가 먼저 적용되고, 그 결과에 Validated 적용
    // 실제로 Swift는 단일 Wrapper만 직접 중첩 지원
    // 아래처럼 하나의 Wrapper에서 여러 기능을 조합하는 게 일반적
    
    @Trimmed var username: String = ""
    @Validated(validator: { !$0.isEmpty && $0.count >= 2 })
    var displayName: String = ""
}

// 더 나은 방법: 기능을 합친 새 Wrapper 만들기
@propertyWrapper
struct TrimmedAndValidated {
    private var value: String
    private let minLength: Int
    
    var wrappedValue: String {
        get { value }
        set { value = newValue.trimmingCharacters(in: .whitespacesAndNewlines) }
    }
    
    var projectedValue: Bool {
        return value.count >= minLength
    }
    
    init(wrappedValue: String, minLength: Int = 1) {
        self.value = wrappedValue.trimmingCharacters(in: .whitespacesAndNewlines)
        self.minLength = minLength
    }
}

struct Form {
    @TrimmedAndValidated(minLength: 2) var name: String = ""
    @TrimmedAndValidated(minLength: 8) var password: String = ""
}

var form = Form()
form.name = "  김개발  "
print(form.name)    // "김개발"
print($form.name)   // true (2글자 이상)
② 클래스 기반 Property Wrapper (참조 타입)

struct 대신 class로 만들면 참조 타입이라 여러 곳에서 같은 인스턴스를 공유할 수 있어:

// 클래스 기반 Wrapper — 공유 상태 관리에 유용
@propertyWrapper
final class SharedStorage<T> {
    private var value: T
    private var observers: [(T) -> Void] = []
    
    var wrappedValue: T {
        get { value }
        set {
            value = newValue
            observers.forEach { $0(newValue) }  // 모든 옵저버에 알림
        }
    }
    
    var projectedValue: SharedStorage<T> {
        return self
    }
    
    init(wrappedValue: T) {
        self.value = wrappedValue
    }
    
    func observe(_ handler: @escaping (T) -> Void) {
        observers.append(handler)
    }
}

class ViewModel {
    @SharedStorage var theme: String = "라이트"
}

let vm = ViewModel()
$vm.theme.observe { newTheme in
    print("테마 변경됨: \(newTheme)")
}
vm.theme = "다크"  // 콘솔: 테마 변경됨: 다크
③ 이니셜라이저 없이 사용하기 (init 오버로딩)

Property Wrapper의 이니셜라이저를 다양하게 오버로딩하면 더 유연하게 쓸 수 있어:

@propertyWrapper
struct SmartDefault<T> {
    var wrappedValue: T
    
    // 기본값으로 초기화
    init(wrappedValue: T) {
        self.wrappedValue = wrappedValue
    }
    
    // 클로저로 지연 초기화
    init(defaultProvider: () -> T) {
        self.wrappedValue = defaultProvider()
    }
}

struct Config {
    // 직접 값 지정
    @SmartDefault var maxRetries: Int = 3
    
    // 클로저로 복잡한 기본값 계산
    @SmartDefault(defaultProvider: {
        ProcessInfo.processInfo.processorCount * 2
    })
    var threadCount: Int
}

let config = Config()
print(config.maxRetries)   // 3
print(config.threadCount)  // CPU 코어 수 × 2
④ Codable과 Property Wrapper 함께 쓰기

이게 실무에서 진짜 많이 쓰이는 패턴이야. JSON 파싱할 때 기본값 처리나 변환 로직을 Wrapper로 처리하는 거:

// Codable 친화적인 Property Wrapper
@propertyWrapper
struct DefaultEmpty: Codable {
    var wrappedValue: String
    
    init(wrappedValue: String = "") {
        self.wrappedValue = wrappedValue
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        // null이거나 디코딩 실패 시 빈 문자열로 대체
        wrappedValue = (try? container.decode(String.self)) ?? ""
    }
    
    func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()
        try container.encode(wrappedValue)
    }
}

@propertyWrapper
struct DefaultFalse: Codable {
    var wrappedValue: Bool
    
    init(wrappedValue: Bool = false) {
        self.wrappedValue = wrappedValue
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        wrappedValue = (try? container.decode(Bool.self)) ?? false
    }
    
    func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()
        try container.encode(wrappedValue)
    }
}

// 서버 응답에서 null 값이 올 수 있는 필드들
struct UserResponse: Codable {
    let id: Int
    @DefaultEmpty var nickname: String    // null이면 ""
    @DefaultEmpty var bio: String         // null이면 ""
    @DefaultFalse var isPremium: Bool     // null이면 false
    @DefaultFalse var isVerified: Bool    // null이면 false
}

// JSON: {"id": 1, "nickname": null, "isPremium": null}
// 파싱 결과: id=1, nickname="", bio="", isPremium=false, isVerified=false
// 에러 없이 안전하게 파싱됨! 🎉
✅ 이 패턴은 재능넷 같은 플랫폼에서 서버 API 연동할 때 진짜 유용해!
서버에서 null이 올 수도 있는 필드들을 안전하게 처리할 수 있거든.
매번 if let 처리하는 것보다 훨씬 깔끔하지? 😎

7. Mirror — Swift의 리플렉션 🪞

메타프로그래밍의 또 다른 축인 리플렉션(Reflection)도 잠깐 짚고 가자.
Swift에서는 Mirror API를 통해 런타임에 타입 정보를 조회할 수 있어.

⚠️ Swift의 리플렉션은 다른 언어(Java, Python)에 비해 읽기 전용이야.
런타임에 프로퍼티 값을 읽을 수는 있지만, 수정은 불가능해.
그래서 주로 디버깅, 로깅, 직렬화 목적으로 사용돼.

struct Person {
    var name: String = "김스위프트"
    var age: Int = 25
    var email: String = "swift@example.com"
}

let person = Person()
let mirror = Mirror(reflecting: person)

// 타입 정보 확인
print(mirror.displayStyle)  // Optional(struct)
print(mirror.subjectType)   // Person

// 모든 프로퍼티 순회
for child in mirror.children {
    if let label = child.label {
        print("\(label): \(child.value)")
    }
}
// 출력:
// name: 김스위프트
// age: 25
// email: swift@example.com

// 실용적인 활용: 자동 로깅 함수
func autoLog<T>(_ object: T) {
    let mirror = Mirror(reflecting: object)
    let typeName = String(describing: type(of: object))
    print("=== \(typeName) 상태 ===")
    for child in mirror.children {
        print("  \(child.label ?? "??"): \(child.value)")
    }
}

autoLog(person)
// === Person 상태 ===
//   name: 김스위프트
//   age: 25
//   email: swift@example.com
Mirror + Property Wrapper 조합
// Property Wrapper가 적용된 프로퍼티를 Mirror로 조회할 때
@propertyWrapper
struct Trackable<T> {
    var wrappedValue: T
    var projectedValue: Self { self }
    
    var isModified: Bool = false
    
    init(wrappedValue: T) {
        self.wrappedValue = wrappedValue
    }
    
    mutating func update(_ newValue: T) {
        wrappedValue = newValue
        isModified = true
    }
}

struct FormData {
    @Trackable var name: String = ""
    @Trackable var email: String = ""
}

var form = FormData()
form.name = "김개발"

// Mirror로 Wrapper 인스턴스 자체에 접근
let mirror = Mirror(reflecting: form)
for child in mirror.children {
    print("\(child.label ?? ""): \(child.value)")
}
// _name: Trackable<String>(wrappedValue: "김개발", isModified: false)
// _email: Trackable<String>(wrappedValue: "", isModified: false)
// 언더스코어(_)가 붙은 게 Wrapper 인스턴스야!

8. Swift Macro vs Property Wrapper — 뭐가 다른 거야? 🤔

Swift 5.9에서 Swift Macro가 나오면서 "Property Wrapper 이제 구식 아냐?" 하는 말도 나왔는데,
사실 둘은 용도가 달라. 비교해보자:

구분 Property Wrapper Swift Macro
도입 버전 Swift 5.1 Swift 5.9
동작 시점 런타임 (일부 컴파일 타임) 순수 컴파일 타임
적용 대상 프로퍼티만 선언, 표현식, 타입 등 다양
코드 생성 제한적 (wrappedValue 패턴) 임의의 코드 생성 가능
복잡도 상대적으로 간단 복잡 (SwiftSyntax 필요)
디버깅 쉬움 상대적으로 어려움
주요 용도 프로퍼티 동작 커스터마이징 보일러플레이트 코드 자동 생성
🎯 결론: Property Wrapper는 여전히 현역!
프로퍼티 레벨의 동작 커스터마이징은 Property Wrapper가 더 직관적이고 간단해.
Swift Macro는 더 복잡한 코드 생성이 필요할 때 쓰는 거야.
둘 다 알아두면 최강 콤보 💪
Swift Macro 맛보기 (@Observable)
// Swift 5.9+ @Observable 매크로
// ObservableObject + @Published 조합을 대체
import Observation

@Observable  // 이게 매크로!
class UserViewModel {
    var name: String = ""
    var age: Int = 0
    var isLoggedIn: Bool = false
    // @Published 없어도 자동으로 관찰 가능!
}

// 컴파일러가 자동 생성하는 코드 (개념적):
// - _$observationRegistrar 추가
// - 각 프로퍼티에 willSet/didSet 관찰 코드 추가
// - ObservationIgnored 등 지원

// SwiftUI에서 사용
struct ContentView: View {
    @State private var vm = UserViewModel()  // @StateObject 대신 @State!
    
    var body: some View {
        Text(vm.name)
    }
}

9. 실전 프로젝트에서 쓰는 Property Wrapper 패턴 모음 💼
① 네트워크 요청 상태 관리
enum LoadingState<T> {
    case idle
    case loading
    case success(T)
    case failure(Error)
}

@propertyWrapper
struct AsyncState<T> {
    private var state: LoadingState<T> = .idle
    
    var wrappedValue: LoadingState<T> {
        get { state }
        set { state = newValue }
    }
    
    // projectedValue로 편의 프로퍼티 제공
    var projectedValue: AsyncStateHelper<T> {
        AsyncStateHelper(state: state)
    }
}

struct AsyncStateHelper<T> {
    let state: LoadingState<T>
    
    var isLoading: Bool {
        if case .loading = state { return true }
        return false
    }
    
    var value: T? {
        if case .success(let v) = state { return v }
        return nil
    }
    
    var error: Error? {
        if case .failure(let e) = state { return e }
        return nil
    }
}

class ProductViewModel: ObservableObject {
    @Published @AsyncState var products: LoadingState<[String]>
    // 실제로는 @Published와 커스텀 Wrapper 조합이 복잡할 수 있음
    // 아래처럼 분리하는 게 더 현실적
    
    @Published var productState: LoadingState<[String]> = .idle
    
    func fetchProducts() async {
        productState = .loading
        do {
            // 네트워크 요청...
            let data = ["상품A", "상품B", "상품C"]
            productState = .success(data)
        } catch {
            productState = .failure(error)
        }
    }
}
② 스레드 안전한 Property Wrapper
// 멀티스레드 환경에서 안전한 프로퍼티 접근
@propertyWrapper
final class ThreadSafe<T> {
    private var value: T
    private let lock = NSLock()
    
    var wrappedValue: T {
        get {
            lock.lock()
            defer { lock.unlock() }
            return value
        }
        set {
            lock.lock()
            defer { lock.unlock() }
            value = newValue
        }
    }
    
    init(wrappedValue: T) {
        self.value = wrappedValue
    }
}

class DataManager {
    @ThreadSafe var cache: [String: Any] = [:]
    @ThreadSafe var requestCount: Int = 0
    
    func processRequest() {
        requestCount += 1  // 스레드 안전하게 증가
        // 여러 스레드에서 동시 접근해도 안전!
    }
}
③ 의존성 주입 Property Wrapper
// 간단한 의존성 컨테이너
class DependencyContainer {
    static let shared = DependencyContainer()
    private var dependencies: [String: Any] = [:]
    
    func register<T>(_ type: T.Type, factory: @escaping () -> T) {
        let key = String(describing: type)
        dependencies[key] = factory
    }
    
    func resolve<T>(_ type: T.Type) -> T? {
        let key = String(describing: type)
        if let factory = dependencies[key] as? () -> T {
            return factory()
        }
        return nil
    }
}

@propertyWrapper
struct Injected<T> {
    private var value: T?
    
    var wrappedValue: T {
        get {
            if let value = value { return value }
            guard let resolved = DependencyContainer.shared.resolve(T.self) else {
                fatalError("의존성 \(T.self)가 등록되지 않았습니다!")
            }
            return resolved
        }
    }
    
    init() {}
}

// 프로토콜 정의
protocol NetworkService {
    func fetch(url: String) async throws -> Data
}

// 실제 구현체
class RealNetworkService: NetworkService {
    func fetch(url: String) async throws -> Data {
        // 실제 네트워크 요청
        return Data()
    }
}

// 등록
DependencyContainer.shared.register(NetworkService.self) {
    RealNetworkService()
}

// 사용
class ViewModel {
    @Injected var networkService: NetworkService
    // 자동으로 DependencyContainer에서 주입됨!
}

Property Wrapper 실전 활용 맵 🗺️ Property Wrapper 🗄️ 데이터 저장 UserDefaults · Keychain · CoreData ✅ 유효성 검사 이메일 · 전화번호 · 범위 📝 로깅 · 추적 변경 감지 · 디버깅 🔒 스레드 안전 Lock · Actor · Atomic 💉 의존성 주입 DI Container · 테스트 🔄 문자열 변환 Trim · 대소문자 · 포맷 📡 Codable 연동 기본값 · null 처리 🎨 SwiftUI 상태 관리 @State · @Binding · @Published Property Wrapper 하나로 이 모든 걸 해결할 수 있다 🚀
10. 주의사항 & 베스트 프랙티스 ⚠️
알아야 할 제약사항들
⚠️ 1. 로컬 변수에는 사용 불가 (Swift 5.7 이전)
Swift 5.7부터 로컬 변수에도 Property Wrapper 사용 가능해졌어.
그 이전 버전에서는 struct/class 프로퍼티에만 사용 가능했음.
⚠️ 2. lazy와 함께 사용 불가
@MyWrapper lazy var value: Int = 0 — 이건 컴파일 에러남!
lazy와 Property Wrapper는 같이 못 써.
⚠️ 3. 프로토콜 요구사항으로 선언 불가
프로토콜에서 @MyWrapper var value: Int 형태로 선언 불가.
프로토콜에서는 일반 프로퍼티로 선언하고 구현체에서 Wrapper 적용해야 해.
⚠️ 4. 성능 고려
클래스 기반 Wrapper는 힙 할당이 발생해서 성능에 영향 줄 수 있어.
성능이 중요한 코드에서는 struct 기반 Wrapper를 선호해.
베스트 프랙티스 ✅
✅ 1. 단일 책임 원칙
하나의 Property Wrapper는 하나의 역할만 담당하게 해.
너무 많은 기능을 하나에 넣으면 재사용성이 떨어져.
✅ 2. 명확한 이름 짓기
@Clamped, @Trimmed, @Logged처럼 동작을 명확히 표현하는 이름을 써.
@MyWrapper 같은 모호한 이름은 피해.
✅ 3. projectedValue 적극 활용
wrappedValue 외에 추가 정보가 필요하면 projectedValue를 활용해.
유효성 상태, 원본 값, 메타데이터 등을 $변수명으로 제공하면 사용성이 높아져.
✅ 4. 테스트 가능하게 설계
Wrapper 내부 로직은 별도 함수로 분리해서 단위 테스트가 가능하게 해.
특히 유효성 검사 로직은 반드시 테스트 코드 작성해.
✅ 5. 문서화
커스텀 Wrapper는 반드시 주석으로 동작 방식, 제약사항, 사용 예시를 문서화해.
팀 프로젝트에서 다른 사람이 이해하기 쉽게!

11. 전체 정리 — 오늘 배운 것들 📚
1
메타프로그래밍은 코드가 코드를 다루는 기법이야. Swift에서는 Property Wrapper, Result Builder, Mirror, Swift Macro 등이 여기에 해당해.
2
Property Wrapper는 @propertyWrapper 어트리뷰트로 만들고, wrappedValue(필수)와 projectedValue(선택)를 정의해. 컴파일러가 자동으로 보일러플레이트 코드를 생성해줘.
3
projectedValue는 $변수명으로 접근하고, SwiftUI의 @State에서 Binding을 반환하는 것도 이 원리야.
4
실전 패턴으로는 Clamped(범위 제한), Trimmed(공백 제거), Logged(로깅), Validated(유효성 검사), Codable 연동, 스레드 안전, 의존성 주입 등이 있어.
5
SwiftUI의 @State, @Binding, @Published 등이 모두 Property Wrapper야. 이제 이것들이 어떻게 동작하는지 이해할 수 있지?
6
Swift Macro는 Property Wrapper보다 더 강력하지만 복잡해. 프로퍼티 레벨 커스터마이징은 Property Wrapper가 여전히 최적의 선택이야.
🎉 🚀 💪 ✨ 🔥

Property Wrapper를 잘 활용하면 코드 품질이 확 올라가.
반복되는 패턴을 발견했을 때 "이거 Property Wrapper로 만들 수 있지 않을까?" 하고 생각하는 습관을 들이면 돼.
처음엔 어색하지만 한 번 익숙해지면 없이는 못 살게 될 거야 ㅋㅋㅋ

Swift 개발 관련 재능을 공유하거나 배우고 싶다면 재능넷(jaenung.net)에서 Swift 전문가들과 연결해보는 것도 좋은 방법이야. 실무 경험 있는 개발자한테 직접 배우면 이런 고급 패턴들을 훨씬 빠르게 익힐 수 있거든 😎

관련 키워드 🏷️
Swift Property Wrapper 메타프로그래밍 @propertyWrapper SwiftUI wrappedValue projectedValue Swift Macro Mirror Codable
댓글 작성

이 글에 대한 여러분의 생각을 들려주세요

댓글 0
댓글을 불러오는 중입니다.

아직 댓글이 없습니다.