- Published on
ERC-1155 토큰 (Multi Token)
- Authors

- Name
- Aaron
ERC-1155이란?
Multi Token Standard
ERC-1155는 2018년 Enjin 팀이 제안한 멀티 토큰 표준입니다. 하나의 컨트랙트에서 여러 종류의 토큰(대체 가능 + 대체 불가능)을 동시에 관리할 수 있는 표준입니다.
블록체인 게임이 본격화되면서 기존 토큰 표준의 한계가 명확해졌습니다. 게임 하나를 만들려면 화폐용 ERC-20 컨트랙트, 아이템용 ERC-721 컨트랙트를 각각 배포해야 했고, 플레이어에게 보상을 지급할 때마다 여러 번의 트랜잭션이 필요해 가스비가 급증했습니다. ERC-1155는 이러한 문제를 해결할 수 있는 표준 컨트랙트입니다.
ERC-1155 이전의 문제점
- ERC-20: 대체 가능한 토큰만 지원 (예: USDT, UNI)
- ERC-721: 대체 불가능한 NFT만 지원 (예: Pudgy Penguins, BAYC)
- 게임에서 코인 + 아이템을 관리하려면 여러 개의 컨트랙트 필요
- 여러 토큰을 전송하려면 여러 번의 트랜잭션 필요 → 가스비 증가
- 각 컨트랙트마다 배포 비용과 관리 부담 발생
ERC-1155의 해결책
- 하나의 컨트랙트로 여러 종류의 토큰 관리
- 배치 전송(Batch Transfer)으로 가스비 최대 90% 절약
- 대체 가능/불가능 토큰을 모두 지원
- 메타데이터 URI 효율적 관리
- 배포 및 유지보수 비용 최소화
ERC-1155 vs ERC-20 vs ERC-721
ERC-20: 1개 컨트랙트 = 1개 토큰
- USDT 컨트랙트는 USDT만 발행합니다. 다른 토큰을 만들려면 새 컨트랙트를 배포해야 합니다.
ERC-721: 1개 컨트랙트 = 여러 개의 고유한 NFT
- Bored Ape 컨트랙트는 #1, #2, #3... 수천 개의 NFT를 관리하지만, 각각은 완전히 다른 원숭이입니다.
ERC-1155: 1개 컨트랙트 = 대체 가능 토큰 + NFT 모두
- 게임 아이템 컨트랙트 하나로 Gold(대체 가능)도 발행하고, Legendary Sword(NFT)도 발행합니다. 각 토큰은 ID로 구분됩니다.
ERC-1155 데이터 구조
ERC-1155는 각 토큰 타입을 id로 구분하고, 각 id마다 수량(amount)을 가집니다.
이중 매핑 구조
ERC-1155의 핵심은 이중 mapping 구조입니다. 첫 번째 key는 토큰 ID, 두 번째 key는 소유자 주소입니다.
// ERC-1155 컨트랙트 내부
// id => (owner => balance)
mapping(uint256 => mapping(address => uint256)) private _balances;
// 예시: 게임 아이템
_balances[0][alice] = 500 // Alice는 Gold 500개
_balances[0][bob] = 300 // Bob은 Gold 300개
_balances[1][alice] = 1 // Alice는 Sword (id=1) 1개 소유
_balances[2][bob] = 1 // Bob은 Shield (id=2) 1개 소유
_balances[3][alice] = 10 // Alice는 Potion 10개
이 구조 덕분에 같은 토큰 ID를 여러 사람이 동시에 소유할 수 있습니다. Gold(id=0)를 Alice는 500개, Bob은 300개 가질 수 있습니다.
ERC-721과의 차이
ERC-721은 tokenId → owner로 1:1 매핑되지만, ERC-1155는 id → (owner → amount)로 1:N 매핑됩니다.
// ERC-721: tokenId → owner (1:1 매핑)
mapping(uint256 => address) private _owners;
_owners[5] = alice // NFT #5의 소유자는 Alice (한 명만 가능)
// ERC-1155: id → (owner → amount) (1:N 매핑)
mapping(uint256 => mapping(address => uint256)) private _balances;
_balances[0][alice] = 500 // Gold를 Alice 500개
_balances[0][bob] = 300 // Gold를 Bob 300개 (여러 명 소유 가능)
ERC-1155 핵심 함수들
1. 조회 함수 (View Functions)
// 특정 계정이 특정 토큰을 몇 개 보유하는지 조회
// ERC-20의 balanceOf + ERC-721의 개념 결합
function balanceOf(address account, uint256 id) public view returns (uint256)
// 여러 계정의 여러 토큰 잔액을 한 번에 조회 (배치 조회)
// accounts와 ids 배열의 길이는 같아야 함
function balanceOfBatch(
address[] accounts,
uint256[] ids
) public view returns (uint256[])
// 토큰의 메타데이터 URI 반환
// {id}를 실제 토큰 ID로 치환하여 사용
function uri(uint256 id) public view returns (string)
// operator가 owner의 모든 토큰을 관리할 권한이 있는지 확인
function isApprovedForAll(address owner, address operator) public view returns (bool)
2. 전송 함수 (Transaction Functions)
// 단일 토큰 전송
// from에서 to로 id 토큰을 amount만큼 전송
function safeTransferFrom(
address from,
address to,
uint256 id,
uint256 amount,
bytes data
) public
// 배치 전송 (여러 종류의 토큰을 한 번에 전송)
// ids와 amounts 배열의 길이는 같아야 함
// 예: ids=[0,1,3], amounts=[100,1,10]
// → Gold 100개, Sword 1개, Potion 10개 동시 전송
function safeBatchTransferFrom(
address from,
address to,
uint256[] ids,
uint256[] amounts,
bytes data
) public
// operator가 owner의 모든 토큰을 관리할 수 있도록 승인/취소
// ERC-721의 setApprovalForAll과 동일
function setApprovalForAll(address operator, bool approved) public
3. 이벤트 (Events)
// 단일 토큰이 전송될 때 발생
event TransferSingle(
address indexed operator, // 전송을 실행한 주소
address indexed from,
address indexed to,
uint256 id,
uint256 amount
)
// 배치 전송이 발생할 때 발생
event TransferBatch(
address indexed operator,
address indexed from,
address indexed to,
uint256[] ids,
uint256[] amounts
)
// 전체 승인이 설정/해제될 때 발생
event ApprovalForAll(
address indexed owner,
address indexed operator,
bool approved
)
// URI가 변경될 때 발생
event URI(string value, uint256 indexed id)
ERC-1155 컨트랙트 구현 예시
실제 ERC-1155 컨트랙트는 다음과 같이 구현됩니다
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract SimpleERC1155 {
// 토큰 ID => (소유자 => 잔액)
mapping(uint256 => mapping(address => uint256)) private _balances;
// 소유자 => (승인된 주소 => 승인 여부)
mapping(address => mapping(address => bool)) private _operatorApprovals;
// 메타데이터 URI (모든 토큰에 공통 사용)
string private _uri;
// 이벤트
event TransferSingle(
address indexed operator,
address indexed from,
address indexed to,
uint256 id,
uint256 amount
);
event TransferBatch(
address indexed operator,
address indexed from,
address indexed to,
uint256[] ids,
uint256[] amounts
);
event ApprovalForAll(
address indexed owner,
address indexed operator,
bool approved
);
event URI(string value, uint256 indexed id);
constructor(string memory uri_) {
_uri = uri_;
}
// 잔액 조회
function balanceOf(address account, uint256 id) public view returns (uint256) {
require(account != address(0), "유효하지 않은 주소입니다");
return _balances[id][account];
}
// 배치 잔액 조회
function balanceOfBatch(
address[] memory accounts,
uint256[] memory ids
) public view returns (uint256[] memory) {
require(accounts.length == ids.length, "배열 길이가 일치하지 않습니다");
uint256[] memory batchBalances = new uint256[](accounts.length);
for (uint256 i = 0; i < accounts.length; i++) {
batchBalances[i] = balanceOf(accounts[i], ids[i]);
}
return batchBalances;
}
// URI 조회
function uri(uint256 id) public view returns (string memory) {
return _uri;
}
// 단일 전송
function safeTransferFrom(
address from,
address to,
uint256 id,
uint256 amount,
bytes memory data
) public {
require(to != address(0), "유효하지 않은 수신 주소입니다");
require(
from == msg.sender || isApprovedForAll(from, msg.sender),
"전송 권한이 없습니다"
);
require(_balances[id][from] >= amount, "잔액이 부족합니다");
_balances[id][from] -= amount;
_balances[id][to] += amount;
emit TransferSingle(msg.sender, from, to, id, amount);
}
// 배치 전송
function safeBatchTransferFrom(
address from,
address to,
uint256[] memory ids,
uint256[] memory amounts,
bytes memory data
) public {
require(to != address(0), "유효하지 않은 수신 주소입니다");
require(ids.length == amounts.length, "배열 길이가 일치하지 않습니다");
require(
from == msg.sender || isApprovedForAll(from, msg.sender),
"전송 권한이 없습니다"
);
for (uint256 i = 0; i < ids.length; i++) {
uint256 id = ids[i];
uint256 amount = amounts[i];
require(_balances[id][from] >= amount, "잔액이 부족합니다");
_balances[id][from] -= amount;
_balances[id][to] += amount;
}
emit TransferBatch(msg.sender, from, to, ids, amounts);
}
// 전체 승인
function setApprovalForAll(address operator, bool approved) public {
require(operator != msg.sender, "자신에게 승인할 수 없습니다");
_operatorApprovals[msg.sender][operator] = approved;
emit ApprovalForAll(msg.sender, operator, approved);
}
// 승인 조회
function isApprovedForAll(address owner, address operator) public view returns (bool) {
return _operatorApprovals[owner][operator];
}
// 토큰 발행
function mint(address to, uint256 id, uint256 amount) public {
require(to != address(0), "유효하지 않은 주소입니다");
_balances[id][to] += amount;
emit TransferSingle(msg.sender, address(0), to, id, amount);
}
// 배치 발행
function mintBatch(
address to,
uint256[] memory ids,
uint256[] memory amounts
) public {
require(to != address(0), "유효하지 않은 주소입니다");
require(ids.length == amounts.length, "배열 길이가 일치하지 않습니다");
for (uint256 i = 0; i < ids.length; i++) {
_balances[ids[i]][to] += amounts[i];
}
emit TransferBatch(msg.sender, address(0), to, ids, amounts);
}
}
핵심 작동 원리
1. 단일 토큰 전송 (safeTransferFrom)
Alice가 Bob에게 Gold 100개를 전송하는 예시
// Alice가 Bob에게 Gold(id=0) 100개 전송
token.safeTransferFrom(alice, bob, 0, 100, "");
// 컨트랙트 내부에서 일어나는 일:
require(_balances[0][alice] >= 100, "잔액 부족");
_balances[0][alice] -= 100; // Alice: 1000 → 900
_balances[0][bob] += 100; // Bob: 500 → 600
emit TransferSingle(msg.sender, alice, bob, 0, 100);
2. 배치 전송 (safeBatchTransferFrom)
여러 종류의 토큰을 한 번의 트랜잭션으로 전송하여 가스비를 절약할 수 있습니다.
// Alice가 Bob에게 여러 아이템 동시 전송
// Gold 100개 + Sword 1개 + Potion 10개
uint256[] memory ids = [0, 1, 3];
uint256[] memory amounts = [100, 1, 10];
token.safeBatchTransferFrom(alice, bob, ids, amounts, "");
// 컨트랙트 내부에서 일어나는 일 (반복문으로 처리):
// Gold (id=0)
_balances[0][alice] -= 100;
_balances[0][bob] += 100;
// Sword (id=1)
_balances[1][alice] -= 1;
_balances[1][bob] += 1;
// Potion (id=3)
_balances[3][alice] -= 10;
_balances[3][bob] += 10;
emit TransferBatch(msg.sender, alice, bob, [0,1,3], [100,1,10]);
3. 승인 (setApprovalForAll)
승인(Approval)이란?
승인은 내 토큰을 다른 주소(사람 또는 컨트랙트)가 대신 전송할 수 있도록 권한을 부여하는 기능입니다. 예를 들어, NFT 마켓플레이스(OpenSea, Blur 등)에서 내 아이템을 판매하려면, 마켓플레이스 컨트랙트가 구매자에게 토큰을 전송할 수 있어야 합니다. 이를 위해 판매자는 먼저 마켓플레이스에게 "내 토큰을 대신 전송할 권한"을 승인해야 합니다.
승인 없이는 다른 주소가 내 토큰을 옮길 수 없습니다. 하지만 승인을 하면, 그 주소는 내 허락 없이도 토큰을 전송할 수 있게 됩니다. 따라서 신뢰할 수 있는 컨트랙트에만 승인해야 합니다.
ERC-1155의 승인 방식
ERC-1155는 ERC-721과 달리 개별 토큰 승인이 존재하지 않습니다. 오직 setApprovalForAll()만 제공됩니다.
이는 한 번 승인하면 operator가 소유자의 모든 토큰 ID와 모든 수량을 마음대로 전송할 수 있다는 의미입니다. 예를 들어, OpenSea에 승인하면 골드 1000개, 전설 검 1개, 포션 50개를 모두 전송할 수 있는 권한이 생깁니다.
따라서 신뢰할 수 없는 컨트랙트나 주소에는 절대 setApprovalForAll()을 실행하지 않도록 주의해야 합니다. 승인 후에는 모든 자산을 탈취당할 수 있기 때문입니다.
// Alice가 OpenSea에게 자신의 모든 ERC-1155 토큰 관리 권한 부여
token.setApprovalForAll(openSeaAddress, true);
// 컨트랙트 내부:
_operatorApprovals[alice][openSea] = true;
emit ApprovalForAll(alice, openSea, true);
// 이제 OpenSea는 Alice의 모든 토큰(id=0,1,2,3...)을 전송할 수 있음
ERC-721과의 차이
// ERC-721: 개별 승인 + 전체 승인 모두 가능
nft.approve(marketplace, tokenId); // 특정 NFT만
nft.setApprovalForAll(marketplace, true); // 모든 NFT
// ERC-1155: 전체 승인만 가능
multiToken.setApprovalForAll(marketplace, true); // 모든 토큰
// approve() 함수 없음
OpenZeppelin으로 ERC-1155 토큰 만들기
ERC-1155 컨트랙트를 작성할 때는 OpenZeppelin ERC-1155의 검증된 구현체를 사용하는 것이 좋습니다.
// contracts/GameItems.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {ERC1155} from "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";
contract GameItems is ERC1155 {
// 토큰 ID 정의
uint256 public constant GOLD = 0; // 대체 가능한 게임 화폐
uint256 public constant SILVER = 1; // 대체 가능한 게임 화폐
uint256 public constant THORS_HAMMER = 2; // 고유 NFT (수량 1)
uint256 public constant SWORD = 3; // 대체 가능한 아이템
uint256 public constant SHIELD = 4; // 대체 가능한 아이템
constructor() ERC1155("https://game.example/api/item/{id}.json") {
// 초기 아이템 발행
_mint(msg.sender, GOLD, 10 ** 18, ""); // 1,000,000,000,000,000,000 (18 decimals)
_mint(msg.sender, SILVER, 10 ** 27, ""); // 1,000,000,000,000,000,000,000,000,000
_mint(msg.sender, THORS_HAMMER, 1, ""); // 고유 아이템 1개
_mint(msg.sender, SWORD, 10 ** 9, ""); // 1,000,000,000개
_mint(msg.sender, SHIELD, 10 ** 9, ""); // 1,000,000,000개
}
}