본문으로 건너뛰기
전체 글

저장 함수가 돌아오면 서명을 보내도 될까

· 약 7분
Bankware Global Engineering

합의의 진행 상태를 관측하는 글에서 살펴봤듯, 서버가 응답한다는 사실과 거래를 확정하고 있다는 사실은 다르다. 합의가 어디서 기다리는지 관측하기 시작하면, 다음 질문은 기다림을 언제 끝내도 되는가로 옮겨간다. 특히 저장을 기다리는 중이라면, 빨리 다음 단계로 넘어가는 것이 항상 좋은 일은 아니다.

합의 노드는 자신이 지지하는 블록에 서명한 메시지를 다른 노드에 보낸다. 다른 노드는 그 서명을 투표로 받아들인다. 그런데 보낸 쪽이 갑자기 종료됐다가 돌아왔을 때, 이미 보낸 투표를 자신만 기억하지 못한다면 어떻게 될까?

nigo-protocol은 이를 막기 위해 외부로 전파할 합의 행동을 먼저 로컬 저장소에 기록한다. 문제는 “저장 함수가 정상적으로 돌아왔다”는 말에 무엇이 포함되느냐였다. 2026년 9월의 구현·검증에서는 기록 요청, 데이터베이스의 commit, 저장 장치로의 동기화 요청 완료를 하나의 성공으로 뭉뚱그리지 않는 것이 핵심이었다.

다른 노드는 기억하는데 나만 잊어버린다면​

작은 예제를 생각해보자. 노드 A가 높이 10의 블록을 정하는 두 번째 라운드에서 후보 X를 지지하는 투표를 보냈다. 높이는 블록의 순번이고, 라운드는 같은 높이에서 합의를 다시 시도하는 차수다. 여기서는 같은 높이·라운드의 같은 투표 단계에서 서로 다른 후보를 지지하면 안 된다고 하자.

A가 투표를 전파한 뒤에야 그 사실을 저장한다면, 두 동작 사이에 빈틈이 생긴다. 다른 노드는 X에 대한 A의 서명을 받았지만, A의 기록은 아직 메모리에만 있을 수 있다. 그때 A가 종료되고, 재시작 후에는 X를 지지했다는 기록 없이 후보 Y를 받는다. 이전 행동을 모르는 상태로 새 투표를 허용하면, 다른 노드에게는 A가 X와 Y를 모두 지지한 것처럼 보인다.

이것은 순서가 필요한 이유를 설명하는 가정이다. 이번 검증에서 실제 이중 투표가 발생했다는 뜻은 아니다. 중요한 점은 외부에 이미 전달한 메시지를 로컬의 실패로 취소할 수 없다는 것이다. “저장에 실패했으니 투표도 없었던 일”이라고 판단해도, 수신한 노드의 서명은 사라지지 않는다.

따라서 순서를 바꾼다. 재시작 뒤에도 복원할 수 있는 기록을 먼저 남기고, 그다음 투표를 전파한다. 저장 이후 전파 직전에 종료되면 남아 있는 기록을 기준으로 행동을 판단할 수 있다. 네트워크가 아직 보지 못한 행동을 내가 기억하는 쪽이, 네트워크가 본 행동을 나만 잊는 쪽보다 안전하다.

이 기록은 블록의 거래 내역과 역할이 다르다. 특정 검증 노드가 어떤 합의 행동을 했고, 어떤 합의 근거를 이미 확보했는지 재시작 후 복원하기 위한 기록이다. nigo-protocol에서는 이를 검증 노드의 안전 기록, 또는 safety WAL이라고 부른다. WAL은 뒤따르는 동작보다 먼저 남기는 기록이라는 뜻이다.

저장 요청을 마친 것과 저장을 확정한 것은 다르다​

코드에서 저장 함수를 먼저 호출했다고 해서 이 순서가 자동으로 보장되지는 않는다. 그 함수가 바깥에서 시작한 데이터베이스 트랜잭션에 합류했다면, 함수는 일을 마치고 돌아와도 실제 commit은 바깥 작업이 끝날 때까지 남아 있을 수 있다. commit은 그 트랜잭션의 변경을 데이터베이스에 확정하는 단계다.

앞의 A가 안전 기록을 저장한 뒤 곧바로 투표를 보냈다고 하자. 겉으로 보이는 호출 순서는 올바르다. 하지만 안전 기록이 아직 바깥 트랜잭션 안에 있고, 그 트랜잭션이 나중에 실패해 되돌려진다면 투표는 전파됐는데 기록은 없어질 수 있다. 저장 함수의 반환을 commit 완료로 해석한 것이 문제다.

