코즘와즘 101 와씀 1편: 카운터 콘트랙트 톺아보기
이 기사를 공유합니다
DSRV
DSRV 2022년 8월15일 12:10
출처=Ilya Pavlov/Unsplash
출처=Ilya Pavlov/Unsplash

DSRV 개발자 길드(Dev Guild)에서는 더 많은 개발자들과 Web3 인프라를 만들어가기 위해, 다양한 메인넷과 스마트계약에 대한 개발자 콘텐츠를 연재합니다.

 

[코즘와즘 101 시리즈]

 

1. 카운터 콘트랙트 톺아보기

2. 카운터 콘트랙트 배포하기 (클리프넷, 테라 테스트넷)

3. 프런트엔드와 통신하기



What is WebAssembly?

웹 어셈블리어(Wasm, 와즘)는 C++, 러스트, 코틀린와 같이 다양한 프로그래밍 언어를 활용하여 컴파일이 가능한 바이트코드 포맷입니다. 바이트코드가 가상머신 실행환경에서 동작함에도, 웹 어셈블리어는 네이티브 코드 수준의 속도를 자랑할 수 있다는 점에서 각광받고 있습니다.

와즘은 모질라 재단에서 2015년부터 개발하고 있는 기술로, 2017년에 처음 발표되었고 현재는 월드 와이드 웹 컨소시엄(W3C)에서 웹 표준으로서 개발되고 있습니다.

이로 인해 ‘웹’은 이제 '자바스크립트/타입스크립트'라는 고수준 언어 외에도 와즘이라는 저수준 언어도 사용할 수 있다는 점에서 큰 의미를 갖게 되었습니다.

와즘이 등장한 배경은 모바일 시대가 대두된 것으로부터 출발합니다. 프로그램을 작성할 때 단순히 PC만 지원해야 하는 것이 아니라, 스마트폰과 태블릿 PC 등 다양한 기기를 지원해야 합니다.

개발자들은 코드를 한 번만 작성하면 다양한 기기에서 동작할 수 있기를 바랐고, 웹 브라우저 위에서 동작하는 고수준 언어인 자바스크립트가 이러한 염원을 풀어주었습니다.

자바스크립트 생태계가 확장되면서 인공지능(AI) 모델이나 웹 그래픽스 라이브러리(WebGL)와 같은 게임 엔진도 웹 브라우저 위에서 구동시키려는 움직임이 나타났습니다.

하지만 AI 모델이나 게임 엔진은 워낙 무거워 일반적인 자바스크립트 엔진 위에서 동작시키는 것이 어려웠습니다. 와즘은 이러한 기술적 요구 속에서 등장했습니다. 와즘은 웹 브라우저 상에서 동작하는 저수준 언어(low-level)로서, 높은 속도와 최적화 레벨을 자랑한다는 특징이 있습니다.





그렇다면, 블록체인에서 와즘은 왜 사용돼야 할까요?

최초의 스마트계약 개념을 적용한 이더리움은 바이트코드를 컴파일하는 과정에서 이더리움 가상 머신(EVM)이라는 가상 머신을 사용합니다. 그리고 솔리디티(Solidity)는 EVM 엔진에서 실행할 수 있는 바이트코드로 컴파일할 수 있는 고수준 프로그래밍 언어이며, 2022년 기준 압도적으로 많이 사용되고 있는 언어입니다.

2022년 4월14일 기준 각 언어별 스마트계약에 예치돼 있는 총 토큰 가치 비율. 출처=더블록, 디파이라마
2022년 4월14일 기준 각 언어별 스마트계약에 예치돼 있는 총 토큰 가치 비율. 출처=더블록, 디파이라마

하지만 블록체인 인프라의 대중화가 성큼 다가오면서 새로운 가상머신 엔진이 등장할 필요성이 대두되고 있습니다. 이는 EVM과 솔리디티가 갖고 있는 한계 때문인데, 이를 고려했을 때 와즘은 EVM에 비교하여 상대적인 장점들을 갖고 있습니다.

 



1. 와즘은 더 많은 언어를 수용합니다.

이더리움 계열의 솔리디티는 일반 개발자들에게 낯선 언어이므로 새롭게 학습해야 한다는 점에서 생태계의 확장을 저해하는 요소가 되고 있습니다. 하지만, 와즘은 저수준 가상 머신(LLVM)이라는 컴파일러 엔진 위에서 만들어진 언어라면 모두 사용할 수 있습니다. 즉 앞서 언급했듯이, C++이나 러스트, 코틀린 등을 활용하여 와즘으로 콘트랙트를 작성할 수 있다는 뜻이죠.

 

