재진입(reentrancy)은 계약 A가 토큰 전송이나 ETH CALL로 외부 계약 B에 제어권을 넘긴 뒤, A가 중요한 상태를 갱신하기 전에 B가 A의 함수로 다시 들어와 같은 권리나 잔액을 한 번 더 쓰는 공격입니다. 방어의 핵심은 입력과 권한을 검사하고, 잔액 차감이나 청구 완료 같은 상태 변경을 먼저 기록한 다음, 마지막에 외부 호출을 하는 ‘검사-반영-호출(checks-effects-interactions)’ 순서입니다. ReentrancyGuard의 잠금 장치는 같은 보호 영역으로 다시 들어오는 호출을 막아 줍니다. 그러나 보호하지 않은 함수, 다른 계약을 거친 콜백, 읽기 전용 관찰, 잘못된 상태 설계까지 자동으로 고쳐 주지는 않습니다.
취약점은 외부 호출 전의 낡은 상태입니다
금고 계약이 balances[user]를 읽고 user.call{value:amount}를 실행한 뒤에 잔액을 0으로 만든다고 해 보겠습니다. 사용자가 계약이라면 receive 함수 안에서 Vault.withdraw를 다시 부를 수 있습니다. 첫 번째 호출이 아직 잔액을 0으로 쓰지 않았기 때문에 두 번째 호출도 같은 금액으로 조건을 통과해 지급이 반복됩니다.
ETH 전송만 위험한 것은 아닙니다. ERC-777 훅, NFT safeTransfer 콜백, 대출 프로토콜의 임의 토큰·오라클 호출도 제어권을 넘깁니다. 잘 알려진 계약을 호출해도 그 계약이 다시 알 수 없는 계약을 부를 수 있습니다. 외부 호출인지 아닌지는 함수 이름이 아니라 실제 CALL 계열 실행 추적으로 판단합니다.
재진입의 본질은 두 번 호출됐다는 사실보다, 두 번째 호출이 첫 번째 호출이 끝나기 전의 상태를 유효하다고 읽었다는 데 있습니다.
JOBCOIN 해설

