리뷰 후속: 버그 수정, 쿼리 개선, 스키마 및 어드민 정리 - #9
Merged
Merged
Conversation
테마 삭제가 해당 시간표의 모든 강의를 paletteIndex 0 으로 되돌려 한 색으로 뭉개고 있었다. 구 백엔드는 강의별 색을 그대로 두었다. 대체 팔레트 범위를 벗어난 인덱스만 접어 넣는다. v1 응답의 theme/themeId 는 내장 테마 id 가 1..6 이라는 시드 순서 가정에 의존했다. TimetableDisplay 가 displaysOf 에서 이미 읽은 테마를 함께 들고 다니게 하여 builtinCode 로 판정한다. 같은 변경으로 modifyTimetableTheme 의 테마 중복 조회도 사라진다.
구 snutt 가 포크 이후 추가한 쿨다운(같은 강의의 일기장은 최근 작성 후 12시간 동안 재작성 불가)을 exists 질의로 옮겼다. v2 에서 40028 은 이미 EVALUATION_CONTENT_BLANK 가 쓰므로 새 코드 40036 을 부여하고, v1 응답에는 구 코드 40028 이 나가도록 매핑에 등록했다. /v2/lectures/search 의 limit 에 상한이 없어 인증 없이 한 학기 전체를 한 번에 받아갈 수 있었다. 1..100 으로 제한한다. v1 의 offset 페이징이 offset + limit 만큼 받아 메모리에서 drop 하고 있었다. 저장소에 offset 을 내려 SQL LIMIT 으로 처리한다.
syncUserLectures 가 변경된 강의마다 시간표·엔트리·수업시간·북마크를 따로 조회하고 있었다. 전부 한 트랜잭션 안이라 전체 동기화에서 수천 번의 왕복이 락을 잡은 채 발생한다. AffectedTimetables 로 네 번의 배치 질의로 모으고, removedEntryIds 로 "이미 덮어써 삭제된 엔트리는 더 이상 충돌하지 않는다"는 기존 동작을 유지한다. affectedCourses 가 동기화로 이미 갱신된 courseId 만 담고 있어, 강사 변경으로 연결이 끊긴 구 course 의 title 이 갱신되지 않았다. 동기화 이전 courseId 도 포함한다. DiaryScheduler 가 대표 시간표마다 질의하던 것을 findByTimetableIdIn + findAllById 로 바꾼다. LectureClassTime 이 lecture 연관과 읽기 전용 미러 컬럼을 함께 들고 있었다. 미러 컬럼은 같은 영속성 컨텍스트에서 방금 insert 한 행에 대해 null 이라 배치 질의로 바꾸면 NPE 가 난다. 다른 엔티티와 같이 lectureId 단일 필드로 정리한다.
sendTargetedPushes 가 읽기 전용 트랜잭션 안에서 FCM 호출을 해 그동안 커넥션을 붙잡고 있었다. 트랜잭션을 걷어내고, 대상 조회는 lazy 연관을 건드리지 않도록 findDisabledUserIds / findPushTargets projection 으로 바꾼다. sendPushAndNotification 이 푸시를 보낸 뒤 알림을 저장해, 롤백하면 알림 없는 푸시가 남았다. 저장을 먼저 하고 발송은 afterCommit 으로 옮긴다. DeviceService 의 FCM 토픽 구독/해제도 같은 이유로 afterCommit 으로 옮긴다.
구 백엔드는 /v1/ev-service/** 전부를 EvService.handleRouting 에서 이메일
인증 검사로 막았다. V1CompatCourseSearchController 에 그 게이트가 빠져
/lectures, /lectures/{courseId}/semester-lectures, /tags/search 가 미인증
사용자에게 열려 있었다.
/v1/semesters/status 가 V1Public 이었다. 구 SemesterController 는 토큰을
요구했다.
구 apikey 검증이 io.jsonwebtoken.impl.security.DefaultMacAlgorithm 을
setAccessible 로 생성해 jjwt 패치 릴리스에 깨질 수 있었다. LegacyApiKeyVerifier
로 분리해 Mac 으로 직접 검증한다. alg=HS256, 서명, exp/nbf 판정은 동일하다.
confirmResetByLocalId 가 localId 로 찾은 계정의 이메일로 다시 조회하고 있어,
같은 주소를 인증한 다른 계정이 있으면 엉뚱한 계정의 비밀번호가 바뀌었다.
localId 로 찾은 계정에 바로 적용한다.
@column(name = ...) 과 @joincolumn 이 전부 기본 CamelCaseToUnderscoresNamingStrategy 가 만들어내는 이름을 그대로 다시 적고 있었다. 전부 제거해 이름 결정을 한 곳으로 모은다. 별도 네이밍 전략 설정은 두지 않는다. @table(name = ...) 도 MySQL 예약어인 User -> `user` 하나만 남긴다. theme 테이블은 TimetableTheme 기본값과 맞추기 위해 timetable_theme 으로 바꿨다.
tagPredicate 가 행마다 EXISTS (SELECT ... GROUP BY course_id HAVING AVG(...)) 서브쿼리를 돌리고 있었다. 구 snutt-ev 는 같은 질의를 Redis 페이지 캐시로 가렸지만 v2 에는 캐시가 없다. 이미 avg_rating / eval_count 를 course 에 materialize 하고 있으므로 나머지 네 평균도 같은 방식으로 둔다. CourseAggregateUpdater 가 강의평 생성·수정·삭제 경로에서 이미 호출되고 있어 갱신 지점은 늘지 않는다. 태그 판정은 course 를 PK 로 조인해 컬럼 비교만 한다.
한 행이 두 OS 의 버전 범위를 함께 들고 있어 단일 OS 타깃을 표현할 수 없었다.
한쪽 범위를 비우면 그 OS 전 버전에 적용된다. reactNativeBundleFriends 는
OS 별로 다른 값이 필요해 value JSON 안에 src.ios / src.android 로 분기를
밀어넣고 있었다. os_type 컬럼을 두고 행을 나눈다.
적용 가능한 행이 여럿일 때의 승자가 암묵적이었다. 구 백엔드는 삽입 순서,
v2 는 createdAt 최신을 골라 같은 데이터로 서로 다른 값을 내보낸다. 실데이터에
범위가 동일하고 값이 반대인 행 쌍이 있다. createdAt 최대 + id tie-break 로
명시한다.
요청당 findAll() 하던 것을 ApiTraceTargetRegistry 와 같은 방식의 스냅샷으로
바꾼다. 앱 시작 시 warm-up, 60초 주기 갱신, 쓰기 커밋 직후 갱신이다.
관리자 도구는 /v2/admin 으로 일원화한다. /v2/admin/configs 쓰기가 OsType 을
받아 행과 1:1 이 되고, v1 admin 의 쓰기 13개와 GET /v1/admin/configs/{name}
을 제거한다. 조회 5개만 남는다.
UserStep 이 user_social_auth.provider 에 소문자를 쓰고 있었다.
@Enumerated(EnumType.STRING) 은 FACEBOOK 을 기대하므로 이관된 행을 JPA 로 읽는
순간 예외가 난다. AuthProvider enum 을 써서 대문자로 기록한다. 테스트가 raw SQL
로만 검증해 드러나지 않던 결함이다.
snutt-ev 의 두 변형 모두 과목 단위 id 를 돌려주는 매핑 조회였다. 구 ev 의 lecture 는 학기 구분이 없었고 v2 의 course 가 그것이며, CourseStep 이 id 를 그대로 이어받으므로 evLectureId 값은 보존된다. semesterLectureSnuttId 는 v1 에서 mongo ObjectId 였다. v2 강의 id 는 새로 채번되므로 구 ObjectId 는 해석할 수 없고, v1compat 응답에서 받은 v2 숫자 id 만 풀린다. 못 찾으면 원본과 같이 LECTURE_NOT_FOUND(ev 24001) 를 돌려준다. 응답은 구 LectureIdResponse 와 같이 camelCase 이고 snuttId 는 null 이면 빠진다(default-property-inclusion: non_null).
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.
버그 수정:
paletteIndex가 0으로 초기화되던 문제를 수정했습니다. 구 백엔드와 동일하게 강의별 색상을 유지하며 새 테마 팔레트 크기를 초과하는 인덱스만paletteIndex %= paletteSize로 계산합니다.confirmResetByLocalId에서 계정을 찾은 뒤 이메일 주소로 다시 단건 조회를 실행하여 동일 이메일을 인증한 다른 계정이 있을 때 다른 계정의 비밀번호가 변경되던 문제를 수정했습니다. 식별된 사용자 엔티티에 직접 새 비밀번호를 반영합니다./v1/ev-service/**경로 전체에 이메일 인증을 요구했으나V1CompatCourseSearchController에 인터셉터 어노테이션이 누락되어 미인증 사용자에게 강의평 검색 API(/lectures,/lectures/{courseId}/semester-lectures,/tags/search)가 열려 있던 문제를 수정했습니다. 컨트롤러에@V1EmailVerifiedRequired를 추가했습니다./v1/semesters/status에@V1Public이 지정되어 있던 것을 제거하여 토큰 인증을 요구하도록 맞췄습니다./v2/lectures/search요청의limit파라미터에 최댓값 제한이 없어 한 번의 요청으로 학기 전체 강의를 가져올 수 있던 점을 보완하여@Max(100)제약을 추가했습니다.UserStep) 시user_social_auth.provider에 소문자 문자열을 저장하던 문제를 수정했습니다. JPA 엔티티가 대문자 enum(FACEBOOK,APPLE등)을 기대하므로 이관 데이터를 읽을 때 발생하던 역직렬화 예외를 해결했습니다.courseId도affectedCourses에 포함하도록 수정했습니다.LectureClassTime엔티티에서@ManyToOne lecture와 읽기 전용lecture_id컬럼(insertable = false, updatable = false)을 중복 매핑하여 신규 영속화 직후 1차 캐시에서lectureId가 null이 되던 구조를 변경했습니다. 다른 엔티티와 동일하게var lectureId: Long단일 필드로 통일했습니다.기능 추가 및 호환성 보완:
v1compat에 구현했습니다. 구 ev의lecture는 v2의course에 대응하므로CourseStep에서 보존된 id를 반환합니다.semesterLectureSnuttId의 경우 구 MongoDB ObjectId는 조회가 불가하며 v2 숫자 id 요청만 처리합니다.40036(DIARY_ALREADY_WRITTEN)을 추가하고 v1 응답 매핑에서는 구 코드인40028로 변환되도록 등록했습니다.성능 개선:
SugangSnuSyncService에서 변경 강의마다 개별 조회하던 시간표, 수강 강의 엔트리, 수업 시간, 북마크를 배치 쿼리로 묶어 처리하도록 개선했습니다.DiaryScheduler에서 대표 시간표마다 반복 호출하던 단건 쿼리를findByTimetableIdIn및findAllById일괄 조회로 묶었습니다.EXISTS ... GROUP BY ... HAVING AVG(...))를 실행하던 구조를 개선했습니다.Course테이블에 4개 평점 평균 컬럼(avg_grade_satisfaction,avg_teaching_skill,avg_gains,avg_life_balance)을 추가하고 평가 생성/수정 시점에 통계를 갱신하여 조회 시 컬럼 조건으로 바로 필터링하도록 변경했습니다./v1/configs,/v2/configs요청 시마다 발생하던 DB 전체 조회를 인메모리 스냅샷 캐싱으로 전환했습니다. 서버 기동 시 초기 로딩 후 60초 주기로 갱신하며 어드민 설정 변경 트랜잭션 커밋 직후에도 갱신됩니다.offset페이징 시offset + limit건을 조회한 뒤 애플리케이션 메모리에서drop하던 처리를 SQLLIMIT/OFFSET파라미터로 직접 전달하도록 개선했습니다.트랜잭션 및 이벤트 순서 정리:
PushService.sendTargetedPushes가@Transactional(readOnly = true)안에서 FCM 네트워크 호출을 실행하여 DB 커넥션을 점유하던 문제를 수정했습니다. 외부 I/O 호출을 트랜잭션 외부로 분리하고 발송 대상 조회 쿼리를 프로젝션으로 변경하여 불필요한 연관관계 로딩을 줄였습니다.afterCommit) 실행되도록 처리했습니다. 트랜잭션 롤백 시 알림 데이터 없이 푸시만 발송되는 문제를 방지하며DeviceService의 FCM 토픽 구독/해제도afterCommit으로 옮겼습니다.스키마 및 어드민 정리:
CamelCaseToUnderscoresNamingStrategy)과 동일한 중복@Column(name = ...),@JoinColumn설정을 제거했습니다.@Table어노테이션은 예약어 매핑인User(user)를 제외하고 정리했으며TimetableTheme엔티티의 테이블명을 네이밍 규칙에 맞게timetable_theme으로 변경했습니다.client_config테이블 구조를 OS(os_type) 단위 행으로 분리하여 단일 OS 타깃 설정이 가능하도록 개선했습니다. 조건에 부합하는 설정이 중복될 때의 선택 규칙을createdAt DESC, id DESC로 명시했습니다./v2/admin으로 일원화하고 사용되지 않는 v1 어드민 쓰기 엔드포인트 13개와 단건 설정을 제거했습니다.DefaultMacAlgorithm)에 리플렉션으로 접근하던 방식을 제거하고 표준javax.crypto.Mac을 사용하는LegacyApiKeyVerifier로 분리하여 서명 검증을 직접 하도록 변경했습니다.검증:
./gradlew test --rerun-tasks(core, batch, migration, api, Testcontainers MySQL 환경) 통과ktlintCheck통과