[USCF 시리즈 (2/4)] 업그레이드 가능한 스마트 컨트랙트를 위한 필요 조건과 배경지식

Prerequisites and Backgrounds for Making an Upgradable Smart Contract

블록체인 기술 솔루션 기업 ‘해치랩스(HAECHI LABS)’에서 업그레이드 가능한 스마트 컨트랙트 프레임워크에 대한 글을 연재합니다.
  1. 왜 우리는 업그레이드 가능한 스마트 컨트랙트가 필요한가?
  2. 업그레이드 가능한 스마트 컨트랙트를 위한 필요 조건과 배경지식
  3. 어떻게 스마트 컨트랙트를 업그레이드 가능하도록 바꿀 수 있을까?
  4. vvisp을 통해 DApp 업그레이드하기

시리즈 1편에서 왜 스마트 컨트랙트 업그레이드가 필요한지 알아보았습니다. 전편을 간단히 요약하자면 업그레이드 가능한 스마트 컨트랙트가 필요한 이유는 크게 1) 버그를 수정하기 위함 2) 비즈니스 로직을 수정하여 사용자 유입을 늘리기 위함이었습니다. 이번 편에서는 스마트 컨트랙트 업그레이드시 만족해야 하는 조건 3가지를 소개하고 본격적으로 업그레이드 방법을 논하기 전에 이해해야 하는 배경지식을 설명 드리겠습니다.

Prerequisites

크립토키티 스마트 컨트랙트를 업그레이드 한다고 해봅시다. 기존의 컨트랙트를 A라 하고, 고양이간 싸움 액션을 새롭게 추가한 컨트랙트를 B라고 합시다. 단순하게 B를 새로 배포한 후 사용자에게 앞으로 A 대신 B를 사용해달라고 안내한다면 어떻게 될까요? 사용자는 크게 두 가지 불편을 겪을 것입니다.

첫 번째로 사용자가 컨트랙트를 호출할 때 컨트랙트 주소를 새로운 컨트랙트 주소로 바꿔야 합니다. 이러한 형태라면, 사용자 입장에서는 매번 서비스 제공자에게 현재 가장 최신 버전의 컨트랙트 주소가 무엇인지 물어봐야 합니다. 더불어, 서비스 제공자 입장에서도 현재 컨트랙트 주소를 기억하고 알려주는 별도의 시스템을 만들어야 하기 때문에 불편합니다.

두 번째로 기존 컨트랙트 A에 보관하고 있던 정보가 사라지게 됩니다. 별도로 배포한 컨트랙트 B는 A에서 저장하고 있는 정보를 전혀 가지고 있지 않습니다. 그 말은 사용자가 가지고 있는 고양이가 사라질 수 있다는 것을 의미합니다. 서비스 제공자 입장에서 여기에 대응하는 가장 단순한 해결책은 A에 있는 정보를 모두 B로 옮기는 것 입니다. 하지만 이 작업은 매우 번거롭고 많은 가스 비용을 지출할 뿐 아니라 사소한 실수로 데이터가 유실될 위험이 있습니다.

이외에도 실제 DApp에서 일어날 수 있는 또 하나의 중요한 문제가 있습니다. 바로 여러 개의 컨트랙트를 배포했을 때 발생하는 버전 호환 문제입니다. 실제 DApp들은 하나의 컨트랙트로만 이루어진 경우가 드뭅니다. 크립토키티 같은 경우에도 4개의 컨트랙트로 이뤄져 있고, 컨트랙트끼리 서로를 호출하고 있습니다. 그렇다면 여러 컨트랙트로 이루어진 DApp을 업그레이드할 때 어떤 문제가 발생할까요? 예를 들어 크립토키티 컨트랙트가 고양이를 만드는 컨트랙트 A와 고양이의 모양을 저장하는 컨트랙트 B로 나뉜다고 합시다(저자주: 실제로는 이렇게 구현되어있지 않습니다). 그리고 새로 업데이트를 진행하여 고양이 모양에 꼬리의 길이가 추가된다고 합시다. 만약 A는 업데이트 되었는데 B가 업데이트 되지 않았다면, A에서 B의 꼬리 길이를 알려주는 함수를 호출할 수 없을 것이고 이는 곧 컨트랙트의 오류로 이어질 것입니다. 이렇듯 여러 컨트랙트가 서로를 호출하는 구조를 가진 DApp을 업그레이드 할 때에는 atomic하게 이뤄져야 합니다. 즉, A는 업그레이드 되었지만 B는 되어있지 않는 현상 없이 한꺼번에 A, B의 버전이 올라가거나 또는 둘 다 올라가지 않아야 오류가 없습니다.

