Search

프록시 패턴의 모든것 - 3

태그
RedSpider
Blockchain
Property
Proxy banner2.png

개요

블록체인 서비스를 개발하다 보면, 동일한 로직을 가진 컨트랙트를 여러 개 배포해야 하는 경우가 있다. 예를 들어, 모든 사용자에게 개별적인 저장소 컨트랙트를 할당하거나, 특정 규칙을 따르는 수많은 NFT 컬렉션을 생성하는 상황을 들 수 있다. 하지만 이더리움과 같은 EVM 기반 블록체인에서 컨트랙트를 배포하는 것은 상당한 가스 비용을 소모한다.
이러한 비효율을 해결하기 위해서도 프록시 패턴(Proxy Pattern)이 활용된다. 이번 글에서는 비슷한 목적을 가졌지만 ‘업그레이드 유무’라는 핵심적인 차이점을 지닌 두 가지 프록시 패턴, Minimal Proxy(EIP-1167)Beacon Proxy(EIP-1822)에 대해 알아본다.

Minimal Proxy Pattern(EIP-1167)

사용 목적

Minimal Proxy Pattern은 'Clone Pattern’이라고도 부른다. 이 패턴의 목표는 단 하나, 동일한 로직을 가진 컨트랙트를 가장 효율적으로, 가장 저렴하게 배포하는 것이다.
이름에서 알 수 있듯이 상기의 목적을 달성하기 위해 최소한의 기능만 가졌다. 업그레이드 기능과 같은 부가적인 요소를 과감히 덜어낸 것이다. Minimal Proxy가 하는 일은 매우 단순하다. 미리 정해진 하나의 Implementation Contract로 모든 호출을 위임(delegatecall)하는 것뿐이다. 따라서, Minimal Proxy는 한번 배포되면 로직을 변경할 수 없는 Immutable(불변) 특성을 가지기 때문에 업그레이드 할 필요 없이 많은 컨트랙트를 복제해야 할 때 빛을 발한다.
이해를 돕기 위해 MinimalProxy의 동작을 Solidity 의사 코드로 나타내면 다음과 같다.
contract MyMinimalProxy{ // Implementation주소는 배포 시점에 고정되어 변경 불가능 address immutable IMPLEMENTATION = 0xbebebebebebebebebebebebebebebebebebebebe; // 프록시로 들어오는 모든 호출은 implementation으로 delegatecall fallback(bytes calldata) external returns (bytes memory) { (bool success, bytes memory data) = IMPLEMENTATION.delegatecall(calldata); require(success); return data; } }
Solidity
복사
사실 실제 Minimal Proxy는 최대의 효율을 위해 Solidity와 같은 고급 언어가 아닌 EVM 바이트코드로 구현된다. 게다가 이 바이트코드는 고작 55byte밖에 안 되는 가벼운 크기에, 가스를 최대한 절약하도록 설계되었다!
3d602d80600a3d3981f3363d3d373d3d3d363d73bebebebebebebebebebebebebebebebebebebebe5af43d82803e903d91602b57fd5bf3
Plain Text
복사
이 바이트코드는 크게 4가지 부분으로 나뉜다.
Init code
콜데이터 복사
Implementation 주소 삽입
Implementation으로 delegatecall 수행 & 실행 결과에 따라 return 혹은 revert
Init Code는 Runtime Code를 메모리에 로드하고 반환하는 역할을 한다. 이 코드는 컨트랙트를 배포할 때만 사용되어 배포 후에는 Runtime Code만 체인에 남는다.
Runtime Code가 실제로 어떻게 동작하는지 어셈블리 레벨에서 간단히 살펴보자.
1.
콜데이터 복사
사용자가 Proxy를 호출할 때 보낸 콜데이터를 delegatecall에 전달하기 위해 콜데이터를 메모리로 복사하는 단계이다.
;stack [0a] CALLDATASIZE ; cds [0b] RETURNDATASIZE ; 0 cds [0c] RETURNDATASIZE ; 0 0 cds [0d] CALLDATACOPY ; 메모리 0번 위치에 calldata를 cds 크기만큼 복사
Assembly
복사
2.
구현 컨트랙트 주소 PUSH 및 delegatecall
바이트코드에 하드코딩된 20바이트의 구현 컨트랙트 주소를 스택에 올리고, 모든 가스를 전달하며 delegatecall을 실행한다.
여기서 재미있는 점은 Minimal Proxy가 제안되었던 때는 상하이 하드포크 이전으로, PUSH0명령어가 없어 PUSH1보다 저렴한 비용으로 스택에 0을 넣기 위한 꼼수가 들어있다는 것이다. 바로 RETURNDATASIZE를 이용하는 것이다. RETURNDATASIZE는 직전에 실행된 외부 호출이 없다면 0을 반환한다.
;stack [0e] RETURNDATASIZE ; 0 [0f] RETURNDATASIZE ; 0 0 [10] RETURNDATASIZE ; 0 0 0 [11] CALLDATASIZE ; cds 0 0 0 [12] RETURNDATASIZE ; 0 cds 0 0 0 ;pushes the 20 bytes address of the implementation contract [13] PUSH20 0ximpl ; 0ximpl cds 0 0 0 ;perform a delegate call on the implementation contract, and forward all available gas [28] GAS ; gas 0ximpl 0 cds 0 0 0 [29] DELEGATECALL ; sucess 0
Assembly
복사
3.
결과 처리
delegatecall의 실행 결과를 확인하고, 성공했다면 반환 데이터를 그대로 사용자에게 전달(return)하고, 실패했다면 트랜잭션을 되돌린다(revert).
;stack [2a] RETURNDATASIZE ; rds success 0 [2b] DUP3 ; 0 rds success 0 [2c] DUP1 ; 0 0 rds success 0 [2d] RETURNDATACOPY ; success 0 ; 조건부 점프를 위해 스택 셋업 [2e] SWAP1 ; 0 success [2f] RETURNDATASIZE ; rds 0 success [30] SWAP2 ; success 0 rds [31] PUSH1 2b ; 0x2b success 0 rds ; DELEGATECALL 결과가 성공이면 RETURN, 실패면 REVERT [33] JUMPI ; 0 rds; [34] REVERT [35] JUMPDEST [36] RETURN
Assembly
복사

코드로 보는 Minimal Proxy

필요한 개념을 모두 알았으니, 이제 코드를 보며 실제 Minimal Proxy가 어떻게 배포되는지 살펴보자. 아래는 OpenZeppelin의 Minimal Proxy용 라이브러리인 Clones.sol의 내용이다.
/** * @dev Same as {xref-Clones-clone-address-}[clone], but with a `value` parameter to send native currency * to the new contract. * * NOTE: Using a non-zero value at creation will require the contract using this function (e.g. a factory) * to always have enough balance for new deployments. Consider exposing this function under a payable method. */ function clone(address implementation, uint256 value) internal returns (address instance) { if (address(this).balance < value) { revert Errors.InsufficientBalance(address(this).balance, value); } assembly ("memory-safe") { // 0x00 메모리 위치에 바이트코드의 앞부분과 implementation 주소의 앞 3byte를 결합하여 저장 mstore(0x00, or(shr(0xe8, shl(0x60, implementation)), 0x3d602d80600a3d3981f3363d3d373d3d3d363d73000000)) // 0x20 메모리 위치에 implementation 주소의 나머지 17byte를 뒷부분과 결합하여 저장 mstore(0x20, or(shl(0x78, implementation), 0x5af43d82803e903d91602b57fd5bf3)) instance := create(value, 0x09, 0x37) } if (instance == address(0)) { revert Errors.FailedDeployment(); } }
Solidity
복사
Clones.sol
clone 함수는 인자로 Implementation 주소와 실행에 필요한 이더를 받아 배포할 Proxy의 바이트코드를 동적으로 생성한다. 어셈블리어의 주된 내용은 바이트코드 템플릿 사이에 Implementation 주소를 끼워넣는 것이다. Init Code와 delegatecall을 하는 부분은 변하지 않으므로 가능한 방식이다.
첫 번째 mstore
이 부분은 바이트코드의 앞부분인 Init Code와 Implementation 주소의 첫 3byte를 결합하여 저장하는 것이다.
(shr(0xe8, shl(0x60, implementation): 왼쪽으로 12바이트만큼 밀고 이를 다시 오른쪽으로 29바이트만큼 밀어 주소의 3byte가 메모리 가장 오른쪽에 남도록 한다.
or(shift res, init code): init code와 3byte 주소를 or 연산하여 결합한다(0과 다른 값을 or연산하면 값이 그대로이므로)
두 번째 mstore
이 부분은 나머지 Implementation 주소의 나머지 17byte를 delegatecall 부분과 이어붙이는 것이다.
(shl(0x78, implementation): 왼쪽으로 15바이트만큼 밀어 주소의 앞 3byte를 제외한 17byte가 메모리의 가장 왼쪽에 위치하도록 한다.
or(shift res, delegatecall block): 17byte 주소와 나머지 바이트코드를 or 연산하여 결합한다.
마지막으로 create명령어가 이리저리 요리한 최종 바이트코드를 사용하여 Proxy를 생성하고 주소를 반환한다. 이 주소가 0이라면 배포에 실패한 것이므로 오류를 발생시킨다.
이처럼 Minimal Proxy는 단 한 치의 낭비도 허용하지 않는 철저한 가스 다이어트를 통해 탄생한 배포 효율성의 결정체다. 실제로 스마트 지갑의 일종인 “gnosis safe wallet”에서 Minimal Proxy를 사용하고 있으니 활용 사례를 보고 싶다면 gnosis의 module factory를 참고하라.

업그레이드, 정말 포기해야 할까?

Minimal Proxy는 분명 매력적인 패턴이다. 하지만 로직에 버그가 발견되거나 정책 변경으로 인해 업그레이드가 필요할 때, Minimal Proxy는 적합하지 않다. 수백, 수천 개의 프록시를 새로 배포하고 데이터 마이그레이션을 진행하는 것은 현실적으로 불가능에 가깝다.
그렇다면 Minimal Proxy의 배포 효율성을 어느 정도 유지하면서, 모든 프록시의 로직을 한 번에 업그레이드할 수는 없을까?
이에 해답이 바로 비콘 프록시 패턴(Beacon Proxy Pattern)다.

Beacon Proxy Pattern(EIP-1822)

사용 목적

Beacon Proxy 패턴은 여러 Proxy가 동일한 Implementation 컨트랙트를 사용할 때, 이 Proxy 그룹 전체를 단 한 번의 트랜잭션으로 업그레이드하기 위해 고안된 강력한 업그레이드 패턴이다.
기존의 UUPS나 Transparent Proxy 패턴이 이미 단일 Proxy를 업그레이드하는 솔루션을 제공하고 있지만, 업그레이드해야 할 프록시가 100개라면 어떨까? 모든 프록시에 하나하나 upgradeToAndCall을 호출하여 구현 컨트랙트 주소를 변경하는 것은 너무 가혹하지 않은가? 그렇다면 Minimal Proxy 패턴을 사용한다면? 이 컨트랙트는 더이상 업그레이드가 불가하다.
Beacon Proxy 패턴은 Beacon이라는 중간자 컨트랙트를 하나 더 추가함으로써 이러한 문제를 간단하게 해결한다. Beacon 컨트랙트는 Proxy 그룹에 현재 시점에서 유효한 Implementation의 주소를 알려준다. 개별 프록시가 Implementation 컨트랙트의 주소를 저장하고 있는 대신, 지정된 Beacon 컨트랙트에서 필요할 때마다 구현 컨트랙트의 주소를 조회하는 것이다. 이를테면 Beacon은 DNS Resolver와 같은 역할을 하는 것이다.

Beacon Proxy 쉽게 이해하기

유명 프랜차이즈 식당을 예시로 생각해보자.
Implementation 컨트랙트: 각 메뉴의 원본 레시피북
Proxy 컨트랙트: 레시피북을 보고 가게를 운영하는 프랜차이즈 지점(1호, 2호, … , n호점)
어느날 본사에서 더 맛있는 레시피를 개발하여 모든 지점에 새로운 레시피북을 배포해야 한다. 이를 기존 업그레이드 방식과 Beacon Proxy 패턴을 이용한 방식으로 나눠 보자.
기존 업그레이드 방식(Transparent/UUPS)
기존의 UUPS나 Transparent Upgradeable 패턴은 각 지점이 인쇄된 레시피북 복사본을 하나씩 갖고 있는 것과 같다. 레시피북 교체를 위해서는 새로운 레시피북을 지점 개수만큼 인쇄하여 1호점부터 일일이 찾아가 새로운 레시피북을 지급해야 한다. 이러한 방식은 비용과 시간이 많이 들고, 특정 지점을 실수로 누락할 수도 있는 문제점이 있다.
Beacon Proxy 방식
이 방식은 각 지점마다 레시피북을 모두 갖고 있는 대신, 본사 비공개 사이트에 접속할 수 있는 태블릿 PC를 한 대씩 놓아두는 것과 같다.
Beacon: 모든 지점이 접속하는 본사 레시피 사이트
새로운 구현 컨트랙트: 사이트에 업로드되는 새로운 레시피북
이 경두 본사는 레시피북을 교체할 때 새로운 레시피북을 단 한 번만 작성해서 본사 사이트에 올리기만 하면 된다. 레시피북이 바뀌는 순간, 모든 지점의 태블릿이 동시에 새로운 레시피를 표시하게 된다. 시간과 비용을 획기적으로 절약할 수 있고, 누락에 대한 걱정도 사라진다!

코드로 보는 Beacon Proxy

그렇다면 이 패턴은 기술적으로 어떻게 구현될까? OpenZeppelin의 코드를 통해 두 가지 핵심 컨트랙트를 살펴보자.

UpgradeableBeacon

UpgradeableBeacon 컨트랙트는 마치 DNS Resolver처럼, 현재 유효한 Implementation 컨트랙트의 주소가 어디인지를 알려주는 역할을 담당한다. 간단한 컨트랙트이기 때문에 오직 다음 2개의 공개 함수만 갖고 있다.
implementation(): 현재 설정된 Implementation의 주소를 반환
upgradeTo(address): Implementation 컨트랙트의 주소 변경
물론 업그레이드 함수는 컨트랙트의 생성자에서 미리 설정해 놓은 owner만 사용할 수 있다. 업그레이드 함수는 내부적으로 _setImplementation를 불러 새로 업그레이드 할 구현 컨트랙트가 유효한 컨트랙트가 맞는지 확인하고 _implementation값을 새 구현 컨트랙트 주소로 변경한다. 업그레이드를 사용하면 Upgraded이벤트가 발생하여 온체인에서 업그레이드 내역을 쉽게 추적할 수 있다.
// SPDX-License-Identifier: MIT // OpenZeppelin Contracts (last updated v5.0.0) (proxy/beacon/UpgradeableBeacon.sol) pragma solidity ^0.8.20; import {IBeacon} from "./IBeacon.sol"; import {Ownable} from "../../access/Ownable.sol"; contract UpgradeableBeacon is IBeacon, Ownable { address private _implementation; error BeaconInvalidImplementation(address implementation); event Upgraded(address indexed implementation); constructor(address implementation_, address initialOwner) Ownable(initialOwner) { _setImplementation(implementation_); } function implementation() public view virtual returns (address) { return _implementation; } function upgradeTo(address newImplementation) public virtual onlyOwner { _setImplementation(newImplementation); } function _setImplementation(address newImplementation) private { if (newImplementation.code.length == 0) { revert BeaconInvalidImplementation(newImplementation); } _implementation = newImplementation; emit Upgraded(newImplementation); } }
Solidity
복사
UpgradeableBeacon.sol

BeaconProxy

BeaconProxy는 사용자와 직접 상호작용하는 Proxy 컨트랙트다. 특정 주소로 요청을 보내기 위해 DNS Query를 보내는 것처럼 Beacon 컨트랙트의 implementation을 호출하여 Implementation의 주소를 조회한다.
이 컨트랙트는 배포될 때 Implementation 주소를 조회할 Beacon의 주소를 설정한다. immutable로 선언되어 나중에 바꿀 수 없으니 신중하게 설정해야 한다. 이를 통해 매번 스토리지에서 sload를 통해 Beacon 주소를 읽어오지 않아도 되므로 가스 비용이 절약된다. Beacon주소는 eip1967.proxy.beacon namespace에 저장되긴 하지만, 블록 익스플로러가 beacon proxy임을 인지하게 하는 용도이지 beacon proxy 내부적으로 사용하진 않는다.
이 프록시의 핵심은 _implementation() 함수의 재정의에 있다. 사용자가 프록시를 통해 함수를 호출하면, 부모 컨트랙트인 Proxy는 delegatecall을 실행하기 전에 _implementation()를 호출하여 실제 로직이 담긴 컨트랙트 주소를 가져온다. BeaconProxy는 이 함수를 Beacon 컨트랙트의 implementation() 함수를 호출하도록 재정의함으로써, 항상 Beacon이 가리키는 최신 구현 컨트랙트를 바라보게 된다.
// SPDX-License-Identifier: MIT // OpenZeppelin Contracts (last updated v5.2.0) (proxy/beacon/BeaconProxy.sol) pragma solidity ^0.8.22; import {IBeacon} from "./IBeacon.sol"; import {Proxy} from "../Proxy.sol"; import {ERC1967Utils} from "../ERC1967/ERC1967Utils.sol"; contract BeaconProxy is Proxy { address private immutable _beacon; constructor(address beacon, bytes memory data) payable { ERC1967Utils.upgradeBeaconToAndCall(beacon, data); _beacon = beacon; } function _implementation() internal view virtual override returns (address) { return IBeacon(_getBeacon()).implementation(); } function _getBeacon() internal view virtual returns (address) { return _beacon; } }
Solidity
복사
BeaconProxy.sol

Factory 활용

Beacon Proxy 패턴은 여러 Proxy 컨트랙트를 한 번에 업그레이드하기 위해 사용되므로, 이 패턴을 사용한다는 것은 Proxy 컨트랙트를 여러 개 배포해야 한다는 뜻이다. 이때 각 Proxy를 개별적으로 배포하는 것은 매우 비효율적이다.
이러한 경우 Factory Contract를 사용하여 BeaconProxy를 생성하고 관리하면 편하다. Factory를 활용하면 일관된 방식으로 다수의 Proxy를 효율적으로 배포하고 추적할 수 있어 전체 시스템의 관리 복잡도를 크게 낮출 수 있다.

3줄 요약

1.
Minimal Proxy Pattern과 Beacon Proxy Pattern은 같은 로직을 사용하는 다수의 Proxy를 한 번에 배포해야 할 때 좋은 방식이다.
2.
Minimal Proxy는 업그레이드가 불가하고 Beacon Proxy는 업그레이드가 가능하다.
3.
Factory와 같이 사용하면 더욱 시너지를 낼 수 있다.