---
title: "반응성 — zenit Zig UI 문서"
description: "사실은 Signal에 담고, 파생 값은 Memo로 표현하며, 외부 세계와의 동기화는 Effect로 합니다."
url: https://zenit.z.express/ko/docs/guide/reactivity
language: ko
alternate_en: https://zenit.z.express/docs/guide/reactivity.md
alternate_zh: https://zenit.z.express/zh/docs/guide/reactivity.md
alternate_es: https://zenit.z.express/es/docs/guide/reactivity.md
alternate_ja: https://zenit.z.express/ja/docs/guide/reactivity.md
alternate_fr: https://zenit.z.express/fr/docs/guide/reactivity.md
alternate_de: https://zenit.z.express/de/docs/guide/reactivity.md
project: zenit v0.1.0-alpha (Zig 0.15.2, macOS)
source: https://github.com/version-next/zenit
---

# 반응성

사실은 Signal에 담고, 파생 값은 Memo로 표현하며, 외부 세계와의 동기화는 Effect로 합니다.

## 세 가지 프리미티브

**Signal**

쓰기 가능한 사실의 원천입니다. `get()`으로 읽고 `set(v)`로 씁니다. 읽으면 현재 계산에 의존성이 기록됩니다.

**Memo**

다른 반응형 값에서 지연 계산됩니다. 입력이 그대로면 이전 결과를 재사용하고, 값이 같으면 하위로 알리지 않습니다.

**Effect**

의존성이 바뀌면 부수 효과를 실행합니다. 노드 갱신, 로그 기록, 외부 시스템 동기화 등입니다.

## 전체 데이터 흐름

`ui.textFmt`는 전달한 Signal / Memo를 구독하고, 이들이 바뀌면 그 텍스트 노드 하나만 다시 포맷합니다. Memo의 의존성은 명시적인 컨텍스트 구조체로 전달합니다.

`counter.zig`

```zig
const count = try scope.createSignal(u32, 0);

const doubled = try scope.createMemo(u32, .{ .count = count }, struct {
    fn compute(ctx: anytype) u32 {
        return ctx.count.get() * 2;
    }
}.compute);

try root.appendChild(cx.allocator, try ui.textFmt(
    cx,
    scope,
    "count = {d}, doubled = {d}",
    .{ count, doubled },
    .{ .color = cx.tokens.color.fg_primary },
));
```

DATA FLOW

한 번의 set이 의존성 간선을 따라 전파되고(Signal → Memo → textFmt), 구독한 텍스트 노드만 render dirty로 표시됩니다.

## diff가 아닌 Signal

zenit은 레이아웃, 히트 테스트, 그리기를 위해 실제 UI 트리를 유지하지만, 업데이트는 “컴포넌트 재실행 → 후보 트리 생성 → 전체 diff” 경로를 거치지 않습니다. Signal을 읽는 Memo / Effect는 의존성을 등록하고, `set`은 정확히 그 구독자에게만 알려 영향받는 노드를 layout 또는 render dirty로 표시합니다.

SIGNAL vs DIFF

트리는 여전히 있습니다. 사라지는 것은 변경마다 후보 트리를 다시 만들고 차이를 찾는 단계입니다.

[Video](https://zenit.z.express/media/reactive-counter.mp4?v=635ee40b41)

실제 Reactive Counter.app: +1마다 count와 doubled가 함께 3 / 6까지 갱신되고, Reset이 두 구독 결과를 0으로 되돌립니다.

## 이벤트에서 Signal 갱신

컴포넌트 콜백은 `HandlerRef`입니다. `cx.bindState`로 Signal 포인터를 가진 작은 구조체를 보관하고, `cx.on`으로 그 메서드를 콜백으로 바꿉니다.

`handlers.zig`

```zig
const Bindings = struct {
    count: *ui.Signal(u32),

    fn increment(self: *Bindings) void {
        self.count.set(self.count.get() + 1);
    }
};

const bindings = try cx.bindState(Bindings, .{ .count = count });
const on_click = cx.on(Bindings, bindings, Bindings.increment);

const plus = try ui.widgets.Button(.{
    .label = "+1",
    .on_click = on_click,
}).mount(scope, cx);
```

## 읽기와 구독

| API | 역할 |
| --- | --- |
| `signal.get()` | 읽고 현재 Memo / Effect에 의존성을 등록 |
| `signal.peek()` | 구독 없이 읽기. 핸들러 안에서 스냅샷을 얻을 때 유용 |
| `signal.set(v)` | 쓰고 구독자에게 알림 |
| `signal.update(fn)` | 이전 값으로 다음 값을 계산한 뒤 쓰기 |
| `scope.createEffect(ctx, fn)` | 변경 시 부수 효과 실행, Scope와 함께 해제 |

`effects.zig`

```zig
// Effect: sync a value to something outside the reactive graph.
try scope.createEffect(.{ .count = count }, struct {
    fn run(ctx: anytype) void {
        std.log.info("count is now {d}", .{ctx.count.get()});
    }
}.run);

// peek(): read without subscribing (no dependency is recorded).
const snapshot = count.peek();

// update(): read-modify-write in one call.
count.update(struct {
    fn inc(v: u32) u32 {
        return v + 1;
    }
}.inc);
```

## 경험 법칙

-   **사실 하나에 Signal 하나.** 같은 상태를 일반 필드와 Signal 양쪽에 두지 마십시오.
    
-   **계산 가능한 값은 Memo로.** 파생 필드를 핸들러마다 수동으로 동기화하지 마십시오.
    
-   **Effect는 동기화만.** Effect 자신의 의존성에 무조건 다시 쓰지 마십시오. 루프가 됩니다.
    
-   **수명은 Scope에 속합니다.** 페이지의 Scope가 해제되면 그 안의 Signal, Memo, Effect도 함께 해제됩니다.
    

> TIP
> 
> **정적 UI에는 반응성이 필요 없습니다.** 변하지 않는 텍스트와 구조는 그냥 마운트하십시오. 반응형 프리미티브는 변하는 데이터를 위한 것이지, 모든 노드를 감싸야 하는 래퍼가 아닙니다.