스마트 컨트랙트를 단순히 재배포하여 업그레이드한다면 위와 같은 세 가지 불편함 또는 문제 상황을 맞이할 수 있습니다. 따라서 업그레이드 가능한 스마트 컨트랙트를 제대로 구현하기 위해서는 이 3가지 문제를 해결해야 하며, 이는 Upgradeable Smart Contract Framework가 만족해야 하는 3가지 조건으로 바꿔 말할 수 있습니다.

1. 스마트 컨트랙트를 업그레이드하더라도 사용자 입장에서 접근하는 컨트랙트의 주소가 변하지 않아야 합니다.

2. 스마트 컨트랙트를 업그레이드하더라도 기존의 데이터가 보존되고 재활용되어야 합니다.

3. 여러 스마트 컨트랙트로 이루어진 DApp를 업그레이드 할때는 항상 atomic하게 이뤄져야 합니다.

이번 편 후반부와 다음 편 글에서는 이러한 조건을 만족하는 프레임워크를 어떻게 만드는지에 대해 더욱 자세히 살펴보도록 하겠습니다.

Background

위의 조건을 만족하는 업그레이드 가능한 스마트 컨트랙트를 만들려면 어떻게 해야할까요? 우선 그 전에 알아야 할 배경지식이 있습니다. 이번 편에서는 업그레이드 가능한 스마트 컨트랙트를 이해하기 위한 지식을 크게 4가지로 분류하였습니다. 업그레이드 가능한 스마트 컨트랙트를 만드는 방법에 대해서는 다음 편에서 자세히 설명 드릴 예정입니다.

1. 스마트 컨트랙트의 변수는 Storage 에 어떻게 저장되는가?

이더리움에서 스마트 컨트랙트의 변수를 저장하는 공간은 크게 두 가지가 있습니다(stack과 calldata 제외). 하나는 memory이고 또 다른 하나는 storage입니다. 이 중에서 storage에 저장된다는 의미는 실제로 블록체인에 영구적으로 기록됨을 뜻합니다. 예를 들어 특정 EOA 주소가 가지고 있는 ERC20 토큰의 수량 등은 전부 storage에 저장됩니다. 그렇다면 한 컨트랙트 내에서 어떻게 서로 다른 변수들이 주소가 겹치지 않도록 저장될 수 있을까요? 해답은 간단합니다. 변수가 선언된 순서대로 slot 번호를 부여하는 것입니다.(그림 1). 서로 다른 변수는 서로 다른 slot 번호로 구분됩니다. 이더리움 스마트 컨트랙트의 storage에는 총 2²⁵⁶개의 슬롯이 있으며 각 슬롯은 최대 32byte의 정보를 저장할 수 있습니다.

그렇다면 상속 받은 컨트랙트의 변수는 어떻게 저장될까요? slot 번호에만 집중하자면 다음과 같습니다. 우선 상속 받을 부모 컨트랙트의 변수들부터 순서대로 slot 번호가 부여됩니다. 그 다음 상속 받는 자식 컨트랙트에서 선언된 변수순으로 slot 번호가 차례대로 증가합니다.(그림 2)

위의 내용을 정리하자면, 스마트 컨트랙트 storage에 저장되는 변수는 각각 순서대로 고유한 slot 번호가 부여되어 저장공간상에 서로 다른 주소를 가지게 됩니다.

그림 1. Storage의 slot이 변수가 선언된 순서대로 채워진다.
그림 2. 상속받은 컨트랙트가 있다면, 상속받은 컨트랙트의 변수부터 순서대로 slot번호를 부여받는다.

2. 스마트 컨트랙트의 함수는 어떻게 호출되는가?

스마트 컨트랙트에서 함수는 어떻게 호출될까요? 우선 일반적으로 프로그램에서 어떻게 함수가 호출되는지 설명드리겠습니다. 함수는 명령의 묶음입니다. 함수를 호출한다는 것은 그 묶음에 원하는 상황(argument)를 주입하여 실행한다는 것입니다. 그렇다면 먼저 명령 묶음이 어디에 저장되어 있는지부터 찾아야 합니다. 함수의 명령 묶음이 저장된 장소는 주로 함수 이름을 통해 찾을 수 있습니다. 이 때, 함수 실행에 필요한 argument를 넣어줄 수 있습니다. 예를 들어 함수 add에 argument 1, 2를 넣은, add(1,2)를 실행한다 하였을 때, add라는 이름으로 해당 함수의 명령 묶음이 저장된 공간을 찾은 후, argument가 들어가도록 약속된 장소에 1과 2를 순서대로 써주면 해당 코드 뭉치는 인자를 읽은 후 하나씩 명령을 실행합니다.

