순수 컴포넌트(Pure Components)
순수 컴포넌트의 개념은 순수 함수(Pure Function)에서 출발합니다.
순수 함수(Pure Function)이란, 같은 입력을 넣으면 언제나 같은 출력을 돌려주고,
함수 외부의 상태를 바꾸지 않는 함수를 말합니다.
예를 들어, x와 y를 더하는 함수를 떠올려봅니다. add(2,3)를 호출할 때마다 결과는 항상 5입니다.
입력이 동일하다면, 결과도 반드시 동일하게 유지되며, 함수 바깥의 값에는 아무런 영향을 주지 않습니다.
function add(x, y) {
return x + y
}
add(2, 3)반대로, 현재 시간을 반환하는 함수나, 전역 변수를 수정하는 함수는 같은 입력을 넣어도 실행 시점에 따라 결과가 다릅니다.
이것은 순수 함수라고 부르지 않습니다.
이와 같이, React에서 순수 컴포넌트의 의미는 props와 state라는 입력에만 의존해서
동일한 입력일 때 동일한 화면을 반환하는 것을 의미합니다.
이렇게 해야 React가 컴포넌트를 예측 가능하게 다룰 수 있고, Virtual DOM과 실제 DOM을 동기화하는데 버그가 줄어듭니다.
쇼핑몰 페이지를 예로 들었을 때, 상품 목록을 보여주는 컴포넌트를 만들었는데
그 컴포넌트 안에서 전역 변수나 외부 API 호출을 마음대로 수행한다면, 같은 props로 랜더링해도 화면이 다르게 보입니다.
const products = ["Shoes", "Bags"]
function ProductLists({ items }) {
items.push("Hat") // props를 직접 수정하지 않는다.
return <ul>{items.map(i => <li key={i}>{i}</li>)}</ul>
}
export default function App() {
return (
<ProductLists items={products} />
)
}만약 props로 연결한 배열을 다른 컴포넌트에서도 참조하고 있다면 다른 컴포넌트에서도 의도치 않게 같이 값이 바뀌어 버립니다.
즉, 한 컴포넌트에서 한 작업이 다른 컴포넌트에도 영향을 미치는 결과가 생깁니다.
이는 React 코드의 예측 가능성을 깨뜨리고, 특히 유지보수에서 큰 문제로 이어질 수 있습니다.
아래와 같은 올바른 예시를 보겠습니다.
function ProductLists({ products, discountRate }) {
return (
<ul>
{
products.map((p) => (
<li key={p.id}>
{p.name}: {Math.floor(p.price * (1 - discountRate)}원
</li>
))
}
</ul>
)
}여기서 같은 products와 discountRate가 들어오면, 항상 똑같은 화면을 반환해 React가 예측 가능하게 랜더링을 합니다.
따라서 이 컴포넌트는 순수 컴포넌트입니다.
즉, 배열이나 객체를 다룰 때는 원본을 건드리지 말고, 새로운 복사본을 만들어 업데이트하는 습관을 들여야합니다.
const updated = products.concat('Hat')
const updated = [...products, 'Hat']또는 React는 개발 모드에서 StrictMode를 활성화하면, 순수성을 검사해주는데 이 때 2번 랜더링을 시뮬레이션 합니다.
이때 같은 입력일 때 화면이 달라지면 비순수 컴포넌트라고 경고해줍니다.
React Strict Mode
개발 전용 검사 도구로써, 엄격한 모드로 잠재적 문제를 조기에 탐지해주는 도구입니다.
실제 배포 환경에서는 자동으로 비활성화되며, 성능에 영향이 없습니다.
앞서 살펴봤듯이 Strict Mode가 필요한 이유는 React가 선언적이고 예측 가능하다고 하더라도
우리가 항상 이 원칙을 지키는 것이 아니기 때문입니다.
그 사례로, 비순수한 컴포넌트나, 불안전한 State 업데이트, 폐지 예정인 API 사용이나 의도치 않은 사이드 이펙트 등이 있습니다.
이 모드의 가장 큰 특징은 컴포넌트를 2번 랜더링한다는 점이고, 같은 props와 state로 컴포넌트를 2번 실행합니다.
이 때 결과가 달라진다면 비순수 컴포넌트로 감지합니다.
추가로 React 18+ 이후의 Strict Mode는 더 강화되었는데 단순히 두 번 실행하는 것에 그치지 않고,
마운트 후, 언 마운트 후, 다시 마운트 하는 과정을 즉시 반복합니다. 즉, 초기화 및 정리 코드의 안정성을 검증합니다.
한마디로 이 때의 의도치 않은 동작이나 메모리 누수가 없는지 확인합니다.
이로써 단순한 랜더링 뿐만 아니라 컴포넌트의 생명 주기 전반에 걸쳐 안전하게 동작하는지 검증할 수 있습니다.
다른 형태의 State 업데이트
React에서 상태값이 number 혹은 string 형태가 아닐 때 어떻게 불변성을 유지할 수 있는지 살펴봅니다.
1. 객체 (Object)
Javascript 객체는 참조 타입(Reference Type)이라서 변수에 값이 아니라 메모리 주소가 저장됩니다.
따라서 원본 객체를 직접 수정하면 변수의 참조 주소 자체는 그대로 남아 React가 변경을 감지하지 못합니다.
React는 내부적으로 참조값(주소값)이 달라졌는지 여부로 변경 여부를 판단해
참조가 같으면 변경으로 인식하지 못합니다.
즉, 항상 새로운 객체를 만들어서 교체해야 불변성이 유지되고, 리 랜더링이 정확하게 동작합니다.
여기서 스프레드 연산자(...)를 사용하여, 기존 속성을 복사하고, 바꾸고 싶은 속성만 덮어쓸 수 있습니다.
function Profile() {
const [user, setUser] = useState({ name: '철수', age: 20 });
function handleBirthday() {
setUser({...user, age: user.age + 1})
}
return (
<div>
<p>{user.name} - {user.age}살</p>
<button onClick={handleBirthday}>생일맞이하기</button>
</div>
)
}아래와 같이 배열 안의 객체를 다룰 때도, 원본 객체를 직접 수정하지 않고
새로운 배열 및 새로운 객체로 교체해야 합니다.
const [todos, setTodos] = useState([
{ id: 1, text: "공부하기", done: false },
{ id: 2, text: "운동하기", done: false }
])
function toggleTodo(id) {
setTodos(
todos.map((todo) =>
todo.id === id ? {...todo, done: !todo.done }: todo
)
)
}2. 중첩 객체 구조 (Nested Object)
바꾸려는 경로까지 단계 별로 새 객체를 만드는데, 외곽 > 내곽 순서로 복사 후에 교체하여
참조 변경을 통해 React에 변경 신호를 전달합니다.
const [user, setUser] = useState({
name: '영희',
age: 25,
address: {
city: 'Seoul',
zipcode: '12345'
}
})
function moveCity() {
setUser({
...user,
address: {
...user.address,
city: 'Busan'
}
});
}3. 배열 (Array)
배열 역시 불변성 유지를 위해 concat이나 전개 연산자(스프레드 연산자)를 사용해 새로운 참조 배열을 만듭니다.
배열 업데이트 시 사용되는 CRUD 기능을 아래와 같이 불변성 유지를 지키며 구현해봅시다.
function TodoList() {
const [todos, setTodos] = useState([
{ id: 1, text: "공부하기" },
{ id: 2, text: "운동하기" },
{ id: 3, text: "청소하기" }
]);
function addTodos(input) {
setTodos([...todos, { id: todos.length, text: input }])
}
function removeTodo(id) {
setTodos(todos.filter(todo => todo.id !== id))
}
function updateTodo(id, input) {
setTodos(todos.map(todo => todo.id === id ? {...todo, text: input} : todo))
}
return ....
}