리액트의 추상화는 왜 오해되고 있는가 — 함수형 철학으로 다시 보는 선언형 UI

💡 리액트에서의 추상화 — 선언형 환상에서 함수형 진리로

"리액트는 선언형이다" — 하지만 선언형은 곧 추상화일까?

많은 개발자들이 이 문장에 함정처럼 빠진다.

리액트의 진정한 힘은 단지 선언형 표현이 아니라, 함수형 추상화를 통해 의미의 계층을 형성하는 데 있다.


1. 추상화는 감추는 게 아니라, '관점을 고정하는 것'

대부분의 개발자가 추상화를 "복잡함을 숨기는 기술"로 생각한다.

하지만 함수형 프로그래밍의 세계에서 추상화란 "평가를 제한하는 것"(언제, 어떻게 실행될지 통제하는 것)이다.

즉, 코드가 오직 특정한 의미 체계 안에서만 작동하도록 만드는 일이다.

<button onClick={() => doSomething()}>확인</button>

이건 단순히 브라우저의 DOM 이벤트를 처리하는 명령형 UI다.

리액트가 이를 JSX로 감쌌다고 해서 추상화된 것은 아니다.

onClick이라는 이벤트가 무엇을 의미하는가를 다시 정의할 때, 비로소 추상화가 시작된다.

FP에서 추상화란 "구조를 감추는 것"이 아니라 "의미를 드러내는 것"이다.

  1. 잘못된 추상화 — 선언형 안에 숨은 '명령형의 그림자' 다음 코드를 보자.
function SearchInput({ value, onChange }) {
  return <input value={value} onChange={onChange} />;
}

겉보기엔 "선언형" 컴포넌트처럼 보이지만, 이건 단지 DOM 이벤트를 Props로 전달하는 위임일 뿐이다.

즉, "이 입력 필드가 무엇을 의미하는가"가 코드에 드러나지 않는다.

리액트는 선언형 UI로 "상태 → 화면"의 매핑은 잘 표현하지만,

그 행위의 의미까지는 추상화하지 않는다.

이 부분을 놓치는 순간, JSX는 다시 명령형 이벤트의 포장지로 되돌아간다.

  1. 함수형 추상화 — "효과를 제거하지 말고, 통제하라" 리액트의 철학은 다음처럼 표현할 수 있다.

UI ≈ f(state) 이는 함수형 프로그래밍의 선언이다.

UI는 상태에 대한 순수 함수처럼 다뤄지고, 부수효과는 관리 가능한 경계 밖으로 격리되어야 한다.

아래 예시는 흔한 "불완전한 추상화"의 예다.

function SearchBox({ onSearch }) {
  const [text, setText] = useState("");
 
  return (
    <div>
      <input value={text} onChange={(e) => setText(e.target.value)} />
      <button onClick={() => onSearch(text)}>검색</button>
    </div>
  );
}

이 컴포넌트는 UI를 잘 조합하지만, 여전히 명령 실행의 의미가 드러나지 않는다.

onSearch는 단순한 콜백이지만, "언제, 무엇을 검색하는가"를 의미 영역으로 추상화하지 못했다.

이를 FP 스타일로 바꿔보자.

function useSearch(initialQuery = "") {
  const [query, setQuery] = useState(initialQuery);
  const [results, setResults] = useState(null);
  const [isLoading, setIsLoading] = useState(false);
 
  const search = useCallback(() => {
    // 효과는 명령(Command)으로 캡슐화되어, 호출 시점이 제어됨
    setIsLoading(true);
    return fetch(`/api/search?q=${query}`)
      .then((r) => r.json())
      .then(setResults)
      .finally(() => setIsLoading(false));
  }, [query]);
 
  return { query, setQuery, search, results, isLoading };
}
 
function SearchBox() {
  const { query, setQuery, search, results, isLoading } = useSearch();
 
  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <button onClick={search}>검색</button>
 
      {isLoading && <Spinner />}
      {results && <SearchResults data={results} />}
    </div>
  );
}

