Feature

[Android] cacheDir 활용으로 카메라 이미지 저장 없이 업로드 기능 구현하기

Marchbreeze 2025. 5. 26. 05:21

구현 영상

다음과 같은 과정으로 카메라 사진 촬영 및 캐시 저장 로직을 구현했습니다.

  1. 카메라 권한 동의 획득
  2. 캐시 디렉토리에 임시 파일 생성 및 URI 저장
  3. 해당 URI를 카메라 런처에 전달한 후 실행
  4. 카메라 사진 촬영 성공 시, 전달했던 URI에 사진이 저장
  5. 저장한 URI를 활용해서 다음 로직 실행

 

 

1. 카메라 권한 획득

안드로이드에서 카메라를 사용하려면 하드웨어 지원 선언 퍼미션 요청이 필수이며, 사용자에게 런타임 권한 요청이 필요합니다.

(1) Manifest 등록

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

<uses-permission android:name="android.permission.CAMERA" />
  • uses-feature : 카메라 기능을 지원하지 않는 기기에는 설치되지 않도록 Google Play가 차단하는 기능입니다.
  • uses-permission : 애플리케이션이 기기 카메라를 사용할 권한을 요청 가능하도록 알리는 기능입니다.

(2) 권한 동의 런처 설정

카메라 사용 권한을 요청하기 위해서는 rememberLauncherForActivityResult로 시스템 다이얼로그 UI를 띄워 사용자가 권한을 동의할 수 있도록 유도한 후, 결과값을 받아와야 합니다.

// Route에 변수 설정    
val cameraPermissionLauncher = rememberLauncherForActivityResult(
    contract = ActivityResultContracts.RequestPermission()
) { isGranted ->
    if (isGranted) viewModel.onIntent(VerifyIntent.CameraPermissionGrant)
}

(3) 카메라 권한 요청

기본적으로 안드로이드의 권한 요청 작동 방식은 다음과 같습니다.

  1. 전에 요청한 적이 없는 경우 : 앱이 해당 권한을 처음 요청하면, 시스템이 권한 요청 다이얼로그를 표시하여 사용자가 허용, 거부할 수 있도록 합니다.
  2. 전에 거절한 적이 있는 경우 : 사용자가 권한 요청을 거절하면서 “다시 묻지 않음” 옵션을 선택한 경우, 시스템이 더 이상 다이얼로그를 표시하지 않아 앱에서 사용자를 직접 설정 화면으로 유도하여 권한을 변경하도록 안내해야 합니다.

다음과 같이 구현할 수 있습니다.

// 간단한 구현 방법
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
    viewModel.onIntent(VerifyIntent.CameraPermissionGrant)
} else if (!ActivityCompat.shouldShowRequestPermissionRationale(context, permission)) {
    cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
} else {
    context.toast("설정에서 권한에 동의해주세요.")
    context.startActivity(
        Intent(ACTION_APPLICATION_DETAILS_SETTINGS).apply {
        	data = Uri.fromParts("package", context.applicationContext.packageName, null)
        }
    )
}

 

이러한 권한 요청 로직은 카메라 뿐만 아니라 다른 권한에도 동일하게 적용되므로, 재사용을 위해 유틸리티 클래스를 제작했습니다.

// 권한 요청을 위한 유틸리티 클래스
object PermissionManager {
    fun checkPermissionAndLaunch(
        permission: String,
        context: Context,
        onPermissionGranted: () -> Unit,
        onPermissionNotGranted: () -> Unit,
        onPermissionAlreadyDenied: (Intent) -> Unit
    ) {
        when {
            isPermissionGranted(permission, context) -> onPermissionGranted()
            !isPermissionAlreadyRejected(permission, context) -> onPermissionNotGranted()
            else -> onPermissionAlreadyDenied(intentToSetting(context))
        }
    }
    
	// 권한이 이미 부여되어 있는지에 대한 여부 체크
    private fun isPermissionGranted(permission: String, context: Context): Boolean =
        ContextCompat.checkSelfPermission(
            context, permission
        ) == PackageManager.PERMISSION_GRANTED
        
    // 권한이 이미 거절된 적이 있는지에 대한 여부 체크
    private fun isPermissionAlreadyRejected(permission: String, context: Context): Boolean =
        (context as? Activity)?.let {
            ActivityCompat.shouldShowRequestPermissionRationale(it, permission)
        } ?: false
    
    // 권한이 거절된 경우 설정으로 이동하도록 돕는 intent 반환
    private fun intentToSetting(context: Context) =
        Intent(ACTION_APPLICATION_DETAILS_SETTINGS).apply {
            data = Uri.fromParts("package", context.applicationContext.packageName, null)
          }
}

적용 시, 가독성이 향상된 코드를 작성할 수 있습니다.

