$ yh.log
[코드 패턴] #1 플러그인 패턴 - 코어를 고치지 않고 기능을 더하는 법

[코드 패턴] #1 플러그인 패턴 - 코어를 고치지 않고 기능을 더하는 법

Design PatternPluginReactTypeScriptCode Patterns

작성자 : 오예환 | 작성일 : 2026-10-14 | 수정일 : 2026-10-14 | 조회수 :

플러그인 패턴 - 코어를 고치지 않고 기능을 더하는 법

이 시리즈에 대해

좋은 코드를 짜는 패턴을 하나씩 공부하면서 정리하는 시리즈다. 예제는 프론트엔드(React, TypeScript) 코드로 쓴다.

"디자인 패턴"이라고 하면 보통 GoF 책의 23개 패턴을 떠올린다. 이 시리즈는 그보다 범위를 넓혀서, 실무 코드에서 자주 만나는 패턴을 출처와 상관없이 다룬다. 그래서 목록에는 수준이 다른 패턴이 섞여 있고, 각 패턴이 어디에 속하는지 함께 표시했다.

  • GoF: 『GoF의 디자인 패턴』(1994)의 23개 패턴에 들어 있는 패턴. 클래스와 객체를 어떻게 나누고 연결할지에 대한 패턴이다.
  • 아키텍처: 모듈이나 시스템 전체의 구조를 정하는 더 큰 수준의 패턴.
  • React: React 생태계에서 컴포넌트와 훅을 설계할 때 쓰는 패턴.
  • 설계 기법: 특정 패턴 목록에 속하지는 않지만 널리 쓰이는 설계 방법.

각 글은 같은 순서로 쓴다.

  1. 이 패턴이 없으면 코드가 어떻게 망가지는가 (Before)
  2. 패턴의 구조와 최소 구현 (After)
  3. 실제 라이브러리는 이 패턴을 어떻게 쓰는가
  4. 언제 쓰면 안 되는가
  5. 가독성 · 예측가능성 · 응집도 · 결합도 관점에서 무엇이 좋아졌는가

예정 목록은 다음과 같다. 공부하면서 순서와 항목은 바뀔 수 있다.

  1. 플러그인 패턴 아키텍처: 코어는 확장 지점만 열어두고, 기능은 바깥에서 등록한다 (이번 글)
  2. Compound Component React: <Select>, <Select.Option>처럼 부품을 조립해서 쓰게 한다
  3. Headless Component / Hook React: 로직과 상태만 제공하고, 마크업은 사용하는 쪽이 정한다
  4. 전략(Strategy) 패턴과 객체 매핑 GoF: switch 분기를 "키 → 처리 함수" 객체로 바꾼다
  5. 옵저버 패턴과 useSyncExternalStore GoF: 상태를 구독하는 방식으로 컴포넌트끼리 직접 묶이지 않게 한다
  6. 미들웨어 · 인터셉터 아키텍처: 요청이나 액션이 지나가는 길목에 처리 단계를 끼워 넣는다
  7. 어댑터 패턴 GoF: 외부 API·SDK의 데이터 모양을 우리 앱이 쓰는 모양으로 바꿔서 연결한다
  8. 의존성 주입과 Context 설계 기법: 구현을 바깥에서 넣어줘서 테스트와 교체를 쉽게 한다
  9. 상태 머신 설계 기법: isLoading && !isError && ... 같은 불리언 조합을 이름 있는 상태로 바꾼다

첫 번째는 플러그인 패턴이다.


1. 문제: 기능이 늘수록 코어 컴포넌트가 커진다

채팅 입력창을 만든다고 하자. 처음에는 "엔터를 누르면 전송"만 있었다. 그런데 요구사항이 하나씩 붙는다.

  • @를 입력하면 멘션 목록을 띄운다
  • :)를 붙여넣으면 🙂로 바꾼다
  • /로 시작하면 슬래시 커맨드로 처리한다
  • 특정 화면에서는 엔터 전송을 끄고 줄바꿈만 한다
  • 툴바에 "이미지 첨부" 버튼을 넣되, 어떤 화면에서는 빼야 한다

플러그인 패턴 없이 그대로 쌓으면 이렇게 된다.

