์ฝ˜ํ…์ธ  ๋Œ€ํ‘œ ์ด๋ฏธ์ง€ - ๐Ÿ ํŒŒ์ด์ฌ + IPFS๋กœ์ด๋ฏธ์ง€ NFT ์—…๋กœ๋“œ ์‹œ์Šคํ…œ ๊ตฌํ˜„ํ•˜๊ธฐ
๐Ÿ”— ๋ธ”๋ก์ฒด์ธ / Web3 ยท ํ”„๋กœ๊ทธ๋žจ๊ฐœ๋ฐœ

๐Ÿ ํŒŒ์ด์ฌ + IPFS๋กœ
์ด๋ฏธ์ง€ NFT ์—…๋กœ๋“œ ์‹œ์Šคํ…œ ๊ตฌํ˜„ํ•˜๊ธฐ

์ฝ”๋“œ ํ•œ ์ค„์”ฉ ๊ฐ™์ด ํŒŒํ—ค์ณ๋ณด๋Š” Web3 ์‹ค์ „ ๊ฐ€์ด๋“œ ๐Ÿš€

์•ผ, ์†”์งํžˆ ๋งํ•ด๋ด. NFT๋ผ๋Š” ๋‹จ์–ด ๋“ค์œผ๋ฉด ์•„์ง๋„ "๊ทธ๊ฑฐ ๊ทธ๋ƒฅ JPEG ์•„๋‹ˆ์•ผ?" ๋ผ๊ณ  ์ƒ๊ฐํ•˜๋Š” ์‚ฌ๋žŒ ์žˆ์ง€? ๐Ÿ˜„
๊ทผ๋ฐ ๋ง‰์ƒ ์ง์ ‘ ๋งŒ๋“ค์–ด๋ณด๋ฉด ์ƒ๊ฐ๋ณด๋‹ค ํ›จ์”ฌ ์žฌ๋ฐŒ๊ณ , ๊ธฐ์ˆ ์ ์œผ๋กœ๋„ ๊ฝค ๊นŠ์ด๊ฐ€ ์žˆ์–ด.
์˜ค๋Š˜์€ ํŒŒ์ด์ฌ(Python)๊ณผ IPFS(InterPlanetary File System)๋ฅผ ์ด์šฉํ•ด์„œ ์ด๋ฏธ์ง€๋ฅผ NFT๋กœ ์—…๋กœ๋“œํ•˜๋Š” ์‹œ์Šคํ…œ์„ ์ฒ˜์Œ๋ถ€ํ„ฐ ๋๊นŒ์ง€ ๊ฐ™์ด ๋งŒ๋“ค์–ด๋ณผ ๊ฑฐ์•ผ.

๊ฒ๋จน์ง€ ๋งˆ. ๋ธ”๋ก์ฒด์ธ ์ „๋ฌธ๊ฐ€ ์•„๋‹ˆ์–ด๋„ ๋ผ. ํŒŒ์ด์ฌ ๊ธฐ์ดˆ๋งŒ ์•Œ๋ฉด ์ถฉ๋ถ„ํ•ด.
์ด ๊ธ€ ๋‹ค ์ฝ๊ณ  ๋‚˜๋ฉด "์•„, ์ด๋ ‡๊ฒŒ ๋Œ์•„๊ฐ€๋Š” ๊ฑฐ๊ตฌ๋‚˜!" ํ•˜๊ณ  ๋ฌด๋ฆŽ์„ ํƒ ์น  ๊ฑฐ์•ผ. ๐ŸŽฏ

๐Ÿ’ก ์ด ๊ธ€์—์„œ ๋‹ค๋ฃจ๋Š” ๊ฒƒ๋“ค IPFS์˜ ๊ฐœ๋…๊ณผ ๋™์ž‘ ์›๋ฆฌ โ†’ Pinata API ํ™œ์šฉ๋ฒ• โ†’ ํŒŒ์ด์ฌ์œผ๋กœ ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ โ†’ NFT ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์ƒ์„ฑ โ†’ ์Šค๋งˆํŠธ ์ปจํŠธ๋ž™ํŠธ ์—ฐ๋™๊นŒ์ง€!

NFT ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ ์ „์ฒด ํ๋ฆ„ ๐Ÿ–ผ๏ธ ์ด๋ฏธ์ง€ ํŒŒ์ผ ๋กœ์ปฌ ์ด๋ฏธ์ง€ ์ค€๋น„ ๋‹จ๊ณ„ ๐Ÿ ํŒŒ์ด์ฌ requests ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ API ํ˜ธ์ถœ ๐Ÿ“Œ IPFS Pinata ํ•€๋‹ CID ๋ฐœ๊ธ‰ ๐Ÿ“‹ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ JSON ์ƒ์„ฑ IPFS ์—…๋กœ๋“œ โ›“๏ธ ์Šค๋งˆํŠธ ์ปจํŠธ๋ž™ํŠธ ๋ฏผํŒ… tokenURI = ipfs://CID โ†’ NFT ๋ฐœํ–‰ ์™„๋ฃŒ! web3.py + ERC-721 ์ปจํŠธ๋ž™ํŠธ ์ด๋ฏธ์ง€ โ†’ IPFS โ†’ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ โ†’ ๋ธ”๋ก์ฒด์ธ ๋ฏผํŒ…

๐ŸŒ IPFS๊ฐ€ ๋ญ”๋ฐ? ์™œ ์จ์•ผ ํ•ด?

NFT๋ฅผ ๋งŒ๋“ค ๋•Œ ๊ฐ€์žฅ ๋จผ์ € ๋ถ€๋”ชํžˆ๋Š” ์งˆ๋ฌธ์ด ๋ฐ”๋กœ ์ด๊ฑฐ์•ผ.
"์ด๋ฏธ์ง€๋ฅผ ์–ด๋””์— ์ €์žฅํ•ด?"

AWS S3๋‚˜ ์ผ๋ฐ˜ ์„œ๋ฒ„์— ์˜ฌ๋ฆฌ๋ฉด ์•ˆ ๋˜๋ƒ๊ณ ? ๊ธฐ์ˆ ์ ์œผ๋กœ๋Š” ๊ฐ€๋Šฅํ•ด. ๊ทผ๋ฐ ์น˜๋ช…์ ์ธ ๋ฌธ์ œ๊ฐ€ ์žˆ์–ด.
์„œ๋ฒ„๊ฐ€ ๊บผ์ง€๋ฉด? ํšŒ์‚ฌ๊ฐ€ ๋งํ•˜๋ฉด? ์ด๋ฏธ์ง€ URL์ด 404๊ฐ€ ๋˜๋ฉด? ๊ทธ NFT๋Š” ๊ทธ๋ƒฅ ๋นˆ ๊ป๋ฐ๊ธฐ๊ฐ€ ๋˜๋Š” ๊ฑฐ์•ผ. ๐Ÿ˜ฑ
์‹ค์ œ๋กœ ์ด๋Ÿฐ ์‚ฌ๋ก€๊ฐ€ ๊ฝค ์žˆ์—ˆ์–ด. ๋น„์‹ธ๊ฒŒ ์‚ฐ NFT์ธ๋ฐ ์ด๋ฏธ์ง€๊ฐ€ ์‚ฌ๋ผ์ง„ ๊ฑฐ์ง€.

๐ŸŒ IPFS(InterPlanetary File System)๋ž€? IPFS๋Š” ๋ถ„์‚ฐํ˜• ํŒŒ์ผ ์ €์žฅ ์‹œ์Šคํ…œ์ด์•ผ. 2015๋…„ Juan Benet์ด ๋งŒ๋“  ํ”„๋กœํ† ์ฝœ๋กœ,
ํŒŒ์ผ์„ ํŠน์ • ์„œ๋ฒ„ ํ•œ ๊ณณ์— ์ €์žฅํ•˜๋Š” ๊ฒŒ ์•„๋‹ˆ๋ผ ์ „ ์„ธ๊ณ„ ์ˆ˜์ฒœ ๊ฐœ์˜ ๋…ธ๋“œ์— ๋ถ„์‚ฐํ•ด์„œ ์ €์žฅํ•ด.

ํ•ต์‹ฌ ๊ฐœ๋…์€ CID(Content Identifier)์•ผ. ํŒŒ์ผ์˜ ๋‚ด์šฉ์„ ํ•ด์‹œ(SHA-256 ๊ธฐ๋ฐ˜)๋กœ ๋ณ€ํ™˜ํ•œ ๊ณ ์œ  ์ฃผ์†Œ์ธ๋ฐ,
ํŒŒ์ผ ๋‚ด์šฉ์ด ๊ฐ™์œผ๋ฉด ํ•ญ์ƒ ๊ฐ™์€ CID๊ฐ€ ๋‚˜์™€. ์ฆ‰, ๋‚ด์šฉ ๊ธฐ๋ฐ˜ ์ฃผ์†Œ ์ง€์ •(Content-Addressed Storage)์ด์•ผ.

์‰ฝ๊ฒŒ ๋น„์œ ํ•˜๋ฉด ์ด๋ž˜. ์ผ๋ฐ˜ ์›น์€ "๊ฐ•๋‚จ๊ตฌ ํ…Œํ—ค๋ž€๋กœ 123๋ฒˆ์ง€์— ๊ฐ€๋ฉด ํŒŒ์ผ์ด ์žˆ์–ด"์ฒ˜๋Ÿผ ์œ„์น˜ ๊ธฐ๋ฐ˜์ด์•ผ.
IPFS๋Š” "์ด ๋‚ด์šฉ์„ ๊ฐ€์ง„ ํŒŒ์ผ์„ ์ค˜"์ฒ˜๋Ÿผ ๋‚ด์šฉ ๊ธฐ๋ฐ˜์ด์•ผ. ์œ„์น˜๊ฐ€ ์–ด๋””๋“  ์ƒ๊ด€์—†์–ด.
ํŒŒ์ผ์ด ๋ณ€์กฐ๋˜๋ฉด CID๊ฐ€ ๋‹ฌ๋ผ์ง€๋‹ˆ๊นŒ ๋ฌด๊ฒฐ์„ฑ ๋ณด์žฅ๋„ ์ž๋™์œผ๋กœ ๋ผ. ๐Ÿ”’

๐Ÿ’ก CID ์˜ˆ์‹œ QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco
์ด๋Ÿฐ ํ˜•ํƒœ์˜ ๋ฌธ์ž์—ด์ด IPFS์—์„œ ํŒŒ์ผ์˜ ์ฃผ์†Œ์•ผ. ์ด๊ฑธ ipfs:// ํ”„๋กœํ† ์ฝœ๊ณผ ํ•จ๊ป˜ ์“ฐ๋ฉด ๋ผ.

๐Ÿ“Œ Pinata๊ฐ€ ํ•„์š”ํ•œ ์ด์œ 

IPFS ๋…ธ๋“œ๋ฅผ ์ง์ ‘ ์šด์˜ํ•  ์ˆ˜๋„ ์žˆ์–ด. ๊ทผ๋ฐ ๊ทธ๋Ÿฌ๋ฉด ๋‚ด ์ปดํ“จํ„ฐ๊ฐ€ ๊บผ์ง€๋ฉด ํŒŒ์ผ์— ์ ‘๊ทผ์ด ์•ˆ ๋  ์ˆ˜๋„ ์žˆ์–ด.
Pinata๋Š” IPFS ํ•€๋‹(Pinning) ์„œ๋น„์Šค์•ผ. ์‰ฝ๊ฒŒ ๋งํ•ด์„œ "์šฐ๋ฆฌ ์„œ๋ฒ„์—์„œ ์ด ํŒŒ์ผ์„ ํ•ญ์ƒ IPFS ๋„คํŠธ์›Œํฌ์— ์œ ์ง€ํ•ด์ค„๊ฒŒ"๋ผ๋Š” ์„œ๋น„์Šค์ง€.
๋ฌด๋ฃŒ ํ”Œ๋žœ๋„ ์žˆ๊ณ , API๋„ ์ž˜ ๋˜์–ด ์žˆ์–ด์„œ ํŒŒ์ด์ฌ์œผ๋กœ ์—ฐ๋™ํ•˜๊ธฐ ๋”ฑ ์ข‹์•„. ๐ŸŽฏ

๋‹ค๋ฅธ ์„ ํƒ์ง€๋กœ๋Š” NFT.Storage, Web3.Storage, Infura IPFS ๋“ฑ์ด ์žˆ์–ด.
์˜ค๋Š˜์€ Pinata๋ฅผ ๊ธฐ์ค€์œผ๋กœ ์„ค๋ช…ํ• ๊ฒŒ. ๊ฐ€์žฅ ๋งŽ์ด ์“ฐ์ด๊ณ  ๋ฌธ์„œ๋„ ์ž˜ ๋˜์–ด ์žˆ๊ฑฐ๋“ .


