Client에서 Webm to Mp4 인코딩 구현

adultlee·2023년 12월 21일
1

NDD

목록 보기
14/16
post-thumbnail

서문

기존 저희 서비스에서는 web api인 media recorder를 통해서 유저의 면접 과정을 녹화하여 서버에 저장하거나 로컬에 바로 다운로드 받을 수 있는 기능을 제공하고 있습니다.

하지만 서비스를 제공하다보니 치명적인 문제가 발생했는데,
바로 web api를 통해서 녹화하는 경우 파일의 확장자가 webm으로만 정해진 다는 것이었습니다. (정확히는 모바일 iOS 유저를 제외한 대부분 크로미움 브라우저에서) iOS는 애플의 App Store 정책에 의해 모든 타사 웹 브라우저가 애플의 Webkit 엔진을 사용하여 웹 콘텐츠를 랜더링해야 하기 때문입니다.

iOS에서 동작하는 Firefox, Opera같은 다른 브라우저도 UI만 다를 뿐 내부적으로는 사파리와 동일한 엔진을 사용하니 동일하게 webm이 지원되지 않습니다.

그래서 저희가 생각할 수 있는 선택지는 두가지가 있었습니다.
첫 번째 선택지는 “모바일 iOS를 지원하지 말자!”입니다. 사실상 올바른 선택지라 보기는 어려웠습니다만, mp4 인코딩, 모바일 반응형에 들이는 시간을 줄이고 서비스 자체의 퀄리티를 높이는쪽으로 집중하는 방향성도 고려했습니다.

하지만 우리 서비스의 목표는 실제 유저에게 서비스하고, 우리가 사용하고 싶은 서비스를 만드는 것인데 아이폰을 사용하는 우리팀의 장희님에게도 서비스할 수 없는 서비스가 과연 가치가 있을까? 에 대해 고민했습니다. 그래서 iOS 유저에게도 서비스를 제공하기로 결정했고 곰터뷰 서비스의 모든 영상을 mp4 형식으로 변환해야했습니다.

인코딩 서버를 도입 후 실패

mp4 인코딩을 위해서 ffmpeg 라는 라이브러리를 도입했으며,
곰터뷰 서비스에서 가장 긴 영상길이인 3분에 대해서 인코딩을 진행했을때, 약 30초 가량의 시간이 소요되는 것을 확인할 수 있었습니다.

하지만 하나 이상의 영상에 대해 인코딩 요청이 들어오면 서버가 다운되는 현상이 발생했습니다.
이 현상에 대한 원인을 분석해봤더니 다음과 같은 이유였습니다.

현재 곰터뷰 서비스는 AWS 프리티어의 t2.micro CPU를 사용중인데 이는 단일 CPU로 구성되어 있습니다.
ffmpeg 에서 하나의 영상을 인코딩 할 때는 하나의 프로세스를 사용하게 되는데
이 때 단일 CPU에서 여러 프로세스를 생성하게 되면, 각각의 프로세스가 하나의 CPU의 자원을 공유하게 되면서 스위칭 오버헤드가 일어나 프로세스의 성능 저하와 응답 딜레이가 발생합니다.
이로 인해 서버의 CPU 사용률이 99%에 다다르며 서버가 멈춰버리는 현상이 발생했었습니다.

이 현상을 해결하기 위해 큐를 구현해 한번에 하나의 영상만 인코딩하는 방법에 대해서 생각해봤지만, 이는 유저의 수가 늘어났을 때 영상 저장에 대한 많은 지연시간이 소요될 것이라고 예상했습니다.

가장 이상적인 해결책은 서버의 스케일을 높이는 방법입니다. 하지만 지속가능한 서비스를 운영하고 싶은 입장에서, 프리티어 수준 이상의 서버를 운용하는것이 부담이 되었습니다.

그에 따라 인코딩 서버를 구축하는 것이 아닌 다른 방법을 찾아야만 했습니다.

클라이언트에서 인코딩을 진행

그래서 저희가 찾은 방법은 클라이언트에서 인코딩을 수행하는것 이엇습니다.

이를 위해 ffmpeg.wasm, 즉 FFmpeg의 웹 어셈블리 버전을 활용했습니다. 원래 C 언어로 작성된 FFmpeg를 웹 어셈블리를 통해 브라우저 상에서 실행 가능하게 합니다.

웹 어셈블리는 웹의 성능 한계를 극복하기 위해 개발된 새로운 기술로, 네이티브 코드에 가까운 실행 속도를 제공함으로써 복잡한 계산과 처리가 필요한 작업을 웹에서도 가능하게 합니다. 기존의 JavaScript 기반 라이브러리에선 제공할 수 없었던 성능을 웹 서비스에 제공합니다. 이는 특히 webm to mp4 등과 같은 cpu의 리소스를 많이 소모해야만 하는 경우 큰 의미를 가질 수 있습니다.

해당 방식은 서버 기반 인코딩에 비해 상대적으로 낮은 성능을 제공하게 됩니다. 그러나 현재 서버의 처리 능력이 한 번에 단 하나의 비디오만 인코딩할 수 있는 상황을 고려했을 때, 이 방법은 여전히 효과적인 해결책이 될 수 있으리라고 판단했습니다. 비록 인코딩 시간이 다소 길어질 수 있지만, 이정도의 길어짐은 서비스 내부의 다른 UX 개선을 통해서 극복할 수 있으리라고 생각했습니다.

