콘텐츠로 이동

Rust 절차적 매크로: 나만의 #[derive] 만들기

#[derive(Debug)]나 #[derive(Serialize)]를 쓸 때마다, 컴파일러 안에서는 작은 프로그램이 하나 실행돼요. 이 프로그램은 여러분의 구조체를 살펴보고 impl 블록을 대신 써 줘요. 이 작은 프로그램이 바로 절차적 매크로(procedural macro)이고, 여러분도 직접 만들 수 있어요.

친절한 사무원을 떠올려 보세요. 서류(구조체)를 건네면 사무원이 항목(필드)을 하나하나 읽고, 귀찮은 서류 작업을 다 채워서 돌려줘요. 매크로 페이지에서 만난 macro_rules!는 패턴을 맞춰 보는 방식으로 동작했죠. 절차적 매크로는 달라요. 코드를 받아서 코드를 돌려주는 평범한 Rust 함수예요. 이 페이지에서는 진짜 절차적 매크로를 한 단계씩 만들고, 실제 실행 결과도 함께 볼 거예요. 처음엔 낯설어도 괜찮아요. 하나씩 따라오면 생각보다 단순하다는 걸 알게 될 거예요.

절차적 매크로는 세 가지가 있어요. 안에서 동작하는 방식은 모두 같지만, 쓰는 사람 입장에서는 모양이 달라요.

종류 사용하는 쪽에서 쓰는 코드 유명한 예
derive 매크로 구조체나 열거형 위에 #[derive(Serialize)] serde의 Serialize, thiserror의 Error, clap의 Parser
속성형 매크로 함수나 다른 아이템 위에 #[route(GET, "/")] #[tokio::main], actix-web의 #[get("/")]
함수형 매크로 sql!(SELECT * FROM users) sqlx의 query!, yew의 html!
  • derive 매크로는 타입 옆에 코드를 추가하기만 해요. 구조체는 여러분이 쓴 그대로 남아요.
  • 속성형 매크로(attribute macro)는 자기가 붙은 아이템을 통째로 바꿔요. #[tokio::main]은 여러분의 async fn main을 받아서, tokio 런타임을 시작하는 평범한 fn main으로 바꿔 줘요.
  • 함수형 매크로(function-like macro)는 macro_rules! 호출처럼 생겼지만, 입력이 무슨 뜻인지는 Rust 함수가 정해요. 그래서 sqlx::query!가 컴파일할 때 SQL을 검사할 수 있는 거예요.

사실 여러분은 이미 다른 크레이트의 derive 매크로를 쓰고 있어요. 아래에서 두 개가 실제로 일하는 모습을 볼 수 있어요. Serialize는 JSON 코드를 써 주고(Serde로 JSON 다루기 참고), Error는 #[error(...)]에 적은 문구로 Display 코드를 써 줘요(anyhow와 thiserror 참고).

use serde::Serialize;
use thiserror::Error;
#[derive(Serialize)]
struct Order {
id: u32,
item: String,
quantity: u8,
}
#[derive(Debug, Error)]
enum ShopError {
#[error("{0} 상품은 품절이에요")]
OutOfStock(String),
}
fn main() {
let order = Order { id: 42, item: "찻주전자".to_string(), quantity: 2 };
println!("{}", serde_json::to_string(&order).unwrap());
let err = ShopError::OutOfStock("찻주전자".to_string());
println!("{err}");
}

실행 결과:

{"id":42,"item":"찻주전자","quantity":2}
찻주전자 상품은 품절이에요
▶ 직접 실행해 보기

Display 구현도, JSON 코드도 여러분은 한 줄도 쓰지 않았어요. 프로그램이 컴파일되는 동안 derive가 대신 써 준 거예요.

동작 원리: 토큰이 들어가고 토큰이 나와요

섹션 제목: “동작 원리: 토큰이 들어가고 토큰이 나와요”

컴파일러는 먼저 소스 코드를 토큰(token)으로 잘라요. 이름, 기호, 리터럴, 괄호 같은 것들이에요. 절차적 매크로는 이 토큰들을 TokenStream으로 받아서 새로운 TokenStream을 돌려주는 함수예요. 그러면 컴파일러는 돌려받은 코드를, 마치 여러분이 직접 타이핑한 것처럼 컴파일해요.

