프론트엔드 개발에서 정말 빼놓을 수 없는 존재가 된 React에 입문하게 되었습니다. 현재 공식 문서를 바탕으로 공부를 하고 있습니다.
프론트엔드에서 하는 대부분의 제어는 상태값을 변화시켜, UI를 변하게 하는 것입니다. 상태가 바뀌는 상황은 보통 버튼과 같이 무언가를 클릭해서 이벤트가 발생하거나, 서버에 API 요청을 보내서 새로운 정보를 업데이트 받을 때 발생합니다.
JSX라는 문법에 친해지고 있는 가운데, 리액트는 component 단위로 개발하며, 이는 나중에 코드의 재사용성을 더 높여주는 역할을 한다는 것을 알게 되었습니다. 컴포넌트는 또 개별적인 여러 컴포넌트들로 나뉘어져 개발하게 됩니다. 컴포넌트의 개념에 대해 JSX 문법을 사용해서 조금씩 알아가게 되다보니, 자연스럽게 props에 대해 배우게 되었습니다.
그럼, props는 언제 사용될까요?
다수의 페이지에 특정한 부분을 다르게 보여주고 싶은데, 이전에 리액트 없이 html 파일을 여러 개 만들었을 경우, 변경되어야 하는 페이지에 해당하는 html 파일들을 일일이 찾아서 수정해야 했습니다.
하지만, props를 이용하면, App.js라는 제일 상위의 컴포넌트가 있다고 가정할 때, 우리는 App이라는 컴포넌트에 추가적인 값을 전달해서, 하위(자식) 컴포넌트가 props를 통해 변경되어야 할 부분을 변경할 수 있습니다. 즉, App이라는 부모 컴포넌트 한 곳에서 필요한 값을 제어하고, 자식 컴포넌트에 보내, 변경이 필요한 페이지에 반영할 수 있는 것입니다.
다음의 코드에서 확인할 수 있습니다.
function App() {
return (
<div className="App">
<Header title = {"News"}/>
<Header title = {"Blog"}/>
<Header title = {"Competition"}/>
</div>
);
}
export default App;
const Header = () => {
return(
<header className="App-header">
<a>
{props.title}
</a>
</header>
)
}
export default Header;
이렇게 단순하게, 특정 값을 변경하는 상황만 있다면 참 좋겠지만, 버튼을 클릭하는 등의 이벤트가 발생할 때, 이 상태를 업데이트 하는 등의 상태를 관리하는 기능들도 있어야 하는 것은 당연합니다. 이 때 필요한 것이 바로 state hook 입니다.
컴포넌트가 '상태를 관리'한다는 것은 바로 상태값을 변경, 수정하고, 그 컴포넌트가 렌더링이 일어날 수 있게 한다는 것을 의미합니다.
useState는 다음과 같이 사용가능합니다.
import React, { useState } from 'react'
const [state, setState] = useState();
코드의 2번째 줄을 보면, 자바스크립트에서 익숙한 문법이 나옵니다. 바로 구조분해할당 문법입니다. 객체(배열) 안에 있는 값을 추출해서 변수나 상수로 바로 선언해 줄 수 있다는 장점이 있죠!
그럼 state와 setState가 의미하는 바는 뭘까요?
- state는 useState()가 실행하고 반환하는 관리하는 '값'을 의미합니다. (state는 사실 객체입니다)
- setState는 관리하고 있는 값, 즉 state를 변경할 때 쓰는 함수라고 생각하면 됩니다.
앞에서 컴포넌트가 상태를 관리한다는 것은 컴포넌트가 렌더링이 일어날 수 있게 한다는 것을 의미한다고 말했었는데, state hook에서 setState()는 이 state 값을 변경시키는 함수로써, 상태값을 변경하기 때문에, 컴포넌트를 re-rendering 시킵니다.
이 re-rendering 과정에 대해서 처음에 지식을 습득할 때 이해가 가지 않았는데, react에서 상태값을 다룰 때 관통하는 개념 중 하나가 'immutable' 즉, 불변성이라는 것을 듣고, 조금씩 이해하기 시작했습니다.
프로그래밍에서 불변성을 지킨다는 의미는, 불필요하게 메모리 영역의 값을 직접적으로 변경하지 않는다는 것을 의미합니다. 어떤 값을 변경할 때, 그 값을 직접적으로 변경하는 것이 아니라 새로운 값을 새로 만들어서 변경하는 것을 의미합니다.
setState()를 실행하면, state의 값이 변경되면서 re-rendering이 발생한다고 말했지만, 꼭 re-rendering 되지는 않습니다.
왜냐하면, state는 객체인데 바로 방금 전 이야기 했듯이, 이 객체의 값을 직접 변경하면 불변성을 지키지 않는 것이기 때문에, Re-rendering이 발생하지 않는 것입니다.
다시 돌아와서, setState()를 실행하면, 이전의 state 값과 이후의 state 값이 다른데, 리액트는 상태를 관리할 때, 즉 state 값을 비교할 때 얕은 비교를 합니다.
그렇다면, 왜 react는 얕은 비교를 통해서 상태값 비교를 할까요?
비교를 할 때 예를 들어, 객체 A와 객체 B가 있다고 했을 때, 객체 안에 또 함수와 같은 객체가 담겨 있거나, 객체 안에 또 다른 객체 참조값이 담겨 있는 경우, 참조하고 있는 그 객체 내부의 값들까지 하나하나 비교해야 하는 상황이 생기기 때문에, 이 경우 성능이 너무 나빠져서, react는 성능 최적화 측면에서 이를 방지하고자 얕은 비교를 통해서 상태값 비교를 합니다.
const obj = {
firstname : 'james',
lastname: 'bond',
age : 64,
countMovie : function() {
return '셀 수 없습니다.'
},
innerObj: {
name: 'james bond'
moviename: 007,
}
}
예시로, 어떻게 얕은 비교를 하는지, 어떻게 useState()를 통해서 상태 변화가 반영되어 리렌더링 되는지 확인해봅시다.
먼저, +1을 버튼을 누르면, 총 몇 개 옆 부분에 값이 1씩 증가되도록 만들고, show Please라는 버튼을 처음 누르면, 하단의 텍스트가 숨겨지고, show Please를 다시 누르면, 하단의 텍스트가 다시 보이도록 간단한 컴포넌트를 구현해봤습니다.
1. 현재 App.js 컴포넌트의 코드는 이러합니다.
function App() {
const info = {
includeImg : true,
lastName : 'kwon',
firstName : 'kyle',
}
return (
<div className="App">
<Header message = {'Blog'}/>
<Welcome {...info}/>
<Counter />
</div>
);
}
export default App;
그럼 직접적으로 버튼들을 제어하는 Counter.js 컴포넌트 내부로 들어가 보겠습니다.
import React, { useState } from 'react'
export default function Counter (props){
const initial = {
count: 0,
show: true,
}
const [info, setInfo] = useState(initial);
return (
<div>
<button onClick = {() => setInfo({...info, count: info.count + 1})}>+1</button>
<button
onClick = {() =>
info.show = !info.show
const newInfo = info
setInfo(newInfo)
}> Show Please </button>
<br/>
{info.show === true && `총 몇개 : ${info.count}`}
</div>
)
}
지금 앞에서 그렇게 강조했던, 불변성의 원칙을 위배하고 있습니다. info.show에 접근해 true라는 값을 직접 false로 변경하고, 새로운 newInfo라는 변수에 false라는 값으로 바뀐 프로퍼티를 담고 있는, info 즉 state 객체를 할당해, setInfo라는 setState라는 함수를 실행했습니다. 이렇게 되면, 절대 버튼을 클릭해도 Show Please라는 버튼은 절대 동작하지 않을 겁니다.
그러면 얕은 비교를 하게 만들어, react가 상태값 변경을 눈치채도록 해야 합니다. 그렇다면, 코드를 다음과 같이 고쳐야 합니다.
import React, { useState } from 'react'
export default function Counter (props){
const initial = {
count: 0,
show: true,
}
const [info, setInfo] = useState(initial);
return (
<div>
<button onClick = {() => setInfo({...info, count: info.count + 1})}>+1</button>
<button
onClick = {() =>
setInfo({...info, show: !info.show})}> Show Please </button>
<br/>
{info.show === true && `총 몇개 : ${info.count}`}
{/* <div>
{props.child}
</div> */}
</div>
)
}
spread 연산자를 통해, 현재 위 info라는 state 객체는 원시값들만 담고 있는 객체이므로, 깊은 복사를 해야 한다는 우려 없이, 객체를 복사하고, 복사한 새로운 객체의 show라는 프로퍼티에 접근해서, 그 값을 변경시켜, 기존의 info라는 객체와 복사한 객체 사이의 얕은 비교를 통해 버튼이 동작되게 할 수 있습니다.
그러면 결과물을 한 번 볼까요?
이와 같이 잘 동작하는 것을 볼 수 있습니다.
공식문서에서는 state 올바르게 사용하기 파트에 이렇게 명시되어 있습니다.
- 직접 state를 수정하지 않기
- state 업데이트는 비동기적일 수 있다. 성능을 위해서 여러 setState() 호출을 단일 업데이트로 한꺼번에 처리할 수 있다.
- state 업데이트는 병합된다.
사실 위 3가지 조건들 중 3번째 조건에 의해, 2번째 조건이 성립한다는 것을 눈치채게 되었습니다. 단일 업데이트를 한 꺼번에 처리한다는 점은, 바로 state 업데이트가 병합된다는 말에 의해 성립하는 것입니다.
어떻게 병합되는지 한 번 살펴볼까요?
다음과 같은 코드가 있습니다.
const [number, setNumber] = useState(1);
const add = () => setNumber(number + 1);
const subtract = () => setNumber(number - 1);
const multiplyBy10 = () => setNumber(number * 10);
const multiplyBy10AndAddBy1 = () => {
multiplyBy10();
add();
};
return (
<div>
<h1>Number : {number}</h1>
<div>
<button onClick={add}>+ 1</button>
<button onClick={subtract}>- 1</button>
<button onClick={multiplyBy10}>*10</button>
<button onClick={multiplyBy10AndAddBy1}>*10 + 1</button>
</div>
</div>
);
위의 코드가 화면에 렌더링 된 모습은 다음과 같습니다.
앞에서 react는 state 업데이트가 병합된다고 했었는데, 이는 어디서 발생할까요?
바로 multiplyBy10AndAddBy1이라는 함수에서 발생합니다.
그러면 내부적으로는 도대체 어떻게 동작하는 걸까요?
바로 얕은 복사의 한 방법으로 사용되는 Object.assign() 메서드가 내부적으로 작동됩니다.
Object.assign({number}, {number: number(value) * 10}, {number : number(value) + 1})
이 코드를 보면, 처음에는 첫 번째 자리에 있는 number는 state 값, 즉 객체이고, number라는 객체 안에 number라는 값이 2배가 되고, 2배가 된 값이, +1이 된 결과값을 반환할 수 있다고 착각할 수 있습니다. 하지만, Object.assign()이라는 메서드는 결과적으로 +1만 된 값을 반환합니다. 왜냐하면, overriding 되기 때문입니다. 즉, 덮어씌우는 것이죠.
이렇게 덮어씌워지는, 즉 병합이 되는 과정을 피하기 위해서는 어떻게 해야할까요?
공식 문서에서는 이를 피하기 위해서, setState() 함수에 객체 대신 함수를 전달하라고 되어 있습니다.
이를 통해, 병합되거나 여러 setState() 함수를 나중에 한꺼번에 처리하는 비동기적으로 발생하는 문제를 방지할 수 있습니다.
그러면, 코드를 다음과 같이 수정하면 됩니다.
const [number, setNumber] = useState(1);
const add = () => setNumber((number) => number + 1);
const subtract = () => setNumber((number) => number - 1);
const multiplyBy10 = () => setNumber((number) => number * 10);
const multiplyBy10AndAddBy1 = () => {
multiplyBy10();
add();
};
return (
<div>
<h1>Number : {number}</h1>
<div>
<button onClick={add}>+ 1</button>
<button onClick={subtract}>- 1</button>
<button onClick={multiplyBy10}>*10</button>
<button onClick={multiplyBy10AndAddBy1}>*10 + 1</button>
</div>
</div>
);
마지막으로, 리액트에서는 props와 state가 단방향식, 즉 하향식으로 흐른다는 것을 위의 코드와 개념들을 보면서 파악했을 것입니다. 부모 컴포넌트가 props를 통해 자식 컴포넌트에게 특정한 값을 전달하는 과정을 보았을 때 알 수 있습니다.
그럼 이 때 어떻게 해야 될까요?
다음 그림과 같이 하면 됩니다. 저는 지금 현재 props와 useState라는 state Hook이라는 개념밖에 모르는 상태인데, 이 둘 만으로도 충분히 해결이 가능합니다.
기존에 B 컴포넌트가 갖고 있던 state를 A 컴포넌트에 끌어올리고, B와 C 컴포넌트도 state가 필요한 상황이니, 알고 있는 props를 이용해서 state를 내려주면 됩니다. 이것이 바로 state 끌어올리기 입니다.
왜 한 곳에서 props로 state를 내려주는 것이 좋을까요?
props는 부모 컴포넌트에서 자식 컴포넌트로 전달하는 값(객체)를 의미하는데, 부모 컴포넌트 한 곳에서 state를 제어하고 편집하면서, 내려주게 되면, 자식 컴포넌트에서 일일히 수정하는 불필요한 작업을 하지 않아도 되며, 리액트가 단방향 데이터 흐름을 지향한다는 것과 일맥상통하기 때문입니다.
state 끌어올리기와 관련된 예제는 이미 공식 문서에서도 보여주고 있고, 이 끌어올리기라는 개념을 이해하는 것이 중요하다고 생각하여 생략하겠습니다. 이후에 스스로 좋은 예제를 만든다면 공유해 보겠습니다.
이제 입문 단계인 제가 react의 그래도 큰 역할을 하는 state에 대해서 스스로 이해하고 정리하는 시간을 가졌습니다. 아직 많이 미숙하지만, 모르는 개념을 습득하고, 작은 토이프로젝트들도 진행해 보면서 점차 리액트에 대한 감을 익힐 것이라고 생각됩니다. 기대가 됩니다!