그래서 nigo-protocol의 관계형 저장소 경로는 안전 기록을 아직 끝나지 않은 바깥 트랜잭션에 합류시키지 않는다. 이런 호출을 거절하고, 안전 기록 자체의 트랜잭션이 commit되는 경계를 요구한다. 노드 내부의 모든 저장에 이 규칙을 적용하는 것은 아니다. 블록과 확정 증거처럼 함께 반영되거나 함께 취소돼야 하는 원장 자료에는 기존의 원자적 저장 관계를 유지한다.

commit 다음에도 질문이 남는다. 데이터베이스가 쓰기를 확정했다는 응답과, 해당 저장 방식에서 요구하는 동기화 절차를 끝냈다는 응답을 같은 것으로 취급해도 되는가? 이 부분은 저장소의 동작과 설정에 의존하므로, 안전 기록의 성공 조건에 명시적으로 포함했다.

관계형 저장소 안전 기록의 정상 경로를 줄인 그림이다. 각 단계가 끝났다는 근거 없이 다음 단계로 넘어가지 않는다. 서명 값의 생성은 저장 전에 이뤄질 수 있으며, 여기서 지키는 경계는 외부 전파 시점이다.

마지막 구분은 중요하다. 현재 구현도 서명된 메시지를 만든 다음 그 메시지를 저장한다. “저장이 끝나기 전에는 서명을 계산하지 않는다”가 보장인 것은 아니다. 다른 노드가 그 서명을 합의 근거로 사용하기 전에, 보내는 노드가 그 행동을 복원할 수 있게 하는 것이 목적이다.

동기화를 요청했다는 이름만으로는 부족했다​

9월 14일의 로컬 검증에서는 실제 파일을 사용하는 H2 저장소의 내부 저장 작업을 의도적으로 지연시켰다. 그 상태에서 동기화 호출이 언제 반환하는지, 호출 스레드가 중단 요청을 받으면 어떻게 되는지 살펴봤다. 확인한 환경에서는 동기화 SQL의 반환만으로 아직 남아 있는 저장 작업의 완료를 판단할 수 없는 경로가 재현됐다.

이를 반영해 안전 기록의 동기화 경계는 SQL 호출 하나로 끝나지 않게 했다. 같은 데이터베이스 연결을 유지한 채 필요한 저장 큐의 처리가 끝나고 파일 동기화까지 수행되는 것을 기다린다. 이 경계는 검증한 embedded H2 버전에 맞춘 구현이며, 다른 버전이나 저장소에도 그대로 성립한다고 가정하지 않는다. RocksDB 경로는 자체 동기 WAL 설정과 실패 차단 경계를 별도로 검증했다.

기다리는 동안의 중단 요청도 성공으로 바꾸지 않는다. 호출자가 기다림을 포기했다고 해서 이미 시작한 저장 작업까지 취소됐다는 뜻은 아니기 때문이다. 현재 H2 경로는 실제 완료를 확인하기 전에 그 연결을 풀에 돌려주지 않는다. 대기 중 중단 요청을 관측했다면 완료 뒤 이를 복원하고, 성공 대신 실패와 차단으로 처리한다. 일정 시간이 지났으니 저장을 강제로 취소해도 안전하다는 계약은 아니다.

이 대목에서 관측 기능이 필요해진다. 저장을 기다리는 합의 작업과 상태 조회가 같은 잠금에 묶여 있다면, 정작 무엇을 기다리는지 알아보기 위해 보낸 요청도 함께 멈출 수 있다. 검증에서는 동기화 완료를 막아둔 동안에도 진행 상태 조회가 응답하고, 그사이 합의 메시지가 전파되지 않는지 함께 확인했다. 응답 가능한 상태 조회는 기다림을 설명하기 위한 수단이며, 저장을 건너뛸 허가가 아니다.

예외가 났다고 기록이 없어진 것은 아니다​

가장 다루기 어려운 경우는 성공도 실패도 명확하지 않을 때다. commit 이후 동기화에서 예외가 발생했다면, 데이터베이스에는 이미 기록이 있을 수 있다. 호출자는 실패를 받았지만, 이를 “아무것도 쓰지 않았다”는 뜻으로 바꾸면 안 된다. 반대로 기록 한 줄을 다시 조회했다고 해서 필요한 동기화까지 모두 끝났다고 판단할 수도 없다.

앞의 A가 X에 관한 기록을 쓰다가 이런 상태가 됐다고 하자. 안전한 선택은 Y로 다시 시도하거나, 새 합의 엔진 객체를 만들어 계속 움직이는 것이 아니다. 해당 작업을 전파하지 않고, 완료를 신뢰할 수 없게 된 안전 저장소의 재사용을 막는 것이다.

