Skip to content

Review: snutt-v2 전체 히스토리 (커밋 77개) - #2

Draft
eastshine2741 wants to merge 77 commits into
review/base-emptyfrom
review/full-history
Draft

eastshine2741 wants to merge 77 commits into
review/base-emptyfrom
review/full-history

Conversation

@eastshine2741

Copy link
Copy Markdown
Contributor

Goal

snutt-v2 전체 히스토리(커밋 77개)를 한 번에 리뷰하기 위한 리뷰 전용 PR입니다.

문제 / 요구 사항

develop 이 기본 브랜치이자 유일한 개발 브랜치라 diff 를 낼 상대가 없었습니다. 그래서:

  • base — 파일이 하나도 없는 빈 root 커밋 review/base-empty
  • head — develop 의 78개 커밋을 그 빈 root 위로 그대로 옮겨 붙인 review/full-history

GitHub 은 공통 조상이 없는 두 브랜치 사이에 PR 을 만들지 못하므로, develop 자체를 head 로 쓸 수는 없었습니다. review/full-history 는 git filter-branch 로 빈 root 커밋만 앞에 붙인 것이라 커밋 SHA 는 다르지만 각 커밋의 트리·메시지·부모 관계(머지 커밋 포함)는 develop 과 동일합니다. (git diff develop review/full-history → 빈 출력으로 확인)

⚠️ 이 PR 은 머지하지 마세요. base 가 리뷰용 빈 브랜치라 머지해도 develop 에는 영향이 없지만, 리뷰가 끝나면 PR 을 close 하고 review/base-empty · review/full-history 브랜치를 삭제하면 됩니다.

변경 사항

전체 코드베이스: 301 files, 약 27.9k lines, 5개 Gradle 모듈.

모듈 역할
core 도메인 모델 · JPA 엔티티 · Flyway 스키마 · 도메인 서비스
api v2 REST API
v1compat 구 SNUTT / SNUTT-EV 클라이언트용 v1 와이어 호환 레이어
batch 수강편람 sync · 빈자리 알림 · 스케줄러 · FCM 푸시
migration 구 Mongo + 구 ev MySQL → 신 MySQL 일회성 이관

주요 흐름 (마일스톤 순):

  • M0–M1 4모듈 빌드 · Flyway 전체 스키마 · 인증/유저 도메인 (이후 이메일 인증, 계정 관리, 비밀번호 초기화 보완)
  • M2 lecture 중심 모델 — Mongo 검색 시맨틱을 MySQL 로 이식
  • M3 시간표 도메인 (lecture 참조 + customization override) · 테마 · 북마크 · 리마인더
  • M4 강의평 도메인 + course 집계 비정규화
  • M5 친구 · 빈자리알림 · 알림함 · 팝업 · 클라이언트설정 · 피드백 · 어드민 · 강의 일기장
  • M6 v1 호환 레이어 — 구 클라이언트 /v1 경로 96개 복원, 실제 v1/ev DTO 와 와이어 형태 대조
  • M7 batch 모듈 + in-process 스케줄러
  • M8 일회성 마이그레이션 모듈

이후 리팩터링 · 결함 수정 구간:

  • 스키마 정규화: 테마 공개 정보 분리(published_theme), 시간표 override 를 timetable_lecture.overrides JSON 으로 통합, 리마인더 스케줄 · 일기장 JSON 컬럼 정규화 테이블 분리, 기본 테마를 user_preference 로 분리
  • core 독립화: external_id 제거, PK Long 단일화, 스키마 단순화
  • i18n: lecture 영문 필드 · x-language · 읽기 시점 로컬라이즈 · 영문 검색 태그 parity
  • 동시성/트랜잭션: 강의 추가 잠금, 원자 카운터, batch 트랜잭션 분리, 빈자리 알림 푸시를 청크 커밋 후 발송
  • 인증: 친구 초대 링크 ES256 JWT 전환 + 구 apikey JWT 검증, user_social_auth 분리
  • 네이밍 정리: 카테고리 → 교양영역, 일상 유형 → 수업 유형
  • JDK 25 전환, CI/CD 워크플로 추가, 경고 제로화

리뷰 관점 제안

  • core 도메인 모델과 Flyway 스키마의 정합성 (특히 후반 정규화 커밋들이 앞선 마이그레이션과 어긋나지 않는지)
  • v1compat 의 와이어 호환성 — 구 클라이언트가 깨질 여지
  • batch 의 트랜잭션 경계와 푸시 발송 시점
  • 검색(SearchKeywordClassifier) 의 v1 시맨틱 재현도

Reference

  • 커밋 단위로 보려면 Commits 탭을 이용하는 편이 파일 단위 diff 보다 읽기 쉽습니다.