2. 와즘은 보다 안전합니다.

스마트계약이 디파이(DeFi, 탈중앙화금융)에서 굉장히 활발히 쓰인다는 것을 고려한다면, 언어 수준에서 높은 수준의 보안성과 테스트 편의성을 제공해야 합니다. 솔리디티는 이 점에서 기존 언어 수준에 미달하는 모습을 보여주고 있지만, 코즘와즘은 솔리디티에 비해 상대적으로 우수한 보안성을 갖춰 디파이 등에 사용하는 데 유리합니다. 예를 들어, 코즘와즘은 콘트랙트에 대한 동기적 실행 자체를 엄격히 금지시킴으로써, 재진입 공격을 방지하고 있습니다.

 

3. 와즘은 빠른 속도를 자랑합니다.

스마트계약의 사용도가 높아지면서 바이트코드의 연산 속도를 끌어올릴 필요성이 대두되고 있습니다.

 

현존하는 V8 엔진이 와즘 기반 가상머신 바이트코드를 네이티브 수준의 속도를 제공합니다. 또한, 와즘은 메모리 관리가 상대적으로 안전하다는 점과 각 콘트랙트 별로 격리된 샌드박싱 실행 환경을 제공한다는 점도 주목할 만합니다. 웹 어셈블리어는 아직 초기 기술이지만, 피그마가 웹 버전에 접목하는 등 다양한 사용 예시가 등장하고 있습니다.

물론 비판도 적지 않습니다.

블록체인 계열에서는 일부 국소적인 단위에서 성능 최적화를 위하여 비결정론적 상황 (non-deterministic) 을 허용하고 있다는 측면에서, 모든 노드에서 동일한 코드를 실행했을 때 동일한 결과를 보장해야 하는 결정론적 전제 (deterministic)를 해칠 수 있지 않느냐는 우려의 목소리도 있습니다.

또한 블록체인 가상 머신으로써 와즘을 도입하는 것이 여전히 초기 단계이다 보니, EVM과 속도를 비교했을 때 기대했던 것만큼의 성능 향상이 보이지 않는다는 비판도 있습니다.

하지만 이러한 비판에도 불구하고, 스마트계약의 사용도가 점점 높아짐에 따라 블록체인 가상머신의 속도 향상이 불가피하다는 것은 자명한 사실이기 때문에 앞으로 웹 어셈블리어를 주목해 볼 필요가 있습니다.



코즘와즘(CosmWasm)

코스모스 SDK 위에서 웹 어셈블리어 바이트코드를 실행할 수 있는 모듈을 코즘와즘(CosmWasm)이라고 부릅니다. 현재 코즘와즘을 활용하여 콘트랙트 실행환경을 구축한 대표적인 네트워크로는 테라, 오스모시스(테스트넷), 시크릿 네트워크, 주노 네트워크, 클리프넷(테스트넷) 등이 있습니다.

이번 글에서는 코스모스 SDK 위에 웹 어셈블리어 바이트코드를 실행할 수 있는 모듈인 코즘와즘 위에서 간단한 콘트랙트를 작성하면서 코즘와즘 코드를 기초적인 수준에서 이해하고자 합니다.

 

솔리디티 콘트랙트와 배포 과정의 차이

개발자가 스마트계약을 배포하는 과정을 한 번 나누어보겠습니다.

출처=DSRV 미디엄
출처=DSRV 미디엄

1. 컴파일된 바이트코드의 업로드

 

2. 새로운 콘트랙트를 인스턴스화 (instantiate)

 

3. 콘트랙트의 실행

 

이더리움에서는 상기한 과정 중 1번과 2번 과정이 서로 통합되어 있는데, 콘트랙트가 배포될 때 constructor()가 호출됩니다.

이더리움의 모든 계정은 balance, code, storage, nonce로 이루어지는데, 콘트랙트의 바이트코드는 콘트랙트 자체 계정의 상태(state)에 저장되는데요.

따라서 web3.eth.getCode(someAddress) 요청을 보내면 someAddress가 보유하고 있는 바이트코드를 불러올 수 있습니다.

그래서 서로 다른 콘트랙트가 동일한 .sol을 컴파일 하였더라도, 동일한 바이트코드를 서로의 상태(state)에 각각 저장하고 있는 꼴이 됩니다.

이러한 설계는 풀 노드의 레벨 데이터베이스(LevelDB) 용량을 낭비할 뿐만 아니라 콘트랙트를 업그레이드(upgradable contract)하는 것도 어렵게 만듭니다.

