
가장 정교한 ZK 응용: Tornado Cash의 원리와 비즈니스 로직 간단 분석
저자: Faust, 극크 web3
서론: 최근 Vitalik과 일부 학자들이 공동으로 발표한 논문에서 Tornado Cash가 자금세탁 방지를 어떻게 구현하는지에 대해 언급했는데, 이는 인출자가 자신의 입금 기록이 불법 자금을 포함하지 않는 집합에 속한다는 것을 증명하는 방식이다. 그러나 이 논문은 Tornado Cash의 비즈니스 로직과 원리를 세밀하게 설명하지 않아 이해하기 어렵다.
또한 주목할 점은, Tornado와 같은 프라이버시 프로젝트가 진정으로 ZK-SNARK 알고리즘의 제로노울리지 성질을 활용하고 있다는 것이다. 반면 대부분의 ZK를 내세우는 롤업들은 ZK-SNARK의 '간결성'만을 이용할 뿐이다. 사람들은 종종 Validity Proof와 제로노울리지(ZK)의 차이를 혼동하기 쉬운데, Tornado는 ZK 응용을 이해하는 데 탁월한 사례가 된다.
본 필자는 2022년 Web3Caff Research에서Tornado의 원리에 관한 글을 작성한 바 있으며, 오늘 이를 일부 발췌하여 확장 정리함으로써 독자들이 Tornado Cash를 체계적으로 이해할 수 있도록 하려 한다.
"토네이도"의 원리
Tornado Cash는 제로노울리지 증명을 활용한 믹서 프로토콜이며, 구버전은 2019년부터 사용되었고 신버전은 2021년 말 베타 서비스를 시작했다. 구버전 Tornado는 기본적으로 탈중앙화를 실현했으며, 스마트 계약은 공개되어 있고 멀티시그 관리도 없고, 프론트엔드 코드 역시 오픈소스로 IPFS 네트워크에 백업되어 있다. 구버전 Tornado의 구조가 더 단순하고 이해하기 쉬워 본고에서는 구버전을 중심으로 설명한다.
Tornado의 핵심 아이디어는 다음과 같다: 수많은 입금 및 출금 행위를 혼합하여, 입금자가 Tornado에 토큰을 예치한 후 제로노울리지 증명(ZK Proof)으로 입금 사실을 증명하고, 새로운 주소를 통해 인출함으로써 입금과 인출 주소 간의 연관성을 끊는 것이다.

좀 더 구체적으로 말하면, Tornado는 마치 유리 상자처럼 여러 사람이 동전을 넣은 상태를 의미한다. 누가 동전을 넣었는지는 알 수 있지만, 그 동전들은 매우 동질화되어 있어 낯선 사람이 유리 상자에서 동전을 가져가도, 그 동전이 누구의 것이었는지 파악하기 어렵다.

이와 비슷한 장면은 흔히 볼 수 있다. Uniswap 풀에서 ETH를 스왑할 때, 제공된 ETH가 누구의 것인지 알 수 없다. 왜냐하면 Uniswap에 유동성을 제공한 사람들이 너무 많기 때문이다. 하지만 차이점은, Uniswap을 이용해 토큰을 교환할 때는 반드시 다른 토큰을 대가로 지불해야 하며, 자금을 "비공개"로 타인에게 양도할 수 없다는 점이다. 반면 믹서는 인출자가 입금 증빙만 제출하면 된다.
입금 및 출금 동작이 동일하게 보이도록 하기 위해, Tornado 풀의 입금 주소는 매번 동일한 금액을 예치하며, 인출 주소도 매번 동일한 금액을 인출한다. 예를 들어, 특정 풀에 100명의 입금자와 100명의 인출자가 존재하더라도, 모두 공개되어 있음에도 불구하고 서로 연결되지 않으며, 각각 동일한 금액을 입금하고 인출한다. 이로 인해 시각적 혼란을 초래하여 입출금 금액으로 연관성을 판단할 수 없게 되며, 자금 이동 흔적을 끊을 수 있다. 명백히 이는 자금세탁에 자연스러운 편의를 제공한다.

