React children props의 진짜 동작 원리와 Context 리렌더링 이슈 분석

우디(박연기)·2025년 6월 1일
3

어느 날, 시지프로부터 위와 같은 질문과 함께 아래에 있는 예시 코드를 전달받았다. 직접 해당 코드를 실행해 보았는데, 예상했던 결과와는 정반대로 동작하는 것을 확인할 수 있었다. 이번 글에서는 왜 내가 예상한 대로 동작하지 않았는지, 그리고 그 원인이 무엇인지 분석한 내용을 정리하고자 한다.

import { createContext, useContext, useState } from "react";

function Comp() {
  console.log("Comp re-render");
  return <div>Comp</div>;
}

function CompWithContext() {
  useContext(TestContext);
  console.log("CompWithContext re-render");
  return <div>CompWithContext</div>;
}

export function TestComp1() {
  return (
    <TestProvider>
      <Comp />
      <CompWithContext />
    </TestProvider>
  );
}

export function TestComp2() {
  const [state, setState] = useState({});
  return (
    <TestContext.Provider value={state}>
      <button onClick={() => setState({})}>forceRender</button>
      <Comp />
      <CompWithContext />
    </TestContext.Provider>
  );
}

const TestContext = createContext({});

function TestProvider({ children }: { children: React.ReactNode }) {
  const [state, setState] = useState({});
  return (
    <TestContext.Provider value={state}>
      <button onClick={() => setState({})}>forceRender</button>
      {children}
    </TestContext.Provider>
  );
}

먼저 코드를 간단히 설명하면, CompuseContext를 사용하지 않는 일반적인 컴포넌트이고, CompWithContextuseContext를 통해 Context구독하는 컴포넌트이다.

TestComp1children props를 사용하여 자식 컴포넌트를 렌더링하는 구조이고, TestComp2는 하위 컴포넌트를 JSX 내부에서 직접 선언하여 렌더링하는 구조이다.

각 컴포넌트에는 Context의 상태를 강제로 변경할 수 있는 버튼도 함께 존재한다.

나는 TestComp1TestComp2 모두에서 Context 상태가 변경되면, 부모의 상태가 변경되므로 하위 컴포넌트인 CompCompWithContext 두 컴포넌트가 모두 리렌더링될 것이라고 예상했다.

하지만 실제로 두 컴포넌트를 실행해본 결과는 다음과 같았다

TestComp1을 실행한 경우

TestComp2를 실행한 경우

나의 예상과는 달리, TestComp2 컴포넌트의 하위 컴포넌트만 모두 리렌더링되고, TestComp1 컴포넌트는 useContext를 사용하는 컴포넌트인 CompWithContext 컴포넌트만 리렌더링이 발생했다. 왜 이런 현상이 발생했을까?

이 차이를 이해하려면 먼저 children props란 무엇이며, 어떻게 동작하는지에 대해 정확히 알아야 한다.

children props

children props를 이용하면, 특정 태그의 하위 컴포넌트로 컴포넌트를 작성할 수 있다.

// children을 사용한 예시 ✅
function A({children}){
	return(
		<div>
			{children}
		<div/>
	)
}

function App(){
	return(
		<A>
			<B/>
		</A>
	)
}

이 구조에서는 A 컴포넌트 내부에 어떤 내용을 렌더링할지 외부에서 결정할 수 있으므로, A 컴포넌트는 다양한 상황에 맞게 유연하게 재사용할 수 있다.

// children을 사용하지 않은 예시 ❌
function A(){
	return(
		<div>
			<B />
		<div/>
	)
}

function App(){
	return(
		<A />
	)
}

이 경우 A 내부에서 항상 B 컴포넌트만 렌더링되므로, A는 고정된 UI를 가지게 되고, 재사용 범위가 제한된다.

children props의 장단점

장점

  • 유연성
    • A 컴포넌트를 다양한 자식 컴포넌트와 함께 사용할 수 있다
  • 재사용성
    • 하나의 A 컴포넌트를 다양한 맥락에서 재사용 가능하다
  • 조합 가능성
    • 상위 컴포넌트가 하위 컴포넌트를 구성하는 방식을 자유롭게 조정할 수 있다

