[주문앱 5탄] 시행착오 모음(key, state)

비얌·2023년 5월 5일
2
post-thumbnail

🧹 개요

강의에는 없던 카테고리 기능을 그릭요거트 주문앱에 추가하면서 많은 시행착오를 겪었다. 그래서 이번 포스팅에서는 겪은 시행착오와 해결한 방법 등을 기록해보려고 한다.



✨ 결과

현재까지 만든 카테고리이다. a 카테고리에서 수량 변경 후 b 카테고리로 이동했다가 다시 a 카테고리로 돌아왔을 때, 수량이 초기화되지 않고 기억될 수 있도록 했다.



🔨 문제 해결

💥 왜 콘솔이 한박자 느리게 찍히지?

분명히 화면에는 잘 반영이 되는데 콘솔을 찍으면 한박자씩 느린 것을 발견했다. onSaveAmount 함수에서는 setTotalAmount를 해서 totalItemData를 바꿨는데, 바로(다음 렌더링 전에) totalItemData를 사용하려고 해서 그렇다. 즉 아직 업데이트되지 않은 값(이전 값)을 콘솔로 출력하니 이전 결과가 나와 한박자씩 느린 결과가 나오는 것이다.

const onSaveAmount = enteredAmount => {
    setTotalAmount(totalAmount + enteredAmount);
    // console.log(totalItemData) // 한박자씩 느림
  }

이는 useEffect를 써서 콘솔을 찍는 것으로 해결할 수 있었다.

const onSaveAmount = enteredAmount => {
    setTotalAmount(totalAmount + enteredAmount);
  }

  useEffect(() => console.log(totalItemData)) // 제대로 찍힘

💥 concat()으로 어떻게 객체를 합치나?

concat()은 인자로 주어진 배열이나 값들을 기존 배열에 합쳐서 새 배열을 반환한다.

아래에서 totalItemData는 배열이고, selectedItemData는 객체여서 어떻게 이것이 잘 합쳐지는 건지 헷갈렸다.

const newTotalItemData = totalItemData.concat(selectedItemData);

실험해보니 concat()으로 배열과 객체를 합치면 배열 안에 객체가 들어가게 되고, 또 배열과 배열을 합치면 배열 안의 것들끼리 합쳐져서 새로운 배열을 만드는 것 같다.

const person = [{id: 1, name: 'aaa'}, {id: 2, name: 'bbb'}, {id: 3, name: 'ccc'}];
// mina가 [{id: 4, name: 'mina'}]여도 동일하게 동작함
const mina = {id: 4, name: 'mina'} 
const all = person.concat(mina)

console.log(all);
  // [{
  //   id: 1,
  //   name: "aaa"
  // },{
  //   id: 2,
  //   name: "bbb"
  // }, {
  //   id: 3,
  //   name: "ccc"
  // }, {
  //   id: 4,
  //   name: "mina"
  // }]

💥 다음 렌더링 전에 연속으로 상위 컴포넌트에 데이터를 보내도 되나?

된다!
아래처럼 써도 상관 없다.

// Toppings.jsx
const onSaveItem = selectedItemData => {
    props.onSaveItem(selectedItemData);
    props.onSaveAmount(selectedItemData.amount)
  }

💥 다음 렌더링 전에 같은 setState를 연속으로 불러도 되나?

안된다!
공식문서: React batches state updates에 의하면 렌더링 전에 동일한 상태를 여러 번 업데이트하면 의도하지 않은 결과가 나올 수 있다.


💥 다음 렌더링 전에 다른 setState를 연속으로 불러도 되나?

된다!
다음 렌더링 전에 같은 setState를 연속으로 브르면 안된다는 것은 알고 있었는데, 그러면 다음 렌더링 전에 다른 상태를 여러 번 업데이트하는 건 괜찮은가? 하는 생각이 들었다.

정답은 그래도 괜찮다!이다. 공식문서를 보면 이렇게 다음 렌더링 전에 다른 setState를 연속으로 쓰는 것을 발견할 수 있다.


