[ 개인플젝 ] TurboModule로 사진 셀렉터 + 카메라 연결하기 - Android
![[ 개인플젝 ] TurboModule로 사진 셀렉터 + 카메라 연결하기 - Android](https://image.inblog.dev?url=https%3A%2F%2Finblog.ai%2Fapi%2Fog-custom%3Ftitle%3D%255B%25EA%25B0%259C%25EC%259D%25B8%25ED%2594%2584%25EB%25A1%259C%25EC%25A0%259D%25ED%258A%25B8%255D%2BSTREAKLY%26tag%3DTemplate%2B1%26description%3D%25EC%258B%259C%25EC%25A6%258C%25EB%25B3%2584%2B%25EC%259A%25B4%25EB%258F%2599%2B%25EC%259D%25B8%25EC%25A6%259D%2B%25EB%259E%25AD%25ED%2582%25B9%25EC%2595%25B1%2B%252F%2B%25EB%2589%25B4%25EC%2595%2584%25ED%2582%25A4%25ED%2585%258D%25EC%25B2%2598%2B%25EC%259D%25B5%25EC%2588%2599%25ED%2595%25B4%25EC%25A7%2580%25EA%25B8%25B0%26template%3D3%26backgroundImage%3Dhttps%253A%252F%252Fsource.inblog.dev%252Fog_image%252Fdefault.png%26bgStartColor%3D%2523ffffff%26bgEndColor%3D%2523ffffff%26textColor%3D%2523000000%26tagColor%3D%2523000000%26descriptionColor%3D%2523000000%26logoUrl%3Dhttps%253A%252F%252Fsource.inblog.dev%252Flogo%252F2025-12-24T06%253A07%253A46.034Z-ddf15b75-3608-41bd-8914-cd5d2d9efc83%26blogTitle%3D&w=3840&q=75)
운동 인증 앱에서 “오늘 사진 인증”을 구현하려면, 사용자가 갤러리에서 사진을 선택하거나 카메라로 촬영한 뒤, 그 결과로 이미지 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.javaandroid/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 nullReactModuleInfo에서 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();