- 들어가며 — 매크로는 "코드를 쓰는 코드"
- 두 종류의 매크로 개관
- 선언적 매크로 — macro_rules!
- 절차적 매크로 — 코드를 데이터로 다루다
- serde의 #[derive(Serialize)]는 무엇을 하는가
- 선언적 vs 절차적 — 언제 무엇을
- 매크로를 쓰지 말아야 할 때
- 마치며
- 참고 자료
들어가며 — 매크로는 "코드를 쓰는 코드"
Rust를 조금 쓰다 보면 이런 것들을 매일 만나게 됩니다. 출력을 위한 println!, 벡터를 만드는 vec!, 그리고 무엇보다 구조체 위에 붙는 #[derive(Debug)]. 이들은 함수가 아니라 매크로입니다. 이름 뒤의 느낌표나 #[derive(...)] 문법이 그 신호입니다.
매크로가 함수와 근본적으로 다른 점은, 값을 다루는 것이 아니라 코드 자체를 다룬다는 것입니다. 매크로는 컴파일 시점에 실행되어, 여러분이 적게 쓴 코드를 더 많은 코드로 펼쳐 놓습니다. 이것을 메타프로그래밍이라고 부릅니다. 그리고 Rust에는 성격이 뚜렷이 다른 두 종류의 매크로가 있습니다. 이 글은 그 둘을 나눠서 이해하는 것을 목표로 합니다.
문법을 직접 실행해 보며 익히고 싶다면, 글을 읽으면서 이 사이트의 Rust 학습 실습실에서 예제를 굴려 보세요.
두 종류의 매크로 개관
Rust의 매크로는 크게 둘로 나뉩니다.
- 선언적 매크로(declarative macro) —
macro_rules!로 정의합니다. 입력 코드의 패턴을 매칭하고, 매칭된 조각으로 미리 정해 둔 코드를 찍어냅니다. 정규식이 문자열의 패턴을 다루듯, 선언적 매크로는 코드 조각의 패턴을 다룹니다. - 절차적 매크로(procedural macro) — 입력 코드를 **토큰 스트림(TokenStream)**으로 받아, 임의의 Rust 코드로 그것을 분석하고 새 토큰 스트림을 만들어 냅니다. 다시 세 갈래로 나뉩니다.
#[derive(...)]가 호출하는 derive 매크로,#[route(...)]같은 어트리뷰트 매크로(attribute macro), 그리고sql!(...)처럼 함수처럼 호출되는 **함수형 매크로(function-like macro)**입니다.
한 줄로 요약하면, 선언적 매크로는 "패턴을 보고 정해진 코드를 찍는" 도구이고, 절차적 매크로는 "코드를 프로그램으로 읽고 프로그램으로 코드를 쓰는" 도구입니다. 먼저 쉬운 쪽부터 봅니다.
선언적 매크로 — macro_rules!
가장 익숙한 매크로 중 하나인 vec!를 직접 흉내 내 보면 선언적 매크로의 감이 옵니다. 아래는 원소를 나열해 벡터를 만드는 단순화된 버전입니다.
macro_rules! my_vec {
// 콤마로 구분된 임의 개수의 표현식을 받는다
( $( $x:expr ),* ) => {
{
let mut temp = Vec::new();
$(
temp.push($x);
)*
temp
}
};
}
fn main() {
let v = my_vec![1, 2, 3];
println!("{:?}", v); // [1, 2, 3]
}
이 짧은 코드에 선언적 매크로의 핵심이 모두 들어 있습니다. 화살표 왼쪽은 매칭할 패턴이고, 오른쪽은 펼쳐 낼 코드입니다.
패턴에 쓰인 기호들을 풀어 보면 이렇습니다. 달러 기호를 앞세운 매크로 변수(위 예의 x)는 매칭된 코드 조각을 담습니다. 뒤에 붙은 expr는 프래그먼트 지정자로, "여기에는 하나의 표현식이 온다"는 뜻입니다. 표현식 외에도 ident(식별자), ty(타입), stmt(문장), block(블록), tt(토큰 트리) 등 여러 종류가 있습니다. 그리고 패턴을 감싼 반복 표기는 "이 부분이 0번 이상 반복된다"는 뜻입니다. 반복 안의 콤마는 구분자이고, 별표는 "0회 이상"을 뜻합니다. 별표 대신 더하기를 쓰면 "1회 이상"이 됩니다.
펼치는 쪽에서도 같은 반복 표기를 씁니다. 매칭 때 여러 개를 잡은 그 매크로 변수를, 펼칠 때 다시 반복하며 각각에 대해 push 호출을 한 줄씩 찍어냅니다. 즉 my_vec![1, 2, 3]은 push(1); push(2); push(3);로 확장됩니다.
여러 규칙을 나열할 수도 있습니다. 매크로는 위에서부터 첫 번째로 맞는 규칙을 고릅니다.
macro_rules! greet {
// 인자가 없을 때
() => {
println!("안녕하세요!");
};
// 이름 하나를 받을 때
($name:expr) => {
println!("안녕하세요, {}님!", $name);
};
}
fn main() {
greet!(); // 안녕하세요!
greet!("루스트"); // 안녕하세요, 루스트님!
}
위생성 — 왜 매크로가 변수를 오염시키지 않는가
C의 텍스트 치환 매크로를 써 본 사람은 매크로가 바깥 변수를 조용히 망가뜨리는 악몽을 압니다. Rust의 선언적 매크로는 **위생적(hygienic)**입니다. 매크로 안에서 만든 변수는 매크로 바깥의 같은 이름 변수와 충돌하지 않습니다.
macro_rules! make_temp {
() => {
let temp = 42;
};
}
fn main() {
let temp = 1;
make_temp!(); // 매크로 안의 temp는 독립된 별개의 변수
println!("{}", temp); // 1 (오염되지 않음)
}
매크로가 내부적으로 temp라는 이름을 쓰더라도, 그 이름은 매크로의 문맥에 속해 바깥의 temp와 섞이지 않습니다. 위생성 덕분에 선언적 매크로는 이름 충돌 걱정 없이 안전하게 쓸 수 있습니다. 이것은 단순한 텍스트 치환과 결정적으로 다른 지점입니다.
절차적 매크로 — 코드를 데이터로 다루다
선언적 매크로가 "패턴 매칭"이라면, 절차적 매크로는 본격적인 "코드 변환 프로그램"입니다. 절차적 매크로는 입력 코드를 토큰 스트림으로 받습니다. 토큰 스트림은 소스 코드를 잘게 쪼갠 토큰들의 흐름입니다. 예를 들어 let x = 1;은 대략 let, x, =, 1, ;이라는 토큰들의 나열입니다.
절차적 매크로 함수의 시그니처는 이런 모양입니다. 토큰 스트림을 받아 토큰 스트림을 돌려줍니다.
use proc_macro::TokenStream;
#[proc_macro_derive(MyTrait)]
pub fn my_derive(input: TokenStream) -> TokenStream {
// input: 매크로가 붙은 구조체/열거형의 토큰들
// 반환값: 생성해 낼 새 코드의 토큰들
// ...
}
문제는, 토큰 스트림을 손으로 파싱하고 조립하는 일이 몹시 번거롭다는 것입니다. 그래서 생태계는 사실상 두 개의 크레이트에 의존합니다.
- syn — 토큰 스트림을 파싱해 다루기 쉬운 **구문 트리(syntax tree)**로 바꿔 줍니다. 구조체 이름, 필드 목록, 타입 같은 정보를 편하게 꺼낼 수 있습니다.
- quote — 반대로, 우리가 만들고 싶은 코드를 거의 그대로 써 내려가면 그것을 토큰 스트림으로 바꿔 줍니다.
quote!매크로 안에 목표 코드를 적고, 변수 자리에는 값을 끼워 넣습니다.
이 둘을 쓰면 절차적 매크로의 전형적인 흐름은 세 단계가 됩니다.
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput};
#[proc_macro_derive(HelloName)]
pub fn hello_name(input: TokenStream) -> TokenStream {
// 1) syn으로 입력을 파싱한다
let ast = parse_macro_input!(input as DeriveInput);
let name = ast.ident; // 구조체(또는 열거형)의 이름
// 2) quote로 생성할 코드를 조립한다
let expanded = quote! {
impl #name {
fn hello() {
println!("안녕, 나는 {}!", stringify!(#name));
}
}
};
// 3) 토큰 스트림으로 되돌려 준다
expanded.into()
}
여기서 quote! 안의 #name은 우리가 파싱해서 얻은 구조체 이름을 그 자리에 끼워 넣는 문법입니다. 사용하는 쪽에서 이 매크로를 이렇게 붙이면:
#[derive(HelloName)]
struct Robot;
fn main() {
Robot::hello(); // 안녕, 나는 Robot!
}
컴파일러는 Robot이라는 이름으로 hello 메서드를 자동으로 생성해 줍니다. 우리가 손으로 impl 블록을 쓰지 않았는데도 말입니다. 참고로 절차적 매크로는 별도의 크레이트로 분리해 정의해야 하고, Cargo.toml에서 proc-macro = true로 표시해야 합니다.
serde의 #[derive(Serialize)]는 무엇을 하는가
이 원리의 가장 유명하고 강력한 실사용 예가 serde입니다. Rust에서 데이터를 JSON 같은 형식으로 직렬화할 때, 우리는 보통 이렇게 씁니다.
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize)]
struct User {
name: String,
age: u32,
active: bool,
}
fn main() {
let user = User {
name: "아영".to_string(),
age: 30,
active: true,
};
// serde_json으로 직렬화
let json = serde_json::to_string(&user).unwrap();
println!("{}", json);
// {"name":"아영","age":30,"active":true}
}
여기서 #[derive(Serialize)]가 하는 일은 정확히 앞 절에서 본 절차적 derive 매크로입니다. 컴파일 시점에 serde의 derive 매크로가 User 구조체의 정의를 토큰 스트림으로 받아, 각 필드(name, age, active)를 순회하며 그것을 어떻게 직렬화할지 아는 Serialize 트레잇 구현을 통째로 생성합니다.
핵심은, 만약 이 코드가 없다면 여러분이 필드마다 손으로 써야 했을 지루하고 오류가 잦은 직렬화 코드를, 매크로가 필드 정의를 읽고 정확히 찍어낸다는 것입니다. 필드를 하나 추가하면, 다음 컴파일 때 매크로가 그 필드까지 처리하는 코드를 다시 생성합니다. 직접 유지보수할 코드가 없는 것입니다. 이것이 절차적 매크로가 주는 힘이자, "코드를 데이터로 읽어 코드를 쓰는" 메타프로그래밍의 실질적 가치입니다.
serde는 여기에 어트리뷰트 매크로의 맛도 섞습니다. 필드 위에 붙이는 힌트로 직렬화 동작을 조정할 수 있습니다.
use serde::Serialize;
#[derive(Serialize)]
struct Config {
// JSON에서는 이 필드 이름을 다르게 쓴다
#[serde(rename = "maxRetries")]
max_retries: u32,
// 값이 없으면 아예 출력에서 뺀다
#[serde(skip_serializing_if = "Option::is_none")]
nickname: Option<String>,
}
이런 #[serde(...)] 어트리뷰트는 derive 매크로가 코드를 생성할 때 참고하는 지시문입니다. 매크로가 필드를 순회하면서 이 힌트를 읽고, 그에 맞게 생성 코드를 바꿉니다.
선언적 vs 절차적 — 언제 무엇을
두 매크로의 성격을 표로 정리하면 선택이 분명해집니다.
| 구분 | 선언적 매크로 | 절차적 매크로 |
|---|---|---|
| 정의 방법 | macro_rules! | 전용 크레이트 + proc_macro |
| 동작 원리 | 패턴 매칭 후 코드 치환 | 토큰 스트림을 프로그램으로 변환 |
| 표현력 | 정해진 패턴 안에서 | 임의의 Rust 로직 |
| 작성 난이도 | 낮음 | 높음 (syn/quote 필요) |
| 대표 예 | vec!, println! | #[derive(Serialize)] |
실용적인 지침은 이렇습니다. 간단한 코드 반복이나 편의 문법이면 선언적 매크로로 충분합니다. 반복적인 초기화, 짧은 도우미 문법 정도는 macro_rules!가 가볍고 안전합니다. 반면 구조체나 열거형의 정의를 읽어 트레잇 구현을 자동 생성하는 것처럼, 입력의 구조를 실제로 분석해야 한다면 절차적 매크로가 답입니다. serde, clap의 derive, 그 밖의 수많은 라이브러리가 이 길을 택했습니다.
매크로를 쓰지 말아야 할 때
매크로는 강력하지만 공짜가 아닙니다. 남용하면 코드베이스가 오히려 이해하기 어려워집니다. 다음 신호가 보이면 한 번 멈춰 생각해 보세요.
- 함수로 충분할 때는 함수를 쓰세요. 매크로가 필요한 진짜 이유는 대개 두 가지입니다. 가변 개수의 인자를 받아야 하거나(예:
println!), 코드 자체를 생성해야 할 때입니다. 값만 다루면 되는 일이라면 평범한 함수나 제네릭이 거의 항상 더 낫습니다. 함수는 타입 검사, 디버깅, 문서화가 모두 쉽습니다. - 디버깅 비용을 기억하세요. 매크로가 만들어 낸 코드는 눈에 보이지 않기 때문에, 오류 메시지가 낯선 위치를 가리키거나 이해하기 어려울 수 있습니다.
cargo expand같은 도구로 펼쳐진 코드를 확인할 수 있지만, 그 자체가 추가 부담입니다. - 컴파일 시간을 의식하세요. 특히 절차적 매크로는 syn 같은 무거운 의존성을 끌어오고 컴파일 시점에 실행되므로, 빌드 시간을 늘립니다. 작은 편의를 위해 큰 컴파일 비용을 치르고 있지 않은지 살펴보세요.
- 가독성을 우선하세요. 매크로가 팀원들이 읽기 어려운 "마법"이 되어 가고 있다면, 그 마법이 정말 값어치를 하는지 물어야 합니다. 명시적인 코드 몇 줄이 영리한 매크로 하나보다 나을 때가 많습니다.
정리하면, 매크로는 "다른 방법으로는 표현할 수 없는 것"을 위해 아껴 두는 도구입니다. 가변 인자, 컴파일 시점 코드 생성, 새로운 문법 — 이런 진짜 필요가 있을 때 빛나고, 단순한 편의를 위해 남발하면 부담이 됩니다.
마치며
Rust의 매크로는 컴파일 시점에 코드를 생성하는 메타프로그래밍 도구이며, 성격이 다른 두 갈래로 나뉩니다. 선언적 매크로는 macro_rules!로 패턴을 매칭해 코드를 찍어내는 가볍고 위생적인 도구이고, 절차적 매크로는 입력을 토큰 스트림으로 받아 syn과 quote로 분석·생성하는 강력한 도구입니다. serde의 #[derive(Serialize)]는 후자의 대표적 성과로, 손으로 쓰면 지루했을 코드를 필드 정의로부터 자동으로 만들어 냅니다.
두 도구의 경계를 이해하면, 언제 macro_rules!로 충분하고 언제 절차적 매크로가 필요한지, 그리고 언제 그냥 함수를 쓰는 게 옳은지 판단할 수 있습니다. 매크로는 아껴 쓸 때 가장 강력합니다. 그 절제가 여러분의 코드를 마법이 아니라 도구로 남게 합니다.
참고 자료
- The Rust Programming Language: Macros — https://doc.rust-lang.org/book/ch19-06-macros.html
- The Little Book of Rust Macros — https://veykril.github.io/tlborm/
- Rust Reference: Macros — https://doc.rust-lang.org/reference/macros.html
- syn 크레이트 — https://docs.rs/syn/
- quote 크레이트 — https://docs.rs/quote/
- serde 공식 사이트 — https://serde.rs/
- cargo-expand (매크로 확장 확인 도구) — https://github.com/dtolnay/cargo-expand
현재 단락 (1/143)
Rust를 조금 쓰다 보면 이런 것들을 매일 만나게 됩니다. 출력을 위한 `println!`, 벡터를 만드는 `vec!`, 그리고 무엇보다 구조체 위에 붙는 `#[derive(De...