์ฝ˜ํ…์ธ  ๋Œ€ํ‘œ ์ด๋ฏธ์ง€ - ๐Ÿ›’ ๋ผ๋ผ๋ฒจ(Laravel) ๊ธฐ๋ฐ˜ NFT ๋งˆ์ผ“ํ”Œ๋ ˆ์ด์Šค์„ค๊ณ„๋ถ€ํ„ฐ ๊ตฌ์ถ•๊นŒ์ง€ โ€” ์™„์ „ ์ •๋ณต ๊ฐ€์ด๋“œ ๐Ÿš€
๐Ÿ”— ๋ธ”๋ก์ฒด์ธ / Web3 ๊ฐœ๋ฐœ

๐Ÿ›’ ๋ผ๋ผ๋ฒจ(Laravel) ๊ธฐ๋ฐ˜ NFT ๋งˆ์ผ“ํ”Œ๋ ˆ์ด์Šค
์„ค๊ณ„๋ถ€ํ„ฐ ๊ตฌ์ถ•๊นŒ์ง€ โ€” ์™„์ „ ์ •๋ณต ๊ฐ€์ด๋“œ ๐Ÿš€

PHP์˜ ์™• Laravel ร— Web3์˜ ๋ฏธ๋ž˜ NFT = ์ตœ๊ฐ• ์กฐํ•ฉ ํƒ„์ƒ!

์•ผ, ์†”์งํžˆ ๋งํ•ด๋ณผ๊ฒŒ. NFT ๋งˆ์ผ“ํ”Œ๋ ˆ์ด์Šค ๋งŒ๋“ ๋‹ค๊ณ  ํ•˜๋ฉด ๋Œ€๋ถ€๋ถ„ Node.js + React ์กฐํ•ฉ๋ถ€ํ„ฐ ๋– ์˜ฌ๋ฆฌ์ž–์•„? ๊ทผ๋ฐ ์ž ๊น, ์šฐ๋ฆฌํ•œํ…Œ๋Š” Laravel์ด ์žˆ๋‹ค๊ณ ! ๐ŸŽ‰

Laravel์€ PHP ์ƒํƒœ๊ณ„์—์„œ ๊ฐ€์žฅ ์„ธ๋ จ๋œ ํ”„๋ ˆ์ž„์›Œํฌ์•ผ. ๊ฐ•๋ ฅํ•œ ORM, ํ ์‹œ์Šคํ…œ, ์ด๋ฒคํŠธ ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ…, API ๋ฆฌ์†Œ์Šค ๋“ฑ NFT ๋งˆ์ผ“ํ”Œ๋ ˆ์ด์Šค์— ํ•„์š”ํ•œ ๋ฐฑ์—”๋“œ ๊ธฐ๋Šฅ์„ ๊ฑฐ์˜ ๋‹ค ๊ฐ–์ถ”๊ณ  ์žˆ์–ด. ์—ฌ๊ธฐ์— ์ด๋”๋ฆฌ์›€ ์Šค๋งˆํŠธ ์ปจํŠธ๋ž™ํŠธ๋ฅผ ๋ถ™์ด๋ฉด? ์ง„์งœ ํ”„๋กœ๋•์…˜ ๋ ˆ๋ฒจ์˜ NFT ๋งˆ์ผ“ํ”Œ๋ ˆ์ด์Šค๊ฐ€ ํƒ„์ƒํ•˜๋Š” ๊ฑฐ์•ผ.

์ด ๊ธ€์—์„œ๋Š” ์„ค๊ณ„ โ†’ ์Šค๋งˆํŠธ ์ปจํŠธ๋ž™ํŠธ โ†’ Laravel ๋ฐฑ์—”๋“œ โ†’ ํ”„๋ก ํŠธ์—”๋“œ ์—ฐ๋™ โ†’ ๋ฐฐํฌ๊นŒ์ง€ ์ „ ๊ณผ์ •์„ ์นœ๊ตฌํ•œํ…Œ ์„ค๋ช…ํ•˜๋“ฏ ์ญ‰ ํ’€์–ด๋ณผ๊ฒŒ. ์ปคํ”ผ ํ•œ ์ž” ๋“ค๊ณ  ํŽธํ•˜๊ฒŒ ์ฝ์–ด๋ด! โ˜•

NFT ๋งˆ์ผ“ํ”Œ๋ ˆ์ด์Šค ์ „์ฒด ์•„ํ‚คํ…์ฒ˜ ๐Ÿ‘ค ์‚ฌ์šฉ์ž / ์ง€๊ฐ‘ MetaMask ๐Ÿ–ฅ๏ธ ํ”„๋ก ํŠธ์—”๋“œ Vue.js / React ethers.js ๐Ÿ”ด Laravel ๋ฐฑ์—”๋“œ API / Auth / Queue Event / Storage Web3PHP ์—ฐ๋™ โ›“๏ธ ์ด๋”๋ฆฌ์›€ / EVM ERC-721 / ERC-1155 Marketplace ์ปจํŠธ๋ž™ํŠธ ๐Ÿ—„๏ธ MySQL / PostgreSQL NFT ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์บ์‹œ โšก Redis ์บ์‹œ / ํ / ์„ธ์…˜ ๐Ÿ“ฆ IPFS / Pinata NFT ํŒŒ์ผ ์ €์žฅ ๐Ÿ‘‚ ์ด๋ฒคํŠธ ๋ฆฌ์Šค๋„ˆ ๋ธ”๋ก์ฒด์ธ ์ด๋ฒคํŠธ ๊ฐ์ง€ Laravel์ด ๋ชจ๋“  ๋ ˆ์ด์–ด๋ฅผ ์—ฐ๊ฒฐํ•˜๋Š” ํ•ต์‹ฌ ํ—ˆ๋ธŒ ์—ญํ• ์„ ํ•ฉ๋‹ˆ๋‹ค PHP 8.2+ Laravel 11 Solidity 0.8+ ethers.js v6 Hardhat

๐Ÿงฐ 1. ๊ธฐ์ˆ  ์Šคํƒ ์„ ์ • โ€” ์™œ Laravel์ธ๊ฐ€?

๋จผ์ € "์™œ ๊ตณ์ด Laravel์ด์•ผ?" ๋ผ๋Š” ์งˆ๋ฌธ์— ๋‹ตํ•ด์•ผ๊ฒ ์ง€. ์‚ฌ์‹ค NFT ๋งˆ์ผ“ํ”Œ๋ ˆ์ด์Šค ๋ฐฑ์—”๋“œ๋กœ Laravel์„ ์„ ํƒํ•˜๋Š” ๊ฑด ๊ฝค ์ „๋žต์ ์ธ ๊ฒฐ์ •์ด์•ผ.

โœ… Laravel์„ ์„ ํƒํ•˜๋Š” ์ด์œ 

1. ์„ฑ์ˆ™ํ•œ ์ƒํƒœ๊ณ„ โ€” Eloquent ORM, Sanctum/Passport ์ธ์ฆ, Queue, Event, Storage ๋“ฑ NFT ํ”Œ๋žซํผ์— ํ•„์š”ํ•œ ๊ธฐ๋Šฅ์ด ์ด๋ฏธ ๋‚ด์žฅ๋˜์–ด ์žˆ์–ด.

2. API ๊ฐœ๋ฐœ ์ตœ์ ํ™” โ€” API Resource, Form Request Validation, Rate Limiting์ด ๊ธฐ๋ณธ ์ œ๊ณต๋ผ์„œ RESTful API ๊ตฌ์ถ•์ด ๋งค์šฐ ๋น ๋ฆ„.

3. ํ ์‹œ์Šคํ…œ โ€” ๋ธ”๋ก์ฒด์ธ ํŠธ๋žœ์žญ์…˜ ์ฒ˜๋ฆฌ, ์ด๋ฒคํŠธ ๋ฆฌ์Šค๋‹, ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ๋™๊ธฐํ™” ๊ฐ™์€ ๋น„๋™๊ธฐ ์ž‘์—…์— Laravel Queue๊ฐ€ ์™„๋ฒฝํ•˜๊ฒŒ ๋งž์•„.

4. ์ปค๋ฎค๋‹ˆํ‹ฐ & ๋ ˆํผ๋Ÿฐ์Šค โ€” PHP ๊ฐœ๋ฐœ์ž ํ’€์ด ๋„“์–ด์„œ ํŒ€ ํ™•์žฅ์ด ์‰ฝ๊ณ , ๊ธฐ์กด PHP ๊ฐœ๋ฐœ์ž๊ฐ€ Web3๋กœ ์ง„์ž…ํ•˜๋Š” ์ตœ์ ์˜ ๊ฒฝ๋กœ์•ผ.

๐Ÿ“ฆ ์ „์ฒด ๊ธฐ์ˆ  ์Šคํƒ ๊ตฌ์„ฑ

๐Ÿ”ด ๋ฐฑ์—”๋“œ
Laravel 11 PHP 8.2+ MySQL 8.0 Redis Laravel Horizon Laravel Sanctum
โ›“๏ธ ๋ธ”๋ก์ฒด์ธ
Solidity 0.8+ Hardhat OpenZeppelin ethers.js v6 web3.php
๐Ÿ–ฅ๏ธ ํ”„๋ก ํŠธ์—”๋“œ
Vue.js 3 Inertia.js Tailwind CSS MetaMask SDK
โ˜๏ธ ์ธํ”„๋ผ
IPFS / Pinata AWS S3 Nginx Docker GitHub Actions
๐Ÿ’ก web3.php๋Š” PHP์—์„œ ์ด๋”๋ฆฌ์›€ ๋…ธ๋“œ์™€ ํ†ต์‹ ํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•ด์ฃผ๋Š” ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ์•ผ. Infura๋‚˜ Alchemy ๊ฐ™์€ RPC ์—”๋“œํฌ์ธํŠธ์™€ ์—ฐ๊ฒฐํ•ด์„œ ๋ธ”๋ก์ฒด์ธ ๋ฐ์ดํ„ฐ๋ฅผ ์ฝ๊ณ  ์“ธ ์ˆ˜ ์žˆ์–ด. Composer๋กœ ์„ค์น˜ ๊ฐ€๋Šฅ!

๐Ÿ—„๏ธ 2. ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์„ค๊ณ„ โ€” NFT ๋งˆ์ผ“์˜ ๋ผˆ๋Œ€

NFT ๋งˆ์ผ“ํ”Œ๋ ˆ์ด์Šค์˜ DB ์„ค๊ณ„๋Š” ์ผ๋ฐ˜ ์ด์ปค๋จธ์Šค๋ž‘ ๋น„์Šทํ•˜๋ฉด์„œ๋„ ๋ธ”๋ก์ฒด์ธ ํŠน์„ฑ์„ ๋ฐ˜์˜ํ•ด์•ผ ํ•ด. ํ•ต์‹ฌ์€ ์˜จ์ฒด์ธ ๋ฐ์ดํ„ฐ์™€ ์˜คํ”„์ฒด์ธ ๋ฐ์ดํ„ฐ๋ฅผ ์–ด๋–ป๊ฒŒ ๋ถ„๋ฆฌํ•˜๊ณ  ๋™๊ธฐํ™”ํ•  ๊ฒƒ์ธ๊ฐ€์•ผ.

