React Native

[React Native] NodeMediaPlayer component unmount issue로 인한 Memory leak 문제 해결

Woonys 2022. 3. 5. 22:59
반응형

 

0. 목차

 

1. Intro

 

2. 문제: Memory leak으로 인한 App crash

 

3. 접근 방법

 

3-1. 네트워크에서 보내주는 방식의 문제인가?

- 서버에서 보낸 .m3u8과 .ts파일이 지워지지 않는 건가?

- VOD 송출 방식의 문제?

- FFmpeg는 대체 어떻게 온디맨드 스트리밍 영상을 보내는 거지?

 

3-2. 내부 코드 문제인가?

- cache를 하지 않아서인가?

- unmount를 해주지 않는 건 아닌가?

- NodePlayerView -> Video 컴포넌트로 바꾸니 해결

 

4. 문제의 원인은?

4-1. NodePlayerView: 무엇이 문제였나

4-2. ComponentWillUnmount(): 매번 unmount할 때 stop()을 실행해 playerview를 종료해줘야 하는데 이걸 하지 않아 계속 메모리에 쌓인 게 문제.

 

5. 회고

- 측정 및 분석 경험 -> 성장

- 논리적 사고에 기반한 문제 해결 방식 & 팀워크

- 해결에 그치지 않고 원인 파악까지

 

 

 

1. Intro

발표까지 일주일 남짓. 어찌어찌 데모 시연은 마무리했지만 여전히 앱 최적화는 골칫덩어리였다. 가장 큰 문제 중 하나는 앱 속도가 너무 느리다는 것. 에뮬레이터 혹은 폰에 앱을 빌드하고서 조금만 사용하면 금세 앱이 느려지고 심하면 crash가 일어났다.

 

 

앱을 처음 켰을 때
홈 <--> 로그인 화면 이동 반복 시 앱 로딩

 

2. 문제: Memory leak으로 인한 App crash

이 문제를 어떻게 접근하면 좋을지 다같이 고심했다. 그러던 중,  Android Studio에서 제공하는 기능인 profiler를 은우형이 발견했다.

 

앱을 빌드하고서 실시간으로 CPU, 메모리, 에너지 소모 상태 등 다양한 지표를 진단할 수 있는 기능이었다. 이전부터 디버깅으로 확인하고 싶었던 네트워크 현황도 위에 포함된 Network Profiler 기능으로 확인이 가능했다. 그야말로 측정 및 분석을 위해 꼭 필요한 기능이다.

 

무튼, 여기서 홈 화면에서 로그인 화면으로 이동을 반복함에 따라 휴대폰 메모리 사용량이 급속도로 증가하는 것을 체크할 수 있었다.(아래 도표 참조) 빨간색 표시는 로그인 화면에서 홈 화면으로 이동할 때를 표시한 위치이다. 아래 그래프는 약 3분 간 홈<-->로그인 화면으로 이동 횟수에 따른 메모리 사용량 증가 추이을 나타내는 그래프이다. 그래프가 우상향하는 것을 확인할 수 있다.

 

가만, 그런데 꼭 앱을 실행해야 증가하는 것처럼 보이지는 않는다. 그냥 가만히만 있어도 메모리 사용량이 증가하나? 체크해보자.

 

위의 그래프는 앱을 실행하고 로그인 화면 -> 홈화면으로 1회 접속 후 앱을 그대로 둔 상태이다. 그래프 시작 지점에서 뾰족하게 올라오는 사용량은 앱을 처음 실행했을 때의 위치이다. 실행 후 메모리 양이 가파르게 내려가다가 홈 화면 접속과 동시에 반등한다. 그런데 그 이후로는 메모리 사용량이 완만한 감소를 그리다가 유지되고 있음을 확인할 수 있다.

 

아래 보이는 작은 흰색 쓰레기통 모양은 Garbage Collector(GC)가 작동했음을 의미한다. 도표 1-1, 1-2 모두 GC가 작동했음을 볼 수 있는데, 그럼에도 로그인 <-> 홈 화면 이동을 반복하게 되면(도표 1-1) 메모리 사용량이 증가하는 걸 막지 못함을 확인할 수 있다. 여기서 또 하나 확인할 수 있는 건 'GC가 작동하지 않아서인가?'인데, GC 자체의 문제는 아니라고 볼 수 있겠다.

 

