Macro를 알아보자 - Rust 프로그래밍

1.png

개요

Rust에는 Macro라는 것이 존재합니다. Macro에 대해 이야기하기 전에 Metaprogramming을 먼저 이해하면 도움이 되는데요. 일반적인 Programming이 "데이터를 조작하는 코드"를 작성하는 것이라면, Metaprogramming은 쉽게 말해 "코드를 조작하는 코드"를 작성하는 것을 말합니다. 우리가 흔히 알고 있는 Java의 Reflection도 Metaprogramming의 한 종류로 볼 수 있습니다. Macro는 Rust에서 제공하는 Metaprogramming 도구의 한 종류입니다.

Rust에서 제공하는 Macro는 크게 Declarative Macros(선언적 매크로)와 Procedure Macros(절차적 매크로) 이렇게 두 가지 종류로 나눌 수 있습니다. Declarative Macros는 macro_rules!라는 키워드를 통해 정의할 수 있고 println!, vec! 같은 것들이 있습니다. Procedure Macros는 custom derive macros, attribute-like macros, function-like macros 이렇게 세 가지 종류로 나뉩니다. Procedure Macros는 함수처럼 동작하며 TokenStream 이라는 것을 input으로 받아 output으로 새로운 TokenStream을 만들어내는 구조로 동작합니다.


Declarative Macros

Declarative Macro는 Rust로 프로그래밍 작성할 때 매우 흔하게 사용되는 Macro 입니다. 앞서 설명했듯이 macro_rules!라는 키워드를 통해 정의할 수 있고, match 표현과 유사한 구조로 작성할 수 있습니다. 공식문서에서 vec! Macro의 간단한 버전을 아래와 같이 볼 수 있습니다.

#[macro_export]
macro_rules! vec {
    ( $( $x:expr ), * ) => {
        {
            let mut temp_vec = Vec::new();
            $(
                temp_vec.push($x);
            )*
            temp_vec
        }
    }
}

macro_export

macro_rules!로 선언한 Macro는 기본적으로 다른 crate에서 접근할 수 없는데요. #[macro_export]는 이 Macro를 다른 crate에서 접근 가능하게 해줍니다. 만약, 선언하지 않는다면 아래처럼 에러가 발생합니다.

macro_rules! potatogi {
    () => {
        println!("This is potatogi!");
    }
}
3.png

#[macro_export]를 사용하지 않고 Macro를 정의한 crate 내에서만 사용할 수 있는데요. 아래와 같이 Macro를 정의한 모듈 위에 #[macro_use]를 사용해 potatogi 모듈 내부에 선언된 potatogi!() 매크로를 사용할 수 있습니다.

#[macro_use]
mod potatogi;

pub fn hello() {
    potatogi!();
}

#[macro_export]를 쓰지 않는다면, #[macro_use] 없이는 같은 crate 내의 다른 모듈에서도 정의한 Macro를 사용할 수 없습니다.


macro definition

Macro는 macro_rules!를 통해 정의할 수 있고 macro_rules! 뒤에는 Macro의 이름이 옵니다. 이때, Macro의 이름에는 !가 들어가지 않습니다. 공식 문서상 예제는 vec 이며 아래와 같은 형태가 됩니다.

#[macro_export]
macro_rules! vec {
    ...
}

macro body

Macro의 body는 이름 뒤의 중괄호({}) 내에 작성할 수 있습니다. 예시에서 볼 수 있듯이 body는 Rust의 match 구문의 구조와 유사합니다. 여기서 ( $( $x:expr ), * )는 패턴을 의미하며 이 패턴과 코드 구조가 맞으면 => { ... } 내의 코드 구조가 생성되는 형태입니다. 아래 코드는 간단한 예시로 하나의 패턴만 존재하고 다른 패턴이 매칭될 경우 에러가 발생합니다. 좀 더 복잡한 Macro의 경우 여러 패턴을 가질 수 있습니다.

