1. Development environment setup and smart contract writing/debugging

스마트 컨트랙트 작성

이번 튜토리얼에서는 EVM에서 스마트 컨트랙트가 어떻게 동작하는지 알아본 뒤 간단한 컨트랙트 코드를 작성하고 빌드, 디버깅, 배포하는 방법을 알아보겠습니다. 실습에서는 Solidity 언어를 사용해 EVM 스마트 컨트랙트를 작성할 예정입니다.


Smart Contract Language

이더리움 환경에서의 스마트 컨트랙트를 작성하기 위해 설계된 Solidity는 정적 타입 언어로 상속, 라이브러리, 복잡한 유저의 지정 타입을 지원하며 다양한 이더리움 환경의 프로그래밍 언어 중 가장 대중적으로 사용되는 언어입니다.

  • Solidity 언어나 문법에 대한 자세한 설명을 여기를 통해 확인할 수 있습니다.

How Smart Contract Works on EVM?

스마트 컨트랙트 소스 코드는 컴파일러를 통해 바이트코드로 변환한 후 블록체인에 배포할 수 있습니다. Solidity 컴파일러는 스마트 컨트랙트 코드를 바이트코드로 변환할 때 스마트 컨트랙트와 외부 애플리케이션 간의 상호작용을 정의한 ABI를 반환해주며 외부 애플리케이션은 ABI를 이용해 스마트 컨트랙트의 함수 이름, 매개변수, 반환값 등을 확인하고 함수 호출 시 이러한 값에 맞는 데이터를 바이트코드로 인코딩하여 트랜잭션 페이로드에 담아 배포된 스마트 컨트랙트와 상호작용할 수 있습니다.

배포된 스마트 컨트랙트는 고유의 주소를 가지고 있으며 클라이언트가 트랜잭션을 실행해 스마트 컨트랙트의 함수를 호출하면 이 주소를 통해 스마트 컨트랙트를 특정할 수 있습니다. 특정된 스마트 컨트랙트의 바이트코드는 노드의 EVM에 로드되어 클라이언트의 트랜잭션 페이로드에 따라 로직을 실행하게 됩니다.

스크린샷 2024-09-10 오후 6.19.29.png

구현할 스마트 컨트랙트의 로직과 기능

스마트 컨트랙트가 어떻게 배포되는지 알아보았으니 간단한 메시지 스마트 컨트랙트 코드를 작성하고 이더리움에 배포하고 호출해 볼까요?

  • 메시지 스마트 컨트랙트의 로직
    1. 초기 메시지를 설정합니다.
    2. 초기에 설정한 메시지를 확인합니다.
    3. 사용자가 입력한 값으로 메시지를 변경합니다.
    4. 변경된 메시지를 확인합니다.

위 내용을 구현하기 위해 각 로직마다 필요한 기능을 정의해 보면 다음과 같습니다.

  1. 초기 메시지를 설정합니다.
    • 컨트랙트 배포 시 기본적으로 입력될 메시지를 적용
  2. 초기에 설정한 메시지를 확인합니다.
    • 블록체인과 상호작용하여 컨트랙트에 입력된 메시지를 조회
  3. 사용자가 입력한 값으로 메시지를 변경합니다.
    • 블록체인과 상호작용하여 컨트랙트에 메시지를 입력
    • 블록체인과 상호작용하여 컨트랙트에 입력된 메시지를 조회
  4. 메시지를 초기화 합니다.
    • 현재의 메시지를 초기화하여 빈 값으로 변경

이상으로 스마트 컨트랙트의 기능과 로직 모두 준비가 되었습니다! 이제 스마트 컨트랙트를 함께 작성해 봅시다!

스마트 컨트랙트 작성

편리하게 스마트 컨트랙트를 작성하고 테스트해 보기 위해 Remix IDE를 이용하겠습니다. 아래 링크를 클릭하여 Remix IDE를 이용할 수 있습니다.

Remix IDE에 접근하면 다음과 같은 화면이 나타납니다. 우측의 탭 중 contracts 디렉토리를 클릭하면 Remix IDE에서 기본적으로 제공해주는 컨트랙트 예제를 확인할 수 있습니다.

