Rust를 컴파일러 관점에서 보기: 소유권·라이프타임 추론·트레이트 해석·제로 코스트 추상화

이 글의 핵심

소유권 규칙을 외우는 것과 컴파일러가 왜 그 코드를 거부하는지 이해하는 것은 다릅니다. 빌림 검사기와 라이프타임 추론이 실제로 푸는 문제, 제네릭이 기계어에서 사라지는 조건과 async fn이 상태 머신으로 바뀌는 과정, 어떤 impl이 선택되는지 결정하는 규칙을 보고 CI·Send/Sync·SemVer까지 운영 관점으로 연결합니다.

이 글은 Rust 문법보다 컴파일러가 코드를 어떻게 검사하고 변환하는지에 초점을 둡니다. 소유권과 빌림 검사기, 라이프타임 추론, 제로 코스트 추상화, 트레이트 해석을 차례로 보고, 마지막에 이것이 프로덕션 코드의 오류 처리, 관측, CI, API 안정성과 어떻게 이어지는지 다룹니다. 기초 문법은 Rust 시리즈와 Rust 소유권 | Ownership, Borrowing, Lifetime를 참고하세요.


소유권과 빌림 검사기: 컴파일러가 보는 그림

세 가지 규칙(사용자 관점)

  1. 값마다 소유자는 하나뿐입니다.
  2. 가변 참조 &mut T는 동시에 하나만 존재할 수 있습니다.
  3. 불변 참조 &T는 여러 개 있을 수 있지만, 가변 참조와 동시에 존재할 수는 없습니다.

이 규칙으로 댕글링 참조와 (안전한 코드 안에서의) 데이터 레이스를 컴파일 타임에 막습니다. 다만 “참조가 언제까지 살아 있는가”를 초기 Rust처럼 어휘적(lexical) 스코프, 즉 중괄호 블록 단위로 판단하면 지나치게 보수적이었고, 이를 완화한 것이 NLL(Non-Lexical Lifetimes)입니다. NLL은 2018 에디션과 함께 도입되어 지금은 모든 에디션의 기본 빌림 검사기입니다.

NLL: “닫는 중괄호”가 아니라 “마지막 사용”까지

NLL 이전에는 참조의 유효 구간이 블록 끝까지로 잡히는 경우가 많았습니다. NLL은 참조가 마지막으로 사용되는 지점까지만 살아 있다고 보므로, 아래처럼 불변 대여와 가변 대여가 시간상 겹치지 않으면 허용됩니다.

fn nll_example(v: &mut Vec<i32>) {
    let r = &v[0];       // 공유 대여 시작
    println!("{}", *r);  // r의 마지막 사용
    v.push(1);           // 가변 대여: r은 이미 끝났으므로 충돌하지 않음
}

println! 다음 줄에서 r을 한 번 더 쓰면 이 코드는 거부됩니다. push가 벡터를 재할당하면 r이 해제된 메모리를 가리킬 수 있기 때문입니다. 에러 메시지는 여전히 “borrow later used here”처럼 사용자 언어로 표현되지만, 내부적으로는 각 대여의 유효 구간을 제어 흐름 그래프 위의 지점 집합(region)으로 표현하고, 그 집합들의 포함 관계로 모순 여부를 판단합니다.

MIR과 빌림 검사

rustc는 소스를 HIR(고수준 IR)을 거쳐 MIR(Mid-level IR)로 낮춥니다. MIR은 기본 블록으로 이루어진 제어 흐름 그래프(CFG)라서, “이 지점에서 이 대여가 살아 있는가”를 데이터 플로우 분석으로 추적하기 좋습니다. 빌림 검사기는 각 대여(공유/가변)의 유효 구간, 그 구간 동안 같은 경로(예: v, v.field)에 대한 다른 접근이 충돌하는지, Drop이 실행되는 지점 같은 정보를 사용합니다.

두 단계 대여(two-phase borrow)는 이런 세부 규칙 중 하나입니다. vec.push(vec.len())은 push를 위해 vec을 가변으로 빌린 상태에서 인자로 vec.len()(공유 대여)을 평가하므로, 단순한 규칙으로는 거부되어야 합니다. 두 단계 대여는 자동 참조로 생긴 &mut 대여를 먼저 “예약”만 해 두고, 인자 평가가 끝나 메서드가 실제로 호출되는 시점에 “활성화”합니다. 예약 단계에서는 공유 읽기를 허용하므로 이 관용구가 컴파일됩니다.

