방송 화면에 자막 하나 얹으려고 별도 실행 파일을 반년 넘게 띄워 뒀다. obs-websocket으로 OBS의 텍스트 소스를 갱신하는 방식이었는데 기능만 놓고 보면 잘 돌아간다. 진짜 문제는 사용자 경험 쪽이다. 시청자는 몰라도 되지만 방송을 켜는 사람 입장에서는 다르다. 그 사람 눈에는 이게 플러그인이 아니라 "외부 도구를 하나 더 켜고 연결까지 맞춰야 하는 설정"으로 보였다.

OBS 네이티브 플러그인으로 갈아타기로 했다. 막상 들여다보니 음성 인식 엔진은 파이썬으로만 짜여 있고 클라우드와 로컬을 합쳐 이미 열두 개 엔진이 돌아가는 데다 설정 화면까지 딸려 있다. C++로 처음부터 다시 짜면 이 전부를 버려야 한다. 그렇다고 통째로 순수 C++ 네이티브로 갈아엎는 게 답일까. 이미 같은 선례가 있는데 굳이 같은 걸 다시 만들 이유는 없다.

결국 남는 길은 하나였다. C++로 만든 얇은 필터와 기존 파이썬 인식 엔진 사이를 잇는 통로, 곧 프로세스 간 통신(IPC, Inter-Process Communication)을 새로 설계하기로 했다.

exe 하나로는 안 되는 이유

OBS 플러그인 생태계에는 이미 whisper.cpp를 통째로 임베드한 오디오 필터 플러그인이 있다. 이 선례 덕분에 C++로 STT(음성을 텍스트로 바꾸는 처리)를 OBS 안에 직접 붙이는 게 기술적으로 가능하다는 건 이미 증명된 셈이었다. 그런데 내가 쓰던 인식 엔진은 그 선례와 사정이 달랐다. faster-whisper는 파이썬 전용 라이브러리라 C++ 코드에 직접 링크할 방법이 없다.

여기서 선택지가 갈렸다. 하나는 whisper.cpp로 완전히 갈아타 순수 네이티브 플러그인을 새로 짜는 길이다. 이건 이미 존재하는 선례와 기능이 겹칠 뿐만 아니라 클라우드 엔진까지 포함한 열두 개 인식 엔진과 설정 GUI를 통째로 버려야 한다. 다른 하나는 C++ 얇은 필터와 파이썬 인식 엔진 사이드카를 IPC로 잇는 하이브리드다. 기존 자산을 그대로 살리면서 사용자에게는 진짜 플러그인처럼 보이게 만드는 쪽이다. 따져볼수록 답은 후자였다.

기존 exe+웹소켓 구조와 비교하면 차이가 선명해진다. exe 쪽은 별도 프로세스를 띄우고 OBS의 웹소켓 API로 텍스트 소스를 갱신하는 방식이다. 로컬이라도 소켓 하나가 열리고 그 위에 JSON 메시지가 오간다. 하이브리드 쪽은 자식 프로세스의 표준 입출력(stdin/stdout)을 그대로 파이프로 쓴다. 이 IPC 경로는 수신 포트를 열지 않는다.

항목 순수 exe + obs-websocket 하이브리드 네이티브 플러그인
사용자 눈에 보이는 형태 별도 실행 파일 + 연결 설정 OBS 필터 목록에 바로 등록
통신 경로 로컬 웹소켓 포트 + JSON stdin/stdout 바이너리 파이프
네트워크 외부 접점 포트 하나 열림 OBS↔사이드카 IPC 수신 포트: 없음
기존 파이썬 엔진 12종 그대로 사용 사이드카로 그대로 재사용
라이선스 제약 배포 방식 따라 검토 GPL-2.0(libobs 링크)
오디오 파형이 실시간으로 자막 텍스트로 흘러가는 개념을 표현한 인디고와 앰버 톤의 일러스트
실시간 오디오 파형이 자막으로 변환되는 흐름을 표현한 일러스트.

C++ 필터와 파이썬 사이드카가 나눠 맡는 일

