Oluwa.io
모든 글
·읽기 5분엔지니어링암호화폐신뢰성트레이딩

신뢰할 수 있는 암호화폐 주문 체결 시스템 만들기

암호화폐 주문 체결의 신뢰성은 상태 머신, 멱등성 키, 그리고 끊임없는 정합성 검증에서 나온다. MadaiOps를 구축하며 얻은 교훈.

글쓴이 Vitor Lima

대부분의 암호화폐 트레이딩 제품은 속도와 차트를 판다. 어려운 부분은 캔들을 그리는 것이 아니다. 매 밀리초마다, 겉보기엔 단순한 질문에 답할 수 있는 것이야말로 어려운 부분이다. 지금 이 순간 내 주문의 진짜 상태는 무엇인가? 우리가 사내 주문 및 트레이딩 운영 앱인 MadaiOps를 구축하기 시작했을 때, 그 질문이 모든 것을 조직했다. 속도는 기능이다. 진실은 제품이다. 암호화폐 주문 체결의 신뢰성이란, 거래소가 성능 저하를 겪는 중에도 앱이 그 질문에 정직하게 답할 수 있을 때 확보되는 것이다. 그리고 일어나지 않은 체결을 보여주거나 실제로 일어난 체결을 숨기는 앱은, 그 어떤 지연 시간 개선으로도 되살 수 없는 방식으로 신뢰를 파괴한다.

이 글은 주문 앱을 신뢰할 수 있게 만드는 엔지니어링에 관한 것이다. 상태 머신, 멱등성, WebSocket을 통한 정합성 검증, 그리고 부하가 걸릴 때에만 드러나는 실패 모드들. 우리는 이 모든 것에 대해 확고한 견해를 가지고 있다. 지루한 선택들이야말로 당신을 파산하지 않게 지켜주는 것이기 때문이다.

주문을 명시적인 상태 머신으로 모델링하라

우리가 내린 가장 훌륭한 결정 하나는, 주문을 UPDATE하는 하나의 행(row)으로 취급하는 것을 멈추고, 이름이 부여되고 열거된 전이(transition)를 가진 유한 상태 머신으로 취급하기 시작한 것이다. 주문은 PENDING_NEW, WORKING, PARTIALLY_FILLED, FILLED, CANCEL_REQUESTED, CANCELED, REJECTED, EXPIRED 같은 상태 안에서 살아간다. 모든 전이는 현재 상태와 들어오는 이벤트의 함수이며, 불법적인 전이는 조용히 흡수되는 대신 요란하게 거부된다.

그 보상은 이렇다. 모호함이 새벽 2시의 장애가 아니라 검증 시점의 관심사가 된다. FILLED 주문은 결코 WORKING으로 돌아갈 수 없다. CANCELED 주문에 도착한 체결 이벤트는 "불가능한" 것이 아니라 실제 경합(race)이며, 상태 머신은 그것이 무엇을 의미하는지 당신이 결정하도록 강제한다. 보통 그것은 취소가 경합에서 졌고, 주문이 실제로 체결되었으며, 당신의 로컬 CANCELED가 틀렸다는 뜻이다. 주문을 가변적인 행으로 모델링하면 그 이벤트는 그냥 무언가를 덮어써 버린다. 정합성 검증이 — 혹은 사용자가 — 그것을 잡아내기 전까지 당신은 결코 알아차리지 못한다.

핵심 정리

API 호출을 한 줄이라도 작성하기 전에 상태를 열거하라. 화이트보드에 다이어그램을 그릴 수 없다면, 당신은 아직 자신의 도메인을 이해하지 못한 것이고, 거래소가 혹독하게 그것을 가르쳐줄 것이다. 현재 상태만이 아니라 전이를 저장하라. 무슨 일이 일어났는지를 담은 추가 전용(append-only) 이벤트 로그가 하나의 가변 상태 컬럼을 이긴다. 로그는 재생 가능하지만, 컬럼은 추측일 뿐이기 때문이다.

멱등성은 선택 사항이 아니다

