본문으로 건너뛰기

FECONF2025 톺아보기

소개

FE conf 내용 정리

스벨트를 통해 리액트 더 잘 이해하기

미세한 반응성(Fine-Grained Reactivity), DOM 조작 패턴, 그리고 트랜지션까지

프론트엔드 코드가 커질수록 “필요한 부분만” 계산·업데이트하는 능력이 중요해집니다. 스벨트(Svelte)는 컴파일 타임에 미세한 반응성(Fine-Grained Reactivity, 이하 FGR)을 녹여 넣고, 리액트(React)는 런타임의 선언적 모델과 메모이제이션으로 최적화를 유도합니다. 이 글은 스벨트의 관점으로 리액트를 다시 바라보며, FGR의 원리를 직접 구현·응용해 보는 실전 가이드입니다.

리렌더링 철학의 차이

리액트: 상태 변경 → 컴포넌트 함수 재실행

리액트에서 useState의 값이 변하면 컴포넌트 함수 전체가 다시 호출됩니다. 필요한 부분만 실제 DOM 패치가 일어나도록 가상 DOM 비교와 메모이제이션이 도와주지만, 기본 규칙은 “다시 호출”이에요.

import { useState } from "react";

export default function Example() {
const [count, setCount] = useState(0);
const [name, setName] = useState("Lee");

return (
<div>
<div>count: {count}</div>
<div>name: {name}</div>

<button onClick={() => setCount((c) => c + 1)}>+1</button>
<button onClick={() => setName((n) => n + "!")}>rename</button>
</div>
);
}

여기서 count만 바꿔도 함수는 통째로 재실행됩니다.

스벨트: 사용된 곳만 업데이트 (컴파일러 주도 FGR)

스벨트는 컴파일 단계에서 “어떤 상태가 어디에서 쓰이는지”를 분석하여, 상태가 바뀔 때 그 상태를 참조한 DOM 조각만 갱신합니다. 스벨트 5의 룬(rune) 문법인 $state는 이 FGR을 아주 간단한 문법으로 노출합니다.

<script>
let count = $state(0);
let name = $state("Lee");
</script>

<div>count: {count}</div>
<div>name: {name}</div>

<button on:click={() => (count = count + 1)}>+1</button>
<button on:click={() => (name = name + "!")}>rename</button>

상태 count만 바뀌면 해당 바인딩 영역만 갱신됩니다. “컴파일러가 의존성을 추출해 놓았다”라고 이해하면 편합니다.

FGR란 무엇인가: 이펙트와 옵저버블의 뼈대

FGR(Fine-Grained Reactivity)는 값 단위로 의존성을 추적하고, 값이 바뀌면 그 값을 사용한 이펙트만 재실행하는 모델입니다.

핵심은 두 가지예요.

  1. 옵저버블 값: get()과 set()을 가진 값 컨테이너
  2. 이펙트: 실행 중 읽힌(get) 옵저버블을 자동 구독 → 그 값이 바뀌면 재실행

아래는 FGR의 최소 구현(컨셉 데모)입니다.

// ── 최소 FGR 런타임 ─────────────────────────────────────────────
const ACTIVE_EFFECT_STACK = [];

export function observable(initial) {
let value = initial;
const subscribers = new Set();

function get() {
const active = ACTIVE_EFFECT_STACK[ACTIVE_EFFECT_STACK.length - 1];
if (active) subscribers.add(active);
return value;
}

function set(next) {
if (Object.is(value, next)) return;
value = next;
subscribers.forEach((fn) => fn());
}

return { get, set };
}

export function effect(fn) {
const runner = () => {
try {
ACTIVE_EFFECT_STACK.push(runner);
fn(); // 실행 중에 읽힌(get) observable들이 이 이펙트를 자동 구독
} finally {
ACTIVE_EFFECT_STACK.pop();
}
};
runner();
return () => {
// 간단 버전: 구독 해제 생략(실전은 필요시 구현)
};
}

사용 예:

const count = observable(0);
const name = observable("Lee");

effect(() => {
console.log("count changed ->", count.get());
});

effect(() => {
console.log("name changed ->", name.get());
});

count.set(1); // count 이펙트만 실행
name.set("Kim"); // name 이펙트만 실행

이것이 스벨트/솔리드(Solid) 계열 반응성의 핵심 아이디어입니다. “그 값을 실제로 읽은 코드만 다시 돈다.”

스벨트의 $state와 FGR

스벨트 5의 $state는 위 원리를 컴파일 타임에 자동 적용합니다. 의존성 추적은 런타임 코드가 아니라 컴파일러가 생성한 코드로 이뤄지므로, 런타임 오버헤드가 낮은 게 강점이죠. 결과적으로 “문법은 간결, 동작은 세밀”이 됩니다.

스벨트 컴파일 결과를 뜯어보면(요약) count를 읽는 자리에 업데이트 슬롯이 삽입·연결되고, count = count + 1 같은 대입 시 해당 슬롯만 갱신합니다. 즉, 위 미니 런타임에서 했던 의존성 그래프 생성·통지를 컴파일된 코드가 직접 수행합니다.

리액트에서 FGR은 불가능할까? (Legend-State 등으로 응용)

리액트 세계에서도 신호형 값(Observable/Signal)과 선택적 구독을 도입하면 FGR에 가까운 모델을 만들 수 있습니다. 예를 들어, 아래는 아주 작은 신호(signal)를 만들고, 컴포넌트가 그 신호만 구독하도록 하는 패턴입니다.

