Front-End/타입스크립트 (TypeScript)

[React-TS]상태 관리, 우리는 얼마나 잘하고 있을까?

MS_developer 2025. 1. 19. 23:41

출처: Using React with Typescript

상태 관리의 중요성

상태(state)란 리액트를 사용하면서 가장 빈번하게 접하는 용어 중 하나로, 렌더링에 영향을 줄 수 있는 동적인 데이터 값을 의미한다. 상태는 리액트를 사용하는 이유라고 봐도 무방할 정도로 중요한 개념인데, 컴포넌트(Component)가 동적으로 렌더링 되는데 영향을 끼치기 때문이다.

 

상태는 각각의 데이터 범위와 목적에 따라 지역 상태(Local State), 전역 상태(Global State), 서버 상태(Server State)로 분류된다.

 

지역 상태는 컴포넌트 내부에서만 사용되는 상태로, 상태 변화가 해당 컴포넌트 또는 자식 컴포넌트에 영향을 미치는 경우들이 해당된다. 보통은 useState 훅으로 관리를 하는데 입력 필드 값, 모달 열림/닫힘 상태 등의 상황에서 사용된다.

 

전역 상태는 여러 컴포넌트에서 공유되는 상태로, 상태 변화가 애플리케이션 전체에 영향을 미친다. Props drilling 문제를 방지하기 위해 사용되는 경우가 있으며, 사용자 인증 정보나 테마 설정, 장바구니 정보 등의 상황에서 사용된다.

 

서버 상태는 서버에서 가져온 데이터로 구성된 상태를 의미한다. 따라서 네트워크 요청과 응답에 의존하는데, API를 통해 가져온 사용자 정보나 채팅, 주식과 같은 실시간 데이터를 사용할 때 서버 상태를 사용한다.

 

리액트를 사용함에 있어 상태를 잘 이해하고 효율적으로 활용하는 것이 중요한데, 막상 코드를 쓰다 보면 생각처럼 쉽지 않다. 때문에 상태를 관리할 때 리액트 내부 API만으로도 상태 관리가 가능하지만 성능 문제와 여러 상태들을 관리하는 데 복잡하기 때문에 Redux, MobX, Zustand 등 다양한 외부 상태 관리 라이브러리가 사용되고 있다.

 

그렇다면 어떻게 해야 상태관리를 "잘" 할 수 있을까?

 

아니, 애당초 상태관리를 "잘" 하고 있었을까?

 

다음 개념들을 통해 기존 프로젝트들에서 상태가 잘 관리되고 있었는지 생각해 보면 좋을 것 같다.


상태의 범위와 목적

 

Generated by Napkin AI

 

먼저 상태가 필요한 컴포넌트의 범위를 명확히 정의할 필요가 있다.

 

앞서 언급한 지역 상태, 전역 상태, 서버 상태에 대한 개념들을 숙지한 상태로 현재 사용하는 데이터가 단일 컴포넌트에서만 사용하는 데이터인지, 애플리케이션 여러 컴포넌트에 공유되는 데이터인지, 또는 외부 API에서 가져와 동기화하는 데이터인지를 인지할 필요가 있다.

 

상태는 애플리케이션의 복잡성을 증가시키고 동작을 예측하기 어렵기 때문에 상태가 반드시 필요한지 확인이 필요하다. 상태는 변화가 감지되어야 하는 데이터에만 사용해야 하고, 계산 가능한 값은 상태가 아니라 파생 상태(derived state)로 처리해야 한다. 예를 들어 리스트의 필터링된 결과는 상태가 아니라 원본 상태에서 계산이 가능하니 파생 상태로 처리하는 것이 좋다.

 

굉장히 기본적인 개념이지만, 데이터에 대한 상태의 범위와 목적을 명확히 인지하지 못한다면 효율적인 상태관리를 한다는 전제 자체가 성립할 수 없다.

 

복잡도 관리

 

Generated by Napkin AI

 

프로젝트를 진행하면서 상태들을 만들다 보면 쉽게 복잡해지는 경우들을 겪을 수 있다. 실제로 사용되는 페이지는 영향을 주는 데이터 값들도 많고 서로 연관성을 가지기 쉽기 때문이다. 따라서 상태의 구조화와 상태 계층 설계를 통해 복잡도를 관리해야 한다.

 

