[Release] develop 을 운영에 반영한다 - 복습 리마인더, 미션, 꾸미기, 스터디룸 챌린지와 인증·알림 정합성 수정 - #331
Merged
Merged
Conversation
- V21 Flyway migration: problem_review_reminder 테이블 추가 (status, scheduled_at 인덱스 포함) - ProblemReviewReminderStatus 상태 enum (SCHEDULED/SENDING/SENT/FAILED/CANCELED/SKIPPED_BY_COMPLETION/EXPIRED) - ProblemReviewReminder 엔티티 (userId, problemId, sequence, intervalDays, scheduledAt, snapshot 필드) - ProblemReviewReminderRepository: 선점 update, 발송 완료/실패, stuck 복구, cancel/skip 쿼리 포함 - ProblemReviewReminderPolicy: D+1/3/7/14/30 간격, 야간(00:00~05:59) → 06:00 조정, FCM 페이로드 생성 - ProblemCreatedEvent: Problem 저장 커밋 후 reminder 예약 생성을 위한 Spring 이벤트 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- ProblemReviewReminderService: AFTER_COMMIT 이벤트로 reminder 예약 생성, 5분 polling 발송 - 하루 1건 제한(sentAt 기준), notificationEnabled/FCM 토큰 없으면 skip - SENDING stuck row 10분 타임아웃 복구 - ProblemReviewReminderJob: Quartz job (0 */5 * ? * *, Asia/Seoul) - ProblemReviewReminderScheduler: @PostConstruct 스케줄 등록 - ProblemService: registerProblem/V2/Batch 후 ProblemCreatedEvent 발행, updateProblemInfo snapshot 갱신, delete 시 pending row cancel - ProblemSolveService: createProblemSolve 후 due pending row SKIPPED_BY_COMPLETION 처리 - UserService: deleteUserById 시 cancelAllByUser로 사용자 전체 pending row 일괄 취소 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- PATCH /api/problems/imageData 엔드포인트 추가 (Flutter 문제 수정 플로우) - ProblemService에 validateSolveImageNotRegisteredToday 검증 로직 추가 - ProblemImageDataRepository에 당일 중복 확인 쿼리 추가 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- findDueReminders에 당일 발송 여부·알림 설정·FCM 토큰 존재 여부를 서브쿼리로 통합, 배치 사이즈(500) 제한 추가 - ProblemReviewReminderSender 컴포넌트 분리 (REQUIRES_NEW 트랜잭션 단위별 처리) - tryUpdateStatus에 updatedAt 파라미터 추가 (stuck 회복 쿼리 정확도 향상) - FcmNotificationProducer RabbitMQ 실패 시 예외 throw로 변경 (발송 실패 추적 가능) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- V21: reminder 테이블 DATETIME → DATETIME(6) (마이크로초 정밀도, LocalDateTime 매핑 오류 방지) - V22: image_data (problem_id, image_type, created_at) 복합 인덱스 추가 - V22: problem_review_reminder (user_id, status, sent_at, deleted_at) 인덱스 추가 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- ProblemApiIntegrationTest: 문제 등록/수정/삭제 시 reminder row 생성·갱신·취소 통합 테스트 추가 - ProblemApiIntegrationTest: 이미지 등록 테스트를 새 URL 기반 API로 수정 - StudyRoomWeeklyReportApiTest: StudyRoomWeeklyReport 생성자 변경(null 파라미터 추가) 반영 - StudyRoomFeedServiceUnitTest: StudyRoomChallengeService 목 추가 - ProblemReviewReminderServiceTest, ProblemReviewReminderPolicyTest 추가 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
[Feat] 오답노트 망각곡선 기반 복습 알림 시스템 추가
- tag, problem_tag_mapping 은 @SQLDelete 로 행을 남기는데 유니크 인덱스가 deleted_at 을 보지 않아, 한 번 지운 태그를 같은 이름으로 다시 만들면 DB 제약 위반으로 500 이 발생 (Sentry JAVA-SPRING-BOOT-4Z) - V23: deleted_at 기반 생성 컬럼 alive_key 를 추가하고 유니크 인덱스를 세 컬럼으로 재생성 살아있는 행은 sentinel 값을 공유해 중복이 계속 막히고, 삭제된 행은 각자 deleted_at 을 가져 공존 - id(AUTO_INCREMENT) 참조는 MySQL 이 거부(ERROR 3109)하므로 deleted_at 을 사용 - VIRTUAL 컬럼이라 테이블 재작성 없이 메타데이터만 변경 - 인덱스 존재 여부를 information_schema 로 확인하는 동적 SQL 로 감싸 재적용에 안전 - problem_tag_mapping 도 같은 구조라 함께 처리. 태그 생성이 풀린 뒤 매핑 쪽에서 같은 500 이 나는 것을 방지 - Tag: 실제 인덱스 정의가 V23 에 있다는 주석 추가 (ddl-auto 가 validate 라 선언은 문서 역할)
- sentry.exception-resolver-order 가 Integer.MIN_VALUE 로 오버라이드되어 있어 SentryExceptionResolver 가 @ControllerAdvice 보다 먼저 실행됨 - 그 결과 GlobalExceptionHandler 가 log.warn 으로 처리하는 4xx 까지 전부 fatal 로 Sentry 에 보고됨 봇 스캐너가 만드는 404/405 가 7일 기준 301건으로 미해결 이벤트의 대부분을 차지 - docker-compose 환경변수로 sentry-java 기본값 1 을 복원 application-prod.yml 은 gitignore 대상이고 CI 가 GitHub Secret 으로 덮어쓰므로 환경변수로 지정 - 5xx 는 GlobalExceptionHandler 의 log.error 를 통해 Logback appender 로 계속 보고되며 레벨이 fatal 에서 error 로 정상화됨 - MaxUploadSizeExceededException(413) 전용 핸들러 추가 ErrorResponse 구현체라 4xx 로 분류되어 log.warn 이 되면 업로드 실패 신호가 묻히므로 error 로 보고
- retention 설정이 없어 기본값 15일로 동작하고 있었고, 그 이전 데이터는 매일 영구 삭제되고 있었음 - 실측 결과 활성 시계열 약 12,000개 / 스크레이프 60초 기준 하루 약 28MB, 90일이면 약 2.5GB - retention.size 를 함께 지정해 시계열 증가로 디스크가 차는 것을 방지 (둘 중 먼저 걸리는 쪽 적용) - 적용하려면 restart-monitoring 워크플로 실행 필요. deploy-prod 는 monitoring 프로파일을 올리지 않음
- memo 가 ddl-auto 시절 기본값 varchar(255) 로 만들어져 있어, 사용자가 긴 메모를 쓰면 등록 자체가 Data truncation 으로 실패했다 (Sentry JAVA-SPRING-BOOT-5B, 5A) - V24: varchar(1000) 으로 확장. utf8mb4 varchar(255) 는 이미 1020 바이트라 2바이트 길이 접두사를 쓰고 있어 접두사 크기가 바뀌지 않는다 → ALGORITHM=INPLACE, LOCK=NONE 으로 테이블 재작성·잠금 없이 적용된다. TEXT 로 바꾸면 COPY 가 되어 선택하지 않았다 - 1000자를 넘으면 저장 전에 걸러 400(PROBLEM_MEMO_TOO_LONG, 4006) 으로 응답 등록 v1/v2/배치, 정보 수정 네 경로 모두 적용 - problem_review_reminder 의 스냅샷 컬럼은 varchar(255)(V21) 이라 memo 만 넓히면 알림 예약 저장이 같은 이유로 실패한다. 알림 문구용 사본이므로 255자로 잘라 담는다 - 통합 테스트: 1000자 저장 + 스냅샷 잘림, 1001자 400 응답
- Problem 의 problemAnalysis 가 @OnetoOne(mappedBy) 라 Hibernate 가 프록시를 못 만들고 행마다 problem_analysis 를 한 번씩 더 조회했다 (Sentry JAVA-SPRING-BOOT-59) - review-due 는 스칼라 필드만 쓰므로 엔티티 대신 QueryDSL DTO 프로젝션으로 조회한다 문제 N개 조회 시 1 + N 번 → 1번 - 이 엔드포인트에서만 쓰던 findReviewDueProblems, ReviewDueProblemDto.from 은 제거 - 통합 테스트: 문제 6개 조회 시 PreparedStatement 1건, overdueCount 경계 검증
- LoggingAspect 는 MDC traceId 가 없으면 "아무도 안 잡은 예외"로 보고 error 로 남기는데, RabbitMQ consumer 스레드에는 traceId 가 없어 consumer 가 정상 처리하는 예외까지 error 가 됐다 (Sentry JAVA-SPRING-BOOT-3A, 누적 140건) - HandledFailure 마커 인터페이스를 추가하고 NonRetryableAnalysisException 에 적용 ProblemAnalysisConsumer 가 재큐잉 없이 ACK 하고 상태도 FAILED 로 정리하므로 warn 으로 내린다 - Discord 웹훅 전송 실패도 error → warn. 일시적 read timeout 은 RabbitMQ 가 재시도하고 최종 실패는 DLQ 에서 error 로 남는다 (Sentry JAVA-SPRING-BOOT-5C) - 웹훅 예외 메시지에 웹훅 URL(토큰 포함)이 그대로 들어 있어 로그·Sentry 로 새고 있었다 메시지 대신 예외 타입만 남긴다 - LoggingAspectTest: HandledFailure warn, 미처리 예외 error, ApplicationException 무시, 요청 스레드 debug 네 경우 검증
- 최근 14일 요청 128,335건 중 92.4%가 /api 밖 경로(봇 스캐너)라, 비율 기준 알림의 분모가 부풀려져 실제 API 5xx 가 희석됐다 (전체 기준 최대 10.6% vs /api 기준 25%) - OnOBackendHigh5xxRatio 의 분자·분모를 uri=~"/api.*" 로 한정 - API 트래픽이 하루 약 780건(분당 0.5건)이라 비율 + for:10m 로는 짧게 터지는 장애를 못 잡는다. 실제로 태그 중복 500 12건, memo 500 3건이 모두 이 규칙을 통과했다 - OnOBackendApi5xxBurst 추가: 10분 내 /api 5xx 3건 이상이면 즉시 발화 과거 14일 데이터로 확인 시 태그 장애 구간에서만 참이었다 - 반영하려면 restart-monitoring 워크플로 실행 필요
- 테스트 워커 기본 힙(512m)으로는 @SpringBootTest 컨텍스트가 캐시에 쌓이며 OOM 이 났고, 워커가 죽으면서 40개 클래스 중 3개(66건)만 돌고 빌드가 끝나고 있었다. maxHeapSize 3g 로 해결 (283건 중 268건 실행, 실행 시간은 오히려 4분 7초 → 2분 7초) - performance 패키지 2개 클래스 제거 어서션이 하나도 없는 측정 스크립트이고, PersonalPerformanceTest 는 하드코딩된 유저 2252 에 의존하며 폴더 500 × 문제 100 = 5만 건을 인메모리 H2 에 밀어넣어 스위트 전체를 죽이고 있었다. LargePerformanceTest 는 MySQL 전용 multi-table DELETE 문법이라 H2 에서 실행 자체가 불가능했다 (프로젝트 전체에서 어서션 없는 테스트 메서드 14개 중 13개가 이 두 클래스였다) - 두 클래스만 쓰던 BulkDataGenerator 도 함께 제거 - JaCoCo 추가: QueryDSL Q 클래스와 부트 진입점은 측정에서 제외, 테스트가 실패해도 리포트가 나오도록 finalizedBy 로 연결 현재 라인 커버리지 57.8% / 분기 38.8%
- JwtTokenizer.validateAccessToken 이 ExpiredJwtException 을 포함한 모든 예외를 ApplicationException 으로 바꿔 던지고 있어, JwtTokenFilter 의 catch(ExpiredJwtException) 은 영영 실행되지 않는 죽은 코드였다. 만료 토큰은 그 아래 catch(Exception) 으로 떨어져 1007 이 됐다 - 프론트(HttpService.dart:292)는 1005 일 때만 토큰 갱신을 재시도하고, 1000~1999 의 다른 코드는 인증 실패로 보고 강제 로그아웃시킨다. 즉 리프레시 토큰이 멀쩡한 사용자가 로그아웃됐다 - validateAccessToken 을 validateRefreshToken 과 같은 형태로 맞춰 만료(1005)와 검증 실패(1009)를 분리. 만료가 아닌 실패까지 1005 로 뭉뚱그리면 갱신할 이유가 없는 토큰에도 프론트가 갱신을 건다 - JwtTokenFilter 는 ApplicationException 을 잡아 errorCase 를 그대로 전달 - 로컬 실측: 만료 401/1005, 위조 401/1009, 토큰 없음 401/1007, 1005 수신 후 refresh → 재요청 200 까지 확인 - 프론트 배포 없이 백엔드만으로 해결된다 - JwtTokenizerValidationTest 3건 추가
- 커서 페이징과 컬렉션 fetch join 을 한 쿼리에 같이 쓰면 조인으로 행이 뻥튀기돼 Hibernate 가 SQL 에 LIMIT 을 걸지 못하고, 조건에 맞는 행을 전부 읽은 뒤 자바에서 잘라냈다 (실행 중 경고 HHH90003004 로 확인). size=20 을 보내도 폴더의 모든 문제와 이미지가 적재됐다 - 폴더/태그/제목 커서 조회 3개를 2단계로 분리: 1단계에서 id 만 limit 으로 뽑고, 2단계에서 그 id 로 fetch join 한다 - 폴더 조회는 14일간 1,171건으로 두 번째로 많이 불리는 API 라 데이터가 쌓일수록 악화되는 구조였다 - 회귀 테스트: 폴더에 5건이 있어도 size=2 요청 시 Problem 엔티티 3건만 적재되는지 검증 (쿼리 횟수는 예전에도 1번이라 지표가 되지 못한다)
- 알림 토글은 PATCH /api/users/notification-settings 로 정상 저장되지만 GET /api/users 응답에 notificationEnabled 가 없었다 - 프론트(UserInfoModel.dart)는 값이 없으면 true 로 복원해, 앱을 다시 켜면 스위치가 켜져 보였다 사용자가 "안 꺼졌네" 하고 다시 누르면 실제로 알림이 켜진다 - 필드를 추가하는 변경이라 하위 호환을 깨지 않고, 프론트는 이미 이 값을 읽고 있어 앱 배포도 필요 없다 - UserResponseDtoTest: 기본값 true, 끈 뒤 false 검증 - 만렙 게이지 어서션도 바로잡았다. 코드는 임계값 600 을 주는데 테스트가 0 을 기대하고 있었다. 현재치와 임계값이 같아야 게이지가 가득 차고, 0 을 주면 프론트가 0 으로 나눈다 - UserControllerTest: 컨트롤러가 touchAndFindUser 로 바뀐 뒤에도 findUser 를 스텁해 응답이 null 이던 것을 수정
- RateLimitAspect 가 한도 종류와 무관하게 ANALYSIS_RATE_LIMIT_EXCEEDED 를 던져서, presigned URL 한도(하루 200회)를 넘긴 사용자에게 "AI 분석 일일 요청 횟수를 초과했습니다" 가 표시됐다 - RateLimitScope 를 두어 한도별 ErrorCase 를 고르게 하고, @ratelimit 에 기본값 없이 요구한다 - FileUploadErrorCase.UPLOAD_RATE_LIMIT_EXCEEDED(429, 2005) 추가 - RateLimitScopeTest: 업로드 한도 문구에 "AI" 가 섞이지 않는지 포함해 검증
깨진 16건 중 10건이 "코드가 바뀌었는데 테스트가 안 따라간" 경우였다. - ProblemServiceTest 4건: S3 삭제가 RabbitMQ 비동기(s3DeleteProducer)로 바뀌었는데 여전히 fileUploadService 직접 호출을 검증하고 있었다 - UserServiceTest: UserService 에 ProblemReviewReminderService 가 추가된 뒤 목이 없어 NPE. 목 추가와 함께 사용자 삭제 시 예약 알림도 취소되는지 검증을 넣었다 - PracticeNoteServiceTest 2건: 테스트 컨텍스트에 Quartz 스케줄러가 없어 SchedulerException 이 났다. 알림 스케줄링은 이 테스트의 관심사가 아니라 PracticeNotificationScheduler 를 목으로 대체 - LearningReportServiceAiTest: 실제 Redis 캐시에 값이 있으면 AI 호출 없이 캐시가 반환돼 실행 순서와 외부 Redis 상태에 좌우됐다. RedisSingleDataService 를 목으로 대체해 항상 캐시 미스 - FolderApiIntegrationTest: 루트 폴더 이름 변경이 막힌 뒤(ROOT_FOLDER_CANNOT_UPDATE)에도 루트를 수정하려 해 400 이 났다. 하위 폴더로 검증하도록 변경
앞선 커밋(388b9d1)에서 build.gradle 이 빠져, 성능 테스트만 삭제되고 정작 원인이던 힙 설정과 커버리지 설정이 반영되지 않았다. - maxHeapSize 3g: 기본 힙(512m)으로는 @SpringBootTest 컨텍스트가 쌓이며 OOM 이 나 워커 JVM 이 죽고 40개 클래스 중 3개(66건)만 돌고 빌드가 끝났다 - JaCoCo: QueryDSL Q 클래스와 부트 진입점 제외, 테스트가 실패해도 리포트가 나오도록 finalizedBy
- 5개 테스트 클래스(@WithMockCustomUser 기본값 포함)가 userId 1L 을 공유해 같은 H2 DB 에서 서로의 폴더/문제를 보고 있었다. 단독 실행하면 통과하고 전체 실행에서만 깨지는 실패들의 원인이었다 - ProblemServiceTest 가 매 테스트마다 자기 유저를 만들어 쓰도록 변경. "다른 유저" 검증용 하드코딩 2L, 폴더 하드코딩 1L 도 픽스처 기준으로 교체 - 호출부가 주석 처리된 채 검증만 남아 있던 죽은 테스트 2건 제거 (registerProblemImageData / updateProblemImageData 는 서비스에서 사라진 메서드다) - findAllProblems 가 최신순 정렬로 바뀐 뒤에도 테스트는 오름차순으로 비교하고 있었다 FolderServiceTest / FolderRepositoryTest 는 같은 방식으로 격리하면 다른 클래스가 남긴 데이터를 전제로 쓴 단언들이 드러나(예: 자기 픽스처는 5개인데 6개를 기대) 클래스를 다시 써야 해서 이번에는 건드리지 않았다. 전체 실패 16건 → 3건.
[Fix] 프로덕션 500 오류 5건 수정 및 테스트 실행 정상화
기존 스위트는 274개 중 74개만 돌고 OutOfMemoryError로 중단됐고, 프로덕션에서 나는 에러를 구조적으로 재현할 수 없는 상태였다. - H2(MODE=MySQL) 제거, 프로덕션과 동일한 MySQL 8 컨테이너에서 실행 H2에서는 컬럼 길이 초과, 유니크 인덱스 충돌, 콜레이션 차이가 재현되지 않았다 - Redis, RabbitMQ도 컨테이너로 전환해 로컬 도커 상태에 의존하지 않도록 함 - IntegrationTestSupport 도입: 통합 테스트가 동일한 애노테이션 조합을 쓰게 해 30개 가까이 뜨던 스프링 컨텍스트를 하나로 수렴시킴. OOM의 직접 원인이었다 - DatabaseCleaner 도입: 테스트마다 DB를 비워 실행 순서 의존성 제거 "expected: 1 but was: 6" 류 실패 3건이 여기서 비롯됐다 - FCM/S3/OpenAI/Discord 외부 연동을 베이스에서 일괄 목 처리 - JaCoCo 적용, 테스트 힙 2g 상향 - Testcontainers 1.21.3 고정 및 api.version 1.44 지정 Docker Engine 29가 docker-java 기본값인 API v1.32를 400으로 거부한다 - 더미 데이터를 대량 생성하던 성능 테스트와 Random*Generator 제거 [Test] Sentry 프로덕션 장애 회귀 테스트 추가 (의도적으로 red) 각 테스트가 Sentry 이슈 하나에 대응한다. 원인 수정은 후속 커밋에서 진행한다. - JAVA-SPRING-BOOT-4Z: 태그 동시 생성 시 유니크 인덱스 충돌로 500 (24건) - JAVA-SPRING-BOOT-5A: 256자 이상 메모 저장 시 Data truncation으로 500 (3건) - registerProblem에 folderId가 null이면 500으로 나가는 경로
기존 ci-dev/ci-prod 는 workflow_dispatch 수동 실행이고 `./gradlew build -x test` 로 테스트를 건너뛴다. main/develop 으로 합치기 전 검증하는 게이트가 없었다. - .github/workflows/test.yml: main/develop 대상 PR과 push에서 테스트 실행 - Testcontainers 로 DB/Redis/RabbitMQ 를 러너 안에서 격리 기동. 외부 인프라·시크릿 불필요 - 러너의 실제 Docker API 버전을 읽어 넘김 (docker-java 기본 v1.32 는 최신 엔진이 거부) - 실패한 테스트 목록과 커버리지 표를 잡 요약에 출력, 리포트는 아티팩트로 보관 - 배포 워크플로는 손대지 않았다. 검증만 분리해 책임진다 - DefaultColumnLengthGuardTest: 길이 미지정 문자열 컬럼을 고정 목록으로 감시 @column(length) 를 빼먹으면 Hibernate 가 조용히 varchar(255) 를 만든다. 프로덕션의 memo Data truncation 이 정확히 이 경우였고, 앞으로 새 컬럼이 검토 없이 추가되면 여기서 걸린다
MySQL만 컴포즈로 뜨고 Redis·RabbitMQ는 docker run 으로 따로 떠 있어서 `docker compose ps` 에 셋이 같이 안 잡혔다. 상태 확인을 매번 docker ps 로 눈으로 해야 했고, 기존 docker-compose.yml 은 redis 6379 / rabbitmq 5672 로 선언돼 있어 실제 포트(6380 / 5673)와도 어긋나 있었다. - docker-compose.local.yml 추가: MySQL/Redis/RabbitMQ 세 개만 묶고 전부 헬스체크와 restart 정책을 붙임. 포트는 application-local.yml 기준으로 맞춤 - MySQL 볼륨은 기존 backend_mysql_data 를 이름으로 명시해 데이터를 그대로 이어받음 - 비밀번호는 .env 에서 읽고 파일에는 값을 남기지 않음 - Makefile 추가: up / down / status / logs / test / coverage make status 한 번으로 세 서비스의 상태·헬스·포트를 확인할 수 있다 - docker-compose.yml 제거: 어떤 워크플로도 참조하지 않고(배포는 dev/prod 파일만 사용) 포트가 실제와 달라 오해만 유발했다
테스트 274개 -> 1396개. 기존 스위트는 74개에서 OOM으로 중단됐으나 이제 완주한다. tag, problemsolve 등 테스트가 0개이던 도메인과 auth 토큰 계약, 예외 처리 경로를 채웠다. 라인 커버리지 74.7%, 클래스 커버리지 88.2%. 프로덕션 결함 수정: - 검증 애노테이션이 전부 무동작이었다. jakarta.validation-api 만 클래스패스에 있고 구현체가 없어 @NotNull/@Valid 20곳이 무시됐고, null 요청이 그대로 서비스에 들어가 500이 됐다. spring-boot-starter-validation 추가 - MethodArgumentNotValidException 핸들러가 첫 인자로 BindingResult 를 받고 있었다. @ExceptionHandler 가 지원하지 않는 인자라 핸들러가 해석되지 못했고, 검증 실패가 400 이 아니라 500 으로 나갔다 - DataIntegrityViolationException 핸들러 부재로 태그 중복·컬럼 길이 초과가 500이 됐다. 중복은 409, 길이 초과는 400 으로 분류 - 태그: 소프트 삭제된 태그가 유니크 인덱스에서 이름을 계속 점유해, 지웠다가 같은 이름으로 다시 만들면 Duplicate entry 로 500. 삭제된 행을 복구하도록 수정하고 동시 생성 경합도 처리 - problem.memo 길이 제약 부재로 256자 이상 저장 시 Data truncation 500. 길이 지정 + 입력 검증 추가 - registerProblem 의 folderId null, ProblemSolve 의 problemId/answerStatus null 이 findById(null) 로 500. 400 으로 거절 - refresh_token 컬럼이 varchar(255)인데 발급 JWT 실측 244자. 512로 확장 - identifier 가 비어도 가입이 통과돼 로그인마다 새 계정이 생성됐다. 검증 추가 - CryptoConverter 가 null 입력에서 NPE. AttributeConverter 계약대로 null 통과 - getReviewDueProblems 의 N+1 제거. mappedBy OneToOne 이 행마다 존재 확인 쿼리를 발생시켜 프로젝션 조회로 교체 - Discord 웹훅 dedup 맵이 무한 증가하던 것에 상한 적용 테스트 기반 수정: - 컨테이너 접속 정보를 ContextCustomizerFactory 로 전역 주입. 베이스 클래스를 상속하지 않은 테스트가 datasource 를 못 받아 컨텍스트 로드 실패가 138건 발생했다 - authenticateAs 를 TestSecurityContextHolder 기반으로 교체. SecurityContextHolder 는 MockMvc 요청에서 필터가 덮어써 인증이 필요한 요청이 전부 401 이 됐다 - DatabaseCleaner 가 테이블을 비우는 방식을 DDL 에서 DML 로 교체. 테스트마다 31개 테이블에 DDL 을 거는 방식은 스위트를 완주 불가로 만들었다. 시퀀스 테이블은 시드가 사라지지 않도록 대상에서 제외 - jacocoTestReport 의 dependsOn test 제거. 테스트가 실패하면 리포트가 건너뛰어져 낡은 커버리지가 남았다 Flyway 마이그레이션 V23~V25 추가 (컬럼 확장, 미적용): - ALGORITHM=INPLACE, LOCK=NONE 명시로 COPY 로 내려갈 때 즉시 실패하게 함 - 테이블별로 파일 분리해 중간 실패 시 상태 추적이 가능하도록 함 - 실패 복구 절차와 롤백 비용을 파일에 명시
- ProblemTestSupport.saveImageData 를 트랜잭션 안에서 관리 엔티티로 다루도록 수정. ProblemImageData.updateProblem 이 연관관계 반대편의 지연 컬렉션을 건드려 detached 상태에서 LazyInitializationException 이 났다. 프로덕션 코드는 @transactional 안에서 호출하므로 해당 없음 - 분석 회귀 테스트의 전제 수정. Problem 은 소프트 삭제지만 ProblemAnalysis 는 cascade = ALL 이라 행이 실제로 삭제된다. "NOT_STARTED 로 남아있다" 대신 검증 의도인 "FAILED 로 덮어쓰지 않는다" 를 확인하도록 변경 - ProblemControllerTest 의 인증 해제를 베이스의 clearAuthentication 으로 통일
- ChallengeNotificationScheduler 가 계산한 시각을 그대로 써서, 앱이 마감을 그날 23:59:59 로 보내는 D-1 알림은 거의 항상 밤 23:59 에 나갔고, 중간 알림은 챌린지를 만든 시각을 따라가 새벽에 걸릴 수 있었다. #276 으로 이 잡이 실제로 발화하게 되면서 실사용자에게 한밤중 푸시가 나갈 수 있는 상태였다 - ChallengeNotificationTimePolicy 를 두고 계산된 시각을 같은 날 09:00 과 18:00 중 가까운 쪽으로 맞춘다. 맞춘 시각이 이미 지났으면 다음 기준 시각으로 넘기고, 그것이 마감을 넘기면 그 알림은 보내지 않는다 - 기간이 하루 이하인 챌린지는 D-1 시점이 시작 시각보다도 일러서, 억지로 미루면 중간 알림과 같은 시각에 두 번 나간다. 그래서 D-1 을 보내지 않는다 - 발송 시각의 기준을 Asia/Seoul 로 맞춘다. 트리거는 Asia/Seoul 로 등록하면서 현재 시각만 JVM 기본 시간대로 읽고 있었고, PersistenceRulesTest 의 예외 목록에서도 뺐다 - 발송 시각 계산 테스트 11개를 추가했다. 마감이 23:59:59 인 실제 케이스, 새벽에 걸리는 중간 알림, 기간이 하루 이하인 챌린지, 마감이 임박해 보낼 수 없는 경우를 담았다 Closes #287
[Fix] 챌린지 알림을 아침 09시나 저녁 18시에만 보낸다
- QuartzSchemaInitializer 를 QuartzConfig 에 직접 등록해 자동 구성의 초기화 빈을 대체했다. 자동 구성 빈은 @ConditionalOnMissingBean(QuartzDataSourceScriptDatabaseInitializer.class) 로 붙어 있어서, 같은 타입 빈이 있으면 물러난다
- 이 빈은 QRTZ_TRIGGERS 가 이미 있으면 초기화 스크립트를 건너뛴다. dev/prod 의 initialize-schema: always 가 기동할 때마다 DROP TABLE 11 줄로 시작하는 tables_mysql_innodb.sql 을 실행해서, 사용자가 등록한 복습노트 알림 트리거(trigger-{practiceId})와 챌린지 알림 트리거가 배포마다 사라졌다
- 테이블 존재 확인에 실패하면 "있다" 로 보고 스크립트를 실행하지 않는다. 상태를 모르는 채로 되돌릴 수 없는 DROP 을 도는 쪽이 건너뛰는 쪽보다 훨씬 위험하기 때문이다
- QRTZ_ 스키마를 V45__create_quartz_tables.sql 로 옮겼다. 전부 CREATE TABLE IF NOT EXISTS 라 테이블이 이미 있는 dev/prod 에서는 아무 일도 하지 않고, 없는 환경에서만 만든다. CREATE INDEX 는 MySQL 에 IF NOT EXISTS 가 없어서 같은 이름의 인덱스를 CREATE TABLE 안으로 옮겼다
- 초기화 빈과 마이그레이션 동작을 고정하는 JDBC 테스트 5개를 추가했다
Closes #288
[Fix] Quartz 스키마 초기화가 기존 QRTZ_ 테이블을 지우지 않게 한다
- 같은 키를 동시에 INSERT 하면 InnoDB 가 중복 확인을 하려고 상대 레코드에 공유 잠금을 걸고 기다리다 교착이 난다 이때는 Duplicate entry 가 아니라 CannotAcquireLockException(PessimisticLockingFailureException)으로 올라와 중복 처리에 걸리지 않고 그대로 500 이 됐다 - registerToken 의 재시도를 중복 키와 잠금 실패 모두로 넓히고, 최대 3번까지만 시도하도록 횟수를 못박았다 그래도 실패하면 행이 이미 있는지 확인하고, 없으면 마지막 예외를 그대로 올린다 - 교착 예외를 한 번 던지는 라이터로 재시도 경로를 고정한 테스트를 추가했다 단위 5개와 실제 MySQL 통합 2개이고, 재시도 없이 돌리면 6개가 실패한다
…etry [Fix] FCM 토큰 등록이 교착으로 깨져도 다시 시도한다
- CustomAuthenticationEntryPoint 가 공개 경로를 /api/auth 접두사로 판정해서, 보호 대상인 POST /api/auth/logout 까지 인증 실패 응답을 쓰지 않고 그냥 반환했다 SecurityConfig 는 이 경로에 GUEST, MEMBER, ADMIN 을 요구하는데 빈 200 이 나가서 앱은 로그아웃에 성공했다고 믿고, 서버는 세션도 블랙리스트도 건드리지 않았다 - 공개 경로 판정을 정확 매칭 집합과 / 로 끝나는 접두사 목록으로 바꿨다. AuthController 를 전수로 보면 /api/auth 아래에서 공개여야 하는 것은 아직 토큰이 없는 상태로 부르는 세 가지뿐이다 게스트 로그인(/api/auth/signup/guest), 소셜 로그인과 가입(/api/auth/signup/member), 토큰 갱신(/api/auth/refresh) - /login 과 /swagger-ui 도 접두사 판정이라 /loginx 같은 옆 경로가 공개로 샜다. 아래에 보호 경로가 없어서 위험하지는 않았지만 같은 방식으로 좁혔다 /grafana 와 /prometheus 는 이미 정확 매칭과 / 로 끝나는 접두사라 그대로 뒀다 - 인증 실패한 로그아웃은 이제 기존 에러 응답과 같은 형식(errorCode 포함)으로 나간다. 토큰 없음 1007, 쓰레기 토큰 1009, 만료 1005 로 사유가 살아 있어서 앱이 갱신할지 재로그인할지 그대로 구분한다 - 로그아웃 거절과 공개 경로 판정을 고정하는 테스트를 추가했다. 접두사 판정으로 되돌리면 6개가 실패한다 Closes #299
- PracticeNotificationJob 이 data 에 practiceId 만 담고 있었다. 앱은 data["type"] 하나로만 화면을 고르기 때문에, 사용자가 직접 켠 복습노트 알림을 눌러도 아무 화면이 열리지 않았다. practice_note_reminder 를 넣었다 - 알림 type 값이 발송하는 곳마다 문자열로 흩어져 있어서 NotificationType 상수 클래스 하나로 모았다. 앱과 맞춰 둔 계약이라 오타가 나도 컴파일이 통과하는 자리였다 - 표기를 소문자 스네이크로 통일했다. CHALLENGE_NOTIFICATION, CHALLENGE_COMPLETED, SHARED_PROBLEM, SHARED_PROBLEM_REACTION, SHARED_PROBLEM_COMMENT 다섯 개가 challenge_notification, challenge_completed, shared_problem, shared_problem_reaction, shared_problem_comment 로 바뀐다 - 운영 중인 4.0.0 앱은 review_due, reengagement, reengagement_monthly 세 개만 분기하고 나머지는 무시하므로 지금 앱에서 깨지는 곳은 없다 - roomId, sharedProblemId, practiceId 같은 다른 데이터 키는 프론트가 그대로 읽으므로 건드리지 않았다 - 알림 경로마다 정해진 type 이 실제로 담겨 나가는지 고정하는 테스트를 추가했다. 복습노트 알림 1개와 스터디룸 공유/반응/댓글 3개다 Closes #301
[Fix] 인증 없는 로그아웃이 200 으로 나가지 않게 한다
[Fix] 복습노트 알림에 type 을 넣고 알림 type 표기를 소문자로 통일합니다
- deleteUserById 가 FCM 토큰과 폴더, 문제, 복습노트는 지우면서 refresh_token 행은 남겨 둬서, 탈퇴한 뒤에도 남은 리프레시 토큰으로 POST /api/auth/refresh 가 200 으로 새 토큰을 내줬다 앱은 자동 로그인에 성공해 로그인 상태로 들어가는데 모든 API 가 404 라 화면이 비고 로그아웃 분기에도 닿지 못했다 - RefreshTokenRepository.deleteByUserId 는 선언만 있고 부르는 곳이 없었다. 벌크 DELETE 한 문장으로 바꾸고 탈퇴 경로에서 부른다 로그인할 때마다 세션 행이 쌓이는 구조라 기기 수만큼 있는 행을 한 번에 지운다 - 지우는 위치는 무거운 정리를 모두 끝낸 뒤, 사용자 소프트 삭제 직전이다. 갱신 요청이 같은 행을 다투기 때문에 앞에 두면 문제 삭제가 도는 동안 그 행 잠금을 계속 쥐고 있게 된다 - 액세스 토큰 블랙리스트는 건드리지 않았다. 탈퇴 경로에는 액세스 토큰이 들어오지 않고 관리자 삭제 경로에는 아예 없으며, 블랙리스트 키가 토큰 문자열이라 사용자 기준으로 지울 수단도 없다 탈퇴 계정의 액세스 토큰은 #300 범위로 뒀다 - 탈퇴 후 갱신이 1002 로 거절되는 것, 기기별 세션 행이 모두 사라지는 것, 다른 사용자 세션은 그대로 남는 것을 통합 테스트로 고정했다 Closes #298
[Fix] 탈퇴할 때 그 사용자의 리프레시 토큰 행을 지운다
- 탈퇴해도 이미 발급된 액세스 토큰은 만료 전(최대 30분)까지 살아 있어서, 사용자 존재를 확인하지 않는 엔드포인트가 그대로 동작했다. POST /api/fcm/token 이 200 으로 성공해 #271 로 지운 fcm_token 행이 되살아났고, 그 기기를 이어 쓰는 사람에게 탈퇴 계정 앞으로 가는 알림이 뜰 수 있었다. 태그 생성, 복습노트 등록, 훈장 조회, 기분 기록도 같은 상태였다 - JwtTokenFilter 가 클레임을 읽은 직후에 사용자 단위 블랙리스트(UBL:{userId})를 확인하게 했다. 기존 블랙리스트는 키가 토큰 문자열이라 탈퇴 요청에 실린 그 한 장밖에 못 막고, 다른 기기가 들고 있는 토큰은 그대로 남는다 - 매 요청마다 DB 로 사용자 존재를 확인하는 방식 대신 이쪽을 골랐다. 필터가 이미 블랙리스트 조회로 Redis 를 타고 있어서 DB 조회는 늘지 않고, 탈퇴 경로가 UserService.deleteUserById 하나뿐이라 막는 범위도 같다 - 표식은 삭제 트랜잭션이 커밋된 뒤에 심는다. 롤백됐는데 토큰을 먼저 막으면 계정은 멀쩡한데 재로그인으로도 못 푸는 사용자가 생긴다 - 표식 수명은 액세스 토큰 30분이 아니라 리프레시 토큰 유효 기간에 맞췄다. 탈퇴해도 refresh_token 행이 남아 POST /api/auth/refresh 로 액세스 토큰을 계속 새로 받을 수 있어서, 30분짜리로 두면 그 뒤에 다시 뚫린다 - 실제 Authorization 헤더로 필터를 태우는 통합 테스트 4개, 필터 단위 테스트 2개, 탈퇴 경로 단위 테스트 2개를 추가했다. 인증 컨텍스트를 직접 세우는 기존 테스트는 필터를 지나지 않아 회귀를 못 잡는다 Closes #300
- PracticeNotificationScheduler.convertDtoToCron 이 repeatType 이 weekly 인데 weekDays 가 비어 있으면 매일 크론("0 m H ? * *")으로 되돌렸다. 사용자는 특정 요일만 고른 줄 아는데 매일 알림을 받았고, 요청이 잘못됐다는 신호도 없었다
- PracticeNoteErrorCase 에 PRACTICE_NOTIFICATION_WEEK_DAYS_REQUIRED(400, 6003) 를 추가했다. 6001/6002 다음 번호라 기존 코드와 겹치지 않는다
- 검증을 요청 진입부인 PracticeNoteService.registerPractice 와 updatePracticeInfo 에 뒀다. 스케줄러에만 두면 복습노트 저장과 Quartz 잡 삭제가 먼저 일어난다
- 스케줄러의 updateNotification 은 잡을 지운 뒤 다시 등록하는데, Quartz 삭제는 서비스 트랜잭션과 같이 롤백되지 않는다. 그래서 지우기 전에 검증한다
- daily 와 그 밖의 repeatType(null 포함)은 지금처럼 매일로 둔다. 구버전 앱 요청까지 막으면 쓰고 있던 알림이 통째로 끊긴다
- 스케줄러 단위 테스트 3개와 복습노트 통합 테스트 3개를 추가·수정했다
Closes #302
- 챌린지는 멤버 전원이 만들 수 있는데 삭제는 방장만 할 수 있어서, 일반 멤버가 만든 챌린지를 본인이 지우지 못했다 - study_room_challenge 에 작성자가 없어 V46 으로 created_by_user_id 를 추가한다. 기존 행은 방장으로 채워서 마이그레이션 전후로 누구도 권한을 새로 얻거나 잃지 않는다 - deleteChallenge 는 멤버 검증을 먼저 하고(비멤버에게 챌린지 존재를 알리지 않는다), 방장이거나 작성자일 때만 지운다. 거절은 기존 코드를 쓴다 - 삭제할 때 Quartz 알림 트리거를 함께 취소하는 기존 동작은 그대로다 - 응답에 createdByUserId 와 canDelete 를 더했다. 앱이 삭제 버튼을 언제 보여 줄지 정할 수 있어야 한다 - 작성자 본인, 방장, 다른 멤버, 멤버 아님 네 가지 권한 조합과 V46 적용을 테스트로 고정했다 Closes #310
[Fix] 탈퇴한 계정의 액세스 토큰으로 FCM 토큰이 다시 등록되지 않게 한다
[Fix] 요일을 고르지 않은 주간 복습 알림을 400 으로 거절한다
[Fix] 챌린지를 만든 본인도 지울 수 있게 한다
- 같은 계정을 신버전과 구버전 기기에서 번갈아 쓰면, 신버전 요청이 남긴 mission_log 행이 구버전 요청의 "이미 적립했다" 판정에 걸려 그 활동이 적립으로도 진행도로도 남지 않았다 - mission_log 행은 그대로 남긴다. DAU, 순 방문자, 일자별 방문 수, 활성 사용자 목록, 복습 로그 조회, 사용자별 기록, 훈장 '개근'이 전부 이 테이블만 본다. 행을 안 남기면 그 지표들이 통째로 0 이 된다 - 대신 MissionLog.point 가 "이 기록으로 자동 적립이 돌았는가"를 담는다. 적립이 돌면 정가, 돌지 않으면 0 이다. 상한에 걸려 깎이기 전 정가를 넣는 이유는 실지급액을 넣으면 상한 계산이 달라져 구버전만 쓰는 사용자의 동작이 바뀌기 때문이다 - 중복 방지 판정에 accruedOnly 를 뒀다. 자동 적립이 도는 요청은 적립된 행만 보고, 진행도를 올리는 요청은 지금처럼 모든 행을 본다. 뒤쪽을 함께 풀면 신버전만 쓰는 사용자가 앱을 열 때마다 출석 진행도가 다시 오른다 - 하루 200점 상한(getPointSumToday)은 point 합을 그대로 보므로, 지급되지도 않은 신버전 활동이 상한을 갉던 것도 함께 사라진다 - 관리자 복습 로그 화면의 점수는 저장값이 아니라 미션 타입의 정가를 쓴다. 화면의 뜻이 "이 행동의 값어치"라 적립 여부와 무관하고, 보이는 값은 지금과 같다 - 출석, 오답노트 등록, 복습 기록, 세트 완료 네 가지의 재현과 신버전 단독, 구버전 단독 사용자의 동작을 MissionCrossVersionAccrualTest 로 고정했다 Closes #318
- #302 로 들어간 검증이 스토어에 나가 있는 구버전 앱 사용자를 막았다. 구버전 앱에는 요일을 고르라는 클라이언트 검증이 없어서 "매주" 만 고른 저장이 그대로 올라오고, 예전 서버는 그 요청을 매일 크론으로 받아 줬다. 그래서 요일이 빈 주간 알림 행을 가진 복습 세트는 제목만 바꿔도 계속 400 이 나 수정할 방법이 없었다 - PracticeNotificationWeekDayPolicy 를 새로 두고 X-App-Version 이 기준 버전(ono.practice-note.week-days-required-version, 기본 4.0.0) 이상인 요청만 400/6003 으로 막는다. 헤더가 없거나 읽을 수 없는 값이면 구버전으로 본다 - 미션의 LegacyAccrualPolicy 는 읽기만 하고 그대로 쓰지 않았다. 거기 붙은 비상 스위치(ono.mission.legacy-accrual.enabled)를 내리면 복습 알림 검증까지 같이 움직이기 때문이다. 공용으로 뺀 것은 AppVersionResolver.isAtLeast 의 버전 비교까지다 - PracticeNotificationScheduler.convertDtoToCron 의 매일 폴백을 되살렸다. 구버전 요청은 예전 서버와 같게 매일로 저장된다. 스케줄러에 있던 검증은 뺐다. 진입부인 PracticeNoteService 에서 이미 걸러지고, 거기서 막아야 Quartz 잡 삭제가 일어나지 않는다 - POST(registerPractice)와 PATCH(updatePracticeInfo) 두 경로 모두에 적용했다 Closes #317
[Fix] 신버전이 남긴 mission_log 가 구버전 기기의 적립을 막지 않게 한다
[Fix] 요일 없는 주간 복습 알림 검증을 앱 버전으로 가른다
- #233 을 막으려고 넣은 lockAllByUserId 가 사용자 폴더 전체를 배타 잠금으로 잡아서, 삭제와 아무 상관 없는 폴더로 들어오는 문제 등록까지 삭제가 끝날 때까지 줄을 섰다. 기다리는 요청이 커넥션을 하나씩 물고 있어서 Hikari 풀(기본 10개)이 바닥나면 다른 사용자의 단순 조회까지 connection-timeout 30초를 채우고 실패했다 - deleteFoldersWithProblems 는 이제 삭제 대상 서브트리만 잡는다. lockAllByIdIn 으로 대상 폴더를 기본 키로 잠그고, lockAllByParentFolderIdIn 으로 한 단계씩 내려가며 하위 폴더를 잠근다. 내려가는 조회를 전부 잠금 조회로 둬야 REPEATABLE READ 스냅숏이 박히지 않아서, 잠금을 기다리다 커밋된 등록도 삭제 대상에 그대로 들어온다 - 그 사이에 새 폴더가 끼어들 자리는 없다. 어떤 폴더 아래에 폴더를 만들거나 옮기려면 findParentFolderForShare 로 그 부모를 공유 잠금해야 하는데, 우리가 배타 잠금을 쥔 뒤에는 기다렸다가 삭제된 부모를 보고 FOLDER_NOT_FOUND 로 거절된다 - 전체 폴더 삭제(deleteAllUserFoldersWithProblems)는 어차피 사용자 폴더 전부가 대상이라 lockAllByUserId 를 그대로 뒀다 - 복습 알림 취소가 문제마다 problem_id 범위 UPDATE 를 돌면서 uq_problem_review_reminder_seq 인덱스 끝의 갭까지 next-key lock 으로 잡고 있었다. problem_id 는 계속 커지므로 그 갭은 대개 supremum 이고, 폴더 삭제가 커밋될 때까지 그 뒤에 등록되는 모든 문제의 예약 INSERT 가 사용자와 무관하게 막혔다. 취소 대상을 먼저 읽고 기본 키로 UPDATE 하도록 바꿨다 - performance_schema 로 잠금 범위를 고정하는 FolderDeleteLockBlastRadiusTest 를 추가했다. 삭제 트랜잭션이 잠그는 folder 행이 삭제 대상 서브트리뿐인지, idx_folder_user_id 에 잠금이 안 잡히는지, 무관한 폴더와 다른 계정의 등록이 안 막히는지를 본다 - 고아 데이터 쪽 계약은 FolderDeleteProblemRegisterRaceTest 9개가 그대로 통과한다 Closes #319
[Fix] 폴더 삭제 잠금이 다른 폴더와 다른 사용자에게 번지지 않게 한다
- SMOKE_PROD_ACCESS_TOKEN_SECRET, SMOKE_DEV_ACCESS_TOKEN_SECRET 으로 같은 값을 한 벌 더 두고 있었다. 배포 워크플로가 서버에 넘기는 키는 JWT_ACCESS_TOKEN_SECRET 하나이고 dev 와 prod 가 같은 값을 쓴다 - 값이 세 군데로 흩어지면 서명 키를 바꿀 때 스모크 쪽을 빠뜨리기 쉽다. 그러면 서버는 멀쩡한데 인증 확인만 실패해서 알림이 계속 울린다 - 스모크 워크플로가 JWT_ACCESS_TOKEN_SECRET 을 읽게 바꿨다. 환경마다 계정이 다른 SMOKE_PROD_USER_ID 와 SMOKE_DEV_USER_ID 는 그대로 둔다 - README 의 설정 순서와 키 교체 안내도 같이 고쳤다
[Chore] 스모크 테스트가 기존 JWT 서명 키 시크릿을 그대로 쓰게 한다
3 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
✔️ 연관 이슈
develop 에 쌓인 47개 PR 을 운영에 반영합니다. 아래 이슈들이 이 배포로 운영에 적용됩니다.
close #233close #237close #239close #260close #261close #262close #265close #267close #269close #270close #271close #272close #273close #287close #288close #298close #299close #300close #301close #302close #310close #317close #318close #319📝 작업 내용
커밋 177개, PR 47개, 파일 446개입니다. 크게 이렇습니다.
👤 사용자 영향
🔌 API 호환성
프론트 세션이 스토어 4.0.0 기준으로 여섯 도메인을 전수 감사했습니다. 구버전이 부르는 엔드포인트 47개 중 사라진 것 0건, 응답 필드 이름 변경·삭제 0건, 에러 코드 매핑 불일치 0건입니다.
🗄️ DB migration
특히 볼 것입니다.
widen_problem_memo: [Fix] V24 마이그레이션에 잠금 대기 제한과 collation 을 넣는다 - 배포 중 오답노트가 멈출 수 있다 #259 가 열려 있습니다. 잠금 대기 제한과 collation 이 없어서 배포 중 오답노트 쓰기가 멈출 수 있습니다. 이 배포에 포함됩니다.dedupe_fcm_token_owner: 탈퇴 사용자의 토큰 행과 중복 토큰을 지웁니다. 되돌릴 수 없습니다. 지워진 사용자는 다시 로그인해야 푸시를 받습니다.fcm_token에 인덱스 추가. 온라인 DDL 이고 안 되면 잠그는 대신 즉시 실패합니다.IF NOT EXISTS로 만듭니다. 이미 있으면 아무것도 하지 않습니다.study_room_challenge에 작성자 컬럼 추가 후 방장으로 백필.🔐 인증/권한
✅ 검증 결과
3541ca8)에 배포해 프론트 세션이 여섯 도메인을 구버전 앱 기준으로 전수 감사했습니다. 배포 차단 3건([Fix] 주간 요일 검증을 앱 버전으로 가른다 - 구버전 사용자가 복습 세트를 수정할 수 없다 #317, [Fix] 신버전이 남긴 mission_log 가 구버전 기기의 적립을 막는다 - 두 기기를 쓰면 XP 가 어느 쪽으로도 안 들어간다 #318, [Fix] 폴더 삭제 잠금이 전역 장애로 번진다 - 등록이 60초 뒤 504 인데 저장은 되고 다른 사용자까지 실패한다 #319)은 수정 후 재확인까지 끝냈습니다.🚀 배포 리스크
QRTZ_TRIGGERS에서TRIGGER_GROUP='CHALLENGE_NOTIFICATION'이면서TRIGGER_STATE='ERROR'인 행을 확인하고 지우면 막을 수 있습니다.↩️ 롤백/대응 방법
CREATE TABLE fcm_token_backup_v43 AS SELECT * FROM fcm_token;로 스냅샷을 떠 두시면 됩니다.배포 후에는 Flyway 가 V46 까지 적용했는지, Quartz 스키마 초기화를 건너뛰었다는 로그가 찍혔는지, 그리고 30초 근처 응답이 있는지 보시면 됩니다.