리액트 애플리케이션 모듈화

지속가능한개발·2023년 4월 25일
2

고민한것

목록 보기
7/7

0.고민의 계기

프로젝트를 진행하다가 기능추가 하나 하는게 뭐가 이렇게 힘들고
많은 코드는 흩어져있고 가독성도 안좋고 내가 만들었지만
참 힘든적이 한두번이 아니다

그러다가 리액트에 객체지향의 방법을 적용해서
클래스도 쓰고 도메인로직을 캡슐화하는 것을
리액트 hooks방식과 연결한 원문을 번역한 eunbinn님의 velog글을 발견했다!

알게되는것들 정리하면서 한번 읽고
프로젝트에 적용할 수 있는것은 적용해보자

이 내용은 객체지향 진영에서 리액트를 바라보는 부분을 설명하는것 같다
함수형도 잘 아는건 아니지만 내가 하고 있는게 뭔지 모르겠지만...
나는 항상 이렇게 생각한다 편식은 안좋다고
객체지향과 함수형을 둘 다 잘 이해하게되면 잘 섞어쓰는법을 알게될거라고.

왜냐하면 객체지향과 함수형 둘다 실무에서 잘 쓰이고 있고
하나를 모두 알지못한채 우위를 가리는것은 바보같은 짓이다
둘 다 일말의 개발에 대한 진리를 가지고있고
나는 둘 모두에게서 개발자로써
좋은 프로그램을 만들기 위한 양분을 얻을 수 있다

어렴풋한 내 생각에는 전체적인 설계나 프로그램의 분류 또는 구조에서는
객체지향을 통해 배울것이많고 세부구현부분은 함수형에서 배울것이 많다

이 블로그도 객체지향 시점에서 쓰여진것 같은데 한번 살펴보자


1.시작

위 그림을 어렴풋이 보면 MVVM은 객체지향에서 뷰단의 최신 아키텍처로 알고있는데
이것과 리액트 컴포넌트와 hooks 그리고 상태를 연결해놨다
리액트는 주로 redux를 통해 elm에서 온 flux아키텍처를 사용하는것으로 알고있는데
이들을 서로 연결할 수 있다는것만 해도 신기하다

2. 두괄식 글의 요지

기존에 잘 알려진 UI 패턴들은 UI 디자인의 복잡한 문제를 해결하는 데 효과가 입증되었음에도 불구하고 프런트엔드 개발 세계에서는 잘 활용되지 않는 경우가 많습니다. 이 글에서는 리팩토링의 여정을 보여주는 코드 예시를 통해 기존 UI 구축 패턴을 리액트 세계에 적용하는 방법을 살펴보고 그 이점을 보여드립니다. 특히 레이어링 아키텍처가 리액트 애플리케이션의 개선된 응답성과 향후 변경사항을 반영하는 측면에서 어떻게 도움이 될 수 있는지에 중점을 둡니다.

3. 체계적이지 못한 구조

종종 사람들은 애플리케이션을 작동시키기 위해 여러 가지를 리액트 컴포넌트나 훅에 짜깁기하는 경우가 많습니다. 애플리케이션의 규모가 작거나 비즈니스 로직이 거의 없는 경우엔 이러한 덜 체계적인 구조는 큰 문제가 되지 않습니다. 하지만 대부분의 경우 더 많은 비즈니스 로직이 프런트엔드로 이동함에 따라 이런 짜깁기된 컴포넌트는 문제를 드러냅니다. 더 자세히 말하자면 이러한 유형의 코드를 이해하기 위한 노력이 상대적으로 많이 들고 코드 수정에 대한 위험도 증가하게 됩니다.

4. 두괄식 글의 요지 2

이 글에서는 "리액트 애플리케이션"을 리액트를 뷰로만 사용하는 일반 애플리케이션으로 재구성하는 데 사용할 수 있는 몇 가지 패턴과 기법에 대해 설명하고자 합니다(뷰는 큰 노력 없이 다른 뷰 라이브러리로 바꿀 수도 있습니다).

5. 패턴과 기법을 적용하는데 중요한점