이제 이 구조는 UI는 순수 함수로, 효과는 명령으로 분리됐다.

사이드 이펙트가 즉시 실행되지 않고 함수로 감싸져 있어, 실행 시점을 외부에서 제어할 수 있다.

즉, 단순 리팩터링이 아니라 평가 시점을 제어하는 함수형 추상화의 구현이다.

React Query를 사용하면 이 패턴을 더욱 선언적으로 표현할 수 있다.

function useSearch(query) {
  return useQuery({
    queryKey: ["search", query],
    queryFn: () => fetch(`/api/search?q=${query}`).then((r) => r.json()),
    enabled: query.length > 0, // 평가 조건을 선언적으로 제어
  });
}
 
function SearchBox() {
  const [query, setQuery] = useState("");
  const { data, isLoading } = useSearch(query);
 
  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
 
      {isLoading && <Spinner />}
      {data && <SearchResults data={data} />}
    </div>
  );
}

이제 효과의 실행 조건까지 선언적으로 제어되고,

컴포넌트는 순수한 UI 함수를 유지한다.

  1. 추상화의 척도 — 합성 가능성 FP에서 좋은 추상화는 "합성 가능한 구조"(다른 함수와 조합했을 때 의미가 보존되는 것)로 평가된다.

현대 리액트에서는 Custom Hook과 Suspense가 이를 가능하게 한다.

function LoadingBoundary({ children }) {
  return (
    <Suspense fallback={<Spinner />}>
      <ErrorBoundary fallback={<ErrorMessage />}>{children}</ErrorBoundary>
    </Suspense>
  );
}
 
// 사용처에서 로딩 상태를 직접 신경쓰지 않음
function UserProfile({ userId }) {
  const user = use(fetchUser(userId));
  return <div>{user.name}</div>;
}
 
function App() {
  return (
    <LoadingBoundary>
      <UserProfile userId="123" />
    </LoadingBoundary>
  );
}

이는 단순한 재사용이 아니라, "로딩, 에러, 경계"라는 개념적 관심사를 합성 가능한 추상 구조로 분리한 것이다.

단, 참고하자면 리액트의 합성은 수학적 함수합성과는 달리 순서 의존적 구조다.

Provider의 중첩 순서에 따라 의미가 달라지기 때문이다.

그럼에도 불구하고, 의미적 일관성이 유지된다면 FP적 조합성과 동일한 효과를 얻을 수 있다.

  1. 선언형 추상화의 철학 — 제어권의 이동 "선언형 프로그래밍"의 본질은 제어권의 포기다.

즉, "데이터의 흐름을 내가 직접 제어하지 않고, 시스템에게 위임하는 것"이다.

이 철학을 리액트 추상화에 적용하면 다음과 같다.

상태 제어 → useState, useReducer 같은 순수한 인터페이스로 이동 효과 제어 → useEffect, useQuery, Suspense 등의 경계로 이동 의미 제어 → useAuth, useSearch, useCart처럼 도메인 단위로 추상화 즉, 진짜 선언형 추상화란 의미 중심의 제어권 재배치다.

  1. 리액트 추상화의 3단계 함수형 구조로 본다면, 리액트의 추상화는 세 단계로 진화한다.

1단계: 표현 추상화

JSX, Virtual DOM, 렌더링 구조 제어 함수형 대응: view = f(state) 2단계: 로직 추상화

상태, 이펙트, 비즈니스 로직의 모듈화 함수형 대응: 순수함수와 효과 함수의 분리 3단계: 의미 추상화

UI 컴포넌트를 도메인 언어로 구성 함수형 대응: 의미적 API 계층(semantic API layer) 진짜 추상화는 세 번째 단계에서 일어난다.

이 시점부터 "UI 구조"가 아니라 "업무 행위"가 코드의 상위 개념으로 등장한다.

의미 추상화의 실제 예시

