Rust LSP가 부분적인 정보로 유용한 답을 만들어 내기까지 마주하는 설계 선택, 인덱싱, 편집기 통합의 복잡성을 Rust Glancer와 rust-analyzer의 관점에서 살펴봅니다.
아주 오래전, 위대한 matklad은 Rust 도구가 어떻게 작동하는지에 관한 훌륭한 글을 쓰곤 했습니다. 좋은 시절이었지만, 아쉽게도 마지막 rust-analyzer 블로그 글은 2023년의 글입니다
저는 matklad은 아니지만, 실험적인 Rust LSP인 Rust Glancer를 꽤 오랫동안 만들고 있습니다. 아마 제가 작업한 프로젝트 중 가장 흥미롭고 야심 찬 프로젝트일 것이며, 이를 만들며 배운 몇 가지를 공유하고 싶습니다.

이 글은 rust-analyzer와 Rust Glancer 양쪽의 관점에서 Rust LSP가 작동하는 방식을 다루는, 바라건대 일관된 이야기가 될 것입니다. 쉬워 보이는 일이 어려워지는 방식, 어려워 보이는 일이 훨씬 더 어려워지는 방식, 그리고 전혀 존재할 것이라 예상하지 못했던 일이 어째서 존재하는지를 다룹니다.
물론 블로그 글 하나로 모든 것을 다룰 수는 없습니다. 이 글은 진행 중에 언급하는 특정 주제를 깊게 파고들기보다는 매우 기술적이되 여전히 아키텍처 중심의 개요가 될 것입니다. 제가 게으르지만 않다면, 그런 주제들은 별도 글로 다룰 예정입니다.
그 외에는, 한 가지 공통점이 있는 수많은 일화적인 장을 맞이할 준비를 하세요. LSP를 만든다는 것은 불완전한 정보로 유용한 답을 만들어 내야 한다는 뜻입니다.
면책 사항: 저는 LSP 구축 전문가가 아니며, 이 글의 목적은 독자에게 LSP의 내부 구조와 주의점을 흥미롭게 소개하는 것이지, 명확하고 형식적인 설계 개요를 제공하는 것이 아닙니다. 의도적으로 컴파일러 전문 용어를 사용하지 않으며, 정확성보다 전반적인 의미에 집중하기 위해 여러 곳에서 근사적인 표현을 사용합니다. 글에는 더 상세하고 정확한 자료로 향하는 링크가 많이 있으니, 꼭 살펴보시기를 권합니다!
또한 이 글을 준비하기 전과 준비하는 동안 rust-analyzer 코드를 많이 읽었지만, 저는 rust-analyzer 유지 관리자가 아닙니다. 일부를 잘못 이해했다면 죄송합니다.
LSP 서버에는 서로 반대편에 있는 두 끝이 있습니다. Language Server Protocol을 구현하는 서버, 즉 “여기에 요청을 보내면 형식이 올바른 응답을 받을 수 있다”는 부분과, 우리가 제공하려는 실제 상태, 즉 “보낸 질의가 실제로 필요한 일을 수행하고 어떤 종류의 인덱싱된 상태 위에서 동작한다”는 부분입니다. 첫 번째는 이미 해결된 문제처럼 보입니다. 특히 tower-lsp-server가 존재하니까요. 음, 그렇지는 않습니다. 거기서 시작해 보죠. 실제로 필요해졌을 때 점차 인덱싱으로 넘어가겠습니다.
LSP는 클라이언트가 initialize 요청을 보내면서 시작하며, 이 요청은 LSP를 초기화하도록 요구합니다. 당연하군요. 응답하기 전까지 클라이언트는 아무것도 하지 않습니다. 응답하면 클라이언트는 initialized 알림을 보내고, 모든 것이 준비되며 LSP 통신이 시작됩니다.
문제는 이 요청에 언제 응답하느냐입니다. 서버가 시작된 순간에는 아무것도 없습니다. 프로젝트에 대해 아는 것이 없고, 이 요청을 받아야만 어떤 코드베이스를 다루는지 알게 됩니다. 실제로 질의에 답하려면 이를 “인덱싱™”해야 합니다. 아직 인덱싱이 무엇인지는 모르지만, 분명 일이 아주 많습니다.
모두 인덱싱할 때까지 막을까요? 그러면 사용자는 편집기가 거의 쓸모없는 상태로 10~20~50~100초를 기다리게 됩니다. 선택지가 아닙니다. 바로 시작할까요? 그러면 현재 열려 있는 파일에 관한, 곧바로 들어올 첫 질의에는 무엇을 답하나요? 그때 대신 막히지 않을까요? 무서운 “모든 것을 인덱싱해야 한다” 문제를 어떻게 피할까요?
여기서 컴파일러와 LSP의 첫 번째 큰 차이가 드러납니다. 컴파일러에는 완료에 대한 꽤 이분법적인 정의가 있습니다. 바이너리는 컴파일됐거나 그렇지 않았습니다. 기술적으로는 컴파일된 공유 라이브러리와 다른 빌드 산출물도 쓸 수 있지만, 실제로는 워크스페이스의 크레이트 716개 중 715개를 컴파일하고 멈추면 짜증 날 겁니다. LSP는 다릅니다. 우리는 거의 즉시 유용한 결과를 제공할 수 있습니다. 첫 밀리초에 완전한 정보가 필요한 것이 아니라, 가능한 한 빨리 사용자에게 유용한 것을 보내야 합니다. 무엇을 “유용한 것”으로 셀지만 결정하면 됩니다.
처음 질문의 답은 이렇습니다. 질의를 처리할 수 있게 만드는, 유용한 작업 중 최소한만 해야 합니다. 따라서 rust-analyzer와 Rust Glancer는 모두 제공된 설정만 검증하고 응답합니다. rust-analyzer는 응답 후에 워크스페이스 탐색을 시작하도록 예약하는 반면, Rust Glancer는 첫 질의가 올 때까지 수동적으로 머뭅니다.
핸드셰이크가 끝나면 진짜 일이 시작됩니다. 첫 실제 질의를 받습니다. 대개 textDocument/didOpen, textDocument/inlayHint, textDocument/documentSymbol일 것입니다. 사용자가 의욕적이고 운이 나쁘다면, 이들 사이에 textDocument/didChange까지 있을 수 있습니다. 그리고 복잡도가 폭발합니다.
첫째, 재미있는 사실입니다. 프로토콜로서 LSP는 여러분이 파일 시스템을 생각하지 않기를 바랍니다. 파일 시스템은 없고 문서와 편집만 있습니다. 이것은 말이 됩니다. 문서가 저장되지 않았을 때가 많아 내용을 알 수 없기 때문입니다. 하지만 실제로는 그렇지 않습니다. 대부분의 언어에서 파일 하나 또는 열린 파일 집합만을 고립시켜 분석하는 것은 금세 쓸모없어집니다.
지옥에 오신 것을 환영합니다. LSP는 자신이 진실의 원천이라고 가정하지만, 여러분은 여전히 파일 시스템에 직접 접근해야 하고 동기화된 방식으로 해야 합니다. 더 재미있게도 편집기 밖에서 편집이 일어날 수 있으며, 클라이언트가 그런 이벤트를 아주 충실히 알려 주지 않을 수도 있습니다. 그래서 가상 파일 시스템과 “소스 세대”, 가령 현재 실행 중인 요청 시점의 소스 코드 상태를 나타내는 식별자가 필요합니다. 파일 시스템 접근과 LSP 알림을 순진하게 결합하려 하면 프로젝트 전체가 끝나지 않는 경쟁 상태가 됩니다. 대신 프로젝트 소스를 메모리에 불러와 VFS라고 선언하고, 불러온 상태 위에 모든 변경을 최대한 적용합니다. 상태를 바꿀 때마다 소스 세대를 갱신합니다. 이렇게 일관된 내부 상태를 가지며, 무효화된 진행 중 질의를 취소할 수 있습니다.
“무슨 진행 중 질의요?”라고 묻는 소리가 들립니다. 두 번째 재미있는 사실이 바로 그것입니다. LSP 질의를 실행하려면 놀랄 만큼 많은 작업이 필요할 수 있고, 모든 질의가 동일하지 않습니다. 심볼의 참조를 찾는 일은 꽤 만만치 않지만 hover는 보통 저렴합니다. 따라서 한 번에 질의 하나만 처리하는 것은 선택지가 아닙니다. 읽기 질의는 병렬로 실행해야 합니다. 그리고 상태가 바뀔 때마다 현재 실행 중인 질의는 이제 낡은 상태를 상대로 쓸모없는 작업을 하게 됩니다. 여러분의 일은 변경 질의와 비변경 질의를 분리하고, 읽기 요청이 병렬 실행되게 하며, 상태가 바뀌면 작업을 취소하는 루프를 만드는 것입니다. 또한 비동기를 운 나쁘게 사용한다면 들어오는 메시지를 직렬화해서 didOpen과 didChange가 역순으로 오지 않게 해야 합니다. 디버깅하기 지옥 같은 문제가 될 수 있죠. 제가 왜 이 말을 해야 했는지 궁금하군요.
세 번째이자 마지막 재미있는 사실은 LSP 작성자들이 실제로 여러분이 준비되지 않았을 수 있음을 고려했고, workspace/inlayHint/refresh 서버 요청처럼 이를 다룰 유용한 도구를 주었다는 것입니다. 이 강력한 도구로 “앗, 이제 다시 시도해 주세요”라고 말하고, 처음에는 아무것도 보내지 않았더라도 실제 응답을 보낼 수 있습니다. 문제는 모든 것을 새로 고칠 수는 없다는 점입니다. 문서 심볼은 안 됩니다. 즉시 보내지 않으면 클라이언트가 다시 물을 때라고 판단할 때까지 낡은 상태로 남습니다. 일부 질의에서는 창의적이어야 한다는 뜻입니다.
하지만 곁길로 샜습니다. 클라이언트는 인레이 힌트와 문서 심볼을 기다립니다.
그리고 우리는 아직 아무것도 인덱싱하지 않았습니다.
무엇을 할까요?
운이 좋게도 문서 심볼에 답하려면 정말 하나만 인덱싱하면 됩니다. 현재 열려 있는 파일입니다. 그리고 이것은 LSP가 아주 빠르게 유용해지는 완벽한 사례입니다. 이 요청에 답하려면 파일을 파싱하기만 하면 됩니다. AST, 아니 정확히는 CST이지만 곧 다루겠습니다, 는 어떤 구조체, 트레이트, 함수, 메서드 등이 있는지 이미 알려 줍니다. 요청의 일부로 필요할 때 처리할 수도 있습니다.
fn foo(a: Bar) {}에서 Bar 위의 textDocument/hover는 조금 더 까다롭습니다. 적어도 어떤 형태로든 _의미 분석_이 필요합니다. 커서 아래의 것이 어디서 왔는지 알아야 합니다. 이를 알기 위해서는 스코프에서 어떤 항목, 즉 구조체나 메서드 등이 사용 가능한지 이해해야 합니다. 그러려면 정의 맵이 필요합니다. 각 크레이트와 모듈마다 “어디에서 무엇을 볼 수 있는가”를 해결한 맵입니다. 정의 맵을 얻으려면 한 단계 더 낮추는 과정이 필요합니다. 여전히 AST/CST 수준에서 작업할 수는 있지만 편하지 않을 것입니다. 아마 파일마다 정의된 항목을 나타내는 자체 표현인 _항목 트리_가 필요할 것입니다. 그러므로 각 파일을 파싱하고 -> AST/CST에서 항목 트리를 만들고 -> 모듈을 해결해 정의 맵을 구축하고 -> 커서 아래에서 보이는 것을 확인하고 -> 정의 맵을 통해 찾고 -> 해결된 항목의 문서를 추출하고 -> 보여 주어야 합니다. “커서 아래”가 무슨 뜻인지 궁금하다면 훌륭한 질문이니 맛있는 것을 드세요. 나중에 돌아오겠습니다. 지금으로서는 꽤 많은 추가 작업이지만, 그래도 상당히 관리할 수 있습니다.
인레이 힌트는 훨씬 더 까다롭습니다. 본문 _내부_에 나타나기 때문입니다. fn foo() { let a = bar(); }에서 fn foo는 _항목 선언_이고 { let a = bar(); }는 진짜 무서운 부분 본문이라고 할 수 있습니다. 위에서 설명한 의미 모델은 본문을 전혀 신경 쓰지 않았다는 점에 유의하세요. 그뿐 아니라 좋은 인레이 힌트를 위해서는 _타입 추론_이 필요합니다. 지금은 더 설명하지 않겠습니다.
하지만 여기서 끝이라고 생각했다면, LSP의 최종 보스를 보세요. textDocument/references입니다. 인레이 힌트에는 _파일 하나_의 본문을 분석하면 됩니다. 참조의 경우, VS Code에서 함수 정의 위에 순진하게 option+shift+F12를 누르면, 불쌍한 서버는 워크스페이스 그래프 전체에서 발견 가능한 모든 곳에 있는 _그 함수의 모든 사용처_를 찾아야 합니다. Option을 생각해 보세요. 수천 개의 본문을 빠르게 훑으면서 바로 이Option을 Option이라는 이름의 다른 항목과 구분해야 합니다. 더 큰 문제가 생깁니다. 모든 본문을 분석해 가지고 있더라도, 그중 어느 것이 Option을 언급하는지 보려고 전부 선형으로 훑고 싶지는 않을 것입니다. 여기서 LSP 특유의 장난이 등장합니다. 텍스트 매칭으로 참조 검색 계획을 만들고, 이 식별자를 포함할 수도 있는 파일 일부만 찾아 그곳의 본문만 훑을 수 있습니다. 그래도 작업이 많을 수 있습니다. “참조 검색 계획”이 무엇인지 궁금하다면 rust-analyzer에 훌륭한 글이 있습니다. 위대한 matklad에게 만세!
덧붙이면, “그래, Option은 텍스트 일치가 많지만 병적인 사례잖아”라고 생각한다면… LSP 구축은 사용자 경험을 망치는 병적인 사례에 전부 관한 일입니다. 이것 또한 LSP가 어려운 이유 중 하나입니다.
여기서 중요한 두 가지가 있습니다.
그리고 LSP가 누릴 수 있는 자유 중 하나는 이 정보를 활용하는 방법입니다. rust-analyzer와 Rust Glancer는 기술적으로 파싱 / 항목 트리 / 정의 맵 / 의미 계층 / 본문 계층을 모두 갖습니다. 여기서는 Rust Glancer 용어를 쓰지만 rust-analyzer에 익숙한 사람은 무엇이 무엇인지 바로 이해할 것이라 생각합니다. 다만 이 데이터를 계산하는 방법이 다릅니다.
rust-analyzer는 증분 데이터베이스인 salsa를 사용합니다. 입력과 입력을 출력으로 옮기는 논리를 정의할 수 있고, 그러면 출력은 느긋하게 계산되어 메모이제이션됩니다. 일부 입력이 바뀌면 관련된 출력 부분만 무효화하고 다시 계산합니다. rust-analyzer 모델은 우아합니다. 인덱싱이 아예 없습니다. 입력과 코드베이스 상태 사이에 관계망이 있어서, 언제든지 _상태_를 요청하면 salsa가 그것을 계산해 줍니다. 직접 요청된 것 외에는 무엇도 “인덱싱”할 필요가 없습니다. 솔직히 salsa는 마법처럼 느껴집니다. 익숙하지 않다면 며칠 저녁을 들여 알아보기를 강력히 권합니다. 이전과 같지 않을 것입니다. 지속 가능한 증분성과 salsa 문서도 보세요. 하지만 salsa가 있어도 장난은 필요합니다. 모든 질의가 필요한 것만 계산한다면, 메모이제이션이 있어도 계산되지 않은 상태가 아주 많이 남아 편집기가 처음에 버벅거릴 수 있습니다. 그래서 rust-analyzer는 기본적으로 _캐시 프라이밍_을 활성화합니다. 이에 대한 블로그 글은 없지만 이 PR이 최신 기술입니다! 워크스페이스 전체에 대해 의미 계층까지 사실상 인덱싱합니다. 이 정보는 항상 가까이에 필요할 가능성이 높기 때문입니다. 본문은 정말 필요해질 때까지 기다릴 수 있습니다.
Rust Glancer는 다릅니다. 낮은 RAM 사용량과 즉각적인 편집기 재시작에 초점을 맞추며, 이 둘은 함께 갑니다. Rust Glancer는 가능한 한 많은 작업을 적극적으로 수행하고, 한 번 _모든 것_을 인덱싱한 뒤 상태를 파일 시스템으로 내보내 초기 인덱싱 후에는 많이 계산하지 않아도 되게 합니다. 하지만 여기서도 장난이 필요합니다! 전체 인덱싱은 시간이 오래 걸리므로, 우선 Rust Glancer는 의미 분석의 관련 부분이 끝나는 즉시 질의에 답하기 시작하고, 본문에 대해서는 현재 열린 파일을 우선합니다. rust-analyzer와 비교하면 초기 인덱싱은 더 오래 걸리고, 적극적이며 더 많은 작업을 하므로 현재는 더 많은 RAM을 소비할 수 있습니다. 하지만 그 뒤에는 사실상 끝입니다. 무언가 바뀌면 관련 부분만 갱신합니다. 편집기를 재시작해도 상태는 파일 시스템에 남아 있으므로 인덱싱이 거의 즉시 끝납니다. 전체 재인덱싱은 드문 경우, 예를 들어 워크스페이스 그래프가 바뀔 때만 필요합니다.
질의로 돌아가면, 이제 LSP에는 제공하려는 상태가 실제로 있으며 준비되었을 수도, 아닐 수도 있습니다. 엔진이 준비되지 않을 때마다 가능한 한 유용한 부정확한 답을 제공할 수 있으며, 대부분의 경우 상태가 계산된 후 클라이언트에 결과를 새로 고치라고 요청할 수 있습니다.
이것이 LSP의 작동 방식입니다! 읽어 주셔서 감사합니다! 다만…
첫 장의 “워크스페이스들?”을 눈치챘고, 열린 폴더가 단일 Rust 워크스페이스라고 보장되는 것처럼 왜 쓰는지 궁금하실 겁니다. 실제로는 전혀 그렇지 않기 때문입니다. 열린 폴더에는 폴더 5개가 있고, 그중 3개는 Rust 워크스페이스이며 2개는 아닐 수 있습니다. 열린 폴더는 워크스페이스 내부의 크레이트일 수 있습니다. 열린 폴더에는 Rust 크레이트가 아닌데 Rust 파일이 있을 수도 있습니다. 이전 장은 실제로 너무 멀리 뛰어갔으니 설계판으로 돌아가야 합니다.
간단한 질문부터 시작합시다. LSP는 어떻게 활성화되고, 활성화된 뒤 프로젝트가 무엇인지 어떻게 결정할까요? 당연히 클라이언트에서 옵니다. 클라이언트를 제어한다면 직접 설명할 수 있습니다. 예를 들어 현재 폴더에는 Cargo.toml 파일이 있어야 한다고 할 수 있습니다. 또는 바로 아래 자식 폴더 중 어디든 Cargo.toml을 포함할 수 있다고 할 수 있습니다. rust-analyzer가 이렇게 합니다. 그러면 워크스페이스 N개가 있는 폴더를 열 때 모두 발견되어 인덱싱을 시작합니다. 더 나아갈 수도 있습니다. 워크스페이스 폴더 안에 워크스페이스가 있는지 재귀적으로 훑는 것입니다. 예를 들어 부모 워크스페이스 Cargo.toml의 exclude 아래에 있을 수 있습니다. 이것은 극단적인 버전이며 rust-analyzer는 그렇게 하지 않습니다.
반대 문제도 있습니다. 폴더에 Rust 파일은 있지만 Cargo.toml이 없다면 어떨까요? 클라이언트는 *.rs 파일을 열 때 여전히 LSP 활성화를 시도할 수 있습니다. 그러면 어떻게 하나요? 한 전략은 cargo locate-project로 루트가 존재한다면 찾아내고, 루트가 디렉터리 밖에 있어도 코드베이스를 인덱싱하는 것입니다. 하지만 사용자가 원하는 일일까요? 전체 분석을 원하지 않아 일부러 특정 폴더를 열었을 수도 있습니다.
마찬가지로 여러 프로젝트가 있는 폴더를 열 때, 사용자는 모두를 발견하고 분석하길 원할까요? 그럴 때도 있습니다. 한 시간 전에 IDE를 열었는데 새 프로젝트를 열자 인덱싱되지 않은 것을 보는 것은 짜증 날 수 있습니다. 아닐 때도 있습니다. 무거운 프로젝트 8개가 든 폴더를 열었더니 LSP가 전부 병렬 인덱싱을 시작해 CPU 팬이 윙윙거리는 것은 짜증 날 수 있습니다.
명시적인 사용자 요청인 컴파일이나 cargo check 실행과 달리, LSP에서 사용자의 의도는 명확하지 않습니다. 사용자는 폴더를 열었을 뿐이지 반드시 어느 한 동작을 원한다는 신호를 보낸 것은 아닙니다. 그러므로 정답은 없고 프로젝트 작성자의 결정만 있습니다.
rust-analyzer는 워크스페이스 탐색에 적극적이며, 캐시 프라이밍을 켜면 꽤 눈에 띌 수 있습니다. 마찬가지로 도움을 주려 하며, 좋은 사용자 경험에 필요하다면 프로젝트 디렉터리 밖으로 나갑니다.
Rust Glancer는 여기서 거의 반대 입장을 취합니다. 분석이 실행되려면 범위 안에 Cargo.toml이 반드시 있어야 하며, 실제로 열기 전에는 워크스페이스 인덱싱을 시작하지 않습니다. 어떤 면에서는 더 느긋하고 엄격합니다. 사용자를 위해 추측하려 하지 않고 사용자가 제공한 범위를 벗어나지 않으려 합니다. 그래도 반론은 가능합니다. 여전히 cargo 레지스트리는 확인하기 때문입니다. 정책을 완전히 순수하게 만들 수는 없습니다.
하지만 문제는 여기서 끝나지 않습니다. 폴더에 Rust 워크스페이스가 둘 있다고 상상해 보세요. 하나는 올바른 형식이고 하나는 아니라면요? 이상한 부분은 LSP 자체가 이를 구분할 도구를 많이 제공하지 않는다는 점입니다. rust-analyzer에서는 어떤 이유로든 워크스페이스 하나라도 처리할 수 없으면 서버 전체가 오류 상태가 되고 VS Code 상태 패널에서 빨갛게 표시됩니다. 다른 크레이트는 작동하는데도 말입니다! 하지만 rust-analyzer는 여전히 모든 워크스페이스를 단일 프로세스로 관리합니다. salsa의 또 다른 좋은 성질로, 그런 모델이 꽤 자연스럽습니다. 따라서 크레이트 하나가 rust-analyzer를 충돌시킬 수 있다면 전역적으로 충돌합니다.
Rust Glancer는 다시 다른 접근을 취합니다. LSP 서버 자체는 라우터일 뿐이고, 각 워크스페이스는 별도 프로세스인 엔진으로 모델링됩니다. LSP 서버는 필요할 때 엔진을 생성할 수 있고, 엔진용 자체 통신 프로토콜이 있으며, 어느 편집기의 충돌도 전역 충돌을 뜻하지 않습니다. 덤으로 낮은 메모리 사용에도 도움이 됩니다. 서로 다른 엔진의 데이터가 섞이지 않아 단편화가 줄어들기 때문입니다. 서로 다른 수명을 지닌 많은 할당이 바로 메모리 단편화를 만드는 방식입니다. 하지만 단점도 있습니다. 훨씬 더 복잡하고 일반적으로 LSP 설계에 맞서 싸우게 됩니다. 상태 보고에도 상당한 장난이 필요합니다.
여기서 얻을 유용한 교훈은 LSP 자체가 잘 정의된 프로토콜이더라도, 구현체에게 정확히 어떻게 동작하고 사용자 의도를 어떻게 해석할지 결정할 넓은 공간을 준다는 것입니다. 어느 접근도 본질적으로 옳거나 그르지 않습니다. 무엇을 우선할지는 여러분이 결정합니다. 사용자들도 무엇이 옳은지 서로 다른 의견을 실제로 갖습니다.
지금까지 LSP에 관한 재미있는 사실을 몇 개나 배웠을까요? 자, 다음 사실입니다.
LSP는 _프로토콜_을 정의하며, 프로토콜은 특정 영역 안의 통신에 맞게 이상하게 최적화되는 경우가 많습니다. 그리고 그 영역은 당연히 편집기입니다. 바이트 오프셋이나 문자 인덱스가 아니라 줄과 열로 말합니다. 게다가 프로토콜은 서버가 UTF-16을 말할 줄 알기를 요구합니다. UTF-16을 좋아하지 않는 사람이 있을까요?
문제는 우선 줄, 열, UTF-16으로 작업하는 일이 별로 편하지 않다는 것입니다. 서버 자체는 오프셋과 UTF-8로 작업하게 하고, 실제 프로토콜 통신 경계 근처에서만 이 값을 변환하도록 하는 일종의 프로토콜 브리지가 필요할 것입니다. 하지만 이는 꽤 정상적이며, 다루는 프로토콜과 무관하게 모범 사례라고 할 수 있습니다. 애플리케이션의 도메인 모델은 프로토콜의 도메인 모델과 같을 필요가 없으며, 서로 동형이면 충분합니다.
하지만 질문이 생깁니다. 평소에 오프셋으로 작업한다면 이를 어떻게 줄과 열로 변환할까요? 파일 전체 텍스트를 파싱하고 줄로 나누고 오프셋을 옮기는 것은, 으, _조금 비효율적_일 겁니다. 프로토콜 도메인이 표현 내부에 _필수적_인 것은 아니지만, 변환을 효율적으로 만드는 도구는 여전히 필요합니다. 예를 들어 파일마다 줄 인덱스를 만드는 방식입니다. rust-analyzer와 Rust Glancer 모두 그렇게 합니다.
재미있는 점은 LSP를 추상화해 내보내고 싶어도 완전히 해낼 수는 없다는 것입니다. 여전히 아키텍처에 스며듭니다.
그리고 “부착된 메타데이터”는 거기서 멈추지 않습니다. 분석과 읽기 질의 외에도 LSP는 편집에도 쓰입니다. 보통 임포트를 처리하고, 스니펫을 가지며, 한정 경로를 임포트로 바꾸거나 누락된 트레이트 멤버를 구현하는 코드 액션을 지원합니다. 그런 편집에는 흔히 무엇이 들어갈까요? 줄 바꿈입니다! 하지만 어느 종류일까요? Windows에서는 언제나 \r\n이고 Unix에서는 언제나 \n이라고 가정할 수는 없습니다. 잘못 추측하면 일관성 없는 편집을 하게 됩니다. 즉 줄 인덱스 외에도 이 특정 파일이 사용하는 줄 끝 종류를 감지하고 저장해야 합니다. 참고로 또 다른 리팩터링 도구인 rustfmt도 이를 고려해야 하지만, 세밀한 편집 대신 파일 전체를 다시 쓰므로 동작을 자동 감지, Unix, Windows, 네이티브인 OS 기본값으로 설정할 수 있습니다.
메타데이터는 여기서도 멈추지 않습니다. 파일을 올바르게 파싱하려면 에디션도 알아야 합니다. 그렇지 않으면 gen이 식별자인지 키워드인지 알 수 없습니다. 즉 파일을 고립시켜 분석할 수는 없습니다. 제대로 파싱하는 법을 알기 위해서도 Cargo.toml 또는 다른 종류의 프로젝트 메타데이터가 필요합니다.
보시다시피 메타데이터의 요구는 LSP, 파일 내용, Rust 자체 등 모든 가능한 방향에서 옵니다. 파싱처럼 단순한 작업조차 상태를 가져야 한다는 점이 어떤 면에서는 재미있습니다.
좋습니다. 긴 글인데 가장 어려운 부분인 인덱싱은 아직 잠깐만 다뤘습니다.
문제는 인덱싱이 정말로 가장 어렵고, 솔직히 그 자체로 비슷한 분량의 글 여러 편이 필요하다는 것입니다. 하지만 LSP 여정에 빈틈이 남지 않도록 고수준 개요를 보겠습니다.
먼저 중요한 점입니다. LSP는 근본적으로 서로 다른 설계를 가질 수 있으며 인덱싱에 다르게 접근할 수 있습니다. 다시 한 번 rust-analyzer 블로그에 좋은 글이 있습니다. 요약하면 다음과 같습니다.
이 접근들은 인덱싱을 어떻게 실행하는지 정합니다. 하지만 인덱싱 단계는 대체로 같을 것입니다. Rust에서는 다음과 같습니다.
impl Foo는 crate::a::Foo와 관련 있나요, crate::b::Foo와 관련 있나요? “연결된 항목 상태”를 만드는 단계가 필요합니다. 어떤 고유 항목이 있는지, 어떤 impl이 무엇에 대응하는지, 어떤 트레이트 impl이 어떤 트레이트와 트레이트 구현자에 대응하는지 말입니다. 여기서 인덱스를 구축하는 일은 특히 중요합니다. 구조체의 항목을 열거할 수 있어야 하므로, 이론적으로 연결되지 않은 항목 트리로 작업할 수는 있어도 효율적이지도 즐겁지도 않을 것입니다.전체 워크스페이스를 분석했든 단일 질의에 필요한 만큼만 작업했든, 인덱싱 끝에는 _인덱싱된 상태_에 도달합니다. 프로젝트에 선언된 것들의 표현이며, 이를 통해 이 변수가 어떤 타입인지, 어떤 메서드를 쓸 수 있는지, 이 구조체에는 어떤 문서를 보여야 하는지 등에 답할 수 있습니다.
여기서 중요한 부분은 인덱싱이 단지 모든 것을 훑을 필요는 없다는 것입니다. 최종 형태는 코드베이스에서 이론적으로 추론할 수 있는 모든 정보가 아니라 처리하려는 질의가 정합니다.
안타깝게도 인덱싱은 위에서 설명한 것처럼 선형적이지 않습니다. 예를 들어 정의 맵을 보세요. use bar::baz; use foo::bar;가 있으면 첫 번째 순회에서 bar가 스코프에 있음을 알지만, 그 정보로 즉시 bar를 해결할 수는 없습니다. 마찬가지로 use bar::generate_gen_mod; use gen_mod::Foo; generate_gen_mod!();에서는 먼저 generate_gen_mod를 스코프에 추가하고, 이를 확장해 gen_mod를 추가하고, gen_mod를 분석한 뒤에야 use gen_mod::Foo를 해결할 수 있습니다. 따라서 인덱싱은 상당수의 “고정 루프”를 사용합니다. 새 정보를 얻는 동안 분석을 계속 반복하고, 더 이상 새 정보가 없으면, 또는 루프 한도에 도달하면 작업을 멈춥니다.
마찬가지로 본문 분석은 어느 정도 재귀적입니다. 본문 _자체_에 항목, 매크로, impl이 들어갈 수 있고, 이들은 다시 항목, 매크로, impl을 담은 본문을 가질 수 있고, 계속 이어집니다. 이해하셨을 겁니다. 각 본문은 자체 고정 루프를 지닌 정의 맵, 본문 지역 항목 인덱스, 그리고 그 본문 안의 본문 분석도 갖습니다.
그리고 타입 추론. 트레이트 해결입니다. 죄송하지만 다음 블로그 글까지는 수수께끼로 남겨 두겠습니다. 여기서는 LSP 자체에 관해 이야기하고 있으므로, 이 둘이 인덱싱된 상태에 추가 정보를 기여한다는 사실이면 충분합니다.
막간은 끝났습니다. LSP 특성으로 돌아갑시다.
컴파일러 자체는 위의 “인덱싱”과 그 이상을 수행합니다. 하지만 엄격할 수 있는 사치를 누립니다. 코드가 올바르지 않다면 여러분에게 소리치고 컴파일을 실패시킬 수 있습니다.
LSP는 그러지 못합니다. IDE의 코드는 여러분이 막 입력하고 있기 때문에 아주 자주 올바르지 않으며, LSP는 그것을 완성하도록 돕기 위한 것입니다. LSP는 “이 코드는 올바르지 않거나 완전하지 않으니 분석하지 않겠다”고 말할 수 없습니다.
따라서 모험은 파싱에서 시작합니다. 사용자가 무엇을 입력하든 파싱은 반드시 성공해야 하며, 현재 가진 정보로 상태를 해석하려 노력해야 합니다. 사용자 규칙 위반도 가정해야 합니다. impl 블록 안에 같은 이름의 메서드가 둘 있을 수 있고, 스코프에 존재하지 않는 트레이트의 impl이 있을 수 있으며, 코드가 단순히 불완전할 수도 있습니다.
파싱 부분과 CST 필요성은 누군지 짐작하셨겠지만 그 사람이 이미 글로 썼습니다. 네, 또 그렇습니다! 1, 2, 3.
하지만 파싱은 문제의 일부일 뿐입니다. 파일을 성공적으로 파싱한 뒤에는 실제로 그 올바르지 않음과 모호함을 처리하고 유용한 것으로 바꿔야 합니다.
파일 끝의 완전히 평범한 fn fo를 생각해 봅시다. 이전 토큰이 fn이므로 의도는 아마 함수를 선언하는 것임을 알아차리고, 매개변수 자리 표시자와 빈 본문을 가진 함수 선언을 생성하는 스니펫을 제안할 수 있어야 합니다. 어떤 항목이 스코프에 없으면 가능한 후보를 찾아 임포트 추가를 제안할 수도 있습니다. 감이 오시죠.
따라서 LSP의 또 다른 규범은 현재 상태가 올바르지 않을 수 있고 _개선될 수 있다_는 점을 고려해야 한다는 것입니다. 어디까지 갈지는 상상력에 달렸습니다. 다시 한 번 올바른 코드의 엄격한 세계에서 작업하는 대신 사용자의 의도를 추측하려 합니다.
하지만 거기서 멈추지 않습니다! 올바르지 않은 코드만 다룰 필요는 없습니다. 사용자는 컴파일러만 쓰지 않습니다. cargo를 쓰고, rustdoc을 쓰며, markdown으로 문서를 작성합니다. 도구 작성자로서 cargo JSON 출력을 다루어 진단을 추출하는 방법, rustdoc이 한정자를 지원한다는 점, 사용자를 위해 테스트를 추출하고 실행하는 방법 등을 알아야 합니다.
이는 깊이의 확장이라기보다 너비의 확장입니다. 사용자가 쓰는 도구를 생각하고 흐름이 “매끄럽게” 느껴지고 LSP가 “그냥 해 주는” 것처럼 느껴지도록 필요한 모든 일을 해야 합니다.
이제 LSP 서버, 인덱싱된 상태, 도구에 관한 여러 추가 지식이 생겼습니다. 마침내 LSP의 중심부인 _커서_를 다룰 때입니다.
편집기에서 하는 모든 일은 LSP의 행동이 필요한 파일 내 위치인 _커서_에 기반합니다. 마우스 커서일 수도 있고, 예를 들어 hover를 위해, 입력 위치일 수도 있습니다. 예를 들어 자동 완성을 위해서요.
흥미로운 부분은 “이 위치에서 hover 정보나 자동 완성이 필요하다”에서 “정확히 무엇이 이 위치에 있는가”로 어떻게 가느냐입니다.
늘 그렇듯 matklad에는 rust-analyzer에서 커서 아래 심볼을 찾는 방법에 관한 훌륭한 글이 있습니다. 요약하면 rust-analyzer는 의미 요소에 맞는 _구문 노드_를 기준으로 소스를 찾습니다. 이는 느긋한 분석 접근 및 리팩터링을 위한 파서 인프라 의존과 잘 맞습니다. 적어도 제 이해로는 그렇습니다.
재미있게도 Rust Glancer는 여기서 거의 반대 입장을 취합니다. 그 글은 범위 기반 접근이 a) LSP가 가능한 한 적은 분석을 수행하려 하므로 너무 느리고, b) 리팩터링에 덜 편리하다고 말합니다. 암묵적인 c)는 분석이 계산되지 않았을 수 있지만 현재 파일의 파싱된 트리는 항상 사용 가능하다는 것입니다. Rust Glancer에서는 반대가 참입니다. 파일 시스템으로 내보낸 전체 분석을 기본으로 하고, 메모리를 확보하기 위해 구문 트리를 적극적으로 제거합니다. 전체 의미 분석을 곁에 두고 있다면 범위 기반 접근은 계층 구조와 결합해 꽤 잘 작동합니다. 예를 들어 일치하지 않는 파일을 먼저 걸러내고, 커서 위치와 닿지 않는 본문을 걸러낸 뒤, 가장 정밀한 범위를 가진 소스 심볼을 찾으며 본문 내용을 순회할 수 있습니다. 이것은 리팩터링 이점을 주지 않으므로 Rust Glancer는 rowan 같은 것에 의존하지 않는 전용 알고리즘 집합으로 리팩터링을 구현합니다. 훨씬 덜 우아하지만 꽤 잘 작동하는 듯합니다. 또한 어쩐지 리팩터링 부분은 매우 중요하지만 구현 논리는 그리 많이 차지하지 않습니다. 전체 아키텍처의 기반으로 둘지 여전히 확신하지 못했습니다. Rust Glancer 목적에 관해서입니다. 모든 프로젝트는 당연히 스스로 결정할 수 있습니다.
하지만 이것은 문제의 절반일 뿐입니다. 때로는 우리가 어디에 있는지 이해하는 것만으로 충분하지 않으며, 가장 중요한 사례가 자동 완성입니다. 우리가 어디에 있는지 이해한 뒤에는 이 특정 맥락에서 말이 되는 제안 목록을 만들어야 합니다. 그리고 자동 완성이 필요한 위치에서는 흔히 코드가 불완전하므로 추측이 조금 더 어려워집니다.
자동 완성의 목적에서는 커서 아래에 정확히 무엇이 있는가보다 커서 주변에 무엇이 있는가에 더 관심이 있습니다. 예를 들면 다음과 같습니다.
:: 바로 뒤에 있나요? 그러면 연관 항목, use 경로 또는 한정 경로일 수 있으므로 :: 앞에 무엇이 있는지 확인하고 때로는 다른 선택지를 제안해야 합니다.User { na$ }처럼 구조체 초기화 내부에 있나요? 이 구조체에서 필드를 가져옵니다. 또는 User { name: fo$ }라면 일치하는 지역 변수를 가져옵니다.f인가요? 그러면 fn 키워드 또는 fn 스니펫이 적용될 수 있습니다.실제로는 지원하고 싶은 수많은 특수 사례가 됩니다. 여기서는 원하는 만큼 창의적일 수 있습니다. 예를 들어 await 키워드를 제안할지 결정할 때 크레이트의 에디션을 고려할 수 있습니다.
다시 한 번 사용자의 의도를 추측하는 게임이 되며, 더 잘할수록 사용자 경험도 좋아집니다.
긴 글이죠? 그리고 훨씬 더 오래 계속할 수 있습니다.
이 글이 일관성 없는 일화들의 모음처럼 보이지 않기를 바랍니다. 의도는 LSP를 바라볼 수 있는 각도가 너무 많고, 각 각도마다 일을 수행하는 여러 접근이 있을 수 있음을 보여 주는 것이었습니다.
이는 컴파일러나 cargo fmt / cargo deny 같은 도구와 큰 대조를 이룹니다. 이들은 _목표_가 꽤 결정론적이고 의도한 동작이 대체로 명확하며 설정 가능합니다. 사용자가 이 도구들을 호출할 때는 무엇이 필요한지 정확히 알고 있고, 호출 자체가 _의도를 보여 주는 행위_입니다.
LSP는 매 단계에서 잠재적으로 올바르지 않은 상태만 주어지고, 사용자에게 무엇이 말이 될지를 추측해야 하는 추측 게임에 가깝습니다.
어렵습니다. 하지만 재미있기도 합니다!
추신: Rust Glancer 자체도 이미 꽤 유능합니다. 한번 확인해 보세요! 프로젝트를 지원하고 싶다면 별을 주는 것을 고려해 보세요. 정말 마음에 들거나 흥미롭다고 생각할 때만요! 그리고 Twitter에서 저를 팔로우할 수도 있습니다. 그곳에 Rust Glancer 공지와 새 글을 올리고, 가끔 Rust에 관한 흥미로운 내용도 게시할 계획입니다. 금전적 지원은 제게 필요하지 않지만 Rust 언어 자체에는 필요합니다. 그러므로 대신 Rust Foundation 후원을 강력히 권합니다.