> ## Documentation Index
> Fetch the complete documentation index at: https://ayakaleaf-pro.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Overleaf 벤치마크: Overleaf(SaaS 및 자체 호스팅)의 동시 LaTeX 컴파일에 대한 심층 연구

<Card title="Overleaf-Benchmark.pdf" icon="file-arrow-down" href="/images/blog/overleaf-benchmark.pdf" horizontal />

## 초록

자체 호스팅 Overleaf 배포의 규모는 흔히 하나의 경험칙으로 산정됩니다. 동시 사용자 5\~10명당 CPU 코어 1개와 메모리 1GB라는 규칙입니다. 우리는 이 규칙이 단지 부정확한 것이 아니라 구조적으로 틀렸음을 보입니다. 이 규칙은 하나의 자원 차원이 용량을 결정한다고 가정하지만 실제로는 서로 독립적인 두 개의 벽이 용량을 결정하며, 하드웨어가 아닌 두 개의 소프트웨어 매개변수가 최대 4배까지 결과를 좌우하기 때문입니다.

우리는 호스트 코어의 클록을 3.0 GHz로 고정한 QEMU/KVM 게스트 안에서 21가지 CPU/메모리 구성에 걸쳐, 샌드박스 컴파일(TeX Live 2025)을 사용하는 기본 Ayakaleaf Pro v6.2.2 배포를 측정했습니다. 워크로드는 실제 63페이지 분량의 XeLaTeX 학위 논문으로, 최대 수백 개의 서로 다른 사용자 계정이 동시에 컴파일합니다. 게스트 메모리가 32 GiB 미만일 때는 코어 수가 거의 무관하다는 사실을 발견했습니다. 16 GiB에서 4, 8, 16 vCPU 게스트의 측정 용량 차이는 8% 미만입니다. 대신 용량은 TeX Live 트리에 대한 공유 페이지 캐시에서 비롯되는 초선형(super-linear) 메모리 벽에 의해 결정됩니다.

이러한 법칙이 규모가 한 자릿수 이상 달라져도 유지되는지 확인하기 위해, 64코어, 995 GiB 단일 서버에서 같은 측정을 반복했습니다. 이 서버는 스레드 수의 8배인 1024개의 동시 콜드 컴파일을 100% 성공률로 감당했으며, 우리는 끝내 그 한계에 도달하지 못했습니다. 유용한 수치는 그 한계가 아니라 그 아래의 무릎(knee) 지점입니다. 꼬리 지연 시간은 $N=256$까지는 두 배가 될 때마다 20–40%씩 증가하지만, $N=512$에서는 190% 증가합니다. 따라서 "실패하지 않는 최대 동시성"으로 보고된 용량은 실제로 사용 가능한 운영 지점을 4배나 과대평가하게 됩니다. 이 머신에서는 메모리가 결코 병목 자원이 아니며, 한계는 CPU와 함께 컨테이너 데몬이 새 샌드박스를 받아들이는 속도입니다. 이 속도는 요청된 컴파일 수와 관계없이 200 근처에서 포화됩니다.

또한 용량 계획에서는 보이지 않는 두 가지 구현 수준의 효과를 확인했습니다. 첫째, CLSI는 어떤 환경 변수로도 노출되지 않는 하드코딩된 동시 컴파일 상한 65를 강제합니다. 이를 넘으면 사용자는 대기열에 들어가는 대신 즉시 HTTP 503을 받습니다. 둘째, Docker 러너의 컨테이너별 메모리 제한은 2018년 도입 이후 크기와 적용 위치 모두에서 효과가 없었기 때문에, 메모리 부족 이벤트가 발생하면 단일 컴파일이 아니라 호스트 전체가 다운됩니다. 동시성 상한을 해제하고 기본 컴파일 타임아웃을 180초에서 300초로 늘리면 8 vCPU / 48 GiB 게스트의 측정 용량이 64개에서 268개의 동시 컴파일로 증가합니다. 하드웨어 비용 없이 4.2배가 늘어나는 셈입니다.

마지막으로, 이 시스템에서 동시성은 시분할(time-sharing) 외에는 아무것도 얻지 못하며 워크로드는 오직 클록에 의해서만 제한된다는 것을 보입니다. 적합한 성능 저하 법칙 $T(N)=T_1\max(1,N/C)^{b}$는 $b=0.914$를 산출하는데, 이는 완벽한 비례적 감속에 가깝습니다. 또한 머신의 전체 1.0–5.5 GHz 범위에 걸친 클록 스윕은 서른 개의 측정값을 $k=27.9 GHz·s$인 $T=(k/f)\max(1,N/C)$ 위로 모으며, 잔차 분산은 5.1%입니다. 클록을 5.5배 높이면 수확 체감 없이 5.5배의 속도 향상을 얻습니다. 이것이 클록과 코어가 서로 다른 것을 얻게 해 준다는 의미입니다. 클록은 모든 사용자의 컴파일을 더 빠르게 만들고, 코어는 더 많은 사용자를 받아들일 뿐입니다.

## 1. 서론

Overleaf는 지배적인 협업 LaTeX 편집기이며, 그 온프레미스 배포판은 미발표 원고를 서드파티 클라우드로 보낼 수 없는 대학과 연구 그룹에서 널리 사용됩니다. 이러한 배포의 규모를 산정하는 것은 반복되는 실무적 질문입니다. 정해진 하드웨어 예산에서 실제로 몇 명이 동시에 "Recompile"을 누를 수 있을까요?

공식 지침은 선형 규칙, 즉 동시 사용자 5\~10명당 대략 코어 1개와 1GB이며, 이는 용량이 두 자원 모두에서 함께 매끄럽게 확장된다고 전제합니다. 우리의 측정 결과는 세 가지 면에서 이와 모순됩니다.

### 1.1 용량은 하나가 아닌 두 개의 독립적인 벽에 의해 결정된다

구성이 실패하는 이유는 둘 중 하나입니다. 메모리가 고갈되어 Overleaf 스택 자체가 죽고 HTTP 502를 반환하거나, 컴파일이 서버 측 타임아웃을 초과하여 수 기가바이트의 메모리가 사용되지 않은 채 CLSI가 `timedout`을 보고하는 경우입니다. 이 두 영역은 확장 동작과 해결책이 완전히 다릅니다. 메모리에 묶인 구성에 코어를 추가하는 것은 단지 비효율적일 뿐 아니라 때로는 역효과를 냅니다. 코어 수를 늘렸을 때 용량이 *감소하는* 구성을 측정했는데, 코어가 많을수록 동시 컴파일이 보조를 맞춰 진행되어 최대 메모리 요구가 서로 엇갈리지 않고 동시에 겹치기 때문입니다.

### 1.2 소프트웨어 매개변수가 하드웨어를 압도한다

컴파일 타임아웃은 MongoDB의 사용자별 필드로, 기본값 180초가 CPU에 묶인 구성의 용량을 조용히 제한합니다. 이를 300초로 늘리면 동일한 하드웨어에서 측정 용량이 최대 4.2배 증가합니다. 이와 별개로 CLSI는 하드코딩된 상수에 의해 65개를 초과하는 동시 컴파일을 거부합니다. 이 두 가지를 모두 고려하지 않는 용량 연구, 그리고 배포는 머신이 아니라 소프트웨어를 측정하고 있는 것입니다.

### 1.3 동시성은 병렬성이 아니라 시분할이다

LaTeX 컴파일은 단일 스레드이므로, $C$개의 코어에서 $N$명의 동시 사용자를 처리한다고 해서 시스템이 더 빨리 끝나지 않습니다. 모든 사용자가 비례적으로 더 오래 기다리게 될 뿐입니다. 따라서 "동시 사용자를 몇 명 지원하는가"라는 질문은 사용자가 얼마나 오래 기다릴 의향이 있는지를 정하기 전까지는 잘못 정의된 질문입니다. 우리는 이 의존성을 명시하고 정량화합니다.

### 1.4 기여

* 클록이 고정되고 반복 검증된 조건에서 측정한 21가지 CPU/메모리 구성에 대한 용량 매트릭스. 각 구성의 실패 신호로부터 병목 제약을 식별합니다.
* 두 가지 적합 모델: 초선형 메모리 벽과 CPU 상한을 분리한 용량 모델, 그리고 순수한 시분할 동작을 확립하는 지연 시간 모델.
* 2018년 이후 작동하지 않은 컨테이너 메모리 제한을 포함하여, 배포된 시스템의 두 가지 구현 문제를 식별하고 실험적으로 확인.
* 컴파일 타임아웃과 용량 간의 트레이드오프 정량화. 우리는 이 값이 모든 동시성 수치와 함께 명시되어야 한다고 주장합니다.

## 2. 배경

### 2.1 컴파일 경로

Overleaf 컴파일 요청은 `web` $\rightarrow$ `clsi` $\rightarrow$ 컴파일 컨테이너 순으로 이동합니다. 샌드박스 컴파일 배포(`SIBLING_CONTAINERS_ENABLED=true`)에서 CLSI는 프로세스 내에서 `latexmk`를 실행하지 않습니다. 대신 바인드 마운트된 소켓으로 접근할 수 있는 호스트 Docker 데몬에 요청하여, 프로젝트 디렉터리를 `/compile`에 바인드 마운트한 TeX Live 이미지로 새 컨테이너를 시작합니다. 따라서 컴파일 하나는 `latexmk` 프로세스 하나를 실행하는 수명이 짧은 컨테이너 하나입니다.

여기서 세 가지 결과가 따라오며, 세 가지 모두 이 논문의 측정에 영향을 줍니다. 첫째, 작업 단위는 단일 스레드 프로세스입니다. XeLaTeX는 병렬화되지 않습니다. 둘째, 컴파일별 자원 격리는 Docker 러너가 요청하는 만큼만 이루어집니다. §6.2에서 보이듯이 사실상 아무것도 요청하지 않습니다. 셋째, 작업 집합(working set)은 문서가 아니라 TeX Live 트리가 지배합니다. 약 32 GiB의 읽기 전용 코퍼스로, 모든 동시 컴파일이 이를 읽으므로 호스트 페이지 캐시를 통해 공유합니다. 이 공유가 우리가 관찰한 초선형 메모리 확장의 근원입니다.

### 2.2 샌드박스 컴파일 활성화

커뮤니티 에디션 Overleaf는 애플리케이션 컨테이너 자체 안에서 `latexmk`를 실행합니다. 반면 Ayakaleaf Pro는 Overleaf Server Pro와 마찬가지로 각 컴파일을 *형제(sibling)* 컨테이너에서 실행할 수 있습니다. 형제 컨테이너는 애플리케이션 컨테이너 안에 중첩되는 것이 아니라 애플리케이션이 *호스트의* Docker 데몬에서 시작하는 컨테이너입니다. 두 가지 Toolkit 설정으로 이를 켤 수 있습니다:

```ini theme={null}
# config/overleaf.rc
SERVER_PRO=true
SIBLING_CONTAINERS_ENABLED=true
DOCKER_SOCKET_PATH=/var/run/docker.sock

# config/variables.env
TEX_LIVE_DOCKER_IMAGE=ghcr.io/ayaka-notes/texlive-full:2025.1
ALL_TEX_LIVE_DOCKER_IMAGES=ghcr.io/ayaka-notes/texlive-full:2025.1
```

Toolkit은 호스트 Docker 소켓을 애플리케이션 컨테이너에 바인드 마운트하고, 이 설정을 CLSI가 읽는 환경 변수로 변환합니다: `SANDBOXED_COMPILES=true`, `SANDBOXED_COMPILES_SIBLING_CONTAINERS=true`, 그리고 컴파일 디렉터리의 *호스트* 경로인 `SANDBOXED_COMPILES_HOST_DIR`입니다. 이 경로가 중요합니다. 컴파일 컨테이너를 시작하는 데몬이 호스트의 데몬이므로, 전달되는 바인드 마운트는 애플리케이션 컨테이너가 아니라 호스트의 네임스페이스에서 확인 가능해야 합니다. 또한 Server-Pro의 `config/env.sh`는 이 모드에서 `TEXLIVE_IMAGE_USER=www-data`를 강제하여 컴파일 컨테이너가 기록한 파일의 소유권이 일관되도록 합니다.