상태를 구조화한다는 것은 상태를 가능한 단순하게 유지하는 것과 같다. 불필요한 중복 상태를 피하고 단일 소스(soure of truth)를 유지한다. 예를 들어 동일한 데이터가 여러 컴포넌트에 영향을 준다고 각 컴포넌트에 따라 상태를 분리하여 관리하는 것은 복잡도를 증가시키는 일이기 때문에, 하나의 상태로 관리하는 것이 좋다.

 

위 예시처럼, 불필요한 상태의 분리를 방지하기 위해 상태의 계층 구조를 설계하는 것이 좋은데, 상위 컴포넌트는 전역 상태를, 하위 컴포넌트는 지역 상태를 관리하도록 구조화해야 한다. 이때, 필요한 상태를 컨텍스트(Context)로 끌어올리거나 분리하여 사용한다.

 

상태 변경의 예측 가능성

 

Generated by Napkin AI

 

상태 변경을 단순하고 예측 가능하게 유지하면 상태를 효율적으로 관리할 수 있다. 이를 위해 불변성을 유지하고 상태 변경 함수의 명확성을 유지할 필요가 있다.

 

불변성(Immutability)란, 상태 변경 시 원래 상태를 직접 수정하지 않고, 새 객체나 배열을 반환하여 변경 사항을 반영하는 방식을 말한다. 리액트는 상태 변경을 감지하기 위해 얕은 비교(Shallow Comparison)를 사용하는데, 불변성을 유지하지 않으면 변경 사항을 제대로 감지하지 못할 수 있다. 

 

const [user, setUser] = useState({ name: "John", age: 25 });

const updateAge = () => {
  // ❌ 직접 수정하지 않기
  // user.age = 26;

  // ✅ 새로운 객체 반환
  setUser({ ...user, age: 26 });
};

 

우리가 흔히 사용하는 useState의 패턴은 불변성을 유지하고 있으니 평소 리액트 문법을 잘 따르고 있다면 문제가 없는 부분이기도 하다.

 

또한, TypeScript에서는 아래와 같이 Readonly나 ReadonlyArray 같은 타입을 활용하여 상태 변경을 제한할 수 있다.

 

const state: Readonly<{ count: number }> = { count: 0 };
state.count = 1; // 오류 발생

 

상태를 변경하는 함수의 명확성을 보장하기 위해서는 단일 책임 원칙(Single Responsibility Principle)을 따라야 한다. 즉, 상태 변경 함수는 가능한 한 단순하고 하나의 작업만 수행해야 한다. 만약 하나의 상태 변경 함수에서 여러 작업을 처리하고 있다면 해당 상태 변수 함수에서 어떤 작업이 어떤 문제를 일으키는지 알기 어렵기 때문이다.

 

성능 고려

 

Generated by Napkin AI

 

결국 상태 관리를 최적화하는 이유는 궁극적으로 성능을 최적화하기 위함이다. 앞서 언급했듯 상태 관리는 편리하지만 그만큼 남용했을 때 쉽게 페이지를 느리게 만들 수 있기 때문이다. 이때, 불필요한 렌더링을 방지하고 복잡한 연산을 최적화하는 것도 효율적인 상태 관리의 일환으로 볼 수 있다.

 

불필요한 랜더링을 방지하기 위해서는 반드시 필요한 데이터만 포함하여 상태를 최소화해야 한다. 만약 필터링을 한다면 아래와 같이 코드를 구성하는 것이 상태를 최소화하는 방식이라고 생각한다.

 

// ❌ 불필요한 상태 포함
const [filteredList, setFilteredList] = useState<Item[]>([]);

// ✅ 상태 최소화 (필터링은 렌더링 중 계산)
const [items] = useState<Item[]>([...]); 
const filteredList = items.filter(item => item.isActive);

 

 

불필요한 렌더링을 방지하기 위해 React.memo를 활용하면 아래와 같이 props가 변경되지 않은 경우 렌더링을 건너뛸 수 있다.

import React from "react";

const ChildComponent = React.memo(({ value }: { value: number }) => {
  console.log("ChildComponent Rendered");
  return <p>{value}</p>;
});

const ParentComponent = () => {
  const [count, setCount] = useState(0);

  return (
    <div>
      <button onClick={() => setCount((prev) => prev + 1)}>Increment</button>
      <ChildComponent value={count} />
    </div>
  );
};

 

불필요한 연산을 최적화하는 것도 비슷하다. useMemo를 사용하여 계산된 값을 캐싱하는 방식으로 다음과 같이 사용하여 성능을 개선시킬 수도 있다.

 

import React, { useMemo, useState } from "react";

const ExpensiveCalculation = (num: number) => {
  console.log("Calculating...");
  return num * 2;
};

