콘텐츠 대표 이미지 - 라라벨과 PostgreSQL로 블록체인 데이터 저장 구조 설계하기: 실전 아키텍처 가이드

라라벨과 PostgreSQL로 블록체인 데이터 저장 구조 설계하기: 실전 아키텍처 가이드

탈중앙화 데이터를 중앙화 DB에 효율적으로 저장하는 방법 🔗

👋 안녕하세요! 블록체인 프로젝트 하다 보면 진짜 고민되는 게 하나 있죠ㅋㅋㅋ 바로 "이 엄청난 양의 블록체인 데이터를 어떻게 저장하고 관리할 것인가?" 문제예요. 이더리움 노드 하나 돌리는 데만 수백 GB가 필요한데, 이걸 실시간으로 조회하고 분석하려면... 진짜 머리 아프죠 😅


그래서 오늘은 라라벨(Laravel)이라는 강력한 PHP 프레임워크와 PostgreSQL이라는 엔터프라이즈급 데이터베이스를 활용해서 블록체인 데이터를 효율적으로 저장하고 관리하는 구조를 설계하는 방법을 알아볼 거예요! 재능넷에서도 이런 기술 스택으로 프로젝트 의뢰가 종종 올라오는데, 제대로 이해하면 진짜 쓸모 많은 스킬이에요 💪

블록체인 네트워크 이더리움/폴리곤 실시간 트랜잭션 라라벨 API 데이터 처리 레이어 검증 및 변환 PostgreSQL 데이터 저장소 인덱싱 및 쿼리 블록 데이터 블록 번호 타임스탬프 해시값 트랜잭션 발신/수신 주소 금액 및 가스비 스마트컨트랙트 이벤트 로그 컨트랙트 이벤트 토픽 및 데이터 디코딩 정보

🤔 왜 블록체인 데이터를 별도로 저장해야 할까요?


블록체인은 탈중앙화되어 있고 불변성을 보장하는 멋진 기술이지만, 실제 서비스를 만들 때는 몇 가지 문제가 있어요:


1. 조회 속도 문제 🐌

블록체인 노드에 직접 쿼리하면 응답 속도가 느려요. 특히 복잡한 조건으로 데이터를 필터링하거나 집계할 때는 더더욱 느리죠ㅠㅠ 사용자가 "내 지갑의 최근 100개 거래 내역 보여줘"라고 하면... 노드가 전체 블록을 스캔해야 할 수도 있어요.


2. 비용 문제 💸

Infura나 Alchemy 같은 노드 서비스를 쓰면 API 호출 횟수에 따라 과금돼요. 사용자가 많아지면 비용이 기하급수적으로 증가하죠. 한 달에 수백만 원씩 나갈 수도 있어요ㅋㅋㅋ


3. 복잡한 쿼리 불가 🚫

블록체인 노드는 SQL 같은 복잡한 쿼리를 지원하지 않아요. "지난 30일간 가장 많이 거래된 NFT 컬렉션 TOP 10"을 뽑으려면? 직접 코드로 구현해야 하는데 진짜 비효율적이에요.


4. 데이터 분석 및 리포팅 📊

비즈니스 인사이트를 얻으려면 데이터를 집계하고 분석해야 하는데, 블록체인 노드만으로는 한계가 있어요. 대시보드 만들기도 어렵고요.


그래서 많은 블록체인 프로젝트들이 인덱싱 레이어를 구축해요. 블록체인 데이터를 실시간으로 가져와서 전통적인 데이터베이스에 저장하고, 빠르고 효율적으로 조회할 수 있게 만드는 거죠! 🚀

🛠️ 왜 라라벨과 PostgreSQL 조합인가?


기술 스택 선택은 프로젝트의 성패를 좌우할 수 있어요. 라라벨과 PostgreSQL 조합이 블록체인 데이터 저장에 특히 좋은 이유를 알아볼게요:


🎯 라라벨의 장점

• Eloquent ORM: 데이터베이스 작업을 객체 지향적으로 처리할 수 있어요. 복잡한 SQL 없이도 직관적인 코드 작성이 가능하죠.


• Queue 시스템: 블록체인 데이터를 비동기로 처리할 수 있어요. 수천 개의 트랜잭션을 백그라운드에서 처리하면서도 API 응답은 빠르게 유지할 수 있죠.


• 풍부한 생태계: 인증, 캐싱, 스케줄링 등 필요한 기능이 다 내장되어 있어요. 개발 속도가 엄청 빨라져요!


• RESTful API 구축: 라우팅과 미들웨어 시스템이 강력해서 API 서버 만들기 정말 쉬워요.

🐘 PostgreSQL의 장점

• JSONB 타입: 블록체인 데이터는 구조가 복잡한데, JSONB로 유연하게 저장하고 인덱싱까지 할 수 있어요. 진짜 꿀이에요ㅋㅋㅋ


• 고급 인덱싱: GIN, GiST 같은 특수 인덱스로 복잡한 쿼리도 빠르게 처리할 수 있어요.


• 트랜잭션 안정성: ACID 속성을 완벽하게 지원해서 데이터 무결성이 보장돼요. 블록체인 데이터는 정확성이 생명이니까요!


• 확장성: 파티셔닝, 복제, 샤딩 등으로 대용량 데이터도 문제없어요. 수억 건의 트랜잭션도 거뜬하죠.

📐 데이터베이스 스키마 설계 전략


블록체인 데이터를 저장할 때 가장 중요한 건 스키마 설계예요. 잘못 설계하면 나중에 마이그레이션 지옥을 경험하게 되죠ㅠㅠ 실전에서 검증된 설계 패턴을 소개할게요!


🔷 1. 블록(Blocks) 테이블


블록은 블록체인의 기본 단위죠. 각 블록에는 여러 트랜잭션이 포함되어 있어요:


