거래소 주문 API 표준화는 많은데 입출금은 왜 없을까. 입출금 표준화, KMS 기반 서명, DAG 송금 모델, Airflow를 참고한 스케줄링까지 QTG의 설계 배경을 정리합니다.
BibTeX
@online{jerry2026why,
author = {Jerry},
title = {{Why I built QTG}},
year = {2026},
date = {2026-10-15},
url = {https://jerrylog.dev/ko/blog/why-qtg},
language = {korean},
}APA
Jerry. (2026, October 15). Why I built QTG. Jerrylog. https://jerrylog.dev/ko/blog/why-qtg
이 글에서는 QTG(Quant Transfer Guard)를 만들게 된 세 가지 이유와, 이 이유들이 구조와 설계 결정에 어떻게 반영되었는지 살펴보겠습니다. QTG는 전략이나 에이전트가 보낸 송금 요청을 승인 절차를 거쳐 실행하고, 출금부터 입금 확인까지 전 과정을 기록합니다. 사용자가 자기 서버에 직접 설치해 운영하는(self-hosted) 방식이며, 자세한 동작은 QTG 문서에 정리해 두었습니다.
CCXT 같은 거래소 연동 라이브러리에서는 출금 요청과 조회가 표준화되어 있습니다. 예를 들어, CCXT에는 withdraw, fetchDepositAddress, fetchDeposits, fetchWithdrawals와 같은 통합 메서드가 있습니다. fetchWithdrawals는 출금 내역을 조회하는 메서드로, 거래소마다 다른 상태 값을 pending, ok, failed, canceled 네 가지로 바꿔 돌려줍니다.
하지만, CCXT는 조회 한 번의 응답만 표준화합니다. 출금이 끝날 때까지 언제 다시 조회할지, 출발 거래소의 txid를 도착 거래소의 입금 내역에서 어떻게 찾을지, 응답이 끊겼을 때 출금이 나갔는지 어떻게 확인할지는 사용자가 직접 정해야 합니다.
거래소 간 송금 한 건은 대략 다음 순서로 진행됩니다.
출금 API는 1번 단계에서 응답을 돌려줍니다. 2번부터 5번까지의 상태는 사용자가 직접 추적해야 합니다. 거래소마다 차이가 가장 큰 것도 이 구간입니다. 출금 상태를 나타내는 문자열이 거래소마다 다르고, 문서에 없는 값이 내려오기도 합니다. CCXT의 변환도 완전하지는 않습니다. CCXT 4.5.88의 Upbit 구현은 출금 상태 accepted를 ok로 바꾸는데, QTG는 같은 값을 아직 처리 중으로 봅니다. 변환 표에 없는 waiting 같은 값은 원본 문자열 그대로 내려옵니다. 트래블룰 때문에 출금 주소를 미리 등록해야 하는 거래소도 있고, 출금 주소 화이트리스트를 따로 관리해야 하는 거래소도 있습니다.
브릿지를 거치면 단계가 더 늘어납니다. Circle의 CCTP로 USDC를 다른 체인에 보내려면, 출발 체인에서 USDC를 burn하고 Circle의 attestation을 받은 뒤 도착 체인에서 mint해야 합니다. burn할 때 지정한 destination_caller와 실제로 mint를 호출하는 주소가 다르면 mint가 실패하고, 이미 burn된 USDC는 어느 체인에서도 되찾을 수 없습니다.
송금 수단마다 실패하는 방식도 다릅니다.
| 항목 | CEX 출금 | Bridge (Ex. CCTP) |
|---|---|---|
| 주로 실패하는 지점 | 거래소 정책 거절, 점검 | attestation 지연, destination_caller 불일치 |
| 결과를 알 수 없는 구간 | 출금 요청이 타임아웃된 뒤 | burn 후 attestation을 기다리는 동안 |
| 재시도 방식 | 출금 내역을 조회한 뒤에만 | burn은 재시도하지 않고 mint만 재시도 |
물론, 이 과정을 대신 처리해 주는 서비스도 있습니다. 기관용 커스터디 솔루션은 거래소 간 이체와 승인 정책을 함께 제공합니다. 하지만, 기관을 대상으로 한 서비스여서 개인이 쓰기에는 비용 부담이 컸습니다. 개인 트레이더나 에이전트 트레이딩을 해 보려는 사람, 작은 퀀트 팀은 직접 짠 스크립트와 기관용 솔루션 사이에서 마땅한 선택지를 찾기 어려웠습니다. 이런 사람들을 타겟해 QTG를 개발했습니다.
QTG는 셀프 호스트 방식이기 때문에 키를 대신 보관하지 않습니다. 키는 사용자의 KMS나 암호화된 keystore에 두고, 어떤 이체를 어디로 누구의 승인으로 허용할지 정하는 통제만 기관용 솔루션 수준으로 가져가려 했습니다.
QTG는 출금 요청부터 입금 확인까지의 과정 전체를 표준화합니다. 거래소와 브릿지마다 다른 부분은 각 단계의 구현 안에 숨기고, 밖에서는 같은 상태와 같은 이벤트로 보이게 했습니다.
예를 들어, CEX 출금 상태는 일곱 가지로 통일했습니다. 거래소 어댑터는 모두 같은 메서드를 구현하고, 거래소에서 받은 원본 값을 이 상태로 바꿔 돌려줍니다. 아래 코드는 이해를 돕기 위해 일부 메서드와 인자를 생략한 것입니다.
class WithdrawalState(StrEnum):
SUBMITTED = "SUBMITTED"
PROCESSING = "PROCESSING"
WAITING_CONFIRMATION = "WAITING_CONFIRMATION"
COMPLETED = "COMPLETED"
CANCELLED = "CANCELLED"
REJECTED = "REJECTED"
FAILED = "FAILED"
class TransferAdapterBase(ABC):
# 지금 이 자산과 네트워크로 출금할 수 있는지 확인
async def check_withdrawal_available(self, asset, network) -> WithdrawalChance: ...
async def submit_withdrawal(self, request) -> WithdrawalResult: ...
# 원본 상태를 WithdrawalState로 바꾸고 txid, confirmation 수와 함께 돌려줌
async def get_withdrawal_status(self, withdrawal_id) -> WithdrawalStatus: ...
async def get_deposit_status(self, deposit_id) -> DepositStatus: ...거래소별 원본 값은 다음과 같이 바뀝니다.
| QTG 상태 | Upbit | OKX | Bybit |
|---|---|---|---|
| SUBMITTED | submitting, submitted, waiting | 0 | Pending |
| PROCESSING | almost_accepted, accepted, processing | 1, 7, 8, 9, 10, 12 등 | SecurityCheck, MoreInformationRequired |
| COMPLETED | done | 2 | success, BlockchainConfirmed |
| CANCELLED | canceled | -2 | CancelByUser |
| REJECTED | rejected | 해당 없음 | Reject |
| FAILED | 해당 없음 | -1 | Fail |
Upbit 어댑터는 표에 없는 값이 내려오면 경고 로그를 남기고 PROCESSING으로 둡니다. 완료로 잘못 판단하면 도착 거래소의 입금 확인이 너무 일찍 시작되지만, 처리 중으로 두면 조회를 한 번 더 할 뿐이기 때문입니다. 송금 요청과 노드 단위의 상태는 뒤의 승인과 결과를 알 수 없는 상태 절에서 다룹니다.
CEX 출금은 거래소 API 키만 있으면 가능하지만, Circle의 CCTP처럼 블록체인에 직접 트랜잭션을 보내려면 개인키 서명이 필요합니다. 대부분, 초기 테스트 단계에서는 서버의 .env 파일에 개인키를 두고 프로세스가 읽어 쓰게 합니다.
하지만, AI의 발달로 공격 시나리오도 상당히 정교해지면서, 로컬 머신에 개인키를 평문으로 두는 방식의 문제가 점점 커지기 시작했습니다. 2025년 8월 npm에 올라온 Nx 빌드 도구의 악성 버전(s1ngularity 공격)이 대표적인 사례입니다. 이 악성 코드는 개발자 PC에 설치된 Claude, Gemini, Amazon Q의 CLI를 실행해 파일 시스템에서 암호화폐 지갑, .env 파일, SSH 키, GitHub 토큰을 찾게 했습니다. Wiz의 집계에 따르면 GitHub 계정 2,180개와 저장소 7,200개가 노출되었습니다.
그 뒤로도 비슷한 공격이 이어졌습니다.
| 시점 | 사건 | 노린 것 | 실행 시점 |
|---|---|---|---|
| 2025년 8월 | Nx 악성 버전(s1ngularity) | 지갑 파일, .env, SSH 키, GitHub 토큰 | 설치 직후 |
| 2025년 11월 | Shai-Hulud 2.0 | npm, GitHub 토큰, 클라우드 자격 증명 | 설치 전(preinstall) |
| 2026년 7월 | @injectivelabs/sdk-ts 1.20.21 | 지갑 개인키, 시드 문구 | 라이브러리가 키를 만들 때 |
| 2026년 7월 | Jscrambler 8.14.0 | 클라우드 자격 증명, Bitwarden 보관함, AI 코딩 도구 설정 파일 | 설치 전(preinstall) |
Shai-Hulud 2.0은 탈취한 maintainer 계정으로 감염된 패키지를 다시 배포하며 스스로 퍼졌고, 훔친 secret을 공개 GitHub 저장소에 올렸습니다. Injective 사례에서는 공격자가 SDK의 GitHub 저장소를 장악해, 정식 배포 파이프라인으로 18개 패키지에 악성 버전을 올렸습니다. 이 코드는 키 유도 함수 안에 숨어 있어서 설치할 때는 아무 일도 하지 않았고, 애플리케이션이 지갑 키를 만들 때 개인키와 시드 문구를 외부로 보냈습니다. 키를 다루는 코드 자체가 오염되면 키를 어디에 보관하든 소용이 없다는 뜻입니다.
코딩 에이전트가 .env를 읽는 것은 hook 같은 설정으로 어느 정도 막을 수 있습니다. 하지만, 의존성 공급망 공격은 개발자가 설치한 패키지 안에서 실행되고, 로컬 파일 시스템을 훑어 secret을 찾아내는 수법도 갈수록 정교해지고 있습니다. 개인키를 평문 파일로 디스크에 두는 한 이런 공격을 피하기 어렵다고 판단했습니다.
디스크에 평문을 남기지 않는 방법도 있습니다. 1Password CLI(op run, op read)를 쓰면 키를 vault에 두고, 프로세스가 시작될 때만 환경 변수로 주입받을 수 있습니다. 이렇게 하면 로컬 파일을 훑는 공격은 막을 수 있습니다. 하지만, 키가 프로세스 메모리에 올라온 뒤에는 같은 머신에서 실행되는 코드가 키를 읽어 갈 수 있습니다. 메모리 덤프나 /proc/<pid>/environ 조회가 그 예이고, Injective 사례처럼 의존성 안에서 실행되는 코드는 키를 쓰는 순간에 가로챌 수 있습니다. 그래서, 키는 별도 vault에 두고 애플리케이션은 서명 결과만 받아 쓰는 방식이 가장 안전하다고 판단했습니다.
QTG는 기본적으로 EVM 서명을 AWS KMS에 맡깁니다. 개인키는 KMS의 HSM 안에서 생성되고, 밖으로 나오지 않습니다. QTG의 서명 서비스는 키 ID만 가지고 있다가, 서명이 필요할 때마다 KMS API를 호출합니다. 모든 서명 요청은 CloudTrail에 기록되고, IAM 권한을 회수하면 곧바로 서명이 막힙니다. 또, secp256k1 키 하나로 여러 EVM 체인에서 같은 주소를 쓸 수 있어서 체인별로 키를 따로 관리하지 않아도 됩니다.
서명 방식에 따라 공격 시나리오별 위험은 다음과 같이 달라집니다.
| 공격 시나리오 | .env 평문 키 | 1Password로 주입한 키 | 암호화 keystore (별도 서명 서비스) | AWS KMS (별도 서명 서비스) |
|---|---|---|---|---|
| 로컬 파일 sweep | 키 유출 | 디스크에 키 없음 | 암호화된 파일만 유출 | 디스크에 키 없음 |
| 메모리 덤프 | 키 유출 | 키 유출 | 잠금을 푼 동안 서명 서비스 메모리에서 유출 | 메모리에 키 없음 |
| QTG 프로세스 안에서 코드 실행 | 키 유출 | 키 유출 | 접근이 유지되는 동안 서명 요청 가능 | 접근이 유지되는 동안 서명 요청 가능 |
| AWS 자격 증명 탈취 | 해당 없음 | 해당 없음 | 해당 없음 | 접근이 유지되는 동안 서명 요청 가능 |
KMS는 키 자체를 지켜 주지만, 서명 권한까지 지켜 주지는 않습니다. 표의 마지막 두 줄처럼 공격자가 접근을 유지하는 동안에는 서명을 요청할 수 있습니다. 다만, 그 요청은 모두 CloudTrail에 남고, IAM 권한을 회수하면 그 자리에서 끝납니다. 키가 통째로 유출되는 것보다는 훨씬 나은 위치이지만, 완전한 방어는 아닙니다.
암호화 keystore는 AWS를 쓰지 않는 로컬 배포를 위해 나중에 추가한 방식입니다. 디스크에는 암호화된 파일만 남아서 passphrase를 모르면 쓸 수 없지만, 서명 서비스가 잠금을 푼 동안에는 그 프로세스의 메모리에 키가 있습니다. 같은 호스트의 root나 Docker 관리자 권한을 가진 공격자는 이 메모리를 읽을 수 있고, CloudTrail 같은 외부 서명 기록이나 IAM 권한 회수 같은 수단도 없습니다. 그래서, 원격 서버나 여러 사람이 함께 쓰는 호스트에는 KMS를 권장합니다. 동작 방식은 뒤의 서명 서비스 분리에서 다룹니다.
서명 키 말고도 QTG가 다루는 secret이 있습니다. 각각이 유출됐을 때 공격자가 할 수 있는 일은 다음과 같습니다.
| 유출된 secret | 공격자가 할 수 있는 일 | 할 수 없는 일 |
|---|---|---|
| DB 자격 증명 또는 DB 파일 | 승인 상태, allowlist, 감사 기록을 모두 고칠 수 있음 | KEK 없이 거래소 API 키 읽기 |
| 거래소 API 키 | 내 키로 거래소 API 호출 | 거래소 쪽 IP 제한과 출금 주소 화이트리스트를 넘는 출금* |
| admin API 키 | 송금 승인, 템플릿과 서명 서비스, allowlist 관리 | KMS 안에 있는 키를 직접 읽기 |
| agent API 키 | 허용된 범위 안에서 송금 제안 | 승인, 권한 확장 |
* 거래소마다 지원 범위가 다릅니다. API 키에 IP 제한을 걸 수 있는지, 출금 주소 화이트리스트를 강제할 수 있는지는 거래소별로 확인해야 합니다.
이 secret들을 QTG에 전달하는 방식도 바꾸었습니다. 처음에는 다른 설정과 함께 .env나 환경 변수로 받았습니다. 하지만, 환경 변수는 자식 프로세스에 그대로 복사되고, docker inspect나 /proc/<pid>/environ으로도 읽힙니다. 그래서, DB 접속 정보, HMAC secret, 알림 봇 토큰 같은 런타임 secret은 권한이 0600인 파일로만 받게 했습니다. 환경 변수나 .env에 secret 값이 남아 있으면 QTG는 아예 시작하지 않습니다. 다만, 파일도 디스크에 평문으로 있다는 점은 같아서, secret manager를 대신하지는 않습니다.
DB 쓰기 권한이 넘어가면 QTG 안의 통제는 모두 무력해집니다. 거래소 API 키는 DB에 레코드별로 암호화해 저장하고, 암호화 키(KEK)는 배포 모드에 따라 로컬 파일이나 AWS KMS가 맡습니다. DB 덤프만으로는 거래소 키를 읽을 수 없습니다. 하지만, 요청마다 서명해야 하기 때문에 실행 중인 프로세스 메모리에는 평문으로 있을 수밖에 없습니다. 다만, 대부분의 거래소는 보안상 IP 화이트리스트나 출금 주소 화이트리스트를 API로 추가할 수 없게 해 두었습니다. 거래소 웹이나 앱에서 직접 등록해야 합니다. 그래서, 이 두 가지를 미리 설정해 두면, 거래소 API 키가 유출되더라도 등록하지 않은 주소로 자산이 빠져나가는 치명적인 사고로 이어지지는 않습니다. 서버가 완전히 장악된 뒤에도 작동하는 통제는 QTG 밖에 있습니다. 거래소의 IP 제한과 출금 주소 화이트리스트, 그리고 KMS 키 정책입니다. 그래서, QTG를 어디에 어떻게 배포하느냐가 설정 하나보다 더 중요한데, 이 내용은 뒤의 배포 구성과 보안 수준에서 다룹니다.
세 번째 이유는 송금 경로가 길어졌기 때문입니다. 거래소에서 출금해 온체인 지갑으로 받고, 브릿지로 다른 체인에 옮긴 다음, 다시 거래소나 DEX에 입금하는 식입니다. 단계마다 확인해야 할 것도 있습니다. 출금이 나갔는지, 체인에 기록됐는지, confirmation이 충분히 쌓였는지, 도착지에 반영됐는지 같은 것들입니다.
이런 확인 작업은 송금 수단이 달라도 성격이 비슷합니다. 외부에 무언가를 제출하고, 그 결과가 외부 시스템에 나타나기를 기다리는 것입니다. 그래서, 송금 한 건을 단계별 노드로 이루어진 DAG로 표현하고, 확인 작업을 몇 가지 유형으로 묶었습니다. 이렇게 하면 경로가 바뀌어도 그래프 모양만 바꾸면 되고, 노드의 동작은 다시 짤 필요가 없습니다.
QTG Core는 다음과 같이 구성됩니다.
EVM 서명은 Core 밖의 서명 서비스가 처리합니다. 모든 상태는 Postgres 한 곳에 저장하며, 메시지 브로커나 별도 스케줄러 프로세스는 두지 않았습니다. 이 결정은 뒤의 Airflow 구조를 참고한 스케줄링에서 다시 설명합니다.
CEX 간 송금은 노드 3개로 이루어집니다. 출금을 요청하고, 출금 상태를 확인해 txid를 얻고, 도착 거래소의 입금 내역에서 그 txid를 찾습니다.
CCTP 송금은 노드 5개로 이루어집니다. burn, attestation, mint 뒤에 받은 금액을 확인하는 노드와 finality를 확인하는 노드가 붙습니다.
각 노드는 executor가 처리합니다. Executor는 송금 수단에 관계없이 같은 메서드를 구현합니다. 아래 코드는 이해를 돕기 위해 인자를 줄인 것입니다.
class ExecutorProtocol(Protocol):
async def preflight(self, ctx) -> ExecutionResult | None: ... # 실행 전 점검
async def prepare(self, ctx) -> ExecutionResult: ... # 제출할 내용 준비
async def submit(self, ctx, prepared_action) -> ExecutionResult: ... # 외부에 제출
async def observe(self, ctx) -> ExecutionResult: ... # 결과 확인
async def recover(self, ctx) -> ExecutionResult: ... # 결과를 모를 때 대조preflight는 allowlist, 잔고, 한도를 확인합니다. 잔고 조회가 타임아웃되는 것처럼 확인 자체가 실패하면, 통과로 보지 않고 실패로 처리합니다(fail-closed). prepare와 submit을 나눈 이유는 서명 전에 제출할 내용을 해시로 고정하기 위해서입니다. prepare가 만든 payload와 실제로 서명할 payload의 해시가 다르면 서명 단계에서 거부됩니다.
출금이 나갔는지는 거래소 API로 확인하고, 그 이후의 확인 작업은 세 가지 probe로 묶었습니다.
class ChainReceiveProbe(Protocol): # 입금 감지: 이 txid가 도착지에 기록됐는가
async def check_receive(...): ...
class ChainFinalityProbe(Protocol): # finality: confirmation이 충분히 쌓였는가
async def check_finality(...): ...
class ProtocolProbe(Protocol): # 프로토콜 증명: CCTP attestation이 발급됐는가
async def check_proof(...): ...새 송금 수단을 추가할 때는 executor와 probe를 구현해 등록하기만 하면 됩니다. 기존 노드 코드는 건드리지 않습니다. 부팅할 때 설정에서 켠 Lane만 등록되므로, CEX만 쓰는 배포에는 브릿지 코드가 아예 올라가지 않습니다.
송금 요청 한 건은 택배 한 건과 비슷한 과정을 거칩니다. 접수하고, 내용을 검수하고, 승인을 받은 뒤, 포장해서 발송하고, 배송을 추적하다가, 수령을 확인하면 끝납니다.
송금 요청이 들어오면 plan compiler가 템플릿과 입력값으로 노드 그래프를 만들고 compiled_plan_hash를 기록합니다. 요청은 PENDING_APPROVAL 상태로 승인을 기다립니다. owner는 승인할 때 자신이 검토한 계획의 해시를 함께 보내야 하고, 이 값이 서버의 해시와 다르면 승인은 거절됩니다. 승인한 내용과 실행될 내용이 같은지 해시로 확인하는 셈입니다.
승인을 받았다고 바로 실행되지도 않습니다. Worker는 노드를 실행하기 직전에 계획의 해시를 다시 계산하고, allowlist도 다시 확인합니다. 그래서, 승인 후에 owner가 어떤 주소를 allowlist에서 빼면, 그 주소로 가는 다음 출금은 실행 직전에 멈춥니다.
Auto-approve는 템플릿 단위로 설정합니다. 다만, 에이전트가 보낸 요청은 auto-approve 대상이 아니며, 항상 사람의 승인을 거칩니다.
노드 그래프를 만들고 나니, 각 노드를 누가 언제 실행할지 정해야 했습니다. 이 부분은 데이터 엔지니어로 일하며 오래 써 온 Airflow의 구조를 참고했습니다. Airflow는 정해진 일정에 따라 데이터 파이프라인을 실행하는 도구이고, QTG는 송금 한 건의 상태를 끝까지 추적하는 도구여서 목적이 다릅니다. 그래서, 상태를 DB에 두고 실행할 작업을 DB에서 골라 가는 구조만 가져왔습니다.
Airflow는 DAG의 실행 상태를 메타데이터 DB에 저장하고, 스케줄러가 주기적으로 DB를 조회해 실행할 태스크를 고릅니다. 스케줄러를 여러 대 띄울 때도 스케줄러끼리 통신하거나 Raft 같은 합의 알고리즘을 쓰지 않고, DB의 행 잠금(SELECT ... FOR UPDATE)으로 조율합니다. Airflow 문서는 그 이유로 운영의 단순함을 듭니다. 어차피 모든 구성 요소가 이 DB와 통신하기 때문입니다. 다시 말해 DB를 공유 큐로 쓰는 셈입니다.
이 구조는 실행 요청을 바로 밀어 넣는 방식보다 느립니다. 스케줄러가 다음번에 DB를 조회할 때까지 기다려야 하기 때문입니다. 하지만, QTG에서는 이 지연이 문제가 되지 않았습니다. 블록체인의 시간은 연속적으로 흐르지 않고 블록 단위로 끊어집니다. 이더리움은 12초마다 블록이 만들어지고, finality까지는 약 13분이 걸립니다. CCTP 표준 전송은 attestation을 받는 데만 10분 이상 걸리고, CEX 출금은 거래소 심사에 따라 수 분에서 수 시간이 걸립니다. 이런 환경에서는 밀리초 단위로 이벤트를 감지할 일이 거의 없습니다. 몇 초 간격으로 조회해서 생기는 지연은 전체 송금 시간에 비하면 무시할 만합니다.
그래서, QTG는 Airflow의 구조는 가져오되, 스케줄러 프로세스는 따로 두지 않았습니다. Worker가 Postgres를 직접 조회해 작업을 가져갑니다.
| 항목 | Airflow | QTG |
|---|---|---|
| DAG 정의 | 스케줄러가 읽는 Python 파일 | 컴파일된 노드와 엣지 행 |
| 상태 저장 | 메타데이터 DB와 스케줄러의 메모리 큐 | Postgres |
| 작업 배분 | 스케줄러가 큐에 넣고 worker가 가져감 | Worker가 행을 직접 잠가 가져감 |
Worker는 역할별로 나뉩니다. node_dispatcher는 2초마다 실행할 노드를 찾아 제출하고, node_observer는 5초마다 제출된 노드의 결과를 확인합니다. node_recovery는 60초마다 결과를 알 수 없는 노드를 대조합니다. Observer가 노드를 가져갈 때 쓰는 쿼리를 단순화하면 다음과 같습니다.
SELECT n.id
FROM movement_request_nodes n
JOIN movement_requests r ON r.id = n.request_id
WHERE n.node_state IN ('SUBMITTED', 'OBSERVING')
AND r.request_state = 'EXECUTING'
AND (n.next_observe_at IS NULL OR n.next_observe_at <= NOW())
FOR UPDATE OF n, r SKIP LOCKED;SKIP LOCKED를 쓰면 다른 트랜잭션이 이미 잠근 행은 기다리지 않고 건너뜁니다. 그래서, worker 두 개가 동시에 조회해도 서로 다른 노드를 가져가고, worker가 죽으면 트랜잭션이 끝나면서 잠금도 함께 풀립니다. 처리량을 늘리고 싶으면 worker 프로세스를 더 띄우면 됩니다.
실제 구현에서는 잠금 없이 후보 ID를 먼저 뽑고, ID마다 조건을 다시 확인하면서 잠그는 두 단계로 나누어 처리합니다.
next_observe_at은 노드별로 다음 확인 시각을 저장하는 컬럼입니다. 몇 초면 끝나는 경로는 짧은 간격으로, 수십 분이 걸리는 브릿지는 긴 간격으로 확인합니다. 확인 간격을 데이터로 관리하기 때문에, Lane별로 간격을 바꿔도 worker 코드는 고칠 필요가 없습니다.
스케줄러를 따로 두지 않았기 때문에 송금 상태를 가진 시스템은 Postgres 하나뿐입니다. 스케줄러와 DB가 각자 상태를 가지고 있으면, 두 기록이 어긋났을 때 송금이 실제로 나갔는지 판단하기 어려워집니다. 대신, Airflow가 제공하는 UI, 일정 기반 실행, 다양한 오퍼레이터는 포기했습니다.
지금의 QTG는 정해진 간격으로 DB를 조회하는 폴링 방식입니다. Airflow도 3.0에서 외부 이벤트를 받아 바로 실행하는 이벤트 기반 스케줄링을 도입했는데, QTG도 같은 방향을 계획하고 있습니다. 노드 상태가 바뀔 때 Postgres의 LISTEN/NOTIFY로 worker를 깨우는 방식입니다. 아직 구현하지는 않았습니다. Circle의 Arc 테스트넷에서 노드 3개짜리 Circle Gateway 송금을 측정해 보니, 전체 13.0초 중 5.6초(43%)가 폴링을 기다리는 시간이었습니다. 블록 시간이 짧은 체인에서는 이 대기가 무시할 수 없는 비중이 됩니다. 다만, 지금은 폴링 간격이 거래소별 rate limit의 상한 역할도 하고 있어서, 우선순위와 리소스 풀 같은 장치를 먼저 갖춘 뒤 바꿀 계획입니다.
분산 시스템에서 외부 호출의 결과는 성공, 실패, 알 수 없음 세 가지입니다. 출금을 요청했는데 응답을 받기 전에 연결이 끊기면, 거래소가 요청을 처리했는지 알 수 없습니다. 이때 다시 요청하면 같은 금액이 두 번 나갈 수 있습니다.
QTG는 이 구간을 별도의 상태로 남깁니다. Worker는 외부에 무언가를 제출하기 전에 intent 행을 먼저 기록하고, 제출이 끝나면 결과를 기록합니다. 두 기록 사이에 worker가 죽으면 해당 노드는 UNKNOWN 상태가 됩니다. UNKNOWN 노드는 자동으로 다시 제출하지 않습니다. 대신, recovery worker가 거래소 출금 내역이나 체인을 조회해, 실제로 제출됐는지부터 확인합니다.
돈이 이미 나간 뒤에 생긴 실패는 따로 다룹니다. 예를 들어, CEX 송금에서 withdrawal_action은 완료됐는데, withdrawal_observe가 거래소 API 오류로 실패했다고 해 보겠습니다. 이 요청을 단순 실패로 처리하고 처음부터 다시 실행하면 출금이 한 번 더 나갑니다. 그래서, 부작용이 있는 노드(has_side_effect=true)가 완료된 뒤에 다른 노드가 실패하거나 결과를 알 수 없게 되면, 요청 전체를 MANUAL_INTERVENTION 상태로 보냅니다. 이때 자동 재시도는 멈추고 owner에게 알림이 갑니다. owner는 거래소 화면에서 상황을 확인한 뒤, 실행을 이어 가거나 완료 또는 실패로 확정합니다.
전체 상태와 전이 규칙은 State Machine 문서에서 볼 수 있습니다.
Core가 KMS를 직접 호출하게 만들 수도 있었습니다. 하지만, 그러면 공급망 공격으로 Core 프로세스 안에서 코드가 실행됐을 때 KMS 자격 증명까지 노출됩니다. 그래서, 서명은 별도의 서명 서비스에 맡기고, Core는 이 서비스에 HTTP로 서명을 요청하게 했습니다. Core는 프로세스 안에 서명 설정이 들어 있으면 아예 시작하지 않습니다.
if any(value not in (None, "") for value in local_values):
raise ValueError(
"qtg.main forbids in-process signer configuration; configure MG_REMOTE_SIGNER_*"
)KMS 키 ID와 AWS 자격 증명은 서명 서비스에만 있습니다. Core와 서명 서비스 사이의 요청은 HMAC과 일회용 nonce로 인증하며, 서명 서비스는 사용한 nonce를 Postgres에 기록해 같은 요청이 다시 들어오면 거부합니다. 서명 요청에는 payload와 함께 signing intent(자산, 수량, 수신 주소)와 payload 해시가 들어갑니다. Core는 서명 서비스가 알려 준 서명 주소가 미리 설정해 둔 주소와 같은지도 확인합니다.
Core는 서명을 요청하기 전에 서명 서비스가 준비된 상태인지 먼저 봅니다. 서명 서비스가 내려갔거나, 마지막 확인이 오래됐거나, 잠겨 있으면, 그 서명 서비스에 묶인 노드는 서명을 시도하지 않고 READY 상태로 기다립니다. 아직 아무것도 제출하지 않은 상태여서 UNKNOWN 노드가 생기지 않고, 다른 송금도 멈추지 않습니다. 반면, 인증 실패나 서명 주소 불일치처럼 기다려도 풀리지 않는 문제는 바로 노드를 실패로 처리합니다. 지갑 서명 승인 API도 같은 기준을 따르며, 서명 서비스가 준비되지 않았으면 job 상태를 바꾸지 않고 409 SIGNER_NOT_READY를 돌려줍니다.
서명 방식은 하나의 인터페이스(SignerProtocol)로 추상화했습니다. 처음에는 운영 환경에서 AWS KMS 구현을 쓰고, 로컬 개발에서는 .env나 1Password에서 키를 읽는 개발 전용 서명기를 쓸 수 있게 했습니다. Core는 이 서명기를 거부했지만, 실거래 점검 스크립트나 일회성 CLI 같은 호스트 도구는 프로세스 안에서 키를 읽어 서명할 수 있었습니다. 앞의 표에서 보았듯 이 방식은 키가 프로세스 메모리에 올라온다는 문제를 그대로 안고 있습니다. 그래서, 개발 전용 서명기를 없애고 호스트 도구도 AWS KMS로만 서명하게 바꾸었습니다.
대신, AWS를 쓰지 않는 로컬 배포를 위해 서명 서비스에 암호화 keystore 방식을 추가했습니다. keystore는 scrypt로 유도한 키로 개인키를 암호화한 Ethereum V3 형식의 파일입니다. 서명 서비스는 항상 잠긴(LOCKED) 상태로 시작하고, owner가 컨테이너 안에서 signer_control unlock을 실행해 passphrase를 직접 입력해야 서명을 시작합니다. 잠금 해제 요청은 컨테이너 안의 unix socket으로만 받고, HTTP로는 열어 두지 않았습니다. passphrase는 환경 변수, Compose 파일, 명령 인자, 로그 어디에도 남기지 않고, 키를 밖으로 꺼내는 API도 없습니다. 재시작하면 다시 잠기는데, 자동으로 잠금을 푸는 기능은 아직 없어서 owner가 매번 직접 풀어야 합니다. KMS와 같은 인터페이스에 구현을 추가하는 방식이어서, 나중에 다른 KMS를 써야 할 때도 같은 방법으로 넓힐 수 있습니다.
앞의 표에서 보았듯 QTG의 통제는 대부분 QTG 프로세스와 DB 안에서 동작합니다. 그래서, QTG를 어디서 운영하느냐가 설정 하나를 바꾸는 것보다 보안 수준에 더 큰 영향을 줍니다.
| 항목 | 개발용 노트북 | 전용 머신이나 VM | 클라우드 VM + IAM 역할 |
|---|---|---|---|
| 디스크의 평문 secret | secret 파일, AWS 키 파일 | secret 파일, AWS 키 파일 | secret 파일만 (AWS 자격 증명은 인스턴스 역할이 제공) |
| 공급망 공격 노출 | 높음. 같은 파일 시스템에서 npm install, pip install 실행 | 낮음 | 낮음 |
| AWS 자격 증명 형태 | 오래 유지되는 키 파일 | 오래 유지되는 키 파일 | 자동으로 교체되는 임시 자격 증명 |
| 패키지 하나가 오염됐을 때 피해 | QTG secret, AWS 자격 증명, 브라우저 세션, SSH 키 | QTG secret | QTG secret |
로컬 호스팅에는 분명한 한계가 있습니다. 패키지를 설치하고 브라우저를 여는 개발 머신에서 돈을 옮기면, 공급망 공격이 떨어지는 곳과 secret이 있는 곳이 같아집니다. 그래서, 실제 자금을 다룰 때는 다음과 같은 구성을 권장합니다.
qtg CLI로만 접근합니다.이렇게 구성하면 개발 머신에 공급망 공격이 떨어져도 서명 키와 거래소 키에는 닿기 어렵습니다. 서버가 장악되더라도 KMS 서명 기록은 CloudTrail에 남고, 거래소 쪽 통제는 그대로 유지됩니다. 개인이나 작은 팀이 운영하면서도 기관에 가까운 수준의 보안을 갖출 수 있는 구성이라고 생각합니다. 다만, tailnet에 연결된 모든 기기가 QTG에 닿을 수 있기 때문에, Tailscale ACL로 접근할 수 있는 기기를 좁혀 두어야 합니다.
QTG의 API 키에는 admin과 agent 두 가지 역할이 있습니다. admin은 사람인 owner가 쓰는 키로, 송금 승인과 거절, 템플릿, 서명 서비스, allowlist 관리를 모두 맡습니다. agent는 허용된 템플릿, 거래소, 자산, 금액 범위 안에서 송금을 제안만 할 수 있습니다. 처음에는 설정 권한 없이 승인만 하는 operator 역할도 있었습니다. 그런데 owner가 Passkey로 로그인해 admin 키 하나로 승인과 설정을 모두 처리하는 구조로 바뀌면서 operator 키를 발급할 일이 없어졌고, 지금은 admin과 agent 두 역할만 남겼습니다. 여러 사람이 나눠 승인하는 구조는 이후 과제로 남겨 두었습니다. 앞에서 설명했듯이 에이전트가 보낸 요청은 auto-approve되지 않습니다.
에이전트는 전용 CLI인 qtg-agent나 MCP 서버인 qtg-mcp로 QTG를 사용합니다. qtg-mcp는 시작할 때 whoami로 API 키의 역할과 네임스페이스를 확인하고, 설정과 다르면 실행되지 않습니다. 잘못된 키가 데스크톱 앱에 조용히 연결되는 일을 막기 위해서입니다.
QTG는 오픈 코어(open core) 방식으로 공개할 계획입니다. 기본 기능은 무료 버전(Free)에, 일부 기능은 유료 버전(Pro)에 넣습니다.
| 구분 | Free | Pro |
|---|---|---|
| 송금 통제 | 송금 그래프, 승인, 감사 기록, allowlist | Free와 같은 통제를 그대로 사용 |
| 거래소 | Upbit, Binance, Coinbase, Bybit, OKX | Bithumb, Backpack, Lighter |
| 브릿지와 체인 | CCTP, Circle Gateway, Hyperliquid | CCIP, USDT0, Stargate |
| 운영 기능 | 템플릿 단위 auto-approve 한도 | 예산 원장 기반 auto-approve, 서명 키 교체와 폐기 관리, 고객 클라우드 배포 패키지 |
승인, allowlist, 감사 기록 같은 안전 장치는 모두 Free에 들어 있습니다. 무료 버전을 쓴다고 송금이 덜 안전해지면 안 된다고 생각했기 때문입니다. Pro에는 트레이딩 편의성을 높이는 기능을 넣습니다. 쓸 수 있는 거래소와 브릿지를 늘리고, 예산 안에서 자동으로 승인하는 기능처럼 운영 부담을 줄여 주는 기능들입니다.
가격은 아직 정하지 않았지만 연간 구독 방식을 생각하고 있습니다. 서명을 AWS KMS에 맡기는 구조인 만큼, 장기적으로는 AWS Marketplace에서 바로 설치해 쓸 수 있게 만드는 것이 목표입니다.
QTG는 주문 API처럼 입출금도 표준화할 수 없을까 하는 질문에서 출발했습니다. 송금을 노드 그래프와 probe로 나눠 거래소와 브릿지 간 차이를 감췄고, 개인키는 KMS와 별도 서명 서비스 뒤로 옮겼습니다. 또, 블록 생성과 거래소 심사라는 느린 시간 단위에 맞춰, Airflow처럼 DB를 조회하는 worker 구조를 택했습니다. QTG는 아직 개발 중이며, 공개 후 운영하면서 배운 점은 따로 정리하겠습니다.
외부 시스템의 느린 이벤트를 기다리는 서비스를 만든다면, 메시지 브로커를 도입하기 전에 DB를 조회하는 worker만으로 충분한지 먼저 따져 보시길 권합니다.
댓글