여기서 중요한 점은 애플리케이션 내에서 코드의 각 부분이 어떤 역할을 하는지 분석해야 한다는 것입니다(같은 파일에 담겨 있을 수도 있습니다). 뷰와 뷰와 관련되지 않은 로직을 분리하고, 뷰와 관련되지 않은 로직을 책임에 따라 더욱 세분화하여 적재적소에 배치해야 있어야 합니다.

이렇게 분리하면 표면의 뷰에 크게 신경 쓰지 않고도 기본 도메인 로직을 변경하거나 그 반대도 가능합니다. 또한 도메인 로직이 다른 부분과 결합되지 않기 때문에 보다 쉽게 다른 곳에서 재사용 될 수 있습니다.

6. 리액트는 뷰를 구축하기위한 소박한 라이브러리다

사용자 인터페이스를 구축하기 위한 자바스크립트 라이브러리
-- 리액트 홈페이지

많은 사람들은 1데이터를 불러오고 2로직을 재구성하는 작업3데이터가 소비되는 곳에서 작성하고 있습니다. 예를 들어 리액트 컴포넌트 안의 렌더링 바로 위 useEffect 블록에서 데이터를 불러오거나 서버 측에서 응답을 받은 후 데이터 매핑 및 변환을 수행합니다.

useEffect(() => {
  fetch("https://address.service/api")
    .then((res) => res.json())
    .then((data) => {
      const addresses = data.map((item) => ({
        street: item.streetName,
        address: item.streetAddress,
        postcode: item.postCode,
      }));

      setAddresses(addresses);
    });
}, []);

프런트엔드 세계에는 아직 보편적인 표준이 없기 때문일 수도 있고, 단순히 잘못된 프로그래밍 습관일 수도 있습니다. 프런트엔드 애플리케이션이라고 해서 일반 소프트웨어 애플리케이션과 다르게 취급되어서는 안됩니다. 프런트엔드 세계에서도 여전히 코드 구조를 정리하기 위해 관심사를 분리합니다. 또한 입증된 모든 유용한 디자인 패턴도 적용할 수 있습니다.

7. 현실세계의 리액트 애플리케이션

많은 개발자들은 리액트의 단순함과 시용자 인터페이스를 데이터를 DOM에 매핑하는 순수한 함수로 표현할 수 있다는 아이디어에 깊은 인상을 받았습니다. 실제로 어느 정도는 그렇습니다.

하지만 백엔드에 네트워크 요청을 보내거나 페이지 탐색을 해야할 때 개발자는 이러한 부작용으로 인해 컴포넌트의 "순수성"이 떨어지며 어려움을 겪기 시작합니다. 또한 다양한 상태(글로벌 상태 또는 로컬 상태)를 고려하면서 상황은 빠르게 복잡해지고 사용자 인터페이스의 어두운 면이 드러나게 됩니다.

8. 사용자인터페이스 이외의 부분

리액트 자체는 사용자 인터페이스를 구축하기 위한 라이브러리일 뿐이므로 1계산이나 2비즈니스 로직을 어디에 넣어야 하는지에 대해서는 크게 신경 쓰지 않습니다. 또한 뷰 레이어 외에도 프런트엔트 애플리케이션에는 다른 부분도 있습니다. 애플리케이션을 작동시키려면 3라우터, 로컬 스토리지, 다양한 수준의 캐시, 네트워크 요청, 서드파티 통합, 서드파티 로그인, 보안, 로깅, 성능 튜닝 등이 필요합니다.

이 모든 추가적인 맥락을 고려할 때, 모든 것을 리액트 컴포넌트나 훅에 구겨 넣는 것은 좋은 생각이 아닙니다. 그 이유는 한 곳에 여러 개념을 뒤섞으면 일반적으로 더 많은 혼란을 야기하기 때문입니다. 처음에는 컴포넌트가 주문 상태에 대한 네트워크 요청을 한 후 문자열에서 선행 공백을 잘라낸 다음 다른 곳으로 이동하는 로직이 있다고 하면, 코드를 읽는 사람 입장에서는 계속해서 논리 흐름을 재설정하고 여러 세부 사항을 왔다 갔다 해야 합니다.

