조각조각 - React(불변성, 얕은 비교, 전개 구문)

eocode·2023년 3월 16일
0

리액트 조각조각

목록 보기
3/11
post-thumbnail

리액트 불변성

리액트 불변성이란?

리액트 불변성이란, 리액트의 상태(state)를 변경할 때 기존의 상태를 수정하여 변경하는 것이 아니라 새로운 상태를 만들고 그 값으로 변경하는것을 말합니다.

const array = [1, 2, 3];
const [reactState, setReactState] = useState(array);

//리액트 불변성이 지켜지지 않은 경우
array.push(4);
setReactState(array);

//리액트 불변성이 지켜진 경우
setReactState([1, 2, 3,4]);
setReactState([...array, 4]);

위 코드를 보면 array.push(4)로 기존 상태를 수정한 후 reactState 상태를 변경하고 있습니다. 이 경우가 불변성이 지켜지지 않은 경우입니다. 그렇기 때문에 문제가 의도하지 않은 문제가 발생할 수 있습니다. 문제점에 대해선 아래에서 좀 더 자세히 알아보겠습니다. 그 아래 코드에선 [1, 2, 3, 4] 새로운 배열로 reactState 상태를 변경합니다. 이 경우 불변성이 지켜져 문제가 발생하지 않습니다. setReactState([...array, 4])는 전개구문을 사용한 코드인데 setReactState([1, 2, 3,4])와 동일하게 동작하여 불변성이 지켜지고 문제가 발생하지 않습니다.

그럼 왜 사용할까?

리액트는 컴포넌트의 state가 변경되면 해당 컴포넌트를 재평가하고, 가상 돔(Virtual DOM)과 실제 돔(Real DOM)을 비교하여 필요한 부분만 업데이트합니다. 이때 state가 변경되었다는 판단이 '얕은 비교'에 의해 이루어집니다. 따라서 리액트 불변성을 이해하기 위해 '얕은 비교'가 무엇인지 먼저 알아야합니다.

얕은 비교

두 개의 객체나 배열이 같은 주소값을 가리키는지만 비교하는 것입니다. 즉, 내부의 값이 같은지는 상관하지 않고, 단순히 참조값이 같은지만 확인합니다.

const arr1 = [1, 2, 3];
const arr2 = [1, 2, 3];
const arr3 = arr1;

console.log(arr1 === arr2); // false
console.log(arr1 === arr3); // true

여기서 arr1과 arr2는 둘 다 [1, 2, 3] 값을가진 배열입니다. 하지만 따로 선언된 변수기 때문에 서로 다른 주소값을 가집니다. 이때 배열은 참조 타입이기 때문에 주소값을 비교하여 같다, 다르다 판단하게 됩니다. 즉 얕은 비교로 같다, 다르다를 판단하게 됩니다. 따라서 얕은 복사가 이루어져 같은 주소를 가진 arr3과 arr1은 얕은 비교시 같다는 결과가 나오게 됩니다. 하지만 다른 주소를 가진 arr1과 arr2는 다르다는 결과가 나오게 됩니다.

불변성이 지켜지지 않은 경우

앞서 언급하였듯이 리액트는 얕은 비교로 state의 변화를 판단합니다. 따라서 다른 내부 값을 지니더라도 참조값이 같다면 변화를 판단하지 못하게 됩니다.

const array = [1, 2, 3];
const [reactState, setReactState] = useState(array);

//리액트 불변성이 지키지지 않은 경우
array.push(4);
setReactState(array);

state 변화를 감지하지 못하는 경우를 알아보겠습니다. 코드에서 reactState의 초기 상태는 값이 [1, 2, 3]array 배열입니다. 이후 array 배열은 push 메서드로 4가 추가되어 [1, 2, 3, 4]가 됩니다. 이때 push는 비파괴적 메서드이므로 array의 참조값은 변하지 않습니다. 끝으로 reactState의 상태를 array로 재설정합니다.

이러면 과연 reactState의 상태가 [1, 2, 3, 4]로 변할까요? 답은 '아니다'입니다. reactState의 상태는 array에서 array로 변경된 것입니다. 이때 array의 내부 값이 변경되었다고 해도 참조값은 여전히 동일합니다. 따라서 리액트는 상태 변화를 인지하지 못하고 리렌더링이 발생하지 않게됩니다.

그러다면 의도한대로 reactState 상태를 [1, 2, 3, 4]를 바꾸고 싶다면 어떻게 해야할까요? 해답은 바로 새로운 배열로 상태를 변경하거나 이전에 이용된 배열이라도 전개 구문을 활용하여 상태를 변경하는 것입니다.

const array = [1, 2, 3];
const [reactState, setReactState] = useState(array);

//리액트 불변성이 지켜진 경우
setReactState([1, 2, 3,4]);
setReactState([...array, 4]);

setReactState([1, 2, 3,4])는 새로운 배열인 [1, 2, 3,4]로 상태를 변경하는 방법입니다. 이때 이전 상태인 array의 참조값과 새로운 [1, 2, 3,4]의 참조값이 다르기 때문에 리액트가 상태 변경을 파악하고 리렌더링이 발생돼 값이 의도한대로 변경되게 됩니다. setReactState([...array, 4])도 동일한 방식으로 동작합니다. 이때 전개 구문이 사용되어 새로운 배열이 만들어져 기존 array와 다른 참조값을 가지게 됩니다.

리액트 불변성 예제

전개 구문 외에도 불변성을 지키는 방법이 존재합니다. map, filter, slice, reduce 등의 메서드를 사용하면 됩니다. 하지만 꼭 이 메서드들만 사용 가능한것은 아닙니다. 포인트는 결국 새로운 것을 객체를 뽑아내서 참조값의 변화를 주는것입니다. 즉 참조값의 변화를 일으킬 수 있는 파괴적 메서드를 활용하면 가능합니다.

map

const numbers = [1, 2, 3, 4, 5];
const [reactState, setReactState] = useState(numbers);

const doubled = numbers.map(number => number * 2);
setReactState(doubled);

파괴적 메서드 map이 새로운 주소의 doubled 배열을 생성합니다. 이는 numbers 배열과 다른 참조값을 가져 리액트가 상태 변화를 감지할 수 있습니다.

filter

const numbers = [1, 2, 3, 4, 5];
const [reactState, setReactState] = useState(numbers);

const evens = numbers.filter(number => number % 2 === 0);
setReactState(evens);

파괴적 메서드 filter 새로운 주소의 evens 배열을 생성합니다. 이는 numbers 배열과 다른 참조값을 가져 리액트가 상태 변화를 감지할 수 있습니다.

slice

const numbers = [1, 2, 3, 4, 5];
const [reactState, setReactState] = useState(numbers);

const sliced = numbers.slice(1, -1);
setReactState(sliced);

파괴적 메서드 slice 새로운 주소의 sliced 배열을 생성합니다. 이는 numbers 배열과 다른 참조값을 가져 리액트가 상태 변화를 감지할 수 있습니다.

reduce

const numbers = [1, 2, 3, 4, 5];
const [reactState, setReactState] = useState(numbers);

const sum = numbers.reduce((acc, cur) => acc + cur, 0);
setReactState(sum);

파괴적 메서드 reduce 새로운 주소의 sum 배열을 생성합니다. 이는 numbers 배열과 다른 참조값을 가져 리액트가 상태 변화를 감지할 수 있습니다.

🚨🚨🚨 전개구문 주의사항

전개 구문은 배열 또는 객체를 풀기위해 사용됩니다. 이때 주의해야할 사항이 있습니다. 바로 전개 구문은 최외곽에서만 반응한다는것입니다. 만약 개체의 깊이가 1단이 아닌 깊은 경우 최외곽만 풀어지고 내부의 값들은 풀어지지 않고 참조값을 그대로 가지게 됩니다.

코드를 보며 알아보겠습니다.

//깊이가 1단인 일반적인 전개 구문
const arr = [1,2,3,4,5];

console.log(arr); // [1,2,3,4,5]
console.log(...arr); // 1 2 3 4 5
console.log([...arr]); // [1,2,3,4,5]

const obj = {depth1_char : "test", depth1_num : 123}

console.log(obj); // {depth1_char : "test", depth1_num : 123}
console.log({...obj}); // {depth1_char : "test", depth1_num : 123}

깊이가 1인 일반적인 전개 구문에선 의도한대로 배열이 펼쳐지는것을 볼 수 있습니다.

//객체 깊이가 깊은 경우
const obj = {
  depth1_obj: {
    depth2_char: "depth2",
    depth2_num: 2
  },
  depth1_num: 1
};
const obj2 = { ...obj };

console.log(obj.depth1_obj.depth2_num); //2
console.log(obj2); //2

obj.depth1_obj.depth2_num = 3;
console.log(obj2.depth1_obj.depth2_num); //3

깊이가 1이 아닌 객체의 전개구문에서 obj와 obj2는 서로 다른 참조값을 가지긴 하지만 내부 obj.depth1_objobj2.depth1_obj는 같은 참조값을 가지게 됩니다. 그렇기 때문에 obj.depth1_obj.depth2_num 값을 변경했을때 obj2.depth1_obj.depth2_num 값도 같이 변경됩니다. 이를 해결하기 위해선 각 깊이마다 전개 구문을 일일이 사용하거나 immer 같은 라이브러리를 사용해야합니다.

참고자료

profile
프론트엔드 개발자

0개의 댓글