diff --git a/features/voice-input-stt-guide.md b/features/voice-input-stt-guide.md
new file mode 100644
index 0000000..0b204ca
--- /dev/null
+++ b/features/voice-input-stt-guide.md
@@ -0,0 +1,674 @@
+# 음성 입력(STT) 기술 가이드
+
+> **문서 버전**: 1.0
+> **작성일**: 2026-02-10
+> **최초 적용**: 공사현장 사진대지 (`/juil/construction-photos`)
+> **대상 프로젝트**: MNG (React 18 + Babel in-browser)
+
+---
+
+## 1. 개요
+
+### 1.1 목적
+
+텍스트 입력 필드(input, textarea)에 **마이크 버튼**을 배치하여, 사용자가 음성으로 텍스트를 입력할 수 있게 하는 브라우저 내장 STT(Speech-to-Text) 기능.
+
+### 1.2 기술 선택
+
+| 방식 | 비용 | 정확도 | 지연 | 채택 |
+|------|------|--------|------|------|
+| Web Speech API (브라우저 내장) | **무료** | 높음 (Google STT 엔진) | 실시간 | **채택** |
+| Google Cloud STT API | 유료 ($0.006/15초) | 매우 높음 | 서버 왕복 | 미채택 |
+| Whisper (OpenAI) | 유료 ($0.006/분) | 매우 높음 | 서버 왕복 | 미채택 |
+
+**선택 이유**: 브라우저 내장 Web Speech API는 Chrome 기반에서 Google STT 엔진을 무료로 사용하며, 실시간 스트리밍으로 interim/final 결과를 즉시 받을 수 있다. 비용 없이 충분한 한국어 인식률을 제공한다.
+
+### 1.3 브라우저 지원
+
+| 브라우저 | 지원 | 비고 |
+|----------|------|------|
+| Chrome (Desktop/Android) | ✅ | 최적 지원, Google STT 엔진 사용 |
+| Edge | ✅ | Chromium 기반 |
+| Safari (iOS/macOS) | ✅ | `webkitSpeechRecognition` |
+| Firefox | ❌ | 미지원 (버튼 자동 숨김) |
+
+---
+
+## 2. 핵심 개념: Interim vs Final
+
+Web Speech API의 핵심은 **미확정(interim)** 텍스트와 **확정(final)** 텍스트의 구분이다.
+
+### 2.1 텍스트 상태 흐름
+
+```
+[음성 입력 시작]
+ │
+ ├─ interim: "안녕하" ← 인식 진행 중 (수정될 수 있음)
+ ├─ interim: "안녕하세" ← 교정 발생 (이전 interim 덮어씀)
+ ├─ interim: "안녕하세요" ← 교정 발생
+ │
+ ├─ ★ FINAL: "안녕하세요" ← 확정! (절대 삭제 불가)
+ │
+ ├─ interim: "반갑습" ← 새로운 인식 시작
+ ├─ interim: "반갑습니다"
+ │
+ ├─ ★ FINAL: "반갑습니다" ← 확정!
+ │
+[음성 입력 종료]
+```
+
+### 2.2 렌더링 규칙 (필수 준수)
+
+| 상태 | 스타일 | 동작 | 삭제 가능 |
+|------|--------|------|-----------|
+| **interim** (미확정) | `italic` + `text-gray-400` | 실시간 교정됨. 이전 interim을 덮어씀 | 교정만 허용 |
+| **final** (확정) | `font-normal` + `text-white` | `finalizedSegments[]` 배열에 영구 추가 | **절대 불가** |
+
+### 2.3 input 반영 규칙
+
+- **final 이벤트 발생 시에만** `onResult(transcript)` 호출하여 input에 텍스트 추가
+- interim 텍스트는 **프리뷰 패널에만** 표시하고, input에는 반영하지 않음
+- input에 추가된 텍스트는 사용자가 직접 수정 가능 (일반 텍스트)
+
+---
+
+## 3. 컴포넌트 아키텍처
+
+### 3.1 VoiceInputButton 컴포넌트
+
+```
+┌─────────────────────────────────┐
+│ VoiceInputButton │
+│ │
+│ Props: │
+│ onResult: (text) => void │ ← final 텍스트만 전달
+│ disabled: boolean │ ← 비활성화 (읽기 모드 등)
+│ │
+│ State: │
+│ recording: boolean │ ← 녹음 중 여부
+│ finalizedSegments: string[] │ ← 확정 텍스트 누적 (프리뷰용)
+│ interimText: string │ ← 현재 미확정 텍스트
+│ │
+│ Refs: │
+│ recognitionRef │ ← SpeechRecognition 인스턴스
+│ startTimeRef │ ← 녹음 시작 시각 (사용량 추적)
+│ dismissTimerRef │ ← 프리뷰 닫기 타이머
+│ previewRef │ ← 프리뷰 DOM (자동 스크롤)
+│ │
+│ Output: │
+│ [마이크 버튼] + [프리뷰 패널] │
+└─────────────────────────────────┘
+```
+
+### 3.2 전체 코드
+
+```jsx
+function VoiceInputButton({ onResult, disabled }) {
+ const [recording, setRecording] = useState(false);
+ const [finalizedSegments, setFinalizedSegments] = useState([]);
+ const [interimText, setInterimText] = useState('');
+ const recognitionRef = useRef(null);
+ const startTimeRef = useRef(null);
+ const dismissTimerRef = useRef(null);
+ const previewRef = useRef(null);
+
+ // 브라우저 지원 확인
+ const isSupported = typeof window !== 'undefined' &&
+ (window.SpeechRecognition || window.webkitSpeechRecognition);
+
+ // STT 사용량 로깅 (AI 토큰 사용량 추적)
+ const logUsage = useCallback((startTime) => {
+ const duration = Math.max(1, Math.round((Date.now() - startTime) / 1000));
+ apiFetch(API.logSttUsage, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ duration_seconds: duration }),
+ }).catch(() => {});
+ }, []);
+
+ // 프리뷰 패널 자동 스크롤
+ useEffect(() => {
+ if (previewRef.current) {
+ previewRef.current.scrollTop = previewRef.current.scrollHeight;
+ }
+ }, [finalizedSegments, interimText]);
+
+ // 녹음 중지
+ const stopRecording = useCallback(() => {
+ recognitionRef.current?.stop();
+ recognitionRef.current = null;
+ if (startTimeRef.current) {
+ logUsage(startTimeRef.current);
+ startTimeRef.current = null;
+ }
+ setRecording(false);
+ setInterimText('');
+ // 녹음 종료 후 2초 뒤 프리뷰 닫기
+ dismissTimerRef.current = setTimeout(() => {
+ setFinalizedSegments([]);
+ }, 2000);
+ }, [logUsage]);
+
+ // 녹음 시작
+ const startRecording = useCallback(() => {
+ // 이전 타이머 정리
+ if (dismissTimerRef.current) {
+ clearTimeout(dismissTimerRef.current);
+ dismissTimerRef.current = null;
+ }
+
+ const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
+ const recognition = new SR();
+ recognition.lang = 'ko-KR'; // 한국어
+ recognition.continuous = true; // 연속 인식 (자동 종료 안 함)
+ recognition.interimResults = true; // interim 결과 수신
+ recognition.maxAlternatives = 1; // 후보 1개만
+
+ recognition.onresult = (event) => {
+ // dismiss 타이머 취소 (아직 인식 중)
+ if (dismissTimerRef.current) {
+ clearTimeout(dismissTimerRef.current);
+ dismissTimerRef.current = null;
+ }
+
+ let currentInterim = '';
+ for (let i = event.resultIndex; i < event.results.length; i++) {
+ const transcript = event.results[i][0].transcript;
+ if (event.results[i].isFinal) {
+ // ★ 확정: input에 반영 + 프리뷰에 영구 저장
+ onResult(transcript);
+ setFinalizedSegments(prev => [...prev, transcript]);
+ currentInterim = '';
+ } else {
+ // 미확정: 교정은 허용하되 이전 확정분은 보존
+ currentInterim = transcript;
+ }
+ }
+ setInterimText(currentInterim);
+ };
+
+ recognition.onerror = () => stopRecording();
+
+ recognition.onend = () => {
+ // 브라우저가 자동 종료한 경우 처리
+ if (startTimeRef.current) {
+ logUsage(startTimeRef.current);
+ startTimeRef.current = null;
+ }
+ setRecording(false);
+ setInterimText('');
+ recognitionRef.current = null;
+ dismissTimerRef.current = setTimeout(() => {
+ setFinalizedSegments([]);
+ }, 2000);
+ };
+
+ recognitionRef.current = recognition;
+ startTimeRef.current = Date.now();
+ setFinalizedSegments([]);
+ setInterimText('');
+ recognition.start();
+ setRecording(true);
+ }, [onResult, stopRecording, logUsage]);
+
+ // 토글 (시작/중지)
+ const toggle = useCallback((e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (disabled || !isSupported) return;
+ recording ? stopRecording() : startRecording();
+ }, [disabled, isSupported, recording, stopRecording, startRecording]);
+
+ // 컴포넌트 언마운트 시 정리
+ useEffect(() => {
+ return () => {
+ recognitionRef.current?.stop();
+ if (dismissTimerRef.current) clearTimeout(dismissTimerRef.current);
+ };
+ }, []);
+
+ // 미지원 브라우저에서는 렌더링하지 않음
+ if (!isSupported) return null;
+
+ const hasContent = finalizedSegments.length > 0 || interimText;
+
+ return (
+
+ {/* 마이크 버튼 */}
+
+
+ {/* 스트리밍 프리뷰 패널 */}
+ {(recording || hasContent) && (
+
+ {/* 확정 텍스트: 일반체 + 흰색 */}
+ {finalizedSegments.map((seg, i) => (
+
+ {seg}
+
+ ))}
+
+ {/* 미확정 텍스트: 이탤릭 + 연한 회색 */}
+ {interimText && (
+
+ {interimText}
+
+ )}
+
+ {/* 녹음 중 + 텍스트 없음: 대기 표시 */}
+ {recording && !hasContent && (
+
+
+ 말씀하세요...
+
+ )}
+
+ {/* 녹음 종료 후 확정 텍스트 완료 표시 */}
+ {!recording && finalizedSegments.length > 0 && !interimText && (
+ ✓
+ )}
+
+ )}
+
+ );
+}
+```
+
+---
+
+## 4. 사용 패턴
+
+### 4.1 기본 사용법 (input 옆에 배치)
+
+```jsx
+function MyForm() {
+ const [value, setValue] = useState('');
+
+ return (
+
+
+
+ setValue(e.target.value)}
+ className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm"
+ placeholder="입력하세요"
+ />
+ setValue(prev =>
+ prev ? prev + ' ' + text : text
+ )}
+ />
+
+
+ );
+}
+```
+
+### 4.2 textarea와 함께 사용
+
+```jsx
+ {/* items-start: 상단 정렬 */}
+
+```
+
+### 4.3 조건부 활성화 (수정 모드에서만)
+
+```jsx
+ setSiteName(prev => prev ? prev + ' ' + text : text)}
+ disabled={!editing} // 수정 모드가 아닐 때 비활성화
+/>
+```
+
+### 4.4 onResult 콜백 패턴
+
+```jsx
+// 패턴 1: 기존 텍스트에 이어붙이기 (공백 구분)
+onResult={(text) => setValue(prev => prev ? prev + ' ' + text : text)}
+
+// 패턴 2: 덮어쓰기
+onResult={(text) => setValue(text)}
+
+// 패턴 3: 커스텀 후처리
+onResult={(text) => {
+ const cleaned = text.trim().replace(/\s+/g, ' ');
+ setValue(prev => prev + ' ' + cleaned);
+}}
+```
+
+---
+
+## 5. 프리뷰 패널 UI 상세
+
+### 5.1 위치와 스타일
+
+```
+ ┌─────────────────────────────┐
+ │ 확정텍스트 미확정텍스트... │ ← 프리뷰 패널
+ │ (흰색,일반체) (회색,이탤릭) │ bg-gray-900
+ └─────────────────────────────┘ w-[300px]
+ ┌──┐ max-h-[120px]
+ │🎤│ line-height: 1.6
+ └──┘
+```
+
+- **위치**: 버튼 상단 (`absolute bottom-full mb-2 right-0`)
+- **배경**: 다크 (`bg-gray-900`) - 밝은 폼 위에서 눈에 잘 띔
+- **너비**: 300px 고정, 높이 최대 120px (스크롤)
+- **자동 스크롤**: 텍스트가 길어지면 하단으로 자동 스크롤
+
+### 5.2 상태별 표시
+
+| 상태 | 표시 내용 |
+|------|-----------|
+| 녹음 시작 직후 (텍스트 없음) | 🔴 `말씀하세요...` (빨간 점 + 회색 텍스트) |
+| interim 수신 중 | 확정 텍스트(흰) + 미확정 텍스트(회색 이탤릭) |
+| final 확정 순간 | 이전 확정 + 새 확정(흰) 추가, interim 초기화 |
+| 녹음 종료 직후 | 모든 확정 텍스트 + ✓ 표시(녹색) |
+| 종료 후 2초 | 패널 자동 닫힘 (`finalizedSegments` 초기화) |
+
+### 5.3 transition 설정
+
+```
+확정 텍스트: transition-colors duration-300 (0.3초 색상 전환)
+미확정 텍스트: transition-colors duration-200 (0.2초 색상 전환)
+line-height: 1.6 고정 (줄 높이 변동 방지)
+```
+
+---
+
+## 6. SpeechRecognition 설정 상세
+
+### 6.1 주요 옵션
+
+```javascript
+const recognition = new SpeechRecognition();
+recognition.lang = 'ko-KR'; // 언어 (한국어)
+recognition.continuous = true; // 연속 인식 모드
+recognition.interimResults = true; // interim 결과 수신
+recognition.maxAlternatives = 1; // 인식 후보 수
+```
+
+| 옵션 | 값 | 설명 |
+|------|-----|------|
+| `lang` | `'ko-KR'` | 한국어 인식. 다국어 필요 시 변경 |
+| `continuous` | `true` | 말을 멈춰도 자동 종료하지 않음. 사용자가 직접 중지 |
+| `interimResults` | `true` | 미확정 결과를 실시간 수신 (false면 final만) |
+| `maxAlternatives` | `1` | 인식 결과 후보 1개만 (속도 최적화) |
+
+### 6.2 이벤트 핸들러
+
+| 이벤트 | 발생 시점 | 처리 |
+|--------|-----------|------|
+| `onresult` | 인식 결과 수신 | interim/final 구분 후 상태 업데이트 |
+| `onerror` | 인식 오류 | 녹음 중지 |
+| `onend` | 인식 세션 종료 | 정리 + 사용량 로깅 + 프리뷰 dismiss 타이머 |
+
+### 6.3 onresult 이벤트 상세
+
+```javascript
+recognition.onresult = (event) => {
+ // event.resultIndex: 이번 이벤트에서 변경된 결과의 시작 인덱스
+ // event.results: SpeechRecognitionResultList (누적)
+ // event.results[i].isFinal: 확정 여부
+ // event.results[i][0].transcript: 인식된 텍스트
+
+ for (let i = event.resultIndex; i < event.results.length; i++) {
+ const transcript = event.results[i][0].transcript;
+ if (event.results[i].isFinal) {
+ // → input에 반영 + finalizedSegments에 추가
+ } else {
+ // → interimText 업데이트 (이전 interim 덮어씀)
+ }
+ }
+};
+```
+
+**주의**: `event.resultIndex`부터 순회해야 한다. 전체(`0`부터)를 순회하면 이미 처리한 final 결과를 중복 처리하게 된다.
+
+---
+
+## 7. 백엔드 (STT 사용량 추적)
+
+### 7.1 라우트
+
+```php
+// routes/web.php (juil 그룹 내)
+Route::post('/construction-photos/log-stt-usage',
+ [ConstructionSitePhotoController::class, 'logSttUsage']
+)->name('construction-photos.log-stt-usage');
+```
+
+### 7.2 컨트롤러
+
+```php
+public function logSttUsage(Request $request): JsonResponse
+{
+ $validated = $request->validate([
+ 'duration_seconds' => 'required|integer|min:1',
+ ]);
+
+ AiTokenHelper::saveSttUsage(
+ '공사현장사진대지-음성입력', // 메뉴명 (사용처 식별)
+ $validated['duration_seconds']
+ );
+
+ return response()->json(['success' => true]);
+}
+```
+
+### 7.3 AiTokenHelper::saveSttUsage
+
+```php
+// App\Helpers\AiTokenHelper
+
+/**
+ * STT 사용량 기록
+ * - 과금 기준: $0.009 / 15초
+ * - Google Cloud Speech-to-Text 기준 단가
+ *
+ * @param string $menuName 사용처 메뉴명
+ * @param int $durationSeconds 녹음 시간(초)
+ */
+public static function saveSttUsage(string $menuName, int $durationSeconds): void
+```
+
+### 7.4 새 페이지에 STT 적용 시 라우트 추가 패턴
+
+```php
+// 1. 컨트롤러에 logSttUsage 메서드 추가
+public function logSttUsage(Request $request): JsonResponse
+{
+ $validated = $request->validate([
+ 'duration_seconds' => 'required|integer|min:1',
+ ]);
+
+ AiTokenHelper::saveSttUsage(
+ '새메뉴명-음성입력', // ← 메뉴명 변경
+ $validated['duration_seconds']
+ );
+
+ return response()->json(['success' => true]);
+}
+
+// 2. 라우트 등록
+Route::post('/new-page/log-stt-usage', [NewController::class, 'logSttUsage'])
+ ->name('new-page.log-stt-usage');
+
+// 3. 프론트엔드 API 객체에 추가
+const API = {
+ logSttUsage: '/path/to/log-stt-usage',
+};
+```
+
+---
+
+## 8. 새 페이지에 음성 입력 적용 체크리스트
+
+### 8.1 프론트엔드
+
+```
+□ 1. VoiceInputButton 컴포넌트 코드 복사 (또는 공통 모듈화 후 import)
+□ 2. API 객체에 logSttUsage 엔드포인트 추가
+□ 3. input/textarea 옆에 VoiceInputButton 배치
+□ 4. onResult 콜백에서 기존 텍스트에 이어붙이기 패턴 적용
+□ 5. disabled prop으로 수정 모드에서만 활성화 (필요 시)
+□ 6. flex 레이아웃 확인:
+ - input: items-center gap-2 (한 줄)
+ - textarea: items-start gap-2 (상단 정렬)
+```
+
+### 8.2 백엔드
+
+```
+□ 1. 컨트롤러에 logSttUsage 메서드 추가
+□ 2. AiTokenHelper::saveSttUsage() 호출 (메뉴명 지정)
+□ 3. routes/web.php에 POST 라우트 등록
+```
+
+### 8.3 레이아웃 참고
+
+```
+┌───────────────────────────────────────────┐
+│ label │
+│ ┌──────────────────────────────────┐ ┌──┐ │
+│ │ input text │ │🎤│ │
+│ └──────────────────────────────────┘ └──┘ │
+│ │
+│ label │
+│ ┌──────────────────────────────────┐ ┌──┐ │
+│ │ textarea │ │🎤│ │
+│ │ │ │ │ │
+│ │ │ │ │ │
+│ └──────────────────────────────────┘ └──┘ │
+└───────────────────────────────────────────┘
+```
+
+---
+
+## 9. 주의사항 및 트러블슈팅
+
+### 9.1 HTTPS 필수
+
+Web Speech API는 **HTTPS** 환경에서만 동작한다 (localhost는 예외). HTTP 배포 시 마이크 접근이 차단된다.
+
+### 9.2 브라우저 자동 종료
+
+`continuous: true`로 설정해도, 브라우저가 긴 무음 구간에서 자동으로 인식을 종료할 수 있다. `onend` 이벤트에서 이를 처리한다.
+
+### 9.3 마이크 권한
+
+첫 사용 시 브라우저가 마이크 접근 권한을 요청한다. 사용자가 거부하면 `onerror`가 발생하고 버튼이 중지 상태로 돌아간다.
+
+### 9.4 컴포넌트 언마운트 시 정리
+
+모달 안에서 사용할 경우, 모달이 닫힐 때 컴포넌트가 언마운트된다. `useEffect` cleanup에서 반드시 `recognition.stop()`과 `clearTimeout`을 호출해야 한다.
+
+```javascript
+useEffect(() => {
+ return () => {
+ recognitionRef.current?.stop();
+ if (dismissTimerRef.current) clearTimeout(dismissTimerRef.current);
+ };
+}, []);
+```
+
+### 9.5 이벤트 전파 방지
+
+마이크 버튼이 form 안에 있으면 클릭 시 form submit이 발생할 수 있다. 반드시 `e.preventDefault()` + `e.stopPropagation()`을 호출한다.
+
+```javascript
+const toggle = useCallback((e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ // ...
+}, []);
+```
+
+### 9.6 다중 VoiceInputButton
+
+한 페이지에 여러 VoiceInputButton을 배치할 수 있다. 각 인스턴스는 독립적인 `recognitionRef`를 가지므로 충돌하지 않는다. 단, **동시에 2개 이상 녹음은 불가**하다 (브라우저 마이크 제한). 한 버튼이 녹음 중일 때 다른 버튼을 누르면 기존 녹음이 중단된다 (브라우저 동작).
+
+---
+
+## 10. 향후 확장 가능성
+
+| 기능 | 설명 | 난이도 |
+|------|------|--------|
+| 화자 분리 (Speaker Diarization) | 여러 사람의 음성을 구분하여 각각 텍스트화 | Google Cloud STT API 필요 |
+| 다국어 전환 | `recognition.lang`을 동적으로 변경 | 낮음 |
+| 음성 명령 | 특정 키워드 인식 시 동작 수행 (예: "저장", "다음") | 중간 |
+| 녹음 파일 저장 | MediaRecorder API로 음성 파일을 GCS에 저장 | 중간 |
+| 실시간 번역 | STT 결과를 번역 API로 전달 | 중간 |
+
+---
+
+## 부록 A: 참조 구현 파일
+
+| 파일 | 설명 |
+|------|------|
+| `mng/resources/views/juil/construction-photos.blade.php` | 최초 적용 (VoiceInputButton 전체 코드) |
+| `mng/app/Http/Controllers/Juil/ConstructionSitePhotoController.php` | logSttUsage 엔드포인트 |
+| `mng/app/Helpers/AiTokenHelper.php` | saveSttUsage 헬퍼 |
+| `mng/routes/web.php` | STT 라우트 등록 위치 |
+
+## 부록 B: CSS 클래스 요약
+
+| 요소 | Tailwind 클래스 |
+|------|----------------|
+| 마이크 버튼 (대기) | `bg-gray-100 text-gray-500 hover:bg-blue-100 hover:text-blue-600 w-8 h-8 rounded-full` |
+| 마이크 버튼 (녹음) | `bg-red-500 text-white shadow-lg shadow-red-200` |
+| 프리뷰 패널 | `bg-gray-900 rounded-lg shadow-xl w-[300px] max-h-[120px] overflow-y-auto` |
+| 확정 텍스트 | `text-white text-xs font-normal transition-colors duration-300` |
+| 미확정 텍스트 | `text-gray-400 text-xs italic transition-colors duration-200` |
+| 대기 표시 | `text-gray-500 text-xs` + 빨간 점 `animate-pulse` |
+| 완료 표시 | `text-green-400 text-xs` ✓ |
+| 비활성화 | `opacity-30 cursor-not-allowed` |