세 가지 함수 모양은 이래요. 컴파일러가 어떤 종류인지 알 수 있도록 각각 속성을 붙여요.

use proc_macro::TokenStream;
// #[derive(HelloMacro)]: 구조체나 열거형을 받아서, 추가할 코드를 돌려줘요.
#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream { /* ... */ }
// #[route(GET, "/")]: 속성 자체의 토큰과 아이템을 받아서, 아이템을 대신할 코드를 돌려줘요.
#[proc_macro_attribute]
pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream { /* ... */ }
// sql!(...): 괄호 안의 모든 것을 받아서, 그 자리를 대신할 코드를 돌려줘요.
#[proc_macro]
pub fn sql(input: TokenStream) -> TokenStream { /* ... */ }

날것의 TokenStream은 그냥 토큰 목록이라서 다루기가 힘들어요. 그래서 거의 모든 절차적 매크로가 도우미 크레이트 세 개를 써요.

  • syn은 토큰을 구문 트리(syntax tree)로 파싱해요. 토큰 대신 타입의 이름, 제네릭, 필드가 담긴 DeriveInput을 받을 수 있어요.
  • quote는 Rust처럼 생긴 코드를 다시 토큰으로 바꿔요. quote! { ... } 안에는 평범한 Rust 코드를 쓰고, #name이라고 쓰면 변수 값이 그 자리에 들어가요.
  • proc-macro2는 컴파일러의 proc_macro 타입을 감싼 크레이트로, syn과 quote가 이걸 써요. 컴파일러 밖에서도 동작해서 도우미 함수를 만들거나 테스트하기가 쉬워져요.

중요한 점이 하나 있어요. 매크로는 컴파일 시간에 실행돼요. Cargo는 매크로 크레이트를 먼저 여러분의 컴퓨터용으로 컴파일하고, 그걸 쓰는 크레이트를 빌드하는 동안 컴파일러가 매크로를 실행해요. 프로그램이 마침내 실행될 때 매크로는 이미 사라지고 없어요. 매크로가 써 준 코드만 남아 있죠.

절차적 매크로는 특별한 크레이트 타입을 가진 별도의 크레이트에 있어야 해요. 그 크레이트는 매크로만 내보낼 수 있고, 일반 함수나 트레이트는 내보낼 수 없어요. 그래서 serde와 serde_derive처럼 크레이트 두 개를 나란히 두는 게 보통이에요. 하나는 트레이트용, 하나는 derive용이에요. 여기에 세 번째 크레이트로, 이 둘을 사용하는 shop이라는 작은 프로그램을 추가할게요. (크레이트와 워크스페이스가 가물가물하다면 Cargo와 크레이트를 보세요.)

Terminal window
mkdir procmacro-demo && cd procmacro-demo
cargo new --lib hello_macro
cargo new --lib hello_macro_derive
cargo new shop

완성된 파일 구조는 이래요.

