개발자가 놓치기 쉬운 솔리디티(Solidity) 보안 코딩 규칙 10선

개발자가 놓치기 쉬운 솔리디티(Solidity) 보안 코딩 규칙 10선 관련 이미지
안녕하세요, 10년 차 생활 블로거 김창수입니다. 요즘 부쩍 블록체인 개발이나 스마트 컨트랙트에 관심을 가지는 분들이 많아진 것 같더라고요. 저도 한때는 코딩 좀 한다고 자부하며 솔리디티(Solidity)의 세계에 발을 들였다가 정말 큰 코 다친 적이 있었거든요. 일반적인 웹 개발과는 다르게 자산이 직접 오가는 환경이라 보안이 정말 생명이라는 사실을 뼈저리게 느꼈답니다.
처음에는 문법이 자바스크립트랑 비슷해서 금방 익힐 수 있을 줄 알았는데, 막상 뚜껑을 열어보니 전혀 다른 세상이더라고요. 코드 한 줄의 실수가 수억 원, 아니 수백억 원의 자산 손실로 이어지는 걸 지켜보면서 이 보안 규칙들이 단순한 권장 사항이 아니라 생존 규칙이라는 걸 깨닫게 되었어요. 오늘 제가 공유해 드리는 10가지 규칙은 제가 직접 몸으로 부딪히며 배운 것들이라 실무에서 정말 유용하게 쓰일 거예요.
스마트 컨트랙트는 한 번 배포하면 수정이 거의 불가능하다는 특성이 있잖아요. 그래서 배포 전에 완벽에 가깝게 검증하는 습관이 중요하더라고요. 개발자라면 누구나 한 번쯤은 실수할 수 있는 부분들을 위주로 하나씩 짚어보려고 하니, 지금 작성 중인 코드가 있다면 옆에 띄워두고 비교하면서 읽어보시면 좋을 것 같아요.
1. 재진입성(Reentrancy) 공격 방어의 핵심
2. 정수 오버플로우와 언더플로우 대응법
3. 권한 관리와 가시성 설정의 중요성
4. 가스 리밋과 서비스 거부(DoS) 방지
5. 외부 호출 및 오라클 데이터 신뢰성
6. 보안 관련 자주 묻는 질문(FAQ)
재진입성(Reentrancy) 공격 방어의 핵심
솔리디티 보안에서 가장 유명하면서도 치명적인 게 바로 재진입성 공격이거든요. 함수가 실행되는 도중에 외부 컨트랙트가 다시 해당 함수를 호출해서 자금을 중복으로 인출해가는 방식이에요. 예전에 저도 테스트넷에서 간단한 은행 컨트랙트를 만들었다가 이 공격에 탈탈 털렸던 아픈 기억이 있답니다. 단순히 잔액을 확인하고 돈을 보내는 순서만 바꿨을 뿐인데 모든 자산이 사라지는 걸 보고 정말 허망하더라고요.
이런 공격을 막으려면 Checks-Effects-Interactions 패턴을 반드시 지켜야 해요. 모든 상태 변경을 외부 호출 전에 미리 완료하는 방식인데, 생각보다 지키기가 까다로울 때가 있더라고요. 혹은 오픈제플린(OpenZeppelin)에서 제공하는 ReentrancyGuard 라이브러리를 사용하는 것도 아주 좋은 방법이에요. nonReentrant 제어자 하나만 붙여도 마음이 훨씬 편안해지는 걸 느낄 수 있을 거예요.
정수 오버플로우와 언더플로우 대응법
솔리디티 0.8.0 버전 이전에는 숫자가 범위를 넘어가면 다시 0으로 돌아가거나 최대치로 변하는 문제가 정말 심각했거든요. 예를 들어 잔액이 0인데 1을 빼면 갑자기 천문학적인 숫자가 잔액으로 찍히는 상황이죠. 다행히 최신 버전에서는 기본적으로 체크를 해주지만, 여전히 unchecked 블록을 사용할 때는 주의가 필요하더라고요. 성능 최적화를 한답시고 무심코 썼다가 보안 구멍이 생길 수 있으니까요.
여기서 잠깐 제가 겪었던 비교 경험을 하나 말씀드려볼게요. 예전에는 SafeMath 라이브러리를 모든 연산에 덕지덕지 붙여서 썼거든요. 그런데 최신 버전으로 넘어오면서 언어 자체에서 지원을 해주니까 코드가 훨씬 깔끔해지더라고요. 하지만 라이브러리를 쓸 때와 안 쓸 때의 가스비 차이나 컴파일러 버전에 따른 동작 방식 차이를 명확히 이해하지 못하면 나중에 유지보수할 때 정말 고생하게 된답니다.
| 구분 | 0.8.0 미만 버전 | 0.8.0 이상 버전 |
|---|---|---|
| 기본 연산 보안 | 오버플로우 자동 체크 안 됨 | 기본적으로 런타임 에러 발생 |
| 라이브러리 의존도 | SafeMath 필수 사용 권장 | 내장 기능으로 대체 가능 |
| 가스 효율성 | 함수 호출 오버헤드 존재 | 컴파일러 최적화로 더 효율적 |
권한 관리와 가시성 설정의 중요성
함수의 가시성(Visibility)을 잘못 설정하는 것도 초보 개발자들이 가장 많이 하는 실수 중 하나인 것 같아요. public으로 두지 말아야 할 초기화 함수나 관리자 전용 함수를 그대로 노출했다가 누구나 컨트랙트의 주인이 될 수 있는 상황이 벌어지기도 하거든요. 실제로 과거에 유명했던 지갑 해킹 사건들도 이런 아주 사소한 가시성 설정 오류에서 시작된 경우가 많았더라고요.
특히 private이나 internal을 적절히 섞어서 사용하는 습관을 들여야 해요. 모든 데이터를 외부에 공개할 필요는 없으니까요. 물론 블록체인 특성상 private 변수라고 해서 데이터 자체가 숨겨지는 건 아니지만, 적어도 다른 컨트랙트가 직접 접근해서 값을 바꾸는 건 막을 수 있거든요. 권한 관리 로직을 짤 때는 Ownable 패턴보다는 AccessControl을 사용해서 역할을 세분화하는 게 대규모 프로젝트에서는 훨씬 안전한 것 같아요.
가스 리밋과 서비스 거부(DoS) 방지
가스 리밋으로 인한 서비스 거부 공격은 정말 교묘하더라고요. 루프(loop)를 돌면서 수많은 사용자에게 보상을 나눠주는 로직이 있다고 가정해볼게요. 사용자가 너무 많아지면 한 번의 트랜잭션이 소모하는 가스량이 블록 가스 리밋을 넘어서게 되거든요. 그렇게 되면 그 함수는 영원히 실행될 수 없는 상태가 되어버려요. 자금이 묶여버리는 끔찍한 상황이 발생하는 거죠.
이런 문제를 피하려면 Pull over Push 방식을 써야 해요. 컨트랙트가 사용자들에게 직접 돈을 '밀어넣어 주는(Push)' 게 아니라, 사용자들이 직접 와서 자기 몫을 '찾아가는(Pull)' 구조로 만드는 거예요. 이렇게 하면 각 사용자의 트랜잭션은 독립적으로 처리되니까 전체 가스 리밋에 걸릴 위험이 전혀 없거든요. 저도 처음에는 한 번에 다 보내주면 편하겠지 생각했다가, 나중에 가스비 감당이 안 돼서 컨트랙트를 새로 배포했던 실패담이 있네요.
외부 호출 및 오라클 데이터 신뢰성
외부 컨트랙트를 호출할 때는 항상 그 컨트랙트가 악의적일 수 있다는 가정을 해야 하더라고요. 특히 call 함수를 사용할 때는 반환 값을 반드시 확인해야 해요. 성공했는지 실패했는지 체크하지 않고 그냥 넘어가면, 실제로는 돈이 안 나갔는데 나간 걸로 처리되는 논리적 오류가 생길 수 있거든요. transfer나 send보다 call이 권장되는 추세지만 그만큼 다루기는 더 까다로운 것 같아요.
또한 오라클(Oracle)을 통해 외부 가격 데이터를 가져올 때도 조심해야 해요. 단일 오라클만 믿었다가 그 데이터가 조작되면 컨트랙트 전체가 붕괴될 수 있거든요. 체인링크(Chainlink) 같은 검증된 탈중앙화 오라클을 사용하거나, 여러 소스의 데이터를 비교하는 로직을 넣는 게 안전하더라고요. 데이터 하나가 내 컨트랙트의 운명을 결정짓는다는 사실을 항상 명심해야 할 것 같아요.
자주 묻는 질문
Q1. 솔리디티에서 tx.origin을 사용하면 안 되는 이유가 뭔가요?
A. tx.origin은 트랜잭션을 처음 생성한 지갑 주소를 나타내는데, 피싱 컨트랙트를 통해 공격자가 사용자의 권한을 도용할 수 있기 때문이에요. 대신 msg.sender를 사용하는 것이 훨씬 안전하답니다.
Q2. transfer() 대신 call()을 권장하는 특별한 이유가 있나요?
A. transfer()는 가스 한도가 2300으로 고정되어 있어서, 수신 측 컨트랙트의 로직이 복잡해지면 실패할 확률이 높거든요. call()은 가스 조절이 가능해서 유연하지만 보안 체크를 직접 해야 한다는 점이 달라요.
Q3. 스마트 컨트랙트 보안 감사는 꼭 받아야 할까요?
A. 메인넷에 배포하고 실제 자금이 유입되는 프로젝트라면 무조건 필수라고 생각해요. 개발자가 미처 보지 못한 사각지대를 전문가들이 찾아내 주기 때문에 사고 예방 비용이라고 보시면 돼요.
Q4. 랜덤 숫자를 생성할 때 keccak256(block.timestamp)를 써도 되나요?
A. 절대 안 돼요! 블록 타임스탬프는 채굴자가 어느 정도 조작할 수 있어서 예측이 가능하거든요. 진짜 랜덤이 필요하다면 체인링크 VRF 같은 외부 솔루션을 쓰는 게 정석이더라고요.
Q5. 컨트랙트의 가스를 절약하는 가장 쉬운 방법은 무엇인가요?
A. 스토리지(Storage) 쓰기를 최소화하는 거예요. 상태 변수를 자주 바꾸기보다 메모리 변수를 활용하고 마지막에 한 번만 저장하는 방식으로 코드를 짜면 가스비가 확 줄어드는 걸 볼 수 있거든요.
Q6. delegatecall을 사용할 때 주의할 점은 무엇인가요?
A. delegatecall은 호출된 코드의 로직을 현재 컨트랙트의 상태에서 실행하거든요. 스토리지 레이아웃이 일치하지 않으면 엉뚱한 변수 값이 덮어씌워질 수 있어서 레이아웃 매칭이 정말 중요해요.
Q7. 컨트랙트 파기 함수(selfdestruct)는 여전히 안전한가요?
A. 최근 이더리움 업그레이드로 인해 selfdestruct의 동작 방식이 많이 변했거든요. 이제는 예전처럼 컨트랙트를 완전히 삭제하는 용도로 쓰기 어려우니 사용을 자제하고 업그레이드 가능한 패턴을 공부하는 게 좋아요.
Q8. 프론트러닝(Front-running) 공격은 어떻게 막나요?
A. 트랜잭션 순서를 악용하는 공격인데, 커밋-리빌(Commit-Reveal) 스킴을 사용하거나 트랜잭션에 유효 시간을 짧게 설정하는 방식으로 어느 정도 방어할 수 있어요. 완벽히 막기는 정말 까다로운 분야더라고요.
솔리디티 보안이라는 게 처음에는 막막하게 느껴질 수 있지만, 하나씩 원리를 이해하다 보면 결국 '예외 상황'을 얼마나 꼼꼼하게 처리하느냐의 싸움인 것 같아요. 저도 수많은 실수와 삽질을 반복하면서 이제야 조금 감이 잡히기 시작했거든요. 여러분은 저처럼 소중한 자산을 잃거나 밤새 고생하지 마시고, 오늘 나눈 내용들을 꼭 실제 코드에 적용해 보셨으면 좋겠어요.
완벽한 코드는 없다고들 하지만, 보안에 있어서만큼은 완벽을 추구하는 태도가 개발자의 가장 큰 덕목이 아닐까 싶어요. 궁금한 점이 있다면 언제든 댓글 남겨주시고, 우리 모두 안전하고 튼튼한 스마트 컨트랙트를 만드는 그날까지 함께 힘내봐요. 긴 글 읽어주셔서 정말 고맙습니다.
작성자: 김창수 (10년 차 생활 블로거)
IT 기술과 일상의 접점을 탐구하며, 초보자도 이해하기 쉬운 실용적인 가이드를 작성하고 있습니다. 직접 겪은 실패와 성공의 기록이 누군가에게 도움이 되길 바랍니다.
본 포스팅은 정보 제공을 목적으로 작성되었으며, 실제 스마트 컨트랙트 개발 시에는 공식 문서와 전문가의 자문을 반드시 확인하시기 바랍니다. 코드 오류나 보안 사고에 대한 책임은 개발자 본인에게 있습니다.
댓글
댓글 쓰기