모든 코드를 컴포넌트에 넣는 것은 Todo나 단일 양식 애플리케이션과 같은 소규모 애플리케이션에서는 효과적일 수도 있습니다. 하지만 이러한 애플리케이션이 일정 수준에 도달하면 이를 이해하기 위해 상당한 노력이 필요하게 됩니다. 새로운 기능을 추가하거나 기존 결함을 수정하는 것은 말할 것도 없죠.

서로 다른 관심사를 구조를 가지고 파일이나 폴더로 분리한다면 애플리케이션을 이해하는 데 필요한 정신적 부하가 크게 줄어들 것입니다. 그리고 한 번에 한 가지에만 집중하면 됩니다. 다행히 웹 이전 시대로 거슬러 올라가면 이미 잘 입증된 몇 가지 패턴이 있습니다. 이러한 디자인 원칙과 패턴은 일반적인 사용자 인터페이스 문제를 해결하기 위해 탐구되고 논의되었지만 데스크톱 GUI 애플리케이션 컨텍스트에서 사용됩니다.

마틴 파울러는 뷰-모델-데이터의 계층화 개념을 잘 요약한 글을 작성했습니다.

전반적으로 저는 이 방식이 많은 애플리케이션에서 효과적인 모듈화 형태이며 제 스스로가 정기적으로 사용하고 추구하는 방식이라는 것을 알았습니다. 이 방법의 가장 큰 장점은 세 가지 주제(뷰, 모델, 데이터)를 비교적 독립적으로 생각할 수 있어 집중력을 높일 수 있다는 것입니다.
-- 마틴 파울러

9. 구체적인방법

모든 기능이 뭉쳐져 있는 하나의 컴포넌트를
분리하면서 발전시켜보자

1) 모든것이 합쳐져 있는 단일 컴포넌트

모든것이 하나의 컴포넌트에 있을때는
작은 프로그램이면 모를까 코드를 읽기도 수정하기도 복잡하다

2) 다중컴포넌트(ui - 상태 - 나머지의 세가지로 분리)

컴포넌트마다 할일을 두는것이다

  • 렌더링만 하는 component가 있고
  • 애플리케이션 로직을 다루는 컴포넌트가 있고
  • 가장위에 나머지 모든것을 다루는 컴포넌트가 있다

3) hook을 이용한 상태관리(상태와 사이드이펙트 분리)


상태관리를 하는부분과 렌더링을 하는 부분을 나누고
나머지 도메인로직이나 네트워크요청 dom조작 사용자와의 상호작용을 감지하는등의 이펙트를 훅으로 분리해서 관리한다

4) 비즈니스모델의 등장(hook에서 계산을 분리)

훅에서도 상태만 남기고
dom관련 작업이나 네트워크와 같은 사이드이펙트와
도메인로직과 같은 계산을 분리할 수 있다
이렇게 분할하면 도메인로직이 응집력있고 재사용할 수 있다
이런 도메인 객체들이 유사할 경우에 상속또는 다형성을 사용하면 더 깔끔할수도있다

5) 계층화된 프런트엔드 애플리케이션

ui에 속하지않고 데이터가 api를 호출한것인지
로컬스토리인지 캐시인지 신경쓸 필요가 없는 객체들이 존재하는데
이부분을 프레젠테이션 - 도메인 - 데이터 레이어로 분리 할 수도 있다

위의 진화 과정은 개략적인 개요이며, 코드를 어떻게 구조화해야 하는지 또는 최소한 어떤 방향으로 나아가야 하는지에 대해서는 어느 정도 감을 잡으셨을 것입니다. 그러나 이 이론을 실제 애플리케이션에 적용하기 전에 고려해야 할 세부 사항들이 있을 것입니다.

10. 예제 (결제기능 구현)

아주 단순한 온라인 주문 애플리케이션으로 시작해보겠습니다. 이 애플리케이션에서 사용자는 제품을 선택하여 주문에 추가한 다음 결제 방법 중 하나를 선택해서 주문을 진행할 수 있습니다.

결제방법의 옵션들은 서버에서 구성되며
다른국가의 사용자에게는 다른옵션이 표시될 수 있다
라디오버튼은 데이터기반이므로 백엔드서비스에서 가져온 데이터가 표시된다
발생할 수 있는 예외는 서버로부터 결제수단이 전달되지 않는것이다
이때는 아무것도 표시하지 않고 "현금결제"처리를 기본으로 한다
이런 결제 프로세스를 통해 Payment 컴포넌트를 만들었다고 가정해보자

