본문 바로가기
개발공부일지/Block-Chain

[Project] Block-Chain DeFi 시스템 구현하기 후기

by Hynn1429 2023. 7. 28.
반응형

안녕하세요.

Hynn 입니다.

 

이번 포스팅에서는 기존의 개념들을 이용해서, DeFi 시스템을 구현하는 프로젝트를 수행하였습니다.

프로젝트 기간중에는 아래와 같은 Schedule 로 진행했고, 총 2달여간의 걸처 작업을 했지만, 실제 코드를 구현하는 작업은 2주 반 정도 되는 기간동안 수행했습니다.

 

 새로운 기술을 사용하여 시도한 프로젝트이니만큼, 공부하는 시간이 더 길어, 실제 구현상의 세부적인 디테일에는 아쉬운 부분들이 있지만,전체적으로 어떻게 접근하고, 어떻게 구현했는지를 후기로 남겨보려고 합니다.

 

 

1. DeFi 시스템 학습하기.

 

먼저 DeFi 시스템이 어떠한 구성으로 이루어지는지를 알아야 했습니다.

하지만 DeFi 의 경우, 한국에서 활발하게 이루어지거나 시행되고 있는 서비스가 아니기때문에, 제한적인 정보만을 가지고 접근해야 했습니다.

그래서 저희 팀인 3명의 팀원은 파트를 나누어 아래의 단계로 학습을 수행했습니다.

 

  • ERC 표준 코드 동작방식 이해하기 ( OpenZepplien)
  • Swap 구조 이해하기 ( UniSwap )
  • DeFi 시스템 흐름도 이해하기

여기서 이 3가지의 시스템을 이해하기 위해서는, 당연하게도 DeFi 에서 핵심적인 개념을 이해해야 했습니다.

여기서 팀원과 제가 착안한 기본적인 이해도 방식은 DeFi 는 말 그대로 Decentralize Finance 라는 것의 약자이기때문에, 즉 금융서비스로 대입하여 이해하는것이 빠르겠다고 판단했습니다.

 

따라서 각각의 핵심용어를 아래와 같은 형태로 이해를 시작했습니다.

 

먼저 주요 용어입니다.

 

  • Governance - 회사의 이사회 같은 의결기구
  • Factory - 중앙화 시스템에서 처리하는 집행 역활
  • Pool - 통장
  • Swap - 이체/송금
  • Staking - 적금/예금
  • Token - 화폐 

이와같이, 실제 은행시스템에서 대체하는 각 항목에 대한 대략적인 대체제를 상정하고, 이와 다른점이 어떤것들이 있는지를 파악해나가면서 공부를 했습니다.

실제 이는 자신이 담당하게 될 파트의 항목에 대해서 집중적으로 공부하였고, 하루에 1차례씩 자신이 공부한 것을 다른 팀원들에게 설명하고, 팀원들은 이에 대한 허점이나 생각을 나누면서, 부족한 점을 보완하는 형태로 수행했습니다.

 

이를 통해 기초적인 DeFi 항목을 학습하고 난 뒤, OpenZepplien, UniSwap 과 같은 대표적인 시스템의 Github Repository 를 보고 코드의 구성을 학습하였습니다.

대표적으로 가장 많이 참고가 된 부분은 Openzepplien 의 Github 입니다.

이 코드에서는 실제 Token 의 표준, 그리고 각 Contract 의 Standard Function 이 모두 잘 녹아 있을 뿐 아니라, 실제 서비스 구현시 Overflow, Underflow 를 방지하고 있는 SafeMath 와 같은 다양한 Contract 표준이 제공되어, 이를 참고하는 것 만으로도 기초적 흐름을 파악할 수 있었습니다.

 

프로젝트 과정 중 맞닥트린 문제는 아래와 같이 여러가지가 존재했고, 이를 파악하는데 시간도 적지 않게 소모되었습니다.

이 경험을 나누어보고, 어떠한 이유에서엿는지, 어떻게 개선햇는지를 나누어 보도록 하겠습니다.

 

