Swift Macros 개요와 Observable 매크로
Swift의 5.9버전이 나오면서 Macros(매크로) 기능이 추가되었다. Swift에서 매크로의 등장으로 코드의 편의성과 가독성이 많이 개선될 것이다. 그렇다면 우선 매크로에 대해 알아보도록 하자.
매크로를 사용하면 반복적인 코드를 직접 작성하지 않아도 된다. 매크로는 새로운 코드를 추가하지만, 기존의 코드를 절대 삭제 또는 수정하지 않는다. 또한 매크로를 추가하는 과정에서 에러가 발생한다면 컴파일 에러로 처리하기 때문에 더욱 쉽게 어느 부분이 잘못됐는지 추적할 수 있다.
매크로는 독립 매크로(Freestanding macros)
와 첨부 매크로(Attached macros)
로 나뉜다.
- 독립 매크로(Freestanding macros) 는 선언에 첨부되지 않고 자체적으로 나타난다.
- 첨부 매크로(Attached macros)는 그것들이 붙어있는 선언을 수정한다.
독립 매크로와 첨부 매크로는 살짝 다르지만 같은 매크로 모델의 원리에 의해 작동한다. 우선 독립 매크로에 대해 알아보도록 하자.
Freestanding Macros
독립 매크로는 #
을 붙혀 호출하는데 다음 예시를 살펴보자.
func myFunction() {
print("Currently running \(#function)")
#warning("Something's wrong")
}
위 함수에는 두가지 독립 매크로가 있다. 우선 #function
과 #warning
이 나오는데 한번 살펴보도록 하자.
#function
의 경우 위 코드를 실행시켜보면 Currently running myFunction()
값을 print 한다. 즉 함수 내부에 사용하면 ExpressibleByStringLiteral
프로토콜을 따르는 제너릭 타입으로 함수명을 반환해준다.
#warning
의 경우는 warning error 를 자체적으로 위와같이 생성한다. 비슷한 독립 매크로로는 #error
가 있는데 이것은 자체적으로 error를 생성한다. 안그래도 골치아픈 에러를 왜 직접 만드는거야라고 생각하겠지만 아마도 TODO 및 FIXME 주석 대체의 역할이나 조건부 컴파일, 오류 조건 강조 등 여러가지 용도로 사용될 수 있을 것 같다.
import SwiftUI
#if !os(iOS)
#error("이 뷰는 iOS에서만 사용할 수 있습니다.")
#endif
struct iOSOnlyView: View {
var body: some View {
// iOS 전용 뷰 구현
}
}
예를 들어 위와 같이 특정 조건에서만 컴파일 되도록 제약을 거는 등의 방식으로 말이다.
그 외에도 소스 파일과 줄 번호를 지정하는 데 사용되는#sourceLocation
이나 프리뷰를 위한 #preview
와 같은 독립 매크로들이 존재한다.
Attached Macros
첨부 매크로는 이름 앞에 @
를 사용하여 호출한다. 첨부 매크로는 첨부된 선언을 수정한다. 새로운 메소드를 정의하거나 프로토콜을 추가하는 것과 같이 해당 선언에 코드를 추가한다.
예시를 보도록 하자
struct SundaeToppings: OptionSet {
let rawValue: Int
static let nuts = SundaeToppings(rawValue: 1 << 0)
static let cherry = SundaeToppings(rawValue: 1 << 1)
static let fudge = SundaeToppings(rawValue: 1 << 2)
}
위 코드는 OptionSet 프로토콜을 따르는 구조체로 rewValue 즉 bitmap number로 상태관리를 하기 위한 구조체이다. 이 코드를 Attached Macros를 이용하여 간단하게 만들면 다음과 같이 바뀐다.
@OptionSet<Int>
struct SundaeToppings {
private enum Options: Int {
case nuts
case cherry
case fudge
}
}
일일이 초기화를 하지 않아도 조금더 간단한 형태로 나타낼 수 있다. 이 코드를 확장해보면 다음과 같이 되는데,
struct SundaeToppings {
private enum Options: Int {
case nuts
case cherry
case fudge
}
typealias RawValue = Int
var rawValue: RawValue
init() { self.rawValue = 0 }
init(rawValue: RawValue) { self.rawValue = rawValue }
static let nuts: Self = Self(rawValue: 1 << Options.nuts.rawValue)
static let cherry: Self = Self(rawValue: 1 << Options.cherry.rawValue)
static let fudge: Self = Self(rawValue: 1 << Options.fudge.rawValue)
}
extension SundaeToppings: OptionSet { }
다음과 같은 과정으로 실행된다.
- 컴파일러가 코드를 읽고, 구문의 메모리 내 표현을 생성한다.
- 컴파일러는 메모리 내 표현의 일부를 매크로 구현에 전송하여 매크로를 확장한다.
- 컴파일러는 매크로 호출을 그 확장된 형태로 대체한다.
- 컴파일러는 확장된 소스 코드를 사용하여 컴파일을 계속 진행한다.
@Observable
이러한 첨부 매크로중 가장 유용하다고 생각된 첨부 매크로는 @Observable
이다. 예를 들어 기존에는 ObservableObject
프로토콜을 따르는 뷰모델을 구성할때는 다음과 같은 형태를 따랐다.
class TimerViewModel: ObservableObject {
@Published var hours = Array(0...23)
@Published var minutes = Array(0...59)
@Published var seconds = Array(0...59)
//...
}
하지만 매크로를 이용하면 다음과 같은 형태로 사용할 수 있다.
@Observable
class TimerViewModel{
var hours = Array(0...23)
var minutes = Array(0...59)
var seconds = Array(0...59)
이러한 변화는 단순히 코드가 조금 더 간편하다는 측면의 변화가 아니라 데이터 전달 방식의 큰 변화를 가져온다. 이러한 구조의 장점은 다음과 같다.
- ObservableObject를 사용할 때는 불가능한 옵셔널과 객체 컬렉션의 추적
- StateObject 및 EnvironmentObject와 같은 객체 기반 대응물 대신 State 및 Environment와 같은 기존 데이터 흐름 기본 요소를 사용
- 뷰의 본문이 읽는 관찰 가능한 속성의 변경에 따라 뷰를 업데이트하는 것은, 관찰 가능한 객체에 발생하는 모든 속성 변경 대신에 사용할 수 있으며, 이는 앱의 성능을 향상시킬 수 있다
차이점을 조금더 자세히 살펴보면
@main
struct BookReaderApp: App {
@State private var library = Library()
var body: some Scene {
WindowGroup {
LibraryView()
.environment(library)
}
}
}
기존의 @StateObject
로 초기화 하던 방식에서 @State
프로퍼티 래퍼를 이용해서 초기화 한다.
struct LibraryView: View {
@Environment(Library.self) private var library
var body: some View {
List(library.books) { book in
BookView(book: book)
}
}
}
그 후 EnvironmentObject 프로퍼티 래퍼 Environment 프로퍼티 래퍼를 사용한다. EnvironmentObject
는 특정 객체에 대한 전역적인 접근을 제공하는 반면, Environment
는 뷰 계층에 걸쳐 데이터를 더 유연하고 세밀하게 관리할 수 있는 방법을 제공한다. 이는 앱의 구조와 데이터 흐름을 더 효율적으로 만들어 줄 수 있다.
뿐만 아니라 다음과 같이 데이터 바인딩 하는 과정에서도 차이가 있다.
struct BookView: View {
var book: Book
@State private var isEditorPresented = false
var body: some View {
HStack {
Text(book.title)
Spacer()
Button("Edit") {
isEditorPresented = true
}
}
.sheet(isPresented: $isEditorPresented) {
BookEditView(book: book)
}
}
}
기존의 @ObservedObject
를 써서 데이터를 상위 뷰에서 전달받는 방식 대신 위와같은 방식으로 전달 받아도 변화된 데이터 내용을 감지하고 반영 해준다.
struct BookEditView: View {
@Bindable var book: Book
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack() {
TextField("Title", text: $book.title)
.textFieldStyle(.roundedBorder)
.onSubmit {
dismiss()
}
Button("Close") {
dismiss()
}
.buttonStyle(.borderedProminent)
}
.padding()
}
}
데이터를 업데이트 즉 set
을 해야하는 경우에는 @Bindable을 사용해서 업데이트 하게 된다.
이처럼 단순히 매크로의 형태를 이용함으로써 데이터 계층구조가 더욱 단순해지고 명확해짐을 확인할 수 있다.
결론
매크로는 swift 언어의 편의성, 가독성 뿐만 아니라 기능적 확장성과 유연성을 부여해주는 한단계 더 발전하는 교두보의 역할을 해줄 수 있는 기능이라고 생각한다. 하지만 무분별하게 매크로를 생성하여 사용하는것은 협업 측면에서 오히려 가독성을 해치는 결과를 초래 할 수 있다. Custom 매크로를 생성하고 프로젝트에 추가할 때는 충분한 협의와 필요성에 대한 검토가 필요하고 현재로서는 애플에서 제공하는 매크로를 위주로 조심스럽게 사용한다면 조금 더 SwiftUI 스러운 코드를 작성 할 수 있는 길이 되지 않을까 싶다.
댓글남기기