콘텐츠로 건너뛰기

업그레이드 가능한 스마트 컨트랙트 작성 방법 이해하기

분산형 애플리케이션에서 업그레이드 가능한 스마트 컨트랙트의 중요성을 살펴보세요. 프록시 패턴을 통해 업데이트, 버그 수정 및 보안 강화를 가능하게 하는 방법을 알아보세요.

8분 읽기
업그레이드 가능한 스마트 컨트랙트 작성 방법 이해하기

스마트 컨트랙트는 중개자의 필요성을 없애는 자동 실행 프로그램을 도입함으로써 탈중앙화 애플리케이션에 대한 우리의 사고방식을 바꿔놓았습니다. 스마트 컨트랙트는 탈중앙화 블록체인 네트워크에서 투명하게 실행되며, 신뢰 불필요성과 향상된 보안 등의 이점을 제공합니다. 그러나 한때 강점으로 여겨졌던 불변성은 중요한 과제를 안겨줍니다. 스마트 컨트랙트는 한번 배포되면 변경할 수 없습니다. 이러한 경직성으로 인해 필요한 업데이트를 구현하거나 버그를 수정하기가 어렵습니다.

이 글에서는 업그레이드 가능한 스마트 컨트랙트의 개념을 살펴보고, 왜 필수적인지 설명하며, 특히 Linea zkEVM에서의 구현에 초점을 맞춰 작동 방식을 자세히 분석합니다.

업그레이드 가능한 스마트 컨트랙트란?

업그레이드 가능한 스마트 컨트랙트는 배포 후 수정 및 개선을 허용함으로써 기존 스마트 컨트랙트의 한계를 해결합니다. 기존 스마트 컨트랙트는 불변성으로 잘 알려져 있지만, 업그레이드 가능한 컨트랙트는 불변성의 보안성과 적응 유연성을 결합합니다. 이를 통해 개발자는 사용자를 방해하거나 새 컨트랙트로 마이그레이션을 요구하지 않고도 업데이트를 구현할 수 있습니다.

기존 컨트랙트에서는 배포 후 버그나 보안 결함이 영구적으로 남습니다. 업그레이드 가능한 컨트랙트는 배포 후 업데이트를 가능하게 함으로써 다음과 같은 핵심 문제를 해결합니다:

1. 버그 수정 및 보안 취약점: 개발자가 배포 후 취약점을 패치하여 사용자 자산을 보호할 수 있습니다.

2. 적응성: 업그레이드 가능한 컨트랙트는 새로운 프로토콜과의 원활한 통합을 가능하게 하여 애플리케이션이 기술 발전에 뒤처지지 않도록 합니다.

3. 보안 강화: 진화하는 사이버 위협에 대응하여, 전체 컨트랙트를 재배포하지 않고도 새로운 보안 조치를 쉽게 구현할 수 있습니다.

4. 거버넌스 유연성: 탈중앙화 자율 조직(DAO)은 커뮤니티 투표를 활용하여 업그레이드 프로세스를 관리함으로써 투명성과 이해관계자 참여를 보장할 수 있습니다.

5. 간소화된 업데이트: 복잡한 재배포와 데이터 마이그레이션이 필요한 기존 컨트랙트와 달리, 업그레이드 가능한 컨트랙트는 보다 효율적이고 사용자 친화적인 경험을 위해 업데이트를 간소화합니다.

업그레이드 가능한 스마트 컨트랙트는 어떻게 작동하나요?

업그레이드 가능한 스마트 컨트랙트를 만들기 위한 가장 일반적인 방법은 프록시 패턴입니다. 이 접근 방식은 컨트랙트의 로직과 데이터를 분리하여, 개발자가 동일한 데이터 스토리지를 유지하면서 로직을 업데이트할 수 있도록 합니다.

작동 방식은 다음과 같습니다:

  1. 프록시 컨트랙트: 스토리지(상태 변수)를 보유하고 사용자를 위한 인터페이스 역할을 합니다.

  2. 구현 컨트랙트: 실행 가능한 코드(비즈니스 로직)를 포함합니다.

  3. 위임: 프록시 컨트랙트는 이더리움의 delegatecall을 사용하여 함수 호출을 구현 컨트랙트에 위임합니다. 이를 통해 프록시는 동일한 스토리지를 유지하면서 구현의 코드를 실행할 수 있습니다.

