이전 글을 통해, 이 글을 쓰게 된 계기인 ‘왜 상태는 불변성을 지켜야 하는가’에 대한 대답이 구체화되어 간다. 그러고보니, 이 글타래에서 불변성에 대해 정의를 내리지 않았던 듯하다. 불변성이란 함수형 프로그래밍의 주요 개념으로 위키에 따르면 다음과 같다.
불변객체(immutable object)는 생성 후 그 상태를 바꿀 수 없는 객체를 말한다. immutable object is an object whose state cannot be modified after it is created.
사실 지난 글에서 리액트 상태의 불변성을 지켜야하는 이유는 이미 설명하였다. 문서에서도 (함수형이라는) 방향을 유도하고, 권장하기에 자연스럽게 얻을 수 있는 결론이다. 하지만 이 글에서 조금 더 직접적으로 그 이유를 설명하고 있다.
컴포넌트를 순수하게 유지하기
일부 JavaScript 함수는 순수합니다. 순수 함수는 단순히 계산만 수행하고 그 이상의 동작은 하지 않습니다. 컴포넌트를 엄격하게 순수 함수로 작성함으로써 코드베이스가 커져도 놀라운 버그와 예측할 수 없는 동작을 피할 수 있습니다…
Purity: 컴포넌트는 수식(formula)처럼
컴퓨터 과학(특히 함수형 프로그래밍의 세계)에서 순수 함수는 다음과 같은 특징을 가진 함수입니다:
1) 자신의 일에만 신경을 씁니다.
2) 호출되기 전에 존재한 객체나 변수를 변경하지 않습니다.
3) 동일한 입력에 대해서는 항상 같은 출력을 반환합니다.
4) 동일한 입력이 주어진다면 순수 함수는 항상 동일한 결과를 반환해야 합니다.
앞선 글들과 이 부분을 함께 읽으면 상태가 불변성을 지켜야 하는 이유를 깨달을 수 있다.
변경이 잦은 ui 상태를 관리하고, 의도치 않은 상태 변경을 막기 위해 state는 불변성을 지킨다. state는 readonly이며, setter로만 state를 업데이트할 수 있다. 이를 통해 컴포넌트 함수에서 상태만을 분리할 수 있다. 컴포넌트는 수식처럼, 동일한 상태를 넣으면 동일한 결과를 렌더링하도록 관리할 수 있으며, 일반적인 함수형 프로그래밍의 이점을 기대할 수 있다.
업데이트된 리액트 문서에서 클래스형의 흔적을 덜어내고 한층 더 함수형을 지향하는 것으로 보인다. 또한 컴포넌트 설계의 바람직한 방향을 제공한다. 현업에서도 기획, 디자인 변경 등 외부 요인으로 인해 빠른 변화에 대응할 수 있는 코드가 필요하기에, 순수 함수를 추구해서 얻을 이점은 상당하다. 만일 컴포넌트가 외부효과에 의존하는 수많은 함수로 작성되었다면, 빠른 변화에 대응할 수 없을 정도로 작업 범위가 커질 수 있다.
이어지는 문서의 상태관리 5~7장에서 중요하거나 인상적인 부분을 간략히 살펴보고 이번 글타래를 마무리한다.
일련의 상태 업데이트 예약 Queueing a Series of State Updates
이전 글에서 setNumber(number + 1)을 3번 연달아 하여도 number는 1이되는 코드를 살펴보았다. 이번 장에선 그렇게 되는 원인과 setter가 연속적으로 일어나게 하는 방법을 다룬다.
...다음 렌더링을 예약하기 전에 값을 여러 번 조작하고 싶을 수도 있습니다. … React는 모든 이벤트 핸들러 내의 코드가 실행된 후 상태 업데이트를 처리할 때까지 기다립니다. 이것이 모든 setNumber() 호출이 끝난 후에만 리렌더링이 발생하는 이유입니다. ...이는 여러 상태 변수를 업데이트할 수 있게 해주며, 심지어 여러 컴포넌트에서도 가능하며, 렌더링을 너무 많이 발생시키지 않습니다. 그러나 이로 인해 UI가 업데이트되는 시점은 이벤트 핸들러와 그 내부의 코드가 완료된 이후에 발생한다는 것을 의미합니다. 이러한 동작은 일괄 처리(배칭)라고도 하며, React 앱을 훨씬 더 빠르게 실행되도록 만들어줍니다.
...
동일한 상태를 다음 렌더링 전에 여러 번 업데이트하기
동일한 상태 변수를 다음 렌더링 전에 여러 번 업데이트하고 싶을 때, setNumber(number + 1)과 같이 다음 상태 값을 전달하는 대신 setNumber(n => n + 1)과 같이 이전 값에 기초하여 다음 상태 값을 계산하는 함수를 전달할 수 있습니다. 이것은 React에게 "상태 값으로 무언가를 수행하라"고 말하는 방법이며, 그냥 상태를 대체하는 것이 아닙니다. (업데이터 함수)를 상태 setter에 전달하면 React는 이 함수를 처리하기 위해 큐에 추가합니다. 다음 렌더링에서, React는 큐를 순회하면서 최종 업데이트된 상태를 제공합니다. 이처럼 이벤트 핸들러 내에서 코드를 실행한 후에만 UI가 업데이트되며, 이러한 일괄 처리(batching) 동작은 React 앱을 훨씬 더 빠르게 만들어줍니다. 또한 일부 변수만 업데이트된 "미완성" 렌더링과 같은 혼란스러운 상황을 피할 수 있습니다.
React는 클릭과 같은 의도적인 이벤트 간에는 일괄 처리를 하지 않습니다. 각 클릭은 개별적으로 처리됩니다. React는 일반적으로 안전한 경우에만 일괄 처리를 수행합니다. 예를 들어, 첫 번째 버튼 클릭이 양식을 비활성화하는 경우, 두 번째 클릭은 양식을 다시 제출하지 않습니다. …. 각 상태 업데이트 후에 React는 렌더링을 트리거하고, 렌더링 도중 업데이터 함수가 실행되기 때문에 업데이터 함수는 순수해야하며 단순히 결과만 반환해야 합니다. 업데이터 함수 내부에서 상태를 설정하거나 다른 부작용을 발생시키지 않도록 주의해야 합니다. Strict Mode에서는 React가 각 업데이터 함수를 두 번 실행하지만(두 번째 결과는 버림), 실수를 찾아 도와줍니다.
중요한 내용으로 읽힌다. 발췌한 부분에서 중요한 내용을 추리면 다음과 같다:
- setter는 큐로 저장되어 순차적으로 일어난다.
- setter의 업데이트는 상태 변화 이전의 모든 setter를 모아 일괄적으로 처리한다. (배칭)
- 배칭은 리액트 앱을 빠르게 하기 위함이고, 컴포넌트의 모든 상태 업데이트를 일괄적으로 처리하지 않는다. (한 클릭 이벤트가 다른 클릭 이벤트의 상태를 함께 업데이트 하지 않는다.)
- setter의 업데이트는 상태를 대체하기와 함수를 전달하여 update 이전 상태에 접근할 수 있다.
- setter에 추가된 함수는 (외부효과가 없는) 순수함수여야 하며, 다른 setter를 부르지 않도록 주의해야한다.
다시 한번 함수형이 강조되는 것을 확인할 수 있다.
이어지는 부분은 mutable한 데이터인 객체, 배열의 상태를 setter를 사용해야하는 이유들이 설명된다. 조금 더 효율적인 setter 업데이트를 위해 spread 연산자를 사용하거나 use-immer 같은 라이브러리를 이용한 손쉬운 방법을 제시한다. 그중 중요하거나 인상적인 부분을 메모하면 다음과 같다.
객체의 상태 없데이트 하기 Updating Objects in State
상태(State)는 JavaScript의 모든 종류의 값, 객체를 포함하여 저장할 수 있습니다. 그러나 React의 상태 안에 있는 객체를 직접 변경해서는 안 됩니다. 대신 객체를 업데이트하고자 할 때는 새로운 객체를 생성하거나 기존 객체의 사본을 만들어서 그 사본을 상태로 설정해야 합니다.
뮤테이션이란 무엇인가요?
상태(State)에는 JavaScript의 모든 종류의 값, 즉 어떤 값이든 저장할 수 있습니다. ...
const [position, setPosition] = useState({ x: 0, y: 0 });
객체인 상태의 내용을 변경하는 것은 가능합니다. 이를 "뮤테이션"이라고 합니다:
position.x = 5;
하지만, React 상태 안의 객체는 변경 가능(mutable)하지만, 그들을 마치 숫자, 불리언, 문자열과 같이 변경 불가능(immutable)한 것처럼 다루어야 합니다. 직접적으로 변경하는 대신, 항상 새로운 객체를 생성하거나 기존 객체의 사본을 만들어서 그것을 사용하여 상태를 업데이트해야 합니다. ...상태(State)는 읽기 전용(Read-only)으로 다루어져야 합니다. 즉, 상태에 넣은 모든 JavaScript 객체를 읽기 전용으로 취급해야 합니다.
이어지는 내용에선 이벤트 핸들러 함수로 객체인 상태의 key에 새로운 값을 할당하여도 그 상태의 값이 ui에 반영되지 않는 코드 예시가 이어진다. 당연한 내용이다. 객체의 어떤 키-밸류가 바뀌더라도 setter를 사용하지 않는 이상 리렌더링이 발생하지 않기 때문이다.
그럼 어째서 객체를 immutable하게 다루라는 걸까? 해당 장의 deep dive에서 아주 상세히 설명해준다.
- 디버깅: 만약 console.log를 사용하고 상태를 불변(immutable)하게 유지한다면, 이전 로그들이 최신 상태 변경에 덮어씌워지지 않습니다. 이로 인해 렌더링 사이에 상태가 어떻게 변경되었는지 명확하게 확인할 수 있습니다.
- 최적화: React에서 일반적으로 사용되는 최적화 전략은 이전의 속성(props)이나 상태(state)가 다음과 동일한 경우 불필요한 작업을 건너뛰도록 하는 것입니다. 상태를 변경하지 않으면 React는 변경사항이 있는지 아주 빠르게 확인할 수 있습니다.
- 새로운 기능: React에서 개발 중인 새로운 기능들은 상태가 스냅샷(snapshot)으로 다뤄지는 것을 전제로 합니다. 과거 버전의 상태를 변경한다면, 이러한 새로운 기능들을 효과적으로 활용하는데 제약이 생길 수 있습니다.
- 요구 사항 변경: Undo/Redo 구현, 변경 이력 표시, 사용자가 이전 값으로 폼을 재설정하는 기능과 같은 일부 애플리케이션 기능들은 상태를 변경하지 않을 때 더 쉽게 구현됩니다. 이는 과거의 상태 사본을 메모리에 유지하고 필요할 때 재사용할 수 있기 때문입니다. 상태를 변경하는 방식으로 시작하면 이러한 기능들을 나중에 추가하는 것이 어려워질 수 있습니다.
- 간단한 구현: React는 뮤테이션을 기반으로 하지 않기 때문에 객체에 대해 특별한 작업을 할 필요가 없습니다. ...이것이 React가 어떤 객체든 상태로 사용할 수 있도록 해주는 이유입니다. 객체가 얼마나 크든간에 성능이나 정확성에 문제가 생기지 않습니다.
아마 누군가 리액트 상태의 불변성을 지켜야하냐고 묻는다면 이 5가지 항목을 답하면 좋겠다.
배열의 상태를 업데이트하기 Updating Arrays in State
상태(State)에 배열을 저장할 때에도 JavaScript에서 배열은 변경 가능(mutable)하지만 불변(immutable)하게 취급하는 것이 좋습니다. 객체와 마찬가지로, 상태에 저장된 배열을 업데이트하고자 할 때에는 새로운 배열을 생성하거나 기존 배열의 사본을 만들어서 상태를 새로운 배열을 사용하도록 설정해야 합니다.
배열을 불변(immutable)하게 업데이트하기
JavaScript에서 배열은 객체와 마찬가지로 다른 종류의 객체입니다. 따라서 React 상태 안의 배열도 읽기 전용으로 취급해야 합니다. 이는 배열 내부의 항목을 arr[0] = 'bird'와 같이 재할당하지 않아야 하며, push()와 pop()과 같이 배열을 변경하는 메서드도 사용하지 말아야 합니다. 대신 배열을 업데이트할 때마다 새로운 배열을 상태 설정 함수에 전달해야 합니다. ...아래는 일반적인 배열 작업에 대한 참조 테이블입니다. React 상태 내부에서 배열을 다룰 때, 왼쪽 열에 있는 메서드들을 피하고 오른쪽 열에 있는 메서드들을 사용하는 것이 좋습니다:
결론 : 오른쪽의 메서드들을 사용한다. 배열은 얕은 복사를 한 뒤 가공하여 사용한다.
이어지는 내용에선 현업에서도 사용되는 상태 배열의 가공을 다루는 부분이 주로 이루어져, 원리를 살피려는 이 글에선 생략한다.
당연한 질문에서 들기 시작한 의문과 호기심들은 어느정도 잘 해결된 것 같다. 새로 업데이트된 리액트 문서가 앞으로 나아가려는 방향성과 실용적인 지식을 잘 전달해주는 것 같다. 언제 한번 시간 잡고 전부 읽어보기로 하자.
긴 글 읽어주셔서 감사합니다!