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

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

현재 하고 있는 개인 프로젝트에선 “운동인증” 이라는 기능이 있고

갤러리에서 선택, 촬영 두 버튼이 제공된다.
이름 그대로의 기능을 가지고 있고 이 두 기능 모두 터보모듈로 구현할 것이다


굳이 네이티브 모듈로 구현하는 이유

  1. 실직으로 시간이 남아돌기 때문

  2. 이 플젝 주 목적이 터보모듈에 익숙해지기 위함이기 때문

  3. 주 기능은 최대한 라이브러리에 의존하면 안된다는 나름의 소신 때문

크로스 플랫폼 앱 개발자 특성상 라이브러리 의존도가 상당히 높을 수 밖에 없다. 그리고 우리같은 개발자 쓰는 이유가 빠르고 싸게 쓰기 위함이기에 잘 만들어진 라이브러리 갖다가 와다다다 작업하는게 맞다

하지만 개인적으로 앱의 주기능이 되는 부분은 꼭 깊게 들여다 봐야한다고 생각한다.

라이브러리 대충보고 심지어 공식문서도 읽지 않고 GPT오빠야가 알려주는대로 때려 넣다가 디버깅할 시점이 오면 뭐가 문제인지 확인하기 어렵다.

내가 원하는 기능이 이 라이브러리가 문제여서 안되는건지 각 플랫폼이 앱에게 지원하지 않는건지 구조나 싸이클에 대한 이해가 부족한건지 파악하는데 한세월 두세월 세세월까지도 걸린다

당연히 시간 없으면 빠르게 GPT형과 함께 작업해서 빨리 밀어내고 돈벌어야지만 밀어낸 후에라도 시간을 내서 꼭 깊게 들여다 보고 앱을 내 자식마냥 생각하며 최대한 들싸가지 없게 크도록 만들어야 하는게 개발자의 책임이자 의무 아닌가 생각이 든다

서론이 길었고 그냥 시간이 남아 돌아 굳이굳이 잘 만들어진 라이브러리 내비두고 터보모듈로 간다


JSI와 Objective-C의 관계

iOS는 공식적으로 Swift를 밀고 있고, 나 역시 Swift를 공부하고 있다.
하지만 React Native의 New Architecture, 특히 JSI를 사용하는 네이티브 모듈을 구현하려면 Objective-C와의 관계를 대략적으로라도 이해하고 가는 것이 필수적이다.

React Native의 New Architecture에서 네이티브 모듈은 다음과 같은 계층을 따라 연결된다.

JS → JSI → Native(iOS)

여기서 중요한 점은 JSI가 C++로 동작한다는 사실이다.
Swift는 C++과 직접 상호작용할 수 없는 언어이기 때문에,
JSI에서 곧바로 Swift 코드로 진입하는 것은 구조적으로 불가능하다.

만약 네이티브 로직을 Swift로 구현했다면,
그 사이에는 반드시 Objective-C(정확히는 Objective-C++)를 거치는 단계가 필요하다.

Objective-C는 C 기반 언어이며, C++과 조상이 같기 때문에
두 언어를 하나의 파일(Objective-C++)에서 함께 사용할 수 있다.
이 덕분에 Objective-C++는 JSI(C++)와 Swift(iOS 네이티브)를 연결하는 교차로 역할을 한다.

결과적으로 iOS에서의 실제 흐름은 다음과 같이 이해할 수 있다.

JS → JSI(C++) → Objective-C++ → Swift


사진 셀렉터 터보 모듈 작업 흐름

  1. .TS로 코드젠 명세서 작성, package.json에 codegenConfig 추가

  2. 코드젠 실행 하며 *spec 파일 생성

  3. 네이티브 구현부 작성

    1. 갤러리 접근 권한 획득

    2. 사진 셀렉터 출력, 선택 후 url, createdAt 반환

  4. JS에서 호출할 수 있도록 하는 호출부 작성

  5. 호출


  1. TS 파일 생성

NativePhotoSelector 모듈의 pickPhoto메서드는 프로미스를 반환하며 성공시 string 값의 url과 number 또는 null값의 createdAt을 넘겨줄꺼야 란 내용

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');
  1. codegenConfig 추가 및 실행

  "codegenConfig": {
    "name": "StreaklySpecs",
    "type": "modules",
    "jsSrcsDir": "specs",
    "android": {
      "javaPackageName": "com.streakly"
    }
  },

