Engineering note · Android

안드로이드 Camera2 API를 활용한 문서 촬영 가이드라인 화면 개발 및 이미지 회전(Exif) 트러블슈팅

2025년 08월 11일이현수
태그androidcamerakotlinexif

안드로이드 Camera2 API로 업무 문서 촬영용 커스텀 카메라 화면을 구현하면서 정리한 미리보기 파이프라인, 가이드라인 오버레이, 줌, JPEG_ORIENTATION, GPS EXIF, ImageReader 안정화 방식을 설명한다.

웹뷰 기반의 웹 개발을 하다가, 카메라를 활용한 UI 제작이 필요해졌다. 카메라 화면에 가이드라인을 띄우는 등의 커스텀 화면을 제작하게 됨. 그래서 네이티브 카메라 화면을 직접 띄우고, 미리보기 위에 문서 촬영 가이드라인을 표시하는 구조를 구성했다.

Camera2 API의 미리보기와 캡처 파이프라인, TextureViewSurface, 기기별 센서 회전값, JPEG EXIF 방향 정보, GPS 메타데이터, ImageReader 버퍼 관리까지 함께 다루었다.

커스텀 카메라가 필요한 이유

요구사항은 단순한 사진 촬영이 아니었다. 특정 문서 첨부 옵션에서 사진을 추가할 때 전용 카메라 화면을 열고, 카메라 미리보기 위에 사각형 가이드라인을 표시해야 했다. 카드 인식 화면에서 카드 모양의 틀을 제공하는 것과 비슷한 방식이다.

구체적인 화면 조건은 다음과 같았다.

  • 웹뷰에서 파일 선택이 발생했을 때 특정 옵션이면 기본 선택기 대신 문서 촬영용 카메라 Activity를 실행한다.
  • 카메라 미리보기는 가로가 화면에 꽉 차고 세로가 더 긴 3:4 비율에 가깝게 보인다.
  • 문서가 들어가야 할 영역은 사각형 선으로 표시한다.
  • 사각형 바깥 영역은 어둡고 반투명하게 처리한다.
  • 촬영 결과는 기존 파일 선택 흐름과 동일하게 URI로 반환한다.
  • 촬영 시점의 위치 정보가 필요하면 EXIF에 GPS 정보를 추가한다.

웹에서도 getUserMedia 등을 사용해 카메라를 띄울 수 있지만, 앱 안에서 파일 선택 흐름과 권한, 촬영 결과 URI, GPS EXIF를 일관되게 다루려면 네이티브 구현이 더 적합하다. 특히 Android 카메라의 회전과 EXIF 처리는 웹 레이어에서 뒤늦게 보정하기보다 촬영 시점에 제어하는 편이 안전하다.

Camera2 API의 기본 구조

Camera2 API는 카메라 하드웨어를 제어하는 프레임워크다. 기본 카메라 앱을 여는 방식보다 복잡하지만, 미리보기 화면, 캡처 설정, 초점, 줌, JPEG 품질, EXIF 방향, 출력 Surface를 직접 구성할 수 있다.

전체 흐름은 다음과 같다.

text
CameraManager
  -> cameraId 선택
  -> openCamera()
  -> CameraDevice.StateCallback
  -> CameraDevice
  -> CaptureRequest 생성
  -> CameraCaptureSession 생성
  -> Preview Surface와 ImageReader Surface로 출력

CameraManager는 기기의 카메라 목록을 제공한다. 사용할 카메라 ID를 고른 뒤 openCamera()를 호출하면, CameraDevice.StateCallback을 통해 실제 카메라 장치 객체를 얻는다. 이후 CameraDevice로 미리보기 요청과 캡처 요청을 만들고, CameraCaptureSession을 통해 해당 요청을 카메라 하드웨어로 전달한다.

Camera2 API의 카메라 출력 흐름

Camera2는 화면에 직접 그리는 API가 아니라, 카메라 프레임을 어떤 Surface로 보낼지 정하는 방식으로 동작한다. 미리보기는 TextureView 또는 SurfaceView의 Surface로 보내고, 촬영 결과는 ImageReader의 Surface로 보낸다.