๐Ÿ“‹ ํ•ต์‹ฌ ํ…Œ์ด๋ธ” ๊ตฌ์กฐ

users ํ…Œ์ด๋ธ” โ€” ์ง€๊ฐ‘ ์ฃผ์†Œ ๊ธฐ๋ฐ˜ ์ธ์ฆ
id, wallet_address(unique), username, email, avatar_url, bio, is_verified, created_at
nfts ํ…Œ์ด๋ธ” โ€” NFT ๊ธฐ๋ณธ ์ •๋ณด
id, token_id, contract_address, chain_id, owner_address, creator_address, name, description, image_url, metadata_uri(IPFS), token_standard(ERC721/ERC1155), royalty_percentage, is_minted, created_at
listings ํ…Œ์ด๋ธ” โ€” ํŒ๋งค ๋“ฑ๋ก
id, nft_id, seller_address, price_wei, currency(ETH/ERC20), listing_type(fixed/auction), start_time, end_time, is_active, tx_hash, created_at
transactions ํ…Œ์ด๋ธ” โ€” ๊ฑฐ๋ž˜ ๋‚ด์—ญ
id, nft_id, listing_id, from_address, to_address, price_wei, tx_hash, block_number, status(pending/confirmed/failed), created_at
collections ํ…Œ์ด๋ธ” โ€” NFT ์ปฌ๋ ‰์…˜
id, contract_address, chain_id, name, symbol, description, banner_url, creator_address, floor_price, volume_traded, created_at
bids ํ…Œ์ด๋ธ” โ€” ๊ฒฝ๋งค ์ž…์ฐฐ
id, listing_id, bidder_address, bid_amount_wei, tx_hash, status(active/outbid/won/refunded), created_at
๐ŸŒŸ ์„ค๊ณ„ ํŒ! price๋ฅผ ์ €์žฅํ•  ๋•Œ๋Š” ๋ฐ˜๋“œ์‹œ Wei ๋‹จ์œ„(์ •์ˆ˜)๋กœ ์ €์žฅํ•ด. ETH ๋‹จ์œ„ ์†Œ์ˆ˜์ ์„ DB์— ์ €์žฅํ•˜๋ฉด ๋ถ€๋™์†Œ์ˆ˜์  ์˜ค๋ฅ˜๊ฐ€ ์ƒ๊ธธ ์ˆ˜ ์žˆ๊ฑฐ๋“ . ํ‘œ์‹œํ•  ๋•Œ๋งŒ ethers.js์˜ formatEther()๋กœ ๋ณ€ํ™˜ํ•ด!

๐Ÿ”„ Laravel Migration ์˜ˆ์‹œ

// database/migrations/create_nfts_table.php
Schema::create('nfts', function (Blueprint $table) {
    $table->id();
    $table->string('token_id');
    $table->string('contract_address', 42)->index();
    $table->integer('chain_id')->default(1); // 1=Mainnet, 11155111=Sepolia
    $table->string('owner_address', 42)->index();
    $table->string('creator_address', 42)->index();
    $table->string('name');
    $table->text('description')->nullable();
    $table->string('image_url');
    $table->string('metadata_uri'); // ipfs://...
    $table->enum('token_standard', ['ERC721', 'ERC1155'])->default('ERC721');
    $table->decimal('royalty_percentage', 5, 2)->default(0);
    $table->boolean('is_minted')->default(false);
    $table->json('attributes')->nullable(); // NFT ์†์„ฑ
    $table->timestamps();
    
    $table->unique(['token_id', 'contract_address', 'chain_id']);
});

โ›“๏ธ 3. ์Šค๋งˆํŠธ ์ปจํŠธ๋ž™ํŠธ ์„ค๊ณ„ โ€” Solidity๋กœ NFT ๋งŒ๋“ค๊ธฐ

์ด์ œ ์ง„์งœ ํ•ต์‹ฌ์ด์•ผ! NFT ๋งˆ์ผ“ํ”Œ๋ ˆ์ด์Šค๋Š” ํฌ๊ฒŒ ๋‘ ๊ฐ€์ง€ ์ปจํŠธ๋ž™ํŠธ๊ฐ€ ํ•„์š”ํ•ด.

๐ŸŽจ NFT ์ปจํŠธ๋ž™ํŠธ
ERC-721 ๋˜๋Š” ERC-1155 ํ‘œ์ค€์„ ๊ตฌํ˜„ํ•œ ์ปจํŠธ๋ž™ํŠธ. NFT๋ฅผ ๋ฏผํŒ…(๋ฐœํ–‰)ํ•˜๊ณ  ์†Œ์œ ๊ถŒ์„ ๊ด€๋ฆฌํ•ด.
๐Ÿช ๋งˆ์ผ“ํ”Œ๋ ˆ์ด์Šค ์ปจํŠธ๋ž™ํŠธ
NFT๋ฅผ ๋ฆฌ์ŠคํŒ…ํ•˜๊ณ , ๊ตฌ๋งคํ•˜๊ณ , ๊ฒฝ๋งค๋ฅผ ์ง„ํ–‰ํ•˜๋Š” ๋กœ์ง์„ ๋‹ด๋‹นํ•ด. ์ˆ˜์ˆ˜๋ฃŒ๋„ ์—ฌ๊ธฐ์„œ ์ฒ˜๋ฆฌ!

๐ŸŽจ ERC-721 NFT ์ปจํŠธ๋ž™ํŠธ (OpenZeppelin ๊ธฐ๋ฐ˜)

// 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 LaravelNFT is ERC721URIStorage, Ownable {
    using Counters for Counters.Counter;
    Counters.Counter private _tokenIds;
    
    // ๋กœ์—ดํ‹ฐ ์ •๋ณด
    mapping(uint256 => address) public creators;
    mapping(uint256 => uint256) public royaltyPercentage;
    
    // ๋ฏผํŒ… ์ˆ˜์ˆ˜๋ฃŒ
    uint256 public mintingFee = 0.001 ether;
    
    event NFTMinted(
        uint256 indexed tokenId,
        address indexed creator,
        string tokenURI,
        uint256 royalty
    );
    
    constructor() ERC721("LaravelNFT", "LNFT") Ownable(msg.sender) {}
    
    function mintNFT(
        address recipient,
        string memory tokenURI,
        uint256 royalty // 100 = 1%, ์ตœ๋Œ€ 1000 = 10%
    ) public payable returns (uint256) {
        require(msg.value >= mintingFee, "Insufficient minting fee");
        require(royalty <= 1000, "Royalty too high");
        
        _tokenIds.increment();
        uint256 newTokenId = _tokenIds.current();
        
        _mint(recipient, newTokenId);
        _setTokenURI(newTokenId, tokenURI);
        
        creators[newTokenId] = recipient;
        royaltyPercentage[newTokenId] = royalty;
        
        emit NFTMinted(newTokenId, recipient, tokenURI, royalty);
        return newTokenId;
    }
    
    // EIP-2981 ๋กœ์—ดํ‹ฐ ํ‘œ์ค€
    function royaltyInfo(uint256 tokenId, uint256 salePrice)
        external view returns (address receiver, uint256 royaltyAmount) {
        receiver = creators[tokenId];
        royaltyAmount = (salePrice * royaltyPercentage[tokenId]) / 10000;
    }
    
    function withdraw() public onlyOwner {
        payable(owner()).transfer(address(this).balance);
    }
}

๐Ÿช ๋งˆ์ผ“ํ”Œ๋ ˆ์ด์Šค ์ปจํŠธ๋ž™ํŠธ

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

import "@openzeppelin/contracts/token/ERC721/IERC721.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract NFTMarketplace is ReentrancyGuard, Ownable {
    
    struct Listing {
        address seller;
        address nftContract;
        uint256 tokenId;
        uint256 price;
        bool isActive;
    }
    
    // listingId => Listing
    mapping(uint256 => Listing) public listings;
    uint256 private _listingCounter;
    
    // ํ”Œ๋žซํผ ์ˆ˜์ˆ˜๋ฃŒ (250 = 2.5%)
    uint256 public platformFee = 250;
    uint256 public constant FEE_DENOMINATOR = 10000;
    
    event Listed(uint256 indexed listingId, address indexed seller, 
                 address nftContract, uint256 tokenId, uint256 price);
    event Sold(uint256 indexed listingId, address indexed buyer, uint256 price);
    event Cancelled(uint256 indexed listingId);
    
    constructor() Ownable(msg.sender) {}
    
    function listNFT(
        address nftContract,
        uint256 tokenId,
        uint256 price
    ) external returns (uint256) {
        require(price > 0, "Price must be greater than 0");
        IERC721 nft = IERC721(nftContract);
        require(nft.ownerOf(tokenId) == msg.sender, "Not the owner");
        require(nft.isApprovedForAll(msg.sender, address(this)) ||
                nft.getApproved(tokenId) == address(this), "Not approved");
        
        _listingCounter++;
        listings[_listingCounter] = Listing({
            seller: msg.sender,
            nftContract: nftContract,
            tokenId: tokenId,
            price: price,
            isActive: true
        });
        
        emit Listed(_listingCounter, msg.sender, nftContract, tokenId, price);
        return _listingCounter;
    }
    
    function buyNFT(uint256 listingId) external payable nonReentrant {
        Listing storage listing = listings[listingId];
        require(listing.isActive, "Listing not active");
        require(msg.value >= listing.price, "Insufficient payment");
        
        listing.isActive = false;
        
        // ์ˆ˜์ˆ˜๋ฃŒ ๊ณ„์‚ฐ
        uint256 fee = (listing.price * platformFee) / FEE_DENOMINATOR;
        uint256 sellerAmount = listing.price - fee;
        
        // NFT ์ „์†ก
        IERC721(listing.nftContract).safeTransferFrom(
            listing.seller, msg.sender, listing.tokenId
        );
        
        // ๋Œ€๊ธˆ ์ง€๊ธ‰
        payable(listing.seller).transfer(sellerAmount);
        
        // ์ดˆ๊ณผ ์ง€๋ถˆ ํ™˜๋ถˆ
        if (msg.value > listing.price) {
            payable(msg.sender).transfer(msg.value - listing.price);
        }
        
        emit Sold(listingId, msg.sender, listing.price);
    }
    
    function cancelListing(uint256 listingId) external {
        Listing storage listing = listings[listingId];
        require(listing.seller == msg.sender, "Not the seller");
        require(listing.isActive, "Already inactive");
        listing.isActive = false;
        emit Cancelled(listingId);
    }
    
    function setPlatformFee(uint256 newFee) external onlyOwner {
        require(newFee <= 1000, "Fee too high"); // ์ตœ๋Œ€ 10%
        platformFee = newFee;
    }
    
    function withdraw() external onlyOwner {
        payable(owner()).transfer(address(this).balance);
    }
}
โš ๏ธ ๋ณด์•ˆ ์ฃผ์˜! ์‹ค์ œ ํ”„๋กœ๋•์…˜์—์„œ๋Š” ๋ฐ˜๋“œ์‹œ Reentrancy Guard๋ฅผ ์‚ฌ์šฉํ•˜๊ณ , Checks-Effects-Interactions ํŒจํ„ด์„ ๋”ฐ๋ผ์•ผ ํ•ด. ์œ„ ์ฝ”๋“œ๋Š” ํ•™์Šต์šฉ์ด๋‹ˆ ์‹ค์ œ ๋ฐฐํฌ ์ „์— ๋ฐ˜๋“œ์‹œ ๋ณด์•ˆ ๊ฐ์‚ฌ(Audit)๋ฅผ ๋ฐ›์•„์•ผ ํ•ด!

