Ver3.0 ๐ ๋ผ๋ผ๋ฒจ๋ก ๋ง๋๋ NFT ํต๊ณ ๋ถ์ ๋์๋ณด๋

๐ ๋ผ๋ผ๋ฒจ๋ก ๋ง๋๋ NFT ํต๊ณ ๋ถ์ ๋์๋ณด๋
Web3 ์๋, ๋ฐ์ดํฐ๋ก NFT ์์ฅ์ ์ ๋ณตํ๋ ๋ฒ
์๋
! ์ค๋์ ์ ๋ง ํซํ ์ฃผ์ ๋ฅผ ๊ฐ์ง๊ณ ์์ด. ๋ฐ๋ก ๋ผ๋ผ๋ฒจ(Laravel)์ ํ์ฉํด์ NFT ํต๊ณ ๋ถ์ ๋์๋ณด๋๋ฅผ ๋ง๋๋ ๋ฐฉ๋ฒ์ด์ผ! ๐จ
์์ฆ NFT ์์ฅ์ด ์์ฒญ๋๊ฒ ์ฑ์ฅํ๊ณ ์์์? ๊ทธ๋ฐ๋ฐ ๋ง์ NFT ํ๋ก์ ํธ๋ฅผ ์ด์ํ๊ฑฐ๋ ํฌ์ํ๋ ค๊ณ ํ๋ฉด ๋ฐ์ดํฐ๊ฐ ์ฌ๊ธฐ์ ๊ธฐ ํฉ์ด์ ธ ์์ด์ ํ๋์ ๋ณด๊ธฐ๊ฐ ์ ๋ง ์ด๋ ค์. ๊ทธ๋์ ์ฐ๋ฆฌ๊ฐ ์ง์ ๋ฉ์ง ๋์๋ณด๋๋ฅผ ๋ง๋ค์ด๋ณด๋ ๊ฑฐ์ง! ๐ช
์ด ๊ธ์์๋ ์ค์ ๋ก ๋์ํ๋ NFT ํต๊ณ ๋ถ์ ๋์๋ณด๋๋ฅผ ์ฒ์๋ถํฐ ๋๊น์ง ๋ง๋๋ ๊ณผ์ ์ ์น๊ตฌ์ฒ๋ผ ํธํ๊ฒ ์ค๋ช
ํด์ค๊ฒ. ์ฝ๋ฉ ๊ฒฝํ์ด ์ข ์๋ค๋ฉด ๋ฐ๋ผํ๊ธฐ ์ด๋ ต์ง ์์ ๊ฑฐ์ผ!
๐ฏ ์ ๋ผ๋ผ๋ฒจ์ธ๊ฐ?
๋จผ์ ์ ์๋ง์ ํ๋ ์์ํฌ ์ค์์ ๋ผ๋ผ๋ฒจ์ ์ ํํ๋์ง ์ด์ผ๊ธฐํด๋ณผ๊ฒ. ์ฌ์ค NFT ๋์๋ณด๋๋ฅผ ๋ง๋ค ์ ์๋ ๋ฐฉ๋ฒ์ ์ ๋ง ๋ง์. Node.js, Django, Ruby on Rails ๋ฑ๋ฑ... ๊ทธ๋ฐ๋ฐ ๋ผ๋ผ๋ฒจ์ด ํนํ ์ข์ ์ด์ ๊ฐ ์์ด! ๐
๐ ๋ผ๋ผ๋ฒจ์ ๊ฐ๋ ฅํ ์ฅ์ ๋ค
1. ์ฐ์ํ ๋ฌธ๋ฒ๊ณผ ์์ฐ์ฑ
๋ผ๋ผ๋ฒจ์ ์ ๋ง ์ฝ๋๊ฐ ์๋ฆ๋ค์. Eloquent ORM์ ์ฌ์ฉํ๋ฉด ๋ฐ์ดํฐ๋ฒ ์ด์ค ์์
์ด ๋ง์น ์๋ฅผ ์ฐ๋ ๊ฒ์ฒ๋ผ ์ฐ์ํด์ ธ. NFT ๋ฐ์ดํฐ๋ฅผ ๋ค๋ฃฐ ๋ ๋ณต์กํ ์ฟผ๋ฆฌ๋ ์ง๊ด์ ์ผ๋ก ์์ฑํ ์ ์์ด.
2. ๊ฐ๋ ฅํ ํ(Queue) ์์คํ
๋ธ๋ก์ฒด์ธ ๋ฐ์ดํฐ๋ฅผ ์์งํ๋ ๊ฑด ์๊ฐ์ด ์ค๋ ๊ฑธ๋ฆฌ๋ ์์
์ด์ผ. ๋ผ๋ผ๋ฒจ์ ํ ์์คํ
์ ์ฌ์ฉํ๋ฉด ๋ฐฑ๊ทธ๋ผ์ด๋์์ ๋ฐ์ดํฐ๋ฅผ ์์งํ๊ณ , ์ฌ์ฉ์๋ ๋น ๋ฅธ ์๋ต์ ๋ฐ์ ์ ์์ด. ์ด๊ฑด ์ ๋ง ์ค์ํ ๊ธฐ๋ฅ์ด์ผ! โก
3. ์ค์ผ์ค๋ง ๊ธฐ๋ฅ
NFT ์์ฅ์ 24์๊ฐ ๋์๊ฐ์์? ๋ผ๋ผ๋ฒจ์ Task Scheduling์ ์ฌ์ฉํ๋ฉด ์ ๊ธฐ์ ์ผ๋ก ๋ฐ์ดํฐ๋ฅผ ์
๋ฐ์ดํธํ๋ ํฌ๋ก ์ก์ ์ฝ๊ฒ ์ค์ ํ ์ ์์ด.
4. ํ๋ถํ ์ํ๊ณ
๋ผ๋ผ๋ฒจ์ ์ปค๋ฎค๋ํฐ๊ฐ ์ ๋ง ํ๋ฐํด. ํ์ํ ํจํค์ง๋ค์ด ์ด๋ฏธ ๋ค ๋ง๋ค์ด์ ธ ์๊ณ , ๋ฌธ์ ๊ฐ ์๊ฒจ๋ ํด๊ฒฐ์ฑ
์ ์ฝ๊ฒ ์ฐพ์ ์ ์์ด. ํนํ ์ฌ๋ฅ๋ท ๊ฐ์ ํ๋ซํผ์์ ๋ผ๋ผ๋ฒจ ์ ๋ฌธ๊ฐ๋ค์ ์ฐพ์ ๋์์ ๋ฐ์ ์๋ ์์ง!
๐ก Pro Tip
๋ผ๋ผ๋ฒจ 8 ์ด์ ๋ฒ์ ์ ์ฌ์ฉํ๋ ๊ฑธ ์ถ์ฒํด! ์ต์ ๋ฒ์ ์ผ์๋ก ์ฑ๋ฅ์ด ์ข๊ณ , ํนํ ๋ผ๋ผ๋ฒจ 10๋ถํฐ๋ PHP 8.1์ ๊ฐ๋ ฅํ ๊ธฐ๋ฅ๋ค์ ํ์ฉํ ์ ์์ด์ NFT ๋ฐ์ดํฐ ์ฒ๋ฆฌ ์๋๊ฐ ํจ์ฌ ๋นจ๋ผ์ ธ.
๐๏ธ ํ๋ก์ ํธ ์ํคํ ์ฒ ์ค๊ณํ๊ธฐ
๋ณธ๊ฒฉ์ ์ผ๋ก ์ฝ๋ฉํ๊ธฐ ์ ์, ์ฐ๋ฆฌ ํ๋ก์ ํธ์ ์ ์ฒด ๊ตฌ์กฐ๋ฅผ ๋จผ์ ์ค๊ณํด์ผ ํด. ์ด๊ฒ ์ ๋ง ์ค์ํ๋ฐ, ๋์ค์ ๊ธฐ๋ฅ์ ์ถ๊ฐํ๊ฑฐ๋ ์์ ํ ๋ ํจ์ฌ ํธํด์ง๊ฑฐ๋ ! ๐๏ธ
๐จ ์์คํ ๊ตฌ์ฑ ์์
์ฐ๋ฆฌ NFT ๋์๋ณด๋๋ ํฌ๊ฒ 5๊ฐ์ ๋ ์ด์ด๋ก ๊ตฌ์ฑ๋ ๊ฑฐ์ผ:
1. ๋ฐ์ดํฐ ์์ง ๋ ์ด์ด (Data Collection Layer)
- ๋ธ๋ก์ฒด์ธ API์ ํต์ ํ๋ ๋ถ๋ถ
- OpenSea API, Etherscan API, Alchemy ๋ฑ์ ํ์ฉ
- ์ค์๊ฐ ์ด๋ฒคํธ ๋ฆฌ์ค๋
2. ๋ฐ์ดํฐ ์ฒ๋ฆฌ ๋ ์ด์ด (Data Processing Layer)
- ์์งํ ์์ ๋ฐ์ดํฐ๋ฅผ ๊ฐ๊ณต
- ํต๊ณ ๊ณ์ฐ ๋ฐ ํธ๋ ๋ ๋ถ์
- ๋ฐ์ดํฐ ์ ๊ทํ ๋ฐ ๊ฒ์ฆ
3. ์ ์ฅ ๋ ์ด์ด (Storage Layer)
- MySQL/PostgreSQL: ๊ตฌ์กฐํ๋ ๋ฐ์ดํฐ
- Redis: ์บ์ฑ ๋ฐ ์ค์๊ฐ ๋ฐ์ดํฐ
- MongoDB: ๋น๊ตฌ์กฐํ ๋ฉํ๋ฐ์ดํฐ (์ ํ์ฌํญ)
4. API ๋ ์ด์ด (API Layer)
- RESTful API ์๋ํฌ์ธํธ
- GraphQL (์ ํ์ฌํญ)
- WebSocket for ์ค์๊ฐ ์
๋ฐ์ดํธ
5. ํ๋ ์ ํ
์ด์
๋ ์ด์ด (Presentation Layer)
- Vue.js ๋๋ React ๊ธฐ๋ฐ SPA
- Chart.js, D3.js๋ก ๋ฐ์ดํฐ ์๊ฐํ
- ๋ฐ์ํ ๋์์ธ
Step 1: ๋ฐ์ดํฐ๋ฒ ์ด์ค ์คํค๋ง ์ค๊ณ
NFT ๋ฐ์ดํฐ๋ฅผ ํจ์จ์ ์ผ๋ก ์ ์ฅํ๋ ค๋ฉด ์ ์ค๊ณ๋ ๋ฐ์ดํฐ๋ฒ ์ด์ค ์คํค๋ง๊ฐ ํ์์ผ. ์ ๋ค์ด์ด๊ทธ๋จ์์ ๋ณธ ๊ฒ์ฒ๋ผ 4๊ฐ์ ํต์ฌ ํ
์ด๋ธ์ ๋ง๋ค ๊ฑฐ์ผ.
collections ํ
์ด๋ธ: NFT ์ปฌ๋ ์
์ ๊ธฐ๋ณธ ์ ๋ณด
nft_items ํ
์ด๋ธ: ๊ฐ๋ณ NFT ์์ดํ
์ ๋ณด
transactions ํ
์ด๋ธ: ๊ฑฐ๋ ๋ด์ญ
analytics ํ
์ด๋ธ: ์ง๊ณ๋ ํต๊ณ ๋ฐ์ดํฐ
์ด๋ ๊ฒ ์ ๊ทํ๋ ๊ตฌ์กฐ๋ฅผ ์ฌ์ฉํ๋ฉด ๋ฐ์ดํฐ ์ค๋ณต์ ํผํ๊ณ , ์ฟผ๋ฆฌ ์ฑ๋ฅ๋ ์ต์ ํํ ์ ์์ด! ๐ฏ
โ๏ธ ๊ฐ๋ฐ ํ๊ฒฝ ์ธํ ํ๊ธฐ
์, ์ด์ ์ค์ ๋ก ์์ ๋๋ฝํ๋ณผ ์๊ฐ์ด์ผ! ๊ฐ๋ฐ ํ๊ฒฝ์ ์ธํ ํ๋ ๊ฒ๋ถํฐ ์์ํด๋ณด์. ์ด ๊ณผ์ ์ด ์ข ์ง๋ฃจํ ์ ์์ง๋ง, ์ ๋๋ก ์ธํ ํด๋๋ฉด ๋์ค์ ์ ๋ง ํธํด์ ธ. ๐
๐ ๏ธ ํ์ํ ๋๊ตฌ๋ค
1. PHP 8.1 ์ด์
๋ผ๋ผ๋ฒจ 10์ ์ฌ์ฉํ๋ ค๋ฉด PHP 8.1 ์ด์์ด ํ์ํด. ์ต์ ๋ฒ์ ์ผ์๋ก ์ฑ๋ฅ์ด ์ข์ผ๋๊น ๊ฐ๋ฅํ๋ฉด PHP 8.2๋ฅผ ์ค์นํ๋ ๊ฑธ ์ถ์ฒํด!
2. Composer
PHP์ ์์กด์ฑ ๊ด๋ฆฌ ๋๊ตฌ์ผ. ๋ผ๋ผ๋ฒจ๊ณผ ๊ฐ์ข
ํจํค์ง๋ฅผ ์ค์นํ ๋ ํ์์ ์ด์ผ.
3. Node.js & NPM
ํ๋ก ํธ์๋ ์์ฐ์ ๋น๋ํ๋ ๋ฐ ํ์ํด. Vue.js๋ React๋ฅผ ์ฌ์ฉํ ๊ฑฐ๋๊น ๊ผญ ์ค์นํด์ผ ํด.
4. MySQL ๋๋ PostgreSQL
๋ฉ์ธ ๋ฐ์ดํฐ๋ฒ ์ด์ค์ผ. ๊ฐ์ธ์ ์ผ๋ก๋ PostgreSQL์ ์ถ์ฒํ๋๋ฐ, JSON ์ปฌ๋ผ ์ฒ๋ฆฌ๊ฐ ๋ ๊ฐ๋ ฅํ๊ฑฐ๋ !
5. Redis
์บ์ฑ๊ณผ ํ ์์
์ ์ฌ์ฉํ ๊ฑฐ์ผ. ์ฑ๋ฅ ํฅ์์ ์ ๋ง ํฐ ๋์์ด ๋ผ.
6. Docker (์ ํ์ฌํญ)
๊ฐ๋ฐ ํ๊ฒฝ์ ์ปจํ
์ด๋๋ก ๊ด๋ฆฌํ๋ฉด ํ์๋ค๊ณผ ํ์
ํ ๋ ์ ๋ง ํธํด. Laravel Sail์ ์ฌ์ฉํ๋ฉด Docker ์ค์ ๋ ์ฌ์!
Step 2: ๋ผ๋ผ๋ฒจ ํ๋ก์ ํธ ์์ฑ
ํฐ๋ฏธ๋์ ์ด๊ณ ๋ค์ ๋ช ๋ น์ด๋ฅผ ์คํํด๋ณด์:
composer create-project laravel/laravel nft-dashboard
cd nft-dashboard
php artisan --version
๋ง์ง๋ง ๋ช ๋ น์ด๋ก ๋ผ๋ผ๋ฒจ์ด ์ ๋๋ก ์ค์น๋์๋์ง ํ์ธํ ์ ์์ด. Laravel Framework 10.x.x ๊ฐ์ ๋ฉ์์ง๊ฐ ๋์ค๋ฉด ์ฑ๊ณต์ด์ผ! ๐
Step 3: ํ๊ฒฝ ์ค์ ํ์ผ ์์
ํ๋ก์ ํธ ๋ฃจํธ์ ์๋ .env ํ์ผ์ ์ด์ด์ ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ค์ ์ ํด์ค์ผ ํด:
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=nft_dashboard
DB_USERNAME=your_username
DB_PASSWORD=your_password
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
QUEUE_CONNECTION=redis
CACHE_DRIVER=redis
SESSION_DRIVER=redis
์ฌ๊ธฐ์ ์ค์ํ ๊ฑด QUEUE_CONNECTION๊ณผ CACHE_DRIVER๋ฅผ Redis๋ก ์ค์ ํ๋ ๊ฑฐ์ผ. ์ด๋ ๊ฒ ํ๋ฉด ๋ฐฑ๊ทธ๋ผ์ด๋ ์์ ๊ณผ ์บ์ฑ์ด ํจ์ฌ ๋นจ๋ผ์ ธ! โก
๐ก Laravel Sail ์ฌ์ฉํ๊ธฐ
Docker๋ฅผ ์ฌ์ฉํ๊ณ ์ถ๋ค๋ฉด Laravel Sail์ด ์ ๋ง ํธํด. ๋ค์ ๋ช
๋ น์ด๋ก ๊ฐ๋จํ๊ฒ ์ค์นํ ์ ์์ด:
composer require laravel/sail --dev
php artisan sail:install
๊ทธ ๋ค์ ./vendor/bin/sail up ๋ช
๋ น์ด๋ก ๋ชจ๋ ์๋น์ค๋ฅผ ํ ๋ฒ์ ์คํํ ์ ์์ด. MySQL, Redis, Mailhog ๋ฑ์ด ์๋์ผ๋ก ์ค์ ๋ผ!
Step 4: ํ์ ํจํค์ง ์ค์น
NFT ๋์๋ณด๋๋ฅผ ๋ง๋ค ๋ ์ ์ฉํ ํจํค์ง๋ค์ ์ค์นํด๋ณด์:
# HTTP ํด๋ผ์ด์ธํธ (API ํธ์ถ์ฉ)
composer require guzzlehttp/guzzle
# ํ ๋ชจ๋ํฐ๋ง
composer require laravel/horizon
# API ๋ฆฌ์์ค ๋ณํ
composer require spatie/laravel-query-builder
# ๋ฐ์ดํฐ ๊ฒ์ฆ ๋ฐ ๋ณํ
composer require spatie/laravel-data
# WebSocket ์ง์
composer require beyondcode/laravel-websockets
# ์ค์ผ์ค๋ง ๋ชจ๋ํฐ๋ง
composer require spatie/laravel-schedule-monitor
์ด ํจํค์ง๋ค์ ๊ฐ๊ฐ ํน๋ณํ ์ญํ ์ ํด. ํนํ Laravel Horizon์ ํ ์์ ์ ๋ชจ๋ํฐ๋งํ๋ ๋ฐ ์ ๋ง ์ ์ฉํด. NFT ๋ฐ์ดํฐ๋ฅผ ๋ฐฑ๊ทธ๋ผ์ด๋์์ ์์งํ ๋ ์ด๋ค ์์ ์ด ์งํ ์ค์ธ์ง ํ๋์ ๋ณผ ์ ์๊ฑฐ๋ ! ๐
๐๏ธ ๋ฐ์ดํฐ๋ฒ ์ด์ค ๋ง์ด๊ทธ๋ ์ด์ ์์ฑํ๊ธฐ
์ด์ ๋ณธ๊ฒฉ์ ์ผ๋ก ๋ฐ์ดํฐ๋ฒ ์ด์ค ๊ตฌ์กฐ๋ฅผ ๋ง๋ค์ด๋ณผ ๊ฑฐ์ผ. ๋ผ๋ผ๋ฒจ์ ๋ง์ด๊ทธ๋ ์ด์ ์์คํ ์ ์ฌ์ฉํ๋ฉด ๋ฐ์ดํฐ๋ฒ ์ด์ค ์คํค๋ง๋ฅผ ์ฝ๋๋ก ๊ด๋ฆฌํ ์ ์์ด์ ์ ๋ง ํธํด! ๐๏ธ
Step 5: ๋ง์ด๊ทธ๋ ์ด์ ํ์ผ ์์ฑ
๋ค์ ๋ช ๋ น์ด๋ค๋ก ํ์ํ ๋ง์ด๊ทธ๋ ์ด์ ํ์ผ๋ค์ ์์ฑํด๋ณด์:
php artisan make:migration create_collections_table
php artisan make:migration create_nft_items_table
php artisan make:migration create_transactions_table
php artisan make:migration create_analytics_table
์ด์ ๊ฐ ๋ง์ด๊ทธ๋ ์ด์ ํ์ผ์ ์ด์ด์ ํ ์ด๋ธ ๊ตฌ์กฐ๋ฅผ ์ ์ํด์ผ ํด. ํ๋์ฉ ์ดํด๋ณด์!
๐ Collections ํ ์ด๋ธ
database/migrations/xxxx_create_collections_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('collections', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->string('contract_address')->unique();
$table->string('blockchain')->default('ethereum');
$table->text('description')->nullable();
$table->string('image_url')->nullable();
$table->string('banner_url')->nullable();
$table->decimal('floor_price', 20, 8)->default(0);
$table->decimal('volume_24h', 20, 8)->default(0);
$table->decimal('volume_7d', 20, 8)->default(0);
$table->decimal('volume_30d', 20, 8)->default(0);
$table->decimal('volume_total', 20, 8)->default(0);
$table->integer('total_supply')->default(0);
$table->integer('owners_count')->default(0);
$table->decimal('average_price', 20, 8)->default(0);
$table->json('metadata')->nullable();
$table->timestamp('last_synced_at')->nullable();
$table->timestamps();
// ์ธ๋ฑ์ค ์ถ๊ฐ
$table->index('blockchain');
$table->index('floor_price');
$table->index('volume_24h');
$table->index('last_synced_at');
});
}
public function down(): void
{
Schema::dropIfExists('collections');
}
};
์ฌ๊ธฐ์ ์ฃผ๋ชฉํ ์ ์ด ๋ช ๊ฐ์ง ์์ด:
1. decimal ํ์
์ฌ์ฉ
๊ฐ๊ฒฉ ์ ๋ณด๋ decimal(20, 8)๋ก ์ ์ฅํด. ์ํธํํ๋ ์์์ ์ดํ ์๋ฆฟ์๊ฐ ๋ง์์ float๋ double์ ์ฌ์ฉํ๋ฉด ์ ํ๋ ๋ฌธ์ ๊ฐ ์๊ธธ ์ ์๊ฑฐ๋ ! ๐ฐ
2. JSON ์ปฌ๋ผ
metadata ์ปฌ๋ผ์ JSON ํ์
์ผ๋ก ๋ง๋ค์ด. NFT ์ปฌ๋ ์
๋ง๋ค ๋ค๋ฅธ ์์ฑ๋ค์ ์ ์ฐํ๊ฒ ์ ์ฅํ ์ ์์ด.
3. ์ธ๋ฑ์ค ์ถ๊ฐ
์์ฃผ ๊ฒ์ํ๊ฑฐ๋ ์ ๋ ฌํ ์ปฌ๋ผ์๋ ์ธ๋ฑ์ค๋ฅผ ์ถ๊ฐํด์ผ ํด. ํนํ floor_price๋ volume_24h ๊ฐ์ ์ปฌ๋ผ์ ๋์๋ณด๋์์ ์์ฃผ ์ฌ์ฉ๋๋๊น ๊ผญ ์ธ๋ฑ์ค๋ฅผ ๊ฑธ์ด์ค์ผ ํด!
๐ NFT Items ํ ์ด๋ธ
๊ฐ๋ณ NFT ์์ดํ ์ ๋ณด๋ฅผ ์ ์ฅํ๋ ํ ์ด๋ธ์ด์ผ:
Schema::create('nft_items', function (Blueprint $table) {
$table->id();
$table->foreignId('collection_id')->constrained()->onDelete('cascade');
$table->string('token_id');
$table->string('owner_address')->index();
$table->string('token_uri')->nullable();
$table->string('image_url')->nullable();
$table->string('name')->nullable();
$table->text('description')->nullable();
$table->json('attributes')->nullable();
$table->decimal('last_price', 20, 8)->nullable();
$table->decimal('highest_price', 20, 8)->nullable();
$table->integer('transfer_count')->default(0);
$table->timestamp('last_transfer_at')->nullable();
$table->boolean('is_listed')->default(false);
$table->decimal('listing_price', 20, 8)->nullable();
$table->string('listing_marketplace')->nullable();
$table->timestamps();
// ๋ณตํฉ ์ ๋ํฌ ์ธ๋ฑ์ค
$table->unique(['collection_id', 'token_id']);
$table->index('is_listed');
$table->index('last_price');
});
์ฌ๊ธฐ์ foreignId๋ฅผ ์ฌ์ฉํด์ collections ํ ์ด๋ธ๊ณผ ๊ด๊ณ๋ฅผ ์ค์ ํ์ด. onDelete('cascade')๋ฅผ ์ถ๊ฐํ๋ฉด ์ปฌ๋ ์ ์ด ์ญ์ ๋ ๋ ๊ด๋ จ๋ NFT ์์ดํ ๋ค๋ ์๋์ผ๋ก ์ญ์ ๋ผ. ํธ๋ฆฌํ์ง? ๐
๐ Transactions ํ ์ด๋ธ
NFT ๊ฑฐ๋ ๋ด์ญ์ ์ ์ฅํ๋ ํ ์ด๋ธ์ด์ผ. ์ด ๋ฐ์ดํฐ๋ก ๊ฐ๊ฒฉ ์ถ์ด๋ฅผ ๋ถ์ํ ์ ์์ด:
Schema::create('transactions', function (Blueprint $table) {
$table->id();
$table->foreignId('nft_item_id')->constrained()->onDelete('cascade');
$table->string('transaction_hash')->unique();
$table->string('from_address')->index();
$table->string('to_address')->index();
$table->decimal('price', 20, 8);
$table->string('currency')->default('ETH');
$table->decimal('price_usd', 20, 2)->nullable();
$table->string('marketplace')->nullable();
$table->enum('transaction_type', ['sale', 'transfer', 'mint', 'burn']);
$table->integer('block_number');
$table->timestamp('transaction_date');
$table->json('raw_data')->nullable();
$table->timestamps();
$table->index('transaction_date');
$table->index('price');
$table->index('transaction_type');
});
transaction_hash๋ ๋ธ๋ก์ฒด์ธ์์ ๊ณ ์ ํ ๊ฐ์ด๋๊น ์ ๋ํฌ ์ธ๋ฑ์ค๋ฅผ ๊ฑธ์ด์คฌ์ด. ๊ทธ๋ฆฌ๊ณ transaction_date๋ก ์๊ณ์ด ๋ถ์์ ํ ๊ฑฐ๋๊น ์ด๊ฒ๋ ์ธ๋ฑ์ค๋ฅผ ์ถ๊ฐํ์ด! ๐
๐ Analytics ํ ์ด๋ธ
์ง๊ณ๋ ํต๊ณ ๋ฐ์ดํฐ๋ฅผ ์ ์ฅํ๋ ํ ์ด๋ธ์ด์ผ. ๋งค๋ฒ ๊ณ์ฐํ๋ฉด ๋๋ฆฌ๋๊น ๋ฏธ๋ฆฌ ๊ณ์ฐํด์ ์ ์ฅํด๋๋ ๊ฑฐ์ง:
Schema::create('analytics', function (Blueprint $table) {
$table->id();
$table->foreignId('collection_id')->constrained()->onDelete('cascade');
$table->date('date');
$table->decimal('floor_price', 20, 8);
$table->decimal('average_price', 20, 8);
$table->decimal('highest_price', 20, 8);
$table->decimal('lowest_price', 20, 8);
$table->decimal('total_volume', 20, 8);
$table->integer('sales_count');
$table->integer('unique_buyers');
$table->integer('unique_sellers');
$table->decimal('market_cap', 20, 8)->nullable();
$table->timestamps();
$table->unique(['collection_id', 'date']);
$table->index('date');
});
collection_id์ date์ ์กฐํฉ์ ์ ๋ํฌ๋ก ์ค์ ํ์ด. ๊ฐ์ ๋ ์ง์ ๊ฐ์ ์ปฌ๋ ์ ์ ํต๊ณ๊ฐ ์ค๋ณต์ผ๋ก ์ ์ฅ๋๋ ๊ฑธ ๋ฐฉ์งํ๊ธฐ ์ํด์์ผ!
Step 6: ๋ง์ด๊ทธ๋ ์ด์ ์คํ
๋ชจ๋ ๋ง์ด๊ทธ๋ ์ด์ ํ์ผ์ ์์ฑํ์ผ๋ฉด ์ด์ ์คํํด๋ณด์:
php artisan migrate
์๋ฌ ์์ด ์คํ๋๋ฉด ์ฑ๊ณต์ด์ผ! ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ํ ์ด๋ธ๋ค์ด ์์ฑ๋์์ ๊ฑฐ์ผ. ํ์ธํ๊ณ ์ถ์ผ๋ฉด MySQL ํด๋ผ์ด์ธํธ๋ phpMyAdmin์ผ๋ก ๋ค์ด๊ฐ์ ํ์ธํด๋ด. ๐ฏ
โ ๏ธ ์ฃผ์์ฌํญ
๋ง์ด๊ทธ๋ ์ด์
์ ์คํํ ํ์ ์คํค๋ง๋ฅผ ๋ณ๊ฒฝํ๊ณ ์ถ์ผ๋ฉด ์๋ก์ด ๋ง์ด๊ทธ๋ ์ด์
ํ์ผ์ ๋ง๋ค์ด์ผ ํด. ๊ธฐ์กด ํ์ผ์ ์์ ํ๋ฉด ์ ๋ผ! ์ด๋ฏธ ์คํ๋ ๋ง์ด๊ทธ๋ ์ด์
์ ๋ค์ ์คํ๋์ง ์๊ฑฐ๋ . ๋ณ๊ฒฝ์ด ํ์ํ๋ฉด php artisan make:migration add_column_to_table ๊ฐ์ ๋ช
๋ น์ด๋ก ์ ๋ง์ด๊ทธ๋ ์ด์
์ ๋ง๋ค์ด์ค.
๐จ Eloquent ๋ชจ๋ธ ๋ง๋ค๊ธฐ
๋ฐ์ดํฐ๋ฒ ์ด์ค ํ ์ด๋ธ์ ๋ง๋ค์์ผ๋ ์ด์ ๋ผ๋ผ๋ฒจ์ Eloquent ORM์ ์ฌ์ฉํด์ ๋ชจ๋ธ์ ๋ง๋ค์ด์ผ ํด. ๋ชจ๋ธ์ ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ์ํธ์์ฉํ๋ ์ธํฐํ์ด์ค ์ญํ ์ ํด. ์ ๋ง ๊ฐ๋ ฅํ ๊ธฐ๋ฅ์ด์ผ! ๐ช
Step 7: ๋ชจ๋ธ ํ์ผ ์์ฑ
php artisan make:model Collection
php artisan make:model NftItem
php artisan make:model Transaction
php artisan make:model Analytics
์ด์ ๊ฐ ๋ชจ๋ธ ํ์ผ์ ์ด์ด์ ๊ด๊ณ(Relationship)์ ์์ฑ๋ค์ ์ ์ํด๋ณด์!
๐ฆ Collection ๋ชจ๋ธ
app/Models/Collection.php ํ์ผ:
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Collection extends Model
{
use HasFactory;
protected $fillable = [
'name',
'slug',
'contract_address',
'blockchain',
'description',
'image_url',
'banner_url',
'floor_price',
'volume_24h',
'volume_7d',
'volume_30d',
'volume_total',
'total_supply',
'owners_count',
'average_price',
'metadata',
'last_synced_at',
];
protected $casts = [
'floor_price' => 'decimal:8',
'volume_24h' => 'decimal:8',
'volume_7d' => 'decimal:8',
'volume_30d' => 'decimal:8',
'volume_total' => 'decimal:8',
'average_price' => 'decimal:8',
'metadata' => 'array',
'last_synced_at' => 'datetime',
];
// ๊ด๊ณ ์ ์
public function nftItems(): HasMany
{
return $this->hasMany(NftItem::class);
}
public function transactions(): HasMany
{
return $this->hasManyThrough(Transaction::class, NftItem::class);
}
public function analytics(): HasMany
{
return $this->hasMany(Analytics::class);
}
// ํฌํผ ๋ฉ์๋
public function getFloorPriceInUsdAttribute(): float
{
// ETH ๊ฐ๊ฒฉ์ USD๋ก ๋ณํ (์ค์ ๋ก๋ API์์ ํ์จ์ ๊ฐ์ ธ์์ผ ํจ)
$ethToUsd = 2000; // ์์ ํ์จ
return $this->floor_price * $ethToUsd;
}
public function getVolumeChangePercentageAttribute(): float
{
if ($this->volume_7d == 0) {
return 0;
}
return (($this->volume_24h * 7 - $this->volume_7d) / $this->volume_7d) * 100;
}
// ์ค์ฝํ
public function scopePopular($query)
{
return $query->orderBy('volume_24h', 'desc');
}
public function scopeTrending($query)
{
return $query->where('volume_24h', '>', 0)
->orderByRaw('(volume_24h / NULLIF(volume_7d, 0)) DESC');
}
}
์, ์ฝ๋๊ฐ ์ข ๊ธธ์ง? ํ๋์ฉ ์ค๋ช
ํด์ค๊ฒ! ๐
$fillable ์์ฑ
๋๋ ํ ๋น(Mass Assignment)์ ํ์ฉํ ์ปฌ๋ผ๋ค์ ์ ์ํด. ๋ณด์์ ์ํด ๊ผญ ํ์ํ ์ปฌ๋ผ๋ง ํฌํจ์์ผ์ผ ํด!
$casts ์์ฑ
๋ฐ์ดํฐ๋ฒ ์ด์ค์์ ๊ฐ์ ธ์จ ๊ฐ์ ์๋์ผ๋ก ๋ณํํด์ค. metadata๋ JSON์ผ๋ก ์ ์ฅ๋์ง๋ง ๋ฐฐ์ด๋ก ์๋ ๋ณํ๋๊ณ , ๋ ์ง๋ Carbon ์ธ์คํด์ค๋ก ๋ณํ๋ผ.
๊ด๊ณ ๋ฉ์๋
hasMany๋ก ์ผ๋๋ค ๊ด๊ณ๋ฅผ ์ ์ํ์ด. ํ๋์ ์ปฌ๋ ์
์ ์ฌ๋ฌ NFT ์์ดํ
์ ๊ฐ์ง ์ ์์ง!
Accessor (์ ๊ทผ์)
getFloorPriceInUsdAttribute ๊ฐ์ ๋ฉ์๋๋ ๊ฐ์ ์์ฑ์ ๋ง๋ค์ด์ค. $collection->floor_price_in_usd๋ก ์ ๊ทผํ ์ ์์ด!
Query Scope
scopePopular ๊ฐ์ ๋ฉ์๋๋ ์ฌ์ฌ์ฉ ๊ฐ๋ฅํ ์ฟผ๋ฆฌ๋ฅผ ๋ง๋ค์ด์ค. Collection::popular()->get()์ฒ๋ผ ์ฌ์ฉํ ์ ์์ด!
๐ฆ NftItem ๋ชจ๋ธ
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class NftItem extends Model
{
protected $fillable = [
'collection_id',
'token_id',
'owner_address',
'token_uri',
'image_url',
'name',
'description',
'attributes',
'last_price',
'highest_price',
'transfer_count',
'last_transfer_at',
'is_listed',
'listing_price',
'listing_marketplace',
];
protected $casts = [
'attributes' => 'array',
'last_price' => 'decimal:8',
'highest_price' => 'decimal:8',
'listing_price' => 'decimal:8',
'is_listed' => 'boolean',
'last_transfer_at' => 'datetime',
];
public function collection(): BelongsTo
{
return $this->belongsTo(Collection::class);
}
public function transactions(): HasMany
{
return $this->hasMany(Transaction::class);
}
// ๊ฐ๊ฒฉ ๋ณ๋๋ฅ ๊ณ์ฐ
public function getPriceChangePercentageAttribute(): ?float
{
if (!$this->last_price || !$this->highest_price) {
return null;
}
return (($this->last_price - $this->highest_price) / $this->highest_price) * 100;
}
// ํฌ๊ท๋ ์ ์ ๊ณ์ฐ (attributes ๊ธฐ๋ฐ)
public function getRarityScoreAttribute(): float
{
if (!$this->attributes) {
return 0;
}
$score = 0;
foreach ($this->attributes as $attribute) {
// ์ค์ ๋ก๋ ๋ ๋ณต์กํ ํฌ๊ท๋ ๊ณ์ฐ ๋ก์ง์ด ํ์ํจ
$score += 1 / ($attribute['occurrence'] ?? 1);
}
return $score;
}
public function scopeListed($query)
{
return $query->where('is_listed', true);
}
public function scopeByOwner($query, string $address)
{
return $query->where('owner_address', $address);
}
}
NFT ์์ดํ ๋ชจ๋ธ์๋ ํฌ๊ท๋ ์ ์๋ฅผ ๊ณ์ฐํ๋ ๋ก์ง๋ ์ถ๊ฐํ์ด. ์ค์ ๋ก๋ ๋ ์ ๊ตํ ์๊ณ ๋ฆฌ์ฆ์ด ํ์ํ์ง๋ง, ๊ธฐ๋ณธ ๊ฐ๋ ์ ์ดํดํ์ ๊ฑฐ์ผ! ๐ฏ
๐ก ์ฑ๋ฅ ์ต์ ํ ํ
NFT ๋ฐ์ดํฐ๋ ์์ด ์ ๋ง ๋ง์์ง ์ ์์ด. ์๋ฐฑ๋ง ๊ฐ์ ์์ดํ
์ด ์์ ์๋ ์์ง! ๊ทธ๋์ ์ฟผ๋ฆฌํ ๋๋ ํญ์ select()๋ก ํ์ํ ์ปฌ๋ผ๋ง ๊ฐ์ ธ์ค๊ณ , with()๋ก Eager Loading์ ์ฌ์ฉํด์ N+1 ์ฟผ๋ฆฌ ๋ฌธ์ ๋ฅผ ๋ฐฉ์งํด์ผ ํด.
์: NftItem::with('collection')->select('id', 'name', 'collection_id')->get()
๐ ๋ธ๋ก์ฒด์ธ API ์ฐ๋ํ๊ธฐ
์, ์ด์ ์ง์ง ์ฌ๋ฏธ์๋ ๋ถ๋ถ์ด์ผ! ์ค์ ๋ธ๋ก์ฒด์ธ ๋ฐ์ดํฐ๋ฅผ ๊ฐ์ ธ์๋ณด์. ์ฌ๋ฌ API ์๋น์ค๊ฐ ์๋๋ฐ, ์ฐ๋ฆฌ๋ OpenSea API์ Alchemy๋ฅผ ์ฌ์ฉํ ๊ฑฐ์ผ. ๐
๐ API ํค ๋ฐ๊ธ๋ฐ๊ธฐ
๋จผ์ ๋ค์ ์๋น์ค๋ค์ ๊ฐ์
ํ๊ณ API ํค๋ฅผ ๋ฐ๊ธ๋ฐ์์ผ ํด:
1. OpenSea API
- https://docs.opensea.io/reference/api-overview
- NFT ๋ฉํ๋ฐ์ดํฐ์ ๊ฑฐ๋ ์ ๋ณด๋ฅผ ๊ฐ์ ธ์ฌ ์ ์์ด
- ๋ฌด๋ฃ ํฐ์ด๋ ์ถฉ๋ถํ ์ฌ์ฉ ๊ฐ๋ฅํด!
2. Alchemy
- https://www.alchemy.com/
- ์ด๋๋ฆฌ์ ๋
ธ๋ ์ ๊ทผ๊ณผ NFT API ์ ๊ณต
- ์ 300๋ง ์์ฒญ๊น์ง ๋ฌด๋ฃ์ผ!
3. Etherscan API (์ ํ์ฌํญ)
- https://etherscan.io/apis
- ํธ๋์ญ์
์์ธ ์ ๋ณด ์กฐํ์ฉ
API ํค๋ฅผ ๋ฐ๊ธ๋ฐ์์ผ๋ฉด .env ํ์ผ์ ์ถ๊ฐํด์ค:
OPENSEA_API_KEY=your_opensea_api_key
ALCHEMY_API_KEY=your_alchemy_api_key
ETHERSCAN_API_KEY=your_etherscan_api_key
Step 8: API ์๋น์ค ํด๋์ค ๋ง๋ค๊ธฐ
API ํธ์ถ ๋ก์ง์ ๋ณ๋์ ์๋น์ค ํด๋์ค๋ก ๋ถ๋ฆฌํ๋ฉด ์ฝ๋๊ฐ ๊น๋ํด์ ธ. app/Services ๋๋ ํ ๋ฆฌ๋ฅผ ๋ง๋ค๊ณ ์์ํด๋ณด์!
mkdir app/Services
touch app/Services/OpenSeaService.php
touch app/Services/AlchemyService.php
๐ OpenSeaService ํด๋์ค
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Cache;
class OpenSeaService
{
private string $baseUrl = 'https://api.opensea.io/api/v2';
private string $apiKey;
public function __construct()
{
$this->apiKey = config('services.opensea.api_key');
}
/**
* ์ปฌ๋ ์
์ ๋ณด ๊ฐ์ ธ์ค๊ธฐ
*/
public function getCollection(string $slug): ?array
{
$cacheKey = "opensea_collection_{$slug}";
return Cache::remember($cacheKey, 3600, function () use ($slug) {
$response = Http::withHeaders([
'X-API-KEY' => $this->apiKey,
])->get("{$this->baseUrl}/collections/{$slug}");
if ($response->successful()) {
return $response->json();
}
return null;
});
}
/**
* ์ปฌ๋ ์
ํต๊ณ ๊ฐ์ ธ์ค๊ธฐ
*/
public function getCollectionStats(string $slug): ?array
{
$cacheKey = "opensea_stats_{$slug}";
return Cache::remember($cacheKey, 300, function () use ($slug) {
$response = Http::withHeaders([
'X-API-KEY' => $this->apiKey,
])->get("{$this->baseUrl}/collections/{$slug}/stats");
if ($response->successful()) {
return $response->json()['stats'] ?? null;
}
return null;
});
}
/**
* NFT ์์ดํ
์ ๋ณด ๊ฐ์ ธ์ค๊ธฐ
*/
public function getNFT(string $contractAddress, string $tokenId): ?array
{
$cacheKey = "opensea_nft_{$contractAddress}_{$tokenId}";
return Cache::remember($cacheKey, 1800, function () use ($contractAddress, $tokenId) {
$response = Http::withHeaders([
'X-API-KEY' => $this->apiKey,
])->get("{$this->baseUrl}/chain/ethereum/contract/{$contractAddress}/nfts/{$tokenId}");
if ($response->successful()) {
return $response->json()['nft'] ?? null;
}
return null;
});
}
/**
* ์ต๊ทผ ๊ฑฐ๋ ๋ด์ญ ๊ฐ์ ธ์ค๊ธฐ
*/
public function getRecentSales(string $contractAddress, int $limit = 50): array
{
$response = Http::withHeaders([
'X-API-KEY' => $this->apiKey,
])->get("{$this->baseUrl}/events/chain/ethereum/contract/{$contractAddress}", [
'event_type' => 'sale',
'limit' => $limit,
]);
if ($response->successful()) {
return $response->json()['asset_events'] ?? [];
}
return [];
}
/**
* ํธ๋ ๋ฉ ์ปฌ๋ ์
๊ฐ์ ธ์ค๊ธฐ
*/
public function getTrendingCollections(int $limit = 10): array
{
$cacheKey = "opensea_trending_{$limit}";
return Cache::remember($cacheKey, 600, function () use ($limit) {
$response = Http::withHeaders([
'X-API-KEY' => $this->apiKey,
])->get("{$this->baseUrl}/collections", [
'order_by' => 'seven_day_volume',
'limit' => $limit,
]);
if ($response->successful()) {
return $response->json()['collections'] ?? [];
}
return [];
});
}
}
์ฌ๊ธฐ์ ์ค์ํ ํฌ์ธํธ๋ ์บ์ฑ์ด์ผ! API ํธ์ถ์ ๋น์ฉ์ด ๋ค๊ณ ๋๋ฆด ์ ์์ด์, Redis ์บ์๋ฅผ ์ฌ์ฉํด์ ๊ฐ์ ์์ฒญ์ ๋ฐ๋ณตํ์ง ์๋๋ก ํ์ด. ํนํ ์ปฌ๋ ์ ์ ๋ณด ๊ฐ์ ๊ฑด ์์ฃผ ๋ฐ๋์ง ์์ผ๋๊น 1์๊ฐ(3600์ด) ๋์ ์บ์ํด๋ ๊ด์ฐฎ์! โก
โ๏ธ AlchemyService ํด๋์ค
namespace App\Services;
use Illuminate\Support\Facades\Http;
class AlchemyService
{
private string $baseUrl;
private string $apiKey;
public function __construct()
{
$this->apiKey = config('services.alchemy.api_key');
$this->baseUrl = "https://eth-mainnet.g.alchemy.com/v2/{$this->apiKey}";
}
/**
* NFT ๋ฉํ๋ฐ์ดํฐ ๊ฐ์ ธ์ค๊ธฐ
*/
public function getNFTMetadata(string $contractAddress, string $tokenId): ?array
{
$response = Http::get("{$this->baseUrl}/getNFTMetadata", [
'contractAddress' => $contractAddress,
'tokenId' => $tokenId,
'tokenType' => 'ERC721',
]);
if ($response->successful()) {
return $response->json();
}
return null;
}
/**
* ์ปฌ๋ ์
์ ๋ชจ๋ NFT ๊ฐ์ ธ์ค๊ธฐ (ํ์ด์ง๋ค์ด์
)
*/
public function getNFTsForCollection(
string $contractAddress,
?string $pageKey = null,
int $limit = 100
): array {
$params = [
'contractAddress' => $contractAddress,
'withMetadata' => true,
'limit' => $limit,
];
if ($pageKey) {
$params['pageKey'] = $pageKey;
}
$response = Http::get("{$this->baseUrl}/getNFTsForCollection", $params);
if ($response->successful()) {
return $response->json();
}
return ['nfts' => [], 'pageKey' => null];
}
/**
* ์์ ์์ NFT ๋ชฉ๋ก ๊ฐ์ ธ์ค๊ธฐ
*/
public function getNFTsForOwner(string $ownerAddress): array
{
$response = Http::get("{$this->baseUrl}/getNFTs", [
'owner' => $ownerAddress,
'withMetadata' => true,
]);
if ($response->successful()) {
return $response->json()['ownedNfts'] ?? [];
}
return [];
}
/**
* ํธ๋์ญ์
์์์ฆ ๊ฐ์ ธ์ค๊ธฐ
*/
public function getTransactionReceipt(string $txHash): ?array
{
$response = Http::post($this->baseUrl, [
'jsonrpc' => '2.0',
'id' => 1,
'method' => 'eth_getTransactionReceipt',
'params' => [$txHash],
]);
if ($response->successful()) {
return $response->json()['result'];
}
return null;
}
/**
* ๋ธ๋ก ๋ฒํธ๋ก NFT ์ ์ก ์ด๋ฒคํธ ๊ฐ์ ธ์ค๊ธฐ
*/
public function getNFTTransfers(
string $contractAddress,
int $fromBlock,
int $toBlock
): array {
$response = Http::post($this->baseUrl, [
'jsonrpc' => '2.0',
'id' => 1,
'method' => 'alchemy_getAssetTransfers',
'params' => [
[
'fromBlock' => '0x' . dechex($fromBlock),
'toBlock' => '0x' . dechex($toBlock),
'contractAddresses' => [$contractAddress],
'category' => ['erc721', 'erc1155'],
'withMetadata' => true,
]
],
]);
if ($response->successful()) {
return $response->json()['result']['transfers'] ?? [];
}
return [];
}
}
Alchemy๋ ๋ ๋ก์ฐ๋ ๋ฒจ์ ๋ธ๋ก์ฒด์ธ ๋ฐ์ดํฐ์ ์ ๊ทผํ ์ ์์ด. ํนํ getNFTTransfers ๋ฉ์๋๋ ํน์ ๋ธ๋ก ๋ฒ์์ ๋ชจ๋ NFT ์ ์ก์ ์ถ์ ํ ์ ์์ด์ ์ ๋ง ์ ์ฉํด! ๐
Step 9: ์๋น์ค ํ๋ก๋ฐ์ด๋ ๋ฑ๋ก
์๋น์ค ํด๋์ค๋ฅผ ๋ง๋ค์์ผ๋ฉด config/services.php์ API ํค ์ค์ ์ ์ถ๊ฐํด์ค:
return [
// ... ๊ธฐ์กด ์ค์ ๋ค
'opensea' => [
'api_key' => env('OPENSEA_API_KEY'),
],
'alchemy' => [
'api_key' => env('ALCHEMY_API_KEY'),
],
'etherscan' => [
'api_key' => env('ETHERSCAN_API_KEY'),
],
];
โ ๏ธ API ์ฌ์ฉ๋ ์ ํ ์ฃผ์
๋ฌด๋ฃ ํฐ์ด๋ ์์ฒญ ํ์ ์ ํ์ด ์์ด. OpenSea๋ ๋ถ๋น 2-4๊ฐ, Alchemy๋ ์ด๋น 25๊ฐ ์ ๋์ผ. ๋๋์ ๋ฐ์ดํฐ๋ฅผ ์์งํ ๋๋ Rate Limiting์ ๊ตฌํํด์ผ ํด. ๋ผ๋ผ๋ฒจ์ RateLimiter ํ์ฌ๋๋ฅผ ์ฌ์ฉํ๊ฑฐ๋, ํ ์์
์ฌ์ด์ ๋๋ ์ด๋ฅผ ์ถ๊ฐํ๋ ๋ฐฉ๋ฒ์ด ์์ด!
โ๏ธ ๋ฐ์ดํฐ ์์ง Job ๋ง๋ค๊ธฐ
API ์๋น์ค๋ฅผ ๋ง๋ค์์ผ๋ ์ด์ ์ค์ ๋ก ๋ฐ์ดํฐ๋ฅผ ์์งํ๋ Job์ ๋ง๋ค์ด์ผ ํด. ๋ผ๋ผ๋ฒจ์ ํ ์์คํ ์ ์ฌ์ฉํ๋ฉด ๋ฐฑ๊ทธ๋ผ์ด๋์์ ํจ์จ์ ์ผ๋ก ๋ฐ์ดํฐ๋ฅผ ์์งํ ์ ์์ด! ๐
Step 10: Job ํด๋์ค ์์ฑ
php artisan make:job SyncCollectionData
php artisan make:job SyncNFTItems
php artisan make:job SyncTransactions
php artisan make:job CalculateAnalytics
๐ SyncCollectionData Job
์ปฌ๋ ์ ์ ๊ธฐ๋ณธ ์ ๋ณด์ ํต๊ณ๋ฅผ ๋๊ธฐํํ๋ Job์ด์ผ:
namespace App\Jobs;
use App\Models\Collection;
use App\Services\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 SyncCollectionData implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 3;
public $timeout = 120;
public function __construct(
private string $collectionSlug
) {}
public function handle(OpenSeaService $openSea): void
{
try {
// ์ปฌ๋ ์
์ ๋ณด ๊ฐ์ ธ์ค๊ธฐ
$collectionData = $openSea->getCollection($this->collectionSlug);
if (!$collectionData) {
Log::warning("Collection not found: {$this->collectionSlug}");
return;
}
// ํต๊ณ ์ ๋ณด ๊ฐ์ ธ์ค๊ธฐ
$stats = $openSea->getCollectionStats($this->collectionSlug);
// ๋ฐ์ดํฐ๋ฒ ์ด์ค ์
๋ฐ์ดํธ
$collection = Collection::updateOrCreate(
['slug' => $this->collectionSlug],
[
'name' => $collectionData['name'] ?? '',
'contract_address' => $collectionData['primary_asset_contracts'][0]['address'] ?? '',
'description' => $collectionData['description'] ?? '',
'image_url' => $collectionData['image_url'] ?? '',
'banner_url' => $collectionData['banner_image_url'] ?? '',
'floor_price' => $stats['floor_price'] ?? 0,
'volume_24h' => $stats['one_day_volume'] ?? 0,
'volume_7d' => $stats['seven_day_volume'] ?? 0,
'volume_30d' => $stats['thirty_day_volume'] ?? 0,
'volume_total' => $stats['total_volume'] ?? 0,
'total_supply' => $stats['total_supply'] ?? 0,
'owners_count' => $stats['num_owners'] ?? 0,
'average_price' => $stats['average_price'] ?? 0,
'metadata' => [
'discord_url' => $collectionData['discord_url'] ?? null,
'twitter_username' => $collectionData['twitter_username'] ?? null,
'instagram_username' => $collectionData['instagram_username'] ?? null,
'external_url' => $collectionData['external_url'] ?? null,
],
'last_synced_at' => now(),
]
);
Log::info("Collection synced: {$collection->name}");
// NFT ์์ดํ
๋๊ธฐํ Job ๋์คํจ์น
SyncNFTItems::dispatch($collection->id)->delay(now()->addSeconds(5));
} catch (\Exception $e) {
Log::error("Error syncing collection {$this->collectionSlug}: " . $e->getMessage());
throw $e;
}
}
public function failed(\Throwable $exception): void
{
Log::error("Failed to sync collection {$this->collectionSlug}: " . $exception->getMessage());
}
}
์ฌ๊ธฐ์ ์ฃผ๋ชฉํ ์ ๋ค:
1. ์ฌ์๋ ๋ก์ง
$tries = 3์ผ๋ก ์ค์ ํด์ ์คํจํ๋ฉด ์ต๋ 3๋ฒ๊น์ง ์ฌ์๋ํด. API๊ฐ ์ผ์์ ์ผ๋ก ๋ค์ด๋์์ ๋ ์ ์ฉํด!
2. ํ์์์
$timeout = 120์ผ๋ก 2๋ถ ์ ํ์ ๋ฌ. ๋๋ฌด ์ค๋ ๊ฑธ๋ฆฌ๋ ์์
์ ์๋์ผ๋ก ์คํจ ์ฒ๋ฆฌ๋ผ.
3. ์ฒด์ด๋
์ปฌ๋ ์
๋๊ธฐํ๊ฐ ๋๋๋ฉด ์๋์ผ๋ก NFT ์์ดํ
๋๊ธฐํ Job์ ๋์คํจ์นํด. 5์ด ๋๋ ์ด๋ฅผ ์ค์ API Rate Limit์ ํผํด! ๐ฏ
4. ์๋ฌ ํธ๋ค๋ง
failed() ๋ฉ์๋๋ก ์คํจํ Job์ ๋ก๊น
ํด. ๋์ค์ ๋ฌธ์ ๋ฅผ ์ถ์ ํ๊ธฐ ์ฌ์์ ธ.
๐จ SyncNFTItems Job
namespace App\Jobs;
use App\Models\Collection;
use App\Models\NftItem;
use App\Services\AlchemyService;
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 SyncNFTItems implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 3;
public $timeout = 300;
public function __construct(
private int $collectionId
) {}
public function handle(AlchemyService $alchemy): void
{
$collection = Collection::find($this->collectionId);
if (!$collection) {
return;
}
try {
$pageKey = null;
$totalSynced = 0;
do {
// ํ์ด์ง๋ค์ด์
์ผ๋ก NFT ๊ฐ์ ธ์ค๊ธฐ
$result = $alchemy->getNFTsForCollection(
$collection->contract_address,
$pageKey,
100
);
foreach ($result['nfts'] ?? [] as $nftData) {
$this->syncNFTItem($collection, $nftData);
$totalSynced++;
}
$pageKey = $result['pageKey'] ?? null;
// Rate limiting์ ์ํ ์งง์ ๋๊ธฐ
if ($pageKey) {
sleep(1);
}
} while ($pageKey && $totalSynced < 1000); // ์ต๋ 1000๊ฐ๊น์ง๋ง
Log::info("Synced {$totalSynced} NFT items for collection: {$collection->name}");
} catch (\Exception $e) {
Log::error("Error syncing NFT items for collection {$this->collectionId}: " . $e->getMessage());
throw $e;
}
}
private function syncNFTItem(Collection $collection, array $nftData): void
{
$tokenId = $nftData['id']['tokenId'] ?? null;
if (!$tokenId) {
return;
}
NftItem::updateOrCreate(
[
'collection_id' => $collection->id,
'token_id' => $tokenId,
],
[
'owner_address' => $nftData['contract']['address'] ?? '',
'token_uri' => $nftData['tokenUri']['raw'] ?? '',
'image_url' => $nftData['media'][0]['gateway'] ?? '',
'name' => $nftData['title'] ?? "#{$tokenId}",
'description' => $nftData['description'] ?? '',
'attributes' => $nftData['metadata']['attributes'] ?? [],
]
);
}
}
NFT ์์ดํ ์ ์๊ฐ ์ ๋ง ๋ง์ ์ ์์ด์ ํ์ด์ง๋ค์ด์ ์ ์ฌ์ฉํ์ด. ํ ๋ฒ์ 100๊ฐ์ฉ ๊ฐ์ ธ์ค๊ณ , ๊ฐ ํ์ด์ง ์ฌ์ด์ 1์ด์ฉ ๋๊ธฐํด์ API Rate Limit์ ํผํด! ๐ค
๐ก ๋์ฉ๋ ๋ฐ์ดํฐ ์ฒ๋ฆฌ ํ
์๋ง ๊ฐ์ NFT๋ฅผ ๋๊ธฐํํด์ผ ํ๋ค๋ฉด? ์ด Job์ ์ฌ๋ฌ ๊ฐ๋ก ์ชผ๊ฐ๋ ๊ฒ ์ข์. ์๋ฅผ ๋ค์ด 1000๊ฐ์ฉ ์ฒ๋ฆฌํ๋ Job์ ์ฌ๋ฌ ๊ฐ ๋ง๋ค์ด์ ๋ณ๋ ฌ๋ก ์คํํ๋ฉด ํจ์ฌ ๋นจ๋ผ์ ธ. ๋ผ๋ผ๋ฒจ์ Bus::batch()๋ฅผ ์ฌ์ฉํ๋ฉด ๋ฐฐ์น ์์
๋ ์ฝ๊ฒ ๊ด๋ฆฌํ ์ ์์ด!
๐ธ SyncTransactions Job
namespace App\Jobs;
use App\Models\Collection;
use App\Models\NftItem;
use App\Models\Transaction;
use App\Services\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 SyncTransactions implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 3;
public $timeout = 180;
public function __construct(
private int $collectionId
) {}
public function handle(OpenSeaService $openSea): void
{
$collection = Collection::find($this->collectionId);
if (!$collection) {
return;
}
try {
$sales = $openSea->getRecentSales($collection->contract_address, 100);
$syncedCount = 0;
foreach ($sales as $sale) {
if ($this->syncTransaction($collection, $sale)) {
$syncedCount++;
}
}
Log::info("Synced {$syncedCount} transactions for collection: {$collection->name}");
// ํต๊ณ ๊ณ์ฐ Job ๋์คํจ์น
CalculateAnalytics::dispatch($collection->id)->delay(now()->addMinutes(1));
} catch (\Exception $e) {
Log::error("Error syncing transactions for collection {$this->collectionId}: " . $e->getMessage());
throw $e;
}
}
private function syncTransaction(Collection $collection, array $saleData): bool
{
$txHash = $saleData['transaction']['transaction_hash'] ?? null;
if (!$txHash) {
return false;
}
// ์ด๋ฏธ ์กด์ฌํ๋ ๊ฑฐ๋๋ ์คํต
if (Transaction::where('transaction_hash', $txHash)->exists()) {
return false;
}
$tokenId = $saleData['nft']['identifier'] ?? null;
if (!$tokenId) {
return false;
}
// NFT ์์ดํ
์ฐพ๊ธฐ
$nftItem = NftItem::where('collection_id', $collection->id)
->where('token_id', $tokenId)
->first();
if (!$nftItem) {
return false;
}
// ๊ฐ๊ฒฉ ์ ๋ณด ์ถ์ถ
$payment = $saleData['payment'] ?? [];
$price = isset($payment['quantity']) ?
$payment['quantity'] / pow(10, $payment['decimals'] ?? 18) : 0;
Transaction::create([
'nft_item_id' => $nftItem->id,
'transaction_hash' => $txHash,
'from_address' => $saleData['seller']['address'] ?? '',
'to_address' => $saleData['winner_account']['address'] ?? '',
'price' => $price,
'currency' => $payment['symbol'] ?? 'ETH',
'price_usd' => $saleData['payment']['usd_price'] ?? null,
'marketplace' => 'OpenSea',
'transaction_type' => 'sale',
'block_number' => $saleData['transaction']['block_number'] ?? 0,
'transaction_date' => $saleData['event_timestamp'] ?? now(),
'raw_data' => $saleData,
]);
// NFT ์์ดํ
์ ์ต๊ทผ ๊ฐ๊ฒฉ ์
๋ฐ์ดํธ
$nftItem->update([
'last_price' => $price,
'highest_price' => max($nftItem->highest_price ?? 0, $price),
'transfer_count' => $nftItem->transfer_count + 1,
'last_transfer_at' => now(),
]);
return true;
}
}
๊ฑฐ๋ ๋ฐ์ดํฐ๋ฅผ ๋๊ธฐํํ๋ฉด์ NFT ์์ดํ ์ ๊ฐ๊ฒฉ ์ ๋ณด๋ ํจ๊ป ์ ๋ฐ์ดํธํด. ์ด๋ ๊ฒ ํ๋ฉด ์ต์ ๊ฐ๊ฒฉ์ ๋น ๋ฅด๊ฒ ์กฐํํ ์ ์์ด! ๐
๐ ํต๊ณ ๋ถ์ ๋ก์ง ๊ตฌํํ๊ธฐ
์ด์ ์์งํ ๋ฐ์ดํฐ๋ฅผ ๋ถ์ํด์ ์๋ฏธ ์๋ ํต๊ณ๋ฅผ ๋ง๋ค์ด๋ณด์! ์ด ๋ถ๋ถ์ด ๋์๋ณด๋์ ํต์ฌ์ด์ผ. ๐ฏ
๐ CalculateAnalytics Job
namespace App\Jobs;
use App\Models\Analytics;
use App\Models\Collection;
use App\Models\Transaction;
use Carbon\Carbon;
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\DB;
use Illuminate\Support\Facades\Log;
class CalculateAnalytics implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 2;
public $timeout = 300;
public function __construct(
private int $collectionId,
private ?Carbon $date = null
) {
$this->date = $this->date ?? now()->subDay();
}
public function handle(): void
{
$collection = Collection::find($this->collectionId);
if (!$collection) {
return;
}
try {
$analytics = $this->calculateDailyAnalytics($collection, $this->date);
Analytics::updateOrCreate(
[
'collection_id' => $collection->id,
'date' => $this->date->toDateString(),
],
$analytics
);
Log::info("Calculated analytics for {$collection->name} on {$this->date->toDateString()}");
} catch (\Exception $e) {
Log::error("Error calculating analytics: " . $e->getMessage());
throw $e;
}
}
private function calculateDailyAnalytics(Collection $collection, Carbon $date): array
{
$startOfDay = $date->copy()->startOfDay();
$endOfDay = $date->copy()->endOfDay();
// ํด๋น ๋ ์ง์ ๋ชจ๋ ๊ฑฐ๋ ๊ฐ์ ธ์ค๊ธฐ
$transactions = Transaction::whereHas('nftItem', function ($query) use ($collection) {
$query->where('collection_id', $collection->id);
})
->whereBetween('transaction_date', [$startOfDay, $endOfDay])
->where('transaction_type', 'sale')
->get();
if ($transactions->isEmpty()) {
return $this->getEmptyAnalytics();
}
// ๊ธฐ๋ณธ ํต๊ณ ๊ณ์ฐ
$prices = $transactions->pluck('price');
$totalVolume = $prices->sum();
$salesCount = $transactions->count();
$averagePrice = $prices->average();
$floorPrice = $prices->min();
$highestPrice = $prices->max();
$lowestPrice = $prices->min();
// ๊ณ ์ ๊ตฌ๋งค์/ํ๋งค์ ์
$uniqueBuyers = $transactions->pluck('to_address')->unique()->count();
$uniqueSellers = $transactions->pluck('from_address')->unique()->count();
// ์๊ฐ์ด์ก ๊ณ์ฐ (floor price * total supply)
$marketCap = $floorPrice * $collection->total_supply;
return [
'floor_price' => $floorPrice,
'average_price' => $averagePrice,
'highest_price' => $highestPrice,
'lowest_price' => $lowestPrice,
'total_volume' => $totalVolume,
'sales_count' => $salesCount,
'unique_buyers' => $uniqueBuyers,
'unique_sellers' => $uniqueSellers,
'market_cap' => $marketCap,
];
}
private function getEmptyAnalytics(): array
{
return [
'floor_price' => 0,
'average_price' => 0,
'highest_price' => 0,
'lowest_price' => 0,
'total_volume' => 0,
'sales_count' => 0,
'unique_buyers' => 0,
'unique_sellers' => 0,
'market_cap' => 0,
];
}
}
์ด Job์ ํ๋ฃจ ๋จ์๋ก ํต๊ณ๋ฅผ ๊ณ์ฐํด. ๋งค์ผ ์์ ์ ์คํ๋๋๋ก ์ค์ผ์ค๋งํ๋ฉด ๋ผ! ๐
Step 11: ์ค์ผ์ค๋ง ์ค์
app/Console/Kernel.php ํ์ผ์ ์ด์ด์ ์ค์ผ์ค์ ์ค์ ํด๋ณด์:
namespace App\Console;
use App\Jobs\CalculateAnalytics;
use App\Jobs\SyncCollectionData;
use App\Models\Collection;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
class Kernel extends ConsoleKernel
{
protected function schedule(Schedule $schedule): void
{
// ๋งค์๊ฐ๋ง๋ค ๋ชจ๋ ์ปฌ๋ ์
์ ๋ฐ์ดํฐ ๋๊ธฐํ
$schedule->call(function () {
Collection::all()->each(function ($collection) {
SyncCollectionData::dispatch($collection->slug);
});
})->hourly();
// ๋งค์ผ ์์ ์ ์ ๋ ํต๊ณ ๊ณ์ฐ
$schedule->call(function () {
Collection::all()->each(function ($collection) {
CalculateAnalytics::dispatch($collection->id);
});
})->dailyAt('00:30');
// Horizon ์ค๋
์ท (ํ ๋ชจ๋ํฐ๋ง์ฉ)
$schedule->command('horizon:snapshot')->everyFiveMinutes();
// ์คํจํ Job ์ ๋ฆฌ
$schedule->command('queue:prune-failed --hours=48')->daily();
}
}
์ค์ผ์ค๋ฌ๊ฐ ์๋ํ๋ ค๋ฉด ์๋ฒ์ cron์ ๋ค์ ๋ช ๋ น์ด๋ฅผ ์ถ๊ฐํด์ผ ํด:
* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1
์ด๋ ๊ฒ ํ๋ฉด ๋งค๋ถ๋ง๋ค ๋ผ๋ผ๋ฒจ ์ค์ผ์ค๋ฌ๊ฐ ์คํ๋๊ณ , ์ค์ ํ ์๊ฐ์ ๋ง์ถฐ์ Job๋ค์ด ์๋์ผ๋ก ์คํ๋ผ! โฐ
๐ก Laravel Horizon ํ์ฉํ๊ธฐ
ํ ์์
์ ๋ชจ๋ํฐ๋งํ๋ ค๋ฉด Horizon์ ์ค์นํ๊ณ ์ค์ ํด์ผ ํด:
php artisan horizon:install
php artisan horizon
๊ทธ๋ฌ๋ฉด http://your-domain.com/horizon์์ ์ค์๊ฐ์ผ๋ก ํ ์ํ๋ฅผ ํ์ธํ ์ ์์ด. ์ด๋ค Job์ด ์คํ ์ค์ธ์ง, ์คํจํ Job์ ์๋์ง ํ๋์ ๋ณผ ์ ์์ด์ ์ ๋ง ํธํด! ๐๏ธ
๐จ API ์๋ํฌ์ธํธ ๋ง๋ค๊ธฐ
๋ฐฑ์๋์์ ๋ฐ์ดํฐ๋ฅผ ์์งํ๊ณ ์ฒ๋ฆฌํ์ผ๋, ์ด์ ํ๋ก ํธ์๋์์ ์ฌ์ฉํ API๋ฅผ ๋ง๋ค์ด์ผ ํด. RESTful API๋ฅผ ์ค๊ณํด๋ณด์! ๐
Step 12: API ์ปจํธ๋กค๋ฌ ์์ฑ
php artisan make:controller Api/CollectionController
php artisan make:controller Api/NftItemController
php artisan make:controller Api/AnalyticsController
php artisan make:controller Api/DashboardController
๐ฏ CollectionController
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Collection;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CollectionController extends Controller
{
/**
* ์ปฌ๋ ์
๋ชฉ๋ก ์กฐํ
*/
public function index(Request $request): JsonResponse
{
$query = Collection::query();
// ํํฐ๋ง
if ($request->has('blockchain')) {
$query->where('blockchain', $request->blockchain);
}
// ์ ๋ ฌ
$sortBy = $request->get('sort_by', 'volume_24h');
$sortOrder = $request->get('sort_order', 'desc');
$query->orderBy($sortBy, $sortOrder);
// ํ์ด์ง๋ค์ด์
$perPage = min($request->get('per_page', 20), 100);
$collections = $query->paginate($perPage);
return response()->json($collections);
}
/**
* ํน์ ์ปฌ๋ ์
์์ธ ์กฐํ
*/
public function show(string $slug): JsonResponse
{
$collection = Collection::where('slug', $slug)
->with(['analytics' => function ($query) {
$query->orderBy('date', 'desc')->limit(30);
}])
->firstOrFail();
return response()->json([
'collection' => $collection,
'stats' => [
'floor_price' => $collection->floor_price,
'volume_24h' => $collection->volume_24h,
'volume_7d' => $collection->volume_7d,
'owners_count' => $collection->owners_count,
'total_supply' => $collection->total_supply,
'average_price' => $collection->average_price,
],
]);
}
/**
* ํธ๋ ๋ฉ ์ปฌ๋ ์
*/
public function trending(): JsonResponse
{
$collections = Collection::trending()
->limit(10)
->get();
return response()->json($collections);
}
/**
* ์ธ๊ธฐ ์ปฌ๋ ์
*/
public function popular(): JsonResponse
{
$collections = Collection::popular()
->limit(10)
->get();
return response()->json($collections);
}
}
API ์ปจํธ๋กค๋ฌ๋ ๊ฐ๋จํ๊ฒ ์ ์งํ๋ ๊ฒ ์ข์. ๋ณต์กํ ๋ก์ง์ Service ํด๋์ค๋ Model์ ๋ฃ๊ณ , ์ปจํธ๋กค๋ฌ๋ ์์ฒญ์ ๋ฐ์์ ์๋ต๋ง ๋๋ ค์ฃผ๋ ์ญํ ๋ง ํด์ผ ํด! ๐ฏ
๐ AnalyticsController
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Analytics;
use App\Models\Collection;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class AnalyticsController extends Controller
{
/**
* ์ปฌ๋ ์
์ ์๊ณ์ด ๋ฐ์ดํฐ
*/
public function timeSeries(string $slug, Request $request): JsonResponse
{
$collection = Collection::where('slug', $slug)->firstOrFail();
$days = min($request->get('days', 30), 365);
$startDate = now()->subDays($days);
$analytics = Analytics::where('collection_id', $collection->id)
->where('date', '>=', $startDate)
->orderBy('date', 'asc')
->get();
return response()->json([
'collection' => $collection->only(['name', 'slug', 'image_url']),
'period' => [
'start' => $startDate->toDateString(),
'end' => now()->toDateString(),
'days' => $days,
],
'data' => $analytics->map(function ($item) {
return [
'date' => $item->date,
'floor_price' => $item->floor_price,
'average_price' => $item->average_price,
'volume' => $item->total_volume,
'sales_count' => $item->sales_count,
];
}),
]);
}
/**
* ๊ฐ๊ฒฉ ๋ณ๋ ๋ถ์
*/
public function priceChange(string $slug): JsonResponse
{
$collection = Collection::where('slug', $slug)->firstOrFail();
$today = Analytics::where('collection_id', $collection->id)
->whereDate('date', today())
->first();
$yesterday = Analytics::where('collection_id', $collection->id)
->whereDate('date', today()->subDay())
->first();
$weekAgo = Analytics::where('collection_id', $collection->id)
->whereDate('date', today()->subWeek())
->first();
return response()->json([
'current_floor_price' => $collection->floor_price,
'changes' => [
'24h' => $this->calculateChange($yesterday?->floor_price, $today?->floor_price),
'7d' => $this->calculateChange($weekAgo?->floor_price, $today?->floor_price),
],
'volume_changes' => [
'24h' => $this->calculateChange($yesterday?->total_volume, $today?->total_volume),
'7d' => $this->calculateChange($weekAgo?->total_volume, $today?->total_volume),
],
]);
}
private function calculateChange(?float $old, ?float $new): array
{
if (!$old || !$new) {
return ['value' => 0, 'percentage' => 0];
}
$change = $new - $old;
$percentage = ($change / $old) * 100;
return [
'value' => round($change, 4),
'percentage' => round($percentage, 2),
];
}
/**
* ์์ฅ ๊ฐ์
*/
public function marketOverview(): JsonResponse
{
$totalVolume = Collection::sum('volume_24h');
$totalCollections = Collection::count();
$avgFloorPrice = Collection::avg('floor_price');
$topCollections = Collection::orderBy('volume_24h', 'desc')
->limit(5)
->get(['name', 'slug', 'volume_24h', 'floor_price']);
return response()->json([
'market_stats' => [
'total_volume_24h' => $totalVolume,
'total_collections' => $totalCollections,
'average_floor_price' => $avgFloorPrice,
],
'top_collections' => $topCollections,
]);
}
}
ํต๊ณ API๋ ๋์๋ณด๋์ ์ฐจํธ๋ฅผ ๊ทธ๋ฆฌ๋ ๋ฐ ์ฌ์ฉ๋ ๊ฑฐ์ผ. ์๊ณ์ด ๋ฐ์ดํฐ, ๊ฐ๊ฒฉ ๋ณ๋, ์์ฅ ๊ฐ์ ๋ฑ ๋ค์ํ ์ ๋ณด๋ฅผ ์ ๊ณตํด! ๐
Step 13: API ๋ผ์ฐํธ ์ค์
routes/api.php ํ์ผ์ ์ด์ด์ ๋ผ์ฐํธ๋ฅผ ์ ์ํด๋ณด์:
use App\Http\Controllers\Api\AnalyticsController;
use App\Http\Controllers\Api\CollectionController;
use App\Http\Controllers\Api\DashboardController;
use App\Http\Controllers\Api\NftItemController;
use Illuminate\Support\Facades\Route;
// ๊ณต๊ฐ API (์ธ์ฆ ๋ถํ์)
Route::prefix('v1')->group(function () {
// ์ปฌ๋ ์
Route::get('/collections', [CollectionController::class, 'index']);
Route::get('/collections/trending', [CollectionController::class, 'trending']);
Route::get('/collections/popular', [CollectionController::class, 'popular']);
Route::get('/collections/{slug}', [CollectionController::class, 'show']);
// NFT ์์ดํ
Route::get('/collections/{slug}/items', [NftItemController::class, 'index']);
Route::get('/items/{id}', [NftItemController::class, 'show']);
// ๋ถ์
Route::get('/analytics/{slug}/timeseries', [AnalyticsController::class, 'timeSeries']);
Route::get('/analytics/{slug}/price-change', [AnalyticsController::class, 'priceChange']);
Route::get('/analytics/market-overview', [AnalyticsController::class, 'marketOverview']);
// ๋์๋ณด๋
Route::get('/dashboard', [DashboardController::class, 'index']);
});
// ์ธ์ฆ์ด ํ์ํ API
Route::middleware('auth:sanctum')->prefix('v1')->group(function () {
Route::post('/collections/{slug}/watch', [CollectionController::class, 'watch']);
Route::delete('/collections/{slug}/unwatch', [CollectionController::class, 'unwatch']);
});
API ๋ฒ์ ์ /v1๋ก ๋ช ์ํ์ด. ๋์ค์ API๋ฅผ ์ ๋ฐ์ดํธํ ๋ ํ์ ํธํ์ฑ์ ์ ์งํ๊ธฐ ์ํด์์ผ. ๊ทธ๋ฆฌ๊ณ ์ผ๋ถ ๊ธฐ๋ฅ์ ์ธ์ฆ์ด ํ์ํ๋๋ก auth:sanctum ๋ฏธ๋ค์จ์ด๋ฅผ ์ฌ์ฉํ์ด! ๐
โ ๏ธ API Rate Limiting
๊ณต๊ฐ API๋ ๋จ์ฉ์ ๋ฐฉ์งํ๊ธฐ ์ํด Rate Limiting์ ์ค์ ํด์ผ ํด. ๋ผ๋ผ๋ฒจ์ ๊ธฐ๋ณธ์ ์ผ๋ก ๋ถ๋น 60๊ฐ ์์ฒญ์ผ๋ก ์ ํ๋์ด ์์ด. app/Http/Kernel.php์์ ์กฐ์ ํ ์ ์์ด:
'api' => ['throttle:60,1', 'bindings']
๋ ์ธ๋ฐํ ์ ์ด๊ฐ ํ์ํ๋ฉด ์ปค์คํ
Rate Limiter๋ฅผ ๋ง๋ค ์๋ ์์ด!
๐จ ํ๋ก ํธ์๋ ๋์๋ณด๋ ๊ตฌ์ถํ๊ธฐ
๋ฐฑ์๋ API๊ฐ ์ค๋น๋์์ผ๋ ์ด์ ๋ฉ์ง ํ๋ก ํธ์๋ ๋์๋ณด๋๋ฅผ ๋ง๋ค์ด๋ณด์! Vue.js์ Chart.js๋ฅผ ์ฌ์ฉํ ๊ฑฐ์ผ. ๐จ
Step 14: ํ๋ก ํธ์๋ ํจํค์ง ์ค์น
npm install vue@next
npm install vue-router@4
npm install axios
npm install chart.js vue-chartjs
npm install @headlessui/vue
npm install @heroicons/vue
npm install tailwindcss@latest postcss@latest autoprefixer@latest
Tailwind CSS๋ฅผ ์ฌ์ฉํ๋ฉด ๋น ๋ฅด๊ฒ ์์ UI๋ฅผ ๋ง๋ค ์ ์์ด. ๊ทธ๋ฆฌ๊ณ Chart.js๋ ์ฐจํธ๋ฅผ ๊ทธ๋ฆฌ๋ ๋ฐ ์ต๊ณ ์ผ! ๐
๐จ ๋์๋ณด๋ ๋ฉ์ธ ์ปดํฌ๋ํธ
resources/js/components/Dashboard.vue ํ์ผ์ ๋ง๋ค์ด๋ณด์:
<template>
<div class="min-h-screen bg-gray-50">
<!-- ํค๋ -->
<header class="bg-white shadow">
<div class="max-w-7xl mx-auto px-4 py-6">
<h1 class="text-3xl font-bold text-gray-900">
NFT Analytics Dashboard
</h1>
</div>
</header>
<main class="max-w-7xl mx-auto px-4 py-8">
<!-- ์์ฅ ๊ฐ์ -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<StatCard
title="24h Volume"
:value="formatEth(marketStats.total_volume_24h)"
:change="volumeChange"
icon="chart-bar"
/>
<StatCard
title="Total Collections"
:value="marketStats.total_collections"
icon="collection"
/>
<StatCard
title="Avg Floor Price"
:value="formatEth(marketStats.average_floor_price)"
icon="currency-dollar"
/>
</div>
<!-- ํธ๋ ๋ฉ ์ปฌ๋ ์
-->
<div class="bg-white rounded-lg shadow mb-8">
<div class="px-6 py-4 border-b">
<h2 class="text-xl font-semibold">Trending Collections</h2>
</div>
<div class="p-6">
<CollectionList :collections="trendingCollections" />
</div>
</div>
<!-- ๊ฐ๊ฒฉ ์ฐจํธ -->
<div class="bg-white rounded-lg shadow">
<div class="px-6 py-4 border-b">
<h2 class="text-xl font-semibold">Price Trends</h2>
</div>
<div class="p-6">
<PriceChart :data="chartData" />
</div>
</div>
</main>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import axios from 'axios';
import StatCard from './StatCard.vue';
import CollectionList from './CollectionList.vue';
import PriceChart from './PriceChart.vue';
const marketStats = ref({
total_volume_24h: 0,
total_collections: 0,
average_floor_price: 0,
});
const trendingCollections = ref([]);
const chartData = ref(null);
const volumeChange = ref(0);
onMounted(async () => {
await loadDashboardData();
});
async function loadDashboardData() {
try {
// ์์ฅ ๊ฐ์ ๋ก๋
const marketResponse = await axios.get('/api/v1/analytics/market-overview');
marketStats.value = marketResponse.data.market_stats;
// ํธ๋ ๋ฉ ์ปฌ๋ ์
๋ก๋
const trendingResponse = await axios.get('/api/v1/collections/trending');
trendingCollections.value = trendingResponse.data;
// ์ฐจํธ ๋ฐ์ดํฐ ๋ก๋ (์ฒซ ๋ฒ์งธ ํธ๋ ๋ฉ ์ปฌ๋ ์
)
if (trendingCollections.value.length > 0) {
const slug = trendingCollections.value[0].slug;
const chartResponse = await axios.get(`/api/v1/analytics/${slug}/timeseries?days=30`);
chartData.value = chartResponse.data;
}
} catch (error) {
console.error('Failed to load dashboard data:', error);
}
}
function formatEth(value) {
return `${parseFloat(value).toFixed(2)} ETH`;
}
</script>
Vue 3์ Composition API๋ฅผ ์ฌ์ฉํ์ด. setup ํจ์์์ ๋ฐ์ดํฐ๋ฅผ ๋ก๋ํ๊ณ , ๋ฐ์ํ ๋ณ์๋ค์ ๊ด๋ฆฌํด. ์ ๋ง ๊น๋ํ์ง? ๐
๐ ์ฐจํธ ์ปดํฌ๋ํธ
resources/js/components/PriceChart.vue:
<template>
<div class="relative h-96">
<Line :data="chartData" :options="chartOptions" />
</div>
</template>
<script setup>
import { computed } from 'vue';
import { Line } from 'vue-chartjs';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler
} from 'chart.js';
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler
);
const props = defineProps({
data: {
type: Object,
required: true
}
});
const chartData = computed(() => {
if (!props.data || !props.data.data) {
return { labels: [], datasets: [] };
}
const labels = props.data.data.map(item => item.date);
const floorPrices = props.data.data.map(item => item.floor_price);
const volumes = props.data.data.map(item => item.volume);
return {
labels,
datasets: [
{
label: 'Floor Price',
data: floorPrices,
borderColor: 'rgb(102, 126, 234)',
backgroundColor: 'rgba(102, 126, 234, 0.1)',
fill: true,
tension: 0.4,
yAxisID: 'y',
},
{
label: 'Volume',
data: volumes,
borderColor: 'rgb(32, 201, 151)',
backgroundColor: 'rgba(32, 201, 151, 0.1)',
fill: true,
tension: 0.4,
yAxisID: 'y1',
}
]
};
});
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
plugins: {
legend: {
position: 'top',
},
tooltip: {
callbacks: {
label: function(context) {
let label = context.dataset.label || '';
if (label) {
label += ': ';
}
label += context.parsed.y.toFixed(4) + ' ETH';
return label;
}
}
}
},
scales: {
y: {
type: 'linear',
display: true,
position: 'left',
title: {
display: true,
text: 'Floor Price (ETH)'
}
},
y1: {
type: 'linear',
display: true,
position: 'right',
title: {
display: true,
text: 'Volume (ETH)'
},
grid: {
drawOnChartArea: false,
},
},
}
};
</script>
์ด ์ฐจํธ๋ Floor Price์ Volume์ ๋์์ ๋ณด์ฌ์ค. ๋ ๊ฐ์ Y์ถ์ ์ฌ์ฉํด์ ์๋ก ๋ค๋ฅธ ์ค์ผ์ผ์ ๋ฐ์ดํฐ๋ฅผ ํ ์ฐจํธ์ ํ์ํ ์ ์์ด! ๐
๐ก ์ค์๊ฐ ์
๋ฐ์ดํธ ์ถ๊ฐํ๊ธฐ
WebSocket์ ์ฌ์ฉํ๋ฉด ์ค์๊ฐ์ผ๋ก ๋ฐ์ดํฐ๋ฅผ ์
๋ฐ์ดํธํ ์ ์์ด. Laravel Echo์ Pusher๋ฅผ ์ฌ์ฉํ๋ฉด ์ฝ๊ฒ ๊ตฌํํ ์ ์์ด:
npm install laravel-echo pusher-js
๊ทธ๋ฆฌ๊ณ Vue ์ปดํฌ๋ํธ์์ Echo๋ฅผ ์ฌ์ฉํด์ ์ค์๊ฐ ์ด๋ฒคํธ๋ฅผ ๋ฆฌ์ค๋ํ๋ฉด ๋ผ. ์๋ก์ด ๊ฑฐ๋๊ฐ ๋ฐ์ํ ๋๋ง๋ค ๋์๋ณด๋๊ฐ ์๋์ผ๋ก ์
๋ฐ์ดํธ๋๋ ๊ฑฐ์ง! โก
๐ ๋ณด์ ๋ฐ ์ต์ ํ
๋์๋ณด๋๊ฐ ๊ฑฐ์ ์์ฑ๋์์ด! ํ์ง๋ง ํ๋ก๋์ ์ ๋ฐฐํฌํ๊ธฐ ์ ์ ๋ณด์๊ณผ ์ฑ๋ฅ ์ต์ ํ๋ฅผ ํด์ผ ํด. ์ด ๋ถ๋ถ์ด ์ ๋ง ์ค์ํด! ๐ก๏ธ
๐ ๋ณด์ ์ฒดํฌ๋ฆฌ์คํธ
1. API ํค ๋ณดํธ
์ ๋๋ก API ํค๋ฅผ ์ฝ๋์ ํ๋์ฝ๋ฉํ์ง ๋ง! .env ํ์ผ์ ์ ์ฅํ๊ณ , .gitignore์ ์ถ๊ฐํด์ Git์ ์ปค๋ฐ๋์ง ์๋๋ก ํด์ผ ํด.
2. CORS ์ค์
ํ๋ก ํธ์๋์ ๋ฐฑ์๋๊ฐ ๋ค๋ฅธ ๋๋ฉ์ธ์ ์๋ค๋ฉด CORS๋ฅผ ์ ๋๋ก ์ค์ ํด์ผ ํด. config/cors.php์์ ํ์ฉํ ๋๋ฉ์ธ์ ๋ช
์ํด์ค.
3. SQL Injection ๋ฐฉ์ง
๋ผ๋ผ๋ฒจ์ Eloquent ORM์ ์ฌ์ฉํ๋ฉด ์๋์ผ๋ก ๋ฐฉ์ง๋์ง๋ง, ์ง์ ์ฟผ๋ฆฌ๋ฅผ ์์ฑํ ๋๋ ํญ์ ํ๋ผ๋ฏธํฐ ๋ฐ์ธ๋ฉ์ ์ฌ์ฉํด์ผ ํด!
4. XSS ๋ฐฉ์ง
์ฌ์ฉ์ ์
๋ ฅ์ ์ถ๋ ฅํ ๋๋ ํญ์ ์ด์ค์ผ์ดํ ์ฒ๋ฆฌ๋ฅผ ํด์ผ ํด. Blade ํ
ํ๋ฆฟ์ {{ }}๋ ์๋์ผ๋ก ์ด์ค์ผ์ดํํด์ค.
5. CSRF ๋ณดํธ
๋ผ๋ผ๋ฒจ์ ๊ธฐ๋ณธ์ ์ผ๋ก CSRF ํ ํฐ์ ์ฌ์ฉํด. API๋ Sanctum์ ์ฌ์ฉํ๋ฉด ์๋์ผ๋ก ๋ณดํธ๋ผ!
โก ์ฑ๋ฅ ์ต์ ํ
1. ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ธ๋ฑ์ค
์์ฃผ ๊ฒ์ํ๋ ์ปฌ๋ผ์๋ ์ธ๋ฑ์ค๋ฅผ ์ถ๊ฐํด์ผ ํด. ํนํ WHERE, ORDER BY, JOIN์ ์ฌ์ฉ๋๋ ์ปฌ๋ผ๋ค!
2. Eager Loading
N+1 ์ฟผ๋ฆฌ ๋ฌธ์ ๋ฅผ ํผํ๋ ค๋ฉด ํญ์ with()๋ฅผ ์ฌ์ฉํด์ ๊ด๊ณ๋ฅผ ๋ฏธ๋ฆฌ ๋ก๋ํด์ผ ํด:
Collection::with('nftItems', 'analytics')->get()
3. ์บ์ฑ
์์ฃผ ์กฐํ๋์ง๋ง ์์ฃผ ๋ณ๊ฒฝ๋์ง ์๋ ๋ฐ์ดํฐ๋ Redis์ ์บ์ํด. ํนํ ํต๊ณ ๋ฐ์ดํฐ๋ ํธ๋ ๋ฉ ๋ชฉ๋ก ๊ฐ์ ๊ฑฐ!
4. ์ฟผ๋ฆฌ ์ต์ ํ
ํ์ํ ์ปฌ๋ผ๋ง ์ ํํ๊ณ , ์ง๊ณ ์ฟผ๋ฆฌ๋ ๋ฐ์ดํฐ๋ฒ ์ด์ค์์ ์ฒ๋ฆฌํด:
Collection::select('id', 'name', 'floor_price')->get()
5. ํ์ด์ง๋ค์ด์
๋๋์ ๋ฐ์ดํฐ๋ฅผ ํ ๋ฒ์ ๋ก๋ํ์ง ๋ง๊ณ ํ์ด์ง๋ค์ด์
์ ์ฌ์ฉํด. ๋ผ๋ผ๋ฒจ์ paginate()๋ ์ ๋ง ํธํด!
Step 15: ์บ์ฑ ์ ๋ต ๊ตฌํ
ํจ์จ์ ์ธ ์บ์ฑ ์ ๋ต์ ๊ตฌํํด๋ณด์. app/Services/CacheService.php ํ์ผ์ ๋ง๋ค์ด:
namespace App\Services;
use Illuminate\Support\Facades\Cache;
class CacheService
{
// ์บ์ ํค ์ ๋์ฌ
private const PREFIX = 'nft_dashboard:';
// TTL ์์ (์ด ๋จ์)
private const TTL_SHORT = 300; // 5๋ถ
private const TTL_MEDIUM = 1800; // 30๋ถ
private const TTL_LONG = 3600; // 1์๊ฐ
private const TTL_VERY_LONG = 86400; // 24์๊ฐ
/**
* ์ปฌ๋ ์
๋ฐ์ดํฐ ์บ์ฑ
*/
public function cacheCollection(string $slug, callable $callback): mixed
{
$key = self::PREFIX . "collection:{$slug}";
return Cache::remember($key, self::TTL_MEDIUM, $callback);
}
/**
* ํธ๋ ๋ฉ ์ปฌ๋ ์
์บ์ฑ
*/
public function cacheTrending(callable $callback): mixed
{
$key = self::PREFIX . 'trending';
return Cache::remember($key, self::TTL_SHORT, $callback);
}
/**
* ํต๊ณ ๋ฐ์ดํฐ ์บ์ฑ
*/
public function cacheAnalytics(string $slug, string $period, callable $callback): mixed
{
$key = self::PREFIX . "analytics:{$slug}:{$period}";
return Cache::remember($key, self::TTL_LONG, $callback);
}
/**
* ์์ฅ ๊ฐ์ ์บ์ฑ
*/
public function cacheMarketOverview(callable $callback): mixed
{
$key = self::PREFIX . 'market_overview';
return Cache::remember($key, self::TTL_SHORT, $callback);
}
/**
* ํน์ ์ปฌ๋ ์
์ ์บ์ ๋ฌดํจํ
*/
public function invalidateCollection(string $slug): void
{
Cache::forget(self::PREFIX . "collection:{$slug}");
Cache::forget(self::PREFIX . "analytics:{$slug}:*");
}
/**
* ๋ชจ๋ ์บ์ ๋ฌดํจํ
*/
public function invalidateAll(): void
{
Cache::tags(['nft_dashboard'])->flush();
}
}
์ด ์๋น์ค๋ฅผ ์ฌ์ฉํ๋ฉด ์บ์ฑ ๋ก์ง์ ์ค์์์ ๊ด๋ฆฌํ ์ ์์ด. TTL(Time To Live)๋ ๋ฐ์ดํฐ์ ํน์ฑ์ ๋ฐ๋ผ ๋ค๋ฅด๊ฒ ์ค์ ํ์ด! โฐ
โ ๏ธ ์บ์ ์๋ฐ์
์๋ฒ๋ฅผ ์ฌ์์ํ๊ฑฐ๋ ์บ์๋ฅผ ํด๋ฆฌ์ดํ ํ์๋ ์บ์๊ฐ ๋น์ด์์ด์ ์ฒซ ์์ฒญ์ด ๋๋ฆด ์ ์์ด. ์ด๋ฅผ ๋ฐฉ์งํ๋ ค๋ฉด "์บ์ ์๋ฐ์
"์ ํด์ผ ํด. ์ค์ผ์ค๋ฌ์ ๋ค์๊ณผ ๊ฐ์ ์์
์ ์ถ๊ฐํ๋ฉด ๋ผ:
$schedule->call(function () { /* ์ฃผ์ ๋ฐ์ดํฐ ๋ฏธ๋ฆฌ ์บ์ฑ */ })->everyFiveMinutes();
๐ ๋ฐฐํฌ ๋ฐ ์ด์
๋๋์ด ๋ง์ง๋ง ๋จ๊ณ์ผ! ์์ฑ๋ ๋์๋ณด๋๋ฅผ ์ค์ ์๋ฒ์ ๋ฐฐํฌํด๋ณด์. ๐
๐ฆ ๋ฐฐํฌ ์ค๋น
1. ํ๊ฒฝ ์ค์
ํ๋ก๋์
์๋ฒ์ .env ํ์ผ์ ์ ๋๋ก ์ค์ ํด์ผ ํด:
- APP_ENV=production
- APP_DEBUG=false
- APP_URL์ ์ค์ ๋๋ฉ์ธ์ผ๋ก ์ค์
- ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ ๋ณด ์
๋ฐ์ดํธ
- API ํค๋ค ์ค์
2. ์์กด์ฑ ์ค์น
ํ๋ก๋์
ํ๊ฒฝ์์๋ ๊ฐ๋ฐ ์์กด์ฑ์ ์ ์ธํ๊ณ ์ค์นํด:
composer install --optimize-autoloader --no-dev
3. ์์ฐ ๋น๋
ํ๋ก ํธ์๋ ์์ฐ์ ํ๋ก๋์
๋ชจ๋๋ก ๋น๋ํด:
npm run build
4. ์บ์ ์ต์ ํ
๋ผ๋ผ๋ฒจ์ ๊ฐ์ข
์บ์๋ฅผ ์์ฑํด์ ์ฑ๋ฅ์ ํฅ์์์ผ:
php artisan config:cache
php artisan route:cache
php artisan view:cache
5. ๋ง์ด๊ทธ๋ ์ด์
์คํ
๋ฐ์ดํฐ๋ฒ ์ด์ค ํ
์ด๋ธ์ ์์ฑํด:
php artisan migrate --force
๐ณ Docker๋ก ๋ฐฐํฌํ๊ธฐ
Docker๋ฅผ ์ฌ์ฉํ๋ฉด ๋ฐฐํฌ๊ฐ ์ ๋ง ์ฌ์์ ธ. Dockerfile์ ๋ง๋ค์ด๋ณด์:
FROM php:8.2-fpm
# ์์คํ
์์กด์ฑ ์ค์น
RUN apt-get update && apt-get install -y \
git \
curl \
libpng-dev \
libonig-dev \
libxml2-dev \
zip \
unzip \
nodejs \
npm
# PHP ํ์ฅ ์ค์น
RUN docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd
# Composer ์ค์น
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# ์์
๋๋ ํ ๋ฆฌ ์ค์
WORKDIR /var/www
# ์ ํ๋ฆฌ์ผ์ด์
ํ์ผ ๋ณต์ฌ
COPY . .
# ์์กด์ฑ ์ค์น
RUN composer install --optimize-autoloader --no-dev
RUN npm install && npm run build
# ๊ถํ ์ค์
RUN chown -R www-data:www-data /var/www
RUN chmod -R 755 /var/www/storage
EXPOSE 9000
CMD ["php-fpm"]
๊ทธ๋ฆฌ๊ณ docker-compose.yml๋ ๋ง๋ค์ด:
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: nft-dashboard-app
restart: unless-stopped
working_dir: /var/www
volumes:
- ./:/var/www
networks:
- nft-network
nginx:
image: nginx:alpine
container_name: nft-dashboard-nginx
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./:/var/www
- ./docker/nginx:/etc/nginx/conf.d
networks:
- nft-network
mysql:
image: mysql:8.0
container_name: nft-dashboard-mysql
restart: unless-stopped
environment:
MYSQL_DATABASE: nft_dashboard
MYSQL_ROOT_PASSWORD: secret
MYSQL_PASSWORD: secret
MYSQL_USER: nft_user
volumes:
- mysql-data:/var/lib/mysql
networks:
- nft-network
redis:
image: redis:alpine
container_name: nft-dashboard-redis
restart: unless-stopped
networks:
- nft-network
horizon:
build:
context: .
dockerfile: Dockerfile
container_name: nft-dashboard-horizon
restart: unless-stopped
command: php artisan horizon
volumes:
- ./:/var/www
networks:
- nft-network
networks:
nft-network:
driver: bridge
volumes:
mysql-data:
์ด์ docker-compose up -d ๋ช ๋ น์ด ํ๋๋ก ๋ชจ๋ ์๋น์ค๋ฅผ ์คํํ ์ ์์ด! ์ ๋ง ํธํ์ง? ๐ณ
๐ก ๋ชจ๋ํฐ๋ง ์ค์
ํ๋ก๋์
ํ๊ฒฝ์์๋ ๋ชจ๋ํฐ๋ง์ด ์ ๋ง ์ค์ํด. ๋ค์ ๋๊ตฌ๋ค์ ์ถ์ฒํด:
Laravel Telescope: ๊ฐ๋ฐ/์คํ
์ด์ง ํ๊ฒฝ์์ ๋๋ฒ๊น
์ฉ
Sentry: ์๋ฌ ์ถ์ ๋ฐ ์๋ฆผ
New Relic / DataDog: ์ฑ๋ฅ ๋ชจ๋ํฐ๋ง
Laravel Pulse: ์ค์๊ฐ ์ ํ๋ฆฌ์ผ์ด์
๋ชจ๋ํฐ๋ง
์ด๋ฐ ๋๊ตฌ๋ค์ ์ฌ์ฉํ๋ฉด ๋ฌธ์ ๊ฐ ์๊ฒผ์ ๋ ๋น ๋ฅด๊ฒ ๋์ํ ์ ์์ด!
๐ ์ถ๊ฐ ๊ธฐ๋ฅ ์์ด๋์ด
๊ธฐ๋ณธ ๋์๋ณด๋๋ ์์ฑํ์ง๋ง, ๋ ๋ฉ์ง ๊ธฐ๋ฅ๋ค์ ์ถ๊ฐํ ์ ์์ด! ๋ช ๊ฐ์ง ์์ด๋์ด๋ฅผ ๊ณต์ ํด์ค๊ฒ. ๐ก
๐ ์๋ฆผ ์์คํ
ํน์ ์ปฌ๋ ์
์ Floor Price๊ฐ ์ผ์ ์์ค ์ดํ๋ก ๋จ์ด์ง๋ฉด ์๋ฆผ์ ๋ณด๋ด๋ ๊ธฐ๋ฅ์ด์ผ. ์ด๋ฉ์ผ, SMS, ๋์ค์ฝ๋ ์นํ
๋ฑ ๋ค์ํ ์ฑ๋๋ก ์๋ฆผ์ ๋ณด๋ผ ์ ์์ด.
๊ตฌํ ๋ฐฉ๋ฒ: ๋ผ๋ผ๋ฒจ์ Notification ์์คํ
์ ์ฌ์ฉํ๋ฉด ์ฝ๊ฒ ๋ง๋ค ์ ์์ด. ์ฌ์ฉ์๊ฐ ๊ด์ฌ ์ปฌ๋ ์
์ ๋ฑ๋กํ๊ณ , ์ค์ผ์ค๋ฌ๊ฐ ์ฃผ๊ธฐ์ ์ผ๋ก ๊ฐ๊ฒฉ์ ์ฒดํฌํด์ ์กฐ๊ฑด์ ๋ง์ผ๋ฉด ์๋ฆผ์ ๋ฐ์กํ๋ ๊ฑฐ์ง!
๐ค AI ๊ธฐ๋ฐ ๊ฐ๊ฒฉ ์์ธก
๋จธ์ ๋ฌ๋ ๋ชจ๋ธ์ ์ฌ์ฉํด์ NFT ๊ฐ๊ฒฉ์ ์์ธกํ๋ ๊ธฐ๋ฅ์ด์ผ. ๊ณผ๊ฑฐ ๊ฑฐ๋ ๋ฐ์ดํฐ๋ฅผ ํ์ตํด์ ๋ฏธ๋ ๊ฐ๊ฒฉ์ ์์ธกํ ์ ์์ด.
๊ตฌํ ๋ฐฉ๋ฒ: Python์ scikit-learn์ด๋ TensorFlow๋ฅผ ์ฌ์ฉํด์ ๋ชจ๋ธ์ ํ์ต์ํค๊ณ , ๋ผ๋ผ๋ฒจ์์ Python ์คํฌ๋ฆฝํธ๋ฅผ ํธ์ถํ๊ฑฐ๋ REST API๋ก ํต์ ํ๋ฉด ๋ผ. ๋๋ ์ฌ๋ฅ๋ท์์ AI/ML ์ ๋ฌธ๊ฐ๋ฅผ ์ฐพ์ ํ์
ํ ์๋ ์์ด!
๐ฑ ๋ชจ๋ฐ์ผ ์ฑ
React Native๋ Flutter๋ฅผ ์ฌ์ฉํด์ ๋ชจ๋ฐ์ผ ์ฑ์ ๋ง๋ค ์ ์์ด. ์ด๋ฏธ ๋ง๋ API๋ฅผ ๊ทธ๋๋ก ์ฌ์ฉํ๋ฉด ๋๋๊น ๋ฐฑ์๋ ์์
์ ํ์ ์์ด!
ํธ์ ์๋ฆผ, ์์ ฏ, ๋คํฌ ๋ชจ๋ ๋ฑ ๋ชจ๋ฐ์ผ์ ์ต์ ํ๋ ๊ธฐ๋ฅ๋ค์ ์ถ๊ฐํ๋ฉด ์ฌ์ฉ์ ๊ฒฝํ์ด ํจ์ฌ ์ข์์ ธ.
๐ฎ ํฌํธํด๋ฆฌ์ค ์ถ์
์ฌ์ฉ์๊ฐ ์์ ์ด ๋ณด์ ํ NFT๋ฅผ ๋ฑ๋กํ๊ณ , ํฌํธํด๋ฆฌ์ค ๊ฐ์น๋ฅผ ์ค์๊ฐ์ผ๋ก ์ถ์ ํ๋ ๊ธฐ๋ฅ์ด์ผ. ์์ต๋ฅ , ์์ต ๊ณ์ฐ, ์ธ๊ธ ๋ฆฌํฌํธ ๋ฑ๋ ์ ๊ณตํ ์ ์์ด.
์ง๊ฐ ์ฃผ์๋ฅผ ์ฐ๊ฒฐํ๋ฉด ์๋์ผ๋ก ๋ณด์ NFT๋ฅผ ๊ฐ์ ธ์ฌ ์๋ ์์ด. Web3.js๋ Ethers.js๋ฅผ ์ฌ์ฉํ๋ฉด ๋ธ๋ก์ฒด์ธ๊ณผ ์ง์ ํต์ ํ ์ ์๊ฑฐ๋ !
๐ ํฌ๊ท๋ ๋ถ์
NFT์ ์์ฑ(Traits)์ ๋ถ์ํด์ ํฌ๊ท๋ ์ ์๋ฅผ ๊ณ์ฐํ๋ ๊ธฐ๋ฅ์ด์ผ. ๊ฐ ์์ฑ์ ์ถํ ๋น๋๋ฅผ ๊ณ์ฐํ๊ณ , ํฌ๊ทํ ์์ฑ ์กฐํฉ์ ๊ฐ์ง NFT๋ฅผ ์ฐพ์๋ผ ์ ์์ด.
์ด ์ ๋ณด๋ ํฌ์ ๊ฒฐ์ ์ ์ ๋ง ์ ์ฉํด. ์ ํ๊ฐ๋ ํฌ๊ท NFT๋ฅผ ์ฐพ์๋ด๋ ๋ฐ ๋์์ด ๋๊ฑฐ๋ !
๐ ๋ฉํฐ์ฒด์ธ ์ง์
ํ์ฌ๋ ์ด๋๋ฆฌ์๋ง ์ง์ํ์ง๋ง, Polygon, Solana, Binance Smart Chain ๋ฑ ๋ค๋ฅธ ๋ธ๋ก์ฒด์ธ๋ ์ง์ํ ์ ์์ด.
๊ฐ ์ฒด์ธ๋ง๋ค API๊ฐ ๋ค๋ฅด์ง๋ง, ์๋น์ค ๋ ์ด์ด๋ฅผ ์ ์ถ์ํํ๋ฉด ์ฝ๊ฒ ํ์ฅํ ์ ์์ด. ์ธํฐํ์ด์ค๋ฅผ ์ ์ํ๊ณ , ๊ฐ ์ฒด์ธ๋ณ๋ก ๊ตฌํ์ฒด๋ฅผ ๋ง๋ค๋ฉด ๋ผ!
๐ฏ ๋ง๋ฌด๋ฆฌํ๋ฉฐ
์! ์ ๋ง ๊ธด ์ฌ์ ์ด์์ง? ์ฐ๋ฆฌ๋ ๋ผ๋ผ๋ฒจ์ ์ฌ์ฉํด์ ์์ ํ ๋์ํ๋ NFT ํต๊ณ ๋ถ์ ๋์๋ณด๋๋ฅผ ์ฒ์๋ถํฐ ๋๊น์ง ๋ง๋ค์ด๋ดค์ด. ๐
์ด ํ๋ก์ ํธ๋ฅผ ํตํด ๋ฐฐ์ด ๊ฒ๋ค์ ์ ๋ฆฌํด๋ณด๋ฉด:
โ ํต์ฌ ํ์ต ๋ด์ฉ
1. ๋ผ๋ผ๋ฒจ ํ๋ ์์ํฌ
- ๋ง์ด๊ทธ๋ ์ด์
๊ณผ Eloquent ORM
- ํ์ Job ์์คํ
- ์ค์ผ์ค๋ง๊ณผ ์๋ํ
- API ๊ฐ๋ฐ๊ณผ RESTful ์ค๊ณ
2. ๋ธ๋ก์ฒด์ธ ํตํฉ
- NFT ๋ฐ์ดํฐ ๊ตฌ์กฐ ์ดํด
- ๋ธ๋ก์ฒด์ธ API ํ์ฉ
- ์ค์๊ฐ ๋ฐ์ดํฐ ์์ง
- ํธ๋์ญ์
์ถ์
3. ๋ฐ์ดํฐ ๋ถ์
- ์๊ณ์ด ๋ฐ์ดํฐ ์ฒ๋ฆฌ
- ํต๊ณ ๊ณ์ฐ ๋ฐ ์ง๊ณ
- ํธ๋ ๋ ๋ถ์
- ๋ฐ์ดํฐ ์๊ฐํ
4. ์ฑ๋ฅ ์ต์ ํ
- ์บ์ฑ ์ ๋ต
- ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ต์ ํ
- ๋น๋๊ธฐ ์ฒ๋ฆฌ
- ํ์ฅ ๊ฐ๋ฅํ ์ํคํ
์ฒ
5. ๋ฐฐํฌ์ ์ด์
- Docker ์ปจํ
์ด๋ํ
- ํ๋ก๋์
ํ๊ฒฝ ์ค์
- ๋ชจ๋ํฐ๋ง๊ณผ ๋ก๊น
- ๋ณด์ ๊ณ ๋ ค์ฌํญ
์ด ๋์๋ณด๋๋ ์์์ ์ผ ๋ฟ์ด์ผ. ์ฌ๊ธฐ์ ๋ฐฐ์ด ๊ฐ๋
๋ค์ ํ์ฉํ๋ฉด ํจ์ฌ ๋ ๋ณต์กํ๊ณ ๊ฐ๋ ฅํ ์์คํ
์ ๋ง๋ค ์ ์์ด! ๐
NFT ์์ฅ์ ๊ณ์ ์งํํ๊ณ ์๊ณ , ์๋ก์ด ๊ธฐํ๋ค์ด ๊ณ์ ์๊ฒจ๋๊ณ ์์ด. ๋ฐ์ดํฐ๋ฅผ ์ ํ์ฉํ๋ฉด ์์ฅ์ ์ดํดํ๊ณ , ๋ ๋์ ๊ฒฐ์ ์ ๋ด๋ฆด ์ ์์ง.
ํน์ ํ๋ก์ ํธ๋ฅผ ์งํํ๋ค๊ฐ ๋งํ๋ ๋ถ๋ถ์ด ์๊ฑฐ๋, ๋ ์ ๋ฌธ์ ์ธ ๋์์ด ํ์ํ๋ค๋ฉด ์ฌ๋ฅ๋ท ๊ฐ์ ํ๋ซํผ์์ ์ ๋ฌธ๊ฐ๋ค์ ๋์์ ๋ฐ์ ์๋ ์์ด. ๋ผ๋ผ๋ฒจ ๊ฐ๋ฐ์, ๋ธ๋ก์ฒด์ธ ์ ๋ฌธ๊ฐ, UI/UX ๋์์ด๋ ๋ฑ ๋ค์ํ ๋ถ์ผ์ ์ ๋ฌธ๊ฐ๋ค์ด ์์ผ๋๊น ํ์
ํ๋ฉด ๋ ๋น ๋ฅด๊ฒ ํ๋ก์ ํธ๋ฅผ ์์ฑํ ์ ์์ ๊ฑฐ์ผ! ๐ช
์, ์ด์ ๋น์ ์ฐจ๋ก์ผ! ์ด ๊ฐ์ด๋๋ฅผ ์ฐธ๊ณ ํด์ ๋ฉ์ง NFT ๋์๋ณด๋๋ฅผ ๋ง๋ค์ด๋ณด์. ๊ทธ๋ฆฌ๊ณ ๋ง๋ ๊ฒฐ๊ณผ๋ฌผ์ ์ปค๋ฎค๋ํฐ์ ๊ณต์ ํ๋ฉด ๋ ์ข๊ฒ ์ง? ๐
ํ์ด์ ๋น์ด! Happy coding! ๐จโจ
๐ ์ด ๊ธ์ด ๋์์ด ๋์๋์?
๋ผ๋ผ๋ฒจ๊ณผ ๋ธ๋ก์ฒด์ธ ๊ฐ๋ฐ์ ๋ํด ๋ ์๊ณ ์ถ๋ค๋ฉด,
๊ณ์ํด์ ํ์ตํ๊ณ ์คํํด๋ณด์ธ์!
๋น์ ์ ๋ค์ ํ๋ก์ ํธ๋ฅผ ์์ํฉ๋๋ค! ๐
๊ด๋ จ ํค์๋
๋๊ธ ์์ฑ
์ด ๊ธ์ ๋ํ ์ฌ๋ฌ๋ถ์ ์๊ฐ์ ๋ค๋ ค์ฃผ์ธ์
๋ก๊ทธ์ธ์ด ํ์ํฉ๋๋ค
๋๊ธ์ ์์ฑํ๋ ค๋ฉด ๋จผ์ ๋ก๊ทธ์ธํด์ฃผ์ธ์.
๋๊ธ 0
์ง์์ธ์ ์ฒ - ์ง์ ์ฌ์ฐ๊ถ ๋ณดํธ ๊ณ ์ง
์ง์ ์ฌ์ฐ๊ถ ๋ณดํธ ๊ณ ์ง
- ์ ์๊ถ ๋ฐ ์์ ๊ถ: ๋ณธ ์ปจํ ์ธ ๋ ์ฌ๋ฅ๋ท์ ๋ ์ AI ๊ธฐ์ ๋ก ์์ฑ๋์์ผ๋ฉฐ, ๋ํ๋ฏผ๊ตญ ์ ์๊ถ๋ฒ ๋ฐ ๊ตญ์ ์ ์๊ถ ํ์ฝ์ ์ํด ๋ณดํธ๋ฉ๋๋ค.
- AI ์์ฑ ์ปจํ ์ธ ์ ๋ฒ์ ์ง์: ๋ณธ AI ์์ฑ ์ปจํ ์ธ ๋ ์ฌ๋ฅ๋ท์ ์ง์ ์ฐฝ์๋ฌผ๋ก ์ธ์ ๋๋ฉฐ, ๊ด๋ จ ๋ฒ๊ท์ ๋ฐ๋ผ ์ ์๊ถ ๋ณดํธ๋ฅผ ๋ฐ์ต๋๋ค.
- ์ฌ์ฉ ์ ํ: ์ฌ๋ฅ๋ท์ ๋ช ์์ ์๋ฉด ๋์ ์์ด ๋ณธ ์ปจํ ์ธ ๋ฅผ ๋ณต์ , ์์ , ๋ฐฐํฌ, ๋๋ ์์ ์ ์ผ๋ก ํ์ฉํ๋ ํ์๋ ์๊ฒฉํ ๊ธ์ง๋ฉ๋๋ค.
- ๋ฐ์ดํฐ ์์ง ๊ธ์ง: ๋ณธ ์ปจํ ์ธ ์ ๋ํ ๋ฌด๋จ ์คํฌ๋ํ, ํฌ๋กค๋ง, ๋ฐ ์๋ํ๋ ๋ฐ์ดํฐ ์์ง์ ๋ฒ์ ์ ์ฌ์ ๋์์ด ๋ฉ๋๋ค.
- AI ํ์ต ์ ํ: ์ฌ๋ฅ๋ท์ AI ์์ฑ ์ปจํ ์ธ ๋ฅผ ํ AI ๋ชจ๋ธ ํ์ต์ ๋ฌด๋จ ์ฌ์ฉํ๋ ํ์๋ ๊ธ์ง๋๋ฉฐ, ์ด๋ ์ง์ ์ฌ์ฐ๊ถ ์นจํด๋ก ๊ฐ์ฃผ๋ฉ๋๋ค.
์์ง ๋๊ธ์ด ์์ต๋๋ค.