피아노 웹 서비스를 만들다 보니 같은 모양의 코드가 여러 곳에 흩어져 있었습니다. 그걸 두 가지 패턴으로 정리해봤습니다.
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 → 팩토리 함수 매핑, 헬퍼로 공통화 |
| useCallbackRef | 4파일에 ref 동기화 반복 | 유틸 훅 1줄로 대체 |