프로젝트 목록

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 + 결과 통합)

서비스 화면

서비스화면 펼쳐보기
메인 페이지면접 진행 화면결과 페이지 감정 분포결과 페이지 질문별 평가

기술 스택

영역기술
FrontendReact · Chart.js · MediaRecorder API · Canvas API
BackendPython · Flask · TensorFlow · OpenCV · Node.js · Express · Google Cloud STT · OpenAI GPT-3.5-turbo
DatabaseMongoDB · Mongoose
InfraCloudType · EC2 · Netlify · GitHub

전체 시스템 아키텍처

Job Fairy 전체 시스템 아키텍처

본인 구현 영역 — AI 면접 모듈 상세

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로 한 번 업로드
  • 면접 종료 시 useEffect cleanup에서 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 .h5 CNN 추론
  • 추론 결과를 프로세스 내 누적 딕셔너리에 합산해 응답
  • 얼굴 미검출 시 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_tokens 500, 명확성·정확성·개선점을 5점 척도로 평가
  • generateEmotionFeedback(emotionTotals) — "비대면 면접관" 페르소나, max_tokens 700, 누적 감정 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 호출 강제
  • 결과 페이지는 클라이언트가 별도로 보관한 accumulatedEmotions state를 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중 가드.

  • isSTTProcessing state — 처리 중 함수 진입 차단
  • isAnsweringComplete 플래그 — 답변 완료 후 재호출 차단
  • AudioRecorder 내부 hasStopped ref — MediaRecorder 인스턴스 레벨에서 정지 상태 확정

결과. 중복 업로드 사례가 0건으로 수렴. 한 층만으론 사용자의 빠른 동시 발화를 막을 수 없어 다층 가드가 필요했다.


성과

항목BeforeAfter
프레임 1장당 용량약 250KB약 15KB
답변 1개당 추론 요청 수약 1,500회약 100회
면접 1회당 프레임 업로드 트래픽약 250MB약 15MB
GPT 평가 결과 생성 시간약 20초약 3-4초
STT 중복 업로드간헐 발생0건 수렴

※ 일부 수치는 개발 환경 측정 및 산술 추정.