개념/배경

CEI 패턴은 Checks-Effects-Interactions의 약자 스마트 컨트랙트 함수에서 실행 순서를 명확히 해 재진입 공격을 줄이는 코드 규칙 핵심은 검증 후 상태 변경을 먼저 완료하고, 외부 호출을 마지막에 수행하는 흐름 유지


3단계 순서

1. Checks (검증)
2. Effects (상태 변경)
3. Interactions (외부 호출)

이 순서를 지키면 외부로 제어권이 나가기 전에 내부 상태가 이미 안전하게 반영됨 재진입 시도는 변경된 상태에 의해 자연스럽게 차단됨


예시로 이해하기

잘못된 순서 위험

function withdraw(uint256 amount) external {
    // 1. Checks
    require(balances[msg.sender] >= amount);

    // 2. Interactions ← 너무 빠름
    (bool success, ) = msg.sender.call{value: amount}("");
    require(success);

    // 3. Effects ← 너무 늦음
    balances[msg.sender] -= amount;
}

문제점

  • 외부 호출로 제어권이 나간 뒤에야 잔액을 차감
  • 공격자가 receive나 fallback에서 동일 함수를 재호출 가능

공격 흐름

ETH 전송 → 공격자 재진입 → 잔액 아직 차감 안 됨 → 반복 출금

올바른 순서 안전

function withdraw(uint256 amount) external {
    // 1. Checks
    require(balances[msg.sender] >= amount, 'insufficient balance');

    // 2. Effects ← 먼저 반영
    balances[msg.sender] -= amount;

    // 3. Interactions ← 나중에 수행
    (bool success, ) = msg.sender.call{value: amount}("");
    require(success, 'transfer failed');
}

효과

  • 상태를 먼저 변경하므로 재진입 시도 시 조건 검증에서 차단됨

각 단계 설명

1. Checks 검증

조건 확인 단계

  • 입력값, 권한, 상태 전제조건 검증
  • 상태 읽기만 수행, 변경 없음
  • 불만족 시 즉시 revert
// 잔액 확인
require(balances[msg.sender] >= amount);

// 권한 확인
require(msg.sender == owner);

// 시간 확인
require(block.timestamp >= deadline);

// 주소 확인
require(to != address(0));

2. Effects 상태 변경

컨트랙트 내부 상태를 변경하는 단계

  • storage 갱신, 소유권 이전, 카운터 증가 등
  • 외부 호출 전에 완료해야 함
  • 재진입 발생 시 이미 변경된 상태에서 시작
balances[msg.sender] -= amount;
balances[recipient] += amount;
owners[tokenId] = newOwner;
status = Status.Completed;
totalSupply += 1;

3. Interactions 외부 호출

외부 컨트랙트나 주소와 상호작용하는 단계

  • 제어권이 외부로 넘어가며 재진입 위험 존재
  • 가장 마지막에 배치
// ETH 전송
(bool ok, ) = to.call{value: amount}("");
require(ok);

// 토큰 전송
require(token.transfer(to, amount));

// 외부 컨트랙트 호출
externalContract.someFunction();

// 이벤트는 상태 확정 후 발행
emit Withdrawn(msg.sender, amount);

왜 중요한가

잘못된 순서의 결과

function badWithdraw(uint256 amount) external {
    require(balances[msg.sender] >= amount);
    // Interactions 먼저
    msg.sender.call{value: amount}(""); // 재진입 가능
    // Effects 나중
    balances[msg.sender] -= amount;      // 차감이 늦음
}

공격 흐름

1. badWithdraw(100) 호출
2. require 통과, 잔액 100
3. ETH 100 전송 → 공격자 receive 실행
   └─ badWithdraw(100) 재호출
      └─ 잔액 여전히 100으로 인식 → 또 전송
4. 마지막에야 차감되어 방어 실패

올바른 순서의 결과

function goodWithdraw(uint256 amount) external {
    require(balances[msg.sender] >= amount);
    // Effects 먼저
    balances[msg.sender] -= amount;
    // Interactions 나중
    msg.sender.call{value: amount}("");
}

방어 흐름

1. goodWithdraw(100) 호출
2. require 통과, 잔액 100
3. 먼저 차감하여 0으로 반영
4. 이후 ETH 전송 중 재진입 시도
   └─ require에서 즉시 실패 및 revert

실전 예시

마켓플레이스 구매

function buy(uint256 itemId) external payable {
    // Checks
    require(listings[itemId].active, 'not for sale');
    require(msg.value == listings[itemId].price, 'incorrect price');

    // Effects
    address seller = listings[itemId].seller;
    listings[itemId].active = false;
    items[itemId].owner = msg.sender;

    // Interactions
    (bool ok, ) = seller.call{value: msg.value}("");
    require(ok, 'payment failed');

    emit Purchased(itemId, msg.sender, msg.value);
}

NFT 교환

function swap(uint256 tokenIdA, uint256 tokenIdB) external {
    // Checks
    require(nft.ownerOf(tokenIdA) == msg.sender, 'not owner');
    require(nft.ownerOf(tokenIdB) == partner, 'partner not owner');
    require(approvals[tokenIdA] && approvals[tokenIdB], 'not approved');

    // Effects
    swaps[swapId].completed = true;

    // Interactions
    nft.transferFrom(msg.sender, partner, tokenIdA);
    nft.transferFrom(partner, msg.sender, tokenIdB);

    emit Swapped(msg.sender, partner, tokenIdA, tokenIdB);
}

예외와 주의

require는 어디에 있어도 트랜잭션 전체가 revert되어 상태가 롤백됨

  • 외부 호출 뒤에 require(success)를 두어도 실패 시 안전하게 되돌림
  • 단, 읽기 전용 체크는 가능한 한 앞단에서 수행 권장

이벤트는 마지막에 발행

  • 상태가 확정된 후 기록해야 관찰 가능성과 진단 품질이 높아짐

베스트 프랙티스

CEI 패턴은 기본 수칙

  • Checks에서 입력과 권한, 컨트랙트 불변식 검증
  • Effects에서 필요한 상태 변경 모두 반영
  • Interactions에서만 외부 호출 수행

재진입 보호 장치 추가 권장

function secureFunction() external nonReentrant {
    // Checks
    require(conditions);
    
    // Effects
    updateState();

    // Interactions
    externalCall();
}

CEI 패턴과 ReentrancyGuard를 함께 사용하면 방어 심도가 올라감


한 줄 요약

검증 → 상태 변경 → 외부 호출 순서를 지키면 재진입 공격을 구조적으로 차단할 수 있음

참고자료