제가 Notion에서 운영중인 Blog 글을 재게시한 글입니다. 좀 더 깔끔하게 보고 싶은 분들이라면 위 링크를 눌러주세요. 🙇
WWDC2023에서 드디어 매크로라는 혁신적인 기능이 나왔어요.
매크로는 상용구 코드(boilerplate-code)를 유려하게 처리할 수 있는 도구예요.
WWDC를 시청하며 “아! 이건 잘만 학습하면 정말 편하게 활용할 수 있겠구나!”라는 생각이 들었고, 이번 기회에 제가 생각한 아이디어를 구현해보면서 매크로를 사용하는 법을 설명드리려고 해요.
앱을 개발하다보면 서버로부터 JSON 객체를 받아와 개발자가 선언한 모델에 파싱하는 작업을 할 거예요. 이 때 우리는 항상 동일한 상용구를 작성하게 되죠. 예를 들어볼까요?
아래는 앱에서 처리해야하는 JSON 코드가 2개 있습니다.
{
"name": "SeungHyun Hong",
"age": 130,
"gender": "male"
"created_at": "2024-08-08T09:33:26Z"
}
{
"user_information": {
"is_admin": true,
"post_count": 30,
"is_banned": false
},
"total_users": 582
}
우리는 이 코드를 파싱하기 위해 아래처럼 구현할 거에요.
struct Model: Codable {
let name: String
let age: Int
let gender: Gender
let createdTime: Date
enum CodingKeys: String, CodingKey {
case name
case age
case gender
case createdTime = "created_at"
}
}
enum Gender: String, Codable {
case male
case female
case other
}
struct Model: Codable {
let userInformation: UserInfo
let totalUsers: Int
enum CodingKeys: String, CodingKey {
case userInformation = "user_information"
case totalUsers = "total_users"
}
}
struct UserInfo: Codable {
let isAdmin: Bool
let postCount: Int
let isBanned: Bool
enum CodingKeys: String, CodingKey {
case isAdmin = "is_admin"
case postCount = "post_count"
case isBanned = "is_banned"
}
}
두 모델을 살펴보면 보다시피 동일하게 반복되는 코드를 볼 수 있어요.
Codable
을 준수한다.CodingKey
를 사용한다snake_case
로 값을 전달하는 경우가 있습니다. Swift 문법에서는 lowerCamelCase
를 주로 사용하므로 프로젝트 내 코드 컨벤션을 유지하기 위해 CodingKey
를 사용하여 변환해줄 수 있습니다.매번 이를 작성하는 것이 번거롭기도하고, 줄일 수 없는 상용 문구다 보니 매크로를 작업하기에 안성맞춤이라고 생각했어요.
그래서 제가 원하는 방법은 아래와 같아요.
@DTO
struct Model {
let name: String
let age: Int
let gender: Gender
@Property(key: "created_at") let createdTime: Date
}
@DTO
enum Gender: String {
case male
case female
case other
}
@DTO
struct Model {
@Property(key: "user_information") let userInfo: UserInfo
@Property(key: "total_users") let totalUsers: Int
}
@DTO
struct UserInfo {
@Property(key: "is_admin") let isAdmin: Bool
@Property(key: "post_count") let postCount: Int
@Property(key: "is_banned") let isBanned: Bool
}
CodingKey는 매크로에게 시키고, 원하는 변수명이 JSON key값에 상응하도록 설정해주는 거예요. 심지어 Codable을 준수하는 것까지도 매크로한테 시키는 거예요!
그러면 지금처럼 간결하게 코드를 구현할 수 있을 거예요.
우선 매크로는 사용시 접두사에 #
을 붙이는 것과 @
를 붙이는 것의 차이로 나뉘어요.
#
은 표현식에서 곧바로 사용하는 매크로이고요. @
는 특정 모델이나 변수에 붙여 쓰는 매크로로, 좀 더 다양한 옵션을 제공해줘요.
저는 특정 모델타입에 매크로로 상용구 코드를 작성하는 것이 목적이므로 @
를 사용할 거예요.
DTO가 하는 역할을 생각해봅시다.
저는 DTO 매크로를 붙이면 다음처럼 코드가 확장되기를 기대하고 있어요.
이럴 때는 @attached(member)
를 선언하고, 그 뒤에 따라오는 names 인자로 생성하고자 하는 이름을 지정하면, 앞으로 이 매크로를 실행했을 때 앞서 기술한 이름이 추가된다는 걸 알릴 수 있어요.
그리고 프로토콜을 준수하는 매크로를 생성하기 위해 @attached(extension)
을 사용하세요.
@attached(extension, conformances: Codable)
@attached(member, names: named(CodingKeys))
public macro DTO() = #externalMacro(module: "BlogMacros", type: "DTOMacro")
선언문과 구현문은 다른 파일로 분리되어있어요. 매크로 선언시 #externalMacro
로 링크해둔 곳에 구현 세부 사항이 존재하죠. 이 파일로 넘어가서 코드를 구현하면 돼요.
import SwiftCompilerPlugin
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros
// MARK: - DTOMacro
public struct DTOMacro: MemberMacro, ExtensionMacro {
...
}
구현할 매크로 종류에 따라 해당하는 프로토콜을 준수해서 필요한 코드를 구현해야해요. 그리고 프로토콜이 요구하는 모든 구현 사항들은 전부 expansion
이라는 정적 메서드를 갖고 있어요. 정적 메서드라는 말은 즉, Macro를 감싸는 타입은 일종의 컨테이너로 그 역할을 수행하고 있다는 뜻이예요. 그래서 enum 타입으로 선언해주어도 상관 없어요.
우선 코드 구현이 단조로운 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] {
return try [.init("extension \(raw: type.trimmedDescription): Codable {}")]
}
파라미터가 잔뜩 들어있는데, 우리는 단순히 타입에 Codable을 준수시키고 싶은 것이기에, type
인자만을 사용하여 매크로를 적용할 타입(type
)을 문자열 내에 보간해주면 돼요.
아래는 MemberMacro에서 요구하는 메서드예요.
public static func expansion(
of node: AttributeSyntax,
providingMembersOf declaration: some DeclGroupSyntax,
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
}
저는 CodingKeys라는 enum타입으로 CodingKey를 준수시키고, 각 모델이 갖고있는 프로퍼티에 따라 case문을 만들어줄 거예요. 그러기 위해서는 declaration을 사용할 필요가 있어요. declaration은 타입의 내부 속성을 들여다볼 수 있는 중요한 키워드 중 하나예요. WWDC 내에서는 아래와 같은 그림으로 설명해주고 있어요.
그래서 실제로 아래의 코드를 매크로가 실행한다고 했을 때, declaration
에서는 https://gist.github.com/WhiteHyun/db8b0fa1eed2d10458abec8062d2815f처럼 모든 변수 이름과 타입 등의 정보가 들어있는 걸 알 수 있어요.
@DTO
struct TestModel {
let name: String
let age: Int
@Property(key: "created_at") let createTime: Date
}
💡 제가 모든 구문을 전부 설명하지는 않을거에요. 설명하려면 Swift Syntax 라이브러리 설명으로 점철될 것 같거든요..
- 더 많은 정보를 알고 싶다면 https://www.youtube.com/watch?v=juUu0nBJ9Ns를 시청해주세요.
- 제가 작성한 코드는 https://gist.github.com/WhiteHyun/368c56ccca57dda5ccc70a59378e7fec에서 자세히 확인하실 수 있어요.
Property는 DTO가 key값을 찾고 원활히 CodingKey에 매칭할 수 있도록 돕는 매크로예요. 그래서 아무런 역할을 하지 않는 매크로를 선언해줘요.
@attached(accessor, names: named(willSet))
public macro Property(key: String? = nil) = #externalMacro(module: "DomainMacros", type: "PropertyMacro")
아무 역할도 하지 않을 거기 때문에 사실상 빈 배열을 리턴해줘도 무방해요.
public struct PropertyMacro: AccessorMacro {
public static func expansion(
of node: AttributeSyntax,
providingAccessorsOf declaration: some DeclSyntaxProtocol,
in context: some MacroExpansionContext
) throws -> [AccessorDeclSyntax] {
return []
}
}
근데 그럴리는 없겠지만 만약 @Property
매크로를 두 번 이상 중첩해서 사용한다 해도 오류가 나타나지 않을 거에요. 매크로가 expansion(of:providingAccessorOf:in:)
메서드를 실행해서 구문 해석을 따로 진행하지 않고 빈배열을 반환하기 때문이예요.
그래서 저는 중첩해서 사용하는지 여부를 파악하는 코드를 추가해서 사용자의 실수를 방지했어요.
public struct PropertyMacro: AccessorMacro {
public static func expansion(...) throws -> [AccessorDeclSyntax] {
guard let varDecl = declaration.as(VariableDeclSyntax.self) else {
return []
}
// Check if there's more than one @Property attribute
let propertyAttributes = varDecl.attributes.filter {
$0.as(AttributeSyntax.self)?.attributeName.description == "Property"
}
if propertyAttributes.count > 1 {
context.diagnose(
.init(
node: Syntax(node),
message: MacroExpansionDiagnostic.multiplePropertyApplication
)
)
}
return []
}
}
enum MacroExpansionDiagnostic: String, DiagnosticMessage { ... }
위 코드 역시 declaration 구문을 분석해서 처리했다보니 자세한 설명은 건너뛸게요.
지금처럼 Property의 key값에 맞게 CodingKey를 설정하는 작업은 전부 DTO가 관장해요. 왜냐하면 매크로는 대상자 내부에 코드를 확장하는 것이기 때문이예요.
그 말은 즉슨, Property는 단순히 메타데이터로만 그 역할을 하게 되고, 모든 작업은 DTO 매크로에서 해주게 되는 거예요.
제가 여기서 고민이 든 것은 Property를 매크로로 두는 게 옳은 방법인지 잘 모르겠다는 거예요.
매크로의 기능을 100% 온전히 활용하지 않는데, 매크로를 설정해야하는 그 당위성을 온전히 찾기가 어려웠어요.
하지만 Property가 아무 역할을 하지 않는 매크로라고 해서 제거하게 된다면, DTO 매크로가 JSON key값과 변수명이 다른 것을 어떻게 받아들일것이며, 어찌 받아들인다고 해도 결국 추가적인 코드가 들어가야 할 거예요.
그래서 이것저것 알아보던 와중, iOS17부터 제공하는 Observation 기능중@ObservationIgnored
의 내부 구현에서 아무 처리를 하지 않더라고요.
실제로 추적하고 싶지 않은 프로퍼티에게 설정해주는 매크로다보니 @Observable
에게 이런 메타데이터를 알려줄 필요성이 있었던 것 같아요.
저도 이 이유로 Property에게 아무런 설정을 해주지 않았는데, Observation을 구현한 애플 개발자도 아무 작업 없이 리턴하도록 설정해서 마음이 좀 놓이는 것 같아요 😅
다음과 같은 코드를 작성해서 출력해본 결과..
@DTO
struct Model {
@Property(key: "user_information") let userInfo: UserInfo
@Property(key: "total_users") let totalUsers: Int
}
@DTO
struct UserInfo {
@Property(key: "is_admin") let isAdmin: Bool
@Property(key: "post_count") let postCount: Int
@Property(key: "is_banned") let isBanned: Bool
}
let response = Model(userInfo: .init(isAdmin: false, postCount: 0, isBanned: true), totalUsers: 0)
let data = try! JSONEncoder().encode(response)
let jsonObject = try! JSONSerialization.jsonObject(with: data)
let jsonString = try! JSONSerialization.data(withJSONObject: jsonObject, options: .prettyPrinted)
print(String(data: jsonString, encoding: .utf8)!)
JSON이 성공적으로 처리된 것을 확인할 수 있었어요.
{
"total_users" : 0,
"user_information" : {
"post_count" : 0,
"is_admin" : false,
"is_banned" : true
}
}
WWDC23: Write Swift macros | Apple