Project
Job Fairy
공고 수집·AI 모의면접·자소서·코딩 테스트 학습을 한 곳에 모은 취준생 통합 플랫폼. AI 면접 모듈 백엔드 담당.
- 기간
- 2024.03 ~ 2024.11
- 역할
- 팀 프로젝트 / AI 면접 모듈 백엔드 (Flask + GCP STT + 결과 통합)
- 기술 스택
- Python (Flask)TensorFlowOpenCVGoogle Cloud Speech-to-TextNode.js (Express)MongoDBCloudType
주요 기능
- 면접 영상 → 감정 인식(FER2013) + STT 결과를 하나의 리포트로 통합
- Flask + TensorFlow 추론 서버를 메인 API와 분리해 메모리 격리
- GCP Speech-to-Text 연동 — WebSocket 스트리밍 시도 후 REST로 안정화
- 면접 결과 영속화·조회 API 설계
프로젝트 개요
공고 수집·AI 모의면접·자소서·코딩 테스트 학습을 한 곳에 모은 취준생 통합 플랫폼. AI 면접 모듈 도메인을 담당했습니다.
| 기간 | 2024.03 ~ 2024.11 |
| 역할 | 팀 프로젝트 / AI 면접 모듈 백엔드 (Flask + GCP STT + 결과 통합) |
서비스 화면
서비스화면 펼쳐보기




