인타바는 운동과 휴식을 번갈아 알려 주는 인터벌·타바타 타이머다. 운동 중에는 화면을 보기 어렵기 때문에 소리가 곧 UI다. 운동이 시작될 때, 휴식으로 바뀔 때, 구간이 끝나기 3초 전, 모든 라운드가 끝났을 때 각각 다른 소리가 나야 한다.

인타바 실행 화면. 운동 구간 00:01, 라운드 2/2, 진행 막대와 일시정지·건너뛰기·초기화 버튼이 보인다

처음에는 효과음 파일을 몇 개 준비할 생각이었다. 하지만 필요한 소리가 모두 "삐" 하는 단순한 음이었고, 높낮이와 길이만 다르면 충분했다. 그래서 파일 대신 Web Audio API의 OscillatorNode로 소리를 즉석에서 만들었다. 이 글은 그 구현과, 나중에 다시 보니 고쳐야 할 점이 있었던 부분을 함께 정리한다.

OscillatorNode로 "삐" 소리 만들기

Web Audio API에서 소리는 노드를 연결해 만든다. 소리를 만드는 노드(오실레이터)를 볼륨을 조절하는 노드(게인)에 연결하고, 게인을 스피커(destination)에 연결한다.

const playBeep = useCallback((frequency = 800, duration = 150) => {
  if (!config.soundEnabled) return;
  try {
    if (!audioContextRef.current) {
      audioContextRef.current = new (window.AudioContext ||
        (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)();
    }
    const ctx = audioContextRef.current;
    const oscillator = ctx.createOscillator();
    const gainNode = ctx.createGain();

    oscillator.connect(gainNode);
    gainNode.connect(ctx.destination);

    oscillator.frequency.value = frequency; // 음 높이(Hz)
    oscillator.type = "sine";               // 가장 부드러운 사인파

    // 시작 볼륨 0.3에서 duration 동안 0.01까지 지수적으로 줄인다
    gainNode.gain.setValueAtTime(0.3, ctx.currentTime);
    gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + duration / 1000);

    oscillator.start(ctx.currentTime);
    oscillator.stop(ctx.currentTime + duration / 1000);
  } catch {
    // Web Audio를 지원하지 않는 환경에서는 소리 없이 진행한다
  }
}, [config.soundEnabled]);

몇 가지 포인트가 있다.

볼륨을 뚝 끊지 않고 줄인다. 사인파를 갑자기 멈추면 파형이 중간에 잘리면서 "틱" 하는 잡음이 난다. 게인을 짧게라도 줄여 주면 끝이 깔끔해진다. 이때 exponentialRampToValueAtTime은 목표값으로 0을 받지 않는다. 지수 곡선은 0에 도달할 수 없기 때문이다. 그래서 0 대신 0.01처럼 아주 작은 양수를 쓴다.

오실레이터는 일회용이다. OscillatorNode는 한 번 stop()하면 다시 start()할 수 없다. 그래서 매번 새로 만든다. 노드를 만드는 비용은 아주 작고, 재생이 끝난 노드는 연결이 끊긴 뒤 가비지 컬렉션 대상이 된다.

AudioContext는 하나만 재사용한다. 반대로 AudioContext는 오디오 장치와 연결된 무거운 객체라 비프음마다 새로 만들 이유가 없다. ref에 하나만 만들어 두고 계속 쓴다.

소리로 단계를 구분하는 설계

같은 비프음 함수 하나로 상황별 신호를 만들었다.

const playPhaseChangeSound = useCallback((phase: TimerPhase) => {
  if (phase === "work") {
    // 올라가는 두 음: "시작!"
    playBeep(1000, 200);
    setTimeout(() => playBeep(1200, 200), 150);
  } else if (phase === "rest" || phase === "setRest") {
    // 낮고 긴 한 음: "쉬어"
    playBeep(600, 300);
  } else if (phase === "completed") {
    // 세 음 상승: "끝!"
    playBeep(800, 150);
    setTimeout(() => playBeep(1000, 150), 150);
    setTimeout(() => playBeep(1200, 300), 300);
  }
}, [playBeep]);

남은 시간이 3초 이하일 때는 500Hz의 짧은 음을 매초 울린다. 운동 시작은 올라가는 높은 음, 휴식은 낮고 긴 음으로 구분되어 있어서, 운동하면서 화면을 보지 않아도 음의 높낮이만으로 지금 상태를 알 수 있다.

