[AI] 00_ADR_ 001‐005 - 100-hours-a-week/9-team-Devths-WIKI GitHub Wiki
기술 의사결정 기록 (ADR)
Architecture Decision Records - 프로젝트에서 기술 선택의 근거를 기록하는 문서
📚 목차
- ADR-001: LLM 스트리밍 통신 방식 (SSE vs WebSocket)
- ADR-002: 분석 결과 저장 형식 (JSON vs Text)
- ADR-003: VectorDB 선택 (ChromaDB vs Pinecone vs Qdrant)
- ADR-004: 비동기 처리 방식 (폴링 vs 콜백 vs WebSocket)
- ADR-005: 면접 Q&A 개별 저장 방식
ADR-001: LLM 스트리밍 통신 방식 (SSE vs WebSocket)
📋 메타데이터
| 항목 | 내용 |
|---|---|
| 상태 | ✅ 채택됨 (Accepted) |
| 작성일 | 2026-01-07 |
| 결정자 | 개발팀 |
| 관련 기능 | AI 채팅, 분석 결과 스트리밍, 면접 리포트 |
🎯 컨텍스트 (Context)
LLM(Large Language Model) 기반 채팅 기능을 구현할 때, 사용자에게 실시간으로 응답을 스트리밍해야 합니다. 이를 위해 **SSE(Server-Sent Events)**와 WebSocket 두 가지 방식 중 하나를 선택해야 합니다.
스트리밍이 필요한 API:
/ai/chat- 대화 처리 (RAG + 에이전트)/ai/analyze- 분석 + 매칭도/ai/interview/report- 면접 평가 및 피드백
🔍 선택지 분석 (Options)
Option 1: SSE (Server-Sent Events)
[클라이언트] ──HTTP 요청──> [서버]
[클라이언트] <──SSE 스트림── [서버]
(text/event-stream)
장점:
- ✅ HTTP/1.1 기반으로 기존 인프라 완벽 호환
- ✅ 구현이 간단 (FastAPI
StreamingResponse) - ✅ 로드밸런서, 프록시, CDN 설정 변경 불필요
- ✅ 브라우저
EventSourceAPI로 자동 재연결 지원 - ✅ HTTP/2에서 멀티플렉싱 지원
- ✅ OpenAI, Anthropic, Google 등 LLM 업계 표준
단점:
- ⚠️ 단방향 통신만 가능 (서버 → 클라이언트)
- ⚠️ 바이너리 데이터 전송에 비효율적
- ⚠️ IE에서 지원 안 됨 (현재는 관계없음)
Option 2: WebSocket
[클라이언트] ──WebSocket 핸드셰이크──> [서버]
[클라이언트] <────양방향 통신────> [서버]
(ws:// 또는 wss://)
장점:
- ✅ 양방향 실시간 통신 가능
- ✅ 바이너리 데이터 효율적 전송
- ✅ 하나의 연결로 여러 기능 처리 가능
- ✅ 낮은 레이턴시
단점:
- ⚠️ 별도 프로토콜 (ws://)로 인프라 설정 필요
- ⚠️ 로드밸런서/프록시 설정 변경 필요할 수 있음
- ⚠️ 연결 끊김 시 재연결 로직 직접 구현 필요
- ⚠️ HTTP/2 미지원 (HTTP/1.1만)
- ⚠️ 상태 관리 복잡도 증가
📊 상세 비교표
| 항목 | SSE | WebSocket |
|---|---|---|
| 통신 방향 | 단방향 (서버 → 클라이언트) | 양방향 |
| 프로토콜 | HTTP/1.1, HTTP/2 | 독자 프로토콜 (ws://) |
| 연결 방식 | 표준 HTTP 요청 | 업그레이드 핸드셰이크 |
| Content-Type | text/event-stream |
N/A (바이너리 프레임) |
| 구현 복잡도 | ⭐ 낮음 | ⭐⭐⭐ 높음 |
| 인프라 호환성 | ⭐⭐⭐ 높음 | ⭐⭐ 설정 필요 |
| 자동 재연결 | 브라우저 기본 지원 | 직접 구현 |
| HTTP/2 지원 | ✅ 멀티플렉싱 | ❌ |
| 모바일 친화성 | ⭐⭐⭐ 높음 | ⭐⭐ 배터리 이슈 가능 |
✅ 결정 (Decision)
SSE(Server-Sent Events)를 선택합니다.
📝 근거 (Rationale)
1. LLM 응답 특성과 일치
사용자 요청 → [서버에서 LLM 호출] → 토큰 단위 스트리밍 응답
- LLM 응답은 서버 → 클라이언트 단방향
- 사용자 메시지는 일반 HTTP POST로 전송 가능
- 양방향 통신의 필요성 없음
2. 업계 표준 준수
| LLM 서비스 | 스트리밍 방식 |
|---|---|
| OpenAI | SSE |
| Anthropic Claude | SSE |
| Google Gemini | SSE |
| Cohere | SSE |
| Mistral | SSE |
대부분의 LLM API가 SSE를 사용 → 참고 자료 풍부, 검증된 방식
3. 구현 및 운영 단순화
FastAPI 서버 코드:
from fastapi.responses import StreamingResponse
@app.post("/ai/chat")
async def chat(request: ChatRequest):
async def generate():
async for chunk in llm.stream(request.message):
yield f"data: {json.dumps({'content': chunk})}\n\n"
yield f"data: {json.dumps({'type': 'done'})}\n\n"
return StreamingResponse(generate(), media_type="text/event-stream")
Frontend 코드:
const eventSource = new EventSource('/ai/chat');
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'done') {
eventSource.close();
} else {
displayChunk(data.content);
}
};
4. 인프라 친화성
- 기존 HTTP 인프라 그대로 사용
- Nginx, AWS ALB 등 별도 설정 불필요
- CORS 설정도 일반 HTTP와 동일
⚠️ 트레이드오프 (Trade-offs)
감수하는 제약사항
| 제약사항 | 대응 방안 |
|---|---|
| 클라이언트 → 서버 실시간 통신 불가 | 일반 HTTP POST로 충분 |
| 바이너리 데이터 비효율 | LLM 응답은 텍스트, 해당 없음 |
| 연결당 하나의 스트림 | 동시 스트림 필요 시 별도 연결 |
향후 WebSocket 도입 검토 시점
- 실시간 협업 기능 추가 시 (여러 사용자 동시 편집)
- 실시간 알림 시스템 고도화 시
- 타이핑 중 인디케이터 기능 추가 시
🔗 참고 자료
📅 이력
| 날짜 | 변경 내용 |
|---|---|
| 2026-01-07 | 초기 결정 및 문서 작성 |
ADR-002: 분석 결과 저장 형식 (JSON vs Text)
📋 메타데이터
| 항목 | 내용 |
|---|---|
| 상태 | ✅ 채택됨 (Accepted) |
| 작성일 | 2026-01-07 |
| 결정자 | 개발팀 |
| 관련 기능 | /ai/analyze (채용공고/이력서 분석 + 매칭도), /ai/interview/report (면접 평가 및 피드백) |
🎯 컨텍스트 (Context)
LLM이 생성한 분석 결과(이력서 분석, 채용공고 분석, 매칭도 등)를 DB에 저장할 때, JSON 구조화 형식으로 저장할지 원본 텍스트로 저장할지 결정해야 합니다.
저장이 필요한 분석 결과:
| API | 설명 | 저장 데이터 |
|---|---|---|
/ai/analyze |
채용공고/이력서(포트폴리오) 분석 + 매칭도 | 강점, 약점, 개선제안, 점수, 등급, 스킬 비교 |
/ai/interview/report |
면접 평가 및 피드백 | 질문별 점수, 피드백, 종합 리포트 |
🔍 선택지 분석 (Options)
Option 1: JSON 구조화 저장
{
"resume_analysis": {
"strengths": ["프론트엔드 개발 경험 3년", "React 숙련"],
"weaknesses": ["클라우드 경험 부족"],
"suggestions": ["AWS 자격증 취득 추천"]
},
"matching": {
"score": 85,
"grade": "A",
"matched_skills": ["React", "TypeScript"]
}
}
장점:
- ✅ 필드별 직접 접근 가능 (
result.matching.score) - ✅ Frontend에서 파싱 없이 바로 사용
- ✅ 특정 필드 검색/필터링 가능 (점수 80 이상인 분석 결과)
- ✅ 일관된 데이터 구조 보장
- ✅ API 응답 그대로 저장 가능
- ✅ 통계/대시보드 구현 용이
단점:
- ⚠️ 스키마 변경 시 마이그레이션 필요
- ⚠️ LLM 응답이 스키마와 다를 경우 파싱 에러
- ⚠️ RAG 검색에는 텍스트가 더 적합할 수 있음
Option 2: 원본 텍스트 저장
{
"analysis_text": "이력서 분석 결과입니다.\n\n강점:\n- 프론트엔드 개발 경험 3년...\n\n약점:\n- 클라우드 경험이 부족합니다..."
}
장점:
- ✅ 스키마 변경에 자유로움
- ✅ LLM 원본 응답 그대로 보존
- ✅ RAG 검색에 바로 사용 가능
- ✅ 자연어 형태로 사용자에게 표시 용이
단점:
- ⚠️ 특정 필드 추출 시 파싱 필요
- ⚠️ Frontend에서 구조화된 UI 렌더링 어려움
- ⚠️ 검색/필터링 어려움
- ⚠️ 일관성 보장 어려움
Option 3: 하이브리드 (JSON + 원본 텍스트)
{
"structured_data": {
"score": 85,
"grade": "A",
"strengths": [...],
"matched_skills": [...]
},
"raw_text": "이력서 분석 결과입니다...전체 텍스트...",
"summary": "매칭도 85%, A등급입니다."
}
장점:
- ✅ 구조화된 데이터로 UI 렌더링
- ✅ 원본 텍스트로 RAG 검색
- ✅ 필요에 따라 유연하게 사용
단점:
- ⚠️ 저장 용량 증가
- ⚠️ 두 데이터 간 동기화 필요
📊 상세 비교표
| 항목 | JSON | Text | 하이브리드 |
|---|---|---|---|
| 필드별 접근 | ✅ 쉬움 | ❌ 파싱 필요 | ✅ 쉬움 |
| Frontend 렌더링 | ✅ 바로 사용 | ⚠️ 가공 필요 | ✅ 바로 사용 |
| 검색/필터링 | ✅ 가능 | ❌ 어려움 | ✅ 가능 |
| RAG 검색 | ⚠️ 변환 필요 | ✅ 바로 사용 | ✅ 가능 |
| 스키마 유연성 | ❌ 낮음 | ✅ 높음 | ⚠️ 중간 |
| 저장 용량 | ⭐ 작음 | ⭐ 작음 | ⭐⭐ 큼 |
| 구현 복잡도 | ⭐ 낮음 | ⭐ 낮음 | ⭐⭐ 중간 |
✅ 결정 (Decision)
JSON 구조화 저장을 기본으로 하되, 필요 시 원본 텍스트도 함께 저장합니다. (하이브리드)
📝 근거 (Rationale)
1. 주요 사용 케이스가 구조화된 데이터 필요
[Frontend UI 요구사항]
├── 매칭도 점수 차트 표시 → score 필드 필요
├── 등급별 뱃지 표시 → grade 필드 필요
├── 스킬 태그 나열 → skills 배열 필요
├── 강점/약점 카드 표시 → strengths/weaknesses 배열 필요
└── 면접 질문별 점수 표시 → evaluations 배열 필요
대부분의 UI가 구조화된 데이터를 직접 접근해야 함
2. API 응답과 DB 저장 형식 일치
/ai/analyze 응답 예시 (채용공고/이력서 분석 + 매칭도):
{
"success": true,
"resume_analysis": {
"strengths": ["..."],
"weaknesses": ["..."]
},
"matching": {
"score": 85,
"grade": "A"
}
}
API 응답을 그대로 DB에 저장 → 일관성 유지, 변환 로직 불필요
3. 검색/통계 기능 지원
-- MongoDB 쿼리 예시
db.analysis_results.find({ "matching.score": { $gte: 80 } })
db.analysis_results.find({ "matching.grade": "A" })
JSON 필드 검색으로 "매칭도 80점 이상인 분석 결과" 쿼리 가능
4. Text만 저장해도 되는 경우
[Text 저장이 적합한 경우]
├── 단순 채팅 로그 저장 (대화 이력)
├── LLM 원본 응답 디버깅/로깅
├── RAG 검색용 데이터 (별도 VectorDB)
└── 자유 형식 메모/피드백
분석 결과는 구조화된 활용이 목적이므로 JSON이 적합
🗂️ 저장 구조 설계
MongoDB Collection: analysis_results
{
"_id": "ObjectId",
"user_id": "user_123",
"resume_id": "resume_456",
"posting_id": "posting_789",
"created_at": "2026-01-07T12:00:00Z",
// 구조화된 분석 결과 (JSON)
"resume_analysis": {
"strengths": ["프론트엔드 개발 경험 3년", "React, TypeScript 숙련"],
"weaknesses": ["클라우드 경험 부족"],
"suggestions": ["AWS 자격증 취득 추천"]
},
"posting_analysis": {
"company": "카카오",
"position": "프론트엔드 개발자",
"required_skills": ["React", "TypeScript"],
"preferred_skills": ["GraphQL", "AWS"]
},
"matching": {
"score": 85,
"grade": "A",
"matched_skills": ["React", "TypeScript"],
"missing_skills": ["GraphQL"]
},
// 원본 텍스트 (RAG 검색용, 선택적)
"raw_response": "전체 LLM 응답 텍스트..."
}
⚠️ 트레이드오프 (Trade-offs)
감수하는 제약사항
| 제약사항 | 대응 방안 |
|---|---|
| 스키마 변경 시 마이그레이션 | MongoDB 유연한 스키마 활용, 버전 필드 추가 |
| LLM 응답 파싱 실패 가능성 | Output Parser로 스키마 강제, 실패 시 재시도 |
| 저장 용량 증가 (하이브리드) | raw_response는 선택적 저장 |
LLM 응답 스키마 강제 방법
# LangChain Output Parser 사용
from langchain.output_parsers import PydanticOutputParser
from pydantic import BaseModel
class AnalysisResult(BaseModel):
strengths: list[str]
weaknesses: list[str]
suggestions: list[str]
score: int
grade: str
parser = PydanticOutputParser(pydantic_object=AnalysisResult)
prompt = prompt_template.format(format_instructions=parser.get_format_instructions())
🔗 참고 자료
📅 이력
| 날짜 | 변경 내용 |
|---|---|
| 2026-01-07 | 초기 결정 및 문서 작성 |
ADR-003: VectorDB 선택 (ChromaDB vs Pinecone vs Qdrant)
📋 메타데이터
| 항목 | 내용 |
|---|---|
| 상태 | ✅ 채택됨 (Accepted) |
| 작성일 | 2026-01-08 |
| 결정자 | 개발팀 |
| 관련 기능 | 이력서/채용공고 임베딩 저장, RAG 검색, 면접 피드백 저장 |
🎯 컨텍스트 (Context)
RAG(Retrieval-Augmented Generation) 기반 AI 서비스를 구현하기 위해 텍스트 임베딩을 저장하고 검색할 VectorDB를 선택해야 합니다.
VectorDB가 필요한 컬렉션:
| 컬렉션 | 용도 | 예상 크기 |
|---|---|---|
resumes |
이력서 + 포트폴리오 임베딩 | ~10K 문서 |
job_postings |
채용공고 임베딩 | ~50K 문서 |
analysis_results |
분석 결과 임베딩 | ~20K 문서 |
interview_feedback |
면접 Q&A 임베딩 | ~100K 문서 |
🔍 선택지 분석 (Options)
Option 1: ChromaDB
장점:
- ✅ 오픈소스 (무료)
- ✅ 로컬 설치 가능 → 개발/테스트 용이
- ✅ Python 네이티브 → FastAPI와 통합 간편
- ✅ LangChain 공식 지원
- ✅ 충분한 성능 (100K~1M 문서)
단점:
- ⚠️ 클라우드 관리형 버전 없음 (직접 운영 필요)
- ⚠️ 대규모 확장 시 성능 한계
Option 2: Pinecone
장점:
- ✅ 완전 관리형 서비스 (No DevOps)
- ✅ 글로벌 분산 인프라
- ✅ 무제한 확장성
단점:
- ⚠️ 유료 (Free tier 제한적)
- ⚠️ 외부 의존성 (네트워크 레이턴시)
- ⚠️ 벤더 종속
Option 3: Qdrant
장점:
- ✅ 오픈소스 + 클라우드 버전 모두 제공
- ✅ Rust 기반 고성능
- ✅ 다양한 필터링 지원
단점:
- ⚠️ LangChain 통합이 ChromaDB보다 덜 성숙
- ⚠️ 커뮤니티/문서가 상대적으로 작음
📊 상세 비교표
| 항목 | ChromaDB | Pinecone | Qdrant |
|---|---|---|---|
| 비용 | 무료 | 유료 | 무료/유료 |
| 설치 | 로컬/Docker | 클라우드 Only | 로컬/클라우드 |
| LangChain 통합 | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ |
| 확장성 | 중간 (~1M) | 높음 (무제한) | 높음 |
| 개발 편의성 | ⭐⭐⭐ | ⭐⭐ | ⭐⭐ |
| 운영 복잡도 | 중간 | 낮음 | 중간 |
✅ 결정 (Decision)
ChromaDB를 선택합니다.
📝 근거 (Rationale)
1. 현재 규모에 적합
예상 문서 수: ~200K 이하
ChromaDB 권장 범위: ~1M 문서
→ 충분한 성능 확보 가능
2. 개발 생산성
# ChromaDB 사용 예시 (간단함)
from langchain_chroma import Chroma
vectorstore = Chroma(
collection_name="resumes",
embedding_function=embeddings,
persist_directory="./chroma_db"
)
# 검색
docs = vectorstore.similarity_search(query, k=5)
3. 비용 효율성
- 초기 MVP/POC 단계에서 무료 사용
- 추후 규모 확장 시 Pinecone 마이그레이션 가능
4. LangChain 생태계 적합
- LangChain 공식 문서에서 가장 많이 사용
- 풍부한 예제 코드
⚠️ 트레이드오프 (Trade-offs)
| 제약사항 | 대응 방안 |
|---|---|
| 클라우드 관리형 없음 | Docker로 배포 → Kubernetes에서 관리 |
| 대규모 확장 한계 | 1M 문서 초과 시 Pinecone 마이그레이션 검토 |
| 백업/복구 직접 구현 | persist_directory 백업 스크립트 구축 |
📅 이력
| 날짜 | 변경 내용 |
|---|---|
| 2026-01-08 | 초기 결정 및 문서 작성 |
ADR-004: 비동기 처리 방식 (폴링 vs 콜백 vs WebSocket)
📋 메타데이터
| 항목 | 내용 |
|---|---|
| 상태 | ✅ 채택됨 (Accepted) |
| 작성일 | 2026-01-08 |
| 결정자 | 개발팀 |
| 관련 기능 | OCR 텍스트 추출 (/ai/ocr/extract), 마스킹 (/ai/masking/draft) |
🎯 컨텍스트 (Context)
OCR이나 마스킹처럼 처리 시간이 오래 걸리는 API의 경우, 클라이언트가 결과를 받는 방식을 선택해야 합니다.
비동기 처리가 필요한 API:
| API | 예상 처리 시간 | 이유 |
|---|---|---|
/ai/ocr/extract |
5~30초 | 파일 크기, 페이지 수에 따라 변동 |
/ai/masking/draft |
3~15초 | VLM 분석 + 이미지 처리 |
🔍 선택지 분석 (Options)
Option 1: 폴링 (Polling)
1. POST /ai/ocr/extract → task_id 즉시 반환
2. GET /ai/task/{task_id} → status 확인 (반복)
3. status === 'completed' → 결과 획득
장점:
- ✅ 구현 단순 (일반 HTTP 요청)
- ✅ 상태 비저장 (Stateless)
- ✅ 클라이언트 구현 간편
- ✅ 네트워크 문제에 강건
단점:
- ⚠️ 불필요한 요청 발생
- ⚠️ 결과 확인 지연 (폴링 간격만큼)
Option 2: 콜백 (Webhook)
1. POST /ai/ocr/extract → task_id + callback_url 전달
2. AI Server가 완료 시 callback_url 호출
장점:
- ✅ 불필요한 요청 없음
- ✅ 즉시 알림
단점:
- ⚠️ Backend가 콜백 엔드포인트 구현 필요
- ⚠️ 네트워크 이슈 시 콜백 실패 처리 복잡
- ⚠️ 보안 고려 필요 (콜백 URL 검증)
Option 3: WebSocket
1. WebSocket 연결 유지
2. 작업 완료 시 서버가 푸시
장점:
- ✅ 실시간 푸시
- ✅ 양방향 통신
단점:
- ⚠️ 연결 관리 복잡
- ⚠️ 인프라 설정 필요
- ⚠️ 단발성 작업에 오버헤드
📊 상세 비교표
| 항목 | 폴링 | 콜백 | WebSocket |
|---|---|---|---|
| 구현 복잡도 | ⭐ 낮음 | ⭐⭐ 중간 | ⭐⭐⭐ 높음 |
| 실시간성 | ⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐ |
| 서버 부하 | ⭐⭐ (폴링 요청) | ⭐⭐⭐ | ⭐⭐ (연결 유지) |
| 신뢰성 | ⭐⭐⭐ | ⭐⭐ | ⭐⭐ |
| 인프라 요구 | 없음 | 콜백 엔드포인트 | WebSocket 서버 |
✅ 결정 (Decision)
폴링(Polling) 방식을 선택합니다.
📝 근거 (Rationale)
1. 구현 단순성
# AI Server (FastAPI)
@app.post("/ai/ocr/extract")
async def extract_text(request: OCRRequest):
task_id = str(uuid.uuid4())
background_tasks.add_task(process_ocr, task_id, request)
return {"task_id": task_id, "status": "processing"}
@app.get("/ai/task/{task_id}")
async def get_task_status(task_id: str):
return task_store.get(task_id)
2. 클라이언트 제어권
- 클라이언트가 폴링 간격, 재시도 로직 직접 제어
- 네트워크 끊김 시 자연스럽게 재시도
3. 지수 백오프로 효율화
// Frontend 폴링 로직
async function pollTask(taskId) {
let interval = 1000; // 1초 시작
const maxInterval = 8000; // 최대 8초
while (true) {
const result = await fetch(`/ai/task/${taskId}`);
const data = await result.json();
if (data.status === 'completed') return data.result;
if (data.status === 'failed') throw new Error(data.error);
await sleep(interval);
interval = Math.min(interval * 2, maxInterval); // 지수 백오프
}
}
4. 기존 인프라 활용
- 별도 WebSocket 서버 불필요
- 콜백 엔드포인트 구현 불필요
⚠️ 트레이드오프 (Trade-offs)
| 제약사항 | 대응 방안 |
|---|---|
| 불필요한 폴링 요청 | 지수 백오프로 요청 최소화 |
| 결과 확인 지연 | 초기 폴링 간격 1초로 설정 |
| 서버 부하 | 캐싱 + Rate Limiting |
📅 이력
| 날짜 | 변경 내용 |
|---|---|
| 2026-01-08 | 초기 결정 및 문서 작성 |
ADR-005: 면접 Q&A 개별 저장 방식
📋 메타데이터
| 항목 | 내용 |
|---|---|
| 상태 | ✅ 채택됨 (Accepted) |
| 작성일 | 2026-01-08 |
| 결정자 | 개발팀 |
| 관련 기능 | 모의 면접 (/ai/interview/question, /ai/interview/save, /ai/interview/report) |
🎯 컨텍스트 (Context)
모의 면접에서 꼬리질문을 생성할 때, LLM이 이전 대화 맥락을 파악해야 합니다. 이를 위해 Q&A 데이터를 어떻게 저장하고 전달할지 결정해야 합니다.
요구사항:
- 꼬리질문 생성 시 이전 Q&A 맥락 필요
- 면접 종료 시 전체 Q&A 기반 리포트 생성
- 세션 중간에 끊겨도 복구 가능해야 함
🔍 선택지 분석 (Options)
Option 1: 프론트엔드에서 히스토리 누적 전달
// 매 요청 시 전체 히스토리 전달
{
"session_id": "...",
"history": [
{ "question": "Q1", "answer": "A1" },
{ "question": "Q2", "answer": "A2" }
]
}
장점:
- ✅ 서버 상태 관리 불필요
단점:
- ⚠️ 요청 크기 증가
- ⚠️ 세션 중단 시 복구 불가
- ⚠️ 프론트엔드 상태 관리 복잡
Option 2: 매 문답 개별 저장 (DB)
답변 입력 → POST /ai/interview/save → DB 저장
↓
POST /ai/interview/question → DB에서 히스토리 조회 → 꼬리질문 생성
장점:
- ✅ 세션 복구 가능
- ✅ 요청 크기 일정
- ✅ 데이터 영속성 보장
- ✅ 면접 통계/분석 가능
단점:
- ⚠️ DB 저장/조회 추가 비용
- ⚠️ API 호출 증가
Option 3: 면접 종료 시 일괄 저장
프론트엔드 → 면접 종료 → POST /ai/interview/report (전체 Q&A 포함)
장점:
- ✅ API 호출 최소화
단점:
- ⚠️ 세션 중단 시 데이터 유실
- ⚠️ 꼬리질문 맥락 파악 어려움
✅ 결정 (Decision)
Option 2: 매 문답 개별 저장 (DB) 방식을 선택합니다.
📝 근거 (Rationale)
1. 세션 안정성
사용자가 면접 중 브라우저 새로고침/종료 시:
- 개별 저장: 기존 Q&A 보존, 이어서 진행 가능
- 일괄 저장: 모든 데이터 유실
2. 꼬리질문 맥락 파악
# /ai/interview/question 내부 로직
async def generate_question(room_id: int, interview_id: int):
# DB에서 히스토리 조회 (ai_chat_messages 테이블에서 면접 관련 메시지 조회)
history = await db.ai_chat_messages.find({"room_id": room_id, "type": {"$in": ["INTERVIEW_QUESTION", "INTERVIEW_ANSWER"]}})
# VectorDB에서 관련 컨텍스트 검색
context = await vectordb.search(history[-1].answer)
# LLM에 히스토리 + 컨텍스트 전달
question = await llm.generate(history=history, context=context)
return question
3. 면접 분석 가능
-- 예: 사용자별 면접 참여 통계
SELECT r.user_id, COUNT(*) as interview_count
FROM ai_chat_interview i
JOIN ai_chat_rooms r ON i.room_id = r.id
GROUP BY r.user_id;
🗂️ API 흐름
[면접 시작]
↓
POST /ai/interview/question → 첫 질문 생성
↓
[사용자 답변 입력]
↓
POST /ai/interview/save → Q&A DB 저장 (qa_id 반환)
↓
POST /ai/interview/question → 꼬리질문 생성 (DB에서 히스토리 조회)
↓
... 반복 (최대 5개) ...
↓
[면접 종료]
↓
POST /ai/interview/report → 전체 Q&A 기반 리포트 생성
⚠️ 트레이드오프 (Trade-offs)
| 제약사항 | 대응 방안 |
|---|---|
| DB 저장/조회 비용 | 인덱싱 최적화 (session_id) |
| API 호출 증가 | 저장과 질문 생성 분리로 UX 개선 |
| 데이터 정합성 | 트랜잭션 처리 |