뒤로가기
이미 쓰고 있는 디자인 패턴들

April 15, 2021

frontendreact

"디자인 패턴 공부해야 하나요?"

주니어 시절에 이 질문을 커뮤니티에 올렸다. 답변은 두 부류였다. "당연하죠, GoF 책은 필수입니다" vs "프론트엔드에서는 별로 안 씁니다." 나는 후자의 의견을 믿었다. GoF의 디자인 패턴 책을 펼쳐봤는데, 자바 코드에 UML 다이어그램에 AbstractFactory니 뭐니 하는 이름들이 나와서 10페이지 만에 덮었다. "이건 백엔드 얘기지, 나한테는 해당 안 되겠지."

2년이 지나서야 깨달았다. 나는 디자인 패턴을 매일 쓰고 있었다. 이름을 몰랐을 뿐.

Addy Osmani와 Lydia Hallie가 만든 patterns.dev에서 JavaScript와 React의 디자인 패턴을 정리한 걸 보고 나서 "아하" 순간이 왔다. 내가 매일 치는 코드에 이름이 붙어있었다.

Observer 패턴: 이벤트 리스너#

Observer 패턴의 정의를 보면 "어떤 이벤트가 발생하면 구독자에게 알린다"이다. 뭔가 학술적으로 들리지만, 프론트엔드 개발자가 첫 주에 배우는 게 바로 이거다.

javascript
// 이게 Observer 패턴이다
button.addEventListener("click", handleClick);
window.addEventListener("resize", handleResize);
document.addEventListener("scroll", handleScroll);

addEventListener가 Observer 패턴의 구현이다. 버튼(Subject)에 클릭 이벤트가 발생하면, 등록된 핸들러(Observer)에게 알려준다. 여러 개의 핸들러를 등록할 수도 있고, 특정 핸들러만 제거(removeEventListener)할 수도 있다.

React에서는 이게 더 자연스럽게 녹아있다.

tsx
// React의 상태 업데이트도 Observer 패턴이다
const [count, setCount] = useState(0);

// count가 바뀌면 이 컴포넌트와 하위 컴포넌트가 자동으로 리렌더링된다
// 상태(Subject)를 구독(Observe)하고 있는 UI가 변경을 감지하는 구조

useState로 상태를 만들면 React가 자동으로 그 상태를 구독한다. 상태가 바뀌면 관련 컴포넌트가 리렌더링된다. "상태 변경을 감지해서 UI를 업데이트한다" — 이게 Observer 패턴의 핵심 아이디어다.

더 명시적인 예시는 브라우저의 IntersectionObserver다.

javascript
const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      loadImage(entry.target);
    }
  });
});

observer.observe(imageElement);

이름 자체에 Observer가 들어있다. 요소가 뷰포트에 진입하는 "이벤트"를 관찰(observe)하다가, 진입하면 콜백을 실행한다. 이미지 lazy loading을 만들 때 자연스럽게 쓰게 되는데, 이게 디자인 패턴이었다.

Strategy 패턴: 함수를 인자로 넘기기#

Strategy 패턴은 "알고리즘을 캡슐화하고 교체 가능하게 만든다"이다. 말이 어렵지만, JavaScript에서는 그냥 함수를 인자로 넘기는 거다.

javascript
// 이게 Strategy 패턴이다
const numbers = [3, 1, 4, 1, 5, 9];

numbers.sort((a, b) => a - b); // 오름차순 전략
numbers.sort((a, b) => b - a); // 내림차순 전략
numbers.sort((a, b) => a.toString().localeCompare(b.toString())); // 문자열 비교 전략

Array.sort()에 비교 함수를 넘긴다. 정렬이라는 행위는 같은데, "어떻게 비교할 것인가"라는 전략을 바꿔 끼울 수 있다. 이게 Strategy 패턴이다.

React에서는 render props가 이 패턴의 대표적 구현이었다. Kent C. Dodds가 React 패턴을 설명할 때 composition을 강조하는 이유도 이거다.

tsx
// 렌더링 전략을 외부에서 주입한다
function List({ items, renderItem }) {
  return (
    <ul>
      {items.map((item) => (
        <li key={item.id}>{renderItem(item)}</li>
      ))}
    </ul>
  );
}

// 사용하는 쪽에서 전략을 결정한다
<List items={users} renderItem={(user)=> <UserCard user={user} />} />
<List items={products} renderItem={(product)=> <ProductRow product={product} />} />

같은 List 컴포넌트인데, renderItem이라는 전략을 바꿔 끼우면 완전히 다른 UI가 된다. "렌더링 방법"이라는 알고리즘을 캡슐화하고 교체 가능하게 만든 거다. Strategy 패턴 그 자체다.

Decorator 패턴: HOC#

Decorator 패턴은 "기존 객체에 새로운 기능을 덧씌운다"이다. React에서 Higher-Order Component(HOC)가 정확히 이 역할을 한다.

tsx
// Decorator 패턴: 컴포넌트에 인증 기능을 덧씌운다
function withAuth(WrappedComponent) {
  return function AuthenticatedComponent(props) {
    const { user, isLoading } = useAuth();

    if (isLoading) return <Spinner />;
    if (!user) return <Navigate to="/login" />;

    return <WrappedComponent {...props} user={user} />;
  };
}

// 원본 컴포넌트는 건드리지 않고 인증 기능이 추가된다
const ProtectedDashboard = withAuth(Dashboard);
const ProtectedSettings = withAuth(Settings);

Dashboard 컴포넌트 자체는 인증에 대해 아무것도 모른다. withAuth가 그 위에 인증 로직을 "장식(decorate)"한다. 원본을 수정하지 않고 기능을 추가하는 거다.

요즘은 HOC보다 커스텀 훅을 더 많이 쓰긴 한다. 근데 패턴의 본질은 같다.

tsx
// 커스텀 훅도 Decorator적 사고방식이다
function useDashboard() {
  const { user } = useAuth();        // 인증 기능 장식
  const data = useFetchData(user.id); // 데이터 패칭 장식
  const analytics = useAnalytics();   // 분석 기능 장식

  return { user, data, analytics };
}

여러 기능을 하나씩 "장식"처럼 쌓아 올리는 구조. 각 훅은 독립적이고, 필요에 따라 추가하거나 제거할 수 있다.

Mediator 패턴: 상태 관리 라이브러리#

Mediator 패턴은 "컴포넌트들이 직접 통신하지 않고, 중앙의 중재자를 통해 통신한다"이다. patterns.dev에서도 이 패턴을 미들웨어 패턴과 연결지어 설명한다.

Redux가 Mediator 패턴의 교과서적 구현이다.

text
컴포넌트A ──dispatch──→ Store(Mediator) ──notify──→ 컴포넌트B
컴포넌트C ──dispatch──→ Store(Mediator) ──notify──→ 컴포넌트D

컴포넌트A가 컴포넌트B에게 직접 데이터를 전달하지 않는다. Store라는 중재자에게 액션을 보내면, Store가 상태를 업데이트하고, 해당 상태를 구독하는 모든 컴포넌트에게 알린다.

컴포넌트끼리 서로의 존재를 모르게 만드는 게 핵심이다. A는 B가 있는지 없는지 모른다. 그냥 Store에 액션을 보낼 뿐이다. 이 덕분에 컴포넌트를 자유롭게 추가하거나 제거할 수 있다.

tsx
// 채팅 앱을 예로 들면
function ChatInput() {
  const dispatch = useDispatch();

  const sendMessage = (text) => {
    // 이 컴포넌트는 메시지가 어디에 표시되는지 모른다
    // 그냥 Store에 "메시지 보냈다"고 알릴 뿐
    dispatch({ type: "SEND_MESSAGE", payload: text });
  };

  return <input onSubmit={sendMessage} />;
}

function MessageList() {
  // 이 컴포넌트는 메시지가 어디서 오는지 모른다
  // 그냥 Store에서 메시지 목록을 가져올 뿐
  const messages = useSelector((state) => state.messages);

  return messages.map((msg) => <Message key={msg.id} text={msg.text} />);
}

ChatInputMessageList는 서로를 모른다. Store가 중재한다. 나중에 NotificationBadge라는 새 컴포넌트를 추가해도, 기존 코드는 한 줄도 안 고쳐도 된다. Store에서 메시지 개수를 구독하기만 하면 된다.

Singleton 패턴: 전역 상태#

Singleton 패턴은 "인스턴스가 하나만 존재하도록 보장한다"이다. JavaScript 모듈 시스템 자체가 Singleton이다.

javascript
// config.js
// 이 모듈은 앱 전체에서 하나의 인스턴스만 존재한다
export const config = {
  apiUrl: process.env.API_URL,
  maxRetries: 3,
  timeout: 5000,
};

import { config } from './config'를 어디서 하든 같은 객체를 참조한다. 모듈이 처음 import될 때 한 번 평가되고, 이후에는 캐시된 결과를 반환한다. 별도의 Singleton 구현 없이도 JavaScript 모듈 시스템이 알아서 해준다.

