엔지니어링 노트음성과 회의

회의가 겹치면 받아쓰기가 멈춘다, 큐는 쓰는 자원마다 나눈다

구축형에서는 여러 회의가 GPU 한 대에서 도는 음성 인식 모델을 나눠 씁니다. 10시 정각에 회의 여덟 개가 함께 시작하면 5분이 지날 때마다 거의 같은 시각에 구간 요약 여덟 건이 생깁니다. 요약과 받아쓰기가 같은 큐에 있으면 요약 작업이 LLM 응답을 기다리며 워커의 실행 자리를 모두 잡고, 받아쓰기는 그 뒤에 줄을 섭니다. 화면에서는 모든 회의의 말풍선이 1분 넘게 멈췄다가 한꺼번에 쏟아집니다.

그래서 회의 작업은 기능이 아니라 쓰는 자원(GPU, LLM 응답 대기, CPU와 디스크)으로 나눠 큐마다 따로 돌립니다. 동시에 몇 회의까지 받을 수 있는지는 설치한 GPU에서 처리량을 재어 정합니다. 이 글은 큐를 나눈 기준과 큐마다 동시성을 정한 이유, 동시 처리 상한을 재는 방법을 다룹니다.

회의 하나는 1분마다 GPU에 창 하나를 보낸다

브라우저는 녹음을 5초마다 청크로 잘라 올리고, 웹 프로세스는 받은 청크를 객체 저장소에 씁니다. 청크가 쌓여 60초 슬롯 하나를 채우면 웹 프로세스가 그 슬롯의 받아쓰기 작업을 큐에 넣습니다. 워커는 앞 30초를 겹친 90초 창(window, 한 번에 받아쓰는 오디오 구간)을 잘라 모델에 보냅니다. 겹친 구간이 있어야 창 끝에서 잘린 발화를 다음 창이 온전히 받아 적습니다.

모델을 부르기 전과 뒤에 판정이 하나씩 있습니다. 앞에서는 창에 받아쓸 소리가 있는지 봅니다. 자체 호스팅 회의 전사 모델은 소리 없는 오디오를 받으면 없는 발화를 지어내기 때문에, 소리가 없는 창은 모델을 부르지 않고 끝냅니다. 뒤에서는 결과가 같은 말을 되풀이하는 반복 루프에 빠졌는지 보고, 빠졌으면 그 창만 한 번 더 부릅니다. 이때는 GPU 요청이 하나 더 생깁니다.

창 하나를 받아쓰는 데는 RTX 4090 한 장에서 중앙값 1.3초, p95 2.4초가 걸렸습니다(2026년 9월 측정). 평소 회의 하나가 GPU를 쓰는 시간은 1분에 1~2초 남짓입니다. 문제는 요청이 고르게 오지 않는다는 점입니다. 네트워크가 끊겼다 돌아온 클라이언트는 밀린 청크를 몰아 올리고, 그 순간 슬롯 여러 개가 한꺼번에 열려 받아쓰기 작업도 여러 개가 함께 들어옵니다. 구간 요약은 같은 청크를 받을 때 5분마다 판정해 따로 LLM 작업으로 겁니다.

큐 하나에 세마포어를 걸면 기다리는 작업이 실행 자리를 쥔다

가장 단순한 구성은 회의 작업을 큐 하나에 넣고 작업 종류마다 세마포어(동시에 들어갈 수 있는 수를 세는 카운터)로 동시 실행 수를 묶는 것입니다. 설정이 한 곳에 모이고 워커도 하나면 됩니다.

그런데 작업 큐 라이브러리(Procrastinate)의 워커는 작업을 꺼낸 순간 실행 자리 하나를 씁니다. 세마포어는 작업 안에서 잡으므로, 세마포어를 기다리는 작업도 자리를 쥔 채 기다립니다. 정시에 시작한 회의 여덟 개의 구간 요약이 실행 자리 여덟 개를 차지하면, 요약 한 건이 길게는 1분 넘게 LLM 응답을 기다리는 동안 받아쓰기 작업은 꺼내지지도 못합니다. 종류마다 세마포어를 걸어도 자리를 나눠 갖지는 못합니다.

기능별로 큐를 나눠도 같은 문제가 남습니다. 회의록을 새로 쓰는 다시 만들기는 회의를 끝낸 뒤에 쓰는 기능이라 종료 처리 쪽에 묶고 싶어집니다. 하지만 이 작업은 사람이 버튼을 누르고 화면을 보며 기다리는 LLM 작업입니다. 동시성을 낮게 잡은 종료 처리 큐에 넣으면, 정시 종료가 몰린 시각에 종료 작업 뒤에 서서 버튼이 십수 분 동안 반응하지 않습니다.

다섯 큐는 쓰는 자원이 다르다

