대규모 React 프로젝트를 서서히 파멸시키는 아키텍처 실수

발행: (2025년 12월 27일 오후 07:38 GMT+9)
18 min read
원문: Dev.to

Source: Dev.to

번역할 전체 텍스트를 제공해 주시면, 원본 형식과 마크다운을 그대로 유지하면서 한국어로 번역해 드리겠습니다.

문제점

  • 클라이언트에 데이터가 너무 많음
  • useMemo 남용
  • React Query가 온‑체인 페칭에 너무 복잡해짐
  • 예측할 수 없는 재렌더링
  • 유지 보수가 어려운 훅 로직
  • 프로젝트와 함께 진화할 수 없는, 너무 일찍 만든 추상화

소개

현대 DeFi 프런트‑엔드는 최대한의 투명성을 제공하려고 합니다: 온‑체인에서 가져온 모든 데이터, 실시간으로 계산되는 모든 값, 반응형 UI.
시작 단계에서는 이것이 아름답게 작동합니다. 깔끔한 UI, 몇 개의 컨트랙트 호출, 몇 개의 훅만 있으면 모든 것이 관리 가능한 느낌이 듭니다.

하지만 프로젝트가 성장함에 따라 데이터도 늘어납니다:

  • 더 많은 금고
  • 더 많은 시장
  • 더 많은 잔액
  • 더 많은 파생 상태
  • 더 많은 APY
  • 더 많은 사용자 포지션

갑자기 프런트‑엔드가 백엔드 + 데이터베이스 역할을 React 컴포넌트 안에서 수행하게 됩니다. 초기 단계의 작은 실수—여기서 헬퍼 훅을, 저기서 추상화를—가 쌓이기 시작합니다. 조건이 늘고, 메모가 늘고, 상태가 늘어납니다. 눈치채기도 전에, 원래 만들고 싶지 않았던 복잡함과 싸우게 됩니다.

이 글에서는 이런 일이 발생하는지, React Query가 Web3 환경에서 매우 까다로워지는지, 그리고 어떻게 간단한 아키텍처—fetch → mapper → hook—가 이러한 문제들을 영구적으로 해결하는지 설명합니다.

클라이언트에 데이터가 너무 많음

데이터셋이 커지면서, 모든 데이터를 체인에서 실시간으로 가져올 필요는 없다는 것을 깨달았습니다. 일부 데이터는

  • 계산 비용이 많이 들고,
  • 거의 변하지 않으며, 혹은
  • 여러 소스에서 집계된 경우가 있습니다.

그래서 특정 종류의 데이터를 백엔드(또는 서브그래프 / 작업자)로 옮긴 뒤, Fetch → Mapper → UI 패턴으로 소비했습니다.

“이미 백엔드가 있다면 이 패턴이 덜 유용하지 않을까?”

Fetch → Mapper → UI 아키텍처는 백엔드를 도입하면 필요성이 줄어든다고 생각할 수도 있습니다.
하지만 현실은 다음과 같습니다.

강력한 백엔드가 있더라도, 체인에서 직접 받아야 하는 데이터가 항상 존재합니다. 이러한 값은

  • 시간에 민감하고,
  • 사용자별이며, 혹은
  • 트랜잭션 게이트 역할을 합니다.

…백엔드에 안전하게 위임하기 어렵습니다.

이로 인해 피할 수 없는 과제가 생깁니다: 프론트엔드가 두 개의 서로 다른 세계를 병합해야 한다는 점입니다.

출처특성
백엔드 제공 데이터캐시된, 집계된, 변동이 느린
온체인 데이터실시간, 반응형, 사용자별

Fetch → Mapper → UI 패턴은 바로 이 작업을 가능하게 합니다:

  1. Fetcher는 데이터가 어디서, 어떻게 오는지를 격리합니다(백엔드든 체인이든).
  2. Mapper는 두 소스를 결합·정규화·포맷팅하고 조정합니다.
  3. UI는 단일하고 깨끗하며 안정적인 데이터 객체를 받아, 그것이 RPC, 백엔드, 혹은 두 곳 모두에서 온 것인지 알 필요도, 신경 쓸 필요도 없습니다.

useMemo 남용하기

