본문으로 건너뛰기

실행 경계와 렌더링 전략 - 디자인 패턴, 시스템 수준 편 ep.04

같은 함수여도 빌드 타임에 돌면 상수가 되고, 서버에서 돌면 요청마다 달라지고, 브라우저에서 돌면 사용자마다 달라진다.


이 경계는 무엇을 청구하는가

이제 코드가 실제로 동작하는 순간을 다룹니다.

개발자는 한 줄의 코드가 어디에서 실행될지 선택할 수 있습니다. 상품 목록을 가져오는 코드를 생각해 봅니다.

  • 빌드 타임에 실행하면 결과가 HTML에 박혀 나갑니다. 요청이 오면 파일을 그대로 내려주면 됩니다. 가장 빠릅니다. 대신 재고가 바뀌어도 다음 빌드까지 반영되지 않습니다.
  • 서버 런타임에 실행하면 요청마다 최신 데이터가 나옵니다. 대신 요청마다 서버가 일을 합니다.
  • 브라우저에서 실행하면 서버의 리소스를 절약하며 실시간 인터랙션을 강화할 수 있습니다. 대신 코드를 불러올 때까지 사용자가 빈 화면을 보는 시간이 생기고, 검색 엔진은 아무것도 보지 못할 수 있습니다.

이 경계가 청구하는 것은 최신성과 속도의 교환입니다. 빌드 타임으로 갈수록 빠르고 낡습니다. 브라우저로 갈수록 개인화되고 느립니다.

실행 경계가 답하는 질문은 하나입니다. 이 작업은 언제, 어디서 실행되어야 하는가.


이 경계에 붙어 있는 이름들

두 축으로 그린 사분면

실행 지점은 두 축으로 갈립니다.

  • 언제: 빌드 타임 ↔ 런타임
  • 어디서: 서버 ↔ 클라이언트

프론트엔드 렌더링 전략: SSG, ISR, SSR, CSR, RSC, Islands는 이 사분면 위의 좌표입니다. 이름을 외우는 대신 좌표로 이해하면 좋습니다.

전략언제어디서최신성첫 화면
SSG빌드 타임서버빌드 시점가장 빠름
ISR빌드 + 주기적 재생성서버재검증 주기빠름
SSR요청 시점서버항상 최신서버 응답 후
CSR로드 후클라이언트항상 최신가장 느림
RSC요청 시점서버 (JS 미전송)항상 최신빠름
Islands빌드 또는 요청 시점 + 로드 후서버 + 클라이언트 조각HTML 생성 전략에 따름빠름

실행 지점의 사분면 다이어그램. 가로축은 실행 위치로 좌측이 서버, 우측이 클라이언트이고, 축 라벨의 색이 그대로 각 전략 칩의 테두리 색과 사분면 배경 색으로 쓰입니다. 서버에서 실행되는 것은 주황, 클라이언트에서 실행되는 것은 초록입니다. 세로축은 실행 시점으로 상단이 빌드, 하단이 런타임입니다. 좌상단 빌드 곱하기 서버 사분면에는 SSG(빌드 시점 고정)와 ISR(주기적 재생성) 두 칩이 나란히 놓이고, '요청이 오면 만들어 둔 파일을 내려줍니다', '가장 빠름 · 가장 낡음', '최신성: 빌드 시점 · 재검증 주기', '첫 화면: 가장 빠름'이 적혀 있습니다. 좌하단 런타임 곱하기 서버 사분면에는 SSR(요청 시점 렌더)과 RSC(JS 미전송)가 놓이고, '요청마다 서버가 화면을 만듭니다', '항상 최신 · 서버 부하', '최신성: 항상 최신', '첫 화면: 서버 응답 후'가 적혀 있습니다. 우하단 런타임 곱하기 클라이언트 사분면에는 CSR(브라우저 렌더)과 Islands(부분 hydration)가 놓이고, '브라우저가 도착한 뒤 화면을 그립니다', '개인화 가능 · 첫 화면 지연', '최신성: 항상 최신 · HTML 생성 전략에 따름', '첫 화면: 가장 느림 · Islands는 빠름'이 적혀 있습니다. 우상단 빌드 곱하기 클라이언트 사분면은 점선 테두리에 큰 X 표시만 있고 '빌드 시점에는 브라우저가 존재하지 않습니다'라고 적혀 있습니다. 하단에는 좌측 '빠르고 낡음'에서 우측 '최신이고 느림'으로 향하는 화살표가 주황에서 초록으로 변하는 그라데이션으로 그려져 있고, '이름을 외우는 대신 좌표로 이해하면, 새 이름이 나와도 자리를 찾을 수 있습니다'라는 문장이 붙어 있습니다.

