Remix에서의 Solidity 핵심: 데이터 타입, 함수, 가시성, 그리고 첫 번째 배포 가능한 컨트랙트
학습 목표
- ✓적절한 Solidity 데이터 타입을 사용하여 상태 변수를 선언하고 초기화한다
- ✓올바른 가시성, 상태 변경 가능성, 반환 타입을 갖춘 함수를 작성한다
- ✓Remix JavaScript VM에 컨트랙트를 배포하고 공개 인터페이스를 통해 상호작용한다
Remix에서 배우는 Solidity 핵심: 데이터 타입, 함수, 가시성, 그리고 첫 배포 가능한 컨트랙트
지난 레슨에서는 이더리움의 내부 구조를 살펴보았습니다. EVM(Ethereum Virtual Machine, 이더리움 가상 머신)의 스택 기반 아키텍처를 이해하고, 스토리지(Storage)에 쓰는 비용이 20,000 가스인 반면 메모리(Memory)는 거의 무료인 이유를 배웠으며, 지갑에서 최종 상태까지 트랜잭션(Transaction)이 전달되는 전체 과정을 파악했습니다. 또한 Hardhat에서 빈 FundChain.sol 파일을 초기화했습니다.
이제 그 빈 파일에 코드를 채울 차례입니다. 하지만 아직 Hardhat으로 돌아가지는 않습니다. 오늘은 이더리움의 브라우저 기반 플레이그라운드인 Remix IDE에서 작업합니다. 이유는 잠시 후 설명하겠습니다.
오늘의 목표
이 레슨을 마치면 다음을 할 수 있습니다:
- 블록체인 데이터에 적합한 데이터 타입을 사용하여 Solidity 코드 작성
- 실제로 온체인(on-chain)에 저장되는 상태 변수(State Variable) 선언 (그리고 진짜 비용이 드는 이유 이해)
- 올바른 가시성(Visibility)과 가변성(Mutability) 키워드를 사용한 함수 작성
- Remix의 로컬 VM에 동작하는
FundChain컨트랙트 스켈레톤 배포 getCampaignCount()를 호출하여0이 반환되는 것 확인
마지막 항목이 사소해 보일 수 있습니다. 하지만 그렇지 않습니다. 배포된 컨트랙트에서 0이 돌아오는 것을 확인하는 순간, 여러분은 전체 사이클을 완성한 것입니다: 작성 → 컴파일 → 배포 → 상호작용. 이후의 모든 것은 여러분이 이미 완전히 이해한 이 과정 위에 복잡성을 더하는 것입니다.
왜 먼저 Remix를 쓰고 나중에 Hardhat을 쓰는가
매 기수마다 이 질문을 받습니다. "이미 Hardhat을 설정했는데 왜 Remix로 바꾸나요?"
솔직하게 말씀드리겠습니다. Remix는 피드백 루프가 3초입니다. Hardhat은 30초입니다. 처음 문법을 배울 때 이 10배의 차이는 매우 큽니다. 함수를 작성하고, 컴파일을 누르고, 오류를 확인하고, 수정하고, 다시 컴파일합니다. 브라우저를 벗어날 필요가 없습니다. 터미널도, 설정 파일도, Node.js 버전 문제도 없습니다.
저는 2017년에 커맨드 라인에서 solc로 Solidity를 배웠습니다. 최악의 선택이었습니다. 컨트랙트 디버깅보다 툴체인 디버깅에 더 많은 시간을 썼습니다. 제 실수를 반복하지 마십시오.
지금 바로 remix.ethereum.org로 이동하십시오. 오늘의 작업 공간입니다. 자동화된 테스트를 작성하는 레슨 9에서 모든 것을 Hardhat으로 가져올 것입니다.
Remix 피드백 루프입니다. 오늘 이 사이클을 수십 번 반복하게 됩니다. 각 사이클은 10초 미만입니다.
Solidity 데이터 타입: 데이터에 맞는 올바른 상자 선택하기
Solidity는 정적 타입(Statically Typed) 언어입니다. JavaScript나 Python을 사용해 왔다면, 이것이 가장 큰 사고방식의 전환점입니다. 모든 변수는 사전에 타입을 선언해야 하며, 숫자가 있어야 할 곳에 문자열을 넣으려 하면 컴파일러가 오류를 냅니다.
핵심은 이렇습니다. 이더리움에서 타입은 정확성의 문제만이 아니라 비용의 문제이기도 합니다. uint8은 uint256보다 스토리지 가스 비용이 적습니다. 잘못된 타입을 선택하는 것은 단순히 우아하지 않은 것이 아니라, 사용자의 ETH를 실제로 낭비하는 것입니다.
값 타입(Value Types): 일상적인 도구 모음
전체 Solidity 코드의 95%에서 사용하게 될 타입들을 보여드리겠습니다:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
contract DataTypeDemo {
// Unsigned integer — no negatives allowed
uint256 public totalDonations = 0; // 0 to 2^256 - 1
// Address — 20-byte Ethereum address
address public owner = 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4;
// Boolean — true or false, that's it
bool public isActive = true;
// Signed integer — can go negative
int256 public temperature = -10;
// Smaller integers exist but USE WITH CAUTION
uint8 public smallNumber = 255; // max value for uint8
}
Remix에서 배포 후 예상 출력:
totalDonations클릭 →0owner클릭 →0x5B38Da6a701c568545dCfcB03FcB875f56beddC4isActive클릭 →truetemperature클릭 →-10smallNumber클릭 →255
이제 정수 크기에 대한 저의 강한 의견을 말씀드리겠습니다. 특별한 이유가 없다면 그냥 uint256을 사용하십시오. 낭비처럼 들릴 수 있습니다. 숫자 5를 저장하는 데 왜 256비트를 쓰냐고요? 하지만 EVM은 기본적으로 256비트 워드(Word) 단위로 작동합니다. uint8이나 uint128 같은 작은 타입을 사용하면 마스킹(Masking)과 캐스팅(Casting)을 위한 추가 연산이 필요하며, 일부 상황에서는 오히려 더 많은 가스가 소모될 수 있습니다. 작은 타입이 가스를 절약하는 경우는 단일 스토리지 슬롯에 여러 작은 변수를 패킹(Packing)할 때뿐입니다(레슨 3에서 다룰 예정).
"가스를 절약하려고" uint8을 모든 곳에 사용했다가 오히려 더 많이 지불한 컨트랙트를 검토한 적이 있습니다. 그런 개발자가 되지 마십시오.
| 타입 | 크기 | 범위 | 사용 시기 |
|---|---|---|---|
uint256 | 32바이트 | 0 ~ ~1.15 × 10⁷⁷ | 숫자의 기본값. 항상 여기서 시작. |
int256 | 32바이트 | -2¹²⁷ ~ 2¹²⁷ - 1 | 음수 값이 필요할 때만 |
address | 20바이트 | 모든 ETH 주소 | 지갑, 컨트랙트, 수신자 |
bool | 1바이트* | true / false | 플래그, 토글, 상태 |
uint8–uint128 | 1–16바이트 | 다양 | 구조체 패킹 전용 |
*bool은 스토리지에서 1바이트를 사용하지만, 다른 작은 타입과 패킹되지 않으면 32바이트 슬롯 전체를 차지합니다.
🤔 생각해 보기: 대부분의 프로그램에서 그렇게 큰 숫자가 필요하지 않은데도 Solidity가 기본으로 256비트 정수를 사용하는 이유는 무엇일까요?
답변 보기
EVM의 워드 크기가 256비트이기 때문입니다. 스택의 모든 연산은 256비트 값을 처리합니다. 이것은 자의적인 선택이 아니었습니다. 256비트는 Keccak-256 해시 출력값, wei(ETH의 최소 단위 — 1 ETH = 10¹⁸ wei) 단위의 모든 값, 그리고 현실적인 모든 토큰 공급량을 담을 수 있습니다. 기본 워드 크기를 사용한다는 것은 변환에 따른 오버헤드(Overhead)가 전혀 없다는 것을 의미합니다. EVM은 큰 수가 일반적인 암호학적·금융적 연산을 위해 설계되었으며, 예외적인 경우가 아닙니다.
address 타입: 이더리움에서 가장 중요한 타입
전통적인 프로그래밍에서 "주소(Address)"는 그냥 문자열입니다. Solidity에서 address는 내장 기능을 갖춘 일급(First-Class) 타입입니다:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
contract AddressDemo {
address public owner;
// Check the ETH balance of any address
function getBalance(address _account) public view returns (uint256) {
return _account.balance; // built-in property!
}
// Check if address is a contract (not foolproof, but useful)
function getCodeSize(address _account) public view returns (uint256) {
return _account.code.length;
}
}
배포 후 예상 출력:
- Remix 계정 드롭다운에서 주소로
getBalance호출 → wei 단위 잔액 반환 (예:100000000000000000000= 로컬 VM에서 100 ETH) - EOA(Externally Owned Account, 외부 소유 계정) 주소로
getCodeSize호출 →0 - 배포된 컨트랙트 자체 주소로
getCodeSize호출 →0보다 큰 숫자
이것이 제가 Solidity의 타입 시스템을 좋아하는 이유입니다. address 타입은 자신이 블록체인 위에 있다는 것을 알고 있습니다. .balance, .code, .transfer(), .send(), .call()이 내장되어 있습니다. 임포트(Import)도, 라이브러리(Library)도 필요 없습니다.
상태 변수 vs. 로컬 변수: 무엇이 온체인에 저장되는가?
초보자들이 실제 돈을 가장 많이 잃는 부분입니다.
상태 변수는 컨트랙트 레벨(함수 외부)에서 선언됩니다. 함수 호출 사이에도 지속됩니다. 스토리지에 저장됩니다. 레슨 1에서 배웠듯이 쓰기 비용이 20,000 가스인 비싼 공간입니다.
로컬 변수는 함수 내부에서 선언됩니다. 함수가 실행되는 동안에만 존재하다가 사라집니다. 공기처럼 무료입니다.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
contract StorageVsMemory {
// STATE VARIABLE — lives in storage slot 0
// Persists forever (until explicitly changed)
uint256 public callCount = 0;
function increment() public {
// LOCAL VARIABLE — lives in memory, disappears after function ends
uint256 previousValue = callCount;
// This modifies storage — costs ~5,000 gas (warm access)
callCount = previousValue + 1;
}
function pureCalculation(uint256 _a, uint256 _b) public pure returns (uint256) {
// _a and _b are local — never touch storage
uint256 result = _a + _b; // local too
return result; // returned, then forgotten
}
}
예상 출력:
- 배포 후
callCount클릭 →0 increment클릭 후callCount클릭 →1increment다시 클릭 후callCount클릭 →2pureCalculation에3과7을 입력하여 호출 →10callCount클릭 → 여전히2(pureCalculation은 건드리지 않았음)
패턴이 보이시나요? callCount는 기억합니다. 나머지는 모두 잊어버립니다. 저는 상태 변수로 선언했어야 할 것을 실수로 함수 안에 선언하여 이틀을 디버깅한 적이 있습니다. 호출할 때마다 값이 0으로 초기화되었습니다. 코드는 정상적으로 컴파일되었고, 테스트도 같은 트랜잭션 컨텍스트에서 실행되어 통과했습니다. 프로덕션에서만 문제가 드러났습니다. 변수를 어디에 선언하는지 항상 두 번 확인하십시오.
🤔 생각해 보기: 로컬 변수는 무료이고 상태 변수는 비싸다면, 왜 모든 것에 로컬 변수를 사용하지 않는 걸까요?
답변 보기
로컬 변수는 트랜잭션 간에 살아남지 못하기 때문입니다. 사용자가 기부를 하고 로컬 변수에 금액을 저장하면, 트랜잭션이 끝나는 순간 그 데이터는 사라집니다. 다음에 컨트랙트를 호출하는 사용자는 이전 기부가 있었다는 것을 알 방법이 없습니다. 상태 변수는 호출 간에 데이터를 유지하는 유일한 방법입니다. 이것이 블록체인 프로그래밍의 근본적인 트레이드오프(Trade-off)입니다. 지속성에는 가스 비용이 들고, 이는 네트워크의 모든 노드(Node)가 여러분의 데이터를 무기한 저장해야 하기 때문입니다.
❌ → 🤔 → ✅ 상태 변수 vs. 스토리지: 흔한 가스 함정
스토리지와 메모리의 비용 차이를 이해했으니, 이것이 실제 시나리오에서 어떻게 작용하는지 살펴보겠습니다. FundChain에서 누적 합계를 계산하고 카운터를 업데이트하는 함수를 만든다고 상상해 봅시다. 동일한 로직이 가스를 낭비하는 최악의 코드에서 최적화된 프로덕션 코드까지 어떻게 달라지는지 살펴보겠습니다.
❌ 잘못된 방법: 임시 작업에 상태 변수 사용
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
contract WrongWay {
uint256 public totalRaised;
uint256 public donationCount;
uint256 public lastCalculation; // ← state variable used as scratch space!
function recordDonation(uint256 _amount) public {
// EVERY line here writes to storage — 3 SSTORE operations!
lastCalculation = totalRaised + _amount; // ~5,000+ gas WASTED
totalRaised = lastCalculation; // another SSTORE
donationCount = donationCount + 1; // another SSTORE
}
}
// Gas cost per call: ~15,000+ (3 storage writes, 1 unnecessary)
// lastCalculation sits in storage FOREVER, wasting a 32-byte slot
문제점: lastCalculation은 한 줄의 연산에만 필요하지만 상태 변수로 선언되어 있습니다. 호출할 때마다 스토리지에 기록되어, 아무도 지속시킬 필요가 없는 값을 위해 ~5,000 가스를 낭비합니다. 수천 건의 기부에 걸쳐 곱하면 실제 ETH를 낭비하는 것입니다.
🤔 개선된 방법: 로컬 변수 사용, 하지만 스토리지를 반복적으로 읽음
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
contract BetterWay {
uint256 public totalRaised;
uint256 public donationCount;
function recordDonation(uint256 _amount) public {
// No wasted state variable — good!
// But we read totalRaised from storage TWICE (once here, once implicitly in +=)
uint256 newTotal = totalRaised + _amount; // SLOAD #1
totalRaised = newTotal; // SSTORE
donationCount = donationCount + 1; // SLOAD #2 + SSTORE
}
}
// Gas cost per call: ~10,200 (2 SLOADs at ~2,100 each + 2 SSTOREs at ~5,000 each)
// Eliminated the junk state variable — already a big improvement
개선된 점: 불필요한 lastCalculation이 스토리지에서 사라졌습니다. 하지만 각 상태 변수는 여전히 스토리지에서 개별적으로 읽히며, 컴파일러가 항상 이 읽기를 캐시(Cache)해 주는 것은 아닙니다.
✅ 최선의 방법: 스토리지 읽기를 로컬 변수에 캐시하고, 한 번만 쓰기
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
contract BestWay {
uint256 public totalRaised;
uint256 public donationCount;
function recordDonation(uint256 _amount) public {
// Cache storage reads into local (memory) variables — ONE SLOAD each
uint256 cachedTotal = totalRaised; // SLOAD #1
uint256 cachedCount = donationCount; // SLOAD #2
// All math happens in memory — essentially free
cachedTotal += _amount;
cachedCount += 1;
// Write back to storage ONCE per variable — ONE SSTORE each
totalRaised = cachedTotal; // SSTORE #1
donationCount = cachedCount; // SSTORE #2
}
}
// Gas cost per call: ~10,000 (2 SLOADs + 2 SSTOREs, no waste)
// Clean, explicit, and minimal storage interaction
왜 이것이 최선인가: 각 상태 변수는 스토리지에서 정확히 한 번 읽히고 정확히 한 번 기록됩니다. 모든 중간 연산은 무료인 로컬 메모리에서 이루어집니다. 이 "캐시 후 다시 쓰기(Cache-then-write-back)" 패턴은 관용적인(Idiomatic) Solidity입니다. 프로덕션의 모든 가스 최적화 컨트랙트에서 볼 수 있습니다. 간단한 예에서는 절약이 작아 보이지만, 루프(Loop)나 5~6개의 상태 변수를 건드리는 함수에서는 캐싱이 트랜잭션당 수만 가스를 절약할 수 있습니다.
원칙: 스토리지를 원격 데이터베이스처럼 다루십시오. 로컬 변수에 한 번 읽어오고, 메모리에서 작업을 마친 후, 마지막에 다시 쓰십시오.
함수(Functions): 컨트랙트의 공개 인터페이스
스마트 컨트랙트와의 모든 상호작용은 함수 호출입니다. 예외는 없습니다. Remix에서 공개 변수(예: callCount)를 클릭하여 읽는 것조차 내부적으로는 함수 호출입니다. 컴파일러가 자동으로 게터(Getter) 함수를 생성합니다.
Solidity 함수의 구조
function functionName(uint256 _param) public view returns (uint256) {
// ^이름 ^매개변수 ^가시성 ^가변성 ^반환 타입
}
초보자를 가장 혼란스럽게 하는 두 키워드를 분석해 보겠습니다: **가시성(Visibility)**과 상태 가변성(State Mutability).
가시성(Visibility): 누가 이것을 호출할 수 있는가?
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
contract VisibilityDemo {
uint256 private secretCount = 42;
// PUBLIC — anyone can call: users, other contracts, this contract
function getPublicData() public view returns (uint256) {
return secretCount;
}
// PRIVATE — only THIS contract can call
function _internalHelper() private pure returns (uint256) {
return 100;
}
// INTERNAL — this contract + child contracts (inheritance)
function _sharedLogic() internal pure returns (uint256) {
return 200;
}
// EXTERNAL — ONLY callable from outside (other contracts, users)
// Cannot be called internally with this.functionName() efficiently
function externalOnly() external view returns (uint256) {
// _internalHelper(); ✅ can call private from same contract
return secretCount + _internalHelper();
}
function testAccess() public view returns (uint256) {
// _internalHelper(); ✅ private — same contract
// _sharedLogic(); ✅ internal — same contract
// externalOnly(); ❌ COMPILE ERROR — external can't be called internally
return _internalHelper() + _sharedLogic();
}
}
배포 후 예상 출력:
getPublicData클릭 →42externalOnly클릭 →142testAccess클릭 →300_internalHelper와_sharedLogic은 Remix에 버튼으로 나타나지 않음 (외부에서 호출 불가)
저의 경험칙 — 한 번도 후회한 적 없습니다:
| 가시성 | 나의 원칙 |
|---|---|
external | 외부 사용자/컨트랙트만 호출하는 함수. 큰 콜데이터(Calldata)에 약간 더 저렴. |
public | 내부와 외부 모두에서 호출되어야 하는 함수. 상태 변수 게터. |
private | 헬퍼(Helper) 함수. _를 앞에 붙임. 기본으로 이것을 사용. |
internal | 상속(Inheritance)을 사용할 때만 (레슨 6+). _를 앞에 붙임. |
private에서 시작하고, 필요할 때만 public 또는 external로 완화하십시오. 이것이 최소 권한 원칙(Principle of Least Privilege)입니다. 개발자들이 "편의를 위해" 모든 것을 public으로 만들어 관리자 함수를 실수로 노출시킨 컨트랙트를 감사한 적이 있습니다. DeFi에서는 수백만 달러짜리 실수입니다.
⚠️ 중요한 오해:
private은 블록체인에서 데이터를 숨기지 않습니다. 누구든eth_getStorageAt을 사용하여 스토리지 슬롯을 직접 읽을 수 있습니다.private은 다른 컨트랙트가 함수를 호출하는 것을 막을 뿐입니다. 블록체인은 투명합니다. 온체인에는 비밀이 없습니다.
상태 가변성(State Mutability): 이 함수는 스토리지에 무엇을 하는가?
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
contract MutabilityDemo {
uint256 public counter = 0;
// No keyword — MODIFIES state. Costs gas.
function increment() public {
counter += 1; // writes to storage
}
// VIEW — reads state, does NOT modify. FREE when called externally.
function getCounter() public view returns (uint256) {
return counter; // reads from storage
}
// PURE — doesn't even READ state. FREE. Pure math.
function add(uint256 _a, uint256 _b) public pure returns (uint256) {
return _a + _b; // no state access whatsoever
}
}
예상 출력:
- 배포 후
getCounter호출 →0(Remix에서 파란색 버튼 — 가스 없음) increment호출 (주황색 버튼 — 가스 소모)getCounter호출 →1add(5, 3)호출 →8(파란색 버튼 — 가스 없음)
Remix에서 view와 pure 함수는 파란색 버튼을 가지고, 상태를 변경하는 함수는 주황색 버튼을 가집니다. 그 색상 차이가 알려주는 것은 이렇습니다: "파란색은 무료, 주황색은 가스 소모." 이에 주의를 기울이십시오.
🔍 심층 분석: view/pure 함수는 왜 무료인가?
view 또는 pure 함수를 외부에서 (다른 트랜잭션 내부가 아닌) 직접 호출하면, 연결된 노드에서 eth_call을 사용하여 로컬로 실행됩니다. 트랜잭션이 브로드캐스트(Broadcast)되지 않고, 블록이 생성되지 않으며, 가스가 청구되지 않습니다. 노드는 실행을 시뮬레이션하고 결과를 반환할 뿐입니다.
하지만 주의할 점이 있습니다: 상태를 변경하는 함수가 내부적으로 view 함수를 호출하면, 그 view 함수의 실행은 가스를 소모합니다. 이제 트랜잭션의 일부이기 때문입니다. "무료"라는 것은 직접 외부 호출에만 적용됩니다.
생성자(Constructor): 컨트랙트의 탄생
생성자는 정확히 한 번 실행됩니다. 컨트랙트가 배포될 때입니다. 그 이후로는 절대 다시 실행되지 않습니다. 초기 상태를 설정하는 곳입니다.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
contract ConstructorDemo {
address public owner;
uint256 public createdAt;
string public name;
// Runs ONCE at deployment
constructor(string memory _name) {
owner = msg.sender; // whoever deploys becomes owner
createdAt = block.timestamp; // current block time
name = _name; // parameter passed during deployment
}
function getContractInfo() public view returns (
address _owner,
uint256 _createdAt,
string memory _name
) {
return (owner, createdAt, name);
}
}
Remix에서 배포하는 방법:
- 컴파일 (Ctrl+S)
- Deploy 패널로 이동
- "Deploy" 버튼 옆에 입력:
"MyCrowdfund"(따옴표 포함 — 문자열이므로) - Deploy 클릭
예상 출력:
owner클릭 → 여러분의 Remix 계정 주소createdAt클릭 → Unix 타임스탬프 (예:1711540800)name클릭 →MyCrowdfundgetContractInfo호출 → 세 값 모두의 튜플(Tuple)
생성자 안의 msg.sender는 컨트랙트를 배포한 주소입니다. 이것은 컨트랙트 소유권을 설정하는 가장 일반적인 패턴입니다. OpenZeppelin의 Ownable, 모든 DeFi 프로토콜, 어디에서나 볼 수 있습니다. 지금 배워두십시오. 앞으로 백 번은 작성하게 될 것입니다.
🤔 생각해 보기: 배포 후에 생성자를 호출하려 하면 어떻게 될까요? 예를 들어,
constructor()로직을 일반 함수에 넣었다면요?
답변 보기
배포 후에는 생성자를 호출할 수 없습니다. EVM은 배포된 바이트코드(Bytecode)에 생성자 코드를 포함하지 않습니다. 생성자는 CREATE 옵코드(Opcode) 실행 중에 실행되어 런타임 바이트코드(온체인에 실제로 존재하는 코드)를 생성합니다. 이것은 보안 기능입니다: 초기화 로직은 재실행될 수 없습니다.
하지만 생성자 로직을 initialize()와 같은 일반 public 함수에 복사하면, 누구든 호출하여 컨트랙트를 재초기화할 수 있습니다. 이것은 프록시(Proxy)/업그레이더블(Upgradeable) 컨트랙트에서 실제로 존재하는 취약점입니다. "초기화되지 않은 프록시(Uninitialized Proxy)" 버그라고 합니다. OpenZeppelin은 주요 프로토콜 감사에서 이것을 놓친 적이 있습니다. 레슨 7에서 보안을 다룰 때 다시 살펴볼 것입니다.
🔨 프로젝트 업데이트
이제 코드를 작성할 차례입니다. 레슨 1 이후 FundChain.sol의 상태는 다음과 같았습니다:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
contract FundChain {
// Empty — we'll add code starting Lesson 2
}
이제 첫 번째 실제 코드를 추가합니다. Remix를 열고, FundChain.sol이라는 새 파일을 만들어 다음을 붙여넣으십시오:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
/// @title FundChain — Decentralized Crowdfunding
/// @notice Lesson 2: Contract skeleton with owner and campaign counter
contract FundChain {
// ========== STATE VARIABLES (NEW) ==========
// Contract deployer — the platform administrator
address public owner;
// Total number of campaigns created (starts at 0)
uint256 public campaignCount;
// ========== CONSTRUCTOR (NEW) ==========
// Runs once at deployment — sets the deployer as owner
constructor() {
owner = msg.sender;
}
// ========== VIEW FUNCTIONS (NEW) ==========
// Returns the current campaign count
// Will become useful once we add campaign creation in Lesson 3
function getCampaignCount() public view returns (uint256) {
return campaignCount;
}
}
배포 및 검증 — 단계별 안내
- Remix에서 Solidity Compiler 탭으로 이동 (왼쪽 사이드바, 두 번째 아이콘)
- 컴파일러 버전을
0.8.26으로 설정 - Compile FundChain.sol 클릭 (또는 Ctrl+S)
- Deploy & Run Transactions 탭으로 이동 (세 번째 아이콘)
- Environment: Remix VM (Cancun) — 브라우저 내 로컬 블록체인
- Deploy 클릭
이제 상호작용:
| 액션 | 예상 결과 |
|---|---|
owner 클릭 | 여러분의 Remix 계정 주소 (예: 0x5B38Da6a...) |
campaignCount 클릭 | 0 |
getCampaignCount 클릭 | 0 |
지금까지 만든 프로젝트를 실행해 보십시오. owner가 여러분의 주소를 반환하고 getCampaignCount()가 0을 반환해야 합니다. 둘 다 작동하면 첫 번째 의미 있는 스마트 컨트랙트를 배포한 것입니다. 다음 레슨에서 캠페인 생성 로직을 추가하면 그 0이 1, 2, 3...으로 바뀔 것입니다.
🤔 생각해 보기: 생성자에서
campaignCount = 0을 명시적으로 설정하지 않았는데 왜0을 반환할까요?
답변 보기
Solidity에서 모든 상태 변수는 타입의 기본값으로 자동 초기화됩니다. uint256의 기본값은 0입니다. address의 기본값은 0x0000000000000000000000000000000000000000(제로 주소)입니다. bool의 기본값은 false입니다. 이것은 EVM 레벨에서 발생합니다. 스토리지 슬롯은 기본적으로 0으로 초기화됩니다. 따라서 uint256 public campaignCount;와 uint256 public campaignCount = 0;은 정확히 동일한 바이트코드로 컴파일됩니다. 저는 프로덕션 코드에서 명시적 = 0을 생략하는 것을 선호하지만(배포 가스를 약간 절약), 학습 중에는 명확성을 위해 명시적으로 쓰는 것도 좋습니다.
"getCampaignCount 함수를 작성하지 않았는데 왜 campaignCount 버튼이 있나요?"
눈치채셨다면 훌륭한 관찰입니다. getCampaignCount()를 작성했지만 Remix는 campaignCount 버튼도 보여줍니다. 둘 다 0을 반환합니다. 무슨 일이 일어나고 있는 걸까요?
상태 변수를 public으로 선언하면 Solidity 컴파일러가 동일한 이름의 게터 함수를 자동으로 생성합니다. 따라서 uint256 public campaignCount는 암묵적으로 function campaignCount() external view returns (uint256)을 만듭니다.
즉, 지금 당장은 getCampaignCount() 함수가 기술적으로 중복됩니다. 그렇다면 왜 포함했을까요? 명시적 게터를 작성하는 습관을 가르치기 위해서입니다. 레슨 3에서 자동 생성된 게터가 예상대로 반환하지 않는 복잡한 데이터 구조(매핑(Mapping), 구조체(Struct))를 다루게 됩니다. 지금 이 습관을 들이면 나중에 혼란을 피할 수 있습니다.
🔍 심층 분석: 자동 게터가 한계에 부딪히는 경우
uint256, address, bool 같은 단순 타입에는 자동 게터가 완벽하게 작동합니다. 하지만 매핑의 경우 자동 게터는 키(Key) 매개변수가 필요합니다. 배열의 경우 인덱스(Index)가 필요합니다. 구조체의 경우 구조체 자체가 아닌 평탄화된 튜플을 반환합니다. 복잡한 데이터의 경우 거의 항상 커스텀 게터가 필요합니다. 레슨 3에서 이 벽에 부딪히게 될 것이며, 그때 작성 방법을 알고 있다는 것에 감사하게 될 것입니다.
복습: 다음으로 넘어가기 전 자기 점검
이 체크리스트를 살펴보십시오. 솔직하게 — 모호한 부분이 있다면 위로 스크롤하여 다시 읽으십시오.
-
uint256이 정수의 기본 선택인 이유(EVM 워드 크기)를 설명할 수 있다 -
state variables(스토리지, 지속성, 가스 소모)과local variables(메모리, 임시, 무료)의 차이를 안다 - 올바른
visibility(public,private,external,internal)로 함수를 작성할 수 있다 -
view(상태 읽기),pure(상태 접근 없음), 키워드 없음(상태 수정)을 언제 사용하는지 이해한다 -
private이 블록체인에서 데이터를 숨기지 않는다는 것을 안다 - 내
FundChain.sol이 컴파일되고, 배포되고,getCampaignCount()에서0을 반환한다
이 레슨을 가르칠 때마다 반복되는 흔한 실수들:
- 함수 시그니처(Signature)에서
returns키워드 빠뜨리기 —function foo() public view (uint256)은 컴파일되지 않습니다. 's'가 있는returns가 필요합니다. - 비교에서
=대신==사용 — Solidity는 C 스타일 문법을 따릅니다. 컴파일러가 잡아주기도 하지만 그렇지 않을 때도 있습니다. string선언에memory빠뜨리기 —function foo(string _name)은 컴파일되지 않습니다. 문자열은 참조 타입(Reference Type)이므로 데이터 위치(Data Location)가 필요합니다:string memory _name. 이유는 레슨 3에서 다룰 것입니다.- 모든 것을
public으로 만들기 — 이것은 현관문을 활짝 열어두는 보안 수준입니다.private으로 시작하고, 필요할 때만 완화하십시오.
다음 단계: 시니어 개발자는 이렇게 다르게 생각한다
위의 모든 내용이 편안하다면, 경험 많은 Solidity 개발자들이 어떻게 다르게 생각하는지 알아보겠습니다:
1. 생성자에서 한 번 설정하는 값에는 immutable을 사용합니다:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
contract Optimized {
// immutable — set in constructor, saved in bytecode, not storage
// Reading this costs 3 gas (PUSH) vs 2,100 gas (SLOAD)
address public immutable owner;
constructor() {
owner = msg.sender;
}
}
immutable 변수는 배포 시 컨트랙트 바이트코드에 포함됩니다. 읽기 비용은 스토리지 읽기의 2,100 가스 대신 3 가스입니다. 절대 변경되지 않는 owner 변수의 경우, 모든 읽기에서 700배의 가스 절감 효과가 있습니다. 저는 한 번 설정되고 수정되지 않는 모든 값에 immutable을 사용합니다. 프로덕션 FundChain에서는 이것을 사용할 것이지만, 학습을 위해서는 일반 상태 변수가 더 명확합니다.
2. require 문자열 대신 custom errors(커스텀 에러)를 사용합니다:
// Gas-expensive way (stores string on-chain)
require(msg.sender == owner, "Not the owner");
// Gas-efficient way (Solidity 0.8.4+)
error NotOwner();
if (msg.sender != owner) revert NotOwner();
커스텀 에러는 레슨 6에서 구현할 것입니다. 지금은 그것이 존재한다는 것만 알아두십시오.
3. 문서화를 위해 NatSpec(NatSpec) 주석을 사용합니다:
FundChain.sol의 /// @title과 /// @notice 주석이 보이시나요? 이것이 NatSpec입니다. 이더리움의 문서화 표준입니다. Etherscan 같은 도구가 이를 사용하여 사람이 읽을 수 있는 컨트랙트 설명을 자동으로 생성합니다. 작성하는 데 10초가 걸리며, 컨트랙트를 전문적으로 보이게 합니다.
요약 다이어그램
다음 레슨에서는 실제로 캠페인을 표현하는 데이터 구조를 추가합니다. struct Campaign, mapping(uint256 => Campaign), 그리고 createCampaign() 함수입니다. 그때부터 FundChain이 진짜 애플리케이션처럼 느껴지기 시작합니다. 오늘 0을 반환하는 campaignCount가 증가하기 시작할 것입니다.
난이도 선택
🟢 편안하게 이해했습니다
기억해야 할 핵심:
uint256은 기본 숫자 타입.address는 기본 "누구" 타입.- 상태 변수 = 지속적 + 비쌈. 로컬 변수 = 임시 + 무료.
view는 상태를 읽고,pure는 상태를 무시하며, 키워드 없음은 상태를 수정함.- 생성자는 한 번 실행됨. 생성자 안의
msg.sender= 배포자 = 소유자.
구조체와 매핑으로 캠페인 데이터 구조를 만드는 레슨 3으로 넘어갈 준비가 되었습니다.
🟡 일부분이 헷갈립니다
가시성 수정자가 흐릿하다면, 건물로 생각해 보십시오:
public= 정문. 누구든 들어올 수 있음.external= 배달 입구. 외부 사람만 사용 가능.internal= 사무실 사이의 복도. 이미 내부에 있는 사람(다른 층의 임차인 포함 — 자식 컨트랙트)만 사용 가능.private= 잠긴 개인 사무실. 본인만.
이 연습을 해보십시오: VisibilityDemo 컨트랙트로 돌아가서 _internalHelper를 private에서 internal로 변경해 보십시오. 달라지는 것이 있나요? (정답: 이 컨트랙트에서는 없습니다. 차이는 다른 컨트랙트가 이 컨트랙트를 상속할 때만 나타납니다.)
그런 다음 externalOnly를 public으로 변경하고 testAccess에서 호출해 보십시오. 이제 작동해야 합니다. 이것이 external vs public의 차이입니다.
🔴 도전: 인터뷰 수준의 질문
질문: 이 컨트랙트를 보십시오. 스토리지 레이아웃은 어떻게 됩니까? 각 변수는 어떤 슬롯을 차지합니까?
contract Layout {
uint128 public a; // ?
uint128 public b; // ?
uint256 public c; // ?
address public d; // ?
bool public e; // ?
}
정답:
a와b는 모두uint128(각 16바이트)입니다. 합쳐서 슬롯 0에 들어갑니다(총 32바이트). 이것을 "변수 패킹(Variable Packing)"이라고 합니다.c는uint256(32바이트)입니다. 슬롯 0의 남은 공간(0바이트)에 들어가지 않습니다. 슬롯 1로 이동합니다.d는address(20바이트)입니다. 슬롯 2로 이동합니다.e는bool(1바이트)입니다. 슬롯 2의 남은 12바이트에 들어갑니다.d와 함께 슬롯 2에 패킹됩니다.
총계: 3개의 스토리지 슬롯. 이것이 Solidity에서 선언 순서가 중요한 이유입니다. 변수를 재배열하면 가스 비용이 달라질 수 있습니다. FundChain을 최적화할 때 이것을 활용할 것입니다.