Solidity 101
Solidity는 이더리움 가상 머신(EVM) 위에서 실행되는 스마트 컨트랙트를 작성하기 위한 정적 타입 프로그래밍 언어이다.
본 글은 Solidity의 기본 문법, 동작 방식, 가스비의 원리, 그리고 ETH 전송 방법까지 — 스마트 컨트랙트 개발을 시작하기 위한 핵심 기초를 다룬다.
Solidity는 어떻게 동작하는가
Solidity 코드는 직접 블록체인에서 실행되지 않는다. 컴파일 → 배포 → 실행의 세 단계를 거친다.
flowchart LR
A["Solidity 코드<br/>(.sol)"] -->|solc 컴파일러| B["바이트코드<br/>(Bytecode)"]
B -->|트랜잭션으로 배포| C["블록체인<br/>(Contract Address)"]
C -->|함수 호출| D["EVM 실행"]
1. 컴파일
Solidity 컴파일러(solc)가 .sol 파일을 EVM 바이트코드로 변환한다. 바이트코드는 EVM이 이해하는 저수준 명령어(opcode)의 연속이다.
컴파일 결과물은 두 가지이다.
- Bytecode: 실제 블록체인에 배포되는 실행 코드
- ABI (Application Binary Interface): 컨트랙트의 함수 시그니처, 파라미터 타입 등을 정의한 JSON 인터페이스. 외부에서 컨트랙트와 상호작용할 때 사용한다
2. 배포
컴파일된 바이트코드를 트랜잭션에 담아 이더리움 네트워크에 전송한다. 배포가 완료되면 고유한 컨트랙트 주소가 생성된다.
3. 실행
사용자 또는 다른 컨트랙트가 함수를 호출하면, EVM이 해당 바이트코드를 스택 기반(stack-based) 으로 실행한다. EVM은 1024 깊이의 스택에서 256비트 단위로 연산을 처리한다.
EVM은 Turing Complete하지만, Gas 메커니즘으로 실행량을 제한하여 무한 루프 같은 문제를 방지한다.
기본 문법
컨트랙트 구조
Solidity의 기본 단위는 contract이다. 객체지향 언어의 클래스(class)와 유사하다.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract SimpleStorage {
// 상태 변수 (state variable) — 블록체인에 영구 저장
uint256 public storedValue;
// 생성자 — 컨트랙트 배포 시 한 번만 실행
constructor(uint256 _initialValue) {
storedValue = _initialValue;
}
// 함수
function set(uint256 _value) public {
storedValue = _value;
}
function get() public view returns (uint256) {
return storedValue;
}
}
pragma solidity ^0.8.24— 컴파일러 버전을 지정한다.^는 0.8.24 이상 0.9.0 미만을 의미한다SPDX-License-Identifier— 라이선스를 명시한다. 없으면 컴파일러 경고가 발생한다- 상태 변수는 블록체인의 스토리지에 영구 저장된다. 읽기/쓰기에 Gas가 소비된다
- 생성자는 배포 시 한 번만 실행된다. 오버로딩은 지원되지 않는다
자료형 (Data Types)
Solidity는 정적 타입 언어이다. 변수를 선언할 때 타입을 반드시 명시해야 하며, undefined나 null 개념이 없다. 선언된 변수는 타입에 따른 기본값을 가진다.
값 타입 (Value Types)
| 타입 | 설명 | 기본값 | 예시 |
|---|---|---|---|
bool | 참/거짓 | false | bool isActive = true; |
uint256 | 부호 없는 256비트 정수 | 0 | uint256 count = 42; |
int256 | 부호 있는 256비트 정수 | 0 | int256 temperature = -10; |
address | 20바이트 이더리움 주소 | 0x0...0 | address owner = msg.sender; |
bytes32 | 고정 크기 바이트 배열 | 0x0...0 | bytes32 hash = keccak256(...); |
uint는uint256의 별칭이다.uint8,uint16, …,uint256까지 8비트 단위로 지정할 수 있다. 하지만 EVM은 256비트 단위로 연산하므로, 작은 타입을 사용한다고 Gas가 절약되지는 않는다. 단, Storage Packing을 활용하는 경우에는 의미가 있다.
참조 타입 (Reference Types)
// 동적 배열
uint256[] public numbers;
// 매핑 (key-value 저장소)
mapping(address => uint256) public balances;
// 문자열
string public name = "Solidity";
// 구조체
struct User {
address wallet;
uint256 balance;
bool isActive;
}
mapping은 Solidity에서 가장 많이 쓰이는 자료구조이다. 해시 테이블과 유사하지만, 모든 키가 기본값으로 초기화되어 있다고 간주하며, 순회(iteration)가 불가능하다.
함수와 가시성 (Visibility)
Solidity 함수는 반드시 가시성(visibility) 을 명시해야 한다.
contract Visibility {
// 외부에서만 호출 가능 (내부에서 this.f()로 우회 가능)
function externalFunc() external returns (uint256) { ... }
// 내부 + 외부 모두 호출 가능
function publicFunc() public returns (uint256) { ... }
// 현재 컨트랙트 + 상속받은 컨트랙트에서만 호출 가능
function internalFunc() internal returns (uint256) { ... }
// 현재 컨트랙트에서만 호출 가능
function privateFunc() private returns (uint256) { ... }
}
| 가시성 | 외부 호출 | 내부 호출 | 상속 컨트랙트 |
|---|---|---|---|
external | O | X (this.f()로 우회) | X |
public | O | O | O |
internal | X | O | O |
private | X | O | X |
private으로 선언해도 블록체인 상의 데이터 자체는 누구나 읽을 수 있다.private은 다른 컨트랙트의 접근만 제한할 뿐, 데이터를 숨기는 것이 아니다.
참고로 this.f()로 external 함수를 내부에서 호출할 수 있지만, 이는 내부 JUMP가 아니라 실제 외부 메시지 콜(CALL opcode)을 발생시킨다.
즉 ABI 인코딩/디코딩이 수행되고, 새로운 EVM 콜 컨텍스트가 생성되므로 일반 내부 호출보다 Gas가 훨씬 많이 든다.
특별한 이유가 없다면 내부에서도 호출이 필요한 함수는 public으로 선언하는 것이 효율적이다.
함수 제어자 (Function Modifiers)
함수의 상태 변경 여부에 따라 제어자를 붙인다.
// view: 상태를 읽기만 함 (Gas 무료 — 외부에서 직접 호출 시)
function getBalance() public view returns (uint256) {
return balances[msg.sender];
}
// pure: 상태를 읽지도 쓰지도 않음
function add(uint256 a, uint256 b) public pure returns (uint256) {
return a + b;
}
// payable: ETH를 받을 수 있음
function deposit() public payable {
balances[msg.sender] += msg.value;
}
| 제어자 | 상태 읽기 | 상태 쓰기 | ETH 수신 |
|---|---|---|---|
| (없음) | O | O | X |
view | O | X | X |
pure | X | X | X |
payable | O | O | O |
view와pure함수는 외부에서 직접 호출할 때 Gas를 소비하지 않는다.
하지만 트랜잭션 내부에서 호출되면 해당 트랜잭션의 Gas에 포함된다.
커스텀 Modifier
반복적인 접근 제어 로직을 재사용할 수 있다.
contract Ownable {
address public owner;
constructor() {
owner = msg.sender;
}
modifier onlyOwner() {
require(msg.sender == owner, "Not the owner");
_; // 이 위치에 원래 함수 본문이 삽입된다
}
function withdraw() public onlyOwner {
// onlyOwner 검사를 통과해야 실행된다
payable(owner).transfer(address(this).balance);
}
}
_(underscore)는 modifier 내에서 원래 함수 본문이 삽입되는 위치를 나타낸다. require를 _ 앞에 배치하면 전처리(precondition) 패턴이 된다.
이벤트 (Events)
이벤트는 블록체인의 트랜잭션 로그에 기록되며, 오프체인 애플리케이션이 컨트랙트 상태 변화를 추적하는 주요 수단이다.
contract Token {
event Transfer(address indexed from, address indexed to, uint256 value);
function transfer(address _to, uint256 _value) public {
// ... 전송 로직
emit Transfer(msg.sender, _to, _value);
}
}
indexed파라미터는 최대 3개까지 지정할 수 있으며, 로그를 필터링할 때 사용된다- 이벤트는 스토리지에 저장되지 않으므로 스토리지 쓰기보다 훨씬 저렴하다
에러 처리
Solidity 0.8.x에서는 세 가지 에러 처리 방식을 제공한다.
// require: 입력값 검증, 조건 불충족 시 revert
require(msg.value > 0, "ETH 내놔!");
// assert: 내부 불변성 검증 (절대 실패하면 안 되는 조건)
assert(totalSupply >= 0);
// revert: 조건부 에러 (복잡한 로직에서 사용)
if (balance < amount) {
revert InsufficientBalance(balance, amount);
}
require 는 외부 입력값이나 사전 조건을 검증할 때 사용한다. 함수 진입부에서 “이 조건이 충족되지 않으면 실행할 필요가 없다”는 의미이다. 조건이 false이면 남은 Gas를 환불하고 트랜잭션을 되돌린다. 사용자 입력 검증, 잔액 확인, 권한 체크 등 대부분의 에러 처리에 require를 사용한다.
assert 는 코드의 내부 불변성(invariant)을 검증할 때 사용한다. “이 조건이 false가 되면 코드에 버그가 있다”는 의미이다. 0.8.0 이전에는 assert 실패 시 남은 Gas를 모두 소비했지만, 0.8.0부터는 require와 동일하게 남은 Gas를 환불한다. 그럼에도 용도의 구분은 유효하다 — require는 “예상 가능한 실패”, assert는 “절대 발생하면 안 되는 상황”을 나타낸다.
revert 는 if 문과 함께 복잡한 조건 분기에서 에러를 발생시킬 때 사용한다. 동작 자체는 require가 실패했을 때와 동일하지만, 다중 조건 검사나 커스텀 에러와 조합할 때 더 가독성이 좋다.
0.8.4부터 커스텀 에러(Custom Errors)가 도입되었다. 문자열 에러 메시지 대신 타입이 있는 에러를 정의할 수 있으며, 문자열이 바이트코드에 포함되지 않으므로 배포 비용과 실행 비용 모두에서 효율적이다.
error InsufficientBalance(uint256 available, uint256 required);
function withdraw(uint256 amount) public {
if (balances[msg.sender] < amount) {
revert InsufficientBalance(balances[msg.sender], amount);
}
// ...
}
0.8.26부터는
require에서도 커스텀 에러를 직접 사용할 수 있다:require(balance >= amount, InsufficientBalance(balance, amount));
Solidity와 Gas
Solidity 코드의 모든 줄은 Gas 비용으로 직결된다. 동일한 로직이라도 작성 방식에 따라 Gas 소비량이 크게 달라진다.
Gas의 기본 개념, EVM opcode별 비용, EIP-1559 등에 대한 자세한 내용은 Gas in Ethereum 글을 참고하라.
스토리지 vs 메모리
Gas 관점에서 가장 중요한 구분은 스토리지(storage)와 메모리(memory)이다.
| 구분 | 스토리지 (storage) | 메모리 (memory) |
|---|---|---|
| 위치 | 블록체인에 영구 저장 | 함수 실행 중에만 존재 |
| 비용 | 매우 비쌈 (쓰기 20,000 Gas) | 저렴 (읽기/쓰기 3 Gas) |
| 용도 | 상태 변수 | 함수 내 임시 데이터 |
// 비효율적: 루프 안에서 매번 스토리지에 쓰기
function inefficient() public {
for (uint256 i = 0; i < 100; i++) {
storedValue += 1; // SSTORE가 100번 실행 (약 500,000 Gas)
}
}
// 효율적: 메모리에서 계산 후 한 번만 스토리지에 쓰기
function efficient() public {
uint256 temp = storedValue; // SLOAD 1번
for (uint256 i = 0; i < 100; i++) {
temp += 1; // 메모리 연산 (저렴)
}
storedValue = temp; // SSTORE 1번
}
주요 Gas 최적화 패턴
1. 변수 패킹 (Storage Packing)
EVM의 스토리지 슬롯은 32바이트(256비트)이다. 작은 타입의 변수를 연속으로 선언하면 하나의 슬롯에 묶여 Gas를 절약할 수 있다.
// 비효율적: 3개의 슬롯 사용
contract Bad {
uint8 a; // 슬롯 0
uint256 b; // 슬롯 1 (uint256이 새 슬롯을 차지)
uint8 c; // 슬롯 2
}
// 효율적: 2개의 슬롯 사용
contract Good {
uint8 a; // 슬롯 0
uint8 c; // 슬롯 0 (a와 같은 슬롯에 패킹)
uint256 b; // 슬롯 1
}
2. 커스텀 에러 사용
문자열 에러 메시지는 배포 비용과 실행 비용 모두에서 비효율적이다.
// 비효율적: 문자열이 바이트코드에 포함됨
require(balance >= amount, "Insufficient balance for withdrawal");
// 효율적: 커스텀 에러 사용 (약 50% Gas 절감)
error InsufficientBalance(uint256 available, uint256 required);
if (balance < amount) revert InsufficientBalance(balance, amount);
3. 불필요한 상태 읽기 최소화
// 비효율적: owner를 두 번 읽음 (SLOAD 2번)
function check() public view returns (bool) {
if (owner != address(0)) {
return msg.sender == owner;
}
return false;
}
// 효율적: 로컬 변수에 캐싱 (SLOAD 1번)
function check() public view returns (bool) {
address _owner = owner;
if (_owner != address(0)) {
return msg.sender == _owner;
}
return false;
}
ETH 전송 방법
Solidity에서 ETH를 전송하는 방법은 세 가지이다.
transfer
payable(recipient).transfer(amount);
- 2300 Gas만 전달한다 (로그 기록 정도만 가능)
- 실패 시 자동으로 revert한다
- 현재 권장되지 않는다 — 2300 Gas 제한이 하드포크로 인해 opcode 비용이 변경되면 수신 컨트랙트의 fallback 함수가 동작하지 않을 수 있다
send
bool success = payable(recipient).send(amount);
require(success, "Send failed");
transfer와 마찬가지로 2300 Gas만 전달한다- 실패 시 revert하지 않고
false를 반환한다 — 반환값을 반드시 확인해야 한다 - 현재 권장되지 않는다 —
transfer와 같은 Gas 제한 문제를 가진다
call (권장)
(bool success, ) = payable(recipient).call{value: amount}("");
require(success, "Transfer failed");
- Gas 제한이 없다 — 남은 Gas를 모두 전달한다 (지정도 가능)
- 실패 시
false를 반환한다 — 반드시 확인해야 한다 - 현재 공식 권장 방식이다
call은 Gas 제한이 없으므로 수신 컨트랙트의 임의 코드가 실행될 수 있다.
재진입 공격에 취약할 수 있으므로, 반드시 상태 변경을 먼저 수행한 후 외부 호출을 해야 한다.
비교
| 방법 | Gas 전달 | 실패 시 동작 | 권장 여부 |
|---|---|---|---|
transfer | 2300 Gas (고정) | 자동 revert | X |
send | 2300 Gas (고정) | false 반환 | X |
call | 전체 Gas (조절 가능) | false 반환 | O |
receive와 fallback
컨트랙트가 ETH를 받으려면 receive 또는 fallback 함수를 정의해야 한다.
contract Receiver {
event Received(address sender, uint256 amount);
// 순수 ETH 전송 시 호출 (calldata가 비어있을 때)
receive() external payable {
emit Received(msg.sender, msg.value);
}
// calldata가 있지만 매칭되는 함수가 없을 때 호출
fallback() external payable {
emit Received(msg.sender, msg.value);
}
}
flowchart TD
A["ETH 전송<br/>(msg.data)"] --> B{msg.data가<br/>비어있는가?}
B -->|Yes| C{receive 함수<br/>존재하는가?}
B -->|No| D{매칭되는<br/>함수 존재?}
C -->|Yes| E["receive() 실행"]
C -->|No| F{fallback 함수<br/>존재하는가?}
D -->|Yes| G["해당 함수 실행"]
D -->|No| F
F -->|Yes| H["fallback() 실행"]
F -->|No| I["트랜잭션 revert"]
Checks-Effects-Interactions Pattern
call을 사용할 때 재진입 공격을 방지하기 위한 필수 패턴이다.
contract SafeWithdraw {
mapping(address => uint256) public balances;
function withdraw() public {
uint256 amount = balances[msg.sender];
// 1. Checks: 조건 검증
require(amount > 0, "No balance");
// 2. Effects: 상태 변경 (외부 호출 전에!)
balances[msg.sender] = 0;
// 3. Interactions: 외부 호출
(bool success, ) = payable(msg.sender).call{value: amount}("");
require(success, "Transfer failed");
}
}
상태 변경을 외부 호출보다 먼저 수행하면, 수신자가 재진입을 시도해도 이미 잔액이 0으로 변경된 상태이므로 공격이 실패한다.