// signal.ts
type Listener = () => void;

export function signal<T>(initial: T) {
let value = initial;
const subs = new Set<Listener>();

return {
get() {
return value;
},
set(next: T) {
if (Object.is(value, next)) return;
value = next;
subs.forEach((l) => l());
},
subscribe(l: Listener) {
subs.add(l);
return () => subs.delete(l);
},
};
}
// useSignal.ts
import { useSyncExternalStore, useMemo } from "react";

export function useSignal<T>(sig: {
get: () => T,
subscribe: (l: () => void) => () => void,
}) {
const getSnapshot = () => sig.get();
const subscribe = (l: () => void) => sig.subscribe(l);
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
// App.tsx
import { signal } from "./signal";
import { useSignal } from "./useSignal";

const count = signal(0);
const name = signal("Lee");

function Count() {
const c = useSignal(count); // count만 구독
return (
<div>
count: {c} <button onClick={() => count.set(c + 1)}>+1</button>
</div>
);
}

function Name() {
const n = useSignal(name); // name만 구독
return (
<div>
name: {n} <button onClick={() => name.set(n + "!")}>rename</button>
</div>
);
}

export default function App() {
return (
<>
<Count />
<Name />
</>
);
}

위 구조는 Count가 다시 그려져도 Name은 건드리지 않습니다. 반대로도 마찬가지. 즉, “상태 단위로 의존성을 구독하는” FGR의 이점을 리액트에서도 누릴 수 있습니다.

참고: React 생태계에는 이러한 아이디어를 품은 서드파티 라이브러리(예: Legend-State 등)가 존재하며, React/React Native/Expo 프로젝트에서도 신호형 상태를 적용하는 선택지가 있습니다. 팀의 규모·취향에 따라 도입을 검토해 보세요.

DOM 조작 패턴: 예시 “밖을 클릭하면 닫기”

(A) 전통적 훅 패턴

일반적으로는 useRef + useEffect + 전역 클릭 리스너로 구현합니다.

import { useEffect, useRef, useState } from "react";

function useOnClickOutside(ref, handler) {
useEffect(() => {
function onClick(e) {
if (!ref.current) return;
if (!ref.current.contains(e.target)) handler(e);
}
document.addEventListener("mousedown", onClick);
return () => document.removeEventListener("mousedown", onClick);
}, [ref, handler]);
}

export default function Dropdown() {
const ref = useRef(null);
const [open, setOpen] = useState(false);
useOnClickOutside(ref, () => setOpen(false));

return (
<div ref={ref}>
<button onClick={() => setOpen((v) => !v)}>toggle</button>
{open && <div className="menu">menu</div>}
</div>
);
}

단점: “DOM에 어떤 로직이 붙는지”컴포넌트 외곽으로 흩어져 있어 가독성이 떨어질 수 있어요.

(B) 스벨트: use: 디렉티브(액션)로 DOM에 직접 기능 부여

스벨트는 DOM 요소에 직접 “액션”을 붙입니다. 로직과 DOM이 한곳에 있어 직관적이에요.

<script>
export function clickOutside(node, handler) {
function onClick(e) {
if (!node.contains(e.target)) handler(e);
}
document.addEventListener('mousedown', onClick);
return {
destroy() {
document.removeEventListener('mousedown', onClick);
}
};
}

let open = $state(false);
</script>

<div use:clickOutside={() => (open = false)}>
<button on:click={() => (open = !open)}>toggle</button>
{#if open}<div class="menu">menu</div>{/if}
</div>

(C) 리액트에 액션 패턴 이식: ref 콜백을 이용하자

리액트의 ref객체(ref.current)만 받는 게 아니라 함수(콜백)도 받을 수 있습니다. 이 콜백은 DOM 생성/소멸 시점에 호출되므로 스벨트의 use: 액션과 유사한 패턴을 만들 수 있습니다.

// makeAction.tsx — 리액트용 "액션" 헬퍼
type Action<T extends Element> = (el: T) => void | (() => void);

export function makeAction<T extends Element>(action: Action<T>) {
let cleanup: (() => void) | undefined;

return (el: T | null) => {
// el이 마운트될 때
if (el && !cleanup) {
const ret = action(el);
cleanup = typeof ret === "function" ? ret : undefined;
}
// el이 언마운트될 때
if (!el && cleanup) {
cleanup();
cleanup = undefined;
}
};
}
// useOutside.tsx — 밖을 클릭하면 닫기
import { makeAction } from "./makeAction";

export function outside(action: (e: MouseEvent) => void) {
return makeAction<HTMLElement>((node) => {
const onClick = (e: MouseEvent) => {
if (!node.contains(e.target as Node)) action(e);
};
document.addEventListener("mousedown", onClick);
return () => document.removeEventListener("mousedown", onClick);
});
}
// Dropdown.tsx — 사용 예
import { useState } from "react";
import { outside } from "./useOutside";

export default function Dropdown() {
const [open, setOpen] = useState(false);
const attachOutside = outside(() => setOpen(false));

return (
<div ref={attachOutside}>
<button onClick={() => setOpen((v) => !v)}>toggle</button>
{open && <div className="menu">menu</div>}
</div>
);
}

이렇게 하면 DOM과 로직이 같은 위치에 있고, 생성/소멸 수명주기도 자연스럽게 처리됩니다. (React 19에서도 콜백 ref 패턴은 그대로 유효합니다.)

애니메이션/트랜지션: 등장·퇴장을 자연스럽게

리액트의 현실과 한계

리액트는 요소를 조건부 렌더링에서 false/null이면 즉시 제거합니다. 그래서 “퇴장 애니메이션 후 제거” 같은 동작에는 상태 지연이나 외부 라이브러리(Framer Motion 등)가 필요합니다.

스벨트: 내장 트랜지션이 기본 제공

스벨트는 transition: 지시어로 쉽게 씁니다.

<script>
import { fade, fly } from 'svelte/transition';
let open = $state(false);
</script>

<button on:click={() => (open = !open)}>toggle</button>

{#if open}
<div transition:fade>간단 페이드</div>
<div transition:fly={{ y: 10, duration: 150 }}>슬쩍 이동</div>
{/if}

커스텀 트랜지션도 함수로 만들 수 있어요. 함수는 (node, params)를 받아 css 또는 tick(매 프레임 호출)을 반환합니다.

<script>
function grow(node, { duration = 200 } = {}) {
const h = node.offsetHeight;
return {
duration,
css: (t) => `height: ${t * h}px; overflow: hidden;`
};
}
let open = $state(false);
</script>

<button on:click={() => (open = !open)}>toggle</button>
{#if open}
<div transition:grow>늘어나는 박스</div>
{/if}

리액트에서 “퇴장 후 제거” 구현: Presence 훅(Web Animations API 버전)

외부 의존성을 줄인 소형 훅입니다. 나갈 때 애니메이션끝나면 unmount 흐름을 처리해요.

// usePresence.ts
import { useEffect, useRef, useState } from "react";

type KeyframesOrFactory =
| Keyframe[]
| PropertyIndexedKeyframes
| ((el: Element, mode: "in" | "out") => Keyframe[]);

export function usePresence(
show: boolean,
keyframes: KeyframesOrFactory,
options?: KeyframeAnimationOptions
) {
const [present, setPresent] = useState(show); // 실제로 렌더할지
+ const modeRef = useRef<"in" | "out">("in");
+ const elRef = useRef<HTMLElement | null>(null);

useEffect(() => {
if (show) setPresent(true);
modeRef.current = show ? "in" : "out";
}, [show]);

useEffect(() => {
const el = elRef.current;
if (!el) return;
const frames =
typeof keyframes === "function"
? keyframes(el, modeRef.current)
: keyframes;
const anim = el.animate(frames, {
duration: 180,
easing: "ease-out",
fill: "both",
...options,
});
if (modeRef.current === "out") {
anim.onfinish = () => setPresent(false);
}
return () => anim.cancel();
}, [present, show, keyframes, options]);

const ref = (node: HTMLElement | null) => {
elRef.current = node;
};

return { present, ref };
}
// FadeSlide.tsx — 사용 예
import { useState } from "react";
import { usePresence } from "./usePresence";

export default function Demo() {
const [open, setOpen] = useState(false);
const { present, ref } = usePresence(
open,
(el, mode) =>
mode === "in"
? [
{ opacity: 0, transform: "translateY(6px)" },
{ opacity: 1, transform: "translateY(0)" },
]
: [
{ opacity: 1, transform: "translateY(0)" },
{ opacity: 0, transform: "translateY(6px)" },
],
{ duration: 200 }
);

return (
<div>
<button onClick={() => setOpen((v) => !v)}>toggle</button>
{present && (
<div ref={ref} style={{ willChange: "opacity, transform" }}>
부드러운 등장/퇴장
</div>
)}
</div>
);
}

리액트에서도 DOM 생성/소멸 시점을 잡아 적절한 애니메이션을 적용하면 스벨트 내장 트랜지션과 거의 같은 UX를 낼 수 있습니다. (규모가 커지면 Framer Motion, React Transition Group 등도 실전에서 훌륭한 선택지예요. 다만 위처럼 “핵심 로직을 직접 이해한 뒤” 라이브러리를 쓰는 것이 유지보수에 유리합니다.)

메모 컴포넌트 패턴에 FGR 접목

리액트에서 memo, useMemo, useCallback불필요한 재실행을 줄이는 기본 도구입니다. 여기에 신호형 상태(위 signal 예시)를 결합하면, “컴포넌트는 작게 쪼개고, 각 컴포넌트는 필요한 신호만 구독”하는 구조를 손쉽게 만들 수 있습니다.

import { memo } from "react";
import { signal } from "./signal";
import { useSignal } from "./useSignal";

const price = signal(100);
const qty = signal(1);

const Total = memo(function Total() {
const p = useSignal(price);
const q = useSignal(qty);
return <div>Total: {p * q}</div>;
});

const Qty = memo(function Qty() {
const q = useSignal(qty);
return <button onClick={() => qty.set(q + 1)}>+1</button>;
});

export default function Cart() {
return (
<>
<Total />
<Qty />
</>
);
}

이 패턴은 의존성 단위로만 다시 그림을 보장합니다. 렌더 트리 규모가 커질수록 차이가 눈에 띱니다.

마무리: 스벨트로 보는 리액트, 리액트로 배우는 스벨트

  • 스벨트는 컴파일러가 FGR을 자동 생성하므로, 문법이 간결하고 기본이 빠릅니다.

  • 리액트는 런타임 선언 모델을 취하지만, 신호형 상태 + 선택적 구독 + 콜백 ref 같은 패턴을 도입하면 FGR의 이점을 상당 부분 흉내 낼 수 있습니다.

  • DOM 액션 패턴, 등장/퇴장 트랜지션도 “생성/소멸 시점”과 “의존성 단위”를 잘 잡아주면 두 프레임워크 모두에서 일관된 설계를 만들 수 있습니다.

참고

스벨트의 FGR 감각을 유지한 채 리액트/리액트 네이티브/Expo 프로젝트에서도 신호형 상태를 쓰고 싶다면, Legend-State처럼 신호·스토어·파생값을 제공하는 경량 라이브러리를 검토해 보세요. 팀 합의와 DX(디버깅, DevTools)도 함께 고려하는 것이 좋습니다.

부록: 새 useOutside(콜백 ref 기반) – 최종 형태

// useOutside.tsx
import { useCallback } from "react";

type Options = {
event?: "mousedown" | "click" | "pointerdown";
enabled?: boolean;
};

export function useOutside(onOutside: (e: Event) => void, opts: Options = {}) {
const { event = "mousedown", enabled = true } = opts;
const nodeRef = useRef<HTMLElement | null>(null);
const handlerRef = useRef(onOutside);
useEffect(() => {
handlerRef.current = onOutside;
}, [onOutside]);

useEffect(() => {
if (!enabled) return;
const onEvt = (e: Event) => {
const node = nodeRef.current;
if (!node) return;
if (!node.contains(e.target as Node)) handlerRef.current(e);
};
document.addEventListener(event, onEvt);
return () => document.removeEventListener(event, onEvt);
}, [enabled, event]);

return useCallback((node: HTMLElement | null) => {
nodeRef.current = node;
}, []);
}
// 사용
function Menu() {
const [open, setOpen] = useState(false);
const ref = useOutside(() => setOpen(false), { event: "pointerdown" });

return (
<div ref={ref}>
<button onClick={() => setOpen((v) => !v)}>toggle</button>
{open && <div className="menu">menu</div>}
</div>
);
}

'memo'를 지울결심: React Compiler가 제안하는 미래

“언제 memo, useMemo, useCallback을 써야 하죠?”
React Compiler의 대답은 간단합니다. “대부분의 경우, 신경 쓰지 마세요.”

React Compiler란?

  • 초기 React Forget으로 연구가 시작되어, React Conf 2024 무대에서 정식으로 공개된 컴파일러 기반 최적화입니다.
  • 목표는 수동 메모이제이션 걱정을 줄이고, React의 룰을 잘 지키면 컴파일러가 자동으로 연산을 캐싱/스킵하게 만드는 것입니다.

리액트의 기본 특성 다시 보기

부모가 리렌더되면, 자식도 리렌더된다

리액트는 상태 변경 시 컴포넌트 함수가 재호출됩니다. 부모가 다시 실행되면 자식도 다시 실행되는 것이 기본 규칙이죠.

그래서 지금까지는 수동 메모이제이션을 했다

memo, useMemo, useCallback으로 불필요한 재실행재생성을 막아왔습니다. 하지만 “어디에, 어느 정도로 쓰느냐”가 항상 고민거리였죠

모든 곳에 다 쓰면 좋을까? → No

  • 오버헤드(의존성 배열 관리, 코드 부피 증가)
  • 가독성 저하(본질 로직이 최적화 코드에 파묻힘)
  • 버그 위험(의존성 누락/과잉)

수동 메모이제이션의 한계 → React Compiler 등장

React Compiler는 코드를 분석하여 자동으로 메모이제이션 지점을 삽입합니다. 즉, 개발자는 React의 규칙(Rules of React)을 지키며 로직을 쓰면 되고, 최적화는 컴파일러가 책임지는 방향으로 진화합니다.

컴파일러는 실제로 무엇을 하나?

아래 코드는 이해를 위한 의사 코드입니다(실제 내부 구현과 다를 수 있음).

(1) 캐시 슬롯을 만드는 내부 훅: useMemoCache(size)

컴파일러는 컴포넌트마다 “필요한 캐시 슬롯 개수”를 계산해 두고, Fiber 노드에 배열 형태로 저장합니다.

// 원본 (개념)
function Text({ color }) {
const style = { color }; // 매 렌더마다 객체 생성
return <span style={style}>Hello</span>;
}
// 컴파일 후 의사 코드
function Text_compiled({ color }) {
const $ = useMemoCache(1); // 캐시 슬롯 1개 확보
let style;

// 첫 렌더: 캐시 미존재 → 계산 후 저장
if ($[0] === undefined || $[0].**key !== color) {
style = { color };
$[0] = { **key: color, **val: style }; // 심볼/키로 동일성 추적
} else {
style = $[0].\*\*val; // 캐시 재사용
}

return <span style={style}>Hello</span>;
}

핵심 아이디어:

  • 리액티브 값(시간에 따라 변할 수 있는 값)을 기준으로 동일성 체크 키를 만들고, 변하지 않았다면 재계산/재생성을 건너뜀.

(2) 조건 분기에서도 분기별 캐시를 둔다

// 원본
function Badge({ variant }) {
if (variant === "success") {
const s = { background: "green", color: "white" };
return <span style={s}>OK</span>;
} else {
const s = { background: "gray", color: "black" };
return <span style={s}>NG</span>;
}
}
// 컴파일 후 의사 코드
function Badge_compiled({ variant }) {
const $ = useMemoCache(2); // 각 분기에 1칸씩
if (variant === "success") {
if ($[0] === undefined) $[0] = { background: "green", color: "white" };
return <span style={$[0]}>OK</span>;
} else {
if ($[1] === undefined) $[1] = { background: "gray", color: "black" };
return <span style={$[1]}>NG</span>;
}
}

분기 전환이 일어나지 않는 한,** 각 분기에서 한 번 만든 객체를 계속 재사용**합니다.

(3) 부모 상태 추가 → 자식 재계산? 컴파일러가 막아준다

function App() {
const [count, setCount] = useState(0);
return (
<>
<button onClick={() => setCount((c) => c + 1)}>{count}</button>
<Text color="tomato" /> {/_ 여기 안의 style 계산을 스킵 _/}
</>
);
}

부모가 리렌더되어도 Text의 리액티브 키(여기선 color)가 변하지 않으면, 컴파일러는 자식 내부의 비싼 연산/생성을 자동으로 스킵하도록 변환합니다.

“컴파일러”의 분석 파이프라인 한눈에

“컴파일이냐 트랜스파일이냐?” 용어는 중요하지 않습니다. 핵심은 고수준 React 코드를 분석하여 저수준 형태(HIR 등)로 바꿔 최적화 정보를 주입한 뒤 다시 JS로 방출한다는 점.

  1. 파싱(AST) Babel 등으로 소스 코드를 AST(Abstract Syntax Tree)로 변환.

  2. HIR(High-level IR) 작성 AST가 구문 구조라면, HIR은 실행 흐름 단위(블록/엣지)를 표현.

  • 블록: 실행 단위
  • 엣지: 블록 간 제어 흐름
  1. SSA(Static Single Assignment)

    • 변수에 단 한 번만 할당되도록 재작성(버저닝).
    • 재할당을 버전으로 관리하면 데이터 흐름 분석이 쉬워짐.
  2. Reactive 분석

  • “시간에 따라 변할 수 있는 값(리액티브 값)”을 식별:
    • props/함수 파라미터(부모가 언제든 바꿀 수 있음)
    • 훅 호출 결과(useState, useContext, useSelector 등)
    • 전역/외부 상태 접근 값
    • 위에서 파생된 값(계산 결과)
  1. Reactive 스코프 형성
  • 리액티브 값의 영향 범위(스코프)를 그룹화.
  • 각 스코프 경계에 캐시 슬롯과 키 비교 로직을 배치.
  1. JS로 방출
  • 내부 HIR/스코프 정보를 반영하여 컴파일된 React 코드를 출력.
  • 결과적으로 개발자가 직접 쓰지 않아도 “조건부/분기별 캐싱”이 자동 삽입됨.

개발자 입장에서 느껴질 변화

✅ 좋은 소식

  • “여기 useMemo 필요할까?” 고민이 크게 줄어듦
  • 분기별 캐시처럼 사람이 손으로 하기 번거로운 최적화를 자동으로

⚠️ 그래도 지켜야 할 것(낙관적 가정의 비용)

컴파일러는 React의 규칙(Rules of React)이 지켜진다는 낙관적 가정 하에 동작합니다. 규칙 위반(순수성 깨짐, 랜더 중 부수효과, 훅 순서 변경 등)은 에러가 아닌 “오동작”으로 나타날 수 있어요.

  • 대처 방법
    • ESLint + React 플러그인을 켜고, 경고를 없앨 것
    • 부수효과는 useEffect로 옮기고, 렌더는 순수하게
    • 안정성 보강 도구: react-forgive(experimental) — LSP 기반으로 컴파일 추론을 노출/보조

📦 번들 크기 주의

  • 컴포넌트 단위로 변환 코드가 추가됩니다.
  • 리액티브 값/분기가 많을수록 코드량이 증가할 수 있습니다.
  • 간단 예제에서 2.28배 늘어난 사례가 보고되지만(예시), 실제 수치는 코드베이스/코드스플리팅 여부에 따라 다릅니다. → 코드스플리팅과 불필요한 리액티브 값 최소화가 여전히 중요.

새 멘탈 모델: React as a Language

React를 “라이브러리”라기보다 언어처럼 생각하면 컴파일러와 궁합이 좋아집니다.

  1. 룰을 엄격히 준수
  • 렌더는 순수 함수처럼: 동등 입력 → 동등 출력
  • 훅은 탑레벨에서 동일 순서로 호출
  • 부수효과는 Effect 계층으로
  1. “변하는 값”을 선명히 파악
  • 무엇이 리액티브인지(언제/어디서 바뀌는지) 의식적으로 구분
  • 변하지 않는 값은 상수/모듈 상단 등으로 탈리액티브화.
  1. 조건 분기 = 스코프 분리
  • if (variant === 'a')처럼 분기가 명확하면, 각 분기에서 별도 캐시가 자리 잡아 이득을 봄.

실전 예제로 보는 “값의 변화 흐름”과 캐시

(A) “직전값과 비교”가 핵심

function ColorText({ color }) {
const style = { color }; // 컴파일러가 color를 키로 캐시
return <span style={style}>Hi</span>;
}
  • color가 같으면 style 재생성은 스킵.

(B) 순회/정렬 같은 비싼 연산도 자동 스킵

function List({ items, sort }) {
// sort가 바뀌지 않으면 정렬 결과 캐시를 재사용
const sorted = [...items].sort(sort);
return (
<ul>
{sorted.map((i) => (
<li key={i.id}>{i.name}</li>
))}
</ul>
);
}

(C) 분기별로 “캐시 하나씩”

function PriceTag({ currency, amount }) {
if (currency === "KRW") {
const text = new Intl.NumberFormat("ko-KR").format(amount);
return <span>{text}</span>;
} else {
const text = new Intl.NumberFormat("en-US", {
style: "currency",
currency,
}).format(amount);
return <span>{text}</span>;
}
}
  • KRW 분기에서 만든 text는 KRW 분기에 캐시,
  • USD/JPY 등 다른 분기는 각각의 캐시를 가짐.

(D) 리액티브 값의 중첩과 캐시 전략

  1. 하나의 캐시에 몰아넣는 경우:
  • “들어올 값 종류를 예측하기 어렵다”면 범용 캐시 1개로, **키(파생값 조합)**로 구분.
  1. 분기별로 쪼개는 경우:
  • if/else, switch 등 스코프가 명확하면 분기마다 캐시 슬롯을 갖게 되어 더 미세하게 스킵.

모두를 위한 웹 접근성: 무엇이고, 어떻게 하나요?

웹 접근성은 모든 사람웹 콘텐츠를 의미 있고 동등하게 인식·조작·이해·활용할 수 있도록 하는 설계 원칙입니다. 팀 버너스-리의 말처럼, 웹의 힘은 보편성에 있습니다. 접근성은 보안·성능과 동급의 필수 품질 속성이에요.

“디지털 접근성은 사용자의 정신적 또는 신체적 능력과 관계없이 웹사이트와 앱을 동등하게 상호작용하도록 제품을 설계·구현하는 것.” — web.dev, 접근성 원칙

1) 우리는 이미 접근성을 고려하고 있다

  • 제품 기획에서 “무슨 정보를 제공할지”를 정의하는 순간부터 접근성은 시작됩니다.
  • “사용자가 정보에 접근할 수 있는가”는 접근성의 핵심 성공 기준 중 하나.
  • 접근성은 사용자 중심 디자인의 또 다른 시각이며, 이를 간과하는 건 “보안을 나중에 하자”와 같습니다.

2) 때론 심미성보다 ‘정보 접근성’이 우선

어떻게 보일지(심미성)보다 무엇을 보여주는가(정보 접근성)가 먼저입니다.

예를 들어 열차 목록을 티켓형 디자인으로 바꾸어 한 화면 표시 수가 6 → 3개로 줄면:

  • 동일 정보 확인을 위해 스크롤·탐색 부담이 증가(인지 부하 ↑)
  • 탐색 효율 저하, 비교 난이도 상승
  • 결과적으로 사용성이 나빠짐 → 보기 좋은 UI가 쓰기도 좋아야 합니다.

3) 접근성 원칙: POUR

Perceivable(인지) · Operable(조작) · Understandable(이해) · Robust(견고)

  • 인지: 대체 텍스트, 자막, 과도한 모션 억제, 고배율/고대비 지원
  • 조작: 키보드·스크린리더 등 다양한 입력/보조기기로 조작 가능, 포커스 순서 버그 없음
  • 이해: 예측 가능한 인터랙션, 직관적 정보 구조
  • 견고: 다양한 기기·브라우저·보조공학 환경에서도 일관 동작

4) 접근성을 준수하는 방법

