피아노 웹 서비스를 만들면서 기능을 하나씩 구현하다 보니 비슷한 코드가 여러 곳에 반복되어 있었습니다.
그래서 적용한 두 가지 패턴을 기록해보았습니다.
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 → 팩토리 함수 매핑, 헬퍼로 공통화 |
| useCallbackRef | 4파일에 ref 동기화 반복 | 유틸 훅 1줄로 대체 |
반복은 타이핑이 귀찮을 뿐만 아니라, 수정할 때 빠뜨리는 곳이 생기기 때문에 문제입니다.