ryong.logryong.log
2024년 12월 30일·읽는 데 약 6분

음성인식 기반 아동 영어 교육 서비스 RockieTalkie

관련 글

Next.js 마이그레이션 및 라이트하우스 성능 최적화

2024. 12. 12.

OpenClaw를 활용한 hotfix 대응구축팀 설계 및 문서 관리 체계

2026. 3. 6.

BottomSheet Drag Close Handler 적용 가이드

2026. 2. 27.

RockieTalkie | 로키토키

영어를 마법처럼 배우는 우주 여행이 시작됩니다

rockie-talkie.com

↗
[video] — 지원하지 않는 블록 타입
RockieTalkie 메인
학습 과정 중 일부
백오피스(교사)와 학습 리포트 중 일부

RockieTalkie란?

영어 그림책을 읽고, 다양한 액티비티(퀴즈, 낱말 찾기 등) 활동을 통해 아이들이 즐겁게 영어를 학습할 수 있는 B2B(도서관 및 교육기관) 서비스입니다. 아이들은 영어 문장을 읽으며 음성 인식 모델을 이용해 본인의 발음 정확도를 진단받을 수 있습니다. 서비스는 크게 [진단평가] - [리딩 학습 / 액티비티] - [학습 통계] - [백오피스] 영역으로 구분됩니다.

기술스택

  • 코어 : React TypeScript
  • 상태 관리 : React Query Redux Toolkit
  • 스타일링 : Styled Components Framer Motion
  • 테스트 : Playwright
  • 패키지 매니저 : Yarn Berry
  • BFF : Express

기술적 고민과 러닝 포인트

1. 서버사이드 데이터의 효율적인 관리를 위한 React-Query 도입

(1) 문제 상황

기존 서비스는 Redux-saga와 RTK(Redux Toolkit)를 사용하여 서버 데이터를 클라이언트 측에서 관리하는 방식을 채택했습니다. RTK 덕분에 Redux의 장황한 보일러플레이트는 어느 정도 해소되었지만, 서비스 규모가 확장될수록 다음과 같은 문제들이 드러나기 시작했습니다.

  • 서버 데이터 동기화의 어려움: 클라이언트 스토어에 저장된 데이터가 서버의 최신 상태와 일치하는지 확인하고, 이를 유지하는 과정이 복잡하고 수동적이었습니다. 특히 과거의 데이터를 무효화(invalidation)하고 재요청(re-fetching)하는 로직을 직접 구현해야 하는 비효율성이 컸습니다.
  • 클라이언트와 서버 사이드 상태가 혼재: 클라이언트 UI 상태와 서버 데이터 상태가 Redux 스토어에 혼재되어 있어, 상태 관리 로직이 복잡해지고 유지보수가 어려웠습니다.

이러한 문제들을 해결하기 위해 클라이언트 데이터와 서버 데이터를 명확히 분리하여 관리할 수 있는 새로운 솔루션이 필요하다는 결론을 내렸습니다.

(2) 해결

새로운 솔루션 탐색 과정에서 SWR과 React-Query를 최종 후보로 선정했습니다. 두 라이브러리를 비교한 결과 SWR이 번들링 사이즈는 작지만, 제공하는 기능의 범위가 상대적으로 제한적이라고 판단했습니다. 반면 TanStack Query는 데이터 캐싱, 재요청, 무한 스크롤 등 다양한 고급 기능을 기본으로 제공하여 복잡한 서버 상태 관리 문제를 해결하는 데 더 적합하다고 판단, 최종적으로 TanStack Query를 도입했습니다.

도입 초기에는 쿼리(Queries)와 뮤테이션(Mutations)을 관리하는 방식에 대한 고민이 많았습니다. 몇 차례의 팀 회의와 리팩토링을 거쳐, 서비스의 특성에 맞는 최적화된 데이터 관리 패턴을 구축하고 안정화시킬 수 있었습니다.

(3) 결과

React-query 도입 후 아래와 같은 결과를 얻었습니다.

  • 버그 발생률 감소: 수동으로 관리해야 했던 데이터 무효화 및 재요청 로직으로 인해 발생하던 동기화 관련 버그가 감소했습니다. 라이브러리가 제공하는 invalidateQueries와 자동 재요청 기능 덕분에 데이터 불일치로 인한 오류가 획기적으로 줄었습니다.
  • 성능 최적화 (API 호출 횟수 감소): 캐싱 기능을 활용하여 불필요한 API 호출을 줄였습니다. 동일한 컴포넌트가 여러 번 렌더링될 때 캐싱된 데이터를 우선적으로 사용하여 서버 부하를 줄이고, 사용자 경험을 개선했습니다.

2. 멀티 스레드 방식으로 웹소켓 데이터 & 시각화 처리 최적화

(1) 문제 상황

사용자가 마이크를 통해 음성 데이터를 실시간으로 입력하고, STT 모델을 통해 평가된 텍스트와 스코어를 제공받으면서 실시간으로 입력되는 음성 데이터를 화면에 시각화하는 로직을 구현했습니다. 다만 개발 결과를 확인하니 시각화 애니메이션이 버벅이는 문제가 있었습니다. 메인 스레드에서 너무 많은 작업을 처리하다보니 하나의 프레임에서 모든 로직 처리를 실패하여 프레임드랍이 발생하고 있었습니다.

(2) 해결

음성 입력, 버퍼링을 AudioWorklet(오디오 렌더링 스레드)로 분리하고, 메인 스레드는 RAF 중심의 시각화만 담당하도록 역할을 분리했습니다. 시각화를 위해 오디오 렌더링 스레드의 결과물이 필요했기 때문에 양 스레드를 MessagePort로 통신할 수 있도록 구현했습니다.

javascript
Copied
1// useAudio.ts (효율을 위해 녹음부를 별도의 훅으로 분리)2...3const init = async () => {4    const context = new AudioContext({5        sampleRate: option?.sampleRate ?? 16000,6    });7    ...8    await context.audioWorklet.addModule("recording-processor.js");9    const node = new AudioWorkletNode(context, "recording-processor", {10        processorOptions: {11            numberOfChannels: channelCount,12            sampleRate: context.sampleRate,13            maxFrameCount: context.sampleRate * (option?.timeout ?? 5),14        },15    });16...

녹음이 필요한 페이지에 접근하면 useAudio 훅에서 제공하는 init 함수를 호출하여 recording-processor.js (오디도 렌더링 스레드)를 불러오고, AudioWorkletNode를 초기화해주도록 설정했습니다.

javascript
Copied
1// recording-processor.js 중 음성 데이터 처리부2process(inputs, outputs, params) {3...4		if (this.isRecording) {5		    if (this.recordedFrames + 128 < this.maxRecordingFrames) {6		        this.recordedFrames += 128; // 마이크 프레임은 일반적으로 128샘플7		        if (shouldPublish) {8		            const message = {9		                message: "UPDATE_RECORDING_STATE",10		                recordedSize: this.recordedFrames,11		                recordingTime: Math.round((this.recordedFrames / this.sampleRate) * 100) / 100,12				            rms, // 음량의 크기13				            peakValue // 음량 최대값14		            };15		            this.framesSinceLastPublish = 0;16		            this.port.postMessage(message);17		        } else {18		            this.framesSinceLastPublish += 128;19		        }20		    } else {21		        this.isRecording = false;22		        this.port.postMessage({ message: "MAX_RECORDING_LENGTH_REACHED" });23		        this.shareRecordingBuffer();24		        return false;25		    }26		}27...28}

위와 같이 음성 데이터는 recording-processor.js 에서 처리됩니다. shouldPublish 지점(음성 데이터의 샘플링 레이트를 기반으로 업데이트 지점을 산정했습니다. 주사율 60Hz를 상정해 계산된 지점입니다)에 도달하면 메인 스레드에 메시지를 수신합니다. 레코딩된 크기, 시간, RMS(소리의 세기)를 메인 스레드에 전달하도록 구현했습니다. 해당 값들은 메인스레드의 렌더링에 활용되는 값들입니다.