procmacro-demo/
├── Cargo.toml (워크스페이스)
├── hello_macro/
│ ├── Cargo.toml
│ └── src/lib.rs (트레이트, 그리고 derive 다시 내보내기)
├── hello_macro_derive/
│ ├── Cargo.toml (proc-macro = true)
│ └── src/lib.rs (매크로 본체)
└── shop/
├── Cargo.toml
└── src/main.rs (#[derive(...)]를 쓰는 프로그램)

맨 위의 Cargo.toml이 세 크레이트를 하나의 워크스페이스로 묶어요.

[workspace]
members = ["hello_macro", "hello_macro_derive", "shop"]
resolver = "2"

특별한 설정이 있는 건 매크로 크레이트뿐이에요. 바로 proc-macro = true예요. 그리고 syn, quote, proc-macro2에 의존해요.

hello_macro_derive/Cargo.toml
[package]
name = "hello_macro_derive"
version = "0.1.0"
edition = "2024"
[lib]
proc-macro = true
[dependencies]
proc-macro2 = "1"
quote = "1"
syn = "2"

나머지 두 크레이트는 평범해요. hello_macro는 derive 크레이트에 의존하고, shop은 hello_macro만 있으면 돼요.

hello_macro/Cargo.toml
[dependencies]
hello_macro_derive = { version = "0.1.0", path = "../hello_macro_derive" }
shop/Cargo.toml
[dependencies]
hello_macro = { version = "0.1.0", path = "../hello_macro" }

아주 작게 시작해 볼게요. #[derive(HelloMacro)]를 붙인 타입이라면 무엇이든, 타입 이름을 출력하는 hello_macro() 함수를 갖게 하고 싶어요. 먼저 hello_macro/src/lib.rs에 트레이트를 써요. 마지막 줄은 derive 매크로를 다시 내보내요(re-export). 그래서 사용하는 쪽은 이 크레이트 하나만 있으면 돼요. serde가 하는 방식과 똑같아요.

hello_macro/src/lib.rs
pub trait HelloMacro {
fn hello_macro();
}
pub trait Describe {
fn type_name() -> &'static str;
fn field_names() -> &'static [&'static str];
}
// derive 매크로를 다시 내보내서, 사용하는 쪽은 이 크레이트 하나만 있으면 돼요.
pub use hello_macro_derive::{Describe, HelloMacro};

(Describe는 2단계에서 써요.) 이제 hello_macro_derive/src/lib.rs에 매크로 본체를 써요.

hello_macro_derive/src/lib.rs
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, Data, DeriveInput, Fields}; // Data와 Fields는 2단계에서 써요
#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
// 1. 토큰을 살펴볼 수 있는 구문 트리로 파싱해요.
let ast = parse_macro_input!(input as DeriveInput);
let name = &ast.ident;
// 2. quote!로 새 코드를 만들어요.
let generated = quote! {
impl hello_macro::HelloMacro for #name {
fn hello_macro() {
println!("Hello, Macro! My name is {}!", stringify!(#name));
}
}
};
// 3. 새 토큰을 컴파일러에게 돌려줘요.
generated.into()
}

세 단계예요. 거의 모든 derive 매크로에서 이 세 단계를 보게 될 거예요.

  1. parse_macro_input!이 토큰을 DeriveInput으로 바꿔요. ast.ident는 Product 같은 타입 이름이에요. 파싱에 실패하면 이 매크로가 알아서 제대로 된 컴파일 에러를 돌려줘요.
  2. quote!가 impl 블록을 만들어요. #name 자리에 이름이 들어가요. stringify!(#name)은 이름을 "Product"라는 텍스트로 바꿔요.
  3. .into()는 quote가 만든 proc_macro2::TokenStream을 컴파일러의 proc_macro::TokenStream으로 바꿔요.

hello_macro::HelloMacro처럼 전체 경로를 쓴 걸 눈여겨보세요. 생성된 코드는 사용하는 쪽의 파일에 붙여 넣어지기 때문에, 그 파일에 어떤 use 줄이 있는지 기대할 수 없어요.

누군가 #[derive(HelloMacro)] struct Product라고 쓰면, 컴파일러는 아래처럼 손으로 쓴 버전과 똑같이 동작하는 코드를 추가해요. 이 버전은 매크로 크레이트가 없으니 Playground에서도 실행돼요.

trait HelloMacro {
fn hello_macro();
}
struct Product;
// #[derive(HelloMacro)]가 대신 써 주는 코드가 바로 이거예요.
impl HelloMacro for Product {
fn hello_macro() {
println!("Hello, Macro! My name is {}!", "Product");
}
}
fn main() {
Product::hello_macro();
}

실행 결과:

Hello, Macro! My name is Product!
▶ 직접 실행해 보기

(출력 문구는 일부러 영어로 두었어요. 아래에서 실제 매크로가 출력하는 결과와 똑같이 맞추기 위해서예요.)

한 번쯤 손으로 쓰는 건 괜찮아요. 하지만 타입 40개에 똑같이 써야 한다면, 그때부터 derive가 제값을 하기 시작해요.

2단계: 필드를 읽는 #[derive(Describe)]