Schema::create('blocks', function (Blueprint $table) {
    $table->id();
    $table->unsignedBigInteger('block_number')->unique();
    $table->string('block_hash', 66)->unique();
    $table->string('parent_hash', 66);
    $table->timestamp('timestamp');
    $table->string('miner', 42);
    $table->unsignedBigInteger('gas_used');
    $table->unsignedBigInteger('gas_limit');
    $table->decimal('base_fee_per_gas', 30, 0)->nullable();
    $table->integer('transaction_count');
    $table->jsonb('extra_data')->nullable();
    $table->timestamps();
    
    // 인덱스 설정
    $table->index('block_number');
    $table->index('timestamp');
    $table->index('miner');
});

설계 포인트:

• block_number는 unique 제약조건을 걸어서 중복 저장을 방지해요.

• block_hash는 66자(0x + 64자 hex)로 고정이에요.

• timestamp에 인덱스를 걸면 시간 범위 쿼리가 빨라져요.

• extra_data는 JSONB로 저장해서 나중에 추가 정보를 유연하게 넣을 수 있어요.


💳 2. 트랜잭션(Transactions) 테이블


트랜잭션은 가장 많이 조회되는 데이터예요. 설계를 잘해야 성능이 나와요:


Schema::create('transactions', function (Blueprint $table) {
    $table->id();
    $table->string('tx_hash', 66)->unique();
    $table->unsignedBigInteger('block_number');
    $table->integer('transaction_index');
    $table->string('from_address', 42)->index();
    $table->string('to_address', 42)->nullable()->index();
    $table->decimal('value', 36, 0);
    $table->unsignedBigInteger('gas');
    $table->decimal('gas_price', 30, 0);
    $table->unsignedBigInteger('nonce');
    $table->text('input_data')->nullable();
    $table->integer('status');
    $table->string('contract_address', 42)->nullable();
    $table->jsonb('receipt')->nullable();
    $table->timestamps();
    
    // 복합 인덱스
    $table->index(['from_address', 'block_number']);
    $table->index(['to_address', 'block_number']);
    $table->index(['block_number', 'transaction_index']);
    
    // 외래키
    $table->foreign('block_number')
          ->references('block_number')
          ->on('blocks')
          ->onDelete('cascade');
});

⚠️ 주의사항:

트랜잭션 테이블은 엄청나게 커질 수 있어요! 이더리움 메인넷은 하루에 100만 건 이상의 트랜잭션이 발생하거든요. 그래서 파티셔닝 전략이 필수예요. 블록 번호나 날짜 기준으로 파티션을 나누면 쿼리 성능이 크게 향상돼요.


📝 3. 이벤트 로그(Event Logs) 테이블


스마트 컨트랙트의 이벤트는 DApp 개발에서 핵심이에요. ERC-20 토큰 전송, NFT 민팅 등 모든 중요한 액션이 이벤트로 기록되거든요:


Schema::create('event_logs', function (Blueprint $table) {
    $table->id();
    $table->string('tx_hash', 66)->index();
    $table->unsignedBigInteger('block_number')->index();
    $table->integer('log_index');
    $table->string('contract_address', 42)->index();
    $table->string('event_signature', 66)->index();
    $table->jsonb('topics');
    $table->text('data');
    $table->jsonb('decoded_data')->nullable();
    $table->timestamps();
    
    // GIN 인덱스로 JSONB 검색 최적화
    $table->index('topics', null, 'gin');
    $table->index('decoded_data', null, 'gin');
    
    $table->foreign('tx_hash')
          ->references('tx_hash')
          ->on('transactions')
          ->onDelete('cascade');
});

💡 Pro Tip:

이벤트 로그의 topics와 decoded_data를 JSONB로 저장하고 GIN 인덱스를 걸면, "특정 주소로 전송된 모든 ERC-20 토큰 찾기" 같은 복잡한 쿼리도 빠르게 처리할 수 있어요! PostgreSQL의 JSONB 쿼리 기능은 진짜 강력해요 🔥


👤 4. 주소(Addresses) 테이블


주소별 통계나 잔액을 추적하려면 별도 테이블이 필요해요:


Schema::create('addresses', function (Blueprint $table) {
    $table->id();
    $table->string('address', 42)->unique();
    $table->decimal('balance', 36, 0)->default(0);
    $table->unsignedBigInteger('transaction_count')->default(0);
    $table->timestamp('first_seen_at')->nullable();
    $table->timestamp('last_seen_at')->nullable();
    $table->boolean('is_contract')->default(false);
    $table->jsonb('metadata')->nullable();
    $table->timestamps();
    
    $table->index('balance');
    $table->index('is_contract');
});
데이터베이스 관계도 Blocks block_number (PK) block_hash timestamp Transactions tx_hash (PK) block_number (FK) from/to address Event Logs id (PK) tx_hash (FK) decoded_data Addresses address (PK) balance transaction_count Token Transfers id (PK) tx_hash (FK) token_address

⚙️ 라라벨 모델과 관계 설정


데이터베이스 스키마를 만들었으니 이제 라라벨 Eloquent 모델을 구현할 차례예요! 모델을 잘 설계하면 코드가 엄청 깔끔해져요 ✨


📦 Block 모델


namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Block extends Model
{
    protected $fillable = [
        'block_number', 'block_hash', 'parent_hash',
        'timestamp', 'miner', 'gas_used', 'gas_limit',
        'base_fee_per_gas', 'transaction_count', 'extra_data'
    ];
    
    protected $casts = [
        'timestamp' => 'datetime',
        'extra_data' => 'array',
        'gas_used' => 'integer',
        'gas_limit' => 'integer',
        'transaction_count' => 'integer',
    ];
    
    // 관계 설정
    public function transactions()
    {
        return $this->hasMany(Transaction::class, 'block_number', 'block_number');
    }
    
    // 스코프: 최근 블록 조회
    public function scopeRecent($query, $limit = 10)
    {
        return $query->orderBy('block_number', 'desc')->limit($limit);
    }
    
    // 스코프: 날짜 범위 조회
    public function scopeBetweenDates($query, $start, $end)
    {
        return $query->whereBetween('timestamp', [$start, $end]);
    }
}

