리액트 디자인패턴 with 리팩토링

백승범·2025년 6월 9일

TIL(Today I Learned)

목록 보기
15/17
post-thumbnail

리팩토링과 디자인 패턴은 뗄 수 없는 관계입니다.
자연스럽게 리팩토링을 진행하다 보면 다양한 디자인 패턴을 참고하거나 어떠한 디자인 패턴과 비슷한 방향으로 코드가 나타나게 되는데요.

그전에 리팩토링과 디자인 패턴이 무엇인지 한번 알아봅시다.

리팩토링

마틴 파울러는 리팩토링소프트웨어의 겉보기 동작은 그대로 유지한 채 여러 가지 리팩토링 기법을 적용해서 소프트웨어를 재구성하는 것이라 합니다.
즉 코드의 외부 동작은 그대로 두고 내부 구조를 더 이해하기 쉽고 수정하기 쉽게 바꾸는 작업을 의미하는데요.

간단한 예시로 설명해보자면 아래와 같이 상태를 렌더링 하고 싶을때 여러 상태에 대해 삼항연산자로 처리할 수 있습니다. 하지만 딱 보기에도 이는 가독성이 좋지 않습니다.

function StatusMessage({ status }) {
  return (
    <div>
      {status === 'loading'
        ? '로딩 중...'
        : status === 'error'
        ? '에러가 발생했습니다.'
        : status === 'success'
        ? '성공!'
        : '알 수 없음'}
    </div>
  );
}

이러한 코드를 리팩토링 하면 아래와 같이 객체 매핑을 사용할 수 있는데요.

const STATUS_MESSAGES = {
  loading: '로딩 중...',
  error: '에러가 발생했습니다.',
  success: '성공!',
};

function StatusMessage({ status }) {
  return <div>{STATUS_MESSAGES[status] || '알 수 없음'}</div>;
}

이렇게 객체로 매핑하면 가독성이 좋아지고 새로운 상태를 추가하거나 수정할 때도 훨씬 편리합니다.
중요한 점은, 코드의 “내부 구조”만 바뀌었을 뿐 “외부 동작”(사용자에게 보이는 결과)은 변하지 않았다는 것입니다.
이것이 바로 리팩토링입니다.

디자인 패턴

그렇담 디자인 패턴은 무엇일까요?

소프트웨어를 개발하다 보면 비슷한 문제를 반복해서 만나게 됩니다.
이때 이미 검증된 해결 방법이 있다면 그 방식을 참고해 문제를 더 쉽고 효율적으로 해결할 수 있습니다.
이렇게 반복적으로 나타나는 소프트웨어 설계 문제에 대한 일반적이고 재사용 가능한 해결책을 정리해 놓은 것이 바로 디자인 패턴입니다.

디자인 패턴으로는 Singleton 패턴, Proxy 패턴, Observer 패턴 등 여러가지가 있지만 리액트에서 자주 사용되는 패턴을 알아보겠습니다.

HOC(Higher-Order Component) 패턴

기본적으로 JS에서 함수의 경우 일급객체입니다.
일급객체 이기에 함수 자체를 변수에 할당하거나, 다른 함수의 인자로 전달이 가능합니다. 그렇기에 고차함수(HOF)를 쉽게 만들 수 있는데요. 여기서 고차함수란 함수를 인자로 받거나 함수를 반환하는 함수를 말합니다.

이러한 고차함수 개념을 이름처럼 알 수 있듯이 컴포넌트에 적용한 디자인 패턴입니다. 아래와 같이 사용할 수 있습니다.

function LoginStartPage() {
  /* ... 로그인 관련 로직 ... */

  return <>{/* ... 로그인 관련 컴포넌트 ... */}</>;
}

export default withAuthGuard(LoginStartPage);

// HOC 정의
function withAuthGuard(WrappedComponent) {
  return function AuthGuard(props) {
    const status = useCheckLoginStatus();

    useEffect(() => {
      if (status === "LOGGED_IN") {
        location.href = "/home";
      }
    }, [status]);

    return status !== "LOGGED_IN" ? <WrappedComponent {...props} /> : null;
  };
}

다음과 같이 로그인 여부 처리하는 로직을 분리하여 상세 구현 사항을 추상화 할 수 있습니다. 또한 동일한 로직을 여러곳에서 재사용할 수 있단 장점이 있습니다.

Compound 패턴

컴포넌트를 만들다 보면 서로를 참조하는 컴포넌트를 만드는 경우가 있습니다. 또는 구역별로 분리되어 재사용이 편하도록 해야할 경우가 있습니다. 이러한 경우에 해당 디자인 패턴이 유용하게 사용되는데요.

기본적으로 Context API를 사용해 컴포넌트끼리 상태를 공유하게 됩니다.

컴파운드 패턴으로 컴포넌트를 만드는 방법을 절차로 간단히 표현해보면

  1. 먼저 Context 를 생성해 줍니다.
  2. 그 후 부모컴포넌트에서 해당 Context를 통해 상태를 공유합니다.
  3. 필요한 자식 컴포넌트를 작성해 줍니다.
  4. 마지막으로 자식 컴포넌트를 부모 컴포넌트의 프로퍼티로 할당합니다.

