Logo
Published on
·10 min read

react-native-tts 사용 시 발생한 경고(Warning) 및 에러(Error) 해결

들어가며

개발 중인 React Native 프로젝트에 TTS(Text To Speech) 기능을 추가하기로 하고 react-native-tts 라이브러리를 사용하기로 했습니다.

라이브러리에 대한 자세한 내용은 react-native-tts를 참고하시면 됩니다.

해당 라이브러리를 설치하고 Tts.speak('Hello, world!'); 코드를 이용해 음성이 잘 나오는 지, 테스트 했습니다.

동작은 잘 되었지만, 앱 시작 시 경고가 보입니다.

경고(Warning)

 WARN  `new NativeEventEmitter()` was called with a non-null argument without the required `addListener` method.
 WARN  `new NativeEventEmitter()` was called with a non-null argument without the required `removeListeners` method.

원인 찾기

스택 오버플로우에서 찾은 내용으로 추측해 보면,

최신의 React Native 버전을 사용하는데, react-native-tts 라이브러리에는 최신 버전에 필요한 addListener, removeListeners 메서드가 구현되어 있지 않아서 발생하는 문제로 추정됩니다.

라이브러리 확인

저에게 설치된 버전은 4.1.0 버전이었습니다. react-native-tts는 4.1.1 버전이었습니다. (2023.12.20일 기준)

버전이 맞지 않아 그런가 하여 npm i react-native-tts@4.1.1 명령어를 실행해 봤는데 결과는 찾을 수 없다는 에러가 발생했습니다.

다른 방법을 찾기 위해 라이브러리 코드를 확인해 보았습니다.

라이브러리 코드 확인

제게 설치된 모듈에는 없는 addListener, removeListeners 메서드가 해당 라이브러리의 Git > TextToSpeechModule.java 코드에는 아래와 같이 구현되어 있었습니다.

    @ReactMethod
    public void removeListeners(Integer count) {
        // Keep: Required for RN built in Event Emitter Calls.
    }

    @ReactMethod
    public void addListener(String eventName) {
        // Keep: Required for RN built in Event Emitter Calls.
    }

해결

저에게 설치된 react-native-tts 모듈에 위 코드를 직접 추가했습니다.

node_modules/react-native-tts/android/src/main/java/net/no_mad/tts/TextToSpeechModule.java 파일의 끝에 위 코드를 추가했습니다.

⚠ 후에 라이브러리가 업데이트 되거나, npm install로 모듈을 다시 설치하면 해당 코드가 사라질 수 있다는 점 참고해 주세요.

그럼 추가한 코드가 적용되도록 다시 빌드 후 확인하면! 경고가 사라진 것을 확인할 수 있습니다.

Tts.removeEventListener 사용 에러

경고를 해결하고 아래와 같이 코드를 작성 후 확인을 하는데, 에러가 발생했습니다.

useEffect(() => {
    const initTts = async () => {
      // 생략
    };

    const eventStart = (event) => {
      console.log('start', event);
    }

    const eventFinish = (event) => {
      console.log('finish', event);
    }

    const eventCancel = (event) => {
      console.log('cancel', event);
    }

    Tts.addEventListener("tts-start", eventStart);
    Tts.addEventListener("tts-finish", eventFinish);
    Tts.addEventListener("tts-cancel", eventCancel);

    Tts.getInitStatus().then(initTts);

    return () => {
      Tts.removeEventListener("tts-start", eventStart);
      Tts.removeEventListener("tts-finish", eventFinish);
      Tts.removeEventListener("tts-cancel", eventCancel);
    };
  }, []);

화면을 나가면서 Tts.removeEventListener를 호출할 때 아래와 같은 에러가 발생합니다.

ERROR  Warning: Internal React error: Attempted to capture a commit phase error inside a detached tree. This indicates a bug in React. Likely causes include deleting the same fiber more than once, committing an already-finishe
d tree, or an inconsistent return pointer.

Error message:

TypeError: undefined is not a function
    in Unknown (created by ...)

removeEventListener에서 오류로 기존 이벤트가 삭제 되지 않았기 떄문에, 다음번에 다시 tts가 호출되는 화면으로 들어가면 addEventListener에 의해 이벤트가 계속해서 중복으로 쌓이게 됩니다.

아래 결과는 4번째로 tts 화면으로 들어가서 Tts.speak를 딱 한 번 호출했을 때의 로그입니다. 4개의 이벤트가 중복으로 쌓여 있는 것을 확인할 수 있습니다.

 LOG  start {"utteranceId": "-42970200"}
 LOG  start {"utteranceId": "-42970200"}
 LOG  start {"utteranceId": "-42970200"}
 LOG  start {"utteranceId": "-42970200"}
 LOG  finish {"utteranceId": "-42970200"}
 LOG  finish {"utteranceId": "-42970200"}
 LOG  finish {"utteranceId": "-42970200"}
 LOG  finish {"utteranceId": "-42970200"}

원인 찾기

최신 버전의 React Native에서는 removeEventListener를 사용할 수 없다는 내용을 찾았습니다.

removeEventListener was removed in a recent react-native release. You're supposed to call .remove() on the subscription returned by addEventListener now.


