재진입 공격(Reentrancy) 방지를 위한 솔리디티 코드 보안 최적화

재진입 공격(Reentrancy) 방지를 위한 솔리디티 코드 보안 최적화 관련 이미지
안녕하세요, 10년 차 생활 블로거 김창수입니다. 오늘은 제가 최근에 블록체인 공부를 하다가 정말 크게 데일 뻔했던 이야기를 들려드리려고 해요. 스마트 컨트랙트 개발을 하시는 분들이라면 한 번쯤은 들어보셨을 재진입 공격(Reentrancy)에 대한 이야기인데요, 이게 말로만 들을 때는 쉬워 보이지만 실제로 코드를 짤 때는 놓치기 십상이더라고요.
제가 예전에 간단한 출금 기능이 있는 컨트랙트를 만들었을 때의 일이에요. 테스트 환경에서 돌려보는데, 분명히 잔액보다 더 많은 금액이 인출되는 현상을 발견했거든요. 알고 보니 전형적인 재진입 취약점이 있는 코드였던 거죠. 이 경험을 바탕으로 여러분은 저와 같은 실수를 하지 않도록 보안 최적화 방법을 꼼꼼하게 공유해 드릴게요.
솔리디티는 자바스크립트나 파이썬과는 또 다른 철학을 가지고 접근해야 하는 언어인 것 같아요. 특히 이더리움을 주고받는 로직에서는 순서 하나만 바뀌어도 자산이 통째로 날아갈 수 있는 위험이 있더라고요. 오늘 포스팅에서는 재진입 공격이 정확히 무엇인지부터 시작해서, 이를 막기 위한 실무적인 패턴들을 하나씩 나누어 보려 합니다.
목차
재진입 공격의 원리와 위험성
재진입 공격은 스마트 컨트랙트가 외부 컨트랙트와 상호작용할 때 발생하는 가장 대표적인 취약점이에요. 쉽게 말해서, 돈을 돌려주는 함수가 실행되는 도중에 공격자가 다시 그 함수를 호출해서 돈을 또 빼가는 방식이라고 이해하시면 편할 것 같아요. 은행원이 돈을 세고 있는 사이에 뒷문으로 다시 들어와서 "아직 돈 안 받았어요"라고 말하는 셈이죠.
이런 상황이 가능한 이유는 솔리디티의 call 함수 때문이에요. 이 함수는 제어권을 외부 주소로 넘겨주는데, 만약 그 주소가 악의적인 스마트 컨트랙트라면 fallback 함수를 통해 원래의 함수를 다시 호출할 수 있거든요. 상태 변수가 업데이트되기 전에 함수가 다시 실행되니까, 조건 검사(require)를 통과해버리는 무서운 일이 벌어집니다.
과거 DAO 해킹 사건도 바로 이 지점에서 시작되었더라고요. 당시 수천억 원 규모의 이더리움이 탈취된 원인이 이 사소한 코드 순서 차이 때문이었다니 정말 놀라운 일이죠. 개발자 입장에서는 로직이 완벽해 보여도 외부 호출이 섞여 있다면 항상 의심의 눈초리로 코드를 바라봐야 하는 이유가 여기에 있습니다.
보안 패턴 비교 분석
재진입 공격을 막는 방법은 크게 세 가지 정도로 나뉘는 것 같아요. 제가 실제로 코드를 짜면서 느꼈던 각 방법의 장단점을 표로 정리해 보았습니다. 상황에 맞는 적절한 기법을 선택하는 것이 가스 효율성과 보안성 두 마리 토끼를 잡는 비결이더라고요.
| 방어 기법 | 구현 난이도 | 가스 비용 | 특징 |
|---|---|---|---|
| CEI 패턴 | 낮음 | 매우 낮음 | 가장 기본적이고 권장되는 로직 순서 변경 방식 |
| Mutex (Guard) | 보통 | 중간 | 상태 변수를 이용해 중복 진입을 원천 차단 |
| Pull Payment | 높음 | 높음 | 사용자가 직접 찾아가게 하여 위험 분산 |
처음에는 저도 무조건 ReentrancyGuard 같은 라이브러리에만 의존했거든요. 그런데 가스비가 급등하는 시기에는 그 작은 비용 차이가 사용자들에게는 부담이 될 수 있더라고요. 그래서 요즘은 기본적인 CEI 패턴을 먼저 적용하고, 복잡한 로직에만 가드를 추가하는 방식으로 믹스해서 사용하고 있습니다.
비교를 해보니 각자만의 매력이 확실히 느껴지지 않나요? 개인적으로는 CEI 패턴을 몸에 익히는 것이 개발자로서의 기본 소양이라고 생각해요. 도구가 해결해 주는 것과 원리를 이해하고 코드를 짜는 것은 하늘과 땅 차이니까요.
CEI 패턴의 실제 적용법
CEI는 Checks-Effects-Interactions의 약자예요. 이 순서만 지켜도 90% 이상의 재진입 공격은 예방할 수 있다고 하더라고요. 우선 Checks 단계에서는 조건이 맞는지 require를 통해 검증합니다. 잔액이 충분한지, 호출자가 권한이 있는지 등을 먼저 보는 거죠.
다음으로 Effects 단계가 가장 중요해요. 외부로 돈을 보내기 전에 미리 내부 잔액 변수를 깎아버리는 거예요. "나 이제 돈 보낼 거니까 미리 장부에서 지워둘게"라고 선언하는 셈이죠. 이렇게 하면 공격자가 다시 들어왔을 때 이미 잔액이 0으로 되어 있어서 추가 인출이 불가능해집니다.
마지막 Interactions 단계에서 실제 전송을 수행합니다. 제가 실패했던 코드는 Effects와 Interactions의 순서가 바뀌어 있었거든요. 돈을 먼저 보내고 장부를 수정하려고 하니, 돈이 나가는 순간 공격자가 다시 들어와서 아직 수정되지 않은 장부를 보고 돈을 또 가져갔던 거예요.
ReentrancyGuard 라이브러리 활용
로직이 너무 복잡해서 순서를 맞추기 어렵거나, 좀 더 확실한 안전장치를 원하신다면 OpenZeppelin의 ReentrancyGuard를 사용하는 게 답이 될 수 있어요. nonReentrant라는 제어자(modifier) 하나만 함수에 붙여주면 되니까 정말 편리하더라고요. 내부적으로는 boolean 값을 바꿔가며 함수가 실행 중인지 체크하는 원리입니다.
하지만 이 방법도 무분별하게 쓰면 안 된다는 걸 깨달았어요. 모든 함수에 다 붙여버리면 불필요한 가스 소모가 발생하거든요. 특히 읽기 전용 함수(view)에는 붙일 필요가 없는데, 습관적으로 다 붙이는 분들도 계시더라고요. 꼭 필요한 쓰기 작업이 포함된 함수에만 골라서 사용하는 지혜가 필요합니다.
또한, 최신 버전의 솔리디티에서는 가스 최적화를 위해 uint256 타입을 이용해 1과 2로 상태를 구분하기도 하더라고요. bool 타입보다 uint256이 가스비 측면에서 유리한 경우가 많다는 사실, 신기하지 않나요? 라이브러리를 쓰더라도 그 내부가 어떻게 돌아가는지 한 번쯤 뜯어보는 재미가 쏠쏠합니다.
결국 보안은 정답이 하나가 아니라 여러 겹의 방어막을 쌓는 과정인 것 같아요. CEI 패턴으로 기본을 다지고, 중요도가 높은 함수에는 가드를 설치하는 이중 보안 시스템을 구축하는 것이 가장 바람직해 보입니다. 저도 이제는 이 원칙을 철저히 지키며 코딩하고 있어요.
자주 묻는 질문
Q. transfer 함수만 쓰면 재진입 공격에서 안전한가요?
A. 과거에는 2300 가스 제한 때문에 안전하다고 여겨졌지만, 가스 산정 방식이 변할 수 있는 이더리움 특성상 call 함수와 보안 패턴을 함께 사용하는 것이 현재는 정석으로 통합니다.
Q. nonReentrant 제어자를 쓰면 CEI 패턴은 안 지켜도 되나요?
A. 아니요, 보안은 다다익선입니다. 라이브러리에만 의존하기보다 로직 자체를 안전하게 짜는 CEI 패턴을 기본으로 가져가는 습관이 훨씬 중요합니다.
Q. 재진입 공격은 인출(Withdraw) 함수에서만 발생하나요?
A. 주로 자산 전송 시 발생하지만, 외부 컨트랙트의 함수를 호출하는 모든 지점에서 발생할 수 있습니다. 민팅이나 투표 로직 등에서도 주의가 필요해요.
Q. 크로스 컨트랙트 재진입은 무엇인가요?
A. 단일 함수가 아니라 서로 다른 두 개 이상의 컨트랙트나 함수 사이에서 발생하는 재진입을 뜻합니다. 상태 변수를 공유할 때 특히 조심해야 합니다.
Q. 가스비 절약을 위해 보안을 조금 포기해도 될까요?
A. 절대 안 됩니다. 해킹으로 인한 자산 손실은 가스비 절약분과는 비교할 수 없을 만큼 치명적입니다. 최적화는 보안이 확보된 뒤의 영역이에요.
Q. 뷰(View) 함수에서도 재진입 문제가 생길 수 있나요?
A. 뷰 함수 자체는 상태를 바꾸지 않아 직접적인 자산 탈취는 어렵지만, 잘못된 정보를 제공하여 다른 로직을 꼬이게 만드는 Read-only Reentrancy 공격이 존재합니다.
Q. 솔리디티 버전이 높으면 자동으로 방어되나요?
A. 버전이 올라가면서 오버플로우 등은 막아주지만, 재진입 공격은 로직의 문제라 컴파일러가 자동으로 해결해 주지 않습니다. 여전히 개발자의 몫입니다.
Q. 테스트 코드만으로 재진입 취약점을 찾을 수 있나요?
A. 일반적인 테스트로는 어렵고, 공격용 컨트랙트를 직접 작성해서 시뮬레이션하거나 슬리더(Slither) 같은 정적 분석 도구를 활용해야 합니다.
오늘 이렇게 솔리디티 보안의 핵심인 재진입 공격 방어에 대해 깊이 있게 다뤄보았는데요. 처음에는 복잡해 보이지만 원리만 이해하면 코드를 짤 때 자연스럽게 적용할 수 있는 부분들이더라고요. 저의 실패담이 여러분에게는 소중한 예방 주사가 되었기를 바랍니다.
스마트 컨트랙트의 세계는 한 번 배포하면 수정이 어려운 만큼, 처음부터 제대로 짜는 것이 무엇보다 중요하더라고요. 오늘 배운 CEI 패턴과 가드 활용법을 잊지 마시고 안전한 디앱(dApp) 개발하시길 응원하겠습니다. 궁금한 점이 있다면 언제든 댓글 남겨주세요.
긴 글 읽어주셔서 감사드리고요, 다음에도 유익하고 실용적인 정보로 찾아오겠습니다. 보안은 선택이 아닌 필수라는 점, 다시 한번 강조하며 이만 줄이겠습니다. 모두 행복한 코딩 생활 되세요.
작성자: 김창수
10년 차 생활 블로거이자 블록체인 기술에 매료된 취미 개발자입니다. 복잡한 기술을 일상의 언어로 쉽게 풀어내는 것을 즐깁니다.
본 포스팅은 정보 제공을 목적으로 하며, 실제 코드 적용 시 발생할 수 있는 보안 사고에 대해 필자는 법적 책임을 지지 않습니다. 반드시 전문적인 보안 오딧(Audit)을 거치시기 바랍니다.
댓글
댓글 쓰기