RockieTalkie | 로키토키
영어를 마법처럼 배우는 우주 여행이 시작됩니다
rockie-talkie.com
↗
영어 그림책을 읽고, 다양한 액티비티(퀴즈, 낱말 찾기 등) 활동을 통해 아이들이 즐겁게 영어를 학습할 수 있는 B2B(도서관 및 교육기관) 서비스입니다. 아이들은 영어 문장을 읽으며 음성 인식 모델을 이용해 본인의 발음 정확도를 진단받을 수 있습니다. 서비스는 크게 [진단평가] - [리딩 학습 / 액티비티] - [학습 통계] - [백오피스] 영역으로 구분됩니다.
React TypeScriptReact Query Redux ToolkitStyled Components Framer MotionPlaywrightYarn BerryExpress기존 서비스는 Redux-saga와 RTK(Redux Toolkit)를 사용하여 서버 데이터를 클라이언트 측에서 관리하는 방식을 채택했습니다. RTK 덕분에 Redux의 장황한 보일러플레이트는 어느 정도 해소되었지만, 서비스 규모가 확장될수록 다음과 같은 문제들이 드러나기 시작했습니다.
이러한 문제들을 해결하기 위해 클라이언트 데이터와 서버 데이터를 명확히 분리하여 관리할 수 있는 새로운 솔루션이 필요하다는 결론을 내렸습니다.
새로운 솔루션 탐색 과정에서 SWR과 React-Query를 최종 후보로 선정했습니다. 두 라이브러리를 비교한 결과 SWR이 번들링 사이즈는 작지만, 제공하는 기능의 범위가 상대적으로 제한적이라고 판단했습니다. 반면 TanStack Query는 데이터 캐싱, 재요청, 무한 스크롤 등 다양한 고급 기능을 기본으로 제공하여 복잡한 서버 상태 관리 문제를 해결하는 데 더 적합하다고 판단, 최종적으로 TanStack Query를 도입했습니다.
도입 초기에는 쿼리(Queries)와 뮤테이션(Mutations)을 관리하는 방식에 대한 고민이 많았습니다. 몇 차례의 팀 회의와 리팩토링을 거쳐, 서비스의 특성에 맞는 최적화된 데이터 관리 패턴을 구축하고 안정화시킬 수 있었습니다.
React-query 도입 후 아래와 같은 결과를 얻었습니다.
invalidateQueries와 자동 재요청 기능 덕분에 데이터 불일치로 인한 오류가 획기적으로 줄었습니다.사용자가 마이크를 통해 음성 데이터를 실시간으로 입력하고, STT 모델을 통해 평가된 텍스트와 스코어를 제공받으면서 실시간으로 입력되는 음성 데이터를 화면에 시각화하는 로직을 구현했습니다. 다만 개발 결과를 확인하니 시각화 애니메이션이 버벅이는 문제가 있었습니다. 메인 스레드에서 너무 많은 작업을 처리하다보니 하나의 프레임에서 모든 로직 처리를 실패하여 프레임드랍이 발생하고 있었습니다.
음성 입력, 버퍼링을 AudioWorklet(오디오 렌더링 스레드)로 분리하고, 메인 스레드는 RAF 중심의 시각화만 담당하도록 역할을 분리했습니다. 시각화를 위해 오디오 렌더링 스레드의 결과물이 필요했기 때문에 양 스레드를 MessagePort로 통신할 수 있도록 구현했습니다.
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를 초기화해주도록 설정했습니다.
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(소리의 세기)를 메인 스레드에 전달하도록 구현했습니다. 해당 값들은 메인스레드의 렌더링에 활용되는 값들입니다.
이 방식으로 음성 데이터에 대한 처리를 별도의 스레드로 분리하고 일정 지점(버퍼)에서만 데이터를 메인 스레드에 전달함으로써 관심사를 분리하고, 메인 스레드의 부하를 막을 수 있었습니다.
기존에 버벅였던 [Tallkie] 버튼에서의 음성 데이터 시각화 영역이 버벅임 없이 자연스럽게 표현될 수 있었습니다.
개발 중이던 서비스는 API 호출 순서가 정해져 있거나 특정 서버 데이터에 의존하는 등 상호 의존성이 높은 API가 많았습니다. 또한, 음성 발화에 대한 평가가 중요한 로직을 차지했기 때문에 테스트 자동화가 쉬운 작업이 아니었습니다.
그러나 특정 기능 하나를 테스트하기 위해 매번 수동으로 선행 단계를 통과해야 하는 비효율적인 상황에 직면했습니다. 이로 인해 테스트에 소요되는 시간이 길어졌고, QA 팀의 2차 검증을 의뢰하는 데에도 한계가 있었습니다. 개발-테스트-QA로 이어지는 비효율적인 프로세스는 궁극적으로 개발 속도 저하와 잠재적 버그 발생 가능성을 높이는 원인이 될 수 있다고 판단했습니다.
문제 해결을 위해 E2E 테스트 자동화 도입을 결정하고, Playwright를 채택했습니다. Cypress에 비해 빠른 테스트 실행 속도와 다양한 모바일 브라우저 지원이라는 장점을 고려했습니다.
기존의 요구기능명세서와 플로우 차트를 분석하여 총 40가지의 핵심 케이스 시나리오를 정의했습니다. 이를 [기본 동작], [파생 동작], [네트워크], [오류 및 안내] 의 네 가지 범주로 분류하고, 서비스의 핵심 기능 흐름을 검증하는 [기본 동작] 과 API 통신 안정성을 확인하는 [네트워크] 케이스를 우선적인 자동화 대상으로 선정했습니다.
특정 지점의 테스트를 위해서는 특정 상태(데이터)가 필요하다는 불편한 지점이 있었는데, 특정 지점(상황)에 도달한 테스트 계정들을 미리 설정해두는 것으로 해결했습니다. E2E 테스트를 진행하기 전에 미리 .sql 파일로 해당 계정들을 생성해두고, E2E 테스트 진행 이후에는 해당 계정들을 삭제했습니다.
음성 발화가 플로우에 영향을 줄 수 있다는 지점은 실제 음성 발화(상, 중, 하로 평가될 수 있는 발화)를 wav 파일로 준비해두고 실제로 해당 음성을 재생하여 플로우를 진행하도록 설정해두어 더미 음성 발화에 대한 테스트 자동화 시스템을 구축했습니다.
Playwright를 통한 E2E 테스트 자동화 도입으로 아래 성과를 달성했습니다.
에듀테크팀이 기존에 사용하던 패키지 매니저 npm은 빌드 프로세스에서 반복되는 설치 작업과 긴 빌드 시간 등의 불편함으로 CI/CD 과정에서 비효율성을 안고 있었습니다. 이러한 문제점들을 극복하고자 에듀테크팀은 Yarn berry 도입을 결정하였습니다.
에듀테크팀이 Yarn berry를 도입함으로 인해 얻은 이점은 아래와 같습니다.
기존 RockieTalkie 서비스는 랜딩 페이지에 진입 시 1초 가량의 시간이 소요됐었습니다. 로딩 애니메이션을 이용하여 사용자 경험을 개선하더라도 1초라는 시간은 최근 트랜드에 맞지 않는 너무 긴 시간임에 개선이 필요했습니다.
SPA 프로젝트 특성상 한 번에 많은 페이지들을 로드하는 메커니즘을 시나리오 단위로 스플리팅하여 하나의 페이지로부터 파생되는 이후 페이지들은 스플리팅 된 페이지에서 preload 하는 방법을 채택했습니다. 또한, 빠르게 다운로드가 필요한 첫 페이지의 폰트는 subset font로 파일 크기를 줄이고, index.html에서 preload를 진행하였습니다. 이미지 파일은 svg 코드를 직접 컴포넌트에서 구현하여 추가적인 다운로드를 진행하지 않도록 최적화 하였습니다.
이에 1초 가량 걸리던 서비스의 랜딩 페이지의 FCP 시간이 0.4초로 단축되었으며, 추가적으로 lighthouse 점수 또한 큰 폭으로 향상되는 경험을 하였습니다.
학습 로비에서 진입할 수 있는 [학습 통계] 페이지는 학생의 학습내용에 대한 통계를 시각적으로 다양하게 보여주는 페이지로 많은 컴포넌트와 그에 따른 API들이 내장되어 있으며, 무려 40개의 음성 파일 다운로드가 필요한 페이지입니다. 버튼을 누르고 진입 시 페이지에 필요한 요소들을 로드하는 개선 전 방식은 랜딩 페이지 로드보다 훨씬 긴 1.5초에서 2초의 로딩 시간을 보임에 따라 개선이 시급했습니다.
처음 고안했던 개선 방향은 화면에 진입하자마자 바로 보이는 2, 3개의 컴포넌트들에 필요한 API 콜이 완전히 완료됐을 때 로딩 애니메이션을 해제하고 보여준 뒤, 나머지는 눈에 보이지 않는 스크롤 아래에서 연쇄적으로 컴포넌트 로드에 필요한 작업을 수행하도록 구현하였습니다. 하지만, 하단에 위치한 컴포넌트들이 로드되는 과정에서 사용자 인터렉션이 먹통이 되는 문제가 발생하였고, 다른 접근 방법이 필요했습니다.
해당 페이지에 존재하는 컴포넌트를 독립적으로 관리하며, 사용자가 해당 컴포넌트가 ‘필요’할 때 로드하는 메커니즘이 오히려 사용자 경험에 도움이 되겠다는 판단 하에 방향을 개선하였습니다. 각 컴포넌트들이 렌더링 되기 이전에는 크기가 없는 div 태그로 존재하며, Intersection Observer API를 사용하여 해당 div의 일정 수준 상단까지 스크롤이 도달할 시 컴포넌트를 렌더링하는 방법을 채택하였습니다.
또한, 컴포넌트 렌더링보다 상대적으로 시간이 소요되는 API 콜의 경우, Response가 도착할 때까지 기다렸다가 컴포넌트를 렌더링 하기 보다, 레이아웃 시프트를 고려하여 디폴트 데이터로 먼저 보여준 후 Response 도착 시 해당 데이터로 바꾸어 보여주는 방식이 사용자 경험에 도움이 되었습니다.
여러번의 리팩토링 이후, 1.5초 이상 소요됐던 TTI 까지의 시간이 0.2초 이하로 단축되었던 경험을 하였습니다.
프론트엔드 코드의 .env 파일에 환경변수를 설정하고 빌드하면, 해당 변수와 값이 고정되어 빌드 이후에는 변경이 어렵습니다. 이로 인해 빌드 종속성이 발생하며, 환경변수를 변경하기 위해서는 다시 빌드해야 했습니다. 그러나 사용자마다 다른 서버 요청 URL이 필요한 상황에서는 이러한 정적 접근 방식이 한계가 있다고 생각했고, 이를 해결하기 위하여 아래와 같은 우회적인 방법을 고안했습니다.
.env 파일에 접근합니다..env 파일에 있는 환경변수 값들을 읽어서 public/static/js 폴더에 env-config.js라는 이름으로 환경변수 정보를 담은 자바스크립트 파일을 만듭니다.이 코드는 window 객체에 _env_라는 전역 객체 변수를 만들어 런타임 환경에서 사용할 수 있도록 합니다.
public 폴더의 index.html 코드에서 아래와 같은 스크립트 요청을 보냅니다.1<script src="%PUBLIC_URL%/static/js/env-config.js"></script>이 코드는 Shell 스크립트가 생성한 env-config.js 파일을 런타임에서 다운로드하는 역할을 합니다. 이를 통해 런타임에서 동적인 환경변수를 참조할 수 있게 됩니다.