์ฝ˜ํ…์ธ  ๋Œ€ํ‘œ ์ด๋ฏธ์ง€ - ๐Ÿš€ ๋ผ๋ผ๋ฒจ๋กœ ๋งŒ๋“œ๋Š” NFT ํ†ต๊ณ„ ๋ถ„์„ ๋Œ€์‹œ๋ณด๋“œ

๐Ÿš€ ๋ผ๋ผ๋ฒจ๋กœ ๋งŒ๋“œ๋Š” NFT ํ†ต๊ณ„ ๋ถ„์„ ๋Œ€์‹œ๋ณด๋“œ

Web3 ์‹œ๋Œ€, ๋ฐ์ดํ„ฐ๋กœ NFT ์‹œ์žฅ์„ ์ •๋ณตํ•˜๋Š” ๋ฒ•

์•ˆ๋…•! ์˜ค๋Š˜์€ ์ •๋ง ํ•ซํ•œ ์ฃผ์ œ๋ฅผ ๊ฐ€์ง€๊ณ  ์™”์–ด. ๋ฐ”๋กœ ๋ผ๋ผ๋ฒจ(Laravel)์„ ํ™œ์šฉํ•ด์„œ NFT ํ†ต๊ณ„ ๋ถ„์„ ๋Œ€์‹œ๋ณด๋“œ๋ฅผ ๋งŒ๋“œ๋Š” ๋ฐฉ๋ฒ•์ด์•ผ! ๐ŸŽจ

์š”์ฆ˜ NFT ์‹œ์žฅ์ด ์—„์ฒญ๋‚˜๊ฒŒ ์„ฑ์žฅํ•˜๊ณ  ์žˆ์ž–์•„? ๊ทธ๋Ÿฐ๋ฐ ๋ง‰์ƒ NFT ํ”„๋กœ์ ํŠธ๋ฅผ ์šด์˜ํ•˜๊ฑฐ๋‚˜ ํˆฌ์žํ•˜๋ ค๊ณ  ํ•˜๋ฉด ๋ฐ์ดํ„ฐ๊ฐ€ ์—ฌ๊ธฐ์ €๊ธฐ ํฉ์–ด์ ธ ์žˆ์–ด์„œ ํ•œ๋ˆˆ์— ๋ณด๊ธฐ๊ฐ€ ์ •๋ง ์–ด๋ ค์›Œ. ๊ทธ๋ž˜์„œ ์šฐ๋ฆฌ๊ฐ€ ์ง์ ‘ ๋ฉ‹์ง„ ๋Œ€์‹œ๋ณด๋“œ๋ฅผ ๋งŒ๋“ค์–ด๋ณด๋Š” ๊ฑฐ์ง€! ๐Ÿ’ช

์ด ๊ธ€์—์„œ๋Š” ์‹ค์ œ๋กœ ๋™์ž‘ํ•˜๋Š” NFT ํ†ต๊ณ„ ๋ถ„์„ ๋Œ€์‹œ๋ณด๋“œ๋ฅผ ์ฒ˜์Œ๋ถ€ํ„ฐ ๋๊นŒ์ง€ ๋งŒ๋“œ๋Š” ๊ณผ์ •์„ ์นœ๊ตฌ์ฒ˜๋Ÿผ ํŽธํ•˜๊ฒŒ ์„ค๋ช…ํ•ด์ค„๊ฒŒ. ์ฝ”๋”ฉ ๊ฒฝํ—˜์ด ์ข€ ์žˆ๋‹ค๋ฉด ๋”ฐ๋ผํ•˜๊ธฐ ์–ด๋ ต์ง€ ์•Š์„ ๊ฑฐ์•ผ!

๋ผ๋ผ๋ฒจ ๋ฐฑ์—”๋“œ ํ”„๋ ˆ์ž„์›Œํฌ ๐Ÿ”ง ๋ธ”๋ก์ฒด์ธ API 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๋กœ ๋ฐ์ดํ„ฐ ์‹œ๊ฐํ™”
- ๋ฐ˜์‘ํ˜• ๋””์ž์ธ

