9.4
Server와 Client Composition Patterns
지금까지는 서버 컴포넌트와 클라이언트 컴포넌트 각각의 개별적인 렌더링 과정에 대해서만 살펴보았습니다. 하지만 실제로 개발을 하다보면 Client Components 내에 서버 환경의 코드를 작성하고 싶은 경우가 있습니다. 예를 들면, 서버에서 직접 데이터를 가져오거나 서버 환경에서만 사용가능한 API를 사용하고 싶은 경우가 있을 수 있겠죠. 이런 경우 Client Components 내에 Server Components나 Server Actions 코드를 작성하여 서버 환경에서 실행되도록 만들 수 있습니다. 이 때 사용하는 것이 바로 Server와 Client Composition Patterns입니다.
Server Components와 Client Components를 잘 교차해서 사용하기 위해서는 먼저 각 컴포넌트를 어떤 경우에 사용해야 하는지 잘 이해하는 것이 중요합니다. 아래 표는 Server Components 와 Client Components의 다양한 사용 사례를 정리한 것입니다.
| 구현하려는 기능 | 서버 컴포넌트 | 클라이언트 컴포넌트 |
|---|---|---|
| 데이터 fetching | ✅ | ❌ |
| 백엔드 리소스에 직접 접근 | ✅ | ❌ |
| Access Token이나 API Key등의 민감한 정보를 서버에 보관 | ✅ | ❌ |
| 대량의 의존성을 서버에서 관리 / 클라이언트의 JavaScript 번들 사이즈 감소 | ✅ | ❌ |
상호작용 및 이벤트 리스너(onClick(), onChange() 등) 등록 |
❌ | ✅ |
State와 Lifecycle Effects (useState(), useEffect() 등) 사용 |
❌ | ✅ |
| 브라우저 전용 API 사용 | ❌ | ✅ |
| State, Effects, 또는 브라우저 전용 API를 사용하는 커스텀 훅 사용 | ❌ | ✅ |
| React 클래스 컴포넌트 사용 | ❌ | ✅ |
위 표에서 알 수 있듯이 Server Components와 Client Components가 할 수 있는 작업이 각각 나눠져 있습니다. Server Components는 주로 데이터를 직접 접근해서 가져오는 작업을 위해 사용되고, Client Components는 주로 상태를 관리하거나 사용자와의 상호작용을 위해 사용됩니다. 이러한 각 컴포넌트의 주된 용도에 대해 먼저 잘 이해하고 다음으로 넘어가기 바랍니다.
먼저 서버 컴포넌트 패턴에 대해 살펴보겠습니다. 서버 컴포넌트는 서버 환경에서 작동하는 컴포넌트이기 때문에 데이터베이스 등으로부터 직접 데이터를 가져오거나 백엔드 서비스에 직접 접근할 수 있습니다. 이러한 특징을 통해 다양한 형태로 컴포넌트를 구현할 수 있습니다.
React로 웹 애플리케이션을 개발하다 보면, 컴포넌트 간에 데이터를 공유해야 할 경우가 굉장히 많습니다. 그리고 이것은 서버 컴포넌트나 클라이언트 컴포넌트에 관계없이 공통적으로 필요한 기능입니다. 일반적으로 클라이언트 컴포넌트에서는 컴포넌트 간에 데이터를 공유하기 위해 React Context를 사용하거나 props로 하위 컴포넌트에 데이터를 전달합니다. 하지만 서버 컴포넌트에서는 Context를 사용할 수 없기 때문에, 각 컴포넌트에서 fetch()나 React의 cache() 함수를 사용해서 직접 데이터를 가져오는 형태로 구현하게 됩니다.
이 중에서 fetch를 사용하는 방법의 경우 동일한 데이터에 대한 fetching이 불필요하게 여러 번 발생한다고 생각할 수 있는데, 이 부분은 React에서 fetch를 확장하여 자동으로 데이터를 memoization 해주기 때문에 개발자가 신경쓰지 않아도 됩니다. 이것을 Request Memoization이라고 부르며, 이에 대한 자세한 내용은 10장에서 다룰 예정입니다. 자동으로 memoization이 적용되기 때문에 굳이 props를 통해 전달할 필요없이 각 컴포넌트에서 곧바로 가져다가 사용하면 되는 것이죠.
그리고 fetching해와야 하는 데이터가 아닌 경우에는 React에서 제공하는 cache() 함수를 사용해서 컴포넌트 간에 데이터를 공유할 수 있습니다. 아래 코드는 React의 cache() 함수를 사용하는 예시 코드입니다.
import { cache } from 'react';
import { getTotal } from '@/lib/statistics';
const cachedGetTotal = cache(getTotal);
interface MyComponentProps {
numbers: number[];
}
function MyComponent(props: MyComponentProps) {
const { numbers } = props;
const total = cachedGetTotal(numbers);
return <p>{`Total: ${total}`}</p>;
}
export default MyComponent;
cache() 함수를 통해 만들어진 cachedGetTotal() 함수를 사용하면 함수의 실행 결과가 캐싱이 되고, 이후 동일한 파라미터로 함수를 호출할 경우 캐싱된 값을 가져오게 됩니다. 이처럼 cache() 함수는 통계를 내거나 계산을 하는 등 동일한 입력값에 대한 계산 결과를 저장해놓고 다른 컴포넌트에서 재사용 해야 할 경우에 사용하면 됩니다.
서버 컴포넌트와 클라이언트 컴포넌트를 함께 사용하다 보면, 하나의 JavaScript 모듈을 서버 컴포넌트와 클라이언트 컴포넌트가 동시에 가져다가 사용하는 경우가 생기게 됩니다. 일반적인 함수의 경우 크게 상관이 없지만, 서버쪽에서만 작동할 수 있거나 클라이언트쪽에서만 작동할 수 있는 코드가 포함된 모듈의 경우 문제가 될 수 있습니다. 예를 들면, 아래와 같이 환경변수의 API_KEY를 사용해서 외부 API를 호출하는 경우가 있습니다.
export async function getData() {
const res = await fetch('https://api.example.com/data', {
headers: {
authorization: process.env.API_KEY,
},
});
return res.json();
}
이 함수는 서버 환경에서는 정상적으로 잘 작동하지만 클라이언트 환경에서는 정상적으로 작동하지 않습니다. 그 이유는 바로 API_KEY라는 환경변수가 서버에서만 접근 가능한 값이기 때문입니다. Next.js에서는 NEXT_PUBLIC으로 시작하는 환경변수들만 클라이언트에서 접근 가능합니다. Access Token이나 암호화 키 값 등의 민감한 정보들이 클라이언트에 노출되면 안되기 때문이죠.
그래서 위의 getData() 함수는 클라이언트 환경에서는 작동하지 않고 서버 환경에서만 작동하기 때문에, 서버 컴포넌트에서만 가져다 사용하게 해야 합니다. 그리고 이러한 제약조건을 강제로 설정할 수 있게 해주는 것이 바로 server-only 패키지입니다. server-only 패키지를 사용하면 서버 환경에서만 작동하는 모듈을 클라이언트 컴포넌트에서 사용하려고 할 때 빌드 타임 오류를 발생시킴으로써 에러를 사전에 미리 방지할 수 있습니다.
server-only를 사용하기 위해서는 먼저 아래 npm install 명령어를 사용해서 패키지를 설치하고,
npm install server-only
이후 아래 코드와 같이 서버 전용 모듈로 server-only 패키지를 import 하면 됩니다.
import 'server-only';
export async function getData() {
const res = await fetch('https://api.example.com/data', {
headers: {
authorization: process.env.API_KEY,
},
});
return res.json();
}
이렇게 하면 getData() 함수를 클라이언트 컴포넌트에서 import 할 경우 빌드 타임에 에러가 발생하게 됩니다.
NOTE. 참고 사항
클라이언트 환경에서만 작동하는 모듈을 위해서는client-only패키지를 사용하면 됩니다.
서버 컴포넌트가 나오기 전까지는 모두 클라이언트 컴포넌트를 사용했기 때문에, React와 관련된 기존의 서드파티 패키지들은 대부분 클라이언트 컴포넌트 환경에 맞춰서 구현되었습니다. 하지만 서버 컴포넌트가 등장하면서 클라이언트 환경에 맞춰 개발된 서드파티 패키지들을 import해서 사용하려고 할 때 오류가 발생하게 되었습니다. 예를 들면, 서드파티 패키지에서 useState() 훅을 사용하는 경우 해당 패키지를 서버 컴포넌트에서 사용하게 되면 오류가 발생하게 됩니다. 애초에 서드파티 패키지에서 'use client' 지시어를 추가해서 배포하는 경우에는 문제가 되지 않지만, 아직은 모든 패키지들이 대응을 하진 못하고 있기 때문에 아래 나오는 예시처럼 우리가 직접 'use client' 지시어를 추가하는 형태로 사용해야 합니다.
예를 들어, 클라이언트 컴포넌트에서 작동하는 simple-carousel이라는 패키지가 있다고 해보겠습니다. 먼저 아래와 같이 'use client' 지시어를 사용하는 클라이언트 컴포넌트에서는 이 simple-carousel을 import해서 사용할 경우 문제없이 정상적으로 작동합니다.
'use client';
import { useState } from 'react';
import SimpleCarousel from 'simple-carousel';
function MainPage() {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button onClick={() => setIsOpen(true)}>Open</button>
{/* 클라이언트 컴포넌트이므로 정상 작동 */}
{isOpen && <SimpleCarousel />}
</div>
);
}
export default MainPage;
하지만 아래와 같이 'use client' 지시어가 없는 서버 컴포넌트에서 사용하게 되면 오류가 발생하게 됩니다. 왜냐하면 Next.js는 SimpleCarousel 컴포넌트가 클라이언트 환경에서만 사용가능한 기능을 사용하는지 아닌지 여부를 알 수 없기 때문입니다.
import SimpleCarousel from 'simple-carousel';
function MainPage() {
return (
<div>
{/* 서버 컴포넌트이므로 오류 발생 */}
<SimpleCarousel />
</div>
);
}
export default MainPage;
이 문제를 해결하기 위해서는 아래와 같이 우리가 직접 만든 클라이언트 컴포넌트에서 서드파티 패키지를 래핑하고, 래핑한 컴포넌트를 import 해서 사용하면 됩니다.
'use client';
import SimpleCarousel from 'simple-carousel';
export default SimpleCarousel;
다만 대부분의 경우 클라이언트 컴포넌트에 내에서 사용하게 되기 때문에, 모든 서드파티 컴포넌트들을 이렇게 래핑할 필요는 없습니다.
Providers의 경우에는 일반적으로 React State와 Context API를 사용하기 때문에 클라이언트 컴포넌트로 래핑이 필요합니다. 보통 Context API는 애플리케이션의 최상위 레벨에서 하위 컴포넌트들을 모두 래핑하는 형태로 사용하게 되는데, 최상위 컴포넌트가 서버 컴포넌트인 환경에서는 사용할 수 없기 때문에 클라이언트 컴포넌트로 래핑한 컴포넌트를 사용해야 합니다.
먼저 아래와 같이 클라이언트 컴포넌트를 새로 만들고, 여기서 Context 생성 및 Context Provider를 export 합니다.
'use client';
import { createContext, type PropsWithChildren } from 'react';
export const ThemeContext = createContext('light');
export default function ThemeProvider(props: PropsWithChildren) {
return (
<ThemeContext.Provider value='light'>
{props.children}
</ThemeContext.Provider>
);
}
이후 만든 Context Provider를 아래와 같이 서버 컴포넌트에서 import해서 사용하면 됩니다.
import ThemeProvider from '@/theme-context';
interface RootLayoutProps {
children: React.ReactNode;
}
function RootLayout(props: RootLayoutProps) {
return (
<html>
<body>
<ThemeProvider>{props.children}</ThemeProvider>
</body>
</html>
);
}
export default RootLayout;
NOTE. Providers를 어떤 레벨에서 사용해야 할까?
위 예시와 같이 할 경우 모든 하위 컴포넌트는 Context의 값에 접근할 수 있게 됩니다. 하지만 그와 함께RootLayout의 모든 하위 컴포넌트가 클라이언트 컴포넌트가 되기 때문에 서버 컴포넌트를 사용할 수 없다는 단점이 있습니다. 그래서 Providers를 사용할 때는 최대한 컴포넌트 트리의 하위 레벨에서 사용하는 것이 좋습니다. 예를 들면,RootLayout에서 사용하는 것이 아니라, 실제로 Context의 값이 필요한 컴포넌트의 바로 상위 컴포넌트에서 사용하는 식으로 말이죠.
다음으로는 클라이언트 컴포넌트 패턴에 대해 살펴보겠습니다. 이 패턴들은 서버 컴포넌트와 클라이언트 컴포넌트를 함께 사용할 때 적용할 수 있는 일반적인 패턴이라고 보면 됩니다.
앞서 언급한 Providers의 사용 레벨과 동일하게, 클라이언트 컴포넌트를 사용할 때는 최대한 컴포넌트 트리의 하위 레벨에서 사용하는 것이 좋습니다. 그렇게 해야 상위 레벨에서 서버 컴포넌트를 사용할 수 있고, 결과적으로 클라이언트에서 필요한 JavaScript 번들 사이즈가 줄어들 수 있기 때문입니다. 하지만 불가피하게 클라이언트 컴포넌트에서만 사용할 수 있는 기능들을 꼭 사용해야 하는 컴포넌트가 있을 수 있습니다. 이러한 경우 실제 클라이언트 컴포넌트의 기능을 사용하는 부분만 별도의 컴포넌트로 분리하는 방법을 적용해볼 수 있습니다.
아래 코드는 전체 레이아웃에서 KeywordInput 컴포넌트만 분리하여 클라이언트 컴포넌트로 만들고, 이를 import하여 사용한 예시 코드입니다. 이렇게 하면 RootLayout은 서버 컴포넌트가 되고, 실제 사용자로부터 키워드 입력을 받는 KeywordInput 컴포넌트만 클라이언트 컴포넌트가 됩니다. 결과적으로 RootLayout에서 사용된 모든 컴포넌트와 관련된 JavaScript 번들을 클라이언트로 전송할 필요가 없게 되는 것이죠.
import Logo from '@/components/Logo';
import KeywordInput from '@/components/KeywordInput';
interface RootLayoutProps {
children: React.ReactNode;
}
function RootLayout(props: RootLayoutProps) {
return (
<>
<nav>
<Logo />
{/* KeywordInput은 클라이언트 컴포넌트 */}
<KeywordInput />
</nav>
<main>{props.children}</main>
</>
);
}
export default RootLayout;
props 전달하기서버 컴포넌트에서 데이터를 클라이언트 컴포넌트로 전달하고 싶은 경우가 있을 수 있습니다. 이 때 props를 통해서 데이터를 전달할 수 있는데, 서버에서 클라이언트 컴포넌트로 전달되는 props는 React에 의해 Serialization 가능해야 합니다. 그 이유는 React가 서버 컴포넌트에서 클라이언트 컴포넌트로 props를 전달할 때 JSON 형태로 직렬화하여 전송하기 때문입니다. 그래서 props로 전달되는 모든 값은 JSON으로 변환할 수 있어야 하며 함수, 클래스 인스턴스, Symbol, BigInt, 순환 참조 객체 등 직렬화할 수 없는 값은 사용할 수 없습니다. 그렇지 않으면 클라이언트에서 렌더링 시 오류가 발생할 수 있습니다.
Serialization은 우리 말로 하면 직렬화라고 표현하는데, 복잡한 구조의 데이터를 일렬로 쭉 늘어놓는 것을 의미한다고 보면 됩니다. 이렇게 데이터를 일렬로 늘어놓은 상태로 전달하고, 전달받은 쪽에서는 데이터를 원래대로 다시 복원해서 사용합니다. 그리고 이렇게 복원하는 과정을 Deserialization이라고 하고 우리 말로는 역직렬화라고 부릅니다.
우리가 평소에 종종 사용하는 Serialization과 Deserialization의 대표적인 예로는 JSON.stringify()와 JSON.parse() 함수를 들 수 있습니다. JSON.stringify() 함수는 JavaScript 객체를 직렬화된 문자열로 변환해주는 함수이고, JSON.parse() 함수는 직렬화된 문자열을 JavaScript 객체로 변환해주는 함수입니다.
예를 들어 아래와 같은 JavaScript 객체를 직렬화 하게 되면,
const data = {
post: [
{
id: 1,
title: '첫 번째 게시글입니다.',
content: 'Next.js 공부 화이팅!',
},
{
id: 2,
title: '두 번째 게시글입니다.',
content: 'Next.js 너무 재밌어요~!',
},
],
comment: {
1: [
{ id: 1, comment: '화이팅!' },
{ id: 2, comment: '열심히 공부할게요~' },
],
2: [{ id: 3, comment: '저도 너무 재미있어요ㅎㅎ' }],
},
};
아래와 같은 직렬화된 문자열이 나오게 되고, 아래 문자열을 다시 역직렬화 하게 되면 원래의 객체가 나오게 됩니다.
'{"post":[{"id":1,"title":"첫 번째 게시글입니다.","content":"Next.js 공부 화이팅!"},{"id":2,"title":"두 번째 게시글입니다.","content":"Next.js 너무 재밌어요~!"}],"comment":{"1":[{"id":1,"comment":"화이팅!"},{"id":2,"comment":"열심히 공부할게요~"}],"2":[{"id":3,"comment":"저도 너무 재미있어요ㅎㅎ"}]}}'
이처럼 서버 컴포넌트에서 클라이언트 컴포넌트로 props를 통해 데이터를 전달할 때는, 해당 데이터가 직렬화 가능해야 한다는 점을 꼭 기억하기 바랍니다. 그리고 만약 직렬화 할 수 없는 데이터가 포함된 경우에는, 서버 컴포넌트에서 데이터를 전달하는 것이 아니라 클라이언트에서 직접 데이터를 fetching하는 형태로 구현해야 합니다.
props로 전달하기클라이언트 컴포넌트 내에서 서버 컴포넌트를 사용하고 싶을 경우, 서버 컴포넌트를 클라이언트 컴포넌트에 props로 전달하는 방법을 사용할 수 있습니다. 예를 들면, 아래 예제에서 ClientComponent는 children이라는 prop을 받아서 렌더링 하게 됩니다.
'use client';
import { useState } from 'react';
interface ClientComponentProps {
children: React.ReactNode;
}
function ClientComponent(props: ClientComponentProps) {
const [count, setCount] = useState(0);
return (
<>
<button onClick={() => setCount(count + 1)}>{count}</button>
{props.children}
</>
);
}
export default ClientComponent;
하지만 ClientComponent는 children을 받아서 렌더링하는 역할만 할뿐 children으로 어떤 것이 전달될지 알지 못합니다. 그렇기 때문에 서버 컴포넌트를 클라이언트 컴포넌트의 하위 컴포넌트로 전달하면 클라이언트 컴포넌트 내에서 서버 컴포넌트를 사용할 수 있습니다. 아래와 같은 식으로 말이죠.
import ClientComponent from '@/components/ClientComponent';
import ServerComponent from '@/components/ServerComponent';
function Page() {
return (
<ClientComponent>
<ServerComponent />
</ClientComponent>
);
}
export default Page;
이러한 방법을 사용하면 ClientComponent와 ServerComponent는 분리되어 독립적으로 렌더링 됩니다. 그리고 ServerComponent의 경우에는 ClientComponent가 클라이언트에서 렌더링되기 이전에 서버에서 렌더링됩니다. 참고로 이 예시에서는 children prop을 사용했지만, 다른 이름의 prop을 직접 선언하고 사용할 수도 있습니다.
마지막 업데이트: 2025년 10월 24일 02시 34분
이 문서의 저작권은 이인제(소플)에 있습니다. 무단 전재와 무단 복제를 금합니다.