DEV_BBAK
← 포스트

SSR을 잘하려면 인프라부터 정돈되어야 한다

·30 min read·로딩중...

최근에 프론트엔드 동료들과 잠깐 이야기를 나누다가 SSR 이야기가 나왔을 때, 한 동료가 이런 말을 했다.

"SSR을 하면 FCP가 늦어져서, 고객이 흰 화면을 보고 있는 시간이 늘어나는 것 같아."

나는 그때 "그건 스트리밍 렌더링으로 풀면 된다"고 답했다. 요즘의 표준 대답이기도 하다. React 18의 Suspense 기반 스트리밍을 쓰면 데이터를 전부 기다리지 않고 셸을 먼저 내려준 뒤, 준비되는 순서대로 화면을 채울 수 있으니까.

그런데 대화가 끝나고 나서 그 전제가 계속 마음에 걸렸다. SSR은 정말 FCP를 늦추는가?

돌이켜보면 답은 명확했다. 인프라가 정돈되지 않은 SSR은 FCP를 늦춘다. 인프라부터 정돈된 SSR에서는 그런 법칙이 성립하지 않는다. FCP가 늦는 이유는 SSR 방식 자체가 아니라, 서버가 데이터를 보러 너무 멀리 가고 있어서인 경우가 대부분이다.

이 글에서는 흰 화면의 원인을 FCP와 TTFB로 분해하고, 서버 응답 시간을 결정하는 인프라의 기본 단위인 존(Zone)을 AWS 문서 기준으로 짚어본 다음, SSR에서 존 배치를 어떻게 다뤄야 하는지 정리한다. 같은 리전에 뒀는데도 남는 문제들과, 페칭을 클라이언트로 옮기는 선택이 포기하는 것까지 함께 본다.

FCP가 늦다는 건, 사실 무엇이 늦다는 건가

FCP(First Contentful Paint)는 브라우저가 처음으로 무언가를 화면에 그리는 시점이다. 텍스트든 이미지든 흰 화면이 아니게 되는 첫 순간이라서, 고객이 흰 화면에 머무는 시간을 이야기할 때 들여다볼 지표다.

SSR과 CSR에서 이 시점은 다른 것에 묶여 있다.

SSR에서 브라우저가 그릴 수 있는 것은 서버가 만든 HTML이다. HTML이 도착해야 그릴 게 생기므로 FCP는 TTFB 뒤에 온다. 서버가 느리면 흰 화면이 길어진다. CSR에서는 HTML이 즉시 도착하지만 내용이 없는 셸이다. 그릴 콘텐츠는 JS 번들이 내려오고 실행된 뒤에야 만들어진다. 흰 화면이 사라지는 시점이 빨라져도 그 공은 서버에 있는 게 아니고, 기다림이 번들 다운로드와 실행으로 옮겨갔을 뿐이다. 정적 셸이나 스켈레톤을 미리 심어두면 CSR에서도 FCP는 빨라지는데, 그러면 중요한 콘텐츠가 늦게 그려지면서 문제가 LCP로 넘어간다.

두 방식 다 흰 화면이 있다. SSR에서는 TTFB가, CSR에서는 JS 다운로드와 실행이 그 시간을 결정한다. "SSR을 하면 FCP가 늦어진다"는 말을 제대로 옮기면 "우리 서버의 TTFB가 늦다"가 된다.

그럼 TTFB는 무엇이 결정하나. TTFB는 두 조각으로 나뉜다. 사용자와 서버 사이의 왕복, 그리고 서버가 요청을 받아 필요한 데이터를 모으고 HTML을 조립하는 시간이다. 첫 조각은 사용자 위치와 리전 선택이 정하고, 둘째 조각은 서버가 데이터를 보러 가는 거리가 정한다. 컴포넌트 트리를 문자열로 만드는 렌더링 자체는 충분히 빠르고, 진짜 시간을 먹는 건 데이터 페칭이다. 서버가 DB와 캐시와 내부 API를 몇 번 왕복하느냐, 한 번 왕복할 때 얼마나 걸리느냐.

왕복 횟수는 애플리케이션 코드가 정한다. 왕복 시간은 인프라가 정한다. 왕복 시간을 가장 크게 좌우하는 변수가 존(Zone)이다.

존의 개념: 리전, 가용 영역, 엣지