SurfaceView와 TextureView 선택

카메라 미리보기를 표시할 때는 주로 SurfaceView 또는 TextureView를 사용한다.

구분장점단점
SurfaceView하드웨어 오버레이를 사용해 성능과 배터리 효율이 좋다.뷰 변형, 회전, 투명도, 복잡한 오버레이 처리가 제한적이다.
TextureView일반 View처럼 회전, 크기 조절, Matrix 변환을 적용하기 쉽다.SurfaceView보다 성능과 배터리 측면에서 불리할 수 있다.

문서 촬영 화면에서는 미리보기 위에 가이드라인을 올리고, 기기 회전에 따라 미리보기 변환도 적용해야 했다. 따라서 단순 성능만 보면 SurfaceView가 유리하지만, 화면 변형과 오버레이 제어를 고려하면 TextureView가 더 다루기 쉽다.

TextureView를 사용할 때 주의할 점은 surfaceTexture가 준비되기 전에는 Surface를 만들 수 없다는 점이다. 실제 구현 중에도 다음과 같은 예외가 발생했다.

text
java.lang.IllegalArgumentException: surfaceTexture must not be null
    at android.view.Surface.<init>(Surface.java:219)

이 문제는 TextureView의 준비 시점을 확인하지 않고 바로 Surface를 생성했을 때 발생한다. surfaceTexture가 null이면 미리보기 세션 생성을 중단하고, SurfaceTextureListener에서 준비 완료 시점에 카메라를 열도록 흐름을 맞추어야 한다.

kotlin
val surfaceTexture = textureView.surfaceTexture ?: run {
    Log.e(TAG, "SurfaceTexture가 아직 준비되지 않았습니다")
    return
}

val previewSurface = Surface(surfaceTexture)

Camera2 구현은 카메라 장치보다 Surface 준비 시점이 더 늦을 수 있다는 점을 항상 고려해야 한다.

카메라 열기와 상태 콜백

카메라를 열기 위해서는 먼저 카메라 권한을 확인한 뒤 CameraManager.openCamera()를 호출한다.

kotlin
private lateinit var cameraManager: CameraManager
private var cameraDevice: CameraDevice? = null

cameraManager = getSystemService(CAMERA_SERVICE) as CameraManager

for (id in cameraManager.cameraIdList) {
    // 후면 카메라 등 사용할 cameraId를 선택한다.
}

cameraManager.openCamera(cameraId, stateCallback, backgroundHandler)

openCamera()에는 CameraDevice.StateCallback을 넘겨야 한다. 카메라는 다른 앱에서 사용 중일 수도 있고, 시스템에서 회수될 수도 있으며, 권한 또는 하드웨어 오류가 발생할 수도 있다. 따라서 정상 연결뿐 아니라 연결 해제와 오류 상황을 모두 처리해야 한다.

kotlin
private val stateCallback = object : CameraDevice.StateCallback() {
    override fun onOpened(camera: CameraDevice) {
        cameraDevice = camera
        createPreviewSession()
    }

    override fun onDisconnected(camera: CameraDevice) {
        camera.close()
        cameraDevice = null
    }

    override fun onError(camera: CameraDevice, error: Int) {
        camera.close()
        cameraDevice = null

        val errorMessage = when (error) {
            ERROR_CAMERA_IN_USE -> "카메라가 다른 앱에서 사용 중입니다"
            ERROR_MAX_CAMERAS_IN_USE -> "최대 카메라 사용 개수를 초과했습니다"
            ERROR_CAMERA_DISABLED -> "카메라가 비활성화되었습니다"
            ERROR_CAMERA_DEVICE -> "카메라 장치 오류가 발생했습니다"
            ERROR_CAMERA_SERVICE -> "카메라 서비스 오류가 발생했습니다"
            else -> "알 수 없는 카메라 오류가 발생했습니다"
        }

        runOnUiThread {
            Toast.makeText(this@DocumentCameraActivity, errorMessage, Toast.LENGTH_LONG).show()
            finish()
        }
    }
}