💥 다음 렌더링 전에 setState를 하고 바로 state를 써도 되나?

안된다!

예를 들면 아래와 같이 handler라는 함수 안에서 setAmount로 amount를 바꾸고, 바로 onSave 함수에 이를 인자로 넘기는 등의 일을 하면 안된다. onSave함수에 들어가는 amount는 setAmount로 변경되기 전의 값이기 때문이다.

const [amount, setAmount] = useState(0);
const onSave = () => {};
const handler = () => {
  setAmount(100)
  onSave(amount)
};

그래서 아래와 같이 코드를 작성했을 때, props.onSaveCategories에 전달되는 itemState가 setItemState로 변경되기 이전의 값이어서 결과적으로 화면에 한박자씩 느리게 반영되었다.

const onChangeHandler = (e) => {
    setItemState(prev => {
      return {
      ...prev,
      amount: e.target.value,
      }
    })
    props.onSaveCategories(itemState);
  }

이는 아래처럼 코드를 수정하여 해결했다. 새로운 상태를 newState라는 변수로 만들고, 그것을 props.onSaveCategories 함수에 먼저 넘겼다. 그러면 최신의 값이 넘어가게 된다. 그리고 newState를 반환하여 itemState도 newState로 업데이트했다.

const onChangeHandler = (e) => {
    setItemState(prev => {
      const newState = {
      ...prev,
      amount: e.target.value,
      };
      props.onSaveCategories(newState);
      return newState
    })
  }

💥 카테고리를 변경해도 하나의 카테고리만 보이는 문제

분명히 카테고리를 변경했는데, 이렇게 수량이 계속 유지되는 문제가 발생했다. 살펴본 결과, '그릭요거트' 카테고리의 입력폼을 모든 카테고리에서 보여주고 있다는 것을 발견했다.

이 문제의 원인은 바로 중복된 key 때문이었다. 서버에서 가져오는 데이터는 아래와 같았는데, 카테고리1과 카테고리2, 3, 4의 DUMMY_TOPPINGS의 id는 t1, t2, t3, ...로 같았다. 나는 각 카테고리의 id가 모두 달랐기 때문에 DUMMY_TOPPINGS의 id는 같아도 되는 줄 알았다. 그런데 아니었다! 이 key를 모두 다르게 해야 한다.

const data = [
  {
    name: '그릭요거트',
    id: 'ca1',
    DUMMY_TOPPINGS: [
      {
        id: 't1',
        생략
      },
      {
        id: 't2',
        생략
      },
      {
        id: 't3',
        생략
      },
      {
        id: 't4',
        생략
      },
      생략
    ]
  },
  {
    name: '그래놀라',
    id: 'ca2',
    DUMMY_TOPPINGS: [
      {
        id: 't1',
        생략
      },
      {
        id: 't2',
        생략
      },
      {
        id: 't3',
        생략
      },
      {
        id: 't4',
        생략
      },
      생략
    ]
    생략
  }
]

각 DUMMY_TOPPINGS의 id를 다른 카테고리의 DUMMY_TOPPINGS와 중복되지 않게 아래처럼 바꾸면 문제가 해결된다.

const data = [
  {
    name: '그릭요거트',
    id: 'ca1',
    DUMMY_TOPPINGS: [
      {
        id: 'a1',
        생략
      },
      {
        id: 'a2',
        생략
      },
      {
        id: 'a3',
        생략
      },
      {
        id: 'a4',
        생략
      },
      생략
    ]
  },
  {
    name: '그래놀라',
    id: 'ca2',
    DUMMY_TOPPINGS: [
      {
        id: 'b1',
        생략
      },
      {
        id: 'b2',
        생략
      },
      {
        id: 'b3',
        생략
      },
      {
        id: 'b4',
        생략
      },
      생략
    ]
    생략
  },
]

그렇다면 나는 왜 id가 중복되어도 된다고 생각했고, 또 id가 중복되면 안되는 이유는 무엇이었을까? 차례로 알아보자.


❓ id가 중복되어도 된다고 생각한 이유