전통적인 앱에서는 백엔드가 데이터를 준비해 줍니다.
Web3에서는 블록체인이 원시적인 기본 상태를 제공하므로 모든 것을 직접 계산해야 합니다.

  • 데이터가 많아질수록 → 파생 계산이 늘어나고 → useMemo도 늘어납니다.
  • 볼트가 10개뿐이라면, 볼트당 20개의 메모도 큰 문제가 되지 않지만, 볼트 수가 증가하면…
볼트 수메모 수 (≈20 × 볼트)
10200
50010 000

각 재렌더링마다 다음을 트리거합니다:

  • 의존성 비교
  • 재계산
  • 차이 계산
  • 메모리 사용
  • 경쟁 조건

데이터가 증가함에 따라 “안전한 실수”의 수는 0으로 줄어듭니다. 단 하나의 불안정한 의존성만으로도 성능이 붕괴될 수 있습니다.

Web3와 Web2에서의 React Query

많은 DeFi 프로젝트를 검토하면서 공통적인 실수를 발견했습니다: Web2와 똑같은 방식으로 useQuery를 사용하는 것.

“React Query가 Web2 앱을 만들 때와 Web3 앱을 만들 때 차이가 있을까? 나는 HTTP 대신 RPC 호출만 하는 거잖아?”

그것은 그렇게 간단하지 않습니다.

Web2Web3
데이터베이스 / 백엔드 서비스가 무거운 작업(계산, 조인, 포맷팅)을 수행한다.체인만 사용할 수 있다 → 모든 DB‑와 같은 작업을 프론트엔드에서 직접 해야 한다.
하나의 훅 → 하나의 쿼리 → 필요한 모든 것.여러 기본 호출 → 서로 의존하는 훅이 많이 생긴다.

전형적인 (지저분한) 훅‑중심 접근 방식은 어떻게 보이는가

// 1️⃣ Fetch vault
const {
  data: vault,
  isLoading: isVaultLoading,
} = useVault({
  address: vaultAddress,
  query: { refetchOnMount: false },
});

// 2️⃣ Fetch Net Asset Value (depends on vault)
const {
  data: netAssetValue,
  isLoading: isNetAssetValueLoading,
} = useNetAssetValue({
  accountAddress: account,
  vaultAddress,
  enabled: Boolean(vault), // enabled #1
});

// 3️⃣ Fetch APY (also depends on vault)
const {
  data: apy,
  isLoading: isApyLoading,
} = useApy({
  account,
  vaultAddress,
  enabled: Boolean(vault), // enabled #2
});

// Global loading state
const isLoading =
  isVaultLoading ||
  isNetAssetValueLoading ||
  isApyLoading;

// TODO: handle errors, etc.

이 패턴의 문제점

  • 각 데이터 포인트마다 React Query 상태(loading, error, status 플래그, 타임스탬프 등)의 묶음을 가지고 있다.
  • 대부분의 훅이 서로에게 의존하기 때문에, 하나라도 하위 쿼리가 로딩 중이면 복합 훅 전체가 “loading” 상태가 된다.
  • 실제로는 모든 데이터를 필요로 하므로, 여러 useQuery 호출은 결국 캐싱이라는 하나의 목적만을 수행하게 된다.

자연스럽게 떠오르는 질문:

그럼 왜 모든 데이터를 한 번에 가져오지 않을까?

더 간단한 접근법: Fetch → Mapper → Hook

아래는 복잡하게 얽힌 hook 중심 코드를 하나의 명확한 데이터 흐름으로 교체할 수 있는 개요입니다.

// mapper.ts
export async function fetchVaultData({
  vaultAddress,
  account,
}: {
  vaultAddress: string;
  account: string;
}) {
  // 1️⃣ Fetch the vault (required) and await it
  const vault = await fetchVault(vaultAddress);
  if (!vault) throw new Error('Vault not found');

  // 2️⃣ Fetch dependent values in parallel
  const [netAssetValue, apy] = await Promise.all([
    fetchNetAssetValue({ accountAddress: account, vaultAddress }),
    fetchApy({ account, vaultAddress }),
  ]);

  // 3️⃣ Combine / map everything into a single object
  return {
    vault,
    netAssetValue,
    apy,
  };
}
// useVaultData.ts
import { useQuery } from '@tanstack/react-query';
import { fetchVaultData } from './mapper';

