공감각과 접근성: @uturi/sonification을 베타 릴리즈하며

뮤돔면·2025년 10월 12일
post-thumbnail

지난 7월, 한달간 작업했던 @uturi/sonification을 프리릴리즈(alpha)로 공개했습니다. 그리고 3개월간 여섯 번의 패치를 거쳐, 드디어 베타 버전(v0.1.0) 을 릴리즈했습니다. 🎉(NPM👩‍🦯🧑‍🦽👨‍🦽). 베타릴리즈를 자축하며 @uturi/sonification의 시작을 돌아보고, 현재까지 어떤 길을 걸어왔고, 어떤 길을 향해갈지 공유해보려 합니다.

👉 직접 들어보기(uturi.web)
👉 코드 돌아보기(uturi.github)


시각의 청각화에서 출발하다.

차트, 비시각 사용자에게도 정말 편할까요?
차트

웹은 본질적으로 시각 중심의 매체입니다. 그렇기 때문에 정보의 원천이 시각이 아닌 사람들에게, 웹은 여전히 공평하지 않을 수 있습니다. 특히 시계열 차트나 테이블처럼 ‘한눈에 보기 쉽게’ 설계된 컴포넌트들은 시각 사용자에게는 효율적이지만, 비시각 사용자에게는 거의 닿을 수 없는 영역일 수 있습니다.

물론 스크린 리더가 수치를 읽어줄 수 있습니다. 다만 데이터가 많고 변화가 빠른 경우라면 이야기가 달라집니다. 사람의 작업 기억(working memory)은 동시에 최대 일곱 개의 정보밖에 유지하지 못한다고 합니다. 결국 수치의 흐름이나 변화량을 직관적으로 파악하기란 쉽지 않습니다.

이 문제의식에서 @uturi/sonification 프로젝트가 출발했습니다. 시각적인 정보를 소리로 변환하는, 데이터를 📊음향화(sonification)🔊하는 시도입니다.

색청 공감각에서 영감을 받았습니다(소리에서 색상을 느끼는 질환에서 영감을 받았다는 표현이 조금 어색하기는 합니다만). 색청과는 반대로 수치에서 소리를 도출해낼 수 있다면 숫자를 ‘보는’ 대신 ‘들을’ 수 있지 않을까. 음향으로 데이터의 의미를 더 빠르고 직관적으로 전달할 수 있지 않을까. 이러한 질문이 이 프로젝트의 시작이었습니다.


출력되는 소리도 다양할 수 있다.

처음에는 단순히 frequency(주파수, 음의 높낮이)를 기반으로 수치 정보를 전달하는 방식을 시도했습니다. 숫자가 커질수록 음이 높아지고, 작아질수록 낮아지는 구조였습니다.

/* 
	전달되는 데이터를 정규화하여 가청 범위의 주파수로 매칭했습니다.
    이 방식으로 변환된 샘플들은 generateFrequencyAudio에 의해 버퍼에 담기게 됩니다.
*/
function mapValueToFrequency(value: number): number {
  const normalizedValue = normalizeValue(value);
  const minFrequency = workerConfig!.minFrequency; // 최소 음
  const maxFrequency = workerConfig!.maxFrequency; // 최대 음
  return minFrequency + normalizedValue * (maxFrequency - minFrequency);
}

하지만 셀프로 청취 테스트를 진행해보니, 수치의 차이가 크지 않은 경우에는 음의 높낮이만으로 변화를 구별하기가 쉽지 않았습니다. 순수한 사인파의 소리를 생성하여 JND(just-noticeable difference, 두 자극을 식별할 수 있는 최소 차이)가 클 것이라고 판단하긴 했는데, 생각보다 음의 높낮이가 뚜렷하게 인지되지 않는 경우가 있었습니다.