💸 Transaction 모델


namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Transaction extends Model
{
    protected $fillable = [
        'tx_hash', 'block_number', 'transaction_index',
        'from_address', 'to_address', 'value', 'gas',
        'gas_price', 'nonce', 'input_data', 'status',
        'contract_address', 'receipt'
    ];
    
    protected $casts = [
        'receipt' => 'array',
        'gas' => 'integer',
        'nonce' => 'integer',
        'status' => 'integer',
    ];
    
    // 관계 설정
    public function block()
    {
        return $this->belongsTo(Block::class, 'block_number', 'block_number');
    }
    
    public function eventLogs()
    {
        return $this->hasMany(EventLog::class, 'tx_hash', 'tx_hash');
    }
    
    public function fromAddress()
    {
        return $this->belongsTo(Address::class, 'from_address', 'address');
    }
    
    public function toAddress()
    {
        return $this->belongsTo(Address::class, 'to_address', 'address');
    }
    
    // 스코프: 특정 주소의 트랜잭션
    public function scopeForAddress($query, $address)
    {
        return $query->where('from_address', $address)
                    ->orWhere('to_address', $address);
    }
    
    // 스코프: 성공한 트랜잭션만
    public function scopeSuccessful($query)
    {
        return $query->where('status', 1);
    }
    
    // Wei를 Ether로 변환하는 헬퍼
    public function getValueInEtherAttribute()
    {
        return bcdiv($this->value, '1000000000000000000', 18);
    }
}

코딩 팁: 💡

블록체인 데이터는 숫자가 엄청 크기 때문에 PHP의 일반 정수형으로는 부족해요. bcmath 확장을 사용해서 큰 숫자를 문자열로 처리하는 게 안전해요. Wei 단위를 Ether로 변환할 때도 bcdiv() 함수를 쓰면 정확도를 유지할 수 있죠!


📋 EventLog 모델


namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class EventLog extends Model
{
    protected $fillable = [
        'tx_hash', 'block_number', 'log_index',
        'contract_address', 'event_signature',
        'topics', 'data', 'decoded_data'
    ];
    
    protected $casts = [
        'topics' => 'array',
        'decoded_data' => 'array',
        'log_index' => 'integer',
    ];
    
    public function transaction()
    {
        return $this->belongsTo(Transaction::class, 'tx_hash', 'tx_hash');
    }
    
    // 특정 이벤트 시그니처로 필터링
    public function scopeByEventSignature($query, $signature)
    {
        return $query->where('event_signature', $signature);
    }
    
    // ERC-20 Transfer 이벤트만 조회
    public function scopeErc20Transfers($query)
    {
        $transferSignature = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef';
        return $query->where('event_signature', $transferSignature);
    }
    
    // JSONB 필드에서 특정 값 검색
    public function scopeWhereDecodedData($query, $key, $value)
    {
        return $query->whereRaw("decoded_data->>'$key' = ?", [$value]);
    }
}

🔄 블록체인 데이터 동기화 전략


이제 실제로 블록체인에서 데이터를 가져와서 데이터베이스에 저장하는 로직을 구현해야 해요. 이 부분이 진짜 핵심이죠! 🎯


🌐 Web3 연결 설정


먼저 블록체인 노드와 통신하기 위해 Web3 라이브러리를 설치해야 해요. PHP에서는 web3.php를 많이 사용해요:


composer require sc0vu/web3.php

그리고 config 파일에 노드 URL을 설정해줘요:


// config/blockchain.php
return [
    'networks' => [
        'ethereum' => [
            'rpc_url' => env('ETH_RPC_URL', 'https://mainnet.infura.io/v3/YOUR_KEY'),
            'chain_id' => 1,
        ],
        'polygon' => [
            'rpc_url' => env('POLYGON_RPC_URL', 'https://polygon-rpc.com'),
            'chain_id' => 137,
        ],
    ],
    'sync' => [
        'batch_size' => 100,
        'delay_between_batches' => 1000, // ms
    ],
];

⚡ 블록 동기화 서비스 구현


블록을 효율적으로 동기화하는 서비스 클래스를 만들어볼게요:


namespace App\Services;

use App\Models\Block;
use App\Models\Transaction;
use App\Models\EventLog;
use Web3\Web3;
use Web3\Providers\HttpProvider;
use Web3\RequestManagers\HttpRequestManager;

class BlockchainSyncService
{
    protected $web3;
    protected $eth;
    
    public function __construct()
    {
        $rpcUrl = config('blockchain.networks.ethereum.rpc_url');
        $this->web3 = new Web3(new HttpProvider(
            new HttpRequestManager($rpcUrl, 10)
        ));
        $this->eth = $this->web3->eth;
    }
    
    public function syncBlock($blockNumber)
    {
        // 블록 정보 가져오기
        $this->eth->getBlockByNumber('0x' . dechex($blockNumber), true, 
            function ($err, $block) use ($blockNumber) {
                if ($err) {
                    \Log::error("Failed to fetch block $blockNumber: " . $err->getMessage());
                    return;
                }
                
                $this->saveBlock($block);
            }
        );
    }
    
