DeFi 금융파생상품(Crypto Default Swap) - 프로젝트 과정에서 만난 문제점들

Hong·2023년 3월 18일
1
post-thumbnail





👨‍💻

프로젝트를 처음 설계할 때 예상되는 문제점들도 분명 존재하지만,
프로젝트를 진행하며 예측하지 못했던 크고 작은 문제들을 수 없이 만났다.
만났던 문제점을 전부 기록하는 것은 무리가 있기에 기억에 남았던 것들을 기록으로 남겨본다.







1. CDS의 기초자산 가격을 어떻게 들고올 것인가?

CDS는 양자계약으로써 Buyer와 Seller가 기초자산과 신용사건을 합의한 후 해당 신용사건이 일어났을 때 Seller는 Buyer에게 보상금을 지급한다 (신용사건이 일어나기 전까지 Buyer는 Seller에게 정해진 기간마다 Premium을 지급한다).


우리의 CDS Dapp은 기초자산과 신용사건의 기준으로 비트코인, 이더리움 등 가상자산의 가격을 가져오기로했다. 하지만 여기서 문제점이 발생한다.

어디서 신뢰할 수 있는 가상자산의 가격을 들고올 수 있는가?

크게 두 가지 해결책이 존재했다.

  • A : 유동성 풀(Liquidity Pool)을 만들고 유동성 풀에서 생성되는 가격을 들고온다.
  • B : Chaninlink Oracle Price Feed를 통해 오라클 가격을 들고온다.

하지만 두 방법은 각각 장단점이 존재했다.

A의 장점 : Chainlink Oracle은 가격 변동률이 약 0.5% 이상이거나 약 1시간(Heart Beat)마다 가격이 업데이트되는데 반해 자체 유동성 풀을 형성하면 0.5%의 변동률이나 Heart Beat시간을 기다릴 필요없이 바로 가격을 들고올 수 있다.

A의 단점 : DeFi에서 유동성 풀 소비자에게 원활한 Swap을 제공하기 위해서는 유동성 풀에 많은 양의 유동성이 예치되어있어야하는데 우리의 유동성 풀이 안정적인 궤도에 오르기 어렵다.
또한 우리의 CDS상품이 Default상황을 가정하고 있는 만큼, 가상자산 시장에 급격한 가격 하방압력이 가해지고 Default상황에 마주했을 때 상대적으로 소규모로 형성된 우리의 유동성 풀 자체가 붕괴될 위험이 존재한다.


B의 장점 : 가상자산 시장 전역에 걸친 Default 위험에서 자체 유동성 풀 붕괴없이 가격을 손쉽고 안전하게 가져올 수 있다. 유동성 풀 자체를 고려할 필요 없기 때문에 Client-Contracr-Server 구조가 간결해진다.

B의 단점 : 최대 0.5%의 슬리피지가 발생할 수 있다.


🧩 문제해결

프로젝트의 정해진 기간이 있었기 때문에 Client-Contract-Server 구조에서 복잡한 로직이 처리되는 자체 유동성 풀을 주입시키는 것은 구현범위를 너무 벗어났다.
체인링크를 사용함으로써 발생할 수 있는 최대 0.5%의 슬리피지를 Clinet에서 User에게 미리 고지함과 동시에 거래소에서 사용되는 Market Price데이터와 체인링크 가격을 클라이언트에서 함께 보여주고 두 가격이 얼마나 차이나는지 UI를 통해 제공함으로써 문제를 해결했다.


⌨️ Code

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;

//Aggregator V3 인터페이스를 가져옵니다
import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";

contract PriceConsumerV3 {
  
    AggregatorV3Interface internal BTCpriceFeed;
    AggregatorV3Interface internal ETHpriceFeed;
    AggregatorV3Interface internal LINKpriceFeed;

		// Goerli Testnet에서 체인링크 데이터를 받아올 수 있는 address를 Aggregator에 연결합니다.
		// Aggregator가 여러개의 오라클 노드와 연결하여 해당 자산의 가격 데이터를 집계합니다.
    constructor() {
				// BTC/USD
        BTCpriceFeed = AggregatorV3Interface(
            0xA39434A63A52E749F02807ae27335515BA4b07F7
        );
				// ETH/USD
        ETHpriceFeed = AggregatorV3Interface(
            0xD4a33860578De61DBAbDc8BFdb98FD742fA7028e
        );
				// LINK/USD
        LINKpriceFeed = AggregatorV3Interface(
            0x48731cF7e84dc94C5f84577882c14Be11a5B7456
        );
    }

     /**
     * Returns the latest BTC price.
     */
    function getLatestBTCPrice() public view returns (int) {
        (
            /* uint80 roundID */,
            int price,
            /*uint startedAt*/,
            /*uint timeStamp*/,
            /*uint80 answeredInRound*/
        ) = BTCpriceFeed.latestRoundData();
        return price;
    }

     /**
     * Returns the latest ETH price.
     */
    function getLatestETHPrice() public view returns (int) {
        (
            /* uint80 roundID */,
            int price,
            /*uint startedAt*/,
            /*uint timeStamp*/,
            /*uint80 answeredInRound*/
        ) = ETHpriceFeed.latestRoundData();
        return price;
    }

     /**
     * Returns the latest LINK price.
     */
    function getLatestLINKPrice() public view returns (int) {
        (
            /* uint80 roundID */,
            int price,
            /*uint startedAt*/,
            /*uint timeStamp*/,
            /*uint80 answeredInRound*/
        ) = LINKpriceFeed.latestRoundData();
        return price;
    }
}