당연히 빌드 시점에는 브라우저가 존재하지 않습니다. 그래서 우상단 사분면이 비어있습니다.

표에서 Islands만 ‘언제’와 ‘어디서’에 값이 둘씩 붙어 있습니다. HTML은 서버에서 만들고, 인터랙션이 필요한 조각만 클라이언트 런타임에서 살립니다. 사분면 위의 한 점이 아니라 두 좌표를 이어 쓰는 전략입니다. 다이어그램에서는 실제로 JavaScript가 도는 자리를 기준으로 오른쪽 아래에 놓았습니다.

같은 코드, 다른 의미

실행 위치가 코드의 의미를 바꿉니다.

// 이 한 줄이 어디서 도는가에 따라 전혀 다릅니다
const now = new Date()
  • 빌드 타임: 배포한 시각이 영원히 박힙니다. 사용자는 몇 달 전 시각을 봅니다.
  • 서버 런타임: 서버 시간대 기준의 현재 시각입니다.
  • 브라우저: 사용자 기기의 시각입니다. 시간대도 다르고, 기기 시계가 틀렸을 수도 있습니다.
// 서버에서만 안전합니다
const apiKey = process.env.PAYMENT_SECRET

이 비밀 키가 클라이언트 번들에 들어가 노출되면 비밀이 아니게 됩니다. 그래서 프레임워크들은 실행 위치를 코드에 표시하도록 만들었습니다. Next.js의 'use client', Astro의 아일랜드 지시자, Remix의 loader와 컴포넌트 분리가 모두 같은 문제에 대한 해결책입니다.

// app/products/page.tsx — 서버 컴포넌트 (기본값)
async function ProductsPage() {
  // 서버에서만 실행됩니다. DB에 직접 접근해도 됩니다.
  const products = await db.product.findMany()
  return <ProductGrid products={products} />
}
'use client'
// 이 파일부터는 브라우저로 전송됩니다
export function AddToCartButton({ id }: { id: string }) {
  const [pending, setPending] = useState(false)
  // useState는 브라우저에서만 의미가 있습니다
}

'use client'는 “클라이언트에서만 실행하라”가 아니라 “여기부터 경계”라는 표시입니다. 그 아래로는 번들에 포함되고, 그 위는 서버에 남습니다.

hydration이라는 비용

서버에서 HTML을 만들어 보내고 브라우저에서 인터랙션을 붙이는 방식에는 hydration(하이드레이션) 이라는 중간 단계가 있습니다.

hydration은 서버가 보낸 정적인 HTML에 이벤트 핸들러와 상태를 다시 연결해, 그 DOM을 반응하는 컴포넌트로 바꾸는 작업입니다. 점입니다. 이때 DOM을 새로 만들지는 않습니다. 이미 화면에 있는 DOM은 그대로 두고, 같은 컴포넌트 트리를 브라우저에서 한 번 더 실행해 어느 노드가 어느 컴포넌트인지 맞춰 놓습니다. 마른 HTML에 JavaScript를 부어 움직이게 만든다는 뜻에서 ‘수분 공급’이라는 이름이 붙었습니다.

