Published on

ERC-721 토큰 (NFT)

Authors
  • avatar
    Name
    Aaron
    Twitter

ERC-721이란?

대체 불가능한 토큰 (NFT)

NFT(Non-Fungible Token) 는 무언가 또는 누군가를 고유한 방식으로 식별하는 데 사용되는 토큰입니다.

NFT가 필요한 이유

  • 수집품: 디지털 아트, 트레이딩 카드 등 각각의 가치가 다른 아이템
  • 접근 권한: 콘서트 티켓, 멤버십 카드
  • 게임 아이템: 무기, 캐릭터, 스킨 등 각각 다른 능력치를 가진 아이템
  • 번호가 매겨진 좌석: 공연장, 경기장의 특정 좌석

대체 가능 vs 대체 불가능

ERC-721 토큰은 대체 불가능(Non-Fungible) 한 디지털 자산입니다. 같은 컨트랙트에서 발행된 토큰이라도 각각 고유한 가치와 속성을 가집니다.

  • ERC-20 (대체 가능): Alice의 100 USDT = Bob의 100 USDT (완전히 동일)
  • ERC-721 (대체 불가능): Alice의 NFT #1 ≠ Bob의 NFT #2 (각각 고유함)

tokenId: NFT의 고유 식별자

모든 NFT는 uint256 타입의 tokenId를 가집니다.

// 전 세계적으로 고유한 NFT 식별
(컨트랙트 주소, tokenId) = 고유한 NFT

예를 들어

  • CryptoKitties: tokenId = 1번 고양이는 희귀한 유전자 조합
  • Bored Ape: tokenId = 1번 원숭이는 독특한 외형과 속성
  • 게임 아이템: tokenId = 1번 검은 +10 공격력, 2번 검은 +5 공격력

같은 컨트랙트에서 나온 NFT라도 tokenId에 따라 나이, 희귀도, 능력치, 비주얼이 모두 다를 수 있습니다. 이것이 NFT가 각각 다른 가격에 거래되는 이유입니다.

ERC-721 표준의 탄생

ERC-721(Ethereum Request for Comment 721)은 2018년에 제안된 NFT 표준입니다.

표준이 필요한 이유

  • 모든 NFT가 동일한 인터페이스로 작동
  • OpenSea 같은 마켓플레이스가 자동으로 모든 ERC-721 NFT 지원
  • 게임, 아트, 부동산 등 다양한 분야에서 활용 가능
  • 지갑, dApp이 컨트랙트 구조를 몰라도 NFT 처리 가능

NFT는 어디에 저장될까?

NFT의 소유권 저장

NFT는 지갑에 저장되는 것이 아니라, ERC-721 컨트랙트가 소유권을 기록하는 장부 역할을 합니다.

컨트랙트는 mapping을 사용해 누가 어떤 NFT를 소유하는지 기록합니다

// ERC-721 컨트랙트 내부
mapping(uint256 => address) private _owners;
mapping(address => uint256) private _balances;

// 예시:
// _owners[1] = 0x1234...  ← Alice가 NFT #1 소유
// _owners[2] = 0x5678...  ← Bob이 NFT #2 소유
// _balances[alice] = 2    ← Alice는 총 2개의 NFT 소유
  • tokenId → address: 각 NFT ID가 누구 소유인지 기록
  • address → uint256: 각 주소가 몇 개의 NFT를 소유하는지 카운트

메타데이터는 어디에?

NFT의 메타데이터(이미지, 속성 등)는 블록체인 에 저장됩니다.

블록체인에 이미지를 직접 저장하면 엄청난 비용이 발생합니다

  • 1MB 이미지를 이더리움에 저장: 수백만 원 이상의 가스비
  • 블록체인 용량 증가 → 노드 운영 부담 증가
  • 대신 이미지 위치만 블록체인에 저장

저장 방식 비교

항목온체인 저장오프체인 저장
소유권 정보✅ 블록체인에 저장-
tokenURI (메타데이터 URL)✅ 블록체인에 저장-
이미지 파일❌ (너무 비쌈)✅ IPFS/서버
JSON 메타데이터❌ (너무 비쌈)✅ IPFS/서버

컨트랙트는 tokenURI() 함수로 메타데이터 JSON 파일의 위치(URL)를 제공합니다. 실제 이미지와 속성 데이터는 IPFS 같은 오프체인 저장소에 저장됩니다.

ERC-721 핵심 함수들

1. 조회 함수 (View Functions)

// NFT의 소유자 조회
// 특정 토큰 ID의 소유자 주소 반환
function ownerOf(uint256 tokenId) public view returns (address)

// 특정 주소가 소유한 NFT 개수 반환
function balanceOf(address owner) public view returns (uint256)

// 토큰의 메타데이터 URI 반환
// 이미지, 이름, 설명 등이 저장된 JSON 파일 위치
function tokenURI(uint256 tokenId) public view returns (string)

