useEffect의 의존성 배열은 훌륭한 제어 장치였지만, 동시에 가장 미묘하고 위험한 함정이었다. 페이스북 내부에서 훅을 도입하던 초기, 한 팀에서 원인을 알 수 없는 성능 저하 문제가 보고되었다.
신고를 받은 소피와 댄은 해당 팀의 코드를 함께 리뷰하기 시작했다.
문제의 컴포넌트는 사용자의 정보를 보여주는 간단한 프로필 카드였다. 이 카드는 user 객체를 prop으로 받아, 그 정보를 화면에 표시하고, user 객체가 바뀔 때마다 분석 로그를 서버로 전송하는 기능을 가지고 있었다.
function UserProfileCard({ user }) { // user는 { id: 1, name: 'Dan' } 형태의 객체
const [clickCount, setClickCount] = useState(0);
useEffect(() => {
// user prop이 바뀔 때마다 로그를 보낸다.
Analytics.logProfileView(user);
}, [user]); // 의존성 배열에 user 객체를 넣었다.
return (
<div onClick={() => setClickCount(c => c + 1)}>
<p>Name: {user.name}</p>
<p>Clicks: {clickCount}</p>
</div>
);
}
코드 자체는 논리적으로 완벽해 보였다.
useEffect는 user prop에 의존하고 있으므로, user가 바뀔 때만 실행될 터였다.
그런데 이상한 현상이 발생했다.
프로필 카드를 클릭해서 clickCount 상태만 바꾸었을 뿐인데도, useEffect가 계속해서 다시 실행되며 서버로 불필요한 로그를 끊임없이 전송하고 있었다. 브라우저의 네트워크 탭에는 logProfileView 요청이 쉴 새 없이 쌓여갔다. 컴포넌트는 사실상 무한 루프에 가까운 상태에 빠져 있었다.
“이해할 수가 없어요.” 코드를 작성한 개발자가 말했다. “저는 user prop을 바꾸지 않았습니다. 단지 내부 상태인 clickCount만 바꿨을 뿐인데, 왜 useEffect가 user가 바뀌었다고 판단하는 거죠?”
소피는 코드의 한 부분을 조용히 가리켰다. 바로 의존성 배열 [user]였다.
그녀가 설명했다.
“리액트는 의존성 배열의 값이 바뀌었는지 비교할 때, 자바스크립트의 Object.is 알고리즘을 사용합니다. 이것은 기본적으로 === 비교와 거의 동일하게 작동하죠.”
그녀는 화이트보드에 간단한 자바스크립트 코드를 적었다.
const user1 = { id: 1 };
const user2 = { id: 1 };
console.log(user1 === user2); // 결과는? false
“user1과 user2는 내용물이 똑같지만, 서로 다른 메모리 주소를 가진 별개의 객체입니다. 따라서 === 비교는 항상 false를 반환하죠.”
그 순간, 회의실의 모두가 문제의 원인을 깨달았다.
UserProfileCard의 부모 컴포넌트는 렌더링될 때마다, user 객체를 새로 만들어서 prop으로 전달하고 있었다.
function ParentComponent() {
// ...
// 렌더링될 때마다 새로운 user 객체가 생성된다!
const user = { id: 1, name: 'Dan' };
return <UserProfileCard user={user} />;
}
UserProfileCard가 리렌더링될 때마다, 리액트는 이전 렌더링의 user 객체와 현재 렌더링의 user 객체를 비교했다. 두 객체는 내용물은 같았지만, 메모리 주소가 달랐다. 리액트 입장에서는 ‘다른’ 값이라고 판단할 수밖에 없었다.
결국, useEffect는 매 렌더링마다 실행되었고, 그 안에서 상태를 변경하는 로직이 있다면 (이 예제에서는 없었지만, 만약 있었다면) 곧바로 무한 루프가 시작될 터였다.
이 문제는 객체뿐만 아니라, 함수나 배열을 의존성 배열에 넣었을 때도 똑같이 발생했다.
이것은 훅을 사용하는 개발자들이 가장 흔하게, 그리고 가장 고통스럽게 겪게 될 문제였다.
의존성 비교가 ‘값’이 아닌 ‘참조(reference)’를 기반으로 이루어진다는 사실을 이해하지 못하면, 누구든 이 교묘한 함정에 빠질 수 있었다.
소피는 직감했다.
이 문제를 해결하기 위해 단순히 개발자들에게 “객체나 함수를 의존성 배열에 넣을 땐 조심하세요”라고 말하는 것만으로는 부족했다.
그들에게는 더 근본적인 해결책과, 실수를 미연에 방지해 줄 더 강력한 도구가 필요했다.
그들이 이미 만들어 놓은 파수꾼, ESLint 플러그인의 역할이 다시 한번 중요해지는 순간이었다.