네트워크는 재시도한다. 당신 자신의 재시도 로직도 재시도한다. 사용자는 "매수"를 두 번 탭한다. 이 중 무엇이든 같은 주문을 두 번 보낼 수 있고, 거래소에서 중복 주문은 실제 돈이다. 방어책은 클라이언트가 생성한 멱등성 키다. 대부분의 거래소는 이것을 clientOrderId 또는 newClientOrderId라고 부른다. 이 키는 사용자가 주문을 확정하는 순간, 요청이 기기나 서버를 떠나기도 전에 모든 주문에 부착된다.

우리가 강제하는 규칙은 이렇다. 키는 한 번만 생성되고, 네트워크 호출 전에 로컬에 영속화되며, 모든 재시도에서 글자 그대로 재사용된다. 첫 시도가 응답 없이 타임아웃되면, 재시도는 같은 키를 가지고 가고, 거래소는 주문을 한 번만 생성하거나 기존 주문을 반환한다. 어느 쪽이든 당신은 하나의 주문으로 수렴한다. 실패 후에 키를 생성하면, 당신은 중복 주문 생성기를 만든 셈이다.

이것은 취소와 수정에도 확장된다. 취소 요청도 멱등적이어야 한다. 이미 취소된 주문을 취소하는 것은, 또 다른 재시도 루프를 촉발하는 에러가 아니라, 성공을 반환하는 무연산(no-op)이다. 우리는 "원하는 최종 상태가 이미 참"인 경우를 어디서나 성공으로 취급한다. 사소하게 들린다. 그러나 이것은 재시도 폭풍(retry storm)이라는 하나의 범주 전체를 제거한다.

핵심 정리

멱등성 키는 성공 후가 아니라 행동하기 전에 영속화하라. "보냈다"와 "작동한 것을 안다" 사이의 틈이 바로 돈이 사라지는 지점이며, 그 틈을 닫는 유일한 것이 키다.

끊임없이 정합성을 맞추라: WebSocket은 힌트이지 진실이 아니다

거래소는 WebSocket을 통해 주문 업데이트를 푸시하며, 잘 작동할 때는 훌륭하다. 1초 미만의 체결, 실시간 부분 체결 수량, 취소 확인. 그러나 WebSocket은 당신이 놓칠 수 있는 스트림이다. 당신은 반드시 연결이 끊긴다. 메시지는 순서가 뒤바뀌어 도착하거나, 재연결 중에 유실되거나, 거래소가 성능 저하 상태라서 아예 오지 않는다. 당신의 주문 상태가 오직 소켓이 마지막으로 알려준 것뿐이라면, 소켓이 딸꾹질하는 순간 당신의 앱은 확신에 차서 틀린다.

그래서 우리는 두 개의 루프를 돌린다. 빠른 루프는 WebSocket을 소비하고 이벤트를 낙관적으로 적용한다. 느린 루프는 REST API — GET /order와 미체결 주문 엔드포인트 — 를 일정 간격마다, 그리고 모든 재연결 후에 폴링한 다음, 그 스냅샷을 로컬 상태와 대조해 정합성을 맞춘다. REST가 진실의 원천이고, 소켓은 저지연 힌트다. 둘이 어긋나면 REST가 이기고, 우리는 그 불일치를 로깅한다. 불일치란 시스템이 당신을 위해 작성해 준 버그 리포트이기 때문이다.

순서가 중요하다. 대부분의 거래소는 업데이트에 단조 증가하는 시퀀스 번호나 updateTime을 찍는다. 우리는 해당 주문에 대해 마지막으로 적용된 것보다 오래된 이벤트는 모두 버리는데, 이렇게 하면 순서가 뒤바뀐 전달이 무해해진다. 재연결 시 올바른 수순은 이렇다. 재구독하고, 즉시 전체 REST 스냅샷을 가져와 공백을 메운 다음, 실시간 스트림을 다시 신뢰하는 것이다. 스냅샷을 건너뛰면 오프라인이었던 2초 동안 일어난 체결을 놓친다.

핵심 정리

실시간 피드가 상태의 유일한 기록자가 되게 하지 마라. 소켓은 당신을 빠르게 만들고, 정합성 루프는 당신을 정확하게 만든다. 둘 다 필요하며, 둘이 충돌할 때는 매번 정확함이 빠름을 이긴다.

