이번엔 진짜로 빠르게 — 서버가 다 그려서 보냅니다
지난 두 편은 빠르게 "느껴지게" 만든 이야기였어요. 이번엔 속임수 없이 진짜 시간을 줄입니다. 빈 화면을 없애고, 지구 반대편 왕복을 없애고, 안 쓰는 데이터를 없앴어요.

체감 속도로 할 수 있는 건 다 했어요. 그런데 첫 화면은 여전히 잠깐 하얬습니다.
프리패치도 붙였고, 클릭하면 화면이 먼저 움직이게도 했죠. 그건 전부 "이미 도착한 다음"의 이야기예요.
도착하기 전은 못 속입니다. 그래서 이번엔 진짜 시간을 줄이기로 했어요.
무엇이 시간을 먹고 있었는지부터 갈라 볼게요. 손댈 데를 정하려면 이 표가 먼저 필요했어요.
| 구간 | 전 | 무엇을 했나 |
|---|---|---|
| 서버가 첫 HTML을 만드는 시간 | 빈 껍데기라 거의 0 | 실제 화면을 그리게 함 — 여기는 오히려 늘어남 |
| 브라우저가 JS 받고 실행 | 여기서 전부 결정됨 | 줄어듦 — 이미 그려진 위에 손만 얹음 |
| 한 요청 안의 DB 왕복 | 여러 번, 각자 따로 | 하나로 묶음 |
| 서버와 DB 사이 거리 | 대륙 넘어감 | 같은 지역으로 |
| 전송 바이트 | 안 쓰는 데이터까지 | 목록 전용 읽기 모델 |
첫 줄이 중요해요. 서버 렌더링은 공짜가 아니에요. 서버가 일을 더 하니까 첫 바이트까지의 시간은 오히려 늘어납니다. 대신 그 뒤가 통째로 사라지고요.
그래서 이건 "빨라진다"가 아니라 "일을 어디서 할지 옮긴다"에 가까워요. 브라우저는 성능이 제각각이지만 서버는 우리가 통제할 수 있으니까, 옮길 만한 거래였습니다.
빈 방을 먼저 보내고 있었어요
플랫폼 첫 화면은 전형적인 그 구조였습니다. 서버는 텅 빈 껍데기를 보내고, 브라우저가 자바스크립트를 받고, 실행하고, 그제서야 화면을 그려요.
사용자 입장에서는 이래요. 주소를 눌렀다 → 하얀 화면 → (기다림) → 화면.
그래서 순서를 바꿨어요. 이제 서버가 인증 설정과 공개 사이트 목록을 동시에 조회해서 실제 화면을 다 그려서 보냅니다. 브라우저는 그 위에 손만 얹어요.
여기에 하나 더 깔았습니다. 서버 렌더링에 문제가 생기면 예전 방식으로 조용히 돌아가게요. 빨라지자고 안 뜨는 화면을 만들 순 없으니까요.
(그리고 첫 화면 마크업은 아예 빌드 결과물에 박아뒀어요. 자바스크립트가 도착하기 전에도 글자는 읽힙니다.)
되돌아갈 길을 먼저 깐 게 이번 작업에서 제일 잘한 부분이에요.
// 서버 렌더링이 실패해도 화면은 떠야 합니다
let html;
try {
html = renderToString(<App context={context} />);
} catch (error) {
logRenderFailure(error);
html = shellHtml; // 예전 방식 — 빈 껍데기 + 클라이언트 렌더
}
// 사용자는 조금 느린 화면을 보고,
// 우리는 로그를 받습니다. 안 뜨는 화면은 없어요.서버 렌더링은 실패할 자리가 많아요. 브라우저에만 있는 걸 렌더 중에 읽는 코드가 하나만 섞여도 터지거든요. 그리고 그런 코드는 앞으로도 계속 들어올 겁니다.
그래서 "터지지 않게 잘 짜자"가 아니라 "터져도 화면은 뜬다"로 갔어요. 앞쪽은 사람의 규율에 기대는 거고, 뒤쪽은 구조로 보장하는 거니까요.
지구를 몇 번씩 돌고 있었어요
공개 페이지 쪽은 더 재미있는 문제였어요.
이 블로그는 요청이 올 때마다 DB를 봅니다. 어느 사이트인지 찾고, 어떤 버전이 공개돼 있는지 찾고, 글 내용을 가져오죠.
그런데 이 조회들이 각자 따로 연결을 맺고 있었어요. 한 번의 페이지 요청 안에서요.
그래서 요청 하나가 연결 하나를 쓰고 같이 끝나도록 묶었습니다. 그렇다고 연결을 전역에 두고 재사용하진 않았어요 — 그건 다른 종류의 사고를 부르거든요.
연결 문제는 이렇게 생겼었어요.
// 전 — 조회마다 각자 연결을 잡습니다
const site = await withConnection(c => c.findSite(host));
const release = await withConnection(c => c.findRelease(site.id));
const post = await withConnection(c => c.findPost(release.id, slug));
// 한 페이지 요청에 연결 세 번
// 후 — 요청 하나가 연결 하나를 쓰고 같이 끝납니다
await withConnection(async (c) => {
const site = await c.findSite(host);
const release = await c.findRelease(site.id);
const post = await c.findPost(release.id, slug);
return render(site, release, post);
});| 방식 | 연결 비용 | 위험 |
|---|---|---|
| 조회마다 연결 | 요청당 여러 번 | 거리가 멀수록 곱해짐 |
| 요청 단위로 묶기 | 요청당 한 번 | 없음 |
| 전역에 하나 두고 재사용 | 거의 없음 | 요청끼리 상태가 섞이고, 끊기면 전부 죽음 |
세 번째를 안 고른 게 중요해요. 제일 빨라 보이지만, 요청끼리 연결을 공유하면 한 요청의 실패가 다른 요청으로 번집니다. 빠르자고 격리를 버리는 거래는 안 했어요.
그리고 위치
이 블로그를 오래 보신 분은 익숙한 이야기일 텐데요. 예전에 인증이 2초를 먹던 사건에서, API를 DB 옆 도쿄로 옮긴 적이 있어요.
이번에 보니 공개 페이지를 그리는 쪽은 아직 안 옮겨져 있었습니다. 한 요청 안에서 DB를 여러 번 보고 API도 부르는데, 그러면서 매번 멀리 다녀오고 있었던 거죠.
같은 도쿄로 붙였습니다. 정적 파일은 원래 정책 그대로 두고요.
그때 제가 쓴 문장이 이번에 또 맞았어요. 병목은 순서대로만 보입니다. 앞의 2초를 지우기 전까지, 뒤에 있던 이 왕복은 보이지도 않았거든요.
안 쓰는 데이터를 보내고 있었어요
블로그 목록 화면이 필요한 건 제목, 요약, 날짜, 대표 이미지 정도예요.
그런데 실제로는 글마다 태그를 다 끌고 오고, 반복되는 메타데이터까지 실어 보내고 있었습니다. 화면에서 안 쓰는 것들을요.
그래서 목록 전용 읽기 모델을 따로 팠어요. DB에서 덜 읽고, 덜 직렬화하고, 덜 보냅니다.
편집 화면도 같이 손봤습니다. 글 하나 고치려고 들어가는데 태그 전체, 시리즈 전체, 수정 이력까지 미리 가져오고 있었거든요. 이제 필요한 순간에 가져와요.
그리고 읽기 모델을 나눈 건 이런 모양이에요.
// 목록 화면이 실제로 쓰는 것
type PostListItem = {
slug: string;
title: string;
excerpt: string;
publishedAt: string;
thumbnail?: { src: string; alt: string };
};
// 전에는 여기에 tags 전체, 시리즈 정보,
// 반복되는 메타데이터까지 실려 나갔습니다별거 아닌 것 같은데 목록은 한 화면에 스무 개씩 나가요. 항목마다 안 쓰는 필드가 붙으면 스무 배가 됩니다.
편집 화면도 같이 손봤어요. 글 하나 고치러 들어가는데 태그 전체, 시리즈 전체, 수정 이력까지 미리 가져오고 있었거든요. "혹시 필요할까 봐" 미리 가져오는 코드는 대부분 안 쓰입니다.
이미지가 제일 무거웠고요
사실 전송량의 진짜 범인은 이미지였어요.
원본 PNG가 그대로 나가고 있던 경로가 있었습니다. 그걸 이미지 변환이 작동하는 경로로 되돌리고, 새로 올라오는 이미지는 WebP로 정규화했어요.
그리고 대표 이미지가 아닌 것들은 화면에 들어올 때까지 안 받습니다. 스크롤을 안 내리면 영영 안 받는 거죠.
(점수 놀이는 안 했어요. 목표는 Lighthouse 숫자가 아니라 실제로 오가는 바이트였습니다. 우회로 올린 점수는 폰에서 티가 나거든요.)
이미지는 세 가지를 갈랐어요.
| 문제 | 전 | 후 |
|---|---|---|
| 원본이 그대로 나감 | PNG 원본 | 변환 경로를 타게 되돌림 |
| 형식 | 올린 그대로 | 새로 올라오는 건 WebP로 정규화 |
| 언제 받나 | 전부 즉시 | 대표 이미지 외엔 화면에 들어올 때 |
세 번째가 목록 화면에서 제일 컸어요. 스무 개 글의 이미지를 다 받는데, 실제로 보는 건 처음 서너 개거든요. 스크롤을 안 내리면 영영 안 받습니다.
점수 놀이는 안 했어요. 목표는 도구 숫자가 아니라 실제로 오가는 바이트였습니다. 우회로 올린 점수는 폰에서 티가 나거든요.
그래서 결론은
첫 응답까지 걸리는 시간을 0.5초 수준으로 잡는 게 이번 목표였어요.
그런데 숫자보다 마음에 드는 건 따로 있습니다. 이제 주소를 누르면 하얀 화면이 없어요. 처음부터 글자가 있습니다.
체감 속도는 기다림을 견디게 해주고, 실제 속도는 기다림을 없애요. 둘 다 필요하더라고요. 순서만 다를 뿐이었습니다.
그런데 서버가 다 그려서 보내기 시작하니까, 전에는 없던 종류의 버그가 생겼어요. 서버가 그린 것과 브라우저가 그린 것이 어긋나는 문제요. 다음 편은 그 이야기입니다.