const App = () => {
  const [count, setCount] = useState(0);
  const [input, setInput] = useState("");

  // Memoized 계산 결과
  const result = useMemo(() => ExpensiveCalculation(count), [count]);

  return (
    <div>
      <input value={input} onChange={(e) => setInput(e.target.value)} />
      <p>Result: {result}</p>
      <button onClick={() => setCount((prev) => prev + 1)}>Increment</button>
    </div>
  );
};

 

useCallback으로 함수 메모이제이션하면 컴포넌트가 렌더링 될 때마다 새로운 함수가 생성되는 것도 방지할 수 있다.

 

import React, { useState, useCallback } from "react";

const Child = React.memo(({ onClick }: { onClick: () => void }) => {
  console.log("Child Rendered");
  return <button onClick={onClick}> Click Me </button>;
});

const App = () => {
  const [count, setCount] = useState(0);

  const handleClick = useCallback(() => {
    console.log("Button clicked");
  }, []); 
  // 빈 배열로 의존성 없음을 명시

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount((prev) => prev + 1)}>Increment</button>
      <Child onClick={handleClick} />
    </div>
  );
};

 

 

유지보수성과 확장성

 

Generated by Napkin AI

 

리액트에서 타입스크립트를 사용하는 이유가 무엇일까? 왜 동적 언어에 엄격한 타입 관리가 필요할까? 상태 관리의 효율성 관점에서 보면 좀 더 이해가 될 것 같다. 타입 안정성을 보장하여 유지보수가 용이하고, 모듈화를 통해 재사용성을 높일 수 있기 때문이다.

 

타입 안정성을 보장한다면 잘못된 데이터 변경을 컴파일 단계에서 방지할 수 있다.

 

interface State {
  count: number;
}

const [state, setState] = useState<State>({ count: 0 });
setState({ count: "wrong type" }); // 오류

 

굉장히 단순한 예시지만, 실제로 다양하고 복잡한 상태들을 사용하는 과정(특히 객체형 상태)에서 잘못된 타입 지정을 방지하는 것이 매우 큰 도움이 될 때가 많다. 

 

모듈화를 통해 상태 관리 로직을 분리하고 재사용 가능한 훅(Hook)으로 구현하는 것도 당연히 상태 관리에 도움이 된다.

 

import { useState } from "react";

const useCounter = (initialValue: number = 0) => {
  const [count, setCount] = useState(initialValue);

  const increment = () => setCount((prev) => prev + 1);
  const decrement = () => setCount((prev) => prev - 1);

  return { count, increment, decrement };
};

export default useCounter;

 

프로젝트의 규모가 커지다보면 같은 상태를 여러 컴포넌트에서 반복적으로 사용하는 경우가 있기 때문에, 모듈화를 통해 관련된 상태를 훅으로 분리해 두었다면 찾기도 용이하고 재사용성이 높아 프로젝트의 진척도 자체에도 도움이 된다.

 


상태 관리를 "잘" 한다는 것

 

정리된 위의 내용들은 리액트-타입스크립트를 공부하다보면 빈번하게 보는 개념들이고, 실제로 사용함에 있어 위와 같은 규칙들을 따르는 것이 늘 권장되고 강조된다.

 

???: 그렇게 쉬우면 해봐요 그럼

 

하지만 프로젝트를 진행할 때 빠듯한 일정과 부족한 인력 상황에서는 항상 효율적으로 코드가 관리하기 힘든 것도 사실이다.

 

장기적인 관점에서 본다면 도움이 된다는 것은 대부분의 개발자들이 동의하는 부분일 것이다.

 

그럼에도 나와 같은 저년차 개발자들에게 Memoization, 모듈화 등의 개념들이 실천이 어려운 이유는 바쁘다는 핑계로 평소에 고민없이 코드를 작성하는 버릇이 있기 때문인 것 같다.

 

유지보수성과 확장성을 고려한 구조적인 접근과 애플리케이션의 구동 시간을 고려한 올바른 코드 습관을 기르는 것이 무엇보다 중요한 것 같다.

 

그런 의미에서 상태 관리를 할 때 위와 같은 개념들을 이해하고 있어야 어떤 식으로 상태들을 관리하는 것이 좋을지, 또 어떻게 접근하는 것이 좋을지 고민할 수 있다. 이 전제가 성립되어야 보다 효율적이고 간결한 코드와 빠른 작업 결과를 내놓은 애플리케이션이 생성될 수 있다고 생각한다.


참조