코즘와즘은 콘트랙트 코드를 업로드하는 과정과 새로운 콘트랙트를 초기화하고 인스턴스화 시키는 과정을 서로 분리하였습니다.

이러한 설계는 서로 다른 콘트랙트가 하나의 바이트코드를 공유할 수 있도록 하였습니다. 콘트랙트를 인스턴스화하기 위해서는 기존에 업로드된 바이트코드의 ID를 알고 있어야 합니다.

InstantiateMsg라는 설정값을 제이슨(JavaScript Object Notation, JSON) 형태의 인자로 던져 새로운 콘트랙트를 생성하고 초기 상태를 설정할 수 있습니다.

코즘와즘에서 콘트랙트를 작성한다는 것은 크게 instantiate()와 execute() 그리고 query() 함수를 구현한다는 의미입니다.

이 중에서 execute()는 콘트랙트의 내부 상태를 바꾸는 트랜잭션을 핸들링하는 메소드이고, query()는 콘트랙트의 내부 상태에 쿼리를 던지는 트랜잭션을 핸들링하는 메소드입니다.

당연하겠지만 상태를 변화시키는 execute()는 별도의 가스비(gas fee)를 지불해야 하며, 상태 조회가 목적인 query()의 경우 가스비를 지불하지 않습니다.

다음 장에서 count라는 콘트랙트 내부 변수의 숫자를 +1하거나 -1하면서 상태를 변경할 수 있는 코드를 작성해 보며 코즘와즘에 대해 더욱 이해해 보도록 하겠습니다.

사전 요구사항

 

아래 코드를 이해하려면 다음의 선수 지식이 필요해요!

1. 아래의 솔리디티 수도코드를 읽고, 코드의 동작을 이해할 수 있어야 해요.

2. 러스트의 기초적인 문법을 이해해야 합니다. 공식 문서에서 1-4장 내용을 사전에 보고 오시면 좋습니다.

카운터 예시

솔리디티 수도코드(Pseudocode)로 위의 요구사항을 간단하게 구현해 보면 다음과 같습니다.

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

contract TestCounter {
    int private count = 0;

    constructor(uint256 _count) {
      count = _count;
    }

    function increment() public {
        count += 1;
    }

    function reset(uint256 _count) public {
        count = _count
    }

    function getCount() public view returns (int) {
        return count;
    }
}

같이 구현해 볼 프로그램의 요구사항은 다음과 같습니다.

 

1. count라는 내부 변수를 정수 데이터 타입으로 선언한다. 콘트랙트가 인스턴스화되면, count에 페이로드(payload)로 명시한 값으로 초기화된다.

2. increment()라는 함수가 있어, 이 메소드를 실행하면 count 변수가 +1씩 증가한다.

3. 콘트랙트를 최초 배포한 사용자가 콘트랙트의 주인이 된다. reset()이라는 함수가 있어, 콘트랙트의 소유자는 count를 페이로드(payload)에 명시된 값으로 초기화하는 것이 가능하다.

4. getCount() 함수를 실행하면, 콘트랙트는 현재 count 변수의 값을 반환한다.

 

이더리움 생태계의 오픈제플린(OpenZeppelin)과 같이, 코즘와즘을 활용한 콘트랙트 라이브러리를 제작하고 있는 팀인 인터와즘은 cw-template 저장소에서 보일러플레이트 스타터 팩(Boilerplate Starter Pack)을 제공하고 있습니다.

해당 저장소에서 cargo-generate를 통해 설치하면 컴파일 준비가 된 콘트랙트 코드를 맞이할 수 있습니다. 해당 보일러플레이트 코드는 상기한 요구사항을 이미 구현하고 있습니다.



콘트랙트 초기화

다음 명령어를 통해 콘트랙트를 로컬 저장소에 클론(clone) 받습니다. 저희가 마련한 동일한 예제 코드 다음의 저장소에서 확인하실 수 있습니다.

cargo generate --git https://github.com/CosmWasm/cw-template.git -- 

branch 0.16 --name my-first-contract 

cd my-first-contract

먼저 테스트 코드를 분석하면서 요구사항이 어떻게 구현되어 있는지 살펴보려고 합니다. src/contract.rs 파일에 들어가서 mod tests 부분을 찾아보면 테스트 코드를 발견할 수 있습니다.

테스트는 크게 1) 콘트랙트 초기화, 2) 카운트(count)의 증가, 3) 카운트(count)의 초기화, 이렇게 세 부분으로 구성되어 있는데요, cargo test를 실행하여 테스트를 실행해 보면 총 4개의 테스트가 성공적으로 수행되게 됩니다.