Coingecko API Docs






2. Smart Contract Compile Error

지금 우리의 Dapp Smart Contract업그레이드 가능하며 팩토리 패턴을 가진다.

원래는 CDS라는 하나의 컨트렉트 안에서 각각의 계약정보는 Swap구조체안에 저장할려고 했었다. 하지만 스마트 컨트렉트의 볼륨이 커지고 복잡한 로직을 처리하기 위한 수많은 변수들이 생기며 아래와 같은 컴파일 Error메세지를 받아보게 되었다.

CompilerError: Stack too deep, try removing local variables

스마트 컨트렉트에 너무 많은 변수가 포함되면 만날 수 있는 에러다.
원래 계획했던 대로 하나의 컨트렉트 안에서 모든 계약정보를 저장할 수 없는 문제상황에 마주했다.


그래서 해결책으로 제시된 방법이 스마트 컨트렉트의 팩토리 패턴이다.

스마트 컨트렉트를 언론이나 책을 통해 접한 사람들은 스마트 컨트렉트가 '불변하지 않는 데이터 정보'라고 생각한다. 반은 맞고 반은 틀린 말이다. 여러개의 스마트 컨트렉트를 유기적으로 잘 연결한다면 스마트 컨트렉트에 저장되는 변수나 데이터, 로직을 바꿀 수 있다.


🧩 문제해결

우리가 저장하고자하는 CDS는 Buyer Seller의 계약정보와 Premium납부 기능 등 많은 양의 변수와 복잡한 로직을 처리하기 때문에 하나의 컨트렉트에 모든 정보와 로직을 저장할 수 없다. 때문에 팩토리 패턴을 통해 각 컨트렉트의 역할을 분리시키고 유기적으로 연결한 후 모든 로직이 처리되도록 재설계했다.

Reference 1
Reference 2






3. Database

문제점이라기보다 왜 noSQL이 아니라 mySQL을 DB로 선택했는지에 대한 기록이다.

  • mySQL이 noSQL보다 쓰기연산이 느릴지라도 우리 프로젝트의 데이터를 감당하지 않을 만큼 느리지는 않다.
  • 금융데이터다보니 정확성과 무결성이 중요하다. 때문에 mySQL이 적합하다, mySQL이 쓰기 연산을 정확하게 구현한다 그 과정이 느릴 지라도 블록체인이 느리기 때문에 큰 문제가 되지 않는다.





4. API통신의 대용량 데이터 처리



Client앱은 현재 발행된 CDS가 누구에 의해 제안되었는지 보여준다.

Client에서 web3.js를 통해 Smart Contract로 데이터를 전송하면
Backend에서는 Smart Contract를 블록리스너로 지켜보다 특정 event가 발생하면 해당 log데이터를 Backend DB에 저장한다.
이렇게 저장된 Backend데이터는 Axios API통신을 통해 Client로 가벼온 뒤 User에게 보여진다.


처음 프론트엔드에서 페이지네이션을 구현할려 했을 때 모든 CDS정보를 API통신을 통해 받아온 다음(예를 들어 100개의 CDS계약이 생성되었다면 100개의 데이터를 모두 받아온다) Client에서 조건에 따라 필터링을 진행한 뒤 User에게 인터페이스를 제공할려했다.

실제로 처음엔 코드도 위와 같은 방식으로 작성했다.
하지만 문득 10,000명의 사용자, 1,000,000명의 사용자가 우리의 Dapp을 이용한다면 어떤 일이 생길지 고민이되었다.
즉, 대용량 트래픽이 발생했을 때 클라이언트가 무리없이 잘 작동할 수 있을까? 라는 질문이 떠올랐다.
결론은 아니었다.
극단적으로 Smart Contract에 존재하는 CDS 갯수가 1억개라면 Client는 페이지네이션을 구현하기 위해 한번에 1억개의 API데이터를 받아오고 이것을 필터링 해야한다. API데이터를 받아오고 Client에서 필터링하는 과정에서 무리가 갈것이고 낮은 User Experience를 제공할 수 밖에 없다.


