Reddit

Why building a Rust LSP is hard

Rust LSP를 만드는 것이 왜 어려운가 — rust-analyzer와 Rust Glancer의 설계 비교

실험적 Rust LSP인 Rust Glancer를 개발하는 저자가 rust-analyzer와의 비교를 통해 LSP 서버 개발이 왜 어려운지를 아키텍처 수준에서 설명합니다. 컴파일러와 달리 LSP는 불완전한 상태에서도 즉시 유용한 답을 내놓아야 하며, VFS·증분 계산·워크스페이스 탐색 등 곳곳에 설계상의 난제가 숨어 있다는 것이 핵심입니다.

본문

rust-analyzer의 내부를 다루던 matklad의 블로그 포스트가 2023년을 마지막으로 멈춘 가운데, 실험적 Rust LSP 'Rust Glancer'를 개발 중인 저자가 LSP 서버의 내부 구조와 난제를 정리한 글입니다. 저자는 'LSP를 만든다는 것은 부분적인 정보로 유용한 답을 만들어내야 한다는 뜻'이라는 하나의 주제로 여러 일화를 엮어냅니다.

초기화와 인덱싱의 딜레마

LSP 통신은 클라이언트의 initialize 요청으로 시작되는데, 서버 입장에서는 '언제 응답할 것인가'가 첫 번째 문제입니다. 전체 코드베이스를 인덱싱한 뒤 응답하면 사용자는 수십 초 동안 멍하니 기다려야 하고, 그렇다고 바로 응답하면 곧바로 들어오는 첫 쿼리를 처리할 수 없습니다. 여기서 컴파일러와 LSP의 근본적 차이가 드러납니다. 컴파일러는 '컴파일됐다/안 됐다'의 이분법적 완료 정의를 갖지만, LSP는 불완전한 상태에서도 거의 즉시 유용한 결과를 줄 수 있습니다. 그래서 rust-analyzer와 Rust Glancer 모두 설정만 검증한 뒤 응답하고, rust-analyzer는 응답 직후 워크스페이스 탐색을 예약하며 Rust Glancer는 첫 쿼리가 올 때까지 수동으로 대기합니다.

파일시스템이 없는 프로토콜과 VFS

LSP 프로토콜은 파일시스템을 전제하지 않습니다. 문서와 편집만 있을 뿐이며, 저장되지 않은 문서도 다뤄야 하기 때문입니다. 하지만 단일 파일만으로는 분석이 금방 한계에 부딪히므로 서버는 결국 파일시스템에 접근해야 하고, 게다가 에디터 밖에서 파일이 변경될 수도 있습니다. 그래서 프로젝트 소스를 메모리에 올린 VFS(가상 파일 시스템)와 'source generation'(현재 요청 시점의 소스 상태 식별자)이 필요하며, 상태가 바뀔 때마다 generation을 갱신해 내부 상태의 일관성을 유지하고 무효화된 진행 중 쿼리를 취소합니다. 또한 hover처럼 저렴한 쿼리와 references처럼 무거운 쿼리가 섞이므로 읽기 쿼리는 병렬로 실행해야 하고, 변경을 가하는 쿼리와 분리해야 합니다. async 환경에서는 didOpen과 didChange의 순서가 뒤집히지 않도록 메시지를 직렬화하는 것도 디버깅 지옥을 피하는 핵심입니다.

쿼리마다 요구하는 분석 깊이가 다르다

document symbols는 현재 파일 하나만 파싱해 CST를 만들면 답할 수 있어 LSP가 '빠르게 유용해지는' 좋은 예시입니다. 반면 fn foo(a: Bar)에서 Bar 위의 hover는 어느 정도의 의미 분석이 필요합니다. 커서 아래 대상이 어디서 왔는지 알려면 파일 파싱 → item tree 구축 → 모듈 해석과 definition map(defmap) 구축 → 가시성 확인 → 문서 추출이라는 단계를 거쳐야 합니다. inlay hint는 본문(body) 내부 분석과 타입 추론이 필요해 훨씬 어렵고, 최종 보스는 textDocument/references입니다. Option 같은 심볼의 모든 사용처를 워크스페이스 전체에서 찾으려면 수천 개의 본문을 구분해 봐야 하므로, 텍스트 매칭으로 후보 파일을 좁히는 'reference search plan' 같은 기법이 필요합니다. 저자는 'LSP 개발은 곧 사용자 경험을 망치는 병리적 사례와의 싸움'이라고 지적합니다.

