블록체인

Solidity로 간단한 NFT와 마켓 만들기

이영훈닷컴 2025. 3. 10. 16:21
728x90

안녕하세요! 오늘은 Solidity라는 스마트 컨트랙트 언어를 사용해서 간단한 NFT(Non-Fungible Token, 대체 불가능 토큰)와 이를 사고팔 수 있는 마켓을 만드는 방법을 배워봤습니다. 초보자인 저도 이해할 수 있게 최대한 쉽게 풀어볼게요!

pragma solidity >=0.4.24 <=0.5.6; // 사용할 Solidity 버전 범위 지정

contract NFTsimple {

    string public name = "NFT Name"; // NFT 컬렉션의 이름
    string public symbol = "NFT"; // NFT의 단위(심볼)

    // 토큰 ID와 소유자 주소를 매핑
    mapping (uint256 => address) public tokenOwner; // 토큰 ID -> 소유자 주소: 누가 어떤 토큰을 소유하는지 추적
    mapping (uint256 => string) public tokenURIs; // 토큰 ID -> URI: 토큰의 메타데이터(컨텐츠) 저장

    // 특정 주소가 소유한 토큰 ID 리스트를 저장
    mapping (address => uint256[]) private _ownedToken; // 주소 -> 토큰 ID 배열: 누가 어떤 토큰을 얼마나 가지고 있는지

    // KIP-17 표준에서 토큰 수신 시 호출되는 함수의 시그니처
    bytes4 private constant _KIP17_RECEICVED = 0x6745782b; // KIP-17 인터페이스에서 수신 확인용 고정값

    // NFT 발행 및 전송 함수
    // mint(tokenId, uri, owner): 새로운 NFT를 발행
    // transferFrom(from, to, tokenId): NFT 소유권을 이전

    // 새로운 NFT를 발행하는 함수
    function mintWithTokenURI(address to, uint256 tokenId, string memory tokenURI) public returns (bool) {
        // 'to' 주소에 'tokenId'를 가진 NFT를 발행하고, 'tokenURI'를 메타데이터로 설정
        tokenOwner[tokenId] = to; // 토큰 소유자를 'to'로 설정
        tokenURIs[tokenId] = tokenURI; // 토큰의 URI(컨텐츠 정보)를 저장

        // 소유자의 토큰 리스트에 해당 토큰 ID 추가
        _ownedToken[to].push(tokenId);

        return true; // 성공적으로 발행되었음을 반환
    }

    // NFT를 안전하게 전송하는 함수
    function safeTransferFrom(address from, address to, uint256 tokenId, bytes memory _data) public {
        require(from == msg.sender, "보내는 사람과 요청자가 동일인물이 아닙니다."); // 호출자가 전송하려는 소유자인지 확인
        require(from == tokenOwner[tokenId], "보내는 사람이 토큰 소유주가 아닙니다."); // 'from'이 해당 토큰의 소유자인지 확인

        _removeTokenFromList(from, tokenId); // 'from'의 토큰 리스트에서 해당 토큰 제거
        _ownedToken[to].push(tokenId); // 'to'의 토큰 리스트에 해당 토큰 추가

        tokenOwner[tokenId] = to; // 토큰 소유권을 'to'로 변경

        // 수신자가 컨트랙트라면 KIP-17 수신 가능 여부 확인
        require(
            _checkOnKIP17Received(from, to, tokenId, _data), 
            "KIP17: transfer to non KIP17Receiver implementer"
        );
    }

    // 수신자가 KIP-17 표준을 준수하는지 확인하는 내부 함수
    function _checkOnKIP17Received(address from, address to, uint256 tokenId, bytes memory _data) internal returns (bool) {
        bool success; // 호출 성공 여부
        bytes memory returndata; // 호출 후 반환 데이터

        if (!isContract(to)) { // 'to'가 컨트랙트가 아니라면 바로 성공 처리
            return true;
        }

        // 'to' 주소에서 'onKIP17Received' 함수 호출
        (success, returndata) = to.call(
            abi.encodeWithSelector(
                _KIP17_RECEICVED, // 'onKIP17Received' 함수의 시그니처
                msg.sender, // 호출자
                from, // 보내는 사람
                tokenId, // 토큰 ID
                _data // 추가 데이터
            )
        );

        // 호출이 성공하고 반환값이 예상한 값과 일치하면 성공
        if (
            returndata.length != 0 && // 반환 데이터가 존재하고
            abi.decode(returndata, (bytes4)) == _KIP17_RECEICVED // 반환값이 KIP-17 시그니처와 일치
        ) {
            return true;
        }
        return false; // 실패 시
    }

    // 주어진 주소가 컨트랙트인지 확인하는 함수
    function isContract(address account) internal view returns (bool) {
        uint256 size;
        assembly { size := extcodesize(account) } // 어셈블리 코드로 계정의 코드 크기 확인
        return size > 0; // 크기가 0보다 크면 컨트랙트로 간주
    }

    // 소유자 리스트에서 특정 토큰을 제거하는 내부 함수
    function _removeTokenFromList(address from, uint256 tokenId) private {
        // 배열에서 토큰을 찾아 제거하는 로직 (마지막 요소와 교체 후 길이 줄임)
        uint256 lastTokenIndex = _ownedToken[from].length - 1; // 마지막 인덱스
        for (uint256 i = 0; i < _ownedToken[from].length; i++) {
            if (tokenId == _ownedToken[from][i]) { // 제거할 토큰을 찾으면
                _ownedToken[from][i] = _ownedToken[from][lastTokenIndex]; // 마지막 요소로 교체
            }
        }
        _ownedToken[from].length--; // 배열 길이 감소
    }

    // 특정 주소가 소유한 토큰 리스트를 반환
    function ownedTokens(address owner) public view returns (uint256[] memory) {
        return _ownedToken[owner]; // 해당 주소의 토큰 ID 배열 반환
    }

    // 토큰의 URI를 설정(수정)하는 함수
    function setTokenUri(uint256 id, string memory uri) public {
        tokenURIs[id] = uri; // 지정된 토큰 ID의 URI를 업데이트
    }

    // 특정 주소가 소유한 토큰 개수를 반환하는 함수 (주석 처리됨)
    // function balanceOf(address owner) public view returns (uint256) {
    //     require(
    //         owner != address(0),
    //         "KIP17: balance query for the zero address"
    //     );
    //     return _ownedToken[owner].length; // 소유한 토큰 개수 반환
    // }
}

// NFT 발행 및 조회는 NFTsimple에서 처리
// 판매: NFT를 마켓으로 전송
// 구매: 마켓에서 buy 실행, 0.01 KLAY를 판매자에게 전송

contract NFTmarket {
    mapping(uint256 => address) public seller; // 토큰 ID -> 판매자 주소: 누가 이 토큰을 판매에 올렸는지

    // NFT를 구매하는 함수
    function buyNFT(uint256 tokenId, address NFTaddress) public payable returns (bool) {
        // 구매자가 0.01 KLAY를 지불하고 NFT를 받음
        address payable receiver = address(uint160(seller[tokenId])); // 판매자 주소를 payable로 변환

        // 판매자에게 0.01 KLAY 전송
        // 10 ** 18 PEB = 1 KLAY, 10 ** 16 PEB = 0.01 KLAY
        receiver.transfer(10 ** 16); // 0.01 KLAY를 판매자에게 전송

        // NFT를 구매자에게 전송
        NFTsimple(NFTaddress).safeTransferFrom(address(this), msg.sender, tokenId, "0x00");

        return true; // 성공적으로 구매 완료
    }

    // 마켓이 NFT를 수신했을 때 호출되는 함수 (KIP-17 표준)
    function onKIP17Received(address operator, address from, uint256 tokenId, bytes memory data) public returns (bytes4) {
        seller[tokenId] = from; // 판매자를 기록 (NFT를 보낸 사람)

        // KIP-17 표준에 따라 함수 시그니처 반환
        return bytes4(keccak256("onKIP17Received(address,address,uint256,bytes)"));
    }
}