주파수와 파형을 고른 기준

운동 중에는 음악, 호흡 소리, 헬스장 소음이 섞여 있다. 비프음은 그 위에서도 들려야 하지만 귀에 거슬리면 안 된다. 사람의 귀는 수백 Hz에서 수 kHz 대역의 소리를 잘 듣고, 아주 낮은 소리는 같은 볼륨이어도 작게 느껴진다. 인타바의 비프음은 5001,200Hz 사이에 모여 있다. 낮은 쪽(500600Hz)은 카운트다운과 휴식처럼 "주의"를 주는 소리, 높은 쪽(1,000~1,200Hz)은 운동 시작처럼 "행동"을 촉구하는 소리에 쓰인다.

파형은 사인파(sine)다. square나 sawtooth 파형은 배음이 많아 더 날카롭고 잘 들리지만, 운동하는 내내 몇십 번씩 들으면 피로감이 크다. 사인파는 부드러운 대신 작게 들릴 수 있어서, 시작 볼륨 0.3에 길이 100~300ms로 짧게 끊는 구성이다. 소리가 너무 작다는 피드백이 오면 파형을 triangle로 바꾸는 것이 볼륨을 올리는 것보다 자연스러운 선택이 될 것이다.

자동 재생 정책: 첫 소리는 클릭에서

브라우저는 사용자가 페이지와 상호작용하기 전에는 소리를 내지 못하게 한다. 사용자 제스처 없이 만든 AudioContext는 suspended 상태로 시작한다. 인타바는 AudioContext를 미리 만들지 않고 처음 소리가 필요할 때 만든다. 그 첫 순간이 "시작" 버튼을 누른 직후의 start() 안이기 때문에, 사용자 제스처 안에서 컨텍스트가 만들어지고 이후 타이머가 울리는 소리도 정상 재생된다.

더 안전하게 하려면 재생 직전에 상태를 확인하고 깨우는 코드를 넣는 것이 좋다. 탭이 오래 백그라운드에 있다가 돌아오면 일부 브라우저는 컨텍스트를 다시 중단시키기도 한다.

if (ctx.state === "suspended") {
  void ctx.resume();
}

다시 보니 문제였던 부분: 업데이트 함수 안의 소리

타이머의 매초 처리는 이렇게 되어 있었다.

const tick = useCallback(() => {
  setState((prev) => {
    if (!prev.isRunning || prev.isPaused) return prev;
    const newTimeRemaining = prev.timeRemaining - 1;
    if (newTimeRemaining > 0) {
      if (newTimeRemaining <= 3) playBeep(500, 100); // ← 업데이트 함수 안에서 소리 재생
      return { ...prev, timeRemaining: newTimeRemaining };
    }
    if (prev.phase === "work") {
      playPhaseChangeSound("rest"); // ← 여기도
      return { ...prev, phase: "rest", timeRemaining: config.restDuration };
    }
    // ...
  });
}, [/* ... */]);

setState에 넘기는 업데이트 함수는 순수 함수여야 한다. React는 개발 모드의 StrictMode에서 업데이트 함수를 일부러 두 번 호출해 부수 효과가 섞여 있는지 드러내고, 동시성 렌더링에서는 업데이트를 다시 계산할 수 있다. 업데이트 함수 안에서 소리를 내면 개발 환경에서는 비프음이 두 번 겹쳐 들리고, 이론적으로는 운영 환경에서도 같은 일이 생길 수 있다.

직접 확인해 보았다. 운동 5초, 휴식 3초, 2라운드, 1세트로 설정하고 시작해서 14초 동안 OscillatorNode.prototype.start 호출을 기록했다.

  • 환경: Chromium 156.0.8078.4(Playwright 1.64.1, headless), macOS 26.6.2 arm64(Apple M5 Pro), Node v22.22.2, Next.js 16.3.5(reactStrictMode: true), 2026-10-23 측정
실행 방식 14초 동안 오실레이터 시작 횟수 같은 시각에 두 번 시작된 소리
운영 빌드(out/) 14 없음
개발 서버(next dev, StrictMode) 26 남은 3초 비프와 구간 전환음 전부

