Search

05_프레임워크는_무엇을_하는가

이전 장에서 SSR의 핵심 기능들을 직접 구현해봤습니다. Hydration, 라우팅, 데이터 페칭, 그리고 SSG와 ISR까지. 기능적으로는 동작하지만, 실제 서비스에 적용하려면 넘어야 할 산이 더 있습니다.

직접 구현의 한계

이전 장의 구현들을 다시 살펴보면, 각각은 동작하지만 프로덕션 레벨로 끌어올리기엔 부족한 점들이 보입니다.

번들 크기의 문제

클라이언트에서 Hydration을 하려면 JavaScript 코드가 필요합니다. 앱이 커질수록 이 번들도 커지죠.
작은 앱: 50KB → 문제없음 중간 앱: 500KB → 느린 3G에서 10초 대기 큰 앱: 2MB+ → 초기 로딩이 체감될 정도로 느림
Plain Text
복사
모든 코드를 한 번에 보내는 건 비효율적입니다. 사용자가 /about 페이지를 보는데 /admin 페이지 코드까지 다운로드할 필요가 없으니까요.
코드 스플리팅은 이 문제를 해결합니다. 번들을 작은 조각(청크)으로 나누고, 필요한 것만 로드하는 거죠. 하지만 직접 구현하려면 번들러 설정, 청크 전략, 로딩 UI까지 고려해야 합니다.

직접 구현한 SSG/ISR의 한계

이전 장에서 SSG와 ISR을 직접 구현해봤습니다. 핵심 원리는 간단했죠: - SSG: 빌드 시 renderToString → 파일 저장 - ISR: 캐시 + TTL + 백그라운드 재생성
하지만 실제 서비스에서는 더 많은 것들이 필요합니다:
직접 구현: - 빌드할 페이지 목록을 수동으로 관리 - 캐시 무효화를 직접 구현 - 에러가 나면 전체가 실패 프레임워크 지원: - 파일 시스템에서 자동으로 페이지 발견 - API 한 번 호출로 특정 페이지만 재생성 - 실패해도 이전 버전 유지, 부분 빌드 가능
Plain Text
복사

이미지 최적화의 복잡함

웹 성능에서 이미지가 차지하는 비중은 큽니다. 평균적으로 웹페이지 용량의 50% 이상이 이미지입니다.
최적화해야 할 것들: - 포맷: JPEG/PNG 대신 WebP/AVIF 사용 - 크기: 디바이스 화면에 맞는 해상도 제공 - 로딩: 화면에 보일 때만 로드 (lazy loading) - 레이아웃: 이미지 영역을 미리 확보해 깜빡임 방지
이걸 직접 구현하려면 이미지 처리 서버, CDN 연동, srcset 생성 로직까지 필요합니다.

프레임워크가 필요한 이유

핵심 원리를 이해하는 건 중요합니다. 하지만 매 프로젝트마다 처음부터 구현하는 건 비효율적입니다. 프레임워크는 이런 문제들을 검증된 방식으로 해결해줍니다.
직접 구현의 한계
프레임워크 해결책
번들이 커지면 성능 저하
자동 코드 스플리팅
빌드할 페이지 수동 관리
파일 기반 자동 발견
캐시 무효화 직접 구현
온디맨드 재생성 API
이미지 최적화 직접 구현
Image 컴포넌트
다음 섹션에서는 프레임워크가 무엇인지, 그리고 이런 문제들을 어떻게 해결하는지 살펴보겠습니다.

프레임워크란 무엇인가

프레임워크는 “반복되는 문제를 미리 해결해둔 코드 뼈대”입니다.
웹 개발에서 반복되는 문제들이 있습니다. URL에 따라 다른 페이지를 보여줘야 하고, 데이터를 가져와서 화면에 렌더링해야 하고, 사용자 인터랙션을 처리해야 합니다. 매 프로젝트마다 이걸 처음부터 구현하는 건 비효율적이죠.
프레임워크는 이런 공통 문제들에 대한 해결책을 미리 만들어둡니다. 개발자는 프레임워크가 정한 규칙을 따르면서 비즈니스 로직에만 집중하면 됩니다.