    protected function saveBlock($blockData)
    {
        \DB::transaction(function () use ($blockData) {
            // 블록 저장
            $block = Block::updateOrCreate(
                ['block_number' => hexdec($blockData->number)],
                [
                    'block_hash' => $blockData->hash,
                    'parent_hash' => $blockData->parentHash,
                    'timestamp' => date('Y-m-d H:i:s', hexdec($blockData->timestamp)),
                    'miner' => $blockData->miner,
                    'gas_used' => hexdec($blockData->gasUsed),
                    'gas_limit' => hexdec($blockData->gasLimit),
                    'base_fee_per_gas' => isset($blockData->baseFeePerGas) 
                        ? hexdec($blockData->baseFeePerGas) : null,
                    'transaction_count' => count($blockData->transactions),
                ]
            );
            
            // 트랜잭션 저장
            foreach ($blockData->transactions as $tx) {
                $this->saveTransaction($tx, hexdec($blockData->number));
            }
        });
    }
    
    protected function saveTransaction($txData, $blockNumber)
    {
        $transaction = Transaction::updateOrCreate(
            ['tx_hash' => $txData->hash],
            [
                'block_number' => $blockNumber,
                'transaction_index' => hexdec($txData->transactionIndex),
                'from_address' => strtolower($txData->from),
                'to_address' => isset($txData->to) ? strtolower($txData->to) : null,
                'value' => hexdec($txData->value),
                'gas' => hexdec($txData->gas),
                'gas_price' => hexdec($txData->gasPrice),
                'nonce' => hexdec($txData->nonce),
                'input_data' => $txData->input,
            ]
        );
        
        // 트랜잭션 영수증 가져오기 (이벤트 로그 포함)
        $this->fetchTransactionReceipt($txData->hash);
    }
    
    protected function fetchTransactionReceipt($txHash)
    {
        $this->eth->getTransactionReceipt($txHash, function ($err, $receipt) use ($txHash) {
            if ($err || !$receipt) return;
            
            // 트랜잭션 상태 업데이트
            Transaction::where('tx_hash', $txHash)->update([
                'status' => hexdec($receipt->status),
                'contract_address' => $receipt->contractAddress ?? null,
                'receipt' => json_decode(json_encode($receipt), true),
            ]);
            
            // 이벤트 로그 저장
            if (isset($receipt->logs)) {
                foreach ($receipt->logs as $log) {
                    $this->saveEventLog($log, $txHash);
                }
            }
        });
    }
    
    protected function saveEventLog($logData, $txHash)
    {
        EventLog::create([
            'tx_hash' => $txHash,
            'block_number' => hexdec($logData->blockNumber),
            'log_index' => hexdec($logData->logIndex),
            'contract_address' => strtolower($logData->address),
            'event_signature' => $logData->topics[0] ?? null,
            'topics' => $logData->topics,
            'data' => $logData->data,
        ]);
    }
}

성능 최적화 포인트: ⚡

위 코드는 기본 구조예요. 실제 프로덕션에서는 다음 최적화가 필요해요:


1. 배치 처리: 한 번에 여러 블록을 가져와서 bulk insert로 저장하면 속도가 10배 이상 빨라져요.

2. 큐 시스템: 라라벨 Queue를 사용해서 비동기로 처리하면 API 응답 속도가 개선돼요.

3. 재시도 로직: 네트워크 오류나 노드 장애에 대비한 재시도 메커니즘이 필수예요.

4. 체크포인트: 마지막으로 동기화한 블록 번호를 저장해서 중단 시 이어서 할 수 있게 해야 해요.

🚀 큐를 활용한 비동기 동기화


블록체인 동기화는 시간이 오래 걸리는 작업이에요. 사용자가 API를 호출할 때마다 기다리게 할 수는 없죠! 라라벨의 Queue 시스템을 활용하면 백그라운드에서 처리할 수 있어요 🎉


📮 Job 클래스 생성


namespace App\Jobs;

use App\Services\BlockchainSyncService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class SyncBlockJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
    
    public $tries = 3;
    public $timeout = 120;
    
    protected $blockNumber;
    
    public function __construct($blockNumber)
    {
        $this->blockNumber = $blockNumber;
    }
    
    public function handle(BlockchainSyncService $syncService)
    {
        try {
            $syncService->syncBlock($this->blockNumber);
            \Log::info("Successfully synced block {$this->blockNumber}");
        } catch (\Exception $e) {
            \Log::error("Failed to sync block {$this->blockNumber}: " . $e->getMessage());
            throw $e; // 재시도를 위해 예외를 다시 던짐
        }
    }
    
    public function failed(\Throwable $exception)
    {
        // 실패 시 알림 보내기 등의 처리
        \Log::critical("Block {$this->blockNumber} sync failed after all retries");
    }
}

⏰ 스케줄러로 자동 동기화


새로운 블록이 생성될 때마다 자동으로 동기화하려면 스케줄러를 설정해야 해요:


// app/Console/Kernel.php
protected function schedule(Schedule $schedule)
{
    // 1분마다 새 블록 확인 및 동기화
    $schedule->call(function () {
        $syncService = app(BlockchainSyncService::class);
        $latestBlockInDb = Block::max('block_number') ?? 0;
        
        // 최신 블록 번호 가져오기
        $syncService->eth->blockNumber(function ($err, $latestBlock) use ($latestBlockInDb) {
            if ($err) return;
            
            $latestBlockNumber = hexdec($latestBlock);
            
            // 누락된 블록들을 큐에 추가
            for ($i = $latestBlockInDb + 1; $i <= $latestBlockNumber; $i++) {
                SyncBlockJob::dispatch($i)->onQueue('blockchain-sync');
            }
        });
    })->everyMinute()->name('sync-new-blocks');
    
    // 매일 자정에 데이터 정합성 체크
    $schedule->call(function () {
        // 누락된 블록이 있는지 확인하고 재동기화
        $this->checkAndResyncMissingBlocks();
    })->daily()->name('check-data-integrity');
}

큐 워커 실행하기:

큐 작업을 처리하려면 워커를 실행해야 해요. 프로덕션에서는 Supervisor로 관리하는 게 좋아요:


php artisan queue:work --queue=blockchain-sync --tries=3 --timeout=120