이 문제를 보완하기 위해 melody 모드를 도입했고, 디폴트 모드로 설정했습니다. 데이터 간 pitch(높낮이)의 차이를 식별 가능한 정도 차이로 조정한 것입니다. 인간이 익숙하게 인식하는 7음계(도레미파솔라시)를 기반으로 소리를 구성하자 데이터의 변화량을 훨씬 더 명확하게 구분할 수 있었습니다.

/*
	전달되는 데이터를 정규화하여 각 음계로 매칭합니다.
    데이터의 일부 크기 정보가 유실되기는 하지만 데이터의 전체적인 흐름을 빠르게 이해하기에는 효과적입니다.
*/
 private mapValueToNoteName(value: number): string {
   const normalizedValue = this.normalizeValue(value);
   const noteNames = ['C', 'D', 'E', 'F', 'G', 'A', 'B'];
   const noteIndex = Math.min(Math.floor(normalizedValue * 7), 6);
   return noteNames[noteIndex];
 }

그리고 이 과정에서 한 가지 좋은 아이디어를 얻었습니다. 정보를 전달하는 방식이 꼭 음의 높낮이에만 의존할 필요가 없다는 점이었습니다. 사람마다 청각적으로 민감하게 반응하는 속성은 다를 수 있기 때문에 소리의 크기(volume)나 리듬(rhythm) 같은 다른 청각적 요소를 활용하는 것도 효과적일 수 있다는 생각이 들었습니다.

이 아이디어를 바탕으로 volume 모드와 rhythm 모드를 추가했습니다. 특히 rhythm 모드는 구현 과정이 다소 까다로웠습니다. 단순히 일정한 간격으로 소리를 재생하는 것이 아니라, 데이터의 변화량에 따라 리듬의 간격과 패턴을 동적으로 조정해야 했기 때문입니다. 예를 들어 값이 급격히 상승할수록 리듬을 촘촘하게, 완만할수록 느리게 들려주는 식으로 구현했습니다. 이를 통해 단순한 ‘데이터의 소리화’를 넘어, 데이터의 리듬을 ‘청각적으로 체감할 수 있는 경험’을 제공할 수 있었습니다.

👉 각 소리 샘플을 어떻게 버퍼에 담아주었는지 궁금하다면?(uturi.github)

/*
	소리 샘플의 핵심은 사인파
*/
audioData[sample] = Math.sin(2 * Math.PI * frequency * localTime) * volume;

소리 생성, 역시나 최적화가 필요했다.

@uturi/sonification은 단순히 몇 개의 수치뿐 아니라 수백~수천 개의 데이터 역시 Web Audio API 기반으로 변환할 수 있어야 했습니다. 데이터의 개수를 늘려가며 테스트를 진행해보았는데, 이 과정에서 성능 병목 현상이 발견되었습니다.

데이터를 정규화(normalization)하는 과정에서 각 데이터 포인트(dataPoint)마다 최소·최대값을 갱신하는 연산이 반복적으로 수행되었고, 각 샘플이 1개 이상의 루프를 돌면서(rhythm 모드의 경우에는 공백음이 포함되므로 2개 이상의 루프를 돌아야 했다) 각 사인파의 특정값으로 할당되어야 했습니다. 이 연산 과정에서 메인 스레드가 블로킹되는 문제가 발견된 것입니다. 특히 데이터 개수가 100개를 넘어가는 경우에는 음성 생성 과정에서 화면이 프리징되는 현상이 발견되기도 했습니다.

/*
	특히 블로킹은 rhythm 모드에서 가장 심했습니다.
    음성 생성 로직이 가장 무거웠기 때문이죠.
*/
const dataPoints: DataPoint[] = data.map((value, index) => ({
  value,
  timestamp: index * (this.config.duration / data.length),
  volume: this.mapValueToVolume(value),
  frequency: this.mapValueToFrequency(value),
}));

