|
| 1 | +## 23. Immer 를 사용한 더 쉬운 불변성 관리 |
| 2 | + |
| 3 | +리액트에서 배열이나 객체를 업데이트 해야 할 때에는 직접 수정 하면 안되고 불변성을 지켜주면서 업데이트를 해주어야 합니다. |
| 4 | + |
| 5 | +예를 들자면 다음과 같이 하면 안되고 |
| 6 | + |
| 7 | +```javascript |
| 8 | +const object = { |
| 9 | + a: 1, |
| 10 | + b: 2 |
| 11 | +}; |
| 12 | + |
| 13 | +object.b = 3; |
| 14 | +``` |
| 15 | + |
| 16 | +다음과 같이 ... 연산자를 사용해서 새로운 객체를 만들어주어야 하죠. |
| 17 | + |
| 18 | +```javascript |
| 19 | +const object = { |
| 20 | + a: 1, |
| 21 | + b: 2 |
| 22 | +}; |
| 23 | + |
| 24 | +const nextObject = { |
| 25 | + ...object, |
| 26 | + b: 3 |
| 27 | +}; |
| 28 | +``` |
| 29 | + |
| 30 | +배열도 마찬가지로, `push`, `splice` 등의 함수를 사용하거나 n 번째 항목을 직접 수정하면 안되고 다음과 같이 `concat`, `filter`, `map` 등의 함수를 사용해야 합니다. |
| 31 | + |
| 32 | +```javascript |
| 33 | +const todos = [ |
| 34 | + { |
| 35 | + id: 1, |
| 36 | + text: '할 일 #1', |
| 37 | + done: true |
| 38 | + }, |
| 39 | + { |
| 40 | + id: 2 |
| 41 | + text: '할 일 #2', |
| 42 | + done: false |
| 43 | + } |
| 44 | +]; |
| 45 | + |
| 46 | +const inserted = todos.concat({ |
| 47 | + id: 3, |
| 48 | + text: '할 일 #3', |
| 49 | + done: false |
| 50 | +}); |
| 51 | + |
| 52 | +const filtered = todos.filter(todo => todo.id !== 2); |
| 53 | + |
| 54 | +const toggled = todos.map( |
| 55 | + todo => todo.id === 2 |
| 56 | + ? { |
| 57 | + ...todo, |
| 58 | + done: !todo.done, |
| 59 | + } |
| 60 | + : todo |
| 61 | +); |
| 62 | +``` |
| 63 | + |
| 64 | +대부분의 경우 ... 연산자 또는 배열 내장함수를 사용하는건 그렇게 어렵지는 않지만 데이터의 구조가 조금 까다로워지면 불변성을 지켜가면서 새로운 데이터를 생성해내는 코드가 조금 복잡해집니다. |
| 65 | + |
| 66 | +가령 다음과 같은 객체가 있다고 가정해봅시다. |
| 67 | + |
| 68 | +```javascript |
| 69 | +const state = { |
| 70 | + posts: [ |
| 71 | + { |
| 72 | + id: 1, |
| 73 | + title: '제목입니다.', |
| 74 | + body: '내용입니다.', |
| 75 | + comments: [ |
| 76 | + { |
| 77 | + id: 1, |
| 78 | + text: '와 정말 잘 읽었습니다.' |
| 79 | + } |
| 80 | + ] |
| 81 | + }, |
| 82 | + { |
| 83 | + id: 2, |
| 84 | + title: '제목입니다.', |
| 85 | + body: '내용입니다.', |
| 86 | + comments: [ |
| 87 | + { |
| 88 | + id: 2, |
| 89 | + text: '또 다른 댓글 어쩌고 저쩌고' |
| 90 | + } |
| 91 | + ] |
| 92 | + } |
| 93 | + ], |
| 94 | + selectedId: 1 |
| 95 | +}; |
| 96 | +``` |
| 97 | + |
| 98 | +여기서 `posts` 배열 안의 id 가 1 인 `post` 객체를 찾아서, `comments` 에 새로운 댓글 객체를 추가해줘야 한다고 가정해봅시다. 그렇다면, 다음과 같이 업데이트 해줘야 할 것입니다. |
| 99 | + |
| 100 | +```javascript |
| 101 | +const nextState = { |
| 102 | + ...state, |
| 103 | + posts: state.posts.map(post => |
| 104 | + post.id === 1 |
| 105 | + ? { |
| 106 | + ...post, |
| 107 | + comments: post.comments.concat({ |
| 108 | + id: 3, |
| 109 | + text: '새로운 댓글' |
| 110 | + }) |
| 111 | + } |
| 112 | + : post |
| 113 | + ) |
| 114 | +}; |
| 115 | +``` |
| 116 | + |
| 117 | +이게 어려운건 아닌데, 솔직히 코드의 구조가 좀 복잡해져서 코드를 봤을 때 한 눈에 들어오질 않습니다. |
| 118 | + |
| 119 | +이럴 때, immer 라는 라이브러리를 사용하면 다음과 같이 구현을 할 수 있답니다. |
| 120 | + |
| 121 | +```javascript |
| 122 | +const nextState = produce(state, draft => { |
| 123 | + const post = draft.posts.find(post => post.id === 1); |
| 124 | + post.comments.push({ |
| 125 | + id: 3, |
| 126 | + text: '와 정말 쉽다!' |
| 127 | + }); |
| 128 | +}); |
| 129 | +``` |
| 130 | + |
| 131 | +어떤가요? 코드가 훨씬 깔끔하고 잘 읽혀지죠? |
| 132 | + |
| 133 | +Immer 를 배우기전에 간단하게 요약을 해드리겠습니다. Immer 를 사용하면 우리가 상태를 업데이트 할 때, 불변성을 신경쓰지 않으면서 업데이트를 해주면 Immer 가 불변성 관리를 대신 해줍니다. |
| 134 | + |
| 135 | +### Immer 사용법 |
| 136 | + |
| 137 | +이번 섹션에서는 우리가 기존에 만들었던 사용자 관리 프로젝트에 Immer 를 적용해보면서 Immer 의 사용법을 알아보겠습니다. |
| 138 | + |
| 139 | +우선 프로젝트에서 다음 명령어를 실행하여 Immer 를 설치해주세요. |
| 140 | + |
| 141 | +```bash |
| 142 | +$ yarn add immer |
| 143 | +``` |
| 144 | + |
| 145 | +이 라이브러리를 사용 할 땐 다음과 같이 사용합니다. |
| 146 | + |
| 147 | +우선 코드의 상단에서 immer 를 불러와주어야 합니다. 보통 `produce` 라는 이름으로 불러옵니다. |
| 148 | + |
| 149 | +```javascript |
| 150 | +import produce from 'immer'; |
| 151 | +``` |
| 152 | + |
| 153 | +그리고 `produce` 함수를 사용 할 때에는 첫번째 파라미터에는 수정하고 싶은 상태, 두번째 파라미터에는 어떻게 업데이트하고 싶을지 정의하는 함수를 넣어줍니다. |
| 154 | + |
| 155 | +두번째 파라미터에 넣는 함수에서는 불변성에 대해서 신경쓰지 않고 그냥 업데이트 해주면 다 알아서 해줍니다. |
| 156 | + |
| 157 | +```javascript |
| 158 | +const state = { |
| 159 | + number: 1, |
| 160 | + dontChangeMe: 2 |
| 161 | +}; |
| 162 | + |
| 163 | +const nextState = produce(state, draft => { |
| 164 | + draft.number += 1; |
| 165 | +}); |
| 166 | + |
| 167 | +console.log(nextState); |
| 168 | +// { number: 2, dontChangeMe: 2 } |
| 169 | +``` |
| 170 | + |
| 171 | +다음 링크를 열어서 CodeSandbox 를 열으시면, Immer 를 쉽게 연습해보실 수 있습니다. |
| 172 | +[](https://codesandbox.io/s/pedantic-grass-ojocz?fontsize=14) |
| 173 | + |
| 174 | +연습 해보고 싶으시면 위 CodeSandbox 에서 해보시고, 우리 프로젝트에서 사용해보겠습니다. |
| 175 | + |
| 176 | +### 리듀서에서 Immer 사용하기 |
| 177 | + |
| 178 | +미리 말씀을 드리자면, Immer 를 사용해서 간단해지는 업데이트가 있고, 오히려 코드가 길어지는 업데이트 들이 있습니다. |
| 179 | + |
| 180 | +예를들어서 우리가 만들었던 프로젝트의 상태의 경우 `users` 배열이 객체의 깊은곳에 위치하지 않기 때문에 새 항목을 추가하거나 제거 할 때는 Immer 를 사용하는 것 보다 `concat` 과 `filter` 를 사용하는것이 더 코드가 짧고 편합니다. |
| 181 | + |
| 182 | +하지만, 사용법을 잘 배워보기 위하여 해당 업데이트도 이번 강좌에서 Immer 를 사용하여 처리를 해주겠습니다. |
| 183 | + |
| 184 | +#### App.js |
| 185 | + |
| 186 | +```javascript |
| 187 | +import React, { useReducer, useMemo } from 'react'; |
| 188 | +import UserList from './UserList'; |
| 189 | +import CreateUser from './CreateUser'; |
| 190 | +import produce from 'immer'; |
| 191 | + |
| 192 | +function countActiveUsers(users) { |
| 193 | + console.log('활성 사용자 수를 세는중...'); |
| 194 | + return users.filter(user => user.active).length; |
| 195 | +} |
| 196 | + |
| 197 | +const initialState = { |
| 198 | + users: [ |
| 199 | + { |
| 200 | + id: 1, |
| 201 | + username: 'velopert', |
| 202 | + |
| 203 | + active: true |
| 204 | + }, |
| 205 | + { |
| 206 | + id: 2, |
| 207 | + username: 'tester', |
| 208 | + |
| 209 | + active: false |
| 210 | + }, |
| 211 | + { |
| 212 | + id: 3, |
| 213 | + username: 'liz', |
| 214 | + |
| 215 | + active: false |
| 216 | + } |
| 217 | + ] |
| 218 | +}; |
| 219 | + |
| 220 | +function reducer(state, action) { |
| 221 | + switch (action.type) { |
| 222 | + case 'CREATE_USER': |
| 223 | + return produce(state, draft => { |
| 224 | + draft.users.push(action.user); |
| 225 | + }); |
| 226 | + case 'TOGGLE_USER': |
| 227 | + return produce(state, draft => { |
| 228 | + const user = draft.users.find(user => user.id === action.id); |
| 229 | + user.active = !user.active; |
| 230 | + }); |
| 231 | + case 'REMOVE_USER': |
| 232 | + return produce(state, draft => { |
| 233 | + const index = draft.users.findIndex(user => user.id === action.id); |
| 234 | + draft.users.splice(index, 1); |
| 235 | + }); |
| 236 | + default: |
| 237 | + return state; |
| 238 | + } |
| 239 | +} |
| 240 | + |
| 241 | +// UserDispatch 라는 이름으로 내보내줍니다. |
| 242 | +export const UserDispatch = React.createContext(null); |
| 243 | + |
| 244 | +function App() { |
| 245 | + const [state, dispatch] = useReducer(reducer, initialState); |
| 246 | + |
| 247 | + const { users } = state; |
| 248 | + |
| 249 | + const count = useMemo(() => countActiveUsers(users), [users]); |
| 250 | + return ( |
| 251 | + <UserDispatch.Provider value={dispatch}> |
| 252 | + <CreateUser /> |
| 253 | + <UserList users={users} /> |
| 254 | + <div>활성사용자 수 : {count}</div> |
| 255 | + </UserDispatch.Provider> |
| 256 | + ); |
| 257 | +} |
| 258 | + |
| 259 | +export default App; |
| 260 | +``` |
| 261 | + |
| 262 | +[](https://codesandbox.io/s/begin-react-1lzz8?fontsize=14) |
| 263 | + |
| 264 | +`TOGGLE_USER` 액션의 경우엔 확실히 Immer 를 사용하니 코드가 깔끔해졌지만 나머지의 경우에는 오히려 코드가 좀 복잡해졌지요? 상황에 따라 잘 선택하여 사용하시면 됩니다. Immer 를 사용한다고 해서 모든 업데이트 로직에서 사용을 하실 필요는 없습니다. |
| 265 | + |
| 266 | +### Immer 와 함수형 업데이트 |
| 267 | + |
| 268 | +우리가 이전에 `useState` 를 사용 할 때 함수형 업데이트란걸 할 수 있다고 배웠습니다. 예를 들자면, |
| 269 | + |
| 270 | +```javascript |
| 271 | +const [todo, setTodo] = useState({ |
| 272 | + text: 'Hello', |
| 273 | + done: false |
| 274 | +}); |
| 275 | + |
| 276 | +const onClick = useCallback(() => { |
| 277 | + setTodo(todo => ({ |
| 278 | + ...todo, |
| 279 | + done: !todo.done |
| 280 | + })); |
| 281 | +}, []); |
| 282 | +``` |
| 283 | + |
| 284 | +이렇게 `setTodo` 함수에 업데이트를 해주는 함수를 넣음으로써, 만약 `useCallback` 을 사용하는 경우 두번째 파라미터인 `deps` 배열에 `todo` 를 넣지 않아도 되게 되지요. |
| 285 | + |
| 286 | +이렇게 함수형 업데이트를 하는 경우에, Immer 를 사용하면 상황에 따라 더 편하게 코드를 작성 할 수 있습니다. |
| 287 | + |
| 288 | +만약에 `produce` 함수에 두개의 파라미터를 넣게 된다면, 첫번째 파라미터에 넣은 상태를 불변성을 유지하면서 새로운 상태를 만들어주지만, |
| 289 | +만약에 첫번째 파라미터를 생략하고 바로 업데이트 함수를 넣어주게 된다면, 반환 값은 새로운 상태가 아닌 상태를 업데이트 해주는 함수가 됩니다. 설명으로 이해하기가 조금 어려울 수 있는데 코드를 보면 조금 더 이해가 쉬워집니다. |
| 290 | + |
| 291 | +```javascript |
| 292 | +const todo = { |
| 293 | + text: 'Hello', |
| 294 | + done: false |
| 295 | +}; |
| 296 | + |
| 297 | +const updater = produce(draft => { |
| 298 | + draft.done = !draft.done; |
| 299 | +}); |
| 300 | + |
| 301 | +const nextTodo = updater(todo); |
| 302 | + |
| 303 | +console.log(nextTodo); |
| 304 | +// { text: 'Hello', done: true } |
| 305 | +``` |
| 306 | + |
| 307 | +결국 `produce` 가 반환하는것이 업데이트 함수가 되기 때문에 `useState` 의 업데이트 함수를 사용 할 떄 다음과 같이 구현 할 수 있게 되지요. |
| 308 | + |
| 309 | +```javascript |
| 310 | +const [todo, setTodo] = useState({ |
| 311 | + text: 'Hello', |
| 312 | + done: false |
| 313 | +}); |
| 314 | + |
| 315 | +const onClick = useCallback(() => { |
| 316 | + setTodo( |
| 317 | + produce(draft => { |
| 318 | + draft.done = !draft.done; |
| 319 | + }) |
| 320 | + ); |
| 321 | +}, []); |
| 322 | +``` |
| 323 | + |
| 324 | +이러한 속성을 잘 알아두시고, 나중에 필요할때 잘 사용하시면 되겠습니다. |
| 325 | + |
| 326 | +Immer 은 분명히 정말 편한 라이브러리인것은 사실입니다. 하지만, 확실히 알아둘 점은, 성능적으로는 Immer 를 사용하지 않은 코드가 조금 더 빠르다는 점 입니다. |
| 327 | + |
| 328 | + |
| 329 | + |
| 330 | +위 성능 분석표는 50,000개의 원소중에서 5,000 개의 원소를 업데이트 하는 코드를 비교 했을때의 결과입니다. 보시면, Immer 의 경우 31ms 걸리는 작업이 (map 을 사용하는) Native Reducer 에서는 6ms 걸린 것을 확인 할 수 있습니다. |
| 331 | + |
| 332 | +그런데, 이렇게 데이터가 많은데도 31ms 가 걸린다는 것은 사실 큰 문제가 아닙니다. 인간이 시각적으로 인지 할 수있는 최소 딜레이는 13ms 라고 합니다 ([참고](https://www.pubnub.com/blog/how-fast-is-realtime-human-perception-and-technology/)]) 그런 것을 생각하면 25ms 의 차이는, 사실 그렇게 큰 차이가 아니기 때문에 걱정할 필요 없습니다. 심지어, 데이터가 50,000개 가량 있는게 아니라면 별로 성능 차이가 별로 없을 것이기 때문에 더더욱 걱정하지 않아도 됩니다. |
| 333 | + |
| 334 | +단, Immer 는 JavaScript 엔진의 [Proxy](https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Proxy) 라는 기능을 사용하는데, 구형 브라우저 및 react-native 같은 환경에서는 지원되지 않으므로 (Proxy 처럼 작동하지만 Proxy는 아닌) ES5 fallback 을 사용하게 됩니다. ES5 fallback 을 사용하게 되는경우는 191ms 정도로, 꽤나 느려지게 됩니다. 물론, 여전히 데이터가 별로 없다면 크게 걱정 할 필요는 없습니다. |
| 335 | + |
| 336 | +Immer 라이브러리는 확실히 편하기 때문에, 데이터의 구조가 복잡해져서 불변성을 유지하면서 업데이트하려면 코드가 복잡해지는 상황이 온다면, 이를 사용하는 것을 권장드립니다. |
| 337 | + |
| 338 | +다만, 무조건 사용을 하진 마시고, 가능하면 데이터의 구조가 복잡해지게 되는 것을 방지하세요. 그리고 어쩔 수 없을 때 Immer 를 사용하는것이 좋습니다. Immer 를 사용한다고 해도, 필요한곳에만 쓰고, 간단히 처리 될 수 있는 곳에서는 그냥 일반 JavaScript 로 구현하시길 바랍니다. |
0 commit comments