|
| 1 | +# ARCHITECTURE.md |
| 2 | + |
| 3 | +Geumpumta 백엔드 시스템 아키텍처 문서. |
| 4 | + |
| 5 | +--- |
| 6 | + |
| 7 | +## 1. 시스템 개요 |
| 8 | + |
| 9 | +``` |
| 10 | +┌─────────────────────────────────────────────────────────────────┐ |
| 11 | +│ 클라이언트 (모바일 앱) │ |
| 12 | +└────────────────────────────┬────────────────────────────────────┘ |
| 13 | + │ HTTPS |
| 14 | + ▼ |
| 15 | +┌─────────────────────────────────────────────────────────────────┐ |
| 16 | +│ Security Filter Chain │ |
| 17 | +│ CORS → OAuth2Login → JwtAuthenticationFilter → @PreAuthorize │ |
| 18 | +├─────────────────────────────────────────────────────────────────┤ |
| 19 | +│ Controller Layer (@AssignUserId AOP → userId 자동 주입) │ |
| 20 | +├─────────────────────────────────────────────────────────────────┤ |
| 21 | +│ Service Layer │ |
| 22 | +│ study │ rank │ statistics │ user │ token │ board │ fcm │ wifi │ |
| 23 | +├─────────────────────────────────────────────────────────────────┤ |
| 24 | +│ Repository Layer (JPA │ Native Query │ JDBC Batch │ Redis) │ |
| 25 | +├─────────────────────────────────────────────────────────────────┤ |
| 26 | +│ Scheduler Layer │ |
| 27 | +│ RankingScheduler │ SeasonTransition │ MaxFocus │ TokenCleanup │ |
| 28 | +└────────┬──────────┬──────────┬──────────┬───────────────────────┘ |
| 29 | + │ │ │ │ |
| 30 | + ┌─────▼───┐ ┌───▼────┐ ┌──▼───┐ ┌───▼──────┐ |
| 31 | + │ MySQL 8 │ │ Redis │ │ FCM │ │Cloudinary│ |
| 32 | + └─────────┘ └────────┘ └──────┘ └──────────┘ |
| 33 | +``` |
| 34 | + |
| 35 | +--- |
| 36 | + |
| 37 | +## 2. 엔티티 관계도 |
| 38 | + |
| 39 | +``` |
| 40 | + ┌─────────────┐ |
| 41 | + │ User │ |
| 42 | + │ role │ GUEST → USER → ADMIN |
| 43 | + │ department │ Enum (25개 학과) |
| 44 | + │ provider │ KAKAO, GOOGLE, APPLE |
| 45 | + │ fcmToken │ |
| 46 | + └──────┬──────┘ |
| 47 | + │ |
| 48 | + ┌──────────────┼──────────────┐ |
| 49 | + │ 1:N (FK) │ 1:N (FK) │ 1:N (FK 없음) |
| 50 | + ▼ ▼ ▼ |
| 51 | + ┌─────────────┐ ┌───────────┐ ┌─────────────┐ |
| 52 | + │StudySession │ │UserRanking│ │RefreshToken │ |
| 53 | + │ startTime │ │ rank │ │ userId │ |
| 54 | + │ endTime │ │ totalMillis│ │ refreshToken│ |
| 55 | + │ totalMillis │ │ rankingType│ │ expiredAt │ |
| 56 | + │ status │ │calculatedAt│ └─────────────┘ |
| 57 | + └─────────────┘ └───────────┘ |
| 58 | +
|
| 59 | +┌──────────────────┐ ┌───────────────────────┐ ┌────────┐ |
| 60 | +│DepartmentRanking │ │SeasonRankingSnapshot │ │ Season │ |
| 61 | +│ department (Enum)│ │ seasonId (FK없음) │ │ type │ |
| 62 | +│ rank, totalMillis│ │ userId (FK없음) │ │ status │ |
| 63 | +│ rankingType │ │ rankType, finalRank │ │ start │ |
| 64 | +│ calculatedAt │ │ department (nullable) │ │ end │ |
| 65 | +└──────────────────┘ └───────────────────────┘ └────────┘ |
| 66 | +``` |
| 67 | + |
| 68 | +**설계 결정:** |
| 69 | +- `SeasonRankingSnapshot`에 FK 없음 → 시즌/유저 삭제 후에도 이력 보존 |
| 70 | +- `RefreshToken`에 FK 없음 → 유저 soft-delete와 독립적으로 토큰 정리 |
| 71 | +- `User` soft-delete 시 `deleted_` prefix → unique 제약 유지하면서 재가입 허용 |
| 72 | + |
| 73 | +--- |
| 74 | + |
| 75 | +## 3. 인증 플로우 |
| 76 | + |
| 77 | +``` |
| 78 | +[OAuth2 로그인] |
| 79 | +앱 → /oauth2/authorization/{provider}?redirect_uri=... |
| 80 | + → CustomAuthorizationRequestResolver (redirect_uri를 state에 인코딩) |
| 81 | + → OAuth2 Provider 인증 |
| 82 | + → CustomOAuth2UserService.loadUser() → User 조회/생성 (role=GUEST) |
| 83 | + → SuccessHandler → JWT 발급 → redirect_uri?accessToken=...&refreshToken=... |
| 84 | +
|
| 85 | +[회원가입 완료] |
| 86 | +POST /email/request-code → Redis에 인증코드 (TTL 5분) |
| 87 | +POST /email/verify-code → 코드 검증 |
| 88 | +POST /user/complete-registration → GUEST→USER 승격, 새 JWT 발급 |
| 89 | +
|
| 90 | +[API 요청] |
| 91 | +Authorization: Bearer {token} |
| 92 | + → JwtAuthenticationFilter → parseToken (JJWT, HMAC-SHA256) |
| 93 | + → withdrawn=true이면 /restore 외 차단 |
| 94 | + → @PreAuthorize → @AssignUserId AOP → Controller |
| 95 | +``` |
| 96 | + |
| 97 | +--- |
| 98 | + |
| 99 | +## 4. 랭킹 시스템 |
| 100 | + |
| 101 | +### 이중 랭킹 구조 |
| 102 | + |
| 103 | +``` |
| 104 | +date 파라미터 유무로 분기: |
| 105 | +
|
| 106 | +date 없음 (현재 기간) date 있음 (과거 기간) |
| 107 | + │ │ |
| 108 | + ▼ ▼ |
| 109 | +실시간 랭킹 확정 랭킹 |
| 110 | +StudySession Native Query로 UserRanking / DepartmentRanking |
| 111 | +직접 계산 (진행중 세션 포함) 테이블에서 조회 (스케줄러가 저장) |
| 112 | +``` |
| 113 | + |
| 114 | +### 시즌 랭킹 계산 |
| 115 | + |
| 116 | +``` |
| 117 | +현재 시즌 랭킹 = ① + ② + ③ 합산 후 순위 부여 |
| 118 | +
|
| 119 | +① 확정 월간 합산 (시즌 시작 ~ 전월 말) |
| 120 | + → UserRankingRepository JPQL |
| 121 | +② 현재 월 일간 합산 (이번 달 1일 ~ 어제) |
| 122 | + → UserRankingRepository JPQL |
| 123 | +③ 오늘 실시간 데이터 |
| 124 | + → StudySessionRepository Native Query |
| 125 | +
|
| 126 | +종료된 시즌 → SeasonRankingSnapshot 불변 스냅샷 조회 (계산 없음) |
| 127 | +``` |
| 128 | + |
| 129 | +### 시즌 전환 (매일 00:05) |
| 130 | + |
| 131 | +``` |
| 132 | +SeasonTransitionScheduler |
| 133 | + → 캐시 우회 DB 조회 → today ≥ endDate+1 ? |
| 134 | + → Yes: activeSeason 캐시 clear |
| 135 | + → transitionToNextSeason (현재=ENDED, 다음=ACTIVE) |
| 136 | + → createSeasonSnapshot (@Retryable 3회, JDBC 배치 2000건) |
| 137 | + → No: return |
| 138 | +``` |
| 139 | + |
| 140 | +### 학과 랭킹 |
| 141 | + |
| 142 | +학과별 상위 30명의 공부 시간 합산. Native Query + CTE로 25개 학과 처리. |
| 143 | +`ROW_NUMBER() PARTITION BY department` → 상위 30 필터 → `SUM GROUP BY` → `RANK()`. |
| 144 | + |
| 145 | +--- |
| 146 | + |
| 147 | +## 5. 학습 세션 흐름 |
| 148 | + |
| 149 | +``` |
| 150 | +[시작] POST /study/start {gatewayIp, clientIp} |
| 151 | + → WiFi 검증 (@Cacheable) → 중복 STARTED 확인 → 세션 생성 (서버 시간) |
| 152 | +
|
| 153 | +[종료] POST /study/end {studySessionId} |
| 154 | + → 세션 조회 → endTime=서버시간, totalMillis=Duration 계산 → FINISHED |
| 155 | +
|
| 156 | +[자동종료] 매 10분 스케줄러 |
| 157 | + → STARTED + 3시간 초과 세션 → 자동 종료 + FCM 알림 |
| 158 | +``` |
| 159 | + |
| 160 | +--- |
| 161 | + |
| 162 | +## 6. 크로스 도메인 의존성 |
| 163 | + |
| 164 | +### 서비스 의존 그래프 |
| 165 | + |
| 166 | +``` |
| 167 | +StudySessionService ──→ CampusWiFiValidationService, FcmService |
| 168 | +PersonalRankService ──→ StudySessionRepository, UserRankingRepository |
| 169 | +DepartmentRankService → StudySessionRepository, DepartmentRankingRepository |
| 170 | +SeasonRankService ────→ SeasonService(@Cacheable), UserRankingRepo, StudySessionRepo |
| 171 | +SeasonSnapshotService → UserRankingRepo, SeasonSnapshotBatchService(JDBC) |
| 172 | +StatisticsService ────→ StudySessionRepository (12개 CTE) |
| 173 | +UserService ──────────→ JwtHandler, RefreshTokenRepo, FcmService |
| 174 | +TokenService ─────────→ JwtHandler, RefreshTokenRepo |
| 175 | +``` |
| 176 | + |
| 177 | +### StudySessionRepository — 쿼리 허브 |
| 178 | + |
| 179 | +3개 도메인(study, rank, statistics)이 공유. 수정 시 전체 영향. |
| 180 | + |
| 181 | +| 쿼리 | 도메인 | 용도 | |
| 182 | +|------|--------|------| |
| 183 | +| `calculateCurrentPeriodRanking` | rank | 실시간 개인 랭킹 | |
| 184 | +| `calculateCurrentDepartmentRanking` | rank | 실시간 학과 랭킹 | |
| 185 | +| `calculateFinalizedPeriodRanking` | rank | 확정 개인 랭킹 배치 | |
| 186 | +| `calculateFinalizedDepartmentRanking` | rank | 확정 학과 랭킹 배치 | |
| 187 | +| `getTwoHourSlotStats` | statistics | 일간 2시간 슬롯 | |
| 188 | +| `getWeeklyStatistics` | statistics | 주간 통계 | |
| 189 | +| `getMonthlyStatistics` | statistics | 월간 통계 | |
| 190 | +| `getGrassStatistics` | statistics | 잔디 차트 (NTILE) | |
| 191 | +| `sumCompletedStudySessionByUserId` | study | 오늘 총 공부 시간 | |
| 192 | + |
| 193 | +--- |
| 194 | + |
| 195 | +## 7. 캐싱 전략 |
| 196 | + |
| 197 | +| 캐시 | 저장소 | 키 | TTL | 무효화 | |
| 198 | +|------|--------|-----|-----|--------| |
| 199 | +| `wifiValidation` | Caffeine | `gatewayIp:clientIp` | 10분 | 자동 만료 | |
| 200 | +| `activeSeason` | Caffeine | 단일 엔트리 | 10분 | 시즌 전환 시 수동 clear | |
| 201 | +| 이메일 인증코드 | Redis | `{userId}email:{email}` | 5분 | 자동 만료 | |
| 202 | + |
| 203 | +--- |
| 204 | + |
| 205 | +## 8. 스케줄러 타임라인 |
| 206 | + |
| 207 | +``` |
| 208 | +매일: |
| 209 | +00:00:00 RefreshTokenDelete 만료 토큰 삭제 |
| 210 | +00:00:05 DailyRanking 전일 개인/학과 랭킹 확정 |
| 211 | +00:05:00 SeasonTransition 시즌 종료 확인 → 전환/스냅샷 |
| 212 | + ★ MonthlyRanking(00:02) 이후 실행 (데이터 의존) |
| 213 | +월요일: 00:01 WeeklyRanking |
| 214 | +1일: 00:02 MonthlyRanking |
| 215 | +매 10분: MaxFocusStudy 3시간 초과 세션 자동 종료 + FCM |
| 216 | +``` |
| 217 | + |
| 218 | +--- |
| 219 | + |
| 220 | +## 9. 예외 처리 경로 |
| 221 | + |
| 222 | +``` |
| 223 | +경로 1: Service 예외 |
| 224 | + throw BusinessException(ExceptionType) → GlobalExceptionHandler |
| 225 | + → {"success":false, "code":"ST002", "msg":"..."} |
| 226 | +
|
| 227 | +경로 2: 인증 예외 |
| 228 | + JwtAuthenticationFilter catch → HttpServletResponse 직접 JSON 작성 |
| 229 | + → {"success":false, "code":"S004", "msg":"..."} |
| 230 | +
|
| 231 | +경로 3: Validation 예외 |
| 232 | + @Valid MethodArgumentNotValidException → GlobalExceptionHandler |
| 233 | + → {"success":false, "code":"C002", "msg":"커스텀 메시지"} |
| 234 | +``` |
| 235 | + |
| 236 | +``` |
| 237 | +예외 계층: |
| 238 | +RuntimeException |
| 239 | + ├── BusinessException (ExceptionType: code + message + HttpStatus) |
| 240 | + └── JwtAuthenticationException |
| 241 | + ├── JwtTokenExpiredException (S004, 401) |
| 242 | + ├── JwtTokenInvalidException (S005, 401) |
| 243 | + ├── JwtNotExistException (S006, 401) |
| 244 | + └── JwtAccessDeniedException (S003, 403) |
| 245 | +
|
| 246 | +응답 구조: |
| 247 | +ResponseBody<T> (sealed) |
| 248 | + ├── SuccessResponseBody<T> → {"success":true, "data":{...}} |
| 249 | + └── FailedResponseBody → {"success":false, "code":"...", "msg":"..."} |
| 250 | +``` |
0 commit comments