for (let i = 0; i < data.length; i++) {
  ...
  for (
    let sample = startSample;
    sample < soundEndSample && sample < audioData.length;
    sample++
  ) {
    ...
    audioData[sample] = Math.sin(2 * Math.PI * baseFrequency * localTime) * this.config.volume;
  }
  if (i < data.length - 1) {
    ...
    for (
      let sample = silenceStartSample;
      sample < silenceEndSample && sample < audioData.length;
      sample++
    ) {
      audioData[sample] = 0;
    }
  }
}

메인 스레드를 블로킹하지 않으면서도, 대용량 데이터를 빠르게 변환할 수 있는 구조가 필요했습니다. 그리고 Web Worker를 활용하여 스레드를 분리하는 방안을 검토했습니다. 몇년 전 음성 변환로직을 별도의 스레드로 분리하여 성능 문제를 해결한 경험이 있었는데, 이 경우에도 도입이 가능할 듯 보였습니다.

Web Worker를 검토하면서 아래 지점들을 고려했습니다.

1. Web Worker의 초기화

우선 인스턴스 레벨에서 Worker는 싱글턴으로 관리되도록 했습니다(객체마다 Worker는 한 개씩 관리). 글로벌한 싱글턴으로 생성하지 않은 이유는 여러 객체에서 오디오 생성을 요청하는 경우 경합 조건이 발생하여 큐(queue) 등으로 관리해야 하는데, 오히려 복잡도가 더 커진다고 판단했습니다.

/*
	인스턴스마다 Worker는 한번만 생성합니다.
*/
private initializeWorker(): void {
  if (this.worker || !this.isWorkerSupported) return;

  try {
    this.worker = new AudioWorker();
  } catch (error) {
    // eslint-disable-next-line no-console
    console.warn('Initialize Web Worker failed, generate on main thread:', error);
    this.isWorkerSupported = false;
    this.worker = null;
  }
}

2. 브라우저 호환성 확보

구형 브라우저 일부는 Web Worker를 지원하지 않습니다. 따라서 Web Worker를 지원하는 일부 브라우저에서만 Web Worker를 사용하고, 지원하지 않는 브라우저의 경우에는 기존 메인 스레드에서 오디오를 생성하도록 구성했습니다.

/*
	Sonifier 생성자가 호출될 때 사용 가능 여부를 판단해서 private 프로퍼티로 관리합니다.
*/
private isWorkerSupported: boolean;
constructor(config: SonifierConfig = {}) {
  this.config = {
    ...defaultConfig,
    ...config,
  };

  this.isWorkerSupported = typeof Worker !== 'undefined';

  if (this.isWorkerSupported) {
    this.initializeWorker();
  }
}

3. 모든 환경에서 사용할 수 있도록 번들링

Vite에서는 Web Worker를 별도의 청크 파일로 분리하여 번들링할 수 있는 기능이 존재했습니다(참고). 하지만 실제로 번들링해보니 별도 청크 파일로 분리는 잘 되었으나, Next.js에서 해당 패키지를 사용하려고 했을 때 에러가 발생했습니다.

Vite에서 제공하는 쿼리 접미사형 임포트 방식
쿼리 접미사형 임포트

번들링된 결과물을 확인해보니 아래처럼 번들링되고 있었는데, Next.js에서는 이를 절대 경로로 해석해 파일을 엉뚱한 곳에서 찾고 있던 것입니다.

function M(g) { return new Worker( "/assets/audioWorker-IyjBHzGd.js", { name: g == null ? void 0 : g.name } ); }

Vite의 번들링 옵션을 수정하거나, 웹 워커를 raw한 문자열 형태로 변환하는 두 옵션을 고민했습니다. 번들링 옵션을 수정하는 방식은 하드코딩의 느낌이 강해, 고민끝에 해당 웹 워커를 별도의 청크 파일로 분리하지 않고 base64의 문자열 형태로 변환하는 방식을 채택했습니다. 해당 방식이 Next.js뿐 아니라 다양한 환경에 유연하게 이식될 수 있다는 판단이기도 했습니다.

