[번역] 잘 알려진 UI 패턴을 사용하여 리액트 애플리케이션 모듈화하기

eunbinn·2023년 2월 22일
186

FrontEnd 번역

목록 보기
17/31
post-thumbnail

출처: https://martinfowler.com/articles/modularizing-react-apps.html

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

리액트 애플리케이션이라고 언급하긴 했지만, 사실 리액트 애플리케이션이라는 것은 존재하지 않습니다. 자바스크립트나 타입스크립트로 작성된 프론트엔드 애플리케이션이 뷰를 나타내기 위해 리액트를 사용했을 뿐이죠. 이들을 리액트 애플리케이션이라고 부르는 것은 Java EE 애플리케이션을 JSP 애플리케이션이라고 부르지 않는 것처럼 적절하지 않다고 생각합니다.

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

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

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

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

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

리액트의 핵심은 사용자의 인터페이스를 구축하는 데 도움이 되는 라이브러리(프레임워크가 아닙니다)라는 사실을 잊어버리기 쉽습니다.

이러한 맥락에서 리액트는 웹 개발의 특정 측면, 즉 UI 컴포넌트에 집중하고 애플리케이션의 디자인 및 전체 구조의 측면에서 충분한 자유를 제공하는 자바스크립트 라이브러리라는 점을 강조합니다.

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

꽤 직관적으로 쓰여져 있습니다만, 많은 사람들은 데이터를 불러오고 로직을 재구성하는 작업을 데이터가 소비되는 곳에서 작성하고 있습니다. 예를 들어 리액트 컴포넌트 안의 렌더링 바로 위 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);
    });
}, []);

// 렌더링 로직...

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

현실 세계의 리액트 애플리케이션에 오신 것을 환영합니다

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

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

사용자 인터페이스 외에도

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

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

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

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

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

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

계층화된 아키텍쳐는 대규모 GUI 애플리케이션의 문제를 해결하기 위해 사용되어 왔으며, 이러한 확립된 프런트엔드 구성 패턴은 "리액트 애플리케이션"에서도 사용할 수 있습니다.

리액트 애플리케이션의 진화

소규모 또는 일회성 프로젝트의 경우 모든 로직이 리액트 컴포넌트 안에 작성되어 있을 수 있습니다. 다 해서 하나 혹은 고작 몇 개의 컴포넌트만 표시됩니다. 코드는 페이지를 "동적"으로 만드는 데 사용되는 일부 변수나 상태만 제외하면 HTML과 매우 유사한 형태를 가집니다. 일부는 컴포넌트가 렌더링된 후 useEffect에서 데이터를 가져오기 위한 요청을 보내기도 합니다.

애플리케이션이 성장함에 따라 코드베이스에 추가되는 코드가 점점 더 많아집니다. 이를 정리할 적절한 방법이 없다면 코드베이스는 곧 유지보수 하기 어려운 상태가 되어 개발자가 코드를 읽는 데 더 많은 시간이 필요하므로 작은 기능을 추가하는 데도 많은 시간이 소요됩니다.

따라서 유지보수 문제를 완화하는 데 도움이 될만한 몇 가지 단계를 나열해 보겠습니다. 일반적으로는 조금 더 많은 노력이 필요하겠지만 애플리케이션에 일정한 구조를 갖춘다는 것에도 충분한 가치가 있습니다. 확장 가능한 프런트엔드 애플리케이션을 구축하기 위한 이러한 단계를 간단히 살펴보겠습니다.

단일 컴포넌트 애플리케이션

거의 하나의 컴포넌트로 구성된 애플리케이션이라고 할 수 있습니다.

하지만 곧 하나의 컴포넌트에서 무슨 일이 일어나고 있는지 읽는 데만 많은 시간이 필요하다는 것을 깨닫게 됩니다. 예를 들어 어떠한 목록을 순회하며 각 항목을 생성하는 로직이 있습니다. 또 다른 로직과는 별개로 몇 가지 설정 코드로 서드파티 컴포넌트를 사용하기 위한 로직도 있습니다.

다중 컴포넌트 애플리케이션

컴포넌트를 여러 개의 컴포넌트로 분할하기로 결정합니다. 결과 HTML에서 일어나는 일을 반영하는 구조는 좋은 아이디어이며, 한 번에 하나의 컴포넌트에 집중하는 데에 도움이 됩니다.

애플리케이션이 성장함에 따라 뷰 외에도 네트워크 요청을 보내고, 뷰에서 사용할 수 있도록 데이터를 다른 형태로 변환하고, 데이터를 수집하여 서버로 다시 전송하는 등의 작업이 필요합니다. 이러한 코드가 컴포넌트 내부에 있는 것은 사용자 인터페이스에 관한 것이 아니기 때문에 적절하지 않습니다. 또한 일부 컴포넌트는 너무 많은 내부 상태를 갖게 됩니다.

