Swift5.9 Macro(매크로) 만들어보기

개발 중 반복적으로 사용되는 코드는 유지보수를 어렵게 하고, 새 기능 추가 시 모든 관련 부분을 수정해야 하는 번거로움이 있다. 
이러한 반복적인 코드를 '보일러플레이트(Boilerplate)'라 부르며, 개발을 간편하게 하지만 코드 양과 복잡성을 증가시킬 수 있다.
 
Swift5.9에서는 매크로를 통해 위 문제를 효과적으로 해결할 수 있는 방법을 제시했다.
Swift 매크로를 사용하면 컴파일 시간에 반복적인 코드를 생성할 수 있어, 앱의 코드베이스를 더 표현적이고 읽기 쉽게 만들 수 있다.

또한 컴파일러를 수정하지 않고 Swift 패키지에 배포할 수 있다. 


이 글에서 다룰 주제를 정리 했다. (Xcode15.2 기준)
0. 예시 소개
1. 매크로 만드는 방법과 실행
2. 매크로 동작 방식
3. 매크로 테스트
 

0. 예시 소개

아래 코드를 보도록 하자

문제 코드 (출처: Apple)

산술연산(+,-,*,%)연산 결과와 그 계산식을 나타내는 string을 튜플로 저장하고 있다. 그리고 그 튜플들을 배열로 관리하고 있는 코드다.
근데 이 코드를 작성하다 보면 코드는 중복적이고, 실수로 string을 잘못 입력할 수 있는 상황도 충분이 있을 수 있다.
이때 연산된 결과(왼쪽)와 표현식을 나타내는 String의 불일치가 발생할 수 있다.
 
이 코드를 매크로를 통해서 단순화 시켜보겠다.
이를 해결하는 매크로 이름을 "stringify"로 정의 했다.

stringify 매크로 적용 결과

 
"stringify" 매크로는 하나의 매개변수(1 + 1...)로 계산을 받고 있고, 컴파일 시간에 앞서 본 튜플로 확장되어 계산과 결과가 일치하도록 보장한다.
해당 매크로는 "독립 매크로(freestanding macro)"라 호칭한다. 아래 내용을 통해 더 자세히 설명하겠다.
 

1. 매크로 만드는 방법과 실행

Xcode 실행

1. Launchpad 실행
2. Xcode 실행
 

Package 생성

1. 모니터 왼쪽 상단 File 선택
2. New 메뉴 선택
3. Package 메뉴 선택
 

매크로 생성 과정

1. Multiplatform 선택
2. Library 선택
3. Swift Macro 선택
4. Next 선택
 

매크로 생성

1. 원하는 이름으로 작성한다.
2. Create 선택
3. Github을 통해 배포할 예정이므로, 선택한다.
 

왼쪽 네비게이션 화면 파일 구조