1. EVM Memory Issue (EIP 170, Contract Code Limit Issue)

 

바로 이더리움 내 표준 중 하나인 EIP (Ethereum Improvement Proposals) 170에 의해, Contract Code 의 제한사항이 존재하는 것이였습니다.

이로 인해 Contract 을 Local 환경에서 배포 후, RemixIDE 로 이루어지는 2차 테스트에서 배포가 수행되지 않는 문제를 확인하게 되었습니다.

EIP 의 대한 세부정보는 아래의 링크에서 확인할 수 있습니다.

 

EIP - Ethereum Improvement Proposals

 

이 문제를 해결하기 위해서는 코드자체의 최적화가 필요했습니다.

하지만, 꼭 필요한 기능도 존재하기 때문에, 기능별로 이를 세분화하여 분리하는 작업이 불가피했습니다.

이 과정에서는 OOP 와 같은 객체지향 프로그래밍에서처럼 의존성 주입 형태로, 각 기능을 분리한 Contract 에 Factory Address 를 주입하여 동작하는 형태로 코드의 작성이 필요하게 되었습니다. 

그렇게 하면, 실제 Pool , Staking 과 같은 독립적 기능은 Factory 가 이를 동작하도록 제한할 수 있고, 이를 요구사항에 포함시킴으로써, 분리된 Contract 이라도 실제 하나의 Contract 에서 동작하는 것 처럼 구현이 가능하였습니다.

 

그렇게 함으로서 실제 배포와 코드의 구성은 아래와 같이 구성하게 되었습니다.

 

 

2. Ether.js 라이브러리 버전에 따른 동작 이슈  / WalletConnect Version Issue 

 

처음에는 Ethers 와 Web3 두 가지의 라이브러리 중, Ethers 를 사용하기로 결정한 것은, 협업회사의 요청도 있었지만, Web3 가 아닌 Ethers 라는 새로운 것을 사용해보고자하는 욕구도 있었습니다.

하지만 Web3 에 비해 Ethers 는 버전에 따른 동작이 매우 크게 달라지는 것을 파악하고, 이를 버전을 통일해 사용했습니다.

 

NextJs 처럼 Ethers 에서는 Ethereum 네트워크와 통신시 구성하는 방식에서도 다른점이 많았습니다.

예를들어 6.x 버전에서는 BrowserProvider method 로 모든 RPC 연결방식을 통일함으로써, RPC 통신의 대한 간소화를 하였지만, 세부적인 컨트롤이 아직 부족한 부분이 다소 있었고, 5.x 에서는 Providers 내의 다양한 RPC 연결방식을 제공함으로서, 이를 Frontend 에서 다양한 형태로 구성이 가능하였습니다. 이 과정에서 Transaciton 이 발생할때의 Signer 의 정보등을 담는 형태가 다르게 구성되어있었습니다.

 

이와 연계되어 대표적인 Wallet 서비스 중 하나인 WallectConnect 의 버전도 구현 당시에 1.0 에서 2.0 으로 마이그레이션이 되던 시기여서 실제 공식문서의 연결가이드와는 다소 다르게 동작하는 부분이 많았습니다.

 

이를 반대로 구성하기 위해서 WalletConnect V1 의 코드를 구성하고, 이를 반대로 하나하나씩 내려가다 보니 WebSocket 형태로 코드가 구성되어있는것을 파악했고, 이를 2.0 에서 동일하게 적용하여 구성을 완료했습니다.

 

실제 이를 올바르게 구현하기 위해, 잔액을 가져오는 함수, 그리고 지갑과 연결하는 함수를 분리하여, 관리하였고 이를 useRef, useEffect 를 통해 변경될 때와, 일정시간마다 새로고침하는 것을 구현하여 코드를 구현했습니다.

 

