24 June 2023

Swift4에서 Codable 프로토콜이 추가되었습니다. 그러나 그전에 개발하고 서비스하고 있던 서비스에서는 Codable로의 전환이 쉽지가 않습니다. 많은 Parameter, Response 타입을 전환하기에는 이미 코드가 잘 돌아가고 있고, Codable로의 전환하는데 비용이 꽤나 크기 때문입니다. 대신 새로운 서비스 등에서는 Codable을 적용할 수 있습니다.

Codable 이전에는 Response 정보를 파싱을 도와주는 ObjectMapper, SwiftyJSON 등을 이용하여 값을 만들었습니다.

/**
 {
 "title": "Init Response",
 "msg": "Hello World",
 "year_mm": "202306"
 }
 */

import SwiftyJSON

public protocol JSONResponse {
    var json: JSON { get }
    init(json: JSON)
}

public struct SomeResponse: JSONResponse {
    public let json: JSON

    public var title: String { json["title"].stringValue }
    public var msg: String { json["msg"].stringValue }
    public var yearMonth: String { json["year_mm"].stringValue }

    public init(json: JSON) { 
        self.json = json
    }
}

위와 같이 SwifyJSON을 활용하면 Codable 만큼은 아니지만, 코드를 나름 간결하게 만들 수 있습니다. 하지만 Codable에 비하면 불편한건 사실입니다.

Macros

Swift 5.9 이전까지는 json을 접근하여 값을 가져오는 상용구 코드는 줄일 수 없습니다. WWDC 2023의 Expand on Swift macros 세션 중에 나온 예제가 위의 코드와 아주 유사하였습니다. 세션에 나온 예제와 비슷하게 작업하여 코드를 줄일 수 있을 것으로 추론해볼 수 있습니다.

Macros에 대해서는 WWDC에서 자세히 다루기 때문에 상세한 이야기는 생략하겠습니다.

위의 코드를 Macros를 활용하여 다음과 같이 코드를 줄일려고 합니다.

import SwiftyJSON

@ResponseInit
public struct SomeResponse {
    @ResponseJSON 
    public var title: String
    @ResponseJSON 
    public var msg: String
    @ResponseJSON(key: "year_mm") 
    public var yearMonth: String
}

그러면 Macros 코드를 작성해봅시다.

첫 번째로, @ResponseInit 매크로를 사용하면 JSONResponse 프로토콜을 준수하고 채택하도록 만들려고 합니다.

ResponseInit 매크로를 정의합니다.

/// Module : ResponseMacro
/// FileName : ResponseMacro.swift

@attached(member,
          names: named(init),
          named(json))
@attached(conformance)
public macro ResponseInit() = #externalMacro(module: "ResponseMacros", type: "ResponseInitMacro")

다음으로 ResponseInitMacro 를 구현합니다.

/// Module : ResponseMacros
/// FileName : ResponseInitMacro.swift

import SwiftCompilerPlugin
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros

public struct ResponseInitMacro {}

extension ResponseInitMacro: MemberMacro {
    public static func expansion(
        of node: AttributeSyntax,
        providingMembersOf declaration: some DeclGroupSyntax,
        in context: some MacroExpansionContext
    ) throws -> [DeclSyntax] {
        guard let _ = declaration.as(StructDeclSyntax.self) else {
            throw CustomError.message("@ResponseInit can only be applied to a struct declarations.")
        }
        let access = declaration.modifiers?.first(where: \.isNeededAccessLevelModifier)
        return [
            "\(access)var json: JSON",
            "\(access)init(json: JSON) { self.json = json }",
        ]
    }
}

extension ResponseInitMacro: ConformanceMacro {
    public static func expansion(
        of attribute: AttributeSyntax,
        providingConformancesOf decl: some DeclGroupSyntax,
        in context: some MacroExpansionContext
    ) throws -> [(TypeSyntax, GenericWhereClauseSyntax?)] {
        return [("JSONResponse", nil)]
    }
}

enum CustomError: Error, CustomStringConvertible {
    case message(String)
    
    var description: String {
        switch self {
        case .message(let text):
            return text
        }
    }
}