/ios에서 bundle install과 bundle exec pod install 명령어 실행

  1. 네이티브 구현부 작성

  1. 갤러리 접근 권한 획득

    1. 필요한 권한 plist에 추가

      //Info.plist
      
      <key>NSPhotoLibraryUsageDescription</key>
      <string>운동 인증을 위해 갤러리에서 사진을 선택하려고 합니다.</string>

      🔗 iOS 권한 리스트 소스

    2. 구현부 작성

      //
      //  PhotoAuthorization.swift
      //
      
      import Photos 
      
      final class PhotoAuthorization:NSObject {
        
        @objc(requestIfNeededWithCompletion:)
        static func requestIfNeeded(completion: @escaping (Bool) -> Void){
          
          let status = PHPhotoLibrary.authorizationStatus(for: .readWrite)
          
          switch status {
          case .authorized, .limited:
            completion(true)
          case .notDetermined:
            PHPhotoLibrary.requestAuthorization(for: .readWrite) {
              newStatus in DispatchQueue.main.async {
                completion(newStatus == .authorized || newStatus == .limited)
              }
            }
          default: completion(false)
          }
          
        }
      }


      위 swift코드를 해석하면 아래와 같다.

      import Photos
      // Photos 프레임워크
      // - 사진 / 비디오 라이브러리 접근 권한 관리
      // - PHPhotoLibrary, PHAuthorizationStatus 등을 제공
      
      final class PhotoAuthorization: NSObject {
        // final
        // - 상속을 의도하지 않은 단일 책임 클래스
        //
        // NSObject 상속
        // - Objective-C 런타임과의 호환을 위해 필요
        // - @objc 메서드를 노출하기 위한 전제 조건
        
        @objc(requestIfNeededWithCompletion:)
        // @objc
        // - 이 메서드를 Objective-C 런타임에 노출
        //
        // requestIfNeededWithCompletion:
        // - Objective-C 에서 인식되는 실제 셀렉터 이름
        // - Swift 메서드명과 Obj-C 메서드명을 명시적으로 With로 연결
        
        static func requestIfNeeded(completion: @escaping (Bool) -> Void) {
          // static
          // - 인스턴스를 생성하지 않고 클래스 자체에서 호출
          //
          // completion: (Bool) -> Void
          // - Bool을 인자로 받는 "콜백 함수"
          // - 값을 return 하지 않음
          // - 결과는 나중에 이 함수가 "호출"될 때 전달됨
          //
          // @escaping
          // - 이 클로저가 현재 함수의 실행이 끝난 후에도
          //   나중에 호출될 수 있음을 컴파일러에게 알림
          // - 비동기 작업에서는 필수
          
          let status = PHPhotoLibrary.authorizationStatus(for: .readWrite)
          // 현재 사진 라이브러리 접근 권한 상태를 조회
          // .readWrite: 읽기/쓰기 권한 기준
          
          switch status {
            
          case .authorized, .limited:
            // 이미 접근 권한이 허용된 상태
            // limited: iOS의 "선택한 사진만 허용" 상태
            
            completion(true)
            // 결과를 return 하는 것이 아님
            // → 콜백 함수를 호출하면서 Boolean 값을 전달
            // → 호출부에서는 이 시점에 true 를 전달받음
            
          case .notDetermined:
            // 사용자가 아직 권한 허용/거부를 선택하지 않은 상태
            // → 시스템 권한 요청 다이얼로그를 띄울 수 있는 유일한 케이스
            
            PHPhotoLibrary.requestAuthorization(for: .readWrite) { newStatus in
              // 시스템 권한 요청 (비동기)
              // 사용자가 다이얼로그에서 선택을 하면 클로저가 호출됨
              
              DispatchQueue.main.async {
                // UI 또는 RN 브릿지와 안전하게 연동하기 위해
                // 메인 스레드에서 completion 호출
                
                completion(newStatus == .authorized || newStatus == .limited)
                // 새로 결정된 권한 상태를 Boolean 값으로 변환
                // 그리고 그 결과를 콜백을 통해 전달
              }
            }
            
          default:
            // denied / restricted / 기타 접근 불가능한 상태
            
            completion(false)
            // 권한이 없다는 결과를 콜백으로 전달
          }
        }
      }
       

  2. 사진 셀렉터 출력

    1. 구현부 작성

      //
      //  PhotoPicker.swift
      //
      
      import PhotosUI
      import UIKit
      
      
      final class PhotoPicker: NSObject {
        private var onSelect: ((URL, Date?) -> Void)?
        
        @objc(presentFrom:onSelect:)
        
        func present(from viewController: UIViewController, onSelect: @escaping (URL, Date?) -> Void) {
          self.onSelect = onSelect
          
          DispatchQueue.main.async {
            var config = PHPickerConfiguration(photoLibrary: .shared())
            config.selectionLimit = 1
            config.filter = .images
            
            let picker = PHPickerViewController(configuration: config)
            picker.delegate = self
            
            viewController.present(picker, animated: true)
          }
        }
      }
      
      
      extension PhotoPicker: PHPickerViewControllerDelegate {
        func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
      
          picker.dismiss(animated: true)
      
          guard let result = results.first else { return }
          let itemProvider = result.itemProvider
      
          if itemProvider.hasItemConformingToTypeIdentifier("public.image") {
      
            itemProvider.loadFileRepresentation(
              forTypeIdentifier: "public.image"
            ) { url, error in
              guard let url = url else { return }
              
              
              let creationDate: Date?
              if let attributes =
                  try? FileManager.default.attributesOfItem(atPath: url.path),
                 let date = attributes[.creationDate] as? Date {
                creationDate = date
              } else {
                creationDate = nil
              }
      
              DispatchQueue.main.async {
                self.onSelect?(url, creationDate)
            self.onSelect = nil
              }
            }
          }
        }
      }


      위 코드 해석(거의 50번째 보고 또 보는 중 더 봐라 더 )

      //
      //  PhotoPicker.swift
      //
      
      import PhotosUI
      // PhotosUI
      // - iOS 14+ 이후의 최신 사진 선택 UI 프레임워크
      // - PHPickerViewController 제공
      // - 기존 UIImagePickerController의 대체재
      
      import UIKit
      
      
      final class PhotoPicker: NSObject {
        // final
        // - 상속을 의도하지 않은 단일 책임 클래스
        //
        // NSObject 상속
        // - Objective-C 런타임과의 호환
        // - @objc 메서드, delegate 패턴 사용을 위한 필수 조건
        
        private var onSelect: ((URL, Date?) -> Void)?
        // 선택 결과를 외부로 전달하기 위한 콜백
        //
        // (URL, Date?) -> Void
        // - URL: 선택된 이미지의 로컬 파일 URL
        // - Date?: 파일의 생성일 (없을 수도 있으므로 optional)
        // - Void: 값을 return 하지 않음
        //
        // private
        // - 외부에서 직접 접근 불가
        
        @objc(presentFrom:onSelect:)
        // @objc
        // - Objective-C / .mm 파일에서 호출 가능하도록 노출
        //
        // presentFrom:onSelect:
        // - Objective-C 런타임에 노출되는 메서드 시그니처
        // - Swift 메서드 이름과 Obj-C 셀렉터를 명시적으로 매핑
        
        func present(
          from viewController: UIViewController,
          onSelect: @escaping (URL, Date?) -> Void
        ) {
          // from viewController
          // - 사진 선택 UI를 띄울 기준 ViewController
          // - RN에서는 보통 RCTPresentedViewController()가 들어옴
          //
          // onSelect
          // - 사진 선택 완료 후 실행될 콜백
          // - 결과는 return이 아니라 이 클로저 호출로 전달됨
          //
          // @escaping
          // - picker UI가 닫힌 "이후"에 실행되므로 escaping 필요
          
          self.onSelect = onSelect
          // 전달받은 콜백을 인스턴스 변수에 저장
          // - delegate 메서드에서 사용하기 위함
          // - picker 생명주기 동안 유지되어야 함
          
          DispatchQueue.main.async {
            // UI 관련 코드는 반드시 메인 스레드에서 실행
            
            var config = PHPickerConfiguration(photoLibrary: .shared())
            // PHPicker 설정 객체
            // - 어떤 라이브러리를 사용할지 지정
            // - .shared(): 시스템 기본 사진 라이브러리
            
            config.selectionLimit = 1
            // 선택 가능한 최대 항목 수
            // - 1: 단일 이미지 선택
            
            config.filter = .images
            // 필터 설정
            // - 이미지만 선택 가능
            // - 동영상 / Live Photo 등은 제외
            
            let picker = PHPickerViewController(configuration: config)
            // 실제 사진 선택 UI ViewController 생성
            
            picker.delegate = self
            // 선택 결과를 받기 위한 delegate 지정
            // - PHPickerViewControllerDelegate 구현 필수
            
            viewController.present(picker, animated: true)
            // 전달받은 ViewController 기준으로 picker 화면 표시
          }
        }
      }
      
      
      extension PhotoPicker: PHPickerViewControllerDelegate {
        // PHPickerViewControllerDelegate
        // - 사용자가 사진 선택을 완료하거나 취소했을 때 호출되는 콜백 인터페이스
        
        func picker(
          _ picker: PHPickerViewController,
          didFinishPicking results: [PHPickerResult]
        ) {
          // 사진 선택이 끝났을 때 시스템에 의해 자동 호출됨
          // - 사진을 선택했든
          // - 그냥 닫았든
          // 무조건 호출됨
          
          picker.dismiss(animated: true)
          // picker UI 닫기
          // - 선택 후 즉시 dismiss 처리
          
          guard let result = results.first else { return }
          // selectionLimit = 1 이므로
          // 첫 번째 결과만 사용
          // - 아무것도 선택하지 않았다면 early return
          
          let itemProvider = result.itemProvider
          // NSItemProvider
          // - 선택된 항목(이미지, 파일 등)에 접근하기 위한 객체
          
          if itemProvider.hasItemConformingToTypeIdentifier("public.image") {
            // 선택된 항목이 이미지 타입인지 확인
            // - Uniform Type Identifier (UTI)
            // - public.image == 모든 이미지 타입
            
            itemProvider.loadFileRepresentation(
              forTypeIdentifier: "public.image"
            ) { url, error in
              // 파일 형태로 이미지에 접근 (비동기)
              // - 이 url은 임시 위치
              // - 필요하다면 복사해서 영구 경로로 옮겨야 함
              
              guard let url = url else { return }
              // 파일 URL이 없으면 처리 중단
              
              let creationDate: Date?
              // 파일 생성일을 담을 변수 (optional)
              
              if let attributes =
                  try? FileManager.default.attributesOfItem(atPath: url.path),
                 let date = attributes[.creationDate] as? Date {
                // 파일 메타데이터 조회
                // - 생성일이 존재한다면 추출
                
                creationDate = date
              } else {
                creationDate = nil
                // 생성일을 가져올 수 없는 경우 nil
              }
      
              DispatchQueue.main.async {
                // 결과 전달은 메인 스레드에서 처리
                // - RN 브릿지 / UI 안전성 보장
                
                self.onSelect?(url, creationDate)
                // 저장해두었던 콜백 호출
                // - URL과 생성일을 인자로 전달
                // - return이 아닌 "호출"로 결과 전달
      		  self.onSelect = nil
      		  //유저가 다시 호출할 것을 위해 메모리 해제
              }
            }
          }
        }
      }
  3. 호출부 작성 (⚠️ 오브젝티브..씨.. 완전히 이해하기에 너무 너무 더럽게 생겼다 흐름만 이해하자)

    1. NativePhotoSelector.h

      1. RN에서 호출되는 TurboModule의 "공식 인터페이스 선언부"로 JS ↔ Native 간의 책임과 역할을 정의한다

      2. 코드

      #import <Foundation/Foundation.h>
      #import <StreaklySpecs/StreaklySpecs.h>
      
      
      NS_ASSUME_NONNULL_BEGIN
      
      @class PhotoPicker;
      @interface NativePhotoSelector : NSObject <NativePhotoSelectorSpec>
      @property (nonatomic, strong, nullable) PhotoPicker *photoPicker;
      @end
      
      NS_ASSUME_NONNULL_END


      #import <Foundation/Foundation.h>

      → Objective-C 기본 런타임을 사용하기 위한 Foundation

      #import <StreaklySpecs/StreaklySpecs.h>
      → codegen으로 생성된 Spec 헤더, 이 헤더를 import함으로써 JS에서 직접 호출 가능한 모듈이 됨

      @class PhotoPicker;
      → 생명주기 관리를 위한 참조만 선언, PhotoPicker 는 사용자 선택을 기다리는 UI 객체이므로 선택이 완료될 때까지 살아 있어야 한다.
      주소를 복사해두고 원할때 사용하고 해제한다.

      @interface NativePhotoSelector : NSObject <NativePhotoSelectorSpec>
      → JS에서 들어오는 호출의 진입점으로 ‘관계’와 ‘책임’을 정의한다.
      즉, NativePhotoSelectorSpec 프로토콜을 채택했으므로 이 프로토콜이 정의한 모든 메서드를 꼭 구현해야겠구나 하는 부분임

      @property (nonatomic, strong, nullable) PhotoPicker *photoPicker;
      → PhotoPicker 객체의 주소를 photoPicker에 저장할거고
      → strong : 이 주소가 있는 동안 객체는 메모리에서 사라지지 않는다
      → nullable : 값이 없을 수도 있다 (nil 허용)
      → nonatomic : 자동 동기화를 하지 않는다
       

    2. NativePhotoSelector.mm

      1. JS에서 들어온 요청을 실제 iOS 네이티브 로직으로 실행하고,
        그 결과를 다시 JS로 돌려보낸다
        새로운 기능을 만드는 파일이 아니며 이미 존재하는 것들을 연결하고 실행 순서를 책임진다

      2. 코드

      //
      //  NativePhotoSelector.m
      //
      
      #import "NativePhotoSelector.h"
      #import <React/RCTUtils.h>
      #import "Streakly-Swift.h"
      
      @implementation NativePhotoSelector
      
      + (NSString *)moduleName { 
        return @"NativePhotoSelector";
      }
      
      - (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:(const facebook::react::ObjCTurboModule::InitParams &)params { 
        return std::make_shared<facebook::react::NativePhotoSelectorSpecJSI>(params);
      }
      
      - (void)pickPhoto:(nonnull RCTPromiseResolveBlock)resolve reject:(nonnull RCTPromiseRejectBlock)reject {
        [PhotoAuthorization requestIfNeededWithCompletion:^(BOOL granted) {
          if (!granted) {
            reject(@"NO_PERMISSION", @"Photo permission denied", nil);
            return;
          }
      
          UIViewController *topVC = RCTPresentedViewController();
          if (topVC == nil) {
            reject(@"NO_VIEW_CONTROLLER", @"No view controller", nil);
            return;
          }
      
          self.photoPicker = [[PhotoPicker alloc] init];
          [self.photoPicker presentFrom:topVC onSelect:^(NSURL *url, NSDate *date){
            NSMutableDictionary *result = [NSMutableDictionary new];
             result[@"url"] = url.absoluteString;
      
             if (date != nil) {
               result[@"createdAt"] = @([date timeIntervalSince1970] * 1000);
             } else {
               result[@"createdAt"] = [NSNull null];
             }
      
             resolve(result);
            
            self.photoPicker = nil;
          }];
        }];
      }
      
      @end


      #import <React/RCTUtils.h>
      → 현재 화면 최상단에 떠 있는 UIViewController를 가져오기 위함으로 네이티브 UI를 안전하게 출력하기 위한 RN 유틸

      #import "Streakly-Swift.h"
      → Swift → Obj-C 브릿징 헤더로 Swift로 구현된 클래스를 Obj-C(.mm)에서 사용 가능하게 함
       

      -(std::shared_ptr<facebook::react::TurboModule>)getTurboModule:(const facebook::react::ObjCTurboModule::InitParams &)params { 
        return std::make_shared<facebook::react::NativePhotoSelectorSpecJSI>(params);
      }

      → 이 부분은 터보모듈 쓸때 항상 들고다니는 연결용 보일러플레이트 로직이다. 코드젠으로 생성된 JSI 바인딩 객체를 RN런타임에 넘겨주는 역할만 한다
       

      [PhotoAuthorization requestIfNeededWithCompletion:^(BOOL granted) {
          if (!granted) {
            reject(@"NO_PERMISSION", @"Photo permission denied", nil);
            return;
          }
      
          UIViewController *topVC = RCTPresentedViewController();
          if (topVC == nil) {
            reject(@"NO_VIEW_CONTROLLER", @"No view controller", nil);
            return;
          }
      
          self.photoPicker = [[PhotoPicker alloc] init];
          [self.photoPicker presentFrom:topVC onSelect:^(NSURL *url, NSDate *date){
            NSMutableDictionary *result = [NSMutableDictionary new];
             result[@"url"] = url.absoluteString;
      
             if (date != nil) {
               result[@"createdAt"] = @([date timeIntervalSince1970] * 1000);
             } else {
               result[@"createdAt"] = [NSNull null];
             }
      
             resolve(result);
            
            self.photoPicker = nil;
          }];
        }]

      → pickPhoto가 실행되면 먼저 PhotoAuthorization의 requestIfNeeded를 호출해 사진 권한을 확인한다.
       

      requestIfNeededWithCompletion은 Objective-C 네이밍 컨벤션으로,

      마지막 인자로 콜백을 받는 메서드임을 의미한다.
       

      콜백으로 전달되는 Boolean 값을 granted라는 이름으로 받아

      권한이 없으면 reject, 있으면 다음 단계로 진행한다.
       

      이후 현재 화면에서 가장 위에 있는 ViewController를 얻고, 없다면 reject 한다. 값이 있다면 PhotoPicker를 생성해 self에 할당하여 사용자 선택이 끝날 때까지 생명주기를 유지한다.
       

      PhotoPicker의 present를 통해 Picker UI를 띄우고,

      사용자가 사진을 선택하면 URL과 생성일을 Promise resolve로 전달한다.

      모든 작업이 끝나면 photoPicker 참조를 해제한다.

  4. RN에서 호출

  const res = await NativePhotoSelector.pickPhoto();
Share article

김보람 | 930802qhfka@gmail.com