Skip to content

Commit 4852710

Browse files
authored
Merge pull request #80 from Geumpumta/dev
Main <- Dev 반영
2 parents 1fd1379 + e44764e commit 4852710

File tree

125 files changed

+9698
-741
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

125 files changed

+9698
-741
lines changed

.ai/ARCHITECTURE.md

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
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

Comments
 (0)