최근 고성능 머신비전 프로젝트를 위해 안드로이드 기반의 엣지 디바이스를 세팅하던 중, 심각한 성능 저하 이슈에 직면했습니다. 스냅드래곤 기반의 레퍼런스 보드에서 4K 스트림을 처리하는데, 하드웨어 스펙상 충분함에도 불구하고 프리뷰 화면이 간헐적으로 끊기거나(Stuttering), 캡처 요청(Request)이 큐(Queue)에 쌓이다가 결국 OOM(Out of Memory)으로 이어지는 현상이었습니다.
단순히 앱 레벨의 최적화 문제라고 생각했지만, AOSP(Android Open Source Project) 소스 코드를 열어 프레임워크 단을 디버깅하면서 문제는 훨씬 깊은 곳, 즉 Camera2 API와 HAL3(Hardware Abstraction Layer v3) 사이의 버퍼 관리 정책에 있다는 것을 알게 되었습니다. 이 글에서는 안드로이드 카메라 서브시스템의 아키텍처를 분석하고, 고성능 카메라 앱이나 커스텀 ROM 개발 시 반드시 알아야 할 파이프라인 최적화 전략을 공유합니다.
Camera2 API와 HAL3 아키텍처의 오해
과거 Camera API(deprecated)는 단순한 블랙박스 모델이었습니다. 앱이 "촬영해"라고 명령하면 시스템이 알아서 결과물을 던져주는 방식이었죠. 하지만 Android 5.0 롤리팝부터 도입된 Camera2 API와 HAL3는 근본적으로 다른 "요청 기반(Request-based)" 파이프라인을 사용합니다.
당시 제가 작업하던 환경은 Android 14 기반의 커스텀 빌드였고, 하드웨어 가속을 위해 Vendor 파티션을 직접 수정해야 했습니다. 문제는 앱이 프레임워크에 요청을 보내는 속도와, HAL이 이미지 센서로부터 데이터를 받아 처리(ISP)하는 속도 간의 비동기적 불일치에서 발생했습니다.
로그를 분석해보니 CameraService에서 HAL로 내려가는 요청 큐는 가득 차 있는데, HAL에서 처리 완료된 결과(Result)가 리턴되지 않고 있었습니다. 이는 전형적인 파이프라인 병목 현상입니다.
실패한 접근: 동기식 처리와 레거시 사고방식
초기 디버깅 단계에서 저는 ImageReader의 콜백 처리 로직이 너무 무거워서 발생한 문제라고 판단했습니다. 그래서 이미지 처리 로직을 별도 스레드로 분리하고, acquireLatestImage() 대신 acquireNextImage()를 사용하여 모든 프레임을 놓치지 않고 처리하려 했습니다.
하지만 이 접근은 상황을 악화시켰습니다. HAL3 파이프라인은 'In-flight(처리 중인)' 요청의 개수 제한이 있는데(보통 장치마다 다름, Pixels의 경우 약 8~10개), 앱이 이미지를 제때 close() 하여 버퍼를 반환하지 않으면 HAL은 더 이상 새로운 프레임을 찍을 빈 버퍼(Empty Buffer)를 확보하지 못해 전체 파이프라인을 멈춰버립니다(Stall). 즉, 모든 프레임을 처리하려는 욕심이 파이프라인 전체의 데드락을 유발한 것입니다.
솔루션: 비동기 파이프라인 최적화 및 AOSP 튜닝
문제를 해결하기 위해 두 가지 계층에서 접근했습니다. 첫째, 앱 레벨에서는 Double-buffering 전략을 최적화했고, 둘째, 시스템 레벨에서는 AOSP의 CameraDevice.cpp 설정을 참조하여 HAL 설정을 튜닝했습니다. 수정 후 변경된 HAL 구성을 적용하기 위해 수차례 vendor.img를 빌드하고 fastboot 명령어로 플래싱하는 과정을 거쳐야 했습니다.
아래는 앱 레벨에서 백그라운드 핸들러를 사용하여 고속으로 들어오는 프레임 버퍼를 병목 없이 처리하고, HAL에 즉시 버퍼를 반환하도록 구성한 핵심 코드입니다.
// Camera2BasicFragment.java 예시
// UI 스레드가 아닌 별도의 백그라운드 스레드에서 카메라 콜백을 처리해야 함
private CameraCaptureSession.CaptureCallback mCaptureCallback = new CameraCaptureSession.CaptureCallback() {
@Override
public void onCaptureCompleted(@NonNull CameraCaptureSession session,
@NonNull CaptureRequest request,
@NonNull TotalCaptureResult result) {
// 프레임 메타데이터 처리가 끝나는 즉시 로직 수행
processCaptureResult(result);
}
};
// ImageReader 설정 시 반드시 백그라운드 핸들러를 주입
private void createCameraPreviewSession() {
try {
// maxImages를 2~3으로 설정하여 HAL이 사용할 버퍼 여유분을 확보
mImageReader = ImageReader.newInstance(width, height, ImageFormat.YUV_420_888, 3);
// Critical: UI 스레드가 아닌 mBackgroundHandler 사용
mImageReader.setOnImageAvailableListener(mOnImageAvailableListener, mBackgroundHandler);
SurfaceTexture texture = mTextureView.getSurfaceTexture();
texture.setDefaultBufferSize(previewSize.getWidth(), previewSize.getHeight());
Surface surface = new Surface(texture);
// ... CaptureRequest.Builder 설정 ...
// 세션 생성
mCameraDevice.createCaptureSession(Arrays.asList(surface, mImageReader.getSurface()),
new CameraCaptureSession.StateCallback() {
@Override
public void onConfigured(@NonNull CameraCaptureSession cameraCaptureSession) {
// 세션이 준비되면 반복 요청(Repeating Request) 시작
updatePreview();
}
// ... 에러 처리
}, mBackgroundHandler); // 여기서도 핸들러 지정 필수
} catch (CameraAccessException e) {
e.printStackTrace();
}
}
위 코드에서 가장 중요한 부분은 createCaptureSession과 setOnImageAvailableListener에 mBackgroundHandler를 전달하는 것입니다. 많은 예제들이 이를 간과하고 메인 스레드(null)를 넘기는데, 고해상도 처리 시 UI 렌더링과 충돌하여 프레임 드랍의 주범이 됩니다.
시스템 레벨: fastboot를 활용한 Vendor 튜닝
앱 최적화만으로는 하드웨어 한계를 넘기 힘들 때가 있습니다. 이 경우 커스텀 펌웨어 개발자라면 HAL 설정을 확인해야 합니다. 저는 AOSP 소스 트리 내의 device/vendor/name/camera/BoardConfig.mk 등을 수정하여 카메라 힙(Heap) 메모리 사이즈를 늘리고, vendor 파티션을 리빌드했습니다.
수정된 이미지를 테스트하기 위해 다음 명령어를 반복적으로 사용했습니다:
# 부트로더 모드 진입
adb reboot bootloader
# 수정된 벤더 이미지 플래싱
fastboot flash vendor vendor.img
# 재부팅
fastboot reboot
이 과정은 하드웨어 제조사가 제공하는 바이너리 블롭(Binary Blob)과 오픈 소스 HAL 래퍼(Wrapper) 간의 호환성을 검증하는 데 필수적입니다.
| 지표 (Metric) | 최적화 전 (Legacy Approach) | 최적화 후 (Async + Buffer Tuning) |
|---|---|---|
| 평균 FPS (4K) | 18 ~ 22 FPS (불안정) | 30 FPS (고정) |
| Preview Latency | 180ms 이상 | 50ms 미만 |
| CPU 점유율 | 45% (메인 스레드 부하) | 15% (분산 처리) |
결과를 보면 알 수 있듯이, 스레드 분리와 적절한 버퍼 카운트(Buffer Count) 설정만으로도 드라마틱한 성능 향상을 이끌어낼 수 있습니다. 특히 ImageReader의 maxImages 파라미터를 1로 설정하면(최신 이미지만 필요하다는 생각에) 파이프라인이 멈출(Stall) 확률이 매우 높아집니다. 최소 2, 권장 3 이상으로 설정하여 생산자(Camera)와 소비자(App) 사이의 속도 차이를 완충해주어야 합니다.
주의 사항 및 엣지 케이스
이 최적화 방식을 적용할 때 몇 가지 주의할 점이 있습니다. 첫째, 과도하게 많은 ImageReader 인스턴스를 생성하거나 버퍼 사이즈를 너무 크게 잡으면 시스템 전체의 사용 가능한 그래픽 메모리(Gralloc)를 고갈시켜 다른 앱을 강제 종료시킬 수 있습니다. 특히 저사양 IoT 기기에서 안드로이드를 구동할 때 이 문제가 빈번합니다.
둘째, 일부 레거시 디바이스나 저가형 칩셋의 경우, 제조사가 제공하는 HAL 구현체가 Camera2 API의 사양을 100% 준수하지 않을 수 있습니다(예: LEGACY 하드웨어 레벨). 이 경우 비동기 요청이 순서대로 보장되지 않거나, 타임스탬프가 꼬이는 문제가 발생할 수 있으므로 CameraCharacteristics를 통해 하드웨어 레벨을 반드시 체크하고 분기 처리를 해야 합니다.
ImageReader.acquireLatestImage() 사용 시, 획득한 이미지는 반드시 image.close()를 호출하여 반환해야 합니다. try-with-resources 구문을 사용하거나 finally 블록에서 명시적으로 닫지 않으면, 정확히 maxImages 횟수만큼 작동한 뒤 카메라 프리뷰가 영구적으로 멈춥니다.
결론
안드로이드 카메라 시스템은 겉보기엔 단순해 보이지만, 내부적으로는 AOSP 프레임워크와 제조사의 HAL이 복잡하게 얽혀 돌아가는 거대한 파이프라인입니다. 단순히 API를 호출하는 것을 넘어, 데이터가 버퍼 큐를 타고 어떻게 흐르는지 이해한다면 4K 고해상도 처리나 실시간 머신러닝 분석과 같은 고부하 작업에서도 부드러운 사용자 경험을 제공할 수 있습니다. 시스템 레벨의 튜닝이 필요하다면 주저하지 말고 fastboot를 연결하고 벤더 영역을 디버깅해보시기 바랍니다. 그곳에 진짜 성능의 열쇠가 있습니다.
Post a Comment