전 세계의 광장을 처음부터 다시 만들다 (X.com)
TMThttps://blog.x.com/en/engineering/rebuilding-the-global-town-square-from-scratch
느린 네트워크에서 X.com의 프로필 하나를 열어 보세요. 예전 웹 앱에서는 첫 게시물이 나타나기까지 22초 넘게 X 로고만 바라보고 있어야 했습니다. 새 웹 앱에서는 2.7초 만에 나타납니다.
새 웹 앱은 사람, 검색 크롤러, 링크 미리보기를 위해 따로 운영하던 서버 세 개를 하나로 대체합니다. 이미 로그아웃 상태의 X.com과 검색 엔진이 크롤링하는 페이지를 이 앱이 처리하고 있습니다. 다음은 로그인한 X.com입니다. 직원과 테스터를 대상으로 lite.x.com에서 테스트를 마쳤고, 지금은 로그인한 사용자를 한 번에 한 국가씩 천천히 옮기고 있습니다.
Similarweb에 따르면 X.com은 세계에서 여섯 번째로 방문자가 많은 웹사이트입니다. 엔지니어 다섯 명이 AI 코딩 에이전트와 함께 일해서 6개월이 채 안 되는 기간에 새 웹 앱의 첫 버전을 만들었습니다. 로그인 페이지를 옮기면서 팀도 커졌고, 9월 말 이후 20명이 넘는 사람이 이 앱에 커밋했습니다.
이 글에서는 X.com을 왜 다시 만들었는지, 국가별로 어떻게 배포하고 있는지, 지금 시점에서 예전 앱과 새 앱을 비교하면 어떤지 설명합니다. 로그아웃 트래픽을 옮긴 뒤 진행한 성능 개선 작업도 다룹니다.
간단한 배경
x.com을 다시 만든 것은 이번이 처음이 아닙니다. 2017년에 저희는 느린 네트워크를 쓰는 사람들을 위해 mobile.twitter.com에서 프로그레시브 웹 앱인 Twitter Lite를 출시했습니다(이 섹션에서 대문자로 쓴 용어는 해당 글에서 정의한 의미를 따릅니다). Twitter Lite는 클라이언트 측 React 애플리케이션이었고, 작은 Node.js 서버가 HTML 애플리케이션 셸을 렌더링했습니다. 셸이 로드되면 브라우저가 저희 API에서 데이터를 직접 가져왔습니다. 이 부분을 기억해 두세요. 뒤에서 다룰 느린 네트워크 문제의 근본 원인이 바로 여기에 있습니다. 2019년에는 같은 코드베이스가 데스크톱의 새 Twitter.com이 되었고, 모든 기기에 "하나의 코드베이스, 하나의 웹사이트"를 적용한다는 원칙을 바탕으로 만들어졌습니다.
저희가 rweb(responsive web의 줄임말)이라고 부르는 이 앱은 지난 10년 중 대부분의 기간 동안 x.com을 서비스해 왔습니다. 그동안 저희는 rweb 옆에 서버를 하나씩 더 붙였고, 서버마다 맡은 역할이 있었습니다.
- rweb은 로그인 사용자와 로그아웃 사용자 모두를 위해 브라우저에서 앱을 렌더링했습니다.
- rweb SSR은 검색 크롤러를 위해 같은 앱을 서버에서 렌더링했고, URL을 정리하는 자체 규칙을 갖고 있었습니다.
- Jetfuel SSR은 검색 엔진이 색인하는 로그아웃 페이지를 위한 별도의 서버 렌더링 사이트였습니다. 이벤트, Spaces, 커뮤니티, 트렌드, 검색, 뉴스 기사 페이지가 여기에 포함됐습니다.
각 서버는 맡은 일을 잘 해냈습니다. 문제는 서버가 세 개였다는 점입니다. x.com의 페이지 하나가 세 가지 다른 방식으로 만들어질 수 있었습니다.
서버 세 개가 하나로
재작성 전과 지금, 방문자 유형별로 어느 서버가 응답하는지 보여 줍니다.
문제
x.com의 URL 하나에는 성격이 전혀 다른 세 부류의 방문자가 있습니다. x.com/elonmusk를 예로 들어 보겠습니다.
휴대폰이나 노트북을 쓰는 사람은 프로필과 최신 게시물을 최대한 빨리 보고, 스크롤을 내리다가 어쩌면 가입하고 싶어 합니다.
Googlebot이나 Bingbot 같은 검색 크롤러는 완전한 HTML을 원합니다. 게시물, 게시물을 설명하는 구조화된 데이터, 페이지의 모든 언어 버전으로 가는 링크, 그리고 계정이 존재하지 않을 때 돌려줄 올바른 상태 코드까지 필요합니다.
iMessage, Slack, Discord 뒤에서 돌아가는 링크 미리보기 봇은 페이지의 제목, 설명, 이미지를 원합니다. 페이지의 나머지 요소는 이 봇에게 쓸데없는 작업일 뿐입니다.
세 부류를 모두 만족시키기 어려운 이유는 x.com 데이터의 생김새 때문입니다. x.com의 데이터는 그래프입니다. 게시물에는 작성자가 있고, 작성자에게는 프로필이 있으며, 게시물은 자체 작성자와 미디어를 가진 다른 게시물을 인용할 수도 있습니다. 브라우저는 그 그래프를 요청하는 코드를 내려받아 실행한 뒤에야 그래프의 대부분을 알게 됩니다. Twitter Lite의 애플리케이션 셸을 기억하시나요? 바로 여기서 문제가 됩니다. rweb에서 로그아웃 방문자의 브라우저는 셸을 내려받고, 이어서 JavaScript를 내려받아 실행하고, 그다음 GraphQL API에 데이터를 요청한 뒤에야 비로소 프로필을 렌더링했습니다. 빠른 네트워크에서는 이 연쇄 과정을 거의 느끼지 못합니다. 느린 네트워크에서는 단계마다 왕복이 한 번씩 더 생기고, 방문자는 그 시간 내내 스플래시 화면만 바라봅니다.
첫 게시물이 나타나기 전에 일어나는 일
단계를 순서대로 나타냈으며, 길이는 실제 시간에 비례하지 않습니다.
rweb SSR과 Jetfuel SSR은 서버에서 HTML을 렌더링해서 이 문제 중 크롤러 쪽 절반을 해결했습니다. 걸림돌은 두 서버가 이 문제를 서로 따로, 그리고 사람들이 쓰는 앱과도 따로 해결했다는 점입니다. 페이지의 메타 설명이나 구조화된 데이터, 404 처리 방식을 하나 고치려면 코드베이스 세 곳에 반영해야 할 수도 있었습니다.
설계 문서에는 드러나지 않는 문제도 있습니다. 이런 문제는 시스템을 몇 년 동안 운영한 뒤에야 발견됩니다.
서버 세 개는 서로 어긋납니다. SEO 수정은 어느 한 서버에만 반영되고 다른 서버에서는 빠질 수 있었습니다. 세 서버를 맞춰 두는 일은 끊임없이 치러야 하는, 대부분 눈에 보이지 않는 비용이었습니다.
로그아웃 방문자가 가장 느린 경로를 탔습니다. 크롤러는 rweb SSR이나 Jetfuel SSR에서 완성된 HTML을 받았습니다. 로그인하지 않은 사람들은 rweb의 애플리케이션 셸을 받고, JavaScript가 페이지를 렌더링할 때까지 기다려야 했습니다. 봇이 더 좋은 대접을 받은 셈입니다.
기술 스택이 고르지 않게 낡았습니다. rweb은 정규화된 Redux 스토어와 Relay를 함께 썼고, React Native for Web으로 컴포넌트에 스타일을 입혔으며, Flow로 타입을 검사하고, 최신 번들과 레거시 번들을 따로 배포했습니다. 각 선택은 당시에는 합리적이었습니다. 하지만 이 선택들이 합쳐지면서 일상적인 변경이 필요 이상으로 느려졌습니다. 저희는 이런 상황을 천 번 베여 죽는다(death by a thousand cuts)고 부릅니다. 하나하나는 작은 문제였지만, 모이니 앱을 유지보수하기도, 빠르게 유지하기도 어려워졌습니다.
결정
이 시점에서 저희에게는 선택지가 세 가지 있었습니다.
첫 번째는 rweb을 그대로 두고 계속 개선하는 것이었습니다. 저희는 수년간 그런 작업을 많이 해 왔고, 덕분에 사이트를 건강하게 유지할 수 있었습니다. 하지만 그렇게 하면 서버 세 개도 그대로 남고, 로그아웃 방문자에게 서버에서 렌더링한 HTML을 주려면 네 번째 렌더링 경로를 만들어야 했을 것입니다. 무려 네 번째입니다.
두 번째는 한 걸음 더 나아가 rweb의 아키텍처는 유지하면서 도구 체인을 교체하는 것이었습니다. Webpack과 Babel을 Vite로, Flow를 TypeScript로, Express 서버를 Fastify로 바꾸는 식입니다. 이런 변경은 하나하나가 코드베이스의 거의 모든 파일을 건드리는데, 다 끝내고 나도 문제를 일으킨 아키텍처는 그대로 남습니다.
세 번째는 모든 방문자를 서버 하나로 처리하는 새 애플리케이션을 만들고, rweb이 나머지 사용자를 계속 처리하는 동안 트래픽을 점진적으로 옮기면서, 그 과정에서 두 SSR 서버를 새 앱으로 흡수하는 것이었습니다. 저희는 이 방법을 택했습니다.
2019년에 저희는 "이런 대규모 마이그레이션은 위험하고 설득하기도 어렵다"고 썼습니다. 지금도 맞는 말입니다. 이번 마이그레이션을 가능하게 한 것은 점진적으로 진행한다는 점이었습니다. 로그아웃 방문자를 먼저 옮기고, 그다음 로그인한 사용자를 한 번에 한 국가씩 옮기며, 모든 사용자가 옮겨 갈 때까지 예전 서버는 계속 돌아갑니다.
이런 재작성에는 타당한 반론이 두 가지 있습니다.
"절대 처음부터 다시 작성하지 마라." Joel Spolsky의 조언은 충분한 근거가 있습니다. 대규모 재작성은 예전 제품이 계속 앞서 나가는 동안 지지부진해지기 쉽습니다. 저희는 그동안 rweb에 계속 기능을 배포했고, 트래픽을 점진적으로 옮겼으며, 예전 서버를 명세로 삼아 새 앱을 테스트했습니다. 구현의 상당 부분을 에이전트가 맡았기 때문에 재작성은 몇 달 만에 끝났고, 예전 제품이 그사이에 앞서 나갈 시간은 거의 없었습니다.
"그냥 Webpack을 업그레이드하면 되지 않나요?" 빌드가 빨라지면 x.com을 만드는 사람들에게는 도움이 됩니다. 하지만 느린 네트워크를 쓰는 방문자에게 서버에서 렌더링한 HTML을 주지는 못하고, 서버 세 개를 하나로 만들어 주지도 않습니다. 새 앱을 만들 만한 문제는 바로 이것들이었습니다.
그대로 가져간 것
재작성을 하다 보면 예전 코드가 실제보다 나빠 보이기 쉽습니다. 예전 스택에서 잘 작동하던 것 중 상당수는 새 앱에도 들어갔습니다.
rweb은 제품 모델을 제대로 잡았습니다. 화면 크기, 입력 방식, 네트워크 상태에 맞춰 적응하는 반응형 코드베이스 하나는 지금도 저희가 웹을 만드는 방식입니다. rweb은 x.com을 GraphQL과 Relay로 옮기기도 했는데, 새 앱은 그 위에 바로 올라가 있습니다.
rweb SSR은 실제 환경에서 x.com URL이 어떻게 망가지는지 알고 있었습니다. rweb SSR은 수년간 망가진 채로 들어오는 URL을 처리하는 규칙을 모아 왔습니다. 끝에 문장부호가 붙은 검색어, 검색어에 들러붙은 언어 매개변수, 채팅 앱이 불필요한 문자로 감싸 버린 인텐트 링크 같은 것들입니다.
Jetfuel SSR은 검색을 진지하게 다뤘습니다. Jetfuel SSR 페이지에는 상세한 구조화된 데이터가 들어 있었고, 페이지마다 모든 언어 버전을 링크했으며, 존재하지 않는 콘텐츠에는 제대로 된 404를 돌려줬습니다.
재작성은 무언가를 버릴 기회이고, 그래서 너무 많이 버리고 싶은 유혹이 생깁니다. 저희가 그대로 가져간 것은 다음과 같습니다.
- 같은 URL. 사람과 크롤러가 이미 의존하고 있는 리디렉션과 별칭도 포함합니다.
- rweb SSR의 URL 정리 규칙. 새 서버로 이식했습니다.
- Jetfuel SSR의 구조화된 데이터, 언어별 대체 링크, 404 페이지. 공유 컴포넌트로 다시 만들었습니다.
- Jetfuel의 링크 미리보기 카드 이미지. 새 Rust 서비스가 픽셀 단위까지 똑같이 다시 그립니다.
- 모든 기기를 위한 반응형 코드베이스 하나.
새 기술 스택
새 앱은 Vite, React, Fastify, Platformatic Watt, Relay와 GraphQL, 그리고 저희의 새 컴포넌트 라이브러리인 XDS로 만들었습니다. 이것들을 저희가 직접 만든 서버 패키지 하나가 묶어 줍니다.
- Vite는 Rolldown으로 클라이언트 번들과 서버 번들을 빌드하고 로컬 개발 환경을 구동합니다. 로그아웃 방문자와 로그인 방문자는 서로 다른 클라이언트 진입점을 받으므로, 로그아웃 페이지는 로그아웃용 코드만 내려받습니다.
- React는 스트리밍 서버 렌더링과 Suspense를 사용해 서버와 브라우저 양쪽에서 렌더링합니다.
- Fastify는 미들웨어부터 최종 HTML 스트림까지 모든 요청을 처리합니다. 저희의
@x-clients/server패키지가 보안 헤더, 쿠키, 메트릭, 스트리밍 렌더링, 정상 종료(graceful shutdown)를 설정에 따라 조립해 Fastify 서버를 구성합니다. - Platformatic Watt는 Node.js v26에서 서버를 한 프로세스 안의 워커 스레드 여러 개로 실행합니다. 이벤트 루프나 메모리가 한도를 계속 넘는 워커는 교체하므로, 렌더링 하나가 멈춰도 그 워커가 맡은 요청에만 영향을 줍니다.
- Relay와 GraphQL 은 데이터를 가져오고 캐시하며, 각 컴포넌트는 자신에게 필요한 데이터를 직접 선언합니다.
- 저희의 새 컴포넌트 라이브러리인 XDS는 버튼과 메뉴부터 대화상자, 툴팁, 토스트까지 40개가 넘는 컴포넌트를 제공하며, Base UI와 Tailwind CSS를 바탕으로 만들었습니다. 다른 X 웹 앱 다섯 개도 XDS를 쓰고 있고, rweb과 공유하는 기능을 통해 rweb에도 XDS의 스타일이 들어갑니다.
- TypeScript와 Tailwind CSS가 타입과 스타일링을 맡습니다. 타입 검사는 TypeScript 컴파일러를 Rust로 이식한 tsrs에서 실행합니다.
모든 방문자를 위한 서버 하나
새 서버는 누가 요청했는지에 따라 각 요청을 어떻게 렌더링할지 결정합니다.
사람에게는 스트리밍 HTML을 보냅니다. 서버는 페이지 셸이 준비되는 즉시 보내고, 페이지의 각 부분은 데이터가 도착하는 대로 스트리밍합니다. 서버가 받은 GraphQL 응답은 페이지 안에 한 번씩만 실려 가므로, 브라우저는 API에 다시 요청하지 않고 그 응답을 재사용할 수 있습니다.
검증된 검색 크롤러에게는 완성된 문서를 보냅니다. 검색 엔진에 대해서는 서버가 페이지의 모든 부분이 렌더링될 때까지 기다렸다가, 구조화된 데이터를 넣고 모든 Suspense 경계를 제자리에서 해소한 문서 전체를 한 번에 보냅니다. 이 판단은 저희 엣지가 크롤러의 IP 주소를 검증한 결과를 바탕으로 합니다. 흔히 쓰는 방식처럼 User-Agent 헤더를 확인하지 않는 이유는 무엇일까요? 이유는 두 가지입니다. 첫째, 그렇게 하면 실제 크롤러뿐 아니라 Lighthouse 같은 성능 측정 도구에도 버퍼링된 응답을 보내게 됩니다. 둘째, 누구나 헤더만 바꿔서 비용이 더 드는 경로를 요청할 수 있게 됩니다. 검증된 IP 주소를 기준으로 삼으면 저희의 Lighthouse 측정은 사람들이 받는 것과 똑같은 스트리밍 페이지를 측정하게 됩니다.
크롤러용 문서는 하이드레이션을 하지 않으므로, 클라이언트 번들도, 라우터 상태도, 저희 JavaScript만 교체할 플레이스홀더도 들어 있지 않습니다. 여기서는 크기가 생각보다 중요합니다. Googlebot은 응답의 처음 2MB만 읽는데, 긴 스레드의 HTML은 이 크기를 넘을 수 있습니다. 이 문서들을 어떻게 다시 한도 안으로 줄였는지는 성능 섹션에서 다루겠습니다.
링크 미리보기 봇에게는 읽는 것만 보냅니다. 이 봇들은 링크를 펼쳐 미리보기로 보여 주는 봇으로, 몇 가지만 꼽아도 Slack, iMessage, Discord, WhatsApp, Signal, Facebook, LinkedIn, Telegram, Snapchat이 있습니다. 이런 봇에 대해서는 서버가 라우트의 데이터 로딩과 head 태그만 실행하고 페이지 본문 렌더링은 건너뛰며, 로더도 본문에만 필요한 타임라인과 대화 요청을 건너뜁니다. 응답에는 봇이 읽는 제목, 설명, 카드 이미지가 들어 있습니다. 저희 엣지가 검증하지 않은 다른 크롤러는 로그아웃 방문자가 받는 것과 정확히 같은 응답을 받습니다. 검색 엔진이라고 주장하지만 검증되지 않은 IP 주소에서 온 요청도 여기에 포함됩니다.
카드 이미지는 Rust 서비스가 만듭니다. 게시물 링크 미리보기에 나오는 이미지는 요청이 올 때마다 그리는 PNG입니다. 예전에는 Jetfuel이 JavaScript 서비스로 이 카드를 그렸습니다. 그 서비스는 코어를 더 쓰려면 인스턴스를 더 띄우는 수밖에 없었는데, 인스턴스끼리는 메모리를 공유하지 못했고, 장애가 났을 때는 응답하는 데 2분 가까이 걸린 적도 있었습니다. 그래서 저희는 이 서비스를 Rust로 다시 작성했습니다. 새 서비스는 새 앱과 같은 모노레포에 있고, 게시물, 미디어 미리보기, 구독, 캐시태그 차트와 Money 등 예전과 같은 라우트에 응답합니다. 녹화·재생 방식의 테스트 하네스가 모든 카드를 커밋된 기준 이미지와 비교하고, 두 번째 하네스가 새 카드를 예전 서비스의 카드와 픽셀 단위로 비교합니다.
데이터와 내비게이션
모든 라우트는 필요한 데이터를 미리 선언합니다. 요청이 들어오면 라우트 로더가 서버가 아직 렌더링하는 동안 페이지에 필요한 GraphQL 요청을 시작합니다. 페이지에는 서버가 받은 GraphQL 응답이 각각 한 번씩 JSON 텍스트로 실려 있습니다. 브라우저는 빈 Relay 스토어로 시작해서 이 응답들을 스토어에 재생해 넣으므로, 서버가 이미 가져온 데이터를 요청하는 컴포넌트는 네트워크 요청 없이 서버의 응답을 받습니다. 아직 아무도 읽지 않은 응답은 스트림이 끝난 뒤 30초 동안 브라우저에 남아 있으므로, 그 직후에 연 탭이나 대화상자도 이 응답을 찾을 수 있습니다.
클라이언트 측 내비게이션도 같은 방식으로 작동합니다. 사용자가 링크에 마우스를 올리거나 포커스를 맞춰 그 링크로 이동하려는 의도를 보이면, 링크는 라우트의 코드와 데이터를 미리 불러옵니다. 타임라인의 게시물은 일반 링크가 아니므로, 마우스가 게시물 위로 올라가 100밀리초 동안 머무르거나 게시물을 누르면 미리 불러옵니다. 포인터가 가만히 있는 상태에서 스크롤되어 지나가는 게시물은 미리 불러오지 않습니다. 9월 이후로 21개 라우트가 추가로 라우트 로더에서 메인 쿼리를 시작하게 되었으므로, 마우스를 올려 미리 불러올 때 이 라우트들의 코드와 함께 데이터도 가져옵니다.
rweb에는 저희가 아직 따라잡고 있는 장점이 하나 있습니다. rweb은 클라이언트 측 캐시에 데이터를 워낙 많이 담아 두어서 화면 사이를 이동할 때 즉각적으로 느껴지는 경우가 많았습니다. 저희는 의도 기반 미리 불러오기로 그 격차를 좁히고 있습니다.
작은 팀이 만든 방법
그렇다면 다섯 명도 안 되는 엔지니어가 어떻게 6개월이 채 안 되는 기간에 첫 버전을 만들었을까요? 짧게 답하자면 AI 코딩 에이전트 덕분입니다.
엔지니어는 아키텍처를 정하고, 무엇을 만들지 결정하고, 모든 변경 사항을 리뷰했습니다. 구현의 상당 부분은 에이전트가 맡았습니다. 예전 서버의 동작을 이식하고, 페이지를 변환하고, 테스트를 작성하고, CI와 리뷰어가 지적한 문제를 고쳤습니다. 이 방식은 에이전트가 만든 결과물을 신뢰할 수 있을 때만 통합니다. 저희는 세 가지로 그 신뢰를 확보했습니다.
공유 지침. 저장소 루트에 있는 파일 하나, AGENTS.md가 모든 에이전트에게 저희가 코드를 작성하는 방식을 알려 줍니다. 이름 있는 export를 쓸 것, 배럴 파일을 만들지 않을 것, 신뢰 경계에서 외부 데이터를 검증할 것, 어떤 테스트가 작성할 가치가 있는지, 그리고 린트, 타입 검사, 관련 테스트를 통과하기 전에는 아무것도 커밋하지 않을 것 등입니다. 모든 에이전트와 모든 신규 엔지니어가 같은 규칙에서 출발합니다.
저장소 안의 스킬. 반복되는 워크플로는 에이전트가 단계별로 따르는 스킬로 만들어 코드 옆에 두며, 그 수가 25개가 넘습니다. 커밋 작성, 풀 리퀘스트 열기와 쌓기, 코드 리뷰, 풀 리퀘스트가 CI를 통과하도록 챙기기, 의존성 변경, 번역 추가, Relay와 저희 라우터 다루기 같은 것들입니다. 워크플로가 바뀌면 해당 스킬을 한 번만 고치면 되고, 모든 에이전트가 그 변경을 반영합니다.
모든 커밋에 적용되는 가드레일. 저희는 코딩 규칙을 린트 규칙으로 만들었습니다. 직접 만든 oxlint 플러그인이 Relay 사용법, Tailwind 클래스, 테스트, 번역을 검사합니다. 그리고 네, "안티 슬롭(anti-slop)" 플러그인도 있습니다. 이 플러그인은 연쇄 타입 단언, unknown으로 타입을 지정한 매개변수, 빈 객체를 조건부로 펼치는 코드처럼 에이전트가 자주 만들어 내는 패턴을 거부합니다. pre-commit 훅은 모든 변경 사항이 반영되기 전에 포맷팅, 린트, 타입 검사를 실행하고, pre-push 훅은 변경된 패키지를 가져다 쓰는 패키지들까지 린트합니다.
마이그레이션 작업 자체도 에이전트에게 잘 맞았습니다. rweb SSR과 Jetfuel SSR이 실제로 작동하는 명세 역할을 했기 때문입니다. 에이전트는 예전 서버가 URL이나 페이지 유형을 어떻게 처리했는지 읽고, 그 동작을 이식하고, 그 동작을 고정하는 테스트를 작성할 수 있었습니다. 리뷰어는 그 결과를 원래 서버와 대조해 확인할 수 있었습니다.
엔지니어의 일도 바뀌었습니다. 코드를 작성하는 시간은 줄고, 시스템이 무엇을 해야 하는지 결정한 다음 실제 브라우저에서 정말 그렇게 작동하는지 확인하는 시간이 늘었습니다.
트래픽 옮기기
검색 크롤러부터
크롤러 트래픽을 옮기는 일은 어떤 웹 마이그레이션에서든 가장 위험한 부분입니다. 검색 엔진은 모든 것을 알아채기 때문입니다. 구조화된 데이터 필드 하나가 빠지거나, 정규 URL이 바뀌거나, 리디렉션이던 페이지가 404를 돌려주면 바로 알아챕니다.
저희는 두 SSR 서버를 명세로 다뤘습니다. 페이지 유형마다 Jetfuel SSR이나 rweb SSR이 돌려준 응답과 새 앱이 돌려준 응답을 비교하고, 구조화된 데이터 주석, 메타 설명, 언어별 대체 링크, 404 페이지, 리디렉션, URL 정리에서 차이를 하나씩 메웠습니다. 이런 동작 중 상당수는 이제 공유 컴포넌트에 들어 있고, 그 옆에는 해당 동작을 어느 서버에서 가져왔는지 이름에 밝힌 테스트가 있습니다.
작은 동등성 검사 스크립트 덕분에 이 비교를 반복해서 실행할 수 있었습니다. 이 스크립트는 검증된 Googlebot으로서 두 서버에 같은 경로를 요청하고, 검색 엔진이 읽는 항목을 비교합니다. 상태 코드와 리디렉션, 제목, 설명, 정규 URL, robots 태그, Open Graph와 X 카드 태그, 언어별 대체 링크의 개수, 구조화된 데이터의 유형입니다.
크롤러 트래픽은 점진적으로 새 앱으로 옮겨 갔습니다.
- 2026년 8월 1일: Jetfuel SSR을 종료했습니다.
- 9월 초: rweb SSR이 처리하던 크롤러 트래픽을 모두 새 앱이 처리하게 되어 rweb SSR을 종료했습니다. rweb SSR의 배포와 빌드를 제거하면서 rweb에서 3만 6,000줄 가까이 삭제했습니다.
- 9월 중순: Jetfuel의 서버 렌더링 서비스와 이미지 서비스도 Jetfuel 저장소에서 삭제했습니다.
그다음 사람들을, 한 번에 한 국가씩
저희는 로그인한 x.com을 한 번에 한 국가씩 옮기고 있습니다. 요청마다 저희 엣지가 경로와 요청한 사람을 바탕으로 어느 서버가 응답할지 결정하므로, 한 그룹의 사람들을 새 앱으로 옮겼다가 문제가 생기면 다시 되돌릴 수 있습니다.
경로 목록은 앱 자체에서 나옵니다. 앱 옆에 있는 설정 파일(ngroutes)이 앱의 파일 기반 라우트 트리에서 라우트를 찾아내고, 배포할 때마다 엣지에 게시합니다. 새 앱에 없는 경로는 rweb으로 갑니다.
누가 새 앱을 받을지는 피처 스위치가 결정합니다. 그래서 앱을 배포하지 않고도 대상자를 바꿀 수 있고, 대상에서 빠진 사람은 곧바로 rweb으로 돌아갑니다.
로그아웃 페이지를 먼저 옮겼습니다. 이 작업이 진행되는 동안 새 앱을 lite.x.com에서도 쓸 수 있게 해 두어서 x.com과 나란히 비교할 수 있었습니다. 지금은 새 앱이 로그아웃 상태의 x.com을 처리하고 있고, 로그인한 x.com은 배포가 진행 중입니다. 로그인 페이지는 먼저 lite.x.com에서 다시 만들고 테스트하는데, 이곳에서 직원과 테스터가 로그인 버전의 앱을 사용합니다. 최근 사례로는 NFL, MLB, 대학 미식축구 허브가 있습니다. 첫 번째 국가에서는 로그인한 사용자를 실험군으로 나눴습니다. 일부는 새 앱을 받고 나머지는 rweb에 남겨서, 실제 트래픽으로 두 앱을 비교할 수 있게 했습니다.
어떤 기능은 한 번만 만들어서 마이그레이션 기간 내내 양쪽에서 공유합니다. 가장 분명한 예는 X Chat입니다. rweb과 새 앱 모두 Module Federation을 통해 런타임에 chat.x.com에서 X Chat을 불러오므로, 양쪽 모두 chat.x.com이 배포한 채팅을 실행합니다. 새 앱은 X Calls와 Money도 같은 방식으로 불러옵니다. 또한 새 앱은 페이지가 가진 React와 다른 공유 라이브러리를 Money가 그대로 빌려 쓰게 하는데, 덕분에 첫 Money 페이지에서 내려받는 코드가 gzip 기준 590KB에서 509KB로 줄었습니다. 분석 기능이나 Grok Imagine처럼 두 앱이 모두 가져다 쓰는 코드는 여전히 공유 기능 패키지에 들어 있습니다.
결과
차이를 가장 쉽게 확인하는 방법은 두 앱이 로드되는 모습을 나란히 보는 것입니다.
저희는 2026년 11월 3일에 양쪽 모두 같은 계정으로 로그인한 상태에서 이 장면을 녹화했습니다. 한쪽은 x.com의 rweb, 다른 쪽은 lite.x.com의 새 앱입니다. 네트워크는 Lighthouse의 기본 Slow 4G 프로필로 제한했습니다. 왕복 시간 150ms, 다운로드 1.6Mbps, 업로드 750Kbps이며, CPU 감속은 없고, 캐시는 비웠고, 서비스 워커는 껐습니다. 각 앱을 따로 세 번씩 녹화하고, 앱마다 중앙값에 해당하는 실행 결과를 사용했습니다. 양쪽 아래에 표시된 타이머 값은 브라우저 자체의 타이밍 API에서 가져왔습니다.
- 새 앱: 2.4초에 페이지가 나타나고, 2.7초에 첫 게시물이 나타납니다.
- rweb: 1.0초에 스플래시 화면이 나타나고, 23.9초에 첫 게시물이 나타날 때까지 스플래시 화면이 계속 떠 있습니다.
22초가 넘도록 스플래시 화면만 보는 셈입니다. 이것은 실험실 환경의 녹화이므로 격차를 보여 주는 예시 정도로 받아들여 주세요. 실제 기기와 네트워크에서는 결과가 달라질 수 있습니다. 하지만 이런 차이가 나는 이유는 아키텍처에서 바로 나옵니다. 새 앱은 첫 응답에 콘텐츠를 담아 보내고, rweb은 콘텐츠를 요청하기 전에 JavaScript부터 내려받아 실행해야 합니다.
나머지 결과는 구조적인 것입니다. x.com의 로그아웃 웹은 예전에 서버 세 개가 하던 일을 이제 서버 하나로 처리합니다. rweb SSR과 Jetfuel SSR이 처리하던 크롤러 트래픽은 새 앱이 처리합니다. 이제 SEO 수정은 한 번만 반영하면 됩니다. 트래픽을 옮기면서 저희는 rweb의 로그아웃용 서버군을 데이터 센터당 인스턴스 1,400개에서 700개로 절반으로 줄였고, rweb SSR의 서버군도 2,400개에서 1,200개로 절반으로 줄인 뒤 종료했습니다.
빠르게 만들기
그 2.7초는 공짜로 얻은 것이 아닙니다. 그림 2의 6월 녹화는 새 앱의 출발점을 보여 줍니다. 그때도 느린 네트워크에서 rweb보다 약 6배 빨랐지만, 고칠 것이 아직 많았습니다. 그 이후 성능은 작업의 큰 부분을 차지했습니다. 9월 1일 이후 perf 라벨이 붙은 커밋이 80개 넘게 이 앱에 들어갔습니다. 아래는 서버와 브라우저에서 가장 효과가 컸던 변경 사항입니다. 아래 수치는 모두 해당 변경을 만든 풀 리퀘스트에서 가져왔고, 스타일시트 변경만 이 글을 위해 다시 측정했습니다. 각 수치에는 측정한 환경을 밝혀 두었습니다. 실험실의 프로덕션 빌드, 개발 서버, 또는 변경 전 프로덕션 서버군 중 하나입니다.
서버에서
렌더링 워커 늘리기. 로그아웃 렌더링은 워커의 이벤트 루프에 발목이 잡혀 있었습니다. 10월에 프로덕션 서버군에서 측정해 보니, 각 워커의 이벤트 루프는 중앙값 기준으로 시간의 약 67%, p95 기준으로 84% 동안 바빴습니다. 피크 시간대에는 가장 긴 지연이 중앙값 기준 약 1.9초, p95 기준 3.7초였습니다. 여기서 교묘한 함정이 있습니다. 지연된 이벤트 루프는 이미 도착한 응답을 처리하기 전에 타이머를 먼저 실행할 수 있습니다. 그래서 업스트림 요청 하나에 걸린 1초 타임아웃이, 그 뒤의 서비스가 약 25ms 만에 응답했는데도 로그아웃 렌더링의 1~9%에서 발동했습니다. 각 워커는 스레드 하나에서 렌더링하므로, 저희는 같은 트래픽을 더 많은 워커에 나눠서 각 이벤트 루프가 맡는 몫을 줄였습니다.
게스트 토큰 기다리지 않기. 로그아웃 렌더링에는 게스트 토큰과 방문자의 피처 플래그가 필요합니다. 예전에는 피처 스위치 요청이 토큰을 쓰지 않는데도, 서버가 게스트 토큰 요청이 돌아온 뒤에야 피처 플래그를 요청했습니다. 이제는 두 요청을 동시에 시작합니다. 덕분에 로그아웃 렌더링에서 순차적인 왕복 한 번이 사라졌고, 게스트 토큰 요청이 느리거나 속도 제한에 걸려도 피처 플래그가 늦어지지 않습니다.
스토어 레코드 대신 GraphQL 응답을 페이지에 싣기. 예전에는 서버가 Relay 스토어를 직렬화해 페이지에 넣은 다음, 스토어가 바뀔 때마다 그 내용을 스트리밍했습니다. 이 정규화된 레코드는 원본 GraphQL 응답보다 4~5배 컸습니다. 이제 페이지에는 각 응답이 한 번씩 JSON 텍스트로 실리고, 브라우저는 그 응답을 자체 스토어에 재생할 때 JSON.parse로 파싱합니다.
크롤러용 문서를 Google의 2MB 한도 안으로. Googlebot의 2MB 한도를 기억하시나요? 긴 스레드의 HTML은 2.2~3.5MB에 달했기 때문에 Google은 잘린 지점 이후의 답글을 놓쳤고, 저희 구조화된 데이터는 Google이 보지도 못한 게시물을 가리키고 있었습니다. 세 가지 변경으로 이 문서들의 크기를 줄였습니다. 각각 검증된 크롤러로 표시한 요청을 로컬에서 렌더링해 측정했습니다.
- 예전에는 게시물마다 액션 아이콘 일곱 개의 전체 그림(약 2KB)과 인증 배지용 775바이트를 인라인으로 넣었습니다. 이제는 빌드 타임 플러그인이 각 아이콘을 페이지 상단의 숨겨진 스프라이트에 한 번만 넣고, 게시물은 그 스프라이트를 참조합니다. 이 변경으로 @elonmusk 프로필에서 165KB(14.2%), 긴 스레드에서 340KB(13.6%)를 줄였습니다.
- 예전에는 사진마다 구조화된 데이터에 게시물 텍스트가 두 번, 사진 URL이 세 번 반복되어 있었습니다. 이제는 Google이 이미지로 읽는 URL 하나만 남기고, 사진은 그 사진의 대체 텍스트로만 설명합니다. 사진이 많은 스레드에서 450KB(13.0%)를 줄였습니다.
- 크롤러용 문서에는 이제 저희 JavaScript가 실행될 때만 의미가 있는 출력이 들어가지 않습니다. 타임스탬프 스크립트, 라우터 상태, 스켈레톤 게시물, 로그인 버튼이 여기에 해당합니다. 게시물 137개짜리 스레드에서 54KB를 추가로 줄였습니다.
브라우저에서
셸을 바로 하이드레이션하기. 예전에는 진입 스크립트가 지연(deferred) 모듈이었는데, 브라우저는 이런 모듈을 문서 전체를 파싱한 뒤에야 실행합니다. 스트리밍되는 문서는 마지막 Suspense 경계가 도착해야 파싱이 끝나므로, 하이드레이션이 페이지에서 가장 느린 부분을 기다려야 했습니다. 이제 진입 스크립트는 비동기(async) 모듈입니다. 로드되는 즉시 실행되어 React가 셸을 하이드레이션하고, 각 경계는 해당 HTML이 도착할 때 하이드레이션됩니다. 대화 쿼리에 서버 측 지연을 5초 넣은 테스트에서 React는 589ms에 페이지에 연결되었습니다. 문서 파싱이 6,017ms에 끝난 것보다 훨씬 앞선 시점입니다.
시작할 때 받는 파일 줄이기. 페이지는 진입점의 정적 import 그래프에 있는 파일이 모두 도착해야 하이드레이션할 수 있습니다. Rolldown의 기본 청크 분할 방식으로는 이 파일이 로그인 진입점에 754개, 로그아웃 진입점에 582개였고, 로그인 상태에서 시작할 때 받는 파일 대부분에는 모듈이 하나씩만 들어 있었습니다. Chrome은 HTTP/2 연결 하나에서 동시에 최대 100개 정도의 요청만 처리하고, 저희 애셋 CDN도 동시 스트림을 100개까지 허용합니다. 그래서 브라우저가 이 파일들을 한꺼번에 요청해도 왕복이 약 8번 필요했습니다. 이제는 빌드 플러그인이 모든 진입점이 시작할 때 불러오는 모듈을 청크 세 개로 묶습니다. 의존성, 저희가 작성한 코드, 그리고 지연 로딩되는 청크의 이름을 담은 코드입니다. 이 마지막 코드는 거의 매 배포마다 파일 이름이 바뀝니다. 로그인 진입점의 시작 파일은 754개에서 182개로, 로그아웃 진입점은 582개에서 단 10개로 줄었습니다. 각 페이지는 여전히 예전과 정확히 같은 모듈을 불러옵니다. x.com 사용자의 p90 왕복 시간 정도인 275ms만큼 모든 JavaScript 응답을 지연시킨 실험실 테스트에서, 로그인 상태의 홈 타임라인이 6.63초가 아니라 4.95초에 나타났습니다.
스타일시트 먼저. 느린 네트워크에서는 진행 중인 다운로드가 모두 렌더링을 막는 스타일시트와 대역폭을 나눠 씁니다. 예전에는 페이지의 JavaScript도 스타일시트와 동시에 다운로드를 시작했기 때문에, 첫 페인트가 페이지에 아직 필요하지도 않은 코드를 기다려야 했습니다. 이제는 작은 인라인 스크립트가 스타일시트 로드가 끝난 뒤에 진입 스크립트와, 서버가 렌더링한 청크의 프리로드를 추가합니다. 느린 프로필(CPU 4배 감속, 왕복 150ms, 1.6Mbps)로 실험실에서 실행해 보니, 로그아웃 상태의 프로필에서 첫 콘텐츠 페인트(FCP)가 3,064ms에서 1,984ms로 빨라졌습니다. 로그아웃 상태의 게시물에서는 7,216ms에서 1,804ms로 빨라졌습니다. 대가도 있습니다. 스크립트가 더 늦게 시작하므로, 느린 페이지 다섯 개 중 두 개에서 하이드레이션이 각각 0.3%와 0.6% 조금 늦게 끝났습니다.
느린 프로필에서 측정한 첫 콘텐츠 페인트
JavaScript가 스타일시트를 기다리도록 바꾸기 전과 후의 실험실 중앙값입니다. 낮을수록 좋습니다.
바이트 줄이기. 여러 변경으로 페이지가 내려받는 양을 줄였습니다.
- 영어 페이지는 이제 각 메시지를 키에서 바로 렌더링합니다. 영어 카탈로그는 모든 키를 키 자신에 매핑하기 때문입니다. 로그아웃 상태의 영어 페이지는 더 이상 하이드레이션 전에 카탈로그 파일 다섯 개(gzip 기준 49.5KB)를 기다리지 않습니다.
- 저희 카탈로그에서 쓰는 문법만 읽는 작은 메시지 파서가 브라우저에서 전체 ICU 메시지 파서를 대체했습니다. 이 변경으로 각 진입점의 시작 코드가 gzip 기준 약 8KB 줄었습니다.
- 예전에는 Sentry 빌드 플러그인이 아무도 읽지 않는데도 모든 청크에 릴리스 ID를 추가했습니다. 로그아웃 상태의 @elonmusk 프로필에서는 그런 청크가 211개, gzip 기준 12.4KB였습니다.
- 게시물 사진, 동영상 포스터, 카드 이미지는 이제 WebP로 요청합니다. 저희 이미지 CDN은 이미 이 URL들을 JPEG의 절반 정도 크기인 WebP로 제공하고 있었습니다.
측정 방법. 앱에 영향을 줄 수 있는 풀 리퀘스트에서는 Lighthouse CI가 @jack의 프로필과 그의 첫 게시물을 대상으로 데스크톱 설정과 모바일 설정으로 실행됩니다. 위 변경 사항을 만든 풀 리퀘스트들은 프로덕션 빌드에서 효과를 측정했는데, 지연 시간을 추가하는 HTTP/2 프록시를 거쳐 측정한 경우가 많았고, 개선 수치와 함께 성능 저하도 보고했습니다. 이 글을 위해 스타일시트 변경도 같은 방식으로 다시 측정했습니다. 페이지마다 실행 쌍 11개를 번갈아 돌리고, 95% 부트스트랩 구간이 0을 포함하지 않을 때만 변화가 있다고 표시했습니다. 실제 환경에서는 앱이 Core Web Vitals(LCP, INP, CLS)와 FCP, TTFB를 저희 클라이언트 성능 파이프라인인 perftown에 rweb과 같은 제품 네임스페이스로 보고합니다.
지금 두 앱을 비교하면
이 글을 위해 2026년 11월 3일에 두 앱의 벤치마크도 측정했습니다. 로그인 상태에서는 두 앱을 직접 비교할 수 있었습니다. 로그인한 x.com의 대부분은 아직 rweb이 처리하고, lite.x.com은 새 앱의 로그인 버전을 제공하기 때문입니다. 로그아웃 상태의 x.com은 이미 새 앱으로 돌아가므로, 로그아웃 쪽은 새 앱만 따로 측정했습니다.
로드할 때마다 캐시를 비우고 서비스 워커를 끈 새 헤드리스 Chromium을 띄웠으므로, 어느 앱도 미리 채워진 캐시나 서비스 워커의 도움을 받지 못했습니다. 각 페이지를 앱과 설정마다 일곱 번씩, 두 앱을 번갈아 가며 로드했습니다. 설정은 두 가지였습니다. 하나는 제한 없는 환경이고, 다른 하나는 지연 시간 150ms 추가, 다운로드 1.6Mbps, 업로드 750Kbps, CPU 4배 감속을 적용한 느린 프로필입니다. 주요 지표는 첫 게시물까지의 시간, 즉 첫 게시물이 화면에 나타나는 시점입니다. 첫 콘텐츠 페인트만 보면 rweb이 실제보다 좋아 보입니다. rweb의 첫 페인트는 스플래시 화면이기 때문입니다.
첫 게시물까지의 시간, 로그인 상태
첫 게시물이 화면에 나타날 때까지 걸린 시간의 중앙값입니다. 낮을수록 좋습니다.
로그인 상태에서 느린 네트워크로 열면, 프로필과 게시물이 8~13배 빨리 나타납니다. 느린 프로필에서 rweb은 저희가 시험한 모든 페이지에서 약 22~25초 뒤에 첫 게시물을 보여 줬습니다. 새 앱은 @SpaceX 게시물을 1.7초, @SpaceX 프로필을 3.0초 만에 보여 줘서 각각 12.8배, 8.2배 빨랐습니다. 제한 없는 환경에서는 새 앱이 게시물에서 3.2배, 프로필에서 2.3배 빨랐습니다.
홈은 개선 폭이 가장 작습니다. 새 앱은 로그인 상태의 홈 타임라인을 브라우저에서만 렌더링하므로, 두 앱 모두 홈에서는 JavaScript를 기다립니다. 느린 프로필에서 새 앱은 홈의 첫 게시물을 14.1초에 보여 줘서 rweb의 24.6초보다 1.7배 빨랐습니다. 제한 없는 환경에서는 1.8초 대 2.2초로 1.2배 빨랐습니다. 새 앱에서 개선할 여지가 가장 많이 남은 곳이 홈입니다.
서버가 응답하기 전에 하는 일이 더 많습니다. 로그인 상태에서 새 앱의 첫 바이트는 프로필과 게시물에서 중앙값 430~560ms에 도착했고, rweb은 300ms 미만이었습니다. 새 앱의 서버는 응답하기 전에 데이터를 가져와 페이지를 렌더링하기 때문입니다. rweb은 빈 셸로 더 빨리 응답한 다음, 브라우저에서 모든 것을 요청합니다.
첫 게시물 전에 내려받은 JavaScript
첫 게시물이 나타나기 전에 다운로드를 마친 스크립트의 압축된 바이트 수입니다.
대부분의 페이지가 JavaScript가 도착하기 전에 게시물을 보여 줍니다. 느린 프로필에서 rweb은 모든 페이지에서 첫 게시물을 보여 주기 전에 압축된 JavaScript를 3.0~3.6MB 내려받았습니다. 새 앱은 프로필이나 게시물을 보여 주기 전에 JavaScript를 하나도 내려받지 않았습니다. 게시물이 이미 HTML에 들어 있기 때문입니다. 홈에서는 먼저 1.9MB를 내려받았습니다. 페이지 로드 전체로 보면 새 앱은 로그인 페이지 하나당 압축된 JavaScript를 1.3~2.3MB 내려받았고, rweb은 4.1~4.8MB를 내려받았습니다.
첫 게시물까지의 시간, 로그아웃 상태
x.com의 새 앱입니다. 첫 게시물이 화면에 나타날 때까지 걸린 시간의 중앙값입니다.
로그아웃 상태의 x.com은 느린 프로필에서 약 2초 만에 게시물을 보여 줍니다. 느린 프로필에서 로그아웃 상태의 새 앱은 @SpaceX 게시물에서 1.7초, @SpaceX 프로필에서 2.2초 만에 첫 게시물을 보여 줬습니다. 제한 없는 환경에서는 각각 442ms와 792ms가 걸렸습니다. 6월 녹화와 같은 네트워크 제한을 걸고 CPU 감속은 하지 않았을 때, @SpaceX 프로필은 중앙값 2.1초에 첫 게시물을 보여 줬습니다. 6월 녹화(그림 2)에서는 로그아웃 상태의 프로필이 새 앱에서 약 3초, rweb에서 약 18초 만에 첫 게시물을 보여 줬습니다. 화면 녹화와 브라우저의 타이밍 API는 정확히 같은 순간을 기록하지 않으므로, 이 비교는 대략적인 것으로 받아들여 주세요.
이 수치는 노트북 한 대에서 측정한 실험실 수치이며, 실제 기기와 네트워크에서는 결과가 달라질 수 있습니다. 로그인 상태에서는 두 앱 모두 같은 계정을 사용했습니다.
배운 점
돌아보면 세 가지 교훈이 두드러집니다.
예전 서버가 저희가 가진 최고의 명세였습니다. rweb SSR과 Jetfuel SSR이 수년간 쌓아 온 것 중 상당수가 새 앱의 테스트가 되었고, 동등성 검사 스크립트로 그 결과를 원래 서버와 대조했습니다. 이 작업이 에이전트에게 그토록 잘 맞았던 이유도 바로 이것입니다.
점진적으로 옮겼기 때문에 재작성이 안전했습니다. 크롤러와 로그아웃 페이지를 먼저 옮겼고, 로그인한 사용자는 rweb이 나머지 사용자를 처리하는 동안 한 번에 한 국가씩 옮기고 있습니다. 옮긴 그룹은 언제든 곧바로 되돌릴 수 있습니다.
실험실 수치 옆에는 실제 환경의 수치가 있어야 합니다. 이 글의 수치는 대부분 실험실에서 나왔습니다. 실제 기기와 네트워크에서는 결과가 늘 달라지기 때문에, 앱은 실제 방문에서 측정한 Core Web Vitals도 보고합니다.
다음 단계는?
로그인한 x.com은 지금 로그아웃 페이지를 처리하는 것과 같은 엣지 라우트와 피처 스위치를 통해 한 번에 한 국가씩 배포되고 있습니다. 배포가 끝나면 rweb은 지난 10년 중 대부분의 기간 동안 x.com을 서비스해 온 끝에 은퇴합니다. 올해는 X에서 다시 만드는 일이 많았던 한 해였습니다. 7월에는 Android 앱도 처음부터 다시 만들었습니다.
Twitter Lite는 2017년에 느리거나, 불안정하거나, 비싼 네트워크에서 Twitter를 가장 잘 쓸 수 있는 방법이 되겠다는 목표로 출발했습니다. 9년이 지난 지금도 목표는 같습니다. 새 x.com은 그 목표를 사람, 검색 엔진, 링크 미리보기라는 세 부류의 방문자 모두에게 넓혀 갑니다.