logo
|
Blog

    [ 개인플젝 ] TurboModule로 사진 셀렉터 + 카메라 연결하기 - Android

    운동인증 랭킹앱 프로젝트를 하면서 카메라와 셀렉터는 터보모듈로 직접 붙혀가며 사용해보기로 했다. 주기능은 라이브러리 의존도를 최대한 줄이는게 옳다고 생각한다
    김보람's avatar
    김보람
    Dec 29, 2025
    [ 개인플젝 ] TurboModule로 사진 셀렉터 + 카메라 연결하기 - Android
    Contents
    목표전체 흐름 요약1) Codegen 명세서 작성 (TS)2) Codegen 실행 & Spec.java 확인3) MainActivity에 Fragment 2개를 “앱 시작 시점”에 붙이기4) 갤러리 TurboModule 구현 (NativePhotoSelector)5) 카메라 TurboModule 구현 (NativeCamera)6) AndroidManifest 정책: CAMERA 권한 + uses-feature(required=false)7) RN에서 실행

    운동 인증 앱에서 “오늘 사진 인증”을 구현하려면, 사용자가 갤러리에서 사진을 선택하거나 카메라로 촬영한 뒤, 그 결과로 이미지 URI와 생성 시각(createdAt) 을 React Native(JS/TS)로 전달해야 한다.

    ActivityResult API가 요구하는 등록 타이밍(Lifecycle)과, 권한 모델, MediaStore 정책등을 고려해야한다


    목표

    • 갤러리 선택 → { uri/url, createdAt } 를 RN으로 전달

    • 카메라 촬영 → { uri, createdAt } 를 RN으로 전달

    전체 흐름 요약

    • Codegen 명세(TS interface) 작성

    • Codegen 실행 → Spec.java 생성 확인

    • MainActivity에 Fragment 2개를 미리 붙여서 런처를 ‘시작 시점’에 등록

    • 갤러리 TurboModule 구현 (NativePhotoSelector)

    • 카메라 TurboModule 구현 (NativeCamera)

    • RN에서 호출하고 결과를 콘솔로 검증

    Fragment내에서의 역할과 책임은 사실 아직도 이해가 잘 안되지만.. 하다보면 또 이해하는 날이 오겠지…

    Android의 registerForActivityResult는 Lifecycle이 STARTED 되기 전에 등록되어야 하며, RN에서 버튼 눌러 호출하는 시점은 이미 Activity/Fragment가 RESUMED 상태일 확률이 높기 때문에, 모듈 호출 시점에 런처를 등록하면 크래시/오류가 난다.

    그래서 “결과 런처”는 앱 시작 시점에 등록되도록 Fragment를 미리 붙이는 구조가 필요

    1) Codegen 명세서 작성 (TS)

    New Architecture에서는 JS에서 호출하는 함수의 시그니처를 Codegen이 읽어서, Android/iOS 네이티브 쪽에 “스펙” 코드를 생성한다.

    • 1-1. 카메라 Spec

    // NativeCamera.ts
    import type { TurboModule } from 'react-native';
    import { TurboModuleRegistry } from 'react-native';
    
    export interface Spec extends TurboModule {
      takePhoto(): Promise<{
        uri: string;
        createdAt: number;
      }>;
    }
    
    export default TurboModuleRegistry.getEnforcing<Spec>('NativeCamera');
    • 1-2. 갤러리 Spec

    // NativePhotoSelector.ts
    import type { TurboModule } from 'react-native';
    import { TurboModuleRegistry } from 'react-native';
    
    export interface Spec extends TurboModule {
      pickPhoto(): Promise<{
        url: string;
        createdAt: number | null;
      }>;
    }
    
    export default TurboModuleRegistry.getEnforcing<Spec>('NativePhotoSelector');
    

    TurboModuleRegistry.getEnforcing에서 모듈 이름을 결정한다.
    Android의 NativeCameraPackage / NativeCameraModule에서 이름이 정확히 일치해야 됨

    2) Codegen 실행 & Spec.java 확인

     ./gradlew generateCodegenArtifactsFromSchema

    생성 경로

    • android/app/build/generated/source/codegen/java/com/streakly/NativeCameraSpec.java

    • android/app/build/generated/source/codegen/java/com/streakly/NativePhotoSelectorSpec.java

    이 Spec.java가 생성되어야 네이티브 모듈이 NativeCameraSpec, NativePhotoSelectorSpec를 상속받아 구현할 수 있음

    3) MainActivity에 Fragment 2개를 “앱 시작 시점”에 붙이기

    Android의 registerForActivityResult(...)는 내부적으로 Lifecycle에 등록된다.
    LifecycleOwner가 STARTED 되기 전에 등록해야 하며, 이미 화면이 떠 있는 상태(RESUMED)에서 등록하려고 하면 아래와 같은 오류가 발생

    LifecycleOwners must call register before they are STARTED

    RN에서 모듈을 호출하는 시점은 대부분 “화면이 다 떠 있는 상태”이기 때문에, 모듈 호출 때 런처를 등록하면 타이밍이 늦다.

    런처는 앱 시작 시점에 등록되도록 Activity에 Fragment를 미리 붙여서 Fragment 생성 시점에 런처가 등록되도록 구조를 잡아야 한다고 한다.

    • MainActivity.onCreate

    override fun onCreate(savedInstanceState: Bundle?) {
      super.onCreate(savedInstanceState)
    
      if (supportFragmentManager.findFragmentByTag(PHOTO_PICKER_TAG) == null) {
        supportFragmentManager.beginTransaction()
          .add(PhotoPickerFragment(), PHOTO_PICKER_TAG)
          .commitNow()
      }
    
      if (supportFragmentManager.findFragmentByTag(CAMERA_TAG) == null) {
        supportFragmentManager.beginTransaction()
          .add(CameraFragment(), CAMERA_TAG)
          .commitNow()
      }
    }

    commitNow()를 쓰는 이유는 즉시 Fragment를 붙이고 초기화까지 완료시키기 위함이고 런처 등록이 바로 수행되도록 보장한다.

    • 3-1. PhotoPickerFragment

      • GetContent() 런처 → 갤러리에서 이미지 선택

      • OS 버전별 권한 런처 → Android 13(API 33) 이상은 READ_MEDIA_IMAGES, 그 미만은 READ_EXTERNAL_STORAGE

      private val photoPickerLauncher =
        registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
          callback?.onResult(uri)
          callback = null
        }

      private val permissionLauncher =
        registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
          if (granted) photoPickerLauncher.launch("image/*")
          else { callback?.onResult(null); callback = null }
        }

    • 3-2. CameraFragment

      • TakePicture() 런처 → 저장할 Uri를 미리 만들어 주고 카메라 앱을 실행

      • 권한 런처 → Manifest.permission.CAMERA

      private val cameraLauncher =
        registerForActivityResult(ActivityResultContracts.TakePicture()) { success ->
          if (success && outputUri != null) callback?.onResult(outputUri!!, createdAt)
          else callback?.onResult(null, 0)
          callback = null
        }

      fun launchCamera(createUri: () -> Uri) {
        createdAt = System.currentTimeMillis()
        outputUri = createUri()
      
        val granted =
          requireContext().checkSelfPermission(Manifest.permission.CAMERA) ==
            PackageManager.PERMISSION_GRANTED
      
        if (granted) {
          cameraLauncher.launch(outputUri!!)
        } else {
          permissionLauncher.launch(Manifest.permission.CAMERA)
        }
      }

    4) 갤러리 TurboModule 구현 (NativePhotoSelector)

    • 4-1. NativePhotoSelectorModule.kt

      • 역할

        • RN이 pickPhoto() 호출

        • MainActivity.PhotoPickerFragment를 찾아 callback 연결

        • 결과 Uri를 받으면 createdAt을 MediaStore에서 추출

        • { url, createdAt } 맵으로 resolve

      ActivityResult는 비동기라서, RN에서 연속 클릭하면 promise가 꼬일 수 있다.
      따라서 pendingPromise로 1회 처리만 보장한다.

      • createdAt 추출 로직

        • DATE_TAKEN이 있으면 우선 사용 (ms)

        • 없으면 DATE_ADDED 사용 (sec → ms 변환)

        private fun extractCreatedAt(uri: Uri): Long? {
          val projection = arrayOf(
            MediaStore.Images.Media.DATE_TAKEN,
            MediaStore.Images.Media.DATE_ADDED
          )
          val cursor = reactApplicationContext.contentResolver.query(uri, projection, null, null, null)
          cursor?.use {
            if (it.moveToFirst()) {
              val takenIndex = it.getColumnIndex(MediaStore.Images.Media.DATE_TAKEN)
              if (takenIndex != -1) {
                val taken = it.getLong(takenIndex)
                if (taken > 0) return taken
              }
              val addedIndex = it.getColumnIndex(MediaStore.Images.Media.DATE_ADDED)
              if (addedIndex != -1) return it.getLong(addedIndex) * 1000
            }
          }
          return null
        }
    • 4-2. NativePhotoSelectorPackage.kt

      TurboModule 등록체로 이름이 TS의 'NativePhotoSelector'와 정확히 일치해야 한다.

    return if (name == "NativePhotoSelector") NativePhotoSelectorModule(reactContext) else null

    ReactModuleInfo에서 isTurboModule = true.

    • 4-3. PhotoPicker.kt 생성 이유

      지금 구조에서는 Fragment 기반으로 런처를 등록해서 사용하고 있다.
      PhotoPicker 클래스는 “Activity에 직접 런처를 등록해서 쓰는 방식” 예시인데, RN New Architecture + Lifecycle 제약 때문에 Activity에 늦게 등록하면 실패할 수 있다.

      선택 사항이지만 앱 시작 시점 등록을 강제하려면, 지금처럼 Fragment에 등록하는 편이 안전하기 때문이다

    5) 카메라 TurboModule 구현 (NativeCamera)

    • 5-1. NativeCameraModule.kt

      • 역할

        • RN이 takePhoto() 호출

        • MainActivity.CameraFragment 찾아 callback 연결

        • Fragment가 촬영 결과 Uri + createdAt을 전달

        • resolve로 { uri, createdAt } 반환

      ActivityResultContracts.TakePicture()는 “저장할 Uri”를 호출자가 제공해야 한다.
      그래서 MediaStore에 insert해서 “빈 항목”을 만들고 그 Uri를 카메라 앱에 전달한다.

      private fun createImageUri(activity: MainActivity): Uri {
        val resolver = activity.contentResolver
        val timeStamp = System.currentTimeMillis()
      
        val contentValues = ContentValues().apply {
          put(MediaStore.Images.Media.DISPLAY_NAME, "IMG_$timeStamp.jpg")
          put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
          put(MediaStore.Images.Media.DATE_TAKEN, timeStamp)
        }
      
        return resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)!!
      }

      DATE_TAKEN을 직접 넣은 이유는 실제 카메라 앱이 저장하면서 메타데이터를 덮어쓸 수 있지만 기본적인 기준접을 만들기위함이다

    6) AndroidManifest 정책: CAMERA 권한 + uses-feature(required=false)

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

    이 카메라 권한만 추가햇더니 Permission exists without corresponding hardware <uses-feature ... required="false"> 이런 에러가 났다

    Google Play는 CAMERA 권한을 보면 “이 앱은 카메라 하드웨어가 필수인 앱”으로 추정한다.

    하지만 ChromeOS/태블릿 등 큰 화면 기기 중 카메라가 없거나 제한된 환경이 있을 수 있으니, “카메라가 없어도 설치는 가능”하다는 의도를 명시하도록 한 것

    7) RN에서 실행

    await NativeCamera.takePhoto();
    await NativePhotoSelector.pickPhoto();

    Share article

    김보람 | 930802qhfka@gmail.com

    RSS·Powered by Inblog