Skip to content

인코딩 속도 30% 개선에 성공한 이야기

SeoKyung Lim edited this page Dec 14, 2023 · 1 revision

🤔 개선하려는 이유

현재 로직에서는 /musics로 DB에 음악 정보를 업로드하는 과정

  1. 클라이언트로부터 mp3 저장 경로(ObjectStorage에 mp3가 담긴 위치) 받아옴
  2. mp3 파일을 로컬 경로에 저장
  3. 로컬 경로를 불러와 ffmpeg을 활용해 인코딩(m3u8, ts로 변환)을 실시

⇒ 다만 이 과정이 완료되기까지 4초 후반~7초 중반이라는 긴 시간이 소요되어, 어떤 부분에서 시간 소요가 많이 발생하는지 알아보고 개선하고자 한다.

✒️ ffmpeg를 활용해 인코딩 하는 코드 개선

return await new Promise((resolve, reject) => {
			const startTime = new Date().getTime();
      ffmpeg(tempFilePath)
        .addOption([
          '-c:a aac',
          '-b:a 192k',
          '-hls_time 10',
          '-hls_list_size 0',
          '-f hls',
        ])
        .output(outputPath)
        .on('end', async () => {
          const encodedPath = await this.uploadEncodedFiles(
            outputMusicPath,
            musicId,
          );
          resolve(encodedPath);
          const finishTime = new Date().getTime();
          console.log(finishTime - startTime);
        })
        .on('error', () => {
          reject(new Error());
        })
        .run();
    });

위의 코드에서 ffmpeg 모듈을 활용해 인코딩이 시작되는 시간(startTime)과 인코딩이 끝나고 변환된 파일의 로컬 경로를 반환(resolve)하는 시간(finishTime)을 두어 정확히 인코딩에만 걸리는 시간을 계산해보았다.

3.23.3초 정도 걸렸는데, 57초가 전체 로직 작동 시간이라면 반 이상을 차지하기 때문에 개선이 필요할 것 같다.

→ ffmpeg 자체가 비동기적으로 동작하여, 따로 워커 스레드를 만드는것이 무의미하다.

→ 옵션 일단 써봤는데 별로 효과가 없다….

.inputOptions('-threads 2')

.audioBitrate('192k')

.outputOptions('-preset fast')


✔️ 개선될 수 있는 부분

  • storage 에서 서버로 mp3 받아오고 서버에서 m3u8, ts 파일 storage 에 저장하는 부분

  • 인코딩을 전부 마친 후에 storage 에 올리는 부분 → ts 파일(0, 1, 2, …) 이 생성되자마자 storage 에 올리도록 수정하면 👍

    → 엇 근데 갑자기 생각이 든게 하나씩 업로드 하다가 서버에서 문제가 생기면 이미 올라간 파일들은 삭제해야 하는데 어떡하지?

  • 개선 X

    1차 - 응답: 12.71s

  • 개선 O (ts 파일 하나씩 버킷에 등록)

    [10초 노동요/ts파일1개]

    11.18초 / 7.624초 12.45초 / 8.579초 11.43초 / 8.065초

그래서 우리는 ts 파일을 하나씩 바로 업로드 하는 방식으로 성능 개선을 해보고자 했다.

⭕ 개선된 로직

😭 개선시도해봤으나 실패한 로직

정말 간단한 Stream과 Buffer

fs 모듈을 활용해 파일을 가져오고 읽고 쓰는게 많은 로직이라 이 시간도 단축시키고 싶었다. 그래서 버킷으로부터 mp3 파일을 받아오면 buffer 형태로 인코딩하는 함수에 넣어주고 30초 단위로 잘라 chunk 파일을 S3에 바로 꽂아넣는 방식. (그래서 시간 단축은 잘 된다. 3분 안되는 곡 4초 걸림)

아래 코드가 복잡해보이지만 방식은 이러하다.

ffmpeg으로 인코딩을 실행하면서 on 함수에서 data를 받아올 때, buffer 형태의 chunk 변수에 data를 쌓아둔다. 그리고 progress 옵션에서 잰 시간이 30초 단위로 지나갔다면 그대로 잘라서 S3 버킷에 올린다.

버킷에 잘 올라가는 것을 확인하긴 했지만, 큰 문제를 간과했다. m3u8 파일이 인코딩 되는 시간을 모르기 때문에, m3u8 파일과 ts 파일의 겹쳐져 들어간 대환장 파일이 생성될 수도 있다는 것이다ㅠㅠㅠ

장점을 살리자고 m3u8 파일을 마음대로 조작하기에는 안정성이 보장이 잘 안돼, 형운님께서 잘 구현하신 watcher를 활용한 로직을 따라가고자 한다.

**(시간):**():**():*** => 00:00:10:567 
private timeMarkToSeconds(timemark: string) {
    const parts = timemark.split(':');
    const hours = parseInt(parts[0], 10) || 0;
    const minutes = parseInt(parts[1], 10) || 0;
    const seconds = parseFloat(parts[2]) || 0;

    return hours * 3600 + minutes * 60 + seconds;
  }

