Solidity 기초: contract, state, function

1. 개요

Solidity는 Ethereum과 EVM 호환 체인에서 스마트 컨트랙트를 작성하는 주요 언어입니다. 이 글은 간단한 Wallet 스마트 계약을 통해 가장 기본적인 세 가지 구성 요소인 contract, state variable, function을 이해하도록 도와줍니다. 이 글을 읽은 후에는 상태를 저장하는 contract를 작성할 수 있고, modifier로 접근 제어를 할 수 있으며, Hardhat으로 첫 테스트를 작성할 수 있게 됩니다.

2. Contract란 무엇인가

Solidity에서 contract는 Java의 class처럼, blockchain에 배포되어 고유한 주소가 될 state(데이터)와 behavior(function)을 정의합니다.

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

contract Wallet {
    // state variables와 function이 여기에 들어갑니다
}
  • // SPDX-License-Identifier 줄은 라이선스를 선언하며, 빠지면 컴파일러가 경고합니다.
  • pragma solidity ^0.8.24는 컴파일러 버전을 0.8.24 이상 0.9.0 미만으로 고정합니다.
  • 각 contract는 배포될 때 고유한 주소(address)를 받으며, blockchain 위에 영구적으로 존재하는 ’object’처럼 작동합니다.

주의: production에서는 >=0.8.0 <0.9.0 같은 너무 느슨한 pragma를 사용하지 마세요 — 새로운 컴파일러는 기본 동작을 변경할 수 있습니다(예: overflow check).

3. State variable — blockchain에 저장된 데이터

State variable은 blockchain의 storage에 직접 기록되는 변수로, 함수 호출 사이에 존재하며 쓰기 시 gas를 소비합니다.

contract Wallet {
    address public owner;
    uint256 public balance;
    mapping(address => uint256) public allowance;

    constructor() {
        owner = msg.sender;
    }
}
  • address public owner: 계약의 소유자 주소를 저장합니다. public 키워드는 자동으로 getter owner()를 생성합니다.
  • uint256 public balance: 잔액을 저장하며, 256비트 부호 없는 정수 타입입니다 — Solidity에서 가장 일반적인 숫자 타입입니다.
  • mapping(address => uint256): HashMap<Address, BigInteger>와 비슷하지만 key를 순회할 수 없고 key로만 조회할 수 있습니다.
  • constructor(): 배포할 때 정확히 한 번 실행되며, msg.sender는 배포 트랜잭션을 호출한 주소입니다.

3.1. storage, memory, calldata의 차이

function updateAllowance(address spender, uint256 amount) external {
    // amount는 calldata에 있습니다(읽기 전용, gas 절약)
    allowance[spender] = amount; // storage에 씁니다
}
  • storage: blockchain 위에 영구적으로 저장된 데이터(state variable)입니다.
  • memory: 함수 실행 중 임시로 저장되는 데이터이며, 함수 종료 후 사라집니다.
  • calldata: memory와 비슷하지만 읽기 전용이며, external 함수의 매개변수로 사용됩니다 — memory보다 gas를 절약합니다.

주의: array, struct, string 타입의 매개변수에 memory/calldata를 선언하지 않으면 컴파일러 오류가 발생합니다 — 메모리 위치에 관심 없어도 되는 Java와 달리요.

4. Function과 visibility

Solidity의 Function은 반드시 명시적으로 visibility를 선언해야 하며, Java처럼 기본값이 ’package-private’는 아닙니다.

contract Wallet {
    address public owner;
    uint256 public balance;

    constructor() {
        owner = msg.sender;
    }

    function deposit() external payable {
        balance += msg.value;
    }

    function withdraw(uint256 amount) external {
        require(msg.sender == owner, "Wallet: not owner");
        require(amount <= balance, "Wallet: insufficient balance");

        balance -= amount;
        (bool success, ) = owner.call{value: amount}("");
        require(success, "Wallet: transfer failed");
    }

    function getBalance() external view returns (uint256) {
        return balance;
    }
}
  • external: contract 외부에서만 호출할 수 있습니다(예: MetaMask 지갑, 다른 contract) — 매개변수가 큰 배열/문자열일 때 public보다 gas가 저렴합니다.
  • payable: 함수가 트랜잭션과 함께 ETH를 받을 수 있게 합니다; msg.value는 함께 전송된 wei의 양입니다.
  • view: 상태를 변경하지 않는다고 약속하며, off-chain에서 호출할 때 gas를 소비하지 않습니다(eth_call 사용).
  • require(condition, "message"): 조건을 검사하고, 거짓이면 오류 메시지와 함께 전체 트랜잭션을 되돌립니다 — IllegalArgumentException을 던지는 것과 비슷하지만 상태와 사용된 gas를 모두 롤백합니다(검사에 사용된 gas 제외).
  • owner.call{value: amount}(""): transfer()/send() 대신 권장되는 ETH 전송 방법으로, 고정된 2300 gas 제한이 없습니다.

네 가지 visibility 수준:

Visibility Contract 내부에서 호출 Contract 자식에서 호출 외부에서 호출
private ✅ ❌ ❌
internal ✅ ✅ ❌
public ✅ ✅ ✅
external ❌ (this.foo() 사용 필요) ❌ ✅

5. Modifier — 검사 조건 재사용

여러 함수에서 require(msg.sender == owner, ...)을 반복하는 것은 modifier로 분리해야 한다는 신호입니다.