구조를 정했으니 이제 누가 뭘 맡을지 선을 그어야 했다. C++ 필터는 libobs에 직접 링크된 얇은 오디오 필터다. OBS 오디오 파이프라인에서 PCM(가공하지 않은 원본 오디오 샘플)을 받아 자식 프로세스로 넘기고 인식 결과가 돌아오면 자막 텍스트 소스를 갱신한다. 그 이상은 하지 않는다. 무겁고 자주 바뀌는 로직은 전부 파이썬 쪽에 남겨 뒀다. libobs를 직접 링크하는 이상 라이선스 문제도 함께 따라왔다. 선례가 GPL-2.0을 쓰고 있었으니 배포하려면 같은 라이선스와 호환되는지부터 확인해야 했다.

파이썬 사이드카는 faster-whisper와 기존 열두 개 엔진, 클라우드 API 연동, 설정 파라미터를 그대로 물려받는다. 새로 짤 필요가 없다는 게 이 구조의 핵심 이득이다. 인터페이스는 오디오를 넣고(feed_audio), 중간 결과를 받고(on_partial), 최종 결과를 받는(on_final) 몇 개 함수로 이미 갖춰져 있었고 거기에 IPC 어댑터 하나만 끼워 넣으면 그만이었다.

C++ libobs 필터와 파이썬 STT 사이드카가 표준 입출력 파이프로 연결된 하이브리드 아키텍처 구조도
OBS 안의 C++ 필터와 별도 프로세스인 파이썬 STT 사이드카가 IPC로 연결되는 구조.

이렇게 나눠 놓으니 각자 잘하는 일만 하게 됐다. C++ 쪽은 OBS 오디오 콜백 안에서 지연 없이 돌아야 하니 가볍게 유지하고 파이썬 쪽은 무거운 모델 추론을 자유롭게 돌린다. 둘을 잇는 통로가 부실하면 이 구분이 다 무의미해진다는 게 다음 문제였다.

16바이트 헤더와 CRC로 프레임을 지킨다

파이프는 그냥 바이트가 흐르는 통로일 뿐이다. 어디까지가 한 메시지인지는 알아서 정해야 한다. 그래서 메시지 앞에 고정 길이 헤더를 붙이는 프레이밍(framing) 방식을 썼다. 헤더는 16바이트, 그 뒤에 CRC-32(데이터가 도중에 깨지지 않았는지 확인하는 체크섬)를 붙인 페이로드가 온다.

// 개념 설명용 의사코드 — 실제 바이트 배치와는 다를 수 있음
struct FrameHeader {
  magic;      // 프레임 시작 표식
  length;     // 페이로드 길이
  seq;        // 순번(desync, 즉 순서 어긋남 감지용)
};
// header(16B) + payload + crc32(payload)

CRC가 안 맞거나 순번이 어긋나면 어떻게 할까. 조용히 다시 맞춰 보려는 시도 없이 그 순간 세션 전체를 종료해 버린다. 어설프게 복구하려다가 절반만 맞는 자막이 나오는 것보다, 확실하게 다시 시작하는 편이 문제를 훨씬 빨리 드러낸다.

큐도 전부 상한을 뒀다. 크기 제한 큐(bounded queue)라고 부르는데 상한이 없으면 인식 속도가 오디오 입력 속도를 못 따라갈 때 메모리가 끝없이 불어난다. 대신 오래된 것부터 버림(drop-oldest) 전략을 썼다. 최신 오디오가 자막에는 더 중요하니까 오래 밀린 프레임은 버리는 게 맞다.

버퍼 자체는 씨크락(seqlock) 방식의 단일 생산자·단일 소비자(SPSC) 원형 버퍼(ring buffer)로 짰다. 읽는 쪽이 락을 걸고 기다리는 대신 버전 번호만 확인한다. 쓰는 도중에 읽었으면 버전이 바뀌어 있으니 그 값은 버리고 다시 읽는데 이를 찢긴 읽기 건너뛰기(torn-read skip)라고 부른다. 방향마다 쓰는 쪽을 하나로 고정해서 경쟁 상태(race condition, 여러 스레드가 같은 데이터를 동시에 건드려 결과가 꼬이는 상황) 자체를 없앴다.

