같은 함수여도 빌드 타임에 돌면 상수가 되고, 서버에서 돌면 요청마다 달라지고, 브라우저에서 돌면 사용자마다 달라진다.
이 경계는 무엇을 청구하는가
이제 코드가 실제로 동작하는 순간을 다룹니다.
개발자는 한 줄의 코드가 어디에서 실행될지 선택할 수 있습니다. 상품 목록을 가져오는 코드를 생각해 봅니다.
- 빌드 타임에 실행하면 결과가 HTML에 박혀 나갑니다. 요청이 오면 파일을 그대로 내려주면 됩니다. 가장 빠릅니다. 대신 재고가 바뀌어도 다음 빌드까지 반영되지 않습니다.
- 서버 런타임에 실행하면 요청마다 최신 데이터가 나옵니다. 대신 요청마다 서버가 일을 합니다.
- 브라우저에서 실행하면 서버의 리소스를 절약하며 실시간 인터랙션을 강화할 수 있습니다. 대신 코드를 불러올 때까지 사용자가 빈 화면을 보는 시간이 생기고, 검색 엔진은 아무것도 보지 못할 수 있습니다.
이 경계가 청구하는 것은 최신성과 속도의 교환입니다. 빌드 타임으로 갈수록 빠르고 낡습니다. 브라우저로 갈수록 개인화되고 느립니다.
실행 경계가 답하는 질문은 하나입니다. 이 작업은 언제, 어디서 실행되어야 하는가.
이 경계에 붙어 있는 이름들
두 축으로 그린 사분면
실행 지점은 두 축으로 갈립니다.
- 언제: 빌드 타임 ↔ 런타임
- 어디서: 서버 ↔ 클라이언트
프론트엔드 렌더링 전략: SSG, ISR, SSR, CSR, RSC, Islands는 이 사분면 위의 좌표입니다. 이름을 외우는 대신 좌표로 이해하면 좋습니다.
| 전략 | 언제 | 어디서 | 최신성 | 첫 화면 |
|---|---|---|---|---|
| SSG | 빌드 타임 | 서버 | 빌드 시점 | 가장 빠름 |
| ISR | 빌드 + 주기적 재생성 | 서버 | 재검증 주기 | 빠름 |
| SSR | 요청 시점 | 서버 | 항상 최신 | 서버 응답 후 |
| CSR | 로드 후 | 클라이언트 | 항상 최신 | 가장 느림 |
| RSC | 요청 시점 | 서버 (JS 미전송) | 항상 최신 | 빠름 |
| Islands | 빌드 또는 요청 시점 + 로드 후 | 서버 + 클라이언트 조각 | HTML 생성 전략에 따름 | 빠름 |
당연히 빌드 시점에는 브라우저가 존재하지 않습니다. 그래서 우상단 사분면이 비어있습니다.
표에서 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이라는 작업 자체를 없앱니다. 서버 컴포넌트는 실행 결과만 내려가고 컴포넌트 코드는 번들에 담기지 않습니다. 받지 않은 코드에는 다시 실행할 것도 없으니, 그 부분에는 죽은 구간이 처음부터 생기지 않습니다.
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 계약 경계가 여기서 화면의 안정성 문제로 돌아옵니다.
개인화의 위치
앞선 시리즈에서 다룬 라이트모드-다크모드 테마 전환도 이 경계 위에 있습니다.
- 빌드 타임에 테마를 넣으면, 사용자는 테마를 선택해 실시간으로 반영시킬 수 없습니다.
- 서버에서 쿠키를 읽어 테마를 정하면, 첫 HTML부터 올바른 테마가 나옵니다.
- 브라우저에서만 정하면, JavaScript가 설정을 읽을 때까지 잠깐 밝은 화면이 번쩍이는 FOUC(flash of unstyled content) 가 생깁니다.
다크 모드 사용자가 새벽에 흰 화면을 한 번 맞는 눈뽕 문제는 스타일링 버그가 아닙니다. 실행 경계를 어디에 두었는가의 결과입니다.
참고 자료
- Next.js Docs. Rendering. https://nextjs.org/docs/app/building-your-application/rendering
- Astro Docs. Islands Architecture. https://docs.astro.build/en/concepts/islands/
- Miller, J. (2020). Islands Architecture. https://jasonformat.com/islands-architecture/
- Newman, S. (2015). Backends For Frontends. https://samnewman.io/patterns/architectural/bff/
- web.dev. Cumulative Layout Shift (CLS). https://web.dev/articles/cls
다음 편 예고
실행 위치를 정하고 나면, 그 사이를 오가는 호출이 남습니다. 로컬 함수 호출과 원격 호출의 차이는 속도가 아니라, 원격 호출은 실패할 수 있다는 것입니다.