이 글은 누구를 위한 것인가
- 앱이 느리거나 버벅거리는데 원인을 찾지 못하는 iOS/Android 개발자
- 메모리 경고(memory warning)나 OOM 크래시를 경험하는 팀
- 프로파일링 도구를 알고는 있지만 제대로 활용하지 못하는 개발자
들어가며
"느린 것 같다"는 느낌과 "어디서 느린가"를 아는 것은 완전히 다르다. 추측으로 최적화하면 시간을 낭비하고 문제도 해결하지 못한다. 프로파일링이 먼저다.
iOS와 Android 모두 강력한 프로파일링 도구를 제공하는데, 대부분의 개발자가 기본적인 사용법만 알고 있다. 이 글에서는 실제 병목을 찾는 워크플로우를 단계별로 다룬다.
이 글은 bluefoxdev.kr의 모바일 앱 성능 최적화 가이드 를 참고하고, 프로파일링 실전 관점에서 확장하여 작성했습니다.
1. 프로파일링 기본 원칙
[프로파일링 워크플로우]
증상 관찰 → 가설 수립 → 프로파일링 → 데이터 분석 → 최적화 → 검증
중요 원칙:
✅ 실제 기기에서 프로파일링 (시뮬레이터는 부정확)
✅ Release 빌드 기준 (Debug는 최적화 없음)
✅ 재현 가능한 시나리오 정의
✅ 수정 전후 수치 비교
2. Xcode Instruments (iOS)
2.1 Time Profiler - CPU 병목 찾기
실행 방법:
Xcode → Product → Profile (⌘I) → Time Profiler
주요 지표:
- Call Tree: 어떤 함수가 CPU 시간을 가장 많이 사용하는가
- 샘플링 간격: 1ms (기본값)
- Weight: 총 실행 시간 중 해당 함수의 비중
Call Tree 해석:
Self % Symbol
45.2% -[ImageProcessor processImage:] ← 직접 소비
│ 32.1% vImageScale_ARGB8888 ← 자식 호출
│ 13.1% CGBitmapContextCreate ← 자식 호출
22.1% -[TableViewController reload:]
│ 18.0% ...
- **Self %**가 높으면 해당 함수 자체가 느린 것
- **Total %**만 높으면 자식 함수 중 하나가 느린 것
주요 발견 패턴:
// ❌ 문제: 메인 스레드에서 이미지 처리
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let image = ImageProcessor.processImage(rawData[indexPath.row]) // 느림!
cell.imageView.image = image
return cell
}
// ✅ 해결: 백그라운드 스레드에서 처리
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
Task.detached(priority: .userInitiated) {
let image = ImageProcessor.processImage(rawData[indexPath.row])
await MainActor.run { cell.imageView.image = image }
}
return cell
}
2.2 Allocations - 메모리 누수 찾기
실행 방법:
Instruments → Allocations
+ Leaks 템플릿 함께 사용 권장
주요 지표:
- All Heap & Anonymous VM: 전체 힙 메모리
- Persistent: 현재 살아있는 객체
- # Persistent: 살아있는 객체 수
- Transient: 해제된 객체 (높을수록 할당/해제 반복)
순환 참조 탐지:
// ❌ 강한 참조 순환으로 메모리 누수
class ViewController: UIViewController {
var viewModel: ViewModel?
func setup() {
viewModel = ViewModel()
viewModel?.onUpdate = {
self.updateUI() // self를 강하게 참조!
}
}
}
// ✅ weak self로 순환 참조 해제
viewModel?.onUpdate = { [weak self] in
self?.updateUI()
}
2.3 Core Animation - 렌더링 병목
60fps = 16.67ms per frame
120fps (ProMotion) = 8.33ms per frame
Instruments → Core Animation
- FPS: 초당 프레임 수 (60fps 목표)
- Commit Transaction: 레이어 변경 커밋 시간
- 빨간 막대: 드랍된 프레임
오프스크린 렌더링 감지:
// Xcode Simulator > Debug > Color Offscreen-Rendered 활성화
// 오프스크린 렌더링 레이어가 노란색으로 표시됨
// ❌ 오프스크린 렌더링 유발
layer.cornerRadius = 10
layer.masksToBounds = true // 비트맵 오프스크린 렌더링
// ✅ 더 효율적인 방법
if #available(iOS 15, *) {
view.clipsToBounds = true
// 시스템이 최적화된 방법 사용
}
3. Android Studio Profiler
3.1 CPU Profiler
실행: View → Tool Windows → Profiler → CPU
모드:
- Sample Java/Kotlin Methods: 가벼운 샘플링 프로파일링
- Trace Java/Kotlin Methods: 정확하지만 오버헤드 큼
- Sample C/C++ Functions: Native 코드 프로파일링
Flame Chart 해석:
[메인 스레드]
─────────────────────────────────────────
onMeasure (RecyclerView)
└── measureChildren
└── onMeasure (ItemView) ← 좁을수록 빠름
└── measureText ██████ ← 넓을수록 느림
─────────────────────────────────────────
ANR 원인 찾기:
// ❌ 메인 스레드에서 네트워크/DB 호출 → ANR 유발
fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val data = database.getAllItems() // 블로킹 호출!
adapter.submitList(data)
}
// ✅ Coroutine으로 백그라운드 실행
fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launch {
val data = withContext(Dispatchers.IO) { database.getAllItems() }
adapter.submitList(data)
}
}
3.2 Memory Profiler
실행: Profiler → Memory
주요 뷰:
- 타임라인: 메모리 사용량 추이
- Heap Dump: 현재 힙 스냅샷
- Allocation Record: 특정 구간 할당 추적
메모리 누수 탐지:
// ❌ Activity context를 static 필드에 저장 → 누수
companion object {
var context: Context? = null // Activity 참조 유지됨
}
// ❌ Activity를 참조하는 리스너 등록 후 해제 안 함
class MainActivity : AppCompatActivity() {
override fun onCreate(...) {
SensorManager.registerListener(this, sensor, SENSOR_DELAY)
// onDestroy에서 해제 안 하면 누수
}
override fun onDestroy() {
super.onDestroy()
SensorManager.unregisterListener(this) // 반드시 해제
}
}
LeakCanary 연동:
// build.gradle.kts
dependencies {
debugImplementation("com.squareup.leakcanary:leakcanary-android:2.13")
}
// 자동으로 Activity, Fragment, ViewModel 메모리 누수 탐지
// 누수 발생 시 알림 + 전체 참조 체인 표시
3.3 네트워크 Profiler
// OkHttp 연동으로 네트워크 프로파일링
val client = OkHttpClient.Builder()
.addNetworkInterceptor(
HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
)
.build()
// Profiler에서 확인:
// - Request/Response 타이밍
// - 헤더 크기
// - 페이로드 크기
// - Connection 재사용 여부
4. 렌더링 성능 비교 분석
[프레임 드랍 원인 분류]
iOS (Instruments > Core Animation):
- Layout: Auto Layout 계산 복잡도
- Display: drawRect, CGContext 드로잉
- Prepare: 이미지 디코딩, 레이어 준비
- Commit: 렌더 서버에 레이어 트리 전달
Android (GPU Rendering 막대 그래프):
- Input Handling: 입력 이벤트 처리
- Animation: 애니메이션 계산
- Measure/Layout: 뷰 측정/배치
- Draw: Canvas 명령 기록
- Sync & Upload: GPU 동기화
- Issue Commands: GPU 렌더링 명령
- Swap Buffers: 버퍼 교환
5. 프로파일링 자동화
5.1 iOS - XCTest Performance
class PerformanceTests: XCTestCase {
func testImageProcessingPerformance() {
let testImage = UIImage(named: "test_large")!
measure(metrics: [
XCTClockMetric(),
XCTMemoryMetric(),
XCTCPUMetric()
]) {
_ = ImageProcessor.processImage(testImage)
}
}
}
5.2 Android - Macrobenchmark
@RunWith(AndroidJUnit4::class)
class ScrollingBenchmark {
@get:Rule
val benchmarkRule = MacrobenchmarkRule()
@Test
fun scrollList() = benchmarkRule.measureRepeated(
packageName = "com.example.app",
metrics = listOf(FrameTimingMetric()),
iterations = 5,
setupBlock = { startActivity() }
) {
val recycler = device.findObject(By.res("recyclerView"))
repeat(5) { recycler.fling(Direction.DOWN) }
}
}
마무리: 성능 진단 체크리스트
배포 전 성능 점검:
iOS:
□ Time Profiler: 메인 스레드 CPU 90% 미만
□ Allocations: 메모리 누수 0건
□ Core Animation: 60fps 유지 (스크롤 중)
□ Network: 이미지 캐시 히트율 > 90%
Android:
□ CPU Profiler: UI 스레드 프레임 16ms 이내
□ Memory Profiler: GC 이벤트 과다 없음
□ GPU Rendering: 대부분 바 16ms 선 이하
□ LeakCanary: 누수 0건
프로파일링은 배포 전 한 번만 하는 것이 아니다. CI에 성능 테스트를 포함시켜 회귀를 조기에 발견하는 것이 최선이다.