// 승인된 주소 조회
// 특정 NFT를 대신 전송할 수 있는 주소 확인
function getApproved(uint256 tokenId) public view returns (address)

// operator가 owner의 모든 NFT를 관리할 권한이 있는지 확인
function isApprovedForAll(address owner, address operator) public view returns (bool)

2. 전송 함수 (Transaction Functions)

// NFT를 from에서 to로 전송
// 가장 기본적인 전송 방식
function transferFrom(address from, address to, uint256 tokenId) public

// transferFrom과 동일하지만 to가 컨트랙트인 경우 안전성 체크
// NFT가 컨트랙트에 갇히는 것을 방지
function safeTransferFrom(address from, address to, uint256 tokenId) public

// 특정 NFT를 approved 주소가 대신 전송할 수 있도록 승인
function approve(address approved, uint256 tokenId) public

// operator가 소유자의 모든 NFT를 관리할 수 있도록 승인/취소
// 마켓플레이스에서 주로 사용
function setApprovalForAll(address operator, bool approved) public

3. 이벤트 (Events)

// NFT가 전송될 때마다 발생
event Transfer(address indexed from, address indexed to, uint256 indexed tokenId)

// NFT 사용이 승인될 때 발생
event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId)

// 전체 승인이 설정/해제될 때 발생
event ApprovalForAll(address indexed owner, address indexed operator, bool approved)

ERC-721 컨트랙트 구현 예시

실제 ERC-721 NFT 컨트랙트는 다음과 같이 구현됩니다

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

contract SimpleNFT {
    // NFT 정보
    string public name = "My NFT";
    string public symbol = "MNFT";

    // 각 토큰 ID의 소유자
    mapping(uint256 => address) private _owners;

    // 각 주소가 소유한 NFT 개수
    mapping(address => uint256) private _balances;

    // 각 토큰 ID의 승인된 주소
    mapping(uint256 => address) private _tokenApprovals;

    // 소유자가 operator에게 모든 NFT 관리 권한 부여
    mapping(address => mapping(address => bool)) private _operatorApprovals;

    // 이벤트
    event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);
    event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId);
    event ApprovalForAll(address indexed owner, address indexed operator, bool approved);

    // NFT 발행
    function mint(address to, uint256 tokenId) public {
        require(to != address(0), "민팅 대상이 유효하지 않습니다");
        require(_owners[tokenId] == address(0), "이미 존재하는 토큰입니다");

        _balances[to] += 1;
        _owners[tokenId] = to;

        emit Transfer(address(0), to, tokenId);
    }

    // 소유자 조회
    function ownerOf(uint256 tokenId) public view returns (address) {
        address owner = _owners[tokenId];
        require(owner != address(0), "존재하지 않는 토큰입니다");
        return owner;
    }

    // 잔액 조회
    function balanceOf(address owner) public view returns (uint256) {
        require(owner != address(0), "유효하지 않은 주소입니다");
        return _balances[owner];
    }

    // NFT 전송
    function transferFrom(address from, address to, uint256 tokenId) public {
        require(_isApprovedOrOwner(msg.sender, tokenId), "전송 권한이 없습니다");
        require(ownerOf(tokenId) == from, "소유자가 일치하지 않습니다");
        require(to != address(0), "유효하지 않은 수신 주소입니다");

        // 승인 초기화
        _approve(address(0), tokenId);

        _balances[from] -= 1;
        _balances[to] += 1;
        _owners[tokenId] = to;

        emit Transfer(from, to, tokenId);
    }

    // 승인
    function approve(address approved, uint256 tokenId) public {
        address owner = ownerOf(tokenId);
        require(msg.sender == owner, "소유자만 승인할 수 있습니다");
        require(approved != owner, "소유자에게 승인할 수 없습니다");

        _approve(approved, tokenId);
    }

    // 전체 승인
    function setApprovalForAll(address operator, bool approved) public {
        require(operator != msg.sender, "자신에게 승인할 수 없습니다");
        _operatorApprovals[msg.sender][operator] = approved;
        emit ApprovalForAll(msg.sender, operator, approved);
    }

    // 승인 조회
    function getApproved(uint256 tokenId) public view returns (address) {
        require(_owners[tokenId] != address(0), "존재하지 않는 토큰입니다");
        return _tokenApprovals[tokenId];
    }

    // 전체 승인 조회
    function isApprovedForAll(address owner, address operator) public view returns (bool) {
        return _operatorApprovals[owner][operator];
    }

    // 내부 함수
    function _approve(address approved, uint256 tokenId) private {
        _tokenApprovals[tokenId] = approved;
        emit Approval(ownerOf(tokenId), approved, tokenId);
    }

    function _isApprovedOrOwner(address spender, uint256 tokenId) private view returns (bool) {
        address owner = ownerOf(tokenId);
        return (spender == owner ||
                getApproved(tokenId) == spender ||
                isApprovedForAll(owner, spender));
    }
}