3. 접근 방법

이를 어떻게 해결하면 좋을지 모두 같이 고심했다. 상황을 다시금 정리해보자.

 

현재 홈 화면에 나오는 영상은 더미로 넣은 6개의 영상으로, 서버(정확히는 서버리스로 S3 -> CloudFront -> Client이지만 이해를 돕기 위해 그냥 서버로 그림)에서 미리 인코딩해둔 m3u8과 ts파일을 HLS 프로토콜로 HTTP를 통해 클라이언트에 전송한다. 클라이언트는 서버로부터 받은 영상을 화면에 띄운다.

 

아래는 우리가 어떻게 문제를 해결했는지 그 흐름을 도식화했다.

 

 

 

1) 외부(Server->client): 네트워크에서 보내줄 때의 문제인가?

 

가장 먼저 들었던 생각은 서버 -> 클라이언트로 영상 송출 단에서의 문제가 아닐까?였다. 우리가 세운 가설은, "서버에서 보내는 파일이 모종의 이유로 클라이언트에서 지워지지 않고 메모리에 남을 것이다" 였다. 여기에는 두 가지 체크할 점이 있는데, 1) 영상 파일 형태와 2) 스트리밍 송출 방식이었다.

 

[1] 영상 파일 인코딩의 문제인가? .m3u8 & .ts

 

우리가 미리 인코딩해둔 영상은 m3u8과 ts 파일 형태로 S3에 저장되어 있다. 그런데 인코딩을 할 때, 처음에 hls_time(ts 영상 길이)을 2초로 주고 hls_size(최대 재생 목록 항목 수)를 3으로 했다. 이렇게 되니 2초짜리 영상을 3개만 읽어와 6초짜리 영상을 클라이언트에서 매번 새로 재생해버리더라. 그래서 아예 hls_size를 200으로 넣어 모든 ts 파일을 다 인덱싱하도록 지정했다.

 

이 부분에 대해 이해를 돕기 위해, 약간의 설명을 곁들인다. ffmpeg를 hls 형식으로 인코딩하면 m3u8은 인코딩 시점을 기준으로 hls_size에 해당하는 숫자만큼의 ts파일만 인덱싱한다. 예시를 들어보자. 60초짜리 영상을 2초 단위로 자른다고 하면 30개의 ts파일이 만들어질 것이다. 이때 hls_size, 그러니까 최대 재생 목록 항목 수를 3으로 한다고 해보자. 그러면  인코딩 과정에서 ts 파일이 ts001, ts002, ts003, ...이렇게 계속해서 만들어지는데 새 ts파일이 만들어지는 시점을 기준으로 m3u8에서 읽을 수 있는 ts 파일에는

 

<m3u8이 읽을 수 있는 ts 파일 번호>

ts001, ts002, ts003 -> (ts003까지 만들어졌을 때)
ts002, ts003, ts004 -> (ts004까지 만들어졌을 때)
ts003, ts004, ts005 -> (ts005까지 만들어졌을 때)
...
ts028, ts029, ts030 -> (ts030까지 만들어졌을 때)

 

이렇게 되어 인코딩이 끝난 시점에서 m3u8이 읽어오는 ts파일은 맨 끝에서 3개인 ts028, 029, 030만 읽어올 수 있게 되는 것이다.

 

더미 영상의 길이는 대략 10여분 남짓이고, 영상이 끝나면 검은 화면과 함께 다시 처음부터 틀기 위해 걸리는 로딩 시간이 있다보니 기존처럼 hls_size를 3으로 하면 6초마다 홈 화면에서 새로 영상을 틀어주느라 자주 끊기는 일이 발생했다. 이로 인해 홈 화면에서 재생되는 더미 영상의 재생 시간 자체를 늘려야 할 필요가 있어 hls_size를 200으로 둔 것이었다. 그런데 이것이 문제의 화근이지 않을까 생각했다. 우리의 가설은, "hls_size가 200이니 더미 영상 하나당 거진 200개씩 거의 1200개의 ts파일을 매번 메모리에 저장해두는 것이 문제일 것이다"였고 이를 해결해보기 위해 hls_size를 30으로 줄여 6~7분 가량 되던 영상을 1분 남짓으로 줄였다.

 