이렇게 말로만 하면 무슨말인지 모르겠으니 직접 코드를 살펴봅시다.

아래와 같이 모달 컴포넌트를 만들 때 자주 사용이 됩니다.

import React, { createContext, useContext, useState } from 'react';

// 1. Context 생성
const ModalContext = createContext();

//2. 부모 컴포넌트에서 해당 Context로 상태 공유
const Modal = ({ children }) => {
  const [isOpen, setIsOpen] = useState(false);
  const openModal = () => setIsOpen(true);
  const closeModal = () => setIsOpen(false);

  return (
    <ModalContext.Provider value={{ isOpen, openModal, closeModal }}>
      {children}
    </ModalContext.Provider>
  );
};

// 3. 자식 컴포넌트 정의
const OpenButton = ({ children }) => {
  const { openModal } = useContext(ModalContext);
  return <button onClick={openModal}>{children}</button>;
};

const Content = ({ children }) => {
  const { isOpen } = useContext(ModalContext);
  if (!isOpen) return null;
  return (
    <div className="modal">
      <div className="modal-content">{children}</div>
    </div>
  );
};

const CloseButton = ({ children }) => {
  const { closeModal } = useContext(ModalContext);
  return <button onClick={closeModal}>{children}</button>;
};

// 4. Modal의 static property로 할당
Modal.OpenButton = OpenButton;
Modal.Content = Content;
Modal.CloseButton = CloseButton;

export default Modal;

다음과 같이 상태를 공유하도록 하는 하나의 Modal 컴포넌트를 만들 수 있습니다.

해당 Compound pattern의 장점은

  • 상태공유 : 모달의 열림, 닫힘 상태를 Context로 관리해 props drilling 없이 자유롭게 접근 가능
  • 유연한 조합 : <Modal>, <Modal.OpenButton>, <Modal.Content>, <Modal.CloseButton> 을 원하는 구조로 모달을 조합해 사용할 수 있습니다. 따라서 변하는 요구조건에 빠르게 대응할 수 있습니다.
  • 캡슐화 : 모달의 내부 동작을 외부에 노출하지 않아 사용자는 Modal을 통한 뷰에 집중할 수 있습니다.

Render Props 패턴

컴포넌트를 재사용하게 해주는 대표적인 패턴입니다. render prop은 컴포넌트의 prop으로 함수이며 JSX 엘리먼트를 리턴하게 됩니다.
주로 렌더링 방식에 유연함이 필요하거나 내부 상태/로직을 외부에서 동적으로 제어하고 싶을때 사용합니다.

아래의 간단한 예시를 통해 이해해 봅시다.

const Title = props => props.render()

다음과 같이 Title 컴포넌트가 있다고 해보자. 해당 Title 컴포넌트의 경우 prop으로 넘어온 함수를 호출하여 반환하는것 외에는 아무런 동작을 하지 않습니다.

<Title render={() => <h1>I am a render prop!</h1>} />

다음과 같이 사용할 수 있게 되는데요.

   <Title
      render={() => (
        <h1>
          <span role="img" aria-label="emoji"></span>
          I am a render prop!{" "}
          <span role="img" aria-label="emoji"></span>
        </h1>
      )}
    />

또는 이런식으로 더 복잡한 함수도 넘겨줄 수 있습니다.
보다시피 render prop 패턴의 장점은 props을 받는 컴포넌트가 재사용성이 좋다는 점 입니다.

또한 다음과 같이 data도 인자로 받을 수 있다.

function Component(props) {
  const data = { ... }

  return props.render(data)
}

위처럼 인자를 넘기게 구현하면 render prop의 경우 아래 코드와 같이 데이터를 인자로 받을 수 있습니다.

<Component render={data => <ChildComponent data={data} />} />

이 구조에서 Component는 내부에서 만든 data 객체를 props.render 함수의 인자로 넘깁니다. 그리고 App에서 render prop으로 익명 함수를 넘기고, 이 함수는 전달받은 data를 ChildComponent의 prop으로 전달하여 렌더링합니다

// 데이터 생성 및 render prop 호출
function Component(props) {
  const data = { name: "Alice", age: 28 };
  return props.render(data);
}

// 사용 예시: data를 받아서 자식 컴포넌트에 전달
function App() {
  return (
    <Component render={data => <ChildComponent data={data} />} />
  );
}

function ChildComponent({ data }) {
  return (
    <div>
      이름: {data.name}, 나이: {data.age}
    </div>
  );
}

이에 데이터 생성이나 상태관리 등 내부로직은 Component에서 담당하게 되고 어떻게 렌더링할지 외부에서 결정합니다.
그렇기에 로직과 UI를 분리해 유지보수성이 높아지고 재사용성 입장에서 유연하게 Component를 사용할 수 있습니다.

profile
트러블 슈팅이 좋을 때..

0개의 댓글