남의 파일을 지우는 포크 — Neovim과 Vim의 undo 파일이 남긴 질문

포크가 부모의 파일 형식을 바꿀 때, 디스크 위에 이미 놓인 부모의 파일은 누구의 것인가 — 개발자의 내부 상태인가, 사용자의 데이터인가?

도입

2026년 9월 27일 해커뉴스에 「On caring for user data: NeoVim caused Vim undo files to be deleted」라는 제목의 글이 올라와 384점을 받았다. 링크된 곳은 Marcin Wichary의 블로그 Unsung의 8월 28일자 글 「They had no concept of a duty of care to their users.」다. Wichary는 컴퓨터 과학자 David Chisnall의 Mastodon 글을 거의 통째로 인용하고 짧은 논평을 붙였다.

Chisnall의 이야기는 이렇다. 그는 2000년 무렵부터 Vim을 써 왔고, 책 다섯 권과 박사 논문, 수십 편의 논문, 150편이 넘는 기사를 Vim으로 썼다. 그가 가장 아끼는 기능은 영속 undo(persistent undo)다. 지난주에, 재부팅 한 번 전에 지운 무언가를 undo로 거슬러 올라가 되찾을 수 있다. 그런데 초창기 Neovim을 써 보자 undo가 작동하지 않았고, 다시 Vim으로 열어도 작동하지 않았다. 그의 서술에 따르면 Neovim은 undo 파일 형식을 바꿨고, 이름을 바꾸지 않았고, Vim의 undo 파일을 발견하자 지우고 Vim이 읽을 수 없는 파일로 대체했다. 이슈를 올리자 「영속 undo 형식은 불안정하며 앞으로도 바뀔 것」이라는 답을 들었다고 한다.

이 글은 「오픈소스 개발자에게 의무가 있는가」라는 익숙한 논쟁으로 번졌다. 하지만 이 사건에서 더 오래 남을 교훈은 다른 곳에 있다고 본다. 포크는 부모의 코드만 물려받지 않는다. 부모가 사용자의 디스크 위에 남긴 파일, 그리고 그 파일을 다루는 규칙까지 물려받는다. 이 글은 2021년의 실제 GitHub 스레드와 현재 양쪽의 소스 코드를 대조해, 이 사건이 「태도의 문제」이기 전에 어떤 구조의 문제였는지를 살펴본다.

2021년의 PR과 2026년의 회상 — 기록이 말하는 것

먼저 사실관계부터 정리한다. 회상과 기록은 일부 어긋나므로 둘을 구분해서 볼 필요가 있다.

해커뉴스 코멘트에서 jeremyjh가 링크한 원래 변경은 Neovim의 PR #13973 「Save extmark undo information when writing to undofile」이다. chentoast가 2021년 2월 20일에 열었고, 3월 3일에 병합됐다. 목적은 이슈 #13048의 수정, 즉 tree-sitter처럼 바이트 단위 버퍼 변경 이벤트에 의존하는 기능이 undo 파일에서 복원된 상태와도 제대로 맞물리게 하는 것이었다.

스레드에서 메인테이너 bfredl은 처음부터 형식 버전(UF_VERSION)을 올려야 한다고 말했다. 이어서 이렇게 썼다. undo 파일은 「장기 보관이 아니라 런타임 상태의 직렬화(크래시 복구 등)」로 간주하며, 「반란(mutiny)이 일어나면」 구형식 호환을 추가할 수 있지만 쓰는 사람이 없다면 그럴 가치가 없다고. 병합 시점의 공지는 명확했다. 새 버전은 병합 이전의 undo 파일을 읽지 않으며 비호환 버전 오류가 뜬다, 오래된 undo 파일을 복구하려면 Nvim 0.4를 따로 보관하라.

반론은 스레드 안에서 바로 나왔다. jamessan은 「버전이 붙은 파일 형식인데 왜 구버전 읽기 능력을 유지하지 않는가」라고 물었다. bfredl의 답은 기술적이었다. Vim의 undo 헤더는 변경된 줄 전체 단위로 동작하는데, 새 형식은 바이트 단위 해상도를 더한다. 줄 전체를 교체하는 가짜 바이트 편집을 만들어 변환할 수는 있지만 tree-sitter 성능이 떨어지고 바이트 단위 업데이트를 쓰는 플러그인이 예상과 다르게 동작한다. 플러그인 작성자 lervag는 Vim과 Neovim 양쪽 호환을 지금까지 유지해 왔는데 이 변경으로 설정을 분리해야 하게 됐다고 아쉬움을 남겼고, blueyed는 Neovim 0.5용 undodir을 따로 지정하는 우회 설정을 공유했다.