AWS의 설명을 그대로 가져오면 리전(Region)은 "AWS가 여러 가용 영역(Availability Zone)을 운영하는, 세계의 물리적 위치"이고, 가용 영역은 "중복 전력, 네트워크, 연결성을 갖춘 하나 이상의 독립적인 데이터센터"로 구성된 리전 안의 격리된 위치다.

  • 리전: 세계 곳곳의 물리적 영역. AWS 기준 39개(2026년 9월). 서울 리전(ap-northeast-2)이 하나의 리전이다.
  • 가용 영역(AZ): 리전 안의 격리된 위치. 전 세계 124개. us-east-1a처럼 리전 코드에 식별자를 붙여 부르고, 하나의 AZ는 하나 이상의 데이터센터다. 리전마다 보통 3개 이상의 AZ가 있다.
  • 엣지 로케이션: CloudFront POP 700곳 이상. 정적 콘텐츠를 사용자 근처에서 캐시하는 층으로, 여기서는 렌더링이 일어나지 않는다.
구간 왕복 지연 (수준) 데이터 전송 비용
같은 AZ (사설 IP) 최소 무료
같은 리전, 다른 AZ (크로스존) 한 자릿수 ms GB당 $0.01, 양 방향 각각
다른 리전 (크로스리전) 수십~수백 ms GB당 수 센트 이상

표에서 보듯 클라우드에서 거리는 물리적 거리가 아니라 몇 개의 경계를 건너느냐다. 서울 리전의 SSR 서버가 오리건 리전의 DB를 보러 가는 순간 요청 하나에 100ms를 넘는 왕복 지연이 기본으로 깔린다.

비용도 같은 문법을 따른다. AWS 아키텍처 블로그는 "Data transfer within the same Availability Zone is free"라고 못 박는다. 같은 존에서 사설 IP로 통신하는 한 무료인데, 과금은 경계를 건너는 왕복에 붙는다. 같은 리전의 다른 존이면 GB당 $0.01이 양 방향에 각각 붙고, 같은 존이더라도 public이나 Elastic IP로 통신하면 과금 대상이 되며, NAT 게이트웨이를 거치면 어디로 가는지와 무관하게 GB당 데이터 처리비가 붙는다. 무료로 열어둔 경로도 있다. RDS Multi-AZ에서 주 인스턴스와 대기 인스턴스 사이의 복제 트래픽은 과금되지 않는다. 장애에 대비하는 길은 무료로 열어두고, 그 외에 경계를 건너는 길에는 요금을 받는 구조다.

왕복은 트래픽에 곱해진다. 서비스 둘이 하루에 1TB씩 크로스존으로 주고받으면 한 방향 1TB에 $0.01/GB가 양쪽에 각각 붙어 하루 $20, 한 달이면 $600이다. 통신량이 많은 마이크로서비스 구조에서 크로스존 요금이 청구서의 독립된 줄로 자라나는 이유다. 요금표 자체가 설계 지침이라는 뜻이기도 하다. AWS조차 같은 존의 사설 통신은 무료로 열어두고 경계를 건너는 왕복에 과금하면서 같은 존 배치를 유인하고 있다.

EC2 문서는 이 원칙을 담백하게 말한다. "인스턴스를 특정 고객과 가깝게 두고 싶다면 그에 맞는 리전을 선택하라." 배치는 추가 최적화가 아니라 클라우드 사용법의 기본 문법이다. AWS는 리전 간에 아무것도 자동으로 복제해주지 않는다. 문서의 표현 그대로 "리소스는 내가 명시하지 않는 한 리전 간에 복제되지 않는다." 데이터의 기본 위치를 정하는 주체는 언제나 나다.

엣지 런타임은 이 문제를 다른 방향으로 푼다. 함수를 사용자 근처, 전 세계 700개가 넘는 POP에서 실행해서 첫 홉을 줄이는데 데이터는 여전히 원점(origin)에 있다. 엣지 함수에서 리전의 DB를 호출하면 그 왕복은 그대로 남는다. 그래서 엣지가 진짜 빨라지려면 데이터도 엣지까지 따라와야 하고, 엣지 KV나 리플리카 같은 데이터 계층이 존재하는 이유다.

노드 런타임에서 SSR을 돌린다면 이 배치 문제를 플랫폼이 대신 해주지 않는다. 서버와 데이터를 어느 존에 둘지, 가용성과 지연을 어떻게 나눌지 전부 스스로 정하고 유지해야 한다. 엣지 런타임이 아니라면 이런 걸 지키는 것까지 프론트엔드 엔지니어링이다. SSR 서버를 데이터와 같은 존에 두는 일은 인프라 팀에 넘길 부탁이 아니라, SSR이 얼마나 빨라질 수 있는지를 정하는 설계다.

