LSP의 부상과 함께 쿼리 기반 컴파일러라는 새로운 아키텍처가 등장했다. 이 아키텍처는 UI 렌더링의 시그널과 생각보다 더 비슷하면서도 다른 점이 있다.
URL: https://marvinh.dev/blog/signals-vs-query-based-compilers/
📖 tl;dr: LSP의 부상과 함께 쿼리 기반 컴파일러가 새로운 아키텍처로 떠올랐다. 이 아키텍처는 내가 처음에 생각했던 것보다 시그널과 훨씬 비슷하면서도, 또 다른 점도 있다.
겨울 연휴 동안 호기심이 발동해서, LSP 시대와 촘촘한 에디터 통합 환경에서 현대 컴파일러가 어떻게 상호작용성을 달성하는지에 대해 많은 시간을 들여 읽어보았다. 그런데 알고 보니 현대 컴파일러는 UI 렌더링에서의 시그널과 같은 개념을 중심으로 구성되어 있었고, 흥미로운 설계 선택의 차이점도 있었다.
컴파일러에 대한 고전적인 교과서는 컴파일러를, 코드가 최종 바이너리로 변환될 때까지 통과하는 선형적인 단계들의 연속으로 설명한다. 언어가 충분히 단순하다면(자바스크립트는 터무니없이 복잡해서 정반대지만) 이런 방식으로 컴파일러를 작성하는 건 꽤 직관적이다.
Source Text -> AST -> IR -> Assembly -> Linker -> Binary
먼저 소스 코드는 추상 구문 트리(=AST)로 변환되는데, 이는 입력 텍스트를 구조화된 객체/구조체로 바꾼 것이다. 문법 오류, 구문 오류 같은 것들이 잡히는 지점이 바로 여기다.
예를 들어 이 자바스크립트 소스 코드는...
const a = 42;
...대략 이런 AST로 변환된다:
{ "type": "VariableDeclaration", "kind": "const", "declarations": [ { "type": "VariableDeclarator", "id": { "type": "Identifier", "name": "a" }, "init": { "type": "NumericLiteral", "value": 42 } } ]}
그 뒤 AST는 몇 단계 더 거쳐 최종 바이너리에 도달한다.
이 단계들의 실제 세부 사항은 이 글에서 중요하지 않으며, 여기서 설명한 내용은 매우 과장되게 단순화한 것이다. 하지만 중요한 점은 컴파일러가 일반적으로 코드를 실행 가능하게 만들기 위해 여러 단계를 거친다는 사실이다. 그리고 이 모든 과정은 시간이 걸린다. 매 키 입력마다 실행하기에는 너무 많은 시간이다.
개발자가 파일 하나를 바꾸면서 글자 하나를 입력하는 순간에도, 내부에서는 많은 일이 일어난다. 이상적으로는 가능한 한 적은 일을 하고 싶다. 물론 각 단계마다 캐시를 넣고 언제 무효화할지에 대한 좋은 휴리스틱을 만들어낼 수도 있지만, 그런 방식은 금방 유지보수가 어려워진다. 그건 좋지 않다.
컴파일러에서의 핵심 전환은 컴파일러를 단순한 변환 파이프라인으로 보지 않고, 쿼리를 실행할 수 있는 대상으로 보는 것이다. 사용자가 에디터에서 타이핑하고 있을 때 LSP는 에디터에 이렇게 묻는다: 이 파일의 이 커서 위치에서 제안(suggestion)은 무엇인가? 식별자에서 “정의로 이동(Go to Definition)”을 클릭하면, 컴파일러에게 점프 대상(있다면)을 반환하라고 요청하는 셈이다.
본질적으로 질문은 컴파일러에 대해 실행하는 여러 쿼리이며, 컴파일러는 가능한 한 빠르게 그것들에 답하는 데만 집중하고 나머지는 무시해야 한다.
이런 사고방식의 전환이 현대 컴파일러를 훨씬 더 상호작용적으로 만든다. 그런데 내부적으로는 어떻게 그렇게 동작하며, 이것이 시그널과는 무슨 관련이 있을까?
쿼리 기반 컴파일러에는 세 가지 핵심 구성 요소가 있다: 쿼리(Queries), 입력(Inputs), 그리고 “데이터베이스(Database)”. 핵심 아이디어는 모든 것, 말 그대로 모든 것이 쿼리와 입력으로 구성된다는 것이다. 쿼리가 실행되지 않으면 아무 일도 일어나지 않는다.
맨 위에는 “최종 바이너리를 달라”라는 큰 쿼리가 하나 있고, 이 쿼리는 다시 “IR을 달라”, “AST를 달라” 같은 여러 쿼리를 발화한다. 끝까지 내려가도 전부 쿼리다.
하지만 그 외에도 이런 쿼리들이 있다: “파일 X의 커서 위치 Y에 있는 식별자의 타입은 무엇인가?” 이런 쿼리는 또 다른 쿼리를 호출해서 현재 파일을 AST로 파싱하도록 한다. 그러면 그 AST를 이용해 커서 위치의 식별자를 가져오는 쿼리를 다시 실행할 수 있다. 그 식별자를 얻으면 또 다른 쿼리를 실행해 그 식별자를 정의로 해석(resolve)한다. 그 정의가 다른 파일에 있다면, 그 파일을 파싱해 달라고 다시 요청한다. 등등.
이 아키텍처의 장점은, 완전히 무관한 파일들을 처리하는 데 시간을 쓰지 않는다는 것이다. 특정 쿼리에 답하는 데 필요한 것만 처리한다. 어떤 소스 파일이 실행 중인 쿼리와 완전히 무관하다면, 그 파일은 결코 처리되지 않는다.
더 빠르게 만들기 위해 쿼리는 쉽게 캐시할 수 있는데, 쿼리는 순수(pure)하다고 기대되기 때문이다. 즉, 부작용이 없어야 한다. 이는 쿼리를 언제든 다시 실행해도 정확히 같은 결과를 얻는다는 뜻이며, 캐싱에 완벽한 성질이다.
쿼리는 자동으로 캐시할 수 있고, 캐시가 너무 많은 메모리를 먹으면 그냥 지울 수도 있다. 다음에 그 쿼리가 호출되면 캐시 엔트리가 없다는 걸 보고 로직을 다시 실행한 뒤 결과를 다시 캐시하면 된다. 한 번만 조금 느려질 뿐이며, 절대로 잘못된 결과를 반환하지 않는다.
하지만 캐시가 올바르려면 한 가지 중요한 디테일이 있다: 해시된 캐시 키에 전달된 인자(arg)도 포함되어야 한다. 따라서 Query A가 여러 곳에서 다양한 인자로 호출된다면, 각 인자는 고유한 캐시된 반환값을 가진 그 쿼리의 새로운 인스턴스를 만든다.
쿼리는 보통 인자 두 개를 받는 함수로 정의된다.
TypeScript로 표현하면 다음과 같다:
type Query<T, R> = (db: Database, arg: T) => R;
db 파라미터는 모든 쿼리가 올라가 있는 곳이고, arg 파라미터는 쿼리를 호출할 때 넘기는 값이다. 쿼리 내부에서 다른 쿼리를 호출하려면 db.call_other_query(someArg)처럼 한다. 이걸 더 보기 좋게 만들고 데이터베이스라는 존재를 덜 의식하게 하려고, 대부분의 구현은 매크로나 데코레이터 같은 설탕(sugar)을 추가한다:
class MyDatabase extends Database { @query getTypeAtCursor(file: string, offset: number): Type { const id = this.getIdentifierAtCursor(file, offset); const type = this.getTypeFromId(id); return type; }}
디스크의 파일이 바뀌면, 컴파일러에게 그것을 무효화해서 캐시 엔트리를 지우고 다음에 쿼리가 요청할 때 그 파일을 다시 처리하도록 알려야 한다. 이 역할을 Input이 한다. Input은 값을 쓸 수 있는(stateful) 객체다. 보통 이것도 데이터베이스 위에 올라가 있다.
watch(directory, ev => { if (ev.type === "change") { const content = readTextFile(ev.path); db.updateFile(name, content); } });
쿼리에서 입력을 읽는 것은 보통 메서드를 호출하거나 특별한 프로퍼티에 접근하는 방식으로 한다.
`class MyDatabase extends Database { files = new Map<string, FileInput>()
// Setter helper updateFile(name: string, content: string) { const input = this.files.get(name) ?? new Input<string>() input.write(content) this.files.set(name, input) }
@query
parseFile(file: string): AST | null {
const fileInput = this.files.get(file)
if (fileInput === null) return null;
// Read input
const code = fileInput.read()
return parse(code)
}}
`
여기서 시그널과 비교했을 때 핵심 차이는 입력에 값을 썼다고 해서 정말로 아무것도 자동으로 일어나지 않는다는 점이다. 쿼리가 자동으로 재실행되지도 않는다. UI 프로그래밍에서 시그널이 보통 “라이브” 구독 형태인 것과 달리, 쿼리는 라이브가 아니다.
시그널 시스템에서는 소스 시그널이 바뀌면 그것이 더럽혀짐(dirty) 상태가 되고, 모든 활성 구독을 따라가며 파생/계산된 시그널을 계속 더럽혀서 구독이 트리거된 지점까지 전파한다. 트리거 부분은 보통 Effect라고 부른다. 변경은 시스템을 통해 “푸시”되고, 이펙트에 도달하면 이펙트가 다시 실행되면서 새 값을 “풀”한다. 물론 라이브러리마다 다양한 최적화 전략이 있지만 이 글의 범위를 벗어난다. 중요한 것은, 쓰기(write) 관점에서 본질적으로 푸시-풀(push-pull) 시스템이라는 점이다.