훅을 이용한 상태 관리

이 로직은 별도의 위치로 분할하는 것이 좋습니다. 다행히 리액트에서는 자신만의 훅을 정의할 수 있습니다. 이는 상태가 변경될 때마다 상태와 로직을 공유할 수 있는 좋은 방법입니다.

멋지네요! 단일 컴포넌트 애플리케이션에서 추출한 여러 컴포넌트가 있고, 순수한 프레젠테이셔널 컴포넌트와 다른 컴포넌트의 상태를 관리할 수 있는 재사용 가능한 훅이 있습니다. 유일한 문제는 훅에서 사이드이펙트와 상태 관리를 제외한 일부 로직은 상태 관리보다는 순수한 계산에 해당한다는 것입니다.

비즈니스 모델의 등장

따라서 이 로직을 다른 곳으로 추출하면 많은 이점을 얻을 수 있다는 것을 알게 되었습니다. 예를 들어, 이렇게 분할하면 로직이 응집력있고 어떤 뷰에서도 독립적일 수 있습니다. 그 후 몇 가지 도메인 객체를 추출합니다.

이러한 간단한 객체들은 데이터 매핑(한 형식에서 다른 형식으로)을 처리하고, 필요에 따라 null을 확인하며 폴백 값을 사용할 수 있습니다. 또한 이러한 도메인 객체가 증가함에 따라 더 깔끔하게 만들기 위해 상속 또는 다형성이 필요하다는 것을 알게 됩니다. 따라서 다른 곳에서 유용하다고 생각했던 많은 디자인 패턴을 여기 프런트엔드 애플리케이션에 적용했습니다.

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

애플리케이션이 계속 진화하면서 몇 가지 패턴이 있다는 사실을 깨닫게 됩니다. 어떠한 사용자 인터페이스에도 속하지 않고, 기본 데이터가 원격 서비스에서 온 것인지, 로컬 스토리지에서 온 것인지, 캐시에서 온 것인지에 대해서도 신경 쓰지 않는 객체들이 많다는 것입니다. 이에 이들을 다른 계층으로 분할하려합니다. 다음은 프레젠테이션 도메인 데이터 레이어 분할에 대한 자세한 설명입니다.

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

다음 섹션에서는 실제 프로젝트에서 추출한 기능을 예시로, 대규모 프런트엔드 애플리케이션에 유용하다고 생각되는 모든 패턴과 디자인 원칙을 보여드리겠습니다.

결제 기능 구현 예

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

결제 방법의 옵션들은 서버 측에서 구성되며, 다른 국가의 사용자들에게는 각각 다른 옵션이 표시될 수 있습니다. 예를 들어 Apple Pay는 일부 국가에서만 표시될 수 있죠. 라디오 버튼은 데이터 기반이므로 백엔드 서비스에서 가져온 데이터가 표시됩니다. 한 가지 발생할 수 있는 예외사항은 서버로부터 결제 수단이 하나도 전달되지 않는 것입니다. 그렇다면 아무것도 표시하지 않고 "현금 결제" 처리를 기본으로 합니다.

간단하게 설명하기 위해 실제 결제 프로세스는 생략하고 Payment 컴포넌트에 집중하겠습니다. 리액트 헬로 월드 문서를 읽고 몇 번의 스택오버플로우 검색을 통해 아래와 같은 코드를 만들었다고 가정해봅시다.

//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>
  );
};

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

초기 구현의 문제

가장 먼저 다루고 싶은 문제는 컴포넌트가 얼마나 많은 일을 처리하는지 입니다. 즉, Payment는 여러 가지를 다루고 있기 때문에 코드를 읽으면서 머릿속으로 계속해서 컨텍스트를 바꾸게 만들고 이는 코드를 읽기 어렵게 만듭니다.

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

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>
  );
};

이 간단한 예제에서는 아직은 큰 문제가 아닙니다. 하지만 코드가 점점 더 커지고 복잡해짐에 따라 리팩토링이 필요할 것입니다.

뷰와 관련된 코드와 뷰와 관련되지 않은 코드는 따로 분리하는 것이 좋습니다. 일반적으로 뷰와 관련된 로직이 관련되지 않은 로직보다 더 자주 변경되기 때문입니다. 또한 애플리케이션의 서로 다른 측면을 다루기 때문에 새로운 기능을 구현할 때 훨씬 더 관리하기 쉬운 특정 독립된 모듈에 집중할 수 있게됩니다.