asp345 and others added 30 commits August 12, 2026 17:39
- snutt-ev 템플릿 기반 Boot 4 MVC + virtual threads 4-모듈 빌드
- Flyway V1: PLAN.md §2 스키마 전체 (단수 테이블명, external_id, lecture/course 분리)
- 인증: JWT access(ES256) + 회전 refresh, user_session, 토큰 교환, 소셜 로그인 OIDC
- 인터셉터 체인(@CurrentUser/@CurrentSessionId), 에러 카탈로그
- Testcontainers-MySQL 인증 통합 테스트 green
- Spring Boot 4.0.1→4.1.0, Kotlin 2.2/2.3→2.3.21 (Boot BOM 정렬), ktlint 13→14.2.0
- Testcontainers 1.21.3→2.0.5 (아티팩트 명 testcontainers-mysql/junit-jupiter)
- QueryDSL 5.1.0:jakarta + kapt (core), Jackson 3 databind 명시
- Gradle wrapper 9.3.1→9.7.0, springdoc 3.1.0, mockk 1.14.11, kotest 6.2.4
SnuttException으로 트랜잭션이 롤백되면 revokeAllByUserId의 bulk UPDATE가
함께 취소되어 재사용된 토큰이 유효하게 남는다. 회전/폐기 커밋을 유지한다.
- lecture/lecture_class_time/course 엔티티, QueryDSL 검색 저장소
  (LectureCustomRepository 이식: 한국어 fuzzy REGEXP, 시간 포함/제외, etc-tags,
  장소/건물, 특수어. REGEXP/STRCMP는 FunctionContributor로 MySQL 연산자 등록)
- 평점 정렬은 평가 도메인의 LectureRatingJoinView가 lecture⋈course 조인 주입
- tag_list/coursebook/lecture_building 도메인 + v2 검색/태그/수강편람/건물 API
- M2 DoD: LectureCustomRepository 참조 포트와 49개 쿼리 corpus side-by-side diff
  (Testcontainers-MySQL, 공유 컨테이너 베이스로 테스트 클래스 간 재사용)
- timetable/timetable_lecture/customization/reminder 엔티티와 서비스
  - 표시 병합: lecture 최신 데이터 위에 customization non-NULL 필드 오버레이 (PLAN.md §2)
  - 강의 추가/중복/겹침/덮어쓰기, custom 강의, reset, 수정, 삭제
  - 리마인더: schedule_list JSON + next_day/next_minute 비정규화, 소유권 검증 추가 (v1 누락 버그 수정)