🔍 효율적인 쿼리 패턴과 인덱싱 전략


데이터가 쌓이면 쌓일수록 쿼리 성능이 중요해져요. 수백만 건의 트랜잭션에서 원하는 데이터를 빠르게 찾으려면 인덱싱과 쿼리 최적화가 필수죠! 💨


📊 PostgreSQL 인덱스 전략


1. B-Tree 인덱스 (기본)

대부분의 조회 쿼리에 사용돼요. 정렬된 데이터에 효율적이죠:


CREATE INDEX idx_transactions_block_number ON transactions(block_number);
CREATE INDEX idx_transactions_from_address ON transactions(from_address);
CREATE INDEX idx_transactions_to_address ON transactions(to_address);
CREATE INDEX idx_blocks_timestamp ON blocks(timestamp);

2. 복합 인덱스

여러 컬럼을 함께 조회할 때 성능이 크게 향상돼요:


-- 특정 주소의 특정 기간 트랜잭션 조회에 최적화
CREATE INDEX idx_tx_address_block 
ON transactions(from_address, block_number DESC);

-- 컨트랙트별 이벤트 로그 조회
CREATE INDEX idx_events_contract_block 
ON event_logs(contract_address, block_number DESC);

3. GIN 인덱스 (JSONB용)

JSONB 필드를 검색할 때는 GIN 인덱스가 필수예요:


CREATE INDEX idx_event_logs_decoded_data 
ON event_logs USING GIN (decoded_data);

CREATE INDEX idx_event_logs_topics 
ON event_logs USING GIN (topics);

-- 특정 JSON 경로에 대한 인덱스
CREATE INDEX idx_decoded_data_token_id 
ON event_logs ((decoded_data->>'tokenId'));

4. 파티셔닝

테이블이 너무 커지면 파티셔닝으로 나눠야 해요. 블록 번호나 날짜 기준이 일반적이죠:


-- 월별 파티셔닝 예시
CREATE TABLE transactions_2024_01 PARTITION OF transactions
FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');

CREATE TABLE transactions_2024_02 PARTITION OF transactions
FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');

⚡ 쿼리 최적화 팁


❌ 비효율적인 쿼리

// 모든 데이터를 가져온 후 필터링
$transactions = Transaction::all()
    ->where('from_address', $address)
    ->where('status', 1);

// N+1 문제
foreach ($transactions as $tx) {
    echo $tx->block->timestamp;
}

✅ 최적화된 쿼리

// DB 레벨에서 필터링
$transactions = Transaction::where('from_address', $address)
    ->where('status', 1)
    ->with('block') // Eager Loading
    ->select(['id', 'tx_hash', 'block_number', 'value'])
    ->limit(100)
    ->get();

실전 쿼리 예제들:


1. 특정 주소의 최근 거래 내역

$transactions = Transaction::forAddress($address)
    ->with('block:block_number,timestamp')
    ->orderBy('block_number', 'desc')
    ->paginate(20);

2. ERC-20 토큰 전송 이벤트 조회

$transfers = EventLog::erc20Transfers()
    ->where('contract_address', $tokenAddress)
    ->whereRaw("decoded_data->>'to' = ?", [$recipientAddress])
    ->with('transaction:tx_hash,block_number')
    ->get();

3. 일별 거래량 집계

$dailyVolume = Transaction::join('blocks', 'transactions.block_number', '=', 'blocks.block_number')
    ->selectRaw('DATE(blocks.timestamp) as date, COUNT(*) as tx_count, SUM(value) as total_value')
    ->whereBetween('blocks.timestamp', [$startDate, $endDate])
    ->groupBy('date')
    ->orderBy('date', 'desc')
    ->get();

🎨 API 엔드포인트 설계


이제 저장된 데이터를 외부에서 사용할 수 있도록 RESTful API를 만들어볼게요! 라라벨의 라우팅과 컨트롤러를 활용하면 정말 쉬워요 😊


🛣️ 라우트 정의


// routes/api.php
Route::prefix('v1')->group(function () {
    // 블록 관련
    Route::get('/blocks/latest', [BlockController::class, 'latest']);
    Route::get('/blocks/{number}', [BlockController::class, 'show']);
    Route::get('/blocks', [BlockController::class, 'index']);
    
    // 트랜잭션 관련
    Route::get('/transactions/{hash}', [TransactionController::class, 'show']);
    Route::get('/transactions', [TransactionController::class, 'index']);
    Route::get('/addresses/{address}/transactions', [TransactionController::class, 'byAddress']);
    
    // 이벤트 로그
    Route::get('/events', [EventLogController::class, 'index']);
    Route::get('/events/erc20-transfers', [EventLogController::class, 'erc20Transfers']);
    
    // 통계
    Route::get('/stats/daily', [StatsController::class, 'daily']);
    Route::get('/stats/addresses/{address}', [StatsController::class, 'addressStats']);
});

🎮 컨트롤러 구현


namespace App\Http\Controllers\Api;

use App\Models\Transaction;
use App\Http\Resources\TransactionResource;
use Illuminate\Http\Request;

class TransactionController extends Controller
{
    public function show($hash)
    {
        $transaction = Transaction::with(['block', 'eventLogs'])
            ->where('tx_hash', $hash)
            ->firstOrFail();
            
        return new TransactionResource($transaction);
    }
    
    public function byAddress(Request $request, $address)
    {
        $validated = $request->validate([
            'page' => 'integer|min:1',
            'per_page' => 'integer|min:1|max:100',
            'from_block' => 'integer|min:0',
            'to_block' => 'integer|min:0',
        ]);
        
        $query = Transaction::forAddress($address)
            ->with('block:block_number,timestamp');
            
        if (isset($validated['from_block'])) {
            $query->where('block_number', '>=', $validated['from_block']);
        }
        
        if (isset($validated['to_block'])) {
            $query->where('block_number', '<=', $validated['to_block']);
        }
        
        $transactions = $query->orderBy('block_number', 'desc')
            ->paginate($validated['per_page'] ?? 20);
            
        return TransactionResource::collection($transactions);
    }
    