이 방식으로 음성 데이터에 대한 처리를 별도의 스레드로 분리하고 일정 지점(버퍼)에서만 데이터를 메인 스레드에 전달함으로써 관심사를 분리하고, 메인 스레드의 부하를 막을 수 있었습니다.

(3) 성과

[video] — 지원하지 않는 블록 타입

기존에 버벅였던 [Tallkie] 버튼에서의 음성 데이터 시각화 영역이 버벅임 없이 자연스럽게 표현될 수 있었습니다.

3. PlayWright를 활용한 E2E 테스팅

(1) 문제 상황

개발 중이던 서비스는 API 호출 순서가 정해져 있거나 특정 서버 데이터에 의존하는 등 상호 의존성이 높은 API가 많았습니다. 또한, 음성 발화에 대한 평가가 중요한 로직을 차지했기 때문에 테스트 자동화가 쉬운 작업이 아니었습니다.

그러나 특정 기능 하나를 테스트하기 위해 매번 수동으로 선행 단계를 통과해야 하는 비효율적인 상황에 직면했습니다. 이로 인해 테스트에 소요되는 시간이 길어졌고, QA 팀의 2차 검증을 의뢰하는 데에도 한계가 있었습니다. 개발-테스트-QA로 이어지는 비효율적인 프로세스는 궁극적으로 개발 속도 저하와 잠재적 버그 발생 가능성을 높이는 원인이 될 수 있다고 판단했습니다.

(2) 해결: PlayWright 도입

문제 해결을 위해 E2E 테스트 자동화 도입을 결정하고, Playwright를 채택했습니다. Cypress에 비해 빠른 테스트 실행 속도와 다양한 모바일 브라우저 지원이라는 장점을 고려했습니다.

기존의 요구기능명세서와 플로우 차트를 분석하여 총 40가지의 핵심 케이스 시나리오를 정의했습니다. 이를 [기본 동작], [파생 동작], [네트워크], [오류 및 안내] 의 네 가지 범주로 분류하고, 서비스의 핵심 기능 흐름을 검증하는 [기본 동작] 과 API 통신 안정성을 확인하는 [네트워크] 케이스를 우선적인 자동화 대상으로 선정했습니다.

특정 지점의 테스트를 위해서는 특정 상태(데이터)가 필요하다는 불편한 지점이 있었는데, 특정 지점(상황)에 도달한 테스트 계정들을 미리 설정해두는 것으로 해결했습니다. E2E 테스트를 진행하기 전에 미리 .sql 파일로 해당 계정들을 생성해두고, E2E 테스트 진행 이후에는 해당 계정들을 삭제했습니다.

음성 발화가 플로우에 영향을 줄 수 있다는 지점은 실제 음성 발화(상, 중, 하로 평가될 수 있는 발화)를 wav 파일로 준비해두고 실제로 해당 음성을 재생하여 플로우를 진행하도록 설정해두어 더미 음성 발화에 대한 테스트 자동화 시스템을 구축했습니다.

(3) 결과

Playwright를 통한 E2E 테스트 자동화 도입으로 아래 성과를 달성했습니다.

  • 테스트 시간 단축 및 효율 증대: 기존에 수동으로 10분 이상 소요되던 핵심 플로우 테스트를 1분 이내로 단축했습니다. 기능 구현 후 즉각적으로 회귀 테스트를 실행하여 버그를 조기에 발견할 수 있게 되었습니다.
  • QA 프로세스 개선: 개발팀의 1차 검증이 자동화됨으로써 QT팀은 더 복잡하고 중요한 2차 검증에 집중할 수 있게 되어, 전체적인 검증 프로세스의 효율성을 높였습니다.

4. 빌드 및 의존성 관리 최적화를 위한 Yarn berry 도입

