react hook와 react-native bridge 고찰

모기·2025년 6월 25일

디버깅이 90퍼센트인 나날들을 보내고 있다.

옛날에는 구현 40퍼센트에 디버깅이 60퍼센트였다면
요즘은 AI가 구현을 90퍼센트 해주기 때문에
내가 구현 10퍼센트 하고 나머지는 디버깅에 쏟고있다.
알고있다. 이상한 계산법이라는 것을

그래서 최근에는 무엇을 했느냐
FFmpeg를 좀 더 다듬고 숏폼에서 사용될 기술을 구현했다.

FFmpeg으로는

이런 걸 만들었고,

React로는

이런걸 만들었다.
뚝딱뚝딱 AI가 다 만들어주긴 했다.

React

아마 Hook에 대한 이해와 ts에 대한 이해 위주로 진행할 것 같다.

Hook

Hook이란?

Hook(훅)은 React 버전 16.8에서 새로 도입된 기능으로, 클래스 컴포넌트를 작성할 필요 없이 함수형 컴포넌트에서 React의 핵심 기능들(예: 상태(state), 생명주기(lifecycle) 등)을 "연결(hook into)"할 수 있게 해주는 함수들입니다.

과거에는 상태 관리나 생명주기 기능을 사용하려면 반드시 클래스 컴포넌트를 사용해야 했습니다. 하지만 Hook의 등장으로 함수형 컴포넌트가 클래스 컴포넌트의 모든 기능을 수행할 수 있게 되면서, 현재 React 개발의 표준으로 자리 잡았습니다.

useState

함수형 컴포넌트 내에서 동적으로 변하는 값, 즉 상태(state)를 추가할 때 사용합니다.
항상 그냥 사용하다가 이번 기회에 정확한 정의에 대해서 알게 됐다.
보통은

const [변수, 함수] = useState(초기값)

과 같은 형태로 사용된다.

그러면 누군가는 이렇게 생각할 수 있다.
그냥 변수와 함수 따로 선언해서 관리하면 안되나?

근데 그러면 리렌더링이 안된다.
리렌더링이 무엇이냐?

화면에 나타나는 컴포넌트가 업데이트되는 것을 말한다.
예를 들어 상태 변수가 아닌 변수 a가 있고, a = 0이고 컴포넌트 A가 a를 화면에 표시하고 있다고 하자.
(여기서 상태 변수란 useState와 같은 상태 관리 함수들로 등록되지 않은 변수를 말한다.)
이때 a = 1로 업데이트 된다고 해도 useState로 정의된 변수가 아니라면 화면의 A 컴포넌트는 여전히 0이다.

const [b, setB] = useState(0) 으로 선언된 b를 표시하는 컴포넌트 B가 있다고 하자.
화면에는 0으로 나타날 것이다.
이때 setB(b + 1)을 하면 b는 1로 업데이트되고, 화면의 컴포넌트 B에도 1이 표시된다.

일반적으로 화면 컴포넌트의 상태 변화는 useState를 사용한다.
다만 전역 상태 관리는 content, redux, zustand를 사용한다.
content와 redux는 사용해봤으나 zustand는 아쉽게도 아직은 기회가 닿지 않았다.

useEffect

보통 화면이 마운트되거나 의존성을 가지고 있는 변수의 값이 변했을 때 실행되는 함수이다.

GPT 말로는
useEffect는 컴포넌트가 렌더링된 이후 특정 작업(= 부수 효과, side effect)을 수행하도록 할 수 있는 리액트 훅입니다. 라고 한다.

  • 사용법: useEffect(실행할 함수, [의존성 배열]) 형태로 사용합니다.
    의존성 배열([])의 역할이 매우 중요합니다.
    • useEffect(callback, []): 배열이 비어있으면, 컴포넌트가 처음 렌더링될 때 한 번만 실행됩니다. (componentDidMount)
    • useEffect(callback, [someValue]): 배열 안에 특정 값(someValue)이 있으면, 해당 값이 변경될 때마다 함수가 실행됩니다. (componentDidUpdate)
    • useEffect(callback): 의존성 배열을 생략하면, 컴포넌트가 렌더링될 때마다 함수가 실행됩니다. (주의해서 사용해야 합니다.)
    • Clean-up 함수: useEffect 내에서 함수를 반환(return)하면, 이 함수는 컴포넌트가 사라지기 전이나, 다음 useEffect가 실행되기 전에 호출됩니다. (componentWillUnmount)

