개념/배경
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를 함께 사용하면 방어 심도가 올라감
한 줄 요약
검증 → 상태 변경 → 외부 호출 순서를 지키면 재진입 공격을 구조적으로 차단할 수 있음