포모동은 포모도로 타이머에 앰비언트 사운드 믹서를 붙인 서비스다. 빗소리, 카페, 벽난로, 밤 방 네 가지 소리를 원하는 만큼 켜고, 트랙마다 볼륨을 따로 맞출 수 있다. 기능 설명은 한 줄이지만 실제로 만들어 보면 "동시에 여러 개를 튼다"는 요구가 생각보다 많은 경계 조건을 만든다. 이 글은 useSound 훅을 만들며 내린 결정과, 중간에 버린 시도를 정리한 기록이다.

Web Audio API 대신 HTMLAudioElement를 고른 이유
브라우저에서 여러 소리를 섞는 정석은 Web Audio API다. AudioContext 하나에 트랙마다 AudioBufferSourceNode와 GainNode를 연결하면 샘플 단위로 정확한 믹싱과 부드러운 볼륨 곡선을 얻을 수 있다.
그래도 포모동은 new Audio(), 즉 HTMLAudioElement를 트랙마다 하나씩 만드는 방식을 택했다. 이유는 세 가지였다.
- 앰비언트 사운드는 정밀한 타이밍이 필요 없다. 빗소리와 카페 소리가 몇 밀리초 어긋나도 아무도 알아채지 못한다. 리듬이 있는 음악이었다면 이야기가 달랐을 것이다.
- 파일을 통째로 디코딩하지 않아도 된다.
AudioBuffer를 쓰려면 파일 전체를 내려받아 메모리에 디코딩된 PCM으로 올려야 한다. 몇 분짜리 WAV 네 개를 모두 올리면 모바일에서 메모리 부담이 크다.HTMLAudioElement는 스트리밍 재생이 가능하다. - 반복 재생이 속성 하나로 끝난다.
audio.loop = true만 설정하면 된다.
단점도 분명하다. 볼륨 변화를 곡선으로 주는 기능이 없어서 페이드를 직접 구현해야 하고, iOS Safari에서는 HTMLMediaElement.volume 속성을 자바스크립트로 바꿀 수 없다. 이 문제는 뒤에서 다시 다룬다.
상태는 두 층으로 나눈다
믹서의 상태는 "사용자가 원하는 것"과 "실제로 재생 중인 것"으로 나눴다.
export interface TrackState {
enabled: boolean;
volume: number;
}
export type MixerState = Record<string, TrackState>;
// 사용자가 원하는 상태: 화면에 그리고 localStorage에 저장한다
const [tracks, setTracks] = useState<MixerState>(getDefaultMixerState);
// 실제로 소리가 나고 있는 트랙: play() 프로미스가 성공한 뒤에만 추가한다
const [playingTracks, setPlayingTracks] = useState<Set<string>>(new Set());
// 오디오 객체는 렌더링과 무관하므로 ref에 둔다
const audioRefs = useRef<Map<string, AudioTrack>>(new Map());
이렇게 나눈 이유는 audio.play()가 프로미스를 반환하는 비동기 함수이기 때문이다. 토글을 켠 순간 enabled는 바로 true가 되지만, 실제 재생은 파일 로딩과 브라우저 자동 재생 정책을 통과한 뒤에야 시작된다. 재생이 거부되면 enabled는 켜져 있는데 소리는 안 나는 상태가 된다. 이 차이를 하나의 상태로 표현하려 하면 UI가 거짓말을 하게 된다.
빠르게 껐다 켜면 생기는 경쟁 상태
처음 구현에서 가장 자주 만난 버그는 "토글을 빠르게 여러 번 누르면 같은 소리가 두 겹으로 겹쳐 나온다"였다. 흐름은 이렇다.
- 켜기 →
new Audio()A 생성,A.play()대기 중 - 끄기 → A를 멈추려 하지만 아직 재생이 시작되지 않음
- 다시 켜기 →
new Audio()B 생성,B.play()대기 중 - A의
play()가 뒤늦게 성공 → A와 B가 함께 재생
해결책은 재생 요청마다 고유한 번호(토큰)를 붙이고, 프로미스가 끝났을 때 그 요청이 아직 최신인지 확인하는 것이었다.
const playTrack = useCallback((soundId: string) => {
const sound = sounds.find((s) => s.id === soundId);
const trackState = tracksRef.current[soundId];
if (!sound || !trackState?.enabled) return;
hardStopTrack(soundId);
const token = ++playTokenRef.current;
const audio = new Audio(sound.file);
audio.loop = true;
audio.volume = Math.max(0, Math.min(1, trackState.volume));
audioRefs.current.set(soundId, { audio, playToken: token });
audio.play()
.then(() => {
const current = audioRefs.current.get(soundId);
// 그사이 다른 요청이 들어왔다면 이 오디오는 버린다
if (!current || current.playToken !== token) {
audio.pause();
return;
}
// 기다리는 동안 사용자가 끄거나 볼륨을 바꿨을 수 있으니 최신 값을 다시 읽는다
const latest = tracksRef.current[soundId];
if (!latest?.enabled) {
hardStopTrack(soundId);
return;
}
const vol = Math.max(0, Math.min(1, latest.volume));
current.audio.volume = vol;
current.audio.muted = vol === 0;
setPlayingTracks((prev) => new Set(prev).add(soundId));
})
.catch(() => {
const current = audioRefs.current.get(soundId);
if (current?.playToken === token) {
audioRefs.current.delete(soundId);
markStopped(soundId);
}
});
}, [hardStopTrack, markStopped]);
여기서 중요한 점은 tracks 상태가 아니라 tracksRef.current를 읽는다는 것이다. 프로미스 콜백은 생성 시점의 클로저를 갖고 있어서, 상태 변수를 직접 읽으면 오래된 값을 보게 된다. 그래서 상태를 바꿀 때마다 ref에도 같은 값을 즉시 써 넣는다.
const toggleTrack = useCallback((soundId: string) => {
const current = tracksRef.current[soundId];
if (!current) return;
const nextTracks = { ...tracksRef.current, [soundId]: { ...current, enabled: !current.enabled } };
tracksRef.current = nextTracks; // 렌더링을 기다리지 않고 즉시 반영
setTracks(nextTracks);
if (!current.enabled) playTrack(soundId);
else stopTrack(soundId);
}, [playTrack, stopTrack]);
현재 코드에서 확인해 보기
토큰 방식이 실제로 소리 겹침을 막는지, 트랙마다 토글 버튼을 빠르게 연타하고 1.5초 뒤 재생 중인 Audio를 세어 보았다. 클릭은 element.click()을 반복 호출해 사람이 누르는 것보다 훨씬 빠르게 넣었다.
- 환경: Chromium 156.0.8078.4(Playwright 1.64.1, headless,
--autoplay-policy=no-user-gesture-required), macOS 26.6.2 arm64(Apple M5 Pro), Node v22.22.2, 포모동 운영 빌드(out/), 2026-10-23 측정
| 조작(트랙마다) | play()가 호출된 Audio 수 |
1.5초 뒤 재생 중인 Audio 수 |
|---|---|---|
| 9번 연타 (마지막 상태: 켜짐) | 5 | 1 |
| 10번 연타 (마지막 상태: 꺼짐) | 5 | 0 |
네 트랙 모두 같은 결과였다. 9번 누르는 동안 Audio가 다섯 번 만들어져 재생을 시도하지만 마지막에 재생 중인 것은 하나뿐이고, 짝수 번 누르면 아무것도 남지 않는다. 한 트랙에서 소리가 두 겹으로 겹치는 상태는 이 조건에서 나타나지 않았다.
페이드 아웃을 포기한 이유
설정 파일에는 아직 fadeInDuration: 1000, fadeOutDuration: 500 값이 남아 있다. 처음에는 트랙을 끌 때 setInterval로 볼륨을 조금씩 줄이는 페이드 아웃을 넣었다. 그런데 페이드 도중에 사용자가 볼륨 슬라이더를 움직이거나, 타이머가 끝나 전체 정지가 호출되면 세 군데에서 같은 오디오의 볼륨을 동시에 바꾸게 됐다. 그 결과 "껐는데 작은 소리로 계속 재생되는" 오디오가 남았다.
결국 끄기는 즉시 멈추는 방식으로 바꿨다. 코드에 남긴 주석이 그 결정을 설명한다.
const stopTrack = useCallback((soundId: string) => {
// 즉시 정지 — 페이드가 볼륨·타이머와 경쟁하면서 오디오가 살아남는 문제가 있었다
hardStopTrack(soundId);
}, [hardStopTrack]);
앰비언트 사운드는 원래 배경에 깔리는 소리라 갑자기 꺼져도 거슬림이 적었다. 기능을 하나 덜어 내고 상태를 단순하게 만든 쪽이 사용자 경험에 더 나았다.
볼륨 0은 일시정지가 아니라 음소거
슬라이더를 0까지 내렸을 때 오디오를 멈출지, 음소거만 할지도 고민이었다. 멈추면 자원을 아낄 수 있지만, 다시 올렸을 때 재생 요청을 새로 보내야 해서 슬라이더가 즉각 반응하지 않는다. 포모동은 0에서 muted = true만 설정하고 재생은 유지한다. 슬라이더를 다시 올리는 순간 소리가 바로 이어진다.
설정 저장: 새 트랙이 추가되어도 깨지지 않게
믹서 상태는 localStorage에 JSON으로 저장한다. 주의할 점은 저장된 값을 그대로 믿지 않는 것이다. 나중에 새 사운드를 추가하면 예전 사용자의 저장값에는 그 트랙이 없다. 반대로 사운드를 제거하면 저장값에 존재하지 않는 트랙이 남는다. 그래서 불러올 때 항상 현재 사운드 목록을 기준으로 기본값을 만들고, 저장값에서 아는 키만 덮어쓴다.
function loadMixerSettings(): MixerState {
if (typeof window === "undefined") return getDefaultMixerState();
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved) as MixerState;
const defaultState = getDefaultMixerState();
for (const soundId of Object.keys(defaultState)) {
if (parsed[soundId]) {
defaultState[soundId] = {
enabled: parsed[soundId].enabled ?? false,
volume: parsed[soundId].volume ?? soundConfig.defaultVolume,
};
}
}
return defaultState;
}
} catch {
// 저장값이 깨졌으면 기본값으로 시작한다
}
return getDefaultMixerState();
}
또 하나, 초기 렌더링에서는 기본값으로 시작하고 useEffect에서 저장값을 불러온다. 정적 생성(SSG)된 HTML에는 localStorage가 없기 때문에, 처음부터 저장값으로 렌더링하면 서버 HTML과 클라이언트 첫 렌더가 달라져 하이드레이션 경고가 난다. 불러오기가 끝나기 전에는 저장도 하지 않도록 isLoaded 플래그를 둬서, 기본값이 사용자의 저장값을 덮어쓰는 사고를 막았다.
남은 과제
- iOS의 볼륨 제한: Apple 문서에 따르면 iOS에서는 오디오 크기를 사용자가 물리적으로 제어하며, 미디어 요소의
volume속성은 자바스크립트로 설정할 수 없고 읽으면 항상 1을 돌려준다(Apple: Device-Specific Considerations). 그렇다면 iOS에서는 트랙별 볼륨 슬라이더가 동작하지 않을 것으로 예상된다. 실제 iPhone에서 포모동 슬라이더를 확인하지는 못했다. 트랙별 볼륨을 iOS에서도 지원하려면 결국MediaElementAudioSourceNode와GainNode를 연결하는 Web Audio 방식으로 옮겨야 한다. - 반복 구간의 이음매: 현재 사운드 파일은 WAV다. 네 파일 모두 44.1kHz, 스테레오, 16비트, 60초이고 파일 하나가 10,584,044바이트(약 10.1MiB)다(2026-10-23 확인). 일반적으로 MP3는 인코더가 앞뒤에 무음 패딩을 넣어 반복 재생 때 짧은 틈이 생기기 쉽고, WAV는 이 문제가 적은 대신 용량이 크다. 이음매 없는 반복과 용량을 모두 잡으려면 Web Audio의
AudioBufferSourceNode.loop가 더 적합하다.
"여러 소리를 동시에 튼다"는 기능에서 가장 많은 시간을 쓴 부분은 소리가 아니라 비동기 요청과 사용자 입력의 순서였다. 오디오처럼 결과가 늦게 도착하는 API를 다룰 때는 요청마다 토큰을 붙이고, 도착한 결과가 여전히 유효한지 확인하는 패턴이 거의 항상 필요하다.