1) 여러 기능이 혼합된 코드

//src/Payment.tsx…
export const Payment = ({ amount }: { amount: number }) => {
  const [paymentMethods, setPaymentMethods] = useState<LocalPaymentMethod[]>(
    []
  );

  useEffect(() => {
    const fetchPaymentMethods = async () => {
      const url = "https://online-ordering.com/api/payment-methods";

      const response = await fetch(url);
      const methods: RemotePaymentMethod[] = await response.json();

      if (methods.length > 0) {
        const extended: LocalPaymentMethod[] = methods.map((method) => ({
          provider: method.name,
          label: `Pay with ${method.name}`,
        }));
        extended.push({ provider: "cash", label: "Pay in cash" });
        setPaymentMethods(extended);
      } else {
        setPaymentMethods([]);
      }
    };

    fetchPaymentMethods();
  }, []);

  return (
    <div>
      <h3>Payment</h3>
      <div>
        {paymentMethods.map((method) => (
          <label key={method.provider}>
            <input
              type="radio"
              name="payment"
              value={method.provider}
              defaultChecked={method.provider === "cash"}
            />
            <span>{method.label}</span>
          </label>
        ))}
      </div>
      <button>${amount}</button>
    </div>
  );
};

위 코드는 매우 일반적인 코드입니다. 어디선가 시작하기 튜토리얼에서 본 적이 있을지도 모릅니다. 그리고 크게 나쁘지 않습니다. 하지만 위에서 언급했듯이 이 코드는 단일 컴포넌트에 여러 가지 기능이 혼합되어 있어 읽기 어렵습니다.

이 코드를 변경하기 위해서는 1네트워크 요청을 초기화하는 방법, 2데이터를 컴포넌트가 이해할 수 있는 로컬 형식으로 매핑하는 방법, 3각 결제 방법을 렌더링하는 방법, 4Payment 컴포넌트 자체의 렌더링 로직 등을 이해해야 합니다.

2) 뷰와 관련된 코드와 그렇지 않은 코드의 분리

// src/Payment.tsx…

const usePaymentMethods = () => {
  const [paymentMethods, setPaymentMethods] = useState<LocalPaymentMethod[]>(
    []
  );

  useEffect(() => {
    const fetchPaymentMethods = async () => {
      const url = "https://online-ordering.com/api/payment-methods";

      const response = await fetch(url);
      const methods: RemotePaymentMethod[] = await response.json();

      if (methods.length > 0) {
        const extended: LocalPaymentMethod[] = methods.map((method) => ({
          provider: method.name,
          label: `Pay with ${method.name}`,
        }));
        extended.push({ provider: "cash", label: "Pay in cash" });
        setPaymentMethods(extended);
      } else {
        setPaymentMethods([]);
      }
    };

    fetchPaymentMethods();
  }, []);

  return {
    paymentMethods,
  };
};
// src/Payment.tsx…

export const Payment = ({ amount }: { amount: number }) => {
  const { paymentMethods } = usePaymentMethods();

  return (
    <div>
      <h3>Payment</h3>
      <div>
        {paymentMethods.map((method) => (
          <label key={method.provider}>
            <input
              type="radio"
              name="payment"
              value={method.provider}
              defaultChecked={method.provider === "cash"}
            />
            <span>{method.label}</span>
          </label>
        ))}
      </div>
      <button>${amount}</button>
    </div>
  );
};

단순히 hook을 통해서 ui와 나머지를 분리했다

3) 하위 컴포넌트 추출을 통한 뷰 분할

//src/Payment.tsx…

const PaymentMethods = ({
  paymentMethods,
}: {
  paymentMethods: LocalPaymentMethod[];
}) => (
  <>
    {paymentMethods.map((method) => (
      <label key={method.provider}>
        <input
          type="radio"
          name="payment"
          value={method.provider}
          defaultChecked={method.provider === "cash"}
        />
        <span>{method.label}</span>
      </label>
    ))}
  </>
);
/// src/Payment.tsx…