salsa의 lazy 모델 vs eager 인덱싱

인덱싱 계산 방식에서 두 구현은 갈라집니다. rust-analyzer는 증분 데이터베이스 salsa를 써서 '인덱싱' 자체가 없는 모델을 취합니다. 입력과 출력의 관계망을 정의하면 출력이 지연 계산·메모이제이션되고, 입력이 바뀌면 관련 부분만 무효화됩니다. 다만 초기 반응성을 위해 semantic layer까지 미리 계산하는 cache priming을 기본 활성화합니다. Rust Glancer는 반대로 저메모리와 빠른 에디터 재시작을 목표로 최대한 많은 작업을 eager하게 수행한 뒤 상태를 파일시스템에 오프로드합니다. 초기 인덱싱이 더 오래 걸리지만 이후에는 거의 계산할 게 없고, 재시작 시에도 거의 즉시 복원됩니다.

워크스페이스 탐색과 사용자 의도

열린 폴더가 단일 Rust 워크스페이스라는 보장은 없습니다. 여러 워크스페이스가 섞여 있거나, Cargo.toml 없이 .rs 파일만 있을 수도 있습니다. cargo check처럼 명시적 요청과 달리 사용자가 폴더를 연 것만으로는 의도를 알 수 없어 정답이 없는 영역입니다. rust-analyzer는 즉시 자식 폴더의 Cargo.toml을 찾아 eager하게 탐색하고 필요하면 프로젝트 디렉터리 밖까지 나가며, 워크스페이스 하나라도 처리에 실패하면 전체 서버가 에러 상태로 표시됩니다. Rust Glancer는 반대로 Cargo.toml이 범위 안에 있어야 분석하고 사용자가 실제로 열 때까지 인덱싱을 미루며, LSP 서버를 라우터로 두고 워크스페이스마다 별도 프로세스(engine)를 띄워 한 크레이트의 크래시가 전체를 죽이지 않게 했습니다. 이는 메모리 단편화 감소에도 도움이 되지만 구조가 훨씬 복잡해지는 대가가 있습니다.

프로토콜은 어느 정도 새어 나온다

LSP는 바이트 오프셋이 아니라 행·열과 UTF-16으로 말합니다. 서버 내부에서는 UTF-8 오프셋으로 작업하고 경계에서만 변환하는 게 좋지만, 효율적 변환을 위해 파일별 line index를 따로 구축해야 합니다. 코드 액션으로 편집을 생성할 때는 해당 파일이 \r\n인지 \n인지 줄바꿈 종류도 감지·저장해야 하고, 파싱에도 edition이 필요합니다. gen이 식별자인지 키워드인지 모르기 때문에 파일 하나조차 단독으로 분석할 수 없고 Cargo.toml 같은 메타데이터가 필요합니다. 파싱처럼 단순해 보이는 작업조차 상태를 요구한다는 것이 저자의 지적입니다.

인덱싱 설계의 세 가지 접근

rust-analyzer 블로그의 분류를 인용해, LSP는 (1) 빠른 per-file 'shallow analysis'와 무거운 'full analysis'를 나누는 방식(Rust Glancer), (2) 컴파일러 상태를 스냅샷하는 방식(초기 Rust LSP인 RLS — Rust에는 비효율적이었음), (3) salsa 같은 incremental/query-based 방식(rust-analyzer)으로 나뉩니다. 인덱싱 단계 자체는 파싱 → item tree 구축 → definition map 구축 순으로 대체로 동일하다고 설명합니다.

Reddit 반응

  • @u/teerre — "컴파일러의 '완료' 정의가 이분법적이라는 말은 정확하지 않다. rustc에는 맞는 말이고 일반적인 컴파일러 다수에도 해당할 수 있지만, incremental 컴파일러도 존재한다. 이론적으로도 실제로도 컴파일러는 불완전한 프로그램에 대해 타이핑하는 동안 피드백을 줄 수 있다."
  • @u/popzxc — "이 블로그 글을 쓰는 데 몇 주가 걸렸으니 재미있게 읽어주길 바란다. 의도적으로 LLM 기반 편집을 피했으니 어색한 영어는 양해해 달라." (글 저자 본인의 코멘트)

원문: Rust Glancer Blog / 번역·요약: Trawling