React에서 Context도 비슷한 역할을 한다.

tsx
// ThemeContext는 앱 전체에서 하나의 테마 상태를 공유한다
const ThemeContext = createContext("light");

function App() {
  return (
    <ThemeContext.Provider value="dark">
      {/* 어떤 하위 컴포넌트든 같은 테마 값을 참조한다 */}
      <Header />
      <Main />
      <Footer />
    </ThemeContext.Provider>
  );
}

Provider가 하나이고, 모든 하위 컴포넌트가 같은 값을 참조한다. "하나의 출처(Single Source of Truth)"라는 React의 철학 자체가 Singleton적 사고방식이다.

Proxy 패턴: 접근 제어#

patterns.dev에서 소개하는 Proxy 패턴은 "객체에 대한 접근을 가로채서 제어한다"이다. JavaScript의 Proxy 객체가 이름 그대로 이 패턴이다.

javascript
const handler = {
  set(target, property, value) {
    if (property === "age" && typeof value !== "number") {
      throw new TypeError("나이는 숫자여야 합니다");
    }
    if (property === "age" && value < 0) {
      throw new RangeError("나이는 0 이상이어야 합니다");
    }
    target[property] = value;
    return true;
  },
};

const user = new Proxy({}, handler);
user.age = 25;  // 통과
user.age = -1;  // RangeError
user.age = "스물다섯"; // TypeError

이걸 매일 직접 쓰지는 않겠지만, 반응형 상태 관리 라이브러리들이 내부적으로 이걸 쓴다. MobX, Vue의 반응형 시스템이 Proxy 기반이다. 상태 변경을 "가로채서" 자동으로 리렌더링을 트리거하는 구조.

"아하" 이후에 달라지는 것#

디자인 패턴의 이름을 아는 것 자체가 코딩 실력을 올려주지는 않는다. 이미 쓰고 있었으니까. 근데 이름을 알면 달라지는 게 있다.

1. 소통이 빨라진다. "이거 Observer 패턴으로 하면 되겠네"라고 하면, 패턴을 아는 팀원은 즉시 구조가 머릿속에 그려진다. "이벤트를 발행하고 구독하는 구조로 만들어서, 새 기능을 추가할 때 기존 코드를 안 건드려도 되게..."라고 길게 설명할 필요가 없다.

2. 설계 판단의 근거가 생긴다. "여기는 Mediator 패턴을 쓰면 컴포넌트 간 의존성을 줄일 수 있다"는 말에는 근거가 있다. GoF 이후 수십 년간 검증된 패턴이다. "뭔가 중간에서 관리하는 걸 두면 좋을 것 같아요"보다 설득력 있다.

3. 새로운 코드를 읽을 때 구조가 보인다. 처음 보는 라이브러리의 소스 코드를 읽을 때, "아 이건 Observer 패턴이구나"하고 인식하면 코드의 흐름을 훨씬 빨리 파악할 수 있다. 패턴을 모르면 함수 하나하나를 따라가야 하지만, 패턴을 알면 큰 구조가 먼저 잡힌다.

디자인 패턴은 발명이 아니라 발견이다#

내가 느끼기에 디자인 패턴의 본질은 이거다. 뛰어난 누군가가 "이렇게 코드를 짜라"고 발명한 게 아니다. 많은 개발자들이 비슷한 문제를 풀다 보니 비슷한 구조에 도달했고, 거기에 이름을 붙인 거다.

Kent C. Dodds가 React 패턴을 설명할 때도 비슷한 접근을 한다. 추상적인 패턴부터 시작하는 게 아니라, 실제 문제를 풀다 보면 자연스럽게 패턴이 나온다는 식이다.

그러니까 "디자인 패턴을 공부해야 하나요?"라는 질문에 대한 내 대답은 이렇다. 이미 쓰고 있는 것에 이름을 붙이는 과정이라고 생각하면 부담이 없다. GoF 책 처음부터 읽을 필요 없다. 내가 매일 짜는 코드에서 출발해서, "이거 혹시 패턴이 있나?" 하고 찾아보면 된다.

addEventListener를 쓰고 있다면 이미 Observer 패턴을 알고 있는 거다. Array.sort()에 비교 함수를 넘기고 있다면 이미 Strategy 패턴을 알고 있는 거다. 모르고 있었을 뿐, 몸은 이미 알고 있었다.

이름을 아는 순간, "아 이게 그거였어?" 하는 감탄이 온다. 그리고 그 감탄 이후에 코드를 보는 눈이 달라진다. 거창한 변화는 아닌데, 한번 보이면 계속 보인다. 어디서든.