๐Ÿ› ๏ธ ๊ฐœ๋ฐœ ํ™˜๊ฒฝ ์„ธํŒ…๋ถ€ํ„ฐ ์‹œ์ž‘ํ•˜์ž

์ž, ์ด์ œ ๋ณธ๊ฒฉ์ ์œผ๋กœ ์ฝ”๋“œ๋ฅผ ์งœ๋ณผ ๊ฑฐ์•ผ. ๋จผ์ € ํ™˜๊ฒฝ๋ถ€ํ„ฐ ์„ธํŒ…ํ•˜์ž.

๐Ÿ“ฆ ํ•„์š”ํ•œ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ์„ค์น˜

# ๊ฐ€์ƒํ™˜๊ฒฝ ๋งŒ๋“ค๊ธฐ (์„ ํƒ์‚ฌํ•ญ์ด์ง€๋งŒ ๊ฐ•๋ ฅ ์ถ”์ฒœ!)
python -m venv nft-env
source nft-env/bin/activate  # Mac/Linux
# nft-env\Scripts\activate  # Windows

# ํ•„์š”ํ•œ ํŒจํ‚ค์ง€ ์„ค์น˜
pip install requests
pip install web3
pip install python-dotenv
pip install Pillow
๊ฐ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ์—ญํ•  ์ •๋ฆฌ requests HTTP API ํ˜ธ์ถœ (Pinata API ์—ฐ๋™)
web3 ์ด๋”๋ฆฌ์›€ ๋ธ”๋ก์ฒด์ธ ์—ฐ๋™ (๋ฏผํŒ…)
python-dotenv API ํ‚ค ํ™˜๊ฒฝ๋ณ€์ˆ˜ ๊ด€๋ฆฌ
Pillow ์ด๋ฏธ์ง€ ์ฒ˜๋ฆฌ ๋ฐ ๊ฒ€์ฆ

๐Ÿ”‘ Pinata API ํ‚ค ๋ฐœ๊ธ‰๋ฐ›๊ธฐ

1
pinata.cloud ์ ‘์† โ†’ ํšŒ์›๊ฐ€์ž… (๋ฌด๋ฃŒ ํ”Œ๋žœ์œผ๋กœ ์ถฉ๋ถ„ํ•ด)
2
๋Œ€์‹œ๋ณด๋“œ โ†’ API Keys ๋ฉ”๋‰ด โ†’ New Key ํด๋ฆญ
3
๊ถŒํ•œ ์„ค์ •: pinFileToIPFS, pinJSONToIPFS ์ฒดํฌ
4
API Key์™€ API Secret ๋ณต์‚ฌํ•ด์„œ ์•ˆ์ „ํ•œ ๊ณณ์— ์ €์žฅ
โš ๏ธ ์ฃผ์˜! API ํ‚ค๋Š” ์ ˆ๋Œ€ ์ฝ”๋“œ์— ์ง์ ‘ ํ•˜๋“œ์ฝ”๋”ฉํ•˜์ง€ ๋งˆ. GitHub์— ์˜ฌ๋ฆฌ๋ฉด ๋ฐ”๋กœ ํ„ธ๋ ค.
๋ฐ˜๋“œ์‹œ .env ํŒŒ์ผ์ด๋‚˜ ํ™˜๊ฒฝ๋ณ€์ˆ˜๋กœ ๊ด€๋ฆฌํ•ด์•ผ ํ•ด!

๐Ÿ“ ํ”„๋กœ์ ํŠธ ๊ตฌ์กฐ ์žก๊ธฐ

nft-upload-system/
โ”œโ”€โ”€ .env                    # API ํ‚ค (์ ˆ๋Œ€ git์— ์˜ฌ๋ฆฌ์ง€ ๋งˆ!)
โ”œโ”€โ”€ .gitignore              # .env ํฌํ•จ์‹œํ‚ค๊ธฐ
โ”œโ”€โ”€ requirements.txt        # ์˜์กด์„ฑ ๋ชฉ๋ก
โ”œโ”€โ”€ images/                 # ์—…๋กœ๋“œํ•  ์ด๋ฏธ์ง€๋“ค
โ”‚   โ”œโ”€โ”€ nft_001.png
โ”‚   โ”œโ”€โ”€ nft_002.png
โ”‚   โ””โ”€โ”€ ...
โ”œโ”€โ”€ metadata/               # ์ƒ์„ฑ๋œ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์ €์žฅ
โ”œโ”€โ”€ upload_to_ipfs.py       # ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ ๋ชจ๋“ˆ
โ”œโ”€โ”€ create_metadata.py      # ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์ƒ์„ฑ ๋ชจ๋“ˆ
โ”œโ”€โ”€ mint_nft.py             # ๋ฏผํŒ… ๋ชจ๋“ˆ
โ””โ”€โ”€ main.py                 # ๋ฉ”์ธ ์‹คํ–‰ ํŒŒ์ผ

์ด๋ ‡๊ฒŒ ๋ชจ๋“ˆ๋ณ„๋กœ ๋‚˜๋ˆ ๋‘๋ฉด ๋‚˜์ค‘์— ์œ ์ง€๋ณด์ˆ˜ํ•˜๊ธฐ ํ›จ์”ฌ ํŽธํ•ด. ๐Ÿ—‚๏ธ

# .env ํŒŒ์ผ ๋‚ด์šฉ
PINATA_API_KEY=your_api_key_here
PINATA_API_SECRET=your_api_secret_here
PINATA_JWT=your_jwt_token_here
INFURA_PROJECT_ID=your_infura_id
PRIVATE_KEY=your_wallet_private_key
CONTRACT_ADDRESS=your_contract_address

IPFS ์ฝ˜ํ…์ธ  ์ฃผ์†Œ ์ง€์ • ๋ฐฉ์‹ ์ดํ•ดํ•˜๊ธฐ โŒ ์ผ๋ฐ˜ ์›น (์œ„์น˜ ๊ธฐ๋ฐ˜) ์„œ๋ฒ„ A ์ด๋ฏธ์ง€.png ์‚ฌ์šฉ์ž ์š”์ฒญ URL ์ฃผ์†Œ๋กœ ์š”์ฒญ ์„œ๋ฒ„ ๊บผ์ง€๋ฉด? ๐Ÿ’€ ํŒŒ์ผ ์ ‘๊ทผ ๋ถˆ๊ฐ€! โœ… IPFS (๋‚ด์šฉ ๊ธฐ๋ฐ˜) ๋…ธ๋“œ1 CID๋ณด์œ  ๋…ธ๋“œ2 CID๋ณด์œ  ๋…ธ๋“œ3 CID๋ณด์œ  โœจ ์–ด๋””์„œ๋“  ์ ‘๊ทผ ๊ฐ€๋Šฅ! ๐Ÿ”‘ CID (Content Identifier) ์ƒ์„ฑ ์›๋ฆฌ ํŒŒ์ผ ๋‚ด์šฉ โ†’ SHA-256 ํ•ด์‹œ โ†’ Base58 ์ธ์ฝ”๋”ฉ โ†’ CID ์˜ˆ: QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco ๐Ÿ”’ ๋ฌด๊ฒฐ์„ฑ ๋ณด์žฅ ๋‚ด์šฉ ๋ณ€๊ฒฝ ์‹œ CID ๋ณ€๊ฒฝ ์œ„๋ณ€์กฐ ๋ถˆ๊ฐ€ ๐ŸŒ ํƒˆ์ค‘์•™ํ™” ๋‹จ์ผ ์„œ๋ฒ„ ์˜์กด ์—†์Œ ๊ฒ€์—ด ์ €ํ•ญ์„ฑ โ™ป๏ธ ์ค‘๋ณต ์ œ๊ฑฐ ๊ฐ™์€ ํŒŒ์ผ์€ ๊ฐ™์€ CID ์ €์žฅ ํšจ์œจ ๊ทน๋Œ€ํ™”

๐Ÿ ํŒŒ์ด์ฌ์œผ๋กœ ์ด๋ฏธ์ง€ IPFS ์—…๋กœ๋“œ ์ฝ”๋“œ ์ž‘์„ฑ

์ด์ œ ์ง„์งœ ํ•ต์‹ฌ์ด์•ผ. ํŒŒ์ด์ฌ์œผ๋กœ ์ด๋ฏธ์ง€๋ฅผ IPFS์— ์˜ฌ๋ฆฌ๋Š” ์ฝ”๋“œ๋ฅผ ๋‹จ๊ณ„๋ณ„๋กœ ์ž‘์„ฑํ•ด๋ณผ๊ฒŒ.
Pinata๋Š” ๋‘ ๊ฐ€์ง€ ์ธ์ฆ ๋ฐฉ์‹์„ ์ง€์›ํ•ด: API Key/Secret ๋ฐฉ์‹๊ณผ JWT ๋ฐฉ์‹.
JWT ๋ฐฉ์‹์ด ๋” ์ตœ์‹ ์ด๊ณ  ๊ถŒ์žฅ๋˜๋‹ˆ๊นŒ JWT๋กœ ํ• ๊ฒŒ. ๐Ÿ”

๐Ÿ“ค upload_to_ipfs.py - ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ ๋ชจ๋“ˆ

import requests
import os
import json
from pathlib import Path
from dotenv import load_dotenv

# .env ํŒŒ์ผ์—์„œ ํ™˜๊ฒฝ๋ณ€์ˆ˜ ๋กœ๋“œ
load_dotenv()