- theme 도메인 (마켓 정보 평탄화: publish_name/author_anonymous/download_count, origin FK)
- bookmark: 스냅샷 폐기, bookmark_lecture FK 참조
- v2 API: /v2/timetables, lectures, reminders, themes, bookmarks (camelCase)
- HTTP QUERY 메서드(RFC 10008) 검색: Spring 7.0.8 미지원(PR #34993 대기)이므로
  QueryMethodFilter로 검색 경로 QUERY를 내부 POST로 전환, JDK HttpClient로 검증
- 테스트 격리: 클래스별 전용 DB(서버 1회 기동 + createDatabaseIfNotExist)
- TimetableIntegrationTest 7건 (v1 TimetableIntegTest 시나리오 이식) green
- evaluation/evaluation_like/evaluation_report + tag_group/tag 엔티티/저장소
- EvaluationService: 생성/수정/삭제(숨김)/신고/공감 + v1 시맨틱(수정 시 공감 초기화 등)
- course.eval_count/avg_rating은 강의평 쓰기 트랜잭션 안에서 재계산 (구 RATING_SYNC_JOB 대체)
- main 태그(최신/교양/추천/명강/꿀강/고진감래) 평가 필터, 이메일 인증 게이트
- CursorPage + base64(JSON) 커서 공통 (구 AES 커서 미이식), 검색 DTO에 evSummary 조인
- deps: 미사용 mockk/kotest 제거 (ExternalIdGeneratorTest JUnit5 전환,
  junit-platform-launcher 명시), springdoc은 swagger-ui 담당이라 유지
- EvaluationIntegrationTest 9건 (집계 property 테스트 포함) green
- friend: 요청/수락/거절/끊기/표시이름 + 14일 TTL Redis 초대 링크 (v1 시맨틱)
- vacancy_notification: 최신 학기 검증 + 중복 방지, notification: 전체/개인 알림 keyset 목록 + 읽음 처리
- popup/client_config: os·버전 적응형 설정, feedback: GitHub Issue 접수 (RestClient, 토큰 env)
- 정적 페이지(member/privacy_policy/terms_of_service) HTML 이관, 어드민 API(알림/설정/팝업/등록기간/유저검색)
- Redis 스타터 추가, ClientInfo 헤더(x-os-type 등) 인터셉터 확장
- 친구 요청/수락 FCM 푸시는 M7 PushService와 함께 연동 예정
- MiscDomainIntegrationTest 7건 green (Redis 컨테이너 포함)
- diary_daily_class_type/diary_question/diary_submission 엔티티 (JSON 리스트 컬럼)
- 질문지 생성(대상 유형 기반 3문항, 무작위), 대상 강의 추천(대표 시간표 기준,
  24시간 내 제출 제외), 제출(댓글 1,000자 제한)/내 기록/삭제
- 어드민: 유형·질문 관리 (v1 AdminController 이식)
- DiaryIntegrationTest 4건 green
- v1 인증: x-access-token(credentialHash) 인터셉터 + x-access-apikey 수용,
  @EmailVerifiedRequired 게이트 v1compat에도 적용
- 이중 매핑(/v1/x + /x) 스캐폴딩 + Deprecation/Sunset/Link 헤더 인터셉터
- v1 auth(register_local/login_local/소셜 → credentialHash 토큰), users/me,
  tables(전체+강의 단수 lecture 경로), search_query, tags, course_books, buildings
- Legacy DTO: snake_case/_id/class_time_json(start_time·len·start), snuttEvLecture(course id 재채번)
- ev-service: in-process 컨트롤러 + ev 에러 봉투 {error:{code,message}} (평가 도메인만 변환)
- V1CompatContractTest 5건 green (이중 매핑, Deprecation 헤더, 레거시 형태, ev 봉투)
- Spring Batch 6 잡: JOB_NAME 선택 러너(k8s CronJob), 단일 MySQL 배치 메타 테이블
- sugangSnuMigrationJob: 수강스누 xlsx 다운로드+파싱(POI) → lecture/lecture_class_time/course
  3계층 트랜잭션 upsert → tag_list 재생성 → 변경/폐지 알림. 스냅샷·북마크 전파 스텝 삭제
- vacancyNotificationJob: 만석 해제 감지 → push_preference 필터 → FCM + 알림함
- primaryTimetableAutoSetJob: 대표 시간표 자동 지정
- core: Firebase Admin SDK FCM 클라이언트(@Profile("!test")), RecordingPushClient(test),
  PushService(수신자 필터 + 기기 발송 + 알림함 저장)
- batch 테스트 4건 green (xlsx 픽스처, diff/알림, 빈자리 필터, 잡 파라미터 고유화)
- @EnableScheduling + ReminderScheduler: 매분 next_day/next_minute 인덱스 조회 →
  FCM('📚 강의 리마인더', N분 전/후) + 알림함, 발화 후 recentNotifiedAt 기록 + next_* 전진
- DiaryScheduler: 월/수/금 19시(KST) 대표 시간표 사용자에게 강의 일기장 안내
  (push_preference DIARY 필터, snutt://diary 딥링크)
- SchedulerIntegrationTest 2건 green
- 단계별 CLI: MigrationRunner가 인자("all" 또는 단계 목록)로 PLAN.md §5 순서 실행
- 3-데이터소스: 신 MySQL(JdbcTemplate) + 구 ev MySQL + Mongo(driver-sync)
- IdMaps: ObjectId → BIGINT 인메모리 매핑 (lecture는 스트리밍)
- 스텝: users, course, tag, lecture(+class_time, id_map 경유 course_id 해석),
  timetable(+customization 스냅샷 diff), bookmark(FK 전환), theme, friend, misc, evaluation
- validate: 행 수 대조 + external_id CRC32 체크섬 + 중복 검사
- 카테고리 상세 API(수강스누 getLectureInfo)는 이관 대상 아님 — xlsx 컬럼 기반 이관
- EmailVerificationService: SNU 메일 6자리 코드, 3분 TTL + 재요청 제한(Redis),
  중복 검증 이메일/이미 인증됨/유효하지 않은 메일 가드 (v1 이식)
- spring-boot-starter-mail SMTP 발송(@Profile("!test")) + RecordingMailClient(test)
- v2: POST/GET/DELETE /v2/users/me/email/verification + /code, v1compat 동일 경로
- EmailVerificationIntegrationTest 3건 green (v1 경로 credentialHash 포함)
기존 snutt 대비 누락된 인증/계정 기능을 보완한다.

- AuthService: 로컬 계정 연결, 비밀번호 변경, 소셜 연동/해제.
  소셜 연동은 중복 이메일·중복 소셜 계정을 막고, 해제는 마지막 로그인 수단을
  남기도록 강제한다. 자격 증명 변경 시 credentialHash를 재생성한다.
- PasswordResetService: 검증된 이메일로 6자리 코드를 보내고 코드 확인 후
  비밀번호를 교체한다. 교체 시 기존 세션을 모두 폐기한다.
  v1의 마스킹 이메일 확인·localId 조회 단계를 없애 이메일+코드 한 흐름으로 정리했다.
- 아이디 찾기: 가입 이메일로 아이디/소셜 수단 정보를 발송한다.
- MailClient는 MailType 파라미터 한 메서드로 통합한다.
- PasswordPolicy로 아이디/비밀번호 규칙을 공유한다.
- v2: /v2/auth/password/reset/{request,confirm}, /v2/auth/id/find,
  /v2/users/me/password, /v2/users/me/social/{provider}
  v1compat: /v1/auth/password/reset/*, /v1/users/{provider}, /v1/users/me/social_providers
기존 snutt 대비 누락되어 있던 나머지 기능을 채운다.

- DeviceService: FCM 등록 토큰 등록/해제. 기기는 device_id로 식별하고 없으면
  등록 토큰으로 대체 식별하여 같은 기기의 토큰 갱신이 행을 늘리지 않게 한다.
  등록/해제 시 전체 공지 토픽을 구독·해제한다.
- SemesterService: 학사 일정 고정 구간으로 현재·다음 학기를 판정한다.
  방학 등 학기 중이 아니면 current는 없다.
- 친구 테마: 친구가 공유한 테마와 친구가 받아간 테마의 원본을 합쳐 다운로드순으로 준다.
- 기본 테마 지정/해제: 기본 테마는 '가장 최근 수정한 커스텀 테마'이므로
  지정은 updatedAt 최신화로 표현한다. 내장 테마는 직접 지정할 수 없다.
- TakenLectureService: 직전 두 학기 시간표에서 수강 강의를 모아 강의평 작성
  대상을 준다. 강의평 쓰기와 달리 이메일 인증을 요구하지 않는다(v1 동일).
- 친구 코스북에 registered-course-books 별칭, 관리자 강의일기 알림 수동 발송.
- ClientInfo를 core로 옮기고 x-app-type/x-device-id를 채운다. v1 경로에도
  clientInfo를 세팅한다.
- /user/device/**, /semesters/** 를 v1 호환 루트 경로에 추가해 인터셉터가
  적용되도록 한다.
- CoverageGapIntegrationTest 7건
기존 코드와 경로를 전수 대조한 결과, v1 경로 96개가 v2에서 서빙되지 않았다.
도메인별로 구 경로와 응답 모양을 복원한다.

- 계정 경로는 단수형 /v1/user 이다. 이메일 인증·비밀번호·소셜 연동을
  /v1/users 에 두었던 것을 바로잡고, /v1/users 에는 /me 계열만 남긴다.
- 테마·친구·북마크·일기장·빈자리·팝업·설정·푸시설정·피드백·알림의 v1 경로를
  추가한다. v1은 목록을 {content,totalCount}로 감싸고, 친구 닉네임은 객체,
  시각은 KST ISO 문자열, 테마 색상은 colors, 팝업은 key/image_url/hidden_days
  별칭을 함께 준다.
- 알림 경로는 단수형 /v1/notification, 일기장·관리자 경로는 camelCase,
  테마 검색은 POST 본문, 공유 해제는 /publish, 시간표 이름 변경은 PUT 이다.
- 태그 갱신 시각, 강의평 요약(무인증), 리마인더 경로를 되살린다.
- 강의평 강의 검색과 개설 학기 목록을 course 기준으로 이식한다.
- 비밀번호 초기화는 v2의 이메일+코드 흐름을 유지하되, 구 클라이언트가 쓰는
  아이디 기준 3단계(마스킹 이메일 확인 → 코드 검증 → 교체)도 함께 받는다.
- 팝업 이미지의 s3:// 원본 URI를 공개 오브젝트 URL로 바꿔 내려준다.
  버킷이 공개 읽기라 서명이 필요 없어 SDK 없이 처리한다.
- V1CompatPathIntegrationTest 10건
팝업 이미지 업로드용 사전 인증 요청(PAR)을 발급한다. PAR 생성은 OCI 요청
서명이 필요해 오브젝트 스토리지 SDK로 호출하며, 테스트 프로파일에서는
형태만 맞춘 URI를 주는 구현으로 대체한다.

읽기는 버킷이 공개라 서명 없이 오브젝트 URL로 충분하므로 StorageUriResolver가
담당하고, 업로드 URI 발급만 SDK 경로를 탄다.
이전에 만든 Legacy DTO가 v1의 실제 직렬화와 달랐다. v1은 전역 snake_case가
아니라 camelCase + 일부 필드만 명시적 JsonProperty이며, non_null 포함 정책을 쓴다.

- 시간표 상세: _id/user_id/lecture_list → id/userId/lectures, updatedAt은 Instant(ISO).
  시간표 응답의 classPlaceAndTimes는 단순 DTO(day/place/startMinute/endMinute).
  start_time/end_time/len/start는 검색·북마크·빈자리 전용 ClassPlaceAndTimeLegacyDto.
- UserDto: local_id/fb_name → localId/fbName(camelCase), regDate/notificationCheckedAt는
  LocalDateTime(ISO), nickname.tag는 문자열. /v1/user/info는 UserLegacyDto(일부 snake_case).
- social_providers는 제공자별 불리언 AuthProvidersCheckDto.
- 닉네임 PATCH는 nullable+trim, 동일하면 재태깅 안 함.
- 로그인/로그아웃/이메일/비밀번호 요청 필드 별칭(user_id, fb_token, apple_token,
  user_email, registration_id)과 OkResponse({message:ok}) 복원.
- logout은 DeviceService.removeRegistrationId(토픽 구독 해제 포함).
- 북마크/빈자리는 LectureDto/BookmarkLectureDto(snake_case + class_time_json + snuttEvLecture),
  state는 {exists}, 알림 created_at은 ZonedDateTime(KST).
- 강의평 요약은 {evLectureId, avgRating, evaluationCount}.
- 관리자 popup key·알림 body 별칭, 테마 publish 요청 isAnonymous + origin 정보,
  검색 응답에 snuttEvLecture 요약.
- 강의 수정에 academicYear/category/classification/categoryPre2025 override 추가
  (customization 스키마 + 요청 + 병합).
- spring.jackson.default-property-inclusion: non_null (v1 동일).
ev 강의평의 응답이 v2의 user 객체 형태로 나가던 것을 v1 ev 형태로 바꾼다.

- 평가 목록/상세/수정/생성 응답: userId는 공개 id 문자열, lectureId는 course id,
  semester는 Int. 생성은 LectureEvaluationDto, 목록은 EvaluationWithSemester/
  WithLectureResponse(isModifiable/isReportable 포함).
- 강의평 요약: id는 course id, evaluation{avgGradeSatisfaction/...}.
- 신고 응답: EvaluationReportDto(id/lectureEvaluationId/userId/content/isHidden).
- tag main 응답은 기존 v2 TagGroupResponse와 동일하다.

코스북 updated_at은 Instant, 건물은 ListResponse + id(camelCase) + locationInDMS로
바로잡는다.
- 친구 primary-table은 v1 TimetableDto(userId/Instant)로 내려준다.
- 다이어리 target은 {lectureId, courseTitle}, my는 v1 DiarySubmissionSummaryDto
  (id/lectureId/date/courseTitle/shortQuestionReplies/comment) 형태로 내려준다.
- 질문 id는 재채번으로 Long이 되었다 (구 ObjectId 문자열과 단절, PLAN.md §8).
- 소셜 제공자 파싱을 HttpServletRequest에서 경로 정규식 변수로 바꾼다
  ({provider:facebook|google|kakao|apple}).
- 테마 컨트롤러의 중복 getTheme 호출을 한 번의 지역 변수로 줄인다.
- KST/시각 직렬화 헬퍼를 dto.LegacyDateTime으로 모아 중복을 없앤다.
- 사용하지 않는 import를 제거한다.
북마크·빈자리·검색에서 반복되던 courseId+평균→요약 매핑을
EvaluationSummary.toLegacyEvSummary 하나로 모은다. 강의평 요약 컨트롤러도
단일 요약 조회로 단순화한다.
테마 행이 '개인 보관함 항목 + 공개 목록 + 받아온 사본' 세 역할을 status/isCustom/
publishName/downloadCount로 겹쳐 저장하던 것을 정리한다.

- theme: user_id/name/color_list/origin_theme_id만 남긴다. is_custom/status/publish_
  name/author_anonymous/download_count 제거 (builtin은 원래 행이 없어 is_custom은
  항상 true, status는 origin 유무 + 공개 행 유무로 유도 가능했다).
- published_theme: 공개 여부를 행 존재로 표현한다. 공개 = INSERT, 비공개 = DELETE로
  바뀌어 발행이 내 테마 행을 변형하지 않는다.
- 마켓 조회(best/search/friends)는 published_theme ⋈ theme ⋈ user 조인으로 바뀐다.
- 마이그레이션은 theme + published_theme 두 행으로 나눠 쓴다.
customization은 timetable_lecture와 1:1이며 전 컬럼이 nullable인 수직 분할이라
조인·엔티티·저장소만 키웠다. override 컬럼 9개를 timetable_lecture로 옮기고
테이블과 엔티티·저장소를 삭제한다.

- display 병합은 lecture base 위에 timetable_lecture의 non-NULL 컬럼을 덮는다.
- add/modify/reset/copy가 행의 override 컬럼을 직접 읽고 쓴다.
- 마이그레이션은 lecture 참조면 차이분, custom이면 내용 전체를 같은 INSERT로 쓴다.
강의 시간을 lecture JSON과 lecture_class_time 테이블에 이중 저장하던 것을
lecture_class_time 테이블 단일 원천으로 바꾼다. 읽기 경로는 findAllByLectureIdIn
1회 배치 조회로 파생한다 (N+1 없음).

- Lecture.classPlaceAndTime 필드와 컬럼 제거, equalsMetadata에서 시간 비교 제거.
- LectureService.classTimesByLectureId 배치 파생 헬퍼.
- 표시 병합/검색 응답/북마크/빈자리/v1compat이 classTimesMap을 받는다.
- 수강스누 sync는 (Lecture, classTimes)를 함께 나르고 diff에 시간을 포함한다.
- 마이그레이션은 lecture_class_time만 쓴다.
- 수강스누 xlsx 다운로드를 POST 폼 제출로 고친다 (workType=EX 포함).
xlsx가 주지 않는 정확한 시간/강의실/교양분류/학과를 강좌 팝업 API
(cc101ajax.action)로 채운다. v1 SugangSnuFetchService와 동일한 enrichment.

- SugangSnuLectureApi.getLectureInfo: 상세 API GET + JSON 파싱.
- SugangSnuLectureEnricher: xlsx 행 위에 상세 정보를 덮는다.
  시간은 ltTime/ltRoom으로 교체, category는 sbjtFldNm, 학과는 departmentKorNm(majorKorNm),
  categoryPre2025는 정적 맵(categoryPre2025.txt)으로 채운다.
- SugangSnuClassTimeUtils: 상세 API 시간 문자열 파싱 이식.
- xlsx 다운로드를 POST 폼 제출로 고친다 (workType=EX 등 전체 파라미터).
- 실제 cc101ajax.action 응답을 픽스처로 커밋하고 enrichment 테스트 2건.
asp345 and others added 20 commits August 22, 2026 20:20
- sync job의 semester 파라미터가 유효하지 않으면 최신 편람으로 조용히 폴백하지 않고 즉시 실패 처리
- 빈자리 알림의 다음 오픈 시각 안내가 이미 지난 슬롯의 시작 시각을 표기하지 않도록 수정(미래 슬롯 우선, 진행 중이면 현재 시각)
- 빈자리 알림의 다음 오픈 시각은 미래 슬롯이 없으면 곧바로 "다음 수강신청 일자"로 안내(진행 중 슬롯 현재 시각 표기 분기 제거)
- 평가 수정 시 좋아요 수 리셋을 엔티티 할당으로 통일하고 원자 쿼리 resetLikeCount 제거
- 디바이스 등록 경로 변수의 도달 불가능한 isBlank 검사 제거
- 강의별 재시도(2회 백오프) 후에도 실패하면 예외를 그대로 전파해 job을 실패시킨다(에러 인지가 목적)
- 이에 따른 부수 장치 제거: 실패 강의 스킵용 failedKeys·sync skipKeys 파라미터, 실패율 임계치 상수
- enrich는 sync 시작 전에 실행되므로 실패 시 DB 변경이 없어 중단이어도 부분 상태가 남지 않는다
- processChunk가 발송 대상 페이로드를 반환하고, 푸시는 청크 커밋 후에 보낸다(청크 도중 실패 시 이미 간 푸시와 재알림 방지)
블로커
- 빈 DB에서 sync 잡이 즉시 실패하던 문제 수정: latest coursebook이 없으면 수강사이트 조건으로 첫 편람을 생성해 동기화하고, 파라미터 실행 시에도 명확히 실패한다

v1 호환 (major)
- /v1/search_query 페이지네이션을 구버전 계약(offset 미지정 시 page*20, 클라이언트 offset 필드 존중)으로 복원하고 times/timesToExclude의 잘못된 요일에 400 응답
- /v1/popups를 ListResponse(content, totalCount) 래퍼로 감싸고 /v1/admin/configs를 legacy 계약({data, minVersion{ios,android}, maxVersion}, POST upsert, PATCH merge)으로 재형성
- 강의 일기 제출에 선택지 범위·오늘 한 일 검증 추가(DIARY_QUESTION_INVALID/DIARY_DAILY_CLASS_TYPE_NOT_FOUND)

기타
- 피드백 GitHub 이슈에 labels(os)와 제출 시각(KST) 포함
- labels에 osType을 넣어 이슈가 OS별로 분류되도록 복원(구버전 계약)
- body에 제출 시각(KST) 추가
- 검색 키워드 매칭을 요청 language 기준으로 분기해 구버전 시맨틱 복원: EN은 스마트검색 없이 ko+en 필드 단순 매칭, KO는 한글 여부와 무관하게 ko 필드만 대상(기존에는 KO 모드에서도 en 필드를 OR 해 결과가 구버전보다 많았던 superset 동작)
- SemesterService가 자체 학사일정 표 대신 SemesterCalendar를 사용하도록 단일화(next 계산 헬퍼 추가), 날짜 표 이중 관리로 인한 drift 위험 제거
Validate request constraints and restrict timetable themes to accessible records.

Keep FCM tokens assigned to one active user and deduplicate migrated devices. Serialize primary timetable changes and restore parameterless coursebook discovery.
Delete obsolete external-ID infrastructure after the Long primary-key migration. Remove uncalled service and repository methods so the maintained APIs match active callers.
전공/석박/영강 등 검색 키워드 관습을 lecture/course/search-reference에
3벌 복제하던 것을 SearchKeywordClassifier(순수 함수)로 단일화한다.
각 리포지토리는 KeywordIntent를 자기 스키마 어휘로 변환한다.

CourseSearchRepository의 전공 검색이 데이터에 없는 '전공선택/전공필수'를
찾던 버그를 수정한다. course.classification은 snutt-ev LectureClassification
enum value(전선/전필/교양/일선...)로 채워지므로 '전선/전필'이 올바른 어휘다.

LectureSearchDiffTest(oracle)와 V1CompatContractTest로 동작 동일성 검증.
커스텀/수정 강좌의 오버라이드 값(courseTitle/instructor/credit/remark/
class_place_and_times/academic_year/category/classification/category_pre2025)을
LectureOverrides 임베디드 JSON 하나로 합친다. NULL 의미(미지정 vs 삭제) 혼동을
제거하고 clearOverrides는 overrides=null로 단순화한다.

- TimetableLecture: 12 nullable → overrides + color/colorIndex
- TimetableDisplay: overrides.merge(base)로 해석 (출력 형태 불변)
- V1__init.sql: timetable_lecture DDL에서 9컬럼 제거, overrides JSON 추가
- batch SugangSnuSyncService.overlapsOtherLecture가 overrides에서 classPlaceAndTimes 읽음
timetable_lecture_reminder의 schedule_list JSON + next_day/next_minute 캐시 +
recent_notified_at 2중화를 제거하고 timetable_lecture_reminder_schedule
(reminder_id, day, minute, recent_notified_at) 정규화 테이블로 옮긴다.

- 스케줄러가 (day, minute) 복합 인덱스로 범위 조회(전체 스캔 제거)
- collect()의 next_fire 캐시 갱신 로직 제거, 발화 스케줄만 recent_notified_at 갱신
- Schedule에서 recentNotifiedAt 제거(스케줄 테이블로 이동), recomputeNextFire 삭제
- migration TimetableStep/FinalSteps가 새 스케줄 테이블로 이관
- V1__init.sql: reminder 테이블 단순화 + reminder_schedule 신설
기본 테마 지정이 theme.updatedAt 최신화로 표현되던 것을 user_preference
(user_id PK, default_theme_id) 명시 테이블로 옮긴다. updatedAt은 순수한
최근 수정 시각으로 복원되고, 기본 테마 조회가 인덱스 PK 히트로 단순해진다.

- UserPreference 모델/리포지토리 신설, V1__init.sql에 user_preference 추가
- setDefault/unsetDefault/getDefaultTheme/getDefaultThemeId를 user_preference 기반으로
- TimetableThemeRepository의 touchUpdatedAt/findFirstByUserIdOrderByUpdatedAtDesc 제거
- 기본값이 없으면 내장 테마(id=1)로 폴백 (신규 사용자 기본 동작 유지)
- DiaryQuestion.targetDailyClassTypeIdList → diary_question_target(question_id, daily_class_type_id)
- DiarySubmission.dailyClassTypeIdList → diary_submission_daily_class_type
- DiarySubmission.questionAnswerList → diary_submission_answer
- generateQuestionnaire가 in-memory 필터 대신 조인 조회
- V1__init.sql에 조인 테이블 3개 추가, JSON 컬럼 3개 제거
- migration CatalogStep/UserDataStep이 새 조인 테이블로 이관
category는 교양 강의에만 값이 있는 교양영역(sbjtFldNm)인데, deriveCategory가
전필→전공필수처럼 classification에서 파생해 전공 강의에도 category를 채웠다.
v1 동작 기준으로 교양영역은 API(sbjtFldNm)에서만 채우고, xlsx 파생은 제거한다.
Phase 2에서 timetable_lecture의 9개 오버라이드 컬럼이 overrides JSON으로 통합되어
TimetableStep의 이관 SQL과 테스트가 깨지던 것을 수정한다.
- toRow가 overrides JSON 하나로 빌드
- MigrationIntegrationTest가 overrides->>'$.key'로 검증
실 데이터(snutt-dev-dump lectures 200,096건) 기준으로:
- classification: 교과구분 약어 (전선/전필/교양/일선/논문/교직)
- category: 2025+ 신규 교양영역(수학과학컴퓨팅, 예술과 체육, 자율과 창의 등), 교양/교직에만 값
- categoryPre2025: 2025+ 강의의 과거 기준 분류(과학적 사고와 실험, 체육 등 구 영역)

기존 코퍼스는 category를 전공필수/전공선택 같은 classification 풀네임으로
잘못 채워 실제 교양영역 검색을 검증하지 못했다. 명시 시드로 카테고리 필터가
비어있지 않음을 보장한다.
- LectureSearchDiffTest 시나리오 라벨을 실제 의미(교양영역/구 교양영역)로 변경
- DiaryJoinTables의 '일상 유형' 오역을 '수업 유형'(오늘 수업이 어떻게 진행되었는지)으로 정정
- 영문 식별자 DailyClassType은 v1 API 계약 필드(dailyClassTypes)라 유지
기존 통합 테스트가 커버하지 않는 순수 로직에 대한 단위 테스트:
- Schedule.plusMinutes: 같은 요일 오프셋, 자정·요일·주 경계 wraparound, 정렬
- TimetableLecture.updateOverrides: 부분 수정이 다른 필드를 보존하고
  merge하는지, clear 후 새로 시작하는지, copyFor가 오버라이드를 옮기는지

모두 출력값을 긍정으로 단정한다.
버그 수정
- theme 삭제 시 user_preference.default_theme_id FK(ON DELETE RESTRICT)로
  기본 테마 삭제가 500이던 회귀 수정. 삭제 전 기본 테마를 내장으로 되돌린다.
- LectureOverrides의 isEmpty()가 Jackson getter로 직렬화되어
  UnrecognizedPropertyException이 나던 것을 인라인 비교로 교체.

개선
- 리마인더 processDueReminders의 스케줄 per-reminder 조회(N+1)를
  윈도우 단위 findByReminderIdIn 배치 로딩으로 축소.
- DiaryQuestionTargetRepository의 미사용 메서드 제거.

레이어링
- v2 컨트롤러의 레거시 경로 별칭 제거(/insert_noti, /registrationPeriods,
  /diary/dailyClassTypes, /registered-course-books). v2는 kebab 정본만 제공하고
  v1 호환은 v1compat가 담당한다.
- 정적 페이지를 /v2/static/*(kebab)으로 옮기고 루트 /member, /privacy_policy,
  /terms_of_service는 외부(앱스토어) 참조 호환을 위해 301 리다이렉트로 유지.
nickname: String,
): User {
user.nickname = userNicknameService.appendNewTag(nickname)
return conflictAs(ErrorType.DUPLICATE_NICKNAME) { userRepository.save(user) }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요거 내부가 catch DataIntegrityViolationException 으로 되어있는데
JPA 쓰기지연때문에 save() 시점에 INSERT 쿼리가 안 나가서 catch가 안 되지 않아?

Comment on lines +44 to +61
@Entity
@Table(name = "timetable_lecture_reminder")
class TimetableLectureReminder(
var timetableLectureId: Long,
var offsetMinutes: Int,
) : BaseEntity()

@Entity
@Table(name = "timetable_lecture_reminder_schedule")
class TimetableLectureReminderSchedule(
var reminderId: Long,
@JdbcTypeCode(SqlTypes.TINYINT)
var day: DayOfWeek,
var minute: Int,
var recentNotifiedAt: Instant? = null,
) : BaseEntity() {
fun toSchedule() = Schedule(day, minute)
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

timetable_lecture_reminder_schedule 이 별도 테이블일 필요 없이 timetable_lecture_reminder에 JSON으로 들어가도 괜찮지않을까

) : HandlerInterceptor {
companion object {
const val USER_ATTRIBUTE = "user"
const val SESSION_ATTRIBUTE = "sessionExternalId"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

구두) 기존 snutt에 없던 거라서 여기서도 없어도 된다

Comment on lines +103 to +112
@Transactional(readOnly = true)
fun authenticate(payload: AccessTokenPayload): User {
val session =
userSessionRepository.findWithUserById(payload.sessionId)
?: throw SnuttException(ErrorType.WRONG_USER_TOKEN)
if (!session.isValid) throw SnuttException(ErrorType.WRONG_USER_TOKEN)
val user = session.user
if (!user.active || user.id != payload.userId) throw SnuttException(ErrorType.WRONG_USER_TOKEN)
return user
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

지금 당장은 문제 안 될 것 같지만 남겨두기)
요게 auth interceptor에서 실행되고 User 엔티티를 반환하는데
그 다음 비즈니스로직에서 다시 트랜잭션 열고 User 엔티티에 접근할 땐 영속성컨텍스트가 다르므로
lazy loading되는 프로퍼티에 접근하면 LazyInitializationException 날 것 같음

지금 당장 문제 안 되는 이유는 User에 lazy loading 프로퍼티가 없기 때문

@eastshine2741 eastshine2741 Aug 29, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lazy loading 말고도 jpa의 동작이 예상이랑 다르게 동작할 수 있는듯 변경감지라던가

import com.querydsl.core.types.dsl.Expressions
import com.querydsl.core.types.dsl.StringExpression
import com.querydsl.jpa.JPAExpressions
import com.querydsl.jpa.impl.JPAQuery

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

qclass 를 만들필요없는 kotlin jdsl 사용해보기?

Comment on lines +11 to +28
@Component
class CourseRatingJoinView : LectureRatingJoinView {
override fun applyOrderBy(
query: JPAQuery<Lecture>,
sort: LectureSort,
): JPAQuery<Lecture> {
val lecture = QLecture.lecture
val course = QCourse.course
query.leftJoin(course).on(course.id.eq(lecture.courseId))
return when (sort) {
// 미연결 강의는 eval_count 0으로 취급 (스키마 DEFAULT 0). MySQL DESC는 NULL을
// 마지막에 정렬하므로 coalesce로 Mongo evInfo.count=0 시맨틱과 맞춘다
LectureSort.RATING_DESC -> query.orderBy(course.avgRating.desc(), lecture.id.asc())
LectureSort.COUNT_DESC -> query.orderBy(course.evalCount.coalesce(0L).desc(), lecture.id.asc())
LectureSort.DEFAULT -> query
}
}
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이거 한 곳에서만 쓰는 것 같은데 inline하기? 사실 view가 무엇인지 잘 이해하지 못했음,,

Comment on lines +33 to +48
@Component
class V1UserAuthInterceptor(
private val legacyTokenService: LegacyTokenService,
) : HandlerInterceptor {
companion object {
const val USER_ATTRIBUTE = "v1compat.user"
}

override fun preHandle(
request: HttpServletRequest,
response: HttpServletResponse,
handler: Any,
): Boolean {
if (handler !is HandlerMethod) return true
if (handler.has(V1Public::class.java)) return true

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요게 기존 해시로 하는 인증을 해주고, jwt로 하는 인증은 interceptor가 추가되었으니

클라에서 v2 엔드포인트로 부르기 + 앱 최초 실행 시 백그라운드에서 v2로 로그인해서 토큰 받아가기 같은 게 필요하겠군

Comment on lines +103 to +112
@Transactional(readOnly = true)
fun authenticate(payload: AccessTokenPayload): User {
val session =
userSessionRepository.findWithUserById(payload.sessionId)
?: throw SnuttException(ErrorType.WRONG_USER_TOKEN)
if (!session.isValid) throw SnuttException(ErrorType.WRONG_USER_TOKEN)
val user = session.user
if (!user.active || user.id != payload.userId) throw SnuttException(ErrorType.WRONG_USER_TOKEN)
return user
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

authenticate 할 때 refresh token(user_session 엔티티)까지 조회하면 stateless하지 않게 되어서 고쳐두겠습니당

Comment on lines +16 to +32
@Entity
@Table(name = "lecture_class_time")
class LectureClassTime(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "lecture_id")
val lecture: Lecture,
@JdbcTypeCode(SqlTypes.TINYINT)
var day: DayOfWeek,
var place: String? = null,
@JdbcTypeCode(SqlTypes.SMALLINT)
var startMinute: Int,
@JdbcTypeCode(SqlTypes.SMALLINT)
var endMinute: Int,
) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lecture에 JSON으로 들어가는 게 더 편하지 않을까

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants