저번 1편에서 PitchCoach가 뭔지, 그리고 Whisper랑 Librosa로 음성 분석 기초를 소개했었는데 이번엔 실제로 서비스가 완성되기까지의 과정을 좀 더 담아보려고 한다. 이번 2편에서는 내가 맡았던 프론트엔드(Next.js)랑 음성 분석 AI 파트를 중심으로 써볼 예정이다!

1. 전체 시스템 구조
먼저 PitchCoach가 어떤 구조로 돌아가는지부터 설명하면, 크게 4개 계층으로 나뉜다.
- Frontend — Next.js + React + TypeScript + TailwindCSS / Vercel 배포
- Backend — NestJS + Nginx / AWS EC2
- AI Server — FastAPI / AWS EC2 (공고문 분석, IR Deck 분석, 음성 분석, Q&A 생성 등)
- Storage — AWS S3 (원본 파일) + RDS PostgreSQL (분석 결과)
AI 분석은 시간이 오래 걸리다 보니까 메인 백엔드랑 AI 서버를 분리해서 비동기로 처리하도록 설계했다. 분석 요청을 보내면 바로 202 응답을 받고, 프론트엔드에서 polling으로 완료 여부를 주기적으로 확인하는 방식이다.
Frontend (Next.js)
↓ 파일 업로드
NestJS Backend
↓ S3 저장 + AI 서버에 분석 요청
FastAPI AI Server
↓ 비동기 분석 (Whisper / Librosa / Gemini 등)
NestJS Backend
↓ 결과 RDS 저장 후 Frontend에 반환
Frontend
→ polling으로 완료 확인 후 결과 렌더링
서비스 플로우는 1편에서 얘기한 것처럼 아래 순서로 이어진다.
공고 업로드 → IR Deck 분석 → 발표 음성 분석 → Q&A 훈련 → AI 종합 리포트
각 단계가 이전 단계 분석 결과를 누적해서 활용하는 구조라, 음성 분석 시 공고문 기준이랑 IR Deck 결과를 context로 같이 넘겨서 훨씬 맥락 있는 피드백을 받을 수 있다.
1. Frontend — Next.js로 서비스 만들기
기술 스택은 Next.js 14 (App Router), TypeScript, TailwindCSS로 구성했고 Vercel로 배포했다.
페이지 구성은 서비스 플로우를 그대로 따라갔다.
app/
├── (auth)/
│ ├── login/page.tsx
│ └── register/page.tsx
├── dashboard/page.tsx # 피치 프로젝트 목록
├── pitch/
│ ├── new/page.tsx # 신규 프로젝트 생성
│ └── [pitchId]/
│ ├── notice/page.tsx # 공고문 업로드 & 분석 결과
│ ├── deck/page.tsx # IR Deck 분석 결과
│ ├── rehearsal/page.tsx # 발표 녹음 & 음성 분석
│ ├── qa/page.tsx # Q&A 훈련
│ └── report/page.tsx # 최종 AI 리포트
(1) 공고문 & IR Deck 분석 결과 화면
공고문 분석이 완료되면 심사 항목별 배점, PitchCoach 해석, IR Deck 작성 가이드를 보여준다. 분석 결과가 마음에 안 들거나 오류가 있으면 사용자가 직접 수정도 가능하도록 했는데, 배점 합계가 100이 아니면 제출이 차단되는 유효성 검증도 프론트에서 함께 처리했다.

IR Deck 분석 결과는 슬라이드별로 카테고리, 점수, 피드백을 보여준다. 슬라이드 썸네일이랑 분석 결과를 나란히 보여줘서 어떤 슬라이드에 대한 피드백인지 바로 확인할 수 있게 구성했다. 오른쪽에는 공고문 평가 기준별로 점수랑 피드백도 함께 노출된다.
(2) 발표 녹음 기능
음성 분석의 출발점은 브라우저에서 직접 녹음하는 거라서 Web API인 MediaRecorder를 사용했다.
단순히 음성만 녹음하는 게 아니라, 슬라이드를 넘길 때마다 전환 타임스탬프를 함께 기록하는 게 핵심이었다. 이 정보가 나중에 AI 서버에서 어떤 슬라이드에서 무슨 말을 했는지 매핑하는 데 쓰인다.
// 녹음 시작
const startRecording = async () => {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const recorder = new MediaRecorder(stream);
const chunks: BlobPart[] = [];
recorder.ondataavailable = (e) => chunks.push(e.data);
recorder.onstop = () => {
const blob = new Blob(chunks, { type: 'audio/webm' });
setAudioBlob(blob);
};
recorder.start(1000); // 1초 단위로 청크 수집
setMediaRecorder(recorder);
};
// 슬라이드 전환 시 타임스탬프 기록
const handleSlideChange = (slideIndex: number) => {
const now = Date.now() - recordingStartTime;
setSlideTimestamps(prev => [
...prev,
{ slideIndex, startMs: now }
]);
setCurrentSlide(slideIndex);
};
녹음이 끝나면 audio/webm 파일이랑 슬라이드 타임스탬프 배열을 함께 백엔드로 전송한다.

