[TS+React] 10장 상태 관리를 잘 하는 방법

naini 🐰·2025년 2월 25일

FrontEnd

목록 보기
18/18

📘 학습 후기
너무 어려워서 눈물난다. 상태 관리 리랜더링... 눈물난다. 그치만 프로젝트에 적용 할 유용한 정보인 거 같아서 신난다..
input 상태값, 아이템 체크리스트, 리뷰리스트 등등.. 상태 관리로 성능 개선할 점이 보인다.

우아한 타입 스크립트 with 리액트 학습 내용을 정리했다.

1. 상태


  • 리액트에서 상태는 시간이 지나면서 변할 수 있는 동적인 데이터이다.
  • 값이 변경될 때마다 컴포넌트의 랜더링 결과물에 영향을 준다.
  • 리액트 앱 내의 상태는 지역 상태, 전역 상태, 서버 상태로 분류할 수 있다.

리액트 내부 API만을 사용하여 상태를 관리할 수 있지만 성능 문제와 상태의 복잡성으로 인해 Redux, MobX, Recoil 같은 외부 상태 관리 라이브러리를 주로 활용한다.

[1] 지역 상태

  • 지역 상태는 컴포넌트 내부에서 사용되는 상태이다.
  • 체크박스의 체크 여부나 폼의 입력값 등이 해당된다.
  • 주로 useState 훅을 가장 많이 사용하며 때에 따라 useReducer와 같은 훅을 사용하기도 한다.

[2] 전역 상태

  • 전역 상태는 앱 전체에서 공유하는 상태를 의미한다.
  • 여러 개의 컴포넌트가 전역 상태를 사용할 수 있으며 상태가 변경되면 컴포넌트들도 업데이트된다.
  • Prop drilling 문제를 피하고자 지역 상태를 해당 컴포넌트들 사이의 전역 상태로 공유할 수도 있다.

Prop drilling
props를 통해 데이터를 전달하는 과정에서 중감 컴포넌트는 해당 데이터가 필요하지 않음에도 자식 컴포넌트에 전달하기 위해 props를 전달해야 하는 과정을 말한다. 컴포넌트의 수가 많아지면 prop drilling으로 인해 코드가 훨씬 복잡해질 수 있다.

[3] 서버 상태

  • 서버 상태는 사용자 정보, 글 목록 등 외부 서버에 저장해야 하는 상태들을 의미한다.
  • UI 상태와 결합하여 관리하게 되며 로딩 여부나 에러 상태 등을 포함한다.
  • 서버 상태는 지역 상태 혹은 전역 상태와 동일한 방법으로 관리된다.
  • react-query, SWR과 같은 외부 라이브러리를 사용하여 관리하기도 한다.

2. 상태를 잘 관리하려면?


상태는 애플리케이션의 복잡성을 증가시키고 동작을 예측하기 어렵게 만든다. 또한 상태가 업데이트될 때마다 리랜더링이 발생하기 때문에 유지보수 및 성능 관점에서 상태의 개수를 최소화하는 것이 좋다.
가능하다면 상태가 없는 stateless 컴포넌트를 활용하는 게 좋다.
어떤 값을 상태로 정의할 때는 다음 2가지 사항을 고려해야 한다.

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

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

시간이 지나도 변하지 않는 값이라면, 객체 참조 동일성을 유지하는 방법을 고려해볼 수 있다.
즉 리액트의 기능을 활용하여 컴포넌트 라이프사이클 내에서 마운트될 때 인스턴스가 생성되고, 랜더링될 때마다 동일한 객체 참조가 유지되도록 구현해줘야 한다.

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

(1) 객체 참조 동일성이 뭘까?

객체 참조 동일성이란 메모리 상에서 같은 객체를 참조하고 있는지를 의미한다.
리액트는 리랜더링이 발생할 때 컴포넌트 내부에서 생성된 객체가 새로운 인스턴스로 평가될 수 있다.

const Component = () => {
  const store = new Store(); // 리렌더링될 때마다 새로운 객체 생성된다.
};