class IPFSUploader:
    """Pinata๋ฅผ ํ†ตํ•œ IPFS ์—…๋กœ๋“œ ํด๋ž˜์Šค"""
    
    def __init__(self):
        self.jwt = os.getenv("PINATA_JWT")
        self.api_key = os.getenv("PINATA_API_KEY")
        self.api_secret = os.getenv("PINATA_API_SECRET")
        
        # Pinata API ์—”๋“œํฌ์ธํŠธ
        self.pin_file_url = "https://api.pinata.cloud/pinning/pinFileToIPFS"
        self.pin_json_url = "https://api.pinata.cloud/pinning/pinJSONToIPFS"
        
        # JWT ํ—ค๋” (๊ถŒ์žฅ ๋ฐฉ์‹)
        self.headers = {
            "Authorization": f"Bearer {self.jwt}"
        }
    
    def validate_image(self, image_path: str) -> bool:
        """์ด๋ฏธ์ง€ ํŒŒ์ผ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ"""
        path = Path(image_path)
        
        # ํŒŒ์ผ ์กด์žฌ ์—ฌ๋ถ€ ํ™•์ธ
        if not path.exists():
            raise FileNotFoundError(f"ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†์–ด์š”: {image_path}")
        
        # ์ง€์› ํ˜•์‹ ํ™•์ธ
        supported_formats = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'}
        if path.suffix.lower() not in supported_formats:
            raise ValueError(f"์ง€์›ํ•˜์ง€ ์•Š๋Š” ํ˜•์‹์ด์—์š”. ์ง€์› ํ˜•์‹: {supported_formats}")
        
        # ํŒŒ์ผ ํฌ๊ธฐ ํ™•์ธ (Pinata ๋ฌด๋ฃŒ ํ”Œ๋žœ: 100MB ์ œํ•œ)
        file_size_mb = path.stat().st_size / (1024 * 1024)
        if file_size_mb > 100:
            raise ValueError(f"ํŒŒ์ผ์ด ๋„ˆ๋ฌด ์ปค์š”: {file_size_mb:.2f}MB (์ตœ๋Œ€ 100MB)")
        
        print(f"โœ… ์ด๋ฏธ์ง€ ๊ฒ€์ฆ ์™„๋ฃŒ: {path.name} ({file_size_mb:.2f}MB)")
        return True
    
    def upload_image(self, image_path: str, nft_name: str = None) -> dict:
        """
        ์ด๋ฏธ์ง€๋ฅผ IPFS์— ์—…๋กœ๋“œํ•˜๊ณ  CID๋ฅผ ๋ฐ˜ํ™˜
        
        Args:
            image_path: ์—…๋กœ๋“œํ•  ์ด๋ฏธ์ง€ ํŒŒ์ผ ๊ฒฝ๋กœ
            nft_name: NFT ์ด๋ฆ„ (๋ฉ”ํƒ€๋ฐ์ดํ„ฐ์šฉ)
        
        Returns:
            dict: {'cid': str, 'ipfs_url': str, 'gateway_url': str}
        """
        # ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ
        self.validate_image(image_path)
        
        path = Path(image_path)
        pin_name = nft_name or path.stem  # ์ด๋ฆ„ ์—†์œผ๋ฉด ํŒŒ์ผ๋ช… ์‚ฌ์šฉ
        
        # Pinata ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ (IPFS ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ์™€ ๋‹ค๋ฆ„! Pinata ๊ด€๋ฆฌ์šฉ)
        pinata_metadata = {
            "name": pin_name,
            "keyvalues": {
                "type": "nft-image",
                "original_filename": path.name
            }
        }
        
        # Pinata ์˜ต์…˜
        pinata_options = {
            "cidVersion": 1  # CIDv1 ์‚ฌ์šฉ (๋” ์ตœ์‹  ํ˜•์‹)
        }
        
        print(f"๐Ÿ“ค IPFS ์—…๋กœ๋“œ ์‹œ์ž‘: {path.name}")
        
        try:
            with open(image_path, 'rb') as img_file:
                # multipart/form-data ํ˜•์‹์œผ๋กœ ์ „์†ก
                files = {
                    'file': (path.name, img_file, self._get_mime_type(path.suffix))
                }
                data = {
                    'pinataMetadata': json.dumps(pinata_metadata),
                    'pinataOptions': json.dumps(pinata_options)
                }
                
                response = requests.post(
                    self.pin_file_url,
                    headers=self.headers,
                    files=files,
                    data=data,
                    timeout=60  # 60์ดˆ ํƒ€์ž„์•„์›ƒ
                )
                
                response.raise_for_status()  # HTTP ์—๋Ÿฌ ์ฒดํฌ
                result = response.json()
                
                cid = result['IpfsHash']
                
                return {
                    'cid': cid,
                    'ipfs_url': f"ipfs://{cid}",
                    'gateway_url': f"https://gateway.pinata.cloud/ipfs/{cid}",
                    'public_gateway': f"https://ipfs.io/ipfs/{cid}",
                    'pin_size': result.get('PinSize', 0),
                    'timestamp': result.get('Timestamp', '')
                }
                
        except requests.exceptions.RequestException as e:
            raise ConnectionError(f"API ํ˜ธ์ถœ ์‹คํŒจ: {str(e)}")
    
    def _get_mime_type(self, suffix: str) -> str:
        """ํŒŒ์ผ ํ™•์žฅ์ž์— ๋”ฐ๋ฅธ MIME ํƒ€์ž… ๋ฐ˜ํ™˜"""
        mime_types = {
            '.png': 'image/png',
            '.jpg': 'image/jpeg',
            '.jpeg': 'image/jpeg',
            '.gif': 'image/gif',
            '.webp': 'image/webp',
            '.svg': 'image/svg+xml'
        }
        return mime_types.get(suffix.lower(), 'application/octet-stream')
    
    def upload_batch(self, image_folder: str) -> list:
        """
        ํด๋” ๋‚ด ๋ชจ๋“  ์ด๋ฏธ์ง€๋ฅผ ์ผ๊ด„ ์—…๋กœ๋“œ
        
        Args:
            image_folder: ์ด๋ฏธ์ง€๊ฐ€ ์žˆ๋Š” ํด๋” ๊ฒฝ๋กœ
        
        Returns:
            list: ๊ฐ ์ด๋ฏธ์ง€์˜ ์—…๋กœ๋“œ ๊ฒฐ๊ณผ ๋ชฉ๋ก
        """
        folder = Path(image_folder)
        if not folder.is_dir():
            raise NotADirectoryError(f"ํด๋”๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์–ด์š”: {image_folder}")
        
        # ์ง€์› ํ˜•์‹ ์ด๋ฏธ์ง€๋งŒ ํ•„ํ„ฐ๋ง
        image_extensions = {'.png', '.jpg', '.jpeg', '.gif', '.webp'}
        images = sorted([
            f for f in folder.iterdir() 
            if f.suffix.lower() in image_extensions
        ])
        
        if not images:
            raise ValueError(f"์—…๋กœ๋“œํ•  ์ด๋ฏธ์ง€๊ฐ€ ์—†์–ด์š”: {image_folder}")
        
        print(f"๐Ÿ—‚๏ธ ์ด {len(images)}๊ฐœ ์ด๋ฏธ์ง€ ์ผ๊ด„ ์—…๋กœ๋“œ ์‹œ์ž‘!")
        
        results = []
        for idx, img_path in enumerate(images, 1):
            print(f"\n[{idx}/{len(images)}] ์ฒ˜๋ฆฌ ์ค‘...")
            try:
                result = self.upload_image(str(img_path))
                result['filename'] = img_path.name
                result['index'] = idx
                results.append(result)
                print(f"โœ… ์™„๋ฃŒ! CID: {result['cid'][:20]}...")
            except Exception as e:
                print(f"โŒ ์‹คํŒจ: {img_path.name} - {str(e)}")
                results.append({
                    'filename': img_path.name,
                    'index': idx,
                    'error': str(e)
                })
        
        return results
๐Ÿ’ก CIDv0 vs CIDv1 CIDv0๋Š” Qm...์œผ๋กœ ์‹œ์ž‘ํ•˜๋Š” ๊ตฌํ˜• ํ˜•์‹์ด๊ณ , CIDv1์€ bafk...์œผ๋กœ ์‹œ์ž‘ํ•˜๋Š” ์‹ ํ˜• ํ˜•์‹์ด์•ผ.
CIDv1์ด ๋Œ€์†Œ๋ฌธ์ž ๊ตฌ๋ถ„ ์—†๊ณ , ๋” ์•ˆ์ „ํ•ด์„œ ์ƒˆ ํ”„๋กœ์ ํŠธ์—” CIDv1์„ ์“ฐ๋Š” ๊ฒŒ ์ข‹์•„.

๐Ÿงช ์—…๋กœ๋“œ ํ…Œ์ŠคํŠธ ์ฝ”๋“œ

# ๋‹จ์ผ ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ ํ…Œ์ŠคํŠธ
if __name__ == "__main__":
    uploader = IPFSUploader()
    
    # ๋‹จ์ผ ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ
    result = uploader.upload_image(
        image_path="images/nft_001.png",
        nft_name="My Awesome NFT #001"
    )
    
    print("\n๐ŸŽ‰ ์—…๋กœ๋“œ ์„ฑ๊ณต!")
    print(f"CID: {result['cid']}")
    print(f"IPFS URL: {result['ipfs_url']}")
    print(f"๊ฒŒ์ดํŠธ์›จ์ด URL: {result['gateway_url']}")
    print(f"ํŒŒ์ผ ํฌ๊ธฐ: {result['pin_size']} bytes")

๐Ÿ“‹ NFT ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์ƒ์„ฑํ•˜๊ธฐ

์ด๋ฏธ์ง€๋ฅผ IPFS์— ์˜ฌ๋ ธ๋‹ค๊ณ  ๋์ด ์•„๋‹ˆ์•ผ. NFT์—๋Š” ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ(Metadata)๊ฐ€ ํ•„์š”ํ•ด.
๋ฉ”ํƒ€๋ฐ์ดํ„ฐ๋Š” NFT์˜ ์ด๋ฆ„, ์„ค๋ช…, ์†์„ฑ(Attributes), ์ด๋ฏธ์ง€ URL ๋“ฑ์„ ๋‹ด์€ JSON ํŒŒ์ผ์ด์•ผ.
์ด JSON๋„ IPFS์— ์˜ฌ๋ ค์•ผ ํ•ด. ๊ทธ๋ฆฌ๊ณ  ์Šค๋งˆํŠธ ์ปจํŠธ๋ž™ํŠธ์˜ tokenURI๊ฐ€ ์ด JSON์˜ IPFS ์ฃผ์†Œ๋ฅผ ๊ฐ€๋ฆฌํ‚ค๊ฒŒ ๋ผ. ๐ŸŽฏ

๐Ÿ“„ OpenSea ํ‘œ์ค€ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ํ˜•์‹ NFT ๋งˆ์ผ“ํ”Œ๋ ˆ์ด์Šค๋“ค์€ OpenSea ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ํ‘œ์ค€์„ ๋”ฐ๋ผ. ์ด ํ˜•์‹์„ ์ง€ํ‚ค๋ฉด OpenSea, Rarible ๋“ฑ์—์„œ ์ž๋™์œผ๋กœ ์˜ˆ์˜๊ฒŒ ํ‘œ์‹œ๋ผ.
# NFT ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ํ‘œ์ค€ ํ˜•์‹ ์˜ˆ์‹œ
{
    "name": "My Awesome NFT #001",
    "description": "์ด NFT๋Š” ์ •๋ง ๋ฉ‹์ง„ ์ž‘ํ’ˆ์ด์•ผ! ํŒŒ์ด์ฌ์œผ๋กœ ๋งŒ๋“ค์—ˆ์–ด.",
    "image": "ipfs://bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzgivvzsqiotiqe",
    "external_url": "https://www.jaenung.net",
    "attributes": [
        {
            "trait_type": "๋ฐฐ๊ฒฝ",
            "value": "ํŒŒ๋ž€ ํ•˜๋Š˜"
        },
        {
            "trait_type": "ํฌ๊ท€๋„",
            "value": "๋ ˆ์–ด"
        },
        {
            "trait_type": "๋ ˆ๋ฒจ",
            "display_type": "number",
            "value": 5
        },
        {
            "trait_type": "์ƒ์„ฑ์ผ",
            "display_type": "date",
            "value": 1704067200
        }
    ]
}

๐Ÿ—๏ธ create_metadata.py - ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์ƒ์„ฑ ๋ชจ๋“ˆ

import json
import os
import time
from pathlib import Path
from upload_to_ipfs import IPFSUploader

class NFTMetadataCreator:
    """NFT ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์ƒ์„ฑ ๋ฐ IPFS ์—…๋กœ๋“œ ํด๋ž˜์Šค"""
    
    def __init__(self):
        self.uploader = IPFSUploader()
        self.metadata_dir = Path("metadata")
        self.metadata_dir.mkdir(exist_ok=True)
    
    def create_metadata(
        self,
        token_id: int,
        name: str,
        description: str,
        image_cid: str,
        attributes: list = None,
        external_url: str = None
    ) -> dict:
        """
        NFT ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ JSON ์ƒ์„ฑ
        
        Args:
            token_id: NFT ํ† ํฐ ID
            name: NFT ์ด๋ฆ„
            description: NFT ์„ค๋ช…
            image_cid: ์ด๋ฏธ์ง€์˜ IPFS CID
            attributes: ์†์„ฑ ๋ชฉ๋ก (OpenSea ํ‘œ์ค€)
            external_url: ์™ธ๋ถ€ ๋งํฌ URL
        
        Returns:
            dict: ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ๋”•์…”๋„ˆ๋ฆฌ
        """
        metadata = {
            "name": name,
            "description": description,
            "image": f"ipfs://{image_cid}",
            "token_id": token_id,
            "created_at": int(time.time())
        }
        
        if external_url:
            metadata["external_url"] = external_url
        
        if attributes:
            # ์†์„ฑ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ
            validated_attrs = []
            for attr in attributes:
                if "trait_type" not in attr or "value" not in attr:
                    print(f"โš ๏ธ ์ž˜๋ชป๋œ ์†์„ฑ ํ˜•์‹ ๊ฑด๋„ˆ๋œ€: {attr}")
                    continue
                validated_attrs.append(attr)
            metadata["attributes"] = validated_attrs
        
        return metadata
    
    def save_metadata_locally(self, token_id: int, metadata: dict) -> str:
        """๋ฉ”ํƒ€๋ฐ์ดํ„ฐ๋ฅผ ๋กœ์ปฌ์— JSON์œผ๋กœ ์ €์žฅ"""
        file_path = self.metadata_dir / f"{token_id}.json"
        with open(file_path, 'w', encoding='utf-8') as f:
            json.dump(metadata, f, ensure_ascii=False, indent=2)
        print(f"๐Ÿ’พ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ๋กœ์ปฌ ์ €์žฅ: {file_path}")
        return str(file_path)
    
    def upload_metadata_to_ipfs(self, metadata: dict, name: str) -> dict:
        """
        ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ JSON์„ IPFS์— ์—…๋กœ๋“œ
        
        Args:
            metadata: ์—…๋กœ๋“œํ•  ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ๋”•์…”๋„ˆ๋ฆฌ
            name: Pinata์—์„œ ๊ด€๋ฆฌํ•  ์ด๋ฆ„
        
        Returns:
            dict: {'cid': str, 'ipfs_url': str}
        """
        pinata_metadata = {
            "name": name,
            "keyvalues": {
                "type": "nft-metadata"
            }
        }
        
        payload = {
            "pinataContent": metadata,
            "pinataMetadata": pinata_metadata,
            "pinataOptions": {
                "cidVersion": 1
            }
        }
        
        import requests
        headers = {
            "Authorization": f"Bearer {self.uploader.jwt}",
            "Content-Type": "application/json"
        }
        
        print(f"๐Ÿ“ค ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ IPFS ์—…๋กœ๋“œ ์ค‘...")
        
        response = requests.post(
            self.uploader.pin_json_url,
            headers=headers,
            json=payload,
            timeout=30
        )
        response.raise_for_status()
        result = response.json()
        
        cid = result['IpfsHash']
        return {
            'cid': cid,
            'ipfs_url': f"ipfs://{cid}",
            'gateway_url': f"https://gateway.pinata.cloud/ipfs/{cid}"
        }
    
    def process_nft(
        self,
        token_id: int,
        image_path: str,
        name: str,
        description: str,
        attributes: list = None,
        external_url: str = None
    ) -> dict:
        """
        ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ โ†’ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์ƒ์„ฑ โ†’ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์—…๋กœ๋“œ ์ „์ฒด ํŒŒ์ดํ”„๋ผ์ธ
        
        Returns:
            dict: ์ „์ฒด ์ฒ˜๋ฆฌ ๊ฒฐ๊ณผ (์ด๋ฏธ์ง€ CID, ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ CID, tokenURI ํฌํ•จ)
        """
        print(f"\n{'='*50}")
        print(f"๐Ÿš€ NFT #{token_id} ์ฒ˜๋ฆฌ ์‹œ์ž‘: {name}")
        print(f"{'='*50}")
        
        # 1๋‹จ๊ณ„: ์ด๋ฏธ์ง€ IPFS ์—…๋กœ๋“œ
        print("\n[1/3] ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ ์ค‘...")
        image_result = self.uploader.upload_image(image_path, name)
        print(f"โœ… ์ด๋ฏธ์ง€ CID: {image_result['cid']}")
        
        # 2๋‹จ๊ณ„: ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์ƒ์„ฑ
        print("\n[2/3] ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์ƒ์„ฑ ์ค‘...")
        metadata = self.create_metadata(
            token_id=token_id,
            name=name,
            description=description,
            image_cid=image_result['cid'],
            attributes=attributes,
            external_url=external_url
        )
        
        # ๋กœ์ปฌ ์ €์žฅ
        self.save_metadata_locally(token_id, metadata)
        
        # 3๋‹จ๊ณ„: ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ IPFS ์—…๋กœ๋“œ
        print("\n[3/3] ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์—…๋กœ๋“œ ์ค‘...")
        metadata_result = self.upload_metadata_to_ipfs(
            metadata, 
            f"NFT #{token_id} Metadata"
        )
        print(f"โœ… ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ CID: {metadata_result['cid']}")
        
        token_uri = metadata_result['ipfs_url']
        
        print(f"\n๐ŸŽ‰ NFT #{token_id} ์ฒ˜๋ฆฌ ์™„๋ฃŒ!")
        print(f"tokenURI: {token_uri}")
        
        return {
            'token_id': token_id,
            'name': name,
            'image_cid': image_result['cid'],
            'image_url': image_result['ipfs_url'],
            'metadata_cid': metadata_result['cid'],
            'token_uri': token_uri,
            'gateway_url': metadata_result['gateway_url']
        }