type ChatInputProps = {
  onSubmit: (text: string) => void;
  isMentionEnabled?: boolean;
  isEmojiReplaceEnabled?: boolean;
  isSlashCommandEnabled?: boolean;
  isSubmitOnEnterEnabled?: boolean;
  isImageAttachEnabled?: boolean;
};
 
function ChatInput({
  onSubmit,
  isMentionEnabled,
  isEmojiReplaceEnabled,
  isSlashCommandEnabled,
  isSubmitOnEnterEnabled = true,
  isImageAttachEnabled,
}: ChatInputProps) {
  const [text, setText] = useState("");
 
  const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
    if (isMentionEnabled && event.key === "@") {
      openMentionList();
      return;
    }
    if (isSlashCommandEnabled && text === "" && event.key === "/") {
      openCommandPalette();
      return;
    }
    if (isSubmitOnEnterEnabled && event.key === "Enter" && !event.shiftKey) {
      event.preventDefault();
      onSubmit(text);
    }
  };
 
  const handlePaste = (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
    if (!isEmojiReplaceEnabled) return;
    event.preventDefault();
    const pasted = event.clipboardData.getData("text");
    setText(prev => prev + pasted.replaceAll(":)", "🙂"));
  };
 
  return (
    <div>
      <div role="toolbar">{isImageAttachEnabled && <ImageAttachButton />}</div>
      <textarea
        value={text}
        onChange={e => setText(e.target.value)}
        onKeyDown={handleKeyDown}
        onPaste={handlePaste}
      />
    </div>
  );
}

지금은 읽을 만하다. 문제는 기능 하나를 추가할 때마다 이 파일을 열어야 한다는 점이다.

  • 기능 하나가 props, handleKeyDown, handlePaste, JSX 네 군데에 흩어진다. 멘션을 지우려면 네 곳을 다 찾아야 한다.
  • handleKeyDown 안의 if 순서가 곧 우선순위다. 그런데 그 사실이 코드 어디에도 적혀 있지 않다. 누군가 순서를 바꾸면 엔터 전송이 멘션보다 먼저 실행되는 버그가 생긴다.
  • isXxxEnabled 불리언이 계속 늘어난다. 조합이 2⁵ = 32가지라서 모든 경우를 테스트하기 어렵다.
  • 다른 팀이 "우리 화면에서만 쓰는 기능"을 넣고 싶어도 공용 컴포넌트를 고쳐야 한다.

핵심은 이것이다. ChatInput은 "입력창"이라는 본업 외에, 그 위에 올라가는 모든 기능을 알고 있다.


2. 플러그인 패턴의 구조

플러그인 패턴은 역할을 둘로 나눈다.

  • 코어(host): 본업만 한다. 그리고 "이 시점에 끼어들 수 있다"는 확장 지점(hook) 을 정해서 공개한다.
  • 플러그인: 확장 지점에 자기 함수를 등록한다. 코어는 플러그인이 무슨 일을 하는지 모른다.

방향이 뒤집힌다는 점이 중요하다. Before에서는 코어가 기능을 알았다(코어 → 기능). After에서는 기능이 코어의 확장 지점을 안다(기능 → 코어). 그래서 기능을 추가하거나 지울 때 코어를 열 필요가 없다.

Before

ChatInput이 모든 기능을 직접 안다.

  • 멘션 로직
  • 이모지 변환 로직
  • 슬래시 커맨드 로직
  • 엔터 전송 로직

기능을 추가하거나 지우려면 ChatInput을 고쳐야 한다.

After

**ChatInput(코어)**은 확장 지점만 안다.

  • onKeyDown
  • transformPaste
  • toolbarItems

멘션, 이모지, 엔터 전송 같은 기능은 각자 플러그인 파일로 존재하고, 확장 지점에 자기 함수를 등록한다. ChatInput은 그 플러그인들이 무엇인지 모른다.

2-1. 플러그인 인터페이스: 코어가 공개하는 계약

가장 먼저 정할 것은 "플러그인이 어디에 끼어들 수 있는가"다. 이 타입이 코어와 플러그인 사이의 계약이다.

// chat-input/plugin.ts
export type KeyDownResult = "handled" | "pass";
 
export interface ChatInputContext {
  getText: () => string;
  insertText: (text: string) => void;
}
 
export interface ToolbarItem {
  key: string;
  render: () => React.ReactNode;
}
 
