LabHub
배우기 러닝패스 코스

SSR — 서버가 먼저 그린다 · 첫 화면 뒤에 여섯 번을 더 다녀왔다 · 이론

사라진 시간은 렌더가 아니라 왕복에 있다

LabHub 에서 이어서 보기

한 줄 요약

서버 렌더가 빨라도 데이터를 부르는 순서가 잘못돼 있으면 첫 화면 뒤로 왕복이
줄줄이 이어진다. 경계를 어디에 두느냐가 그 순서를 정한다.

왜 이게 필요했나

SSR 을 넣었는데 체감이 별로 나아지지 않았다는 이야기가 흔하다. 서버 렌더 시간을
재 보면 30ms 인데 사용자가 실제로 쓸 수 있게 되기까지는 2초가 걸린다. 사라진
시간은 렌더가 아니라 왕복에 있다.

전형적인 모양은 이렇다. 페이지가 사용자 정보를 받고, 그 안의 팀 id 로 팀을 받고,
그 팀의 프로젝트 목록을 받고, 각 프로젝트의 최근 활동을 받는다. 코드로 보면
자연스러운 중첩인데, 네트워크로 보면 줄 세운 네 번의 왕복이다. 지연이 200ms
면 800ms 가 그냥 사라진다.

더 나쁜 형태도 있다. 컴포넌트마다 자기 데이터를 자기가 받으면, 부모가 렌더돼야
자식이 존재하고 자식이 존재해야 그 요청이 나간다. 컴포넌트 트리의 **깊이가 곧
왕복 횟수**가 되는 구조다.

어떻게 동작하나

서로 기다릴 이유가 없는 것은 함께 보낸다

폭포수를 없애는 첫 걸음은 "이 요청이 앞 응답의 값을 정말 쓰는가" 를 묻는 것이다.
대부분은 안 쓴다. 팀 정보와 알림 목록은 서로 몰라도 되는데, 코드 모양 때문에
줄을 서 있었을 뿐이다. 그런 것은 한 번에 내보내면 왕복이 하나로 줄어든다.

정말 앞 응답이 필요한 경우라면 요청을 합치는 쪽으로 간다. 서버에서 한 번에
조립해 내려보내면 클라이언트의 왕복은 한 번이고, 서버 안의 질의는 같은 데이터
센터 안에서 일어난다.

무엇을 기다릴지는 경계가 정한다

React 의 스트리밍 렌더에서 <Suspense> 경계 에 있는 부분을 셸(shell)이라
부른다. 셸은 먼저 내려가고, 경계 안쪽은 준비되는 대로 뒤이어 흘러간다
([renderToPipeableStream](https://react.dev/reference/react-dom/server/renderToPipeableStream)).
그래서 느린 데이터를 경계 안으로 넣는 것이 설계의 전부다. 느린 것 하나가
셸에 남아 있으면 그 하나 때문에 첫 바이트가 통째로 늦어진다.

주의할 것이 하나 있다. 경계를 활성화하는 것은 경계를 활성화하는 자료원
읽었을 때뿐이다. React 문서는 use 로 읽은 프라미스 같은 것을 예로 들면서,
이펙트나 이벤트 핸들러 안에서 받은 데이터는 렌더를 멈추지 않는다고 못을
박는다([Suspense](https://react.dev/reference/react/Suspense)). 경계를 감싸 놓고
데이터는 이펙트에서 받으면 폴백이 보이지 않고, 첫 화면에는 빈 자리가 그려진 채로
HTML 이 나간다 — 그리고 그 빈 자리가 하이드레이션 불일치의 재료가 된다.

경계를 잘게 쪼갤수록 첫 화면은 빨라지지만 화면이 여러 번 덜컥거린다. 경계를
크게 잡으면 조용한 대신 늦다. 여기서 기준은 취향이 아니라 **그 자리가 사용자가
보자마자 쓰는 것인가**다.

캐시와 개인화는 같은 응답에 못 산다

데이터를 미리 채운 HTML 은 그 자체가 그 사용자의 데이터다. ssr-core 에서
본 것처럼 개인화된 응답에 공용 캐시 지시를 붙이면 CDN 이 남의 페이지를 준다.
그런데 응답 헤더만 문제가 아니다. 같은 주소가 조건에 따라 다른 내용을 내보낸다면,
그 조건을 캐시가 알아야 한다. 그 역할이 Vary 다 — 메서드와 URL 말고 **요청의
어떤 부분이 응답 내용에 영향을 줬는지**를 적는 헤더이고, Vary: * 는 요청 헤더
밖의 요인이 개입했다는 뜻이라 캐시할 수 없음을 함의한다
([Vary](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Vary)).

실무에서 쓸 만한 갈래는 이렇다. 공용으로 캐시할 수 있는 셸을 먼저 내려보내고,
사람마다 다른 조각은 경계 안에서 따로 받는다. 그러면 캐시는 셸에만 걸리고
개인화는 경계 안쪽에 갇힌다 — 하이드레이션 불일치를 가두는 경계와 같은 선이다.

현장에서 만나는 모습

"서버 렌더는 30ms 인데 화면은 2초" 라는 제보의 대부분은 폭포수다. 확인하는
방법은 프로파일러가 아니라 네트워크 탭의 폭포 그림이다. 요청 막대들이
계단처럼 어긋나 있으면 줄을 선 것이고, 나란히 시작하면 병렬이다. 계단이 몇
칸인지가 곧 없앨 수 있는 왕복 수다.

경계를 감싸 놓고 데이터는 이펙트에서 받는 코드도 자주 본다. 화면에는 스피너가
잘 보여서 맞게 동작하는 것처럼 느껴지지만, 그 스피너는 스트리밍 폴백이 아니라
클라이언트 상태다. 서버가 내려보낸 HTML 에는 빈 자리만 있고, 첫 화면의 값은
여전히 브라우저가 받은 뒤에야 생긴다.

마지막으로, 로그인 여부에 따라 다른 HTML 을 내보내면서 Vary 를 안 적어 둔
페이지는 CDN 앞에서 언젠가 반드시 사고를 낸다. 캐시가 처음 받은 쪽을 모두에게
주기 때문이다.

경계를 고르는 일은 결국 한 문장으로 줄어든다. **사용자가 보자마자 쓰는 것은
셸에, 조금 기다려도 되는 것은 경계 안에.** 그 선을 그으면 첫 바이트가 무엇에
묶이는지, 어디까지 공용으로 캐시할 수 있는지, 불일치가 어디까지 번지는지가
한꺼번에 정해진다. 세 가지가 같은 선 위에 있다는 것이 SSR 설계에서 가장 늦게
깨닫게 되는 사실이다.

다음 퀴즈에서 확인할 것

폭포수를 알아보는 자리, 셸이 무엇인지, 경계를 활성화하는 것이 무엇인지,
개인화된 응답에 Vary 가 왜 필요한지를 여섯 문항으로 확인한다.