Ver3.5 ๐ ๋ผ๋ผ๋ฒจ(Laravel) ๊ธฐ๋ฐ NFT ๋ง์ผํ๋ ์ด์ค์ค๊ณ๋ถํฐ ๊ตฌ์ถ๊น์ง โ ์์ ์ ๋ณต ๊ฐ์ด๋ ๐

๐ ๋ผ๋ผ๋ฒจ(Laravel) ๊ธฐ๋ฐ NFT ๋ง์ผํ๋ ์ด์ค
์ค๊ณ๋ถํฐ ๊ตฌ์ถ๊น์ง โ ์์ ์ ๋ณต ๊ฐ์ด๋ ๐
์ผ, ์์งํ ๋งํด๋ณผ๊ฒ. NFT ๋ง์ผํ๋ ์ด์ค ๋ง๋ ๋ค๊ณ ํ๋ฉด ๋๋ถ๋ถ Node.js + React ์กฐํฉ๋ถํฐ ๋ ์ฌ๋ฆฌ์์? ๊ทผ๋ฐ ์ ๊น, ์ฐ๋ฆฌํํ ๋ Laravel์ด ์๋ค๊ณ ! ๐
Laravel์ PHP ์ํ๊ณ์์ ๊ฐ์ฅ ์ธ๋ จ๋ ํ๋ ์์ํฌ์ผ. ๊ฐ๋ ฅํ ORM, ํ ์์คํ , ์ด๋ฒคํธ ๋ธ๋ก๋์บ์คํ , API ๋ฆฌ์์ค ๋ฑ NFT ๋ง์ผํ๋ ์ด์ค์ ํ์ํ ๋ฐฑ์๋ ๊ธฐ๋ฅ์ ๊ฑฐ์ ๋ค ๊ฐ์ถ๊ณ ์์ด. ์ฌ๊ธฐ์ ์ด๋๋ฆฌ์ ์ค๋งํธ ์ปจํธ๋ํธ๋ฅผ ๋ถ์ด๋ฉด? ์ง์ง ํ๋ก๋์ ๋ ๋ฒจ์ NFT ๋ง์ผํ๋ ์ด์ค๊ฐ ํ์ํ๋ ๊ฑฐ์ผ.
์ด ๊ธ์์๋ ์ค๊ณ โ ์ค๋งํธ ์ปจํธ๋ํธ โ Laravel ๋ฐฑ์๋ โ ํ๋ก ํธ์๋ ์ฐ๋ โ ๋ฐฐํฌ๊น์ง ์ ๊ณผ์ ์ ์น๊ตฌํํ ์ค๋ช ํ๋ฏ ์ญ ํ์ด๋ณผ๊ฒ. ์ปคํผ ํ ์ ๋ค๊ณ ํธํ๊ฒ ์ฝ์ด๋ด! โ
๐งฐ 1. ๊ธฐ์ ์คํ ์ ์ โ ์ Laravel์ธ๊ฐ?
๋จผ์ "์ ๊ตณ์ด Laravel์ด์ผ?" ๋ผ๋ ์ง๋ฌธ์ ๋ตํด์ผ๊ฒ ์ง. ์ฌ์ค NFT ๋ง์ผํ๋ ์ด์ค ๋ฐฑ์๋๋ก 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
๐๏ธ 2. ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ค๊ณ โ NFT ๋ง์ผ์ ๋ผ๋
NFT ๋ง์ผํ๋ ์ด์ค์ DB ์ค๊ณ๋ ์ผ๋ฐ ์ด์ปค๋จธ์ค๋ ๋น์ทํ๋ฉด์๋ ๋ธ๋ก์ฒด์ธ ํน์ฑ์ ๋ฐ์ํด์ผ ํด. ํต์ฌ์ ์จ์ฒด์ธ ๋ฐ์ดํฐ์ ์คํ์ฒด์ธ ๋ฐ์ดํฐ๋ฅผ ์ด๋ป๊ฒ ๋ถ๋ฆฌํ๊ณ ๋๊ธฐํํ ๊ฒ์ธ๊ฐ์ผ.
๐ ํต์ฌ ํ ์ด๋ธ ๊ตฌ์กฐ
id, wallet_address(unique), username, email, avatar_url, bio, is_verified, created_at
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
id, nft_id, seller_address, price_wei, currency(ETH/ERC20), listing_type(fixed/auction), start_time, end_time, is_active, tx_hash, created_at
id, nft_id, listing_id, from_address, to_address, price_wei, tx_hash, block_number, status(pending/confirmed/failed), created_at
id, contract_address, chain_id, name, symbol, description, banner_url, creator_address, floor_price, volume_traded, created_at
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 ๋ง์ผํ๋ ์ด์ค๋ ํฌ๊ฒ ๋ ๊ฐ์ง ์ปจํธ๋ํธ๊ฐ ํ์ํด.
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);
}
}
๐จ 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);
๐ด 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์ด
}
}
}
๐ 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 }
}
โก 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,
],
],
],
๐ 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);
});
});
๐ฏ 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
๐ ๋ค์ ๋จ๊ณ๋ก ๋์๊ฐ๊ธฐ
๊ด๋ จ ํค์๋
๋๊ธ 0
์ง์์ธ์ ์ฒ - ์ง์ ์ฌ์ฐ๊ถ ๋ณดํธ ๊ณ ์ง
์ง์ ์ฌ์ฐ๊ถ ๋ณดํธ ๊ณ ์ง
- ์ ์๊ถ ๋ฐ ์์ ๊ถ: ๋ณธ ์ปจํ ์ธ ๋ ์ฌ๋ฅ๋ท์ ๋ ์ AI ๊ธฐ์ ๋ก ์์ฑ๋์์ผ๋ฉฐ, ๋ํ๋ฏผ๊ตญ ์ ์๊ถ๋ฒ ๋ฐ ๊ตญ์ ์ ์๊ถ ํ์ฝ์ ์ํด ๋ณดํธ๋ฉ๋๋ค.
- AI ์์ฑ ์ปจํ ์ธ ์ ๋ฒ์ ์ง์: ๋ณธ AI ์์ฑ ์ปจํ ์ธ ๋ ์ฌ๋ฅ๋ท์ ์ง์ ์ฐฝ์๋ฌผ๋ก ์ธ์ ๋๋ฉฐ, ๊ด๋ จ ๋ฒ๊ท์ ๋ฐ๋ผ ์ ์๊ถ ๋ณดํธ๋ฅผ ๋ฐ์ต๋๋ค.
- ์ฌ์ฉ ์ ํ: ์ฌ๋ฅ๋ท์ ๋ช ์์ ์๋ฉด ๋์ ์์ด ๋ณธ ์ปจํ ์ธ ๋ฅผ ๋ณต์ , ์์ , ๋ฐฐํฌ, ๋๋ ์์ ์ ์ผ๋ก ํ์ฉํ๋ ํ์๋ ์๊ฒฉํ ๊ธ์ง๋ฉ๋๋ค.
- ๋ฐ์ดํฐ ์์ง ๊ธ์ง: ๋ณธ ์ปจํ ์ธ ์ ๋ํ ๋ฌด๋จ ์คํฌ๋ํ, ํฌ๋กค๋ง, ๋ฐ ์๋ํ๋ ๋ฐ์ดํฐ ์์ง์ ๋ฒ์ ์ ์ฌ์ ๋์์ด ๋ฉ๋๋ค.
- AI ํ์ต ์ ํ: ์ฌ๋ฅ๋ท์ AI ์์ฑ ์ปจํ ์ธ ๋ฅผ ํ AI ๋ชจ๋ธ ํ์ต์ ๋ฌด๋จ ์ฌ์ฉํ๋ ํ์๋ ๊ธ์ง๋๋ฉฐ, ์ด๋ ์ง์ ์ฌ์ฐ๊ถ ์นจํด๋ก ๊ฐ์ฃผ๋ฉ๋๋ค.

๋๊ธ ์์ฑ
์ด ๊ธ์ ๋ํ ์ฌ๋ฌ๋ถ์ ์๊ฐ์ ๋ค๋ ค์ฃผ์ธ์
๋ก๊ทธ์ธ์ด ํ์ํฉ๋๋ค
๋๊ธ์ ์์ฑํ๋ ค๋ฉด ๋จผ์ ๋ก๊ทธ์ธํด์ฃผ์ธ์.