들어가며
안녕하세요. LINE+ VOOM Web Engineering 팀 프런트엔드 개발자 한규민입니다. 이번 글에서는 MP4 파일의 구조를 분석해서 오디오 존재 여부를 판단하는 기능을 만든 내용을 공유하려고 합니다. 프런트엔드 영역에서 이 문제를 해결하기 위해 고민하며 검토했던 방법과, 선택한 방법을 구현하면서 어려웠던 점을 소개하고, 마지막으로 적용 결과를 말씀드리겠습니다.
기능 개발 배경
LINE 앱에는 일본과 대만, 태국 등에서 서비스하고 있는 LINE VOOM이라는 동영상 플랫폼이 있습니다. 단순히 동영상을 시청하는 것을 넘어 사용자가 직접 동영상을 만드는 공간으로 자리매김하고 있는 플랫폼으로, 현재 앱과 웹 모두에서 서비스하고 있습니다.
LINE VOOM에는 동영상에 오디오가 존재하지 않는 경우 특정 아이콘으로 소리가 없는 동영상이라는 것을 표시하는 기능이 있습니다. 앱에서 먼저 제공한 기능인데요. 앱에 이어서 웹 환경에서도 이 기능을 제공하기 위해 동영상 파일 URL을 활용해 웹 프런트엔드 영역에서 해결할 수 있는 방법을 찾아봤습니다.
먼저 결과부터 보여드리겠습니다. 다음은 현재 웹뷰 환경으로 작동하는 LINE Official Account 앱에서 LINE VOOM 서비스를 실행한 모습입니다. 소리가 없는 영상의 경우 영상 오른쪽 위에 이를 나타내는 아이콘이 표시된 것을 확인할 수 있습니다.
기능 개발 방법
동영상 섬네일에 오디오 존재 여부를 표시해야 하기 때문에 확인에 시간이 오래 걸리면 안 됩니다. 또한 모바일 웹 환경에서 실행되는 것을 고려해 최대한 데이터를 적게 사용해야 했습니다. 이런 요구 사항을 염두에 두고 방법을 찾기 시작했습니다.
브라우저 지원 API 사용
먼저 팀에서 시도했던 방법은 브라우저에서 지원하는 웹 API를 찾는 것이었습니다. 하지만 이 방법은 아래와 같이 브라우저마다 API 이름이 달라서 코드에서 브라우저를 따로 확인해야 했으며, Chrome에서 제공하는 API는 정확하게 오디오 존재 여부를 판단하지 못하는 문제도 있었습니다.
- Safari:
audioTracks
- FireFox:
mozHasAudio
- Chrome:
webkitAudioDecodedByteCount
MP4 파일 구조 분석
다음으로 시도한 방법은 MP4 파일 구조를 분석해 오디오 정보를 포함하는 바이트를 찾는 것이었습니다. 처음에는 파일 구조를 분석해서 정보를 찾는다는 것이 막막했지만, 팀에서 이미 다른 파일 포맷의 구조를 분석해 문제를 해결한 경험이 있었기에 많은 도움을 받을 수 있었습니다.
분석할 때는 Apple에서 제공하는 QuickTime File Format 문서를 참고했습니다. MP4 파일 명세는 Apple의 QuickTime 파일을 기반으로 작성됐는데요(참고). 일부 값이 다른 부분이 존재할 수는 있지만, QuickTime File Format 문서가 가장 간결해서 참고하기 좋습니다.
QuickTime 파일은 기본 데이터 단위인 '아톰(atom)'의 계층으로 구성된 구조입니다(QuickTime 아톰 구조 예시 - Movie 아톰). 아톰에는 여러 종류가 있는데요. 그중에서 '미디어 아톰(Media atom)'에는 오디오나 비디오 등 미디어 유형을 정의하는 데이터가 들어있습니다. 이 미디어 아톰 안에서 미디어 데이터를 해석하는 데 사용하는 'hdlr 아톰(Type = 'hdlr'
)'을 발견했습니다.
hdlr(handler reference) 아톰
hdlr(handler reference) 아톰은 미디어 데이터를 해석하는 데 사용하는 미디어 핸들러 컴포넌트로, 이 아톰 안에 Component subtype이라는 필드가 있습니다. 이 필드는 데이터 타입을 정의하는 필드로, 비디오가 존재하면 'vide', 오디오가 존재하면 'soun'을 4 바이트로 표현합니다(참고).
Apple에서 제공하는 QuickTime File Format 문서의 57 페이지 Figure 2-17을 보면 hdlr 아톰의 전체 구조를 파악할 수 있습니다. 이를 통해 아톰 유형 값(Type = 'hdlr'
)을 확인할 수 있는 주소에서 8 바이트를 이동하면 오디오 존재 여부를 확인할 수 있다는 것을 알게 됐습니다.
MP4 바이너리 데이터를 16진수로 확인해 위 구조와 일치하는지 확인
실제 데이터도 위 구조와 동일한지 확인하기 위해 다음 사양의 MP4 파일을 hexed.it에서 16진수로 확인해 봤습니다.
- 코덱: H.264/AVC
- 확장자: mp4
아래는 확인 결과입니다. 오디오가 존재할 경우 아톰 유형 값('hdlr')을 확인할 수 있는 주소에서 8 바이트를 이동하면 'soun' 이 존재하는 것을 확인할 수 있었습니다. 비디오도 함께 존재할 경우 'hdlr'과 'vide'로 구성된 아톰이 하나 더 존재했습니다. 반면 오디오가 없는 경우 'hdlr'과 'soun'으로 구성된 아톰을 찾을 수 없었습니다.
- 오디오가 존재하는 경우
- 비디오가 존재하는 경우
위 결과를 보면 MP4 파일의 경우 'hdlr'과 'soun' 사이에 있는 Version
과 Flags
, Component type
에는 널(null) 값이 들어가고 있습니다. MP4 파일이 아닌 경우 코덱이나 확장자의 종류에 따라 파일 구조가 변경될 수도 있는데요. 예를 들어 MOV 파일의 경우 Component type
에 'mhlr' 같은 값이 들어갈 수 있습니다.
기능 구현하기
위 방법은 다음과 같은 두 단계로 나눠 구현할 수 있습니다.
- LINE VOOM 웹 프런트엔드에서 MP4 파일 URL로 HTTP 요청하기
- MP4 파일은 BLOB(binary large object) 형태로 요청합니다.
- 데이터 사용량을 줄이기 위해 HTTP 헤더의 Range 필드를 이용합니다.
- HTTP 응답 데이터를 분석해 오디오 존재 여부 확인하기
- HTTP 응답으로 받은 바이너리 데이터에
hdlr\0\0\0\0\0\0\0\0soun
이 존재하면 오디오가 있다고 판단합니다. - 존재하지 않을 경우 몇 가지 조건을 확인해 데이터를 조금 더 요청해서 위 과정을 반복하거나 오디오가 없다고 판단합니다.
- HTTP 응답으로 받은 바이너리 데이터에
MP4 파일 URL로 HTTP 요청하기
MP4 파일은 오디오를 나타내는 'soun'의 위치가 파일마다 다릅니다. 따라서 'soun'을 확인할 수 있게 필요한 크기만 요청하는 것이 중요했습니다.
우선 무작위로 영상 데이터를 골라 파일 크기와 'soun'의 위치를 확인해 봤습니다. 확인 결과 아래와 같이 일반적으로 파일이 커질수록 'soun'이 상대적으로 데이터의 뒤쪽에 위치하는 것을 확인할 수 있었습니다.
파일 크기 | 'soun' 시작 위치 | 요청해야 하는 데이터 크기 |
---|---|---|
13MB | 0x1C90 | 7.3KB |
25MB | 0x53D0 | 21.4KB |
186MB | 0x15D60 | 89.4KB |
742MB | 0x8BDF0 | 572.9KB |
파일 크기와 'soun'의 시작 위치가 항상 비례하는 것은 아니었지만, 위 데이터를 기반으로 파일 크기에 따라 대략적으로 'soun'이 등장하는 위치를 추정해 HTTP 요청에 사용할 Range 필드값을 6개 설정했습니다. 값을 설정할 때에는 데이터 사용량과 요청 횟수를 줄이기 위해 다음과 같은 세 가지 규칙을 적용했습니다.
- 요청하는 데이터 크기는 최대한 작아야 한다.
- 브라우저에서 병렬로 처리할 수 있는 요청 개수를 고려해 너무 많이 요청하지 않도록 값을 설정한다.
- 파일 크기별 'soun' 시작 위치를 조사한 결과와 업로드할 수 있는 동영상의 최대 크기가 2048MB인 점을 고려해 배열의 최댓값을 설정한다.
이와 같은 규칙으로 6개의 값을 만들어 아래와 같이 defaultThreshold
라는 배열로 관리했고, 업로드할 수 있는 영상 파일의 크기에 맞춰 Range 필드값을 함수의 인자로 넘겨 변경할 수 있는 형태로 코드를 구성했습니다.
export const defaultThreshold = [
0xffff, 0xfffff, 0x1ffffe, 0x3ffffc, 0x7ffff8, 0xffffff,
]; // [66kb, 1mb, 2mb, 4mb, 8mb, 16mb]
테스트해 본 결과, 1GB 동영상의 경우 약 1MB, 그 외 대략 1분 내외 동영상의 경우 약 66KB를 요청하면 대부분 오디오 존재 여부를 확인할 수 있었습니다.
HTTP 응답 데이터를 분석해 오디오 존재 여부 확인하기
HTTP 응답으로 받은 데이터는 FileReader
를 이용해 처리한 뒤 문자열 형태로 변경해서 hdlr\0\0\0\0\0\0\0\0soun
이 포함돼 있는지 확인합니다.
아래 코드는 전체 코드 중 오디오 존재 여부를 확인하는 부분을 가져온 것으로 로직은 다음과 같습니다.
- HTTP 요청을 통해 받아온 바이너리 데이터에 soundFlag(
hdlr\0\0\0\0\0\0\0\0soun
)을 문자열(String
)로 변경한 변수를 이용해 오디오가 있는지 확인합니다. - 오디오가 없다면 요청 바이트와 응답 바이트를 비교해 추가 요청이 가능한지(아직 영상 파일 중 확인하지 않은 부분이 있는지) 확인합니다.
- 추가 요청이 가능한 경우(아직 영상 파일 중 확인하지 않은 부분이 있는 경우) 요청 범위를 조금씩 늘려가며 오디오 존재 여부를 지속적으로 확인합니다.
- 추가 요청이 가능하지 않거나
defaultThreshold
배열의 최댓값까지 모두 요청했는데도 오디오가 없다면 오디오가 존재하지 않는 것으로 판단합니다.
// constants.js
export const soundFlag = [
104, 100, 108, 114, 0, 0, 0, 0, 0, 0, 0, 0, 115, 111, 117, 110,
].join(); // 'hdlr\0\0\0\0soun' to decimal
// index.js
export function checkAudioPresenceInBlob(res, params) {
const { url, currentIndex, threshold } = params;
const { startByte, endByte } = extractByteRange(threshold, currentIndex);
const isLastRequest = currentIndex === threshold.length - 1;
return new Promise((resolve, reject) => {
const fr = new FileReader();
fr.readAsArrayBuffer(res.data);
fr.onloadend = () => {
if (fr.readyState === FileReader.DONE) {
const uIntArr = new Uint8Array(fr.result).join();
const hasAudio = uIntArr.includes(soundFlag);
const requestChunkSize = endByte - startByte + 1;
const responseChunkSize = +res.headers["content-length"];
if (hasAudio) {
resolve(true);
return;
}
// MP4 파일에서 아직 확인할 부분이 남았거나 defaultThreshold 배열의 마지막 인덱스까지 사용하지 않은 경우
if (requestChunkSize === responseChunkSize && !isLastRequest) {
checkAudioPresence({
url,
currentIndex: currentIndex + 1,
threshold,
}).then((res) => resolve(res));
return;
}
// defaultThreshold 배열의 마지막 인자까지 다 호출했거나 MP4 파일을 다 확인했는데도 오디오가 없는 경우
resolve(false);
return;
}
};
fr.onerror = (error) => {
reject(error);
};
});
}
적용 과정 중 트러블 슈팅 - Range 필드 관련 이슈
위 기능을 적용한 후 QA 과정에서 iOS 16 버전 미만(Safari 16 버전 미만)에서 기능이 작동하지 않는다는 이슈를 공유 받았습니다. 아래와 같이 'Access-Control-Allow-Headers'에 Range 필드가 없다는 에러가 발생하고 있었습니다.
실제로 서버의 응답 헤더를 살펴보니 Range 필드가 포함돼 있지 않았습니다.
하지만 Chrome(115 버전)에서는 정상 작동했고, Safari 역시 16 이상 버전에서는 정상 작동했습니다. 또한 iOS 15 버전에서 실패한 요청을 curl로 확인한 결과 역시 정상이었습니다. 따라서 문제는 브라우저에 있다는 것을 알 수 있었습니다.
먼저 Safari 버전이 15에서 16으로 업데이트되는 과정에서 어떤 변경 사항이 있었는지 Safari 릴리스 노트를 전부 확인했는데요. 여기서는 Range 필드와 관련된 내용을 찾을 수 없었습니다. 에러 내용을 바탕으로 문제가 CORS(Cross-Origin Resource Sharing)와 관련 있을 것이라고 판단했기 때문에 이어서 MDN CORS 문서를 확인했고, 이 문서를 보 다가 CORS-safelisted request-header라는 것을 알게 됐습니다.
CORS-safelisted request-header는 헤더에 특정 필드만 존재하면서 각 필드의 값이 특정 조건을 만족하면 사전 요청을 보낼 필요가 없다는 내용이었습니다. 저는 Range 필드가 CORS-safelisted request-header에 추가된 것을 확인하고 적용 시기를 찾아봤고, 각 브라우저별 적용 시기는 다음과 같았습니다.
Chrome |
|
---|---|
Safari |
|
Firefox |
|
Safari의 경우 논의 스레드만 남아 있어서 WebKit 코드를 직접 확인해 봤습니다. 그 결과 iOS 15 버전까지는 CORS 확인 관련 로직에 들어가 있지 않았던 Range 필드가 iOS 16.1 버전에서 추가된 것을 확인할 수 있었습니다.
버전 | 코드 |
---|---|
iOS 15.7.2 | |
iOS 16.1 |
따라서 문제를 해결할 수 있는 방법은 두 가지였습니다.
- 응답 헤더의 Access-Control-Allow-Headers에 Range 필드를 추가하는 방법
- iOS 16.0 이상의 버전을 사용하는 방법
어떤 방법을 선택할지 결정하기 위해 먼저 사용자의 iOS 버전 점유율을 확인했습니다. 그 결과 iOS 16.0 이상의 버전을 사용하는 사용자의 비율이 약 87% 정도로 상당히 높았습니다. 이에 추가 수정 없이 그대로 기능을 적용하기로 결정했습니다.
기능 적용 결과
아래 왼쪽은 기능을 적용하기 전, 오른쪽은 기능을 적용한 후의 모습입니다. 기능을 적용하기 전에는 오디오가 없는 경우 음소거 아이콘으로 표시되고 있었는데요. 적용 후에는 오디오 존재 여부를 정상적으로 판단한 뒤 오디오가 없다는 뜻의 새로운 아이콘으로 표시하는 것을 확인할 수 있습니다.
기능 적용 전 | 기능 적용 후 |
---|---|
마치며
이번 글에서는 프런트엔드에서 FileReader
객체를 활용해 MP4 파일에 오디오가 포함돼 있는지 확인하는 기능을 어떻게 구현했는지 살펴봤습니다. 동영상 섬네일에 사용해야 했기에 실행 속도가 빨라야 했고, 데이터 사용량에 제한이 있어서 동영상 전체를 요청할 수 없다는 점이 주요 요구 사항이었습니다. 인터넷에 이런 기능을 구현하는 것과 관련된 정보가 별로 없어서 구현할 때에는 어려움이 많았는데요. 완성하고 나니 이처럼 파일 구조를 분석할 수 있다면 여러 방면으로 기능을 확장할 수 있겠다는 생각이 들어서 보람을 느낄 수 있었습니다. 이 글이 저와 같은 어려움을 겪고 계신 분들에게 도움이 되기를 바라며 이만 마치겠습니다. 긴 글 읽어주셔서 감사합니다.