라이브러리 vs 프레임워크

라이브러리: 내가 필요할 때 호출한다 (React, lodash) 프레임워크: 프레임워크가 내 코드를 호출한다 (Next.js, Remix)
Plain Text
복사
비유하자면 연필과 프린터의 차이입니다.
연필(라이브러리)은 내가 쥐고 원하는 대로 사용합니다. 글씨체도, 필압도, 어디에 쓸지도 내가 정합니다. 마음에 안 들면 다른 필기구로 바꿀 수 있죠.
프린터(프레임워크)는 다릅니다. 내가 할 수 있는 건 허용된 입력(문서)을 주고, 허용된 출력(인쇄물)을 받는 것뿐입니다. 프린터 내부가 어떻게 동작하는지는 알 수 없고, 알 필요도 없습니다. 그 제약을 감수하는 대가로 편리함을 얻습니다.
React는 라이브러리입니다. 화면을 그리는 코드가 눈에 보입니다.
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
TypeScript
복사
이 한 줄을 지우면 아무것도 렌더링되지 않습니다. renderToString을 언제, 어떻게 호출할지는 개발자가 결정합니다.
반면 Next.js는 프레임워크입니다. 파일을 pages/ 폴더에 두면 라우팅이 되고, getServerSideProps를 export하면 서버에서 실행됩니다. 하지만 이게 언제, 어디서, 어떻게 호출되는지 코드에서 직접 확인할 수 없습니다. Next.js가 알아서 처리하니까요.
"도대체 코드가 어디서 시작하는지 알 수가 없다"
Plain Text
복사
이게 프레임워크를 처음 접할 때 느끼는 당혹감입니다. 하지만 그 “알 수 없음”이 바로 프레임워크가 제공하는 가치입니다. 복잡한 설정과 연결을 프레임워크가 대신 처리해주니까요.
이전 장에서 우리가 직접 구현한 것들—라우팅, 데이터 페칭, Hydration—이 바로 프레임워크가 대신 해주는 것들입니다.

메타프레임워크

Next.js, Remix, Nuxt 같은 것들을 메타프레임워크라고 부릅니다. “라이브러리 위에 올라간 프레임워크”라는 뜻이죠.
React 자체는 라이브러리입니다. 화면을 그리는 것만 담당하죠. Next.js는 React 위에 라우팅, SSR, 빌드 시스템을 얹은 프레임워크입니다.
메타프레임워크가 해결하는 의사결정들:
“라우터는 뭘 쓰지?” → 파일 기반 라우팅 제공
“SSR은 어떻게?” → 자동 설정
“번들링은?” → Webpack/Turbopack 내장
“개발 서버는?” → npm run dev
이런 결정들을 프레임워크가 대신 해줍니다. 개발자는 비즈니스 로직에만 집중하면 됩니다.

프레임워크가 추상화하는 것들

문제
직접 구현하면
프레임워크가 해주면
URL별 다른 페이지
라우트마다 핸들러 등록, 중복 코드
파일만 만들면 자동 라우팅
렌더링 전 데이터
핸들러에서 fetch 후 props 전달
getServerSideProps export
클라이언트 인터랙션
client.tsx 작성, 번들링 설정
자동 Hydration
번들 크기 최적화
Webpack/Vite 설정, 수동 청크 분리
자동 코드 스플리팅
정적 페이지 생성
빌드 스크립트 작성, 경로 관리
getStaticProps + getStaticPaths
이미지 최적화
Sharp 서버 구축, CDN 연동, srcset 관리
next/image 컴포넌트
환경 변수 관리
dotenv 설정, 서버/클라이언트 분리
.env.local, NEXT_PUBLIC_ 접두어
개발 서버
HMR 설정, 프록시 설정
npm run dev
프로덕션 빌드
최적화 설정, 번들 분석
npm run build
이 모든 걸 직접 구현할 수 있습니다. 하지만 매 프로젝트마다 같은 걸 반복하는 건 비효율적이죠. 프레임워크는 검증된 방식으로 이 문제들을 해결해둔 것입니다.

프레임워크 내부 들여다보기

