분석 대상: github.com/zed-industries/zed · Rust · ~200 crates

Zed 소스코드로 배우는 Rust 고급 패턴

이 글은: Zed 에디터(Rust로 작성된 대형 프로젝트) 소스를 직접 읽고 발견한 고급 패턴 모음. 단순 크레이트 소개가 아니라, 실제 코드에서 왜 이렇게 썼는가를 분석한다. 크게 두 가지 철학이 관통한다: 런타임 오류를 타입 시스템으로 컴파일 타임에 차단하고, hot path에서 할당을 최소화한다.

1. Newtype에 의미 있는 Debug impl

clock/src/clock.rs · 타입 시스템 / DX

Newtype은 보통 타입 안전성 때문에 만든다. 그런데 Zed는 Debug 출력까지 도메인 언어로 직접 구현한다.

// derive(Debug)를 쓰면 그냥 ReplicaId(5) 로 나온다
#[derive(Clone, Copy, Default, Eq, Hash, PartialEq, Ord, PartialOrd)]
pub struct ReplicaId(u16);

impl fmt::Debug for ReplicaId {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        if *self == ReplicaId::LOCAL        { write!(f, "<local>") }
        else if *self == ReplicaId::REMOTE_SERVER { write!(f, "<remote>") }
        else { write!(f, "{}", self.0) }
    }
}

derive(Debug)를 쓰면 로그에 ReplicaId(5)가 찍힌다. 직접 구현하면 <local>, <remote>처럼 의미 있는 이름이 나온다. CRDTs 디버깅 시 수백 개의 로그 중에 어떤 복제본인지 바로 보인다.

Takeaway: struct UserId(u64) 같은 newtype은 Debug도 직접 구현해 도메인 언어로 출력하자. 타입 안전성에서 끝내지 말고, 디버그 경험까지 책임져라.

2. clone_from 수동 구현 — 힙 재할당 제거

clock/src/clock.rs, text/src/locator.rs · 성능

Clone의 기본 clone_from 구현은 *self = other.clone()이다. 이는 기존 self의 힙 버퍼를 버리고 새 버퍼를 만든다. 직접 구현하면 기존 버퍼를 재사용할 수 있다.

impl Clone for Global {
    fn clone(&self) -> Self {
        Self { values: SmallVec::from_slice(&self.values) }
    }

    fn clone_from(&mut self, source: &Self) {
        // 용량이 충분하면 재할당 없이 복사
        self.values.clone_from(&source.values);
    }
}

기본 clone_from

  1. 새 Vec 할당
  2. source 복사
  3. 기존 Vec 해제

= 할당 1회 + 해제 1회

수동 clone_from

  1. self 버퍼 용량 확인
  2. 충분하면 memcpy만
  3. 부족할 때만 재할당

= 할당 0회 (대부분의 경우)

Zed의 Global은 버전 벡터(version vector)로 CRDTs 연산마다 수십~수백 번 복제된다. 이 최적화가 실제로 의미 있다.

Takeaway: Hot path에서 자주 복제되는 컬렉션 wrapper 타입은 clone_from을 구현하라. Vec, SmallVec, String 모두 clone_from을 지원하므로 내부 필드에 위임하면 된다.

3. Sealed Trait — 외부 구현을 컴파일 타임에 차단

gpui/src/interactive.rs · 타입 시스템 / API 설계

라이브러리 내부 타입에만 구현 가능해야 하는 trait을 설계할 때, 문서로만 제한하는 건 충분하지 않다. Sealed trait 패턴은 이 제약을 컴파일러가 강제하게 만든다.

// private mod이 핵심 — 외부 크레이트에서 이 경로를 볼 수 없다
mod seal {
    pub trait Sealed {}
}

// 퍼블릭 trait이 Sealed를 수퍼 trait으로 요구
pub trait InputEvent: seal::Sealed + 'static {
    // ...
}

// 내부에서만 impl 추가 가능
impl seal::Sealed for KeyDownEvent {}
impl seal::Sealed for MouseMoveEvent {}

외부 크레이트에서 InputEvent를 구현하려면 seal::Sealed를 먼저 구현해야 한다. 하지만 seal이 완전 private module이라 외부에서는 경로 자체를 참조할 수 없다. 컴파일 오류.

