생성형 AI 기반 인터랙티브 콘텐츠 개발 회고록
이번 프로젝트에서는 생성형 AI를 이용한 인터랙티브 콘텐츠를 개발했다.
사용자가 키오스크에서 음성으로 입력하면 이를 서버로 보내고, AI를 통해 생성된 결과를 별도의 대형 디스플레이에 보여주는 방식이었다.
처음에는 AI 서버와 클라이언트를 연결하고 결과 영상만 재생하면 크게 어렵지 않을 것이라고 생각했다. 실제로 만들어보니 생각보다 신경 써야 할 부분이 많았다.
AI 자체보다도 그 앞뒤에 붙는 것들이 문제였다.
음성은 언제 녹음을 시작하고 끝낼지 정해야 했고, 키오스크와 프레젠터는 서로 다른 PC에서 동작했다. AI 영상은 바로 만들어지지 않았기 때문에 그동안 보여줄 화면도 필요했다. 영상이 만들어진 뒤에는 다운로드하고 준비하는 시간도 있었다.
결국 이 프로젝트에서 내가 주로 고민했던 것은 AI 모델 자체가 아니라 AI를 포함한 전체 콘텐츠 흐름을 어떻게 끊김 없이 만들 것인가였다.

처음에는 음성 입력부터 막혔다
키오스크에서 음성을 입력받는 부분은 Unity의 Microphone API를 사용했다.
문제는 녹음을 언제 끝낼 것인가였다.
버튼을 누르고 있는 동안만 녹음하는 방법도 있었지만, 실제 콘텐츠에서는 사용자가 버튼을 계속 누르고 있는 방식이 어색했다. 반대로 일정 시간마다 녹음을 종료하면 짧게 말하는 경우와 길게 말하는 경우를 제대로 처리하기 어려웠다.
그래서 마이크 버퍼를 직접 읽으면서 현재 음량을 확인하는 쪽으로 갔다.
마이크 데이터는 계속 순환하기 때문에 단순히 배열의 처음부터 끝까지 읽을 수 있는 구조도 아니었다.
현재 마이크 위치와 내가 마지막으로 읽은 위치를 비교해서 새로운 데이터가 어디까지 들어왔는지 확인했다.
버퍼 끝을 넘어간 경우에는 뒤쪽 데이터와 앞쪽 데이터를 따로 가져와 이어 붙였다.
이 부분은 코드 자체는 복잡하지 않았지만 실제 장비에서 테스트하면서 계속 확인해야 했다. 특히 마이크 위치가 버퍼의 끝을 넘어가는 순간을 제대로 처리하지 않으면 음성 데이터 일부가 빠질 수 있었다.
음성의 시작과 종료는 샘플의 RMS를 이용했다.
처음에는 하나의 음량 기준만 두려고 했지만 그렇게 하면 경계에서 계속 상태가 바뀌는 문제가 생길 수 있었다. 그래서 시작 기준과 종료 기준을 따로 두었다.
말할 정도의 음량이 들어오면 녹음을 시작하고, 일정 수준 이하의 소리가 일정 시간 동안 계속되면 녹음을 끝내는 방식이었다.
결과적으로 사용자는 버튼을 직접 관리하지 않고 그냥 말하면 됐다.
녹음 파일은 굳이 디스크에 만들 필요가 없었다
녹음이 끝나면 ASR 서버에 음성 데이터를 보내야 했다.
여기서 처음부터 파일을 만드는 방식으로 갈 수도 있었지만, 서버에 보내기 위해 잠깐 필요한 파일이었다. 굳이 디스크에 저장했다가 다시 읽어오는 과정이 필요하지 않았다.
마이크에서 얻은 Float32 데이터를 16-bit PCM으로 변환하고 WAV 헤더를 메모리에서 붙였다.
그렇게 만든 데이터를 MultipartFormDataSection에 넣어서 바로 서버로 보냈다.
Microphone -> Float32 -> PCM16 -> WAV -> HTTP
이 방식으로 처리하면서 임시 파일을 만들고 삭제하는 과정도 없앴다.
이런 부분은 처음부터 거창한 최적화를 하려고 한 것은 아니다. 그냥 한 번 쓰고 버릴 데이터를 굳이 디스크까지 거칠 이유가 없다고 판단했다.
키오스크와 프레젠터를 나누면서 통신 문제가 생겼다
이번 프로젝트는 프로그램 하나로 끝나지 않았다.
키오스크에서는 사용자의 입력을 받고, 실제 콘텐츠 출력은 다른 PC에 연결된 프레젠터에서 담당했다.
두 프로그램 사이에서는 TCP 소켓을 이용해 필요한 상태와 명령을 전달했다.
처음에는 소켓에서 데이터를 받으면 그 자리에서 이벤트를 호출하면 된다고 생각했다.
그런데 Unity에서 네트워크 처리를 별도 스레드로 돌리다 보니 문제가 생겼다.
백그라운드 스레드에서 받은 데이터를 가지고 Unity의 GameObject나 Transform을 건드리면 안 됐다.
그래서 네트워크 스레드에서는 메시지만 받아서 큐에 넣고, 실제 처리는 Unity 메인 스레드에서 하도록 바꿨다.
Socket Thread -> Queue -> Update -> Event
구조는 단순했다.
오히려 이런 식으로 역할을 확실하게 나누는 것이 관리하기 편했다.
네트워크 코드가 Unity 객체를 직접 만지지 않게 하니 관련된 예외도 정리하기 쉬워졌다.
AI 생성 시간을 어떻게 보여줄 것인가
이 프로젝트에서 생각보다 고민을 많이 한 부분이다.
AI 영상 생성은 요청한다고 바로 결과가 나오는 작업이 아니었다.
서버에 생성 요청을 보내고 작업 ID를 받은 다음, 작업 상태를 확인하고, 완료되면 결과 영상을 받아야 했다.
Generate Request -> Task ID -> Status Check -> Result
문제는 이 시간이 사용자 입장에서는 그냥 기다리는 시간이라는 것이다.
화면에 로딩 아이콘 하나만 띄워놓는 것도 방법이었지만 콘텐츠의 성격과는 맞지 않았다.
그래서 생성 중인 시간을 아예 콘텐츠 연출에 사용하기로 했다.
3D 공간에 픽셀과 노드 형태의 오브젝트를 배치하고 이들을 연결하는 애니메이션을 만들었다.
AI 내부에서 실제로 이런 그래픽 처리가 일어나는 것은 아니다. 중요한 건 사용자가 결과를 기다리는 동안 화면에서 계속 무언가 진행되고 있다는 느낌을 주는 것이었다.
이때부터 AI 생성 작업과 3D 연출을 따로 생각하지 않게 됐다.
AI 서버가 작업하는 동안 클라이언트에서는 그 시간을 받아서 연출을 진행하고, 영상이 준비되면 다음 화면으로 넘어가는 식으로 연결했다.
만들고 보니 오브젝트가 너무 많았다
3D 연출을 처음 만들었을 때는 필요한 오브젝트를 그때그때 생성했다.
픽셀을 만들어야 하면 Instantiate하고, 필요 없어지면 Destroy했다.
테스트 이미지에서는 별 문제가 없었다.
그런데 이미지가 커지고 연출을 반복하니까 문제가 눈에 띄기 시작했다.
오브젝트를 대량으로 만들고 없애는 과정에서 프레임이 순간적으로 크게 떨어졌다. 특히 여러 번 반복할수록 GC가 발생하는 것이 보였다.
그래서 Object Pool을 적용했다.
필요한 오브젝트를 미리 만들어놓고 이후에는 꺼내서 사용하고 다시 돌려놓았다.
Pool -> Get -> Use -> Return
이후에는 생성과 삭제를 계속 반복할 필요가 없었다.
처음부터 모든 것을 풀링한 것은 아니고 실제로 문제가 발생한 부분부터 바꿨다. 이 프로젝트를 하면서 성능 문제는 예상해서 전부 최적화하는 것보다 실제로 병목이 생긴 부분을 확인하고 고치는 편이 훨씬 효율적이라는 생각이 더 강해졌다.
색상 하나 바꾸는데도 성능 문제가 생겼다
픽셀이나 노드의 색상은 계속 변해야 했다.
처음에는 각 Renderer의 Material 색상을 직접 변경했다.
그런데 오브젝트가 많아지면서 드로우콜이 크게 늘어났다.
확인해보니 renderer.material을 직접 사용하는 방식 때문에 Material 인스턴스가 만들어지는 문제가 있었다.
그래서 공유 Material은 그대로 사용하고 개별 색상만 MaterialPropertyBlock으로 넘겼다.
if (_mpb == null)
_mpb = new MaterialPropertyBlock();
mr.GetPropertyBlock(_mpb);
_mpb.SetColor("_BaseColor", color);
mr.SetPropertyBlock(_mpb);
이후에는 오브젝트마다 Material을 따로 만들지 않고 필요한 값만 바꿀 수 있었다.
이 부분은 코드 몇 줄을 바꾼 것처럼 보이지만 실제로는 프레임 성능에 차이가 꽤 컸다.
특히 많은 오브젝트를 동시에 움직이는 연출에서는 개별 객체 하나의 비용이 작아 보여도 결국 전부 합쳐져서 문제가 된다.
화면이 두 개라서 렌더링도 다시 생각해야 했다
최종 출력은 1920×1080 화면 두 대를 붙여 3840×1080으로 사용하는 환경이었다.
처음 생각한 방법은 카메라를 두 개 만들고 각각 화면을 렌더링하는 것이었다.
그렇게 하면 구현 자체는 어렵지 않다.
하지만 같은 장면을 두 번 렌더링하게 된다.
그래서 카메라에서 3840×1080 크기의 RenderTexture 하나를 만든 뒤 전체 장면을 한 번 렌더링하고, 그 결과를 두 화면에 나눠 보여주는 방식으로 변경했다.
왼쪽 화면에는 RenderTexture의 왼쪽 절반을 보여주고 오른쪽 화면에는 오른쪽 절반을 보여줬다.
Camera -> 3840x1080 RT -> Left UV / Right UV -> Two Displays
이렇게 하면 실제 3D 장면은 한 번만 렌더링하면 된다.
두 화면을 하나의 넓은 화면처럼 다뤄야 했기 때문에 UI나 카메라 구성을 잡을 때 실제 장비에서 계속 확인해야 했다.
PC 모니터 하나에서 볼 때는 괜찮아도 실제 두 디스플레이로 출력하면 화면 경계나 비율이 다르게 느껴지는 경우가 있었기 때문이다.
캡처도 생각보다 비용이 컸다
AI 서버에 전달할 이미지가 필요한 경우에는 별도의 카메라를 이용해 화면을 캡처했다.
평상시에는 이 카메라를 사용하지 않다가 캡처가 필요한 순간에만 활성화했다.
Capture Request -> Camera Render -> RenderTexture -> Texture2D -> Upload
여기서 ReadPixels를 사용하면 GPU에 있는 데이터를 CPU 메모리 쪽으로 가져와야 한다.
3840×1080 정도의 이미지를 다루다 보니 이 작업도 무시할 수 있는 비용은 아니었다.
그래서 캡처용 RenderTexture와 Texture2D의 생성 및 삭제 시점을 명확하게 관리했다.
특히 이런 리소스는 C# 객체만 보고 있으면 실제 메모리 사용량을 놓치기 쉽다. Unity에서는 GPU 리소스의 수명도 같이 생각해야 했다.
영상이 준비되기 전에 재생하면 화면이 깨졌다
AI 영상 생성이 끝났다고 바로 화면에 영상을 틀 수 있는 것도 아니었다.
파일을 다운로드한 뒤 VideoPlayer.Play()를 호출했는데 처음 몇 프레임 동안 화면이 검게 나오는 문제가 있었다.
영상 파일이 존재하는 것과 영상 재생 준비가 끝난 것은 다른 문제였다.
그래서 다운로드가 끝나면 바로 재생하지 않고 VideoPlayer.Prepare()를 호출했다.
Download -> Prepare -> prepareCompleted -> Play
그리고 준비되는 동안에는 기존 3D 화면을 유지하거나 화면을 어둡게 만들고, 영상이 준비된 시점에 맞춰 전환했다.
이렇게 하고 나니 사용자가 검은 화면을 보는 시간이 크게 줄었다.
여기서도 결국 문제는 영상 재생 자체가 아니라 재생 준비 시간을 어떻게 콘텐츠 흐름에 포함시키느냐였다.
네트워크가 끊겼을 때를 나중에 생각하면 늦는다
개발하면서 가장 골치 아팠던 부분 중 하나는 정상적인 상황에서는 잘 동작하는데 연결이 끊겼을 때 프로그램이 멈추는 경우였다.
키오스크와 프레젠터 사이의 연결이 끊어졌는데 소켓 읽기가 끝나지 않으면 다음 작업으로 넘어갈 수 없었다.
처음에는 정상적으로 연결되어 있는 상황만 생각하고 흐름을 만들었는데, 실제 장비에서는 언제든 케이블이나 네트워크 문제가 발생할 수 있었다.
그래서 각 비동기 작업에 타임아웃을 두고, 실패했을 때 현재 작업을 정리하고 초기 상태로 돌아갈 수 있도록 수정했다.
콘텐츠가 한 번 실행되고 끝나는 프로그램이었다면 문제가 덜했을 것이다.
하지만 키오스크는 계속해서 다음 사용자를 받아야 한다.
한 번의 오류 때문에 프로그램을 재시작해야 한다면 운영 환경에서는 상당히 불편하다.
그래서 작업이 성공했을 때뿐만 아니라 실패했을 때도 마지막에는 같은 초기 상태로 돌아오도록 만드는 것을 중요하게 봤다.
개발하면서 생각이 바뀐 부분
처음에는 생성형 AI 콘텐츠를 개발한다고 하면 AI 모델을 어떻게 연결하고 결과를 어떻게 보여줄지가 가장 중요하다고 생각했다.
실제로 개발해보니 그렇지 않았다.
사용자가 말하는 순간부터 결과 영상이 재생될 때까지 수많은 단계가 있었다.
음성 입력이 제대로 끝나야 하고, 데이터가 서버로 넘어가야 하고, AI 작업이 완료되어야 하고, 영상이 다운로드되어야 하고, 영상 플레이어가 준비되어야 한다.
중간에 어느 한 단계라도 멈추면 사용자는 전체 시스템이 멈춘 것으로 느낀다.
그래서 각 기능을 따로 잘 만드는 것보다 전체 흐름을 하나의 상태로 보고 관리하는 것이 중요했다.
예를 들어 내부적으로는
Idle -> Recording -> Processing -> Generating -> Downloading -> Preparing -> Playing -> Reset
같은 흐름으로 생각했다.
어떤 단계에서 문제가 발생하더라도 결국 Reset까지 갈 수 있어야 했다.
이 구조를 만들고 나니 새로운 예외 상황이 생겼을 때도 어디에서 처리해야 하는지 판단하기 쉬워졌다.
이번 프로젝트에서 배운 것
이번 개발을 통해 생성형 AI를 콘텐츠에 붙이는 것과 생성형 AI를 실제 서비스 형태의 콘텐츠로 만드는 것은 상당히 다르다는 것을 경험했다.
AI 서버에 요청을 보내는 것은 어렵지 않다.
진짜 문제는 그 사이의 시간을 어떻게 처리하고, 실패했을 때 어떻게 복구하고, 결과가 늦게 도착해도 사용자 경험이 깨지지 않게 만드는 것이었다.
특히 생성형 AI는 결과 생성 시간이 일정하지 않다는 특성이 있다.
그래서 일반적인 게임처럼 모든 작업이 즉시 끝난다는 전제로 클라이언트를 설계하면 문제가 생기기 쉽다.
이번 프로젝트에서는 그 시간을 3D 연출로 사용했다.
결과를 기다리는 시간이 단순한 대기 시간이 아니라 콘텐츠의 한 부분이 되도록 만든 것이다.
성능 측면에서도 비슷했다.
처음부터 복잡한 최적화 구조를 만든 것이 아니라 실제로 문제가 생긴 곳을 하나씩 찾아갔다.
오브젝트 생성 때문에 프레임이 떨어지면 풀링을 적용했고, Material 사용으로 드로우콜이 늘어나면 MaterialPropertyBlock으로 변경했다.
영상 다운로드로 메모리가 커지면 파일 기반 다운로드로 바꿨다.
이런 식으로 하나씩 문제를 해결하면서 시스템을 완성했다.
다음에 다시 만든다면
현재 구조를 다시 만든다면 통신 부분부터 조금 다르게 가져갈 것 같다.
현재는 JSON 기반 TCP 통신으로 필요한 메시지를 전달하고 있는데, 프로젝트 규모가 커진다면 메시지 구조를 명확하게 관리할 수 있는 바이너리 프로토콜을 사용하는 것도 고려할 수 있다.
3D 연출 역시 현재는 많은 GameObject를 직접 관리하는 방식이다.
오브젝트 수가 지금보다 훨씬 많아진다면 CPU에서 각각의 GameObject를 관리하는 방식은 한계가 있다. 그 단계에서는 GPU Instancing이나 Compute Shader를 사용하는 쪽이 더 적합할 것이다.
음성 인식 역시 모든 처리를 서버에 맡기기보다는 일부 처리를 클라이언트에서 수행하면 전체 응답 시간을 줄일 수 있다.
다만 다음 프로젝트에서 무조건 이런 기술을 적용할 생각은 없다.
이번 프로젝트를 하면서 오히려 필요하지 않은 기술을 미리 넣지 않는 것이 중요하다는 것도 배웠다.
현재 규모에서 문제가 없는 구조를 복잡하게 바꾸는 것보다 실제 병목이 생겼을 때 그 부분을 교체하는 편이 낫다.
마무리
이번 프로젝트는 AI 모델을 호출하는 기능 하나를 만드는 프로젝트라기보다는, 생성형 AI를 포함한 전체 사용자 경험을 실제 클라이언트에서 연결하는 작업에 가까웠다.
음성을 받고, 서버에 보내고, AI가 작업하는 동안 화면을 보여주고, 결과가 만들어지면 영상을 준비해서 재생한다.
겉으로 보면 단순한 흐름이지만 그 사이에는 오디오 버퍼, 네트워크 스레드, 비동기 작업, 메모리 관리, 렌더링, 영상 디코딩 등 여러 문제가 있었다.
개발하면서 가장 크게 느낀 것은 생성형 콘텐츠에서는 AI 자체보다 AI를 기다리는 동안의 경험까지 개발해야 한다는 것이었다.
결과가 빨리 나오는 상황만 고려하면 시스템은 쉽게 만들어진다.
하지만 실제 환경에서는 네트워크가 끊길 수도 있고, 생성이 늦어질 수도 있고, 영상이 바로 재생되지 않을 수도 있다.
결국 좋은 생성형 콘텐츠는 AI 결과만 잘 만드는 것이 아니라, 그 결과가 나오기 전과 나온 이후까지 포함해서 하나의 흐름으로 만드는 것이라고 생각한다.