주요 주석 요약

  1. NFTsimple 컨트랙트:

    • NFT 발행(mintWithTokenURI), 전송(safeTransferFrom), 소유자 조회(ownedTokens) 등 기본적인 NFT 관리 기능 제공.
    • KIP-17 표준을 준수하며, 수신자가 컨트랙트일 경우 안전한 전송을 보장(_checkOnKIP17Received).
    • 내부적으로 토큰 리스트를 관리하며, 효율적인 제거 로직(_removeTokenFromList) 포함.
  2. NFTmarket 컨트랙트:

    • NFT 판매 및 구매를 중개.
    • buyNFT: 구매자가 0.01 KLAY를 지불하고 NFT를 받음.
    • onKIP17Received: NFT가 마켓으로 전송될 때 판매자를 기록.

아래는 주어진 Solidity 코드를 초보자가 이해하기 쉽게 TIL(Today I Learned) 블로그 형태로 작성한 내용입니다. NFT와 스마트 컨트랙트의 기본 개념을 설명하면서 코드를 자연스럽게 풀어냈습니다.


1. NFT가 뭐예요?

NFT는 블록체인 위에서 고유한 디지털 자산을 나타내는 토큰이에요. 예를 들어, 디지털 그림이나 게임 아이템처럼 "이건 나만의 것!"이라고 증명할 수 있는 거죠. 오늘 제가 만든 NFT는 "KlayLion"이라는 이름의 컬렉션이고, 심볼은 "KL"이에요.

2. NFT 컨트랙트: NFTsimple

먼저 NFT를 발행하고 관리할 수 있는 스마트 컨트랙트를 만들어봤어요. 이름은 NFTsimple이고, 여기서 할 수 있는 주요 기능들을 하나씩 살펴볼게요.

2.1. 기본 설정

string public name = "NFT Name"; // NFT 컬렉션 이름
string public symbol = "NFT"; // NFT 단위(심볼)
mapping (uint256 => address) public tokenOwner; // 토큰 ID -> 소유자 주소
mapping (uint256 => string) public tokenURIs; // 토큰 ID -> 메타데이터 URI
mapping (address => uint256[]) private _ownedToken; // 주소 -> 소유한 토큰 ID 목록
  • 이름과 심볼: 이 NFT는 "KlayLion"이라는 이름과 "KL"이라는 단위를 가졌어요.
  • 매핑(mapping): 블록체인에서 데이터를 저장하는 방법인데, 여기서는 토큰 ID와 소유자 주소, 메타데이터(예: 이미지 링크), 그리고 누가 어떤 토큰을 가지고 있는지를 저장해요.

2.2. NFT 발행하기 (mintWithTokenURI)

function mintWithTokenURI(address to, uint256 tokenId, string memory tokenURI) public returns (bool) {
    tokenOwner[tokenId] = to; // 이 토큰은 'to'라는 사람 거야!
    tokenURIs[tokenId] = tokenURI; // 토큰에 메타데이터(예: 이미지 링크) 연결
    _ownedToken[to].push(tokenId); // 'to'가 가진 토큰 목록에 추가
    return true; // 성공!
}
  • 발행(minting): 새로운 NFT를 만드는 거예요. tokenId는 토큰의 고유 번호, tokenURI는 이 토큰이 나타내는 디지털 콘텐츠(예: 이미지 URL)예요. 이걸 특정 사람(to)에게 줍니다.

2.3. NFT 보내기 (safeTransferFrom)