기술 스택
| 영역 | 기술 |
|---|---|
| Frontend | React · Chart.js · MediaRecorder API · Canvas API |
| Backend | Python · Flask · TensorFlow · OpenCV · Node.js · Express · Google Cloud STT · OpenAI GPT-3.5-turbo |
| Database | MongoDB · Mongoose |
| Infra | CloudType · EC2 · Netlify · GitHub |
전체 시스템 아키텍처
본인 구현 영역 — AI 면접 모듈 상세
구현 내용
프론트엔드 (React)
1. 면접 진행 UI · 캡처 파이프라인
- 준비 20초 → 답변 50초 타이머가 자동으로 흐르도록 구성. 사용자가 "다음" 버튼을 누르지 않아도 실제 면접의 시간 압박이 그대로 재현됨
- 웹캠 영상을 Canvas로 0.5초마다 캡처 → JPEG 인코딩 →
multipart/form-data로 Flask/predict에 송신 - 캔버스 폭은 320-480px로 다운스케일, JPEG 품질은
0.6고정 — 프레임당 약 250KB → 15KB로 압축 - MediaRecorder API로 답변 음성을 WebM/Opus로 녹음, 답변 종료 시점에 Node
/api/upload로 한 번 업로드 - 면접 종료 시
useEffectcleanup에서videoStream.getTracks().forEach(t => t.stop())로 카메라/마이크 트랙 누수 차단
2. 결과 대시보드
- 7가지 감정 누적 점수를 Chart.js 막대 그래프로 시각화
- 질문별 답변·평가를 카드 리스트로 표시
- 누적 감정 state를 클라이언트에서 별도 보관하고, 결과 화면의 SSOT(단일 진실 공급원) 로 신뢰. 서버 재기동/세션 오염이 일어나도 결과 정확도 유지
- 마이페이지 진입 시 MongoDB에 저장된 과거 면접을 동일한 대시보드 컴포넌트로 재조회
백엔드
3. Flask 표정 감정 추론 서버
POST /predict — 클라이언트가 0.5초 주기로 보내는 JPEG를 받아 7감정 누적 점수 반환.
- OpenCV Haar Cascade(
haarcascade_frontalface_default.xml)로 얼굴 영역 검출 - 48×48 그레이스케일로 정규화 후 Keras
.h5CNN 추론 - 추론 결과를 프로세스 내 누적 딕셔너리에 합산해 응답
- 얼굴 미검출 시
accumulated_emotions키 자체를 응답에서 제거 — "정상 응답 안의 실패"를 명시적으로 표현
POST /reset_emotions — 세션 종료 시 누적 상태 초기화.
Node 메인 API와 분리한 이유 — TensorFlow 모델 로딩과 0.5초 주기 추론 트래픽이 일반 CRUD 응답 시간에 영향을 주지 않도록.
4. Google Cloud STT 연동 (Node Express)
POST /api/upload — multer로 WebM/Opus 음성을 수신, Google Cloud Speech API 호출로 한국어 텍스트 반환.
encoding: 'WEBM_OPUS',sampleRateHertz: 48000,languageCode: 'ko-KR'— 브라우저 MediaRecorder 기본 출력을 변환 없이 그대로 처리- 인증 키는 환경변수
GOOGLE_STT_KEY로만 다뤄 클라이언트 노출 차단 - 답변 단위로 한 번에 업로드해 변환하는 REST 방식 — 실시간 자막이 필요한 시나리오가 아니라서 스트리밍 대신 단순한 업로드로 충분
5. GPT 면접 평가 모듈
질문별 평가와 종합 피드백을 두 함수로 분리.
evaluateInterviewIndividually(question, answer)— "AI 면접 평가관" 페르소나,max_tokens500, 명확성·정확성·개선점을 5점 척도로 평가generateEmotionFeedback(emotionTotals)— "비대면 면접관" 페르소나,max_tokens700, 누적 감정 JSON을 받아 400자 이내 종합 피드백 생성
분리한 이유 — 입력 성격(텍스트 vs 감정 JSON)과 토큰 분배가 다름. 한 프롬프트로 합치면 종합 피드백이 잘리거나 질문별 평가가 짧아짐.
10문항의 질문별 평가는 서로 독립이므로 Promise.all로 병렬화 — 결과 페이지 첫 페인트가 약 20초 → 약 3-4초로 단축.
6. MongoDB 결과 저장
POST /api/interview/save — 한 면접 회차의 결과를 단일 문서로 영구 저장.
InterviewResult스키마 설계 — 질문 / 답변(STT 결과) / GPT 평가 / 누적 감정 점수가 중첩된 구조- Mongoose로 ODM 처리. 한 면접이 다섯 가지 종류의 데이터를 nested로 갖기 때문에 관계형 테이블보다 문서형이 자연스러움
- 마이페이지에서
kakaoId기준으로 과거 면접 목록 재조회
트러블슈팅
1. 프레임 전송량 폭주 → 추론 큐 적체
증상. 면접이 길어질수록 추론 응답이 점점 늦어지고, 결과 페이지의 누적 감정 점수가 답변 종료 시점과 어긋남.
원인. 초기 구현은 requestAnimationFrame 기반으로 웹캠 캡처마다(약 30fps) 프레임을 전송. 답변 50초 × 30fps ≈ 약 1,500요청. 프레임당 추론이 100-150ms 걸리는 단일 Flask 워커에서 큐가 즉시 적체.
해결.
- 전송 주기를
setInterval(sendImageToServer, 500)로 고정 → 2fps, 답변당 약 100회 - 캔버스 폭을 1280→480px로 다운스케일, JPEG 품질 0.92→0.6 — 프레임당 약 250KB → 15KB
결과. 추론 요청 약 93%↓, 트래픽 약 94%↓, 평균 응답 시간 800-1,500ms → 150-200ms.
2. 얼굴 미검출 시 결과 차트가 깨짐
증상. 사용자가 카메라 밖으로 잠깐 움직이면 그 시점부터 차트의 점수가 0으로 덮어써지거나 그래프가 깨졌다.
원인. Flask 서버가 얼굴 미검출 케이스도 200 OK로 응답하면서 accumulated_emotions에 빈 값을 보냈고, 클라이언트는 응답을 그대로 덮어쓰고 있었다. "외부 추론 API의 실패도 HTTP 200 안에 담겨 온다"는 가정이 처음 설계에 없었음.
해결.
- 서버 — 얼굴 미검출 시
accumulated_emotions키 자체를 응답에서 제거 (HTTP 200은 유지) - 클라이언트 —
response.data.accumulated_emotions가 존재할 때만 상태 업데이트
남는 원칙. 외부 추론 API는 200 응답 안에서도 실패할 수 있다. "정상 응답 안의 실패"를 별도 키로 표현하고, 클라이언트는 항상 null-guard.
3. 세션 간 감정 누적값이 섞임
증상. 한 사용자가 면접을 끝내고 다음 사용자(또는 동일 사용자의 두 번째 면접)를 시작했을 때, 결과 차트에 이전 점수가 그대로 누적되어 나타남.
원인. emotion_totals 딕셔너리가 Flask 프로세스 전역에서 세션 구분 없이 살아 있었음.
해결.
- 면접 종료 함수(
stopInterview)에서 항상/reset_emotions호출 강제 - 결과 페이지는 클라이언트가 별도로 보관한
accumulatedEmotionsstate를 SSOT로 신뢰. 서버 값과 충돌해도 클라이언트 값이 우선
남는 원칙. 서버와 클라이언트가 동시에 같은 상태를 들고 있을 때 SSOT를 정하지 않은 시점부터 디버그가 사실상 불가능해진다.
4. GPT 평가 결과 생성이 약 20초
증상. 답변을 모두 마치고 결과 페이지로 이동했을 때, 첫 평가가 보이기까지 약 20초가 걸려 사용자가 페이지를 떠나는 케이스가 발생.
원인. 10문항의 GPT 평가를 for 루프로 순차 호출 — 호출당 평균 2초 × 10 ≈ 20초.
해결. 질문별 평가는 서로 의존성이 없으므로 Promise.all로 병렬화.
const results = await Promise.all(
questionsAndAnswers.map(qa => evaluateOne(qa))
);결과. 결과 페이지 첫 페인트 약 20초 → 약 3-4초. OpenAI rate limit는 분당 호출 수 기준이라 10건 동시 호출도 문제 없는 범위.
5. STT 중복 업로드 (프론트)
증상. 답변 시간이 끝나는 타이밍과 사용자가 "답변 완료" 버튼을 누르는 타이밍이 거의 동시에 발생하면 동일 답변이 2번 업로드되어 결과 페이지에 중복 평가가 표시.
원인. handleSaveRecording이 타이머 콜백과 버튼 onClick 두 경로에서 동시에 호출되고 있었음.
해결 — 3중 가드.
isSTTProcessingstate — 처리 중 함수 진입 차단isAnsweringComplete플래그 — 답변 완료 후 재호출 차단AudioRecorder내부hasStoppedref — MediaRecorder 인스턴스 레벨에서 정지 상태 확정
결과. 중복 업로드 사례가 0건으로 수렴. 한 층만으론 사용자의 빠른 동시 발화를 막을 수 없어 다층 가드가 필요했다.
성과
| 항목 | Before | After |
|---|---|---|
| 프레임 1장당 용량 | 약 250KB | 약 15KB |
| 답변 1개당 추론 요청 수 | 약 1,500회 | 약 100회 |
| 면접 1회당 프레임 업로드 트래픽 | 약 250MB | 약 15MB |
| GPT 평가 결과 생성 시간 | 약 20초 | 약 3-4초 |
| STT 중복 업로드 | 간헐 발생 | 0건 수렴 |
※ 일부 수치는 개발 환경 측정 및 산술 추정.