backend 코드를 소스 오브 트루스로 정리한 아키텍처 문서입니다.
대상 독자는 신규 백엔드 개발자/운영자이며, 실제 런타임 정책(app.py, config.py, utils/*, fastapi_app/*)을 중심으로 설명합니다.
이 서비스는 Flask 메인 API + FastAPI 실시간/이벤트 API 구조이며, 두 런타임이 같은 인증 쿠키와 MariaDB 스키마를 공유합니다. Flask는 일반 커뮤니티/인증 기능과 학교/학생회/예산 공개 공지 도메인을, FastAPI는 스포츠리그, 수학여행 게시판, 급식 조회/평점/알림 기능을 담당합니다.
flowchart LR
Client[웹 프론트엔드<br/>React/Vite] -->|HTTPS + Cookie + CSRF| Flask[Flask App]
Client -->|HTTPS + Cookie + CSRF| FastAPI[FastAPI App]
Flask --> Auth[JWT + Token State<br/>utils/security.py<br/>utils/security_tokens.py]
Flask --> RL[Rate Limiter<br/>utils/rate_limit.py]
Flask --> Cache[Response Cache<br/>utils/cache.py]
Flask --> Upload[Upload Guard<br/>utils/files.py]
Flask --> ReqMeta[Request Metadata Hook<br/>utils/request_metadata.py]
Flask --> Routes[Blueprint Routes<br/>routes/*.py]
ReqMeta --> ORM
Routes --> ORM[SQLAlchemy Models<br/>models/*.py]
FastAPI --> FastRoutes[Async Routers<br/>fastapi_app/routes/*.py]
FastRoutes --> FastServices[Async Services<br/>fastapi_app/services/*.py]
FastServices --> FastORM[SQLAlchemy Models<br/>fastapi_app/models.py]
ORM --> DB[(MariaDB)]
FastORM --> DB
Cache --> Redis[(Redis)]
RL --> Redis
FastAPI --> Redis
핵심 특징:
- 인증: JWT를 **쿠키(
JWT_TOKEN_LOCATION=['cookies'])**로 운용 - 세션 무효화:
auth_tokens테이블 기반 서버 주도 토큰 폐기 - 운영 방어: 보안 설정 fail-fast + 레이트리밋 + 보안 헤더
- 쓰기 감사:
before_flush훅으로 신규 row의ip_address/user_agent자동 주입(best-effort) - 데이터 일관성: 소프트 삭제(
deleted_at) + 카운터 캐시 + 유니크 제약 - 사이드카 계약: FastAPI가 스포츠리그, 수학여행, 급식 기능을 별도 라우터로 제공
create_app()은 “설정 검증 → 확장 초기화 → 라우트 등록” 순서를 강제합니다.
sequenceDiagram
participant Runtime as Runtime
participant App as create_app
participant Config as config.py
participant Ext as Extensions
participant BP as Blueprints
Runtime->>App: create_app()
App->>Config: 환경 결정(FLASK_ENV)
App->>Config: app.config.from_object(...)
App->>App: validate_security_config()
Note over App: JWT_SECRET 길이/값<br/>CORS_ORIGINS(운영)<br/>MAX_CONTENT_LENGTH 검증<br/>BUDGET_BOARD 연도 범위 검증
App->>Ext: init_cache()
App->>Ext: db.init_app()
App->>Ext: Session before_flush hook 등록(request metadata)
App->>Ext: init_limiter()
App->>Ext: CORS/JWT 핸들러 등록
App->>BP: 블루프린트별 write limit 적용
App->>BP: auth/notices/... 등록
App->>App: /api/health 등록
App-->>Runtime: Flask app 반환
커뮤니티 보드 중 인성 가치 PICK!, 동아리 모집, 선택과목 변경, BOSPI, 스터디 윗 범서는 *_BOARD_ENABLED=false일 때 Flask 블루프린트 등록과 공통 쓰기 제한 적용을 함께 건너뜁니다. 수학여행은 FastAPI에서 같은 의미의 FIELD_TRIP_BOARD_ENABLED로 라우터 등록을 제어합니다.
| 계층 | 주요 파일 | 책임 |
|---|---|---|
| App Bootstrap | app.py, config.py |
환경/보안 정책 로딩, 확장 초기화, 블루프린트 등록 |
| Route Layer | routes/*.py |
HTTP 입력 검증, 권한 체크, 응답 직렬화, 도메인 흐름 오케스트레이션 |
| Domain/Data Layer | models/*.py |
엔티티, enum, 관계, 직렬화(to_dict) |
| Security Utils | utils/security.py, utils/security_tokens.py |
비밀번호 해시, 권한 데코레이터, principal 해석, 토큰 회전/폐기 |
| Audit Utils | utils/request_metadata.py |
IP/User-Agent 정규화 및 신규 row 감사 메타데이터 자동 주입 |
| Infra Utils | utils/cache.py, utils/rate_limit.py, utils/files.py, utils/pagination.py |
캐시/레이트리밋/업로드 검증/페이지네이션 규약 |
sequenceDiagram
participant C as Client
participant F as Flask Route
participant S as Security Utils
participant DB as SQLAlchemy/DB
participant CA as Cache
C->>F: HTTP 요청
F->>S: (선택) verify_jwt_in_request(optional=True)
alt 보호 엔드포인트
F->>S: jwt_required / require_role
end
alt GET + cache_json_response
F->>CA: 캐시 조회
CA-->>F: HIT/MISS
end
F->>DB: 조회/갱신
alt 쓰기 성공
F->>DB: commit
F->>CA: invalidate_cache_namespaces(...)
end
F-->>C: JSON 응답
app.py는 부팅 과정에서 SQLAlchemy Session.before_flush 이벤트를 1회 등록합니다.
이 훅은 요청 컨텍스트 안에서 session.new 엔티티를 순회하며, 대상 모델이 ip_address, user_agent 컬럼을 가지고 있고 값이 비어 있을 때만 메타데이터를 채웁니다.
- IP 소스:
utils.security.get_client_ip()(신뢰 프록시 정책 반영) - User-Agent 소스:
request.headers['User-Agent'] - 정규화:
ip_address64자,user_agent255자 제한 - 예외 처리: 추출 실패 시
None으로 남기고 요청 플로우는 계속 진행
토큰 발급 경로(utils/security_tokens.py)도 동일 헬퍼를 사용해 저장 정책을 일치시킵니다.
- Access/Refresh JWT를 HttpOnly 쿠키로 발급
- 비안전 메서드(
POST/PUT/PATCH/DELETE)에X-CSRF-TOKEN요구 /api/auth/refresh는 refresh 토큰 회전(rotate) 수행
UserRole:
admin: 전체 관리 권한student_council: 공지/공식 답변/일부 관리 기능teacher,student: 일반 사용자
require_role(...) 동작:
- JWT role claim 우선 사용(핫패스 DB 조회 절감)
- legacy 토큰(역할 claim 없음)은 사용자 조회 후 판정
admin은 role gate 우회 허용
auth_tokens 테이블이 세션 상태의 단일 진실 소스입니다.
- 존재하지 않는 JTI: 차단
revoked_at존재: 차단- refresh 회전 시 부모 refresh 토큰 자동 폐기/교체 링크(
replaced_by_jti) 저장
stateDiagram-v2
[*] --> Issued
Issued --> Active: DB 저장 + 만료 전
Active --> Rotated: refresh 재발급
Active --> Revoked: logout/관리 폐기
Active --> Expired: 만료 시각 도달
Rotated --> Revoked: 이전 토큰 폐기
Revoked --> [*]
Expired --> [*]
init_cache()는 부팅 시 Redis 실연결(PING) 성공 여부를 검사해 캐시 모드를 결정합니다.
redis: 실제 캐시 read/write 활성fallback-null:CACHE_ENABLED=true지만 Redis 연결 실패 →NullCache로 강등disabled:CACHE_ENABLED=false로 명시 비활성
핵심은 API 가용성을 우선한다는 점입니다. Redis 장애가 발생해도 요청은 계속 처리되고, 캐시만 비활성화됩니다.
@cache_json_response(namespace, ttl?)는 아래 조건을 모두 만족할 때만 동작합니다.
- 요청 메서드가
GET - 런타임 모드가
redis - 호출자가
admin이 아님 - 응답이
2xx + JSON + non-streaming(direct_passthrough=False)
즉, admin GET/비GET/에러 응답/파일 스트리밍 응답은 캐시하지 않습니다.
디버깅이 필요하면 CACHE_DEBUG_HEADERS=true로 X-Cache: HIT|MISS|BYPASS를 확인할 수 있습니다.
캐시 키 원문은 아래 요소를 결합한 뒤 SHA-256 해시로 축약합니다.
namespacerequest.methodrequest.pathnormalized query(키/값 정렬로 동치 쿼리 통합)actor_scope
actor_scope 규칙:
- 비로그인:
anon - 로그인:
user:{id}:role:{role_or_unknown}
role claim이 없는 legacy 토큰은 DB에서 role을 1회 복구한 뒤 scope를 결정합니다.
이 구조 때문에 같은 URL이라도 사용자별로 캐시가 분리됩니다.
승인 워크플로우가 있는 목록 엔드포인트는 비관리자에서 공통적으로 다음 가시성 조건을 갖습니다.
- 공개:
APPROVED - 예외 허용:
author_id == current_user.id(본인 글)
대표 라우트:
free.list_postsclub_recruit.list_recruitssubject_changes.list_subject_changespetitions.list_petitionssurveys.list_surveysgomsol_market.list_posts
free.list_posts를 예로 들면:
- 비관리자 요청 시
status관리자 필터를 무력화 - 최종 where 절에서
approved OR author_id=current_user적용 - 같은 요청 URL이어도
actor_scope가 사용자마다 달라 별도 캐시 키 생성
결과적으로:
- 사용자 A의 캐시에는 "A의 pending 글"이 포함될 수 있음
- 사용자 B/익명은 다른 scope 키를 사용하므로 A의 pending 결과를 재사용하지 않음
즉, "일반 사용자가 자신의 미승인 글을 본다"는 요구사항은 라우트 SQL 가시성 규칙 + actor_scope 기반 캐시 분리의 조합으로 안전하게 구현됩니다.
sequenceDiagram
participant UA as User A
participant UB as User B
participant API as list_posts()
participant CA as Redis Cache
participant DB as MariaDB
UA->>API: GET /api/community/free?sort=recent
API->>API: visibility = approved OR author_id=A
API->>CA: key(..., actor_scope=user:A:role:student)
CA-->>API: MISS
API->>DB: 조회 (A pending 포함 가능)
API->>CA: set key(A_scope)
API-->>UA: A 전용 결과
UB->>API: 동일 URL 요청
API->>API: visibility = approved OR author_id=B
API->>CA: key(..., actor_scope=user:B:role:student)
CA-->>API: A와 다른 키 (HIT/MISS 별개)
API-->>UB: B 전용 결과
일부 detail GET도 캐시를 사용합니다(예: petitions.get_petition, surveys.get_survey, votes.get_vote).
이 경우에도 권한 체크/개인화 필드 계산이 view 함수 내부에 존재하며, actor_scope가 다르면 캐시도 분리됩니다.
반대로 free.get_post, club_recruit.get_recruit, subject_changes.get_subject_change, gomsol_market.get_post는 캐시 데코레이터를 사용하지 않습니다.
쓰기 성공 경로는 invalidate_cache_namespaces(...)를 호출합니다.
- 네임스페이스 인덱스 키:
nsidx:{namespace} - 인덱스에는 실제 응답 키 목록을 저장
- 인덱스 최대 길이: 5000 (초과 시 오래된 키 제거)
- 인덱스 TTL:
max(CACHE_DEFAULT_TIMEOUT * 20, 3600)
무효화/캐시 I/O 예외는 요청 실패로 전파하지 않습니다(운영 안정성 우선).
키 함수 우선순위:
- 로그인 사용자:
user:<id> - 익명:
ip:<remote_addr>
적용 정책:
- 공통 쓰기 제한: 블루프린트 단위
POST/PUT/PATCH/DELETE - 인증 엔드포인트 별도 제한:
- register
- login
- refresh
- 초과 응답: 429 + JSON(
error_code=rate_limit_exceeded) +Retry-After(가능 시)
업로드 정책은 utils/files.py에서 중앙화됩니다.
- 스코프 고정:
UPLOAD_SCOPE_DIRS,UPLOAD_ROUTE_PREFIXES - 검증 3단계:
- 확장자 allowlist
- MIME allowlist
- 파일 시그니처(sniff) 검증
- 임시 미리보기:
preview_token서명 토큰(itsdangerous) 필요- TTL 기반 만료
- 저장 URL 정규화:
- 서버 canonical URL로 변환
- HTML 본문 내 업로드 URL canonicalize
sequenceDiagram
participant U as User
participant API as Upload API
participant FU as files.py
participant FS as Upload Dir
participant DB as Domain Row
U->>API: POST /uploads (file)
API->>FU: validate_upload()
FU-->>API: ok + mime/size/kind
API->>FU: save_upload_for_scope()
FU->>FS: 파일 저장(uuid 파일명)
API-->>U: preview url + canonicalUrl
U->>API: GET /uploads/{filename}?preview_token=...
alt DB 연결된 파일
API->>DB: 소유 리소스 확인
API-->>U: 파일 반환
else 임시 업로드
API->>FU: is_valid_upload_preview_token()
API-->>U: 토큰 유효 시 파일 반환
end
- 소프트 삭제 표준:
deleted_at - 카운터 캐시:
views,comments_count,votes_count,total_votes등 - 중복 방지 유니크 제약:
- 반응/북마크/투표/설문 응답/BOSPI 대기 예측/BOSPI 날짜별 예측
- 감사 메타데이터 컬럼: 주요 쓰기 엔티티에
ip_address(64),user_agent(255)nullable 적용 - 직렬화 계약:
- 프론트 계약 안정화를 위해 camelCase 중심 응답 유지
감사 메타데이터는 users, notices, free_posts, value_pick_posts, club_recruits, subject_changes, petitions, surveys, votes, bospi_records, bospi_pending_predictions, bospi_predictions, study_with_beomseo_score_updates, lost_found_*, gomsol_market_* 및 관련 댓글/반응/이미지/응답 엔티티에 적용됩니다.
예산 공개 게시판은 별도 테이블을 만들지 않고 기존 Notice aggregate를 확장하는 방식으로 구현되어 있습니다.
NoticeCategory.BUDGETenum이 추가되었습니다.notices테이블에budget_year,budget_monthnullable 컬럼이 추가되었습니다.- 월별 리스트 성능을 위해
(category, budget_year, budget_month, deleted_at, pinned, created_at)복합 인덱스ix_notices_budget_list를 사용합니다. - 직렬화 시 budget 글은
budgetYear,budgetMonth를 응답에 포함하고, 공지 전용 affordance인pinned,important,examRelated,tags는 숨깁니다. - 라우트 레이어는
BUDGET_BOARD_START_YEAR~BUDGET_BOARD_END_YEAR범위 밖의 budget 글을404로 처리해 비활성 회계연도 링크와 첨부 경로가 그대로 노출되지 않게 합니다. - 기존 운영 DB는
scripts/migrate_notice_budget_board.py를 사용해 enum, 컬럼, 인덱스를 idempotent하게 보강합니다.
erDiagram
USERS ||--o{ AUTH_TOKENS : owns
USERS ||--o{ NOTICES : writes
NOTICES ||--o{ ATTACHMENTS : has
NOTICES ||--o{ COMMENTS : has
NOTICES ||--o{ NOTICE_REACTIONS : has
USERS ||--o{ FREE_POSTS : writes
FREE_POSTS ||--o{ FREE_COMMENTS : has
FREE_POSTS ||--o{ FREE_REACTIONS : has
FREE_POSTS ||--o{ FREE_BOOKMARKS : has
USERS ||--o{ VALUE_PICK_POSTS : writes
VALUE_PICK_POSTS ||--o{ VALUE_PICK_COMMENTS : has
VALUE_PICK_POSTS ||--o{ VALUE_PICK_REACTIONS : has
USERS ||--o{ CLUB_RECRUITS : writes
USERS ||--o{ SUBJECT_CHANGES : writes
SUBJECT_CHANGES ||--o{ SUBJECT_CHANGE_COMMENTS : has
SUBJECT_CHANGES ||--o{ SUBJECT_CHANGE_LIKES : has
USERS ||--o{ PETITIONS : writes
PETITIONS ||--o{ PETITION_VOTES : has
PETITIONS ||--|| PETITION_ANSWERS : has
USERS ||--o{ SURVEYS : owns
SURVEYS ||--o{ SURVEY_RESPONSES : has
USERS ||--|| SURVEY_CREDITS : has
USERS ||--o{ VOTES : creates
VOTES ||--o{ VOTE_OPTIONS : has
VOTES ||--o{ VOTE_RESPONSES : has
USERS ||--o{ BOSPI_RECORDS : enters
USERS ||--o{ BOSPI_PENDING_PREDICTIONS : predicts
USERS ||--o{ BOSPI_PREDICTIONS : owns
USERS ||--|| BOSPI_USER_SCORES : scores
USERS ||--o{ STUDY_WITH_BEOMSEO_SCORE_UPDATES : schedules
SPORTS_LEAGUE_CATEGORIES ||--o{ SPORTS_LEAGUE_TEAMS : has
SPORTS_LEAGUE_CATEGORIES ||--o{ SPORTS_LEAGUE_MATCHES : has
SPORTS_LEAGUE_CATEGORIES ||--o{ SPORTS_LEAGUE_PLAYERS : has
SPORTS_LEAGUE_CATEGORIES ||--o{ SPORTS_LEAGUE_EVENTS : has
SPORTS_LEAGUE_CATEGORIES ||--o{ SPORTS_LEAGUE_STANDING_OVERRIDES : has
SPORTS_LEAGUE_TEAMS ||--o{ SPORTS_LEAGUE_PLAYERS : owns
SPORTS_LEAGUE_MATCHES ||--o{ SPORTS_LEAGUE_EVENTS : receives
USERS ||--o{ LOST_FOUND_POSTS : writes
LOST_FOUND_POSTS ||--o{ LOST_FOUND_IMAGES : has
LOST_FOUND_POSTS ||--o{ LOST_FOUND_COMMENTS : has
USERS ||--o{ GOMSOL_MARKET_POSTS : writes
GOMSOL_MARKET_POSTS ||--o{ GOMSOL_MARKET_IMAGES : has
COUNTDOWN_EVENTS {
int id PK
string event_name
datetime event_at
}
참고:
COUNTDOWN_EVENTS는 사용자 소유가 아닌 독립 엔티티입니다. 공지 목록 응답에 다음 카운트다운 이벤트가 부가 데이터로 포함됩니다.
BOSPI는 고정 운영일 설정을 두지 않고 공식 기록이 시간순으로 누적되는 구조입니다.
BospiRecord는 한 날짜의 전체 학생 수, 교복 착용 학생 수, 서버 계산 비율(소수 4자리), 입력자 역할을 저장합니다.BospiPendingPrediction은 사용자의 다음 공식 기록 등락 예측을 날짜 없이 1건만 유지합니다.- 새 공식 기록이 들어오면 대기 예측을 해당 날짜의
BospiPrediction으로 확정하고, 이전 공식 기록과 비교해increase|decrease|tie결과를 평가합니다. - 점수는 매 요청마다 합산하지 않고
BospiUserScore집계 테이블에 반영합니다. 재평가 대상 날짜가 바뀌면 영향받은 사용자만 다시 계산합니다. - 랭킹은
total_score desc,correct_count desc,incorrect_count asc, 닉네임, 사용자 ID 순으로 안정화하며, 같은 총점은 같은 표시 순위를 공유합니다.
스터디 윗 범서는 Flask 커뮤니티 API에서 반별 최종 총점 snapshot을 예약 공개하는 단순 순위판입니다.
StudyWithBeomseoScoreUpdate는 반, 최종 총점, 공개 예정 시각, 입력자 역할을 append-only로 저장합니다.- 공개 순위는 현재 KST 시간 이하인 최신 기록만 반영하며, 미래 예약은
admin | student_council에게만 노출합니다. - 순위판 응답은 항상 30개 반을 반환하고, 아직 공개 기록이 없는 반은 0점으로 표시합니다.
services/study_with_beomseo.py가 classId/점수/시각 검증, 최신 snapshot 선택, 동점 순위 계산을 담당합니다.effective_at은 DB에 KST 기준 naive datetime으로 저장하고, 응답 직렬화 시+09:00offset을 붙여 브라우저가 명확한 시각으로 파싱하게 합니다.- 최신 공개 점수 조회는
class_id,effective_at desc,id desc정렬에서 첫 행을 고르는 방식이며, 동일 공개 시각에 여러 입력이 있어도 더 나중에 저장된 행이 우선합니다. - 동점 순위는 competition ranking 방식으로 계산합니다. 예를 들어 두 반이 공동 1위면 다음 반은 3위입니다.
- 미래 예약 목록은
joinedload(created_by)로 입력자 표시 정보를 함께 가져오며, 일반 사용자 응답에는 빈 배열로 유지합니다. - 이 엔드포인트는 공개 시각 경계에서 결과가 바뀌므로 response cache를 사용하지 않습니다.
- 신규 score update 행은 공통
before_flush요청 메타데이터 훅의 대상이므로ip_address,user_agent감사 필드가 자동 보강될 수 있습니다.
스포츠리그는 일반 게시판과 달리 카테고리 → 팀/경기 → 이벤트 → snapshot 계층으로 동작합니다.
- 기준 read 모델은
build_snapshot(category_id)가 만드는 단일 snapshot입니다. - 카테고리 전환용 목록은 seed registry를 기준으로 만들고, bootstrap된 DB row가 있으면 DB metadata가 seed summary를 덮어씁니다.
SportsLeagueMatch의 스코어/상태는 독립 입력값이 아니라 최신 active event를 다시 계산한 projection입니다.- 예선 순위는 완료 경기만으로 자동 계산하고,
SportsLeagueStandingOverride가 있으면 운영진 확정 순위가 자동 계산을 완전히 덮어씁니다. - 선수 라인업/개인 순위는
SportsLeaguePlayer+services/sports_league_players.py가 별도 CRUD로 관리하며, snapshot/SSE 응답에는 포함하지 않습니다.
sequenceDiagram
participant Operator as 학생회/Admin
participant Route as fastapi_app/routes/sports_league.py
participant Service as fastapi_app/services/sports_league.py
participant DB as MariaDB
participant RT as fastapi_app/services/sports_league_realtime.py
participant FE as Frontend SSE
Operator->>Route: POST/PATCH/DELETE event
Route->>Service: payload 정규화 + 검증
Service->>DB: event 저장 / match projection 재계산 / category.updated_at touch
Service->>RT: publish_category_update(category_id)
RT-->>FE: pub/sub 신호 or in-process fallback
FE->>Route: GET /stream 재수신 대기
Route->>Service: build_snapshot(category_id)
Service-->>FE: event:snapshot + 전체 snapshot
현재 구현 기준으로:
- snapshot 조회(
GET /categories/:id)만60 per minutelimiter가 직접 연결되어 있습니다. GET /categories는 기본 2학년 카테고리와 이전 3학년 카테고리를 함께 노출합니다.- SSE route는 pub/sub 신호가 없어도
updatedAt변화를 감지하기 위해 유휴 시 snapshot을 다시 읽습니다. RATELIMIT_SPORTS_LEAGUE_STREAM_CONNECT,SPORTS_LEAGUE_MAX_STREAMS_PER_CLIENT는 config/helper에 존재하지만 route-level enforcement는 아직 연결되지 않았습니다.- 선수 라인업 탭은
GET/POST/DELETE/PATCH /players엔드포인트를 사용하고, 실시간 중계와는 별도 상태 흐름으로 유지됩니다.
수학여행 기능은 FastAPI 안에서 잠금 해제 쿠키 → 익명/로그인 작성 → 리치 본문 + 업로드 → 점수판 흐름으로 동작합니다.
- 반 게시판은
field_trip_unlock_token쿠키에 포함된 반 목록으로 접근을 제어합니다. - 쓰기 요청은
X-Field-Trip-CSRF헤더를 추가로 요구해, 비로그인 작성도 반별 잠금 해제 상태와 묶어 보호합니다. - 게시글은
author_role을 별도 컬럼으로 저장해 anonymous 작성과 로그인 작성을 같은 응답 스키마로 직렬화합니다. - 프론트에는 anonymous 작성자를
authorUserId=0,authorRole='anonymous'로 노출합니다. - 본문은 plain text가 아니라 rich HTML이며, 저장 전에 field-trip 업로드 canonical URL로 정규화됩니다.
- 파일 접근은 첨부 row뿐 아니라 게시글 본문에 포함된 canonical URL까지 검사해 연결 여부를 판정합니다.
- 게시판 비밀번호 변경/게시판 설명 수정은
admin전용이고, 점수 조정은student_council | admin이±5step으로만 수행합니다.
sequenceDiagram
participant Visitor as 잠금 해제된 사용자
participant Route as fastapi_app/routes/field_trip.py
participant Service as fastapi_app/services/field_trip.py
participant DB as MariaDB
Visitor->>Route: POST /classes/:id/posts
Route->>Route: unlock cookie + X-Field-Trip-CSRF 확인
Route->>Service: current_user(optional), payload 전달
Service->>Service: author_role / nickname 결정
Service->>Service: rich body canonical URL 정규화
Service->>DB: post + attachment 저장
Service-->>Visitor: authorRole + authorUserId 응답
급식 기능은 FastAPI 안에서 동기화 스크립트가 적재한 급식 데이터 조회 → 브라우저 단위 평점 → 설치 기기 단위 알림 구독 흐름으로 동작합니다.
- 요청 경로는 NEIS를 직접 호출하지 않고
school_meals테이블만 읽습니다. scripts/sync_school_meals.py만 NEISmealServiceDietInfo를 호출해 MySQL 데이터를 갱신합니다.- 읽기/평점 엔드포인트는
MEAL_RATING_COOKIE_NAME쿠키를 발급해 비로그인 브라우저도 날짜/카테고리별 1개 평점을 유지합니다. - 평점 viewer key는
JWT_SECRET_KEY와 사용자/익명 토큰을 해시한 값으로 저장되어, 브라우저 원문 식별자를 DB에 직접 남기지 않습니다. - 알림 구독은 사용자 계정이 아니라
installationId기준의 PWA 기기 단위 레코드입니다. - 알림 시각은
notificationTime -> minute_of_day로 정규화되고 5분 단위만 허용됩니다. - 같은 FCM token이 다른 설치 레코드에 이미 묶여 있으면 최신 installationId가 소유권을 가져가고 이전 레코드는 비활성화됩니다.
- 발송 payload는 menu 목록을
menuItemsJson으로도 보내므로 foreground/browser notification에서도 여러 줄 메뉴를 복원할 수 있습니다. - sender가
registration token not registered계열 오류를 받으면 해당 구독의 token을 즉시 제거하고is_enabled=0으로 내려 후속 재시도를 막습니다. taste평점은 당일(KST)만,anticipation평점은 당일 또는 미래 급식만 허용합니다.
sequenceDiagram
participant Browser as 브라우저/PWA
participant Route as fastapi_app/routes/meals.py
participant Service as fastapi_app/services/meals.py
participant DB as MariaDB
participant Sync as sync_school_meals.py
participant NEIS as NEIS API
Sync->>NEIS: mealServiceDietInfo 조회
NEIS-->>Sync: 급식 row
Sync->>DB: school_meals upsert
Browser->>Route: GET /api/school-info/meals/today
Route->>Route: 익명 평점 쿠키 보장
Route->>Service: 오늘 급식 + ratings payload 조회
Service->>DB: school_meals + school_meal_ratings 조회
Service-->>Browser: item + ratings
Browser->>Route: PUT /api/school-info/meals/notifications/subscription
Route->>Service: installationId 기준 upsert
Service->>DB: subscription 저장
다음 보드는 공통적으로 승인 워크플로우를 가집니다.
free_postsvalue_pick_postsclub_recruitssubject_changes(approval_status)petitions(pending/approved/rejected)surveysgomsol_market_posts(approval_status)
공통 흐름:
- 작성 시
pending계열 상태 - 관리자 승인 시 승인자/승인시각 기록
- 비관리자 목록 조회는 “승인 + 본인 작성글”로 제한
- 상세 조회에도 동일한 가시성 규칙 적용
value_pick은 자유게시판과 비슷한 moderation 보드지만, 도메인 모델이 competency + pledge + rich body 조합에 맞춰 따로 분리되어 있습니다.
- 생성 시 항상
pending - 비관리자 목록/상세는
approved OR author_id=current_user.id - 반응은
like | dislike2종만 허용 - 본문은 rich HTML을 저장하지만, 목록 preview는 모델
summarize_body()가 plain text로 축약합니다. - 첨부는 별도 row가 아니라 업로드 canonical URL이 본문에 연결되었는지로 공개 여부를 판정합니다.
survey_credits는 설문과 투표가 공유하는 사용자 크레딧 원장입니다.
available = base + earned - used
설문 응답 시:
- 설문 소유자 크레딧 1 소비
- 응답자(타인 설문) 크레딧 +5 보상
투표 응답 시:
- 응답자에게
VOTE_REWARD_CREDITS만큼 적립(기본 1)
validate_security_config()가 부팅 시 필수 보안값 검증- 운영에서 HSTS는 HTTPS 요청일 때만 주입
TRUST_PROXY_HEADERS=true+TRUSTED_PROXY_CIDRS비어 있음 조합은 위험(경고 로그)- 캐시/레이트리밋 장애는 요청 실패로 확산되지 않도록 설계
| 결정 | 선택 | 근거 |
|---|---|---|
| Web Framework | Flask 3.1 | 학교 프로젝트 규모에 적합한 마이크로프레임워크; 블루프린트 기반 도메인 분리 용이 |
| ORM | SQLAlchemy + Flask-SQLAlchemy | 선언적 모델, 관계 매핑, 마이그레이션 지원 |
| DB Driver | PyMySQL | 순수 Python MariaDB/MySQL 호환 드라이버; C 확장 빌드 불필요로 배포 단순화 |
| Auth | Flask-JWT-Extended (Cookie) | 쿠키 기반 JWT + CSRF 이중 제출로 OWASP 권장 패턴 충족 |
| Cache | Flask-Caching + Redis | 응답 캐시와 레이트리밋 저장소를 Redis 하나로 통합; 장애 시 NullCache 강등 |
| Password | bcrypt | 업계 표준 적응형 해시; timing-safe 비교 내장 |
| Blueprint | 주 모델 | 캐시 네임스페이스 | 쓰기 제한 |
|---|---|---|---|
auth |
User, AuthToken |
(없음) | login/register/refresh 별도 |
notices |
Notice, Attachment, Comment, NoticeReaction |
notices |
공통 write limit |
free |
FreePost, FreeComment, FreeReaction, FreeBookmark |
free |
공통 write limit |
value_pick |
ValuePickPost, ValuePickComment, ValuePickReaction |
value_pick |
공통 write limit |
club_recruit |
ClubRecruit |
club_recruit |
공통 write limit |
subject_changes |
SubjectChange, SubjectChangeComment |
subject_changes |
공통 write limit |
petitions |
Petition, PetitionVote, PetitionAnswer |
petitions |
공통 write limit |
surveys |
Survey, SurveyResponse, SurveyCredit |
surveys |
공통 write limit |
votes |
Vote, VoteOption, VoteResponse |
votes |
공통 write limit |
bospi |
BospiRecord, BospiPendingPrediction, BospiPrediction, BospiUserScore |
bospi |
공통 write limit |
study_with_beomseo |
StudyWithBeomseoScoreUpdate |
(없음) | 공통 write limit |
sports_league (FastAPI) |
SportsLeagueCategory, SportsLeagueMatch, SportsLeaguePlayer, SportsLeagueEvent, SportsLeagueStandingOverride |
sports_league |
route별 limiter/SSE 정책 |
field_trip (FastAPI) |
FieldTripClass, FieldTripPost, FieldTripPostAttachment |
(별도 response cache 없음) | FastAPI route별 권한/CSRF |
school_meals (FastAPI) |
SchoolMeal, SchoolMealRating, SchoolMealNotificationSubscription |
(별도 response cache 없음) | 익명 평점 쿠키 + installationId 구독 |
lost_found |
LostFoundPost, LostFoundImage, LostFoundComment |
lost_found |
공통 write limit |
gomsol_market |
GomsolMarketPost, GomsolMarketImage |
gomsol_market |
공통 write limit |
app.pyconfig.pyutils/security.pyutils/security_tokens.pyutils/request_metadata.pyutils/cache.pyutils/files.pyroutes/surveys.pyroutes/petitions.pyroutes/notices.pyroutes/value_pick.pyroutes/bospi.pyroutes/study_with_beomseo.pyservices/study_with_beomseo.pyfastapi_app/routes/sports_league.pyfastapi_app/services/sports_league.pyfastapi_app/services/sports_league_players.pyfastapi_app/services/sports_league_realtime.pyfastapi_app/routes/field_trip.pyfastapi_app/services/field_trip.pyfastapi_app/routes/meals.pyfastapi_app/services/meals.py
- 신규 쓰기 엔드포인트에 캐시 무효화 네임스페이스를 연결했는가
- 권한 체크(
jwt_required,require_role, 소유자 검증)가 누락되지 않았는가 - 응답 키(camelCase) 변경 시 프론트 문서/코드 동기화가 되었는가
- 업로드 URL을 canonical URL로 정규화하고 있는가
- 레이트리밋 정책(특히 auth/쓰기)이 의도대로 적용되는가
- 신규 쓰기 엔티티가
ip_address,user_agent감사 정책과 충돌하지 않는가