NFT ๋Œ€์‹œ๋ณด๋“œ ์•„ํ‚คํ…์ฒ˜ ๋ธ”๋ก์ฒด์ธ Ethereum Polygon Solana API ๊ฒŒ์ดํŠธ์›จ์ด OpenSea API Alchemy Moralis ๋ผ๋ผ๋ฒจ ๋ฐฑ์—”๋“œ ๋ฐ์ดํ„ฐ ์ˆ˜์ง‘ ์ฒ˜๋ฆฌ ๋ฐ ๋ถ„์„ API ์ œ๊ณต ํ”„๋ก ํŠธ์—”๋“œ Vue.js / React ์ฐจํŠธ ์‹œ๊ฐํ™” ๋Œ€์‹œ๋ณด๋“œ UI ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๊ตฌ์กฐ collections id name contract_address floor_price volume_24h nft_items id collection_id token_id owner_address last_price transactions id nft_item_id from_address to_address price analytics id collection_id date avg_price total_volume Redis ์บ์‹œ: ์‹ค์‹œ๊ฐ„ ๋ฐ์ดํ„ฐ / ์„ธ์…˜ ๊ด€๋ฆฌ ํ ์‹œ์Šคํ…œ: ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์ž‘์—… ์ฒ˜๋ฆฌ WebSocket: ์‹ค์‹œ๊ฐ„ ์—…๋ฐ์ดํŠธ ํ‘ธ์‹œ

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์€ ์—†๋Š”์ง€ ํ•œ๋ˆˆ์— ๋ณผ ์ˆ˜ ์žˆ์–ด์„œ ์ •๋ง ํŽธํ•ด! ๐ŸŽ›๏ธ

๋ฐ์ดํ„ฐ ์ˆ˜์ง‘ ๋ฐ ์ฒ˜๋ฆฌ ํ”Œ๋กœ์šฐ 1. ์Šค์ผ€์ค„๋Ÿฌ ๋งค์‹œ๊ฐ„ ์‹คํ–‰ 2. API ํ˜ธ์ถœ ๋ฐ์ดํ„ฐ ์ˆ˜์ง‘ 3. ํ ์ฒ˜๋ฆฌ ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์ž‘์—… 4. DB ์ €์žฅ ๋ฐ์ดํ„ฐ ์˜์†ํ™” ์ฒ˜๋ฆฌ ๋‹จ๊ณ„๋ณ„ ์ƒ์„ธ ์ปฌ๋ ‰์…˜ ์ •๋ณด ๋™๊ธฐํ™” (SyncCollectionData) NFT ์•„์ดํ…œ ๋™๊ธฐํ™” (SyncNFTItems) ๊ฑฐ๋ž˜ ๋‚ด์—ญ ๋™๊ธฐํ™” (SyncTransactions) ํ†ต๊ณ„ ๊ณ„์‚ฐ (CalculateAnalytics)

๐ŸŽจ 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๊ฐ€ ๋‹ค๋ฅด์ง€๋งŒ, ์„œ๋น„์Šค ๋ ˆ์ด์–ด๋ฅผ ์ž˜ ์ถ”์ƒํ™”ํ•˜๋ฉด ์‰ฝ๊ฒŒ ํ™•์žฅํ•  ์ˆ˜ ์žˆ์–ด. ์ธํ„ฐํŽ˜์ด์Šค๋ฅผ ์ •์˜ํ•˜๊ณ , ๊ฐ ์ฒด์ธ๋ณ„๋กœ ๊ตฌํ˜„์ฒด๋ฅผ ๋งŒ๋“ค๋ฉด ๋ผ!

ํ™•์žฅ ๊ฐ€๋Šฅํ•œ ๊ธฐ๋Šฅ๋“ค ๐Ÿ”” ์•Œ๋ฆผ ๐Ÿค– AI ์˜ˆ์ธก ๐Ÿ“ฑ ๋ชจ๋ฐ”์ผ ๐ŸŽฎ ํฌํŠธํด๋ฆฌ์˜ค ๐Ÿ” ํฌ๊ท€๋„ ๐ŸŒ ๋ฉ€ํ‹ฐ์ฒด์ธ ๋‹น์‹ ์˜ ์ฐฝ์˜๋ ฅ์œผ๋กœ ๋” ๋งŽ์€ ๊ธฐ๋Šฅ์„ ์ถ”๊ฐ€ํ•ด๋ณด์„ธ์š”!

