Jungseob's Note
포스트
영상 썸네일 · From Hack to Skip: Building Incremental Everything

3천만 줄의 PHP에서 Skip까지, Julien Verlaguet이 15년간 밀어붙인 증분 계산의 설계

Facebook의 Hack 언어 서버에서 출발해 불변성을 타입으로 추적하는 Skip 언어와 반응형 프레임워크, 코딩 에이전트 Skipper로 이어진 증분 계산 설계를 Julien Verlaguet의 설명으로 정리한다.

3천만 줄의 PHP에서 Skip까지, Julien Verlaguet이 15년간 밀어붙인 증분 계산의 설계

TL;DR

  • Julien Verlaguet은 Facebook에서 수천만 줄 PHP 코드베이스에 타입 시스템(Hack)을 붙이면서, 개발자의 즉시 피드백을 지키려면 모든 도구가 증분이어야 한다는 결론에 이르렀다. 모듈로 쪼갤 수 없는 의존성 때문에 언어 서버를 택했다.
  • 증분 계산에서 핵심은 캐시 관리이며 캐시에서 꺼낸 값을 복사하지 않으려면 불변성이 증명되어야 한다. 주류 언어의 const나 함수형 언어의 모나드는 이 목적에 맞지 않았다고 그는 본다.
  • Skip은 가변성을 타입으로 정밀하게 추적하는 언어다. 불변 객체가 기본이고 가변 값은 타입에 mutable이 드러난다.
  • 이 위에 반응형 프레임워크와 TypeScript 포팅, 코딩 에이전트 Skipper를 쌓았지만 Skipper의 증분 체인은 아직 전부 연결되지 않았다고 그는 밝힌다.
  • 성능 수치와 설계 우월성은 본인 설명이며, 이 노트에서 코드를 실행하거나 벤치마크를 검증하지는 않았다.

3천만 줄 코드베이스가 던진 질문

진행자 Chris Jenkins는 3천만 줄의 PHP를 어떻게 다룰 것이냐는 질문으로 시작한다. 다시 쓰는 것은 선택지가 아니고 코드베이스를 주어진 대로 다뤄야 한다는 조건이다. Verlaguet는 2011년 초 Facebook에 합류해 처음에는 PHP의 보안 결함을 찾는 정적 분석을 만들었다고 회상한다. 사용자 입력이 이스케이프 없이 SQL 쿼리 같은 위험 지점으로 흘러가는지 추적하는 오염 분석이었다. 코드가 과거 방식의 한 페이지짜리 스크립트일 때는 타입 없이도 통했지만 코드베이스가 빠르게 구조화되고 간접층이 늘면서 한계가 왔다.

그래서 정적 타입을 붙이자는 아이디어가 나왔다. 처음에는 보안이나 결제처럼 정확성이 중요한 일부에만 쓰는 엄격 모드로 구상했지만 신뢰할 수 있는 영역을 벗어나면 널이 섞여 들어오는 식으로 보증이 깨졌다. 그래서 전체 코드베이스에 적용되는 Hack으로 커졌다. 그는 처음부터 시작할 수 있었다면 그렇게 하지 않았을 것이라고 덧붙인다. PHP의 자동 로딩 때문에 의존성이 뒤엉켜, 열 줄짜리 코드도 수백만 줄을 끌어온다는 것이 모듈로 나눌 수 없었던 이유다.

즉시 피드백을 지키는 언어 서버

가장 중요한 제약은 개발자의 작업 방식이었다. 코드를 고치고 브라우저를 새로고침해서 결과를 보는 주기가 소중했고 느린 도구는 모두 꺼졌다. 그래서 수천만 줄에서도 빠르게 응답하는 언어 서버를 만들었다. 첫 구현은 OCaml의 포크 기반이었는데 프로세스 사이에서 데이터를 복사하는 비용이 지배적이어서 결국 불변 데이터만 담을 수 있는 공유 힙과 원자적 해시 테이블로 프로세스들이 협업하는 런타임을 만들었다. 조율은 파이프로 하고 실제 데이터는 이 공유 힙에 둔다.

단계는 파싱, 타입 선언 해소, 본검사로 나뉜다. 파일을 묶음으로 나눠 워커에 보내 AST를 공유 메모리에 올리고 클래스 계층을 평탄화해 조회가 빠른 환경을 만들고 그 환경으로 함수 본문을 검사한다. 그는 32개 프로세서, 64코어 서버에서 초당 약 100만 줄을 검사했고 병목은 CPU가 아니라 메모리였다고 말한다. 이 수치는 본인 기억이다.