#[macro_export]
macro_rules! vec {
    ( $( $x:expr ), * ) => {
        {
            let mut temp_vec = Vec::new();
            $(
                temp_vec.push($x);
            )*
            temp_vec
        }
    }
}

pattern

먼저, 패턴 부분을 알아보겠습니다.

...
( $( $x:expr ), * ) => {
...

패턴 전체를 감싸기 위해서 소괄호(()) 를 사용합니다. $는 Macro 구문에서 변수를 담기 위해 사용하고, 이 변수는 패턴과 일치하는 Rust의 코드를 담고 있습니다. $ 형태로 Macro 변수를 선언하는 것으로 일반적인 Rust 코드의 변수와 구분된다는 것을 알 수 있습니다.

$ 다음으로 또 하나의 소괄호(())가 따라오는데, 이는 내부의 패턴을 그룹화하고 반복될 수 있음을 의미합니다. $x:expr은 Rust의 특정 표현과 match되며 $x는 변수명을 나타냅니다. 이어서 expr은 캡처할 코드의 유형으로 expression의 줄임말로 Rust의 모든 표현식 형태를 나타냅니다. 뒤에 따라오는 콤마(,)는 반복되는 요소($()) 사이에 들어갈 구분자를 나타내고, * 는 요소가 0번이상 반복된다는 의미입니다.

이제, 매칭된 패턴에 따라 코드가 작성되는데, $()* 안의 코드가 인식된 개수만큼 반복해서 작성됩니다. 예를 들어, vec![1, 2, 3]을 호출하면 temp_vec.push(1), temp_vec.push(2), temp_vec.push(3) 이렇게 세 줄의 코드가 자동으로 생성됩니다. 결과적으로 아래와 같은 코드를 생성합니다.

{
    let mut temp_vec = Vec::new();
    temp_vec.push(1); // 첫 번째
    temp_vec.push(2); // 두 번째
    temp_vec.push(3); // 세 번째
    temp_vec
}

Procedure Macros

Macro의 또 다른 형태로 함수처럼 동작하는 Procedure Macro가 있습니다. Declarative Macro는 패턴 매칭을 통해 코드를 다른 코드로 대체하는 식으로 동작하는 반면 Procedure Macro는 특정 코드를 input으로 받아서 코드를 수행하고 특정 코드를 output으로 만들어냅니다. 이러한 Procedure Macro에는 custom derive macro, attribute-like macro, function-like macro가 있습니다. Procedure Macro를 만들때는 특별한 crate type으로 별도의 crate로 정의해야합니다.

아래는 Procedure Macro를 정의하는 방법인데요.some_attribute에 들어가는 종류에 따라 특정 Macro를 정의할 수 있습니다.

use proc_macro::TokenStream;

#[some_attribute]
pub fn some_name(input: TokenStream) -> TokenStream {
}

이 함수는 TokenStream을 input으로 받아서 TokenStream을 output으로 만들어내는 Procedure Macro입니다. TokenStream 타입은 proc_macro crate에 포함되어 있고 하나의 토큰 시퀀스를 나타냅니다. 핵심은 Macro가 작용하는 코드가 input TokenStream이 되고 Macro가 생산하는 코드가 output TokenStream이 되는 것입니다. 그리고 이 함수는 어떤 종류의 Prodecure Macro인지를 나타내는 속성을 갖고 있으며, 하나의 crate에 여러 종류의 Procedure Macro를 정의할 수 있습니다.


custom derive macros

custom derive macros는 구조체(struct)나 열거형(enum)에 #[derive(...)] 속성을 붙여 특정 트레이트의 구현을 자동으로 생성하는 Procedure Macro입니다. 우리가 흔히 사용하는 #[derive(Debug)], #[derive(Clone)] 등이 모두 이 방식으로 동작합니다.

직접 derive macro를 만드는 예시를 살펴보겠습니다. HelloMacro라는 트레이트와 이를 자동으로 구현해주는 derive macro를 정의한다고 가정해봅시다. 사용하는 쪽에서는 아래와 같이 작성할 수 있습니다.

use hello_macro::HelloMacro;
use hello_macro_derive::HelloMacro;

#[derive(HelloMacro)]
struct Pancakes;

fn main() {
    Pancakes::hello_macro();
    // 출력: Hello, Macro! My name is Pancakes!
}

#[derive(HelloMacro)] 하나만으로 Pancakes 구조체에 HelloMacro 트레이트가 자동으로 구현됩니다. 이제 이 Macro를 구현하는 방법을 살펴보겠습니다.

Procedure Macro는 별도의 crate로 정의해야 합니다. hello_macro_derive라는 새 crate를 만들고 Cargo.toml에 아래 의존성을 추가합니다.

[lib]
proc-macro = true

[dependencies]
syn = "2.0"
quote = "1.0"

그리고 Macro 구현은 아래와 같습니다.

use proc_macro::TokenStream;
use quote::quote;

#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
    let ast = syn::parse(input).unwrap();
    impl_hello_macro(&ast)
}