๐Ÿ’ก tokenURI๋ž€? ERC-721 ์Šค๋งˆํŠธ ์ปจํŠธ๋ž™ํŠธ์—์„œ ๊ฐ ํ† ํฐ ID์— ์—ฐ๊ฒฐ๋œ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ URL์ด์•ผ.
tokenURI(1)์„ ํ˜ธ์ถœํ•˜๋ฉด ipfs://bafk.../1.json ๊ฐ™์€ ๊ฐ’์ด ๋ฐ˜ํ™˜๋ผ.
OpenSea ๊ฐ™์€ ๋งˆ์ผ“ํ”Œ๋ ˆ์ด์Šค๊ฐ€ ์ด URL์„ ์ฝ์–ด์„œ NFT ์ •๋ณด๋ฅผ ํ‘œ์‹œํ•˜๋Š” ๊ฑฐ์•ผ.

โ›“๏ธ ์Šค๋งˆํŠธ ์ปจํŠธ๋ž™ํŠธ ์—ฐ๋™์œผ๋กœ ์‹ค์ œ ๋ฏผํŒ…ํ•˜๊ธฐ

์ด์ œ ์ง„์งœ ํ•˜์ด๋ผ์ดํŠธ์•ผ! ์ด๋ฏธ์ง€๋„ ์˜ฌ๋ ธ๊ณ , ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ๋„ ์˜ฌ๋ ธ์–ด.
์ด์ œ ์ด๋”๋ฆฌ์›€(๋˜๋Š” Polygon, Sepolia ํ…Œ์ŠคํŠธ๋„ท) ๋ธ”๋ก์ฒด์ธ์— ์‹ค์ œ๋กœ ๋ฏผํŒ…(Minting)์„ ํ•ด๋ณผ ๊ฑฐ์•ผ.
๋ฏผํŒ…์ด๋ž€ ์Šค๋งˆํŠธ ์ปจํŠธ๋ž™ํŠธ๋ฅผ ํ†ตํ•ด NFT๋ฅผ ๋ธ”๋ก์ฒด์ธ์— ๊ธฐ๋กํ•˜๋Š” ํ–‰์œ„์•ผ. ๐Ÿ”จ

โš ๏ธ ํ…Œ์ŠคํŠธ๋„ท ๋จผ์ €! ์‹ค์ œ ์ด๋”๋ฆฌ์›€ ๋ฉ”์ธ๋„ท์—์„œ ํ…Œ์ŠคํŠธํ•˜๋ฉด ๊ฐ€์Šค๋น„๊ฐ€ ๋‚˜๊ฐ€. ์ฒ˜์Œ์—” ๋ฐ˜๋“œ์‹œ Sepolia ํ…Œ์ŠคํŠธ๋„ท์—์„œ ํ…Œ์ŠคํŠธํ•ด.
Sepolia ํ…Œ์ŠคํŠธ ETH๋Š” sepoliafaucet.com์—์„œ ๋ฌด๋ฃŒ๋กœ ๋ฐ›์„ ์ˆ˜ ์žˆ์–ด.

๐Ÿ“œ ERC-721 ์Šค๋งˆํŠธ ์ปจํŠธ๋ž™ํŠธ (Solidity)

๋จผ์ € ๋ฏผํŒ…ํ•  ์Šค๋งˆํŠธ ์ปจํŠธ๋ž™ํŠธ๊ฐ€ ํ•„์š”ํ•ด. Remix IDE(remix.ethereum.org)์—์„œ ๋ฐฐํฌํ•  ์ˆ˜ ์žˆ์–ด.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Counters.sol";

contract MyNFTCollection is ERC721URIStorage, Ownable {
    using Counters for Counters.Counter;
    Counters.Counter private _tokenIds;
    
    // ๋ฏผํŒ… ์ด๋ฒคํŠธ
    event NFTMinted(
        address indexed to,
        uint256 indexed tokenId,
        string tokenURI
    );
    
    constructor() ERC721("My NFT Collection", "MNFT") Ownable(msg.sender) {}
    
    /**
     * @dev NFT ๋ฏผํŒ… ํ•จ์ˆ˜ (์˜ค๋„ˆ๋งŒ ํ˜ธ์ถœ ๊ฐ€๋Šฅ)
     * @param recipient ๋ฐ›์„ ์ง€๊ฐ‘ ์ฃผ์†Œ
     * @param tokenURI IPFS ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ URI
     */
    function mintNFT(
        address recipient,
        string memory tokenURI
    ) public onlyOwner returns (uint256) {
        _tokenIds.increment();
        uint256 newTokenId = _tokenIds.current();
        
        _safeMint(recipient, newTokenId);
        _setTokenURI(newTokenId, tokenURI);
        
        emit NFTMinted(recipient, newTokenId, tokenURI);
        
        return newTokenId;
    }
    
    /**
     * @dev ๋ฐฐ์น˜ ๋ฏผํŒ… (์—ฌ๋Ÿฌ ๊ฐœ ํ•œ ๋ฒˆ์—)
     */
    function batchMint(
        address recipient,
        string[] memory tokenURIs
    ) public onlyOwner returns (uint256[] memory) {
        uint256[] memory tokenIds = new uint256[](tokenURIs.length);
        
        for (uint256 i = 0; i < tokenURIs.length; i++) {
            tokenIds[i] = mintNFT(recipient, tokenURIs[i]);
        }
        
        return tokenIds;
    }
    
    // ํ˜„์žฌ๊นŒ์ง€ ๋ฐœํ–‰๋œ ์ด NFT ์ˆ˜
    function totalSupply() public view returns (uint256) {
        return _tokenIds.current();
    }
}

๐Ÿ mint_nft.py - ํŒŒ์ด์ฌ ๋ฏผํŒ… ๋ชจ๋“ˆ

from web3 import Web3
import os
import json
from dotenv import load_dotenv

load_dotenv()

class NFTMinter:
    """web3.py๋ฅผ ์‚ฌ์šฉํ•œ NFT ๋ฏผํŒ… ํด๋ž˜์Šค"""
    
    # ERC-721 ๋ฏผํŒ…์— ํ•„์š”ํ•œ ์ตœ์†Œ ABI
    MINT_ABI = [
        {
            "inputs": [
                {"internalType": "address", "name": "recipient", "type": "address"},
                {"internalType": "string", "name": "tokenURI", "type": "string"}
            ],
            "name": "mintNFT",
            "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}],
            "stateMutability": "nonpayable",
            "type": "function"
        },
        {
            "inputs": [],
            "name": "totalSupply",
            "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}],
            "stateMutability": "view",
            "type": "function"
        },
        {
            "anonymous": False,
            "inputs": [
                {"indexed": True, "name": "to", "type": "address"},
                {"indexed": True, "name": "tokenId", "type": "uint256"},
                {"indexed": False, "name": "tokenURI", "type": "string"}
            ],
            "name": "NFTMinted",
            "type": "event"
        }
    ]
    
    def __init__(self, network: str = "sepolia"):
        """
        Args:
            network: 'sepolia' (ํ…Œ์ŠคํŠธ๋„ท) ๋˜๋Š” 'mainnet'
        """
        # Infura ๋˜๋Š” Alchemy RPC URL ์„ค์ •
        if network == "sepolia":
            rpc_url = f"https://sepolia.infura.io/v3/{os.getenv('INFURA_PROJECT_ID')}"
        elif network == "polygon":
            rpc_url = f"https://polygon-mainnet.infura.io/v3/{os.getenv('INFURA_PROJECT_ID')}"
        else:
            rpc_url = f"https://mainnet.infura.io/v3/{os.getenv('INFURA_PROJECT_ID')}"
        
        self.w3 = Web3(Web3.HTTPProvider(rpc_url))
        
        if not self.w3.is_connected():
            raise ConnectionError(f"๋ธ”๋ก์ฒด์ธ ์—ฐ๊ฒฐ ์‹คํŒจ: {rpc_url}")
        
        print(f"โœ… {network} ๋„คํŠธ์›Œํฌ ์—ฐ๊ฒฐ ์„ฑ๊ณต!")
        print(f"ํ˜„์žฌ ๋ธ”๋ก: {self.w3.eth.block_number}")
        
        # ์ง€๊ฐ‘ ์„ค์ •
        self.private_key = os.getenv("PRIVATE_KEY")
        self.account = self.w3.eth.account.from_key(self.private_key)
        self.wallet_address = self.account.address
        
        print(f"์ง€๊ฐ‘ ์ฃผ์†Œ: {self.wallet_address}")
        
        # ์ปจํŠธ๋ž™ํŠธ ์„ค์ •
        contract_address = os.getenv("CONTRACT_ADDRESS")
        self.contract = self.w3.eth.contract(
            address=Web3.to_checksum_address(contract_address),
            abi=self.MINT_ABI
        )
    
    def get_balance(self) -> float:
        """์ง€๊ฐ‘ ETH ์ž”์•ก ํ™•์ธ"""
        balance_wei = self.w3.eth.get_balance(self.wallet_address)
        balance_eth = self.w3.from_wei(balance_wei, 'ether')
        return float(balance_eth)
    
    def estimate_gas(self, recipient: str, token_uri: str) -> int:
        """๊ฐ€์Šค๋น„ ์˜ˆ์ธก"""
        return self.contract.functions.mintNFT(
            Web3.to_checksum_address(recipient),
            token_uri
        ).estimate_gas({'from': self.wallet_address})
    
    def mint_nft(self, recipient: str, token_uri: str) -> dict:
        """
        NFT ๋ฏผํŒ… ์‹คํ–‰
        
        Args:
            recipient: NFT๋ฅผ ๋ฐ›์„ ์ง€๊ฐ‘ ์ฃผ์†Œ
            token_uri: IPFS ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ URI (ipfs://...)
        
        Returns:
            dict: ํŠธ๋žœ์žญ์…˜ ๊ฒฐ๊ณผ
        """
        print(f"\n๐Ÿ”จ NFT ๋ฏผํŒ… ์‹œ์ž‘...")
        print(f"์ˆ˜์‹ ์ž: {recipient}")
        print(f"tokenURI: {token_uri}")
        
        # ์ž”์•ก ํ™•์ธ
        balance = self.get_balance()
        print(f"์ง€๊ฐ‘ ์ž”์•ก: {balance:.4f} ETH")
        
        if balance < 0.001:
            raise ValueError("ETH ์ž”์•ก์ด ๋ถ€์กฑํ•ด์š”! ํ…Œ์ŠคํŠธ๋„ท faucet์—์„œ ๋ฐ›์•„์™€.")
        
        # ๊ฐ€์Šค๋น„ ์˜ˆ์ธก
        estimated_gas = self.estimate_gas(recipient, token_uri)
        gas_price = self.w3.eth.gas_price
        gas_cost_eth = self.w3.from_wei(estimated_gas * gas_price, 'ether')
        
        print(f"์˜ˆ์ƒ ๊ฐ€์Šค: {estimated_gas}")
        print(f"์˜ˆ์ƒ ๊ฐ€์Šค๋น„: {gas_cost_eth:.6f} ETH")
        
        # ํŠธ๋žœ์žญ์…˜ ๋นŒ๋“œ
        nonce = self.w3.eth.get_transaction_count(self.wallet_address)
        
        transaction = self.contract.functions.mintNFT(
            Web3.to_checksum_address(recipient),
            token_uri
        ).build_transaction({
            'from': self.wallet_address,
            'nonce': nonce,
            'gas': int(estimated_gas * 1.2),  # 20% ์—ฌ์œ ๋ถ„
            'gasPrice': gas_price,
            'chainId': self.w3.eth.chain_id
        })
        
        # ํŠธ๋žœ์žญ์…˜ ์„œ๋ช…
        signed_txn = self.w3.eth.account.sign_transaction(
            transaction, 
            private_key=self.private_key
        )
        
        # ํŠธ๋žœ์žญ์…˜ ์ „์†ก
        tx_hash = self.w3.eth.send_raw_transaction(signed_txn.rawTransaction)
        print(f"\n๐Ÿ“ก ํŠธ๋žœ์žญ์…˜ ์ „์†ก๋จ!")
        print(f"TX Hash: {tx_hash.hex()}")
        print("โณ ์ปจํŽŒ ๋Œ€๊ธฐ ์ค‘...")
        
        # ํŠธ๋žœ์žญ์…˜ ์˜์ˆ˜์ฆ ๋Œ€๊ธฐ (์ตœ๋Œ€ 120์ดˆ)
        receipt = self.w3.eth.wait_for_transaction_receipt(tx_hash, timeout=120)
        
        if receipt['status'] == 1:
            print(f"\n๐ŸŽ‰ ๋ฏผํŒ… ์„ฑ๊ณต!")
            print(f"๋ธ”๋ก ๋ฒˆํ˜ธ: {receipt['blockNumber']}")
            print(f"๊ฐ€์Šค ์‚ฌ์šฉ๋Ÿ‰: {receipt['gasUsed']}")
            
            return {
                'success': True,
                'tx_hash': tx_hash.hex(),
                'block_number': receipt['blockNumber'],
                'gas_used': receipt['gasUsed'],
                'token_uri': token_uri
            }
        else:
            raise Exception(f"ํŠธ๋žœ์žญ์…˜ ์‹คํŒจ! TX: {tx_hash.hex()}")

