ERC 표준은 단순한 인터페이스 규약이 아니라,
컨트랙트 간 자산 이동 방식을 설계하는 핵심 패턴
이 글은 코드 레벨에서 approve/transferFrom의 내부 동작, ERC-721과 ERC-1155의 구현 차이,
NFT 메타데이터가 체인 밖에서 어떻게 관리되는지를 다룸
ERC-20 approve / transferFrom — 내부 구현까지
transfer vs transferFrom — 컨트랙트 관점의 차이
transfer는 msg.sender가 직접 토큰을 보내는 구조
외부 컨트랙트(DEX, 마켓플레이스)가 사용자 대신 토큰을 가져가려면 transfer로는 불가능
컨트랙트는 msg.sender가 될 수 없기 때문
// transfer — msg.sender가 직접 보냄
function transfer(address to, uint256 amount) public returns (bool) {
_transfer(msg.sender, to, amount);
return true;
}
// transferFrom — from의 allowance를 소진하면서 대리 전송
function transferFrom(address from, address to, uint256 amount) public returns (bool) {
// 1. spender(msg.sender)의 잔여 allowance 확인
uint256 currentAllowance = allowance(from, msg.sender);
require(currentAllowance >= amount, "ERC20: insufficient allowance");
// 2. allowance 차감
unchecked {
_approve(from, msg.sender, currentAllowance - amount);
}
// 3. 실제 잔액 이동
_transfer(from, to, amount);
return true;
}
_approve는 _allowances[owner][spender] = amount를 기록하는 내부 함수
_transfer는 _balances mapping을 직접 수정
두 함수 모두 storage write이므로 가스 비용 발생
allowance의 내부 저장 구조
// OpenZeppelin ERC20 내부 — allowance는 중첩 mapping으로 관리
mapping(address owner => mapping(address spender => uint256)) private _allowances;
// approve 호출 시
function approve(address spender, uint256 amount) public returns (bool) {
_approve(msg.sender, spender, amount);
return true;
}
function _approve(address owner, address spender, uint256 amount) internal {
require(owner != address(0), "ERC20: approve from the zero address");
require(spender != address(0), "ERC20: approve to the zero address");
_allowances[owner][spender] = amount;
emit Approval(owner, spender, amount);
}
// allowance 조회
function allowance(address owner, address spender) public view returns (uint256) {
return _allowances[owner][spender];
}
approve 경쟁 조건(Race Condition) 공격
ERC-20 스펙 원문에 경고로 명시된 내용
allowance를 100에서 50으로 바꾸려 할 때, spender가 트랜잭션 사이에 먼저 100을 소진하면
최종적으로 100 + 50 = 150을 가져가는 공격이 가능
// 취약한 패턴 — 100 → 50으로 직접 변경
token.approve(spender, 50); // 트랜잭션 사이 spender가 기존 100을 먼저 소진 가능
// 안전한 패턴 1 — 0으로 먼저 초기화
token.approve(spender, 0);
token.approve(spender, 50);
// 안전한 패턴 2 — OpenZeppelin의 increaseAllowance / decreaseAllowance 사용
// (내부적으로 현재 값에 더하거나 빼는 방식으로 원자적 처리)
token.increaseAllowance(spender, 50);
token.decreaseAllowance(spender, 30);
다만 OpenZeppelin v5부터 increaseAllowance / decreaseAllowance가 제거되고
EIP-2612의 permit 방식(서명 기반 approve)이 권장 대안으로 제시됨
EIP-2612 permit — 가스 없는 approve
permit은 오프체인 서명으로 allowance를 설정하는 방식
사용자가 approve 트랜잭션을 별도로 보낼 필요 없이,
서명 데이터를 첨부해 transferFrom과 한 번에 처리 가능
// EIP-2612 permit 인터페이스
function permit(
address owner,
address spender,
uint256 value,
uint256 deadline, // 서명 만료 시각
uint8 v, bytes32 r, bytes32 s // EIP-712 서명
) external;
// 사용 예: permit + transferFrom을 한 트랜잭션에 묶음
// approve 트랜잭션 불필요 — UX 개선 + 가스 절감
router.permitAndSwap(token, amount, deadline, v, r, s);
ERC-721 vs ERC-1155 — 구현 레벨 차이
ERC-721 — tokenId별 소유권 추적
ERC-721의 핵심은 각 tokenId를 독립적으로 추적하는 두 개의 mapping
// OpenZeppelin ERC721 내부 구조
mapping(uint256 tokenId => address) private _owners; // tokenId → 소유자
mapping(address owner => uint256) private _balances; // 주소 → 보유 수량
// ownerOf — 특정 토큰의 소유자 반환
function ownerOf(uint256 tokenId) public view returns (address) {
address owner = _owners[tokenId];
require(owner != address(0), "ERC721: invalid token ID");
return owner;
}
// _transfer 내부 — 소유권 mapping 교체
function _transfer(address from, address to, uint256 tokenId) internal {
require(ownerOf(tokenId) == from, "ERC721: transfer from incorrect owner");
delete _tokenApprovals[tokenId]; // 기존 approve 초기화
_balances[from] -= 1;
_balances[to] += 1;
_owners[tokenId] = to;
emit Transfer(from, to, tokenId);
}
ERC-721 safeTransferFrom — 수신 컨트랙트 검증
transferFrom은 수신 주소가 컨트랙트여도 무조건 전송
수신 컨트랙트가 ERC-721을 처리하는 로직이 없으면 NFT가 영구적으로 잠김safeTransferFrom은 수신자가 컨트랙트일 경우 onERC721Received를 호출해서 응답을 검증
// safeTransferFrom 내부 검증 로직
function _checkOnERC721Received(
address from, address to, uint256 tokenId, bytes memory data
) private {
if (to.code.length > 0) { // 수신자가 컨트랙트인지 확인
try IERC721Receiver(to).onERC721Received(msg.sender, from, tokenId, data)
returns (bytes4 retval) {
// 올바른 selector를 반환해야 전송 성공
require(retval == IERC721Receiver.onERC721Received.selector,
"ERC721: transfer to non ERC721Receiver implementer");
} catch {
revert("ERC721: transfer to non ERC721Receiver implementer");
}
}
}
// 수신 컨트랙트가 반드시 구현해야 하는 함수
function onERC721Received(
address operator, address from, uint256 tokenId, bytes calldata data
) external returns (bytes4) {
// NFT 수신 처리 로직
return IERC721Receiver.onERC721Received.selector; // 이 값을 반환해야 전송 완료
}
ERC-1155 — id별 잔액 관리와 배치 처리
ERC-721이 tokenId당 소유자 1명을 추적하는 것과 달리,
ERC-1155는 (주소, id) 쌍별 잔액을 관리하는 구조
// ERC-1155 내부 구조 — (owner, id) 쌍으로 잔액 관리
mapping(address account => mapping(uint256 id => uint256)) private _balances;
// 단건 잔액 조회
function balanceOf(address account, uint256 id) public view returns (uint256) {
return _balances[account][id];
}
// 배치 잔액 조회 — 한 번의 호출로 여러 (account, id) 쌍 조회
function balanceOfBatch(
address[] calldata accounts,
uint256[] calldata ids
) public view returns (uint256[] memory) {
require(accounts.length == ids.length, "ERC1155: accounts and ids length mismatch");
uint256[] memory batchBalances = new uint256[](accounts.length);
for (uint256 i = 0; i < accounts.length; ++i) {
batchBalances[i] = balanceOf(accounts[i], ids[i]);
}
return batchBalances;
}
// 배치 전송 — 여러 토큰 유형을 한 트랜잭션으로 처리
function safeBatchTransferFrom(
address from, address to,
uint256[] calldata ids,
uint256[] calldata amounts,
bytes calldata data
) public {
require(from == msg.sender || isApprovedForAll(from, msg.sender),
"ERC1155: caller is not token owner or approved");
for (uint256 i = 0; i < ids.length; ++i) {
uint256 id = ids[i];
uint256 amount = amounts[i];
uint256 fromBalance = _balances[from][id];
require(fromBalance >= amount, "ERC1155: insufficient balance for transfer");
unchecked { _balances[from][id] = fromBalance - amount; }
_balances[to][id] += amount;
}
emit TransferBatch(msg.sender, from, to, ids, amounts);
}
ERC-721 vs ERC-1155 — 코드 구조 차이 요약
| 항목 | ERC-721 | ERC-1155 |
|---|---|---|
| 소유권 저장 | mapping(uint256 => address)tokenId → 소유자 1명 | mapping(address => mapping(uint256 => uint256))(주소, id) → 잔액 |
| approve 단위 | tokenId 단위 또는 전체 | 전체(setApprovalForAll)만 지원 |
| 배치 전송 | 미지원 | safeBatchTransferFrom 기본 제공 |
| 수신 검증 | onERC721Received selector 검증 | onERC1155Received / onERC1155BatchReceived |
| FT 표현 | 불가 (tokenId마다 고유) | supply > 1로 FT처럼 사용 가능 |
NFT 메타데이터와 IPFS — tokenURI 내부 설계
tokenURI가 체인에 저장하는 것
ERC-721은 tokenURI(uint256 tokenId)를 통해 메타데이터 위치를 반환
이미지 자체는 체인에 없고, 메타데이터 JSON의 주소만 기록
이 주소가 IPFS CID를 가리키도록 설계하는 것이 현재 표준 관행
// 기본 tokenURI 구현 — baseURI + tokenId 조합
contract MyNFT is ERC721, ERC721URIStorage {
uint256 private _nextTokenId;
function safeMint(address to, string memory uri) public onlyOwner returns (uint256) {
uint256 tokenId = _nextTokenId++;
_safeMint(to, tokenId);
_setTokenURI(tokenId, uri); // tokenId → URI 매핑 저장
return tokenId;
}
// ERC721URIStorage의 내부 — tokenId별 URI를 storage에 저장
// mapping(uint256 => string) private _tokenURIs;
function tokenURI(uint256 tokenId)
public view override(ERC721, ERC721URIStorage) returns (string memory) {
return super.tokenURI(tokenId);
}
}
메타데이터 JSON 구조 — ERC-721 Metadata 표준
tokenURI가 가리키는 JSON의 스키마는 ERC-721 Metadata Extension에서 정의
IPFS에 올라가는 파일이 이 구조를 따라야 지갑과 마켓플레이스에서 올바르게 표시됨
// IPFS에 저장되는 메타데이터 JSON
{
"name": "My NFT #42",
"description": "A unique collectible from the MyNFT collection",
"image": "ipfs://QmXyz.../image.png", // 이미지 CID
"attributes": [
{ "trait_type": "Background", "value": "Blue" },
{ "trait_type": "Rarity", "value": "Legendary" },
{ "trait_type": "Level", "display_type": "number", "value": 10 }
]
}
// tokenURI 예시
// "ipfs://QmMetaCID123.../metadata.json"
// 이 JSON 안의 image 필드가 다시 IPFS를 가리킴
// → 체인에는 CID 문자열만, 실제 파일은 IPFS 노드에
온체인 vs IPFS — 저장 방식별 비교
| 방식 | 저장 위치 | 비용 | 영속성 | 변조 가능성 |
|---|---|---|---|---|
| 온체인 (Base64 SVG) | 블록체인 storage | 매우 높음 | 영구 보장 | 불가 (immutable) |
| IPFS (CID 기반) | 분산 노드 | 낮음 | 핀닝 필요 | 불가 (CID가 해시) |
| 중앙 서버 (HTTP URL) | 단일 서버 | 가장 낮음 | 서버 의존 | 가능 (서버 측 변경) |
단순 텍스트나 SVG처럼 크기가 작은 경우 온체인에 직접 Base64로 인코딩해 저장하는 방식도 사용됨
이 경우 외부 의존성 없이 완전한 탈중앙화가 가능하지만 배포/민팅 비용이 급격히 증가
온체인 메타데이터 예시 — Base64 SVG
// 이미지까지 온체인에 담는 패턴
function tokenURI(uint256 tokenId) public view override returns (string memory) {
string memory svg = string(abi.encodePacked(
'<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200">',
'<rect width="200" height="200" fill="black"/>',
'<text x="100" y="110" text-anchor="middle" fill="white" font-size="24">#',
tokenId.toString(),
'</text></svg>'
));
string memory json = Base64.encode(bytes(string(abi.encodePacked(
'{"name":"NFT #', tokenId.toString(), '",',
'"image":"data:image/svg+xml;base64,', Base64.encode(bytes(svg)), '"}'
))));
return string(abi.encodePacked("data:application/json;base64,", json));
}
// 외부 서버/IPFS 없이 완전 온체인 — Loot, Nouns 등이 이 방식 사용
Pinata를 통한 IPFS 업로드 흐름
// Next.js API Route — Pinata에 파일 업로드 후 CID 반환
// /api/upload/route.ts
export async function POST(req: Request) {
const formData = await req.formData()
const file = formData.get('file') as File
// 1. 이미지를 Pinata에 업로드 → 이미지 CID 획득
const imageRes = await fetch('https://api.pinata.cloud/pinning/pinFileToIPFS', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.PINATA_JWT}` },
body: (() => { const fd = new FormData(); fd.append('file', file); return fd })()
})
const { IpfsHash: imageCID } = await imageRes.json()
// 2. 메타데이터 JSON을 Pinata에 업로드 → 메타데이터 CID 획득
const metadata = {
name: formData.get('name'),
description: formData.get('description'),
image: `ipfs://${imageCID}` // 이미지 CID를 메타데이터에 포함
}
const metaRes = await fetch('https://api.pinata.cloud/pinning/pinJSONToIPFS', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.PINATA_JWT}`
},
body: JSON.stringify({ pinataContent: metadata })
})
const { IpfsHash: metaCID } = await metaRes.json()
// 3. 메타데이터 CID를 tokenURI로 컨트랙트에 전달
return Response.json({ tokenURI: `ipfs://${metaCID}` })
}
정리
| 주제 | 핵심 구현 포인트 |
|---|---|
| ERC-20 approve / transferFrom | _allowances[owner][spender] mapping으로 한도 관리연속 approve 시 경쟁 조건 주의 — EIP-2612 permit으로 대체 가능 |
| ERC-721 safeTransferFrom | 수신자가 컨트랙트면 onERC721Received selector 검증 필수미구현 컨트랙트로 전송 시 NFT 영구 잠김 |
| ERC-1155 배치 처리 | (address, id) 쌍으로 잔액 관리safeBatchTransferFrom으로 여러 유형 한 번에 처리 |
| NFT 메타데이터 | 체인에는 CID 문자열만, 실제 파일은 IPFS에 온체인 SVG 방식은 외부 의존 없는 완전한 탈중앙화 가능 |
참고 자료
[1] Fabian Vogelsteller, Vitalik Buterin — ERC-20: Token Standard (2015)
https://eips.ethereum.org/EIPS/eip-20
[2] William Entriken et al. — ERC-721: Non-Fungible Token Standard (2018)
https://eips.ethereum.org/EIPS/eip-721
[3] Witek Radomski et al. — ERC-1155: Multi Token Standard (2018)
https://eips.ethereum.org/EIPS/eip-1155
[4] Martin Lundfall et al. — EIP-2612: Permit Extension for EIP-20 (2020)
https://eips.ethereum.org/EIPS/eip-2612
[5] OpenZeppelin — ERC20, ERC721, ERC1155 Contracts Source
https://github.com/OpenZeppelin/openzeppelin-contracts
[6] Protocol Labs — IPFS Docs: Content Identifiers (CIDs)
https://docs.ipfs.tech/concepts/content-addressing/
[7] ethereum.org — ERC-721 Non-Fungible Token Standard
https://ethereum.org/en/developers/docs/standards/tokens/erc-721/