본문 바로가기
🌳Frontend/react

React에서 state를 정의할 때 고려해야 할 점 (feat. SSOT)

by Bㅐ추 2024. 2. 25.
728x90
반응형

 

해당 게시글은 우아한 타입스크립트 with 리액트 10장을 읽고 정리한 글 입니다.


React에서 State 란?

- 시간이 지나면서 변할 수 있는 동적인 데이터

- 렌더링에 영향을 줄 수 있는 동적인 데이터

 

리액트 앱 내에서의 상태는 지역 상태, 전역 상태, 서버 상태 로 분류할 수 있습니다.

리액트 API만 으로도 상태를 관리할 수 있지만, 성능 문제가 발생하거나 규모가 커져 복잡해지면 Redux, MobX, Recoil 과 같은 외부 상태 라이브러리를 주로 활용합니다.

특히나, 컴포넌트 간의 상하관계 사이에서 상태 전달을 위해 과도한 Props drilling 을 막고자 사용합니다.

 

React 에서 State 를 정의할 때 고려해야 할 사항

리액트에서 state가 변하게 되면(업데이트가 되면) 리렌더링이 발생하기 때문에, 성능과 유지보수를 위해 상태의 개수를 최소화 하는 것이 바람직합니다. 되도록이면 상태가 없는 Stateless 컴포넌트를 활용하는 것이 좋습니다.

하지만, 실제로 애플리케이션을 개발하다보면 상태가 필요해지는 순간이 옵니다.

이때, 어떠한 값을 상태로 정의하고자 할 때 고려해야 할 사항 두가지가 있습니다.

 

1. 시간이 지나도 변하지 않는다면 상태가 아니다.
2. 파생된 값은 상태가 아니다.

 

📌 시간이 지나도 변하지 않는다면 상태가 아니다.

시간이 지나도 변하지 않는 값이라면, 객체 참조 동일성을 유지하는 방법을 고려해볼 수 있습니다.

"컴포넌트가 마운트되었을 때만 스토어 객체 인스턴스를 만들고, 컴포넌트가 언마운트 될 때 까지 해당 참조가 변하지 않았으면 한다"  라는 상황을 가정해봅시다.

 

상수 변수로 선언한다면?

import React from 'react';

const Component: React.VFC = () => {
  const store = new Store();
  return (
    <StoreProvider store={store}>
     <Children>
    </StoreProvider>
  );
};

 

위처럼 스토어 인스턴스를 상수객체로 선언한다면 어떨까요?

이 방식은 렌더링 때 마다 새로운 객체 인스턴스가 생성되기 때문에, 매번 다른 객체로 인식되어 불필요한 리렌더링이 일어날 수 있습니다.

우리는 렌더링 될 때 마다 동일한 객체 참조가 유지되도록 구현해주어야 합니다.

 

useMemo로 참조값을 유지한다면 어떨까?

import {useMemo} from 'react';

const store = useMemo(() => new Store(), []);

 

단순히 코드로 보았을 땐 문제가 없어보입니다. 하지만, 객체 참조 동일성 유지를 위해 useMemo 를 사용하는 것은 권장되는 방법은 아닙니다.

리액트 공식문서에 따르면, useMemo는 오로지 성능 향상을 위한 용도로 사용되어야 한다고 언급하고 있습니다.

또한, 리액트에서는 메모리 확보를 위해 이전 메모제이션한 값을 삭제할 수 있다고 합니다.

useMemo는 리액트에서 제시한 용도와 알맞게 성능 개선을 위해 useMemo를 쓰는 것이 좋습니다.

 

useState 에 초깃값만 지정해서 사용하는 건 어떨까?

 

`useState(new Store())` 처럼 사용할 수 있습니다.

하지만, 이는 렌더링 될 때 마다 생성되어 초깃값 설정에 큰 비용이 소요될 수 있습니다.

그래서 주로 지연 초기화 방식을 사용하는데,

import {useState} from 'react'

const [store] = useState(() => new Store());

 

지연초기화?
위처럼 직접적인 값이 아닌 함수를 useState 인자로 넘기는 것.
상태가 최초로 생성될 때만 실행된다.

 

기술적으로 잘 동작할 수 있지만 의미론 적으로 봤을 때는 좋은 방법은 아닙니다.

현재의 목적은 모든 렌더링과정에서 참조가 동일하게 하고자 함이지만, 위의 방법은 최초 상태 선언시 성능 개선을 위한 방법입니다.

 

useRef 를 사용하자!

결론은 useRef를 사용하면 됩니다. 공식 문서에 따르면 useRef가 동일한 객체 참조를 유지하려는 목적으로 사용하기에 가장 적합한 훅입니다.

import {useRef} from 'react';

const store = useRef<Store>(null);

if (!store.current) {
  store.current = new Store();
}

 

useRef 인자로 직접 `new Store()` 를 사용하면 useState와 마찬가지로 렌더링마다 불필요한 인스턴스가 생성되므로 위와같이 작성해주어야 합니다.

 

상태라고 하는 것은 렌더링에 영향을 주며 변화하는 값을 의미하므로, 객체 참조 동일성을 유지하기 위해 useState에 초깃값을 할당하는 건 적절하지 않습니다.

 

📌 파생된 값은 상태가 아니다.

부모에게서 전달받을 수 있는 props이거나, 기존 상태에서 계산될 수 있는 값은 상태가 아닙니다.

 

SSOT (Single Source Of Truth)

  • 어떠한 데이터도 단 하나의 출처에서 생성하고 수정해야 한다는 원칙을 의미하는 방법론.
  • 즉, 데이터는 한 곳에서만 제어, 편집하도록 하는 방법론.

 

