Custom Codable Macro #2 구현하기

김가영·2025년 3월 25일
0

swift

목록 보기
9/9

매크로 구현을 시작하기 전에

  • 매크로는 SwiftSyntax 라이브러리를 이용해 작성된다.
    스위프트 컴파일러는 소스코드를 AST로 변환하여 처리를 하는데, SwiftSyntax는 이 AST에 직접 접근해서 조작하는 걸 도와주는 API다.
  • 기본적으로 매크로 구현은 1.선언 2.구현 3.사용 의 과정을 거친다.
CodableMacro/
├── Package.swift
├── Sources/
│   ├── CodableMacro/             # 매크로 선언
│   └── CodableMacroCore/         # 매크로 구현 
└── Tests/
    └── CodableMacrosTests/       # 매크로 테스트

매크로 선언

  • CodableMacro.swift 파일에서 매크로를 선언할 수 있다.
  • 아래는 추가할 수 있는 매크로타입들이다.

매크로 종류

  • freestanding -> 말그대로 독립적으로 이용할 수 있는 매크로다.
    • expression -> 값을 리턴하는 코드 블록을 추가한다.
    • declaration -> 하나 이상의 선언을 추가한다.
  • attached -> 다른 변수나 타입들이 선언(declare)된 부분에 추가돼서 선언을 확장시킨다.
    • peer -> 선언된 곳의 같은 level에 새로운 선언을 추가한다. (import/operator 등 모든 선언에 추가할 수 있다)
    • accessor -> get/set 을 추가한다. (변수나 subscript에 추가할 수 있다)
    • memberAttribute -> 해당 타입의 멤버에 속성을 추가할 수 있다. (type/extension에 추가할 수 있다)
      예를 들면 public 등의 접근제어자, @discardableResult, @objc, 또는 프로퍼티 래퍼를 추가할 수 있다.
    • member -> 선언된 곳의 내부에 선언을 추가할 수 있다.(type/extension에 추가가능)
    • extension -> conformance를 추가한다. (type/expression에 추가가능)
  • https://engineering.traderepublic.com/get-ready-for-swift-macros-fe21d3867e02
    이 링크에서 각 타입들에 대한 좀 더 자세한 설명과 예시를 볼 수 있다.

Decodable 선언하기

  • 일단은 가장 기본적으로 Decodable conformance를 추가해주는 매크로를 만들어보자.
    1. 타입에 Decodable conformance를 추가해야하므로 attached(conformance)가 필요하다.
    2. conformance를 위해 타입 내부에 함수(init(from:))와 타입(CodingKeys)을 추가해야하므로 attached(member) 가 필요하다.
// CodableMacros.swift
@attached(extension, conformances: Decodable)
@attached(member, names: named(CodingKeys), named(init(from:)))
// CodableMacroCore 폴더 안에 정의된 DecodableMacro 타입을 보라는 것. DecodableMacro는 아직 정의되지 않았다.
public macro Decodable() = #externalMacro(module: "CodableMacroCore", type: "DecodableMacro")

이렇게 정의하면 아래처럼 사용할 수 있다.