4.1 시맨틱 마크업이 기본

  • <button>, <nav>, <main>, <ul>, <li> 등 의미 있는 HTML을 올바르게 사용하세요.
  • 시맨틱만 잘 써도 접근성의 큰 부분이 해결됩니다.

4.2 WAI-ARIA는 보완

  • 시맨틱만으로 어려운 리치 UI(예: 콤보박스, 탭, 모달)는 WAI-ARIA로 보완합니다.
  • ARIA = 역할(Role), 상태(State), 속성(Property)
    • 역할(Role): 요소의 의미(예: role="switch", role="listbox" 등)
    • 상태(State): UI의 현재 상태(예: aria-expanded, aria-selected)
    • 속성(Property): 이름/설명/관계(예: aria-labelledby, aria-describedby, aria-controls, aria-live)

원칙: Semantic HTML First → 필요할 때만 ARIA. “Bad ARIA is worse than No ARIA(잘못된 ARIA는 없는 것보다 해롭다)”

5) ARIA 기초·패턴 한눈에

5.1 역할(Role) 예시

  • 내장 역할: <button> → 암묵적 role="button"
  • 커스텀 스위치:
    <button role="switch" aria-checked="false">다크 모드</button>

5.2 상태(State) 예시

  • 펼침 상태: aria-expanded(버튼/리스트박스/탭 등)
  • 선택 상태: aria-selected(옵션/탭 등)

