useState로 상태 구조 잘못 작성하고 계세요

jay·2022년 8월 29일
38

react

목록 보기
10/14

잠깐, 읽기 귀찮다면?

영상으로 보기!

접시돌리기

접시돌리기라는 단어를 들어보신 적이 있으신가요?
저는 공을 한번에 여러 개 돌리는 마술사나 서커스의 삐에로가 연상되는데요,
흔히 여러 개의 사물에 동일한 주의를 기울여 밸런스를 맞추는 상황을 이야기할 때 자주 사용하는 단어입니다.
능숙한 삐에로의 접시돌리기는 관객의 탄성을 자아내지만, 때로는 조그마한 실수로 전체 그림을 망가뜨리는 사고를 일으키기도 합니다.

프론트엔드 개발자에게 있어 컴포넌트는 상태관리는 접시돌리기와 같습니다.
하나의 상태 머신으로 만들어 버그없이 매끄러운 UI를 만들어내기도 하지만, 너무나 많은 접시(상태)는 한순간에 컴포넌트를 고장내는 주범으로 작용하기도 합니다.

상태관리는 모던 프레임워크 사용에 있어 가장 주의를 기울여야 하는 주제입니다.

오늘은 상태 관리의 주요 쟁점과 모범적인 상태 구조를 만드는 방법을 알아보겠습니다.

중복되는 상태를 만들면 안된다.

The most important principle is that state shouldn’t contain redundant or duplicated information.
가장 중요한 원리는 상태는 중복된 정보를 담고 있으면 안된다는 것입니다.
https://beta.reactjs.org/learn/managing-state

중복된 정보를 나타내는 상태가 존재한다면, 어떤 상태를 대충관리하더라도 여러분의 컴포넌트는 잘 동작하는 것 처럼 보일 것입니다. 이는 프로젝트가 고도화됨에 따라 여러분의 건물(프로젝트)에 군데군데 점점 커지는 구멍(컴포넌트)들을 잠재적 위험으로 남겨두는 것입니다.

여기서 대충 관리한다는 말은 업데이트를 시키지 않거나 참조하지 않는 것을 의미합니다.

import { useState } from 'react';

export default function Form() {
  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');
  const [fullName, setFullName] = useState('');

  function handleFirstNameChange(e) {
    setFirstName(e.target.value);
    setFullName(e.target.value + ' ' + lastName);
  }

  function handleLastNameChange(e) {
    setLastName(e.target.value);
    setFullName(firstName + ' ' + e.target.value);
  }

  return (
    <>
      <h2>Let’s check you in</h2>
      <label>
        First name:{' '}
        <input
          value={firstName}
          onChange={handleFirstNameChange}
        />
      </label>
      <label>
        Last name:{' '}
        <input
          value={lastName}
          onChange={handleLastNameChange}
        />
      </label>
      <p>
        Your ticket will be issued to: <b>{fullName}</b>
      </p>
    </>
  );
}

각 인풋 창에서 handleFirstNameChange, handleLastNameChange 핸들러를 사용하고 있는데요, 이 핸들러들은 firstName, LastName뿐만 아니라 fullName state를 업데이트하고 있습니다.

리엑트18에서는 핸들러 내부에서 디폴트로 배치 업데이트가 되기 때문에 리렌더링이 한번 더 일어난다는가 하는 성능적 악영향은 없습니다. 다만 불필요한 상태값이 존재하네요. fullName은 일반 변수로 놔두는게 좋겠습니다.

const fullName = firstName + ' ' + lastName;

중복된 상태는 하나의 상태 값을 가르키는 완전히 동일한 두 개의 useState를 만든다는 의미라기 보다는 컴포넌트의 props나 이미 존재하는 상태 값을 통해 컴포넌트 리렌더링 시 자동으로 만들 수 있는 값을 internal state, external state로 만드는 것을 의미합니다.

