요즘 AI와 에이전트를 적극적으로 활용하며 개발을 진행하다 보니 문득 이런 생각이 들었습니다.
'내가 기술의 주도권을 쥐고 있는 걸까, 아니면 단순히 AI가 짜준 코드를 조립하고만 있는 걸까?'
강력한 도구 덕분에 생산성은 극대화되었지만, 역설적으로 프레임워크의 핵심 철학이나 기본기에 대해서는 점차 무뎌지고 있다는 위기감이 찾아왔습니다.
아무리 AI가 훌륭한 코드를 빠르게 뱉어내더라도, 결국 전체적인 시스템의 아키텍처를 설계하고 디테일한 퀄리티를 결정짓는 것은 개발자의 단단한 기본기입니다.
도구에 휘둘리지 않고 주도적인 개발을 하기 위해, 다시 처음으로 돌아가 기본을 점검해 보려 합니다.
그 첫걸음으로, React 공식 문서의 'React처럼 사고하기(Thinking in React) 를 다시 읽으며 컴포넌트를 설계하고 상태를 관리하는 리액트의 핵심 패러다임을 깊이 있게 정리해 봅니다.
프론트엔드 개발을 하면서, 가장 중요한 것중에 하나는 "리액트 문법을 아느냐"가 아니라 "데이터가 어디서 와서 어디로 흐르는지를 머릿속에 그릴 수 있느냐" 였습니다.
리액트를 처음 배우면 useState, props, useEffect를 단순히 도구로써 받아들입니다. 그런데 시간이 지남에 따라 이것들이 도구가 아니라 하나의 일관된 모델이라는 걸 알게 됩니다.
그 모델은 딱 한 문장으로 요약됩니다.
"데이터는 위에서 아래로 흐른다. UI는 그 데이터의 함수다."
이 한 문장을 진짜로 체득하면, 평소에 헷갈리던 질문들이 거의 자동으로 풀립니다.
Q) 부모가 준 값을 내 state에 넣어도 되나?
Q) 자식이 부모 데이터를 바꾸려면?
Q) 이건 useEffect에 넣어야 하나, 말아야 하나?
하나씩 풀어보겠습니다.
먼저 전체 지도를 그려놓고 시작합시다. 리액트의 모든 규칙은 결국 이 그림 한 장에서 파생됩니다.

핵심은 방향이 정해져 있다는 점입니다.
이 단순한 규칙이 지켜지면, 어떤 화면이 잘못 그려졌을 때 "데이터가 어디서 왔는지"를 한 방향으로만 거슬러 올라가면 원인을 찾을 수 있습니다. 디버깅이 추적 가능해지는 거죠.
| state | props | |
|---|---|---|
| 소유자 | 그 컴포넌트 자신 | 부모 |
| 변경 권한 | 자신만 (setState) | 없음 (읽기 전용) |
| 값이 바뀌면? | 내가 리렌더 | 부모가 리렌더 → 새 값 전달 |
좀 더 간단히 요약하자면 다음과 같습니다. state는 "내가 기억하고, 내가 바꾸는 값"이고, props는 "부모가 나에게 보여주는, 내가 못 건드리는 값"입니다.
가장 흔하고, 가장 고치기 어려운 안티패턴입니다.
코드 리뷰에서 은근히 자주 잡는 것이기도 하고요.
예를 들어 다음과 같은 코드가 있다고 합시다.
해당 코드에서는 부모가 userName을 props로 내려줍니다.
function Greeting({ userName }) {
// props를 state로 복사 (미러링)
const [name, setName] = useState(userName);
return <h1>안녕하세요, {name}님</h1>;
}
언뜻 동작하는 것처럼 보입니다. 그런데 여기에 숨어 있는 버그가 있습니다.
useState(userName)의 userName은 딱 한 번, 마운트될 때만 초기값으로 쓰입니다.
그 뒤로 부모가 userName을 "철수"에서 "영희"로 바꿔도, 자식의 name은 여전히 "철수"입니다.

