reactive effects가 단지 일시 중지 가능한 async tasks라면?

발행: (2026년 5월 13일 AM 10:24 GMT+9)
9 분 소요
원문: Dev.to

Source: Dev.to

Rust가 이미 무료로 제공하는 세 가지

Pin – 일시 중단된 계산

생각해 보세요: 반응형 효과는 “코드 조각이 일시 정지하고, 의존성이 변할 때까지 기다렸다가 다시 실행되는” 것입니다. 이는 Poll::Pending 상태에 머물러 있는 미래와 정확히 같습니다.

scope.spawn(async move {
    // The SignalChangedFuture::poll() checks a monotonic version number.
    // If unchanged, it subscribes a callback and returns Poll::Pending.
    // The async executor polls other tasks.
    // When the signal changes, the version bumps, the callback fires,
    // the waker wakes, the executor re‑polls.
});

실행기의 poll 루프 그 자체가 효과 스케줄러이며—별도의 스케줄러, 효과‑그래프 순회, create_effect() 추상화가 필요 없습니다.

Waker – 알림 디스패처

반응형 라이브러리는 “시그널 X가 변할 때 어떤 효과를 다시 실행해야 하는가”를 결정해야 합니다. 비동기 모델에서는 이것이 곧 “어떤 미래를 다시 poll 해야 하는가”와 직접 연결됩니다. waker 그 자체가 그 디스패치 메커니즘입니다:

fn poll(self: Pin, cx: &mut Context) -> Poll {
    // …
}

별도의 알림 큐, 종속성의 위상 정렬이 필요 없으며—그저 “waker를 깨우면, 실행기가 이를 잡아 처리”합니다.

Drop – 정리

작업을 소유하고 있는 스코프를 드롭하면 모든 미래가 드롭됩니다; 각 미래의 Drop 구현은 자신의 시그널 구독을 사전에 해제하여 남은 구독자를 0으로 만듭니다. 수동 on_cleanup() 토큰이나 효과‑폐기 bookkeeping이 필요 없습니다. Rust의 소유권 시스템이 이 작업을 수행합니다.

수백 단계에 달하는 UI 컴포넌트 트리와 같은 깊게 중첩된 스코프의 경우, 취소는 너비‑우선 탐색을 통해 모든 하위 요소를 수집한 뒤, leaf‑to‑root 순으로 반복합니다—재귀적 드롭이나 스택 오버플로우가 발생하지 않습니다.

Rust가 제공하지 않는 20 %: 재진입 방지

Pure async는 Signal::set()이 구독자 콜백을 동기적으로 호출할 때 깨집니다:

set() → callback → set() on same signal → callback → infinite loop

async 모델만으로는 이를 방지할 수 없으며, 지연 알림 상태 머신이 필요합니다.

접근 방식

  • Signal::set()은 콜백을 직접 호출하지 않습니다.
  • 버전을 증가시키고, 구독자 목록을 스냅샷한 뒤, 클로저를 실행기의 지연 콜백 큐에 푸시합니다.
  • 실행기는 모든 플러시 시작 시, 작업을 폴링하기 전에 이 큐를 비웁니다.

상태 표

notifyingdirtyDescription
falsefalse정상. 구독자를 스냅샷하고, 알림을 푸시하고, dirty를 설정합니다.
truefalse콜백 실행 중.
true재진입 set()이 발생했습니다; 새로운 스냅샷으로 후속 알림을 예약합니다(새로 추가된 구독자도 포함됩니다).

이것은 모든 엣지 케이스를 포괄합니다:

  • 콜백 라운드 중 재진입 set().
  • 콜백을 순회하는 동안 구독/구독 해제.
  • 콜백 중 스코프가 드롭될 때(RefCell 외부에 Cell에 저장된 취소 플래그, 언제든지 쓰기 가능).
  • Drop에서의 set()(set_deferred()를 통해 다음 플러시로 지연).

트레이드‑오프

전통적인 리액티브 스케줄러는 효과들을 위상 정렬 순서대로—의존성이 선행하고 종속성이 뒤따라—실행할 수 있어 각 메모가 최대 한 번만 재계산되도록 보장합니다. 비동기 실행기는 작업을 깨워지는 순서대로 실행합니다. 두 효과가 모두 더러운 메모를 읽는 경우, 각각이 재계산을 트리거할 수 있습니다; 두 번째 읽기는 메모가 이미 깨끗해졌음을 확인하고 작업을 건너뛰지만, 각 효과의 첫 번째 폴은 여전히 버전 검사를 수행합니다.

  • Correctness는 유지됩니다—버전 검사와 지연 메모 재계산이 최종 일관성을 보장합니다.
  • Optimality는 최선 노력(best‑effort)이며 보장되지 않습니다.

UI‑프레임 수준의 리액티비티에는 이것이 괜찮습니다. 컴파일러의 증분 분석에는 아마도 적합하지 않을 것입니다. 대부분의 애플리케이션에서는 단순성 트레이드‑오프가 충분히 가치 있게 느껴지지만, 반대 의견도 궁금합니다.

왜 신경 써야 할까?

눈을 가늘게 뜨면, 반응형 프로그래밍과 비동기 프로그래밍은 서로 다른 방향에서 같은 문제를 해결합니다. 하나는 데이터 변화가 있을 때까지 계산을 일시 중단하고, 다른 하나는 I/O가 완료될 때까지 계산을 일시 중단합니다. 두 접근 방식의 기본 요소는 거의 완전히 겹칩니다.

문제는 “비동기 위에 반응형을 구축할 수 있느냐?”가 아니라 “왜 그렇지 않겠느냐?” 입니다. 실제로 새롭게 필요한 부분은 재진입 없이 구독자 콜백을 안전하게 호출할 수 있는 방법뿐입니다. 나머지는 이미 언어에 존재합니다.

저는 이를 구현한 작업 가능한 구현체를 만들었습니다(~1800 줄, 두 개의 크레이트에 걸쳐, #![forbid(unsafe_code)], 시그널 레이어에 외부 의존성 없음). 특히 다음에 대한 기술적인 피드백을 받고 싶습니다:

  • 실제 UI 프로젝트에서 위상 정렬이 없어서 문제가 된 사례가 있는지, 아니면 주로 이론적인 우려에 불과한지.
  • 제가 실행기 라우팅에 사용하고 있는 세대별 슬롯 테이블보다 더 간단한 대안이 있는지.
  • 놓친 가장자리 경우나 잘못된 것처럼 느껴지는 트레이드오프가 있는지.

이것은 아직 제 개인적인 학습 과정이며, 나중에 문제를 발견하기보다 지금 바로 수정받는 것이 좋습니다.

0 조회
Back to Blog

관련 글

더 보기 »