다만 callback 함수와 의존성 배열 안의 값의 관계를 잘 생각해서 설계해야한다.
잘못 설계하면 리렌더링 재귀 지옥에 빠져버린다.

예를 들어

const [c, setC] = useState(0)

라고 선언하고

useEffect(() => {
  setC(c + 1)
}, [c])

와 같이 설계하면

c값이 변할 때 해당 useEffect 함수가 실행되고, 내부의 setC 함수가 다시 c값을 바꾸므로 리렌더링 재귀 지옥이다.

Ref란?

useRef와 같은 형태로 또 쓰이는데, 이번에 GPT를 쓰면서 굉장히 Ref라는 단어가 여기저기 많이 쓰인다는 것을 알았다.
예를 들면 이런 식이다.

export interface TrimmerRef {
  playVideo: () => void;
  pauseVideo: () => void;
  seekToStart: () => void;
}

코드 고찰을 위해 봤는데 머리가 멍해지는 느낌이었다. 내가 알고 있는 react가 아니었다.

근데 보다보니 아 ts 형태가 맞구나 싶다.
콜론(:)을 기준으로 오른쪽에 있는 녀석들이 타입이다.
따라서 playVideo는 <() => void>라는 타입을 가지고 있는 것이다.

그건 그런데, 왜 AI는 여기서 이름에 Ref를 붙였을까?
과연 Ref는 왜, 언제 쓰는 것일까?

Ref란 무엇인가요?

Ref는 'Reference'(참조)의 줄임말로, React의 일반적인 데이터 흐름(Props와 State)을 벗어나 컴포넌트나 DOM 요소에 직접적으로 접근해야 할 때 사용하는 기능입니다.

쉽게 비유하자면,

Props와 State는 부모가 자식에게 "이 편지를 전달해줘"라고 시키는 것처럼, 정해진 규칙(데이터 흐름)에 따라 소통하는 방식입니다.

Ref는 자식 컴포넌트에게 직접 전화를 걸어 "지금 바로 불 좀 켜줘!"라고 명령하는 것과 같습니다. 즉, 중간 과정을 생략하고 직접 제어할 수 있는 '비상 연락망'이나 '리모컨' 같은 역할을 합니다.

Ref는 언제 사용할까요?

Ref는 꼭 필요할 때만 사용하는 것이 좋습니다. 다음과 같은 경우에 주로 사용됩니다.

  1. 포커스(Focus), 텍스트 선택, 미디어 제어
  • TextInput 컴포넌트가 렌더링되자마자 자동으로 포커스를 주고 싶을 때 (.focus())
  • 버튼을 누르면 TextInput의 내용을 지우고 싶을 때 (.clear())
  • Video나 Audio 컴포넌트의 재생/일시정지를 제어하고 싶을 때

  1. 애니메이션 직접 실행
  • 특정 인터랙션에 반응하여 컴포넌트에 직접적인 애니메이션 효과(예: 흔들기)를 주고 싶을 때

  1. 컴포넌트의 위치나 크기 측정
  • 화면상에 렌더링된 컴포넌트의 실제 너비, 높이, 위치 등을 알아내야 할 때

  1. 서드파티 라이브러리와의 연동
  • React 생태계 밖에서 만들어진 라이브러리가 특정 DOM 노드나 컴포넌트 인스턴스에 직접 접근해야 할 경우