변수는 상태가 아닙니다. 상태로부터 파생된 정보를 담고 있는 것일 뿐입니다.
링크드인이나 여타 다른 SNS에서는 모든 변수가 상태다 라는 주장을 하는 분들이 더러 있습니다.
하지만 리엑트에서 상태구조를 설계한다고 함은 리렌더링을 유발하는 api를 통해 만들어지는 state들을 어떻게 구성할지에 대한 논의를 진행하는 것임으로, 또한 이 때 중복된 상태라 함은 state를 만드는 api로 만들어지는 state를 말함으로 모든 변수를 상태라고 보는 것은 옳지 않습니다.
모든 변수가 상태라면 리엑트 공식문서가 다시 쓰여져야 할 것입니다.

두개의 상태 값을 가지고 표현할 수 있는 UI를 불필요하게 세 개의 상태 값을 사용하여 표현한다면 안티패턴입니다.

불필요하게 props 미러링 하지 마세요.

흔히 발견되는 중복 상태 값은 다음과 같이 상위 컴포넌트의 props를 하위 컴포넌트에서 useState로 한번 감싸 사용하는 경우 입니다. 이 패턴을 props mirror라고 합니다.

 function Message({ initialColor }) {
  // The `color` state variable holds the *first* value of `initialColor`.
  // Further changes to the `initialColor` prop are ignored.
  const [color, setColor] = useState(initialColor);

문맥상 메세지 컴포넌트는 상위 컴포넌트에서 담당하는 색 변경이 수정되었을 때 동일하게 수정이 필요해 보입니다만 useState의 반환 값인 color를 가지고 UI를 표현하기 때문에 상위 컴포넌트의 initialColor가 변경되더라도 color가 업데이트 되지 않습니다.

이러한 정보 전달 오류는 컴파일 에러로 잡히지도 않기 때문에 버그 원인 검출에 시간이 많이 소요될 수 있어 더욱 더 엄격하게 관리되어야 합니다.

그럼 미러링이 다 필요가 없나요?

필요한 순간이 있습니다. useState로 전달 되는 인자가 첫 렌더링 이후 무시된다는 특성을 이용해야 하는 경우입니다.

대표적으로
부모 컴포넌트로 부터 전달 받는 특정 props 변경 상황에만 자식 컴포넌트의 상태 값이 업데이트 되어야 하는 경우입니다.

useEffect 잘못 쓰고 계신 겁니다. 포스팅에서 이 유스케이스를 소개했었습니다.

function List({ items }) {
  const [isReverse, setIsReverse] = useState(false);
  const [selection, setSelection] = useState(null);

  // Better: Adjust the state while rendering
  const [prevItems, setPrevItems] = useState(items);
  if (items !== prevItems) {
    setPrevItems(items);
    setSelection(null);
  }
  // ...
}

부모로 부터 전달받는 items의 변경점이 일어났을 경우에만 selection 상태 값을 초기화 하고 싶어 useEffect를 이용해 불필요한 렌더링을 없애고 useState를 이용해 items를 미러링하는 기법을 사용했습니다.

한 객체 프로퍼티를 여러 useState에서 참조하지 마세요.

아래 코드에서는 간식정보를 initialItems에 담고 useState에서 파생된 items 뿐만 아니라 seletedItem 이라는 상태 값을 따로 관리하고 있습니다.
간식 정보를 담고 있는 상태 값이 두 부분으로 나뉘어져 있는 것입니다.

import { useState } from 'react';

const initialItems = [
  { title: 'pretzels', id: 0 },
  { title: 'crispy seaweed', id: 1 },
  { title: 'granola bar', id: 2 },
];

export default function Menu() {
  const [items, setItems] = useState(initialItems);
  const [selectedItem, setSelectedItem] = useState(
    items[0]
  );

  function handleItemChange(id, e) {
    setItems(items.map(item => {
      if (item.id === id) {
        return {
          ...item,
          title: e.target.value,
        };
      } else {
        return item;
      }
    }));
  }

  return (
    <>
      <h2>What's your travel snack?</h2> 
      <ul>
        {items.map((item, index) => (
          <li key={item.id}>
            <input
              value={item.title}
              onChange={e => {
                handleItemChange(item.id, e)
              }}
            />
            {' '}
            <button onClick={() => {
              setSelectedItem(item);
            }}>Choose</button>
          </li>
        ))}
      </ul>
      <p>You picked {selectedItem.title}.</p>
    </>
  );
}

입력 창을 통해 간식 이름을 변경할 수 있습니다.
원하는 간식을 선택하고 좀 더 멋진 이름으로 변경해보세요.

저는 프리첼을 좋아해서 이 과자를 선택했습니다.
선택한 후 이름을 변경하니 인풋 창에는 UI가 업데이트 되었지만 제가 선택한 과자는 더 이상 이 세상에 존재하지 않는 과자가 되었습니다.

이런 불일치가 일어나는 이유는 하나의 메타정보에 대한 상태 값을 여러 개의 useState에서 참조함으로 setSelectedItem을 호출해줘야 하는 것을 잊어버렸기 때문입니다.

명시적으로 setSelectedItem을 호출함으로 인해 지금의 문제점을 해결할 수 있다고 하더라도 다음과 같이 프로퍼티를 참조하는 것이 아닌 id를 참조하는 것이 더 좋은 선택입니다.

const [selectedId, setSelectedId] = useState(0);

const selectedItem = items.find(item => item.id === selectedId);

Rule of three

재밌는 법칙이 있습니다. 무엇이든 세 개를 초과하면 뭔가 잘못되었을 수 있다는 말입니다.
(여행은 세 명이 가는 것 보다 네 명이 가는게 더 사이가 좋다는 말이 있는데.. 이 법칙이 항상 맞는 건 아닌가 보군요 ㅎㅎ..)

이 법칙은 코드 리팩토링 적용 여부를 판단하는 기준으로 중복된 코드가 발견될 시 새로운 접근 방법이 필요하다는 시사점을 제시해줍니다.

“Refactoring is a disciplined technique for restructuring an existing body of code, altering its internal structure without changing its external behavior”
― Martin Fowler

이미 존재하는 상태조각들을 하나의 그룹으로 만들어 봅시다.

상태 그룹핑

항상 한번에 여러 개의 상태 값을 변경할 경우 그룹화 하는 것이 좋을 수 있습니다.

흔히 생각할 수 있는 예제가 좌표 값을 상태 값으로 저장 할 때 입니다.

export function App() {
 const canvasRef = useRef(null);
 const [state, setState] = useState({
 x: 0,
 y: 0
});
 const isDrawingRef = useRef(false);

 useEffect(() => {
   const canvas = canvasRef?.current;
   const c = canvas?.getContext("2d");
   const draw = (x, y) => {
     if (isDrawingRef.current) {
       c.beginPath();
       c.arc(x, y, 10, 0, Math.PI * 2);
       c.closePath();
       c.fill();
     }
   };
   const mousedown = () => (isDrawingRef.current = true);
   const mouseup = () => (isDrawingRef.current = false);
   const mousemove = ({ x, y }) => {
     draw(x, y);
     setState({ x, y });
   };

   if (canvasRef?.current) {
     c.fillStyle = "hotpink";
     canvasRef.current.addEventListener("mousedown", mousedown);
     canvasRef.current.addEventListener("mouseup", mouseup);
     canvasRef.current.addEventListener("mousemove", mousemove);
   }

   return () => {
     canvasRef?.current?.removeEventListener("mousedown", mousedown);
     canvasRef?.current?.removeEventListener("mouseup", mouseup);
     canvasRef?.current?.removeEventListener("mousemove", mousemove);
   };
 }, []);
 return (
   <div>
     <p>마우스 드래그로 그림을 그려보세요.</p>
     <p>
       {" "}
       현재 좌표: x:{state.x}, y: {state.y}{" "}
     </p>
     <canvas width="300" height="300" ref={canvasRef} />
   </div>
 );
}

class component의 this.setState 와는 다르게 x, y 중 단일 필드만 업데이트 할 수 없습니다.
x,y 중 다른 상태 값이 있다면 move 이벤트 핸들러에서는 setState(prev => ({...prev, x,y})와 같이 spread operator를 사용해서 업데이트 해야 합니다.

중첩 구조의 상태 업데이트

리엑트에서 상태는 immutable하게 다뤄야 합니다.
state.x = 1와 같이 부분적으로 상태 값을 변경하면 안됩니다.

아래와 같이 중첩 구조로 된 객체는 어떻게 변경해야 하띾요?

const [person, setPerson] = useState({
  name: 'Niki de Saint Phalle',
  artwork: {
    title: 'Blue Nana',
    city: 'Hamburg',
    image: 'https://i.imgur.com/Sd1AgUOm.jpg',
  }
});

새로운 객체를 만들어서 기존 상태 값을 덮어씌워야 하는데요
예제 코드와 같은 obejct literal 을 복사할 때는 편의를 위해 spread operator를 많이 사용합니다.

artwork.city를 변경하려면 다음처럼 여러 번에 나누어 spread operator를 사용해야 합니다.

function handleTitleChange(e) {
    setPerson({
      ...person,
      artwork: {
        ...person.artwork,
        title: e.target.value
      }
    });
  }

프로그래밍할 떄 immutable 객체를 다루는데 실수를 덜기 위해, 그리고 async await과 같은 문법적 설탕을 위해 Immer와 같은 라이브러리를 사용할 수 있습니다.

Immer 사용의 대표적인 예시는 redux/toolkit입니다.
createReducer에서 내부적으로 Immer를 사용하여 값의 변경을 고민하지 않고 편하게 수행할 수 있습니다.

const todosReducer = createReducer([], (builder) => {
  builder.addCase('todos/todoAdded', (state, action) => {
    // "mutate" the array by calling push()
    state.push(action.payload) 
  })
})

상태를 왜 mutable하게 다루면 안되나요?

디버깅 하실 때 console.log 많이 사용하시죠?

리렌더링 될 때마다 컴포넌트 내부의 console.log를 통해 상태 값이 어떻게 변경되었는지 많이 살펴보시죠? 디버깅을 하는 것은 버그 발생지를 살펴보기 위함인데 디버깅에 정보가 잘못 표기되면 안되겠죠?

상태를 immutable 하게 사용하면 디버깅 시 혼란을 줄일 수 있습니다.

최적화

리엑트가 상태 변경 감지에 어떤 알고리즘을 사용하는지 알고 계시나요?

Object.is 알고리즘을 사용합니다.
Object.is(value1, value2);
이 알고리즘은 value1, value2가 다음의 값을 가지고 있을 때 같다고 판단합니다.

  • 두 값이 모두 (undefined, undefined) 이거나 (null, null) 일 때
  • 두 값이 모두 true 이거나 false 일 떄
  • 두 값이 모두 String 타입이고 글자, 글자수, 순서가 같을 때
  • 두 값이 모두 숫자이고 같은 값을 가지고 있거나 NaN 일 때
  • 두 값이 객체이고 같은 메모리 주소를 가지고 있을 때
  const [state, setState] = useState(undefined);

  const click = () => {
    setState((prev) => prev);
  };

  useEffect(() => {
    console.log(state); // 버튼 클릭해도 콘솔에 찍히지 않음
  });

마지막이 중요해요

  • 두 값이 객체이고 같은 메모리 주소를 가지고 있을 때

계층 구조를 가지고 있는 상태라고 할지라도 상태를 일일히 키, 벨류 값 비교하면서 변경점을 파악하는게 아니라 메모리 주소가 같은지 살펴봐요.
그래서 다음과 같이 상태 변경을 하더라도 리렌더링이 일어나지 않습니다.

const changeNameToJohn = () => {
    state.age = 25;
    setMySelf(state)
  }

리엑트는 앞서 소개했던 Object.is 알고리즘을 가지고 현재 상태의 state, 이전 상태의 state를 비교해요. 이때 객체를 평가하는 방식이 Referential Equality입니다.

imutable 하게 데이터를 다루라고 했으니까 spread operator 써서 setState로 업데이트 하면 대충 잘 돌아가니까 외워두고 이렇게 사용해야지

보다는 다음과 같은 이해를 선행으로 리엑트를 다루는게 바람직하겠습니다.

imutable 하게 다루라고 하는 이유는 리엑트가 수행하는 referential equality를 무시하면 버그가 발생할 수 있다는 말이구나

리엑트가 referential equality를 사용해 상태 비교를 함으로 상태 업데이트를 최적화해요.
객체가 n개의 계층으로 되어있을 때 단순 메모리 주소를 비교하는게 n개의 계층을 일일히 비교하는 것보다 훨씬 빠르겠죠?

We do not recommend doing deep equality checks or using JSON.stringify() in shouldComponentUpdate(). It is very inefficient and will harm performance.
우리는 deep equality check를 권장하지 않습니다. 이것은 비효율적이며 퍼포먼스에 악영향을 미칩니다.
https://reactjs.org/docs/react-component.html#shouldcomponentupdate

뒤로가기, 앞으로가기 시 버그 발생

뒤로가기, 앞으로가기 시 이전 시점의 상태 값을 UI에 표현하는 것은 메모리 상태 값의 무결성이 보장되었을 때 유저에게 적용할 수 있는 기능입니다. 상태 값을 mutable하게 관리하면, 과거시점의 데이터를 임의로 변경할 수 있어 혼동을 불러일으킬 수 있습니다.

미래에 출시될 기능을 위해서 사용하면 안됩니다.

we strongly advise you not to do that so that you can use new React features developed with this approach in mind.
우리는 미래에 추가될 리엑트 기능을 위해 상태를 mutable하게 다루지 않기를 강권합니다.
https://beta.reactjs.org/learn/updating-objects-in-state#write-concise-update-logic-with-immer

너무 깊은 계층 구조를 사용하지 마세요

앞서서 계층 구조의 상태를 업데이트 하기 위해서 immutable하게 상태를 다뤄야 한다는 점을 알아보았습니다.

다음처럼 중첩 구조가 계속 반복되는 객체 타입이 있을 때 (childPlaces)

export const initialTravelPlan = {
  id: 0,
  title: '(Root)',
  childPlaces: [{
    id: 1,
    title: 'Earth',
    childPlaces: [{
      id: 2,
      title: 'Africa',
      childPlaces: [{
        ...

깊숙한 내부의 속성 값을 없애거나 추가하기 위해서 부모의 전체 구조를 복사해야 합니다.
복사 과정이 번거롭게 느껴지는 이유는 이 상태가 트리 구조이기 때문입니다.

이러한 구조를 개선하기 위해 편탄화 작업을 할 수 있습니다. normalize라고도 합니다.

const initialTravelPlan = {
...
 10: {
    id: 10,
    title: 'Americas',
    childIds: [11, 12, 13, 14, 15, 16, 17, 18],   
  },
  11: {
    id: 11,
    title: 'Argentina',
    childIds: []
  },
    ...
     19: {
    id: 19,
    title: 'Asia',
    childIds: [20, 21, 22, 23, 24, 25, 26],   
  },
    ...
}

children에 트리구조를 그대로 놓는 것이 아니라 childIds를 배열 형식으로 넣고 다른 프로퍼티를 id로 참조할 수 있게 만들었습니다.

이 경우 상태를 업데이트 할 때 깊숙하게 ... spread operator를 써서 복사하지 않아도 됩니다.

import { useState } from 'react';
import { initialTravelPlan } from './places.js';

export default function TravelPlan() {
  const [plan, setPlan] = useState(initialTravelPlan);

  function handleComplete(parentId, childId) {
    const parent = plan[parentId];
    // Create a new version of the parent place
    // that doesn't include this child ID.
    const nextParent = {
      ...parent,
      childIds: parent.childIds
        .filter(id => id !== childId)
    };
    // Update the root state object...
    setPlan({
      ...plan,
      // ...so that it has the updated parent.
      [parentId]: nextParent
    });
  }

  const root = plan[0];
  const planetIds = root.childIds;
  return (
    <>
      <h2>Places to visit</h2>
      <ol>
        {planetIds.map(id => (
          <PlaceTree
            key={id}
            id={id}
            parentId={0}
            placesById={plan}
            onComplete={handleComplete}
          />
        ))}
      </ol>
    </>
  );
}

function PlaceTree({ id, parentId, placesById, onComplete }) {
  const place = placesById[id];
  const childIds = place.childIds;
  return (
    <>
      <li>
        {place.title}
        <button onClick={() => {
          onComplete(parentId, id);
        }}>
          Complete
        </button>
      </li>
      {childIds.length > 0 &&
        <ol>
          {childIds.map(childId => (
            <PlaceTree
              key={childId}
              id={childId}
              parentId={id}
              placesById={placesById}
              onComplete={onComplete}
            />
          ))}
        </ol>
      }
    </>
  );
}

immer을 쓰세요

immer가 있으면 immutable한 상태를 마치 mutable 한 것 처럼 변경할 수 있습니다.
좀 더 간단한 구조를 원한다면 immutable library 를 사용하는 것을 검토해보세요

immer를 쓰기 전

 const nextParent = {
      ...parent,
      childIds: parent.childIds
        .filter(id => id !== childId)
    };
// Update the root state object...
    setPlan({
      ...plan,
      // ...so that it has the updated parent.
      [parentId]: nextParent
    });

immer를 쓴 후

중첩구조에서 spread operator를 사용하지 않습니다.

import { useImmer } from 'use-immer';
...
const [plan, updatePlan] = useImmer(initialTravelPlan);

function handleComplete(parentId, childId) {
    updatePlan(draft => {
      
      // Remove from the parent place's child IDs.
      const parent = draft[parentId];
      parent.childIds = parent.childIds
        .filter(id => id !== childId);

      // Forget this place and all its subtree.
      deleteAllChildren(childId);
      function deleteAllChildren(id) {
        const place = draft[id];
        place.childIds.forEach(deleteAllChildren);
        delete draft[id];
      }
    });
  }
updatePerson(draft => {
  draft.artwork.city = 'Lagos';
});

모순된 상황을 야기하는 상태가 존재하면 안됩니다.

아래 코드에서는 isSending, isSent 와 같이 발송 상황을 나타내는 상태가 두개 이상 존재합니다. setIsSent, setIsSending을 함께 호출하는 것을 항상 염두에 두지 않고 놓친다면, 컴포넌트에서 모순된 상태 관계가 형성되게 됩니다.

이렇게 하나의 상황을 여러 개의 상태 값이 책임을 나누어 관리하게 하는 것은 컴포넌트의 복잡도를 올리고 이해하기 어렵게 만듭니다.

이런 상황에서는 상태 값을 boolean 타입으로 만들기 보다는 'typing', 'sending', 'sent'로 세분화해 관리하는 것이 좋습니다.

import { useState } from 'react';

export default function FeedbackForm() {
  const [text, setText] = useState('');
  const [isSending, setIsSending] = useState(false);
  const [isSent, setIsSent] = useState(false);

  async function handleSubmit(e) {
    e.preventDefault();
    setIsSending(true);
    await sendMessage(text);
    setIsSending(false);
    setIsSent(true);
  }

  if (isSent) {
    return <h1>Thanks for feedback!</h1>
  }

  return (
    <form onSubmit={handleSubmit}>
      <p>How was your stay at The Prancing Pony?</p>
      <textarea
        disabled={isSending}
        value={text}
        onChange={e => setText(e.target.value)}
      />
      <br />
      <button
        disabled={isSending}
        type="submit"
      >
        Send
      </button>
      {isSending && <p>Sending...</p>}
    </form>
  );
}

// Pretend to send a message.
function sendMessage(text) {
  return new Promise(resolve => {
    setTimeout(resolve, 2000);
  });
}

오늘은 useState를 사용하며 흔히 나타날 수 있는 문제점을 개선해보고
상태 구조를 설계할 때 알고 있으면 좋은 점에 대해 알아봤습니다.

상태 구조를 설계하는데 있어 왜 리엑트에서 mutable한 데이터 처리를 지양해야 하는지,
리렌더링을 유발하는데 어떤 알고리즘을 사용하는지 먼저 이해하면, 이전에는 느낌만으로 작성했던 코드들이 좀 더 명확하게 다가올 것이라고 생각합니다.

보다 자세한 내용을 알고 싶으시면 리엑트 공식문서와 베타 문서를 확인해보세요.

읽어주셔서 감사합니다!

예제 코드, 내용 참조
https://beta.reactjs.org
썸네일 사진
https://unsplash.com/photos/nA6Xhnq2Od8

profile
성장을 향한 작은 몸부림의 흔적들

0개의 댓글