useEffect(() => {
  console.log("스토어 변경 감지");
}, [store]); // store가 매번 변경되므로 useEffect가 불필요하게 실행된다.

객체 참조 동일성을 유지하면, 이러한 불필요한 리랜더링을 줄일 수 있다.

(2) useMemo 메모이제이션

객체의 참조 동일성을 유지하기 위해 널리 사용되는 방법의 하나는 메모이제이션이다.
useMemo를 활용하여 컴포넌트가 마운트될 때만 객체 인스턴스를 생성하고 이후 랜더링에서는 이전 인스턴스를 재활용할 수 있도록 구현할 수 있다.

const store useMemo(() => new Store(), []);
  • 객체 참조 동일성을 유지하기 위해 useMemo를 사용하는 것은 권장되는 방법이 아니다.
  • useMemo를 통한 메모이제이션은 의미상으로 보장된 것이 아니기 때문에 오로지 성능 향상을 위한 용도로만 사용되어야 한다.
  • 리액트에서는 메모리 확보를 위해 이전 메모이제이션 데이터가 삭제될 수 있다.

useMemo 없이도 올바르게 동작하도록 코드를 작성하고 나중에 성능 개선을 위해 useMemo를 추가하는 것이 적절한 접근 방식이다.

(3) useState의 초깃값만 지정: 의미론적으로 부적절

  • useState를 사용하여 초깃값만 지정함으로써 모든 랜더링 과정에서 객체 참조를 동일하게 유지할 수 있다.
  • useState는 초깃값을 지정할 때 함수를 전달하면 최초 랜더링에서만 실행되는 특징이 있다.
const [store] = useState(new Store()); // ❌ 리렌더링될 때마다 new Store()가 실행되어 초깃값 설정에 큰 비용이 소요될 수 있다.
const [store] = useState(() => new Store()); // 최초 한 번만 실행된다.

따라서 초깃값을 계산하는 콜백을 지정하는 방식(지연 초기화 방식)을 사용한다.

  • 마운트될 때 한 번만 실행되고, 이후에는 기존 store 값을 유지한다.
  • 즉 리랜더링이 발생해도 같은 객체를 참조하기 때문에 객체 참조 동일성이 유지된다.

하지만 useState는 의미론적으로 봤을 때는 좋은 방법이 아니다.
처음에는 상태를 시간이 지나면서 변화되어 랜더링에 영향을 주는 데이터로 정의했지만, 현재의 목적은 모든 랜더링 과정에서 객체의 참조를 동일하게 유지하고자 하는 것이기 때문이다.

(4) useRef를 사용하는 방법: 권장됨

useRef가 동일한 객체 참조를 유지하려는 목적으로 사용하기에 가장 적합한 훅이다.
useRef의 인자로 직접 new Store()를 사용하면 useState와 마찬가지로 랜더링마다 불필요한 인스턴스가 생성되므로 아래와 같이 작성해야 한다.

const store = useRef<Store>(null);

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

[2] 파생된 값은 상태가 아니다.

부모에게서 props로 전달받을 수 있는 props 이거나 기존 상태에서 계산될 수 있는 값은 상태가 아니다.
SSOT는 어떠한 데이터도 단 하나의 출처에서 생성하고 수정해야 한다는 원칙을 의미하는 방법론이다.
다른 값에서 파생된 값을 상태로 관리하게 되면 기존 출처와는 다른 새로운 출처에서 관리하게 되는 것이므로 해당 데이터의 정확성과 일관성을 보장하기 어렵다.

(1) 부모 컴포넌트로부터 데이터를 받을때 문제 발생

import { useState } from "react";

type UserEmailProps = {
  initialEmail: string;
}