๐Ÿ”„ ์ „์ฒด ํŒŒ์ดํ”„๋ผ์ธ ํ†ตํ•ฉ ์‹คํ–‰

์ด์ œ ๋ชจ๋“  ๋ชจ๋“ˆ์„ ํ•ฉ์ณ์„œ ์›ํด๋ฆญ์œผ๋กœ ์ด๋ฏธ์ง€ โ†’ IPFS โ†’ ๋ฏผํŒ…๊นŒ์ง€ ๋˜๋Š” ๋ฉ”์ธ ์Šคํฌ๋ฆฝํŠธ๋ฅผ ๋งŒ๋“ค์–ด๋ณด์ž! ๐Ÿš€

๐ŸŽฏ main.py - ํ†ตํ•ฉ ์‹คํ–‰ ํŒŒ์ผ

import json
import csv
from pathlib import Path
from create_metadata import NFTMetadataCreator
from mint_nft import NFTMinter

def load_nft_config(config_file: str) -> list:
    """
    NFT ์„ค์ • ํŒŒ์ผ ๋กœ๋“œ (JSON ํ˜•์‹)
    
    config.json ์˜ˆ์‹œ:
    [
        {
            "token_id": 1,
            "image": "images/nft_001.png",
            "name": "My NFT #001",
            "description": "์ฒซ ๋ฒˆ์งธ NFT์•ผ!",
            "attributes": [
                {"trait_type": "๋ฐฐ๊ฒฝ", "value": "ํŒŒ๋ž€์ƒ‰"},
                {"trait_type": "ํฌ๊ท€๋„", "value": "๋ ˆ์–ด"}
            ]
        }
    ]
    """
    with open(config_file, 'r', encoding='utf-8') as f:
        return json.load(f)

def save_results(results: list, output_file: str = "mint_results.json"):
    """๋ฏผํŒ… ๊ฒฐ๊ณผ๋ฅผ JSON์œผ๋กœ ์ €์žฅ"""
    with open(output_file, 'w', encoding='utf-8') as f:
        json.dump(results, f, ensure_ascii=False, indent=2)
    print(f"\n๐Ÿ’พ ๊ฒฐ๊ณผ ์ €์žฅ ์™„๋ฃŒ: {output_file}")

def main():
    print("๐Ÿš€ NFT ์—…๋กœ๋“œ & ๋ฏผํŒ… ์‹œ์Šคํ…œ ์‹œ์ž‘!")
    print("="*60)
    
    # ์„ค์ • ๋กœ๋“œ
    config = load_nft_config("nft_config.json")
    print(f"์ด {len(config)}๊ฐœ NFT ์ฒ˜๋ฆฌ ์˜ˆ์ •")
    
    # ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์ƒ์„ฑ๊ธฐ ์ดˆ๊ธฐํ™”
    creator = NFTMetadataCreator()
    
    # ๋ฏผํ„ฐ ์ดˆ๊ธฐํ™” (ํ…Œ์ŠคํŠธ๋„ท)
    minter = NFTMinter(network="sepolia")
    
    results = []
    
    for nft_config in config:
        try:
            # 1. ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ + ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์ƒ์„ฑ + IPFS ์—…๋กœ๋“œ
            nft_data = creator.process_nft(
                token_id=nft_config['token_id'],
                image_path=nft_config['image'],
                name=nft_config['name'],
                description=nft_config['description'],
                attributes=nft_config.get('attributes', []),
                external_url=nft_config.get('external_url')
            )
            
            # 2. ๋ธ”๋ก์ฒด์ธ ๋ฏผํŒ…
            mint_result = minter.mint_nft(
                recipient=minter.wallet_address,  # ์ž๊ธฐ ์ง€๊ฐ‘์œผ๋กœ ๋ฏผํŒ…
                token_uri=nft_data['token_uri']
            )
            
            # ๊ฒฐ๊ณผ ํ•ฉ์น˜๊ธฐ
            final_result = {**nft_data, **mint_result}
            results.append(final_result)
            
            print(f"\nโœ… NFT #{nft_config['token_id']} ์™„๋ฃŒ!")
            
        except Exception as e:
            print(f"\nโŒ NFT #{nft_config['token_id']} ์‹คํŒจ: {str(e)}")
            results.append({
                'token_id': nft_config['token_id'],
                'error': str(e)
            })
    
    # ๊ฒฐ๊ณผ ์ €์žฅ
    save_results(results)
    
    # ์š”์•ฝ ์ถœ๋ ฅ
    success_count = sum(1 for r in results if 'error' not in r)
    print(f"\n{'='*60}")
    print(f"๐Ÿ ์ฒ˜๋ฆฌ ์™„๋ฃŒ!")
    print(f"์„ฑ๊ณต: {success_count}/{len(config)}")
    print(f"์‹คํŒจ: {len(config) - success_count}/{len(config)}")

if __name__ == "__main__":
    main()

๐Ÿ“ nft_config.json ์˜ˆ์‹œ

[
    {
        "token_id": 1,
        "image": "images/nft_001.png",
        "name": "ํŒŒ์ด์ฌ ๋“œ๋ž˜๊ณค #001",
        "description": "ํŒŒ์ด์ฌ์œผ๋กœ ํƒ„์ƒํ•œ ์ „์„ค์˜ ๋“œ๋ž˜๊ณค. ์ฝ”๋“œ๋ฅผ ๋จน๊ณ  ์ž๋ž€๋‹ค.",
        "external_url": "https://www.jaenung.net",
        "attributes": [
            {"trait_type": "์ข…์กฑ", "value": "๋“œ๋ž˜๊ณค"},
            {"trait_type": "์›์†Œ", "value": "ํŒŒ์ด์–ด"},
            {"trait_type": "ํฌ๊ท€๋„", "value": "๋ ˆ์ „๋”๋ฆฌ"},
            {
                "trait_type": "๊ณต๊ฒฉ๋ ฅ",
                "display_type": "number",
                "value": 95,
                "max_value": 100
            },
            {
                "trait_type": "์ƒ์„ฑ ํƒ€์ž„์Šคํƒฌํ”„",
                "display_type": "date",
                "value": 1704067200
            }
        ]
    },
    {
        "token_id": 2,
        "image": "images/nft_002.png",
        "name": "IPFS ๋งˆ๋ฒ•์‚ฌ #002",
        "description": "๋ถ„์‚ฐ ๋„คํŠธ์›Œํฌ๋ฅผ ์ง€๋ฐฐํ•˜๋Š” ๋งˆ๋ฒ•์‚ฌ. CID๋ฅผ ์ž์œ ์ž์žฌ๋กœ ๋‹ค๋ฃฌ๋‹ค.",
        "attributes": [
            {"trait_type": "์ง์—…", "value": "๋งˆ๋ฒ•์‚ฌ"},
            {"trait_type": "์›์†Œ", "value": "์•„์ด์Šค"},
            {"trait_type": "ํฌ๊ท€๋„", "value": "์—ํ”ฝ"},
            {
                "trait_type": "๋งˆ๋ ฅ",
                "display_type": "number",
                "value": 88,
                "max_value": 100
            }
        ]
    }
]

NFT ๋ฏผํŒ… ํŠธ๋žœ์žญ์…˜ ํ๋ฆ„๋„ ๐Ÿ ํŒŒ์ด์ฌ ์•ฑ web3.py ํŠธ๋žœ์žญ์…˜ ๋นŒ๋“œ ๐Ÿ” ์ง€๊ฐ‘ ์„œ๋ช… ๊ฐœ์ธํ‚ค ์„œ๋ช… rawTransaction ๐Ÿ“ก RPC ๋…ธ๋“œ Infura/Alchemy ๋„คํŠธ์›Œํฌ ์ „ํŒŒ โ›“๏ธ ๋ธ”๋ก์ฒด์ธ ์ปจํŠธ๋ž™ํŠธ ์‹คํ–‰ NFT ๋ฐœํ–‰! ๐Ÿ“‹ ํŠธ๋žœ์žญ์…˜ ๊ตฌ์กฐ from ์ง€๊ฐ‘ ์ฃผ์†Œ to ์ปจํŠธ๋ž™ํŠธ ์ฃผ์†Œ data mintNFT ํ•จ์ˆ˜ ํ˜ธ์ถœ gas ์˜ˆ์ƒ๊ฐ’ ร— 1.2 nonce ์ˆœ์„œ ๋ณด์žฅ ์„œ๋ช… ํ›„ โ†’ RPC ์ „์†ก โ†’ ๋ธ”๋ก ํฌํ•จ โ†’ ์˜์ˆ˜์ฆ ์ˆ˜์‹  ๐ŸŽซ ๋ฐœํ–‰๋œ NFT ์ •๋ณด Token ID: 1 Owner: 0x์ง€๊ฐ‘์ฃผ์†Œ tokenURI: ipfs://CID ๋ธ”๋ก์ฒด์ธ์— ์˜๊ตฌ ๊ธฐ๋ก! ๐Ÿ“Š ํŠธ๋žœ์žญ์…˜ ์˜์ˆ˜์ฆ status: 1 (์„ฑ๊ณต) blockNumber: ํ™•์ธ gasUsed: ์‹ค์ œ ์‚ฌ์šฉ๋Ÿ‰ transactionHash: TX ID ๐ŸŒ ๋งˆ์ผ“ํ”Œ๋ ˆ์ด์Šค ํ™•์ธ OpenSea์—์„œ ํ™•์ธ ์ด๋ฏธ์ง€ + ์†์„ฑ ํ‘œ์‹œ tokenURI ์ฝ์–ด์„œ ์ž๋™ ๋ Œ๋”๋ง!