이렇게 하니 확실히 네트워크를 통해 들어오는 인풋 용량이 줄어들긴 했으나, 여전히 홈에서 로그인 화면으로 나갔다 들어오기를 반복하면 메모리 사용량이 증가했다. 사실 위의 해결책은 들어오는 인풋 용량을 줄이는 것이지, 그마저도 홈<->로그인을 반복해서 메모리에 쌓이는 문제를 해결하는 해결책은 아니었다.

 

[2] 스트리밍 송출 방식의 문제인가? 미리 인코딩해놓은 영상 송출 vs ffmpeg를 이용해 실시간 인코딩해서 송출

 

다음에 접근했던 방식은 "스트리밍 송출 방식에서 일어난 문제가 아닐까?"였다. 그 생각의 근원은 예전에 온디맨드 스트리밍을 공부할 때 봤던 도식에서였다.

 

 

위의 그림을 보면서 "엥? 미리 인코딩해두는 게 아니라 클라이언트가 영상을 요청하는 시점에 인코딩을 하는 건가?"라는 생각이 들어 뭔가 이상하다는 느낌을 지울 수가 없었다. 그런데 [1]에서 ffmpeg가 hls_size를 반영해 m3u8이 읽어오는 인덱스를 동적으로 변하게 하는 것을 보고서 "아,그래서 영상을 미리 m3u8 & ts파일로 인코딩해두는 게 아니라 소스 파일을 s3에 넣어두고 클라이언트가 영상 시청을 요청하는 시점을 트리거로 lambda와 mediaconvert가 동작해 인코딩을 진행하고 이를 송출해주는 방식이구나!" 라고 생각했다.

 

원래 HLS 프로토콜은 실시간으로 스트리밍하는 것이고 m3u8은 이를 반영해 동적으로 hls_size에 맞게 인덱스 번호를 바꾼다. 반면, 우리가 더미 영상을 송출해주는 방식은 ffmpeg로 실시간 인코딩을 해서 m3u8과 ts파일을 보내는 게 아니라 m3u8이 모든 ts 파일의 인덱스 번호를 읽도록 미리 인코딩을 해놓고 이를 전송하는 방식이었다. 그러니 "클라이언트가 요청하는 시점에 실시간으로 ffmpeg를 이용해 인코딩해서 m3u8과 ts파일을 송출하면 클라이언트 메모리에 ts파일이 쌓이지 않을 것이다"였다.

 

하지만 여전히 찜찜함은 남아있었는데, "왜 굳이 미리 인코딩을 하지 않고 요청 시점에 인코딩을 하는 거지? 매번 인코딩하는 건 아무리 생각해도 비효율적인데?"였다. 납득이 되지 않아 ffmpeg가 hls 인코딩을 어떻게 하는지에 대해 좀 더 파봤다. 그러다 중요한 개념을 하나 알게 됐는데, 이 글을 통해서였다.

 

(아래는 위 링크 글의 인용문)

더보기

VOD streaming 과 Live streaming

HLS 에는 VOD(Video on Demand), Live(Sliding window)두 가지 방식으로 스트리밍이 가능하다.

 

VOD streaming

VOD 방식부터 살펴보면, 완전한 미디어 파일을 segmenter가 잘게 쪼개놓고, 스트리밍 요청을 받으면 세그먼트 playlist 를 통으로 보내 스트리밍을 하는 방식이다. 클라이언트에서는 playlist 를 보고 재생에 필요한 파일 조각들만 시간을 두고 차례 차례 요청하여 미디어 파일을 재생한다. 이로써 거대한 파일을 한꺼번에 요청함으로써 발생하는 네트워크 과부하를 피할 수 있다.

playlist 파일 예시는 다음과 같다.

 

#EXTM3U
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-TARGETDURATION:10
#EXT-X-VERSION:4
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:10.0,
fileSequenceA.ts
#EXTINF:10.0,
fileSequenceB.ts
#EXTINF:10.0,
fileSequenceC.ts
#EXTINF:9.0,
fileSequenceD.ts
#EXT-X-ENDLIST

EXT-X-PLAYLIST-TYPE를 통해 스트리밍 타입을 알린다. 제공하는 파일이 완전하기 때문에 어느 시점에 이 미디어가 끝나는지 서버 역시 알고 있고, 어디가 끝인 지 클라이언트에게 알려주기 위해 끝나는 지점에 #EXT-X-ENDLIST 테그를 붙여 알린다.