뷰와 관련된 코드와 뷰와 관련되지 않은 코드의 분리

리액트에서는 커스텀 훅을 사용해 컴포넌트의 상태를 유지하면서 컴포넌트 자체는 상태를 갖지 않도록 유지할 수 있습니다. 함수 추출(Extract Function)을 사용해서 usePaymentMethods 라는 함수를 만들 수 있습니다(접두사 use는 함수가 훅이고 그 안에서 일부 상태를 처리한다는 것을 나타내는 리액트의 규칙입니다).

// 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,
  };
};

이는 내부 상태인 paymentMethods 배열(LocalPaymentMethod타입)을 반환하여 렌더링에 사용할 수 있습니다. 따라서 Payment의 로직을 아래와 같이 단순화 할 수 있습니다.

// 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>
  );
};

이는 Payment 컴포넌트의 어려움을 덜어주는 데 도움이 됩니다. 하지만 paymentMethods를 순회하는 블록에는 아직 개념이 누락된 것 같습니다. 이 블록은 자체 컴포넌트가 필요합니다. 이상적으로는 각 컴포넌트는 한 가지에만 집중하는 것이 좋습니다.

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

만약 컴포넌트를 순수 함수, 즉 입력이 주어지면 출력이 확실한 함수로 만들 수 있다면 테스트를 작성하고 코드를 이해하며 다른 곳에서 컴포넌트를 재사용하는 데 많은 도움이 될 것입니다. 결국 컴포넌트가 작을수록 재사용될 가능성이 높아집니다.

또 한 번 함수 추출("컴포넌트 추출" 이라고 해야할 지도 모르겠습니다만, 어쨌든 리액트에서 컴포넌트도 함수입니다)을 사용할 수 있습니다.

//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>
    ))}
  </>
);

Payment 컴포넌트는 PaymentMethods를 바로 사용할 수 있으므로 아래와 같이 단순화할 수 있습니다.

/// 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는 상태가 없는 순수 함수(순수 컴포넌트)라는 점에 유의하세요. 단순히 문자열을 포맷팅하는 함수입니다.

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

지금까지의 변경 사항들은 모두, 뷰와 뷰가 아닌 것을 분리한 것입니다. 모두 잘 동작합니다. 훅은 데이터를 가져오고 데이터 형식을 재구성합니다. PaymentPaymentMethods 는 모두 비교적 작고 이해하기도 쉽습니다.

하지만 자세히 살펴보면 여전히 개선의 여지가 있습니다. 우선, 순수 함수 컴포넌트인 PaymentMethods에는 결제 수단이 디폴트로 체크되어 있어야 하는지 확인하는 약간의 로직이 있습니다.

// 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…

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,
  };
};

methods.map 내부의 익명 함수는 형태 변환을 수행하고 있기 때문에 해당 로직에서 method.provider === "cash" 또한 클래스로 추출할 수 있습니다.

이로서 데이터와 동작을 한 곳에 집중시킨 PaymentMethod 클래스를 만들 수 있습니다.

// 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" });

그리고 서버에서 결제 수단을 가져온 후 이를 변환하는 동안 PaymentMethod 객체를 만들 수 있습니다. 또는 convertPaymentMethods라는 작은 함수를 추출할 수도 있습니다.

// 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;
};

또한 PaymentMethods 컴포넌트에서는 method.provider === "cash"를 더이상 사용하지 않고 getter를 호출할 수 있습니다.

// 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 콜백을 전달해야 할 수도 있지만, 이 경우도 순수 함수이므로 외부의 상태를 건드릴 필요가 없습니다.
  • 각 기능의 부분이 명확합니다. 새로운 요구 사항이 발생하면 모든 코드를 읽지 않고도 적절한 위치로 이동할 수 있습니다.

많은 패턴을 추출할 수 있도록 이 글의 예제를 더욱 복잡하게 만들어보겠습니다. 모든 패턴과 원칙들은 코드의 수정을 단순화하기 위한 것입니다.

새로운 요구 사항: 자선 단체에 기부

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

예를 들어, 주문 금액이 $19.80인 경우 $0.20달러를 기부할 것인지 묻습니다. 사용자가 기부에 동의하면 버튼에 총 금액이 표시됩니다.

바로 변경하기 전에 현재 코드 구조를 간단히 살펴봅시다. 저는 폴더가 커졌을 때 쉽게 탐색할 수 있도록 여러 파트를 폴더에 분리하는 것을 선호합니다.

  src
  ├── App.tsx
  ├── components
  │   ├── Payment.tsx
  │   └── PaymentMethods.tsx
  ├── hooks
  │   └── usePaymentMethods.ts
  ├── models
  │   └── PaymentMethod.ts
  └── types.ts

