관심사의 분리, 의존성 줄이기, 책임과 역할의 명확화, 공통 로직 분리 등... 훅이 너무 강력해서 대부분의 문제를 해결해주는 듯 싶다. 하지만 어떤 코드를 훅으로 분리해야 할지 인지하는 것이 아직 어렵다. 그래서 컴포넌트 패턴을 학습해보기로 했다. 사람들이 어떤 코드를 '문제'라고 느끼는지, 그리고 그것을 꼭 훅이 아니더라도 어떻게 해결하는지 살펴보았다.

1️⃣ 비지니스 로직과 UI 분리하기

Container and Presentational Components Pattern

한 컴포넌트 안에 비즈니스 로직과 UI가 섞여 있을 때, 관심사를 분리하기 위해 사용하는 패턴이다. 비즈니스 로직은 Container 컴포넌트로 분리하고, UI는 오직 UI만 책임지도록 구성한다.

좋았던 점은, UI를 그리는 컴포넌트를 쉽게 교체할 수 있다는 것이다. 재사용성이 자연스럽게 올라갔다. 실제로 나도 한 컴포넌트에 모든 걸 몰아넣는 경우가 종종 있었는데, 이 패턴을 접하고 나니 컴포넌트도 결국 함수이니 하나의 책임만 가져야겠다는 생각이 들었다. 결과적으로 가독성도 더 좋아졌다.

import { useQuery } from 'react-query';
import { PaymentCardList } from './payment-card-list';

// 비즈니스 로직의 주체(컨테이너)는 비지니스 로직을 전부 갖는다.
export const PaymentCardListContainer = () => {
  const { loading, data: cardList } = useQuery({
    // ...
  });

  // UI를 그리는 주체(프리젠테이션)는 결과 값만 받아서 UI만 그려준다. 비지니스 로직과 상관X
  return <PaymentCardList loading={loading} cardList={cardList} />;
};

2️⃣ 동일 로직을 여러 컴포넌트에 제공하기 (1)

Higher-Order Component Pattern

중복 로직을 하나로 모으기 위한 패턴이다. HOC라는 이름을 처음 봤을 때는 '함수를 반환하는 함수'로 어떻게 문제를 해결하지? 라는 생각이 들었다. 그런데 실제로는 하나의 컴포넌트에 중복 로직을 몰아넣고, 렌더링할 컴포넌트를 인자로 받아 해결한다. '역시 자바스크립트니까 이런 것도 가능하구나' 싶었다. 로그인 인증처럼 여러 페이지에서 반복되는 로직이 있을 때 유용해 보였다.

// 🚨 변경 전: 페이지마다 중복 로직 발생
const MyPage = () => {
  const { isLoggedIn } = useAuth();
  
  if (!isLoggedIn) {
    return <div>로그인이 필요한 페이지입니다.</div>;
  }

  return <p>마이 페이지</p>;
};

const OrderPage = () => {
  const { isLoggedIn } = useAuth();

  if (!isLoggedIn) {
    return <div>로그인이 필요한 페이지입니다.</div>;
  }

  return <p>주문 페이지</p>;
};
// ✅ 변경 후: 동일 로직을 여러 컴포넌트에 제공

// 첫 번째 인자: 함수 컴포넌트
// 두 번째 인자: 로그인될 때 튕길지 아닐지
const withAuth = (Component, avoidNotLoggedUser = false) => {
    return (props) => {
        const { isLoggedIn } = useAuth();
        if (avoidNotLoggedUser && isLoggedIn) {
            return <div> 로그인 이 필요한 페이지입니다.</div>;
        }
        return <Component {...props} />;
    };
};

const HomePage = withAuth(() => <div>메인 페이지</div>);
const MyPage = withAuth(() => <p>마이 페이지</p>, true);

⚠️ 주의할 점: props 충돌 가능성

HOC 패턴은 렌더링 대상 컴포넌트에 props를 주입하는 방식이라, 원래 컴포넌트가 기대하는 props와 충돌할 수 있는 위험이 있다.

const withUser = (Component) => {
  return (props) => {
    const user = useUser();
    return <Component user={user} {...props} />; // props.user와 충돌 가능!
  };
};

user라는 prop이 이미 외부에서 전달되었다면, withUser가 덮어쓰게 된다. 이처럼 의도치 않은 prop 덮어쓰기가 발생할 수 있다는 점은 HOC의 한계로 지적된다.

3️⃣ 동일 로직을 여러 컴포넌트에 제공하기 (2)

Render Props Pattern

데이터 공유 구조는 HOC와 유사하지만,

  • props 충돌을 방지할 수 있고
  • 생명주기를 더 유연하게 활용할 수 있다는 장점이 있다.
// 🚨 변경 전: 상태가 props로 흩어짐
const Display = ({ count }) => {
    return <h1>{count}</h1>;
};

const Counter = ({ count, setCount }) => {
    return <input type="number" value={count} onChange={(e) => setCount(e.target.value)} />;
};

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

    return (
        <div>
            <Display count={count} />
            <Counter count={count} setCount={setCount} />
        </div>
    );
};

처음 이 코드를 봤을 땐 setCount 같은 상태 변경자가 props로 내려오는 구조가 불편하게 느껴졌다. 상태 변경 책임이 여러 컴포넌트에 흩어져 있어 예측이 어렵고, 추적하기도 힘들겠다는 생각을 했다. 나라면 훅으로 빼거나 핸들러 함수를 만들어 처리했을 것 같다.

