온체인 데이터 설계: 구조체, 매핑, 그리고 캠페인 레지스트리 구축
학습 목표
- ✓Define a struct that models a real-world entity with multiple typed fields
- ✓Use mappings with the counter pattern to store and retrieve records by unique ID
- ✓Explain the gas cost difference between storage writes and memory operations
- ✓Distinguish between storage references and memory copies when working with structs
- ✓Implement a createCampaign function that validates input, increments a counter, and stores structured data on-chain
- ✓Read structured data from a mapping using a view function with proper data location annotations
온체인 데이터 설계: 구조체(Struct), 매핑(Mapping), 그리고 캠페인 레지스트리 구축
지난 레슨에서 여러분은 owner 주소와 0으로 초기화된 campaignCount, 그리고 getCampaignCount() 뷰 함수를 갖춘 컨트랙트 스켈레톤을 배포했습니다. 뼈대는 완성됐습니다. 이제 내장 기관을 채워 넣을 차례입니다.
현실을 직시해 봅시다. 진지한 스마트 컨트랙트는 근본적으로 비즈니스 로직이 결합된 데이터베이스입니다. FundChain 컨트랙트는 캠페인을 저장해야 합니다 — 각각 생성자, 제목, 목표 금액, 마감일 등을 포함해서요. 이제 uint256 하나로는 부족합니다. 오늘은 Solidity가 복잡한 온체인 데이터를 어떻게 설계하게 해주는지, 그리고 더 중요하게는 사용자의 ETH 가스비를 낭비하지 않는 방법을 배웁니다.
개발자가 잘못된 데이터 구조를 선택해서 불필요한 가스비로 $50,000을 소진한 컨트랙트를 실제로 본 적이 있습니다. 매핑(Mapping)이 필요한 곳에 배열(Array)을 쓰고, 메모리(Memory)로 충분한 곳에 스토리지(Storage) 쓰기를 사용한 것이죠. 오늘 레슨이 끝나면 왜 그런 실수가 발생하는지 정확히 이해할 수 있을 것이고 — 여러분은 그런 실수를 하지 않을 것입니다.
오늘의 미션
구체적인 결과물: 작동하는 FundChain 컨트랙트:
- 7개 필드를 가진
Campaign구조체 - ID를 Campaign 데이터에 연결하는
campaigns매핑 - 새 캠페인을 온체인에 저장하는
createCampaign()함수 - 캠페인을 읽어오는
getCampaign()뷰 함수 - Remix에서 테스트: 캠페인 2개 이상 생성, 읽기, 영속성 확인
크라우드펀딩만이 아니라 어떤 스마트 컨트랙트에도 데이터 모델을 설계할 수 있는 능력을 갖추고 이 레슨을 떠나게 됩니다.
구조체(Struct): 나만의 데이터 설계도
레슨 2에서 다뤘듯, Solidity는 기본 타입을 제공합니다: uint256, address, bool, string. 그런데 크라우드펀딩 캠페인은 단일 숫자나 단일 주소가 아닙니다. 연관된 데이터의 묶음입니다. 이럴 때 구조체가 필요합니다.
구조체는 여러분이 직접 발명하는 커스텀 타입이라고 생각하면 됩니다. Solidity는 "Campaign"이 무엇인지 모릅니다 — 여러분이 가르쳐줘야 합니다.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
contract StructDemo {
// Define the blueprint — no data stored yet
struct Campaign {
address creator;
string title;
string description;
uint256 goalAmount;
uint256 currentAmount;
uint256 deadline;
bool isCompleted;
}
// Now USE the blueprint to create an actual variable
Campaign public myCampaign;
function setCampaign() public {
myCampaign = Campaign(
msg.sender, // creator
"Build a Bridge", // title
"Community bridge", // description
5 ether, // goalAmount
0, // currentAmount
block.timestamp + 30 days, // deadline
false // isCompleted
);
}
}
// Deploy → call setCampaign() → click myCampaign
// Output: tuple with all 7 fields populated
제 강한 의견: 구조체 필드는 항상 논리적인 순서로 나열하세요 — 식별 필드 먼저(creator), 다음에 콘텐츠 필드(title, description), 재무 필드(goalAmount, currentAmount), 마지막으로 시간 및 상태 필드(deadline, isCompleted). 수십 개의 프로덕션 컨트랙트를 리뷰해봤는데, 6개월 후에도 읽기 쉬운 컨트랙트는 전부 이 패턴을 따릅니다. 그렇지 않은 것들은? isCompleted가 title과 goalAmount 사이에 끼어 있는 걸 봤습니다. 그걸 디버깅하는 건 정말 고역입니다.
🤔 생각해보세요: 레슨 1에서 EVM에는 스택(Stack), 메모리(Memory), 스토리지(Storage) 세 가지 데이터 영역이 있다고 했습니다.
Campaign public myCampaign을 상태 변수로 선언하면 어느 영역에 저장될까요?
답변 보기
스토리지(Storage)입니다. 상태 변수는 항상 컨트랙트 스토리지에 저장됩니다 — 트랜잭션 사이에도 데이터가 유지되는 영구적인 키-값 저장소입니다. 새로운 슬롯에 쓰는 데 약 20,000 가스가 드는 가장 비싼 영역이지만, 온체인에서 데이터가 영구적으로 지속되는 유일한 곳입니다. 메모리와 스택은 각 트랜잭션이 완료되면 삭제됩니다.
구조체를 초기화하는 두 가지 방법
위치 기반 문법과 필드명 기반 문법이 있습니다. 저는 항상 프로덕션에서 필드명 방식을 선호합니다 — 이유는 다음과 같습니다:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
contract InitStyles {
struct Campaign {
address creator;
string title;
uint256 goalAmount;
}
Campaign public campA;
Campaign public campB;
function positionalInit() public {
// ❌ Positional — which field is which?
campA = Campaign(msg.sender, "Alpha Fund", 1 ether);
}
function namedInit() public {
// ✅ Named — crystal clear, order doesn't matter
campB = Campaign({
title: "Beta Fund",
creator: msg.sender,
goalAmount: 2 ether
});
}
}
// Deploy → call both functions → click campA and campB
// campA: (your_address, "Alpha Fund", 1000000000000000000)
// campB: (your_address, "Beta Fund", 2000000000000000000)
필드가 3개일 때는 위치 기반 초기화가 깔끔해 보입니다. 이제 7개를 상상해보세요. 또는 12개를. 두 uint256 값을 실수로 바꿔서 넣게 될 것입니다. 타입이 맞아서 컴파일러가 잡아주지도 않습니다. 저도 그런 경험을 했습니다. goalAmount와 deadline이 뒤바뀐 채로 컨트랙트가 배포됐었죠. 필드명 방식은 그런 종류의 버그 자체를 없애줍니다.
매핑(Mapping): 온체인 키-값 저장소
배열은 순서가 있는 리스트를 저장하게 해줍니다. 그런데 온체인에서는 리스트를 순회하는 경우가 거의 없습니다 — 각 단계마다 가스가 소비되고, 배열이 10,000개 항목으로 늘어나면 루프가 블록 가스 한도를 초과해서 함수가 영구적으로 호출 불가 상태가 될 수 있습니다.
**매핑(Mapping)**은 Solidity의 해답입니다. 해시 기반 조회 방식으로, 키를 주면 값을 돌려줍니다. 상수 시간(O(1))이고, 순회도 없습니다. 이것이 트레이드오프이며 — 스마트 컨트랙트 사용 사례의 90%에서는 올바른 선택입니다.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
contract MappingDemo {
// mapping(KeyType => ValueType) visibility name;
mapping(uint256 => string) public names;
mapping(address => uint256) public balances;
function setData() public {
names[1] = "Alice";
names[2] = "Bob";
balances[msg.sender] = 100;
}
function getData() public view returns (string memory, uint256) {
return (names[1], balances[msg.sender]);
}
}
// Deploy → call setData() → call getData()
// Output: ("Alice", 100)
// Try: names(1) → "Alice", names(99) → "" (default!)
대부분의 튜토리얼이 건너뛰는 중요한 사실이 있습니다: 매핑의 모든 가능한 키는 이미 기본값과 함께 "존재"합니다. names(99)를 호출해도 에러가 나지 않습니다 — 빈 문자열이 반환됩니다. 무작위 주소의 balances를 조회해도 0이 반환됩니다. 매핑은 명시적으로 설정된 것과 그렇지 않은 것을 구분하지 못합니다.
이것이 매우 중요합니다. 매핑에 "항목이 몇 개입니까?" 또는 "모든 키를 알려주세요"라고 물을 수 없습니다. 그래서 카운터 패턴이 필요합니다 — 곧 자세히 설명하겠습니다.
| 기능 | 매핑(Mapping) | 동적 배열(Dynamic Array) |
|---|---|---|
| 키/인덱스로 조회 | O(1) — 상수 시간 | O(1) — 상수 시간 |
| 키 존재 여부 확인 | 내장 방법 없음 | length 확인 가능 |
| 전체 항목 순회 | 불가능 | 가능하지만 비용 큼 |
| 전체 개수 조회 | 별도 카운터 필요 | .length 내장 |
| 단일 접근 가스 | ~200 gas (warm) | ~200 gas (warm) |
| 순회 가스 | 해당 없음 | O(n) — 위험 |
| 사용 사례 | 대부분의 컨트랙트 데이터 | 소규모, 크기 제한된 리스트 |
저의 경험 법칙: 기본적으로 매핑을 사용하세요. 배열은 모든 요소를 열거해야 할 때만 사용하세요 (예: UI를 위한 모든 기부자 주소 목록) — 그 경우에도 배열 크기를 제한하세요.
🤔 생각해보세요: 매핑을 순회할 수 없고 길이도 없다면, DApp 프론트엔드는 "모든 캠페인"을 어떻게 표시할까요? Etherscan은 ERC-20의 모든 토큰 보유자를 어떻게 보여줄까요?
답변 보기
두 가지 접근 방식이 있습니다:
- 이벤트(Events, 레슨 6): 컨트랙트는 상태가 변경될 때 이벤트를 발행합니다. 오프체인 인덱서(The Graph 또는 Etherscan 같은)가 이 이벤트를 듣고 쿼리 가능한 데이터베이스를 구축합니다. 블록체인이 진실의 원천이고, 인덱서가 검색 가능하게 만들어줍니다.
- 카운터 패턴 + 순차 읽기:
count변수를 유지하고, 순차적인 ID(1, 2, 3...)를 사용하여 프론트엔드가getCampaign(1),getCampaign(2)등을 순서대로 호출합니다. 이것이 바로 오늘 구축할 내용입니다.
대부분의 프로덕션 DApp은 두 방식을 모두 사용합니다.
스토리지(Storage) vs. 메모리(Memory) vs. 콜데이터(Calldata): 데이터가 저장되는 곳
여기서 Solidity가 진짜로 까다로워지기 시작합니다. 레슨 1의 EVM 아키텍처 지식이 빛을 발하는 순간이기도 합니다. 세 가지 데이터 영역을 기억하시나요? 이제 함수 실행 중에 복잡한 데이터(문자열, 배열, 구조체)가 어디에 있어야 하는지 Solidity에 명시적으로 알려줘야 합니다.
차이를 보여주는 구체적인 예시입니다:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
contract DataLocations {
struct Player {
string name;
uint256 score;
}
Player public player1;
// calldata: read-only, cheapest for inputs
function setPlayer(string calldata _name) external {
// memory: temporary copy, modifiable
Player memory tempPlayer = Player(_name, 0);
tempPlayer.score = 100; // ✅ can modify memory
// storage: writing to persistent state
player1 = tempPlayer; // copies memory → storage
}
function getScore() external view returns (uint256) {
// Reading from storage — no gas cost in view
return player1.score;
}
}
// Deploy → setPlayer("Alex") → getScore()
// Output: 100
규칙은 간단합니다:
- 함수 매개변수 (문자열, 배열 같은 복잡한 타입):
external함수에는calldata,public함수에는memory사용 - 지역 변수 (복잡한 타입):
memory사용 - 상태 변수: 항상
storage(키워드를 쓰지 않아도 암묵적으로 적용됨)
위험해지는 지점은 **스토리지 참조(Storage Reference)**입니다:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
contract StorageRef {
uint256[] public numbers;
constructor() {
numbers.push(10);
numbers.push(20);
}
function dangerousModify() public {
// storage reference — points directly at state!
uint256[] storage ref = numbers;
ref[0] = 999; // ⚠️ This modifies the ACTUAL state variable
}
function safeRead() public view returns (uint256) {
// memory copy — independent from state
uint256[] memory copy = numbers;
// copy[0] = 999 would NOT affect `numbers`
return copy[0];
}
}
// Deploy → safeRead() returns 10
// Call dangerousModify() → safeRead() now returns 999!
storage 참조는 실제 온체인 데이터를 가리키는 포인터입니다. 수정하면 상태가 수정됩니다. memory 복사본은 독립적입니다 — 그것에 가한 변경이 스토리지에 영향을 미치지 않습니다. 저는 주니어 개발자가 스토리지 참조를 복사본으로 착각하고 실수로 컨트랙트 상태를 덮어쓰는 걸 본 적이 있습니다. 최신 버전의 컴파일러는 storage 의도에 memory를 사용하려 할 때 경고를 해주지만, 개념을 이해하는 것이 결국 여러분을 보호합니다.
🔍 심화: 스토리지 쓰기에 왜 20,000 가스가 드는가?
모든 스토리지 슬롯 수정은 다음 과정을 거쳐야 합니다:
- 실행 노드에 의해 처리
- 트랜잭션 영수증에 포함
- 전 세계 모든 이더리움 노드에 복제 — 수만 개에 달합니다
- 영구적으로 저장 (덮어쓰기 전까지)
단일 SSD에 쓰는 것이 아닙니다. 전 세계적으로 복제되는 불변 데이터베이스에 쓰는 것입니다. 그 20,000 가스는 영구적이고 탈중앙화된 지속성의 경제적 비용입니다. 바로 그래서 레슨 4에서 ETH를 이동시키기 시작할 때 가스 최적화가 중요해지는 것입니다 — 불필요한 storage 쓰기는 곧 낭비되는 실제 돈입니다.
카운터 패턴: 배열 없이 순차적 ID 할당하기
이제 모든 것을 조합해봅시다. 각 캠페인에 고유한 ID를 부여해야 합니다. 전통적인 데이터베이스에서는 자동 증가(auto-increment)를 사용합니다. Solidity에는 자동 증가가 없습니다. 하지만 레슨 2에서 이미 campaignCount를 만들었습니다 — 이것이 우리의 카운터입니다.
패턴은 다음과 같습니다:
왜 먼저 증가시키고 저장하는가? ID가 0이 아닌 1부터 시작하기 때문입니다. 설정되지 않은 매핑 키는 기본값(전부 0)을 반환합니다. 첫 번째 캠페인의 ID가 0이라면 "캠페인 0이 존재함"과 "이 ID는 한 번도 사용된 적 없음"을 구분할 수 없습니다. 1부터 시작하면 creator 주소가 0인 캠페인은 생성된 적이 없다는 뜻입니다. 이것은 Solidity에서 잘 알려진 패턴이며, 저는 모든 컨트랙트에서 사용합니다.
FundChain에 적용하기 전에 카운터 패턴을 단독으로 보여드리겠습니다:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
contract CounterPattern {
struct Item {
string name;
uint256 value;
}
uint256 public itemCount;
mapping(uint256 => Item) public items;
function addItem(string calldata _name, uint256 _value) external {
itemCount++; // 0 → 1, then 1 → 2, etc.
items[itemCount] = Item(_name, _value);
}
function getItem(uint256 _id) external view returns (string memory, uint256) {
Item memory item = items[_id];
return (item.name, item.value);
}
}
// Deploy → addItem("Sword", 100) → addItem("Shield", 50)
// itemCount() → 2
// getItem(1) → ("Sword", 100)
// getItem(2) → ("Shield", 50)
// getItem(99) → ("", 0) ← default values, not an error!
🤔 생각해보세요: 두 사용자가 동시에
addItem()을 호출하면 어떻게 될까요? 같은 ID를 받을 수 있을까요?
답변 보기
아닙니다. 이더리움 트랜잭션은 블록 내에서 병렬이 아닌 순차적으로 실행됩니다. 두 트랜잭션이 같은 블록에 있더라도 EVM은 하나씩 처리합니다. 첫 번째 트랜잭션이 itemCount를 예를 들어 3으로 증가시키면, 두 번째 트랜잭션은 3을 보고 4로 증가시킵니다. 멀티스레드 프로그래밍에서 아는 경쟁 조건(Race Condition)은 EVM에서 존재하지 않습니다. 이것이 블록체인의 아름다움 중 하나입니다: 결정론적이고 순차적인 실행.
(MEV(Miner/Maximal Extractable Value)라 불리는 미묘한 순서 문제가 있지만, 오늘 범위를 훨씬 벗어납니다.)
createCampaign()과 getCampaign() 구현하기
모든 것을 적용할 시간입니다. FundChain 컨트랙트를 완전한 캠페인 레지스트리로 확장해봅시다.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
contract FundChain {
// ── From Lesson 2 ──
address public owner;
uint256 public campaignCount;
// ── NEW: Campaign struct ──
struct Campaign {
address creator;
string title;
string description;
uint256 goalAmount;
uint256 currentAmount;
uint256 deadline;
bool isCompleted;
}
// ── NEW: Campaign storage ──
mapping(uint256 => Campaign) public campaigns;
constructor() {
owner = msg.sender;
}
// ── From Lesson 2 ──
function getCampaignCount() public view returns (uint256) {
return campaignCount;
}
// ── NEW: Create a campaign ──
function createCampaign(
string calldata _title,
string calldata _description,
uint256 _goalAmount,
uint256 _durationInDays
) external {
campaignCount++;
campaigns[campaignCount] = Campaign({
creator: msg.sender,
title: _title,
description: _description,
goalAmount: _goalAmount,
currentAmount: 0,
deadline: block.timestamp + (_durationInDays * 1 days),
isCompleted: false
});
}
// ── NEW: Read a campaign ──
function getCampaign(uint256 _id) external view returns (
address creator,
string memory title,
string memory description,
uint256 goalAmount,
uint256 currentAmount,
uint256 deadline,
bool isCompleted
) {
Campaign memory c = campaigns[_id];
return (
c.creator,
c.title,
c.description,
c.goalAmount,
c.currentAmount,
c.deadline,
c.isCompleted
);
}
}
설계 결정 사항들을 설명하겠습니다:
왜 _title과 _description에 calldata를 사용하는가? 함수가 external이고, 이 문자열 매개변수는 읽기만 할 뿐 수정하지 않습니다. calldata가 가장 저렴한 옵션입니다 — 데이터를 메모리로 복사하는 과정을 피합니다. external 함수의 문자열에는 항상 memory 대신 calldata를 사용하세요. 무료로 얻는 최적화입니다.
왜 원시 타임스탬프 대신 _durationInDays인가? 사용성 때문입니다. 호출자에게 block.timestamp + (30 * 86400)을 계산하게 하는 것은 오류를 유발합니다. 컨트랙트가 계산을 합니다: block.timestamp + (_durationInDays * 1 days). 1 days 접미사는 Solidity의 문법 설탕(Syntactic Sugar)으로 — 컴파일러가 86400초로 변환합니다.
왜 getCampaign이 구조체 대신 개별 필드를 반환하는가? Solidity 0.8.x에서 memory를 사용하면 외부 함수에서 구조체를 반환할 수 있지만, 명명된 개별 필드를 반환하면 프론트엔드 통합을 위한 ABI가 더 명확해집니다. 두 접근 방식 모두 작동합니다 — 저는 프론트엔드가 받는 것이 모호하지 않도록 명시적 반환을 선호합니다.
단계별: Remix에서 테스트하기
-
Remix 열기 → 새 파일
FundChain.sol생성 → 위의 전체 컨트랙트 붙여넣기 -
컴파일 → Solidity 0.8.26 선택 (또는 0.8.x 버전)
-
배포 → Remix VM (Cancun) 사용
-
초기 상태 확인:
getCampaignCount클릭 →0반환owner클릭 → 배포자 주소 표시
-
캠페인 #1 생성:
createCampaign에 입력:_title:"Build a School"_description:"Community school in rural area"_goalAmount:5000000000000000000(wei 단위 5 ETH)_durationInDays:30
- transact 클릭
-
캠페인 #2 생성:
_title:"Clean Ocean Project"_description:"Remove plastic from coastline"_goalAmount:10000000000000000000(wei 단위 10 ETH)_durationInDays:60- transact 클릭
-
검증:
getCampaignCount()→2getCampaign(1)→ "Build a School"의 모든 7개 필드 반환getCampaign(2)→ "Clean Ocean Project"의 모든 7개 필드 반환getCampaign(99)→ 모두 0/빈 문자열 반환 (에러 없음!)
getCampaign(1) 예상 출력:
creator: 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4
title: "Build a School"
description: "Community school in rural area"
goalAmount: 5000000000000000000
currentAmount: 0
deadline: (미래 타임스탬프, 예: 1745000000)
isCompleted: false
💡 막히셨나요? 자주 발생하는 Remix 오류
- "Invalid type for argument": 문자열이 큰따옴표
"이렇게"안에 있는지 확인하세요. 숫자에는 따옴표를 사용하지 마세요. - "Gas estimation failed": 보통 잘못된 매개변수 타입을 전달했다는 뜻입니다.
_goalAmount는 문자열"5 ether"가 아닌 순수 숫자여야 합니다. - Transaction reverted: 이 단계에서는 함수에
require체크가 없으므로 리버트가 발생해서는 안 됩니다. 발생한다면 Solidity 버전을 확인하세요. - getCampaign이 모두 0 반환: 존재하지 않는 ID를 조회했을 가능성이 높습니다.
0이 아닌1이나2를 시도해보세요.
🔨 프로젝트 업데이트
레슨 2와 3의 모든 내용이 합쳐진 누적 코드입니다. Remix에 복붙하면 바로 작동하는 캠페인 레지스트리가 됩니다:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
/// @title FundChain — Decentralized Crowdfunding Platform
/// @notice Lessons 2-3: Contract skeleton + Campaign registry
contract FundChain {
// ═══════════════════════════════════════════
// STATE — Lesson 2
// ═══════════════════════════════════════════
address public owner;
uint256 public campaignCount;
// ═══════════════════════════════════════════
// DATA STRUCTURES — Lesson 3 (NEW)
// ═══════════════════════════════════════════
struct Campaign {
address creator;
string title;
string description;
uint256 goalAmount;
uint256 currentAmount;
uint256 deadline;
bool isCompleted;
}
mapping(uint256 => Campaign) public campaigns;
// ═══════════════════════════════════════════
// CONSTRUCTOR — Lesson 2
// ═══════════════════════════════════════════
constructor() {
owner = msg.sender;
}
// ═══════════════════════════════════════════
// READ FUNCTIONS — Lesson 2
// ═══════════════════════════════════════════
function getCampaignCount() public view returns (uint256) {
return campaignCount;
}
// ═══════════════════════════════════════════
// CAMPAIGN MANAGEMENT — Lesson 3 (NEW)
// ═══════════════════════════════════════════
function createCampaign(
string calldata _title,
string calldata _description,
uint256 _goalAmount,
uint256 _durationInDays
) external {
campaignCount++;
campaigns[campaignCount] = Campaign({
creator: msg.sender,
title: _title,
description: _description,
goalAmount: _goalAmount,
currentAmount: 0,
deadline: block.timestamp + (_durationInDays * 1 days),
isCompleted: false
});
}
function getCampaign(uint256 _id) external view returns (
address creator,
string memory title,
string memory description,
uint256 goalAmount,
uint256 currentAmount,
uint256 deadline,
bool isCompleted
) {
Campaign memory c = campaigns[_id];
return (
c.creator,
c.title,
c.description,
c.goalAmount,
c.currentAmount,
c.deadline,
c.isCompleted
);
}
}
지금까지 구축한 프로젝트 실행하기:
- Remix VM에 배포
getCampaignCount()→0createCampaign("Build a School", "Community project", 5000000000000000000, 30)→ 트랜잭션 성공getCampaignCount()→1getCampaign(1)→ creator 주소와 함께 7개 필드 전체 반환- 두 번째 캠페인 생성 후
getCampaignCount()→2확인
예상 결과: 각자 고유한 ID로 조회 가능한 두 개의 캠페인이 온체인에 저장됩니다. 레슨 1에서 배운 EVM의 영구 계층인 스토리지에 있기 때문에 호출 사이에도 데이터가 지속됩니다.
복습: 자가 점검
다음 항목을 넘어가기 전에 확인하세요:
-
Campaign구조체에 정확히 7개의 필드가 올바른 타입으로 있음 -
campaignCount가 매핑 키로 사용되기 전에 증가함 (ID가 1부터 시작) -
createCampaign의 문자열 매개변수에calldata사용 -
getCampaign의 로컬 Campaign 복사본에memory사용 - 존재하지 않는 ID 조회 시 기본값 반환 (에러 아님)
- 매핑을 순회할 수 없는 이유 이해
자주 발생하는 실수들:
- 카운터 증가를 잊음 — 캠페인이 모두 ID 0을 덮어씀
- 외부 함수 문자열 매개변수에
calldata대신memory사용 — 작동은 하지만 가스 낭비 - 뷰 함수에서
Campaign storage반환 시도 — 외부 호출에서 컴파일 불가 campaigns[0]이 첫 번째 캠페인이라고 가정 — 아닙니다, 기본 빈 Campaign입니다
다음 단계: 시니어들은 이렇게 합니다
가스 절약을 위한 패킹(Packed) 구조체. EVM의 스토리지 슬롯은 32바이트입니다. Solidity는 더 작은 타입들을 함께 패킹합니다. 더 작은 타입들이 인접하도록 구조체 필드를 재정렬하면 슬롯을 공유합니다:
// ❌ Naive ordering — wastes storage slots
struct BadLayout {
bool isCompleted; // 1 byte → slot 0 (31 bytes wasted)
uint256 goalAmount; // 32 bytes → slot 1
bool isActive; // 1 byte → slot 2 (31 bytes wasted)
uint256 deadline; // 32 bytes → slot 3
}
// Uses 4 slots
// ✅ Packed ordering — bools share a slot
struct GoodLayout {
uint256 goalAmount; // 32 bytes → slot 0
uint256 deadline; // 32 bytes → slot 1
bool isCompleted; // 1 byte → slot 2
bool isActive; // 1 byte → slot 2 (packed!)
}
// Uses 3 slots — saves ~20,000 gas on first write
현재 FundChain 컨트랙트에서는 문자열(title, description)이 순서와 관계없이 각자의 슬롯을 차지하기 때문에 bool과 uint를 패킹해도 큰 절약이 없습니다. 하지만 bool 플래그나 작은 정수 필드가 많은 컨트랙트를 구축할 때 구조체 패킹은 실질적인 가스 절약이 됩니다. 구조체 필드 순서를 바꿔 배포 비용을 15% 절약한 컨트랙트를 최적화한 경험이 있습니다.
상태 추적을 위한 열거형(Enum). 지금은 bool isCompleted만 있습니다 — 캠페인이 완료됐거나 아니거나. 하지만 실제 크라우드펀딩에는 더 많은 상태가 필요합니다: 활성(Active), 성공(Successful), 실패(Failed), 취소(Cancelled). 레슨 7에서 bool을 적절한 상태 머신으로 교체할 것입니다. 지금은 단순하게 유지하세요.
🔍 심화: 왜 매핑 + 카운터 대신 배열을 사용하지 않는가?
Campaign[] public campaigns를 사용할 수 있습니다 — Solidity 동적 배열은 .push()와 .length를 지원합니다. 일부 개발자가 이를 선호합니다. 제가 주요 데이터 저장소에 사용하지 않는 이유는 다음과 같습니다:
- 삭제 공백. 배열에서
delete campaigns[3]을 하면 인덱스 3에 0으로 채워진 요소가 생기지만 길이는 그대로입니다. 공백을 없애려면 이후의 모든 요소를 이동시켜야 합니다 — 극도로 비쌉니다. - 프론트엔드 결합도. 배열은 ABI에서 매핑과 다르게 데이터를 반환하며, 일부 프론트엔드 라이브러리는 한쪽을 더 잘 처리합니다.
- 일관성. 카운터 + 매핑 패턴은 캠페인, 사용자, 주문, 제안 등 무엇을 저장하든 동일하게 작동합니다. 한 번 배우면 어디서든 사용할 수 있습니다.
배열을 사용하는 한 가지 경우: 온체인 로직에서 모든 항목을 열거해야 할 때(예: 모든 스테이커에게 보상 배분). 그 경우에도 배열 크기를 제한합니다.
요약 다이어그램
레슨 4 예고: 뼈대(레슨 2)와 데이터 계층(레슨 3)을 구축했습니다. 그런데 캠페인이 아직 ETH를 받을 수 없습니다 — payable 함수도, msg.value 처리도 없습니다. 다음 레슨에서는 가스 경제학과 이더리움에서의 실제 자금 이동을 다룹니다. 어떤 트랜잭션은 $2이고 어떤 것은 $200인 이유를 배우고, ETH 기부를 받는 fundCampaign() 함수를 추가하게 됩니다. 오늘 구축한 데이터 구조가 자금이 흘러들어올 기반이 됩니다.
난이도 분기
🟢 쉬웠습니다
좋습니다. 이제 온체인 데이터의 세 가지 기둥을 알게 됐습니다: 형태를 위한 구조체, 조회를 위한 매핑, ID를 위한 카운터 패턴. 핵심 요점: 기본 데이터 구조는 배열이 아닌 매핑입니다. 레슨 4에서는 payable과 msg.value로 자금을 추가합니다.
간단한 연습: 캠페인 생성자만 제목을 변경할 수 있는 updateTitle() 함수를 추가해보세요. campaigns[_id].creator == msg.sender 체크가 필요합니다 — 레슨 6에서 require로 공식화하겠지만, 지금은 간단한 if 문으로 시도해보세요.
🟡 어려웠습니다
그건 정상입니다. storage/memory 구분은 이 레슨에서 가장 어려운 개념이고 — Solidity 전체에서도 가장 까다로운 부분 중 하나입니다.
이렇게 생각해보세요:
- Storage = 하드 드라이브. 컴퓨터를 껐다 켜도 데이터가 남습니다. 쓰기 속도가 느리고 비쌉니다.
- Memory = RAM. 빠르고 저렴하지만 프로그램이 닫히면 모든 것이 사라집니다.
- Calldata = 누군가 꽂아놓은 읽기 전용 USB 드라이브. 읽을 수는 있지만 내용을 변경할 수 없습니다.
위의 StorageRef 컨트랙트로 돌아가서 Remix에서 직접 해보세요. dangerousModify()를 호출하고 상태가 변하는 것을 확인해보세요. 그런 다음 memory 복사본으로 같은 실험을 해보고 상태가 변하지 않음을 확인하세요. 그 직접적인 실험이 개념을 확실히 이해하게 해줄 것입니다.
🔴 도전: 인터뷰 수준 문제
문제: mapping(address => Profile)에 사용자 프로필을 저장하는 컨트랙트가 있습니다. 어떤 함수가 프로필을 읽고, 조건을 확인하고, 조건부로 한 필드를 업데이트합니다. 어느 것이 가스 효율이 더 높을까요?
// Option A
function updateA(address user) external {
Profile memory p = profiles[user];
if (p.score > 100) {
profiles[user].score = 0; // write to storage directly
}
}
// Option B
function updateB(address user) external {
Profile storage p = profiles[user];
if (p.score > 100) {
p.score = 0; // modify via storage reference
}
}
정답: Option B가 더 가스 효율적입니다. Option A는 전체 Profile 구조체를 스토리지에서 메모리로 복사합니다(모든 필드 읽기에 가스 소비), 그 다음 다시 스토리지에 씁니다. Option B는 스토리지 참조(본질적으로 포인터)를 생성합니다 — p.score를 읽는 것은 그 하나의 슬롯만 로드하고, p.score = 0 쓰기는 전체 구조체를 복사하지 않고 그 단일 스토리지 슬롯을 직접 수정합니다. 큰 구조체의 한 필드만 읽거나 써야 할 때 스토리지 참조는 사용하지 않는 필드 복사 오버헤드를 피해줍니다.
프로덕션에서 이것은 중요합니다. 10개 필드를 가진 구조체를 메모리로 복사하면 스토리지 읽기가 10번 발생합니다. 스토리지 참조로 1개 필드를 읽으면 스토리지 읽기가 1번 발생합니다.