(주)사운드마인드
KSTT 한국어 말하기 시험 플랫폼
외부 음성 서비스를 제품의 중심 경로에 놓되, 운영 중에 무엇이 바뀌어도 이미 치른 시험 결과는 흔들리지 않게 만드는 일이었습니다.
- 역할
- 응시 · 채점 관리 · 음성 파이프라인 · 배포 전 영역
- 기간
- 2025.07 ~ 현재
- 사용 기술
- Next.js 15React 19TypeScriptPrismaMySQLSTTTTSffmpegDocker
30여 개
채점과 운영을 위한 관리자 화면
50개
데이터 모델 규모
2종
음성 합성 제공자를 이원화해 한쪽 장애 시 대체
01문제
외국인 학습자가 브라우저에서 발음과 말하기를 녹음해 응시하고, 그 녹음을 음성 인식으로 전사한 뒤 채점자가 채점하는 서비스입니다. 최종 목적은 채점 결과만이 아니라, 개인정보를 지우고 음량을 고르게 맞춘 학습용 데이터셋을 만드는 것이었습니다. 어려운 지점은 이 전체가 운영 중에도 계속 바뀐다는 데 있었습니다. 문항을 고치고 회차를 추가하고 배포를 하는 와중에, 이미 치른 시험 결과는 절대 변하면 안 됩니다.
02제약
무중단 배포를 쓰기 때문에 배포 도중에는 예전 코드와 새 코드가 잠시 함께 돕니다. 이때 데이터베이스 구조가 바뀌면 예전 코드가 깨집니다. 음성 인식은 시간이 오래 걸려 별도 프로그램이 처리하는데, 실수로 두 개가 동시에 돌면 같은 녹음을 중복 처리합니다. 응시자 쪽에는 주소를 조작하거나 브라우저 번역 기능으로 문제를 미리 읽는 우회 경로가 있었습니다.
- 배포 구간에 구버전과 신버전이 공존하므로 스키마 변경이 곧 장애가 됩니다
- 전사가 중복되면 같은 녹음을 두 번 보내게 되어 외부 서비스 호출 비용이 그대로 두 배가 됩니다
- 시험 URL과 번역 기능을 통한 부정행위 경로가 열려 있었습니다
03검토한 대안
전사는 한 건에 수십 초가 걸립니다. 이 작업을 어디서 돌릴지가 구조 전체를 결정했습니다.
| 안 | 장점 | 문제 |
|---|---|---|
| 요청 안에서 동기 처리 | 구조가 가장 단순하고 상태 관리가 필요 없음 | 업로드 요청이 전사가 끝날 때까지 잠기고, 타임아웃과 재시도가 겹치면 같은 녹음이 중복 전사됨 |
| 앱 서버 내장 스케줄러 | 배포 단위가 하나로 유지됨 | 무중단 배포 중에는 서버가 두 대라 스케줄러도 두 개가 돌고, 배포할 때마다 진행 중 작업이 끊김 |
| 전용 상시 워커 + 행 잠금 선점채택 | 작업을 한 건씩 원자적으로 가져가 중복이 원천 차단되고, 배포와 무관하게 계속 돎 | 워커가 별도 프로세스라 죽었는지 살았는지를 따로 관리해야 함 |
04선택과 근거
전용 워커를 골랐습니다. 남은 문제는 워커 자신이 죽거나 둘이 되는 경우인데, 이것은 워커가 생존을 주기적으로 DB에 기록하게 해서 풀었습니다. 다른 워커의 기록이 살아 있으면 새 워커는 기동을 거부하고, 기록이 멎으면 정상 종료인지 연락 두절인지를 구분합니다. 같은 기준을 배포에도 적용했습니다. 되돌릴 수 없는 것은 자동화 경로에서 건드리지 못하게 막는 것입니다.
배포 앞의 스키마 게이트
배포 중에는 구버전 코드도 함께 돕니다. 삭제 구문은 서버에 닿기 전에 막습니다
- 배포 시 스키마 변경은 추가 전용이며, 삭제 구문이 감지되면 서버에 닿기 전에 배포를 중단
- 녹음은 삭제 없이 세대로 쌓아, 관리자 지시 재응시와 자가 재녹음을 구분
- 문항은 응시 시점 리비전으로 고정해, 수정이 과거 회차에 영향을 주지 않음
- 응시 화면의 우회 경로 차단: 배정 외 문항 접근, 번역 기능 악용, 마이크 테스트 유효성
05구현과 시행착오
빌드와 배포 경계에서 예상 밖의 문제가 나왔습니다. 맥에서 빌드해 리눅스 컨테이너에서 실행하는 체제라, 로컬에서 멀쩡하던 것이 서버에서 깨지는 유형이었습니다.
- ① 개발 도구 패널이 운영 번들에 섞여 들어간 적이 있습니다. 조건부 import를 정리한 리팩터링이 번들러의 코드 제거를 조용히 깨뜨린 것이 원인이라, 해당 패턴을 규칙으로 금지하고 빌드 산출물을 검사해 확인하도록 바꿨습니다.
- ② 맥에서 빌드한 산출물이 컨테이너에서 기동에 실패했습니다. DB 엔진과 이미지 라이브러리가 플랫폼별 바이너리라, 리눅스용 바이너리를 산출물에 포함하도록 빌드를 고쳤습니다.
- ③ 개발용 워커를 로컬에서 켜둔 채 운영 배포를 하면 전사가 중복될 뻔했습니다. 워커 식별자를 두고, 다른 식별자의 생존 기록이 있으면 운영 워커 기동을 거부하도록 배포 스크립트에 가드를 넣었습니다.
06음성 파이프라인
음성 인식과 합성 모델은 직접 만들지 않았습니다. 외부 서비스를 가져다 붙였고, 제 몫은 그것을 제품의 중심 경로에 안전하게 앉히는 일이었습니다. 응시자가 녹음을 마치면 외부 음성 인식 서비스가 전사하고, 문항 안내 음성은 외부 합성 서비스로 만듭니다. 남의 서비스에 의존하는 경로가 곧 서비스의 급소가 되므로, 모델 자체보다 그것이 느려지거나 멈췄을 때 시험이 무너지지 않게 만드는 데 시간을 더 썼습니다.
- 전사 요청을 별도 상시 프로그램으로 분리하고 동시 호출 수를 제한했습니다. 외부 서비스에 한꺼번에 몰리지 않도록 하는 동시에, 작업을 하나씩 잠가 가져가 중복 호출을 막습니다
- 그 프로그램이 살아 있는지 주기적으로 기록하게 해서 이중 실행을 막고, 정상 종료와 연락 두절을 구분합니다
- 음성 합성은 제공자를 둘로 두고 음성 식별자로 분기했습니다. 외부 서비스는 언제든 장애가 나거나 정책이 바뀔 수 있어, 한쪽이 막혀도 다른 쪽으로 넘어갈 수 있게 한 것입니다
- 합성된 음성은 문항 유형별 속도 기준에 맞게 후처리합니다
- 학습 데이터셋은 음원 하나를 한 행으로 두고, 개인정보를 지운 뒤 익명 식별자로 묶어 내보냅니다
07결과
배포가 스키마 사고로 실패하는 일이 없어졌습니다. 파괴적 명령은 배포 스크립트가 자동으로 걸러내기 때문에, 실수로 마이그레이션에 삭제 구문을 넣더라도 서버에 도달하지 않습니다. 문항을 운영 중에 수정해도 진행 중이거나 이미 끝난 회차의 결과는 변하지 않습니다. 음성 인식과 합성, 데이터셋 세 파이프라인은 사람이 지켜보지 않아도 도는 상태가 됐고, 녹음이 쌓이면 학습용 데이터로 나가는 과정까지 자동으로 이어집니다.