// 표현 추상화 수준 (UI 중심)
function ProductCard({ product }) {
  const [cart, setCart] = useState([]);
 
  const handleAdd = () => {
    fetch("/api/cart", {
      method: "POST",
      body: JSON.stringify({ productId: product.id }),
    }).then(() => {
      setCart([...cart, product]);
    });
  };
 
  return <button onClick={handleAdd}>장바구니 추가</button>;
}
 
// 의미 추상화 수준 (도메인 중심)
function ProductCard({ product }) {
  const { addToCart } = useCart(); // 도메인 언어로 추상화
 
  return <button onClick={() => addToCart(product)}>장바구니 추가</button>;
}
 
// useCart는 도메인 로직을 캡슐화
function useCart() {
  const queryClient = useQueryClient();
 
  const addToCart = useMutation({
    mutationFn: (product) => api.cart.add(product),
    onSuccess: () => {
      queryClient.invalidateQueries(["cart"]);
      toast.success("장바구니에 추가되었습니다");
    },
  });
 
  return {
    addToCart: addToCart.mutate,
    isAdding: addToCart.isPending,
  };
}

이제 addToCart는 "장바구니에 상품을 추가한다"는 도메인 의미를 가진다.

HTTP 요청, 상태 업데이트, 토스트 메시지 같은 구현 세부사항은 감춰지고, 오직 의도된 의미만 남는다.

  1. 함수형 사고로 보는 '추상화 냄새' 잘못된 추상화는 일관된 패턴을 가진다.

FP적 시각에서 보면, 이는 대부분 "부수효과가 제어되지 않은 구조"에서 나타난다.

대표적인 추상화 냄새들:

Prop drilling everywhere — 상태가 문맥 없이 흘러내린다 → Context, Custom Hook으로 추상화 Generic Component 남용 — 추상화가 의미를 잃은 범용화 → 도메인 중심 컴포넌트로 분리 useEffect 남용 — 사이드 이펙트가 컴포넌트 안으로 침투 → Effect를 명령 레벨로 추출 UI와 로직 결합 — 책임 경계가 모호 → View와 Behavior를 함수로 분리 추상화의 목적은 복잡성을 숨기는 것이 아니라, 효과를 제어 가능한 영역으로 격리하는 것이다.

개선 예시

// useEffect 남용 (효과가 컴포넌트 안에 침투)
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
 
  useEffect(() => {
    setLoading(true);
    fetch(`/api/users/${userId}`)
      .then((r) => r.json())
      .then(setUser)
      .finally(() => setLoading(false));
  }, [userId]);
 
  if (loading) return <Spinner />;
  return <div>{user.name}</div>;
}
 
// 효과를 명령 레벨로 추출
function useUser(userId) {
  return useQuery({
    queryKey: ["user", userId],
    queryFn: () => fetch(`/api/users/${userId}`).then((r) => r.json()),
  });
}
 
function UserProfile({ userId }) {
  const { data: user, isLoading } = useUser(userId);
 
  if (isLoading) return <Spinner />;
  return <div>{user.name}</div>;
}

이제 데이터 페칭 로직은 useUser라는 재사용 가능한 명령으로 캡슐화되고,

컴포넌트는 순수 렌더링 함수로 남는다.

  1. React를 "함수 합성의 의미 공간"으로 보기 리액트의 컴포넌트 구조를 함수형 프로그래밍 관점에서 보면,

각각의 컴포넌트는 입력(props)을 받아 UI를 반환하는 순수 함수처럼 동작하는 단위다.

그리고 여러 컴포넌트의 중첩은 수학의 함수 합성과 유사한 구조다.

// 고차 함수로서의 UI
const App = compose(
  withRouting,
  withSession,
  withTheme,
  withErrorBoundary
)(Page);

현대 리액트에선 이 구조가 다음과 같이 바뀐다.