๐Ÿ”จ Hardhat์œผ๋กœ ๋ฐฐํฌํ•˜๊ธฐ

// hardhat.config.js
require("@nomicfoundation/hardhat-toolbox");
require("dotenv").config();

module.exports = {
  solidity: "0.8.20",
  networks: {
    sepolia: {
      url: process.env.ALCHEMY_SEPOLIA_URL,
      accounts: [process.env.DEPLOYER_PRIVATE_KEY]
    },
    mainnet: {
      url: process.env.ALCHEMY_MAINNET_URL,
      accounts: [process.env.DEPLOYER_PRIVATE_KEY]
    }
  },
  etherscan: {
    apiKey: process.env.ETHERSCAN_API_KEY
  }
};

// scripts/deploy.js
async function main() {
  const [deployer] = await ethers.getSigners();
  console.log("Deploying with:", deployer.address);
  
  const NFT = await ethers.getContractFactory("LaravelNFT");
  const nft = await NFT.deploy();
  await nft.waitForDeployment();
  console.log("NFT Contract:", await nft.getAddress());
  
  const Marketplace = await ethers.getContractFactory("NFTMarketplace");
  const marketplace = await Marketplace.deploy();
  await marketplace.waitForDeployment();
  console.log("Marketplace:", await marketplace.getAddress());
}

main().catch(console.error);

NFT ๋ฏผํŒ… ํ”Œ๋กœ์šฐ โ€” Laravel + Web3 ์—ฐ๋™ ๐Ÿ–ผ๏ธ 1. ํŒŒ์ผ ์—…๋กœ๋“œ ์‚ฌ์šฉ์ž โ†’ Laravel ์ด๋ฏธ์ง€/์˜์ƒ ์ „์†ก ๐Ÿ“ฆ 2. IPFS ์—…๋กœ๋“œ Laravel โ†’ Pinata CID ๋ฐ˜ํ™˜ ๐Ÿ“ 3. ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์ƒ์„ฑ Laravel API JSON โ†’ IPFS โ›“๏ธ 4. ๋ฏผํŒ… ํŠธ๋žœ์žญ์…˜ MetaMask ์„œ๋ช… ์ปจํŠธ๋ž™ํŠธ ํ˜ธ์ถœ โœ… 5. DB ๋™๊ธฐํ™” ์ด๋ฒคํŠธ ๊ฐ์ง€ Laravel ์ €์žฅ ํŒŒ์ผ ๊ฒ€์ฆ & ์ €์žฅ - ํŒŒ์ผ ํ˜•์‹ ๊ฒ€์ฆ - ์ตœ๋Œ€ ํฌ๊ธฐ ์ œํ•œ - S3 ์ž„์‹œ ์ €์žฅ - ์ธ๋„ค์ผ ์ƒ์„ฑ Pinata API ์—ฐ๋™ - pinFileToIPFS() - CID ๋ฐ˜ํ™˜ ์ €์žฅ - ipfs:// URI ์ƒ์„ฑ - ๊ฒŒ์ดํŠธ์›จ์ด URL ERC-721 ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ - name, description - image: ipfs://... - attributes ๋ฐฐ์—ด - JSON โ†’ IPFS ethers.js ์—ฐ๋™ - MetaMask ์—ฐ๊ฒฐ - mintNFT() ํ˜ธ์ถœ - tx ํ•ด์‹œ ๋ฐ˜ํ™˜ - ์ปจํŽŒ ๋Œ€๊ธฐ ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ - NFTMinted ๊ฐ์ง€ - DB ์—…๋ฐ์ดํŠธ - ์•Œ๋ฆผ ๋ฐœ์†ก - ์บ์‹œ ๊ฐฑ์‹  ์ „์ฒด ๋ฏผํŒ… ๊ณผ์ •์€ Laravel Queue๋ฅผ ํ†ตํ•ด ๋น„๋™๊ธฐ๋กœ ์ฒ˜๋ฆฌ๋ฉ๋‹ˆ๋‹ค

๐Ÿ”ด 4. Laravel ๋ฐฑ์—”๋“œ ๊ตฌํ˜„ โ€” ํ•ต์‹ฌ ์ฝ”๋“œ ํŒŒํ—ค์น˜๊ธฐ

์ด์ œ Laravel ์ฝ”๋“œ๋ฅผ ๋ณธ๊ฒฉ์ ์œผ๋กœ ์งœ๋ณผ ์‹œ๊ฐ„์ด์•ผ! ๊ตฌ์กฐ๋ฅผ ์ž˜ ์žก์•„์•ผ ๋‚˜์ค‘์— ์œ ์ง€๋ณด์ˆ˜๊ฐ€ ํŽธํ•˜๋‹ˆ๊นŒ ๊ผผ๊ผผํ•˜๊ฒŒ ๋ณด์ž.

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

app/
โ”œโ”€โ”€ Http/
โ”‚   โ”œโ”€โ”€ Controllers/
โ”‚   โ”‚   โ”œโ”€โ”€ NFTController.php
โ”‚   โ”‚   โ”œโ”€โ”€ MarketplaceController.php
โ”‚   โ”‚   โ”œโ”€โ”€ CollectionController.php
โ”‚   โ”‚   โ””โ”€โ”€ AuthController.php (์ง€๊ฐ‘ ์ธ์ฆ)
โ”‚   โ”œโ”€โ”€ Requests/
โ”‚   โ”‚   โ”œโ”€โ”€ MintNFTRequest.php
โ”‚   โ”‚   โ””โ”€โ”€ ListNFTRequest.php
โ”‚   โ””โ”€โ”€ Resources/
โ”‚       โ”œโ”€โ”€ NFTResource.php
โ”‚       โ””โ”€โ”€ ListingResource.php
โ”œโ”€โ”€ Models/
โ”‚   โ”œโ”€โ”€ User.php
โ”‚   โ”œโ”€โ”€ NFT.php
โ”‚   โ”œโ”€โ”€ Listing.php
โ”‚   โ””โ”€โ”€ Transaction.php
โ”œโ”€โ”€ Services/
โ”‚   โ”œโ”€โ”€ IPFSService.php      โ† Pinata ์—ฐ๋™
โ”‚   โ”œโ”€โ”€ Web3Service.php      โ† ๋ธ”๋ก์ฒด์ธ ์—ฐ๋™
โ”‚   โ””โ”€โ”€ NFTMetadataService.php
โ”œโ”€โ”€ Jobs/
โ”‚   โ”œโ”€โ”€ SyncNFTFromChain.php
โ”‚   โ””โ”€โ”€ ProcessBlockchainEvent.php
โ””โ”€โ”€ Console/Commands/
    โ””โ”€โ”€ ListenBlockchainEvents.php

๐Ÿ” ์ง€๊ฐ‘ ๊ธฐ๋ฐ˜ ์ธ์ฆ ๊ตฌํ˜„

NFT ๋งˆ์ผ“ํ”Œ๋ ˆ์ด์Šค์—์„œ ์ธ์ฆ์€ ์ผ๋ฐ˜ ์ด๋ฉ”์ผ/ํŒจ์Šค์›Œ๋“œ๊ฐ€ ์•„๋‹ˆ๋ผ ์ง€๊ฐ‘ ์„œ๋ช…(Wallet Signature) ๋ฐฉ์‹์„ ์จ. SIWE(Sign-In with Ethereum) ํ‘œ์ค€์ด์•ผ.

// app/Http/Controllers/AuthController.php
class AuthController extends Controller
{
    // 1๋‹จ๊ณ„: ๋…ผ์Šค(nonce) ๋ฐœ๊ธ‰
    public function getNonce(Request $request): JsonResponse
    {
        $walletAddress = strtolower($request->wallet_address);
        $nonce = bin2hex(random_bytes(16));
        
        Cache::put("nonce:{$walletAddress}", $nonce, now()->addMinutes(5));
        
        return response()->json([
            'nonce' => $nonce,
            'message' => "NFT ๋งˆ์ผ“ํ”Œ๋ ˆ์ด์Šค์— ๋กœ๊ทธ์ธํ•ฉ๋‹ˆ๋‹ค.\n\n๋…ผ์Šค: {$nonce}"
        ]);
    }
    
    // 2๋‹จ๊ณ„: ์„œ๋ช… ๊ฒ€์ฆ & ๋กœ๊ทธ์ธ
    public function verifySignature(Request $request): JsonResponse
    {
        $walletAddress = strtolower($request->wallet_address);
        $signature = $request->signature;
        
        $cachedNonce = Cache::get("nonce:{$walletAddress}");
        if (!$cachedNonce) {
            return response()->json(['error' => '๋…ผ์Šค ๋งŒ๋ฃŒ'], 401);
        }
        
        $message = "NFT ๋งˆ์ผ“ํ”Œ๋ ˆ์ด์Šค์— ๋กœ๊ทธ์ธํ•ฉ๋‹ˆ๋‹ค.\n\n๋…ผ์Šค: {$cachedNonce}";
        
        // ์„œ๋ช… ๊ฒ€์ฆ (web3.php ์‚ฌ์šฉ)
        $web3 = app(Web3Service::class);
        $recoveredAddress = $web3->recoverAddress($message, $signature);
        
        if (strtolower($recoveredAddress) !== $walletAddress) {
            return response()->json(['error' => '์„œ๋ช… ๊ฒ€์ฆ ์‹คํŒจ'], 401);
        }
        
        Cache::forget("nonce:{$walletAddress}");
        
        // ์‚ฌ์šฉ์ž ์ƒ์„ฑ ๋˜๋Š” ์กฐํšŒ
        $user = User::firstOrCreate(
            ['wallet_address' => $walletAddress],
            ['username' => 'user_' . substr($walletAddress, 2, 8)]
        );
        
        $token = $user->createToken('auth-token')->plainTextToken;
        
        return response()->json([
            'token' => $token,
            'user' => new UserResource($user)
        ]);
    }
}

๐Ÿ“ฆ IPFS ์„œ๋น„์Šค (Pinata ์—ฐ๋™)

// app/Services/IPFSService.php
class IPFSService
{
    private string $apiKey;
    private string $secretKey;
    private string $baseUrl = 'https://api.pinata.cloud';
    
    public function __construct()
    {
        $this->apiKey = config('services.pinata.api_key');
        $this->secretKey = config('services.pinata.secret_key');
    }
    
