Ver3.5 ๐ ํ์ด์ฌ + IPFS๋ก์ด๋ฏธ์ง NFT ์ ๋ก๋ ์์คํ ๊ตฌํํ๊ธฐ

๐ ํ์ด์ฌ + IPFS๋ก
์ด๋ฏธ์ง NFT ์
๋ก๋ ์์คํ
๊ตฌํํ๊ธฐ
์ฝ๋ ํ ์ค์ฉ ๊ฐ์ด ํํค์ณ๋ณด๋ Web3 ์ค์ ๊ฐ์ด๋ ๐
์ผ, ์์งํ ๋งํด๋ด. NFT๋ผ๋ ๋จ์ด ๋ค์ผ๋ฉด ์์ง๋ "๊ทธ๊ฑฐ ๊ทธ๋ฅ JPEG ์๋์ผ?" ๋ผ๊ณ ์๊ฐํ๋ ์ฌ๋ ์์ง? ๐
๊ทผ๋ฐ ๋ง์ ์ง์ ๋ง๋ค์ด๋ณด๋ฉด ์๊ฐ๋ณด๋ค ํจ์ฌ ์ฌ๋ฐ๊ณ , ๊ธฐ์ ์ ์ผ๋ก๋ ๊ฝค ๊น์ด๊ฐ ์์ด.
์ค๋์ ํ์ด์ฌ(Python)๊ณผ IPFS(InterPlanetary File System)๋ฅผ ์ด์ฉํด์ ์ด๋ฏธ์ง๋ฅผ NFT๋ก ์
๋ก๋ํ๋ ์์คํ
์ ์ฒ์๋ถํฐ ๋๊น์ง ๊ฐ์ด ๋ง๋ค์ด๋ณผ ๊ฑฐ์ผ.
๊ฒ๋จน์ง ๋ง. ๋ธ๋ก์ฒด์ธ ์ ๋ฌธ๊ฐ ์๋์ด๋ ๋ผ. ํ์ด์ฌ ๊ธฐ์ด๋ง ์๋ฉด ์ถฉ๋ถํด.
์ด ๊ธ ๋ค ์ฝ๊ณ ๋๋ฉด "์, ์ด๋ ๊ฒ ๋์๊ฐ๋ ๊ฑฐ๊ตฌ๋!" ํ๊ณ ๋ฌด๋ฆ์ ํ ์น ๊ฑฐ์ผ. ๐ฏ
๐ IPFS๊ฐ ๋ญ๋ฐ? ์ ์จ์ผ ํด?
NFT๋ฅผ ๋ง๋ค ๋ ๊ฐ์ฅ ๋จผ์ ๋ถ๋ชํ๋ ์ง๋ฌธ์ด ๋ฐ๋ก ์ด๊ฑฐ์ผ.
"์ด๋ฏธ์ง๋ฅผ ์ด๋์ ์ ์ฅํด?"
AWS S3๋ ์ผ๋ฐ ์๋ฒ์ ์ฌ๋ฆฌ๋ฉด ์ ๋๋๊ณ ? ๊ธฐ์ ์ ์ผ๋ก๋ ๊ฐ๋ฅํด. ๊ทผ๋ฐ ์น๋ช
์ ์ธ ๋ฌธ์ ๊ฐ ์์ด.
์๋ฒ๊ฐ ๊บผ์ง๋ฉด? ํ์ฌ๊ฐ ๋งํ๋ฉด? ์ด๋ฏธ์ง URL์ด 404๊ฐ ๋๋ฉด? ๊ทธ NFT๋ ๊ทธ๋ฅ ๋น ๊ป๋ฐ๊ธฐ๊ฐ ๋๋ ๊ฑฐ์ผ. ๐ฑ
์ค์ ๋ก ์ด๋ฐ ์ฌ๋ก๊ฐ ๊ฝค ์์์ด. ๋น์ธ๊ฒ ์ฐ NFT์ธ๋ฐ ์ด๋ฏธ์ง๊ฐ ์ฌ๋ผ์ง ๊ฑฐ์ง.
ํ์ผ์ ํน์ ์๋ฒ ํ ๊ณณ์ ์ ์ฅํ๋ ๊ฒ ์๋๋ผ ์ ์ธ๊ณ ์์ฒ ๊ฐ์ ๋ ธ๋์ ๋ถ์ฐํด์ ์ ์ฅํด.
ํต์ฌ ๊ฐ๋ ์ CID(Content Identifier)์ผ. ํ์ผ์ ๋ด์ฉ์ ํด์(SHA-256 ๊ธฐ๋ฐ)๋ก ๋ณํํ ๊ณ ์ ์ฃผ์์ธ๋ฐ,
ํ์ผ ๋ด์ฉ์ด ๊ฐ์ผ๋ฉด ํญ์ ๊ฐ์ CID๊ฐ ๋์. ์ฆ, ๋ด์ฉ ๊ธฐ๋ฐ ์ฃผ์ ์ง์ (Content-Addressed Storage)์ด์ผ.
์ฝ๊ฒ ๋น์ ํ๋ฉด ์ด๋. ์ผ๋ฐ ์น์ "๊ฐ๋จ๊ตฌ ํ
ํค๋๋ก 123๋ฒ์ง์ ๊ฐ๋ฉด ํ์ผ์ด ์์ด"์ฒ๋ผ ์์น ๊ธฐ๋ฐ์ด์ผ.
IPFS๋ "์ด ๋ด์ฉ์ ๊ฐ์ง ํ์ผ์ ์ค"์ฒ๋ผ ๋ด์ฉ ๊ธฐ๋ฐ์ด์ผ. ์์น๊ฐ ์ด๋๋ ์๊ด์์ด.
ํ์ผ์ด ๋ณ์กฐ๋๋ฉด 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
web3 ์ด๋๋ฆฌ์ ๋ธ๋ก์ฒด์ธ ์ฐ๋ (๋ฏผํ )
python-dotenv API ํค ํ๊ฒฝ๋ณ์ ๊ด๋ฆฌ
Pillow ์ด๋ฏธ์ง ์ฒ๋ฆฌ ๋ฐ ๊ฒ์ฆ
๐ Pinata API ํค ๋ฐ๊ธ๋ฐ๊ธฐ
๋ฐ๋์
.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 ์ ๋ก๋ ์ฝ๋ ์์ฑ
์ด์ ์ง์ง ํต์ฌ์ด์ผ. ํ์ด์ฌ์ผ๋ก ์ด๋ฏธ์ง๋ฅผ 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
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 ์ฃผ์๋ฅผ ๊ฐ๋ฆฌํค๊ฒ ๋ผ. ๐ฏ
# 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(1)์ ํธ์ถํ๋ฉด ipfs://bafk.../1.json ๊ฐ์ ๊ฐ์ด ๋ฐํ๋ผ.OpenSea ๊ฐ์ ๋ง์ผํ๋ ์ด์ค๊ฐ ์ด URL์ ์ฝ์ด์ NFT ์ ๋ณด๋ฅผ ํ์ํ๋ ๊ฑฐ์ผ.
โ๏ธ ์ค๋งํธ ์ปจํธ๋ํธ ์ฐ๋์ผ๋ก ์ค์ ๋ฏผํ ํ๊ธฐ
์ด์ ์ง์ง ํ์ด๋ผ์ดํธ์ผ! ์ด๋ฏธ์ง๋ ์ฌ๋ ธ๊ณ , ๋ฉํ๋ฐ์ดํฐ๋ ์ฌ๋ ธ์ด.
์ด์ ์ด๋๋ฆฌ์(๋๋ Polygon, Sepolia ํ
์คํธ๋ท) ๋ธ๋ก์ฒด์ธ์ ์ค์ ๋ก ๋ฏผํ
(Minting)์ ํด๋ณผ ๊ฑฐ์ผ.
๋ฏผํ
์ด๋ ์ค๋งํธ ์ปจํธ๋ํธ๋ฅผ ํตํด NFT๋ฅผ ๋ธ๋ก์ฒด์ธ์ ๊ธฐ๋กํ๋ ํ์์ผ. ๐จ
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
}
]
}
]
๐ก๏ธ ๋ณด์๊ณผ ์๋ฌ ์ฒ๋ฆฌ - ์ค์ ์์ ๊ผญ ํ์ํ ๊ฒ๋ค
์ฝ๋๊ฐ ๋์๊ฐ๋ค๊ณ ๋์ด ์๋์ผ. ์ค์ ์๋น์ค์์๋ ๋ณด์๊ณผ ์๋ฌ ์ฒ๋ฆฌ๊ฐ ์๋ช
์ด์ผ.
ํนํ ๊ฐ์ธํค์ 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}
ํ๋์จ์ด ์ง๊ฐ(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())
๋น๋๊ธฐ 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
์๋ฅผ ๋ค์ด
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
๐ ํธ๋ฌ๋ธ์ํ - ์์ฃผ ๋ง๋๋ ์๋ฌ๋ค
๊ฐ๋ฐํ๋ค ๋ณด๋ฉด ๋ถ๋ช ํ ์๋ฌ๋ฅผ ๋ง๋ ๊ฑฐ์ผ. ๋ฏธ๋ฆฌ ์์๋๋ฉด ์๊ฐ ๋ญ๋น ์์ด ํด๊ฒฐํ ์ ์์ด! ๐ ๏ธ
ํด๊ฒฐ: Pinata ๊ฒ์ดํธ์จ์ด(
gateway.pinata.cloud)๋ฅผ ๋จผ์ ์ฌ์ฉํด. ์์ฒด ํ๋ ์๋ฒ๋ผ ๋ฐ๋ก ์ ๊ทผ ๊ฐ๋ฅํด.๊ณต๊ฐ ๊ฒ์ดํธ์จ์ด(
ipfs.io)๋ ์ ํ ํ ์ฌ์ฉํด.
ํด๊ฒฐ: ํ ์คํธ๋ท์ด๋ฉด faucet์์ ๋ฐ์. ๋ฉ์ธ๋ท์ด๋ฉด ETH ์ถฉ์ ํ์.
minter.get_balance()๋ก ์์ก ๋จผ์ ํ์ธํ๋ ์ต๊ด ๋ค์ด๊ธฐ.
ํด๊ฒฐ:
w3.eth.get_transaction_count(address, 'pending')์ผ๋ก pending ํฌํจ nonce ๊ฐ์ ธ์ค๊ธฐ.
ํด๊ฒฐ: ์์ฒญ ์ฌ์ด์
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
๐ ๋ ๋์๊ฐ๊ธฐ - ์ฌํ ํ์ต ๋ก๋๋งต
์ฌ๊ธฐ๊น์ง ์์ผ๋ฉด ๊ธฐ๋ณธ๊ธฐ๋ ๋ค ๊ฐ์ถ ๊ฑฐ์ผ! ์ด์ ๋ ๊น์ด ํ๊ณ ๋ค ์ ์๋ ๋ฐฉํฅ์ ์๋ ค์ค๊ฒ. ๐บ๏ธ
ํ์ด์ฌ + 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 ๊ฐ๋ฐ ์คํฌ์ ์ง๊ธ ๋ฐฐ์๋๋ฉด ๋ถ๋ช
ํ ์จ๋จน์ ๊ณณ์ด ์๊ธธ ๊ฑฐ์ผ. ๐ช
Hardhat ๋ก์ปฌ ์ด๋๋ฆฌ์ ๊ฐ๋ฐ ํ๊ฒฝ
OpenZeppelin ๊ฒ์ฆ๋ ์ปจํธ๋ํธ ๋ผ์ด๋ธ๋ฌ๋ฆฌ
Etherscan ํธ๋์ญ์ ํ์ธ
IPFS Desktop ๋ก์ปฌ IPFS ๋ ธ๋ ์ด์
๐ ๋ง๋ฌด๋ฆฌ - ์ค๋ ๋ฐฐ์ด ๊ฒ ์ ๋ฆฌ!
์ค๋ ์ฐ๋ฆฌ๊ฐ ํจ๊ป ๋ง๋ ๊ฒ๋ค์ ์ ๋ฆฌํด๋ณด์. ๊ฝค ๋ง์ด ํ์ง? ๐
โ
IPFS์ ๊ฐ๋
๊ณผ ์ NFT์ ํ์ํ์ง ์ดํด
โ
Pinata API๋ฅผ ํ์ฉํ ํ์ด์ฌ ์ด๋ฏธ์ง ์
๋ก๋ ๋ชจ๋ ๊ตฌํ
โ
OpenSea ํ์ค ๋ฉํ๋ฐ์ดํฐ JSON ์์ฑ ๋ฐ IPFS ์
๋ก๋
โ
ERC-721 ์ค๋งํธ ์ปจํธ๋ํธ์ web3.py๋ก ์ค์ ๋ฏผํ
โ
๋น๋๊ธฐ ์ฒ๋ฆฌ๋ก ๋๋ ์
๋ก๋ ์ฑ๋ฅ ์ต์ ํ
โ
Pillow๋ก ๋ ์ด์ด ๊ธฐ๋ฐ ์ด๋ฏธ์ง ์๋ ์์ฑ
โ
๋ณด์, ์๋ฌ ์ฒ๋ฆฌ, ์ฌ์๋ ๋ก์ง ์ค์ ํจํด
๋ธ๋ก์ฒด์ธ ๊ฐ๋ฐ์ด ์ด๋ ต๊ฒ ๋๊ปด์ก๋ค๋ฉด, ์ด์ ์กฐ๊ธ์ ์น๊ทผํด์ก๊ธธ ๋ฐ๋ผ! ๐ค
์ฌ๋ฅ๋ท์์ ์ด ์ง์์ ํ์ฉํด์ ๋ฉ์ง Web3 ํ๋ก์ ํธ๋ฅผ ๋ง๋ค์ด๋ด. ํ์ดํ
! ๐
๊ด๋ จ ํค์๋
๋๊ธ ์์ฑ
์ด ๊ธ์ ๋ํ ์ฌ๋ฌ๋ถ์ ์๊ฐ์ ๋ค๋ ค์ฃผ์ธ์
๋ก๊ทธ์ธ์ด ํ์ํฉ๋๋ค
๋๊ธ์ ์์ฑํ๋ ค๋ฉด ๋จผ์ ๋ก๊ทธ์ธํด์ฃผ์ธ์.
๋๊ธ 0
์ง์์ธ์ ์ฒ - ์ง์ ์ฌ์ฐ๊ถ ๋ณดํธ ๊ณ ์ง
์ง์ ์ฌ์ฐ๊ถ ๋ณดํธ ๊ณ ์ง
- ์ ์๊ถ ๋ฐ ์์ ๊ถ: ๋ณธ ์ปจํ ์ธ ๋ ์ฌ๋ฅ๋ท์ ๋ ์ AI ๊ธฐ์ ๋ก ์์ฑ๋์์ผ๋ฉฐ, ๋ํ๋ฏผ๊ตญ ์ ์๊ถ๋ฒ ๋ฐ ๊ตญ์ ์ ์๊ถ ํ์ฝ์ ์ํด ๋ณดํธ๋ฉ๋๋ค.
- AI ์์ฑ ์ปจํ ์ธ ์ ๋ฒ์ ์ง์: ๋ณธ AI ์์ฑ ์ปจํ ์ธ ๋ ์ฌ๋ฅ๋ท์ ์ง์ ์ฐฝ์๋ฌผ๋ก ์ธ์ ๋๋ฉฐ, ๊ด๋ จ ๋ฒ๊ท์ ๋ฐ๋ผ ์ ์๊ถ ๋ณดํธ๋ฅผ ๋ฐ์ต๋๋ค.
- ์ฌ์ฉ ์ ํ: ์ฌ๋ฅ๋ท์ ๋ช ์์ ์๋ฉด ๋์ ์์ด ๋ณธ ์ปจํ ์ธ ๋ฅผ ๋ณต์ , ์์ , ๋ฐฐํฌ, ๋๋ ์์ ์ ์ผ๋ก ํ์ฉํ๋ ํ์๋ ์๊ฒฉํ ๊ธ์ง๋ฉ๋๋ค.
- ๋ฐ์ดํฐ ์์ง ๊ธ์ง: ๋ณธ ์ปจํ ์ธ ์ ๋ํ ๋ฌด๋จ ์คํฌ๋ํ, ํฌ๋กค๋ง, ๋ฐ ์๋ํ๋ ๋ฐ์ดํฐ ์์ง์ ๋ฒ์ ์ ์ฌ์ ๋์์ด ๋ฉ๋๋ค.
- AI ํ์ต ์ ํ: ์ฌ๋ฅ๋ท์ AI ์์ฑ ์ปจํ ์ธ ๋ฅผ ํ AI ๋ชจ๋ธ ํ์ต์ ๋ฌด๋จ ์ฌ์ฉํ๋ ํ์๋ ๊ธ์ง๋๋ฉฐ, ์ด๋ ์ง์ ์ฌ์ฐ๊ถ ์นจํด๋ก ๊ฐ์ฃผ๋ฉ๋๋ค.
์์ง ๋๊ธ์ด ์์ต๋๋ค.