contract Wallet {
    address public owner;
    uint256 public balance;

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

    constructor() {
        owner = msg.sender;
    }

    function withdraw(uint256 amount) external onlyOwner {
        require(amount <= balance, "Wallet: insufficient balance");
        balance -= amount;
        payable(owner).transfer(amount);
    }
}
  • modifier onlyOwner()은 검사 블록을 정의하고; _;은 원본 함수의 본문이 삽입될 위치입니다.
  • withdraw 선언 뒤에 onlyOwner를 붙이면 코드가 ’지갑 소유자만 출금할 수 있다’는 의미로 읽힙니다 — 각 함수에서 반복되는 require보다 명확합니다.
  • Modifier는 매개변수를 받을 수 있습니다. 예: modifier minAmount(uint256 amount) { require(amount > 0, "..."); _; }

6. Event — off-chain을 위한 로그 기록

Contract는 frontend에 직접 과거 데이터를 반환할 수 없습니다; event는 dApp이 수신할 수 있는 로그를 기록하는 데 사용됩니다.

contract Wallet {
    event Deposited(address indexed from, uint256 amount);
    event Withdrawn(address indexed to, uint256 amount);

    uint256 public balance;

    function deposit() external payable {
        balance += msg.value;
        emit Deposited(msg.sender, msg.value);
    }
}
  • event Deposited(...)는 로그 구조를 선언하며, 최대 3개의 indexed 매개변수로 필터링을 쉽게 합니다.
  • emit Deposited(...)는 transaction receipt에 로그를 기록하며, storage에 저장하는 것보다 훨씬 저렴한 gas 비용입니다.
  • Frontend은 ethers.js와 같은 라이브러리를 사용하여 contract.on("Deposited", ...)로 수신하고 UI를 실시간으로 업데이트합니다.

7. Solidity 초보자가 흔히 하는 실수

  • ETH 전송 시 transfer/send/call 혼동: transfer는 2300 gas로 제한되어 수신 contract에 복잡한 로직(proxy, multisig)이 있으면 실패하기 쉽습니다. 섹션 4의 예제처럼 call을 사용하고 require로 결과를 검사하세요.
  • 입력 require 검사 생략: validation이 없으면 contract가 악용될 수 있습니다(예: 음수 출금, overflow — Solidity 0.8부터 overflow 시 자동으로 revert되지만, 비즈니스 로직은 여전히 별도 validation이 필요합니다).
  • external과 public을 잘못된 상황에서 비교: 외부에서만 호출되는 함수에 public을 사용하면 매개변수가 배열/문자열일 때 gas가 낭비됩니다. Solidity가 calldata에서 직접 읽는 대신 데이터를 memory에 복사해야 하기 때문입니다.
  • 가능할 때 view/pure를 선언하지 않기: block explorer나 지갑 같은 도구가 읽기 전용 함수를 인식하지 못해 사용자가 gas를 지불해야 한다고 착각합니다.

Function을 public으로 두면 안 되는 경우: 다른 contract나 상속 class에서 호출되지 않는 함수라면 external을 사용하여 gas를 절약하고 공격 표면(attack surface)을 제한하세요.

8. Hardhat으로 테스트 작성하기

Hardhat 프로젝트에 의존성을 추가합니다(Node.js):

npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox

withdraw가 owner만 허용하는지 테스트합니다:

const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("Wallet", function () {
  it("owner만 출금할 수 있습니다", async function () {
    const [owner, stranger] = await ethers.getSigners();
    const Wallet = await ethers.getContractFactory("Wallet");
    const wallet = await Wallet.deploy();

    await wallet.connect(owner).deposit({ value: ethers.parseEther("1") });

    await expect(
      wallet.connect(stranger).withdraw(ethers.parseEther("1"))
    ).to.be.revertedWith("Wallet: not owner");

    await expect(
      wallet.connect(owner).withdraw(ethers.parseEther("1"))
    ).to.changeEtherBalance(owner, ethers.parseEther("1"));
  });
});
  • ethers.getContractFactory("Wallet")는 contract를 컴파일하고 artifact를 로드합니다.
  • wallet.connect(stranger)는 owner가 아닌 다른 주소에서 보낸 트랜잭션을 시뮬레이션합니다.
  • revertedWith("...")는 트랜잭션이 require의 정확한 메시지로 revert되었음을 assert합니다.
  • changeEtherBalance는 출금 후 owner의 ETH 잔액이 정확히 1 ETH만큼 증가했는지 확인합니다 — 반환값만 확인하는 것보다 더 일반적인 테스트 방식입니다.

테스트 실행:

npx hardhat test
Wallet
  ✔ owner만 출금할 수 있습니다 (120ms)

1 passing (120ms)

9. 결론

Solidity의 네 가지 핵심 개념을 배웠습니다: 배포 단위로서의 contract, storage/memory/calldata의 세 가지 메모리 영역에서 on-chain 데이터를 저장하는 state variable, visibility와 modifier로 접근을 제어하는 function, 그리고 off-chain과 통신하는 event입니다. 이 개념들은 inheritance, interface, 그리고 ERC-20/ERC-721 표준 같은 고급 주제로 나아가기 위한 토대입니다 — 모두 이 네 가지 기초 위에 구축됩니다.

solidityblockchainsmart-contractethereumweb3