이더리움에서도 위와 비슷한 방법으로 함수를 찾아 실행합니다. 다만 이더리움은 스마트 컨트랙트의 모든 명령을 트랜잭션이라는 일정한 스펙에 따라 실행해야 하기 때문에 함수 실행도 그 형식에 맞추는 작업이 필요합니다. 이를 위해 모든 함수 호출은 총 4 + 32 * N(변수의 개수) byte의 문자열로 표현되는데요, 다음과 같은 내용이 들어갑니다.

  • Function Selector: 함수 이름을 공백 없는 문자열로 표현한 뒤 이를 keccak-256이라는 해시 함수에 넣어 만든 해시 값의 첫 4bytes. 이를 기반으로 스마트 컨트랙트 내의 어떤 함수를 호출할지 결정함.
  • Function Argument: 함수의 argument를 32bytes라는 고정된 길이의 16진수 문자열로 만듦. 여러 개의 변수가 있을 때에는 이어붙임.
그림 3. 스마트컨트랙트에서 function call을 할 때에 트랜잭션의 data field에 들어가는 값.

이더리움 스마트 컨트랙트를 호출하는 트랜잭션에 위와 같은 문자열을 data field로 넘겨주면, Function Selector를 통해 어떤 함수를 실행할지 찾고 Function Argument를 통해 해당 함수에 어떤 변수값을 넣어 실행할지 판단합니다.

3. DelegateCall 이란 무엇인가?

그림 4. delegate call 개념도

Delegatecall 을 설명하기 전에, 프로그램에서 컨텍스트(Context)가 어떠한 개념인지 설명 드리겠습니다. 컨텍스트란 프로그램을 실행할 때 프로그램이 참고할 만한 모든 변수 등의 환경을 뜻합니다. 예를 들어 어떤 프로그램 A가 실행될 때 이 프로그램을 실행한 사람이 Robbie라고 어딘가에 저장되어 있다면 그것도 일종의 컨텍스트라고 할 수 있습니다(리눅스는 실제로 프로세스가 실행될때 그 프로세스를 실행한 유저를 uid로 구분합니다). 이처럼 스마트 컨트랙트에서도 여러 컨텍스트가 있을 수 있는데, 그 중에서 가장 대표적인 컨텍스트는 바로 누가 이 함수를 실행하는 트랜잭션을 발생시켰느냐 입니다. 컨트랙트 코드 내에서 msg.sender로 표현되는 이 컨텍스트는 굉장히 자주 활용됩니다. 예를 들어, ICO의 토큰 세일 컨트랙트 같은 경우 msg.sender에 해당하는 주소로 토큰을 보내주기도 하고, 해당 컨트랙트의 owner와 같은 권한을 설정할 때 msg.sender에 해당하는 주소를 활용하기도 합니다.

DelegateCall은 어떤 스마트 컨트랙트(caller, 호출 컨트랙트)가 다른 스마트 컨트랙트(callee, 타겟 컨트랙트)를 호출하는 상황에서, caller의 컨텍스트를 유지하며 호출하는 방식입니다. 예를 들어 위 그림과 같이 스마트 컨트랙트가 또 다른 스마트 컨트랙트의 함수를 call한다고 가정해봅시다. EOA에서 호출 컨트랙트인 A를 call한 상황이라면, A가 타겟 컨트랙트인 B를 부를 때, 일반적인 call 방식과 delegateCall 방식이 어떤 차이가 있는지 설명해드리겠습니다. 우선 A가 B의 함수를 call했다면 B의 함수는 msg.sender가 A인 상황에서 실행됩니다. 반면 A가 B의 함수를 delegatecall했다면 msg.sender는 EOA가 됩니다(그림 4).

DelegateCall을 사용하는 대표적인 예제는 라이브러리 컨트랙트입니다. 라이브러리 컨트랙트는 자주 쓰는 유용한 함수의 모음으로 다른 컨트랙트에서 해당 함수를 다시 구현하지 않고 불러 쓸 수 있게 합니다. 이 때, 호출 컨트랙트 쪽에서 delegatecall을 통해 호출함으로써 컨텍스트의 전환 없이 라이브러리에 있는 함수들을 이용할 수 있습니다.

4. DelegateCall 을 통한 함수 호출시 Storage 저장은 어떻게 일어나는가?

그림 5. proxy contract -> business logic contract
그림 6. fallback function 코드(출처:링크)

이제 proxy contract라는 호출 컨트랙트가 있고, 실제 비즈니스 로직이 구현되어 있는 타겟 컨트랙트가 있다고 해봅시다(그림 5). proxy contract는 위에 있는 fallback function을(그림 6) 제외하고는 아무것도 구현되어있지 않은 껍데기 contract입니다. 결론부터 말씀 드리면, proxy contract에다가 비즈니스 로직에 있는 함수를 부르듯 트랜잭션을 발생시키면 실제로 비즈니스 로직의 함수가 실행되며 해당 함수의 실행 결과로 생기는 storage의 변화는 proxy contract에 반영됩니다. 어떻게 이런 일이 발생할 수 있을까요?

