1편에서 EIP-6963의 개념과 필요성을 살펴봤습니다. 이번 편에서는 실제로 TypeScript와 React를 사용해 EIP-6963를 구현하는 방법을 단계별로 알아보겠습니다.
1. 시작하기 전에
개발 환경 준비
이 튜토리얼에서는 다음 환경을 기준으로 설명합니다:
- Node.js 18 이상
- React 18 이상
- TypeScript 5.0 이상
- Next.js 14 이상 (선택사항)
프로젝트를 새로 시작한다면 Vite로 빠르게 세팅할 수 있습니다:
npm create vite@latest my-dapp -- --template react-ts
cd my-dapp
npm install
테스트용 지갑 준비
실제 개발하면서 테스트하려면 최소 2개 이상의 지갑을 설치해야 합니다. EIP-6963의 진가는 여러 지갑이 있을 때 발휘되니까요.
- MetaMask: https://metamask.io/download/
- Coinbase Wallet: https://www.coinbase.com/wallet/downloads
- Phantom: https://phantom.app/download (Solana 지원도 필요하다면)
저는 테스트 시 MetaMask와 Coinbase Wallet을 주로 사용합니다. 두 지갑 모두 2023년 10월부터 EIP-6963를 지원하고 있어 문제없이 작동합니다.
2. 기본 구현
2.1 타입 정의
먼저 TypeScript 타입을 정의해야 합니다. src/types/eip6963.d.ts 파일을 생성하고 EIP-6963 표준에 정의된 인터페이스들을 추가합니다:
// src/types/eip6963.d.ts
// 지갑 정보를 담는 인터페이스
interface EIP6963ProviderInfo {
uuid: string; // 세션마다 변경되는 고유 식별자
name: string; // 사용자에게 표시될 지갑 이름 (예: "MetaMask")
icon: string; // Base64 또는 SVG 형식의 아이콘
rdns: string; // 지갑을 식별하는 역방향 DNS (예: "io.metamask")
}
// EIP-1193 표준 Provider 인터페이스
interface EIP1193Provider {
isStatus?: boolean;
host?: string;
path?: string;
request: (request: {
method: string;
params?: Array<unknown>
}) => Promise<unknown>;
}
// 지갑 정보와 Provider를 함께 담는 인터페이스
interface EIP6963ProviderDetail {
info: EIP6963ProviderInfo;
provider: EIP1193Provider;
}
// DApp이 발송하는 요청 이벤트
interface EIP6963RequestProviderEvent extends Event {
type: "eip6963:requestProvider";
}
// 지갑이 발송하는 응답 이벤트
type EIP6963AnnounceProviderEvent = {
detail: {
info: EIP6963ProviderInfo;
provider: EIP1193Provider;
}
}
이 부분에서 주의할 점은 uuid와 rdns의 차이입니다. uuid는 세션마다 변경되는 임시 식별자이고, rdns는 지갑의 영구적인 식별자입니다. 사용자가 마지막으로 선택한 지갑을 기억하려면 rdns를 localStorage에 저장해야 합니다.
2.2 Provider 탐색 로직
이제 실제로 지갑들을 탐색하는 로직을 구현합니다. React의 useSyncExternalStore를 사용하면 외부 이벤트를 React 상태와 동기화할 수 있습니다.
먼저 지갑 정보를 저장할 store를 만듭니다. src/hooks/store.ts:
// src/hooks/store.ts
// 글로벌 WindowEventMap에 커스텀 이벤트 타입 추가
declare global {
interface WindowEventMap {
"eip6963:announceProvider": CustomEvent;
}
}
// 탐색된 지갑 목록을 저장
let providers: EIP6963ProviderDetail[] = [];
export const store = {
// 현재 저장된 지갑 목록 반환
value: () => providers,
// 지갑 발견 이벤트를 구독
subscribe: (callback: () => void) => {
function onAnnouncement(event: EIP6963AnnounceProviderEvent) {
// 이미 등록된 지갑은 중복 추가하지 않음
if (providers.map(p => p.info.uuid).includes(event.detail.info.uuid)) {
return;
}
// 새로운 지갑 추가
providers = [...providers, event.detail];
callback(); // 구독자에게 변경 알림
}
// 지갑의 announceProvider 이벤트 리스닝
window.addEventListener("eip6963:announceProvider", onAnnouncement);
// DApp에서 지갑 탐색 요청
window.dispatchEvent(new Event("eip6963:requestProvider"));
// 클린업 함수
return () =>
window.removeEventListener("eip6963:announceProvider", onAnnouncement);
}
};
처음에는 useState로 구현했는데, 이벤트가 컴포넌트 마운트 전에 발생하면 놓치는 문제가 있었습니다. useSyncExternalStore를 사용하니 이벤트 타이밍 이슈가 깔끔하게 해결되더군요.
다음으로 이 store를 React Hook으로 감싸줍니다. src/hooks/useSyncProviders.ts:
// src/hooks/useSyncProviders.ts
import { useSyncExternalStore } from "react";
import { store } from "./store";
export const useSyncProviders = () =>
useSyncExternalStore(
store.subscribe, // 구독 함수
store.value, // 클라이언트 스냅샷
store.value // 서버 스냅샷 (SSR 지원)
);
2.3 지갑 선택 UI
이제 탐색된 지갑들을 화면에 표시하고 사용자가 선택할 수 있도록 UI를 만듭니다.
// src/components/WalletConnect.tsx
import { useState } from "react";
import { useSyncProviders } from "../hooks/useSyncProviders";
export function WalletConnect() {
const providers = useSyncProviders();
const [selectedWallet, setSelectedWallet] =
useState<EIP6963ProviderDetail | null>(null);
const [userAccount, setUserAccount] = useState<string>("");
// 지갑 연결 핸들러
const handleConnect = async (providerWithInfo: EIP6963ProviderDetail) => {
try {
// eth_requestAccounts로 계정 접근 권한 요청
const accounts = await providerWithInfo.provider.request({
method: "eth_requestAccounts"
}) as string[];
if (accounts?.[0]) {
setSelectedWallet(providerWithInfo);
setUserAccount(accounts[0]);
// 선택한 지갑의 rdns를 localStorage에 저장
localStorage.setItem("selectedWalletRdns", providerWithInfo.info.rdns);
}
} catch (error) {
console.error("지갑 연결 실패:", error);
}
};
return (
<div>
<h2>발견된 지갑</h2>
{providers.length === 0 ? (
<p>지갑을 찾지 못했습니다. 브라우저 확장 프로그램을 확인해주세요.</p>
) : (
<div className="wallet-list">
{providers.map((provider) => (
<button
key={provider.info.uuid}
onClick={() => handleConnect(provider)}
className="wallet-button"
>
<img
src={provider.info.icon}
alt={provider.info.name}
width={32}
height={32}
/>
<span>{provider.info.name}</span>
</button>
))}
</div>
)}
{userAccount && (
<div className="account-info">
<p>연결된 계정: {userAccount.slice(0, 6)}...{userAccount.slice(-4)}</p>
<p>지갑: {selectedWallet?.info.name}</p>
</div>
)}
</div>
);
}
실제로 적용해보니 아이콘 크기가 지갑마다 달라서 UI가 들쭉날쭉했습니다. CSS로 고정 크기를 지정하는 게 좋습니다:
/* styles.css */
.wallet-button {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border: 1px solid #e5e5e5;
border-radius: 8px;
background: white;
cursor: pointer;
transition: all 0.2s;
}
.wallet-button:hover {
border-color: #2962EF;
background: #f8f9ff;
}
.wallet-button img {
width: 32px;
height: 32px;
object-fit: contain; /* 비율 유지하면서 크기 맞춤 */
}
3. 블록체인 통신 구현
지갑 연결 후에는 실제로 블록체인과 통신해야겠죠. 자주 사용하는 RPC 메서드들을 정리했습니다.
계정 정보 조회
// 현재 연결된 계정 조회
const accounts = await provider.request({
method: "eth_requestAccounts"
}) as string[];
// 네트워크 ID 조회
const chainId = await provider.request({
method: "eth_chainId"
}) as string;
// 잔액 조회
const balance = await provider.request({
method: "eth_getBalance",
params: [accounts[0], "latest"]
}) as string;
트랜잭션 전송
// 트랜잭션 전송 함수
async function sendTransaction(
provider: EIP1193Provider,
from: string,
to: string,
value: string
) {
try {
const txHash = await provider.request({
method: "eth_sendTransaction",
params: [{
from,
to,
value: value, // Wei 단위 (1 ETH = 10^18 Wei)
gas: "0x5208", // 21000 gas (기본 전송)
}]
}) as string;
console.log("트랜잭션 해시:", txHash);
return txHash;
} catch (error) {
console.error("트랜잭션 실패:", error);
throw error;
}
}
서명 요청
// 메시지 서명 (로그인 등에 사용)
async function signMessage(
provider: EIP1193Provider,
account: string,
message: string
) {
try {
const signature = await provider.request({
method: "personal_sign",
params: [message, account]
}) as string;
return signature;
} catch (error) {
console.error("서명 실패:", error);
throw error;
}
}
// 사용 예시
const signature = await signMessage(
selectedWallet.provider,
userAccount,
"로그인을 위한 서명입니다."
);
실무에서는 personal_sign 대신 EIP-712 타입 서명을 사용하는 경우도 많습니다. 특히 DeFi 프로토콜에서 Permit 같은 기능을 구현할 때 유용합니다.
4. React/Next.js 프로젝트 통합
여러 컴포넌트에서 지갑 정보를 사용하려면 Context API로 전역 상태를 관리하는 게 편합니다.
Context 생성
src/context/WalletContext.tsx:
// src/context/WalletContext.tsx
import { createContext, useContext, useState, useEffect, ReactNode } from "react";
import { useSyncProviders } from "../hooks/useSyncProviders";
interface WalletContextType {
providers: EIP6963ProviderDetail[];
selectedWallet: EIP6963ProviderDetail | null;
userAccount: string;
connect: (provider: EIP6963ProviderDetail) => Promise<void>;
disconnect: () => void;
isConnected: boolean;
}
const WalletContext = createContext<WalletContextType | undefined>(undefined);
export function WalletProvider({ children }: { children: ReactNode }) {
const providers = useSyncProviders();
const [selectedWallet, setSelectedWallet] =
useState<EIP6963ProviderDetail | null>(null);
const [userAccount, setUserAccount] = useState<string>("");
// 이전에 연결했던 지갑 자동 재연결
useEffect(() => {
const savedRdns = localStorage.getItem("selectedWalletRdns");
if (savedRdns && providers.length > 0) {
const savedProvider = providers.find(p => p.info.rdns === savedRdns);
if (savedProvider) {
connect(savedProvider);
}
}
}, [providers]);
const connect = async (provider: EIP6963ProviderDetail) => {
try {
const accounts = await provider.provider.request({
method: "eth_requestAccounts"
}) as string[];
if (accounts?.[0]) {
setSelectedWallet(provider);
setUserAccount(accounts[0]);
localStorage.setItem("selectedWalletRdns", provider.info.rdns);
}
} catch (error) {
console.error("연결 실패:", error);
throw error;
}
};
const disconnect = () => {
setSelectedWallet(null);
setUserAccount("");
localStorage.removeItem("selectedWalletRdns");
};
return (
<WalletContext.Provider
value={{
providers,
selectedWallet,
userAccount,
connect,
disconnect,
isConnected: !!userAccount,
}}
>
{children}
</WalletContext.Provider>
);
}
// 커스텀 훅
export function useWallet() {
const context = useContext(WalletContext);
if (!context) {
throw new Error("useWallet은 WalletProvider 내부에서 사용해야 합니다");
}
return context;
}
Next.js에서 사용
// app/providers.tsx
'use client';
import { WalletProvider } from '@/context/WalletContext';
import { ReactNode } from 'react';
export function Providers({ children }: { children: ReactNode }) {
return (
<WalletProvider>
{children}
</WalletProvider>
);
}
// app/layout.tsx
import { Providers } from './providers';
import './globals.css';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ko">
<body>
<Providers>
{children}
</Providers>
</body>
</html>
);
}
App Router에서는 클라이언트 컴포넌트와 서버 컴포넌트를 명확히 분리해야 합니다. WalletProvider는 useState, useEffect 같은 클라이언트 훅을 사용하므로 별도의 providers.tsx 파일에 'use client' 지시자를 추가하고, layout.tsx에서 import해서 사용하는 방식입니다.
이제 어떤 페이지나 컴포넌트에서든 useWallet() 훅을 사용할 수 있습니다:
// app/page.tsx
'use client';
import { useWallet } from '@/context/WalletContext';
export default function Home() {
const { isConnected, userAccount } = useWallet();
return (
<main>
{isConnected ? (
<p>연결됨: {userAccount}</p>
) : (
<p>지갑을 연결해주세요</p>
)}
</main>
);
}
에러 핸들링
사용자가 지갑 연결을 거부하거나 네트워크가 맞지 않을 때를 대비한 에러 처리도 필요합니다:
const connect = async (provider: EIP6963ProviderDetail) => {
try {
const accounts = await provider.provider.request({
method: "eth_requestAccounts"
}) as string[];
if (accounts?.[0]) {
// 네트워크 확인
const chainId = await provider.provider.request({
method: "eth_chainId"
}) as string;
// Mainnet이 아니면 경고
if (chainId !== "0x1") {
console.warn("메인넷이 아닙니다:", chainId);
// 필요시 네트워크 전환 요청
}
setSelectedWallet(provider);
setUserAccount(accounts[0]);
}
} catch (error: any) {
if (error.code === 4001) {
// 사용자가 연결을 거부
alert("지갑 연결이 거부되었습니다.");
} else if (error.code === -32002) {
// 이미 대기 중인 요청이 있음
alert("지갑에서 이전 요청을 먼저 처리해주세요.");
} else {
alert("지갑 연결 중 오류가 발생했습니다.");
console.error(error);
}
}
};
5. 테스트 및 디버깅
eip6963.org에서 테스트
공식 테스트 사이트 eip6963 에서 설치된 지갑들이 제대로 탐색되는지 확인할 수 있습니다.
크롬 개발자 도구 활용
개발자 도구 콘솔에서 이벤트를 직접 모니터링할 수 있습니다:
// 콘솔에서 실행
window.addEventListener('eip6963:announceProvider', (event) => {
console.log('지갑 발견:', event.detail);
});
window.dispatchEvent(new Event('eip6963:requestProvider'));
자주 발생하는 이슈
1. 지갑이 탐색되지 않음
- 지갑 확장 프로그램이 실제로 설치되어 있는지 확인
- 페이지 새로고침 후 다시 시도
- EIP-6963 지원 버전인지 확인 (MetaMask 11.4.0 이상)
2. 중복 지갑 표시
uuid대신rdns로 중복 체크하도록 수정- 같은 지갑이 여러 세션으로 표시될 수 있음
3. Next.js SSR 에러
ReferenceError: window is not defined
서버 사이드에서는 window 객체가 없으므로 클라이언트에서만 실행되도록 처리:
useEffect(() => {
if (typeof window !== 'undefined') {
// window 관련 코드
}
}, []);
6. 라이브러리 활용 (선택)
직접 구현하는 것도 좋지만, 프로덕션 환경에서는 검증된 라이브러리를 사용하는 게 안전합니다.
wagmi로 구현하기
wagmi는 React Hooks 기반으로 EIP-6963를 자동 지원합니다:
// wagmi.config.ts
import { createConfig, http } from 'wagmi';
import { mainnet, sepolia } from 'wagmi/chains';
import { injected } from 'wagmi/connectors';
export const config = createConfig({
chains: [mainnet, sepolia],
connectors: [
injected(), // EIP-6963 자동 지원
],
transports: {
[mainnet.id]: http(),
[sepolia.id]: http(),
},
multiInjectedProviderDiscovery: true, // EIP-6963 활성화
});
사용은 매우 간단합니다:
import { useConnect, useAccount } from 'wagmi';
function WalletConnect() {
const { connectors, connect } = useConnect();
const { address, isConnected } = useAccount();
return (
<div>
{connectors.map((connector) => (
<button key={connector.id} onClick={() => connect({ connector })}>
{connector.name}
</button>
))}
{isConnected && <p>연결됨: {address}</p>}
</div>
);
}
RainbowKit UI 컴포넌트
Rainbowkit은 아예 UI까지 제공합니다:
import { RainbowKitProvider, ConnectButton } from '@rainbow-me/rainbowkit';
import { WagmiProvider } from 'wagmi';
import '@rainbow-me/rainbowkit/styles.css';
function App() {
return (
<WagmiProvider config={config}>
<RainbowKitProvider>
<ConnectButton /> {/* 이게 끝 */}
</RainbowKitProvider>
</WagmiProvider>
);
}
직접 구현 vs 라이브러리
| 항목 | 직접 구현 | wagmi/RainbowKit |
|---|---|---|
| 학습 곡선 | 낮음 (기본 개념만 필요) | 중간 (라이브러리 API 학습) |
| 커스터마이징 | 완전한 자유 | 제한적 (테마 수정 정도) |
| 유지보수 | 직접 관리 필요 | 커뮤니티 지원 |
| 번들 크기 | 최소 (~5KB) | 큼 (wagmi ~50KB, RainbowKit ~100KB) |
| 기능 | 기본 연결만 | 네트워크 전환, ENS, 트랜잭션 관리 등 |
지갑 연결만 필요하고 번들 크기를 최소화하고 싶다면 직접 구현을 추천하고, 복잡한 DeFi 프로토콜이라면 wagmi를 추천합니다.
7. 마무리
EIP-6963 구현을 마쳤습니다! 핵심은 이벤트 기반 통신을 이해하고, React의 useSyncExternalStore로 상태를 동기화하는 것입니다.
완성된 데모 프로젝트
공식 예제 저장소들을 참고하시면 도움이 됩니다:
- MetaMask 공식 예제: https://github.com/MetaMask/vite-react-ts-eip-6963
- LUKSO 네트워크 예제: https://github.com/lukso-network/example-eip-6963-test-dapp
제가 개인적으로 만든 데모 레포지토리도 확인해보세요~!
추가 학습 리소스
- EIP-6963 공식 문서: https://eips.ethereum.org/EIPS/eip-6963
- MetaMask 개발자 가이드: https://metamask.io/news/how-to-implement-eip-6963-support-in-your-web3-dapp
- Web3.js EIP-6963 가이드: https://docs.web3js.org/guides/web3_providers_guide/eip6963/
프로덕션 배포 시 체크리스트
배포 전에 다음 항목들을 확인하세요:
- 주요 지갑 테스트 완료 (MetaMask, Coinbase Wallet, Phantom 등)
- localStorage에 마지막 선택 지갑 저장 (rdns 사용)
- 에러 핸들링 (연결 거부, 네트워크 불일치 등)
- 네트워크 전환 로직 추가 (필요시)
- SSR 환경에서
window객체 접근 보호 - 지갑 연결 상태 변경 이벤트 구독 (
accountsChanged,chainChanged) - 보안 검토 (XSS 방지, icon URL 검증 등)
특히 accountsChanged 이벤트 처리를 빠뜨리면 사용자가 지갑 계정을 바꿔도 화면이 업데이트되지 않는 버그가 발생할 수 있습니다. 이 부분 꼭 확인하세요:
useEffect(() => {
if (!selectedWallet) return;
const handleAccountsChanged = (accounts: string[]) => {
if (accounts.length === 0) {
disconnect();
} else {
setUserAccount(accounts[0]);
}
};
const handleChainChanged = () => {
window.location.reload(); // 네트워크 변경 시 새로고침
};
selectedWallet.provider.on?.('accountsChanged', handleAccountsChanged);
selectedWallet.provider.on?.('chainChanged', handleChainChanged);
return () => {
selectedWallet.provider.removeListener?.('accountsChanged', handleAccountsChanged);
selectedWallet.provider.removeListener?.('chainChanged', handleChainChanged);
};
}, [selectedWallet]);
이제 여러분의 DApp에서도 사용자가 원하는 지갑을 자유롭게 선택할 수 있을 겁니다. 궁금한 점이 있다면 댓글로 남겨주세요!
'Blockchain' 카테고리의 다른 글
| 암호화폐 지갑의 비밀: 12개 단어가 모든 것을 담는 이유 (0) | 2026.02.08 |
|---|---|
| ERC 토큰 표준 둘러보기 (0) | 2026.02.07 |
| EIP-6963 1편: 지갑 연결의 새로운 표준 EIP-6963 (0) | 2026.01.27 |
| Ethers.js 없이 ERC-20 토큰 단위 변환하기 (0) | 2026.01.26 |
| 블록체인 도메인 개발자가 JSON-RPC를 꼭 알아야 하는 이유 (0) | 2026.01.26 |