검증은 간단합니다. 컴파일 중에 호스트에는 TeX Live 이미지에서 `latexmk`를 실행하고 0으로 종료하는 `project-{projectId}-{userId}-{hash}`라는 이름의 컨테이너가 표시됩니다. 이 논문 전반에 걸쳐 우리가 그 수를 측정하는 단위가 바로 이것이며, §6.2에서 이 단위에 자원 제한이 전혀 없다는 점을 보고합니다.

<strong>이것이 연구에 중요한 이유.</strong>

형제 컨테이너는 측정을 깔끔하게 만들어 줍니다. 각 컴파일이 관찰 가능하고 독립적으로 스케줄링되는 OS 엔티티이기 때문입니다. 하지만 이는 동시에 Overleaf가 아니라 게스트 커널이 컴파일 간의 CPU와 메모리를 중재한다는 뜻이기도 합니다. 따라서 이 논문의 모든 확장 법칙은 $N$개의 단일 스레드 프로세스에 적용된 Linux 스케줄러의 속성이며, 그래서 이토록 규칙적입니다.

<Frame>
  <img src="https://mintcdn.com/ayakaleaf-pro/x9kfDjtWlyyhG_mR/images/blog/fig_flow.png?fit=max&auto=format&n=x9kfDjtWlyyhG_mR&q=85&s=c6b96766866668c223bfa4ae1a4d9d1b" alt="" width="1353" height="359" data-path="images/blog/fig_flow.png" />
</Frame>

<strong>그림 1.</strong> 커뮤니티 에디션 마이크로서비스를 통과하는 하나의 컴파일 요청 추적. 각 단계에서의 분기는 용량에 중요합니다. 문서 텍스트는 요청 본문에 복사되는 반면, 바이너리 자산은 참조로 전달되어 `clsi`가 가져옵니다. 어느 쪽도 지배적이지 않습니다. 프로젝트의 컴파일 비용은 모든 동시 컴파일이 공유 페이지 캐시를 통해 읽는 32 GiB의 TeX Live 트리에 의해 결정됩니다.

<Frame>
  <img src="https://mintcdn.com/ayakaleaf-pro/x9kfDjtWlyyhG_mR/images/blog/fig_arch.png?fit=max&auto=format&n=x9kfDjtWlyyhG_mR&q=85&s=117eb544d44a6d2bdd9dc88945d9543f" alt="" width="1731" height="559" data-path="images/blog/fig_arch.png" />
</Frame>

<strong>그림 2.</strong> 세 가지 배포 토폴로지와 각각에서 인스턴스별 컴파일 상한이 위치하는 곳. 겹쳐진 패널은 복제를 나타냅니다. 65 컴파일 상수는 *하나의* CLSI를 보호하므로, SaaS 플릿은 이를 인스턴스와 영역 수만큼 곱하고(a), Server Pro와 Ayakaleaf Pro가 지원하는 수평 확장은 인스턴스 수만큼 곱합니다(c). 그 대가로 중앙 MongoDB, Redis, S3 호환 스토리지, 쿠키 세션 어피니티를 갖춘 로드 밸런서(컴파일 출력이 인스턴스 로컬 디스크에 기록되므로 컴파일과 그 이후의 PDF 다운로드는 같은 인스턴스로 가야 함), 그리고 단일 `git-bridge`가 필요합니다. 우리가 측정한 Toolkit 기본값(b)은 배수가 1이므로, 플릿의 한 구성원을 위해 정해진 상수가 설치 전체의 상한이 됩니다.

<Frame>
  <img src="https://mintcdn.com/ayakaleaf-pro/x9kfDjtWlyyhG_mR/images/blog/fig_ring.png?fit=max&auto=format&n=x9kfDjtWlyyhG_mR&q=85&s=65b022a41f38ac91f273953b1e5f7003" alt="" width="971" height="279" data-path="images/blog/fig_ring.png" />
</Frame>

<strong>그림 3.</strong> `clsi-cache`의 샤드 선택. 프로젝트는 $\operatorname{crc32}(\text{projectId}\text{-}i)\bmod|\text{shards}|$로 매핑됩니다. 즉, 해시 공간이 샤드 수만큼의 동일한 섹터로 나뉩니다. 이는 링 기반 일관된 해싱(consistent hashing)이 아닌 *모듈로* 해싱입니다. 플릿을 샤드 3개에서 4개로 늘리면 공간 전체가 다시 분할되어 사실상 모든 프로젝트가 다시 매핑됩니다(a, b). 바로 그 때문에 구현에는 일관된 해싱 링이 제공하는 $K/n$ 이동 대신, 일정 시간 동안 선형적으로 증가하는 비율의 프로젝트를 `currentShards`에서 `desiredShards`로 옮기는 명시적인 온라인 리샤딩 램프가 필요합니다. 샤드의 서킷 브레이커가 작동하면 솔트 $i$가 증가하고 해당 샤드가 후보 목록에서 제거되므로, 조회는 실패하지 않고 다음 후보를 탐색합니다(c).

### 2.3 두 가지 실패 모드

우리가 측정한 모든 구성은 정확히 두 가지 방식 중 하나로 실패하며, 그 구분은 추론이 아니라 응답 상태에서 직접 드러납니다:

* **메모리 고갈** — Overleaf 스택 자체가 응답하지 않게 되고 요청이 **HTTP 502**를 반환합니다. 실패 수준에서 사용 가능한 게스트 메모리는 일반적으로 500 MiB 미만입니다.
* **컴파일 타임아웃** — CLSI가 사용자별 타임아웃에서 컴파일을 종료하고 `timedout` 상태를 보고합니다. 실패 수준에서 사용 가능한 메모리는 종종 수 기가바이트에 달합니다.

우리는 자원 비율에 대한 휴리스틱이 아니라 이 신호로 각 구성을 분류하며, 이를 통해 "어느 벽에 부딪혔는가"라는 질문에 데이터 자체로 답할 수 있습니다.

## 3. 방법론

### 3.1 테스트베드와 클록 제어

모든 게스트는 62 GiB RAM과 NVMe 스토리지를 갖춘 단일 Intel Core i9-14900K 호스트에서 QEMU/KVM으로 실행됩니다. 게스트는 Docker 29.7이 설치된 Ubuntu 24.04이며, Overleaf Toolkit으로 `texlive-full:2025.1` 기반 샌드박스 컴파일을 사용하는 Ayakaleaf Pro v6.2.2를 배포했습니다.

범용 데스크톱 CPU는 클록을 제어하지 않으면 서버의 대용물로 적합하지 않습니다. KVM은 가상 클록을 설정하는 메커니즘을 제공하지 않습니다. vCPU는 호스트 스레드이며 호스트 코어가 동작하는 주파수로 실행됩니다. 따라서 우리는 호스트를 직접 제한하여 터보를 비활성화하고 모든 코어의 `scaling_max_freq`를 3.0 GHz로 고정했으며, `taskset`으로 게스트의 vCPU를 물리 P-코어에 고정했습니다. 하이브리드 코어 CPU에서는 이 구분이 중요합니다. 이 제품의 E-코어는 기본 클록이 2.4 GHz이며 터보를 끄면 3.0 GHz에 *도달할 수 없으므로*, E-코어로 넘어간 실행은 더 느린 머신을 조용히 측정하게 됩니다. 전체 부하 상태에서 고정된 16개 스레드 모두가 정확히 3000 MHz인지 확인했습니다. 가드 스크립트가 모든 벤치마크 전에 이 불변 조건을 검사하고 충족되지 않으면 시작을 거부하며, 연구 기간 중 거버너가 조용히 재설정된 한 건을 잡아냈습니다.

### 3.2 두 번째 테스트베드: 대형 러너 하나

QEMU 매트릭스는 한 번에 하나의 변수를 분리하지만, 고정된 스레드 16개가 상한입니다. 같은 법칙이 한 자릿수 더 큰 규모에서도 성립하는지 확인하기 위해, 단일 대형 서버에서 동시성 스윕을 반복했습니다. AMD EPYC 7773X(Milan-X, 64코어 / 128스레드, L3 768 MiB) 하나와 995 GiB RAM을 갖춘 서버로, 같은 `texlive-full:2025.1`에 대해 같은 Ayakaleaf Pro v6.2.2 이미지를 실행했습니다. QEMU 게스트와 달리 이 머신은 클록이 고정되어 있지 않습니다. 프로덕션급 서버이며 우리도 그렇게 측정했습니다.

두 가지 운영상 예방 조치가 필요했으며, 이를 언급할 가치가 있습니다. 이것이 없으면 실험은 서버가 아니라 테스트 하네스를 측정하게 되기 때문입니다. 첫째, 모든 컨테이너를 `MemoryMax=940 GiB`인 `systemd` 슬라이스에 가두어, 폭주하는 스윕이 호스트가 아니라 cgroup을 고갈시키도록 했습니다. 둘째, 샌드박스 컴파일은 호스트 데몬이 생성하며 각각 자체 copy-on-write 레이어를 변경합니다. 20.6 GiB 베이스 이미지가 공유되더라도 컨테이너당 116 MiB로 측정되었으므로, Docker 데이터 루트를 전용 NVMe 장치로 옮겼습니다. $N=1024$에서의 스윕은 약 119 GiB의 스크래치 레이어를 기록하며, 이는 기본 루트 파일 시스템에 들어가지 않습니다.

### 3.3 워크로드

문서는 `latexmk`를 통해 XeLaTeX로 컴파일되는 실제 63페이지 석사 학위 논문(SJTU 템플릿)으로, TikZ 그림, `biblatex` 참고문헌 처리, 내장 PDF 자산을 포함합니다. 즉, 합성이 아닌 현실적인 부하입니다. 부하가 없는 게스트에서의 단일 컴파일은 모든 구성에서 8.6–9.8초가 걸리며, 우리는 이를 자유 비행 기준선 $T_1$으로 사용합니다.

### 3.4 부하 생성

512개의 실제 사용자 계정을 만들고 각각에 프로젝트 사본을 하나씩 주어, 동시 컴파일이 프로젝트 잠금을 공유하지 않고 독립적인 사용자처럼 정확히 경합하도록 했습니다. 요청은 호스트에서 게스트의 포워딩된 포트로 보내므로, 부하 생성이 게스트 CPU를 소비하지 않습니다.

동시성은 시차를 둔 것이 아니라 \_동시적\_입니다. 모든 세션(로그인, CSRF 토큰, 컴파일러 선택)을 먼저 수립한 다음에야 각 스레드는 한 번 계산되어 공유된 공통 벽시계 시각까지 대기한 후 `POST /project/:id/compile`을 보냅니다. 이 구분은 사소한 것이 아닙니다. 시차를 둔 램프는 안정된 대기열에서의 처리량을 측정하고, 동시 버스트는 강의실 가득한 학생들이 같은 마감 공지 후에 같은 버튼을 누를 때 일어나는 일을 측정합니다. 운영자가 실제로 두려워하는 것은 후자입니다. 두 경우는 상수 배 이상으로 차이가 나는데, 후자는 데몬이 처리할 수 있는 것보다 빠르게 컴파일 대기열을 채우기 때문입니다.

그 버스트를 충실하게 전달하려면 먼저 네 가지 실무적 장애물을 제거해야 했습니다. 각각을 기록할 가치가 있습니다. 각각이 실험을 서버가 아닌 하네스의 측정으로 조용히 변질시키기 때문입니다.

#### 3.4.1 속도 제한기는 하나가 아니라 둘

Overleaf는 출발지 주소별로 로그인을 분당 20회로 제한하며, 우리의 모든 트래픽은 단일 호스트에서 발생합니다. 시뮬레이션된 각 사용자에게 서로 다른 `X-Forwarded-For` 주소를 할당하면 이 제한은 사라지지만, 곧바로 더 거친 두 번째 제한, 즉 서브넷당 분당 약 200회의 예산에 부딪힙니다. 따라서 연속된 블록에 사용자를 분산하면 201번째 계정에서 실패합니다. 대신 연속된 사용자가 서로 다른 `/24`에 속하도록 사용자 인덱스로부터 합성 주소를 도출했습니다.