줄줄 적혀있는 설명만 보면 이해하기가 힘들다. 하지만 나도 많이 써본적이 없어서 설명하지 못한다.
Vision Camera를 사용할 때, CameraRef 같은 걸 사용한 적이 있다. 그런 비슷한 느낌일까

Gemini가 설명하는 useRef 사용법을 보자.

useRef 사용법

  1. useRef 훅 불러오기 및 생성
import React, { useRef } from 'react';

컴포넌트 내에서 useRef를 호출하여 Ref 객체를 생성합니다. 초기값으로 보통 null을 설정합니다.

const myRef = useRef(null);

useRef는 .current 프로퍼티를 가진 객체를 반환합니다. 이 .current 프로퍼티에 우리가 참조하려는 실제 컴포넌트나 요소가 담기게 됩니다.

  1. 컴포넌트에 ref 속성으로 연결하기

접근하고 싶은 컴포넌트의 ref 속성(prop)에 위에서 생성한 Ref 객체를 전달합니다.

<TextInput ref={myRef} placeholder="여기에 입력하세요..." />
  1. Ref 사용하기

이제 myRef.current를 통해 TextInput 컴포넌트의 인스턴스에 접근하여 내장된 메서드(예: focus(), clear())를 호출할 수 있습니다.

주의: myRef.current는 컴포넌트가 화면에 렌더링(마운트)된 후에만 값이 할당됩니다. 따라서 렌더링되기 전이나, 비동기적으로 접근할 때는 myRef.current가 null일 수 있으므로 Optional Chaining (?.)을 사용하는 것이 안전합니다.

myRef.current?.focus();

useContext

사실 중요도로 따지자면 얘가 useRef보다 더 중요하긴 하다.
리액트로 코딩을 하다보면 상태 변수를 전역적으로 관리해야하는 경우가 생긴다.
그때 유용하게 쓸 수 있는 친구이다.
gemini는 theme를 예시로 들었다.

import React, { useState, useContext, createContext } from 'react';

// 1. Context 생성
const ThemeContext = createContext();

function App() {
  const [theme, setTheme] = useState('light');

  return (
    // 2. Provider로 하위 컴포넌트에 값 제공
    <ThemeContext.Provider value={{ theme, setTheme }}>
      <Toolbar />
    </ThemeContext.Provider>
  );
}

function Toolbar() {
  // 3. useContext로 값 사용
  const { theme, setTheme } = useContext(ThemeContext);
  const toggleTheme = () => setTheme(theme === 'light' ? 'dark' : 'light');

  return (
    <div>
      <p>Current theme: {theme}</p>
      <button onClick={toggleTheme}>Toggle Theme</button>
    </div>
  );
}

로그인 정보에 많이 사용된다고 하면 더 이해하기 쉬울 것이다.
로그인 정보는 어떤 페이지든, 어떤 컴포넌트든 영향을 줄 수 있어야하기 때문이다.

그 밖에도 useCallback과 useMemo가 있는데, 진짜 단 한번도 써본 적 없다.

inCode

뭐랄까 코드 보면서 굉장히 현타가 왔다.
진짜 너무 예쁘게 잘 짜놨고... 뭐라고 표현해야 할지 잘 모르겠다.
보고 한 눈에 잘 들어오지 않았던 코드들 위주로 정리할 생각이다.

1번은 이퀄라이저 컴포넌트이다.

const Equalizer: React.FC<{ bands: EQBand[], onGainChange: (bandId: string, newGain: number) => void }> = ({ bands, onGainChange }) => {
  return (
    <div className="equalizer-section">
      <h3>이퀄라이저</h3>
      <div className="equalizer-controls">
        {bands.map(band => (
          <div key={band.id} className="eq-band">
            <label>{band.frequency < 1000 ? `${band.frequency}Hz` : `${band.frequency/1000}kHz`}</label>
            <Slider
              vertical
              min={-12}
              max={12}
              step={0.5}
              value={band.gain}
              onChange={(value) => onGainChange(band.id, value as number)}
              className="eq-slider"
            />
            <span>{band.gain.toFixed(1)} dB</span>
          </div>
        ))}
      </div>
    </div>
  );
};

