React useState

Jemin·2023년 12월 6일
0

프론트엔드

목록 보기
44/51
post-thumbnail

서론

React Hook이 등장한 이유는 React 공식 문서에서 찾아볼 수 있다.

React 팀에서는 Hook이 만들어진 동기를 아래와 같이 설명한다.

  • 컴포넌트 사이에서 상태 로직을 재사용하기 어렵다.

  • 복잡한 컴포넌트들은 이해하기 어렵다.

  • Class는 사람과 기계를 혼동시킨다.

[React 공식문서] Hook의 개요

React 16.8 버전에서 도입된 Hook은 컴포넌트를 단순하게 만들어주고 로직을 재사용하기 쉽게 만들어주고, 이전에 클래스 컴포넌트에서만 사용할 수 있었던 상태나 생명주기를 함수형 컴포넌트에서도 사용할 수 있도록 만들어주었다.

많은 React 개발자들이 적극적으로 Hook을 사용하고 있으며 중요한 개념으로 자리잡았다.

아마 React를 사용하면서 제일 처음 접하는 useState가 제일 많이 사용하는 Hook이라고 생각한다. 그렇기에 정확하게 알고 사용하지 않으면 문제가 많이 발생한다. 이 글에서는 useState에 대해서 조금 자세하게 다뤄볼 것이다.

useState

React에서 기본적으로 제공하는 Hook이다.

useState Hook은 함수 컴포넌트에서 상태(state)를 추가하고 관리할 수 있게 해준다. 이를 통해 함수형 컴포넌트에서도 클래스 컴포넌트와 같이 상태 관리 기능을 사용할 수 있다.

const [count, setCount] = useState(0);

기본적인 사용 방법은 배열로 state와 setState를 받아 사용하면 된다. state는 직접적으로 변경이 불가능하고 setState 함수를 통해서만 변경이 가능하다. useState에 넘겨주는 값은 state의 초기값이다.

컴포넌트 내의 지역변수는 컴포넌트가 다시 렌더링되면서 모두 초기화되기 때문에 리렌더링이 발생하더라도 상태를 유지하고, 상태가 변경되었을 때 리렌더링을 발생시키는 용도로 useState Hook을 사용한다.

상태는 어떻게 유지되는가?

JS 엔진은 Call Stack메모리 Heap 2가지 메모리 공간을 가지고 있다.

  • Call Stack은 함수들의 호출을 기록하는 공간

    • Call Stack 내부의 Stack Frame에는 함수 호출 시 지역 변수, 매개 변수, 복귀 주소 등을 저장한다.
  • 메모리 Heap은 동적으로 할당된 메모리(객체, 배열, 함수 등)를 저장하는 공간

React에서 함수 컴포넌트는 호출되면서 Call Stack에 쌓이게 되고 내부에서 선언된 지역 변수들(const, let)은 Call Stack 내부의 Stack Frame에 할당되어 메모리에 저장된다. 함수의 실행이 끝나면 해당 함수의 Stack Frame은 Call Stack에서 제거되고, 해당 함수 내부에서 생성된 지역 변수들도 메모리에서 해제된다.

함수형 컴포넌트도 함수이기 때문에 당연히 메모리 Heap 영역에 저장되는데, 이때 컴포넌트에 렉시컬 스코프와 클로저를 활용하여 함수형 컴포넌트 내의 상태를 유지할 수 있다.

보통 함수는 실행되면서 지역 변수를 생성하고 해당 값이 더 이상 참조되지 않을 때 메모리에서 해제하는데, 클로저와 관련된 경우에는 해당 함수가 참조되는 한 해당 함수의 스코프에 있는 값들이 유지될 수 있다.

이는 컴포넌트의 라이프사이클과 관련이 있는데, 컴포넌트가 언마운트되기 전까지 useState의 값을 참조하기 때문에 useState가 유지되는 것이고 이 useState 함수도 클로저를 활용하기 때문에 내부의 값을 유지할 수 있는 것이다.

클로저가 참조하고 있는 외부 변수는 렉시컬 스코프에 존재하고 이 외부 변수는 클로저가 생성될 때 메모리 Heap에 저장된다. 메모리 Heap에 저장된 외부 변수는 클로저가 참조하는 동안에는 해제되지 않는다.

상태 변경 감지

useState에서 자주 언급되는 것은 불변성이다.

불변성은 변하면 안된다는 뜻이다. 즉, 상태가 변하면 안된다는 것인데 우리는 값을 유지하고 변경하기 위해 useState를 사용한다. 처음 들으면 말이 어려워서 이해가 안갈 수 있다.

불변성을 지키라는 것은 쉽게 말해 기존의 값을 직접 변경하지 않고, 새로운 상태로 대체하는 것을 의미한다. 이는 더 깊이 들어가면 더 이해가 잘될 것이다.

setState는 호출되면 상태를 변경하고 React가 내부적으로 감지하여 화면이 다시 렌더링된다. 이때 React는 얕은 비교를 통해서 변경을 감지한다. 즉, React는 메모리 주소를 비교해 변화를 감지한다.

자바스크립트에서 원시 타입(문자열, 숫자, 불리언 등)의 값은 immutable(변경 불가능)한 특성을 가지고 있기 때문에 값을 변경하면 새로운 메모리 주소에 할당하게 된다. 이 경우 React에서는 주소가 변경됐음을 감지하고 화면을 다시 렌더링한다.

참조 타입(객체, 배열 등)은 변경되었을 때 메모리 Heap에 저장된 값이 변경되지만 주소값 자체는 바뀌지 않고 React에서는 아무것도 감지하지 못하고 화면을 다시 렌더링해주지 않는다.

메모리의 다른 주소에 값을 할당해서 주소값을 바꿔야 하기 때문에 깊은 복사를 통해서 새로운 값을 setState에 전달해주어야 React에서 이를 감지하고 다시 렌더링할 수 있다.

기존의 state를 사용하는 것이 아닌 완전 새로운 객체나 배열을 생성해서 setState에 넘기는 경우는 불변성을 지키는 것이기 때문에 굳이 복사할 필요는 없다.

깊은 복사

많은 사람들이 배열이나 객체를 깊은 복사하기 위해 사용하는 방법은 스프레드 문법이 있다.

// 배열의 경우
const [list, setList] = useState([1, 2, 3]);

setList([...list]);

const [object, setObject] = useState({a: 1, b: 2, c: 3});

setList({...object});

하지만 스프레드 문법은 1차원(depth)만 안전하게 복사해주고 2차원(depth) 이상부터는 문제가 발생할 수 있다. 2차원 이상일 때에는 내부에 있는 객체나 배열 또한 새로운 복사본을 만들어야 한다.

2차원 이상의 객체의 경우 JSON을 사용해 문자열로 바꾼 후 다시 객체로 파싱하는 방법을 사용해 다음과 같이 복사할 수 있다.

const [list, setList] = useState([[1, 2, 3], [4, 5, 6], [7, 8, 9]]);

const deepCopyList = JSON.parse(JSON.stringify(list));
setList(deepCopyList);

const [object, setObject] = useState({ a: 1, b: { c: 2 }, d: 3 });

const deepCopyObject = JSON.parse(JSON.stringify(object));
setObject(deepCopyObject);

Lodash 라이브러리를 사용해서 깊은 복사를 하는 방법도 있다.

import _ from 'lodash';

const [nestedList, setNestedList] = useState([[1, 2, 3], [4, 5, 6], [7, 8, 9]]);

setNestedList(_.cloneDeep(nestedList));

const [nestedObject, setNestedObject] = useState({ a: 1, b: { c: 2 }, d: 3 });

setNestedObject(_.cloneDeep(nestedObject));

이렇게 깊은 복사를 통해 객체나 배열의 상태를 대체하면 React에서 메모리에 새로 할당된 주소값을 감지하고 리렌더링을 수행한다.

setState 비동기

동기는 해당 코드의 실행이 끝난 후 다음 코드가 실행되는 방식인데 useState의 setState 함수는 비동기로 동작한다.

setState 함수가 동기적으로 상태를 갱신하는 것이 아닌 비동기적으로 처리되는 이유는 여러 setState 호출이 한 번에 묶여서 처리되어 성능을 최적화하기 때문이다. React는 여러 setState 호출을 그룹화하고, 해당 그룹을 단일 업데이트로 처리하여 컴포넌트를 효율적으로 렌더링한다.

이런 동작 방식 때문에 setState 호출 이후에 상태가 즉시 업데이트되지 않을 수 있다. 예를 들어 setState 이후에 state의 값을 확인하기 위해 console.log()를 호출하여도 이전 상태가 출력되는 경우가 이러한 동작 방식 때문이다.

동기적으로 어떤 일을 처리하고 싶다면 아래 코드와 같이 setState 함수에 두 번째 인자로 콜백 함수를 넘겨줄 수 있다.

const [text, setText] = useState("");

setText("text", () => {
  console.log(`state: ${text}`);
});

// 콜백 함수 안의 console.log()는 상태가 업데이트된 후에 실행된다.

동일한 setState가 여러번 호출되고 이에 따라 발생하는 비동기 문제를 해결하고 싶다면 함수형 업데이트를 사용할 수 있다.

만약에 count에 1씩 더하고자 한다면 아래와 같은 코드를 작성할 수 있다.

const [count, setCount] = useState(0);

setCount(count + 1);
setCount(count + 1);
// count 값은 1이 된다

개발자가 생각하기에는 count값이 2가 되기를 바라겠지만 실제로 코드를 작성해서 확인해보면 1이라는 값이 나온다.

첫 번째 호출은 이전 상태를 고려하지 않고 현재 상태에 1을 더한 값을 설정하고, 두 번째 호출은 또 다시 현재 상태에 1을 더한 값을 설정한다. 즉, 두 번째 호출이 첫 번째 호출의 결과를 덮어쓰는 문제가 발생하는 것이다.

이런 문제를 해결하기 위해 함수형 업데이트를 사용하는 것이다.

const [count, setCount] = useState(0);

setCount((prev) => prev + 1);
setCount((prev) => prev + 1);
// count 값은 2가 된다

함수형 업데이트로 이전 상태를 인자로 받아서 사용할 수 있는데 이전 상태를 고려하고, 그 이전 상태에 1을 더한 값을 설정하기 때문에 두 번째 호출은 첫 번째 호출의 결과를 덮어쓰지 않고, 이전 상태를 기반으로 최종적으로 2를 더한 값을 설정하게 된다.

함수형 업데이트를 통해 비동기 문제를 피할 수 있는 이유는 자바스크립트의 클로저와 함수형 업데이트의 특성 때문이다. setState는 클로저를 활용하기 때문에 이전 상태를 기억할 수 있고 함수형 업데이트를 사용해 이전 상태를 참조하여 새로운 상태를 생성할 수 있는 것이다.

마무리

처음에는 useState의 불변성에 대해서만 알아보려 했는데 "왜?"가 자꾸 붙다보니 점점 자세하게 들어간 것 같다. 공식문서나 다른 글들을 많이 찾아봤는데 공식문서는 메모리에 대한 설명을 찾을 수 없었고 블로그마다, 사람마다 state는 어디에 저장되는지 답변이 전부 달랐다.

여기저기서 긁어모은 정보들과 주관적인 생각을 합쳐서 정리한 글이기 때문에 오류가 있을 수 있다.

이 글을 보는 사람은 별로 없겠지만 누군가 나와서 명쾌하게 "이건 아니다", "이건 맞다" 라고 설명해줬으면 좋겠다.

자바스크립트의 클로저는 중요하기에 다시 이해할 필요가 있다.

참고
[React] 리액트 State
자바스크립트 클로저(Closure)
모던 자바스크립트 딥다이브 클로저
[Java Script] 원시타입과 참조타입

profile
꾸준하게

0개의 댓글