    public function index(Request $request)
    {
        $validated = $request->validate([
            'block_number' => 'integer',
            'status' => 'in:0,1',
            'page' => 'integer|min:1',
        ]);
        
        $query = Transaction::with('block:block_number,timestamp');
        
        if (isset($validated['block_number'])) {
            $query->where('block_number', $validated['block_number']);
        }
        
        if (isset($validated['status'])) {
            $query->where('status', $validated['status']);
        }
        
        return TransactionResource::collection(
            $query->orderBy('block_number', 'desc')->paginate(20)
        );
    }
}

📦 API 리소스 클래스


API 응답 형식을 일관되게 유지하려면 Resource 클래스를 사용하는 게 좋아요:


namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\JsonResource;

class TransactionResource extends JsonResource
{
    public function toArray($request)
    {
        return [
            'hash' => $this->tx_hash,
            'block_number' => $this->block_number,
            'timestamp' => $this->block?->timestamp,
            'from' => $this->from_address,
            'to' => $this->to_address,
            'value' => [
                'wei' => $this->value,
                'ether' => $this->value_in_ether,
            ],
            'gas_used' => $this->gas,
            'gas_price' => $this->gas_price,
            'status' => $this->status === 1 ? 'success' : 'failed',
            'contract_address' => $this->contract_address,
            'event_logs_count' => $this->whenLoaded('eventLogs', function () {
                return $this->eventLogs->count();
            }),
            'event_logs' => EventLogResource::collection($this->whenLoaded('eventLogs')),
        ];
    }
}

API 응답 예시:

{
  "data": {
    "hash": "0xabc123...",
    "block_number": 18500000,
    "timestamp": "2024-01-15T10:30:00Z",
    "from": "0x742d35...",
    "to": "0x1f9840...",
    "value": {
      "wei": "1000000000000000000",
      "ether": "1.000000000000000000"
    },
    "gas_used": 21000,
    "gas_price": "50000000000",
    "status": "success"
  }
}

🔐 보안과 성능 최적화


프로덕션 환경에서는 보안과 성능이 정말 중요해요! 실전에서 꼭 적용해야 할 것들을 정리해볼게요 🛡️


🚦 Rate Limiting


API 남용을 방지하기 위해 요청 제한을 걸어야 해요:


// app/Http/Kernel.php
protected $middlewareGroups = [
    'api' => [
        \Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
        // ...
    ],
];

// routes/api.php
Route::middleware('throttle:60,1')->group(function () {
    // 분당 60개 요청으로 제한
});

// 더 세밀한 제어
Route::middleware('throttle:rate_limit,1')->group(function () {
    // 사용자별 다른 제한 적용 가능
});

💾 캐싱 전략


블록체인 데이터는 불변이기 때문에 캐싱하기 완벽해요! Redis를 활용하면 응답 속도가 엄청 빨라져요:


use Illuminate\Support\Facades\Cache;

class BlockController extends Controller
{
    public function show($number)
    {
        // 블록 데이터는 변하지 않으므로 영구 캐싱
        $block = Cache::rememberForever("block:$number", function () use ($number) {
            return Block::with('transactions')
                ->where('block_number', $number)
                ->firstOrFail();
        });
        
        return new BlockResource($block);
    }
    
    public function latest()
    {
        // 최신 블록은 1분간만 캐싱
        $latestBlocks = Cache::remember('blocks:latest', 60, function () {
            return Block::with('transactions')
                ->orderBy('block_number', 'desc')
                ->limit(10)
                ->get();
        });
        
        return BlockResource::collection($latestBlocks);
    }
}

🔍 쿼리 최적화 체크리스트


✅ Select 절 최소화: 필요한 컬럼만 조회하기

✅ Eager Loading: N+1 문제 방지

✅ 인덱스 활용: WHERE, JOIN, ORDER BY 절에 사용되는 컬럼에 인덱스

✅ 페이지네이션: 대량 데이터는 반드시 페이징 처리

✅ 쿼리 로그 분석: 느린 쿼리 찾아서 최적화

✅ DB 커넥션 풀: 적절한 커넥션 수 설정


📊 모니터링과 로깅


// 느린 쿼리 로깅
DB::listen(function ($query) {
    if ($query->time > 1000) { // 1초 이상
        Log::warning('Slow query detected', [
            'sql' => $query->sql,
            'bindings' => $query->bindings,
            'time' => $query->time
        ]);
    }
});

// 동기화 상태 모니터링
class SyncMonitor
{
    public function checkSyncStatus()
    {
        $latestBlockInDb = Block::max('block_number');
        $latestBlockOnChain = $this->getLatestBlockFromChain();
        
        $lag = $latestBlockOnChain - $latestBlockInDb;
        
        if ($lag > 100) {
            // 100블록 이상 뒤처지면 알림
            $this->sendAlert("Sync lag detected: $lag blocks behind");
        }
        
        return [
            'latest_block_db' => $latestBlockInDb,
            'latest_block_chain' => $latestBlockOnChain,
            'lag' => $lag,
            'status' => $lag < 10 ? 'healthy' : 'lagging'
        ];
    }
}

🎯 실전 활용 사례


이론만 알아서는 부족하죠! 실제로 어떻게 활용되는지 몇 가지 사례를 살펴볼게요. 재능넷에서도 이런 프로젝트들이 종종 의뢰되는데, 제대로 구현하면 정말 가치 있는 서비스가 돼요! 💎


🔎 1. 블록체인 익스플로러


Etherscan 같은 블록 탐색기를 만들 수 있어요:


주요 기능:

• 블록, 트랜잭션, 주소 검색