Chisnall 본인은 2021년 7월 13일 이 PR에 코멘트를 남겼다. 그는 undo 파일이 크래시 복구용이라는 정의에 강하게 반대하며, 아직 커밋하지 않은 소스 트리의 이력이 재부팅과 몇 달의 공백을 넘어 남는 것이 Vim에서 가장 좋아하는 기능이라고 썼다. 그리고 두 가지 대안을 제시했다. 파일 이름을 바꿨다면 최소 놀람의 원칙을 어기지 않았을 것이고, 구형식은 읽고 신형식으로만 쓰는 방식이었다면 급격한 데이터 손실을 피할 수 있었다는 것이다.

여기서 기록과 회상이 갈린다. bfredl은 Neovim의 기본 경로는 이미 다르다(당시 ~/.cache/nvim/undo/)고 지적하고, 「읽을 수 없는 파일은 덮어쓰지 않을 것」이라고 답했다. Chisnall은 자신이 VIMINIT 환경 변수로 Vim용 vimrc를 읽게 해 두었기 때문에 Neovim이 Vim과 같은 undodir을 쓰게 됐다고 설명한 뒤, 덮어쓰기에 대해서는 「그렇지 않다(It doesn’t)」고 인정하면서 대신 Neovim으로 먼저 편집한 파일은 Vim이 못 읽는 undo 파일을, 그 반대도 마찬가지로 남게 됐다고 썼다. 그리고 처음 10분 만에 Neovim을 지우고 Vim으로 돌아갔다.

즉 2026년의 Mastodon 글이 말하는 「Vim의 undo 파일을 지우고 대체했다」는 서술과, 2021년 스레드에서 본인이 동의한 「읽을 수 없는 파일은 덮어쓰지 않는다」 사이에는 긴장이 있다. 다만 이 둘은 완전히 모순되지는 않는다. 다음 절에서 보겠지만, 코드가 말하는 「읽을 수 없다」와 사용자가 느끼는 「읽을 수 없다」는 서로 다른 개념이기 때문이다.

해커뉴스 코멘트들도 이 지점을 짚었다. utopcell은 Neovim의 기본 undodir은 XDG_STATE_HOME 아래(~/.local/state/nvim/undo/)이고 Vim은 기본적으로 편집 파일 옆에 저장하므로, 두 편집기가 같은 위치를 쓰도록 설정한 경우에만 충돌이 난다고 정리했다(직접 인용은 아니고, 코멘트의 요지를 요약). jeremyjh는 처음에 「알고도 출시했다」며 강하게 비판하다가, 사용자가 undodir을 공유했다는 세부를 확인하고 이야기가 상당히 달라진다고 코멘트를 수정했다(직접 인용은 아니고, 코멘트의 요지를 요약).

매직 넘버는 확인하고, 버전은 확인하지 않는다

Neovim의 현재 master 소스 src/nvim/undo.c에서 undo 파일을 쓰는 경로를 보면 무슨 일이 일어났는지 구조적으로 이해할 수 있다. 이미 같은 이름의 undo 파일이 있으면 다음 순서로 판단한다.

// If the undo file already exists, verify that it actually is an undo
// file, and delete it.
if (os_path_exists(file_name)) {
  if (name == NULL || !forceit) {
    // Check we can read it and it's an undo file.
    int fd = os_open(file_name, O_RDONLY, 0);
    if (fd < 0) { /* "Will not overwrite with undo file, cannot read" */ goto theend; }
    // ... read UF_START_MAGIC_LEN bytes ...
    if (len < UF_START_MAGIC_LEN || memcmp(mbuf, UF_START_MAGIC, UF_START_MAGIC_LEN) != 0) {
      /* "Will not overwrite, this is not an undo file" */ goto theend;
    }
  }
  os_remove(file_name);
}

(인용은 현재 master에서 발췌·생략한 것이며 2021년 당시 코드와 세부가 다를 수 있다.)