업그레이드가 필요할 때, 프록시 컨트랙트는 새로운 구현 컨트랙트로 연결되어 컨트랙트의 스토리지를 변경하거나 사용자를 방해하지 않고 로직 업데이트를 가능하게 합니다. 다음은 이 프로세스를 설명하기 위해 DAO 프록시 컨트랙트를 사용한 예시입니다:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract DAOProxy {
    // Storage slot for the address of the implementation contract
    bytes32 private constant implementationSlot = keccak256("simple.dao.proxy.implementation");
    // Storage slot for the admin address
    bytes32 private constant adminSlot = keccak256("simple.dao.proxy.admin");

    // Modifier to restrict access to admin
    modifier onlyAdmin() {
        require(msg.sender == _admin(), "Caller is not admin");
        _;
    }

    // Constructor to set the initial implementation and admin
    constructor(address _implementation) {
        _setImplementation(_implementation);
        _setAdmin(msg.sender);
    }

    // Fallback function to delegate calls to the implementation
    fallback() external payable {
        _delegate(implementation());
    }

    // Receive function to handle plain Ether transfers
    receive() external payable {
        _delegate(implementation());
    }

    // Internal function to delegate the call to the implementation
    function _delegate(address _implementation) internal {
        assembly {
            // Copy msg.data
            calldatacopy(0, 0, calldatasize())
            // Call the implementation
            let result := delegatecall(gas(), _implementation, 0, calldatasize(), 0, 0)
            // Copy the returned data
            returndatacopy(0, 0, returndatasize())
            // Handle result
            switch result
            case 0 {
                // Revert if the call failed
                revert(0, returndatasize())
            }
            default {
                // Return data if the call succeeded
                return(0, returndatasize())
            }
        }
    }

    // Getter for the implementation address
    function implementation() internal view returns (address impl) {
        bytes32 slot = implementationSlot;
        assembly {
            impl := sload(slot)
        }
    }

    // Getter for the admin address
    function _admin() internal view returns (address adm) {
        bytes32 slot = adminSlot;
        assembly {
            adm := sload(slot)
        }
    }

    // Function to upgrade the implementation contract
    function upgradeTo(address newImplementation) external onlyAdmin {
        _setImplementation(newImplementation);
    }

    // Internal function to set the implementation address
    function _setImplementation(address newImplementation) internal {
        bytes32 slot = implementationSlot;
        assembly {
            sstore(slot, newImplementation)
        }
    }

    // Function to change admin
    function changeAdmin(address newAdmin) external onlyAdmin {
        _setAdmin(newAdmin);
    }

    // Internal function to set the admin address
    function _setAdmin(address newAdmin) internal {
        bytes32 slot = adminSlot;
        assembly {
            sstore(slot, newAdmin)
        }
    }
}
복사
    bytes32 private constant implementationSlot = keccak256("simple.dao.proxy.implementation");
    bytes32 private constant adminSlot = keccak256("simple.dao.proxy.admin");
복사

주목해야 할 두 가지 중요한 사항은 구현 컨트랙트 주소의 스토리지 슬롯과 관리자 주소의 스토리지 슬롯입니다. 이것들은 프록시 컨트랙트 내에서 구현 주소의 스토리지 슬롯을 계산하는 데 사용되는 의도적이고 고유한 식별자입니다.

delegatecall과 함께 프록시 패턴을 사용할 때(이 함수는 곧 자세히 살펴볼 예정입니다), 구현 컨트랙트는 프록시의 스토리지 컨텍스트에서 작동합니다. 이는 다음을 의미합니다:

  • 공유 스토리지 공간: 두 컨트랙트는 동일한 스토리지 레이아웃을 공유합니다.

  • 덮어쓰기 위험: 신중하게 관리하지 않으면 프록시 컨트랙트의 변수가 구현 컨트랙트에 의해 덮어쓰여질 수 있습니다.

해시된 고유 식별자를 사용함으로써:

  • 격리: 프록시가 사용하는 스토리지 슬롯이 구현 컨트랙트의 어떤 변수와도 충돌할 가능성이 없도록 보장합니다.

  • 일관성: 구현 주소가 필요한 컨트랙트 전체에서 동일한 스토리지 슬롯이 일관되게 사용됩니다.