App.tsx가 기본 진입점이고 App.tsx에서는 Payment 컴포넌트를 사용하며 Payment는 다양한 결제 옵션을 렌더링하기 위해 PaymentMethods를 사용합니다. 훅인 usePaymentMethods는 서버에서 데이터를 가져온 다음 레이블과 isDefaultChecked 플래그를 보유하는 PaymentMethod 도메인 객체로 변환하는 역할을 합니다.

내부 상태: 기부 동의 여부

Payment에서 이 변경 사항을 반영하려면 사용자가 페이지에서 체크박스를 선택했는지 여부를 나타내는 boolean의 ageeToDonate 상태가 필요합니다.

// src/Payment.tsx…

const [agreeToDonate, setAgreeToDonate] = useState<boolean>(false);

const { total, tip } = useMemo(
  () => ({
    total: agreeToDonate ? Math.floor(amount + 1) : amount,
    tip: parseFloat((Math.floor(amount + 1) - amount).toPrecision(10)),
  }),
  [amount, agreeToDonate]
);

Math.floor 함수는 사용자가 기부 동의를 선택했을 때 정확한 금액을 금액을 확인할 수 있도록 숫자를 반올림하고, 반올림된 값과 원래 금액의 차액이 팁에 할당됩니다.

그리고 뷰는 체크박스와 간단한 설명으로 구성됩니다.

// src/Payment.tsx…

return (
  <div>
    <h3>Payment</h3>
    <PaymentMethods options={paymentMethods} />
    <div>
      <label>
        <input
          type="checkbox"
          onChange={handleChange}
          checked={agreeToDonate}
        />
        <p>
          {agreeToDonate
            ? "Thanks for your donation."
            : `I would like to donate $${tip} to charity.`}
        </p>
      </label>
    </div>
    <button>${total}</button>
  </div>
);

이러한 새로운 변경 사항으로 인해 코드는 다시 여러 작업을 수행하기 시작합니다. 뷰와 관련된 코드와 관련되지 않은 코드가 섞여 있을 수 있으므로 항상 주의를 기울여야 합니다. 불필요하게 섞였다면, 이를 분리하는 방법을 찾아볼 수 있습니다.

이것은 정해진 규칙은 아닙니다. 작고 응집력 있는 컴포넌트는 한곳에 모여있어 전체적인 동작을 이해하기 위해 여러 곳을 살펴볼 필요가 없도록 하는 것이 좋습니다. 일반적으로 컴포넌트 파일이 너무 커져서 이해하기 어려워지는 것을 주의해야 합니다.

훅 추출하기

여기에선 팁과 금액을 계산하는 객체가 필요하며 사용자가 마음이 바뀔 때마다 객체는 업데이트된 금액과 팁을 반환해야 합니다.

따라서 아래와 같은 객체가 필요합니다.

  • 원래 금액을 입력으로 받습니다
  • agreeToDonate가 변경될 때마다 totaltip을 반환합니다.

커스텀 훅을 사용하기에 최적이라고 생각되지 않으신가요?

// src/hooks/useRoundUp.ts…

export const useRoundUp = (amount: number) => {
  const [agreeToDonate, setAgreeToDonate] = useState<boolean>(false);

  const { total, tip } = useMemo(
    () => ({
      total: agreeToDonate ? Math.floor(amount + 1) : amount,
      tip: parseFloat((Math.floor(amount + 1) - amount).toPrecision(10)),
    }),
    [amount, agreeToDonate]
  );

  const updateAgreeToDonate = () => {
    setAgreeToDonate((agreeToDonate) => !agreeToDonate);
  };

  return {
    total,
    tip,
    agreeToDonate,
    updateAgreeToDonate,
  };
};

뷰에서는 초기 amount값과 함께 이 훅을 호출하고 외부에서 접근 가능한 상태들을 사용할 수 있습니다. updateAgreeToDonate 함수는 훅 내부에 정의된 값을 업데이트하여 리렌더링이 일어나게 할 수 있습니다.

// src/components/Payment.tsx…

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

  const { total, tip, agreeToDonate, updateAgreeToDonate } = useRoundUp(amount);

  return (
    <div>
      <h3>Payment</h3>
      <PaymentMethods options={paymentMethods} />
      <div>
        <label>
          <input
            type="checkbox"
            onChange={updateAgreeToDonate}
            checked={agreeToDonate}
          />
          <p>{formatCheckboxLabel(agreeToDonate, tip)}</p>
        </label>
      </div>
      <button>${total}</button>
    </div>
  );
};