5.3 속성(Property) 예시

  • 관계 지정: aria-controls(트리거 ↔ 대상), aria-labelledby(이름), aria-describedby(설명)
  • 가상 포커스: aria-activedescendant
  • 실시간 읽기: aria-live
  • 계층: aria-level

5.4 콤보박스(리스트박스) 예시

<h2 id="brand-label">신발 브랜드</h2>

<div
role="combobox"
aria-expanded="true"
aria-labelledby="brand-label"
aria-controls="brand-list"
>
<input
type="search"
aria-autocomplete="list"
aria-activedescendant="opt-nike"
value="나이"
/>
</div>

<ul role="listbox" id="brand-list">
<li role="option" id="opt-nike" aria-selected="true" tabindex="0">나이키</li>
<li role="option" id="opt-nb" aria-selected="false" tabindex="-1">
뉴발란스
</li>
<li role="option" id="opt-ske" aria-selected="false" tabindex="-1">
스케처스
</li>
</ul>
  • 래퍼에 role="combobox"
  • 입력에 aria-autocomplete, 가상 포커스(aria-activedescendant)
  • listbox/option 역할 지정, Roving tabindex(0/-1)로 방향키 내 이동
  • 실제 포커스는 입력에 두고, 활성 항목만 스크린리더에 알려주는 패턴

