React 렌더링과 Children Prop
React에서 children prop을 올바르게 이해하고 사용하는 것은 불필요한 렌더링을 막는 핵심 최적화 기법 중 하나입니다.
핵심 원리: React Element는 불변이다
가장 중요한 전제는 **"React Element는 불변(immutable)"**이라는 것입니다. UI를 변경하려면 새로운 엘리먼트를 생성해 기존 엘리먼트를 대체해야 합니다. 즉, 리렌더링은 새로운 엘리먼트를 만드는 과정입니다.
React는 리렌더링 시 이전 엘리먼트와 새로 생성된 엘리먼트를 비교하여 변경이 필요한 부분만 DOM에 실제 반영합니다. 이때 props가 변경되지 않았다면 해당 컴포넌트와 그 자식 컴포넌트의 리렌더링을 건너뛸 수 있습니다. children도 props 중 하나입니다.
children의 특징
- 매 렌더링마다 react element들은 새롭게 생성되고, 그로인해 object의 참조값이 변합니다.
- 한번 전달된 prop은 상위 컴포넌트가 리렌더링되지 않는한 갱신되지 않고 이전 prop값을 재사용합니다.
- 부모 컴포넌트가 부모 컴포넌트의 외부에서 정의된 children을 prop로 받으면, children은 object형태인 상수입니다.
- 부모 컴포넌트가 리렌더링될때 props로 전달된 children의 참조값은 동일하게 유지되므로, children은 리렌더링되지 않습니다.
- children이 부모 컴포넌트 내부에서 JSX 형태로 정의되었다면, 부모가 리렌더링될 때마다 새로운 엘리먼트가 생성되어 children도 리렌더링됩니다.
children을 쓰면 부모가 리렌더링된다?
잘못된 내용: Parent가 Child를 children prop으로 받으면 Child가 리렌더링될 때 Parent도 리렌더링된다.
이는 사실과 다릅니다. 오히려 그 반대가 children을 사용하는 핵심적인 최적화 원리입니다.
정확한 내용: 컴포넌트를 children으로 전달하면, 부모 컴포넌트가 리렌더링되어도 children 자체의 참조값은 변하지 않기 때문에 children의 불필요한 리렌더링을 막을 수 있습니다.
children은 부모 컴포넌트의 render 함수 외부에서 생성되어 전달됩니다. 따라서 부모의 상태가 변경되어 리렌더링이 발생하더라도, children prop으로 받은 JSX 엘리먼트는 이전에 생성된 것과 동일한 참조를 유지합니다. React는 이 참조가 동일함을 보고 children 부분은 "바뀐 것이 없네"라고 판단하여 렌더링 과정을 생략합니다.
올바른 최적화 예시 코드
아래 예시에서 App 컴포넌트의 count가 변경될 때 어떤 일이 일어나는지 주목해 보세요.
// ❌ 안티 패턴: 자식을 부모 안에서 직접 렌더링
import React, { useState } from 'react';
const HeavyComponent = () => {
console.log("무거운 컴포넌트가 렌더링되었습니다! 😱");
return <p>저는 아주 무거운 컴포넌트예요.</p>;
};
const Parent = () => {
// Parent가 렌더링될 때마다 HeavyComponent도 항상 새로 렌더링됩니다.
return (
<div>
<p>부모 컴포넌트</p>
<HeavyComponent />
</div>
);
};
export default function App() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>
카운트: {count}
</button>
<Parent />
</div>
);
}
결과: 버튼을 누를 때마다 App과 Parent가 리렌더링되고, 콘솔에 "무거운 컴포넌트가 렌더링되었습니다! 😱"가 계속 출력됩니다.
// ✅ 최적화 패턴: 자식을 children prop으로 전달
import React, { useState } from 'react';
const HeavyComponent = () => {
console.log("무거운 컴포넌트가 렌더링되었습니다! ✨");
return <p>저는 아주 무거운 컴포넌트예요.</p>;
};
// children을 prop으로 받는 부모
const Parent = ({ children }) => {
return (
<div>
<p>부모 컴포넌트</p>
{children}
</div>
);
};
export default function App() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>
카운트: {count}
</button>
{/* Parent는 리렌더링되지만, children으로 넘긴 HeavyComponent는
App 스코프에서 생성되었으므로 참조가 동일하여 리렌더링되지 않습니다.
*/}
<Parent>
<HeavyComponent />
</Parent>
</div>
);
}
결과: 버튼을 눌러도 HeavyComponent는 최초 한 번만 렌더링되고, 콘솔 메시지는 더 이상 출력되지 않습니다. 이것이 바로 "Composition을 통한 렌더링 최적화"입니다.
Next.js 서버/클라이언트 컴포넌트와 Children
Next.js의 App Router에서는 children prop을 이용한 합성이 더욱 중요해집니다.
클라이언트 컴포넌트가 서버 컴포넌트를 children으로 받는다면?
서버 컴포넌트는 서버에서 렌더링되어 그 결과물(RSC Payload)이 클라이언트로 전달됩니다. 클라이언트 컴포넌트는 이 결과물을 children prop으로 받을 수 있습니다.
이점:
- 서버-클라이언트 경계 유지: 상호작용이 필요한 클라이언트 컴포넌트("use client") 안에, 데이터베이스 접근 등 서버에서만 실행되어야 하는 서버 컴포넌트를 '끼워넣을' 수 있습니다.
- 성능 최적화: 클라이언트 컴포넌트의 상태가 변경되어 리렌더링되더라도, children으로 받은 서버 컴포넌트의 결과물은 다시 렌더링되지 않습니다. 서버에 다시 요청을 보내지 않는다는 의미입니다.
바로 잡아야 할 개념들:
- 서버 액션과 리렌더링: children으로 전달된 서버 컴포넌트에서 서버 액션이 실행되어도, 부모인 클라이언트 컴포넌트가 반드시 리렌더링되는 것은 아닙니다. Next.js 라우터가 서버 액션의 결과에 따라 영향을 받는 부분만 지능적으로 업데이트합니다.
- 서버 액션 사용 제한 이유: 클라이언트 컴포넌트 내에서 서버 액션 함수를 직접 정의하지 못하는 이유는 보안과 번들링 때문입니다. 서버에서만 실행되어야 할 코드(DB 접속 정보 등)가 클라이언트 번들에 포함되는 것을 막기 위함이며, 렌더링 루프와는 직접적인 관련이 적습니다. 서버 액션은 서버 컴포넌트에서 정의하여 prop으로 넘기거나, "use server" 지시어가 있는 별도 파일에 정의해야 합니다.
올바른 이해를 위한 예시 코드
// app/server-info.js (서버 컴포넌트)
// "use client"가 없으므로 기본적으로 서버 컴포넌트입니다.
export default async function ServerInfo() {
// 서버에서만 실행되는 로직 (예: DB 조회)
const data = await fetch('https://api.example.com/info', { cache: 'no-store' });
const info = await data.json();
console.log("✅ 서버 컴포넌트 렌더링");
return <p>서버에서 가져온 정보: {info.message}</p>;
}
// app/counter.js (클라이언트 컴포넌트)
"use client";
import { useState } from 'react';
export default function Counter({ children }) {
const [count, setCount] = useState(0);
console.log("⚛️ 클라이언트 컴포넌트 렌더링");
return (
<div style={{ border: '1px solid blue', padding: '10px' }}>
<p>여기는 클라이언트 컴포넌트입니다.</p>
<button onClick={() => setCount(c => c + 1)}>클릭: {count}</button>
{/* 서버 컴포넌트가 'children'으로 렌더링되는 영역 */}
<div style={{ marginTop: '10px', border: '1px solid green', padding: '10px' }}>
{children}
</div>
</div>
);
}
// app/page.js
import Counter from './counter';
import ServerInfo from './server-info';
export default function Page() {
return (
<main>
<h1>서버와 클라이언트 컴포넌트의 조화</h1>
<Counter>
{/* 클라이언트 컴포넌트의 children으로 서버 컴포넌트를 전달 */}
<ServerInfo />
</Counter>
</main>
);
}
결과:
- 페이지 첫 로드 시, 서버 콘솔에 "✅ 서버 컴포넌트 렌더링"이, 브라우저 콘솔에 "⚛️ 클라이언트 컴포넌트 렌더링"이 출력됩니다.
- 버튼을 클릭하면 Counter 컴포넌트만 리렌더링되어 브라우저 콘솔에 "⚛️ 클라이언트 컴포넌트 렌더링"만 반복해서 출력됩니다.
- children으로 전달된 ServerInfo는 다시 렌더링되거나 서버에 데이터를 재요청하지 않습니다.
클라이언트 컴포넌트의 경계는?
Next.js에서 어떤 컴포넌트가 클라이언트 컴포넌트가 되는지는 모듈 의존성에 따라 결정됩니다.
"use client" 지시어가 선언된 파일과, 그 파일에서 import하는 모든 하위 모듈(컴포넌트 포함)은 클라이언트 번들에 포함됩니다.
렌더링 트리 상의 부모-자식 관계가 아니라 import 관계가 중요합니다.
따라서 서버 컴포넌트를 클라이언트 컴포넌트의 자식으로 유지하고 싶다면, import하는 대신 children prop으로 전달해야 합니다.
'Next.js' 카테고리의 다른 글
next.js parallel & intercepting route 구현하는 방법 (0) | 2024.03.06 |
---|