티스토리 뷰
EVM의 저장 공간
이더리움 가상머신(evm)에는 세 종류의 저장 공간이 있습니다.
- 스택 : evm은 스택을 기반으로 동작하는 가상머신으로, 모든 연산은 스택을 통해 이루어집니다. opcode와 opcode 실행에 필요한 값들이 저장됩니다.
- 메모리 : 데이터를 임시로 저장하는 데 사용됩니다. 스마트 컨트랙트가 실행되는 동안 활성화되고, 트랜잭션이 완료되면 사라집니다. 메모리는 가변적인 크기를 가지며, 필요에 따라 동적으로 확장됩니다.
- 스토리지 : 스마트 컨트랙트의 영구적인 데이터를 저장하는 공간입니다. 트랜잭션이 완료된 후에도 정보가 유지되며, 비용이 매우 높기 때문에 중요한 데이터만 저장해야 합니다.
콜 데이터는 함수를 호출할 때 입력 데이터로 사용되며, 메모리와 비슷하지만 변경이 불가능한 읽기 전용의 데이터입니다.
부담되는 스토리지 저장 비용
스토리지는 데이터를 영구적으로 저장(변경 가능)합니다. 구체적으로 스토리지 사용은 다음과 같은 이유로 비싸야만 합니다.
- 스토리지에 저장된 데이터는 전 세계의 모든 노드에 복제되어 저장되므로 이를 위한 동기화 및 유지 비용이 듭니다.
- 데이터를 저장하는 것뿐만 아니라, 추가적인 읽기 및 쓰기에 필요한 비용이 발생합니다.
- 만약 스토리지 비용이 낮다면, 악의적인 사용자들이 불필요하게 많은 데이터를 싼 가격에 저장함으로써 네트워크의 오버헤드를 증가시켜 서비스 거부 공격을 야기할 수 있습니다.
네트워크의 안정성과 보안을 위해 필요한 조치이기는 하나, 정직한 사용자들의 입장에서는 상당히 부담되는 것이 현실. 현재 스토리지 저장에는 대략 2만 가스 정도가 필요합니다. EIP-2200을 통해 스토리지에서 데이터를 제거할 경우 가스비를 일부 환불해 주는 메커니즘이 도입되긴 했으나, 일정한 비율로 환불(EIP-3592)되므로 여전히 스토리지 저장 비용으로 인한 과다한 가스 지출이 발생하고 있습니다.
그중 한 예로, ReentrancyGuard 추상 컨트랙트를 상속하여 재진입을 방지하는 메커니즘을 적용하는 경우를 살펴봅시다.
ReentrancyGuard의 noReentrant 변경자의 동작은 다음과 같습니다.
- _locked 값이 true인지 확인합니다. 만약 true라면 함수 실행은 revert 됩니다.
- _locked의 값을 true로 변경합니다. 이렇게 함으로써 함수로의 재진입을 막습니다.
- 함수 로직을 실행합니다.
- 함수가 종료되기 직전에 locked 값을 다시 false로 변경하여 잠금을 해제합니다.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.24;
abstract contract ReentrancyGuard {
bool private _locked;
modifier noReentrant() {
if (_locked) {
revert("ReentrancyGuard: reentrant call");
}
_locked = true;
_;
_locked = false;
}
}
ReentrancyGuard를 사용하지 않는 버전 V0와 ReentrancyGuard 상속하여 increment 함수에 noReentrant 변경자를 붙여 놓은 V1, 두 종류의 Counter 컨트랙트를 다음과 같이 구현했습니다.
// ReentrancyGuard를 사용하지 않은 버전
contract CounterV0 {
uint256 public number;
function setNumber(uint256 newNumber) public {
number = newNumber;
}
function increment() public {
number++;
}
}
// ReentrancyGuard를 사용한 버전
contract CounterV1 is ReentrancyGuard {
uint256 public number;
function setNumber(uint256 newNumber) public {
number = newNumber;
}
function increment() public noReentrant {
number++;
}
}
두 컨트랙트의 increment 함수를 호출하여 ReentrancyGuard를 사용했을 때와 안 했을 때의 가스 사용량을 비교해 보면 다음과 같습니다.
| src/Counter.sol:CounterV0 contract | | | | | |
|------------------------------------|-----------------|-------|--------|-------|---------|
| Deployment Cost | Deployment Size | | | | |
| 104479 | 264 | | | | |
| Function Name | min | avg | median | max | # calls |
| increment | 43401 | 43401 | 43401 | 43401 | 1 |
| number | 281 | 281 | 281 | 281 | 1 |
| src/Counter.sol:CounterV1 contract | | | | | |
|------------------------------------|-----------------|-------|--------|-------|---------|
| Deployment Cost | Deployment Size | | | | |
| 130939 | 388 | | | | |
| Function Name | min | avg | median | max | # calls |
| increment | 52694 | 52694 | 52694 | 52694 | 1 |
| number | 282 | 282 | 282 | 282 | 1 |
차이를 비교해 보면 무려 9,300 정도가 차이가 나며, 이더리움 메인넷에서의 가스 당 가격이 20 Gwei라고 쳤을 때 9,300 × 20 = 186,000 Gwei, 가격을 환산해 보면 약 0.60 USD ≒ 823.20 KRW입니다. 은행 계좌이체 수수료도 0원인 마당에 ReentrancyGuard 하나 만으로 이만한 수수료를 지불해야 한다는 것은 일반적인 사용자의 입장에서 부담이 될 수밖에 없습니다(물론 효용성 면에서 은행 계좌이체와 차이는 있겠지만). 이러한 이유로 가스비 최적화를 위해 일부에서는 ReentrancyGuard를 활용하지 않는 경우도 종종 있습니다.
어떻게 하면 ReentrancyGuard를 적용하여 보안을 강화하면서도 가스를 효율적으로 사용할 수 있을까요?
Transient Storage
임시 스토리지(transient storage)는 지난 2024년 3월 13일 이더리움 Cancun 하드포크(EIP-1153)를 통해 새롭게 도입된 키-값 저장 공간입니다. 임시 스토리지는 현재 트랜잭션의 맥락에서만 유효하며, 트랜잭션이 종료되면 저장된 값들은 사라집니다. 새롭게 추가된 임시 스토리지를 사용하는 opcode는 tstore와 tload가 있으며 이들의 실행 가격은 100 가스에 불과합니다.
tstore로 임시 스토리지에 값을 저장할 때는 32바이트 크기의 키를 사용해 위치를 지정합니다. 그리고 이렇게 저장된 값은 스토리지와는 완전히 별개의 저장 공간에 저장됩니다. 다음 예제는 동일한 키(슬롯 번호)를 사용하여 서로 다른 값을 tstore와 ssotre를 사용해 스토리지와 임시 스토리지에 각각 저장하고, sload와 tload를 사용해 불러오는 opcode를 나열해 놓은 것입니다.
// transient storage
PUSh2 0xFFFF
PUSH1 0
TSTORE
// storage
PUSh2 0xFF
PUSH2 0
SSTORE
// load from storage
PUSH1 0
SLOAD
// load from transient storage
PUSH1 0
TLOAD
opcode를 실행해 보면 각각의 저장 공간에 데이터가 저장되고 또 이로부터 서로 다른 값을 불러와 스택에 저장하는 것을 확인할 수 있습니다.
Transient Storage를 사용한 ReentrancyGuard
임지 스토리지를 사용해 ReentrancyGuard를 재구성해볼 수 있습니다. 컴파일러가 아직 Solidity 같은 고수준 언어에서 임시 스토리지를 사용하는 것을 허용하지 않으므로 어셈블리를 사용해 호출을 해줘야 합니다.
noReentrant의 동작은 이전과 동일합니다. 차이점이라고 한다면 별도의 스토리지 변수를 선언하지 않고 키를 사용해 임시 스토리지에 접근하는 것입니다.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.24;
abstract contract TransientReentrancyGuard {
bytes32 private constant _GUARD_SLOT = 0x8e94fed44239eb2314ab7a406345e6c5a8f0ccedf3b600de3d004e672c33abf5; // keccak256("ReentrancyGuard")
modifier noReentrant() {
assembly {
if tload(_GUARD_SLOT) { revert(0, 0) }
tstore(_GUARD_SLOT, 1)
}
_;
// Unlocks the guard, making the pattern composable.
// After the function exits, it can be called again, even in the same transaction.
assembly {
tstore(_GUARD_SLOT, 0)
}
}
}
TransientReentrancyGuard를 상속하여 increment 함수에 noReentrant 변경자를 붙여놓은 V2 컨트랙트를 다음과 같이 구현했습니다.
contract CounterV2 is TransientReentrancyGuard {
uint256 public number;
function setNumber(uint256 newNumber) public {
number = newNumber;
}
function increment() public noReentrant {
number++;
}
}
그러고 나서 이전에 작성한 V0, V1과 V2의 increment 함수를 호출한 경우의 가스 사용량을 한 번에 비교해 보면 다음과 같습니다.
| src/Counter.sol:CounterV0 contract | | | | | |
|------------------------------------|-----------------|-------|--------|-------|---------|
| Deployment Cost | Deployment Size | | | | |
| 104467 | 264 | | | | |
| Function Name | min | avg | median | max | # calls |
| increment | 43401 | 43401 | 43401 | 43401 | 1 |
| number | 281 | 281 | 281 | 281 | 1 |
| src/Counter.sol:CounterV1 contract | | | | | |
|------------------------------------|-----------------|-------|--------|-------|---------|
| Deployment Cost | Deployment Size | | | | |
| 130939 | 388 | | | | |
| Function Name | min | avg | median | max | # calls |
| increment | 52694 | 52694 | 52694 | 52694 | 1 |
| number | 282 | 282 | 282 | 282 | 1 |
| src/Counter.sol:CounterV2 contract | | | | | |
|------------------------------------|-----------------|-------|--------|-------|---------|
| Deployment Cost | Deployment Size | | | | |
| 131959 | 393 | | | | |
| Function Name | min | avg | median | max | # calls |
| increment | 43732 | 43732 | 43732 | 43732 | 1 |
| number | 281 | 281 | 281 | 281 | 1 |
임시 스토리지 기반 ReentrancyGuard를 사용한 V2는
- ReentrancyGuard를 사용하지 않은 V0의 가스 사용량에 비해 고작 300 가스 밖에 더 사용하지 않았고
- 스토리지 기반 ReentrancyGuard를 사용한 V1의 가스 샤용량에 비해 약 8,900 가스를 절약할 수 있습니다.
임시 스토리지를 활용함으로써 스토리지를 사용한 ReentrancyGuard에 비해 95% 이상의 가스를 절약할 수 있습니다.
Transient Storage의 위험성
그러나 한편으로 임시 스토리지를 제대로 이해하지 않고 사용할 경우 예상치 못한 결과로 이어지는 위험성이 있습니다.
스마트 컨트랙트에서의 합성성(Composability)은 여러 개별 스마트 컨트랙트를 결합하여 보다 복잡한 트랜잭션과 기능을 구성할 수 있도록 하는데 중점을 둡니다. 이는 각 컨트랙트가 다른 컨트랙트의 기능에 의존하거나 그 결과를 활용할 수 있음을 의미합니다. 예를 들어, 한 스마트 컨트랙트는 결제를 처리할 수 있고, 다른 하나는 거래에 필요한 조건을 검증할 수 있으며, 이 두 컨트랙트를 결합하여 안전한 거래 처리 시스템을 구성할 수 있습니다. 또한 하나의 복잡한 트랜잭션을 여러 개의 트랜잭션으로 나누어 실행하더라도 두 동작이 거의 구별되지 않을 정도로 합성적인 행동에 대한 결과가 대부분 보장됩니다.
그러나 임시 스토리지를 사용하는 경우, 합성성의 원칙을 위반할 수 있습니다. 다음 예를 살펴봅시다.
contract MulService {
function setMultiplier(uint256 multiplier) external {
assembly {
tstore(0, multiplier)
}
}
function getMultiplier() private view returns (uint256 multiplier) {
assembly {
multiplier := tload(0)
}
}
function multiply(uint256 value) external view returns (uint256) {
return value * getMultiplier();
}
}
MulService 컨트랙트는 임시 스토리지를 사용하여 값을 저장하고 불러옵니다. 이 컨트랙트를 외부에서 다음과 같이 여러 개의 트랜잭션으로 호출하면 어떤 결과가 나올까요?
- setMultiplier(42);
- multiply(2);
예상되는 결과는 '84'였지만 'multiply(2)'의 실제 결과는 '0'입니다. 'setMultiplier(42)'로 임시 스토리지에 저장된 42는 트랜잭션이 종료됨과 함께 제거되므로, 다음으로 실행된 트랜잭션에서 호출된 'multiply(2)'가 'getMultiplier()'를 통해 불러온 값은 0이 됩니다.
반면, 다음과 같이 두 동작을 하나의 함수로 묶어 실행하는 경우에는 예상되는 결과와 일치하는 결과를 얻을 수 있습니다.
contract MulServiceHelper {
MulService public svc;
uint256 public number;
constructor(MulService _svc) {
svc = _svc;
}
function multiply(uint256 multiplier, uint256 value) public {
svc.setMultiplier(multiplier);
number = svc.multiply(value);
}
}
또 다른 문제로는 임시 스토리지에 저장된 값을 적절하게 제거하지 않아 동일한 트랜잭션 내에서 다음 호출에 대한 예기치 못한 동작을 유발할 수 있다는 것입니다.
앞서 작성한 TransientReentrancyGuard를 다시 살펴봅시다. 트랜잭션이 종료된 뒤에 임시 스토리지에 저장된 데이터가 제거된다는 특성에 매몰되어 100 가스마저 절약하기 위해 다음과 같이 함수 로직이 실행되고 난 뒤에 임시 스토리지를 비워주지 않도록 코드를 수정했다고 봅시다.
abstract contract BrokenTransientReentrancyGuard {
bytes32 private constant _GUARD_SLOT = 0x8e94fed44239eb2314ab7a406345e6c5a8f0ccedf3b600de3d004e672c33abf5; // keccak256("ReentrancyGuard")
modifier noReentrant() {
assembly {
if tload(_GUARD_SLOT) { revert(0, 0) }
tstore(_GUARD_SLOT, 1)
}
_;
}
}
하나의 트랜잭션에서 다음과 같이 BrokenTransientReentrancyGuard를 상속하여 작성한 CounterV3의 increment 함수를 두 번 실행할 경우, 두 번째 호출에서 함수 실행이 revert 됩니다.
contract CounterV3Caller {
CounterV3 public v3;
constructor(CounterV3 _v3) {
v3 = _v3;
}
function incrementTwice() public {
v3.increment();
v3.increment();
}
}
function test_RevertIncrementV3TwiceByNotClearingTS() public {
// v3Caller에서 v3.increment()를 두 번 호출하면, 첫 번째 호출에서 ts가 0으로
// 초기화되지 않아서 두 번째 호출에서 revert된다.
vm.expectRevert();
v3Caller.incrementTwice();
}
그 이유는 두 번째 increment 호출에서 첫 번째 increment 호출과 동일한 맥락 상에서 0으로 초기화되지 않은 임시 스토리지의 값을 읽어 들여 noReentrant 변경자가 함수 호출을 revert 시킨 것입니다.
이처럼 임시 스토리지는 새롭게 도입된 메커니즘이고 아직 연구가 덜 된 만큼 사용함에 있어서 신중함이 필요합니다.
정리
- Dencun 하드포크를 통해 키-값으로 구성된 임시 스토리지와 새로운 opcode, tstore와 tload가 추가되었으며 이들의 비용은 100 가스입니다.
- 임시 스토리지에 저장된 값은 트랜잭션이 종료되는 시점에 제거됩니다.
- 임시 스토리지를 사용하면 스토리지를 사용하는 것에 비해 90% 이상의 비용을 절약할 수 있습니다.
- Solidity는 아직 임시 스토리지 사용을 허용하지 않으며, 어셈블리를 통해서만 실행할 수 있습니다.
- 임시 스토리지는 아직 연구가 덜 된 만큼 사용함에 있어서 신중함이 필요합니다.
Solidity 0.8.28 업데이트 (2024.10.13일 추가)
새로운 업데이트로 인해 다음과 같이 transient 키워드를 사용해서 임시 스토리지를 사용하는 변수를 명시적으로 선언할 수 있게 되었습니다. 덕분에 어셈블리를 사용하지 않고도 코드상에서 임시 스토리지를 사용할 수 있게 되어 코드가 좀 더 직관적으로 보이네요.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.28;
abstract contract TransientReentrancyGuard {
uint256 transient _locked;
modifier noReentrant() {
if (_locked == 1) {
revert("ReentrancyGuard: reentrant call");
}
_locked = 1;
_;
// Unlocks the guard, making the pattern composable.
// After the function exits, it can be called again, even in the same transaction.
_locked = 0;
}
}
다만 어셈블리를 사용하는 경우보다 미세한(10~20) 가스 차이가 발생할 수 있습니다.
| src/Counter.sol:CounterV2 contract | | | | | |
|------------------------------------|-----------------|-------|--------|-------|---------|
| Deployment Cost | Deployment Size | | | | |
| 128311 | 375 | | | | |
| Function Name | min | avg | median | max | # calls |
| increment | 43745 | 43745 | 43745 | 43745 | 1 |
| number | 281 | 281 | 281 | 281 | 1 |
가장 범용적으로 사용되는 OpenZeppelin 라이브러리에도 어셈블리를 사용해 ReentrancyGuard를 구현해 놓은 것을 확인할 수 있습니다.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {TransientSlot} from "./TransientSlot.sol";
/**
* @dev Variant of {ReentrancyGuard} that uses transient storage.
*
* NOTE: This variant only works on networks where EIP-1153 is available.
*
* _Available since v5.1._
*/
abstract contract ReentrancyGuardTransient {
using TransientSlot for *;
// keccak256(abi.encode(uint256(keccak256("openzeppelin.storage.ReentrancyGuard")) - 1)) & ~bytes32(uint256(0xff))
bytes32 private constant REENTRANCY_GUARD_STORAGE =
0x9b779b17422d0df92223018b32b4d1fa46e071723d6817e2486d003becc55f00;
/**
* @dev Unauthorized reentrant call.
*/
error ReentrancyGuardReentrantCall();
/**
* @dev Prevents a contract from calling itself, directly or indirectly.
* Calling a `nonReentrant` function from another `nonReentrant`
* function is not supported. It is possible to prevent this from happening
* by making the `nonReentrant` function external, and making it call a
* `private` function that does the actual work.
*/
modifier nonReentrant() {
_nonReentrantBefore();
_;
_nonReentrantAfter();
}
function _nonReentrantBefore() private {
// On the first call to nonReentrant, _status will be NOT_ENTERED
if (_reentrancyGuardEntered()) {
revert ReentrancyGuardReentrantCall();
}
// Any calls to nonReentrant after this point will fail
REENTRANCY_GUARD_STORAGE.asBoolean().tstore(true);
}
function _nonReentrantAfter() private {
REENTRANCY_GUARD_STORAGE.asBoolean().tstore(false);
}
/**
* @dev Returns true if the reentrancy guard is currently set to "entered", which indicates there is a
* `nonReentrant` function in the call stack.
*/
function _reentrancyGuardEntered() internal view returns (bool) {
return REENTRANCY_GUARD_STORAGE.asBoolean().tload();
}
}
| src/Counter.sol:OpenZeppelinCounter contract | | | | | |
|----------------------------------------------|-----------------|-------|--------|-------|---------|
| Deployment Cost | Deployment Size | | | | |
| 144871 | 452 | | | | |
| Function Name | min | avg | median | max | # calls |
| increment | 43867 | 43867 | 43867 | 43867 | 1 |
| number | 281 | 281 | 281 | 281 | 1 |
전체 코드
참고
'블록체인 > Ethereum' 카테고리의 다른 글
ERC-4337: 계정 추상화 - 간단 정리 (0) | 2024.04.23 |
---|---|
ERC-4337: 계정 추상화 - 테스트를 통한 Aggregator의 동작 이해 (0) | 2024.04.22 |
ERC-4337: 계정 추상화 - 테스트를 통한 Paymaster와 LegacyTokenPaymaster의 동작 이해 (0) | 2024.04.18 |
ERC-4337: 계정 추상화 - 테스트를 통한 Account Factory의 동작 이해 (0) | 2024.04.18 |
ERC-4337: 계정 추상화 - 테스트 수정 사항 (0) | 2024.04.17 |