Immer로 더 나은 감속기
게시 됨: 2022-03-10React 개발자는 상태가 직접 변경되어서는 안 된다는 원칙에 이미 익숙해야 합니다. 그것이 무엇을 의미하는지 궁금할 것입니다(우리 대부분은 시작할 때 혼란을 겪었습니다).
이 튜토리얼은 그것에 대해 정의할 것입니다: 당신은 불변 상태가 무엇인지 그리고 그것의 필요성을 이해하게 될 것입니다. 또한 Immer를 사용하여 변경할 수 없는 상태로 작업하는 방법과 이를 사용할 때의 이점도 배우게 됩니다. 이 Github 리포지토리의 이 기사에서 코드를 찾을 수 있습니다.
JavaScript의 불변성과 그것이 중요한 이유
Immer.js는 Michel Weststrate가 작성한 작은 JavaScript 라이브러리로, "보다 편리한 방식으로 불변 상태로 작업"할 수 있도록 하는 것이 사명입니다.
그러나 Immer에 대해 알아보기 전에 JavaScript의 불변성과 이것이 React 애플리케이션에서 중요한 이유에 대해 빠르게 복습해 보겠습니다.
최신 ECMAScript(JavaScript라고도 함) 표준은 9개의 내장 데이터 유형을 정의합니다. 이 9가지 유형 중 primitive
값/유형이라고 하는 6가지 유형이 있습니다. 이 여섯 가지 기본 요소는 undefined
, number
, string
, boolean
, bigint
및 symbol
입니다. JavaScript의 typeof
연산자를 사용하여 간단히 검사하면 이러한 데이터 유형의 유형이 표시됩니다.
console.log(typeof 5) // number console.log(typeof 'name') // string console.log(typeof (1 < 2)) // boolean console.log(typeof undefined) // undefined console.log(typeof Symbol('js')) // symbol console.log(typeof BigInt(900719925474)) // bigint
primitive
는 객체가 아니며 메소드가 없는 값입니다. 현재 논의에서 가장 중요한 것은 프리미티브 값이 일단 생성되면 변경할 수 없다는 사실입니다. 따라서 프리미티브는 immutable
이라고 합니다.
나머지 세 가지 유형은 null
, object
및 function
입니다. typeof
연산자를 사용하여 유형을 확인할 수도 있습니다.
console.log(typeof null) // object console.log(typeof [0, 1]) // object console.log(typeof {name: 'name'}) // object const f = () => ({}) console.log(typeof f) // function
이러한 유형은 mutable
합니다. 이는 값이 생성된 후 언제든지 값을 변경할 수 있음을 의미합니다.
왜 배열 [0, 1]
이 위에 있는지 궁금할 것입니다. JavaScriptland에서 배열은 단순히 특수한 유형의 객체입니다. null
과 그것이 undefined
와 어떻게 다른지 궁금하신 분들을 위해. undefined
는 단순히 변수에 값을 설정하지 않았음을 의미하지만 null
은 객체에 대한 특별한 경우입니다. 어떤 것이 객체여야 한다는 것을 알고 있지만 객체가 거기에 없으면 단순히 null
을 반환합니다.
간단한 예를 들어 설명하기 위해 브라우저 콘솔에서 아래 코드를 실행해 보십시오.
console.log('aeiou'.match(/[x]/gi)) // null console.log('xyzabc'.match(/[x]/gi)) // [ 'x' ]
String.prototype.match
는 object
유형인 배열을 반환해야 합니다. 그러한 객체를 찾을 수 없으면 null
을 반환합니다. undefined
를 반환하는 것도 여기에서 의미가 없습니다.
그것으로 충분합니다. 불변성에 대한 논의로 돌아가자.
MDN 문서에 따르면:
"객체를 제외한 모든 유형은 변경할 수 없는 값(즉, 변경할 수 없는 값)을 정의합니다."
이 명령문에는 특수 유형의 JavaScript 객체이기 때문에 함수가 포함됩니다. 여기에서 함수 정의를 참조하십시오.
변경 가능한 데이터 유형과 변경 불가능한 데이터 유형이 실제로 무엇을 의미하는지 간단히 살펴보겠습니다. 브라우저 콘솔에서 아래 코드를 실행해 보세요.
let a = 5; let b = a console.log(`a: ${a}; b: ${b}`) // a: 5; b: 5 b = 7 console.log(`a: ${a}; b: ${b}`) // a: 5; b: 7
우리의 결과는 b
가 에서 "파생"되더라도 b
값을 변경해도 a
값에 영향을 미치지 않는다는 것을 보여 a
. 이것은 JavaScript 엔진이 b = a
문을 실행할 때 새로운 별도의 메모리 위치를 만들고 거기에 5
를 넣고 해당 위치에서 b
를 가리키기 때문에 발생합니다.
개체는 어떻습니까? 아래 코드를 고려하십시오.
let c = { name: 'some name'} let d = c; console.log(`c: ${JSON.stringify(c)}; d: ${JSON.stringify(d)}`) // {"name":"some name"}; d: {"name":"some name"} d.name = 'new name' console.log(`c: ${JSON.stringify(c)}; d: ${JSON.stringify(d)}`) // {"name":"new name"}; d: {"name":"new name"}
변수 d
를 통해 name 속성을 변경하면 c
에서도 변경됨을 알 수 있습니다. 이것은 JavaScript 엔진이 c = { name: 'some name
'
}
명령문을 실행할 때 JavaScript 엔진이 메모리에 공간을 만들고 그 안에 객체를 넣고 c
를 가리키기 때문에 발생합니다. 그런 다음 d = c
문을 실행할 때 JavaScript 엔진은 d
가 동일한 위치를 가리키도록 합니다. 새로운 메모리 위치를 생성하지 않습니다. 따라서 d
의 항목에 대한 변경 사항은 암시적으로 c
의 항목에 대한 작업입니다. 많은 노력을 기울이지 않아도 이것이 왜 문제인지 알 수 있습니다.
React 애플리케이션을 개발 중이고 어딘가에서 변수 c
를 읽어서 사용자 이름을 some name
으로 표시하고 싶다고 상상해 보십시오. 그러나 다른 곳에서 개체 d
를 조작하여 코드에 버그를 도입했습니다. 그러면 사용자 이름이 new name
으로 표시됩니다. c
와 d
가 원시적이라면 우리는 그 문제가 없을 것입니다. 그러나 프리미티브는 일반적인 React 애플리케이션이 유지해야 하는 상태의 종류에 비해 너무 단순합니다.
이것은 애플리케이션에서 불변 상태를 유지하는 것이 중요한 주요 이유에 관한 것입니다. Immutable.js README: 불변성의 경우에서 이 짧은 섹션을 읽고 몇 가지 다른 고려 사항을 확인하는 것이 좋습니다.
React 애플리케이션에서 불변성이 필요한 이유를 이해했다면 이제 Immer가 produce
기능으로 문제를 해결하는 방법을 살펴보겠습니다.
Immer의 produce
기능
Immer의 핵심 API는 매우 작으며 작업할 주요 기능은 produce
기능입니다. produce
단순히 초기 상태와 상태가 어떻게 변경되어야 하는지를 정의하는 콜백을 취합니다. 콜백 자체는 의도한 모든 업데이트를 수행하는 상태의 초안(동일하지만 여전히 사본) 사본을 수신합니다. 마지막으로 모든 변경 사항이 적용된 새로운 불변 상태를 produce
합니다.
이러한 종류의 상태 업데이트에 대한 일반적인 패턴은 다음과 같습니다.
// produce signature produce(state, callback) => nextState
이것이 실제로 어떻게 작동하는지 봅시다.
import produce from 'immer' const initState = { pets: ['dog', 'cat'], packages: [ { name: 'react', installed: true }, { name: 'redux', installed: true }, ], } // to add a new package const newPackage = { name: 'immer', installed: false } const nextState = produce(initState, draft => { draft.packages.push(newPackage) })
위의 코드에서 우리는 단순히 시작 상태와 돌연변이가 일어나기를 원하는 방법을 지정하는 콜백을 전달합니다. 그것만큼 간단합니다. 우리는 주의 다른 부분을 만질 필요가 없습니다. initState
그대로 두고 시작 상태와 새 상태 사이에서 손대지 않은 상태 부분을 구조적으로 공유합니다. 우리 주의 그러한 부분 중 하나는 pets
배열입니다. produce
d nextState
는 변경 사항과 수정하지 않은 부분이 있는 불변 상태 트리입니다.