// 로직 발췌
let index: number = 1;
let m3u8Path: string = '';

let chunk: Buffer = Buffer.from(Buffer.alloc(0));
let curSecond = 30;
let perSecond = 30;

const passthroughStream = new PassThrough();
passthroughStream.on('data', async (data: Buffer) => {

	const isTSFile = data.slice(0, 3).equals(Buffer.from([0x47, 0x41, 0x00]));
	
	chunk = Buffer.concat([Buffer.from(chunk), Buffer.from(data)]);
	
	if (isTSFile && curSecond >= perSecond * index) {
	  const name = `${musicName}${index++}.ts`;
	  await this.uploadEncodedFile(chunk, musicId, name);
	  chunk = Buffer.alloc(0);
	} else if (!isTSFile && curSecond >= perSecond * index) {
	   const name = `${musicName}.m3u8`;
	   m3u8Path = await this.uploadEncodedFile(chunk, musicId, name);
	   chunk = Buffer.alloc(0);
	}
});

return new Promise<string>((resolve, reject) => {
      ffmpeg(musicStream)
        .addOption([
          '-map 0:a',
          '-c:a aac',
          '-b:a 192k',
          '-hls_time 30',
          '-hls_list_size 0',
          '-f hls',
        ])
        .output(passthroughStream, { end: true })
        .on('progress', (progress) => {
          curSecond = this.timeMarkToSeconds(progress.timemark);
        })
        .on('end', async () => {
          if (chunk.length > 0) {
            const isTSFile = chunk
              .slice(0, 3)
              .equals(Buffer.from([0x47, 0x41, 0x00]));

            if (isTSFile && curSecond >= perSecond * index) {
              const name = `${musicName}${index++}.ts`;
              await this.uploadEncodedFile(chunk, musicId, name);
              chunk = Buffer.alloc(0);
            } else if (!isTSFile && curSecond >= perSecond * index) {
              const name = `${musicName}.m3u8`;
              m3u8Path = await this.uploadEncodedFile(chunk, musicId, name);
              chunk = Buffer.alloc(0);
            }
          }
          resolve(m3u8Path);
        })
        .on('error', (err) => {
          console.log(err);
          reject(err);
        })
        .run();
    });

** Transform Stream은 데이터 변환을 처리하는데 특화된 스트림이다. 이 중 하나인 PassThroughStream은 아무런 변형 없이 데이터를 읽어와 손실 없이 인코딩 된 파일을 chunk에 담을 수 있다!

** progress의 timemark는 처리 중인 음성 파일의 인코딩 진행 시간을 나타냄!

fs.watch 를 이용한 개선

Untitled

파일이 이런 식으로 생성 되는데 지금까지 로직은 ts 파일들이 생성되는 동안 기다리고 모두 생성된 후에 ts 파일과 m3u8 파일들을 업로드하는 방식이었다.

그래서 ts 파일들이 생성되는 것을 기다리는 시간이 뜨고 단축할 수 있을 것 같아서 이 부분의 개선을 진행했다.

/musics 디렉토리 안에 해당 파일들이 생성되는데, 이 디렉토리에서 파일이 생성되거나 변화되는 것을 관찰하는 부분이 있으면 좋겠다고 생각하던 중에 **fs.watch** 라는 함수를 발견했다.

const watcher = fs.watch(outputMusicPath, (eventType, fileName) => {
		console.log(eventType, fileName);
});

watch 는 지정한 디렉토리에서의 이벤트를 감지하는 함수고, 사실 (이벤트, 파일이름) 을 출력한 것이 위의 사진이다.

그래서 fs.watch 로 /musics 디렉토리에서 .ts 파일들이 생기는 즉시 Object Storage 에 업로드를 하는 것으로 구현했다.

const watcher = fs.watch(outputMusicPath, (eventType, fileName) => {
  if (fileName.match(/.m3u8$/)) {
    m3u8FileName = fileName;
  } else if (!fileName.match(/\.tmp$/)) {
    this.uploadEncodedFile(
      outputMusicPath + `/${fileName}`,
      musicId,
      fileName,
    );
  }
});

.ts 파일이 생성되면 즉시 uploadEncodedFile 로 업로드를 진행하고 .m3u8 파일은 작업이 모두 끝난 뒤 업로드 하는 것으로 구현했다.

결과

이렇게 인코딩 작업을 기다리는 시간을 줄인 결과 3분 50초대 mp3 파일을 인코딩하고 업로드하는데

9초대 → 5~6초대 로 약 30% 이상 단축시킬 수 있었다.

  • 테스트: 3분 28초 음악 4.71s

Untitled

더 개선할 여지

ts 파일을 worker thread 를 이용해서 더 단축할 수 있지 않을까?


Clone this wiki locally