export interface ChatInputPlugin {
  name: string;
  /** 키 입력을 처리했다면 "handled"를 반환한다. 뒤의 플러그인은 실행되지 않는다. */
  onKeyDown?: (event: React.KeyboardEvent, context: ChatInputContext) => KeyDownResult;
  /** 붙여넣은 텍스트를 바꿔서 반환한다. 이전 플러그인의 결과를 입력으로 받는다. */
  transformPaste?: (text: string) => string;
  /** 툴바에 추가할 항목을 반환한다. 모든 플러그인의 결과를 합친다. */
  toolbarItems?: () => ToolbarItem[];
}

모든 확장 지점이 선택(?)이라는 점을 보자. 플러그인은 필요한 지점에만 끼어든다.

그리고 플러그인에 ChatInputContext만 넘긴다. setText나 내부 state를 그대로 넘기지 않는다. 플러그인이 코어 내부 구현에 기대기 시작하면, 코어를 리팩터링할 때 플러그인이 전부 깨지기 때문이다. 플러그인에게 보여주는 면을 좁게 유지하는 것이 이 패턴에서 가장 중요한 설계 결정이다.

2-2. 확장 지점의 세 가지 종류

위 인터페이스의 세 확장 지점은 실행 방식이 모두 다르다. 이 구분은 webpack의 tapable, Rollup의 플러그인 훅에서도 똑같이 나온다.

1) 중단형 (bail)

  • 동작: 앞에서부터 차례로 실행하다가, 누군가 "내가 처리했다"고 반환하면 거기서 멈춘다.
  • 이 글의 예: onKeyDown. 멘션 플러그인이 엔터를 처리했으면 엔터 전송 플러그인은 실행되지 않는다.
  • 다른 곳의 이름: tapable SyncBailHook, Rollup first

2) 이어받기형 (waterfall)

  • 동작: 앞 플러그인이 반환한 값을 다음 플러그인이 입력으로 받는다. 마지막 플러그인의 결과가 최종 결과다.
  • 이 글의 예: transformPaste. 이모지 변환 결과를 다른 플러그인이 이어서 바꿀 수 있다.
  • 다른 곳의 이름: tapable SyncWaterfallHook, Rollup sequential

3) 모으기형 (collect)

  • 동작: 모든 플러그인을 실행하고 결과를 하나로 합친다. 플러그인끼리 서로 영향을 주지 않는다.
  • 이 글의 예: toolbarItems. 각 플러그인이 내놓은 툴바 버튼을 전부 모아서 보여준다.
  • 다른 곳의 이름: tapable SyncHook, Rollup parallel

확장 지점을 만들 때 "이건 어떤 종류인가"를 먼저 정하면, 문서에 적을 내용과 실행 코드가 자연스럽게 정해진다. 위 인터페이스의 JSDoc 주석도 이 세 종류를 그대로 적은 것이다.

2-3. 코어 런타임: 등록된 플러그인을 실행하는 쪽

// chat-input/createPluginRunner.ts
import type { ChatInputContext, ChatInputPlugin, ToolbarItem } from "./plugin";
 
export function createPluginRunner(plugins: ChatInputPlugin[]) {
  assertUniqueNames(plugins);
 
  return {
    // 중단형: 처음으로 "handled"를 반환한 플러그인에서 멈춘다
    runKeyDown(event: React.KeyboardEvent, context: ChatInputContext): boolean {
      return plugins.some(plugin => plugin.onKeyDown?.(event, context) === "handled");
    },
 
    // 이어받기형: 앞 플러그인의 결과가 다음 플러그인의 입력이 된다
    runTransformPaste(text: string): string {
      return plugins.reduce((current, plugin) => plugin.transformPaste?.(current) ?? current, text);
    },
 
    // 모으기형: 모든 플러그인의 결과를 합친다
    collectToolbarItems(): ToolbarItem[] {
      return plugins.flatMap(plugin => plugin.toolbarItems?.() ?? []);
    },
  };
}
 
function assertUniqueNames(plugins: ChatInputPlugin[]) {
  const names = plugins.map(plugin => plugin.name);
  const duplicated = names.find((name, index) => names.indexOf(name) !== index);
  if (duplicated) {
    throw new Error(`[ChatInput] 같은 이름의 플러그인이 두 번 등록됐다: ${duplicated}`);
  }
}

세 종류의 확장 지점이 각각 some, reduce, flatMap 한 줄로 끝난다. 배열 메서드의 의미가 확장 지점의 실행 방식과 정확히 맞아떨어진다.