GPUI에서 InputEvent를 외부가 구현하면 이벤트 디스패치 로직이 깨진다. 이 패턴으로 API 계약을 타입 시스템에 인코딩했다.

Takeaway: "이 trait은 내부 타입에만 impl해야 한다"는 제약이 있을 때 mod seal { pub trait Sealed {} } 패턴을 써라. 주석이나 문서로는 부족하다 — 컴파일러가 강제해야 한다.

4. PhantomData<fn(T) -> T> — 불변 분산(invariance) 제어

gpui/src/app/entity_map.rs · 타입 시스템 / 안전성

PhantomData를 쓸 때 분산(variance)을 의식적으로 선택해야 한다. Zed의 Entity<T> 핸들은 의도적으로 불변(invariant)으로 만들었다.

pub struct Entity<T> {
    pub(crate) any_entity: AnyEntity,
    pub(crate) entity_type: PhantomData<fn(T) -> T>,  // invariant
}
PhantomData 형태분산용도
PhantomData<T>공변(covariant)T를 소유하거나 읽기만 하는 경우
PhantomData<fn(T) -> T>불변(invariant)ID 핸들, 슬롯, 제네릭 포인터
PhantomData<fn(T)>반공변(contravariant)거의 안 씀
PhantomData<*mut T>불변(invariant)raw pointer 래핑

Entity<T>가 공변이면 Entity<Dog>Entity<Animal>로 암묵적으로 캐스팅할 수 있다. 그러면 앱 컨텍스트에서 잘못된 타입으로 엔티티를 읽어올 수 있어 UB. 불변으로 만들어 이 캐스팅을 컴파일 타임에 차단한다.

Takeaway: 제네릭 핸들/포인터/슬롯 타입을 만들 때 분산을 의식적으로 선택하라. PhantomData<T>가 기본값이지만, ID처럼 동작하는 핸들엔 PhantomData<fn(T) -> T>가 맞다.

5. Drop 기반 RAII 구독 해제 — Subscription

gpui/src/subscription.rs · 리소스 관리 / API 설계

이벤트 구독을 등록하면 Subscription을 반환한다. 이 값이 drop되면 자동으로 구독이 해제된다. 명시적으로 영구 구독하려면 detach()를 호출한다.

#[must_use]  // 실수로 버리면 경고
pub struct Subscription {
    unsubscribe: Option<Box<dyn FnOnce() + 'static>>,
}

impl Drop for Subscription {
    fn drop(&mut self) {
        if let Some(f) = self.unsubscribe.take() { f(); }
    }
}

impl Subscription {
    /// 명시적으로 영구 구독 — drop해도 해제되지 않는다
    pub fn detach(mut self) {
        self.unsubscribe.take();
    }
}

세 가지 사용 패턴:

// 1. 변수에 저장 — 변수가 drop될 때 구독 해제
let _subscription = cx.observe(&model, |this, cx| { ... });

// 2. 즉시 버리기 — 즉시 구독 해제 (#[must_use] 경고 발생)
// let _ = cx.observe(...);  ← 컴파일러가 경고

// 3. 영구 구독
cx.observe(&model, |this, cx| { ... }).detach();

Option<Box<dyn FnOnce>>를 쓰는 이유: Dropdetach가 모두 "내용을 take해서 처리 or 무시"하는 구조를 공유하기 때문이다.

Takeaway: 해제가 필요한 핸들 타입은 Drop + #[must_use] + detach() 삼총사로 설계하라. 사용자가 의도를 명시적으로 코드에 표현하게 강제한다.

6. Upgradable Read Lock — 원자적 Read→Write 전환

gpui/src/text_system.rs · 동시성

"캐시에 있으면 읽기, 없으면 계산 후 쓰기" 패턴에서 일반 RwLock은 TOCTOU 문제가 있다. parking_lotupgradable_read로 해결한다.

fn read_metrics<T>(&self, font_id: FontId, read: impl FnOnce(&FontMetrics) -> T) -> T {
    let lock = self.font_metrics.upgradable_read();  // 읽기 락 (다른 읽기 허용)

    if let Some(metrics) = lock.get(&font_id) {
        read(metrics)  // 캐시 히트 — 쓰기 락 없이 반환
    } else {
        let mut lock = RwLockUpgradableReadGuard::upgrade(lock);  // 원자적 쓰기 전환
        let metrics = lock.entry(font_id).or_insert_with(|| {
            self.platform_text_system.font_metrics(font_id)
        });
        read(metrics)
    }
}

