스마트 컨트랙트 보안: 재진입 방어와 안전한 산술·가스 설계

스마트 컨트랙트의 재진입 공격, 오버플로우·언더플로우, 가스 최적화 패턴과 테스트·운영 점검 체계를 정리한다.

2026-08-14 · 최초 발행 2025-10-31

외부 호출이 상태를 다시 열어 두는 순간

스마트 컨트랙트 보안은 DeFi, NFT, DAO 같은 온체인 애플리케이션의 신뢰를 구성하는 기반이다. 특히 재진입 공격, 정수 오버플로우·언더플로우, 가스 비용은 구현 단계에서 함께 검토해야 하는 영역이다.

재진입 공격은 외부 호출로 제어권이 다른 컨트랙트에 넘어간 뒤, 원래 컨트랙트의 동일하거나 연관된 함수를 다시 호출해 상태 불일치를 만드는 취약성이다. 잔액을 갱신하기 전에 출금을 수행하면 중복 출금 같은 문제가 생길 수 있다. Checks-Effects-Interactions를 지키지 않고 상태 변경보다 외부 호출을 앞세우는 구조가 핵심 위험이다.

오버플로우와 언더플로우는 정수 값이 허용 범위를 넘거나 밑돌 때 래핑(wrap-around)되는 문제를 말한다. Solidity 0.8.x는 산술 오버플로우에 기본적으로 자동 revert를 적용한다. 다만 안전성과 가스 비용 사이에는 여전히 트레이드오프가 있으므로, unchecked는 범위가 검증된 구간에서만 사용해야 한다.

가스 최적화는 같은 기능을 더 적은 가스로 실행하도록 설계와 코드, 스토리지 접근을 다듬는 작업이다. EIP-2929/3529/1559 등의 네트워크 업그레이드에 따라 가스 비용 모델은 달라질 수 있으므로 최신 정보를 확인할 필요가 있다.

EVM 비용 모델과 방어 수단을 함께 본다

call이나 delegatecall처럼 외부로 제어권을 넘기는 호출은 재진입 표면을 넓힌다. CEI(Checks-Effects-Interactions)를 적용하고 필요하면 재진입 락을 더해야 한다.

가스 관점에서는 SLOAD와 SSTORE 비용이 큰 비중을 차지한다. 스토리지 접근 횟수를 줄이는 일이 비용 절감의 출발점이다.

Solidity 0.8.x에서는 산술 검사가 기본 활성화되어 오버플로우나 언더플로우가 발생하면 revert된다. 이전 버전에서는 SafeMath 라이브러리에 의존했다. receivefallback, try/catch, custom error, immutable, constant 같은 언어 기능은 안전성과 비용을 함께 다루는 데 활용할 수 있다.

방어는 한 가지 장치로 끝나지 않는다. ReentrancyGuard, CEI, Pull-Payment(Withdraw 패턴), Access Control(onlyOwner/role)을 조합하고, 상태 잠금과 상태 선갱신을 통해 재진입을 차단한다. 외부 호출 자체도 가능한 한 줄여야 한다.

검증 도구 역시 개발 흐름에 포함된다. 정적 분석에는 Slither와 Mythril, 퍼징·프로퍼티 테스트에는 Echidna와 Foundry(Forge/Cast), 포멀 검증에는 Certora와 Scribble을 사용할 수 있다. 가스 분석에는 forge test --gas-report, hardhat-gas-reporter가 있으며 도구 생태계는 변동될 수 있으므로 최신 정보를 확인해야 한다.

배포 전후에는 위협 모델링, 감사, 시뮬레이션·퍼징, 버그바운티, 온체인 모니터링을 연결한다. 이벤트 기반 알림, 델타 가스 스냅샷 비교, 업그레이어블 프록시 안전 가이드 준수도 운영 체계에 포함된다.

재진입이 발생하는 호출 순서

AttackerVulnerableUserAttackerVulnerableUserbalances[A] = Xopt[외부 호출 실패]alt[완화(C/E/I +ReentrancyGuard)]withdraw()call{value:X} (fallback)withdraw() 재진입중복 출금balances[A] = 0 (지연 갱신, 실패)withdraw()effects 먼저: balances[A]=0call{value:X} (nonReentrant)revert (상태 원복)

출금 함수에서는 상태를 먼저 확정한다

출금 경로는 상태 검증(Checks), 상태 선갱신(Effects), 외부 호출(Interactions)의 순서를 강제한다. 비즈니스 크리티컬 함수에는 nonReentrant를 적용하고, 외부 호출의 반환값을 확인해 실패하면 revert해야 한다.

환경은 Solidity ^0.8.20, OpenZeppelin ReentrancyGuard ^4.9를 기준으로 한다.

// Vulnerable
pragma solidity ^0.8.20;
contract VaultBad {
    mapping(address => uint256) public bal;
    function deposit() external payable { bal[msg.sender] += msg.value; }
    function withdraw() external {
        uint256 amt = bal[msg.sender];
        require(amt > 0, "no bal");
        // 위험: 외부 호출 먼저
        (bool ok,) = msg.sender.call{value: amt}("");
        require(ok, "send fail");
        bal[msg.sender] = 0; // 늦은 갱신
    }
}

