
다른 영상은 다 잘 변환되는데, 유독 하나만 계속 죽었다. 이럴 때가 제일 사람 성질 긁는다. 전체가 안 되면 장애처럼 보이니까 바로 원인을 좁히는데, 이런 건 평소엔 잘 되다가 특정 입력에서만 조용히 실패해서 처음엔 방향을 잘못 잡기 쉽다.
이번에도 그랬다. 사용자 화면에는 그냥 변환 실패 정도만 보였고, 다른 파일들은 멀쩡했으니 나도 처음엔 워커가 잠깐 이상한가 보다 싶었다. 근데 같은 파일을 다시 올려도 안 되고, 옵션을 바꿔도 안 되고, 다른 파일은 또 잘 된다. 그때부터 아 이건 전체 문제는 아니고 특정 영상 조건이 뭔가를 밟고 있구나 싶었다.
멀쩡하던 게 하나에서만 멈췄다
처음 관찰한 현상은 단순했다.
영상 A는 된다
영상 B도 된다
영상 C도 된다
영상 D만 안 된다
이게 더 애매했다.
포맷 업로드 자체가 깨진 것도 아니고, 변환 기능 전체가 죽은 것도 아니었다. 특정 영상만 실패하니까 괜히 파일 손상, 코덱 조합, 옵션 매핑 같은 데로 생각이 먼저 튄다.
실제로 내가 먼저 의심한 것도 그런 쪽이었다. 품질 프리셋이 문제인가, FPS가 문제인가, MP4 쪽 경로만 따로 깨졌나, 앱에서 워커로 넘기는 옵션이 꼬였나. 지금 보면 다 빗나간 의심이었는데, 당시엔 그게 제일 그럴싸해 보였다.
이럴 때 사람 머리가 웃긴 게, 실제 에러 문구를 보기 전까지는 자기가 익숙한 문제 유형으로 먼저 해석해버린다. 나도 그랬다. 결국 로그 안 보고 옵션부터 만진 거였다.
처음엔 옵션 문제라고 생각했다
가장 먼저 건드린 건 출력 옵션이었다.
영상 변환기에는 해상도 상한, 압축 강도, FPS 같은 설정이 있으니, 특정 조합에서만 인코더가 죽는 거라고 본 거다.
예를 들면 이런 식으로 봤다.
근데 하나씩 바꿔도 증상은 그대로였다.
이쯤 되면 옵션 값 자체보다, 그 옵션이 어떤 입력 영상과 만나서 만든 최종 출력값을 봐야 한다.
문제는 사용자 화면만 보고 있으면 그 최종 출력값이 안 보인다는 거다.
그래서 결국 개발환경에서 워커 로그를 직접 열어봤다. 보통은 이런 쪽이 정답인 걸 알면서도, 귀찮아서 자꾸 바깥부터 건드리게 된다. 이번에도 반쯤 그랬다.
로그를 보고 나서야 숫자 하나가 보였다
로그에는 이렇게 찍혀 있었다.
[libx264 @ ...] height not divisible by 2 (1280x549)
Error while opening encoder - maybe incorrect parameters such as bit_rate, rate, width or height
[vf @ ...] Error sending frames to consumers여기서 거의 끝났다.
원인은 해상도였다. 더 정확히 말하면 홀수 높이였다.
문제 영상은 긴 변 기준으로 리사이즈되면서 최종 크기가 1280x549로 계산되고 있었고, libx264는 이런 홀수 해상도를 그대로 받아주지 않았다. 다른 영상은 왜 됐냐면, 리사이즈 결과가 우연히 짝수 해상도로 떨어졌기 때문이다. 그러니까 특정 영상만 실패한 게 아니라, 특정 리사이즈 결과가 실패 조건을 밟은 거였다.
이게 허무한 이유는 숫자만 보면 이상해 보이지 않아서다.
1280x549가 인간 눈에는 그냥 정상 해상도처럼 보인다. 근데 인코더 입장에선 저 1픽셀이 꽤 중요하다. 실제로는 비트레이트 문제도, 업로드 포맷 문제도, FPS 문제도 아니었다. 1픽셀이었다.
이때 좀 민망했다.
나는 아까까지 품질 옵션이니 코덱 경로니 하고 있었는데, 로그는 이미 너무 직설적으로 말하고 있었으니까. 결국 내가 틀렸었다.
왜 어떤 영상만 그 숫자가 나왔을까
여기서 한 번 더 보면 이해가 된다.
리사이즈는 보통 긴 변을 맞추고 짧은 변은 비율 계산으로 따라온다. 문제는 어떤 원본은 그 계산 결과가 짝수로 떨어지고, 어떤 원본은 홀수로 떨어진다는 점이다.
가령 긴 변을 1280으로 맞출 때
어떤 원본은 1280x720
어떤 원본은 1280x960
어떤 원본은 1280x548
어떤 원본은 1280x549
이런 식으로 떨어질 수 있다.
앞의 셋은 괜찮고, 마지막 하나가 문제를 만든다.
즉 서비스가 멀쩡한데 특정 파일만 안 되는 것처럼 보였던 건, 사실 입력 영상의 비율 때문에 계산 결과가 우연히 홀수가 됐기 때문이다. 이걸 모르고 있으면 특정 파일 손상이나 코덱 문제로 오해하기 딱 좋다.
워커에서는 마지막에 한 번 더 정리했다
처음엔 리사이즈 필터를 조금만 바꾸면 끝날 줄 알았다. 예를 들면 긴 변 고정 시 -2를 쓰는 식으로 말이다. 그 방식도 나쁘진 않은데, 나는 이번에 그걸로 끝내지 않았다.
이유는 간단했다. 경로가 하나가 아니었기 때문이다.
한 군데만 고치면 다른 경로에서 비슷한 문제가 다시 날 수 있다.
이런 건 공통 규칙으로 마무리하는 게 낫다 싶었다. 그래서 워커 쪽 필터 helper에서 마지막에 항상 짝수 해상도로 정리하는 단계를 넣었다.
def build_video_filters(max_long_edge: int | None, preserve_resolution: bool) -> list[str]:
filters: list[str] = []
if max_long_edge and not preserve_resolution:
filters.append(resize_filter_for_max_long_edge(max_long_edge))
filters.append("scale=trunc(iw/2)*2:trunc(ih/2)*2")
return filters핵심은 이 한 줄이다.
scale=trunc(iw/2)*2:trunc(ih/2)*2이렇게 해두면 앞에서 어떤 계산을 했든 마지막 출력 직전에
로 정리된다.
그래서 1280x549는 1280x548로 내려가고, 인코더가 더 이상 그 숫자 때문에 죽지 않는다.
이 방식이 좋았던 건 리사이즈 경로뿐 아니라 원본 유지 경로도 같이 보호된다는 점이었다. 원본이 애매하게 홀수 해상도여도 마지막에서 정리되니, 특정 경로만 땜빵한 느낌이 덜했다.
1픽셀 줄어드는 건 괜찮나
이건 잠깐 고민했다.
출력이 1280x549에서 1280x548이 되면 실제로 한 변이 1픽셀 줄어드는 거니까.
근데 이건 현실적으로 답이 정해져 있었다.
인코딩 실패와 1픽셀 손실 중에서 뭘 고를 거냐고 하면, 거의 무조건 후자다.
체감 품질 차이는 사실상 없고, 서비스는 훨씬 안정적이 된다. 괜히 숫자를 지키겠다고 버티다가 워커가 죽는 쪽이 훨씬 안 좋다.
그래서 이건 부작용이라기보다 의도된 보정으로 받아들였다. 오히려 이런 건 미리 명시하고 가는 게 맞다 싶었다.
검증은 좀 꼼꼼하게 봤다
이런 류는 고쳤다고 생각했는데 다른 경로에서 다시 터지는 경우가 은근 있다. 그래서 말로만 끝내긴 좀 찝찝했다.
먼저 명령 생성 테스트를 추가했다.
python -m unittest discover -s tests -v그다음 실제 샘플 영상으로 다시 돌려봤다.
홀수 해상도가 나올 수 있는 원본을 넣고 MP4, WEBM 둘 다 확인했다.
결과는 괜찮았다.
MP4 정상 인코딩
WEBM 정상 인코딩
최종 출력 해상도는 짝수로 정리됨
이 단계까지 보고 나서야 아 이건 진짜로 막혔던 구멍이 맞았구나 싶었다. 테스트만 통과하고 실제 ffmpeg 명령에서 다른 필터 체인이 또 삐끗하면 그건 또 다른 얘기라, 실제 샘플 검증은 꼭 필요했다.
여기서 끝내면 또 같은 식으로 헤맬 것 같았다
워커 쪽 패치만 넣고 끝낼 수도 있었다.
실제 문제는 해결됐고, 재현도 끝났고, 다시 돌려보니 정상적으로 변환됐으니까.
근데 이번에 느낀 불편함이 따로 있었다.
사용자 화면에서는 변환 실패만 보이고, 왜 실패했는지는 안 보여준다. 그러면 다음에 비슷한 문제가 다시 나왔을 때 나는 또 개발환경 열고, 워커 로그 열고, 재현하고, 같은 순서를 반복해야 한다.
이게 진짜 피곤하다.
특히 이런 식으로
전체는 멀쩡한데
특정 입력에서만
조용히 죽는 문제
는 운영하면서 자주 사람 괴롭힌다. 전체 장애보다 더 늦게 눈치채고, 늦게 원인을 찾게 된다.
그래서 이번엔 기술 수정에서 안 끝내고 운영 대응도 같이 넣었다.
관리자 대시보드에 실패 로그를 붙였다
앱 쪽에서는 영상 변환 실패가 날 때 내부 디버깅 로그를 남기게 했다.
그리고 관리자 대시보드에서 최근 실패를 바로 확인할 수 있게 붙였다.
여기서 욕심내서 다 넣진 않았다.
이런 로그는 많다고 좋은 게 아니라, 원인 파악에 필요한 것만 있어야 다시 볼 때 덜 피곤하다.
그래서 대충 이런 정도만 남기게 했다.
에러 메시지
에러 코드
파일명
파일 크기
출력 포맷
해상도 옵션
품질 옵션
FPS 옵션
실패 시각
반대로 안 남긴 것도 있다.
토큰
업로드 원본 바이너리
너무 긴 원문 전체
민감한 요청 헤더
이번에 직접 겪어보니까, 조용한 실패는 기록이 없으면 나중에 사람을 더 힘들게 한다.
사용자는 그냥 안 된다고 느끼고 끝나지만, 운영하는 사람은 왜 안 됐는지를 추적할 단서가 있어야 한다. 그래서 관리자 화면 대응은 기능 추가라기보다, 다음 삽질을 줄이기 위한 안전장치에 가깝다.
가장 아쉬웠던 건 로그를 늦게 본 거였다
솔직히 말하면 이번 건 그렇게 어려운 문제는 아니었다.
어려웠다기보다 내가 괜히 한 바퀴 돌아간 쪽에 가깝다.
다른 영상은 잘 되니까 옵션 매핑이나 프론트 전달값부터 의심했는데, 실제로는 로그가 이미 거의 정답을 말하고 있었다. 특정 영상만 실패할 때는 전체 기능보다 입력 조건을 먼저 봤어야 했다. 그 판단을 초반에 못 한 건 내 실수였다.
그래도 한 가지는 남았다.
다음에 비슷한 증상이 나오면 나는 아마 코덱 이름보다 숫자부터 볼 것 같다. 그리고 이번엔 관리자 대시보드에 실패 로그도 남게 해놨으니, 적어도 똑같이 캄캄한 상태에서 다시 시작하진 않을 것 같다.