이번 프로젝트에서는 음성을 텍스트로 변환하고(STT) 달리에게 텍스트를 기반으로 그림을 그려달라고 요청하는(Open AI API) 서비스를 구현하고 있다

 

그 과정에서 form data를 처음 다뤄보게 되었고, 특히 여러 파일을 multipart로 한 번에 보내면서 정말 며칠을 헤맸던 것 같다

생각보다 자료가 많이 없어서 스택오버플로우와 깃허브 이슈, 미디엄, 코드샌드박스, 하다하다 무슨 중국어로 된 qna 사이트까지 다 뒤져가면서 구글링했다 하하

 

처음에는 한국어로 된 블로그를 보고 녹음 기능을 생각보다 금방 구현해서 오! 간단한데? 라고 생각.. 했으나 (사망 플래그)

mp3로 파일을 변환하는 과정이나 서버로 파일을 보내는 과정 등을 확인할 수 없어서 직접 이것저것 찾아보며 구현했고

기존 코드에 대한 이해도가 높지 않다보니 변환한 mp3 파일이 비어있는 등 여기저기서 문제가 발생했다

어찌저찌 하나씩 문제를 해결해서 길고 복잡한.. 코드가 만들어졌지만, 내 콘솔에는 분명 mp3 파일이 찍히고 다운로드도 되는데 백에서는 유효한 파일 형식이 아니라는 응답이 오는 등... 머리 깨질 것 같은 에러가 반복됐다

 

콘솔에 formData가 잘만 찍히는데

 

막 이래..........

 

주변 현직자분께도 질문 드리고 qna도 해보고 스스로도 찾아보면서 문제점을 발견하려고 했으나, 이미 코드가 너무나 길고 방대하고.. 하고자 하는 기능이 너무나 많아서 문제점이 잘 보이지 않았다

이때 현직자분께서 이런 조언을 해주셨다

 

너무 어려울 때는, 오히려 처음부터 다시 시작해봐!

 

자....그래서 처음부터 다시 시작했다

 


우선은 기능 구현이 주 목적이고, 해커톤 마감까지 남은 기간이 얼마 없기 때문에 완성을 최우선으로 구현했다. 그래서 구조가 가장 효율적인 방식은 아닐수도 있다는 점 감안 부탁..!

 

아래는 녹음 기능 + mp3 파일 서버 전송 + 응답을 받는 전체 코드이다.

사실 질문이 여러 개라 다른 페이지가 더 있는데, 주요한 기능은 여기서 다 처리해서 이 코드를 첨부했다.

 

import React from "react";
import { useNavigate } from "react-router-dom";
import axios from "axios";
import useStore from "../../../state/store";
import MicRecorder from "mic-recorder-to-mp3";
import * as S from "../Word.style";
import * as W from "./VoiceRecord.style";

const RcThird = (e) => {
  const navigate = useNavigate();

  const {
    setAudioUrl,
    keyword,
    audioData,
    isRecording,
    setIsRecording,
    recorder,
    setRecorder,
    setPictureDiary,
    setTextDiary,
  } = useStore((state) => state);

  const accessToken = localStorage.getItem("accessToken");
  const recordApiUrl = "api 주소를 이곳에 입력해주시면 됩니다";

  const startRecording = async () => {
    const mp3Recorder = new MicRecorder({ bitRate: 128 });

    try {
      await mp3Recorder.start();
      setRecorder(mp3Recorder);
      setIsRecording(true);
    } catch (error) {
      console.error(error);
    }
  };

  const stopRecording = async () => {
    try {
      const [buffer, blob] = await recorder.stop().getMp3();
      const file = new File(buffer, "3rd_q.mp3", {
        type: blob.type,
        lastModified: Date.now(),
      });

      if (file.size === 0) {
        console.log("파일이 비어있거나 유효하지 않습니다");
        return;
      }

      const formData = new FormData();
      formData.append("keyword", keyword);
      formData.append("firstAnswer", audioData.get("firstAnswer"));
      formData.append("secondAnswer", audioData.get("secondAnswer"));
      formData.append("thirdAnswer", file);

      console.log("formData:", ...formData);

      setAudioUrl(URL.createObjectURL(file));
      setIsRecording(false);

      if (accessToken) {
        axios
          .post(recordApiUrl, formData, {
            headers: {
              "Content-Type": "multipart/form-data",
              Authorization: `Bearer ${accessToken}`,
            },
          })
          .then((response) => {
            console.log("음성 파일 업로드 성공 ✨", response.data);
            setTextDiary(response.data.textDiary);
            setPictureDiary(response.data.pictureDiary);
          })
          .catch((error) => {
            console.error("음성 파일 업로드 실패:", error);
          });
      } else {
        console.log("액세스 토큰이 존재하지 않습니다");
      }
    } catch (error) {
      console.error(error);
    }
  };

  const handleButtonClick = () => {
    if (isRecording) {
      stopRecording();
      navigate("/textResult");
    } else {
      startRecording();
    }
  };

  return (
    <W.VoiceRecordContainer>
      <S.TodayWordContainer>
        <S.TodayWord>
          {(function () {
            if (keyword === "가족")
              return <S.TodayWordContent src="/images/text_family.png" />;
            if (keyword === "친구")
              return <S.TodayWordContent src="/images/text_friend.png" />;
            if (keyword === "여행")
              return <S.TodayWordContent src="/icons/text_travel.png" />;
          })()}
        </S.TodayWord>
      </S.TodayWordContainer>
      <W.VoiceRecordTitle>
        누구와 어떤 하루를 <br /> 보내셨나요?
      </W.VoiceRecordTitle>
      {isRecording ? (
        <>
          <W.ListeningText>귀기울여 듣고 있어요</W.ListeningText>
          <W.StartRecordButton onClick={handleButtonClick} value={1}>
            말 끝내기
          </W.StartRecordButton>
        </>
      ) : (
        <W.StartRecordButton onClick={handleButtonClick} value={0}>
          말 시작하기
        </W.StartRecordButton>
      )}
      <W.CancelButton onClick={() => navigate("/word")}>
        취소하기
      </W.CancelButton>
    </W.VoiceRecordContainer>
  );
};