5.5 폼 오류(실시간 알림) 예시

<Tooltip enabled="{invalidPrice}" placement="top">
<span id="price-error" slot="message" aria-live="assertive">
숫자만 입력하세요
</span>
<input
name="price"
aria-invalid="{invalidPrice}"
aria-describedby="price-error"
inputmode="numeric"
/>
</Tooltip>

<style>
input[name="price"][aria-invalid="true"] {
border: 1px solid #dc2626; /* red-600 */
background-color: rgba(220, 38, 38, 0.08);
}
</style>
  • aria-invalid: 오류 상태
  • aria-describedby: 오류 설명 연결
  • aria-live="assertive": 즉시 읽기

Tailwind를 쓴다면 aria-* 변형자(variants) 활용 가능: 예) .aria-[invalid='true']:(border-red-600 bg-red-50)

6) “열림/닫힘”을 ARIA로 주도하기(스타일도 깔끔해짐)

6.1 클래스 토글 방식(기존)

<button type="button" class="expanded" onclick="isOpen = !isOpen">
폰트 선택 <i class="chevron-icon"></i>
</button>
<ul role="listbox" hidden>
...
</ul>
button.expanded {
background: #eee;
}
button.expanded > .chevron-icon {
transform: rotate(0.5turn);
}
  • 단점: 접근성 상태와 스타일 상태를 클래스로 이중 관리

