Solidity 개발자가 스마트컨트렉트를 개발할 때, 어떤 설계와 코딩 방법을 사용하냐에 따라 가스비가 달라집니다.
회사에서는 개발비용을 줄여주는 개발자를 찾고, 사용자는 자신의 가스비 소비를 줄여주는 Dapp을 선호합니다.
스마트컨트렉트 개발자가 어떻게 코딩했냐에 따라, 즉 개발자의 역량에 따라 모두가 만족할 수 있는 Dapp이 개발될 수 있습니다.
그 역량중 하나가 바로 가스비 줄이는 스마트컨트렉트 코딩 방법이라고 생각합니다.
지금부터 사용자가 트랜잭션 시, 가스비를 효율적으러 발생할 수 있도록 하는 코딩에 대해 배워보겠습니다.
Example Code 📑
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
contract DownsizeGasFee {
uint256 public total;
/*
_array 배열에 있는 숫자를 전부 total에 저장합니다.
홀수는 저장하지 않습니다.
99이상은 저장하지 않습니다.
테스트 : _array = [1,2,3,4,5,6,100]
**/
function sum(uint[] memory _array) public {
for(uint i = 0; i < _array.length; i +=1) {
bool isEven = _array[i] % 2 == 0;
bool isLEssThen99 = _array[i] < 99;
if(isEven && isLEssThen99) {
total += _array[i];
}
}
}
}
예시 코드에서
파라미터 _array에 [1,2,3,4,5,6,100]를 넣고 function sum 실행했을 때, 총가스비는48860 gas가 발생했습니다.
실습 내용을 진행한 후, 총 가스비는 47040 gas가 발생했습니다.
48860 gas -> 47040 gas
결과적으로 가스비가 1820 gas 절약이 되었습니다.
지금부터 설명드리는 코딩을 통해 가스비가 어디까지 내려가는지 실습을 통해 알아보겠습니다.
(1) Memory vs Calldata
- function의 파라미터가 참조 타입(Reference Type)인 경우 memory가 아닌 calldata를 사용해주세요.
...
function sum(uint[] /* memory */ calldata _array) external {
for(uint i = 0; i < _array.length; i +=1) {
bool isEven = _array[i] % 2 == 0;
bool isLEssThen99 = _array[i] < 99;
if(isEven && isLEssThen99) {
total += _array[i];
}
}
}
...
48860 gas -> 48135 gas
파라미터의 저장 타입을 memory에서 calldata로만 바꿔도 가스비가 절약되는 걸 볼 수 있습니다.
Calldata is a non-modifiable, non-persistent area where function arguments are stored, and behaves mostly like memory.
Calldata는 함수 인수가 저장되고 대부분 Memory처럼 동작하는 수정 불가능하고 비영구적인 영역입니다.
- ethereum stackexcahnge 내용 일부
> 링크
calldata와 memory는 둘 다 임시의 값을 저장합니다. 그리고 트랜잭션이 완료된 후, 네트워크는 해당 데이터를 저장하거나 유지하지 않습니다.
하지만 Calldata에 저장된 데이터는 수정이 불가능하고 임의로 저장된 위치가 비영구적인 영역입니다.
- 비영구적 : 트랜잭션이 완료된 후 값이 지속되지 않음
즉 Calldata에 저장된 값의 데이터 사이즈가 변하지 않기 때문에, 예상 가능한 메모리 영역이 생성됩니다.
컴퓨터공학에서 메모리가 예상 가능할 경우 데이터를 굉장히 효율적으로 축적할 수 있습니다. 블록체인 네트워크고 마찬가지이기 때문에, 가스비에 대한 혜택을 줍니다.
(2) 상태 변수를 함수 내부 memory에 저장하기
...
uint256 public total;
function sum(uint[] calldata _array) external {
uint _total = total;
for(uint i = 0; i < _array.length; i +=1) {
bool isEven = _array[i] % 2 == 0;
bool isLEssThen99 = _array[i] < 99;
if(isEven && isLEssThen99) {
// totla += _array[i];
_total += _array[i];
}
total = _total;
}
}
...
48135 gas -> 47702 gas
sum 함수 외부의 상태 변수인 total을 sum 함수 내부의 _total에 저장해주었더니, 가스비가 줄어들었습니다.
블록체인 네트워크에 저장할 수 있는 영역이 3가지로 나누어져 있습니다.
The Ethereum Virtual Machine has three areas where it can store items.
- The first is “storage”, where all the contract state variables reside. Every contract has its own storage and it is persistent between function calls and quite expensive to use.
- The second is “memory”, this is used to hold temporary values. It is erased between (external) function calls and is cheaper to use.
- The third one is the stack, which is used to hold small local variables. It is almost free to use, but can only hold a limited amount of values.
- stack overflow 내용 일부
> 링크
1. "Storage"
모든 계약의 상태 변수가 저장되는 곳입니다. 데이터가 컨트렉트에 영구적으로 저장되며, 가스비가 비교적 비쌉니다.
2. "Memory"
임시의 데이터가 저장되는 곳입니다. 비영구적으로 저장되며, 트랜잭션 호출이 완료되면 데이터는 없어집니다.
3. "Stack"
작은 지역 변수를 보관하는 공간으로, 거의 무료로 사용이 가능합니다. 하지만 제한된 양의 값만 보유할 수 있습니다.
예시 코드에서 total은 상태 변수로 Storage에 저장됩니다. sum 함수 내부에 있는 _total은 Memory에 저장됩니다.
반복문을 통해 Storage에 위치한 total에 데이터를 반복적으로 저장하는 것보다, Memory에 위치한 _total에 반복문이 실행되는 동안 임의로 저장한 후, 마지막에 한 번만 Storage에 위치한 total에 데이터를 저장하는 게 가스비가 당연히 더 저렴할 겁니다.
(3) 작은 반복 회로 변수 지정 삭제하기
...
uint256 public total;
function sum(uint[] calldata _array) external {
uint _total = total;
for(uint i = 0; i < _array.length; i +=1) {
// bool isEven = _array[i] % 2 == 0;
// bool isLEssThen99 = _array[i] < 99;
if(_array[i] % 2 == 0 && _array[i] < 99) {
_total += _array[i];
}
total = _total;
}
}
...
47702 gas -> 47337 gas
데이터를 저장할 때, EVM에는 Opcode 명령어를 통해 실행됩니다.
- Memory 조작 :
MLOAD, MSTORE, MSTORE8, MSIZE
- Sotrage 조작 :
SLOAD, SSTORE
sum 함수 내부에 isEven과 isLessThen99 데이터는 함수 내부에 있음으로 Memory 조작 Opcode 명령어가 실행됩니다.
가스비는 어떤 Opcode가 얼마나 사용됐는지에 따라 지정됩니다.
반복문 내부에 새로운 변수를 새로 생성하는 코드 회로는 계속해서 데이터를 새로운 변수에 저장하는 행동이고, 반복해서 가스비가 사용되는 걸 의미합니다.
(4) 반복문 데이터 사용법 변경
...
uint256 public total;
function sum(uint[] calldata _array) external {
uint _total = total;
uint _length = _array.length;
// for(uint i = 0; i < _array.length; i +=1) {
for(uint i = 0; i < _length; ++i ) {
if(_array[i] % 2 == 0 && _array[i] < 99) {
_total += _array[i];
}
total = _total;
}
}
...
47337 gas -> 47303 gas
// 변경 전
for(uint i = 0; i < _array.length; i +=1)
//변경 후
uint _length = _array.length;
for(uint i = 0; i < _length; ++i )
_array.length는 Solidity에서 지원하는 함수입니다. EVM에서 데이터를 바로 추출할 수 있는 함수들이 Slidity에 지원이 됩니다.
for문 안에 있는 데이터들은 반복문이 실행될 때마다 반복해서 실행됩니다. EVM에서 지원하는 함수들은 Memory에 저장하는 것보다 가스비가 더 많이 나옵니다. 반복적으로 함수를 Call 하는 것보단 Memory에 저장해서 데이터를 호출하는 것이 훨씬 가스비가 저렴합니다.
i += 1( i = i + 1)은 "로직"입니다.
++i 는 EVM에서 지원하는 "함수"입니다.
함수는 로직을 압축 시킨 데이터입니다. Soldity에선 코드를 반복해서 사용하는것보다, 함수를 만들어서 호출하는 게 가스비가 저렴합니다.
그 이유는 함수를 저장할 때, 정해진 메모리에 저장됩니다. 그리고 한번 저장된 함수는 수정할 수 없습니다.
위에서 한번 말한 것처럼 네트워크에서 예상 가능한 메모리는 효율적으로 데이터를 저장할 수 있습니다.
로직은 메모리를 예상하지 못합니다.
때문에 자주 사용하는 로직은 함수화 해서 사용하는 게 가스비를 훨씬 효율적으로 사용할 수 있습니다.
(5) 배열 인덱싱 데이터 메모리에 저장하기
...
uint256 public total;
function sum(uint[] calldata _array) external {
uint _total = total;
uint _length = _array.length;
for(uint i = 0; i < _length; ++i ) {
uint _num = _array[i];
//if(_array[i] % 2 == 0 && _array[i] < 99) {
// _total += _array[i];
//}
if(_num % 2 == 0 && _num < 99) {
_total += _num;
}
total = _total;
}
}
...
47303 gas -> 47040 gas
배열에서 데이터를 추출하는 작업을 "인덱싱"이라고 합니다.
배열에 저장된 데이터를 조작(스택 조작)할 경우, Opcode 명령어가 실행됩니다.
스택 조작 :
POP, PUSH ,DUP, SWAP
// 인덱싱
_array[i]
배열에서 인덱스를 통해 데이터를 추출하는 작업은 가스비가 발생합니다.
때문에 반복해서 인덱싱을 하는 것보단, 한 번만 데이터를 추출하여, Memory에 저장한 후,
저장된 데이터를 호출해서 사용하는 것이 훨씬 가스비가 저렴합니다.
(6) 정리
수정 전
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
contract DownsizeGasFee {
uint256 public total;
/*
_array 배열에 있는 숫자를 전부 total에 저장합니다.
홀수는 저장하지 않습니다.
99이상은 저장하지 않습니다.
테스트 : _array = [1,2,3,4,5,6,100]
**/
function sum(uint[] memory _array) public {
for(uint i = 0; i < _array.length; i +=1) {
bool isEven = _array[i] % 2 == 0;
bool isLEssThen99 = _array[i] < 99;
if(isEven && isLEssThen99) {
total += _array[i];
}
}
}
}
수정 후
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
contract DownsizeGasFee {
uint256 public total;
/*
_array 배열에 있는 숫자를 전부 total에 저장합니다.
홀수는 저장하지 않습니다.
99이상은 저장하지 않습니다.
테스트 : _array = [1,2,3,4,5,6,100]
**/
function sum(uint[] calldata _array) external {
uint _total = total;
uint _length = _array.length;
for(uint i = 0; i < _length; ++i ) {
uint _num = _array[i];
if(_num % 2 == 0 && _num < 99) {
_total += _num;
}
total = _total;
}
}
}
48860 gas -> 47040 gas
결과적으로 가스비가 1820gas 절약이 되었습니다.
Solidity가 EVM에서 어떻게 적용되는지
Solidity의 메모리가 어떻게 저장되는지
Solidity의 Opcode가 언제 사용되는지
Solidity에 대해 깊게 이해하면 사용자가 사용하는 가스비를 절약시켜 줄 수 있습니다.
마지막으로 정리를 하겠습니다.
- (1) 함수의 Parameter에는 Memory보단 Calldata를 사용하자
- 예상 가능한 메모리를 사용하는 데이터는 가스비가 저렴하다
- (2) 상태 변수 함수를 Memory에 저장하여 사용하자
- 함수 외부에 저장돼있는 상태 변수는 Storage에 저장되고, 함수 내부의 변수는 Memory에 저장된다. 서로 다른 위치에 보관된 데이터를 조회 & 수정할 때, Memory가 훨씬 가스비가 저렴하다.
- (3) 작은 로직 회로는 변수에 저장하지 말고 바로 사용하자.
- EVM에서 데이터에 저장되는 작업은 Opcode를 실행시키고, 어떤 Opcode를 얼마나 사용했는지에 따라 가스비 측정이 달라진다. Opcode 사용을 최소화하자
- (4) 로직 보단 함수를 사용하자
함수는 예상 가능한 메모리에 저장되기에 메모리 효율이 효율적이다. 로직은 예상할 수 있는 범위가 아니기에, 메모리 효율이 떨어진다. 때문에 로직이 함수보다 가스비가 많이 필요하다.
- (5) 배열 인덱싱은 가스비가 많이 드니, 반복되는 인덱싱은 Memory에 저장해서 사용하자
- 배열 인덱싱은 스택 조작 Opcode를 사용한다. 때문에 반복되는 Opcode는 가스비를 반복적으로 발생시킨다.
이상 트랜잭션 시 가스비를 줄일 수 있는 Solidity 코딩에 대해 실습해봤습니다.
Reference 📘
'Block Chain > Solidity' 카테고리의 다른 글
[Solidity] Array Memory에서 사용하는 방법 || Solidity 0.8 || (0) | 2022.12.17 |
---|---|
[Solidity] Unchecked || Optimization of gas cost | Solidity 0.8 || (0) | 2022.11.27 |
[Solidity] Event || emit | indexed | ethers | Solidity 0.8 || (0) | 2022.11.21 |
[Solidity] Access Control 구현 || Grant & Revoke Role | Solidity 0.8 || KR (2) | 2022.10.01 |
[Solidity] ABI 인코딩(encoding) || Solidity 0.8 || KR (0) | 2022.09.26 |
댓글