Write Once Deploy Anywhere
사실 나는 Cosmos 블록체인이 망할 줄 알았다.
처음 접했던 2–3년 전만 해도 텐더민트 합의 알고리즘 말고는 공부할 게 별로 없었고 IBC는 나온다는 말만 하고 보이지 않았다. 블록체인 간 상호운용성은 이종 블록체인 간 확정성(Finality) 시점이 다르기 때문에 실제 연동은 어려울 거라고 생각해서 관심을 끄고 있었다.
그러다가 최근 몇년간 테라 생태계가 급성장 하고 Keplr, Osmosis 등의 여러 플레이어들이 코스모스 생태계에 편입되는 것을 보면서 코즘워즘을 공부해봤는데 이 녀석.. 생각보다 괜찮다. 솔라나와 이더리움 롤업 때문에 시끄러운 블록체인 씬에서 우직히 조용히 갈 길 갔던 친구였다. 우선 인터페이스 디자인이 깔끔해서 러스트 코드를 조금 안다면 이해하는데 큰 어려움이 없었다. 이번에 코즘워즘의 공식 문서를 보면서 단순히 또 다른 버전의블록체인 X가 아닌, 원래 코스모스가 풀려고 했던 문제인 블록체인 간 상호운용성에 대한 고민을 볼 수 있었다. 코스모스 블록체인에 큰 관심은 없더라도 Rust를 공부하는 김에 한 번 보는 것도 개발자에겐 나쁘지 않을 것 같다.
Table of contents
- Write Once Deploy Anywhere
어디에서나 쓰일 수 있는 스마트 컨트랙트 SDK - 액터 모델(Actor Model)과 코즘워즘
- 코드로 보는 코즘워즘 구성 요소
- 레퍼런스
Write Once Deploy Anywhere
대한민국 한정, 가장 많이쓰이는 Java는 초창기에는 Write Once Run Everywhere를 모토로 한 번 작성한 코드는 OS에 종속되지 않고 어디에서든 실행 가능하도록 해서 인기를 끌었다. 코즘워즘도 비슷하게 한 번 작성된 컨트랙트 코드는 어떤 블록체인에서도 작동할 수 있는 환경을 구축했다.
Wasmvm 라이브러리는 코즘워즘으로 컴파일된 .wasm 파일을 Go언어로 실행할 수 있도록 한다. 이를 이용해서 Go언어로 작성된 어떤 블록체인에서라도 코즘워즘 컨트랙트 코드를 실행할 수 있다. 그래서 지금도 코스모스 생태계에서는 코즘워즘으로 작성해서 컴파일 하면 Juno, Cosmos, Osmosis, Terra등의 블록체인에 배포할 수 있다. 이를 재단에서는 멀티-체인 컨트랙트라고 말한다[1].
멀티-체인 컨트랙트는 단순히 이더리움 포크 버전에서 솔리디티를 실행하는 것 과는 다르다. 다른 코드 베이스를 가지거나, 매커니즘이 다르게 동작하는 블록체인에서도 CosmWasm 라이브러리를 붙이고 연동하면 동작이 가능하다. 예를 들어 CosmWasm을 이더리움 OPCODE로 컴파일 하는 라이브러리가 있다고 하면 이더리움에서도 배포와 실행이 이론적으로는 가능하다. 코스모스가 처음 만들어진 이유가 블록체인 간 상호운용성인 점을 생각하면 이런 개발 방향은 원래 하고자 했던 목적과 부합한다.
💡 블록체인 프로젝트들 중 개발은 하지 않고 컨퍼런스만 돌아다니면서 친목질만 하는 경우가 많은데 코스모스의 최신 문서와 IBC가 연동 되는 것을 보면서 느리지만 추구하고자 하는 방향으로 가고 있다는 느낌을 받았다.
액터 모델과 코즘워즘
액터 모델은 분산 시스템에서 사용되는 프로그래밍 패러다임의 일종이다. 동시에 처리될 수 있는 프로그램의 단위를 액터(Actor) 로 취급하고 액터들끼리는 서로 메시지로만 통신하고 메모리를 공유하지 않음으로써 안정성을 높이는 접근법이다[2].
💡 Go언어에서 채널(Channle)과 고루틴의 관계와 비슷하다. 차이점은 고루틴끼리는 동일한 채널을 구독하면서 서로 메모리를 격리해서 사용하는 반면, 액터 모델에서는 액터 마다 메시지를 수신받는 채널이 존재한다.
쓰레드와 액터와의 관계 | 액터끼리는 서로 메시지로 통신해야 한다.
이를 Rust 프로그래밍 언어에서 표현하면 하나의 액터는 다음과 같은 인터페이스를 가진다. 먼저 메시지 타입인 Msg를 구현하고 액터에서 이 메시지를 수신받는 fn handle(store, params, msg) 함수를 구현하는 방법을 생각할 수 있다. 액터의 동작 및 스케쥴러는 이를 실행하는 런타임에 맡긴다.
Rust에서 Actor 모델 인터페이스를 정의하는 모습
코즘워즘에서는 하나의 컨트랙트는 하나의 Actor로 동작한다. 이런 디자인에는 크게 두 가지 정도 장점이 있다.
- (느슨한 결합성) 액터들끼리는 메시지만 교환하기 때문에 API를 통한 코드의 직접 호출이 없기 때문에 느슨한 결합성을 가진다. 보통 분산 시스템에서 느슨한 결합성이란 한 곳의 장애가 전체로 전파되지 않음을 의미한다. 코즘워즘 에서는 서브 메시지를 이용해서 컨트랙트 A가 컨트랙트 B에 메시지를 보내서 실행할 수 있는데, 컨트랙트 B에서 오류가 발생하더라도 컨트랙트 A의 행위가 번복되지 않게 할 수 있다.
- (Reentrancy 공격 내성) CosmWasm 컨트랙트에서는 재진입 공격이 존재하지 않는다.
Actor는 각자의 스레드에서 실행되고 내부적으로 큐로 인해 작업을 관리하기 때문에 하나의 액터가 이전에 실행하던 Handle 함수를 멈추고 다른 컨트랙트에 의해 호출된 Handle을 수행하지 않기 때문에 그 자체로 재진입 공격 내성을 가진다.
Actor Model을 이용해 분산 시스템을 구축할 때 가장 많이 쓰이는 Akka 에서는 모든 액터가 한 노드에 있지 않고 전체 노드에 분포되어 있다. 만약 한 액터가 노드 A에 있다가 노드 A에 장애가 발생한 경우, Akka는 다른 노드에 동일한 액터를 생성해서 메시지를 수신 받을 수 있도록 한다.
이런 디자인을 블록체인의 인터넷을 구축하고자 하는 코스모스를 생각해 본다면 Juno에서 생성된 컨트랙트 A가 Terra의 컨트랙트 B에게 메시지를 보내는 모습을 생각할 수 있다. 코스모스 자체가 메시지의 수신과 전송을 보장해준다면 멀티-체인 환경에서 서로 다른 네트워크 간 컨트랙트 호출이 손쉽게 이루어지며 최근 오스모시스와 Keplr 지갑에서 IBC 프로토콜을 통해 서로 다른 블록체인에 있는 컨트랙트끼리 호출 되는 모습을 볼 수 있다.
코드로 보는 코즘워즘 구성요소
이번 섹션에서는 코즘워즘의 실제 구현 코드를 보고 Actor Model과 어떻게 매칭되는지 설명하고자 한다. 먼저 하나의 컨트랙트는 하나의 액터이고 액터들끼리는 서로 메시지로만 통신한다는 것을 생각해보자.
아래 코드는 실제 앵커 프로토콜에 배포된 코드 중 일부를 가져온 것이다. 앵커 프로토콜에서는 리워드(보상)이 임계값 이상 누적되면 이를 출금할 수 있는 기능이 존재한다. 실제 구현에서는 보상 출금 컨트랙트 A가 출금을 실행할 때 토큰 컨트랙트 B에 실제 출금을 지시하는 메시지를 보내면 된다.
코드를 보면 Response::new() 로 먼저 응답을 생성하고 .add_message() 함수로 컨트랙트 실행이 끝난 이후에 메시지를 &config.collateral_token 컨트랙트 주소로 전송한다. Solidity의 경우 컨트랙트 코드 중간에 다른 컨트랙트를 호출하는 코드를 직접 넣어주는 반면, 코즘워즘에서는 컨트랙트 응답 코드에 메시지를 보내주는 방식으로 처리한다. 이 짧은 예제를 통해 액터 모델의 기본적인 아이디어가 코드에서 어떻게 구현되는지 이해할 수 있을 것이다.
Anchor Protocol의 실제 컨트랙트 코드에서 일부 인용한 코드 | 리워드 토큰 출금 기능
💡 .add_message()함수를 이용해서 메시지를 전송하는 경우, 메시지를 수신받는 컨트랙트에서 잔액 부족으로 실패한다면 메시지를 보낸 컨트랙트 또한 번복되기 때문에 원자성이 보장된다.
지금부터는 코즘워즘 컨트랙트 코드의 큰 그림을 이해하기 위해 컨트랙트 코드의 구성요소인 엔트리포인트 (Entrypoints), 상태(State), 메시지(Message), 쿼리(Query)에 대해서 다룬다.
엔트리포인트 (Entrypoints)
엔트리포인트는 액터가 받는 메시지를 수신 받는 함수이다. 코즘워즘에서 하나의 액터는 instantiate, execute, query 3개의 함수를 src/contract.rs 파일에 정의 한다. 이 세개의 함수는 메시지를 수신 받아서 블록체인의 상태를 변경하거나 값을 조회한다. 컨트랙트가 수신 받는 메시지는 사용자가 지갑을 이용해 함수를 실행하거나, 외부 컨트랙트로 부터 호출 될 때 발생한다.
- instantiate | 이더리움에서는 스마트 컨트랙트 코드를 블록체인에 업로드 함과 동시에 컨트랙트 객체가 생성된다. 반면, 코스모스에서는
.wasm파일을 블록체인에 업로드하면 codeId가 부여받는다. 그리고 이 codeId로 컨트랙트를 초기화 하는 2단계를 거친다.instantiate함수는 컨트랙트가 실제로 생성되는 초기화 단계에서 호출되는 엔트리포인트다. - $execute \mid$ 이 함수는 컨트랙트 주소로 맵핑된 트랜잭션을 수신받는다. 블록체인에서 트랜잭션이란 실제 내부 값을 변경 시켜 연결된 노드의 합의가 필요하기 때문에 execute 함수에 메시지를 전송하는 행위는 수수료(gas)가 발생한다.
- $query \mid$ 쿼리는 RPC 노드 하나에게서 블록체인에 저장된 상태 값을 조회한다. 쿼리 또한 함수로 구현하기 때문에 값을 반환하기 전에 약간의 연산을 할 수 있다.
코즘워즘으로 컨트랙트를 개발한다고 하면, 개발자는 이 세 개의 함수를 구현해야 한다. 대표적인 엔트리포인트인 execute함수를 한 번 알아보자. 세 개의 엔트리포인트 함수는 이름만 다를 뿐 메시지를 처리하는 방식과 컨텍스트 정보를 받는 방법이 동일하다.
Cosmwasm 컨트랙트의 execute 함수 정의
💡 러스트에서는 컴파일 되는 코드의 양 자체를 줄이기 위해서 조건부 컴파일이라는 걸 도입했다. 잘은 모르지만 cfg_attr(not(feature=library))은 다른 프레임워크에서 라이브러리로 사용되는 거라면 컴파일 하지 않겠다는 의미인 것 같다.
- DepsMut — mutable dependencies. 라는 의미인데 그냥 대부분의 언어에서 Context라고 쓰는 것과 비슷하게 풀이된다. 용어 자체의 의미에 대해서는 크게 신경쓰지 말고 무엇을 할 수 있는지에 집중해보자. 우리는 이 변수를 통해서 ① 다른 스마트 컨트랙트를 호출하는 API나 ② 다른 스마트 컨트랙트의 정보를 가져오는 Querier를 사용하거나 ③ read/write 접근을 하는 Stroage를 이용할 수 있다.
- Env — environment information. 블록 시간, 블록 넘버, 컨트랙트 주소, 트랜잭션 정보 등을 담고있다. 이더리움과 차이점은 랜덤 정보를 생성하기 위해 잘못 사용되는 hash값은 주지 않는다.
- MessageInfo — 보낸 이의 주소
(sender)와 얼마를 보냈는지funds:Vec<Coin>정보가 담겨져 있다. 여기서 funds 변수의 타입이 벡터인 이유는 테라 같은 곳에서는 네이티브 코인들이 여러 종류이기 때문이다. 참고로query함수에는 MessageInfo가 없다. 따라서 잔액주소를 조회하는 행위에는 지갑 주소를 파라미터로 따로 받아야 한다. - ExecuteMsg — 컨트랙트 작성자가 정의하는 메시지 이넘 타입이다.
함수 정의 부분을 봤으니, 실제로 메시지를 어떻게 수신하는지 보자. msg: ExecuteMsg 는 보통 이넘 타입으로 정의하고 이를 패턴 매칭으로 각 메시지를 처리하는 로직을 구현한다. 아래 코드는 cw-plus의 cw-20코드에서 가져온 내용이다. 이더리움 솔리디티에는 오픈 제플린이 공통 컨트랙트를 작성하고 관리한다면 코즘워즘에서는 cw-plus가 이 역할을 하고 있다.
execute 함수의 실제 구현부
match 키워드를 통해 패턴 매칭을 이용해서 이넘 타입으로 정의된 각 메시지를 별도로 정의된 함수인 execute_transfer, execute_burn … 등을 호출한다. execute 함수의 리턴 타입은 Result<Response, ContractError> 로 성공/실패에 따른 결과를 반환하도록 하기 때문에 execute_transfer 등의 각 함수도 해당 값을 리턴하도록 정의하면 된다. 아래 코드를 보면 엔트리포인트가 받은 DepsMut, Env, MessageInfo의 정보도 한꺼번에 넘겨서 편하게 사용하는 것을 볼 수 있다.
토큰 송금 구현 함수의 정의
💡 execute 함수에서 리턴 타입은 정의했는데 실제 return 키워드가 사용되지 않음을 알 수 있다. 러스트에서는 코드 마지막에 세미콜론(;)이 붙지 않으면 암묵적으로 리턴이 앞에 붙는다.
상태 (State)
State란 블록체인에 실제로 내용을 저장하는 저장소이며, 각 컨트랙트마다 독립적으로 접근하는 공간이다. Solidity에서는 컨트랙트내에 정의된 지역 변수가 바로 코즘워즘에서는 상태와 의미가 매칭된다고 보면된다. 코즘워즘에서는 단순한 정보 저장과 Map 자료구조 뿐 아니라 결합 키를 이용한 방법와 인덱싱이 지원되서 페이징과 정렬이 지원되는 자료구조까지 다양한 상태를 지원한다. 정확히는 cw_storage_plus 크레이트를 이용해 이를 사용한다. 아래는 ERC-20 토큰에서 토큰 정보를 저장하는 공간을 생성하는 코드이다.
상태 정의/저장/불러오는 방법에 대해 간략하게 보여준다.
위 코드는 필자가 쉽게 보여주기 위해 간략해서 표기한 것으로 실제로는 서로 다른 파일에 존재하던 코드들임에 유념하자. 상태 생성은 보통 src/state.rs 파일에서 정의하고 실제 로직의 대부분은 src/contract.rs 파일에서 다루기 때문에 상태 저장/불러오기는 이 파일에 존재하게 된다.
Item::new() 를 이용해 token_info 라는 저장 공간을 생성했다. 이때 사용한 “token_info”라는 값은 스토리지 키로, 한 컨트랙트에서는 유니크한 값이어야 한다. 상태를 저장할 때는 엔트리포인트 함수에서 파라미터로 받는 deps:DepsMut 변수의 storage값을 첫 번째 인자에 주고 두 번째 인자에 실제 저장할 데이터를 넘겨주면 된다.
Map 자료 구조
코즘워즘에서는 Map 자료구조를 저장할 때 Map::new함수를 이용해서 저장소를 생성하고 저장할 수 있다. 흔히 토큰에서 주소를 키로 하고 잔액 값을 가지는 자료구조를 생성할 때 Map을 이용한다.
아래 코드에서는 2번째 파라미터인 주소를 키로 가지는 balance값을 수정하는 방법이다. 수정할 때는 기존 값인 balance를 수정해서 새로운 값을 리턴하는 람다 함수를 3번째 파라미터로 받는다.
Map State 를 이용해 주소 -> 잔액 맵핑 저장소를 사용하는 모습
여기서는 update 함수에 람다 함수로 Uint128 => StdResult 형식을 전달해서 기존 값에 접근했다. 만약 그냥 값에 접근하고 싶다면 may_load 혹은 load 함수를 이용해서 값에 접근해서 사용이 가능하다.
💡 BALANCES.update() 함수 맨 마지막에 물음표(?)가 붙어있는 것을 알 수 있다. 이 문법은 러스트에서 Option<T>와 Result<S,T>타입에 한해서 사용할 수 있는 특수 문법인데 에러가 발생하는 경우 이후 문법을 실행하지 않고 에러를 리턴한다는 의미이다. 코즘워즘에서는 에러가 발생하는 경우 컨트랙트 실행이 모두 번복되어서 트랜잭션이 실행되지 않게 된다.
메시지 (Message)
메시지는 컨트랙트와 상호 작용하는 방법이다. 컨트랙트<->컨트랙트 혹은 사용자 <-> 컨트랙트 사이의 상호작용이 발생할 때 주고 받아야 하는 메시지의 인터페이스를 정의한다.
쿼리 (Query)
블록체인에서 정보를 조회하는 메시지이다. 데이터베이스에서 값을 조회한다고 이해하면 쉽다. 일단 쿼리는 컨트랙트 데이터 스토어에 일기-전용 모드로 값을 가져오기 때문에 블록체인의 정보를 조작하면 에러를 반환한다.
Raw Query
Key-Value 저장소에 접근할 수 있는 CosmWasm에서 가장 기본이 되는 Query 형식으로 바이너리로 표현된 값을 반환받는다.
Custom Query
단순히 Key-Value 저장소를 넘어서 복잡한 쿼리를 수행할 수 있다. Raw Query는 저장소에 저장된 값만 가져오는 반면에 Custom Query는 복잡한 계산을 수행한 후에 값을 조회할 수도 있다. (현재 이자율이나 Swap Rate 같은 정보 등)
External Query
외부 쿼리를 뜻하는 말로 cli나 web등에서 접근할 때 사용하는 쿼리로 RPC를 통해서 정보를 주고받는다. 커스텀 쿼리의 한 종류이기 때문에 복잡한 연산을 수행할 수 있다.
- 하나의 노드에서 정보만 접근하기 때문에 한 노드에 가해지는 부하가 전체 노드로 전파되지 않는다.
- 하지만
infinite-loop같은 악의적 코드를 배포하고 실행하는 경우를 방지하기 위해서 각 노드마다 query의 호출 limit을 정할 수 있는 파라미터가 존재한다. - 절대 컨트랙트 실행 중간 상태를 조회하지 않고,
read-only속성을 가지는 스냅샷 정보에만 접근한다.
Internal Query
단순하게 스마트 컨트랙트를 실행하는 건 Message를 또다른 액터(컨트랙트)에 보내는 것만으로 해결이 되는데 쿼리를 하는 건 이야기가 달라진다. 액터 모델에서는 서로 상태가 격리된 환경에서 동작하는 것을 원칙으로 삼기 때문에 다른 컨트랙트의 상태를 query하는 건 액터 원칙에 어긋나기 때문이다.
- 그래서 코즘워즘 에서는 액터가 실행되기 이전 상태 정보를 스냅샷 떠놔서 이전 상태 정보를 참조하도록 했다. 그래서 개발자는 아무런 위화감 없이 단순하게 query를 사용하면 된다.
- Internal Query는 스마트 컨트랙트 실행 과정에서 참조하는 정보기 때문에 External Query와는 다르게 수수료가 부과된다.
단점
사실 EVM기반이 아닌 스마트 컨트랙트가 대부분이 그렇지만, Solidity에 비해 테스트 환경이 부족하다. 확실히 remix와 같은 IDE 도구의 부재는 간단한 기능도 테스트하기 불편하게 했고 요즘 처럼 다른 스마트 컨트랙트와의 상호작용이 많이 필요한 DeFi, NFT가 대세인 상황에서는 실제 테스트넷에 업로드를 해보고 나서야 테스트 하거나 혹은 직접 Mock 객체를 모두 작성해야 하기 때문에 개발 기간이 Solidity에 비해서 길어진 건 사실인 듯 하다.
결론
오늘은 오랜 만에 실용적인 관점에서 주제를 잡아서 풀어냈고, 최근에 관심있게 보던 코즘워즘의 컨트랙트 작성에 대해서 다루어 보았다. 만약 컨트랙트 작성을 진지하게 다루려면 컨트랙트의 컴파일 및 배포와 테스트 까지 자동화가 가능해야 한다. 테라에 문서가 없어서 필자 깃허브에 이를 간단하게 정리해보았는데 테라에서 간단하게 테스트 해보려면 이 링크를 참고하면 된다.