모바일 성능 프로파일링: Xcode Instruments와 Android Studio Profiler 실전 가이드

모바일

모바일 성능Xcode InstrumentsAndroid ProfileriOS 성능Android 성능

이 글은 누구를 위한 것인가

  • 앱이 느리거나 버벅거리는데 원인을 찾지 못하는 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에 성능 테스트를 포함시켜 회귀를 조기에 발견하는 것이 최선이다.