스크린샷 2024-09-06 오후 5.54.21.png

새로운 Solidity 파일을 만들어 메시지 컨트랙트를 작성하겠습니다. Solidity 파일의 확장자는 .sol 입니다. 파일의 이름은 4_Message.sol 로 명명합니다.


라이센스 및 버전 설정

코드를 작성할 준비가 되었다면 가장 먼저 작성할 코드는 라이센스와 사용할 Solidity 버전을 명시하는 것입니다. 라이센스는 SPDX 표준을 따르며 해당 코드는 누구나 사용할 수 있도록 Unlicensed로 명시하였습니다.

pragma solidity는 solidity 버전을 의미합니다. 특정 버전을 지정할 수 있으며 아래와 같이 사용할 수 있는 버전의 범위를 설정할 수 있습니다. 버전에 따라 문법이 달라질 수 있어 사용하는 Solidity 문법에 맞는 버전을 설정해야 합니다.


스마트 컨트랙트 선언

이제 컨트랙트의 이름을 선언하고 기능을 구현할 수 있습니다. contract Name {} 과 같은 구조로 선언할 수 있으며 본 튜토리얼에서는 Message라는 이름으로 명명하였습니다. 이후 중괄호 안에 해당 컨트랙트의 기능을 구현하여야 합니다.

// SPDX-License-Identifier: Unlicensed
pragma solidity >=0.7.0 <0.9.0;

contract Message {
	// write your contract functions
}

본 튜토리얼에서 구현할 기능을 다시 상기해 보도록 하겠습니다.

  1. 초기 메시지를 설정합니다.

    • 컨트랙트 배포 시 기본적으로 입력될 메시지를 적용
  2. 초기에 설정한 메시지를 확인합니다.

    • 블록체인과 상호작용하여 컨트랙트에 입력된 메시지를 조회
  3. 사용자가 입력한 값으로 메시지를 변경 및 확인합니다.

    • 블록체인과 상호작용하여 컨트랙트에 메시지를 입력
    • 블록체인과 상호작용하여 컨트랙트에 입력된 메시지를 조회
  4. 메시지를 초기화 합니다.

    • 현재의 메시지를 초기화하여 빈 값으로 변경


  1. 초기 메시지 설정

위 내용에 따르면 가장 먼저 해야할 것은 초기 메시지 설정입니다. 초기 메시지 설정을 위해 Constructor 함수를 작성합니다. Constructor 함수는 컨트랙트 배포 시점에만 한 번 실행되는 함수로 컨트랙트의 초기값을 설정하는데에 사용됩니다.

// SPDX-License-Identifier: Unlicensed
pragma solidity >=0.7.0 <0.9.0;

contract Message {

    string public currentMessage;

    constructor() {
        currentMessage = "Hello World! This is Nodit!";
    }

}

string 타입의 currentMessage라는 변수를 선언하고 constructor 함수를 이용해 배포 시 초기 메시지로 “Hello World! This is Nodit!”이 설정되도록 작성하였습니다.


  1. 초기에 설정한 메시지를 확인

설정한 메시지를 확인하기 위해 getMessage 함수를 구현합니다. 현재 메시지가 없다면 your current message is empty! 라는 메시지를 보여주고 현재 메시지가 있다면 currentMessage 변수 값을 반환합니다.

function getMessage() public view returns (string memory) {
  return currentMessage;
}

선언된 함수를 살펴보면 다음과 같은 의미를 가지고 있습니다.

public : Solidity 언어의 특징으로 함수의 가시성을 나타냅니다. public은 다른 컨트랙트 혹은 다른 EOA가 호출할 수 있는 함수임을 의미하며 public 외에도 internal, external, private 등이 있습니다. 가시성에 대한 자세한 설명은 여기서 확인할 수 있습니다.

view : 함수의 타입 중 하나로 해당 함수가 view function 임을 의미합니다. view function이란 상태 변수에 접근하지만 값을 변경하지 않는 함수로 상태 변경이 없기 때문에 해당 함수를 호출할 때 가스를 소모하지 않습니다.

returns (string memory) : 해당 함수의 결과로 반환되는 값의 타입과 Data Location을 의미합니다. Data Location에는 memory, calldata, storage가 있으며 자세한 설명은 여기서 확인할 수 있습니다.

