수호아이오의 신입 개발자 Danny입니다. 저의 첫 프로젝트인 수호 카페 NFT 프로젝트가 시작된 배경과 재료, 음료, 레시피 등 각종 정보를 컨트랙트에 저장하는 방법을 정리해보았습니다. 저처럼 처음 블록체인 개발을 접하시는 분들께 유용한 정보가 되었으면 좋겠어요!


느슨한 수호 카페에 긴장감을 주는 NFT

저는 프론트엔드, 백엔드 개발 경험은 있었지만 블록체인 개발 경험은 없었기 때문에 입사 후 한동안은 Solidity 개발 공부 위주에만 전념하기로 했습니다.

크립토좀비나 인터넷 강의들을 통해 1~2주 간 Solidity의 기본 문법을 익히고, 이제는 뭐라도 뚱땅뚱땅 만들어봐야 실력이 빠르게 성장할 것이라고 생각해서 혼자 할 수 있는 간단한 프로젝트를 기획해 보기로 했습니다.

수호는 회사 내에 있는 카페를 블록체인 커뮤니티로 만들어내고자 하는 비전이 있습니다. 그 비전에 맞게, 카페와 블록체인을 접목할 수 있는 프로젝트를 기획해 보면 재밌을 것 같다는 생각이 들었습니다.

제 아이디어를 듣고 브랜드 디자이너 Joojoo, 브랜드 매니저 Seney, 그리고 카페 운영 매니저 Chacha가 TF팀을 꾸려 이번 프로젝트를 저와 함께 만들어가기로 했어요! 🙏

가끔 동네 카페에서 커피를 한 잔 주문할 때마다, 쿠폰에 도장을 찍어주고 도장을 열 개 모으면 아메리카노 1잔으로 바꿀 수 있는 곳들이 있는데요. 저희는 도장 대신 토큰을 발급해주고, 토큰을 여러 개 모으면 커피 한 잔으로 바꿔주기로 했습니다.

그런데 그냥 토큰을 주면 동네 카페에서 찍어주는 도장이랑 다를 바가 없겠죠. 그래서 토큰으로 커피 재료(물, 우유, 샷 등등...)를 발급해주고, 재료를 여러 개 조합해서 커피 NFT로 교환할 수 있도록 만들기로 했습니다.

기존 카페 쿠폰
기존 카페 쿠폰 (출처:새김디자인)

예를 들어, 아메리카노 NFT를 얻기 위해서는 필요한 재료 토큰들을 수집해야 합니다. 아메리카노를 만들기 위해 물 400mL와 에스프레소 2샷이 필요하다는 레시피를 컨트랙트 상에 저장해두고, 고객이 커피 한 잔을 주문할 때마다 '물 100mL 토큰', '에스프레소 1샷 토큰' 이렇게 랜덤으로 재료 토큰을 발급할 예정입니다. 정해진 레시피대로 토큰을 모두 모으면, 커피 NFT로 교환을 할 수 있게 됩니다.

모두 완성이 되면 1차적으로 수호 팀원들을 대상으로 테스트를 진행해보고, 그다음엔 실제 카페 손님들에게 서비스해보려고 합니다. NFT가 뭔지 모르거나 낯설어하시는 분들도 간단한 방법으로 NFT를 경험할 수 있게 하고자 해요!

그럼, 개발 과정부터 이용 후기까지 낱낱이 공개해보겠습니다 :)


CafeMenu 컨트랙트 개발을 시작해보자

컨트랙트란?

기존의 백엔드는 프론트의 요청을 받아 DB에서 데이터를 읽기/쓰기 합니다. 이런 백엔드가 컨트랙트와 유사한 역할입니다. 다만, 컨트랙트는 한 번 블록체인 상에 올리면 코드를 변경할 수 없고, 모든 쓰기 작업마다 가스비가 소요됩니다.

