네 노드 중 하나만 멈췄다: 타임아웃 이후의 합의
네 대의 검증 노드가 하나의 원장을 만들고 있었다. 세 대는 첫 블록을 확정했는데, 한 대만 그 이전에 남았다. 뒤처진 노드의 프로세스는 살아 있었고 나머지 세 대와 연결도 유지했다. 그런데 기다려도 같은 블록에 도달하지 않았다.
2026년 9월 8일 nigo-protocol의 로컬 재현에서 확인한 장면이다. 저장소에서 블록을 읽지 못한 것도, 네트워크 연결이 끊긴 것도 아니었다. 뒤처진 노드는 오히려 합의 시도 횟수에서는 더 앞서 있었다. 먼저 타임아웃을 겪어 다음 라운드로 넘어간 뒤, 이전 라운드에서 온 메시지를 버리고 있었다.
시간이 지난 메시지를 거부하는 규칙은 자연스러워 보인다. 하지만 그 메시지가 새 투표를 요청하는 것이 아니라 다른 노드들이 이미 합의했다는 증거라면 어떨까? 이 차이가 살아 있는 노드 한 대를 계속 뒤에 남겨 두었다.
같은 블록을 기다리지만 같은 라운드는 아니다
nigo-protocol의 이 합의 구성은 정해진 검증자들이 서명한 메시지를 교환하는 QBFT를 사용한다. 여기서는 검증자가 네 대이고, 같은 블록에 대한 서로 다른 세 검증자의 확정 투표가 필요하다. 이 최소 수를 정족수라고 한다. 한 노드가 같은 메시지를 세 번 보내도 세 표가 되지는 않는다.
합의의 위치에는 두 숫자가 있다. 높이는 만들려는 블록의 순번이다. 라운드는 그 높이에서 합의를 다시 시도하는 차례다. 블록 1을 기다리며 라운드가 2에서 3으로 올랐다고 해서, 블록 2를 만든 것은 아니다. 노드마다 메시지 도착과 타임아웃 시점이 다르므로 같은 높이에서도 라운드가 잠시 어긋날 수 있다.
설명을 위해 네 검증자를 A, B, C, D라고 부르자. 실제 재현에서는 D에 해당하는 한 대를 먼저 시작하고, 연결된 동료가 없는 동안 높이 1의 로컬 라운드가 3에 도달한 것을 확인한 뒤 나머지를 시작했다. 이름은 설명용이며, 아래의 라운드와 높이 관계는 실제 재현에서 관측한 것이다.
- A·B·C: 라운드 2에서 같은 블록에 대한 확정 투표를 모아 첫 블록을 확정했다.
- D: 이미 로컬 라운드 3에 들어간 뒤 라운드 2의 메시지를 받았다. 수정 전에는 이 메시지를 버려 첫 블록을 확정하지 못했다.
A·B·C는 세 표를 확보했으므로 다음 높이로 넘어갈 수 있다. D는 혼자 라운드 3 이후의 합의를 시도하지만, 이미 떠난 세 대를 대신해 정족수를 만들 수 없다. 시작할 때 수행하는 동기화도 끝난 상태였다. 연결이 유지된다는 사실만으로 이 간격이 메워지지는 않았다.
수정 전에는 같은 높이라도 현재보다 낮은 라운드의 제안과 확정 투표를 오래된 메시지로 분류했다. D가 필요로 하는 자료가 도착해도, 바로 그 이유로 버렸다. 실제 통제 재현에서는 다른 세 대의 최신 블록이 1인데 D는 0인 채로 확정 제한 시간에 도달했다.
돌아가서 투표하는 일과 결과를 배우는 일
해결의 출발점은 “오래된 메시지도 받아 주자”보다 좁다. 현재 만들고 있는 높이의 이전 라운드에서, 유효한 확정 증거를 학습할 수 있게 하자.
합의 메시지 중 PROPOSAL은 제안한 블록을, COMMIT은 그 블록에 대한 확정 투표를 전달한다.
D는 라운드 2의 제안과 COMMIT들을 확정 증거로 모을 수 있다.
그렇다고 자기 라운드를 2로 되돌리거나, 거기서 준비 투표(PREPARE)나 확정 투표(COMMIT)를 새로 서명하지는 않는다.
이미 받은 서명을 검증하는 것과 자신의 서명을 추가하는 것은 서로 다른 행동이다.
이 경로에서도 검사는 줄어들지 않는다. 서명이 올바른지, 서명자가 해당 검증자 집합에 속하는지, 같은 체인과 합의 문맥을 가리키는지 확인한다. 제안자는 그 높이·라운드에 블록을 제안할 자격이 있어야 한다. 첫 라운드가 아닌 제안에는 라운드 변경의 증거가 필요하며, 앞서 준비된 제안이 있다면 그것을 이어야 한다는 규칙도 확인한다.
그다음 같은 높이·라운드·제안·블록을 가리키는 서로 다른 세 COMMIT이 필요하다. 라운드 1의 한 표와 라운드 2의 두 표를 합치거나, 서로 다른 블록의 표를 섞어서는 안 된다. 또한 서명된 요약값만 모였다고 충분하지 않다. 그 요약값에 맞는 블록 본문을 받아, 부모 블록·높이·거래 실행과 결과 상태까지 검증해야 한다.
자료가 덜 모인 동안에는 이를 제한된 캐시에 보관한다. 아직 검증하지 않은 블록을 확정하거나, 불완전한 증거에 맞춰 현재 투표 상태를 바꾸지 않는다. 필요한 증거가 완성되면 기존 블록 반영 절차로 높이 1을 확정하고 높이 2로 전진한다. D는 라운드 3에서 라운드 2로 후퇴한 것이 아니다. 확정된 높이를 하나 앞으로 옮긴 것이다.
이 구분 덕분에 안전 규칙과 진전을 함께 지킬 수 있었다. 현재 라운드가 정하는 것은 지금 어떤 새 투표를 만들 수 있는가다. 이미 완성된 정족수 증거가 정하는 것은 어떤 블록이 확정됐는가다. 자신의 시계가 먼저 움직였다는 이유로 다른 검증자들의 유효한 확정을 무효로 만들 수는 없다.
잘못된 본문을 거부했는데, 올바른 본문도 막혔다
첫 수정 후에는 메시지의 도착 순서를 더 바꿔 보았다. 그 과정에서 “거절은 했지만 다시 시도할 수 없는” 별도의 결함이 드러났다.
이번에는 D가 아직 해당 라운드에 있을 때 제안을 받았다고 하자. 제안의 서명된 헤더와 함께 온 블록 본문이 맞지 않아 검증에 실패했다. 이 거절 자체는 옳았다. 그런데 이미 처리한 메시지라는 기록은 남아 있었다.
그 뒤 D가 타임아웃으로 다음 라운드에 들어가고, 올바른 COMMIT 세 표를 받았다.
마지막으로 같은 서명된 헤더에 맞는 올바른 본문이 다시 도착했다.
이제는 확정할 자료가 모두 있는데도, 중복 방지 장치가 앞선 기록을 보고 DUPLICATE로 처리했다.
잘못된 첨부 자료 한 번이 나중의 유효한 증거까지 가로막은 것이다.
여기서 중복 검사를 통째로 끄면 안 된다. 같은 메시지를 무한히 처리하지 않게 하는 방어는 여전히 필요하다. 수정은 검증에 실패한 본문과 그에 딸린 중복 기록만 정리하는 것으로 한정했다. 이미 검증한 COMMIT들은 유지했다. 따라서 올바른 본문만 다시 전달해도 남아 있는 표와 결합해 확정할 수 있다.
현재 라운드에서 본문을 거부하든, 타임아웃 이후에 늦은 본문을 거부하든 같은 정리 절차를 적용했다. 회귀 테스트는 잘못된 본문 거절, 타임아웃, 세 COMMIT 수신, 올바른 본문 재전달 순서를 그대로 재현한다. 마지막 입력으로 확정되면서도 이전 라운드의 새 서명은 생기지 않는지 함께 확인했다.
이 문제는 “중복 메시지는 버린다”는 설명만으로는 보이지 않는다. 무엇이 중복인가를 판단할 때, 검증된 제안과 검증에 실패한 첨부 자료의 처리 이력을 구분해야 했다. 거부의 정확성만큼 중요한 것이 거부 이후에 올바른 입력을 다시 받을 수 있는가였다.
블록 하나를 따라잡은 뒤에도 확인할 것이 있다
수정 결과를 네 노드의 높이가 같아졌다는 사실만으로 판정하지는 않았다. D가 우연히 다른 노드와 같은 라운드에 들어가 정상 투표를 했을 수도 있기 때문이다. 실제로 초기 재현 중에는 이런 실행이 나와, 늦은 확정 학습의 근거에서 제외했다.
수정 후에는 메시지를 처리하기 직전의 로컬 라운드와 입력 메시지의 라운드를 함께 기록했다. 최종 실행 파일로 수행한 RDB와 RocksDB 실험 모두에서, 로컬 라운드 3인 D가 라운드 2의 COMMIT을 처리하는 동안 높이 1을 확정하는 전이를 확인했다. 기다리는 시간을 늘려 성공 판정을 얻은 것이 아니다. 기존 요청·확정 타임아웃 설정은 유지했다.
다음에는 A·B·C 중 한 대를 잠시 정지시키고, 새 거래를 회복한 D에만 제출했다. 검증자가 네 대인 구성에서 한 대를 멈추면 남은 세 대가 모두 있어야 정족수를 채울 수 있다. 그 조건에서 두 번째 블록을 확정했고, 정지한 노드를 재개한 뒤에는 네 대 모두 블록 2의 해시와 실행 결과를 요약하는 상태 루트가 같아졌다.
이 실험은 D가 첫 블록을 읽어 온 뒤 계속 침묵하는 노드로 남지 않았음을 보여 준다. 다만 실제 프로세스 검사에서 모든 확정 증명서의 서명자를 일일이 감사한 것은 아니다. 이전 라운드의 재서명을 하지 않는다는 규칙은 별도 안전성 테스트로 확인했다. 최초 수정본의 RDB 실행 한 건에서는 종료 후 서명 기록도 읽어, 첫 높이의 PREPARE·COMMIT 없이 다음 높이에서는 정상 서명했다는 근거를 추가로 확보했다.
순서를 바꾸고, 저장소를 다시 열어 보았다
실제 프로세스 실험은 특정 실행에서 문제가 해결됐다는 강한 근거를 준다. 하지만 모든 메시지 순서를 한 번에 다룰 수는 없다. 제안이 먼저 오는 경우와 표가 먼저 오는 경우, 마지막 한 표만 늦는 경우, 중복 메시지와 추가 타임아웃이 섞이는 경우를 별도로 구성했다.
검증자 수와 증거의 라운드, 메시지 순서를 바꾼 가상 스케줄을 반복 실행해 전이 기록이 같은지 확인했다. 핵심 판정은 단순했다. 증거가 부족하면 확정도 새 투표도 없어야 하고, 완성되면 정확히 한 번 확정한 뒤 다음 높이로 가야 한다. 이 수는 실제 네트워크의 성공률이나 처리 성능을 추정한 결과가 아니다.
RDB와 RocksDB를 정상 종료한 뒤 다시 여는 검사도 따로 수행했다. 높은 라운드까지 갔다는 기록을 복원한 후에도, 낮은 라운드의 유효한 확정을 새 서명 없이 학습하고 다음 높이에서는 정상 투표할 수 있어야 했다. 반면 일부 표만 모아 둔 메모리 캐시는 재개방으로 복원되지 않는다. 부족했던 한 표만 보내는 것으로 충분하다고 가정하지 않고, 필요한 제안과 표를 다시 전달했다.
최종 수정본은 같은 실행 파일을 사용해 RDB·RocksDB의 시작 시점 차이, mTLS 통신, 검증자 동기화 복구, 관찰 노드 복구를 다시 검사했다. 선택한 시나리오가 모두 통과했다.
검증 환경과 시나리오 수
가상 스케줄은 검증자 수 4·7·10, 증거의 라운드 0·1, 무작위 순서용 seed 24개를 조합한 144개 테스트다. 각 조합을 두 번 실행해 총 288개 스케줄을 비교했다. 실제 프로세스 검사는 macOS arm64의 Java 21 환경에서 최종 실행 파일로 8회 수행했고, 선택한 22개 시나리오가 통과했다. 최종 수정 전 실행 파일의 별도 4회·18개 결과와 합산하지 않았으며, 공통 통신 묶음의 선택되지 않은 시나리오까지 모두 검증한 것은 아니다.
정상 종료·재개방은 강제 종료나 전원 차단 시험이 아니다. 또한 각 실행의 준비 조건이 다른 만큼, 관측한 소요 시간을 성능 개선 폭으로 읽지 않았다. 검증한 것은 어떤 증거에서 어떤 상태 전이가 일어나는가였다.
늦게 배울 수 있어야 다음 합의에 참여한다
이번 수정은 현재 높이에 전달된 유효한 확정 증거를 배울 수 없었던 공백을 닫았다. 영구히 유실된 블록을 찾아오거나, 캐시에서 사라진 모든 표를 자동 재요청하는 기능까지 추가한 것은 아니다. 자료가 아예 오지 않는 상황의 동기화와 복구는 여전히 별도 문제다. 과거에 관측한 모든 타임아웃의 원인이 이 결함이었다고 소급해 단정하지도 않는다.
저장 완료 뒤에 서명을 전파하는 내구성 규칙도 계속 필요하다. 다만 이 재현에서 고친 것은 저장 완료 대기가 아니라, 도착한 과거 라운드의 증거를 다루는 방식이었다. 겉으로 둘 다 “합의가 멈췄다”고 보여도, 진행을 가로막은 조건을 나누어 보아야 해결책을 정할 수 있다.
D는 기다리는 시간을 더 받은 덕분에 돌아온 것이 아니다. 지금 새로 투표할 수 있는 범위와, 이미 성립한 합의를 인정할 수 있는 범위를 구분한 덕분에 돌아왔다. 새로운 서명을 조심스럽게 만드는 일에 더해, 늦게 도착한 유효한 결론을 정확히 배우는 일까지 갖춰야 뒤처진 노드가 다음 합의의 참여자로 다시 설 수 있다.