증분으로 만드는 방법은 의존성 기록이다. 검사 중 어떤 타입을 참조하면 그 관계를 기록해 두고 바뀐 타입에서 다시 검사할 대상을 거꾸로 찾는다. 이 기록은 모든 워커가 공유하는 저수준 자료구조로, 양쪽 이름의 32비트 해시를 합친 64비트 값을 원자적으로 저장하는 식이다. 가장 어려웠던 것은 비결정적 동시성 버그의 안정화였다. 서버 상태 전체를 사람이 읽을 수 있는 형태로 덤프하는 기능을 만들고 깃 저장소의 임의 리비전들로 건너뛰며 증분 결과와 처음부터 계산한 결과의 덤프를 비교해 어긋남을 찾았다. 깃이 사람보다 훨씬 빠르게 변경을 만들어 내므로 깃을 따라갈 수 있으면 사람도 따라갈 수 있다는 논리다.

불변성이 왜 언어 문제가 되었나

그는 증분 프레임워크의 난이도를 한 질문으로 요약한다. 캐시에서 값을 꺼낼 때 복사 비용을 치를 수 있느냐는 것이다. 치를 수 있다면 어떤 언어로든 몇 주면 만든다. 치를 수 없다면 넘긴 계산이 그 값을 바꾸지 않는다는 보증이 필요한데, 주류 언어의 const는 그 함수가 바꾸지 않는다는 뜻일 뿐 값이 누구에게도 바뀌지 않는다는 뜻이 아니다. 전이적 닫힘까지 불변이어야 하는데 클로저나 인터페이스처럼 데이터를 숨기는 추상화가 이를 가린다.

순수 함수형 언어도 검토했으나 상태를 독립적으로 유지하려면 여러 모나드가 필요하고 그 API가 다루기 어렵다고 판단했다. 기존 언어에 불변 타입을 덧붙이는 접근은 그 언어와 호환되지 않는 새 언어가 되고 만다고 설명한다. 가변 객체와 불변 객체가 서로 호출하는 순간 복사하거나 보증이 깨지기 때문이다. 그의 결론은 가변성이 타입 안에 숨을 수 없게 하는 언어다. 이것이 Skip이며 모나드의 모든 부작용 대신 가변성이라는 한 가지 성질만 추적해서 사용성이 좋다는 설명이다.

Skip 언어의 특징

Skip은 Scala나 Java를 닮은 객체 지향 문법이지만 기본 블록이 불변 객체다. 생성자와 객체가 같은 것이어서 기반 클래스를 상속한 하위 클래스를 그대로 패턴 매칭할 수 있다. 타입 클래스에 해당하는 트레이트, 해시 함수 같은 반복 코드를 만드는 간단한 매크로, 예외, 패턴 매칭을 갖췄고 모듈 시스템은 이름 공간 수준이다. 함수형 언어의 펑터보다 타입 클래스를 택한 이유는 추상화 계층이 늘수록 실제 구현을 찾기 어려워지는 점을 꺼려서다. 그는 타입 클래스의 약점, 곧 같은 타입에 대해 서로 다른 구현을 둘 수 없다는 점을 알지만 Scala의 implicit은 좋은 생각이 아니라고 본다.

가변성은 눈에 보인다. 가변일 수 있는 자리에는 mutable이라는 키워드가 타입 앞에 붙고 화살표 모양이 다른 두 종류의 클로저는 각각 가변 값을 캡처할 수 있는 것과 증명된 불변인 것으로 나뉜다. 불변 객체의 메서드는 상태가 바뀔 때 자신의 새 버전을 돌려주고 이 불편을 덜려고 bang 연산자가 있다. 좌변에 bang을 붙이면 등호가 새 바인딩과 수정을 동시에 하고 경로 중간 어디에 붙여도 렌즈처럼 필요한 복사를 만들어 준다. 그는 이것이 다른 언어에 없는 혁신이라고 주장하는데, 이는 발언자의 평가다.

반응형 프레임워크와 메모리 모델