이 구도는 플랫폼 업체의 방향에서도 읽힌다. Vercel은 Next.js를 직접 만드는 회사인데, 스트리밍 렌더링과 ISR, 캐싱 개선을 프레임워크에 연달아 넣고 있다. 기술들을 나열해보면 전부 렌더링을 엣지 위에 올리기 위한 부품이다. 엣지 위에 제품이 배치되어야 엣지 네트워크를 파는 비즈니스가 성립하니, 프레임워크의 진화 방향과 플랫폼의 이해가 같은 곳을 향한다.

그런데 그 Vercel의 기본값이 이 글의 논지를 그대로 보여준다. Vercel 함수는 새 프로젝트 기준으로 워싱턴 DC 리전(iad1) 한 곳에서 돈다. 공식 문서가 밝히는 이유가 인상적인데, 대부분의 외부 데이터 소스가 미국 동부에 있어서 가깝게 두기 위해서라고 한다. 배치가 성능을 좌우한다는 걸 벤더 스스로 가장 잘 알고 있고, 기본값조차 데이터와 가까운 곳으로 정했다는 뜻이다. 이 기본값을 모르고 서울 사용자를 위한 SSR을 올리면 요청은 태평양을 건너 다녀온다. 함수를 여러 리전에 두는 것도 플랜에 따라 제한된다.

데이터 쪽도 같은 방향이다. 앞에서 본 엣지 KV나 리플리카 같은 데이터 계층은, 플랫폼 입장에서 보면 렌더링을 엣지에 올릴 때 따라붙는 데이터 상품이다. 그래서 배치 문제는 사라지지 않고 소유주만 바뀐다. 내가 직접 관리하면 엔지니어링이고, 플랫폼에 맡기면 구독이다. 어느 쪽이든 누군가는 존을 정돈하고 있다.

SSR의 존 배치: 왕복 횟수 × 왕복 지연

서버 응답 시간을 대략 이렇게 쓸 수 있다.

TTFB ≈ (데이터 왕복 횟수 × 왕복당 지연) + 조립 시간

인프라가 정돈되지 않았을 때를 보자. SSR 서버는 서울 리전에 있고 인증 API는 다른 리전, 상품 DB는 또 다른 리전에 있다고 하자. 직렬로 호출하면 왕복 두 번에 각각 100ms 넘게 붙어서, 데이터 이동만으로 200ms 이상이 쌓이고 조립 시간이 더해진다. 이 상태에서 컴포넌트를 아무리 튜닝해도 그 바닥은 깎이지 않는다. 렌더링 코드가 좋아지는 것과 서버가 데이터를 보러 가는 거리는 별개의 레이어다.

반대로 SSR 서버와 DB, 캐시를 같은 존에 두면 왕복 한 번이 1~2ms 수준으로 내려온다. 같은 왕복 두 번이어도 데이터 이동은 한 자릿수 ms다. 왕복 횟수가 같아도 배치가 다르면 TTFB의 자릿수가 달라진다. 여기까지 정돈되고 나서야 렌더링 레이어의 최적화, 이를테면 스트리밍이나 컴포넌트 분할이 의미를 갖는다.

배치를 다루는 도구들은 이렇다.

  • 서브넷은 AZ 단위다. SSR 서버를 어느 서브넷에 뒀는지가 곧 어느 존에 있는지를 결정하니, DB 서브넷과 앱 서브넷의 AZ가 일치하는지 확인하는 것부터가 배치 점검이다.
  • placement group의 cluster 전략으로 한 AZ 안에서 인스턴스를 밀착 배치할 수도 있다. AWS가 존보다 좁은 단위까지 배치를 제어하는 도구를 주고 있다는 뜻이다. 클라우드가 위치를 추상화해준다고 착각하기 쉬운 부분이다.
  • 다만 관리형 서비스를 쓰면 통제권이 줄어든다. RDS는 프라이머리가 어느 AZ에 있을지 내가 정하지 못하고 장애 조치 때 옮겨간다. placement group으로 앱을 한 존에 몰아도 DB 프라이머리가 그 존을 벗어나면 크로스존 왕복이 돌아온다.
  • 가용성을 위해 AZ를 분산하면 크로스존 왕복을 감수하고, 지연을 위해 한 존에 모으면 그 존의 장애를 감수한다. 정답이 있는 문제가 아니라 선택의 문제고, 선택하고 문서화하는 일이 엔지니어의 몫이다.
  • 캐시도 배치의 대상이다. 캐시는 계산과 같은 곳에 있어야 효과가 있는데, 캐시 서버가 다른 존이나 다른 리전이면 히트해도 왕복 지연을 그대로 낸다. 페칭 횟수를 줄이려고 넣은 캐시가 지연의 원인이 되는 역설이 여기서 생긴다.