constructor(address _implementation) {
        _setImplementation(_implementation);
        _setAdmin(msg.sender);
    }
복사

또 다른 중요한 측면은 프록시 컨트랙트의 생성자입니다. 여기에는 _implementation 매개변수가 포함되어 있어, 구현 컨트랙트를 먼저 배포해야 하며 프록시 컨트랙트를 배포할 때 그 주소를 인수로 전달해야 합니다.

 fallback() external payable {
        _delegate(implementation());
    }

    receive() external payable {
        _delegate(implementation());
    }
복사

이제 fallback 함수와 receive 함수가 있습니다. 이것들은 Solidity 함수로, 구현 컨트랙트로의 호출 및 이더 전송을 전달하는 데 중요한 역할을 합니다. 이 함수들은 컨트랙트가 명시적으로 정의된 함수와 일치하지 않는 호출이나 자금을 받는 상황을 처리합니다. 다음은 이 함수들이 하는 일에 대한 심층적인 설명입니다:

1. fallback 함수

  • Fallback 메커니즘: Solidity의 fallback 함수는 두 가지 주요 조건에서 실행되는 특수 함수입니다:- 컨트랙트에 존재하지 않는 함수가 호출될 때.

  • 함수 서명과 일치하지 않는 데이터가 컨트랙트로 전송될 때.

  • 위임: DAOProxy 컨트랙트의 맥락에서, fallback 함수는 이러한 모든 호출을 현재 구현 컨트랙트에 위임합니다.

DAOProxy에서의 작동 방식:

  • 함수 호출 처리:- 사용자가 프록시 컨트랙트에 정의되지 않은 함수를 호출하면 fallback 함수가 트리거됩니다.

  • 프록시 컨트랙트는 주로 구현 컨트랙트의 비즈니스 로직 함수보다는 upgradeTo 및 changeAdmin과 같은 관리 함수를 포함하고 있기 때문에 이는 일반적인 경우입니다.

  • 구현으로의 위임:- fallback 함수는 _delegate(implementation())를 호출하여 구현 컨트랙트로 호출을 전달합니다.

  • _delegate는 프록시의 스토리지 컨텍스트에서 작동하면서 구현의 코드를 실행하기 위해 delegatecall을 사용하는 내부 함수입니다.

  • 실행 컨텍스트:- 모든 상태 변경은 프록시의 스토리지에서 발생합니다.

  • msg.sender와 msg.value는 변경되지 않아 원래 호출자의 컨텍스트가 보존됩니다.

2. receive 함수

  • 이더 수신: Solidity 0.6.0에서 도입된 receive 함수는 컨트랙트가 데이터 없이 이더를 받을 때(즉, msg.data가 비어 있을 때) 실행됩니다.

  • 이더 전송 위임: DAOProxy 컨트랙트에서 일반 이더 전송은 구현 컨트랙트에 위임됩니다.

DAOProxy에서의 작동 방식:

  • 이더 전송 처리:- 누군가 데이터 없이 프록시 컨트랙트 주소로 이더를 보내면 receive 함수가 트리거됩니다.

  • 이는 특정 함수를 호출하지 않고 to 주소와 value(이더 금액)만 지정하는 트랜잭션을 통해 발생합니다.

  • 구현으로의 위임:- fallback 함수와 마찬가지로, receive는 _delegate(implementation())를 호출하여 이더 전송을 구현 컨트랙트로 전달합니다.

  • 구현 컨트랙트는 수신된 이더를 처리하는 방법(예: 잔액 업데이트, 이벤트 발생)을 정의합니다.

 function _delegate(address _implementation) internal {
        assembly {
            calldatacopy(0, 0, calldatasize())
            let result := delegatecall(gas(), _implementation, 0, calldatasize(), 0, 0)
            returndatacopy(0, 0, returndatasize())
            switch result
            case 0 {
                revert(0, returndatasize())
            }
            default {
                return(0, returndatasize())
            }
        }
    }
복사

이제 _delegate 함수가 있습니다. 이 함수는 원래 호출의 컨텍스트를 보존하면서 프록시가 구현 컨트랙트로 함수 호출을 전달할 수 있게 합니다. 이를 통해 컨트랙트의 주소를 변경하거나 상태를 방해하지 않고 시간이 지남에 따라 컨트랙트 로직을 업그레이드할 수 있습니다.

