React 프로젝트에서 반복 코드를 줄이는 2가지 리팩터링 패턴

2026년 03월 27일

피아노 웹 서비스를 만들다 보니 같은 모양의 코드가 여러 곳에 흩어져 있었습니다. 그걸 두 가지 패턴으로 정리해봤습니다.


1. 악기가 추가될 때마다 switch/case가 늘어났습니다 — Strategy 패턴

문제 발견

피아노 소리를 Tone.js로 재생하는 기능에서, 사용자가 악기 종류를 고를 수 있게 만들어야 했습니다. 그랜드 피아노는 샘플 파일을 로드하고, 일렉트릭 피아노·신스 패드·오르간 같은 나머지는 Tone.js의 PolySynth로 생성합니다.

악기마다 옵션 값만 다를 뿐 작성하는 코드 모양은 거의 같은데, switch/case로 분기하다 보니 케이스마다 같은 코드를 옵션만 바꿔서 반복 작성하고 있었습니다.

function createSynthInstrument(id: InstrumentId): Tone.PolySynth {
  switch (id) {
    case "electric-piano": {
      const s = new Tone.PolySynth(Tone.FMSynth);
      s.set({
        harmonicity: 3,
        modulationIndex: 1.5,
        oscillator: { type: "sine" },
        envelope: { attack: 0.01, decay: 0.4, sustain: 0.3, release: 1.2 },
        modulation: { type: "square" },
        modulationEnvelope: { attack: 0.01, decay: 0.2, sustain: 0.1, release: 0.5 },
      });
      s.maxPolyphony = 16;
      return s.toDestination();
    }
 
    case "synth-pad": {
      const s = new Tone.PolySynth(Tone.Synth);
      s.set({
        oscillator: { type: "triangle8" },
        envelope: { attack: 0.3, decay: 0.5, sustain: 0.7, release: 2 },
      });
      s.maxPolyphony = 16;
      return s.toDestination();
    }
 
    case "organ": {
      // ... 또 비슷한 코드
    }
 
    case "bright-synth": {
      // ... 또 비슷한 코드
    }
 
    default:
      // ...
  }
}

악기마다 설정 값만 다르고 new PolySynth → set(옵션) → maxPolyphony → toDestination 흐름은 동일합니다. 악기를 하나 추가할 때마다 case 블록이 늘어나고, 공통 코드(maxPolyphony, toDestination)도 매번 같이 반복됩니다.

해결: Strategy 패턴

악기 ID와 생성 함수를 매핑하는 전략 객체를 만들고, 공통 로직은 헬퍼 함수로 뽑았습니다.

// 공통 생성 로직을 헬퍼로 추출
function makeSynth(options: Record<string, unknown>): Tone.PolySynth {
  const s = new Tone.PolySynth(Tone.Synth);
  s.set(options);
  s.maxPolyphony = 16;
  return s.toDestination();
}
 
function makeFMSynth(options: Record<string, unknown>): Tone.PolySynth {
  const s = new Tone.PolySynth(Tone.FMSynth);
  s.set(options);
  s.maxPolyphony = 16;
  return s.toDestination();
}
 
// 악기 ID → 생성 함수 매핑
const INSTRUMENT_STRATEGIES: Record<InstrumentId, () => Tone.PolySynth> = {
  "electric-piano": () =>
    makeFMSynth({
      harmonicity: 3,
      modulationIndex: 1.5,
      oscillator: { type: "sine" },
      envelope: { attack: 0.01, decay: 0.4, sustain: 0.3, release: 1.2 },
      modulation: { type: "square" },
      modulationEnvelope: { attack: 0.01, decay: 0.2, sustain: 0.1, release: 0.5 },
    }),
 
  "synth-pad": () =>
    makeSynth({
      oscillator: { type: "triangle8" },
      envelope: { attack: 0.3, decay: 0.5, sustain: 0.7, release: 2 },
    }),
 
  "organ": () =>
    makeSynth({
      oscillator: { type: "sine4" },
      envelope: { attack: 0.01, decay: 0.1, sustain: 0.9, release: 0.3 },
    }),
 
  "bright-synth": () =>
    makeSynth({
      oscillator: { type: "sawtooth" },
      envelope: { attack: 0.005, decay: 0.2, sustain: 0.4, release: 0.8 },
    }),
};

createSynthInstrument 함수는 이제 ID로 전략을 골라 실행하기만 하면 됩니다.

function createSynthInstrument(id: InstrumentId): Tone.PolySynth {
  return INSTRUMENT_STRATEGIES[id]();
}

수십 줄짜리 switch/case가 한 줄로 줄었습니다. 이 구조가 바로 Strategy 패턴입니다. 같은 인터페이스(() => Tone.PolySynth)를 가진 여러 구현(전략)을 객체에 모아두고, 호출 시점에 ID로 골라 실행하는 형태입니다.

호출하는 쪽은 어떤 악기가 어떻게 만들어지는지 몰라도 됩니다. ID만 넘기면 해당 전략이 실행되고, 결과 타입은 항상 Tone.PolySynth로 동일합니다.

새 악기를 추가하는 일은 전략 객체에 키-값 하나를 더하는 것으로 끝납니다. 호출부도, makeSynth/makeFMSynth 헬퍼도 수정할 필요가 없습니다. 변경의 영향 범위가 한 곳으로 좁혀집니다.