에듀테크팀이 기존에 사용하던 패키지 매니저 npm은 빌드 프로세스에서 반복되는 설치 작업과 긴 빌드 시간 등의 불편함으로 CI/CD 과정에서 비효율성을 안고 있었습니다. 이러한 문제점들을 극복하고자 에듀테크팀은 Yarn berry 도입을 결정하였습니다.

에듀테크팀이 Yarn berry를 도입함으로 인해 얻은 이점은 아래와 같습니다.

  1. Zero-Install 설정을 통해 약 1분 이상 소요됐던 의존성 설치 시간을 절약할 수 있었습니다.
  2. 의존성 파일 용량을 node_modules의 약 615MB에서 yarn .cache의 155MB로 대폭 줄였습니다.
  3. 기존 2분 18초 가량 소요됐던 빌드 시간을 47초 가량으로 대폭 줄였습니다.
  4. 기존 npm의 유령 의존성 문제로 인해 package.json에 명시하지 않았던 기술 스택을 사용하고 있던 문제를 바로잡을 수 있었습니다.
  5. 의존성 설치 시 같이 설치가 필요한 @types 파일들을 버전에 맞게 자동으로 설치해주는 플러그인으로 인해 편리성을 챙겼습니다.

5. 코드 스플리팅, 폰트 및 이미지 최적화를 통한 랜딩 페이지 성능 최적화

기존 RockieTalkie 서비스는 랜딩 페이지에 진입 시 1초 가량의 시간이 소요됐었습니다. 로딩 애니메이션을 이용하여 사용자 경험을 개선하더라도 1초라는 시간은 최근 트랜드에 맞지 않는 너무 긴 시간임에 개선이 필요했습니다.

SPA 프로젝트 특성상 한 번에 많은 페이지들을 로드하는 메커니즘을 시나리오 단위로 스플리팅하여 하나의 페이지로부터 파생되는 이후 페이지들은 스플리팅 된 페이지에서 preload 하는 방법을 채택했습니다. 또한, 빠르게 다운로드가 필요한 첫 페이지의 폰트는 subset font로 파일 크기를 줄이고, index.html에서 preload를 진행하였습니다. 이미지 파일은 svg 코드를 직접 컴포넌트에서 구현하여 추가적인 다운로드를 진행하지 않도록 최적화 하였습니다.

이에 1초 가량 걸리던 서비스의 랜딩 페이지의 FCP 시간이 0.4초로 단축되었으며, 추가적으로 lighthouse 점수 또한 큰 폭으로 향상되는 경험을 하였습니다.

[file] — 지원하지 않는 블록 타입

6. Intersection Observer API를 활용한 [학습 통계] 페이지 최적화

학습 로비에서 진입할 수 있는 [학습 통계] 페이지는 학생의 학습내용에 대한 통계를 시각적으로 다양하게 보여주는 페이지로 많은 컴포넌트와 그에 따른 API들이 내장되어 있으며, 무려 40개의 음성 파일 다운로드가 필요한 페이지입니다. 버튼을 누르고 진입 시 페이지에 필요한 요소들을 로드하는 개선 전 방식은 랜딩 페이지 로드보다 훨씬 긴 1.5초에서 2초의 로딩 시간을 보임에 따라 개선이 시급했습니다.

처음 고안했던 개선 방향은 화면에 진입하자마자 바로 보이는 2, 3개의 컴포넌트들에 필요한 API 콜이 완전히 완료됐을 때 로딩 애니메이션을 해제하고 보여준 뒤, 나머지는 눈에 보이지 않는 스크롤 아래에서 연쇄적으로 컴포넌트 로드에 필요한 작업을 수행하도록 구현하였습니다. 하지만, 하단에 위치한 컴포넌트들이 로드되는 과정에서 사용자 인터렉션이 먹통이 되는 문제가 발생하였고, 다른 접근 방법이 필요했습니다.