๐Ÿ›ก๏ธ ๋ณด์•ˆ๊ณผ ์—๋Ÿฌ ์ฒ˜๋ฆฌ - ์‹ค์ „์—์„œ ๊ผญ ํ•„์š”ํ•œ ๊ฒƒ๋“ค

์ฝ”๋“œ๊ฐ€ ๋Œ์•„๊ฐ„๋‹ค๊ณ  ๋์ด ์•„๋‹ˆ์•ผ. ์‹ค์ œ ์„œ๋น„์Šค์—์„œ๋Š” ๋ณด์•ˆ๊ณผ ์—๋Ÿฌ ์ฒ˜๋ฆฌ๊ฐ€ ์ƒ๋ช…์ด์•ผ.
ํŠนํžˆ ๊ฐœ์ธํ‚ค์™€ API ํ‚ค๋ฅผ ๋‹ค๋ฃจ๋Š” ์ฝ”๋“œ๋‹ˆ๊นŒ ๋”๋”์šฑ ์‹ ๊ฒฝ ์จ์•ผ ํ•ด. ๐Ÿ”’

๐Ÿ” ๋ณด์•ˆ ์ฒดํฌ๋ฆฌ์ŠคํŠธ

ํ•ญ๋ชฉ ์œ„ํ—˜๋„ ๋Œ€์‘ ๋ฐฉ๋ฒ•
๊ฐœ์ธํ‚ค ํ•˜๋“œ์ฝ”๋”ฉ ๐Ÿ”ด ์น˜๋ช…์  .env ํŒŒ์ผ + python-dotenv ์‚ฌ์šฉ
API ํ‚ค GitHub ๋…ธ์ถœ ๐Ÿ”ด ์น˜๋ช…์  .gitignore์— .env ์ถ”๊ฐ€ ํ•„์ˆ˜
๋ฉ”์ธ๋„ท ์ง์ ‘ ํ…Œ์ŠคํŠธ ๐ŸŸ  ๋†’์Œ Sepolia ํ…Œ์ŠคํŠธ๋„ท ๋จผ์ € ๊ฒ€์ฆ
๊ฐ€์Šค๋น„ ๋ฏธ์˜ˆ์ธก ๐ŸŸก ์ค‘๊ฐ„ estimate_gas() ํ•ญ์ƒ ๋จผ์ € ํ˜ธ์ถœ
ํƒ€์ž„์•„์›ƒ ๋ฏธ์„ค์ • ๐ŸŸก ์ค‘๊ฐ„ requests timeout ํŒŒ๋ผ๋ฏธํ„ฐ ์„ค์ •
์ž…๋ ฅ๊ฐ’ ๋ฏธ๊ฒ€์ฆ ๐ŸŸก ์ค‘๊ฐ„ ์ฃผ์†Œ ์ฒดํฌ์„ฌ, ํŒŒ์ผ ํ˜•์‹ ๊ฒ€์ฆ

๐Ÿ”„ ์žฌ์‹œ๋„ ๋กœ์ง ์ถ”๊ฐ€

import time
import functools

def retry(max_attempts: int = 3, delay: float = 2.0, backoff: float = 2.0):
    """
    ์žฌ์‹œ๋„ ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ
    ๋„คํŠธ์›Œํฌ ์˜ค๋ฅ˜๋‚˜ ์ผ์‹œ์  API ์˜ค๋ฅ˜ ์‹œ ์ž๋™ ์žฌ์‹œ๋„
    """
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            attempt = 0
            current_delay = delay
            
            while attempt < max_attempts:
                try:
                    return func(*args, **kwargs)
                except (ConnectionError, TimeoutError) as e:
                    attempt += 1
                    if attempt == max_attempts:
                        raise
                    print(f"โš ๏ธ ์‹œ๋„ {attempt}/{max_attempts} ์‹คํŒจ: {e}")
                    print(f"โณ {current_delay}์ดˆ ํ›„ ์žฌ์‹œ๋„...")
                    time.sleep(current_delay)
                    current_delay *= backoff  # ์ง€์ˆ˜ ๋ฐฑ์˜คํ”„
        return wrapper
    return decorator

# ์‚ฌ์šฉ ์˜ˆ์‹œ
class IPFSUploaderWithRetry(IPFSUploader):
    
    @retry(max_attempts=3, delay=2.0)
    def upload_image(self, image_path: str, nft_name: str = None) -> dict:
        return super().upload_image(image_path, nft_name)

# ํŠธ๋žœ์žญ์…˜ ์ƒํƒœ ๋ชจ๋‹ˆํ„ฐ๋ง
def monitor_transaction(w3, tx_hash: str, timeout: int = 300) -> dict:
    """
    ํŠธ๋žœ์žญ์…˜ ์ƒํƒœ๋ฅผ ์ฃผ๊ธฐ์ ์œผ๋กœ ํ™•์ธ
    
    Args:
        w3: Web3 ์ธ์Šคํ„ด์Šค
        tx_hash: ํŠธ๋žœ์žญ์…˜ ํ•ด์‹œ
        timeout: ์ตœ๋Œ€ ๋Œ€๊ธฐ ์‹œ๊ฐ„ (์ดˆ)
    """
    start_time = time.time()
    
    while time.time() - start_time < timeout:
        try:
            receipt = w3.eth.get_transaction_receipt(tx_hash)
            
            if receipt is not None:
                if receipt['status'] == 1:
                    return {'status': 'success', 'receipt': receipt}
                else:
                    return {'status': 'failed', 'receipt': receipt}
            
            # ์•„์ง ์ฒ˜๋ฆฌ ์ค‘
            elapsed = int(time.time() - start_time)
            print(f"โณ ๋Œ€๊ธฐ ์ค‘... ({elapsed}์ดˆ ๊ฒฝ๊ณผ)")
            time.sleep(5)
            
        except Exception as e:
            print(f"โš ๏ธ ์ƒํƒœ ํ™•์ธ ์˜ค๋ฅ˜: {e}")
            time.sleep(5)
    
    return {'status': 'timeout', 'tx_hash': tx_hash}
โš ๏ธ ๊ฐœ์ธํ‚ค ๊ด€๋ฆฌ ์ฃผ์˜์‚ฌํ•ญ ์‹ค์ œ ์„œ๋น„์Šค์—์„œ๋Š” ๊ฐœ์ธํ‚ค๋ฅผ ์„œ๋ฒ„์— ์ง์ ‘ ์ €์žฅํ•˜์ง€ ๋งˆ. AWS KMS, HashiCorp Vault ๊ฐ™์€ ํ‚ค ๊ด€๋ฆฌ ์„œ๋น„์Šค๋ฅผ ์‚ฌ์šฉํ•˜๊ฑฐ๋‚˜,
ํ•˜๋“œ์›จ์–ด ์ง€๊ฐ‘(Ledger, Trezor)๊ณผ ์—ฐ๋™ํ•˜๋Š” ๋ฐฉ์‹์„ ๊ณ ๋ คํ•ด๋ด.

โšก ์„ฑ๋Šฅ ์ตœ์ ํ™” - ๋Œ€๋Ÿ‰ ๋ฏผํŒ…์„ ์œ„ํ•œ ํŒ

10,000๊ฐœ์งœ๋ฆฌ PFP(Profile Picture) NFT ์ปฌ๋ ‰์…˜์„ ๋งŒ๋“ ๋‹ค๊ณ  ์ƒ๊ฐํ•ด๋ด.
ํ•˜๋‚˜์”ฉ ์ˆœ์„œ๋Œ€๋กœ ์˜ฌ๋ฆฌ๋ฉด ์—„์ฒญ ์˜ค๋ž˜ ๊ฑธ๋ฆฌ๊ฒ ์ง€? ์ด๋Ÿด ๋•Œ ๋น„๋™๊ธฐ ์ฒ˜๋ฆฌ๊ฐ€ ํ•„์š”ํ•ด. ๐Ÿš€

๐Ÿ”€ ๋น„๋™๊ธฐ ๋ฐฐ์น˜ ์—…๋กœ๋“œ (asyncio + aiohttp)

import asyncio
import aiohttp
import aiofiles
import os
from pathlib import Path

class AsyncIPFSUploader:
    """๋น„๋™๊ธฐ IPFS ์—…๋กœ๋“œ ํด๋ž˜์Šค - ๋Œ€๋Ÿ‰ ์—…๋กœ๋“œ์— ์ตœ์ ํ™”"""
    
    def __init__(self, max_concurrent: int = 5):
        """
        Args:
            max_concurrent: ๋™์‹œ ์—…๋กœ๋“œ ์ตœ๋Œ€ ์ˆ˜ (Pinata ๋ฌด๋ฃŒ ํ”Œ๋žœ: 5 ๊ถŒ์žฅ)
        """
        self.jwt = os.getenv("PINATA_JWT")
        self.pin_file_url = "https://api.pinata.cloud/pinning/pinFileToIPFS"
        self.semaphore = asyncio.Semaphore(max_concurrent)
    
    async def upload_single(
        self, 
        session: aiohttp.ClientSession, 
        image_path: str,
        index: int
    ) -> dict:
        """๋‹จ์ผ ์ด๋ฏธ์ง€ ๋น„๋™๊ธฐ ์—…๋กœ๋“œ"""
        async with self.semaphore:  # ๋™์‹œ ์š”์ฒญ ์ˆ˜ ์ œํ•œ
            path = Path(image_path)
            
            async with aiofiles.open(image_path, 'rb') as f:
                file_data = await f.read()
            
            form_data = aiohttp.FormData()
            form_data.add_field(
                'file',
                file_data,
                filename=path.name,
                content_type='image/png'
            )
            form_data.add_field(
                'pinataMetadata',
                f'{{"name": "NFT #{index}"}}'
            )
            
            headers = {"Authorization": f"Bearer {self.jwt}"}
            
            async with session.post(
                self.pin_file_url,
                headers=headers,
                data=form_data,
                timeout=aiohttp.ClientTimeout(total=60)
            ) as response:
                result = await response.json()
                return {
                    'index': index,
                    'filename': path.name,
                    'cid': result['IpfsHash'],
                    'ipfs_url': f"ipfs://{result['IpfsHash']}"
                }
    
    async def upload_batch_async(self, image_paths: list) -> list:
        """์—ฌ๋Ÿฌ ์ด๋ฏธ์ง€ ๋น„๋™๊ธฐ ๋ณ‘๋ ฌ ์—…๋กœ๋“œ"""
        async with aiohttp.ClientSession() as session:
            tasks = [
                self.upload_single(session, path, idx+1)
                for idx, path in enumerate(image_paths)
            ]
            
            # ์ง„ํ–‰ ์ƒํ™ฉ ํ‘œ์‹œ์™€ ํ•จ๊ป˜ ์‹คํ–‰
            results = []
            for coro in asyncio.as_completed(tasks):
                result = await coro
                results.append(result)
                print(f"โœ… [{len(results)}/{len(tasks)}] {result['filename']} ์™„๋ฃŒ")
            
            return sorted(results, key=lambda x: x['index'])

# ์‹คํ–‰ ์˜ˆ์‹œ
async def main_async():
    uploader = AsyncIPFSUploader(max_concurrent=5)
    
    # ์ด๋ฏธ์ง€ ๋ชฉ๋ก ๊ฐ€์ ธ์˜ค๊ธฐ
    images = list(Path("images").glob("*.png"))
    image_paths = [str(img) for img in sorted(images)]
    
    print(f"๐Ÿš€ {len(image_paths)}๊ฐœ ์ด๋ฏธ์ง€ ๋น„๋™๊ธฐ ์—…๋กœ๋“œ ์‹œ์ž‘!")
    
    results = await uploader.upload_batch_async(image_paths)
    
    print(f"\n๐ŸŽ‰ ์™„๋ฃŒ! {len(results)}๊ฐœ ์—…๋กœ๋“œ๋จ")
    return results

# asyncio ์‹คํ–‰
if __name__ == "__main__":
    results = asyncio.run(main_async())
๐Ÿ“Š ์„ฑ๋Šฅ ๋น„๊ต (100๊ฐœ ์ด๋ฏธ์ง€ ๊ธฐ์ค€) ์ˆœ์ฐจ ์—…๋กœ๋“œ: ์•ฝ 200์ดˆ (๊ฐœ๋‹น 2์ดˆ ร— 100๊ฐœ)
๋น„๋™๊ธฐ 5๊ฐœ ๋™์‹œ: ์•ฝ 45์ดˆ (5๋ฐฐ ๋น ๋ฆ„)
๋น„๋™๊ธฐ 10๊ฐœ ๋™์‹œ: ์•ฝ 25์ดˆ (8๋ฐฐ ๋น ๋ฆ„, API ์ œํ•œ ์ฃผ์˜)

