Frontend 22
React UI 갱신의 비밀: useState와 상태 불변성
posts 배열을 직접 바꾸면 콘솔에는 바뀌는데 화면은 왜 그대로인지, useState가 정확히 무엇을 해주는지 모르겠다.
근거 · 교안 p137-p149
1장: 직접 수정의 함정: React는 왜 못 보는가?
솔라의 손가락이 노트북 터치패드 위에서 초조하게 맴돌았다. 화면 한쪽에는 방금 막 만든 간단한 블로그 페이지가, 다른 한쪽에는 개발자 도구의 콘솔 창이 열려 있었다. 모든 게 완벽해 보였다. 게시물 목록이 있고, 그 아래 ‘글 추가’ 버튼이 있었다. 하지만 버튼을 누를 때마다 솔라의 미간은 좁아졌다.
“이상하잖아, 언니. 봐봐.”
솔라의 부름에 옆에서 책을 읽던 루나가 조용히 고개를 돌렸다. 솔라는 화면을 가리키며 ‘글 추가’ 버튼을 다시 한번 클릭했다.
“자, 여기 콘솔에는 분명 ‘새 글’이 추가됐다고 나오거든? 배열 길이가 2에서 3으로 늘어났고, 마지막에 새 객체도 잘 들어가 있어.”
솔라의 말대로 콘솔 창에는 방금 추가된 게시물 정보를 포함한 배열이 선명하게 찍혀 있었다. 하지만 브라우저 화면의 게시물 목록은 요지부동이었다. 초기 게시물 두 개만 덩그러니 자리를 지킬 뿐이었다.
“근데 화면은 왜 그대로냐고. JavaScript에서는 배열에 push 쓰면 그냥 바뀌는 거 아니었어? React도 JavaScript니까 당연히 이것도 알아서 그려줘야 하는 거 아니야?”
솔라는 당연한 사실이 배신당한 것 같은 얼굴이었다. 그녀의 코드에서 ‘글 추가’ 버튼이 하는 일은 지극히 단순했다. 컴포넌트 안에 선언된 posts라는 배열에 새 게시물 객체를 push로 밀어 넣고, 그 결과를 콘솔에 출력하는 게 전부였다. 데이터는 분명히 바뀌었다. 하지만 눈에 보이는 결과는 전혀 바뀌지 않았다.
루나는 솔라의 화면을 잠시 들여다보았다. 코드 몇 줄과, 그 코드의 배신에 어쩔 줄 몰라 하는 솔라의 표정을 번갈아 보았다.
// 솔라가 작성한 코드의 일부
function PostList() {
let posts = [
{ id: 1, title: '리액트 공부 시작!' },
{ id: 2, title: '어렵지만 재밌다' },
];
const addPost = () => {
posts.push({ id: 3, title: '새 글' });
console.log('게시물 추가됨:', posts);
};
return (
<div>
{posts.map(post => (
<div key={post.id}>{post.title}</div>
))}
<button onClick={addPost}>글 추가</button>
</div>
);
}
루나는 화면 대신, 책상 위에 놓인 빈 상자와 조약돌 몇 개를 가져왔다. 그녀는 상자 안에 조약돌 두 개를 넣고 뚜껑을 닫았다.
“솔라, 이게 네가 처음 화면을 그릴 때 사용한 posts 배열이라고 생각해봐.”
루나는 상자를 가리켰다.
“그리고 나는 React라고 할게. 내 임무는 아주 간단해. 이 상자가 ‘완전히 새로운 상자’로 바뀌었을 때만 알아차리고 보고하는 거야.”
솔라는 의아한 표정으로 루나를 바라봤다. 루나는 말을 이었다.
“자, 이제 네가 한 일을 똑같이 해봐. posts.push 말이야. 그건 이 상자에 무슨 일을 하는 것과 같을까?”
솔라는 잠시 생각하더니, 루나가 닫아놓은 상자 뚜껑을 살짝 열고 손에 든 다른 조약돌 하나를 몰래 집어넣었다. 그리고 뚜껑을 다시 닫았다. 상자 안의 조약돌은 이제 세 개가 되었다.
“이렇게 하는 거?”
“맞아. 자, 그럼 나, React는 이 변화를 알아차렸을까?”
루나의 질문에 솔라는 즉시 대답하지 못했다. 상자는 그대로였다. 겉모습은 전혀 바뀌지 않았다. 위치도, 색깔도, 모양도. 단지 그 안의 내용물만 바뀌었을 뿐이다.
”…아니. 상자 자체는 그대로니까.”
“바로 그거야.”
루나가 말했다.
“React는 네가 만든 모든 변수 안을 하나하나 들여다보지 않아. 그건 너무 비효율적이거든. 대신 React는 컴포넌트라는 함수를 실행하고, 그 결과로 나온 화면을 기억해 둬. 그리고 다시 화면을 그려야 할 ‘특별한 신호’가 올 때만 함수를 재실행해서 이전 결과와 비교하지. 네가 한 posts.push는 그 특별한 신호가 아니야. 그냥 상자 안에 몰래 조약돌을 하나 더 넣은 것처럼, React의 감시망 밖에서 일어난 일이야.”
루나의 비유를 듣고 나니, 솔라는 콘솔과 화면 사이의 불일치가 조금 다르게 보이기 시작했다. 콘솔에 찍힌 결과는 조약돌을 몰래 넣은 장본인인 ‘나’는 알 수 있는 사실이었다. 상자 안을 열어보면 실제로 조약돌이 세 개가 되어 있으니까. 하지만 멀리서 상자가 교체되기만을 기다리는 경비원 React에게는 그저 똑같은 상자가 계속 놓여 있는 것으로 보일 뿐이었다.
“아… 그럼 내가 아무리 posts 배열 안의 내용을 바꿔도, React 입장에서는 그냥 원래 있던 그 posts 배열일 뿐이구나. 내용물이 바뀌었는지는 아예 관심도 없는 거고.”
솔라는 자신의 코드를 다시 보았다. posts.push라는 구문이 전과 다르게 읽혔다. 이전에는 ‘데이터를 변경하는’ 강력한 명령으로 보였다면, 이제는 ‘React 몰래 데이터를 바꾸는’ 무의미한 속삭임처럼 느껴졌다. 화면을 바꾸는 힘이 전혀 없는 공허한 외침이었다.
“정확해. 그래서 React는 네가 버튼을 눌렀다는 사실조차 모르는 것처럼 행동하는 거야. React의 ‘인지 범위’ 밖에 있는 일반 변수를 바꾼 건, 화면 갱신과는 아무런 관련이 없는 거지.”
솔라는 허탈한 웃음을 지었다. 당연히 될 거라고 생각했던 가장 간단한 방법이, 사실은 React의 작동 방식과 정면으로 부딪히는 함정이었던 것이다. 데이터는 바뀌었지만, React에게 ‘다시 그려달라’는 요청을 보내는 데는 완전히 실패했다.
문제가 무엇인지는 명확해졌다. 직접 수정은 답이 아니었다. 그렇다면 새로운 질문이 고개를 들었다.
“알겠어. 내 방식이 왜 틀렸는지는 이제 알겠어. React가 못 본다는 거. 그럼 대체 뭘 해야 React가 볼 수 있는데? React가 감시하고 있다는 그 ‘특별한 신호’라는 건 대체 뭐야?“
2장: State: React에게 ‘바뀌었다’고 알리는 방법
솔라의 질문이 채 끝나기도 전에, 루나는 솔라의 노트북을 자기 쪽으로 살짝 돌렸다. 그리고는 몇 글자 입력하기 시작했다. 솔라가 만든 PostList 컴포넌트의 맨 윗줄에 새로운 코드가 추가되었다.
import { useState } from 'react';
그리고 PostList 함수 바로 안쪽에는, 어딘가 낯선 문법의 코드가 한 줄 더 생겼다.
// 루나가 추가한 코드
function PostList() {
const [count, setCount] = useState(0);
// ... (이전 솔라의 코드)
}
루나는 아무 말 없이 키보드에서 손을 뗐다. 화면에는 바뀐 코드와 함께, 솔라의 원래 게시물 목록 아래에 숫자 ‘0’과 ’+’ 버튼이 새로 그려져 있었다. 지난번의 실패 이후, 루나가 무언가 다른 실험을 준비한 것이다. 솔라는 새로운 코드를 뚫어지라 쳐다봤다. let posts = ... 와는 확실히 달라 보였다. 대괄호, 쉼표, useState라는 처음 보는 함수까지.
“이건… 변수를 만드는 새로운 방법이야?”
솔라가 조심스럽게 물었다. useState(0)이라는 부분이 왠지 count라는 변수에 초기값 0을 할당하는 것처럼 보였다. 그냥 let count = 0이라고 쓰면 될 것을, 왜 이렇게 복잡하게 하는 걸까. 솔라는 이것이 React만의 특별한 규칙일 거라고 짐작했다.
“저 ’+’ 버튼을 누르면 저 숫자가 1씩 올라가게 만들어 봐. 방금 네가 추측한 대로.”
루나는 ’+’ 버튼을 가리키며 말했다.
“간단하지.”
솔라는 자신 있게 키보드를 가져왔다. ‘count라는 변수에 1을 더하면 되는 거잖아?’ 그녀는 지난번 posts 배열 때와는 달리, 이번엔 React의 ‘특별한 변수’를 사용하고 있다는 사실에 약간은 안도했다. 이 방식이라면 React가 분명 알아챌 거라고 믿었다.
// 솔라가 수정한 코드
const handleIncrease = () => {
count = count + 1;
console.log('카운트 증가:', count);
};
// ... JSX 안에 <button onClick={handleIncrease}>+</button> 추가
솔라는 코드를 저장하고 망설임 없이 ’+’ 버튼을 눌렀다. 콘솔 창에는 ‘카운트 증가: 1’이 찍혔다. 한번 더 누르자 ‘카운트 증가: 2’가 찍혔다. 데이터는 분명히 바뀌고 있었다. 하지만 화면의 숫자는 여전히 ‘0’에 못 박혀 있었다.
“또야! 대체 왜!”
솔라는 거의 소리를 질렀다. 또다시 배신당한 기분이었다. 상자 비유를 통해 일반 변수를 직접 바꾸는 게 소용없다는 건 배웠다. 그래서 이번엔 React가 제공하는 useState라는 특별한 방식으로 변수를 만들지 않았는가. 그런데도 결과는 똑같았다.
루나는 말없이 솔라가 작성한 const [count, setCount] = useState(0); 줄에서 setCount 부분을 손가락으로 가리켰다.
“이건 왜 안 썼어?”
“어… 그건 그냥 변수 만들 때 따라오는 이름인 줄 알았지. count가 중요한 거 아니야?”
솔라의 대답에 루나는 고개를 저었다.
“React에게 ‘특별한 신호’를 보내는 게 뭐라고 했지? useState는 값을 저장하는 상자 하나만 주는 게 아니야. ‘이 상자의 내용물이 바뀌었으니 모두 주목해!’라고 외칠 수 있는 확성기를 함께 주는 거지.”
루나의 손가락이 setCount를 다시 한번 꾹 눌렀다.
“네가 한 count = count + 1은 여전히 상자 안에 몰래 조약돌을 넣는 행위랑 똑같아. 상자가 useState로 만들어졌다고 해서 React가 갑자기 투시력을 갖게 되는 게 아니야. 변화를 알리려면 반드시 이 확성기, 즉 setCount라는 함수를 사용해야만 해.”
확성기. 그 단어에 솔라의 머릿속이 번쩍 뜨이는 것 같았다. useState는 변수와, 그 변수의 변경을 ‘공식적으로 알리는’ 함수를 한 쌍으로 제공하는 장치였다. count는 현재 값을 읽기 위한 창문이고, setCount는 React의 관제탑에 변경을 알리는 유일한 통신 수단이었다.
솔라는 떨리는 손으로 handleIncrease 함수를 수정했다.
// 솔라가 최종적으로 수정한 코드
const handleIncrease = () => {
setCount(count + 1);
};
count = count + 1; 이라는 직접적인 대입문을 지우고, 대신 setCount라는 함수를 호출해 새로운 값(count + 1)을 인자로 넘겨주었다. 코드를 저장하고 다시 ’+’ 버튼을 클릭했다.
화면의 숫자가 ‘0’에서 ‘1’로 바뀌었다.
한 번 더 누르자 ‘2’가 되었다. 콘솔이 아니라, 진짜 사용자에게 보이는 바로 그 화면이, 드디어 솔라의 의도대로 움직였다.
“아…!”
솔라는 짧은 탄성을 내뱉었다. 드디어 알 것 같았다. React가 화면을 다시 그리는 시점은 ‘데이터 값이 바뀔 때’가 아니었다. ‘상태를 바꾸는 함수(State Setter)가 호출될 때’였다. setCount를 호출하는 행위 자체가 React에게 “이 컴포넌트를 다시 실행해서 변경 사항이 있는지 확인하고 화면을 업데이트해!”라고 명령하는 신호탄이었던 것이다.
솔라는 잠시 만족감에 잠겨 움직이는 카운터를 바라보았다. 일반 변수와 state의 차이는 단순히 선언 방식의 차이가 아니었다. React에게 변경을 ‘통보’할 수 있는 공식적인 수단이 있느냐 없느냐의 차이였다.
그때, 원래 해결하려던 문제가 다시 눈에 들어왔다. 저 아래 얌전히 있는 게시물 목록.
“좋아, 이제 숫자를 바꾸는 방법은 알겠어. setCount 같은 함수를 부르면 되는구나. 그럼… 내 원래 문제였던 posts 배열은? useState로 만들어서 setPosts 같은 함수를 쓰면 될 텐데… 그럼 새 글을 추가할 때 어떻게 하지? posts.push({ id: 3, title: '새 글' }); 이렇게 배열을 직접 바꾼 다음에, setPosts(posts) 이렇게 호출하면 되나?”
새로운 도구를 손에 쥔 솔라는, 아직 풀리지 않은 가장 큰 숙제를 어떻게 다뤄야 할지 새로운 고민에 빠졌다.
3장: 불변성과 새 참조: React의 효율적인 업데이트
솔라는 자신의 코드를 승리에 찬 눈으로 바라보았다. 마침내 모든 조각이 맞춰지는 기분이었다. let posts = ... 로 시작했던 첫 번째 실수는 ‘React의 인지 범위’ 밖에서 일어난 일이었다. count = count + 1 로 저질렀던 두 번째 실수는 ‘상태 변경 알림’이라는 확성기를 쓰지 않은 탓이었다. 이제 그녀는 두 가지 교훈을 모두 적용하여 원래의 문제, 게시물 목록으로 돌아왔다.
솔라는 망설임 없이 PostList 컴포넌트를 수정했다. 기존의 let posts = ... 줄을 과감히 지우고, useState를 사용해 다시 선언했다. 그리고 ‘글 추가’ 버튼을 눌렀을 때 실행될 함수에는 setCount를 썼을 때처럼, ‘확성기’ 역할을 할 setPosts를 호출하도록 코드를 넣었다. 모든 것이 논리적으로 완벽했다.
// 솔라가 자신 있게 수정한 코드
function PostList() {
const [posts, setPosts] = useState([
{ id: 1, title: '리액트 공부 시작!' },
{ id: 2, title: '어렵지만 재밌다' },
]);
const addPost = () => {
// 1. 기존 배열을 직접 수정하고 (push)
posts.push({ id: 3, title: '새 글' });
// 2. 확성기 함수를 호출!
setPosts(posts);
console.log('setPosts 호출됨:', posts);
};
return (
// ... JSX 부분은 동일 ...
<button onClick={addPost}>글 추가</button>
);
}
‘배열에 새 글을 추가하고, 그 바뀐 배열을 setPosts로 알려준다.’ 솔라는 자신의 논리에 감탄했다. 이번에야말로 틀릴 리가 없었다. 그녀는 숨을 참고 ‘글 추가’ 버튼을 눌렀다. 콘솔 창에는 ‘setPosts 호출됨:’ 이라는 메시지와 함께 항목이 세 개가 된 배열이 찍혔다. 하지만… 화면의 게시물 목록은 또다시, 끈질기게도, 두 개 그대로였다.
“말도 안 돼….”
솔라의 입에서 탄식이 흘러나왔다. 이번엔 뭐가 문제란 말인가. useState도 썼고, 상태 변경 함수(setPosts)도 분명히 호출했다. React에게 변경을 알리는 공식적인 절차를 모두 밟았다고 생각했는데, 결과는 첫 번째 실패와 똑같았다. 솔라는 절망적인 표정으로 루나를 쳐다봤다.
루나는 말없이 지난번에 썼던 상자를 다시 가져왔다. 안에는 조약돌 두 개가 들어 있었다.
“자, 이게 useState로 만든 네 posts 상자야. 이번엔 나, React가 이 상자를 아주 특별하게 관리하고 있지.”
루나는 상자를 가리키며 말했다.
“네가 한 일을 그대로 재현해 봐. posts.push를 하고, setPosts(posts)를 호출했지? 그건 이 상자에 어떤 일을 한 걸까?”
솔라는 잠시 생각에 잠겼다. posts.push는 지난번처럼 상자 뚜껑을 열고 몰래 조약돌 하나를 더 넣는 행위다. 상자 안의 조약돌은 세 개가 되었다. 그 다음, setPosts(posts)를 호출했다. 이건 ‘확성기’를 쓰는 행위다.
솔라는 상자를 들고 루나에게 외치는 시늉을 했다. “자, 여기! 상자가 바뀌었어! 확인해 봐!”
그러자 루나는 솔라가 내민 상자를 빤히 쳐다보더니, 고개를 저었다.
“안 바뀌었는데?”
“뭐? 바뀌었잖아! 안에 조약돌 하나 더 넣었다고!”
솔라가 억울하다는 듯 소리쳤다. 루나는 차분하게 대답했다.
“나는 상자 안을 들여다볼 권한도, 관심도 없어. 내 임무는 오직 하나, 네가 나에게 ‘이전과는 다른, 완전히 새로운 상자’를 건네줬는지 확인하는 거야. 그런데 네가 지금 나에게 준 상자는, 내가 원래 가지고 있던 바로 그 상자잖아. 주소도, 모양도, 색깔도 똑같은. 그러니 나는 ‘아무것도 바뀌지 않았다’고 판단하고 아무 일도 하지 않는 거지.”
그제야 솔라는 깨달았다. setPosts(posts)를 호출할 때, 그녀가 인자로 넘겨준 posts는 push로 내용물만 바뀐 ‘원래 그 배열’이었다. React 입장에서는 똑같은 주소값을 가진, 동일한 객체를 받은 셈이다. React는 효율성을 위해 상태가 정말 바뀌었는지를 판단할 때, 객체나 배열의 내부 속성 하나하나를 비교하는 대신, 이전 상태와 새로운 상태의 ‘주소(참조)’가 같은지를 먼저 확인한다. 주소가 같으면, 그냥 ‘변화 없음’으로 간주하고 넘어가 버리는 것이다.
“아… setPosts를 부르는 것만으로는 부족했구나. React가 이전 posts와 ‘다른 것’이라고 인식할 수 있도록, 완전히 새로운 상자를 만들어서 줘야만 ‘state가 바뀌었다’고 알아듣는 거였어.”
“정확해. 원래 상자의 내용물을 바꾸는 건 금지야. 그게 React와 우리 사이의 규칙이지. 그걸 ‘불변성을 지킨다’고 말해. 항상 새로운 상자를 만들어서 건네줘야 해.”
새로운 상자. 솔라는 어떻게 새로운 상자를 만들 수 있을지 고민했다. 원래 있던 조약돌 두 개와, 새로 추가할 조약돌 한 개를 모두 담은, 완전히 새로운 상자.
그녀는 키보드로 손을 가져가 addPost 함수를 다시 수정했다. posts.push 구문을 지웠다. 대신, 새로운 배열을 만드는 JavaScript 문법을 떠올렸다.
// 솔라가 마침내 완성한 코드
const addPost = () => {
// 1. 기존 배열의 모든 항목을 복사하고, 새 항목을 추가한 '새로운 배열'을 만든다.
const newPosts = [...posts, { id: 3, title: '새 글' }];
// 2. '완전히 새로운' 배열을 확성기로 알려준다.
setPosts(newPosts);
};
[...posts]는 기존 posts 배열의 모든 항목을 꺼내어 새로운 배열 안에 펼쳐놓는 문법이었다. 그 뒤에 새 게시물 객체를 추가함으로써, newPosts는 기존 posts와 내용물은 비슷하지만 주소값은 전혀 다른, ‘완전히 새로운 배열’이 되었다.
솔라는 코드를 저장하고, 마지막으로 ‘글 추가’ 버튼을 눌렀다.
화면에 ‘새 글’이 나타났다.
드디어, 그녀가 원했던 대로 게시물 목록이 갱신된 것이다. 솔라는 안도의 한숨을 내쉬었다. 길고 긴 길을 돌아온 기분이었다. 이제 React의 UI 갱신 규칙이 머릿속에 명확하게 그려졌다.
“그러니까… React한테 ‘바뀌었어!’라고 말하려면, 첫째, set 함수라는 확성기를 써야 하고,”
솔라는 setPosts 부분을 손가락으로 가리켰다.
“둘째, 배열이나 객체일 때는 ‘이거 완전히 새 거야!’라고 알려주듯, 기존 것을 절대 건드리지 않고 새로운 복사본을 만들어서 줘야 하는 거구나.”
그녀는 자신의 마지막 코드, [...posts, { id: 3, title: '새 글' }] 부분을 뿌듯하게 바라보았다. 이것이 바로 React가 알아들을 수 있는 유일한 언어였다. 더 이상 화면의 침묵에 당황할 일은 없을 것이다.