같은 리전 안에 뒀는데도 SSR은 느릴 수 있다

다 같은 리전으로 옮기면 끝인가? 그렇지 않다. 배치는 빨라질 수 있는 선을 정해줄 뿐, 그 아래에서 벌어지는 일들은 여전히 남는다.

왕복 횟수는 여전히 애플리케이션의 몫이다. 같은 존에서 왕복 한 번이 1~2ms라도 직렬 await로 50번 왕복하면 그대로 100ms가 쌓인다. 배치는 왕복당 지연을 깎았을 뿐이고 왕복의 개수는 코드가 정한다. N+1 쿼리, 순차 대기하는 내부 API 호출은 인프라가 아니라 코드 리뷰에서 잡혀야 한다.

크로스존도 왕복이다. 같은 리전과 같은 존은 다르다. 로드밸런서가 트래픽을 AZ에 분산하는데 데이터는 한 존에만 있다면 요청 상당수가 매번 존을 건너게 된다. 한 번에 한두 ms, GB당 $0.01이지만 트래픽이 쌓이면 지연과 비용이 함께 눈에 띄게 쌓인다.

연결도 왕복이다. 요청마다 DB 커넥션을 새로 맺으면 TCP 핸드셰이크와 TLS 협상 왕복이 매번 붙는다. 커넥션 풀은 이 왕복을 나눠 내는 도구인데 풀 크기와 인스턴스 수를 함께 관리해야 한다. 서버리스로 SSR을 돌리면 콜드 스타트 때 커넥션을 다시 맺고, 트래픽이 늘어 인스턴스가 늘면 커넥션 수가 함께 폭증한다. RDS Proxy 같은 프록시 계층이 존재하는 이유다.

여기까지 세 가지는 왕복의 이야기다. 남은 두 가지는 조합과 장애의 이야기다.

SSR 응답은 여러 의존성의 조합이라서, 10개를 병렬로 페칭하면 응답 시간은 가장 느린 하나를 따라간다. 하나의 의존성이 100번 중 1번 500ms를 내면 조립된 페이지의 느린 비율, p99는 그 병목을 그대로 물려받는다. SSR이 느려지기 시작하는 건 평균이 아니라 꼬리에서부터다.

타임아웃과 서킷브레이커가 없으면 하나가 느려졌을 때 전체가 오염된다. 느려진 내부 API에 SSR 워커들이 몰려 대기하고, 워커 풀이 고갈되면 그 화면 전체가 느려진다. 심하면 응답 자체를 하지 못하게 된다. 배치가 아무리 좋아도 장애 전파를 막는 건 운영 레이어의 정책이다.

스트리밍 렌더링의 역할

이제 다시 동료의 문장이다. "SSR을 하면 FCP가 늦어진다."

스트리밍 렌더링이 하는 일은 서버가 모든 데이터를 기다리지 않고 셸을 먼저 내보내는 것이다. 셸이 먼저 도착하니 FCP는 가장 느린 데이터를 기다리지 않아도 되고, 측정되는 TTFB도 함께 내려간다. 그런데 그게 서버가 빨라진 건 아니다. 데이터 왕복 속도는 그대로인 채로 그리는 순서를 바꿔서 체감을 구하는 기술이다.

그래서 두 가지가 동시에 참이다. 데이터 의존이 필수적으로 긴 화면, 이를테면 개인화 피드나 실시간 시세처럼 그 데이터 없이는 의미 없는 화면이라면 스트리밍이 최선일 수 있다. 빠른 셸에 점진적 채움은 그런 화면에서 올바른 설계다. 반면 인프라가 정돈되지 않아 왕복 자체가 느린 상태라면 스트리밍은 흰 화면만 덮는다. FCP는 개선되어도 콘텐츠가 완성되는 시점은 여전히 느리다. 페인트를 빨리 시작하고 늦게 끝내는 셈이다.

스트리밍은 데이터가 느린데 사용자에게 어떻게 보일지를 다루는 렌더링 레이어의 해법이고, 존 배치는 데이터가 왜 느린지를 다루는 인프라 레이어의 해법이다. 둘은 경쟁하지 않는다. 순서가 있을 뿐이다.

그럼 클라이언트에서 페칭하면 안 되나

서버에서 페칭하면 이런 고생이 따르니, 클라이언트로 옮기는 게 낫지 않을까 하는 반론이 남는다. 결론부터 말하면 왕복은 사라지지 않는다. 위치가 바뀔 뿐이고, 그 위치는 경로 전체에서 가장 느린 구간이다.