6.2 ARIA 주도 방식(추천)

<button type="button" aria-expanded="true" onclick="isOpen = !isOpen">
폰트 선택 <i class="chevron-icon"></i>
</button>
<ul role="listbox">
...
</ul>
button[aria-expanded="true"] {
background: #eee;
}
button[aria-expanded="true"] > .chevron-icon {
transform: rotate(0.5turn);
}
button[aria-expanded="true"] + ul[role="listbox"] {
display: block;
}
  • 장점: 한 개의 접근성 표식(ARIA)로 보조공학·스타일 모두 일관 관리
  • 주의: ARIA는 원래 스타일링 용도가 아니지만, 의미론을 훼손하지 않는 선에서 실용적으로 활용 가능

※ “클래스네임은 의미를 담지 않는다” — HTML 의미(역할/상태)는 시맨틱/ARIA로 표현하고, 스타일은 이를 참조하는 형태가 바람직합니다.

7) 초점 흐름 설계 🎯

7.1 탭 시퀀스 원칙

  • 복합 위젯(콤보박스/탭/메뉴)은 탭으로 한 덩어리씩만 이동
  • Tab / Shift+Tab은 위젯 간 이동에 사용

7.2 위젯 내 초점 이동(방향키)

  • 위젯 내부 항목 간 이동은 방향키
  • Roving tabindex: 선택 항목만 tabindex="0", 나머지 -1
  • 위젯을 빠져나갈 땐 Tab으로 다음 입력으로