const UserEmail: React.VFC<UserEmailProps> = ({initialEmail}) => {
  // 1. useState를 사용하여 email 상태를 관리한다.
  // 초기값은 props로 전달된 initialEmail
  const [email, setEmail] = useState(initialEmail);
  
  // 2. 사용자가 input 필드에 값을 입력할 때 호출되는 함수이다.
  // event 객체에서 입력된 값을 가져와서 email 상태를 업데이트 한다.
  const onChangeEmail = (event: React.ChangeEvent<HTMLInputElement>) => {
    setEmail(event.target.value);
  };
  
  return(
    <div>
    	<input type="text" value={email} onChange={onChangeEmail} />
    </div>
  );
}
  • useState(initialEmail) 은 초기 랜더링 시 한 번만 실행되기 때문에 이후 initialEmail 값이 바뀌더라도 email 상태는 변경되지 않는다.

(2) useEffect를 사용한 동기화 작업은 피해야 한다.

useEffect로 props와 상태를 동기화하면 사용자의 입력이 무시될 수 있다.
props와 상태를 동기화하기 위해 useEffect를 사용한 해결책을 떠올릴 수 있지만, 좋은 방법은 아니다.

만약 사용자가 값을 변경한 뒤에 initialEmail prop이 변경된다면 input 태그의 value는 어떻게 설정될까?

이럴 때는 사용자의 입력을 무시하고 부모 컴포넌트로부터 전달된 initialEmail prop의 값을 value로 설정할 것이다.

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

  // initialEmail이 변경될 때마다 email 상태도 업데이트되도록 설정
  useEffect(() => {
    setEmail(initialEmail);
  }, [initialEmail]);

(3) 상태 끌어올리기 기법: 단일한 출처에서 데이터를 사용하도록 하기

현재 email 상태에 대한 출처는 prop으로 받는 initialEmail과 useState로 생성한 email state이다.
문제를 해결하기 위해 두 출처 간의 데이터를 동기화하기보다 단일한 출처에서 데이터를 사용하도록 변경해줘야 한다.

일반적으로 리액트에서는 상위 컴포넌트에서 상태를 관리하도록 해주는 상태 끌어올리기 기법을 사용한다.
UserEmail에서 관리하던 상태를 부모 컴포넌트로 옮겨서 email 데이터의 출처를 props 하나로 통일할 수 있다.

import { 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.targer.value);
  };
  
  return (
    <div>
    	<input type="text" value={email} onChange={onChangeEmail} />
    </div>
  );
};

두 컴포넌트에서 동일한 데이터를 "상태"로 갖고 있을 때는 두 컴포넌트 간의 상태를 동기화하는 방법을 사용하면 안된다.

대신 가까운 공통 부모 컴포넌트로 상태를 끌어올려서 SSOT를 지킬 수 있도록 해야 한다.

[3] 다음ㅇ ㅖ시로..

아이템 목록과 선택된 아이템 목록을 가지고 있는 코드다.
이 코드는 아이템 목록이 변경될 때마다 선택된 아이템 목록을 가져오기 위해 useEffect로 동기화 작업을 하고 있다.

이 코드의 문제점은 무엇일까?

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

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

(1) 문제점

  • items와 selectedItems가 동기화되지 않을 수 있다.
  • 새로운 상태로 정의함으로써 단일 출처가 아닌 여러 출처를 가지게 되었고 이에 따라 동기화 문제가 발생하게 된다.

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

(2) 자바 스크립트 변수로 담자

items가 변경될 때마다 컴포넌트가 새로 랜더링되며, 매번 랜더링될 때마다 selectedItems를 다시 계산하게 된다. 이런 식으로 단일 출처를 가지면서 원하는 동작을 수행하게 할 수 있다.

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

items와 selectedItems 2가지 상태를 유지하면서 useEffect로 동기화하는 과정을 거치면 selectedItems 값을 얻기 위해서 2번의 랜더링이 발생한다.

  • items의 값이 바뀌며 랜더링 발생
  • items의 값이 변경됨을 감지하고 selectedItems 값을 변경하며 리랜더링 발생

계산할 수 있는 값을 상태로 관리하지 않고, 직접 자바스크립트 변수에 계산 결과를 담으면 리랜더링 횟수를 줄일 수 있다.