핵심 작동 원리

1. NFT 전송 (transferFrom)

NFT를 전송하는 방법입니다. Alice가 Bob에게 NFT를 보낼 때 실제로 일어나는 일

  1. 호출자가 NFT를 전송할 권한이 있는지 확인 (소유자 또는 승인받은 주소)
  2. 컨트랙트 내부의 _owners mapping 값 변경
  3. 잔액 업데이트
  4. Transfer 이벤트 발생

중요: NFT는 계정에서 계정으로 직접 이동하지 않습니다. 컨트랙트가 소유권 기록을 업데이트하는 것입니다.

// Alice가 Bob에게 NFT #5 전송
nft.transferFrom(aliceAddress, bobAddress, 5);

// 컨트랙트 내부에서 일어나는 일:
require(_isApprovedOrOwner(msg.sender, 5), "전송 권한이 없습니다");
_balances[alice] -= 1;  // Alice: 3개 → 2개
_balances[bob] += 1;    // Bob: 1개 → 2개
_owners[5] = bob;       // NFT #5의 소유자를 Bob으로 변경
emit Transfer(alice, bob, 5);

2. NFT 승인 (approve & setApprovalForAll)

왜 승인이 필요한가?

NFT 마켓플레이스(OpenSea, Blur 등)에서 NFT를 판매하려면 마켓플레이스가 당신의 NFT를 대신 전송할 수 있는 권한이 필요합니다.

거래 과정

  1. Alice가 OpenSea에 NFT 판매 등록
  2. Alice가 OpenSea 컨트랙트에 승인 (approve 또는 setApprovalForAll)
  3. Bob이 OpenSea에서 NFT 구매
  4. OpenSea 컨트랙트가 Alice의 NFT를 Bob에게 전송 (transferFrom 호출)
  5. 판매 대금이 Alice에게 전달됨

승인 없이는 마켓플레이스가 NFT를 이동시킬 수 없기 때문에, 승인은 NFT 거래의 필수 단계입니다.

개별 승인 (approve)

특정 NFT 하나만 다른 주소가 전송할 수 있도록 승인합니다.

// Alice가 OpenSea에게 NFT #5만 전송 권한 부여
nft.approve(openSeaAddress, 5);

전체 승인 (setApprovalForAll)

소유자의 모든 NFT를 operator가 관리할 수 있도록 승인합니다.

// Alice가 OpenSea에게 자신의 모든 NFT 전송 권한 부여
nft.setApprovalForAll(openSeaAddress, true);
// Alice가 OpenSea에게 NFT #5 전송 권한 승인
nft.approve(openSeaAddress, 5);

// 컨트랙트 내부:
_tokenApprovals[5] = openSea;
emit Approval(alice, openSea, 5);

// 또는 모든 NFT에 대한 권한 승인
nft.setApprovalForAll(openSeaAddress, true);

// 컨트랙트 내부:
_operatorApprovals[alice][openSea] = true;
emit ApprovalForAll(alice, openSea, true);

setApprovalForAll()로 승인하면 operator가 모든 NFT를 가져갈 수 있습니다. 신뢰할 수 없는 컨트랙트에는 승인하지 마세요.

3. 메타데이터와 tokenURI

NFT의 시각적 정보(이미지, 이름, 속성 등)는 블록체인에 직접 저장되지 않습니다. 대신 tokenURI() 함수가 메타데이터 파일의 위치를 알려줍니다.

function tokenURI(uint256 tokenId) public view returns (string) {
    return string(abi.encodePacked(baseURI, tokenId.toString(), ".json"));
}

// 예시:
// tokenURI(1) => "https://api.example.com/metadata/1.json"
// tokenURI(2) => "https://api.example.com/metadata/2.json"

동작 과정

  1. OpenSea가 NFT #1의 정보를 보여주려고 함
  2. 컨트랙트의 tokenURI(1) 호출
  3. "https://api.example.com/metadata/1.json" 반환
  4. OpenSea가 해당 URL로 HTTP 요청을 보냄
  5. JSON 파일을 받아서 이미지와 속성을 화면에 표시

블록체인에는 URL만 저장되고, 실제 이미지와 메타데이터는 오프체인 서버에 저장됩니다.

OpenZeppelin으로 ERC-721 NFT 만들기

ERC-721 컨트랙트를 직접 작성할 때는 OpenZeppelin ERC-721의 검증된 구현체를 사용하는 것이 좋습니다.

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";

contract MyNFT is ERC721 {
    constructor() ERC721("MyNFT", "MNFT") {}

    // baseURI 반환 (tokenURI 생성에 사용됨)
    // tokenURI(1) => "https://api.example.com/metadata/1"
    // tokenURI(2) => "https://api.example.com/metadata/2"
    function _baseURI() internal view override returns (string memory) {
        return "https://api.example.com/metadata/";
    }
}