본문으로 건너뛰기
0%

Solidity 가스 최적화: 스마트 컨트랙트를 더 저렴하고 효율적으로 만드는 12가지 기법

Usman Asim headshot

작성자 Usman Asim

2025년 11월 19일에 게시됨13분 읽기

Solidity 가스 최적화를 나타내는 주유소 일러스트

Ethereum 가스 비용은 생태계의 오랜 골칫거리였습니다. 단순한 온체인 트랜잭션에 20달러 이상의 가스비를 내고 싶은 사용자가 어디 있겠습니까? 지난 몇 년간의 Ethereum 업그레이드로 사용자의 가스 비용이 상당히 낮아졌지만, Solidity 코드에서 가스를 최적화하는 것은 여전히 사용자에게 과도한 부담을 주지 않으면서 앱에서 복잡한 온체인 작업을 가능하게 하는 주된 방법입니다.

코드 리뷰에서 리스크를 줄이려는 경우든, 더 깔끔한 컨트랙트를 작성하려는 경우든, 가스 최적화를 제대로 하면 수백만 사용자로 확장 가능한 안전하고 저렴한 앱을 만들 수 있습니다. 이 가이드에서는 가스의 기본 개념, 최적화가 중요한 이유(스포일러: 최적화되지 않은 코드에 비해 비용을 20-50% 절감할 수 있습니다), 그리고 코드 예제와 함께 12가지 최적화 기법을 다룹니다.

Solidity에서 가스와 가스 최적화란 무엇인가?

가스는 Ethereum에서 특정 연산을 수행하는 데 필요한 계산 작업량을 측정하는 단위이며, Solidity 가스 최적화는 Solidity 스마트 코드를 실행하는 데 드는 비용을 줄이는 과정입니다.

Ethereum에서 트랜잭션을 처리할 때마다 비용이 발생합니다. 데이터를 스토리지에 쓰거나 트랜잭션을 처리하는 비용이며, 이 비용을 적절하게도 "가스"라고 부릅니다. 가스비를 지불하지 않으면 아무 일도 일어나지 않습니다. 자동차가 움직이려면 기름이 필요한 것과 마찬가지입니다.

컨트랙트에도 이 가스가 필요합니다. 스마트 컨트랙트를 배포하고 실행할 때 벌어지는 일은 다음과 같습니다:

  1. Solidity 코드를 작성합니다. 이는 Ethereum 스마트 컨트랙트를 위한 고수준의 사람이 읽을 수 있는 프로그래밍 언어입니다.
  2. 컴파일러가 이를 바이트코드로 변환합니다. Solidity 코드를 컴파일하면 컴파일러가 이를 저수준 명령어를 16진수로 표현한 바이트코드로 번역합니다. 실제로 블록체인에 저장되는 것이 이 바이트코드입니다.
  3. 바이트코드는 옵코드를 포함합니다. 바이트코드는 옵코드(operation code)로 구성되는데, 이는 EVM의 명령어 집합입니다. Ethereum의 어셈블리어라고 생각하면 됩니다. 각 옵코드는 "두 숫자를 더하기", "스토리지에서 읽기", "다른 명령어로 이동하기"와 같은 특정 연산을 나타냅니다.
  4. EVM이 옵코드를 실행합니다. 누군가 스마트 컨트랙트를 호출하면, 네트워크 전체 노드에서 실행되는 Ethereum Virtual Machine(EVM)이 바이트코드를 읽고, 이를 개별 옵코드로 디코딩한 후 하나씩 실행합니다. 각 옵코드에는 고정된 가스 비용이 있습니다.

예를 들어, ADD 옵코드(두 숫자를 더하는 연산)는 3 가스가 들지만, SSTORE(스토리지에 쓰는 연산)은 최소 20,000 가스가 듭니다. EVM은 트랜잭션 실행 중 실행되는 모든 옵코드의 가스 비용을 합산합니다.

Ethereum의 가스 모델에 대해 더 알고 싶다면 공식 문서를 확인하세요.

가스 최적화란 같은 작업을 더 적은 연산으로 수행하도록 코드를 조정하여 실행 비용을 줄이는 것입니다. 앞서 언급했듯이 모든 트랜잭션은 가스(ETH 또는 다른 체인에서의 등가물로 지불)를 필요로 합니다. Dencun 이후 블롭 덕분에 데이터 가용성 비용은 저렴해졌지만, 실행 가스(연산 비용)는 여전히 쌓일 수 있습니다. 최적화된 컨트랙트는 사용자의 비용을 절감할 뿐 아니라, 코드베이스를 더 가볍게 만들어 DoS 공격을 방어하는 데도 도움이 됩니다. 옵코드에 대해 더 깊이 알아보려면 "Gas Optimizer" 문서를 참고하세요.

개발자에게 가스 최적화가 중요한 이유

높은 가스비는 사용자를 불편하게 만들고, 특히 트래픽이 급증해 가스비가 치솟는 시기에는 사용자가 앱 사용을 중단하게 만들 수 있습니다.

낮은 가스 사용을 위해 코드를 최적화하면 사용자의 수수료를 줄이고, 더 나은 UX를 제공하며, 수수료 때문에 비경제적이던 저가치 트랜잭션을 가능하게 하고, 블록 한도에 걸리지 않으면서 높은 사용량을 처리할 수 있습니다.

최적화되지 않은 컨트랙트는 20-50%의 추가 가스를 소모하여 비용을 높이고 공격 경로를 열어줄 수 있습니다. 2025년 10월 기준 DeFi TVL이 거의 1,500억 달러에 달하는 상황에서, 가스 효율적인 컨트랙트는 단순히 있으면 좋은 것이 아니라 사용자 채택을 좌우할 수 있는 경쟁 우위입니다.

