LabHub
배우기 러닝패스 코스

자본시장과 결제 · 장애 뒤 재처리에서 주문을 두 번 내지 않기 · 이론

다시 읽는 일은 피할 수 없다 — 두 번 내보내는 일을 막을 뿐이다

LabHub 에서 이어서 보기

한 줄 요약

프로세스가 죽었다 살아나면 어디부터 다시 읽을지 고르게 되는데, 너무 앞에서 읽으면 주문이 두 번 나가고 너무 뒤에서 읽으면 주문이 빠진다. 이 둘 사이에는 안전한 지점이 없다. 그래서 답은 "정확히 한 번 읽기" 가 아니라 다시 읽더라도 같은 주문 id 가 나오게 만들고, 두 번째를 받는 쪽이 걸러 내게 하는 것이다.

왜 이게 필요했나

주문을 내보내는 프로그램은 대개 이런 모양이다. 신호 스트림에서 한 건을 읽고, 주문 메시지를 만들어 보내고, 어디까지 처리했는지를 체크포인트에 적는다. 세 동작 사이에 프로세스가 죽을 수 있다는 것이 문제의 전부다.

체크포인트를 보낸 뒤에 적으면, 보내고 나서 적기 전에 죽었을 때 그 구간을 다시 읽는다. 주문이 다시 나간다 — at-least-once 다. 체크포인트를 보내기 전에 적으면, 적고 나서 보내기 전에 죽었을 때 그 구간은 영영 읽히지 않는다. 주문이 빠진다 — at-most-once 다. 게다가 체크포인트는 보통 한 건마다 저장하지 않고 몇 건씩 묶어 저장한다. 그래서 겹치는 구간도 빠지는 구간도 한 건이 아니라 묶음 크기만큼 벌어진다.

주문에서 이 둘의 무게는 같지 않다. 빠진 주문은 다시 내면 되지만, 두 번 나간 주문은 이미 체결됐을 수 있고 그러면 원하지 않은 포지션이 생긴다. 그래서 전송 경로는 보통 at-least-once 를 고르고, 중복을 받는 쪽에서 거른다. 중복 제거를 어디에 둘 것인가가 이 문제의 실제 설계 결정이다.

어떻게 동작하나

거르려면 "같은 주문" 임을 알아볼 표가 있어야 한다. 주문 메시지에는 보내는 쪽이 붙이는 식별자가 있고, FIX 계열 규격에서는 이것을 ClOrdID 라고 부른다. 정정과 취소는 자기 id 를 새로 달면서 OrigClOrdID 로 이전 id 를 가리킨다. 규격의 뜻은 [FIXimate](https://fiximate.fixtrading.org/) 에서, 규격 자체는 [FIX Trading Community 표준 페이지](https://www.fixtrading.org/standards/)에서 확인할 수 있다.

여기서 사람들이 가장 자주 넘어지는 자리가 있다. id 를 어떻게 만드는가. 실행마다 1번부터 매기는 일련번호, 프로세스 id 나 시작 시각을 섞은 접두어, 그리고 난수 [UUID](https://www.rfc-editor.org/rfc/rfc4122) — 셋 다 흔하고 셋 다 재처리에 무력하다. 같은 신호를 다시 읽어도 다른 id 가 나오기 때문이다. 받는 쪽은 그것이 새 주문인지 아까 받은 주문인지 알 방법이 없다.

고치는 방법은 단순하다. id 를 입력의 안정된 필드만으로 만든다. 스트림 이름, 오프셋, 종목, 방향, 수량, 가격처럼 다시 읽어도 같은 값을 이어 붙여 해시하면, 같은 신호는 몇 번을 다시 읽어도 같은 id 를 낸다. 시각도 난수도 프로세스 id 도 넣지 않는다.

그리고 한 가지가 더 따라온다. 정정과 취소가 가리키는 OrigClOrdID같은 규칙으로 다시 계산할 수 있어야 한다. 오프셋과 id 를 잇는 표를 메모리에만 두면 재시작할 때 사라지고, 그러면 재처리된 취소 메시지는 존재하지 않는 주문을 가리킨다. 거래소는 그것을 거절하는데, 그 사이 원주문은 살아 있다. 취소하려던 주문이 취소되지 않은 채 남는 것이 이 사슬이 끊겼을 때의 실제 결과다.

마지막으로 중복 제거 창을 얼마나 길게 잡을지가 남는다. 이것은 취향이 아니라 관측값이다. 같은 신호가 처음 나간 시각과 재처리로 다시 나간 시각의 차이를 자료에서 재고, 거기에 여유를 곱한다. 창이 관측된 최대 재처리 지연보다 짧으면 중복 제거는 있으나 마나 하다.

현장에서 만나는 모습

한 전송 경로에서 장애 뒤 이중 주문을 조사한 적이 있다. 전송 로그만 보면 같은 오프셋이 두 번 나갔으니 전부 중복으로 보였는데, 거래소 접수 확인과 대조하니 절반이 아니었다. 한쪽은 수량 한도에 걸려 거절돼 있었고, 한쪽은 취소 메시지가 모르는 주문을 가리켜 거절돼 있었다. 실제로 위험한 것은 양쪽 다 접수된 건뿐이었고, 그 수는 처음 세었던 것의 절반이 안 됐다. 보낸 기록이 아니라 받아들여진 기록으로 세는 것이 조사의 출발점이다.

또 하나. 사고 보고서에 "중복 제거를 넣겠다" 고만 적힌 경우를 자주 본다. 창 길이가 없으면 그 문장은 일이 아니라 의견이다. 자료에서 잰 지연이 있으면 숫자가 붙고, 숫자가 붙으면 나중에 그 값이 맞았는지 되물을 수 있다.

세 번째로 자주 만나는 것은 복구를 사람이 손으로 하는 경우다. 장애가 나면 담당자가 오프셋을 눈으로 보고 값을 적어 넣는데, 그 값이 한 자리 어긋나면 그대로 이중 주문이 된다. 손으로 고르는 절차를 없앨 수 없다면 적어도 그 값이 무엇을 뜻하는지 화면에 함께 보여 줘야 한다 — 이 오프셋부터 읽으면 몇 건이 다시 나가고 그 가운데 몇 건이 이미 접수된 주문인지. 그 두 숫자가 보이면 사람은 대개 옳은 값을 고른다. 복구 절차서에 오프셋만 적어 두고 그 영향을 적지 않은 것이 실제 사고의 절반이다.

그리고 재처리는 주문에서 끝나지 않는다. 같은 신호가 두 번 흐르면 위험 한도 계산, 포지션 집계, 감사 로그도 함께 두 번 움직인다. 주문만 중복 제거하고 나머지를 그대로 두면, 주문은 한 번 나갔는데 장부에는 두 번 잡히는 더 나쁜 상태가 된다. 중복 제거 지점은 한 곳으로 모으고, 그 뒤에 오는 모든 계산이 그 지점을 지나게 만드는 것이 설계의 핵심이다.

다음 실습에서 할 것

48건짜리 신호 스트림과 두 번의 실행이 남긴 전송 로그, 거래소 접수 확인, 체크포인트, 장애 기록으로 시작합니다. 재처리 구간을 찾고, 다시 나간 주문을 세고, 결정적 주문 id 를 직접 구현한 뒤, 접수 확인과 대조해 정말 두 번 받아들여진 주문만 남깁니다. 그 다음 체크포인트 저장 시점을 앞뒤로 바꿔 중복과 누락을 각각 세어 견주고, 끊어진 취소와 정정 사슬을 찾고, 마지막으로 중복 제거 창 길이를 자료에서 구해 복구 절차서를 씁니다.