실시간 3D 버추얼 휴먼 시스템 개발 회고록
프로젝트 개요
이번 프로젝트에서는 키오스크와 임베디드 PC에서 동작하는 3D 버추얼 휴먼 시스템을 개발했다.
사용자가 마이크에 말을 하면 클라이언트에서 음성을 녹음하고 서버로 전송한다. 서버에서는 ASR, LLM, TTS 처리를 거친 뒤 대사와 음성 파일을 다시 클라이언트로 내려준다. 클라이언트에서는 받은 결과를 바탕으로 캐릭터의 음성, 표정, 제스처, 립싱크를 같이 재생한다.
구조만 보면 크게 복잡하지 않다.
문제는 실제로 붙여놓고 테스트하면서부터 생겼다.
말을 시작하면 첫 음절이 잘리는 경우가 있었고, 주변 소음 때문에 엉뚱하게 녹음이 시작되기도 했다. 서버 응답을 기다리는 동안 캐릭터가 가만히 있으면 프로그램이 멈춘 것처럼 보였다. TTS 음성을 재생할 때도 단순히 입을 열고 닫는 정도로는 캐릭터가 상당히 어색했다.
게다가 실제 사용 환경이 키오스크였기 때문에 PC에서 잘 돌아가는 것만으로는 부족했다.
장시간 실행했을 때 프레임이 떨어지지 않아야 했고, USB 마이크나 오디오 인터페이스가 순간적으로 끊겨도 프로그램 전체를 재시작하지 않고 복구할 수 있어야 했다.
그래서 이번 프로젝트에서는 AI 모델 자체보다는 AI 결과를 실제 3D 캐릭터의 인터랙션으로 연결하는 클라이언트 쪽을 직접 다듬는 데 많은 시간을 사용했다.