How?


다음 코드는 서버에 업로드 하거나 혹은 local 로 다운로드 하는 분기점을 나타넵니다.

여기서 확인할 수 있듯, recordedBlobs 에 저장된 녹화된 영상을 인코딩 해주어야 합니다.

내부 함수에 도입해보면, EncodingWebmToMp4 라는 함수가 blob, recordTime 을 받아서 인코딩을 수행합니다. 그 후 변환된 mp4Blob을 통해 기존에 수행하던 로직을 그대로 수행합니다.

그렇다면 EncodingWebmToMp4 로직을 살펴보도록 하겠습니다.

EncodingWebmToMp4

const EncodingWebmToMp4 = async (blob: Blob, recordTime: string) => {
  const baseURL = 'https://unpkg.com/@ffmpeg/core-mt@0.12.4/dist/umd';
  
  videoRecordQueue.push({ recordTime, questionNumber: index++ }); // 해당 파일의 전역변수로 설정되어 queue를 관리합니다. 

  if (!ffmpeg.loaded) { // ffmpeg를 동작시키기 위한 모듈을 import 합니다. 이때 Cors 정책을 우회하기 위해 toBlobURL을 수행합니다.
    await ffmpeg.load({
      coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'),
      wasmURL: await toBlobURL(
        `${baseURL}/ffmpeg-core.wasm`,
        'application/wasm'
      ),
      workerURL: await toBlobURL(
        `${baseURL}/ffmpeg-core.worker.js`,
        'text/javascript'
      ),
    });
  }

  const arrayBuffer = await blob.arrayBuffer(); 
  const uint8Array = new Uint8Array(arrayBuffer);
  // ffmpeg의 파일 시스템에 파일 작성
  await ffmpeg.writeFile('input.webm', uint8Array);
  await ffmpeg.exec([
    '-i',
    'input.webm', // 입력 파일
    '-s',
    '640x360',
    '-r',
    '30', // 프레임 레이트를 고정 설정: 30fps
    'output.mp4', // 출력 파일
  ]);
  const data = await ffmpeg.readFile('output.mp4');
  const newBlob = new Blob([data], { type: 'video/mp4' }); // 다시 blob 타입으로 반환합니다. 
  toast.info('성공적으로 Mp4 인코딩이 완료되었습니다😊', {
    position: 'bottomLeft',
  });
  
  toast.delete(videoRecordQueue[0].toastId!);
  videoRecordQueue.shift();

  return newBlob;
};

해당 함수에는 몇가지 포인트가 있습니다.

해당 함수를 그대로 사용하셔도 좋으나, 몇가지 포인트들이 있음을 추가로 알고 계시는것이 좋을거 같아서 추가합니다.

  1. 함수를 호출 할 때마다 ffmpeg를 중복호출함으로 인해, 메모리 leak을 일으킬 수 있습니다. 이는 module의 크기가 방대하기 때문에 GC 가 동작하기 전에 치명적인 이슈를 일으킬 수 있으므로, 함수의 외부에서 전역으로 ffmpeg에 대한 객체를 선언합니다.

  2. ffmpeg 의 모듈을 사용하기 위해선 cors 정책을 우회해야합니다.

  3. 인코딩은 굉장한 CPU 에 굉장한 리소스를 요구합니다. 그에 따라 메인스레드에서 동작하게 되면, 이는 서비스 전체가 멈추게 되는 문제가 발생하게 됩니다. 이를 워커스레드를 사용함으로서 극복해야 했는데, 이 과정에서 sharedArrayBuffer CORS 정책 이슈가 발생합니다. 이를 해결하기 위해선 제 이전 글을 참고하시면 됩니다.

  4. 클라이언트에서 균일한 화질과 bitrate를 제공하기 위해서는 ffmpeg에서도 설정을 추가해 주어야 합니다. 현재는 360p 의 영상을 제공하고 있지만, 곧 개선할 예정입니다. 현재는 3분의 영상을 기준으로 약 10메가바이트의 크기를 가지면 clinet에서 약 30초 가량의 시간이 소요됩니다.

  5. blob을 그대로 인코딩에 사용할 수 없습니다. arrayBuffer를 이용해서 파일 타입으로 변환한뒤 인코딩의 인자로 사용해 주어야 합니다.

Result

결국 client에서 webm to mp4 인코딩을 성공할 수 있었습니다.
하지만 아직 여러가지 아쉬운점이 있습니다.

  1. 3분의 영상을 기준으로 약 10M 정도의 메모리를 차지해야만, 이상적인 인코딩 시간을 가질 수 있었습니다. 그렇기 때문에 어쩔 수 없이 화질을 저하 시키고, bitrate를 줄여야만 했습니다.
  2. 녹화와 동시에 인코딩을 진행해서, 녹화 종료시 인코딩된 결과물을 반환하면 더 좋은 UX를 제공할 수 있을것 같습니다.

서비스의 핵심 기능인 동영상 서비스를 제공하기 위해 결국 인코딩을 필요로 했습니다.
여러가지 상황속에서 서버에서 인코딩과정을 처리할 수 없음을 알게 된 이후로 client에서 인코딩을 진행하며 문제를 해결할 수 있었다고 생각합니다.

0개의 댓글