@Decodable
struct Person {
    let name: String
}
  • 선언부에서는 어떤 이름의 변수/함수가 선언될 지를 미리 알려줘야한다.
  • 지금은 정확히 어떤 이름의 변수/함수가 추가될 지 알고 있기 때문에 .named(CodingKeys), named(init(from:) 를 사용해서 이름을 명시해줬다.
    • .prefix(String), .suffix(String) 를 써서 선언에 붙은 이름에 prefix/suffix가 붙는 이름의 선언이 추가됨을 알려주거나
    • .overloaded 로 동일한 이름의 선언이 추가됨을 알려주거나
    • 위에 해당하는 규칙을 사용할 수 없다면, .arbitrary 로 무작위 이름의 선언이 추가된다고 알려줄 수 있다.
  • 선언된 것과 다른 이름의 변수나 함수가 맘대로 추가되면 컴파일 에러가 뜬다. (.arbitrary 로 정의하면 어떤게 추가돼도 에러가 뜨지 않는다.)
  • 선언된 이름의 변수/함수가 추가되지 않은 경우에는 에러가 뜨지 않는다.

테스트 코드 작성하기

  • 매크로가 실제로 코드를 원하는대로 확장했는지 확인하려면, 실제로 매크로를 사용해봐야 한다. AST를 건들기 때문에 구현부에서는 코드가 어떻게 확장되었는지 직관적으로 보기 어려울 수 있다.
    그렇기에 일반적인 스위프트 프로젝트를 만들때보다 테스트 코드가 더 중요하다.
  • MacroTesting을 이용하면 매크로를 추가했을 때 코드가 어떻게 추가되길 원하는지를 테스트로 미리 작성할 수 있다.
// CodableMacroTests.swift
import SwiftSyntaxMacros
import SwiftSyntaxMacrosTestSupport
import XCTest
import CodableMacroCore 

// 테스트를 원하는 매크로들, 선언의 #externalMacro(module:type:) 에서 type에 써준 이름을 그대로 써준다.
// 아직은 해당 타입을 추가하지 않아서 에러가 뜬다.
// 테스트에서 자동으로 @Decodable 부분을 DecodableMacro 로 변경해준다.
let testMacros: [String: Macro.Type] = [
    "Decodable": DecodableMacro.self,
]

final class CodableMacroTests: XCTestCase {
    func testMacro() throws {
        assertMacroExpansion(
            """
            @Decodable
            struct Person {
                let name: String
                var age: Int?
            }
            """,
            expandedSource: """
            struct Person {
                let name: String
                var age: Int?
            
                enum CodingKeys: String, CodingKey {
                    case name, age
                }
            
                init(from decoder: any Decoder) throws {
                    let container = try decoder.container(keyedBy: CodingKeys.self)
                    self.name = try container.decode(String.self, forKey: .name)
                    self.age = try container.decodeIfPresent(Int.self, forKey: .age)
                }
            }
            """,
            macros: testMacros
        )
    }
}
  • assertMacroExpansion 은 첫번째 arg로 매크로가 사용된 코드를 받고, 두번째 arg로 해당 코드가 확장할 결과물을 받는다. 둘 사이가 다르면 에러가 난다.
    열받는 건,, 토씨 하나는 물론이고 개행문자(\n)나 tab 하나만 달라져도 에러가 난다는 것. 처음 작성할 때는 정성껏 작성해주고 나중에 코드 수정하면서 테스트 변경할 때는 테스트를 하나씩 실행하고 콘솔에서 actual source를 복붙한 후 변경된 부분을 git으로 확인하면 공수를 줄일 수 있다.
  • 이렇게만 작성하면 DecodableMacro.self가 정의되어있지 않기 때문에 에러가 난다. CodableMacroCore 하위에 DecodableMacro.swift 파일을 생성해서 아래처럼 기본 코드를 작성하자.
// CodableMacroCore.swift
import SwiftCompilerPlugin // Swift 컴파일러와 연결되어 매크로 기능을 등록하고 실행할 수 있게 해준다.
import SwiftSyntax // 소스 코드를 Sytax Tree 구조로 표현해준다.
import SwiftSyntaxBuilder // Syntax Tree 구성을 위한 편리 API를 제공한다.
import SwiftSyntaxMacros // 매크로 작성에 필요한 프로토콜과 타입을 제공한다.

/// 매크로 기능을 Swift 컴파일러에 등록하는 진입점
/// @main은 이 구조체가 프로그램의 시작점임을 나타낸다.
///
/// CompilerPlugin을 채택하여:
/// 1. 컴파일러가 이 매크로 패키지를 플러그인으로 인식하게 하고
/// 2. providingMacros 배열을 통해 사용 가능한 매크로 목록을 컴파일러에 알린다.
///
/// 새로운 매크로를 추가하려면 providingMacros 배열에 해당 매크로 타입을 추가해야한다.
@main
struct CodableMacroPlugin: CompilerPlugin {
    let providingMacros: [Macro.Type] = [
        DecodableMacro.self
    ]
}
// DecodableMacro.swift
import SwiftCompilerPlugin
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros

public struct DecodableMacro: ExtensionMacro, MemberMacro {
    // ExtensionMacro
    public static func expansion(
        of node: SwiftSyntax.AttributeSyntax,
        attachedTo declaration: some SwiftSyntax.DeclGroupSyntax,
        providingExtensionsOf type: some SwiftSyntax.TypeSyntaxProtocol,
        conformingTo protocols: [SwiftSyntax.TypeSyntax],
        in context: some SwiftSyntaxMacros.MacroExpansionContext
    ) throws -> [SwiftSyntax.ExtensionDeclSyntax] {
        []
    }
    
    // MemberMacro
    public static func expansion(
        of node: AttributeSyntax,
        providingMembersOf declaration: some DeclGroupSyntax,
        conformingTo protocols: [TypeSyntax],
        in context: some MacroExpansionContext
    ) throws -> [DeclSyntax] {
        []
    }
}
  • Macro 타입에 어떤 conformance를 추가해야하는지는, 위의 매크로 선언 섹션에 있는 이미지를 참고하면 된다.
  • 위 코드는 아무런 타입을 추가하지 않지만 테스트 코드를 동작시킨다. 테스트를 실행(이때 target은 mac)시킨 후 아래 에러를 확인하면 테스트 작성을 성공적으로 마친 것이다.

매크로 구현하기

  • 구현시 나는 공식 예제가 큰 도움이 됐다. MacroExamples/Implementation 하위에서 원하는 타입의 매크로가 어떻게 작성됐는지 보면 간단한 매크로는 뚝딱 할 수 있다.

swift 주요 AST 노드 유형

  • 매크로 구현은 AST를 기반으로 한다. swift 컴파일러는 소스 코드를 파싱해서 AST를 생성하는데, 매크로는 이 AST를 조작해서 코드를 생성하거나 변환하게 된다.
  1. DeclSyntax - 선언 노드
  • StructDeclSyntax: 구조체 선언
  • ClassDeclSyntax: 클래스 선언
  • FunctionDeclSyntax: 함수 선언
  • VariableDeclSyntax: 변수 선언
  1. ExprSyntax - 표현식 노드
  • LiteralExprSyntax: 리터럴 표현식(문자열, 숫자 등)
  • MemberAccessExprSyntax: 멤버 접근 표현식
  • FunctionCallExprSyntax: 함수 호출 표현식
  • ArrayExprSyntax: 배열 표현식
  1. StmtSyntax - 구문 노드
  • ReturnStmtSyntax: return 구문
  • IfStmtSyntax: if 구문
  • ForStmtSyntax: for 루프 구문
  1. TypeSyntax - 타입 노드
  • SimpleTypeIdentifierSyntax: 간단한 타입 식별자
  • ArrayTypeSyntax: 배열 타입
  • OptionalTypeSyntax: 옵셔널 타입

탐색하기

  • AST 노드는 계층 구조를 가진다. 노드를 탐색할 때는 아래 처럼 탐색할 수 있다.
    • 다운캐스팅 - declaration.as(StructDeclSyntax.self)
    • 자식 노드 접근 - structDecl.memberBlock 또는 for member in memberBlock.member
  • AST explorer 를 이용해서 구체적으로 내가 접근하고 싶거나 작성하고 싶은 node가 어떻게 구성되어있는지 볼 수 있다.

작성하기

  • Decodable conformance를 추가하는 ExtensionMacro를 먼저 작성해보자면, 공식 예제의 EqutableExtensionMacro를 참고하면 된다.
  • ExtensionMacro 를 아래처럼 수정하고 다시 testMacro() 테스트를 돌려보자.
// ExtensionMacro
    public static func expansion(
        of node: SwiftSyntax.AttributeSyntax,
        attachedTo declaration: some SwiftSyntax.DeclGroupSyntax,
        providingExtensionsOf type: some SwiftSyntax.TypeSyntaxProtocol,
        conformingTo protocols: [SwiftSyntax.TypeSyntax],
        in context: some SwiftSyntaxMacros.MacroExpansionContext
    ) throws -> [SwiftSyntax.ExtensionDeclSyntax] {
        let decodableExtension = try ExtensionDeclSyntax("extension \(type.trimmed): Decodable {}")
        return [decodableExtension]
    }
  • console에서 Actual expanded source 부분을 찾아보면 아래처럼 Decodable이 추가된 걸 확인할 수 있다.
Actual expanded source:

struct Person {
    let name: String
    var age: Int?
}

extension Person: Decodable {
}

에러 추가하기

  • 매크로를 잘못 사용할 경우에는 에러를 내야한다.
    예를 들면 위 예시에서 나는 enum에 대한 Decodable conformance는 구현하지 않을 예정이므로, enum인 경우 에러를 내서 잘못된 활용을 방지할 수 있다.
  • 에러에는 기본 swift Error 또는 매크로에서 제공하는 Diagnostics을 사용할 수 있다.
  • Diagnostics는 문제가 있는 정확한 노드를 찾거나, error 말고도 warning, note, fixit 등의 기능을 지원한다. 추가로 하이라이팅/다중메시지/quick fix 등의 장점을 갖는다.
  • 하지만 그만큼 코드가 좀 더 복잡해지고 기본 에러를 사용하는 것에 비해 추가리소스/러닝커브가 꽤 클 것이라 생각하여 일단 기본 에러를 사용했다.
  • 대신 error message를 자세하게 작성해서 에러가 발생한 자세한 맥락을 전달하고자 했다.
// CodableMacroCore
// CodableMacroError.swift
enum CodableMacroError: Error, CustomStringConvertible {
  case message(String)

  var description: String {
    switch self {
    case .message(let text):
      return text
    }
  }
}
  • ExtensionMacro에서 struct/class가 아닐 경우 에러를 내게 해보자.
  • DefaultFatalErrorImplementationMacro 예제에서 프로토콜이 아닌 경우 에러를 내는 예시를 확인할 수 있다. 추가로 AST explorer에서 struct, class를 정의해서 노드를 분석해서 class/struct인 경우 각각 ClassDecl, StructDecl를 사용함을 확인했다.
  • 그럼 아래처럼 작성할 수 있다.
// Decodable annotation이 struct/class에 추가된 것인지 체크
guard declaration.is(StructDeclSyntax.self) || declaration.is(ClassDeclSyntax.self) else {
    throw CodableMacroError.message("@Codable은 class 또는 struct에 선언되어야 합니다.")
}
let decodableExtension = try ExtensionDeclSyntax("extension \(type.trimmed): Decodable {}")
return [decodableExtension]

에러 테스트 코드

func testErrorIfEnum() throws {
        assertMacroExpansion(
            """
            @Decodable
            enum Cafe {
                case starbucks
            }
            """,
            expandedSource: """
            enum Cafe {
                case starbucks
            }
            """,
            diagnostics: [
                DiagnosticSpec(message: "@Codable은 class 또는 struct에 선언되어야 합니다.", line: 1, column: 1)
            ],
            macros: testMacros
        )
    }
  • 에러를 테스트하는 코드도 추가할 수 있다.
  • expandedSource에 빈 string을 넣으면 expandedSource 부분은 무시한다는데 잘 안됐다. (기본 에러를 써서 그럴까?)
  • line, column은 우리가 작성한 source code의 어디에서 에러가 발생하는지 정보이다. @Decodable 이 정의된 부분에서 에러가 발생할 것이므로 (1,1) 을 넣어줬다.

마무리

  • 같은 방식으로 MemberMacro의 CodingKeys, init(from:) 선언도 추가할 수 있다.
  • Codable, nested 구조 decoding 등 추가 구현은 https://github.com/jujube0/CodableMacro 에서 볼 수 있다.
profile
개발블로그

0개의 댓글

관련 채용 정보