export const WalletConnect = () => {
  const [isLogin, setIsLogin] = useRecoilState(loginState);
  const [account, setAccount] = useRecoilState(accountState);
  const [isLoading, setIsloading] = useRecoilState(loadingState);
  const [wallet, setWallet] = useRecoilState(selectedWallet);
  const [popupOpen, setPopupOpen] = useRecoilState(popupState);
  const [qrCodeUri, setQrCodeUri] = useState(null);
  const [isMetamaskLogin, setIsMetamaskLogin] =
    useRecoilState(metamaskLoginState);
  const [isTrustwalletLogin, setIsTrustwalletLogin] = useRecoilState(
    trustwalletLoginState,
  );
  const [balance, setBalance] = useRecoilState(balanceState);

  const updateBalances = async (accounts) => {
    if (!account) {
      console.log('Account is not defined');
      return;
    }
    const provider = new ethers.providers.JsonRpcProvider(ARBrpc);
    const tokenAddressMap = {
      ARB: process.env.REACT_APP_ARB_TOKEN_ADDRESS,
      ETH: process.env.REACT_APP_ETH_TOKEN_ADDRESS,
      ASD: process.env.REACT_APP_ASD_TOKEN_ADDRESS,
      USDT: process.env.REACT_APP_USDT_TOKEN_ADDRESS,
    };

    let newBalance = { ...balance };

    for (let tokenName in tokenAddressMap) {
      const tokenContract = new ethers.Contract(
        tokenAddressMap[tokenName],
        TokenABi.abi,
        provider,
      );
      const tokenBalance = await tokenContract.balanceOf(account);
      newBalance[tokenName] = ethers.utils.formatEther(tokenBalance);
    }
    console.log(newBalance);
    setBalance(newBalance);
  };

  const handleLogin = async () => {
    setIsloading(true);
    try {
      const walletConnectProvider = await EthereumProvider.init({
        projectId: APIKEY,
        chains: [421613],
        showQrModal: true,
        methods: ['eth_accounts', 'eth_sendTransaction'],
        events: [
          'session_request',
          'session_update',
          'session_reject',
          'call_request',
          'disconnect',
          'connect',
          'reset',
        ],
      });

      const accounts = await window.ethereum.request({
        method: 'eth_requestAccounts',
        wallet: 'walletconnect',
      });

      walletConnectProvider.on('connect', () => {
        setQrCodeUri(null);
      });

      const uri = await walletConnectProvider.connect();
      setQrCodeUri(uri);

      const walletConnectProviderAccounts = new ethers.providers.Web3Provider(
        walletConnectProvider,
      );
      const signer = walletConnectProviderAccounts.getSigner();

      setAccount(accounts[0]);
      setIsLogin(true);
      setWallet('walletconnect');
      await updateBalances(accounts);
      setPopupOpen(false);
      setIsTrustwalletLogin(false);
      setIsMetamaskLogin(false);
    } catch (e) {
      console.log(e);
    }
    setIsloading(false);
  };

 

3. Event & Log 의 부재

 

기존의 JavaScript/TypeScript 혹은 HTML,CSS 는 최종적으로 코드가 브라우저의 런타임 환경에서 실행되고, 문제가 발견되었을 경우 이를 오류로 반환함으로써, 오류를 개발자가 확인할 수 있는 형태가 존재했습니다.

하지만 SmartContract 에서는 이 조차도 작성한 코드의 함수 내에 Event, Log 등을 포함하지 않으면, 단순히 오류만 발생하게 되고, 어떠한 오류인지 세부적으로 나타나지 않는 문제가 다수 확인되었었습니다.

 

예시를 한번 보도록 하겠습니다.

 

먼저 기존의 AirDrop method 를 살펴보도록 하겠습니다.

 

function airdrop(address[] memory recipients, uint256[] memory amounts) external onlyOwner whenNotPaused nonReentrant {
        require(recipients.length == amounts.length, "Recipients and amounts array length should be same");
        for(uint256 i=0; i<recipients.length; i++){
            require(!claimed[recipients[i]], "Address has already claimed the airdrop");
            claimed[recipients[i]] = true;
            governanceToken.safeTransfer(recipients[i], amounts[i]);
        }
    }

위의 코드 구조를 보면, 이 airdrop 이라는 method 는 2개의 매개변수가 존재합니다.

receipts 라는 주소를 메모리에 담는 배열, 그리고 amounts 는 각각의 주소에게 보낼 토큰의 개수를 표현하고 있습니다. 이 배열은 같은 길이를 가집니다.

그리고 external 은 typescript 와 마찬가지로 이 method 의 접근제어자 역활을 합니다.

추가적으로 이 Solidity 에서는 require 문과 같이 요구사항을 포함할 수 있습니다. 여기서 onyOwner, whenNotPaused, nonReentract 3개의 요구사항이 존재합니다.

각각의 요구사항은 아래의 사항을 담고 있습니다.

 

  • OnlyOwner - 이 Modifier 는 이 함수를 호출하는 주소가 Contract 의 소유주인지를 확인합니다. 소유주가 아니라면 Revert, 즉 요청이 거절되며, 수행되지 않는 Modifier 입니다.
  • WhenNotPaused - 이 Modifier 는 Pausable Contract 으로 상속받아, Contract 이 일시중지 상태일 경우, 함수를 호출할 수 없도록 제어하는 Modifier 입니다.
  • NonReentrant 는 함수 호출 중 같은 함수를 호출하는 재진입공격을 방지하는 Modifier 입니다

 

이제 이를 바탕으로 Require 문에서는 한가지를 더 처리하게 됩니다.

코드를 그대로 해석하면, receipents 와 amounts 의 length 길이를 비교합니다. 이를 통해, 배열이 다를 경우, 에러메시지를 반환하고 트랜잭션을 중단하는 역활을 하게 됩니다.

이 첫번째 요구사항을 통과하게 되면 반복문에서 이제 추가적인 사항을 한가지 확인하게 됩니다. 수신자가 이전에 Airdrop을 받은 지갑 주소인지를 확인하고, 만약 받은 지갑이라면 여기서 오류를 반환하며 종료됩니다. 그리고 claimed 에서는 airdrop 을 받은 지갑주소라면 이를 매핑을 업데이트 하도록 처리합니다. 

그리고 마지막으로 SafeERC20 의 함수를 상속받아, 수신자에게 안전하게 토큰을 전송합니다.

 

위 코드의 경우, 전체적으로 구동사항이 명확하고, 짧은 코드기때문에 오류메시지 만으로도 코드가 어떤곳에서 오류가 발생했는지를 확인하고, 이를 처리할 수 있습니다.

또한 테스트를 위해 일부러 오류를 발생시켰을때 기대하는 방향으로 동작하는지도 살펴볼 수 있습니다.

이를 살펴보면 아래의 영상처럼 오류를 반환하는 것을 볼 수 있습니다.

 

 

하지만 영상과 다르게, 만약 이러한 오류가 내부 로직에서 발생하면 오류의 원인을 알 수가 없게 됩니다.

이로 인해서 몇몇 Transaction 중 발생한 오류에 대해서 정확한 원인을 찾는데 시간이 더 소요되게 되었습니다.

내부적으로 원인을 탐색하고, 이슈를 분리하는 과정을 통해 문제를 해결하기는 했지만, 각 코드의 이벤트 요소를 등록하고, Logger 에 이를 남기면, 이를 보다 빠르게 파악하고 효율적인 문제원인 파악이 가능하게 됩니다.

 

이를 통해 추후에 충분히 개선가능한 여지가 있는 코드로 남게 되었고, 이는 개인적으로도 조금씩 작업을 해보고 있습니다.

예를 들어 위의 코드를 이벤트로거를 추가한다면 아래와 같이 추가가 가능하게 됩니다.

 

    // Event
    event AirdropClaimed(address indexed recipient, uint256 amount);


    function airdrop(address[] memory recipients, uint256[] memory amounts) external onlyOwner whenNotPaused nonReentrant {
        require(recipients.length == amounts.length, "Recipients and amounts array length should be same");
        for(uint256 i=0; i<recipients.length; i++){
            require(!claimed[recipients[i]], "Address has already claimed the airdrop");
            claimed[recipients[i]] = true;
            governanceToken.safeTransfer(recipients[i], amounts[i]);

            emit AirdropClaimed(recipients[i], amounts[i]);
        }
    }
}

 