// 새 악기 추가: 옵션만 넣으면 끝
"new-synth": () =>
  makeSynth({
    oscillator: { type: "square" },
    envelope: { attack: 0.01, decay: 0.3, sustain: 0.5, release: 1 },
  }),

2. useRef + .current 갱신이 모든 훅에서 반복되고 있었습니다 — useCallbackRef

배경: 키보드 입력을 감지하는 훅

피아노 건반을 컴퓨터 키보드로 누르면 소리가 나는 기능이 있습니다. 이걸 구현하려면 window에 keydown 이벤트 리스너를 등록해야 합니다.

function useKeyboardInput({ onNoteOn, onNoteOff }) {
  useEffect(() => {
    function handleKeyDown(e: KeyboardEvent) {
      onNoteOn(midiFromKey(e.code), 0.8);
    }
    function handleKeyUp(e: KeyboardEvent) {
      onNoteOff(midiFromKey(e.code));
    }
 
    window.addEventListener("keydown", handleKeyDown);
    window.addEventListener("keyup", handleKeyUp);
    return () => {
      window.removeEventListener("keydown", handleKeyDown);
      window.removeEventListener("keyup", handleKeyUp);
    };
  }, [onNoteOn, onNoteOff]); // 문제: 콜백이 바뀔 때마다 리스너가 재등록됨
}

문제는 의존성 배열에 들어간 onNoteOn, onNoteOff입니다. 부모 컴포넌트가 리렌더될 때마다 이 콜백들의 참조가 바뀌고, 그때마다 리스너가 해제됐다가 다시 등록됩니다. 렌더가 잦은 상황에서는 매번 리스너를 떼었다 붙이는 비용이 따라옵니다.

이걸 피하려고 흔히 쓰는 게 ref 패턴입니다.

const onNoteOnRef = useRef(onNoteOn);
onNoteOnRef.current = onNoteOn; // 매 렌더마다 최신 콜백으로 갱신
 
useEffect(() => {
  function handleKeyDown(e: KeyboardEvent) {
    onNoteOnRef.current(midiFromKey(e.code), 0.8); // ref를 통해 호출
  }
  window.addEventListener("keydown", handleKeyDown);
  return () => window.removeEventListener("keydown", handleKeyDown);
}, []); // 의존성 배열이 비어있으므로 리스너는 한 번만 등록됨

ref는 값이 바뀌어도 리렌더를 일으키지 않으므로 의존성에서 빠질 수 있습니다. 그 결과 리스너는 마운트 시 한 번만 등록되고, 콜백은 항상 최신 것을 참조합니다.

문제: 이 패턴이 프로젝트 전체에 퍼져 있었습니다

// use-keyboard-input.ts
const onNoteOnRef = useRef(onNoteOn);
const onNoteOffRef = useRef(onNoteOff);
const onSustainChangeRef = useRef(onSustainChange);
onNoteOnRef.current = onNoteOn;
onNoteOffRef.current = onNoteOff;
onSustainChangeRef.current = onSustainChange;
 
// use-midi-input.ts
const onNoteOnRef = useRef(onNoteOn);
const onNoteOffRef = useRef(onNoteOff);
onNoteOnRef.current = onNoteOn;
onNoteOffRef.current = onNoteOff;
 
// use-chord-validator.ts
const onCorrectRef = useRef(onCorrect);
const onIncorrectRef = useRef(onIncorrect);
onCorrectRef.current = onCorrect;
onIncorrectRef.current = onIncorrect;
 
// use-metronome.ts
const onBeatRef = useRef(onBeat);
const onBarCompleteRef = useRef(onBarComplete);
onBeatRef.current = onBeat;
onBarCompleteRef.current = onBarComplete;

4개 파일에서 같은 두 줄(useRef 선언 + .current 갱신)이 약 20줄 분량으로 반복되고 있었습니다.

게다가 .current 갱신 줄을 빠뜨리기 쉽습니다. 빠뜨리면 이전 렌더의 콜백이 그대로 호출되어, 옥타브를 3에서 4로 바꿨는데도 키를 누르면 옥타브 3 소리가 나는 식의 버그가 생깁니다.

해결: 유틸 훅 추출

반복되는 2줄을 하나의 훅으로 만들었습니다.

// shared/lib/react/use-callback-ref.ts
import { useRef } from "react";
 
export function useCallbackRef<T>(callback: T): React.RefObject<T> {
  const ref = useRef(callback);
  ref.current = callback;
  return ref;
}

반환 타입을 React.RefObject<T>로 둔 이유는 사용하는 쪽에서 .current를 덮어쓰지 못하게 막기 위해서입니다. MutableRefObject<T>였다면 외부에서 .current = ...로 갱신할 수 있는데, 그러면 훅이 보장하려던 "콜백은 항상 최신"이라는 약속이 깨질 수 있습니다.

적용 결과

// Before — 파일마다 2줄씩 반복
const onNoteOnRef = useRef(onNoteOn);
const onNoteOffRef = useRef(onNoteOff);
onNoteOnRef.current = onNoteOn;
onNoteOffRef.current = onNoteOff;
 
// After
const onNoteOnRef = useCallbackRef(onNoteOn);
const onNoteOffRef = useCallbackRef(onNoteOff);

정리

패턴적용 전적용 후
Strategy악기마다 switch/case + 공통 코드 반복ID → 팩토리 함수 매핑, 헬퍼로 공통화
useCallbackRef4파일에 ref 동기화 반복유틸 훅 1줄로 대체