CSR 앱의 재방문 경험을 SSR처럼 만들기: 타깃 재정의부터 아키텍처 전환까지
'첫 방문도 빠르게' → '재방문만 집중하자' → 'Prepaint는 나중으로'. 타깃 재정의, 복잡도 절충, 아키텍처 전환을 거치며 배운 오픈소스 기획의 현실적 고민들을 공유합니다.
'첫 방문도 빠르게' → '재방문만 집중하자' → 'Prepaint는 나중으로'. 타깃 재정의, 복잡도 절충, 아키텍처 전환을 거치며 배운 오픈소스 기획의 현실적 고민들을 공유합니다.
요즘 웹 개발의 트렌드는 명확합니다. SSR(Server-Side Rendering)과 RSC(React Server Components)로 초기 화면을 빠르게 로드하여 사용자가 빈 화면을 보는 시간을 최소화하는 것입니다. 하지만 모든 프로젝트가 이 트렌드를 따를 수 있는 건 아닙니다. SEO가 필요 없는 B2B 대시보드, 사내 도구, CRM 같은 앱들은 여전히 CSR(Client-Side Rendering)을 선택합니다. 서버 인프라의 비용과 복잡도를 피하면서도, 복잡한 인터랙션을 자유롭게 구현할 수 있기 때문입니다.
FirstTx는 이 간극에서 시작했습니다. "CSR을 쓰면서도 SSR처럼 빠른 초기 화면을 보여줄 수 없을까?" IndexedDB에 저장한 스냅샷을 메인 번들이 도착하기 전에 복원하면, 재방문 시 빈 화면 시간을 0ms로 만들 수 있다는 아이디어였습니다. 낙관적 업데이트의 원자적 롤백과 오프라인 상태 복원까지 더하면, CSR의 한계를 돌파하는 새로운 접근법이 될 거라 생각했습니다.
하지만 실제 개발 과정은 순탄하지 않았습니다. "모든 방문을 빠르게"라는 초기 목표는 3번의 큰 전환을 거쳤습니다. 타깃을 재정의하고, 복잡도를 절충하고, 아키텍처를 근본부터 뒤집었습니다. 이 글에서는 그 과정에서 마주한 기술적 결정들과, 각 전환이 FirstTx의 설계에 어떤 영향을 미쳤는지 공유하려 합니다.
프로젝트를 시작할 때 가장 먼저 정의한 건 문제였습니다. "CSR 앱은 매번 빈 화면을 보여준다." 해결책은 명확해 보였습니다. IndexedDB에 저장된 스냅샷을 부트 스크립트로 즉시 복원하면, 메인 번들이 도착하기 전에도 의미 있는 화면을 보여줄 수 있습니다. 첫 방문이든 재방문이든 상관없이, 모든 방문에서 빠른 경험을 제공하는 것이 목표였습니다.
하지만 곧 첫 번째 모순을 마주했습니다. 첫 방문 시에는 IndexedDB가 비어있다는 사실이었습니다. 스냅샷이 없으니 복원할 것도 없고, 결국 빈 화면을 보여줄 수밖에 없습니다. "그럼 부트 스크립트에서 서버 API를 호출하면 되지 않을까?"라는 생각도 했지만, 그건 결국 SSR과 다를 바 없었습니다. CSR의 장점인 "서버 인프라 최소화"를 포기하는 셈이었습니다.
더 근본적인 질문이 떠올랐습니다. "애초에 FirstTx가 타깃으로 하는 앱들은 첫 방문 최적화가 그렇게 중요할까?" SEO가 필요 없다는 건, 검색 엔진을 통한 랜딩이 아니라는 의미입니다. 로그인이 필요한 B2B 도구, 사내 대시보드, CRM 같은 앱들입니다. 이런 앱들의 특징은 재방문 빈도가 높다는 것입니다. 하루에도 수십 번씩 접속하고, 같은 페이지를 반복적으로 오갑니다. 오히려 첫 방문은 한 번뿐이고, 그 이후의 수백 번의 재방문에서 매번 2초씩 로딩을 기다리는 게 진짜 문제였습니다.
결국 타깃을 재정의했습니다. FirstTx는 재방문 경험에 집중하기로 했습니다. 첫 방문은 일반 CSR 앱처럼 스켈레톤을 보여주되, 두 번째 방문부터는 마지막 상태를 즉시 복원합니다. 이렇게 정의하니 모든 설계 결정이 명확해졌습니다. 부트 스크립트는 스냅샷이 있을 때만 동작하면 되고, 서버 API 호출도 필요 없습니다. 오히려 "재방문이 많은 앱"이라는 명확한 use case가 생기면서, 타깃 사용자도 분명해졌습니다.
// Before: 모든 방문을 커버하려는 복잡한 로직
if (hasSnapshot) {
  restoreSnapshot();
} else {
  // 첫 방문: 서버 호출? 스켈레톤? 정적 템플릿?
  // 결정할 수 없음
}
// After: 재방문에 집중
if (hasSnapshot) {
  restoreSnapshot(); // 0ms
} else {
  // 첫 방문: 그냥 스켈레톤 (일반 CSR과 동일)
}
이 전환으로 얻은 결과는 세 가지였습니다. 첫째, 기술적으로 구현이 훨씬 단순해졌습니다. 서버 통신 로직이나 복잡한 폴백 전략이 필요 없었습니다. 둘째, 성능 목표가 구체화되었습니다. "재방문 시 빈 화면 시간 0ms"라는 명확한 지표가 생겼습니다. 셋째, 마케팅 메시지도 분명해졌습니다. "하루 50번 재방문하는 대시보드라면, FirstTx로 월 33분을 절약할 수 있습니다"처럼 정량적으로 설명할 수 있게 되었습니다.
타깃을 재정의하고 나니 핵심 기능에 집중할 수 있었습니다. Prepaint(스냅샷 복원), Local-First(데이터 관리), Tx(낙관적 업데이트 롤백) 세 개의 패키지 구조가 명확해졌고, 각각의 역할도 정의되었습니다. 특히 Prepaint는 프로젝트의 "얼굴"이었습니다. 재방문 시 0ms에 화면을 복원하는, 가장 눈에 띄는 기능이었으니까요.
목표는 명확했습니다. "개발자가 기존 컴포넌트를 거의 그대로 쓸 수 있게 하자." 파일 상단에 'use prepaint' 지시자만 추가하면, 빌드 타임에 자동으로 부트 스크립트용 코드로 변환되는 구조를 상상했습니다. React 컴포넌트든, styled-components든, Tailwind든 상관없이 모두 지원하는 것이 목표였습니다. 부트 스크립트는 2-5KB로 유지하면서도, 개발자 경험은 최대한 해치지 않으려 했습니다.
하지만 설계를 구체화할수록 현실적인 장벽들이 보이기 시작했습니다. 첫 번째는 CSS 처리였습니다. styled-components는 런타임에 스타일을 주입하는데, 이걸 부트 스크립트에 포함하려면 전체 런타임(약 60KB)이 필요했습니다. 그러면 "2-5KB"라는 목표는 무너집니다. Tailwind는 빌드 타임에 CSS를 생성하지만, 사용하지 않는 클래스를 제거하는 purge 과정과 부트 스크립트 생성을 어떻게 통합할지 막막했습니다. CSS Modules, Emotion, Sass... 각 도구마다 다른 처리 방식이 필요했습니다.
두 번째는 빌드 파이프라인의 복잡도였습니다. 'use prepaint' 지시자를 감지하고, JSX를 문자열 렌더러로 변환하고, 크리티컬 CSS를 추출하고, 다크모드를 고려하고... 각 단계마다 Vite 플러그인, esbuild 플러그인을 개발해야 했습니다. 게다가 사용자가 어떤 CSS 솔루션을 쓰는지에 따라 다른 전략을 써야 했습니다. "개발자 경험을 해치지 않겠다"던 목표는, 역설적으로 상당한 복잡도를 요구하고 있었습니다.
해결책을 찾으려 여러 방향을 고민했습니다. 번들 크기를 60KB까지 늘려서 React를 포함시키는 방법, 별도의 .prepaint.tsx 파일을 만들어 단순한 버전을 작성하게 하는 방법, CSS 솔루션별로 Tier 시스템을 만드는 방법... 하지만 어떤 접근도 만족스럽지 않았습니다. 복잡도는 늘어나는데, 제공하는 가치는 불분명했습니다. "use server" 지시자처럼 불가능을 가능하게 만드는 게 아니라, 그냥 제약만 늘어나는 느낌이었습니다.
이때 다시 생각해보니, Prepaint는 처음부터 "추가로 붙은 기획"이었다는 것입니다. FirstTx의 진짜 핵심은 Local-First와 Tx였습니다. IndexedDB 기반의 오프라인 데이터 관리와, 낙관적 업데이트의 원자적 롤백. 이 두 가지만으로도 충분히 차별화된 가치를 제공할 수 있었습니다. Prepaint는 "있으면 좋은" 기능이지, "없으면 안 되는" 기능은 아니었습니다.
결정을 내렸습니다. Prepaint는 Phase 2로 미루고, Local-First와 Tx를 먼저 완성하기로 했습니다. 복잡한 빌드 파이프라인이나 CSS 처리 전략은 나중 문제였습니다. 지금 당장 필요한 건, 핵심 가치를 검증하는 것이었습니다.
// Before: 모든 것을 한 번에
FirstTx = Prepaint + Local-First + Tx
  → Prepaint 복잡도가 전체를 블로킹
