Intro
시작하기에 앞서 두 개의 코드 스니핏을 보여드리겠습니다. 어떤 차이가 있을까요?
참고로 Counter 컴포넌트의 로직은 같습니다.
이번 글에서 다룰 내용은 아래 두 코드의 차이에 대한 설명입니다. 바로 맞추신 분들은 자신있게 뒤로가기를 누르셔도 됩니다.
// 1️⃣ 삼항 연산자
function Scoreboard() {
const [isPlayerA, setIsPlayerA] = useState(true);
return (
<div>
{isPlayerA ? (
<Counter person="Taylor" />
) : (
<Counter person="Sarah" />
)}
<button onClick={() => {
setIsPlayerA(!isPlayerA);
}}>
Next player!
</button>
</div>
);
}
// 2️⃣ && 연산자
function Scoreboard() {
const [isPlayerA, setIsPlayerA] = useState(true);
return (
<div>
{isPlayerA &&
<Counter person="Taylor" />
}
{!isPlayerA &&
<Counter person="Sarah" />
}
<button onClick={() => {
setIsPlayerA(!isPlayerA);
}}>
Next player!
</button>
</div>
);
}
function Counter({ person }) {
const [score, setScore] = useState(0);
const [hover, setHover] = useState(false);
let className = 'counter';
if (hover) {
className += ' hover';
}
return (
<div
className={className}
onPointerEnter={() => setHover(true)}
onPointerLeave={() => setHover(false)}
>
<h1>{person}'s score: {score}</h1>
<button onClick={() => setScore(score + 1)}>
Add one
</button>
</div>
);
}
1️⃣번 코드(삼항 연산자)에서 Counter 컴포넌트간 내부 상태가 공유된다는 이슈가 있습니다.
아래 영상을 참고 부탁드립니다. person이 바뀌었을 때 score가 초기화되는 것을 의도했으나 Taylor에서 Sarah로 바뀌어도 스코어가 그대로 유지됩니다. 왜 이런 일이 발생하는 걸까요? 그리고 2️⃣코드에서는 왜 이 문제가 해결됐을까요?
이번 글에서는 리액트에서 같은 상태로 취급하는 조건과 상태 초기화 방법에 대해서 알아보겠습니다.
상태는 렌더트리 위치에 연결됩니다
리액트에서는 컴포넌트 구조에 따른 렌더 트리를 만듭니다.
컴포넌트에서 사용되는 상태는 컴포넌트 내부에 귀속되는 것이 아닌 리액트 인스턴스가 관리합니다. 리액트는 컴포넌트 UI트리에 있는 위치를 이용해 리액트 인스턴스가 가지고 있는 각 상태를 렌더된 컴포넌트와 연결합니다.
이 지식을 바탕으로 위 코드의 사례를 풀어서 설명해보겠습니다.
컴파일된 JSX코드를 보면 두 코드의 차이가 좀 더 명확해집니다. 우선 createElement 함수의 타입스크립트 정의부터 살펴보겠습니다. 여기서 주목해야하는 것은 세번째 매개변수입니다. children요소들이 rest문법으로 정의되어 있네요.
function createElement<P extends DOMAttributes<T>, T extends Element>(
type: string,
props?: ClassAttributes<T> & P | null,
...children: ReactNode[]
): DOMElement<P, T>;
첫번째 케이스(삼항 연사자)를 사용한 코드의 컴파일된 코드입니다.
children을 보면 배열의 길이가 2입니다. 주목해야 하는 Counter컴포넌트가 0인덱스에 렌더링된다는 것을 알 수 있습니다.
// 1️⃣ 삼항연산자 사용
React.createElement(
'div',
null,
React.createElement(Counter, { person: 'Taylor' }), // children[0]
React.createElement('button', { onClick: ... }, 'Next player!') // children[1]
);
두번째 케이스(&&연산자)입니다. children의 길이가 3이고 children[1]의 자리에 null이 추가된 것을 알 수 있습니다.
// 2️⃣ && 연산자 사용
React.createElement(
'div',
null,
React.createElement(Counter, { person: 'Taylor' }), // children[0]
null, // children[1]
React.createElement('button', { onClick: ... }, 'Next player!') // children[2]
);
이제 다시 "상태는 렌더트리 위치에 연결된다"라는 말을 다시 곱씹어보겠습니다. 1️⃣케이스에서는 위치(0인덱스)가 똑같기 때문에 에 상태가 공유됐고 2️⃣케이스에서는 위치(Tayor는0, sarah는 1)가 다르기 때문에 상태가 초기화됐습니다.
2️⃣케이스를 이미지로 표현하면 다음과 같습니다.
각 Counter의 인덱스 위치가 다르기 떄문에 각각의 상태가 격리됩니다.

같은 자리의 같은 컴포넌트는 상태를 보존합니다
렌더 트리상 위치가 동일해야 한다는 것이 상태가 보존되는 조건임을 배웠습니다. 좀 더 구체적으로 말하면 같은 자리에 같은 컴포넌트이라는 세부 조건이 있습니다.
이를 설명할 좋은 코드가 있습니다. 코드를 한번 천천히 읽어보세요
import { useState } from 'react';
export default function App() {
const [isFancy, setIsFancy] = useState(false);
if (isFancy) {
return (
<div>
<Counter isFancy={true} />
<label>
<input
type="checkbox"
checked={isFancy}
onChange={e => {
setIsFancy(e.target.checked)
}}
/>
Use fancy styling
</label>
</div>
);
}
return (
<div>
<Counter isFancy={false} />
<label>
<input
type="checkbox"
checked={isFancy}
onChange={e => {
setIsFancy(e.target.checked)
}}
/>
Use fancy styling
</label>
</div>
);
}
function Counter({ isFancy }) {
const [score, setScore] = useState(0);
const [hover, setHover] = useState(false);
let className = 'counter';
if (hover) {
className += ' hover';
}
if (isFancy) {
className += ' fancy';
}
return (
<div
className={className}
onPointerEnter={() => setHover(true)}
onPointerLeave={() => setHover(false)}
>
<h1>{score}</h1>
<button onClick={() => setScore(score + 1)}>
Add one
</button>
</div>
);
}
App컴포넌트에서 isFancy 상태에 따라서 조건부 렌더링을 하고 있습니다.
여기서 다시 Counter컴포넌트의 상태는 isFancy에 따라 초기화될까요? 보존될까요?
정답은 보존된다입니다.
이 결과의 이유가 상태를 컴포넌트가 아닌 리액트 인스턴스가 가지고 있으며 동일 위치, 동일 컴포넌트일 경우 상태를 보존하기 때문입니다.
그리고 아래 코드에서 상태가 초기화되는 이유 역시 동일 컴포넌트 라는 조건 때문입니다.
function App() {
const [isFancy, setIsFancy] = useState(false);
return (
<div>
{isFancy ? (
<div>
<Counter isFancy={true} />
</div>
) : (
<section>
<Counter isFancy={false} />
</section>
)}
<label>
<input
type="checkbox"
checked={isFancy}
onChange={e => {
setIsFancy(e.target.checked)
}}
/>
Use fancy styling
</label>
</div>
);
}


동일 컴포넌트의 상태 초기화하기
만약 동일 컴포넌트의 상태를 초기화하려면 어떻게 할까요? 보존의 조건이 동일 위치, 동일 컴포넌트인데 컴포넌트는 동일하니까 위치를 바꾸면 됩니다.
Children[] 에서 인덱스 위치를 변경한다
서문에서 소개한 방식입니다. children의 위치가 바뀌면 상태가 격리될테고 상태가 초기화됨을 확인했습니다.
// 방법1️⃣ children 위치를 변경한다.
function Scoreboard() {
const [isPlayerA, setIsPlayerA] = useState(true);
return (
<div>
{isPlayerA &&
<Counter person="Taylor" />
}
{!isPlayerA &&
<Counter person="Sarah" />
}
<button onClick={() => {
setIsPlayerA(!isPlayerA);
}}>
Next player!
</button>
</div>
);
}
key Prop사용하기
동일 컴포넌트의 상태를 격리시키기 위한 가장 좋은 방법은 key를 사용하는 것입니다.
앞서 동일 컴포넌트 동일 위치의 상태 격리를 위해 위치를 변경해야 한다고 말했습니다. 그런데 key는 다른 얘기 아닌가? 하는 의문이 들 수 있습니다. 하지만 children 배열의 인덱스와 key 모두 인덱스라는 관점에서 바라보면 같은 주소의 같은 컴포넌트라는 조건이 성립합니다.
function Scoreboard() {
const [isPlayerA, setIsPlayerA] = useState(true);
return (
<div>
{isPlayerA ? (
<Counter key="Taylor" person="Taylor" />
) : (
<Counter key="Sarah" person="Sarah" />
)}
<button onClick={() => {
setIsPlayerA(!isPlayerA);
}}>
Next player!
</button>
</div>
);
}
마무리
상태는 UI트리 위치에 따라 연결된다 라는 주제로 이야기를 나눠봤습니다.
개인적으로 서문에서 소개했던 코드 스니핏은 해설을 듣기전 쉽게 납득하기 어려운 결과였습니다. 리액트의 권고사항을 잘 따랐다면 겪지 않았겠지만 간혹 서로 다른 컴포넌트의 상태가 공유되거나 초기화되지 않는 이슈를 겪으셨을 수 있습니다. 이 글이 트러블 슈팅의 힌트가 되길 바랍니다.
참고
https://ko.react.dev/learn/preserving-and-resetting-state
State를 보존하고 초기화하기 – React
The library for web and native user interfaces
ko.react.dev
'react' 카테고리의 다른 글
| useEffect, 의존성 배열 lint를 무시하면 안되는 이유 (0) | 2025.12.19 |
|---|---|
| useReducer를 이용한 상태 모델링의 이점 (0) | 2025.12.19 |
| [설계] 실무에서 자주 겪는 잘못된 추상화 사례 (0) | 2025.12.19 |
| [설계] 모듈 응집도와 단방향 의존성을 통한 유지보수 비용 줄이기 (0) | 2025.12.18 |
| [설계] UI로직에서 비즈니스 로직 분리하기 (0) | 2025.12.17 |