Ver3.5 라라벨과 오픈시(OpenSea) API를 이용한NFT 자동 동기화

라라벨과 오픈시(OpenSea) API를 이용한
NFT 자동 동기화
ㅋㅋㅋ 그래 오늘은 라라벨(Laravel)과 오픈시(OpenSea) API를 활용해서 NFT 데이터를 자동으로 동기화하는 방법을 완전 뜯어볼 거야. 단순히 API 호출하는 수준이 아니라, 실제 프로덕션에서 쓸 수 있는 수준의 아키텍처까지 다룰 거니까 집중해줘! 🔥
NFT 시장이 한때 엄청 뜨거웠고, 지금도 Web3 생태계에서 NFT는 여전히 핵심 자산이야. 오픈시는 전 세계 최대 NFT 마켓플레이스로, 이더리움·폴리곤·솔라나 등 다양한 체인의 NFT 데이터를 API로 제공하고 있어. 이걸 라라벨로 연동하면? 내 서비스에서 실시간으로 NFT 정보를 보여주거나, 사용자 지갑의 NFT를 자동으로 불러오는 기능을 구현할 수 있지!
🌊 1. 오픈시(OpenSea) API, 뭘 줄 수 있는 거야?
오픈시 API를 쓰기 전에 일단 뭘 제공하는지부터 파악해야 해. 모르고 쓰면 나중에 "어? 이게 왜 안 되지?" 하는 상황이 생기거든 ㅋㅋㅋ
📌 오픈시 API v2 핵심 엔드포인트
오픈시는 현재 API v2를 공식 지원하고 있어. v1은 deprecated 됐으니까 반드시 v2 기준으로 개발해야 해! 주요 엔드포인트를 정리해볼게:
| 엔드포인트 | 설명 | 주요 파라미터 |
|---|---|---|
GET /api/v2/chain/{chain}/contract/{address}/nfts |
특정 컨트랙트의 NFT 목록 조회 | chain, address, limit, next |
|
특정 NFT 단건 조회 | chain, address, identifier |
GET /api/v2/chain/{chain}/account/{address}/nfts |
특정 지갑 주소의 NFT 목록 | chain, address, collection, limit |
GET /api/v2/collections/{collection_slug} |
컬렉션 상세 정보 | collection_slug |
|
컬렉션 최저가 리스팅 | collection_slug, limit |
GET /api/v2/events/collection/{collection_slug} |
컬렉션 이벤트(거래, 전송 등) | event_type, after, before |
https://docs.opensea.io/에서 계정 만들고 API 키 발급받아야 해. 무료 플랜은 분당 4 요청(4 RPM) 제한이 있으니까 자동 동기화 구현할 때 이 제한을 꼭 고려해야 함!
🔗 지원 블록체인 목록
오픈시 API v2에서 지원하는 체인 식별자를 알아야 API 호출할 때 올바른 값을 넣을 수 있어:
| 체인 이름 | API 식별자 | 특징 |
|---|---|---|
| 이더리움 메인넷 | ethereum |
가장 많은 NFT 컬렉션 |
| 폴리곤 | matic |
가스비 저렴, 게임 NFT 많음 |
| 솔라나 | solana |
빠른 처리속도 |
| 아비트럼 | arbitrum |
이더리움 L2 |
| 옵티미즘 | optimism |
이더리움 L2 |
| 베이스 | base |
Coinbase L2 |
| 아발란체 | avalanche |
C-Chain 기반 |
next 토큰을 받아서 다음 페이지를 요청하는 방식이야. 자동 동기화 구현할 때 이 부분을 제대로 처리 안 하면 데이터가 누락될 수 있어!
⚙️ 2. 라라벨 환경 세팅 - 기초부터 탄탄하게!
자 이제 본격적으로 라라벨 프로젝트를 세팅해보자. 처음부터 제대로 구조를 잡아야 나중에 유지보수할 때 안 힘들어 ㅋㅋㅋ
📦 필요한 패키지 설치
# 라라벨 프로젝트 생성 (이미 있으면 스킵)
composer create-project laravel/laravel nft-sync-app
# HTTP 클라이언트는 라라벨 내장 Guzzle 사용 (별도 설치 불필요)
# 하지만 명시적으로 확인
composer require guzzlehttp/guzzle
# 큐 드라이버로 Redis 사용 시
composer require predis/predis
# 환경변수 관리 편의를 위해
composer require vlucas/phpdotenv
🔧 .env 설정
# .env 파일에 추가
OPENSEA_API_KEY=your_opensea_api_key_here
OPENSEA_BASE_URL=https://api.opensea.io
OPENSEA_API_VERSION=v2
# 큐 설정 (자동 동기화에 필수!)
QUEUE_CONNECTION=redis
# Redis 설정
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
# 캐시 설정
CACHE_DRIVER=redis
# NFT 동기화 관련 설정
NFT_SYNC_INTERVAL=60 # 분 단위
NFT_SYNC_BATCH_SIZE=50 # 한 번에 처리할 NFT 수
🗄️ 데이터베이스 마이그레이션
NFT 데이터를 저장할 테이블 구조를 설계해야 해. 오픈시에서 받아오는 데이터 구조를 잘 분석해서 적절한 컬럼을 만들어줘야 함:
<?php
// database/migrations/xxxx_create_nfts_table.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('nfts', function (Blueprint $table) {
$table->id();
$table->string('identifier'); // 토큰 ID
$table->string('collection'); // 컬렉션 슬러그
$table->string('contract'); // 컨트랙트 주소
$table->string('token_standard'); // ERC721, ERC1155 등
$table->string('name')->nullable(); // NFT 이름
$table->text('description')->nullable();
$table->string('image_url')->nullable();
$table->string('display_image_url')->nullable();
$table->string('display_animation_url')->nullable();
$table->string('metadata_url')->nullable();
$table->string('opensea_url')->nullable();
$table->string('chain'); // 블록체인 체인
$table->boolean('is_suspicious')->default(false);
$table->boolean('is_disabled')->default(false);
$table->json('traits')->nullable(); // NFT 특성 데이터
$table->json('owners')->nullable(); // 소유자 정보
$table->timestamp('last_synced_at')->nullable();
$table->timestamps();
// 인덱스 설정 (조회 성능 최적화)
$table->unique(['identifier', 'contract', 'chain']);
$table->index('collection');
$table->index('chain');
$table->index('last_synced_at');
});
}
public function down(): void
{
Schema::dropIfExists('nfts');
}
};
<?php
// database/migrations/xxxx_create_nft_collections_table.php
Schema::create('nft_collections', function (Blueprint $table) {
$table->id();
$table->string('collection_slug')->unique(); // 오픈시 컬렉션 슬러그
$table->string('name');
$table->text('description')->nullable();
$table->string('image_url')->nullable();
$table->string('banner_image_url')->nullable();
$table->string('owner')->nullable();
$table->string('safelist_status')->nullable();
$table->string('category')->nullable();
$table->boolean('is_disabled')->default(false);
$table->boolean('is_nsfw')->default(false);
$table->string('opensea_url')->nullable();
$table->string('project_url')->nullable();
$table->string('wiki_url')->nullable();
$table->string('discord_url')->nullable();
$table->string('telegram_url')->nullable();
$table->string('twitter_username')->nullable();
$table->string('instagram_username')->nullable();
$table->decimal('total_supply', 20, 0)->nullable();
$table->json('contracts')->nullable(); // 연결된 컨트랙트들
$table->json('payment_tokens')->nullable(); // 결제 토큰 정보
$table->timestamp('last_synced_at')->nullable();
$table->timestamps();
});
<?php
// database/migrations/xxxx_create_nft_sync_logs_table.php
// 동기화 이력 관리 테이블
Schema::create('nft_sync_logs', function (Blueprint $table) {
$table->id();
$table->string('sync_type'); // 'collection', 'wallet', 'single'
$table->string('target'); // 동기화 대상 (슬러그, 지갑주소 등)
$table->string('chain')->nullable();
$table->enum('status', ['pending', 'running', 'completed', 'failed']);
$table->integer('total_items')->default(0);
$table->integer('synced_items')->default(0);
$table->integer('failed_items')->default(0);
$table->text('error_message')->nullable();
$table->json('metadata')->nullable(); // 추가 메타데이터
$table->timestamp('started_at')->nullable();
$table->timestamp('completed_at')->nullable();
$table->timestamps();
});
traits(특성) 데이터는 컬렉션마다 구조가 달라서 JSON으로 저장하는 게 훨씬 유연해. 라라벨 모델에서 $casts로 자동 변환 처리하면 편리하게 쓸 수 있어!
🛠️ 3. OpenSea Service 클래스 구현 - 핵심 중의 핵심!
이제 진짜 핵심이야! 오픈시 API와 통신하는 서비스 클래스를 만들어볼 거야. 이 클래스가 잘 만들어져야 나머지가 다 편해지거든 ㅋㅋㅋ
📁 디렉토리 구조
app/├── Services/│ ├── OpenSea/│ │ ├── OpenSeaClient.php ← API 통신 담당│ │ ├── OpenSeaService.php ← 비즈니스 로직│ │ └── OpenSeaRateLimiter.php ← 요청 제한 관리├── Jobs/│ ├── SyncNftCollection.php ← 컬렉션 동기화 잡│ ├── SyncWalletNfts.php ← 지갑 NFT 동기화 잡│ └── SyncSingleNft.php ← 단건 동기화 잡├── Models/│ ├── Nft.php│ ├── NftCollection.php│ └── NftSyncLog.php└── Console/Commands/ └── NftSyncCommand.php ← 아티즌 커맨드
🔌 OpenSeaClient.php - API 통신 클래스
<?php
namespace App\Services\OpenSea;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Http\Client\RequestException;
class OpenSeaClient
{
private string $apiKey;
private string $baseUrl;
private string $apiVersion;
private int $maxRetries = 3;
private int $retryDelay = 1000; // 밀리초
public function __construct()
{
$this->apiKey = config('services.opensea.api_key');
$this->baseUrl = config('services.opensea.base_url', 'https://api.opensea.io');
$this->apiVersion = config('services.opensea.api_version', 'v2');
}
/**
* GET 요청 기본 메서드
*/
public function get(string $endpoint, array $params = []): array
{
$url = "{$this->baseUrl}/api/{$this->apiVersion}/{$endpoint}";
$attempt = 0;
while ($attempt < $this->maxRetries) {
try {
$response = Http::withHeaders([
'X-API-KEY' => $this->apiKey,
'Accept' => 'application/json',
'Content-Type' => 'application/json',
])
->timeout(30)
->get($url, $params);
// 429 Too Many Requests - 레이트 리밋 처리
if ($response->status() === 429) {
$retryAfter = $response->header('Retry-After', 60);
Log::warning("OpenSea API 레이트 리밋 초과. {$retryAfter}초 후 재시도", [
'endpoint' => $endpoint,
'attempt' => $attempt + 1,
]);
sleep((int)$retryAfter);
$attempt++;
continue;
}
$response->throw(); // 4xx, 5xx 에러 시 예외 발생
return $response->json();
} catch (RequestException $e) {
$attempt++;
Log::error("OpenSea API 요청 실패", [
'endpoint' => $endpoint,
'attempt' => $attempt,
'error' => $e->getMessage(),
'status' => $e->response?->status(),
]);
if ($attempt >= $this->maxRetries) {
throw $e;
}
// 지수 백오프 (Exponential Backoff)
usleep($this->retryDelay * pow(2, $attempt) * 1000);
}
}
throw new \RuntimeException("OpenSea API 최대 재시도 횟수 초과: {$endpoint}");
}
/**
* 컬렉션 NFT 목록 조회 (페이지네이션 포함)
*/
public function getNftsByCollection(
string $chain,
string $contractAddress,
int $limit = 50,
?string $next = null
): array {
$params = ['limit' => min($limit, 200)]; // 최대 200개
if ($next) {
$params['next'] = $next;
}
return $this->get(
"chain/{$chain}/contract/{$contractAddress}/nfts",
$params
);
}
/**
* 지갑 주소의 NFT 목록 조회
*/
public function getNftsByWallet(
string $chain,
string $walletAddress,
?string $collection = null,
int $limit = 50,
?string $next = null
): array {
$params = ['limit' => min($limit, 200)];
if ($collection) {
$params['collection'] = $collection;
}
if ($next) {
$params['next'] = $next;
}
return $this->get(
"chain/{$chain}/account/{$walletAddress}/nfts",
$params
);
}
/**
* 단일 NFT 조회
*/
public function getSingleNft(
string $chain,
string $contractAddress,
string $identifier
): array {
return $this->get(
"chain/{$chain}/contract/{$contractAddress}/nfts/{$identifier}"
);
}
/**
* 컬렉션 정보 조회
*/
public function getCollection(string $collectionSlug): array
{
return $this->get("collections/{$collectionSlug}");
}
/**
* 컬렉션 이벤트 조회
*/
public function getCollectionEvents(
string $collectionSlug,
?string $eventType = null,
?int $after = null,
?int $before = null,
int $limit = 50
): array {
$params = ['limit' => min($limit, 50)];
if ($eventType) {
$params['event_type'] = $eventType;
}
if ($after) {
$params['after'] = $after;
}
if ($before) {
$params['before'] = $before;
}
return $this->get(
"events/collection/{$collectionSlug}",
$params
);
}
}
🧠 OpenSeaService.php - 비즈니스 로직
<?php
namespace App\Services\OpenSea;
use App\Models\Nft;
use App\Models\NftCollection;
use App\Models\NftSyncLog;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Cache;
class OpenSeaService
{
public function __construct(
private OpenSeaClient $client
) {}
/**
* 컬렉션 전체 NFT 동기화 (페이지네이션 처리 포함)
*/
public function syncCollection(
string $chain,
string $contractAddress,
?NftSyncLog $syncLog = null
): array {
$synced = 0;
$failed = 0;
$next = null;
do {
try {
// 레이트 리밋 준수를 위한 딜레이 (무료 플랜: 4 RPM)
$this->rateLimitDelay();
$response = $this->client->getNftsByCollection(
$chain,
$contractAddress,
50,
$next
);
$nfts = $response['nfts'] ?? [];
$next = $response['next'] ?? null;
foreach ($nfts as $nftData) {
try {
$this->upsertNft($nftData, $chain);
$synced++;
} catch (\Exception $e) {
$failed++;
Log::error("NFT 저장 실패", [
'identifier' => $nftData['identifier'] ?? 'unknown',
'error' => $e->getMessage(),
]);
}
}
// 동기화 로그 업데이트
if ($syncLog) {
$syncLog->update([
'synced_items' => $synced,
'failed_items' => $failed,
]);
}
Log::info("NFT 동기화 진행 중", [
'synced' => $synced,
'has_next' => !empty($next),
]);
} catch (\Exception $e) {
Log::error("컬렉션 동기화 오류", ['error' => $e->getMessage()]);
break;
}
} while (!empty($next));
return ['synced' => $synced, 'failed' => $failed];
}
/**
* 지갑 NFT 동기화
*/
public function syncWalletNfts(
string $chain,
string $walletAddress,
?string $collection = null
): array {
$synced = 0;
$failed = 0;
$next = null;
do {
$this->rateLimitDelay();
$response = $this->client->getNftsByWallet(
$chain,
$walletAddress,
$collection,
50,
$next
);
$nfts = $response['nfts'] ?? [];
$next = $response['next'] ?? null;
foreach ($nfts as $nftData) {
try {
$this->upsertNft($nftData, $chain);
$synced++;
} catch (\Exception $e) {
$failed++;
}
}
} while (!empty($next));
return ['synced' => $synced, 'failed' => $failed];
}
/**
* NFT 데이터 Upsert (있으면 업데이트, 없으면 생성)
*/
private function upsertNft(array $nftData, string $chain): void
{
DB::transaction(function () use ($nftData, $chain) {
Nft::updateOrCreate(
[
'identifier' => $nftData['identifier'],
'contract' => strtolower($nftData['contract']),
'chain' => $chain,
],
[
'collection' => $nftData['collection'] ?? null,
'token_standard' => $nftData['token_standard'] ?? null,
'name' => $nftData['name'] ?? null,
'description' => $nftData['description'] ?? null,
'image_url' => $nftData['image_url'] ?? null,
'display_image_url' => $nftData['display_image_url'] ?? null,
'display_animation_url' => $nftData['display_animation_url'] ?? null,
'metadata_url' => $nftData['metadata_url'] ?? null,
'opensea_url' => $nftData['opensea_url'] ?? null,
'is_suspicious' => $nftData['is_suspicious'] ?? false,
'is_disabled' => $nftData['is_disabled'] ?? false,
'traits' => $nftData['traits'] ?? null,
'owners' => $nftData['owners'] ?? null,
'last_synced_at' => now(),
]
);
});
}
/**
* 컬렉션 정보 동기화
*/
public function syncCollectionInfo(string $collectionSlug): NftCollection
{
$this->rateLimitDelay();
$data = $this->client->getCollection($collectionSlug);
return NftCollection::updateOrCreate(
['collection_slug' => $collectionSlug],
[
'name' => $data['name'] ?? null,
'description' => $data['description'] ?? null,
'image_url' => $data['image_url'] ?? null,
'banner_image_url' => $data['banner_image_url'] ?? null,
'owner' => $data['owner'] ?? null,
'safelist_status' => $data['safelist_request_status'] ?? null,
'category' => $data['category'] ?? null,
'is_disabled' => $data['is_disabled'] ?? false,
'is_nsfw' => $data['is_nsfw'] ?? false,
'opensea_url' => $data['opensea_url'] ?? null,
'project_url' => $data['project_url'] ?? null,
'discord_url' => $data['discord_url'] ?? null,
'twitter_username' => $data['twitter_username'] ?? null,
'instagram_username' => $data['instagram_username'] ?? null,
'contracts' => $data['contracts'] ?? null,
'payment_tokens' => $data['payment_tokens'] ?? null,
'last_synced_at' => now(),
]
);
}
/**
* 레이트 리밋 딜레이 (무료 플랜 기준: 4 RPM = 15초에 1회)
* 유료 플랜이면 이 값을 줄여도 됨
*/
private function rateLimitDelay(): void
{
$plan = config('services.opensea.plan', 'free');
$delays = [
'free' => 15, // 4 RPM
'starter' => 2, // 30 RPM
'growth' => 1, // 60 RPM
'enterprise' => 0, // 커스텀
];
$delay = $delays[$plan] ?? 15;
if ($delay > 0) {
sleep($delay);
}
}
}
updateOrCreate를 쓰면 중복 데이터 없이 항상 최신 상태를 유지할 수 있어. 라라벨의 이 메서드는 내부적으로 INSERT ... ON DUPLICATE KEY UPDATE 쿼리를 사용해서 성능도 좋아!
⚡ 4. Queue Job으로 비동기 처리 - 이게 진짜 자동화야!
동기화 작업을 HTTP 요청 중에 직접 처리하면 타임아웃 나고 사용자 경험도 최악이 돼 ㅋㅋㅋ 그래서 Queue Job으로 비동기 처리하는 게 핵심이야!
📋 SyncNftCollection Job
<?php
namespace App\Jobs;
use App\Models\NftSyncLog;
use App\Services\OpenSea\OpenSeaService;
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 SyncNftCollection implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
// 최대 재시도 횟수
public int $tries = 3;
// 타임아웃 (초) - 대용량 컬렉션은 넉넉하게
public int $timeout = 3600; // 1시간
// 재시도 간격 (초)
public int $backoff = 60;
public function __construct(
private string $chain,
private string $contractAddress,
private string $collectionSlug,
private int $syncLogId
) {}
public function handle(OpenSeaService $openSeaService): void
{
$syncLog = NftSyncLog::findOrFail($this->syncLogId);
try {
// 동기화 시작 상태 업데이트
$syncLog->update([
'status' => 'running',
'started_at' => now(),
]);
Log::info("NFT 컬렉션 동기화 시작", [
'collection' => $this->collectionSlug,
'chain' => $this->chain,
]);
// 컬렉션 정보 먼저 동기화
$openSeaService->syncCollectionInfo($this->collectionSlug);
// NFT 목록 동기화
$result = $openSeaService->syncCollection(
$this->chain,
$this->contractAddress,
$syncLog
);
// 완료 상태 업데이트
$syncLog->update([
'status' => 'completed',
'synced_items' => $result['synced'],
'failed_items' => $result['failed'],
'completed_at' => now(),
]);
Log::info("NFT 컬렉션 동기화 완료", [
'collection' => $this->collectionSlug,
'synced' => $result['synced'],
'failed' => $result['failed'],
]);
} catch (\Exception $e) {
$syncLog->update([
'status' => 'failed',
'error_message' => $e->getMessage(),
'completed_at' => now(),
]);
Log::error("NFT 컬렉션 동기화 실패", [
'collection' => $this->collectionSlug,
'error' => $e->getMessage(),
]);
throw $e; // 재시도를 위해 예외 다시 던지기
}
}
/**
* 잡 실패 시 처리
*/
public function failed(\Throwable $exception): void
{
NftSyncLog::where('id', $this->syncLogId)->update([
'status' => 'failed',
'error_message' => $exception->getMessage(),
'completed_at' => now(),
]);
Log::critical("NFT 동기화 잡 최종 실패", [
'collection' => $this->collectionSlug,
'error' => $exception->getMessage(),
]);
}
}
👛 SyncWalletNfts Job
<?php
namespace App\Jobs;
use App\Services\OpenSea\OpenSeaService;
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\Cache;
class SyncWalletNfts implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 1800; // 30분
public int $backoff = 30;
public function __construct(
private string $chain,
private string $walletAddress,
private ?string $collection = null,
private ?int $userId = null // 연동된 사용자 ID (선택)
) {}
public function handle(OpenSeaService $openSeaService): void
{
// 중복 실행 방지 (같은 지갑 동기화가 이미 진행 중이면 스킵)
$lockKey = "nft_sync_wallet_{$this->chain}_{$this->walletAddress}";
$lock = Cache::lock($lockKey, 1800); // 30분 락
if (!$lock->get()) {
// 이미 동기화 중이면 스킵
return;
}
try {
$result = $openSeaService->syncWalletNfts(
$this->chain,
$this->walletAddress,
$this->collection
);
// 사용자가 있으면 동기화 완료 이벤트 발생
if ($this->userId) {
// 이벤트 발생 (알림 등에 활용)
// event(new WalletNftsSynced($this->userId, $result));
}
} finally {
$lock->release();
}
}
}
Cache::lock()을 사용해서 분산 락을 걸어주면 이 문제를 해결할 수 있어. Redis를 캐시 드라이버로 쓰면 이 기능이 완벽하게 동작해!
⏰ 5. Laravel Scheduler로 완전 자동화!
이제 진짜 "자동" 동기화를 구현할 차례야! 라라벨 스케줄러를 사용하면 Cron 설정 하나로 모든 걸 자동화할 수 있어 ㅋㅋㅋ
🖥️ Console Command 만들기
<?php
namespace App\Console\Commands;
use App\Jobs\SyncNftCollection;
use App\Models\NftCollection;
use App\Models\NftSyncLog;
use Illuminate\Console\Command;
class NftSyncCommand extends Command
{
protected $signature = 'nft:sync
{--type=collection : 동기화 타입 (collection|wallet)}
{--chain=ethereum : 블록체인 체인}
{--address= : 컨트랙트 또는 지갑 주소}
{--slug= : 컬렉션 슬러그}
{--all : 등록된 모든 컬렉션 동기화}';
protected $description = 'OpenSea NFT 데이터 동기화';
public function handle(): int
{
if ($this->option('all')) {
return $this->syncAllCollections();
}
$type = $this->option('type');
$chain = $this->option('chain');
$address = $this->option('address');
$slug = $this->option('slug');
if (!$address) {
$this->error('--address 옵션이 필요합니다!');
return Command::FAILURE;
}
// 동기화 로그 생성
$syncLog = NftSyncLog::create([
'sync_type' => $type,
'target' => $address,
'chain' => $chain,
'status' => 'pending',
]);
// 잡 디스패치
SyncNftCollection::dispatch(
$chain,
$address,
$slug ?? $address,
$syncLog->id
);
$this->info("동기화 잡이 큐에 추가되었습니다! (Log ID: {$syncLog->id})");
return Command::SUCCESS;
}
private function syncAllCollections(): int
{
$collections = NftCollection::all();
$this->info("총 {$collections->count()}개 컬렉션 동기화 시작...");
foreach ($collections as $collection) {
$contracts = $collection->contracts ?? [];
foreach ($contracts as $contract) {
$syncLog = NftSyncLog::create([
'sync_type' => 'collection',
'target' => $collection->collection_slug,
'chain' => $contract['chain'] ?? 'ethereum',
'status' => 'pending',
]);
SyncNftCollection::dispatch(
$contract['chain'] ?? 'ethereum',
$contract['address'],
$collection->collection_slug,
$syncLog->id
)->onQueue('nft-sync'); // 전용 큐 사용
}
}
$this->info("모든 동기화 잡이 큐에 추가되었습니다!");
return Command::SUCCESS;
}
}
📅 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
{
// 매 시간마다 전체 컬렉션 동기화
$schedule->command('nft:sync --all')
->hourly()
->withoutOverlapping(120) // 2시간 내 중복 실행 방지
->runInBackground()
->appendOutputTo(storage_path('logs/nft-sync.log'));
// 매일 새벽 2시에 전체 재동기화 (완전 갱신)
$schedule->command('nft:sync --all')
->dailyAt('02:00')
->withoutOverlapping(360)
->runInBackground();
// 큐 워커 모니터링 (5분마다 상태 체크)
$schedule->command('queue:monitor nft-sync')
->everyFiveMinutes();
}
protected function commands(): void
{
$this->load(__DIR__.'/Commands');
require base_path('routes/console.php');
}
}
🔧 Cron 설정 (서버에서 딱 한 줄만!)
# crontab -e 로 편집
* * * * * cd /path/to/your/project && php artisan schedule:run >> /dev/null 2>&1
php artisan schedule:work를 실행하면 Cron 없이도 스케줄러가 동작해. 프로덕션에서는 반드시 Cron을 설정해야 하지만, 로컬 테스트할 때 유용해!
🚀 6. 캐싱 전략 - API 비용 아끼고 속도 올리기!
오픈시 API는 무료 플랜에서 요청 제한이 빡세거든 ㅋㅋㅋ 그래서 캐싱 전략을 잘 세워야 해. 불필요한 API 호출을 줄이고 응답 속도도 올리는 두 마리 토끼를 잡아보자!
🔴 Redis 캐싱 레이어 구현
<?php
namespace App\Services\OpenSea;
use Illuminate\Support\Facades\Cache;
class OpenSeaCacheService
{
// 캐시 TTL 설정 (초 단위)
private const CACHE_TTL = [
'collection_info' => 3600, // 컬렉션 정보: 1시간
'nft_list' => 1800, // NFT 목록: 30분
'single_nft' => 900, // 단일 NFT: 15분
'wallet_nfts' => 300, // 지갑 NFT: 5분 (자주 변경됨)
'floor_price' => 60, // 바닥가: 1분 (실시간성 중요)
];
/**
* 캐시에서 NFT 목록 가져오기 (없으면 API 호출)
*/
public function rememberNftList(
string $chain,
string $contractAddress,
callable $callback
): array {
$cacheKey = "nft_list_{$chain}_{$contractAddress}";
return Cache::remember(
$cacheKey,
self::CACHE_TTL['nft_list'],
$callback
);
}
/**
* 단일 NFT 캐시
*/
public function rememberSingleNft(
string $chain,
string $contract,
string $identifier,
callable $callback
): array {
$cacheKey = "nft_{$chain}_{$contract}_{$identifier}";
return Cache::remember(
$cacheKey,
self::CACHE_TTL['single_nft'],
$callback
);
}
/**
* 컬렉션 정보 캐시
*/
public function rememberCollection(
string $slug,
callable $callback
): array {
$cacheKey = "collection_{$slug}";
return Cache::remember(
$cacheKey,
self::CACHE_TTL['collection_info'],
$callback
);
}
/**
* 특정 NFT 캐시 무효화 (동기화 후 호출)
*/
public function invalidateNft(
string $chain,
string $contract,
string $identifier
): void {
Cache::forget("nft_{$chain}_{$contract}_{$identifier}");
}
/**
* 컬렉션 전체 캐시 무효화
*/
public function invalidateCollection(
string $chain,
string $contractAddress,
string $slug
): void {
Cache::forget("nft_list_{$chain}_{$contractAddress}");
Cache::forget("collection_{$slug}");
}
/**
* 태그 기반 캐시 무효화 (Redis 전용)
* 특정 컬렉션의 모든 캐시를 한 번에 삭제
*/
public function invalidateByTag(string $tag): void
{
Cache::tags([$tag])->flush();
}
}
📊 Controller에서 캐시 활용
<?php
namespace App\Http\Controllers\Api;
use App\Models\Nft;
use App\Models\NftCollection;
use App\Jobs\SyncWalletNfts;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Cache;
class NftController extends Controller
{
/**
* 컬렉션 NFT 목록 조회 (DB에서 가져오기 + 캐싱)
*/
public function getCollectionNfts(
Request $request,
string $collectionSlug
): JsonResponse {
$cacheKey = "api_collection_nfts_{$collectionSlug}_"
. md5(serialize($request->query()));
$data = Cache::remember($cacheKey, 300, function () use ($request, $collectionSlug) {
return Nft::where('collection', $collectionSlug)
->when($request->chain, fn($q) => $q->where('chain', $request->chain))
->when($request->search, fn($q) => $q->where('name', 'like', "%{$request->search}%"))
->orderBy('identifier')
->paginate($request->per_page ?? 20);
});
return response()->json([
'success' => true,
'data' => $data,
]);
}
/**
* 지갑 NFT 조회 (동기화 트리거 포함)
*/
public function getWalletNfts(
Request $request,
string $walletAddress
): JsonResponse {
$chain = $request->chain ?? 'ethereum';
// DB에서 먼저 조회
$nfts = Nft::whereJsonContains('owners', ['address' => strtolower($walletAddress)])
->where('chain', $chain)
->get();
// 마지막 동기화가 5분 이상 지났으면 백그라운드 재동기화 트리거
$lastSync = Cache::get("wallet_last_sync_{$chain}_{$walletAddress}");
if (!$lastSync || now()->diffInMinutes($lastSync) > 5) {
SyncWalletNfts::dispatch($chain, $walletAddress)
->onQueue('nft-sync');
Cache::put(
"wallet_last_sync_{$chain}_{$walletAddress}",
now(),
600 // 10분 캐시
);
}
return response()->json([
'success' => true,
'data' => $nfts,
'is_refreshing' => !$lastSync, // 처음 조회면 갱신 중 표시
]);
}
/**
* NFT 단건 조회
*/
public function getSingleNft(
string $chain,
string $contract,
string $identifier
): JsonResponse {
$nft = Nft::where([
'chain' => $chain,
'contract' => strtolower($contract),
'identifier' => $identifier,
])->firstOrFail();
return response()->json([
'success' => true,
'data' => $nft,
]);
}
}
🏗️ 7. Eloquent 모델 완성하기
모델도 제대로 설정해야 나중에 쓰기 편해! 특히 JSON 컬럼 처리랑 스코프 설정이 중요해 ㅋㅋㅋ
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Builder;
class Nft extends Model
{
protected $fillable = [
'identifier', 'collection', 'contract', 'token_standard',
'name', 'description', 'image_url', 'display_image_url',
'display_animation_url', 'metadata_url', 'opensea_url',
'chain', 'is_suspicious', 'is_disabled', 'traits',
'owners', 'last_synced_at',
];
protected $casts = [
'traits' => 'array', // JSON → 배열 자동 변환
'owners' => 'array',
'is_suspicious' => 'boolean',
'is_disabled' => 'boolean',
'last_synced_at' => 'datetime',
];
// 숨길 컬럼 (API 응답에서 제외)
protected $hidden = ['created_at', 'updated_at'];
// 항상 포함할 추가 속성
protected $appends = ['opensea_chain_url'];
/**
* 오픈시 체인 URL 생성
*/
public function getOpenseaChainUrlAttribute(): string
{
return "https://opensea.io/assets/{$this->chain}/{$this->contract}/{$this->identifier}";
}
/**
* 스코프: 활성 NFT만 (의심스럽거나 비활성화된 것 제외)
*/
public function scopeActive(Builder $query): Builder
{
return $query->where('is_suspicious', false)
->where('is_disabled', false);
}
/**
* 스코프: 특정 체인 필터
*/
public function scopeOnChain(Builder $query, string $chain): Builder
{
return $query->where('chain', $chain);
}
/**
* 스코프: 오래된 NFT (재동기화 필요)
*/
public function scopeNeedsSync(Builder $query, int $minutes = 60): Builder
{
return $query->where(function ($q) use ($minutes) {
$q->whereNull('last_synced_at')
->orWhere('last_synced_at', '<', now()->subMinutes($minutes));
});
}
/**
* 특정 특성(trait) 값으로 필터링
*/
public function scopeWithTrait(
Builder $query,
string $traitType,
string $value
): Builder {
return $query->whereJsonContains(
'traits',
['trait_type' => $traitType, 'value' => $value]
);
}
/**
* 컬렉션 관계
*/
public function nftCollection()
{
return $this->belongsTo(NftCollection::class, 'collection', 'collection_slug');
}
}
whereJsonContains()로 JSON 배열 안의 값을 검색할 수 있어. NFT의 traits를 기반으로 필터링할 때 매우 유용해! 다만 대용량 데이터에서는 성능 이슈가 있을 수 있으니 인덱스를 잘 설계해야 해.
🛡️ 8. 에러 처리 & 모니터링 - 프로덕션 필수!
자동화 시스템에서 에러 처리는 진짜 중요해. 모르고 있다가 동기화가 며칠째 안 되고 있으면 ㅋㅋㅋ 큰일 나잖아!
🔔 Slack 알림 연동
<?php
namespace App\Notifications;
use Illuminate\Notifications\Notification;
use Illuminate\Notifications\Messages\SlackMessage;
class NftSyncFailed extends Notification
{
public function __construct(
private string $collection,
private string $errorMessage,
private int $failedCount
) {}
public function via($notifiable): array
{
return ['slack'];
}
public function toSlack($notifiable): SlackMessage
{
return (new SlackMessage)
->error()
->content('🚨 NFT 동기화 실패 알림')
->attachment(function ($attachment) {
$attachment
->title("컬렉션: {$this->collection}")
->fields([
'에러 메시지' => $this->errorMessage,
'실패 건수' => $this->failedCount,
'발생 시간' => now()->format('Y-m-d H:i:s'),
]);
});
}
}
📊 동기화 상태 대시보드 API
<?php
namespace App\Http\Controllers\Admin;
use App\Models\NftSyncLog;
use App\Models\Nft;
use App\Models\NftCollection;
use Illuminate\Http\JsonResponse;
class NftSyncDashboardController extends Controller
{
public function getStats(): JsonResponse
{
$stats = [
// 전체 통계
'total_nfts' => Nft::count(),
'total_collections' => NftCollection::count(),
// 동기화 상태
'sync_stats' => [
'pending' => NftSyncLog::where('status', 'pending')->count(),
'running' => NftSyncLog::where('status', 'running')->count(),
'completed' => NftSyncLog::where('status', 'completed')
->whereDate('created_at', today())->count(),
'failed' => NftSyncLog::where('status', 'failed')
->whereDate('created_at', today())->count(),
],
// 최근 동기화 이력
'recent_syncs' => NftSyncLog::latest()
->limit(10)
->get(['id', 'sync_type', 'target', 'status',
'synced_items', 'failed_items', 'created_at']),
// 재동기화 필요한 NFT 수
'needs_sync_count' => Nft::needsSync(60)->count(),
// 체인별 NFT 분포
'chain_distribution' => Nft::selectRaw('chain, COUNT(*) as count')
->groupBy('chain')
->get(),
];
return response()->json(['success' => true, 'data' => $stats]);
}
}
⚡ Horizon으로 큐 모니터링
# Laravel Horizon 설치 (Redis 큐 모니터링 대시보드)
composer require laravel/horizon
php artisan horizon:install
php artisan migrate
# Horizon 실행
php artisan horizon
# 프로덕션에서는 Supervisor로 관리
# /etc/supervisor/conf.d/horizon.conf
[program:horizon]
process_name=%(program_name)s
command=php /path/to/artisan horizon
autostart=true
autorestart=true
user=www-data
redirect_stderr=true
stdout_logfile=/path/to/storage/logs/horizon.log
stopwaitsecs=3600
<?php
// config/horizon.php - NFT 동기화 전용 큐 설정
'environments' => [
'production' => [
'supervisor-1' => [
'maxProcesses' => 10,
'balanceMaxShift' => 1,
'balanceCooldown' => 3,
],
// NFT 동기화 전용 워커 (느린 작업이라 별도 관리)
'nft-sync-supervisor' => [
'connection' => 'redis',
'queue' => ['nft-sync'],
'balance' => 'auto',
'processes' => 3,
'tries' => 3,
'timeout' => 3600,
],
],
],
💎 9. 실전 팁 & 트러블슈팅 - 삽질 방지!
이론은 알겠는데 실제로 구현하다 보면 예상치 못한 문제들이 생기거든 ㅋㅋㅋ 내가 겪은(혹은 겪을 수 있는) 문제들을 미리 정리해줄게!
🚨 자주 발생하는 문제들
무료 플랜에서 4 RPM 제한을 초과하면 429 에러가 발생해. 해결책:
① sleep()으로 요청 간격 조절 (15초 이상)
② 지수 백오프(Exponential Backoff) 구현
③ Retry-After 헤더 값 확인 후 대기
④ 유료 플랜 업그레이드 고려 (Starter: 30 RPM)
BAYC 같은 10,000개짜리 컬렉션은 동기화에 엄청 오래 걸려. 해결책:
① Job의 $timeout을 충분히 크게 설정 (3600초 이상)
② 청크 단위로 나눠서 여러 Job으로 분산 처리
③ 이미 동기화된 NFT는 스킵하는 로직 추가
④ last_synced_at 기준으로 오래된 것만 갱신
이더리움 주소는 EIP-55 체크섬 형식이 있어서 대소문자가 섞여 있어. 해결책:
① DB 저장 시 항상 strtolower()로 소문자 변환
② 조회 시에도 소문자로 변환해서 비교
③ DB 컬럼에 BINARY 타입 대신 일반 VARCHAR 사용
NFT 이미지가 ipfs:// 프로토콜로 저장된 경우가 많아. 해결책:
① ipfs://를 https://ipfs.io/ipfs/로 변환하는 헬퍼 함수 작성
② 또는 Cloudflare IPFS 게이트웨이 사용: https://cloudflare-ipfs.com/ipfs/
③ 이미지 프록시 서버를 통해 캐싱 처리
<?php
// IPFS URL 변환 헬퍼 함수
function convertIpfsUrl(string $url): string
{
if (str_starts_with($url, 'ipfs://')) {
$hash = substr($url, 7);
return "https://cloudflare-ipfs.com/ipfs/{$hash}";
}
// ar:// (Arweave) 처리
if (str_starts_with($url, 'ar://')) {
$hash = substr($url, 5);
return "https://arweave.net/{$hash}";
}
return $url;
}
// 모델의 Accessor에서 활용
public function getImageUrlAttribute($value): ?string
{
if (!$value) return null;
return convertIpfsUrl($value);
}
🔧 성능 최적화 체크리스트
identifier + contract + chain 복합 유니크 인덱스✅ 청크 처리 대량 데이터는
chunk() 또는 cursor() 사용✅ 큐 분리 NFT 동기화 전용 큐
nft-sync 분리 운영✅ 캐시 전략 Redis 캐시로 반복 API 호출 최소화
✅ 분산 락 중복 동기화 방지를 위한 Cache::lock() 활용
✅ 로그 관리 동기화 이력 테이블로 추적 가능성 확보
✅ 모니터링 Laravel Horizon으로 큐 상태 실시간 확인
🔌 10. 서비스 프로바이더 & 라우팅 마무리
마지막으로 서비스 프로바이더에 등록하고 라우팅까지 완성하면 끝이야!
📋 config/services.php 설정
<?php
// config/services.php에 추가
'opensea' => [
'api_key' => env('OPENSEA_API_KEY'),
'base_url' => env('OPENSEA_BASE_URL', 'https://api.opensea.io'),
'api_version' => env('OPENSEA_API_VERSION', 'v2'),
'plan' => env('OPENSEA_PLAN', 'free'), // free, starter, growth, enterprise
],
🔗 AppServiceProvider 바인딩
<?php
namespace App\Providers;
use App\Services\OpenSea\OpenSeaClient;
use App\Services\OpenSea\OpenSeaService;
use App\Services\OpenSea\OpenSeaCacheService;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
// 싱글톤으로 등록 (앱 전체에서 같은 인스턴스 사용)
$this->app->singleton(OpenSeaClient::class);
$this->app->singleton(OpenSeaCacheService::class);
$this->app->singleton(OpenSeaService::class, function ($app) {
return new OpenSeaService(
$app->make(OpenSeaClient::class)
);
});
}
}
🛣️ API 라우팅
<?php
// routes/api.php
use App\Http\Controllers\Api\NftController;
use App\Http\Controllers\Admin\NftSyncDashboardController;
// NFT 조회 API
Route::prefix('nft')->group(function () {
// 컬렉션 NFT 목록
Route::get('/collection/{slug}', [NftController::class, 'getCollectionNfts']);
// 지갑 NFT 목록
Route::get('/wallet/{chain}/{address}', [NftController::class, 'getWalletNfts']);
// 단일 NFT 조회
Route::get('/{chain}/{contract}/{identifier}', [NftController::class, 'getSingleNft']);
});
// 관리자 API (인증 필요)
Route::middleware(['auth:sanctum', 'admin'])->prefix('admin/nft')->group(function () {
// 동기화 대시보드
Route::get('/stats', [NftSyncDashboardController::class, 'getStats']);
// 수동 동기화 트리거
Route::post('/sync/collection', function (Request $request) {
$syncLog = \App\Models\NftSyncLog::create([
'sync_type' => 'collection',
'target' => $request->slug,
'chain' => $request->chain,
'status' => 'pending',
]);
\App\Jobs\SyncNftCollection::dispatch(
$request->chain,
$request->contract,
$request->slug,
$syncLog->id
)->onQueue('nft-sync');
return response()->json(['success' => true, 'sync_log_id' => $syncLog->id]);
});
// 동기화 상태 조회
Route::get('/sync/logs', function () {
return response()->json([
'data' => \App\Models\NftSyncLog::latest()->paginate(20)
]);
});
});
🎯 11. 마무리 & 실제 활용 시나리오
자 이제 라라벨 + 오픈시 API NFT 자동 동기화 시스템의 전체 그림이 그려졌지? ㅋㅋㅋ 이 기술을 실제로 어떻게 활용할 수 있는지 정리해볼게!
🌟 실제 활용 시나리오
사용자가 지갑 주소를 입력하면 보유한 NFT를 예쁘게 보여주는 갤러리 서비스. 오픈시 API로 데이터를 가져와서 라라벨 DB에 저장하고, 프론트엔드에서 멋지게 표현할 수 있어!
자체 마켓플레이스를 만들 때 오픈시 데이터를 참조해서 NFT 정보를 풍부하게 표시. 바닥가(Floor Price), 거래 이력, 희귀도 등을 자동으로 동기화해서 보여줄 수 있어!
게임 내 NFT 아이템을 오픈시와 연동해서 실시간으로 소유권을 확인하고, 게임 내에서 NFT를 활용할 수 있게 하는 시스템!
여러 지갑의 NFT를 한 곳에서 관리하고 가치를 추적하는 서비스. 자동 동기화로 항상 최신 데이터를 유지!
이런 Web3 개발 프로젝트를 혼자 하기 어렵다면? 재능넷(jaenung.net)에서 라라벨 전문 개발자나 블록체인 개발자를 찾아볼 수 있어! 다양한 Web3 개발 재능을 가진 분들이 많이 계시거든 ㅋㅋㅋ 프리랜서 협업으로 프로젝트를 빠르게 완성해보는 것도 좋은 방법이야!
① 오픈시 API 키 발급 및 .env 설정
② DB 마이그레이션 실행
③ OpenSeaClient → OpenSeaService 구현
④ Queue Job 구현 (SyncNftCollection, SyncWalletNfts)
⑤ Artisan Command 작성
⑥ Scheduler 등록 및 Cron 설정
⑦ Controller & API 라우팅 완성
⑧ Redis 캐싱 레이어 추가
⑨ Laravel Horizon으로 모니터링
⑩ 에러 처리 & 알림 설정
📝 전체 요약 - 한눈에 보기!
오늘 배운 내용을 정리해볼게. 라라벨로 오픈시 API NFT 자동 동기화 시스템을 구축하는 건 생각보다 체계적으로 접근하면 충분히 할 수 있어!
| 단계 | 구성 요소 | 핵심 포인트 |
|---|---|---|
| 1단계 | 환경 설정 | API 키 발급, .env 설정, DB 마이그레이션 |
| 2단계 | OpenSeaClient | HTTP 통신, 재시도 로직, 레이트 리밋 처리 |
| 3단계 | OpenSeaService | 비즈니스 로직, Upsert 패턴, 페이지네이션 |
| 4단계 | Queue Jobs | 비동기 처리, 분산 락, 에러 처리 |
| 5단계 | Scheduler | 자동화, Cron 설정, 중복 실행 방지 |
| 6단계 | 캐싱 레이어 | Redis 캐시, TTL 전략, 캐시 무효화 |
| 7단계 | 모니터링 | Horizon, 동기화 로그, Slack 알림 |
마지막으로 한 가지 더! 이 시스템을 구축하다가 막히는 부분이 생기면 재능넷에서 라라벨이나 Web3 전문 개발자에게 도움을 요청하는 것도 좋은 방법이야. 혼자 삽질하는 것보다 전문가의 도움을 받으면 훨씬 빠르게 해결할 수 있거든 ㅋㅋㅋ
• 오픈시 공식 API 문서: https://docs.opensea.io/reference/api-overview
• 라라벨 공식 문서 (Queue): https://laravel.com/docs/queues
• 라라벨 Horizon: https://laravel.com/docs/horizon
• 라라벨 Scheduler: https://laravel.com/docs/scheduling
• EIP-55 (이더리움 주소 체크섬): https://eips.ethereum.org/EIPS/eip-55
관련 키워드
댓글 작성
이 글에 대한 여러분의 생각을 들려주세요
로그인이 필요합니다
댓글을 작성하려면 먼저 로그인해주세요.
댓글 0
지식인의 숲 - 지적 재산권 보호 고지
지적 재산권 보호 고지
- 저작권 및 소유권: 본 컨텐츠는 재능넷의 독점 AI 기술로 생성되었으며, 대한민국 저작권법 및 국제 저작권 협약에 의해 보호됩니다.
- AI 생성 컨텐츠의 법적 지위: 본 AI 생성 컨텐츠는 재능넷의 지적 창작물로 인정되며, 관련 법규에 따라 저작권 보호를 받습니다.
- 사용 제한: 재능넷의 명시적 서면 동의 없이 본 컨텐츠를 복제, 수정, 배포, 또는 상업적으로 활용하는 행위는 엄격히 금지됩니다.
- 데이터 수집 금지: 본 컨텐츠에 대한 무단 스크래핑, 크롤링, 및 자동화된 데이터 수집은 법적 제재의 대상이 됩니다.
- AI 학습 제한: 재능넷의 AI 생성 컨텐츠를 타 AI 모델 학습에 무단 사용하는 행위는 금지되며, 이는 지적 재산권 침해로 간주됩니다.
아직 댓글이 없습니다.