이 간단하지만 유용한 지식으로 무장하고, produce
이 React 리듀서를 단순화하는 데 어떻게 도움이 되는지 살펴보겠습니다.
Immer로 감속기 작성하기
아래에 정의된 상태 객체가 있다고 가정합니다.
const initState = { pets: ['dog', 'cat'], packages: [ { name: 'react', installed: true }, { name: 'redux', installed: true }, ], };
그리고 우리는 새 개체를 추가하고 후속 단계에서 installed
키를 true
로 설정하고 싶었습니다.
const newPackage = { name: 'immer', installed: false };
JavaScript 개체 및 배열 확산 구문을 사용하여 일반적인 방법으로 이 작업을 수행하면 상태 감소기가 아래와 같이 보일 수 있습니다.
const updateReducer = (state = initState, action) => { switch (action.type) { case 'ADD_PACKAGE': return { ...state, packages: [...state.packages, action.package], }; case 'UPDATE_INSTALLED': return { ...state, packages: state.packages.map(pack => pack.name === action.name ? { ...pack, installed: action.installed } : pack ), }; default: return state; } };
우리는 이것이 불필요하게 장황하고 이 비교적 단순한 상태 객체에 대해 실수하기 쉽다는 것을 알 수 있습니다. 우리는 또한 불필요한 상태의 모든 부분을 터치해야 합니다. Immer를 사용하여 이를 단순화하는 방법을 살펴보겠습니다.
const updateReducerWithProduce = (state = initState, action) => produce(state, draft => { switch (action.type) { case 'ADD_PACKAGE': draft.packages.push(action.package); break; case 'UPDATE_INSTALLED': { const package = draft.packages.filter(p => p.name === action.name)[0]; if (package) package.installed = action.installed; break; } default: break; } });
그리고 몇 줄의 코드로 리듀서를 크게 단순화했습니다. 또한 기본 케이스에 빠지면 Immer는 우리가 아무것도 할 필요 없이 초안 상태를 반환합니다. 상용구 코드가 적고 상태 확산이 제거된 방법에 주목하십시오. Immer를 사용하면 업데이트하려는 상태 부분에만 관심이 있습니다. 'UPDATE_INSTALLED' 작업에서와 같이 이러한 항목을 찾을 수 없으면 다른 것은 건드리지 않고 계속 진행합니다. '프로듀스' 기능도 커링에 적합합니다. 'produce'에 대한 첫 번째 인수로 콜백을 전달하는 것은 커링에 사용하기 위한 것입니다. 카레 '프로듀스'의 시그니처는 //curried produce signature produce(callback) => (state) => nextState
카레 제품으로 이전 상태를 업데이트하는 방법을 살펴보겠습니다. 카레 제품은 다음과 같습니다. const curriedProduce = produce((draft, action) => { switch (action.type) { case 'ADD_PACKAGE': draft.packages.push(action.package); break; case 'SET_INSTALLED': { const package = draft.packages.filter(p => p.name === action.name)[0]; if (package) package.installed = action.installed; break; } default: break; } });
커리 생성 함수는 함수를 첫 번째 인수로 받아들이고 다음 상태를 생성하기 위한 상태가 필요한 현재에만 커리 생성을 반환합니다. 함수의 첫 번째 인수는 초안 상태입니다(이 카레 제품을 호출할 때 전달될 상태에서 파생됨). 그런 다음 함수에 전달하려는 모든 수의 인수를 따릅니다.
이제 이 함수를 사용하기 위해 해야 할 일은 다음 상태를 생성하려는 상태와 이와 같은 작업 객체를 전달하는 것입니다.
// add a new package to the starting state const nextState = curriedProduce(initState, { type: 'ADD_PACKAGE', package: newPackage, }); // update an item in the recently produced state const nextState2 = curriedProduce(nextState, { type: 'SET_INSTALLED', name: 'immer', installed: true, });
React 애플리케이션에서 useReducer
후크를 사용할 때 위에서 했던 것처럼 명시적으로 상태를 전달할 필요가 없습니다. 상태를 처리하기 때문입니다.
요즘 React의 모든 것과 마찬가지로 Immer가 hook
를 얻을 수 있을까? 글쎄, 당신은 좋은 소식을 가지고 있습니다. Immer에는 상태 작업을 위한 두 개의 후크가 있습니다. useImmer
및 useImmerReducer
후크입니다. 그들이 어떻게 작동하는지 봅시다.
useImmer
및 useImmerReducer
후크 사용
useImmer
후크에 대한 가장 좋은 설명은 use-immer README 자체에서 나옵니다.
useImmer(initialState)
는useState
와 매우 유사합니다. 이 함수는 튜플을 반환하고, 튜플의 첫 번째 값은 현재 상태이고, 두 번째는 생산자가 종료되고 변경 사항이 적용될 때까지draft
을 자유롭게 변경할 수 있는 immer 생산자 기능을 허용하는 업데이트 함수입니다. 불변하고 다음 상태가 됩니다.
이러한 후크를 사용하려면 기본 Immer 라이브러리 외에 별도로 설치해야 합니다.
yarn add immer use-immer
코드 측면에서 useImmer
후크는 다음과 같습니다.
import React from "react"; import { useImmer } from "use-immer"; const initState = {} const [ data, updateData ] = useImmer(initState)
그리고 그만큼 간단합니다. React의 useState라고 할 수 있지만 약간의 스테로이드가 있습니다. 업데이트 기능을 사용하는 방법은 매우 간단합니다. 초안 상태를 받아 아래와 같이 원하는 만큼 수정할 수 있습니다.
// make changes to data updateData(draft => { // modify the draft as much as you want. })
Immer의 제작자는 작동 방식을 확인하기 위해 가지고 놀 수 있는 코드샌드박스 예제를 제공했습니다.
useImmerReducer
는 React의 useReducer
후크를 사용한 적이 있는 경우 유사하게 사용하기 쉽습니다. 비슷한 서명이 있습니다. 코드 용어로 어떻게 보이는지 봅시다.
import React from "react"; import { useImmerReducer } from "use-immer"; const initState = {} const reducer = (draft, action) => { switch(action.type) { default: break; } } const [data, dataDispatch] = useImmerReducer(reducer, initState);
리듀서는 우리가 원하는 만큼 수정할 수 있는 draft
상태를 수신하는 것을 볼 수 있습니다. 여기에 실험할 수 있는 코드샌드박스 예제도 있습니다.
Immer 후크를 사용하는 방법은 간단합니다. 그러나 프로젝트에서 Immer를 사용해야 하는 이유가 여전히 궁금한 경우를 대비하여 Immer를 사용하는 가장 중요한 몇 가지 이유를 요약해 보겠습니다.
Immer를 사용해야 하는 이유
오랜 기간 동안 상태 관리 로직을 작성해 왔다면 Immer가 제공하는 단순함을 빠르게 이해할 수 있을 것입니다. 그러나 그것이 Immer가 제공하는 유일한 이점은 아닙니다.
Immer를 사용하면 상대적으로 단순한 리듀서에서 본 것처럼 상용구 코드를 덜 작성하게 됩니다. 이것은 또한 깊은 업데이트를 비교적 쉽게 만듭니다.
Immutable.js와 같은 라이브러리를 사용하면 불변성의 이점을 누리려면 새로운 API를 배워야 합니다. 그러나 Immer를 사용하면 일반 JavaScript Objects
, Arrays
, Sets
및 Maps
에서 동일한 결과를 얻을 수 있습니다. 새로 배울 것은 없습니다.
Immer는 기본적으로 구조적 공유도 제공합니다. 이것은 단순히 상태 개체를 변경할 때 Immer가 새 상태와 이전 상태 간에 상태의 변경되지 않은 부분을 자동으로 공유한다는 것을 의미합니다.
Immer를 사용하면 produced
상태를 변경할 수 없음을 의미하는 자동 개체 동결도 발생합니다. 예를 들어, Immer를 사용하기 시작했을 때 Immer의 생성 함수에서 반환된 객체 배열에 sort
방법을 적용하려고 했습니다. 배열을 변경할 수 없다는 오류가 발생했습니다. sort
를 적용하기 전에 array slice 메소드를 적용해야 했습니다. 다시 한 번, 생성된 nextState
는 변경할 수 없는 상태 트리입니다.
Immer는 또한 강력한 형식이며 gzip으로 압축할 때 3KB로 매우 작습니다.
결론
상태 업데이트를 관리할 때 Immer를 사용하는 것은 쉬운 일이 아닙니다. 완전히 새로운 것을 배우려고 하지 않고도 JavaScript에 대해 배운 모든 것을 계속 사용할 수 있는 매우 가벼운 라이브러리입니다. 프로젝트에 설치하고 바로 사용하는 것이 좋습니다. 기존 프로젝트에서 사용을 추가하고 감속기를 점진적으로 업데이트할 수 있습니다.
또한 Michael Weststrate의 Immer 소개 블로그 게시물도 읽어보시기 바랍니다. 특히 흥미로운 부분은 "Immer는 어떻게 작동합니까?"입니다. Immer가 프록시와 같은 언어 기능과 쓰기 중 복사와 같은 개념을 활용하는 방법을 설명하는 섹션입니다.
또한 저자인 Steven de Salas가 불변성을 추구하는 장점에 대한 자신의 생각을 제시하는 이 블로그 게시물인 JavaScript의 불변성: 대조적 관점을 살펴보시기 바랍니다.
이 게시물에서 배운 내용으로 즉시 Immer를 사용할 수 있기를 바랍니다.
관련 리소스
-
use-immer
, GitHub - 임머, 깃허브
-
function
, MDN 웹 문서, Mozilla -
proxy
, MDN 웹 문서, Mozilla - 개체(컴퓨터 과학), Wikipedia
- "JS의 불변성", Orji Chidi Matthew, GitHub
- "ECMAScript 데이터 유형 및 값", Ecma International
- JavaScript, Immutable.js, GitHub용 불변 컬렉션
- "불변성의 경우", Immutable.js, GitHub