복잡한 스마트 컨트랙트 로직 때문에 사용자가 그 복잡성에 따른 높은 수수료를 부담하게 만들거나, 코드를 최적화해서 사용자의 최종 경험을 더 낫고 더 저렴하게 만들거나, 둘 중 하나입니다.

상위 12가지 Solidity 가스 최적화 기법

다음은 실전에서 검증된 코드 가스 최적화 방법입니다. 각 예제와 그것이 가스를 절감하는 방식을 설명하고 코드를 보여드립니다. Remix나 Hardhat에서 직접 테스트해보며 차이를 확인해보시기 바랍니다.

1. 배열 대신 매핑 사용하기

Solidity는 데이터 목록을 저장하기 위한 두 가지 주요 데이터 구조를 제공합니다: 배열과 매핑입니다. 문법은 비슷해 보이지만, 용도가 매우 다르고 가스 비용도 크게 차이납니다.

배열은 순서가 있고 순회 가능한 컬렉션으로, 메모리나 스토리지에 요소를 순차적으로 저장합니다. 모든 항목을 순회하거나 특정 순서를 유지해야 할 때 유용합니다. 하지만 배열에서 특정 항목을 찾으려면 순회가 필요합니다. EVM은 일치하는 항목을 찾을 때까지 각 요소를 확인해야 합니다. 즉, 조회 연산의 복잡도는 O(n)이며, 배열이 커질수록 비용이 증가합니다.

매핑(해시 테이블이라고도 함)은 완전히 다르게 작동합니다. 키-값 구조를 사용하여 저장된 항목 수와 무관하게 O(1) 상수 시간에 키로 값을 바로 조회할 수 있습니다. 이는 Solidity가 해시 함수를 사용해 키로부터 스토리지 슬롯을 직접 계산하기 때문에 가능하며, 데이터를 탐색할 필요가 없습니다. 대신 매핑은 순회할 수 없다는 트레이드오프가 있습니다. 별도로 추적하지 않으면 모든 항목을 순회하거나 어떤 키가 존재하는지 알 수 없습니다.

가스에 미치는 영향: 반복 순회 오버헤드가 없기 때문에 매핑 항목을 생성하고 접근하는 것이 배열 연산보다 훨씬 저렴합니다. 모든 항목을 순회하거나 삽입 순서를 유지해야 하는 특별한 경우에만 배열을 사용하세요. 그 외의 경우, 특히 사용자 잔액, 소유권 기록, 또는 키 기반 조회에는 매핑이 확실한 승자입니다.

배열을 사용한 예시입니다(접근 비용이 더 비쌈):

solidity
Copied
string[] public cars = ["ford", "audi", "chevrolet"]; // To find "audi", you'd need to loop through the array function findCar(string memory target) public view returns (bool) { for (uint i = 0; i < cars.length; i++) { if (keccak256(bytes(cars[i])) == keccak256(bytes(target))) { return true; // Cost increases with array size } } return false; }

동일한 데이터를 매핑으로 사용한 예시입니다(조회 비용이 훨씬 저렴함):

solidity
Copied
mapping(uint => string) public cars; constructor() { cars[101] = "Ford"; cars[102] = "Audi"; cars[103] = "Chevrolet"; } // Direct access - O(1) constant time regardless of data size function getCar(uint id) public view returns (string memory) { return cars[id]; // Single storage read, minimal gas }

정수 키를 사용하면 배열의 순회 비용 없이 순서가 있는 목록을 흉내낼 수 있습니다. 이는 ID로 빠르고 직접적인 접근이 필요한 잔액이나 소유권 기록 같은 사용자 데이터에 특히 유용합니다. 매핑이 내부적으로 어떻게 동작하는지 더 알고 싶다면 Solidity 매핑 문서를 참고하세요.

2. Solidity 컴파일러 옵티마이저 활성화하기

Solidity 컴파일러 옵티마이저는 가스 비용을 크게 줄일 수 있는 강력한 도구이지만, 사용 사례에 맞는 설정이 필요합니다. 옵티마이저는 코드를 분석하여 다양한 변환을 적용하는 방식으로 작동합니다: 표현식을 단순화하고, 불필요한 코드를 제거하며, 비용이 큰 점프 연산을 없애기 위해 작은 함수를 인라인화하고, 중복된 코드 세그먼트를 재사용합니다. 이 모든 변경은 EVM이 실행해야 하는 옵코드 수를 줄입니다.

하지만 "runs" 파라미터가 제어하는 중요한 트레이드오프가 있습니다. 이 숫자는 컨트랙트 수명 동안 각 옵코드가 몇 번 실행될 것으로 예상하는지를 옵티마이저에 알려줍니다. 옵티마이저는 이를 사용해 배포 비용(한 번만 발생) 최소화와 런타임 실행 비용(모든 함수 호출마다 반복 발생) 최소화라는 상충하는 두 목표의 균형을 맞춥니다.

runs 파라미터의 작동 방식:

  • 낮은 runs (예: 200): 옵티마이저가 더 작은 바이트코드를 우선시하여 배포 비용이 저렴해집니다. 코드 중복이 줄고 재사용 가능한 코드 세그먼트 간 점프가 늘어납니다. 자주 배포하지만 드물게 호출되는 컨트랙트에 적합합니다. 예를 들어 팩토리 컨트랙트나 일회성 배포 스크립트가 있습니다.
  • 높은 runs (예: 10,000 이상): 옵티마이저가 점프를 피하기 위해 코드를 중복시키고 함수를 대폭 인라인화하여 런타임 효율을 우선시합니다. 이는 더 큰 바이트코드(더 비싼 배포)를 만들지만, 함수 실행은 더 빠르고 저렴해집니다. 트랜잭션 볼륨이 높은 컨트랙트, 예를 들어 DEX 라우터, 스테이킹 컨트랙트, NFT 마켓플레이스에 적합합니다.