• 실시간 트랜잭션 모니터링

• 주소별 잔액 및 거래 내역

• 스마트 컨트랙트 상호작용 내역

• 가스비 통계 및 네트워크 상태


// 검색 기능 구현
class SearchController extends Controller
{
    public function search(Request $request)
    {
        $query = $request->input('q');
        
        // 블록 번호인지 확인
        if (is_numeric($query)) {
            $block = Block::where('block_number', $query)->first();
            if ($block) return redirect()->route('blocks.show', $query);
        }
        
        // 트랜잭션 해시인지 확인
        if (strlen($query) === 66 && str_starts_with($query, '0x')) {
            $tx = Transaction::where('tx_hash', $query)->first();
            if ($tx) return redirect()->route('transactions.show', $query);
        }
        
        // 주소인지 확인
        if (strlen($query) === 42 && str_starts_with($query, '0x')) {
            return redirect()->route('addresses.show', $query);
        }
        
        return back()->with('error', '검색 결과를 찾을 수 없습니다.');
    }
}

💰 2. DeFi 대시보드


탈중앙화 금융 프로토콜의 데이터를 시각화하는 대시보드:


추적 가능한 데이터:

• DEX 거래량 및 유동성

• 렌딩 프로토콜 TVL(Total Value Locked)

• 토큰 스왑 내역

• 수익률 농사(Yield Farming) 통계


// Uniswap Swap 이벤트 추적
class UniswapAnalyzer
{
    const SWAP_EVENT_SIGNATURE = '0xd78ad95fa46c994b6551d0da85fc275fe613ce37657fb8d5e3d130840159d822';
    
    public function getSwapVolume($poolAddress, $startDate, $endDate)
    {
        return EventLog::where('contract_address', $poolAddress)
            ->where('event_signature', self::SWAP_EVENT_SIGNATURE)
            ->whereHas('transaction.block', function ($query) use ($startDate, $endDate) {
                $query->whereBetween('timestamp', [$startDate, $endDate]);
            })
            ->get()
            ->sum(function ($log) {
                $decoded = $log->decoded_data;
                return abs($decoded['amount0'] ?? 0) + abs($decoded['amount1'] ?? 0);
            });
    }
}

🎨 3. NFT 마켓플레이스 분석


NFT 거래 데이터를 분석해서 인사이트를 제공하는 서비스:


분석 항목:

• 컬렉션별 거래량 및 평균 가격

• 희귀도(Rarity) 분석

• 고래(Whale) 지갑 추적

• 민팅 및 전송 이력


class NftAnalytics
{
    public function getCollectionStats($contractAddress, $days = 30)
    {
        $startDate = now()->subDays($days);
        
        // ERC-721 Transfer 이벤트
        $transfers = EventLog::where('contract_address', $contractAddress)
            ->where('event_signature', '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef')
            ->whereHas('transaction.block', function ($query) use ($startDate) {
                $query->where('timestamp', '>=', $startDate);
            })
            ->with('transaction')
            ->get();
        
        return [
            'total_transfers' => $transfers->count(),
            'unique_holders' => $transfers->pluck('decoded_data.to')->unique()->count(),
            'total_volume' => $this->calculateVolume($transfers),
            'average_price' => $this->calculateAveragePrice($transfers),
            'floor_price' => $this->getFloorPrice($contractAddress),
        ];
    }
}

🚨 4. 실시간 알림 시스템


특정 조건이 충족되면 사용자에게 알림을 보내는 시스템:


class AlertService
{
    public function checkAlerts()
    {
        $alerts = Alert::where('is_active', true)->get();
        
        foreach ($alerts as $alert) {
            switch ($alert->type) {
                case 'large_transaction':
                    $this->checkLargeTransactions($alert);
                    break;
                case 'whale_movement':
                    $this->checkWhaleMovements($alert);
                    break;
                case 'token_transfer':
                    $this->checkTokenTransfers($alert);
                    break;
            }
        }
    }
    
    protected function checkLargeTransactions($alert)
    {
        $threshold = $alert->parameters['threshold'];
        
        $largeTxs = Transaction::where('value', '>', $threshold)
            ->where('created_at', '>', now()->subMinutes(5))
            ->get();
        
        foreach ($largeTxs as $tx) {
            $this->sendNotification($alert->user, [
                'title' => '대량 거래 감지',
                'message' => "금액: {$tx->value_in_ether} ETH",
                'tx_hash' => $tx->tx_hash,
            ]);
        }
    }
}

🚀 확장성과 미래 대비


프로젝트가 성장하면서 데이터가 폭발적으로 증가할 수 있어요. 미리 확장성을 고려한 설계가 중요하죠! 🌟


📈 수평적 확장(Horizontal Scaling)


1. 데이터베이스 샤딩

체인별로 데이터베이스를 분리하거나, 블록 범위별로 샤딩할 수 있어요:


// config/database.php
'connections' => [
    'ethereum_shard_1' => [
        'driver' => 'pgsql',
        'host' => env('DB_ETH_SHARD1_HOST'),
        // 블록 0 ~ 10,000,000
    ],
    'ethereum_shard_2' => [
        'driver' => 'pgsql',
        'host' => env('DB_ETH_SHARD2_HOST'),
        // 블록 10,000,001 ~ 20,000,000
    ],
];

2. 읽기 전용 복제본

쓰기는 마스터 DB에, 읽기는 복제본에서 처리하면 부하를 분산할 수 있어요:


'read' => [
    ['host' => '192.168.1.1'],
    ['host' => '192.168.1.2'],
],
'write' => [
    ['host' => '192.168.1.3'],
],

3. 마이크로서비스 아키텍처

기능별로 서비스를 분리하면 독립적으로 확장할 수 있어요:

• 블록 동기화 서비스

• 이벤트 처리 서비스

• API 서비스