TIP : Solidity에서는 문자열은 연산자로 직접 비교할 수 없기 때문에 currentMessage와 빈 문자열의 타입을 변환한 후 비교해야 합니다. 위 코드에서는 문자열을 바이트코드로 변환한 후 이를 해시화하여 해시값을 비교합니다.


  1. 사용자가 입력한 값으로 메시지를 변경

이제 사용자가 입력한 값으로 메시지를 변경할 수 있는 setMessage 함수를 구현합니다.

function setMessage(string memory _newMessage) public returns (string memory) {
    currentMessage = _newMessage;
    return string(abi.encodePacked("your current message is :",currentMessage));
}

사용자가 입력한 문자열을 currentMessage에 할당합니다. 그리고 이를 “your current message is :”라는 문장과 합쳐 반환합니다.

  • abi.encodePacked() : 인자로 받은 값을 바이트로 변환하여 합칩니다. 현재 코드에서는 “your current message is”와 currentMessage를 합쳐 하나의 바이트로 합쳤습니다.
  • string() : 인자로 받은 값을 string 타입으로 변환합니다. 현재 코드에서는 합쳐진 바이트를 string 타입으로 변환하고 있습니다.

setMessage 함수를 위와 같이 정의해 사용자가 입력한 값으로 메시지를 변경하는 동작을 수행할 수 있습니다. 다만 기대치 못한 동작이 한 가지 발생할 수 있는데 어떤 것일지 예상이 가시나요?

바로 가시성이 public이기 때문에 누구나 setMessage를 호출하여 메시지를 변경할 수 있다는 점입니다. 컨트랙트를 배포한 사용자만 메시지를 변경할 수 있도록 코드를 수정해 보겠습니다.

// SPDX-License-Identifier: Unlicensed
pragma solidity >=0.7.0 <0.9.0;

contract Message {

    string public currentMessage;
    **address private owner = msg.sender;**

    **modifier onlyOnwer() {
        require(msg.sender == owner, "Caller is not owner");
        _;
    }**

    constructor() {
        currentMessage = "Hello World! This is Nodit!";
        **owner = msg.sender;**
    }
    
		function getMessage() public view returns (string memory) {
		    if (keccak256(abi.encodePacked(currentMessage)) == keccak256(abi.encodePacked(""))) {
		            return "your current message is empty!";
		        } else {
		            return currentMessage;
		        }       
		}

    function setMessage(string memory _newMessage) public **onlyOnwer** returns (string memory) {
        currentMessage = _newMessage;
        return string(abi.encodePacked("your current message is :",currentMessage));
    }
}

modifier 함수는 정해진 조건이 일치하는 경우에만 함수를 실행하고 조건이 일치하지 않는 경우 에러 메시지를 반환합니다. 현재 코드에서는 owner라는 이름의 변수가 함수를 호출하는 주소와 다를 경우 에러 메시지를 반환하도록 작성되어 있습니다.

address private owner;

modifier onlyOnwer() {
    require(msg.sender == owner, "Caller is not owner");
    _;
}

그리고 컨트랙트를 배포하는 주소를 owner로 설정하도록 constructor 함수를 수정합니다.

constructor() {
    owner = msg.sender;
    currentMessage = "Hello World! This is Nodit!";
}


onlyOwner라는 modifier 함수로 접근이 제어되고 있는 setMessage 함수는 컨트랙트를 배포한 주소가 호출하지 않는 이상 실행할 수 없도록 제어됩니다.

function setMessage(string memory _newMessage) public **onlyOwner** returns (string memory) {
    currentMessage = _newMessage;
    return string(abi.encodePacked("your current message is :",currentMessage));
}

  1. 메시지 초기화

사용자가 저장된 메시지를 지우고 빈 값으로 변경할 수 있는 initMessage 함수를 구현합니다. modifier를 이용해 스마트 컨트랙트를 배포한 주소만 해당 함수를 호출할 수 있도록 구현합니다.

function initMessage() public onlyOwner {
    currentMessage = "";
}

이제 메시지 컨트랙트의 모든 기능을 구현하였습니다. 완성된 컨트랙트의 코드는 다음과 같습니다.