읽은 값을 언제 실제로 반영하느냐도 따로 손봤다. 세대 관문(epoch gate)이라고 부르는데 반영 시점을 하나의 세대로 묶어서 세대가 바뀌는 중간에는 어떤 상태 변화도 밖으로 새 나가지 않게 막는다. 도서관 사서가 서가를 재정리하는 중에는 책을 안 빌려주는 것과 비슷하다.

오디오 프레임이 링버퍼와 bounded queue를 거쳐 STT 사이드카로 전달되고 자막으로 돌아오는 저지연 파이프라인 흐름도
오디오 프레임이 프레이밍·링버퍼·bounded queue를 거쳐 자막으로 돌아오는 저지연 파이프라인.

종료는 반드시 한 번만

연결을 끊는 절차, 그러니까 티어다운(teardown)이 이 구조에서 제일 까다로웠다. OBS에서 필터를 끄는 시점과 사이드카가 응답을 보내는 시점이 겹치면 어느 한쪽이 이미 사라진 객체를 건드리게 된다. 그래서 종료 요청이 몇 번 겹쳐 들어와도 실제 처리는 딱 한 번만 하는 싱글플라이트(single-flight) 걸쇠를 뒀다. 취소가 먼저 이기고 그 뒤 스레드를 join(끝나길 기다려 정리)한 다음 진행 중이던 작업이 모두 멎어 안정된 상태가 되어야 링을 해제한다.

사용 후 해제된 메모리를 다시 건드리는 문제(UAF, use-after-free)를 막으려고 count-then-check 방식의 배리어도 넣었다. 생산자 쪽 작업이 몇 개 남았는지 먼저 세고 그 수가 0이 되는 걸 확인한 뒤에만 실제로 자원을 정리한다. 응답을 기다리는 쪽도 정확히 한 번만 응답받도록 만들었다. 응답이 오거나 타임아웃이 나거나 종료로 취소되거나 셋 중 하나로 반드시 끝나야지 둘 다 걸리거나 아무것도 안 걸리는 상태는 없어야 했다.

말로는 깔끔했지만 실제로 코드를 짜고 돌려 보는 과정에서 문제가 여럿 걸렸다. 사소한 착오는 검토 단계에서 먼저 걸러졌다. OBS 오디오 데이터 구조체에 채널 수를 담는 필드가 있다고 가정하고 코드를 짰는데 실제로 그런 필드는 없었다. 진짜 필드는 평면별 PCM 배열과 샘플 포맷뿐이다. 정작 뼈아팠던 쪽은 실제 가동 중에야 드러난 런타임 버그들이었다.

먼저 걸린 런타임 버그는 조건 변수(condition variable) 대기 코드였다. 알림(notify)이 대기(wait) 시작 직전에 도착하면 그 알림을 놓쳐 영영 못 깨는 lost-wakeup 데드락이 생긴다. 그래서 조건(predicate, 깨어날 조건을 검사하는 조건식)을 걸고 기다려야 하는데 이렇게 하면 스퓨리어스 웨이크업(spurious wakeup, 원인 없이 스레드가 저절로 한 번씩 깨어나는 현상)도 함께 걸러진다. 실제 코드는 predicate 없이 wait()만 호출해 둔 상태였다. predicate 람다를 추가하고 나서야 두 문제가 한꺼번에 풀렸다.

제일 뼈아픈 문제는 따로 있다. 핸드셰이크(연결 초기 협상)에 실패하는 경로에서 워커 스레드를 join하지 않고 그대로 두는 버그였다. 객체가 소멸할 때 아직 join 안 된 스레드가 남아 있으면 std::terminate가 호출돼 프로세스 전체가 죽는다. 필터 하나가 잘못됐을 뿐인데 OBS 전체가 뻗어 버리는 셈이었다. 세션 시작 실패 경로에 종료 처리 호출이 빠져 있었던 게 원인이었다. 그 경로에 종료 호출을 추가하고 루프 끝에 마지막 안전망으로 join을 한 번 더 두는 한편 같은 실패를 다시 못 만나도록 이 시나리오만 겨냥한 회귀 테스트도 새로 마련했다.