$$
\texttt{203.}\;\big\lfloor i/250 \big\rfloor \bmod 100 + 1\texttt{.}\;
  i \bmod 250 + 1\texttt{.}\; i \bmod 200 + 10 ,
$$

이렇게 하면 1024명 전체에 대해 두 제한기 모두 여유가 유지됩니다.

#### 3.4.2 주입된 헤더는 기본적으로 버려진다

헤더를 설정하는 것만으로는 충분하지 않습니다. Express는 신뢰하도록 지정된 피어에 대해서만 `X-Forwarded-For`를 인정하며, Overleaf의 `trustedProxyIps` 기본값은 `loopback`입니다. 부하 생성기는 루프백 인터페이스가 아니라 컨테이너의 브리지를 통해 애플리케이션에 도달하므로, 헤더는 파싱된 후 버려지고 시뮬레이션된 모든 사용자가 다시 하나의 주소로 합쳐집니다. 증상은 정확히 스무 번째 로그인에서 발생하는 `HTTP 429`의 물결로, 서버 과부하로 오인하기 쉽습니다. 게이트웨이 네트워크를 신뢰 체인에 명시적으로 추가해야 하며, §4.3의 클러스터 배포에서는 파드 및 서비스 CIDR도 추가해야 합니다.

#### 3.4.3 로드 밸런서는 보존하라고 요청받은 헤더를 덮어쓴다

인스턴스가 프록시 뒤에 있을 때 관례적인 `option forwardfor`는 실제 클라이언트 주소를 체인에 \_추가\_합니다. 이는 프로덕션에서는 올바른 동작이지만 여기서는 정확히 잘못된 동작입니다. 합성 주소가 부하 생성기 자신의 주소로 대체되기 때문입니다. 지시어를 `option forwardfor if-none`으로 한정하여, 클라이언트가 값을 제공하지 않은 경우에만 프록시가 값을 추가하도록 해야 합니다.

#### 3.4.4 서버의 용량보다 클라이언트의 파일 디스크립터가 먼저 바닥난다

$N=1024$에서 생성기는 천 개가 넘는 소켓을 동시에 유지하며, 기본 소프트 제한인 디스크립터 1024개는 측정 중이 아니라 세션 설정 중에 도달합니다. 실패는 조용히 일어납니다. 세 개의 세션이 수립되지 못해 실행 결과가 1024가 아닌 1021로 보고되고, 컨테이너 수를 얻기 위해 셸 명령을 호출하는 샘플링 스레드는 `EMFILE`로 죽으면서 원격 측정 데이터를 조용히 잘라 버립니다. 생성기에서 소프트 제한을 올려야 하며(우리 호스트의 하드 제한은 이미 1048576이었습니다), 실행을 반복해야 합니다. §4.3에서 두 실행을 모두 보고합니다. 수정된 실행은 1024개 중 1024개를 완료했으며 중앙값은 잘린 실행과 1.2초 이내의 차이를 보였습니다. 그래서 첫 번째 실행을 사용할 수는 있지만 권위 있는 결과로 보지는 않습니다.

### 3.5 측정 프로토콜

재현성을 위해 몇 가지 방법론적 선택이 필요했습니다.

#### 3.5.1 워밍업

새로 부팅한 게스트에서는 페이지 캐시가 비어 있어, 처음 몇 번의 컴파일은 정상 상태의 용량이 아니라 콜드 스타트 I/O를 측정합니다. 같은 2 vCPU / 2 GiB 구성이 콜드 상태에서는 36.5초, 워밍 상태에서는 9.8초로 3.7배 차이가 납니다. 따라서 각 구성은 부팅 후 결과를 버리는 단일 컴파일 워밍업을 두 번 수행합니다.

#### 3.5.2 통과 기준

동시성 수준은 *모든* 컴파일이 성공하고 반복 실행에서도 유지될 때만 통과합니다. 이는 성공률 임계값보다 엄격하며 중요합니다. 4 vCPU / 16 GiB에서 수준 32는 한 번은 중앙값 80.2초로 통과했지만 반복했을 때 32개 컴파일 모두 타임아웃되었으므로, 우리는 31을 보고합니다.

#### 3.5.3 탐색

수준은 모델이 예측한 시드에서 시작하는 지수적 범위 설정(exponential bracketing)과 그 뒤의 정확한 정수 이분 탐색으로 찾습니다. 기준이 전부 아니면 전무이므로 수준은 첫 번째 실패로 결정되며, 따라서 하나가 실패하면 나머지 진행 중인 요청은 포기합니다. 단, 작은 수준에서는 포기된 컴파일이 작은 게스트를 심하게 붙잡아 결코 회복되지 않으므로 예외입니다.

#### 3.5.4 수준 간 격리

다음 수준을 시작하기 전에 컴파일 컨테이너를 모두 비우고, 웹 애플리케이션이 다시 응답할 때까지 폴링합니다. 이렇게 하지 않으면 충돌 직후의 수준에서 세션이 0개인 가짜 실패가 기록됩니다.

#### 3.5.5 호스트 위생

호스트의 관련 없는 가상 머신은 종료했습니다. 호스트 메모리 24 GiB가 다른 곳에 할당되어 있을 때 같은 게스트 구성은 동일한 동시성에서 부하 평균이 3.2가 아닌 11.7로 보고되었습니다. 호스트 메모리 압박은 게스트로 전파되어 측정을 무효화합니다.

## 4. 결과

### 4.1 용량 매트릭스

표 1과 그림 4는 모든 구성의 측정 상한을 보여 줍니다. 행을 가로로 읽으면 첫 번째 놀라움이 나타납니다. 4 GiB에서 2, 4, 8 vCPU 게스트는 모두 정확히 9에 도달합니다. 코어를 네 배로 늘려도 아무것도 바뀌지 않습니다. 16 GiB에서는 54, 45, 57에 도달합니다. 코어를 4개에서 16개로 늘려 얻는 것은 6%이며, 8코어 게스트는 오히려 4코어 게스트보다 *나쁩니다*(§5.2). 48 GiB에서만 코어 수가 구성들을 확실히 구분합니다: 143, 268, 331.

<Frame>
  <img src="https://mintcdn.com/ayakaleaf-pro/x9kfDjtWlyyhG_mR/images/blog/fig1_capacity.png?fit=max&auto=format&n=x9kfDjtWlyyhG_mR&q=85&s=a7bf6e97613c9565b4031986e98dffb2" alt="" width="2713" height="1116" data-path="images/blog/fig1_capacity.png" />
</Frame>

<strong>그림 4.</strong> 구성 매트릭스 전체의 측정 용량. (a) 모든 구성을 메모리별로 묶고 코어 수별로 색을 입힌 막대로 표시했습니다. 채워진 막대는 메모리에 묶인 구성(메모리가 고갈되어 게스트가 죽음)이고, 빗금 막대는 CPU에 묶인 구성(메모리가 남은 상태에서 컴파일이 타임아웃됨)입니다. 한 그룹을 왼쪽에서 오른쪽으로 읽으면 16 GiB 미만에서 코어 수가 얼마나 적은 이득을 주는지 알 수 있고, 그룹을 가로질러 읽으면 메모리에 대한 초선형 수익을 알 수 있습니다. (b) 같은 점들을 적합 모델 $N_{\max}=\min(0.69R^{1.60},\,26.4C)$와 비교했습니다. 파선은 메모리 벽이고 점선 수평선은 코어 수별 CPU 상한입니다. 구성은 둘 중 먼저 만나는 쪽에 묶입니다.

열을 세로로 읽으면 두 번째 놀라움이 나타납니다. 코어가 고정되었을 때 용량은 메모리에 대해 대략 $R^{1.6}$으로 초선형적으로 증가하며, 그 이유는 §5.1에서 설명하는 페이지 캐시 때문입니다.

| 메모리 | 2 vCPU | 4 vCPU | 8 vCPU | 16 vCPU |
| -: | -: | -: | -: | -: |
| 2 GiB | 1 | 1 | 1 | — |
| 3 GiB | 4 | 5 | 6 | — |
| 4 GiB | 9 | 9 | 9 | — |
| 8 GiB | 21 | 22 | 26 | — |
| 16 GiB | — | 54 | **45** | 57 |
| 32 GiB | — | **145** | 135 | 141 |
| 48 GiB | — | **143** | **268** | **331** |

<strong>표 1.</strong> 성공적으로 완료되는 최대 동시 컴파일 수. 컴파일 타임아웃 300초, CLSI 동시성 상한을 해제한 상태에서 측정했습니다. **굵은 글씨**는 CPU에 묶인 구성(메모리가 남은 상태에서 컴파일이 타임아웃됨)을 나타내며, 나머지는 메모리에 묶인 구성(스택이 HTTP 502와 함께 죽음)입니다. 2 GiB 행에는 §5.2에서 논의하는 보정이 반영되어 있습니다.

### 4.2 동시성은 시분할이다

그림 5는 고정된 8 vCPU / 16 GiB 게스트에서 모든 동시성 수준을 스윕한 결과입니다. 두 영역은 코어당 정확히 하나의 컴파일 지점에 있는 뚜렷한 무릎으로 나뉩니다. 그 아래에서 평균 컴파일 시간은 평탄합니다. $N=1$에서 8.7초, $N=C=8$에서 9.1초로 5% 변화합니다. 그 위에서는 시간이 $N/C$에 엄격하게 비례하여 증가합니다. $N=16,24$에서 18.5초와 27.1초를 측정했으며, 이는 이상적인 $1:2:3$에 대해 $1:2.13:3.12$의 비율입니다.

<Frame>
  <img src="https://mintcdn.com/ayakaleaf-pro/x9kfDjtWlyyhG_mR/images/blog/fig4_scaling.png?fit=max&auto=format&n=x9kfDjtWlyyhG_mR&q=85&s=edcc49a4e403ecead7b9dd21f795bdb1" alt="" width="2633" height="1085" data-path="images/blog/fig4_scaling.png" />
</Frame>

<strong>그림 5.</strong> 고정된 하드웨어에서 동시성에 따른 컴파일 지연 시간. 무릎은 $N=C$에 있으며, 그 너머에서 측정된 감속은 5–7% 이내로 $N/C$를 따릅니다. 15개 수준 모두 완전히 성공했습니다.

<Frame>
  <img src="https://mintcdn.com/ayakaleaf-pro/x9kfDjtWlyyhG_mR/images/blog/fig2_latency.png?fit=max&auto=format&n=x9kfDjtWlyyhG_mR&q=85&s=01880bd8722d1ad2789649c049c0ecc2" alt="" width="2693" height="1960" data-path="images/blog/fig2_latency.png" />
</Frame>

<strong>그림 6.</strong> 여러 구성에서 동시성에 따른 컴파일 지연 시간. 각 패널은 하드웨어를 고정하고 제시된 부하를 스윕하며, 수직선은 $N=C$를 나타냅니다. 곡선은 그 왼쪽에서 평탄하고 오른쪽에서 $N/C$에 선형적입니다. 이는 경합이 아니라 시분할의 특징입니다. 작업이 더 비싸지는 것이 아니라 단지 차례를 기다릴 뿐입니다.

연구의 모든 성공 측정값에 대해 $T(N)=T_1\max(1,N/C)^{b}$를 적합하면 $b=0.914$($R^2_{\log}=0.904$, $n=81$)를 얻습니다. 1과 구별할 수 없는 지수는 컴파일이 단일 스레드, CPU에 묶인 작업 단위이며 동시성이 코어를 나누는 것 이상으로 도움도 해도 되지 않는다는 정량적 진술입니다. 용량 계획에 대한 실무적 귀결은 불편합니다. 구성은 *실패하지* 않고도 임의의 수의 사용자를 받아들이면서 그들 모두를 비례적으로 더 오래 기다리게 할 수 있습니다. 이 게스트에서 $N=56$일 때 모든 컴파일은 여전히 성공하지만, 각 사용자는 8.7초 대신 64.8초를 기다립니다.

### 4.3 1024개 동시 컴파일까지의 수직 확장