๐ŸŽฏ ๋งˆ๋ฌด๋ฆฌํ•˜๋ฉฐ

์™€! ์ •๋ง ๊ธด ์—ฌ์ •์ด์—ˆ์ง€? ์šฐ๋ฆฌ๋Š” ๋ผ๋ผ๋ฒจ์„ ์‚ฌ์šฉํ•ด์„œ ์™„์ „ํžˆ ๋™์ž‘ํ•˜๋Š” NFT ํ†ต๊ณ„ ๋ถ„์„ ๋Œ€์‹œ๋ณด๋“œ๋ฅผ ์ฒ˜์Œ๋ถ€ํ„ฐ ๋๊นŒ์ง€ ๋งŒ๋“ค์–ด๋ดค์–ด. ๐ŸŽ‰

์ด ํ”„๋กœ์ ํŠธ๋ฅผ ํ†ตํ•ด ๋ฐฐ์šด ๊ฒƒ๋“ค์„ ์ •๋ฆฌํ•ด๋ณด๋ฉด:

โœ… ํ•ต์‹ฌ ํ•™์Šต ๋‚ด์šฉ

1. ๋ผ๋ผ๋ฒจ ํ”„๋ ˆ์ž„์›Œํฌ
- ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜๊ณผ Eloquent ORM
- ํ์™€ Job ์‹œ์Šคํ…œ
- ์Šค์ผ€์ค„๋ง๊ณผ ์ž๋™ํ™”
- API ๊ฐœ๋ฐœ๊ณผ RESTful ์„ค๊ณ„

2. ๋ธ”๋ก์ฒด์ธ ํ†ตํ•ฉ
- NFT ๋ฐ์ดํ„ฐ ๊ตฌ์กฐ ์ดํ•ด
- ๋ธ”๋ก์ฒด์ธ API ํ™œ์šฉ
- ์‹ค์‹œ๊ฐ„ ๋ฐ์ดํ„ฐ ์ˆ˜์ง‘
- ํŠธ๋žœ์žญ์…˜ ์ถ”์ 

3. ๋ฐ์ดํ„ฐ ๋ถ„์„
- ์‹œ๊ณ„์—ด ๋ฐ์ดํ„ฐ ์ฒ˜๋ฆฌ
- ํ†ต๊ณ„ ๊ณ„์‚ฐ ๋ฐ ์ง‘๊ณ„
- ํŠธ๋ Œ๋“œ ๋ถ„์„
- ๋ฐ์ดํ„ฐ ์‹œ๊ฐํ™”

4. ์„ฑ๋Šฅ ์ตœ์ ํ™”
- ์บ์‹ฑ ์ „๋žต
- ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ตœ์ ํ™”
- ๋น„๋™๊ธฐ ์ฒ˜๋ฆฌ
- ํ™•์žฅ ๊ฐ€๋Šฅํ•œ ์•„ํ‚คํ…์ฒ˜

5. ๋ฐฐํฌ์™€ ์šด์˜
- Docker ์ปจํ…Œ์ด๋„ˆํ™”
- ํ”„๋กœ๋•์…˜ ํ™˜๊ฒฝ ์„ค์ •
- ๋ชจ๋‹ˆํ„ฐ๋ง๊ณผ ๋กœ๊น…
- ๋ณด์•ˆ ๊ณ ๋ ค์‚ฌํ•ญ

์ด ๋Œ€์‹œ๋ณด๋“œ๋Š” ์‹œ์ž‘์ ์ผ ๋ฟ์ด์•ผ. ์—ฌ๊ธฐ์„œ ๋ฐฐ์šด ๊ฐœ๋…๋“ค์„ ํ™œ์šฉํ•˜๋ฉด ํ›จ์”ฌ ๋” ๋ณต์žกํ•˜๊ณ  ๊ฐ•๋ ฅํ•œ ์‹œ์Šคํ…œ์„ ๋งŒ๋“ค ์ˆ˜ ์žˆ์–ด! ๐Ÿš€