카테고리를 일단 선택한 다음 거기서 DUMMY_TOPPINGS를 빼오기 때문에 DUMMY_TOPPINGS 안에 있는 토핑들의 id는 어떤 것이어도 상관 없다고 생각했다.

ToppingItemForm의 상위 컴포넌트인 AvailableToppings를 보면, 카테고리의 id로 필터링되어 선택된 카테고리 filteredCategory의 DUMMY_TOPPINGS를 가져와서 ToppingItemForm에 펼쳐서 넣는다. 그렇다면 이미 카테고리가 지정된 채로 재료의 id를 key로 사용하는 거니까 문제가 없지 않나? 하는 생각이었다.

// 📃 AvailableToppings.jsx
const filteredCategory = props.backendData.filter(category => {
    return category.id === props.selectedCategory;
  })

const toppingsList = filteredCategory[0].DUMMY_TOPPINGS.map(topping => 
    <React.Fragment key={topping.id}>
      <ToppingItem
        ...생략...
      />
      {topping.amount}
      <ToppingItemForm 
        ...생략...
      />
    </React.Fragment>
  )

❗ id가 중복되면 안되는 이유

그렇다면 왜 id가 중복되었을 때 의도대로 작동하지 않은 걸까?

그 이유는 key의 작동방식에 있다. 리액트 공식 문서: Resetting state with a key를 보면, 아래와 같이 key가 변경되면 키가 전달된 컴포넌트를 처음부터 다시 만들게 되어 초기화된다고 설명하고 있다. 그래서 form을 다시 그리게 하려면 key가 달라야 하는데, 나의 코드에서는 DUMMY_TOPPINGS의 id들이 같았으므로 이전과 카테고리가 달라졌음에도 리액트는 그걸 인지하지 못했던 것 같다.

Resetting state with a key
You’ll often encounter the key attribute when rendering lists. However, it also serves another purpose. You can reset a component’s state by passing a different key to a component. When the key changes, React re-creates the Form component (and all of its children) from scratch, so its state gets reset.


리액트 공식문서: Resetting a form with a key에서는 이를 더 자세하게 설명하고 있다. Taylor에게 보낼 메시지를 작성하고 Alice나 Bob에게 메시지를 쓰려고 클릭하면 다른 채팅창이 뜨는 것을 기대하지만, Chat 컴포넌트가 바뀌지 않아 여전히 Taylor에게 쓴 메시지가 보인다.

하지만 이렇게 <Chat key={to.id} contact={to} />처럼 Chat 컴포넌트에 key를 부여하면 선택되는 사람이 바뀔 때마다 Chat 컴포넌트가 초기화되는 것을 볼 수 있다.


하지만 이렇게 key를 부여했을 때 컴포넌트가 다시 그려지므로 폼이 항상 초기화되는데, 이전의 상태를 유지하려면 어떻게 해야하는지도 설명하고 있다.

  1. css로 보이게, 혹은 보이지 않게 작업하기. 하지만 숨겨진 DOM이 많을수록 느려진다는 단점이 있다.
  2. state를 상위 컴포넌트로 끌어올려서 상태를 부모 컴포넌트에서 관리하기. 하위 컴포넌트가 사라져도 값을 유지할 수 있다.
  3. localStorage 사용하기

나는 2번을 사용해서 해결하기로 했다.


💥 각 카테고리의 수량이 저장되지 않는 문제

위에 있던 문제를 해결하기 위해 상위 컴포넌트에 state를 만들었다. 그리고 ToppingItemForm 컴포넌트에서 수량이 바뀌면 그 부분만 상태가 바뀌게 했다. 아래처럼 작성하면 정상적으로 동작하지만, 처음에는 그러지 않았어서 어떤 상황이었는지, 또 왜 그랬는지를 기록해보려고 한다.

// 📃 Toppings.jsx
const [backendData, setBackendData] = useState([]);

const filteredCategory = props.backendData.filter(category => {
    return category.id === props.selectedCategory
})