그림은 이 주제의 공통 개념을 단순화한 설명입니다. 아래 항목에서 이 글의 구체적인 조건과 예외를 함께 읽어보세요.
- 취약점은 외부 호출 전의 낡은 상태입니다
금고 계약이 balances[user]를 읽고 user.call{value:amount}를 실행한 뒤에 잔액을 0으로 만든다고 해 보겠습니다.
- Checks-Effects-Interactions로 순서를 뒤집습니다
안전한 출금 함수는 먼저 금액과 권한을 검사하고 balances[user]=0을 저장한 다음 외부 전송을 시도합니다.
- Mutex는 범위를 정확히 설정해야 합니다
OpenZeppelin ReentrancyGuard는 진입 상태를 표시해 두었다가 nonReentrant 함수가 끝나면 되돌립니다.
설명을 위한 개념도 · 실제 가격·거래 내역·통계가 아닙니다.
Checks-Effects-Interactions로 순서를 뒤집습니다
안전한 출금 함수는 먼저 금액과 권한을 검사하고 balances[user]=0을 저장한 다음 외부 전송을 시도합니다. 콜백이 다시 들어와도 이미 0인 상태를 보게 되므로 지급 조건을 통과하지 못합니다. 외부 전송이 실패하면 require로 거래 전체를 되돌려, 먼저 기록한 상태 변경도 원래대로 돌릴 수 있습니다.
이 순서는 단순히 코드 줄의 순서가 아니라, 외부 호출 전에 불변 조건이 성립하는지를 보는 방법입니다. 총부채, 지분 발행량, 논스, 청구 표시처럼 함께 움직이는 값이 모두 갱신돼 있어야 합니다. 변수 하나만 먼저 바꾸고 다른 함수가 아직 갱신되지 않은 변수를 읽는다면 함수를 넘나드는 재진입 위험이 남습니다.
| 유형 | 재진입 경로 | 검토 항목 |
|---|---|---|
| 동일 함수 | withdraw→콜백→withdraw | 잔액 먼저 차감 |
| 교차 함수 | withdraw→콜백→transfer | 함께 쓰는 불변 조건 |
| 교차 계약 | A→B→C→A | 전체 호출 관계도 |
| 읽기 전용 | 콜백 도중의 조회 함수 호출 | 중간 가격·비율 노출 |
| 콜백 토큰 | 전송 훅에서 재호출 | 지원 토큰의 동작 방식 |
Mutex는 범위를 정확히 설정해야 합니다
OpenZeppelin ReentrancyGuard는 진입 상태를 표시해 두었다가 nonReentrant 함수가 끝나면 되돌립니다. 같은 잠금을 쓰는 보호 함수끼리는 서로 중첩 호출할 수 없으므로, 외부 진입 함수가 private 작업 함수를 부르는 구조가 흔합니다. 업그레이드형 환경이라면 선택한 구현의 초기 상태와 저장소 슬롯도 확인합니다.
출금 함수만 보호하고 같은 잔액 기록을 고치는 borrow·transfer를 열어 두면 콜백이 다른 진입점으로 들어올 수 있습니다. 여러 계약이 각자 잠금을 갖고 있어도 서로의 잠금은 공유하지 않습니다. 잠금이 있다는 사실보다, 어떤 불변 조건을 어느 진입점들이 함께 쓰는지를 표로 정리합니다.
- 외부 CALL·토큰 훅·콜백 지점을 모두 표시합니다.
- 지점마다 직전에 끝나지 않은 상태 변경을 찾습니다.
- 같은 불변 조건을 쓰는 모든 공개 진입점을 묶습니다.
- 호출 순서 규칙과 잠금을 각각 따로 시험합니다.
- 악성 수신 계약으로 중첩 콜백을 재현합니다.
Pull payment와 실패 처리를 함께 설계합니다
거래 하나에서 여러 사용자에게 ETH를 밀어 보내면, 수신자 한 명의 실패가 정산 전체를 막거나 콜백이 들어올 틈을 넓힙니다. 사용자별 지급액을 먼저 기록하고 각자 따로 출금하게 하는 ‘인출형 지급(pull payment)’ 방식은 외부 호출을 작은 함수 하나로 모읍니다. 그래도 출금 함수 자체에는 호출 순서 규칙과 실패 반환값 검사가 필요합니다.
저수준 호출이 false를 돌려줬는데 무시하면 잔액만 깎이고 지급은 실패할 수 있습니다. 반대로 실패했을 때 지급액을 다시 더해 주는 보상 로직이 콜백과 얽히면 지급액이 중복될 수 있습니다. 성공 조건과 되돌림 경계를 상태 기계로 보고 시험합니다.
Gas 제한을 방어선으로 의존하지 않습니다
transfer의 2,300 가스 지급량이 콜백을 영원히 막아 준다는 가정은 옵코드 가스 비용 변경과 수신 계약의 동작 때문에 튼튼하지 않습니다. Solidity 문서도 Ether 전송에는 코드 실행이 포함되며, call은 더 많은 가스를 넘겨 재진입 가능성을 연다고 설명합니다. 가스 제한 대신 상태 갱신 순서와 명시적인 잠금을 씁니다.
시험에는 동일 함수, 교차 함수, 여러 프로토콜을 거치는 콜백, 실패와 성공을 번갈아 내는 수신 계약을 넣습니다. 실행 추적에서 호출 깊이만 세지 말고, 각 호출 단계가 읽고 쓴 슬롯과 외부 자산 이동을 맞춰 봐야 실제로 불변 조건이 깨졌는지 확인할 수 있습니다.
자주 묻는 질문
nonReentrant modifier 하나면 모든 재진입이 막히나요?
그 잠금을 함께 쓰는 함수로의 진입만 막습니다. 보호 밖의 함수, 다른 계약의 상태, 읽기 전용 재진입은 따로 검토해야 합니다.
View 함수도 재진입 위험이 있나요?
상태를 쓰지 않더라도, 거래 도중의 불완전한 가격·비율을 외부 프로토콜이 읽어 판단에 쓰면 읽기 전용 재진입 문제가 생길 수 있습니다.
ETH를 transfer로 보내면 안전한가요?
가스 지급량을 영구적인 방어선으로 믿으면 안 됩니다. 호출 순서 규칙, 인출형 지급, 반환값 검사를 적용해야 합니다.
더 깊이 읽기
본문에서 다룬 개념과 확인 절차를 다음 글에서 이어서 살펴보세요.



