March 15, 2026

Solidity; (Round1) Simple vault

들어가며

Round 1의 과제로 SimpleVault 문제를 해결했다. DeFi 프로토콜의 기본은 자산을 안전하게 보관하고 관리하는 금고를 만드는데 있다.

과제에서는 복잡한 토큰 로직 대신 Native ETH를 관리하는 금고를 설계하며 Solidity의 기본 문법을 다룬다. 특히, 가시성(Visibility), 기초적인 Foundry 테스트 방식, 그리고 이더 전송 방식의 차이를 배울 수 있었다.

가시성(Visibility)

솔리디티에서 가시성(Visibility)이란 상태 변수와 함수에 대한 접근 범위를 제어하는 키워드를 의미하는데, 주요 키워드에는 public, external, private, internal이 있다.

💡 기본값은 함수는 public, 변수는 internal 이다.

접근 범위는 아래 표와 같다.

키워드 컨트랙트 내부 상속 컨트랙트 외부 컨트랙트/트랜잭션
public 가능 가능 가능
external 불가 (함수는 this. 필요) 불가 가능 ​
internal 가능 가능 불가
private 가능 불가 ​ 불가

실무에서 external은 사용자 호출 함수에, internal/private은 헬퍼 함수에 사용하는 편이다. 이는 가스 비용 최적화와 보안 캡슐화에 관련있기 때문이다.

external은 외부 호출에 특화되어 calldata(저비용 읽기 전용)를 직접 사용해 메모리 복사 비용을 줄일 수 있고, internal/private은 내부 로직을 숨겨 불필요한 노출을 방지한다.

🧐 public 함수는 내부 호출 시 memory로 복사되어 추가 가스(수백 단위)가 발생하므로, 사용자 트랜잭션에 external을 써서 가스를 절약(5~20% 수준)하는게 좋다.

internal/private 헬퍼 함수(_validate, _transfer 등)는 컨트랙트 내부 로직만 처리하며 외부 노출을 막아 재진입 공격 리스크를 줄인다. public/external은 인터페이스 역할로 유지하고, 내부 호출은 internal 헬퍼를 호출하는 패턴이 일반적이다.

plaintext
// 사용자 호출 (external: 가스 최적화)
function mint(address to, uint256 amount) external {
    _mint(to, amount);  // internal 헬퍼 호출
}

// 헬퍼 (internal: 보안/재사용)
function _mint(address to, uint256 amount) internal {
    // 내부 로직만
}

totalBalance 같은 상태 변수는 가시성을 어떻게 설정하는게 좋을까?

보통 Vault 컨트랙트에서 사용자 잔액 상태를 나타내는 totalBalancepublic으로 설정하는 게 표준이라 한다. [1] 이유는 자동으로 getter가 생성되어 누구나 잔액을 읽을 수 있게 하면서, 직접 수정은 함수로만 제어해 보안을 유지하기 때문이다.

plaintext
uint256 public totalBalance;  // 자동 getter: totalBalance()

function transfer(address to, uint256 amount) external {
    require(balances[msg.sender] >= amount, "Insufficient balance");
    balances[msg.sender] -= amount;
    balances[to] += amount;
    totalBalance -= amount;
}

getter 함수는 SLOAD opcode 하나(2100 gas)로 스토리지 값을 로드하며, 상태 변경이 없어 트랜잭션 가스 지불 없이 staticcall로 처리된다.

vm.prank의 컨텍스트를 유지하는 방법

vm.prank는 단 한 번의 다음 호출에만 적용된다.

plaintext
function testDeposit() public {
        address user = makeAddr("user");
        vm.deal(user, 10 ether);

        vm.prank(user);
        sut.deposit{ value: 10 ether }();
        vm.assertEq(user.balance, 0 ether);

        uint256 totalBalance = sut.totalBalance();
        vm.assertEq(totalBalance, 10 ether);

        uint256 myBalance = sut.getMyBalance();
        vm.assertEq(myBalance, 10 ether);
    }

이런 코드가 있다면 user에 대한 컨텍스트는 sut.deposit까지만 유지된다. 따라서 vm.startPrank를 사용해 테스트 계정의 컨텍스트를 유지해야 한다.