name을 필수로 둔 이유는 디버깅 때문이다. 플러그인이 많아지면 "누가 이 키 입력을 먹었는지"를 찾아야 하는 순간이 온다. 이름이 있으면 로그와 에러 메시지에 남길 수 있다.

2-4. 코어 컴포넌트

// chat-input/ChatInput.tsx
type ChatInputProps = {
  plugins: ChatInputPlugin[];
};
 
export function ChatInput({ plugins }: ChatInputProps) {
  const [text, setText] = useState("");
  const runner = useMemo(() => createPluginRunner(plugins), [plugins]);
 
  const context: ChatInputContext = {
    getText: () => text,
    insertText: inserted => setText(prev => prev + inserted),
  };
 
  const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
    runner.runKeyDown(event, context);
  };
 
  const handlePaste = (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
    event.preventDefault();
    const pasted = event.clipboardData.getData("text");
    context.insertText(runner.runTransformPaste(pasted));
  };
 
  return (
    <div>
      <div role="toolbar">
        {runner.collectToolbarItems().map(item => (
          <Fragment key={item.key}>{item.render()}</Fragment>
        ))}
      </div>
      <textarea
        value={text}
        onChange={event => setText(event.target.value)}
        onKeyDown={handleKeyDown}
        onPaste={handlePaste}
      />
    </div>
  );
}

예제를 짧게 하려고 insertText는 커서 위치를 무시하고 끝에 붙인다. 실제로는 selectionStart를 써서 커서 위치에 넣어야 한다.

ChatInput에서 "멘션", "이모지", "전송"이라는 단어가 모두 사라졌다. 이 컴포넌트는 이제 텍스트를 들고, 이벤트가 오면 플러그인에게 물어보는 일만 한다.

2-5. 플러그인 작성

// chat-input/plugins/submitOnEnter.ts
export function submitOnEnterPlugin(onSubmit: (text: string) => void): ChatInputPlugin {
  return {
    name: "submit-on-enter",
    onKeyDown: (event, context) => {
      // 한글 조합 중의 Enter는 글자를 확정하는 입력이라 전송하면 안 된다
      const isComposing = event.nativeEvent.isComposing;
      const isPlainEnter = event.key === "Enter" && !event.shiftKey && !isComposing;
      if (!isPlainEnter) return "pass";
 
      event.preventDefault();
      onSubmit(context.getText());
      return "handled";
    },
  };
}
// chat-input/plugins/emojiReplace.ts
const EMOJI_SHORTCUTS: Record<string, string> = {
  ":)": "🙂",
  ":(": "🙁",
  "<3": "❤️",
};
 
export const emojiReplacePlugin: ChatInputPlugin = {
  name: "emoji-replace",
  transformPaste: text =>
    Object.entries(EMOJI_SHORTCUTS).reduce(
      (result, [shortcut, emoji]) => result.replaceAll(shortcut, emoji),
      text,
    ),
};
// chat-input/plugins/imageAttach.tsx
export const imageAttachPlugin: ChatInputPlugin = {
  name: "image-attach",
  toolbarItems: () => [{ key: "image-attach", render: () => <ImageAttachButton /> }],
};

submitOnEnterPlugin처럼 설정값이 필요한 플러그인은 플러그인을 만드는 함수(factory) 로, 설정이 없는 플러그인은 객체 그대로 내보냈다. Vite 플러그인이 react()처럼 함수 호출 형태인 이유도 같다. 옵션을 받아서 플러그인 객체를 만들어 반환하는 것이다.

isComposing 체크는 플러그인 패턴과 별개로 기억해둘 만하다. 이 체크가 없으면 한글을 입력하고 엔터를 칠 때 마지막 글자가 확정되기 전에 전송되거나, 두 번 전송되는 버그가 생긴다.

2-6. 사용하는 쪽

// 일반 채팅방: 모든 기능
const CHAT_ROOM_PLUGINS = [
  mentionPlugin,
  slashCommandPlugin,
  submitOnEnterPlugin(sendMessage),
  emojiReplacePlugin,
  imageAttachPlugin,
];
 
// 긴 글 작성 화면: 엔터는 줄바꿈, 첨부 없음
const LONG_FORM_PLUGINS = [mentionPlugin, emojiReplacePlugin];
 
