Edu-Sync AI — 트러블슈팅 모음: 실전에서 부딪힌 문제들과 해결 과정

멀티에이전트 AI 교육 시스템을 개발·배포하면서 겪은 모든 트러블슈팅을 시간순으로 기록합니다.
1. JSON 스토어 → PostgreSQL 마이그레이션
문제
로컬에서는 JSON 파일 기반 데이터 저장으로 개발했다. data/users.json, data/chat_messages.json 등 각 엔티티마다 JSON 파일을 읽고 쓰는 구조.
Render에 배포하니 파일이 사라졌다. Render의 파일시스템은 배포할 때마다 초기화되기 때문이다.
# 배포 전
data/users.json ← 사용자 데이터 존재 ✓
data/chat_messages.json ← 대화 이력 존재 ✓
# 배포 후 (재시작)
data/ ← 텅 빈 디렉토리 ✗
해결
PostgreSQL로 전면 마이그레이션했다. 핵심 전략은 라우터 코드를 건드리지 않는 것.
# Before (JSON)
class Store:
def get_user(self, user_id):
data = json.load(open("data/users.json"))
return data.get(user_id)
# After (SQLAlchemy)
class Store:
def get_user(self, user_id):
with self._session() as db:
row = db.get(User, user_id)
return _row_to_dict(row) if row else None
같은 메서드 시그니처, 같은 반환 타입. 라우터 코드에서는 store.get_user(id)만 호출하면 되니까 내부 구현만 바꿨다.
추가 작업:
- 12개 SQLAlchemy 모델 정의 (
models.py) - JSON 시드 데이터를
add_seed_data()로 자동 주입 database.py에서 PostgreSQL/SQLite 자동 전환
# database.py — 로컬(SQLite) / 배포(PostgreSQL) 자동 전환
DATABASE_URL = os.getenv("DATABASE_URL", "")
if DATABASE_URL:
# Render는 postgres:// 형식인데 SQLAlchemy는 postgresql:// 필요
url = DATABASE_URL.replace("postgres://", "postgresql://", 1)
engine = create_engine(url, pool_size=10, max_overflow=20)
else:
engine = create_engine("sqlite:///./local.db")
2. 파일 업로드 → 배포 후 파일 소실
문제
멘토가 PDF 파일을 업로드하면 디스크에 저장했는데, Render 재배포 시 디스크가 초기화되면서 업로드한 파일이 전부 사라졌다.
Render의 Persistent Disk(1GB)를 마운트했지만, 이것은 벡터스토어 인덱스 파일용이었고 업로드 파일은 별도 경로에 저장하고 있었다.
해결
파일 바이너리를 PostgreSQL LargeBinary 컬럼에 저장하기로 했다.
# models.py
class MentorDoc(Base):
__tablename__ = "mentor_docs"
# ...
file_data = Column(LargeBinary, nullable=True) # 파일 전체 바이너리
파일 서빙 시: 디스크 우선 → DB 폴백
# mentor.py — 파일 다운로드
async def open_mentor_asset(doc_id: str):
# 1) 디스크에 파일이 있으면 → FileResponse
asset_path = MENTOR_ASSET_DIR / doc["mentor_id"] / file_name
if asset_path.exists():
return FileResponse(asset_path)
# 2) 디스크에 없으면 → DB에서 바이너리 조회
file_bytes = store.get_mentor_doc_file_data(doc_id)
if file_bytes:
return Response(content=file_bytes)
raise HTTPException(404, "파일 없음")
3. KST 타임존 — 3번 고친 이야기
문제 1: UTC로 저장됨
처음에는 datetime.now().isoformat()로 시간을 저장했다. Render 서버는 UTC 기준이라 한국 시간보다 9시간 뒤처진 시간이 저장되었다.
# Before — UTC 기준
uploaded_at = datetime.now().isoformat()
# "2026-04-11T01:30:00" (UTC) ← 한국은 10:30인데...
해결 1: KST 오프셋 적용
_KST = timezone(timedelta(hours=9))
uploaded_at = datetime.now(_KST).strftime("%Y-%m-%dT%H:%M:%S")
# "2026-04-11T10:30:00" ✓
문제 2: 오프셋이 이중 적용됨
_doc_is_stale() 함수에서 DB에 저장된 KST 시간을 다시 KST로 변환하면서 이중 오프셋이 발생했다.
# Bug: 이미 KST인 문자열에 다시 KST를 붙임
uploaded = datetime.fromisoformat("2026-04-11T10:30:00")
uploaded = uploaded.replace(tzinfo=_KST) # +9h 다시 적용 → 19:30으로 계산됨
해결 2: replace 대신 비교 로직 수정
DB에 KST 문자열로 저장하되, 비교할 때는 둘 다 오프셋 관계없이 비교하도록 통일했다.
문제 3: 카카오 웹훅에서 날짜 불일치
카카오 웹훅에서 "오늘의 큐레이션"을 요청하면, 서버의 datetime.now() (UTC)와 큐레이션의 date (KST) 기준이 달라서 못 찾는 문제 발생.
해결 3: 모든 날짜 비교에 _KST 통일
today = datetime.now(_KST).strftime("%Y-%m-%d")
curations = store.get_curations(date=today) # KST 기준 오늘
교훈
타임존은 "저장 시점"과 "비교 시점"을 반드시 통일해야 한다. 가급적 UTC로 저장하고 표시만 KST로 변환하는 것이 안전하지만, 이미 KST 문자열로 저장 중이라면 전체를 일관되게 유지해야 한다.
4. 카카오톡 5초 타임아웃
문제
카카오 오픈빌더 스킬은 응답을 5초 이내에 반환해야 한다. 초과하면 사용자에게 "응답 실패" 메시지가 표시된다.
우리 시스템의 처리 시간:
PostgreSQL 쿼리: ~2-3초 (Render 무료 DB 레이턴시)
GPT-4o-mini API 호출: ~2-5초
RAG 벡터 검색: ~0.5-1초
──────────────────────────────
총합: ~4.5-9초 ← 5초 초과 가능
특히 Render 무료 PostgreSQL은 콜드 스타트 시 첫 연결에 10초 이상 걸리기도 한다.
해결 시도 1: 응답 시간 줄이기
- LLM 프롬프트를 영어로 전환 → 토큰 감소 → 처리 속도 향상
- GPT-4o-mini로 모델 고정 (다른 모델 실험 중 속도 저하 경험)
- RAG 검색 결과 수 제한 (k=4)
효과: 평균 4-5초까지 줄였지만, 여전히 5초를 넘는 경우가 발생.
해결 시도 2: asyncio.wait_for() 타임아웃 래핑 (최종)
5초 안에 완료되면 정상 응답, 초과하면 폴백 메시지를 즉시 반환한다.
# kakao.py
KAKAO_TIMEOUT = 4.8 # 0.2초 여유
@router.post("/webhook")
async def kakao_webhook(req: Request):
body = await req.json()
utterance = body["userRequest"]["utterance"]
try:
result = await asyncio.wait_for(
_process_kakao_message(utterance, user_id),
timeout=KAKAO_TIMEOUT
)
return result
except asyncio.TimeoutError:
# 5초 초과 → 폴백 메시지
return {
"version": "2.0",
"template": {
"outputs": [{"simpleText": {
"text": "답변을 준비하는 데 시간이 걸리고 있어요. 잠시 후 다시 질문해주세요!"
}}]
}
}
근본적 한계
Render 무료 PostgreSQL의 레이턴시(2-10초)와 GPT API 호출 시간을 합하면, 카카오 5초 제한은 구조적으로 불가능한 경우가 많다.
결국 카카오톡 채널은 보조 채널로 두고, 메인 사용자 경험은 웹 KakaoTalk 스타일 UI로 제공하기로 결정했다.
교훈
외부 서비스의 하드 리밋은 아키텍처 설계 단계에서 고려해야 한다. 사후에 최적화로 맞추기 어렵다. 무료 인프라의 레이턴시 한계를 인식하고, 대안 UI를 미리 준비하는 것이 현명하다.
5. 멘토 자료 업로드 속도 — "느려서 실패"
문제
멘토가 PDF 파일을 업로드하면 10초 이상 걸려서 프론트엔드에서 "업로드 실패" 에러가 표시됐다. 하지만 새로고침하면 파일은 올라가 있었다.
원인 분석:
HTTP 요청 → 파일 읽기 (빠름)
→ PDF 텍스트 추출 (pypdf, 1-3초)
→ GPT-4o-mini AI 요약 생성 (5-10초) ← 병목
→ 대용량 바이너리 DB 저장 (~2초)
→ 벡터스토어 인덱싱 (~1초)
→ HTTP 응답 반환 ← 이때쯤 Render 프록시 타임아웃
Render의 프록시가 30초 타임아웃인데, PostgreSQL 레이턴시까지 합치면 총 처리 시간이 10-20초에 달했다.
1차 수정 (부분 실패)
AI 요약 + 벡터스토어만 BackgroundTasks로 분리:
# 1차 시도 — 여전히 느림
store.add_mentor_doc({..., file_data=payload}) # 대용량 INSERT (느림)
bg.add_task(_bg_digest_and_index, ...) # AI만 백그라운드
return {"status": "ok"}
문제: file_data(파일 바이너리)를 초기 INSERT에 포함하고 있어서, 수 MB PDF를 PostgreSQL에 즉시 INSERT하는 것 자체가 느렸다.
또한 PDF 텍스트 추출(pypdf)도 응답 전에 실행하고 있었다.
2차 수정 (완전 해결)
응답 전에 하는 작업을 극소화: 파일명·메타데이터만 DB에 저장하고 즉시 응답. 나머지 전부 백그라운드.
# 최종 — 응답 전: 최소 정보만 INSERT
mentor_doc = {
"id": mentor_doc_id,
"digest_title": Path(original_filename).stem, # 파일명을 임시 제목으로
"digest_summary": "AI 요약 생성 중...", # 플레이스홀더
"file_data": None, # 바이너리 나중에
...
}
store.add_mentor_doc(mentor_doc) # 텍스트 컬럼만 → INSERT 빠름
bg.add_task(_bg_process_knowledge, ...) # 전부 백그라운드
return {"status": "ok", "document": _serialize_mentor_doc(mentor_doc)} # 즉시
백그라운드 태스크:
async def _bg_process_knowledge(mentor_id, doc_id, stored_path, ...):
# 1. PDF 텍스트 추출
content_text = _extract_text(stored_path, original_filename, source_link)
# 2. AI 요약 생성
digest_title, digest_summary = await _build_ai_digest(content_text, ...)
# 3. DB 업데이트 (file_data 포함)
store.update_mentor_doc(doc_id, {
"digest_title": digest_title,
"digest_summary": digest_summary,
"file_data": payload, # 바이너리 나중에 저장
})
# 4. 벡터스토어 인덱싱
add_mentor_document_to_vectorstore(mentor_id, [...])
프론트엔드도 개선:
// 서버 응답의 document를 즉시 목록에 추가 (낙관적 UI)
const { document } = await res.json();
if (document) setDocs(prev => [document, ...prev]);
// 6초 / 15초 후 자동 리프레시 → AI 요약 반영
setTimeout(() => fetchDocs(), 6000);
setTimeout(() => fetchDocs(), 15000);
교훈
HTTP 응답 전에는 최소한의 작업만. 무거운 작업은 모두 BackgroundTasks로. 특히 LargeBinary INSERT는 예상보다 느리므로 초기 저장에는 포함하지 않는 것이 좋다.
6. Intent Router — 핸드오프 오분류
문제
수강생이 "파이썬 어려워요"라고 하면, AI가 감정적 지원이 필요하다고 판단하여 멘토 1:1 상담으로 넘겼다. 단순한 학습 질문인데 핸드오프가 트리거된 것.
# 분류 결과
{"agent": "human_handoff", "confidence": 0.6}
# → 0.6이면 확실하지 않은데... 핸드오프 됨
멘토 대시보드에 불필요한 상담 요청이 쌓였다.
해결
핸드오프 신뢰도 임계값을 0.8 이상으로 올렸다. 그 미만이면 Agent A로 폴백.
# agent_router.py
if result["agent"] == "human_handoff" and result["confidence"] < 0.8:
result["agent"] = "agent_a" # 모호하면 안전한 쪽으로
프롬프트에도 명시적으로 가이드:
Classification Rules:
- human_handoff ONLY when emotional distress is CLEARLY expressed
(slump, giving up, severe frustration, career crisis)
- "어려워요" alone is NOT enough for handoff — it's a learning question
- When uncertain, ALWAYS choose agent_a (safest default)
7. 벡터스토어 동기화 — 삭제 후 유령 검색
문제
멘토가 자료를 삭제했는데, 수강생이 검색하면 여전히 삭제된 자료의 요약이 나왔다. DB에서는 삭제됐지만 FAISS 벡터스토어에는 그대로 남아있었기 때문.
[멘토] 자료 삭제 → DB에서 삭제됨 ✓
[수강생] "관련 자료 알려줘" → FAISS 검색 → 삭제된 자료 노출 ✗
해결
삭제 시 벡터스토어 전체 리빌드:
@router.delete("/knowledge/{doc_id}")
async def delete_mentor_knowledge(doc_id, token):
# 1. DB에서 삭제
removed = store.remove_mentor_doc(mentor["id"], doc_id)
# 2. 남은 문서로 벡터스토어 재구축
remaining = store.get_mentor_docs(mentor["id"])
if remaining:
docs = [
Document(
page_content=f"제목: {d['digest_title']}\n요약: {d['digest_summary']}",
metadata={"mentor_doc_id": d["id"]}
)
for d in remaining
]
rebuild_mentor_vectorstore(mentor["id"], docs)
else:
rebuild_mentor_vectorstore(mentor["id"], []) # 빈 벡터스토어
FAISS는 개별 벡터 삭제가 깔끔하지 않아서, 남은 문서로 통째로 리빌드하는 방식을 택했다. 문서 수가 수십~수백 건 수준이라 성능 문제 없음.
8. LLM 프롬프트 언어 — 영어 vs 한국어
문제
모든 프롬프트를 한국어로 작성했더니, GPT-4o-mini의 응답 속도가 의도보다 느렸다. 또한 토큰 소모량이 많았다.
한국어는 BPE 토큰화 시 영어 대비 2-3배 많은 토큰을 사용한다:
"당신은 수강생 학습 지원 AI입니다." → ~15 tokens
"You are a student learning support AI." → ~8 tokens
시스템 프롬프트만 해도 한영 차이가 크다.
해결
프롬프트는 영어, 출력은 한국어 규칙 적용.
prompt = """
You are a study support AI for Korean vocational training (KDT).
Your users are Korean students. Always reply in Korean.
Analyze the student's question and return a JSON response:
{"content": "한국어 답변", "choices": ["선택지1", "선택지2"]}
"""
결과
- 토큰 수 ~30% 감소
- 응답 속도 체감 개선
- 한국어 출력 품질은 동일 (GPT-4o-mini는 영어 프롬프트로 한국어 생성해도 품질 유지)
교훈
비영어 프로젝트에서도 시스템 프롬프트는 영어가 유리하다. 토큰 절약 + 속도 향상. 단, 사용자에게 표시되는 응답은 반드시 해당 언어로 지정.
9. 조교 스케줄 — 날짜 하나 바꾸면 전체 초기화
문제
조교가 AI 스케줄 어시스턴트에게 "4월 15일 오전 10시~12시 추가해줘"라고 하면, 해당 월의 전체 스케줄이 초기화되고 4월 15일만 남았다.
원인: schedule-assistant API가 full 모드만 지원해서, AI가 응답하면 해당 월 슬롯을 전부 삭제하고 새로 생성했다.
# Before — full 모드만
async def schedule_assistant(msg: str):
ai_plan = await llm.chat(...) # AI가 전체 스케줄 응답
store.delete_all_slots(ta_id, month) # 전부 삭제
store.create_slots(ai_plan) # AI 응답으로 재생성
# → 기존 예약이 있던 슬롯도 삭제됨!
해결
date_override 모드 추가. 특정 날짜만 수정하고 나머지는 유지.
# After — date_override 모드
if mode == "date_override":
for date_str, slots in plan.items():
store.delete_slots_for_date(ta_id, date_str) # 해당 날짜만 삭제
store.create_slots(ta_id, date_str, slots) # 해당 날짜만 재생성
# → 다른 날짜의 기존 예약은 보존
프론트엔드에서도 달력의 날짜를 클릭하면 해당 날짜만 수정하는 UI를 추가했다.
10. 카카오/웹 예약 플로우 불일치
문제
웹 챗봇에서는 예약 플로우가 4단계(날짜 → 시간 → 설명 입력 → 확인)인데, 카카오톡에서는 3단계(날짜 → 시간 → 바로 확인)로 달랐다. 설명 입력 단계가 카카오에서 빠져있었다.
또한 카카오에서 "예약"이라고 말하면 바로 날짜 선택이 나왔는데, 웹에서는 "예약/취소" 선택 메뉴가 먼저 나왔다.
해결
카카오 웹훅에도 웹과 동일한 4단계 플로우를 구현했다.
# 카카오 예약 플로우
"예약" → [예약하기 / 취소하기] quickReplies
"예약하기" → [4/14(월) / 4/15(화) ...] quickReplies
"4/15(화)" → [10:00~11:00 / 14:00~15:00] quickReplies
(시간 선택) → "어떤 질문인지 간단히 입력해주세요" (자유 입력)
(설명 입력) → 예약 완료 + AI 브리핑 생성
핵심은 pending slot 메커니즘: 시간대를 선택한 후 슬롯 ID를 서버에 임시 저장해두고, 다음 메시지(자유 텍스트)가 오면 해당 슬롯에 설명을 붙여 예약 확정.
# 카카오는 stateless이므로 서버에 pending 상태 저장
pending_slots = {} # {kakao_user_id: slot_id}
# 1) 시간대 선택 시
pending_slots[user_id] = selected_slot_id
# 2) 다음 메시지 (설명 입력) 시
if user_id in pending_slots:
slot_id = pending_slots.pop(user_id)
confirm_booking(slot_id, description=utterance)
11. 큐레이션 RAG — "오늘의 큐레이션"인데 어제 것이 나옴
문제
수강생이 "오늘의 큐레이션 알려줘"라고 하면, Agent A가 RAG 검색으로 큐레이션을 찾는다. 그런데 어제 등록된 큐레이션이 유사도 점수가 더 높아서 오늘 것 대신 어제 것이 나왔다.
해결
Agent A에 시간 범위 분석 단계를 추가했다. LLM이 먼저 질문의 시간 의도를 파악한다.
# 시간 범위 분류
time_range_prompt = """
Analyze: does the user want today's, this week's, or all curations?
Return JSON: {"time_range": "today"|"this_week"|"last_week"|"recent"|"all"}
"""
그리고 FAISS 검색 결과를 시간 범위로 필터링 + 카테고리 힌트 정렬:
# 검색 결과 필터링
if time_range == "today":
results = [r for r in results if r["date"] == today]
elif time_range == "this_week":
results = [r for r in results if r["date"] >= this_week_start]
폴백: 벡터 검색 결과가 없으면 최근 3일 큐레이션을 DB에서 직접 가져온다.
12. gpt-5.4-mini 실험 → 급히 복원
문제
새 모델을 테스트하기 위해 gpt-5.4-mini(가칭)로 전환했더니, 응답 속도가 크게 느려졌다. 카카오 타임아웃 초과 빈도가 급증하고, 웹 챗봇 응답도 체감상 2배 느려졌다.
해결
gpt-4o-mini로 즉시 복원. 성능 대비 속도와 비용 효율이 검증된 모델을 유지하기로 결정.
# llm_provider.py
self.model = ChatOpenAI(
model_name=model_name or "gpt-4o-mini", # 고정
temperature=0.3,
)
13. N+1 쿼리 — 대시보드 로딩 병목
문제
멘토 대시보드의 "최근 수강생 활동" 섹션에서, 학생 수만큼 DB 쿼리가 발생했다.
# Before — N+1 쿼리
for student in students:
activity = store.get_chat_history(student["id"]) # 학생 1명당 1쿼리
# 학생 20명이면 → 20번 쿼리
Render 무료 PostgreSQL에서 쿼리 1회 = 100ms
500ms. 학생 20명이면 **2
10초**.
해결
한 번의 쿼리로 모든 학생의 최근 활동을 가져오도록 변경:
# After — 배치 쿼리
def get_recent_chat_activity(self, mentor_id, hours=168):
student_ids = [s["id"] for s in self.get_students_by_mentor(mentor_id)]
with self._session() as db:
messages = db.query(ChatMessage).filter(
ChatMessage.user_id.in_(student_ids),
ChatMessage.created_at >= cutoff
).order_by(ChatMessage.created_at.desc()).all()
# 1번의 쿼리로 전체 조회
14. 자동 마이그레이션 — 테이블 컬럼 추가
문제
개발 중 모델에 새 컬럼을 추가할 때마다, 이미 배포된 PostgreSQL 테이블에는 해당 컬럼이 없어서 에러가 발생했다.
sqlalchemy.exc.ProgrammingError: column "briefing_report" does not exist
Alembic 같은 마이그레이션 도구를 별도로 쓰기엔 오버헤드가 컸다.
해결
database.py에 자동 컬럼 추가 로직을 넣었다. 앱 시작 시 모델 정의와 실제 DB 스키마를 비교해서 빠진 컬럼을 ALTER TABLE로 추가.
# database.py — 자동 마이그레이션
def _auto_add_missing_columns(engine):
inspector = inspect(engine)
for table_name, model in models:
existing_cols = {c["name"] for c in inspector.get_columns(table_name)}
for col in model.__table__.columns:
if col.name not in existing_cols:
col_type = col.type.compile(engine.dialect)
engine.execute(f"ALTER TABLE {table_name} ADD COLUMN {col.name} {col_type}")
인덱스도 마찬가지로 자동 추가.
15. 프론트엔드 빌드 — SPA 라우팅
문제
React SPA를 Render 정적 사이트로 배포했더니, 새로고침하면 404 에러가 발생했다. SPA는 모든 경로를 index.html로 리다이렉트해야 하는데, 정적 서버가 실제 파일 경로로 찾으려 했기 때문.
해결
FastAPI 백엔드에서 SPA 정적 파일을 직접 서빙하도록 변경:
# main.py
dist = Path(__file__).parent.parent / "frontend" / "dist"
if dist.exists():
app.mount("/", StaticFiles(directory=str(dist), html=True), name="spa")
html=True 옵션이 핵심 — 파일이 없는 경로는 자동으로 index.html을 반환한다.
마무리 — 배운 점 정리
| # | 트러블슈팅 | 핵심 교훈 |
|---|---|---|
| 1 | JSON → PostgreSQL | 데이터 레이어 추상화의 가치 |
| 2 | 파일 소실 | 영속 스토리지(DB/S3) 필수 |
| 3 | KST 타임존 | 저장·비교 기준 통일 |
| 4 | 카카오 5초 | 외부 하드 리밋은 설계 단계에서 고려 |
| 5 | 업로드 속도 | HTTP 응답 전 = 최소 작업, 나머지 백그라운드 |
| 6 | 핸드오프 오분류 | LLM 분류 신뢰도 임계값 필수 |
| 7 | 벡터스토어 동기화 | DB CRUD ↔ 벡터스토어 동기화 필수 |
| 8 | 한국어 프롬프트 | 영어 프롬프트 + 한국어 출력 = 최적 |
| 9 | 스케줄 전체 초기화 | "전체 교체"보다 "부분 수정"이 기본 |
| 10 | 카카오/웹 불일치 | 멀티 채널 플로우 통일 |
| 11 | 큐레이션 시간 | RAG에서 시간 필터링은 별도 처리 |
| 12 | 모델 교체 실패 | A/B 테스트 없이 프로덕션 교체 금지 |
| 13 | N+1 쿼리 | 리스트는 배치 쿼리 |
| 14 | 자동 마이그레이션 | 컬럼 추가는 자동, 삭제는 수동 |
| 15 | SPA 라우팅 | html=True 옵션 |
실전 프로젝트에서 겪는 문제들은 대부분 "코드 자체"보다는 인프라, 외부 서비스 제약, 데이터 일관성에서 발생한다. 이 글이 비슷한 시스템을 만드시는 분들에게 도움이 되길 바랍니다.
이전 글(1장)에서는 프로젝트의 기획부터 완성까지의 전체 개발기를 다루고 있습니다.
'5. [개인] 프로젝트 및 공모전 > 4-4 공모전' 카테고리의 다른 글
| [개인 공모전] DACON - ETRI 휴먼이해 인공지능 논문경진대회 (2): 그래프 분석 시작 (0) | 2026.06.01 |
|---|---|
| [개인 공모전] DACON - ETRI 휴먼이해 인공지능 논문경진대회 (1) (0) | 2026.05.31 |
| [KDT 공모전] 멀티 에이전트 카카오톡 챗봇 플랫폼: Edu-Sync AI (1) (0) | 2026.04.10 |