<Router>
  <SessionProvider>
    <ThemeProvider>
      <ErrorBoundary>
        <Page />
      </ErrorBoundary>
    </ThemeProvider>
  </SessionProvider>
</Router>

각 Provider는 환경(Context) 을 합성하며,

이는 함수형 프로그래밍의 “환경 합성” 개념에 해당한다.

리액트 생태계는 이러한 합성을 통해 관심사를 분리하고 의미를 누적시킨다.

  1. 도메인 중심 추상화 장바구니 기능을 서로 다른 추상화 수준으로 구현해보면 다음과 같다.

Level 1: 표현 추상화

function CartButton({ productId }) {
  const [loading, setLoading] = useState(false);
 
  const handleClick = async () => {
    setLoading(true);
    try {
      await fetch("/api/cart", {
        method: "POST",
        body: JSON.stringify({ productId }),
      });
      alert("추가 완료!");
    } catch {
      alert("실패!");
    } finally {
      setLoading(false);
    }
  };
 
  return (
    <button onClick={handleClick} disabled={loading}>
      {loading ? "추가 중..." : "장바구니"}
    </button>
  );
}

문제점: API 호출, 상태 관리, UI가 모두 뒤섞여 있다.

Level 2: 로직 추상화

function useAddToCart() {
  return useMutation({
    mutationFn: (productId) =>
      fetch("/api/cart", {
        method: "POST",
        body: JSON.stringify({ productId }),
      }),
    onSuccess: () => toast.success("추가 완료!"),
    onError: () => toast.error("실패!"),
  });
}
 
function CartButton({ productId }) {
  const { mutate, isPending } = useAddToCart();
 
  return (
    <button onClick={() => mutate(productId)} disabled={isPending}>
      {isPending ? "추가 중..." : "장바구니"}
    </button>
  );
}

로직이 분리됐지만, 여전히 “추가”라는 행위 자체를 다루는 수준이다.

Level 3: 의미 추상화

// 도메인 언어로 추상화
function useCart() {
  const queryClient = useQueryClient();
 
  const { data: cart = [] } = useQuery({
    queryKey: ["cart"],
    queryFn: () => fetch("/api/cart").then((r) => r.json()),
  });
 
  const addItem = useMutation({
    mutationFn: (product) => api.cart.addItem(product),
    onSuccess: (product) => {
      queryClient.invalidateQueries(["cart"]);
      analytics.track("cart_item_added");
      toast.success(`${product.name}이(가) 담겼습니다`);
    },
  });
 
  const hasItem = (productId) => cart.some((i) => i.id === productId);
 
  return {
    cart,
    addItem: addItem.mutate,
    hasItem,
    isAdding: addItem.isPending,
  };
}
 
function ProductCard({ product }) {
  const { addItem, hasItem, isAdding } = useCart();
  const inCart = hasItem(product.id);
 
  return (
    <Card>
      <h3>{product.name}</h3>
      <button onClick={() => addItem(product)} disabled={isAdding || inCart}>
        {inCart ? "담김" : "장바구니"}
      </button>
    </Card>
  );
}

이제 컴포넌트는 “장바구니에 담는다”는 도메인적 의미를 표현하고,

API 호출, 상태 동기화, 분석 추적은 전부 캡슐화된다.

  1. 결론 — 리액트 추상화의 본질은 "의미의 함수화" 정리하자면:

리액트의 선언형 문법은 추상화의 도구이지, 추상화 그 자체가 아니다. 좋은 추상화는 UI를 감싸는 게 아니라, 의도를 함수로 드러내는 것이다. 함수형 추상화는 부수효과를 제거하는 게 아니라, 제어 가능한 경계로 격리한다. 리액트의 추상화란 상태의 함수가 아니라, 의미의 함수로 나아가는 여정이다. 선언형 UI는 출발점이다.

리액트의 진짜 추상화는, 함수형 철학으로 ‘의미’를 설계하는 데 있다.

추상화는 기술이 아니라, 사고방식이다.