너굴 개발 일지

[TypeScript + React] 반응형 데이터를 OOP적으로 상태관리하는 방법에 대한 고민 본문

TypeScript

[TypeScript + React] 반응형 데이터를 OOP적으로 상태관리하는 방법에 대한 고민

너굴냥 2023. 3. 19. 01:02

회사 프로젝트는 TypeScript + React를 기반으로 되어있다. 그래서 반응형 데이터를 상태 관리할 때 타입 선언은 인터페이스를 사용했었다. 그런데 OOP적으로 작성하지 못하는 것 같아서 클래스로 변경도 해보고 여러 정보들을 찾아보면서 했던 고민들과 그에 대한 결론을 작성한 글이다.

목차

1. React + TypeScript 프로젝트에서 state의 타입 선언을 클래스보다 인터페이스로 많이 사용하는 이유
2. React + TypeScript에서 OOP적으로 클래스 타입의 상태를 관리하는 방법
3. 클래스 타입의 state에서 일부만 변경된 경우, setState에게 일부만 변경되었다는 것을 알려주는 방법
결론
또 다른 고민들..

 

1. React와 TypeScript가 적용된 프로젝트에서 state의 타입 선언을 클래스보다 인터페이스로 많이 사용하는 이유는 뭘까? 

OOP에서는 클래스가 중요한 핵심이라 되는데...상태 관리할 때 왜 클래스를 사용하지 않는 건가? 클래스를 사용해야 OOP적으로 구현할 수 있지 않나 라는 생각이 들며 인터페이스는 단지 객체의 타입을 선언하는 역할이라 OOP에 반대되지 않나라는 생각이 들었다.

 

  • 인터페이스 사용이 많은 이유는 OOP의 개념을 따르기 위해서가 아니라 다른 이유 때문이다.
    1. 인터페이스는 클래스보다 추상적인 개념으로 객체의 구체적 구현 방법과는 별개로 객체가 가져야 하는 속성, 메서드의 형태를 정의한다. 따라서 인터페이스는 코드의 유연성과 확장성을 높여준다.
    2. TypeScript는 타입 시스템을 강화한 언어로, 코드의 안정성을 높이고 타입 체크 수행에 사용된다.
  • 결론적으로, TypeScript에서 인터페이스를 많이 쓰는 이유는 코드의 유연성과 확장성을 높이고 코드의 안정성을 높이기 위함

2. React와 Typescript가 적용된 프로젝트에서 상태 관리를 할 때 클래스를 기반으로한 OOP적인 구현을 할 수는 없는 걸까?

다음은 클래스를 기반으로 한 OOP 상태관리 코드로 숫자 증감 예시이다.

import { useState } from "react";

class State {
  count: number;

  constructor(count: number) {
    this.count = count;
  }

  increment(): void {
    this.count += 1;
  }

  decrement(): void {
    this.count -= 1;
  }
}

const Counter = (): JSX.Element => {
  const [state, setState] = useState<State>(new State(0));

  const increment = (): void => {
    state.increment();
    setState(new State(state.count));
  };

  const decrement = (): void => {
    state.decrement();
    setState(new State(state.count));
  };

  return (
    <div>
      <h1>{state.count}</h1>
      <button onClick={increment}>Increment</button>
      <button onClick={decrement}>Decrement</button>
    </div>
  );
};

export default Counter;
  • increment(), decrement() 함수에서 state의 멤버 함수를 호출해 상태를 변경함으로써 OOP적인 상태 관리를 구현할 수 있다.
  • 하지만 상태 변경시 매번 새로운 state 클래스의 인스턴스를 생성해야 한다는 단점이 있다.
  • 이유는 useState 훅에서 상태를 업데이트할 때, 이전 상태와 새로운 상태를 비교해 변경사항이 있는 경우에만 리렌더링되어있기에 setState 함수 사용시 매번 새로운 state 클래스를 선언한 것이다.

 

3. 클래스 타입의 state에서 일부만 변경된 경우, setState에게 일부만 변경되었다는 것을 어떻게 알려줄 수 있을까?

  • 객체나 배열의 얕은 복사를 사용할 수 있으며, 얕은 복사를 수행하는 함수 또한 만들어야 한다.
  • 하지만 이런 방식은 코드의 복잡도를 높이기에 상태를 객체로 관리하면서, 필요한 부분만 변경하는 방식으로 작성한다.
  • 이를 위해 typescript의 Partial 타입을 사용하여 인터페이스의 일부분만 선택적으로 정의하며 클래스에서도 일부분만 변경할 수 있도록 함수를 구현하고, 이 함수의 반환값을 Partial 타입으로 정의한 인터페이스에 할당하여 사용하는 것이 좋다.
  • Partial 참고 자료 1 , Partial 참고 자료 2 
