Ver3.0
์ ์ฒด ์ด๋
Ver3.0 ๐ฆ ๋ผ๋ผ๋ฒจ์์ ๋ฉํ๋ง์คํฌ ๋ก๊ทธ์ธ UX ๊ฐ์ ํ๊ธฐ

๐ฆ ๋ผ๋ผ๋ฒจ์์ ๋ฉํ๋ง์คํฌ ๋ก๊ทธ์ธ UX ๊ฐ์ ํ๊ธฐ
Web3 ์๋, ์ฌ์ฉ์ ๊ฒฝํ์ ํ ๋จ๊ณ ์
๊ทธ๋ ์ด๋ํ๋ ์ค์ ๊ฐ์ด๋
## ๐ฏ ์ ๋ฉํ๋ง์คํฌ ๋ก๊ทธ์ธ UX๊ฐ ์ค์ํ ๊น?
์๋
! ์ค๋์ ๋ผ๋ผ๋ฒจ์์ ๋ฉํ๋ง์คํฌ ๋ก๊ทธ์ธ UX๋ฅผ ๊ฐ์ ํ๋ ๋ฐฉ๋ฒ์ ๋ํด ์ด์ผ๊ธฐํด๋ณผ๊ฒ. ์์ฆ Web3 ํ๋ก์ ํธ๋ฅผ ์งํํ๋ค ๋ณด๋ฉด ๋ฉํ๋ง์คํฌ ์ฐ๋์ ๊ฑฐ์ ํ์๊ฐ ๋์์ง? ๐ฆ
๊ทธ๋ฐ๋ฐ ๋ง์ด์ผ, ๋ง์ ๊ฐ๋ฐ์๋ค์ด ๊ธฐ๋ฅ ๊ตฌํ์๋ง ์ง์คํ๋ค๊ฐ ์ ์ ์ฌ์ฉ์ ๊ฒฝํ span>์ ๋์น๋ ๊ฒฝ์ฐ๊ฐ ๋ง์. "์ด? ์ ์ ๋์ง?" ํ๋ฉด์ ์ฌ์ฉ์๊ฐ ์ดํํ๋ ์๊ฐ, ๊ทธ๊ฒ ๋ฐ๋ก UX ๊ฐ์ ์ด ํ์ํ ์์ ์ด์ผ.
์ค์ ๋ก Web3 ์๋น์ค์์ ์ฌ์ฉ์ ์ดํ๋ฅ ์ ๋ถ์ํด๋ณด๋ฉด, ์ฒซ ๋ก๊ทธ์ธ ๋จ๊ณ์์๋ง ์ฝ 40% ์ด์์ด ์ดํํ๋ค๋ ํต๊ณ๊ฐ ์์ด. ์ด๊ฒ ์ผ๋ง๋ ์๊น์ด ์ผ์ธ์ง ์์ง? ๐ฑ
๊ทธ๋ฐ๋ฐ ๋ง์ด์ผ, ๋ง์ ๊ฐ๋ฐ์๋ค์ด ๊ธฐ๋ฅ ๊ตฌํ์๋ง ์ง์คํ๋ค๊ฐ ์ ์ ์ฌ์ฉ์ ๊ฒฝํ span>์ ๋์น๋ ๊ฒฝ์ฐ๊ฐ ๋ง์. "์ด? ์ ์ ๋์ง?" ํ๋ฉด์ ์ฌ์ฉ์๊ฐ ์ดํํ๋ ์๊ฐ, ๊ทธ๊ฒ ๋ฐ๋ก UX ๊ฐ์ ์ด ํ์ํ ์์ ์ด์ผ.
์ค์ ๋ก Web3 ์๋น์ค์์ ์ฌ์ฉ์ ์ดํ๋ฅ ์ ๋ถ์ํด๋ณด๋ฉด, ์ฒซ ๋ก๊ทธ์ธ ๋จ๊ณ์์๋ง ์ฝ 40% ์ด์์ด ์ดํํ๋ค๋ ํต๊ณ๊ฐ ์์ด. ์ด๊ฒ ์ผ๋ง๋ ์๊น์ด ์ผ์ธ์ง ์์ง? ๐ฑ
๐ก ํต์ฌ ํฌ์ธํธ
๋ฉํ๋ง์คํฌ ๋ก๊ทธ์ธ UX ๊ฐ์ ์ ๋จ์ํ '์์๊ฒ ๋ง๋ค๊ธฐ'๊ฐ ์๋์ผ. ์ฌ์ฉ์๊ฐ ๋งํ์์ด ์๋น์ค์ ์ง์ ํ ์ ์๋๋ก ํ๋ ๋น์ฆ๋์ค ์ ๋ต์ด์ง!
๋ฉํ๋ง์คํฌ ๋ก๊ทธ์ธ UX ๊ฐ์ ์ ๋จ์ํ '์์๊ฒ ๋ง๋ค๊ธฐ'๊ฐ ์๋์ผ. ์ฌ์ฉ์๊ฐ ๋งํ์์ด ์๋น์ค์ ์ง์ ํ ์ ์๋๋ก ํ๋ ๋น์ฆ๋์ค ์ ๋ต์ด์ง!
## ๐ ๋ฉํ๋ง์คํฌ ๋ก๊ทธ์ธ์ ์ผ๋ฐ์ ์ธ ๋ฌธ์ ์ ๋ค
์ค์ ํ๋ก์ ํธ์์ ์์ฃผ ๋ง์ฃผ์น๋ ๋ฌธ์ ๋ค์ ์ ๋ฆฌํด๋ดค์ด. ํน์ ๋๋ ์ด๋ฐ ๊ฒฝํ ์์ง ์์?
1๋ฉํ๋ง์คํฌ๊ฐ ์ค์น๋์ง ์์์ ๋
์ฌ์ฉ์๊ฐ ๋ฉํ๋ง์คํฌ๋ฅผ ์ค์นํ์ง ์์ ์ํ์์ "Connect Wallet" ๋ฒํผ์ ๋๋ ๋๋ฐ, ์๋ฌด ๋ฐ์๋ ์๊ฑฐ๋ ์๋ฌ๋ง ๋จ๋ ๊ฒฝ์ฐ. ์ด๊ฑด ์ ๋ง ์ต์ ์ UX์ผ. ์ฌ์ฉ์๋ "์ด ์ฌ์ดํธ ๋ง๊ฐ์ง ๊ฑฐ ์๋?"๋ผ๊ณ ์๊ฐํ๊ณ ๋ฐ๋ก ๋๊ฐ๋ฒ๋ฆฌ๊ฑฐ๋ .
์ฌ์ฉ์๊ฐ ๋ฉํ๋ง์คํฌ๋ฅผ ์ค์นํ์ง ์์ ์ํ์์ "Connect Wallet" ๋ฒํผ์ ๋๋ ๋๋ฐ, ์๋ฌด ๋ฐ์๋ ์๊ฑฐ๋ ์๋ฌ๋ง ๋จ๋ ๊ฒฝ์ฐ. ์ด๊ฑด ์ ๋ง ์ต์ ์ UX์ผ. ์ฌ์ฉ์๋ "์ด ์ฌ์ดํธ ๋ง๊ฐ์ง ๊ฑฐ ์๋?"๋ผ๊ณ ์๊ฐํ๊ณ ๋ฐ๋ก ๋๊ฐ๋ฒ๋ฆฌ๊ฑฐ๋ .
2๋คํธ์ํฌ ๋ถ์ผ์น ๋ฌธ์
์ฌ์ฉ์๊ฐ ์ด๋๋ฆฌ์ ๋ฉ์ธ๋ท์ ์ฐ๊ฒฐ๋์ด ์๋๋ฐ, ์๋น์ค๋ ํด๋ฆฌ๊ณค ๋คํธ์ํฌ๋ฅผ ์๊ตฌํ๋ ๊ฒฝ์ฐ. ์ด๋ "Wrong Network"๋ผ๋ ๋ฉ์์ง๋ง ๋์ ธ์ฃผ๋ฉด ์ฌ์ฉ์๋ ์ด๋ป๊ฒ ํด์ผ ํ ์ง ๋ชฐ๋ผ์ ๋นํฉํ๊ฒ ๋ผ.
์ฌ์ฉ์๊ฐ ์ด๋๋ฆฌ์ ๋ฉ์ธ๋ท์ ์ฐ๊ฒฐ๋์ด ์๋๋ฐ, ์๋น์ค๋ ํด๋ฆฌ๊ณค ๋คํธ์ํฌ๋ฅผ ์๊ตฌํ๋ ๊ฒฝ์ฐ. ์ด๋ "Wrong Network"๋ผ๋ ๋ฉ์์ง๋ง ๋์ ธ์ฃผ๋ฉด ์ฌ์ฉ์๋ ์ด๋ป๊ฒ ํด์ผ ํ ์ง ๋ชฐ๋ผ์ ๋นํฉํ๊ฒ ๋ผ.
3๋ก๋ฉ ์ํ ํ์ ๋ถ์ฌ
๋ธ๋ก์ฒด์ธ ํธ๋์ญ์ ์ ์๊ฐ์ด ๊ฑธ๋ฆฌ์์? ๊ทธ๋ฐ๋ฐ ๋ก๋ฉ ์ธ๋์ผ์ดํฐ๊ฐ ์์ผ๋ฉด ์ฌ์ฉ์๋ "์ด๊ฑฐ ๋จนํต๋ ๊ฑฐ ์๋?"๋ผ๊ณ ์๊ฐํ๊ณ ์๋ก๊ณ ์นจ์ ๋๋ฅด๊ฒ ๋ผ. ๊ทธ๋ฌ๋ฉด ์ฒ์๋ถํฐ ๋ค์ ์์ํด์ผ ํ๋ ์ ์ํ์ด ๋ฐ๋ณต๋์ง.
๋ธ๋ก์ฒด์ธ ํธ๋์ญ์ ์ ์๊ฐ์ด ๊ฑธ๋ฆฌ์์? ๊ทธ๋ฐ๋ฐ ๋ก๋ฉ ์ธ๋์ผ์ดํฐ๊ฐ ์์ผ๋ฉด ์ฌ์ฉ์๋ "์ด๊ฑฐ ๋จนํต๋ ๊ฑฐ ์๋?"๋ผ๊ณ ์๊ฐํ๊ณ ์๋ก๊ณ ์นจ์ ๋๋ฅด๊ฒ ๋ผ. ๊ทธ๋ฌ๋ฉด ์ฒ์๋ถํฐ ๋ค์ ์์ํด์ผ ํ๋ ์ ์ํ์ด ๋ฐ๋ณต๋์ง.
4์๋ฌ ๋ฉ์์ง๊ฐ ๋ถ์น์ ํจ
"Error: User rejected the request" ๊ฐ์ ๊ธฐ์ ์ ์ธ ๋ฉ์์ง๋ง ๋ณด์ฌ์ฃผ๋ฉด, ์ผ๋ฐ ์ฌ์ฉ์๋ ์ดํดํ ์ ์์ด. "๋ฉํ๋ง์คํฌ์์ ์ฐ๊ฒฐ์ ๊ฑฐ๋ถํ์ จ์ต๋๋ค. ๋ค์ ์๋ํด์ฃผ์ธ์!" ๊ฐ์ ์น์ ํ ์๋ด๊ฐ ํ์ํด.
"Error: User rejected the request" ๊ฐ์ ๊ธฐ์ ์ ์ธ ๋ฉ์์ง๋ง ๋ณด์ฌ์ฃผ๋ฉด, ์ผ๋ฐ ์ฌ์ฉ์๋ ์ดํดํ ์ ์์ด. "๋ฉํ๋ง์คํฌ์์ ์ฐ๊ฒฐ์ ๊ฑฐ๋ถํ์ จ์ต๋๋ค. ๋ค์ ์๋ํด์ฃผ์ธ์!" ๊ฐ์ ์น์ ํ ์๋ด๊ฐ ํ์ํด.
5๋ชจ๋ฐ์ผ ํ๊ฒฝ ๊ณ ๋ ค ๋ถ์กฑ
PC์์๋ ์ ๋๋๋ฐ ๋ชจ๋ฐ์ผ์์๋ ๋ฉํ๋ง์คํฌ ์ฑ์ด ์ ๋๋ก ์ด๋ฆฌ์ง ์๋ ๊ฒฝ์ฐ. ํนํ ๋ชจ๋ฐ์ผ ๋ธ๋ผ์ฐ์ ์์ ๋ฅ๋งํฌ ์ฒ๋ฆฌ๊ฐ ์ ๋๋ฉด ์ฌ์ฉ์๋ ์์ฒญ ๋ถํธํดํด.
PC์์๋ ์ ๋๋๋ฐ ๋ชจ๋ฐ์ผ์์๋ ๋ฉํ๋ง์คํฌ ์ฑ์ด ์ ๋๋ก ์ด๋ฆฌ์ง ์๋ ๊ฒฝ์ฐ. ํนํ ๋ชจ๋ฐ์ผ ๋ธ๋ผ์ฐ์ ์์ ๋ฅ๋งํฌ ์ฒ๋ฆฌ๊ฐ ์ ๋๋ฉด ์ฌ์ฉ์๋ ์์ฒญ ๋ถํธํดํด.
## ๐ ๏ธ ๋ผ๋ผ๋ฒจ ํ๊ฒฝ ์ค์ ๋ถํฐ ์์ํ๊ธฐ
์, ์ด์ ๋ณธ๊ฒฉ์ ์ผ๋ก ๋ผ๋ผ๋ฒจ์์ ๋ฉํ๋ง์คํฌ ๋ก๊ทธ์ธ UX๋ฅผ ๊ฐ์ ํ๋ ๋ฐฉ๋ฒ์ ์์๋ณผ๊ฒ. ๋จผ์ ๊ธฐ๋ณธ์ ์ธ ํ๊ฒฝ ์ค์ ๋ถํฐ ์์ํด๋ณด์!
### ๐ฆ ํ์ํ ํจํค์ง ์ค์น
๋ผ๋ผ๋ฒจ ํ๋ก์ ํธ์์ Web3 ๊ธฐ๋ฅ์ ๊ตฌํํ๋ ค๋ฉด ๋ช ๊ฐ์ง ํจํค์ง๊ฐ ํ์ํด. ํ๋ก ํธ์๋์์๋ ์ฃผ๋ก ethers.js๋ web3.js๋ฅผ ์ฌ์ฉํ๊ณ , ๋ฐฑ์๋์์๋ PHP์ฉ Web3 ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ํ์ฉํ ์ ์์ด.
๐ package.json
{
"dependencies": {
"ethers": "^6.9.0",
"axios": "^1.6.0"
}
}
๐ง ์ค์น ๋ช
๋ น์ด
npm install ethers axios
composer require web3p/web3.php
๐ก ํ: ethers.js๋ web3.js๋ณด๋ค ๊ฐ๋ณ๊ณ ํ์
์คํฌ๋ฆฝํธ ์ง์์ด ์ฐ์ํด์ ์์ฆ ๋ ๋ง์ด ์ฌ์ฉ๋๋ ์ถ์ธ์ผ. ํนํ ๋ฒ๋ค ์ฌ์ด์ฆ๊ฐ ์์์ ๋ก๋ฉ ์๋ ๊ฐ์ ์๋ ๋์์ด ๋ผ!
### ๐ ํ๊ฒฝ ๋ณ์ ์ค์
๋ผ๋ผ๋ฒจ์ `.env` ํ์ผ์ Web3 ๊ด๋ จ ์ค์ ์ ์ถ๊ฐํด์ผ ํด. ์ด๋ ๊ฒ ํ๋ฉด ๋คํธ์ํฌ ์ค์ ์ ์ฝ๊ฒ ๊ด๋ฆฌํ ์ ์์ด.
๐ .env
WEB3_NETWORK_NAME=Ethereum
WEB3_CHAIN_ID=1
WEB3_RPC_URL=https://mainnet.infura.io/v3/YOUR_INFURA_KEY
WEB3_EXPLORER_URL=https://etherscan.io
# ํ
์คํธ๋ท ์ค์ (๊ฐ๋ฐ์ฉ)
WEB3_TESTNET_NAME=Sepolia
WEB3_TESTNET_CHAIN_ID=11155111
WEB3_TESTNET_RPC_URL=https://sepolia.infura.io/v3/YOUR_INFURA_KEY
๐ฏ ์ ํ๊ฒฝ ๋ณ์๋ก ๊ด๋ฆฌํ ๊น?
๊ฐ๋ฐ ํ๊ฒฝ์์๋ ํ ์คํธ๋ท์, ํ๋ก๋์ ์์๋ ๋ฉ์ธ๋ท์ ์ฌ์ฉํด์ผ ํ์์? ํ๊ฒฝ ๋ณ์๋ก ๊ด๋ฆฌํ๋ฉด ์ฝ๋ ์์ ์์ด ๋ฐฐํฌ ํ๊ฒฝ์ ๋ฐ๋ผ ์๋์ผ๋ก ๋คํธ์ํฌ๊ฐ ์ ํ๋ผ. ์ด๊ฒ ๋ฐ๋ก ํ๋กํ์ ๋ํ ๊ฐ๋ฐ ๋ฐฉ์์ด์ผ! ๐
### ๐ Config ํ์ผ ์์ฑ
๋ผ๋ผ๋ฒจ์ config ๋๋ ํ ๋ฆฌ์ web3.php ํ์ผ์ ๋ง๋ค์ด์ ์ค์ ์ ์ค์ํํ์.
๊ฐ๋ฐ ํ๊ฒฝ์์๋ ํ ์คํธ๋ท์, ํ๋ก๋์ ์์๋ ๋ฉ์ธ๋ท์ ์ฌ์ฉํด์ผ ํ์์? ํ๊ฒฝ ๋ณ์๋ก ๊ด๋ฆฌํ๋ฉด ์ฝ๋ ์์ ์์ด ๋ฐฐํฌ ํ๊ฒฝ์ ๋ฐ๋ผ ์๋์ผ๋ก ๋คํธ์ํฌ๊ฐ ์ ํ๋ผ. ์ด๊ฒ ๋ฐ๋ก ํ๋กํ์ ๋ํ ๊ฐ๋ฐ ๋ฐฉ์์ด์ผ! ๐
๐ config/web3.php
<?php
return [
'networks' => [
'mainnet' => [
'name' => env('WEB3_NETWORK_NAME', 'Ethereum'),
'chain_id' => env('WEB3_CHAIN_ID', 1),
'rpc_url' => env('WEB3_RPC_URL'),
'explorer_url' => env('WEB3_EXPLORER_URL'),
'currency' => [
'name' => 'Ether',
'symbol' => 'ETH',
'decimals' => 18
]
],
'testnet' => [
'name' => env('WEB3_TESTNET_NAME', 'Sepolia'),
'chain_id' => env('WEB3_TESTNET_CHAIN_ID', 11155111),
'rpc_url' => env('WEB3_TESTNET_RPC_URL'),
'currency' => [
'name' => 'Sepolia Ether',
'symbol' => 'SEP',
'decimals' => 18
]
]
],
'session_timeout' => 3600, // 1์๊ฐ
'nonce_expiry' => 300, // 5๋ถ
];
์ด๋ ๊ฒ ์ค์ ํ๋ฉด ์ปจํธ๋กค๋ฌ์์ `config('web3.networks.mainnet.chain_id')` ๊ฐ์ ๋ฐฉ์์ผ๋ก ์ฝ๊ฒ ์ ๊ทผํ ์ ์์ด!
## ๐จ ํ๋ก ํธ์๋: ์ฌ์ฉ์ ์นํ์ ์ธ ์ฐ๊ฒฐ ๋ฒํผ ๋ง๋ค๊ธฐ
์ด์ ๋ณธ๊ฒฉ์ ์ผ๋ก UX ๊ฐ์ ์ ํต์ฌ์ธ ํ๋ก ํธ์๋ ์์
์ ํด๋ณด์. ๋จ์ํ "Connect Wallet" ๋ฒํผ ํ๋ ๋ง๋๋ ๊ฒ ์๋๋ผ, ์ฌ์ฉ์์ ๋ชจ๋ ์ํฉ์ ๊ณ ๋ คํ ์ค๋งํธํ ์ธํฐํ์ด์ค๋ฅผ ๊ตฌํํ ๊ฑฐ์ผ! ๐ฏ
### ๐ฆ ๋ฉํ๋ง์คํฌ ๊ฐ์ง ๋ฐ ์ค์น ์ ๋
๊ฐ์ฅ ๋จผ์ ํด์ผ ํ ์ผ์ ์ฌ์ฉ์์ ๋ธ๋ผ์ฐ์ ์ ๋ฉํ๋ง์คํฌ๊ฐ ์ค์น๋์ด ์๋์ง ํ์ธํ๋ ๊ฑฐ์ผ. ์ค์น๋์ง ์์๋ค๋ฉด ์น์ ํ๊ฒ ์ค์น ํ์ด์ง๋ก ์๋ดํด์ค์ผ ํด.
๐ resources/js/web3/metamask.js
class MetaMaskConnector {
constructor() {
this.ethereum = window.ethereum;
this.provider = null;
this.signer = null;
this.account = null;
}
// ๋ฉํ๋ง์คํฌ ์ค์น ์ฌ๋ถ ํ์ธ
isMetaMaskInstalled() {
return typeof this.ethereum !== 'undefined' && this.ethereum.isMetaMask;
}
// ๋ฉํ๋ง์คํฌ ์ค์น ์ ๋
promptInstall() {
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
if (isMobile) {
// ๋ชจ๋ฐ์ผ: ๋ฉํ๋ง์คํฌ ์ฑ ๋ค์ด๋ก๋ ๋๋ ์ธ์ฑ ๋ธ๋ผ์ฐ์ ๋ก ์ ๋
const dappUrl = window.location.href;
window.location.href = `https://metamask.app.link/dapp/${dappUrl}`;
} else {
// ๋ฐ์คํฌํฑ: ์ค์น ํ์ด์ง๋ก ์ด๋
window.open('https://metamask.io/download/', '_blank');
}
}
// ์ฌ์ฉ์ ์นํ์ ์ธ ์๋ฌ ๋ฉ์์ง ์์ฑ
getErrorMessage(error) {
const errorMessages = {
4001: '๋ฉํ๋ง์คํฌ์์ ์ฐ๊ฒฐ์ ๊ฑฐ๋ถํ์
จ์ต๋๋ค. ๋ค์ ์๋ํด์ฃผ์ธ์.',
4100: '์์ฒญํ ๋ฉ์๋๊ฐ ์ง์๋์ง ์์ต๋๋ค.',
4200: '๋ฉํ๋ง์คํฌ๊ฐ ์ ๊ฒจ์์ต๋๋ค. ์ ๊ธ์ ํด์ ํด์ฃผ์ธ์.',
4900: '๋ฉํ๋ง์คํฌ ์ฐ๊ฒฐ์ด ๋์ด์ก์ต๋๋ค.',
4901: '๋ฉํ๋ง์คํฌ๊ฐ ์ฐ๊ฒฐ๋์ง ์์์ต๋๋ค.',
'-32002': '์ด๋ฏธ ๋ฉํ๋ง์คํฌ ์ฐ๊ฒฐ ์์ฒญ์ด ์งํ ์ค์
๋๋ค. ๋ฉํ๋ง์คํฌ๋ฅผ ํ์ธํด์ฃผ์ธ์.',
'-32603': '๋ด๋ถ ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค. ํ์ด์ง๋ฅผ ์๋ก๊ณ ์นจํด์ฃผ์ธ์.'
};
const code = error.code?.toString();
return errorMessages[code] || `์ฐ๊ฒฐ ์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค: ${error.message}`;
}
}
export default MetaMaskConnector;
๐ฏ ๋ชจ๋ฐ์ผ ํ๊ฒฝ ํน๋ณ ์ฒ๋ฆฌ
๋ชจ๋ฐ์ผ์์๋ ์ผ๋ฐ ๋ธ๋ผ์ฐ์ ๊ฐ ์๋ ๋ฉํ๋ง์คํฌ ์ธ์ฑ ๋ธ๋ผ์ฐ์ ๋ฅผ ์ฌ์ฉํด์ผ ํด. `metamask.app.link`๋ฅผ ์ฌ์ฉํ๋ฉด ์๋์ผ๋ก ๋ฉํ๋ง์คํฌ ์ฑ์ด ์ด๋ฆฌ๋ฉด์ ํด๋น ํ์ด์ง๋ก ์ด๋ํ๊ฒ ๋ผ. ์ด๊ฒ ๋ฐ๋ก ๋ชจ๋ฐ์ผ UX์ ํต์ฌ์ด์ผ!
### ๐ญ ๋ค์ํ ์ํ๋ฅผ ํํํ๋ UI ์ปดํฌ๋ํธ
์ฌ์ฉ์๋ ํญ์ ํ์ฌ ์ํ๋ฅผ ์๊ณ ์ถ์ด ํด. "์ง๊ธ ๋ญ๊ฐ ์งํ๋๊ณ ์๋ ๊ฑฐ์ง?"๋ผ๋ ์๋ฌธ์ด ๋ค์ง ์๋๋ก ๋ช
ํํ ํผ๋๋ฐฑ์ ์ ๊ณตํด์ผ ํด.
๋ชจ๋ฐ์ผ์์๋ ์ผ๋ฐ ๋ธ๋ผ์ฐ์ ๊ฐ ์๋ ๋ฉํ๋ง์คํฌ ์ธ์ฑ ๋ธ๋ผ์ฐ์ ๋ฅผ ์ฌ์ฉํด์ผ ํด. `metamask.app.link`๋ฅผ ์ฌ์ฉํ๋ฉด ์๋์ผ๋ก ๋ฉํ๋ง์คํฌ ์ฑ์ด ์ด๋ฆฌ๋ฉด์ ํด๋น ํ์ด์ง๋ก ์ด๋ํ๊ฒ ๋ผ. ์ด๊ฒ ๋ฐ๋ก ๋ชจ๋ฐ์ผ UX์ ํต์ฌ์ด์ผ!
๐ resources/views/components/wallet-connect.blade.php
<div id="wallet-connect-container" class="wallet-container">
<!-- ๋ฉํ๋ง์คํฌ ๋ฏธ์ค์น ์ํ -->
<div id="install-prompt" class="wallet-state" style="display: none;">
<div class="wallet-icon">๐ฆ</div>
<h3>๋ฉํ๋ง์คํฌ๊ฐ ํ์ํด์</h3>
<p>Web3 ์๋น์ค๋ฅผ ์ด์ฉํ๋ ค๋ฉด ๋ฉํ๋ง์คํฌ ์ง๊ฐ์ด ํ์ํฉ๋๋ค.</p>
<button id="install-metamask-btn" class="btn-primary">
๋ฉํ๋ง์คํฌ ์ค์นํ๊ธฐ
</button>
</div>
<!-- ์ฐ๊ฒฐ ๋๊ธฐ ์ํ -->
<div id="connect-prompt" class="wallet-state" style="display: none;">
<div class="wallet-icon">๐</div>
<h3>์ง๊ฐ ์ฐ๊ฒฐํ๊ธฐ</h3>
<p>๋ฉํ๋ง์คํฌ๋ฅผ ์ฐ๊ฒฐํ์ฌ ์๋น์ค๋ฅผ ์์ํ์ธ์.</p>
<button id="connect-wallet-btn" class="btn-primary">
๋ฉํ๋ง์คํฌ ์ฐ๊ฒฐ
</button>
</div>
<!-- ์ฐ๊ฒฐ ์งํ ์ค -->
<div id="connecting-state" class="wallet-state" style="display: none;">
<div class="spinner"></div>
<h3>์ฐ๊ฒฐ ์ค...</h3>
<p>๋ฉํ๋ง์คํฌ์์ ์ฐ๊ฒฐ์ ์น์ธํด์ฃผ์ธ์.</p>
</div>
<!-- ๋คํธ์ํฌ ์ ํ ํ์ -->
<div id="network-switch" class="wallet-state" style="display: none;">
<div class="wallet-icon">๐</div>
<h3>๋คํธ์ํฌ ์ ํ์ด ํ์ํด์</h3>
<p id="network-message"></p>
<button id="switch-network-btn" class="btn-primary">
๋คํธ์ํฌ ์ ํํ๊ธฐ
</button>
</div>
<!-- ์ฐ๊ฒฐ ์๋ฃ -->
<div id="connected-state" class="wallet-state" style="display: none;">
<div class="wallet-icon">โ
</div>
<div class="wallet-info">
<div class="wallet-address" id="wallet-address"></div>
<div class="wallet-balance" id="wallet-balance"></div>
</div>
<button id="disconnect-btn" class="btn-secondary">
์ฐ๊ฒฐ ํด์
</button>
</div>
<!-- ์๋ฌ ์ํ -->
<div id="error-state" class="wallet-state" style="display: none;">
<div class="wallet-icon">โ ๏ธ</div>
<h3>์ค๋ฅ๊ฐ ๋ฐ์ํ์ด์</h3>
<p id="error-message"></p>
<button id="retry-btn" class="btn-primary">
๋ค์ ์๋ํ๊ธฐ
</button>
</div>
</div>
๋ณด๋ค์ํผ ๊ฐ ์ํ๋ณ๋ก ๋ช
ํํ UI๋ฅผ ์ ๊ณตํ๊ณ ์์ด. ์ฌ์ฉ์๋ ์ง๊ธ ๋ฌด์จ ์ผ์ด ์ผ์ด๋๊ณ ์๋์ง, ๋ค์์ ๋ฌด์์ ํด์ผ ํ๋์ง ๋ฐ๋ก ์ ์ ์์ง!
### ๐ฌ ์ํ ๊ด๋ฆฌ ๋ก์ง ๊ตฌํ
์ด์ ์ด UI๋ค์ ์ ์ ํ ํ์ด๋ฐ์ ๋ณด์ฌ์ฃผ๊ณ ์จ๊ธฐ๋ ๋ก์ง์ ๋ง๋ค์ด๋ณด์.
๐ resources/js/web3/wallet-ui.js
import MetaMaskConnector from './metamask.js';
import { ethers } from 'ethers';
class WalletUI {
constructor() {
this.connector = new MetaMaskConnector();
this.currentState = 'init';
this.requiredChainId = window.APP_CONFIG?.chainId || 1;
this.init();
}
init() {
this.bindEvents();
this.checkInitialState();
}
// ์ด๊ธฐ ์ํ ํ์ธ
async checkInitialState() {
if (!this.connector.isMetaMaskInstalled()) {
this.showState('install-prompt');
return;
}
// ์ด์ ์ ์ฐ๊ฒฐ๋ ๊ณ์ ์ด ์๋์ง ํ์ธ
try {
const accounts = await this.connector.ethereum.request({
method: 'eth_accounts'
});
if (accounts.length > 0) {
await this.handleConnection(accounts[0]);
} else {
this.showState('connect-prompt');
}
} catch (error) {
console.error('Initial state check failed:', error);
this.showState('connect-prompt');
}
}
// ์ด๋ฒคํธ ๋ฐ์ธ๋ฉ
bindEvents() {
document.getElementById('install-metamask-btn')?.addEventListener('click', () => {
this.connector.promptInstall();
});
document.getElementById('connect-wallet-btn')?.addEventListener('click', () => {
this.connectWallet();
});
document.getElementById('switch-network-btn')?.addEventListener('click', () => {
this.switchNetwork();
});
document.getElementById('disconnect-btn')?.addEventListener('click', () => {
this.disconnect();
});
document.getElementById('retry-btn')?.addEventListener('click', () => {
this.checkInitialState();
});
// ๋ฉํ๋ง์คํฌ ์ด๋ฒคํธ ๋ฆฌ์ค๋
if (this.connector.ethereum) {
this.connector.ethereum.on('accountsChanged', (accounts) => {
if (accounts.length === 0) {
this.showState('connect-prompt');
} else {
this.handleConnection(accounts[0]);
}
});
this.connector.ethereum.on('chainChanged', () => {
window.location.reload();
});
}
}
// ์ง๊ฐ ์ฐ๊ฒฐ
async connectWallet() {
this.showState('connecting-state');
try {
const accounts = await this.connector.ethereum.request({
method: 'eth_requestAccounts'
});
await this.handleConnection(accounts[0]);
} catch (error) {
console.error('Connection error:', error);
this.showError(this.connector.getErrorMessage(error));
}
}
// ์ฐ๊ฒฐ ์ฒ๋ฆฌ
async handleConnection(account) {
try {
// ๋คํธ์ํฌ ํ์ธ
const chainId = await this.connector.ethereum.request({
method: 'eth_chainId'
});
const currentChainId = parseInt(chainId, 16);
if (currentChainId !== this.requiredChainId) {
this.showNetworkSwitch(currentChainId);
return;
}
// Provider ๋ฐ Signer ์ค์
this.connector.provider = new ethers.BrowserProvider(this.connector.ethereum);
this.connector.signer = await this.connector.provider.getSigner();
this.connector.account = account;
// ์์ก ์กฐํ
const balance = await this.connector.provider.getBalance(account);
const balanceInEth = ethers.formatEther(balance);
// ๋ฐฑ์๋์ ์ธ์ฆ ์์ฒญ
await this.authenticateWithBackend(account);
// UI ์
๋ฐ์ดํธ
this.showConnected(account, balanceInEth);
} catch (error) {
console.error('Handle connection error:', error);
this.showError('์ฐ๊ฒฐ ์ฒ๋ฆฌ ์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค.');
}
}
// ๋คํธ์ํฌ ์ ํ
async switchNetwork() {
const networkConfig = this.getNetworkConfig(this.requiredChainId);
try {
await this.connector.ethereum.request({
method: 'wallet_switchEthereumChain',
params: [{ chainId: `0x${this.requiredChainId.toString(16)}` }],
});
} catch (switchError) {
// ๋คํธ์ํฌ๊ฐ ๋ฉํ๋ง์คํฌ์ ์ถ๊ฐ๋์ง ์์ ๊ฒฝ์ฐ
if (switchError.code === 4902) {
try {
await this.connector.ethereum.request({
method: 'wallet_addEthereumChain',
params: [networkConfig],
});
} catch (addError) {
this.showError('๋คํธ์ํฌ ์ถ๊ฐ์ ์คํจํ์ต๋๋ค.');
}
} else {
this.showError('๋คํธ์ํฌ ์ ํ์ ์คํจํ์ต๋๋ค.');
}
}
}
// ๋ฐฑ์๋ ์ธ์ฆ
async authenticateWithBackend(address) {
try {
// 1. ์๋ฒ์์ nonce ๋ฐ๊ธฐ
const nonceResponse = await axios.post('/api/web3/nonce', { address });
const nonce = nonceResponse.data.nonce;
// 2. ๋ฉ์์ง ์๋ช
const message = `์ฌ๋ฅ๋ท ๋ก๊ทธ์ธ ์ธ์ฆ\n\nNonce: ${nonce}\n์ฃผ์: ${address}`;
const signature = await this.connector.signer.signMessage(message);
// 3. ์๋ช
๊ฒ์ฆ ๋ฐ ๋ก๊ทธ์ธ
const authResponse = await axios.post('/api/web3/verify', {
address,
signature,
nonce
});
// 4. ํ ํฐ ์ ์ฅ
localStorage.setItem('auth_token', authResponse.data.token);
return true;
} catch (error) {
console.error('Backend authentication error:', error);
throw error;
}
}
// ์ํ ํ์
showState(stateName) {
document.querySelectorAll('.wallet-state').forEach(el => {
el.style.display = 'none';
});
document.getElementById(stateName).style.display = 'block';
this.currentState = stateName;
}
// ๋คํธ์ํฌ ์ ํ UI
showNetworkSwitch(currentChainId) {
const networkNames = {
1: '์ด๋๋ฆฌ์ ๋ฉ์ธ๋ท',
5: '๊ณ ์ผ๋ฆฌ ํ
์คํธ๋ท',
137: 'ํด๋ฆฌ๊ณค ๋ฉ์ธ๋ท',
80001: '๋ญ๋ฐ์ด ํ
์คํธ๋ท'
};
const message = `ํ์ฌ ${networkNames[currentChainId] || '์ ์ ์๋ ๋คํธ์ํฌ'}์ ์ฐ๊ฒฐ๋์ด ์์ต๋๋ค. ${networkNames[this.requiredChainId]}์ผ๋ก ์ ํํด์ฃผ์ธ์.`;
document.getElementById('network-message').textContent = message;
this.showState('network-switch');
}
// ์ฐ๊ฒฐ ์๋ฃ UI
showConnected(address, balance) {
const shortAddress = `${address.slice(0, 6)}...${address.slice(-4)}`;
document.getElementById('wallet-address').textContent = shortAddress;
document.getElementById('wallet-balance').textContent = `${parseFloat(balance).toFixed(4)} ETH`;
this.showState('connected-state');
// ์ฑ๊ณต ํ ์คํธ ๋ฉ์์ง
this.showToast('์ง๊ฐ์ด ์ฑ๊ณต์ ์ผ๋ก ์ฐ๊ฒฐ๋์์ต๋๋ค! ๐', 'success');
}
// ์๋ฌ ํ์
showError(message) {
document.getElementById('error-message').textContent = message;
this.showState('error-state');
this.showToast(message, 'error');
}
// ์ฐ๊ฒฐ ํด์
disconnect() {
this.connector.account = null;
this.connector.provider = null;
this.connector.signer = null;
localStorage.removeItem('auth_token');
this.showState('connect-prompt');
this.showToast('์ง๊ฐ ์ฐ๊ฒฐ์ด ํด์ ๋์์ต๋๋ค.', 'info');
}
// ํ ์คํธ ๋ฉ์์ง
showToast(message, type = 'info') {
// ํ ์คํธ ๊ตฌํ (๊ฐ๋จํ ์์)
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.classList.add('show');
}, 100);
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// ๋คํธ์ํฌ ์ค์ ๊ฐ์ ธ์ค๊ธฐ
getNetworkConfig(chainId) {
const configs = {
1: {
chainId: '0x1',
chainName: 'Ethereum Mainnet',
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
rpcUrls: ['https://mainnet.infura.io/v3/'],
blockExplorerUrls: ['https://etherscan.io']
},
137: {
chainId: '0x89',
chainName: 'Polygon Mainnet',
nativeCurrency: { name: 'MATIC', symbol: 'MATIC', decimals: 18 },
rpcUrls: ['https://polygon-rpc.com/'],
blockExplorerUrls: ['https://polygonscan.com']
}
};
return configs[chainId];
}
}
// ์ด๊ธฐํ
document.addEventListener('DOMContentLoaded', () => {
window.walletUI = new WalletUI();
});
export default WalletUI;
๐ฏ UX ๊ฐ์ ํฌ์ธํธ ์ ๋ฆฌ
1๏ธโฃ ์ฆ๊ฐ์ ์ธ ํผ๋๋ฐฑ: ๋ชจ๋ ์ก์ ์ ๋ํด ์ฆ์ ์ํ ๋ณํ๋ฅผ ๋ณด์ฌ์ค
2๏ธโฃ ๋ช ํํ ์๋ด: ๊ฐ ๋จ๊ณ๋ง๋ค ์ฌ์ฉ์๊ฐ ๋ฌด์์ ํด์ผ ํ๋์ง ๋ช ํํ ํ์
3๏ธโฃ ์๋ฌ ๋ณต๊ตฌ: ์๋ฌ ๋ฐ์ ์ ์ฌ์๋ ๋ฒํผ ์ ๊ณต
4๏ธโฃ ์๋ ์ฌ์ฐ๊ฒฐ: ํ์ด์ง ์๋ก๊ณ ์นจ ์ ์ด์ ์ฐ๊ฒฐ ์ํ ๋ณต์
5๏ธโฃ ํ ์คํธ ์๋ฆผ: ์ค์ํ ์ด๋ฒคํธ๋ฅผ ๋์น์ง ์๋๋ก ์๋ฆผ ์ ๊ณต
1๏ธโฃ ์ฆ๊ฐ์ ์ธ ํผ๋๋ฐฑ: ๋ชจ๋ ์ก์ ์ ๋ํด ์ฆ์ ์ํ ๋ณํ๋ฅผ ๋ณด์ฌ์ค
2๏ธโฃ ๋ช ํํ ์๋ด: ๊ฐ ๋จ๊ณ๋ง๋ค ์ฌ์ฉ์๊ฐ ๋ฌด์์ ํด์ผ ํ๋์ง ๋ช ํํ ํ์
3๏ธโฃ ์๋ฌ ๋ณต๊ตฌ: ์๋ฌ ๋ฐ์ ์ ์ฌ์๋ ๋ฒํผ ์ ๊ณต
4๏ธโฃ ์๋ ์ฌ์ฐ๊ฒฐ: ํ์ด์ง ์๋ก๊ณ ์นจ ์ ์ด์ ์ฐ๊ฒฐ ์ํ ๋ณต์
5๏ธโฃ ํ ์คํธ ์๋ฆผ: ์ค์ํ ์ด๋ฒคํธ๋ฅผ ๋์น์ง ์๋๋ก ์๋ฆผ ์ ๊ณต
## ๐ ๋ฐฑ์๋: ์์ ํ ์ธ์ฆ ์์คํ
๊ตฌ์ถ
ํ๋ก ํธ์๋์์ ๋ฉํ๋ง์คํฌ ์ฐ๊ฒฐ์ด ์๋ฃ๋์๋ค๊ณ ํด์ ๋์ด ์๋์ผ. ๋ฐฑ์๋์์ ์ ๋๋ก ๋ ์ธ์ฆ ์์คํ
์ ๊ตฌ์ถํด์ผ ๋ณด์๋ ์งํค๊ณ UX๋ ์ข์์ ธ! ๐ก๏ธ
### ๐ ์๋ช
๊ธฐ๋ฐ ์ธ์ฆ ํ๋ก์ฐ
๋ฉํ๋ง์คํฌ ๋ก๊ทธ์ธ์ ํต์ฌ์ ์๋ช
๊ธฐ๋ฐ ์ธ์ฆ์ด์ผ. ์ฌ์ฉ์๊ฐ ์์ ์ ๊ฐ์ธํค๋ก ๋ฉ์์ง์ ์๋ช
ํ๋ฉด, ์๋ฒ์์ ๊ทธ ์๋ช
์ ๊ฒ์ฆํด์ ๋ณธ์ธ ํ์ธ์ ํ๋ ๊ฑฐ์ง.
1Nonce ์์ฑ ๋ฐ ๋ฐ๊ธ
์ฌ์ฉ์๊ฐ ๋ก๊ทธ์ธ์ ์๋ํ๋ฉด ์๋ฒ๋ ์ผํ์ฉ ๋์(nonce)๋ฅผ ์์ฑํด์ ์ ๋ฌํด. ์ด nonce๋ ์ฌ์ฌ์ฉ ๊ณต๊ฒฉ(replay attack)์ ๋ฐฉ์งํ๋ ์ญํ ์ ํด.
์ฌ์ฉ์๊ฐ ๋ก๊ทธ์ธ์ ์๋ํ๋ฉด ์๋ฒ๋ ์ผํ์ฉ ๋์(nonce)๋ฅผ ์์ฑํด์ ์ ๋ฌํด. ์ด nonce๋ ์ฌ์ฌ์ฉ ๊ณต๊ฒฉ(replay attack)์ ๋ฐฉ์งํ๋ ์ญํ ์ ํด.
2๋ฉ์์ง ์๋ช
์ฌ์ฉ์๋ ๋ฐ์ nonce๋ฅผ ํฌํจํ ๋ฉ์์ง๋ฅผ ์์ ์ ๊ฐ์ธํค๋ก ์๋ช ํด. ์ด ๊ณผ์ ์ ๋ฉํ๋ง์คํฌ ํ์ ์์ ์งํ๋๊ณ , ๊ฐ์ธํค๋ ์ ๋ ์ธ๋ถ๋ก ๋ ธ์ถ๋์ง ์์.
์ฌ์ฉ์๋ ๋ฐ์ nonce๋ฅผ ํฌํจํ ๋ฉ์์ง๋ฅผ ์์ ์ ๊ฐ์ธํค๋ก ์๋ช ํด. ์ด ๊ณผ์ ์ ๋ฉํ๋ง์คํฌ ํ์ ์์ ์งํ๋๊ณ , ๊ฐ์ธํค๋ ์ ๋ ์ธ๋ถ๋ก ๋ ธ์ถ๋์ง ์์.
3์๋ช
๊ฒ์ฆ
์๋ฒ๋ ๋ฐ์ ์๋ช ์ ๊ฒ์ฆํด์ ์ค์ ๋ก ํด๋น ์ง๊ฐ ์ฃผ์์ ์์ ์๊ฐ ์๋ช ํ ๊ฒ ๋ง๋์ง ํ์ธํด. ๊ฒ์ฆ์ด ์ฑ๊ณตํ๋ฉด JWT ํ ํฐ์ ๋ฐ๊ธํ์ง.
### ๐๏ธ ๋ฐ์ดํฐ๋ฒ ์ด์ค ๋ง์ด๊ทธ๋ ์ด์
๋จผ์ Web3 ์ธ์ฆ์ ํ์ํ ํ
์ด๋ธ์ ๋ง๋ค์ด๋ณด์.
์๋ฒ๋ ๋ฐ์ ์๋ช ์ ๊ฒ์ฆํด์ ์ค์ ๋ก ํด๋น ์ง๊ฐ ์ฃผ์์ ์์ ์๊ฐ ์๋ช ํ ๊ฒ ๋ง๋์ง ํ์ธํด. ๊ฒ์ฆ์ด ์ฑ๊ณตํ๋ฉด JWT ํ ํฐ์ ๋ฐ๊ธํ์ง.
๐ database/migrations/xxxx_create_web3_users_table.php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::create('web3_users', function (Blueprint $table) {
$table->id();
$table->string('wallet_address', 42)->unique();
$table->string('nonce')->nullable();
$table->timestamp('nonce_expires_at')->nullable();
$table->string('username')->nullable();
$table->string('email')->nullable();
$table->json('metadata')->nullable();
$table->timestamp('last_login_at')->nullable();
$table->timestamps();
$table->index('wallet_address');
$table->index('nonce_expires_at');
});
Schema::create('web3_sessions', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained('web3_users')->onDelete('cascade');
$table->string('token')->unique();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->timestamp('expires_at');
$table->timestamps();
$table->index('token');
$table->index('expires_at');
});
}
public function down()
{
Schema::dropIfExists('web3_sessions');
Schema::dropIfExists('web3_users');
}
};
### ๐ฏ Web3 User ๋ชจ๋ธ ์์ฑ
๐ app/Models/Web3User.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
use Carbon\Carbon;
class Web3User extends Model
{
protected $fillable = [
'wallet_address',
'nonce',
'nonce_expires_at',
'username',
'email',
'metadata',
'last_login_at'
];
protected $casts = [
'metadata' => 'array',
'nonce_expires_at' => 'datetime',
'last_login_at' => 'datetime'
];
// Nonce ์์ฑ
public function generateNonce()
{
$this->nonce = Str::random(32);
$this->nonce_expires_at = Carbon::now()->addMinutes(5);
$this->save();
return $this->nonce;
}
// Nonce ๊ฒ์ฆ
public function validateNonce($nonce)
{
if ($this->nonce !== $nonce) {
return false;
}
if ($this->nonce_expires_at < Carbon::now()) {
return false;
}
return true;
}
// Nonce ๋ฌดํจํ
public function invalidateNonce()
{
$this->nonce = null;
$this->nonce_expires_at = null;
$this->save();
}
// ์ธ์
๊ด๊ณ
public function sessions()
{
return $this->hasMany(Web3Session::class, 'user_id');
}
// ํ์ฑ ์ธ์
๋ง
public function activeSessions()
{
return $this->sessions()->where('expires_at', '>', Carbon::now());
}
// ๋ก๊ทธ์ธ ์๊ฐ ์
๋ฐ์ดํธ
public function updateLastLogin()
{
$this->last_login_at = Carbon::now();
$this->save();
}
}
๐ app/Models/Web3Session.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
use Carbon\Carbon;
class Web3Session extends Model
{
protected $fillable = [
'user_id',
'token',
'ip_address',
'user_agent',
'expires_at'
];
protected $casts = [
'expires_at' => 'datetime'
];
// ํ ํฐ ์์ฑ
public static function createToken($userId, $request)
{
return self::create([
'user_id' => $userId,
'token' => Str::random(64),
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'expires_at' => Carbon::now()->addHours(24)
]);
}
// ํ ํฐ์ผ๋ก ์ธ์
์ฐพ๊ธฐ
public static function findByToken($token)
{
return self::where('token', $token)
->where('expires_at', '>', Carbon::now())
->first();
}
// ์ฌ์ฉ์ ๊ด๊ณ
public function user()
{
return $this->belongsTo(Web3User::class, 'user_id');
}
// ๋ง๋ฃ ์ฌ๋ถ ํ์ธ
public function isExpired()
{
return $this->expires_at < Carbon::now();
}
}
### ๐ง Web3 ์ธ์ฆ ์๋น์ค ํด๋์ค
์ด์ ์ค์ ์๋ช
๊ฒ์ฆ ๋ก์ง์ ๋ด๋นํ ์๋น์ค ํด๋์ค๋ฅผ ๋ง๋ค์ด๋ณด์.
๐ app/Services/Web3AuthService.php
<?php
namespace App\Services;
use App\Models\Web3User;
use App\Models\Web3Session;
use Illuminate\Support\Facades\Log;
use kornrunner\Keccak;
class Web3AuthService
{
/**
* Nonce ์์ฒญ ์ฒ๋ฆฌ
*/
public function requestNonce($walletAddress)
{
// ์ฃผ์ ์ ํจ์ฑ ๊ฒ์ฆ
if (!$this->isValidAddress($walletAddress)) {
throw new \Exception('Invalid wallet address');
}
// ์ฃผ์ ์ ๊ทํ (์ฒดํฌ์ฌ ์ ์ฉ)
$walletAddress = $this->toChecksumAddress($walletAddress);
// ์ฌ์ฉ์ ์ฐพ๊ธฐ ๋๋ ์์ฑ
$user = Web3User::firstOrCreate(
['wallet_address' => $walletAddress],
['metadata' => []]
);
// Nonce ์์ฑ
$nonce = $user->generateNonce();
return [
'nonce' => $nonce,
'message' => $this->generateMessage($walletAddress, $nonce)
];
}
/**
* ์๋ช
๊ฒ์ฆ ๋ฐ ๋ก๊ทธ์ธ
*/
public function verifySignature($walletAddress, $signature, $nonce, $request)
{
// ์ฃผ์ ์ ๊ทํ
$walletAddress = $this->toChecksumAddress($walletAddress);
// ์ฌ์ฉ์ ์ฐพ๊ธฐ
$user = Web3User::where('wallet_address', $walletAddress)->first();
if (!$user) {
throw new \Exception('User not found');
}
// Nonce ๊ฒ์ฆ
if (!$user->validateNonce($nonce)) {
throw new \Exception('Invalid or expired nonce');
}
// ๋ฉ์์ง ์์ฑ
$message = $this->generateMessage($walletAddress, $nonce);
// ์๋ช
๊ฒ์ฆ
$recoveredAddress = $this->recoverAddress($message, $signature);
if (strtolower($recoveredAddress) !== strtolower($walletAddress)) {
Log::warning('Signature verification failed', [
'expected' => $walletAddress,
'recovered' => $recoveredAddress
]);
throw new \Exception('Signature verification failed');
}
// Nonce ๋ฌดํจํ
$user->invalidateNonce();
// ๋ก๊ทธ์ธ ์๊ฐ ์
๋ฐ์ดํธ
$user->updateLastLogin();
// ์ธ์
์์ฑ
$session = Web3Session::createToken($user->id, $request);
return [
'user' => $user,
'token' => $session->token,
'expires_at' => $session->expires_at
];
}
/**
* ์๋ช
์์ ์ฃผ์ ๋ณต๊ตฌ
*/
private function recoverAddress($message, $signature)
{
// ์ด๋๋ฆฌ์ ์๋ช
๋ฉ์์ง ํ๋ฆฌํฝ์ค ์ถ๊ฐ
$hash = Keccak::hash("\x19Ethereum Signed Message:\n" . strlen($message) . $message, 256);
// ์๋ช
๋ถํด
$signature = str_replace('0x', '', $signature);
$r = substr($signature, 0, 64);
$s = substr($signature, 64, 64);
$v = hexdec(substr($signature, 128, 2));
// v ๊ฐ ์กฐ์
if ($v < 27) {
$v += 27;
}
// ๊ณต๊ฐํค ๋ณต๊ตฌ
$pubkey = $this->recoverPublicKey($hash, $r, $s, $v);
// ์ฃผ์ ์ถ์ถ
$address = '0x' . substr(Keccak::hash(substr(hex2bin($pubkey), 1), 256), 24);
return $this->toChecksumAddress($address);
}
/**
* ๊ณต๊ฐํค ๋ณต๊ตฌ (๊ฐ์ํ๋ ๋ฒ์ )
*/
private function recoverPublicKey($hash, $r, $s, $v)
{
// ์ค์ ํ๋ก๋์
์์๋ elliptic curve ๋ผ์ด๋ธ๋ฌ๋ฆฌ ์ฌ์ฉ ๊ถ์ฅ
// ์ฌ๊ธฐ์๋ ๊ฐ๋
์ค๋ช
์ ์ํ ๊ฐ์ํ๋ ๋ฒ์
// web3p/web3.php ๋ผ์ด๋ธ๋ฌ๋ฆฌ ์ฌ์ฉ ์์
$ec = new \Elliptic\EC('secp256k1');
$signature = [
'r' => $r,
's' => $s,
'recoveryParam' => $v - 27
];
$publicKey = $ec->recoverPubKey($hash, $signature, $signature['recoveryParam']);
return $publicKey->encode('hex');
}
/**
* ๋ก๊ทธ์ธ ๋ฉ์์ง ์์ฑ
*/
private function generateMessage($walletAddress, $nonce)
{
return "์ฌ๋ฅ๋ท ๋ก๊ทธ์ธ ์ธ์ฆ\n\n" .
"์ด ์๋ช
์ ์ฌ๋ฅ๋ท(jaenung.net)์ ๋ก๊ทธ์ธํ๊ธฐ ์ํ ๊ฒ์
๋๋ค.\n\n" .
"Nonce: {$nonce}\n" .
"์ฃผ์: {$walletAddress}\n\n" .
"์ด ์๋ช
์ ์ด๋ ํ ํธ๋์ญ์
๋ ๋ฐ์์ํค์ง ์์ผ๋ฉฐ,\n" .
"๊ฐ์ค ๋น์ฉ์ด ๋ค์ง ์์ต๋๋ค.";
}
/**
* ์ฃผ์ ์ ํจ์ฑ ๊ฒ์ฆ
*/
private function isValidAddress($address)
{
return preg_match('/^0x[a-fA-F0-9]{40}$/', $address);
}
/**
* ์ฒดํฌ์ฌ ์ฃผ์๋ก ๋ณํ
*/
private function toChecksumAddress($address)
{
$address = strtolower(str_replace('0x', '', $address));
$hash = Keccak::hash($address, 256);
$checksum = '0x';
for ($i = 0; $i < 40; $i++) {
$checksum .= (intval($hash[$i], 16) > 7)
? strtoupper($address[$i])
: $address[$i];
}
return $checksum;
}
/**
* ํ ํฐ์ผ๋ก ์ฌ์ฉ์ ์ธ์ฆ
*/
public function authenticateByToken($token)
{
$session = Web3Session::findByToken($token);
if (!$session) {
throw new \Exception('Invalid or expired token');
}
return $session->user;
}
/**
* ๋ก๊ทธ์์
*/
public function logout($token)
{
$session = Web3Session::where('token', $token)->first();
if ($session) {
$session->delete();
}
}
}
โ ๏ธ ๋ณด์ ์ฃผ์์ฌํญ
์ ์ฝ๋์ `recoverPublicKey` ํจ์๋ ๊ฐ๋ ์ค๋ช ์ ์ํ ๊ฐ์ํ๋ ๋ฒ์ ์ด์ผ. ์ค์ ํ๋ก๋์ ํ๊ฒฝ์์๋ ๊ฒ์ฆ๋ ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ์ฌ์ฉํด์ผ ํด:
โข web3p/web3.php: PHP์ฉ Web3 ๋ผ์ด๋ธ๋ฌ๋ฆฌ
โข kornrunner/keccak: Keccak ํด์ ํจ์
โข simplito/elliptic-php: ํ์๊ณก์ ์ํธํ
`composer require web3p/web3.php kornrunner/keccak simplito/elliptic-php` ๋ช ๋ น์ด๋ก ์ค์นํ ์ ์์ด!
### ๐ฎ ์ปจํธ๋กค๋ฌ ๊ตฌํ
์ด์ API ์๋ํฌ์ธํธ๋ฅผ ๋ง๋ค์ด๋ณด์.
์ ์ฝ๋์ `recoverPublicKey` ํจ์๋ ๊ฐ๋ ์ค๋ช ์ ์ํ ๊ฐ์ํ๋ ๋ฒ์ ์ด์ผ. ์ค์ ํ๋ก๋์ ํ๊ฒฝ์์๋ ๊ฒ์ฆ๋ ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ์ฌ์ฉํด์ผ ํด:
โข web3p/web3.php: PHP์ฉ Web3 ๋ผ์ด๋ธ๋ฌ๋ฆฌ
โข kornrunner/keccak: Keccak ํด์ ํจ์
โข simplito/elliptic-php: ํ์๊ณก์ ์ํธํ
`composer require web3p/web3.php kornrunner/keccak simplito/elliptic-php` ๋ช ๋ น์ด๋ก ์ค์นํ ์ ์์ด!
๐ app/Http/Controllers/Web3AuthController.php
<?php
namespace App\Http\Controllers;
use App\Services\Web3AuthService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class Web3AuthController extends Controller
{
protected $web3Auth;
public function __construct(Web3AuthService $web3Auth)
{
$this->web3Auth = $web3Auth;
}
/**
* Nonce ์์ฒญ
*/
public function requestNonce(Request $request)
{
$validator = Validator::make($request->all(), [
'address' => 'required|string|regex:/^0x[a-fA-F0-9]{40}$/'
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'message' => '์ ํจํ์ง ์์ ์ง๊ฐ ์ฃผ์์
๋๋ค.',
'errors' => $validator->errors()
], 400);
}
try {
$result = $this->web3Auth->requestNonce($request->address);
return response()->json([
'success' => true,
'nonce' => $result['nonce'],
'message' => $result['message']
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => 'Nonce ์์ฑ์ ์คํจํ์ต๋๋ค.',
'error' => $e->getMessage()
], 500);
}
}
/**
* ์๋ช
๊ฒ์ฆ ๋ฐ ๋ก๊ทธ์ธ
*/
public function verify(Request $request)
{
$validator = Validator::make($request->all(), [
'address' => 'required|string|regex:/^0x[a-fA-F0-9]{40}$/',
'signature' => 'required|string',
'nonce' => 'required|string'
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'message' => 'ํ์ ์ ๋ณด๊ฐ ๋๋ฝ๋์์ต๋๋ค.',
'errors' => $validator->errors()
], 400);
}
try {
$result = $this->web3Auth->verifySignature(
$request->address,
$request->signature,
$request->nonce,
$request
);
return response()->json([
'success' => true,
'message' => '๋ก๊ทธ์ธ์ ์ฑ๊ณตํ์ต๋๋ค.',
'token' => $result['token'],
'user' => [
'wallet_address' => $result['user']->wallet_address,
'username' => $result['user']->username,
'last_login_at' => $result['user']->last_login_at
],
'expires_at' => $result['expires_at']
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => '์ธ์ฆ์ ์คํจํ์ต๋๋ค.',
'error' => $e->getMessage()
], 401);
}
}
/**
* ํ์ฌ ์ฌ์ฉ์ ์ ๋ณด
*/
public function me(Request $request)
{
try {
$token = $request->bearerToken();
if (!$token) {
return response()->json([
'success' => false,
'message' => '์ธ์ฆ ํ ํฐ์ด ํ์ํฉ๋๋ค.'
], 401);
}
$user = $this->web3Auth->authenticateByToken($token);
return response()->json([
'success' => true,
'user' => [
'wallet_address' => $user->wallet_address,
'username' => $user->username,
'email' => $user->email,
'metadata' => $user->metadata,
'last_login_at' => $user->last_login_at
]
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => '์ธ์ฆ์ ์คํจํ์ต๋๋ค.'
], 401);
}
}
/**
* ๋ก๊ทธ์์
*/
public function logout(Request $request)
{
try {
$token = $request->bearerToken();
if ($token) {
$this->web3Auth->logout($token);
}
return response()->json([
'success' => true,
'message' => '๋ก๊ทธ์์๋์์ต๋๋ค.'
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => '๋ก๊ทธ์์์ ์คํจํ์ต๋๋ค.'
], 500);
}
}
}
### ๐ฃ๏ธ ๋ผ์ฐํธ ์ค์
๐ routes/api.php
<?php
use App\Http\Controllers\Web3AuthController;
use Illuminate\Support\Facades\Route;
Route::prefix('web3')->group(function () {
// ์ธ์ฆ ๋ถํ์
Route::post('/nonce', [Web3AuthController::class, 'requestNonce']);
Route::post('/verify', [Web3AuthController::class, 'verify']);
// ์ธ์ฆ ํ์
Route::middleware('auth:web3')->group(function () {
Route::get('/me', [Web3AuthController::class, 'me']);
Route::post('/logout', [Web3AuthController::class, 'logout']);
});
});
### ๐ ์ธ์ฆ ๋ฏธ๋ค์จ์ด
๋ง์ง๋ง์ผ๋ก Web3 ํ ํฐ์ ๊ฒ์ฆํ๋ ๋ฏธ๋ค์จ์ด๋ฅผ ๋ง๋ค์ด๋ณด์.
๐ app/Http/Middleware/Web3Authenticate.php
<?php
namespace App\Http\Middleware;
use Closure;
use App\Services\Web3AuthService;
use Illuminate\Http\Request;
class Web3Authenticate
{
protected $web3Auth;
public function __construct(Web3AuthService $web3Auth)
{
$this->web3Auth = $web3Auth;
}
public function handle(Request $request, Closure $next)
{
$token = $request->bearerToken();
if (!$token) {
return response()->json([
'success' => false,
'message' => '์ธ์ฆ ํ ํฐ์ด ํ์ํฉ๋๋ค.'
], 401);
}
try {
$user = $this->web3Auth->authenticateByToken($token);
$request->merge(['web3_user' => $user]);
return $next($request);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => '์ ํจํ์ง ์์ ํ ํฐ์
๋๋ค.'
], 401);
}
}
}
๋ฏธ๋ค์จ์ด๋ฅผ ๋ฑ๋กํ๋ ๊ฒ๋ ์์ง ๋ง์!
๐ app/Http/Kernel.php
protected $middlewareAliases = [
// ... ๊ธฐ์กด ๋ฏธ๋ค์จ์ด๋ค
'auth:web3' => \App\Http\Middleware\Web3Authenticate::class,
];
๐ ๋ฐฑ์๋ ๊ตฌํ ์๋ฃ!
์ด์ ์์ ํ๊ณ ํจ์จ์ ์ธ Web3 ์ธ์ฆ ์์คํ ์ด ์์ฑ๋์์ด! ํต์ฌ ํฌ์ธํธ๋ฅผ ์ ๋ฆฌํ๋ฉด:
โ Nonce ๊ธฐ๋ฐ์ผ๋ก ์ฌ์ฌ์ฉ ๊ณต๊ฒฉ ๋ฐฉ์ง
โ ์๋ช ๊ฒ์ฆ์ผ๋ก ๋ณธ์ธ ํ์ธ
โ JWT ํ ํฐ์ผ๋ก ์ธ์ ๊ด๋ฆฌ
โ ๋ง๋ฃ ์๊ฐ ์ค์ ์ผ๋ก ๋ณด์ ๊ฐํ
โ ๊น๋ํ ์๋ฌ ํธ๋ค๋ง
์ด์ ์์ ํ๊ณ ํจ์จ์ ์ธ Web3 ์ธ์ฆ ์์คํ ์ด ์์ฑ๋์์ด! ํต์ฌ ํฌ์ธํธ๋ฅผ ์ ๋ฆฌํ๋ฉด:
โ Nonce ๊ธฐ๋ฐ์ผ๋ก ์ฌ์ฌ์ฉ ๊ณต๊ฒฉ ๋ฐฉ์ง
โ ์๋ช ๊ฒ์ฆ์ผ๋ก ๋ณธ์ธ ํ์ธ
โ JWT ํ ํฐ์ผ๋ก ์ธ์ ๊ด๋ฆฌ
โ ๋ง๋ฃ ์๊ฐ ์ค์ ์ผ๋ก ๋ณด์ ๊ฐํ
โ ๊น๋ํ ์๋ฌ ํธ๋ค๋ง
## โก ์ฑ๋ฅ ์ต์ ํ: ๋น ๋ฅด๊ณ ๋ถ๋๋ฌ์ด UX ๋ง๋ค๊ธฐ
๊ธฐ๋ฅ์ด ์์ฑ๋์๋ค๊ณ ๋์ด ์๋์ผ! ์ด์ ์ฌ์ฉ์๊ฐ "์, ์ด๊ฑฐ ์ง์ง ๋น ๋ฅด๋ค!"๋ผ๊ณ ๋๋ ์ ์๋๋ก ์ฑ๋ฅ์ ์ต์ ํํด๋ณด์. ๐
### ๐ฏ ํ๋ก ํธ์๋ ์ต์ ํ ์ ๋ต
1์ฐ๊ฒฐ ์ํ ์บ์ฑ
๋งค๋ฒ ๋ฉํ๋ง์คํฌ ์ฐ๊ฒฐ ์ํ๋ฅผ ํ์ธํ๋ ๊ฑด ๋นํจ์จ์ ์ด์ผ. LocalStorage๋ฅผ ํ์ฉํด์ ์ด์ ์ฐ๊ฒฐ ์ ๋ณด๋ฅผ ์ ์ฅํ๊ณ , ํ์ด์ง ๋ก๋ ์ ๋น ๋ฅด๊ฒ ๋ณต์ํ ์ ์์ด.
๋งค๋ฒ ๋ฉํ๋ง์คํฌ ์ฐ๊ฒฐ ์ํ๋ฅผ ํ์ธํ๋ ๊ฑด ๋นํจ์จ์ ์ด์ผ. LocalStorage๋ฅผ ํ์ฉํด์ ์ด์ ์ฐ๊ฒฐ ์ ๋ณด๋ฅผ ์ ์ฅํ๊ณ , ํ์ด์ง ๋ก๋ ์ ๋น ๋ฅด๊ฒ ๋ณต์ํ ์ ์์ด.
๐ resources/js/web3/connection-cache.js
class ConnectionCache {
constructor() {
this.CACHE_KEY = 'web3_connection_cache';
this.CACHE_DURATION = 24 * 60 * 60 * 1000; // 24์๊ฐ
}
// ์ฐ๊ฒฐ ์ ๋ณด ์ ์ฅ
save(data) {
const cacheData = {
...data,
timestamp: Date.now()
};
localStorage.setItem(this.CACHE_KEY, JSON.stringify(cacheData));
}
// ์ฐ๊ฒฐ ์ ๋ณด ๋ถ๋ฌ์ค๊ธฐ
load() {
try {
const cached = localStorage.getItem(this.CACHE_KEY);
if (!cached) return null;
const data = JSON.parse(cached);
// ์บ์ ๋ง๋ฃ ํ์ธ
if (Date.now() - data.timestamp > this.CACHE_DURATION) {
this.clear();
return null;
}
return data;
} catch (error) {
console.error('Cache load error:', error);
return null;
}
}
// ์บ์ ์ญ์
clear() {
localStorage.removeItem(this.CACHE_KEY);
}
// ๋น ๋ฅธ ์ฌ์ฐ๊ฒฐ ์๋
async quickReconnect(connector) {
const cached = this.load();
if (!cached || !cached.address) return false;
try {
// ๋ฉํ๋ง์คํฌ์ ์ ์ฅ๋ ๊ณ์ ํ์ธ
const accounts = await connector.ethereum.request({
method: 'eth_accounts'
});
if (accounts.length > 0 &&
accounts[0].toLowerCase() === cached.address.toLowerCase()) {
return cached.address;
}
} catch (error) {
console.error('Quick reconnect failed:', error);
}
return false;
}
}
export default ConnectionCache;
2์์ฒญ ๋๋ฐ์ด์ฑ
์ฌ์ฉ์๊ฐ ๋ฒํผ์ ์ฌ๋ฌ ๋ฒ ํด๋ฆญํ๋ ๊ฒฝ์ฐ๋ฅผ ๋๋นํด์ ๋๋ฐ์ด์ฑ์ ์ ์ฉํด์ผ ํด. ์ค๋ณต ์์ฒญ์ ๋ฐฉ์งํ๋ฉด ์๋ฒ ๋ถํ๋ ์ค์ด๊ณ UX๋ ๊ฐ์ ๋ผ!
์ฌ์ฉ์๊ฐ ๋ฒํผ์ ์ฌ๋ฌ ๋ฒ ํด๋ฆญํ๋ ๊ฒฝ์ฐ๋ฅผ ๋๋นํด์ ๋๋ฐ์ด์ฑ์ ์ ์ฉํด์ผ ํด. ์ค๋ณต ์์ฒญ์ ๋ฐฉ์งํ๋ฉด ์๋ฒ ๋ถํ๋ ์ค์ด๊ณ UX๋ ๊ฐ์ ๋ผ!
๐ resources/js/utils/debounce.js
class RequestDebouncer {
constructor() {
this.pending = new Map();
}
// ๋๋ฐ์ด์ค๋ ์์ฒญ ์คํ
async execute(key, fn, delay = 300) {
// ์ด๋ฏธ ์งํ ์ค์ธ ์์ฒญ์ด ์์ผ๋ฉด ๋๊ธฐ
if (this.pending.has(key)) {
return this.pending.get(key);
}
// ์ ์์ฒญ ์์ฑ
const promise = new Promise(async (resolve, reject) => {
try {
await new Promise(r => setTimeout(r, delay));
const result = await fn();
resolve(result);
} catch (error) {
reject(error);
} finally {
this.pending.delete(key);
}
});
this.pending.set(key, promise);
return promise;
}
// ํน์ ํค์ ์์ฒญ ์ทจ์
cancel(key) {
this.pending.delete(key);
}
// ๋ชจ๋ ์์ฒญ ์ทจ์
cancelAll() {
this.pending.clear();
}
}
export default new RequestDebouncer();
3ํ๋ฆฌ๋ก๋ฉ ์ ๋ต
์ฌ์ฉ์๊ฐ ๋ฒํผ์ ๋ง์ฐ์ค๋ฅผ ์ฌ๋ฆฌ๋ ์๊ฐ ๋ฏธ๋ฆฌ ์ค๋น ์์ ์ ์์ํ๋ฉด ์ฒด๊ฐ ์๋๊ฐ ํจ์ฌ ๋นจ๋ผ์ ธ!
์ฌ์ฉ์๊ฐ ๋ฒํผ์ ๋ง์ฐ์ค๋ฅผ ์ฌ๋ฆฌ๋ ์๊ฐ ๋ฏธ๋ฆฌ ์ค๋น ์์ ์ ์์ํ๋ฉด ์ฒด๊ฐ ์๋๊ฐ ํจ์ฌ ๋นจ๋ผ์ ธ!
๐ resources/js/web3/preloader.js
class Web3Preloader {
constructor(connector) {
this.connector = connector;
this.preloadStarted = false;
}
// ํธ๋ฒ ์ ํ๋ฆฌ๋ก๋
onButtonHover() {
if (this.preloadStarted) return;
this.preloadStarted = true;
// ๋ฉํ๋ง์คํฌ ์ค๋น ์ํ ํ์ธ
if (this.connector.isMetaMaskInstalled()) {
// Provider ๋ฏธ๋ฆฌ ์ด๊ธฐํ
this.preloadProvider();
}
}
// Provider ํ๋ฆฌ๋ก๋
async preloadProvider() {
try {
// ๊ฐ๋ฒผ์ด ์์ฒญ์ผ๋ก ์ฐ๊ฒฐ ์ค๋น
await this.connector.ethereum.request({
method: 'eth_chainId'
});
console.log('Provider preloaded');
} catch (error) {
// ์๋ฌ๋ ๋ฌด์ (์ค์ ์ฐ๊ฒฐ ์ ์ฒ๋ฆฌ)
console.log('Preload skipped:', error.message);
}
}
// ๋คํธ์ํฌ ์ ๋ณด ํ๋ฆฌํ์น
async prefetchNetworkInfo() {
try {
const [chainId, accounts] = await Promise.all([
this.connector.ethereum.request({ method: 'eth_chainId' }),
this.connector.ethereum.request({ method: 'eth_accounts' })
]);
return { chainId, accounts };
} catch (error) {
return null;
}
}
}
export default Web3Preloader;
### ๐ง ๋ฐฑ์๋ ์ต์ ํ ์ ๋ต
1Redis ์บ์ฑ
Nonce์ ์ธ์ ์ ๋ณด๋ฅผ Redis์ ์บ์ฑํ๋ฉด ๋ฐ์ดํฐ๋ฒ ์ด์ค ๋ถํ๋ฅผ ํฌ๊ฒ ์ค์ผ ์ ์์ด.
Nonce์ ์ธ์ ์ ๋ณด๋ฅผ Redis์ ์บ์ฑํ๋ฉด ๋ฐ์ดํฐ๋ฒ ์ด์ค ๋ถํ๋ฅผ ํฌ๊ฒ ์ค์ผ ์ ์์ด.
๐ app/Services/Web3CacheService.php
<?php
namespace App\Services;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\Cache;
class Web3CacheService
{
private $noncePrefix = 'web3:nonce:';
private $sessionPrefix = 'web3:session:';
private $userPrefix = 'web3:user:';
/**
* Nonce ์บ์ฑ
*/
public function cacheNonce($address, $nonce, $ttl = 300)
{
$key = $this->noncePrefix . strtolower($address);
Cache::put($key, $nonce, $ttl);
}
public function getNonce($address)
{
$key = $this->noncePrefix . strtolower($address);
return Cache::get($key);
}
public function deleteNonce($address)
{
$key = $this->noncePrefix . strtolower($address);
Cache::forget($key);
}
/**
* ์ธ์
์บ์ฑ
*/
public function cacheSession($token, $userId, $ttl = 86400)
{
$key = $this->sessionPrefix . $token;
Cache::put($key, $userId, $ttl);
}
public function getSession($token)
{
$key = $this->sessionPrefix . $token;
return Cache::get($key);
}
public function deleteSession($token)
{
$key = $this->sessionPrefix . $token;
Cache::forget($key);
}
/**
* ์ฌ์ฉ์ ์ ๋ณด ์บ์ฑ
*/
public function cacheUser($address, $userData, $ttl = 3600)
{
$key = $this->userPrefix . strtolower($address);
Cache::put($key, $userData, $ttl);
}
public function getUser($address)
{
$key = $this->userPrefix . strtolower($address);
return Cache::get($key);
}
public function invalidateUser($address)
{
$key = $this->userPrefix . strtolower($address);
Cache::forget($key);
}
/**
* Rate Limiting
*/
public function checkRateLimit($address, $maxAttempts = 5, $decayMinutes = 1)
{
$key = 'rate_limit:' . strtolower($address);
$attempts = Cache::get($key, 0);
if ($attempts >= $maxAttempts) {
return false;
}
Cache::put($key, $attempts + 1, $decayMinutes * 60);
return true;
}
}
2๋ฐ์ดํฐ๋ฒ ์ด์ค ์ฟผ๋ฆฌ ์ต์ ํ
๋ถํ์ํ ์ฟผ๋ฆฌ๋ฅผ ์ค์ด๊ณ , ์ธ๋ฑ์ค๋ฅผ ์ ์ ํ ํ์ฉํ๋ฉด ์๋ต ์๋๊ฐ ํฌ๊ฒ ๊ฐ์ ๋ผ.
๋ถํ์ํ ์ฟผ๋ฆฌ๋ฅผ ์ค์ด๊ณ , ์ธ๋ฑ์ค๋ฅผ ์ ์ ํ ํ์ฉํ๋ฉด ์๋ต ์๋๊ฐ ํฌ๊ฒ ๊ฐ์ ๋ผ.
๐ app/Services/OptimizedWeb3AuthService.php
<?php
namespace App\Services;
use App\Models\Web3User;
use Illuminate\Support\Facades\DB;
class OptimizedWeb3AuthService extends Web3AuthService
{
protected $cache;
public function __construct(Web3CacheService $cache)
{
$this->cache = $cache;
}
/**
* ์ต์ ํ๋ Nonce ์์ฒญ
*/
public function requestNonce($walletAddress)
{
// Rate Limiting ์ฒดํฌ
if (!$this->cache->checkRateLimit($walletAddress)) {
throw new \Exception('Too many requests. Please try again later.');
}
// ์บ์์์ ๋จผ์ ํ์ธ
$cachedNonce = $this->cache->getNonce($walletAddress);
if ($cachedNonce) {
return [
'nonce' => $cachedNonce,
'message' => $this->generateMessage($walletAddress, $cachedNonce)
];
}
// DB ์กฐํ (์บ์ ๋ฏธ์ค)
$walletAddress = $this->toChecksumAddress($walletAddress);
$user = Web3User::firstOrCreate(
['wallet_address' => $walletAddress],
['metadata' => []]
);
$nonce = $user->generateNonce();
// ์บ์์ ์ ์ฅ
$this->cache->cacheNonce($walletAddress, $nonce, 300);
return [
'nonce' => $nonce,
'message' => $this->generateMessage($walletAddress, $nonce)
];
}
/**
* ์ต์ ํ๋ ์๋ช
๊ฒ์ฆ
*/
public function verifySignature($walletAddress, $signature, $nonce, $request)
{
$walletAddress = $this->toChecksumAddress($walletAddress);
// ์บ์์์ Nonce ํ์ธ
$cachedNonce = $this->cache->getNonce($walletAddress);
if ($cachedNonce !== $nonce) {
throw new \Exception('Invalid or expired nonce');
}
// ์บ์์์ ์ฌ์ฉ์ ์ ๋ณด ํ์ธ
$userData = $this->cache->getUser($walletAddress);
if (!$userData) {
$user = Web3User::where('wallet_address', $walletAddress)->first();
if (!$user) {
throw new \Exception('User not found');
}
// ์บ์์ ์ ์ฅ
$this->cache->cacheUser($walletAddress, $user->toArray());
} else {
$user = Web3User::find($userData['id']);
}
// ์๋ช
๊ฒ์ฆ
$message = $this->generateMessage($walletAddress, $nonce);
$recoveredAddress = $this->recoverAddress($message, $signature);
if (strtolower($recoveredAddress) !== strtolower($walletAddress)) {
throw new \Exception('Signature verification failed');
}
// Nonce ๋ฌดํจํ
$this->cache->deleteNonce($walletAddress);
// ํธ๋์ญ์
์ผ๋ก ๋ฌถ์ด์ ์ฒ๋ฆฌ
DB::transaction(function () use ($user) {
$user->invalidateNonce();
$user->updateLastLogin();
});
// ์ธ์
์์ฑ
$session = Web3Session::createToken($user->id, $request);
// ์ธ์
์บ์ฑ
$this->cache->cacheSession($session->token, $user->id, 86400);
return [
'user' => $user,
'token' => $session->token,
'expires_at' => $session->expires_at
];
}
/**
* ์ต์ ํ๋ ํ ํฐ ์ธ์ฆ
*/
public function authenticateByToken($token)
{
// ์บ์์์ ๋จผ์ ํ์ธ
$userId = $this->cache->getSession($token);
if ($userId) {
$user = Web3User::find($userId);
if ($user) {
return $user;
}
}
// ์บ์ ๋ฏธ์ค - DB ์กฐํ
$session = Web3Session::findByToken($token);
if (!$session) {
throw new \Exception('Invalid or expired token');
}
// ์บ์ ์
๋ฐ์ดํธ
$this->cache->cacheSession($token, $session->user_id, 86400);
return $session->user;
}
}
๐ ์ฑ๋ฅ ๊ฐ์ ํจ๊ณผ
์ด๋ฐ ์ต์ ํ๋ฅผ ์ ์ฉํ๋ฉด ์ค์ ๋ก ์ผ๋ง๋ ๋นจ๋ผ์ง๊น?
์ฒด๊ฐ ์๋๊ฐ 4๋ฐฐ ์ด์ ๋นจ๋ผ์ง๋ ๊ฑฐ์ผ! ๐
์ด๋ฐ ์ต์ ํ๋ฅผ ์ ์ฉํ๋ฉด ์ค์ ๋ก ์ผ๋ง๋ ๋นจ๋ผ์ง๊น?
| ํญ๋ชฉ | ์ต์ ํ ์ | ์ต์ ํ ํ | ๊ฐ์ ์จ |
|---|---|---|---|
| Nonce ์์ฒญ | ~150ms | ~20ms | 87% โฌ๏ธ |
| ์๋ช ๊ฒ์ฆ | ~300ms | ~80ms | 73% โฌ๏ธ |
| ํ ํฐ ์ธ์ฆ | ~100ms | ~5ms | 95% โฌ๏ธ |
| ํ์ด์ง ๋ก๋ | ~2s | ~0.5s | 75% โฌ๏ธ |
์ฒด๊ฐ ์๋๊ฐ 4๋ฐฐ ์ด์ ๋นจ๋ผ์ง๋ ๊ฑฐ์ผ! ๐
## ๐จ ๊ณ ๊ธ UX ํจํด: ์ฌ์ฉ์๋ฅผ ๊ฐ๋์ํค๋ ๋ํ
์ผ
๊ธฐ๋ณธ์ ์ธ ๊ธฐ๋ฅ๊ณผ ์ฑ๋ฅ ์ต์ ํ๊ฐ ๋๋ฌ๋ค๋ฉด, ์ด์ ์ฌ์ฉ์๋ฅผ "์!" ํ๊ณ ๊ฐํํ๊ฒ ๋ง๋ค ๋ํ
์ผ์ ์ถ๊ฐํด๋ณด์! โจ
### ๐ ํ๋ก๊ทธ๋ ์ค ์ธ๋์ผ์ดํฐ
๋จ์ํ ๋ก๋ฉ ์คํผ๋๊ฐ ์๋๋ผ, ๊ฐ ๋จ๊ณ๋ฅผ ์๊ฐ์ ์ผ๋ก ๋ณด์ฌ์ฃผ๋ ํ๋ก๊ทธ๋ ์ค ๋ฐ๋ฅผ ๋ง๋ค์ด๋ณด์.
๐ resources/views/components/connection-progress.blade.php
<div class="progress-container">
<div class="progress-steps">
<div class="progress-step" data-step="1">
<div class="step-icon">๐ฆ</div>
<div class="step-label">๋ฉํ๋ง์คํฌ ์ฐ๊ฒฐ</div>
<div class="step-status"></div>
</div>
<div class="progress-line"></div>
<div class="progress-step" data-step="2">
<div class="step-icon">๐</div>
<div class="step-label">์๋ช
์์ฒญ</div>
<div class="step-status"></div>
</div>
<div class="progress-line"></div>
<div class="progress-step" data-step="3">
<div class="step-icon">โ
</div>
<div class="step-label">์ธ์ฆ ์๋ฃ</div>
<div class="step-status"></div>
</div>
</div>
<div class="progress-message" id="progress-message">
๋ฉํ๋ง์คํฌ๋ฅผ ์ด์ด์ฃผ์ธ์...
</div>
</div>
๐ resources/js/components/progress-tracker.js
class ProgressTracker {
constructor() {
this.currentStep = 0;
this.steps = [
{ id: 1, message: '๋ฉํ๋ง์คํฌ๋ฅผ ์ด์ด์ฃผ์ธ์...' },
{ id: 2, message: '๋ฉ์์ง์ ์๋ช
ํด์ฃผ์ธ์...' },
{ id: 3, message: '์ธ์ฆ์ ์๋ฃํ๋ ์ค...' }
];
}
// ๋จ๊ณ ์งํ
advance(stepId) {
this.currentStep = stepId;
this.updateUI();
}
// UI ์
๋ฐ์ดํธ
updateUI() {
const steps = document.querySelectorAll('.progress-step');
const lines = document.querySelectorAll('.progress-line');
steps.forEach((step, index) => {
const stepNum = index + 1;
const status = step.querySelector('.step-status');
if (stepNum < this.currentStep) {
// ์๋ฃ๋ ๋จ๊ณ
step.classList.add('completed');
step.classList.remove('active', 'pending');
status.textContent = 'โ';
status.style.color = '#00b894';
} else if (stepNum === this.currentStep) {
// ํ์ฌ ์งํ ์ค
step.classList.add('active');
step.classList.remove('completed', 'pending');
status.innerHTML = '<div class="spinner-small"></div>';
} else {
// ๋๊ธฐ ์ค
step.classList.add('pending');
step.classList.remove('completed', 'active');
status.textContent = '';
}
});
// ์ฐ๊ฒฐ์ ์ ๋๋ฉ์ด์
lines.forEach((line, index) => {
if (index < this.currentStep - 1) {
line.classList.add('completed');
} else {
line.classList.remove('completed');
}
});
// ๋ฉ์์ง ์
๋ฐ์ดํธ
const step = this.steps.find(s => s.id === this.currentStep);
if (step) {
document.getElementById('progress-message').textContent = step.message;
}
}
// ์๋ฌ ํ์
showError(stepId, message) {
const step = document.querySelector(`[data-step="${stepId}"]`);
if (step) {
step.classList.add('error');
const status = step.querySelector('.step-status');
status.textContent = 'โ';
status.style.color = '#ff7675';
}
document.getElementById('progress-message').textContent = message;
document.getElementById('progress-message').style.color = '#ff7675';
}
// ๋ฆฌ์
reset() {
this.currentStep = 0;
document.querySelectorAll('.progress-step').forEach(step => {
step.classList.remove('completed', 'active', 'pending', 'error');
});
document.querySelectorAll('.progress-line').forEach(line => {
line.classList.remove('completed');
});
}
}
export default ProgressTracker;
### ๐ญ ์ค๋งํธ ์๋ฌ ๋ณต๊ตฌ
์๋ฌ๊ฐ ๋ฐ์ํ์ ๋ ๋จ์ํ ๋ฉ์์ง๋ง ๋ณด์ฌ์ฃผ๋ ๊ฒ ์๋๋ผ, ์๋์ผ๋ก ํด๊ฒฐ ๋ฐฉ๋ฒ์ ์ ์ํ๊ณ ๋ณต๊ตฌ๋ฅผ ์๋ํ๋ ์์คํ
์ ๋ง๋ค์ด๋ณด์!
๐ resources/js/utils/error-recovery.js
class ErrorRecovery {
constructor(connector) {
this.connector = connector;
this.recoveryStrategies = {
4001: this.handleUserRejection.bind(this),
4100: this.handleUnsupportedMethod.bind(this),
4200: this.handleLockedWallet.bind(this),
4900: this.handleDisconnected.bind(this),
'-32002': this.handlePendingRequest.bind(this),
'-32603': this.handleInternalError.bind(this)
};
}
// ์๋ฌ ๋ณต๊ตฌ ์๋
async recover(error, context) {
const code = error.code?.toString();
const strategy = this.recoveryStrategies[code];
if (strategy) {
return await strategy(error, context);
}
return this.handleGenericError(error);
}
// ์ฌ์ฉ์ ๊ฑฐ๋ถ ์ฒ๋ฆฌ
async handleUserRejection(error, context) {
return {
canRetry: true,
message: '์ฐ๊ฒฐ์ ๊ฑฐ๋ถํ์
จ์ต๋๋ค. ๋ค์ ์๋ํ์๊ฒ ์ด์?',
action: 'retry',
actionLabel: '๋ค์ ์๋',
tips: [
'๋ฉํ๋ง์คํฌ ํ์
์์ "์ฐ๊ฒฐ" ๋ฒํผ์ ๋๋ฌ์ฃผ์ธ์',
'ํ์
์ด ๋ณด์ด์ง ์์ผ๋ฉด ๋ธ๋ผ์ฐ์ ์๋จ์ ํ์ธํด์ฃผ์ธ์'
]
};
}
// ์ง๊ฐ ์ ๊ธ ์ฒ๋ฆฌ
async handleLockedWallet(error, context) {
return {
canRetry: true,
message: '๋ฉํ๋ง์คํฌ๊ฐ ์ ๊ฒจ์์ต๋๋ค',
action: 'unlock',
actionLabel: '์ ๊ธ ํด์ ์๋ด',
tips: [
'๋ฉํ๋ง์คํฌ ํ์ฅ ํ๋ก๊ทธ๋จ์ ํด๋ฆญํ์ธ์',
'๋น๋ฐ๋ฒํธ๋ฅผ ์
๋ ฅํ์ฌ ์ ๊ธ์ ํด์ ํ์ธ์',
'์ ๊ธ ํด์ ํ ๋ค์ ์ฐ๊ฒฐ์ ์๋ํ์ธ์'
],
autoRetry: true,
retryDelay: 2000
};
}
// ๋๊ธฐ ์ค์ธ ์์ฒญ ์ฒ๋ฆฌ
async handlePendingRequest(error, context) {
return {
canRetry: false,
message: '์ด๋ฏธ ๋ฉํ๋ง์คํฌ ์์ฒญ์ด ์งํ ์ค์
๋๋ค',
action: 'open_metamask',
actionLabel: '๋ฉํ๋ง์คํฌ ์ด๊ธฐ',
tips: [
'๋ฉํ๋ง์คํฌ ํ์
์ ํ์ธํด์ฃผ์ธ์',
'ํ์
์ด ๋ณด์ด์ง ์์ผ๋ฉด ํ์ฅ ํ๋ก๊ทธ๋จ ์์ด์ฝ์ ํด๋ฆญํ์ธ์',
'์ด์ ์์ฒญ์ ์๋ฃํ๊ฑฐ๋ ์ทจ์ํ ํ ๋ค์ ์๋ํ์ธ์'
],
highlight: true
};
}
// ์ฐ๊ฒฐ ๋๊น ์ฒ๋ฆฌ
async handleDisconnected(error, context) {
// ์๋ ์ฌ์ฐ๊ฒฐ ์๋
try {
await this.connector.ethereum.request({
method: 'eth_requestAccounts'
});
return {
recovered: true,
message: '์ฐ๊ฒฐ์ด ๋ณต๊ตฌ๋์์ต๋๋ค!'
};
} catch (reconnectError) {
return {
canRetry: true,
message: '๋ฉํ๋ง์คํฌ ์ฐ๊ฒฐ์ด ๋์ด์ก์ต๋๋ค',
action: 'reconnect',
actionLabel: '์ฌ์ฐ๊ฒฐ',
tips: [
'์ธํฐ๋ท ์ฐ๊ฒฐ์ ํ์ธํด์ฃผ์ธ์',
'๋ฉํ๋ง์คํฌ๊ฐ ์คํ ์ค์ธ์ง ํ์ธํด์ฃผ์ธ์'
]
};
}
}
// ๋ด๋ถ ์ค๋ฅ ์ฒ๋ฆฌ
async handleInternalError(error, context) {
// ํ์ด์ง ์๋ก๊ณ ์นจ ์ ์
return {
canRetry: true,
message: '์ผ์์ ์ธ ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค',
action: 'refresh',
actionLabel: 'ํ์ด์ง ์๋ก๊ณ ์นจ',
tips: [
'ํ์ด์ง๋ฅผ ์๋ก๊ณ ์นจํ๋ฉด ํด๊ฒฐ๋ ์ ์์ต๋๋ค',
'๋ฌธ์ ๊ฐ ๊ณ์๋๋ฉด ๋ธ๋ผ์ฐ์ ๋ฅผ ์ฌ์์ํด๋ณด์ธ์'
],
severity: 'high'
};
}
// ์ผ๋ฐ ์๋ฌ ์ฒ๋ฆฌ
handleGenericError(error) {
return {
canRetry: true,
message: `์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค: ${error.message}`,
action: 'retry',
actionLabel: '๋ค์ ์๋',
tips: [
'์ ์ ํ ๋ค์ ์๋ํด์ฃผ์ธ์',
'๋ฌธ์ ๊ฐ ๊ณ์๋๋ฉด ๊ณ ๊ฐ์ผํฐ์ ๋ฌธ์ํด์ฃผ์ธ์'
]
};
}
// ๋ณต๊ตฌ UI ํ์
showRecoveryUI(recovery) {
const container = document.createElement('div');
container.className = 'recovery-modal';
container.innerHTML = `
<div class="recovery-content ${recovery.severity === 'high' ? 'high-severity' : ''}">
<div class="recovery-icon">${recovery.recovered ? 'โ
' : 'โ ๏ธ'}</div>
<h3>${recovery.message}</h3>
${recovery.tips ? `
<div class="recovery-tips">
<p><strong>ํด๊ฒฐ ๋ฐฉ๋ฒ:</strong></p>
<ul>
${recovery.tips.map(tip => `<li>${tip}</li>`).join('')}
</ul>
</div>
` : ''}
${recovery.canRetry ? `
<button class="recovery-action" data-action="${recovery.action}">
${recovery.actionLabel}
</button>
` : ''}
<button class="recovery-close">๋ซ๊ธฐ</button>
</div>
`;
document.body.appendChild(container);
// ์๋ ์ฌ์๋
if (recovery.autoRetry) {
setTimeout(() => {
this.executeRecoveryAction(recovery.action);
container.remove();
}, recovery.retryDelay);
}
return container;
}
// ๋ณต๊ตฌ ์ก์
์คํ
async executeRecoveryAction(action) {
switch (action) {
case 'retry':
window.walletUI.connectWallet();
break;
case 'reconnect':
window.walletUI.connectWallet();
break;
case 'refresh':
window.location.reload();
break;
case 'open_metamask':
// ๋ฉํ๋ง์คํฌ ์ด๊ธฐ ์๋
try {
await this.connector.ethereum.request({
method: 'wallet_requestPermissions',
params: [{ eth_accounts: {} }]
});
} catch (e) {
console.log('Could not open MetaMask');
}
break;
}
}
}
export default ErrorRecovery;
๐ก UX ๋ํ
์ผ์ ํ
์ด๋ฐ ์ธ์ฌํ ์๋ฌ ์ฒ๋ฆฌ๊ฐ ์์ผ๋ฉด ์ฌ์ฉ์๋ "์ด ์๋น์ค๋ ๋๋ฅผ ๋ฐฐ๋ คํ๋๊ตฌ๋"๋ผ๊ณ ๋๋ผ๊ฒ ๋ผ. ๋จ์ํ "์๋ฌ ๋ฐ์"์ด๋ผ๊ณ ๋์ ธ์ฃผ๋ ๊ฒ๊ณผ "์ด๋ ๊ฒ ํ๋ฉด ํด๊ฒฐํ ์ ์์ด์"๋ผ๊ณ ์๋ดํ๋ ๊ฒ์ ์ฐจ์ด๋ ์์ฒญ๋๊ฑฐ๋ ! ๐ฏ
### ๐ฌ ์ ๋๋ฉ์ด์
๊ณผ ํธ๋์ง์
๋ถ๋๋ฌ์ด ์ ๋๋ฉ์ด์
์ UX์ ์์ฑ๋๋ฅผ ํ์ธต ๋์ฌ์ค. CSS์ JavaScript๋ฅผ ์กฐํฉํด์ ์์ฐ์ค๋ฌ์ด ์ ํ ํจ๊ณผ๋ฅผ ๋ง๋ค์ด๋ณด์.
์ด๋ฐ ์ธ์ฌํ ์๋ฌ ์ฒ๋ฆฌ๊ฐ ์์ผ๋ฉด ์ฌ์ฉ์๋ "์ด ์๋น์ค๋ ๋๋ฅผ ๋ฐฐ๋ คํ๋๊ตฌ๋"๋ผ๊ณ ๋๋ผ๊ฒ ๋ผ. ๋จ์ํ "์๋ฌ ๋ฐ์"์ด๋ผ๊ณ ๋์ ธ์ฃผ๋ ๊ฒ๊ณผ "์ด๋ ๊ฒ ํ๋ฉด ํด๊ฒฐํ ์ ์์ด์"๋ผ๊ณ ์๋ดํ๋ ๊ฒ์ ์ฐจ์ด๋ ์์ฒญ๋๊ฑฐ๋ ! ๐ฏ
๐ resources/css/wallet-animations.css
/* ํ์ด๋ ์ธ ์ ๋๋ฉ์ด์
*/
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ์คํผ๋ ์ ๋๋ฉ์ด์
*/
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* ํ์ค ์ ๋๋ฉ์ด์
*/
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.7;
transform: scale(1.05);
}
}
/* ์ฌ๋ผ์ด๋ ์ธ */
@keyframes slideIn {
from {
transform: translateX(-100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* ๋ฐ์ด์ค */
@keyframes bounce {
0%, 20%, 50%, 80%, 100% {
transform: translateY(0);
}
40% {
transform: translateY(-10px);
}
60% {
transform: translateY(-5px);
}
}
/* ์ํ ์ ํ */
.wallet-state {
animation: fadeIn 0.3s ease-out;
}
/* ๋ฒํผ ํธ๋ฒ ํจ๊ณผ */
.btn-primary {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
}
.btn-primary::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.3);
transform: translate(-50%, -50%);
transition: width 0.6s, height 0.6s;
}
.btn-primary:hover::before {
width: 300px;
height: 300px;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(108, 92, 231, 0.3);
}
/* ํ๋ก๊ทธ๋ ์ค ๋ผ์ธ */
.progress-line {
position: relative;
width: 60px;
height: 2px;
background: #e1e8ed;
margin: 0 10px;
}
.progress-line::after {
content: '';
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 0;
background: linear-gradient(90deg, #6c5ce7, #a29bfe);
transition: width 0.5s ease-out;
}
.progress-line.completed::after {
width: 100%;
}
/* ์คํผ๋ */
.spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #6c5ce7;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 20px auto;
}
/* ํ ์คํธ ์๋ฆผ */
.toast {
position: fixed;
bottom: -100px;
left: 50%;
transform: translateX(-50%);
background: white;
padding: 16px 24px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transition: bottom 0.3s ease-out;
z-index: 9999;
}
.toast.show {
bottom: 30px;
}
.toast-success {
border-left: 4px solid #00b894;
}
.toast-error {
border-left: 4px solid #ff7675;
}
.toast-info {
border-left: 4px solid #74b9ff;
}
/* ์ง๊ฐ ์ฃผ์ ๋ณต์ฌ ํจ๊ณผ */
.wallet-address {
cursor: pointer;
transition: all 0.2s;
}
.wallet-address:hover {
background: #f8f9fa;
padding: 4px 8px;
border-radius: 4px;
}
.wallet-address.copied {
animation: pulse 0.5s;
}
/* ์ฐ๊ฒฐ ์ฑ๊ณต ์ถํ ํจ๊ณผ */
@keyframes celebrate {
0% {
transform: scale(0) rotate(0deg);
opacity: 0;
}
50% {
transform: scale(1.2) rotate(180deg);
opacity: 1;
}
100% {
transform: scale(1) rotate(360deg);
opacity: 1;
}
}
.celebration-icon {
animation: celebrate 0.6s ease-out;
}
## ๐ฑ ๋ชจ๋ฐ์ผ ์ต์ ํ: ์์ ํ๋ฉด์์๋ ์๋ฒฝํ๊ฒ
๋ชจ๋ฐ์ผ ํ๊ฒฝ์ ๋ฐ์คํฌํฑ๊ณผ ์์ ํ ๋ค๋ฅธ ์ ๊ทผ์ด ํ์ํด. ํนํ ๋ฉํ๋ง์คํฌ๋ ๋ชจ๋ฐ์ผ์์ ์ธ์ฑ ๋ธ๋ผ์ฐ์ ๋ฅผ ์ฌ์ฉํ๊ธฐ ๋๋ฌธ์ ๋์ฑ ์ ๊ฒฝ ์จ์ผ ํ์ง! ๐ฑ
### ๐ฏ ๋ชจ๋ฐ์ผ ๊ฐ์ง ๋ฐ ๋ถ๊ธฐ ์ฒ๋ฆฌ
### ๐ ๋คํธ์ํฌ ์ํ ๋ชจ๋ํฐ๋ง
๋ชจ๋ฐ์ผ์ ๋คํธ์ํฌ๊ฐ ๋ถ์์ ํ ์ ์์ผ๋, ์ฐ๊ฒฐ ์ํ๋ฅผ ๋ชจ๋ํฐ๋งํ๊ณ ์ ์ ํ ๋์ํด์ผ ํด.
๐ resources/js/utils/device-detector.js
class DeviceDetector {
constructor() {
this.userAgent = navigator.userAgent || navigator.vendor || window.opera;
this.isMobile = this.detectMobile();
this.isMetaMaskBrowser = this.detectMetaMaskBrowser();
this.platform = this.detectPlatform();
}
// ๋ชจ๋ฐ์ผ ๊ธฐ๊ธฐ ๊ฐ์ง
detectMobile() {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(this.userAgent);
}
// ๋ฉํ๋ง์คํฌ ์ธ์ฑ ๋ธ๋ผ์ฐ์ ๊ฐ์ง
detectMetaMaskBrowser() {
return typeof window.ethereum !== 'undefined' &&
window.ethereum.isMetaMask &&
this.isMobile;
}
// ํ๋ซํผ ๊ฐ์ง
detectPlatform() {
if (/iPad|iPhone|iPod/.test(this.userAgent)) {
return 'ios';
} else if (/Android/.test(this.userAgent)) {
return 'android';
}
return 'desktop';
}
// ๋ฅ๋งํฌ ์์ฑ
getDeepLink(url) {
const dappUrl = encodeURIComponent(url || window.location.href);
if (this.platform === 'ios') {
return `https://metamask.app.link/dapp/${dappUrl}`;
} else if (this.platform === 'android') {
return `https://metamask.app.link/dapp/${dappUrl}`;
}
return null;
}
// ๋ฉํ๋ง์คํฌ ์ฑ ์ค์น ๋งํฌ
getInstallLink() {
if (this.platform === 'ios') {
return 'https://apps.apple.com/app/metamask/id1438144202';
} else if (this.platform === 'android') {
return 'https://play.google.com/store/apps/details?id=io.metamask';
}
return 'https://metamask.io/download/';
}
// ๋ชจ๋ฐ์ผ ์ต์ ํ ์ค์ ์ ์ฉ
applyMobileOptimizations() {
if (!this.isMobile) return;
// ๋ทฐํฌํธ ์ค์
const viewport = document.querySelector('meta[name="viewport"]');
if (viewport) {
viewport.setAttribute('content',
'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no'
);
}
// ํฐ์น ์ด๋ฒคํธ ์ต์ ํ
document.body.style.touchAction = 'manipulation';
// ๋ชจ๋ฐ์ผ ์ ์ฉ ํด๋์ค ์ถ๊ฐ
document.body.classList.add('mobile-device');
document.body.classList.add(`platform-${this.platform}`);
}
}
export default DeviceDetector;
### ๐ฒ ๋ชจ๋ฐ์ผ ์ ์ฉ UI ์ปดํฌ๋ํธ
๋ชจ๋ฐ์ผ์์๋ ๋ฒํผ ํฌ๊ธฐ, ๊ฐ๊ฒฉ, ํฐ์น ์์ญ ๋ฑ์ ๋ ํฌ๊ฒ ๋ง๋ค์ด์ผ ํด.
๐ resources/js/components/mobile-wallet-ui.js
import DeviceDetector from '../utils/device-detector.js';
import MetaMaskConnector from '../web3/metamask.js';
class MobileWalletUI {
constructor() {
this.detector = new DeviceDetector();
this.connector = new MetaMaskConnector();
this.init();
}
init() {
if (!this.detector.isMobile) return;
this.detector.applyMobileOptimizations();
this.setupMobileUI();
this.handleDeepLinking();
}
// ๋ชจ๋ฐ์ผ UI ์ค์
setupMobileUI() {
// ํ์คํฌ๋ฆฐ ๋ชจ๋ฌ ์ฌ์ฉ
const container = document.getElementById('wallet-connect-container');
if (container) {
container.classList.add('mobile-fullscreen');
}
// ํฐ์น ์ ์ค์ฒ ์ถ๊ฐ
this.addSwipeGestures();
// ํ๋จ ์ํธ ์คํ์ผ ์ ์ฉ
this.applyBottomSheetStyle();
}
// ๋ฅ๋งํน ์ฒ๋ฆฌ
handleDeepLinking() {
// ์ผ๋ฐ ๋ธ๋ผ์ฐ์ ์์ ์ ์ํ ๊ฒฝ์ฐ
if (!this.detector.isMetaMaskBrowser && !window.ethereum) {
this.showDeepLinkPrompt();
}
}
// ๋ฅ๋งํฌ ์๋ด ํ์
showDeepLinkPrompt() {
const prompt = document.createElement('div');
prompt.className = 'deeplink-prompt';
prompt.innerHTML = `
<div class="deeplink-content">
<div class="deeplink-icon">๐ฆ</div>
<h3>๋ฉํ๋ง์คํฌ ์ฑ์์ ์ด๊ธฐ</h3>
<p>์ด ํ์ด์ง๋ฅผ ๋ฉํ๋ง์คํฌ ์ฑ์ ๋ธ๋ผ์ฐ์ ์์ ์ด์ด์ผ ํฉ๋๋ค.</p>
<button id="open-in-metamask" class="btn-primary btn-large">
๋ฉํ๋ง์คํฌ์์ ์ด๊ธฐ
</button>
<div class="deeplink-divider">๋๋</div>
<button id="install-metamask-mobile" class="btn-secondary btn-large">
๋ฉํ๋ง์คํฌ ์ฑ ์ค์นํ๊ธฐ
</button>
<div class="deeplink-help">
<p><strong>์ฌ์ฉ ๋ฐฉ๋ฒ:</strong></p>
<ol>
<li>๋ฉํ๋ง์คํฌ ์ฑ์ ์ค์นํฉ๋๋ค</li>
<li>์ฑ ๋ด ๋ธ๋ผ์ฐ์ ๋ฅผ ์ฝ๋๋ค</li>
<li>์ด ํ์ด์ง ์ฃผ์๋ฅผ ์
๋ ฅํฉ๋๋ค</li>
</ol>
</div>
</div>
`;
document.body.appendChild(prompt);
// ์ด๋ฒคํธ ๋ฐ์ธ๋ฉ
document.getElementById('open-in-metamask').addEventListener('click', () => {
const deepLink = this.detector.getDeepLink();
if (deepLink) {
window.location.href = deepLink;
}
});
document.getElementById('install-metamask-mobile').addEventListener('click', () => {
window.location.href = this.detector.getInstallLink();
});
}
// ์ค์์ดํ ์ ์ค์ฒ ์ถ๊ฐ
addSwipeGestures() {
let touchStartY = 0;
let touchEndY = 0;
const modal = document.querySelector('.wallet-container');
if (!modal) return;
modal.addEventListener('touchstart', (e) => {
touchStartY = e.changedTouches[0].screenY;
}, { passive: true });
modal.addEventListener('touchend', (e) => {
touchEndY = e.changedTouches[0].screenY;
this.handleSwipe(touchStartY, touchEndY);
}, { passive: true });
}
// ์ค์์ดํ ์ฒ๋ฆฌ
handleSwipe(startY, endY) {
const swipeDistance = startY - endY;
const threshold = 50;
if (Math.abs(swipeDistance) > threshold) {
if (swipeDistance > 0) {
// ์๋ก ์ค์์ดํ
this.expandModal();
} else {
// ์๋๋ก ์ค์์ดํ
this.minimizeModal();
}
}
}
// ํ๋จ ์ํธ ์คํ์ผ
applyBottomSheetStyle() {
const style = document.createElement('style');
style.textContent = `
.mobile-fullscreen {
position: fixed;
bottom: 0;
left: 0;
right: 0;
max-height: 90vh;
background: white;
border-radius: 20px 20px 0 0;
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.1);
transition: transform 0.3s ease-out;
z-index: 9999;
}
.mobile-fullscreen::before {
content: '';
display: block;
width: 40px;
height: 4px;
background: #ddd;
border-radius: 2px;
margin: 12px auto;
}
.btn-large {
min-height: 56px;
font-size: 18px;
padding: 16px 32px;
width: 100%;
margin: 8px 0;
}
.deeplink-prompt {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: flex-end;
z-index: 10000;
animation: fadeIn 0.3s;
}
.deeplink-content {
background: white;
border-radius: 20px 20px 0 0;
padding: 32px 24px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
}
.deeplink-icon {
font-size: 64px;
text-align: center;
margin-bottom: 16px;
}
.deeplink-divider {
text-align: center;
color: #999;
margin: 24px 0;
position: relative;
}
.deeplink-divider::before,
.deeplink-divider::after {
content: '';
position: absolute;
top: 50%;
width: 40%;
height: 1px;
background: #ddd;
}
.deeplink-divider::before {
left: 0;
}
.deeplink-divider::after {
right: 0;
}
.deeplink-help {
margin-top: 32px;
padding: 20px;
background: #f8f9fa;
border-radius: 12px;
}
.deeplink-help ol {
margin: 12px 0 0 20px;
padding: 0;
}
.deeplink-help li {
margin: 8px 0;
color: #666;
}
`;
document.head.appendChild(style);
}
// ๋ชจ๋ฌ ํ์ฅ
expandModal() {
const modal = document.querySelector('.mobile-fullscreen');
if (modal) {
modal.style.maxHeight = '95vh';
}
}
// ๋ชจ๋ฌ ์ต์ํ
minimizeModal() {
const modal = document.querySelector('.mobile-fullscreen');
if (modal) {
modal.style.maxHeight = '70vh';
}
}
}
export default MobileWalletUI;
๐ฑ ๋ชจ๋ฐ์ผ UX ์ฒดํฌ๋ฆฌ์คํธ
- โ ํฐ์น ์์ญ ์ต์ 44x44px ์ด์
- โ ๋ฒํผ ๊ฐ๊ฒฉ ์ถฉ๋ถํ ํ๋ณด (์ต์ 8px)
- โ ํ ์คํธ ํฌ๊ธฐ ์ต์ 16px (ํ๋ ๋ฐฉ์ง)
- โ ์คํฌ๋กค ์์ญ ๋ช ํํ ๊ตฌ๋ถ
- โ ๋ก๋ฉ ์ธ๋์ผ์ดํฐ ํฌ๊ณ ๋ช ํํ๊ฒ
- โ ์๋ฌ ๋ฉ์์ง ์ ์ฒด ํ๋ฉด์ ํ์
- โ ๋ฅ๋งํฌ ์๋ ์ฒ๋ฆฌ
- โ ๋ค๋ก๊ฐ๊ธฐ ๋ฒํผ ๋์ ์ฒ๋ฆฌ
๐ resources/js/utils/network-monitor.js
class NetworkMonitor {
constructor() {
this.isOnline = navigator.onLine;
this.connectionType = this.getConnectionType();
this.listeners = [];
this.init();
}
init() {
window.addEventListener('online', () => this.handleOnline());
window.addEventListener('offline', () => this.handleOffline());
// ์ฐ๊ฒฐ ํ์
๋ณ๊ฒฝ ๊ฐ์ง
if ('connection' in navigator) {
navigator.connection.addEventListener('change', () => {
this.handleConnectionChange();
});
}
}
// ์ฐ๊ฒฐ ํ์
ํ์ธ
getConnectionType() {
if ('connection' in navigator) {
return navigator.connection.effectiveType;
}
return 'unknown';
}
// ์จ๋ผ์ธ ์ํ๋ก ์ ํ
handleOnline() {
this.isOnline = true;
this.showToast('์ธํฐ๋ท์ ์ฐ๊ฒฐ๋์์ต๋๋ค', 'success');
this.notifyListeners('online');
// ๋๊ธฐ ์ค์ธ ์์ฒญ ์ฌ์๋
this.retryPendingRequests();
}
// ์คํ๋ผ์ธ ์ํ๋ก ์ ํ
handleOffline() {
this.isOnline = false;
this.showToast('์ธํฐ๋ท ์ฐ๊ฒฐ์ด ๋์ด์ก์ต๋๋ค', 'warning');
this.notifyListeners('offline');
}
// ์ฐ๊ฒฐ ํ์
๋ณ๊ฒฝ
handleConnectionChange() {
const newType = this.getConnectionType();
if (newType !== this.connectionType) {
this.connectionType = newType;
// ๋๋ฆฐ ์ฐ๊ฒฐ ๊ฒฝ๊ณ
if (newType === 'slow-2g' || newType === '2g') {
this.showToast('๋คํธ์ํฌ ์๋๊ฐ ๋๋ฆฝ๋๋ค', 'warning');
}
this.notifyListeners('connection-change', newType);
}
}
// ๋ฆฌ์ค๋ ๋ฑ๋ก
addListener(callback) {
this.listeners.push(callback);
}
// ๋ฆฌ์ค๋ ์๋ฆผ
notifyListeners(event, data) {
this.listeners.forEach(callback => {
callback(event, data);
});
}
// ๋๊ธฐ ์ค์ธ ์์ฒญ ์ฌ์๋
retryPendingRequests() {
const pending = JSON.parse(localStorage.getItem('pending_requests') || '[]');
pending.forEach(async (request) => {
try {
await this.executeRequest(request);
this.removePendingRequest(request.id);
} catch (error) {
console.error('Retry failed:', error);
}
});
}
// ์์ฒญ ์คํ
async executeRequest(request) {
// ์์ฒญ ์ฌ์คํ ๋ก์ง
return axios(request.config);
}
// ๋๊ธฐ ์์ฒญ ์ ๊ฑฐ
removePendingRequest(id) {
const pending = JSON.parse(localStorage.getItem('pending_requests') || '[]');
const filtered = pending.filter(r => r.id !== id);
localStorage.setItem('pending_requests', JSON.stringify(filtered));
}
// ํ ์คํธ ํ์
showToast(message, type) {
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => toast.classList.add('show'), 100);
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
}
export default NetworkMonitor;
## ๐งช ํ
์คํธ์ ๋๋ฒ๊น
: ์๋ฒฝํ UX๋ฅผ ์ํ ๋ง์ง๋ง ๋จ๊ณ
์๋ฌด๋ฆฌ ์ข์ ์ฝ๋๋ฅผ ์์ฑํด๋ ํ
์คํธ ์์ด๋ ์์ฑ์ด๋ผ๊ณ ํ ์ ์์ด. ํนํ Web3๋ ๋ค์ํ ํ๊ฒฝ๊ณผ ์ํฉ์ ๊ณ ๋ คํด์ผ ํ๋๊น ์ฒ ์ ํ ํ
์คํธ๊ฐ ํ์์ผ! ๐
### ๐ฏ ํ
์คํธ ์๋๋ฆฌ์ค ์์ฑ
1์ ์ ํ๋ก์ฐ ํ
์คํธ
โข ๋ฉํ๋ง์คํฌ ์ค์น๋จ + ์ฌ๋ฐ๋ฅธ ๋คํธ์ํฌ
โข ๋ฉํ๋ง์คํฌ ์ค์น๋จ + ์๋ชป๋ ๋คํธ์ํฌ
โข ๋ฉํ๋ง์คํฌ ๋ฏธ์ค์น
โข ์ด์ ์ธ์ ๋ณต์
โข ๋ฉํ๋ง์คํฌ ์ค์น๋จ + ์ฌ๋ฐ๋ฅธ ๋คํธ์ํฌ
โข ๋ฉํ๋ง์คํฌ ์ค์น๋จ + ์๋ชป๋ ๋คํธ์ํฌ
โข ๋ฉํ๋ง์คํฌ ๋ฏธ์ค์น
โข ์ด์ ์ธ์ ๋ณต์
2์๋ฌ ์ํฉ ํ
์คํธ
โข ์ฌ์ฉ์๊ฐ ์ฐ๊ฒฐ ๊ฑฐ๋ถ
โข ์๋ช ๊ฑฐ๋ถ
โข ๋คํธ์ํฌ ํ์์์
โข ์๋ชป๋ ์๋ช
โข ๋ง๋ฃ๋ nonce
โข ์ฌ์ฉ์๊ฐ ์ฐ๊ฒฐ ๊ฑฐ๋ถ
โข ์๋ช ๊ฑฐ๋ถ
โข ๋คํธ์ํฌ ํ์์์
โข ์๋ชป๋ ์๋ช
โข ๋ง๋ฃ๋ nonce
3์ฃ์ง ์ผ์ด์ค ํ
์คํธ
โข ๋น ๋ฅธ ์ฐ์ ํด๋ฆญ
โข ํ์ด์ง ์๋ก๊ณ ์นจ ์ค ์ฐ๊ฒฐ
โข ์ฌ๋ฌ ํญ์์ ๋์ ์ฐ๊ฒฐ
โข ๋ฉํ๋ง์คํฌ ๊ณ์ ์ ํ
โข ๋ฉํ๋ง์คํฌ ์ ๊ธ/ํด์
### ๐ ๏ธ ๋๋ฒ๊น
๋๊ตฌ ๊ตฌํ
๊ฐ๋ฐ ์ค์ ๋ฌธ์ ๋ฅผ ๋น ๋ฅด๊ฒ ํ์
ํ ์ ์๋๋ก ๋๋ฒ๊น
๋๊ตฌ๋ฅผ ๋ง๋ค์ด๋ณด์.
โข ๋น ๋ฅธ ์ฐ์ ํด๋ฆญ
โข ํ์ด์ง ์๋ก๊ณ ์นจ ์ค ์ฐ๊ฒฐ
โข ์ฌ๋ฌ ํญ์์ ๋์ ์ฐ๊ฒฐ
โข ๋ฉํ๋ง์คํฌ ๊ณ์ ์ ํ
โข ๋ฉํ๋ง์คํฌ ์ ๊ธ/ํด์
๐ resources/js/utils/web3-debugger.js
class Web3Debugger {
constructor() {
this.enabled = process.env.NODE_ENV === 'development';
this.logs = [];
this.maxLogs = 100;
if (this.enabled) {
this.init();
}
}
init() {
// ๋๋ฒ๊ทธ ํจ๋ ์์ฑ
this.createDebugPanel();
// ์ ์ญ ์๋ฌ ํธ๋ค๋ฌ
window.addEventListener('error', (e) => {
this.logError('Global Error', e.error);
});
// Promise rejection ํธ๋ค๋ฌ
window.addEventListener('unhandledrejection', (e) => {
this.logError('Unhandled Promise Rejection', e.reason);
});
}
// ๋๋ฒ๊ทธ ํจ๋ ์์ฑ
createDebugPanel() {
const panel = document.createElement('div');
panel.id = 'web3-debug-panel';
panel.innerHTML = `
<div class="debug-header">
<span>๐ Web3 Debugger</span>
<button id="debug-toggle">_</button>
<button id="debug-clear">Clear</button>
</div>
<div class="debug-content">
<div class="debug-tabs">
<button class="debug-tab active" data-tab="logs">Logs</button>
<button class="debug-tab" data-tab="state">State</button>
<button class="debug-tab" data-tab="network">Network</button>
</div>
<div class="debug-body">
<div id="debug-logs" class="debug-section active"></div>
<div id="debug-state" class="debug-section"></div>
<div id="debug-network" class="debug-section"></div>
</div>
</div>
`;
document.body.appendChild(panel);
this.bindDebugEvents();
this.applyDebugStyles();
}
// ์ด๋ฒคํธ ๋ฐ์ธ๋ฉ
bindDebugEvents() {
document.getElementById('debug-toggle').addEventListener('click', () => {
const content = document.querySelector('.debug-content');
content.style.display = content.style.display === 'none' ? 'block' : 'none';
});
document.getElementById('debug-clear').addEventListener('click', () => {
this.clearLogs();
});
document.querySelectorAll('.debug-tab').forEach(tab => {
tab.addEventListener('click', (e) => {
this.switchTab(e.target.dataset.tab);
});
});
}
// ํญ ์ ํ
switchTab(tabName) {
document.querySelectorAll('.debug-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.debug-section').forEach(s => s.classList.remove('active'));
document.querySelector(`[data-tab="${tabName}"]`).classList.add('active');
document.getElementById(`debug-${tabName}`).classList.add('active');
if (tabName === 'state') {
this.updateStateView();
} else if (tabName === 'network') {
this.updateNetworkView();
}
}
// ๋ก๊ทธ ๊ธฐ๋ก
log(type, message, data = null) {
if (!this.enabled) return;
const timestamp = new Date().toLocaleTimeString();
const logEntry = {
type,
message,
data,
timestamp
};
this.logs.push(logEntry);
if (this.logs.length > this.maxLogs) {
this.logs.shift();
}
this.updateLogsView();
// ์ฝ์์๋ ์ถ๋ ฅ
console.log(`[Web3 ${type}]`, message, data);
}
logError(message, error) {
this.log('ERROR', message, {
message: error.message,
stack: error.stack,
code: error.code
});
}
logEvent(event, data) {
this.log('EVENT', event, data);
}
logRequest(method, params) {
this.log('REQUEST', method, params);
}
logResponse(method, result) {
this.log('RESPONSE', method, result);
}
// ๋ก๊ทธ ๋ทฐ ์
๋ฐ์ดํธ
updateLogsView() {
const container = document.getElementById('debug-logs');
container.innerHTML = this.logs.map(log => `
<div class="debug-log debug-log-${log.type.toLowerCase()}">
<span class="log-time">${log.timestamp}</span>
<span class="log-type">[${log.type}]</span>
<span class="log-message">${log.message}</span>
${log.data ? `<pre class="log-data">${JSON.stringify(log.data, null, 2)}</pre>` : ''}
</div>
`).reverse().join('');
}
// ์ํ ๋ทฐ ์
๋ฐ์ดํธ
updateStateView() {
const container = document.getElementById('debug-state');
const state = {
ethereum: {
isMetaMask: window.ethereum?.isMetaMask,
chainId: window.ethereum?.chainId,
selectedAddress: window.ethereum?.selectedAddress
},
localStorage: {
authToken: !!localStorage.getItem('auth_token'),
connectionCache: !!localStorage.getItem('web3_connection_cache')
},
session: {
isConnected: !!window.walletUI?.connector?.account,
currentState: window.walletUI?.currentState
}
};
container.innerHTML = `<pre>${JSON.stringify(state, null, 2)}</pre>`;
}
// ๋คํธ์ํฌ ๋ทฐ ์
๋ฐ์ดํธ
updateNetworkView() {
const container = document.getElementById('debug-network');
const networkInfo = {
online: navigator.onLine,
connectionType: navigator.connection?.effectiveType,
downlink: navigator.connection?.downlink,
rtt: navigator.connection?.rtt,
saveData: navigator.connection?.saveData
};
container.innerHTML = `<pre>${JSON.stringify(networkInfo, null, 2)}</pre>`;
}
// ๋ก๊ทธ ์ด๊ธฐํ
clearLogs() {
this.logs = [];
this.updateLogsView();
}
// ์คํ์ผ ์ ์ฉ
applyDebugStyles() {
const style = document.createElement('style');
style.textContent = `
#web3-debug-panel {
position: fixed;
bottom: 20px;
right: 20px;
width: 400px;
max-height: 500px;
background: #1e1e1e;
color: #d4d4d4;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
font-family: 'Courier New', monospace;
font-size: 12px;
z-index: 99999;
overflow: hidden;
}
.debug-header {
background: #2d2d2d;
padding: 10px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #3e3e3e;
}
.debug-header button {
background: #3e3e3e;
border: none;
color: #d4d4d4;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
margin-left: 5px;
}
.debug-header button:hover {
background: #4e4e4e;
}
.debug-content {
max-height: 450px;
overflow: hidden;
}
.debug-tabs {
display: flex;
background: #2d2d2d;
border-bottom: 1px solid #3e3e3e;
}
.debug-tab {
flex: 1;
background: transparent;
border: none;
color: #888;
padding: 8px;
cursor: pointer;
transition: all 0.2s;
}
.debug-tab.active {
color: #4ec9b0;
border-bottom: 2px solid #4ec9b0;
}
.debug-body {
height: 400px;
overflow-y: auto;
}
.debug-section {
display: none;
padding: 10px;
}
.debug-section.active {
display: block;
}
.debug-log {
padding: 8px;
margin: 4px 0;
border-left: 3px solid #4ec9b0;
background: #252526;
border-radius: 4px;
}
.debug-log-error {
border-left-color: #f48771;
}
.debug-log-event {
border-left-color: #4fc1ff;
}
.log-time {
color: #858585;
margin-right: 8px;
}
.log-type {
color: #4ec9b0;
margin-right: 8px;
font-weight: bold;
}
.log-message {
color: #d4d4d4;
}
.log-data {
margin-top: 8px;
padding: 8px;
background: #1e1e1e;
border-radius: 4px;
overflow-x: auto;
font-size: 11px;
}
.debug-body::-webkit-scrollbar {
width: 8px;
}
.debug-body::-webkit-scrollbar-track {
background: #1e1e1e;
}
.debug-body::-webkit-scrollbar-thumb {
background: #3e3e3e;
border-radius: 4px;
}
.debug-body::-webkit-scrollbar-thumb:hover {
background: #4e4e4e;
}
`;
document.head.appendChild(style);
}
}
// ์ ์ญ ์ธ์คํด์ค ์์ฑ
window.web3Debugger = new Web3Debugger();
export default Web3Debugger;
๐ก ๋๋ฒ๊ฑฐ ํ์ฉ ํ
๊ฐ๋ฐ ์ค์๋ ์ด ๋๋ฒ๊ฑฐ๋ฅผ ํญ์ ์ผ๋๊ณ ์์ ํ๋ฉด ๋ฌธ์ ๋ฅผ ๋น ๋ฅด๊ฒ ๋ฐ๊ฒฌํ ์ ์์ด. ํนํ ๋ฉํ๋ง์คํฌ ์ด๋ฒคํธ๋ ๋คํธ์ํฌ ์์ฒญ์ ์ค์๊ฐ์ผ๋ก ๋ชจ๋ํฐ๋งํ ์ ์์ด์ ์์ฒญ ์ ์ฉํด! ํ๋ก๋์ ๋น๋์์๋ ์๋์ผ๋ก ๋นํ์ฑํ๋๋๊น ๊ฑฑ์ ๋ง! ๐ฏ
### ๐ ์ฑ๋ฅ ๋ชจ๋ํฐ๋ง
UX ๊ฐ์ ์ ํจ๊ณผ๋ฅผ ์ธก์ ํ๋ ค๋ฉด ์ฑ๋ฅ ์งํ๋ฅผ ์ถ์ ํด์ผ ํด.
๊ฐ๋ฐ ์ค์๋ ์ด ๋๋ฒ๊ฑฐ๋ฅผ ํญ์ ์ผ๋๊ณ ์์ ํ๋ฉด ๋ฌธ์ ๋ฅผ ๋น ๋ฅด๊ฒ ๋ฐ๊ฒฌํ ์ ์์ด. ํนํ ๋ฉํ๋ง์คํฌ ์ด๋ฒคํธ๋ ๋คํธ์ํฌ ์์ฒญ์ ์ค์๊ฐ์ผ๋ก ๋ชจ๋ํฐ๋งํ ์ ์์ด์ ์์ฒญ ์ ์ฉํด! ํ๋ก๋์ ๋น๋์์๋ ์๋์ผ๋ก ๋นํ์ฑํ๋๋๊น ๊ฑฑ์ ๋ง! ๐ฏ
๐ resources/js/utils/performance-monitor.js
class PerformanceMonitor {
constructor() {
this.metrics = {};
this.marks = {};
}
// ์ธก์ ์์
start(name) {
this.marks[name] = performance.now();
window.web3Debugger?.log('PERF', `Started: ${name}`);
}
// ์ธก์ ์ข
๋ฃ
end(name) {
if (!this.marks[name]) {
console.warn(`No start mark for: ${name}`);
return;
}
const duration = performance.now() - this.marks[name];
if (!this.metrics[name]) {
this.metrics[name] = [];
}
this.metrics[name].push(duration);
delete this.marks[name];
window.web3Debugger?.log('PERF', `Completed: ${name}`, {
duration: `${duration.toFixed(2)}ms`
});
return duration;
}
// ํต๊ณ ์กฐํ
getStats(name) {
const measurements = this.metrics[name];
if (!measurements || measurements.length === 0) {
return null;
}
const sorted = [...measurements].sort((a, b) => a - b);
const sum = sorted.reduce((a, b) => a + b, 0);
return {
count: sorted.length,
min: sorted[0],
max: sorted[sorted.length - 1],
avg: sum / sorted.length,
median: sorted[Math.floor(sorted.length / 2)],
p95: sorted[Math.floor(sorted.length * 0.95)]
};
}
// ๋ชจ๋ ํต๊ณ ์ถ๋ ฅ
printStats() {
console.table(
Object.keys(this.metrics).reduce((acc, name) => {
acc[name] = this.getStats(name);
return acc;
}, {})
);
}
// ๋ฆฌํฌํธ ์์ฑ
generateReport() {
const report = {
timestamp: new Date().toISOString(),
metrics: {}
};
Object.keys(this.metrics).forEach(name => {
report.metrics[name] = this.getStats(name);
});
return report;
}
// ์๋ฒ๋ก ์ ์ก
async sendToServer() {
const report = this.generateReport();
try {
await axios.post('/api/analytics/performance', report);
console.log('Performance report sent');
} catch (error) {
console.error('Failed to send performance report:', error);
}
}
}
// ์ ์ญ ์ธ์คํด์ค
window.perfMonitor = new PerformanceMonitor();
export default PerformanceMonitor;
์ด์ ์ฝ๋ ๊ณณ๊ณณ์์ ์ฑ๋ฅ์ ์ธก์ ํ ์ ์์ด:
// ์ฌ์ฉ ์์
perfMonitor.start('wallet-connection');
await connectWallet();
perfMonitor.end('wallet-connection');
perfMonitor.start('signature-verification');
await verifySignature();
perfMonitor.end('signature-verification');
// ํต๊ณ ํ์ธ
perfMonitor.printStats();
## ๐ ์ค์ ํ๊ณผ ๋ฒ ์คํธ ํ๋ํฐ์ค
์ง๊ธ๊น์ง ๋ฐฐ์ด ๋ด์ฉ์ ๋ฐํ์ผ๋ก ์ค์ ์์ ๋ฐ๋ก ์ ์ฉํ ์ ์๋ ํ๋ค์ ์ ๋ฆฌํด๋ณผ๊ฒ! ๐ก
### โจ UX ๊ฐ์ ์ฒดํฌ๋ฆฌ์คํธ
### ๐ ์ฑ๋ฅ ์ต์ ํ ์ฐ์ ์์
| ์นดํ ๊ณ ๋ฆฌ | ์ฒดํฌ ํญ๋ชฉ | ์ค์๋ |
|---|---|---|
| ์ด๊ธฐ ๋ก๋ฉ | ๋ฉํ๋ง์คํฌ ๊ฐ์ง ๋ฐ ์ค์น ์ ๋ | โญโญโญ |
| ์ด๊ธฐ ๋ก๋ฉ | ์ด์ ์ธ์ ์๋ ๋ณต์ | โญโญโญ |
| ์ฐ๊ฒฐ ๊ณผ์ | ๊ฐ ๋จ๊ณ๋ณ ๋ช ํํ ํผ๋๋ฐฑ | โญโญโญ |
| ์ฐ๊ฒฐ ๊ณผ์ | ํ๋ก๊ทธ๋ ์ค ์ธ๋์ผ์ดํฐ ํ์ | โญโญ |
| ์๋ฌ ์ฒ๋ฆฌ | ์น์ ํ ์๋ฌ ๋ฉ์์ง | โญโญโญ |
| ์๋ฌ ์ฒ๋ฆฌ | ์๋ ๋ณต๊ตฌ ์๋ | โญโญ |
| ๋คํธ์ํฌ | ์๋ ๋คํธ์ํฌ ์ ํ | โญโญโญ |
| ๋คํธ์ํฌ | ๋คํธ์ํฌ ์ํ ๋ชจ๋ํฐ๋ง | โญโญ |
| ๋ชจ๋ฐ์ผ | ๋ฅ๋งํฌ ์ฒ๋ฆฌ | โญโญโญ |
| ๋ชจ๋ฐ์ผ | ํฐ์น ์ต์ ํ | โญโญโญ |
| ์ฑ๋ฅ | ์บ์ฑ ์ ๋ต | โญโญโญ |
| ์ฑ๋ฅ | ์์ฒญ ๋๋ฐ์ด์ฑ | โญโญ |
| ๋ณด์ | Nonce ๊ธฐ๋ฐ ์ธ์ฆ | โญโญโญ |
| ๋ณด์ | ํ ํฐ ๋ง๋ฃ ๊ด๋ฆฌ | โญโญโญ |
1์์: ์ด๊ธฐ ๋ก๋ฉ ์๋
์ฌ์ฉ์๊ฐ ๊ฐ์ฅ ๋จผ์ ์ฒด๊ฐํ๋ ๋ถ๋ถ์ด์ผ. ์บ์ฑ๊ณผ ํ๋ฆฌ๋ก๋ฉ์ ์ ๊ทน ํ์ฉํด์ 1์ด ์ด๋ด์ UI๊ฐ ํ์๋๋๋ก ํด์ผ ํด.
์ฌ์ฉ์๊ฐ ๊ฐ์ฅ ๋จผ์ ์ฒด๊ฐํ๋ ๋ถ๋ถ์ด์ผ. ์บ์ฑ๊ณผ ํ๋ฆฌ๋ก๋ฉ์ ์ ๊ทน ํ์ฉํด์ 1์ด ์ด๋ด์ UI๊ฐ ํ์๋๋๋ก ํด์ผ ํด.
2์์: ์ฐ๊ฒฐ ์๋ต ์๋
๋ฉํ๋ง์คํฌ ์ฐ๊ฒฐ ์์ฒญ ํ 3์ด ์ด๋ด์ ๊ฒฐ๊ณผ๊ฐ ๋์์ผ ํด. ์๋ฒ ์๋ต ์๊ฐ์ ์ต์ํํ๊ณ , ๋๊ด์ UI ์ ๋ฐ์ดํธ๋ฅผ ํ์ฉํ์.
๋ฉํ๋ง์คํฌ ์ฐ๊ฒฐ ์์ฒญ ํ 3์ด ์ด๋ด์ ๊ฒฐ๊ณผ๊ฐ ๋์์ผ ํด. ์๋ฒ ์๋ต ์๊ฐ์ ์ต์ํํ๊ณ , ๋๊ด์ UI ์ ๋ฐ์ดํธ๋ฅผ ํ์ฉํ์.
3์์: ์๋ฌ ๋ณต๊ตฌ ์๋
์๋ฌ ๋ฐ์ ์ ์ฌ์ฉ์๊ฐ ๋น ๋ฅด๊ฒ ์ฌ์๋ํ ์ ์๋๋ก ํด์ผ ํด. ์๋ ๋ณต๊ตฌ๊ฐ ๊ฐ๋ฅํ ๊ฒฝ์ฐ๋ ์ฌ์ฉ์ ๊ฐ์ ์์ด ์ฒ๋ฆฌํ์.
### ๐ ๊ณ ๊ธ ํ
ํฌ๋
์๋ฌ ๋ฐ์ ์ ์ฌ์ฉ์๊ฐ ๋น ๋ฅด๊ฒ ์ฌ์๋ํ ์ ์๋๋ก ํด์ผ ํด. ์๋ ๋ณต๊ตฌ๊ฐ ๊ฐ๋ฅํ ๊ฒฝ์ฐ๋ ์ฌ์ฉ์ ๊ฐ์ ์์ด ์ฒ๋ฆฌํ์.
1๋๊ด์ UI ์
๋ฐ์ดํธ
์๋ฒ ์๋ต์ ๊ธฐ๋ค๋ฆฌ์ง ์๊ณ ๋จผ์ UI๋ฅผ ์ ๋ฐ์ดํธํด์ ์ฒด๊ฐ ์๋๋ฅผ ๋์ฌ. ๋ง์ฝ ์คํจํ๋ฉด ๋กค๋ฐฑํ๋ฉด ๋ผ.
์๋ฒ ์๋ต์ ๊ธฐ๋ค๋ฆฌ์ง ์๊ณ ๋จผ์ UI๋ฅผ ์ ๋ฐ์ดํธํด์ ์ฒด๊ฐ ์๋๋ฅผ ๋์ฌ. ๋ง์ฝ ์คํจํ๋ฉด ๋กค๋ฐฑํ๋ฉด ๋ผ.
// ๋๊ด์ ์
๋ฐ์ดํธ ์์
async function optimisticConnect(address) {
// 1. ๋จผ์ UI ์
๋ฐ์ดํธ
showConnected(address, '0.00'); // ์์ ์์ก
try {
// 2. ์ค์ ๋ฐ์ดํฐ ๊ฐ์ ธ์ค๊ธฐ
const balance = await getBalance(address);
updateBalance(balance);
} catch (error) {
// 3. ์คํจ ์ ๋กค๋ฐฑ
showConnectPrompt();
showError(error.message);
}
}
2์์ฒญ ๋ฐฐ์นญ
์ฌ๋ฌ ๊ฐ์ ์์ฒญ์ ํ๋๋ก ๋ฌถ์ด์ ๋ณด๋ด๋ฉด ๋คํธ์ํฌ ์๋ณต ์๊ฐ์ ์ค์ผ ์ ์์ด.
์ฌ๋ฌ ๊ฐ์ ์์ฒญ์ ํ๋๋ก ๋ฌถ์ด์ ๋ณด๋ด๋ฉด ๋คํธ์ํฌ ์๋ณต ์๊ฐ์ ์ค์ผ ์ ์์ด.
// ๋ฐฐ์นญ ์์
async function batchRequests(address) {
const [balance, nonce, chainId] = await Promise.all([
provider.getBalance(address),
provider.getTransactionCount(address),
provider.getNetwork().then(n => n.chainId)
]);
return { balance, nonce, chainId };
}
3์ธํฐ๋ํฐ๋ธ ๋ก๋ฉ
๋จ์ํ ์คํผ๋ ๋์ ์ฌ์ฉ์๊ฐ ์ํธ์์ฉํ ์ ์๋ ๋ก๋ฉ ํ๋ฉด์ ๋ง๋ค์ด.
๋จ์ํ ์คํผ๋ ๋์ ์ฌ์ฉ์๊ฐ ์ํธ์์ฉํ ์ ์๋ ๋ก๋ฉ ํ๋ฉด์ ๋ง๋ค์ด.
// ์ธํฐ๋ํฐ๋ธ ๋ก๋ฉ ์์
function showInteractiveLoading() {
const tips = [
'๐ก ๋ฉํ๋ง์คํฌ๋ ๊ฐ์ค๋น๊ฐ ๋ค์ง ์๋ ์๋ช
๋ง ์์ฒญํฉ๋๋ค',
'๐ ๊ฐ์ธํค๋ ์ ๋ ์ธ๋ถ๋ก ์ ์ก๋์ง ์์ต๋๋ค',
'โก ๋ก๊ทธ์ธ ํ ๋ชจ๋ ๊ธฐ๋ฅ์ ์ด์ฉํ ์ ์์ต๋๋ค'
];
let currentTip = 0;
const interval = setInterval(() => {
document.getElementById('loading-tip').textContent = tips[currentTip];
currentTip = (currentTip + 1) % tips.length;
}, 3000);
return () => clearInterval(interval);
}
### ๐ฏ ์ฌ๋ฅ๋ท์์์ ํ์ฉ
์ฌ๋ฅ๋ท ๊ฐ์ ์ฌ๋ฅ ๊ฑฐ๋ ํ๋ซํผ์์ Web3 ๋ก๊ทธ์ธ์ ๋์
ํ๋ฉด ์ด๋ค ์ฅ์ ์ด ์์๊น? ๐ค
๐จ ํฌ๋ฆฌ์์ดํฐ ์ธ์ฆ
๋ธ๋ก์ฒด์ธ ์ง๊ฐ ์ฃผ์๋ฅผ ํตํด ํฌ๋ฆฌ์์ดํฐ์ ์ ์ฒด์ฑ์ ๊ฒ์ฆํ ์ ์์ด. NFT ํฌํธํด๋ฆฌ์ค๋ ์จ์ฒด์ธ ํ๋ ์ด๋ ฅ์ ์๋์ผ๋ก ๊ฐ์ ธ์์ ํ๋กํ์ ํ์ํ ์๋ ์์ง!
๋ธ๋ก์ฒด์ธ ์ง๊ฐ ์ฃผ์๋ฅผ ํตํด ํฌ๋ฆฌ์์ดํฐ์ ์ ์ฒด์ฑ์ ๊ฒ์ฆํ ์ ์์ด. NFT ํฌํธํด๋ฆฌ์ค๋ ์จ์ฒด์ธ ํ๋ ์ด๋ ฅ์ ์๋์ผ๋ก ๊ฐ์ ธ์์ ํ๋กํ์ ํ์ํ ์๋ ์์ง!
๐ฐ ์ํธํํ ๊ฒฐ์
๋ฉํ๋ง์คํฌ๋ก ๋ก๊ทธ์ธํ ์ฌ์ฉ์๋ ๋ฐ๋ก ์ํธํํ๋ก ๊ฒฐ์ ํ ์ ์์ด. ๊ตญ์ ์ก๊ธ ์์๋ฃ ์์ด ์ ์ธ๊ณ ํฌ๋ฆฌ์์ดํฐ์ ๊ฑฐ๋ํ ์ ์๋ ๊ฑฐ์ผ!
๋ฉํ๋ง์คํฌ๋ก ๋ก๊ทธ์ธํ ์ฌ์ฉ์๋ ๋ฐ๋ก ์ํธํํ๋ก ๊ฒฐ์ ํ ์ ์์ด. ๊ตญ์ ์ก๊ธ ์์๋ฃ ์์ด ์ ์ธ๊ณ ํฌ๋ฆฌ์์ดํฐ์ ๊ฑฐ๋ํ ์ ์๋ ๊ฑฐ์ผ!
๐ ์จ์ฒด์ธ ํํ ์์คํ
๊ฑฐ๋ ์ด๋ ฅ๊ณผ ํ๊ฐ๋ฅผ ๋ธ๋ก์ฒด์ธ์ ๊ธฐ๋กํ๋ฉด ์กฐ์์ด ๋ถ๊ฐ๋ฅํ ์ ๋ขฐ ์์คํ ์ ๋ง๋ค ์ ์์ด. ์ฌ๋ฅ๋ท์์ ์์ ํํ์ ๋ค๋ฅธ ํ๋ซํผ์์๋ ํ์ฉํ ์ ์๊ฒ ๋๋ ๊ฑฐ์ง!
๊ฑฐ๋ ์ด๋ ฅ๊ณผ ํ๊ฐ๋ฅผ ๋ธ๋ก์ฒด์ธ์ ๊ธฐ๋กํ๋ฉด ์กฐ์์ด ๋ถ๊ฐ๋ฅํ ์ ๋ขฐ ์์คํ ์ ๋ง๋ค ์ ์์ด. ์ฌ๋ฅ๋ท์์ ์์ ํํ์ ๋ค๋ฅธ ํ๋ซํผ์์๋ ํ์ฉํ ์ ์๊ฒ ๋๋ ๊ฑฐ์ง!
## ๐ฌ ๋ง๋ฌด๋ฆฌํ๋ฉฐ
์, ์ฌ๊ธฐ๊น์ง ๋ผ๋ผ๋ฒจ์์ ๋ฉํ๋ง์คํฌ ๋ก๊ทธ์ธ UX๋ฅผ ๊ฐ์ ํ๋ ๋ฐฉ๋ฒ์ ๋ํด ์์๋ดค์ด! ์ ๋ง ๊ธด ์ฌ์ ์ด์์ง? ๐
ํต์ฌ์ ๋ค์ ํ๋ฒ ์ ๋ฆฌํด๋ณผ๊ฒ:
์ค๋ ๋ฐฐ์ด ๋ด์ฉ์ ๋ฐํ์ผ๋ก ์ฌ๋ฌ๋ถ๋ง์ ๋ฉ์ง Web3 ์๋น์ค๋ฅผ ๋ง๋ค์ด๋ณด๊ธธ ๋ฐ๋ผ! ์ฌ๋ฅ๋ท ๊ฐ์ ํ๋ซํผ์์๋ ์ด๋ฐ ๊ธฐ์ ๋ค์ด ํ์ฉ๋๋ฉด ์ ๋ง ํ์ ์ ์ธ ์๋น์ค๊ฐ ํ์ํ ๊ฑฐ์ผ. ๐ช
ํน์ ๊ตฌํํ๋ค๊ฐ ๋งํ๋ ๋ถ๋ถ์ด ์์ผ๋ฉด ์ธ์ ๋ ์ปค๋ฎค๋ํฐ์ ์ง๋ฌธํ๊ณ , ์๋ก ๋์ฐ๋ฉด์ ํจ๊ป ์ฑ์ฅํ์! Web3์ ๋ฏธ๋๋ ์ฐ๋ฆฌ๊ฐ ๋ง๋ค์ด๊ฐ๋ ๊ฑฐ๋๊น! โจ
๊ทธ๋ผ ์ฆ๊ฑฐ์ด ์ฝ๋ฉ ๋๊ธธ! Happy coding! ๐
ํต์ฌ์ ๋ค์ ํ๋ฒ ์ ๋ฆฌํด๋ณผ๊ฒ:
๐ฏ UX ๊ฐ์ ์ ํต์ฌ ์์น
1๏ธโฃ ๋ช ํํ ํผ๋๋ฐฑ: ์ฌ์ฉ์๊ฐ ํญ์ ํ์ฌ ์ํ๋ฅผ ์ ์ ์์ด์ผ ํด
2๏ธโฃ ์๋ฌ ๋ณต๊ตฌ: ๋ฌธ์ ๊ฐ ์๊ฒผ์ ๋ ํด๊ฒฐ ๋ฐฉ๋ฒ์ ์ ์ํด์ผ ํด
3๏ธโฃ ๋น ๋ฅธ ์๋ต: ๋ชจ๋ ์ก์ ์ ์ฆ๊ฐ์ ์ผ๋ก ๋ฐ์ํด์ผ ํด
4๏ธโฃ ๋ชจ๋ฐ์ผ ์ต์ ํ: ์์ ํ๋ฉด์์๋ ์๋ฒฝํ๊ฒ ๋์ํด์ผ ํด
5๏ธโฃ ๋ณด์ ์ฐ์ : ํธ์์ฑ๊ณผ ๋ณด์์ ๊ท ํ์ ๋ง์ถฐ์ผ ํด
Web3๋ ์์ง ๋์คํ ์ด๊ธฐ ๋จ๊ณ์ผ. ๊ทธ๋์ UX๊ฐ ๋์ฑ ์ค์ํด. ์ฌ์ฉ์๋ค์ด "์ด๋ ต๋ค", "๋ณต์กํ๋ค"๊ณ ๋๋ผ๋ ์๊ฐ, Web3์ ๋ฏธ๋๋ ๋ฉ์ด์ง๊ฑฐ๋ . ์ฐ๋ฆฌ ๊ฐ๋ฐ์๋ค์ด ์ด๋ฐ ์ฅ๋ฒฝ์ ๋ฎ์ถฐ์ ๋๊ตฌ๋ ์ฝ๊ฒ Web3 ์๋น์ค๋ฅผ ์ด์ฉํ ์ ์๊ฒ ๋ง๋ค์ด์ผ ํด! ๐1๏ธโฃ ๋ช ํํ ํผ๋๋ฐฑ: ์ฌ์ฉ์๊ฐ ํญ์ ํ์ฌ ์ํ๋ฅผ ์ ์ ์์ด์ผ ํด
2๏ธโฃ ์๋ฌ ๋ณต๊ตฌ: ๋ฌธ์ ๊ฐ ์๊ฒผ์ ๋ ํด๊ฒฐ ๋ฐฉ๋ฒ์ ์ ์ํด์ผ ํด
3๏ธโฃ ๋น ๋ฅธ ์๋ต: ๋ชจ๋ ์ก์ ์ ์ฆ๊ฐ์ ์ผ๋ก ๋ฐ์ํด์ผ ํด
4๏ธโฃ ๋ชจ๋ฐ์ผ ์ต์ ํ: ์์ ํ๋ฉด์์๋ ์๋ฒฝํ๊ฒ ๋์ํด์ผ ํด
5๏ธโฃ ๋ณด์ ์ฐ์ : ํธ์์ฑ๊ณผ ๋ณด์์ ๊ท ํ์ ๋ง์ถฐ์ผ ํด
์ค๋ ๋ฐฐ์ด ๋ด์ฉ์ ๋ฐํ์ผ๋ก ์ฌ๋ฌ๋ถ๋ง์ ๋ฉ์ง Web3 ์๋น์ค๋ฅผ ๋ง๋ค์ด๋ณด๊ธธ ๋ฐ๋ผ! ์ฌ๋ฅ๋ท ๊ฐ์ ํ๋ซํผ์์๋ ์ด๋ฐ ๊ธฐ์ ๋ค์ด ํ์ฉ๋๋ฉด ์ ๋ง ํ์ ์ ์ธ ์๋น์ค๊ฐ ํ์ํ ๊ฑฐ์ผ. ๐ช
ํน์ ๊ตฌํํ๋ค๊ฐ ๋งํ๋ ๋ถ๋ถ์ด ์์ผ๋ฉด ์ธ์ ๋ ์ปค๋ฎค๋ํฐ์ ์ง๋ฌธํ๊ณ , ์๋ก ๋์ฐ๋ฉด์ ํจ๊ป ์ฑ์ฅํ์! Web3์ ๋ฏธ๋๋ ์ฐ๋ฆฌ๊ฐ ๋ง๋ค์ด๊ฐ๋ ๊ฑฐ๋๊น! โจ
๊ทธ๋ผ ์ฆ๊ฑฐ์ด ์ฝ๋ฉ ๋๊ธธ! Happy coding! ๐
๊ด๋ จ ํค์๋
๋๊ธ 0
์ง์์ธ์ ์ฒ - ์ง์ ์ฌ์ฐ๊ถ ๋ณดํธ ๊ณ ์ง
์ง์ ์ฌ์ฐ๊ถ ๋ณดํธ ๊ณ ์ง
- ์ ์๊ถ ๋ฐ ์์ ๊ถ: ๋ณธ ์ปจํ ์ธ ๋ ์ฌ๋ฅ๋ท์ ๋ ์ AI ๊ธฐ์ ๋ก ์์ฑ๋์์ผ๋ฉฐ, ๋ํ๋ฏผ๊ตญ ์ ์๊ถ๋ฒ ๋ฐ ๊ตญ์ ์ ์๊ถ ํ์ฝ์ ์ํด ๋ณดํธ๋ฉ๋๋ค.
- AI ์์ฑ ์ปจํ ์ธ ์ ๋ฒ์ ์ง์: ๋ณธ AI ์์ฑ ์ปจํ ์ธ ๋ ์ฌ๋ฅ๋ท์ ์ง์ ์ฐฝ์๋ฌผ๋ก ์ธ์ ๋๋ฉฐ, ๊ด๋ จ ๋ฒ๊ท์ ๋ฐ๋ผ ์ ์๊ถ ๋ณดํธ๋ฅผ ๋ฐ์ต๋๋ค.
- ์ฌ์ฉ ์ ํ: ์ฌ๋ฅ๋ท์ ๋ช ์์ ์๋ฉด ๋์ ์์ด ๋ณธ ์ปจํ ์ธ ๋ฅผ ๋ณต์ , ์์ , ๋ฐฐํฌ, ๋๋ ์์ ์ ์ผ๋ก ํ์ฉํ๋ ํ์๋ ์๊ฒฉํ ๊ธ์ง๋ฉ๋๋ค.
- ๋ฐ์ดํฐ ์์ง ๊ธ์ง: ๋ณธ ์ปจํ ์ธ ์ ๋ํ ๋ฌด๋จ ์คํฌ๋ํ, ํฌ๋กค๋ง, ๋ฐ ์๋ํ๋ ๋ฐ์ดํฐ ์์ง์ ๋ฒ์ ์ ์ฌ์ ๋์์ด ๋ฉ๋๋ค.
- AI ํ์ต ์ ํ: ์ฌ๋ฅ๋ท์ AI ์์ฑ ์ปจํ ์ธ ๋ฅผ ํ AI ๋ชจ๋ธ ํ์ต์ ๋ฌด๋จ ์ฌ์ฉํ๋ ํ์๋ ๊ธ์ง๋๋ฉฐ, ์ด๋ ์ง์ ์ฌ์ฐ๊ถ ์นจํด๋ก ๊ฐ์ฃผ๋ฉ๋๋ค.

๋๊ธ ์์ฑ
์ด ๊ธ์ ๋ํ ์ฌ๋ฌ๋ถ์ ์๊ฐ์ ๋ค๋ ค์ฃผ์ธ์
๋ก๊ทธ์ธ์ด ํ์ํฉ๋๋ค
๋๊ธ์ ์์ฑํ๋ ค๋ฉด ๋จผ์ ๋ก๊ทธ์ธํด์ฃผ์ธ์.