리액트 앱에서 상태를 정의할 때도 SSOT를 고려해야 합니다. 상태를 기존 출처와는 다른 새로운 출처에서 관리하게 된다면 해당 데이터의 정확성과 일관성을 보장하기 어렵습니다.

부모에게서 props로 전달받으면 상태가 아니다.

아래와 같은 컴포넌트가 있다고 가정해봅시다.

 

  • 초기 이메일 값을 부모컴포넌트로부터 받아 input value로 렌더링
  • 이후 사용자가 입력한 값을 input value로 렌더링
import React, { useState } from 'react';

type UserEmailProps = {
  initialEmail: string;
};

const UserEmail: React.VFC<UserEmailProps> = ({ initialEmail }) => {
  const [email, setEmail] = useState(initialEmail); 
  const onChangeEmail = (event: React.ChangeEvent<HTMLInputElement>) => {
    setEmail(event.target.value);
  };
  return (
    <div>
      <input type='text' value={email} onChange={onChangeEmail} />
    </div>
  );
};

 

문제점

  • 전달받은 initialEmail props의 값이 변경되어도 input 의 value는 변하지 않음.
  • useState의 초깃값으로 설정한 값은 컴포넌트가 마운트될 때 한 번만 email 상태의 값으로 설정되며 이후 독자적으로 관리됨.

 

위의 문제점을 보고 useEffect를 이용한 해결방안을 떠올릴 수도 있습니다.

import {useState, useEffect} from 'react';

const [email, setEmail] = useState(initialEmail);

useEffect(() => {
  setEmail(initialEmail);
}, [initialEmail]);

 

initialEmail이 변경되면 setEmail 로 설정할 수 있도록 side effect를 추가하면 되지 않을까요?

언뜻 문제가 없어보이지만, 예상치 못한 변경이 일어날 수 있어요. 만약 사용자가 값을 변경한 뒤에 initialEmail 값이 변경되면 사용자가 입력한 값은 어떻게 될까요?

 

위의 방법은 리액트 외부 데이터(ex. LocalStorage) 와 동기화할 때 사용해야 합니다.

내부에 존재하는 데이터를 상태와 동기화하는 데 사용하면 개발자가 추적하기 어려운 오류를 발생시킬 수 있습니다.

 

이때 SSOT 원칙을 따라봅시다.

데이터를 동기화하는 것에 집중하지 않고, 단일한 출처에서 데이터를 사용하고 관리하도록 바꿔봅시다.

예를들어, 상위 컴포넌트에서 상태를 관리하도록 해주는 상태 끌어올리기(Lifting State Up) 기법을 사용합니다.

 

import React, { useState } from 'react';

type UserEmailProps = {
  email: string;
  setEmail: React.Dispatch<React.SetStateAction<string>>;
};

const UserEmail: React.VFC<UserEmailProps> = ({ email, setEmail }) => {
  const onChangeEmail = (event: React.ChangeEvent<HTMLInputElement>) => {
    setEmail(event.target.value);
  };
  return (
    <div>
      <input type='text' value={email} onChange={onChangeEmail} />
    </div>
  );
};

 

앞의 상황처럼 두 컴포넌트에서 동일한 데이터를 상태로 갖고있을 때는 두 컴포넌트 간의 상태를 동기화하는 방법을 사용하는 것 보단, 가까운 공통 부모컴포넌트로 상태를 끌어올려서 SSOT 를 지킬 수 있도록 해야합니다.

 

props 혹은 기존 상태에서 계산할 수 있는 값은 상태가 아닙니다.

import {useState, useEffect} from 'react';

const [items, setItems] = useState<Item[]>([]);
const [selectedItems, setSelectedItems] = useState<Item[]>([]);

useEffect(() => {
  setSelectedItems(items.filter((item) = > item.isSelected));
}, [items]);

 

이 코드는 Item이 변경될 때 마다 선택된 아이템을 가져오기 위해 useEffect로 동기화 작업을 해주고 있습니다.

 

문제점

  • 여러 상태가 복잡하게 얽혀있는 경우 흐름을 파악하기 어렵고 의도치않게 동기화 과정이 누락될 수도 있다.
  • 새로운 상태로 정의함으로써 단일 출처가 아닌 여러 출처를 가지게 되었다.
  • 성능측면에서 바라보면,
    • items의 값이 바뀌며 렌더링 발생
    • items의 값이 변경됨을 감지하고 selectedItems 값을 변경
    • 리렌더링 발생
    • -> 총 두번 이상의 렌더링이 일어난다.

 

우리는 내부 상태끼리 동기화하는 방법이 아니라 여러 출처를 하나의 출처로 합치는 방법을 고민해야 합니다.

아주 간단한 방법은 상태로 정의하지 않고 계산된 값을 자바스크립트 변수로 담는 것입니다.

import {useState} from 'react';

const [items, setItems] = useState<Item[]>([]);
const selectedItems = items.filter((item) = > item.isSelected);

 

그러면 Item이 변경될 때 마다 컴포넌트가 새로 렌더링 되며, 매번 렌더링될 때 마다 selectedItems 를 다시 계산하게 됩니다.

만약, 계산비용이 크게 느껴진다면 useMemo를 써 메모제이션하는 것도 방법 중 하나가 될 수 있겠죠?

import {useState, useMemo} from 'react';

const [items, setItems] = useState<Item[]>([]);
const selectedItems = useMemo(() => veryExpensiveCalculation(items), [items]);
728x90
반응형