우선 fallback function이 무엇인지부터 설명 드리겠습니다. 배경지식 2번에서 우리는 어떻게 이더리움 스마트 컨트랙트의 함수를 call하는지 살펴 보았습니다. 만약 function selector에 해당하는 함수가 해당 스마트 컨트랙트에 존재하지 않으면 어떻게 될까요? 답은 이름이 없는 함수인 fallback function이 실행됩니다. 일반적으로 fallback function이란 주로 프로그램이 의도하지 않은 동작을 할 때에 오류를 다루기 위해 정의되는 함수를 말합니다. 예를 들어 A라는 컨트랙트에 정의되지 않은 함수인 send()를 A.send(1) 라는 방식으로 호출했다고 합시다. send라는 함수는 A에 있지 않으므로 EVM은 A에 정의되어 있는 fallback function를 찾아 실행합니다. (fallback function은 (그림 6)처럼 solidity에서 이름을 생략한 채 정의하면 됩니다.) 마찬가지로 Proxy 컨트랙트에는 정의되지 않고, 비즈니스 로직 컨트랙트(Business)에만 정의되어 있는 함수 transfer()가 있다고 합시다. 이 때, Proxy.transfer(0x222,1) 를 실행하게 되면 Proxy에는 transfer 함수가 없으므로 fallback function이 실행되게 됩니다.

Proxy의 fallback function이 실행되면 어떻게 될까요? 위의 코드에서 앞서 설명한 delegatecall이 있다는 사실에 주목할 필요가 있습니다. delegatecall을 활용하면 “사용자가 transfer라는 함수를 변수 0x222, 1을 넣어 실행” 이라는 context를 그대로 보존한 채 비즈니스 로직 컨트랙에 다시 트랜잭션을 날리게 됩니다. 그렇게 되면 비즈니스 로직 컨트랙트에 있는 transfer라는 함수를 실행할 수 있습니다. 더 중요한 사실은 해당 함수를 실행하면서 생기는 상태 변화는 proxy contract에 반영됩니다. 예를 들어 비즈니스 로직 컨트랙트의 transfer(0x222, 1)를 실행하면 mapping balance(address => uint256) 라는 비즈니스 로직 컨트랙트 내에 있는 변수가 바뀐다고 가정 합시다. 그리고 balance라는 변수는 slot 번호 3이라 합시다. 그렇게 되면 실제로 delegatecall을 사용하였으므로 proxy contract의 storage slot 3번에 해당 상태 변화가 쓰여지게 됩니다. 이는 해당 proxy contract에 어떠한 변수 선언이 없어도 일어나는 일입니다. 원래 비즈니스 로직 컨트랙트에서 transfer를 직접 call했다면, 해당 컨트랙트의 storage slot 3번이 변형 되었겠지만, proxy contract에서 delegatecall을 하였기에 상태 변화가 proxy contract의 storage에서 일어나는 것 입니다(그림 7).

Conclusion

지금까지 업그레이드 가능한 스마트컨트랙트에서 필요한 3가지 조건과, 이를 구현하기 위해 필요한 사전 지식을 알아보았습니다. 정리해보자면

  • 단순히 새로운 컨트랙트로 바꾼다고 업그레이드가 끝나는 것이 아닙니다.
  • 업그레이드시 컨트랙트 주소가 바뀌지 않아야 사용자가 더 편하게 사용할 수 있습니다.
  • 버전이 올라갔다고 저장되어 있던 정보가 사라지면 안됩니다.
  • 여러 개의 컨트랙트를 업그레이드 하는 도중에도 버전이 서로 같아야 합니다.

이런 조건을 만족하면서 업그레이드를 하기 위한 사전지식 4가지도 살펴보았습니다.

  • 스마트 컨트랙트에서 storage는 slot이라는 단위로 저장됨을 확인하였습니다.
  • 함수 호출이 실제로 트랜잭션에서 어떻게 표현되는지 알아보았습니다.
  • delegatecall을 이용하면 call을 사용할 때와 context가 달라짐을 알 수 있었습니다.
  • 호출 컨트랙트가 타겟 컨트랙트에 있는 함수를 delegatecall을 통해 실행하면 실제로 변수는 호출 컨트랙트의 storage에 저장됨을 알게 됐습니다.

다음 편에서는 실제로 이 배경지식을 바탕으로 어떻게 3가지 조건을 만족하는 업그레이드 패턴을 만들 수 있는지에 대해 설명하겠습니다.