> ## Documentation Index
> Fetch the complete documentation index at: https://daehan-base.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Base에서 앱 빌드하기

> wagmi와 viem을 사용하여 Base 위에서 Next.js 집계 앱을 처음부터 빌드하는 단계별 가이드. 지갑 연결, 컨트랙트 읽기/쓰기, 배치 트랜잭션 지원 포함.

이 가이드는 Base 위에서 온체인 집계 앱을 처음부터 빌드하는 과정을 안내합니다. 지갑을 연결하고, 스마트 컨트랙트를 읽고 쓰고, 지갑 기능을 감지하고, 배치를 지원하지 않는 지갑에 대한 우아한 폴백을 처리합니다.

## 빌드할 것

* 지갑을 연결하고 연결 상태를 처리하는 Next.js 앱
* Base Sepolia에 배포된 카운터에 대한 컨트랙트 읽기 및 쓰기
* EIP-5792를 통한 스마트 지갑의 배치 트랜잭션 지원
* 배치를 지원하지 않는 지갑을 위한 우아한 폴백

<Tip>
  Base는 다음 10억 명의 사용자를 온체인으로 데려오기 위해 구축된 빠르고 저렴한 Ethereum L2입니다. 낮은 가스 수수료로 배치 트랜잭션이 실용적이고 실시간 UX가 가능해집니다. 이 가이드의 모든 패턴은 모든 EVM 체인에서 작동합니다.
</Tip>

## 단계