단점

  • 내부 구현이 불명확할 수 있음
    • children을 사용하는 컴포넌트는 외부에서 어떤 자식이 들어올지 예측하기 어렵기 때문에, 내부에서 자식 요소에 대한 제어가 어려울 수 있다
  • 과도한 추상화는 오히려 복잡도를 높일 수 있음

언제 children props를 사용해야 할까?

children props는 고정된 틀 안에 유동적인 콘텐츠를 삽입하거나, 컴포넌트 간 조합을 유연하게 구성해야 할 때, 또는 slot처럼 외부에서 정의한 콘텐츠를 내부에서 제어해야 할 때 유용하게 활용된다.

  1. 레이아웃 컴포넌트를 만들 때
  • 공통된 UI 틀은 유지하되, 내부 콘텐츠만 유동적으로 바꾸고 싶을 때 유용하다.
  • 상품의 레이아웃은 동일하고 내부 콘텐츠만 바뀌는 경우
  1. 어떤 요소가 렌더링되어야 할지 모를 때
  • 대표적인 예로 공용 버튼 컴포넌트가 있다
function Button({children, onClick}){
	return(
		<button onClick={onClick}>
			{children}
		</button>
	)
}

위 코드처럼, 버튼의 기본 구조는 동일하지만 내부 콘텐츠(텍스트, 아이콘 등)는 children을 통해 자유롭게 주입할 수 있다.

children의 동작 원리

리액트는 <App />과 같은 컴포넌트를 해석하기 위해, 내부적으로 React.createElement 함수를 호출한다. 하지만 여기서 App 컴포넌트가 실행된다는 것React.createElement를 호출한다는 것다르다.

정확히 말하면, <App />React.createElement(App)으로 변환되며, 이 호출 결과는 ReactElement 객체이다. 이 객체는 “App이라는 컴포넌트를 어떤 props로 렌더링할지”에 대한 정보를 담고 있는 일종의 설명서 역할을 한다. 즉, <App />곧바로 컴포넌트 함수를 실행하는 것이 아니라, React가 렌더링을 수행할 때 이 ReactElement를 해석하여 그 시점에 App() 컴포넌트 함수를 호출하게 된다.

App() 함수를 실행하면, App 내부의 JSX가 평가된다. 즉, App() 함수가 실행되어야 비로소 App 내부 JSX가 React.createElement("main", { id: "main" }, …)과 같은 형태로 변환되며, 새로운 ReactElement가 생성된다.

이러한 과정을 통해 React는 컴포넌트를 재귀적으로 따라가며 전체 UI 트리를 구성해 나간다.

📌 React.createElement 인자

첫 번째 인자는 최상위 태그로, 현재 예시에서는 "main"
두 번째 인자는 props로, 현재 예시에서는 { id: "main" }
세 번째 인자는 자식 콘텐츠로, 현재 예시에서는 React.createElement("p", null, "Hello")


이제, children이 어떻게 해석되는지 알아보자.

function Parent({children}){
	return(
		<div>
			{children}
		</div>
	)
}

function Child(){
	return(
		<div>
			자식 컴포넌트
		</div>
	)
}

function App(){
	return(
		<Parent>
			<Child />
		</Parnet>
	)	
}

리액트는 위 App()을 실행하면 App내부의 JSX를 다음과 같이 해석한다.

React.createElement(**Parent**, null, React.createElement(Child, null))

즉, <App />React.createElement(Parent, null, React.createElement(Child, null))로 변환되며, 이때 Child즉시 실행되어 ReactElement로 평가된 후, Parent 컴포넌트의 children으로 전달된다.

그리고 Parent 가 실행되면 Parent 내부 JSX가 다음과 같이 해석된다.

React.createElement('div', null, children)

여기서 중요한 점은, children은 JSX가 아닌, 이미 평가된 ReactElement라는 것이다. 즉, Parentchildren실행하는 것이 아니라, 전달받은 ReactElement화면에 렌더링만 한다.

결과적으로, Parent가 리렌더링되더라도 childrenApp에서 이미 평가된 값이므로 값이 변경되지 않는다., children의 리렌더링을 유발하려면 App 컴포넌트 자체가 리렌더링되어야 한다.

다음으로는 하위 컴포넌트를 JSX 내부에서 직접 선언한 방식의 동작 원리를 알아보자.