이렇게 되면 화면에는 여전히 "철수님"이 떠 있습니다.
props는 바뀌었는데 화면은 안 바뀌는, 가장 디버깅하기 짜증나는 종류의 버그죠.
우리가 대부분 아는 규칙은 이렇습니다.
부모가 리렌더되면, 그 자식 컴포넌트도 (기본적으로) 함께 리렌더된다.
맞습니다. 부모의 userName이 "영희"로 바뀌면 부모가 리렌더되고, 자식 Greeting도 리렌더됩니다.
리렌더된다는 건 곧 Greeting 함수의 본문이 처음부터 다시 실행된다는 뜻입니다.
당연히 const [name, setName] = useState(userName) 이 줄도 다시 실행되고, 이때 userName은 이미 "영희"죠.
그러면
useState("영희")니까name도"영희"가 되어야 하는 거 아닌가요?
useState에 넘긴 인자는 "초기값(initial value)"입니다. 그리고 리액트는 이 초기값을 그 컴포넌트가 처음 마운트될 때 딱 한 번만 사용합니다.
두 번째 렌더부터는 useState에 무슨 값을 넣든 그 인자를 완전히 무시하고, 리액트가 내부에 저장해둔 "기존 state"를 그대로 돌려줍니다.
즉, 리렌더 때 실행되는
useState("영희")는"영희"를 보고도 무시하고, 예전에 저장된"철수"를 반환합니다.

정리하면 이렇습니다.
name이 갱신되지 않는 이유는, 리렌더 시 useState가 인자(userName)를 읽지 않고 저장된 state를 반환하도록 설계되어 있기 때문입니다.setName을 통해서만 바뀝니다".setName을 호출하지 않는 한 그 state는 절대 스스로 갱신되지 않습니다.💡 이게 바로 state와 props가 서로 다른 생명주기를 가진 별개의 데이터라는 증거입니다.
props는 "매 렌더마다 부모가 새로 건네주는 값"이고, state는 "컴포넌트가 마운트 때부터 끌어안고 있는,setter로만 바뀌는 값"입니다.
이 둘을useState(prop)으로 묶는 순간, 태어날 때 한 번 찍힌 스냅샷이 그대로 굳어버리는 겁니다.
다음과 같이 코드를 작성할 수 있습니다.
function Greeting({ userName }) {
const [name, setName] = useState(userName);
// Effect을 통한 동기화
useEffect(() => {
setName(userName);
}, [userName]);
return <h1>안녕하세요, {name}님</h1>;
}
위의 코드는 잘 동작합니다.
하지만 약간은 code smell이 나는데요, 왜일까요?
userName이 바뀌면 → 부모 리렌더 → 자식 리렌더(이전 name으로 한 번 그림) → Effect 실행 → setName → 또 리렌더. 렌더가 두 번 돕니다.
따라서 userName과 name. 둘이 어긋날 수 있는 화면이 항상 존재합니다.
기억해야할 핵심 원칙: 이미 props나 다른 state로부터 계산해낼 수 있는 값은 절대 state에 따로 저장하지 마세요. 이건 "파생 데이터"이지 "상태"가 아닙니다.
이 경우는 state도 Effect도 필요 없습니다.
단순히 렌더링 중에 계산하면 되므로 그냥 변수로써 계산하면 됩니다.
function Greeting({ userName }) {
// 렌더링 중에 계산
const displayName = userName.trim() || "손님";
return <h1>안녕하세요, {displayName}님</h1>;
}
userName이 바뀌면 컴포넌트가 리렌더되고, displayName은 그 자리에서 새로 계산됩니다.
어긋날 틈이 없습니다. 무겁게 계산되는 값이라면 useMemo로 감싸면 되고요.
"이 prop이 바뀌면 이 컴포넌트의 상태를 처음부터 다시 시작하고 싶다"면, Effect로 일일이 리셋하지 말고 key를 주세요.
// 부모 컴포넌트
<UserProfile key={userId} userId={userId} />
key가 바뀌면 리액트는 그 컴포넌트를 완전히 새로 마운트합니다. 내부 state가 전부 초기화되죠.
이게 "prop 변화에 따른 리셋"의 정석입니다.
"미러링"하려고 Effect를 쓰는 건 안 되지만, "부수 효과(side effect)"를 일으키려고 Effect를 쓰는 건 맞습니다.
예를 들어, 선택된 상품 ID가 바뀔 때마다 분석 이벤트를 서버로 쏘거나, 문서 제목을 바꾸거나, 외부 라이브러리에 알려주는 건 진짜 부수 효과입니다.
이때는 useEffect를 활용하면 되는 거죠.
function ProductPage({ productId }) {
useEffect(() => {
analytics.track("view_product", { productId })
}, [productId]);
return <Detail id={productId} />;
}
데이터는 아래로만 흐른다고 했습니다. 그런데 현실에서는 자식의 입력창에 글자를 치면 부모가 알아야 하는 상황이 널려 있습니다.
자식이 부모의 데이터를 바꿔야 하는 거죠.
데이터는 위로 못 흐르는데, 어떻게 할까요?
답은 "데이터 대신 이벤트를 위로 보낸다"입니다. 이걸 역방향 데이터 흐름이라고 부릅니다.

흐름을 말로 풀면:
value)과 바꾸는 방법(onChange 함수)을 둘 다 내려줍니다.<input>에서 이벤트가 발생합니다.setText이므로, 부모의 state가 바뀌고, 리렌더가 일어나 새 value가 다시 아래로 흐릅니다.즉, 데이터는 끝까지 아래로만 흐릅니다. 위로 올라간 건 데이터가 아니라 "바꿔달라는 요청(함수 호출)"일 뿐입니다.
단방향 원칙이 깨지지 않았습니다.
function SearchInput({ value, onChange }) {
return (
<input
value={value}
onChange={(e) => onChange(e.target.value)} // 부모가 준 함수 호출
/>
);
}
function SearchPage() {
const [keyword, setKeyword] = useState("");
return (
<>
<SearchInput value={keyword} onChange={setKeyword} />
<p>검색어: {keyword}</p>
</>
);
}
역방향 흐름이 필요해지는 순간, 자연스럽게 따라오는 질문이 있습니다.
"이 state는 누가 가져야 하지?"
원칙은 이렇습니다.
그 state를 필요로 하는 모든 컴포넌트의 "가장 가까운 공통 부모"에 둔다.

검색창(A)에 친 글자를 결과 목록(B)이 봐야 한다면, 둘의 공통 부모로 state를 끌어올리고, 양쪽에 props로 내려줍니다.
이렇게 하여 형제 컴포넌트는 서로 직접 대화하지 않습니다.
💡 이 패턴이 손에 익으면, Redux·Zustand·Context 같은 전역 상태 도구가 "왜 필요한지"도 자연스럽게 이해됩니다.
끌어올리기를 앱 최상단까지 한 게 결국 전역 스토어거든요.
처음 리액트를 마주했을 때 배웠던 "Thinking in React(React처럼 사고하기)"를 다시금 짚어보며, 화려한 도구들에 가려져 잠시 잊고 있었던 본질을 마주할 수 있었습니다.
AI 에이전트에게 "이러한 기능을 하는 컴포넌트를 만들어줘"라고 요청하면 순식간에 수십 줄의 코드가 뚝딱 만들어집니다. 하지만 그 AI가 만든 컴포넌트들을 어디서 어떻게 쪼갤지, 상태(State)의 소유권을 어느 부모에게 넘길지, 그리고 데이터의 흐름을 얼마나 추적 가능하게 설계할지를 결정하는 것은 결국 스크린 앞에 앉아 있는 개발자의 몫입니다.
단방향 데이터 흐름을 이해하고, 파생 데이터를 상태와 분리하며, 올바른 생명주기에 맞게 설계하는 '기본기'야말로 AI 시대에 개발자의 대체 불가능한 무기가 된다고 믿습니다.
도구가 강력해질수록 우리는 더 날카로운 안목을 가져야 합니다. 오늘 복기한 리액트의 핵심 패러다임을 나침반 삼아, 앞으로 AI와 협업할 때도 코드 조립공이 아닌 '주도적인 아키텍트'로서 중심을 잡고 개발을 이어 나가고자 합니다.