그런데 Render Props 패턴에서는 상태를 컴포넌트 안에 고립시켜 관리할 수 있다. HOC처럼 함수를 인자로 받지만, UI 렌더링 책임을 render 함수로 넘기는 구조이다.

  • HOC: 로직을 감싸고, UI는 넘겨받은 컴포넌트가 담당
  • Render Props: 로직을 내부에 갖고, UI는 render 함수 prop으로 위임
// ✅ 변경 후: 상태 고립 + 렌더 위임
type CounterProps = {
    render: (count: number, change: () => void) => JSX.Element;
};

const Counter = ({ render }: CounterProps) => {
    const [count, setCount] = useState(0);
    
    const increment = () => setCount((prev) => prev + 1);
    
    return render(count, increment);
};
import { Counter } from './counter';

const App = () => {
    return (
        <div>
            <Counter render={(count, increment) => (
                <div>
                    <h1>{count}</h1>
                    <button onClick={increment}>+1</button>
                </div>
            )} />
        </div>
    );
};

✅ Render Props의 장점 1. props 충돌 방지

렌더링을 위한 함수를 명시적으로 넘기므로, 내부에서 어떤 props가 들어올지 예측 가능하다.

✅ Render Props의 장점 2. 생명주기와 상태 로직의 유연한 활용

Render Props는 일반적인 컴포넌트이기 때문에, useEffect, useState, useRef 등의 훅을 자유롭게 사용할 수 있다. UI는 외부에서 제어할 수 있지만, 상태와 생명주기는 내부에 고립시킬 수 있다는 점에서 HOC보다 구조적으로 유연하다.

물론 HOC 내부에서도 useState나 useEffect를 쓸 수 있다. 하지만, 컴포넌트 계층이 감춰지면서 디버깅이 어렵고 여러 HOC가 중첩되면 컴포넌트 추적이 복잡해지며, props 충돌 위험이 항상 존재한다. 예를 들어... withTheme(withUser(...))

예전에 클래스 컴포넌트가 사라진 이유 중에 HOC와 같은 문제가 있다는 걸 봤다.

4️⃣ Render Props Pattern 확장

Prop Collections (or Combination) Pattern

Render Props Pattern의 확장이다. 넘겨줄 props를 객체 형태로 묶어서 전달한다. 상위에서 내려오는 props를 예쁘게 가공해서 하위 컴포넌트에 전달할 때 사용한다고 한다.

페이먼츠 미션처럼 input이 많고, 비슷한 props를 반복적으로 내려줘야 할 때 이 패턴이 유용할 수 있겠다는 생각이 들었다.

const Button = ({ label, color, size, ...rest }) => {
    return (
        <p style={{ color, fontSize: size }} {...rest}>
            {label}
        </p>
    );
};

const App = () => {
    const buttonProps = {
        label: '클릭',
        color: 'red',
        size: '20px',
        onClick: () => console.log("버튼이 클릭 되었습니다!"),
    };
    return <Button {...buttonProps} />;
};

위 예시만으로는 이 패턴이 잘 와닿지 않아서 조금 더 찾아보았고, 아래처럼 사용하면 좋을 것 같다.

const getCounterProps = (count, increment) => ({
  onClick: increment,
  children: count,
});

// 필요한 props를 묶어서 전달 → 재사용성 ↑
<Counter>
  {({ count, increment }) => {
    const props = getCounterProps(count, increment);
    return <button {...props} />;
  }}
</Counter>

5️⃣ 추상화로 컴포넌트에 역할 추가하기

Conditional Rendering Pattern 또는 Disabled Prop Pattern

조건부 렌더링을 통해 컴포넌트의 책임을 나누는 패턴이다. 처음에는 단순한 조건 하나인데 굳이 추상화할 필요가 있을까? 싶었지만, 조건이 많아질수록 하나의 컴포넌트에 관심사가 너무 많아질 수 있다는 점을 알게 되었다. App 컴포넌트에 disabled 관련 조건이 존재하는 이유가 한눈에 드러나지 않는다면, 오히려 추상화해서 렌더링 책임을 명확히 분리하는 편이 가독성에 더 좋을 수 있다.

물론 지나치게 추상화되면 오히려 구조가 더 복잡해 보일 수 있다.

// 🚨 변경 전
const App = ({ disabled }) => {
  return (
    <div>
      {!disabled && (
        <div>
          <h1>특정 상황에서만 그려집니다.</h1>
        </div>
      )}
    </div>
  );
};
// ✅ 변경 후
const ConditionalComponent = ({ disabled }) => {
  return (
      {!disabled && (
        <div>
          <h1>특정 상황에서만 그려집니다.</h1>
        </div>
      )}
  );
};

const App = ({ disabled }) => {
  return (
    <div>
      <ConditionalComponent disabled={disabled} />
    </div>
  );
};

🍵 마무리

실제로 내 코드에 적용해본 건 아니라서 엄청 와닿는다고는 할 수 없지만, '이런 상황에 써보면 좋겠는데?' 하는 생각이 들었다. 꼭 훅만이 관심사 분리의 정답은 아니라는 것도 알게 되었다.

제어/비제어 컴포넌트, 합성 컴포넌트 등 아직 이 글에서 다루지 못한 컴포넌트 패턴이 몇 가지 더 있다. 이 패턴들은 실제로 사용해본 경험이 있는 만큼, 좀 더 자세히 써보고 싶어서 다음 글에서 이어서 정리해보려 한다.

profile
기술을 위한 기술이 되지 않도록!

0개의 댓글