[H264] 영상 업로드 실패 사태 분석 - 1. 홀수 해상도 문제와 트러블슈팅
회사에서 운영하고 있는 MLOps 서비스에서 특정 영상만 업로드하면 영상 업로드가 실패해 버리는 문제가 발생했다. 하필이면 전시회 시연 도중에 해당 문제가 발생해서 더 진땀을 흘렸는데, 해당 문제가 왜 발생했고, 어떻게 해결했는지 과정을 기록한다.
TL;DR
- 문제: H.264는 홀수 해상도를 지원하지 않음
- 원인: YUV 4:2:0이 색상 정보를 절반으로 압축
- 해결: 해상도를 짝수로 보정
배경
회사에서 운영하고 있는 MLOps 서비스는 사용자가 영상을 업로드하면, 업로드한 영상의 해상도를 열화하여 프레임을 추출한뒤 해당 영상의 프레임을 자동 라벨링에 사용한다.
해상도를 열화하는 까닭은, 자동 라벨링을 위해 사용하는 Co-DETR 모델의 추론 속도를 개선하기 위해서다. 프레임 크기가 크면, 그만큼 계산량이 늘어나기 때문에, 추론 속도가 오래 걸린다. 다만, 프레임 품질이 열화된다고 해서 추론을 못하는 것은 아니다.
입력 영상은 대부분 카메라에서 오기 때문에, 1920x1080 이상의 해상도를 가지고 있고, H.264 코덱을 이용해 압축되어 있다. H.264 코덱으로 압축된 영상을 반출(export)해 mp4 컨테이너에 담아, 해당 파일을 업로드하면, 백엔드에서 받은 영상을 필요 시 열화하고, 프레임을 추출하는 구조다.
그런데 이 과정에서 문제가 발생한 것이다.
백엔드에서는 영상을 열화한 뒤, 프레임을 추출하기 위해 ffmpeg을 이용한다. 더 정확히는, go에서 ffmpeg을 이용할 수 있도록 구현된 오픈소스 라이브러리 ffmpeg-go를 이용한다. 해당 라이브러리를 래핑해 백엔드 내부 라이브러리를 구현했는데, 이 라이브러리 내부에서 영상 리사이징을 처리하기 위한 구현 함수는 다음과 같다.
func Resize(inputVideoPath, outputVideoPath string, width, height int) error {
if inputVideoPath == "" {
return fmt.Errorf("input video path is empty")
}
if outputVideoPath == "" {
return fmt.Errorf("output video path is empty")
}
return resize(inputVideoPath, outputVideoPath, width, height)
}
func resize(inputVideoPath, outputVideoPath string, width, height int) error {
err := ffmpeg_go.
Input(inputVideoPath).
Filter("scale", ffmpeg_go.Args{
fmt.Sprintf("%d:%d", width, height),
}).
// Set sample aspect ratio to 1:1 to avoid
// ffmpeg changing the aspect ratio
Filter("setsar", ffmpeg_go.Args{"1"}).
Output(outputVideoPath, ffmpeg_go.KwArgs{
"c:a": "copy",
}).
OverWriteOutput().
Run()
if err != nil {
return fmt.Errorf("failed to process video: %v", err)
}
return nil
}
width, height 정보는 해당 함수를 호출하는 부분에서 주입해 준다. 이 때, 리사이징에 필요한 width, height 로직은 아래와 같이 계산된다.
- 기준 해상도 가로 크기를 1280, 세로 크기를 720으로 설정
- 가로와 세로 모두 해당 해상도보다 낮은 영상이 들어 오면 리사이징을 하지 않음
- 가로와 세로 중 하나라도 기준 크기보다 크다면, 더 큰 쪽에 맞춰 비율을 유지하며 리사이징
문제가 발생했을 상황에도 이 로직을 거쳤다. 결론적으로, 영상 열화를 위해 사용되었던 커맨드는 아래와 같다.
2025/11/05 10:36:38 compiled command: ffmpeg -i /data/3480/original_video.mp4 -filter_complex [0]scale=1235:720[s0];[s0]setsar=1[s1] -map [s1] -c:a copy /data/3480/video.mp4 -y
분석
해당 커맨드를 살펴 보자.
ffmpeg -i /data/3480/original_video.mp4 \
-filter_complex [0]scale=1235:720[s0];[s0]setsar=1[s1] \
-map [s1] \
-c:a copy \
/data/3480/video.mp4 -y
-i /data/3480/original_video.mp4: 입력 파일 지정-filter_complex [0]scale=1235:720[s0];[s0]setsar=1[s1][0]: 첫 번째 입력 스트림의scale=1235:720: 해상도를 1235x720으로 변경하고, → 여기서 1235가 홀수라 문제가 발생한다[s0]: scale 결과를 s0이라는 이름으로 저장하고,setsar=1: SAR(Sample Aspect Ratio, 픽셀 하나의 가로세로 비율)을 1:1로 설정하고,- 가로세로 픽셀 비율을 1로 설정하지 않으면, ffmpeg이 자동으로 DAR(Display Aspect Ratio) 값이 유지되도록 SAR 값을 변경해 버림
- 웹 브라우저, 앱 플레이어 등 대부분의 현대 플레이어는 정사각형 픽셀을 가정하기 때문에, 해당 옵션을 주지 않고 ffmpeg의 디폴트 옵션을 믿고 영상을 변환할 경우, 우리 서비스 프런트엔드(웹 브라우저)에서 확인할 때 찌그러져 보임
- 따라서, 픽셀 비율이 1을 유지할 수 있도록 해당 옵션을 줘야 함
[s1]: 최종 결과를 s1이라는 이름으로 저장해라
-map [s1]: 필터 결과를 출력에 매핑하고-c:a copy: 오디오 코덱은 원본 그대로 복사해라 → 오디오는 우리 관심사가 아니기에, 그냥 재인코딩 없이 원본 그대로 사용해라/data/3480/video.mp4 -y: 출력 파일 경로- 파일 이미 있으면 덮어 쓰기
위의 커맨드를 통해 백엔드는 업로드 영상을 디코딩하여 처리(해상도 변경)한 뒤, 재인코딩하여 저장한다. 트랜스코딩을 하는 과정이다.
참고: 엄밀한 의미에서의 트랜스코딩
- 광의의 트랜스코딩은 디코딩 → 처리/변환 → 재인코딩과정을 의미한다. 재인코딩이 발생하는 상황인데, 코덱 변환, 해상도 변환, 비트레이트 변환, 필터 적용 등의 작업을 통칭한다.
- 다만, 더 엄밀한 의미에서 트랜스코딩은 코덱 변경을 의미한다. 광의의 개념에서 함께 지칭하는 해상도 변환은 트랜스사이징, 비트레이팅 변경의 경우 트랜스레이팅, 컨테이너만 변경하는 경우 트랜스먹싱이라고 해야 한다.
- 그러나 실무에서는 대부분 다 트랜스코딩이라고 통칭한다. 각 단어가 사용되는 의미에 따라서 문맥에 의해 파악해야 하는 것이다. 예컨대,
영상 트랜스코딩 해 주세요하면, 코덱 변경인지, 해상도 변경인지 파악해 봐야 하는 것이다. 다만,트랜스먹싱 좀 해 주세요하면, 재인코딩 없이 컨테이너만 변경하라는 의미다.
원인
그렇다면 해당 커맨드에서 문제가 되는 부분은 해상도 변경 부분이었다. H.264 코덱은 영상을 압축할 때, YUV 4:2:0 색공간을 사용해야 하는데, 해당 색공간은 홀수 해상도를 지원하지 않는다.
- Y: 밝기 정보 → 원본 해상도 그대로 사용
- U, V: 색상 정보 → 색상을 원본에서 가로 세로 각각 절반 크기로 압축
- U, V 압축 시, 가로 세로 크기가 짝수가 아닌 경우, 절반 크기로 만들었을 때 정수가 되지 않아 문제 발생
예컨대, 영상 해상도에 따라 아래와 같이 작동한다.
- 1280x720: 인코딩 시 Y는 1280x720, U/V는 640x360 → 픽셀 정보가 꼬이지 않고 압축 가능
- 1235x720: 인코딩 시 Y는 1235x720, U/V는 617.5x360 → 픽셀 정보가 꼬여서 압축 불가능
결과적으로 해상도를 변경하기 위해 들어온 width 값이 홀수(1235)였던 게 문제였다. ffmpeg에서 H.264 코덱으로 다시 인코딩하는 과정에서, libx264(H.264 구현체)가 에러를 뱉어 버린 것이다.
해결
간단하다. 리사이징 시 해상도가 짝수가 되게 맞춰 주면 된다.
- 애초에 ffmpeg 라이브러리에서 scale 시
-2옵션을 주면, 자동으로 width, height가 짝수가 되도록 보정해 준다고 하니, ffmpeg 사용 시 커맨드에 해당 옵션이 붙을 수 있도록 구현을 변경한다.# 자동으로 짝수로 맞추기 (가로만 지정, 세로는 비율에 맞춰 자동 계산 + 짝수 보정) -filter_complex [0]scale=1234:-2[s0];[s0]setsar=1[s1] - 애초에 영상 열화 시 지정할 width, height를 보정한다.
if newWidth%2 != 0 { newWidth++ } if newHeight%2 != 0 { newHeight++ }코드 수정하려고
newWidth,newHeight계산하는 부분에 코드 갖다 댔더니, Copilot이 알아서 저 코드를 추천해 주더라. 나만 몰랐던 이야기였나보다.
결론
원본 해상도가 어떤 영상이었기에 리사이징 계산 결과 값이 홀수가 들어갔는지는 의문이나, 왜 그 동안에는 한 번도 발생하지 않다가 갑자기 전시회장에서 이 문제가 발생했는지가 더 의문이었다.
매우 당황했지만.. 결과적으로는 그 동안에 한 번도 발생하지 않다가라고 생각했던 게 패착이다. 다양한 영상으로 테스트해보고, 영상 테스트가 여의치 않다면 ffmpeg 래퍼 라이브러리를 작성할 때, 해상도 입력 조건 값을 여러 가지로 해서 리사이징 결과를 검증했어야 하는데 그렇지 못했다.
그리고, 영상을 다루는 회사에 있으면서 H.264 코덱의 기본적인 내용에 대해서도 숙지하지 못했다는 것도 패착이다. 항상 공부해야 한다고 생각은 했으나, 내부까지 다 들여다 볼 시간이 없었다. H.264 코덱이 어떻게 작동하는지 세부 사항에 대해서 알고만 있었더라도, 이렇게 에러가 발생하는 코드를 짜지 않았을 것이다.
다시 한 번, 반성하자. 엉엉.. 반성 그만하게 해주세요 나 자신아..
댓글남기기