하지만 이 경우에는 매번 랜더링될 때마다 계산을 수행하게 되므로 계산 비용이 크다면 성능 문제가 발생할 수도 있다.

(3) useMemo로 성능 개선하기

useMemo를 사용하여 items가 변경될 때만 계산을 수행하고 결과를 메모이제이션하여 성능을 개선할 수 있다.

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

[3] useState vs useReducer

리액트에서 상태를 관리할 때 가장 많이 사용되는 훅이 useState와 useReducer 이다.
이 둘은 언제, 왜 사용하는지에 대해 차이가 있다.

useState 대신 useReducer 사용을 권장하는 경우는 크게 2가지가 있다.

  • 다수의 하위 필드를 포함하고 있는 복잡한 상태 로직을 다룰 때
  • 다음 상태가 이전 상태에 의존적일 때
  • boolean 상태를 토글하는 액션만 사용하는 경우에

예를 들어 배달의민족 리뷰 리스트를 필터링하여 보여주기 위한 쿼리를 상태로 저장해야 한다고 해보자.
이러한 쿼리는 단순하지 않고 검색 날짜 범위, 리뷰 점수, 키워드 등 많은 하위 필드를 가지게 된다. 페이지네이션을 고려한다면 페이지, 사이즈 등의 필드도 추가될 수 있다.

type DateRangePreset = "TODAY" | "LAST_WEEK" | "LAST_MONTH";

type ReviewRatingString = "1" | "2" | "3" | "4" | "5";

interface ReviewFilter {
  // 리뷰 날짜 필터링
  startDate: Date;
  endDate: Date;
  dateRangePreset: Nullable<DateRangePreset>;
  
  // 키워드 필터링
  keyword: string[];
  
  // 리뷰 점수 필터링
  ratings: ReviewRatingString[];
  
  // ...
}

// Review List Query State
interface State {
  filter: ReviewFilter;
  page: string;
  size: number;
}

(1) useState의 한계

  • 이러한 데이터 구조를 useState로 다루면 상태를 업데이트할 때마다 잠재적인 오류 가능성이 증가한다.
  • 특정한 업데이트 규칙이 있다면 useState만으로는 한계가 있다.

(2) useReducer

useReducer는 '무엇을 변경할지'와 '어떻게 변경할지'를 분리하여 dispatch를 통해 어떤 작업을 할지를 액션으로 넘기고 reducer 함수 내에서 상태를 업데이트하는 방식을 정의한다.

이로써 복잡한 상태 로직을 숨기고 안전성을 높일 수 있다.

// Action 정의
type Action = 
  | {payload: ReviewFilter; type: "filter";}
  | {payload: number; type: "navigate";}
  | {payload: number; type: "resize";}

// Reducer 정의
const reducer: React.Reducer<State, Action> = (state, action) => {
  switch(action.type) {
    case "filter":
      return {
      };
    case "navigate":
      return{
      };
    case "resize":
      return{
      };
    default:
      return state;
  }
};

// useReducer 사용
const [state, dispatch] = useReducer(reducer, getDefaultState());

// dispatch 예시
dispatch({payload: ReviewFilter; type: "filter";});
dispatch({payload: page; type: "navigate";});
dispatch({payload: size; type: "resize";});

boolean 상태를 토글하는 액션만 사용하는 경우의 예시도 봐보자

import { useReducer } from "react";

// Before
const [fold, setFole] = useState(true);

const toggleFold = () => {
  setFole((preve) => !prev);
};

// After
const [fold, toggleFold] = useReducer((v) => !v, true);

(3) 비교 정리

useStateuseReducer
언제 사용단순한 상태 관리복잡한 상태 관리(이전 상태 기반 업데이트)
상태 변경 방식setState로 직접 변경dispatch(action)을 통해 변경
로직 위치컴포넌트 내부reducer 함수에서 관리
읽기 쉬운 코드간단한 경우 적합상태 로직이 복잡할 때 가독성이 더 좋음
성능 최적화단순 상태에는 적합dispatch로 불필요한 리랜더링을 줄일 수 있음

0개의 댓글