💡불변성이란?
사전적 의미에서 불변성(Immutability)은 값이나 상태를 변경할 수 없음을 의미한다. 그리고 프로그래밍에서의 불변성은 메모리 영역에서 값이 변하지 않도록 하는 것을 의미한다.
"불변성을 지킨다"라는 말은 기존의 값을 직접 수정하지 않으면서 새로운 값을 만들어 내는 것을 말한다.
왜 React에서 불변성을 유지해야 할까?
React는 상태(State)를 업데이트할 때, 이전 상태와 새로운 상태를 비교하여 렌더링을 결정한다. 이를 위해 React는 얕은 비교(Shallow Comparison)를 수행하는데, 이는 객체나 배열의 참조 주소만 비교한다는 의미이다.
만약 상태를 업데이트할 때 불변성을 유지하지 않으면, 이전 상태와 새로운 상태가 동일한 참조를 가리키게 되어 React가 상태 변화를 감지하지 못할 수 있다. 이는 컴포넌트 최적화를 위해 React.memo와 같은 기능을 사용할 수 없게 된다.
불변성을 유지하지 않으면 React는 상태 변화를 올바르게 감지하지 못해 해당 컴포넌트가 업데이트되어야 하는지 여부를 판단하기 어려워진다. 그 결과, 컴포넌트가 불필요하게 다시 렌더링되어 성능이 저하될 수 있다.
React.memo란?
리액트에서 컴포넌트, 변수, 함수 등을 재렌더링할 때 제어가 필요한 경우에 메모이제이션을 수행한다.
메모이제이션은 이전 처리 결과를 저장하여 처리속도를 높이는 기술이다. React.memo를 사용하면 부모 컴포넌트에서 재렌더링되더라도 자녀 컴포넌트의 재렌더리를 방지할 수 있다.
React.memo는 React에서 성능 최적화를 위해 사용되는 고차 컴포넌트(Higher-Order Component)이다. 이를 사용하면 함수형 컴포넌트의 리렌더링을 제어할 수 있다.
기본적으로 React 컴포넌트는 부모 컴포넌트가 업데이트될 때마다 리렌더링된다. 그러나 부모 컴포넌트의 props나 state가 변경되지 않았을 때, 자식 컴포넌트의 리렌더링을 방지하여 성능을 향상시킬 수 있다. 이때 React.memo가 사용된다.
React.memo를 사용하면 컴포넌트를 렌더링할 때 이전에 렌더링한 결과를 기억하고, props가 변경되지 않았다면 이전 결과를 재사용한다. 따라서 props가 변경되지 않았을 때 불필요한 리렌더링을 방지할 수 있다.
import React from 'react';
const MyComponent = React.memo((props) => {
/* 컴포넌트 로직 */
});
export default MyComponent;
사용법은 간단하다. 함수형 컴포넌트를 만들고 React.memo 함수로 감싸면 된다.
이제 MyComponent는 props가 변경되지 않으면 이전 결과를 재사용하여 리렌더링을 방지한다. 이는 컴포넌트의 성능을 향상시키는 데 도움이 된다.
가상 DOM의 효율 저하
불변성을 유지하지 않으면 React의 장점인 가상 DOM의 효율성을 제대로 활용할 수 없다.
React는 상태의 변경을 추적하여 필요한 경우에만 실제 DOM에 변경 사항을 반영하는데, 이를 위해서는 상태가 불변성을 유지해야 한다. 그렇지 않으면 React가 상태 변경을 추적하지 못하고 모든 컴포넌트를 다시 렌더링해야 하므로 성능이 저하된다.
따라서, 불변성을 유지함으로써 React의 성능을 최적화하고 컴포넌트의 불필요한 렌더링을 방지할 수 있다. 이를 위해 주로 spread 연산자나 Immer와 같은 라이브러리를 사용하여 상태를 업데이트하는 것이 권장된다.
React에서는 상태를 업데이트할 때 불변성을 유지해야 한다. React가 얕은 비교를 통해 이전 상태와 새로운 상태를 비교하여 렌더링을 결정하기 때문이다.
얕은 비교와 깊은 비교
얕은 비교와 깊은 비교는 자바스크립트에서 객체나 배열을 비교하는 방법이다.
얕은 비교(Shallow Comparison)
얕은 비교는 객체나 배열의 참조 값만을 비교한다. 즉, 두 객체나 배열이 같은 메모리 주소를 가리키는지를 확인한다. 따라서 객체나 배열의 내부 구조까지는 비교하지 않는다.
얕은 비교는 주로 React와 같은 라이브러리에서 이전 상태와 새로운 상태를 비교하여 렌더링을 결정할 때 사용된다. 예를 들어, 두 객체의 내용이 같지만 서로 다른 메모리 주소를 가지고 있으면 얕은 비교에서는 두 객체를 다르다고 판단한다.
깊은 비교(Deep Comparison)
깊은 비교는 객체나 배열의 내부 구조까지 모두 비교한다. 즉, 모든 속성이 같은지를 확인한다. 이를 위해서는 객체나 배열의 모든 속성을 순회하면서 값을 비교해야 한다.
깊은 비교는 주로 객체나 배열의 내용을 비교하거나 복사할 때 사용된다. 예를 들어, 두 객체의 내용이 같고 내부 구조도 동일하면 깊은 비교에서는 두 객체를 동일하다고 판단한다.
얕은 복사와 깊은 복사
얕은 비교, 깊은 비교를 얕은 복사, 깊은 복사와 헷갈리지 않도록 한다.
이는 비슷한 이름을 가지고 있지만 서로 다른 개념이다.
얕은 복사(Shallow Copy)
객체나 배열의 참조 주소만 복사하는 것을 말한다. 내부 객체나 배열은 같은 메모리 공간을 참조한다.
깊은 복사(Deep Copy)
객체나 배열을 완전히 새로운 메모리 공간에 복사하는 것을 말한다. 이는 모든 내부 객체나 배열도 새로운 복사본을 만드는 것을 의미한다.
React에서 다루는 상태(state)와 속성(props)은 원시 타입과 참조 타입으로 구분할 수 있다. 그리고 이를 이해하기 위해서는 JavaScript의 메모리 구조에 대한 이해가 필요하다.
콜 스택과 메모리 힙
JavaScript 엔진은 메모리를 콜 스택과 메모리 힙으로 구분한다. 원시 타입은 콜 스택에 값을 저장하고, 참조 타입은 실제 값이 메모리 힙에 저장되고 해당 주소가 콜 스택에 저장된다.
콜 스택(Stack)
콜 스택은 함수 호출에 따른 실행 컨텍스트를 저장하는 메모리 영역이다.
함수가 호출될 때마다 해당 함수의 실행 컨텍스트가 콜 스택에 추가되고, 함수의 실행이 끝나면 해당 실행 컨텍스트가 스택에서 제거된다.
함수의 호출 순서대로 실행되며, 이것이 자바스크립트의 실행 순서를 제어하는 메커니즘이다.
단일 호출 스택(call stack)을 사용하므로 한 번에 하나의 작업만 처리할 수 있다.
메모리 힙(Heap)
메모리 힙은 동적으로 할당된 메모리를 저장하는 공간이다.
객체(Object)와 배열(Array) 등의 복합 데이터 타입(참조 타입)은 메모리 힙에 저장된다.
콜 스택에서 참조되는 값들은 실제로는 메모리 힙에 저장된 객체의 주소(참조)를 가리키게 된다.
가비지 컬렉션(Garbage Collection)에 의해 사용되지 않는 메모리가 정리되어 관리된다.
가비지 컬렉션
가비지 컬렉션은 더 이상 사용되지 않는 메모리를 자동으로 해제하는 프로세스를 의미한다. JavaScript 엔진은 이를 통해 동적으로 할당된 메모리를 추적하고, 더 이상 필요하지 않은 객체나 값들을 정리하여 메모리 누수를 방지한다. 이는 개발자가 메모리 관리에 직접 신경 쓰지 않고도 메모리를 효율적으로 활용할 수 있도록 도와준다.
https://developer.mozilla.org/ko/docs/Web/JavaScript/Memory_management
JavaScript의 메모리 관리에 대해서는 위 글을 참고한다.
원시 타입과 참조 타입
- 원시 타입: Boolean, String, Number, null, undefined, Symbol
- 참조 타입: Object, Array, Function
원시 타입
let a = 10;
let b = a; // b는 10을 가리킴
a = 20; // a를 변경해도 b는 변하지 않음
console.log(b); // 출력 결과: 10
원시 타입은 주로 속성(props)으로 사용된다. 속성은 부모 컴포넌트에서 자식 컴포넌트로 전달된다.
원시 타입의 저장 과정
원시 타입은 값 자체가 콜 스택에 저장된다. 즉, 변수에는 해당 값이 직접 저장된다. 예를 들어, let num = 10;과 같이 변수에 숫자를 할당하면 해당 숫자 값이 직접 스택에 저장된다.
참조 타입
let obj1 = { name: 'Isaac' };
let obj2 = obj1; // obj2가 obj1을 가리킴
obj1.age = 24; // obj1을 변경하면 obj2도 영향을 받음
console.log(obj2); // 출력 결과: { name: 'Isaac', age: 24 }
참조 타입은 주로 상태(state)로 사용된다. 상태는 컴포넌트 내에서 변경되거나 관리되는 값으로, 상태의 변경을 통해 컴포넌트의 렌더링을 업데이트하는 데 사용된다.
즉, 복사가 이루어지는 것이 아닌 참조값을 갖게되기 때문에 사본을 수정하게 되면 원본도 수정이 되어버리는 것이다.
참조 타입의 저장 과정
참조 타입은 값이 실제로는 메모리 힙에 저장되고, 콜 스택에는 그 값에 대한 참조(주소)가 저장된다. 즉, 변수에는 해당 값이 아닌 메모리 힙에 대한 주소가 저장된다. 예를 들어, let obj = { name: 'John' };와 같이 객체를 변수에 할당하면 객체 자체는 힙에 저장되고, 변수 obj에는 해당 객체의 메모리 주소가 저장된다.
주로 원시 타입은 값을 직접 변경할 수 없으나, 참조 타입은 내부의 값이 변경되어도 변수는 여전히 같은 주소를 가리킨다. 이로 인해 React에서 상태 변화를 감지하는 데 문제가 발생할 수 있다.
원시 타입은 변수에 새로운 값이 할당되면 이전 값이 아닌 새로운 값을 가리키며, 참조 타입은 변수가 객체나 배열을 가리키는 경우, 해당 값이 변경되어도 변수는 여전히 같은 주소를 가리킨다.
앞서 리액트에서 상태의 변화를 감지하기 위해 얕은 비교를 수행할 때, 객체나 배열의 참조 주소만 비교한다고 했다. 이는 원시 타입은 값을 직접 변경해도 상태 변화가 감지되지만, 참조 타입은 내부의 값이 변경되어도 주소값이 변하지 않아 상태 변화가 감지되지 않을 수 있다는 말이다. 이를 해결하기 위해서는 불변성을 유지해야 한다.
import React, { useState } from 'react';
function App() {
const [person, setPerson] = useState({
name: "John",
age: 30,
});
const changeAge = () => {
// 잘못된 방식: 직접 객체 내부를 변경
person.age = 31;
setPerson(person); // 상태 변화 감지 안됨
console.log(person.age); // 출력 결과: 31
};
return (
<div>
<span>{person.name}</span>
<span>{person.age}</span>
<button onClick={changeAge}>Change Age</button>
</div>
);
}
spread 연산자를 사용하여 객체를 복제하고 변경하거나 Immer와 같은 라이브러리를 사용하여 불변성을 유지할 수 있다.
import React, { useState } from 'react';
function App() {
const [person, setPerson] = useState({
name: "John",
age: 30,
});
const changeAge = () => {
// 올바른 방식: 불변성 유지
setPerson(prevPerson => ({ ...prevPerson, age: 31 }));
};
return (
<div>
<span>{person.name}</span>
<span>{person.age}</span>
<button onClick={changeAge}>Change Age</button>
</div>
);
}
이렇게 하면 React는 상태의 변경을 올바르게 감지하여 UI를 업데이트한다.
불변성을 유지하는 방법
1. 새로운 객체나 배열 생성
객체나 배열을 업데이트 할 때마다 새로운 객체나 배열을 생성하여 불변성을 유지한다.
// 객체 업데이트
const updatedObject = { ...oldObject, key: value };
// 배열 업데이트
const updatedArray = [...oldArray, newValue];
2. spread 연산자 활용
spread 연산자를 사용하여 새로운 객체나 배열을 생성한다.
// 객체 업데이트
const updatedObject = { ...oldObject, key: value };
// 배열 업데이트
const updatedArray = [...oldArray, newValue];
3. 함수형 메서드 활용
spread operator외에도 map, filter, slice, reduce 등등 새로운 배열을 반환하는 함수형 메서드를 사용하여 업데이트를 수행한다.
// 배열 업데이트
const updatedArray = oldArray.map(item => {
if (item.id === id) {
return { ...item, key: newValue };
}
return item;
});
불변성을 유지하는 방법으로는 새로운 객체나 배열을 생성하거나, spread 연산자를 활용하여 불변성을 유지할 수 있다. 또한, Immer 라이브러리를 사용하여 상태를 업데이트할 때 더 간편하게 불변성을 유지할 수 있다.
불변성의 장단점
불변성의 장점
불변성은 상태 변화를 예측 가능하게 만들어서 디버깅이 쉽고, 병렬 처리 및 캐싱 등의 최적화를 가능하게 한다. 또한 함수형 프로그래밍의 개념을 적용하여 코드를 보다 간결하고 안정적으로 만들 수 있다.
불변성의 단점
불변성을 유지하는 과정에서 메모리 사용량이 증가할 수 있고, 복잡한 객체나 배열을 다룰 때 번거로움을 초래할 수 있다. 또한 가변성을 허용하지 않기 때문에 일부 상황에서는 처리 속도가 저하될 수 있다.
💡Immer 라이브러리
불변성 라이브러리
Immutable.js
Immutable.js는 Facebook에서 제공하는 라이브러리로, 데이터의 변경을 효율적으로 관리하고 불변성을 보장하는 데 도움을 준다. Immutable.js를 사용하면 불변성을 유지하는 작업이 훨씬 간단해진다.
Immer
Immer는 불변성을 유지하면서도 더 간편하게 상태를 업데이트할 수 있도록 도와주는 라이브러리이다. immer를 사용하면 기존의 객체를 변경하는 것처럼 코드를 작성할 수 있으면서도 내부적으로는 불변성을 보장한다. 우리가 살펴볼 것은 바로 이 Immer 라이브러리이다.
Immer를 사용하면 가독성이 향상되고 불변성이 유지되면서도 코드를 간결하게 작성할 수 있으며, 상태 관리를 보다 안정적으로 할 수 있다.
https://immerjs.github.io/immer/
Immer 라이브러리에 관해서는 위 글을 참고한다.
Immer의 특징
1. 가독성이 향상된다.
Immer를 사용하면 코드의 가독성이 향상된다. 이로 인해 상태를 업데이트하는 로직을 더 명확하게 표현할 수 있다.
2. 불변성이 유지된다.
Immer는 내부적으로 불변성을 유지하면서도 가독성을 높이는 데 집중한다. 따라서 코드를 작성할 때 실수로 상태를 변경하는 것을 방지할 수 있다.
3. 코드가 간결해진다.
복잡한 불변성 관련 로직을 직접 작성하는 대신 Immer를 사용하여 코드를 간결하게 만들 수 있다.
Immer 설치하기
$ npm install immer
$ yarn add immer
먼저 프로젝트에 Immer를 설치해야 한다. npm 또는 yarn을 사용하여 설치할 수 있다.
Immer를 활용한 불변성 유지
import produce from 'immer';
// 예시 상태
const initialState = {
todos: [
{ id: 1, text: 'Buy groceries', done: false },
{ id: 2, text: 'Clean the house', done: true }
]
};
// produce 함수를 사용하여 새로운 상태를 생성
const nextState = produce(initialState, draftState => {
// draftState는 불변성을 유지하는 데 필요한 원본 상태와 동일한 구조를 가짐
draftState.todos.push({ id: 3, text: 'Go for a run', done: false });
draftState.todos[0].done = true;
});
Immer를 사용하면 기존의 불변성을 유지하면서도 코드를 간결하게 작성할 수 있다. 주로 produce 함수를 사용하여 상태를 업데이트한다.
Immer의 활용
import produce from 'immer';
// 리덕스 리듀서 예시
function todosReducer(state = initialState, action) {
switch (action.type) {
case 'ADD_TODO':
return produce(state, draftState => {
draftState.todos.push({ id: action.payload.id, text: action.payload.text, done: false });
});
case 'TOGGLE_TODO':
return produce(state, draftState => {
const todo = draftState.todos.find(todo => todo.id === action.payload.id);
if (todo) {
todo.done = !todo.done;
}
});
default:
return state;
}
}
Immer는 리덕스와 같은 상태 관리 라이브러리와 함께 사용될 때 특히 유용하다. 리덕스의 리듀서 함수에서 Immer를 사용하여 상태를 업데이트하면 코드의 가독성을 높이고 실수를 줄일 수 있다.
이렇게 Immer를 활용하면 복잡한 불변성 관리를 쉽게 처리할 수 있으며, 이 과정에서 코드를 간결하게 유지하면서도 불변성을 유지할 수 있어서 개발자의 생산성이 향상된다.
Immer 사용 시 고려사항
1. 코드 분리와 모듈화를 진행한다.
Immer를 사용하여 상태를 업데이트하는 코드는 간결하고 가독성이 높지만, 여러 곳에서 동일한 상태를 업데이트하는 경우에는 유지보수가 어려워질 수 있다. 따라서 관련된 로직은 모듈화 하고, 필요한 경우 별도의 함수로 분리하여 사용하는 것이 좋다.
2. Immer를 사용한 중첩된 객체나 배열의 업데이트에 주의한다.
Immer는 내부적으로 얕은 복사를 통해 불변성을 유지하지만, 중첩된 객체나 배열의 경우에는 이를 어렵게 만들 수 있다. 따라서 중첩된 객체나 배열을 업데이트할 때에는 주의가 필요하다. 만약 중첩된 구조가 복잡하다면 깊은 복사를 고려해야 한다.
3. 성능을 고려한다.
Immer를 사용하면 코드의 가독성과 유지보수성이 향상되지만, 내부적으로는 불변성을 유지하기 위해 새로운 객체나 배열을 생성하는 등의 작업이 수행된다. 따라서 상태 업데이트가 빈번하게 일어나는 경우에는 Immer를 사용하는 것이 성능에 영향을 줄 수 있다. 이러한 경우에는 성능에 민감한 부분에서 직접 불변성을 유지하는 방법을 고려해야 한다.
4. 타입스크립트와 함께 사용한다.
Immer를 타입스크립트와 함께 사용할 때에는 immer의 Draft 타입을 활용하여 상태를 정확하게 타입 지정하는 것이 좋다. 이를 통해 타입 안정성을 높일 수 있다.
5. 테스트를 진행한다.
Immer를 사용한 코드의 테스트도 중요하다. Immer를 사용하여 상태를 업데이트하는 코드에 대한 유닛 테스트를 작성하여 예상치 못한 버그를 방지할 수 있다.
참고 자료
리액트에서 불변성이란, 김정민, 2022.05.09.
[React] 불변성이란?, 김하영, 2022.11.26.
[React] 리액트와 불변성, LasBe, 2023.01.05.
My Event Loop, Rod Machen, 2017.04.06.
JavaScript의 메모리 관리, MDN contributors, 2023.10.25.