    // ํŒŒ์ผ ์—…๋กœ๋“œ
    public function uploadFile(UploadedFile $file, string $name): array
    {
        $response = Http::withHeaders([
            'pinata_api_key' => $this->apiKey,
            'pinata_secret_api_key' => $this->secretKey,
        ])->attach(
            'file', $file->getContent(), $file->getClientOriginalName()
        )->post("{$this->baseUrl}/pinning/pinFileToIPFS", [
            'pinataMetadata' => json_encode(['name' => $name]),
        ]);
        
        if (!$response->successful()) {
            throw new \Exception('IPFS ์—…๋กœ๋“œ ์‹คํŒจ: ' . $response->body());
        }
        
        $data = $response->json();
        return [
            'cid' => $data['IpfsHash'],
            'uri' => "ipfs://{$data['IpfsHash']}",
            'gateway_url' => "https://gateway.pinata.cloud/ipfs/{$data['IpfsHash']}"
        ];
    }
    
    // ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ JSON ์—…๋กœ๋“œ
    public function uploadMetadata(array $metadata, string $name): array
    {
        $response = Http::withHeaders([
            'pinata_api_key' => $this->apiKey,
            'pinata_secret_api_key' => $this->secretKey,
            'Content-Type' => 'application/json',
        ])->post("{$this->baseUrl}/pinning/pinJSONToIPFS", [
            'pinataContent' => $metadata,
            'pinataMetadata' => ['name' => $name],
        ]);
        
        $data = $response->json();
        return [
            'cid' => $data['IpfsHash'],
            'uri' => "ipfs://{$data['IpfsHash']}",
        ];
    }
}

๐ŸŽจ NFT ๋ฏผํŒ… ์ปจํŠธ๋กค๋Ÿฌ

// app/Http/Controllers/NFTController.php
class NFTController extends Controller
{
    public function __construct(
        private IPFSService $ipfsService,
        private NFTMetadataService $metadataService
    ) {}
    
    // NFT ๋ฏผํŒ… ์ค€๋น„ (ํŒŒ์ผ ์—…๋กœ๋“œ + ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์ƒ์„ฑ)
    public function prepareMint(MintNFTRequest $request): JsonResponse
    {
        // 1. ํŒŒ์ผ IPFS ์—…๋กœ๋“œ
        $fileResult = $this->ipfsService->uploadFile(
            $request->file('nft_file'),
            $request->name
        );
        
        // 2. ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ๊ตฌ์„ฑ
        $metadata = [
            'name' => $request->name,
            'description' => $request->description,
            'image' => $fileResult['uri'],
            'attributes' => $request->attributes ?? [],
            'external_url' => config('app.url') . '/nft/' . $fileResult['cid'],
        ];
        
        // 3. ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ IPFS ์—…๋กœ๋“œ
        $metadataResult = $this->ipfsService->uploadMetadata(
            $metadata,
            $request->name . '_metadata'
        );
        
        // 4. DB์— ์ž„์‹œ ์ €์žฅ (๋ฏผํŒ… ์ „)
        $nft = NFT::create([
            'creator_address' => auth()->user()->wallet_address,
            'owner_address' => auth()->user()->wallet_address,
            'name' => $request->name,
            'description' => $request->description,
            'image_url' => $fileResult['gateway_url'],
            'metadata_uri' => $metadataResult['uri'],
            'royalty_percentage' => $request->royalty ?? 0,
            'is_minted' => false,
        ]);
        
        return response()->json([
            'nft_id' => $nft->id,
            'metadata_uri' => $metadataResult['uri'],
            'royalty_bps' => ($request->royalty ?? 0) * 100, // % โ†’ BPS ๋ณ€ํ™˜
            'message' => '๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์ค€๋น„ ์™„๋ฃŒ! ์ด์ œ ์ง€๊ฐ‘์œผ๋กœ ๋ฏผํŒ…ํ•˜์„ธ์š”.'
        ]);
    }
    
    // ๋ฏผํŒ… ์™„๋ฃŒ ํ›„ ํŠธ๋žœ์žญ์…˜ ํ•ด์‹œ ๋“ฑ๋ก
    public function confirmMint(Request $request, NFT $nft): JsonResponse
    {
        $request->validate([
            'tx_hash' => 'required|string',
            'token_id' => 'required|integer',
            'contract_address' => 'required|string',
        ]);
        
        $nft->update([
            'token_id' => $request->token_id,
            'contract_address' => strtolower($request->contract_address),
            'is_minted' => true,
            'chain_id' => config('blockchain.chain_id'),
        ]);
        
        // ๋น„๋™๊ธฐ๋กœ ๋ธ”๋ก์ฒด์ธ ํ™•์ธ
        SyncNFTFromChain::dispatch($nft)->delay(now()->addSeconds(15));
        
        return response()->json([
            'success' => true,
            'nft' => new NFTResource($nft)
        ]);
    }
    
    // NFT ๋ชฉ๋ก ์กฐํšŒ
    public function index(Request $request): JsonResponse
    {
        $nfts = NFT::with(['collection', 'activeListing'])
            ->where('is_minted', true)
            ->when($request->collection, fn($q, $v) => $q->where('contract_address', $v))
            ->when($request->min_price, fn($q, $v) => $q->whereHas('activeListing', 
                fn($lq) => $lq->where('price_wei', '>=', $v)))
            ->latest()
            ->paginate(20);
            
        return NFTResource::collection($nfts)->response();
    }
}

โšก ๋ธ”๋ก์ฒด์ธ ์ด๋ฒคํŠธ ๋ฆฌ์Šค๋„ˆ (Laravel Command)

// app/Console/Commands/ListenBlockchainEvents.php
class ListenBlockchainEvents extends Command
{
    protected $signature = 'blockchain:listen {--from-block=latest}';
    protected $description = '๋ธ”๋ก์ฒด์ธ ์ด๋ฒคํŠธ๋ฅผ ์‹ค์‹œ๊ฐ„์œผ๋กœ ๊ฐ์ง€ํ•ฉ๋‹ˆ๋‹ค';
    
    public function handle(Web3Service $web3): void
    {
        $this->info('๋ธ”๋ก์ฒด์ธ ์ด๋ฒคํŠธ ๋ฆฌ์Šค๋‹ ์‹œ์ž‘...');
        
        $marketplaceAddress = config('blockchain.marketplace_address');
        $fromBlock = $this->option('from-block');
        
        // ๋งˆ์ง€๋ง‰ ์ฒ˜๋ฆฌ ๋ธ”๋ก ๊ฐ€์ ธ์˜ค๊ธฐ
        if ($fromBlock === 'latest') {
            $fromBlock = Cache::get('last_processed_block', 'latest');
        }
        
        while (true) {
            try {
                // Sold ์ด๋ฒคํŠธ ์กฐํšŒ
                $soldEvents = $web3->getEvents(
                    $marketplaceAddress,
                    'Sold(uint256,address,uint256)',
                    $fromBlock
                );
                
                foreach ($soldEvents as $event) {
                    ProcessBlockchainEvent::dispatch([
                        'type' => 'NFT_SOLD',
                        'listing_id' => $event['listingId'],
                        'buyer' => $event['buyer'],
                        'price' => $event['price'],
                        'tx_hash' => $event['transactionHash'],
                        'block_number' => $event['blockNumber'],
                    ]);
                    
                    $fromBlock = $event['blockNumber'] + 1;
                }
                
                Cache::put('last_processed_block', $fromBlock, now()->addDay());
                
            } catch (\Exception $e) {
                $this->error('์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ์˜ค๋ฅ˜: ' . $e->getMessage());
                Log::error('Blockchain event error', ['error' => $e->getMessage()]);
            }
            
            sleep(12); // ์ด๋”๋ฆฌ์›€ ๋ธ”๋ก ์ƒ์„ฑ ์ฃผ๊ธฐ ~12์ดˆ
        }
    }
}
๐ŸŒŸ ํ”„๋กœ๋•์…˜ ํŒ! ์‹ค์ œ ์„œ๋น„์Šค์—์„œ๋Š” ์ด Command๋ฅผ Supervisor๋กœ ๊ด€๋ฆฌํ•˜๊ฑฐ๋‚˜, Moralis Streams๋‚˜ Alchemy Webhooks ๊ฐ™์€ ์„œ๋น„์Šค๋ฅผ ํ™œ์šฉํ•˜๋ฉด ํ›จ์”ฌ ์•ˆ์ •์ ์ด์•ผ. ์ง์ ‘ ํด๋งํ•˜๋Š” ๊ฑด ์†Œ๊ทœ๋ชจ ํ”„๋กœ์ ํŠธ์—์„œ๋งŒ ์จ!

๐ŸŒ 5. RESTful API ์„ค๊ณ„ โ€” ๋งˆ์ผ“ํ”Œ๋ ˆ์ด์Šค ์—”๋“œํฌ์ธํŠธ

Laravel์˜ Route ์‹œ์Šคํ…œ์„ ํ™œ์šฉํ•ด์„œ ๊น”๋”ํ•œ API๋ฅผ ์„ค๊ณ„ํ•ด๋ณด์ž. NFT ๋งˆ์ผ“ํ”Œ๋ ˆ์ด์Šค์— ํ•„์š”ํ•œ ํ•ต์‹ฌ ์—”๋“œํฌ์ธํŠธ๋“ค์ด์•ผ.

// routes/api.php

// ์ธ์ฆ ๊ด€๋ จ
Route::post('/auth/nonce', [AuthController::class, 'getNonce']);
Route::post('/auth/verify', [AuthController::class, 'verifySignature']);

// ๊ณต๊ฐœ API (์ธ์ฆ ๋ถˆํ•„์š”)
Route::prefix('v1')->group(function () {
    
    // NFT ์กฐํšŒ
    Route::get('/nfts', [NFTController::class, 'index']);
    Route::get('/nfts/{nft}', [NFTController::class, 'show']);
    Route::get('/nfts/{nft}/history', [NFTController::class, 'history']);
    
    // ์ปฌ๋ ‰์…˜
    Route::get('/collections', [CollectionController::class, 'index']);
    Route::get('/collections/{address}', [CollectionController::class, 'show']);
    Route::get('/collections/{address}/nfts', [CollectionController::class, 'nfts']);
    
    // ๋งˆ์ผ“ํ”Œ๋ ˆ์ด์Šค
    Route::get('/listings', [MarketplaceController::class, 'index']);
    Route::get('/listings/{listing}', [MarketplaceController::class, 'show']);
    
    // ๊ฒ€์ƒ‰
    Route::get('/search', [SearchController::class, 'index']);
    
    // ํ†ต๊ณ„
    Route::get('/stats', [StatsController::class, 'index']);
});

// ์ธ์ฆ ํ•„์š” API
Route::prefix('v1')->middleware('auth:sanctum')->group(function () {
    
    // NFT ๋ฏผํŒ…
    Route::post('/nfts/prepare-mint', [NFTController::class, 'prepareMint']);
    Route::post('/nfts/{nft}/confirm-mint', [NFTController::class, 'confirmMint']);
    
    // ๋งˆ์ผ“ํ”Œ๋ ˆ์ด์Šค
    Route::post('/listings', [MarketplaceController::class, 'create']);
    Route::delete('/listings/{listing}', [MarketplaceController::class, 'cancel']);
    Route::post('/listings/{listing}/buy', [MarketplaceController::class, 'confirmPurchase']);
    
    // ๊ฒฝ๋งค
    Route::post('/listings/{listing}/bids', [BidController::class, 'place']);
    Route::get('/my/bids', [BidController::class, 'myBids']);
    
    // ํ”„๋กœํ•„
    Route::get('/profile', [ProfileController::class, 'show']);
    Route::put('/profile', [ProfileController::class, 'update']);
    Route::get('/profile/nfts', [ProfileController::class, 'nfts']);
    Route::get('/profile/activity', [ProfileController::class, 'activity']);
});