function Parent(){
	return(
		<div>
			<Child />
		</div>
	)
}

function Child(){
	return(
		<div>
			자식 컴포넌트
		</div>
	)
}

function App(){
	return(
		<Parent />
	)	
}

App()을 실행하면 AppJSX다음과 같이 해석한다.

React.createElement(Parent, null)

이후 리액트는 Parent 가 실행를 실행하고 Parent 내부 JSX 다음과 같이 변환한다.

React.createElement('div', null, React.createElement(**Child**, null))

다시 Child 컴포넌트를 재귀적으로 실행하여 JSX를 해석한다.

React.createElement('div', null, "자식 컴포넌트")

즉, Child 컴포넌트는 Parent 컴포넌트가 실행되는 과정에서 함께 실행된다.


위 두 예시를 비교하며 Child 컴포넌트가 언제 실행되는지 살펴보았다. children props를 사용하는 경우, ChildParent의 부모인 App 컴포넌트가 실행될 때 이미 평가된다.

반면, children props를 사용하지 않고 JSX 내부에 직접 <Child />를 선언한 경우에는, Parent 컴포넌트가 실행될 때 비로소 평가된다.

function Parent({children}){
	return(
		<div>
			{children}
		</div>
	)
}

function Child(){
	return(
		<div>
			자식 컴포넌트
		</div>
	)
}

function App(){
	return(
		<Parent>
			<Child />
		</Parnet>
	)	
}

이 말은 곧, 위 코드에서 children실질적인 부모Parent가 아니라 App 컴포넌트라고 볼 수 있다.

즉, childrenParent 내부에서 실행되는 것이 아니라, 상위 컴포넌트에서 이미 평가된 결과가 전달되는 것이라고 이해할 수 있다.

우리는 보통 JSX 상에서의 계층 구조를 보고 <Child /> 컴포넌트가 <Parent />의 자식이라고 생각한다. 하지만 실제로는 ChildParent형제 노드처럼 동작하며, 공통 부모인 App을 가진다.

이해를 돕기 위해 아래 코드처럼 바꿔 보면 더 명확해진다

function App(){
	return(
		<Parent children={<Child />}/>
	)	
}

이 코드를 보면, ChildApp 컴포넌트 내부에서 실행되며, 그 결과가 Parentchildren으로 전달된다.

즉, Child는 실제로 Parent 내부에서 실행되는 것이 아니라, 이미 상위 컴포넌트에서 실행된 React Element 객체가 내려오는 것이다.

반면, 아래와 같이 children props를 사용하지 않고 <Child />Parent 내부에 직접 선언한 경우에는

function Parent(){
	return(
		<div>
			<Children />
		</div>
	)
}

ChildParent가 실행될 때 함께 실행되므로, 이 경우에는 진짜로 Parent의 자식이라고 볼 수 있다.

결론

  • children props를 사용하는 경우, 상위 컴포넌트에서 평가된 결과가 전달된다. → Parent의 진짜 자식은 아님
  • JSX에 직접 <Child />를 선언하는 경우, 해당 컴포넌트 내부에서 평가된다. → Parent의 진짜 자식이다.

Context API에 적용해보자

이제 이 내용을 Context API에 적용해 보자.

import { createContext, useContext, useState } from "react";

function Comp() {
  console.log("Comp re-render");
  return <div>Comp</div>;
}

function CompWithContext() {
  useContext(TestContext);
  console.log("CompWithContext re-render");
  return <div>CompWithContext</div>;
}

export function TestComp1() {
  return (
    <TestProvider>
      <Comp />
      <CompWithContext />
    </TestProvider>
  );
}

export function TestComp2() {
  const [state, setState] = useState({});
  return (
    <TestContext.Provider value={state}>
      <button onClick={() => setState({})}>forceRender</button>
      <Comp />
      <CompWithContext />
    </TestContext.Provider>
  );
}

const TestContext = createContext({});

function TestProvider({ children }: { children: React.ReactNode }) {
  const [state, setState] = useState({});
  return (
    <TestContext.Provider value={state}>
      <button onClick={() => setState({})}>forceRender</button>
      {children}
    </TestContext.Provider>
  );
}