여기서 「읽을 수 없다」는 파일을 open()할 수 없다는 뜻이다. 권한 문제다. 그리고 「undo 파일이 아니다」는 앞쪽 9바이트 매직 넘버가 다르다는 뜻이다. 둘 다 통과하면 파일은 삭제되고 새로 쓰인다. 버전은 확인하지 않는다.

그런데 매직 넘버는 Vim과 Neovim이 똑같다. 양쪽 소스 모두 UF_START_MAGIC을 "Vim\237UnDo\345"로 정의한다. 반면 버전은 다르다. 현재 Vim master의 UF_VERSION은 2, Neovim master는 3이다. 읽기 경로에서 Neovim은 버전이 맞지 않으면 E824: Incompatible undo file 오류를 내고 읽기를 포기한다. 그러나 쓰기 경로는 그 파일을 「undo 파일이 맞다」고 판정하고 지운다.

결과적으로 같은 undodir을 쓰는 사용자가 Neovim에서 파일을 열면 Vim의 undo 이력은 읽히지 않고(오류), 버퍼를 저장하면 그 이력 파일은 삭제되고 Neovim 형식으로 대체된다. bfredl이 말한 「읽을 수 없는 파일은 덮어쓰지 않는다」는 코드 수준에서 사실이다. 그리고 Chisnall이 느낀 「내 이력이 사라졌다」도 사용자 수준에서 사실이다. 두 문장은 「읽는다」를 다르게 정의하고 있을 뿐이다.

이 쓰기 가드는 Neovim이 새로 만든 것이 아니다. Vim의 src/undo.c에도 같은 메시지(「Will not overwrite with undo file, cannot read」, 「Will not overwrite, this is not an undo file」)와 같은 구조가 있다. Vim 문서도 이를 숨기지 않는다. :help undo-persistence의 E824 설명은 「undo 파일의 버전이 더 새로운 Vim이 쓴 것임을 나타낸다. 그 정보를 유지하고 싶다면 버퍼를 저장하지 말라」고 적는다. :wundo 설명은 기존 파일이 undo 파일처럼 보이면 덮어쓰며, 덮어쓰기는 먼저 삭제한 뒤 새로 만드는 방식이라고 밝힌다.

즉 Vim 안에서 이 설계는 합리적이었다. 하나의 프로그램이 형식 버전을 단조 증가시키는 한, 「매직 넘버가 맞으면 내 파일」이라는 판단은 거의 틀리지 않는다. 더 새로운 버전의 파일을 만날 일은 사용자가 Vim을 다운그레이드했을 때뿐이고, 문서는 그 경우를 경고한다. 문제는 포크가 같은 매직 넘버를 유지한 채 독자적으로 버전을 올리면서 생겼다. 그 순간 「매직 넘버가 맞다」는 「내가 쓴 파일이다」를 더 이상 의미하지 않게 됐다. 매직 넘버는 「같은 계열」을, 버전은 「같은 방언」을 뜻하는데, 가드는 계열만 확인한다.

해커뉴스 코멘트 중 lokar의 「다른 파일 이름을 고르기만 하면 됐다」(직접 인용은 아니고, 코멘트의 요지를 요약)는 이 구조를 가장 짧게 요약한다. 실제로 Neovim은 기본 경로를 분리했다. 그러나 경로는 설정으로 덮어쓸 수 있는 값이고, Neovim은 Vim 설정을 그대로 읽어들이는 경로(VIMINIT 등)를 지원한다. 파일 이름이나 확장자, 혹은 매직 넘버 자체를 바꿨다면 설정이 겹쳐도 충돌하지 않았을 것이다.

반대편의 논거도 기록해 둘 필요가 있다. justinmk는 Vim 자체도 외부 도구(git, nano 등)가 파일을 바꾸면 undo 파일을 재설정한다는 점을 재현 절차와 함께 지적했다(직접 인용은 아니고, 코멘트의 요지를 요약). Vim 문서에 따르면 Vim은 파일 내용의 해시로 동기화 여부를 확인하고, 어긋나면 undo 파일을 무시한다. 영속 undo는 애초에 깨지기 쉬운 기능이며, 이를 백업이나 버전 관리처럼 쓰는 것은 기대 과잉이라는 주장이다. soraminazuki는 반대로, 문서가 명시한 무효화 조건은 「파일 내용이 바뀐 경우」뿐이고 「다른 편집기나 업그레이드」는 거기에 없다고 반박했다(직접 인용은 아니고, 코멘트의 요지를 요약).