nigo-protocol은 이 차단 상태를 같은 안전 저장소를 사용하는 이후 읽기·쓰기에도 적용한다. 엔진만 새로 만들어 붙여도 같은 저장소의 불확실성을 우회할 수 없다. 이때 사용하는 차단을 fence라고 부른다. 잠시 기다린 뒤 저절로 해제되는 재시도 표시가 아니라, 저장 결과를 신뢰할 수 없는 채로 다음 합의 행동을 쌓지 않도록 막는 경계다.

범위는 이 안전 저장소다. 전체 데이터베이스의 모든 실패를 찾아내는 장치도, 고장 난 저장 장치를 자동으로 복구하는 기능도 아니다. WAL을 지우거나 프로세스를 무조건 다시 시작해 없애도 되는 상태로 취급하지 않는다. 재시작 후에도 보존된 안전 기록을 복원하고 기존 행동과 충돌하지 않는지 확인하는 과정이 필요하다.

재시작 검증에서 확인한 것​

9월 14일 검증은 저장 완료를 기다리는 경계와 실패 후의 행동을 나눠 살폈다. 동기화를 막아둔 동안 외부 전파가 없고, 이를 해제한 뒤에는 각 행동의 commit과 동기화가 끝난 후 전파되는지 확인했다. commit 전후의 예외, rollback 실패, 동기화 실패를 주입해 같은 안전 저장소의 재사용도 거부되는지 검사했다.

프로세스를 실제로 종료하는 검사도 있었다. 첫 번째 JVM에서 투표와 합의 잠금의 안전 기록을 저장하고 완료 응답을 받은 뒤, 정상 종료 훅을 실행하지 않고 프로세스를 멈췄다. 두 번째 JVM이 같은 파일을 열어 서명 대상 바이트, 서명, 라운드, 확보한 합의 근거를 복원했다. 복원한 기록과 충돌하는 투표를 거부하고 상태 머신을 다시 진행할 수 있는지도 확인했다.

이 결과는 처음 예제의 질문에 직접 답한다. 확인한 프로세스 종료 조건에서는, 안전 기록의 완료 응답을 받은 행동을 새 프로세스가 다시 읽을 수 있었다. 별도의 실제 4개 검증 노드 회귀에서도 같은 빌드로 대기 후 새 거래 두 개가 모두 확정되는 것을 확인했다. 안전 경계를 추가한 뒤 정상 합의 경로가 동작하는지 보는 검사다.

다만 프로세스 종료는 컴퓨터의 전원 차단과 다르다. JVM을 강제로 끝내도 운영체제와 그 캐시는 살아 있으므로, 이 결과를 전원 장애에서도 데이터가 반드시 남는다는 증명으로 쓰지 않는다. 장치가 동기화 요청을 실제로 지키는지, 서로 다른 호스트와 장기 부하에서도 어떻게 동작하는지는 별도 검증 대상이다.

이 글의 구현·검증 근거는 macOS arm64, Java 21.0.7, H2 2.4.240 등을 사용한 당시의 로컬 인수 기록이다. 원고를 작성하며 새 장애 실험을 수행한 결과가 아니며, 전체 운영 환경의 내구성 인증을 뜻하지 않는다.

밖으로 한 약속을 안에서도 기억하기​

안전 기록의 저장 함수가 정상 반환한다는 것은 호출이 끝났다는 사실보다 강한 약속이어야 한다. 그 약속에는 자체 commit과 저장소 동기화 완료가 포함돼야 하고, 완료를 확신할 수 없다면 이후 행동을 멈출 수 있어야 한다. 그래야 외부에 보낸 투표와 재시작 뒤의 기억 사이에 빈틈이 생기지 않는다.

이 경계 때문에 기다림이 길어질 수 있다. 그래서 기다림을 관측하는 기능과, 저장 결과가 불확실한 상태에서 더 진행하지 않는 규칙이 함께 필요하다. 지연을 보여주는 것과 지연을 없애도 되는지 판단하는 것은 서로 다른 책임이다.

재시작한 노드가 자기 행동을 잊지 않는다는 조건을 세웠다면, 다음에는 다른 노드가 이미 확정한 일을 어떻게 따라잡을지 물을 수 있다. 자신의 타이머는 다음 라운드로 넘어갔는데 이전 라운드의 확정 증거가 늦게 도착하는 경우다. 네 노드 중 하나만 멈췄다: 타임아웃 이후의 합의에서는 새로운 서명을 만드는 규칙을 지키면서도 유효한 확정 결과를 받아들이는 문제를 살펴본다.