낮은 runs(200)로 배포가 잦은 앱을 위한 예시입니다:

javascript
Copied
module.exports = { solidity: { version: "0.8.9", settings: { optimizer: { enabled: true, // Set to true for opt runs: 200, }, }, },};

높은 runs(10000)로 최적화된 배포가 드문 앱을 위한 예시입니다:

javascript
Copied
module.exports = { solidity: { version: "0.8.9", settings: { optimizer: { enabled: true, runs: 10000, }, }, },};

적절한 runs 값 선택하기

두 접근 방식 모두 트레이드오프가 있습니다. 컨트랙트의 라이프사이클을 생각해보세요. 한 번 배포되지만 수백만 건의 전송 트랜잭션이 발생하는 거버넌스 토큰은 높은 runs를 사용해야 합니다. 새로운 컨트랙트를 계속 생성하지만 그 컨트랙트가 거의 호출되지 않는 배포 팩토리는 낮은 runs를 사용해야 합니다. 확신이 서지 않을 때는 200이 두 관심사를 적절히 균형 잡는 안전한 기본값입니다. 컨트랙트의 프로파일에 맞게 자유롭게 테스트해보시고, Hardhat 설정이나 Remix에서 어느 쪽이든 활성화할 수 있습니다.

3. 온체인 데이터 최소화하기

온체인 스토리지는 Solidity에서 단연 가장 비싼 연산입니다. 각 SSTORE 옵코드(스토리지에 쓰기)는 20,000 가스 이상(Gwei 단위로 측정)이 들 수 있으며, 기존 스토리지 슬롯을 수정하는 것도 2,900-5,000 가스가 듭니다. 이를 3 가스만 드는 메모리 연산과 비교하면 스토리지 최적화가 왜 중요한지 금방 알 수 있습니다. 여기서 기본 원칙은 단순합니다: 온체인에는 반드시 필요한 것만 저장하고, 나머지는 API, 오라클, 인덱싱 서비스를 통해 오프체인에서 처리하세요.

스토리지 쓰기를 줄이는 것을 넘어, 중복 비용을 피하기 위해 연산을 영리하게 그룹화해야 합니다. 이렇게 하면 컨트랙트가 가벼워져 배포 및 런타임 수수료가 모두 줄고, DoS 공격에 악용될 수 있는 연산이 많은 함수를 공격자가 노리기 어려워집니다.

스토리지 변수에 데이터 저장하기

스토리지에 저장할 가치가 있는 것에 대해 엄격해야 합니다. 예를 들어 사용자 잔액, 소유권 기록, 그리고 변조 불가능하고 전역적으로 접근 가능해야 하는 컨트랙트 상태는 온체인에 있어야 합니다. 가스 최적화를 목표로 한다면, 나머지는 모두 오프체인에 있어야 합니다. 예를 들어 외부 가격 데이터가 필요하다면, 과거 가격을 저장하는 대신 Chainlink 같은 오라클을 사용해 실행 시점에 가져오세요. 프론트엔드를 위해 트랜잭션 이력을 추적해야 한다면, 배열을 저장하는 대신 이벤트를 발생시키세요.

이벤트와 관련한 중요한 함정이 있습니다: 이벤트를 발생시키는 것은 저렴하지만(기본 약 375 가스 + 토픽당 375 가스), 컨트랙트는 자신이 발생시킨 이벤트를 읽을 수 없습니다. 이벤트는 순전히 인덱서와 프론트엔드의 오프체인 소비를 위해 존재합니다. 컨트랙트 로직이 접근해야 하는 스토리지 대신 이벤트를 사용하지 마세요.

연산 배칭하기

사용자에게 여러 개의 개별 트랜잭션을 제출하도록 요구하는 대신, 관련된 작업을 하나의 트랜잭션으로 묶으세요. 이렇게 하면 모든 트랜잭션에 부과되는 기본 21,000 가스 비용을 절약할 수 있고, msg.sender을 여러 번 확인하거나, 같은 스토리지 변수를 반복해서 불러오거나, 여러 트랜잭션 제출에 대한 calldata 비용을 지불하는 것과 같은 중복 연산도 줄일 수 있습니다.

이 패턴은 토큰 승인 후 전송처럼 여러 단계로 구성된 프로세스나, 여러 DeFi 연산을 원자적으로 실행하는 경우(스왑 후 스테이킹, 그리고 보상 청구)에 특히 유용합니다.

배치 전송 함수의 예시입니다:

solidity
Copied
Struct Call { address recipient; uint256 gas; uint256 value; bytes data; } function batchSend(Call[] memory _calls) public payable { for(uint256 i = 0; i < _calls.length; i++) { (bool _success, bytes memory _data) = _calls[i].recipient.call{ gas: _calls[i].gas, value: _calls[i].value }(_calls[i].data); if (!_success) { assembly { revert(add(0x20, _data), mload(_data)) } } } }

이 패턴은 반복되는 msg.sender 검증을 제거하고, calldata 오버헤드를 줄이며(함수 셀렉터를 한 번만 전달), 기본 트랜잭션 수수료도 연산마다가 아니라 한 번만 지불하도록 하여 가스를 크게 절약합니다.

루프

루프는 가스 승수와 같습니다. 매 반복마다 같은 연산이 반복되며 비용이 선형적으로 쌓입니다. 100개 항목에 대해 스토리지 연산을 수행하는 루프는 500,000 가스 이상을 소모할 수 있으며, 크기가 정해지지 않은 배열에 대한 루프는 블록 가스 한도를 초과해 함수를 영구적으로 호출 불가능하게 만들 수도 있습니다.

해결책은 거의 항상 루프를 완전히 제거하는 것입니다. O(n) 배열 순회 대신 O(1) 상수 시간 조회를 위해 매핑을 사용하세요. 반드시 순회해야 한다면 배열 크기를 엄격히 제한하거나, 더 나은 방법으로는 순회를 오프체인으로 옮기고 사용자가 특정 인덱스나 키를 제출하게 하세요.

가스 환급에 대한 참고사항

Solidity는 예전에 스토리지를 정리(값을 0으로 설정)하면 가스를 환급해줬지만, EIP-3529로 인해 이 환급이 크게 줄었습니다. 여전히 스토리지 정리에 대해 소액의 환급을 받을 수 있지만, 더 이상 주요 최적화 전략은 아닙니다. 현재의 환급 메커니즘에 대한 자세한 내용은 Ethereum 가스 환급 제안을 참고하세요.

4. 인덱싱된 이벤트 사용하기

이벤트는 스토리지 연산 비용의 극히 일부만 드는 가벼운 로깅 메커니즘입니다. 기본 약 375 가스에 인덱싱된 파라미터당 375 가스가 더해지는 정도로, 스토리지 쓰기의 20,000 가스 이상에 비하면 훨씬 저렴합니다. 이벤트는 컨트랙트 상태 스토리지와는 별개인 트랜잭션 영수증 트라이에 기록되어, 오프체인 애플리케이션이 추적해야 하는 정보를 기록하기에 완벽합니다.

핵심 제약사항: 이벤트는 컨트랙트 입장에서 쓰기 전용입니다. 한 번 발생하면 컨트랙트 코드가 이를 다시 읽을 수 없습니다. 이벤트는 순전히 프론트엔드, 인덱서, 모니터링 도구 같은 외부 소비를 위해 존재합니다. 알림이나 오프체인 시스템이 필요로 하는 이력 기록에는 이벤트를 사용하되, 컨트랙트 로직이 의존하는 데이터에는 절대 사용하지 마세요.

이는 상당한 양의 가스를 오프로드합니다. 모든 트랜잭션을 비싼 스토리지 배열에 저장하는 대신, 이벤트를 발생시키고 The Graph나 Alchemy의 API 같은 오프체인 인덱서가 프론트엔드를 위한 이력을 구축하게 하세요.

이벤트를 선언하고 발생시키는 방법입니다:

solidity
Copied
event MyFirstEvent(address indexed sender, uint256 indexed amount, string message); function doSomething(uint256 _amount, string memory _message) public { // Your contract logic here // Emit the event - cheap logging instead of expensive storage emit MyFirstEvent(msg.sender, _amount, _message); }

인덱싱된 파라미터

최대 3개의 파라미터를 indexed로 표시할 수 있으며, 이렇게 하면 로그 쿼리에서 검색 가능해집니다. 예를 들어 senderamount이 인덱싱되어 있으면 모든 이벤트를 훑지 않고도 "sender = 0x123...인 모든 이벤트"를 빠르게 필터링할 수 있습니다. message 같은 인덱싱되지 않은 파라미터도 여전히 로그에 기록되지만 직접 검색은 불가능합니다.

일반적인 사용 사례로는 토큰 전송, 소유권 변경, 상태 전환, 사용자 활동 추적이 있으며, 온체인 접근은 필요 없지만 오프체인 소비를 위한 기록이 필요한 모든 경우에 해당합니다. 자세한 내용은 Solidity 이벤트 문서를 참고하세요.

5. 변수 패킹하기

EVM은 32바이트 슬롯 단위로 데이터를 저장하며, 각 스토리지 슬롯을 쓰는 데는 가스가 듭니다(새 슬롯은 20,000 가스 이상, 업데이트는 2,900-5,000 가스). 작은 변수들을 전략적으로 그룹화하면 여러 변수를 하나의 슬롯에 담을 수 있어 컨트랙트에 필요한 SSTORE 연산 횟수를 크게 줄일 수 있습니다.

여행 가방을 효율적으로 싸는 것과 비슷하다고 생각하면 됩니다. 물건을 배치하는 순서가 얼마나 많은 공간을 낭비하는지를 결정합니다. 변수는 선언한 순서대로 패킹되므로 신중한 배치가 중요합니다.

이전(3개 슬롯에 걸쳐 공간 낭비):

solidity
Copied
contract MyContract { uint128 c; // Slot 0 (uses 16 bytes, wastes 16 bytes) uint256 b; // Slot 1 (uses full 32 bytes) uint128 a; // Slot 2 (uses 16 bytes, wastes 16 bytes) }

데이터는 2.5개 슬롯 분량의 공간만 필요하지만 3개의 스토리지 슬롯을 사용합니다.

이후(2개 슬롯으로 압축):

solidity
Copied
contract MyContract { uint128 a; // Slot 0 (first 16 bytes) uint128 c; // Slot 0 (second 16 bytes) - packed together! uint256 b; // Slot 1 (full 32 bytes) }

uint128 변수를 연속해서 선언하면 하나의 슬롯을 공유하게 되어, 두 변수 모두에 쓸 때마다 SSTORE 연산 전체를 절약할 수 있습니다.

주요 패킹 규칙:

  • 변수는 선언 순서대로 패킹됩니다
  • 다음 변수가 현재 슬롯에 맞지 않으면 새 슬롯이 시작됩니다
  • uint8, uint128, address(20바이트) 같은 더 작은 타입이 패킹하기에 좋은 후보입니다
  • 작은 타입이 단독으로 있어도 여전히 전체 32바이트 슬롯을 소비하므로, 항상 짝을 지으려 시도하세요

예를 들어, 두 개의 address 변수(각각 20바이트)는 40바이트가 32바이트를 초과하므로 하나의 슬롯에 패킹되지 않습니다. 하지만 address(20바이트)과 uint96(12바이트)은 32바이트 슬롯 하나에 딱 맞습니다.

Solidity가 스토리지를 어떻게 배치하는지 더 알고 싶다면 스토리지 레이아웃 문서를 참고하세요.

6. 사용하지 않는 스토리지 정리하기

스토리지 변수를 기본값(정수는 0, 주소는 address\(0\), 불리언은 false)으로 재설정하여 정리하면 EVM은 가스 환급을 제공합니다. EIP-3529로 원래 금액보다 이 환급이 크게 줄었지만, 여전히 정리된 스토리지 슬롯당 4,800 가스를 돌려받을 수 있습니다. 이는 오래된 데이터를 정리할 때 의미 있는 회수입니다.

이는 스토리지 슬롯을 재설정하면 블록체인의 상태 크기가 줄어들기 때문에 작동하며, 그래서 Ethereum은 이러한 정리 행위를 장려합니다. 데이터가 더 이상 필요 없어졌을 때 일부 비용을 회수하면서 컨트랙트 상태를 가볍고 효율적으로 유지하는 방법입니다.

변수를 정리하는 방법입니다:

solidity
Copied
delete myVariable; // Resets to default value and triggers refund// Or explicitly: myInt = 0; myAddress = address(0); myBool = false;

매핑에 대한 중요한 참고사항: delete 키워드는 매핑이 어떤 키가 존재하는지 추적하지 않기 때문에 전체 매핑에는 작동하지 않습니다. 대신 개별 매핑 항목을 삭제해야 합니다:

solidity
Copied
mapping(address => uint256) public balances; // This won't work - can't delete entire mapping// delete balances;// Instead, delete specific keys: delete balances[msg.sender]; // Clears this specific entry

실용적인 사용 사례

가스 환급은 데이터에 명확한 라이프사이클이 있는 컨트랙트에서 가장 유용합니다. 완료 후 정리할 수 있는 에스크로 컨트랙트, 만료되는 임시 승인, 또는 오래되어 쓸모없어진 캐시된 데이터를 예로 들 수 있습니다. 환급을 좇기 위해 컨트랙트 로직을 억지로 비틀지는 마세요. 하지만 데이터가 자연스럽게 쓸모없어질 때 이를 정리하는 것은 서로에게 이득이 됩니다.

현재 환급 메커니즘과 제한사항에 대해서는 EIP-3529 가스 환급 문서를 참고하세요.

7. 특정 함수 파라미터에는 memory 대신 calldata에 데이터 저장하기

함수 파라미터를 선언할 때, 배열, 문자열, 구조체 같은 참조 타입에 대해 memorycalldata 중 하나를 선택할 수 있습니다. 이 차이를 이해하면 특히 파라미터가 큰 external 함수에서 상당한 가스를 절약할 수 있습니다.

Calldata는 트랜잭션 데이터에 직접 위치하는 읽기 전용 스토리지입니다. calldata를 사용하면 함수는 트랜잭션에서 인자를 어디로도 복사하지 않고 직접 읽습니다. 메모리 할당과 복사 연산을 완전히 피하기 때문에 이것이 가장 저렴한 선택입니다.

반면 Memory는 EVM이 공간을 할당하고 calldata의 데이터를 메모리로 복사해야 하며, 여러 번의 MLOADMSTORE 연산을 실행하게 됩니다. 이 복사 오버헤드는 큰 배열이나 문자열에서 비싸집니다. 복사되는 각 요소마다 추가 가스가 듭니다.

규칙: 읽기만 필요한 external 함수 파라미터에는 calldata를 사용하세요. 함수 내에서 데이터를 수정해야 할 때만 memory을 사용하세요.

calldata을 사용한 예시입니다(읽기 전용 접근에 더 저렴함):

solidity
Copied
function processNumbers(uint[] calldata nums) external { for (uint i = 0; i < nums.length; i++) { // Read-only operations - no copy needed uint value = nums[i]; // Do something with value } }

memory과 비교해보세요(복사 때문에 더 비쌈):

solidity
Copied
function processNumbers(uint[] memory nums) external { // Data gets copied from calldata to memory first (costs gas) for (uint i = 0; i < nums.length; i++) { uint value = nums[i]; } }

가스 절감폭은 데이터 크기에 비례해 커집니다. calldata로 전달된 100개 요소 배열은 memory에 비해 수천 가스를 절약할 수 있습니다.

memory를 반드시 사용해야 할 때: 함수가 배열을 수정하거나, 추가하거나, 새 데이터 구조를 만들어야 한다면 calldata가 불변이기 때문에 memory이 필요합니다. 하지만 순수 읽기 연산에는 calldata이 항상 더 나은 선택입니다.

스토리지 위치 간의 차이에 대해 더 알고 싶다면 calldata vs memory 문서를 참고하세요.

8. immutable과 constant로 고정값 사용하기

constantimmutable로 표시된 변수는 스토리지 슬롯을 전혀 사용하지 않습니다. 값이 배포 시점에 컨트랙트 바이트코드에 직접 새겨집니다. 이는 이러한 변수에 접근할 때마다 발생하는 비싼 SLOAD 연산(각 2,100 가스)을 없애고, 대신 저렴한 바이트코드 읽기로 대체합니다.

Constant: 값은 컴파일 시점에 설정되어야 하며 변경할 수 없습니다. 여러 배포에서도 절대 바뀌지 않는 하드코딩된 값에 사용하세요.

Immutable: 값은 생성자에서 한 번만 설정되며 이후에는 변경할 수 없습니다. 배포마다 다르지만(토큰 주소나 소유자 주소처럼) 배포되고 나면 고정되는 값에 사용하세요.

두 가지를 사용하는 방법입니다:

solidity
Copied
contract MyContract { uint256 constant FEE_RATE = 10; // Set at compile time address immutable owner; // Set once at deployment constructor(address _owner) { owner = _owner; // Can only set in constructor } function calculateFee(uint256 amount) public pure returns (uint256) { return amount * FEE_RATE / 100; // No SLOAD - reads from bytecode } }

가스 절감 예시: 함수에서 일반 스토리지 변수를 10번 읽으면 SLOAD 연산에 21,000 가스가 듭니다. constant이나 immutable를 사용하면 이러한 읽기는 사실상 비용이 거의 들지 않습니다. 기본 산술 연산을 실행하는 정도의 가스만 필요합니다.

일반적인 사용 사례:

  • 프로토콜 수수료율이나 비율(constant)
  • 소수점이나 스케일링 팩터 같은 수학 상수(constant)
  • 생성자 인자로 받은 토큰 주소(immutable)
  • 컨트랙트 소유자나 관리자 주소(immutable)
  • 변경되지 않는 외부 컨트랙트 주소(immutable)

constantimmutable 변수 모두 컨트랙트 외부의 파일 레벨에서도 선언할 수 있어, 같은 파일 내 여러 컨트랙트에서 재사용할 수 있습니다.

자세한 내용은 constants and immutables 문서를 참고하세요.

9. external 가시성 제한자 사용하기

함수 가시성 제한자는 EVM이 함수 호출을 처리하는 방식에 영향을 미치며 가스 비용에도 영향을 줄 수 있습니다. 핵심 차이는: external 함수는 컨트랙트 외부에서의 호출에 특화되어 최적화되어 있는 반면, public 함수는 external 및 internal 호출을 모두 처리해야 하므로 오버헤드가 추가됩니다.

External 함수는 외부에서만 호출할 수 있습니다(트랜잭션이나 다른 컨트랙트를 통해). 외부에서 호출될 때 파라미터를 복사 없이 calldata에서 직접 읽어 약간 더 효율적입니다. functionName\(\)를 사용해 external 함수를 내부에서 호출할 수 없습니다. this.functionName\(\)을 사용해야 하는데, 이는 비싼 external 호출을 만듭니다.

Public 함수는 외부와 내부 모두에서 호출할 수 있습니다. 이러한 유연성 때문에 컴파일러는 두 가지 호출 방식을 모두 처리하기 위한 추가 코드를 생성해야 하며, 외부에서 호출될 때조차 약간의 가스 오버헤드가 추가됩니다.

일반적인 경험칙: 컨트랙트의 공개 API의 일부이며 사용자나 다른 컨트랙트에서만 호출될 함수에는 external을 사용하세요. 컨트랙트 자체 함수만 호출해야 하는 헬퍼 함수에는 internalprivate을 사용하세요.

외부 호출에 최적화된 external 함수의 예시입니다:

solidity
Copied
function updateMessage(string calldata _newMessage) external returns (string memory) { message = _newMessage; return message; }

internal과 external을 모두 처리하는 public과 비교해보세요:

solidity
Copied
function updateMessage(string memory _newMessage) public returns (string memory) { message = _newMessage; return message; }

가스 절감폭은 크지 않습니다. 일반적으로 호출당 20-50 가스 정도지만, 수천 건의 트랜잭션에서 쌓이면 무시할 수 없습니다. 더 중요한 것은 external를 사용하면 의도가 명확해진다는 점입니다: 이 함수는 컨트랙트 외부에서 호출되도록 만들어졌다는 신호를 줍니다.

보너스 팁: external 버전에서 문자열 파라미터에 calldata을 사용한 것을 보셨나요? external 함수는 둘 다 외부 호출에 최적화되어 있어 calldata 인자와 완벽한 조합을 이룹니다.

함수 가시성과 모범 사례에 대해 더 알고 싶다면 visibility 문서를 참고하세요.

10. unchecked 산술 연산 안전하게 사용하기

Solidity 0.8.0부터 컴파일러는 모든 산술 연산에 오버플로우와 언더플로우 검사를 자동으로 추가합니다. 이는 버그를 방지하지만 연산당 대략 30-40 가스가 듭니다. 오버플로우/언더플로우가 수학적으로 불가능하다고 확신할 때는 unchecked 블록으로 연산을 감싸서 이러한 검사를 건너뛰고 가스를 절약할 수 있습니다.

안전한 경우: 알려진 범위를 가진 루프 카운터, 입력값을 검증한 산술 연산, 또는 오버플로우가 수학적으로 불가능한 연산.

피해야 할 경우: 검증되지 않은 사용자 입력값, 금융 계산, 또는 오버플로우가 취약점을 만들 수 있는 곳.

기본적인 예시입니다:

solidity
Copied
function add(uint x, uint y) external pure returns (uint) { unchecked { return x + y; // Skips overflow check - saves ~30 gas } }

이 기법의 가장 일반적인 사용 사례인 루프 카운터를 보여주는 또 다른 예시입니다:

solidity
Copied
function processArray(uint[] calldata items) external { for (uint i = 0; i < items.length;) { // Process item unchecked { ++i; // i can never realistically overflow } } }

이 루프에서 i은 0에서 시작해 매 반복마다 1씩 증가합니다. i가 오버플로우되려면 배열에 2^256개의 요소가 필요한데, 블록체인의 제약 조건상 물리적으로 불가능합니다. 이것이 unchecked에 완벽한 후보입니다.

중요한 안전 참고사항: unchecked을 사용한다면, 문제를 일으킬 수 있는 입력을 검증하기 위해 수동으로 require 문을 추가하세요:

solidity
Copied
function subtract(uint x, uint y) external pure returns (uint) { require(x >= y, "Underflow prevented"); unchecked { return x - y; // Safe because validated above } }

unchecked 블록으로 절약되는 가스는 루프나 자주 호출되는 함수에서 빠르게 누적됩니다. 자세한 내용과 예외 상황에 대해서는 unchecked 문서를 참고하세요.

11. 외부 호출 최소화하기

다른 컨트랙트를 호출할 때마다 CALL 옵코드에 최소 100 가스가 들며, 여기에 calldata 비용과 호출된 컨트랙트에서 발생하는 상태 변경에 대한 추가 비용이 더해집니다. 이러한 호출은 외부 컨트랙트가 되돌려질 경우 예측할 수 없게 실패할 수도 있어, 비용도 크고 위험도 있습니다. 외부 컨트랙트에서 데이터가 필요할 때는 여러 호출을 함께 배치하거나 결과를 캐싱해 같은 트랜잭션 내에서 반복 호출을 피하세요.

가스 효율적인 접근 방식:

solidity
Copied
interface IExternal { function getData() external view returns (uint); } function processData(address addr) external { // Call once and cache the result uint data = IExternal(addr).getData(); // Reuse cached value multiple times - no additional calls uint result1 = data * 2; uint result2 = data + 100; uint result3 = data / 5; }

비효율적인 접근 방식(피하세요):

solidity
Copied
function processData(address addr) external { // Calling three separate times - wastes ~300 gas uint result1 = IExternal(addr).getData() * 2; uint result2 = IExternal(addr).getData() + 100; uint result3 = IExternal(addr).getData() / 5; }

한 트랜잭션 내에서 같은 데이터가 여러 번 필요하다면 항상 로컬 변수에 캐싱하세요. 여러 트랜잭션에 걸쳐 외부 데이터가 필요하다면 저장하는 것도 고려해볼 수 있습니다(다만 20,000 가스의 SSTORE 비용을 외부 호출 빈도와 비교해 따져봐야 합니다).

외부 호출과 관련한 패턴 및 보안 고려사항에 대해서는 external calls best practices를 참고하세요.

12. 중요 경로에 어셈블리 사용하기

타이트 루프나 자주 호출되는 함수 같은 성능이 중요한 코드에서는 Yul 어셈블리로 내려가 Solidity 컴파일러가 달성할 수 있는 수준을 넘어 수동으로 최적화할 수 있습니다. 어셈블리는 메모리 관리를 직접 제어할 수 있게 해주고, 안전성 검사를 건너뛸 수 있게 하며, 추상화 오버헤드를 없애줍니다. 하지만 이것은 양날의 검입니다. 어셈블리는 Solidity의 모든 안전 기능을 우회하기 때문에 코드를 읽기 어렵게 만들고 오류가 발생하기 매우 쉽습니다.

어셈블리를 고려할 때: 고빈도 연산, 복잡한 비트 조작, 커스텀 메모리 레이아웃, 또는 모든 가스 단위가 중요한 수천 번 실행되는 루프.

어셈블리를 피해야 할 때: 그 외 모든 경우. 가스 절감이 버그, 보안 취약점, 유지보수 부담 증가라는 리스크를 정당화하는 경우는 거의 없습니다.

어셈블리를 사용해 배열의 합을 구하는 예시입니다:

solidity
Copied
function sum(uint[] memory arr) public pure returns (uint s) { assembly { let len := mload(arr) // Load array length let data := add(arr, 0x20) // Skip length, point to data for { let i := 0 } lt(i, len) { i := add(i, 1) } { s := add(s, mload(add(data, mul(i, 0x20)))) // Load and sum each element } } }

이 어셈블리 버전은 메모리 포인터를 직접 조작하고 경계 검사를 건너뛰어 가스를 절약하지만, 동등한 Solidity 코드에 비해 이해하고 감사하기가 훨씬 어렵습니다.

중요한 안전 수칙

  • 경계 사례를 포함해 어셈블리 코드를 철저히 테스트하세요
  • 모든 연산을 설명하는 상세한 주석을 추가하세요
  • 어셈블리 섹션은 보안 전문가의 감사를 받으세요
  • Solidity 최적화를 모두 시도한 후 최후의 수단으로만 어셈블리를 사용하세요

대부분의 개발자와 대부분의 사용 사례에서는 이 가이드의 다른 11가지 기법이 훨씬 적은 리스크로 더 나은 가스 절감 효과를 제공합니다. 어셈블리는 컨트랙트를 프로파일링하여 구체적인 병목 지점을 파악하고, 가스 절감이 추가된 복잡성을 정당화한다고 확인했을 때만 사용하세요.

Yul 문법과 기능에 대해 더 알고 싶다면 Yul 문서를 참고하세요.

배포 전 스마트 컨트랙트 테스트하기

메인넷에 배포하기 전에 개발 도구를 사용해 가스 사용량을 철저히 테스트하세요. Hardhat은 함수별 가스 비용의 상세한 분석을 생성하는 가스 리포터 플러그인을 제공합니다. Remix IDE는 테스트하는 동안 실시간으로 가스 추정치를 보여줍니다. 민트, 전송, 스왑 같은 사용자 대면 함수에 최적화 노력을 집중하세요. 이러한 함수가 가장 자주 호출되며 사용자 경험에 가장 큰 영향을 미칩니다.

더 깊이 있는 테스트를 위해서는 Foundry의 퍼징 기능을 사용해 수천 개의 무작위 입력값에 대해 최적화를 벤치마킹하세요. 이를 통해 실제 환경과 경계 사례에서도 가스 절감 효과가 유지되는지 확인할 수 있습니다.

마무리: 최적화를 시작하세요

이제 Solidity 컨트랙트를 더 가볍고 실행 비용이 저렴하게 만드는 12가지 실전 검증 기법을 갖추셨습니다. 가스 최적화는 단순히 비용을 절약하는 것이 아니라, 사용자를 위한 더 나은 경험을 만들고 사용자가 실제로 사용하고 싶어하는 애플리케이션을 만드는 것입니다.

우선 쉽게 얻을 수 있는 것부터 시작하세요: 컴파일러 옵티마이저를 활성화하고, 배열 대신 매핑을 사용하고, 고정값을 constant나 immutable로 표시하세요. 그런 다음 컨트랙트를 프로파일링하여 병목 지점을 찾고, 더 고급 기법을 가장 큰 영향을 미칠 곳에 적용하세요.

추가 학습을 위한 자료:

이제 나가서 최적화를 시작해보세요. 사용자들(그리고 그들의 지갑)이 감사해할 것입니다!

자주 묻는 질문

Solidity 스마트 컨트랙트에서 가스 비용을 줄이는 가장 효과적인 방법은 무엇인가요?

가장 영향력 있는 기법으로는 조회에 배열 대신 매핑 사용하기, 적절한 runs 설정으로 Solidity 컴파일러 옵티마이저 활성화하기, 고정값을 constant이나 immutable로 표시하기, 스토리지 연산 최소화하기, 읽기 전용 함수 파라미터에 memory 대신 calldata 사용하기가 있습니다.

constantimmutable 변수를 사용하는 것이 가스 최적화에 어떻게 도움이 되나요?

constantimmutable 값은 비싼 스토리지 슬롯에 저장되는 대신 컨트랙트 바이트코드에 직접 내장되어, 비용이 큰 SLOAD 연산(각 2,100 가스)을 없애고 저렴한 바이트코드 읽기로 대체합니다.

데이터 조회에 배열 대신 매핑을 사용해야 하는 이유는 무엇인가요?

매핑은 데이터 크기와 무관하게 O(1) 상수 시간 조회를 제공하는 반면, 배열은 커질수록 비용이 증가하는 O(n) 순회가 필요합니다. 매핑은 사용자 잔액이나 소유권 기록 같은 키 기반 접근 패턴에서 훨씬 저렴합니다.

Solidity 컴파일러 옵티마이저는 어떻게 작동하며 어떤 runs 설정을 사용해야 하나요?

옵티마이저는 표현식을 단순화하고, 불필요한 코드를 제거하고, 함수를 인라인화하여 가스를 줄입니다. 자주 배포하지만 드물게 호출하는 컨트랙트에는 낮은 runs(200)를 사용하고, 배포 비용보다 런타임 효율성이 더 중요한 트랜잭션 볼륨이 높은 컨트랙트에는 높은 runs(10,000 이상)를 사용하세요.

가스 최적화를 위해 calldata, memory, storage을 사용하는 것의 차이는 무엇인가요?

읽기만 하는 external 함수 파라미터에는 calldata을 사용하고(복사 비용 방지), 임시 데이터 조작에는 memory를 사용하며, 영구적인 상태가 필요할 때만 storage을 사용하세요. 읽기 전용 연산에는 calldata이 항상 memory보다 저렴합니다.

unchecked 산술 블록은 언제 안전하게 사용해야 하나요?

알려진 범위를 가진 루프 카운터나 검증된 산술 연산처럼 오버플로우가 수학적으로 불가능한 연산에 unchecked를 사용하세요. Solidity 0.8.0에서 도입된 자동 오버플로우 검사를 건너뛰어 연산당 약 30-40 가스를 절약할 수 있습니다.

가스 비용을 줄이기 위해 스토리지 연산을 어떻게 최적화할 수 있나요?

여러 개의 작은 변수를 하나의 32바이트 스토리지 슬롯에 패킹하고, 가스 환급을 위해 사용하지 않는 스토리지를 삭제하며(정리된 슬롯당 4,800 가스), 함수 실행 중 값을 메모리에 캐싱해서 스토리지 쓰기를 최소화하세요.

public보다 external 가시성 제한자를 사용하는 이점은 무엇인가요?

external 함수는 컨트랙트 외부에서의 호출에 특화되어 최적화되어 있고 calldata 파라미터와 효율적으로 작동하여, internal과 external 호출을 모두 처리해야 하는 public 함수에 비해 호출당 20-50 가스를 절약합니다.

Background gradient

블록체인 매직을 만드세요

Alchemy는 가장 강력한 Web3 개발자 제품 및 도구를 리소스, 커뮤니티, 그리고 전설적인 지원과 결합합니다.