온체인 데이터 설계: 구조체, 매핑, 그리고 캠페인 레지스트리 구축

3강2125,057

학습 목표

  • 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개월 후에도 읽기 쉬운 컨트랙트는 전부 이 패턴을 따릅니다. 그렇지 않은 것들은? isCompletedtitlegoalAmount 사이에 끼어 있는 걸 봤습니다. 그걸 디버깅하는 건 정말 고역입니다.

🤔 생각해보세요: 레슨 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 값을 실수로 바꿔서 넣게 될 것입니다. 타입이 맞아서 컴파일러가 잡아주지도 않습니다. 저도 그런 경험을 했습니다. goalAmountdeadline이 뒤바뀐 채로 컨트랙트가 배포됐었죠. 필드명 방식은 그런 종류의 버그 자체를 없애줍니다.


매핑(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의 모든 토큰 보유자를 어떻게 보여줄까요?

답변 보기

두 가지 접근 방식이 있습니다:

  1. 이벤트(Events, 레슨 6): 컨트랙트는 상태가 변경될 때 이벤트를 발행합니다. 오프체인 인덱서(The Graph 또는 Etherscan 같은)가 이 이벤트를 듣고 쿼리 가능한 데이터베이스를 구축합니다. 블록체인이 진실의 원천이고, 인덱서가 검색 가능하게 만들어줍니다.
  2. 카운터 패턴 + 순차 읽기: 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 가스가 드는가?

모든 스토리지 슬롯 수정은 다음 과정을 거쳐야 합니다:

  1. 실행 노드에 의해 처리
  2. 트랜잭션 영수증에 포함
  3. 전 세계 모든 이더리움 노드에 복제 — 수만 개에 달합니다
  4. 영구적으로 저장 (덮어쓰기 전까지)

단일 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_descriptioncalldata를 사용하는가? 함수가 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에서 테스트하기

  1. Remix 열기 → 새 파일 FundChain.sol 생성 → 위의 전체 컨트랙트 붙여넣기

  2. 컴파일 → Solidity 0.8.26 선택 (또는 0.8.x 버전)

  3. 배포 → Remix VM (Cancun) 사용

  4. 초기 상태 확인:

    • getCampaignCount 클릭 → 0 반환
    • owner 클릭 → 배포자 주소 표시
  5. 캠페인 #1 생성:

    • createCampaign에 입력:
      • _title: "Build a School"
      • _description: "Community school in rural area"
      • _goalAmount: 5000000000000000000 (wei 단위 5 ETH)
      • _durationInDays: 30
    • transact 클릭
  6. 캠페인 #2 생성:

    • _title: "Clean Ocean Project"
    • _description: "Remove plastic from coastline"
    • _goalAmount: 10000000000000000000 (wei 단위 10 ETH)
    • _durationInDays: 60
    • transact 클릭
  7. 검증:

    • getCampaignCount()2
    • getCampaign(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
        );
    }
}

지금까지 구축한 프로젝트 실행하기:

  1. Remix VM에 배포
  2. getCampaignCount()0
  3. createCampaign("Build a School", "Community project", 5000000000000000000, 30) → 트랜잭션 성공
  4. getCampaignCount()1
  5. getCampaign(1) → creator 주소와 함께 7개 필드 전체 반환
  6. 두 번째 캠페인 생성 후 getCampaignCount()2 확인

예상 결과: 각자 고유한 ID로 조회 가능한 두 개의 캠페인이 온체인에 저장됩니다. 레슨 1에서 배운 EVM의 영구 계층인 스토리지에 있기 때문에 호출 사이에도 데이터가 지속됩니다.


복습: 자가 점검

다음 항목을 넘어가기 전에 확인하세요:

  • Campaign 구조체에 정확히 7개의 필드가 올바른 타입으로 있음
  • campaignCount가 매핑 키로 사용되기 전에 증가함 (ID가 1부터 시작)
  • createCampaign의 문자열 매개변수에 calldata 사용
  • getCampaign의 로컬 Campaign 복사본에 memory 사용
  • 존재하지 않는 ID 조회 시 기본값 반환 (에러 아님)
  • 매핑을 순회할 수 없는 이유 이해

자주 발생하는 실수들:

  1. 카운터 증가를 잊음 — 캠페인이 모두 ID 0을 덮어씀
  2. 외부 함수 문자열 매개변수에 calldata 대신 memory 사용 — 작동은 하지만 가스 낭비
  3. 뷰 함수에서 Campaign storage 반환 시도 — 외부 호출에서 컴파일 불가
  4. 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를 지원합니다. 일부 개발자가 이를 선호합니다. 제가 주요 데이터 저장소에 사용하지 않는 이유는 다음과 같습니다:

  1. 삭제 공백. 배열에서 delete campaigns[3]을 하면 인덱스 3에 0으로 채워진 요소가 생기지만 길이는 그대로입니다. 공백을 없애려면 이후의 모든 요소를 이동시켜야 합니다 — 극도로 비쌉니다.
  2. 프론트엔드 결합도. 배열은 ABI에서 매핑과 다르게 데이터를 반환하며, 일부 프론트엔드 라이브러리는 한쪽을 더 잘 처리합니다.
  3. 일관성. 카운터 + 매핑 패턴은 캠페인, 사용자, 주문, 제안 등 무엇을 저장하든 동일하게 작동합니다. 한 번 배우면 어디서든 사용할 수 있습니다.

배열을 사용하는 한 가지 경우: 온체인 로직에서 모든 항목을 열거해야 할 때(예: 모든 스테이커에게 보상 배분). 그 경우에도 배열 크기를 제한합니다.


요약 다이어그램

레슨 4 예고: 뼈대(레슨 2)와 데이터 계층(레슨 3)을 구축했습니다. 그런데 캠페인이 아직 ETH를 받을 수 없습니다 — payable 함수도, msg.value 처리도 없습니다. 다음 레슨에서는 가스 경제학이더리움에서의 실제 자금 이동을 다룹니다. 어떤 트랜잭션은 $2이고 어떤 것은 $200인 이유를 배우고, ETH 기부를 받는 fundCampaign() 함수를 추가하게 됩니다. 오늘 구축한 데이터 구조가 자금이 흘러들어올 기반이 됩니다.


난이도 분기

🟢 쉬웠습니다

좋습니다. 이제 온체인 데이터의 세 가지 기둥을 알게 됐습니다: 형태를 위한 구조체, 조회를 위한 매핑, ID를 위한 카운터 패턴. 핵심 요점: 기본 데이터 구조는 배열이 아닌 매핑입니다. 레슨 4에서는 payablemsg.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번 발생합니다.

질문 & 토론