메세지 포맷팅 부분도 헬퍼 함수 formatCheckboxLabel로 추출하여 컴포넌트 코드를 간소화할 수 있습니다.

const formatCheckboxLabel = (agreeToDonate: boolean, tip: number) => {
  return agreeToDonate
    ? "Thanks for your donation."
    : `I would like to donate $${tip} to charity.`;
};

Payment 컴포넌트는 이제 꽤 단순해졌습니다. 이제 상태들은 모두 useRoundUp 훅에서 관리됩니다.

훅은 체크박스 변경 이벤트와 같이 UI에서 어떤 변화가 발생할 때마다 상태를 변경시키는 뷰 뒤의 상태 머신이라고 생각할 수 있습니다. 이벤트는 상태 머신으로 전송되어 새 상태를 생성하고, 새 상태는 리렌더링을 유발합니다.

여기서 적용된 패턴은 상태 관리를 컴포넌트에서 벗어나 presentational 함수로 만들어야 한다는 것입니다(따라서 작은 유틸리티 함수처럼 쉽게 테스트하고 재사용할 수 있습니다). 리액트 훅은 여러 컴포넌트에서 재사용 가능한 로직을 공유하기 위해 설계되었지만, 컴포넌트에서 렌더링에 집중하고 상태와 데이터를 훅에 보관하는 데에도 도움이 되기 때문에 한 번만 사용하는 경우에도 유용하다고 생각합니다.

기부 체크박스가 보다 독립적이 되어 자체 순수 함수 컴포넌트로도 옮길 수 있습니다.

// src/components/DonationCheckbox.tsx…

const DonationCheckbox = ({
  onChange,
  checked,
  content,
}: DonationCheckboxProps) => {
  return (
    <div>
      <label>
        <input type="checkbox" onChange={onChange} checked={checked} />
        <p>{content}</p>
      </label>
    </div>
  );
};

이제 Payment는 리액트의 선언적 UI 덕분에 조그마한 HTML 조각처럼 코드를 읽는 것이 매우 직관적입니다.

// src/components/Payment.tsx…

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

  const { total, tip, agreeToDonate, updateAgreeToDonate } = useRoundUp(amount);

  return (
    <div>
      <h3>Payment</h3>
      <PaymentMethods options={paymentMethods} />
      <DonationCheckbox
        onChange={updateAgreeToDonate}
        checked={agreeToDonate}
        content={formatCheckboxLabel(agreeToDonate, tip)}
      />
      <button>${total}</button>
    </div>
  );
};

이 시점에서 코드 구조는 아래 이미지와 비슷해지기 시작합니다. 어떻게 서로 다른 파트가 각자의 작업에 집중하고 함께 모여 프로세스를 작동시키는지에 주목하세요.

반올림 로직에 대한 추가 업데이트

반올림 로직은 나쁘지 않아 보입니다만, 다른 국가로 사업이 확장된다고 했을 때 새로운 요구 사항이 생깁니다. 일본 시장에서는 0.1엔이 기부금으로 너무 작기 때문에 동일한 로직이 적용되지 않으므로 일본 통화의 경우 가장 가까운 100으로 반올림해야 합니다. 그리고 덴마크의 경우는 가장 가까운 10의 자리로 반올림해야 합니다.

쉽게 해결할 수 있을 것 같습니다. Payment 컴포넌트에 countryCode 만 전달하면 되겠죠?

<Payment amount={3312} countryCode="JP" />

모든 로직이 useRoundUp 훅에 정의되어 있으므로 국가 코드를 훅으로 전달합니다.

const useRoundUp = (amount: number, countryCode: string) => {
  //...

  const { total, tip } = useMemo(
    () => ({
      total: agreeToDonate
        ? countryCode === "JP"
          ? Math.floor(amount / 100 + 1) * 100
          : Math.floor(amount + 1)
        : amount,
      //...
    }),
    [amount, agreeToDonate, countryCode]
  );
  //...
};

useEffect 블록에 countryCode가 새로 추가됨에 따라 if-else 가 동작할 수 있습니다. 또, getTipMessage의 경우 달러 기호가 아닌 국가별로 다른 통화 기호를 사용할 수 있으므로 동일한 if-else 검사가 필요합니다.

const formatCheckboxLabel = (
  agreeToDonate: boolean,
  tip: number,
  countryCode: string
) => {
  const currencySign = countryCode === "JP" ? "¥" : "$";

  return agreeToDonate
    ? "Thanks for your donation."
    : `I would like to donate ${currencySign}${tip} to charity.`;
};

마지막으로 변경해야 할 것은 버튼의 통화 기호입니다.

<button>
  {countryCode === "JP" ? "¥" : "$"}
  {total}
