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키워드는 자동으로 getterowner()를 생성합니다.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 표준 같은 고급 주제로 나아가기 위한 토대입니다 — 모두 이 네 가지 기초 위에 구축됩니다.