Development Log
U+모바일tv 화면 전환 반복 종료: MobX autorun 메모리 누수 개선.
U+모바일tv WebView에서 화면 전환 반복 시 종료되던 문제를 MobX autorun disposer 누락으로 좁히고 생명주기에 맞게 정리한 사례입니다.
- #Performance
- #Incident
한눈에 보기 U+모바일tv WebView에서 홈 → 상세 → 홈 → 상세 화면 전환을 반복하면 앱이 종료되는 현상이 있었다. 화면마다 새로 등록된 MobX
autorun이 unmount 시 해제되지 않는 구조를 주요 원인으로 좁혔고,autorun이 반환하는 disposer를 보관해componentWillUnmount에서 호출하도록 수정했다. 운영 서비스는 종료되어 당시 기기의 메모리 원본 로그를 다시 확보할 수 없었다. 대신 실제 운영 커밋과 동일한 MobX 4.9.2 환경을 별도로 구성해, 1,000회 mount·unmount 후 잔존 reaction 1,000개 → 0개, 상태 변경 callback 1,000회 → 0회를 확인했다.
- 프로젝트: U+모바일tv
- 환경: Next.js 6.1.1 / React 16.4.1 / MobX 4.9.2 / mobx-react 5.2.3 / Native WebView Bridge
- 운영 수정: 2022-10-07
- 근거: Git 변경 이력·현재 소스·동일 버전 독립 재현
- 구분: 이 문서는 브라우저 WebView의 컴포넌트 생명주기 문제를 다룬다. 차세대 U+tv모바일 SSR 서버 OOM 분석과는 별개의 사례다.
1. 장애 현상
사용자가 U+모바일tv 앱에서 콘텐츠를 탐색하며 아래 동선을 반복하면 일정 시점에 앱이 종료됐다.
홈 진입 → 콘텐츠 상세 진입 → 홈 복귀 → 다른 상세 진입 → 반복 → 앱 종료 한 번의 화면 진입에서는 문제가 바로 드러나지 않았다. 하지만 홈에는 여러 콘텐츠 패널과 썸네일이 있고, 상세에도 회차·추천 콘텐츠 등 다수의 콘텐츠 아이템이 렌더링된다. 화면을 오갈 때마다 공통 컴포넌트가 대량으로 mount·unmount되므로, 해제되지 않은 구독이나 반응 함수가 있다면 반복 횟수에 비례해 참조가 쌓일 수 있는 구조였다. 사실관계의 범위 당시의 실제 앱 종료 현상과 수정 커밋은 확인할 수 있지만, 페이드아웃된 서비스라 운영 단말의 crash dump와 전·후 메모리 시계열은 현재 남아 있지 않다. 따라서 아래 수치는 운영 성과를 소급 계산한 값이 아니라, 운영 당시 코드를 동일 라이브러리 버전으로 독립 재현한 검증 결과다.
2. 원인 가설: 화면은 사라졌지만 reaction은 남았다
문제의 중심에는 성인 콘텐츠 잠금 상태를 표시하는 공통 컴포넌트 OverlayAdult가 있었다. 현재 저장소 기준으로도 홈 패널, 상세·회차·추천 콘텐츠, 검색·찜·구매 내역 등 60개 TypeScript 파일에서 이 컴포넌트를 참조한다. 화면 전환 때 생성·해제되는 빈도가 높은 코드다.
기존 구현은 componentDidMount에서 MobX autorun을 등록했지만, 반환되는 disposer를 보관하지 않았다.
public componentDidMount() {
setTimeout(() => {
this.isLocked = checkLocked(false, this.props.isAdult);
}, 0);
autorun(() => {
this.isLocked = checkLocked(false, this.props.isAdult);
});
}
MobX의 autorun은 관찰 중인 값이 바뀔 때마다 다시 실행되는 reaction을 만든다. React 컴포넌트가 unmount되더라도 disposer를 호출하지 않으면, 장수명 store에 연결된 reaction이 컴포넌트의 클로저와 상태를 계속 참조할 수 있다.
즉, 사용자에게 보인 결과는 “앱이 갑자기 죽는다”였지만 코드 관점에서는 다음 순서로 설명할 수 있었다.
- 홈·상세의 콘텐츠 아이템마다
OverlayAdult가 mount되며autorun등록 - 화면을 벗어나 컴포넌트는 unmount
- disposer가 없어 이전 reaction이 남을 수 있음
- 화면 재진입 때 새로운 reaction 추가
- 반복 전환에 따라 reaction·클로저·관련 객체가 누적되고 WebView의 메모리 압박 증가
3. 원인 추적 과정
한 번에 결론을 단정하지 않고 autorun이 사용된 범위를 줄여가며 가설을 확인했다.
| 날짜 | 커밋 | 확인 내용 | 판단 |
| 2022-09-29 | `780270b2`, `1fe3c13e` | `OverlayAdult`의 autorun 제거 실험 | 공통 썸네일 컴포넌트의 반응 함수가 누적에 관여하는지 확인 |
| 2022-09-30 | `13f48a39` | `Store.tsx`, `Toast.tsx` 등 여러 autorun을 넓게 제거 | 영향 범위를 넓혀 가설 탐색 |
| 2022-09-30 | `0371b8f2`, `be364b5d` | 광범위 제거를 되돌림 | 정상 동작에 필요한 반응까지 제거하는 방식은 배제 |
| 2022-10-04 | `78df86cb` | autorun 제거 추가 테스트 | 원인 후보 재확인 |
| 2022-10-07 | `e52204a5` | `OverlayAdult`에 disposer 보관·unmount 정리 적용 | 기능은 유지하면서 생명주기만 바로잡는 최종 방식 |
| 2022-10-07 | `6c6b7932` | 릴리스 브랜치에 동일 변경 반영 | 운영 반영 이력 |
| 2022-10-20 | `RELEASE-221020` | 위 변경을 포함한 릴리스 태그 | 릴리스 포함 확인 |
이 과정의 핵심은 반응형 로직 자체를 없애는 것이 아니라, 화면 생명주기에 맞게 등록과 해제를 대칭으로 만드는 것이었다.
4. 실제 운영 코드 수정
autorun이 반환한 disposer를 인스턴스 필드에 저장하고, 컴포넌트가 사라질 때 실행하도록 변경했다.
@observer
class OverlayAdult extends Component<PropsType> {
private autorunDisposer: () => void;
@observable private isLocked: boolean = false;
public componentDidMount() {
setTimeout(() => {
this.isLocked = checkLocked(false, this.props.isAdult);
}, 0);
this.autorunDisposer = autorun(() => {
this.isLocked = checkLocked(false, this.props.isAdult);
});
}
public componentWillUnmount() {
this.autorunDisposer();
}
}
- 실제 파일:
frontend/components/common/OverlayAdult.tsx - 저장소:
/Users/gimdongmin/project/mobiletv - 주요 커밋:
e52204a52e636908196cec1471f9accbcfc92a5d - 변경 규모: 1개 파일, 8줄 추가·2줄 수정
- 현재 코드: disposer와 함께 Native Bridge 이벤트 구독도
unsubscribe()하도록 정리되어 있어 같은 생명주기 원칙을 유지한다. 작은 코드 변경이지만 공통 컴포넌트의 사용 범위가 넓었기 때문에, 화면 전환을 반복할수록 커지던 누적 효과를 차단하는 지점이었다.
5. 운영 종료 후 독립 재현
운영 환경을 다시 실행할 수 없어서, 이력서와 면접에서 설명 가능한 근거를 만들기 위해 실제 패턴만 분리한 재현 프로젝트를 구성했다.
- 재현 경로:
/Users/gimdongmin/project/mobiletv-memory-repro - MobX 버전: 운영 코드와 동일한 4.9.2
- 비교 조건: disposer 미적용(Before) / unmount 시 disposer 적용(After)
- 부하 조건: 한 주기당 객체 100개 × 10주기 = 총 1,000회 mount·unmount
- 관측 도구: Chrome DevTools Performance의 Memory 그래프, 페이지 내부 reaction·callback 계수
- 관찰 방법: 동일 동작 완료 후 강제 GC를 수행해 회수되지 않고 남는 Heap을 비교
재현에서는 누적 차이를 짧은 시간 안에 시각화하기 위해 객체당 모의 데이터를 의도적으로 크게 구성했다. 따라서 104MB → 4MB를 운영 당시 앱의 개선 수치로 사용해서는 안 된다. 이 테스트가 검증하는 것은 “disposer 유무에 따라 reaction과 참조가 남는가, GC 이후 회수되는가”이다.
6. 측정 결과
6.1 원본 Chrome DevTools 캡처
아래 두 이미지는 별도 재현 환경에서 사용자가 직접 기록한 Chrome DevTools Performance·Memory 원본 캡처다. 페이지 폭에 맞게 표시되며, 이미지를 열면 큰 화면으로 확인할 수 있다.
Before — disposer 미적용
- 주소:
?mode=before - JS Heap: 약 1.6MB에서 104MB까지 계단식 증가
- 반복 동작과 GC 이후에도 높은 Heap이 남는 패턴 확인
After — unmount 시 disposer 적용
- 주소:
?mode=after - JS Heap: 약 1.6MB에서 최대 4.0MB 범위
- 반복 주기 중 일시 증가하지만 GC 이후 초기 기준선으로 회수
6.2 비교 요약
| 관측 항목 | Before: disposer 미적용 | After: disposer 적용 |
| Documents | 1 → 1 | 1 → 1 |
| JS Heap | 약 1.6MB → 104MB | 약 1.6MB → 최대 4.0MB |
| 테스트 종료·GC 후 Heap | 약 104MB가 남음 | 초기 기준선 약 1.6MB로 회수 |
| 잔존 active reaction | 1,000개 | 0개 |
| observable 변경 시 callback | 1,000회 | 0회 |
독립 재현에서 disposer 적용 후 관측된 최대 Heap은 약 104MB에서 4MB로 낮아졌다. 수치상 약 96% 차이지만, 이 비율은 합성 부하 환경에서 누적 여부를 검증한 값이다. 더 중요한 결과는 다음 두 가지다.
- 컴포넌트가 모두 unmount된 뒤에도 남아 있던 reaction이 1,000개에서 0개로 정리됐다.
- disposer 적용 후에는 주기 중 Heap이 일시적으로 증가하더라도 GC 뒤 초기 기준선으로 돌아왔다. DevTools의 Nodes·Listeners·INP 값은 React 개발 모드, DevTools 자체, 테스트 버튼 이벤트의 영향을 받을 수 있어 판단 근거에서 제외했다. 이 사례의 주 지표는 JS Heap 회수 여부와 잔존 reaction·callback 수다.
7. 결론
실제 운영 이력과 독립 재현을 함께 보면 다음 연결이 성립한다.
반복 화면 전환 중 앱 종료 → 홈·상세에서 대량 mount되는 공통 컴포넌트 확인 → disposer가 없는 MobX autorun 발견 → 제거·복원 실험으로 영향 범위 축소 → unmount cleanup 적용 → 동일 버전 재현에서 reaction·callback 잔존이 0으로 정리되고 Heap 회수 확인 운영 당시 단말의 원본 메모리 그래프가 남아 있지 않으므로 “운영 메모리를 96% 개선했다”라고 주장하지 않는다. 대신 실제 수정 코드가 어떤 참조 누적을 차단했는지, 그 메커니즘이 동일 조건에서 재현되는지까지 증명할 수 있다.
8. 재발 방지 원칙
- MobX
autorun·reaction등록 시 반환 disposer를 반드시 보관하고 unmount에서 호출 - RxJS
subscribe는unsubscribe, DOM·Native Bridge listener는 대응되는 remove 처리 - 공통 컴포넌트는 단일 인스턴스보다 한 화면에 몇 개가 생성되고 화면 전환마다 몇 번 반복되는지를 기준으로 영향 범위 판단
- 메모리 문제는 단순 peak보다 동일 시나리오 반복 후 강제 GC를 거친 retained heap으로 비교
- 리뷰 시 “등록 코드”와 “해제 코드”가 같은 변경 안에 존재하는지 확인