Fleet
More Than a Code Editor
Fleet의 내부 구조, 파트 IV – 분산형 트랜잭션
목차
JetBrains의 차세대 IDE인 Fleet 구축에 대해 알아보는 연재 게시물입니다.
이 연재물의 파트 III에서 Fleet 상태의 요소를 표현, 저장 및 변경하는 방법을 중점적으로 살펴보았습니다. 이번 편에서는 분산형 IDE로서 Fleet의 특징을 설명하고 모든 분산형 구성 요소에서 데이터 일관성을 보장하는 방법을 설명합니다.
Fleet은 개발자 간 실시간 원격 공동 작업을 지원하는 플랫폼입니다. 매우 흥미로운 기술 분야죠. 먼저 분산형 환경에서 작업 중 직면할 수 있는 문제를 살펴보겠습니다.
분산형 작업 중 발생 가능한 문제
A와 B라는 두 명의 사용자가 있다고 가정해 보겠습니다. 두 사용자는 다른 시스템상에서 동일한 Fleet 문서를 작업하고 있습니다. 네트워크 채널을 사용하면 지연이 무작위로 발생합니다. 각 사용자는 다른 시점에 다른 사용자의 활동에 대한 정보를 수신합니다. Fleet은 이 문제를 어떻게 처리할까요?
분산형 환경에서 Fleet의 작동 방식을 설명하기 위해 몇 가지 단순한 샘플 시나리오를 활용해 보겠습니다.
- 시나리오 1: 사용자 A가 Fleet 인터페이스를 통해 파일 이름 변경을 변경. 그 결과 사용자 B의 UI가 업데이트된 경우.
- 시나리오 2: 사용자 A가 Fleet 인터페이스를 통해 파일 이름을 변경. 그와 동시에 사용자 B가 문서에 단어를 추가. 그 결과 둘의 UI가 모두 업데이트된 경우.
- 시나리오 3: 사용자 A가 함수 호출로 괄호를 연 후 Fleet 명령어를 호출하여 괄호의 짝을 맞춤. 그와 동시에 사용자 B가 여는 괄호를 제거함. 그 결과 짝을 맞출 괄호가 사라짐. 사용자 A와 B 중 누가 이길까요?
Fleet 상태 관리에 대해 읽었다면 위의 모든 시나리오에서 핵심은 Fleet 상태 변경이라는 것을 알고 계실 겁니다. Fleet은 대응하는 엔티티의 속성 값을 업데이트하고 새로운 상태 스냅샷을 제공합니다. 이에 따라 모든 UI 요소는 새 값으로 업데이트됩니다.
더 자세히 살펴보자면 이 시나리오의 모든 사용자 작업은 명령어 시퀀스로 컴파일됩니다. 명령어는 값 추가 또는 제거와 같이 간단할 수 있으며, 속성 또는 전체 엔티티에서 작동할 수도 있습니다. 이러한 명령어 시퀀스가 네트워크를 통해 다른 Fleet 인스턴스로 이동합니다.
Fleet은 (원자성, 일관성, 고립성 및 지속성 제공을 위해) 트랜잭션의 명령어를 실행하고, 결과적으로 새로운 상태 스냅샷을 제공합니다. 또한 우리는 Fleet이 모든 트랜잭션(데이터 추가 및 제거 세트)에 대한 변경 사항의 참신성을 기록하고, 읽기 추적 및 쿼리 반응성을 사용하여 영향을 받는 UI 요소를 업데이트한다는 것도 알고 있습니다.
이를 시나리오 컨텍스트에 적용해 보겠습니다. 시나리오 1의 경우, Fleet에 파일 이름 변경 명령어가 있을 수 있습니다. 사용자 A가 Files(파일) 뷰를 통해 파일 이름을 변경하면 해당 명령어가 실행되고, 열린 파일 문서의 파일 주소가 변경됩니다(대응하는 속성 값 변경).
- [18 :fileAddress "~/file.kt"] + [18 :fileAddress "~/newFile.kt"]
또한 문서 탭 이름도 변경됩니다(UI 요소 업데이트). 사용자 B에 대해서도 동일한 명령어를 실행하여 사용자 B의 시스템에서 UI 요소를 업데이트해야 합니다.
시나리오 2의 경우, 위 작업 외에도 사용자 B의 Fleet 인스턴스가 명령어를 실행하여 다음과 같이 변경됩니다.
- [19 :text ""] + [19 :text "hello"]
두 사용자의 Fleet 인스턴스 모두에서 최종 내용은 “hello”이며, 파일 이름이 변경될 것으로 예상할 수 있습니다.
시나리오 3의 경우 상황이 조금 다릅니다. 두 사용자 모두 텍스트 “val x = f(“로 시작한다고 가정할 때 사용자 A가 괄호 짝 맞추기 명령어를 실행하면 텍스트가 다음과 같이 변경될 수 있습니다.
- [19 :text "val x = f("] + [19 :text "val x = f()"]
사용자 B가 여는 괄호를 제거하면 다음과 같이 표시될 겁니다.
- [19 :text "val x = f("] + [19 :text "val x = f"]
이때 새로운 시작 상태에서 괄호 짝 맞추기 명령어를 실행하면 아무런 변화도 없어야 합니다. 사용자 A와 B가 편집 작업을 수행하는 실제 타이밍과 무작위로 발생하는 네트워킹 지연에 따라 최종 결과가 달라질 수 있습니다. 이 상황을 적절히 이해하려면 Fleet의 내부 구조를 이해해야 합니다.
분산형 상태 관련 참고 사항
지금까지는 상태가 하나뿐인 상황을 가정했습니다. 이전 게시물에서 상태 스냅샷 간 전환을 담당하고 최신 스냅샷에 대한 참조를 제공하는 커널이라는 하나의 구성 요소에 대해 설명해 드렸습니다. 실제로 Fleet은 분산형 IDE이므로 여러 개의 상태와 커널이 있습니다. 모든 프런트엔드 구성 요소에 고유한 상태 및 커널이 있는 것이죠. 상태의 일부는 로컬이지만 작업공간에서 관리하는 공유 상태도 있습니다(파트 I의 개요 참조). 작업공간에도 고유한 커널이 있습니다. 작업공간 커널의 주요 목표는 모든 프런트엔드 간 상태를 동기화하는 것입니다.
작업공간은 개발자의 시스템 중 한 곳이나 클라우드에서 실행될 수 있습니다. 이로 인해 상태가 분산되고, 인터넷상에서 상태 동기화가 필요하게 되는 등 다양한 복잡성이 초래됩니다. 다행히 리더(reader) 락, 불변성 및 트랜잭션 메커니즘이 없으므로 모든 문제는 해결 가능합니다.
이전 섹션의 샘플 시나리오에서 사용자 A와 B가 작업 중인 문서도 네트워크상에서 분산되어 있습니다. 해당 문서는 작업공간에 저장되고, 그 사본은 모든 프런트엔드 구성 요소에 저장됩니다.
모든 사용자의 편집 작업(타이핑, 괄호 짝 맞춤, 삭제 등)은 해당 작업을 시작한 사용자의 프런트엔드, 작업공간 및 기타 모든 프런트엔드에서 Fleet 명령어 형태로 실행됩니다. 해당 명령어는 작업공간의 커널을 통해 모든 프런트엔드에 분산됩니다. 이 명령어를 실행하면 모든 종속 UI 요소가 업데이트되어야 합니다.
일부 원격 구성 요소에서 변경 사항을 확인할 때까지 작업을 시작한 사용자의 UI가 대기 상태로 남기를 바라시나요? 당연히 아닐 겁니다. 그렇게 되면 불편한 지연이 발생할 테니까요. Fleet은 분산형 IDE이므로 사용자가 로컬 상태로 작업하는 것처럼 보이게 하려면 먼저 로컬에서 명령어 전체를 실행해야 합니다. 하지만 다른 사용자로 인해 변경 사항이 전역에서 확인되지 않을 가능성이 있습니다. 이 경우 로컬 Fleet의 프런트엔드는 작업을 잠시 멈추고 상태가 작업공간의 전역 프런트엔드 상태와 일치하도록 합니다.
트랜잭션 동기화
다시 샘플 시나리오를 살펴보겠습니다. 시나리오 1에서 사용자 A는 파일 이름을 변경합니다. 이때 Fleet의 상태가 변경되므로 해당 트랜잭션을 실행하고 영향을 받은 UI 요소를 업데이트합니다. 사용자 A의 프런트엔드는 파일 이름 변경 명령어를 작업공간으로 전송합니다. 작업공간에서 해당 명령어를 실행하고, 문제가 없는지 확인한 후 사용자 B의 프런트엔드로 전송합니다. 사용자 B의 프런트엔드에서 이 명령어를 실행하고 사용자 B의 UI에 변경 사항을 반영합니다. 어떤 문제도 발생하지 않습니다.
시나리오 2에서는 파일 이름이 변경되며, 내용이 편집됩니다. 작업공간에 먼저 도달하는 작업에 따라 트랜잭션 순서가 바뀔 수 있으나 최종 결과는 동일합니다. 다음 다이어그램에서 사용자 A의 트랜잭션이 먼저 적용된 것을 확인할 수 있습니다. 사용자 A의 트랜잭션은 전역으로 가장 먼저 실행됩니다.
사용자 B에게는 한동안 트랜잭션 A가 없는 상태가 표시되지만, 작업공간 동기화 이후 트랜잭션 A 다음에 트랜잭션 B가 적절한 순서로 재생되었습니다.
작업공간은 프런트엔드에서 어떤 순서에 따라 명령어를 수신하지만, 시퀀스는 완료됩니다. 모든 프런트엔드는 (잠시 동안은 오래된 UI 요소를 표시할 수 있어도) 결국 최종 순서를 준수해야 합니다. 따라서 지정된 순서에 따라 트랜잭션을 재생해야 할 수 있습니다.
트랜잭션 재생은 무엇일까요? Git 사용자라면 git-rebase라는 유사한 개념을 알고 계실 겁니다. 트랜잭션 재생 시 동일한 명령어가 실행되지만, 새로운 상태로 실행됩니다. 상태 지속성이 있고 작업공간에서 확인되지 않은 트랜잭션 기록의 일부를 알고 있기에 트랜잭션 재생은 실용적이고 효율적입니다.
이제 시나리오 3의 괄호 짝 맞추기 명령어 구현 문제를 자세히 살펴보겠습니다.
- 우리는 코드 줄을 보고 상태를 읽습니다.
- 여는 괄호가 있으면 닫는 괄호를 추가합니다. write 명령어를 전송합니다.
- 여는 괄호가 없으면 코드 줄을 그대로 둡니다. 이 경우 명령어를 전송하지 않습니다.
문제는 코드를 실행하는 상태에 따라 명령어의 결과 시퀀스가 달라질 수 있다는 것입니다.
Fleet에서 다음 다이어그램과 같은 상황이 발생하면 어떻게 될까요?
가장 핵심적인 문제는 해당 상황을 인지하는 것입니다. Fleet에서 읽기 유효성 검사가 사용됩니다. (오래된 상태를 기반으로 한) 트랜잭션의 명령이 유효하지 않다는 점이 확인되면 Fleet은 프런트엔드 코드가 현재 상태에 기반한 명령어를 다시 생성하도록 합니다.
읽기 유효성 검사
솔루션은 트랜잭션 빌드 중 수행되는 모든 읽기 작업에 대해 특별한 Validate 명령어를 추가하는 것입니다. 새 상태에 대한 트랜잭션의 유효성이 검증되면 특별한 처리가 불필요하지만, 유효성이 검증되지 않으면 다시 빌드해야 합니다.
읽기 작업의 유효성은 어떻게 검증할 수 있을까요? 해시를 계산하여 읽은 값을 추적하는 방식은 값이 클 수 있으므로 적합하지 않습니다. 전체 문서 텍스트는 상태의 일부로 저장되기에 더 효율적인 방식이 필요합니다.
엔티티 ID, 속성 및 값으로 구성된 트리플인 datom을 기억하시나요? 사실 datom은 트리플이 아닌 쿼드러플입니다. 그동안 사실대로 말씀드리지 못해 죄송합니다! datom의 네 번째 구성 요소인 tx라는 정수는 트리플 값의 변경 기록을 추적하는 역할을 합니다.
모든 datom은 트랜잭션 내부에서 생성되며, datom을 작성하려면 해당 명령어가 다른 datom을 읽어야 합니다. 새로 생성된 datom은 트랜잭션 ID를 해싱한 결과인 tx 값과 명령어로 읽은 datom의 모든 tx의 정렬된 시퀀스를 수신합니다. 결과적으로 이 tx 번호에는 해당 값의 전체 기록이 기록됩니다.
트랜잭션 유효성 검증 시 첫 번째 로컬 실행을 통해 얻은 tx 번호와 작업공간에서 트랜잭션을 재생할 때 얻은 다른 번호를 간편하게 비교할 수 있습니다. 다른 숫자가 나온다는 것은 트랜잭션 생성 시 사용된 값의 기록이 다르다는 것을 입증합니다. 따라서 트랜잭션을 무효화한 후, 새 상태 스냅샷을 사용하여 처음부터 다시 작성해야 합니다. tx를 데이터베이스 내부의 특정 값에 대한 데이터 흐름 추적으로 간주할 수 있습니다.
시나리오 3을 다시 살펴보자면, 작업공간의 작동 방식은 다음과 같습니다.
- 작업공간에서 사용자 B의 여는 괄호 삭제 트랜잭션을 수신하고 실행한 후 유효성을 검사합니다.
- 다음으로 사용자 A의 괄호 짝 맞추기 명령어를 수신 및 실행하면 해당 명령어가 유효하지 않다는 점이 확인됩니다(:text 속성의 잘못된 tx 값).
- 새로운 상태 스냅샷을 기반으로 괄호 짝 맞춤 명령어를 처음부터 다시 작성하고 실행합니다(사실 여는 괄호가 없으므로 변경된 사항도 없음).
- 명령어의 최종 버전을 프런트엔드에 배포한 후 전역 및 로컬 상태를 동기화합니다.
사용자 A에게 한동안 짝을 맞춘 괄호가 표시될 테지만 동기화 후에는 괄호가 표시되지 않습니다. 즉, 어느 편에 있는지에 따라 승리 또는 실패 여부가 결정되는 것입니다.
요약
Fleet 빌드 방법을 설명하는 네 번째 연재물에서는 분산형 프런트엔드 구성 요소 간 동기화 문제를 살펴보았습니다. 모든 변경 사항은 트랜잭션에서 실행되는 명령어 시퀀스의 형태로 제공되며 작업공간은 프런트엔드에서 이러한 명령어를 수신하고 실행한 후 유효성을 검증하여 다시 배포합니다. 모든 프런트엔드에서 한동안 다른 상태가 표시될 수 있으나, 결국 모든 프런트엔드 상태는 전역 상태와 일치하게 됩니다.
이 연재물을 통해 더 다양한 정보를 다룰 예정이니 나중에 또 확인해 보세요!
게시물 원문 작성자