ERC 표준은 단순한 인터페이스 규약이 아니라,
컨트랙트 간 자산 이동 방식을 설계하는 핵심 패턴
이 글은 코드 레벨에서 approve/transferFrom의 내부 동작, ERC-721과 ERC-1155의 구현 차이,
NFT 메타데이터가 체인 밖에서 어떻게 관리되는지를 다룸


ERC-20 approve / transferFrom — 내부 구현까지

transfer vs transferFrom — 컨트랙트 관점의 차이

transfermsg.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-721ERC-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/

+ Recent posts