카메라 오류는 사용자의 행동과 무관하게 발생할 수 있으므로, 오류 메시지를 남기고 Activity를 정리하는 흐름을 두어야 한다. 카메라 리소스를 닫지 않으면 다음 실행에서 카메라를 다시 열지 못하는 상황이 생길 수 있다.

미리보기 세션 만들기

카메라가 열리고 TextureView의 Surface가 준비되면 미리보기 요청을 만든다. 미리보기는 CameraDevice.TEMPLATE_PREVIEW 템플릿을 사용한다.

kotlin
val surfaceTexture = textureView.surfaceTexture ?: return
val previewSurface = Surface(surfaceTexture)

previewRequestBuilder =
    cameraDevice?.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW)

previewRequestBuilder?.addTarget(previewSurface)

이후 createCaptureSession()으로 미리보기 Surface와 캡처용 Surface를 묶어 세션을 구성한다.

kotlin
cameraDevice?.createCaptureSession(
    surfaces,
    object : CameraCaptureSession.StateCallback() {
        override fun onConfigured(session: CameraCaptureSession) {
            captureSession = session

            val previewRequest = previewRequestBuilder?.build() ?: return
            session.setRepeatingRequest(previewRequest, null, backgroundHandler)
        }

        override fun onConfigureFailed(session: CameraCaptureSession) {
            Log.e(TAG, "CameraCaptureSession 구성 실패")
        }
    },
    backgroundHandler
)

setRepeatingRequest()는 미리보기 프레임을 계속 갱신하는 요청이다. 이 요청이 유지되는 동안 사용자는 카메라 화면을 실시간으로 볼 수 있다. 반면 실제 사진 촬영은 capture()로 일회성 요청을 보내는 방식이다. 미리보기와 캡처는 같은 카메라 장치를 사용하지만 요청 템플릿과 출력 Surface가 다르다.

이미지 캡처와 결과 반환

사진 촬영은 TEMPLATE_STILL_CAPTURE 요청을 사용한다. 미리보기와 동일한 카메라 장치에서 요청을 만들지만, 출력 대상은 화면 Surface가 아니라 ImageReader.surface다.

kotlin
private fun captureImage() {
    val captureRequestBuilder =
        cameraDevice?.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE)

    captureRequestBuilder?.addTarget(imageReader?.surface!!)
    captureRequestBuilder?.set(
        CaptureRequest.CONTROL_AF_MODE,
        CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE
    )
    captureRequestBuilder?.set(CaptureRequest.JPEG_QUALITY, 95.toByte())
    captureRequestBuilder?.set(CaptureRequest.JPEG_ORIENTATION, getJpegOrientation())

    captureSession?.capture(
        captureRequestBuilder?.build()!!,
        null,
        backgroundHandler
    )
}

캡처 결과는 ImageReader.OnImageAvailableListener에서 받는다. 이미지 저장은 파일 I/O이므로 UI 스레드가 아니라 백그라운드 스레드에서 처리한다.

kotlin
private val imageAvailableListener = ImageReader.OnImageAvailableListener { reader ->
    val image = reader.acquireLatestImage() ?: return@OnImageAvailableListener

    backgroundHandler?.post {
        saveImage(image)
    }
}

저장 후에는 FileProvider로 얻은 URI를 결과 Intent에 담아 Activity를 종료한다. 이렇게 하면 웹뷰의 파일 선택 흐름과 비슷하게, 호출한 쪽에서는 촬영된 이미지 URI를 받을 수 있다.

kotlin
runOnUiThread {
    val resultIntent = Intent()
    resultIntent.data = imageUri
    setResult(Activity.RESULT_OK, resultIntent)
    finish()
}

이 구조에서는 촬영 전용 Activity가 카메라 세부 구현을 책임지고, 호출 측은 URI만 받는다. 웹뷰나 상위 화면에서 Camera2 세부 구현을 알 필요가 없기 때문에 책임이 분리된다.

권한과 하드웨어 선언