🧩 문제해결

때문에 백엔드와 협업하여 백엔드 서버에서 필터링 한 뒤 Client에서는 페이지를 클릭할 때마다 필터링에 알맞는 API요청을 받아오는 구조로 바꿨다.






5. prompt 값을 post요청에 담아 백엔드 서버로 보내기

Clinet의 Mypage는 이름 변경기능, Email변경 기능을 제공한다.
이름과 Email은 Client에서 Backend서버로 post 요청에 담아 보내면 DB User테이블에 저장된다.


🧩 문제해결

닉네임이나 email을 변경하기 위해서 post요청 보낼때 prompt로 값을 입력받아서 서버에 넘겨줄려했다. 하지만 propmt에 값을 입력할 때 cancle을 누르거나 empty value(빈 값)를 입력하면 페이지의 닉네임이나 이메일이 빈칸으로 나타나는 문제가 발생했다.

이러한 문제는 prompt value에 빈 값이 들어오거나, cancle을 누르는 경우를 if문으로 분기처리해 해결했다.


⌨️ Code

  // User가 서버로 post요청을 보낼 데이터를 저장합니다
  const [emailChange, setEmailChange] = useState('');
  const [nickNameChange, setNickNameChange] = useState('');


  // 사용자가 prompt에 empty value를 입력하거나 cancle을 눌렀을 경우 분기처리합니다
  function emailClick() {
    let promptValue = prompt('Enter the email you want to change.');
    if (promptValue === '') {
      // user pressed OK, but the input field was empty
    } else if (promptValue) {
      // user typed something and hit OK
      setEmailChange(promptValue);
    } else {
      // user hit cancel
    }
  }

  function nickNameClick() {
    let promptValue = prompt('Enter the username you want to change.');
    if (promptValue === '') {
      // user pressed OK, but the input field was empty
    } else if (promptValue) {
      // user typed something and hit OK
      setNickNameChange(promptValue);
    } else {
      // user hit cancel
    }
  }





6. React 첫번째 랜더링시 useEffect막기

Mypage가 처음 랜더링 될 때, post요청을 보내지 않았음에도 코드에 입력했던 post요청이 보내지는 Error가 존재했다.
나는 아래의 useEffect가 userAddress라는 변수가 변화할 때만 작동하기를 원했다.
때문에 useEffect의 useEffect dependency array에 userAddress라는 변수 값을 넣어줬다.

// apis
import { getSwapByAddress } from '../apis/request';

  useEffect(() => {
      const APIdata = getSwapByAddress(userAddress);
      const getData = () => {
        APIdata.then((response) => {
          return response.swaps;
        }).then((data) => {
          const pendingFiltered = data.filter(
            (swap) => swap.status === 'pending',
          );
          const activeFiltered = data.filter(
            (swap) => swap.status === 'active',
          );

          setPendingSwaps(pendingFiltered);
          setActiveSwaps(activeFiltered);
        });
      };
      getData();
  }, [userAddress]);



🧩 문제해결

하지만 userAddress값이 변하지 않았는데 페이지가 첫 랜더링 될 때 위의 코드가 실행되었다.

useEffect가 종속성 배열에 관계없이 초기 랜더링될 때 실행된다는 사실을 몰랐던 것이다.

이 문제를 해결하기 위해 useRef를 사용했다.

useRef는 랜더링에 관계없는 전체 참조값을 제공한다.
이를 활용해서 첫 랜더링시 post요청이 실행되지 않도록 useRef와 if문을 통해 분기처리를 해준다. 이렇게 되면 이후에는 useEffect의 종속성 배열 값이 변화할 때만 useEffect가 작동할 것이다.


⌨️ Code

  // useEffect의 첫번째 랜더링을 막기 위한 상태를 저장합니다
  const isMountedEmail = useRef(false);
  const isMountedNickname = useRef(false);



  // Email post요청을 보냅니다
  useEffect(() => {
    if (isMountedEmail.current) {
      const postData = () => {
        postEmailData(emailChange).then((response) => {
          setEmail(response.data.email);
        });
      };

      postData();
    } else {
      isMountedEmail.current = true;
    }
  }, [emailChange]);



  // Nickname post요청을 보냅니다
  useEffect(() => {
    if (isMountedNickname.current) {
      const postData = () => {
        postNicknameData(nickNameChange).then((response) => {
          setNickName(response.data.nickname);
        });
      };

      postData();
    } else {
      isMountedNickname.current = true;
    }
  }, [nickNameChange]);

Reference

profile
Notorious

0개의 댓글