먼저 React.FC가 뭘까? 타입은 타입인데 어떤 타입을 말하는 걸까?

여기서 props는 properties의 줄임말로 부모 컴포넌트가 자식 컴포넌트에 값을 전달할 때 사용하는 매개변수라고 한다.

여기서는 bands와 onGainChange라는 props가 있는데,
return문의 div컴포넌트와 Slider컴포넌트에 전달된다.
그리고 이 props들의 컴포넌트가 각각 EQBand[]와 (bandID, newGain) => void 라는 타입으로, 또 함수의 매개변수 역시 string과 number라는 타입으로 명시되어 있다.

이 컴포넌트에 대한 이해를 위해 필요한 녀석들을 가져와보겠다.

export interface EQBand {
  id: string;
  frequency: number; // 헤르츠 (Hz)
  gain: number;      // 데시벨 (dB)
}

이렇게 생겼다.

그리고 onGainChange에 대한 이해를 하려면 Slider 컴포넌트의 onChange 역시 살펴봐야하기 때문에 그 전에 map부터 살펴보자.

딱 봐도 for문 잘할 것 같이 생기지 않았는가?

이건 차이점이라고 한다.

아무튼 그렇게 만들어진 컴포넌트 하나하나들이 하나의 이퀄라이저를 구성한다.
그리고 그 이퀄라이저는

<Equalizer bands={equalizer} onGainChange={handleEQGainChange} />

이런 식으로 활용되는데, eqaulizer는 매개변수로부터, handleEQGainChange는

  const handleEQGainChange = (bandId: string, newGain: number) => {
    const newBands = equalizer.map(band => 
      band.id === bandId ? { ...band, gain: newGain } : band
    );
    onUpdate(trimmerId, { equalizer: newBands });
  };

여기서 온다.

onChange={(value) => onGainChange(band.id, value as number)}

위 onGainChange에 들어가는 것이 handleEQGainChange이므로 onChange의 value가 newGain에 들어간다고 생각하면 된다.

    const newBands = equalizer.map(band => 
      band.id === bandId ? { ...band, gain: newGain } : band
    );

이건 아마 band를 순회하며 band.id와 bandId가 같을 때, newGain을 해당 band에 적용시키는 것 같다.
이렇게 만들어진 bands는 onUpdate에 적용된다.
근데 trimmerId랑 onUpdate는 또 어디서 왔을까