const onSaveCategories = itemState => {
  // 이곳의 backendData.map() 주목
  const newData = backendData.map(category => {
    const newToppings = category.DUMMY_TOPPINGS.map(topping => {
      if (topping.id === itemState.id) {
        return {...topping, amount: itemState.amount};
      }
      return topping;
    });
    return {...category, DUMMY_TOPPINGS: newToppings}
  }
                                 )
  setBackendData(newData)
}

처음에는 서버에서 받아온 데이터인 backendData가 아니라 선택한 카테고리인 filteredCategory를 map으로 펼쳐서 전체 데이터를 갱신했다. 이렇게 하면 어떤 오류가 발행하는데, 만약 어떤 카테고리에서 수량을 변경하고 다른 카테고리를 클릭했다가 돌아오면 수량이 초기화되는 것이었다.

// 📃 Toppings.jsx
const [backendData, setBackendData] = useState([]);

const filteredCategory = props.backendData.filter(category => {
    return category.id === props.selectedCategory
})

const onSaveCategories = itemState => {
  // backendData.map() => filteredCategory.map()
  const newData = backendData.map(category => {
    const newToppings = category.DUMMY_TOPPINGS.map(topping => {
      if (topping.id === itemState.id) {
        return {...topping, amount: itemState.amount};
      }
      return topping;
    });
    return {...category, DUMMY_TOPPINGS: newToppings}
  }
                                 )
  setBackendData(newData)
}

그 이유는 filteredCategory.map()에 있었다. filteredCategory는 선택한 카테고리이다. 이것은 어느 한 가지의 변수이므로, 선택한 카테고리가 바뀔 때마다 어딘가에 이 값이 저장되지 않고 바뀐다. 따라서 선택한 카테고리가 변경될 때마다 이전 값을 다음 값이 덮어써서 카테고리가 변경되면 수량이 초기화됐던 것이다.

더군다나, filteredCategory.map()을 하면 결과적으로 setBackendData(newData)를 했을 때 backendData에 전체 카테고리에 대한 정보가 아니라 한 카테고리에 대한 정보만 담기는 오류도 있었다.


💥 어떻게 selectedItemData 객체에 선택된 재료의 정보가 들어오는 거지?

ToppingItem 컴포넌트를 보다가, 궁금한 점을 발견했다. 여기서 selectedItemData라는 객체에 어떻게 선택한 재료에 대한 정보가 들어오는 걸까?