(2-1) polling으로 분석 완료 확인하기
AI 분석은 시간이 걸리기 때문에 분석이 완료될 때까지 주기적으로 상태를 확인해야 했다. 음성 분석은 5초, 공고문/IR Deck은 10초 간격으로 polling하도록 했다.
const pollAnalysisStatus = async (rehearsalId: string) => {
const interval = setInterval(async () => {
const res = await fetch(`/api/rehearsals/${rehearsalId}/status`);
const data = await res.json();
if (data.status === 'COMPLETED') {
clearInterval(interval);
setAnalysisResult(data.result);
setIsAnalyzing(false);
} else if (data.status === 'FAILED') {
clearInterval(interval);
setError('분석 중 오류가 발생했습니다.');
}
}, 5000);
};
(3) 음성 분석 결과 화면
분석이 완료되면 WPM, 전달력 분석 카드, 슬라이드별 음성 분석 결과를 보여준다. Librosa에서 뽑아낸 수치들은 사용자한테 직접 보여주지 않고, Gemini가 자연어로 풀어서 전달하는 방식이라 화면에는 피드백 문장으로만 노출된다.

(3) Q&A 훈련 & 최종 리포트 화면
Q&A는 학습 모드랑 실전 모드 두 가지로 나뉜다. 학습 모드는 예상 질문이랑 답변 가이드를 미리 확인하는 방식이고, 실전 모드는 질문이 하나씩 제시되고 음성으로 답변하는 방식이다.

최종 리포트는 공고문, IR Deck, 음성, Q&A 분석 결과를 한 화면에서 종합해서 보여준다.