생성 후, 위와 같은 파일 구조를 볼 수 있다.
1. 매크로 이름과 Package 파일 (Package.swift 구조: https://leviblog.tistory.com/38)
2. 매크로 선언 코드가 들어있다.
3. 정의한 매크로를 호출해 볼 수 있는 main
4. 매크로 구현 코드가 들어있다.
5. 구현한 매크로를 테스트 하는곳

 

 

매크로 실행해보기

메인 함수 실행 과정

1. 타겟을 선택하기 위해 해당 메뉴 선택
2. 타겟 선택 (...Client) 
 

맥앱 실행

1. 실행할 시뮬레이터 선택
2. 내 맥 선택
 

main.swift 실행 결과

1. 생성된 매크로를 이용해서 main.swift 실행한 결과다.
 
위 과정을 통해 매크로가 어떤 결과를 가져오는지 알 수 있게 되었다.
아래 코드를 통해서 main.swift의 구현부분을 자세히 살펴보자.

Stringify/Sources/StringifyClient/main.swift

생성된 매크로를 테스트해볼 수 있는 main.swift다.
1. 생성한 매크로를 import한다.
2. 독립 매크로(freestanding macro)를 호출하기 위해 이름 앞에 숫자 기호(#) 를 작성하고, 이름 뒤 소괄호 안에 매크로의 인수(a + b)를 삽입.

 
2. 매크로 동작 방식

이제 본격적으로 선언부와 구현부를 살펴보면서 매크로가 어떻게 동작되는지 살펴보겠다.
 
2.1 선언부

Sources/Stringify/Stringify.swift 선언부 선택

1. 붉은색 영역안에 있는 Stringify.swift 파일을 선택하자.
 

독립 매크로 선언부

매크로에는 "독립(freestanding) 매크로"와 "첨부(attached) 매크로"총 2가지 종류가 있다. 
여기서는 독립 매크로(freestanding)를 사용하여 구현했다.
 
1. 독립(Freestanding)매크로는 다른 코드 구조체나 선언에 종속되지 않고, 자체적으로 존재하며 동작한다. 특정 함수나 변수 등에 직접적으로 연결되어 있지 않고, 코드 내에서 독립적인 위치에 존재하며 필요한 작업을 수행할 수 있습니다. 쉽게 말해서 "혼자서도 잘 작동한다는 뜻", "그냥 코드 어디에서나 나타날 수 있고 혼자서도 잘 동작한다."는 의미
 
2. 제네릭 타입 'T'로 정의 했다. 독립 매크로의 인수는 expression(표현)또는 declaration(선언)이 들어갈 수 있으며, 해당 예시에서는 표현식(2 + 2)을 받고 있다.

키워드들을 생략하다 보면 함수 정의와 동일한 모습을 볼 수 있다. public stringify<T>(_ value: T) -> (T, String)
 

externalMacro 정의

3. externalMacro도 마찬가지로 독립 매크로로 제작되었다.

#externalMacro는 compiler plugin에 관한 관계를 정의한다.
#externalMacro 함수는 외부에서 정의된 매크로에 대해 모듈과 타입 이름을 지정한다.
externalMacro(module:type:) 매크로를 사용하여 Swift에 매크로의 구현 위치를 알림.

#externalMacro를 선언하면, 다른 API와 함께 일반 라이브러리에 들어가지만 매크로 구현은 별도의 컴파일러 플러그인 모듈에 들어간다.

 

module: 플러그인의 이름

1. Sources/StringifyMacros

모듈 이름(StringifyMacros)과 일치하며, Package.swift안에 작성된 2번 내용들을 모두 동일하게 작성해야함.

폴더(StringifyMacros)가 모듈로 생각하면 되겠다.

 

type: 

3. 플러그인 내부 유형의 이름. 즉 #externalMacro를 사용하면 Swift compiler가 플러그인에게 StringifyMacro라는 유형에 대해 expansion(확장)을 요청하는 것

실제로 #stringify 매크로가 구현된 타입이다. 

구현된 매크로는 1번 (StringifyMacros/StringifyMacro.swift)에 구현되어있다.


2.2 구현부

구현부 선택 -&amp;gt; StringifyMacro.swift

1. 구현 파일인 StringifyMacro 선택
 

구현부 코드

1. 필수적으로 필요한 모듈을 import 했다.

1.1 SwiftComilerPlugin: 컴파일러 플러그인을 구현하기 위한 모듈
=> SwiftCompilerPlugin은 Swift 컴파일러를 위한 플러그인을 만들 수 있게 해주는 도구로, 개발자가 컴파일 과정에 추가적인 기능이나 검사를 넣을 수 있도록 도와준다. 이 플러그인을 통해 컴파일러의 작동 방식을 사용자 정의하여 코드를 더 유연하게 관리할 수 있다.
 
1.2 SwiftSyntax: 코드를 구문 분석하고 조작하기 위한 라이브러리
=> SwiftSyntax 라이브러리는 고수준, 안전하고 효율적인 API를 통해 Swift 소스 코드를 검사, 처리, 조작하기 위한 데이터 구조와 알고리즘을 제공한다. SwiftSyntax 라이브러리는 Swift 파서, swift-format, Swift 매크로와 같은 도구가 구축되는 기반이 된다.
 
1.3 SwiftSyntaxBuilder: Swift 코드를 생성하기 위한 도구
=> SwiftSyntaxBuilder는 result builders를 사용하여 편리하게 Swift 코드를 생성하는 도구.
 
1.4  SwiftSyntaxMacros: 매크로를 구현할 때 사용하는 라이브러리.
 

2. StringifyMacro Struct:
=> ExpressionMacro 프로토콜을 준수하는 매크로의 일부. 주어진 표현식을 받아서 처리하고 새로운 표현식을 반환하는 역할을 한다.

2.1 node: some FreestandingMacroExpansionSyntax:
=> 매크로 확장이 적용될 노드를 나타낸다. FreestandingMacroExpansionSyntax 유형은 독립적으로 사용할 수 있는 매크로 확장을 나타내며, 이 경우에는 특정 표현식이 된다. 이 노드는 매크로가 적용될 소스 코드의 일부를 나타낸다.

2.2 context: some MacroExpansionContext:
=> 매크로 확장이 일어나는 컨텍스트를 제공. MacroExpansionContext는 확장 과정에서 필요할 수 있는 추가 정보나 유틸리티 함수를 제공할 수 있다.

2.3 Return - ExprSyntax:
=> ExprSyntax 타입을 반환. 이 리턴 값은 확장된 후의 새로운 표현식을 나타냄.
ExprSyntax는 SwiftSyntax 라이브러리에서 제공하는 타입으로, Swift의 표현식 구문을 나타낸다.
 
2.4 함수 내부:
=> 함수 내부에서는 guard 문을 사용하여 node에서 첫 번째 인자를 추출하고, 이 인자가 없을 경우 오류를 발생시킨다.
그리고 표현식의 값과 해당 값의 소스 코드 문자열을 포함하는 튜플을 생성하여 반환한다.
#stringify 매크로를 사용할 때, 주어진 표현식과 그 표현식의 소스 코드를 함께 튜플 형태로 제공하는 기능을 구현.

StringifyPlugin


3. StringifyPlugin Struct
CompilerPlugin 프로토콜을 준수하고 있고, Swift 컴파일러 플러그인을 생성하기 위한 기본적인 설정을 한다.

3.1 @main:
=> StringifyPlugin 구조체가 프로그램의 진입점을 포함하고 있음을 나타냄. 즉, 이 구조체가 프로그램 실행 시 가장 먼저 호출되는 코드를 포함한다는 의미.

3.2 struct StringifyPlugin: 
=> StringifyPlugin이라는 이름의 구조체를 정의한다. 구조체는 여러 멤버 변수와 메서드를 포함할 수 있는 데이터 구조.

3.3 CompilerPlugin:
=> CompilerPlugin을 준수하고 있고, 이는 StringifyPlugin이 컴파일러 플러그인으로서 필요한 특정 기능과 인터페이스를 구현해야 함을 의미한다.

3.4 providingMacros:
=> StringifyPlugin이 제공하는 매크로의 배열을 나타내고 있다. 배열에 포함된 매크로는 StringifyMacro.self로, StringifyMacro의 타입을 나타낸다. 이 구조체의 인스턴스가 생성될 때, providingMacros 배열은 StringifyMacro 타입을 포함하게 되며, 이는 StringifyPlugin이 StringifyMacro를 사용하여 매크로 기능을 제공함을 의미한다.

위에서 언급한 코드 덕분에, 컴파일러는 StringifyPlugin을 플러그인으로 인식하고, 이 플러그인이 StringifyMacro 매크로를 제공하게 된다. 따라서 사용자는 StringifyMacro가 제공하는 기능을 소스 코드에서 사용할 수 있게 됨.

 

3. 매크로 테스트

StringifyMacro 매크로를 테스트

 
3.1 모듈 임포트: 
SwiftSyntaxMacros:
Swift 매크로를 작성할 때 사용하는 API를 제공. 매크로를 정의하고 확장하는데 필요한 기능들이 이 모듈에 포함되어 있다.

SwiftSyntaxMacrosTestSupport:
매크로를 테스트하는 데 필요한 추가적인 지원 기능을 제공. 매크로 테스트를 보다 쉽고 효율적으로 수행할 수 있도록 도와주는 유틸리티나 도구들이 포함되어 있음.

XCTest:
애플이 제공하는 테스트 프레임워크로, Swift에서 단위 테스트를 작성하고 실행하는 데 사용함.
이 모듈을 통해 테스트 케이스를 정의하고, 테스트를 실행하며, 테스트 결과를 검증할 수 있음.
 
3.2 
#if canImport(StringifyMacros):
이 컴파일 지시문은 StringifyMacros 모듈이 현재 컴파일 환경에서 사용 가능한지 검사. 
크로스 컴파일링 시에는 일부 모듈이 사용 불가능할 수 있기 때문에, 이를 확인하여 불필요한 컴파일 오류를 방지한다.

StringifyMacros:
이 모듈에는 실제 StringifyMacro 매크로의 구현이 포함되어 있으며, 이를 테스트 코드에서 사용할 수 있게 된다.
 
let testMacros: [String: Macro.Type]: 
사용 가능한 경우, 테스트에 사용할 매크로를 딕셔너리 형태로 선언함.
이 딕셔너리는 매크로의 이름을 Macro.Type에 매핑하여, 테스트 중에 해당 매크로를 참조할 수 있게 한다.
 
3.3 testMacro: 테스트 매크로 정의
testMacro, testMacroWithStringLiteral
assertMacroExpansion 메서드 부분이 핵심이므로, 아래 파라미터를 구체적으로 살펴보자


매개변수:
originalSource: 원본 소스 코드로, 다양한 위치에 매크로가 포함되어 있을 것으로 예상됨. (예: #stringify(x + y)).
expectedExpandedSource: 원본 소스에 매크로 확장을 수행한 후에 보기를 기대하는 소스 코드.
diagnostics: 매크로를 확장할 때의 진단 정보
macros: 확장되어야 할 매크로들로, 매크로 이름 (예: "stringify")과 구현 타입 (예: StringifyMacro.self)을 매핑하는 딕셔너리 형태로 제공됨.
testModuleName: 사용할 테스트 모듈의 이름.
testFileName: 사용할 테스트 파일 이름.
 
출처:
https://swiftpackageindex.com/apple/swift-syntax/510.0.1/documentation/swiftsyntax
https://github.com/apple/swift-syntax/tree/main
https://developer.apple.com/videos/play/wwdc2023/10166
https://github.com/davidsteppenbeck/WWDC23