안드로이드 비디오 앱이 점점 느려지는 이유: ghostFaces 맵이 범인이었다

얼굴 블러 처리 앱을 만들었는데, 이상한 현상이 발생했습니다. 처음엔 빠르게 잘 돌아가다가 시간이 지날수록 점점 느려지더니, 결국 앱이 뻗어버렸어요.

로그를 찍어보니 충격적인 결과가 나왔습니다.

[프레임 0~30] 블러=9ms
[프레임 90~120] 블러=73ms
[프레임 210~240] 블러=150ms
[프레임 450~480] 블러=264ms

블러 처리 시간이 9ms에서 시작해서 264ms까지 늘어났습니다. 거의 30배 느려진 거죠.

첫 번째 용의자: RenderScript Allocation

처음엔 당연히 RenderScript가 문제라고 생각했습니다. GPU 메모리 누수가 의심스러웠거든요.

Allocation 재사용 구현

매번 Allocation을 생성하고 해제하는 대신, 캐시해서 재사용하도록 수정했습니다.

Kotlin
private var cachedInput: Allocation? = null
private var cachedOutput: Allocation? = null

// 크기가 같으면 재사용
if (cachedInput == null || cachedWidth != w || cachedHeight != h) {
    cachedInput?.destroy()
    cachedOutput?.destroy()
    cachedInput = Allocation.createFromBitmap(renderScript, faceRegion)
    cachedOutput = Allocation.createTyped(renderScript, cachedInput!!.type)
} else {
    cachedInput!!.copyFrom(faceRegion)
}

결과는? 여전히 느려졌습니다. 조금 나아지긴 했지만 근본적인 해결책은 아니었어요.

두 번째 용의자: Canvas 중복 생성

혹시 Canvas를 루프 안에서 계속 생성하고 있나 싶어서 확인해봤습니다.

Kotlin
// 잘못된 코드
facesWithIntensity.forEach { (face, _) ->
    val canvas = Canvas(output)  // 매번 생성!
    canvas.drawBitmap(faceRegion, ...)
}

// 수정한 코드
val canvas = Canvas(output)  // 한 번만 생성
facesWithIntensity.forEach { (face, _) ->
    canvas.drawBitmap(faceRegion, ...)
}

이것도 고쳤지만… 여전히 느려졌습니다.

진짜 범인: ghostFaces 맵

얼굴이 화면에서 사라졌다가 다시 나타날 때를 대비해서 ghostFaces라는 맵을 만들었습니다. 얼굴이 잠깐 가려지거나 옆을 봐도 블러가 유지되도록 하려고요.

Kotlin
// 사라진 얼굴들도 일시적으로 블러 유지
ghostFaces.entries.removeAll { (faceId, rectCounter) ->
    val (ghostRect, counter) = rectCounter
    
    val isNearCurrentFace = currentFaces.any { ... }
    val newCounter = if (isNearCurrentFace) 0 else counter + 1
    
    if (newCounter > 60) {
        true  // 60프레임 후 제거
    } else {
        activeFaces.add(ghostRect to intensity)
        ghostFaces[faceId] = ghostRect to newCounter
        false
    }
}

문제는 Map.entries.removeAll이었습니다. 이 메서드는 내부적으로 매번 전체 맵을 순회하면서 조건을 체크합니다. ghostFaces 맵이 커질수록 점점 느려지는 거죠.

더 큰 문제는 제거 조건이 까다로워서 맵이 계속 커진다는 점이었습니다. 얼굴이 여러 번 나타났다 사라지면 ghostFaces에 수십 개의 항목이 쌓이고, 매 프레임마다 이걸 전부 순회하니 느려질 수밖에 없었어요.

해결책: 별도 리스트로 수집 후 제거

removeAll 대신 제거할 항목을 별도 리스트에 모은 다음, 한 번에 제거하도록 바꿨습니다.

Kotlin
val toRemove = mutableListOf<String>()