๐Ÿ“Š API Resource ์˜ˆ์‹œ

// app/Http/Resources/NFTResource.php
class NFTResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'token_id' => $this->token_id,
            'contract_address' => $this->contract_address,
            'chain_id' => $this->chain_id,
            'name' => $this->name,
            'description' => $this->description,
            'image_url' => $this->image_url,
            'metadata_uri' => $this->metadata_uri,
            'attributes' => $this->attributes,
            'owner' => [
                'address' => $this->owner_address,
                'username' => $this->owner?->username,
                'avatar' => $this->owner?->avatar_url,
            ],
            'creator' => [
                'address' => $this->creator_address,
                'username' => $this->creator?->username,
            ],
            'royalty_percentage' => $this->royalty_percentage,
            'token_standard' => $this->token_standard,
            'listing' => $this->whenLoaded('activeListing', function () {
                return [
                    'id' => $this->activeListing->id,
                    'price_eth' => $this->activeListing->price_eth, // Wei โ†’ ETH ๋ณ€ํ™˜
                    'price_wei' => $this->activeListing->price_wei,
                    'listing_type' => $this->activeListing->listing_type,
                    'end_time' => $this->activeListing->end_time,
                ];
            }),
            'is_minted' => $this->is_minted,
            'created_at' => $this->created_at->toISOString(),
        ];
    }
}

๐Ÿ–ฅ๏ธ 6. ํ”„๋ก ํŠธ์—”๋“œ ์—ฐ๋™ โ€” ethers.js + Vue.js

๋ฐฑ์—”๋“œ๊ฐ€ ์™„์„ฑ๋์œผ๋ฉด ์ด์ œ ํ”„๋ก ํŠธ์—”๋“œ์—์„œ MetaMask์™€ ์—ฐ๋™ํ•˜๋Š” ๋ถ€๋ถ„์„ ๊ตฌํ˜„ํ•ด์•ผ ํ•ด. ethers.js v6๋ฅผ ์‚ฌ์šฉํ•  ๊ฑฐ์•ผ.

๐ŸฆŠ MetaMask ์—ฐ๊ฒฐ & ์ง€๊ฐ‘ ์ธ์ฆ

// composables/useWallet.js (Vue 3 Composable)
import { ref, computed } from 'vue'
import { ethers } from 'ethers'
import axios from 'axios'

export function useWallet() {
    const address = ref(null)
    const isConnected = computed(() => !!address.value)
    const provider = ref(null)
    const signer = ref(null)
    
    async function connect() {
        if (!window.ethereum) {
            alert('MetaMask๋ฅผ ์„ค์น˜ํ•ด์ฃผ์„ธ์š”!')
            return
        }
        
        try {
            // MetaMask ์—ฐ๊ฒฐ
            provider.value = new ethers.BrowserProvider(window.ethereum)
            await provider.value.send('eth_requestAccounts', [])
            signer.value = await provider.value.getSigner()
            address.value = await signer.value.getAddress()
            
            // Laravel ๋ฐฑ์—”๋“œ์— ๋กœ๊ทธ์ธ
            await signIn()
            
        } catch (error) {
            console.error('์ง€๊ฐ‘ ์—ฐ๊ฒฐ ์‹คํŒจ:', error)
        }
    }
    
    async function signIn() {
        // 1. ๋…ผ์Šค ์š”์ฒญ
        const { data: { nonce, message } } = await axios.post('/api/auth/nonce', {
            wallet_address: address.value
        })
        
        // 2. ๋ฉ”์‹œ์ง€ ์„œ๋ช… (MetaMask ํŒ์—…)
        const signature = await signer.value.signMessage(message)
        
        // 3. ์„œ๋ช… ๊ฒ€์ฆ & ํ† ํฐ ๋ฐœ๊ธ‰
        const { data } = await axios.post('/api/auth/verify', {
            wallet_address: address.value,
            signature: signature
        })
        
        // 4. ํ† ํฐ ์ €์žฅ
        localStorage.setItem('auth_token', data.token)
        axios.defaults.headers.common['Authorization'] = `Bearer ${data.token}`
        
        return data.user
    }
    
    return { address, isConnected, provider, signer, connect }
}

๐ŸŽจ NFT ๋ฏผํŒ… ์ปดํฌ๋„ŒํŠธ

// composables/useNFTMint.js
import { ref } from 'vue'
import { ethers } from 'ethers'
import NFT_ABI from '@/abis/LaravelNFT.json'

export function useNFTMint() {
    const isMinting = ref(false)
    const mintStep = ref('')
    
    async function mintNFT(formData, signer) {
        isMinting.value = true
        
        try {
            // Step 1: ๋ฐฑ์—”๋“œ์— ํŒŒ์ผ ์—…๋กœ๋“œ & ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์ค€๋น„
            mintStep.value = '๐Ÿ“ฆ IPFS์— ํŒŒ์ผ ์—…๋กœ๋“œ ์ค‘...'
            const { data } = await axios.post('/api/v1/nfts/prepare-mint', formData, {
                headers: { 'Content-Type': 'multipart/form-data' }
            })
            
            // Step 2: ์Šค๋งˆํŠธ ์ปจํŠธ๋ž™ํŠธ ๋ฏผํŒ…
            mintStep.value = 'โ›“๏ธ ๋ธ”๋ก์ฒด์ธ์— ๋ฏผํŒ… ์ค‘...'
            const contract = new ethers.Contract(
                import.meta.env.VITE_NFT_CONTRACT_ADDRESS,
                NFT_ABI,
                signer
            )
            
            const mintingFee = await contract.mintingFee()
            
            const tx = await contract.mintNFT(
                await signer.getAddress(),
                data.metadata_uri,
                data.royalty_bps,
                { value: mintingFee }
            )
            
            mintStep.value = 'โณ ํŠธ๋žœ์žญ์…˜ ํ™•์ธ ๋Œ€๊ธฐ ์ค‘...'
            const receipt = await tx.wait()
            
            // Step 3: ์ด๋ฒคํŠธ์—์„œ tokenId ์ถ”์ถœ
            const mintedEvent = receipt.logs.find(log => {
                try {
                    const parsed = contract.interface.parseLog(log)
                    return parsed?.name === 'NFTMinted'
                } catch { return false }
            })
            
            const parsedEvent = contract.interface.parseLog(mintedEvent)
            const tokenId = parsedEvent.args.tokenId.toString()
            
            // Step 4: ๋ฐฑ์—”๋“œ์— ๋ฏผํŒ… ์™„๋ฃŒ ์•Œ๋ฆผ
            mintStep.value = 'โœ… ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๋™๊ธฐํ™” ์ค‘...'
            await axios.post(`/api/v1/nfts/${data.nft_id}/confirm-mint`, {
                tx_hash: receipt.hash,
                token_id: tokenId,
                contract_address: import.meta.env.VITE_NFT_CONTRACT_ADDRESS
            })
            
            mintStep.value = '๐ŸŽ‰ ๋ฏผํŒ… ์™„๋ฃŒ!'
            return { success: true, tokenId, txHash: receipt.hash }
            
        } catch (error) {
            mintStep.value = 'โŒ ๋ฏผํŒ… ์‹คํŒจ'
            throw error
        } finally {
            isMinting.value = false
        }
    }
    
    return { isMinting, mintStep, mintNFT }
}
๐Ÿ’ก ์žฌ๋Šฅ๋„ท์—์„œ Web3 ๊ฐœ๋ฐœ ๊ด€๋ จ ์žฌ๋Šฅ์„ ์ฐพ๊ณ  ์žˆ๋‹ค๋ฉด, ์ด๋Ÿฐ NFT ๋งˆ์ผ“ํ”Œ๋ ˆ์ด์Šค ๊ตฌ์ถ• ๊ฒฝํ—˜์„ ๊ฐ€์ง„ ๊ฐœ๋ฐœ์ž๋“ค์„ ๋งŒ๋‚  ์ˆ˜ ์žˆ์–ด! Laravel + ๋ธ”๋ก์ฒด์ธ ์กฐํ•ฉ์€ ๊ฝค ํฌ๊ท€ํ•œ ์Šคํ‚ฌ์…‹์ด๋ผ ์ˆ˜์š”๊ฐ€ ๋†’๊ฑฐ๋“ . ๐Ÿ˜‰

๐Ÿ”’ NFT ๋งˆ์ผ“ํ”Œ๋ ˆ์ด์Šค ๋ณด์•ˆ ์ฒดํฌ๋ฆฌ์ŠคํŠธ ๐Ÿ›ก๏ธ โ›“๏ธ ์ปจํŠธ๋ž™ํŠธ ๋ณด์•ˆ โœ… Reentrancy Guard ์ ์šฉ โœ… CEI ํŒจํ„ด ์ค€์ˆ˜ โœ… ์ •์ˆ˜ ์˜ค๋ฒ„ํ”Œ๋กœ์šฐ ๋ฐฉ์ง€ โœ… Access Control ์„ค์ • โœ… ๋ณด์•ˆ ๊ฐ์‚ฌ(Audit) ํ•„์ˆ˜ โœ… ํ…Œ์ŠคํŠธ๋„ท ์ถฉ๋ถ„ํžˆ ํ…Œ์ŠคํŠธ โœ… ์—…๊ทธ๋ ˆ์ด๋“œ ๊ฐ€๋Šฅ ์„ค๊ณ„ โœ… ์ด๋ฒคํŠธ ๋กœ๊น… ์ฒ ์ €ํžˆ โœ… ๊ธด๊ธ‰ ์ •์ง€ ๊ธฐ๋Šฅ ๊ตฌํ˜„ โœ… ์ตœ๋Œ€ ์ˆ˜์ˆ˜๋ฃŒ ์ œํ•œ โœ… ํƒ€์ž„๋ฝ ์ ์šฉ ๐Ÿ”ด Laravel ๋ณด์•ˆ โœ… SIWE ์„œ๋ช… ๊ฒ€์ฆ โœ… Rate Limiting ์„ค์ • โœ… Input Validation ์ฒ ์ € โœ… SQL Injection ๋ฐฉ์ง€ โœ… XSS ๋ฐฉ์ง€ ์ฒ˜๋ฆฌ โœ… CORS ์„ค์ • ์ตœ์†Œํ™” โœ… API ํ‚ค ํ™˜๊ฒฝ๋ณ€์ˆ˜ ๊ด€๋ฆฌ โœ… HTTPS ๊ฐ•์ œ ์ ์šฉ โœ… ๋กœ๊ทธ ๋ชจ๋‹ˆํ„ฐ๋ง โœ… ์ง€๊ฐ‘ ์ฃผ์†Œ ์†Œ๋ฌธ์ž ์ •๊ทœํ™” โœ… ๋…ผ์Šค ๋งŒ๋ฃŒ ์ฒ˜๋ฆฌ โ˜๏ธ ์ธํ”„๋ผ ๋ณด์•ˆ โœ… ๊ฐœ์ธํ‚ค ์ ˆ๋Œ€ ๋…ธ์ถœ ๊ธˆ์ง€ โœ… AWS IAM ์ตœ์†Œ ๊ถŒํ•œ โœ… VPC ๋„คํŠธ์›Œํฌ ๊ฒฉ๋ฆฌ โœ… DB ์•”ํ˜ธํ™” ์ €์žฅ โœ… ์ •๊ธฐ ๋ฐฑ์—… ์„ค์ • โœ… DDoS ๋ฐฉ์–ด (CloudFlare) โœ… WAF ์„ค์ • โœ… ์ทจ์•ฝ์  ์Šค์บ” ์ •๊ธฐ ์‹คํ–‰ โœ… ์ปจํ…Œ์ด๋„ˆ ์ด๋ฏธ์ง€ ์Šค์บ” โœ… ์‹œํฌ๋ฆฟ ๊ด€๋ฆฌ (Vault) โœ… ์นจ์ž… ํƒ์ง€ ์‹œ์Šคํ…œ ๋ณด์•ˆ์€ ์„ ํƒ์ด ์•„๋‹Œ ํ•„์ˆ˜! ํŠนํžˆ ์‹ค์ œ ์ž์‚ฐ์ด ์˜ค๊ฐ€๋Š” NFT ๋งˆ์ผ“ํ”Œ๋ ˆ์ด์Šค์—์„œ๋Š” ๋”์šฑ ์ค‘์š”ํ•ฉ๋‹ˆ๋‹ค.