</button>

산탄총 수술(Shotgun Surgery) 문제

이 시나리오는 많은 곳에서 볼 수 있는 유명한 "산탄총 수술" 문제입니다 (꼭 React 애플리케이션에 한정된 문제는 아닙니다). 버그를 수정하거나 새로운 기능을 추가하기 위해 코드를 수정할 때마다 여러 모듈을 건드려야 한다면 산탄총 수술 문제가 있다고 할 수 있습니다. 특히 테스트가 불충분할 때 이렇게 많은 변경을 하면 실수를 저지르기 쉽습니다.

위 그림에서 같은 색으로 표시된 선은 여러 파일을 가로지르는 국가 코드 확인의 분기를 나타냅니다. 뷰에서는 국가 코드에 따라 별도의 작업을 수행해야 하고, 훅에서도 관련 작업이 필요합니다. 새로운 국가 코드를 추가해야 할 때마다 이 모든 부분을 건드려야 하죠.

예를 들어 덴마크에 비즈니스 확장을 고려한다면 다음과 같이 여러 곳에 코드를 추가해야 합니다.

const currencySignMap = {
  JP: "¥",
  DK: "Kr.",
  AU: "$",
};

const getCurrencySign = (countryCode: CountryCode) =>
  currencySignMap[countryCode];

분기가 여러 곳에 흩어져 있는 문제에 대해 한 가지 가능한 해결책은 다형성을 사용해서 switch 케이스나 table 조회 로직을 대체하는 것입니다. 클래스 추출을 활용해서 조건부를 다형성으로 대체할 수 있습니다.

다형성으로 구출하기

가장 먼저 할 수 있는 일은 모든 변화를 검토하여 클래스로 추출해야 하는 것이 무엇인지 확인하는 것입니다. 예를 들어 국가마다 통화 기호가 다르므로 getCurrencySign을 공용 인터페이스로 추출할 수 있습니다. 또한 국가마다 반올림 알고리즘이 다를 수 있으므로 getRoundUpAmountgetTip을 인터페이스로 가져올 수 있습니다.

export interface PaymentStrategy {
  getRoundUpAmount(amount: number): number;

  getTip(amount: number): number;
}

전략 인터페이스의 구체적인 구현은 아래 코드 스니펫과 같습니다.

export class PaymentStrategyAU implements PaymentStrategy {
  get currencySign(): string {
    return "$";
  }

  getRoundUpAmount(amount: number): number {
    return Math.floor(amount + 1);
  }

  getTip(amount: number): number {
    return parseFloat((this.getRoundUpAmount(amount) - amount).toPrecision(10));
  }
}

여기서 인터페이스와 클래스는 UI와 직접적인 관련이 없다는 점에 유의하세요. 이 로직은 애플리케이션의 다른 위치에서 공유하거나 백엔드 서비스로 이동할 수도 있습니다(백엔드가 Node로 작성된 경우).

각 국가별로 서브클래스를 만들고 각 서브클래스에는 국가별 반올림 로직이 있을 수 있습니다. 하지만 자바스크립트에서 함수는 일급 시민이므로 반올림 알고리즘을 전략의 구현체에 전달하면 서브클래스를 만들지 않아도 됩니다. 또한 인터페이스 구현이 하나뿐이므로 인라인 클래스를 사용해서 단일 구현 인터페이스로 줄일 수 있습니다.

//src/models/CountryPayment.ts…

export class CountryPayment {
  private readonly _currencySign: string;
  private readonly algorithm: RoundUpStrategy;

  public constructor(currencySign: string, roundUpAlgorithm: RoundUpStrategy) {
    this._currencySign = currencySign;
    this.algorithm = roundUpAlgorithm;
  }

  get currencySign(): string {
    return this._currencySign;
  }

  getRoundUpAmount(amount: number): number {
    return this.algorithm(amount);
  }

  getTip(amount: number): number {
    return calculateTipFor(this.getRoundUpAmount.bind(this))(amount);
  }
}

아래 그림과 같이 컴포넌트와 훅에 흩어져 있는 로직에 의존하는 대신, 이제 단일 클래스 PaymentStrategy에만 의존합니다. 그리고 런타임에 PaymentStrategy의 하나의 인스턴스를 다른 인스턴스로 쉽게 대체할 수도 있습니다 (빨간색, 초록색, 파란색 사각형은 각각 다른 PaymentStrategy 클래스의 인스턴스를 나타냅니다).

useRoundUp 훅을 사용하면 코드를 다음과 같이 단순화할 수 있습니다.

//src/hooks/useRoundUp.ts…