plaintext
function testDeposit() public {
        address user = makeAddr("user");
        vm.deal(user, 10 ether);

        vm.startPrank(user);
        sut.deposit{ value: 10 ether }();
        vm.assertEq(user.balance, 0 ether);

        uint256 totalBalance = sut.totalBalance();
        vm.assertEq(totalBalance, 10 ether);

        uint256 myBalance = sut.getMyBalance();
        vm.assertEq(myBalance, 10 ether);

        vm.stopPrank();
    }

이더 전송 방식

이더 전송 방식에는 transfer, send, 그리고 call이 있다. [2]

방식 가스 전달 에러 처리 재진입 위험 안전성 평가
transfer 2300 고정 자동 revert 낮음 높음 (간단하지만 제한적)
send 2300 고정 bool 반환 (수동 체크) 낮음 중간 (체크 누락 위험)
call 전체 bool 반환 (수동 체크) 높음 최고 (올바른 구현 시)

표에서 확인할 수 있듯이 transfersend는 2300 gas 제한이 장점이다 단점이다. 복잡한 로직에서 가스 초과로 인해 revert가 발생할 수 있지만, 복잡한 로직(상태 변경과 반복 호출 등)이 불가능해 자연스럽게 재진입이 차단된다.

call은 전체 가스 전달로 인해 수신자가 충분한 가스로 로직 실행을 가능하게 하며, 가스량, 반환값 체크로 실패 상황을 명시적으로 처리할 수 있단 장점이 있다. 재진입 위험 같은 경우에는 아래 예시처럼 CEI 패턴을 적용해 충분히 방어할 수 있다.

plaintext
function withdraw(uint256 amount) external {
    // 1. Checks (상태 검증)
    require(balances[msg.sender] >= amount, "Insufficient balance");
    
    // 2. Effects (상태 변경)
    balances[msg.sender] -= amount;
    
    // 3. Interactions (ETH 전송 - 마지막 위치)
    (bool success, ) = msg.sender.call{value: amount}("");
    require(success, "Transfer failed");  // 실패 시 revert
}

따라서 재인입 위험이 높음에도 불구하고 call을 쓰는 이유는 CEI 패턴과 Reentrancy Guard를 올바르게 구현시 안정성이 가장 좋기 때문이다.

재진입 공격은 무엇이며 CEI가 이를 어떻게 방어하는가?

재진입 공격이란 외부 컨트랙트에서 함수를 재귀적으로 호출해 잔고를 여러 번 빼가는 공격 기법이고, CEI 패턴은 이 순서를 뒤집어서 차단하는 방어법이다.

재진입 공격은 보통 아래와 같은 순서로 진행된다.

  1. msg.sender.call{ value: amount }("") 처럼 외부 컨트랙트에 ETH를 보낼 때, 제어권이 상태 컨트랙트의 receive/fallback으로 넘어간다.
  2. 이때 상대 컨트랙트가 다시 피해 컨트랙트의 withdraw() 같은 함수를 호출하면, 잔고가 아직 차감되지 않았지만 같은 금액을 반복해서 인출할 수 있다.
  3. balances[msg.sender] = 0 업데이트가 ETH 전송보다 나중에 일어나면, 그 사이에 수십 번 재진입이 가능해 진다.

이해를 위해 예시 코드를 보자.

plaintext
function withdraw() external { 
	uint amount = balances[msg.sender];
	require(amount > 0);
	
	// 외부 호출 먼저
	(bool success, ) = msg.sender.call{ value: amount }("");
	require(success);
	
	// 상태 업데이트 나중
	balances[msg.sender] = 0;
}

위 코드처럼 CEI 순서가 아닌 경우에, 외부 호출 진행되면 바로 withdraw()를 반복 호출해서 자금을 탈취하는 형식이다.

따라서 CEI 처럼 Checks(검사) -> Effects(상태 반영) -> Interactions(외부 상호작용) 순서를 함수에서 강제하는 코딩 원칙을 적용하는 것이 좋다.

실무에서는 CEI를 기본으로 적용하고, 재진입 락으로 함수 실행 중 재호출을 차단하거나 외부 호출을 최소화하는 것이 베스트 프랙티스이다. [3]


References


  1. 솔리디티 가시성 딥다이브 ↩︎

  2. 이더 전송 방식 ↩︎

  3. 재진입 공격 방어 ↩︎