브라우저 내장 Web Speech API(Speech Synthesis)를 활용하면 별도 네이티브 모듈 없이도 텍스트를 음성으로 출력할 수 있습니다. React 환경에서 안전하게 지원 여부를 판단하고, 음성 목록 로딩, 속도·피치 조절, 일시정지/재개, 단어 하이라이트까지 실무적으로 적용하는 방법을 정리합니다.
1. 지원 현황과 주의 사항
대부분의 최신 브라우저에서 지원하지만, 음성 목록(voices)은 비동기로 로딩되며 iOS Safari는 사용자 제스처 이후에만 재생이 가능합니다. 또한 자동 재생 정책 때문에 페이지 로드 즉시 TTS를 시작하는 것은 피해야 합니다. React/Next.js 환경에서는 SSR 시 window가 없으므로 클라이언트에서만 API를 사용하도록 분기해야 합니다.
2. 가장 단순한 사용 예제
지원 여부를 확인하고 버튼 클릭으로 텍스트를 읽어주는 최소 구현입니다. iOS Safari 등 일부 환경에서는 사용자 이벤트(클릭)가 있어야 음성이 재생됩니다.
import React from "react";
export default function SpeakButton() {
const onSpeak = () => {
if (typeof window === "undefined") return;
if (!("speechSynthesis" in window) || typeof window.SpeechSynthesisUtterance === "undefined") {
alert("이 브라우저는 Speech Synthesis를 지원하지 않습니다.");
return;
}
const utter = new SpeechSynthesisUtterance("안녕하세요. 스피치 합성 데모입니다.");
utter.lang = "ko-KR";
utter.rate = 1; // 0.1 ~ 10
utter.pitch = 1; // 0 ~ 2
window.speechSynthesis.cancel(); // 기존 큐 제거(선택)
window.speechSynthesis.speak(utter);
};
return <button onClick={onSpeak}>읽어주기</button>;
}
3. 음성(Voice) 목록 로딩과 선택
브라우저마다 설치된 시스템 음성이 다르며, voiceschanged 이벤트가 발생한 후에야 목록을 안전하게 가져올 수 있습니다. React에서 상태로 보관해 선택할 수 있게 합니다.
import React, { useEffect, useState } from "react";
export default function VoiceSelect() {
const [voices, setVoices] = useState([]);
const [selected, setSelected] = useState(null);
useEffect(() => {
if (typeof window === "undefined" || !("speechSynthesis" in window)) return;
const synth = window.speechSynthesis;
const load = () => {
const v = synth.getVoices();
setVoices(v);
if (!selected && v.length > 0) setSelected(v.find(x => x.lang.startsWith("ko")) || v[0]);
};
// 일부 브라우저는 즉시 빈 배열을 반환하므로 이벤트와 지연 호출을 함께 사용
load();
synth.addEventListener("voiceschanged", load);
const t = setTimeout(load, 300);
return () => {
synth.removeEventListener("voiceschanged", load);
clearTimeout(t);
};
}, [selected]);
const speakSample = () => {
if (!selected) return;
const u = new SpeechSynthesisUtterance("선택한 음성으로 읽습니다.");
u.voice = selected;
u.lang = selected.lang;
window.speechSynthesis.cancel();
window.speechSynthesis.speak(u);
};
return (
<div>
<select value={selected?.voiceURI || ""} onChange={(e) => {
const v = voices.find(v => v.voiceURI === e.target.value);
setSelected(v || null);
}}>
{voices.map(v => (
<option key={v.voiceURI} value={v.voiceURI}>{`${v.name} (${v.lang})`}</option>
))}
</select>
<button onClick={speakSample} disabled={!selected}>샘플 재생</button>
</div>
);
}
4. 실전에 쓰는 커스텀 훅(useSpeechSynthesis)
말하기, 일시정지/재개, 취소, 음성 목록, 긴 문장 분할까지 포함한 훅입니다. 기본 동작은 기존 큐를 대체(replace)하며, 긴 텍스트는 문장 단위로 분할해 연속 재생합니다.
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
function isSupported() {
return typeof window !== "undefined" && "speechSynthesis" in window && typeof window.SpeechSynthesisUtterance !== "undefined";
}
function chunkText(text, maxLen = 180) {
// 문장 단위로 최대 길이를 넘지 않게 분할
const sents = text
.replace(/\s+/g, " ")
.split(/(?<=[.!?\u3002\uFF01\uFF1F])/)
.map(s => s.trim())
.filter(Boolean);
const chunks = [];
let buf = "";
for (const s of sents) {
if ((buf + " " + s).trim().length <= maxLen) {
buf = (buf ? buf + " " : "") + s;
} else {
if (buf) chunks.push(buf);
if (s.length <= maxLen) {
chunks.push(s);
buf = "";
} else {
// 너무 긴 문장은 공백 기준으로 다시 분할
let cur = "";
for (const w of s.split(" ")) {
if ((cur + " " + w).trim().length <= maxLen) cur = (cur ? cur + " " : "") + w;
else {
if (cur) chunks.push(cur);
cur = w;
}
}
if (cur) chunks.push(cur);
buf = "";
}
}
}
if (buf) chunks.push(buf);
return chunks;
}
export function useSpeechSynthesis() {
const supported = isSupported();
const [voices, setVoices] = useState([]);
const [speaking, setSpeaking] = useState(false);
const [paused, setPaused] = useState(false);
const [pending, setPending] = useState(false);
const stateRef = useRef({ cancelled: false });
useEffect(() => {
if (!supported) return;
const synth = window.speechSynthesis;
const load = () => setVoices(synth.getVoices());
load();
synth.addEventListener("voiceschanged", load);
const t = setTimeout(load, 300);
return () => {
synth.removeEventListener("voiceschanged", load);
clearTimeout(t);
};
}, [supported]);
useEffect(() => {
if (!supported) return;
const synth = window.speechSynthesis;
const onEvents = () => {
setSpeaking(synth.speaking);
setPending(synth.pending);
setPaused(synth.paused);
};
const id = setInterval(onEvents, 120);
return () => clearInterval(id);
}, [supported]);
const cancel = useCallback(() => {
if (!supported) return;
stateRef.current.cancelled = true;
window.speechSynthesis.cancel();
setSpeaking(false);
setPending(false);
setPaused(false);
// 다음 tick에 플래그 초기화
setTimeout(() => { stateRef.current.cancelled = false; }, 0);
}, [supported]);
const pause = useCallback(() => {
if (!supported) return;
window.speechSynthesis.pause();
}, [supported]);
const resume = useCallback(() => {
if (!supported) return;
window.speechSynthesis.resume();
}, [supported]);
const speak = useCallback((text, opts = {}) => {
if (!supported || !text) return;
const {
voice, // SpeechSynthesisVoice 객체
voiceURI, // 또는 특정 voiceURI/name
lang = "ko-KR",
rate = 1,
pitch = 1,
volume = 1,
queue = "replace", // "replace" | "enqueue"
onBoundary, // (payload) => void
onEnd, // () => void
onStart, // () => void
} = opts;
if (queue === "replace") {
window.speechSynthesis.cancel();
}
const vs = voices.length ? voices : (supported ? window.speechSynthesis.getVoices() : []);
const pickVoice = () => {
if (voice) return voice;
if (voiceURI) return vs.find(v => v.voiceURI === voiceURI || v.name === voiceURI) || null;
const ko = vs.find(v => v.lang && v.lang.startsWith("ko"));
return ko || vs[0] || null;
};
const chosen = pickVoice();
const chunks = chunkText(text);
let baseIndex = 0; // chunk 누적 offset
const playChunk = (i) => {
if (stateRef.current.cancelled) return;
if (i >= chunks.length) {
onEnd && onEnd();
return;
}
const u = new SpeechSynthesisUtterance(chunks[i]);
u.lang = chosen?.lang || lang;
u.rate = rate;
u.pitch = pitch;
u.volume = volume;
if (chosen) u.voice = chosen;
u.onstart = () => {
onStart && i === 0 && onStart();
};
u.onboundary = (e) => {
if (!onBoundary) return;
onBoundary({
type: e.name || "word",
charIndex: baseIndex + (e.charIndex || 0),
chunkIndex: i,
text: chunks[i],
});
};
u.onend = () => {
baseIndex += chunks[i].length + 1; // 간단 누적(공백 보정)
playChunk(i + 1);
};
window.speechSynthesis.speak(u);
};
playChunk(0);
}, [supported, voices]);
return { supported, voices, speak, cancel, pause, resume, speaking, paused, pending };
}
5. 단어 하이라이트(경계 이벤트) 예시
boundary 이벤트의 charIndex를 이용해 현재 단어를 하이라이트할 수 있습니다. 아래 예시는 텍스트의 단어 오프셋을 계산하고, 재생 중인 위치에 해당하는 단어에 스타일을 적용합니다.
import React, { useMemo, useState } from "react";
import { useSpeechSynthesis } from "./useSpeechSynthesis";
function buildWordOffsets(text) {
const words = [];
const regex = /\S+/g;
let match;
while ((match = regex.exec(text))) {
words.push({ start: match.index, end: match.index + match[0].length });
}
return words;
}
export default function Highlighter() {
const [text, setText] = useState("안녕하세요. 이 문장은 단어 하이라이트를 테스트합니다.");
const [currentWord, setCurrentWord] = useState(-1);
const { supported, speak, cancel, speaking } = useSpeechSynthesis();
const offsets = useMemo(() => buildWordOffsets(text), [text]);
const onSpeak = () => {
setCurrentWord(-1);
speak(text, {
rate: 1,
pitch: 1,
onBoundary: ({ charIndex }) => {
// charIndex에 해당하는 단어 찾기
const idx = offsets.findIndex(w => charIndex >= w.start && charIndex < w.end);
if (idx !== -1) setCurrentWord(idx);
},
onEnd: () => setCurrentWord(-1),
});
};
const renderText = () => {
return text.split(/(\s+)/).map((seg, i) => {
if (/^\s+$/.test(seg)) return <span key={i}>{seg}</span>;
// seg가 실제 단어인 경우, offsets의 인덱스를 매칭
const start = text.indexOf(seg, offsets.find(o => o.start >= 0)?.start || 0);
const wordIdx = offsets.findIndex(o => o.start === start);
const active = wordIdx === currentWord;
return <span key={i} style={{ background: active ? "#ffe08a" : "transparent" }}>{seg}</span>;
});
};
return (
<div>
<textarea value={text} onChange={e => setText(e.target.value)} rows={4} style={{ width: "100%" }} />
<div style={{ margin: "8px 0" }}>{renderText()}</div>
<button onClick={onSpeak} disabled={!supported || speaking}>읽기</button>
<button onClick={cancel} disabled={!speaking}>중지</button>
</div>
);
}
6. Next.js/SSR에서의 안전한 사용
SSR 시점에는 window가 없으므로 클라이언트 사이드에서만 훅과 API를 사용해야 합니다. dynamic import의 ssr: false 옵션 또는 컴포넌트 내부에서 typeof window 체크를 통해 보호합니다.
// 예: Next.js에서 클라이언트 전용 컴포넌트로 분리
import dynamic from "next/dynamic";
const TtsClientOnly = dynamic(() => import("./TtsComponent"), { ssr: false });
export default function Page() {
return <TtsClientOnly />;
}
7. 실무 팁과 문제 해결
첫째, 사용자 제스처 이후 재생을 권장합니다. 앱 시작 시 자동 재생은 정책에 막힐 수 있습니다. 둘째, voiceschanged 이벤트는 여러 번 발생할 수 있으니 중복 로딩을 방지하거나 마지막 상태만 반영하십시오. 셋째, 긴 텍스트는 분할해서 재생하면 Safari에서 끊김이나 무응답을 줄입니다. 넷째, 언어(lang)와 voice가 일치하도록 설정해야 발음이 자연스럽습니다. 다섯째, 취소 시 이벤트 리스너는 자동 해제되지만 utterance 인스턴스를 재사용하지 말고 매 호출마다 새로 만들면 안전합니다. 여섯째, 접근성 관점에서 자동 낭독보다는 버튼, 키보드 포커스, 진행 상태 표시 등을 제공하는 것이 좋습니다.
8. 체크리스트
브라우저 지원 여부 점검, 사용자 제스처 유도, voiceschanged 처리, 언어/음성 매칭, rate/pitch/volume 조절 UI, 일시정지/재개/중지 UX, 긴 텍스트 분할, 경계 이벤트로 하이라이트, SSR 가드 적용까지 준비하면 대부분의 TTS 요구사항을 React에서 안정적으로 충족할 수 있습니다.
'React' 카테고리의 다른 글
| React에서 애플리케이션 Configuration 파일 동적 로드하기 (0) | 2026.07.10 |
|---|---|
| React 앱에서 사용자 활동 로그 시각화하기 (1) | 2026.07.10 |
| React 앱에서 블루투스 디바이스 연동하기 (0) | 2026.07.09 |
| React에서 오프라인 모드 전환 UI 구성하기 (0) | 2026.07.08 |
| React 앱에서 지도 경로 탐색(Route Finding) 기능 추가하기 (0) | 2026.07.08 |