어느 날, 시지프로부터 위와 같은 질문과 함께 아래에 있는 예시 코드를 전달받았다. 직접 해당 코드를 실행해 보았는데, 예상했던 결과와는 정반대로 동작하는 것을 확인할 수 있었다. 이번 글에서는 왜 내가 예상한 대로 동작하지 않았는지, 그리고 그 원인이 무엇인지 분석한 내용을 정리하고자 한다.
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>
);
}
먼저 코드를 간단히 설명하면, Comp
는 useContext
를 사용하지 않는 일반적인 컴포넌트이고, CompWithContext
는 useContext
를 통해 Context
를 구독하는 컴포넌트이다.
TestComp1
은 children props
를 사용하여 자식 컴포넌트를 렌더링하는 구조이고, TestComp2
는 하위 컴포넌트를 JSX 내부에서 직접 선언하여 렌더링하는 구조이다.
각 컴포넌트에는 Context
의 상태를 강제로 변경할 수 있는 버튼도 함께 존재한다.
나는 TestComp1
과 TestComp2
모두에서 Context
상태가 변경되면, 부모의 상태가 변경되므로 하위 컴포넌트인 Comp
와 CompWithContext
두 컴포넌트가 모두 리렌더링될 것이라고 예상했다.
하지만 실제로 두 컴포넌트를 실행해본 결과는 다음과 같았다
TestComp1
을 실행한 경우
TestComp2
를 실행한 경우
나의 예상과는 달리, TestComp2
컴포넌트의 하위 컴포넌트만 모두 리렌더링되고, TestComp1
컴포넌트는 useContext
를 사용하는 컴포넌트인 CompWithContext
컴포넌트만 리렌더링이 발생했다. 왜 이런 현상이 발생했을까?
이 차이를 이해하려면 먼저 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는 고정된 틀 안에 유동적인 콘텐츠를 삽입하거나, 컴포넌트 간 조합을 유연하게 구성해야 할 때, 또는 slot처럼 외부에서 정의한 콘텐츠를 내부에서 제어해야 할 때 유용하게 활용된다.
function Button({children, onClick}){
return(
<button onClick={onClick}>
{children}
</button>
)
}
위 코드처럼, 버튼의 기본 구조는 동일하지만 내부 콘텐츠(텍스트, 아이콘 등)는 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
라는 것이다. 즉, Parent
는 children
을 실행하는 것이 아니라, 전달받은 ReactElement
를 화면에 렌더링만 한다.
결과적으로, Parent
가 리렌더링되더라도 children
은 App
에서 이미 평가된 값이므로 값이 변경되지 않는다., children
의 리렌더링을 유발하려면 App
컴포넌트 자체가 리렌더링되어야 한다.
다음으로는 하위 컴포넌트를 JSX 내부에서 직접 선언한 방식의 동작 원리를 알아보자.
function Parent(){
return(
<div>
<Child />
</div>
)
}
function Child(){
return(
<div>
자식 컴포넌트
</div>
)
}
function App(){
return(
<Parent />
)
}
App()
을 실행하면 App
의 JSX를 다음과 같이 해석한다.
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
를 사용하는 경우, Child
는 Parent
의 부모인 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
컴포넌트라고 볼 수 있다.
즉, children
은 Parent
내부에서 실행되는 것이 아니라, 상위 컴포넌트에서 이미 평가된 결과가 전달되는 것이라고 이해할 수 있다.
우리는 보통 JSX 상에서의 계층 구조를 보고 <Child />
컴포넌트가 <Parent />
의 자식이라고 생각한다. 하지만 실제로는 Child
와 Parent
는 형제 노드처럼 동작하며, 공통 부모인 App
을 가진다.
이해를 돕기 위해 아래 코드처럼 바꿔 보면 더 명확해진다
function App(){
return(
<Parent children={<Child />}/>
)
}
이 코드를 보면, Child
는 App
컴포넌트 내부에서 실행되며, 그 결과가 Parent
의 children
으로 전달된다.
즉, Child
는 실제로 Parent
내부에서 실행되는 것이 아니라, 이미 상위 컴포넌트에서 실행된 React Element
객체가 내려오는 것이다.
반면, 아래와 같이 children props
를 사용하지 않고 <Child />
를 Parent
내부에 직접 선언한 경우에는
function Parent(){
return(
<div>
<Children />
</div>
)
}
Child
는 Parent
가 실행될 때 함께 실행되므로, 이 경우에는 진짜로 Parent
의 자식이라고 볼 수 있다.
children props
를 사용하는 경우, 상위 컴포넌트에서 평가된 결과가 전달된다. → Parent의 진짜 자식은 아님<Child />
를 선언하는 경우, 해당 컴포넌트 내부에서 평가된다. → Parent의 진짜 자식이다.이제 이 내용을 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>
);
}
겉보기에는 Comp
와 CompWithContext
가 TestProvider
의 자식처럼 보인다. 그래서 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
의 실행 결과는 예상대로 Comp
와 CompWithContext
모두 리렌더링이 발생한다. 이는 너무나도 자연스러운 결과다.
현재 state
는 TestComp2
내부에서 관리되고 있으며, 이 상태 변경은 컴포넌트 전체의 리렌더링을 유발한다. 그리고 해당 상태를 직접적으로 참조하든 아니든, TestComp2
의 자식 컴포넌트인 Comp
와 CompWithContext
모두 리렌더링의 영향을 받는다.
즉, 상위 컴포넌트의 상태 변경 → 컴포넌트 리렌더링 → 자식 컴포넌트 재호출 → 리렌더링. 이 구조는 리액트의 기본 동작 방식이므로 특별할 것 없는, 당연한 결과다.
그럼 이제, 처음 시지프가 내주었던 질문에 대해 답해보자
나의 결론은 다음과 같다.
“Context API
는 해당 Context
를 구독하는 컴포넌트만 리렌더링되지만, 구현 방식에 따라 Provider
하위의 모든 컴포넌트가 리렌더링될 수도 있다.”
즉, Context
자체는 구독하는 컴포넌트에만 영향을 주지만, Context Provider
가 포함된 컴포넌트의 구조에 따라 의도치 않은 리렌더링이 발생할 수도 있다. 결국, 리렌더링 범위는 Context
의 특성뿐 아니라 렌더링 트리의 구조와 코드 작성 방식에 따라 달라진다.
저도 질문에 대한 답을 몰랐는데
우디 덕분에 children에 따라 다르게 동작한다는 것을 알 수 있었습니다 !