두가지가 추가됩니다.

먼저 Contract 내부에 event 라는 명챙으로 각각의 이벤트 로거를 남기도록 설정하고, 함수 내에서 emit 을 사용하여 이벤트에 기록하도록 코드를 작성할 수 있습니다.

이렇게 하면 성공,실패를 함수 내에서 담게 되므로, 코드의 동작기록 을 볼 수 있을 뿐 아니라, 어떠한 문제가 발생햇는지도 파악할 수 있게 됩니다.

 

4. Gas Limit

 

이더리움과 같은 스마트 컨트랙트의 개발은 기존의 개발환경과 가장 크게 다른점이 한가지가 존재합니다.

바로 아래의 내용입니다.

 

"코드를 실행할 때 비용이 발생합니다."

 

당연히 전기세[?] 나 이러한 것들의 비용이 아니라, DeFi 와 같은 Web3.0 기반의 시스템에서는 트랜잭션, Transaction 이 발생할때마다 일정 비용의 Gas 가 소모됩니다. 아래의 동영상 예시를 살펴보도록 하겠습니다.

 

 

위 항목은, 이번 프로젝트의 실제 홈페이지에서 제가 가지고 있는 토큰을 예치함으로써, 이를 리워드로 제공받을 수 있는 페이지입니다.