export const Payment = ({ amount }: { amount: number }) => {
  const { paymentMethods } = usePaymentMethods();

  return (
    <div>
      <h3>Payment</h3>
      <PaymentMethods paymentMethods={paymentMethods} />
      <button>${amount}</button>
    </div>
  );
};

하위컴포넌트 PaymentMethods를 추출했다

4) 로직을 캡슐화 하는 데이터 모델링

ui만 존재하는 PaymentMethods에는 결제수단이 디폴트로 체크되어있어야 하는지
확인하는 로직이 있는데 이것은 로직의 누수로 간주될 수 있으며
여러곳에 흩어지게 되면 수정하기는 더욱어려워진다

// src/PaymentMethod.ts…

class PaymentMethod {
  private remotePaymentMethod: RemotePaymentMethod;

  constructor(remotePaymentMethod: RemotePaymentMethod) {
    this.remotePaymentMethod = remotePaymentMethod;
  }

  get provider() {
    return this.remotePaymentMethod.name;
  }

  get label() {
    if (this.provider === "cash") {
      return `Pay in ${this.provider}`;
    }
    return `Pay with ${this.provider}`;
  }

  get isDefaultMethod() {
    return this.provider === "cash";
  }
}
const payInCash = new PaymentMethod({ name: "cash" });

이 클래스를 사용하면 현금 결제 방법을 디폴트로 정의할 수 있다

// src/usePaymentMethods.ts…

const convertPaymentMethods = (methods: RemotePaymentMethod[]) => {
  if (methods.length === 0) {
    return [];
  }

  const extended: PaymentMethod[] = methods.map(
    (method) => new PaymentMethod(method)
  );
  extended.push(payInCash);

  return extended;
};

convertPaymentMethods라는 작은함수를 추출할 수 있다

// src/PaymentMethods.tsx…

export const PaymentMethods = ({ options }: { options: PaymentMethod[] }) => (
  <>
    {options.map((method) => (
      <label key={method.provider}>
        <input
          type="radio"
          name="payment"
          value={method.provider}
          defaultChecked={method.isDefaultMethod}
        />
        <span>{method.label}</span>
      </label>
    ))}
  </>
);
// src/PaymentMethods.tsx…

export const PaymentMethods = ({ options }: { options: PaymentMethod[] }) => (
  <>
    {options.map((method) => (
      <label key={method.provider}>
        <input
          type="radio"
          name="payment"
          value={method.provider}
          defaultChecked={method.isDefaultMethod}
        />
        <span>{method.label}</span>
      </label>
    ))}
  </>
);

이제 Payment컴포넌트는 함께 작동하는 여러개의 작은 부분으로 리팩토링 되었다

새로운 구조의 이점들

  • 클래스로 결제 수단과 관련된 모든 로직을 캡슐화 할 수 있습니다. 이는 도메인 객체이며 UI와 관련된 정보를 포함하지 않습니다. 따라서 여기서 로직을 테스트하고 잠재적으로 수정하는 것이 뷰에 포함되어 있을 때보다 훨씬 쉽습니다.
  • 새로 추출된 컴포넌트인 PaymentMethods는 순수 함수이며 도메인 객체 배열에만 의존하므로 다른 곳에서 테스트하고 재사용하기가 매우 쉽습니다. onSelect 콜백을 전달해야 할 수도 있지만, 이 경우도 순수 함수이므로 외부의 상태를 건드릴 필요가 없습니다.
  • 각 기능의 부분이 명확합니다. 새로운 요구 사항이 발생하면 모든 코드를 읽지 않고도 적절한 위치로 이동할 수 있습니다.

11. 새로운 요구사항

애플리케이션에 몇 가지 추가 변경 사항을 통해 이론을 살펴보도록 하겠습니다. 새로운 요구 사항은 사용자가 주문과 함께 팁으로 소액을 자선 단체에 기부할 수 있는 옵션을 제공하고자 하는 것입니다.

뒤에 하나더 새로운 요구사항을 붙여서 도메인객체를 더 만들어서
예시로 보여주는 부분과 산탄총수술이라고 해서 내가 기능을 추가할때
여러군데를 수정해야 하는현상등이 설명되어있었다

