- Published on
ERC-721 토큰 (NFT)
- Authors

- Name
- Aaron
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를 보낼 때 실제로 일어나는 일
- 호출자가 NFT를 전송할 권한이 있는지 확인 (소유자 또는 승인받은 주소)
- 컨트랙트 내부의
_ownersmapping 값 변경 - 잔액 업데이트
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를 대신 전송할 수 있는 권한이 필요합니다.
거래 과정
- Alice가 OpenSea에 NFT 판매 등록
- Alice가 OpenSea 컨트랙트에 승인 (approve 또는 setApprovalForAll)
- Bob이 OpenSea에서 NFT 구매
- OpenSea 컨트랙트가 Alice의 NFT를 Bob에게 전송 (transferFrom 호출)
- 판매 대금이 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"
동작 과정
- OpenSea가 NFT #1의 정보를 보여주려고 함
- 컨트랙트의
tokenURI(1)호출 "https://api.example.com/metadata/1.json"반환- OpenSea가 해당 URL로 HTTP 요청을 보냄
- 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/";
}
}