즉, 클라이언트는 어느 시점에 접속하든 미디어 파일의 처음 지점부터 접근이 가능하다.

 


위의 글을 통해 얻은 인사이트는, 우리가 잘못 생각했다고 판단했던 방식인 "m3u8이 모든 ts 파일의 인덱스 번호를 읽도록 미리 인코딩을 해놓고 이를 전송하는 방식"은 잘못한 게 아니라 HLS에서 VOD streaming을 할 때 인코딩하는 방식과 동일하며, 따라서 이로 인해 클라이언트에 ts파일이 남을 일이 없을 가능성이 훨씬 높을 것이다였다.

 

이렇게 되면 서버에서 클라이언트로 보낼 때 문제가 일어날 일은 거의 없어보였다.

 

2) 내부(client 자체): 내부 코드 상 문제인가?

 

서버 -> 클라이언트에서 일어날 수 있는 문제를 모두 타진했으니 이제는 클라이언트 내부를 파헤쳐봤다. 가장 유력한 건 클라이언트 내부 코드 상의 문제일 것이다. 

 

다시 문제를 상기해보자. "앱에서 로그인 화면 <-> 홈 화면 사이를 반복해서 이동할 때마다 메모리 사용량이 올라간다"가 문제다. 여기서 무엇이 메모리 사용량을 올리는 주체인지를 구별해서 생각해야 한다. 이 역시 내/외부로 구분할 수 있다.

 

1) 외부: 서버로부터 온 video 파일(.m3u8 & .ts)

 

만약 네트워크를 통해 들어온 m3u8과 ts파일을 매번 메모리에 넣는 과정에서 생기는 문제라면, 캐시를 적용해보면 어떨까 생각했다. 매번 I/O를 하지 않고 메모리에 한 번 넣어놓기만 하면 그 이후로는 굳이 I/O를 하지 않아도 되니 메모리 사용량을 늘리지 않을 것이다.

 

그런데 지금 사용하고 있는 컴포넌트는 Node-Player-View로, 오직 스트리밍만을 제공하는 컴포넌트이기에 캐싱을 지원하지 않았다. 이를 위해 <NodePlayerView />를 <Video /> 컴포넌트로 변경했다.

 

<View>
      <Video
        style={styles.previewimage}
        source={{ uri: inputUrl }}
        muted
        cache
        resizeMode="cover"
      />
       {/* <NodePlayerView
        style={styles.previewimage}
        inputUrl={inputUrl}
        scaleMode="ScaleToFill"
        bufferTime={300}
        maxBufferTime={1000}
        audioEnable={false}
        autoplay
      /> */}
    </View>

 

그러고서 테스트를 해봤다.

엇! 이전과 동일한 횟수로 홈 <-> 로그인 화면을 번갈아 이동했음에도 메모리 사용량은 홈 화면에 들어올 때 올라갔다가 로그인 화면으로 나가면 곧바로 줄어드는 것을 알 수 있었다.

문제 해결! 끝!

 

...이면 좋겠지만 뭔가 찜찜함이 여전했다. 캐싱이 본질적인 문제였나? 그렇지 않다고 생각했기 때문이다. 캐싱을 하지 않았다고 메모리 사용량이 지속적으로 늘어나는 것도 말이 되지 않았다.

 

 

아니나 다를까. 캐싱 적용 전후로 큰 차이는 없었다. 결론은 이전에 사용했던 컴포넌트인 NodePlayerView와 이번에 새로 변경한 컴포넌트인 React-Native-Video의 차이에 기인했다.

 

2) 내부: video를 렌더링하는데 사용하는 컴포넌트

그렇다면 이전에 사용했던 NodePlayerView는 대체 무엇이 문제였을까. digging하다보니 재밌는 걸 하나 발견했다. 바로 unmount에 대한 내용이었다.

 

 

이전에 썼던 글에서 참조했던 이미지를 보자. 특정 페이지를 렌더링한 후, 다른 페이지로 이동할 때 unmount하는 작업이 필요하다. 이를 실행해주는 메소드가 바로 componentWillUnmount().

 

여기서 새로운 가설을 세울 수 있었다. 바로 "홈 화면에서 로그인 화면으로 이동할 때, NodePlayerView를 unmount해주지 않아 해당 컴포넌트가 힙 메모리에 계속해서 쌓여 문제가 발생할 것이다"였다. 곧바로 NodePlayerView를 렌더링하는 다른 코드를 살펴봤다.

 