โšก 7. ์„ฑ๋Šฅ ์ตœ์ ํ™” โ€” ๋น ๋ฅธ ๋งˆ์ผ“ํ”Œ๋ ˆ์ด์Šค ๋งŒ๋“ค๊ธฐ

NFT ๋งˆ์ผ“ํ”Œ๋ ˆ์ด์Šค๋Š” ๋ธ”๋ก์ฒด์ธ ๋ฐ์ดํ„ฐ๋ฅผ ๋งŽ์ด ๋‹ค๋ฃจ๋‹ค ๋ณด๋‹ˆ ์„ฑ๋Šฅ ์ตœ์ ํ™”๊ฐ€ ์ •๋ง ์ค‘์š”ํ•ด. ๋А๋ฆฐ ๋งˆ์ผ“ํ”Œ๋ ˆ์ด์Šค๋Š” ์‚ฌ์šฉ์ž๊ฐ€ ๋ฐ”๋กœ ๋– ๋‚˜๊ฑฐ๋“ !

๐Ÿš€ Laravel ์บ์‹ฑ ์ „๋žต

// app/Services/NFTCacheService.php
class NFTCacheService
{
    // NFT ์ƒ์„ธ ์ •๋ณด ์บ์‹ฑ (1์‹œ๊ฐ„)
    public function getNFT(int $id): NFT
    {
        return Cache::remember("nft:{$id}", 3600, function () use ($id) {
            return NFT::with(['collection', 'activeListing', 'creator', 'owner'])
                ->findOrFail($id);
        });
    }
    
    // ์ปฌ๋ ‰์…˜ ํ†ต๊ณ„ ์บ์‹ฑ (5๋ถ„)
    public function getCollectionStats(string $address): array
    {
        return Cache::remember("collection:stats:{$address}", 300, function () use ($address) {
            return [
                'floor_price' => Listing::whereHas('nft', fn($q) => 
                    $q->where('contract_address', $address))
                    ->where('is_active', true)
                    ->min('price_wei'),
                'volume_24h' => Transaction::whereHas('nft', fn($q) => 
                    $q->where('contract_address', $address))
                    ->where('created_at', '>=', now()->subDay())
                    ->sum('price_wei'),
                'total_supply' => NFT::where('contract_address', $address)
                    ->where('is_minted', true)->count(),
            ];
        });
    }
    
    // NFT ๋ชฉ๋ก ์บ์‹ฑ (2๋ถ„)
    public function getListings(array $filters): LengthAwarePaginator
    {
        $cacheKey = 'listings:' . md5(serialize($filters));
        return Cache::remember($cacheKey, 120, function () use ($filters) {
            return Listing::with(['nft.collection'])
                ->where('is_active', true)
                ->when($filters['sort'] ?? null, function ($q, $sort) {
                    match($sort) {
                        'price_asc' => $q->orderBy('price_wei', 'asc'),
                        'price_desc' => $q->orderBy('price_wei', 'desc'),
                        default => $q->latest()
                    };
                })
                ->paginate(20);
        });
    }
    
    // ์บ์‹œ ๋ฌดํšจํ™”
    public function invalidateNFT(int $id): void
    {
        Cache::forget("nft:{$id}");
        Cache::tags(['nft-listings'])->flush(); // ํƒœ๊ทธ ๊ธฐ๋ฐ˜ ์บ์‹œ ๋ฌดํšจํ™”
    }
}

๐Ÿ“Š ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ธ๋ฑ์Šค ์ตœ์ ํ™”

// ์„ฑ๋Šฅ ์ตœ์ ํ™”๋ฅผ ์œ„ํ•œ ์ธ๋ฑ์Šค ์ถ”๊ฐ€ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜
Schema::table('nfts', function (Blueprint $table) {
    $table->index(['contract_address', 'is_minted']); // ์ปฌ๋ ‰์…˜ ์กฐํšŒ
    $table->index(['owner_address', 'is_minted']);     // ์†Œ์œ ์ž๋ณ„ ์กฐํšŒ
    $table->index(['creator_address']);                 // ํฌ๋ฆฌ์—์ดํ„ฐ๋ณ„ ์กฐํšŒ
    $table->index(['created_at']);                      // ์ตœ์‹ ์ˆœ ์ •๋ ฌ
});

Schema::table('listings', function (Blueprint $table) {
    $table->index(['is_active', 'price_wei']);          // ๊ฐ€๊ฒฉ ํ•„ํ„ฐ๋ง
    $table->index(['is_active', 'listing_type']);       // ๊ฒฝ๋งค/๊ณ ์ •๊ฐ€ ํ•„ํ„ฐ
    $table->index(['seller_address', 'is_active']);     // ํŒ๋งค์ž๋ณ„ ์กฐํšŒ
    $table->index(['end_time', 'is_active']);           // ๊ฒฝ๋งค ๋งŒ๋ฃŒ ์ฒ˜๋ฆฌ
});

Schema::table('transactions', function (Blueprint $table) {
    $table->index(['from_address']);
    $table->index(['to_address']);
    $table->index(['nft_id', 'created_at']);
    $table->index(['tx_hash'], 'idx_tx_hash');
});

๐Ÿ”„ Laravel Horizon์œผ๋กœ ํ ๊ด€๋ฆฌ

// config/horizon.php
'environments' => [
    'production' => [
        'supervisor-1' => [
            'maxProcesses' => 10,
            'balanceMaxShift' => 1,
            'balanceCooldown' => 3,
        ],
        // ๋ธ”๋ก์ฒด์ธ ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ์ „์šฉ ํ
        'blockchain-supervisor' => [
            'connection' => 'redis',
            'queue' => ['blockchain-events'],
            'balance' => 'auto',
            'processes' => 3,
            'tries' => 3,
            'timeout' => 60,
        ],
        // IPFS ์—…๋กœ๋“œ ์ „์šฉ ํ
        'ipfs-supervisor' => [
            'connection' => 'redis',
            'queue' => ['ipfs-uploads'],
            'balance' => 'auto',
            'processes' => 5,
            'tries' => 3,
            'timeout' => 120,
        ],
    ],
],
๐ŸŒŸ ์„ฑ๋Šฅ ํŒ! NFT ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ๋Š” IPFS์—์„œ ๊ฐ€์ ธ์˜ค๋Š” ๊ฒŒ ๋А๋ฆด ์ˆ˜ ์žˆ์–ด. ๊ทธ๋ž˜์„œ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ๋ฅผ DB์— ์บ์‹ฑํ•˜๊ณ , ์ฃผ๊ธฐ์ ์œผ๋กœ ๋™๊ธฐํ™”ํ•˜๋Š” ์ „๋žต์„ ์จ์•ผ ํ•ด. ๋ชจ๋“  ์š”์ฒญ๋งˆ๋‹ค IPFS๋ฅผ ์ง์ ‘ ์กฐํšŒํ•˜๋ฉด UX๊ฐ€ ์ตœ์•…์ด ๋ผ!

๐Ÿš€ 8. ๋ฐฐํฌ ์ „๋žต โ€” Docker + CI/CD

์ฝ”๋“œ๊ฐ€ ์™„์„ฑ๋์œผ๋ฉด ์ด์ œ ๋ฐฐํฌํ•  ์ฐจ๋ก€์•ผ! Docker๋ฅผ ํ™œ์šฉํ•œ ์ปจํ…Œ์ด๋„ˆ ๊ธฐ๋ฐ˜ ๋ฐฐํฌ๊ฐ€ ๊ฐ€์žฅ ์•ˆ์ •์ ์ด์•ผ.

๐Ÿณ Docker Compose ๊ตฌ์„ฑ

# docker-compose.yml
version: '3.8'

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    volumes:
      - .:/var/www/html
    environment:
      - APP_ENV=production
    depends_on:
      - mysql
      - redis
    networks:
      - nft-network

  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf
      - ./ssl:/etc/nginx/ssl
    depends_on:
      - app
    networks:
      - nft-network

  mysql:
    image: mysql:8.0
    environment:
      MYSQL_DATABASE: nft_marketplace
      MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
    volumes:
      - mysql_data:/var/lib/mysql
    networks:
      - nft-network

  redis:
    image: redis:7-alpine
    command: redis-server --requirepass ${REDIS_PASSWORD}
    volumes:
      - redis_data:/data
    networks:
      - nft-network

  horizon:
    build:
      context: .
      dockerfile: Dockerfile
    command: php artisan horizon
    depends_on:
      - mysql
      - redis
    networks:
      - nft-network

  blockchain-listener:
    build:
      context: .
      dockerfile: Dockerfile
    command: php artisan blockchain:listen
    restart: unless-stopped
    depends_on:
      - mysql
      - redis
    networks:
      - nft-network

volumes:
  mysql_data:
  redis_data:

networks:
  nft-network:
    driver: bridge

๐Ÿ”„ GitHub Actions CI/CD

# .github/workflows/deploy.yml
name: NFT Marketplace Deploy

on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: PHP ์„ค์ •
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.2'
          
      - name: Composer ์˜์กด์„ฑ ์„ค์น˜
        run: composer install --no-dev --optimize-autoloader
        
      - name: ํ™˜๊ฒฝ ์„ค์ •
        run: |
          cp .env.testing .env
          php artisan key:generate
          
      - name: ํ…Œ์ŠคํŠธ ์‹คํ–‰
        run: php artisan test --parallel
        
      - name: Hardhat ํ…Œ์ŠคํŠธ
        run: |
          npm install
          npx hardhat test

  deploy:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Docker ์ด๋ฏธ์ง€ ๋นŒ๋“œ & ํ‘ธ์‹œ
        run: |
          docker build -t nft-marketplace:${{ github.sha }} .
          docker push registry.example.com/nft-marketplace:${{ github.sha }}
          
      - name: ์„œ๋ฒ„ ๋ฐฐํฌ
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            cd /app/nft-marketplace
            docker-compose pull
            docker-compose up -d --no-deps app horizon
            docker-compose exec -T app php artisan migrate --force
            docker-compose exec -T app php artisan config:cache
            docker-compose exec -T app php artisan route:cache
            docker-compose exec -T app php artisan view:cache