Camera2를 사용하려면 카메라 권한이 필요하다. 또한 앱이 카메라 하드웨어를 사용한다는 점을 Manifest에 선언해야 한다.

xml
<uses-permission android:name="android.permission.CAMERA" />

<uses-feature
    android:name="android.hardware.camera"
    android:required="true" />

<uses-feature
    android:name="android.hardware.camera.autofocus"
    android:required="false" />

카메라가 앱의 필수 기능이면 android.hardware.camerarequired="true"로 둘 수 있다. 다만 자동초점은 모든 기기에서 보장되지 않을 수 있으므로 required="false"로 두는 편이 호환성에 유리하다.

런타임 권한은 Activity에서 확인하고 요청한다.

kotlin
private fun checkCameraPermission(): Boolean {
    return ContextCompat.checkSelfPermission(
        this,
        Manifest.permission.CAMERA
    ) == PackageManager.PERMISSION_GRANTED
}

private fun requestCameraPermission() {
    ActivityCompat.requestPermissions(
        this,
        arrayOf(Manifest.permission.CAMERA),
        CAMERA_PERMISSION_REQUEST
    )
}

권한이 거부되면 카메라 화면을 유지할 이유가 없으므로 안내 후 종료한다.

kotlin
override fun onRequestPermissionsResult(
    requestCode: Int,
    permissions: Array<String>,
    grantResults: IntArray
) {
    super.onRequestPermissionsResult(requestCode, permissions, grantResults)

    if (requestCode == CAMERA_PERMISSION_REQUEST) {
        if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
            openCamera()
        } else {
            Toast.makeText(this, "카메라 권한이 필요합니다.", Toast.LENGTH_SHORT).show()
            finish()
        }
    }
}

카메라 회전 문제의 본질

화면에서 보이는 미리보기 방향과 실제 저장되는 JPEG 방향은 같은 문제가 아니다. 미리보기는 사용자 눈에 똑바로 보이면 되지만, 캡처 결과는 갤러리나 서버에서 열었을 때도 올바른 방향이어야 한다.

안드로이드 카메라 센서는 보통 기기 안에 가로 방향으로 장착되어 있다. 사용자는 세로 화면으로 촬영하지만 센서의 자연 방향은 다를 수 있다. 예를 들어 후면 카메라 센서가 90도, 전면 카메라 센서가 270도인 식이다.

전면후면
전면 카메라의 센서 방향후면 카메라의 센서 방향
270도90도

기기가 어떤 방향으로 들려 있는지도 함께 고려해야 한다. 같은 후면 카메라라도 사용자가 기기를 세로로 들었는지, 왼쪽으로 눕혔는지, 오른쪽으로 눕혔는지에 따라 저장될 JPEG의 방향 보정값이 달라진다.

미리보기 회전과 JPEG 회전은 다르다

미리보기와 캡처 방향 보정은 목적과 방법이 다르다.

구분미리보기캡처 JPEG
목표화면에서 사용자에게 똑바로 보이게 한다.저장된 파일이 뷰어에서 똑바로 보이게 한다.
처리 위치TextureView의 Matrix 변환CaptureRequest.JPEG_ORIENTATION
데이터원본 프레임은 그대로 두고 표시만 회전한다.JPEG EXIF 방향 태그를 기록한다.

미리보기는 View의 표시 문제다. TextureView.setTransform(matrix)를 사용해 회전과 스케일을 적용한다.

kotlin
private fun configureTransform(viewWidth: Int, viewHeight: Int) {
    val rotation = windowManager.defaultDisplay.rotation
    val matrix = Matrix()
    val viewRect = RectF(0f, 0f, viewWidth.toFloat(), viewHeight.toFloat())
    val centerX = viewRect.centerX()
    val centerY = viewRect.centerY()

    if (Surface.ROTATION_90 == rotation || Surface.ROTATION_270 == rotation) {
        // 회전과 스케일 보정
    } else if (Surface.ROTATION_180 == rotation) {
        matrix.postRotate(180f, centerX, centerY)
    }

    textureView.setTransform(matrix)
}

