Skip to content

Latest commit

 

History

History
668 lines (517 loc) · 31.7 KB

File metadata and controls

668 lines (517 loc) · 31.7 KB

백엔드 아키텍처 문서

backend 코드를 소스 오브 트루스로 정리한 아키텍처 문서입니다.
대상 독자는 신규 백엔드 개발자/운영자이며, 실제 런타임 정책(app.py, config.py, utils/*, fastapi_app/*)을 중심으로 설명합니다.

1. 시스템 개요

이 서비스는 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
Loading

핵심 특징:

  1. 인증: JWT를 **쿠키(JWT_TOKEN_LOCATION=['cookies'])**로 운용
  2. 세션 무효화: auth_tokens 테이블 기반 서버 주도 토큰 폐기
  3. 운영 방어: 보안 설정 fail-fast + 레이트리밋 + 보안 헤더
  4. 쓰기 감사: before_flush 훅으로 신규 row의 ip_address/user_agent 자동 주입(best-effort)
  5. 데이터 일관성: 소프트 삭제(deleted_at) + 카운터 캐시 + 유니크 제약
  6. 사이드카 계약: FastAPI가 스포츠리그, 수학여행, 급식 기능을 별도 라우터로 제공

2. 앱 부팅 순서 (app.py:create_app)

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 반환
Loading

커뮤니티 보드 중 인성 가치 PICK!, 동아리 모집, 선택과목 변경, BOSPI, 스터디 윗 범서는 *_BOARD_ENABLED=false일 때 Flask 블루프린트 등록과 공통 쓰기 제한 적용을 함께 건너뜁니다. 수학여행은 FastAPI에서 같은 의미의 FIELD_TRIP_BOARD_ENABLED로 라우터 등록을 제어합니다.

3. 계층 구조와 책임

계층 주요 파일 책임
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 캐시/레이트리밋/업로드 검증/페이지네이션 규약

4. 요청 처리 시퀀스

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 응답
Loading

4.1 쓰기 요청 메타데이터 주입 파이프라인

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_address 64자, user_agent 255자 제한
  • 예외 처리: 추출 실패 시 None으로 남기고 요청 플로우는 계속 진행

토큰 발급 경로(utils/security_tokens.py)도 동일 헬퍼를 사용해 저장 정책을 일치시킵니다.

5. 인증/인가 아키텍처

5.1 인증 매체

  1. Access/Refresh JWT를 HttpOnly 쿠키로 발급
  2. 비안전 메서드(POST/PUT/PATCH/DELETE)에 X-CSRF-TOKEN 요구
  3. /api/auth/refresh는 refresh 토큰 회전(rotate) 수행

5.2 권한 모델

UserRole:

  • admin: 전체 관리 권한
  • student_council: 공지/공식 답변/일부 관리 기능
  • teacher, student: 일반 사용자

require_role(...) 동작:

  1. JWT role claim 우선 사용(핫패스 DB 조회 절감)
  2. legacy 토큰(역할 claim 없음)은 사용자 조회 후 판정
  3. admin은 role gate 우회 허용

5.3 토큰 상태 저장소

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 --> [*]
Loading

6. 캐시 아키텍처

6.1 런타임 모드

init_cache()는 부팅 시 Redis 실연결(PING) 성공 여부를 검사해 캐시 모드를 결정합니다.

  • redis: 실제 캐시 read/write 활성
  • fallback-null: CACHE_ENABLED=true지만 Redis 연결 실패 → NullCache로 강등
  • disabled: CACHE_ENABLED=false로 명시 비활성

핵심은 API 가용성을 우선한다는 점입니다. Redis 장애가 발생해도 요청은 계속 처리되고, 캐시만 비활성화됩니다.

6.2 캐시 저장/조회 조건

@cache_json_response(namespace, ttl?)는 아래 조건을 모두 만족할 때만 동작합니다.

  1. 요청 메서드가 GET
  2. 런타임 모드가 redis
  3. 호출자가 admin이 아님
  4. 응답이 2xx + JSON + non-streaming(direct_passthrough=False)

즉, admin GET/비GET/에러 응답/파일 스트리밍 응답은 캐시하지 않습니다.

디버깅이 필요하면 CACHE_DEBUG_HEADERS=trueX-Cache: HIT|MISS|BYPASS를 확인할 수 있습니다.

6.3 키 설계와 권한 격리

캐시 키 원문은 아래 요소를 결합한 뒤 SHA-256 해시로 축약합니다.

  • namespace
  • request.method
  • request.path
  • normalized query (키/값 정렬로 동치 쿼리 통합)
  • actor_scope

actor_scope 규칙:

  • 비로그인: anon
  • 로그인: user:{id}:role:{role_or_unknown}

role claim이 없는 legacy 토큰은 DB에서 role을 1회 복구한 뒤 scope를 결정합니다.
이 구조 때문에 같은 URL이라도 사용자별로 캐시가 분리됩니다.

6.4 "승인 전 본인 글만 보임"과 캐시의 결합 방식

승인 워크플로우가 있는 목록 엔드포인트는 비관리자에서 공통적으로 다음 가시성 조건을 갖습니다.

  • 공개: APPROVED
  • 예외 허용: author_id == current_user.id (본인 글)

대표 라우트:

  • free.list_posts
  • club_recruit.list_recruits
  • subject_changes.list_subject_changes
  • petitions.list_petitions
  • surveys.list_surveys
  • gomsol_market.list_posts

free.list_posts를 예로 들면:

  1. 비관리자 요청 시 status 관리자 필터를 무력화
  2. 최종 where 절에서 approved OR author_id=current_user 적용
  3. 같은 요청 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 전용 결과
Loading

6.5 detail 캐시와 권한 체크

일부 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는 캐시 데코레이터를 사용하지 않습니다.

6.6 무효화/인덱스 관리

쓰기 성공 경로는 invalidate_cache_namespaces(...)를 호출합니다.

  • 네임스페이스 인덱스 키: nsidx:{namespace}
  • 인덱스에는 실제 응답 키 목록을 저장
  • 인덱스 최대 길이: 5000 (초과 시 오래된 키 제거)
  • 인덱스 TTL: max(CACHE_DEFAULT_TIMEOUT * 20, 3600)

무효화/캐시 I/O 예외는 요청 실패로 전파하지 않습니다(운영 안정성 우선).

7. 레이트리밋 아키텍처

키 함수 우선순위:

  1. 로그인 사용자: user:<id>
  2. 익명: ip:<remote_addr>

적용 정책:

  • 공통 쓰기 제한: 블루프린트 단위 POST/PUT/PATCH/DELETE
  • 인증 엔드포인트 별도 제한:
    • register
    • login
    • refresh
  • 초과 응답: 429 + JSON(error_code=rate_limit_exceeded) + Retry-After(가능 시)

8. 업로드 보안 아키텍처

업로드 정책은 utils/files.py에서 중앙화됩니다.

  1. 스코프 고정: UPLOAD_SCOPE_DIRS, UPLOAD_ROUTE_PREFIXES
  2. 검증 3단계:
    • 확장자 allowlist
    • MIME allowlist
    • 파일 시그니처(sniff) 검증
  3. 임시 미리보기:
    • preview_token 서명 토큰(itsdangerous) 필요
    • TTL 기반 만료
  4. 저장 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
Loading

9. 데이터 모델 핵심 규칙

  1. 소프트 삭제 표준: deleted_at
  2. 카운터 캐시: views, comments_count, votes_count, total_votes
  3. 중복 방지 유니크 제약:
    • 반응/북마크/투표/설문 응답/BOSPI 대기 예측/BOSPI 날짜별 예측
  4. 감사 메타데이터 컬럼: 주요 쓰기 엔티티에 ip_address(64), user_agent(255) nullable 적용
  5. 직렬화 계약:
    • 프론트 계약 안정화를 위해 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.BUDGET enum이 추가되었습니다.
  • notices 테이블에 budget_year, budget_month nullable 컬럼이 추가되었습니다.
  • 월별 리스트 성능을 위해 (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하게 보강합니다.

9.1 ER 다이어그램 (핵심 관계)

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
    }
Loading

참고: COUNTDOWN_EVENTS는 사용자 소유가 아닌 독립 엔티티입니다. 공지 목록 응답에 다음 카운트다운 이벤트가 부가 데이터로 포함됩니다.

9.1A BOSPI 지수/예측 도메인

BOSPI는 고정 운영일 설정을 두지 않고 공식 기록이 시간순으로 누적되는 구조입니다.

  • BospiRecord는 한 날짜의 전체 학생 수, 교복 착용 학생 수, 서버 계산 비율(소수 4자리), 입력자 역할을 저장합니다.
  • BospiPendingPrediction은 사용자의 다음 공식 기록 등락 예측을 날짜 없이 1건만 유지합니다.
  • 새 공식 기록이 들어오면 대기 예측을 해당 날짜의 BospiPrediction으로 확정하고, 이전 공식 기록과 비교해 increase|decrease|tie 결과를 평가합니다.
  • 점수는 매 요청마다 합산하지 않고 BospiUserScore 집계 테이블에 반영합니다. 재평가 대상 날짜가 바뀌면 영향받은 사용자만 다시 계산합니다.
  • 랭킹은 total_score desc, correct_count desc, incorrect_count asc, 닉네임, 사용자 ID 순으로 안정화하며, 같은 총점은 같은 표시 순위를 공유합니다.

9.1B 스터디 윗 범서 순위판 도메인

스터디 윗 범서는 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:00 offset을 붙여 브라우저가 명확한 시각으로 파싱하게 합니다.
  • 최신 공개 점수 조회는 class_id, effective_at desc, id desc 정렬에서 첫 행을 고르는 방식이며, 동일 공개 시각에 여러 입력이 있어도 더 나중에 저장된 행이 우선합니다.
  • 동점 순위는 competition ranking 방식으로 계산합니다. 예를 들어 두 반이 공동 1위면 다음 반은 3위입니다.
  • 미래 예약 목록은 joinedload(created_by)로 입력자 표시 정보를 함께 가져오며, 일반 사용자 응답에는 빈 배열로 유지합니다.
  • 이 엔드포인트는 공개 시각 경계에서 결과가 바뀌므로 response cache를 사용하지 않습니다.
  • 신규 score update 행은 공통 before_flush 요청 메타데이터 훅의 대상이므로 ip_address, user_agent 감사 필드가 자동 보강될 수 있습니다.

9.2 스포츠리그 문자중계 도메인

스포츠리그는 일반 게시판과 달리 카테고리 → 팀/경기 → 이벤트 → 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
Loading

현재 구현 기준으로:

  1. snapshot 조회(GET /categories/:id)만 60 per minute limiter가 직접 연결되어 있습니다.
  2. GET /categories는 기본 2학년 카테고리와 이전 3학년 카테고리를 함께 노출합니다.
  3. SSE route는 pub/sub 신호가 없어도 updatedAt 변화를 감지하기 위해 유휴 시 snapshot을 다시 읽습니다.
  4. RATELIMIT_SPORTS_LEAGUE_STREAM_CONNECT, SPORTS_LEAGUE_MAX_STREAMS_PER_CLIENT는 config/helper에 존재하지만 route-level enforcement는 아직 연결되지 않았습니다.
  5. 선수 라인업 탭은 GET/POST/DELETE/PATCH /players 엔드포인트를 사용하고, 실시간 중계와는 별도 상태 흐름으로 유지됩니다.

9.3 수학여행 게시판 도메인

수학여행 기능은 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±5 step으로만 수행합니다.
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 응답
Loading

9.4 급식/알림 도메인

급식 기능은 FastAPI 안에서 동기화 스크립트가 적재한 급식 데이터 조회 → 브라우저 단위 평점 → 설치 기기 단위 알림 구독 흐름으로 동작합니다.

  • 요청 경로는 NEIS를 직접 호출하지 않고 school_meals 테이블만 읽습니다.
  • scripts/sync_school_meals.py만 NEIS mealServiceDietInfo를 호출해 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 저장
Loading

10. 보드 공통 승인(Moderation) 패턴

다음 보드는 공통적으로 승인 워크플로우를 가집니다.

  • free_posts
  • value_pick_posts
  • club_recruits
  • subject_changes(approval_status)
  • petitions(pending/approved/rejected)
  • surveys
  • gomsol_market_posts(approval_status)

공통 흐름:

  1. 작성 시 pending 계열 상태
  2. 관리자 승인 시 승인자/승인시각 기록
  3. 비관리자 목록 조회는 “승인 + 본인 작성글”로 제한
  4. 상세 조회에도 동일한 가시성 규칙 적용

10.1 Value Pick 보드 패턴

value_pick은 자유게시판과 비슷한 moderation 보드지만, 도메인 모델이 competency + pledge + rich body 조합에 맞춰 따로 분리되어 있습니다.

  • 생성 시 항상 pending
  • 비관리자 목록/상세는 approved OR author_id=current_user.id
  • 반응은 like | dislike 2종만 허용
  • 본문은 rich HTML을 저장하지만, 목록 preview는 모델 summarize_body()가 plain text로 축약합니다.
  • 첨부는 별도 row가 아니라 업로드 canonical URL이 본문에 연결되었는지로 공개 여부를 판정합니다.

11. 크레딧 경제(설문/투표)

survey_credits는 설문과 투표가 공유하는 사용자 크레딧 원장입니다.

  • available = base + earned - used

설문 응답 시:

  1. 설문 소유자 크레딧 1 소비
  2. 응답자(타인 설문) 크레딧 +5 보상

투표 응답 시:

  1. 응답자에게 VOTE_REWARD_CREDITS만큼 적립(기본 1)

12. 운영 관점 체크포인트

  1. validate_security_config()가 부팅 시 필수 보안값 검증
  2. 운영에서 HSTS는 HTTPS 요청일 때만 주입
  3. TRUST_PROXY_HEADERS=true + TRUSTED_PROXY_CIDRS 비어 있음 조합은 위험(경고 로그)
  4. 캐시/레이트리밋 장애는 요청 실패로 확산되지 않도록 설계

12.1 기술 선택 근거

결정 선택 근거
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 비교 내장

12.2 블루프린트 ↔ 모델 ↔ 캐시 네임스페이스 교차 참조표

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

13. 온보딩 권장 읽기 순서

  1. app.py
  2. config.py
  3. utils/security.py
  4. utils/security_tokens.py
  5. utils/request_metadata.py
  6. utils/cache.py
  7. utils/files.py
  8. routes/surveys.py
  9. routes/petitions.py
  10. routes/notices.py
  11. routes/value_pick.py
  12. routes/bospi.py
  13. routes/study_with_beomseo.py
  14. services/study_with_beomseo.py
  15. fastapi_app/routes/sports_league.py
  16. fastapi_app/services/sports_league.py
  17. fastapi_app/services/sports_league_players.py
  18. fastapi_app/services/sports_league_realtime.py
  19. fastapi_app/routes/field_trip.py
  20. fastapi_app/services/field_trip.py
  21. fastapi_app/routes/meals.py
  22. fastapi_app/services/meals.py

14. 변경 시 아키텍처 체크리스트

  1. 신규 쓰기 엔드포인트에 캐시 무효화 네임스페이스를 연결했는가
  2. 권한 체크(jwt_required, require_role, 소유자 검증)가 누락되지 않았는가
  3. 응답 키(camelCase) 변경 시 프론트 문서/코드 동기화가 되었는가
  4. 업로드 URL을 canonical URL로 정규화하고 있는가
  5. 레이트리밋 정책(특히 auth/쓰기)이 의도대로 적용되는가
  6. 신규 쓰기 엔티티가 ip_address, user_agent 감사 정책과 충돌하지 않는가