Polonius와 더 정밀한 빌림 검사

현재의 NLL 분석은 일부 거짓 양성(실제로는 안전한데 거부하는 경우)을 남깁니다. 대표적인 예가 조건에 따라 대여를 반환하거나 반환하지 않는 함수에서, 반환하지 않은 경로에서도 대여가 계속 살아 있다고 보는 경우입니다. Polonius는 이런 경우를 받아들이도록 대여의 출처(origin)를 더 정밀하게 추적하는 새 분석으로, 처음에는 Datalog 규칙으로 정의되었고 지금은 컴파일러에 통합하는 작업이 단계적으로 진행 중입니다(나이틀리의 -Z polonius 플래그). 안정화되면 같은 코드가 새 버전에서 갑자기 컴파일되는 경험을 할 수 있습니다. 다만 현재 컴파일러에서 우회가 필요하다면, 대여 범위를 좁히거나 구조체를 나눠 서로 다른 필드를 따로 빌리는 쪽이 장기적으로도 읽기 쉬운 코드가 됩니다.

unsafe와 Stacked Borrows / Tree Borrows

unsafe 블록은 컴파일러가 검증하지 못하는 조건을 개발자가 대신 보증한다는 뜻입니다. 미정의 동작(UB)을 피하려면, 컴파일러가 최적화에서 가정하는 포인터 별칭 규칙을 지켜야 합니다. 예를 들어 &mut T가 살아 있는 동안에는 다른 포인터로 같은 메모리에 접근하지 않는다는 가정입니다. Stacked Borrows와 Tree Borrows는 원시 포인터와 참조를 섞어 쓸 때 어떤 접근 순서가 허용되는지를 정식화한 모델이며, Miri가 이 모델로 실제 실행을 검사합니다. 세부 모델을 외울 필요는 없지만, 참조를 원시 포인터로 바꿨다가 다시 참조로 만드는 코드는 cargo +nightly miri test로 확인하는 습관을 들이는 것이 좋습니다.


라이프타임 추론: 제약을 만족하는 가장 작은 해

라이프타임은 런타임 값이 아니다

'a 같은 라이프타임 매개변수는 제네릭 파라미터의 한 종류이지만, 구체적인 시간 값이 바이너리에 들어가지 않습니다. 컴파일러는 “이 대여는 저 대여보다 오래 살아야 한다(outlives)“는 포함 관계만 검증하고, 검사가 끝나면 라이프타임 정보를 지웁니다.

지역(region)과 제약

함수 본문을 검사할 때 컴파일러는 대략 다음을 합니다.

  1. 각 참조에 라이프타임 변수(region 변수)를 붙입니다.
  2. 대입, 함수 호출, 반환 같은 구문으로부터 “'r1은 'r2보다 오래 살아야 한다” 같은 제약을 만듭니다.
  3. 모든 제약을 만족하는 가장 작은 지점 집합을 각 변수에 할당하고, 그 구간 안에서 충돌하는 접근이 있는지 확인합니다.

함수 시그니처의 라이프타임은 추론하지 않고 선언된 대로 믿습니다. 함수 경계에서 추론을 하지 않기 때문에 함수 본문을 바꿔도 호출하는 쪽의 검사 결과가 바뀌지 않습니다.

// 'a는 호출하는 쪽에서 x와 y가 모두 유효한 구간으로 정해진다.
// 결과적으로 반환 참조는 둘 중 더 짧게 사는 쪽보다 오래 쓸 수 없다.
fn pick<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

생략(elision) 규칙

라이프타임 생략은 자주 쓰는 패턴에 대한 문법 설탕입니다. 입력 참조마다 각자의 라이프타임이 붙고, 입력 참조가 하나뿐이면 출력 참조는 그 라이프타임을 따르며, 메서드에 &self나 &mut self가 있으면 출력 참조는 self의 라이프타임을 따릅니다. 이 세 규칙으로 정해지지 않으면(예: 입력 참조가 둘이고 self가 없는 앞의 pick) 명시적으로 'a를 써야 합니다.

분산(Variance): 왜 &mut T는 T에 대해 무공변인가

분산은 서브타입 관계가 타입 생성자를 거쳐 어떻게 전달되는지를 말합니다. Rust에서 서브타입은 주로 라이프타임에서 생깁니다. 'static은 어떤 'a보다도 오래 살므로 &'static str은 &'a str이 필요한 곳에 쓸 수 있고, 이것이 &'a T가 'a와 T 모두에 대해 공변(covariant)이라는 뜻입니다.

&'a mut T는 'a에 대해서는 공변이지만 T에 대해서는 무공변(invariant)입니다. 만약 &mut &'static str을 &mut &'a str로 바꿀 수 있다면, 그 가변 참조를 통해 짧게 사는 문자열 참조를 써 넣을 수 있고, 원래 변수는 여전히 &'static str이라고 믿으므로 댕글링 참조가 생깁니다.

fn overwrite<'a>(slot: &mut &'a str, short: &'a str) {
    *slot = short;
}

fn main() {
    let mut s: &'static str = "static";
    {
        let local = String::from("local");
        overwrite(&mut s, &local); // 거부됨: &mut는 T에 대해 무공변이라 'a를 'static으로 맞춰야 함
    }
    println!("{s}");
}

Cell<T>, RefCell<T>, *mut T 같은 내부 가변성 타입도 같은 이유로 T에 대해 무공변입니다. “왜 이 값을 여기 넘길 수 없지?”라는 라이프타임 오류가 가변 참조나 내부 가변성 타입을 거칠 때만 생긴다면 원인은 대개 분산입니다.

HRTB: for<'a> 고차 라이프타임

고차 트레이트 바운드(Higher-Ranked Trait Bounds)는 “모든 라이프타임 'a에 대해” 성립해야 하는 조건을 표현합니다. 클로저가 호출하는 쪽이 정한 임의의 수명을 가진 참조를 받아야 할 때 등장합니다.

fn apply<F>(f: F, s: &str)
where
    F: for<'a> Fn(&'a str) -> &'a str,
{
    let _ = f(s);
}

실제로는 F: Fn(&str) -> &str라고만 써도 생략 규칙에 의해 같은 HRTB로 해석되므로, 명시적인 for<'a>는 트레이트 바운드가 복잡할 때 주로 보게 됩니다.


제로 코스트 추상화: 추상화가 기계어에서 사라지는 조건

Rust의 “제로 코스트”는 추상화를 써도 같은 일을 손으로 작성한 코드보다 비용이 크지 않다는 설계 목표입니다. 이 목표는 최적화가 적용된다는 전제 위에 있어서, 디버그 빌드에서는 추상화 비용이 그대로 남습니다.

단조화(Monomorphization)

제네릭 함수 fn foo<T: Trait>(t: T)는 실제로 쓰인 구체 타입마다 별도의 기계어 사본이 생성됩니다. 호출 대상이 컴파일 시점에 정해지는 정적 디스패치가 되어 인라인화가 가능하고, C++ 템플릿과 비슷한 최적화 여지가 생깁니다. 대가는 컴파일 시간과 바이너리 크기입니다. 큰 제네릭 함수에서 타입에 의존하지 않는 부분을 비제네릭 내부 함수로 빼 두면 사본 크기를 줄일 수 있고, 표준 라이브러리도 이 기법을 씁니다.

반복자 체인

Iterator 체인은 고수준 API처럼 보이지만, 각 어댑터가 구체 타입으로 단조화되고 인라인되므로 최적화 후에는 보통 하나의 루프로 합쳐집니다. 아래 함수는 릴리스 빌드에서 손으로 쓴 for 루프와 같은 수준의 코드가 되고, 경우에 따라 SIMD로 벡터화되기도 합니다.

pub fn sum_squares(v: &[i32]) -> i32 {
    v.iter().map(|x| x * x).sum()
}

반대로 중간에 Box<dyn Iterator>가 끼어 동적 디스패치가 생기면 인라인이 막혀 이 융합이 일어나지 않습니다. 핫 경로라면 impl Iterator나 제네릭으로 구체 타입을 유지하는 것이 좋습니다.

dyn Trait: fat pointer와 vtable

&dyn Trait는 데이터 포인터와 vtable 포인터, 두 개의 포인터로 이루어진 fat pointer입니다. 메서드 호출은 vtable을 거치는 간접 호출이라 인라인이 어렵지만, 플러그인처럼 타입을 런타임에만 알 수 있는 경계에서는 필요합니다. 제네릭 메서드가 있거나 Self를 값으로 반환하는 메서드가 있는 트레이트는 vtable을 만들 수 없어 dyn으로 쓸 수 없습니다. 이런 성질을 예전에는 객체 안전성(object safety)이라고 불렀고, 최근 공식 문서에서는 dyn 호환성(dyn compatibility)이라는 용어로 바꿔 부릅니다.

async fn과 상태 머신

async fn은 컴파일러가 만든 상태 머신 타입으로 변환되며, 각 .await 지점이 상태 하나가 됩니다. .await를 넘어 살아 있어야 하는 지역 변수는 이 상태 머신 구조체의 필드가 되므로, 큰 버퍼를 .await 앞뒤로 들고 있으면 future 크기가 그만큼 커집니다. 상태 머신 자체는 힙 할당 없이 만들어지지만, 실제 실행에 필요한 폴링, 작업 스케줄링, 스레드 풀은 Tokio 같은 런타임이 제공하며 그 비용은 런타임 설계에 달려 있습니다. 상태 머신이 자기 자신을 가리키는 참조를 담을 수 있기 때문에, 폴링 중에는 메모리 위치가 바뀌지 않도록 Pin으로 고정합니다.


트레이트 해석: 어떤 impl이 선택되는가

일관성(Coherence)과 고아 규칙(Orphan Rule)

Rust는 같은 트레이트와 같은 타입 조합에 대해 겹치는 impl이 둘 이상 존재하지 않도록 보장합니다(일관성). 고아 규칙은 이를 크레이트 단위로 지키기 위한 규칙으로, 단순화하면 트레이트나 타입 중 적어도 하나가 내 크레이트에 정의되어 있어야 impl을 쓸 수 있다는 뜻입니다. 외부 타입에 외부 트레이트를 구현하는 것을 막아, 서로 다른 두 크레이트가 같은 조합을 각자 구현해 충돌하는 상황을 원천적으로 없앱니다. 외부 타입에 외부 트레이트를 구현해야 한다면 newtype(struct Wrapper(ExternalType);)으로 감싸는 것이 일반적인 우회 방법입니다.

관련 타입(Associated Type)과 제네릭 트레이트

Iterator의 Item처럼 구현하는 타입마다 하나로 정해지는 출력에는 관련 타입이 맞습니다. 호출하는 쪽에서 Item을 따로 지정하지 않아도 되고, 타입 추론도 단순해집니다. 반대로 From<T>처럼 한 타입이 여러 타입 매개변수에 대해 각각 구현될 수 있어야 한다면 제네릭 트레이트를 씁니다.

특화(Specialization)

특정 타입에 대해 더 효율적인 구현을 따로 두는 특화는 아직 불안정 기능입니다. 표준 라이브러리 내부에서는 제한된 형태(min_specialization)로 쓰이지만, 안정 컴파일러를 쓰는 크레이트에서는 사용할 수 없으므로 트레이트 설계를 안정 기능 범위 안에서 해야 합니다.

Deref 강제와 메서드 탐색

메서드를 호출할 때 컴파일러는 수신자 타입에 자동 참조(&, &mut)와 역참조(Deref)를 차례로 적용해 가며 맞는 메서드를 찾습니다. 편리하지만, Deref를 상속처럼 쓰면 어느 타입의 메서드가 호출되는지 코드만 보고 알기 어려워집니다. 그래서 Deref는 스마트 포인터처럼 “이 타입은 사실상 저 타입을 가리킨다”는 관계에만 구현하는 것이 공식 API 가이드라인의 권장입니다.


프로덕션 Rust 패턴

오류 계층: thiserror와 anyhow

라이브러리는 thiserror로 오류를 enum으로 정의해, 호출하는 쪽이 match로 종류를 구분하고 복구할 수 있게 합니다. 바이너리나 서비스 경계처럼 오류를 복구하기보다 기록하고 위로 올리는 곳에서는 anyhow::Result와 .context(...)로 “무엇을 하다가 실패했는지”를 쌓아 로그와 트레이스에 넘기는 편이 간결합니다.

use thiserror::Error;

#[derive(Debug, Error)]
#[non_exhaustive]
pub enum ConfigError {
    #[error("missing env {0}")]
    MissingEnv(String),
    #[error("invalid port")]
    InvalidPort,
}

관측: tracing 스팬과 구조적 로그

tracing은 로그 줄 대신 스팬(span) 단위로 요청 ID, 사용자 ID, 소요 시간 같은 필드를 묶습니다. async 코드에서는 하나의 요청이 여러 스레드를 오가며 실행되므로, 스레드 로컬에 기대는 방식으로는 로그를 요청 단위로 묶기 어렵습니다. tracing은 future에 스팬을 붙여(.instrument(span)) 실행 위치와 관계없이 맥락을 유지하고, OpenTelemetry 같은 분산 추적으로 내보내기도 쉽습니다.

CI 파이프라인

CI에는 보통 cargo fmt --check로 포맷을, cargo clippy -- -D warnings로 린트 경고를 실패로 처리하는 단계를 넣습니다. 공급망 쪽으로는 cargo audit이나 Dependabot으로 알려진 취약점이 있는 의존성을 찾고, 필요하면 cargo deny로 라이선스와 중복 버전 크레이트를 관리합니다. unsafe나 FFI 경계가 있는 크레이트는 Miri나 AddressSanitizer 같은 새니타이저로 테스트를 한 번 더 돌리는 단계를 두면, 일반 테스트로는 드러나지 않는 미정의 동작을 잡을 수 있습니다.

동시성과 Send / Sync

값을 다른 스레드로 옮길 수 있는지(Send), 여러 스레드에서 공유 참조로 접근할 수 있는지(Sync)는 타입 시스템이 자동으로 판단합니다. Rc는 참조 카운트를 원자적으로 갱신하지 않으므로 Send가 아니고, 여러 스레드에서 상태를 공유할 때는 Arc와 Mutex/RwLock 같은 내부 가변성 타입을 조합합니다. Tokio의 spawn은 future가 Send이길 요구하므로, .await를 넘어 Rc나 std::sync::MutexGuard를 들고 있으면 컴파일 오류가 납니다. RwLock은 읽기가 압도적으로 많을 때 유리하지만 구현에 따라 쓰기 기아가 생길 수 있어, 어느 쪽이 나은지는 실제 접근 패턴으로 측정해 보는 것이 좋습니다.

API 안정성과 SemVer

공개 API에서 enum에 variant를 추가하거나, 공개 필드만 있는 구조체에 필드를 추가하거나, 트레이트에 기본 구현 없는 메서드를 추가하면 하위 호환이 깨집니다. 각각 외부의 match가 모든 경우를 처리하지 못하게 되고, 구조체 리터럴로 값을 만들던 코드와 그 트레이트를 구현하던 코드가 컴파일되지 않기 때문입니다. 나중에 확장할 가능성이 있는 enum과 구조체에는 #[non_exhaustive]를 붙이고, 트레이트에 메서드를 추가할 때는 기본 구현을 함께 제공하면 마이너 버전 업데이트로 기능을 늘릴 수 있습니다. cargo-semver-checks를 CI에 넣으면 이런 변경을 릴리스 전에 잡을 수 있습니다.


이어 읽기

이 글은 컴파일러 관점의 큰 그림을 다뤘기 때문에 개별 주제는 얕게 지나갔습니다. 제네릭과 동적 디스패치의 차이는 트레이트에서, Send/Sync와 스레드·채널은 동시성에서, 실제 서비스 코드는 Rust 웹 개발 가이드에서 이어서 볼 수 있습니다.

참고

  • The Rust Reference — Ownership, Lifetimes, Traits, Subtyping and Variance
  • rustc dev guide — MIR, borrow checker, two-phase borrows
  • The Rustonomicon — unsafe와 메모리 모델

같이 보면 좋은 글