일반 RwLock: TOCTOU 발생

  1. read() 락 획득
  2. 캐시 미스 확인
  3. drop(read guard)
  4. ← 다른 스레드가 끼어든다
  5. write() 락 획득
  6. 중복 계산 발생

upgradable_read: 원자적

  1. upgradable_read() 획득
  2. 캐시 미스 확인
  3. upgrade() — 원자적 전환
  4. ← 다른 스레드 진입 불가
  5. 계산 후 삽입
Takeaway: std::sync::RwLock에는 upgradable read가 없다. Read-check-write 패턴이 필요하면 parking_lotRwLock을 써라.

7. FluentBuilder trait — 조건부 빌더 체이닝

gpui/src/util.rs · API 설계 / 인체공학

빌더 체인 중간에 if를 쓰면 코드가 지저분해지고 타입 추론이 꼬일 수 있다. Zed는 when 메서드를 trait으로 만들어 모든 빌더 타입에 blanket impl한다.

pub trait FluentBuilder {
    fn when(self, condition: bool, then: impl FnOnce(Self) -> Self) -> Self
    where Self: Sized {
        if condition { then(self) } else { self }
    }

    fn when_some<T>(self, option: Option<T>, then: impl FnOnce(Self, T) -> Self) -> Self
    where Self: Sized {
        if let Some(v) = option { then(self, v) } else { self }
    }

    fn when_else(self, condition: bool, then: impl FnOnce(Self) -> Self,
                 otherwise: impl FnOnce(Self) -> Self) -> Self
    where Self: Sized {
        if condition { then(self) } else { otherwise(self) }
    }
}

if 없이 — 지저분함

let mut d = div().flex();
if is_focused {
    d = d.border_color(blue);
}
if let Some(msg) = error {
    d = d.child(text(msg));
}
d

FluentBuilder — 깔끔한 체인

div()
  .flex()
  .when(is_focused, |d|
      d.border_color(blue))
  .when_some(error, |d, msg|
      d.child(text(msg)))
Takeaway: 조건부 빌더 설정이 많은 타입에 FluentBuilder를 blanket impl하라. 로직의 흐름이 한 체인으로 읽힌다.

8. 아레나 할당자 + 유효성 검사 핸들

gpui/src/arena.rs · 성능 / unsafe 안전 래핑

GPUI는 매 프레임마다 전체 UI 트리를 재구성한다. 각 요소에 Box::new()를 쓰면 수천 번의 힙 할당이 발생한다. 대신 프레임 단위 아레나를 쓰고 clear() 한 번으로 전체를 리셋한다.

pub struct Arena {
    chunks: Vec<Chunk>,
    valid: Rc<Cell<bool>>,  // clear() 이후 사용 감지용
}

pub struct ArenaBox<T: ?Sized> {
    ptr: *mut T,
    valid: Rc<Cell<bool>>,  // Arena와 공유
}

impl Arena {
    pub fn clear(&mut self) {
        self.valid.set(false);      // 기존 핸들 무효화
        self.valid = Rc::new(Cell::new(true));  // 새 세대 시작
        // chunks는 메모리 해제 없이 포인터만 리셋
        for chunk in &mut self.chunks { chunk.len = 0; }
    }
}

impl<T: ?Sized> Deref for ArenaBox<T> {
    fn deref(&self) -> &Self::Target {
        assert!(self.valid.get(), "ArenaRef used after Arena::clear");
        unsafe { &*self.ptr }  // unsafe이지만 위에서 검사
    }
}

프레임 단위로 clear()를 호출하면 기존 ArenaBox들의 valid 플래그가 false로 설정된다. 오래된 핸들을 실수로 역참조하면 패닉이 발생한다. 실제 제품 빌드에서는 assert를 제거해 오버헤드를 없앨 수 있다.

Takeaway: unsafe를 쓸 때는 그 위에 런타임 검사 계층을 얹어라. Rc<Cell<bool>>처럼 가벼운 도구로 use-after-free 류의 오류를 개발 중에 빠르게 잡을 수 있다.

9. Identity Hasher — 도메인 특화 zero-cost 해싱

gpui_util/src/lib.rs · 성능