푸시-풀 아키텍처는 UI 렌더링에 완벽하다. 변경은 즉시 표시되어야 하는 경우가 많고, 화면 전체가 동기화되어 있어야 한다. 렌더링되는 모든 시그널은 자신이 속한 동일한 리비전의 값을 보여야 한다. 화면 위쪽 절반은 새 값을 보이는데 아래쪽은 여전히 오래된 값을 렌더링하는 상황은 절대로 일어나면 안 된다. 이를 흔히 “글리치(glitch)”라고 부른다. 변경을 시스템 전체에 푸시하는 것은 이런 일이 절대 일어나지 않게 보장하는 우아한 방법이다. 이는 더 많은 메모리를 쓰는 대신 더 빠른 실행과 글리치 없는 결과 보장을 얻는 트레이드오프다.
쿼리 기반 컴파일러는 다르게 동작한다. 이는 수요(demand) 기반이다. 재실행을 원하면 _요청_해야 한다. 모든 것이 같은 틱(tick) 안에 일어나는 것이 그렇게 중요하지 않다. 자동완성 쿼리가 먼저 반환되고 타입 에러 쿼리가 몇 밀리초 뒤에 반환되는 건 전혀 문제 없다. 매 프레임마다 어떤 화면을 동기화할 필요가 없다. 물론 정확성은 보장되어야 하지만, 타이밍에 대한 보장은 조금 더 느슨하다.