extension DeclModifierSyntax {
    var isNeededAccessLevelModifier: Bool {
        switch self.name.tokenKind {
        case .keyword(.public): return true
        default: return false
        }
    }
}

@ResponseInit 매크로를 사용하여 다음 결과를 얻을 수 있습니다.

@ResponseInit
public struct SomeResponse {
    public var title: String = ""
    public var msg: String = ""
    public var yearMonth: String = ""
}

// Expand Macro

public struct SomeResponse {
    public var title: String = ""
    public var msg: String = ""
    public var yearMonth: String = ""
    public var json: JSON

    public init(json: JSON) {
        self.json = json
    }
}

extension SomeResponse : JSONResponse  {}

@ResponseInit 매크로를 이용하여 JSONResponse 프로토콜을 준수하고 채택하였음을 확인할 수 있습니다.

두 번째로, 각 변수들은 해당 이름이 키로 사용하거나, 지정된 키를 통해 json을 접근하여 값을 얻어오도록 만들어주는 ResponseJSON 매크로를 만들어봅시다.

/// Module : ResponseMacro
/// FileName : ResponseMacro.swift

@attached(accessor)
public macro ResponseJSON(key: String? = nil) = #externalMacro(module: "ResponseMacros", type: "ResponseJSONMacro")

다음으로 ResponseJSONMacro 를 구현합니다.

/// Module : ResponseMacros
/// FileName : ResponseJSONMacro.swift

import SwiftCompilerPlugin
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros

public struct ResponseJSONMacro {}

extension ResponseJSONMacro: AccessorMacro {
    public static func expansion(
        of node: AttributeSyntax,
        providingAccessorsOf declaration: some DeclSyntaxProtocol,
        in context: some MacroExpansionContext
    ) throws -> [AccessorDeclSyntax] {
        guard let property = declaration.as(VariableDeclSyntax.self),
              let binding = property.bindings.first,
              let identifier = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier,
              let type = binding.typeAnnotation?.type,
              binding.accessor == nil
        else {
            return []
        }

        var key = identifier.text
        
        if case let .argumentList(arguments) = node.argument,
           let expression = arguments.first?.expression,
           let stringSegment = expression.as(StringLiteralExprSyntax.self)?.segments.first,
           case let .stringSegment(manualKey) = stringSegment {
            key = manualKey.content.text
        }

        let typeDesc = type.as(SimpleTypeIdentifierSyntax.self)?.description
        let jsonValueText: String = switch typeDesc {
        case "String": ".stringValue"
        case "Int": ".intValue"
        default: ""
        }
        let getAccessor: AccessorDeclSyntax =
      """
      get {
        json[\(literal: key)]\(raw: jsonValueText)
      }
      """
        
        return [getAccessor]
    }
}

@ResponseJSON 매크로를 사용하여 다음 결과를 얻을 수 있습니다.

public struct SomeResponse {
    @ResponseJSON 
    public var title: String
    @ResponseJSON 
    public var msg: String
    @ResponseJSON(key: "year_mm") 
    public var yearMonth: String
}

// Expand Macro

public struct SomeResponse {
    public var title: String
    {
        get {
          json["title"].stringValue
        }
    }
    public var msg: String
    {
        get {
          json["msg"].stringValue
        }
    }
    public var yearMonth: String
    {
        get {
          json["year_mm"].stringValue
        }
    }
}

다음으로, ResponseInit, ResponseJSON 매크로를 모두 적용한 결과입니다.

import SwiftyJSON

@ResponseInit
public struct SomeResponse {
    @ResponseJSON 
    public var title: String
    @ResponseJSON 
    public var msg: String
    @ResponseJSON(key: "year_mm") 
    public var yearMonth: String
}

// Expand Macro

public struct SomeResponse {
    public var title: String
    {
        get {
          json["title"].stringValue
        }
    }
    public var msg: String
    {
        get {
          json["msg"].stringValue
        }
    }
    public var yearMonth: String
    {
        get {
          json["year_mm"].stringValue
        }
    }
    public var json: JSON

    public init(json: JSON) {
        self.json = json
    }
}
extension SomeResponse : JSONResponse  {}

위의 코드는 GitHub에서 확인하실 수 있습니다.

참고자료