키가 이미 유일한 정수(TypeId, UUID 등)라면 해시 연산 자체를 없애버릴 수 있다.

pub struct TypeIdHasher { value: u64 }

impl Hasher for TypeIdHasher {
    fn write(&mut self, bytes: &[u8]) {
        // TypeId는 내부적으로 u64 하나 — 그 8바이트를 그대로 쓴다
        self.value = u64::from_ne_bytes(bytes[..8].try_into().unwrap());
    }
    fn finish(&self) -> u64 { self.value }
}

impl BuildHasher for TypeIdHasherBuilder {
    type Hasher = TypeIdHasher;
    fn build_hasher(&self) -> TypeIdHasher { TypeIdHasher { value: 0 } }
}

// 사용
type TypeMap = HashMap<TypeId, Box<dyn Any>, TypeIdHasherBuilder>;

TypeId는 컴파일러가 보장하는 유일한 64비트 정수다. 충돌이 원리적으로 불가능하므로 해시 연산을 완전히 생략했다. Zed 내부의 전역 타입 레지스트리가 이 해시맵을 극도로 자주 조회한다.

Takeaway: 키가 이미 잘 분산된 유일한 정수라면 identity hasher가 FxHasher보다 빠르다. 단, 일반 데이터에 쓰면 해시 충돌로 O(n) 성능 붕괴 — 도메인을 알 때만 써라.

10. SumTree — GAT로 추상화된 함수형 B+ 트리

crates/sum_tree/src/sum_tree.rs · 자료구조 / 트레이트 설계

Zed의 텍스트 버퍼, 로프(Rope), 진단 목록이 모두 동일한 SumTree<T> 구현을 쓴다. "바이트 오프셋으로 탐색"과 "줄 번호로 탐색"을 같은 트리 코드가 지원하는 비결은 Item, Summary, Dimension 세 trait의 분리다.

pub trait Item: Clone {
    type Summary: Summary;
    fn summary(&self, cx: <Self::Summary as Summary>::Context<'_>) -> Self::Summary;
}

pub trait Summary: Clone {
    type Context<'a>: Copy;  // GAT — 탐색 컨텍스트를 zero-copy로 전달
    fn zero<'a>(cx: Self::Context<'a>) -> Self;
    fn add_summary<'a>(&mut self, summary: &Self, cx: Self::Context<'a>);
}

// "어떤 방향으로 탐색하는가"를 별도 trait으로 분리
pub trait Dimension<'a, S: Summary>: Clone + Default {
    fn add_summary(&mut self, summary: &'a S, cx: S::Context<'_>);
}
// 실제 사용: 같은 트리를 두 가지 방향으로 탐색
let offset: usize = tree.summary().bytes;       // 바이트 오프셋
let row: u32      = tree.summary().lines;       // 줄 번호

// Dimension으로 특정 지점까지 커서 이동
let mut cursor = tree.cursor::<ByteOffset>();
cursor.seek(&ByteOffset(1024), Bias::Left, &());  // 1024바이트 지점으로
GAT(type Context<'a>)가 필요한 이유:
트리 탐색 중에 외부 컨텍스트(예: 언어 파서, 설정)를 노드 요약 계산에 전달해야 한다. 이 컨텍스트는 트리 자신보다 짧은 수명을 가질 수 있다. Rust 1.65 이전에는 이를 표현할 방법이 없었고, GAT가 도입되면서 가능해졌다.
Takeaway: 복잡한 자료구조를 만들 때 "어떻게 집계되나"(Summary)와 "어떻게 탐색하나"(Dimension)를 별도 trait으로 분리하라. 같은 B-tree 구현체를 전혀 다른 도메인에서 재사용할 수 있게 된다. GAT는 "수명이 있는 연관 타입"이 필요한 순간에 꺼내라.

공통 철학 요약

런타임 오류 → 컴파일 타임으로

  • Sealed trait — 외부 구현 불가
  • PhantomData 분산 — 잘못된 캐스팅 차단
  • #[must_use] — 실수로 버리는 핸들 경고
  • Newtype — 타입 혼용 차단

Hot path 할당 최소화

  • 아레나 할당자 — 프레임당 수천 Box 대신 clear() 한 번
  • clone_from — 버퍼 재사용
  • Identity hasher — 해시 연산 자체를 제거
  • Upgradable lock — 불필요한 락 해제/재획득 제거