아키텍처
SCH Web Portal 전체 구조, 인증 흐름, 보안 설계, DB 스키마
전체 구조
학적·성적·시간표 등 대부분의 데이터는 DB에 저장되지 않고 매 요청마다 SCH 서버에서 실시간 조회합니다.
DB에 저장하는 데이터는 즐겨찾기, 시간표 테마, 학사일정, 공지사항 4종뿐입니다.
백엔드 구조
디렉터리
backend/
├── portal/
│ ├── settings.py # 전체 설정
│ └── urls.py # path('api/', include('main.urls'))
└── main/
├── models.py # DB 모델 4종
├── urls.py # 70+ API 경로
├── session_crypto.py # AES-256-GCM 세션 암호화
├── views/ # 기능별 뷰 모듈 (40+ 파일)
│ ├── _common.py # 세션 쿠키 헬퍼, 공통 상수
│ ├── auth.py
│ ├── academic.py
│ └── ...
└── sch_client/ # SCH 서버 HTTP 통신 클라이언트
├── _common.py # 공통 요청 로직
└── ...요청 처리 흐름
브라우저 요청 (쿠키 포함)
│
▼
Django View
│
├─ _get_session(request)
│ ├─ 쿠키에서 sch_session 추출
│ ├─ session_crypto.decrypt(token, ip, ua)
│ └─ 만료 시간 확인
│
├─ sch_client.*(std_no, ecd_key, jsessionid, ...)
│ └─ SCH 서버 HTTP 요청
│
└─ Response(data)세션 암호화 상세
알고리즘
| 항목 | 값 |
|---|---|
| 암호화 | AES-256-GCM |
| 키 파생 | HKDF-SHA256 |
| HKDF salt | "sch-portal-v1" |
| HKDF info | "session-v1" |
| Nonce | 12 bytes (랜덤) |
| GCM Tag | 16 bytes |
| AAD | HMAC-SHA256("ctx-binding", ip + "|" + user_agent) |
토큰 구조 (바이너리, Base64URL 인코딩)
┌──────────┬──────────┬───────────────┬────────────────────────────┐
│ version │ kid │ nonce │ ciphertext + GCM tag │
│ (1 byte) │ (1 byte) │ (12 bytes) │ (N + 16 bytes) │
└──────────┴──────────┴───────────────┴────────────────────────────┘현재 버전: 0x01, 키 ID: 1 (향후 키 교체 시 kid로 구분)
IP + User-Agent 바인딩
AAD(Additional Authenticated Data)에 클라이언트 IP와 User-Agent가 포함됩니다.
따라서 같은 토큰을 다른 IP나 다른 브라우저에서 재사용하면 GCM 인증이 실패합니다.
이 설계의 장점:
- 쿠키 탈취 후 다른 IP에서 사용 불가
- 같은 네트워크(사내 NAT)에서도 User-Agent가 다르면 실패
단점:
- 모바일 로밍 중 IP가 바뀌면 세션이 끊김
- VPN 환경에서 간헐적으로 IP가 바뀌면 로그아웃됨
세션 페이로드
{
"std_no": "20201234", # 학번
"user_no": "U000001234", # SCH 사용자 번호
"name": "홍길동", # 이름
"jsessionid": "ABC123...", # SCH 서버 세션 ID
"ecd_key": "ENCKEY...", # SCH 암호화 키
"dept_cd": "CS", # 학과 코드
"use_div": "", # 재학구분 (항상 빈 문자열)
"aug_div": "", # 인증구분 (항상 빈 문자열)
"expired": 1700007200 # 만료 Unix timestamp
}DB 스키마
student_favorites
| 컬럼 | 타입 | 설명 |
|---|---|---|
std_no | VARCHAR(20) PK | 학번 |
items | JSON | 즐겨찾기 항목 이름 배열 |
updated_at | DATETIME | 최종 수정 시각 |
timetable_themes
| 컬럼 | 타입 | 설명 |
|---|---|---|
id | INT PK | 자동증가 |
name | VARCHAR(40) | 테마 이름 |
description | VARCHAR(140) | 설명 |
colors | JSON | 배경색 배열 (HEX, 3~16개) |
text_colors | JSON | 텍스트색 배열 |
author_std_no | VARCHAR(20) INDEX | 작성자 학번 |
is_public | BOOLEAN | 공개 여부 |
use_count | INT UNSIGNED | 적용 횟수 |
created_at | DATETIME | 생성 시각 |
updated_at | DATETIME | 수정 시각 |
정렬 기본값: (-use_count, -created_at) — 많이 사용된 테마가 상위에 표시
student_timetable_themes
| 컬럼 | 타입 | 설명 |
|---|---|---|
std_no | VARCHAR(20) PK | 학번 |
theme_id | INT FK (nullable) | 적용 테마 ID. NULL이면 기본값 |
updated_at | DATETIME | 최종 수정 시각 |
테마 적용 시 timetable_themes.use_count를 원자적으로 +1 증가시킵니다.
academic_events
| 컬럼 | 타입 | 설명 |
|---|---|---|
id | INT PK | 자동증가 |
start_date | DATE | 일정 시작일 |
end_date | DATE | 일정 종료일 |
content | VARCHAR(255) | 일정 내용 |
정렬 기본값: start_date 오름차순
chat_conversations
AI 챗봇 대화 기록 (클라우드 동기화에 동의한 사용자만 해당 — 학번 기준으로 저장).
| 컬럼 | 타입 | 설명 |
|---|---|---|
id | INT PK | 자동증가 |
std_no | VARCHAR(20) INDEX | 학번 |
title | VARCHAR(100) | 대화 제목 — 첫 질문에서 자동 생성 |
turns | JSON | [{question, answer, trace}] |
last_notice | JSON (nullable) | [board, id] — 후속 질문 문맥용 |
import_key | VARCHAR(80) (nullable) | 로컬→서버 이전 시 멱등 처리 키 |
created_at / updated_at | DATETIME | 생성/수정 시각 |
UniqueConstraint(std_no, import_key), 정렬 기본값: -updated_at
chat_logs
챗봇 전체 문답의 익명 로그 — 답변 품질 검토용. 학번 등 식별 정보는 저장하지 않는다.
| 컬럼 | 타입 | 설명 |
|---|---|---|
id | INT PK | 자동증가 |
thread_key | VARCHAR(36) INDEX | 대화 식별자를 비밀키(HMAC)로 해시한 값 — 같은 대화의 턴을 묶되 원래 대화·사용자로 역추적 불가 |
question / answer | TEXT | 질문/답변 원문 |
trace | JSON | 진행 과정 문구 (디버깅·검토용) |
high_quality | BOOLEAN | 고성능 모드 답변 여부 |
created_at | DATETIME | 생성 시각 |
정렬 기본값: -created_at. 저장 설정(로컬/클라우드)과 무관하게 항상 기록된다.
chat_feedback
답변 평가(좋아요/싫어요) — 추후 RLHF 학습 데이터로 활용 예정. 익명 수집.
| 컬럼 | 타입 | 설명 |
|---|---|---|
id | INT PK | 자동증가 |
rating | VARCHAR(4) | up / down |
question / answer | TEXT | 평가 대상 문답 |
comment | TEXT | 사용자 의견 (선택) |
context | JSON | 직전 문답 [{question, answer}] 최대 3쌍 |
high_quality | BOOLEAN | 고성능 모드 답변 여부 |
created_at | DATETIME | 생성 시각 |
정렬 기본값: -created_at
AI 챗봇 아키텍처
순라이프 AI는 별도 Django 앱이 아니라 main 앱 안의 chatbot/ 패키지로 구현되어 있습니다.
backend/main/
├── chatbot/
│ ├── agent.py # LLM 에이전트 — 도구 호출, 프롬프트, 스트리밍, 답변 검증
│ └── attachment_extractor.py # 공지 첨부파일(pdf/hwp/docx/xlsx) 다운로드·텍스트·이미지 추출
├── views/chat.py # DRF 뷰 — NDJSON 스트리밍 엔드포인트
└── models.py # ChatConversation, ChatLog, ChatFeedback흐름
Client (fetch, 청크 읽기)
│ POST /api/chat/
▼
ChatView.post()
│ sch_session 쿠키 검증 (다른 API와 동일)
│ 별도 스레드에서 Agent.run_turn() 실행 → queue로 이벤트 전달
▼
Agent (agent.py)
│ ├─ 도구 호출: 공지 검색 / 시맨틱 검색 / 학사일정 조회 / 첨부 조회
│ ├─ vLLM 서버 (OpenAI 호환 /chat/completions, stream=True)
│ └─ 답변 검증 후 확정된 텍스트만 6자 단위로 타자기 스트리밍
▼
StreamingHttpResponse (application/x-ndjson)- 학생당 서버 메모리에
Agent인스턴스 1개(+락 +중단 이벤트)를 유지하며, 진행 중인 답변 생성은 학생당 동시에 하나만 허용됩니다. - vLLM은 systemd로 관리되는 자체 호스팅 서버(
soonlife-vllm서비스)이며, 프롬프트 이미지 개수 제한(--limit-mm-per-prompt)에 맞춰 첨부 이미지 처리 개수가 관리됩니다. - 시맨틱 검색(BGE-M3 임베딩 + Qdrant + reranker)은
EMBED_SEARCH_URL이 설정된 경우에만 도구로 활성화되며, 미설정 시 공지 키워드 검색으로 대체됩니다. - 기숙사 규정 변경처럼 오답 위험이 큰 특정 질문 패턴은 키워드 필터 + 저비용 분류 LLM 호출로 판별해, 사람이 검증한 고정 답변으로 즉시 응답하고 일반 생성 경로를 건너뜁니다.
배포 시 gunicorn 설정
챗봇의 긴 스트리밍 응답을 지원하기 위해 gunicorn을 sync 워커에서 gthread로 변경했습니다.
gunicorn portal.wsgi:application \
--workers 3 --worker-class gthread --threads 8 --timeout 600sync 워커는 요청 처리 중 하트비트를 보내지 못해 챗봇처럼 오래 걸리는 스트리밍 응답이 기존 타임아웃(120초)에 강제 종료되는 문제가 있었습니다. gthread는 메인 스레드가 하트비트를 유지해 긴 응답이 끊기지 않고, 동시 처리량도 3(워커) → 24(워커×스레드)로 늘어 챗봇 응답 중에도 다른 포털 API 요청이 막히지 않습니다.
프론트엔드 구조
라우팅
App.tsx에서 React Router v7로 관리. 모든 페이지 컴포넌트는 lazy()로 코드 스플리팅됩니다.
/ → HomePage (대시보드)
/login → LoginPage
/academic/status → AcademicStatusPage
/grades/cumulative → CumulativeGradesPage
/grades/semester → SemesterGradesPage
/timetable → (시간표 관련 여러 경로)
/scholarship → ScholarshipPage
/dorm/bill → DormBillPage
...메뉴 관리 (sitemap.ts)
| 상수 | 설명 |
|---|---|
SITEMAP | 3개 그룹, 17개 섹션, 전체 메뉴 아이템 정의 |
IMPLEMENTED_ITEMS | 현재 구현된 메뉴 이름 Set — 미포함 항목은 사이드바에서 비활성화 |
ITEM_ROUTES | 메뉴 이름 → URL 경로 매핑 |
SECTION_ROUTES | 섹션 ID → URL 경로 매핑 |
새 기능 추가 체크리스트:
SITEMAP에 메뉴 항목 추가 (이미 있으면 생략)IMPLEMENTED_ITEMSSet에 항목 이름 추가ITEM_ROUTES에 경로 추가App.tsx에lazy()import +<Route>추가components/에 페이지 컴포넌트 생성
상태 관리
별도의 전역 상태 라이브러리 없음.
| 데이터 | 관리 방식 |
|---|---|
| 사용자 세션 | GET /api/auth/me/로 초기화, 메모리에 유지 |
| 즐겨찾기 | API로 서버와 동기화 |
| 시간표 테마 | API를 통해 DB에 저장 |
| 페이지별 데이터 | 각 컴포넌트의 useState + API 호출 |
보안 설계
| 항목 | 구현 방식 |
|---|---|
| 세션 저장 | HttpOnly 쿠키 — JS에서 document.cookie로 읽기 불가 |
| 세션 무결성 | AES-256-GCM 인증 태그 — 변조 시 복호화 실패 |
| 세션 바인딩 | IP + User-Agent AAD — 쿠키 탈취 후 재사용 방지 |
| CSRF 방어 | HttpOnly 쿠키 기반이므로 CSRF 미들웨어 비활성화 (SameSite=Lax로 CSRF 방어) |
| CORS | CORS_ALLOWED_ORIGINS 화이트리스트 방식 |
| 클릭재킹 방어 | X-FRAME-OPTIONS: DENY |
| MIME 스니핑 방어 | SECURE_CONTENT_TYPE_NOSNIFF: True |
| Referrer 정책 | strict-origin-when-cross-origin |
| Django auth 프레임워크 | 미사용 — django.contrib.{admin,auth,contenttypes,sessions,messages}를 INSTALLED_APPS/MIDDLEWARE에서 전부 제거. 인증은 처음부터 자체 sch_session 쿠키(AES-256-GCM)로만 처리하며, auth_*/django_session/django_content_type 등 불필요한 테이블 생성을 막기 위한 정리 |
CORS 허용 오리진
CORS_ALLOWED_ORIGINS = [
"http://localhost:5173", # Vite 개발 서버
"http://127.0.0.1:5173",
"https://sch.wonchan.net", # 프로덕션
"http://sch.wonchan.net",
]
CORS_ALLOW_CREDENTIALS = True # 쿠키 허용새 배포 환경을 추가할 경우 portal/settings.py의 이 목록에 오리진을 추가하세요.
학기 코드 규칙
| 코드 | 설명 |
|---|---|
11 | 1학기 (3월~8월) |
21 | 2학기 (9월~2월) |
12 | 하계방학 (기숙사 한정) |
22 | 동계방학 (기숙사 한정) |
백엔드의 _current_semester() 함수는 오늘 날짜를 기준으로 (yy, smt)를 자동 반환합니다:
def _current_semester() -> tuple[str, str]:
today = date.today()
year, month = today.year, today.month
if 3 <= month <= 8:
return str(year), '11' # 1학기
elif month >= 9:
return str(year), '21' # 2학기
else:
return str(year - 1), '21' # 전년도 2학기 (1~2월)