(위 5.4 코드 예시가 Roving tabindex를 적용한 형태)

8) Headless UI 적극 활용

접근성 표준에 맞춘 리치 UI를 매번 직접 구현하기는 비용이 큽니다. Headless UI는 스타일을 배제하고 접근성 로직을 제공하는 툴킷입니다.

  • Ark UI(Chakra 팀): React/Solid/Vue/Svelte 등 멀티 프레임워크
  • React Aria(Adobe): 다양한 위젯의 접근성 로직
  • Melt UI(Svelte 커뮤니티): Svelte 친화적 패턴

설사 접근성을 이유로 도입하지 않더라도, 보일러플레이트 감축과 DI 유연성 측면에서 큰 이점이 있습니다.

9) 접근성으로 ‘테스트’가 더 좋아진다

9.1 접근성 트리(AOM)

  • 브라우저는 DOM과 1:1로 접근성 객체 모델(AOM)을 구축
  • 역할/이름/상태 중심이라, 사용자가 실제로 “접근 가능한 정보”만을 관찰하기 좋음

9.2 Playwright의 ARIA 스냅샷(권장)

기존 Assertion(요소 하나씩 쿼리) 대신, 접근성 트리 스냅샷으로 비교하면 명세가 간결해집니다.

import { test, expect } from "@playwright/test";

test("Banner contains expected elements", async ({ page }) => {
await page.goto("https://playwright.dev/");
await expect(page.getByRole("banner")).toMatchAriaSnapshot(`
- banner:
- heading /Playwright enables reliable end-to-end/ [level=1]
- link "Get started"
- link "Star microsoft/playwright on GitHub"
- link /[\\d]+k\\+ stargazers on GitHub/
`);
});

Testing Library도 getByRole, getByLabelText 등 접근성 우선 쿼리를 권장합니다.

완전한 ZERO COST CSS-IN-JS, Devup-UI

React Server Components와 CSS-in-JS

CSS-in-JS는 한때 리액트 생태계에서 가장 인기 있는 스타일링 기법이었습니다. styled-components, Emotion 등을 사용하면 컴포넌트 단위 스타일링, 동적 props 기반 스타일, 테마 관리를 간편하게 구현할 수 있었죠.