fn impl_hello_macro(ast: &syn::DeriveInput) -> TokenStream {
    let name = &ast.ident;
    let gen = quote! {
        impl HelloMacro for #name {
            fn hello_macro() {
                println!("Hello, Macro! My name is {}!", stringify!(#name));
            }
        }
    };
    gen.into()
}

#[proc_macro_derive(HelloMacro)]를 함수에 붙이면 HelloMacro derive macro가 됩니다. syn 크레이트로 input TokenStream을 파싱해 AST로 변환하고, quote! 매크로로 트레이트 구현 코드를 생성합니다. #name은 컴파일 타임에 구조체 이름(Pancakes)으로 치환되며, stringify!는 이를 문자열 리터럴로 변환합니다.


attribute-like macros

attribute-like macros는 custom derive macros와 유사하지만, struct와 enum에만 적용 가능한 derive와 달리 함수를 포함한 모든 항목에 적용할 수 있는 커스텀 속성을 정의합니다.

예를 들어, 웹 프레임워크에서 아래와 같이 라우팅을 정의할 수 있습니다.

#[route(GET, "/")]
fn index() {
    // 함수 본문
}

이 Macro를 구현하는 함수의 시그니처는 아래와 같습니다.

#[proc_macro_attribute]
pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream {
    // attr: 속성의 내용 (GET, "/")
    // item: 속성이 붙은 항목의 본문 (fn index() {...} 전체)
}

custom derive macros와의 핵심 차이점은 TokenStream 파라미터가 두 개라는 점입니다. 첫 번째는 속성의 내용(GET, "/"), 두 번째는 속성이 붙은 항목의 본문 전체입니다.


function-like macros

function-like macros는 함수 호출과 유사한 형태로 사용되지만, Declarative Macro와 달리 TokenStream을 직접 조작하여 더 복잡한 처리가 가능합니다.

예를 들어, SQL 문법을 검증하는 Macro를 아래와 같이 사용할 수 있습니다.

let sql = sql!(SELECT * FROM posts WHERE id=1);

이 Macro를 구현하는 함수의 시그니처는 아래와 같습니다.

#[proc_macro]
pub fn sql(input: TokenStream) -> TokenStream {
    // 괄호 안의 토큰들을 파싱하고 처리하여 코드 생성
}

macro_rules!로 정의하는 Declarative Macro와 형태는 비슷하지만, TokenStream을 직접 다루므로 SQL 문법 검증과 같이 더 복잡한 처리를 구현할 수 있습니다.


마무리

Macro를 남용하게 되면 코드를 읽고 디버깅하는 것을 어렵게 만드는데요. Rust에서는 Macro를 사용하기 전에 일반 함수나 Generics 사용을 먼저 고려하는 것을 권장합니다. 따라서, 꼭 필요한 상황에만 적절히 사용하는 것이 좋을 것 같습니다.


References