โœจ 9. ๊ณ ๊ธ‰ ๊ธฐ๋Šฅ ๊ตฌํ˜„ โ€” ๊ฒฝ์Ÿ๋ ฅ ์žˆ๋Š” ๋งˆ์ผ“ํ”Œ๋ ˆ์ด์Šค๋กœ!

๊ธฐ๋ณธ ๊ธฐ๋Šฅ์ด ์™„์„ฑ๋์œผ๋ฉด ์ด์ œ ์ฐจ๋ณ„ํ™” ํฌ์ธํŠธ๋ฅผ ๋งŒ๋“ค์–ด์•ผ ํ•ด. ์‹ค์ œ OpenSea๋‚˜ Blur ๊ฐ™์€ ๋งˆ์ผ“ํ”Œ๋ ˆ์ด์Šค๋“ค์ด ์ œ๊ณตํ•˜๋Š” ๊ณ ๊ธ‰ ๊ธฐ๋Šฅ๋“ค์„ ์‚ดํŽด๋ณด์ž.

๐Ÿท๏ธ ๊ฒฝ๋งค ์‹œ์Šคํ…œ
์˜๊ตญ์‹ ๊ฒฝ๋งค(์ตœ๊ณ ๊ฐ€ ๋‚™์ฐฐ), ๋”์น˜ ๊ฒฝ๋งค(๊ฐ€๊ฒฉ ํ•˜๋ฝ), ์˜ˆ์•ฝ๊ฐ€ ๊ฒฝ๋งค๋ฅผ ์Šค๋งˆํŠธ ์ปจํŠธ๋ž™ํŠธ๋กœ ๊ตฌํ˜„. ์ž…์ฐฐ ์‹œ ์ด์ „ ์ž…์ฐฐ์ž์—๊ฒŒ ์ž๋™ ํ™˜๋ถˆ.
๐Ÿ’ฐ ๋กœ์—ดํ‹ฐ ์‹œ์Šคํ…œ
EIP-2981 ํ‘œ์ค€์œผ๋กœ ํฌ๋ฆฌ์—์ดํ„ฐ ๋กœ์—ดํ‹ฐ ์ž๋™ ์ง€๊ธ‰. 2์ฐจ ํŒ๋งค ๋•Œ๋งˆ๋‹ค ์›์ž‘์ž์—๊ฒŒ ์„ค์ •๋œ % ์ž๋™ ์ „์†ก.
๐Ÿ”” ์‹ค์‹œ๊ฐ„ ์•Œ๋ฆผ
Laravel Broadcasting + Pusher๋กœ ์ž…์ฐฐ, ํŒ๋งค, ๊ตฌ๋งค ์™„๋ฃŒ ์•Œ๋ฆผ์„ ์‹ค์‹œ๊ฐ„์œผ๋กœ ์ „์†ก. WebSocket ๊ธฐ๋ฐ˜.
๐Ÿ“Š ๋ถ„์„ ๋Œ€์‹œ๋ณด๋“œ
๊ฑฐ๋ž˜๋Ÿ‰, ๋ฐ”๋‹ฅ๊ฐ€, ๋ณด์œ ์ž ์ˆ˜, ๊ฐ€๊ฒฉ ์ฐจํŠธ๋ฅผ Laravel + Chart.js๋กœ ์‹œ๊ฐํ™”. ์ปฌ๋ ‰์…˜๋ณ„ ํ†ต๊ณ„ ์ œ๊ณต.
๐Ÿ” ๊ณ ๊ธ‰ ๊ฒ€์ƒ‰
Laravel Scout + Meilisearch๋กœ NFT ์ด๋ฆ„, ์†์„ฑ, ์ปฌ๋ ‰์…˜ ์ „๋ฌธ ๊ฒ€์ƒ‰. ํ•„ํ„ฐ๋ง & ์ •๋ ฌ ์กฐํ•ฉ.
๐ŸŒ ๋ฉ€ํ‹ฐ์ฒด์ธ ์ง€์›
์ด๋”๋ฆฌ์›€ ์™ธ Polygon, BNB Chain, Arbitrum ๋“ฑ EVM ํ˜ธํ™˜ ์ฒด์ธ ์ง€์›. chain_id๋กœ ๊ตฌ๋ถ„ ๊ด€๋ฆฌ.

๐Ÿ”” ์‹ค์‹œ๊ฐ„ ์•Œ๋ฆผ ๊ตฌํ˜„ (Laravel Broadcasting)

// app/Events/NFTSold.php
class NFTSold implements ShouldBroadcast
{
    use Dispatchable, InteractsWithSockets, SerializesModels;
    
    public function __construct(
        public NFT $nft,
        public string $buyerAddress,
        public string $priceWei,
        public string $txHash
    ) {}
    
    public function broadcastOn(): array
    {
        return [
            new Channel('marketplace'),                    // ์ „์ฒด ๋งˆ์ผ“ ์ฑ„๋„
            new PrivateChannel("user.{$this->nft->owner_address}"), // ํŒ๋งค์ž ์ฑ„๋„
            new PrivateChannel("user.{$this->buyerAddress}"),       // ๊ตฌ๋งค์ž ์ฑ„๋„
        ];
    }
    
    public function broadcastWith(): array
    {
        return [
            'nft_id' => $this->nft->id,
            'nft_name' => $this->nft->name,
            'nft_image' => $this->nft->image_url,
            'price_eth' => number_format(
                (float) \Web3\Utils::fromWei($this->priceWei, 'ether'), 4
            ),
            'buyer' => $this->buyerAddress,
            'tx_hash' => $this->txHash,
            'timestamp' => now()->toISOString(),
        ];
    }
    
    public function broadcastAs(): string
    {
        return 'nft.sold';
    }
}

// ์ด๋ฒคํŠธ ๋ฐœ์ƒ (Job์—์„œ)
event(new NFTSold($nft, $buyerAddress, $priceWei, $txHash));

๐Ÿ” Laravel Scout ๊ฒ€์ƒ‰ ์„ค์ •

// app/Models/NFT.php
class NFT extends Model
{
    use HasFactory, Searchable;
    
    public function toSearchableArray(): array
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'description' => $this->description,
            'collection_name' => $this->collection?->name,
            'creator_address' => $this->creator_address,
            'attributes' => collect($this->attributes)->pluck('value')->implode(' '),
            'token_standard' => $this->token_standard,
        ];
    }
}

// ๊ฒ€์ƒ‰ ์ปจํŠธ๋กค๋Ÿฌ
public function search(Request $request): JsonResponse
{
    $results = NFT::search($request->q)
        ->where('is_minted', true)
        ->paginate(20);
        
    return NFTResource::collection($results)->response();
}

๐Ÿงช 10. ํ…Œ์ŠคํŠธ ์ „๋žต โ€” ๋ฏฟ์„ ์ˆ˜ ์žˆ๋Š” ์ฝ”๋“œ ๋งŒ๋“ค๊ธฐ

NFT ๋งˆ์ผ“ํ”Œ๋ ˆ์ด์Šค๋Š” ์‹ค์ œ ๋ˆ์ด ์˜ค๊ฐ€๋Š” ์„œ๋น„์Šค์•ผ. ๊ทธ๋ž˜์„œ ํ…Œ์ŠคํŠธ๊ฐ€ ์ •๋ง ์ค‘์š”ํ•ด! Laravel์˜ ๊ฐ•๋ ฅํ•œ ํ…Œ์ŠคํŠธ ๋„๊ตฌ๋ฅผ ํ™œ์šฉํ•˜์ž.

๐Ÿ”ฌ Feature ํ…Œ์ŠคํŠธ ์˜ˆ์‹œ

// tests/Feature/NFTMintTest.php
class NFTMintTest extends TestCase
{
    use RefreshDatabase;
    
    public function test_authenticated_user_can_prepare_mint(): void
    {
        // ์ง€๊ฐ‘ ์ธ์ฆ๋œ ์‚ฌ์šฉ์ž ์ƒ์„ฑ
        $user = User::factory()->create([
            'wallet_address' => '0x742d35cc6634c0532925a3b8d4c9b4c4c4c4c4c4'
        ]);
        
        // IPFS ์„œ๋น„์Šค Mock
        $this->mock(IPFSService::class, function ($mock) {
            $mock->shouldReceive('uploadFile')->once()->andReturn([
                'cid' => 'QmTest123',
                'uri' => 'ipfs://QmTest123',
                'gateway_url' => 'https://gateway.pinata.cloud/ipfs/QmTest123'
            ]);
            $mock->shouldReceive('uploadMetadata')->once()->andReturn([
                'cid' => 'QmMeta456',
                'uri' => 'ipfs://QmMeta456',
            ]);
        });
        
        $response = $this->actingAs($user)
            ->postJson('/api/v1/nfts/prepare-mint', [
                'name' => 'ํ…Œ์ŠคํŠธ NFT',
                'description' => 'ํ…Œ์ŠคํŠธ์šฉ NFT์ž…๋‹ˆ๋‹ค',
                'nft_file' => UploadedFile::fake()->image('test.jpg', 800, 800),
                'royalty' => 5,
            ]);
        
        $response->assertStatus(200)
            ->assertJsonStructure([
                'nft_id',
                'metadata_uri',
                'royalty_bps',
                'message'
            ]);
        
        $this->assertDatabaseHas('nfts', [
            'name' => 'ํ…Œ์ŠคํŠธ NFT',
            'creator_address' => $user->wallet_address,
            'is_minted' => false,
        ]);
    }
    
    public function test_unauthenticated_user_cannot_mint(): void
    {
        $response = $this->postJson('/api/v1/nfts/prepare-mint', [
            'name' => 'ํ…Œ์ŠคํŠธ NFT',
        ]);
        
        $response->assertStatus(401);
    }
}