<홈 화면에서 방송 접속 시 렌더링해주는 Viewer 화면에 대한 코드>

 

// src/page/viewer/index.js

(...)
componentWillUnmount() {
    if (this.nodePlayerView) this.nodePlayerView.stop();
    
(...)

 

역시 다른 페이지에서는 해당 페이지를 unmount할 때 nodePlayerView.stop()이라는 메소드를 실행해 메모리 상에서 지워주는 것을  확인했다. 반면, 홈 화면에서 다른 화면으로 이동할 때 componentWillUnmount()에서 nodePlayerView.stop() 메소드를 실행해주지 않아 마운트된 nodePlayerView가 메모리에 계속해서 쌓이던 게 원인이었다. 즉, NodePlayerView()를 쓰려면 해당 페이지를 unmount()할 때 항상 componentWillUnmount()에 stop()을 명시적으로 선언해줘야 한다.

 

반면, <video> 컴포넌트는 코드 상에서 명시적으로 선언해주지 않아도 알아서 unmount해주는 기능이 있었던 게 차이였다.

//nodePlayerView: 항상 componentWillUnmount()에 stop()을 명시적으로 선언해줘야 함

  stop() {
    UIManager.dispatchViewManagerCommand(
      findNodeHandle(this.refs[RCT_VIDEO_REF]),
      UIManager.RCTNodePlayer.Commands.stop,
      null
    );
  }

 

 

5. 회고

1) 측정 및 분석에 대한 경험 -> 성장!

 

이전 글에서 마지막에 쓴 회고를 보자.

사실 위의 글은 반쪽짜리 문제 해결 경험이다. 석사까지 했으면서 이런 글을 썼다는 건 어떤 면에서는 빵점에 가까울 수도 있다. 내가 해결한 문제를 정량적으로 측정하지 않았기 때문.
 
기존에는 렌더링에 몇 초가 걸렸고 이를 몇 초로 단축시켰다는 얘기가 없으면 위의 글은 허풍에 가까워진다. 물론, 위 문제 해결은 눈으로만 봐도 확연한 개선이 있기는 하나 수치로 증명할 수 없으면 말짱 도루묵이다. 문제는 어떻게 앱에서 렌더링 속도를 측정할 수 있는지를 모른다는 점..RNdebugger를 쓰면 된다고 얼핏 들었던 것 같아 이참에 적용해볼 생각이다.
 
명심하자. 무언가를 주장할 때는 확실하고 객관적인 근거가 있어야 한다. 다음에 문제 해결 글을 쓸 때는 이 점을 보완할 것!

 

이전의 내가 내준 숙제를 달성했다. 이전과 달리 profiler를 이용해 근거에 기반한 논리적 사고를 전개할 수 있었다. 무엇보다 '다음에는 아쉬운 점을 보완하자'고 다짐했는데 이번 글에서 이를 달성하니 기분이 좋다. 성장했다는 느낌이 든다.

 

2) 논리적 사고 & 팀워크

외부/내부로 문제가 일어날 가능성이 있는 곳들을 분류하고 하나씩 체크해나가면서 문제를 해결하는 과정은 논리적 사고에 기반한 문제 해결 과정 그 자체였다. 예전 경영학회에서 problem solving session하던 기억이 생각나더라.

 

 

위의 문제 해결 과정은 절대 혼자 한 게 아니다. 팀원 모두가 달라붙어 논리적 사고에 기반해 함께 문제를 해결해나가는 경험은 이루 말할 수 없이 재밌었다. 일주일은 걸리지 않을까 생각했는데 문제 제기한 시점으로부터 약 48시간 만에 해결한 것 역시 매우 뿌듯하다. 

 

3) 해결에 그치지 않고 파고들어 원인을 파악하기까지

위의 과정에서 NodePlayerView 대신 Video 컴포넌트를 바꾸고서 오 되네? 하고 끝냈을 수도 있었다. 단순히 "NodePlayerView보다 Video가 더 좋네~"라던지 "아, 그냥 캐싱 때문이었나보네~"하고 넘어갔을 수도 있다. 하지만 미심쩍은 부분을 남기지 않고 끝까지 파고들어 원인까지 파악해냈다. 더욱 깊이 digging하는 좋은 경험이었다.

 

반응형