섹션 제목: “2단계: 필드를 읽는 #[derive(Describe)]”

HelloMacro는 타입 이름만 썼어요. serde 같은 진짜 derive는 필드를 살펴봐요. 그래서 이번엔 Describe를 만들어 볼게요. 구조체의 필드 이름을 나열해 주는데, CSV 헤더를 만들 때 편리해요. syn은 필드를 ast.data로 알려 줘요.

  • Data::Struct, Data::Enum, Data::Union: 어떤 종류의 타입인지 알려 줘요.
  • 구조체라면 Fields::Named(struct Product { id: u32 }), Fields::Unnamed(struct Meters(f64)), Fields::Unit(struct Marker;) 중 하나예요.

hello_macro_derive/src/lib.rs의 첫 번째 매크로 아래에 이 코드를 추가해요.

#[proc_macro_derive(Describe)]
pub fn describe_derive(input: TokenStream) -> TokenStream {
let ast = parse_macro_input!(input as DeriveInput);
expand_describe(&ast)
.unwrap_or_else(|err| err.to_compile_error())
.into()
}
fn expand_describe(ast: &DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
let name = &ast.ident;
let Data::Struct(data) = &ast.data else {
return Err(syn::Error::new_spanned(
name,
"Describe can only be derived for structs",
));
};
let field_names: Vec<String> = match &data.fields {
Fields::Named(fields) => fields
.named
.iter()
.map(|field| field.ident.as_ref().unwrap().to_string())
.collect(),
Fields::Unit => Vec::new(),
Fields::Unnamed(_) => {
return Err(syn::Error::new_spanned(
name,
"Describe needs named fields, like `struct Point { x: i32 }`",
));
}
};
Ok(quote! {
impl hello_macro::Describe for #name {
fn type_name() -> &'static str {
stringify!(#name)
}
fn field_names() -> &'static [&'static str] {
&[#(#field_names),*]
}
}
})
}

새로 나온 부분은 이래요.

  • 실제 일은 syn::Result를 돌려주는 평범한 도우미 함수에서 해요. 덕분에 다른 에러 처리 코드처럼 ?나 return Err(...)를 쓸 수 있어요. 맨 위의 매크로 함수는 에러를 컴파일 에러로 바꾸기만 해요.
  • let ... else가 “구조체가 아닌 경우”를 바로 처리해서, 정상적인 흐름이 들여쓰기 없이 평평하게 유지돼요.
  • #(#field_names),*는 quote의 반복(repetition)이에요. Vec에 든 문자열을 쉼표로 구분해서 하나씩 모두 붙여 넣어요. macro_rules!의 $(...),*와 같은 아이디어예요. 각 String은 문자열 리터럴이 되므로, 결과는 &["id", "name", "price_cents"]가 돼요.
  • 여기서 field.ident.as_ref().unwrap()은 안전해요. Fields::Named에서는 모든 필드에 이름이 있거든요.

이제 shop/src/main.rs에서 두 derive를 모두 써 볼게요. use hello_macro::{Describe, HelloMacro} 한 줄이 트레이트와 derive 매크로를 함께 가져온다는 점을 눈여겨보세요. 이름은 같지만 서로 다른 네임스페이스에 살고 있어요. serde::Serialize도 정확히 이렇게 동작해요.

shop/src/main.rs
use hello_macro::{Describe, HelloMacro};
#[derive(HelloMacro, Describe)]
struct Product {
id: u32,
name: String,
price_cents: u64,
}
#[derive(HelloMacro, Describe)]
struct Customer {
email: String,
city: String,
}
fn main() {
Product::hello_macro();
Customer::hello_macro();
println!("{} has fields {:?}", Product::type_name(), Product::field_names());
// CSV 파일: 헤더는 derive가 만들고, 행은 실제 값으로 채워요.
let ana = Customer { email: "[email protected]".to_string(), city: "Lisbon".to_string() };
println!("{}", Customer::field_names().join(","));
println!("{},{}", ana.email, ana.city);
let teapot = Product { id: 7, name: "Teapot".to_string(), price_cents: 1999 };
println!("{} #{}: {} costs {} cents", Product::type_name(), teapot.id, teapot.name, teapot.price_cents);
}