<ChatInput plugins={CHAT_ROOM_PLUGINS} />
<ChatInput plugins={LONG_FORM_PLUGINS} />

불리언 props 다섯 개가 배열 하나로 바뀌었다. 어떤 화면이 어떤 기능을 쓰는지 배열만 보면 알 수 있고, 배열의 순서가 곧 우선순위라는 규칙이 하나로 정해졌다. 멘션이 엔터 전송보다 먼저 실행된다는 사실이 이제 눈에 보인다.

한 가지 주의할 점이 있다. 플러그인 배열은 컴포넌트 바깥이나 useMemo 안에서 만든다. 렌더할 때마다 새 배열을 만들어 넘기면 useMemo(() => createPluginRunner(plugins), [plugins])가 매번 다시 실행된다.


3. 플러그인 패턴은 어디서 나온 이름인가

GoF의 23개 패턴에는 "플러그인 패턴"이 없다. 플러그인은 클래스 몇 개의 관계를 다루는 GoF 패턴보다 한 단계 큰, 모듈 구조 수준의 패턴으로 다뤄진다.

정식으로 소개된 곳

  • Microkernel(마이크로커널) 패턴: 『Pattern-Oriented Software Architecture 1권』(Buschmann 외, 1996)에 나오는 아키텍처 패턴이다. 꼭 필요한 최소 기능만 가진 핵심부(마이크로커널)를 두고, 나머지 기능은 바깥 모듈로 붙인다. 흔히 "플러그인 아키텍처"라고 부르는 구조가 이것이다. 이 글의 ChatInput이 핵심부, 각 플러그인이 바깥 모듈에 해당한다.
  • Plugin: Martin Fowler의 『Patterns of Enterprise Application Architecture』(2002)에 기본 패턴 중 하나로 실려 있다. 인터페이스의 어떤 구현을 쓸지를 코드에 고정하지 않고, 설정 단계에서 연결한다는 내용이다. 이 글에서 화면마다 다른 플러그인 배열을 넘기는 부분이 여기에 해당한다.

GoF 패턴으로 나눠 보기

플러그인 패턴은 GoF에 없지만, 뜯어보면 GoF 패턴 여러 개가 조합되어 있다. 2장의 코드와 하나씩 맞춰 보면 다음과 같다.

  • 전략(Strategy) 패턴: 모든 플러그인이 같은 인터페이스(ChatInputPlugin)를 구현하고, 코어는 어떤 구현이 들어오든 같은 방식으로 호출한다. 구현을 갈아 끼울 수 있다는 점이 전략 패턴이다.
  • 책임 연쇄(Chain of Responsibility) 패턴: 중단형 확장 지점인 onKeyDown이 여기에 해당한다. 요청(키 입력)을 플러그인에 차례로 넘기고, 처리할 수 있는 플러그인이 처리하면 거기서 멈춘다.
  • 옵저버(Observer) 패턴: 플러그인은 확장 지점에 함수를 등록하고, 코어는 해당 시점이 되면 등록된 함수를 호출한다. 특히 모으기형 확장 지점은 "등록된 모두에게 알린다"는 점에서 옵저버와 가깝다.

바탕에 있는 원칙: 개방-폐쇄 원칙

이 패턴들을 묶는 원칙은 개방-폐쇄 원칙(Open-Closed Principle, OCP) 이다. "소프트웨어는 확장에는 열려 있고, 수정에는 닫혀 있어야 한다"는 원칙으로, SOLID의 O에 해당한다. 이 글의 제목인 "코어를 고치지 않고 기능을 더한다"가 바로 이 원칙을 코드로 옮긴 것이다. 새 기능은 플러그인 파일을 추가하는 것으로 확장하고(열림), ChatInput 파일은 건드리지 않는다(닫힘).

정리하면, 플러그인 패턴은 GoF의 전략·책임 연쇄·옵저버 패턴을 이용해 개방-폐쇄 원칙을 모듈 구조 수준에서 지키는 방법이다.


4. 실제 라이브러리는 어떻게 쓰고 있나

플러그인 패턴은 프론트엔드 생태계 곳곳에 있다. 이미 매일 쓰고 있던 것들이다.

Vite / Rollup

// vite.config.ts
export default defineConfig({
  plugins: [react(), tsconfigPaths(), svgr()],
});