하지만 중요한 문제가 하나 있다: 인출자가 인출할 때 어떻게 자신이 입금했음을 증명할까? 믹서에서 인출 요청을 보내는 주소는 모든 입금 주소와 연결되지 않는데, 어떻게 인출 자격을 판단할 수 있을까? 가장 직접적인 방법은 인출자가 자신의 입금 기록이 어느 것인지 공개하는 것이겠지만, 이는 곧바로 신원을 노출시키는 결과를 낳는다. 이때 제로노울리지 증명이 등장한다.
인출자는 ZK Proof를 제출하여 자신이 Tornado 컨트랙트에 입금 기록이 있고, 해당 입금이 아직 인출되지 않았음을 증명하면, 인출을 성공적으로 수행할 수 있다. 제로노울리지 증명은 본래 프라이버시 보호를 실현하며, 외부는 인출인이 풀에 입금한 사실만 알 수 있고, 어떤 입금자와 연결되는지는 알 수 없다.

"내가 Tornado 풀에 입금한 적이 있다"는 것을 증명하는 것은 "나의 입금 기록이 Tornado 컨트랙트에서 찾을 수 있다"는 것으로 전환될 수 있다. Cn을 입금 기록이라 하면, 문제는 다음과 같이 요약된다:
Tornado의 입금 기록 집합이 {C1, C2, …, C100, ...}일 때, 인출자 Bob은 자신의 개인키를 사용해 그 중 특정 Cn을 생성했음을 증명하되, ZK를 통해 Cn이 무엇인지 노출하지 않는다.
여기서 Merkle Proof의 특수한 성질을 사용해야 한다. Tornado의 모든 입금 기록은 체인 위에 구성된 Merkle 트리의 가장 아래쪽 리프 노드로 저장되며, 전체 리프 수는 약 2^20, 즉 100만 개 이상이고, 대부분은 공백 상태(초기값 부여됨)이다. 새로운 입금이 발생하면 컨트랙트는 해당 특징값인 Commitment를 리프에 기록하고 Merkle 트리의 Root를 업데이트한다.

예를 들어, Bob의 입금이 Tornado 역사상 1만 번째라면, 이 입금과 관련된 특징값 Cn이 Merkle 트리의 1만 번째 리프에 기록되고, 즉 C10000 = Cn이 된다. 이후 컨트랙트는 자동으로 새로운 Root를 계산하여 업데이트한다. (참고: 계산량을 줄이기 위해 Tornado 컨트랙트는 이전 변경된 노드들의 데이터를 캐싱한다. 예: 아래 그림의 Fs1, Fs2, Fs0)

Merkle Proof 자체는 매우 간결하고 가볍다. 이는 트리 구조 데이터가 검색/추적 과정에서 가지는 효율성을 활용한다. 만약 Merkle Tree에 거래 TD가 존재함을 증명하고자 한다면, 오른쪽 그림처럼 Root에 대응하는 Merkle Proof만 제공하면 되며, 이는 매우 간단하다. Merkle Tree가 매우 크고 리프 수가 2^20, 즉 약 100만 개의 입금 기록을 포함하더라도, Merkle Proof는 고작 21개의 노드 값만 포함하면 되므로 매우 짧다.

어떤 거래 H3가 Merkle Tree에 포함되어 있음을 증명하려면, H3와 Merkle Tree의 일부 데이터를 조합해 Root를 생성할 수 있음을 보이면 된다. 이 과정에서 필요한 데이터(Td 포함)가 바로 Merkle Proof가 된다.
Bob이 인출할 때는, 자신이 소유한 증빙이 Merkle Tree에 기록된 입금 해시 Cn과 대응됨을 증명해야 한다. 즉 다음 두 가지를 증명해야 한다:
· Cn이 체인 상의 Tornado 컨트랙트 Merkle Tree에 존재하며, 이에 대한 Merkle Proof를 구성할 수 있다
· Cn은 Bob이 소유한 입금 증빙과 연관되어 있다

