Ver3.5 라라벨 기반 스테이킹 대시보드설계 및 구축 완전 가이드

라라벨 기반 스테이킹 대시보드
설계 및 구축 완전 가이드
🚀 PHP 최강 프레임워크 Laravel로 Web3 스테이킹 플랫폼을 처음부터 끝까지 만들어보자!
야, 솔직히 말해볼게. 스테이킹(Staking)이라는 단어 들으면 뭔가 복잡하고 어렵게 느껴지지 않아? 🤔
근데 막상 뜯어보면 "내 코인을 네트워크에 맡기고 이자 받는 것"이 핵심이야.
그리고 이걸 사용자들이 편하게 쓸 수 있도록 예쁘고 기능적인 대시보드로 만드는 게 오늘의 미션이지!
Laravel은 PHP 생태계에서 압도적인 1위 프레임워크야. MVC 패턴, Eloquent ORM, Blade 템플릿, Queue, Event 시스템까지 갖춘 풀스택 솔루션이거든.
여기에 Web3 기술을 붙이면? 💥 강력한 블록체인 대시보드가 탄생해!
1. 스테이킹이 뭔지 제대로 알고 가자 🎯
코드 짜기 전에 개념부터 확실히 잡아야 해. 모르고 만들면 나중에 설계가 꼬이거든.
📌 스테이킹(Staking)의 핵심 개념
스테이킹은 Proof of Stake(PoS) 기반 블록체인에서 사용자가 자신의 토큰을 스마트 컨트랙트에 잠가두고(lock), 그 대가로 보상(reward)을 받는 메커니즘이야.
은행 예금이랑 비슷한 개념인데, 차이점은 탈중앙화되어 있고 스마트 컨트랙트가 자동으로 처리한다는 거지.
| 구분 | 은행 예금 | 블록체인 스테이킹 |
|---|---|---|
| 관리 주체 | 중앙화된 은행 | 스마트 컨트랙트 (탈중앙) |
| 이자율 | 연 1~3% 수준 | 연 5~20%+ (프로토콜마다 다름) |
| 출금 제한 | 만기 전 패널티 | 언본딩 기간 (7~21일 등) |
| 투명성 | 내부 장부 | 온체인 완전 공개 |
| 보안 | 예금자보호법 | 스마트 컨트랙트 감사 |
🔑 대시보드에서 다뤄야 할 핵심 데이터
스테이킹 대시보드를 만들 때 반드시 보여줘야 하는 데이터들이 있어:
⚙️ 스마트 컨트랙트와의 상호작용 흐름
대시보드가 블록체인과 어떻게 소통하는지 이해하면 설계가 훨씬 쉬워져:
2. Laravel 프로젝트 설계 - 뼈대부터 탄탄하게 🏗️
📁 디렉토리 구조 설계
Laravel의 기본 구조를 스테이킹 대시보드에 맞게 확장해보자. 처음부터 잘 잡아놔야 나중에 유지보수가 편해!
프로젝트 구조staking-dashboard/
├── app/
│ ├── Console/
│ │ └── Commands/
│ │ ├── SyncStakingData.php # 스테이킹 데이터 동기화
│ │ └── ProcessRewards.php # 보상 계산 처리
│ ├── Http/
│ │ ├── Controllers/
│ │ │ ├── DashboardController.php # 메인 대시보드
│ │ │ ├── StakingController.php # 스테이킹 CRUD
│ │ │ ├── WalletController.php # 지갑 연결/인증
│ │ │ └── Api/
│ │ │ ├── StakingApiController.php
│ │ │ └── PriceApiController.php
│ │ ├── Middleware/
│ │ │ ├── WalletAuthenticated.php # 지갑 인증 미들웨어
│ │ │ └── RateLimitBlockchain.php # API 요청 제한
│ │ └── Requests/
│ │ ├── StakeRequest.php
│ │ └── UnstakeRequest.php
│ ├── Models/
│ │ ├── User.php
│ │ ├── StakingPosition.php # 스테이킹 포지션
│ │ ├── StakingReward.php # 보상 기록
│ │ ├── Transaction.php # 트랜잭션 기록
│ │ └── TokenPrice.php # 토큰 가격 캐시
│ ├── Services/
│ │ ├── BlockchainService.php # 블록체인 연동 핵심
│ │ ├── StakingService.php # 스테이킹 비즈니스 로직
│ │ ├── Web3AuthService.php # Web3 인증
│ │ └── PriceOracleService.php # 가격 오라클
│ ├── Jobs/
│ │ ├── SyncUserStaking.php # 비동기 동기화
│ │ └── ProcessRewardClaim.php # 보상 청구 처리
│ └── Events/
│ ├── StakingDeposited.php
│ ├── StakingWithdrawn.php
│ └── RewardClaimed.php
├── database/
│ └── migrations/
│ ├── create_staking_positions_table.php
│ ├── create_staking_rewards_table.php
│ └── create_transactions_table.php
├── resources/
│ └── views/
│ ├── dashboard/
│ │ ├── index.blade.php # 메인 대시보드
│ │ └── partials/
│ │ ├── stats-card.blade.php
│ │ └── transaction-list.blade.php
│ └── layouts/
│ └── app.blade.php
└── config/
└── blockchain.php # 블록체인 설정
🗄️ 데이터베이스 마이그레이션 설계
스테이킹 대시보드의 핵심 테이블들을 설계해보자:
database/migrations/create_staking_positions_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(): void
{
Schema::create('staking_positions', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('wallet_address', 42)->index(); // 이더리움 주소
$table->string('contract_address', 42); // 스테이킹 컨트랙트
$table->string('token_address', 42); // 스테이킹 토큰
$table->string('network', 20)->default('ethereum'); // 네트워크
$table->decimal('staked_amount', 36, 18); // 스테이킹 수량
$table->decimal('pending_rewards', 36, 18)->default(0); // 미청구 보상
$table->decimal('claimed_rewards', 36, 18)->default(0); // 청구된 보상
$table->decimal('apy', 8, 4)->nullable(); // 현재 APY
$table->timestamp('staked_at'); // 스테이킹 시작 시간
$table->timestamp('last_reward_at')->nullable(); // 마지막 보상 시간
$table->enum('status', ['active', 'unbonding', 'withdrawn'])->default('active');
$table->timestamp('unbonding_ends_at')->nullable(); // 언본딩 종료 시간
$table->string('tx_hash', 66)->nullable(); // 스테이킹 트랜잭션 해시
$table->json('metadata')->nullable(); // 추가 메타데이터
$table->timestamps();
$table->index(['wallet_address', 'status']);
$table->index(['contract_address', 'network']);
});
}
public function down(): void
{
Schema::dropIfExists('staking_positions');
}
};
database/migrations/create_transactions_table.php
<?php
return new class extends Migration {
public function up(): void
{
Schema::create('transactions', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained();
$table->string('tx_hash', 66)->unique();
$table->string('wallet_address', 42)->index();
$table->string('contract_address', 42);
$table->string('network', 20);
$table->enum('type', [
'stake', 'unstake', 'claim_reward',
'emergency_withdraw', 'compound'
]);
$table->decimal('amount', 36, 18)->nullable();
$table->decimal('gas_used', 20, 0)->nullable();
$table->decimal('gas_price', 20, 0)->nullable();
$table->bigInteger('block_number')->nullable();
$table->enum('status', ['pending', 'confirmed', 'failed'])->default('pending');
$table->json('event_data')->nullable();
$table->timestamp('confirmed_at')->nullable();
$table->timestamps();
});
}
};
⚙️ 블록체인 설정 파일
config/blockchain.php<?php
return [
'networks' => [
'ethereum' => [
'rpc_url' => env('ETH_RPC_URL', 'https://mainnet.infura.io/v3/YOUR_KEY'),
'chain_id' => 1,
'explorer' => 'https://etherscan.io',
'native_token' => 'ETH',
],
'bsc' => [
'rpc_url' => env('BSC_RPC_URL', 'https://bsc-dataseed.binance.org'),
'chain_id' => 56,
'explorer' => 'https://bscscan.com',
'native_token' => 'BNB',
],
'polygon' => [
'rpc_url' => env('POLYGON_RPC_URL', 'https://polygon-rpc.com'),
'chain_id' => 137,
'explorer' => 'https://polygonscan.com',
'native_token' => 'MATIC',
],
],
'contracts' => [
'staking' => [
'address' => env('STAKING_CONTRACT_ADDRESS'),
'abi_path' => storage_path('app/abis/staking.json'),
],
'token' => [
'address' => env('STAKING_TOKEN_ADDRESS'),
'abi_path' => storage_path('app/abis/erc20.json'),
],
],
'sync' => [
'interval_seconds' => 30, // 동기화 주기
'max_retries' => 3, // 최대 재시도 횟수
'timeout' => 10, // 요청 타임아웃(초)
],
'price_oracle' => [
'provider' => env('PRICE_ORACLE', 'coingecko'), // coingecko, chainlink
'cache_ttl' => 60, // 가격 캐시 시간(초)
],
];
3. 블록체인 연동 - Web3PHP로 스마트 컨트랙트 붙이기 ⛓️
이제 진짜 핵심이야! Laravel에서 블록체인과 통신하는 방법을 알아보자.
PHP에서 이더리움 계열 블록체인과 통신하려면 web3.php 라이브러리를 주로 사용해.
📦 필요한 패키지 설치
터미널# Web3 PHP 라이브러리 설치
composer require web3p/web3.php
# HTTP 클라이언트 (Guzzle)
composer require guzzlehttp/guzzle
# 가격 데이터용 (선택)
composer require coingecko/api
# 큐 처리용 (Redis 드라이버)
composer require predis/predis
# 서명 검증용
composer require kornrunner/keccak
🔧 BlockchainService 구현
모든 블록체인 통신을 담당하는 핵심 서비스야. 여기서 실수하면 전체가 흔들리니까 꼼꼼하게 짜야 해!
app/Services/BlockchainService.php<?php
namespace App\Services;
use Web3\Web3;
use Web3\Contract;
use Web3\Providers\HttpProvider;
use Web3\RequestManagers\HttpRequestManager;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Exception;
class BlockchainService
{
private Web3 $web3;
private Contract $stakingContract;
private Contract $tokenContract;
private string $network;
public function __construct(string $network = 'ethereum')
{
$this->network = $network;
$config = config("blockchain.networks.{$network}");
// Web3 인스턴스 생성 (타임아웃 설정 포함)
$provider = new HttpProvider(
new HttpRequestManager(
$config['rpc_url'],
config('blockchain.sync.timeout')
)
);
$this->web3 = new Web3($provider);
// 스테이킹 컨트랙트 초기화
$stakingAbi = json_decode(
file_get_contents(config('blockchain.contracts.staking.abi_path')),
true
);
$this->stakingContract = new Contract(
$provider,
$stakingAbi
);
$this->stakingContract->at(
config('blockchain.contracts.staking.address')
);
// ERC20 토큰 컨트랙트 초기화
$tokenAbi = json_decode(
file_get_contents(config('blockchain.contracts.token.abi_path')),
true
);
$this->tokenContract = new Contract($provider, $tokenAbi);
$this->tokenContract->at(
config('blockchain.contracts.token.address')
);
}
/**
* 특정 주소의 스테이킹 잔액 조회
* 결과를 캐시해서 RPC 호출 최소화
*/
public function getStakedBalance(string $walletAddress): string
{
$cacheKey = "staked_balance:{$this->network}:{$walletAddress}";
return Cache::remember($cacheKey, 30, function () use ($walletAddress) {
$balance = null;
$error = null;
$this->stakingContract->call(
'balanceOf',
$walletAddress,
function ($err, $result) use (&$balance, &$error) {
if ($err !== null) {
$error = $err->getMessage();
return;
}
$balance = $result[0]->toString();
}
);
if ($error) {
Log::error("스테이킹 잔액 조회 실패: {$error}", [
'wallet' => $walletAddress,
'network' => $this->network
]);
throw new Exception("블록체인 조회 실패: {$error}");
}
return $balance ?? '0';
});
}
/**
* 미청구 보상 조회
*/
public function getPendingRewards(string $walletAddress): string
{
$cacheKey = "pending_rewards:{$this->network}:{$walletAddress}";
return Cache::remember($cacheKey, 15, function () use ($walletAddress) {
$rewards = null;
$this->stakingContract->call(
'earned',
$walletAddress,
function ($err, $result) use (&$rewards) {
if ($err === null) {
$rewards = $result[0]->toString();
}
}
);
return $rewards ?? '0';
});
}
/**
* 전체 TVL (Total Value Locked) 조회
*/
public function getTotalValueLocked(): string
{
return Cache::remember("tvl:{$this->network}", 60, function () {
$tvl = null;
$this->stakingContract->call(
'totalSupply',
function ($err, $result) use (&$tvl) {
if ($err === null) {
$tvl = $result[0]->toString();
}
}
);
return $tvl ?? '0';
});
}
/**
* 현재 APY 계산
* rewardRate와 totalSupply를 이용해서 계산
*/
public function calculateAPY(): float
{
return Cache::remember("apy:{$this->network}", 300, function () {
$rewardRate = null;
$totalSupply = null;
$this->stakingContract->call(
'rewardRate',
function ($err, $result) use (&$rewardRate) {
if ($err === null) {
$rewardRate = $result[0]->toString();
}
}
);
$this->stakingContract->call(
'totalSupply',
function ($err, $result) use (&$totalSupply) {
if ($err === null) {
$totalSupply = $result[0]->toString();
}
}
);
if (!$rewardRate || !$totalSupply || $totalSupply === '0') {
return 0.0;
}
// APY 계산: (rewardRate * 31536000 / totalSupply) * 100
// rewardRate는 초당 보상량 (wei 단위)
$annualRewards = bcmul($rewardRate, '31536000', 18);
$apy = bcdiv($annualRewards, $totalSupply, 18);
$apy = bcmul($apy, '100', 4);
return (float) $apy;
});
}
/**
* 트랜잭션 상태 확인
*/
public function getTransactionStatus(string $txHash): array
{
$receipt = null;
$this->web3->eth->getTransactionReceipt(
$txHash,
function ($err, $result) use (&$receipt) {
if ($err === null && $result !== null) {
$receipt = $result;
}
}
);
if (!$receipt) {
return ['status' => 'pending', 'block_number' => null];
}
return [
'status' => $receipt->status === '0x1' ? 'confirmed' : 'failed',
'block_number' => hexdec($receipt->blockNumber),
'gas_used' => hexdec($receipt->gasUsed),
];
}
/**
* Wei를 Ether 단위로 변환 (18 decimals 기준)
*/
public static function weiToEther(string $wei, int $decimals = 18): string
{
if ($wei === '0') return '0';
$divisor = bcpow('10', (string)$decimals, $decimals);
return bcdiv($wei, $divisor, 6);
}
/**
* 캐시 무효화 (트랜잭션 후 호출)
*/
public function invalidateCache(string $walletAddress): void
{
Cache::forget("staked_balance:{$this->network}:{$walletAddress}");
Cache::forget("pending_rewards:{$this->network}:{$walletAddress}");
Cache::forget("tvl:{$this->network}");
}
}
🔐 Web3 인증 서비스 (지갑 서명 검증)
Web3 로그인의 핵심은 "서명 검증"이야. 사용자가 개인키로 메시지에 서명하면, 서버에서 그 서명이 해당 주소에서 나온 게 맞는지 검증하는 방식이지.
app/Services/Web3AuthService.php<?php
namespace App\Services;
use Elliptic\EC;
use kornrunner\Keccak;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Cache;
class Web3AuthService
{
/**
* 로그인용 논스(nonce) 생성 및 저장
* 리플레이 어택 방지를 위해 1회용으로 사용
*/
public function generateNonce(string $walletAddress): string
{
$nonce = Str::random(32);
$message = "재능넷 스테이킹 대시보드 로그인\n\n주소: {$walletAddress}\n논스: {$nonce}";
// 5분간 캐시 (만료 시 재로그인 필요)
Cache::put(
"auth_nonce:{$walletAddress}",
['nonce' => $nonce, 'message' => $message],
300
);
return $message;
}
/**
* 서명 검증
* 사용자가 서명한 메시지를 복호화해서 주소 일치 여부 확인
*/
public function verifySignature(
string $walletAddress,
string $signature
): bool {
$cached = Cache::get("auth_nonce:{$walletAddress}");
if (!$cached) {
return false; // 논스 만료 또는 없음
}
$message = $cached['message'];
// 이더리움 서명 메시지 해시 생성
$prefixedMessage = "\x19Ethereum Signed Message:\n" . strlen($message) . $message;
$messageHash = Keccak::hash($prefixedMessage, 256);
// 서명에서 r, s, v 추출
$signature = ltrim($signature, '0x');
$r = substr($signature, 0, 64);
$s = substr($signature, 64, 64);
$v = hexdec(substr($signature, 128, 2));
// v 값 정규화 (27 또는 28 → 0 또는 1)
if ($v >= 27) {
$v -= 27;
}
// EC 복구로 공개키 추출
$ec = new EC('secp256k1');
$publicKey = $ec->recoverPubKey(
$messageHash,
['r' => $r, 's' => $s],
$v
);
// 공개키에서 이더리움 주소 도출
$publicKeyHex = $publicKey->encode('hex');
$publicKeyBytes = substr($publicKeyHex, 2); // 04 prefix 제거
$addressHash = Keccak::hash(hex2bin($publicKeyBytes), 256);
$recoveredAddress = '0x' . substr($addressHash, -40);
// 논스 삭제 (1회용)
Cache::forget("auth_nonce:{$walletAddress}");
// 주소 비교 (대소문자 무시)
return strtolower($recoveredAddress) === strtolower($walletAddress);
}
}
4. 스테이킹 서비스 레이어 구현 💼
🎯 StakingService - 비즈니스 로직의 심장
app/Services/StakingService.php<?php
namespace App\Services;
use App\Models\StakingPosition;
use App\Models\Transaction;
use App\Models\User;
use App\Jobs\SyncUserStaking;
use App\Events\StakingDeposited;
use App\Events\RewardClaimed;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Exception;
class StakingService
{
public function __construct(
private BlockchainService $blockchain,
private PriceOracleService $priceOracle
) {}
/**
* 스테이킹 포지션 생성 (트랜잭션 제출 후 DB 기록)
*/
public function recordStake(
User $user,
string $txHash,
string $amount,
string $network = 'ethereum'
): StakingPosition {
return DB::transaction(function () use ($user, $txHash, $amount, $network) {
// 트랜잭션 기록 (pending 상태)
$transaction = Transaction::create([
'user_id' => $user->id,
'tx_hash' => $txHash,
'wallet_address' => $user->wallet_address,
'contract_address' => config('blockchain.contracts.staking.address'),
'network' => $network,
'type' => 'stake',
'amount' => $amount,
'status' => 'pending',
]);
// 스테이킹 포지션 생성 또는 업데이트
$position = StakingPosition::updateOrCreate(
[
'user_id' => $user->id,
'wallet_address' => $user->wallet_address,
'contract_address' => config('blockchain.contracts.staking.address'),
'network' => $network,
'status' => 'active',
],
[
'staked_amount' => DB::raw("staked_amount + {$amount}"),
'staked_at' => now(),
'tx_hash' => $txHash,
]
);
// 비동기로 트랜잭션 확인 작업 디스패치
SyncUserStaking::dispatch($user->id, $txHash, $network)
->delay(now()->addSeconds(15)); // 15초 후 첫 확인
// 이벤트 발생
event(new StakingDeposited($user, $position, $amount));
Log::info("스테이킹 기록 완료", [
'user_id' => $user->id,
'tx_hash' => $txHash,
'amount' => $amount,
'network' => $network,
]);
return $position;
});
}
/**
* 사용자 대시보드 데이터 집계
* 온체인 + 오프체인 데이터를 합쳐서 반환
*/
public function getDashboardData(User $user): array
{
$walletAddress = $user->wallet_address;
$network = $user->preferred_network ?? 'ethereum';
try {
// 온체인 데이터 조회
$stakedBalance = $this->blockchain->getStakedBalance($walletAddress);
$pendingRewards = $this->blockchain->getPendingRewards($walletAddress);
$tvl = $this->blockchain->getTotalValueLocked();
$apy = $this->blockchain->calculateAPY();
// 토큰 가격 조회
$tokenPrice = $this->priceOracle->getTokenPrice();
// 오프체인 DB 데이터
$positions = StakingPosition::where('user_id', $user->id)
->where('status', 'active')
->get();
$recentTransactions = Transaction::where('user_id', $user->id)
->orderBy('created_at', 'desc')
->limit(10)
->get();
// 수익 계산
$stakedEther = BlockchainService::weiToEther($stakedBalance);
$rewardsEther = BlockchainService::weiToEther($pendingRewards);
$stakedUSD = bcmul($stakedEther, (string)$tokenPrice, 2);
$rewardsUSD = bcmul($rewardsEther, (string)$tokenPrice, 2);
return [
'wallet_address' => $walletAddress,
'network' => $network,
'staked_balance' => $stakedEther,
'staked_usd' => $stakedUSD,
'pending_rewards' => $rewardsEther,
'rewards_usd' => $rewardsUSD,
'total_value_locked' => BlockchainService::weiToEther($tvl),
'apy' => round($apy, 2),
'token_price' => $tokenPrice,
'positions' => $positions,
'recent_transactions' => $recentTransactions,
'last_updated' => now()->toISOString(),
];
} catch (Exception $e) {
Log::error("대시보드 데이터 조회 실패: " . $e->getMessage(), [
'user_id' => $user->id
]);
// 블록체인 오류 시 캐시된 DB 데이터로 폴백
return $this->getFallbackDashboardData($user);
}
}
/**
* 언스테이킹 처리
*/
public function recordUnstake(
User $user,
string $txHash,
string $amount,
int $unbondingDays = 14
): StakingPosition {
return DB::transaction(function () use ($user, $txHash, $amount, $unbondingDays) {
Transaction::create([
'user_id' => $user->id,
'tx_hash' => $txHash,
'wallet_address' => $user->wallet_address,
'contract_address' => config('blockchain.contracts.staking.address'),
'network' => $user->preferred_network ?? 'ethereum',
'type' => 'unstake',
'amount' => $amount,
'status' => 'pending',
]);
$position = StakingPosition::where('user_id', $user->id)
->where('status', 'active')
->firstOrFail();
$position->update([
'status' => 'unbonding',
'unbonding_ends_at' => now()->addDays($unbondingDays),
]);
SyncUserStaking::dispatch($user->id, $txHash)
->delay(now()->addSeconds(15));
return $position;
});
}
/**
* 블록체인 오류 시 DB 폴백 데이터
*/
private function getFallbackDashboardData(User $user): array
{
$position = StakingPosition::where('user_id', $user->id)
->where('status', 'active')
->first();
return [
'wallet_address' => $user->wallet_address,
'staked_balance' => $position?->staked_amount ?? '0',
'pending_rewards' => $position?->pending_rewards ?? '0',
'apy' => $position?->apy ?? 0,
'is_fallback' => true, // UI에서 "데이터 갱신 중" 표시용
'last_updated' => $position?->updated_at?->toISOString(),
];
}
}
⚡ Queue Job - 비동기 트랜잭션 확인
app/Jobs/SyncUserStaking.php<?php
namespace App\Jobs;
use App\Models\Transaction;
use App\Models\StakingPosition;
use App\Services\BlockchainService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class SyncUserStaking implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 10; // 최대 10번 재시도
public int $backoff = 30; // 30초 간격으로 재시도
public int $timeout = 60; // 60초 타임아웃
public function __construct(
private int $userId,
private string $txHash,
private string $network = 'ethereum'
) {}
public function handle(BlockchainService $blockchain): void
{
$transaction = Transaction::where('tx_hash', $this->txHash)->first();
if (!$transaction || $transaction->status !== 'pending') {
return; // 이미 처리됨
}
$status = $blockchain->getTransactionStatus($this->txHash);
if ($status['status'] === 'pending') {
// 아직 컨펌 안 됨 → 재시도
$this->release(30);
return;
}
// 트랜잭션 상태 업데이트
$transaction->update([
'status' => $status['status'],
'block_number' => $status['block_number'],
'gas_used' => $status['gas_used'],
'confirmed_at' => now(),
]);
if ($status['status'] === 'confirmed') {
// 캐시 무효화 → 다음 조회 시 최신 데이터 가져옴
$blockchain->invalidateCache($transaction->wallet_address);
// 스테이킹 포지션 APY 업데이트
$apy = $blockchain->calculateAPY();
StakingPosition::where('user_id', $this->userId)
->where('status', 'active')
->update(['apy' => $apy]);
Log::info("트랜잭션 컨펌 완료", [
'tx_hash' => $this->txHash,
'block_number' => $status['block_number'],
]);
}
}
public function failed(\Throwable $exception): void
{
Log::error("스테이킹 동기화 Job 실패", [
'tx_hash' => $this->txHash,
'error' => $exception->getMessage(),
]);
}
}
5. 컨트롤러 & API 라우팅 설계 🛣️
🎮 DashboardController
app/Http/Controllers/DashboardController.php<?php
namespace App\Http\Controllers;
use App\Services\StakingService;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
class DashboardController extends Controller
{
public function __construct(
private StakingService $stakingService
) {}
/**
* 메인 대시보드 뷰
*/
public function index(Request $request)
{
$user = $request->user();
// 초기 데이터는 SSR로 전달 (첫 로딩 속도 개선)
$dashboardData = $this->stakingService->getDashboardData($user);
return view('dashboard.index', compact('dashboardData', 'user'));
}
/**
* 대시보드 데이터 API (AJAX 폴링용)
*/
public function getData(Request $request): JsonResponse
{
$user = $request->user();
$data = $this->stakingService->getDashboardData($user);
return response()->json([
'success' => true,
'data' => $data,
]);
}
}
// ─────────────────────────────────────────────
// app/Http/Controllers/StakingController.php
class StakingController extends Controller
{
public function __construct(
private StakingService $stakingService
) {}
/**
* 스테이킹 기록 (프론트에서 트랜잭션 제출 후 호출)
*/
public function stake(StakeRequest $request): JsonResponse
{
$user = $request->user();
$position = $this->stakingService->recordStake(
user: $user,
txHash: $request->tx_hash,
amount: $request->amount,
network: $request->network ?? 'ethereum'
);
return response()->json([
'success' => true,
'message' => '스테이킹이 기록되었습니다. 블록 확인 중...',
'position' => $position,
], 201);
}
/**
* 언스테이킹 기록
*/
public function unstake(UnstakeRequest $request): JsonResponse
{
$user = $request->user();
$position = $this->stakingService->recordUnstake(
user: $user,
txHash: $request->tx_hash,
amount: $request->amount,
);
return response()->json([
'success' => true,
'message' => '언스테이킹 요청이 처리되었습니다.',
'position' => $position,
]);
}
/**
* 트랜잭션 히스토리
*/
public function history(Request $request): JsonResponse
{
$transactions = Transaction::where('user_id', $request->user()->id)
->orderBy('created_at', 'desc')
->paginate(20);
return response()->json($transactions);
}
}
🛣️ 라우팅 설계
routes/web.php & routes/api.php<?php
// routes/web.php - 웹 라우트
use App\Http\Controllers\DashboardController;
use App\Http\Controllers\WalletController;
Route::get('/', fn() => view('welcome'));
// 지갑 인증 라우트
Route::prefix('auth')->group(function () {
Route::post('/nonce', [WalletController::class, 'getNonce']);
Route::post('/verify', [WalletController::class, 'verify']);
Route::post('/logout', [WalletController::class, 'logout'])
->middleware('auth');
});
// 인증된 사용자만 접근 가능
Route::middleware(['auth', 'wallet.authenticated'])->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index'])
->name('dashboard');
});
// ─────────────────────────────────────────────
// routes/api.php - API 라우트
use App\Http\Controllers\Api\StakingApiController;
use App\Http\Controllers\Api\PriceApiController;
Route::middleware('auth:sanctum')->group(function () {
// 대시보드 데이터
Route::get('/dashboard', [DashboardController::class, 'getData']);
// 스테이킹 API
Route::prefix('staking')->group(function () {
Route::post('/stake', [StakingController::class, 'stake']);
Route::post('/unstake', [StakingController::class, 'unstake']);
Route::get('/history', [StakingController::class, 'history']);
Route::get('/positions', [StakingController::class, 'positions']);
});
// 가격 정보 (캐시 활용)
Route::get('/price/{token}', [PriceApiController::class, 'getPrice'])
->middleware('throttle:60,1'); // 분당 60회 제한
});
✅ Form Request 유효성 검사
app/Http/Requests/StakeRequest.php<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StakeRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
return [
'tx_hash' => [
'required',
'string',
'regex:/^0x[a-fA-F0-9]{64}$/', // 이더리움 트랜잭션 해시 형식
'unique:transactions,tx_hash', // 중복 제출 방지
],
'amount' => [
'required',
'string',
'regex:/^\d+$/', // 정수 문자열 (wei 단위)
function ($attribute, $value, $fail) {
// 최소 스테이킹 금액 검증 (0.01 ETH = 10^16 wei)
if (bccomp($value, '10000000000000000') < 0) {
$fail('최소 스테이킹 금액은 0.01 ETH입니다.');
}
},
],
'network' => 'nullable|in:ethereum,bsc,polygon',
];
}
public function messages(): array
{
return [
'tx_hash.regex' => '올바른 트랜잭션 해시 형식이 아닙니다.',
'tx_hash.unique' => '이미 처리된 트랜잭션입니다.',
'amount.regex' => '금액은 wei 단위 정수여야 합니다.',
];
}
}
6. 대시보드 UI 구현 - Blade + Alpine.js 🎨
Laravel의 Blade 템플릿에 Alpine.js를 결합하면 React/Vue 없이도 충분히 반응형 대시보드를 만들 수 있어!
Web3 연동은 ethers.js를 사용할게.
🖥️ 메인 대시보드 뷰
resources/views/dashboard/index.blade.php@extends('layouts.app')
@section('content')
<div x-data="stakingDashboard()" x-init="init()" class="dashboard-container">
{{-- 지갑 연결 상태 배너 --}}
<div class="wallet-banner" :class="walletConnected ? 'connected' : 'disconnected'">
<template x-if="!walletConnected">
<div>
<span>⚠️ 지갑이 연결되지 않았습니다</span>
<button @click="connectWallet()" class="btn-connect">
MetaMask 연결
</button>
</div>
</template>
<template x-if="walletConnected">
<div>
<span class="wallet-address" x-text="shortAddress(walletAddress)"></span>
<span class="network-badge" x-text="currentNetwork"></span>
</div>
</template>
</div>
{{-- 통계 카드 그리드 --}}
<div class="stats-grid">
{{-- 스테이킹 잔액 --}}
<div class="stat-card">
<div class="stat-icon">🔒</div>
<div class="stat-label">스테이킹 잔액</div>
<div class="stat-value" x-text="formatAmount(dashboardData.staked_balance)">
{{ number_format($dashboardData['staked_balance'], 4) }}
</div>
<div class="stat-usd" x-text="'≈ $' + formatUSD(dashboardData.staked_usd)">
≈ ${{ number_format($dashboardData['staked_usd'], 2) }}
</div>
</div>
{{-- 미청구 보상 --}}
<div class="stat-card highlight">
<div class="stat-icon">🎁</div>
<div class="stat-label">미청구 보상</div>
<div class="stat-value" x-text="formatAmount(dashboardData.pending_rewards)">
{{ number_format($dashboardData['pending_rewards'], 6) }}
</div>
<button @click="claimRewards()"
:disabled="isLoading || parseFloat(dashboardData.pending_rewards) === 0"
class="btn-claim">
<span x-show="!isLoading">보상 청구하기</span>
<span x-show="isLoading">처리 중...</span>
</button>
</div>
{{-- APY --}}
<div class="stat-card">
<div class="stat-icon">📈</div>
<div class="stat-label">현재 APY</div>
<div class="stat-value apy" x-text="dashboardData.apy + '%'">
{{ $dashboardData['apy'] }}%
</div>
<div class="stat-sub">연간 예상 수익률</div>
</div>
{{-- TVL --}}
<div class="stat-card">
<div class="stat-icon">🏦</div>
<div class="stat-label">총 예치금 (TVL)</div>
<div class="stat-value" x-text="formatAmount(dashboardData.total_value_locked)">
{{ number_format($dashboardData['total_value_locked'], 2) }}
</div>
<div class="stat-sub">전체 스테이킹 풀</div>
</div>
</div>
{{-- 스테이킹 액션 패널 --}}
<div class="action-panel">
<div class="tab-buttons">
<button @click="activeTab = 'stake'"
:class="activeTab === 'stake' ? 'active' : ''">
스테이킹
</button>
<button @click="activeTab = 'unstake'"
:class="activeTab === 'unstake' ? 'active' : ''">
언스테이킹
</button>
</div>
{{-- 스테이킹 폼 --}}
<div x-show="activeTab === 'stake'" class="action-form">
<label>스테이킹 수량</label>
<div class="input-group">
<input type="number" x-model="stakeAmount"
placeholder="0.0" step="0.001" min="0.01">
<button @click="setMaxAmount()" class="btn-max">MAX</button>
</div>
<div class="amount-info">
<span>지갑 잔액: <strong x-text="walletBalance + ' ETH'"></strong></span>
<span>예상 연간 수익: <strong x-text="estimatedReward + ' ETH'"></strong></span>
</div>
<button @click="executeStake()"
:disabled="!stakeAmount || isLoading || !walletConnected"
class="btn-primary">
스테이킹 실행
</button>
</div>
{{-- 언스테이킹 폼 --}}
<div x-show="activeTab === 'unstake'" class="action-form">
<label>언스테이킹 수량</label>
<input type="number" x-model="unstakeAmount"
placeholder="0.0" step="0.001">
<div class="unbonding-notice">
⏳ 언본딩 기간: <strong>14일</strong> (기간 중 보상 없음)
</div>
<button @click="executeUnstake()"
:disabled="!unstakeAmount || isLoading"
class="btn-secondary">
언스테이킹 요청
</button>
</div>
</div>
{{-- 트랜잭션 히스토리 --}}
<div class="history-section">
<h3>📋 트랜잭션 히스토리</h3>
@foreach($dashboardData['recent_transactions'] as $tx)
<div class="tx-item">
<span class="tx-type tx-type-{{ $tx->type }}">
{{ match($tx->type) {
'stake' => '🔒 스테이킹',
'unstake' => '🔓 언스테이킹',
'claim_reward' => '🎁 보상 청구',
default => $tx->type
} }}
</span>
<span class="tx-amount">{{ number_format($tx->amount, 4) }} ETH</span>
<span class="tx-status tx-status-{{ $tx->status }}">
{{ match($tx->status) {
'confirmed' => '✅ 완료',
'pending' => '⏳ 처리중',
'failed' => '❌ 실패',
default => $tx->status
} }}
</span>
<a href="{{ config('blockchain.networks.'.$tx->network.'.explorer') }}/tx/{{ $tx->tx_hash }}"
target="_blank" class="tx-link">
🔗 탐색기
</a>
</div>
@endforeach
</div>
</div>
@push('scripts')
<script src="https://cdn.jsdelivr.net/npm/ethers@5.7.2/dist/ethers.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<script>
function stakingDashboard() {
return {
walletConnected: false,
walletAddress: '',
walletBalance: '0',
currentNetwork: '',
dashboardData: @json($dashboardData),
activeTab: 'stake',
stakeAmount: '',
unstakeAmount: '',
isLoading: false,
provider: null,
signer: null,
stakingContract: null,
async init() {
// 폴링으로 데이터 갱신 (30초마다)
setInterval(() => this.refreshData(), 30000);
// MetaMask 이미 연결된 경우 자동 복원
if (window.ethereum && window.ethereum.selectedAddress) {
await this.connectWallet();
}
},
async connectWallet() {
if (!window.ethereum) {
alert('MetaMask를 설치해주세요!');
return;
}
try {
this.provider = new ethers.providers.Web3Provider(window.ethereum);
await this.provider.send('eth_requestAccounts', []);
this.signer = this.provider.getSigner();
this.walletAddress = await this.signer.getAddress();
const network = await this.provider.getNetwork();
this.currentNetwork = this.getNetworkName(network.chainId);
const balance = await this.provider.getBalance(this.walletAddress);
this.walletBalance = parseFloat(ethers.utils.formatEther(balance)).toFixed(4);
// 서버에 지갑 인증
await this.authenticateWallet();
this.walletConnected = true;
// 컨트랙트 초기화
const stakingAbi = @json(json_decode(file_get_contents(config('blockchain.contracts.staking.abi_path'))));
this.stakingContract = new ethers.Contract(
'{{ config("blockchain.contracts.staking.address") }}',
stakingAbi,
this.signer
);
} catch (error) {
console.error('지갑 연결 실패:', error);
alert('지갑 연결에 실패했습니다: ' + error.message);
}
},
async authenticateWallet() {
// 서버에서 논스 받기
const nonceRes = await fetch('/auth/nonce', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({ wallet_address: this.walletAddress })
});
const { message } = await nonceRes.json();
// 메시지 서명
const signature = await this.signer.signMessage(message);
// 서버에서 서명 검증
await fetch('/auth/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({
wallet_address: this.walletAddress,
signature: signature
})
});
},
async executeStake() {
if (!this.stakeAmount || parseFloat(this.stakeAmount) <= 0) return;
this.isLoading = true;
try {
const amountWei = ethers.utils.parseEther(this.stakeAmount.toString());
// 스마트 컨트랙트 stake() 호출
const tx = await this.stakingContract.stake(amountWei, {
value: amountWei // ETH 스테이킹의 경우
});
// 서버에 트랜잭션 기록
await fetch('/api/staking/stake', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({
tx_hash: tx.hash,
amount: amountWei.toString(),
network: this.currentNetwork
})
});
alert(`스테이킹 트랜잭션이 제출되었습니다!\nTX: ${tx.hash}`);
this.stakeAmount = '';
await this.refreshData();
} catch (error) {
console.error('스테이킹 실패:', error);
alert('스테이킹 실패: ' + (error.reason || error.message));
} finally {
this.isLoading = false;
}
},
async claimRewards() {
this.isLoading = true;
try {
const tx = await this.stakingContract.getReward();
await fetch('/api/staking/stake', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tx_hash: tx.hash, type: 'claim_reward' })
});
alert('보상 청구 트랜잭션이 제출되었습니다!');
await this.refreshData();
} catch (error) {
alert('보상 청구 실패: ' + error.message);
} finally {
this.isLoading = false;
}
},
async refreshData() {
try {
const res = await fetch('/api/dashboard');
const json = await res.json();
if (json.success) {
this.dashboardData = json.data;
}
} catch (e) {
console.warn('데이터 갱신 실패:', e);
}
},
get estimatedReward() {
if (!this.stakeAmount || !this.dashboardData.apy) return '0';
return (parseFloat(this.stakeAmount) * this.dashboardData.apy / 100).toFixed(4);
},
shortAddress(addr) {
if (!addr) return '';
return addr.slice(0, 6) + '...' + addr.slice(-4);
},
formatAmount(val) {
return parseFloat(val || 0).toFixed(4);
},
formatUSD(val) {
return parseFloat(val || 0).toLocaleString('ko-KR', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
},
setMaxAmount() {
this.stakeAmount = this.walletBalance;
},
getNetworkName(chainId) {
const networks = { 1: 'ethereum', 56: 'bsc', 137: 'polygon' };
return networks[chainId] || 'unknown';
}
};
}
</script>
@endpush
@endsection
7. 보안 강화 & 성능 최적화 🛡️⚡
스테이킹 대시보드는 금융 데이터를 다루기 때문에 보안이 절대적으로 중요해!
그리고 블록체인 RPC 호출은 느리기 때문에 캐싱 전략도 필수야.
🔒 보안 체크리스트
| 보안 항목 | 구현 방법 | 중요도 |
|---|---|---|
| CSRF 보호 | Laravel 기본 CSRF 토큰 (모든 POST 요청) | ⭐⭐⭐ |
| 지갑 서명 검증 | 서버사이드 ECDSA 서명 복구 | ⭐⭐⭐ |
| 논스 재사용 방지 | 1회용 논스 + 5분 만료 | ⭐⭐⭐ |
| Rate Limiting | throttle 미들웨어 + IP 기반 제한 | ⭐⭐⭐ |
| 입력 검증 | Form Request + 정규식 검증 | ⭐⭐⭐ |
| 트랜잭션 중복 방지 | tx_hash unique 제약 | ⭐⭐⭐ |
| SQL Injection | Eloquent ORM (파라미터 바인딩) | ⭐⭐⭐ |
| XSS 방지 | Blade {{ }} 자동 이스케이프 | ⭐⭐ |
| 환경변수 보호 | .env 파일 + 서버 환경변수 | ⭐⭐⭐ |
| HTTPS 강제 | HSTS 헤더 + SSL 인증서 | ⭐⭐⭐ |
🚀 성능 최적화 전략
캐싱 전략 요약// 캐시 TTL 전략 (데이터 변동성에 따라 차등 적용)
// 1. 자주 변하는 데이터 → 짧은 TTL
Cache::remember("pending_rewards:{$address}", 15, fn() => ...); // 15초
// 2. 보통 변하는 데이터 → 중간 TTL
Cache::remember("staked_balance:{$address}", 30, fn() => ...); // 30초
// 3. 느리게 변하는 데이터 → 긴 TTL
Cache::remember("apy:{$network}", 300, fn() => ...); // 5분
Cache::remember("tvl:{$network}", 60, fn() => ...); // 1분
// 4. 가격 데이터 → 외부 API 부하 고려
Cache::remember("token_price:{$token}", 60, fn() => ...); // 1분
// Redis 태그를 이용한 그룹 무효화
Cache::tags(["user:{$userId}", "staking"])->flush(); // 특정 유저 캐시 전체 삭제
app/Http/Middleware/RateLimitBlockchain.php
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Symfony\Component\HttpFoundation\Response;
class RateLimitBlockchain
{
public function handle(Request $request, \Closure $next): Response
{
$key = 'blockchain:' . ($request->user()?->id ?? $request->ip());
// 분당 30회 제한 (블록체인 RPC 과부하 방지)
if (RateLimiter::tooManyAttempts($key, 30)) {
$seconds = RateLimiter::availableIn($key);
return response()->json([
'error' => '요청이 너무 많습니다.',
'retry_after' => $seconds,
], 429);
}
RateLimiter::hit($key, 60);
return $next($request);
}
}
📊 데이터베이스 최적화
인덱스 전략// 자주 쓰이는 쿼리 패턴에 맞는 복합 인덱스 추가
Schema::table('staking_positions', function (Blueprint $table) {
// 사용자별 활성 포지션 조회 (가장 빈번한 쿼리)
$table->index(['user_id', 'status', 'network'], 'idx_user_status_network');
// 지갑 주소로 조회 (블록체인 동기화 시)
$table->index(['wallet_address', 'contract_address'], 'idx_wallet_contract');
});
Schema::table('transactions', function (Blueprint $table) {
// 트랜잭션 상태 모니터링 (Queue Job에서 사용)
$table->index(['status', 'created_at'], 'idx_status_created');
// 사용자 히스토리 조회
$table->index(['user_id', 'created_at'], 'idx_user_created');
});
// N+1 문제 방지 - Eager Loading 적용
$positions = StakingPosition::with(['user', 'transactions'])
->where('status', 'active')
->get();
// 청크 처리로 메모리 효율화 (대량 동기화 시)
StakingPosition::where('status', 'active')
->chunk(100, function ($positions) use ($blockchain) {
foreach ($positions as $position) {
// 각 포지션 동기화 처리
}
});
8. 스케줄러 & 모니터링 설정 📅
⏰ Laravel Scheduler 설정
app/Console/Kernel.php<?php
namespace App\Console;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
class Kernel extends ConsoleKernel
{
protected function schedule(Schedule $schedule): void
{
// 30초마다 스테이킹 데이터 동기화
$schedule->command('staking:sync')
->everyThirtySeconds()
->withoutOverlapping()
->runInBackground();
// 1분마다 토큰 가격 업데이트
$schedule->command('price:update')
->everyMinute()
->withoutOverlapping();
// 5분마다 APY 재계산
$schedule->command('staking:calculate-apy')
->everyFiveMinutes();
// 매시간 언본딩 완료 처리
$schedule->command('staking:process-unbonding')
->hourly();
// 매일 자정 보상 통계 집계
$schedule->command('staking:aggregate-rewards')
->dailyAt('00:05')
->onOneServer(); // 다중 서버 환경에서 중복 실행 방지
}
}
app/Console/Commands/SyncStakingData.php
<?php
namespace App\Console\Commands;
use App\Models\StakingPosition;
use App\Services\BlockchainService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class SyncStakingData extends Command
{
protected $signature = 'staking:sync {--network=ethereum}';
protected $description = '활성 스테이킹 포지션 데이터를 블록체인과 동기화';
public function handle(BlockchainService $blockchain): int
{
$network = $this->option('network');
$this->info("🔄 {$network} 스테이킹 데이터 동기화 시작...");
$positions = StakingPosition::where('status', 'active')
->where('network', $network)
->get();
$this->withProgressBar($positions, function ($position) use ($blockchain) {
try {
$stakedBalance = $blockchain->getStakedBalance($position->wallet_address);
$pendingRewards = $blockchain->getPendingRewards($position->wallet_address);
$apy = $blockchain->calculateAPY();
$position->update([
'staked_amount' => BlockchainService::weiToEther($stakedBalance),
'pending_rewards' => BlockchainService::weiToEther($pendingRewards),
'apy' => $apy,
]);
} catch (\Exception $e) {
Log::warning("포지션 동기화 실패: {$position->id}", [
'error' => $e->getMessage()
]);
}
});
$this->newLine();
$this->info("✅ 동기화 완료! 총 {$positions->count()}개 포지션 처리");
return Command::SUCCESS;
}
}
📊 모니터링 & 알림
운영 중 문제를 빠르게 감지하려면 모니터링이 필수야!
# Telescope 설치 (개발/스테이징 환경)
composer require laravel/telescope --dev
php artisan telescope:install
php artisan migrate
# Horizon 설치 (Queue 모니터링)
composer require laravel/horizon
php artisan horizon:install
php artisan migrate
# Horizon 실행 (Supervisor로 데몬 관리 권장)
php artisan horizon
9. 테스트 전략 - 믿을 수 있는 코드 만들기 🧪
금융 앱에서 테스트는 선택이 아니라 필수야! 특히 블록체인 연동 부분은 Mock을 잘 활용해야 해.
🔬 단위 테스트 (Unit Test)
tests/Unit/StakingServiceTest.php<?php
namespace Tests\Unit;
use App\Services\BlockchainService;
use App\Services\StakingService;
use App\Services\PriceOracleService;
use App\Models\User;
use App\Models\StakingPosition;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;
use Tests\TestCase;
class StakingServiceTest extends TestCase
{
use RefreshDatabase;
private StakingService $stakingService;
private $blockchainMock;
private $priceMock;
protected function setUp(): void
{
parent::setUp();
// 블록체인 서비스 Mock (실제 RPC 호출 없이 테스트)
$this->blockchainMock = Mockery::mock(BlockchainService::class);
$this->priceMock = Mockery::mock(PriceOracleService::class);
$this->stakingService = new StakingService(
$this->blockchainMock,
$this->priceMock
);
}
/** @test */
public function 스테이킹_기록이_정상적으로_생성된다(): void
{
$user = User::factory()->create([
'wallet_address' => '0x742d35Cc6634C0532925a3b8D4C9C6A5b8c9d0e1'
]);
$txHash = '0x' . str_repeat('a', 64);
$amount = '1000000000000000000'; // 1 ETH in wei
$position = $this->stakingService->recordStake(
$user, $txHash, $amount, 'ethereum'
);
$this->assertInstanceOf(StakingPosition::class, $position);
$this->assertDatabaseHas('transactions', [
'tx_hash' => $txHash,
'status' => 'pending',
'type' => 'stake',
]);
}
/** @test */
public function 대시보드_데이터가_올바르게_집계된다(): void
{
$user = User::factory()->create([
'wallet_address' => '0x742d35Cc6634C0532925a3b8D4C9C6A5b8c9d0e1'
]);
// Mock 설정
$this->blockchainMock
->shouldReceive('getStakedBalance')
->once()
->andReturn('1000000000000000000'); // 1 ETH
$this->blockchainMock
->shouldReceive('getPendingRewards')
->once()
->andReturn('50000000000000000'); // 0.05 ETH
$this->blockchainMock
->shouldReceive('getTotalValueLocked')
->once()
->andReturn('1000000000000000000000'); // 1000 ETH
$this->blockchainMock
->shouldReceive('calculateAPY')
->once()
->andReturn(12.5);
$this->priceMock
->shouldReceive('getTokenPrice')
->once()
->andReturn(2000.0); // $2000
$data = $this->stakingService->getDashboardData($user);
$this->assertEquals('1.000000', $data['staked_balance']);
$this->assertEquals('0.050000', $data['pending_rewards']);
$this->assertEquals(12.5, $data['apy']);
$this->assertEquals('2000.00', $data['staked_usd']);
}
/** @test */
public function 블록체인_오류시_폴백_데이터를_반환한다(): void
{
$user = User::factory()->create();
$this->blockchainMock
->shouldReceive('getStakedBalance')
->andThrow(new \Exception('RPC 연결 실패'));
$data = $this->stakingService->getDashboardData($user);
$this->assertTrue($data['is_fallback']);
}
}
🌐 기능 테스트 (Feature Test)
tests/Feature/StakingApiTest.php<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class StakingApiTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function 인증된_사용자는_스테이킹을_기록할_수_있다(): void
{
$user = User::factory()->create([
'wallet_address' => '0x742d35Cc6634C0532925a3b8D4C9C6A5b8c9d0e1'
]);
Sanctum::actingAs($user);
$response = $this->postJson('/api/staking/stake', [
'tx_hash' => '0x' . str_repeat('b', 64),
'amount' => '1000000000000000000',
'network' => 'ethereum',
]);
$response->assertStatus(201)
->assertJsonStructure([
'success', 'message', 'position'
]);
}
/** @test */
public function 비인증_사용자는_스테이킹_API에_접근할_수_없다(): void
{
$this->postJson('/api/staking/stake', [
'tx_hash' => '0x' . str_repeat('c', 64),
'amount' => '1000000000000000000',
])->assertStatus(401);
}
/** @test */
public function 중복_트랜잭션_해시는_거부된다(): void
{
$user = User::factory()->create();
Sanctum::actingAs($user);
$txHash = '0x' . str_repeat('d', 64);
// 첫 번째 요청
$this->postJson('/api/staking/stake', [
'tx_hash' => $txHash,
'amount' => '1000000000000000000',
])->assertStatus(201);
// 중복 요청
$this->postJson('/api/staking/stake', [
'tx_hash' => $txHash,
'amount' => '1000000000000000000',
])->assertStatus(422); // Validation Error
}
}
10. 실전 팁 & 자주 하는 실수들 💡
🚨 자주 하는 실수 TOP 5
→ PHP의 float는 정밀도 손실이 있어서 bcmath 함수 필수!
→ 사용자는 즉시 응답 받고, 결과는 폴링/WebSocket으로 전달
→ 서버에서 실제 트랜잭션 존재 여부도 확인 권장
→ invalidateCache() 메서드를 Job 완료 시 호출
🌟 추가 기능 아이디어
기본 대시보드를 완성했다면 이런 기능들로 확장해봐:
| 기능 | 설명 | 난이도 |
|---|---|---|
| 🔔 알림 시스템 | 보상 청구 가능 시 이메일/푸시 알림 | ⭐⭐ |
| 📊 수익 차트 | Chart.js로 시간별 보상 추이 시각화 | ⭐⭐ |
| 🔄 자동 복리 | 보상을 자동으로 재스테이킹 (Compound) | ⭐⭐⭐ |
| 🌐 멀티체인 | 이더리움 + BSC + Polygon 동시 지원 | ⭐⭐⭐ |
| 📱 PWA | 모바일 앱처럼 설치 가능한 웹앱 | ⭐⭐ |
| 🤝 레퍼럴 | 친구 초대 시 추가 보상 지급 | ⭐⭐⭐ |
| 📈 포트폴리오 | 여러 스테이킹 풀 통합 관리 | ⭐⭐⭐ |
🎉 마무리 - 우리가 만든 것들
와, 진짜 긴 여정이었지? 😄 오늘 우리가 함께 만든 것들을 정리해보자:
Laravel은 Web3 개발에도 충분히 강력한 프레임워크야. PHP의 성숙한 생태계와 블록체인의 혁신성을 결합하면 정말 멋진 제품을 만들 수 있어!
이 가이드를 바탕으로 자신만의 스테이킹 플랫폼을 만들어봐. 궁금한 점이 있으면 재능넷에서 Web3 개발 전문가들에게 도움을 구해봐도 좋아! 🚀
댓글 작성
이 글에 대한 여러분의 생각을 들려주세요
로그인이 필요합니다
댓글을 작성하려면 먼저 로그인해주세요.
댓글 0
지식인의 숲 - 지적 재산권 보호 고지
지적 재산권 보호 고지
- 저작권 및 소유권: 본 컨텐츠는 재능넷의 독점 AI 기술로 생성되었으며, 대한민국 저작권법 및 국제 저작권 협약에 의해 보호됩니다.
- AI 생성 컨텐츠의 법적 지위: 본 AI 생성 컨텐츠는 재능넷의 지적 창작물로 인정되며, 관련 법규에 따라 저작권 보호를 받습니다.
- 사용 제한: 재능넷의 명시적 서면 동의 없이 본 컨텐츠를 복제, 수정, 배포, 또는 상업적으로 활용하는 행위는 엄격히 금지됩니다.
- 데이터 수집 금지: 본 컨텐츠에 대한 무단 스크래핑, 크롤링, 및 자동화된 데이터 수집은 법적 제재의 대상이 됩니다.
- AI 학습 제한: 재능넷의 AI 생성 컨텐츠를 타 AI 모델 학습에 무단 사용하는 행위는 금지되며, 이는 지적 재산권 침해로 간주됩니다.
아직 댓글이 없습니다.