export const useRoundUp = (amount: number, strategy: PaymentStrategy) => {
  const [agreeToDonate, setAgreeToDonate] = useState<boolean>(false);

  const { total, tip } = useMemo(
    () => ({
      total: agreeToDonate ? strategy.getRoundUpAmount(amount) : amount,
      tip: strategy.getTip(amount),
    }),
    [agreeToDonate, amount, strategy]
  );

  const updateAgreeToDonate = () => {
    setAgreeToDonate((agreeToDonate) => !agreeToDonate);
  };

  return {
    total,
    tip,
    agreeToDonate,
    updateAgreeToDonate,
  };
};

Payment 컴포넌트에서는 전략을 props 를 통해 훅까지 전달합니다.

//src/components/Payment.tsx…

export const Payment = ({
  amount,
  strategy = new PaymentStrategy("$", roundUpToNearestInteger),
}: {
  amount: number;
  strategy?: PaymentStrategy;
}) => {
  const { paymentMethods } = usePaymentMethods();

  const { total, tip, agreeToDonate, updateAgreeToDonate } = useRoundUp(
    amount,
    strategy
  );

  return (
    <div>
      <h3>Payment</h3>
      <PaymentMethods options={paymentMethods} />
      <DonationCheckbox
        onChange={updateAgreeToDonate}
        checked={agreeToDonate}
        content={formatCheckboxLabel(agreeToDonate, tip, strategy)}
      />
      <button>{formatButtonLabel(strategy, total)}</button>
    </div>
  );
};

라벨 생성을 위해 몇 가지 헬퍼 함수를 추출하여 약간 정리한 코드는 다음과 같습니다.

// src/utils.ts…

export const formatCheckboxLabel = (
  agreeToDonate: boolean,
  tip: number,
  strategy: CountryPayment
) => {
  return agreeToDonate
    ? "Thanks for your donation."
    : `I would like to donate ${strategy.currencySign}${tip} to charity.`;
};

뷰가 아닌 코드를 별도의 위치로 추출하거나 새로운 매커니즘을 추상화하여 보다 모듈화하기 위해 노력하고 있다는 것을 눈치채셨을 수도 있습니다.

리액트의 뷰는 뷰가 아닌 코드의 소비자 중 하나에 불과합니다. 예를 들어 뷰 또는 CLI로 새로운 인터페이스를 구축한다면 얼마나 많은 코드를 재사용할 수 있을까요?

디자인 좀 더 발전시키기: 네트워크 클라이언트 추출

이 "관심사 분리" 사고방식을 유지한다면(뷰 로직과 뷰와 관련 없는 로직을 분리하거나 각기 다른 책임을 자체 함수/클래스/객체로 분리), 다음 단계는 usePaymentMethods 훅에서 혼합되어 있는 것을 푸는 작업을 진행하는 것입니다.

현재 이 훅에는 코드가 많진 않습니다. 에러 핸들링과 재시도 같은 것들을 추가하면 쉽게 커질 수 있습니다. 또한 훅은 리액트의 개념이기 때문에 뷰에서는 직접 재사용 할 수 없을 거에요.

// src/hooks/usePaymentMethods.ts…

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

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

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

      setPaymentMethods(convertPaymentMethods(methods));
    };

    fetchPaymentMethods();
  }, []);

  return {
    paymentMethods,
  };
};

여기서 convertPaymentMethods를 전역 함수로 추출했습니다. 데이터를 가져오는 로직을 별도의 함수로 옮겨서 리액트 쿼리(React Query)와 같은 라이브러리를 사용하여 네트워크와 관련된 모든 문제를 처리할 수 있도록 하고 싶습니다.

// src/hooks/usePaymentMethods.ts…

const fetchPaymentMethods = async () => {
  const response = await fetch(
    "https://5a2f495fa871f00012678d70.mockapi.io/api/payment-methods?countryCode=AU"
  );
  const methods: RemotePaymentMethod[] = await response.json();

  return convertPaymentMethods(methods);
};

이 작은 클래스는 데이터 페칭과 변환이라는 두 가지 작업을 수행합니다. 이 클래스는 Anti-Corruption 계층(또는 게이트웨이)처럼 작동하여 PaymentMethod 구조에 대한 변경이 하나의 파일 내에서 가능하도록 제한할 수 있습니다. 이 분할의 이점은 위에서 살펴본 전략 객체와 마찬가지로 백엔드 서비스에서도 필요할 때마다 클래스를 사용할 수 있다는 것입니다.

이제 usePaymentMethods 훅은 매우 간단해졌습니다.

// src/hooks/usePaymentMethods.ts…

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

  useEffect(() => {
    fetchPaymentMethods().then((methods) => setPaymentMethods(methods));
  }, []);

  return {
    paymentMethods,
  };
};