<Steps>
  <Step title="프로젝트 설정">
    새 Next.js 앱을 만들고 필요한 의존성을 설치합니다.

    ```bash Terminal theme={null}
    npx create-next-app@latest my-base-app --typescript --tailwind --app
    cd my-base-app
    npm install wagmi viem @tanstack/react-query @base-org/account
    ```
  </Step>

  <Step title="Base를 위한 Wagmi 설정">
    Base Sepolia로 Wagmi 설정을 만들고 앱을 필요한 프로바이더로 감쌉니다.

    ```typescript config/wagmi.ts lines expandable theme={null}
    import { http, createConfig, createStorage, cookieStorage } from 'wagmi'
    import { baseSepolia } from 'wagmi/chains'
    import { baseAccount, injected } from 'wagmi/connectors'

    export const config = createConfig({
      chains: [baseSepolia],
      connectors: [
        injected(),
        baseAccount({
          appName: 'My Base App',
        }),
      ],
      storage: createStorage({ storage: cookieStorage }),
      ssr: true,
      transports: {
        [baseSepolia.id]: http('https://sepolia.base.org'),
      },
    })

    declare module 'wagmi' {
      interface Register {
        config: typeof config
      }
    }
    ```

    <Note>
      `ssr: true`와 `cookieStorage`의 조합은 Next.js 하이드레이션 불일치를 방지합니다. `baseAccount` 커넥터는 [Base Account SDK](/base-account/overview/what-is-base-account) 스마트 지갑을 통해 사용자를 연결하며, 7단계에서 기능을 감지합니다. `injected` 커넥터는 MetaMask 같은 브라우저 확장 지갑을 처리합니다.
    </Note>

    ```typescript app/providers.tsx lines expandable theme={null}
    'use client'

    import { WagmiProvider } from 'wagmi'
    import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
    import { type ReactNode } from 'react'
    import { config } from '@/config/wagmi'

    const queryClient = new QueryClient()

    export function Providers({ children }: { children: ReactNode }) {
      return (
        <WagmiProvider config={config}>
          <QueryClientProvider client={queryClient}>
            {children}
          </QueryClientProvider>
        </WagmiProvider>
      )
    }
    ```

    루트 레이아웃을 `<Providers>`로 감쌉니다.
  </Step>

  <Step title="지갑 연결">
    네 가지 지갑 연결 상태를 모두 처리하는 컴포넌트를 만듭니다.

    ```typescript components/ConnectWallet.tsx lines expandable theme={null}
    'use client'

    import { useAccount, useConnect, useDisconnect } from 'wagmi'

    export function ConnectWallet() {
      const { address, isConnected, isConnecting, isReconnecting } = useAccount()
      const { connect, connectors } = useConnect()
      const { disconnect } = useDisconnect()

      if (isReconnecting) return <div>재연결 중...</div>

      if (!isConnected) {
        return (
          <div className="flex flex-col gap-2">
            {connectors.map((connector) => (
              <button
                key={connector.uid}
                onClick={() => connect({ connector })}
                disabled={isConnecting}
              >
                {connector.name} 연결
              </button>
            ))}
          </div>
        )
      }

      return (
        <div className="flex items-center gap-3">
          <span className="font-mono text-sm">
            {address?.slice(0, 6)}...{address?.slice(-4)}
          </span>
          <button onClick={() => disconnect()}>연결 해제</button>
        </div>
      )
    }
    ```

    <Note>
      `useAccount`는 `isConnecting`, `isReconnecting`, `isConnected`, `isDisconnected` 네 가지 상태를 노출합니다. `isConnected`만 확인하면 페이지 로드 시 UI가 깜박입니다 — 네 가지 모두 처리하세요.
    </Note>
  </Step>

  <Step title="Foundry로 컨트랙트 배포">
    Foundry를 설치하고 프로젝트 내에 contracts 디렉토리를 초기화합니다.

    ```bash Terminal theme={null}
    mkdir contracts && cd contracts
    curl -L https://foundry.paradigm.xyz | bash
    foundryup
    forge init --no-git
    ```

    <Note>
      `--no-git` 플래그는 Foundry가 프로젝트 내에 중첩된 git 저장소를 초기화하는 것을 방지합니다.
    </Note>

    환경 파일에 Base Sepolia를 설정합니다.

    ```bash contracts/.env theme={null}
    BASE_SEPOLIA_RPC_URL="https://sepolia.base.org"
    ```

    <Note>
      `https://sepolia.base.org`에 접근할 수 없는 경우 `https://base-sepolia-rpc.publicnode.com`과 같은 대체 공개 엔드포인트를 사용하세요. 프로덕션 앱의 경우 전용 RPC 프로바이더를 사용하세요.
    </Note>

    변수를 로드하고 배포자 키를 안전하게 가져옵니다.

    ```bash Terminal theme={null}
    source .env
    cast wallet import deployer --interactive
    ```

    <Warning>
      개인 키를 절대 공유하거나 커밋하지 마세요. `cast wallet import`는 git에서 추적되지 않는 `~/.foundry/keystores`에 키를 저장합니다.
    </Warning>

    <Note>
      `cast wallet import --interactive`는 TTY(인터랙티브 터미널)가 필요합니다. 스크립트나 CI 환경에서는 키를 직접 전달하세요:

      ```bash Terminal theme={null}
      forge create ./src/Counter.sol:Counter \
        --rpc-url $BASE_SEPOLIA_RPC_URL \
        --private-key $DEPLOYER_PRIVATE_KEY
      ```
    </Note>

    컨트랙트를 배포합니다.

    ```bash Terminal theme={null}
    forge create ./src/Counter.sol:Counter \
      --rpc-url $BASE_SEPOLIA_RPC_URL \
      --account deployer
    ```

    초기 카운터 값을 읽어 배포를 확인합니다.

    ```bash Terminal theme={null}
    cast call <CONTRACT_ADDRESS> "number()(uint256)" --rpc-url $BASE_SEPOLIA_RPC_URL
    ```

    배포 비용을 지불하려면 테스트넷 ETH가 필요합니다. [네트워크 파우셋](/base-chain/network-information/network-faucets)에서 무료 Base Sepolia ETH를 받으세요.
  </Step>

  <Step title="컨트랙트 데이터 읽기">
    컨트랙트 주소와 ABI를 정의한 다음 현재 카운터 값을 읽습니다.

    ```typescript config/counter.ts lines expandable theme={null}
    export const COUNTER_ADDRESS = '0x...' as const

    export const counterAbi = [
      {
        type: 'function',
        name: 'number',
        inputs: [],
        outputs: [{ name: '', type: 'uint256' }],
        stateMutability: 'view',
      },
      {
        type: 'function',
        name: 'increment',
        inputs: [],
        outputs: [],
        stateMutability: 'nonpayable',
      },
    ] as const
    ```

    <Note>
      `as const`는 필수입니다. 이것이 없으면 wagmi가 ABI에서 함수 이름, 인수 타입, 반환 타입을 추론할 수 없습니다.
    </Note>

    ```typescript components/CounterDisplay.tsx lines expandable theme={null}
    'use client'

    import { useReadContract } from 'wagmi'
    import { baseSepolia } from 'wagmi/chains'
    import { COUNTER_ADDRESS, counterAbi } from '@/config/counter'

    export function CounterDisplay() {
      const { data: count, isLoading, isError } = useReadContract({
        address: COUNTER_ADDRESS,
        abi: counterAbi,
        functionName: 'number',
        chainId: baseSepolia.id,
      })

      if (isLoading && count === undefined) return <p>로딩 중...</p>
      if (isError && count === undefined) return <p>컨트랙트 읽기 실패</p>

      return <p className="text-5xl font-bold">{count?.toString()}</p>
    }
    ```

    <Note>
      `isError`가 `true`여도 이전의 성공적인 페치에서 `data`에 유효한 캐시된 값이 남아 있을 수 있습니다. 오류 메시지보다 오래된 데이터가 선호되도록 항상 `data === undefined`를 조건으로 오류를 렌더링하세요.
    </Note>
  </Step>

  <Step title="컨트랙트에 쓰기">
    트랜잭션을 전송하고 세 가지 확인 상태를 사용자에게 표시합니다.

    ```typescript components/IncrementButton.tsx lines expandable theme={null}
    'use client'

    import { useEffect } from 'react'
    import {
      useWriteContract,
      useWaitForTransactionReceipt,
      useChainId,
      useSwitchChain,
    } from 'wagmi'
    import { readContractQueryOptions } from 'wagmi/query'
    import { useQueryClient } from '@tanstack/react-query'
    import { baseSepolia } from 'wagmi/chains'
    import { config } from '@/config/wagmi'
    import { COUNTER_ADDRESS, counterAbi } from '@/config/counter'

    export function IncrementButton() {
      const chainId = useChainId()
      const { switchChain, isPending: isSwitching } = useSwitchChain()
      const { data: hash, isPending, writeContract } = useWriteContract()
      const { isLoading: isConfirming, isSuccess } =
        useWaitForTransactionReceipt({ hash })
      const queryClient = useQueryClient()

      useEffect(() => {
        if (isSuccess) {
          queryClient.invalidateQueries({
            queryKey: readContractQueryOptions(config, {
              address: COUNTER_ADDRESS,
              abi: counterAbi,
              functionName: 'number',
              chainId: baseSepolia.id,
            }).queryKey,
          })
        }
      }, [isSuccess, queryClient])

      if (chainId !== baseSepolia.id) {
        return (
          <button onClick={() => switchChain({ chainId: baseSepolia.id })}>
            {isSwitching ? '전환 중...' : 'Base Sepolia로 전환'}
          </button>
        )
      }

      return (
        <div>
          <button
            onClick={() =>
              writeContract({
                address: COUNTER_ADDRESS,
                abi: counterAbi,
                functionName: 'increment',
                chainId: baseSepolia.id,
              })
            }
            disabled={isPending || isConfirming}
          >
            {isPending
              ? '지갑에서 확인 중...'
              : isConfirming
              ? '확인 중...'
              : '증가'}
          </button>
          {isSuccess && <p>확인 완료!</p>}
          {hash && (
            <a href={`https://sepolia.basescan.org/tx/${hash}`} target="_blank">
              Basescan에서 보기
            </a>
          )}
        </div>
      )
    }
    ```

    <Note>
      `useReadContract`는 결과를 캐시하며 쓰기 후 자동으로 다시 가져오지 않습니다. 트랜잭션이 확인될 때 단일 재페치를 트리거하려면 읽기의 쿼리 키와 함께 `queryClient.invalidateQueries`를 사용하세요.
    </Note>

    사용자에게 세 가지 상태를 표시합니다: 지갑 서명 대기, 온체인 확인 대기, 성공.

    <Warning>
      `useSwitchChain` 없이 잘못된 네트워크에서 `writeContract`를 호출하면 wagmi가 백그라운드 체인 전환을 시도합니다. 사용자가 지갑 팝업을 놓치거나 닫으면 버튼이 오류 없이 "지갑에서 확인 중..."에 무기한 머물게 됩니다.
    </Warning>
  </Step>

  <Step title="지갑 기능 감지">
    스마트 지갑은 EIP-5792를 통해 배치 트랜잭션을 지원합니다. EOA는 지원하지 않습니다. 배치를 시도하기 전에 지원 여부를 감지하세요.

    ```typescript hooks/useWalletCapabilities.ts lines expandable theme={null}
    import { useCapabilities } from 'wagmi'
    import { baseSepolia } from 'wagmi/chains'
    import { useMemo } from 'react'

    export function useWalletCapabilities() {
      const { data: capabilities } = useCapabilities()

      const supportsBatching = useMemo(() => {
        const atomic = capabilities?.[baseSepolia.id]?.atomic
        return atomic?.status === 'ready' || atomic?.status === 'supported'
      }, [capabilities])

      const supportsPaymaster = useMemo(() => {
        return capabilities?.[baseSepolia.id]?.paymasterService?.supported === true
      }, [capabilities])

      return { supportsBatching, supportsPaymaster }
    }
    ```

    <Note>
      `useChainId()`는 배포 체인이 아닌 지갑의 현재 체인을 반환합니다. Ethereum 메인넷에 있는 MetaMask 사용자는 잘못된 기능 결과를 얻게 됩니다. 항상 컨트랙트가 배포된 체인에 대한 기능을 확인하세요.
    </Note>

    EIP-5792 기능 감지에 대한 자세한 내용은 [Wagmi를 사용한 배치 트랜잭션](/base-account/framework-integrations/wagmi/batch-transactions)을 참조하세요.
  </Step>

  <Step title="폴백을 포함한 배치 트랜잭션">
    스마트 지갑에는 `useSendCalls`를, EOA에는 `useWriteContract`를 사용합니다. 컴포넌트는 렌더링 시점에 어떤 경로를 선택할지 감지합니다.

    ```typescript components/BatchIncrement.tsx lines expandable theme={null}
    'use client'

    import { useEffect } from 'react'
    import {
      useSendCalls,
      useWaitForCallsStatus,
      useWriteContract,
      useWaitForTransactionReceipt,
      useAccount,
      useChainId,
      useSwitchChain,
    } from 'wagmi'
    import { readContractQueryOptions } from 'wagmi/query'
    import { useQueryClient } from '@tanstack/react-query'
    import { encodeFunctionData } from 'viem'
    import { baseSepolia } from 'wagmi/chains'
    import { config } from '@/config/wagmi'
    import { useWalletCapabilities } from '@/hooks/useWalletCapabilities'
    import { COUNTER_ADDRESS, counterAbi } from '@/config/counter'

    const counterQueryKey = readContractQueryOptions(config, {
      address: COUNTER_ADDRESS,
      abi: counterAbi,
      functionName: 'number',
      chainId: baseSepolia.id,
    }).queryKey

    export function BatchIncrement() {
      const { isConnected } = useAccount()
      const { supportsBatching } = useWalletCapabilities()

      if (!isConnected) return <p>먼저 지갑을 연결하세요.</p>

      return supportsBatching ? <BatchFlow /> : <SequentialFlow />
    }

    function BatchFlow() {
      const chainId = useChainId()
      const { switchChain, isPending: isSwitching } = useSwitchChain()
      const { data, sendCalls, isPending } = useSendCalls()
      const { isLoading: isConfirming, isSuccess } = useWaitForCallsStatus({
        id: data?.id,
      })
      const queryClient = useQueryClient()

      useEffect(() => {
        if (isSuccess) {
          queryClient.invalidateQueries({ queryKey: counterQueryKey })
        }
      }, [isSuccess, queryClient])

      if (chainId !== baseSepolia.id) {
        return (
          <button onClick={() => switchChain({ chainId: baseSepolia.id })}>
            {isSwitching ? '전환 중...' : 'Base Sepolia로 전환'}
          </button>
        )
      }

      const incrementData = encodeFunctionData({
        abi: counterAbi,
        functionName: 'increment',
      })

      return (
        <div>
          <button
            onClick={() =>
              sendCalls({
                calls: [
                  { to: COUNTER_ADDRESS, data: incrementData },
                  { to: COUNTER_ADDRESS, data: incrementData },
                ],
                chainId: baseSepolia.id,
              })
            }
            disabled={isPending || isConfirming}
          >
            {isPending
              ? '지갑에서 확인 중...'
              : isConfirming
              ? '확인 중...'
              : 'x2 증가 (배치)'}
          </button>
          {isSuccess && <p>배치 확인 완료!</p>}
        </div>
      )
    }

    function SequentialFlow() {
      const chainId = useChainId()
      const { switchChain, isPending: isSwitching } = useSwitchChain()
      const { data: hash, isPending, writeContract } = useWriteContract()
      const { isLoading: isConfirming, isSuccess } =
        useWaitForTransactionReceipt({ hash })
      const queryClient = useQueryClient()

      useEffect(() => {
        if (isSuccess) {
          queryClient.invalidateQueries({ queryKey: counterQueryKey })
        }
      }, [isSuccess, queryClient])

      if (chainId !== baseSepolia.id) {
        return (
          <button onClick={() => switchChain({ chainId: baseSepolia.id })}>
            {isSwitching ? '전환 중...' : 'Base Sepolia로 전환'}
          </button>
        )
      }

      return (
        <button
          onClick={() =>
            writeContract({
              address: COUNTER_ADDRESS,
              abi: counterAbi,
              functionName: 'increment',
              chainId: baseSepolia.id,
            })
          }
          disabled={isPending || isConfirming}
        >
          {isPending ? '지갑에서 확인 중...' : isConfirming ? '확인 중...' : '증가'}
        </button>
      )
    }
    ```

    <Warning>
      `supportsBatching`이 `true`인지 확인하지 않고 `useSendCalls`를 호출하지 마세요. EOA에 대해 호출하면 오류가 발생합니다.
    </Warning>
  </Step>

  <Step title="페이지 조합">
    컴포넌트들을 하나의 페이지로 구성합니다.

    ```typescript app/page.tsx lines expandable theme={null}
    import { ConnectWallet } from '@/components/ConnectWallet'
    import { CounterDisplay } from '@/components/CounterDisplay'
    import { BatchIncrement } from '@/components/BatchIncrement'

    export default function Home() {
      return (
        <main className="min-h-screen flex flex-col items-center justify-center gap-8 p-8">
          <h1 className="text-3xl font-bold">온체인 집계</h1>
          <ConnectWallet />
          <CounterDisplay />
          <BatchIncrement />
        </main>
      )
    }
    ```

    개발 서버를 시작합니다.

    ```bash Terminal theme={null}
    npm run dev
    ```
  </Step>
</Steps>

## 다음 단계

* **메인넷으로 이동** — `config/wagmi.ts`의 `chains` 배열과 트랜스포트에 `base`를 추가하고, 컨트랙트를 Base 메인넷에 재배포한 후 `COUNTER_ADDRESS`를 업데이트하세요.
* **가스 스폰서** — `useSendCalls`와 `paymasterService` 기능을 사용하여 사용자의 트랜잭션 수수료를 대신 지불하세요. [가스 스폰서](/base-account/improve-ux/sponsor-gas/paymasters)를 참조하세요.
* **읽기 호출 배치** — viem의 `multicall`을 통해 읽기를 배치하여 RPC 왕복을 줄이세요.
* **낙관적 업데이트** — TanStack Query의 `onMutate` 콜백을 사용하여 확인 전에 UI를 업데이트하세요.
* **Wagmi 설정 참조** — 추가 설정 옵션을 위해 전체 [Wagmi 설정 가이드](/base-account/framework-integrations/wagmi/setup)를 검토하세요.