앞서 살펴본 TestComp1의 실행 결과는 Context를 구독하고 있는 CompWithContext 컴포넌트만 리렌더링이 된다.

이러한 결과는 앞에서 살펴본 children props의 동작 원리로 설명할 수 있다.

export function TestComp1() {
  return (
    <TestProvider>
      <Comp />
      <CompWithContext />
    </TestProvider>
  );
}

function TestProvider({ children }: { children: React.ReactNode }) {
  const [state, setState] = useState({});
  return (
    <TestContext.Provider value={state}>
      <button onClick={() => setState({})}>forceRender</button>
      {children}
    </TestContext.Provider>
  );
}

겉보기에는 CompCompWithContextTestProvider의 자식처럼 보인다. 그래서 TestProvider의 상태(state)가 변경되면 두 컴포넌트 모두 리렌더링될 것으로 예상할 수 있다.

하지만 앞서 설명했듯이, children props는 실제로는 자식 컴포넌트가 아니라 상위 컴포넌트에서 평가된 React Element를 전달받는 형식이다. 즉, TestProvider, Comp, CompWithContext실행 시점상 같은 레벨의 형제 노드처럼 동작하게 된다.

이를 명확히 표현하면 다음과 같이 작성할 수 있다

export function TestComp1() {
  return (
    <TestProvider children={[<Comp />, <CompWithContext />]}>
    </TestProvider>
  );
}

이처럼 TestProvider 실행될 때 이미 children은 평가가 완료된 상태이며, 이는 TestProvider에 상태가 변경되더라도 children 자체가 다시 평가되지 않음을 의미한다.
즉, TestProvider의 상태 변경은 children다시 실행시키지 않는다.

정리하면

  • children은 상위 컴포넌트에서 이미 평가된 React Element다.
  • 따라서 TestProvider의 상태가 바뀌더라도 children 자체는 다시 평가되지 않는다.
  • 단, context를 구독하고 있는 컴포넌트만 리렌더링이 발생한다.

이제, TestComp2를 살펴보자

export function TestComp2() {
  const [state, setState] = useState({});
  return (
    <TestContext.Provider value={state}>
      <button onClick={() => setState({})}>forceRender</button>
      <Comp />
      <CompWithContext />
    </TestContext.Provider>
  );
}

TestComp2의 실행 결과는 예상대로 CompCompWithContext 모두 리렌더링이 발생한다. 이는 너무나도 자연스러운 결과다.

현재 stateTestComp2 내부에서 관리되고 있으며, 이 상태 변경은 컴포넌트 전체의 리렌더링을 유발한다. 그리고 해당 상태를 직접적으로 참조하든 아니든, TestComp2자식 컴포넌트CompCompWithContext 모두 리렌더링의 영향을 받는다.

즉, 상위 컴포넌트의 상태 변경컴포넌트 리렌더링자식 컴포넌트 재호출리렌더링. 이 구조는 리액트의 기본 동작 방식이므로 특별할 것 없는, 당연한 결과다.

결론

그럼 이제, 처음 시지프가 내주었던 질문에 대해 답해보자

나의 결론은 다음과 같다.

Context API는 해당 Context를 구독하는 컴포넌트만 리렌더링되지만, 구현 방식에 따라 Provider 하위의 모든 컴포넌트가 리렌더링될 수도 있다.”

즉, Context 자체는 구독하는 컴포넌트에만 영향을 주지만, Context Provider가 포함된 컴포넌트의 구조에 따라 의도치 않은 리렌더링이 발생할 수도 있다. 결국, 리렌더링 범위는 Context의 특성뿐 아니라 렌더링 트리의 구조와 코드 작성 방식에 따라 달라진다.

참고

https://www.inflearn.com/community/questions/1045415/props-children-사용법-및-동작원리-질문?srsltid=AfmBOoqlhbgNRlYFVtujLyuKOMWzCi6Sj_BRZiKnHRZuOuXS7ZSHF40F

https://lasbe.tistory.com/186

profile
프론트엔드 개발하는 사람

1개의 댓글

comment-user-thumbnail
2025년 6월 1일

저도 질문에 대한 답을 몰랐는데
우디 덕분에 children에 따라 다르게 동작한다는 것을 알 수 있었습니다 !

답글 달기