두 주장은 같은 사실을 다르게 본다. 이력이 무시되는 것과 이력이 삭제되는 것은 다르다. Vim이 해시 불일치로 undo 파일을 무시할 때도, 그 파일은 버퍼를 저장하는 순간 새 파일로 대체된다. 결과는 비슷하지만, 사용자가 예측할 수 있는 조건(내가 다른 도구로 파일을 고쳤다)과 예측할 수 없는 조건(다른 편집기를 한 번 열어 봤다)은 신뢰에 미치는 영향이 다르다.

포크가 물려받는 디스크 위의 계약

이 사건에서 일반화할 수 있는 것은 세 가지다.

첫째, 파일 형식의 호환성은 문서화되지 않아도 계약이 된다. Neovim은 Vim과의 완전 호환을 목표로 내건 적이 없다. 해커뉴스에서 natbennett은 초기 Neovim이 「깨지는 변경을 감수하는 Vim」으로 받아들여졌다고 회상했고, kps는 POSIX vi 준수가 Neovim의 비목표라는 점을 들었다(둘 다 직접 인용은 아니고, 코멘트의 요지를 요약). 그런데도 Neovim은 Vim의 설정 파일을 읽고, Vim과 같은 옵션 이름 undofile과 undodir을 쓰며, 같은 매직 넘버의 파일을 만든다. 사용자는 이 겉모습에서 호환을 추론한다. throwaway899773의 지적처럼, 같은 이름의 기능이 한쪽에서는 안정적이고 다른 쪽에서는 불안정한데 그 차이를 미리 알아야 한다면 데이터 손실은 예측 가능한 결과다(직접 인용은 아니고, 코멘트의 요지를 요약). 2021년 스레드에서 MRAAGH가 undo 파일 비호환을 Neovim의 vim-differences 문서에 적자고 제안한 것도 같은 맥락이다.

반대로 skybrian은 약속은 명시적이어야 하고, 사용자가 일방적으로 상정한 「주의 의무」를 상대가 같은 의미로 이해하리라 기대할 수 없다고 말했다(직접 인용은 아니고, 코멘트의 요지를 요약). 이것도 맞는 말이다. 그러나 파일 형식은 문서가 침묵할 때도 무언가를 말한다. 같은 매직 넘버를 쓴다는 것은 「이 파일은 그 계열의 파일이다」라는 선언이다. 그 선언을 유지한 채 의미를 바꾸면, 명시적 약속이 없어도 암묵적 약속을 깬 것이 된다.

둘째, 「이 파일은 무엇인가」라는 정의가 프로젝트 안에서와 사용자 쪽에서 다를 수 있다. bfredl에게 undo 파일은 크래시 복구용 런타임 상태였다. Chisnall에게는 몇 달치 작업 이력이었다. 해커뉴스에서 dtech와 RVuRnvbM2e는 undo 파일이 ~/.cache에 있으니 캐시이고 지워져도 된다고 주장했지만, jorams는 Vim도 Neovim도 기본값으로 ~/.cache를 쓰지 않으며 Neovim의 기본 경로는 상태 디렉터리(~/.local/state)라고 반박했다(모두 직접 인용은 아니고, 코멘트의 요지를 요약). XDG 규약에서 cache와 state는 바로 「지워도 되는가」로 구분된다. Neovim이 기본 경로를 state 쪽에 둔 것은, 적어도 디렉터리 배치 수준에서는 이 파일을 지워도 되는 캐시로 보지 않았다는 뜻으로 읽을 수 있다. 개발자의 의도가 어떻든, 사용자가 오래 축적한 파일은 사용자의 데이터로 취급하는 편이 안전하다.