표 2와 그림 7은 대형 러너에서의 스윕 결과를 보고합니다. 모든 수준은 *콜드* 컴파일입니다. 각 수준 전에 `DELETE /project/:id/output`을 통해 참여하는 모든 프로젝트의 컴파일 디렉터리와 CLSI 캐시를 지우므로, 어떤 수준도 그 아래 수준이 한 작업의 이점을 얻지 못합니다. 이 머신의 단일 컴파일 기준선은 28.8초이며, 이는 콜드 수치이므로 앞에서 사용한 8.6–9.8초의 정상 상태 기준선과 비교해서는 안 됩니다. QEMU 게스트의 콜드 기준선은 28.3초이므로, 이 워크로드에서 두 머신의 스레드당 성능 차이는 2% 이내입니다.

| $N$ | 성공 | $p_{50}$ | $p_{95}$ | $p_{50}/T_1$ | 최대 컨테이너 |
| -: | -: | -: | -: | -: | -: |
| 64 | 64/64 | 55.0 s | 55.1 s | 1.9× | — |
| 128 | 128/128 | 73.6 s | 74.4 s | 2.6× | — |
| 192 | 192/192 | 80.2 s | 87.3 s | 2.8× | — |
| 256 | 256/256 | 69.8 s | 121.2 s | 2.4× | 151 |
| 512 | 512/512 | 179.2 s | 344.6 s | 6.2× | 205 |
| 1024 | 1024/1024 | 280.2 s | 468.9 s | 9.7× | 179 |

<strong>표 2.</strong> EPYC 7773X 1대(64코어 / 128스레드, 995 GiB)에서의 동시성 스윕. 모든 수준은 콜드이며 기준선은 28.8초입니다. 최대 컨테이너는 동시에 살아 있는 샌드박스의 최대 개수입니다.

<Frame>
  <img src="https://mintcdn.com/ayakaleaf-pro/x9kfDjtWlyyhG_mR/images/blog/fig6_large_runner.png?fit=max&auto=format&n=x9kfDjtWlyyhG_mR&q=85&s=baf71caed9904a87456b4df16bb7a708" alt="" width="2677" height="1040" data-path="images/blog/fig6_large_runner.png" />
</Frame>

<strong>그림 7.</strong> 대형 러너 1대에서의 수직 확장. (a) 제시된 동시성에 따른 지연 시간. 음영 영역은 무릎을 지난 영역을 나타냅니다. (b) 실제로 살아 있는 샌드박스 수는 요청된 수를 결코 따라가지 못하고 200 근처에서 포화되며, 컴파일 cgroup은 상한의 5분의 1 이상을 사용하지 않습니다.

#### 4.3.1 머신은 결코 실패하지 않는다

스레드 수의 8배인 $N=1024$를 포함하여 모든 수준이 100% 완료됩니다. 우리는 이 머신의 용량 상한을 찾지 못했습니다. 머신의 여유가 바닥나기 전에 우리의 인내심이 먼저 바닥났습니다. 이는 이 연구에서 병목 제약이 메모리가 아닌 첫 번째 구성입니다. $N=1024$에서 컴파일 cgroup은 940 GiB 상한의 5분의 1인 184 GiB에서 정점을 찍는 반면, CPU는 부하 평균 166으로 100% 사용률을 보입니다.

#### 4.3.2 수용 속도가 제한되므로 성능 저하는 준선형이다

단순한 시분할은 스레드의 $8\times$가 지연 시간의 $8\times$를 초래한다고 예측합니다. 측정된 비용은 단일 컴파일 대비 $9.7\times$이지만, 제시된 부하가 8배 증가했음에도 $N=128$ 대비로는 $3.8\times$에 불과합니다. 그 이유는 그림 7(b)와 표 2의 마지막 열에서 볼 수 있습니다. 1024개의 요청이 동시에 발행되지만 실제로 살아 있는 샌드박스 수는 205를 넘지 않습니다. 데몬은 클라이언트가 요청하는 만큼 빠르게 컨테이너를 만들 수 없으므로, 요청은 CPU 안에서 경합하는 대신 수용 단계에서 대기합니다. 여기서 꼬리 지연을 구하는 것은 대기열이며, 이는 우연히 그렇게 된 것입니다.

#### 4.3.3 무릎은 실패 지점이 아니라 512에 있다

$N=256$과 $N=512$ 사이에서 부하가 두 배가 될 때 $p_{95}$ 지연 시간은 $2.9\times$ 증가합니다. 그 이전의 모든 두 배 증가는 $1.2\times$에서 $1.4\times$ 사이의 비용이 들었습니다. "실패하지 않는 최대 $N$"으로 기술된 용량은 1024를 보고할 것이고 운영자에게는 쓸모가 없습니다. 그 지점에서 꼬리 대기 시간은 거의 8분입니다.

### 4.4 컴파일 시간은 클록에 반비례한다

워크로드가 CPU에 묶여 있으므로 그 비용은 $1/f$로 확장되어야 합니다. 다른 조건은 그대로 둔 게스트에서 호스트 클록을 머신의 전체 범위인 1.0–5.5 GHz에 걸쳐 10단계로 스윕하여 이를 직접 검증했습니다(그림 8). 단일 컴파일 시간은 26.5초에서 4.8초로 변합니다. 클록 5.5배가 범위 내 어디에서도 수확 체감 없이 5.5배의 속도 향상을 가져옵니다. 곱 $T\!\cdot\!f$는 10개 클록 모두에서 2% 이내로 일정합니다.

코어 몫으로 정규화하면 서른 개의 측정값(10개 클록에서 3개 동시성 수준)이 모두 단일 상수로 수렴합니다:

$$
T(N,f) \;=\; \frac{k}{f}\,\max\!\left(1,\frac{N}{C}\right),
  \qquad k = 27.9\ \mathrm{GHz\cdot s}
$$

클록 자체가 5.5배 변하는 범위에서 잔차 분산은 5.1%입니다. 곡률이 전혀 없다는 것 자체가 결과입니다. 워크로드가 메모리 대역폭이나 I/O에 묶여 있었다면, CPU가 다른 자원을 앞지르면서 높은 클록에서 $T$가 평탄해졌을 것입니다.

<Frame>
  <img src="https://mintcdn.com/ayakaleaf-pro/x9kfDjtWlyyhG_mR/images/blog/fig5_frequency.png?fit=max&auto=format&n=x9kfDjtWlyyhG_mR&q=85&s=64447722825ad0cc0277e4734c7625e9" alt="" width="2633" height="1085" data-path="images/blog/fig5_frequency.png" />
</Frame>

<strong>그림 8.</strong> 클록 스윕. (a) 적합된 쌍곡선과 함께 표시한 $T=k/f$. (b) $\max(1,N/C)$로 나누면 모든 점이 하나의 상수로 수렴하여 식 (1)을 확인해 줍니다.

식 (1)은 말하기는 쉽지만 틀리기도 쉬운 직접적인 조달상의 귀결을 갖습니다. *클록은 모든 개별 사용자의 경험을 개선하고, 코어 수는 더 많은 사용자를 받아들일 뿐입니다*. 클록이 20% 높은 머신은 수확 체감 없이 모두에게 20% 더 빠르게 컴파일하지만, 코어를 두 배로 늘려도 누구의 컴파일도 빨라지지 않습니다.

## 5. 분석

### 5.1 두 개의 벽, 각각 적합

각 구성은 실패 신호(§2.3)로 분류되며, 그런 다음 메모리 벽과 CPU 상한은 실제로 그 벽에 부딪힌 구성에 대해서만 적합됩니다:

$$
N_{\max} = \min\left(A R^{p},\; k_c C\right)
$$

여기서 $R$은 기비바이트 단위, $C$는 vCPU 단위입니다.

메모리 벽 지수는 일관되게 초선형($p>1$)입니다. 동시 컴파일 하나를 추가하는 데 드는 한계 메모리 비용은 전체 메모리가 커질수록 \_감소\_하며, 3 GiB 게스트에서 컴파일당 약 312 MiB였던 것이 32 GiB 게스트에서는 약 194 MiB가 됩니다. 그 메커니즘은 §2.1에서 설명한 TeX Live 트리에 대한 공유 페이지 캐시입니다. 동시 컴파일은 겹치는 글꼴과 매크로 파일을 읽으므로, 더 큰 캐시는 더 많은 컴파일에 걸쳐 분할 상환됩니다. 이 때문에 "사용자 5명당 1GB"라는 단순한 규칙은 대형 머신을 과소 예측하고 소형 머신을 과대 예측합니다.

<Frame>
  <img src="https://mintcdn.com/ayakaleaf-pro/x9kfDjtWlyyhG_mR/images/blog/fig3_3d.png?fit=max&auto=format&n=x9kfDjtWlyyhG_mR&q=85&s=ba01a4cb43c6a05b1dca0742971bb539" alt="" width="2868" height="1298" data-path="images/blog/fig3_3d.png" />
</Frame>

<strong>그림 9.</strong> 같은 데이터를 $(C,R)$ 평면 위의 두 곡면으로 나타냈습니다. (a) 용량: 적합 곡면은 평면이 아니라 능선입니다. 메모리에 따라 가파르게 올라가고, 메모리가 병목이 아니게 될 때까지 코어 축을 따라서는 거의 평탄합니다. 그래서 48 GiB 행만이 코어 수로 구성이 구분되는 유일한 행입니다. (b) 모든 구성에 대한 동시성별 지연 시간으로, 적합된 $T=12.6\,(N/C)^{0.91}$을 파선으로, 180초 타임아웃을 평면으로 그렸습니다. 구성은 실선 곡선이 그 평면을 뚫는 지점에서 실패하며, 이를 통해 타임아웃 설정이 보고되는 용량을 얼마나 직접적으로 결정하는지 볼 수 있습니다.

### 5.2 코어가 많을수록 오히려 나빠지는 경우

식 (2)는 두 항의 최솟값이므로 $C$에 대해 단조적이지만, 측정값은 그렇지 않습니다. 코어를 추가했을 때 용량이 *감소한* 두 번의 역전을 관찰했습니다. 16 GiB(54 대 45)와 32 GiB(145 대 135)입니다. 두 경우 모두 메모리에 묶인 영역에서 발생하며, 메커니즘도 같습니다. 코어가 많으면 동시 컴파일이 보조를 맞춰 진행되어 같은 순간에 최대 상주 크기에 도달하는 반면, 코어가 적으면 스케줄러가 이들을 번갈아 실행하여 정점이 엇갈립니다. 메모리 여유가 이미 빠듯한 게스트에서는 이 엇갈림이 게스트를 살려 둡니다. 평균 자원 사용량에 기반한 용량 모델은 이를 표현할 수 없습니다. 이는 정점의 \_동시 발생\_이라는 속성입니다.

2 GiB에서 나타난 세 번째 겉보기 역전은 이제 무시합니다. 탐색은 2 vCPU에서 용량 2, 4 및 8 vCPU에서 용량 1을 기록했는데, 이는 같은 효과처럼 읽힙니다. 원시 스윕을 다시 검토하면 더 단순한 사실이 드러납니다. 2 GiB에서 수준 $N=2$는 세 가지 코어 수 모두에서 첫 시도에 성공했고, 그중 두 개에서 확인 실행에 실패했습니다. 이 수준은 용량이 아니라 동전 던지기이며, 2 vCPU 항목은 우연히 그렇게 떨어진 결과일 뿐입니다. 따라서 세 가지 코어 수 모두에서 재현 가능한 값인 1을 보고하고 그 차이로부터 어떤 결론도 내리지 않습니다. 표를 조용히 수정하는 대신 여기에 보정을 기록하는 이유는, 버려진 판독값이 바로 흥미로운 주장을 뒷받침했을 법한 종류이기 때문입니다.

## 6. 구현상의 발견

### 6.1 하드코딩된 동시성 상한

충분히 큰 게스트에서는 요청된 동시성과 관계없이 용량이 정확히 65개의 동시 컴파일에서 멈췄습니다. $N=66,80,96,128$에서 $65$개의 성공과 $1,15,31,63$개의 즉각적인 `unavailable` 응답을 측정했으며, 컨테이너 수는 65로 고정되고, 수 기가바이트의 메모리가 사용되지 않았으며, 중앙 컴파일 시간은 어떤 타임아웃보다도 훨씬 짧은 77초로 일정했습니다.