서버가 보낸 HTML은 그 자체로는 그림일 뿐이라 버튼이 그려져 있지만 눌러도 아무 일이 일어나지 않습니다. 브라우저가 JavaScript를 내려받고 실행해 hydration을 마쳐야 비로소 버튼이 동작합니다.

HTML은 빠르게 오지만, JavaScript가 로드되고 실행되기까지의 사이에 보이지만 눌리지 않는 구간이 생깁니다. 사용자가 가장 답답해하는 순간입니다. 화면은 완성된 것처럼 보이는데 아무 반응이 없습니다.

이 구간을 줄이는 방법은 세 가지입니다. 줄이는 대상이 각각 다릅니다.

  • Islands Architecture: hydration 하는 범위를 줄입니다. 페이지 전체가 아니라 인터랙션이 필요한 조각만 되살리고, 정적인 부분은 영영 JavaScript를 받지 않습니다. 죽은 구간이 조각 단위로 쪼개집니다. Astro가 이 방식입니다.
  • Streaming SSR: hydration이 시작되는 시점을 앞당깁니다. HTML을 한 번에 다 보내지 않고 준비된 부분부터 흘려보내므로, 느린 데이터를 기다리는 동안 나머지는 이미 화면에 있습니다. 죽은 구간이 뒤로 미뤄지는 대신 짧게 여러 번 나뉩니다.
  • React Server Components: hydration 할 코드를 아예 보내지 않습니다. 앞의 둘이 hydration의 범위와 시점을 손보는 것이라면, 이쪽은 hydration이라는 작업 자체를 없앱니다. 서버 컴포넌트는 실행 결과만 내려가고 컴포넌트 코드는 번들에 담기지 않습니다. 받지 않은 코드에는 다시 실행할 것도 없으니, 그 부분에는 죽은 구간이 처음부터 생기지 않습니다.

hydration 비용의 타임라인 다이어그램. 상단 범례는 네 가지 색을 정의합니다. 파랑은 'HTML 도착', 보라는 'JS 다운로드·실행', 주황은 '보이지만 눌리지 않음', 초록은 '사용 가능'입니다. 가로축은 왼쪽에서 오른쪽으로 흐르는 시간이고, 오른쪽 끝에는 '전송 JS' 열이 따로 있어 방식별 JavaScript 전송량을 막대와 백분율로 비교합니다. 세 가지 방식이 같은 형식의 카드로 세로로 쌓여 있고, 각 카드는 시간 막대와 그 아래 '눌리지 않는 구간' 막대로 이루어집니다. 첫 번째 '전체 hydration'은 '이벤트를 붙이려고, 컴포넌트 트리 전체를 다시 실행합니다'로 설명됩니다. 짧은 파랑 뒤에 아주 긴 보라가 이어지고 맨 끝에서야 초록으로 바뀌며, 그 보라 구간 전체가 주황으로 덮여 '페이지 전체가 길게'로 표시됩니다. 전송 JS는 100%입니다. 두 번째 'Islands Architecture'는 '정적 영역은 JS를 받지 않고, 인터랙션 조각만 hydration 합니다'로 설명됩니다. 파랑이 끝나는 지점부터 곧바로 초록이 끝까지 이어지고, 그 위에 작은 보라 조각 세 개만 얹혀 있습니다. 주황은 그 세 조각 아래에만 짧게 나타나 '조각마다 짧게'로 표시됩니다. 전송 JS는 35%입니다. 세 번째 'Streaming SSR + RSC'는 '준비된 조각부터 흘려보내고, 서버 컴포넌트는 hydration 하지 않습니다'로 설명됩니다. 첫 조각이 도착한 직후부터 초록이 끝까지 이어지고, 그 뒤로 얇은 파랑 표시 두 개가 나중에 도착하는 조각을 나타냅니다. 보라 조각은 하나뿐이고 주황도 그 아래에만 있어 '클라이언트 조각만 짧게'로 표시됩니다. 전송 JS는 20%로 가장 적습니다. 하단에는 '서버가 보낸 HTML은 그림이고, 눌리려면 JavaScript가 붙어야 합니다'와 '화면이 완성되어 보이는데 반응이 없는 구간이 가장 답답한 순간입니다'가 적혀 있습니다.