해당 페이지에 존재하는 컴포넌트를 독립적으로 관리하며, 사용자가 해당 컴포넌트가 ‘필요’할 때 로드하는 메커니즘이 오히려 사용자 경험에 도움이 되겠다는 판단 하에 방향을 개선하였습니다. 각 컴포넌트들이 렌더링 되기 이전에는 크기가 없는 div 태그로 존재하며, Intersection Observer API를 사용하여 해당 div의 일정 수준 상단까지 스크롤이 도달할 시 컴포넌트를 렌더링하는 방법을 채택하였습니다. 또한, 컴포넌트 렌더링보다 상대적으로 시간이 소요되는 API 콜의 경우, Response가 도착할 때까지 기다렸다가 컴포넌트를 렌더링 하기 보다, 레이아웃 시프트를 고려하여 디폴트 데이터로 먼저 보여준 후 Response 도착 시 해당 데이터로 바꾸어 보여주는 방식이 사용자 경험에 도움이 되었습니다.

여러번의 리팩토링 이후, 1.5초 이상 소요됐던 TTI 까지의 시간이 0.2초 이하로 단축되었던 경험을 하였습니다.

7. 프론트엔드 환경변수 빌드 의존성 문제 및 해결

프론트엔드 코드의 .env 파일에 환경변수를 설정하고 빌드하면, 해당 변수와 값이 고정되어 빌드 이후에는 변경이 어렵습니다. 이로 인해 빌드 종속성이 발생하며, 환경변수를 변경하기 위해서는 다시 빌드해야 했습니다. 그러나 사용자마다 다른 서버 요청 URL이 필요한 상황에서는 이러한 정적 접근 방식이 한계가 있다고 생각했고, 이를 해결하기 위하여 아래와 같은 우회적인 방법을 고안했습니다.

  1. CI/CD 환경에서 빌드 과정에서, Shell 스크립트로 프론트엔드 코드의 .env 파일에 접근합니다.
  2. .env 파일에 있는 환경변수 값들을 읽어서 public/static/js 폴더에 env-config.js라는 이름으로 환경변수 정보를 담은 자바스크립트 파일을 만듭니다.
      javascriptenv-config.js
      Copied
      1window._env_ = {2  REACT_APP_SERVER_API: "SERVER_API_URL",3  REACT_APP_SERVER_NODE: "NODE_SERVER_URL",4}

      이 코드는 window 객체에 _env_라는 전역 객체 변수를 만들어 런타임 환경에서 사용할 수 있도록 합니다.

  3. public 폴더의 index.html 코드에서 아래와 같은 스크립트 요청을 보냅니다.
      htmlindex.html
      Copied
      1<script src="%PUBLIC_URL%/static/js/env-config.js"></script>

      이 코드는 Shell 스크립트가 생성한 env-config.js 파일을 런타임에서 다운로드하는 역할을 합니다. 이를 통해 런타임에서 동적인 환경변수를 참조할 수 있게 됩니다.

  • 음성인식 기반 아동 영어 교육 서비스 RockieTalkie
  • RockieTalkie란?
  • 기술스택
  • 기술적 고민과 러닝 포인트
  • 1. 서버사이드 데이터의 효율적인 관리를 위한 React-Query 도입
  • (1) 문제 상황
  • (2) 해결
  • (3) 결과
  • 2. 멀티 스레드 방식으로 웹소켓 데이터 & 시각화 처리 최적화
  • (1) 문제 상황
  • (2) 해결
  • (3) 성과
  • 3. PlayWright를 활용한 E2E 테스팅
  • (1) 문제 상황
  • (2) 해결: PlayWright 도입
  • (3) 결과
  • 4. 빌드 및 의존성 관리 최적화를 위한 Yarn berry 도입
  • 5. 코드 스플리팅, 폰트 및 이미지 최적화를 통한 랜딩 페이지 성능 최적화
  • 6. Intersection Observer API를 활용한 [학습 통계] 페이지 최적화
  • 7. 프론트엔드 환경변수 빌드 의존성 문제 및 해결