셸에서 전에 실행했던 명령을 다시 찾는 일은 자주 반복된다. 같은 작업을 다른 기기에서 이어가려면 그 기기에서도 이전 명령을 찾을 수 있어야 한다. 기기마다 따로 남는 기본 히스토리를 넘어, 여러 기기의 기록을 함께 검색할 수 있는 도구가 필요했다.
Rustory는 각 기기의 SQLite에 명령을 저장하고 기기끼리 기록을 교환하는 셸 히스토리 도구다. 이 구조를 선택한 이유와, 연결이 끊긴 기기가 다시 돌아왔을 때 처리해야 하는 문제를 순서대로 살펴본다.
01 / 개발의 출발점
원하는 사용 방식에 맞는 도구가 필요했다
먼저 hishtory와 Atuin을 사용했다. hishtory는 여러 기기의 기록을 모으는 목적에 맞아 오랫동안 최신 버전으로 갱신하며 사용해 왔다. 하지만 프로젝트 개발자인 ddworken이 Anthropic으로 이직한 뒤에는 유지보수가 뜸해졌고, 동기화도 기대만큼 안정적이지 않았다. 서버 기반 동기화를 쓰거나 서버를 직접 운영해야 하는 방식도 부담이었다. 명령을 기록하고 다른 기기에서 찾는 일에 비해 동기화와 운영에 신경 쓸 부분이 많았다.
Atuin에서는 검색 화면과 조작 방식이 원하는 흐름과 달랐다. 필요하지 않은 기능과 GUI를 포함한 확장이 늘어나는 방향도 일상적인 셸 도구로 쓰기에는 무겁게 느껴졌다. 자주 쓰는 기록과 검색을 원하는 방식으로 구성하기 위해 직접 도구를 만들었다.
필요한 동작은 각 기기에서 명령을 바로 기록하고 검색하며, 연결될 때 다른 기기와 기록을 나누는 것이었다. 모든 히스토리를 한 서버에 모으는 대신 같은 동기화 그룹의 기기들이 서로 복사본을 보유하는 분산 그리드 구조를 만들기로 했다. 기록을 모으는 서버의 부담은 줄지만, 기기마다 다른 시점의 데이터를 가진 상태를 직접 다뤄야 하는 선택이었다.
02 / 저장과 동기화
기록은 각 기기에 남기고 서로 교환하기
명령이 끝나면 우선 현재 기기의 SQLite에 기록한다. 검색도 이 데이터베이스를 사용한다. 네트워크에 연결되지 않아도 현재 기기에 저장된 명령은 계속 찾을 수 있고, 아직 다른 기기로 전달하지 못한 기록은 이후 동기화에서 보낸다. 기록과 검색에 네트워크 응답을 기다리게 하지 않는 것이 로컬 우선 구조의 기본 동작이다.
기기들이 인터넷에서 서로 찾고 연결되려면 도움이 필요하다. 트래커(tracker)는 기기의 접속 주소와 동기화 그룹의 가입 상태를 관리한다. 공유기 뒤에 있는 등 외부에서 직접 연결하기 어려운 환경에서는 릴레이(relay)가 두 기기 사이의 암호화된 통신을 중계한다. 기록과 검색은 각 기기에서 처리하고, 기기 발견과 연결은 이 서버들의 도움을 받도록 구성했다.
표를 좌우로 밀어 비교할 수 있습니다.
| 구성 요소 | 역할 | 접근하는 정보 |
|---|---|---|
| 기기 (SQLite) | 명령 기록·검색, 기록과 삭제의 교환 | 동기화해 받은 명령 원문과 로컬 데이터베이스 |
| 트래커 (Tracker) | 기기 발견과 동기화 그룹의 가입 상태 관리 | 기기 식별자·접속 주소·상태 등 메타데이터 |
| 릴레이 (Relay) | 직접 연결이 어려운 기기 사이의 통신 중계 | 연결 정보와 암호화된 통신 |
명령 원문은 각 기기의 SQLite에 저장한다. 릴레이를 거칠 때도 기기 사이에서 암호화한 상태로 전달한다.
같은 그룹의 기기(peer)마다 마지막으로 주고받은 기록의 진행 위치를 저장한다. 이 위치를 나타내는 커서는 각 데이터베이스가 기록을 받아들인 순서를 기준으로 한다. 기기들의 시계가 달라도 이 순서를 따라 다음에 전달할 기록을 찾을 수 있다. 보낸 기록의 커서는 상대가 해당 묶음을 받아들였다는 응답을 확인한 뒤에 갱신한다. 연결이 끊겨도 다시 동기화할 때 마지막으로 확인한 위치 다음부터 이어서 보낼 수 있다.
03 / 삭제의 전파
삭제한 기록이 다시 돌아오지 않도록
추가된 명령을 교환하는 것만으로는 동기화가 완성되지 않는다. 기록을 지웠을 때는 다른 기기에서도 사라져야 한다. 데이터베이스에서 행을 지우기만 하면 삭제한 명령과 아직 받아 본 적 없는 명령을 구분할 수 없다. 오래 연결되지 않았던 기기가 가진 복사본을 새 기록처럼 다시 받아들이는 문제가 생긴다.
기기 A·B·C가 같은 명령 기록을 가진 상태를 생각해 보자. C가 오프라인인 동안 A에서 그 기록을 삭제하고 B까지 삭제를 전달했다. 이후 C가 돌아와 오래된 복사본을 보내면, A와 B에는 그 복사본을 받아도 되는지 판단할 근거가 필요하다. 이를 위해 기록의 고유 ID와 함께 삭제 표시(tombstone)를 따로 남긴다.
- A에서 명령을 삭제할 때 해당 기록의 ID에 대한 tombstone을 저장하고 명령 행을 지운다.
- B가 tombstone을 받으면 같은 ID의 기록을 지우고 삭제 표시를 보관한다.
- C가 오래된 복사본을 보내도 A와 B는 보관 중인 tombstone을 확인해 그 기록을 다시 넣지 않는다.
- C의 기록은 다시 연결되어 삭제 표시를 받은 뒤에 지워진다.
구현에서는 받은 명령을 넣는 경로에서도 삭제 표시를 확인한다. entry_deletions에 같은 entry_id가 있으면 entries에 삽입하지 않는다. 삭제를 적용할 때는 tombstone의 저장과 명령 행의 삭제를 하나의 트랜잭션에서 처리한다. 삭제 표시가 남아 있는 동안은 오래된 명령이 다시 전달돼도 기록이 되살아나지 않는다.
삭제 표시를 정리할 때는 보관 기간과 기기별 전달 상태를 함께 확인한다. 알려진 동기화 기기가 있으면, 그중 삭제 전달이 가장 뒤처진 커서를 기준으로 삼는다. 이 위치까지 전달을 확인한 삭제 표시 중 보관 기한이 지난 것만 정리한다. 아직 삭제를 받았다는 확인이 없는 기기가 있으면 해당 표시를 계속 보관한다.
이 정리 기준은 현재 알고 있는 동기화 기기를 대상으로 한다. 삭제 표시까지 정리한 뒤 오래된 백업을 복원하거나 제외했던 기기를 다시 연결하면, 삭제했던 기록이 다시 들어올 수 있다.
04 / 연결 실패와 신뢰
연결 상태와 접근 권한을 함께 다루기
상대 기기가 꺼지거나 네트워크가 끊기면 그 기기와의 동기화가 실패할 수 있다. 릴레이를 사용하는 연결도 예약 상태가 사라지면 다시 맺어야 한다. 한 기기의 응답을 기다리는 동안 다른 기기의 동기화까지 밀리지 않도록, 한 기기와 기록을 받아오고 보내는 전체 작업에 시간 제한을 뒀다. 제한 시간이 지나면 다음 기기로 넘어가고, 이미 저장한 묶음의 커서는 보존해 다음 동기화에서 이어간다.
직접 연결과 릴레이를 거치는 연결 모두 Noise 프로토콜로 상대 기기를 인증하고 통신을 암호화한다.
반면 히스토리를 받은 기기는 검색을 위해 원문을 읽을 수 있어야 한다. 로컬 SQLite의 명령과 작업 경로는 평문으로 저장한다. 따라서 같은 그룹에 가입한 기기들은 서로 신뢰한다는 전제가 있다. 전송 암호화가 있어도 그 기기나 로컬 백업에 접근할 수 있는 사람으로부터 기록을 숨기지는 못한다. 기기를 동기화 그룹에 넣는 일은 그 기기에 히스토리의 복사본을 맡기는 일이기도 하다.
기기를 분실하거나 더 이상 신뢰할 수 없을 때는 rr device revoke로 그 기기의 동기화 그룹 가입 상태를 해제한다. 동기화할 때마다 상대 기기의 가입 상태를 트래커에 확인하는 엄격 모드(strict membership)에서는 이 판정에 따라 기록 교환을 허용하거나 거부한다. 이 모드에서는 트래커에 접근할 수 없으면 동기화 요청을 거부한다. 로컬 기록과 검색은 계속 사용할 수 있다.
권한을 회수해도 그 기기가 이미 받아 간 기록이나 백업은 남는다. 공유 키까지 유출됐다면 키를 교체하는 작업도 따로 필요하다.
05 / 셸에서의 사용
매일 사용하는 셸의 흐름에 맞추기
동기화 구조를 만드는 목적은 평소 쓰는 셸에서 명령을 더 쉽게 찾는 것이었다. 셸에 설치한 hook은 명령이 끝나면 기록을 표준 입력으로 넘겨 백그라운드에서 저장하게 한다. 기록을 동기화하는 일은 이후에 이어가며, 명령을 실행할 때마다 다른 기기의 응답을 기다리게 하지 않는다.
검색은 Ctrl-R에서 시작한다. 별도의 전체 화면으로 전환하는 대신 현재 터미널 아래에 작은 표를 표시하고, 입력한 단어와 현재 작업 경로 등의 맥락을 이용해 명령을 찾는다. 고른 명령은 셸의 입력 버퍼로 돌려보낸다. 과거 명령에 포함된 경로나 옵션을 현재 작업에 맞게 고친 뒤 실행할 수 있도록, 선택과 실행을 구분했다.
설치와 동기화가 잘됐는지 확인할 방법도 필요하다. 셸 hook을 설치하거나 갱신해도 이미 열린 셸에는 이전 함수가 남을 수 있으므로 새 설정을 다시 읽어야 한다. rr doctor는 hook과 설정, 키, 트래커와 릴레이 접근 상태를 확인하는 시작점이다. rr sync-status는 기기별로 아직 전달하지 못한 명령과 삭제 표시를 나눠 보여준다. 동기화가 밀렸을 때 어느 기기로 보낼 기록이 남아 있는지 확인할 수 있다.
각 기기에 기록을 두면 네트워크와 무관하게 지금 가진 명령을 사용할 수 있다. 그 대신 다른 기기에 무엇을 전달했고, 삭제를 어디까지 확인했으며, 어떤 기기와 기록을 나눌지를 관리해야 한다. 기록과 검색은 로컬에서 처리하고, 동기화가 어긋나면 기기별 전달 상태를 확인할 수 있도록 구성했다.