Tornado 비즈니스 로직 상세 설명
Tornado 사용자 인터페이스의 프론트엔드 코드는 미리 많은 기능을 구현해 놓았다. 입금자가 Tornado Cash 웹사이트를 열고 입금 버튼을 클릭하면, 프론트엔드 프로그램은 로컬에서 두 개의 난수 K와 r을 생성하고, Cn = Hash(K, r) 값을 계산한 후, 이 Cn(아래 그림의 commitment)을 Tornado 컨트랙트에 전송하여 Merkle Tree에 삽입한다. 즉, K와 r은 개인키와 같다. 이들은 매우 중요하며 시스템은 사용자에게 안전하게 보관하도록 안내한다. 추후 인출 시에도 여전히 필요하다.

(여기서 encryptedNote는 선택사항으로, 사용자가 증빙 K와 r을 개인키로 암호화하여 체인에 저장해 분실을 방지할 수 있다)
주목할 점은, 위 작업은 모두 오프체인에서 이루어진다는 것이다. 즉 Tornado 컨트랙트와 외부 관찰자는 K와 r을 알 수 없다. K와 r이 유출되면 마치 지갑의 개인키가 도난당한 것과 같다.

Tornado 컨트랙트는 사용자의 입금과 함께 Cn = Hash(K, r)을 수신하면, Cn을 Merkle 트리 최하단의 새로운 리프 노드로 삽입하고 Root 값을 업데이트한다. 따라서 Cn은 사용자의 입금 동작과 일대일로 연결되며, 외부는 각 Cn이 어떤 사용자와 연결되는지, 누구든 믹서에 토큰을 예치했는지, 각 입금자별 입금 기록 Cn을 알 수 있다.
인출 단계에서, 인출자는 프론트엔드 페이지에 증빙/개인키(입금 시 생성된 난수 K와 r)를 입력하면, Tornado Cash 프론트엔드 프로그램은 K와 r, Cn = Hash(K, r), Cn에 대응하는 Merkle Proof를 입력값으로 하여 ZK Proof를 생성, Cn이 Merkle Tree 상의 입금 기록임을 증명하고, K와 r이 해당 Cn의 증빙임을 확인한다.
이는 마치 나는 Merkle Tree 상의 특정 입금 기록에 대응하는 키를 알고 있다는 것을 증명하는 것과 같다. ZK Proof가 Tornado 컨트랙트에 제출될 때, 상기 4개의 매개변수는 모두 숨겨져 외부(컨트랙트 포함)는 이를 알 수 없으며, 프라이버시가 보장된다.
ZK Proof 생성에 포함되는 기타 매개변수는: 인출 시 Tornado 컨트랙트의 Merkle Tree Root, 사용자가 설정한 수취 주소 A, 재생공격 방지를 위한 식별자 nf(뒤에서 설명)이다. 이 3개의 매개변수는 체인 상에 공개되며 외부에 알려지지만, 프라이버시에는 영향을 주지 않는다.

여기서 세부 사항 하나는, Cn을 생성할 때 난수 K와 r 두 개를 사용해 Cn을 생성하고, 단일 난수를 사용하지 않은 이유이다. 단일 난수는 충분히 안전하지 않아 충돌 가능성이 있는데, 예를 들어 두 입금자가 우연히 같은 난수를 사용해 Cn이 중복되는 현상이 발생할 수 있기 때문이다.
위 그림의 A는 인출 수령 주소를 나타내며, 인출자가 직접 입력한다. nf는 재생공격 방지를 위한 식별자이며, 그 값은 nf = Hash(K)로, K는 입금 시 Cn 생성에 사용된 두 난수 중 하나(K와 r)이다. 이렇게 함으로써 nf는 Cn과 연결되며, 즉 각 Cn은 대응하는 nf를 가지며 일대일로 연결된다.
왜 재생공격을 방지해야 하는가? 믹서의 설계 특성상, 인출 시 사용자가 인출한 코인이 Merkle 트리의 어느 리프 Cn에 대응하는지 알 수 없고, 인출자와 어떤 입금자와 연결되는지도 모르며, 인출자가 몇 번 입금했는지도 모른다. 인출자는 이러한 특성을 이용해 빈번하게 인출하며 재생공격을 시도해 여러 번 토큰을 인출, 결국 풀의 자금을 고갈시킬 수 있다.