SSR의 데이터 왕복은 같은 존의 데이터센터 안에서 일어나서 왕복당 1~2ms다. 클라이언트 페칭의 왕복은 사용자 단말에서 시작해 인터넷을 건너 API까지 간다. 왕복당 수십 ms가 보통이고 이동 네트워크라면 더하다. 순서도 늘어난다. HTML 다운로드, JS 번들 다운로드와 실행, 그 다음에야 API 페칭과 렌더가 시작된다. 인라인 스크립트나 프리페치로 워터폴을 좁히는 표준 기법이 있긴 하지만, 어느 쪽이든 사용자 단말이 왕복을 치른다는 사실은 바뀌지 않는다. 서버에서는 병렬로 한 번에 모을 수 있었던 데이터를 클라이언트에서는 늦게 시작하는 셈이다.

페칭을 클라로 옮긴다는 말은 왕복을 가장 빠른 구간에서 가장 느린 구간으로 옮기겠다는 뜻이다. FCP는 빨라질 수 있어도 콘텐츠가 완성되는 시점은 대개 늦어진다.

캐시 이야기가 먼저다. 캐시 가능한 화면이라면 서버가 렌더링한 HTML을 CDN에 통째로 올릴 수 있고, 한 번 캐시된 페이지를 만 명이 봐도 새로운 데이터 페칭은 없다. 클라이언트 페칭은 캐시 단위가 API 응답으로 쪼개지고, 모든 사용자의 단말이 각자 API를 호출한다.

보안과 호환성도 포기 대상이다. SSR 서버가 같은 존의 사설 네트워크에서 내부 API를 호출하는 동안에는 그 API를 인터넷에 그대로 노출하지 않아도 된다. 클라이언트가 직접 호출하는 순간 공개 엔드포인트, CORS, 클라이언트의 토큰 관리까지 챙겨야 한다. 구글은 JS를 실행하지만 렌더링이 별도 큐로 밀리면서 색인이 늦어질 수 있고, SNS 링크 미리보기(OG 태그)처럼 HTML을 그대로 읽는 소비자는 JS를 실행하지 않는다. 호출의 통합도 사라진다. 서버는 열 개의 내부 호출을 하나의 응답으로 묶어 내려줄 수 있는데, 클라이언트가 직접 부르면 호출 수만 단말에서 인터넷을 건너는 왕복으로 늘어난다.

클라이언트 페칭이 맞는 화면도 있다. 로그인 후 개인화 화면처럼 캐시 히트율이 0에 수렴하고 인터랙션이 먼저인 화면이라면 빈 셸에 클라 페칭이 올바른 설계다. 스트리밍 섹션과 같은 구도인데, 답은 화면의 성격이 정한다.

마무리: 흰 화면을 SSR 탓하기 전에

SSR 애플리케이션에서 흰 화면이 길다면 렌더링 코드를 의심하기 전에 이 순서로 점검한다.

  1. SSR 서버와 주 데이터소스(DB, 캐시)가 같은 리전인가? 같은 존인가?
  2. 요청당 데이터 왕복은 몇 번인가? 왕복이 직렬로 쌓이고 있지는 않은가?
  3. 캐시가 계산과 같은 존에 있는가? 캐시가 죽었을 때 우회로가 왕복 지연을 곱하지는 않는가?
  4. 타임아웃과 재시도 정책이 왕복 지연을 배가하지 않는가? 느린 의존성에 무한히 기다리는 것도 폭풍처럼 재시도하는 것도 같은 병이다.
  5. 그 다음에야 렌더링 레이어다. 스트리밍으로 셸을 먼저 내보낼지, 어느 Suspense 경계에서 끊을지 설계한다.

왜 이 순서인가. 인프라가 얼마나 빨라질 수 있는지를 정하고 렌더링은 그 안에서 일하기 때문이다. 서버가 느린 상태에서 렌더링만 튜닝하는 건 천장 낮은 방에서 페인트칠을 다시 하는 일이고, 천장을 올리는 일은 존 배치와 왕복 횟수, 캐시 위치에서 일어난다. 엣지 런타임이 아니라면 그 일을 대신해주는 사람이 없다.

"SSR을 잘한다"는 말을 우리는 종종 컴포넌트 설계와 렌더링 API의 숙련도로만 읽는다. 그런데 SSR은 서버가 데이터를 가져다가 화면을 만드는 일이고, 데이터를 얼마나 먼 데서 가져오는지는 인프라가 정한다.

다음에 "SSR 때문에 FCP가 늦다"는 말을 들으면, 렌더링 코드보다 먼저 물어야 할 질문이 하나 있다. 우리 서버는 지금 데이터를 보러 어디까지 가는가.