근데 한번에 이해하기는 너무많고
일단 모든게 들어있는 컴포넌트에서
ui를 분리해내고
거기서 hooks를 분리해내고
hooks에서 사이드이펙트와 도메인로직을 분리해내서
도메인로직(프론트 애플리케이션의 도메인 로직)은 앱에서 쓰이는 논리니까 잘 뭉쳐서 관리해야한다

이정도 알게된것 적용하는데도 많은 시간이 필요할것같다
이부분 먼저 적용해보자


다른 리액트앱 모듈화 관련 글


이것도 직관적이고 좋은것 같다

레이어의 특징은 다음과 같습니다.

  • 프레젠테이션: 이 레이어는 기본적으로 UI 구성 요소로 구성됩니다. Vue의 경우 Vue SFcs입니다. React의 경우 React 구성 요소입니다. Svelte의 경우 Svelte SFC입니다. 등등. 프리젠테이션 계층은 애플리케이션 계층에 직접 연결됩니다.
  • 애플리케이션: 이 계층에는 애플리케이션 로직이 포함됩니다. 도메인 계층과 인프라 계층을 알고 있습니다. 이 아키텍처에서 이 계층은 React의 React Hooks 또는 Vue 3의 Vue "Hooks"를 통해 구현됩니다.
  • 도메인: 이 계층은 도메인/비즈니스 로직을 위한 것입니다. 비즈니스 로직만 도메인 계층에 있으므로 여기에는 프레임워크/라이브러리가 전혀 없는 순수한 JavaScript/TypeScript 코드만 있습니다.
  • 인프라: 이 계층은 외부 세계와의 통신(요청 전송/응답 수신) 및 로컬 데이터 저장을 담당합니다. 다음은 이 계층에 대한 실제 응용 프로그램에서 사용할 라이브러리의 예입니다.
    HTTP 요청/응답: Axios, Fetch API, Apollo Client 등
    Store(상태 관리): Vuex, Redux, MobX, Valtio 등

다음 특성은 아키텍처의 위 다이어그램에서 참조됩니다.

  • UI 라이브러리/프레임워크를 교체하면 프레젠테이션 및 애플리케이션 계층만 영향을 받습니다.
  • 인프라 계층에는 스토어의 구현 세부 사항을 교체할 때(예: Redux를 Vuex로 교체) 스토어 자체만 영향을 받을 수 있도록 Facade가 있습니다. Axios를 Fetch API로 교체하거나 그 반대로 교체하는 경우에도 마찬가지입니다.
  • 응용 프로그램 계층은 상점 또는 HTTP 클라이언트의 구현 세부 사항에 대해 알지 못합니다. 즉, Redux/Vuex/MobX에서 React를 분리했습니다. 스토어의 로직도 React뿐만 아니라 Vue나 Svelte에서도 사용할 수 있을 만큼 충분히 일반적입니다.
  • 비즈니스 논리가 변경되면 이에 따라 도메인 계층을 수정해야 하며 이는 아키텍처의 다른 부분에 영향을 미칩니다.
    이 아키텍처에서 더 흥미로운 점은 이를 더 모듈화할 수 있다는 것입니다.

결론


지금까지 살펴본것들을 통해 리덕스에서 추천하는 폴더구조를 다시 보면
app폴더가 infrastructure부분을 맡고있다
common폴더에 hook이 들어있다
features는 기능의 단위로 나눈것인데 전역으로 사용되는 slice의 단위로 나누고있다
domain은 따로 없는데 내생각에 그 이유는 원래 도메인이 백엔드의 것이기 때문이다
실제로 도메인로직은 프론트까지 오면안되지만 실제로는 그렇지 않다
걍 app폴더에 넣으면 될것같다

리덕스의 추천폴더구조는 완전 실용적으로 나눈것 같다
사실 모듈안에 폴더명을 application이라고 짓는것보다
실제로 그 폴더명의 분류를 나누게 된 맥락은 잃겠지만 이것을 기억할수만 있다면
hooks라고 짓는것이 훨씬 편할것이다

원문
https://velog.io/@eunbinn/modularizing-react-apps

참고자료
https://dev.to/itshugo/a-different-approach-to-frontend-architecture-38d4

profile
좋은 프로그램을 만들 수 있는 사람이 되기 위해 꾸준히 노력합니다

0개의 댓글