해석: 최신 버전의 React Native에서는 removeEventListener가 제거되었습니다. 이제 addEventListener로 반환된 구독(subscription)에서 .remove()를 호출해야 합니다.

해결

Tts.removeEventListener 대신 Tts.addEventListener로 반환된 구독(subscription)에서 .remove()를 호출하도록 수정했습니다.

  useEffect(() => {
    const initTts = async () => {
      // 생략
    };

    const eventStart = (event) => {
      console.log('start', event);
    }

    const eventFinish = (event) => {
      console.log('finish', event);
    }

    const eventCancel = (event) => {
      console.log('cancel', event);
    }

    // subscription을 반환 받아서
    const subscriptionStart = Tts.addEventListener("tts-start", eventStart);
    const subscriptionFinish = Tts.addEventListener("tts-finish", eventFinish);
    const subscriptionCancel = Tts.addEventListener("tts-cancel", eventCancel);
    Tts.getInitStatus().then(initTts);

    return () => {
      // 여기서 .remove()를 호출
      subscriptionStart.remove();
      subscriptionFinish.remove();
      subscriptionCancel.remove();
    };
  }, []);

에러도 해결되고, 화면에서 나갈 때 이벤트도 정상적으로 제거되는 것을 확인할 수 있습니다.

TypeScript 문제

.remove()로 변경 후 동작에는 문제가 없습니다.

하지만 TypeScript에서 빨간 줄이 보이고 TS2339: Property 'remove' does not exist on type 'void'. 에러를 표시합니다.

거슬리니 이것도 해결하기로 합니다.

원인 찾기

해당 모듈을 확인하면 addEventListener의 실제 반환 타입은 EmitterSubscription 이지만, index.d.ts 파일에는 void로 정의되어 있습니다.

// node_modules/react-native-tts/index.js
// 실제 구현 코드
  addEventListener(type, handler) {
    return this.addListener(type, handler);
  }
// node_modules/react-native-tts/index.d.ts
addEventListener: <T extends TtsEvents>(
    type: T,
    handler: TtsEventHandler<T>
  ) => void; // <-- 이 부분이 문제

해결

이 부분은 해당 모듈의 index.d.ts 파일을 직접 수정하지 않고 현재 프로젝트에서 override 하는 방식으로 해결하기로 했습니다.

최상위 폴더에 react-native-tts 폴더를 만들고, 해당 폴더에 index.d.ts 파일을 만들어 아래와 같이 작성했습니다.

(모든 내용은 동일하고 addEventListener의 반환 타입만 EmitterSubscription으로 변경했습니다.)

// react-native-tts/index.d.ts
declare module "react-native-tts" {

  export class ReactNativeTts extends RN.NativeEventEmitter {
    getInitStatus: () => Promise<"success">;
    requestInstallEngine: () => Promise<"success">;
    requestInstallData: () => Promise<"success">;
    setDucking: (enabled: boolean) => Promise<"success">;
    setDefaultEngine: (engineName: string) => Promise<boolean>;
    setDefaultVoice: (voiceId: string) => Promise<"success">;
    setDefaultRate: (rate: number, skipTransform?: boolean) => Promise<"success">;
    setDefaultPitch: (pitch: number) => Promise<"success">;
    setDefaultLanguage: (language: string) => Promise<"success">;
    setIgnoreSilentSwitch: (ignoreSilentSwitch: boolean) => Promise<boolean>;
    voices: () => Promise<Voice[]>;
    engines: () => Promise<Engine[]>;
    /** Read the sentence and return an id for the task. */
    speak: (utterance: string, options?: Options) => string | number;
    stop: (onWordBoundary?: boolean) => Promise<boolean>;
    pause: (onWordBoundary?: boolean) => Promise<boolean>;
    resume: () => Promise<boolean>;
    addEventListener: <T extends TtsEvents>(
      type: T,
      handler: TtsEventHandler<T>
    ) => EmitterSubscription; // <-- 여기만 변경
    removeEventListener: <T extends TtsEvents>(
      type: T,
      handler: TtsEventHandler<T>
    ) => void;
  }

  declare const Tts: ReactNativeTts;

  export default Tts;
}

참고로, 저는 IntelliJ를 사용하고 있는데, 해당 코드로도 빨간 줄이 제거되지 않아서 File > Invalidate Caches > 모든 옵션 체크 후 > [Invalidate and Restart]를 해서 캐시를 지우고 나니 깔끔하게 빨간 줄이 사라졌습니다.

결론

  • 'node_modules/react-native-tts/android/src/main/java/net/no_mad/tts/TextToSpeechModule.java' 파일에 addListenerremoveListener를 추가
  • removeEventListener 대신 .remove() 방식으로 수정
  • '/react-native-tts/index.d.ts' 파일을 override 하여 EmitterSubscription 타입을 반환하도록 수정

이제 모든 노란 경고와, 빨간 에러, 빨간 줄이 모두 사라졌습니다.

[심통]현장에서 바로 써먹는 리액트 with 타입스크립트 : 리액트와 스토리북으로 배우는 컴포넌트 주도 개발, 심통  리액트 네이티브를 다루는 기술:실무에서 알아야 할 기술은 따로 있다!, 길벗
(위 링크는 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.)