반면 캡처는 저장 파일의 방향 문제다. CaptureRequestJPEG_ORIENTATION 값을 넣어야 한다.

kotlin
captureRequestBuilder?.set(
    CaptureRequest.JPEG_ORIENTATION,
    getJpegOrientation(cameraCharacteristics, displayRotation)
)

이 둘을 혼동하면 화면에서는 똑바로 보이는데 갤러리에서는 누워 있거나, 반대로 저장 파일은 맞지만 미리보기 화면이 회전되어 보이는 문제가 생긴다.

JPEG_ORIENTATION 계산

JPEG 방향은 센서 각도와 기기 회전값을 조합해서 계산한다. 후면 카메라는 일반적으로 다음 공식을 사용한다.

text
(sensorOrientation - deviceOrientationDegrees + 360) % 360

전면 카메라는 거울 모드와 방향 차이 때문에 부호가 달라진다.

text
(sensorOrientation + deviceOrientationDegrees) % 360

Kotlin 코드로 정리하면 다음과 같다.

kotlin
private fun getJpegOrientation(
    characteristics: CameraCharacteristics,
    deviceRotation: Int
): Int {
    val sensorOrientation =
        characteristics.get(CameraCharacteristics.SENSOR_ORIENTATION) ?: 0

    val deviceOrientationDegrees = when (deviceRotation) {
        Surface.ROTATION_90 -> 90
        Surface.ROTATION_180 -> 180
        Surface.ROTATION_270 -> 270
        else -> 0
    }

    val lensFacing = characteristics.get(CameraCharacteristics.LENS_FACING)

    return if (lensFacing == CameraCharacteristics.LENS_FACING_FRONT) {
        (sensorOrientation + deviceOrientationDegrees) % 360
    } else {
        (sensorOrientation - deviceOrientationDegrees + 360) % 360
    }
}

예를 들어 후면 카메라 센서가 90도일 때, 기기를 오른쪽으로 눕힌 상태에서 Display.getRotation()이 270도를 반환한다면 결과는 180도다.

text
(90 - 270 + 360) % 360 = 180

이 값을 JPEG_ORIENTATION에 넣으면 JPEG 파일에는 EXIF 방향 정보가 기록된다. 많은 이미지 뷰어는 이 EXIF 값을 보고 사진을 올바른 방향으로 표시한다.

기기 회전에 따른 카메라 센서 방향

여기서 주의할 점은 회전 API마다 기준이 다를 수 있다는 것이다. Display.getRotation()은 화면 표시 회전을 기준으로 하고, OrientationEventListener는 물리적인 기기 회전 각도를 더 직접적으로 다룬다. 두 값을 섞어 쓰면 방향 계산이 반대로 보일 수 있으므로, 하나의 기준을 정하고 로그로 센서 각도, 기기 각도, 결과 각도를 함께 확인해야 한다.

GPS EXIF는 자동으로 들어가지 않는다

위치 권한이 있다고 해서 촬영 이미지의 EXIF에 위경도가 자동으로 들어가지는 않는다. 카메라 요청에 GPS 위치 정보를 명시적으로 넣어야 한다.

먼저 촬영 시점에 사용할 위치를 가져온다.

kotlin
fusedLocationProviderClient?.lastLocation
    ?.addOnSuccessListener { location: Location? ->
        callback(location)
    }
    ?.addOnFailureListener {
        callback(null)
    }

그 다음 캡처 요청에 JPEG_GPS_LOCATION을 설정한다.

kotlin
location?.let { loc ->
    captureRequestBuilder?.set(CaptureRequest.JPEG_GPS_LOCATION, loc)
}

이 설정을 추가해야 카메라 하드웨어 또는 프레임워크가 JPEG EXIF에 GPS 정보를 기록한다. 촬영 시점과 위치가 문서 데이터의 근거가 되는 경우 이 처리가 중요하다. 권한을 얻는 것과 EXIF에 기록하는 것은 별개의 단계다.

좋아요와 댓글

댓글 남기기

댓글 0개

댓글을 불러오는 중입니다.