남은 문제도 하나씩 걸렸다. 좀비 프로세스(이미 끝났지만 부모가 회수하지 않은 자식)를 확인하는 함수와 실제로 회수하는 함수가 따로 있었는데 확인 함수가 먼저 회수해 버리는 바람에 회수 함수를 다시 불러도 대상이 없어 진짜 종료 코드를 잃어버리는 경우가 생긴다. 윈도우 쪽은 일부 경로에서 파이프 핸들 두 개가 닫히지 않고 새기도 했다. 파일 디스크립터를 평범한 정수형으로 들고 있던 게 취소와 읽기/쓰기 사이의 경쟁 상태를 만들었는데 이건 ThreadSanitizer(스레드 간 경쟁 상태를 잡아내는 검사 도구)를 새로 돌리고 나서야 드러났다. 기존 테스트 실행기에는 애초에 이 검사 단계 자체가 빠져 있었던 것이다.

검증 순서: Mac 먼저, Windows는 관문으로

libobs를 링크하지 않고도 만들 수 있는 부분, 그러니까 프레이밍·링버퍼·에폭 게이트·종료 걸쇠는 순서상 제일 먼저 순수 C++ 코드로 짜고 맥에서 컴파일러의 주소·정수·스레드 검사기를 전부 통과시켰다. OBS도, 윈도우도 아직 필요 없는 단계다. 이렇게 먼저 굳혀 두면 나중에 플랫폼 문제와 로직 문제가 뒤섞이지 않는다.

그다음이 진짜 관문이었다. 자식 프로세스를 취소하는 방식이 맥과 윈도우에서 완전히 다르다. 그래서 이 부분만 따로 떼어 세 가지 취소 상황을 검증했다. 읽기에서 블로킹된 상태, 파이프가 가득 차 쓰기에서 블로킹된 상태, 내부 큐 앞에서 대기 중인 상태. 셋 다 같은 방식으로 풀었다. 블로킹 중인 스레드를 별도로 두고 핸들을 닫은 뒤 프로세스를 정지시키는 방식이다. 내 환경에서 재 보니 맥에서는 한 자릿수 밀리초대, 원격 윈도우 머신에서도 같은 방식으로 십수 밀리초 안에 풀렸다. 더 복잡한 폴백 경로까지 갈 필요는 없었다.

이 관문을 통과하기 전에는 다음 단계로 못 넘어가게 막아 뒀다. 취소가 안 되는 전송 계층 위에 필터 연결부를 올리는 건 무의미하다. 문제가 생기면 어디서 막혔는지도 모른 채 상위 레이어까지 다 의심해야 한다.

지금까지 확인한 저지연 IPC 요소를 정리하면 다음과 같다.

  • 프레이밍: 고정 길이 헤더 + CRC로 메시지 경계와 무결성을 함께 확인했는가
  • desync 대응: 조용한 재동기화 대신 세션 전체 종료로 처리하는가
  • 큐: 모든 큐에 크기 상한을 뒀는가, 오버플로 시 정책(drop-oldest 등)이 정해져 있는가
  • 동시성: 방향마다 쓰는 쪽을 하나로 고정해 경쟁 상태를 구조적으로 없앴는가
  • 반영 시점: 상태 변화를 세대 단위로 게이트해 중간 상태가 새지 않는가
  • 종료: 중복 요청이 와도 실제 처리는 한 번만 되는가(single-flight)
  • 플랫폼별 취소: 세 가지 블로킹 상황을 맥과 윈도우 양쪽에서 실측했는가

이 목록에서 하나라도 비어 있으면 나머지가 아무리 정교해도 어느 시점엔 뚫린다. 실제로 겪은 버그 대부분이 이 목록 중 정확히 한 항목의 빈틈에서 나왔다.

프레이밍·링버퍼·종료 순서로 정리한 이 뼈대는 다른 동시성 프로그래밍 설계에도 그대로 재사용할 수 있다. 지금 남은 숙제는 두 가지다. 하나는 필터와 IPC 브리지를 실제 콜백 안에서 완전히 결선하는 작업이고 다른 하나는 윈도우에서 플러그인을 빌드해 OBS에 로드하고 끝까지 돌려 보는 검증이다. 전송 계층이라는 관문은 넘었지만 그 위에 쌓을 층은 아직 둘 남았다.