Swift Macros 사용 이유
이전 포스팅(https://jeong9216.tistory.com/727)에서 Swift macros에 대해 소개했습니다.
Swift macros는 코드 작성 시 발생하는 반복적인 패턴을 효과적으로 제거하는 데 큰 도움이 됩니다.
여기서 주목할 점은 단순한 코드의 반복이 아닌, 코드만으로는 해결하기 어려운 '패턴의 반복'을 제거할 수 있다는 것입니다.
이는 Swift macros의 강력한 특징 중 하나입니다.
구체적인 예로, DTO(Data Transfer Object)에서 CodingKeys를 작성하는 패턴을 들 수 있습니다. 이러한 패턴은 일반적인 방법으로는 코드 레벨에서 반복을 제거하기 어렵습니다. 그러나 Swift macros를 활용하면 이러한 반복적인 패턴을 효과적으로 제거할 수 있어, 코드의 간결성과 유지보수성을 크게 향상시킬 수 있습니다.
DTO 매크로 구현 목표
DTO 매크로로 이루고 싶은 목표는 아래와 같습니다.
as-is
struct Model {
let id: Int
let title: String
let thumbnailImageURLString: String
enum CodingKeys: String, CodingKey {
case id = "idx"
case title
case thumbnailImageURLString = "thumbnail"
}
}
to-be
@DTO
struct Model {
@Key("idx") let id: Int
let title: String
@Key("thumbnail") let thumbnailImageURLString: String
}
CodingKeys 정의부를 제거할 수 있고,
API 속성 키와 구조체 속성 이름이 같다면 별도 작성이 필요 없게 되었습니다.
또 구조체 속성 이름을 복붙하는 행위도 더이상 하지 않아도 됩니다.
DTO 매크로 구현
DTO 매크로를 구현하는 방법을 가볍게 살펴보겠습니다.
매크로 정의
@attached(extension, conformances: Decodable)
@attached(member, names: named(CodingKeys))
public macro DTO() = #externalMacro(module: "DTOMacros", type: "DTOMacro")
@attached(accessor, names: named(willSet))
public macro Key(_: String? = nil) = #externalMacro(module: "DTOMacros", type: "KeyMacro")
DTOMacro는 Decodable(또는 Codable)을 채택하는 extension을 추가하고, CodingKeys enum을 추가합니다.
KeyMacro는 willSet 접근자를 추가합니다.
매크로 구현
DTOMacro는 ExtensionMacro과 MemberMacro 총 두 가지 Macro 프로토콜을 준수해야 합니다.
ExtensionMacro
extension DTOMacro: ExtensionMacro {
public static func expansion(
...
providingExtensionsOf type: some TypeSyntaxProtocol,
...
) throws -> [ExtensionDeclSyntax] {
try [.init("extension \(raw: type.trimmedDescription): Decodable {}")]
}
}
ExtensionMacro에서는 Decodable을 채택하는 extension 코드를 추가해야 합니다.
전달 받은 DTOMacro 사용 타입 이름으로 코드를 작성합니다.
MemberMacro
MemberMacro는 CodingKeys 코드를 작성해야 합니다.
모델의 각 속성 이름을 얻은 뒤 @Key가 붙었다면 "case {name} = {key name}"을, 아니라면 "case {name}" 코드를 추가합니다.
public struct ORCDTOMacro: MemberMacro {
public static func expansion(
of node: AttributeSyntax,
providingMembersOf declaration: some DeclGroupSyntax,
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
// Struct인지 확인합니다.
guard let structDecl = declaration as? StructDeclSyntax else { return [] }
...
}
}
먼저 구조체인지 확인합니다.
이건 구현하고 싶으신 방향에 따라 적용하시면 됩니다.
...
// name과 key 쌍을 만듭니다. Key로 작성한 key가 없다면 name으로 설정합니다.
let properties = structDecl.memberBlock.members.compactMap { member -> (name: String, key: String)? in
guard let variable = member.decl.as(VariableDeclSyntax.self),
let pattern = variable.bindings.first?.pattern.as(IdentifierPatternSyntax.self)
else {
return nil
}
let name = pattern.identifier.text
let key = variable.attributes.compactMap { attr -> String? in
guard let attr = attr.as(AttributeSyntax.self),
attr.attributeName.description == "Key",
let arguments = attr.arguments?.as(LabeledExprListSyntax.self),
let stringLiteral = arguments.first?.expression.as(StringLiteralExprSyntax.self)
else {
return nil
}
return stringLiteral.segments.description
}.first ?? name
return (name: name, key: key)
}
...
두 번째로 속성 이름과 key를 얻습니다.
만약 Key 설정을 안 했다면 key는 name과 동일하게 설정합니다.
...
// (name, key) 쌍으로 CodingKey enum을 작성합니다.
let codingKeySyntax = try EnumDeclSyntax("enum CodingKeys: String, CodingKey") {
for property in properties {
if property.name == property.key {
try EnumCaseDeclSyntax("case \(raw: property.name)")
} else {
try EnumCaseDeclSyntax("case \(raw: property.name) = \(literal: property.key)")
}
}
}
return [DeclSyntax(codingKeySyntax)]
...
위에서 만든 name과 key 쌍으로 CodingKeys 코드를 작성합니다.
EnumDeclSyntax로 Enum을 작성하고, EnumCaseDeclSyntax로 각 case를 작성합니다.
만약 name과 key가 같다면 별도의 Key 커스텀을 안 한 것이므로 name만 작성하고,
name과 key가 다르다면 key 대입을 합니다.
엣지 케이스 처리
잘못된 매크로 사용을 컴파일 에러로 발생시켜 봅시다.
@Key는 단 하나만 존재해야 합니다.
@Key가 2개 이상일 경우, 컴파일 에러를 발생시키고 저희가 직접 작성한 컴파일 에러 메시지를 보여줍시다.
public struct KeyMacro: AccessorMacro {
public static func expansion(
of node: AttributeSyntax,
providingAccessorsOf declaration: some DeclSyntaxProtocol,
in context: some MacroExpansionContext
) throws -> [AccessorDeclSyntax] {
guard let varDecl = declaration.as(VariableDeclSyntax.self) else { return [] }
let propertyAttributes = varDecl.attributes.filter {
$0.as(AttributeSyntax.self)?.attributeName.description == "Key"
}
if propertyAttributes.count > 1 {
context.diagnose(
.init(
node: Syntax(node),
message: MacroExpansionDiagnostic.multipleKey
)
)
}
return []
}
}
각 프로퍼티에 Key를 작성한 개수를 계산합니다.
만약 Key의 개수가 1개 초과라면 multipleKey 에러를 발생시킵니다.
enum MacroExpansionDiagnostic: String, DiagnosticMessage {
case multipleKey
var severity: DiagnosticSeverity {
switch self {
case .multipleKey:
return .error
}
}
var message: String {
switch self {
case .multipleKey:
return "@Key can only be applied once per property"
}
}
var diagnosticID: MessageID {
switch self {
case .multipleKey:
return MessageID(domain: "DomainMacro", id: rawValue)
}
}
}
multipleKey 에러를 발생시키면 "@Key can only be applied once per property" 컴파일 에러를 표시합니다.
구현 확인
Swift Macros를 사용하는 곳에서 Macros가 추가한 코드를 확인할 수 있습니다.
우리가 원하는대로 CodingKeys 작성, Decodable 채택, 엣지 케이스 처리가 완료되었습니다.
이제 저희는 CodingKeys를 위해 복사-붙여넣기와 Decodable, Codable 채택을 하지 않아도 됩니다!
DTO 매크로 적용 후기
현업 프로젝트에 DTO 매크로를 가볍게 적용했습니다.
CodingKeys를 작성하는건 필수적이지만 굉장히 귀찮은 작업이었는데요.
이 반복 작업이 상당히 간략화되어 매우 만족스러웠습니다.
생산성을 물론이고 심적 안정도 챙길 수 있었습니다🙏
매크로는 기존 코드를 변경시키지 않기 때문에 사이드 이펙트 걱정도 덜했고,
추가되는 코드를 확인할 수 있어 불안감도 낮았습니다.
혹시 매크로 적용을 고민하신다면 적극 추천드립니다.
감사합니다.
아직은 초보 개발자입니다.
더 효율적인 코드 훈수 환영합니다!
공감과 댓글 부탁드립니다.