Frontend 24
Controlled Component: input 값, 누가 주인인가?
input에 타이핑한 값이 이미 화면에 있는데 왜 value와 onChange로 state에 매번 저장해야 하는지 모르겠다.
근거 · 교안 p163-p174
1장: input, 네 값은 어디에서 왔니?
솔라의 손가락이 키보드 위에서 멈칫했다. 화면에는 방금 만든 블로그의 댓글 입력창이 덩그러니 놓여 있었다.
<input type="text" placeholder="댓글을 입력하세요" />
코드는 이게 전부였다. 솔라는 입력창을 클릭하고 ‘첫 댓글!’이라고 타이핑했다. 물론, 화면에는 ‘첫 댓글!’이라는 글자가 선명하게 나타났다. 너무나 당연한 결과였다.
하지만 솔라의 미간은 펴질 줄을 몰랐다. 지금 보고 있는 React 강의 노트에는 전혀 다른 이야기가 적혀 있었다. state라는 것을 만들고, input 태그에 value와 onChange라는 속성을 복잡하게 연결하라고 쓰여 있었다.
“이해가 안 되네.”
나직한 솔라의 혼잣말에, 소파에서 책을 읽던 루나가 고개를 들었다.
“뭐가?”
“언니, 이것 좀 봐. 내가 input 창에 글자를 썼고, 글자가 보이잖아. 그런데 왜 굳이 이걸 state라는 곳에 또 저장해야 하는 거야? 이미 값이 있는데, 두 번 일하는 느낌이라고.”
솔라는 의자 등받이에 몸을 깊게 기대며 팔짱을 꼈다. 이건 마치 가게 주인이 금고에 돈을 잘 넣어 뒀는데, 굳이 장부에 ‘금고에 돈을 넣었음’이라고 다시 한번 쓰는 것처럼 불필요한 일로 느껴졌다. 입력창이 자기 값을 스스로 잘 가지고 있는데, 왜 React가 그걸 굳이 뺏어와서 관리하려는 걸까?
루나는 잠시 솔라의 화면을 들여다보더니, 조용히 자기 노트북을 가져와 옆에 앉았다. 그리고는 검은 화면의 코드 에디터를 열어 아주 짧은 코드 한 줄만 작성했다.
<input type="text" />
“솔라, 이거 한번 열어볼래? React도, 아무런 스크립트도 없는 그냥 순수한 HTML 파일이야.”
솔라는 루나의 노트북을 받아 파일을 브라우저에서 열었다. 화면에는 자신이 만들었던 것과 똑같은, 밋밋한 입력창 하나만 덩그러니 있었다. 솔라는 아까처럼 그 안에 ‘안녕하세요’라고 타이핑했다. 글자는 잘 입력됐다.
“자, 지금 네가 입력한 ‘안녕하세요’라는 글자는 어디에 있을까?”
“어디 있긴. 저기 input 상자 안에 있잖아.”
솔라는 화면의 입력창을 손가락으로 가리켰다. 너무나 뻔한 질문이라고 생각했다.
“맞아. 그 상자 안에 있어.”
루나가 고개를 끄덕였다.
“그럼 질문을 바꿔볼게. 만약 우리가 버튼을 하나 만들어서, ‘입력된 글자 가져오기’라는 기능을 만든다고 해보자. 우리 프로그램이 그 ‘안녕하세요’라는 값을 어떻게 알 수 있을까? 저 상자 안에 들어 있다는 건 알겠는데, 그걸 어떻게 꺼내오지?”
“음…”
솔라는 잠시 생각에 잠겼다. 예전에 어렴풋이 봤던 코드를 떠올렸다. document.querySelector 같은 걸로 저 input 요소를 선택한 다음에, .value라는 속성을 읽었던 것 같았다.
“뭔가… 저 상자를 콕 집어서 ‘네 안에 든 값 내놔’ 하고 물어봐야 할 것 같은데.”
“바로 그거야.”
루나가 솔라의 말을 받았다.
“네가 타이핑한 내용은 브라우저가 화면의 저 네모난 상자에 예쁘게 보여주고, 그 값을 DOM이라는 곳에 속성으로 보관하고 있어. 중요한 건, 그건 어디까지나 브라우저가 혼자 하는 일이라는 거야.”
루나는 말을 잠시 끊고, 솔라가 자신의 코드를 다시 볼 수 있도록 노트북 화면을 돌려주었다.
“솔라, 봐. 네가 타이핑한 내용은 input 요소의 DOM 속성으로 바뀌었을 뿐, 우리 React 앱과는 아직 아무 상관이 없는 상태야. 마치 네가 네 방 서랍에 일기장을 넣어둔 것과 같아. 네가 일기장을 가지고 있다는 건 알지만, 내가 그 내용을 보려면 ‘서랍 열어서 일기장 좀 보여줘’라고 직접 요청해야만 해. React 앱도 마찬가지야. 사용자가 입력창에 무언가 썼다는 사실 자체를 아직 몰라.”
솔라의 눈이 동그래졌다. 아, 그런 거였구나. 화면에 글자가 보인다는 사실과, 내 프로그램이 그 글자를 ‘알고 있다’는 것은 완전히 다른 차원의 문제였다. 지금까지는 그 둘을 같은 것이라고 착각하고 있었다. 입력창은 그저 값을 보여주는 유리 상자일 뿐, 그 안의 내용물은 React가 스스로 알 수 없는, 분리된 존재였던 것이다. 브라우저가 ‘알아서’ 보여주는 값. 그래서 ‘제어되지 않은(Uncontrolled)’ 입력이라고 부르는구나.
“그럼… React는 저 유리 상자 안에 있는 값을 어떻게 자기 것으로 가져오는 거야? 매번 ‘값 내놔!’ 하고 물어봐야 하는 건가?”
솔라의 새로운 질문에 루나는 희미하게 웃었다. 드디어 진짜 문제의 입구에 도착한 것 같았다. DOM의 서랍 속에 갇힌 값을, React는 과연 어떻게 자기 세상으로 끌어와 ‘제어’하기 시작하는 걸까?
2장: 값의 주인은 DOM이 아니라 state!
루나는 대답 대신, 솔라가 방금 전까지 들여다보던 화면을 가리켰다. 거기 있던 하나의 입력창 코드를 복사해 그 아래에 붙여넣었다. 이제 화면에는 똑같이 생긴 입력창이 나란히 두 개가 되었다. 루나는 각각의 입력창 위에 작은 제목을 붙였다.
[실험 1: 제멋대로인 입력창]
<input type="text" placeholder="여기에 입력해보세요" />
[실험 2: 고집불통 입력창]
<input type="text" value="" />
“솔라, 여기 새로운 실험 재료야.” 루나가 노트북을 솔라 쪽으로 살짝 밀었다. “왼쪽은 아까와 똑같은 평범한 입력창, 오른쪽은 내가 딱 한 가지 속성, value를 추가한 입력창이야. 둘 다 클릭하고 ‘안녕’이라고 똑같이 써볼래?”
솔라는 고개를 끄덕이고 먼저 왼쪽 ‘제멋대로인 입력창’을 클릭했다. 키보드로 ‘ㅇ’, ‘ㅏ’, ‘ㄴ’… 글자들은 아무런 저항 없이 화면에 나타났다. ‘안녕’이라는 단어가 선명하게 찍혔다. “이건 잘 되네.”
문제는 다음이었다. 솔라는 오른쪽 ‘고집불통 입력창’을 클릭하고 똑같이 ‘안녕’을 타이핑했다. 하지만 아무 일도 일어나지 않았다. 키보드를 누르는 감각은 분명한데, 입력창은 텅 빈 채로 꿈쩍도 하지 않았다. 마치 투명한 벽에 막힌 것처럼 커서만 깜빡일 뿐이었다.
“어? 언니, 이거 고장 났어. 아무것도 안 써져.”
솔라는 당황해서 몇 번 더 키보드를 두드려봤지만 결과는 같았다.
“고장 난 게 아니야.” 루나가 조용히 말했다. “오히려 네가 시키는 대로 너무나 말을 잘 듣고 있는 상태지.”
“말도 안 돼. 난 분명히 글자를 쓰라고 했는데!”
“아니, 넌 키보드를 눌렀을 뿐이고, 이 입력창은 React의 명령을 듣고 있어.”
루나는 ‘고집불통 입력창’의 코드를 가리켰다. <input type="text" value="" />.
“이 value=""라는 코드가 무슨 뜻 같아?”
“음… 값이 비어있다는 뜻?”
“맞아. 더 정확히는, ‘이 입력창이 보여줄 값은 언제나, 무슨 일이 있어도, 무조건, 빈 문자열이어야 한다’는 강력한 명령이야. 네가 키보드로 ‘안녕’을 입력하려고 시도하는 건 브라우저에겐 단순한 ‘이벤트’일 뿐, 입력창의 값을 바꿀 권한은 오직 value 속성을 제어하는 React에게만 있어. 그리고 React는 지금 계속해서 ‘빈 값을 보여줘!’라고 명령하고 있는 거고.”
그제야 솔라는 상황을 이해했다. 입력창이 고장 난 게 아니라, React가 텅 빈 값을 강제하고 있었던 것이다. 사용자의 입력을 철저히 무시하도록 ‘제어’되고 있었던 셈이다.
“그럼 영원히 아무것도 못 쓰는 거야? 이걸 어떻게 써?”
“이제 열쇠를 줘야지.”
루나는 코드를 다시 몇 줄 추가했다. useState와 onChange가 등장했다.
const [text, setText] = useState("");
const handleChange = (e) => {
setText(e.target.value);
};
return (
<input
type="text"
value={text}
onChange={handleChange}
/>
);
“자, 이제 다시 해봐.”
솔라는 조심스럽게 수정된 입력창에 ‘안녕’을 다시 타이핑했다. 이번에는 글자가 부드럽게 입력되었다. 겉보기에는 첫 번째 ‘제멋대로인 입력창’과 똑같이 동작했다. 하지만 솔라는 이제 그 내부에서 완전히 다른 일이 벌어지고 있음을 직감했다.
“이제 알겠지?” 루나가 설명했다. “value 속성이 없으면 input은 자기 마음대로 값을 바꾸지만, value가 state와 연결되면 state만이 값을 결정할 수 있어. 이 새로운 코드는 이렇게 움직여.”
루나는 손가락으로 데이터의 흐름을 그려 보였다.
- 솔라가 키보드로 ‘ㅇ’을 누른다.
onChange이벤트가 발생하며handleChange함수를 호출한다. “솔라가 ‘ㅇ’을 입력했어요!” 라고 React에게 보고하는 거야.handleChange는e.target.value에서 ‘ㅇ’을 꺼내setText('ㅇ')을 실행한다. React의text라는 상태(state)가 ‘ㅇ’으로 업데이트된다.- React는 state가 바뀌었으니 화면을 다시 그린다. 이때 input 태그의
value={text}가 ‘ㅇ’으로 바뀐다. - 드디어 입력창은 ‘ㅇ’이라는 글자를 화면에 표시하라는 명령을 받고, 솔라는 자기가 입력한 글자를 보게 되는 거지.
솔라의 입이 살짝 벌어졌다. 복잡해 보였던 value와 onChange의 관계가 하나의 흐름으로 명확하게 보였다. 이건 단순히 값을 복사해서 저장하는 중복된 작업이 아니었다. 값의 소유권을 브라우저의 DOM에서 React의 state로 완전히 가져오는 ‘주권 선언’과 같은 것이었다.
“아! 그러니까… 첫 번째 입력창은 자기가 자기 값의 주인이고, 두 번째 입력창은 그냥 React의 state가 시키는 대로 값을 보여주는 스피커 같은 거구나! 내가 타이핑하는 건 그저 ‘이렇게 바꿔주세요’라는 요청일 뿐이고, 진짜로 값을 바꾸는 권한은 React가 쥐고 있는 거고.”
이제 강의 노트에 적힌 “입력값의 진짜 주인은 state이고 input은 그 값을 화면에 보여줄 뿐이다”라는 문장이 비로소 제 뜻을 드러냈다.
기분 좋은 깨달음에 잠시 미소 짓던 솔라의 얼굴에 다시 작은 의문이 떠올랐다.
“알겠어. 이제 값의 주인이 왜 state인지 확실히 알겠어. 근데… 언니. 솔직히 이거 너무 번거롭지 않아? 그냥 첫 번째 입력창처럼 알아서 하게 두면 편할 텐데, 왜 굳이 React가 이 모든 과정을 통제해야만 하는 특별한 이유라도 있어?“
3장: 게시글 폼, 이제 네가 주도해!
솔라가 던진 질문—그저 편하게 두면 될 것을 왜 굳이 React가 모든 과정을 통제해야 하냐는 의문—에 루나는 바로 답하는 대신, 화면에 떠 있는 ‘제어되는’ 입력창의 코드를 다시 한번 조용히 수정하기 시작했다. 솔라가 방금 깨달았던 그 데이터 흐름의 중심, handleChange 함수 안쪽에 짧은 조건문 한 줄이 추가되는 것을 솔라는 눈으로 좇았다.
단순했던 데이터 흐름에 이제 관문이 생긴 셈이었다. 솔라가 이해한 state 업데이트 로직이 if 문으로 감싸였다. 루나는 키보드에서 손을 떼며, 바뀐 코드와 입력창을 차례로 가리켰다.
const handleChange = (e) => {
// 방금 추가된 코드
if (e.target.value.length <= 10) {
setText(e.target.value);
}
};
“자, 솔라.” 루나가 말했다. “아까랑 똑같은 입력창이야. 이번엔 여기에 네 이름을 아주 길게, 열 글자가 넘도록 한번 써볼래?”
솔라는 고개를 갸웃거리며 다시 입력창을 클릭했다. 자신의 이름 ‘솔라’를 치고, 스페이스바를 누르고, ‘동생’이라고 쳤다. 거기까지는 아무 문제가 없었다. 화면에는 ‘솔라 동생’이라고 7글자가 찍혔다. 이제 열 글자를 넘길 차례. 솔라는 ‘입니다’를 마저 타이핑했다. ‘입’, ‘니’, ‘다’까지는 잘 입력되었다. 이제 열 글자가 채워졌다. 마지막으로 마침표(.)를 누르는 순간, 아무 일도 일어나지 않았다. 키보드를 눌렀는데도 입력창은 미동도 없었다. 마치 10글자짜리 투명한 상자에 텍스트가 꽉 들어찬 느낌이었다.
“어? 또 막혔어. 이번엔 열 글자까지만 써지네.”
솔라는 신기하다는 듯 몇 번 더 키보드를 두드려봤지만, 입력창의 값은 ‘솔라 동생입니다’에서 한 글자도 늘어나지 않았다.
“왜 그럴까?” 루나가 방금 추가한 코드 블록을 손가락으로 가리켰다.
솔라의 시선이 if (e.target.value.length <= 10)라는 조건문으로 향했다. 순간, 모든 조각이 맞춰졌다.
“아! 설마… 내가 글자를 입력할 때마다, 이 함수가 먼저 글자 수를 검사하는 거구나! 그리고 열 글자가 넘으면… setText 함수를 아예 실행하지 않는 거고!”
“바로 그거야.” 루나가 조용히 고개를 끄덕였다.
“우리가 키보드를 누를 때마다 onChange는 ‘사용자가 이런 값을 원해요!’라고 React에게 요청을 보내. 그럼 handleChange 함수는 그 요청을 받아서 검토하는 거지. ‘음, 이번에 들어온 값은 길이가 11글자네? 이건 우리 규칙에 어긋나. 업데이트 요청 기각!’ 하고 setText 호출을 건너뛰는 거야. state가 바뀌지 않으니, React는 화면을 다시 그릴 필요성을 못 느끼고, 결국 입력창의 값은 그대로 머물게 돼.”
솔라는 잠시 말을 잃었다. 이것은 완전히 새로운 차원의 제어였다. 만약 제어되지 않는 첫 번째 입력창이었다면 어땠을까? 사용자가 스무 글자든 서른 글자든 마음껏 입력하게 둔 다음, ‘제출’ 버튼을 눌렀을 때 ‘앗! 글자 수가 너무 많아요!’ 하고 뒤늦게 알려줘야 했을 것이다. 하지만 지금 이 방식은 달랐다. 규칙에 어긋나는 값은 애초에 state에 들어올 기회조차 얻지 못했다. 잘못된 상태가 발생하는 것을 원천적으로 차단하고 있었다.
“그럼… 만약 욕설을 못 쓰게 막고 싶으면, 저 if 문 안에서 욕설이 포함됐는지 검사하면 되는 거네? 아니면 모든 글자를 대문자로 바꾸고 싶으면, setText(e.target.value.toUpperCase()) 이렇게 호출하면 되는 거고?”
“해볼까?”
루나가 if 문을 지우고 솔라가 말한 대로 코드를 바꿔주었다. 솔라가 다시 입력창에 ‘hello’라고 소문자로 입력하자, 화면에는 ‘HELLO’라는 대문자가 마법처럼 나타났다. 모든 입력이 즉시 대문자로 ‘제어’되고 있었다.
솔라는 환호성을 질렀다. “와! 대박이다! 이제야 알겠어. 번거로운 게 아니었어. 이게 진짜 ‘제어’구나!”
state로 모든 것을 통제한다는 사실이 이제는 전혀 다른 의미로 다가왔다. 그것은 단순히 값을 복사해두는 행위가 아니었다. 입력, 검증, 변경, 표시까지 이어지는 모든 흐름을 React의 통제하에 두는 강력한 선언이었다. 모든 데이터가 state라는 단 하나의 문을 통해서만 UI에 도달할 수 있으니, 그 문지기(handleChange)에게 어떤 규칙을 주느냐에 따라 폼의 동작을 마음대로 설계할 수 있었다.
그 순간, 솔라는 자신이 처음 고민했던 블로그 게시글 작성 폼을 떠올렸다. 밋밋하게 놓여 있던 제목과 내용 입력창, 그리고 제출 버튼. 이제 그것들은 더 이상 제멋대로인 부품들이 아니었다.
솔라는 루나의 노트북 옆에 놓여 있던 자신의 노트를 펼쳤다. 그리고 방금 깨달은 내용을 바탕으로 게시글 폼의 동작을 새롭게 설계하기 시작했다.
- 입력: 사용자가 제목(
title) 입력창에 글자를 쓴다. - 제어:
onChange가 발생하면,handleTitleChange함수가 호출된다. - 검증: 함수 안에서 제목이 비어있지 않은지, 너무 길지는 않은지 검사한다.
- 상태 업데이트: 검증을 통과하면
setTitle(입력값)을 호출해 state를 업데이트한다. (통과 못 하면 아무것도 안 함!) - 제출: ‘게시’ 버튼을 누르면, 버튼의
onClick함수는titlestate에 저장된 값을 가져와 서버로 보낸다. - 초기화: 제출이 성공하면,
setTitle("")을 호출해서 입력창을 깨끗하게 비운다.
솔라는 자신이 적은 흐름을 보며 미소 지었다. 입력, 검증, 제출, 초기화. 제각각 흩어져 있던 기능들이 모두 ‘state’라는 중심점을 통해 하나의 일관된 흐름으로 움직이고 있었다. 이제 value와 onChange는 더 이상 번거롭고 불필요한 중복 작업이 아니었다. 오히려 폼이라는 작은 애플리케이션의 모든 것을 지휘하는 오케스트라의 지휘봉과도 같았다.
“언니, 나 이제 내 블로그 폼, 제대로 다시 만들 수 있을 것 같아.”
자신감 넘치는 솔라의 목소리에, 루나는 흐뭇한 미소로 화답했다. 솔라의 손은 이미 키보드 위에서 새로운 state를 만들 준비를 하고 있었다.