프레임워크가 “알아서 해준다”고 하면 뭔가 대단한 마법처럼 느껴집니다. 하지만 실제 코드를 보면 생각보다 단순합니다. Next.js 내부 코드를 살펴보면서 프레임워크가 실제로 무엇을 하는지 확인해보겠습니다.

파일 기반 라우팅

pages/ 폴더에 파일을 만들면 자동으로 라우트가 됩니다. 어떻게 가능할까요?
// 폴더 구조로 라우트 구성하기 (간략화된 버전) const pages = import.meta.glob("./pages/*.tsx", { eager: true }); const routes = Object.keys(pages).map((path) => { const name = path.match(/\.\/pages\/(.*)\.tsx/)?.[1] ?? ""; return { name, path: `/${name === "index" ? "" : name}`, component: pages[path].default, }; });
TypeScript
복사
import.meta.glob은 빌드 도구(Vite 등)가 제공하는 기능으로, 패턴에 맞는 파일들을 한 번에 가져옵니다. 파일 경로에서 라우트 이름을 추출하고, 해당 컴포넌트와 연결하면 끝입니다.
핵심은 “파일 시스템을 라우트 설정으로 사용한다”는 발상의 전환입니다. 별도의 라우트 설정 파일 대신, 폴더 구조 자체가 URL 구조가 됩니다.

데이터 페칭 (getServerSideProps)

getServerSideProps를 export하면 서버에서 실행됩니다. 어떻게?
// Next.js 내부: 페이지 컴포넌트 모듈 로드 const ComponentMod = await requirePage(pathname, distDir, serverless, isAppPath) // export된 함수들 추출 const { getServerSideProps, getStaticProps, getStaticPaths } = ComponentMod
TypeScript
복사
Next.js는 페이지 컴포넌트 파일을 로드한 뒤, export된 함수 중 특정 이름을 가진 것들을 뽑아냅니다. 그리고 요청이 들어오면 해당 함수를 실행합니다:
// 추출한 함수 실행 data = await getServerSideProps({ req: req, res: res, query, resolvedUrl: renderOpts.resolvedUrl, ...(pageIsDynamic ? { params } : undefined), })
TypeScript
복사
getServerSideProps가 마법처럼 동작하는 게 아닙니다. Next.js가 우리 코드를 가져가서 적절한 시점에 실행해주는 것뿐입니다. 이게 바로 “프레임워크가 내 코드를 호출한다”는 말의 의미입니다.

이미지 최적화

next/image는 이미지 src를 어떻게 처리할까요?
// Image 컴포넌트 내부의 defaultLoader function defaultLoader({ config, src, width, quality }) { return `${config.path}?url=${encodeURIComponent(src)}&w=${width}&q=${quality || 75}` }
TypeScript
복사
원본 이미지 URL을 /_next/image?url=...&w=800&q=75 형태로 변환합니다. 브라우저는 이 URL로 요청하고, Next.js 서버가 이를 받아서 이미지를 최적화합니다.
최적화된 이미지는 캐시에 저장됩니다:
// 이미지 캐시 로직 const cacheEntry = await this.imageResponseCache.get( cacheKey, async () => { const { buffer, contentType, maxAge } = await this.imageOptimizer(req, res, paramsResult) const etag = getHash([buffer]) return { value: { kind: 'IMAGE', buffer, etag, extension }, revalidate: maxAge } } )
TypeScript
복사
같은 이미지에 대한 요청이 다시 오면 캐시된 결과를 반환합니다. 이미지 처리는 CPU를 많이 쓰는 작업이라 캐싱이 중요합니다.

결국 특별하지 않은 코드들

프레임워크 내부를 보면 “별거 아니네?”라는 생각이 들 수 있습니다. 맞습니다. 각각의 코드는 특별하지 않습니다.
하지만 이런 것들을 직접 구현하려면: - 파일 시스템 감시와 라우트 자동 생성 - 빌드 타임과 런타임 분리 - 코드 스플리팅과 번들 최적화 - 개발 서버와 HMR - 에러 핸들링과 폴백
이 모든 걸 조합하고, 엣지 케이스를 처리하고, 성능을 최적화하는 게 진짜 어려운 일입니다. 프레임워크는 이 조합의 산물입니다.

프레임워크마다 다른 건 뭔가

Next.js, Remix, Nuxt 등 여러 프레임워크가 있습니다. 이들이 해결하는 문제는 같지만, 접근 방식이 다릅니다.

데이터 페칭

// Next.js Pages Router export async function getServerSideProps({ params }) { const data = await fetchData(params.id); return { props: { data } }; } // Next.js App Router async function Page({ params }) { const data = await fetchData(params.id); return <div>{data}</div>; } // Remix export async function loader({ params }) { return json(await fetchData(params.id)); }
TypeScript
복사
방식은 달라도 본질은 같습니다. “렌더링 전에 데이터를 가져와서 컴포넌트에 전달한다.”

라우팅

Next.js Pages Router: pages/posts/[id].tsx Next.js App Router: app/posts/[id]/page.tsx Remix: app/routes/posts.$id.tsx Nuxt: pages/posts/[id].vue
Plain Text
복사
파일 구조와 네이밍 규칙이 다르지만, 모두 “파일 시스템 = URL 구조”라는 아이디어입니다.

이미지 최적화

이전 장에서 서버에서 이미지를 최적화하는 원리를 살펴봤습니다. 프레임워크는 이 과정을 어떻게 추상화할까요?
// Next.js의 next/image import Image from 'next/image'; <Image src="/hero.jpg" width={800} height={600} alt="Hero image" />
TypeScript
복사
이 컴포넌트가 실제로 생성하는 HTML을 보면:
<img srcset="/_next/image?url=%2Fhero.jpg&w=640&q=75 640w, /_next/image?url=%2Fhero.jpg&w=750&q=75 750w, /_next/image?url=%2Fhero.jpg&w=828&q=75 828w, /_next/image?url=%2Fhero.jpg&w=1080&q=75 1080w" src="/_next/image?url=%2Fhero.jpg&w=1080&q=75" width="800" height="600" loading="lazy" />
HTML
복사
프레임워크가 자동으로 해주는 것들: - srcset 생성: 다양한 해상도에 맞는 이미지 URL 자동 생성 - 크기 명시: width/height로 레이아웃 시프트 방지 - lazy loading: 화면에 보일 때만 로드 - 포맷 변환: WebP 같은 최신 포맷으로 자동 변환

이미지 최적화의 비용

여기서 한 가지 중요한 점이 있습니다. /_next/image 엔드포인트는 요청이 올 때마다 서버에서 이미지를 변환합니다. Sharp(Node.js의 고성능 이미지 처리 라이브러리)로 리사이즈와 포맷 변환을 수행하죠.
이 과정은 CPU와 메모리를 많이 사용합니다. 이미지 처리는 결국 수백만 개의 픽셀을 하나씩 연산하는 작업이니까요.
1920×1080 이미지 → 약 200만 픽셀 각 픽셀마다 RGB 변환, 리샘플링, 압축...
Plain Text
복사
트래픽이 많은 서비스라면 이미지 처리만으로 서버 비용이 급증할 수 있습니다. 그래서 많은 서비스가 이미지 처리를 전문 CDN에 위임합니다.

관심사의 분리: 커스텀 로더

Next.js는 이 문제를 커스텀 로더로 해결합니다. 이미지 URL 생성 로직만 교체하면 외부 서비스로 처리를 위임할 수 있죠.
// next.config.js module.exports = { images: { loader: 'custom', loaderFile: './image-loader.ts', }, } // image-loader.ts export default function cloudinaryLoader({ src, width, quality }) { const params = ['f_auto', 'c_limit', `w_${width}`, `q_${quality || 'auto'}`]; return `https://res.cloudinary.com/demo/image/upload/${params.join(',')}${src}`; }
TypeScript
복사
이제 <Image> 컴포넌트를 그대로 사용하면서도, 실제 변환은 Cloudinary가 처리합니다.
<Image src="/hero.jpg" ... /> ↓ 커스텀 로더 https://res.cloudinary.com/demo/image/upload/f_auto,c_limit,w_800,q_auto/hero.jpg
Plain Text
복사
프레임워크의 추상화(컴포넌트 API)는 유지하면서, 실행 전략(서버 vs CDN)은 바꿀 수 있습니다. 이게 좋은 추상화의 특징입니다.

은탄환은 없다

next/image가 항상 정답은 아닙니다.
이미지가 몇 개 없는 단순한 사이트 → 그냥 <img>면 충분
이미 CDN에서 최적화된 이미지 → next/image가 이중 처리할 필요 없음
정적 사이트 → 빌드 타임에 미리 최적화하는 게 효율적
프레임워크 기능이 어떤 문제를 해결하는지 알면, 언제 쓰고 언제 안 써도 되는지 판단할 수 있습니다.

프레임워크를 이해하는 방법

프레임워크 문서를 읽을 때 이렇게 질문해보세요.
1.
이 기능은 어떤 문제를 해결하는가?
getServerSideProps → 렌더링 전 데이터 페칭
'use client' → 클라이언트 전용 컴포넌트 선언
2.
이 기능은 언제 실행되는가?
빌드 타임? 요청 시? 클라이언트에서?
getStaticProps는 빌드 타임, getServerSideProps는 요청 시
3.
이 기능의 제약 조건은 무엇인가?
대부분 직렬화 관련 → “반환값이 직렬화 가능해야 한다”
4.
이 기능 없이 직접 구현하면 어떻게 되는가?
이 문서에서 한 것처럼 직접 구현해보면 프레임워크가 뭘 해주는지 명확해집니다

새로운 기능이 나와도

React Server Components, Streaming SSR, Edge Computing… 새로운 개념들이 계속 나옵니다. 하지만 근본 원리는 변하지 않습니다.
흥미로운 건 React의 탄생 배경입니다. React 창시자 조던 워크(Jordan Walke)가 영감을 받은 건 Facebook 내부의 PHP 확장 XHP(2010)였습니다. XHP의 핵심 철학은 “마크업과 로직을 분리하지 않고, 하나의 재사용 가능한 컴포넌트로 캡슐화한다”는 것이었죠.
React의 DNA에는 태생적으로 ’서버 사이드 렌더링(PHP)’과 ’컴포넌트 기반 UI 구성(XHP)’이라는 유전자가 각인되어 있었다.
지난 10년간 React를 ’클라이언트 사이드 라이브러리’로만 인식해온 것은, React의 본질 중 절반만 사용해온 셈입니다. React Server Components가 등장한 건 React가 원래의 유전자로 돌아가는 과정일 수도 있습니다.
서버와 클라이언트는 네트워크로 분리되어 있고, 그 경계를 넘으려면 직렬화가 필요하다.
Plain Text
복사
새 기능이 나오면 이렇게 질문해보세요.
“이건 렌더링 위치의 문제인가?” → SSR, CSR, RSC
“이건 데이터 전달의 문제인가?” → 직렬화, Hydration
“이건 성능의 문제인가?” → Streaming, 코드 스플리팅
대부분의 SSR 관련 기능은 이 범주 안에 들어갑니다.

경계의 명시적 선언

React Server Components와 함께 새로운 개념이 등장했습니다. “이 코드가 어디서 실행되는지”를 명시적으로 선언하는 것입니다.

‘use client’

파일 맨 위에 이 지시어를 쓰면, 해당 컴포넌트는 클라이언트에서 실행됩니다.
'use client'; import { useState } from 'react'; export function Counter() { const [count, setCount] = useState(0); return <button onClick={() => setCount(c => c + 1)}>{count}</button>; }
TypeScript
복사
useState, useEffect, 이벤트 핸들러를 쓰려면 'use client'가 필요합니다. 이런 기능들은 브라우저에서만 동작하니까요.

‘use server’

서버에서만 실행되는 함수를 선언합니다.
'use server'; export async function saveData(formData) { await db.insert(formData); }
TypeScript
복사
클라이언트에서 이 함수를 호출하면, 자동으로 서버에 요청이 갑니다. 함수 코드 자체는 클라이언트 번들에 포함되지 않습니다.

왜 명시적 선언이 필요한가

SSR의 어려움 중 하나는 “이 코드가 어디서 도는 거지?”였습니다. 같은 컴포넌트가 서버에서도, 클라이언트에서도 실행되니까요.
명시적 선언은 이 모호함을 제거합니다:
'use client' → 클라이언트 전용
'use server' → 서버 전용
아무것도 없으면 → Server Component (서버에서 렌더링)
경계가 명확해지면 실수가 줄어듭니다. window 객체를 서버 컴포넌트에서 쓰려다 에러가 나거나, 데이터베이스 연결을 클라이언트로 노출하는 실수를 방지할 수 있습니다.

서버 환경의 특성

SSR 코드를 작성할 때 한 가지 더 알아야 할 게 있습니다. 서버는 브라우저와 근본적으로 다른 환경입니다.

요청 격리 (Request Isolation)

브라우저에서는 한 사용자만 있습니다. 전역 변수를 써도 그 사용자만 영향을 받죠.
서버는 다릅니다. 하나의 서버 프로세스가 수천 명의 요청을 동시에 처리합니다.
// ❌ 위험: 전역 상태 let currentUser = null; app.get('/profile', (c) => { currentUser = getUserFromSession(c); // 사용자 A 저장 // 이 순간 다른 요청이 currentUser를 덮어쓸 수 있음! return c.html(renderProfile(currentUser)); // 사용자 B의 프로필이 보일 수도 }); // ✅ 안전: 요청 스코프 내에서만 사용 app.get('/profile', (c) => { const currentUser = getUserFromSession(c); // 이 요청에만 유효 return c.html(renderProfile(currentUser)); });
TypeScript
복사
요청 격리란 각 요청이 독립적으로 처리되어야 한다는 원칙입니다. 한 요청의 데이터가 다른 요청에 영향을 주면 안 됩니다.
프레임워크들은 이 문제를 다양한 방식으로 해결합니다: - Next.js의 cookies(), headers() 함수 - Remix의 loader 함수에 전달되는 request 객체
이런 API들은 “현재 요청”의 컨텍스트를 안전하게 접근하도록 설계되어 있습니다.

정리: 프레임워크는 도구일 뿐

프레임워크가 제공하는 기능들은 마법이 아닙니다. 이 문서에서 직접 구현해본 것처럼, 각각은 특정 문제를 해결하기 위한 코드의 집합입니다.
프레임워크를 사용하면서 “이게 왜 이렇게 동작하지?”라는 의문이 들 때, 이 문서에서 다룬 원리를 떠올려보세요.
Hydration mismatch → 서버와 클라이언트 렌더링 결과가 달라서
getServerSideProps 반환값 에러 → 직렬화 불가능한 값이 있어서
'use client' 필요 → 이벤트 핸들러나 상태가 있어서
원리를 알면 에러 메시지가 읽히기 시작합니다.

다음 단계

이 문서는 SSR의 기초를 다뤘습니다. 더 깊이 들어가고 싶다면 이런 주제들이 있습니다.

심화 주제

Streaming SSR: HTML을 청크 단위로 보내는 방식. renderToPipeableStream과 Suspense의 조합.
React Server Components: 서버에서만 실행되는 컴포넌트. 번들 크기 최적화와 데이터 접근 방식의 변화.
Edge Computing: CDN 엣지에서 SSR 실행. 지연 시간 최소화.
캐싱 전략: CDN, ISR, stale-while-revalidate 패턴.

참고할 만한 자료

핵심은 하나입니다. SSR은 “서버에서 HTML을 만들어서 보내는 것”입니다. 나머지는 이 단순한 원리 위에 쌓인 최적화와 추상화입니다.

다음: 심화 주제들

지금까지 프레임워크가 “무엇”을 해주는지 봤습니다. 다음 네 장에서는 “어떻게”를 깊게 파고듭니다:
5-1: 직렬화 - 왜 함수는 전송할 수 없는가
5-2: Streaming SSR - 기다리지 않고 보내기
5-3: RSC - 두 종류의 컴포넌트
5-4: Server Actions - 클라이언트에서 서버 함수 호출하기