원인은 CLSI의 상수입니다:

```javascript theme={null}
// services/clsi/config/settings.defaults.cjs:110
compileConcurrencyLimit: isSpotInstance ? 32 : 64,

// services/clsi/app/js/LockManager.js
if (LOCKS.size <= Settings.compileConcurrencyLimit) return   // <= admits 64+1
throw new Errors.TooManyCompileRequestsError(...)
```

비교가 엄격하지 않으므로(non-strict) 실제 상한은 $64+1=65$이며, 측정값과 정확히 일치합니다. 초과 요청은 **HTTP 503**을 받습니다. 대기열에 들어가는 것이 아니라 \_거부\_되므로, 사용자 입장에서는 컴파일 버튼이 그냥 실패합니다. 같은 파일의 다른 모든 조정 가능한 값과 달리 이 값은 어떤 환경 변수도 읽지 않습니다. 2024년 8월 업스트림에서 도입되었으며 이미지를 수정해야만 변경할 수 있습니다. 상한을 올리자, $N=80$에서 `success=65, unavailable=15`를 보고했던 같은 16 vCPU / 32 GiB 게스트가 `success=80`을 보고했습니다.

### 6.2 작동하지 않는 컨테이너 메모리 제한

실행 중인 컴파일 컨테이너를 검사하면 자원 격리가 전혀 없음을 알 수 있습니다:

```text theme={null}
Memory=0  NanoCpus=0  CpuShares=0  CpuQuota=0  CpusetCpus=[]
Ulimits=[{Name:cpu Soft:305 Hard:310}]
```

CPU 할당량이 없는 것은 의도된 설계이며, §4.2의 시분할 지수가 그토록 깔끔한 이유를 설명합니다. 컴파일 간의 경쟁을 왜곡하는 것이 없기 때문입니다. 그러나 *메모리* 제한이 없는 것은 의도된 것이 아닙니다. Docker 러너는 실제로 제한을 요청합니다:

```javascript theme={null}
// services/clsi/app/js/DockerRunner.mjs:262
Memory: 1024 * 1024 * 1024 * 1024, // 1 Gb
```

이는 두 가지 면에서 잘못되었습니다. 주석은 $1024^3$을 의도했지만 값은 $1024^4=1 tebiB$입니다. 그리고 이 필드는 Docker API가 기대하는 `HostConfig` 안이 아니라 생성 옵션의 최상위 수준에 놓여 있어 버려지며, 관찰된 `Memory=0`이 이를 확인해 줍니다. 두 오류 모두 이 파일을 도입한 커밋(`9a519f0d3d`, 2018년 3월)에 존재했으며, CoffeeScript에서의 변환, 저장소 전체 재포맷, CJS에서 ESM으로의 마이그레이션을 거치면서도 살아남았습니다. 이 중 어느 것도 의미를 다시 검토하지 않았습니다. 특히 같은 커밋의 `MAX_OUTPUT = 1024 * 1024 // 1MB`는 올바르므로, 오해가 아니라 실수임을 알 수 있습니다.

그 결과는 저메모리 측정에서 드러납니다. 컴파일에 제한이 없으므로 메모리 고갈은 Docker가 문제를 일으킨 컨테이너 하나를 종료하는 형태로 나타나지 않고 게스트 전체를 다운시킵니다. 2 vCPU / 2 GiB 구성에서 모니터링 SSH 세션이 300초 동안 멈추고, 코어 두 개에서 부하 평균이 68에 이르며, 결국 게스트가 스스로 재부팅되는 것을 관찰했습니다. 제대로 작동하는 컨테이너별 제한이 있었다면 훨씬 우아하게 성능이 저하되었을 것입니다. 너무 큰 컴파일은 실패하고 서비스는 살아남았을 것입니다.

실제로 효과가 있는 유일한 제한은 $\text{timeout}+5$초로 설정된 `RLIMIT_CPU`입니다. 이는 벽시계 시간이 아니라 *CPU* 시간을 제한하며, 단일 컴파일은 CPU를 약 9초만 소비하므로 어떤 동시성에서도 걸리지 않습니다. 이는 폭주하는 매크로와 같은 병적인 입력을 막기 위한 것입니다. 그러나 유용한 지표 역할을 합니다. `Soft:305`를 관찰하면 300초 타임아웃 설정이 실제로 컨테이너까지 전파되었음을 확인할 수 있습니다.

### 6.3 컴파일 타임아웃이 가장 중요한 조정 값이다

사용자별 필드 `features.compileTimeout`의 기본값은 180초입니다. CPU에 묶인 구성에서 이는 안전 여유가 아니라 용량 설정입니다. 여전히 올바르게 계산 중인 머신이 실패로 선언되기 때문입니다. MongoDB 업데이트 한 번으로 이를 300초로 늘리면 측정 용량이 최대 4.2배 변합니다(표 3). 상한은 `RequestParser.MAX_TIMEOUT`이 강제하는 600초이며, 그 이상의 값은 조용히 잘립니다.

| 구성 | 180 s | 300 s | 비율 | 병목 제약 |
| - | -: | -: | -: | - |
| 8 vCPU / 48 GiB | 64 | 268 | $4.19\times$ | CPU, 28 GiB 여유 |
| 4 vCPU / 32 GiB | 47 | 145 | $3.09\times$ | CPU, 30 GiB 여유 |
| 4 vCPU / 48 GiB | 63 | 143 | $2.27\times$ | CPU |
| 4 vCPU / 16 GiB | 31 | 54 | $1.74\times$ | CPU |
| 2 vCPU / 8 GiB | 15 | 21 | $1.40\times$ | CPU |
| 8 vCPU / 32 GiB | 127 | 135 | $1.06\times$ | 다음은 메모리 벽 |
| 8 vCPU / 16 GiB | 56 | 45 | $0.80\times$ | 메모리 |
| 16 vCPU / 32 GiB | 159 | 141 | $0.89\times$ | 메모리 |

<strong>표 3.</strong> 컴파일 타임아웃이 측정 용량에 미치는 영향.

마지막 두 행은 결과의 직관에 반하는 절반이며, 우리가 모든 구성을 하나의 타임아웃으로 다시 측정한 이유입니다. \_메모리\_에 묶인 구성에서는 타임아웃이 길수록 용량이 \_감소\_합니다. 각 컴파일이 상주 집합을 더 오래 유지하여 더 많은 컴파일이 겹치기 때문입니다. 따라서 용량 수치는 측정 시의 타임아웃을 명시하지 않으면 의미가 없으며, 두 가지를 하나의 표 안에서 섞을 수 없습니다.

## 7. 관련 연구

### 7.1 공급업체 지침

Overleaf의 자체 하드웨어 문서는 여기서 우리가 정량화하는 정성적 사실을 언급합니다. LaTeX는 단일 스레드이고, 따라서 단일 코어 성능이 컴파일 시간을 좌우하며, "더 많은 코어는 여유 CPU 코어보다 많은 문서를 컴파일하려 할 때만 도움이 된다"는 것입니다 \[1]. 이어서 이 연구의 동기가 된 선형 규모 산정 규칙, 즉 기본 2코어/3 GiB에 동시 사용자 5\~10명당 코어 1개와 1GB를 더하는 규칙을 제시합니다. 우리의 기여는 이러한 진술을 측정된 법칙(식 (1)과 (2))으로 바꾸고, 선형 규칙이 어디서 깨지는지 보이는 것입니다. 이 규칙에는 메모리 벽을 초선형으로 만드는 공유 페이지 캐시에 대한 항도, 결과를 좌우하는 두 소프트웨어 매개변수에 대한 항도 없습니다.

### 7.2 빌드 및 CI 용량 연구

동시성 하에서 빌드 시스템을 측정하는 것은 LaTeX 외의 분야에서 잘 확립되어 있습니다. LightSys는 Docker 컨테이너 안에서 컴파일하는 기존 CI 시스템이 pull request 도착률이 높아질수록 I/O 성능이 저하되며, 약 11개의 동시 요청에서 병목이 나타난다고 보고합니다 \[17]. TAOS-CI는 컴파일이 CI 벽시계 시간을 지배하여 대형 프로젝트에서 전체 파이프라인 시간의 60–67%를 차지한다고 관찰합니다 \[18]. 우리 시스템은 결정적인 것으로 드러난 한 가지 측면에서 다릅니다. LaTeX 컴파일은 대화형입니다. 두 배 오래 걸리는 CI 작업은 불편함에 그치지만, 두 배 오래 걸리는 컴파일은 미리보기 창 앞에서 기다리는 사용자가 직접 체감합니다. 그래서 우리는 타임아웃을 실패 임계값이 아니라 용량 매개변수로 취급합니다.

### 7.3 컨테이너 오버헤드

최근 연구는 Docker 컨테이너 시작 지연 시간을 스토리지 계층별로 분해하고 \[19], 엣지에서의 컨테이너 성능을 특성화합니다 \[20]. 우리 환경에서는 컴파일별 컨테이너 시작이 분할 상환됩니다. 9초 컴파일에 비해 작은 상수이며, 우리가 적합한 자유 비행 시간 $T_1$이 이를 흡수합니다. 중요한 컨테이너 속성은 자원 제한의 *부재*(§6.2)로, 이는 컴파일별 메모리 초과를 호스트 전체의 장애로 바꿉니다.

### 7.4 신뢰할 수 없는 입력으로서의 LaTeX

샌드박스 컴파일이 존재하는 이유는 TeX가 프로그래밍 언어이고 문서가 신뢰할 수 없는 입력이기 때문입니다 \[21, 22]. 이 설계 선택 덕분에 이 연구가 가능했으며(각 컴파일은 관찰 가능한 자원 동작을 가진 격리된 컨테이너임), 동시에 메모리 제한의 누락이 중대한 결과를 낳는 이유이기도 합니다. 이를 배포하는 운영자는 격리를 전제하기 때문입니다.

### 7.5 연구 대상으로서의 컴파일러

TeX 자체는 언어로서 잘 문서화되어 있지만 \[16], \_빌드 대상\_으로서의 동작은 최근에야 주목받기 시작했습니다. Tan과 Rigger \[8]는 대규모 arXiv 소스 코퍼스를 여러 엔진과 배포판 버전에 걸쳐 컴파일하여, 엔진 선택이 대체 가능하지 않다는 것을 발견했습니다. XeTeX와 pdfTeX에서 바이트 단위로 동일한 출력을 생성하는 문서는 1% 미만에 불과합니다. 이 결과는 우리의 방법론과 직접 관련됩니다. 용량은 문서 *그리고* 엔진의 속성이므로, 둘 다 고정하지 않은 벤치마크는 재현할 수 없습니다. 따라서 우리는 전체에 걸쳐 하나의 문서, 하나의 엔진, 하나의 배포판(`texlive-full:2025.1`)을 고정하고, 모든 그림 캡션에 엔진을 기재합니다. 또한 이는 우리 수치의 일반성을 제한하며, 이를 분명히 밝혀 둘 가치가 있습니다. 우리 수치는 추상적인 TeX가 아니라 이 문서에 대한 XeLaTeX를 특성화합니다.

LaTeX 빌드 \_시스템\_에 관한 작업은 대부분 실무자 주도로 이루어집니다. LaTeX3 프로젝트의 `l3build` \[13]는 회귀 테스트와 패키징을 표준화하며, 독립적인 벤치마크들은 래퍼 도구를 비교합니다. 26개 빌드 시스템에 대한 조사에서는 사전 컴파일된 프리앰블이 일반 실행 대비 약 20%, `latexmk` 대비 약 40%의 이점을 준다고 밝혔습니다 \[14]. 이들은 *단일* 컴파일을 최적화합니다. 이는 우리가 측정하는 것과 직교하며 함께 결합됩니다. 프리앰블 캐시는 $T_1$을 단축하고, 이 논문의 모든 용량 수치는 $T_1$에 비례하여 확장됩니다.

### 7.6 컴파일러가 아닌 편집기에서의 동시성 제어

Overleaf의 협업 측면은 잘 확립된 연구 계보에 기반합니다. 운영 변환(Operational transformation)은 Ellis와 Gibbs \[9]에서 비롯되었으며, Jupiter 시스템 \[10]에 의해 고지연 클라이언트에서도 실용화되었습니다. 그 설계는 `document-updater`에서 알아볼 수 있습니다. 연산의 순서를 정하는 서버와 클라이언트가 동기화하는 문서별 버퍼입니다. 충돌 없는 복제 데이터 타입(CRDT) \[11]은 중앙 시퀀서 없이 같은 문제를 해결합니다. 이 구분이 §4.3의 토폴로지를 애초에 가능하게 합니다. 대기 중인 업데이트 버퍼가 인스턴스의 메모리가 아니라 공유 Redis에 있으므로, 어느 복제본으로 라우팅된 컴파일이든 최신 키 입력을 볼 수 있으며, 컴파일 어피니티는 정확성이 아니라 캐시 지역성을 위해 선택할 수 있습니다.

### 7.7 용량 모델

암달의 법칙 \[24]은 병렬화로 인한 속도 향상의 한계를 정하고, 리틀의 법칙 \[23]은 점유율을 도착률 및 서비스 시간과 연관 짓습니다. 두 법칙 모두 위에서 사용했습니다. Gunther의 보편적 확장성 법칙 \[12]은 첫 번째 법칙에 일관성 지연에 대한 역행 항을 추가하여, 처리량이 정점에 도달한 후 감소한다고 예측합니다. 우리 시스템은 $N=1024$까지 그런 역행 영역을 보이지 *않습니다*. 처리량은 포화되고 지연 시간은 증가하지만 아무것도 붕괴하지 않습니다. 그 이유는 운이 아니라 구조적인 것입니다. 컴파일은 일관성을 유지해야 할 상태를 공유하지 않으므로 이 법칙이 추가하는 항은 0에 가까우며, §4.3의 수용 정체 구간이 경합이 문제가 되기 전에 이를 제한합니다.

## 8. 운영자를 위한 권장 사항

<Steps>
  <Step title="하드웨어를 구매하기 전에 두 소프트웨어 매개변수를 고치세요">
    둘 다 무료이며, 둘 다 우리가 측정한 어떤 단일 하드웨어 업그레이드보다 가치가 큽니다. `features.compileTimeout`을 사용자가 실제로 감내할 수 있는 값으로 늘리고(CLSI가 허용하는 최댓값은 600초), 65개를 초과하는 동시 컴파일이 예상된다면 파생 이미지에서 `compileConcurrencyLimit`을 해제하거나 수평 확장하세요. 둘 다 하지 않으면 소프트웨어가 사용을 거부하는 코어에 비용을 지불하게 됩니다.
  </Step>

  <Step title="머신 하나의 규모는 상한이 아니라 무릎으로 산정하세요">
    대형 러너 스윕(§4.3)은 흔히 혼동되는 두 수치를 분리합니다. *상한*, 즉 여전히 모든 PDF를 반환하는 최대 동시성은 64코어 서버에서 최소 1024이며, 우리는 끝내 도달하지 못했습니다. *무릎*, 즉 꼬리 지연 시간이 완만한 증가를 멈추고 두 배로 늘어나기 시작하는 지점은 512이며, 그 아래의 마지막 쾌적한 운영 지점은 256입니다. $N=256$과 $N=512$ 사이에서 $p_{95}$ 대기 시간은 2분에서 거의 6분으로 늘어나고, 512와 1024 사이에서는 8분에 이릅니다. 상한에 맞춰 규모를 산정하는 운영자는 기술적으로는 동작하지만 아무도 쓰고 싶어 하지 않는 시스템을 내놓게 됩니다.

    따라서 이 머신과 이 문서에 대한 권장 운영 지점은 **동시 컴파일 256개**입니다. 이는 물리 코어 수의 $4\times$, 스레드 수의 $2\times$이며, $p_{95}$를 120초 근처로 유지합니다. `compileConcurrencyLimit`을 높게 두기보다는 이 값으로 설정할 것을 제안합니다. 1024개의 컴파일을 한꺼번에 받아들이면 모두가 8분을 기다리지만, 256개를 받아들이고 나머지를 대기열에 넣으면 대부분의 사용자가 2분 안에 처리됩니다. 대기열은 늦게 도착한 사용자의 경험을 떨어뜨리지만, 경합은 모두의 경험을 떨어뜨립니다.
  </Step>

  <Step title="이 수치를 최악의 경우로 간주하세요">
    표 2의 모든 수준은 동시에 발사된 콜드 컴파일입니다. 프로덕션에서는 두 조건 모두 성립하지 않습니다. 같은 문서의 워밍 컴파일은 콜드 28.3초 대비 8.6초로 $3.3$배 빠르며, 실제 사용자는 같은 초에 버튼을 누르지 않습니다. 따라서 일반적인 캐시 적중률로 2분마다 다시 컴파일하는 정상 상태의 사용자 집단은 동시성 수치만으로 짐작하는 것보다 훨씬 많은 작성자를 감당할 수 있습니다. 운영 지점 256에서 대략 천 명 이상의 활성 작성자 수준입니다. 동시성 수치는 순간 버스트에 대한 한계이지 좌석 수가 아닙니다.
  </Step>

  <Step title="먼저 지연 시간 예산을 정한 다음 규모를 읽어 내세요">
    식 (1)은 바로 역산할 수 있습니다. $C$개 코어, 클록 $f$에서 목표 대기 시간 $T$에 맞는 동시성은 $N \le C\,fT/k$이며, 이 문서의 경우 $k\approx28 GHz·s$입니다. 3 GHz의 8코어에서 60초 예산은 $N\le51$을 주고, 120초 예산은 그 두 배입니다. 용량과 함께 예산을 공개하는 것만이 둘 중 어느 하나라도 정직하게 기술하는 유일한 방법입니다.
  </Step>

  <Step title="메모리를 먼저, 그다음 코어를 구매하고, 어느 벽에 있는지 확인하세요">
    32 GiB 미만에서는 코어 추가로 인한 이점이 거의 측정되지 않았습니다. 진단은 간단합니다. 게스트의 메모리가 부족한 상태에서 실패가 HTTP 502로 나타나면 메모리를 추가하고, 메모리가 남은 상태에서 `timedout`으로 나타나면 코어를 추가하거나 타임아웃을 늘리세요. 운영자는 우리가 구성을 분류하는 데 사용한 것과 같은 실패 신호로 이를 판단할 수 있습니다.
  </Step>

  <Step title="경험을 위해서는 클록을, 사용자 규모를 위해서는 코어를 선택하세요">
    $T\propto 1/f$가 곡률 없이 성립하므로(1.0–5.5 GHz에서 2%), 더 빠른 클록은 모든 사용자의 모든 컴파일을 더 빠르게 만듭니다. 더 많은 코어는 개별 컴파일을 빠르게 하지 않으며, 더 많은 동시 컴파일을 받아들일 뿐입니다. "컴파일이 느리다"는 불만이 있는 배포는 클록을 구매해야 하고, "마감 때 컴파일이 실패한다"는 불만이 있는 배포는 메모리와 코어를 구매해야 합니다.
  </Step>

  <Step title="상한을 넘어서면 수직 확장 대신 수평 확장하세요">
    65개를 초과하는 동시 컴파일에 대해 지원되는 경로는 수평 확장입니다(그림 2c와 10, §9에서 자세히 설명). 쿠키 세션 어피니티를 갖춘 로드 밸런서 뒤에 여러 애플리케이션 인스턴스를 두고, 중앙 MongoDB, Redis, S3 호환 스토리지를 공유하며, `git-bridge`는 단일 인스턴스로 둡니다. 이렇게 하면 인스턴스별 상한에 인스턴스 수를 곱하게 되며, 이는 정확히 SaaS 배포가 자체 용량에 도달하는 방식입니다.
  </Step>

  <Step title="컴파일별 격리에 의존하지 마세요">
    Docker 러너의 메모리 제한이 수정되기 전까지는(§6.2), 병적인 문서 하나가 혼자 종료되는 대신 호스트를 고갈시킬 수 있습니다. 그런 보장이 필요한 운영자는 수정을 기다리기보다 직접 강제해야 합니다. 우리가 대형 호스트에서 사용한 메커니즘은 하드 상한을 가진 systemd 슬라이스이며, Docker 데몬이 이 슬라이스를 가리키도록 하여 데몬이 생성하는 모든 컨테이너가 그 안에서 집계되도록 합니다:

    ```ini theme={null}
    [Slice]
    MemoryMax=940G
    ```

    여기서 한 가지 세부 사항을 놓치면 오후 한나절을 허비하게 됩니다. `docker-capped.slice`라는 이름의 슬라이스는 `docker.slice` 옆이 아니라 그 *안에* 위치합니다. 하이픈은 이름의 일부가 아니라 계층 구분자이기 때문입니다. 효과가 없어 보이는 상한은 대개 컨테이너가 실제로 있는 곳에서 한 단계 떨어진 곳에 적용된 것입니다. 설정 파일을 믿기보다는 실행 후 `memory.max_usage_in_bytes`에서 정점 값을 다시 읽어 확인하세요. 우리 호스트에서는 1024개의 동시 컴파일에서도 컴파일 cgroup이 상한의 5분의 1을 넘지 않았으며, 이것 자체가 메모리가 아니라 데몬이 병목 제약이었다는 증거입니다.
  </Step>
</Steps>

<Frame>
  <img src="https://mintcdn.com/ayakaleaf-pro/x9kfDjtWlyyhG_mR/images/blog/arch_ha.png?fit=max&auto=format&n=x9kfDjtWlyyhG_mR&q=85&s=fe6e39991948cf607ea85bfedf8daac8" alt="" width="1309" height="1159" data-path="images/blog/arch_ha.png" />
</Frame>

<strong>그림 10.</strong> 우리가 검증한 구성에서 도출한, 수평 확장 배포의 참조 토폴로지. 애플리케이션 복제본은 서로 교체 가능하며 영속적인 것을 보유하지 않으므로 자유롭게 추가하고 제거할 수 있습니다. 그렇지 않은 구성 요소가 세 가지 있습니다. Redis는 그 문서 버퍼 덕분에 어느 복제본으로 라우팅된 컴파일이든 최신 키 입력을 볼 수 있습니다. 객체 저장소는 복제본이 둘 이상이면 선택이 아니라 필수가 됩니다. 그리고 `git-bridge`는 복제 경로 없이 저장소를 로컬 디스크에 보관하므로, 지정된 복제본 하나 옆에서 단일 인스턴스로 실행되어야 합니다.

## 9. 참조용 다중 머신 배포

위의 모든 내용은 머신 하나를 측정한 것입니다. 이 섹션에서는 분산 형태를 구축할 수 있을 만큼 자세히 기술하며, 운영자가 실제로 직면하는 질문은 \_어떻게\_가 아니라 \_해야 하는가\_이므로, 먼저 그것이 수고할 가치가 생기는 지점을 밝힙니다.

### 9.1 분산 형태가 정당화되는 경우

머신 하나는 중요한 모든 측면에서 운영 비용이 더 저렴합니다. 장애 도메인이 하나이고, 일관성을 유지해야 할 공유 상태가 없으며, 잘못될 라우팅도 없습니다. 우리의 데이터는 언제 단일 머신을 떠나야 하는지에 대해 세 가지 임계값을 제시합니다.

#### 9.1.1 동시 컴파일 65개 미만이라면 하지 마세요

인스턴스별 상한은 하드웨어가 아니라 소프트웨어 상수입니다(§6.1). 제시된 부하가 이에 근접하기 전까지 두 번째 머신은 장애 모드만 늘릴 뿐 아무것도 얻지 못합니다. 64코어 호스트는 `compileConcurrencyLimit`을 해제한 후에야 256개의 동시 컴파일을 완전히 성공적으로 처리했습니다. 아직 그 값 하나도 바꾸지 않은 운영자는 하드웨어에 묶인 것이 아니며 하드웨어를 쇼핑해서는 안 됩니다.

#### 9.1.2 65개에서 약 500개 사이라면 먼저 수직 확장하세요

수직 확장은 우리가 측정한 전체 범위에서 선형을 유지했고 역행 영역에 들어서지 않았습니다. 대형 호스트 하나가 1024개의 동시 콜드 컴파일을 100% 성공률로 처리했으며(§4.3), 지연 시간의 무릎은 512에서야 나타났습니다. 이 범위 안에서는 큰 머신 하나가 작은 머신 여러 대보다 확실히 단순하며, §4.4에 따르면 더 빠른 머신은 단지 더 많은 사용자를 받아들이는 것이 아니라 모든 사용자의 경험을 개선합니다.

#### 9.1.3 처리량이 아니라 가용성을 위해 분산하세요

상한 아래에서 둘 이상의 애플리케이션 복제본을 운영하는 정직한 이유는 머신 하나가 전원 공급 장치 하나, 커널 하나, 업그레이드 창 하나이기 때문입니다. 이는 정당한 이유이며 우리도 그렇게 말할 것입니다. 다만 용량에 관한 논거가 아닐 뿐이며, 둘을 혼동하면 운영자는 메모리가 필요할 때 복제본을 구매하게 됩니다.

### 9.2 계층과 규모 산정

그림 10은 토폴로지를 보여 줍니다. 네 개의 계층이 있으며, 각 계층은 서로 다른 양에 따라 확장됩니다. 이것이 계층을 분리하는 핵심 이유입니다.

#### 9.2.1 엣지

로드 밸런서 하나, 또는 가용성을 위해 둘입니다. TLS를 종료하며 비용이 큰 작업은 하지 않습니다. 컴파일 수가 아니라 연결 수에 따라 확장되며, 여기서 연구한 부하에는 작은 인스턴스로 충분합니다. 중요한 것은 크기가 아니라 설정입니다(§9.3).

#### 9.2.2 애플리케이션 복제본

이 계층이 컴파일 부하를 담당하며, 동시성에 따라 확장되는 유일한 계층입니다. 각 복제본의 규모는 §8의 규칙(코어보다 메모리 먼저, 그다음 클록)에 따라 산정한 다음, 복제본 수를 최대 동시성을 복제본별 상한으로 나눈 값을 감당하도록 설정하세요. 복제본은 영속적인 것을 보유하지 않습니다. 로컬 디스크에는 컴파일 스크래치와 출력 캐시가 있으며, 둘 다 재구성할 수 있습니다. 이것이 복제본을 자유롭게 추가하고 제거해도 안전한 이유이며, 가정하기보다 검증할 가치가 있습니다. 잘못 설정된 `filestore` 경로 하나가 이 계층을 조용히 상태 저장 계층으로 바꿔 버리기 때문입니다.

#### 9.2.3 상태

Redis, MongoDB, S3 호환 객체 저장소를 별도의 호스트에 둡니다. 이 중 가장 중요하면서도 가장 눈에 띄지 않는 것은 Redis입니다. Redis는 세션 저장소와 실시간 문서 버퍼를 보유하며, 이 덕분에 어느 복제본으로 라우팅된 컴파일이든 다른 복제본에서 입력된 키 입력을 볼 수 있습니다. Redis를 캐시로 취급하고 퇴출(eviction)을 전제로 규모를 산정하는 운영자는 오래된 문서를 컴파일하는 결과를 낳게 되며, 이는 진단하기가 매우 어렵습니다. 아무것도 실패하지 않고 출력이 단지 틀릴 뿐이기 때문입니다. MongoDB는 컴파일 속도가 아니라 프로젝트 수에 따라 확장됩니다. 객체 저장소는 복제본이 하나일 때는 선택 사항이지만 그 이상에서는 필수입니다.

#### 9.2.4 단일 인스턴스

`git-bridge`는 저장소를 로컬 디스크에 보관하고, 로컬 인덱스를 유지하며, 복제 경로가 없습니다. 정확히 하나의 인스턴스로, 지정된 복제본 하나 옆에 고정하여 실행해야 하며, 배포를 완전한 무상태가 아니게 만드는 구성 요소입니다. 그에 맞게 호스트를 계획하세요. 백업이 필요한 디스크는 이 디스크입니다.

| **계층** | **개수** | **확장 기준** |
| - | - | - |
| 로드 밸런서 | 1–2 | 동시 연결 수 |
| 애플리케이션 | $\lceil N/C\rceil$ | 최대 동시 컴파일 수 |
| Redis | 1 (+복제본) | 활성 편집 세션 수 |
| MongoDB | 1 (+복제본) | 저장된 프로젝트 수 |
| 객체 저장소 | 클러스터 1개 | 전체 프로젝트 바이트 수 |
| `git-bridge` | 정확히 1개 | 디스크의 저장소 수 |

<strong>표 4.</strong> 참조 계층. 애플리케이션 계층만 동시성에 따라 확장되며, 그 규모 산정이 §8의 주제입니다.

### 9.3 라우팅은 틀리기 쉬운 부분이다

세 종류의 요청이 서로 다른 세 곳에 도달해야 하며, 기본 단일 규칙 설정은 그중 최대 두 가지만 충족합니다.

`/project/` 아래의 컴파일 트래픽은 프로젝트 식별자에 대한 일관된 해싱으로 분산해야 프로젝트의 컴파일 캐시가 한 복제본에 머뭅니다. 우리는 HAProxy의 `balance hash path,field(3,/)`를 `hash-type consistent` 및 `hash-balance-factor 150`과 함께 사용합니다. 이 선택은 수평 확장 시 중요합니다. 쿠키 어피니티를 사용하면 기존 세션은 무기한 원래 복제본에 고정되고 새로 추가된 복제본은 새 사용자만 받으므로, 운영자가 방금 비용을 지불한 머신이 그 구매의 동기가 된 부하를 전혀 흡수하지 못합니다. 우리 구성에서 일관된 해싱은 수평 확장 시 프로젝트의 35%를 재분배했으며, 쿠키의 경우 0%였습니다.

세션 트래픽은 다릅니다. WebSocket 업그레이드가 실패하고 `socket.io`가 XHR 폴링으로 대체되면, 한 세션의 연속된 폴링은 하나의 복제본에 도달해야 하는데 경로에 해싱할 프로젝트 식별자가 없습니다. 이 트래픽에는 쿠키 어피니티를 갖춘 별도의 백엔드가 필요합니다. 우리는 이 분리를 설계했지만 배포하지는 않았습니다. 해결했다고 주장하기보다 미비점으로 표시해 둡니다.

마지막으로 `/git/`은 `git-bridge`가 옆에서 실행되는 복제본에 도달해야 합니다. `git-bridge`로 직접 보내지 않고 해당 복제본으로 라우팅하는 이유는, 브리지가 애플리케이션의 OAuth 엔드포인트로 콜백을 인증하고 애플리케이션을 통해 blob URL을 확인하기 때문입니다. 복제본을 우회하면 아무것도 개선되지 않고 인증만 깨집니다.

### 9.4 축소에는 드레인 버퍼가 필요하다

복제본을 제거하는 것은 추가하는 것과 대칭적이지 않습니다. 진행 중인 컴파일은 손실되고, 사용자는 자신이 일으키지 않은 실패를 보게 됩니다. 실행 가능한 순서는 먼저 새 트래픽을 막고, 기다린 다음, 그제야 종료하는 것입니다. 우리는 이를 pre-stop 훅으로 구현하여, 밸런서가 백엔드를 드레인 중으로 표시하는 동안 설정 가능한 간격만큼 파드를 유지하도록 했습니다. 테스트에서는 몇 분 안에 확인할 수 있을 만큼 짧게, 프로덕션에서는 세션이 자연스럽게 끝날 만큼 길게(초가 아니라 시간 단위로) 설정합니다. 이 간격이 탄력성이 눈에 띄지 않을지, 아니면 사용자를 화나게 할지를 결정하는 조정 손잡이입니다.

설계가 아니라 측정으로 발견한 제약이 하나 더 있습니다. CPU 기반 자동 확장은 이 워크로드에서 동작하지 않습니다. 애플리케이션 파드 자체의 사용률은 노드 전체 3997m 코어 대비 22m 코어에 머물렀습니다. 컴파일 작업이 파드가 집계하지 않는 형제 컨테이너에서 일어나기 때문입니다. 이 계층의 확장에 사용하는 신호는 파드 CPU가 아니라 실행 중인 컴파일 컨테이너 수를 세야 합니다.

## 10. Overleaf를 넘어서는 함의

§4.2나 §4.3의 어떤 내용도 Overleaf의 코드에 특정되지 않습니다. 측정된 법칙은 모든 호스팅형 LaTeX 서비스가 공유하는 세 가지 속성에서 비롯됩니다. 작업 단위가 단일 스레드 프로세스이고, 컨테이너에 격리되며, 작업 집합이 페이지 캐시가 보유해야 하는 큰 읽기 전용 트리라는 점입니다. 이러한 서비스를 구축하는 누구에게나 세 가지 귀결이 그대로 적용됩니다.

### 10.1 메모리를 먼저, 그다음 코어를 프로비저닝하라

매트릭스의 가장 강력한 결과는 부정적인 것입니다. 16 GiB 미만에서는 코어 수가 거의 무관하며, 48 GiB에서야 4, 8, 16 vCPU 구성이 비로소 구분됩니다(143, 268, 331). 관례적인 규칙을 "사용자 5명당 코어 하나 추가"로 읽는 운영자는 잘못된 자원을 구매합니다. 메커니즘은 배포판 트리에 대한 공유 페이지 캐시이며, 이는 특정 프런트엔드가 아니라 TeX Live의 크기에서 비롯된 속성입니다.

### 10.2 수용 속도도 자원이며, 대개 잊힌다

$N=1024$에서 모든 요청이 한꺼번에 도착했음에도 우리 서버는 205개를 초과하는 살아 있는 샌드박스를 보유한 적이 없습니다(그림 7b). 제한 요인은 컴파일이 아니라 컨테이너 생성이었습니다. 이는 컨테이너 시작 비용을 이미지 크기가 아닌 런타임 오버헤드에 귀속시키는 측정 연구들과 일치합니다 \[19, 20]. CPU와 메모리만으로 규모를 산정하는 서비스는 버스트 동작이 측정해 본 적 없는 양에 의해 좌우된다는 것을 알게 될 것입니다. 이를 실무적으로 표현한 것이 §8의 권장 사항입니다. 의도적으로 수용을 제한하세요. 직접 선택한 대기열이 나중에 발견하는 대기열보다 낫습니다.

### 10.3 컴파일보다 오래 사는 샌드박스는 모델을 무효화한다

여기의 모든 용량 수치는 컨테이너가 생성되어 컴파일 한 번을 수행하고 종료된다고 가정합니다. 수명은 수십 초이며, 실행되는 동안에만 가동률이 1에 가깝습니다. 최근의 두 가지 설계 패턴이 이 가정을 깨며, 같은 방식으로 깹니다.

첫 번째는 사용자별 영구 샌드박스입니다. 각 사용자에게 고정된 개인 환경을 할당하면 통계적으로 다중화된 풀이 예약의 집합으로 바뀝니다. 시분할로 64코어에서 256개의 동시 컴파일을 처리할 수 있던 서비스가 각 사용자에게 전용 코어 4개를 주면 16명만 처리할 수 있게 되며, 같은 하드웨어에서 한 자릿수 적은 수치입니다. 우리 데이터는 그 선택에 반대하기보다 비용을 정량화합니다. 예약은 예측 가능성을 사며, 우리가 권장하는 운영 지점에서 교환 비율은 대략 $16\times$입니다.