먼저, proper_initalization 함수를 살펴보려고 합니다. 코즘와즘은 유닛 테스트를 위하여 별도의 모킹(mocking) 라이브러리를 지원하는데, 그중 하나가 mock_dependencies_with_balance입니다. 이 메소드는 쿼리(Query)를 날리는 임의의 사용자 계정을 만드는데, 이 계정은 token이라는 이름의 코인을 2개 가지고 있습니다.

그리고, mock_info라는 가상의 토큰 송금 페이로드 정보를 만듭니다.

creator라는 이름의 전송자(sender)가 콘트랙트 초기화 시에 earth라고 하는 가상의 코인 1000개를 보내라는 메시지입니다. 콘트랙트 초기화 시에 count: 17이라고 메시지를 명시하여 초기화 함수에 보냅니다.

이를 통해 우리는 콘트랙트의 count 값이 17로 초기화될 것이라 기대할 수 있습니다. contract.rs에서 instantiate() 함수를 실행시켜 새로운 콘트랙트를 생성하고, 쿼리(query)를 보낼 때 사전에 구현된 GetCount()라는 함수를 사용하여 count 값을 호출합니다.

이때 assert_eq를 통해 콘트랙트의 값이 17로 초기화가 잘 되었는지 확인하기 위해 값을 비교해 봅니다.

#[test]
    fn proper_initialization() {
        let mut deps = mock_dependencies_with_balance(&coins(2, "token"));

        let msg = InstantiateMsg { count: 17 };
        let info = mock_info("creator", &coins(1000, "earth"));

        // we can just call .unwrap() to assert this was a success
        let res = instantiate(deps.as_mut(), mock_env(), info, msg).unwrap();
        assert_eq!(0, res.messages.len());

        // it worked, let's query the state
        let res = query(deps.as_ref(), mock_env(), QueryMsg::GetCount {}).unwrap();
        let value: CountResponse = from_binary(&res).unwrap();
        assert_eq!(17, value.count);
    }

이제 콘트랙트의 초기화 과정에 대해 살펴볼까요? 콘트랙트를 instantiate 하기 위해서는 외부에서 어느 타입의 메시지를 어떤 형태로 보내주어야 한다는 것을 명시해야 합니다. 또한, 웹에서의 컨트롤러(Controller) 패턴처럼 어떤 타입의 메시지를 받았을 때 어느 메소드로 넘겨줄지 비즈니스 로직을 작성해야 합니다.

코즘와즘에서는 InstantiateMsg라는 메시지 타입을 구조체로 정의해야 합니다. 요구사항에는 초기 count 값이 메시지에 명시되어야 하므로, 다음과 같이 InstantiateMsg를 작성하면 됩니다.

아래 InstantiateMsg 구조체는 src/msg.rs에 작성되어 있습니다. InstantiateMsg는 개발자가 콘트랙트 초기화를 시도할 때 코즘와즘의 구현체인 wasmd의 MsgInstantiateContract로 전달되며, 해당 메시지 구조체 내부에 있는 초깃값을 활용하여 콘트랙트의 상태가 초기화됩니다.

이 과정이 굉장히 인상적일 수 있는데요, 그 이유는 이더리움과는 달리 복수의 콘트랙트가 공통의 코드 베이스를 공유하면서도 서로 다른 파라미터의 값으로 초기화될 수 있다는 점 때문입니다.

예를 들어, 동일한 ERC20.sol의 코드 베이스를 참조하는 복수의 ERC-20 토큰 콘트랙트가 있고 각각의 콘트랙트는 티커(symbol)와 발행량(totalSupply)을 다르게 초기화할 수 있는 것입니다.

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct InstantiateMsg {
    pub count: i32,
}

다음으로, 초기화 함수를 정의합시다. 초기화 함수는 InstantiateMsg 가 호출하는데, 하기 코드를 보고 간략히 설명해 보겠습니다.

// src/contract.rs