큐가 다르면 꺼내 가는 워커 인스턴스가 다르고, 기다리는 작업은 다른 큐의 실행 자리를 건드리지 않고 자기 큐에 남습니다. 회의 작업은 아래 다섯 큐로 나눴고, 기본 동시성은 worker.taskParallel 의 키로 설치마다 바꿀 수 있습니다.

회의 작업 큐 다섯 개: 쓰는 자원과 기본 동시성
그림 1. 회의 작업 큐 다섯 개: 쓰는 자원과 기본 동시성
큐 쓰는 자원 기본 동시성 이 값을 고른 이유
라이브 회의의 창 받아쓰기(meeting) GPU 8 동시 회의 수 가정 이상으로 둔다. 이 값은 GPU 앞에 줄 세우는 요청 수다
녹음 파일 업로드의 받아쓰기(meetingUpload) GPU 1 업로드는 창 수백 개를 한꺼번에 넣는다. 1이면 GPU에 한 번에 하나만 보낸다
업로드 원본 디코딩·구간 나누기(meetingPrepare) CPU·디스크 1 동시 디코딩 수만큼 PCM 사본이 생긴다. 4시간 파일이면 한 벌에 460MB다
구간 요약·회의록 생성(meetingLlm) LLM 응답 대기 16 대부분 네트워크를 기다리는 시간이라 넉넉해도 호스트를 압박하지 않는다
회의 종료 처리(meetingFinalize) GPU(마지막 구간) 3 정시 종료가 몰려도 GPU를 통째로 차지하지 않게 낮게 둔다

업로드 받아쓰기는 라이브 회의와 같은 GPU를 쓰지만 큐를 따로 둡니다. 4시간짜리 녹음 파일을 올리면 60초 슬롯 240개가 한 번에 받아쓰기를 기다립니다. 같은 큐였다면 진행 중인 회의의 창이 그 240개 뒤에 섭니다. 업로드 큐의 동시성을 1로 두면 업로드는 GPU에 요청을 하나씩만 보내고, 라이브 창은 그 사이에 바로 들어갑니다. 받아쓰기 처리량은 GPU가 정하므로, 이 값을 올려도 업로드는 크게 빨라지지 않고 진행 중인 회의만 늦어집니다.

디코딩을 GPU 큐와 떼어 둔 것은 바쁜 자원이 다르기 때문입니다. 같은 큐에 두면 새로 올린 파일의 디코딩이 앞선 업로드의 받아쓰기 작업 뒤에 서서, GPU만 돌고 CPU는 노는 동안 화면이 「오디오 분석 중」에 멈춰 있습니다. 동시성을 1로 둔 이유는 처리량이 아니라 디스크입니다. 디코딩은 이미 녹음 길이의 1,700배 속도라 동시에 여러 건을 풀어도 얻을 것이 적고, 대신 PCM 사본이 그만큼 늘어납니다.

종료 처리는 마지막 구간을 받아쓰는 GPU 작업을 안에 품고 있어 라이브 받아쓰기와 GPU를 다툽니다. 음성을 지우기 전에 마지막 구간을 받아써야 하므로 피할 수 없는 경합이고, 그래서 동시성을 낮춰 기다림이 실행 자리가 아니라 큐에 남게 했습니다. 종료 작업의 순서는 「회의를 끝내면 마지막 발언이 빠진다, 음성은 받아쓰기를 마친 뒤 지운다」에서 다룹니다.

상한은 설치 전체에 걸린다

세마포어 값은 큐의 동시성과 같아야 합니다. 세마포어가 더 낮으면 실행 자리를 쥔 채 세마포어를 기다리는 작업이 다시 생기고, 큐를 나눈 의미가 사라집니다.

워커를 여러 벌 띄우면 프로세스 안의 세마포어는 벌마다 따로 셉니다. 그래서 하드웨어를 뜻하는 값, 곧 GPU 앞의 요청 수(meeting·meetingUpload·meetingFinalize)와 디코딩의 디스크 예산(meetingPrepare)은 설치 전체의 상한으로 겁니다. 워커가 몇 벌이든 그 큐들의 합계는 설정값을 넘지 않습니다. 자리가 없으면 작업은 자기 큐의 실행 자리에서 기다립니다. 그 자리는 어차피 상한이 차서 쓸 수 없는 자리라 다른 큐는 영향을 받지 않습니다. LLM 작업처럼 처리량을 조절하는 값은 워커 한 벌마다의 상한입니다.

사람 단위의 상한도 둡니다. 진행 중인 회의는 한 사람당 하나이고, DB의 유일 인덱스가 이를 강제합니다. 녹음 파일 업로드는 이 제약 밖이라 한 사람이 동시에 처리 중인 업로드를 3건으로 묶습니다. 이 상한이 없으면 한 사람이 동시성 1인 업로드 큐와 디스크를 혼자 차지합니다. 회의 한 건의 녹음 길이도 4시간으로 묶어, 그 길이를 넘는 오디오는 받지 않습니다.