2. 음성 분석 AI — 완성까지
1편에서 Whisper랑 Librosa 기초를 소개했었는데, 이번엔 실제 서비스에 연결된 최종 구조를 공유해보려고 한다.
전체 파이프라인은 다음과 같다.
음성 파일 수신 → ① Whisper STT → ② Librosa 음향 분석 → ③ 슬라이드 매핑 → ④ Gemini 코칭 → 결과 반환
1) Whisper — 단어 단위 타임스탬프
1편에서는 단순 텍스트 변환만 했는데, 실제 서비스에서는 단어 단위 타임스탬프가 핵심이다. 이게 있어야 슬라이드별로 어떤 구간에 어떤 말을 했는지 매핑이 가능하기 때문이다.
def transcribe_with_timestamps(audio_path: str) -> dict:
client = openai.OpenAI()
with open(audio_path, "rb") as f:
result = client.audio.transcriptions.create(
model="whisper-1",
file=f,
response_format="verbose_json", # 타임스탬프 포함
timestamp_granularities=["word"] # 단어 단위
)
return {
"text": result.text,
"words": result.words, # [{word, start, end}, ...]
"duration": result.duration
}
2) Librosa — pyin 알고리즘으로 개선
1편 코드에서 피치 추출 방식을 piptrack에서 pyin 알고리즘으로 바꿨다. 보컬 구간 감지 성능이 훨씬 좋아서 발표 음성처럼 말하는 구간이 불규칙한 경우에 더 정확하다.
def extract_audio_features(audio_path: str) -> dict:
y, sr = librosa.load(audio_path, sr=16000, mono=True)
duration = librosa.get_duration(y=y, sr=sr)
rms = librosa.feature.rms(y=y)[0]
energy_std = float(rms.std())
# pyin 알고리즘으로 피치 추출 (C2~C6 범위)
f0, voiced_flag, _ = librosa.pyin(
y,
fmin=librosa.note_to_hz('C2'),
fmax=librosa.note_to_hz('C6')
)
voiced_f0 = f0[voiced_flag]
pitch_mean = float(np.mean(voiced_f0)) if voiced_f0.size > 0 else 0.0
pitch_std = float(np.std(voiced_f0)) if voiced_f0.size > 0 else 0.0
pitch_range = float(np.ptp(voiced_f0)) if voiced_f0.size > 0 else 0.0
silence_ratio = float((np.abs(y) < 0.01).sum() / len(y))
return {
"duration_sec": duration,
"energy_std": energy_std,
"pitch_mean": pitch_mean,
"pitch_std": pitch_std,
"pitch_range": pitch_range,
"silence_ratio": silence_ratio,
}
3) 슬라이드-발화 매핑
프론트에서 기록한 슬라이드 전환 타임스탬프랑 Whisper의 단어 타임스탬프를 결합해서, 슬라이드별로 어떤 말을 했는지 구간을 나누는 함수다.
def map_slides_to_speech(words, slide_timestamps):
results = []
for i, slide in enumerate(slide_timestamps):
start_sec = slide["startMs"] / 1000
end_sec = (
slide_timestamps[i + 1]["startMs"] / 1000
if i + 1 < len(slide_timestamps)
else float("inf")
)
slide_words = [
w["word"] for w in words
if start_sec <= w["start"] < end_sec
]
results.append({
"slideIndex": slide["slideIndex"],
"transcript": " ".join(slide_words),
"start_sec": start_sec,
"end_sec": end_sec,
})
return results
4) Gemini 코칭
Whisper 텍스트 + Librosa 수치 + IR Deck 분석 결과를 한꺼번에 Gemini에 넘기는데, Librosa에서 나오는 "에너지 표준편차", "Hz" 같은 수치를 사용자한테 직접 보여주면 의미를 모르니까 프롬프트에서 수치는 내부 참고용으로만 쓰고 Gemini가 자연어로 풀어서 전달하도록 했다.
VOICE_ANALYSIS_PROMPT = """
당신은 IR 피칭 분석 전문 코치입니다.
[IR 덱 분석 결과]
{deck_json}
[음성 텍스트]
{speech_text}
[음향 특성 — 내부 참고용, 최종 출력에 수치 직접 언급 금지]
{audio_features}
⚠️ "에너지 표준편차", "Hz", "주파수" 등 기술 용어 사용 금지
사용자가 이해하기 쉬운 자연어 코칭 문장으로 작성할 것
JSON 형식으로만 응답:
{
"발표_상황": "",
"상황_적합성_점수": { "총점": 0, "세부_기준": {...} },
"음성_전달력_분석": {
"말하기_속도_WPM": 0,
"억양_강조_안정성": "",
"감정_톤": "",
"문장_명료성": "",
"불필요한_말버릇": "",
"강점": [],
"개선점": []
},
"1분_요약": ""
}
"""
5) FastAPI 최종 엔드포인트
분석 요청이 오면 즉시 202를 반환하고, 실제 분석은 백그라운드에서 돌리는 구조다.
@router.post("/rehearsals/analyze")
async def analyze_rehearsal(
background_tasks: BackgroundTasks,
audio_file: UploadFile = File(...),
slide_timestamps: str = Form(...),
context: str = Form(...),
):
background_tasks.add_task(
run_analysis,
audio_path=save_temp(audio_file),
slide_timestamps=json.loads(slide_timestamps),
context=json.loads(context),
)
return {"status": "PROCESSING"}
async def run_analysis(audio_path, slide_timestamps, context):
transcript = transcribe_with_timestamps(audio_path)
features = extract_audio_features(audio_path)
wpm = int(len(transcript["text"].split()) / (transcript["duration"] / 60))
slide_speech = map_slides_to_speech(transcript["words"], slide_timestamps)
coaching = analyze_with_gemini(transcript["text"], features, context["deck_result"])
await save_result(coaching, wpm, slide_speech)
3. 회고
사실 항상 프론트엔드 개발만 해보다가 이렇게 AI 파트까지 직접 건드려본 건 처음이라 모르는 것도 많고 공부할 것도 정말 많았는데, 그래도 새로운 파트에 도전을 해볼 수 있었다는 점에서 유의미했던거 같다. 무엇보다 1년동안 해오던 졸업프로젝트의 끝을 이렇게 돌아본다는 점에서 정말 후련한거 같다.