NFT ์‹œ์žฅ์€ ๊ณ„์† ์ง„ํ™”ํ•˜๊ณ  ์žˆ๊ณ , ์ƒˆ๋กœ์šด ๊ธฐํšŒ๋“ค์ด ๊ณ„์† ์ƒ๊ฒจ๋‚˜๊ณ  ์žˆ์–ด. ๋ฐ์ดํ„ฐ๋ฅผ ์ž˜ ํ™œ์šฉํ•˜๋ฉด ์‹œ์žฅ์„ ์ดํ•ดํ•˜๊ณ , ๋” ๋‚˜์€ ๊ฒฐ์ •์„ ๋‚ด๋ฆด ์ˆ˜ ์žˆ์ง€.

ํ˜น์‹œ ํ”„๋กœ์ ํŠธ๋ฅผ ์ง„ํ–‰ํ•˜๋‹ค๊ฐ€ ๋ง‰ํžˆ๋Š” ๋ถ€๋ถ„์ด ์žˆ๊ฑฐ๋‚˜, ๋” ์ „๋ฌธ์ ์ธ ๋„์›€์ด ํ•„์š”ํ•˜๋‹ค๋ฉด ์žฌ๋Šฅ๋„ท ๊ฐ™์€ ํ”Œ๋žซํผ์—์„œ ์ „๋ฌธ๊ฐ€๋“ค์˜ ๋„์›€์„ ๋ฐ›์„ ์ˆ˜๋„ ์žˆ์–ด. ๋ผ๋ผ๋ฒจ ๊ฐœ๋ฐœ์ž, ๋ธ”๋ก์ฒด์ธ ์ „๋ฌธ๊ฐ€, UI/UX ๋””์ž์ด๋„ˆ ๋“ฑ ๋‹ค์–‘ํ•œ ๋ถ„์•ผ์˜ ์ „๋ฌธ๊ฐ€๋“ค์ด ์žˆ์œผ๋‹ˆ๊นŒ ํ˜‘์—…ํ•˜๋ฉด ๋” ๋น ๋ฅด๊ฒŒ ํ”„๋กœ์ ํŠธ๋ฅผ ์™„์„ฑํ•  ์ˆ˜ ์žˆ์„ ๊ฑฐ์•ผ! ๐Ÿ’ช

์ž, ์ด์ œ ๋‹น์‹  ์ฐจ๋ก€์•ผ! ์ด ๊ฐ€์ด๋“œ๋ฅผ ์ฐธ๊ณ ํ•ด์„œ ๋ฉ‹์ง„ NFT ๋Œ€์‹œ๋ณด๋“œ๋ฅผ ๋งŒ๋“ค์–ด๋ณด์ž. ๊ทธ๋ฆฌ๊ณ  ๋งŒ๋“  ๊ฒฐ๊ณผ๋ฌผ์„ ์ปค๋ฎค๋‹ˆํ‹ฐ์™€ ๊ณต์œ ํ•˜๋ฉด ๋” ์ข‹๊ฒ ์ง€? ๐Ÿ˜Š

ํ–‰์šด์„ ๋นŒ์–ด! Happy coding! ๐ŸŽจโœจ

๐ŸŽ“ ์ด ๊ธ€์ด ๋„์›€์ด ๋˜์—ˆ๋‚˜์š”?

๋ผ๋ผ๋ฒจ๊ณผ ๋ธ”๋ก์ฒด์ธ ๊ฐœ๋ฐœ์— ๋Œ€ํ•ด ๋” ์•Œ๊ณ  ์‹ถ๋‹ค๋ฉด,
๊ณ„์†ํ•ด์„œ ํ•™์Šตํ•˜๊ณ  ์‹คํ—˜ํ•ด๋ณด์„ธ์š”!

๋‹น์‹ ์˜ ๋‹ค์Œ ํ”„๋กœ์ ํŠธ๋ฅผ ์‘์›ํ•ฉ๋‹ˆ๋‹ค! ๐Ÿš€

๋Œ“๊ธ€ ์ž‘์„ฑ

์ด ๊ธ€์— ๋Œ€ํ•œ ์—ฌ๋Ÿฌ๋ถ„์˜ ์ƒ๊ฐ์„ ๋“ค๋ ค์ฃผ์„ธ์š”

๋Œ“๊ธ€ 0
๋Œ“๊ธ€์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘์ž…๋‹ˆ๋‹ค.

์•„์ง ๋Œ“๊ธ€์ด ์—†์Šต๋‹ˆ๋‹ค.