변경을 항상 시스템을 통해 푸시하는 것은 비용이 너무 크다. 쿼리 기반 컴파일러는 프로젝트 크기에 따라 쉽게 10만 개가 넘는 노드를 가질 수 있다. 그 규모에서는 메모리가 실제 성능 문제로 부상한다. 메모리 사용량을 줄이기 위해, 쿼리 기반 시스템에서는 시그널처럼 양방향으로 의존성을 추적하지 않고 한 방향으로만 추적한다.
그렇지만 시그널과 마찬가지로, 쿼리 기반 시스템에서도 정확성은 어려운 요구사항이다. 그렇다면 쿼리가 서로 다른 시점에 끝나더라도 결과가 항상 올바르다는 것을 어떻게 보장할까? 핵심 통찰은, 궁극적으로 쿼리는 입력들에 대한 순수 함수라는 점이다. 같은 입력이 주어지면 같은 결과가 나와야 한다. 그러므로 그 결과는 올바르다.
시스템 내부에는 전역 리비전 카운터가 있고, 입력이 바뀔 때마다 증가한다. 각 노드에는 캐시된 값의 상태를 확인하는 데 사용할 수 있는 changed_at과 verified_at 필드가 있다.
interface Node<T> { changed_at: Revision; verified_at: Revision; value: T; dependencies: Node<any>[]; }
이 정보로 노드의 캐시 결과를 재사용할 수 있는지 판단할 수 있다. 다만 의존성을 한 방향으로만 추적하므로, verified_at이 현재 리비전과 같아서 조기 종료할 수 있는 경우가 아니라면, 어떤 쿼리든 리프 노드까지 모든 의존성을 더티 체크해야 한다. 리프 노드에 도달했는데 전역 리비전이 증가했음에도 그 노드가 전혀 바뀌지 않았음을 알게 되면, 부모 노드들의 verified_at을 모두 현재 전역 리비전으로 설정한다. 입력 또는 쿼리 결과가 바뀌었다면 changed_at과 verified_at을 모두 업데이트한다. 하지만 의존성이 바뀌었음에도 어딘가에서 쿼리가 동일한 결과를 반환한다면, 그 경우에도 중간에서 종료하고 스택을 되감는(unwind) 동안 verified_at만 업데이트한다.
이건 쿼리 기반 시스템의 킬러 기능 중 하나다. 컴파일할 언어에 따라 파일 파싱 같은 작업을 공격적으로 병렬화할 수 있다. 각 쿼리는 한 번에 하나의 스레드에서만 실행될 수 있다고 보장하면 많은 일을 병렬화할 수 있다. 쿼리는 보통 꽤 세분화되어 있어서, 스레드를 종료하고 최신 리비전으로 다시 스폰(spawn)하는 것도 종종 가능하다. 이 부분은 나도 더 깊게 파봐야 하지만, 이런 시스템에서 작업 재시작이 가능하다는 점 자체가 흥미롭다.
상황에 따라 다르다. 시그널은 UI 렌더링에 더 좋지만, 쿼리 기반 시스템은 컴파일러에 더 적합하다. 결국 용도에 달려 있다. 어쨌든 흥미로운 점은, 서로 다른 시스템들이 점진적(incremental) 시스템을 달성하기 위해 뒤에서는 비슷한 구성 요소와 개념에 도달했다는 것이다.
자바스크립트 도구들이 처음부터 점진성을 목표로 만들어졌다면 어떤 모습이었을지 궁금해진다. 예를 들어 vite는 쿼리 기반 시스템으로 만들었다면 어떤 모습이었을까? 정신적으로(dev server의 본질을 생각하면) 개발 서버는 HMR 업데이트처럼 서버가 푸시하는 것 외에도, 지속적으로 데이터를 요청하는 “살아있는” 존재와 비슷하다. 시그널과 쿼리 아키텍처를 적절히 섞는 것이 정답일지도 모르겠다.