Skip 위에 만든 반응형 프레임워크의 발상은 시간이 멈춘 것처럼 초기화 코드를 쓰면 시스템이 변화에 반응하는 코드를 유도해 준다는 것이다. 핵심 구조는 두 가지 컬렉션이다. 입력에 함수를 맵핑하면 항상 최신으로 유지되는 컬렉션이 되고 지연 컬렉션은 필요할 때 계산해 캐시하며 쓰이지 않으면 버려진다. 가능하면 지연 컬렉션을 쓰라고 하고 색인처럼 미리 계산해야 하는 조회표만 즉시 유지하라고 한다. 스트림은 동시성 문제가 쌓인다는 이유로 좋은 추상화가 아니라고 본다. 영구 상태는 SQLite처럼 파일 하나에 두는 객체 저장소로, 명령을 실행할 때마다 이 파일을 메모리에 매핑해 갱신한다. 그래서 CI에서 저장된 상태로 시작해 처음부터 다시 계산하지 않을 수 있다.

메모리 모델도 독특하다고 소개한다. 장수하는 영속 객체는 불변이고 순환이 없음이 증명되므로 참조 횟수 방식으로 안전하게 관리하고 갱신 중의 임시 객체는 세대별 수집이나 누수를 허용하는 할당으로 처리한 뒤 커밋 시점에 살아남는 것만 복사한다. 그러면 수집 비용이 전체 힙 크기가 아니라 이번 갱신에서 할당한 양에 비례한다고 설명한다. 동시 커밋은 롤백 대신 바뀐 부분을 찾아 잠금 아래에서 증분으로 다시 계산해 충돌을 조정한다. 이 설명은 구조에 대한 그의 말이며 실제 지연 시간 측정은 영상에 없다.

제품과 한계

클론 SQLite로 만든 반응형 데이터베이스 SKDB는 구독 가능한 쿼리를 제공했지만 로직을 SQL로 더 쓰기 싫어하는 고객 반응으로 제품을 접었다고 한다. 그래서 TypeScript 프레임워크를 만들었다. JavaScript에서는 프록시 객체로 Skip 힙의 데이터를 보여 주고 브라우저에서는 WASM이라 힙이 1~2기가로 제한되며 서버에서는 노드와 Bun용 네이티브 바인딩을 쓴다. 서버에서 클라이언트로는 양방향이 필요 없어 서버 전송 이벤트를 쓰고 Postgres는 PG notify로 읽기 경로만 반응형으로 만든다. 성능 수치를 묻자 로직에 따라 달라 한 숫자로 답하기 어렵다고 답한다.

그가 제시하는 적합성 기준이 유용하다. 변경이 들어왔을 때 무엇을 갱신해야 하는지를 거꾸로 쓰는 것이 어렵다면 Skip이 맞고 쉽다면 단순한 라우팅 문제이므로 쓰지 말라는 것이다. 채팅 메시지 전달은 전자이고 접속한 사람들의 친구 관계 연결 요소 유지는 후자다. 기존 백엔드 로직이 복잡하면 다시 써야 하는 점이 채택의 걸림돌이다. 게임 쪽 수요는 없다고 그는 인정한다.

상업 전략은 기술은 MIT로 공개하고 그 위에 제품을 만드는 것이다. 최신 제품 Skipper는 명세를 주면 사람의 개입 없이 닫힌 루프로 프로그램을 만들어 내는 코딩 에이전트로, 타입이 신뢰할 만한 자체 TypeScript 구현을 새로 썼다. 타입이 건전하면 도달 가능성 분석으로 바뀐 코드가 영향을 주는 테스트만 다시 돌리고 생성된 프로그램 자체도 증분이 된다는 것이 비전이다. 그러나 이 전체 증분 체험은 아직 모두 연결되지 않았고 조각별로 내놓겠다고 그는 밝힌다.

읽을 때 유의할 점

이 영상은 설계자가 자신의 작업을 설명하는 대화라서 주장이 설계 선택의 정당화에 치우칠 수 있다. 초당 100만 줄, 수집 비용의 상한 같은 수치는 본인의 기억이나 설명이고 재현 자료가 없다. 자동 자막이라 일부 용어가 어긋나 있어 이름과 용어는 공식 문서로 확인하는 편이 안전하다. 그래도 가치 있는 통찰은 분명하다. 증분 계산의 어려움을 캐시와 불변성 보증의 문제로 환원한 점, 그리고 가변성만을 타입으로 추적해 사용성을 지키려 한 선택이다.

참고 자료

Developer Voices — From Hack to Skip: Building Incremental Everything (with Julien Verlaguet) (YouTube) — 영어 자동 자막 전체(약 2시간 22분)를 읽고 정리했다.

원문 출처는 본문의 Source에서 확인할 수 있습니다.