워크스페이스 폴더에서 실행해요.

Terminal window
cargo run -p shop

실행 결과(처음부터 빌드할 때):

Compiling proc-macro2 v1.0.107
Compiling unicode-ident v1.0.27
Compiling quote v1.0.47
Compiling syn v2.0.119
Compiling hello_macro_derive v0.1.0 (/home/you/procmacro-demo/hello_macro_derive)
Compiling hello_macro v0.1.0 (/home/you/procmacro-demo/hello_macro)
Compiling shop v0.1.0 (/home/you/procmacro-demo/shop)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.45s
Running `target/debug/shop`
Hello, Macro! My name is Product!
Hello, Macro! My name is Customer!
Product has fields ["id", "name", "price_cents"]
email,city
Product #7: Teapot costs 1999 cents

빌드 순서를 보세요. Cargo는 shop보다 먼저 syn과 hello_macro_derive를 빌드해요. 컴파일러가 shop을 컴파일하는 동안 여러분의 매크로를 실행해야 하기 때문이에요. Customer에 필드를 하나 추가하면, 다음 빌드 때 CSV 헤더도 저절로 바뀌어요.

누군가 열거형에 #[derive(Describe)]를 붙이면 어떻게 될까요? expand_describe가 syn::Error를 돌려주기 때문에, 사용하는 쪽에서는 평범한 컴파일러 에러처럼 보이는 메시지를 받아요. (이 페이지의 잘못된 예제들은 shop/src/bin/broken.rs에 넣어 두었어요. 그래서 에러에 그 경로가 나와요.)

use hello_macro::Describe;
#[derive(Describe)]
enum OrderStatus {
Pending,
Shipped,
}
error: Describe can only be derived for structs
--> shop/src/bin/broken.rs:4:6
|
4 | enum OrderStatus {
| ^^^^^^^^^^^

메시지는 우리가 매크로에 쓴 문구 그대로예요(“Describe는 구조체에만 derive할 수 있어요”). 이 에러가 좋은 이유는 두 가지예요.

  • syn::Error::new_spanned(name, ...): 이름이 사용자 파일의 어디에 있는지(이걸 스팬(span)이라고 해요) 기억해요. 그래서 ^^^^^가 매크로 안의 어떤 줄이 아니라 OrderStatus를 가리키는 거예요.
  • to_compile_error(): 에러를 compile_error!("...") 호출로 바꿔요. 그러면 컴파일러가 다른 에러와 똑같이 보고해요. 같은 compile_error! 매크로는 평범한 코드에서도 쓸 수 있어요.

튜플 구조체에는 다른 메시지(“Describe에는 이름 있는 필드가 필요해요”)가 나오고, 이것도 정확한 위치를 가리켜요.

error: Describe needs named fields, like `struct Point { x: i32 }`
--> shop/src/bin/broken.rs:4:8
|
4 | struct Meters(f64);
| ^^^^^^

에러 메시지는 매크로를 사용하는 사람을 위해 쓰세요. 무엇이 잘못됐는지, 대신 어떻게 하면 되는지 알려 주세요.

매크로는 마법처럼 느껴질 수 있고, 마법은 디버깅하기 어려워요. cargo-expand를 쓰면 모든 매크로가 실행된 뒤의 코드를 볼 수 있어요. 한 번만 설치하면 돼요(다른 유용한 도구는 Rust 도구와 개발 환경을 보세요).

Terminal window
cargo install cargo-expand
cargo expand -p shop

우리 프로젝트에서 출력되는 내용 중 일부예요.

impl hello_macro::Describe for Product {
fn type_name() -> &'static str {
"Product"
}
fn field_names() -> &'static [&'static str] {
&["id", "name", "price_cents"]
}
}
impl hello_macro::Describe for Customer {
fn type_name() -> &'static str {
"Customer"
}
fn field_names() -> &'static [&'static str] {
&["email", "city"]
}
}

