NFT는 2021년 붐 이후 web3 생태계의 핵심 요소로 자리 잡았으며, 단순한 수집품을 넘어 멤버십 접근권, 자산 토큰화, 게임 아이템, 디지털 신원 등 실생활 활용 사례로 발전해 왔습니다.
Phosphor와 같은 노코드 플랫폼은 직관적인 Creator Studio와 API 제품군을 통해 크리에이터와 개발자가 NFT를 민팅, 커스터마이징, 배포하는 것을 그 어느 때보다 쉽게 만들어, 커스텀 NFT 컨트랙트를 처음부터 직접 구축할 필요성을 없애주었습니다. 그러나 Solidity와 web3 네이티브 경험에 대한 이해를 심화하고자 하는 개발자에게는 NFT 마켓플레이스를 직접 구축하는 방법을 배우는 것이 여전히 기초적인 핵심 역량입니다.
이 가이드에서는 Linea에서 기본적이지만 완전히 기능하는 NFT 마켓플레이스를 개발하고 배포하는 과정을 단계별로 안내합니다. 가이드를 마치면 자신만의 마켓플레이스를 출시하는 데 필요한 지식을 갖추고, 스마트 컨트랙트 개발, 탈중앙화 애플리케이션, web3 인터랙션에 대한 이해를 한층 높일 수 있습니다.
사전 요구 사항
Solidity에 대한 기본적인 이해가 필요합니다.
PC에 Nodejs와 Foundry가 설치되어 있어야 합니다.
컨트랙트 개요
이 NFT 마켓플레이스는 다음 기능을 지원합니다:
NFT 판매 등록: 사용자가 가격을 설정하고 NFT를 등록할 수 있으며, 세부 정보는 스마트 컨트랙트에 안전하게 저장됩니다.
NFT 구매: 구매자가 필요한 Ether를 지불하여 등록된 NFT를 구매할 수 있으며, 원활한 거래가 보장됩니다.
NFT 일괄 구매: 가스 비용을 줄이기 위해 단일 트랜잭션으로 여러 NFT를 구매할 수 있습니다.
NFT 등록 취소: 소유자가 판매 중인 NFT를 취소하고 자신의 지갑으로 반환받을 수 있습니다.
프로젝트 설정
이 프로젝트에는 Foundry 프레임워크를 사용합니다. Foundry가 설치된 상태에서 아래 명령어를 실행하여 새 프로젝트를 시작합니다:
forge init marketVscode 또는 선호하는 코드 에디터에서 market 폴더를 열고, scripts/counter.s.sol, src/counter.sol, test/counter.t.sol 파일을 삭제합니다.
모든 의존성 설치
forge install foundry-rs/forge-std --no-commit && forge install OpenZeppelin/openzeppelin-contracts --no-commitNFT 마켓플레이스 스마트 컨트랙트 전체 코드
src 폴더에 market.sol 파일을 생성하고 아래 코드를 추가합니다.
pragma solidity ^0.8.18;
import "lib/openzeppelin-contracts/contracts/token/ERC721/IERC721.sol";
import {Test, console} from "forge-std/Test.sol";
contract NFTMarketplace {
struct Listing {
address seller;
uint256 price;
}
mapping(address => mapping(uint256 => Listing)) public listings;
event Listed(address indexed nftContract, uint256 indexed tokenId, address seller, uint256 price);
event Purchase(address indexed nftContract, uint256 indexed tokenId, address buyer, uint256 price);
event Delisted(address indexed nftContract, uint256 indexed tokenId, address seller);
modifier isSeller(address nftContract, uint256 tokenId, address spender) {
require(listings[address(nftContract)][tokenId].seller == spender, "Not the seller");
_;
}
modifier isListed(address nftContract, uint256 tokenId) {
require(listings[nftContract][tokenId].price > 0, "Not listed");
_;
}
modifier notListed(address nftContract, uint256 tokenId) {
require(listings[nftContract][tokenId].price == 0, "Already listed");
_;
}
function listNFT(address nftContract, uint256 tokenId, uint256 price)
external
notListed(nftContract, tokenId)
{
require(price > 0, "Price must be greater than zero");
require(IERC721(nftContract).ownerOf(tokenId) == msg.sender, "not owner");
IERC721(nftContract).transferFrom(msg.sender, address(this), tokenId);
listings[nftContract][tokenId] = Listing(msg.sender, price);
emit Listed(nftContract, tokenId, msg.sender, price);
}
function buyNFT(address nftContract, uint256 tokenId)
public
payable
isListed(nftContract, tokenId)
{
Listing memory listing = listings[nftContract][tokenId];
require(msg.value >= listing.price, "Insufficient funds");
delete listings[nftContract][tokenId];
(bool sent, ) = listing.seller.call{value: listing.price}("");
require(sent, "Failed to send Ether");
IERC721(nftContract).transferFrom(address(this), msg.sender, tokenId);
emit Purchase(nftContract, tokenId, msg.sender, listing.price);
}
function buyMultipleNFT(address nftContract, uint256 [] memory tokenIds)
external
payable
{
uint256 totalAmount;
for (uint256 i; i < tokenIds.length; i++){
Listing memory listing = listings[nftContract][tokenIds[i]];
totalAmount += listing.price;
}
require(msg.value >= totalAmount, "Insufficient funds");
for (uint256 i; i < tokenIds.length; i++){
buyNFT(nftContract, tokenIds[i]);
}
}
function delistNFT(address nftContract, uint256 tokenId)
external
isListed(nftContract, tokenId)
isSeller(nftContract, tokenId, msg.sender)
{
Listing memory listing = listings[nftContract][tokenId];
delete listings[nftContract][tokenId];
IERC721(nftContract).transferFrom(address(this), listing.seller, tokenId);
emit Delisted(nftContract, tokenId, listing.seller);
}
}
코드의 주요 기능
Listing 구조체
이 구조체는 탈중앙화 NFT 마켓플레이스에서 등록, 구매, 등록 취소 기능을 구현하는 데 필수적입니다. 등록 항목을 명확하고 효율적으로 관리하는 방법을 제공하며, 마켓플레이스가 각 아이템의 판매자와 가격을 기록할 수 있도록 합니다. 이 두 가지는 마켓플레이스 운영에 있어 매우 중요합니다.
struct Listing {
address seller;
uint256 price;
}
Listing 매핑
이 코드 라인은 Solidity에서 중첩 매핑을 정의하며, NFT 마켓플레이스의 등록 항목을 체계적으로 구성하고 저장합니다. 외부 매핑은 NFT 컨트랙트 주소를 키로 사용하여 스마트 컨트랙트가 각 NFT 컬렉션별로 등록 항목을 분류할 수 있도록 합니다. 이를 통해 마켓플레이스가 여러 NFT 컬렉션을 지원할 수 있습니다.
내부 매핑은 토큰 ID(각 NFT의 고유 식별자)를 키로 사용하여 단일 NFT 컨트랙트 내 여러 등록 항목을 효율적으로 관리할 수 있습니다.
mapping(address => mapping(uint256 => Listing)) public listings;
NFT 등록
listNFT 함수는 사용자가 마켓플레이스에 NFT를 판매 등록할 수 있도록 합니다. NFT 컨트랙트, 토큰 ID, 가격의 세 가지 매개변수를 받습니다. NFT 컨트랙트는 사용자가 NFT를 등록하려는 컬렉션의 주소를 의미하고, 토큰 ID는 해당 컬렉션 내 NFT의 고유 식별자이며, 가격은 사용자가 NFT를 판매하고자 하는 금액입니다.
이 함수는 먼저 사용자가 NFT의 소유자인지 확인하고 가격이 0보다 큰지 검증합니다. 이 검사를 통과하면 NFT가 판매자로부터 마켓플레이스로 전송되고, listings 매핑이 업데이트되며, 올바른 매개변수와 함께 Listed 이벤트가 발생합니다.
function listNFT(address nftContract, uint256 tokenId, uint256 price)
external
notListed(nftContract, tokenId)
{
require(price > 0, "Price must be greater than zero");
require(IERC721(nftContract).ownerOf(tokenId) == msg.sender, "not owner");
IERC721(nftContract).transferFrom(msg.sender, address(this), tokenId);
listings[nftContract][tokenId] = Listing(msg.sender, price);
emit Listed(nftContract, tokenId, msg.sender, price);
}
NFT 구매
buyNFT 함수는 사용자가 마켓플레이스에 등록된 NFT를 구매할 수 있도록 합니다. nftContract(NFT 스마트 컨트랙트 주소)와 tokenId(구매할 NFT의 고유 식별자)의 두 가지 매개변수를 받습니다.
이 함수는 먼저 구매자가 NFT 가격을 충당할 만큼 충분한 Ether를 전송했는지 확인합니다. 검증이 완료되면 listings 매핑에서 해당 등록 항목이 제거되고, 결제 금액이 판매자에게 안전하게 전송되며, NFT가 마켓플레이스 컨트랙트에서 구매자의 주소로 전송되어 구매가 완료됩니다.
function buyNFT(address nftContract, uint256 tokenId)
public
payable
isListed(nftContract, tokenId)
{
Listing memory listing = listings[nftContract][tokenId];
require(msg.value >= listing.price, "Insufficient funds");
delete listings[nftContract][tokenId];
(bool sent, ) = listing.seller.call{value: listing.price}("");
require(sent, "Failed to send Ether");
IERC721(nftContract).transferFrom(address(this), msg.sender, tokenId);
emit Purchase(nftContract, tokenId, msg.sender, listing.price);
}
여러 NFT 구매
buyMultipleNFT 함수는 사용자가 단일 트랜잭션으로 여러 NFT를 구매할 수 있도록 합니다. 먼저 tokenIds 배열에 나열된 모든 NFT의 가격을 합산하여 총 금액을 계산합니다. 그런 다음 구매자가 총 금액을 충당할 만큼 충분한 자금을 전송했는지 확인합니다.
자금 검증이 완료되면 함수는 tokenIds 배열을 순회하며 각 NFT에 대해 buyNFT 함수를 호출합니다. 이를 통해 각 NFT가 개별적으로 구매되고, 결제 금액이 판매자에게 전송되며, NFT가 구매자에게 전달됩니다. 이 과정은 buyNFT 함수와 동일한 절차를 따릅니다.
function buyMultipleNFT(address nftContract, uint256 [] memory tokenIds)
external
payable
{
uint256 totalAmount;
for (uint256 i; i < tokenIds.length; i++){
Listing memory listing = listings[nftContract][tokenIds[i]];
totalAmount += listing.price;
}
require(msg.value >= totalAmount, "Insufficient funds");
for (uint256 i; i < tokenIds.length; i++){
buyNFT(nftContract, tokenIds[i]);
}
}
NFT 등록 취소
delistNFT 함수는 판매자가 마켓플레이스에서 자신의 NFT를 제거할 수 있도록 합니다. 먼저 NFT가 등록되어 있는지, 그리고 호출자가 정당한 소유자인지 확인합니다. 검증이 완료되면 마켓플레이스에서 해당 등록 항목이 삭제되어 NFT가 판매 목록에서 효과적으로 제거됩니다.
그런 다음 함수는 NFT를 판매자에게 반환하여 자산의 소유권을 되찾을 수 있도록 합니다. 마지막으로 Delisted 이벤트가 발생하여 NFT가 마켓플레이스에서 성공적으로 제거되었음을 확인합니다.
function delistNFT(address nftContract, uint256 tokenId)
external
isListed(nftContract, tokenId)
isSeller(nftContract, tokenId, msg.sender)
{
Listing memory listing = listings[nftContract][tokenId];
delete listings[nftContract][tokenId];
IERC721(nftContract).transferFrom(address(this), listing.seller, tokenId);
emit Delisted(nftContract, tokenId, listing.seller);
}
주요 이벤트
컨트랙트는 마켓플레이스의 주요 작업에 대해 이벤트를 발생시킵니다:
Listed: NFT가 등록될 때 트리거되며, 판매자, 가격, NFT 정보 등의 세부 사항을 기록합니다.
Purchase: 구매자가 NFT를 구매할 때 발생하여 트랜잭션 추적 가능성을 보장합니다.
Delisted: NFT가 마켓플레이스에서 성공적으로 제거되었음을 나타내며 투명성을 유지합니다.
event Listed(address indexed nftContract, uint256 indexed tokenId, address seller, uint256 price);
event Purchase(address indexed nftContract, uint256 indexed tokenId, address buyer, uint256 price);
event Delisted(address indexed nftContract, uint256 indexed tokenId, address seller
테스트 스위트 개요
src 폴더에 NFT.sol 파일을 생성하고 아래 코드를 추가합니다.
pragma solidity ^0.8.18;
import "lib/openzeppelin-contracts/contracts/token/ERC721/ERC721.sol";
import "lib/openzeppelin-contracts/contracts/access/Ownable.sol";
contract NFT is ERC721, Ownable {
uint256 private _tokenIdCounter;
string private _baseTokenURI;
event Minted(address indexed to, uint256 tokenId);
constructor(string memory name, string memory symbol, string memory baseTokenURI) ERC721(name, symbol) Ownable(msg.sender) {
_baseTokenURI = baseTokenURI;
}
function mint(address to) external onlyOwner {
uint256 tokenId = _tokenIdCounter;
_tokenIdCounter += 1;
_safeMint(to, tokenId);
emit Minted(to, tokenId);
}
function _baseURI() internal view override returns (string memory) {
return _baseTokenURI;
}
function setBaseTokenURI(string memory newBaseTokenURI) external onlyOwner {
_baseTokenURI = newBaseTokenURI;
}
function getTokenCount() external view returns (uint256) {
return _tokenIdCounter;
}
}
test 폴더에 market.t.sol 파일을 생성하고 아래 코드를 추가합니다.
pragma solidity ^0.8.13;
import {Test, console} from "forge-std/Test.sol";
import {NFTMarketplace} from "../src/Market.sol";
import {NFT} from "../src/NFT.sol";
contract MarketTest is Test {
NFTMarketplace public market;
NFT public nft;
address public user1 = makeAddr("user1");
address public user2 = makeAddr("user2");
address public user3 = makeAddr("user3");
function setUp() public {
nft = new NFT("TestNFT", "tnft", "");
market = new NFTMarketplace();
nft.mint(user1);
nft.mint(user1);
nft.mint(user1);
}
function test_Listing() public {
vm.startPrank(user1);
nft.approve(address(market), 0);
market.listNFT(address(nft), 0, 1 ether);
}
function test_BuyingNFT() public {
vm.startPrank(user1);
nft.approve(address(market), 0);
market.listNFT(address(nft), 0, 1 ether);
vm.startPrank(user2);
deal(user2, 10 ether);
market.buyNFT{value: 1 ether}(address(nft), 0);
assert(nft.ownerOf(0) == user2);
}
function test_BuyingMultipleNFT() public {
vm.startPrank(user1);
nft.approve(address(market), 0);
nft.approve(address(market), 1);
nft.approve(address(market), 2);
market.listNFT(address(nft), 0, 1 ether);
market.listNFT(address(nft), 1, 1 ether);
market.listNFT(address(nft), 2, 1 ether);
vm.startPrank(user2);
deal(user2, 10 ether);
uint256 [] memory nfts = new uint256[] (3);
nfts[0] = 0;
nfts[1] = 1;
nfts[2] = 2;
market.buyMultipleNFT{value: 3 ether}(address(nft), nfts);
}
function test_DelistingNFT() public {
vm.startPrank(user1);
nft.approve(address(market), 0);
market.listNFT(address(nft), 0, 1 ether);
assert(nft.ownerOf(0) == address(market));
market.delistNFT(address(nft), 0);
assert(nft.ownerOf(0) == address(user1));
}
}
위의 테스트 스위트는 NFT 마켓플레이스 컨트랙트가 핵심 기능 전반에 걸쳐 의도한 대로 작동하는지 확인합니다. 각 테스트에 대한 설명은 다음과 같습니다:
테스트 환경 설정
setUp 함수는 NFTMarketplace와 NFT 컨트랙트를 모두 배포하여 환경을 초기화합니다. 테스트 목적으로 user1에게 세 개의 NFT를 민팅합니다. 모의 NFT 컨트랙트(NFT)는 NFT를 생성하는 mint 함수와 함께 배포되고, 마켓플레이스 컨트랙트(NFTMarketplace)가 인스턴스화됩니다. User1이 민팅된 모든 NFT를 소유하여 등록 및 등록 취소 기능 테스트가 가능합니다.
NFT 등록 테스트
test_Listing 함수는 user1이 마켓플레이스에 NFT를 판매 등록할 수 있는지 확인합니다.
User1이 마켓플레이스에 tokenId 0 전송을 승인합니다.
User1이 1 Ether의 가격으로 NFT를 등록합니다.
테스트는 listNFT 함수가 올바르게 작동하고 NFT가 마켓플레이스로 전송되었는지 확인합니다.
단일 NFT 구매 테스트
test_BuyingNFT 함수는 user2가 등록된 NFT를 구매하는 과정을 시뮬레이션합니다.
User1이 tokenId 0을 1 Ether에 등록합니다.
User2가 10 Ether를 받고 buyNFT 함수를 호출하여 1 Ether를 전송해 NFT를 구매합니다.
구매 후 테스트는 user2가 tokenId 0의 새 소유자인지 확인합니다.
여러 NFT 구매 테스트
test_BuyingMultipleNFT 함수는 단일 트랜잭션으로 여러 NFT를 구매하는 기능을 테스트합니다.
User1이 세 개의 NFT(tokenId 0, 1, 2)를 각각 1 Ether에 등록합니다.
User2가 buyMultipleNFT를 호출하여 토큰 ID를 전달하고 3 Ether를 전송합니다.
테스트는 user2가 세 NFT 모두의 소유자가 되었는지, 그리고 마켓플레이스가 여러 구매를 올바르게 처리했는지 확인합니다.
NFT 등록 취소 테스트
test_DelistingNFT 함수는 user1이 마켓플레이스에서 자신의 NFT를 등록 취소할 수 있는지 확인합니다.
User1이 tokenId 0을 1 Ether에 등록하고 delistNFT를 호출합니다.
NFT가 user1에게 반환되고, 테스트는 소유권이 user1에게 되돌아갔는지 확인합니다.
테스트 실행
forge testLinea에서의 배포 및 검증
.env 파일에 필요한 변수를 추가합니다. 파일이 프로젝트의 루트 레벨에 위치해야 합니다:
LINEA_RPC_URL=https://linea-mainnet.infura.io/v3/
PRIVATE_KEY=*************************************************
foundry.toml에 아래 세부 정보를 추가합니다.
[etherscan]
linea = { key = "${LINEA_API_KEY}", url = "https://api-sepolia.lineascan.build/api" }
Makefile을 생성하고 아래 세부 정보를 추가합니다:
deploy:
forge create src/Market.sol:NFTMarketplace --rpc-url $(LINEA_RPC_URL) --private-key $(PRIVATE_KEY)
verify:; forge verify-contract --rpc-url $(LINEA_RPC_URL) --chain linea <contract address> src/Market.sol:NFTMarketplace
배포
make deploy검증
make verify마무리
Linea에서 NFT 마켓플레이스를 구축하는 것은 개발자에게 스마트 컨트랙트 개발, Solidity 프로그래밍, web3 인터랙션에 대한 실전 경험을 제공합니다. 이 가이드에서는 NFT 등록, 구매, 일괄 구매, 등록 취소를 포함한 탈중앙화 마켓플레이스의 핵심 기능을 다루면서 안전하고 효율적인 트랜잭션을 보장하는 방법을 살펴보았습니다.
또한 Foundry를 사용한 스마트 컨트랙트 구현 및 테스트, 그리고 Linea에서의 배포 및 검증 과정도 함께 안내했습니다. 이 가이드를 따라 진행하셨다면 이제 자신만의 NFT 마켓플레이스를 구축, 테스트, 배포하는 데 필요한 기초 지식을 갖추게 되었습니다.
더 많은 실습 튜토리얼과 블록체인 개발에 대한 심층적인 내용은 MetaMask Developer YouTube 채널을 확인하시고, Twitter를 팔로우하여 최신 인사이트를 받아보세요.