import React, { useState } from 'react';

interface User {
  name: string;
  age: number;
  address: string;
}

class UserClass implements User {
  constructor(public name: string, public age: number, public address: string) {}
}

function updateUser(user: User, updates: Partial<User>): User {
  return { ...user, ...updates };
}

function App() {
  const [user, setUser] = useState<User>(new UserClass('John', 30, '123 Main St.'));

  const handleNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    const newName = event.target.value;
    // 일부분만 변경된 새로운 User 객체를 생성하고, setUser 함수를 사용하여 상태를 업데이트합니다.
    setUser(prevUser => updateUser(prevUser, { name: newName }));
  };

  return (
    <div>
      <p>Name: {user.name}</p>
      <p>Age: {user.age}</p>
      <p>Address: {user.address}</p>
      <input type="text" value={user.name} onChange={handleNameChange} />
    </div>
  );
}
  • updateUser 함수는 기존의 User 객체와 update 객체를 얕은 복사하여 일부분만 변경된 새로운 User 객체를 생성한다.
  • Partial<User>로 정의된 인터페이스는 User인터페이스의 일부분만 선택적으로 정의한 것이다.
  • 하지만 여전히 불필요한 코드들이 많다. 이와 관련해서는 개발바닥 유튜브 채널에서 프론트엔드서의 class 활용, oop 적용에 대한 영상에 달린 댓글에서도 확인할 수 있다.

  • 아래처럼 애초에 인터페이스만을 사용하면 더 간결한 코드로 작성된다.
interface User {
  name: string;
  age: number;
  address: string;
}

function App() {
  const [user, setUser] = useState<User>({
    name: "John",
    age: 30,
    address: "123 Main St.",
  });

  const handleNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    const newName = event.target.value;
    setUser((prevUser) => {
      return { ...prevUser, name: newName };
    });
  };

  return (
    <div>
      <p>Name: {user.name}</p>
      <p>Age: {user.age}</p>
      <p>Address: {user.address}</p>
      <input type="text" value={user.name} onChange={handleNameChange} />
    </div>
  );
}

하지만 바로 위 코드를 보면 인터페이스를 사용했기에 OOP라고 볼 수 없지 않을까?

  • 클래스를 사용하지 않았지만 인터페이스를 사용해 객체 타입을 정의하고 해당 인터페이스를 구현하는 객체를 생성하는 것은 객체 지향 프로그래밍 개념 중 다형성을 의미하기도 한다.
  • 리액트에서는 일박적으로 클래스가 아닌 함수형 컴포넌트, hooks를 사용해 상태를 관리하는것이 일반적인데 이 방식은 함수형 프로그래밍 패러다임에 더 가깝다. 따라서 React와 TypeScript를 사용하는 프로젝트에서도 인터페이스를 사용하여 타입을 정의하는 것이 일반적이다.

 

결론

  • 프론트엔드에서 class 활용 및 더 나아가 OOP로 상태 관리하는 일은 여러 문제들로 인해 드문 것 같다... React를 사용하는 이상 함수형으로 관리하는 형태로 해야 할 듯 싶다.
  • class는 유틸리티 혹은 변할 가능성이 적은 도메인 데이터에 적용해 상태 관리를 하는 것이 해당 객체에 대한 역할도 명확해지고 좋을 것 같다.

 

또 다른 고민들...

만약 사용자가 선택한 API에 따라서 차트를 생성해주고 차트의 옵션을 커스텀할 수 있는 컴포넌트가 있다고 할 때, 제약 조건은 다음과 같다.

 

  • 사용자가 선택한 API, API의 반환 데이터는 상태 관리를 한다.
  • 반환 데이터에는 선택된 API 데이터에 종속된 차트 타입 데이터가 포함된다.
  • 따라서 API 선택시 차트 타입 옵션에는 해당 API에 종속되는 차트 타입 리스트가 보여진다.
  • 사용자는 API, 차트 타입을 모두 선택해야 차트가 그려진다.
  • API마다 모두 다른 형태의 데이터를 반환된다.
  • 사용자가 특정 API를 선택한 경우, 차트의 색상을 변경할 수 있는 옵션이 생성되며 나머지 API는 차트의 색상을 변경할 수 없다.
  • 특정 차트 타입의 경우, 차트를 엑셀 표로 변환하여 출력하는 기능을 사용할 수 있으며 나머지 차트는 사용할 수 없다.
  • 이럴 경우, 어떻게 OOP 상태 관리를 할 수 있을까?