셋째, 자기가 만들지 않은 파일을 다룰 때의 규칙이다. applfanboysbgon은 마이그레이션 전에는 반드시 대상 파일을 백업한다고 썼다(직접 인용은 아니고, 코멘트의 요지를 요약). Chisnall이 2021년에 제안한 「구형식은 읽고 신형식으로만 쓴다」, 「이름을 바꾼다」와 함께 놓으면 실천 가능한 규칙 몇 개로 정리된다.

  • 읽지 못하는 파일은 지우지 않는다. 여기서 「읽지 못한다」에는 권한 실패뿐 아니라 버전 불일치도 포함한다.
  • 형식을 바꿀 때는 식별자(매직 넘버, 확장자, 경로) 중 적어도 하나를 함께 바꾼다. 버전 필드만 바꾸면 같은 계열의 다른 구현이 구분하지 못한다.
  • 삭제나 대체가 꼭 필요하면 원본을 옆으로 옮긴다. 디스크 몇 킬로바이트가 사용자 신뢰보다 비싸지 않다.

이 규칙들은 어렵지 않다. 실제로 Neovim 쓰기 가드에 버전 비교 한 줄을 넣는 것으로 첫 번째 규칙은 충족된다. 이 일이 2021년에 일어나지 않은 이유는 기술적 난이도가 아니라 「이 파일은 크래시 복구용」이라는 정의 때문이었을 가능성이 높다. 정의가 정해지면 그 정의에 맞지 않는 사용은 「쓰는 사람이 없다」로 처리된다. 스레드에서 「반란이 일어나면」 호환을 추가하겠다는 말은, 이용자가 목소리를 내기 전까지 그 사용 사례가 보이지 않는다는 구조를 그대로 보여준다.

흥미로운 후일담도 있다. 2022년 8월 Arnie97은 같은 PR 스레드에 구형식 undo 파일을 새 형식으로 올리는 간이 스크립트 ufbump를 공유했고, 2023년 1월 EricDuminil은 그 스크립트로 0.4.4의 undo 파일을 0.8.2용으로 옮겼다고 감사를 남겼다. 메인테이너가 「가치가 없다」고 본 변환을 사용자가 만들어 쓴 셈이다. 해커뉴스에서 utopcell은 지금이라면 LLM에 물어 40줄 이하의 변환 코드를 몇 초 만에 얻을 수 있다고 썼고, asveikau는 오히려 LLM 시대에 이런 세부를 신경 쓰지 않는 속도가 빨라지고 있다고 우려했다(둘 다 직접 인용은 아니고, 코멘트의 요지를 요약). 변환 비용이 0에 가까워질수록 「변환할 가치가 없다」는 논거는 약해진다. 남는 것은 「변환해야 하는 파일이라고 인식했는가」라는 질문뿐이다.

결론

해커뉴스의 논쟁은 상당 부분 「무료 소프트웨어 개발자에게 의무가 있는가」로 흘렀다. recursivedoubts는 처음에 무료로 주는 선물에 의무는 없다고 썼다가, 기존 파일을 하위 호환되지 않는 방식으로 변경했다는 부분을 놓쳤다며 코멘트를 고쳤다(직접 인용은 아니고, 코멘트의 요지를 요약). 이 수정이 논쟁의 핵심을 보여준다. 기능을 안 만드는 것과 사용자의 기존 파일을 바꾸는 것은 다른 범주다.

기록을 따라가 보면 이 사건에 악의나 무관심이 있었다고 단정하기는 어렵다. bfredl은 병합 시점에 비호환을 공지했고, 복구 방법(구버전 보관)을 안내했고, 나중에 Vim 측과 새 공유 형식을 논의하고 싶다고까지 썼다. Chisnall 자신도 2021년에는 덮어쓰기가 아니라는 점에 동의했다. 2026년의 회상은 그때보다 단호하다. 5년 동안 기억이 더 날카로워진 것인지, 당시 표현이 조심스러웠던 것인지는 기록만으로 알 수 없다.

그러나 구조는 분명하다. 같은 매직 넘버를 쓰는 두 구현이 서로 다른 버전을 쓰고, 쓰기 가드는 매직 넘버만 확인하고 지운다. 이것은 어느 한 개발자의 판단이 아니라 포크라는 행위가 만들어낸 조건이다. 부모의 파일 식별자를 물려받는 순간, 부모가 만든 파일도 내 코드의 삭제 경로 위에 올라온다.

당신의 도구는 자기가 만들지 않은 파일을 만났을 때 무엇을 확인하는가. 「내 형식인가」만 묻는가, 아니면 「내가 이 파일을 지워도 되는가」까지 묻는가.


출처: