안녕하세요! 오늘은 Next.js의 App Router를 다룰 때 가장 핵심이 되는 개념인 서버 컴포넌트(Server Components)와 클라이언트 컴포넌트(Client Components)에 대해 완벽하게 마스터해보겠습니다.
기본적으로 Next.js의 레이아웃과 페이지는 서버 컴포넌트로 동작합니다. 이를 통해 서버에서 데이터를 가져오고 UI의 일부분을 렌더링하며, 선택적으로 결과를 캐싱하고 클라이언트로 스트리밍할 수 있죠. 반면, 사용자와의 상호작용(Interactivity)이나 브라우저 전용 API가 필요한 경우에는 클라이언트 컴포넌트를 기능의 레이어로 얹어서 사용할 수 있습니다.
이 페이지에서는 Next.js에서 서버 및 클라이언트 컴포넌트가 어떻게 작동하는지, 언제 사용해야 하는지, 그리고 애플리케이션에서 두 가지를 어떻게 조화롭게 조합하는지에 대한 예시를 차근차근 설명해 드릴게요.
클라이언트와 서버 환경은 각기 다른 장점과 능력을 갖추고 있습니다. 서버 컴포넌트와 클라이언트 컴포넌트를 적절히 나누면 여러분의 사용 사례에 맞춰 각 환경에서 최적의 로직을 실행할 수 있습니다.
다음과 같은 기능이 필요할 때는 클라이언트 컴포넌트(Client Components)를 사용하세요:
onClick, onChange 등)useEffect 등)localStorage, window, Navigator.geolocation 등)반면, 다음과 같은 상황에서는 서버 컴포넌트(Server Components)를 활용하세요:
💡 강사의 팁:
기술 면접에서 "CSR과 SSR의 차이", 그리고 "Next.js에서 서버 컴포넌트를 왜 기본으로 채택했는가?"는 정말 자주 나오는 단골 질문입니다. 서버 컴포넌트를 사용하면 무거운 자바스크립트 번들을 브라우저로 내려보내지 않아도 되기 때문에 초기 로딩 속도(FCP)가 획기적으로 개선된다는 점을 꼭 기억해두세요.
예를 들어볼게요. 아래의 <Page> 컴포넌트는 포스트에 대한 데이터를 가져오는 서버 컴포넌트입니다. 데이터를 가져온 후, 그 데이터를 클라이언트 측 상호작용을 담당하는 <LikeButton>(클라이언트 컴포넌트)에 props로 전달하고 있습니다.
import LikeButton from '@/app/ui/like-button'
import { getPost } from '@/lib/data'
export default async function Page({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const post = await getPost(id)
return (
<div>
<main>
<h1>{post.title}</h1>
{/* ... */}
<LikeButton likes={post.likes} />
</main>
</div>
)
}
import LikeButton from '@/app/ui/like-button'
import { getPost } from '@/lib/data'
export default async function Page({ params }) {
const post = await getPost(params.id)
return (
<div>
<main>
<h1>{post.title}</h1>
{/* ... */}
<LikeButton likes={post.likes} />
</main>
</div>
)
}
'use client'
import { useState } from 'react'
export default function LikeButton({ likes }: { likes: number }) {
// ...
}
'use client'
import { useState } from 'react'
export default function LikeButton({ likes }) {
// ...
}
서버에서 Next.js는 React의 API를 사용하여 렌더링 과정을 오케스트레이션(조율)합니다. 렌더링 작업은 각 개별 라우트 세그먼트(레이아웃 및 페이지) 단위로 청크(chunks)로 분할됩니다.
RSC Payload (React Server Component Payload)란 무엇인가요?
RSC Payload는 렌더링된 React 서버 컴포넌트 트리를 압축된 바이너리 형태로 표현한 것입니다. 클라이언트 측의 React가 브라우저의 DOM을 업데이트하는 데 이 데이터를 사용합니다. RSC Payload에는 다음 내용이 포함됩니다:
- 서버 컴포넌트의 렌더링된 결과물
- 클라이언트 컴포넌트가 렌더링되어야 할 위치의 '플레이스홀더(Placeholders)' 및 해당 자바스크립트 파일에 대한 참조 링크
- 서버 컴포넌트에서 클라이언트 컴포넌트로 전달된 모든 props 데이터
💡 강사의 팁:
이 RSC 페이로드 개념은 조금 낯설 수 있습니다. 쉽게 말해 서버가 클라이언트에게 "뼈대는 이렇게 생겼고, 중간중간 구멍(플레이스홀더)을 뚫어놨으니 거기엔 네가 가진 JS 파일(클라이언트 컴포넌트)을 끼워 넣어!"라고 알려주는 설계도라고 생각하시면 됩니다.
그다음 클라이언트에서는 이런 과정이 일어납니다:
하이드레이션(Hydration)이란 무엇인가요?
하이드레이션은 정적인 HTML 웹페이지를 상호작용 가능하게 만들기 위해 React가 DOM에 이벤트 핸들러를 부착하는 과정을 말합니다. 메마른 HTML 구조에 자바스크립트라는 '물'을 주어 생명력을 불어넣는다고 이해하시면 좋습니다. (프론트엔드 포지션 인터뷰에서 1순위로 묻는 단골 질문이니 반드시 완벽히 숙지해 두세요!)
사용자가 첫 로드 이후 다른 페이지로 이동할 때는 동작 방식이 약간 달라집니다:
파일 최상단, 즉 import 구문보다도 위에 "use client" 지시어를 추가하면 클라이언트 컴포넌트를 만들 수 있습니다.
'use client'
import { useState } from 'react'
export default function Counter() {
const [count, setCount] = useState(0)
return (
<div>
<p>{count} likes</p>
<button onClick={() => setCount(count + 1)}>Click me</button>
</div>
)
}
'use client'
import { useState } from 'react'
export default function Counter() {
const [count, setCount] = useState(0)
return (
<div>
<p>{count} likes</p>
<button onClick={() => setCount(count + 1)}>Click me</button>
</div>
)
}
"use client"는 서버 모듈 그래프와 클라이언트 모듈 그래프(트리) 사이의 경계(boundary)를 선언하는 역할을 합니다.
어떤 파일에 "use client"가 선언되면, 그 파일에서 import하는 모든 모듈과 자식 컴포넌트들도 자동으로 클라이언트 번들의 일부로 간주됩니다. 즉, 클라이언트에서 실행될 모든 개별 컴포넌트마다 일일이 이 지시어를 붙일 필요는 없다는 뜻입니다.
클라이언트의 자바스크립트 번들 크기를 효과적으로 줄이려면, UI의 거대한 덩어리를 통째로 클라이언트 컴포넌트로 만들기보다는 딱 상호작용이 필요한 특정 컴포넌트에만 'use client'를 선언하여 클라이언트 경계선을 최대한 아래로 밀어내세요.
예를 들어, 아래의 <Layout> 컴포넌트는 로고나 내비게이션 링크 같은 정적인 요소를 주로 담고 있지만, 인터랙티브한 검색창도 포함하고 있습니다. 여기서 <Search /> 컴포넌트는 상호작용이 필요하므로 클라이언트 컴포넌트가 되어야 하지만, 레이아웃의 나머지 부분은 서버 컴포넌트로 남겨두는 것이 좋습니다.
// 클라이언트 컴포넌트
import Search from './search'
// 서버 컴포넌트
import Logo from './logo'
// 레이아웃은 기본적으로 서버 컴포넌트입니다.
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<>
<nav>
<Logo />
<Search />
</nav>
<main>{children}</main>
</>
)
}
// 클라이언트 컴포넌트
import Search from './search'
// 서버 컴포넌트
import Logo from './logo'
// 레이아웃은 기본적으로 서버 컴포넌트입니다.
export default function Layout({ children }) {
return (
<>
<nav>
<Logo />
<Search />
</nav>
<main>{children}</main>
</>
)
}
'use client'
export default function Search() {
// ...
}
'use client'
export default function Search() {
// ...
}
💡 강사의 팁:
포트폴리오를 작성하실 때 이런 "상태를 가지는 노드를 최대한 하위로 분리하여 번들 사이즈를 최적화했다"는 내용을 어필하면, 퍼포먼스를 고민할 줄 아는 훌륭한 프론트엔드 개발자로 보일 수 있습니다.
서버 컴포넌트에서 가져온 데이터를 클라이언트 컴포넌트에 props를 통해 전달할 수 있습니다.
import LikeButton from '@/app/ui/like-button'
import { getPost } from '@/lib/data'
export default async function Page({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const post = await getPost(id)
return <LikeButton likes={post.likes} />
}
import LikeButton from '@/app/ui/like-button'
import { getPost } from '@/lib/data'
export default async function Page({ params }) {
const post = await getPost(params.id)
return <LikeButton likes={post.likes} />
}
'use client'
export default function LikeButton({ likes }: { likes: number }) {
// ...
}
'use client'
export default function LikeButton({ likes }) {
// ...
}
또 다른 방법으로는, 서버 컴포넌트에서 클라이언트 컴포넌트로 데이터를 스트리밍하면서 클라이언트에서 use API를 활용하는 방식도 있습니다. 관련 예시 확인하기
알아두면 좋은 점 (Good to know): 클라이언트 컴포넌트로 전달되는 Props는 반드시 React가 직렬화(serializable) 할 수 있는 데이터여야 합니다.
서버 컴포넌트를 클라이언트 컴포넌트의 prop으로 전달하는 것도 가능합니다. 이 방식을 사용하면 클라이언트 컴포넌트 안쪽에 서버에서 렌더링된 UI를 시각적으로 중첩시킬 수 있습니다.
가장 흔하게 쓰이는 패턴은 children prop을 사용해 <ClientComponent> 내부에 슬롯(slot)을 만드는 것입니다. 예를 들어, 클라이언트 상태를 이용해 보이기/숨기기를 전환하는 <Modal>(클라이언트 컴포넌트) 안에, 서버에서 데이터를 가져오는 <Cart>(서버 컴포넌트)를 넣는 경우를 생각해 볼 수 있습니다.
'use client'
export default function Modal({ children }: { children: React.ReactNode }) {
return <div>{children}</div>
}
'use client'
export default function Modal({ children }) {
return <div>{children}</div>
}
그런 다음 부모 서버 컴포넌트(예: <Page>)에서 <Cart>를 <Modal>의 자식(child)으로 넘겨주는 거죠:
import Modal from './ui/modal'
import Cart from './ui/cart'
export default function Page() {
return (
<Modal>
<Cart />
</Modal>
)
}
import Modal from './ui/modal'
import Cart from './ui/cart'
export default function Page() {
return (
<Modal>
<Cart />
</Modal>
)
}
이 패턴에서는 prop으로 전달된 컴포넌트들을 포함해 모든 서버 컴포넌트가 서버에서 미리 렌더링됩니다. 그 결과로 생성되는 RSC Payload에는 컴포넌트 트리 내에서 클라이언트 컴포넌트가 어디에 위치해야 하는지 참조(references) 정보가 포함됩니다.
현재 테마(Theme)와 같은 전역 상태를 공유할 때 React context를 흔히 사용합니다. 하지만, 서버 컴포넌트에서는 React context를 지원하지 않습니다.
Context를 사용하려면, children을 받는 클라이언트 컴포넌트를 생성해야 합니다:
'use client'
import { createContext } from 'react'
export const ThemeContext = createContext({})
export default function ThemeProvider({
children,
}: {
children: React.ReactNode
}) {
return <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>
}
'use client'
import { createContext } from 'react'
export const ThemeContext = createContext({})
export default function ThemeProvider({ children }) {
return <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>
}
그런 다음, 이것을 서버 컴포넌트(예: layout) 안으로 가져와서 감싸주면 됩니다:
import ThemeProvider from './theme-provider'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html>
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
)
}
import ThemeProvider from './theme-provider'
export default function RootLayout({ children }) {
return (
<html>
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
)
}
이제 서버 컴포넌트는 여러분이 만든 Provider를 직접 렌더링할 수 있으며, 앱 내부의 다른 모든 클라이언트 컴포넌트들은 이 Context의 값을 소비(consume)할 수 있게 됩니다.
알아두면 좋은 점 (Good to know): Provider는 트리 구조에서 가능한 한 깊은 곳에 렌더링하는 것이 좋습니다. 위의 예시에서도 전체
<html>문서 대신{children}만을ThemeProvider로 감싼 것을 볼 수 있죠. 이렇게 하면 Next.js가 서버 컴포넌트의 정적 부분들을 최적화하기가 훨씬 수월해집니다.
React.cache와 Context Provider를 조합하면, 가져온 데이터를 서버와 클라이언트 컴포넌트 양쪽 모두에 안전하게 공유할 수 있습니다.
먼저, 데이터를 가져오는 캐시된 함수를 만듭니다:
// filename="app/lib/user.ts" switcher
import { cache } from 'react'
export const getUser = cache(async () => {
const res = await fetch('https://api.example.com/user')
return res.json()
})
// filename="app/lib/user.js" switcher
import { cache } from 'react'
export const getUser = cache(async () => {
const res = await fetch('https://api.example.com/user')
return res.json()
})
Promise를 저장하는 Context Provider를 생성합니다:
// filename="app/user-provider.tsx" switcher
'use client'
import { createContext } from 'react'
type User = {
id: string
name: string
}
export const UserContext = createContext<Promise<User> | null>(null)
export default function UserProvider({
children,
userPromise,
}: {
children: React.ReactNode
userPromise: Promise<User>
}) {
return <UserContext value={userPromise}>{children}</UserContext>
}
// filename="app/user-provider.js" switcher
'use client'
import { createContext } from 'react'
export const UserContext = createContext(null)
export default function UserProvider({ children, userPromise }) {
return <UserContext value={userPromise}>{children}</UserContext>
}
레이아웃 컴포넌트에서 비동기 함수를 await 없이 Promise 자체로 Provider에 전달합니다:
import UserProvider from './user-provider'
import { getUser } from './lib/user'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
const userPromise = getUser() // Don't await (await 하지 마세요)
return (
<html>
<body>
<UserProvider userPromise={userPromise}>{children}</UserProvider>
</body>
</html>
)
}
import UserProvider from './user-provider'
import { getUser } from './lib/user'
export default function RootLayout({ children }) {
const userPromise = getUser() // Don't await (await 하지 마세요)
return (
<html>
<body>
<UserProvider userPromise={userPromise}>{children}</UserProvider>
</body>
</html>
)
}
클라이언트 컴포넌트에서는 use()를 활용해 Context에서 가져온 Promise를 해결(resolve)하고, <Suspense>로 감싸서 로딩 UI(Fallback UI)를 처리합니다.
'use client'
import { use, useContext } from 'react'
import { UserContext } from '../user-provider'
export function Profile() {
const userPromise = useContext(UserContext)
if (!userPromise) {
throw new Error('useContext must be used within a UserProvider')
}
const user = use(userPromise)
return <p>Welcome, {user.name}</p>
}
'use client'
import { use, useContext } from 'react'
import { UserContext } from '../user-provider'
export function Profile() {
const userPromise = useContext(UserContext)
if (!userPromise) {
throw new Error('useContext must be used within a UserProvider')
}
const user = use(userPromise)
return <p>Welcome, {user.name}</p>
}
import { Suspense } from 'react'
import { Profile } from './ui/profile'
export default function Page() {
return (
<Suspense fallback={<div>Loading profile...</div>}>
<Profile />
</Suspense>
)
}
import { Suspense } from 'react'
import { Profile } from './ui/profile'
export default function Page() {
return (
<Suspense fallback={<div>Loading profile...</div>}>
<Profile />
</Suspense>
)
}
물론 서버 컴포넌트에서는 getUser()를 다시 직접 호출할 수도 있습니다:
import { getUser } from '../lib/user'
export default async function DashboardPage() {
const user = await getUser() // 캐시됨 - 같은 요청에서는 중복 fetch가 일어나지 않습니다.
return <h1>Dashboard for {user.name}</h1>
}
import { getUser } from '../lib/user'
export default async function DashboardPage() {
const user = await getUser() // 캐시됨 - 같은 요청에서는 중복 fetch가 일어나지 않습니다.
return <h1>Dashboard for {user.name}</h1>
}
getUser가 React.cache로 감싸져 있기 때문에, 하나의 동일한 요청 안에서 서버 컴포넌트에서 직접 호출되든 클라이언트 컴포넌트에서 Context를 통해 해결되든 중복 요청 없이 메모이제이션된 똑같은 결과값을 반환하게 됩니다.
알아두면 좋은 점 (Good to know):
React.cache는 현재 진행 중인 개별 요청(Request)에 대해서만 스코프(Scope)를 가집니다. 각 요청은 자신만의 메모이제이션 스코프를 가지며, 요청들 사이에는 데이터가 공유되지 않습니다. 즉,React.cache로 감싼getUser함수의 캐시 수명은 "딱 한 번의 HTTP 요청(단일 렌더링 사이클)" 동안만 유지됩니다.
클라이언트 전용 기능(useState 등)에 의존하는 외부 라이브러리 컴포넌트를 사용할 때, 예상대로 잘 작동하게 만들려면 직접 클라이언트 컴포넌트로 한 번 감싸주는 작업이 필요할 수 있습니다.
예를 들어, acme-carousel이라는 패키지에서 가져온 <Carousel /> 컴포넌트가 있다고 가정해 봅시다. 이 컴포넌트는 내부적으로 useState를 사용하지만, 코드에 "use client" 지시어가 명시되어 있지 않은 상태입니다.
이 <Carousel />을 여러분이 직접 만든 클라이언트 컴포넌트 내에서 사용한다면 문제없이 잘 작동합니다:
'use client'
import { useState } from 'react'
import { Carousel } from 'acme-carousel'
export default function Gallery() {
const [isOpen, setIsOpen] = useState(false)
return (
<div>
<button onClick={() => setIsOpen(true)}>View pictures</button>
{/* 클라이언트 컴포넌트 안에서 사용하므로 잘 작동합니다. */}
{isOpen && <Carousel />}
</div>
)
}
'use client'
import { useState } from 'react'
import { Carousel } from 'acme-carousel'
export default function Gallery() {
const [isOpen, setIsOpen] = useState(false)
return (
<div>
<button onClick={() => setIsOpen(true)}>View pictures</button>
{/* 클라이언트 컴포넌트 안에서 사용하므로 잘 작동합니다. */}
{isOpen && <Carousel />}
</div>
)
}
하지만, 이 외부 컴포넌트를 서버 컴포넌트 안에서 곧바로 사용하려고 하면 에러가 발생합니다. Next.js는 <Carousel />이 내부적으로 클라이언트 전용 기능을 사용하고 있다는 사실을 미리 알지 못하기 때문이죠.
이 문제를 해결하려면, 클라이언트 기능에 의존하는 서드파티 컴포넌트를 여러분만의 클라이언트 컴포넌트로 래핑(Wrapping)해주면 됩니다:
// filename="app/carousel.tsx" switcher
'use client'
import { Carousel } from 'acme-carousel'
export default Carousel
// filename="app/carousel.js" switcher
'use client'
import { Carousel } from 'acme-carousel'
export default Carousel
자, 이제 래핑된 <Carousel />을 서버 컴포넌트 안에서 바로 사용할 수 있습니다:
// filename="app/page.tsx" switcher
import Carousel from './carousel'
export default function Page() {
return (
<div>
<p>View pictures</p>
{/* 래핑 과정을 거쳐 클라이언트 컴포넌트가 되었으므로 문제없이 작동합니다. */}
<Carousel />
</div>
)
}
// filename="app/page.js" switcher
import Carousel from './carousel'
export default function Page() {
return (
<div>
<p>View pictures</p>
{/* 래핑 과정을 거쳐 클라이언트 컴포넌트가 되었으므로 문제없이 작동합니다. */}
<Carousel />
</div>
)
}
라이브러리 제작자를 위한 조언 (Advice for Library Authors)
만약 여러분이 컴포넌트 라이브러리를 만들고 있다면, 클라이언트 전용 기능에 의존하는 진입점(entry points) 파일에는 직접
"use client"지시어를 추가해 두는 것이 좋습니다. 이렇게 하면 여러분의 라이브러리를 사용하는 개발자들이 굳이 번거롭게 래퍼(wrapper)를 만들지 않고도 서버 컴포넌트에서 바로 임포트해서 사용할 수 있습니다.다만, 일부 번들러는
"use client"지시어를 알아서 제거해 버리기도 하므로 주의가 필요합니다. esbuild가"use client"지시어를 유지하도록 설정하는 방법은 React Wrap Balancer 및 Vercel Analytics 레포지토리의 예시를 참고하세요.
JavaScript 모듈은 서버와 클라이언트 컴포넌트 모듈 양쪽에서 공유될 수 있습니다. 이 말은 즉, 서버에서만 실행되어야 할 코드를 개발자가 실수로 클라이언트로 임포트할 위험이 존재한다는 뜻입니다. 다음 함수를 예로 들어보겠습니다.
export async function getData() {
const res = await fetch('https://external-service.com/data', {
headers: {
authorization: process.env.API_KEY,
},
})
return res.json()
}
export async function getData() {
const res = await fetch('https://external-service.com/data', {
headers: {
authorization: process.env.API_KEY,
},
})
return res.json()
}
이 함수 내부에는 API_KEY가 포함되어 있는데, 이 키는 절대 클라이언트(브라우저) 환경에 노출되어서는 안 됩니다.
Next.js에서는 NEXT_PUBLIC_ 이라는 접두사가 붙은 환경 변수만 클라이언트 번들에 포함시킵니다. 접두사가 없는 변수는 보안을 위해 Next.js가 빈 문자열로 치환해 버립니다.
결과적으로, getData() 함수를 클라이언트로 불러와서 실행하는 것 자체는 가능하지만, 키가 빈 문자열이 되어버리니 코드가 의도한 대로 작동하지 않게 되죠. 더 나아가, 만약 보안 설정이 다르게 되어있다면 심각한 키 유출 사고로 이어질 수 있습니다.
이런 치명적인 실수를 원천 차단하기 위해, server-only 패키지를 사용할 수 있습니다.
서버에서만 실행되어야 하는 코드를 담은 파일 최상단에 이 패키지를 임포트해 줍니다:
import 'server-only'
export async function getData() {
const res = await fetch('https://external-service.com/data', {
headers: {
authorization: process.env.API_KEY,
},
})
return res.json()
}
이렇게 해두면, 누군가 실수로 이 모듈을 클라이언트 컴포넌트에서 임포트하려고 시도할 때 빌드 타임에서 즉시 명확한 에러를 뿜어냅니다!
반대로, window 객체에 접근하는 등 오직 클라이언트에서만 작동해야 하는 로직이 담긴 모듈을 명시할 때는 짝꿍 패키지인 client-only 패키지를 활용하면 됩니다.
Next.js에서 server-only나 client-only를 설치하는 것은 선택 사항(optional)입니다. 하지만, 린트(Lint) 규칙이 외부 종속성과 관련해 경고를 표시한다면 문제를 방지하기 위해 이 패키지들을 설치해 두는 것을 권장합니다.
npm install server-only
yarn add server-only
pnpm add server-only
bun add server-only
Next.js는 모듈이 잘못된 환경에서 사용되었을 때 훨씬 더 명확한 에러 메시지를 제공하기 위해 내부적으로 server-only와 client-only 임포트를 알아서 처리합니다. 즉, NPM에서 다운받은 이 패키지들의 실제 내용물이 Next.js 구동 자체에 쓰이는 것은 아닙니다.
또한 Next.js는 TypeScript 설정에서 noUncheckedSideEffectImports 옵션이 켜져 있을 때를 대비하여 server-only와 client-only에 대한 자체적인 타입 선언(type declarations)도 제공합니다.
💡 강사의 팁:
이server-only개념은 정말 매력적입니다. 백엔드와 프론트엔드 경계가 모호해지는 Next.js 같은 풀스택 프레임워크 환경에서는 이런 보호 장치가 필수적이죠. 협업을 하거나 면접관에게 코드 리뷰를 받을 때, 이server-only패키지를 적절히 사용한 코드를 보여주면 "보안과 아키텍처 경계에 대한 이해도가 높은 지원자"라는 평가를 받을 수 있습니다.
이 페이지에서 언급된 API들에 대해 더 자세히 알아보시려면 아래 링크를 참조하세요.
use client 지시어를 사용하여 컴포넌트를 클라이언트에서 렌더링하는 방법을 상세히 배워보세요.모든 문서에 대한 의미론적 개요는 https://nextjs.org/docs/sitemap.md를 확인하세요.
모든 사용 가능한 문서의 목차 색인은 https://nextjs.org/docs/llms.txt를 확인하세요.
어떠셨나요? 서버 컴포넌트와 클라이언트 컴포넌트의 개념이 확실히 잡히셨길 바랍니다. 혹시 이 문서 내용을 학습하시다가 더 궁금한 점이나, 실습 중에 이해가 안 가는 코드가 있다면 언제든 편하게 질문해주세요! 제가 도와드리겠습니다.