클래스 다이어그램은 아래와 같이 변경됩니다. 대부분의 코드는 다른 위치에서도 사용할 수 있도록 뷰와 관련없는 파일로 이동했습니다.

이러한 계층이 가지는 이점

위에서 언급한 것처럼 이러한 계층은 많은 이점을 제공합니다.

  • 유지보수성 향상: 컴포넌트를 여러 부분으로 분리하면 코드의 특정 부분에서 결함을 찾아 수정하기가 더욱 쉬워집니다. 따라서 시간을 절약하고 변경 시 새로운 버그가 발생할 위험도 줄일 수 있습니다.
  • 모듈성 향상: 계층 구조가 더 모듈화되어 코드를 재사용하고 새로운 기능을 구축하기가 더 쉬워집니다. 예를 들어 뷰는 각 계층에서 더 쉽게 구성할 수 있습니다.
  • 가독성 향상: 코드의 논리를 이해하고 따르기가 훨씬 쉬워집니다. 이는 코드를 읽고 작업하는 다른 개발자에게 특히 유용할 수 있습니다. 이것이 바로 코드베이스 변경의 핵심입니다.
  • 확장성 향상: 각 개별 모듈의 복잡성이 감소하면 전체 시스템에 영향을 주지 않고 새로운 기능을 추가하거나 변경하기가 더 쉬워지므로 애플리케이션의 확장성이 향상되는 경우가 많습니다. 이는 시간이 지남에 따라 발전할 것으로 예상되는 대규모의 복잡한 애플리케이션에 특히 중요할 수 있습니다.
  • 다른 기술 스택으로의 마이그레이션: 필요한 경우 (대부분의 프로젝트에서는 거의 불가능하지만) 기본 모델과 로직을 변경하지 않고 뷰 계층을 교체할 수 있습니다. 도메인 로직이 순수 자바스크립트(또는 타입스크립트) 코드로 캡슐화되어 있고 뷰의 존재를 인식하지 못하기 떄문입니다.

결론

리액트 애플리케이션 또는 리액트를 뷰로 사용하는 프런트엔드 애플리케이션을 구축하는 것을 새로운 유형의 소프트웨어로 취급해서는 안됩니다. 전통적인 사용자 인터페이스를 구축하기 위한 대부분의 패턴과 원칙이 여전히 적용됩니다. 백엔드에서 헤드리스 서비스를 구축하기 위한 패턴도 프런트엔드 분야에서도 유효합니다. 프런트엔드에서 계츨을 사용해서 사용자 인터페이스를 최대한 얇게 만들고, 로직을 모델 계층에, 데이터 접근을 또 다른 계층에 저장할 수 있습니다.

프런트엔드 애플리케이션에 이러한 계층 구조를 사용하면 다른 게층에 대한 걱정 없이 한 부분만 이해하면 된다는 이점이 있습니다. 또한 재사용성이 향상되어 기존 코드를 변경하는 것이 이전보다 상대적으로 더 용이해집니다.

6개의 댓글

comment-user-thumbnail
2023년 2월 23일

단점도 있다고 생각합니다. 클래스를 통해서 응집성을 높이는 것도 방법이지만 기능이 확장되며 클래스가 비대해지면 혹은 클래스가 나눠져 여러 클래스가 생겨날 경우 페이지별로 쓰이는지 안쓰이는지, 어떤로직이 무슨일을 하는지 바로 보기 어렵다고 생각합니다.

이는 네이밍을 잘못하면 읽기 어렵고버리기 어려운 코드를 만들어 리팩토링시 코드가 유착되기 쉬울 거 같아요

클래스 말고 로직이 들어간 훅안에서 함수를 따로 정의해서 데이터 가공을 하는게 어떤가요?

답글 달기
comment-user-thumbnail
2023년 2월 23일

너무 질 좋은 번역 잘 읽었습니다!

답글 달기
comment-user-thumbnail
2023년 2월 23일

엣지 있네요. 감사합니다

답글 달기
comment-user-thumbnail
2023년 2월 23일

이 글은 유명한 (잘만들어진) 리엑트를 위한 추가라이브러리 (react-query mobx) 등등 이 어떻게 생긴지 코드를 본사람이라면 바로 공감 했을 것 같네요 위에서 소개한 내용으로 다들 만들어져 있더라고요 좋은 글 입니다 !!

답글 달기
comment-user-thumbnail
2023년 4월 12일

잘 배워갑니다

답글 달기
comment-user-thumbnail
2023년 4월 13일

감사합니다. 실무에 적용할 여지가 있네요

답글 달기