편집자는 원본의 오디오 6개가 필요하고, 되감기 플레이어에는 대표 오디오 1개를 가진 파일이 필요합니다. 원본을 보존한 채 재생 파일을 만들기로 하면 산출물만 늘어나는 것이 아닙니다. 원본은 올라갔는데 재생 조각은 실패하거나, 조각은 있는데 초기화 정보인 init이 없는 상태도 복구해야 합니다.
저는 바이트 생성과 저장 완료를 분리하고 원본·재생 조각·init을 독립 상태로 관리했습니다. 생성 함수가 성공했다는 이유로 세 축을 모두 완료 처리하지 않기 위해서입니다. AI와 구현·검증을 협업했으며 책임 경계와 실패 분류는 제가 정했습니다. 생산 계층은 2026년 9월 24일 PR #192로 병합됐습니다.
실제 RTMP 입력에서 HTTPS MinIO까지 연결한 대조에서는 재생 객체 24개와 init 1개가 모두 첫 시도에 저장됐습니다. 이후에는 성공 여부와 별개로 큰 입력의 거절 시점, 동시 실행의 메모리 비용을 따로 확인했습니다.
변환 함수가 파일 저장까지 알 필요는 없었습니다
Producer는 입력 핸들과 방송 내 위치를 받아 init, 미디어 조각, init 해시를 반환합니다. init은 플레이어가 트랙과 코덱 설정을 이해하는 초기화 정보입니다. 파일 쓰기나 자식 프로세스 실행, DB·S3 접근은 이 부품 밖에 뒀습니다. Producer 코드는 재포장 방식이 바뀌는 경계만 인터페이스로 유지합니다.
이렇게 나누면 “변환이 결정적으로 실패한 입력”과 “바이트는 만들어졌지만 잠깐 업로드하지 못한 상태”를 다르게 처리할 수 있습니다. 잘린 입력, 필요한 트랙 누락, 빈 트랙, 크기 초과를 무작정 재시도해도 해결되지는 않습니다. 반면 일시적인 PUT 실패 때문에 원본 변환의 의미까지 바꿀 필요는 없습니다.
| 축 | 확정하는 것 | 복구 기준 |
|---|---|---|
| 편집용 원본 | 원본 객체 업로드 | 원본 상태 |
| 되감기 조각 | 재생용 객체 업로드 | 독립 CAS·수거 커서 |
| init | 해시와 업로드 완료 | init 확정 뒤 대기 조각 해제 |
이렇게 나누면 원본이 uploaded여도 재생 조각의 실패를 다시 찾을 수 있고, init 확정 뒤에만 대기 조각을 해제할 수 있습니다. 파일의 시간 위치는 mtxi와 회차 보정값으로 정합니다. 기존 회차의 init과 달라진 입력은 같은 방송에 섞지 않고 실패를 확정한 뒤 회차 종료 방향으로 전이시킵니다. 저장 상태뿐 아니라 플레이어가 함께 읽을 수 있는 산출물인지도 경계에 포함했습니다.
실제 입력부터 저장소까지 이어 본 결과
2026년 9월 23일 Docker의 MediaMTX → sidecar → HTTPS MinIO 경로에서 RTMP 입력을 흘렸습니다. 재생 객체 24개와 init 1개가 모두 첫 시도에 업로드됐습니다. 두 번째 송출의 seq 12–23에서는 원본 12개와 재생용 12개의 완료 로그를 짝지었습니다.
| 관측 | 결과 |
|---|---|
| 재생용 PUT 중앙값 / 최대 | 12ms / 14ms |
| 원본 완료 → 재생 완료 로그 간격 중앙값 / 최대 | 22.65ms / 29.72ms |
| 두 번째 송출 원본 / 재생용 | 12개 / 12개, 모두 첫 시도 |
PUT 중앙값 12ms는 업로더가 기록한 저장 시간입니다. 원본 완료 뒤 재생 완료까지 22.65ms에는 작업 전환·재포장·업로드가 포함될 수 있습니다. 이 둘을 나눠야 어느 단계의 비용을 보고 있는지 알 수 있습니다. 측정 상세는 로컬 Docker·MinIO 경로의 검증으로, AWS S3나 시청까지의 종단 지연은 포함하지 않습니다.
hls.js 1.5.15에서는 플레이어 오류 0, 단일 버퍼, 탐색과 재생 진행을 확인했습니다. 그러나 같은 실행의 109행 로그에는 negative_drift −1,980ms 오류 1건이 남았습니다. 저장 경로가 성공해도 시간축 문제는 따로 남을 수 있었습니다. 생성·저장·재생을 하나의 성공 플래그로 합치지 않은 판단이 검증에서도 필요했습니다.
거절할 입력은 읽기 전에 거절하는가
저장 경로를 확인한 다음에는 Producer가 받아들이는 입력의 경계를 봤습니다. 전체 입력을 메모리에 읽으므로 크기 제한이 읽기 뒤에 있다면 메모리를 보호하지 못합니다. 16MiB 상한을 두고, 2026년 10월 6일 실제 녹화 파일에 유효한 free box를 넣어 미디어 내용은 유지한 채 정확한 크기 경계를 만들었습니다.
| 입력 크기 | 결과 | 실제 읽기·산출 |
|---|---|---|
| 16,777,216B, 16MiB | 성공 | 16MiB 읽음, 산출물 생성 |
| 16,777,217B, 16MiB+1B | ErrInputTooLarge | Read 0회, 산출물 0 |
조건당 1회인 경계 검사입니다. 크기가 알려진 초과 입력을 읽기 전에 막는다는 근거이지, 모든 악성 MP4나 무한 스트림을 검증한 결과는 아닙니다. 단위·race·PostgreSQL 통합·골든·결함 주입 검증과도 역할이 다릅니다.
빠른 부품도 동시에 많이 돌리면 비싸집니다
별도의 동시성 시험은 패딩한 경계 파일 대신 실제 녹화 입력 3종을 사용했습니다. Apple M5, Go 1.26.5, GOMAXPROCS=4에서 동시성 1·2·4·8, 독립 프로세스 각 5회, 프로세스당 800회로 48,000회를 처리했고 출력 SHA256이 모두 일치했습니다.
6.78MB 입력에서 최대 RSS 중앙값은 동시성별 85·107·172·303MiB, 호출 p95 중앙값은 5.83·10.57·11.15·45.21ms였습니다. 4→8 작업의 처리량은 약 293→294회/초로 비슷했습니다. 디스크와 S3를 제외한 부품 시험에서도 작업 수를 무한히 늘릴 근거는 없었습니다. 동시성과 입력 상한 결과를 따로 둔 이유입니다.
단일 변환이 빨라도 4→8에서 처리량이 거의 늘지 않고 RSS가 커졌습니다. 그래서 생성 성공률과 함께 입력 상한·동시 작업 수를 관리할 근거가 필요했습니다. 여기에 재생 객체와 회차별 init의 PUT·저장량도 추가됩니다.
생산 계층이 맡는 끝은 산출물 생성과 각 상태의 저장·복구입니다. 보정값의 재기동 복구와 일부 init 잔량 처리는 남아 있고, 목록 렌더·발행은 다음 계층입니다. 이 경계를 유지해야 “파일이 만들어졌다”는 결과로 init 미확정이나 저장 실패를 놓치지 않을 수 있습니다.
이 글은 PokeClip의 개발·검증 기록을 2026년 10월 15일 기준으로 정리한 CASE 21입니다. 전체 기술 기록에서 연결되는 결정을 함께 읽을 수 있습니다.