음성 입력부터 다시 만들었다
처음에는 마이크 입력의 RMS와 데시벨을 기준으로 음성이 시작되었는지만 판단했다.
구현 자체는 어렵지 않았다.
문제는 사람이 실제로 말할 때 처음부터 큰 소리가 들어오지 않는다는 것이었다.
예를 들어 “안녕하세요”라고 말하면 첫 음절의 시작 부분은 상대적으로 작게 들어올 수 있다. 임계값을 넘어간 순간부터 녹음을 시작하면 앞부분이 잘릴 수밖에 없었다.
그래서 마이크 데이터를 계속 가지고 있는 Pre-Roll 버퍼를 만들었다.
약 750ms 정도의 샘플을 원형 버퍼에 유지했다.
Microphone -> Ring Buffer -> Voice Detected -> Pre-Roll Copy -> Recording
음성이 감지되면 현재 녹음 버퍼에 바로 쓰는 것이 아니라, 먼저 링 버퍼에 남아 있던 데이터를 복사한 다음 현재 입력을 이어 붙였다.
이렇게 하니 녹음 시작 시점이 조금 늦어져도 앞부분을 어느 정도 복구할 수 있었다.
원형 버퍼는 고정 크기로 만들었다. 계속 새로운 배열을 생성하는 방식으로 구현하면 음성이 시작될 때마다 불필요한 할당이 발생하기 때문이다.
버퍼의 끝에서 다시 처음으로 넘어가는 경우에는 데이터를 한 번에 복사할 수 없어서 Tail과 Head 부분을 나눠 Array.Copy했다.
별것 아닌 것처럼 보이지만 실제로 음성 시작 부분이 잘리는 문제를 잡는 데는 이 방식이 가장 간단했다.
RMS 하나로는 음성을 제대로 구분하기 어려웠다
Pre-Roll을 넣고 나니 다른 문제가 보였다.
이번에는 녹음이 너무 자주 시작됐다.
키오스크 주변에서 박수를 치거나 마이크를 건드리면 데시벨 값이 순간적으로 올라갔다. RMS 임계값만 보고 있으면 이런 소리도 음성으로 판단했다.
그래서 주파수 정보를 같이 보기로 했다.
외부 DSP 라이브러리를 붙이는 대신 필요한 FFT를 C#으로 직접 구현했다. Radix-2 DIT 방식으로 FFT를 구성하고 입력 크기를 2의 거듭제곱으로 맞췄다.
그 다음 300Hz에서 3400Hz 정도를 음성 대역으로 잡고 전체 주파수 에너지 중 해당 구간에 얼마나 에너지가 들어있는지를 계산했다.
결국 음성 판단 조건은 하나가 아니게 됐다.
RMS >= Threshold && VoiceEnergyRatio >= Threshold
여기에 일정 시간 이상 조건을 만족해야 실제 녹음 상태로 넘어가도록 했다.
반대로 녹음 중에는 일정 시간 동안 조건을 만족하지 못하면 녹음을 종료했다.
여기서 단순히 조건이 깨지는 순간 누적값을 0으로 만드는 대신 감소 속도를 더 빠르게 줬다.
음성이 들어오는 동안에는 천천히 누적하고, 음성이 끊기면 빠르게 감소시키는 식이다.
이렇게 바꾸고 나서 순간적인 소음 때문에 녹음 상태가 왔다 갔다 하는 현상이 많이 줄었다.
서버에 보내기 전에 오디오를 정리했다
녹음 데이터가 만들어졌다고 바로 서버에 보내지는 않았다.
실제 환경에서는 마이크마다 입력 크기가 달랐고 저주파 잡음도 꽤 많이 들어왔다.
그래서 전송 전에 간단한 전처리를 넣었다.
먼저 하이패스 필터를 적용해 저주파 성분을 줄였다. 그 다음 RMS를 기준으로 음량을 맞추고 16-bit PCM으로 변환했다.
최종적으로는 직접 WAV 헤더를 만들어 전송했다.
PCM -> High Pass -> Normalize -> 16-bit PCM -> WAV
파일 구조도 직접 알고 있는 편이 디버깅하기 편했다.
특히 서버에서 ASR 결과가 이상하게 나왔을 때 클라이언트에서 실제 어떤 WAV를 만들고 있는지 바로 확인할 수 있어야 했다.
립싱크는 음소 대신 주파수로 처리했다
TTS까지 연결한 뒤 가장 눈에 띄었던 문제가 립싱크였다.
처음에는 음성이 재생되는 동안 입을 일정하게 열고 닫는 방식으로 테스트했다.
당연히 어색했다.
사람이 말을 할 때 입 모양은 계속 달라지는데 단순한 Open / Close만 반복하니 캐릭터가 말을 한다기보다 입을 움직이는 것처럼 보였다.
그렇다고 음성을 음소 단위로 분석하는 시스템을 새로 만드는 것은 프로젝트 범위에 맞지 않았다.
그래서 현재 재생 중인 오디오의 Spectrum Data를 가져와 주파수 대역별 에너지를 계산했다.
저역, 중저역, 중고역, 고역으로 나누고 각 영역의 에너지에 서로 다른 가중치를 적용했다.
대략적으로 ah, eh, ee, oh, oo에 해당하는 몇 가지 입 모양을 만들고 주파수 에너지와 연결했다.
정밀한 음소 기반 립싱크는 아니다.
하지만 실제 캐릭터에 적용했을 때 단순 개폐 방식보다는 확실히 자연스러웠다.
여기에 전체 음량을 이용해 입 모양의 크기를 조절했다.
작게 말하면 입도 작게 움직이고 크게 말하면 크게 움직이게 했다.
그대로 값을 넣으면 프레임마다 입 모양이 흔들려서 보기 좋지 않았기 때문에 SmoothStep과 지수 평활화를 추가했다.
이 부분은 수치를 계속 조절하면서 맞췄다. 너무 빠르게 따라가면 떨리고, 너무 느리면 음성과 입이 어긋났다.
캐릭터마다 립싱크 방식이 달랐다
모든 캐릭터가 같은 BlendShape 구조를 가지고 있지는 않았다.
일부 캐릭터는 여러 개의 입 모양 BlendShape가 있었지만, 마스코트 형태의 캐릭터는 입을 하나의 Mesh로 열고 닫는 정도만 가능했다.
처음부터 캐릭터에 맞춰 립싱크 코드를 따로 만드는 방향으로 가면 캐릭터가 추가될 때마다 수정할 부분이 많아진다.
그래서 오디오 분석과 캐릭터 적용 부분을 분리했다.
Audio Analysis -> LipSync Value -> Character Adapter -> BlendShape
여러 입 모양을 사용할 수 있는 캐릭터는 분석 결과를 각각의 BlendShape에 넣고, 단일 입 모프만 있는 캐릭터는 전체 에너지를 하나의 값으로 압축해서 사용했다.
덕분에 오디오 분석 코드는 그대로 두고 캐릭터 쪽만 다르게 처리할 수 있었다.
실제 개발에서는 이런 식으로 변경될 가능성이 높은 부분을 뒤쪽으로 밀어두는 것이 꽤 중요했다.
AI 응답을 기다리는 시간도 연출해야 했다
AI 서버를 붙이고 나서 가장 크게 체감한 문제는 응답 시간이었다.
음성을 업로드하고 ASR을 거친 다음 LLM 응답을 만들고 TTS까지 수행해야 하기 때문에 사용자가 바로 답변을 받을 수 있는 구조가 아니었다.
이 시간을 그냥 비워두면 캐릭터가 몇 초 동안 가만히 있게 된다.
그래서 요청을 보내는 순간 캐릭터를 생각하는 모션으로 변경했다.
그리고 응답이 오래 걸리는 경우에는 짧은 대기 대사를 재생했다.
예를 들어 “음…”, “잠시만요…” 같은 짧은 대사를 먼저 재생하고, 필요하면 조금 더 긴 대사를 넣었다.
대기 대사 역시 그냥 음성만 재생하지 않고 기존 립싱크 시스템을 그대로 연결했다.
덕분에 서버 응답을 기다리는 동안에도 캐릭터가 계속 말을 하고 있는 것처럼 보였다.
실제 서버 처리 시간이 줄어든 것은 아니다.
대신 사용자가 아무것도 하지 않고 기다리는 시간이 줄어든 것처럼 느끼게 만들 수 있었다.
이때 레이턴시를 없애는 것과 레이턴시를 덜 느끼게 만드는 것은 다른 문제라는 걸 확실히 느꼈다.
비동기 요청은 실패하는 경우까지 생각했다
대기 상태를 구현하면서 타임아웃 처리도 필요했다.
서버 요청을 보내고 일정 시간이 지나면 오류 상태로 빠지게 했는데, 여기서 서버 응답과 타임아웃이 거의 동시에 발생하는 상황이 생길 수 있었다.
응답 쪽에서 성공 처리를 했는데 타임아웃 쪽에서도 실패 처리를 해버리는 식이다.
이런 상황이 발생하면 nowProcessing 같은 상태값이 꼬이거나 Animator가 대기 상태에 그대로 남기도 했다.
그래서 요청마다 타임아웃을 관리할 수 있는 키를 두고, 정상 응답이 들어오면 해당 타이머를 바로 제거했다.
반대로 타임아웃이 먼저 발생하면 현재 요청에 연결된 상태를 정리했다.
실패 시에는 UI만 닫는 식으로 처리하지 않고 오디오, Animator, 처리 플래그까지 같이 초기화했다.
Request -> Processing -> Success / Timeout -> Reset
비동기 시스템은 정상적인 경우보다 오히려 중간에 실패했을 때 상태가 어디까지 복구되는지가 중요했다.
표정과 제스처도 별도로 관리했다
대화하는 캐릭터는 입만 움직여서는 부족했다.
기본적으로 숨을 쉬거나 대기하는 모션이 있고, 그 위에 인사나 설명 같은 제스처가 들어간다.
Animator Layer를 분리해서 기본 동작과 제스처를 각각 관리했다.
제스처가 시작되는 순간 Layer Weight를 바로 1로 바꾸면 동작이 튀기 때문에 일정 시간 동안 가중치를 보간했다.
끝날 때도 같은 방식으로 다시 내려줬다.
Idle Layer + Gesture Layer
이 구조를 사용하니 상체 제스처가 들어가도 기본 동작이 갑자기 끊기는 문제가 줄었다.
감정 표현은 서버에서 받은 감정 코드를 BlendShape와 연결했다.
Emotion Code -> BlendShape -> Attack / Sustain / Decay
감정을 바로 100으로 올렸다가 바로 0으로 내리는 방식보다는 어느 정도 시간을 두고 올라갔다 내려오도록 만들었다.
기본 표정과 감정 표정의 가중치도 서로 맞춰서 같은 부위의 BlendShape가 겹쳐서 이상하게 변형되는 것을 피했다.
눈 깜빡임은 랜덤하게 만들었다
눈 깜빡임은 작은 기능이지만 캐릭터의 인상에 영향을 많이 줬다.
처음처럼 일정한 시간마다 깜빡이면 반복 패턴이 눈에 보인다.
그래서 다음 깜빡임 시간을 최소 2초, 최대 6초 사이에서 랜덤하게 결정했다.
눈을 감고 뜨는 시간도 짧게 보간했다.
이 정도만 넣어도 캐릭터가 같은 애니메이션을 반복해서 재생하는 느낌이 조금 줄었다.
키오스크에서는 메모리와 발열도 봐야 했다
개발 PC에서는 성능 문제가 크게 보이지 않았다.
하지만 실제 키오스크에서는 장시간 실행되는 상황을 생각해야 했다.
특히 매 프레임 메모리를 할당하는 부분은 최대한 없앴다.
FFT 버퍼와 Pre-Roll 버퍼, 녹음 버퍼는 미리 만들어 두고 재사용했다.
Update나 모니터링 루프에서는 LINQ 사용도 피했다.
이런 부분 하나하나가 엄청난 성능 향상을 만드는 것은 아니지만, 장시간 실행되는 프로그램에서는 불필요한 GC를 줄여두는 편이 안전했다.
렌더링 쪽에서는 URP Render Scale과 목표 FPS를 런타임에서 조절할 수 있도록 했다.
키오스크의 GPU가 계속 높은 부하를 유지하면서 발열이 올라가는 것보다는 약간의 화질을 포기하고 일정한 프레임을 유지하는 쪽이 낫다고 판단했다.
현장에서 직접 값을 조절할 수 있게 만들었다
VAD는 코드만 보고 튜닝하기가 상당히 불편했다.
개발실에서 사용하는 마이크와 실제 설치 장소의 마이크가 다르고, 주변 소음도 다르기 때문이다.
그래서 현재 데시벨과 음성 대역 에너지 비율을 그래프로 보여주는 HUD를 만들었다.
실제로 사람이 말할 때 값이 어느 정도 나오는지 보고 임계값을 조절할 수 있게 했다.
이 기능을 넣고 나서는 현장에서 튜닝하는 시간이 많이 줄었다.
숫자 하나를 바꿔서 다시 빌드하는 것보다 실제 입력 신호를 보면서 값을 맞추는 편이 훨씬 빠르기 때문이다.
오디오 장치가 끊기는 경우도 처리했다
키오스크는 USB 오디오 장치가 사용되는 경우가 많았다.
문제는 USB 장치가 항상 정상적으로 유지된다는 보장이 없다는 것이다.
일시적으로 장치가 빠지거나 드라이버가 재설정되면 기존 마이크 스트림이 정상적으로 동작하지 않는 경우가 있었다.
이 상황에서 프로그램을 다시 실행시키는 것은 현장 대응 방법으로 적절하지 않았다.
그래서 일정 시간마다 Microphone.devices를 확인하고 기존 목록과 비교했다.
장치 목록이 바뀌면 현재 모니터링을 중지하고 잠시 기다린 뒤 마이크를 다시 초기화했다.
Device Changed -> Stop -> Reinitialize -> Resume
모든 하드웨어 문제를 해결할 수는 없지만, 일시적인 장치 오류 정도는 프로그램을 재시작하지 않고 복구할 수 있었다.
아쉬웠던 부분
현재 구조가 모든 상황에서 최선이라고 생각하지는 않는다.
FFT와 립싱크 처리를 메인 스레드에서 수행했기 때문에 오디오 처리량이 많아지면 렌더링 프레임에 영향을 줄 가능성이 있다.
다시 만든다면 Unity Job System과 Burst를 이용해 신호 처리를 별도 작업으로 빼는 것을 먼저 고려할 것 같다.
Coroutine도 구현 당시에는 충분했지만 비동기 요청이 많아지면서 상태 관리가 복잡해지는 부분이 있었다.
특히 요청 취소와 예외 처리를 생각하면 UniTask와 CancellationToken을 이용하는 구조가 더 깔끔했을 것 같다.
통신 역시 HTTP로 완성된 오디오 파일을 받아오는 방식이라 TTS가 끝날 때까지 기다려야 했다.
다음에는 WebSocket이나 스트리밍 방식을 이용해 TTS가 생성되는 즉시 일부 음성을 재생할 수 있는 구조를 고려할 수 있다.
립싱크도 마찬가지다.
현재 방식은 주파수 에너지를 이용한 근사 방식이라 음소를 정확하게 구분하지 못한다. 파열음이나 양순음처럼 주파수만으로 구분하기 어려운 발음도 있다.
정밀한 립싱크가 필요하다면 음성에서 직접 phoneme 또는 viseme 정보를 얻어 캐릭터 애니메이션으로 전달하는 구조가 더 적합하다.
개발하면서 느낀 점
이 프로젝트를 하면서 가장 크게 느낀 것은 버추얼 휴먼은 어느 한 시스템만 잘 만든다고 끝나는 작업이 아니라는 점이었다.
오디오 입력이 조금 늦어도 문제가 생기고, 서버 응답이 늦어도 문제가 생긴다.
AI 응답은 정상인데 립싱크가 어색해도 전체적으로는 어색하다.
애니메이션이 잘 만들어져 있어도 USB 마이크가 끊기면 아무 의미가 없다.
결국 각각의 기능을 따로 보는 것보다 전체 흐름을 하나의 시스템으로 보는 것이 중요했다.
특히 이번 프로젝트에서는 실제 처리 시간을 줄이는 작업만큼 사용자가 기다리는 동안 무엇을 보여줄 것인가를 고민하는 것이 중요했다.
서버가 3초 걸리는 것을 클라이언트에서 1초로 만들 수는 없다.
하지만 그 3초 동안 캐릭터가 생각하고, 움직이고, 짧게 반응하도록 만들 수는 있다.
VAD도 마찬가지였다.
완벽하게 음성과 소음을 구분하는 모델을 만드는 대신 Pre-Roll, FFT 기반 에너지 분석, 히스테리시스를 조합해서 현재 하드웨어와 환경에서 충분히 안정적인 결과를 만드는 쪽을 선택했다.
결국 이 프로젝트에서 가장 중요했던 것은 새로운 기술을 많이 사용하는 것이 아니었다.
주어진 하드웨어와 네트워크 환경에서 실제 사용자가 자연스럽게 느낄 수 있는 하나의 경험으로 묶는 것.
그 과정에서 오디오 DSP부터 네트워크, 비동기 상태 관리, 애니메이션, 렌더링 최적화까지 서로 다른 영역을 같이 다뤄볼 수 있었다.
버추얼 휴먼을 단순한 3D 캐릭터가 아니라 하나의 실시간 시스템으로 바라보게 된 프로젝트였다.