• 분석 서비스


🔮 멀티체인 지원


여러 블록체인을 동시에 지원하려면 추상화 레이어가 필요해요:


interface BlockchainInterface
{
    public function getBlock($number);
    public function getTransaction($hash);
    public function subscribeToNewBlocks(callable $callback);
}

class EthereumBlockchain implements BlockchainInterface
{
    // 이더리움 구현
}

class PolygonBlockchain implements BlockchainInterface
{
    // 폴리곤 구현
}

class BlockchainFactory
{
    public static function create($chain)
    {
        return match($chain) {
            'ethereum' => new EthereumBlockchain(),
            'polygon' => new PolygonBlockchain(),
            'bsc' => new BscBlockchain(),
            default => throw new \Exception("Unsupported chain: $chain"),
        };
    }
}

💡 실전 팁과 주의사항


마지막으로 실제 프로젝트에서 겪을 수 있는 문제들과 해결 방법을 공유할게요! 이거 모르면 진짜 고생할 수 있어요ㅋㅋㅋ


⚠️ 체인 재구성(Reorg) 처리

블록체인은 가끔 재구성이 일어나요. 최신 몇 개 블록이 무효화되고 다른 블록으로 대체될 수 있죠. 이를 처리하지 않으면 데이터 불일치가 발생해요!


class ReorgHandler
{
    public function handleReorg($fromBlock)
    {
        DB::transaction(function () use ($fromBlock) {
            // 영향받은 블록과 관련 데이터 삭제
            EventLog::whereHas('transaction', function ($query) use ($fromBlock) {
                $query->where('block_number', '>=', $fromBlock);
            })->delete();
            
            Transaction::where('block_number', '>=', $fromBlock)->delete();
            Block::where('block_number', '>=', $fromBlock)->delete();
            
            // 재동기화
            for ($i = $fromBlock; $i <= $this->getLatestBlock(); $i++) {
                SyncBlockJob::dispatch($i);
            }
        });
    }
}

💡 가스비 추정 및 최적화

블록체인 데이터를 분석하면 가스비 패턴을 파악할 수 있어요:


class GasAnalyzer
{
    public function getOptimalGasPrice()
    {
        // 최근 100개 블록의 가스비 분석
        $recentBlocks = Block::orderBy('block_number', 'desc')
            ->limit(100)
            ->with('transactions')
            ->get();
        
        $gasPrices = $recentBlocks->flatMap(function ($block) {
            return $block->transactions->pluck('gas_price');
        })->sort()->values();
        
        return [
            'slow' => $gasPrices->percentile(25),
            'average' => $gasPrices->percentile(50),
            'fast' => $gasPrices->percentile(75),
        ];
    }
}

✅ 데이터 검증

저장된 데이터가 정확한지 주기적으로 검증해야 해요:


class DataValidator
{
    public function validateBlock($blockNumber)
    {
        $dbBlock = Block::where('block_number', $blockNumber)->first();
        $chainBlock = $this->fetchBlockFromChain($blockNumber);
        
        $issues = [];
        
        if ($dbBlock->block_hash !== $chainBlock->hash) {
            $issues[] = 'Block hash mismatch';
        }
        
        if ($dbBlock->transaction_count !== count($chainBlock->transactions)) {
            $issues[] = 'Transaction count mismatch';
        }
        
        if (!empty($issues)) {
            Log::error("Block $blockNumber validation failed", $issues);
            $this->resyncBlock($blockNumber);
        }
        
        return empty($issues);
    }
}

🎓 마무리하며


와... 여기까지 정말 긴 여정이었네요! 😅 라라벨과 PostgreSQL로 블록체인 데이터 저장 구조를 설계하는 방법을 아주 자세히 알아봤어요. 정리하자면:


핵심 포인트 요약:


1️⃣ 스키마 설계: 블록, 트랜잭션, 이벤트 로그를 효율적으로 저장할 수 있는 구조 설계


2️⃣ 인덱싱 전략: B-Tree, GIN 인덱스와 파티셔닝으로 대용량 데이터 처리


3️⃣ 동기화 시스템: Queue와 스케줄러를 활용한 안정적인 데이터 동기화


4️⃣ API 설계: RESTful API로 데이터를 외부에 제공


5️⃣ 성능 최적화: 캐싱, 쿼리 최적화, Rate Limiting으로 안정적인 서비스 구축


6️⃣ 확장성: 샤딩, 복제, 마이크로서비스로 미래 대비


블록체인 프로젝트를 시작하려는 분들에게 이 글이 도움이 되었으면 좋겠어요! 재능넷에서도 이런 기술 스택을 활용한 프로젝트들이 많이 올라오는데, 제대로 구현하면 정말 가치 있는 서비스를 만들 수 있답니다 💪


처음에는 복잡해 보이지만, 하나씩 차근차근 구현하다 보면 어느새 완성도 높은 블록체인 인덱서를 만들 수 있을 거예요. 특히 PostgreSQL의 JSONB 기능과 라라벨의 Eloquent ORM을 잘 활용하면 개발 생산성이 엄청 높아지죠!


혹시 구현하다가 막히는 부분이 있으면 라라벨 공식 문서와 PostgreSQL 문서를 참고하시고, 커뮤니티에서 도움을 구하는 것도 좋은 방법이에요. 블록체인 개발은 계속 발전하고 있으니 최신 트렌드도 놓치지 마세요! 🚀


그럼 여러분의 블록체인 프로젝트가 성공하길 바라며, 이만 글을 마칠게요. 화이팅! 🔥✨

🎯 이 글이 도움이 되셨나요?

재능넷에서 더 많은 블록체인 개발 지식과 프로젝트를 만나보세요!

댓글 작성

이 글에 대한 여러분의 생각을 들려주세요

댓글 0
댓글을 불러오는 중입니다.

아직 댓글이 없습니다.