BFF

실행 경계를 이야기할 때 빠뜨릴 수 없는 구조가 BFF(Backend for Frontend) 입니다.

프론트엔드가 여러 마이크로서비스에서 데이터를 모아야 할 때, 브라우저가 직접 여섯 번 호출하면 네트워크 왕복이 여섯 번입니다. 그 사이에 서버를 하나 두면 프론트엔드와의 왕복이 한 번으로 줄고, 백엔드 서버끼리의 호출도 훨씬 빨라집니다.

// BFF 라우트 — 서버에서 세 곳을 모아 화면이 필요한 모양으로 돌려줍니다
export async function GET(req: Request) {
  const [user, orders, coupons] = await Promise.all([
    userService.get(userId),
    orderService.listRecent(userId),
    couponService.listAvailable(userId),
  ])

  // 화면이 원하는 모양으로 조립합니다
  return Response.json({
    displayName: user.nickname ?? user.name,
    recentOrders: orders.slice(0, 3).map(toOrderSummary),
    couponCount: coupons.length,
  })
}

BFF는 ep.02 의존 방향에서 본 어댑터가 네트워크 단으로 옮겨간 형태입니다. 화면이 원하는 모양을 화면 쪽에서 정의하고, BFF가 그 모양을 만들어 냅니다. 화면마다 BFF를 따로 두는 것이 원래 의도입니다. 모바일 앱과 웹이 같은 BFF를 쓰기 시작하면, 그것은 그냥 또 하나의 범용 API 게이트웨이입니다.

안티패턴

모든 것을 서버로: RSC가 나온 뒤 흔해진 실수입니다. 입력값에 따라 즉시 반응해야 하는 UI를 서버 왕복으로 처리하면 체감이 나빠집니다. 타이핑에 따라 필터되는 목록, 드래그, 애니메이션은 클라이언트의 일입니다.

모든 것을 클라이언트로: 정적인 마케팅 페이지를 CSR로 만들면 첫 로딩이 느리고, 검색 엔진이 내용을 보지 못할 수 있습니다.

경계를 흐리는 유틸: 서버와 클라이언트 양쪽에서 import 되는 파일에 process.env나 window 접근이 섞이면, 어느 쪽이든 반드시 문제가 생깁니다. 공통 유틸은 양쪽에서 모두 안전하게 작동하는 것만 담습니다.

실행 위치를 모르는 채로 쓰는 라이브러리: 번들 크기가 갑자기 커졌다면, 서버 전용이라 생각한 라이브러리가 클라이언트 번들에 들어갔을 가능성이 큽니다.


이 경계가 화면에 드러나는 자리

이 경계는 사용자에게 아주 직접적으로 보입니다. 로딩 화면은 실행 경계의 산물입니다.

스켈레톤은 왜 있는가

스켈레톤 UI는 장식이 아닙니다. “이 데이터는 지금 이 자리에서 준비되고 있다”는 선언입니다. 그리고 스켈레톤이 필요하다는 사실 자체가 그 데이터가 첫 HTML에 없다는 뜻입니다.

여기서 디자이너와 개발자가 무엇을 첫 화면에 확정해서 보여줄 것인가를 함께 정해야 합니다.

[확정] 헤더 · 네비게이션 · 상품 이름 · 가격 · 이미지
[지연] 재고 수량 · 개인화 추천 · 리뷰 요약 · 장바구니 개수

확정 영역은 서버에서 만들어 보냅니다. 지연 영역은 스켈레톤을 두고 나중에 채웁니다. 이 구분이 곧 실행 경계의 설계입니다.

Streaming SSR은 이 구분을 코드로 표현할 수 있게 해 줍니다.