// 유틸리티 클래스를 적용한 권한 요청 로직
checkPermissionAndLaunch(
    permission = Manifest.permission.CAMERA,
    context = context,
    onPermissionGranted = { viewModel.onIntent(VerifyIntent.CameraPermissionGrant) },
    onPermissionNotGranted = { cameraPermissionLauncher.launch(Manifest.permission.CAMERA)},
    onPermissionAlreadyDenied = { intentToSetting ->
        context.toast("설정에서 권한에 동의해주세요.")
        context.startActivity(intentToSetting)
    }
)

 

 


2. 임시 캐시파일 생성

ActivityResultContracts.TakePicture()의 경우, 사진을 찍은 후 미리 지정한 URI에 저장하는 방식으로 동작합니다.

촬영 결과를 Boolean으로 반환하기 때문에, 사진의 URI는 미리 만들어서 launcher에 전달해 주어야 합니다.

suspend fun getTempImageFile(): Result<File> =
    runCatching {
        withContext(Dispatchers.IO) {
            val timestamp = dateTimeFormatter.format(LocalDateTime.now())
            File(appContext.cacheDir, "img_genti_${timestamp}.jpeg").apply {
                if (!exists()) createNewFile()
            }
        }
    }
  1. I/O 스레드로 전환합니다.
  2. “yyyyMMdd_HHmmss”형식의 시간 문자열을 통해 이미지의 고유한 파일명을 생성하여, 이후 사진을 다시 찍어도 중복되지 않도록 설정합니다.
  3. 해당 파일명을 가진 파일을 cacheDir에 생성합니다.

 

 


3. 카메라 실행

ActivityResult API의 TakePicture 계약을 사용해, 사진을 촬영하여 전달한 URI에 저장하도록 합니다.

(1) 임시 파일 정보 저장

카메라 권한이 승인되었다는 intent를 획득한 이후, 뷰모델에서는 앞서 설정한 임시 파일 생성 함수를 실행한 후 값을 저장하고 런처를 실행하도록 구현했습니다.

// ViewModel
fun onIntent(intent: VerifyIntent) {
    when (intent) {
        is VerifyIntent.CameraPermissionGrant -> handleCameraPermissionGrant()
        //...
    }
}

private fun handleCameraPermissionGrant() {
    viewModelScope.launch {
        ImageManager.getTempImageFile()
            .onSuccess { file ->
                _verifyState.update {
                    it.copy(
                        imageUri = Uri.fromFile(file),
                        imageName = file.name,
                    )
                }
                _verifySideEffect.emit(VerifySideEffect.StartCameraLauncher)
            }.onFailure {
                _verifySideEffect.emit(VerifySideEffect.ShowErrorToast)
            }
    }
}
  • 임시 파일 생성 성공 시, Content URI와 파일명을 state에 저장한 후 Camera 실행 SideEffect를 방출합니다.

(2) 카메라 런처 설정 및 실행

ActivityResult API의 TakePicture 계약을 사용해, 사진을 촬영하여 전달한 URI에 저장하도록 합니다.

// Route
val cameraLauncher = rememberLauncherForActivityResult(
    contract = ActivityResultContracts.TakePicture()
) { isSuccess ->
    if (isSuccess) viewModel.onIntent(VerifyIntent.CameraResultSuccess)
}

LaunchedEffect(viewModel.verifySideEffect, lifecycleOwner) {
    viewModel.verifySideEffect.collect { sideEffect ->
        when (sideEffect) {
            is VerifySideEffect.StartCameraLauncher -> {
                cameraLauncher.launch(verifyState.imageUri)
            }
            //...
        }
    }
}
  • TakePicture()는 Boolean 결과만 반환하므로, 이미 생성한 URI를 ViewModel 상태에 저장해야 합니다.
  • 생성해둔 임시 파일의 Content URI를 카메라 런처에 전달한 후 실행합니다.
  • 이후 촬영 성공 시, 찍은 이미지가 앱 내부 캐시 디렉토리의 임시 파일에 저장됩니다.

(3) 사용이 끝난 캐시 파일 삭제

사용이 끝나 더이상 필요없어진 캐시 파일의 경우, 메모리 확보를 위해 삭제해줍니다.

suspend fun deleteTempImageFile(fileName: String) {
    withContext(Dispatchers.IO) {
        val tempFile = File(appContext.cacheDir, fileName)
        if (tempFile.exists()) {
            val isDeleted = tempFile.delete()
            if (!isDeleted) throw Exception()
        }
    }
}

 

 

위 과정을 통해, 앱 내에서 카메라 촬영 기능을 안전하고 유연하게 구현할 수 있습니다. 

 


참고 자료 :

[Android] 카메라 앱으로 사진 촬영