ghostFaces.forEach { (faceId, rectCounter) ->
    val (ghostRect, counter) = rectCounter
    
    val isNearCurrentFace = currentFaces.any { ... }
    val newCounter = if (isNearCurrentFace) 0 else counter + 1
    
    if (newCounter > 60) {
        toRemove.add(faceId)  // 제거 목록에 추가
    } else {
        activeFaces.add(ghostRect to intensity)
        ghostFaces[faceId] = ghostRect to newCounter
    }
}

// 한 번에 제거
toRemove.forEach { ghostFaces.remove(it) }

추가로 안전장치도 넣었습니다.

Kotlin
// 50프레임마다 맵이 너무 크면 강제 정리
if (frameProcessed % 50 == 0 && ghostFaces.size > 10) {
    ghostFaces.clear()
}

결과: 드디어 해결!

이 수정 후 블러 시간이 안정화되었습니다.

[프레임 0~30] 블러=11ms
[프레임 30~60] 블러=30ms
[프레임 60~90] 블러=52ms
[프레임 90~120] 블러=71ms
[프레임 120~150] 블러=95ms
[프레임 150~180] 블러=20ms
[프레임 180~210] 블러=37ms
이후 100ms 이하 유지.

더 이상 시간이 증가하지 않습니다. 8~100ms 사이에서 안정적으로 유지되고 있어요.

추가 최적화: 얼굴 감지

ghostFaces 문제를 해결한 김에 다른 부분도 최적화했습니다.

다운스케일 감지

1080p 영상에서 얼굴을 찾는 건 과한 감이 있습니다. 절반 크기로 줄여서 감지하고, 좌표만 2배로 늘렸어요.

Kotlin
// 50% 크기로 다운스케일
val detectW = (bmp.width * 0.5f).toInt()
val detectH = (bmp.height * 0.5f).toInt()
val downscaled = Bitmap.createScaledBitmap(bmp, detectW, detectH, false)

// 감지 후 좌표 스케일업
val scaledFaces = rawFaces.map { (rect, conf) ->
    Rect(rect.left * 2, rect.top * 2, rect.right * 2, rect.bottom * 2) to conf
}

결과: 얼굴 감지 속도 4배 향상

감지 간격 확대

매 3프레임마다 감지하던 걸 8프레임으로 늘렸습니다. 얼굴이 그렇게 빨리 움직이지 않으니까요.

Kotlin
if (frameProcessed % 8 == 0) {
    frameQueue.offer(frameProcessed to downscaled)
}

성능 모니터링

최적화 효과를 확인하려면 측정이 필요합니다. 30프레임마다 각 작업별 평균 시간을 로그로 남겼어요.

Kotlin
private data class PerformanceMetrics(
    var yuvConversionMs: Long = 0,
    var blurProcessingMs: Long = 0,
    var surfaceRenderMs: Long = 0,
    var encoderMs: Long = 0,
    var frameCount: Int = 0
)

// 30프레임마다 출력
Log.d("VideoPerf", "[프레임 $start~$end] YUV=${avgYuv}ms, 블러=${avgBlur}ms, 렌더=${avgRender}ms")

이 로그 덕분에 ghostFaces가 문제라는 걸 정확히 찾아낼 수 있었습니다.

최종 결과

항목최적화 전최적화 후개선율
블러 시간 (초기)9ms8ms11% 향상
블러 시간 (안정)264ms8~100ms95% 향상
얼굴 감지 빈도매 3프레임매 8프레임62% 감소
감지 해상도100%50%4배 빠름

가장 중요한 건 시간이 지나도 성능이 일정하게 유지된다는 점입니다.

배운 교훈 by 개고생

1. Map.entries.removeAll은 생각보다 느리다

2. 컬렉션 크기를 제한하라

3. 성능 로그는 필수

4. 첫 번째 용의자가 범인이 아닐 수 있다

5. 작은 최적화도 쌓이면 크다

마치며

성능 문제는 예상치 못한 곳에서 발생합니다. ghostFaces라는 작은 맵이 전체 앱을 느리게 만들 줄은 몰랐어요.

중요한 건 측정입니다. 로그를 찍고, 프로파일링하고, 데이터를 보면서 하나씩 개선해 나가면 됩니다.

이 글이 비슷한 문제를 겪는 분들께 도움이 되길 바랍니다.