// SPDX-License-Identifier: Unlicensed
pragma solidity >=0.7.0 <0.9.0;

contract Message {

    string public currentMessage;
    address private owner;

    modifier onlyOwner() {
        require(msg.sender == owner, "Caller is not owner");
        _;
    }

    constructor() {
        owner = msg.sender;
        currentMessage = "Hello World! This is Nodit!";
    }

    function getMessage() public view returns (string memory) {
        if (keccak256(abi.encodePacked(currentMessage)) == keccak256(abi.encodePacked(""))) {
                return "your current message is empty!";
            } else {
                return currentMessage;
            }       
    }

    function setMessage(string memory _newMessage) public onlyOwner returns (string memory) {
        currentMessage = _newMessage;
        return string(abi.encodePacked("your current message is ",currentMessage));
    }

    function initMessage() public onlyOwner {
        currentMessage = "";
    }
}

이제 Remix IDE의 기능을 이용하여 코드를 컴파일하고 테스트를 해 보도록 하겠습니다! 🚀


스마트 컨트랙트 컴파일 및 테스트

스마트 컨트랙트 컴파일 및 로컬 환경 배포

컨트랙트 작성을 완료한 후 Remix IDE 좌측 메뉴 중 3번째 메뉴인 [Solidity compiler]를 클릭합니다. [Compile 4_Message.sol] 버튼을 클릭하여 컴파일을 진행할 수 있으며 정상적으로 컴파일을 완료하면 [Solidity compiler] 메뉴에 초록색 배경의 체크 아이콘이 나타납니다.

컴파일 시 compiler의 버전이 Solidity 파일에 명시한 버전과 일치해야 하며 일치하지 않을 경우 컴파일이 실패할 수 있습니다.

스크린샷 2024-09-09 오후 4.56.40.png

컴파일을 완료했다면 Remix IDE 좌측 메뉴 중 4번째 메뉴인 [Deploy & run transactions] 메뉴를 클릭하여 체인에 배포하기 전에 가상의 환경에서 테스트를 진행할 수 있습니다.

스크린샷 2024-09-09 오후 5.14.17.png

Environment 탭에서 실행할 VM의 버전을 선택할 수 있으며 원활한 테스트를 위해 100 ETH를 가진 계정을 15개를 제공받습니다. Contract 탭에는 컴파일을 완료한 메시지 컨트랙트가 자동으로 선택되어 있으며 아래 [Deploy] 버튼을 클릭하여 메시지 컨트랙트를 배포할 수 있습니다.


스마트 컨트랙트 테스트

성공적으로 배포가 완료되면 메뉴 하단의 [Deployed/Unpinned Contracts] 탭에서 배포된 컨트랙트를 확인할 수 있고 컨트랙트에 작성된 함수를 실행할 수 있습니다. 구현하려 했던 로직이 정상적으로 동작을 하는지 확인해 보도록 하겠습니다. 초기 기획한 로직은 다음과 같습니다.

  1. 초기 메시지를 설정합니다.
  2. 초기에 설정한 메시지를 확인합니다.
  3. 사용자가 입력한 값으로 메시지를 변경합니다.
  4. 메시지를 초기화 합니다.

  • 초기 메시지 설정 및 메시지 확인

초기 메시지 설정의 경우 constructor 함수를 이용해 컨트랙트가 배포될 때 설정이 되었을 것입니다. 이를 확인하기 위해 [getMessage] 버튼을 클릭해 현재 currentMessage가 무엇인지 확인해 보도록 하겠습니다.

스크린샷 2024-09-09 오후 5.27.33.png

getMessage 함수의 실행 결과로 constructor 함수에 입력했던 “Hello World! This is Nodit!” 라는 string 값이 반환되는 것을 확인할 수 있습니다. 컨트랙트가 배포될 때 constructor 함수가 정상적으로 동작하였군요!

스크린샷 2024-09-09 오후 5.33.35.png
  • 사용자가 입력한 값으로 메시지 변경 및 메시지 확인

이제 사용자가 입력한 값으로 메시지를 변경해 보도록 하겠습니다. [setMessage] 버튼 옆 Input에 원하는 내용을 입력하고 [setMessage] 버튼을 클릭합니다.

