Claude Code로 거래소 만들기(5) — 조용히 돈이 새는 곳들
거래소를 만들면서 가장 무서웠던 건 서버가 죽는 버그가 아니었습니다. 죽으면 알람이 오고, 고치면 됩니다.
무서운 건 아무 오류도 내지 않고 조용히 틀리는 것이었습니다. 서비스는 전부 초록불이고 화면도 잘 나오는데, 어딘가에서 이용자의 돈이 묶여 있습니다. 로그를 아무리 봐도 에러가 없습니다. 그런 걸 다섯 개쯤 찾았고, 이 글은 그 기록입니다.
구조도를 곁들여 두는 편이 읽기 쉬울 것 같아 포트폴리오의 케이스 스터디에 배포 구성 · 체결 흐름 · 시세 파이프라인 세 장을 올려뒀습니다. 이 글과 같은 코드를 근거로 그린 것들입니다.
1. 오더북은 메모리에만 있다
매칭 엔진의 오더북은 BTreeMap 두 개입니다. 디스크에 쓰지 않습니다. 그게 빠른 이유고, 동시에 문제입니다.
엔진이 죽으면 오더북은 통째로 사라집니다. 그런데 DB의 주문은 그대로 “거래중”으로 남아 있습니다. 엔진은 주문 테이블을 한 번도 읽지 않기 때문에, 그 주문들은 어느 오더북에도 존재하지 않는 유령이 됩니다. 체결될 수도 없고, 취소를 눌러도 엔진이 모르는 주문이라 취소 확정이 돌아오지 않습니다. 동결된 자금은 영영 풀리지 않습니다.
얼마나 심각한지 재봤습니다. 재기동 80분 뒤, 미체결 주문의 94~98%가 재기동 이전에 들어온 것들이었습니다. 즉 화면에 보이는 미체결 대부분이 이미 죽은 주문이었습니다.
고친 방법은 단순합니다. 기동할 때 심볼별로 “거래중”인 지정가 주문을 시간 순으로 읽어 오더북에 되돌립니다. 시장가는 애초에 오더북에 놓이지 않으니 대상이 아닙니다.
한 가지 결정이 필요했습니다. 되돌리면서 매칭을 돌릴 것인가. 돌리지 않기로 했습니다. 복원은 “죽기 직전의 상태로 되돌리는 것”이지 “그동안 못 한 체결을 몰아서 하는 것”이 아니기 때문입니다. 몰아서 체결하면 그 순간 가격이 튀고, 그건 이용자가 주문을 넣던 시점에 동의한 적 없는 체결입니다.
상한도 뒀습니다. 다만 넘친 만큼을 조용히 넘기지 않고 건수를 로그에 남깁니다. 상한에 걸린다는 건 그 자체가 정리가 필요하다는 신호니까요.
2. 체결마다 트랜잭션을 열면 따라잡지 못한다
체결 한 건을 정산하는 데 드는 일은 이렇습니다. 중복 방지용 INSERT 한 번, 주문 조회와 갱신 두 번씩, 지갑 갱신 최대 네 번, 그리고 커밋.
이 중 압도적으로 비싼 건 커밋의 디스크 동기화입니다. 그리고 그 비용은 체결 건수에 정비례합니다.
실측했습니다. Kafka 파티션당 초당 30건 언저리를 처리했습니다. 그런데 마켓메이킹 봇이 만들어내는 체결은 초당 39건이었습니다. 9건씩 매초 밀립니다. 격차는 줄어들 일이 없으니 계속 벌어졌고, 그 줄 뒤에 선 이용자 주문은 몇 시간 뒤에야 반영됐습니다.
여기서 흥미로운 건, 이게 에러로 드러나지 않는다는 점입니다. 처리는 정상적으로 되고 있습니다. 그냥 느릴 뿐입니다. 모니터링에 “지연”이라는 지표를 따로 두지 않으면 영영 모릅니다.
묶어서 처리하도록 바꿨습니다. 커밋이 건수만큼이 아니라 묶음 수만큼만 일어납니다. 같은 주문에 대한 체결이 여러 건이면 갱신도 한 번으로 합쳐집니다.
왜 심볼로 묶는가가 핵심입니다. Kafka 파티션 키가 심볼이고 컨슈머가 파티션별 고루틴으로 돌기 때문에, 한 심볼의 메시지는 언제나 한 고루틴이 순서대로 처리합니다. 심볼로 묶으면 묶음 하나를 건드리는 고루틴이 하나뿐이라 순서가 뒤집히지 않습니다. 파티셔닝 키를 그냥 분산용으로 고른 게 아니라, 그게 곧 순서 보장 단위가 되도록 고른 것이 여기서 값을 합니다.
묶음 크기는 200건, 대기 시간은 200ms로 잡았습니다. 크게 잡을수록 커밋당 이득은 커지지만 한 트랜잭션이 잠그는 주문 행도 늘고 실패했을 때 되돌릴 양도 커집니다. 그리고 거래가 뜸한 심볼의 체결이 묶음이 찰 때까지 붙잡혀 있으면 안 되니 대기 상한이 필요합니다.
3. 오프셋은 “처리했는가”가 아니라 “꺼냈는가”로 전진한다
이게 가장 오래 못 찾은 것입니다.
컨슈머가 자동 커밋으로 돕니다. 오프셋은 5초 타이머로 전진하는데, 그 기준은 “처리를 끝냈는가”가 아니라 “ReadMessage가 꺼냈는가” 입니다.
꺼낸 메시지는 곧바로 처리되지 않습니다. 파티션별 레인 큐(최대 256)에 들어가고, 거기서 꺼내져도 앞서 말한 정산 묶음에 다시 쌓입니다. 그 두 단계에 앉아 있는 동안 프로세스가 죽으면 — 강제 종료든, OOM이든, 배포 중 컨테이너 재생성이든 — 그 체결은 영영 오지 않습니다. 오프셋은 이미 지나갔기 때문입니다.
돈으로는 이렇게 나타납니다. 엔진은 체결시켰고 상대방은 정산됐는데, 이쪽 주문은 미체결로 남고 동결도 풀리지 않습니다. 그리고 아무 오류도 나지 않습니다.
얼마나 새는지 재봤습니다. 2시간 동안 214,482건 중 8건이 그렇게 사라져 있었습니다. 0.004%입니다. 그리고 사라진 시각은 그날 컨테이너를 재시작한 시점에 뭉쳐 있었습니다. 원인이 딱 맞아떨어지는 순간이었습니다.
0.004%는 작아 보입니다. 그런데 이건 확률이 아니라 재시작할 때마다 반드시 일어나는 일입니다. 그리고 사라진 8건은 각각 누군가의 묶인 돈입니다.
4. 되찾는 법 — 두 소비자의 차집합
정석대로라면 오프셋을 처리 뒤에 손으로 커밋해야 합니다. 그러면 창 자체가 닫힙니다.
그런데 그 전에 이미 쓸 수 있는 게 있었습니다. 같은 체결 토픽을 market 서비스가 따로 소비해 별도 테이블에 적고 있었습니다. 컨슈머 그룹이 다르므로 오프셋도 따로 움직입니다. 둘이 같은 메시지를 함께 잃을 일은 드뭅니다.
그러면 이렇게 됩니다.
market 쪽에는 있는데 정산 쪽에는 없는 체결 = 정산이 놓친 것
찾으면 원래 정산 경로로 다시 흘려보냅니다. 중복 방지 키가 있으므로 이미 정산된 걸 다시 넣어도 두 번 반영되지 않습니다. 그래서 판정이 조금 헐거워도 안전합니다 — 놓치지 않은 것을 놓쳤다고 잘못 판단해도 손해가 없습니다. 이런 성질이 있으면 복구 장치를 훨씬 과감하게 만들 수 있습니다.
한계는 정직하게 적어둡니다. market까지 같은 메시지를 잃으면 어느 쪽에도 흔적이 없어 되찾을 수 없습니다. 여기서 하는 일은 유실의 창을 닫는 게 아니라, 창이 열렸을 때 스스로 메우는 것입니다.
그리고 한 건이라도 되찾으면 반드시 로그에 남깁니다. 되찾았다는 건 곧 유실이 일어났다는 뜻이니까요. 복구 장치가 조용히 잘 돌면 문제가 없어진 것처럼 보이는데, 그게 제일 위험합니다.
5. 원인을 못 찾아도 돈을 묶어둘 수는 없다
체결량이 주문량에 이르렀는데 상태가 계속 “거래중”으로 남는 주문이 나옵니다. 그 주문은 매칭엔진에는 없습니다. 화면에는 미체결로 계속 보이고, 취소를 눌러도 엔진이 모르니 취소 확정이 오지 않습니다. 동결된 돈이 영원히 안 풀린다는 뜻입니다.
원인 하나는 찾았습니다. 취소로 닫힌 주문에 체결이 늦게 도착하면 정산이 그걸 되살려 “거래중”으로 되돌려놨습니다. 가드를 넣어 고쳤습니다.
그런데 그 뒤로도 적은 수가 계속 남았습니다. 나머지 원인은 아직 못 찾았습니다.
여기서 판단이 필요했습니다. 원인을 못 찾았다고 해서 이용자 돈을 묶어둔 채로 둘 수는 없습니다. 그래서 거두는 작업을 따로 뒀습니다.
왜 이렇게 해도 안전한가를 설명할 수 있어야 넣을 수 있습니다. 대상은 “체결량이 이미 주문량에 이른” 주문뿐입니다. 그런 주문은 엔진이 더 채울 수 없습니다 — 남은 수량이 없기 때문입니다. 그래서 여기서 닫아도 뒤늦게 오는 체결을 잃을 일이 없습니다.
거기에 유예를 뒀습니다. 방금 체결된 주문은 정산이 아직 묶음 안에 들고 있을 수 있습니다. 그 사이에 끼어들면 우리가 먼저 닫고 정산이 그걸 “끝난 주문”으로 보고 버립니다. 없던 문제를 만드는 셈입니다.
이것도 한 건이라도 거두면 로그에 남깁니다. 거둘 게 있다는 건 위쪽에 아직 버그가 있다는 뜻입니다.
6. 차트 거래량이 통째로 작았던 이유
이건 돈이 새는 건 아니지만, 같은 종류의 조용한 오류라 같이 적습니다.
1분봉을 30초마다 집계합니다. 그런데 집계 시작점을 분 경계로 내리지 않았습니다.
09:25:37에 돌면 시작점이 09:15:37이 됩니다. 그러면 09:15 봉은 37초부터의 체결만 합산된 값이 나오는데, 그 값이 ON DUPLICATE KEY UPDATE로 정상값 위에 덮어써집니다.
30초마다 도니 경계가 계속 앞으로 쓸려가며 지나간 분마다 이 일이 생겼습니다. 실측으로 저장된 볼륨이 실제의 0.9~17% 였고, 역산한 오프셋이 50~59초라 각 봉에 마지막 1~10초치만 남아 있었습니다. 15분봉·1시간봉은 이 값을 더해 만드니 차트 거래량이 통째로 작게 나왔습니다.
증상이 헷갈렸던 이유가 있습니다. 1분봉은 조회할 때 최근 구간을 체결에서 다시 계산하는 경로가 있어서 최근 몇 분만 맞고 그 앞은 틀렸습니다. 화면을 열어보면 지금은 맞아 보입니다.
같은 종류의 실수가 조회 쪽에도 있었습니다. 요청 시작점이 봉 한가운데면 첫 봉이 반만 담긴 채 나갑니다. 시가와 거래량이 실제보다 작게 찍히는데 차트에는 온전한 봉처럼 보입니다. 경계로 내려 잡아 고쳤습니다.
시간을 다루는 코드에서 경계 처리는 거의 항상 이런 식으로 틀립니다. 그리고 거의 항상 조용합니다.
7. 매칭 엔진은 어떻게 생겼나
정합성 이야기만 하다 보니 정작 엔진 이야기를 안 했네요. 짧게 적습니다.
오더북은 가격-시간 우선순위를 자료구조로 그대로 표현합니다.
- 매수:
BTreeMap<Reverse<Decimal>, VecDeque<Order>>— 높은 가격 우선 - 매도:
BTreeMap<Decimal, VecDeque<Order>>— 낮은 가격 우선 - 같은 가격 레벨 안에서는
VecDeque로 먼저 온 순서
금액은 전부 rust_decimal입니다. 부동소수점으로 돈 계산을 하면 언젠가 반드시 사고가 납니다.
동시성은 심볼마다 전용 OS 스레드 하나입니다. tokio 태스크가 아닙니다. 주문은 크기 4096짜리 채널로 그 스레드에 들어갑니다. 이렇게 두면 오더북에 락이 필요 없습니다 — 건드리는 스레드가 하나뿐이니까요. 그리고 한 심볼 안에서 순서가 뒤집힐 수 없습니다.
두 가지 세부가 재미있었습니다.
시장가 잔량 처리. 시장가는 미체결 잔량을 오더북에 넣지 않고 취소합니다. 그런데 그 취소 사실을 호출자에게 알려야 합니다. 안 알리면 DB 주문은 영원히 “거래중”이고 동결 자금도 잠깁니다. 앞에서 본 유령 주문과 정확히 같은 모양의 버그입니다.
자기거래 방지에 예외를 둔 것. 같은 사람의 매수·매도가 서로 체결되는 걸 막아야 하는데, 마켓메이킹 봇은 자전거래로 거래량과 봉을 만드는 게 일입니다. 그래서 주문에 출처를 실어 보내 봇은 예외로 둡니다. 기본값은 “고객”이라 출처가 빠진 주문은 안전한 쪽으로 떨어집니다.
관통하는 것
여섯 개를 다시 보면 모양이 같습니다.
조용히 틀리는 것이 시끄럽게 죽는 것보다 위험합니다. 배포 스크립트가 초록불을 내는 조건을 “명령이 에러 없이 끝났다”에서 “이게 있어야 서비스가 성립한다”로 바꾼 것도 같은 이유입니다.
복구 장치는 스스로가 알람이어야 합니다. 되찾은 체결이 0건이 아니면, 거둔 주문이 0건이 아니면, 그건 위쪽에 버그가 남아 있다는 신호입니다. 조용히 잘 도는 복구 장치는 문제를 감춥니다.
안전한 이유를 말로 설명할 수 있을 때만 자동으로 고칩니다. “더 채울 수 없는 주문만 고른다”, “중복 방지 키가 있으니 두 번 넣어도 된다” — 이런 근거가 있으면 과감하게 자동화할 수 있고, 없으면 사람이 봐야 합니다.
원인을 못 찾은 것도 그대로 적어둡니다. 5번의 남은 원인은 아직 모릅니다. 모르는 걸 아는 척하는 것보다, 모른다고 적어두고 증상을 막아두는 편이 다음 사람에게 낫습니다. 그 다음 사람은 대개 6개월 뒤의 저입니다.
여기까지가 거래소를 만들며 배운 것 중 코드보다 오래 남을 것 같은 부분입니다. Rust로 오더북을 짜는 법은 검색하면 나오지만, 오프셋이 언제 전진하는지 몰라서 8건을 잃어보는 경험은 검색으로 얻어지지 않았습니다.
exchange.agentmichael.me에서 돌아가는 걸 볼 수 있습니다. 지난 글들은 3편 — Go+Rust 재플랫폼, 4편 — EC2 한 대로 다시 올리기에 있습니다.