// After: 단계적 접근
Phase 1: Local-First + Tx (핵심 가치 검증)
Phase 2: Prepaint 추가 (있으면 좋은 기능)
이 전환의 결과는 즉각적이었습니다. 첫째, 개발 속도가 빨라졌습니다. CSS 처리나 빌드 도구 통합을 고민하는 대신, 데이터 동기화와 트랜잭션 롤백 로직에 집중할 수 있었습니다. 둘째, MVP 범위가 명확해졌습니다. "useSyncedModel로 서버 동기화 보일러플레이트 90% 제거", "Tx로 원자적 롤백 보장"처럼 측정 가능한 목표들이 생겼습니다. 셋째, 기술적 리스크가 줄어들었습니다. Prepaint의 복잡도가 프로젝트 전체를 위협하는 상황을 피할 수 있었습니다.
Local-First와 Tx에 집중하기로 한 후, Prepaint는 잠시 뒤로 미뤄졌습니다. 하지만 완전히 포기한 건 아니었습니다. 복잡도를 낮추면서도 핵심 가치를 전달할 방법을 계속 고민했습니다. 그러던 중 다시 Prepaint 설계로 돌아왔을 때, v1 아키텍처의 근본적인 문제를 발견했습니다.
v1의 접근은 "템플릿 실행"이었습니다. 'use prepaint' 지시자가 붙은 컴포넌트를 부트 스크립트에서 실행 가능한 형태로 변환하는 것입니다. React 컴포넌트를 순수 함수로 만들고, 이걸 부트 스크립트에 포함시켜서 데이터와 함께 실행하면 DOM이 생성됩니다. 개념적으로는 SSR과 비슷했지만, 클라이언트에서 일어나는 방식이었습니다.
// v1 아키텍처: 템플릿 실행
// 부트 스크립트가 컴포넌트를 실행
const data = await getFromIndexedDB('cart');
const html = CartTemplate(data); // 템플릿 실행
document.body.innerHTML = html;
하지만 이 방식에는 두 가지 치명적인 문제가 있었습니다. 첫째는 앞서 말한 CSS 문제였습니다. CSS-in-JS나 동적 스타일을 제대로 처리하려면 런타임이 필요한데, 그러면 부트 스크립트 크기가 폭발합니다. 둘째는 보안이었습니다. IndexedDB에 저장된 데이터가 템플릿과 함께 실행되는데, 만약 민감한 정보(PII)가 포함되어 있다면? 개발자가 실수로 주민등록번호나 신용카드 번호를 저장했을 때, 이게 스냅샷에 그대로 남아있으면 위험했습니다. 자동으로 감지해서 마스킹하는 건 거의 불가능했고, 책임 소재도 애매했습니다.
대안을 찾다가 Service Worker 기반 접근도 검토했습니다. 하지만 이 역시 복잡도 문제에서 자유롭지 못했습니다. 그러던 중 근본적인 질문을 다시 던졌습니다. "왜 템플릿을 실행해야 하는가?" 재방문 시 보여줄 화면은 이미 사용자가 마지막으로 본 그 화면입니다. 굳이 다시 렌더링할 필요가 있을까요?
답은 간단했습니다. "재생"하면 되는 겁니다. 사용자가 페이지를 떠날 때 DOM과 CSS를 스냅샷으로 저장하고, 돌아왔을 때 그대로 복원하는 것입니다. 템플릿 실행이 아니라 HTML 재생. 이 접근을 "Instant Replay"라고 이름 붙였습니다.
// v3 아키텍처: Instant Replay
// 부트 스크립트가 스냅샷을 재생
const snapshot = await getFromIndexedDB('cart');
if (snapshot) {
  document.body.innerHTML = snapshot.html; // 단순 복원
  injectStyles(snapshot.css);
}
이 전환은 여러 문제를 한 번에 해결했습니다. 첫째, CSS 문제가 사라졌습니다. 어떤 CSS 솔루션을 쓰든, 최종 결과물은 HTML과 CSS일 뿐입니다. 이미 렌더링된 결과를 저장하는 것이므로, 런타임이 필요 없습니다. 둘째, 보안 문제도 명확해졌습니다. 민감한 데이터는 개발자가 직접 제외하거나 마스킹해야 합니다. 프레임워크가 자동으로 해주는 게 아니라, 개발자의 책임입니다. 이게 더 명확한 contract였습니다.
하지만 새로운 도전도 생겼습니다. 스냅샷 DOM과 React가 렌더링한 DOM이 다를 때 어떻게 할까요? React의 hydration은 기존 DOM을 재사용하려 시도하지만, 불일치가 있으면 경고를 내고 전체를 다시 렌더링합니다. 이 "깜빡임"을 어떻게 부드럽게 만들 수 있을까요?
해답은 ViewTransition API였습니다. React의 hydration을 ViewTransition으로 감싸면, DOM 변경이 있을 때 자동으로 부드러운 애니메이션을 적용합니다. 스냅샷과 실제 데이터가 다르더라도, 시각적으로는 자연스러운 전환처럼 보이는 것입니다.
// ViewTransition으로 감싸진 handoff
const strategy = handoff();
if (strategy === 'has-prepaint' && document.startViewTransition) {
  document.startViewTransition(() => {
    hydrateRoot(root, <App />);
  });
} else {
  hydrateRoot(root, <App />);
}
이 아키텍처 전환의 결과는 극적이었습니다. 첫째, 부트 스크립트 크기가 실제로 2KB 이하로 줄어들었습니다. 템플릿 실행 로직이 사라지고, 단순한 DOM 주입과 스타일 적용만 남았으니까요. 둘째, 개발자 제약이 최소화되었습니다. CSS 솔루션을 가리지 않고, 컴포넌트 구조도 자유롭습니다. 이미 렌더링된 결과를 저장하는 것이므로, 무엇을 쓰든 상관없었습니다. 셋째, hydration 실패 케이스의 80%가 ViewTransition으로 부드럽게 처리되었습니다. 나머지 20%는 구조적 변경이라 어쩔 수 없지만, 이는 애초에 Prepaint의 범위 밖이었습니다.
3번의 전환을 거치며 FirstTx는 처음 상상했던 모습과 달라졌습니다. 타깃을 재방문 앱으로 좁히고, Prepaint를 Phase 2로 미루고, Instant Replay 아키텍처로 전환했습니다. 현재 v0.2.1은 Local-First의 useSyncedModel로 서버 동기화 보일러플레이트를 90% 줄였고, Tx는 ViewTransition과 함께 원자적 롤백을 제공합니다. Prepaint는 Vite 플러그인으로 재방문 시 0ms 복원을 구현했습니다.
28개의 테스트가 통과했고, 데모 사이트(firsttx-demo.vercel.app)에서 실제 동작을 확인할 수 있습니다. 다음 단계는 BroadcastChannel을 통한 멀티탭 동기화, 라우터 통합, 그리고 Prepaint의 안정화입니다. 기획은 계속 바뀔 수 있지만, 각 전환은 더 명확한 방향을 찾아가는 과정이었습니다.