두 번째이자 더 새로운 패턴은 컴파일러와 샌드박스를 공유하는 AI 에이전트입니다. 에이전트 지원 저작 플랫폼에서는 XeLaTeX를 실행하는 같은 컨테이너가 장시간 실행되는 코딩 에이전트도 호스팅할 수 있어, 컨테이너가 버스트 방식이 아니라 지속적으로 점유됩니다. 실무자들은 이러한 배포에 대해 모델이 예측하는 바로 그 증상, 즉 적은 사용자 수에서도 지속되는 느려짐을 보고합니다 \[15]. 이 상호작용은 단순히 "부하가 더 많다"가 아니므로 정확히 기술할 가치가 있습니다. 우리의 발견 세 가지가 복합적으로 작용합니다. 점유가 버스트 형태가 아니게 되므로 §4.2의 시분할 법칙이 현재 컴파일 중인 일부가 아니라 전체 사용자 집단에 동시에 적용됩니다. §4.1의 초선형 메모리 수익을 가져다주는 페이지 캐시가 이제 에이전트 자체의 작업 집합과 공유되어 TeX를 위해 따뜻한 상태로 유지되지 않습니다. 그리고 §6.2의 누락된 컨테이너 메모리 제한은 훨씬 더 위험해집니다. 종료되지 않는 컨테이너는 메모리를 결코 반환하지 않기 때문입니다.

우리는 그러한 플랫폼을 측정하지 않았으며 특정 제품에 대해 어떤 주장도 하지 않습니다. 우리가 말할 수 있는 것은 우리 수치가 설계에 대해 시사하는 바입니다. 각 사용자에게 수명이 긴 다중 코어 샌드박스를 제공하는 아키텍처는 여기서 보고한 동시성 수치가 아니라 예약 시스템으로서 규모를 산정해야 하며, 기대할 수 있는 용량은 표 1의 어떤 값보다도 코어 수를 사용자당 코어 수로 나눈 값에 더 가깝습니다.

## 11. 타당성에 대한 위협

### 11.1 단일 문서

모든 측정은 63페이지 분량의 XeLaTeX 문서 하나를 사용합니다. 다른 문서에서는 절대 용량이 다를 것이지만, 비율인 확장 법칙은 달라지지 않아야 합니다. 상주 집합이 상당히 큰 문서는 메모리 벽의 초선형 특성을 바꾸지 않은 채 그 위치를 이동시킬 것입니다.

### 11.2 가상화된 호스트

게스트는 물리 머신 하나에서 KVM으로 실행되므로, 절대 수치에는 가상화 오버헤드가 포함되며 게스트들은 호스트 페이지 캐시와 NVMe 장치를 공유합니다. 호스트 메모리 압박이 동일한 동시성에서 게스트 내부 부하 평균을 $3\times$ 이상 부풀린다는 것을 관찰한 후, 관련 없는 게스트를 종료하여 가장 큰 교란 요인을 완화했습니다.

### 11.3 동시 도착

모든 컴파일은 한순간에 발행되며, 이는 최악의 경우입니다. 실제 사용자는 확률적 과정으로 도착하므로 우리 수치로 규모를 산정한 배포에는 부족이 아니라 여유가 있습니다. 하지만 제출 마감 직전의 정점은 포아송 모델보다 우리 모델에 더 가깝습니다.

### 11.4 경계 구성

2 GiB에서는 시스템이 붕괴에 매우 가까워 같은 구성의 반복 실행이 컴파일 하나만큼 차이 날 수 있습니다. 우리는 보수적인 값을 보고하며, 그 영역에서 $\pm 1$의 차이로부터 결론을 내리지 않습니다.

## 12. 가용성

테스트 대상 시스템, 배포 도구, 그리고 그 기반이 된 업스트림 프로젝트는 모두 공개되어 있습니다:

* Ayakaleaf Pro — [https://github.com/ayaka-notes/ayakaleaf-pro](https://github.com/ayaka-notes/ayakaleaf-pro)
* 배포 Toolkit — [https://github.com/ayaka-notes/toolkit](https://github.com/ayaka-notes/toolkit)
* 문서 — [https://ayakaleaf-pro.ayaka.space](/ko)
* 업스트림 Overleaf — [https://github.com/overleaf/overleaf](https://github.com/overleaf/overleaf)
* TeX Live 컴파일 이미지 — `ghcr.io/ayaka-notes/texlive-full:2025.1`

우리가 인용한 모든 소스 위치는 Ayakaleaf Pro v6.2.2 기준의 저장소 상대 경로와 줄 번호로 제시되며, 날짜를 명시한 두 업스트림 커밋(`9a519f0d3d`, `5d472e9b38`)은 Overleaf 기록에서 확인할 수 있습니다.

## 13. 기여

Musicminion은 연구를 설계하고, 테스트베드를 제공 및 운영하며, 조사의 방향을 이끌고, 여기에 보고된 모든 측정을 검증했습니다. Claude Opus 5(Anthropic)는 벤치마크 하네스를 구축 및 운영하고, 배포를 자동화하고, 소스 코드 고고학을 수행하고, 그림을 제작하고, 원고 초안을 작성했습니다. 두 저자 모두 최종 텍스트를 검토했습니다. 오염된 것으로 보고된 실행(§4.3의 1021 세션 스윕과 §4.1의 비정상적인 $N=256$ 수준)의 경우, 결함은 검토 중에 발견되었으며 조용히 제외하는 대신 게시 전에 실행을 반복했습니다.

독자들은 ACM, IEEE, ICMJE의 저자 정책이 현재 저작물에 대한 책임을 질 수 있는 당사자에게만 저자 자격을 부여하며, 두 번째 저자의 기여는 저자 표기가 아니라 공개 사항으로 기록하도록 요구한다는 점에 유의해야 합니다. 어느 관례에서든 기록이 정확하도록 여기에 역할 분담을 명시합니다.

## 14. 결론

자체 호스팅 Overleaf의 용량 계획은 하나의 자원을 확장하는 문제가 아닙니다. 세 가지 발견이 그 방식을 바꿔야 합니다.

첫째, 게스트 메모리 32 GiB 미만에서는 코어 수가 거의 중요하지 않습니다. 16 GiB에서 4, 8, 16 vCPU 게스트의 용량 차이는 8% 미만입니다. TeX Live 트리에 대한 공유 페이지 캐시를 통해 메모리가 한계를 결정하며, 코어는 메모리가 넉넉해진 후에야 중요해지기 시작합니다.

둘째, 두 소프트웨어 매개변수가 하드웨어보다 더 큰 영향을 미칩니다. CLSI의 하드코딩된 65 컴파일 상한을 해제하고 기본 컴파일 타임아웃 180초를 늘리자, 8 vCPU / 48 GiB 게스트가 동시 컴파일 64개에서 268개로 늘어났습니다. 추가 하드웨어 없이 4.2배입니다. 둘 다 설정 문서에서는 찾을 수 없으며, 하나는 아예 설정할 수 없습니다.

셋째, "이 머신이 동시 사용자를 몇 명 지원하는가"라는 질문은 충분히 명시되지 않은 질문입니다. 이 시스템에서 동시성은 순수한 시분할이며, 용량은 타임아웃이 허용하는 만큼입니다. 정직한 형태의 답은 두 가지를 모두 명시합니다. *이 머신은 사용자가* $T$ *초를 기다린다면* $N$ *개의 동시 컴파일을 처리합니다*. 여기서 $N$과 $T$는 식 (1)로 연결됩니다.

또한 잠재적 결함 하나를 보고합니다. Docker 러너의 컨테이너별 메모리 제한은 2018년 이후 크기와 위치 모두에서 효과가 없었습니다. 실질적인 영향은 소규모 배포에서 메모리가 고갈되면 원인이 된 컴파일 하나가 아니라 서비스 전체가 다운된다는 것입니다.

## 참고 문헌

\[1] Overleaf. *Hardware requirements*, 온프레미스 문서. [https://docs.overleaf.com/on-premises/getting-started/requirements/hardware-requirements](https://docs.overleaf.com/on-premises/getting-started/requirements/hardware-requirements)

\[2] Overleaf. *Horizontal scaling*, 온프레미스 문서. [https://docs.overleaf.com/on-premises/maintenance/horizontal-scaling](https://docs.overleaf.com/on-premises/maintenance/horizontal-scaling)

\[3] Overleaf. *Microservices*, 온프레미스 문서. [https://docs.overleaf.com/on-premises/getting-started/microservices](https://docs.overleaf.com/on-premises/getting-started/microservices)

\[4] Overleaf. *Source repository*. [https://github.com/overleaf/overleaf](https://github.com/overleaf/overleaf)

\[5] Ayaka-notes. *Ayakaleaf Pro*. [https://github.com/ayaka-notes/ayakaleaf-pro](https://github.com/ayaka-notes/ayakaleaf-pro)

\[6] Ayaka-notes. *Overleaf Toolkit*. [https://github.com/ayaka-notes/toolkit](https://github.com/ayaka-notes/toolkit)

\[7] D. Karger, E. Lehman, T. Leighton, R. Panigrahy, M. Levine and D. Lewin. *Consistent Hashing and Random Trees: Distributed Caching Protocols for Relieving Hot Spots on the World Wide Web*. STOC, 1997.

\[8] J. Tan and M. Rigger. *Inconsistencies in TeX-Produced Documents*. In *Proc. 33rd ACM SIGSOFT International Symposium on Software Testing and Analysis (ISSTA)*, Vienna, 2024. doi: [https://doi.org/10.1145/3650212.3680370](https://doi.org/10.1145/3650212.3680370)

\[9] C. A. Ellis and S. J. Gibbs. *Concurrency Control in Groupware Systems*. In *Proc. ACM SIGMOD*, pp. 399–407, 1989.

\[10] D. A. Nichols, P. Curtis, M. Dixon and J. Lamping. *High-Latency, Low-Bandwidth Windowing in the Jupiter Collaboration System*. In *Proc. ACM UIST*, pp. 111–120, 1995.

\[11] M. Shapiro, N. Preguiça, C. Baquero and M. Zawirski. *Conflict-Free Replicated Data Types*. In *Proc. SSS*, pp. 386–400, 2011.

\[12] N. J. Gunther. *Guerrilla Capacity Planning: A Tactical Approach to Planning for Highly Scalable Applications and Services*. Springer, 2007.

\[13] The LaTeX3 Project. *l3build — A Testing and Building System for (La)TeX*. CTAN.

\[14] M. Isaksson. *Which LaTeX Build System Is Fastest? A Benchmark*. [https://blog.martisak.se/latex-build-systems-comparison/](https://blog.martisak.se/latex-build-systems-comparison/)

\[15] 사용자별 샌드박스에 영구 코딩 에이전트를 LaTeX 컴파일러와 함께 배치하는 에이전트 지원 저작 플랫폼에서 지속적인 지연이 발생한다는 실무자 보고. 이는 통제된 측정이 아니라 보고된 운영 경험으로 인용하며, 우리는 그러한 플랫폼을 벤치마크하지 않았습니다.

\[16] D. E. Knuth. *The TeXbook*. Addison-Wesley, 1984.

\[17] G. Lim, M. Ham, J. Moon and W. Song. *LightSys: Lightweight and Efficient CI System for Improving Integration Speed of Software*. arXiv:2101.07961 \[cs.SE], 2021. Preprint.

\[18] G. Lim, M. Ham, J. Moon, W. Song, S. Woo and S. Oh. *TAOS-CI: Lightweight & Modular Continuous Integration System for Edge Computing*. arXiv:2101.08889 \[cs.SE], 2021. Preprint.

\[19] S. Khan. *Decomposing Docker Container Startup Performance: A Three-Tier Measurement Study on Heterogeneous Infrastructure*. arXiv:2602.15214, 2026. Preprint.

\[20] R. Gupta and K. Nahrstedt. *Performance Characterization of Containers in Edge Computing*. arXiv:2505.02082, 2025. Preprint.

\[21] S. Checkoway, H. Shacham and E. Rescorla. *Are Text-Only Data Formats Safe? Or, Use This LaTeX Class File to Pwn Your Computer*. In *Proc. USENIX Workshop on Large-Scale Exploits and Emergent Threats (LEET)*, 2010.

\[22] G. Lacombe, K. Masalygina, A. Tahiri, C. Adam and C. Lauradoux. *Can You Accept LaTeX Files from Strangers? Ten Years Later*. arXiv:2102.00856 \[cs.CR], 2021. Preprint.

\[23] J. D. C. Little. *A Proof for the Queuing Formula* $L=\lambda W$. Operations Research, 9(3):383–387, 1961.

\[24] G. M. Amdahl. *Validity of the Single Processor Approach to Achieving Large Scale Computing Capabilities*. AFIPS, 1967.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.