동시 처리 상한은 처리량 곡선에서 읽는다

라이브 받아쓰기 큐의 동시성 8은 동시 회의 수의 상한이 아닙니다. GPU 앞에 줄 세우는 요청 수일 뿐이고, 실제 상한은 GPU와 모델이 정합니다. 창 하나에 1.3초라는 수치로 나눗셈을 하면 1분에 수십 개 창을 받아쓸 수 있을 것 같지만, 요청이 겹칠 때의 처리량은 혼자 돌 때의 시간에서 계산되지 않습니다. 그래서 동시 회의 수를 한 단계씩 올리면서 처리량(1초에 녹음 몇 초를 받아쓰는가)과 GPU 지표를 함께 기록합니다.

동시 처리 상한 판정: 처리량 곡선과 GPU가 노는지로 가르기
그림 2. 동시 처리 상한 판정: 처리량 곡선과 GPU가 노는지로 가르기

판정은 처리량에서 시작합니다. 동시 회의 수에 따라 처리량이 계속 오르면 아직 여유가 있습니다. 처리량이 평평해졌는데 GPU가 놀고 있으면 큐 동시성이나 세마포어 같은 코드의 상한에 걸린 것입니다. 처리량이 평평하고 GPU도 쉬지 않으면 그 GPU의 한계입니다.

GPU 사용률은 이 판정에서 한쪽 방향으로만 씁니다. nvidia-smi와 DCGM이 보여 주는 GPU 사용률은 커널이 하나라도 돌고 있던 시간의 비율입니다. 사용률이 낮으면 GPU가 놀고 있다는 증거가 되지만, 높다고 포화했다는 뜻은 아닙니다. 채팅 모델을 서빙하는 GPU의 7일 실운영 관측(2026년 9월)에서 GPU 사용률은 동시 요청 1건일 때 이미 중앙값 98%였고, 동시 요청을 1건에서 4건으로 늘리자 처리량은 3.74배가 됐습니다. 사용률만 봤다면 요청 1건에서 GPU가 다 찼다고 읽었을 것입니다.

GPU 메모리(VRAM)가 남는 것과 연산이 남는 것도 따로 봅니다. 이전 세대 음성 인식 모델로 잰 측정(2026년 8월)에서는 동시 요청 2건부터 처리량이 녹음 길이의 29.7배 속도에서 더 늘지 않았는데, 그때도 VRAM은 14GB가 남아 있었습니다. 메모리 여유만 보고 동시성을 올렸다면 처리량은 그대로이고 말풍선만 늦어졌을 것입니다.

그래서 동시 처리 상한은 설치할 GPU와 지금 쓰는 모델의 조합에서 이 방법으로 재고, 라이브 받아쓰기 큐의 동시성을 그 값에 맞춥니다. 모델이나 GPU를 바꾸면 다시 잽니다.

설계에서 고른 것

기능 대신 쓰는 자원으로 큐를 나눴습니다. 기능별 큐는 처음 읽기에는 자연스럽지만, 다시 만들기처럼 사람이 기다리는 작업과 종료 처리처럼 GPU를 오래 쥐는 작업이 같은 큐에 묶입니다. 대가는 큐와 워커 인스턴스, 설정 키가 다섯 벌이 된다는 점입니다. 회의에 GPU를 쓰는 작업이 새로 생기면 그 작업이 어느 자원을 기다리는지부터 정해 큐를 고릅니다.

세마포어 하나로 묶는 대신 큐를 나눴습니다. 세마포어는 동시 실행 수는 묶지만 기다리는 작업이 실행 자리를 쥐는 것은 막지 못합니다. 같은 이유로 각 큐의 세마포어 값은 그 큐의 동시성과 같게 둡니다.

업로드 받아쓰기의 동시성은 1로 고정했습니다. 업로드 혼자 GPU를 쓰는 시간에는 더 빨리 끝낼 여지를 내주는 대신, 진행 중인 회의의 말풍선이 업로드 뒤에 밀리지 않게 했습니다.

동시 처리 상한은 기본값으로 정해 두는 대신 설치마다 잽니다. 대가는 설치할 때와 모델을 바꿀 때마다 측정을 한 번 거쳐야 한다는 점입니다. GPU 한 대를 여러 회의가 나눠 쓰는 환경이라면 큐 설정값보다 동시 회의 수에 따른 처리량 곡선을 먼저 보고, GPU 사용률은 GPU가 놀고 있는지 확인하는 데만 쓰는 편이 상한을 바로 찾습니다.