Fleet
More Than a Code Editor
Fleet의 내부 구조, 파트 III – 상태 관리
목차
JetBrains의 차세대 IDE인 Fleet 구축에 대해 알아보는 연재 게시물입니다.
이전 연재 게시물에서는 Fleet 아키텍처의 개요 및 에디터 내부에서 사용되는 알고리즘과 데이터 구조를 살펴보았습니다. 이번 편에서는 상태 관리 구현을 위한 접근 방식을 살펴보겠습니다. 이 주제는 복잡하므로 몇 개의 게시물에 걸쳐 다루겠습니다. 이번에는 애플리케이션 상태의 요소를 표현하고 저장하는 방법을 중점적으로 설명하고, 다음 파트에서는 Fleet의 상태 관리와 관련된 트랜잭션 메커니즘을 자세히 설명하겠습니다.
Fleet은 다양한 부분으로 구성되고 다음과 같은 수많은 작업을 수행합니다.
- UI 요소 렌더링 및 사용자와 상호작용
- 다른 서비스와 상호작용을 통해 데이터 확보 및 UI 요소 업데이트
- 파일 처리(예: 저장, 로드, 구문 분석 및 파일 간 차이점 표시 등)
- 코드 분석 기능, 코드 완성 및 검색 결과를 처리하는 백엔드 오케스트레이션
이러한 작업 중 대부분은 매우 복잡하여 인터페이스 반응성을 저해할 수 있습니다. Fleet은 분산형 애플리케이션이므로, 네트워크를 통해 여러 프런트엔드가 분산될 수 있어 복잡성이 한층 증가합니다. 그럼에도 사용자를 위한 모든 정보는 일관성 있고 정확하게 표시되어야 하며, 사용자는 여러 프런트엔드를 조화롭게 사용할 수 있어야 합니다.
상태 관리 측면에서 이와 같은 모든 작업의 핵심은 상태 읽기 또는 상태 업데이트입니다. 사용자가 문서 편집 및 변경을 통해 상태를 업데이트하면, UI 요소는 상태를 읽어 사용자에게 실제 데이터를 제공합니다. 그리고 이러한 작업은 매분 수천 번 발생합니다. 따라서 적절한 상태 관리가 Fleet의 핵심이라 볼 수 있습니다.
JetBrains의 원칙
JetBrains는 20년 이상 IDE를 개발해 왔으며, 이러한 오랜 경험을 바탕으로 Fleet 상태 관리와 관련한 다음의 원칙을 지침으로 삼고 있습니다.
원칙 1: 아무도 차단하지 않기
동시성 프로그래밍 시대에는 고려해야 할 사항이 많습니다. Kotlin(및 Fleet)은 코루틴이라고 하는 가벼운 동시성 기본 기능을 사용하여 동시성 코드를 정리합니다. 여러 코루틴에서 동시에 상태를 읽을 때 문제가 발생할 가능성은 거의 없지만, 상태가 변경되면 위험할 수 있습니다. 기존의 접근 방식은 하나의 라이터(writer) 스레드에 대한 잠금을 획득하여 긴 대기열에서 뭔가 읽도록 하는 것입니다. 그러나 리더(reader)는 상태를 지연 없이 시간이 조금 경과했을 때 읽을 수 있어야 하므로 이러한 접근 방식은 부적절하다고 생각됩니다. 저희는 이 동작을 구현하기 위해 MVCC(다중버전 동시성 제어) 모델의 변형을 사용하여 코루틴의 상태 요소에 액세스합니다. 이러한 코루틴은 상태의 일부 버전을 읽거나 새 버전을 제공하여 상태를 변경합니다. MVCC에서는 트랜잭션 중인 상태를 훨씬 쉽게 읽고 변경할 수 있습니다.
원칙 2: 효율적으로 대응하기
상태는 항상 변경되므로 UI에 변경 사항이 최대한 빠르게 반영되어야 합니다. 첫 프로그래밍 언어로 간단한 애니메이션을 프로그래밍해 보신 분이라면, 이미 방법을 알고 계실 겁니다. 바로 모든 것을 지우고 처음부터 다시 그리는 것이죠. 그러나 전체를 다시 그리려면 시간이 많이 걸립니다. 더 좋은 방법은 변경된 부분만 다시 그리는 것입니다. 이를 위해 정확히 어떤 부분이 변경되었는지 확인할 방법이 필요합니다. 변경 사항은 적을수록 좋습니다. 상태에서 변경된 부분을 찾으면 해당 부분에 종속된 항목을 최대한 빨리 판단하여 그에 해당하는 코루틴을 실행해야 합니다. 상태 변화에는 효율적으로 대응해야 합니다.
원칙 3: 효율적으로 데이터 표현하기
이 세 번째 원칙이 없다면 앞의 두 원칙은 허울 좋은 선언에 불과할 겁니다. 데이터를 저장하고 처리하는 방법을 치열하게 고민해야 합니다. 매우 효율적으로 조회 및 변경할 수 있는 스토리지는 이제 데이터베이스 시스템 구현자만의 고민이 아닙니다. 분산형 IDE인 Fleet에도 필요한 작업입니다. 자체 요구 사항을 충족하기 위해 저희는 충분한 유연성과 성능을 갖춘 자체 내부 데이터베이스 솔루션을 개발해야 했습니다.
상태의 개념
Fleet의 상태에 관하여 고려해야 할 측면은 3가지가 있습니다.
첫째, 상태는 시간에 따른 변화를 모델링하는 다양한 버전의 영구 데이터 구조로 표현됩니다. 이를 설명하는 한 가지 방식은 순차적으로 진행되는 에포크 선형 시퀀스인 에포크 시간 모델입니다. 모든 관련된 부분, 즉 코루틴은 항상 에포크 중 하나를 읽지만 반드시 최신 에포크를 읽는 것은 아닙니다.
둘째, 상태란 화면에 표시된 모든 것과 내부에 숨겨진 모든 것에 대한 정보를 포함하는 엔티티의 데이터베이스입니다. 다수의 데이터베이스와 마찬가지로 이러한 엔티티는 다양한 방식으로 서로 관련되어 있습니다.
셋째, 상태 및 상태 변경의 핵심은 datom이라는 기본 트리플입니다. 원시 데이터 항목인 datom을 통해 필요한 효율성을 달성할 수 있습니다. 이제 이 3가지 아이디어를 자세히 살펴보겠습니다.
에포크 시간 모델
오랫동안 프로그램에서 상태가 변경되었습니다. 안타깝게도 변수 하나만 업데이트하고 끝나는 일은 없습니다. 일반적으로 여러 항목을 차례로, 일관성 있게 변경해야 합니다. 그런데 누군가 불완전한 형식의 상태를 발견하거나 변경하려는 시도를 하면 어떻게 될까요? 문자열 길이는 늘어났지만, 새로운 내용이 없는 상황을 생각해 보세요. 사용자가 이를 발견해서는 안 됩니다. 즉, 일관성 없는 상태는 숨겨야 합니다. 일관된 있는 상태에서 다음 상태로 넘어가는 데에는 시간이 소요됩니다. 이는 마치 한 에포크 후에 다음 에포크가 오는 것과 같습니다.
Rich Hickey는 Clojure 프로그래밍 언어 구현과 관련한 아이디어를 설명하는 멋진 프레젠테이션 Are We There Yet(대본은 이곳 참조)에서 처음으로 에포크 시간 모델을 프로그래밍 커뮤니티에 널리 소개한 바 있습니다. 그는 언젠가 프로그램이 일관성 있는 불변의 영역에 존재할 수 있을 것이라 말합니다. 불변성의 영역에서 많은 것을 더 쉽게 구현할 수 있지만, 영원히 같은 영역에 머무는 것은 불가능합니다. 상태 라이터 활동의 결과로서 일관성 있는 불변의 새로운 영역이 이전 영역 다음으로 등장할 것입니다.
Fleet의 상태에는 불변 스냅샷의 형태로 액세스할 수 있으며, 모든 상태 요소 간의 일관성이 보장됩니다. 이 모델에서 상태를 업데이트하면 새로운 스냅샷이 생성됩니다. 상태가 변경될 경우 일관성을 보장하기 위해 트랜잭션을 구현합니다.
Fleet에는 상태 라이터 활동의 결과로 스냅샷을 전환하고 최신 스냅샷에 대한 참조를 제공하는 커널이라는 구성 요소가 있습니다. 관련 부분(리더 및 라이터)에서 필요할 때 참조를 불러올 수 있지만 참조를 사용할 때는 그 참조가 해당 영역의 최신 버전과 일치하는지 확신할 수 없습니다. 또한 커널은 그에 종속된 관련 부분에 변경 사항을 알려야 합니다. 다행히 일부 값을 읽은 후 다음 변경 사항에 대해 알림을 받는 것으로 충분하므로 수동으로 구독할 필요는 없습니다.
라이터는 새 스냅샷을 생성하기 위해 정렬되었지만, 리더를 방해하지 않습니다. 단, 리더는 조금 시간이 지난 정보를 받을 수 있습니다.
상태 데이터 모델
자, 이제 우리는 ‘상태에 포함된 것은 무엇인가?’라는 질문에 답할 수 있습니다. 해당 파일 정보가 담긴 문서 콘텐츠, 해당 콘텐츠에 대해 추론된 모든 정보, 캐럿 위치, 로드된 플러그인 및 구성, 뷰 및 패널 위치 등 사실상 모든 것이 포함되어 있습니다. 해당 데이터 모델은 Fleet에서 Kotlin 인터페이스를 통해 다음과 같이 설명됩니다.
interface DocumentFileEntity : SharedEntity { @Unique @CascadeDeleteBy var document: DocumentEntity @Unique var fileAddress: FileAddress var readCharset: DetectedCharset // ... } interface DocumentEntity : SharedEntity { var text: Text var writable: Boolean // ... }
참고: Text 타입은 이 연재물의 이전 글에서 다룬 로프입니다.
엔티티 구성 요소 및 해당 요소 간의 관계를 설명하는 데 프로퍼티 어노테이션이 사용됩니다. 이 예시의 경우, 문서 파일 엔티티에 디스크 드라이브의 고유한 파일과 이 파일에서 읽은 고유한 문서 간의 관계가 설명되어 있습니다. 문서 파일 엔티티가 삭제되면 해당 문서 엔티티도 삭제됩니다.
엔티티 데이터베이스 관리를 위해 저희는 RhizomeDB라는 자체 데이터베이스 엔진을 구현했습니다. RhizomeDB는 엔티티에 계층 구조를 적용하지 않으므로, 식물의 마디로부터 뿌리와 싹을 틔워내는 지하의 식물 줄기인 Rhizome(뿌리줄기)라는 이름을 붙였습니다.
위의 예시와 같이 인터페이스에서 속성을 구현하는 객체로 엔티티에 액세스하기 위해 RhizomeDB가 API를 제공합니다. 예를 들어, 다음과 같이 주어진 파일 주소를 기반으로 문서를 가져올 수 있습니다.
val document = lookupOne(DocumentFileEntity::fileAddress, fileAddress)?.document
이제 문서 객체에서 DocumentEntity 인터페이스를 구현하며, 이를 사용하여 Fleet에 로드된 문서 내용에 액세스할 수 있습니다.
이 엔티티 데이터 모델은 데이터뿐 아니라 데이터 모델 자체도 표현할 수 있을 만큼 유연합니다. 플러그인을 개발하려는 경우를 가정해 보세요(이 연재물의 후반부에서 Fleet 플러그인을 다룰 예정입니다). 로드된 플러그인은 Fleet 상태의 일부를 구성합니다. 모든 플러그인에는 애플리케이션과 원활한 통합을 위해 필요한 일부 공통 데이터가 있습니다. 하지만 모든 플러그인의 상태는 고유하며, 이는 고유한 데이터 모델로 설명됩니다. 이는 RhizomeDB의 문제가 아닙니다. 플러그인의 데이터 모델을 엔티티로 표현할 수 있습니다. 플러그인을 로드할 경우 해당 플러그인의 데이터 모델도 새 엔티티로 로드됩니다. 결과적으로 Fleet의 상태 관리 시스템은 플러그인의 상태 데이터를 허용할 준비가 되었습니다.
트리플의 집합으로의 상태
API가 엔티티 작업을 위한 객체를 제공하지만, 객체는 그대로 저장되지 않습니다. 그 대신 [entity_id, attribute, value]
처럼 트리플로 표현됩니다. 이러한 트리플을 datom이라고 합니다(이 용어는 데이터 구조를 모델링한 Datomic 데이터베이스에서 비롯되었습니다).
문서를 참조하는 특정 파일의 엔티티 ID가 18이고 해당 문서의 엔티티 ID가 19라고 가정해 보시죠. 데이터는 트리플로 저장될 겁니다.
[18 :type DocumentFile]
[18 :document 19]
[18 :fileAddress "~/file.kt"]
[18 :readCharset "UTF-8"]
인터페이스의 프로퍼티는 트리플의 프로퍼티가 됩니다. :type
과 같이 특별한 의미를 지닌 속성도 다양하게 있습니다. 값의 타입은 프로퍼티 타입에 따라 다르며, 다른 엔티티를 참조할 경우 프로퍼티 값은 ID입니다.
언뜻 보기에 너무 단순한 것 같은 트리플 구조는 데이터 조회 시 매우 효율적입니다. 엔진은 쿼리에 대한 응답을 [entity_id?, attribute?, value?]
처럼 마스크 형태로 매우 빠르게 반환할 수 있으며, 이 형태에 구성 요소가 존재하거나 누락될 수 있습니다. 쿼리 결과는 항상 주어진 마스크를 충족하는 datom의 집합입니다.
예를 들어, 현재 로드된 문서 파일의 모든 파일 이름을 요청할 수 있습니다.
[? :fileAddress ?]
또는 주어진 이름의 파일에 해당하는 entity_id를 찾을 수도 있습니다.
[? :fileAddress "~/file.kt"]
두 번째 쿼리의 경우 고유성 제약 조건으로 인해 결과 집합의 응답은 1개를 초과할 수 없습니다.
쿼리를 빠르게 실행하기 위해 RhizomeDB는 다음과 같이 색인 4개(각각 hash trie로 구현됨)를 유지합니다.
- 엔티티 | 속성 | 값
- 속성 | 엔티티 | 값
- 값 | 속성 | 엔티티
- 속성 | 값 | 엔티티
RhizomeDB API의 lookup*
함수군은 이 색인에서 연산을 수행하여 해당 트리플을 찾고 결과 엔티티 객체를 빌드합니다.
RhizomeDB는 Datomic에서 큰 영향을 받았으나, 사용 사례에 적합한 읽기 추적 및 쿼리 반응성 등 몇 가지 새로운 아이디어가 추가되었습니다. 이러한 기능은 곧 소개해 드릴 상태 변경 사항을 처리하는 데 도움이 됩니다.
변경 처리
불변 상태의 경우 호기심을 끄는 점이 없습니다. 흥미로운 일은 뭔가 바뀔 때 생깁니다. 상태의 변경 사항과 업데이트해야 할 UI 요소를 알아보려 합니다. 변경 처리를 위해 다음 3가지 아이디어가 구현되었습니다.
- 변경의 참신성으로 변경된 정확한 사항을 기록합니다.
- 리더가 쿼리하는 사항을 추적합니다.
- 변경에 따라 새로운 결과를 제공할 쿼리를 판단합니다.
이와 같은 아이디어를 논의하고, Fleet에서 작동하는 방식을 살펴보겠습니다.
참신성 값
기억하셔야 할 점은 최대한 불변 상태를 유지해야 하므로 값을 변경할 수 없다는 것입니다. 또한 상태는 엔티티 ID, 속성 및 해당하는 데이터 엔티티를 나타내는 데이터 값이 있는 트리플 집합을 포함하는 스냅샷 형식입니다. 속성 값을 변경하는 대신 변경하려는 속성의 새 값이 적용된 새로운 상태 스냅샷을 생성합니다. 따라서 변경이란 이전 값을 제거하고, 새 값을 추가하는 것입니다. 예를 들어, 파일의 이름을 바꾸려면 다음을 수행합니다.
- [18 :fileAddress "~/file.kt"] + [18 :fileAddress "~/newFile.kt"]
이 두 작업은 트랜잭션 내에서 실행되어야 합니다. 그렇지 않으면 파일 이름이 전혀 없는 상태를 목격하게 될 겁니다. 이러한 트랜잭션을 실행하면 새로운 파일 이름이 적용된 새로운 상태 스냅샷을 얻을 수 있습니다.
따라서 모든 변경은 datom 제거 및 추가의 집합일 뿐입니다. 트랜잭션을 통해 다른 엔티티 및 속성에서도 이와 같은 제거 및 추가가 많이 발생할 수 있으며, 두 스냅샷의 차이도 이와 같은 집합입니다. 변경 집합의 엔티티 ID와 속성을 통해 트랜잭션 중 어떤 상태 구성 요소가 변경되었는지 정확하게 이해할 수 있습니다. 이를 변경의 참신성이라 합니다. 트랜잭션을 실행하면 참신성 값이 기록됩니다.
읽기 추적 및 쿼리 반응성
리더는 쿼리를 통해 상태 데이터에 액세스합니다. 쿼리는 마스크 형식을 가지므로 특정 함수의 모든 마스크를 쉽게 추적할 수 있습니다. 모든 함수의 정보를 획득하면 함수와 마스크의 종속 관계를 판단할 수 있습니다.
모든 변경 이후, 참신성 값을 얻습니다. 쿼리된 모든 마스크를 살펴보면 변경이 영향을 미치는 쿼리를 확인할 수 있습니다. 읽기 추적 기능을 통해 영향을 받은 함수를 파악할 수 있습니다. 결과적으로 함수를 호출하는 UI 요소를 무효화할 수 있습니다. 따라서 UI 반응의 효율성이 매우 높습니다.
UI 요소 업데이트 외에도 다양한 목적으로 읽기 추적을 사용합니다. 이는 반응형 프로그래밍의 유용한 패턴을 가능케 하는 상당히 일반적인 메커니즘입니다. 예를 들어, 상태 쿼리 함수가 있는 경우 비동기 흐름으로 간편한 전환이 가능합니다. 상태의 변화가 함수 결과에 영향을 미칠 경우 흐름의 새로운 요소를 출력합니다. 또한 캐시된 값이 오래된 값일 위험 없이 쿼리 결과를 안전하게 캐시할 수 있습니다. 상태의 값이 업데이트되면 즉시 알 수 있습니다.
요약
Fleet 구축 방법을 다루는 연재물의 이번 글에서는 일련의 불변 스냅샷을 통해 에포크 시간 모델을 사용했으며, 상태 유지를 위한 스마트 데이터 표현을 구축했습니다. 데이터는 개발자가 편리하게 사용할 수 있는 데이터 엔티티 및 효율적으로 조회하는 데 적합한 트리플이라는 두 가지 수준으로 존재합니다. 변경이 발생할 때 변경 사항이 기록되고, 특정 변경 사항과 관련된 당사자가 파악되며, 해당 UI 요소가 업데이트됩니다.
이러한 배경을 염두에 두고, 이제 Fleet 상태의 분산형 특성과 일관성 있는 방식으로 변경하기 위한 트랜잭션 메커니즘을 논의해보겠습니다. 이 논의는 이 연재물의 다음 블로그 게시물에서 다룰 예정입니다. 다음 블로그 게시물도 기대해 주세요!
게시물 원문 작성자