필드 이름이 채워진 impl 블록이 보이죠. stringify!(#name)도 이미 "Product"로 바뀌어 있어요. derive가 예상대로 동작하지 않으면 제일 먼저 cargo expand를 실행해 보세요. println! 같은 다른 매크로도 함께 펼쳐지니까, 여러분이 만든 impl 블록을 찾아서 보면 돼요.

매크로 크레이트는 매크로만 내보낼 수 있어서, 스스로를 테스트하기가 쉽지 않아요. 간단한 해결책은 매크로를 사용하는 크레이트에 평범한 테스트를 쓰는 거예요. 이 코드를 hello_macro/tests/describe.rs에 넣고 cargo test -p hello_macro를 실행해요.

use hello_macro::Describe;
#[allow(dead_code)]
#[derive(Describe)]
struct Product {
id: u32,
name: String,
}
#[test]
fn lists_field_names_in_order() {
assert_eq!(Product::type_name(), "Product");
assert_eq!(Product::field_names(), ["id", "name"]);
}
running 1 test
test lists_field_names_in_order ... ok

그런데 열거형에 붙였을 때 좋은 에러가 나오는지는 어떻게 테스트할까요? 평범한 테스트로는 할 수 없어요. 테스트 파일 자체가 컴파일되지 않을 테니까요. 이럴 때 쓰는 게 trybuild 크레이트예요. “실패해야 하는” 작은 파일들을 폴더에 넣어 두면, trybuild가 하나씩 컴파일해서 컴파일러 에러를 저장해 둔 .stderr 파일과 비교해 줘요.

#[test]
fn ui() {
let t = trybuild::TestCases::new();
t.compile_fail("tests/ui/*.rs");
}

serde와 thiserror를 비롯한 인기 매크로 크레이트 대부분이 이런 방식으로 에러 메시지를 테스트해요.

절차적 매크로는 강력하지만, 그만큼 대가가 있어요. 만들기 전에 스스로에게 솔직하게 물어보세요.

  • 컴파일 시간. syn은 큰 크레이트예요. 여러분의 매크로를 쓰는 모든 프로젝트는 처음 빌드할 때 syn을 컴파일해야 해요. (derive에는 syn의 기본 기능(feature)만으로 충분해요. 함수 본문 같은 걸 파싱할 때만 features = ["full"]이 필요한데, 그러면 더 느려져요.)
  • 복잡도. 크레이트가 하나 더 생기고, 코드를 쓰는 코드가 생기고, 에러를 따라가기도 더 어려워져요.
  • 도구 지원. 에디터와 rust-analyzer는 매크로가 만든 코드보다 평범한 코드를 더 잘 이해해요.

이런 순서로 시도해 보는 게 좋아요.

  1. 함수, 기본 메서드가 있는 트레이트, 또는 제네릭. “반복되는 코드” 문제는 대부분 여기서 해결돼요.
  2. macro_rules! 매크로(매크로 참고). 크레이트를 따로 만들 필요가 없어요.
  3. 절차적 매크로. 코드가 타입의 구조(이름, 필드, 배리언트)에 따라 달라지고, 많은 타입에 필요하고, 다른 사람들도 쓸 때예요. serde, thiserror, clap이 derive를 쓰는 이유가 바로 이거예요.

비교해 보자면, 아래는 Describe를 macro_rules!로 만든 버전이에요. 잘 동작하고 크레이트도 따로 필요 없지만, 구조체를 매크로 호출 안에 써야 해요. 문서 주석이나 pub 필드, 다른 derive도 붙이고 싶어지면 금방 불편해져요.

trait Describe {
fn type_name() -> &'static str;
fn field_names() -> &'static [&'static str];
}
macro_rules! describe_struct {
(struct $name:ident { $($field:ident : $ty:ty),* $(,)? }) => {
struct $name {
$($field: $ty),*
}
impl Describe for $name {
fn type_name() -> &'static str {
stringify!($name)
}
fn field_names() -> &'static [&'static str] {
&[$(stringify!($field)),*]
}
}
};
}
describe_struct!(struct Customer { email: String, city: String });
fn main() {
let ana = Customer { email: "[email protected]".to_string(), city: "Lisbon".to_string() };
println!("{} has fields {:?}", Customer::type_name(), Customer::field_names());
println!("{},{}", ana.email, ana.city);
}

실행 결과:

Customer has fields ["email", "city"]
▶ 직접 실행해 보기

내 프로그램 안에서 타입 한두 개에만 필요하다면, 이 방법(또는 그냥 impl을 손으로 쓰는 것)이 더 나은 선택일 때가 많아요. derive는 사용하는 사람에게 더 편해요. 평범한 구조체를 그대로 두고 한 줄만 추가하면 되니까요.

proc-macro = true를 빠뜨리기. 매크로 크레이트의 Cargo.toml에 [lib] 섹션이 없으면 그 크레이트는 평범한 라이브러리가 되고, 컴파일러가 거부해요.

error: the `#[proc_macro_derive]` attribute is only usable with crates of the `proc-macro` crate type
--> hello_macro_derive/src/lib.rs:5:1
|
5 | #[proc_macro_derive(HelloMacro)]
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
error[E0432]: unresolved import `proc_macro`

#[proc_macro_derive] 속성은 proc-macro 타입의 크레이트에서만 쓸 수 있다는 뜻이에요. 그리고 proc_macro 크레이트도 찾지 못해요(E0432).

사용자의 실수를 panic!이나 unwrap()으로 처리하기. “동작”은 하지만 에러가 모호하고, 문제가 있는 곳이 아니라 #[derive] 전체를 가리켜요.

error: proc-macro derive panicked
--> shop/src/bin/broken.rs:3:10
|
3 | #[derive(Describe)]
| ^^^^^^^^
|
= help: message: Describe can only be derived for structs

대신 2단계에서처럼 new_spanned로 syn::Error를 돌려주세요.

생성된 코드에 짧은 경로 쓰기. impl hello_macro::Describe for #name 대신 impl Describe for #name이라고 쓰면, 사용자가 마침 Describe를 가져왔을(import) 때만 동작해요. #[derive(hello_macro::Describe)]라고 쓴 사람은 이런 에러를 받아요.

error[E0405]: cannot find trait `Describe` in this scope
--> shop/src/bin/broken.rs:1:10
|
1 | #[derive(hello_macro::Describe)]
| ^^^^^^^^^^^^^^^^^^^^^ not found in this scope
|
= note: this error originates in the derive macro `hello_macro::Describe`

이 스코프에서 Describe 트레이트를 찾을 수 없다는 에러이고, 마지막 줄은 그 코드가 derive 매크로에서 나왔다고 알려 줘요. quote! 안에서는 언제나 전체 경로를 쓰세요. hello_macro::Describe, ::std::fmt::Display, ::core::option::Option처럼요.

이 연습 문제들은 실제 프로젝트를 고치는 거라서 Try it 버튼이 없어요. 이 페이지의 프로젝트를 여러분의 컴퓨터에 만들고 cargo run -p shop으로 실행해 보세요.

Describe 트레이트와 derive에 세 번째 메서드 fn field_count() -> usize를 추가하세요. Product::field_count()는 3을 돌려줘야 해요. 힌트: field_names 벡터는 이미 있어요. quote! 전에 field_names.len()을 변수에 저장하고, #으로 붙여 넣으세요.

정답 보기

hello_macro/src/lib.rs에서 트레이트에 메서드를 추가해요.

pub trait Describe {
fn type_name() -> &'static str;
fn field_names() -> &'static [&'static str];
fn field_count() -> usize;
}

expand_describe에서 필드 개수를 세고, 생성되는 impl에 메서드를 추가해요.

let count = field_names.len();
Ok(quote! {
impl hello_macro::Describe for #name {
fn type_name() -> &'static str {
stringify!(#name)
}
fn field_names() -> &'static [&'static str] {
&[#(#field_names),*]
}
fn field_count() -> usize {
#count
}
}
})

그다음 main에서 println!("{} has {} fields", Product::type_name(), Product::field_count());를 실행하면 이렇게 출력돼요.

Product has 3 fields