하지만 **React Server Components(RSC)**의 등장 이후 이야기가 달라졌습니다.

  • RSC 환경에서는 클라이언트 측 런타임 로직을 최소화해야 성능을 극대화할 수 있습니다.
  • CSS-in-JS 라이브러리들은 대부분 런타임에 스타일을 생성하기 때문에 서버 중심 렌더링 모델과 잘 맞지 않습니다.

즉, 서버 컴포넌트의 이점을 살리려면 CSS-in-JS 사용은 제약이 생길 수밖에 없는 구조입니다.

“use client”와 근본적 한계

많은 팀들이 RSC와 CSS-in-JS를 함께 쓰기 위해 "use client" 지시어를 추가하는 방법을 사용합니다.

그러나 이는 단순히 클라이언트 컴포넌트로 강제 변환하는 방식일 뿐, 런타임에서 스타일을 주입하는 특성은 그대로 남아있습니다.

결과적으로 성능 최적화의 이점은 희석되고, 서버 중심 아키텍처의 장점도 살리지 못합니다.

Zero-runtime CSS의 부상

이런 흐름 속에서 Zero-runtime CSS 솔루션이 주목받고 있습니다. 대표적인 예시로는 Kuma UI, Panda CSS가 있습니다.

Kuma UI

  • 런타임이 아닌 빌드 타임에 스타일을 생성
  • RSC 친화적: 불필요한 클라이언트 코드 제거
  • 직관적인 컴포넌트 API 제공 (styled-components 문법과 유사)

Panda CSS

  • Utility-first 방식과 Theme 기반 설계를 동시에 지원
  • Tailwind와 유사하지만 런타임이 없는 정적 CSS 출력
  • 타입 안전성과 디자인 토큰 관리에 강점

CSS-in-JS에서 배운 것, 그리고 앞으로

CSS-in-JS는 여전히 강력합니다. 동적 스타일링과 개발자 경험(DX) 측면에서는 많은 장점을 제공합니다. 그러나 RSC와 서버 우선 패러다임에서 런타임 비용을 최소화하는 방향이 중요해졌습니다.

TanStack Query 너머를 향해! 쿼리를 라우트까지 전파시키기

RSC 다시 보기

React Server Components(RSC)는 서버 자원을 효과적으로 사용하면서, 불필요한 클라이언트 코드 전송을 줄이는 새로운 아키텍처입니다.

  • 클라이언트까지 도달하지 않는 컴포넌트를 만들어 번들 크기를 줄임
  • 서버 환경에서 동작하므로 DB 쿼리, API 호출 등 서버 전용 자원을 직접 활용 가능
  • 내부에서 async/await 같은 비동기 연산을 직접 사용
  • 자식 요소로는 클라이언트 컴포넌트를 포함할 수 있어 유연하게 조합 가능

클라이언트 미도달과 전통적 SSR 비교

과거의 SSR(Spring, RoR, Flask 등)은 서버에서 HTML을 미리 렌더링 → 클라이언트에서 하이드레이션하는 구조였습니다. RSC는 다릅니다.

  • 서버와 클라이언트의 경계를 다시 분리
  • 서버만 알고 있는 정보를 클라이언트에 불필요하게 노출하지 않고 서버 컴포넌트에서만 처리

즉, 기존 isomorphic(서버=클라이언트 동일 환경) 접근과 달리, 이제는 서버와 클라이언트의 역할을 명확히 구분합니다.

컴포넌트 단위의 데이터 패칭

Next.js의 getServerSideProps, getStaticProps 같은 함수들은 특정 프레임워크에 종속적이었습니다. RSC에서는 이를 넘어서, 모든 React 프레임워크에서 컴포넌트 단위로 SSG/SSR/ISR을 구현할 수 있게 됩니다.

특징

  • 컴포넌트 안에서 바로 await fetch() 가능
  • Promise.all() 같은 동시 비동기 연산을 hooks 없이 자유롭게 활용 가능 즉, 더 이상 useEffect와 같은 클라이언트 훅에 의존할 필요가 없음

Rule of Hooks는 이제 없다 서버 컴포넌트에서는 useQuery, useEffect 같은 훅 대신, 단순히 await으로 데이터를 가져오면 됩니다.

Tanstack Query와 RSC

Tanstack Query는 여전히 클라이언트 상태 관리 + 데이터 캐싱에 강력합니다. 하지만 서버에서 데이터를 바로 불러올 수 있는 RSC 환경에서는 새로운 패턴이 필요합니다.

  • 기존: 클라이언트에서 useQuery 훅을 통해 데이터 요청 후 캐싱
  • 이후: 서버 컴포넌트에서 데이터 요청 → 라우터 레벨까지 데이터 전파

즉, RSC 시대에는 쿼리가 클라이언트 훅에 머무르지 않고 라우팅 체계와 함께 전파될 수 있습니다.

Advanced Server Rendering: 한 단계 더

고급 서버 렌더링 패턴에서는:

  • 서버 컴포넌트에서 비동기 데이터를 패칭
  • 라우터를 통해 해당 데이터를 클라이언트까지 자연스럽게 전달
  • 클라이언트 쪽에서는 Tanstack Query 같은 라이브러리가 이 데이터를 재활용하거나 보강

결국, 서버에서 데이터를 가져오고, 라우터와 클라이언트 상태 관리가 이를 매끄럽게 연결하는 아키텍처가 가능해집니다.