얼굴 블러 처리 앱을 만들었는데, 이상한 현상이 발생했습니다. 처음엔 빠르게 잘 돌아가다가 시간이 지날수록 점점 느려지더니, 결국 앱이 뻗어버렸어요.
로그를 찍어보니 충격적인 결과가 나왔습니다.
[프레임 0~30] 블러=9ms
[프레임 90~120] 블러=73ms
[프레임 210~240] 블러=150ms
[프레임 450~480] 블러=264ms
블러 처리 시간이 9ms에서 시작해서 264ms까지 늘어났습니다. 거의 30배 느려진 거죠.
첫 번째 용의자: RenderScript Allocation
처음엔 당연히 RenderScript가 문제라고 생각했습니다. GPU 메모리 누수가 의심스러웠거든요.
Allocation 재사용 구현
매번 Allocation을 생성하고 해제하는 대신, 캐시해서 재사용하도록 수정했습니다.
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를 루프 안에서 계속 생성하고 있나 싶어서 확인해봤습니다.
// 잘못된 코드
facesWithIntensity.forEach { (face, _) ->
val canvas = Canvas(output) // 매번 생성!
canvas.drawBitmap(faceRegion, ...)
}
// 수정한 코드
val canvas = Canvas(output) // 한 번만 생성
facesWithIntensity.forEach { (face, _) ->
canvas.drawBitmap(faceRegion, ...)
}
이것도 고쳤지만… 여전히 느려졌습니다.
진짜 범인: ghostFaces 맵
얼굴이 화면에서 사라졌다가 다시 나타날 때를 대비해서 ghostFaces라는 맵을 만들었습니다. 얼굴이 잠깐 가려지거나 옆을 봐도 블러가 유지되도록 하려고요.
// 사라진 얼굴들도 일시적으로 블러 유지
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 대신 제거할 항목을 별도 리스트에 모은 다음, 한 번에 제거하도록 바꿨습니다.
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) }
추가로 안전장치도 넣었습니다.
// 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배로 늘렸어요.
// 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프레임으로 늘렸습니다. 얼굴이 그렇게 빨리 움직이지 않으니까요.
if (frameProcessed % 8 == 0) {
frameQueue.offer(frameProcessed to downscaled)
}
성능 모니터링
최적화 효과를 확인하려면 측정이 필요합니다. 30프레임마다 각 작업별 평균 시간을 로그로 남겼어요.
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가 문제라는 걸 정확히 찾아낼 수 있었습니다.
최종 결과
| 항목 | 최적화 전 | 최적화 후 | 개선율 |
|---|---|---|---|
| 블러 시간 (초기) | 9ms | 8ms | 11% 향상 |
| 블러 시간 (안정) | 264ms | 8~100ms | 95% 향상 |
| 얼굴 감지 빈도 | 매 3프레임 | 매 8프레임 | 62% 감소 |
| 감지 해상도 | 100% | 50% | 4배 빠름 |
가장 중요한 건 시간이 지나도 성능이 일정하게 유지된다는 점입니다.
배운 교훈 by 개고생
1. Map.entries.removeAll은 생각보다 느리다
2. 컬렉션 크기를 제한하라
3. 성능 로그는 필수
4. 첫 번째 용의자가 범인이 아닐 수 있다
5. 작은 최적화도 쌓이면 크다
마치며
성능 문제는 예상치 못한 곳에서 발생합니다. ghostFaces라는 작은 맵이 전체 앱을 느리게 만들 줄은 몰랐어요.
중요한 건 측정입니다. 로그를 찍고, 프로파일링하고, 데이터를 보면서 하나씩 개선해 나가면 됩니다.
이 글이 비슷한 문제를 겪는 분들께 도움이 되길 바랍니다.