Vite 코어는 "파일을 찾는다(resolveId) → 읽는다(load) → 변환한다(transform)" 같은 빌드 단계만 안다. JSX를 바꾸는 일, SVG를 컴포넌트로 바꾸는 일은 전부 플러그인이 transform 같은 확장 지점에 끼어들어서 한다. resolveId는 처음으로 값을 반환한 플러그인에서 멈추는 중단형, transform은 앞 결과를 이어받는 이어받기형이다. 2-2에서 본 분류 그대로다.

Tiptap / Lexical (리치 텍스트 에디터)

Tiptap은 굵게, 기울임, 표, 멘션이 전부 Extension이다. 코어는 문서 모델과 편집만 담당한다.

const editor = new Editor({
  extensions: [StarterKit, Mention.configure({ suggestion }), Placeholder],
});

Lexical은 editor.registerCommand(KEY_ENTER_COMMAND, handler, COMMAND_PRIORITY_HIGH)처럼 우선순위를 숫자로 받는다. 핸들러가 true를 반환하면 뒤의 핸들러는 실행되지 않는다. 이 글의 onKeyDown과 같은 중단형인데, 순서를 배열 순서가 아니라 우선순위 값으로 정한다는 점이 다르다. 플러그인을 여러 팀이 따로 등록해서 배열 순서를 한곳에서 관리하기 어려울 때 이 방식을 쓴다.

dayjs

import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
 
dayjs.extend(relativeTime);
dayjs("2026-01-01").fromNow(); // "9 months ago" 같은 상대 시간

dayjs는 코어 크기를 2KB 정도로 작게 유지하고, 상대 시간이나 타임존 같은 기능을 플러그인으로 뺐다. 쓰는 기능만 번들에 들어간다. 플러그인이 코어에 메서드를 추가하기 때문에, 각 플러그인 패키지는 declare module "dayjs"로 타입도 함께 확장한다. 플러그인이 코어의 타입을 바꿀 때 어떻게 타입 안전성을 지키는지 보여주는 좋은 예다.

ky / axios 인터셉터

const api = ky.create({
  hooks: {
    beforeRequest: [attachAuthToken],
    afterResponse: [refreshTokenOnUnauthorized, logSlowResponse],
  },
});

HTTP 클라이언트의 훅·인터셉터도 같은 구조다. 코어는 요청을 보내고 받는 일만 하고, 인증 토큰, 로깅, 재시도는 확장 지점에 등록한다. 이건 6편에서 다룰 미들웨어 패턴과 겹치는 부분이 많다.


5. 설계할 때 부딪히는 문제들

순서 의존

중단형과 이어받기형 확장 지점은 순서에 따라 결과가 달라진다. 선택지는 두 가지다.

  • 배열 순서 = 실행 순서 (이 글의 예제, Vite): 단순하다. 한곳에서 배열을 관리할 수 있을 때 쓴다.
  • 우선순위 값 (Lexical, Vite의 enforce: "pre" | "post"): 여러 곳에서 플러그인을 등록할 때 쓴다. 대신 같은 우선순위끼리의 순서는 다시 등록 순서에 의존한다.

어느 쪽이든 규칙을 인터페이스 주석이나 문서에 적어야 한다. 순서 규칙이 코드에만 숨어 있으면 Before의 if 순서 문제가 그대로 돌아온다.

플러그인끼리 직접 통신하지 않게 하기

멘션 플러그인이 멘션 목록을 열었을 때, 엔터 전송 플러그인이 엔터를 무시해야 한다고 하자. 엔터 플러그인이 멘션 플러그인을 import해서 상태를 확인하면, 두 플러그인이 서로 묶여서 하나만 따로 쓸 수 없게 된다.

이 경우는 이미 순서로 해결된다. 멘션 목록이 열려 있으면 멘션 플러그인이 엔터를 먼저 받아서 "handled"를 반환하면 된다. 순서로 풀리지 않는 정보 공유가 필요하다면 코어가 context에 공유 공간을 열어주는 식으로 반드시 코어를 거치게 한다.

에러 격리

플러그인 하나가 예외를 던지면 입력창 전체가 멈춘다. 외부에서 받은 플러그인을 실행하는 코어라면 플러그인 실행을 try/catch로 감싸고, 에러 로그에 plugin.name을 남긴 뒤 다음 플러그인으로 넘어가는 처리가 필요하다. 팀 내부 플러그인만 쓴다면 굳이 감싸지 않고 에러를 그대로 터뜨리는 편이 버그를 빨리 찾게 해준다. 플러그인 작성자를 얼마나 믿을 수 있는지에 따라 정한다.