โš ๏ธ Pinata ๋ฌด๋ฃŒ ํ”Œ๋žœ์€ ๋ถ„๋‹น 180 ์š”์ฒญ ์ œํ•œ์ด ์žˆ์–ด. ๋„ˆ๋ฌด ๋น ๋ฅด๊ฒŒ ๋ณด๋‚ด๋ฉด 429 ์—๋Ÿฌ๊ฐ€ ๋‚˜๋‹ˆ๊นŒ ์ ์ ˆํžˆ ์กฐ์ ˆํ•ด.

๐ŸŽจ ์ด๋ฏธ์ง€ ์ž๋™ ์ƒ์„ฑ๊นŒ์ง€ ์—ฐ๊ฒฐํ•˜๊ธฐ

์ง„์งœ NFT ํ”„๋กœ์ ํŠธ๋“ค์€ ์ด๋ฏธ์ง€๋ฅผ ํ•˜๋‚˜ํ•˜๋‚˜ ๊ทธ๋ฆฌ๋Š” ๊ฒŒ ์•„๋‹ˆ์•ผ.
๋ ˆ์ด์–ด(Layer) ๊ธฐ๋ฐ˜์œผ๋กœ ์ด๋ฏธ์ง€๋ฅผ ์ž๋™ ์กฐํ•ฉํ•ด์„œ ์ˆ˜์ฒœ ๊ฐœ๋ฅผ ๋งŒ๋“ค์–ด. ํŒŒ์ด์ฌ Pillow ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋กœ ์ด๊ฑธ ๊ตฌํ˜„ํ•  ์ˆ˜ ์žˆ์–ด! ๐ŸŽจ

from PIL import Image
import random
import os
from pathlib import Path

class NFTImageGenerator:
    """๋ ˆ์ด์–ด ๊ธฐ๋ฐ˜ NFT ์ด๋ฏธ์ง€ ์ž๋™ ์ƒ์„ฑ๊ธฐ"""
    
    def __init__(self, layers_dir: str, output_dir: str, size: tuple = (1000, 1000)):
        """
        Args:
            layers_dir: ๋ ˆ์ด์–ด ํด๋” ๊ฒฝ๋กœ
            output_dir: ์ƒ์„ฑ๋œ ์ด๋ฏธ์ง€ ์ €์žฅ ํด๋”
            size: ์ด๋ฏธ์ง€ ํฌ๊ธฐ (width, height)
        
        ๋ ˆ์ด์–ด ํด๋” ๊ตฌ์กฐ:
        layers/
        โ”œโ”€โ”€ 01_background/    (๋ฐฐ๊ฒฝ)
        โ”‚   โ”œโ”€โ”€ blue.png
        โ”‚   โ””โ”€โ”€ red.png
        โ”œโ”€โ”€ 02_body/          (๋ชธํ†ต)
        โ”‚   โ”œโ”€โ”€ normal.png
        โ”‚   โ””โ”€โ”€ robot.png
        โ”œโ”€โ”€ 03_eyes/          (๋ˆˆ)
        โ”‚   โ”œโ”€โ”€ happy.png
        โ”‚   โ””โ”€โ”€ sad.png
        โ””โ”€โ”€ 04_accessory/     (์•…์„ธ์„œ๋ฆฌ)
            โ”œโ”€โ”€ hat.png
            โ””โ”€โ”€ glasses.png
        """
        self.layers_dir = Path(layers_dir)
        self.output_dir = Path(output_dir)
        self.output_dir.mkdir(exist_ok=True)
        self.size = size
        
        # ๋ ˆ์ด์–ด ๋กœ๋“œ (ํด๋”๋ช… ์ˆœ์„œ๋Œ€๋กœ ์ •๋ ฌ)
        self.layers = self._load_layers()
        print(f"โœ… {len(self.layers)}๊ฐœ ๋ ˆ์ด์–ด ๋กœ๋“œ ์™„๋ฃŒ")
    
    def _load_layers(self) -> dict:
        """๋ ˆ์ด์–ด ํด๋” ๊ตฌ์กฐ ๋กœ๋“œ"""
        layers = {}
        
        for layer_dir in sorted(self.layers_dir.iterdir()):
            if layer_dir.is_dir():
                layer_name = layer_dir.name.split('_', 1)[-1]  # ๋ฒˆํ˜ธ ์ œ๊ฑฐ
                traits = list(layer_dir.glob("*.png"))
                
                if traits:
                    layers[layer_name] = traits
                    print(f"  ๋ ˆ์ด์–ด '{layer_name}': {len(traits)}๊ฐœ ํŠธ๋ ˆ์ž‡")
        
        return layers
    
    def generate_single(self, token_id: int, seed: int = None) -> dict:
        """
        ๋‹จ์ผ NFT ์ด๋ฏธ์ง€ ์ƒ์„ฑ
        
        Returns:
            dict: {'image_path': str, 'attributes': list}
        """
        if seed:
            random.seed(seed)
        
        # ๋ฒ ์ด์Šค ์ด๋ฏธ์ง€ (ํˆฌ๋ช… ๋ฐฐ๊ฒฝ)
        composite = Image.new('RGBA', self.size, (0, 0, 0, 0))
        
        selected_traits = {}
        attributes = []
        
        for layer_name, trait_files in self.layers.items():
            # ๋žœ๋ค์œผ๋กœ ํŠธ๋ ˆ์ž‡ ์„ ํƒ
            selected_file = random.choice(trait_files)
            trait_name = selected_file.stem  # ํŒŒ์ผ๋ช… (ํ™•์žฅ์ž ์ œ์™ธ)
            
            # ๋ ˆ์ด์–ด ํ•ฉ์„ฑ
            layer_img = Image.open(selected_file).convert('RGBA')
            layer_img = layer_img.resize(self.size, Image.LANCZOS)
            composite = Image.alpha_composite(composite, layer_img)
            
            selected_traits[layer_name] = trait_name
            attributes.append({
                "trait_type": layer_name,
                "value": trait_name
            })
        
        # PNG๋กœ ์ €์žฅ
        output_path = self.output_dir / f"{token_id}.png"
        composite.convert('RGB').save(output_path, 'PNG', optimize=True)
        
        return {
            'image_path': str(output_path),
            'attributes': attributes,
            'traits': selected_traits
        }
    
    def generate_collection(
        self, 
        count: int, 
        ensure_unique: bool = True
    ) -> list:
        """
        NFT ์ปฌ๋ ‰์…˜ ์ „์ฒด ์ƒ์„ฑ
        
        Args:
            count: ์ƒ์„ฑํ•  NFT ์ˆ˜
            ensure_unique: ์ค‘๋ณต ์กฐํ•ฉ ๋ฐฉ์ง€ ์—ฌ๋ถ€
        """
        print(f"\n๐ŸŽจ {count}๊ฐœ NFT ์ด๋ฏธ์ง€ ์ƒ์„ฑ ์‹œ์ž‘!")
        
        generated = []
        used_combinations = set()
        
        for i in range(1, count + 1):
            attempts = 0
            
            while True:
                result = self.generate_single(i)
                
                # ์ค‘๋ณต ์ฒดํฌ
                combo_key = str(sorted(result['traits'].items()))
                
                if not ensure_unique or combo_key not in used_combinations:
                    used_combinations.add(combo_key)
                    result['token_id'] = i
                    generated.append(result)
                    
                    if i % 100 == 0:
                        print(f"  ์ง„ํ–‰: {i}/{count}")
                    break
                
                attempts += 1
                if attempts > 100:
                    print(f"โš ๏ธ #{i}: ๊ณ ์œ  ์กฐํ•ฉ ์ฐพ๊ธฐ ์‹คํŒจ, ์ค‘๋ณต ํ—ˆ์šฉ")
                    result['token_id'] = i
                    generated.append(result)
                    break
        
        print(f"โœ… {len(generated)}๊ฐœ ์ด๋ฏธ์ง€ ์ƒ์„ฑ ์™„๋ฃŒ!")
        return generated
๐Ÿ’ก ํฌ๊ท€๋„(Rarity) ์„ค์ •ํ•˜๊ธฐ ๊ฐ ํŠธ๋ ˆ์ž‡์— ๊ฐ€์ค‘์น˜๋ฅผ ์ค˜์„œ ํฌ๊ท€ํ•œ ์•„์ดํ…œ์ด ๋œ ๋‚˜์˜ค๊ฒŒ ํ•  ์ˆ˜ ์žˆ์–ด.
์˜ˆ๋ฅผ ๋“ค์–ด random.choices(trait_files, weights=[70, 20, 10])์ฒ˜๋Ÿผ ๊ฐ€์ค‘์น˜๋ฅผ ์„ค์ •ํ•˜๋ฉด
์ฒซ ๋ฒˆ์งธ ํŠธ๋ ˆ์ž‡์ด 70% ํ™•๋ฅ , ์„ธ ๋ฒˆ์งธ๊ฐ€ 10% ํ™•๋ฅ ๋กœ ๋‚˜์˜ค๊ฒŒ ๋ผ.

๐ŸŒ ๋‹ค์–‘ํ•œ ๋„คํŠธ์›Œํฌ์—์„œ ๋ฐฐํฌํ•˜๊ธฐ

์ด๋”๋ฆฌ์›€ ๋ฉ”์ธ๋„ท๋งŒ ์žˆ๋Š” ๊ฒŒ ์•„๋‹ˆ์•ผ. ๊ฐ€์Šค๋น„๊ฐ€ ์ €๋ ดํ•œ ๋ ˆ์ด์–ด2๋‚˜ ์‚ฌ์ด๋“œ์ฒด์ธ๋„ ๋งŽ์ด ์จ. ๐ŸŒ

๋„คํŠธ์›Œํฌ ํŠน์ง• ๊ฐ€์Šค๋น„ RPC URL
Ethereum Mainnet ๊ฐ€์žฅ ์•ˆ์ „, ๋†’์€ ์œ ๋™์„ฑ ๐Ÿ”ด ๋น„์Œˆ mainnet.infura.io
Polygon (MATIC) ๋น ๋ฆ„, ์ €๋ ด, OpenSea ์ง€์› ๐ŸŸข ๋งค์šฐ ์ €๋ ด polygon-rpc.com
Arbitrum One ์ด๋”๋ฆฌ์›€ L2, ๋น ๋ฆ„ ๐ŸŸก ์ €๋ ด arb1.arbitrum.io
Optimism ์ด๋”๋ฆฌ์›€ L2 ๐ŸŸก ์ €๋ ด mainnet.optimism.io
Sepolia (ํ…Œ์ŠคํŠธ) ์ด๋”๋ฆฌ์›€ ํ…Œ์ŠคํŠธ๋„ท ๐Ÿ†“ ๋ฌด๋ฃŒ sepolia.infura.io
# ๋„คํŠธ์›Œํฌ๋ณ„ ์„ค์ • ๋”•์…”๋„ˆ๋ฆฌ
NETWORK_CONFIG = {
    "ethereum": {
        "rpc": f"https://mainnet.infura.io/v3/{os.getenv('INFURA_PROJECT_ID')}",
        "chain_id": 1,
        "explorer": "https://etherscan.io"
    },
    "polygon": {
        "rpc": "https://polygon-rpc.com",
        "chain_id": 137,
        "explorer": "https://polygonscan.com"
    },
    "arbitrum": {
        "rpc": "https://arb1.arbitrum.io/rpc",
        "chain_id": 42161,
        "explorer": "https://arbiscan.io"
    },
    "sepolia": {
        "rpc": f"https://sepolia.infura.io/v3/{os.getenv('INFURA_PROJECT_ID')}",
        "chain_id": 11155111,
        "explorer": "https://sepolia.etherscan.io"
    }
}

def get_minter(network: str) -> NFTMinter:
    """๋„คํŠธ์›Œํฌ ์ด๋ฆ„์œผ๋กœ ๋ฏผํ„ฐ ์ธ์Šคํ„ด์Šค ์ƒ์„ฑ"""
    config = NETWORK_CONFIG.get(network)
    if not config:
        raise ValueError(f"์ง€์›ํ•˜์ง€ ์•Š๋Š” ๋„คํŠธ์›Œํฌ: {network}")
    
    w3 = Web3(Web3.HTTPProvider(config['rpc']))
    # ... ๋‚˜๋จธ์ง€ ์„ค์ •
    return minter

๐Ÿ” ํŠธ๋Ÿฌ๋ธ”์ŠˆํŒ… - ์ž์ฃผ ๋งŒ๋‚˜๋Š” ์—๋Ÿฌ๋“ค

