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

운동인증 랭킹앱 프로젝트를 하면서 카메라와 셀렉터는 터보모듈로 직접 붙혀가며 사용해보기로 했다. 주기능은 라이브러리 의존도를 최대한 줄이는게 옳다고 생각한다
김보람's avatar
Dec 29, 2025
[ 개인플젝 ] TurboModule로 사진 셀렉터 + 카메라 연결하기 - Android

운동 인증 앱에서 “오늘 사진 인증”을 구현하려면, 사용자가 갤러리에서 사진을 선택하거나 카메라로 촬영한 뒤, 그 결과로 이미지 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