운영 빌드에서는 비프와 전환음이 모두 한 번씩만 울렸다. 개발 서버에서는 업데이트 함수 안에서 재생하는 소리(2초, 3초, 4초의 500Hz 비프, 5초의 휴식 전환음 등)가 같은 시각에 두 번씩 시작됐고, 시작 버튼 클릭에서 바로 재생하는 첫 소리만 한 번이었다. 이 측정에서 운영 빌드의 중복은 재현되지 않았다. 이론상의 가능성은 남아 있지만, 개발 중에 소리가 겹쳐 들리는 현상은 이 구조 때문임을 확인했다.

개선 방향은 "상태 계산"과 "소리 재생"을 분리하는 것이다. 업데이트 함수는 다음 상태만 계산하고, 소리는 바뀐 상태를 보고 이펙트에서 낸다.

// 1) 업데이트 함수는 다음 상태만 계산한다
const tick = useCallback(() => {
  setState((prev) => nextTimerState(prev, config));
}, [config]);

// 2) 소리는 상태 변화를 관찰해서 낸다
const prevRef = useRef(state);
useEffect(() => {
  const prev = prevRef.current;
  prevRef.current = state;
  if (prev.phase !== state.phase) {
    playPhaseChangeSound(state.phase);
  } else if (state.isRunning && state.timeRemaining > 0 && state.timeRemaining <= 3
             && state.timeRemaining !== prev.timeRemaining) {
    playBeep(500, 100);
  }
}, [state, playBeep, playPhaseChangeSound]);

nextTimerState를 순수 함수로 빼내면 덤으로 테스트도 쉬워진다. "운동 마지막 라운드가 끝나면 세트 휴식으로 넘어가고, 세트 휴식은 휴식 시간의 3배"라는 규칙을 브라우저 없이 단위 테스트로 확인할 수 있다. 실제로 인타바는 세트 사이 휴식을 config.restDuration * 3으로 계산하는데, 이런 규칙이야말로 테스트로 고정해 둘 가치가 있다.

setTimeout 대신 오디오 시계로 예약하기

여러 음을 연달아 낼 때 setTimeout을 쓰고 있었는데, 자바스크립트 타이머는 메인 스레드가 바쁘면 늦게 실행된다. 음과 음 사이 간격이 들쭉날쭉해질 수 있다. Web Audio에는 별도의 정밀한 시계(ctx.currentTime)가 있으니, 시작 시각을 미래로 지정해 한 번에 예약하는 편이 정확하다.

function scheduleBeep(ctx: AudioContext, frequency: number, duration: number, at: number) {
  const osc = ctx.createOscillator();
  const gain = ctx.createGain();
  osc.connect(gain).connect(ctx.destination);
  osc.frequency.value = frequency;
  gain.gain.setValueAtTime(0.3, at);
  gain.gain.exponentialRampToValueAtTime(0.01, at + duration / 1000);
  osc.start(at);
  osc.stop(at + duration / 1000);
}

// "끝!" 세 음을 오디오 시계 기준으로 한 번에 예약
const now = ctx.currentTime;
scheduleBeep(ctx, 800, 150, now);
scheduleBeep(ctx, 1000, 150, now + 0.15);
scheduleBeep(ctx, 1200, 300, now + 0.3);

이렇게 하면 메인 스레드가 잠시 멈춰도 오디오 스레드가 예약된 시각에 정확히 소리를 낸다.

정리

  • 단순한 효과음은 파일 없이 OscillatorNode + GainNode로 충분하다. 번들 크기도, 네트워크 요청도 늘지 않는다.
  • 소리 끝은 게인을 짧게 줄여 잡음을 없애고, 지수 감소의 목표값은 0이 아닌 작은 양수로 둔다.
  • AudioContext는 사용자 제스처 안에서 만들고 재사용한다.
  • 소리 같은 부수 효과는 setState 업데이트 함수 밖, 상태 변화를 관찰하는 쪽에 둔다.
  • 연속된 음은 setTimeout보다 ctx.currentTime 기준 예약이 정확하다.

뒤의 두 가지는 아직 인타바 코드에 반영하지 않은 개선안이다. 작동하는 코드도 시간이 지나 다시 읽으면 고칠 점이 보인다. 그 과정을 기록해 두는 것이 이 블로그의 목적이기도 하다.