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 },
    }),
};

새 악기를 추가할 때 switch/case 버전에서는 case 블록을 추가하면서 maxPolyphony 설정과 toDestination 호출을 매번 직접 써야 했습니다. Strategy 패턴에서는 전략 객체에 옵션만 추가하면 되고, 공통 로직은 헬퍼 함수가 처리합니다.

// 새 악기 추가: 옵션만 넣으면 끝
"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를 useEffect 의존성 배열에 넣으면, 부모 컴포넌트가 리렌더될 때마다 콜백 참조가 바뀌고, 그때마다 리스너를 해제했다가 다시 등록합니다. 동작은 하지만, 렌더가 잦은 상황에서 매번 리스너를 떼었다 붙이는 건 불필요한 연산입니다.

그래서 흔히 쓰는 해결법이 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는 값이 바뀌어도 리렌더를 일으키지 않으므로, useEffect 의존성에서 빠질 수 있습니다. 리스너는 마운트 시 한 번만 등록되고, 콜백은 항상 최신 것을 참조합니다.

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

// 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개 파일, 20줄 정도가 이 패턴의 반복이었습니다.

useRef 선언은 했는데 .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: T } 형태의 객체입니다. useRef가 반환하는 타입이 이것이고, .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);

4개 파일에서 총 10줄을 제거했습니다.


정리

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

반복은 타이핑이 귀찮을 뿐만 아니라, 수정할 때 빠뜨리는 곳이 생기기 때문에 문제입니다.