export default function ProductPage({ id }: { id: string }) {
  return (
    <>
      {/* 즉시 렌더 — 첫 HTML에 포함됩니다 */}
      <ProductHeader id={id} />

      {/* 느린 데이터는 경계를 두고 나중에 흘려보냅니다 */}
      <Suspense fallback={<ReviewSkeleton />}>
        <ReviewSummary id={id} />
      </Suspense>

      <Suspense fallback={<RecommendSkeleton />}>
        <Recommendations userId={userId} />
      </Suspense>
    </>
  )
}

<Suspense>의 경계가 곧 디자인상의 스켈레톤 경계입니다. 개발자가 임의로 정하는 것이 아니라, 화면 설계에서 나와야 하는 선입니다.

레이아웃 시프트

지연 영역이 채워질 때 주변이 밀리면 사용자가 잘못된 버튼을 누릅니다. 스켈레톤은 실제 콘텐츠와 같은 크기여야 합니다.

이것은 디자인 요구사항이면서 동시에 데이터 요구사항입니다. 리뷰 요약이 한 줄일지 세 줄일지 모른다면 스켈레톤 크기를 정할 수 없습니다. 그래서 “리뷰 요약은 최대 두 줄”이라는 디자인 결정이 필요하고, 그 결정이 API 응답의 제약이 됩니다.

디자인 결정과 데이터 계약이 만나는 자리입니다. ep.03 계약 경계가 여기서 화면의 안정성 문제로 돌아옵니다.

상품 상세 화면에서 확정 영역과 지연 영역을 나누는 다이어그램. 좌측에는 화면 목업이 배치되어 있습니다. 헤더와 네비게이션, 상품 이미지, 상품 이름, 가격 45,000원, 브랜드와 카테고리는 확정 영역으로 표시되어 첫 HTML에 포함되고 서버에서 완성된다는 라벨이 붙습니다. 재고 수량, 리뷰 요약, 개인화 추천은 지연 영역으로 표시되어 스켈레톤을 두고 나중에 채운다는 라벨이 붙으며 회색 자리표시자로 그려져 있습니다. 우측에는 대응하는 코드 구조가 배치되어, ProductHeader는 즉시 렌더되어 첫 HTML에 포함되고 ReviewSummary와 Recommendations는 각각 Suspense 경계 안에 들어 있는 모습이 표시됩니다. 이 코드 구조 옆에는 Suspense 경계가 곧 스켈레톤 경계이며 개발자가 임의로 정하지 않고 화면 설계에서 나와야 하는 선이라는 설명이 붙어 있습니다. 하단에는 레이아웃 시프트 사례가 별도로 그려집니다. 스켈레톤이 한 줄인데 실제 콘텐츠가 세 줄로 도착해 아래 요소들이 밀려나는 모습이며, 디자인 결정이 곧 API 응답의 제약이 되는 자리라는 설명이 함께 배치되어 있습니다.

개인화의 위치

앞선 시리즈에서 다룬 라이트모드-다크모드 테마 전환도 이 경계 위에 있습니다.

  • 빌드 타임에 테마를 넣으면, 사용자는 테마를 선택해 실시간으로 반영시킬 수 없습니다.
  • 서버에서 쿠키를 읽어 테마를 정하면, 첫 HTML부터 올바른 테마가 나옵니다.
  • 브라우저에서만 정하면, JavaScript가 설정을 읽을 때까지 잠깐 밝은 화면이 번쩍이는 FOUC(flash of unstyled content) 가 생깁니다.

다크 모드 사용자가 새벽에 흰 화면을 한 번 맞는 눈뽕 문제는 스타일링 버그가 아닙니다. 실행 경계를 어디에 두었는가의 결과입니다.


참고 자료


다음 편 예고

ep.05 - 네트워크 경계와 회복 탄력성

실행 위치를 정하고 나면, 그 사이를 오가는 호출이 남습니다. 로컬 함수 호출과 원격 호출의 차이는 속도가 아니라, 원격 호출은 실패할 수 있다는 것입니다.