수호 카페 NFT 프로젝트의 컨트랙트는 다음과 같이 세 가지입니다.

  • CafeMenu : 재료, 제품, 레시피들을 관리하는 컨트랙트 (여기에 신메뉴, 새로운 재료 등을 추가하면 됩니다.)
  • MaterialFactory : 재료 토큰을 고객에게 발급해주는 컨트랙트
  • ProductFactory : 재료 토큰을 모아오면, 제품 NFT로 발급해주는 컨트랙트

먼저 재료 토큰과 커피 토큰을 발급하기 위해서, 재료와 음료 정보 및 레시피 정보를 저장할 수 있는 CafeMenu 라는 컨트랙트를 작성합니다.

Solidity 개발 환경은 Hardhat을 이용하였습니다. 아래 명령어를 실행하면 hardhat이 알아서 개발환경을 멋지게 세팅해줍니다!

npm install --save-dev hardhat
mkdir sooho-cafe
npx hardhat
npm i @openzeppelin/contracts

OpenZeppelin에서 제공하는 컨트랙트들을 활용하기 위해 @openzeppelin/contracts 모듈도 다운로드합니다.

0. 용어 정리

이해를 돕기 위해 먼저 용어 정리부터 하겠습니다.

Material, 재료
커피(Product)를 만들기 위해 필요한 재료입니다.

아메리카노 한 잔을 만드는 데 필요한 재료는 물과 샷밖에 없는데, 토큰을 단순히 ’물’, ‘샷' 이렇게 발급하면 토큰 단 두 개로 아메리카노 한 잔을 마실 수 있게 되어 카페 재정에 심각한 영향을 끼칠 우려가 있기 때문에,

토큰을 물 50ml, 에스프레소 1shot 과 같은 방식으로 발급하고 아메리카노 1잔의 레시피를 물 200ml + 에스프레소 2shot 로 정해서 최소 6번의 커피를 마셔야 아메리카노 NFT 1개를 받을 수 있도록 설계했습니다. defaultAmount 변수는 토큰을 한 번 발급해줄 때 얼마나 발급해주어야 하는지, 즉 1회 제공량을 나타냅니다.

struct Material {
    string name;
    string unit;    """세는 단위(ml, shots, EA ...etc)"""
    uint32 defaultAmount;   """기본 양 (ex: 물: 50ml -> defaultAmount = 50)"""
}
Material[] public materials;

Product, 제품
위 예시의 아메리카노와 같이, 레시피대로 Material을 모아서 교환받은 커피를 의미합니다. productmaterial과 달리 이름만 저장하고 있으면 될 것 같아, products 변수는 string의 배열로 선언했습니다.

string[] public products;

Recipe, 레시피
Product를 만들기 위해 필요한 Material의 종류와 양을 의미합니다. 하나의 Product를 만들기 위해서는 1개 이상의 Recipe가 필요합니다. productToRecipes라는 mapping을 통해 레시피 목록을 관리합니다.

struct Recipe {
    uint128 materialId;
    uint128 amount;
}
mapping (uint => Recipe[]) public productToRecipes;

1. Constructor

카페의 재료나 제품 목록은 관리자만 추가할 수 있기 때문에, OpenZeppelin의 Ownable 컨트랙트를 상속했습니다.

컨트랙트를 배포하고 나서도 재료와 제품을 추가할 수도 있지만, 처음에 미리 재료들을 추가해 줍니다. 이후에 새로운 재료나 제품이 추가되면 더 추가할 수 있을 것 같네요!

contract CafeMenu is Ownable {
		struct Material {...}
	  struct Product {...}
	  struct Recipe {...}
	
		Material[] public materials;
    Product[] public products;
    mapping (uint => Recipe[]) public productToRecipes;

    constructor(){
        materials.push(Material("Water", "ml", 50));
        materials.push(Material("Milk", "ml", 50));
        materials.push(Material("Espresso", "shot", 1));
    }
		...
}

2. 새로운 Material 생성하기