4. Web Worker의 오버헤드

Web Worker를 사용하면서 오히려 오버헤드가 발생할 수 있기 때문에 성능 테스트가 필요했습니다. Claude의 도움을 받아 벤치마크 테스트가 가능한 페이지를 생성했고(🫶Claude 사랑해요🫶), 테스트를 진행했습니다.

Claude가 뚝딱 만들어준 벤치마크 테스트 페이지
테스트

데이터 크기 100개 미만에서는 오히려 오버헤드가 발생했지만(처리 결과까지의 시간이 오히려 오래 걸림), 100개 이상에서부터는 성능이 점차 큰 폭으로 개선되었습니다. 데이터 100개 미만 케이스에서 오버헤드가 발생하기는 하지만 메인 스레드가 블로킹되는 현상은 최소한 회피할 수 있으므로 Web Worker를 적용하는 것의 이점이 더 크다고 판단할 수 있었습니다. 더구나 대용량 데이터에서는 성능 개선이 명확했기 때문에 Web Worker를 마다할 이유가 없기도 했습니다.


웹은 모두의 것

웹의 철학
웹의 철학
출처: 위키피디아

웹은 시각 사용자만을 위한 것이 아닙니다. 월드 와이드 웹의 이념은 모든 사람들이 동등하게 정보를 공유하는 것에 있습니다. 따라서 다수가 시각 사용자라는 이유로 소수의 접근권이 제한된다면 그것은 웹의 근본적인 철학에서 벗어난 일일지도 모르겠습니다. 우리는 기술과 정보를 전파하는 사람으로서 모든 사람이 소외되지 않도록 항상 더 포용적인 기술, 더 따뜻한 기술을 지향해야 합니다.

그래서 저는 점점 화려해지는 웹을 보며 가끔은 안타깝기도 합니다.-누구를 위한 화려함인가?-
시각적인 화려함은 때로 사용자의 불편함을 감추는 눈속임이 되기도 합니다. 화려함이라는 눈속임으로 누군가의 불편함을 해소해주기는커녕 더 복잡성, 불편함을 가중시키는 것은 기술의 책임을 다하지 못하는 것일 수 있습니다. 기술적인, 감각적인 화려함 속에서 누군가는 여전히 내실과 철학을 이야기해야 하지 않을까, 저는 그런 생각을 합니다.

저는 그래서 이런 것들을 연구하고 개발해왔습니다: 배리어프리 키오스크, 온라인 강박장애 치료제, 포용적인 디자인 시스템 등. 혹자는 기술적으로 쓸모 없다고, 너의 개발 커리어에 도움되지 않는다고 말합니다. 씁쓸하긴 하지만 저는 이러한 개발을 계속하고 싶습니다. 이런 개발을 하는 사람도 있어야 세상이 좀더 건강해지지 않을까요.

@uturi/sonification 베타 릴리즈는 이 믿음의 연장선이자 일종의 다짐입니다. 베타 릴리즈를 시작으로 앞으로 더 다양한 데이터, 더 다양한 커스텀, 그리고 더 다양한 환경에서도 동작할 수 있는 포용적 웹 경험에 기여하고 싶습니다. 더 발전된, 더 포용적인 라이브러리로 돌아오겠습니다. 여기까지 글을 읽어주신 모든 분들께 감사드립니다. 피드백과 의견은 언제나 환영이니 자그마한 의견이라도 전달해주시면 달게 받고 더 개선해보겠습니다! 감사합니다.

profile
자바스크립트가 중심이 되는 프론트엔드 파트에서 개발하고 있습니다. 배려하고 포용하는 모든 것들을 사랑합니다.

2개의 댓글

comment-user-thumbnail
2025년 10월 12일

너무 훌륭한 프로그램이네요, 한 번 활용해 보겠습니다 👍

1개의 답글