영상 작업 두 개도 같은 부하를 만들지는 않는다
강의 영상 한 편을 만들려면 페이지마다 음성을 준비하고, 슬라이드 이미지와 음성을 영상 조각으로 인코딩한 뒤 조각을 연결해야 한다. 외부 음성 API의 응답을 기다리는 시간과 서버에서 영상을 인코딩하는 시간에는 서로 다른 자원이 필요하다. 전체 영상 작업 수만 제한하면 각 작업 안에서 얼마나 많은 호출과 변환을 시작하는지는 드러나지 않는다.
AI Commons에서는 순차 반복문으로 처리하던 음성 준비와 세그먼트 생성을 각각 async.mapLimit으로 바꿨다. 전체 영상 작업의 동시성 기본값은 10에서 2로 낮추고, 각 작업의 TTS와 세그먼트 단계에는 별도 설정을 추가했다.
| 제한 | 값 | 적용 범위 |
|---|---|---|
| 영상 작업 | 2 | 워커가 동시에 처리할 영상 작업 수 |
| TTS 단계 | 10 | 한 영상 작업에서 동시에 음성을 준비할 페이지 수 |
| 세그먼트 단계 | 2 | 한 영상 작업에서 동시에 인코딩할 페이지 수 |
두 영상 작업이 모두 TTS 단계라면 설정상 최대 20개 페이지의 준비가 겹칠 수 있고, 모두 세그먼트 단계라면 최대 4개의 페이지 인코딩이 겹칠 수 있다. 이 수는 설정의 조합을 설명한 것이며 실제 동시 호출 수나 처리량을 측정한 결과가 아니다. 워커 인스턴스가 늘면 각 인스턴스의 제한도 따로 적용되므로 공급자 전체 요청량의 상한으로 사용할 수는 없다.
TTS 단계도 순수한 네트워크 대기는 아니다. 외부에서 받은 MP3를 파일로 저장하고 ffmpeg으로 WAV로 변환한 뒤 길이를 확인한다. TTS 동시성을 높이면 API 요청뿐 아니라 이 변환도 함께 늘어난다. 단계 이름만 보고 CPU 비용이 없다고 판단할 수 없는 이유다.
먼저 끝난 페이지가 먼저 재생돼서는 안 된다
변경은 페이지마다 음성과 영상을 연속으로 만들어 다음 페이지로 넘어가는 구조가 아니다. 한 영상 작업에서 모든 대상 페이지의 TTS 단계를 기다린 뒤 세그먼트 단계를 시작한다. 단계 안에서는 병렬로 실행하지만 단계 사이에는 완료를 기다리는 경계가 있다.
- 음성 준비 · 페이지별 병렬 실행
음성 API 호출, WAV 변환과 길이 검사를 마친다.
- 단계 전환 · 모든 대상 음성 대기
TTS 단계가 완료돼야 세그먼트 단계에 들어간다.
- 영상 조각 · 별도 제한으로 병렬 실행
페이지 index와 파일 경로를 함께 모은다.
- 순서 복원 · 연결
index로 정렬한 경로를 concat 입력으로 사용한다.
이 경계는 세그먼트 단계가 확인된 음성과 길이를 쓰게 하지만, 먼저 준비된 한 페이지도 가장 늦은 음성을 기다리게 한다. 페이지별로 준비와 인코딩을 겹치는 흐름이라면 이 대기를 줄일 여지가 있는 대신, 두 단계의 부하와 실패 처리를 함께 조정해야 한다. 서로 다른 영상 작업 사이에서는 한 작업의 음성 준비와 다른 작업의 인코딩이 겹칠 수 있다.
병렬 인코딩의 결과는 완료되는 순서로 segmentResults에 들어간다. 그 순서를 그대로 연결하면 짧거나 먼저 처리된 페이지가 앞에 올 수 있다. 코드에서는 각 결과에 원래 index를 남기고, 최종 연결 직전에 순서를 복원한다.
segmentResults.sort((a, b) => a.index - b.index);
segmentPaths.push(...segmentResults.map((r) => r.path)); 병렬 실행을 허용하는 것과 출력 순서를 바꾸는 것은 별개의 선택이다. 실행 시간은 달라도 강의의 페이지 순서는 입력의 index를 따른다.
실패한 음성은 인코딩 입력으로 넘기지 않는다
같은 변경은 TTS 완료 뒤 WAV 파일의 크기와 ffprobe가 반환한 길이를 확인했다. 길이를 페이지 index별 Map에 저장해 세그먼트 단계에서 다시 읽는다. 다음 단계가 파일을 받았다는 사실만으로 길이까지 유효하다고 가정하지 않도록 했다.
파일이 없거나 크기가 0이면 오류로 처리한다. ffprobe 실행이 실패하거나 길이가 숫자가 아니거나 0 이하인 경우에는 최대 세 번 검사하고, 재시도 사이에 짧은 대기를 둔다. 이 검사는 파일과 길이의 유효성을 확인하며 발음이나 강의 내용의 품질을 평가하지 않는다.
단계 Promise가 실패하면 다음 단계로 정상 진행하지 않지만, 이미 시작한 외부 요청과 ffmpeg 프로세스를 모두 취소한다는 보장은 이 변경에 없다. 병렬 실행 수를 제한하는 mapLimit과 진행 중인 작업의 취소는 다른 책임이다. 실패 직후에도 진행 중인 작업이 자원을 사용할 수 있다.
실측 길이를 인코딩의 -t에 넣는 것도 모든 싱크 문제를 해결하지는 않는다. 이후 AAC 프레임과 음량 정규화의 시간축을 다룬 오디오 타임스탬프 보정은 별도 변경이다. 길이 입력, 출력 순서와 실제 재생 시간축을 각각 확인해야 한다.
프로세스 수를 줄여도 한 프로세스가 쓰는 코어는 남는다
후속 변경은 세그먼트 인코딩을 taskset으로 실행했다. 환경변수로 지정한 CPU 수에 따라 모든 해당 인코딩 프로세스가 같은 코어 집합을 사용하도록 했다. 두 코어 설정이라면 명령의 형태는 다음과 같다.
taskset -c 0-1 ffmpeg ... 페이지 동시성은 한 번에 시작할 인코딩 수를 제한한다. CPU affinity는 시작한 프로세스가 실행될 코어 집합을 제한한다. 여러 인코딩은 이 집합을 나눠 쓰며, 프로세스마다 전용 코어 두 개를 받는 것은 아니다. 나머지 코어를 사용할 여지는 남기지만 API 응답 시간까지 보장하는 자원 예약은 아니다.
변경 지점은 세그먼트 인코딩이다. TTS의 WAV 변환과 최종 concat까지 모든 ffmpeg 실행이 이때 같은 방식으로 제한됐다고 설명할 수는 없다. 단계별 병렬 처리와 후속 CPU pinning은 서로 다른 변경이다.
초기 병렬 처리 변경에서 확인한 것은 단계별 제한, 완료 대기, 길이 전달과 결과 정렬의 코드다. 이 변경에 동시성 회귀 테스트나 API 지연·영상 처리 시간의 전후 비교는 포함돼 있지 않다. 효과를 판단하려면 같은 입력과 워커 구성에서 단계별 대기, CPU 사용과 API 지연을 함께 비교해야 한다. AI Commons의 이 선택은 병렬 처리 수를 하나로 늘리는 대신 각 단계의 실행 범위를 따로 조정할 수 있게 한 구조였다.