개수는 매크로 안에서, 컴파일 시간에 세요. cargo expand로 보면 결과는 그냥 fn field_count() -> usize { 3usize }예요. 실행 시간에 할 일이 하나도 남지 않은 상수죠.

struct Page<T> { items: Vec<T>, number: u32 } 같은 제네릭 구조체에 #[derive(Describe)]를 붙여 보세요. error[E0107]: missing generics for struct Page라는 에러가 나요. 생성된 코드가 impl hello_macro::Describe for Page라고만 쓰고 <T>를 빠뜨렸기 때문이에요. 제네릭 구조체에서도 동작하도록 derive를 고쳐 보세요. 힌트: ast.generics.split_for_impl()을 쓰면 impl 줄에 붙여 넣을 조각 세 개를 얻을 수 있어요. impl<T>가 어떻게 생겼는지 기억이 안 나면 제네릭을 보세요.

정답 보기

expand_describe에서 제네릭을 나누고, 세 조각을 모두 impl 줄에 써요.

let (impl_generics, ty_generics, where_clause) = ast.generics.split_for_impl();
Ok(quote! {
impl #impl_generics hello_macro::Describe for #name #ty_generics #where_clause {
fn type_name() -> &'static str {
stringify!(#name)
}
fn field_names() -> &'static [&'static str] {
&[#(#field_names),*]
}
}
})

이제 shop에서 이 코드가 동작해요.

#[derive(Describe)]
struct Page<T> {
items: Vec<T>,
number: u32,
}
fn main() {
let page = Page { items: vec!["a", "b"], number: 1 };
println!("{} {} {:?}", page.items.len(), page.number, Page::<String>::field_names());
}
2 1 ["items", "number"]

impl_generics는 (제약이 있다면 그것까지 포함한) <T>, ty_generics는 <T>, where_clause는 where ... 부분이에요(있을 때만). 제대로 만든 derive는 모두 이렇게 하니까, split_for_impl은 꼭 기억해 두세요.

  • 절차적 매크로는 컴파일 시간에 실행되는 Rust 함수예요. TokenStream이 들어가고 TokenStream이 나와요.
  • 종류는 세 가지예요. derive(#[derive(Serialize)]), 속성형(#[tokio::main]), 함수형(sql!(...)).
  • 절차적 매크로는 proc-macro = true를 설정한 별도의 크레이트에 있어요. 보통은 메인 크레이트가 트레이트와 함께 다시 내보내요.
  • syn은 입력을 파싱하고(DeriveInput, Data, Fields), quote는 출력을 써요(#name, #(...),*).
  • 사용자의 실수는 syn::Error::new_spanned(...).to_compile_error()로 알려 주고, 결과는 cargo expand로 확인하고, 에러는 trybuild로 테스트해요.
  • 먼저 함수, 트레이트, macro_rules!를 시도해 보세요. 많은 타입에 구조에 따른 코드가 필요할 때 절차적 매크로가 제값을 해요.

이해도 점검

1. 절차적 매크로의 코드는 언제 실행될까요?

2. #[tokio::main]은 어떤 종류의 절차적 매크로일까요?

3. syn과 quote는 각각 무슨 일을 할까요?

4. 여러분의 derive가 열거형을 거부해야 해요. 가장 좋은 방법은 무엇일까요?

점수: 0 / 4

  • 매크로 — The Rust Programming Language 한국어판: HelloMacro derive를 처음부터 만들어 보는 공식 안내예요. 속성형 매크로와 함수형 매크로도 소개해요.
  • Procedural Macros — The Rust Reference (영어): 세 가지 종류의 정확한 규칙, 크레이트 타입, 스팬, 위생성(hygiene)을 설명해요.
  • syn 문서 (영어): 이 페이지에서 쓴 DeriveInput, Data, Fields, Error와 파싱 도우미들이 있어요.
  • quote 문서 (영어): quote! 안에서 #var 끼워 넣기와 #(...),* 반복이 어떻게 동작하는지 알려 줘요.
  • proc-macro-workshop (영어): David Tolnay가 만든 무료 실습 프로젝트예요(builder derive, debug derive 등). 테스트가 한 단계씩 길을 안내해 줘요.