Soonlife DOCS
아키텍처

아키텍처

SCH Web Portal 전체 구조, 인증 흐름, 보안 설계, DB 스키마

전체 구조

사용자 브라우저React SPA (Vite)localhost:5173/api/* proxyDjango REST APIlocalhost:8000SCH 통합정보시스템(학교 서버)학적 · 성적 · 시간표 · 웹메일 실시간 조회MariaDB즐겨찾기 · 시간표 테마 · 학사일정공지사항 (외부 스크래퍼 적재)

학적·성적·시간표·졸업진도·웹메일 등 대부분의 데이터는 DB에 저장되지 않고 매 요청마다 학교 서버에서 실시간 조회합니다.

DB 저장실시간 조회
즐겨찾기, 시간표 테마, 학사일정, 공지사항(외부 스크래퍼가 적재)학적, 성적, 시간표, 졸업진도, 등록·장학, 기숙사, 웹메일, 각종 신청

연동하는 외부 서버

서버용도
st.sch.ac.kr학적·성적·등록·장학 등 XPLATFORM XML API
h5.sch.ac.kr전자출결·시간표
report.sch.ac.krOZ 리포트 뷰어 (증명서·고지서 출력)
sso.sch.ac.kr · portal.sch.ac.kr · mail.sch.ac.kr웹메일 (RSA SSO 로그인 + JSON API)
challenges.cloudflare.com로그인 Turnstile 토큰 검증

백엔드 구조

디렉터리

backend/
├── portal/
│   ├── settings.py        # 전체 설정
│   └── urls.py            # path('api/', include('main.urls'))
└── main/
    ├── models.py          # DB 모델 4종
    ├── urls.py            # 114개 API 경로
    ├── session_crypto.py  # AES-256-GCM 세션 암호화
    ├── turnstile.py       # Cloudflare Turnstile 토큰 검증
    ├── views/             # 기능별 뷰 모듈 (41개 + _common.py)
    │   ├── _common.py     # 세션 쿠키 헬퍼, 공통 상수
    │   ├── auth.py
    │   ├── mail.py        # 웹메일 — _MailAPIView 별도 베이스
    │   └── ...
    ├── sch_client/        # 학교 서버 HTTP 통신 클라이언트 (38개 + _common.py)
    │   ├── _common.py     # XML 파싱·헤더·인증 예외
    │   ├── mail.py        # 유일하게 XPLATFORM이 아님 (SSO + JSON)
    │   └── ...
    └── migrations/        # 0001 ~ 0011 (0006~0011은 제거된 챗봇 모델 — 아래 참고)

요청 처리 흐름

브라우저 요청 (쿠키 포함)
       │
       ▼
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"
Nonce12 bytes (랜덤)
GCM Tag16 bytes
AADHMAC-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...",   # TRANSACTION_ECDKEY
  "dept_cd":      "CS",          # 학과 코드 (없으면 "")
  "password":     "...",         # 웹메일 SSO 전용
  "mail_cookies": {},            # 웹메일 세션 쿠키 (최초엔 빈 dict)
  "expired":      1700007200     # 만료 Unix timestamp
}

password는 웹메일 SSO가 재사용 가능한 토큰을 주지 않기 때문에 들고 있습니다. 이 페이로드 안에만 존재하고 서버 DB·로그에는 남지 않습니다. 자세한 내용은 웹메일 절을 참고하세요.

페이로드를 재암호화하는 곳이 세 군데(auth.py의 LoginView·RefreshView, mail.py의 _renew_session)라, 세션 값을 늘릴 때는 세 곳을 모두 확인해야 합니다.


DB 스키마

student_favorites

컬럼타입설명
std_noVARCHAR(20) PK학번
itemsJSON즐겨찾기 항목 이름 배열
updated_atDATETIME최종 수정 시각

timetable_themes

컬럼타입설명
idINT PK자동증가
nameVARCHAR(40)테마 이름
descriptionVARCHAR(140)설명
colorsJSON배경색 배열 (HEX, 3~16개)
text_colorsJSON텍스트색 배열
author_std_noVARCHAR(20) INDEX작성자 학번
is_publicBOOLEAN공개 여부
use_countINT UNSIGNED적용 횟수
created_atDATETIME생성 시각
updated_atDATETIME수정 시각

정렬 기본값: (-use_count, -created_at) — 많이 사용된 테마가 상위에 표시

student_timetable_themes

컬럼타입설명
std_noVARCHAR(20) PK학번
theme_idINT FK (nullable)적용 테마 ID. NULL이면 기본값
updated_atDATETIME최종 수정 시각

테마 적용 시 timetable_themes.use_count를 원자적으로 +1 증가시킵니다.

academic_events

컬럼타입설명
idINT PK자동증가
start_dateDATE일정 시작일
end_dateDATE일정 종료일
contentVARCHAR(255)일정 내용

정렬 기본값: start_date 오름차순

제거된 챗봇 테이블

chat_conversations·chat_logs·chat_feedback은 챗봇 제거(2026-09-28)와 함께 models.py에서 삭제되었습니다. 그러나 마이그레이션 0006~0011은 그대로 남아 있고 모델을 지우는(DeleteModel) 마이그레이션은 없습니다.

  • 이미 운영 중인 DB에는 chat_* 테이블이 데이터와 함께 남아 있을 수 있습니다(migrate는 이 테이블을 지우지 않습니다).
  • 새 DB에 migrate하면 0006~0011이 chat_* 테이블을 만든 뒤 아무도 쓰지 않습니다.

챗봇 테이블을 정리하려면 makemigrations로 삭제 마이그레이션을 만들기 전에, 남아 있는 대화·로그·피드백 데이터를 보존할지 먼저 결정하세요. 챗봇 데이터는 되돌릴 수 없습니다.

공지사항 테이블 (Django 모델 아님)

공지사항은 별도 스크래퍼가 적재하며, Django 모델이 아니라 raw SQL(django.db.connection)로 읽습니다. 마이그레이션 대상이 아닙니다.

테이블게시판
academic_notices학사
scholarship_notices장학
career_notices취업
sw_noticesSW
university_notices대학 (종합)
bid_notices입찰
nanum_notices향설교양대학
src_notices생활관
meal_menus식단 (주간 학식 식단표)

읽는 곳은 views/notices.py(공지 목록 API)입니다. 예전에는 챗봇 검색 도구도 같은 테이블을 읽었지만 챗봇과 함께 제거되었습니다.

meal_menus는 다른 게시판과 칼럼 구성이 다릅니다. author·content·attachments가 없고 식단표 이미지(images)가 내용 전부입니다.


gunicorn 설정

gunicorn portal.wsgi:application \
    --workers 3 --worker-class gthread --threads 8 --timeout 600

원래 챗봇의 긴 스트리밍 응답이 sync 워커의 하트비트 부재로 타임아웃에 강제 종료되던 문제 때문에 gthread로 바꾼 설정입니다. 챗봇이 제거된 지금도 scripts/server-setup.sh가 이 설정으로 서비스를 구성합니다. 동시 처리량은 3(워커) → 24(워커×스레드)이며, 웹메일 첨부 스트리밍처럼 오래 걸리는 요청이 다른 API를 막지 않는 데 여전히 도움이 됩니다.

자세한 배포 절차는 배포 문서를 참고하세요.


웹메일 아키텍처

웹메일은 포털에서 유일하게 인증 체계가 다른 기능입니다. 종합정보시스템(st.sch.ac.kr)의 jsessionid/ecd_key로는 메일 서버에 접근할 수 없고, 응답도 XPLATFORM XML이 아니라 JSON입니다.

backend/main/
├── sch_client/mail.py   # SSO RSA 로그인 체인 + 메일 JSON API
└── views/mail.py        # _MailAPIView — 세션 지연 수립, 쿠키 갱신

frontend/src/
├── components/MailApp.tsx  # /mail 전용 셸 (사이드바까지 통째로 다름)
├── data/mail.ts            # MAIL_FOLDERS · 별표 색상
└── data/mailApi.ts         # API 래퍼 + MailApiError

SSO 로그인 체인

  1. portal.sch.ac.kr/_custom/sch/oauth/login_portal.jsp → oauth/authorize → SSO 로그인 폼. authorize를 거쳐야 세션에 OAuth 클라이언트 컨텍스트가 등록됩니다. 로그인 폼 URL로 바로 들어가면 자격증명이 맞아도 거부됩니다.
  2. 자격증명 JSON 전체를 RSA(PKCS1v15)로 암호화해 /oa/au/auth/verify에 POST.
  3. portal.sch.ac.kr/r.jsp?url=<메일 oAuthCheckUser.do> 브리지가 메일 서버가 신뢰하는 SCHDR/SCHDRS 쿠키를 sch.ac.kr 도메인에 심습니다. 브리지를 건너뛰면 메일 자체 로그인 페이지로 튕깁니다.

요청 하나당 상한(_LOGIN_TIMEOUT 6초)과 체인 전체 예산(_LOGIN_BUDGET 10초)을 따로 둡니다. 요청별 타임아웃만 두면 왕복이 누적돼 화면이 1분 가까이 멈추기 때문입니다.

세션 지연 수립

로그인 시점에는 SSO를 태우지 않습니다. 왕복이 3번이라 웹메일을 쓰지 않는 대다수의 로그인까지 느려집니다. 대신 메일 API 첫 요청에서 세션 속 비밀번호로 SSO를 수립하고, 받은 쿠키를 mail_cookies에 넣어 세션 쿠키를 재발급합니다.

세션은 서버 상태가 없는 암호화 쿠키입니다. 응답에 다시 실어 보내지 않으면 매 요청 SSO를 처음부터 다시 탑니다. 핸들러가 어떻게 끝나든 한 곳에서 쿠키를 붙이려고 finalize_response를 씁니다.

오류의미복구
MAIL_SESSION_EXPIRED메일 쿠키 만료세션에서 지우므로 다음 요청이 알아서 복구
MAIL_UNAVAILABLE메일함 없는 계정, 또는 지연 수립 도입 전 발급된 세션(비밀번호 없음)재시도로 풀리지 않음 — 재로그인 필요

첨부파일 중계

메일 쿠키가 HttpOnly 세션 안에 있어 브라우저가 메일 서버에서 직접 받을 수 없습니다. /api/mail/attach/가 중계하되 파일을 메모리에 담지 않고 스트리밍합니다. 자세한 규칙은 웹메일 API 문서에 있습니다.


프론트엔드 구조

라우팅

App.tsx에서 React Router v7로 관리하며, URL이 곧 상태입니다. 페이지 컴포넌트는 대부분 lazy() + Suspense로 코드 스플리팅됩니다.

/                            → HomePage (대시보드)
/notices, /notices/:category → NoticesPage
/:sectionSlug/:itemSlug      → renderMenuPage(메뉴명)
*                            → / 로 리다이렉트

/mail, /mail/:folder         → MailApp (위 Routes 밖)

개별 페이지마다 라우트를 선언하지 않고, 슬러그 두 조각을 한글 메뉴명으로 되돌려 컴포넌트를 고릅니다.

담당역할
SECTION_ROUTES / ITEM_ROUTES (data/sitemap.ts)슬러그 ↔ 한글 메뉴명 매핑
findMenuByPath() / getMenuPath() / findMenuMeta()양방향 변환
renderMenuPage() (App.tsx)메뉴명 → 컴포넌트 분기
IMPLEMENTED_ITEMS미구현 항목은 OfficialPortalPage로 보내 공식 포털 링크를 안내

/mail은 사이드바까지 통째로 바뀌는 별도 셸이라 App 셸의 <Routes>를 거치지 않습니다. inMail 판정으로 MailApp이 화면 전부를 그리며, 그래서 sitemap.ts에도 없습니다.

페이지 전환 애니메이션은 AnimatedContent, 문서 제목은 buildDocumentTitle이 처리합니다.

메뉴 관리 (sitemap.ts)

상수설명
SITEMAP3개 그룹, 17개 섹션, 전체 메뉴 아이템 정의
IMPLEMENTED_ITEMS현재 구현된 메뉴 이름 Set — 미포함 항목은 사이드바에서 비활성화
ITEM_ROUTES메뉴 이름 → URL 경로 매핑
SECTION_ROUTES섹션 ID → URL 경로 매핑

새 메뉴 항목 하나를 추가할 때 건드리는 곳:

  1. data/sitemap.ts — SITEMAP 항목 + ITEM_ROUTES 슬러그 + IMPLEMENTED_ITEMS
  2. components/[Page].tsx — 페이지 컴포넌트
  3. App.tsx — lazy() import + renderMenuPage() 분기
  4. 백엔드 — sch_client/[module].py → views/[module].py → views/__init__.py → urls.py
  5. types.ts — 새 타입

새 뷰 모듈은 views/__init__.py에 export를 추가해야 urls.py에서 import됩니다.

상태 관리

별도의 전역 상태 라이브러리 없음.

데이터관리 방식
사용자 세션GET /api/auth/me/로 초기화, 메모리에 유지. TopNav가 주기적으로 refresh 호출하고 만료 시 자동 로그아웃. 로그인 직후 GET /api/main/info/가 401(UNAUTHORIZED/SCH_SESSION_EXPIRED)이면 로그아웃, 503이면 세션을 유지한 채 대시보드를 빈 상태로 둠
즐겨찾기로그인 후 서버에서 로드 → 변경 시 디바운스 저장 (최대 12개)
시간표 테마API를 통해 DB에 저장
웹메일 mailbox_id서버 세션에 두지 않고 프론트엔드가 들고 다님
테마 (light/dark/system)localStorage + prefers-color-scheme → documentElement.dataset.theme
페이지별 데이터각 컴포넌트의 useState + API 호출

보안 설계

항목구현 방식
세션 저장HttpOnly 쿠키 — JS에서 document.cookie로 읽기 불가
세션 무결성AES-256-GCM 인증 태그 — 변조 시 복호화 실패
세션 바인딩IP + User-Agent AAD — 쿠키 탈취 후 재사용 방지
로그인 봇 차단Cloudflare Turnstile — 자격증명을 학교 서버로 릴레이하기 전에 검증
클라이언트 IP 판별X-Forwarded-For를 신뢰 프록시 홉 수(TRUSTED_PROXY_HOPS)만큼 오른쪽에서 세어 읽음
객체 수준 권한증명서·고지서 리포트 파라미터를 클라이언트에서 받지 않고 세션 학번으로 서버가 재조회 (BOLA 방어)
HTTPS 강제SECURE_SSL_REDIRECT + HSTS 1년 (includeSubDomains). 예외 경로 없음 (DEBUG=False일 때만 적용)
CSRF 방어HttpOnly 쿠키 기반이므로 CSRF 미들웨어 비활성화 (SameSite=Lax로 CSRF 방어)
CORSCORS_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 등 불필요한 테이블 생성을 막기 위한 정리

X-Forwarded-For를 오른쪽에서 세는 이유

nginx는 클라이언트가 보낸 X-Forwarded-For 값 뒤에 실제 접속 IP를 덧붙입니다($proxy_add_x_forwarded_for). 그래서 리스트 맨 왼쪽 값은 클라이언트가 마음대로 채워 넣을 수 있는 위조 가능한 값입니다.

이 IP가 세션 토큰의 AAD(IP 바인딩)에 쓰이므로, 왼쪽 값을 그대로 쓰면 헤더 위조만으로 IP 바인딩을 우회할 수 있습니다. 신뢰할 수 있는 프록시 홉 수만큼 오른쪽에서 세어 들어가야 실제 접속 IP를 얻습니다.


CORS 허용 오리진

CORS_ALLOWED_ORIGINS = [
    "http://localhost:5173",    # Vite 개발 서버
    "http://127.0.0.1:5173",
    "https://sch.wonchan.net",  # 프로덕션 — nginx가 http는 이미 301로 넘긴다
]
CORS_ALLOW_CREDENTIALS = True  # 쿠키 허용

새 배포 환경을 추가할 경우 portal/settings.py의 이 목록에 오리진을 추가하세요.


학기 코드 규칙

코드설명
111학기 (3월~8월)
212학기 (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월)

On this page