export function useVaultData({
  vaultAddress,
  account,
}: {
  vaultAddress: string;
  account: string;
}) {
  return useQuery(
    ['vaultData', vaultAddress, account],
    () => fetchVaultData({ vaultAddress, account }),
    {
      // you can still control refetching, caching, etc.
      staleTime: 60_000,
      enabled: Boolean(vaultAddress && account),
    }
  );
}

장점

  1. 단일 진실 원천 – 하나의 쿼리로 완전한 데이터 객체를 반환합니다.
  2. 로드 플래그 연쇄 없음 – hook의 isLoading이 전체 페이로드를 반영합니다.
  3. 테스트 및 유지보수 용이 – mapper는 순수 함수이며 단위 테스트가 가능합니다.
  4. useMemo 사용 감소 – 파생 값은 mapper에서 한 번만 계산되며, 매 렌더링 시마다 계산되지 않습니다.

TL;DR

  • 프론트‑엔드가 임시 백엔드가 되지 않도록 하세요. 정적/비싼 데이터를 실제 백엔드나 서브그래프로 옮기세요.
  • useMemo의 남발을 피하세요. 메모 훅을 컴포넌트에 흩뿌리는 대신 전용 매퍼에서 파생 데이터를 계산하세요.
  • 복잡하게 얽힌 useQuery 훅들을 하나의 “fetch → mapper → hook” 파이프라인으로 교체하세요. 이렇게 하면 DeFi 앱이 성장함에 따라 깨끗하고 예측 가능한 데이터 흐름을 제공할 수 있습니다.

React Query를 사용한 데이터 가져오기 리팩터링

// 1. Define the query options
export const getOwnerQueryOptions = (
  vaultAddress: Address,
  chainId: number,
) => ({
  // readContractQueryOptions utils from wagmi
  ...readContractQueryOptions(getWagmiConfig(), {
    abi: eulerEarnAbi,
    address: vaultAddress,
    chainId,
    functionName: "owner",
    args: [],
  }),
  staleTime: 30 * 60 * 1000, // ← cache the smallest part of the data (30 min)
});

// 2. Fetch the data using the query client
export async function fetchOwner(vaultAddress: Address, chainId: number) {
  const result = await getQueryClient().fetchQuery(
    // ← fetch query from query client
    getOwnerQueryOptions(vaultAddress, chainId),
  );

  return result;
}

왜 더 간단한가

  • 단일 진실의 원천 – 모든 가져오기 로직이 순수 함수에 존재합니다.
  • 내장 캐싱staleTime이 만료를 자동으로 처리합니다.
  • Hook‑inside‑hook 금지 – 상위 레벨 로직이 fetchOwner를 직접 호출할 수 있어 Hook이 얇아집니다.

Hook‑무거운 구현에서 순수 Fetch 함수로 전환하기

예전에는 아래와 같이 거대한 Hook을 작성했을 수 있습니다 (단순화된 예시):

// Example of a large, hook‑heavy implementation
function useVaultData(vaultAddress: Address, chainId: number) {
  const { data: owner } = useQuery(getOwnerQueryOptions(vaultAddress, chainId));
  const { data: isVerified } = useQuery(getVerifiedQueryOptions(vaultAddress));
  // … many more useQuery calls, useMemo, etc.
  // 500–800 lines of intertwined logic
}

그 접근 방식의 문제점

  1. 흩어진 캐시 로직 – 내부 Hook마다 각각 enabled, staleTime 등을 별도로 지정해야 함.
  2. 요구사항 변경이 어려움 – 사전 검증(예: “볼트가 인증되었는가?”)을 추가하려면 수십 군데를 수정해야 함.
  3. 성능 오버헤드 – 다수의 useMemo 호출, 의존성 배열, 쿼리 키가 불필요한 재렌더링을 유발함.

더 깔끔한 흐름

  1. Fetch 함수 – 데이터를 반환하는 순수 async 함수 (fetchOwner, fetchIsVerified 등).
  2. Mapper 레이어 – 필요에 따라 여러 Fetch 함수를 호출해 데이터를 조합하는 함수 (보통 Promise.all 사용).
  3. useQuery 래퍼 – Mapper를 감싸는 단일 useQuery 로 UI 반응성을 제공.
