TL;DR

  • 서버 기반 스트리밍 HTML에서 틱 단위 배치 렌더링과 집계를 적용하면 동시 사용자가 늘어도 렌더링 비용의 우발적인 2차 증가를 막으면서 시스템을 단순하게 유지할 수 있음.
  • 서버가 거의 모든 상태를 관리하고 SSE와 스트리밍 압축으로 매 틱마다 전체 HTML 프레임을 전송하는 구조임.
  • 틱은 쓰기와 읽기 사이의 장벽이자 배치 경계로 작동하며, 렌더링 횟수·메모리 사용량·자원 경합을 제한하는 데 도움이 됨.
  • 재귀형 Hiccup 인터프리터와 함수·인자 기반 구성 요소 캐시로 쿼리 결과를 출력 버퍼에 바로 기록하고 반복되는 HTML 생성을 줄이는 방식임.
  • WTinyLFU의 캐시 입장 및 퇴출 특성이 캐시 스래싱을 완화하며, 실험 구현은 공유 VPS에서 동시 사용자 약 1,000명, 10 FPS를 처리함.

아키텍처 간단히 살펴보기

  • 이 모델에서는 상태의 거의 전부가 서버에 있으며, 서버가 생성한 다음 HTML 페이지 버전인 ‘프레임’을 매 X밀리초마다 한 번씩 모든 연결 클라이언트에 스트리밍함. 이 주기를 틱(tick)이라고 부름.
  • 이런 렌더링 방식은 즉시 모드(immediate mode)라고 불리며, Datastar Discord에서는 팻 모프(fat morph)라고도 부름.
  • 각 클라이언트에는 장시간 유지되는 SSE 연결과 스트리밍 압축(Brotli 또는 Zstandard)을 통해 프레임을 전송함.
  • view = f(state)라는 관점을 클라이언트가 아닌 서버에서 적용하는 구조임.

틱으로 시스템에 경계 설정

  • 틱은 작업을 묶을 배치 경계와 백프레셔(back pressure)를 제공하고, 시스템 성능을 측정할 지점도 마련함.
  • 서버에 부하가 걸리면 프레임이 누락되지만 시스템 전체가 무너지지는 않음.
  • 배치가 없으면 사용자가 수행한 각 작업이 모든 사용자에 대한 렌더링을 촉발해 시스템 비용이 우발적으로 2차 증가할 수 있음.
  • 사용자 1,000명이 각자 초당 한 번 작업하면 초당 1,000 × 1,000 = 1,000,000회 렌더링이 발생함.
  • 초당 한 번 업데이트하는 틱 기반 시스템에서는 사용자가 수행하는 작업 수와 관계없이 초당 1,000회만 렌더링함.

장벽

  • 틱 기반 시스템에는 장벽을 도입할 자연스러운 지점이 있음. 쓰기를 배치하는 가장 간단한 방법은 단일 쓰기 작업자를 두는 것임.
  • 틱 기반 시스템에서는 쓰기와 읽기 사이에 명확한 장벽을 둘 수 있음.
  • 쓰기 배치 → 렌더링 배치 → 이후 작업
  • 모든 장시간 연결을 순회하며 각 연결에 필요한 HTML을 생성하는 방식으로 렌더링을 배치할 수 있음.
  • 더 정교한 방식으로는 각 연결 그룹을 코어 하나에 고정하고 해당 그룹을 순회할 수 있음. 그룹마다 버퍼·캐시·데이터베이스 연결 같은 스레드 로컬 자원을 두는 방식임.
  • 이 구성은 시스템 동작을 더 결정적으로 만들고 메모리 사용량을 제한하는 데 도움이 됨.
  • 기존에는 각 연결의 템플릿 생성에 64KB 버퍼를 할당할 수 있지만, 렌더링을 배치하면 배칭 스레드마다 64KB만 필요함.
  • 동시 사용자 10,000명 기준으로 기존 방식은 640MB를 사용함. 버퍼를 사용하지 않는 경우 렌더링마다 640MB의 가비지가 생성됨.
  • 배치 방식에서는 스레드당 64KB가 필요하며, 코어가 4개인 시스템의 총량은 256KB임.
  • 자주 접근하는 캐시와 데이터베이스 연결에서는 경합을 제거할 수 있음. 경합이 중요한 이유는 LMAX 발표에서 더 살펴볼 수 있음. 각 렌더링 스레드가 자체 자원을 가지므로 조정이 필요하지 않음.

압축과 대역폭

  • 별도의 글에서 스트리밍 압축이 즉시 모드 렌더링의 네트워크 오버헤드를 제거하는 방식을 다룸.
  • 변경 사항이 없으면 전체 50KB HTML 프레임을 전송해도 네트워크상 크기가 13바이트까지 줄어들 수 있음.

매 틱마다 데이터베이스를 조회하는가?

  • 틱 기반 모델에서는 매 틱 데이터베이스를 조회하게 되며, 이 사례에서도 실제로 그렇게 함.
  • 프로젝션(projection)에 SQLite 같은 임베디드 데이터베이스를 사용하면 빠르게 조회할 수 있도록 프로젝션을 구성할 수 있음.
  • 쓰기 처리량이 걱정된다면 1,000억 행에서 초당 100,000건 처리: SQLite의 놀라운 효율성 글을 참고할 수 있음.

HTML 템플릿 생성

  • 이 구조에서는 문자열 생성·연결·인코딩·이스케이프와 일부 순회가 병목임.
  • 틱, 장벽, 압축, SQLite를 통해 많은 작업을 제거한 뒤에도 남는 병목은 HTML 템플릿 생성임. 초당 10회 갱신되는 동시 사용자 수가 수천 명이면 템플릿 생성이 프레임 예산의 상당 부분을 차지함.
  • 갱신이 필요한 사용자만 업데이트하는 최적화도 가능하지만, 모든 사용자가 공유 위젯을 사용해 함께 갱신되어야 하는 경우나 계속 변하는 동적 콘텐츠까지 해결하지는 못함.
  • 목표는 비용이 우발적으로 2차 증가하지 않게 하는 것임. 정상 경로만 개선하고 최악의 경우를 개선하지 않는 최적화는 장애 상황에서 오버헤드로 남음.
  • 여기서 집계가 활용됨. 지금까지는 변경이 없어도 매 틱 모든 사용자에게 브로드캐스트하는 단순한 방식을 의도적으로 유지했음. 따라서 모든 사용자 프레임을 함께 생성하는 배치 프로세스로 렌더링을 볼 수 있으며, 한 배치 안의 프레임과 이전 배치의 프레임 사이에 겹치는 부분이 있을 가능성을 전제로 삼을 수 있음.

간단한 Hiccup 인터프리터

  • 시스템에는 Hiccup을 재귀적으로 순회하며 바이트 버퍼에 기록하는 간단한 인터프리터가 있음.
  • 인터프리터에는 두 가지 작은 변경이 있음.
  • 함수를 만나면 평가하고, 그 결과를 추가로 해석할 Hiccup으로 취급함.
  • 요소의 첫 번째 인자가 함수이면 나머지 요소 콘텐츠를 인자로 전달해 해당 함수를 적용함. 이를 구성 요소처럼 취급함.
  • 함수 평가를 인터프리터가 해당 지점에 도달할 때까지 미룰 수 있음. 이에 따라 데이터베이스 쿼리 결과 전체를 메모리에 먼저 구체화하지 않고 출력 바이트 버퍼에 바로 스트리밍할 수 있음.
  • 구성 요소를 캐시로 감싸고 함수와 인자를 키로 출력 결과를 저장할 수 있음. 사실상 자동 콘텐츠 주소 지정 캐시임.
  • Palette 구성 요소는 현재 선택한 색상을 입력으로 받아 팔레트 항목을 생성함. Hiccup 안에서 Jump, Palette, Info 같은 구성 요소를 호출할 수 있으며, 인터프리터가 재귀형이므로 중첩도 가능함.
  • 현재 방식은 Reagent 구성 요소와 비슷하지만, 향후 Chassis 스타일 별칭으로 바꿀 가능성도 있음.
  • Hiccup 예시에 사용된 링크는 다음과 같음: Clojure, Datastar, 블로그.

캐시 스래싱

  • 자동 구성 요소 단위 콘텐츠 주소 지정 캐시에서는 서로 다른 작은 구성 요소가 많이 생성되어 더 가치 있는 캐시 항목을 밀어낼 수 있음.
  • 이를 위해 Caffeine 캐시 라이브러리에 구현된 캐시 알고리즘인 WTinyLFU를 사용함.
  • WTinyLFU에는 두 가지 특성이 있음.
  • 일정한 빈도로 접근된 항목만 캐시에 들어오므로 서로 다른 작은 항목은 캐시에 진입하지 않음.
  • 자주 요청되지 않는 항목은 자연스럽게 캐시에서 빠져나감.
  • 이 특성은 중첩 구성 요소에서 흥미로운 최적화 동작을 만듦. 자식 구성 요소와 모든 자식을 포함한 부모 구성 요소를 모두 캐시하면 캐시 공간을 대략 두 배 사용함.
  • 감싸는 구성 요소가 매번 달라지면 입장 정책에 따라 해당 구성 요소는 캐시에 저장되지 않음.
  • 감싸는 구성 요소가 안정적으로 유지되면 요청되지 않는 캐시된 하위 구성 요소가 캐시에서 빠져나감.

결론

  • 배치와 집계를 중심으로 사고하면 시스템을 이해하기 쉬운 단순한 상태로 유지하면서도 강력한 성능 최적화를 적용할 수 있음.
  • 애플리케이션을 개발할 때 캐싱이나 성능을 직접 고민하지 않고 Hiccup과 SQL 쿼리만 작성하는 구성이 목표임.
  • 실험용 소스 코드는 hyperlith 저장소에서 확인할 수 있음. 해당 코드는 프로덕션에서 실행 중이며, 2 vCore 공유 VPS에서 동시 사용자 약 1,000명, 10 FPS를 처리함.