#[cfg_attr(not(feature = "library"), entry_point)]
pub fn instantiate(
  deps: DepsMut,
  _env: Env,
  info: MessageInfo,
  msg: InstantiateMsg,
) -> Result<Response, ContractError> {

먼저, 초기화 함수가 받아내는 파라미터를 살펴보도록 합시다. DepsMut는 다른 언어에서 Context와 비슷한 의미인데요. DepsMut를 통해 다른 콘트랙트를 호출할 수 있는 API 및 Querier를 사용할 수 있으며, 현재 주소에 명시된 Storage에 접근하여 읽기 및 쓰기 작업을 수행할 수 있습니다.



  • 여기서 Storage는 콘트랙트에 영구히 저장되는 메모리 공간을 이야기합니다. 개발자는 처음으로 콘트랙트를 배포할 때 Storage의 스키마(schema)를 명시할 수 있는데, 이러한 스키마를 State라고 부릅니다. State에 대한 설명은 아래에서 좀 더 상세히 설명해 보도록 하겠습니다.

  • 다음으로, API는 코즘와즘 모듈 밖에서 정의된 시스템 함수를 이야기합니다. 공식 문서를 참고해 보면 여러 크립토그래픽 헬퍼 함수(cryptographic helper functions)가 정의되어 있는데, 대표적으로는 콘트랙트 주소를 변환하는 addr_canonicalize() 같은 함수가 있습니다.

  • Querier는 외부의 사용자가 콘트랙트를 향해 쿼리(query)를 던질 때 해당 요청(request)를 파싱해서 쿼리 요청을 처리하는 구조체입니다. 필요에 따라서 커스텀 쿼리 타입을 구현할 수도 있습니다.



그다음으로, Env는 block과 콘트랙트와 관련된 ContractInfo 정보가 담겨 있습니다. block은 height, time, chain_id를 명시하고 있으며, ContractInfo에는 콘트랙트 주소 자체만을 담고 있는 구조체입니다.

MessageInfo에는 sender의 주소와 sender가 콘트랙트 초기화 요청 시 함께 보낸 네트워크 네이티브(native) 토큰을 몇 개 보냈는지에 대한 정보가 명시되어 있습니다.

let state = State {
    count: msg.count,
    owner: info.sender.clone(),
};

이제 콘트랙트의 Storage 스키마를 결정짓는 State 구조체를 초기화합니다. State 구조체는 src/state.rs에 명시되어 있는데, 해당 구조체의 코드를 살펴보면 다음과 같습니다.

아래 코드에는 count라고 하는 32비트 정수형 타입(integer type)이 명시되어 있고, owner라고 하는 콘트랙트 주소가 있습니다. cosmwasm_std와 cw_storage_plus를 import 하는 코드가 상위에 보이는데, 이를 통해 Cosmwasm에서 지원하는 자료구조와 데이터 타입을 사용할 수 있습니다.

한 가지 눈에 띄는 점은 Serde를 통하여 serialize와 deserialize도 지원하는 점입니다. serialize를 통하여 to_string 메소드를 사용할 수 있습니다.

이 메소드는 주어진 object를 string 타입으로 변환하는 역할을 수행합니다. 마치 자바스크립트에서 JSON.stringify 과정을 통해 주어진 제이슨 파일을 string 타입으로 직렬화하는 과정을 생각해 보면 쉽게 이해하실 수 있습니다.

use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

use cosmwasm_std::Addr;
use cw_storage_plus::Item;

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct State {
    pub count: i32,
    pub owner: Addr,
}

pub const STATE: Item<State> = Item::new("state");

다음으로, 우리가 정의한 상태(State)를 아이템(Item) 타입으로 감싸준 다음에 export하는 코드를 살펴보고자 합니다. 코즘와즘은 Storage에 저장하는 자료구조로 아이템(Item)과 맵(Map)을 제공합니다.

아이템(Item)은 단일 원소를 저장할 때 사용하지만, 맵(Map)은 여러 개의 원소를 키-값(key-value) 형태로 저장할 때 사용합니다. Map은 Solidity의 mapping 타입을 통해 직관적으로 이해해 볼 수 있습니다. 아이템(Item)과 맵(Map)에 대한 자세한 내용은 추후 작성해 보도록 하겠습니다.

위에서는 아이템(Item)이라는 단일 원소를 저장하는 자료구조를 통해 State를 저장했으며, 해당 상태(State)의 storage key로 state라고 정의했습니다.

set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?;
STATE.save(deps.storage, &state)?;

다시 초기화 함수로 돌아와보면, 코즘와즘은 모든 콘트랙트가 업그레이드 가능하다는 것을 전제하고 있으므로, 이 콘트랙트의 버전이 어느 버전인지 명시하도록 하기 위해 contract_version을 할당할 수 있습니다.

이어서는 콘트랙트를 생성할 때 초기화할 state 인스턴스를 STATE에 저장하는 것을 확인할 수 있습니다.

STATE는 state 구조체를 감싸고 있는 Item이라는 프락시 객체라고 생각하면 되는데, 이 Item 객체는 콘트랙트 Storage에 state 구조체를 저장하는 함수를 실행합니다.

DSRV’s Tip: 프락시 패턴이 무엇일까요?

프락시 패턴은 어떤 다른 객체로 접근하는 것을 통제하기 위해서 그 객체의 대리자(surrogate)나 자리표시자(placeholder)의 역할을 하는 객체를 제공하는 패턴입니다.

다시 말해, 프락시 객체는 원래 객체를 감싸고 있는 객체인데, 원본 객체의 접근을 제어하고 싶거나, 부가 기능을 추가하고 싶을 때 사용합니다.

예를 들어, 스프링(Spring) JPA(Java Persistence API)에서는 지연 로딩을 구현하기 위해서 프락시 패턴을 사용합니다. 게시글에 댓글을 작성하는 기능을 구현한다고 생각해 보겠습니다.

스프링 JPA는 내부적으로 개발자가 만든 아티클(Article)의 프락시 객체를 생성하고 이 프락시 게시글 객체는 댓글에게 마치 자신이 진짜 게시글 객체인 것처럼 행동하는데요, 이 프락시 객체는 게시글 데이터가 필요한 시점에 데이터베이스에서 게시글 정보를 가져오도록 할 수 있는 초기화 기능을 가지고 있습니다.

이번 예시에서는 state 구조체에 Item 자료구조가 지원하는 공통 부가 기능을 추가하고 있습니다. 그리고 state 구조체와 메시지를 주고받기 위해서는 state를 감싸고 있는 Item 클래스로 접근하게 됩니다. 이 과정이 프락시 패턴과 유사합니다.

이 과정을 통해 우리가 정의한 state 구조체의 형태로 콘트랙트가 초기화되고 콘트랙트 Storage에 값을 저장하게 됩니다.

Ok(Response::new()
        .add_attribute("method", "instantiate")
        .add_attribute("owner", info.sender)
        .add_attribute("count", msg.count.to_string()))

마지막으로, return할 Response 객체를 구현해야 합니다. 이 Response 객체는 먼저 비어있는 상태로 초기화되어 있는데, add_attribute를 통해 attribute를 추가하면서, 반환값을 추가로 명시할 수 있습니다.

위의 코드에서는 방금 실행한 메소드의 이름(“method”)과 콘트랙트 보유자 주소(“owner”), 그리고 콘트랙트 Storage에 있는 count 정숫값("count")을 반환하고 있습니다.



콘트랙트 함수의 실행

앞서 초기화 함수에서 살펴보았던 것처럼, 사용자가 어떤 메시지 객체를 보내오면, 콘트랙트의 어느 함수가 실행될지 명시해야 합니다. 상기 요구사항에 따르면 increment 실행과 reset 실행 총 두 가지 실행이 가능합니다.

콘트랙트가 실행 가능한 경우는 여러 가지일 것이므로, ExecuteMsg는 enum 타입으로 정의해야 합니다. 쿼리를 날리는 메시지와는 달리, ExecuteMsg는 콘트랙트의 상태를 바꾸는 목적이 있으므로 가스비가 필요합니다.

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ExecuteMsg {
    Increment {},
    Reset { count: i32 },
}

이제 ExecuteMsg를 받아주는 execute 함수를 구현해 보려고 합니다. match는 switch와 동일한 역할을 수행하는데, 들어온 요청을 increment 함수와 reset 함수로 분기해 주는 역할을 수행합니다.

#[cfg_attr(not(feature = "library"), entry_point)]
pub fn execute(
    deps: DepsMut,
    _env: Env,
    info: MessageInfo,
    msg: ExecuteMsg,
) -> Result<Response, ContractError> {
    match msg {
        ExecuteMsg::Increment {} => try_increment(deps),
        ExecuteMsg::Reset { count } => try_reset(deps, info, count),
    }
}

먼저, ExecuteMsg가 Increment일 때 실행되는 try_increment를 살펴봅시다. 특이한 점은 try_increment의 파라미터로 storage에 대한 mutable 참조를 담아내는 deps의 소유권을 넘긴다는 점입니다.

pub fn try_increment(deps: DepsMut) -> Result<Response, ContractError> {
    STATE.update(deps.storage, |mut state| -> Result<_, ContractError> {
        state.count += 1;
        Ok(state)
    })?;

    Ok(Response::new().add_attribute("method", "try_increment"))
}

상기 코드에는 소유권을 넘겨받은 deps를 활용하여 storage에 접근하고, STATE라는 Item 객체에 접근하여 해당 deps에 저장된 state를 불러와 새로운 상태로 update하고 있습니다.

STATE는 내부적으로 storage에 접근해 state 정보를 불러오고 이를 State 구조체로 mapping하는 역할을 수행합니다.

만약, storage에 저장된 item이 없었다면 에러를 반환합니다. STATE.update는 인자로 “클로저”를 받습니다.

위의 코드를 살펴보면 클로저가 state.count 값에 1을 더하는 로직을 구현하고 있음을 알 수 있습니다. 참고로, 자바스크립트에 익숙한 독자라면 “클로저”를 리듀서(reducer)와 같다고 생각하면 이해가 편할 것입니다.

pub fn update<A, E>(&self, store: &mut dyn Storage, action: A) -> Result<T, E>
where
    A: FnOnce(T) -> Result<T, E>,
    E: From<StdError>,

Item.rs에 선언된 update 함수를 살펴보면 다음과 같습니다. 즉, 클로저를 준비해 주면 단 한 번만 그 클로저를 실행할 수 있도록 하고 FnOnce(), 이후에는 해당 클로저를 메모리에서 제거하게 됩니다.

유의할 점은 클로저 내부에서 소유권을 받아낸 객체가 있을 경우 단 한 번만 클로저가 실행 가능하며, 두 번 이상 실행하려는 경우 컴파일러가 사전에 에러를 반환한다는 것입니다. 러스트(Rust)의 “클로저”에 대한 깊이 있는 이해는 추후 Item과 Map을 설명할 때 상세히 진행해 보도록 하겠습니다.

Reset 역시 비슷하게 작동합니다. 특이한 점은 오직 콘트랙트 소유자만이 count를 초기화할 수 있다는 점입니다.

따라서, 트랜잭션을 보낸 sender가 콘트랙트 소유자 주소인 state.owner와 다르면 에러를 반환합니다. ContractError를 반환할 때 별도의 Custom Exception으로 Unauthorized를 선언해 주었으며, 해당 exception은 error.rs에서 확인할 수 있습니다.

pub fn try_reset(deps: DepsMut, info: MessageInfo, count: i32) -> Result<Response, ContractError> {
    STATE.update(deps.storage, |mut state| -> Result<_, ContractError> {
        if info.sender != state.owner {
            return Err(ContractError::Unauthorized {});
        }
        state.count = count;
        Ok(state)
    })?;
    Ok(Response::new().add_attribute("method", "reset"))
}

콘트랙트 변수 쿼리하기

출처=쿠도스 미디엄
출처=쿠도스 미디엄

상술했다시피, 쿼리하는 메시지는 콘트랙트의 상태를 변경하지 않기 때문에 별도의 가스비를 지불할 필요가 없습니다. 쿼리를 하기 위해서는 쿼리의 종류와 반응(Response) 구조체를 설계해야 합니다. 아래를 예시를 통해 자세히 알아보도록 하겠습니다.

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum QueryMsg {
    // GetCount returns the current count as a json-encoded number
    GetCount {},
}

위와 같이 구현한다면, 콘트랙트는 프런트엔드의 요청 페이로드를 파싱해서 GetCount가 있는지 살펴봅니다. 만약 GetCount가 있다면, 해당 메시지를 파싱해서 QueryMsg의 인스턴스를 만들어냅니다.

이때 유의할 점은, serde 라이브러리를 사용했기에 프런트엔드 측에서는 CamelCase가 아니라 snake_case로 요청을 전송해야 한다는 점인데요, 이를 통해 각 언어의 표준에 맞추어 코딩할 수 있도록 구현했습니다.

자바 스프링에 익숙한 독자라면, 잭슨(Jackson) 라이브러리의 JsonProperty와 동일한 기능을 수행한다고 생각하면 쉽게 이해해 보실 수 있습니다. 따라서, 프런트엔드 측에서 넘기는 제이슨(JSON) 타입은 다음과 같습니다.

{
  "get_count": {}
}

이제 CountResponse를 살펴볼 차례입니다. Response에서는 count의 값을 알려주어야 하므로, 상기와 같이 count를 선언하고, 타입을 32비트의 정수 타입으로 정의하면 됩니다.

// We define a custom struct for each query response
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct CountResponse {
    pub count: i32,
}

마지막으로, query 기능을 구현하는 코드를 살펴보도록 합시다. query 함수는 QueryMsg에서 GetCount라는 함수가 호출되었을 경우, query_count로 실행 책임을 위임합니다. query_count는 count 값을 추출하기 위하여 Item 객체의 load 함수를 사용합니다.

해당 함수는 저장된 State 객체가 없을 경우 에러를 반환합니다. 만약 State 객체가 있다면 response를 query 함수에 StdResult<Binary> 형태로 반환합니다. 여기서 반환값이 Binary인 이유는 Deserialize를 하기 위함입니다.

특이한 점은 query 함수의 파라미터에 명시된 데이터 타입인 Deps는 변경할 수 없는 콘텍스트(immutable context)를 공유한다는 점입니다. 즉, 스토리지(Storage)에 읽기는 가능하지만 쓰기는 불가능합니다.

마치 솔리디티에서 읽기와 쓰기가 동시에 가능한 로우 레벨 코드인 call과 읽기 기능만 가능한 로우 레벨 코드인 staticcall의 차이와 유사하다고 볼 수 있습니다.

#[cfg_attr(not(feature = "library"), entry_point)]
pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult<Binary> {
    match msg {
        QueryMsg::GetCount {} => to_binary(&query_count(deps)?),
    }
}

fn query_count(deps: Deps) -> StdResult<CountResponse> {
    let state = STATE.load(deps.storage)?;
    Ok(CountResponse { count: state.count })



글을 마무리하며..

이번 시간에는 웹 어셈블리어(WebAssembly)가 무엇인지와 어떤 의미를 갖는지 알아보고, 실제로 코즘와즘을 활용한 간단한 콘트랙트인 카운터(Counter) 예제를 살펴보았습니다.

러스트(Rust)의 장점으로 인해 기존의 솔리디티에 비해 안전하게 코딩할 수 있다는 점, 메소드 별로 책임과 역할이 명확하게 분리되어 있어 유지 보수가 용이하다는 점을 느낄 수 있었습니다. 그러나 기존 솔리디티에 비하여 코드 자체가 과하게 장황(verbose))하다는 점은 단점으로 파악되었습니다.

다음 시간에는 오늘 작성한 콘트랙트 코드를 컴파일하여 생성한 와즘 파일을 활용하여, 클리프넷, 테라 테스트넷, 그리고 오스모시스 테스트넷에 각각 배포하는 과정을 설명해 보고자 합니다.

이 글이 웹 어셈블리어 공부를 시작하고자 했던 많은 개발자분들께서 코즘와즘에 쉽게 입문하는 데 도움이 되었길 바라며, 다음 글로 또 찾아오도록 하겠습니다. 이 글을 읽는데 귀중한 시간을 할애해 주셔서 감사합니다.

 

저자: 박진형 DSRV 데브 에반젤리스트

유의사항: 이 글은 정보 전달을 위한 목적으로 작성되었으며, 특정 프로젝트에 대한 투자 권고, 법률적 자문 등 목적으로 하지 않습니다. 모든 투자의 책임은 개인에게 있으며, 이로 발생된 결과에 대해 어떤 부분에서도 DSRV는 책임을 지지 않습니다. 본문이 포괄하는 내용들은 특정 자산에 대한 투자를 추천하는 것이 아니며, 언제나 본문의 내용만을 통한 의사결정은 지양하시길 바랍니다.

안녕하세요, 웹3 시대의 인프라를 만들어가는 DSRV입니다!

웹3 개발, 어디서부터 시작해야 할지 고민이셨나요? 지식 콘텐츠부터 실전 코딩까지 더 많은 개발자들과 탄탄한 웹3 인프라를 만들어가기 위해, DSRV는 다양한 메인넷과 스마트계약에 대한 가이드를 제공합니다.

정보를 찾기 어려워 쉽사리 시작하지 못했던 웹3 댑(DApp, 탈중앙화애플리케이션) 및 콘트랙트 개발, 이제 개발자 플레이그라운드(Dev Playground)와 함께 차근차근 알아가보아요.

이번 코즘와즘(CosmWasm) 101 시리즈는 간단한 클리커 게임(Clicker Game)을 만들기 위해 필요한 콘트랙트 설명, 배포, 리액트(React) 프런트엔드 연결까지 필요한 모든 내용을 실습을 통해 차근차근 이해할 수 있도록 구성되었습니다.

웹3와 블록체인에 관심이 많은 웹 개발자라면 누구나 함께할 수 있습니다.

 

더 자세한 내용은 '코인데스크 프리미엄'에서 읽을 수 있습니다.

제보, 보도자료는 contact@coindeskkorea.com



댓글삭제
삭제한 댓글은 다시 복구할 수 없습니다.
그래도 삭제하시겠습니까?
댓글 0
댓글쓰기
계정을 선택하시면 로그인·계정인증을 통해
댓글을 남기실 수 있습니다.