// Mitigated
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract VaultSafe is ReentrancyGuard {
    mapping(address => uint256) public bal;
    function deposit() external payable { bal[msg.sender] += msg.value; }

    function withdraw() external nonReentrant {
        uint256 amt = bal[msg.sender];
        require(amt > 0, "no bal");
        // Effects first
        bal[msg.sender] = 0;
        // Interactions
        (bool ok,) = msg.sender.call{value: amt}("");
        require(ok, "send fail");
    }
}

Pull-Payment에서는 사용자가 명시적으로 인출을 요청하며, 컨트랙트가 외부로 즉시 송금하지 않는다. 외부 호출 대상을 제한하거나 화이트리스트를 적용하는 방식도 재진입 표면을 줄이는 데 쓸 수 있다.

기본 산술 검사와 unchecked의 경계

Solidity 0.8.x의 기본 산술 검사를 우선 사용한다. unchecked는 범위가 확정적으로 안전한 루프나 누산 구간에만 적용한다. 타입 캐스팅에는 OpenZeppelin의 SafeCast를 사용한다.

pragma solidity ^0.8.20;
import "@openzeppelin/contracts/utils/math/SafeCast.sol";

contract MathSafe {
    using SafeCast for uint256;
    uint256 public total;

    // 기본: 오버플로우 시 자동 revert
    function add(uint256 x) external { total += x; }

    // 가스 최적화: 상한 보장 시 unchecked
    function addBounded(uint256 x, uint256 limit) external {
        require(total <= limit - x, "overflow");
        unchecked { total += x; } // 안전 영역에서만
    }

    function toU128(uint256 x) external pure returns (uint128) {
        return x.toUint128(); // 범위 검사 포함
    }
}

스토리지 접근부터 줄이는 가스 설계

가스 최적화에서는 SSTORE와 SLOAD를 최소화하고, 구조체 패킹·캐싱·단일 쓰기를 검토한다. calldata, immutable, constant, custom error를 활용할 수 있으며, 루프 언롤링은 최소화한다. 이벤트 인덱싱 최적화와 라이브러리 인라인도 고려 대상이다.

pragma solidity ^0.8.20;
error NotAuthorized();

contract GasPatterns {
    // storage packing: 256비트 내 필드 배치
    struct Packed {
        uint128 a;
        uint64  b;
        uint64  c;
    }
    Packed private p;

    address public immutable admin;
    uint256 public constant FEE_BPS = 30; // 0.3%

    constructor(address _admin) { admin = _admin; }

    function setPacked(uint128 a, uint64 b, uint64 c) external {
        if (msg.sender != admin) revert NotAuthorized();
        // 메모리 캐싱 후 1회 SSTORE
        Packed memory tmp = Packed(a, b, c);
        p = tmp;
    }

    // 외부 함수 파라미터: calldata 사용
    function sum(uint256[] calldata xs) external pure returns (uint256 s) {
        uint256 len = xs.length;
        for (uint256 i; i < len; ) {
            s += xs[i];
            unchecked { ++i; } // 경계 체크 사전 검증 시 사용
        }
    }
}

forge test --gas-report를 자동화하고 PR마다 가스 스냅샷을 비교할 수 있다. EIP 변경에 따른 가스 스케줄 변동 가능성을 감안해, 고정 가스 보장 메서드인 transfersend에 의존하지 않고 call과 정교한 에러 처리를 사용하는 방식을 권장한다. 최신 정보 확인도 필요하다.

방어 패턴을 고를 때의 트레이드오프

패턴 안전성 성능(가스) 복잡도 운영 편의 비고
Checks-Effects-Interactions 높음 매우 좋음 낮음 높음 기본 원칙, 광범위 적용
ReentrancyGuard(뮤텍스) 높음 좋음 낮음 높음 중첩 호출 제한, 단일 함수 보호 용이
Pull-Payment(Withdraw) 매우 높음 보통 보통 보통 UX 영향 가능, 대량 지급에 유리
Access Control 제한 보조 우수 낮음 높음 공격 표면 축소, 단독으론 불충분
외부 호출 제거/지연 매우 높음 보통 높음 보통 아키텍처/UX 트레이드오프

패턴과 테스트를 준수하면 재진입·오버플로우 클래스 취약점 제거율 90%+를 기대할 수 있다. 스토리지 접근 최적화, 제한적인 unchecked 사용, calldata 적용을 기준으로 가스 비용은 1030% 절감 가능하다. 배포 전 자동화 점검을 도입하면 리뷰·감사 비용도 1525% 절감할 수 있다.

CEI, 락, Pull-Payment 같은 코드 규율은 설계 품질을 높인다. 장애 격리와 롤백의 용이성도 개선되어 운영 신뢰도에 영향을 준다.

표준 개발 플레이북은 위협 모델링, 보안 패턴 템플릿화, 테스트·퍼징 자동화, 가스 리포트 파이프라인, 온체인 모니터링으로 이어진다. 네트워크와 EIP 변경을 주기적으로 점검하며 가스·보안 가정을 재검증해야 한다. 최신 정보 확인도 필요하다.

스마트 컨트랙트재진입 공격Solidity가스 최적화블록체인 보안