안녕하세요. 아이들나라 Mobile팀 김일범입니다.
아이들나라 모바일앱은 UI가 복잡하게 구성되어 있고, 사용하는 그래픽 리소스가 많아 메모리 사용량과 큰 크기의 App 용량은 언제나 도전을 받고 있습니다.
이번에는 아이들나라 Mobile팀에서 Memory leak을 만났을 때 이를 분석하고, 해결하는 과정을 소개하며 아이들나라 Mobile팀에서 문제를 해결하는 방식을 간단하게 설명드리려고 합니다.
프로필을 변경하면 메모리가 증가한다?
아이들나라 모바일 App은 여러 개의 프로필을 생성할 수 있고, 프로필별로 학습, 추천, 시청 이력등을 분류하고 있습니다.
프로필은 사용자를 구분하고 최적의 추천, 학습 및 독서 커리큘럼 관리에 사용할 수 있어서 아이들나라 서비스의 중요한 부분을 담당하고 있습니다.
어느 날 QA팀에서 프로필 변경을 반복할 경우 App이 종료되는 현상을 발견하였고, 확인한 결과 iPhone15 Pro에서는 재현 빈도가 굉장히 낮지만, iPhone SE와 같은 저사양 단말에서는 재현 빈도가 높은 것을 확인하여 Critical issue로 판단하고 바로 수정을 위한 분석에 들어갔습니다.
메모리 사용량이 문제이다? 그런데 어디서?
일반적으로 App에서 Crash가 발생한 경우 Call stack이 남기 마련이지만, 이 경우에는 XCode에서 원인을 파악할 정도의 자세한 로그가 남지 않아 대략적인 원인만을 확인할 수 있었습니다.
iPhone15 Pro에서는 4GB의 메모리도 소화
문제가 발생했을 때의 XCode console log로 특별한 오류 로그 없이 메모리 할당이 실패한듯한 로그만 보임
위 오류와 같이 메모리 할당에 문제가 있는 것은 확인하였으나, XCode의 Memory graph에서도 leak이 발생한 부분은 없는 것을 확인하였고 다른 방법을 통해 원인을 파악하려고 시도했습니다.
프로필 변경 시 데이터가 증가하는 곳을 찾자
재현 경로와 현상을 확인하였으니 이제는 실제로 메모리가 증가하는 영역을 찾아야만 했습니다.
먼저 XCode에서 제공하고 있는 Memory graph를 통해 프로필을 변경했을 때에도 메모리 사용량이 증가하는지 확인했습니다.
첫 실행 시의 메모리 사용량
프로필을 1번 변경했을 때의 메모리 사용량
프로필을 1번 변경하니 메모리 사용량이 약 150MB가 증가하는 것을 확인하였고, 시간이 지나도 해당 메모리는 감소되지 않는 것을 확인했습니다.
XCode의 Memory Graph 기능 중 Memory leak이 발생한 instance를 확인하는 필터를 선택하였으나 아무 결과가 나오지 않는 것을 확인하였는데, 이는 XCode에서 감지할 수 있는 단순한 수준의 Memory leak 아닌 내부 구조에서 발생한 로직임을 확인할 수 있었습니다.
따라서 XCode만을 이용하여 Memory leak의 원인을 파악할 수 없었고, 결국 프로필을 변경하면서 초기화되는 로직 중 실제로 instance가 증가할만한 부분을 수동으로 찾는 방법밖에 없었습니다.
효율적으로 원인을 파악하기 위해 아래와 같은 규칙으로 instance를 조사하였습니다.
•
프로필을 변경하기 전과 변경한 후의 instance 개수를 확인한다.
•
우리가 생성한 instance가 원인일 것이므로 instance 중 3rd party library에서 생성한 instance는 제외한다.
•
프로필을 변경하고 홈화면이 초기화되는 과정에 포함된 instance가 범인일 것이다.
홈화면이 원인인가? - 첫 번째 가설
프로필을 변경 시 비교적 규칙적으로 150MB 정도 메모리가 오르는 것을 보아 분명 프로필 변경 시 진행하는 로직에 문제가 있다고 판단하였습니다.
프로필에 따른 초기화가 수행되는 코드를 분석한 결과 프로필을 변경하면 아래와 같은 순서대로 데이터가 초기화되는 것을 확인하였습니다.
•
프로필에 맞는 모드 데이터 load
•
모드에서 지원하는 메뉴 데이터 load
•
메뉴에 포함되는 홈 데이터 load
처음에는 가장 데이터가 많은 홈 영역이 문제라고 의심하였습니다. 그런데 프로필을 2번 변경한 상태여도 ViewController는 1개를 유지하고 있고, 참조하는 ViewModel에서도 프로필 변경에 따라 instance가 증가하는 것은 없었습니다.
이 중 2개의 instance를 보유한 LearningHomeViewModel은 첫 실행 시에도 2개를 생성하고 있어 이번 문제의 원인은 아닌 것으로 확인했습니다.
다른 분석으로는 지면 데이터가 원인이라면 메뉴만 변경할 경우에도 메모리가 쌓이는 문제가 발생할 것으로 추측하고 테스트를 진행하였으나 메뉴 변경 시에는 메모리가 정상적으로 해제되어 Leak이 발생하는 부분은 없었습니다.
따라서 홈 화면 혹은 여러 메뉴 선택 시 노출되는 화면은 원인이 아닌 것으로 결정되었습니다.
메뉴가 원인인가? - 2번째 가설
다음으로는 메뉴를 초기화하고 생성하는 로직을 분석하였습니다.
먼저 Memory graph를 통해 실제로 메뉴와 연관된 instance가 증가하는지를 확인하였는데요.
프로필을 2번 변경했을 때 memory graph 결과
위 데이터를 유심히 살펴보던 중 묘한 결과가 있는 것을 확인했습니다.
프로필을 2번 변경했을 때의 초기화 로직의 수를 계산해보면, 처음 실행 시 초기화, 프로필을 1번 변경 시 초기화, 프로필을 2번 변경 시 초기화로 3번의 초기화 과정을 포함하고 있습니다.
위 데이터 중 3의 배수로 떨어지는 부분이 의심되지 않나요?
3번을 초기화했을 때 3의 배수가 된다면, 1번 더 프로필을 변경하면 instance가 4의 배수로 존재하는지 확인하고 싶었습니다.
프로필을 3번 변경했을 때의 memory graph 결과
드디어 원인을 찾았습니다.
ExpandableTopMenuBar, TopBarMenuView, TopMenuLargeCell이 정확히 4의 배수로 떨어지고 있네요.
가설 검증이 완료되었으니 본격적으로 instance가 증가하는 코드들을 분석했습니다.
왜 메모리가 해제되지 않는가?
메뉴의 instance들이 4의 배수로 깔끔하게 떨어지는 것을 확인하고 종속 관계에 의해 숫자가 규칙적으로 증가하는 것으로 생각했습니다.
관련 코드를 확인하니 결국 ExpandableTopMenuBar가 가장 상위 View로 TopBarMenuView를 보유하고 있는 것을 확인했습니다.
그 다음으로는 메모리가 해제되지 않는 이유를 확인하였는데요.
일반적으로 가장 많이 하는 실수가 Closure에서 self 사용 시 weak를 사용하지 않아 self에 대한 강한 참조가 걸리는 것인 만큼, 이 부분이 원인일지도 모른다고 생각하여 모든 코드를 살펴보았습니다.
하지만 그런 부분은 전혀 없었는데요. self를 사용하는 로직은 전부 weak self 처리가 되어 있어 의심가는 부분이 없었습니다.
그래서 다시 한번 Memory graph 기능을 사용하던 중 의심가는 부분을 찾게 되었습니다.
Memory leak이 발생한 ExpandableTopMenuBar의 memory graph
Closure로 참조하는 부분이 있고, UIControlTargetAction을 포함하고 있는 부분이 있네요?
추가로 분석하기 위해 UIControlTargetAction의 memory graph를 살펴보았습니다.
UIControlTargetAction의 memory graph
위 그림을 보고 이상한 점이 느껴지지 않나요?
코드 레벨로 분석한 결과에서는 ExpandableTopMenuBar가 가장 최상위였는데, 하위 View인 TopBarModeView에서 상위 View를 참조하고 있는 부분이 있네요.
더 상위에는 ExpandableTopMenuBar가 TopBarModeView를 참조하고 있고요.
순환 참조 동작에 의해 메모리가 해제되지 않는 것으로 추측했고, 버튼 액션 이벤트와 연관이 되어 있을 것 같아 그 부분을 코드로 돌아가 다시 한번 살펴보았습니다.
위와 같이 ExpandableTopMenuBar가 하위 View인 topBarModeView의 버튼을 직접 참조하고, addAction을 사용하고 있네요.
그렇다면 addAction은 어떻게 구성되어 있는지 살펴보겠습니다.
ExpandableTopMenuBar가 하위 View인 TopBarModeView의 버튼 이벤트를 직접 관리하면서 Closure를 사용하고 있어 순환 참조가 발생하는 구조로 변경되었네요.
이로 인해 프로필 변경 시 메뉴가 초기화되면서 기존 ExpandableTopMenuBar instance는 메모리에서 해제가 되어야 하나 ExpandableTopMenuBar와 TopBarModeView가 서로 참조하고 있어서 Reference count가 0으로 떨어지지 않아 메모리가 해제되지 않는 것으로 근본 원인으로 가정했습니다.
Closure에서 Delegate 방식으로
해당 구조를 보며 가장 먼저 든 생각은 Android Compose의 State Hoisting 방식을 응용하는 것이었습니다.
이를 Swift에서는 Delegate 방식으로 표현할 수 있으며, 서로 용어가 다르지만 본래의 의미는 UI의 Click event 처리를 위임하기 위한 구조로 이해했습니다.
하위 View에서 직접 Click event를 처리하지 않고, event가 발생하면 상위 View로 event만 전달하여 상위 View에서 모든 event를 모아서 처리하는 로직을 포함하는 것입니다.
이 구조의 장점은 여러가지가 있지만 가장 중요한 부분은 관심사 분리를 통해 하위 View는 UI를 구성하는 것에만 집중하고, 상위 View는 여러 하위 View의 배치와 이벤트만 신경을 쓰는 것이었습니다.
화면 구조가 복잡한 상황에서 많은 장점을 가지며, 메뉴 화면 구성의 경우 모드와 메뉴, 검색 아이콘 등을 표현하고 있어 여러 하위 View들은 독립적으로 구성하고, 관심사 분리를 통해 확장성이 쉬운 구조로 변경하고자 했습니다.
이를 위해 기존 구조를 아래와 같이 개선하기로 하였습니다.
Closure 방식과 가장 큰 차이는 ExpandableTopMenuBar에서 TopBarModeView 생성 시 Click event 전달을 위한 Callback method를 전달하는 것입니다.
이 구조를 사용하면 TopBarModeView는 상위 View인 ExpandableTopMenuBar를 직접 참조하지 않는 구조로 변경되어 순환 참조가 발생하지 않습니다.
이 구조를 실제로 코드로는 아래와 같이 구성했습니다.
TopBarModeView는 내부 변수로 onEvent를 가지고 있고, 모든 클릭 이벤트는 onEvent 함수를 통해 전달합니다. 이를 수신하는 ExpandableTopMenuBar는 아래와 같이 구성했습니다.
topBarModeView.onEvent가 호출될 때 event를 onEvent 함수를 통해 전달하도록 구성했네요.
또한 처리하는 이벤트가 많아 효율적으로 관리하기 위해 event는 MenuUIEvent라는 enum으로 구성하여 가독성을 높였습니다.
기존에는 Callback method를 버튼마다 선언하였다면, 이제는 enum으로 관리하면서 Callback method는 1개만 유지하도록 간결화했습니다.
Android Hoisting?
위에서 구현한 구조는 Swift에서 사용하던 Delegate 패턴과 조금은 다른 모습인 것 같은데, 어디에선가 많이 본 느낌을 받을 수 있습니다.
네, 이 구조는 위에서 잠시 언급했던 Android Compose의 State Hoisting 구조입니다.
Compose에서는 흔히 사용하는 패턴으로 하위 View 생성 시 event 전달을 위한 Callback method를 전달합니다.
하위 View에서는 event가 발생할 경우 직접 처리하지 않고 Callback method를 통해 상위 View로 event를 전달하고, event 동작은 상위 View에서 수행합니다.
이를 확인하기 위해 Android 코드를 잠시 살펴보겠습니다.
하위 View
상위 View
상위 View에서는 MyCookieScreen View 생성할 때 생성자로 event callback 함수들을 전달합니다.
MyCookieScreen을 생성할 때 callback method가 호출되면 수행하는 로직을 모두 구현하고 있고, 이로 인해 MyCookieScreen은 click event를 직접 사용하지 않고, UI를 구성하는 부분에만 집중하고 있습니다.
아이들나라 Android App에서는 이 구조를 표준으로 채택하여 사용하고 있고, iOS에서도 문제를 해결하기 위해 적용했습니다.
적용 결과는??
다행스럽게도 의심하던 부분이 맞았고, 프로필을 여러 번 변경하여도 ExpandableTopMenuBar와 TopBarMenuView는 1개만 유지하고 있습니다.
실제로 ExpandableTopMenuBar deinit에 로그를 추가하여 동작을 확인하면 프로필이 변경될 때 정상적으로 로그가 출력되면서 메모리에서 ExpandableTopMenuBar가 해제되는 것을 확인했습니다.
(수정 전에는 deinit이 호출되지 않아서 실제로 메모리가 남아있는 동작도 확인했습니다.)
이로 인해 Crash 뿐만 아니라 사용 시 메모리가 증가하던 문제까지 모두 해결할 수 있었습니다.
마치며
글로 표현하면 제법 짧은 시간이었지만 실제로 원인에 대한 가설을 세우고 검증을 하던 부분은 상당한 시간을 필요로 했습니다.
Memory graph나 log를 통해 명확하게 구분되지 않는 부분으로 코드 리뷰와 로그 추가, break point, 다양한 테스트 등을 통해 원인을 찾는 과정을 반복했습니다.
많은 수의 오류들은 여러 분석 Tool이나 Log만으로는 원인을 파악할 수 없어 여러 데이터를 상황에 맞춰 분석하면서 유의미한 결과를 낼 수 있습니다.
이 오류도 XCode의 memory graph, crash log만 이용하며 분석했다면 원인을 파악할 수 없었겠지만, 범위를 좁혀가는 방법이나 여러 환경을 고정하고 확인하고 싶은 데이터만 보는 등 다양한 방법을 통해 원인을 찾았습니다.
원인인 순환 참조를 확인하고, 구조를 개선하는 것은 오랜 시간이 필요하지 않았습니다. 해당 구조를 확인하자마자 Android Compose에서 사용하고 있는 구조를 적용하면 문제가 해결될 것으로 예상했고, 이는 적중했습니다.
이렇듯이 아이들나라 모바일팀은 여러 Tool, 데이터들을 사용하여 다양한 관점에서 SW 이슈의 원인을 찾고, 특정 언어나 플랫폼에 국한하지 않고 여러 SW 원리들이나 장점들을 적용하는 것을 통해 문제를 해결하고 있습니다.