하지만 페이지에서 제가 요청을 하면, 서명을 요청하면서, 가스 수수료가 표시되는 것을 볼 수 있습니다.

위의 사항처럼 이 스마트 컨트랙트는 코드가 실행되면, 이로인한 가스 수수료가 발생하게 되고, 사용자는 이를 지불해야 코드가 실행되는 구조입니다.

 

하지만 이 수수료는 적을수록 좋은것이고, 그렇기 때문에, SmartContract 내에서는 가스 수수료를 정의할 수 있게 됩니다.

하지만 컨트랙트 내에서 정의한 것 외에도, 이는 Frontend 에서도 GasLimit 를 정의할 수 있습니다.

위 사항을 처리하는 실제 코드를 살펴보도록 하겠습니다.

 

const swap = async (fromToken, toToken, amount) => {
    console.log(
      tokenCA[fromToken],
      tokenCA[toToken],
      BigNumber.from(ethers.utils.parseEther(amount)).toString()
    );
    try {
      const tx = await contract.swapToken(
        tokenCA[fromToken],
        tokenCA[toToken],
        ethers.utils.parseEther(amount),
        {
          gasLimit: 1000000,
          maxFeePerGas: ethers.utils.parseUnits('10', 'gwei'),
          maxPriorityFeePerGas: ethers.utils.parseUnits('1', 'gwei'),
        }
      );
      setTransaction((prevTX) => [...prevTX, tx]);
    } catch (e) {
      console.log(e.message);
    }
  };

실제 코드를 살펴보면, Ethereum 의 경우, 일반적인 숫자단위가 아니라  BigNumber 구조로 되어 있어 이를 컨버팅도 처리해야 하지만, tx 로 선언한 항목 내부를 보면, 객체형태로 gasLimit, maxFeePerGas, maxPriorityFeePerGas 3개로 명시가 가능했습니다.

 

3개의 항목은 각각 아래의 역활을 담당합니다.

 

  • 거래에서 사용될 최대 가스의 양
  • 거래의 최대 가스 가격
  • 트랜잭션의 우선순위를 결정하는 가스의 가격

위 사항 역시도 EIP-1559 에서 가스 비용 모델을 개선한 결과 추가된 새로운 개념이 도입된 항목입니다.