export default RcThird;

 

우선 녹음한 음성을 mp3 파일로 변환하기 위해 아래 라이브러리를 설치해서 import 해준다

import MicRecorder from "mic-recorder-to-mp3";

 

 

상태관리는 zustand를 사용하고 있기 때문에, store 폴더에 저장해둔 state를 불러온다

(여담이지만 zustand 진짜 짱이다....)

 const {
    setAudioUrl,
    keyword,
    audioData,
    isRecording,
    setIsRecording,
    recorder,
    setRecorder,
    setPictureDiary,
    setTextDiary,
  } = useStore((state) => state);

 

간단하게 녹음을 구현해준다. bitRate는 128로 해뒀는데 때에 따라 오류가 나면 낮춰야할 수도 있다.

가끔 여기서 오류가 나는 사람도 있다길래..!

 const startRecording = async () => {
    const mp3Recorder = new MicRecorder({ bitRate: 128 });

    try {
      await mp3Recorder.start();
      setRecorder(mp3Recorder);
      setIsRecording(true);
    } catch (error) {
      console.error(error);
    }
  };

 

자 이제 stopRecording 함수를 작성해준다 (여기서 대부분의 시간을 썼다)

글로 쓰기에는 애매할 것 같아서 주석으로 설명을 작성해두었다

const stopRecording = async () => {
    try {
      const [buffer, blob] = await recorder.stop().getMp3();
      const file = new File(buffer, "3rd_q.mp3", {
        type: blob.type,
        lastModified: Date.now(),
      });

	  // 빈 파일이거나 유효하지 않을 경우 콘솔에 표시
      if (file.size === 0) {
        console.log("파일이 비어있거나 유효하지 않습니다");
        return;
      }

	  // 새로운 formData 생성
      const formData = new FormData();
      
      // formData에 필요한 내용을 추가해준다
      formData.append("keyword", keyword);
      formData.append("firstAnswer", audioData.get("firstAnswer"));
      formData.append("secondAnswer", audioData.get("secondAnswer"));
      formData.append("thirdAnswer", file);

	  // formData는 그냥 콘솔에 찍으면 빈 객체로 나오므로 펼쳐서 내용을 확인한다
      console.log("formData:", ...formData);

      setAudioUrl(URL.createObjectURL(file));
      setIsRecording(false);

	  // 액세스 토큰이 있을 경우 API 통신 진행
      if (accessToken) {
        axios
          .post(recordApiUrl, formData, {
            headers: {
              "Content-Type": "multipart/form-data",  // 여러 파일을 form data로 전송
              Authorization: `Bearer ${accessToken}`, // 액세스 토큰을 헤더에 담아서 전송
            },
          })
          .then((response) => {
            console.log("음성 파일 업로드 성공 ✨", response.data); // 응답 데이터 출력
            setTextDiary(response.data.textDiary); // STT 결과 데이터
            setPictureDiary(response.data.pictureDiary); // AI 그림 데이터
          })
          .catch((error) => {
            console.error("음성 파일 업로드 실패:", error);
          });
      } else {
        console.log("액세스 토큰이 존재하지 않습니다");
      }
    } catch (error) {
      console.error(error);
    }
  };

 

 

그리고 마지막으로 버튼을 누르면 진행할 액션에 대해서 별도의 함수를 만들어 관리해준다

나는 녹음을 마치면 서버에 파일을 보내고, 응답을 받은 후에 STT 결과를 보여주는 페이지로 넘어가도록 설정했다

 const handleButtonClick = () => {
    if (isRecording) {
      stopRecording();
      navigate("/textResult");
    } else {
      startRecording();
    }
  };

 

 

 

 

 


 

TIL 끝!

 

'Web > React' 카테고리의 다른 글

[React] 라우터 구현하기 + reset css 설치  (0) 2022.11.04
Vercel 로 React 프로젝트 배포하기  (0) 2022.10.27
[React] 리액트 설치와 세팅법  (2) 2022.09.20