부분 체결과 연결 끊김: 당신을 드러내는 두 가지

부분 체결은 순진한 앱이 조용히 거짓말하는 지점이다. 1.0 BTC 주문이 0.3, 그다음 0.4를 체결하고 멈춰버리면, 그것은 "미체결"도 아니고 "완료"도 아니다. 0.7이 체결되고 0.3이 남은 PARTIALLY_FILLED이며, 이 숫자들 각각은 하나의 상태 필드에서 추론되는 것이 아니라 개별 체결 이벤트로부터 추적되어야 한다. 우리는 개별 체결들로부터 체결 수량과 거래량 가중 평균 가격(VWAP)을 누적하므로, 포지션은 언제나 기본 요소들로부터 재구성 가능하다. "체결됨"을 보는 사용자는 얼마나, 어떤 평균 가격에 체결되었는지 알아야 한다. 그보다 못한 것은 절반의 진실이다.

연결 끊김은 이를 가중시킨다. 체결 도중에 오프라인이 되면 WebSocket은 그 0.4에 대해 결코 알려주지 않았으므로 당신의 로컬 상태는 낡은 것이 된다. 재연결-후-스냅샷 원칙이 존재하는 이유가 바로 이것이다. REST 스냅샷은 누적 체결 수량을 반환하고, 정합성 루프는 사용자가 틀린 숫자를 결코 보지 않은 채로 공백을 메운다. 우리가 대비해 설계하는 실패 모드는 "앱이 크래시했다"가 아니다. 크래시는 정직하다. 그것은 "앱이 계속 돌아가면서 거짓을 보여줬다"이다.

핵심 정리

누적 체결 수량을 일차적 사실로 추적하고 나머지는 모두 그로부터 도출하라. "남은 수량"을 당신이 변경하는 독립적인 필드로 저장하지 마라. 그것을 도출하라(주문 − 체결). 그래야 현실과 어긋나 표류할 수 없다.

압박 속에서 진실한 것이 영리한 것을 이긴다

영리하고 싶은 끈질긴 유혹이 있다. 확인 전에 체결을 예측하고, "일시적인" 에러를 숨기고, UI가 결코 깜빡이지 않도록 재연결을 매끄럽게 덮는 것. 우리는 그 대부분에 저항하는 법을 배웠다. 트레이딩 운영자는 매끄러운 거짓말을 원하지 않는다. 그들은 연결이 방금 끊겼고 상태가 정합성을 맞추는 중이라는 것을, 정직하게 알고 싶어 한다. "재연결 중, 주문 확인 중"이라는 눈에 보이는 상태는, 알고 보니 어제의 진실을 보여주고 있던 매끄러운 UI보다 더 가치 있다.

구체적으로, 우리는 불확실성을 덮는 대신 드러낸다. 로컬 상태와 거래소가 최근에 정합성을 맞추지 못했으면, 앱은 그렇다고 말한다. 주문이 CANCEL_REQUESTED이지만 아직 취소가 확인되지 않았으면, 앱은 성급한 CANCELED가 아니라 정확히 그 상태를 보여준다. 낙관적 UI는 할 일 목록 앱에는 괜찮다. 돈에 대해서는 낙관은 지원 티켓과 잃어버린 신뢰로 대가를 치르는 부채다.

핵심 정리

의심스러울 때는 의심을 보여줘라. 스트레스 상황에서 금융 앱이 할 수 있는 가장 안심되는 일은 추측하기를 거부하는 것이다.

맺음말

이 중 어느 것도 화려하지 않다. "우리의 멱등성 키는 네트워크 호출 전에 영속화됩니다"에 박수가 터지는 데모는 없다. 그러나 신뢰성은 나중에 추가하는 기능이 아니다. 그것은 일찌감치 내리고 타협하기를 거부하는 결정들의 집합이며, 지름길이 유혹적으로 보일 때마다 방어하는 결정들이다. 모든 것이 불타는 와중에도 진실을 말하는 시스템을 만들어라. 그러면 빠르고 예쁜 부분들은 알아서 잘 굴러간다.