Proxy Pattern & Upgradeable Contract
블록체인에 한 번 저장된 데이터는 변경이나 삭제가 불가능합니다. 물론 특정 컨트랙트의 변수 값을 새로운 값으로 덮어씌울 수는 있지만 이전에 어떤 값이었는지 그 기록 자체가 변경되진 않습니다. 스마트 컨트랙트도 역시 블록체인에 저장되는 데이터이기 때문에 한 번 배포된 코드의 내용은 바꿀 수 없습니다.
다만, 소프트웨어 개발에 있어서 버그를 수정하고, 기능 업그레이드를 위해 코드를 수정하는 것은 중요한 일입니다. 이를 해결하기 위해 스마트 컨트랙트에 Proxy pattern을 이용해 업그레이드가 가능한 컨트랙트(Upgradeable Contract)를 구현할 수 있습니다.
해당 포스팅은 이전 포스팅 [Smart contract의 code size를 줄이는 방법]과 이어집니다.
Smart contract의 code size를 줄이는 방법
EVM에서 스마트 컨트랙트로 작성 가능한 크기는 약 24.000 KiB로 한계가 있습니다. solidity optimizer의 runs 옵션을 이용해 배포할 때의 코드 크기를 어느 정도 줄일 수 있기는 하지만, 이 역시 무한정으
him-down.tistory.com
Proxy Pattern이란?
보통 프록시 패턴(Proxy Pattern)은 무언가를 대신해 이어주는 역할을 합니다. 즉, 어떤 대상과 직접적으로 상호작용하는 것이 아니라 중간의 대리인(Proxy)을 거쳐서 간접적으로 상호작용할 수 있게 도와줍니다. 이를 컨트랙트에 대입하면 다음과 같은 구조가 됩니다.
사용자가 프록시 컨트랙트를 통해 어떤 함수를 호출하면 프록시 컨트랙트에 저장되어 있는 로직 컨트랙트의 주소를 통해 로직 컨트랙트의 함수를 대신 호출합니다. 이때, 한 번 배포된 프록시 컨트랙트와 로직 컨트랙트는 각각 불변이겠지만, 로직 컨트랙트를 다른 컨트랙트로 바꿔 낄 수 있다는 점에서 컨트랙트에 가변성을 줄 수 있습니다.
Proxy Forwading
프록시 컨트랙트를 통해 로직 컨트랙트의 함수를 호출하는 구조기 때문에, 함수를 하나하나 매핑시키는 것 말고 유동적으로 로직 컨트랙트에 있는 함수들을 자유롭게 호출할 수 있어야 합니다. 아래는 다이나믹하게 컨트랙트의 함수를 포워딩할 수 있는 메커니즘입니다.
// This code is for "illustration" purposes. To implement this functionality in production it
// is recommended to use the `Proxy` contract from the `@openzeppelin/contracts` library.
// https://github.com/OpenZeppelin/openzeppelin-contracts/blob/v4.8.2/contracts/proxy/Proxy.sol
assembly {
// (1) copy incoming call data
calldatacopy(0, 0, calldatasize())
// (2) forward call to logic contract
let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0)
// (3) retrieve return data
returndatacopy(0, 0, returndatasize())
// (4) forward return data back to caller
switch result
case 0 {
revert(0, returndatasize())
}
default {
return(0, returndatasize())
}
}
먼저 (1) 함수를 호출할 때 함께 들어온 call data를 복사하고, (2) 이를 로직 컨트랙트로 전달합니다. 그러면 로직 컨트랙트에서 해당 call data에 맞는 함수가 호출됩니다. (3) 이후 반환된 데이터를 받고 (4) 반환된 데이터를 다시 caller에게 전달합니다.
위 메커니즘은 주로 Fallback 함수에 넣어서 사용됩니다. 이는 사용자는 프록시 컨트랙트를 이용해 함수를 호출하지만 실제로 함수 자체는 프록시 컨트랙트에는 존재하지 않고, 로직 컨트랙트에 있기 때문입니다.
또한 로직 컨트랙트를 업그레이드할 때마다 컨트랙트가 가지고 있는 데이터가 변하면 안 되기 때문에, 로직 컨트랙트의 Storage를 사용하기보다는 업그레이드하지 않는 프록시 컨트랙트의 Storage를 사용하는데요. 이를 위해 Delegate Call을 이용해 함수를 호출합니다.
Fallback Function
사용자가 특정 함수를 호출했는데, 해당 함수가 컨트랙트에 존재하지 않는다면 fallback 함수가 호출됩니다. 컨트랙트에는 단 1개의 Fallback 함수만이 존재할 수 있으며, 무조건 External으로 선언되어야 합니다.
Soliditysms 0.6.0 이후로부터 Fallback 함수 하나였던 것이 Receive와 Fallback 함수로 나뉘었습니다.
Fallback 함수는 호출한 함수가 컨트랙트 내에서 조회되지 않는 경우에 실행되며(Calldata O),
Receive는 ETH를 받을 때 실행됩니다(for empty Calldata, and any value).
프록시 컨트랙트를 통해 로직 컨트랙트의 함수를 호출한다면, 호출된 함수가 프록시 컨트랙트에 존재하지 않으므로 자동으로 fallback 함수가 호출됩니다. 이때, 위 메커니즘을 fallback 함수가 포함하고 있다면, 해당 메커니즘을 통해 유저가 호출한 요청을 로직 컨트랙트로 전달해 유저가 원하는 함수를 로직 컨트랙트에서 호출할 수 있습니다.
Delegate Call
Solidity는 다른 컨트랙트의 함수를 호출할 때 크게 두 가지의 EVM opcode를 사용합니다.
- Call : 통상적으로 사용하는 opcode
- DelegateCall : 다른 컨트랙트의 코드를 사용하되, 실행 환경(context)은 기존 컨트랙트를 이용
즉, A 컨트랙트에서 B 컨트랙트를 Delegate call을 통해 호출하면 B 컨트랙트의 코드를 사용하지만, Storage는 A를 사용하게 되는 것입니다. 이때 msg 객체의 값들이 변하지 않기 때문에, msg.sender와 msg.value 역시 당연히 변하지 않습니다.
간단하게 프록시 패턴과 업그레이드 가능한 컨트랙트에 대해서 정리해 보았습니다. 프록시 패턴에서는 컨트랙트는 여러 개 바꿔낄 수 있다는 점에서 Storage를 일관성 있게 유지하고 각각의 컨트랙트끼리 Storage 위치가 충돌 나지 않게 하는 것이 중요합니다. 이와 관련해서 프록시 패턴에 대해서 더 자세한 내용을 알고 싶다면 아래 참고 자료를 함께 살펴보는 것이 도움이 될 수 있을 것 같습니다.
- https://velog.io/@imysh578/Proxy-Contract-업그레이드가-가능한-스마트-컨트랙트
- https://medium.com/@aiden.p/업그레이더블-컨트랙트-씨-리즈-part-1-업그레이더블-컨트랙트란-b433225ebf58
- https://medium.com/@aiden.p/업그레이더블-컨트랙트-씨-리즈-part-2-프록시-컨트랙트-해체-분석하기-95924cb969f0
- https://docs.openzeppelin.com/upgrades-plugins/1.x/proxies
- https://blog.openzeppelin.com/proxy-patterns/