// 📃 ToppingItem.jsx
const ToppingItem = props => {
  const onSaveItem = (amount) => {
    const selectedItemData = {
      id: props.id,
      name: props.name,
      price: props.price,
      amount: amount
    }
    props.onSaveItem(selectedItemData);
  }
  return <ToppingItemForm onSaveItem={onSaveItem} id={props.id} /> // 생략
export default ToppingItem;

selectedItemData에 prop을 넘겨주는 AvailableToppings 컴포넌트에 가보기로 했다.
아래를 보면 ToppingItem 컴포넌트에 map()으로 각 재료에 대한 id, name, description, price을 AvailableToppings에 내려준다.

// 📃 AvailableToppings.jsx
const toppingsList = filteredCategory[0].DUMMY_TOPPINGS.map(topping => 
    <ToppingItem
      id={topping.id}
      key={topping.id}
      name={topping.name}
      description={topping.description}
      price={topping.price}
      onSaveItem={onSaveItem}
    />
  )

DUMMY_TOPPINGS는 선택한 카테고리에 있는 각 재료에 대한 상세 정보를 담고 있다. 아래의 DUMMY_TOPPINGS는 '그릭요거트' 카테고리에 있는 정보이다.

// DUMMY_TOPPINGS 예
DUMMY_TOPPINGS: [
      {
        id: 't1',
        name: '무가당(100g)',
        description: '첨가물이 전혀 없는 그릭요거트',
        price: 3500,
      },
      {
        id: 't2',
        name: '망고맛(100g)',
        description: '망고의 달콤함과 풍미를 담은 망고 그릭요거트',
        price: 4500,
      },
      {
        id: 't3',
        name: '딸기맛(100g)',
        description: '설향딸기의 달콤함을 담은 딸기 그릭요거트',
        price: 4500,
      },
      {
        id: 't4',
        name: '황치즈맛(100g)',
        description: '체다치즈의 깊은 맛을 담은 황치즈 그릭요거트',
        price: 5000,
      },
      {
        id: 't5',
        name: '무가당(100g)',
        description: '첨가물이 전혀 없는 그릭요거트',
        price: 3500,
      },
      {
        id: 't6',
        name: '망고맛(100g)',
        description: '망고의 달콤함과 풍미를 담은 망고 그릭요거트',
        price: 4500,
      },
      {
        id: 't7',
        name: '딸기맛(100g)',
        description: '설향딸기의 달콤함을 담은 딸기 그릭요거트',
        price: 4500,
      },
      {
        id: 't8',
        name: '황치즈맛(100g)',
        description: '체다치즈의 깊은 맛을 담은 황치즈 그릭요거트',
        price: 5000,
      },
    ]

분명히 이렇게 map으로 DUMMY_TOPPINGS에 있는 모든 재료들(8개)을 펼쳐서 보여줘서 아래의 selectedItemData도 8개가 나와야 한다고 생각했다. 하지만 콘솔을 찍어봤을 때, 딱 선택한 재료에 대한 하나의 정보만 나왔다.


모든 재료에 대한 정보가 아니라 선택한 재료의 정보만 나올 수 있는 이유는 onSaveItem 함수에서 찾을 수 있었다.

// 📃 ToppingItem.jsx
const ToppingItem = props => {
  const onSaveItem = (amount) => {
    const selectedItemData = {
      id: props.id,
      name: props.name,
      price: props.price,
      amount: amount
    }
    props.onSaveItem(selectedItemData);
  }
  return <ToppingItemForm onSaveItem={onSaveItem} id={props.id} /> // 생략
export default ToppingItem;
  1. onSavaItem 함수는 props.onSaveItem(enteredAmount)에 의해 호출된다.
  2. enteredAmount는 submitHandler 함수 안에서 실행된다.
  3. submitHandler는 + 담기 버튼이 클릭됐을 때 실행된다

즉, 아래에 있는 + 담기 버튼이 클릭되었을 때 해당하는 재료의 수량이 상위 컴포넌트 ToppingItem로 넘어가고, 그제서야 selectedItemData 객체가 만들어지는 것이다.

// 📃 ToppingItemForm.jsx
const ToppingItemForm = props => {
  const amountInputRef = useRef();

  const submitHandler = (e) => {
    e.preventDefault();
    const enteredAmount = Number(amountInputRef.current.value);
    props.onSaveItem(enteredAmount);
  }

  return (
    <form onSubmit={submitHandler}>
      {/* 내용 생략 */}
      <button type="submit">+ 담기</button>
    </form>
  );
};

export default ToppingItemForm;

그렇게 해서 결국 selectedItemData 객체는 모든 재료에 대해서 만들어지는 게 아니라 선택된 재료에 한해서 만들어지게 된다.



🐹 회고

주문앱도 처음 만들어보는데(그래도 이건 강의에서 배웠음) 카테고리도 처음 만들어봐서 엄청 헤맸던 것 같다😂😂 그리고 카테고리 오류를 수정하면서 지난 포스팅에서 작성한 코드를 많이 바꿨다. 예를 들면 ref를 state로 바꾸고, 일부만이 아니라 카테고리 전체를 state로 만드는 등 많이 수정했다.

그릭요거트 주문앱을 만들면서 가장 걱정이 되었던 게 담기 기능이랑 카테고리 기능이었다. 현재 좋은 코드는 아니더라도 일단 기본 동작은 가능하게 만들어서 뿌듯하다! 꾸준히 리팩토링해봐야겠다.

다음 포스팅에서는 현재까지 만든 담기 기능(1) 외에도 장바구니 안에서 수량을 조절하고, 담은 재료를 삭제할 수 있는 담기 기능(2)를 만들어보자.

profile
🐹강화하고 싶은 기억을 기록하고 공유하자🐹

0개의 댓글