function safeTransferFrom(address from, address to, uint256 tokenId, bytes memory _data) public {
    require(from == msg.sender, "너가 보내려는 사람이 맞아?");
    require(from == tokenOwner[tokenId], "이 토큰 너 거 맞아?");
    _removeTokenFromList(from, tokenId); // 'from'의 토큰 목록에서 제거
    _ownedToken[to].push(tokenId); // 'to'의 토큰 목록에 추가
    tokenOwner[tokenId] = to; // 소유자 변경
    require(_checkOnKIP17Received(from, to, tokenId, _data), "받는 쪽이 문제 있어!");
}
  • 전송: NFT를 다른 사람에게 보내는 기능이에요. 조건을 확인해서("너 이 토큰 진짜 주인 맞아?") 소유권을 안전하게 옮기고, 받는 사람이 컨트랙트면 제대로 받을 수 있는지 확인해요.

2.4. 내가 가진 NFT 확인하기 (ownedTokens)

function ownedTokens(address owner) public view returns (uint256[] memory) {
    return _ownedToken[owner]; // 이 사람이 가진 토큰 ID 목록 반환
}
  • 조회: 특정 주소가 가진 모든 NFT 목록을 볼 수 있어요. 예를 들어, "나 지금 어떤 KlayLion 가지고 있지?"를 알 수 있죠.

2.5. 작은 팁: 안전한 전송이란?

KIP-17이라는 표준을 따라가는데, 이건 NFT를 보낼 때 받는 사람이 "나 이거 받았어요!"라고 제대로 알려주는 걸 확인하는 거예요. 특히 받는 쪽이 스마트 컨트랙트일 때 중요해요. 이걸 확인하는 코드가 _checkOnKIP17Received 함수예요.

3. NFT 마켓 컨트랙트: NFTmarket

이제 NFT를 사고팔 수 있는 마켓을 만들어봤어요. 이름은 NFTmarket이고, 여기서 NFT를 올려놓고 구매할 수 있어요.

3.1. 기본 설정

mapping(uint256 => address) public seller; // 토큰 ID -> 판매자 주소
  • 누가 어떤 토큰을 판매에 올렸는지 저장해요.

3.2. NFT 사기 (buyNFT)

function buyNFT(uint256 tokenId, address NFTaddress) public payable returns (bool) {
    address payable receiver = address(uint160(seller[tokenId])); // 판매자 주소
    receiver.transfer(10 ** 16); // 0.01 KLAY 보내기
    NFTsimple(NFTaddress).safeTransferFrom(address(this), msg.sender, tokenId, "0x00"); // NFT 구매자에게 전송
    return true; // 성공!
}
  • 구매: 구매자가 0.01 KLAY(블록체인 돈)를 지불하면, 판매자에게 돈을 보내고 NFT를 구매자에게 넘겨줘요. 여기서 10 ** 16은 0.01 KLAY를 의미해요(1 KLAY = 10^18 PEB).

3.3. NFT 판매에 올리기 (onKIP17Received)

function onKIP17Received(address operator, address from, uint256 tokenId, bytes memory data) public returns (bytes4) {
    seller[tokenId] = from; // 판매자를 기록
    return bytes4(keccak256("onKIP17Received(address,address,uint256,bytes)")); // KIP-17 규칙 맞췄어요!
}
  • 판매 등록: 누군가 NFT를 마켓으로 보내면 "이 사람이 판매자야!"라고 기록해요. 이건 KIP-17 표준에서 요구하는 함수예요.

4. 어떻게 동작해요?

  1. NFT 발행: NFTsimple에서 "KlayLion" NFT를 만들어요.
  2. 판매 올리기: 만든 NFT를 NFTmarket으로 보내면 판매 목록에 올라가요.
  3. 구매하기: 다른 사람이 0.01 KLAY를 지불하고 NFT를 사면, 돈은 판매자에게 가고 NFT는 구매자에게 가요.

5. 느낀 점

처음엔 Solidity가 어렵게 느껴졌는데, 하나씩 뜯어보니까 "아, 이런 식으로 NFT를 만들고 거래하는구나!" 하고 이해가 됐어요. 특히 매핑(mapping)이랑 함수들이 어떻게 연결되는지 보니까 재밌더라고요. 다음엔 더 복잡한 기능을 추가해보고 싶어요!

728x90