다른 함수들은 프록시 컨트랙트의 상태 변수를 관리하는 데 도움이 되는 getter와 setter입니다. 이를 위해 assembly 키워드를 사용하는데, 이를 통해 개발자는 컨트랙트 내에 저수준 어셈블리 코드를 직접 포함할 수 있습니다. 이 기능은 이더리움 가상 머신(EVM) 명령어에 직접 접근하여 컨트랙트 동작에 대한 더 정밀한 제어, 가스 소비 최적화, 그리고 Solidity의 고수준 코드에서 직접 사용할 수 없거나 덜 효율적인 작업의 실행을 가능하게 합니다.

이제 가장 큰 질문은: 이 프록시 컨트랙트를 사용하여 구현 스마트 컨트랙트와 어떻게 상호작용하나요?

이를 위해서는 구현의 스마트 컨트랙트 ABI가 필요합니다. 프록시가 구현 컨트랙트로 호출을 위임하기 때문에, 어떤 함수를 호출할 수 있는지 알기 위해 ABI가 필요합니다. 또한 블록체인과 상호작용할 때 구현 컨트랙트가 아닌 프록시 컨트랙트의 주소를 사용해야 합니다. 프록시가 상태를 보유하고 구현으로 호출을 위임하므로, 모든 상호작용은 프록시를 통해 이루어져야 합니다.

JavaScript 프로젝트에서 Ethers.js를 사용하여 블록체인과 상호작용한다고 가정해 보겠습니다. 다음과 같이 보일 것입니다:

const proxyAddress = "0xProxyContractAddress";
const implementationABI = [ /* ABI array of the implementation contract */ ];

const proxyContract = new ethers.Contract(proxyAddress, implementationABI, provider);

// For a function 'setValue(uint256 newValue)'
await proxyContract.connect(signer).setValue(42);

// For a function 'getValue()'
const value = await proxyContract.getValue();
console.log("Value:", value);
복사

이렇게 하면 프록시 컨트랙트를 사용하여 구현 컨트랙트의 가상 함수 setValue를 호출합니다.

이제 업그레이드 가능한 컨트랙트에 대해 논의하면서 또 다른 중요한 질문이 생깁니다: 컨트랙트를 어떻게 업그레이드하나요?

먼저, 관리자(프록시 컨트랙트를 배포한 사람)만이 구현 컨트랙트를 업그레이드할 수 있습니다. 이를 위해 관리자는 먼저 블록체인에 구현 컨트랙트의 새 버전을 배포해야 합니다. 그 후, 새 컨트랙트 주소를 매개변수로 전달하여 프록시 컨트랙트의 upgradeTo 함수를 호출해야 합니다. 다음과 같이 보일 것입니다:

await proxyContract.connect(adminSigner).upgradeTo(newImplementationAddress);

스마트 컨트랙트의 미래를 받아들이며

업그레이드 가능한 스마트 컨트랙트는 배포 후 변경 및 개선을 허용하기 때문에 블록체인 개발에서 중요합니다. 프록시 패턴을 활용함으로써 개발자는 사용자 경험을 방해하거나 자산을 마이그레이션하지 않고도 새로운 기능을 원활하게 도입하고, 버그를 수정하며, 변화하는 요구사항에 대응할 수 있습니다.

그러나 큰 힘에는 큰 책임이 따릅니다. 업그레이드 가능한 컨트랙트의 메커니즘, 보안 함의, 그리고 모범 사례에 대한 철저한 이해를 바탕으로 접근하는 것이 중요합니다. 스토리지 레이아웃 일관성 보장, 강력한 접근 제어 구현, 그리고 업그레이드의 철저한 테스트는 잠재적 위험으로부터 보호하기 위한 필수 단계입니다.

업그레이드 가능한 컨트랙트 구축에 나설 때, 프로세스를 간소화하고 보안을 강화하기 위해 OpenZeppelin의 Upgrades Plugin과 같은 검증된 도구의 활용을 고려해 보세요. 즐거운 코딩 되세요!

AI가 번역했습니다. 오류가 있을 수 있습니다. 항상 정보를 확인하시기 바랍니다.

번역 평가하기

이 글의 작성자: