Skip to content

[fix] 자연어 추천 예시 문구 시드 및 release 배포 - #406

Merged
uykm merged 75 commits into
releasefrom
develop
Sep 18, 2026
Merged

uykm merged 75 commits into
releasefrom
develop

Conversation

@uykm

@uykm uykm commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator

🌳이슈 번호

이슈 없이 develop에서 직접 작업 (예시 문구 시드)


☀️어떻게 이슈를 해결했나요?

이번에 새로 담은 변경

GET /api/recommend/example-phrases가 prod에서 빈 배열을 내려주고 있었습니다.
V18이 recommend_example_phrases 테이블만 만들고 값은 dev에 손으로 넣어둔 상태라,
prod DB에는 한 건도 없었습니다(실제 확인: dev 7건 / prod 0건).

V47로 고정 목록(PLACE 4건 · COURSE 3건)을 심었습니다. 전량 교체 방식입니다.

  • content에 유니크 제약이 없어 덧붙이는 INSERT는 중복 행이 됩니다.
  • collation utf8mb4_0900_ai_ci는 NO PAD라, 손으로 넣은 값의 꼬리 공백 때문에
    같은 문구로도 잡히지 않습니다.
  • 그래서 DELETE 후 id를 명시해 넣습니다. 어느 환경에서 몇 번 돌아도 결과가 같습니다.
    이 테이블을 참조하는 외래키는 없습니다.
  • dev에 있던 값의 꼬리 공백 4건과 줄바꿈 1건도 이 값으로 정리됩니다.

함께 올라가는 기존 develop 작업

#404 인증 MySQL 전환, 리뷰/북마크 집계 스케줄 분리, 인스턴스별 목록 스냅샷(번호 둘),
장소 목록 조회 개선 문서 등 develop에 머지돼 있던 작업 전체입니다. 총 75커밋.


🗯️ PR 포인트

prod에서 이번 배포에 새로 돌아갈 마이그레이션은 V39~V47 아홉 개입니다.
(prod 현재 적용분은 V38까지 확인)

마이그레이션 내용
V39, V43, V45, V46 목록 스냅샷 구조 (V46에서 번호 둘로 최종 정리)
V40 place_stats 표시 컬럼
V41 refresh token · 관리자 임시 상태
V42 북마크 카운트 이벤트 claim
V44 집계 스케줄
V47 추천 예시 문구 시드

V43·V45가 만든 것을 V46이 다시 걷어내는 구조라, prod는 중간 구조를 거쳐 최종 구조로
갑니다. 순서상 문제는 없지만 배포 중 스냅샷 관련 테이블이 바뀌니 트래픽 적은 시간대가
낫습니다.

  • V47은 dev MySQL의 임시 스키마에서 V18+V47을 실제로 돌려 검증했습니다.
    7건, 꼬리 공백 0건, 두 번 적용해도 결과 동일.
  • ./gradlew build -x test 성공.

uykm added 30 commits September 9, 2026 01:03
엔트리에서 이름·썸네일·대표 태그를 빼 회차 사진 바깥의 홀더 둘로 옮긴다. 사진은
순서만 박제하고 표시값은 조립 시점에 합류하므로, 이름 하나 고치는 데 전량 재빌드가
돌 이유가 사라진다. 커서의 계약도 "정렬 순서의 일관성"까지로 좁아진다.

메인 태그는 id만 담고 활성 판정을 조회 시점 태그 맵으로 미룬다 - 비활성을 빌드
쿼리에서 걸러내면 다음 MAIN 태그가 뽑혀 엔티티 경로와 갈리기 때문이다.

쓰기를 줄 세우는 락과, 장소 하나의 표시값을 재빌드와 같은 규칙으로 다시 읽는
readView를 로더에 함께 둔다.
장소 표시값·태그 표시값을 그 항목만 갈아 끼우는 훅을 더한다. 트랜잭션당 동기화는
하나로 모으고, 전량 재생성이 함께 걸리면 그것 하나만 돈다 - 전량이 표시값 맵도
다시 짓기 때문이다.

패치와 재빌드는 같은 락 안에서 돈다. 그러지 않으면 재빌드가 읽어 둔 옛 값이 그
사이 들어온 어드민 수정을 덮고, 그 유실이 다음 회차까지 남는다. 재빌드의 읽기를
래치로 붙잡아 그 창을 실제로 벌리는 테스트로 지킨다.
패치 경로는 재빌드의 규칙(첫 MAIN 태그·display_order 첫 이미지·빈 파일 키)을 상관
서브쿼리로 옮긴 것이라, 둘이 어긋나도 각자는 그럴듯한 답을 낸다. 같은 DB 상태에서
"고치고 패치한 응답"과 "고치고 통째로 다시 지은 응답"이 같은지를 실제 DB 위에서
묻는다. 패치가 회차를 새로 찍지 않는 것도 함께 못 박는다.
버전은 지금까지 빌드가 끝난 밀리초 시각이었다. 빌더가 하나인 동안은 그것으로
단조였지만, 인스턴스가 둘 이상이 되면 빌더도 둘이 된다(어드민 요청을 받은 인스턴스
+ 타이머 리더). 그때 시계가 느린 쪽이 더 새 데이터로 지은 사진이 작은 번호를 달고
adopt의 단조 가드에 "낡은 버전"으로 걸린다. Redis INCR도 같은 일을 하지만 Redis가
비워지면 번호가 1부터 다시 시작해 같은 거절이 난다.

place_list_snapshot_versions에 빌드마다 한 줄을 넣고 AUTO_INCREMENT id를 버전으로
쓴다. 발급은 별도 빈의 REQUIRES_NEW 트랜잭션이다 - 재빌드가 readOnly라 그 안에서
INSERT를 실으면 드라이버가 "Connection is read-only"로 거부하고, 같은 클래스의
메서드로 두면 자기 호출이라 프록시를 안 타 그 전파가 조용히 무시된다.

발급 실패는 예외를 그대로 올려 재빌드를 실패시킨다(직전 사진 유지). 밀리초 폴백을
두면 번호 공간이 둘로 섞여 한 번의 폴백이 그 뒤 실제 발급 번호를 전부 낡은 것으로
만든다.

커서 포맷(v6)은 그대로다 - 버전은 이미 long이라 번호가 그 자리에 실린다.
uykm and others added 25 commits September 11, 2026 16:16
앱이 아직 평점을 보내지 않아 요청이 400으로 막힌다. 요청 DTO의 @NotNull과 서비스의
null 검증을 빼서 평점 없는 요청을 받는다. 1~5 범위 검증은 값이 왔을 때만 그대로 건다.

place_reviews.rating은 NOT NULL이라(V22) 값이 없으면 저장 자체가 깨진다. 그래서
서비스가 중립값 3으로 채운다. 3점은 인기순 점수의 (rating - 3) 환산에서 0을 더해
순위를 흔들지 않는다. 다만 avg_rating에는 3.0으로 섞이므로, V22가 DEFAULT를 즉시
제거하며 피하려 했던 "값을 안 보내면 조용히 3점" 상태를 한시로 되살린 것이다.

앱이 평점을 보내기 시작하면 @NotNull과 서비스의 null 처리를 함께 되돌린다.
요청 DTO와 서비스에 그 표식을 주석으로 남겼고, 평점 없는 요청을 검증하던 테스트
둘은 통과·중립값 저장을 확인하도록 바꿨다.
[refactor] 스냅샷을 current 한 벌로 좁히고 어드민 수정은 손댄 장소만 갈아 끼운다 — 10배 무부하 재빌드 실측과 유지 판단
SnapshotLoaderPartialUpdateTest의 워커 스레드에 daemon을 세운다. join이 타임아웃으로
빠져나오면 살아 있는 스레드가 테스트 JVM을 붙잡는다.

AdminPlaceUpdateSnapshotIT의 place_stats 삭제가 테이블 전체를 비웠다. 같은 컨테이너를
쓰는 다른 IT가 커밋해 둔 칸까지 지우므로 이 클래스의 픽스처 장소로 범위를 좁힌다.
[Feat] 목록 스냅샷에서 표시값을 분리하고 스냅샷을 current 한 벌·부분 갱신으로 좁힌다
회전은 "부모를 회전됨으로 표시"와 "자식을 만든다"가 한 덩어리여야 하는데 Redis 쓰기는
DB 트랜잭션 밖이라 자식 생성이 실패해도 부모는 이미 무효가 됐다. 인증 저장소 셋(refresh,
어드민 state/nonce, 어드민 authCode)을 V41의 MySQL 테이블 셋으로 옮겨 그 둘이 한 번에
커밋되거나 한 번에 없던 일이 되게 한다. 추천 캐시는 Redis에 그대로 남는다.

- 로그인 1회 = 계열 1개. 회전마다 같은 계열에 자식이 붙어 기기별 로그인이 서로를 끊지 않는다.
- 원문 JWT를 저장하지 않는다. 유예 3초 동안 같은 문자열은 고정 클레임·정수 초 시각으로 재구성한다.
- 상태는 컬럼이 아니라 시각의 우선순위로 읽는다(REVOKED > EXPIRED > ACTIVE > GRACE).
- 발급·회전·계열 로그아웃·전체 폐기·탈퇴에 사용자 단위 DB 공통 잠금. 잠금 순서는 사용자 → refresh 행.
- 어드민 일회 소비는 GETDEL 대신 조건부 UPDATE가 승자를 하나로 정한다.
- Access 30분 / Refresh 14일. 새 필수 클레임이 없는 기존 토큰은 거절되므로 전환은 전원 재로그인을 동반한다.

상세: docs/design/2026-09-12-auth-mysql-implementation.md
2026-08-17부터 매시 :30 회차 하나가 리뷰 축 전량 재계산과 북마크 델타 소비를 순차로 돌았다.
한 메서드를 둘로 갈라 회차마다 자기 기준 시각·자기 ShedLock 이름·자기 재시도 설정을 갖게 한다.

성능 개선이 아니다. 두 축은 갈리기 전에도 각자 트랜잭션이었고 SET 목록이 겹치지 않아 서로를
기다리지 않았으므로 DB가 하는 일의 총량은 같다. 실제로 달라지는 것은 둘이다 — 두 축의 주기를
따로 잡을 자유(지금은 쓰지 않고 둘 다 매시 1회를 유지한다), 그리고 한 축의 재시도 대기가
다른 축의 시작을 매번 밀지 않는 것.

:15를 고른 이유는 같은 자원을 잡는 회차와 시각을 가르기 위해서다. 01:00 점수 회차(place_stats
전 행 X 락), 리뷰 축 :30, 01:45 안전망(같은 아웃박스 전표를 FOR UPDATE)에서 각각 떨어진다.

점수(01:00)·안전망(01:45)·스냅샷(10분 fixedDelay)·기동 백필은 건드리지 않았다.
batch-max-attempts·batch-retry-delay를 조정해 온 환경은 새 키 넷에 같은 값을 함께 적어야 한다.

상세: docs/design/2026-09-12-stats-schedule-split.md
전 테스트 522건 통과(실패·오류·스킵 0). H2로 동시성을 대체하지 않고, 환경변수 부재로
조용히 빠지는 테스트도 두지 않는다 — @disabled·assumeTrue가 테스트 트리에 0건이고,
MySQL 컨테이너가 뜨지 않으면 테스트는 통과가 아니라 실패한다.

- 회전 경쟁: CyclicBarrier로 같은 부모에 동시 진입시켜 승자 하나·자식 하나를 확인한다.
- 커밋 경계: DriverManager로 연 독립 커넥션이 "폐기됐다"와 "폐기가 커밋됐다"를 가른다.
- 유예 3초·만료 경계는 MutableClock으로 재현한다.
- 스케줄 분리: 두 회차가 각자 락 이름·재시도 설정·기준 시각을 쓰는지, 델타 소비가
  한 트랜잭션으로 롤백되는지 확인한다.

@SpringBootTest가 실제 앱을 띄우므로 @scheduled가 등록된다. 스위트가 :15나 04:40을
지나면 남의 픽스처를 건드리므로 bookmark-delta-cron·auth.cleanup-cron을 "-"로 끄는 키를
IT 11곳에 넣었다.

상세: docs/design/2026-09-12-auth-mysql-verification.md
문서 여섯 장이 각각 다른 질문에 답한다.

- 합의문: Q1~Q9의 정책 결정과 검증 기준 1~14. 범위에서 뺀 것(쿠키·익명 세션·블랙리스트)도 적는다.
- 구현 기록 둘(인증·통계): 무엇이 바뀌었나, 클라이언트가 보는 변화, 전환 순서, 시크릿에 넣을 키.
- 코드 검토: 읽어서 짚은 셋과 그 처리. 폐기 출처를 묻지 않는 것이 정책으로 확정된 경위.
- 검증 감정: 단언을 뒤집어 보고 "있는 테스트"와 "증명하는 테스트"를 가른 기록.
- 검증 기록: 돌린 결과. 주장마다 그것을 낸 테스트 메서드 이름이 붙어 있다.

아웃박스 델타 설계문(2026-08-17)에는 회차가 :15로 갈린 사실을 덧댔다.
- place_list_snapshot_metadata 한 행(revision·cursor_version, V46)만 공유하고 payload 발행·작업 큐·소비자를 제거
- 목록을 바꾸는 쓰기(어드민·집계·북마크 델타)가 같은 트랜잭션의 마지막 문장으로 번호를 올린다
- 각 인스턴스가 폴로 번호를 보고 REPEATABLE READ 한 시점으로 원본을 읽어 스냅샷을 짓는다
- 표시값은 커밋 직후 관측 revision과 함께 얹고, 설치 전 후보 맵에 합쳐 역행을 막는다
- 요청은 검증 뒤 공유 cursor_version을 읽어 응답·대기·만료를 가른다
- 어드민 restartPlaceList 옵션(기본 유지)
- 회차는 cron 발화 + ShedLock + 프로세서 자기 트랜잭션뿐. 놓친 회차는 다음 발화가 메운다
- 델타 0 0/15, 리뷰 0 5/15, 인기 점수 매시 :10, 안전망 매일 01:25 (Asia/Seoul)
- 안전망은 북마크 수만 원본에서 다시 센다
- PlaceStatsJobKind는 회차별 설정 표로만 남긴다. V44 테이블은 런타임 참조 없이 보존
- V46 시드 정책, 같은 read view의 번호·데이터 일치, 폴·요청·기동의 단일 리빌드
- 설치와 패치의 순서 역전, 패치끼리의 순서, 실패 뒤 대기표 유지와 예산 만료
- 집계 회차 커밋·롤백·다음 호출 재소비, 복구 경로 부재, cron 기본값 짝
- 옛 발행물·작업 큐 테스트 제거
기본 실행기(스레드 1)에서는 집계 회차 하나가 길어지면 다른 회차와 스냅샷 폴의
발화가 그만큼 밀렸다. SchedulingConfig가 배치 회차용 taskScheduler(4,
batch-sched-)와 snapshotPollScheduler(1, snapshot-poll-)를 두고, 폴만 후자에
올린다. 같은 회차의 자기 겹침은 스프링이, 인스턴스 간 겹침은 ShedLock이 막는
규칙은 그대로이고, 5분 엇갈림의 남은 역할은 DB 경합 회피다.

두 빈의 선언 타입은 TaskScheduler로 고정한다. 구현체로 적으면 부트의
applicationTaskExecutor가 Executor 조건에 걸려 사라지고 그것을 이름으로
주입받는 자리 때문에 기동이 깨진다. SchedulingConfigTest가 빈 정의 타입과
"배치 스레드 넷을 다 잡아도 폴은 돈다"를 문다.

"기본 실행기는 단일 스레드"를 사실로 적은 주석을 정리하면서, 예외를 잡는
근거가 스케줄러 스레드 보호가 아니라 재시도와 로그 등급이라는 것도 바로잡는다.
남는 규칙은 넷이다. 폴은 1분마다 번호 한 행을 읽어 revision이 크면 짓는다.
리빌드는 한 번에 하나만 돌고 겹치는 호출은 그것에 붙는다. 실패는 로그만
남기고 다음 폴이나 다음 요청이 다시 띄운다. 요청은 번호를 읽어 뒤처졌으면
대기표를 걸고 예산 안에서 기다린다.

실패 지수 백오프, 최소 리빌드 간격, 밀린 목표 회차 기억, 설치 뒤 즉시 재시도,
연속 실패 경보 임계를 지운다. 전부 1초 폴에서 파생된 장치였고 폴이 1분이면
근거가 남지 않는다. 백오프는 오히려 요청 예산을 죽이고 있었다.

대기표는 다음 설치에 전부 깨어나고, 깨어난 요청이 번호를 다시 읽어 판정한다.
붙었던 리빌드가 요청보다 앞선 시점을 읽은 경우는 그 재확인(상한 2회)이 푼다.
프로퍼티는 poll-interval-ms·request-wait-timeout-ms·bootstrap-timeout-ms 셋만
남는다. 어드민 표시값 수정은 다른 인스턴스에 최대 1분 뒤 반영된다.
- cursor_version이 오르는 UPDATE에서 revision을 0으로 리셋. 비교는 SnapshotMetadata.isNewerThan
  하나로 모으고 (cursor_version, revision) 사전식으로 잰다. revision 단독 대소 비교는 남기지 않는다