// Hardhat ์Šค๋งˆํŠธ ์ปจํŠธ๋ž™ํŠธ ํ…Œ์ŠคํŠธ
// test/NFTMarketplace.test.js
describe("NFTMarketplace", function () {
    it("Should list and buy NFT correctly", async function () {
        const [owner, seller, buyer] = await ethers.getSigners();
        
        // NFT ๋ฏผํŒ…
        const NFT = await ethers.getContractFactory("LaravelNFT");
        const nft = await NFT.deploy();
        
        const mintTx = await nft.connect(seller).mintNFT(
            seller.address, "ipfs://test", 500,
            { value: ethers.parseEther("0.001") }
        );
        const receipt = await mintTx.wait();
        const tokenId = 1;
        
        // ๋งˆ์ผ“ํ”Œ๋ ˆ์ด์Šค ๋ฐฐํฌ
        const Marketplace = await ethers.getContractFactory("NFTMarketplace");
        const marketplace = await Marketplace.deploy();
        
        // ์Šน์ธ & ๋ฆฌ์ŠคํŒ…
        await nft.connect(seller).approve(marketplace.target, tokenId);
        const listPrice = ethers.parseEther("1.0");
        await marketplace.connect(seller).listNFT(nft.target, tokenId, listPrice);
        
        // ๊ตฌ๋งค
        const buyerBalanceBefore = await ethers.provider.getBalance(buyer.address);
        await marketplace.connect(buyer).buyNFT(1, { value: listPrice });
        
        // ๊ฒ€์ฆ
        expect(await nft.ownerOf(tokenId)).to.equal(buyer.address);
    });
});
๐Ÿ’ก ์žฌ๋Šฅ๋„ท์—์„œ Laravel์ด๋‚˜ Solidity ๊ฐœ๋ฐœ ๊ด€๋ จ ๋ฉ˜ํ† ๋ง์„ ๋ฐ›์„ ์ˆ˜ ์žˆ์–ด! ์ด๋Ÿฐ ๋ณต์žกํ•œ Web3 ํ”„๋กœ์ ํŠธ๋ฅผ ์ฒ˜์Œ ์‹œ์ž‘ํ•  ๋•Œ ๊ฒฝํ—˜ ์žˆ๋Š” ๊ฐœ๋ฐœ์ž์˜ ์ฝ”๋“œ ๋ฆฌ๋ทฐ๋ฅผ ๋ฐ›์œผ๋ฉด ์‹ค์ˆ˜๋ฅผ ๋งŽ์ด ์ค„์ผ ์ˆ˜ ์žˆ๊ฑฐ๋“ . ๐ŸŽฏ

๐Ÿ—บ๏ธ NFT ๋งˆ์ผ“ํ”Œ๋ ˆ์ด์Šค ๊ฐœ๋ฐœ ๋กœ๋“œ๋งต 1 1~2์ฃผ์ฐจ ๊ธฐ๋ฐ˜ ์„ค๊ณ„ โ€ข DB ์„ค๊ณ„ โ€ข Laravel ์„ธํŒ… โ€ข ์ง€๊ฐ‘ ์ธ์ฆ โ€ข ๊ธฐ๋ณธ API 2 3~4์ฃผ์ฐจ ์Šค๋งˆํŠธ ์ปจํŠธ๋ž™ํŠธ โ€ข ERC-721 ๊ฐœ๋ฐœ โ€ข ๋งˆ์ผ“ํ”Œ๋ ˆ์ด์Šค โ€ข Hardhat ํ…Œ์ŠคํŠธ โ€ข ํ…Œ์ŠคํŠธ๋„ท ๋ฐฐํฌ 3 5~6์ฃผ์ฐจ IPFS & ๋ฏผํŒ… โ€ข Pinata ์—ฐ๋™ โ€ข ๋ฏผํŒ… ํ”Œ๋กœ์šฐ โ€ข ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ๊ด€๋ฆฌ โ€ข ์ด๋ฒคํŠธ ๋ฆฌ์Šค๋„ˆ 4 7~8์ฃผ์ฐจ ํ”„๋ก ํŠธ์—”๋“œ โ€ข Vue.js ๊ฐœ๋ฐœ โ€ข MetaMask ์—ฐ๋™ โ€ข ๋งˆ์ผ“ UI ๊ตฌํ˜„ โ€ข ์‹ค์‹œ๊ฐ„ ์•Œ๋ฆผ 5 9~10์ฃผ์ฐจ ๋ณด์•ˆ & ๋ฐฐํฌ โ€ข ๋ณด์•ˆ ๊ฐ์‚ฌ โ€ข ์„ฑ๋Šฅ ์ตœ์ ํ™” โ€ข Docker ๋ฐฐํฌ โ€ข ๋ฉ”์ธ๋„ท ๋Ÿฐ์นญ ์ด 10์ฃผ ๊ธฐ์ค€ ๊ฐœ๋ฐœ ์ผ์ • (ํŒ€ ๊ทœ๋ชจ ๋ฐ ๊ฒฝํ—˜์— ๋”ฐ๋ผ ์กฐ์ • ํ•„์š”)

๐ŸŽฏ 11. ๋งˆ๋ฌด๋ฆฌ โ€” ํ•ต์‹ฌ ์š”์•ฝ & ๋‹ค์Œ ๋‹จ๊ณ„

์ž, ์—ฌ๊ธฐ๊นŒ์ง€ ์™”์œผ๋ฉด Laravel ๊ธฐ๋ฐ˜ NFT ๋งˆ์ผ“ํ”Œ๋ ˆ์ด์Šค์˜ ์ „์ฒด ๊ทธ๋ฆผ์ด ๋จธ๋ฆฟ์†์— ๊ทธ๋ ค์กŒ์„ ๊ฑฐ์•ผ! ์ •๋ง ๊ธด ์—ฌ์ •์ด์—ˆ๋Š”๋ฐ ์ˆ˜๊ณ ํ–ˆ์–ด ๐Ÿ‘

๐Ÿ“Œ ํ•ต์‹ฌ ์š”์•ฝ

1. ์„ค๊ณ„ โ€” ์˜จ์ฒด์ธ/์˜คํ”„์ฒด์ธ ๋ฐ์ดํ„ฐ ๋ถ„๋ฆฌ, Wei ๋‹จ์œ„ ์ €์žฅ, ์ธ๋ฑ์Šค ์ตœ์ ํ™”
2. ์Šค๋งˆํŠธ ์ปจํŠธ๋ž™ํŠธ โ€” OpenZeppelin ๊ธฐ๋ฐ˜ ERC-721 + ๋งˆ์ผ“ํ”Œ๋ ˆ์ด์Šค ์ปจํŠธ๋ž™ํŠธ, ReentrancyGuard ํ•„์ˆ˜
3. Laravel ๋ฐฑ์—”๋“œ โ€” SIWE ์ง€๊ฐ‘ ์ธ์ฆ, IPFS ์—ฐ๋™, Queue ๊ธฐ๋ฐ˜ ๋น„๋™๊ธฐ ์ฒ˜๋ฆฌ
4. ํ”„๋ก ํŠธ์—”๋“œ โ€” ethers.js v6 + MetaMask SDK, Vue.js Composable ํŒจํ„ด
5. ๋ณด์•ˆ โ€” ์ปจํŠธ๋ž™ํŠธ ๊ฐ์‚ฌ, Rate Limiting, ์ž…๋ ฅ ๊ฒ€์ฆ, HTTPS ๊ฐ•์ œ
6. ์„ฑ๋Šฅ โ€” Redis ์บ์‹ฑ, DB ์ธ๋ฑ์Šค, Laravel Horizon, ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์บ์‹ฑ
7. ๋ฐฐํฌ โ€” Docker Compose, GitHub Actions CI/CD, Supervisor

๐Ÿš€ ๋‹ค์Œ ๋‹จ๊ณ„๋กœ ๋‚˜์•„๊ฐ€๊ธฐ

1
Sepolia ํ…Œ์ŠคํŠธ๋„ท์—์„œ ๋จผ์ € ๋ฐฐํฌ โ€” ์‹ค์ œ ETH๋ฅผ ์“ฐ๊ธฐ ์ „์— ํ…Œ์ŠคํŠธ๋„ท์—์„œ ์ถฉ๋ถ„ํžˆ ๊ฒ€์ฆํ•ด. Alchemy๋‚˜ Infura์—์„œ ๋ฌด๋ฃŒ RPC ์—”๋“œํฌ์ธํŠธ๋ฅผ ์ œ๊ณตํ•ด.
2
๋ณด์•ˆ ๊ฐ์‚ฌ ๋ฐ›๊ธฐ โ€” ์‹ค์ œ ์ž์‚ฐ์ด ์˜ค๊ฐ€๋Š” ์ปจํŠธ๋ž™ํŠธ๋Š” ๋ฐ˜๋“œ์‹œ ์ „๋ฌธ ๊ฐ์‚ฌ ์—…์ฒด(CertiK, OpenZeppelin Audits ๋“ฑ)์˜ ๊ฒ€ํ† ๋ฅผ ๋ฐ›์•„์•ผ ํ•ด.
3
The Graph ๋„์ž… ๊ณ ๋ ค โ€” ๋ธ”๋ก์ฒด์ธ ์ด๋ฒคํŠธ๋ฅผ ์ง์ ‘ ํด๋งํ•˜๋Š” ๋Œ€์‹  The Graph ํ”„๋กœํ† ์ฝœ์„ ์‚ฌ์šฉํ•˜๋ฉด ํ›จ์”ฌ ํšจ์œจ์ ์œผ๋กœ ๋ฐ์ดํ„ฐ๋ฅผ ์ธ๋ฑ์‹ฑํ•  ์ˆ˜ ์žˆ์–ด.
4
๋ฉ€ํ‹ฐ์ฒด์ธ ํ™•์žฅ โ€” Polygon์ด๋‚˜ Arbitrum ๊ฐ™์€ L2 ์ฒด์ธ์„ ์ถ”๊ฐ€ํ•˜๋ฉด ๊ฐ€์Šค๋น„๋ฅผ ๋‚ฎ์ถ”๊ณ  ๋” ๋งŽ์€ ์‚ฌ์šฉ์ž๋ฅผ ์œ ์น˜ํ•  ์ˆ˜ ์žˆ์–ด.
5
์ปค๋ฎค๋‹ˆํ‹ฐ ๊ธฐ๋Šฅ ์ถ”๊ฐ€ โ€” ์ปฌ๋ ‰์…˜ ํŒ”๋กœ์šฐ, ํฌ๋ฆฌ์—์ดํ„ฐ ํ”„๋กœํ•„, ์†Œ์…œ ๊ธฐ๋Šฅ์„ ์ถ”๊ฐ€ํ•˜๋ฉด ์‚ฌ์šฉ์ž ๋ฆฌํ…์…˜์ด ํฌ๊ฒŒ ์˜ฌ๋ผ๊ฐ€.
โš ๏ธ ๋ฒ•์  ๊ณ ๋ ค์‚ฌํ•ญ! NFT ๋งˆ์ผ“ํ”Œ๋ ˆ์ด์Šค๋ฅผ ์‹ค์ œ ์šด์˜ํ•˜๋ ค๋ฉด ๊ฐ ๊ตญ๊ฐ€์˜ ๊ธˆ์œต ๊ทœ์ œ, ์ €์ž‘๊ถŒ๋ฒ•, KYC/AML ์š”๊ฑด์„ ๋ฐ˜๋“œ์‹œ ํ™•์ธํ•ด์•ผ ํ•ด. ํŠนํžˆ ํ•œ๊ตญ์—์„œ๋Š” ๊ฐ€์ƒ์ž์‚ฐ ๊ด€๋ จ ๋ฒ•๊ทœ๊ฐ€ ๊ณ„์† ๋ณ€ํ™”ํ•˜๊ณ  ์žˆ์œผ๋‹ˆ ๋ฒ•๋ฅ  ์ „๋ฌธ๊ฐ€ ์ƒ๋‹ด์„ ๊ผญ ๋ฐ›์•„๋ด!
๋Œ“๊ธ€ ์ž‘์„ฑ

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

๋Œ“๊ธ€ 0