function createMaterial(
	string memory _name, 
	string memory _unit, 
	uint32 _defaultAmount
) public onlyOwner returns(uint) {
    """재료 이름 중복 허용 X"""
    for(uint i = 0; i < materials.length; i++){
        require(
            !equals(materials[i].name, _name), 
            string(abi.encodePacked("Material ", _name, " already exists."))
        );
    }

    materials.push(Material(_name, _unit, _defaultAmount));
    return materials.length - 1;
}

먼저 새로운 재료를 추가하는 컨트랙트입니다. 이미 존재하는 재료 이름이라면 요청을 revert하고, 그렇지 않으면 materials 배열에 새로운 재료를 추가합니다.

재료는 관리자만 추가할 수 있도록 onlyOwner modifier(제어자)를 추가합니다.

3. 새로운 Product 생성하기

function createProduct(string memory _name, Recipe[] calldata _recipes) public onlyOwner returns(uint) {
    """recipe의 재료들이 존재하는 material인지 확인."""
    for(uint i=0; i<_recipes.length; i++){
        require(
            _recipes[i].materialId < materials.length, 
            string(abi.encodePacked("Material id ", _recipes[i].materialId, " does not exist."))
        );
    }
    
    """제품 이름 중복 허용 X"""
    for(uint i = 0; i < products.length; i++){
        require(
            !equals(products[i], _name), 
            string(abi.encodePacked("Product ", _name, " already exists."))
        );
    }
    
    """new Product"""
    products.push(_name);
    uint productId = products.length - 1;

    for(uint i=0; i<_recipes.length; i++){
        productToRecipes[productId].push(_recipes[i]);
    }

    console.log("Product %s is created with %d materials", _name, _recipes.length);
    return products.length - 1;
}

새로운 제품을 생성하는 함수는 조금 더 복잡합니다. 레시피가 필요하기 때문이죠.

먼저 제품을 생성하기 위해 필요한 레시피들이 모두 존재하는 재료인지 확인합니다. 그다음 재료에서와 마찬가지로 제품 이름이 중복인지를 체크하고, 모두 문제가 없으면 새로운 제품을 생성하여 products 배열에 추가하고, productToRecipes mapping에 레시피들을 추가합니다.

제품도 마찬가지로 관리자만 추가할 수 있도록 onlyOwner modifier를 추가합니다.

4. 문자열 비교 함수

두 함수 중간에 재료, 제품 이름의 중복을 확인하는 지점에서 equals라는 함수를 사용했는데요, Solidity는 자바스크립트처럼 문자열 비교 연산을 지원하지 않습니다. 그래서 아래와 같이 equals와 같은 함수를 만들어 문자열 비교에 사용하였습니다.

function equals(string memory a, string memory b) internal pure returns (bool) {
    if(bytes(a).length != bytes(b).length) {
        return false;
    } else {
        return keccak256(abi.encodePacked(a)) == keccak256(abi.encodePacked(b));
    }
}

5. 제품 생성에 필요한 레시피 얻기

제품 생성에 필요한 레시피 배열을 얻는 함수입니다. 먼저 인자로 들어온 productId가 존재하는지 확인하고, 그다음 해당 제품에 대한 레시피 배열을 반환합니다. public 변수 productToRecipes 의 getter 함수는 레시피 배열 전체가 아닌그 중 하나의 요소만 가져올 수 있기 때문에, 전체 배열을 얻을 수 있는 getRecipes 함수를 따로 만들었습니다.

function getRecipes(uint _productId) external view returns(Recipe[] memory){
    require(_productId < products.length, "No product registered");

    Recipe[] memory p = productToRecipes[_productId];
    require(p.length > 0, "There are no recipes");
    return p;
}

다음 포스트에서는 실제로 고객들에게 재료 토큰을 발급하는 컨트랙트를 개발해 보겠습니다!

Cafe NFT Project 시리즈 모아보기
수호 카페에 NFT가 생긴다면?
컨트랙트 개발 시작 - ERC721Enumerable
컨트랙트 코드 리뷰 - 가스비 절약하기
스마트 컨트랙트와 상호작용하기