6. 언제 쓰면 안 되는가

플러그인 패턴은 간접 계층을 하나 더 만든다. Before 코드는 handleKeyDown만 읽으면 무슨 일이 일어나는지 다 보였다. After 코드는 "어떤 플러그인이 어떤 순서로 등록됐는지"를 따로 확인해야 한다. 이 비용보다 얻는 게 클 때만 쓴다.

쓰면 좋은 경우

  • 같은 종류의 기능이 이미 3개 이상이고 계속 늘어날 예정이다
  • 코어가 그 기능들의 내용을 몰라도 된다 (확장 지점만으로 기능을 표현할 수 있다)
  • 화면마다 기능 조합이 다르다
  • 다른 팀이나 외부 사용자가 코어를 고치지 않고 기능을 넣어야 한다

쓰지 않는 게 나은 경우

  • 기능이 한두 개뿐이고 늘어날 계획도 없다. 그냥 props나 조건 분기가 더 읽기 쉽다.
  • 기능마다 코어 내부 상태를 깊이 건드려야 한다. 확장 지점을 계속 넓히다 보면 결국 코어 내부를 다 공개하게 되고, 코어와 플러그인이 다시 강하게 묶인다.
  • 확장 지점을 무엇으로 할지 아직 감이 오지 않는다. 기능을 2~3개 먼저 직접 구현해보고, 반복되는 모양이 보일 때 확장 지점으로 뽑는 편이 안전하다. 처음부터 확장 지점을 잘못 설계하면 바꾸기가 가장 어렵다. 모든 플러그인이 그 계약에 의존하기 때문이다.

7. 4대 원칙으로 다시 보기

토스 Frontend Fundamentals의 네 가지 기준으로 Before와 After를 비교하면 이렇다.

가독성

  • Before: handleKeyDown 하나에 여러 기능의 조건이 섞여 있어서, 한 기능을 이해하려 해도 다른 기능의 조건까지 같이 읽어야 한다.
  • After: 플러그인 파일 하나에는 그 기능의 코드만 있다. 대신 전체 실행 흐름을 보려면 "어떤 플러그인이 어떤 순서로 등록됐는지"를 한 번 더 확인해야 한다.

예측가능성

  • Before: if 문의 순서가 곧 우선순위인데, 그 사실이 어디에도 적혀 있지 않다.
  • After: "배열 순서 = 우선순위"라는 규칙이 하나로 정해져 있고, 확장 지점마다 실행 방식(중단형, 이어받기형, 모으기형)이 타입 주석에 적혀 있다.

응집도

  • Before: 기능 하나가 props, 이벤트 핸들러 두 개, JSX 네 군데에 흩어져 있다.
  • After: 기능 하나가 플러그인 파일 하나다. 기능을 지울 때는 그 파일과 등록 배열의 한 줄만 지우면 된다.

결합도

  • Before: 공용 컴포넌트가 모든 기능을 알고 있어서, 기능을 추가하는 일이 곧 공용 컴포넌트를 수정하는 일이다.
  • After: 코어는 ChatInputPlugin 타입만 안다. 플러그인끼리도 서로를 모른다.

가독성 항목에 적었듯이 얻기만 하는 건 아니다. 각 파일은 읽기 쉬워지지만, 전체 흐름을 따라가려면 등록 목록을 확인해야 한다. 그래서 6장의 "쓰지 말아야 할 때"가 중요하다.


직접 해볼 것

공부한 내용을 손으로 확인하려고 남겨두는 연습 과제다.

  • 위 createPluginRunner에 플러그인별 try/catch를 넣고, 에러 로그에 플러그인 이름을 남기기
  • priority?: number 필드를 추가해서, 배열 순서 대신 우선순위로 정렬해 실행하기
  • Vite 플러그인 하나를 직접 만들어보기 (예: 빌드 시 console.log를 지우는 transform 플러그인)
  • 지금 일하는 코드베이스에서 isXxxEnabled 불리언 props가 4개 이상인 컴포넌트를 찾아, 플러그인 패턴이 맞는 경우인지 6장 기준으로 판단해보기

참고 자료