๊ฐœ๋ฐœํ•˜๋‹ค ๋ณด๋ฉด ๋ถ„๋ช…ํžˆ ์—๋Ÿฌ๋ฅผ ๋งŒ๋‚  ๊ฑฐ์•ผ. ๋ฏธ๋ฆฌ ์•Œ์•„๋‘๋ฉด ์‹œ๊ฐ„ ๋‚ญ๋น„ ์—†์ด ํ•ด๊ฒฐํ•  ์ˆ˜ ์žˆ์–ด! ๐Ÿ› ๏ธ

โŒ ์—๋Ÿฌ 1: "IPFS Hash not found" ๋˜๋Š” ๊ฒŒ์ดํŠธ์›จ์ด ํƒ€์ž„์•„์›ƒ ์›์ธ: ํŒŒ์ผ์ด IPFS ๋„คํŠธ์›Œํฌ์— ์ „ํŒŒ๋˜๋Š” ๋ฐ ์‹œ๊ฐ„์ด ๊ฑธ๋ ค. ๋ฐฉ๊ธˆ ์—…๋กœ๋“œํ•œ ํŒŒ์ผ์€ ๋ฐ”๋กœ ์ ‘๊ทผ์ด ์•ˆ ๋  ์ˆ˜ ์žˆ์–ด.
ํ•ด๊ฒฐ: Pinata ๊ฒŒ์ดํŠธ์›จ์ด(gateway.pinata.cloud)๋ฅผ ๋จผ์ € ์‚ฌ์šฉํ•ด. ์ž์ฒด ํ•€๋‹ ์„œ๋ฒ„๋ผ ๋ฐ”๋กœ ์ ‘๊ทผ ๊ฐ€๋Šฅํ•ด.
๊ณต๊ฐœ ๊ฒŒ์ดํŠธ์›จ์ด(ipfs.io)๋Š” ์ „ํŒŒ ํ›„ ์‚ฌ์šฉํ•ด.
โŒ ์—๋Ÿฌ 2: "insufficient funds for gas" ์›์ธ: ์ง€๊ฐ‘์— ETH๊ฐ€ ๋ถ€์กฑํ•ด.
ํ•ด๊ฒฐ: ํ…Œ์ŠคํŠธ๋„ท์ด๋ฉด faucet์—์„œ ๋ฐ›์•„. ๋ฉ”์ธ๋„ท์ด๋ฉด ETH ์ถฉ์ „ ํ•„์š”.
minter.get_balance()๋กœ ์ž”์•ก ๋จผ์ € ํ™•์ธํ•˜๋Š” ์Šต๊ด€ ๋“ค์ด๊ธฐ.
โŒ ์—๋Ÿฌ 3: "nonce too low" ๋˜๋Š” "replacement transaction underpriced" ์›์ธ: ์ด์ „ ํŠธ๋žœ์žญ์…˜์ด ์•„์ง ์ฒ˜๋ฆฌ ์ค‘์ธ๋ฐ ๊ฐ™์€ nonce๋กœ ์ƒˆ ํŠธ๋žœ์žญ์…˜์„ ๋ณด๋ƒˆ์–ด.
ํ•ด๊ฒฐ: w3.eth.get_transaction_count(address, 'pending')์œผ๋กœ pending ํฌํ•จ nonce ๊ฐ€์ ธ์˜ค๊ธฐ.
โŒ ์—๋Ÿฌ 4: Pinata API 429 Too Many Requests ์›์ธ: API ์š”์ฒญ ํ•œ๋„ ์ดˆ๊ณผ.
ํ•ด๊ฒฐ: ์š”์ฒญ ์‚ฌ์ด์— time.sleep(0.5) ์ถ”๊ฐ€ํ•˜๊ฑฐ๋‚˜, ๋น„๋™๊ธฐ ์ฝ”๋“œ์—์„œ Semaphore๋กœ ๋™์‹œ ์š”์ฒญ ์ˆ˜ ์ œํ•œ.
# ์ž์ฃผ ์“ฐ๋Š” ๋””๋ฒ„๊น… ์œ ํ‹ธ๋ฆฌํ‹ฐ

def check_ipfs_availability(cid: str, timeout: int = 10) -> bool:
    """IPFS CID ์ ‘๊ทผ ๊ฐ€๋Šฅ ์—ฌ๋ถ€ ํ™•์ธ"""
    gateways = [
        f"https://gateway.pinata.cloud/ipfs/{cid}",
        f"https://ipfs.io/ipfs/{cid}",
        f"https://cloudflare-ipfs.com/ipfs/{cid}"
    ]
    
    for gateway in gateways:
        try:
            response = requests.head(gateway, timeout=timeout)
            if response.status_code == 200:
                print(f"โœ… ์ ‘๊ทผ ๊ฐ€๋Šฅ: {gateway}")
                return True
        except:
            pass
    
    print(f"โŒ ๋ชจ๋“  ๊ฒŒ์ดํŠธ์›จ์ด์—์„œ ์ ‘๊ทผ ๋ถˆ๊ฐ€: {cid}")
    return False

def verify_metadata(token_uri: str) -> dict:
    """tokenURI์˜ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ๋‚ด์šฉ ๊ฒ€์ฆ"""
    # ipfs:// โ†’ https:// ๋ณ€ํ™˜
    if token_uri.startswith("ipfs://"):
        cid = token_uri.replace("ipfs://", "")
        url = f"https://gateway.pinata.cloud/ipfs/{cid}"
    else:
        url = token_uri
    
    response = requests.get(url, timeout=30)
    response.raise_for_status()
    metadata = response.json()
    
    # ํ•„์ˆ˜ ํ•„๋“œ ํ™•์ธ
    required_fields = ['name', 'description', 'image']
    missing = [f for f in required_fields if f not in metadata]
    
    if missing:
        print(f"โš ๏ธ ๋ˆ„๋ฝ๋œ ํ•„๋“œ: {missing}")
    else:
        print(f"โœ… ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ๊ฒ€์ฆ ์™„๋ฃŒ!")
        print(f"  ์ด๋ฆ„: {metadata['name']}")
        print(f"  ์ด๋ฏธ์ง€: {metadata['image']}")
    
    return metadata

๐Ÿ“š ๋” ๋‚˜์•„๊ฐ€๊ธฐ - ์‹ฌํ™” ํ•™์Šต ๋กœ๋“œ๋งต

์—ฌ๊ธฐ๊นŒ์ง€ ์™”์œผ๋ฉด ๊ธฐ๋ณธ๊ธฐ๋Š” ๋‹ค ๊ฐ–์ถ˜ ๊ฑฐ์•ผ! ์ด์ œ ๋” ๊นŠ์ด ํŒŒ๊ณ ๋“ค ์ˆ˜ ์žˆ๋Š” ๋ฐฉํ–ฅ์„ ์•Œ๋ ค์ค„๊ฒŒ. ๐Ÿ—บ๏ธ

๐Ÿš€ ๋‹ค์Œ ๋‹จ๊ณ„ ํ•™์Šต ๋กœ๋“œ๋งต 1๋‹จ๊ณ„ (์ง€๊ธˆ ์—ฌ๊ธฐ) - ์™„๋ฃŒ! โœ…
ํŒŒ์ด์ฌ + IPFS + ๊ธฐ๋ณธ ERC-721 ๋ฏผํŒ…

2๋‹จ๊ณ„ - ์Šค๋งˆํŠธ ์ปจํŠธ๋ž™ํŠธ ์‹ฌํ™”
ERC-721A (๊ฐ€์Šค ์ตœ์ ํ™”), ERC-1155 (๋ฉ€ํ‹ฐํ† ํฐ), Lazy Minting, Merkle Tree ํ™”์ดํŠธ๋ฆฌ์ŠคํŠธ

3๋‹จ๊ณ„ - ํ”„๋ก ํŠธ์—”๋“œ ์—ฐ๋™
React + ethers.js, MetaMask ์—ฐ๋™, WalletConnect, IPFS ๊ฒŒ์ดํŠธ์›จ์ด ์ตœ์ ํ™”

4๋‹จ๊ณ„ - ๊ณ ๊ธ‰ ๊ธฐ๋Šฅ
์˜จ์ฒด์ธ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ, SVG NFT, ๋‹ค์ด๋‚˜๋ฏน NFT(Chainlink VRF), ๋กœ์—ดํ‹ฐ(EIP-2981)

5๋‹จ๊ณ„ - ์ธํ”„๋ผ
The Graph (์ธ๋ฑ์‹ฑ), Moralis API, OpenSea API ์—ฐ๋™, ์ž์ฒด ๋งˆ์ผ“ํ”Œ๋ ˆ์ด์Šค ๊ตฌ์ถ•

์žฌ๋Šฅ๋„ท ๊ฐ™์€ ํ”Œ๋žซํผ์—์„œ๋„ NFT ๊ด€๋ จ ๊ฐœ๋ฐœ ์žฌ๋Šฅ์„ ๊ฑฐ๋ž˜ํ•˜๋Š” ์‚ฌ๋žŒ๋“ค์ด ๋Š˜๊ณ  ์žˆ์–ด.
Web3 ๊ฐœ๋ฐœ ์Šคํ‚ฌ์€ ์ง€๊ธˆ ๋ฐฐ์›Œ๋‘๋ฉด ๋ถ„๋ช…ํžˆ ์จ๋จน์„ ๊ณณ์ด ์ƒ๊ธธ ๊ฑฐ์•ผ. ๐Ÿ’ช

๐Ÿ’ก ์œ ์šฉํ•œ ๋„๊ตฌ๋“ค Remix IDE ์Šค๋งˆํŠธ ์ปจํŠธ๋ž™ํŠธ ๊ฐœ๋ฐœ/๋ฐฐํฌ
Hardhat ๋กœ์ปฌ ์ด๋”๋ฆฌ์›€ ๊ฐœ๋ฐœ ํ™˜๊ฒฝ
OpenZeppelin ๊ฒ€์ฆ๋œ ์ปจํŠธ๋ž™ํŠธ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ
Etherscan ํŠธ๋žœ์žญ์…˜ ํ™•์ธ
IPFS Desktop ๋กœ์ปฌ IPFS ๋…ธ๋“œ ์šด์˜

๐ŸŽ‰ ๋งˆ๋ฌด๋ฆฌ - ์˜ค๋Š˜ ๋ฐฐ์šด ๊ฒƒ ์ •๋ฆฌ!

์˜ค๋Š˜ ์šฐ๋ฆฌ๊ฐ€ ํ•จ๊ป˜ ๋งŒ๋“  ๊ฒƒ๋“ค์„ ์ •๋ฆฌํ•ด๋ณด์ž. ๊ฝค ๋งŽ์ด ํ–ˆ์ง€? ๐Ÿ˜„

โœ… IPFS์˜ ๊ฐœ๋…๊ณผ ์™œ NFT์— ํ•„์š”ํ•œ์ง€ ์ดํ•ด
โœ… Pinata API๋ฅผ ํ™œ์šฉํ•œ ํŒŒ์ด์ฌ ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ ๋ชจ๋“ˆ ๊ตฌํ˜„
โœ… OpenSea ํ‘œ์ค€ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ JSON ์ƒ์„ฑ ๋ฐ IPFS ์—…๋กœ๋“œ
โœ… ERC-721 ์Šค๋งˆํŠธ ์ปจํŠธ๋ž™ํŠธ์™€ web3.py๋กœ ์‹ค์ œ ๋ฏผํŒ…
โœ… ๋น„๋™๊ธฐ ์ฒ˜๋ฆฌ๋กœ ๋Œ€๋Ÿ‰ ์—…๋กœ๋“œ ์„ฑ๋Šฅ ์ตœ์ ํ™”
โœ… Pillow๋กœ ๋ ˆ์ด์–ด ๊ธฐ๋ฐ˜ ์ด๋ฏธ์ง€ ์ž๋™ ์ƒ์„ฑ
โœ… ๋ณด์•ˆ, ์—๋Ÿฌ ์ฒ˜๋ฆฌ, ์žฌ์‹œ๋„ ๋กœ์ง ์‹ค์ „ ํŒจํ„ด

๋ธ”๋ก์ฒด์ธ ๊ฐœ๋ฐœ์ด ์–ด๋ ต๊ฒŒ ๋А๊ปด์กŒ๋‹ค๋ฉด, ์ด์ œ ์กฐ๊ธˆ์€ ์นœ๊ทผํ•ด์กŒ๊ธธ ๋ฐ”๋ผ! ๐Ÿค
์žฌ๋Šฅ๋„ท์—์„œ ์ด ์ง€์‹์„ ํ™œ์šฉํ•ด์„œ ๋ฉ‹์ง„ Web3 ํ”„๋กœ์ ํŠธ๋ฅผ ๋งŒ๋“ค์–ด๋ด. ํ™”์ดํŒ…! ๐Ÿš€

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

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

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

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