// 1️⃣ Fetch functions (already shown above)

// 2️⃣ Mapper that composes the data
async function fetchVaultInfo(vaultAddress: Address, chainId: number) {
  const [owner, isVerified] = await Promise.all([
    fetchOwner(vaultAddress, chainId),
    fetchIsVerified(vaultAddress, chainId),
  ]);

  if (!isVerified) {
    throw new Error("Vault is not verified");
  }

  return { owner, isVerified };
}

// 3️⃣ Hook that gives the UI reactivity
export function useVaultInfo(vaultAddress: Address, chainId: number) {
  return useQuery({
    queryKey: ["vaultInfo", vaultAddress, chainId],
    queryFn: () => fetchVaultInfo(vaultAddress, chainId),
    staleTime: 5 * 60 * 1000, // example cache duration
  });
}

이제 Hook은 작아졌고, 데이터 레이어는 테스트 가능하며, 캐싱은 Fetch 함수 한 곳에서만 처리됩니다.

과잉 반응형 아키텍처의 비용

IssueWhat Happens
많은 useQuery 호출7개 이상의 쿼리 키 → 많은 독립 캐시
다중 useMemo 래퍼훅당 3‑5개의 추가 의존성 배열
10개 이상의 의존성 배열React는 매 렌더링마다 이를 비교해야 함
스케일링더 많은 컴포넌트 → 더 많은 배열 및 키 → 재렌더링 연쇄
메모리 및 CPU배열을 저장하고 비교하는 비용이 크게 증가함

“그냥 useMemo로 감싸자.”
이것은 또 다른 반응 레이어를 추가할 뿐이며(더 많은 의존성, 더 많은 동등성 검사, 더 많은 메모리) 근본적인 문제를 해결하지 못한다.

올바른 진행 방향

  1. Move fetching out of hooks – Keep hooks thin; let them only provide reactivity.
    훅에서 데이터를 가져오는 로직을 분리 – 훅은 얇게 유지하고 반응성만 제공하도록 합니다.

  2. Simplify the pipeline – Use Promise.all (or similar) to parallelise fetches.
    파이프라인을 단순화Promise.all(또는 유사한 방법)을 사용해 fetch를 병렬화합니다.

  3. Leverage React Query – For caching, stale‑time, retries, and UI state (loading/error).
    React Query 활용 – 캐싱, stale‑time, 재시도, UI 상태(로딩/오류) 관리에 사용합니다.

  4. Keep a single useQuery per feature – One source of truth for UI reactivity.
    기능당 하나의 useQuery만 사용 – UI 반응성의 단일 진실 원천을 유지합니다.

Benefits

이점

  • Debuggable – Pure functions are easy to unit‑test.
    디버깅 용이 – 순수 함수는 단위 테스트가 쉽습니다.

  • Predictable – Fewer moving parts, less surprise re‑renders.
    예측 가능 – 구성 요소가 적어 예기치 않은 재렌더링이 줄어듭니다.

  • Performant – Less memory, fewer comparisons.
    성능 향상 – 메모리 사용이 적고 비교 횟수가 줄어듭니다.

  • Scalable – Adding new data requirements is a matter of adding a fetch function.
    확장성 – 새로운 데이터 요구사항은 fetch 함수를 추가하는 것만으로 충분합니다.

  • Maintainable – Clear separation between data fetching and UI logic.
    유지보수성 – 데이터 가져오기와 UI 로직을 명확히 분리합니다.

TL;DR

  • Fetch functions → 순수 async, queryClient.fetchQuery 로 캐시됨.
  • Mapper layer → 여러 fetch를 조합하고 비즈니스 로직(예: 검증)을 처리.
  • Single useQuery → UI 반응성을 위해 매퍼를 감싸서 사용.

이 세 층 접근 방식을 채택하면 “hook‑heavy” 코드의 함정을 피하고, React Query가 가장 잘하는 캐시와 상태 관리를 맡기면서 코드베이스를 깔끔하고 성능 좋게, 이해하기 쉽게 유지할 수 있습니다.

Back to Blog

관련 글

더 보기 »