기존에서는 GasLimit 만을 설정하여 최대 가스 양을 명시했다면, 아래의 2개의 항목의 도입으로 인해 보다 더 예측가능하고 효율적인 거래 비용 모델을 제공하기 위한 새로운 표준이기도 합니다. 또한 이러한 세부적인 명시로 인해 네트워크가 바쁠 때, 가스 수수료의 증가를 완화시키기 위한 목적이기도 합니다.

 

실제로 이 비용을 조정함으로서, 트랜잭션의 처리속도를 증가할 수 도있겠지만, 비용증가적 측면도 발생하기 때문에, 실제 서비스를 구현한다면 이에 대한 효과적인 조정도 필요하게 될 것입니다.

 

5. Frontend Build Issue

 

이번 프로젝트에서는 Github Actions 를 사용하여 자동배포 시스템을 구축했습니다.

따라서 기본적으로 Github Branch 전략을 아래와 같이 통일하여 구성했습니다.

 

Branch

 

여기서 Github Actions 를 통해 EC2 에 Main Branch 의 변경사항이 존재하면 자동으로 데이터를 가져오고, 이를 빌드 후 배포하는 과정에서 EC2 인스턴스의 메모리 부족으로 인해 빌드가 실패하는 경우가 잦았습니다.

이를 해결하기 위해 EC2 인스턴스의 메모리 스왑기능을 구현해서 문제를 해결하는 과정을 겪었고, EC2 Instance 에 메모리 스왑을 구현하도록 처리하였습니다.

현재 인스턴스에 구성된 스왑의 현황은 아래와 같습니다.

 

실제 배포를 위한 Github Actions Workflow 는 아래와 같이 구성이 되어 있습니다.

 

name: CICD

on:
  push:
    branches: [ main ]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
    - name: Checkout source code
      uses: actions/checkout@v2

    - name: Setup Node.js environment
      uses: actions/setup-node@v2
      with:
        node-version: '20.4.0' 

    - name: Copy repository files
      uses: appleboy/scp-action@master
      with:
        host: ${{ secrets.HOST }}
        username: ${{ secrets.USERNAME }}
        key: ${{ secrets.SSH_PRIVATE_KEY_ID }}
        source: "."
        target: "/home/ubuntu/Front_DeFi"

    - name: Install and build
      uses: appleboy/ssh-action@master
      with:
        host: ${{ secrets.HOST }}
        username: ${{ secrets.USERNAME }}
        key: ${{ secrets.SSH_PRIVATE_KEY_ID }}
        script: |
          export PATH=$PATH:/home/ubuntu/.nvm/versions/node/v20.4.0/bin
          cd /home/ubuntu/Front_DeFi/front
          rm -rf build
          git pull
          npm install
          npm run build

    - name: Restart Nginx
      uses: appleboy/ssh-action@master
      with:
        host: ${{ secrets.HOST }}
        username: ${{ secrets.USERNAME }}
        key: ${{ secrets.SSH_PRIVATE_KEY_ID }}
        script: "sudo systemctl restart nginx"

 

실제 은행시스템의 기초적인 사항만을 구현했기 때문에, 기초적 기능만 존재합니다.

하지만 DeFi 시스템을 이해하고 이를 Front에 구현하여 적용함으로서, 새로운 Frontend 영역을 다루게 된것에 대한 즐거움을 느끼는 계기가 되었습니다.

이제 이를 바탕으로, 이를 Next로 변환도 해볼 수 있고, 기존의 Frontend <-> Backend 의 형태가 아닌 다양한 통신을 새롭게 경험한 것에 대해 매우 즐거운 프로젝트였습니다.

 

아쉬운 점은 공부하는 시간이 적지 않게 소모되었다 보니, 코드 작성에 대해서 세밀하게 다루지 못하여 지저분하게 코드가 작성되었다고 스스로 평가되어, 이를 개선한다면 더욱 간결한 코드작성 및 구성이 이루어지지 않을까 합니다.

 

홈페이지 구동에 대한 전체적 화면을 보여드리고, 마무리하도록 하겠습니다.

 

감사합니다.

 

 

 

반응형

댓글