- 코디네이터의 단일 비행을 비행 목록으로. 새로 온 쪽은 읽은 번호가 비어 있거나 목표 이상인 비행에
  붙고, 없으면 하나 더 띄운다. 로더가 첫 SELECT 직후 읽은 번호를 알린다. 실행기는 고정 2
- 대기표는 설치된 cursor_version이 목표 이상일 때만 깨어나고, 깨어난 요청은 DB를 다시 읽지 않는다.
  재판정 상한(MAX_CATCH_UPS)과 재귀를 뺀다
- 기존 14파일을 새 시그니처로. 전부 깨움·재판정 2회·무조건 합류를 단언하던 케이스는 새 규칙으로
- SnapshotMetadataTest(사전식 비교), SnapshotMetadataRepositoryIT(ADVANCE 뒤 revision 0) 신설
- 코디네이터 규칙 (a)~(g)를 latch·수동 실행기로 결정적으로. 실제 ThreadPoolExecutor(2)로 대기열 1 확인
- 기동 백필이 남긴 place_stats 행을 새 IT가 지우게. clean build 69클래스 606건 실패 0
- 구조 문서: 두 번호 절에 리셋 규칙과 (cursor_version, revision) 사전식 비교, 비교기 단일화의 이유.
  리빌드 절에 "겹치는 리빌드는 붙는다"(비행 목록·read view 근거·스탬피드 방지·고정 2·대기열 1).
  요청 절에 목표 이상 설치에만 깨어남·재조회 없음·3초 초과 503
- 트레이드오프 문서: 동시 리빌드 힙 산수(x10 무부하 측정 250MB × 2·3)와 고정 2 결정
- V46 머리 주석에 리셋 규칙과 revision 단독 비교의 함정
- MyReviewPreviewItem에 placeId 추가(reviewId 다음). /reviews/me/preview와 프로필 응답의
  myReviewPreview 둘에 함께 실린다. 쿼리 변화 없음
- from 매핑 단위 테스트 추가(장소 id·첫 이미지만 URL 변환)
인증 MySQL 전환과 통계 분리·스냅샷 발행 및 복원
08-29 두 편·09-09·09-10 원고를 요구사항을 앞에 세운 탑다운 서술로 취합했다.
- read-path-story: 기능·비기능 요구사항과 조회 계약 → 출발점(UNION ALL + 마스크) →
  비용 귀속 → 문장 캐시 → DB 정렬의 구조적 한계 → 인메모리 조회 모델 →
  캐시 적재·갱신 요구사항 G1~G10과 그것을 만족하는 구조 → 측정 → 최종 구조
- scale-readiness: 서버 수 / 데이터 / 트래픽 세 축마다 보장·확인한 값·대안 순서·
  미측정 항목. 외부 조회 인프라가 수렴 시차 축의 답이기도 하다는 점, 지속 리빌드
  실패 시 자동 DB 폴백을 두지 않은 이유, DB 경로가 등가 테스트용으로만 남아 있다는
  사실을 명시

수치는 전부 docs/perf 정본에서 옮겼고, 서로 다른 해법을 적용한 구성들을 한 시스템의
사용률 곡선처럼 잇지 않도록 표현을 고쳤다.
V18이 테이블만 만들고 값은 dev에 손으로 넣어둔 상태라, prod에는 한 건도 없어
GET /api/recommend/example-phrases가 빈 배열을 내려주고 있었다.

전량 교체(DELETE 후 id 명시 INSERT)로 두었다. content에 유니크 제약이 없어
덧붙이는 INSERT는 중복 행이 되고, collation이 NO PAD라 손으로 넣은 값의
꼬리 공백 때문에 같은 문구로도 잡히지 않는다. 그 꼬리 공백 4건과 줄바꿈
1건도 이 값으로 정리된다.
@uykm uykm self-assigned this Sep 18, 2026
@coderabbitai

coderabbitai Bot commented Sep 18, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Advanced

Run ID: 2550f4ca-2a6f-478e-a8a4-000aa680930a

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@uykm
uykm merged commit 9fb2e10 into release Sep 18, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant