배열 상태 마스터하기 - 불변성을 지키는 배열 조작법
리액트 공식문서 기반의 리액트 입문기
들어가며
자바스크립트에서 객체와 함께 가장 많이 다루는 데이터 타입 중 하나가 바로 배열입니다. 우리는 그동안 push, pop, splice, sort와 같은 다양한 배열 메서드를 활용하여 데이터를 추가하고, 제거하고, 정렬하는 등 배열의 내용을 자유롭게 변경해왔습니다. 3-6편에서 객체 상태를 다룰 때 불변성 원칙이 중요하다고 이야기했던 것처럼, 리액트에서 배열 상태를 관리할 때도 이 '불변성' 원칙을 지키는 것이 매우 중요합니다.
이번 편에서는 리액트에서 배열 상태를 어떻게 불변적으로 다루어야 하는지, 그리고 자바스크립트 방식과 리액트 방식이 어떻게 다른지 예제를 통해 알아보려 합니다. 배열의 불변적 업데이트는 단순히 오류를 방지하는 것을 넘어, 리액트의 효율적인 렌더링 메커니즘을 최대한 활용하고 예측 가능한 애플리케이션 상태를 유지하는 데 필수적인 요소입니다. 자바스크립트의 배열 메서드들이 리액트의 useState 훅과 만나 어떻게 새로운 형태로 진화하는지 함께 탐구해 보겠습니다.
리액트의 불변성 원칙 - 배열 상태 관리의 핵심
리액트가 상태 변경을 감지하는 방식은 객체와 마찬가지로 '참조(Reference)'의 동일성 여부에 달려 있습니다. 즉, 이전 배열의 참조와 현재 배열의 참조가 다를 때 리액트는 상태가 변경되었다고 판단하고 렌더링을 진행합니다.
만약 우리가 배열의 내용을 push나 splice와 같이 직접 수정하게 되면, 배열 내부의 요소는 바뀌지만 배열 변수가 가리키는 메모리 주소, 즉 '참조' 자체는 변하지 않습니다. 이 경우 리액트는 상태가 변경된 것을 인지하지 못하여 UI가 업데이트되지 않거나, 예상치 못한 동작을 보일 수 있습니다. 이러한 문제를 해결하기 위해 리액트에서는 배열을 업데이트할 때 항상 기존 배열을 직접 수정하는 대신, 새로운 배열을 생성하여 변경 사항을 반영하는 '불변성 원칙'을 따르도록 권장합니다. 이 원칙을 지킴으로써 우리는 리액트의 렌더링 메커니즘과 상태 관리를 더욱 효과적으로 활용할 수 있습니다.
자바스크립트로 배열 상태 변경하기
먼저 자바스크립트 방식으로 배열 상태를 변경하는 방법을 살펴보겠습니다. 간단한 할 일 목록 배열을 만들고, 항목을 추가하거나 제거, 또는 수정하는 예제를 통해 자바스크립트의 배열 처리 방식을 이해해 봅시다.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>JavaScript Array Update</title>
</head>
<body>
<h1>JavaScript 배열 상태 변경 예제</h1>
<input type="text" id="todoInput" placeholder="새로운 할 일" />
<button id="addTodoButton">추가</button>
<ul id="todoList"></ul>
<script>
(function () {
let todos = [
{ id: 1, text: "리액트 공식문서 읽기", completed: false },
{ id: 2, text: "블로그 포스트 작성", completed: true },
];
const todoInput = document.getElementById("todoInput");
const addTodoButton = document.getElementById("addTodoButton");
const todoList = document.getElementById("todoList");
let nextId = 3; // 다음 할 일 ID
function renderTodos() {
todoList.innerHTML = ""; // 목록 초기화
todos.forEach((todo) => {
const li = document.createElement("li");
li.textContent = todo.text + (todo.completed ? " (완료)" : "");
li.style.textDecoration = todo.completed ? "line-through" : "none";
// 완료 토글 버튼
const toggleButton = document.createElement("button");
toggleButton.textContent = todo.completed ? "미완료" : "완료";
toggleButton.style.marginLeft = "10px";
toggleButton.addEventListener("click", () => {
toggleTodo(todo.id);
});
// 삭제 버튼
const deleteButton = document.createElement("button");
deleteButton.textContent = "삭제";
deleteButton.style.marginLeft = "5px";
deleteButton.addEventListener("click", () => {
deleteTodo(todo.id);
});
li.appendChild(toggleButton);
li.appendChild(deleteButton);
todoList.appendChild(li);
});
}
function addTodo() {
const text = todoInput.value.trim();
if (text) {
todos.push({ id: nextId++, text, completed: false }); // 배열 직접 수정
todoInput.value = "";
console.log("할 일 추가 후 todos:", todos);
renderTodos(); // UI 수동 업데이트
}
}
function toggleTodo(id) {
const todoIndex = todos.findIndex((todo) => todo.id === id);
if (todoIndex > -1) {
todos[todoIndex].completed = !todos[todoIndex].completed; // 객체 속성 직접 수정
console.log("할 일 토글 후 todos:", todos);
renderTodos(); // UI 수동 업데이트
}
}
function deleteTodo(id) {
const initialLength = todos.length;
todos = todos.filter((todo) => todo.id !== id); // 새로운 배열 생성 (원본 유지 X)
if (todos.length < initialLength) {
console.log("할 일 삭제 후 todos:", todos);
renderTodos(); // UI 수동 업데이트
}
}
// 초기 렌더링
renderTodos();
// 이벤트 리스너 연결
addTodoButton.addEventListener("click", addTodo);
todoInput.addEventListener("keypress", (e) => {
if (e.key === "Enter") {
addTodo();
}
});
})();
</script>
</body>
</html>위 자바스크립트 코드에서는 todos 배열에 push 메서드를 사용하여 새 항목을 직접 추가하고, toggleTodo 함수에서는 특정 할 일 객체의 completed 속성을 직접 수정하고 있습니다. deleteTodo에서는 filter를 사용했지만, 여전히 todos 변수에 새로운 배열을 할당하여 원본 배열을 대체하는 방식입니다. 이렇게 배열의 내용을 직접 수정하거나 할당하는 방식은 자바스크립트에서는 일반적이지만, 리액트의 상태 관리에서는 다른 접근이 필요합니다. 변경 후에는 renderTodos() 함수를 명시적으로 호출하여 UI를 수동으로 업데이트해주고 있습니다.
자바스크립트 방식의 특징
- 배열 직접 수정과 참조 동일성 유지 (Mutable Update): 자바스크립트에서는
push,splice와 같은 배열 메서드를 사용하거나 특정 인덱스에 직접 값을 할당하여 배열의 내용을 변경합니다. 이 방식은 원본 배열 객체의 참조가 그대로 유지된 채 내부 값만 바뀌는 '가변적(mutable)'인 업데이트로, 배열 변수가 가리키는 메모리 주소(참조)가 동일하게 유지됩니다. - 수동적인 UI 업데이트: 배열 상태가 변경된 후에는
renderTodos()와 같은 함수를 명시적으로 호출하여 DOM을 직접 조작해 UI를 업데이트해야 합니다. - 다양한 메서드 활용:
push,pop,splice,filter,map등 다양한 내장 배열 메서드를 활용하여 배열을 조작합니다. - 예측의 어려움: 배열 객체가 여러 곳에서 공유되거나 사용될 경우, 한 곳에서의 직접적인 변경이 다른 곳에 어떤 영향을 미칠지 추적하기 어려워 예상치 못한 부작용(side effect)을 유발할 수 있습니다.
리액트로 동일한 배열 상태 만들기
이제 리액트 버전으로 넘어가 보겠습니다. 리액트에서는 useState 훅을 사용하여 배열 상태를 관리하며, 배열을 업데이트할 때는 객체와 마찬가지로 불변성 원칙을 지키는 것이 매우 중요합니다. 즉, 기존 배열을 직접 수정하는 대신, 항상 새로운 배열을 만들고 변경된 요소만 새 배열에 반영하는 방식으로 상태를 업데이트해야 합니다. 이를 위해 map, filter, concat, 그리고 스프레드 연산자(...)를 적극적으로 활용합니다.
import { useState } from 'react';
let nextId = 3; // 모듈 스코프에서 관리하여 고유 ID 유지
function TodoList() {
const [todos, setTodos] = useState([
{ id: 1, text: '리액트 공식문서 읽기', completed: false },
{ id: 2, text: '블로그 포스트 작성', completed: true },
]);
const handleAddTodo = text => {
if (text.trim() === '') return;
setTodos(prevTodos => [
...prevTodos, // 기존 배열의 모든 요소를 복사
{ id: nextId++, text, completed: false }, // 새로운 할 일 추가
]);
};
const handleToggleTodo = id => {
setTodos(prevTodos =>
prevTodos.map(todo => (todo.id === id ? { ...todo, completed: !todo.completed } : todo))
);
};
const handleDeleteTodo = id => {
setTodos(prevTodos => prevTodos.filter(todo => todo.id !== id)); // 특정 할 일을 제외하고 새로운 배열 생성
};
return (
<div>
<h1>React 배열 상태 변경 예제</h1>
<AddTodo onAddTodo={handleAddTodo} />
<ul>
{todos.map(todo => (
<li key={todo.id} style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
{todo.text} {todo.completed ? '(완료)' : ''}
<button onClick={() => handleToggleTodo(todo.id)} style={{ marginLeft: '10px' }}>
{todo.completed ? '미완료' : '완료'}
</button>
<button onClick={() => handleDeleteTodo(todo.id)} style={{ marginLeft: '5px' }}>
삭제
</button>
</li>
))}
</ul>
</div>
);
}
function AddTodo({ onAddTodo }) {
const [inputText, setInputText] = useState('');
const handleSubmit = e => {
e.preventDefault();
onAddTodo(inputText);
setInputText('');
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={inputText}
onChange={e => setInputText(e.target.value)}
placeholder="새로운 할 일"
/>
<button type="submit">추가</button>
</form>
);
}
export default TodoList;위 리액트 코드에서 handleAddTodo 함수를 보면, setTodos를 호출할 때 ...prevTodos와 같이 스프레드 연산자를 사용하여 기존 prevTodos 배열의 모든 요소를 복사하고, 새로운 할 일 객체를 추가하여 완전히 새로운 배열 인스턴스를 생성하고 있습니다.
handleToggleTodo 함수에서는 map 메서드를 사용하여 특정 id를 가진 할 일 객체의 completed 속성만 변경된 새로운 객체를 만들고, 나머지 할 일은 그대로 유지하여 새로운 배열을 반환합니다. map 메서드는 항상 새로운 배열을 반환하므로 불변성을 유지하는 데 유용합니다.
handleDeleteTodo 함수에서는 filter 메서드를 사용하여 특정 id를 가진 할 일만 제외하고 새로운 배열을 생성합니다. filter 역시 항상 새로운 배열을 반환하므로 불변성을 지키면서 요소를 제거할 수 있습니다. 모든 상태 업데이트는 setTodos의 함수형 업데이트를 통해 이전 상태에 안전하게 접근하고 있습니다.
리액트 방식의 특징 (배열 상태의 불변성)
- 1. 불변적 업데이트(Immutable Update)와 새로운 참조 생성: 리액트에서 배열 상태를 업데이트할 때는 기존 배열을 직접 수정(
Mutable Update)하는 대신, 항상 새로운 배열 인스턴스를 생성하여 변경된 요소만 새 배열에 반영하는 불변적 업데이트(Immutable Update) 방식을 사용해야 합니다. 이는 리액트가 이전 배열과 현재 배열의 '참조(Reference)'가 달라졌는지 여부로 변화를 감지하기 때문입니다. 새로운 배열 참조를 생성함으로써 리액트는 상태 변경을 명확히 인지하고 효율적으로 UI를 업데이트합니다. 이 원칙은 배열 상태 변화를 예측 가능하게 하고, 불필요한 리렌더링을 방지하여 성능을 최적화하는 데 핵심적인 역할을 합니다.filter,map,concat, 스프레드 연산자(...) 등이 이 방식의 주요 도구입니다. - 2. 자동적인 UI 동기화와 개발 생산성:
setTodos와 같은setter함수를 통해 배열 상태를 업데이트하면, 리액트가 자동으로 컴포넌트를 다시 렌더링하고 변경된 부분만 효율적으로Virtual DOM을 통해 실제DOM에 반영합니다. 자바스크립트에서renderTodos()와 같은 함수를 명시적으로 호출하여 UI를 수동으로 업데이트해야 했던 것과 달리, 리액트는 상태와 UI의 동기화 책임을 자동으로 관리합니다. 이는 개발자가 UI 업데이트 로직에 대한 부담을 덜고, 애플리케이션의 비즈니스 로직 구현에 더 집중할 수 있게 하여 개발 생산성을 크게 향상시킵니다. - 3.
keyProp의 중요성과 렌더링 최적화: 리스트를 렌더링할 때는 각 항목에 고유하고 안정적인keyprop을 제공해야 합니다.key는 리액트가 리스트의 어떤 항목이 변경, 추가, 제거되었는지 효율적으로 식별하는 데 도움을 주어,Virtual DOM의 재조정(Reconciliation) 과정에서 불필요한DOM조작을 최소화하고 UI 업데이트의 성능과 안정성을 크게 향상시킵니다. 가능한 한 배열 인덱스 사용은 피하고, 데이터의 고유 ID와 같은 안정적인 키를 부여하는 것이 중요합니다. (2-5편에서keyprop의 중요성을 더 자세히 다룹니다.) - 4. 함수형 업데이트(Functional Updates)로 안전한 상태 변경: 이전 배열 상태 값에 기반하여 새로운 배열 상태를 안전하게 계산해야 할 때, 함수형 업데이트를 사용하면 항상 최신 상태 값을 기반으로 업데이트가 이루어지도록 보장합니다.
setTodos((prevTodos) => [...prevTodos, newTodo])와 같이setter에 함수를 전달하는 방식은 비동기적인 상태 업데이트 상황에서 우리가 예상하는 정확하고 일관된 결과를 보장하여 버그 발생 가능성을 줄여줍니다. 이는 복잡한 배열 로직에서 상태 관리의 안정성과 예측 가능성을 높이는 데 매우 효과적입니다. 특히 비동기적인 상태 업데이트 상황에서는 버그 발생 가능성을 줄여주고, 복잡한 배열 로직에서 상태 관리의 안정성을 향상시키는 핵심적인 접근 방식 중 하나입니다. - 5. 예측 가능한 상태 관리와 디버깅 용이성: 불변성 원칙을 유지하면 배열 상태 변화가 언제, 어디서 일어났는지 추적하기 쉬워지고, 예기치 못한 **사이드 이펙트(Side Effect)**를 줄여 애플리케이션의 안정성과 디버깅 용이성을 높일 수 있습니다. 각 상태 변경이 새로운 배열 인스턴스를 생성하므로,
props와state의 변화를 추적하는 것이 훨씬 용이해집니다. 특히 Immer 라이브러리와 같은 도구는 복잡한 불변성 업데이트를 직관적으로 만들어 개발자의 생산성을 향상시키고 디버깅을 더욱 쉽게 만듭니다.
Immer 라이브러리: 불변성을 더 쉽게 (배열 편)
3-6편에서 객체 상태의 불변성을 다룰 때 Immer 라이브러리가 복잡한 불변성 업데이트를 얼마나 직관적으로 만들어주는지 잠깐 살펴보았습니다. Immer는 배열 상태를 다룰 때도 그 진가를 발휘합니다. 스프레드 연산자를 여러 번 사용하거나 map, filter 등의 메서드 체이닝이 복잡해질 때, Immer를 사용하면 마치 일반 자바스크립트 배열을 직접 수정하는 것처럼 코드를 작성하면서도 불변성을 쉽게 유지할 수 있습니다.
import produce from 'immer';
const baseTodos = [
{ id: 1, text: '리액트 공식문서 읽기', completed: false },
{ id: 2, text: '블로그 포스트 작성', completed: true },
];
// 할 일 추가
const todosAfterAdd = produce(baseTodos, draft => {
draft.push({ id: 3, text: 'Immer 공부하기', completed: false }); // push를 직접 사용
});
console.log(baseTodos === todosAfterAdd); // false (새로운 배열이 생성됨)
console.log(todosAfterAdd[2].text); // Immer 공부하기
// 할 일 토글 (수정)
const todosAfterToggle = produce(todosAfterAdd, draft => {
const todo = draft.find(t => t.id === 1);
if (todo) {
todo.completed = !todo.completed; // 객체 속성을 직접 수정
}
});
console.log(todosAfterAdd === todosAfterToggle); // false
console.log(todosAfterToggle[0].completed); // true
// 할 일 삭제
const todosAfterDelete = produce(todosAfterToggle, draft => {
const index = draft.findIndex(t => t.id === 2);
if (index !== -1) {
draft.splice(index, 1); // splice를 직접 사용
}
});
console.log(todosAfterToggle === todosAfterDelete); // false
console.log(todosAfterDelete.length); // 2위 예시처럼 produce 함수 내의 draft 배열에 push, splice와 같은 가변적인 배열 메서드를 직접 사용하거나, 배열 내 객체의 속성을 직접 수정해도 Immer는 자동으로 불변성을 유지하는 새로운 상태를 반환합니다. 이는 특히 중첩된 배열이나 배열 내의 객체를 수정할 때 코드의 복잡성을 크게 줄여줍니다.
Immer의 현재 사용 현황 및 고려사항
Immer의 현재 사용 현황 및 고려사항은 3-6편에서 언급된 내용과 기본적으로 동일하며, 배열 상태 관리에도 동일하게 적용됩니다. 복잡하거나 깊이 중첩된 배열 상태를 불변적으로 업데이트해야 할 때 코드의 가독성과 유지보수성을 크게 향상시켜 줍니다. 아주 작은 규모의 프로젝트에서는 번들 크기 증가와 학습 곡선을 고려할 수 있지만, 리액트 공식문서에서도 언급되고 있고 Redux Toolkit과 같은 주요 상태 관리 라이브러리들이 내부적으로 Immer를 채택할 정도로 그 유용성은 검증되어 있습니다. 따라서 배열 상태 관리의 복잡성을 줄이고 싶을 때 Immer는 매우 매력적인 선택지가 될 수 있습니다.
불변성 원칙과 메모리 효율성 - 오해와 진실
3-6편과 지금 포스트 글을 읽으면서 이런 의문점이 들 수도 있습니다. "불변성 원칙을 지키기 위해 객체나 배열 데이터가 포함된 UI를 갱신하면 바닐라 자바스크립트보다 더 메모리를 사용하게 되어 비효율적인거 아닐까" 하고 말입니다.
단편적으로 보면 새로운 객체나 배열을 생성하는 것이 메모리를 더 많이 소비하는 것처럼 보일 수 있습니다. 하지만 리액트에서는 불변성 원칙을 지키는 것이 전반적인 관점에서 더 효율적이며, 개발 생산성과 애플리케이션의 안정성을 크게 향상시킵니다. 그 이유는 다음과 같습니다.
- 리액트의 렌더링 효율성: 리액트는 상태 변경을 감지할 때 객체/배열의 참조(Reference)가 달라졌는지 여부만을 빠르게 비교합니다. 불변성을 지키면 새로운 참조가 생성되고, 리액트는 Virtual DOM과 Reconciliation 과정을 통해 실제로 변경된 부분만을 찾아 최소한의 DOM 업데이트를 수행합니다. 이는 바닐라 자바스크립트의 수동적인 DOM 조작에서 발생할 수 있는 불필요한 렌더링 비용보다 훨씬 효율적입니다.
- 예측 가능한 상태 관리: 불변성은 상태 변화를 예측 가능하게 하고, 사이드 이펙트를 방지하여 버그 발생률을 낮춥니다. 상태 변화 시 새로운 스냅샷이 생성되므로 디버깅이 용이하고 애플리케이션의 안정성이 높아집니다.
- JavaScript 엔진의 최적화: 현대 JavaScript 엔진의 가비지 컬렉션은 고도로 최적화되어 있어, 단기적으로 생성되는 작은 객체나 배열들은 효율적으로 처리되고 빠르게 회수됩니다. 새로운 객체 생성에 드는 메모리 비용은 리액트의 이점과 비교했을 때 미미합니다.
- 개발 편의성 및 최적화 도구와의 시너지: Immer와 같은 라이브러리는 불변성을 유지하면서도 직관적인 코드 작성을 가능하게 하여 개발자의 생산성을 향상시키고, 복잡한 상태 관리를 더욱 용이하게 합니다.
결론적으로, 불변성 원칙은 리액트의 핵심 철학이자 효율적인 작동 방식의 기반이며, 장기적인 관점에서 더 안정적이고 성능 좋은 애플리케이션을 만드는 데 기여합니다.
자바스크립트 vs 리액트 차이
이제 자바스크립트 방식과 리액트 방식의 배열 상태 관리 차이를 명확하게 비교해 보겠습니다.
| 구분 | 자바스크립트 | 리액트 |
|---|---|---|
| 상태 정의 | 일반 자바스크립트 배열 변수 | useState 훅을 사용한 상태 배열 정의 |
| 상태 변경 | 배열 메서드 직접 호출 (push, splice) 또는 재할당 (가변적) | 새 배열을 생성하여 변경된 값 반영 (map, filter, concat, 스프레드) (불변적) |
| UI 업데이트 | innerHTML, appendChild 등으로 DOM 직접 조작 (수동) | setState 호출 시 리액트가 자동 렌더링 (자동) |
| 변경 감지 | 개발자가 직접 변경 여부 판단 및 UI 업데이트 | 배열 참조의 동일성 여부로 변경 감지 (Immer는 불변성을 유지하며 새 참조 생성) |
| 효율성 | 전체 DOM을 다시 그리거나 수동으로 특정 요소만 조작 | Virtual DOM을 통해 변경된 부분만 효율적으로 업데이트 (key prop 활용) |
요약
이번 편에서는 리액트에서 배열 상태를 다룰 때 핵심적인 '불변성' 원칙과, 이 원칙이 자바스크립트의 전통적인 배열 처리 방식과 어떻게 다른지 다양한 예제를 통해 심층적으로 살펴보았습니다. 공부한 것을 정리해보면 다음과 같습니다.
- 불변성 원칙: 배열 상태 변경 시 기존 배열을 직접 수정하는 대신 항상 새로운 배열 인스턴스 생성.
- 스프레드 연산자 및 배열 메서드 활용:
...,map,filter,concat등을 사용하여 기존 요소를 복사하고 변경 사항을 반영하여 새로운 배열 반환. keyProp의 중요성: 리액트가 리스트 항목의 변화를 효율적으로 추적하고 렌더링하기 위해 각 리스트 항목에 고유한keyprop 제공 필수.- 함수형 업데이트: 이전 상태(
prevTodos)에 안전하게 접근하여 최신 상태를 기반으로 업데이트 수행. - Immer 라이브러리: 복잡한 배열 불변성 업데이트를 직관적이고 간결하게 만들어주는 유용한 도구.
다음 편(4-1편)에서는 상태 구조를 어떻게 설계하면 중복을 줄이고 예측 가능한 데이터를 유지할 수 있는지, 상태 구조 선택의 원칙을 살펴보겠습니다.