여기서 nf 식별자는 각 이더리움 주소가 가지는 트랜잭션 카운터 nonce와 유사한 역할을 한다. 둘 다 특정 트랜잭션이 재생되는 것을 방지하기 위해 설정된다. 인출이 발생할 때 인출자는 nf를 제출해야 하며, 이 nf가 이미 사용되었는지 확인한다. 사용된 경우 인출은 무효이며, 사용되지 않았다면 유효하며 해당 nf가 기록된다. 이후 동일한 nf가 다시 제출되면 인출 동작은 무효 처리된다.

누군가 임의로 컨트랙트에 기록되지 않은 nf를 생성하는 것은 가능한가? 불가능하다. 인출자가 ZK Proof를 생성할 때 nf = Hash(K)임을 보장해야 하며, 난수 K는 입금 기록 Cn과 연결되어 있다. 즉 nf는 기록된 입금 Cn과 연결된다. 임의로 nf를 조작하면, 이는 모든 입금 기록과 일치하지 않아 유효한 ZK Proof를 생성할 수 없으며, 이후 작업은 진행되지 못하고 인출도 실패한다.
혹자는 nf 없이도 가능한가? 인출 시 ZK 증명을 제출하여 자신이 특정 Cn과 연결되어 있음을 증명한다면, 인출 시마다 해당 ZK Proof가 체인에 제출되었는지 확인하면 되지 않겠는가?
하지만 실제로는 비용이 매우 크다. Tornado Cash 컨트랙트는 과거 제출된 ZK Proof를 영구 저장하지 않기 때문이다. 저장 공간 낭비를 막기 위함이다. 새로 제출된 ZK Proof가 기존 Proof와 동일한지 비교하는 것보다, 용량이 매우 작은 식별자 nf를 영구 저장하는 것이 훨씬 경제적이다.
인출 함수의 코드 예시에 따르면, 요구되는 매개변수와 비즈니스 로직은 다음과 같다:
사용자는 ZKProof, nf(NullifierHash) = Hash(K), 수취 주소 recipient를 지정하여 제출한다. ZKProof는 Cn과 K, r의 값을 숨기므로 외부는 사용자 신원을 파악할 수 없다. recipient는 일반적으로 깨끗한 새 주소를 기입하며 개인정보를 노출하지 않는다.

하지만 여기서 작은 문제가 하나 있다. 사용자는 추적이 불가능하도록 인출 시 새로 생성한 주소를 사용하는 경우가 많으므로, 이 새 주소는 가스비를 지불할 ETH가 없다. 따라서 인출 주소가 인출 거래를 시작할 때는 명시적으로 중계자(relayer)를 선언하여 가스비를 대납하게 하고, 이후 믹서 컨트랙트는 사용자의 인출금에서 일부를 relayer에게 지급하여 보상한다.

요약하자면, TornadoCash는 인출자와 입금자 사이의 연결을 숨길 수 있으며, 사용자 수가 많을 경우 마치 붐비는 시장처럼, 범죄자가 군중에 섞이면 경찰도 추적하기 어렵다. 인출 과정에서는 ZK-SNARK가 필요하며, 숨겨진 witness 부분에는 인출자의 핵심 정보가 포함되며, 이는 전체 믹서 시스템에서 가장 중요한 포인트이다. 현재까지 Tornado는 ZK와 관련된 가장 정교한 애플리케이션 레이어 프로젝트 중 하나일 가능성이 높다.
TechFlow 공식 커뮤니티에 오신 것을 환영합니다
Telegram 구독 그룹:https://t.me/TechFlowDaily
트위터 공식 계정:https://x.com/TechFlowPost
트위터 영어 계정:https://x.com/BlockFlow_News