const VideoTrimmer = forwardRef<TrimmerRef, VideoTrimmerProps>(({ trimmerId, initialState, onUpdate }, ref) => {

전체 컴포넌트의 props로 전달받았다.
그리고 VideoTrimmer 컴포넌트는 상위 컴포넌트에서 다음과 같은 형태로 담긴다.

      <div className={`trimmers-wrapper layout-${layoutDirection}`}>
        {trimmers.map((trimmerState) => (
          <div key={trimmerState.id} className="trimmer-item-container">
            <VideoTrimmer
              ref={el => { trimmerRefs.current[trimmerState.id] = el; }}
              trimmerId={trimmerState.id}
              initialState={trimmerState}
              onUpdate={handleTrimmerUpdate}
            />
          </div>
        ))}
      </div>

ref 지옥이다...

우선은 forwardRef에 대해서 이해해보려 하기 전에

사실 여태 ref에 대해서 감이 안 잡혔는데..
'DOM요소나 컴포넌트 내부 메서드/값에 접근할 수 있는 참조 객체'라고 하길래 void*와 비슷한 느낌인 것 같아서 다시 AI에게 물어봤다.

다시 forwardRef로 돌아와서

그럼 TrimmerRef랑 VideoTrimmerProps를 살펴보자.

export interface TrimmerRef {
  playVideo: () => void;
  pauseVideo: () => void;
  seekToStart: () => void;
}

interface VideoTrimmerProps {
  trimmerId: string;
  initialState: TrimmerState;
  onUpdate: (id: string, newState: Partial<Omit<TrimmerState, 'id'>>) => void;
}

얘네들과

<VideoTrimmer
  ref={el => { trimmerRefs.current[trimmerState.id] = el; }}
  trimmerId={trimmerState.id}
  initialState={trimmerState}
  onUpdate={handleTrimmerUpdate}
/>

이 녀석과

const trimmerRefs = useRef<Record<string, TrimmerRef | null>>({});

이 녀석,

useImperativeHandle(ref, () => ({
    playVideo: () => {
      if(videoRef.current) {
        // 오디오 컨텍스트가 정지 상태이면 재개
        if (audioContextRef.current?.state === 'suspended') {
          audioContextRef.current.resume();
        }
        if (videoRef.current.currentTime < startTime || videoRef.current.currentTime >= endTime) {
          videoRef.current.currentTime = startTime;
        }
        videoRef.current.play();
      }
    },
    pauseVideo: () => {
      videoRef.current?.pause();
    },
    seekToStart: () => {
      if (videoRef.current) {
        videoRef.current.currentTime = startTime;
        setCurrentTime(startTime);
      }
    },
  }));

마지막으로 이 녀석의 관계를 파악하면 만들어준 코드에서 제일 어려웠던 부분에 대해 이해할 수 있을 것 같다.

흐름을 따라서 가면,

const trimmerRefs = useRef<Record<string, TrimmerRef | null>>({});

우선은 여기서 출발이다.
Record는 딕셔너리 타입 객체의 키와 밸류 타입을 정한다.
즉, trimmerRefs는 string key와 TrimmerRef value를 갖는 딕셔너리 타입의 useRef 타입이다.

그렇다면 이 trimmerRef에 값이 들어가는 시점은 언제일까?

ref={el => { trimmerRefs.current[trimmerState.id] = el; }}

바로 여기다.

특정 컴포넌트에 ref 옵션이 있고, ref 옵션을 사용하게 되면 react에서 ref를 참조할 수 있는 값을 매개변수(el)로 넘겨준다.

trimmerRefs는 앞서 말했듯이 Record로 명시된 딕셔너리 타입이므로 위와 같이 넣으면 자동으로 키-밸류 쌍을 생성한다.

그리고 이런 ref에는 일반적으로 .current를 통해서 접근한다고 한다.

이렇게 전달된 값들은 TrimmerRef에 정의되어있는

.current.playVideo
.current.pauseVideo
.current.seekToStart

함수들을 실행시킬 수 있다. 이 함수들은 어떤 기능이 있는지 아직은 정의되어있지 않다.

useImperativeHandle(ref, () => ({
    playVideo: () => {
      if(videoRef.current) {
        // 오디오 컨텍스트가 정지 상태이면 재개
        if (audioContextRef.current?.state === 'suspended') {
          audioContextRef.current.resume();
        }
        if (videoRef.current.currentTime < startTime || videoRef.current.currentTime >= endTime) {
          videoRef.current.currentTime = startTime;
        }
        videoRef.current.play();
      }
    },
    pauseVideo: () => {
      videoRef.current?.pause();
    },
    seekToStart: () => {
      if (videoRef.current) {
        videoRef.current.currentTime = startTime;
        setCurrentTime(startTime);
      }
    },
  }));

useImperativeHandle에서 이걸 마무리 시켜준다.

그리고 여기서 보면 전달된 handleTrimmerUpdate 함수 즉, onUpdate 함수는 하나인데
이 onUpdate 함수를 굉장히 다양한 함수에서 사용하고 있다.

  const handleTrimmerUpdate = (id: string, newState: Partial<Omit<TrimmerState, 'id'>>) => {
    setTrimmers(prevTrimmers =>
      prevTrimmers.map(trimmer =>
        trimmer.id === id ? { ...trimmer, ...newState } : trimmer
      )
    );
  };

이게 들어가는데

videoElement.onloadedmetadata = () => {
        const duration = videoElement.duration;
        onUpdate(trimmerId, {
          sourceVideo: { file, url: videoURL, duration },
          startTime: 0,
          endTime: duration,
        });
        setCurrentTime(0);
      };

이렇게도 사용되고

if (Array.isArray(value)) {
      const newStartTime = value[0];
      const newEndTime = value[1];
      onUpdate(trimmerId, { startTime: newStartTime, endTime: newEndTime });
      
      if (currentTime < newStartTime || currentTime > newEndTime) {
        setCurrentTime(newStartTime);
        if (videoRef.current) videoRef.current.currentTime = newStartTime;
      }
    }

이렇게도 사용되며

  const handleAspectRatioChange = (newRatio: AspectRatio) => {
    onUpdate(trimmerId, { aspectRatio: newRatio });
  };

  // 이퀄라이저 gain 값 변경 핸들러
  const handleEQGainChange = (bandId: string, newGain: number) => {
    const newBands = equalizer.map(band => 
      band.id === bandId ? { ...band, gain: newGain } : band
    );
    onUpdate(trimmerId, { equalizer: newBands });
  };

  const handleVolumeChange = (newVolume: number) => {
    onUpdate(trimmerId, { volume: newVolume });
  };

이런 식으로도 사용된다.

정체는

이다.

handleTrimmerUpdate는 trimmers와 setTrimmers로 구성된 hook을 사용한다.
즉, trimmer의 상태가 변하면 리렌더링 시켜준다고 볼 수 있다.
근데 그 trimmer의 구성요소 중 일부만을 매개 변수로 담아도 처리해준다.

그리고 그 비밀은

newState: Partial<Omit<TrimmerState, 'id'>>

여기에 담겨있을 것 같다.
AI 말로는

  • Omit<TrimmerState, 'id'>
    => TrimmerState에서 id를 제외한 나머지 필드만 남긴 타입
  • Partial<...>
    => <>안의 각 속성을 선택적으로(optional) 만들어줌
    => 즉, 전부 안 넘겨도 되고, 일부만 넘겨도 OK!

라고 한다.

그래서 onUpdate시 id값이 같은 것을 찾고 특정값만 수정해주고 이를 setTrimmers를 통해 상태에 반영하는 것 같다.

React Native Bridge

하루를 꼬박 썼던 디버깅이었다.

구현하고자 했던 기능은 Video를 재생하면서 동시에 녹화하는 기능이었다.
recording video while playing music(video)

길고 길었던 여정을 써보자면
1. 관련 리액트 네이티브 라이브러리 확인
2. 해당 라이브러리로 할 수 있는 기능을 통한 구현 시도와 실패
3. 네이티브(스위프트) 수준에서 새로운 소스 코드 생성(swift, m, c(bridge))
4. node module에서 해당 라이브러리의 네이티브 코드 확인
5. 해당 라이브러리의 새로운 option 발견 및 적용
6. 기존의 네이티브 코드 삭제 및 AppDelegate 수준에서 구현

이라고 할 수 있다.
그럼 그 여정에 본격적으로 들어가기 전에 bridge란 무엇인가?

리액트 네이티브 브릿지(Bridge)

란 무엇인가?

브릿지는 리액트 네이티브의 두 세계, 즉 자바스크립트 스레드와 네이티브 스레드를 연결하는 통신 계층입니다. 사용자의 터치 이벤트, 디바이스 정보 등 네이티브 세계에서 일어나는 일들을 자바스크립트로 전달하고, 반대로 자바스크립트에서 계산된 UI 변경 요청("버튼을 빨간색으로 바꿔줘")을 네이티브 코드로 전달하여 실제 화면에 렌더링하는 역할을 합니다.

브릿지의 아키텍처

여기까지 아키텍처가 있을 줄은 몰랐는데 두 가지 있다고 한다.

기존 브릿지 아키텍처

  • 작동 방식
  1. 비동기 통신: 자바스크립트 스레드와 네이티브 스레드는 직접적으로 통신하지 않고, 브릿지를 통해 비동기적으로 메시지를 주고받습니다.

  2. 직렬화 (Serialization): 자바스크립트 객체나 함수 호출 정보는 문자열(JSON) 형태로 직렬화되어 브릿지를 통해 네이티브 스레드로 전달됩니다.

  3. 일괄 처리 (Batching): 통신 효율을 높이기 위해 짧은 시간 동안 발생한 여러 메시지를 모아 한 번에 처리합니다.

이 구조는 대부분의 경우 잘 작동하지만, 통신량이 많아지면 JSON 직렬화/역직렬화 과정에서 병목 현상이 발생하여 성능 저하의 원인이 되기도 합니다.

JSI와 Fabric

이러한 기존 브릿지의 한계를 극복하기 위해 리액트 네이티브는 새로운 아키텍처를 도입했습니다.

  • JSI (JavaScript Interface)
    기존 브릿지를 대체하는 새로운 통신 계층입니다. JSI는 자바스크립트 객체에 대한 참조를 C++ 객체에서 직접 보유할 수 있게 해줍니다. 이로 인해 JSON 직렬화 과정 없이 자바스크립트와 네이티브 코드 간의 동기적인 함수 호출이 가능해졌습니다. 이는 통신 오버헤드를 획기적으로 줄여 성능을 크게 향상시킵니다.

  • Fabric (패브릭)
    새로운 렌더링 시스템입니다. JSI를 통해 UI 작업을 더 우선적으로, 동기적으로 처리할 수 있게 되어 애니메이션이나 사용자 인터랙션이 훨씬 부드러워집니다.

아마 리액트와 네이티브간 통신이 많아질 정도로 무거워지면 다시 한 번 보게 될 것 같다.

시도와 실패

1차 시도

Video Component와 Vision Camera의 내부 prop를 사용한 구현
video component에 mixWithOthers라는 옵션이 있고, vision Camera에는 별도의 옵션이 없다.
mixWithOthers = 'mix'로 하고 시도해봤으나 실패했다.

2차 시도

이번에는 gemini가 추천해준 방법을 사용했다.
AudioSession이라는 새로운 모듈을 사용하는 방법이다.

@objc(AudioSessionModule)
class AudioSessionModule: NSObject {

  @objc
  func activateAudioSession(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) {
    let audioSession = AVAudioSession.sharedInstance()
    do {
      // 이미 AppDelegate에서 올바르게 설정되었을 것이므로 setCategory 호출 제거
      try audioSession.setCategory(.playAndRecord, mode: .videoRecording, options: [.mixWithOthers, .defaultToSpeaker, .allowBluetooth, .allowAirPlay])
      try audioSession.setActive(true) // 단순히 활성화만 수행
      print("Swift: 오디오 세션 활성화 성공.")
      resolve("Audio session activated.")
    } catch {
      print("Swift: 오디오 세션 활성화 실패: \(error.localizedDescription)")
      reject("AUDIO_SESSION_ERROR", "Failed to activate audio session", error)
    }
  }

  @objc
  func deactivateAudioSession() {
    let audioSession = AVAudioSession.sharedInstance()
    do {
      try audioSession.setActive(false, options: .notifyOthersOnDeactivation)
      print("Swift: 오디오 세션 비활성화 성공.")
    } catch {
      print("Swift: 오디오 세션 비활성화 실패: \(error.localizedDescription)")
    }
  }

  @objc
  static func requiresMainQueueSetup() -> Bool {
    return true
  }
}

라는 swift라는 파일을 만들고

// AudioSessionModule.m

#import <React/RCTBridgeModule.h>

@interface RCT_EXTERN_MODULE(AudioSessionModule, NSObject)

RCT_EXTERN_METHOD(activateAudioSession:(RCTPromiseResolveBlock)resolve
                  rejecter:(RCTPromiseRejectBlock)reject)

// --- [추가] 새로운 함수를 브릿지에 노출시킵니다 ---
RCT_EXTERN_METHOD(deactivateAudioSession)

@end

라는 object-c 파일을 만들고

#import <React/RCTBridgeModule.h>

라는 브릿지 헤더 파일을 만든다.
물론 xcode 내부에서 다른 몇 가지 설정들이 더 필요하지만 그 부분은 생략하고

리액트 네이티브 tsx 파일에는

{ NativeModules } from 'react-native';
const {AudioSessionModule} = NativeModules;

와 같은 식으로 소환하면 된다.
그리고

AudioSessionModule.activateAudioSession();

이런 식으로 사용한다.

여기서 핵심 코드는 스위프트의

try audioSession.setCategory(.playAndRecord, mode: .videoRecording, options: [.mixWithOthers, .defaultToSpeaker, .allowBluetooth, .allowAirPlay])

이 부분이다. 여기서 playAndRecord와 mixWithOther가 중요하다.
이 코드와 모듈을

@interface RCT_EXTERN_MODULE(AudioSessionModule, NSObject)

RCT_EXTERN_METHOD(activateAudioSession:(RCTPromiseResolveBlock)resolve
                  rejecter:(RCTPromiseRejectBlock)reject)

이런 식으로 내보내서 ts코드는 native코드와 통신할 수 있게 된다.

비디오를 재생하기 전 혹은 후로 해당 activateAudioSession 코드를 실행시켰으나 실패했다.

3차 시도

이번엔 node modules를 타고 들어가 해당 라이브러리와 네이티브 코드 간 어떤 연관이 있는지 살펴봤다.
AI가 말로는 Video와 Vision Camera가 서로 오디오 세션을 독점하려고 해서 생기는 일이라고 했다.

그래서 나는 저 코드를 두 라이브러리가 가지고 있을지 궁금해졌다.
npm을 배포했던 경험이 여기서 도움이 된 게 사실 모듈의 폴더 구조는 리액트 네이티브 프로젝트의 폴더 구조랑 큰 차이가 없다는 것이다.

Video의 오디오 세션은

private func configureForRemoteControlEvents() {
        let audioSession = AVAudioSession.sharedInstance()

        do {
            // Remote control events always need playback category
            try audioSession.setCategory(.playback, mode: .moviePlayback)
            activateAudioSession()
        } catch {
            print(
                "Failed to configure audio session for remote control events: \(error.localizedDescription)"
            )
        }
    }

이렇게 돼 있었고,

Vision Camera의 오디오 세션은

      try audioSession.updateCategory(AVAudioSession.Category.playAndRecord,
                                      mode: .videoRecording,
                                      options: [.mixWithOthers,
                                                .allowBluetoothA2DP,
                                                .defaultToSpeaker,
                                                .allowAirPlay])

이런 식으로 돼 있었다.

즉, vision camera는 오디오 세션을 독점하지 않고, video에서 독점한다는 것을 확인했다.
그래서 video의 네이티브 코드를

try audioSession.setCategory(.playback, mode: .moviePlayback, options: [.mixWithOthers])

이렇게 바꾸고 실행해봤지만, 아쉽게 실패했다.
아마 어디선가 이 설정을 바꾸는 것이 아닐까? 라는 생각이 들었다.

4차 시도

그럼 이 Video의 오디오 세션 독점을 어떻게 막을 수 있을까?
살펴보니, 놀랍게도 video option 중에

disableAudioSessionManagement={true}

이런 옵션이 있었다...

해당 옵션을 추가하고, 네이티브의 기존 소스 코드를 삭제하고 appDelegate에서 mixWithOthers를 설정해두었다.
appDelegate는 어플이 실행될 때, 자동으로 실행해주는 네이티브 코드라고 한다.

결국 성공했다.

profile
안녕

0개의 댓글