스크린샷 2024-09-09 오후 6.59.52.png

Remix IDE의 터미널에서 아래 사진과 같이 트랜잭션이 실행된 내역을 확인할 수 있으며 클릭해 트랜잭션의 정보를 확인할 수 있습니다.

스크린샷 2024-09-09 오후 7.00.31.png

decoded 영역에서 입력한 내용과 setMessage 함수에서 출력하도록 구성한 내용이 정상적으로 반환되는 것을 확인할 수 있습니다.

스크린샷 2024-09-09 오후 7.00.43.png

다시 [getMessage] 버튼을 클릭해 볼까요? setMessage 함수를 이용해 입력한 내용이 출력되는 것을 확인할 수 있습니다.

스크린샷 2024-09-09 오후 7.01.56.png
  • 메시지 초기화 및 메시지 확인

로직의 마지막 단계인 메시지 초기화 기능을 확인해 보도록 하겠습니다. [initMessage] 버튼을 클릭하여 해당 함수를 호출해 메시지를 초기화 할 수 있습니다.

스크린샷 2024-09-09 오후 7.05.44.png

setMessage 함수를 실행할 때와 같이 터미널에서 함수가 정상적으로 실행된 것을 확인할 수 있습니다.

스크린샷 2024-09-09 오후 7.06.11.png

다시 [getMessage] 버튼을 클릭하여 현재 메시지의 내용을 확인해 볼까요? 컨트랙트에 작성했던 코드와 같이 빈 string 값이 반환되는 것을 확인할 수 있으며 모든 함수가 의도한대로 동작하는 것을 확인할 수 있습니다!


스마트 컨트랙트 디버깅

Remix IDE 좌측 메뉴 중 5번째 메뉴인 [Debugger]를 클릭하여 Remix IDE에서 제공하는 디버깅 기능을 이용할 수 있습니다.

Remix IDE의 디버깅 기능을 사용하면, 사용자는 실행 중인 함수, 입력된 인자, 현재 실행되고 있는 EVM의 opcode, 그리고 스마트 계약의 상태 등을 확인할 수 있습니다. 이를 통해 낮은 레벨에서 디버깅을 수행하고 오류를 식별하거나 코드 최적화를 진행할 수 있습니다. 작성한 코드로 예를 들어 보겠습니다.


트랜잭션 실행에 실패한 케이스를 추가하기 위해 메시지의 길이가 5글자 보다 짧다면 에러가 발생하여 트랜잭션이 실패하도록 setMessage 함수를 수정하였습니다.

function setMessage(string memory _newMessage) public onlyOwner returns (string memory) {
    require(bytes(_newMessage).length > 5, "Message is too short!"); 
    
    currentMessage = _newMessage;
    return string(abi.encodePacked("your current message is : ", currentMessage));
}

이후 해당 컨트랙트를 배포하고 setMessage의 인자를 5글자 보다 짧게 작성한 후 함수를 호출합니다. 아래 사진과 같이 트랜잭션이 revert가 발생한 것을 확인할 수 있습니다.

스크린샷 2024-09-10 오전 11.19.42.png

해당 트랜잭션을 이용해 디버깅 해보도록 하겠습니다. 아래 사진과 같이 opcode의 진행에 따라 소모되는 gas의 양을 확인할 수 있으며 어떤 call에서 트랜잭션이 revert 되었는지 확인할 수 있습니다.

스크린샷 2024-09-10 오전 11.23.23.png 스크린샷 2024-09-10 오전 11.24.36.png

Remix IDE의 디버깅 기능을 활용하여 오류를 찾아내고 문제를 해결함으로써 더욱 안정적이고 완성도 높은 스마트 컨트랙트 코드를 작성할 수 있습니다!

이번 튜토리얼에서는 Web3 개발 환경을 설정하는 방법과 간단한 컨트랙트 작성 및 테스트, 그리고 디버깅을 하는 방법을 알아보았습니다. 다음 튜토리얼에서는 테스트 완료한 컨트랙트를 실제 이더리움 체인에 배포하는 방법과 트랜잭션을 실행하는 방법을 알아보도록 하겠습니다!